自然语言处理入门笔记-> 感知机分类

474 阅读12分钟

此文为学习何晗老师《自然语言处理入门》笔记

分类问题

定义

  • 分类(classification):预测样本所属类别的一类问题。

分类问题的目标就是给定输入样本x,将其分配给K种类别C_k中的一种,其中k = 1, 2, \cdots, K

如果$K = 2,则成为二分类(binary classification),否则称为多分类(multiclass classification)。比如性别预测{男,女}属于二分类问题。

正如二进制可以表示任意进制一样,二分类也可以解决任意类别数的多分类问题。具体如下两种方案:

  • one-vs-one: 进行多轮二分类,每次区分两种类别C_iC_j。共进行 \left\{
\begin{matrix}
 K\\
 2
\end{matrix} 
\right\} 次二次分类,理想情况下有且仅有一种类别C_k每次都胜出,则预测结果为C_k
  • one-vs-rest:依然是多轮二分类,每次区分类别C_i与非C_i。一共进行K次二分类。理想情况下,模型给C_k的分数是所有K次分类中最高值,则预测结果为C_k

可见one-vs-rest成本更低,但正负样本数量不均匀,也会降低分类准确率。

应用

在NLP领域,绝大多数任务可以用分类来解决:

  • 文本分类
  • 关键词提取时,对文章中的每个单词判断是否属于关键词,转化为二分类
  • 指代消解问题中,对每个代词和每个实体判断是否存在指代关系
  • 在语言模型中,将词表中每个单词作为一种类别,给定上下文预测接下来要出现的单词

线性分类模型与感知机算法

线性模型(linear model)用一条线性直线或高维平面将数据一分为二,构成:

  • 一系列用来提取特征的特征函数\phi
  • 权重向量w

特征向量与样本空间

  • 特征向量:描述样本特征的向量
  • 特征提取:构造特征向量的过程
  • 特征函数:提取每种特征的函数
  • 指示函数(indicator function):在线性模型中,特征函数的输出一般是二进制的1或0,表示样本是否含有该特征,所以特征函数也是指示函数的一个实例
  • 样本空间:样本分布的空间

一旦将数据转换为特征向量,那么分类问题就转换为对样本空间的分割。

  • 定义特征向量x = [x_1, x_2, \cdots, x_D] \in \mathbb R^{1 \times D},第i个样本的特征为x^{(i)},相应的标签为y^{(i)}
  • 则训练集可以表示为M个二元组(x^{(i)}, y^{(i)}), i = 1, 2, \cdots, M
  • 测试集则是一系列标签未知的样本点x

那么怎样对样本进行分类呢?

决策边界与分离超平面

  • 决策区域(decision region):如上图,一条之间将平面分为两个部分
  • 决策边界(decision boundary):决策区域的边界
  • 线性分类模型:二维空间中,如果决策边界是直线,产生的决策边界模型
  • 分离超平面(separating hyperplane):任意维度空间中的线性决策边界

D维空间分离超平面的方程:

\sum_{i = 1}^{D} w_i x_i + b = 0

其中,w是权重,b是偏置。为了美观,可以将b也堪称一个权重:

w = [w_1, w_2, \cdots, w_D, b]
x = [x_1, x_2, \cdots, x_D, 1]

则超平面可以简化为权重向量与特征向量的内积

w \cdot x = 0

有了决策边界和方程后,线性模型使用方程左边的符号作为最终决策:

\hat y = sign(w \cdot x) = 
\begin{cases}
-1& w \cdot x \le 0\\
+1& w \cdot x \gt 0
\end{cases}
  • 线性可分:如果数据集中所有样本都可以被分离超平面分割

感知机算法

即给定训练集,如何训练线性模型

感知机算法(Perceptron Algorithm)是一种迭代算法:

  1. 读入训练样本x^{(i)}, y^{(i)},执行预测\hat y = sign(w \cdot x^{(i)})
  2. 如果\hat y \ne y^{(i)},则更新参数 w \leftarrow w + y^{(i)}x^{(i)}
  • 在线学习:在训练集的每个样本上执行步骤1、2称为一次在线学习
  • 迭代(epoch):把训练集完整学习一遍称为一次迭代
  • 超参数(hyperparameter):人工指定的参数

损失函数与随机梯度下降

  • 损失函数J(w):用来衡量模型在训练集上的错误程度
w \leftarrow w - \alpha\Delta w
  • 学习率:上式中的\alpha
  • 随机梯度下降(Stochastic Gradient Descent, SGD):如果算法每次迭代随机选取部分样本,计算损失函数的梯度让参数反向移动
  • 随机梯度上升(Stochastic Gradient Ascend):参数更新的方向是梯度的方向

平均感知机

  1. 当前时刻t = 0,为每个参数w_d初始化累计量sum_d = 0,上次更新时刻time_d = t = 0
  2. t \leftarrow t + 1,读入训练样本(x^i, y^i),执行预测\hat y = sign(w \cdot x^i)
  3. 如果\hat y \ne y^i,则对所有需更新(x_d^i \ne 0)的w_d执行:
    • 更新sum_d \leftarrow sum_d + (t - time_d) \times w_d
    • 更新time_d \leftarrow t
    • 更新w_d \leftarrow w_d + y^i x^i
  4. 训练指定迭代次数后计算平均值:w_d = \frac {sum_d} {T}

代码实现-> 基于感知机的人名性别识别

人名语料库

于光浦,男
孙爱仙,女
杨莲菊,女
赵东祥,男

特征提取

List<Integer> extractFeature(String text, FeatureMap featureMap) {
    List<Integer> featureList = new LinkedList<Integer>();
    String givenName = extractGivenName(text);
    // 特征模板1:g[0]
    addFeature("1" + givenName.substring(0, 1), featureMap, featureList);
    // 特征模板2:g[1]
    addFeature("2" + givenName.substring(1), featureMap, featureList);
    return featureList;
}

训练

  • 迭代训练
/**
 * 朴素感知机训练算法
 *  @param instanceList 训练实例
 * @param featureMap   特征函数
 * @param maxIteration 训练迭代次数
 */
LinearModel trainNaivePerceptron(Instance[] instanceList, FeatureMap featureMap, int maxIteration) {
    LinearModel model = new LinearModel(featureMap, new float[featureMap.size()]);
    for (int it = 0; it < maxIteration; ++it) {
        Utility.shuffleArray(instanceList);
        for (Instance instance : instanceList) {
            int y = model.decode(instance.x);
            if (y != instance.y) // 误差反馈
                model.update(instance.x, instance.y);
        }
    }
    return model;
}

  • 预测与更新参数
/**
 * 分离超平面解码预测
 *
 * @param x 特征向量
 * @return sign(wx)
 */
int decode(Collection<Integer> x) {
    float y = 0;
    for (Integer f : x) {
        // 更新每个特征的对应参数
        y += parameter[f];
    }
    return y < 0 ? -1 : 1;
}

/**
 * 参数更新
 *
 * @param x 特征向量
 * @param y 正确答案
 */
void update(Collection<Integer> x, int y) {
    assert y == 1 || y == -1 : "感知机的标签y必须是±1";
    for (Integer f : x) {
        // 更新每个参数
        parameter[f] += y;
    }
}
  • 根据训练好的模型预测
public String predict(String text) {
    // 根据当前字符串提取到特征,执行上面的预测方法
    int y = model.decode(extractFeature(text, model.featureMap));
    if (y == -1)
        y = 0;
    // 将预测转换为标签字符
    return model.tagSet().stringOf(y);
}

结构化预测问题

  • 结构化预测(structured prediction):预测对象结构的一类监督学习问题

预测结果的区别

  • 分类问题:一个决策边界
  • 回归问题:一个实际标量
  • 结构化预测:一个完整的结构

应用:

  • 序列化标注:一整个序列
  • 句法分析:一棵语法树
  • 机器翻译:一段完整的译文

结构化预测与学习流程: 给定一个模型\lambda及打分函数score_{\lambda}(x, y),利用打分函数给一些备选结构打分,选择分数最高的结构作为预测输出:

\hat y = \arg \max_{y \in Y} \, score_{\lambda}(x, y)

其中Y是备选结构的集合

线性模型的结构化感知机算法

线性结构感知机算法

打分函数特征x与结构y

  • 之前线性模型的打分函数
f(x) = w \cdot x
  • y也作为一种特种,特征函数\phi(x, y) \in \mathbb{R}^{D \times 1},与权重w点积后得到标量作为分数:
score(x,y) = w \cdot \phi(x, y)
  • 对应的最大分数:
\hat y = \arg \max_{y \in Y} \, (w \cdot \phi(x, y))

计算过程

  1. 读入样本(x^i, y^i),执行结构化预测 \hat y = \arg \max_{y \in Y} \, (w \cdot \phi(x, y))
  2. 与正确答案对比,若\hat y \ne y^i,更新参数:
    • 奖励正确答案:w \leftarrow w + \phi (x^i, y))
    • 惩罚错误结果:w \leftarrow w - \phi (x^i, \hat y))
    • 奖惩结合:w \leftarrow w + \alpha (\phi (x^i, y)- \phi (x^i, \hat y))

结构化感知机与序列标注

在线性模型中(不同于隐马尔科夫模型),对序列中的连续标签提取如下转移特征

\phi_k(y_{t-1}, y_t) = 
\begin{cases}
1, y_{t-1} = s_i 且 y_t = s_j \\
0, 其他
\end{cases} \quad i = 0, \cdots,N; \, j = 1, \cdots, N; \, k = 1, \cdots, N^2 + N

其中y_t为序列预测第t个标签,s_i为标注集第i种标签,N为标注集大小。s_0 = BOS为第一个元素之前的虚拟标签,k = i \times N + j为转移特征编号,加上s_0共有(N + 1) \times N种转移特征

类比隐马尔科夫模型,每个时刻的状态特征

\phi_l(x_t, y_t) = 
\begin{cases}
1\\
0
\end{cases} \quad l = 1, \cdots, N^2 +N + 1

则结构化感知机特征函数就是转移特征状态特征的合集:

\phi = [\phi_k;\phi_l]

简化为 \phi(y_{t-1}, y_t, x_t),则整个序列的分数:

score(x, y) = \sum_{t = 1}^T w \cdot \phi(y_{t-1}, y_t, x_t)

得出最高分的方法:维比特解码算法

  1. 初始化t = 1时初始优化路径备选由N个状态组成,前驱为空:
\delta_{1, i} = w \cdot \phi(s_0, s_i, x_1), \quad i = 1, \cdots, N
\psi_{1, i} = 0, \quad i = 1, \cdots, N
  1. 递推:当t \ge 2时每条备选路径增长一个单位,找出局部最优解
\delta_{t, i} = \max_{1 \le j \le N} (\delta_{t-1, j} + w \cdot \phi(s_j, s_i, x_t)), \quad i = 1, \cdots, N
\psi_{t, i} = \arg \max_{1 \le j \le N} (\delta_{t-1, j} + w \cdot \phi(s_j, s_i, x_t)), \quad i = 1, \cdots, N
  1. 终止:找到最终时刻\delta_{t,i}数组中最大分数S^*,以及对应的结尾状态下标i_T^*
S^* = \max_{1 \le i \le N} \delta_{T,j}
i_T^* = \max_{1 \le i \le N} \delta_{T, i}
  1. 回溯:根据前驱数组\psi回溯前驱状态,取得最后解下标
i_t^* = \psi_{t+1, i_{i+1}^*} \quad t = T-1, T-2, \cdots, 1

代码实现 -> 基于结构化感知机的中文分词

特征提取

示例

布什将出任美国总统
布什/nr 将/d 出任/v 美国/ns 总统/n

特征会加上位置信息,下面有示例特征对应的编码:

转移特征 状态特征 示例_{t=1} 示例_{t=2}
y_{t-1} x_{t-1} 1 -> 5 布1 -> 12
x_t 布2 -> 6 什2 -> 13
x_{t+1} 什3 -> 7 将3 -> 14
x_{t-2} / x_{t-1} /4 -> 8 /布4 -> 15
x_{t-1} / x_t /布5 -> 9 布/什5 -> 16
x_t / x_{t+1} 布/什6 -> 10 什/将6 -> 17
x_{t+1} / x_{t+2} 什/将7 -> 11 将/出7 -> 18
protected int[] extractFeature(String sentence, FeatureMap featureMap, int position) {
    List<Integer> featureVec = new LinkedList<Integer>();

    char pre2Char = position >= 2 ? sentence.charAt(position - 2) : CHAR_BEGIN;
    char preChar = position >= 1 ? sentence.charAt(position - 1) : CHAR_BEGIN;
    char curChar = sentence.charAt(position);
    char nextChar = position < sentence.length() - 1 ? sentence.charAt(position + 1) : CHAR_END;
    char next2Char = position < sentence.length() - 2 ? sentence.charAt(position + 2) : CHAR_END;

    StringBuilder sbFeature = new StringBuilder();
    // 一元语法特征
    sbFeature.delete(0, sbFeature.length());
    sbFeature.append(preChar).append('1');
    addFeature(sbFeature, featureVec, featureMap);
    
    .............................

    // 二元语法特征
    sbFeature.delete(0, sbFeature.length());
    sbFeature.append(pre2Char).append("/").append(preChar).append('4');
    addFeature(sbFeature, featureVec, featureMap);
    
    .............................

    return toFeatureArray(featureVec);
}

  • score方法,参数表大小:特征数量 \times 标签大小,可以表示每个特征在每个标签下的权重
/**
 * 通过命中的特征函数计算得分
 *
 * @param featureVector 压缩形式的特征id构成的特征向量
 * @param currentTag 当前标签 {B, M, E, S} 之一
 * @return 从特性+前一标签 转移到 当前标签的分数
 */
public double score(int[] featureVector, int currentTag) {
    double score = 0;
    // 遍历extraxtFeature提取的特征(一元+二元语法特征)
    for (int index : featureVector) {
        // 获取对应的参数下标
        index = index * featureMap.tagSet.size() + currentTag;
        // 其实就是特征权重的累加
        score += parameter[index];
    }
    return score;
}

特性的参数值

特性\状态 B M E S
特性1 p_{1, 1} p_{1, 2} p_{1, 3} p_{1, 4}
特性2 p_{2, 1} p_{2, 2} p_{2, 3} p_{2, 4}
\vdots \vdots \vdots \vdots \vdots
特性N p_{N, 1} p_{N, 2} p_{N, 3} p_{N, 4}

示例,第一组训练,“布什将出任美国总统”

如单词“什“性组(特性字符串:索引),标注是B(2),初始预测标签是B(0)

单词\特性 x_{t-1} x_t x_{t+1} x_{t-2}/x_{t-1} x_{t-1}/x_t x_t/x_{t+1} x_{t+1}/x_{t+2} 预测标签y_t
1: 5 布2: 6 什3: 7 /4: 8 /布5: 9 布/什6: 10 什/将7: 11 BOS
布1: 12 什2: 13 将3: 14 /布4: 15 布/什5: 16 什/将6: 17 将/出7: 18 S
什1: 19 将2: 20 出3: 21 布/什4: 22 什/将5: 23 将/出6: 24 出/任7: 25 S
将1: 26 出2: 27 任3: 28 什/将4: 29 将/出30: 30 出/任6: 31 任/美7: 32 S
出1: 33 任2: 34 美3: 35 将/出4: 36 出/任5: 37 任/美6: 38 美/国7: 39 S
任1: 40 美2: 41 国3: 42 出/任4: 43 任/美5: 44 美/国6: 45 国/总7: 46 S
美1: 47 国2: 48 总3: 49 任/美4: 50 美/国5: 51 国/总6: 52 总/统7: 53 S
国1: 54 总2: 55 统3: 56 美/国4: 57 国/总5: 58 总/统6: 59 统/27: 60 S
总1: 61 统2: 62 3: 63 国/总4: 64 总/统5: 65 统/ 6: 66 /7: 67 S

训练

  • 状态转移概率计算
/**
 * 维特比解码
 *
 * @param instance   实例
 * @param guessLabel 输出标签
 * @return
 */
public double viterbiDecode(Instance instance, int[] guessLabel) {
    final int[] allLabel = featureMap.allLabels();
    final int bos = featureMap.bosTag();
    final int sentenceLength = instance.tagArray.length;
    final int labelSize = allLabel.length;
    
    // 前驱数组 字符长度(单词数) * 标签数 矩阵
    int[][] preMatrix = new int[sentenceLength][labelSize];
    // 分数数组
    double[][] scoreMatrix = new double[2][labelSize];
    // 遍历句子的每个单词的特性(一个单词对应七个特性)
    for (int i = 0; i < sentenceLength; i++) {
        int _i = i & 1;
        // 滚动数组,节省存储空间,保存当前跟前一两个状态即可
        int _i_1 = 1 - _i;
        int[] allFeature = instance.getFeatureAt(i);
        final int transitionFeatureIndex = allFeature.length - 1;
        // 第一步:初始状态概率矩阵计算
        if (0 == i) {
            // 第一个元素的虚拟标签BOS
            allFeature[transitionFeatureIndex] = bos;
            // 遍历标签 {B, M, E, S}
            for (int j = 0; j < allLabel.length; j++) {
                // 初始前驱矩阵,便于维比特回溯使用
                preMatrix[0][j] = j;
                // 从BOS标签转移到当前标签的分数
                double score = score(allFeature, j);
                // 预测标签矩阵
                scoreMatrix[0][j] = score;
            }
        } else {
            // 第二步 状态转移矩阵计算,找出新的局部最优路径
            // 遍历标签
            for (int curLabel = 0; curLabel < allLabel.length; curLabel++) {
                double maxScore = Integer.MIN_VALUE;
                // 遍历前一标签
                for (int preLabel = 0; preLabel < allLabel.length; preLabel++) {
                    allFeature[transitionFeatureIndex] = preLabel;
                    // 计算当前特性+前一标签 转移到 当前标签分数
                    double score = score(allFeature, curLabel);
                    // 当前分数 = 前一标签分数 + 前一标签转移到当前标签的分数
                    double curScore = scoreMatrix[_i_1][preLabel] + score;
                    // 找出局部最优解
                    if (maxScore < curScore) {
                        maxScore = curScore;
                        // 更新前驱矩阵
                        preMatrix[i][curLabel] = preLabel;
                        // 更新前一状态的最大分数
                        scoreMatrix[_i][curLabel] = maxScore;
                    }
                }
            }

        }
    }
    // 查找备选路径的最大分数以及其对应的最后状态的下标
    int maxIndex = 0;
    double maxScore = scoreMatrix[(sentenceLength - 1) & 1][0];
    for (int index = 1; index < allLabel.length; index++) {
        if (maxScore < scoreMatrix[(sentenceLength - 1) & 1][index]) {
            maxIndex = index;
            maxScore = scoreMatrix[(sentenceLength - 1) & 1][index];
        }
    }
    // 根据最优路径下标回溯,复原最优路径
    for (int i = sentenceLength - 1; i >= 0; --i) {
        // 回溯得到最优标签序列
        guessLabel[i] = allLabel[maxIndex];
        maxIndex = preMatrix[i][maxIndex];
    }
    return maxScore;
}

单词“什”(标签编码E: 2) 预测与答案 其中索引 = 特征编码 \times 标签大小 + 当前标签编码

预测\特性 x_{t-1} x_t x_{t+1} x_{t-2}/x_{t-1} x_{t-1}/x_t x_t/x_{t+1} x_{t+1}/x_{t+2} 预测标签y_t
维比特预测下标 48 52 56 60 64 68 72 0
标注答案下标 50 54 58 62 66 70 74 2

如果预测与标注不一致,则会惩罚预测错误的下标的参数,奖励标注答案标签对应的参数

  • 更新参数
/**
 * 在线学习
 *
 * @param instance 样本
 */
public void update(Instance instance) {
    int[] guessLabel = new int[instance.length()];
    viterbiDecode(instance, guessLabel);
    TagSet tagSet = featureMap.tagSet;
    // 遍历话术每个单词的特性
    for (int i = 0; i < instance.length(); i++) {
        int[] featureVector = instance.getFeatureAt(i);
        // 根据答案应当被激活的特征
        int[] goldFeature = new int[featureVector.length];
        // 实际预测时激活的特征
        int[] predFeature = new int[featureVector.length];
        // 遍历每个特性,更新标注和预测索引
        for (int j = 0; j < featureVector.length - 1; j++) {
            // 标注答案
            goldFeature[j] = featureVector[j] * tagSet.size() + instance.tagArray[i];
            // 预测
            predFeature[j] = featureVector[j] * tagSet.size() + guessLabel[i];
        }
        // 设置初始状态BOS
        goldFeature[featureVector.length - 1] = (i == 0 ? tagSet.bosId() : instance.tagArray[i - 1]) * tagSet.size() + instance.tagArray[i];
        predFeature[featureVector.length - 1] = (i == 0 ? tagSet.bosId() : guessLabel[i - 1]) * tagSet.size() + guessLabel[i];
        // 更新模型
        update(goldFeature, predFeature);
    }
}

/**
 * 根据答案和预测更新参数
 *
 * @param goldIndex    答案的特征函数(非压缩形式)
 * @param predictIndex 预测的特征函数(非压缩形式)
 */
public void update(int[] goldIndex, int[] predictIndex) {
    for (int i = 0; i < goldIndex.length; ++i) {
        if (goldIndex[i] == predictIndex[i]) {
            // 与标注答案一致,无需更新参数
            continue;
        } else {
            // 预测与答案不一致
            // 奖励正确的特征函数(将它的权值加一)
            parameter[goldIndex[i]]++;
            // 惩罚招致错误的特征函数(将它的权值减一)
            parameter[predictIndex[i]]--; 
        }
    }
}

训练分词

public void segment(String text, Instance instance, List<String> output) {
    int[] tagArray = instance.tagArray;
    // 通过维比特算法得到预测标签
    model.viterbiDecode(instance, tagArray);
    StringBuilder result = new StringBuilder();
    result.append(text.charAt(0));
    // 通过标签,反向拼接处分词
    for (int i = 1; i < tagArray.length; i++) {
        if (tagArray[i] == CWSTagSet.B || tagArray[i] == CWSTagSet.S) {
            // 当前字符为{B, S},得到上一个分词结果
            output.add(result.toString());
            result.setLength(0);
        }
        result.append(text.charAt(i));
    }
    if (result.length() != 0) {
        output.add(result.toString());
    }
}