jieba源碼研讀筆記(六) - 分詞之精確模式(使用DAG有向無環圖+動態規劃)
前言
本篇的主題是精確模式(不使用HMM,使用動態規劃),它是在Tokenizer
這個類別中,以calc
及__cut_DAG_NO_HMM
這兩個函數來實現。
jieba文檔上精確模式的介紹:
精确模式,试图将句子最精确地切开,适合文本分析;
采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合
動態規劃的程序寫在calc
函數中,但是因為它會把英數字都切成一個一個的字元,所以我們還需要__cut_DAG_NO_HMM
這個wrapper,用於處理句中有英數字的情況。
calc函數
calc
函數接受sentence
,DAG
及route
三個參數。
其中sentece
是要分詞的句子,DAG
是由get_DAG
函數得到的有向無環圖。
route
在被傳入時是一個空的字典。
在calc
這個函數中會利用sentence
及DAG
來填充route
,在函數結束後,route
記錄的是句中每個詞的範圍,以及他們的機率對數。
def calc(self, sentence, DAG, route):
N = len(sentence)
route[N] = (0, 0)
#self.total在gen_pfdict中計算,代表的是字典中所有詞出現次數的總和
logtotal = log(self.total)
"""
由後往前建構route
"""
for idx in xrange(N - 1, -1, -1):
route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])
route[idx]
是一個tuple。其中第一個元素表示從句中第idx
個字到句尾的切分方式的最大機率對數。第二個元素表示在該切分方式中與sentence[idx]
組成詞的詞尾索引。
我們會使用動態規劃的方式,由後往前填充route
。
為什麼要由後往前填充呢?我們可以從代碼中看到,在計算route[idx]
時會利用到route[x+1]
(x大於等於idx)的值。因為在計算前面的內容時會用到後面的內容,使用這種順序來填充,就能讓我們在一次循環裡完成整個過程。
接著開始來研讀代碼:
-
for x in DAG[idx]
:
DAG
這個字典記錄的是sentence
中可以成詞的範圍。
它的鍵代表詞首索引,值則代表以該鍵為詞首的所有詞的詞尾索引。
在上面的代碼塊中,idx
就是詞首索引,DAG[idx]
是所有以sentence[idx]
為詞首的詞的詞尾索引。x
則是DAG[idx]
中的單個元素。所以sentence[idx:x + 1]
就表示DAG裡存的詞。 -
self.FREQ.get(sentence[idx:x + 1]) or 1
:
這一句用到了dict.get()
,它具有以下特性:D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.
所以
self.FREQ.get(sentence[idx:x + 1]) or 1
表示如果sentence[idx:x + 1]
這個字段不存在FREQ
中,或者它的詞頻為0,則以1取代,這樣在經過log後的結果便是0。 -
logtotal
:字典中所有詞出現次數的總和。 -
route[i]
是一個tuple。tuple裡第一個元素代表的是sentence[i:]
(從當前字元到句子末尾的字段)最有可能出現的切分方式的機率對數。第二個元素則是在該切分方式中,包含sentence[i]
的詞的詞尾索引。 -
log(self.FREQ.get(sentence[idx:x + 1]) or 1) - logtotal + route[x + 1][0]
:
現在我們可以來看看這則算式代表的是什麼。第一項是句中第idx
個字到第x
個字的詞頻對數。將第一項與第二項相加,相當於把第一項正規化,得到的是機率對數。第三項是句中第x+1
個字到句尾最大切分組合的機率對數。所以三項總和就是"如果新增x
為切分點,這種組合的機率對數"。 -
上述機率對數會與
x
(切分點)組成一個tuple。list comprehension結束後會得到一個list of tuple。 -
list of tuple作為
max
的參數。但是tuple要如何比較大小呢?根據How does tuple comparison work in Python?,tuple的比較方式是按元素順序比較,所以這裡主要是以詞組的機率對數作為比較對象。 -
max會找出
sentence[idx:]
的最大切分組合的機率對數以及對應的切分點。這也是route[idx]
被賦予的值。 -
for idx in xrange(N - 1, -1, -1):
:
由後往前填滿route
,依序找出由sentence[idx]
到句尾的最大切分組合的機率對數。
讓我們來模擬一次calc
在看到人工智能
這個句子時的計算過程:
-
for循環的第一次迭代:
idx
是3,關注的是能
這個字。
它的log(self.FREQ.get(sentence[idx:x + 1]) or 1)
(以下簡稱第一項)是字典裡它的詞頻的對數。route[x + 1][0]=route[4][0]
(以下簡稱第三項)則被初始化為0。所以三項之和就是log(能
的詞頻*1/所有詞出現次數之和),這代表將sentence[3:]
切成能
字的機率對數。 -
第二次迭代:
idx
是2,關注的是智
這個字。
這時候字典中共有兩個相關的詞,分別是單字詞智
及智能
這個詞。首先看
智
,也就是把智能
切成智
跟能
兩個字的切分組合。它的三項總和可以重新表示成log(self.FREQ.get(sentence[2:2+1])) - logtatal + log(self.FREQ.get(sentence[3:3+1])) - logtotal + 0
,也就是智
這個單字詞的機率對數乘上能
的機率對數。再來看
智能
,它代表的是不切分sentence[2:]
,而把它當成一個詞。它的三項總和可以重新表示成log(self.FREQ.get(sentence[2:3+1])) - logtatal + 0
,即智能
一詞的機率對數。從下表中我們可以看出,
智能
的三項總和大於智
的三項總和,所以最後會將route[2]
更新為(智能的機率對數, 能的索引)
。也就是說,比起將智能
兩字分開,分詞算法更偏好將兩字當成一個詞。 -
第三次迭代:
idx
是3,關注的是工
這個字。這時字典裡只有工
這一個相關的詞。
它的三項總和代表的是:工
的機率對數乘上智能
的機率對數。 -
第四次迭代:
idx
為4,關注人
。字典裡有人
,人工
,人工智能
這幾個相關的詞。
它們分別對應人|工|智能
,人工|智能
,人工智能
這三種分詞方式。
從他們的三項總和看來,算法最偏好將句子切成人工智能
這一個詞。
注:這種方式會偏好切出長詞,具體情形可以參考词频高却没有被切出来這個issue。
詞首索引 idx | 詞尾索引 x | 詞 sentence [詞首索引: 詞尾索引 + 1] | 詞頻 self.FREQ.get(詞) | 詞頻對數: log(詞頻 or 1) | 負的總詞頻對數 -logtotal | 從詞尾的 後一字到句尾(sentence[x+1:]) 的機率對數,即route[x+1][0] | 三項總和: 從該字到句尾(sentence[idx:]) 的機率對數 | route [詞首索引] |
---|---|---|---|---|---|---|---|---|
3 | 3 | 能 | 93096 | 11.44 | -17.91 | 0 | -6.47 | (-6.47, 3) |
2 | 2 | 智 | 3359 | 8.11 | -17.91 | -6.47 | -16.26 | |
2 | 3 | 智能 | 778 | 6.65 | -17.91 | 0 | -11.25 | (-11.25, 3) |
1 | 1 | 工 | 3771 | 8.23 | -17.91 | -11.25 | -20.93 | (-20.93, 1) |
0 | 0 | 人 | 313209 | 12.65 | -17.91 | -20.93 | -26.18 | |
0 | 1 | 人工 | 2816 | 7.94 | -17.91 | -11.25 | -21.22 | |
0 | 3 | 人工智能 | 151 | 5.01 | -17.91 | 0 | -12.89 | (-12.89, 3) |
__cut_DAG_NO_HMM函數
這個函數利用calc
函數算出route
,因為英數字並不存在字典中,所以route
裡的英數字會被切成一個一個的字元。
在__cut_DAG_NO_HMM
中,會由前往後掃描route
裡的內容。使用re_eng
來找出句中的英數字,如果碰到了,就把它們放到buf
裡,碰到下個中文字時再輸出。
注:關於re_eng
,可參考jieba源碼研讀筆記(四) - 正則表達式。
def __cut_DAG_NO_HMM(self, sentence):
DAG = self.get_DAG(sentence)
route = {}
#注意因為字典裡沒有記錄英數字的詞頻,所以會把它們切成一個一個的字元
self.calc(sentence, DAG, route)
x = 0
N = len(sentence)
buf = '' #用來暫存英數字
# 由前往後
while x < N:
#route[x][1]表示的是"以sentence[x]開頭,擁有最大概率的詞的詞尾的索引"
y = route[x][1] + 1
#sentence[x:y]:從句子中擷取出該詞
l_word = sentence[x:y]
#如果是英數字且長度為1
if re_eng.match(l_word) and len(l_word) == 1:
buf += l_word
#接下來從y(當前詞的下一個字元)開始搜尋
x = y
else:
# 之前收集的字元被集中到buf裡,這裡輸出並清出buf
if buf:
yield buf
buf = ''
# 把buf清空後(如果有的話),才輸山l_word
yield l_word
#接下來從y(當前詞的下一個字元)開始搜尋
x = y
# 最後一次的清空
if buf:
yield buf
buf = ''
測試:
首先看一下DAG
的內容:
tokenizer = jieba.Tokenizer()
route = {}
sentence = "Andy今年13岁了"
DAG = tokenizer.get_DAG(sentence)
print(DAG)
"""
{0: [0],
1: [1],
2: [2],
3: [3],
4: [4, 5],
5: [5],
6: [6],
7: [7],
8: [8],
9: [9]}
"""
可視化如下:
再來看看使用動態規劃分詞的結果:
sentence = "Andy今年13岁了" #N=10
jieba.lcut(sentence, cut_all=False, HMM=False)
x | y | l_word | buf |
---|---|---|---|
0 | 1 | A | A |
1 | 2 | n | An |
2 | 3 | d | And |
3 | 4 | y | Andy |
4 | 6 | 今年 | |
6 | 7 | 1 | 1 |
7 | 8 | 3 | 13 |
8 | 9 | 岁 | |
9 | 10 | 了 |
buf
是儲存英文及數字的暫存空間,從上表可以看出,在Andy或13成詞以前,它們都被存在buf
裡。
sentence = "Andy今年13岁了"
DAG = jieba.get_DAG(sentence)
route = {}
jieba.calc(sentence, DAG, route)
print(route)
"""
{10: (0, 0),
9: (-4.219754899017666, 9),
8: (-12.762341291514282, 8),
7: (-30.6738944192695, 7),
6: (-48.58544754702472, 6),
5: (-54.073565153894585, 5),
4: (-57.2907687308483, 5),
3: (-75.20232185860351, 3),
2: (-93.11387498635872, 2),
1: (-111.02542811411394, 1),
0: (-128.93698124186915, 0)}
"""
參考連結
jieba文檔
词频高却没有被切出来
How does tuple comparison work in Python?
jieba源碼研讀筆記(四) - 正則表達式