Ansj是由孫健(ansjsun)開源的一箇中文分詞器,爲ICTLAS的Java版本,也採用了Bigram + HMM分詞模型(可參考我以前寫的文章):在Bigram分詞的基礎上,識別未登陸詞,以提升分詞準確度。雖然基本分詞原理與ICTLAS的同樣,可是Ansj作了一些工程上的優化,好比:用DAT高效地實現檢索詞典、鄰接表實現分詞DAG、支持自定義詞典與自定義消歧義規則等。html
【開源中文分詞工具探析】系列:java
Ansj支持多種分詞方式,其中ToAnalysis爲店長推薦款:git
它在易用性,穩定性.準確性.以及分詞效率上.都取得了一個不錯的平衡.若是你初次嘗試Ansj若是你想開箱即用.那麼就用這個分詞方式是不會錯的.github
所以,本文將主要分析ToAnalysis的分詞實現(基於ansj-5.1.0版本)。算法
ToAnalysis
繼承自抽象類org.ansj.splitWord.Analysis
,重寫了抽象方法getResult
。其中,分詞方法的依賴關係:ToAnalysis::parse -> Analysis::parseStr -> Analysis::analysisStr。analysisStr
方法就幹了兩件事:json
analysisStr
方法的最後調用了抽象方法getResult
,用於對分詞DAG的再處理;全部的分詞類均重寫這個方法。爲了便於理解ToAnalysis分詞,我用Scala 2.11重寫了:數組
import java.util import org.ansj.domain.{Result, Term} import org.ansj.recognition.arrimpl.{AsianPersonRecognition, ForeignPersonRecognition, NumRecognition, UserDefineRecognition} import org.ansj.splitWord.Analysis import org.ansj.util.TermUtil.InsertTermType import org.ansj.util.{Graph, NameFix} /** * @author rain */ object ToAnalysis extends Analysis { def parse(sentence: String): Result = { parseStr(sentence) } override def getResult(graph: Graph): util.List[Term] = { // bigram分詞 graph.walkPath() // 數字發現 if (isNumRecognition && graph.hasNum) new NumRecognition().recognition(graph.terms) // 人名識別 if (graph.hasPerson && isNameRecognition) { // 亞洲人名識別 new AsianPersonRecognition().recognition(graph.terms) // 詞黏結後修正打分, 求取最優路徑 graph.walkPathByScore() NameFix.nameAmbiguity(graph.terms) // 外國人名識別 new ForeignPersonRecognition().recognition(graph.terms) graph.walkPathByScore() } // 用戶自定義詞典的識別 new UserDefineRecognition(InsertTermType.SKIP, forests: _*).recognition(graph.terms) graph.rmLittlePath() graph.walkPathByScore() import scala.collection.JavaConversions._ val terms = graph.terms new util.ArrayList[Term](terms.take(terms.length - 1).filter(t => t != null).toSeq) } }
若是沒看懂,不要緊,且看下小節分解。app
分詞DAG是由類org.ansj.util.Graph
實現的,主要的字段terms
爲org.ansj.domain.Term
數組。其中,類Term爲DAG的節點,字段包括:offe
首字符在句子中的位置、name
爲詞,next
具備相同首字符的節點、from
前驅節點、score
打分。仔細看源碼容易發現DAG是用鄰接表(array + linked-list)方式所實現的。dom
Bigram模型對應於一階Markov假設,詞只與其前面一個詞相關,其對應的分詞模型:ide
\begin{equation}
\arg \max \prod_{i=1}^m P(w_{i} | w_{i-1}) = \arg \min - \sum_{i=1}^m \log P(w_{i} | w_{i-1})
\label{eq:bigram}
\end{equation}
對應的詞典爲bigramdict.dic
,格式以下:
始##始@和 11 和@尚 1 和@還沒有 1 世紀@末##末 3 ...
初始狀態\(w_0\)對應於Bigram詞典中的「始##始」,\(w_{m+1}\)對應於「末##末」。Bigram分詞的實現爲Graph::walkPath
函數:
/** * bigram分詞 * @param relationMap 干涉性打分, key爲"first_word \tab second_word", value爲干涉性score值 */ public void walkPath(Map<String, Double> relationMap) { Term term = null; // 給terms[0] bigram打分, 且前驅節點爲root_term "始##始" merger(root, 0, relationMap); // 從第一個字符開始日後更新打分 for (int i = 0; i < terms.length; i++) { term = terms[i]; while (term != null && term.from() != null && term != end) { int to = term.toValue(); // 給terms[to] bigram打分, 更新最小非零score值對應的term爲前驅 merger(term, to, relationMap); term = term.next(); } } // 求解最短路徑 optimalRoot(); }
對條件機率\(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-1})}{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=2079997\)爲訓練語料中的總詞數,\(\lambda = \frac{1}{N}\)。上述平滑處理實現函數爲MathUtil.compuScore
。
求解式子\eqref{eq:bigram}的最優解等價於求解分詞DAG的最短路徑。Ansj採用了相似於Dijkstra的動態規劃算法(做者稱之爲Viterbi算法)來求解最短路徑。記\(G=(V,E)\)爲分詞DAG,其中邊\((u,v) \in E\)知足以下性質:
\[ v > u, \quad \forall \ (u,v) \in E \]
即DAG頂點的序號的順序與圖流向是一致的。這個重要的性質確保了(按Graph.terms[]
的index依次遞增)用動態規劃求解最短路徑的正確性。用\(d_i\)標記源節點到節點\(i\)的最短距離,則有遞推式:
\[ d_i = \min_{(j,i) \in E} \ \{ d_j+ b_{(j,i)} \} \]
其中,\(b_{(j,i)}\)爲兩個相鄰詞的條件機率的負log值-$ \log P(w_{i} | w_{j})$。上述實現請參照源碼Graph::walkPath
與Graph::optimalRoot
。
Ansj支持自定義詞典分詞,是經過詞黏結的方式——若是相鄰的詞黏結後正好爲自定義詞典中的詞,則能夠被分詞——實現的。換句話說,若是自定義的詞未能徹底覆蓋相鄰詞,則不能被分詞。舉個例子:
import scala.collection.JavaConversions._ val sentence = "倒模,替身算什麼?鍾漢良、ab《孤芳不自賞》摳圖來充數" println(ToAnalysis.parse(sentence).mkString(" ")) // 倒/v 模/ng ,/w 替身/n 算/v 什麼/r ?/w 鍾漢良/nr 、/w ab/en 《/w 孤芳/nr 不/d 自賞/v 》/w 摳/v 圖/n 來/v 充數/v DicLibrary.insert(DicLibrary.DEFAULT, "身算") DicLibrary.insert(DicLibrary.DEFAULT, "摳圖") println(ToAnalysis.parse(sentence).mkString(" ")) // 倒/v 模/ng ,/w 替身/n 算/v 什麼/r ?/w 鍾漢良/nr 、/w ab/en 《/w 孤芳/nr 不/d 自賞/v 》/w 摳圖/userDefine 來/v 充數/v
[1] goofyan, ansj詞典加載及簡要分詞過程.