作者:zhbzz2007 出處:http://www.cnblogs.com/zhbzz2007 歡迎轉載,也請保留這段聲明。謝謝!
1 簡介
jieba分詞主要是基於統計詞典,構造一個前綴詞典;然后利用前綴詞典對輸入句子進行切分,得到所有的切分可能,根據切分位置,構造一個有向無環圖;通過動態規划算法,計算得到最大概率路徑,也就得到了最終的切分形式。
2 實例講解
以“去北京大學玩”為例,作為待分詞的輸入文本。
離線統計的詞典形式如下,每一行有三列,第一列是詞,第二列是詞頻,第三列是詞性。
...
北京大學 2053 nt
大學 20025 n
去 123402 v
玩 4207 v
北京 34488 ns
北 17860 ns
京 6583 ns
大 144099 a
學 17482 n
...
2.1 前綴詞典構建
首先是基於統計詞典構造前綴詞典,如統計詞典中的詞“北京大學”的前綴分別是“北”、“北京”、“北京大”;詞“大學”的前綴是“大”。統計詞典中所有的詞形成的前綴詞典如下所示,你也許會注意到“北京大”作為“北京大學”的前綴,但是它的詞頻卻為0,這是為了便於后面有向無環圖的構建。
...
北京大學 2053
北京大 0
大學 20025
去 123402
玩 4207
北京 34488
北 17860
京 6583
大 144099
學 17482
...
2.2 有向無環圖構建
然后基於前綴詞典,對輸入文本進行切分,對於“去”,沒有前綴,那么就只有一種划分方式;對於“北”,則有“北”、“北京”、“北京大學”三種划分方式;對於“京”,也只有一種划分方式;對於“大”,則有“大”、“大學”兩種划分方式,依次類推,可以得到每個字開始的前綴詞的划分方式。
在jieba分詞中,對每個字都是通過在文本中的位置來標記的,因此可以構建一個以位置為key,相應划分的末尾位置構成的列表為value的映射,如下所示,
0: [0]
1: [1,2,4]
2: [2]
3: [3,4]
4: [4]
5: [5]
對於0: [0],表示位置0對應的詞,就是0 ~ 0,就是“去”;對於1: [1,2,4],表示位置1開始,在1,2,4位置都是詞,就是1 ~ 1,1 ~ 2,1 ~ 4,即“北”,“北京”,“北京大學”這三個詞。
對於每一種划分,都將相應的首尾位置相連,例如,對於位置1,可以將它與位置1、位置2、位置4相連接,最終構成一個有向無環圖,如下所示,
2.3 最大概率路徑計算
在得到所有可能的切分方式構成的有向無環圖后,我們發現從起點到終點存在多條路徑,多條路徑也就意味着存在多種分詞結果,例如,
# 路徑1
0 -> 1 -> 2 -> 3 -> 4 -> 5
# 分詞結果1
去 / 北 / 京 / 大 / 學 / 玩
# 路徑2
0 -> 1 , 2 -> 3 -> 4 -> 5
# 分詞結果2
去 / 北京 / 大 / 學 / 玩
# 路徑3
0 -> 1 , 2 -> 3 , 4 -> 5
# 分詞結果3
去 / 北京 / 大學 / 玩
# 路徑4
0 -> 1 , 2 , 3 , 4 -> 5
# 分詞結果4
去 / 北京大學 / 玩
...
因此,我們需要計算最大概率路徑,也即按照這種方式切分后的分詞結果的概率最大。在計算最大概率路徑時,jieba分詞采用從后往前這種方式進行計算。為什么采用從后往前這種方式計算呢?因為,我們這個有向無環圖的方向是從前向后指向,對於一個節點,我們只知道這個節點會指向后面哪些節點,但是我們很難直接知道有哪些前面的節點會指向這個節點。
在采用動態規划計算最大概率路徑時,每到達一個節點,它前面的節點到終點的最大路徑概率已經計算出來。
3 源碼分析
3.1 算法流程
jieba.__init__.py中實現了jieba分詞接口函數cut(self, sentence, cut_all=False, HMM=True)。
jieba分詞接口主入口函數,會首先將輸入文本解碼為Unicode編碼,然后根據入參,選擇不同的切分方式,本文主要以精確模式進行講解,因此cut_all和HMM這兩個入參均為默認值;
切分方式選擇,
re_han = re_han_default
re_skip = re_skip_default
塊切分方式選擇,
cut_block = self.__cut_DAG
函數__cut_DAG(self, sentence)首先構建前綴詞典,其次構建有向無環圖,然后計算最大概率路徑,最后基於最大概率路徑進行分詞,如果遇到未登錄詞,則調用HMM模型進行切分。本文主要涉及前三個部分,基於HMM的分詞方法則在下一文章中詳細說明。
3.2 前綴詞典構建
get_DAG(self, sentence)函數會首先檢查系統是否初始化,如果沒有初始化,則進行初始化。在初始化的過程中,會構建前綴詞典。
構建前綴詞典的入口函數是gen_pfdict(self, f),解析離線統計詞典文本文件,每一行分別對應着詞、詞頻、詞性,將詞和詞頻提取出來,以詞為key,以詞頻為value,加入到前綴詞典中。對於每個詞,再分別獲取它的前綴詞,如果前綴詞已經存在於前綴詞典中,則不處理;如果該前綴詞不在前綴詞典中,則將其詞頻置為0,便於后續構建有向無環圖。
jieba分詞中gen_pfdict函數實現如下,
# f是離線統計的詞典文件句柄
def gen_pfdict(self, f):
# 初始化前綴詞典
lfreq = {}
ltotal = 0
f_name = resolve_filename(f)
for lineno, line in enumerate(f, 1):
try:
# 解析離線詞典文本文件,離線詞典文件格式如第2章中所示
line = line.strip().decode('utf-8')
# 詞和對應的詞頻
word, freq = line.split(' ')[:2]
freq = int(freq)
lfreq[word] = freq
ltotal += freq
# 獲取該詞所有的前綴詞
for ch in xrange(len(word)):
wfrag = word[:ch + 1]
# 如果某前綴詞不在前綴詞典中,則將對應詞頻設置為0,
# 如第2章中的例子“北京大”
if wfrag not in lfreq:
lfreq[wfrag] = 0
except ValueError:
raise ValueError(
'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
f.close()
return lfreq, ltotal
為什么jieba沒有使用trie樹作為前綴詞典存儲的數據結構?
參考jieba中的issue--不用Trie,減少內存加快速度;優化代碼細節 #187,本處直接引用該issue的comment,如下,
對於get_DAG()函數來說,用Trie數據結構,特別是在Python環境,內存使用量過大。經實驗,可構造一個前綴集合解決問題。
該集合儲存詞語及其前綴,如set(['數', '數據', '數據結', '數據結構'])。在句子中按字正向查找詞語,在前綴列表中就繼續查找,直到不在前綴列表中或超出句子范圍。大約比原詞庫增加40%詞條。
該版本通過各項測試,與原版本分詞結果相同。
測試:一本5.7M的小說,用默認字典,64位Ubuntu,Python 2.7.6。
Trie:第一次加載2.8秒,緩存加載1.1秒;內存277.4MB,平均速率724kB/s;
前綴字典:第一次加載2.1秒,緩存加載0.4秒;內存99.0MB,平均速率781kB/s;
此方法解決純Python中Trie空間效率低下的問題。
同時改善了一些代碼的細節,遵循PEP8的格式,優化了幾個邏輯判斷。
3.2 有向無環圖構建
有向無環圖,directed acyclic graphs,簡稱DAG,是一種圖的數據結構,顧名思義,就是沒有環的有向圖。
DAG在分詞中的應用很廣,無論是最大概率路徑,還是其它做法,DAG都廣泛存在於分詞中。因為DAG本身也是有向圖,所以用鄰接矩陣來表示是可行的,但是jieba采用了Python的dict結構,可以更方便的表示DAG。最終的DAG是以{k : [k , j , ..] , m : [m , p , q] , ...}的字典結構存儲,其中k和m為詞在文本sentence中的位置,k對應的列表存放的是文本中以k開始且詞sentence[k: j + 1]在前綴詞典中的 以k開始j結尾的詞的列表,即列表存放的是sentence中以k開始的可能的詞語的結束位置,這樣通過查找前綴詞典就可以得到詞。
get_DAG(self, sentence)函數進行對系統初始化完畢后,會構建有向無環圖。
從前往后依次遍歷文本的每個位置,對於位置k,首先形成一個片段,這個片段只包含位置k的字,然后就判斷該片段是否在前綴詞典中,
-
如果這個片段在前綴詞典中,
1.1 如果詞頻大於0,就將這個位置i追加到以k為key的一個列表中;
1.2 如果詞頻等於0,如同第2章中提到的“北京大”,則表明前綴詞典存在這個前綴,但是統計詞典並沒有這個詞,繼續循環;
-
如果這個片段不在前綴詞典中,則表明這個片段已經超出統計詞典中該詞的范圍,則終止循環;
-
然后該位置加1,然后就形成一個新的片段,該片段在文本的索引為[k:i+1],繼續判斷這個片段是否在前綴詞典中。
jieba分詞中get_DAG函數實現如下,
# 有向無環圖構建主函數
def get_DAG(self, sentence):
# 檢查系統是否已經初始化
self.check_initialized()
# DAG存儲向無環圖的數據,數據結構是dict
DAG = {}
N = len(sentence)
# 依次遍歷文本中的每個位置
for k in xrange(N):
tmplist = []
i = k
# 位置k形成的片段
frag = sentence[k]
# 判斷片段是否在前綴詞典中
# 如果片段不在前綴詞典中,則跳出本循環
# 也即該片段已經超出統計詞典中該詞的長度
while i < N and frag in self.FREQ:
# 如果該片段的詞頻大於0
# 將該片段加入到有向無環圖中
# 否則,繼續循環
if self.FREQ[frag]:
tmplist.append(i)
# 片段末尾位置加1
i += 1
# 新的片段較舊的片段右邊新增一個字
frag = sentence[k:i + 1]
if not tmplist:
tmplist.append(k)
DAG[k] = tmplist
return DAG
以“去北京大學玩”為例,最終形成的有向無環圖為,
{0: [0], 1: [1,2,4], 2: [2], 3: [3,4], 4: [4], 5: [5]}
3.3 最大概率路徑計算
3.2章節中構建出的有向無環圖DAG的每個節點,都是帶權的,對於在前綴詞典里面的詞語,其權重就是它的詞頻;我們想要求得route = (w1,w2,w3,...,wn),使得 \(\sum weight(w_{i})\) 最大。
如果需要使用動態規划求解,需要滿足兩個條件,
- 重復子問題
- 最優子結構
我們來分析一下最大概率路徑問題,是否滿足動態規划的兩個條件。
重復子問題
對於節點wi和其可能存在的多個后繼節點Wj和Wk,
任意通過Wi到達Wj的路徑的權重 = 該路徑通過Wi的路徑權重 + Wj的權重,也即{Ri -> j} = {Ri + weight(j)}
任意通過Wi到達Wk的路徑的權重 = 該路徑通過Wi的路徑權重 + Wk的權重,也即{Ri -> k} = {Ri + weight(k)}
即對於擁有公共前驅節點Wi的節點Wj和Wk,需要重復計算達到Wi的路徑的概率。
最優子結構
對於整個句子的最優路徑Rmax和一個末端節點Wx,對於其可能存在的多個前驅Wi,Wj,Wk...,設到達Wi,Wj,Wk的最大路徑分別是Rmaxi,Rmaxj,Rmaxk,有,
Rmax = max(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)
於是,問題轉化為,求解Rmaxi,Rmaxj,Rmaxk,...等,
組成了最優子結構,子結構里面的最優解是全局的最優解的一部分。
狀態轉移方程為,
Rmax = max{(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)}
jieba分詞中計算最大概率路徑的主函數是calc(self, sentence, DAG, route),函數根據已經構建好的有向無環圖計算最大概率路徑。
函數是一個自底向上的動態規划問題,它從sentence的最后一個字(N-1)開始倒序遍歷sentence的每個字(idx)的方式,計算子句sentence[idx ~ N-1]的概率對數得分。然后將概率對數得分最高的情況以(概率對數,詞語最后一個位置)這樣的元組保存在route中。
函數中,logtotal為構建前綴詞頻時所有的詞頻之和的對數值,這里的計算都是使用概率對數值,可以有效防止下溢問題。
jieba分詞中calc函數實現如下,
def calc(self, sentence, DAG, route):
N = len(sentence)
# 初始化末尾為0
route[N] = (0, 0)
logtotal = log(self.total)
# 從后到前計算
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])