開源中文分詞工具探析(四):THULAC


THULAC是一款相當不錯的中文分詞工具,准確率高、分詞速度蠻快的;並且在工程上做了很多優化,比如:用DAT存儲訓練特征(壓縮訓練模型),加入了標點符號的特征(提高分詞准確率)等。


【開源中文分詞工具探析】系列:

  1. 開源中文分詞工具探析(一):ICTCLAS (NLPIR)
  2. 開源中文分詞工具探析(二):Jieba
  3. 開源中文分詞工具探析(三):Ansj
  4. 開源中文分詞工具探析(四):THULAC
  5. 開源中文分詞工具探析(五):FNLP
  6. 開源中文分詞工具探析(六):Stanford CoreNLP
  7. 開源中文分詞工具探析(七):LTP

1. 前言

THULAC所采用的分詞模型為結構化感知器(Structured Perceptron, SP),屬於兩種CWS模型中的Character-Based Model,將中文分詞看作為一個序列標注問題:對於字符序列\(C=c_1^n\),找出最有可能的標注序列\(Y=y_1^n\)。定義score函數\(S(Y,C)\)為在\(C\)的情況下標注序列為\(Y\)的得分。SP以最大熵准則建模score函數,分詞結果則等同於最大score函數所對應的標注序列。記在時刻\(t\)的狀態為\(y\)的路徑\(y_1^{t}\)所對應的score函數最大值為

\[\delta_t(y) = \max S(y_1^{t-1}, C, y_t=y) \]

那么,則有遞推式

\[\delta_{t+1}(y) = \max_{y'} \ \{ \delta_t(y') + w_{y',y} + F(y_{t+1}=y,C) \} \]

其中,\(w_{y',y}\)為轉移特征\((y',y)\)所對應的權值,\(F(y_{t+1}=y, C)\)為特征模板的特征值的加權之和:

\[F(y_{t+1}=y,C) = \sum_{i=1}^7 \alpha_i f_i(y_{t+1}=y,C) \]

其中,\(\alpha_i\)為特征\(f_i(y_{t+1}=y,C)\)所對應的權重。

2. 分解

以下源碼分析基於最近lite_v1_2版本THULAC-Java

訓練模型

seg_only模式(只分詞沒有POS)對應的訓練模型的數據包括三種:權重數據cws_model.bin、序列標注類別數據cws_label.txt、特征數據cws_dat.bin。權重數據對應於類character.CBModel,其格式如下:

int l_size (size of the labels): 4
int f_size (size of the features): 2453880
int[] ll_weights // weights of (label, label):
-42717
39325
-33792
...
-31595
26794
int[] fl_weights //weights of (feature, label):
-4958
2517
5373
-2930
-286
0
...

訓練模型中的標注類別(label)共有4類“0”, “2”, “3”, “1”(見文件cws_label.txt),分別對應於中文分詞中的標注類別B、E、S、M;這一點可以在C++版的thulac_base.h找到映照:

enum POC{
    kPOC_B='0',
    kPOC_M='1',
    kPOC_E='2',
    kPOC_S='3'
};

但是,THULAC在解碼時用到的label,則是BMES在特征數據文件的索引位置,因此標注類別B、M、E、S映射到整數0、3、1、2;這些映射可以在后面的構建label轉移矩陣labelTransPre及Viterbi解碼的代碼中找到印證。那么,則B轉移到B的權重\(w_{B,B}=w_{0,0}=-42717\),B轉移到E的權重\(w_{B,E}=w_{0,1}=39325\),B轉移到S的權重\(w_{B,S}=w_{0,2}=-33792\)。此即與實際情況相對應,B只能會轉移到M和E,而不可能轉移到S。

權重數據中標識對應於特征模板的feature共有2453880個。那么,label與label之間的組合共有4×4=16種,即ll_weights的長度為16;feature與label之間的組合共有4×2453880=9815520種,即fl_weights的長度為9815520。

特征數據則是用雙數組Trie樹DAT來存儲的,對應於類base.Dat,其格式如下:

int datSize: 7643071
Vector<Entry> dat:
0 0
0 1
0 51
0 52
...
25053 0
5 25088
...

datSizedat的長度,等於7643071;類Entry表示雙數組中的的base與check值。那么,問題來了:一是特征長什么樣?二是知道特征如何得到對應的權重?三是為什么DAT的長度遠大於字符特征的個數2453880?

特征

首先,我們來看看特征長什么樣,參看特征生成方法CBNGramFeature::featureGeneration

public void featureGeneration(String seq, Indexer<String> indexer, Counter<String> bigramCounter){
  ...
  for(int i = 0; i < seq.length(); i ++){
    mid = seq.charAt(i);
    left = (i > 0) ? (seq.charAt(i-1)) : (SENTENCE_BOUNDARY);
    ...
    key = ((char)mid)+((char)SEPERATOR) + "1";
    key = ((char)left)+((char)SEPERATOR) + "2";
    key = ((char)right)+((char)SEPERATOR) + "3";
    key = ((char)left)+((char)mid)+((char)SEPERATOR) + "1";
    key = ((char)mid)+((char)right)+((char)SEPERATOR) + "2";
    key = ((char)left2)+((char)left)+((char)SEPERATOR) + "1"; // should be + "3"
    key = ((char)right)+((char)right2)+((char)SEPERATOR) + "1"; // should be + "4"
    ...
  }
}

特征模板共定義7個字符特征:3個unigram字符特征與4個bigram字符特征。在處理特征時,字符后面加上了空格,然后在加上標識1、2、3、4,用以區分特征的種類。值得指出的是Java版作者寫錯了最后兩個bigram特征,應該是加上數字3、4;在C++版的函數NGramFeature::feature_generation可找到印證。通過特征模板定義,我們發現THULAC既考慮到了前面2個字符(各種組合)對當前字符標注的影響,也考慮到了后面2個字符的影響。

接下來,為了解決第二個問題,我們來看看用Viterbi算法解碼前的代碼——THULAC先將特征值的加權之和\(F(t_i,C)\)計算出來,然后按label次序逐個放入values[i*4+label] 中。主干代碼如下:

/**
 * @param datSize DAT size
 * @param ch1 first character
 * @param ch2 second character
 * @return [unigram字符特征的base + " ", bigram字符特征的base + " "]
 */
public Vector<Integer> findBases(int datSize, int ch1, int ch2) {
  uniBase = dat.get(ch1).base + SEPERATOR;
  biBase = dat.get(ind).base + SEPERATOR;
}

/**
 * 按label 0,1,2,3 將特征值的加權之和放入values數組中
 * @param valueOffset values數組偏置量,在putValues中調用時按步長4遞增
 * @param base unigram字符特征或bigram字符特征的base加上空格的index
 * @param del 標識'1', '2', '3', '4' -> 49, 50, 51, 52
 * @param pAllowedLable null
 */
private void addValues(int valueOffset, int base, int del, int[] pAllowedLable) {
  int ind = dat.get(base).base + del; // 加上標識del后特征的index
  int offset = dat.get(ind).base; // 特征的base
  int weightOffset = offset * model.l_size; // 特征數組的偏移量
  int allowedLabel;
  if (model.l_size == 4) {
    values[valueOffset] += model.fl_weights[weightOffset];
    values[valueOffset + 1] += model.fl_weights[weightOffset + 1];
    values[valueOffset + 2] += model.fl_weights[weightOffset + 2];
    values[valueOffset + 3] += model.fl_weights[weightOffset + 3];
  }
}

public int putValues(String sequence, int len) {
  int base = 0;
  for (int i = 0; i < len; i++) {
    int valueOffset = i * model.l_size;
    if ((base = uniBases[i + 1]) != -1) {
      addValues(valueOffset, base, 49, null); // c_{i}t_{i}
    }
    if ((base = uniBases[i]) != -1) {
      addValues(valueOffset, base, 50, null); // c_{i-1}t_{i}
    }
    if ((base = uniBases[i + 2]) != -1) {
      addValues(valueOffset, base, 51, null); // c_{i+1}t_{i}
    }
    if ((base = biBases[i + 1]) != -1) {
      addValues(valueOffset, base, 49, null); // c_{i-1}c_{i}t_{i}
    }
    if ((base = biBases[i + 2]) != -1) {
      addValues(valueOffset, base, 50, null); // c_{i}c_{i+1}t_{i}
    }
    if ((base = biBases[i]) != -1) {
      addValues(valueOffset, base, 51, null); // c_{i-2}c_{i-1}t_{i}
    }
    if ((base = biBases[i + 3]) != -1) {
      addValues(valueOffset, base, 52, null); // c_{i+1}c_{i+2}t_{i}
    }
  }
}

注意:49對應於數字1的unicode值,50對應於數字2等。從上述代碼中,我們發現特征數組fl[4*i+j]對應於特征的base為i,label為j。拼接特征的流程如下:先得到unigram或bigram字符特征,然后加空格,再加標識。在拼接過程中,按照DAT的轉移方程進行轉移:

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

最后,我們回到第三個問題——為什么DAT的長度遠大於特征數?這是因為在構建DAT時,需要存儲很多的中間結果。

解碼

THULAC用於解碼的類character.CBTaggingDecoder,Viterbi算法實現對應於方法AlphaBeta::dbDecode;CBTaggingDecoder的主要字段如下:

public char separator;
private int maxLength; // 定義的最大句子長度20000
private int len; // 待分詞句子的長度
private String sequence; // 待分詞句子的深拷貝
private int[][] allowedLabelLists; // 根據標點符號及句子結構,判斷當前字符允許的label: int[len][]
private int[][] pocsToTags; // index -> allow-labels: int[16][2,3,4]

private CBNGramFeature nGramFeature;
private Dat dat; // 字符特征DAT
private CBModel model; // 權重

private Node[] nodes; // 分詞DAG鄰接表
private int[] values; // 特征模板F(t_i,X)的加權之和: int[i*4+label]
private AlphaBeta[] alphas; // 解碼時用來記錄path, [i, j]指c_{i}的標注為t_{j}的前一結點
private int[] result; // 分詞后的標注數組
private String[] labelInfo; // ["0", "2", "3", "1"]
private int[] labelTrans; // 
private int[][] labelTransPre; // 可能情況的前labels: int[4][3]
public int threshold;
private int[][] labelLookingFor;

其中,二維數組pocsToTags為index與allow labels之間的映射,內容如下:

[null, [0, -1], [3, -1], [0, 3, -1], 
[1, -1], [0, 1, -1], [1, 3, -1], [0, 1, 3, -1], 
[2, -1], [0, 2, -1], [2, 3, -1], [0, 2, 3, -1], 
[1, 2, -1], [0, 1, 2, -1], [1, 2, 3, -1], [0, 1, 2, 3, -1]]

該數組的index表示了當前字符具有某些性質,比如:

  • 1([0])表示詞的開始,即標注B;
  • 2([3])表示詞的中間字符,即標注M;
  • 4([1])表示詞的結尾,即標注E;
  • 8([2])表示標點符號,即標注S;
  • 9([0,2])表示某一個詞的開始,或者能單獨成詞,即標注BS;
  • 12([1,2])表示詞的結束,即標注ES;
  • 15([0,1,2,3])為默認值。

在分詞前,THULAC根據標點符號等特征,給一些字符加入allow labels以提高分詞准確性。比如,根據書名號確定里面為一個詞,

val sentence = "倒模,替身算什么?鍾漢良、ab《孤芳不自賞》摳圖來充數"
val poc_cands = new POCGraph
val tagged = new TaggedSentence
val segged = new SegmentedSentence
val segmenter = new CBTaggingDecoder
val preprocesser = new Preprocesser
val prefix = "models/"
segmenter.init(prefix + "cws_model.bin", prefix + "cws_dat.bin", prefix + "cws_label.txt")
segmenter.setLabelTrans()
segmenter.segment(raw, poc_cands, tagged)
segmenter.get_seg_result(segged)
println(segged.mkString(" "))
// 倒模 , 替身 算 什么 ? 鍾漢良 、 ab 《 孤芳不自賞 》 摳 圖 來 充數

3. 參考資料

[1] Li, Z., & Sun, M. (2009). Punctuation as implicit annotations for Chinese word segmentation. Computational Linguistics, 35(4), 505-512.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM