JieBa使用
List<SegToken> process = segmenter.process("今天早上,出門的的時候,天氣很好", JiebaSegmenter.SegMode.INDEX);
for (SegToken token:process){
//分詞的結果
System.out.println( token.word);
}
複製程式碼
輸出內容如下
今天
早上
,
出門
的
的
時候
,
天氣
很
好
複製程式碼
分詞的執行邏輯
可以看到核心在於
- 內部包含一個字典
- 分詞邏輯
- 不同模式的切分粒度
分詞的模式
- search 精準的切開,用於對使用者查詢詞分詞
- index 對長詞再切分,提高召回率
分詞流程
可以看到核心在於
- 根據輸入建立DAG
- 選取高頻的詞
- 詞典中不包含的情況下,即未記錄詞,進行重新識別
建立DAG
- 獲取已經載入的trie樹
- 從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; } } 複製程式碼
- 補充DAG中沒有出現的句子中的字元
DAG結果示例
比如輸入的是 “今天早上”
它的DAG展示如下
也就是說 “今天早上” 這個句子,在trie中能查到的詞為
今/今天/早/早上/上
複製程式碼
Trie樹運用
JieBa內部儲存了一個檔案dict.txt,比如記錄了 X光線 3 n
。在內部的儲存trie樹結構則為
-
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之後的取詞如下
分詞程式碼
取完了高頻詞之後,核心邏輯如下
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模型的三組概率集合和隱藏狀態集合
-
未知的詞定義了4個隱藏狀態。 B 表示詞的開始 M 表示詞的中間 E 表示詞的結束 S 表示單字成詞
-
初始化每個隱藏狀態的初始概率
start.put(`B`, -0.26268660809250016); start.put(`E`, -3.14e+100); start.put(`M`, -3.14e+100); start.put(`S`, -1.4652633398537678); 複製程式碼
-
初始化狀態轉移矩陣
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
-
讀取實現準備好的混淆矩陣,存入 emit中
紅框表示 隱藏狀態 `E`的前提下,觀察狀態是 `要`的概率為 -5.26
紅框表示 隱藏狀態 `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` 之間的一個
演算法的流程如下:
- 獲取句子中第一個字元在所有隱藏狀態下的概率
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)); } 複製程式碼
- 計算根據觀察序列得到和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;
}
複製程式碼
- 找到句子尾部的字元的隱藏個狀態,並通過最佳路徑旅順整個句子的切割方式
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); 複製程式碼
- 遍歷這個句子,根據標註,記下所有切割點的詞
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));
複製程式碼
自此執行結束