這篇文章主要是記錄HanLP標準分詞算法整個實現流程。html
HanLP.Config.enableDebug();
那什麼是語料呢?通俗的理解,就是HanLP裏面的二個核心詞典。假設收集了人民日報若干篇文檔,經過人工手工分詞,統計人工分詞後的詞頻:①統計分詞後的每一個詞出現的頻率,獲得一元核心詞典;②統計兩個詞兩兩相鄰出現的頻率,獲得二元核心詞典。根據貝葉斯公式:
\[ P(A|B)=\frac{P(A,B)}{P(B)}\qquad=\frac{count(A,B)}{count(B)}\qquad \]
其中\(count(A,B)\)表示詞A和詞B 在語料庫中共同出現的頻率;\(count(B)\)表示詞B 在語料庫中出現的頻率。有了這兩個頻率,就能夠計算在給定詞B的條件下,下一個詞是 A的機率。java
採用維特比分詞器:基於動態規劃的維特比算法。node
List<Term> termList = HanLP.segment(sentence);
在進入正式分詞流程前,可選擇是否進行歸一化,而後進入到正式的分詞流程。git
if (HanLP.Config.Normalization) { CharTable.normalization(text); } return segSentence(text);
第一步,構建詞網WordNet,參考:詞圖的生成github
詞網包含起始頂點和結束頂點,以及待分詞的文本內容,文本內容保存在charArray數組中。vertexes表示詞網中結點的個數:vertexes = new LinkedList[charArray.length + 2]
,加2的緣由是:起始頂點和結束頂點。算法
再將每一個結點初始化,每一個結點由一個LinkedList存儲,值爲空數組
for (int i = 0; i < vertexes.length; ++i) { vertexes[i] = new LinkedList<Vertex>();//待分詞的句子的每一個字符 對應的 LinkedList }
最後將起始結點和結束結點初始化,LinkedList中添加進相應的頂點。安全
vertexes[0].add(Vertex.newB());//添加起始頂點 vertexes[vertexes.length - 1].add(Vertex.newE());//添加結束頂點 size = 2;//結點size
在添加起始頂點和結束頂點的時候,會從核心詞典構建出一棵雙數組樹。好比,建立一個起始結點:函數
public static Vertex newB() { return new Vertex(Predefine.TAG_BIGIN, " ", new CoreDictionary.Attribute(Nature.begin, Predefine.MAX_FREQUENCY / 10), CoreDictionary.getWordID(Predefine.TAG_BIGIN)); }
每一個頂點Vertex包括以下屬性:ui
/** * 節點對應的詞或等效詞(如未##數) */ public String word; /** * 節點對應的真實詞,絕對不含## */ public String realWord; /** * 詞的屬性,謹慎修改屬性內部的數據,由於會影響到字典<br> * 若是要修改,應當new一個Attribute */ public CoreDictionary.Attribute attribute; /** * 等效詞ID,也是Attribute的下標 */ public int wordID;//CoreDictionary.txt 每一個詞的編號,從下標0開始 /** * 在一維頂點數組中的下標,能夠視做這個頂點的id */ public int index;//Vertex中 頂點的編號
下面來一 一解釋Vertex類中各個屬性的意義:
/** * 結束 end */ public final static String TAG_END = "末##末"; /** * 句子的開始 begin */ public final static String TAG_BIGIN = "始##始"; /** * 數詞 m */ public final static String TAG_NUMBER = "未##數";
也即句子的開始用符號"始##始"來表示,結束用"末##末"表示。也即前面提到的起始頂點和結束頂點。
另外,在分詞過程當中,會產生一些數量詞,好比一人、兩人……而這些數量詞統一用"未##數"表示。爲何要這樣表示呢?因爲分詞是基於n-gram模型的(n=2),一人、兩人 這樣的詞統計出來的頻率不太靠譜,致使在二元核心詞典中找不到詞共現頻率,所以使用等效詞串來進行處理。
真實詞String realWord
真實詞是待分詞的文本字符。好比:「商品和服務」,真實詞就是其中的每一個char,「真」、「品」、「和」……
Attribute屬性
記錄這個詞在一元核心詞典中的詞性、詞頻。因爲一個詞可能會有多個詞性和詞頻,所以詞性和詞頻都有一維數組來存儲。
wordId
該詞在一元核心詞典中的位置(行號)
index
這個詞在詞網(詞圖)中的頂點的編號
若是有bin文件,則直接是以二進制流的形式構建了一顆雙數組樹,不然從CoreNatureDictionary.txt中讀取詞典構建雙數組中。關於雙數組樹的原理比較複雜,等之後完全弄清楚了再來解釋。
基於核心一元詞典構建好了雙數組樹以後,就能夠用雙數組樹來查詢結點的wordId、詞頻、詞性……信息,
return new Vertex(Predefine.TAG_BIGIN, " ", new CoreDictionary.Attribute(Nature.begin, Predefine.MAX_FREQUENCY / 10), CoreDictionary.getWordID(Predefine.TAG_BIGIN));
CoreDictionary.getWordID(Predefine.TAG_BIGIN)//查詢獲得一元詞典中該結點所表明的字符對應的wordId
至此,建立了一個Vertex對象。
WordNet wordNetAll = new WordNet(sentence);
只是(初始化了)生成了詞網中的頂點,爲各個頂點分配了存儲空間,並初始化了起始結點和結束結點。接下來,須要初始化待分詞的文本中的各個字符了。
GenerateWordNet(wordNetAll);
生成完整的詞網。
生成完整詞網的流程是:根據待分詞的文本 使用雙數組樹 對一元核心詞典進行 最大匹配查詢,將命中的詞建立一個Vertex對象,而後添加到詞網中。
while (searcher.next()) { wordNetStorage.add(searcher.begin + 1, new Vertex(new String(charArray, searcher.begin, searcher.length), searcher.value, searcher.index)); }
具體舉例來講:假設待分詞的文本是"商品和服務",首先將該文本分解成單個的字符。
而後,對每個單個的字符,在雙數組樹(基於一元核心詞典構建的)中進行最大匹配查找:
對於 '商' 而言,最大匹配查找獲得:'商'--->‘商品’
對於'品'而言,最大匹配查找獲得:'品'
對於'和’而言,最大匹配查找獲得:'和'--->'和服'
對於'服'而言,最大匹配查找獲得:'服'--->'服務'
對於'務'而言,最大匹配查找獲得:'務'
下面來具體分析 '商' 這個頂點是如何構建的:
因爲'商'這個字存在於一元核心詞典中
searcher.next()
確定會命中了'商' ,因而查找到了雙數組樹中'商'這個頂點的全部信息:
wordID:32769
詞性vg,對應的詞頻是607;詞性v對應的詞頻是198
所以,將雙數組樹中的頂點信息提取出來,用來構建 '商' 這個Vertex對象,並將之加入到LinkedList中
public Vertex(String word, String realWord, CoreDictionary.Attribute attribute, int wordID) { if (attribute == null) attribute = new CoreDictionary.Attribute(Nature.n, 1); //attribute爲null,就賦一個默認值 安全起見 this.wordID = wordID; this.attribute = attribute; //初始時,全部詞的等效詞串word=null,當碰到數詞/地名...時, 會爲這些詞生成等效詞串. if (word == null) word = compileRealWord(realWord, attribute);//1人 或者 2人 這樣的詞,轉化爲:未##數@人 assert realWord.length() > 0 : "構造空白節點會致使死循環!"; this.word = word; this.realWord = realWord; }
compileRealWord()函數的做用是:當碰到數量詞……生成等效詞串
同理,下一步從雙數組樹中找到詞是'商品',相似地,構建'商品'這個Vertex對象,並將之添加到LinkedList下一個元素。
關於詞圖的生成,可參考:詞圖的生成。詞圖中創建各個節點之間的聯繫,是經過文章中提到的快速offset法實現的。其實就是經過快速offset法來尋找某個節點的下一個節點。由於後面會使用基於動態規劃的維特比算法來求解詞圖的最短路徑,而求解最短路徑就須要根據某個節點快速定位該節點的後繼節點。
下面,以 商品和服務爲例,詳細解釋一下快速offset法:
0 始##始 | |
---|---|
1 商 | 商品 |
2 品 | |
3 和 | 和服 |
4 服 | 服務 |
5 務 | |
6 末##末 |
上表可用一個LinkedList數組存儲。每一行表明一個LinkedList,存儲該字符最大匹配到的全部結果。這與圖:詞圖的連接表示 是一致的。使用這種方式存儲詞圖,不只簡單並且也易於查找下一個詞。
從詞網轉化成詞圖
而後,接下來是:原子分詞,保證圖連通。這一部分,不是太理解
// 原子分詞,保證圖連通 LinkedList<Vertex>[] vertexes = wordNetStorage.getVertexes(); for (int i = 1; i < vertexes.length; ) { if (vertexes[i].isEmpty()) { int j = i + 1; for (; j < vertexes.length - 1; ++j) { if (!vertexes[j].isEmpty()) break; } wordNetStorage.add(i, quickAtomSegment(charArray, i - 1, j - 1)); i = j; } else i += vertexes[i].getLast().realWord.length(); }
至此,獲得一個粗分詞網,以下:
構建好了詞圖以後,有了詞圖中各條邊以及邊上的權值。接下來,來到了基於動態規劃的維特比分詞,使用維特比算法來求解最短路徑。
List<Vertex> vertexList = viterbi(wordNetAll);
先把詞圖畫出來,以下:
nodes數組就是用來存儲詞圖的鏈表數組:
起始頂點"始##始"到第一層頂點"商"和"商品"的權值:
//始##始 到 第一層頂點之間的 邊以及權值 構建 for (Vertex node : nodes[1]) { node.updateFrom(nodes[0].getFirst()); }
須要注意的是,每一個頂點Vertex的weight屬性,保存的是從起始頂點到該頂點的最短路徑。
public void updateFrom(Vertex from) { double weight = from.weight + MathTools.calculateWeight(from, this); if (this.from == null || this.weight > weight)//this.weight>weight 代表尋找到了一條比原路徑更短的路徑 { this.from = from;//記錄 更短路徑上 的前驅頂點,用來回溯獲得最短路徑上的節點 this.weight = weight;//記錄 更短路徑 的權值 } }
updateFrom()方法實現了動態規劃自底向上計算最短路徑。當計算出的weight比當前頂點的路徑this.weight還要短時,就意味着找到一條更短的路徑。
MathTools.calculateWeight()方法計算權值。這個權值是如何得出的呢?這就涉及到核心二元詞典CoreNatureDictionary.ngram.txt了。
int nTwoWordsFreq = CoreBiGramTableDictionary.getBiFrequency(from.wordID, to.wordID);
會開始加載核心二元詞典CoreNatureDictionary.ngram.txt到內存中,而後查找這兩個詞:from@to 的詞共現頻率。顯然,這裏的詞典採用了延遲加載的模式,也即當須要查詢詞共現頻率的時候,纔會去加載核心二元詞典,從詞典中找到對應的頻率。好比 始##始@商 即表示:語料中以第一個字'商'開頭的頻率是46
有了從一元核心詞典中查詢到的單個詞的詞頻,以及兩個詞之間的詞共現頻率,就能夠計算「機率」了。(背後的思想是貝葉斯機率,而且須要進行平滑)。關於平滑,可參考這個issue
double value = -Math.log(dSmoothingPara * frequency / (MAX_FREQUENCY) + (1 - dSmoothingPara) * ((1 - dTemp) * nTwoWordsFreq / frequency + dTemp));
for (int i = 1; i < nodes.length - 1; ++i) { LinkedList<Vertex> nodeArray = nodes[i]; if (nodeArray == null) continue; for (Vertex node : nodeArray)//當前節點爲 node { if (node.from == null) continue; for (Vertex to : nodes[i + node.realWord.length()])//獲取當前節點 node的下一個節點 to { to.updateFrom(node); } } }
看updateFrom()
方法,經過比較節點的權重(權重表明着機率),更新最優路徑上的節點(DP求解最優路徑)
public void updateFrom(Vertex from) { double weight = from.weight + MathTools.calculateWeight(from, this); if (this.from == null || this.weight > weight)//DP求解 2-gram 機率最大的路徑 { this.from = from; this.weight = weight; } }
最終生成的詞圖以下:
以"商品"節點爲例,它的下一個節點有兩個:"和服" 、 "和"
0 始##始 | |
---|---|
1 商 | 商品 |
2 品 | |
3 和 | 和服 |
4 服 | 服務 |
5 務 | |
6 末##末 |
"商品"的行號爲1,長度爲2,那麼它的下一個節點存儲在行號爲 1+2 =3 的鏈表中。
同理,"品"的行號爲2,長度爲1,那麼它的下一個節點存儲在行號爲2+1=3的鏈表中。
從上面的詞圖中可驗證:節點"商品"的下一個節點是"和" 、"和服",正確無誤。
最終,計算出起始頂點到詞圖中各個頂點的最短路徑的權值。而後從結束頂點開始,回溯,找到最短路徑上的各個結點。
Vertex from = nodes[nodes.length - 1].getFirst();//最後一個頂點的from屬性記錄着到達該頂點的前綴頂點 while (from != null) { vertexList.addFirst(from); from = from.from;//weight屬性是最短路徑的權值,因此直接回溯就能獲得最短路徑上的各個結點 } return vertexList;
如今已經經過維特比算法求得最短路徑上的結點,可以使用用戶自定義的詞典合併結果。這裏的使用用戶自定義詞典合併結果的原理,有待進一步研究。(可參考:)
if (config.useCustomDictionary) { if (config.indexMode > 0) combineByCustomDictionary(vertexList, wordNetAll); else combineByCustomDictionary(vertexList); }
至此,粗分結果完畢。粗分結果以下:
粗分結果[商品/n, 和/cc, 服務/vn]
粗分完成以後,根據Config中的相應配置:是否開啓數字識別、NER命名識別、將最終的最短路徑上的分詞結果輸出。
return convert(vertexList, config.offset);
從而,最終的分詞結果以下:
[商品/n, 和/cc, 服務/vn]
原文:https://www.cnblogs.com/hapjin/p/11172299.html