转:从头开始编写基于隐含马尔可夫模型HMM的中文分词器

2023-05-18,,

http://blog.csdn.net/guixunlong/article/details/8925990

从头开始编写基于隐含马尔可夫模型HMM的中文分词器之一 - 资源篇

首先感谢52nlp的系列博文(http://www.52nlp.cn/),提供了自然语言处理的系列学习文章,让我学习到了如何实现一个基于隐含马尔可夫模型HMM的中文分词器。

在编写一个中文分词器前,第一步是需要找到一些基础的词典库等资源,用以训练模型参数,并进行后续的结果评测,这里直接转述52nlp介绍的“中文分词入门之资源”:

原文地址:http://www.52nlp.cn/%E4%B8%AD%E6%96%87%E5%88%86%E8%AF%8D%E5%85%A5%E9%97%A8%E4%B9%8B%E8%B5%84%E6%BA%90

这里我从http://sighan.cs.uchicago.edu/bakeoff2005/下载icwb2-data.zip词库做模型训练与后续评测使用。

作为中文信息处理的“桥头堡”,中文分词在国内的关注度似乎远远超过了自然语言处理的其他研究领域。在中文分词中,资源的重要性又不言而喻,最大匹配法等需要一个好的词表,而基于字标注的中文分词方法又需要人工加工好的分词语料库。所以想研究中文分词,第一步需要解决的就是资源问题,这里曾经介绍过“LDC上免费的中文信息处理资源”,其中包括一个有频率统计的词表,共计44405条,就可以作为一个不错的中文分词词表使用。而一个好的人工分词语料库,需要很大的人力物力投入,所以无论研究还是商用往往需要一定的费用购买,好在SIGHAN Bakeoff为我们提供了一个非商业使用(non-commercial)的免费获取途径,以下将介绍SIGHAN Bakeoff及相关的中文分词入门资源。
  SIGHAN是国际计算语言学会(ACL)中文语言处理小组的简称,其英文全称为“Special Interest Group for Chinese Language Processing of the Association for Computational Linguistics”,又可以理解为“SIG汉“或“SIG漢“。而Bakeoff则是SIGHAN所主办的国际中文语言处理竞赛,第一届于2003年在日本札幌举行(Bakeoff 2003),第二届于2005年在韩国济州岛举行(Bakeoff 2005), 而2006年在悉尼举行的第三届(Bakeoff 2006)则在前两届的基础上加入了中文命名实体识别评测。目前SIGHAN Bakeoff已成功举办了6届,其中Bakeoff 2005的数据和结果在其主页上是完全免费和公开的,但是请注意使用的前提是非商业使用(non-commercial):

  The data and results for the 2nd International Chinese Word Segmentation Bakeoff are now available for non-commercial use.

  在Bakeoff 2005的主页上,我们可以找到如下一行:“The complete training, testing, and gold-standard data sets, as well as the scoring script, are available for research use”,在这一行下面提供了三个版本的icwb2-data。下载解压后,通过README就可以很清楚的了解到它包含哪些中文分词资源,特别需要说明的是这些中文分词语料库分别由台湾中央研究院(Academia Sinica)、香港城市大学(City University of Hong Kong)、北京大学(Peking University)及微软亚洲研究院(Microsoft Research)提供,其中前二者是繁体中文,后二者是简体中文,以下按照README简要介绍icwb2-data:

1) 介绍(Introduction):
  本目录包含了训练集、测试集及测试集的(黄金)标准切分,同时也包括了一个用于评分的脚本和一个可以作为基线测试的简单中文分词器。(This directory contains the training, test, and gold-standard data used in the 2nd International Chinese Word Segmentation Bakeoff. Also included is the script used to score the results submitted by the bakeoff participants and the simple segmenter used to generate the baseline and topline data.)

2) 文件列表(File List)
  在gold目录里包含了测试集标准切分及从训练集中抽取的词表(Contains the gold standard segmentation of the test data along with the training data word lists.)
  在scripts目录里包含了评分脚本和简单中文分词器(Contains the scoring script and simple segmenter.)
  在testing目录里包含了未切分的测试数据(Contains the unsegmented test data.)
  在training目录里包含了已经切分好的标准训练数据(Contains the segmented training data.)
  在doc目录里包括了bakeoff的一些指南(Contains the instructions used in the bakeoff.)

3) 编码(Encoding Issues)
  文件包括扩展名”.utf8”则其编码为UTF-8(Files with the extension “.utf8″ are encoded in UTF-8 Unicode.)
  文件包括扩展名”.txt”则其编码分别为(Files with the extension “.txt” are encoded as follows):
  前缀为as_,代表的是台湾中央研究院提供,编码为Big Five (CP950);
  前缀为hk_,代表的是香港城市大学提供,编码为Big Five/HKSCS;
  前缀为msr_,代表的是微软亚洲研究院提供,编码为 EUC-CN (CP936);
  前缀为pku_,代表的北京大学提供,编码为EUC-CN (CP936);
  EUC-CN即是GB2312(EUC-CN is often called “GB” or “GB2312″ encoding, though technically GB2312 is a character set, not a character encoding.)

4) 评分(Scoring)
  评分脚本“score”是用来比较两个分词文件的,需要三个参数(The script ‘score’ is used to generate compare two segmentations. The script takes three arguments):
  1. 训练集词表(The training set word list)
  2. “黄金”标准分词文件(The gold standard segmentation)
  3. 测试集的切分文件(The segmented test file)
 
  以下利用其自带的中文分词工具进行说明。在scripts目录里包含一个基于最大匹配法的中文分词器mwseg.pl,以北京大学提供的人民日报语料库为例,用法如下:
  ./mwseg.pl ../gold/pku_training_words.txt < ../testing/pku_test.txt > pku_test_seg.txt
  其中第一个参数需提供一个词表文件pku_training_word.txt,输入为pku_test.txt,输出为pku_test_seg.txt。
  利用score评分的命令如下:
  ./score ../gold/pku_training_words.txt ../gold/pku_test_gold.txt pku_test_seg.txt > score.txt
  其中前三个参数已介绍,而score.txt则包含了详细的评分结果,不仅有总的评分结果,还包括每一句的对比结果。这里只看最后的总评结果:


= SUMMARY:
=== TOTAL INSERTIONS: 9274
=== TOTAL DELETIONS: 1365
=== TOTAL SUBSTITUTIONS: 8377
=== TOTAL NCHANGE: 19016
=== TOTAL TRUE WORD COUNT: 104372
=== TOTAL TEST WORD COUNT: 112281
=== TOTAL TRUE WORDS RECALL: 0.907
=== TOTAL TEST WORDS PRECISION: 0.843
=== F MEASURE: 0.874
=== OOV Rate: 0.058
=== OOV Recall Rate: 0.069
=== IV Recall Rate: 0.958
### pku_test_seg.txt 9274 1365 8377 19016 104372 112281 0.907 0.843 0.874 0.058 0.069 0.958

  说明这个中文分词器在北大提供的语料库上的测试结果是:召回率为90.7%,准确率为84.3%,F值为87.4%等。
  SIGHAN Bakeoff公开资源的一个重要意义在于这里提供了一个完全公平的平台,任何人都可以拿自己研究的中文分词工具进行测评,并且可以和其公布的比赛结果对比,是驴子是马也就一目了然了。

下面是我自己实现的中文分词器在msra的测试集上的分词效果:

    === SUMMARY:
    === TOTAL INSERTIONS:   7303
    === TOTAL DELETIONS:    4988
    === TOTAL SUBSTITUTIONS:        15988
    === TOTAL NCHANGE:      28279
    === TOTAL TRUE WORD COUNT:      106873
    === TOTAL TEST WORD COUNT:      109188
    === TOTAL TRUE WORDS RECALL:    0.804
    === TOTAL TEST WORDS PRECISION: 0.787
    === F MEASURE:  0.795
    === OOV Rate:   0.026
    === OOV Recall Rate:    0.402
    === IV Recall Rate:     0.815
    ###     msr_test_result.txt     7303    4988    15988   28279   106873  109188  0.804   0.787   0.795   0.026   0.402   0.815

分词效果如下,个人感觉对于一个花三小时实现的程序来说,效果还不错 

      而/大量/“/看/图学/计算机/”/教材/的/出现/,/和/蔡志忠/的/漫画/诸子/相映/成趣/,/掀起/了/一股/成人/“/看/图识/字/”/的/出版潮/,/到/后/来竟/说/不清/是/谁/影响/了/谁/。/
      words.length=38   timecost:0
      在/日常/生活/中/,/像/“/软件/”/、/“/硬件/”/、/“/死/循环/”/一类/的/新词/充实/了/我们/的/语言库/。/
      words.length=74   timecost:0
      而/计算/机造/词/功能/的/运用/,/形成/了/一种/被/称为/“/语词/传染/”/的/新/现象/:/如果/有人/在/电脑/里/造出/了/一个/错误/的/词/,/所有/使用/该/电脑/的/人/都/会出/现同/一/错误/,/而且/防不/胜防/。/
      words.length=35   timecost:0
      至于/计算机/的/使用/对/学生/书法/、/笔顺/、/发音/的/影响/,/更/是/教育/学家/关心/的/话题/。/
      words.length=56   timecost:0
      再往/远些/看/,/随着/汉字识别/和/语音识别技术/的/发展/,/中文/计算/机用/户/将/跨越/语言/差异/的/鸿沟/,/在/录入/上/走向/中西/文求/同/的/道路/。/
      words.length=35   timecost:0
      而/计算机/翻译/系统/与/现代/汉语/分析/相辅/相成/,/正/推动/着/人工/智能/科学/的/前进/…/…/
      words.length=68   timecost:0
      每/跨过/一道/障碍/,/人们/都会/发现/一片/新/的/开阔/地/,/每/经过/一次/剧烈/的/碰撞/,/都/将/激起/一团/耀眼/的/火花/,/这/就/是/崭新/的/计算机/技术/和/古老/的/中华/文化/的/对话/。/

分词效果如下,个人感觉对于一个花三小时实现的程序来说,效果还不错 

http://blog.csdn.net/guixunlong/article/details/8926167

从头开始编写基于隐含马尔可夫模型HMM的中文分词器之二 - 模型训练与使用

我们使用/icwb2-data.rar/training/msr_training.utf8  用以训练模型,这个词库里包含已分词汇约2000000个。

使用经典的字符标注模型,首先需要确定标注集,在前面的介绍中,我们使用的是{B,E}的二元集合,研究表明基于四类标签的字符标注模型明显优于两类标签,原因是两类标签过于简单而损失了部分信息。四类标签的集合是 {B,E,M,S},其含义如下:
B:一个词的开始
E:一个词的结束
M:一个词的中间
S:单字成词
举例:你S现B在E应B该E去S幼B儿M园E了S

用四类标签为msr_training.utf8做好标记后,就可以开始用统计的方法构建一个HMM。我们打算构建一个2-gram(bigram)语言模型,也即一个1阶HMM,每个字符的标签分类只受前一个字符分类的影响。现在,我们需要求得HMM的状态转移矩阵 A 以及混合矩阵 B。其中:
                     Aij = P(Cj|Ci)  =  P(Ci,Cj) / P(Ci) = Count(Ci,Cj) / Count(Ci)
                     Bij = P(Oj|Ci)  =  P(Oj,Ci) / P(Ci) = Count(Oj,Ci) / Count(Ci)
公式中C = {B,E,M,S},O = {字符集合},Count代表频率。在计算Bij时,由于数据的稀疏性,很多字符未出现在训练集中,这导致概率为0的结果出现在B中,为了修补这个问题,我们采用加1的数据平滑技术,即:
                 Bij = P(Oj|Ci)  =  (Count(Oj,Ci) + 1)/ Count(Ci)
         这并不是一种最好的处理技术,因为这有可能低估或高估真实概率,更加科学的方法是使用复杂一点的Good—Turing技术,这项技术的的原始版本是图灵当年和他的助手Good在破解德国密码机时发明的。

我完成的分词器暂时没有用这个技术,只是简单的为没出现的词赋一个很小的值来实现简单的情况模拟,后续会尝试使用Good-Turing技术,试下效果怎么样。

求得的PI跟矩阵A如下:

    private static  double[] PI = new double[] {0.529918835192331, 0.0, 0.0, 0.470081164807669}; //B, M, E, S
    private static double[][] A = new double[][] {
    {0.0, 0.17142595344427136, 0.8285740465557286, 0.0}, //B
    {0.0, 0.49616531193870117, 0.5038346880612988, 0.0}, //M
    {0.4741680643477776, 0.0, 0.0, 0.5258319356522224}, //E
    {0.5927662448712697, 0.0, 0.0, 0.4072328569272888} //S
    };

相关训练代码:

    public static void buildPiAndMatrixA() {
    /**
    * count matrix:
    *   ALL B M E S
    * B *   * * * *
    * M *   * * * *
    * E *   * * * *
    * S *   * * * *
    *
    * NOTE:
    *  count[2][0] is the total number of complex words
    *  count[3][0] is the total number of single words
    */
    long[][] count = new long[4][5];
    try {
    BufferedReader br=new BufferedReader(new InputStreamReader(new FileInputStream("icwb2-data/training/msr_training.utf8"),"UTF-8"));
    String line = null;
    String last = null;
    while ((line = br.readLine()) != null) {
    String[] words = line.split(" ");
    for (int i=0; i<words.length; i++) {
    String word = words[i].trim();
    int length = word.length();
    if (length < 1)
    continue;
    if (length == 1) {
    count[3][0]++;
    if (last != null) {
    if (last.length() == 1)
    count[3][4]++;
    else
    count[2][4]++;
    }
    } else {
    count[2][0]++;
    count[0][0]++;
    if (length > 2) {
    count[1][0] += length-2;
    count[0][2]++;
    if (length-2 > 1) {
    count[1][2] += length-3;
    }
    count[1][3]++;
    } else {
    count[0][3]++;
    }
    if (last != null) {
    if (last.length() == 1) {
    count[3][1]++;
    } else {
    count[2][1]++;
    }
    }
    }
    last = word;
    }
    //System.out.println("Finish " + words.length + " words ...");
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    for (int i=0; i<count.length; i++)
    System.out.println(Arrays.toString(count[i]));
    System.out.println(" ===== So Pi array is: ===== ");
    double[] pi = new double[4];
    long allWordCount = count[2][0] + count[3][0];
    pi[0] = (double)count[2][0] / allWordCount;
    pi[3] = (double)count[3][0] / allWordCount;
    System.out.println(Arrays.toString(pi));
    System.out.println(" ===== And A matrix is: ===== ");
    double[][] A = new double[4][4];
    for (int i=0; i<A.length; i++)
    for (int j=0; j<A[i].length; j++)
    A[i][j] = (double)count[i][j+1]/ count[i][0];
    for (int i=0; i<A.length; i++)
    System.out.println(Arrays.toString(A[i]));
    }

矩阵中出现的概率为0的元素表明B-B, B-S, M-B, M-S, E-M, E-E, S-M, S-E这8种组合是不可能出现的。这是合乎逻辑的。

矩阵B内容比较大,格式如下:

    1.0 3.174041419653506E-6 0.0016687522763828306 1.0633038755839245E-4 4.7610621294802586E-6
    1.0 2.313791818894887E-6 5.738203710859319E-4 4.8589628196792625E-5 2.313791818894887E-6
    1.0 7.935103549133765E-7 0.005299062150111528 6.348082839307012E-6 7.935103549133765E-7
    1.0 1.7881026800082968E-6 0.005061224635763484 4.470256700020742E-6 8.940513400041484E-7

矩阵B训练代码:

    public static void buildMatrixB(String charMapFile, String charMapCharset, String matrixBFileName) {
    /**
    * Chinese Character count => 5167
    *
    * count matrix:
    *   ALL C1 C2 C3 CN C5168
    * B  *  *  *  *  *  1/ALL+5168
    * M  *  *  *  *  *  1/ALL+5168
    * E  *  *  *  *  *  1/ALL+5168
    * S  *  *  *  *  *  1/ALL+5168
    *
    * NOTE:
    *  count[0][0] is the total number of begin count
    *  count[0][0] is the total number of middle count
    *  count[2][0] is the total number of end count
    *  count[3][0] is the total number of single cound
    *
    *  B row -> 4
    *  B col -> 5169
    */
    long[][] matrixBCount = new long[4][5169];
    for (int row = 0; row < matrixBCount.length; row++) {
    Arrays.fill(matrixBCount[row], 1);
    matrixBCount[row][0] = 5168;
    }
    Map<Character, Integer> dict = new HashMap<Character, Integer>();
    Utils.readDict(charMapFile, charMapCharset, dict, null);
    try {
    BufferedReader br=new BufferedReader(new InputStreamReader(new FileInputStream("icwb2-data/training/msr_training.utf8"),"UTF-8"));
    String line = null;
    while ((line = br.readLine()) != null) {
    String[] words = line.split(" ");
    for (int i=0; i<words.length; i++) {
    String word = words[i].trim();
    if (word.length() < 1)
    continue;
    if (word.length() == 1) {
    int index = dict.get(word.charAt(0));
    matrixBCount[3][0]++;
    matrixBCount[3][index]++;
    } else {
    for (int j=0; j<word.length(); j++) {
    int index = dict.get(word.charAt(j));
    if (j == 0) {
    matrixBCount[0][0]++;
    matrixBCount[0][index]++;
    } else if (j == word.length()-1) {
    matrixBCount[2][0]++;
    matrixBCount[2][index]++;
    } else {
    matrixBCount[1][0]++;
    matrixBCount[1][index]++;
    }
    }
    }
    }
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    System.out.println(" ===== matrixBCount ===== ");
    for (int i=0; i<matrixBCount.length; i++)
    System.out.println(Arrays.toString(matrixBCount[i]));
    System.out.println(" ========= B matrix =========");
    double[][] B = new double[matrixBCount.length][matrixBCount[0].length];
    for (int row = 0; row < B.length; row++) {
    for (int col = 0; col < B[row].length; col++) {
    B[row][col] = (double) matrixBCount[row][col] / matrixBCount[row][0];
    if (col < 50) {
    System.out.print(B[row][col] + " ");
    }
    }
    System.out.println("");
    }
    try {
    PrintWriter bOut = new PrintWriter(new File(matrixBFileName));
    for (int row = 0; row < B.length; row++) {
    for (int col = 0; col < B[row].length; col++) {
    bOut.print(B[row][col] + " ");
    }
    bOut.println("");
    bOut.flush();
    }
    bOut.close();
    System.out.println("Finish write B to file " + matrixBFileName);
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    }
    }

有了矩阵PI,A跟B,我们就可以写入一个观察序列,用隐含马尔可夫模型跟Viterbi算法获得一个隐藏序列(分词结果)了。如果你对隐含马尔可夫模型还有什么疑问,请参考52nlp的博文:

http://www.52nlp.cn/hmm-learn-best-practices-one-introduction

以下是我用Java自己实现的Viterbi算法,有一点需要注意的是,Java的double能表示最小的值约为1E-350,如果一个文本串很长,例如大于200个字符,算得的结果值很有可能会小于double的最小值,而由于精度问题变为0,这样最终的计算结果就失去意义了。当然,如果能保证输入串的长度比较短,可以不care这个,但为了程序的健壮性,我这里在计算某一列的结果值小于1E-250时,将停止使用double而改用Java提供的高精度类BigDecimal,虽然计算速度会比double慢很多(尤其随着串越来越长),但总比变为0,结果失去意义要强一些。但即便是这样,这个函数也期望输入串不长于200,否则就有可能在1S内得不到最终计算结果。

    public static String Viterbi(double[] PI, double[][] A, double[][] B, int[] sentences) {
    StringBuilder ret = new StringBuilder();
    double[][] matrix = new double[PI.length][sentences.length];
    int[][] past = new int[PI.length][sentences.length];
    int supplementStartColumn = -1;
    BigDecimal[][] supplement = null; //new BigDecimal[][];
    for (int row=0; row<matrix.length; row++)
    matrix[row][0] = PI[row] * B[row][sentences[0]];
    for (int col=1; col<sentences.length; col++) {
    if (supplementStartColumn > -1) { //Use supplement BigDecimal matrix
    for (int row=0; row<matrix.length; row++) {
    BigDecimal max = new BigDecimal(0d);
    int last = -1;
    for (int r=0; r<matrix.length; r++) {
    BigDecimal value = supplement[r][col-1-supplementStartColumn].multiply(new BigDecimal(A[r][row])).multiply(new BigDecimal(B[row][sentences[col]]));
    if (value.compareTo(max) > 0) {
    max = value;
    last = r;
    }
    }
    supplement[row][col-supplementStartColumn] = max;
    past[row][col] = last;
    }
    } else {
    boolean switchSupplement = false;
    for (int row=0; row<matrix.length; row++) {
    double max = 0;
    int last = -1;
    for (int r=0; r<matrix.length; r++) {
    double value = matrix[r][col-1] * A[r][row] * B[row][sentences[col]];
    if (value > max) {
    max = value;
    last = r;
    }
    }
    matrix[row][col] = max;
    past[row][col] = last;
    if (max < 1E-250)
    switchSupplement = true;
    }
    //Really small data, should switch to supplement BigDecimal matrix now, or we will loose accuracy soon
    if (switchSupplement) {
    supplementStartColumn = col;
    supplement = new BigDecimal[PI.length][sentences.length - supplementStartColumn];
    for (int row=0; row<matrix.length; row++) {
    supplement[row][col - supplementStartColumn] = new BigDecimal(matrix[row][col]);
    }
    }
    }
    }
    int index = -1;
    if (supplementStartColumn > -1) {
    BigDecimal max = new BigDecimal(0d);
    int column = supplement[0].length-1;
    for (int row=0; row<supplement.length; row++) {
    if (supplement[row][column].compareTo(max) > 0) {
    max = supplement[row][column];
    index = row;
    }
    }
    } else {
    double max = 0;
    for (int row=0; row<matrix.length; row++)
    if (matrix[row][sentences.length-1] > max) {
    max = matrix[row][sentences.length-1];
    index = row;
    }
    }
    /*for (int i=0; i<matrix.length; i++)
    System.out.println(Arrays.toString(matrix[i]));*/
    ret.append(getDesc(index));
    for (int col=sentences.length-1; col>=1; col--) {
    index = past[index][col];
    ret.append(getDesc(index));
    }
    return ret.reverse().toString();
    }

测试一下,对如下串进行分词:

    String str2 = "这并不是一种最好的处理技术,因为这有可能低估或高估真实概率,更加科学的方法是使用复杂一点的Good—Turing技术,这项技术的原始版本是图灵当年和他的助手Good在破解德国密码机时发明的。";

结果为:

      Switch to BigDecimal at length = 79
      words.length=95   timecost:63
      这/并/不是/一种/最好/的/处理/技术/,/因为/这有/可能/低估/或/高估/真实/概率/,/更加/科学/的/方法/是/使用/复杂/一点/的/Good—Turing技术/,/这项/技术/的/原始/版本/是/图灵/当年/和/他/的/助手/Good/在/破解/德国/密码/机时/发明/的/。/
      timeCost: 63

转:从头开始编写基于隐含马尔可夫模型HMM的中文分词器的相关教程结束。

《转:从头开始编写基于隐含马尔可夫模型HMM的中文分词器.doc》

下载本文的Word格式文档,以方便收藏与打印。