【開源中文分詞工具探析】系列:
- 開源中文分詞工具探析(一):ICTCLAS (NLPIR)
- 開源中文分詞工具探析(二):Jieba
- 開源中文分詞工具探析(三):Ansj
- 開源中文分詞工具探析(四):THULAC
- 開源中文分詞工具探析(五):FNLP
- 開源中文分詞工具探析(六):Stanford CoreNLP
- 開源中文分詞工具探析(七):LTP
1. 前言
Jieba是由fxsjy大神開源的一款中文分詞工具,一款屬於工業界的分詞工具——模型易用簡單、代碼清晰可讀,推薦有志學習NLP或Python的讀一下源碼。與采用分詞模型Bigram + HMM 的ICTCLAS 相類似,Jieba采用的是Unigram + HMM。Unigram假設每個詞相互獨立,則分詞組合的聯合概率:
\begin{equation}
P(c_1^n) = P(w_1^m) = \prod_i P(w_{i})
\label{eq:unigram}
\end{equation}
在Unigram分詞后用HMM做未登錄詞識別,以修正分詞結果。
2. 分解
以下源碼分析基於jieba-0.36版本。
分詞模式
Jieba支持的三種分詞模式:全模式、精確模式、搜索引擎模式。分詞函數jieba.cut()
中有兩個模式調節參數cut_all
、HMM
,分別表示是否采用全模式(若否,則為精確模式)、是否使用HMM。這兩個參數的組合對應於如下分詞模式:
cut_all=True, HMM=_
對應於全模式,即所有在詞典中出現的詞都會被切分出來,實現函數為__cut_all
;cut_all=False, HMM=False
對應於精確模式且不使用HMM;按Unigram語法模型找出聯合概率最大的分詞組合,實現函數為__cut_DAG
;cut_all=False, HMM=True
對應於精確模式且使用HMM;在聯合概率最大的分詞組合的基礎上,HMM識別未登錄詞,實現函數為__cut_DAG_NO_HMM
。
另一個分詞函數jieba.cut_for_search()
對應於搜索引擎模式,對長詞進行更細粒度的切分:
def cut_for_search(sentence, HMM=True):
"""
Finer segmentation for search engines.
"""
words = cut(sentence, HMM=HMM)
for w in words:
if len(w) > 2:
for i in xrange(len(w) - 1):
gram2 = w[i:i + 2]
if FREQ.get(gram2):
yield gram2
if len(w) > 3:
for i in xrange(len(w) - 2):
gram3 = w[i:i + 3]
if FREQ.get(gram3):
yield gram3
yield w
從上面的代碼中,可以看出:對於長度大於2的詞,依次循環滾動取出在前綴詞典中的二元子詞;對於長度大於3的詞,依次循環滾動取出在前綴詞典中的三元子詞。至於前綴詞典是什么,且看下一小節。
詞典檢索
為了檢索詞典中的詞時,一般采取的思路是構建Trie樹——利用了字符串的公共前綴,以縮短查詢時間。作者當時也是這樣做的,用了兩個dict,trie dict用於保存trie樹,lfreq dict用於存儲詞 -> 詞頻
:
def gen_trie(f_name):
lfreq = {}
trie = {}
ltotal = 0.0
with open(f_name, 'rb') as f:
lineno = 0
for line in f.read().rstrip().decode('utf-8').split('\n'):
lineno += 1
try:
word,freq,_ = line.split(' ')
freq = float(freq)
lfreq[word] = freq
ltotal+=freq
p = trie
for c in word:
if c not in p:
p[c] ={}
p = p[c]
p['']='' #ending flag
except ValueError, e:
logger.debug('%s at line %s %s' % (f_name, lineno, line))
raise ValueError, e
return trie, lfreq, ltotal
何不將前綴信息也放到lfreq中呢?Pull request 187中便有人提出來並實現了,還給lfreq取了個好聽的名字“前綴字典”。
分詞DAG
一個句子所有的分詞組合構成了有向無環圖(Directed Acyclic Graph, DAG)\(G=(V,E)\),一個詞對應與DAG中的的一條邊\(e \in E\),邊的起點為詞的初始字符,邊的結點為詞的結束字符。jieba.get_DAG()
函數實現切分句子得到DAG:
sentence = "印度報業托拉斯"
dag = jieba.get_DAG(sentence)
# {0: [0, 1, 6], 1: [1], 2: [2, 3], 3: [3], 4: [4, 5, 6], 5: [5, 6], 6: [6]}
DAG是用dict表示的,key為邊的起點,value為邊的終點集合,比如:上述例子中4 -> 6表示詞“托拉斯”。
求解Unigram模型
對於Unigram模型下的聯合概率\eqref{eq:unigram}求對數:
將詞頻的log值作為圖\(G\)邊的權值,從圖論的角度出發,將最大概率問題變成了最大路徑問題;是不是與ICTCLAS的處理思路有異曲同工之妙。在上面的DAG中,節點0表示源節點,節點m-1表示尾節點;則\(V=\{0, \cdots , m-1 \}\),且DAG滿足如下性質:
即DAG頂點的序號的順序與圖流向是一致的。Jieba用動態規划(DP)來求解最大路徑問題,假設用\(d_i\)標記源節點到節點i的最大路徑的值,則有
其中,\(w(j,i)\)表示詞\(c_j^i\)的詞頻log值,\(w(i,i)\)表示字符\(c_i\)獨立成詞的詞頻log值。在求解上述式子時,需要知道所有節點i的前驅節點j;然后DAG中只有后繼結點list。在這里,作者巧妙地用到了一個trick——從尾節點m-1往前推算的最優解等價於從源節點0往后推算的。那么,用\(r_i\)標記節點i到尾節點的最大路徑的值,則
def calc(sentence, DAG, route):
N = len(sentence)
route[N] = (0, 0)
logtotal = log(total)
for idx in xrange(N - 1, -1, -1):
# r[i] = max { log P(c_{i}^{x}) + r(x)}
route[idx] = max((log(FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])
關於HMM識別未登錄詞,可看我之前寫的一篇《【中文分詞】隱馬爾可夫模型HMM》. 至此,Jieba完成了一個非常漂亮實用的分詞模型。