java版JieBa分詞原始碼走讀

爬蜥發表於2019-03-01

JieBa使用

List<SegToken> process = segmenter.process("今天早上,出門的的時候,天氣很好", JiebaSegmenter.SegMode.INDEX);
for (SegToken token:process){
    //分詞的結果
    System.out.println( token.word);
}
複製程式碼

輸出內容如下

今天
早上
,
出門
的
的
時候
,
天氣
很
好
複製程式碼

分詞的執行邏輯

java版JieBa分詞原始碼走讀

可以看到核心在於

  1. 內部包含一個字典
  2. 分詞邏輯
  3. 不同模式的切分粒度

分詞的模式

  • search 精準的切開,用於對使用者查詢詞分詞
  • index 對長詞再切分,提高召回率

分詞流程

java版JieBa分詞原始碼走讀

可以看到核心在於

  1. 根據輸入建立DAG
  2. 選取高頻的詞
  3. 詞典中不包含的情況下,即未記錄詞,進行重新識別

建立DAG

  1. 獲取已經載入的trie樹
  2. 從trie樹中匹配,核心程式碼如下
    int N = chars.length; //獲取整個句子的長度
    int i = 0, j = 0; //i 表示詞的開始 ;j 表示詞的結束
    while (i < N) {
        Hit hit = trie.match(chars, i, j - i + 1); //從trie樹中匹配
        if (hit.isPrefix() || hit.isMatch()) {
            if (hit.isMatch()) {
                //完全匹配
                if (!dag.containsKey(i)) {
                    List<Integer> value = new ArrayList<Integer>();
                    dag.put(i, value);
                    value.add(j); 
                }
                else
                    dag.get(i).add(j); //以當前字元開頭的詞有哪些,詞結尾的座標記下來
            }
            j += 1; 
            if (j >= N) {
                //以當前字元開頭的所有詞已經匹配完成,再以當前字元的下一個字元開頭尋找詞
                i += 1;
                j = i;
            }
        }
        else {
        //以當前字元開頭的詞已經全部匹配完成,再以當前字元的下一個字元開頭尋找詞
            i += 1;
            j = i;
        }
    }
    複製程式碼
  3. 補充DAG中沒有出現的句子中的字元

DAG結果示例

比如輸入的是 “今天早上”

java版JieBa分詞原始碼走讀

它的DAG展示如下

java版JieBa分詞原始碼走讀

也就是說 “今天早上” 這個句子,在trie中能查到的詞為

今/今天/早/早上/上
複製程式碼

Trie樹運用

JieBa內部儲存了一個檔案dict.txt,比如記錄了 X光線 3 n。在內部的儲存trie樹結構則為

java版JieBa分詞原始碼走讀
  • nodeState:當前DictSegment狀態 ,預設 0 , 1表示從根節點到當前節點的路徑表示一個詞 ,比如 x光和 x光線

  • storeSize:當前節點儲存的Segment數目

比如除了x光線之外,還有x射

  • childrenArray和childrenMap用來儲存trie樹的子節點

storeSize <=ARRAY_LENGTH_LIMIT ,使用陣列儲存, storeSize >ARRAY_LENGTH_LIMIT,則使用Map儲存 ,取值為3

選取高頻的詞

核心程式碼如下

 for (int i = N - 1; i > -1; i--) {
 //從右往左去檢視句子,這是因為中文的重點一般在後面
 //表示詞的開始位置
        Pair<Integer> candidate = null;
        for (Integer x : dag.get(i)) {
        //x表示詞的結束位置
        // wordDict.getFreq表示獲取trie這個詞的頻率
        //route.get(x+1)表示當前詞的後一個詞的概率
        //由於頻率本身儲存的是數學上log計算後的值,這裡的加法其實就是當前這個詞為A並且後面緊跟著的詞為B的概率,B已經由前面算出
            double freq = wordDict.getFreq(sentence.substring(i, x + 1)) + route.get(x + 1).freq;
            if (null == candidate) {
                candidate = new Pair<Integer>(x, freq);
            }
            else if (candidate.freq < freq) {
                //儲存概率高的詞
                candidate.freq = freq;
                candidate.key = x;
            }
        }
        //可見route中儲存的資料為key:詞頭下標 value:詞尾下標,詞的頻率
        route.put(i, candidate);
    }
複製程式碼

高頻詞選取過程:

  • i=N-1: route中僅存了一個初始的值,N=4,freq=0,代表最後一個次後面是沒有詞的,首先獲取詞 `上` 在trie中的頻率為 -5.45,相加後可得上的概率為 -5.45,由於以 `上` 開頭的只有1個詞,存入route中 <3,-5.45>

    (3,<3,-5.45>) :第一個3是詞頭,第二個3是 `上` 的詞尾下標;-5.45是它出現的概率;
    (4,<0,0>):初始概率

  • i=N-2: 以 `早` 開頭的有兩個詞,分別為 `早` 和 `早上`,首先獲取詞典中 `早` 的頻率為 -8.33,它的後一個詞為 `上` ,求和後頻率為 -13.78;再獲取 `早上`,在詞典中的頻率為 -10.81 ,`早上` 後面沒有詞,因此頻率取值就是 -10.81,對比兩個詞頻大小,`早上`的頻率大,因此只保留了 `早上`

    此時 route保留了 (3,<3,-5.45>)、(4,<0,0>)和(2, <3,-10.81> )

依此類推,經過route之後的取詞如下

java版JieBa分詞原始碼走讀

分詞程式碼

取完了高頻詞之後,核心邏輯如下

 while (x < N) {
        //獲取當前字元開頭的詞的詞尾
        y = route.get(x).key + 1;
        String lWord = sentence.substring(x, y);
        if (y - x == 1)
            sb.append(lWord); //單個字元成詞,先保留
        else {
            if (sb.length() > 0) {
                buf = sb.toString();
                sb = new StringBuilder();
                if (buf.length() == 1) {
                    tokens.add(buf);
                }
                else {
                    if (wordDict.containsWord(buf)) {
                        tokens.add(buf); //多個字元並且字典中存在,作為分詞的結果
                    }
                    else {
                        finalSeg.cut(buf, tokens);
                    }
                }
            }
            //保留多個字元組成的詞
            tokens.add(lWord);
        }
        x = y; //從當前詞的詞尾開始找下一個詞
    }
複製程式碼

詞提取的過程

  • x=0,找到它的詞尾為1,此時獲取到了 `今天`,由於包含多個詞,直接作為分詞的結果
  • x=2,詞尾為3,獲取到`早上` ,分詞結束

至此 `今天早上` 這句話分詞結束。可以看到這都是建立在這個詞已經存在於字典的基礎上成立的。
如果出現了多個單個字成詞的情況,比如 `出門的的時候` 中的 `的`,一方面它成為了單個的詞,另一方面後面緊跟著的 `的`與它一起成為了兩個字元組成的詞,又在詞典中不存在 `的的` ,因而識別為未知的詞,呼叫 finalSeg.cut

Viterbi演算法處理未記錄詞,重新識別

使用的方法為Viterbi演算法。首先預載入如下HMM模型的三組概率集合和隱藏狀態集合

  1. 未知的詞定義了4個隱藏狀態。 B 表示詞的開始 M 表示詞的中間 E 表示詞的結束 S 表示單字成詞

  2. 初始化每個隱藏狀態的初始概率

    start.put(`B`, -0.26268660809250016);
    start.put(`E`, -3.14e+100);
    start.put(`M`, -3.14e+100);
    start.put(`S`, -1.4652633398537678);
    複製程式碼
  3. 初始化狀態轉移矩陣

    trans = new HashMap<Character, Map<Character, Double>>();
    Map<Character, Double> transB = new HashMap<Character, Double>();
    transB.put(`E`, -0.510825623765990);
    transB.put(`M`, -0.916290731874155);
    trans.put(`B`, transB);
    Map<Character, Double> transE = new HashMap<Character, Double>();
    transE.put(`B`, -0.5897149736854513);
    transE.put(`S`, -0.8085250474669937);
    trans.put(`E`, transE);
    Map<Character, Double> transM = new HashMap<Character, Double>();
    transM.put(`E`, -0.33344856811948514);
    transM.put(`M`, -1.2603623820268226);
    trans.put(`M`, transM);
    Map<Character, Double> transS = new HashMap<Character, Double>();
    transS.put(`B`, -0.7211965654669841);
    transS.put(`S`, -0.6658631448798212);
    trans.put(`S`, transS);
    複製程式碼

    比如trans.get(`S`).get(`B`)表示如果當前字元是 `S`,那麼下個是另一個詞(非單字成詞)開始的概率為 -0.721

  4. 讀取實現準備好的混淆矩陣,存入 emit中

    java版JieBa分詞原始碼走讀

    紅框表示 隱藏狀態 `E`的前提下,觀察狀態是 `要`的概率為 -5.26

    java版JieBa分詞原始碼走讀

    紅框表示 隱藏狀態 `B`的前提下,觀察狀態是 `要`的概率為 -6.73

另外它預定義了每個隱藏狀態之前只能是那些狀態

prevStatus.put(`B`, new char[] { `E`, `S` });
prevStatus.put(`M`, new char[] { `M`, `B` });
prevStatus.put(`S`, new char[] { `S`, `E` });
prevStatus.put(`E`, new char[] { `B`, `M` });
複製程式碼

比如 `M` 它的前面必定是 `M` 和 `B` 之間的一個

演算法的流程如下:

  1. 獲取句子中第一個字元在所有隱藏狀態下的概率
      for (char state : states) {
            Double emP = emit.get(state).get(sentence.charAt(0));
            if (null == emP)
                emP = MIN_FLOAT;
            //儲存第一個字元 是 `B` `E` `M` `S`的概率,即初始化轉移概率
            v.get(0).put(state, start.get(state) + emP);
            path.put(state, new Node(state, null));
        }
    複製程式碼
  2. 計算根據觀察序列得到和HMM模型求隱藏序列的概率,並記下最佳解析位置,通過父指標連線起來
for (int i = 1; i < sentence.length(); ++i) {
    Map<Character, Double> vv = new HashMap<Character, Double>();
    v.add(vv);
    Map<Character, Node> newPath = new HashMap<Character, Node>();
    for (char y : states) {
    //y表示隱藏狀態
    //emp是獲取混淆矩陣的概率,比如 在 `B`發生的情況下,觀察到字元 `要` 的概率
        Double emp = emit.get(y).get(sentence.charAt(i));
        if (emp == null)
            emp = MIN_FLOAT; //樣本中沒有,就設定為最小的概率
        Pair<Character> candidate = null;
        for (char y0 : prevStatus.get(y)) {
            Double tranp = trans.get(y0).get(y);//獲取狀態轉移概率,比如 E -> B
            if (null == tranp)
                tranp = MIN_FLOAT; //轉移概率不存在,取最低的
            //v中放的是當前字元的前一個字元的概率,即前一個狀態的最優解
            //tranp 是狀態轉移的概率
            //三者相加即計算已知觀察序列和HMM的條件下,求得最可能的隱藏序列的概率
            tranp += (emp + v.get(i - 1).get(y0));
            if (null == candidate)
                candidate = new Pair<Character>(y0, tranp);
            else if (candidate.freq <= tranp) {
            //儲存最優可能的隱藏概率
                candidate.freq = tranp;
                candidate.key = y0;
            }
        }
        //儲存是`B`還是 `E`各自的概率
        vv.put(y, candidate.freq);
        //記下前後兩個詞最優的路徑,以便還原原始的隱藏狀態分隔點
        newPath.put(y, new Node(y, path.get(candidate.key)));
    }
    //儲存最終句子的最優路徑
    path = newPath;
}
複製程式碼
  1. 找到句子尾部的字元的隱藏個狀態,並通過最佳路徑旅順整個句子的切割方式
    double probE = v.get(sentence.length() - 1).get(`E`);
    double probS = v.get(sentence.length() - 1).get(`S`);
    Vector<Character> posList = new Vector<Character>(sentence.length());
    Node win;
    if (probE < probS)
        win = path.get(`S`);
    else
        win = path.get(`E`);
    
    while (win != null) {
    //沿著指標找到句子的每個字元的個子位置
        posList.add(win.value);
        win = win.parent;
    }
    Collections.reverse(posList);
    複製程式碼
  2. 遍歷這個句子,根據標註,記下所有切割點的詞
int begin = 0, next = 0;
for (int i = 0; i < sentence.length(); ++i) {
    char pos = posList.get(i);
    if (pos == `B`)
        begin = i;
    else if (pos == `E`) {
    //到詞尾了,記下
        tokens.add(sentence.substring(begin, i + 1));
        next = i + 1;
    }
    else if (pos == `S`) {
    //單個字成詞,記下
        tokens.add(sentence.substring(i, i + 1));
        next = i + 1;
    }
}
if (next < sentence.length())
    tokens.add(sentence.substring(next));
複製程式碼

自此執行結束

java版 JieBa原始碼

相關文章