一、前言

上節介紹了ansj的原子切分和全切分。切分完成之後,就要構建最短路徑,得到分詞結果。
以“商品和服務”為例,呼叫ansj的標準分詞:
String str = "商品和服務" ;
Result result = ToAnalysis.parse(str);
System.out.println(result.getTerms());
先不管數字發現、人名識別、使用者自定義詞典的識別,暫時只考慮ToAnalysis類裡面,構建最短路徑的這行程式碼:
graph.walkPath();
上面這行程式碼執行前,已完成了全切分,構建瞭如下的有向無環圖:

事實上,此時沒有“務”這個節點

如上圖所示,terms[4] = null。
不過這也沒關係,後面給節點打分時,會填充這個null,這段程式碼位於Graph.merger(Term fromTerm, int to, Map<String, Double> relationMap):
char c = chars[to];
TermNatures tn = DATDictionary.getItem(c).termNatures;
if (tn == null || tn == TermNatures.NULL) {
tn = TermNatures.NULL;
}
terms[to] = new Term(String.valueOf(c), to, tn);
也就是說,給“和服”的後繼節點打分時,發現其後繼節點為null,那麼就例項化一個Term,填充在terms[to]的位置。

二、理論基礎

兩個節點之間分之計算的程式碼位於MathUtil.compuScore(Term from, Term to, Map<String, Double> relationMap)
其中核心程式碼只有一行:
double value = -Math.log(dSmoothingPara * frequency / (MAX_FREQUENCE + 80000) + (1 - dSmoothingPara) * ((1 - dTemp) * nTwoWordsFreq / frequency + dTemp));
我們了探討一下這行程式碼的理論基礎。
首先,ansj使用二元語法模型(Bigram)進行分詞。Bigram模型對應於一階Markov假設,詞只與其前面一個詞相關,其對應的分詞模型:
$arg\,max\prod_{m}^{i=1}P({w}_{i}|{w}_{i-1})\, =\,arg\,min-\sum_{m}^{i=1}logP({w}_{i}|{w}_{i-1})$
該等式將求解最大聯合概率的問題轉化為了求解有向無環圖最短路徑問題。
其中,數學符號arg表示使目標函式取最小值時的變數值。這裡是指求解條件概率之積$\prod_{m}^{i=1}P({w}_{i}|{w}_{i-1})$取最大值時的分詞結果。
對條件概率$P({w}_{i}|{w}_{i-1})$做如下的平滑處理:

\begin{aligned}
- \log P(w_{i} | w_{i-1}) & \approx - \log \left[ aP(w_{i-1}) + (1-a) P(w_{i}|w_{i-1}) \right] \\
& \approx - \log \left[ a\frac{f(w_i)}{N} + (1-a) \left( \frac{(1-\lambda)f(w_{i-1},w_i)}{f(w_{i-1})} + \lambda \right) \right]
\end{aligned}

其中,a = 0.1為平滑因子,N = 207997為訓練語料中的總次數,$\lambda \,=\,\frac{1}{N}$。
第一個約等式是採用線性插值法(Linear Interpolation)(可參考自然語言處理:盤點一下資料平滑演算法)進行平滑處理。
第二個約等式,我還沒搞清楚是什麼處理。

三、具體打分流程如下

程式碼位於Graph.walkPath(Map<String, Double> relationMap)。
Ansj採用了類似於Dijkstra的動態規劃演算法(作者稱之為Viterbi演算法)來求解最短路徑。
如果存在一條從i到j的最短路徑(Vi.....Vk,Vj),Vk是Vj前面的一頂點,那麼(Vi...Vk)也必定是從i到k的最短路徑。(可參考Dijkstra演算法
1、從起始節點“始##始”開始,對其後繼節點打分

設定“商”、“商品”的前驅節點(也就是Term類的from屬性)為“始##始”。
2、計算“商”後繼節點的分值

只有一個後繼節點“品”。“商”和“品”的分值是13.509,因此從“始##始”到“品”的分值是19.56。
設定“品”的前驅節點為“商”。
3、計算“商品”後繼節點分值

設定“和”、“和服”的前驅節點為“商品”。
4、計算“品”後繼節點分值

以“和”為例,“和”有“商品”、“品”兩個前驅節點。應該取分值最小的那個。因此,“和”的分值依然是8.92,前驅節點依然是“商品”。
同理,“和服”的前驅節點依然是“商品”。
對上圖進行簡化:

5、計算“和”後繼節點分值

設定“服”、“服務”的前驅節點為“和”。
6、計算“和服”後繼節點分值

設定“務”的前驅節點為“和服”。
對上圖簡化:

7、計算“服”後繼節點分值

“務”以“服”為前驅,可以得到更小的分值。因此,更改“務”的前驅節點為“服”。
對上圖簡化:

8、計算“服務”後繼節點分值

設定“末##末”的前驅節點為“服務”。
9、計算“務”後繼節點分值

“末##末”以“服務”為前驅節點,分值更新。因此,“末##末”的前驅節點依然是“服務”。
對上圖簡化:

10、設定後繼節點
目前已構建了最短路徑,並且知道了每個節點的前驅節點。
例如,“末##末”的前驅節點是“服務”。但是並沒有將“服務”的後繼節點(也就是Term類的to屬性)設定為“末##末”。
Graph.optimalRoot()就是設定後繼節點的。執行完該方法後,terms被簡化為了如下形式:

去掉null,就是分詞結果了。

參考資料