jieba分詞原始碼閱讀
jieba是一個開源的中文分詞庫,這幾天看了下原始碼,就做下記錄。
下載jieba後,tree得到主要部分的目錄樹結構如下:
├── jieba │ ├── analyse │ │ ├── analyzer.py │ │ ├── idf.txt │ │ ├── __init__.py │ │ ├── textrank.py │ │ └── tfidf.py │ ├── _compat.py │ ├── dict.txt │ ├── finalseg │ │ ├── __init__.py │ │ ├── prob_emit.p │ │ ├── prob_emit.py │ │ ├── prob_start.p │ │ ├── prob_start.py │ │ ├── prob_trans.p │ │ └── prob_trans.py │ ├── __init__.py │ ├── __main__.py │ └── posseg │ ├── char_state_tab.p │ ├── char_state_tab.py │ ├── __init__.py │ ├── prob_emit.p │ ├── prob_emit.py │ ├── prob_start.p │ ├── prob_start.py │ ├── prob_trans.p │ ├── prob_trans.py │ └── viterbi.py ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.py └── test
analyse目錄下是幾個提取文字關鍵詞的演算法實現。
dict.txt是總的詞庫,每行記錄了一個詞和這個詞的詞頻及詞性。
__init__.py是jieba的主要入口
finalseg是如果使用hmm,那麼在初步分詞之後還要呼叫這裡的程式碼,主要是對hmm的實現。
然後介紹下主要介面__init__.py中的幾個函式:
def gen_pfdict(self, f): lfreq = {} ltotal = 0 f_name = resolve_filename(f) for lineno, line in enumerate(f, 1): try: 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] 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
gen_pfdict載入dict.txt生成字典樹,lfreq儲存dict.txt每個詞出現了多少次,以及每個詞的所有字首,字首的頻數置為0,ltotal是所有詞出現的總次數。得到的字典樹存在self.FREQ中。例如對於詞"不拘一格",在字典樹lfreq中是這樣的{"不" : 0, "不拘" : ,"不拘一" , 0, "不拘一格" : freq} 其中的freq就是在dict.txt中記錄的詞頻。
def get_DAG(self, sentence): self.check_initialized() DAG = {} N = len(sentence) for k in xrange(N): tmplist = [] i = k frag = sentence[k] while i < N and frag in self.FREQ: if self.FREQ[frag]: tmplist.append(i) i += 1 frag = sentence[k:i + 1] if not tmplist: tmplist.append(k) DAG[k] = tmplist return DAG
FREQ是根據dict.txt生成的字典樹,get_DAG函式是根據FREQ對於每個句子sentence生成一個有向無環圖,圖資訊存在字典DAG中,其中DAG[pos]是一個列表[a, b, c...],pos從0到len(sentence) - 1,表示sentence[pos : a + 1],sentence[pos, b + 1]...這些單詞出現在了dict中。
例如以“但也並不是那麼出乎意料或難以置信”這句話作為輸入,生成的DAG如下,簡單的講就是把句子中詞的位置標記出來
0 [0] 但
1 [1] 也
2 [2] 並
3 [3, 4] 不是
4 [4] 是
5 [5, 6] 那麼
6 [6] 麼
7 [7, 8, 10] 出乎意料
8 [8] 乎
9 [9, 10] 意料
10 [10] 料
11 [11] 或
12 [12, 13, 15] 難以置信
13 [13] 以
14 [14, 15] 置信
15 [15] 信
接下來就是對句子的切分,即jieba.cut。具體的分詞流程概括起來如下:
1. 給定待分詞的句子, 使用正則(re_han)獲取匹配的中文字元(和英文字元)切分成的短語列表;
2. 利用get_DAG(sentence)函式獲得待切分句子的DAG,首先檢測(check_initialized)程序是否已經載入詞庫,若未初始化詞庫則呼叫initialize函式進行初始化,initialize中判斷有無已經快取的字首詞典cache_file檔案,若有相應的cache檔案則直接使用 marshal.load 方法載入字首詞典,若無則通過gen_pfdict對指定的詞庫dict.txt進行計算生成字首詞典,到jieba程序的初始化工作完成後就呼叫get_DAG獲得句子的DAG;
3. 根據cut_block指定具體的方法(__cut_all,__cut_DAG,__cut_DAG_NO_HMM)對每個短語使用DAG進行分詞 ,如cut_block=__cut_DAG時則使用DAG(查字典)和動態規劃, 得到最大概率路徑, 對DAG中那些沒有在字典中查到的字, 組合成一個新的片段短語, 使用HMM模型進行分詞, 也就是作者說的識別新詞, 即識別字典外的新詞;
4. 使用python的yield 語法生成一個詞語生成器, 逐詞語返回;
def __cut_all(self, sentence):
dag = self.get_DAG(sentence)
old_j = -1
for k, L in iteritems(dag):
if len(L) == 1 and k > old_j:
yield sentence[k:L[0] + 1]
old_j = L[0]
else:
for j in L:
if j > k:
yield sentence[k:j + 1]
old_j = j
__cut_all是全模式切分,其實就是把DAG中的所有組合顯示出來。對於上個句子得到的結果如下:但/也/並/不是/那麼/出乎/出乎意料/意料/或/難以/難以置信/置信
def calc(self, sentence, DAG, route):
N = len(sentence)
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])
calc函式根據出現的概率來計算最可能的切詞結果,其中單詞A的概率為A的出現次數除以所有單詞出現的總次數。通過dp計算最大概率的切詞方式,route[i][0]表示sentence[i : len]的最大概率,route[i][1]表示sentence[i : len]這個子串的第一個切分位置在哪。
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:
y = route[x][1] + 1
l_word = sentence[x:y]
if re_eng.match(l_word) and len(l_word) == 1:
buf += l_word
x = y
else:
if buf:
yield buf
buf = ''
yield l_word
x = y
if buf:
yield buf
buf = ''
__cut_DAG_NO_HMM是不使用hmm的精確模式,首先呼叫calc,得到的route[i][1]中儲存的是切分的位置資訊,然後遍歷輸出切分方式。
def __cut_DAG(self, sentence):
DAG = self.get_DAG(sentence)
route = {}
self.calc(sentence, DAG, route)
x = 0
buf = ''
N = len(sentence)
while x < N:
y = route[x][1] + 1
l_word = sentence[x:y]
if y - x == 1:
buf += l_word
else:
if buf:
if len(buf) == 1:
yield buf
buf = ''
else:
if not self.FREQ.get(buf):
recognized = finalseg.cut(buf)
for t in recognized:
yield t
else:
for elem in buf:
yield elem
buf = ''
yield l_word
x = y
if buf:
if len(buf) == 1:
yield buf
elif not self.FREQ.get(buf):
recognized = finalseg.cut(buf)
for t in recognized:
yield t
else:
for elem in buf:
yield elem
__cut_DAG同時使用最大概率路徑和hmm,對於利用動態規劃計算出的最大概率切分後,用buf將連續的單字收集以及未登入詞收集起來,再呼叫finalseg.cut利用hmm進行分詞。
然後是final_seg的__init__.py
def viterbi(obs, states, start_p, trans_p, emit_p):
V = [{}] # tabular
path = {}
for y in states: # init
V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
path[y] = [y]
for t in xrange(1, len(obs)):
V.append({})
newpath = {}
for y in states:
em_p = emit_p[y].get(obs[t], MIN_FLOAT)
(prob, state) = max(
[(V[t - 1][y0] + trans_p[y0].get(y, MIN_FLOAT) + em_p, y0) for y0 in PrevStatus[y]])
V[t][y] = prob
newpath[y] = path[state] + [y]
path = newpath
(prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')
return (prob, path[state])
HMM中的viterbi演算法的實現函式,是viterbi演算法給定了模型引數和觀察序列之後求隱藏狀態序列,其中對於分詞,觀察序列就是句子本身,而隱藏序列就是一個由{B, M, E, S}組成的序列,B表示詞的開始,M表示詞的中間,E表示詞的結尾,S表示單字成詞。函式的輸入引數中obs是輸入的觀察序列,即句子本身,states表示隱藏狀態的集合,即{B, M, E, S},start_p表示第一個字分別處於{B, M, E, S}這幾個隱藏狀態的概率,trans_p是狀態轉移矩陣,記錄了隱藏狀態之間的轉化概率,例如trans_p[‘B’][‘E’]代表的含義就是從狀態B轉移到狀態E的概率,emit_p是發射概率矩陣,表示從一個隱藏狀態轉移到一個觀察狀態的概率,例如P[‘B’][‘\u4e00’]代表的含義就是’B’狀態下觀測的字為’\u4e00’(對應的漢字為’一’)的概率大小。
V是一個列表,V[i][j]表示對於子觀察序列obs[0 ~ i],在第i個位置時隱藏狀態為j的最大概率,其實就是個簡單dp。
path是記錄了狀態轉移的路徑。
def __cut(sentence):
global emit_P
prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
begin, nexti = 0, 0
# print pos_list, sentence
for i, char in enumerate(sentence):
pos = pos_list[i]
if pos == 'B':
begin = i
elif pos == 'E':
yield sentence[begin:i + 1]
nexti = i + 1
elif pos == 'S':
yield char
nexti = i + 1
if nexti < len(sentence):
yield sentence[nexti:]
通過呼叫viterbi演算法得到概率和path之後,對sentence進行分詞。
參考:
http://www.cnblogs.com/lrysjtu/p/4529325.html
http://blog.csdn.net/daniel_ustc/article/details/48195287