平均感知机分词

感知机其实就是一个二分类算法,通过y = sign(wx+b)公式进行计算,来判断类别。

平均感知机是在每次训练时,对于正确地样本,则权重值加1;对于正确的样本,则权重值加1;对于错误的样本,则权重值减1。

下面提到的是基于基于结构化平均感知机的分词器Java实现这篇文章中的信息。它的代码实现集成在了HanLP中,你可以点击查看。

首先,需要从句子中抽取特征,Hanlp中定义的特征模板(或规则)是,一共7个特征:

Ci-1 #前一个词
Ci #当前词
Ci+1 #后一个词
Ci-2/Ci-1 #前两个词与前一个词
Ci-1/Ci #前一个词与当前词
Ci/Ci+1  #当前词与后一个词
Ci+1/Ci+2  #后一个词与后两个词

抽取特征部分源码,更多细节,你可以查看完整源码:

    /**
    * sentence:当前的句子
    * featureMap:一个TreeMap,用于保存所有特征
    * position:句子的index,即此时处理的哪一个字符
    */
    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(curChar).append('2');
        addFeature(sbFeature, featureVec, featureMap);//第二个特征,当前词

        sbFeature.delete(0, sbFeature.length());
        sbFeature.append(nextChar).append('3');
        addFeature(sbFeature, featureVec, featureMap);//第三个特征,后一个词


        //char bigram feature
        sbFeature.delete(0, sbFeature.length());
        sbFeature.append(pre2Char).append("/").append(preChar).append('4');  
        addFeature(sbFeature, featureVec, featureMap);//第四个特征,前两个词与前一个词

        sbFeature.delete(0, sbFeature.length());
        sbFeature.append(preChar).append("/").append(curChar).append('5');
        addFeature(sbFeature, featureVec, featureMap);//第五个特征,前一个词与当前词

        sbFeature.delete(0, sbFeature.length());
        sbFeature.append(curChar).append("/").append(nextChar).append('6');
        addFeature(sbFeature, featureVec, featureMap);//第六个特征,当前词与后一个词

        sbFeature.delete(0, sbFeature.length());
        sbFeature.append(nextChar).append("/").append(next2Char).append('7');
        addFeature(sbFeature, featureVec, featureMap);//第七个特征,后一个词与后两个词

        return toFeatureArray(featureVec);//所有特征转为一个数组
    }

比如,一个句子是:商品与服务,假设当前处理的词是,那么的特征就是:

品
与
服
商品
品与
与服
服务

画个图表示下(特征数值化的表示方法,可以在源码中找到。其实就是每次添加特征时,当前map的大小作为此时特征id)

image

一共7个特征,在实际处理时,需要把上面的特征转化为数值,便于计算机处理。 按照定义的特征模板,对所有的语料中的句子,句子中的字符进行处理,以得到全部的特征。

抽取的所有特征都对应一个特征参数。现在,我们是在处理分词任务,用B,M,E,S表示,那么我们总共要训练的特征参数就是featureMapSize * 4,即每一个特征对B,M,E,S的影响。

image

训练的部分代码:

// 加载训练语料
        TagSet tagSet = createTagSet();
        MutableFeatureMap mutableFeatureMap = new MutableFeatureMap(tagSet);
        ConsoleLogger logger = new ConsoleLogger();
        logger.start("开始加载训练集...\n");
        Instance[] instances = loadTrainInstances(trainingFile, mutableFeatureMap);//所有句子实例
        tagSet.lock();
        logger.finish("\n加载完毕,实例一共%d句,特征总数%d\n", instances.length, mutableFeatureMap.size() * tagSet.size());

        // 开始训练
        ImmutableFeatureMap immutableFeatureMap = new ImmutableFeatureMap(mutableFeatureMap.featureIdMap, tagSet);
        mutableFeatureMap = null;
        double[] accuracy = null;

        AveragedPerceptron model;
        model = new AveragedPerceptron(immutableFeatureMap);
        final double[] total = new double[model.parameter.length];
        final int[] timestamp = new int[model.parameter.length];
        int current = 0;//每次迭代,每个句子就加一,记录时间戳
        for (int iter = 1; iter <= maxIteration; iter++)
        {
                Utility.shuffleArray(instances);//数据随机化
                for (Instance instance : instances)
                {
                    ++current;
                    int[] guessLabel = new int[instance.length()];
                    model.viterbiDecode(instance, guessLabel);//用不断更新的模型去做预测
                    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++)//7个自定义特征
                        {
                            goldFeature[j] = featureVector[j] * tagSet.size() + instance.tagArray[i];//实际特征+实际标签作为特征,方便后面的权重特征更新
                            predFeature[j] = featureVector[j] * tagSet.size() + guessLabel[i];//实际特征+预测标签作为特征,方便后面的权重特征更新
                        }
                        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];//预测的转移特征
                        model.update(goldFeature, predFeature, total, timestamp, current);//更新权重值
                    }
                }
            
        }


/*******权重值更新update*******/
/**
     * 根据答案和预测更新参数
     *
     * @param goldIndex    预测正确的特征函数
     * @param predictIndex 命中的特征函数
     */
    public void update(int[] goldIndex, int[] predictIndex, double[] total, int[] timestamp, int current)
    {
        for (int i = 0; i < goldIndex.length; ++i)
        {
            if (goldIndex[i] == predictIndex[i])
                continue;
            else
            {
                update(goldIndex[i], 1, total, timestamp, current);//正确预测,权重加1
                if (predictIndex[i] >= 0 && predictIndex[i] < parameter.length)
                    update(predictIndex[i], -1, total, timestamp, current);//错误预测,权重减1
                else
                {
                    throw new IllegalArgumentException("更新参数时传入了非法的下标");
                }
            }
        }
    }
/*******实际权重值更新update*******/
  /**
     * 根据答案和预测更新参数
     *
     * @param index     特征向量的下标
     * @param value     更新量
     * @param total     权值向量总和
     * @param timestamp 每个权值上次更新的时间戳
     * @param current   当前时间戳
     */
    private void update(int index, float value, double[] total, int[] timestamp, int current)
    {
        int passed = current - timestamp[index];//距离index上次更新已经过去多少个时刻了
        total[index] += passed * parameter[index];
        parameter[index] += value;//特征权重更新
        timestamp[index] = current;
    }
    
 /*******模型预测,使用维特比算法预测一个字符是{B,M,E,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)
            {
                allFeature[transitionFeatureIndex] = bos;
                for (int j = 0; j < allLabel.length; j++)
                {
                    preMatrix[0][j] = j;

                    double score = score(allFeature, j);

                    scoreMatrix[0][j] = score;
                }
            }
            else
            {
                //每一个状态转移到哪一个更好
                for (int curLabel = 0; curLabel < allLabel.length; curLabel++)
                {

                    double maxScore = Integer.MIN_VALUE;

                    //上一次的状态0,1,2,3  哪一个转移curLabel状态更好
                    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;//上一次preLabel状态转移到curLabe状态更好
                            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;
    }

上面的部分源码是主要的训练过程,实际分词的时候,用训练好的特征权重值作为参数,维特比算法找出每个字符最有可能的{B,M,E,S}中的哪一个值。

为了便于理解上述流程,我自己画了一个图:

image

上面红色位置的表示应该是{B,M,E,S}中的哪一个值,计算的方法就是源码中的score()函数:

计算特征中与B相关的所有特征权重值:
score_B = para[32_B] + para[33_B]+...+para[3_B]
按照上述一样的方法计算score_M,score_E,score_S
然后取最大值作为红色位置中的值
参考文献:

核心二元词典的加载

二元词典保存的是两个词之间的频次,举个栗子:

锻炼@健身 12
锻炼@其实 2
锻炼@减肥 6
有@意外 102
有@意见 128
有@意识形态 6
有@意趣 6
做@了 3454
做@争辩 6
做@事先 2

上面是一个小小的二元词典,用文本文件保存的,保存的形式是:第一个词@第二次 频率, 比如,锻炼@健身 12表示的是锻炼健身这对词语一起出现在语料中的的频率是12次。

那么我们要怎么把它加载到内存中呢?

下面的示例过程是根据HanLP中的实现来讲解的。你可以参考HanLP的核心二元词典源码

它主要是使用两个数组来实现的:

  /**
     * 描述了词在pair中的范围,具体说来<br>
     * 给定一个词idA,从pair[start[idA]]开始的start[idA + 1] - start[idA]描述了一些接续的频次
     */
    static int start[];
    /**
     * pair[偶数n]表示key,pair[n+1]表示frequency
     */
    static int pair[];

部分示例代码:

start = new int[maxWordId + 1];
            pair = new int[total];  // total是接续的个数*2
            int offset = 0;

            for (int i = 0; i < maxWordId; ++i)
            {
                TreeMap<Integer, Integer> bMap = map.get(i);//获取第一个词
                if (bMap != null)
                {
                    for (Map.Entry<Integer, Integer> entry : bMap.entrySet())
                    {
                        int index = offset << 1;
                        pair[index] = entry.getKey();//保存第二个词的id
                        pair[index + 1] = entry.getValue();//保存两个词的频率
                        ++offset;
                    }
                }
                start[i + 1] = offset;
            }

仍然以上面的小的二元词典为例,最终的数组形式是: image

要查找一个二元词频的时候,可以直接使用二分查找:

public class Mytest {
    public static void main(String[] args){
        int[] a = {6896, 3454, 6975, 6, 7009, 2,
                   59539, 102, 59575, 128, 59582, 6, 59588, 6,
                   13874, 12, 16764, 2, 18875, 6};

       int index = binarySearch(a,0,7,59575); // 查找“有意见”的词频
        
       System.out.print(pair[index+1]);

    }


    /**
     * 二分搜索,由于二元接续前一个词固定时,后一个词比较少,所以二分也能取得很高的性能
     * @param a 目标数组
     * @param fromIndex 开始下标
     * @param length 长度
     * @param key 词的id
     * @return 共现频次
     */
    private static int binarySearch(int[] a, int fromIndex, int length, int key)
    {
        int low = fromIndex;
        int high = fromIndex + length - 1;

        while (low <= high)
        {
            int mid = (low + high) >>> 1;
            int midVal = a[mid << 1];

            if (midVal < key)
                low = mid + 1;
            else if (midVal > key)
                high = mid - 1;
            else
                return mid; // key found
        }
        return -(low + 1);  // key not found.
    }
}


双数组Trie树(DoubleArrayTrie)

双数组Trie (Double-Array Trie)结构由日本人JUN-ICHI AOE于1989年提出的,是Trie结构的压缩形式,仅用两个线性数组来表示Trie树,该结构有效结合了数字搜索树(Digital Search Tree)检索时间高效的特点和链式表示的Trie空间结构紧凑的特点。双数组Trie的本质是一个确定有限状态自动机(DFA),每个节点代表自动机的一个状态,根据变量不同,进行状态转移,当到达结束状态或无法转移时,完成一次查询操作。在双数组所有键中包含的字符之间的联系都是通过简单的数学加法运算表示,不仅提高了检索速度,而且省去了链式结构中使用的大量指针,节省了存储空间。 ——《基于双数组Trie树算法的字典改进和实现》

双数组Trie主要用于分词中加载词典。

Double-Array Tire的本质是一颗树形结构,但是具体实现却是通过两个数组来实现的。Tire树中几个重要的概念

  • STATE:状态,实际为在数组中的下标
  • CODE : 状态转移值,实际为转移字符的 ASCII码
  • BASE :表示后继节点的基地址的数组,叶子节点没有后继,标识为字符序列的结尾标志
  • CHECK:标识前驱节点的地址

静态构造Tire树,一般双数组的实现都会对算法做一个改进,下面的算法讲解主要参考开源实现dart-clone,dart-clone 也对双数组算法做了一个改进,即

base[s] + c = t
check[t] = base[s]

Double-Array Tire的构造过程

1 建立根节点root,令base[root] = 1
2 找出root的子节点 集{root.childreni }(i = 1...n) , 使得 check[root.childreni ] = base[root] = 1
3 对 each element in  root.children : 
    1)找到{elemenet.childreni }(i = 1...n) ,然后选择一个值begin,使得每一个check[ begini + element.childreni .code] = 0
      2)设置base[element.childreni] = begini
    3)对element.childreni 递归执行步骤3,若遍历到某个element,其没有children,即叶节点,则设置base[element]为负值(一般为在字典中的index取负)

示例:

对于由Dic = { AC,ACE,ACFF,AD,CD,CF,ZQ } (在ASCII表中code有:A-65,C-67,D-68,E-69,F-70,Q-81,Z-90;在实际使用时code+1)构成的Tire树,其双数组最终生成的结果

image

其中i是下标,即为state,这里根据下标i可以看出BASE与CHECK数组的长度均达到了144,图中只显示了BASE与CHECK中不为0的信息。

展开成树的形式就是这样的:

image

一步步构建Trie:

1 root –> A , C , Z

遍历字典,找到root的所有children,在Dic中为{A , C, Z}root经过A , C, Z 的作用分别到达三个状态 t1 ,t2 , t3

base[root] + A.code = t1
check[t1] = base[root]

base[root] + C.code = t2
check[t2] = base[root]

base[root] + Z.code = t3
check[t3] = base[root]

那么我们如何寻找begin的值呢?使得check[ begini + element.childreni .code] = 0成立。

下面是一个求解过程的伪代码,具体的代码实现可以参考 HanLP中的DoubleArrayTrie

begin = 0
pos = max(A.code + 1, nextCheckPos) - 1  # nextCheckPos(初始值为0)每次与根节点的第一个孩子节点比较,此时pos=66

while(true){
    pos++
    if(check[pos] != 0){#找到check[pos]=0的值
        continue
    }
    nextCheckPos = pos #此时nextCheckPos = pos = 67
    begin = pos - A.code  #此时begin = 1
    
    if(used.get(begin)){#检查begin是否被占用
        continue
    }
    
    break
}
# 通过上述过程就找到了合适的begin的值,此时 begin = 1, 根据步骤3.1,3.2,可得base[root] = 1

那么两个数组的转态转移值为:

base[root] + A.code = 1 + 66 = 67 = t1 
check[t1] = 1

base[root] + C.code = 1 + 68 = 69 = t2
check[t2] = 1

base[root] + Z.code = 1 + 91 = 92 = t3
check[t3] = 1

此时数双组的值为:

image

树形结构为:

image

2 A –>C , D

接着递归A节点,A的子节点有{C,D}, A经过C , D 的作用分别到达三个状态 t4 , t5

base[A] + C.code = t4
check[t4] = base[A]

base[A] + D.code = t5
check[t5] = base[A]

求解begin的过程:

begin = 0
 # nextCheckPos每次与根节点的第一个孩子节点比较,上次nextCheckPos=67
 # 所以此时 pos = max(68+1,67)-1 = 68
pos = max(C.code + 1, nextCheckPos) - 1 

while(true){
    pos++
    if(check[pos] != 0){ #对照着看上一次的表格,就知道check[pos]的值是否为0,即是否被占用
        continue
    }
    nextCheckPos = pos #此时nextCheckPos = pos = 70
    begin = pos - C.code  #此时begin = 70-68 = 2
    
    if(used.get(begin)){#检查begin是否被占用
        continue
    }
    break
}
# 通过上述过程就找到了合适的begin的值,此时 begin = 2,
# 根据步骤3.1,3.2,可得base[A] = base[t1] =  begin = 2

那么两个数组的转态转移值为:

base[A] + C.code = 2 + 68 = 70 = t4
check[t4] = 2

base[A] + D.code = 2 + 69 = 71 = t5
check[t5] = 2

此时数双组的值为:

image

树形结构为:

image

3 C –>0 , E, F

接着递归A节点,A的子节点有{0,E,F}, A经过0 ,E , F 的作用分别到达三个状态 t6 , t7, t8

base[C] + 0.code = t6
check[t6] = base[C]

base[C] + E.code = t7
check[t7] = base[C]

base[C] + F.code = t8
check[t8] = base[C]

求解begin的过程:

begin = 0
 # nextCheckPos每次与根节点的第一个孩子节点比较,上次nextCheckPos=70
 # 所以此时 pos = max(0+1,70)-1 = 69
pos = max(0.code + 1, nextCheckPos) - 1 

while(true){
    pos++
    if(check[pos] != 0){ #对照着看上一次的表格,就知道check[pos]的值是否为0,即是否被占用
        continue
    }
    nextCheckPos = pos #此时nextCheckPos = pos = 72
    begin = pos - 0.code  #此时begin = 72 - 0 =72
 
    if(used.get(begin)){#检查begin是否被占用
        continue
    }
    break
}
# 通过上述过程就找到了合适的begin的值,此时 begin = 72,
# 根据步骤3.1,3.2,可得base[C] = base[t4] =  begin = 72

那么两个数组的转态转移值为:

base[C] + 0.code = 72 + 0 = 72 = t6
check[t6] = 72

base[C] + E.code = 72 + 70 = 142 =t7
check[t7] = 72

base[C] + F.code = 72 + 71 = 143 = t8
check[t8] = 72

此时,节点中有叶子节点,那么它(‘0’)就遍历结束了(叶子节点直接处理:base[72] = -1(-1是字符串在字典中的索引值), check[72] = 72),接着处理它(‘0’)的兄弟节点E

此时数双组的值为:

image

树形结构为:

image

4 E –>0

接着递归E节点,E的子节点只有{0}, E经过0 的作用到达状态 t9

base[E] + 0.code = t9
check[t9] = base[E]

求解begin的过程:

begin = 0
 # nextCheckPos每次与根节点的第一个孩子节点比较,上次nextCheckPos=72
 # 所以此时 pos = max(0+1,72)-1 = 71
pos = max(0.code + 1, nextCheckPos) - 1 

while(true){
    pos++
    if(check[pos] != 0){ #对照着看上一次的表格,就知道check[pos]的值是否为0,即是否被占用
        continue
    }
    nextCheckPos = pos #此时nextCheckPos = pos = 73
    begin = pos - 0.code  #此时begin = 73 - 0 =73
    
    if(used.get(begin)){#检查begin是否被占用
        continue
    }
    break
}
# 通过上述过程就找到了合适的begin的值,此时 begin = 73,
# 根据步骤3.1,3.2,可得base[E] = base[t7] =  begin = 73

那么两个数组的转态转移值为:

base[E] + 0.code = 73 + 0 = 73 = t9
check[t9] = 73

此时,节点中有叶子节点,那么它(‘0’)就遍历结束了(叶子节点直接处理:base[73]=base[t9] = -2(-2是字符串在字典中的索引值), check[73] = 73),接着处理它(‘0’)的兄弟节点F

此时数双组的值为:

image

树形结构为:

image

5 F –>F

接着递归F节点,F的子节点只有{F}, F经过F 的作用到达状态 t10

(…… 省略剩下的过程了,方法是一样的)

按照上面一样的方法进行计算,我们就可以得到最终的构造,生成DoubleArray-Trie

此时数双组的值为:

image

树形结构为:

image

Note: nextCheckPos是DoubleArray-Trie的一个字段,不是递归时的局部变量。

Tire树的查询

有了如上的构建过程,查询就很简单了:

根据转移方程:

base[s] + c = t
check[t] = base[s] 

当有 check[t] == t 时,说明遇到了叶子节点,那么有base[t]=index,记录下其位置index,然后输出Dic[index]即为匹配出来的dic中的词。

参考链接: