開源中文分詞工具探析(三):Ansj

Ansj是由孫健(ansjsun)開源的一箇中文分詞器,爲ICTLAS的Java版本,也採用了Bigram + HMM分詞模型(可參考我以前寫的文章):在Bigram分詞的基礎上,識別未登陸詞,以提升分詞準確度。雖然基本分詞原理與ICTLAS的同樣,可是Ansj作了一些工程上的優化,好比:用DAT高效地實現檢索詞典、鄰接表實現分詞DAG、支持自定義詞典與自定義消歧義規則等。html


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

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

1. 前言

Ansj支持多種分詞方式,其中ToAnalysis爲店長推薦款:git

它在易用性,穩定性.準確性.以及分詞效率上.都取得了一個不錯的平衡.若是你初次嘗試Ansj若是你想開箱即用.那麼就用這個分詞方式是不會錯的.github

所以,本文將主要分析ToAnalysis的分詞實現(基於ansj-5.1.0版本)。算法

ToAnalysis繼承自抽象類org.ansj.splitWord.Analysis,重寫了抽象方法getResult。其中,分詞方法的依賴關係:ToAnalysis::parse -> Analysis::parseStr -> Analysis::analysisStr。analysisStr方法就幹了兩件事:json

  1. 按照消歧義規則分詞;
  2. 在此基礎上,按照核心詞典分詞;

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

2. 分解

分詞DAG

分詞DAG是由類org.ansj.util.Graph實現的,主要的字段termsorg.ansj.domain.Term數組。其中,類Term爲DAG的節點,字段包括:offe首字符在句子中的位置、name爲詞,next具備相同首字符的節點、from前驅節點、score打分。仔細看源碼容易發現DAG是用鄰接表(array + linked-list)方式所實現的。dom

Bigram模型

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::walkPathGraph::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

3. 參考資料

[1] goofyan, ansj詞典加載及簡要分詞過程.

相關文章
相關標籤/搜索