中科院的ICTCLAS,哈工大的ltp,東北大學的NIU Parser是學術界著名的分詞器,我曾淺顯讀過一些ICTCLAS的代碼,然而並不那麼好讀。jieba分詞是python寫成的一個算是工業界的分詞開源庫,其github地址爲:https://github.com/fxsjy/jiebapython
jieba分詞雖然效果上不如ICTCLAS和ltp,可是勝在python編寫,代碼清晰,擴展性好,對jieba有改進的想法能夠很容易的本身寫代碼進行魔改。畢竟這樣說好像本身就有能力改進jieba分詞同樣_(:з」∠)_git
網上諸多關於jieba分詞的分析,多已過期,曾經分析jieba分詞采用trie樹的數據結構云云的文章都已通過時,如今的jieba分詞已經放棄trie樹,採用前綴數組字典的方式存儲詞典。github
本文分析的jieba分詞基於2015年7月
左右的代碼進行,往後jieba若更新,看緣分更新這一系列文章_(:з」∠)_算法
jieba分詞對已收錄詞和未收錄詞都有相應的算法進行處理,其處理的思路很簡單,固然,過於簡單的算法也是制約其召回率的緣由之一。數組
其主要的處理思路以下:數據結構
加載詞典dict.txtapp
從內存的詞典中構建該句子的DAG(有向無環圖)函數
對於詞典中未收錄詞,使用HMM模型的viterbi算法嘗試分詞處理性能
已收錄詞和未收錄詞所有分詞完畢後,使用dp尋找DAG的最大機率路徑spa
輸出分詞結果
jieba分詞默認的模型使用了一些語料來作訓練集,在 https://github.com/fxsjy/jieba/issues/7 中,做者說
來源主要有兩個,一個是網上能下載到的1998人民日報的切分語料還有一個msr的切分語料。另外一個是我本身收集的一些txt小說,用ictclas把他們切分(可能有必定偏差)。 而後用python腳本統計詞頻。
jieba分詞的默認語料庫選擇看起來滿隨意的_(:з」∠)_,做者也吐槽高質量的語料庫很差找,因此若是須要在生產環境使用jieba分詞,儘可能本身尋找一些高質量的語料庫來作訓練集。
語料庫中全部的詞語被用來作兩件事情:
對詞語的頻率進行統計,做爲登陸詞使用
對單字在詞語中的出現位置進行統計,使用BMES模型進行統計,供後面套HMM模型Viterbi算法使用,這個後面說。
統計後的結果保存在dict.txt中,摘錄其部分結構以下:
上訪 212 v 上訪事件 3 n 上訪信 3 nt 上訪戶 3 n 上訪者 5 n 上證 120 j 上證所 8 nt 上證指數 3 n 上證綜指 3 n 上訴 187 v 上訴書 3 n 上訴人 3 n 上訴期 3 b 上訴狀 4 n 上課 650 v
其中,第一列是中文詞語
,第二列是詞頻
,第三列是詞性,jieba分詞如今的版本除了分詞也提供詞性標註等其餘功能,這個不在本文討論範圍內,能夠忽略第三列。jieba分詞全部的統計來源,就是這個語料庫產生的兩個模型文件。
jieba分詞爲了快速地索引詞典以加快分詞性能,使用了前綴數組的方式構造了一個dict用於存儲詞典。
在舊版本的jieba分詞中,jieba採用trie樹的數據結構來存儲,其實對於python來講,使用trie樹顯得很是多餘,我將對新老版本的字典加載分別進行分析。
trie樹又叫字典樹,是一種常見的數據結構,用於在一個字符串列表中進行快速的字符串匹配。其核心思想是將擁有公共前綴的單詞歸一到一棵樹下以減小查詢的時間複雜度,其主要缺點是佔用內存太大了。
trie樹按以下方法構造:
trie樹的根節點是空,不表明任何含義
其餘每一個節點只有一個字符,詞典中全部詞的第一個字的集合做爲第一層葉子節點,以字符α開頭的單詞掛在以α爲根節點的子樹下,全部以α開頭的單詞的第二個字的集合做爲α子樹下的第一層葉子節點,以此類推
從根節點到某一節點,路徑上通過的字符鏈接起來,爲該節點對應的字符串
一個以and at as cn com
構造的trie樹以下圖:
查找過程以下:
從根結點開始一次搜索;
取得要查找關鍵詞的第一個字母,並根據該字母選擇對應的子樹並轉到該子樹繼續進行檢索;
在相應的子樹上,取得要查找關鍵詞的第二個字母,並進一步選擇對應的子樹進行檢索。
迭代過程……
在某個結點處,關鍵詞的全部字母已被取出,則讀取附在該結點上的信息,即完成查找。其餘操做相似處理.
如查詢at,能夠找到路徑root-a-t
的路徑,對於單詞av,從root找到a後,在a的葉子節點下面不能找到v結點,則查找失敗。
trie樹的查找時間複雜度爲O(k),k = len(s),s爲目標串。
二叉查找樹的查找時間複雜度爲O(lgn),比起二叉查找樹,trie樹的查找和結點數量無關,所以更加適合詞彙量大的狀況。
可是trie樹對空間的消耗是很大的,是一個典型的空間換時間的數據結構。
舊版本jieba分詞中關於trie樹的生成代碼以下:
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
代碼很簡單,遍歷每行文件,對於每一個單詞的每一個字母,在trie樹(trie和p變量)中查找是否存在,若是存在,則掛到下面,若是不存在,就創建新子樹。
jieba分詞采用python 的dict來存儲樹,這也是python對樹的數據結構的通用作法。
我寫了一個函數來直觀輸出其生成的trie樹,代碼以下:
def print_trie(tree, buff, level = 0, prefix=''): count = len(tree.items()) for k,v in tree.items(): count -= 1 buff.append('%s +- %s' % ( prefix , k if k!='' else 'NULL')) if v: if count == 0: print_trie(v, buff, level + 1, prefix + ' ') else: print_trie(v, buff, level + 1, prefix + ' | ') pass pass trie, list_freq, total = gen_trie('a.txt') buff = ['ROOT'] print_trie(trie, buff, 0) print('\n'.join(buff))
使用上面列舉出的dict.txt的部分詞典做爲樣例,輸出結果以下
ROOT +- 上 +- 證 | +- NULL | +- 所 | | +- NULL | +- 綜 | | +- 指 | | +- NULL | +- 指 | +- 數 | +- NULL +- 訴 | +- NULL | +- 人 | | +- NULL | +- 狀 | | +- NULL | +- 期 | | +- NULL | +- 書 | +- NULL +- 訪 | +- NULL | +- 信 | | +- NULL | +- 事 | | +- 件 | | +- NULL | +- 者 | | +- NULL | +- 戶 | +- NULL +- 課 +- NULL
原本jieba採用trie樹的出發點是能夠的,利用空間換取時間,加快分詞的查找速度,加速全切分操做。可是問題在於python的dict原生使用哈希表實現,在dict中獲取單詞是近乎O(1)的時間複雜度,因此使用trie樹,實際上是一種拈輕怕重的作法。
因而2014年某位同窗的PR修正了這一狀況。
在2014年的某次PR中(https://github.com/fxsjy/jieba/pull/187 ),提交者將trie樹改爲前綴數組,大大地減小了內存的使用,加快了查找的速度。
如今jieba分詞對於詞典的操做,改成了一層word:freq的結構,存於lfreq中,其具體操做以下:
對於每一個收錄詞,若是其在lfreq中,則詞頻累積,若是不在則加入lfreq
對於該收錄詞的全部前綴進行上一步操做,如單詞'cat',則對c, ca, cat分別進行第一步操做。除了單詞自己的全部前綴詞頻初始爲0.
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
很樸素的作法,然而充分利用了python的dict類型,效率提升了很多。