結巴分詞在Android手機上的應用:原理、接入和啟動優化

中文分詞
中文分詞功能是一項常用的基礎功能,有很多開源的工程實現,目前能應用於Android手機端的中文分詞器沒有很完善的版本。經過調研,我選擇了結巴分詞,該開源工程思路簡單,易於理解,分詞效果也還不錯,目前有 ofollow,noindex">眾多語言版本 ,PYTHON、C++、JAVA、IOS等,暫時還沒有Android版本,所以我在Java版本的基礎上進行了移植,開發了適用於Android手機的 結巴分詞Android版(Github) 。
相比於Java版本的實現,Android版將字典檔案存放在Asset目錄下進行讀取,同時對字典載入速度進行了大幅優化。原始的Java版本載入完整的字典檔案在測試手機上需要28秒,時間太長,經過優化,成功將載入時間降到1.5秒,分詞速度1秒以內,滿足了Android手機的啟動速度要求。
本文將結合程式碼通過以下三個方面展開介紹:結巴分詞的基本原理,Android版的接入方式,以及啟動速度優化的實現。
結巴分詞的原理
結巴分詞采用兩種方式進行分詞,基於字典的分詞和基於HMM(隱馬爾科夫模型)的分詞。模型會首先載入詞典檔案生成一個字典樹,並利用該字典樹進行一段中文的分詞,比如 “我要去五道口吃肯德基” 被分詞成 “我/要/去/五道口/吃/肯德基” ,其中被分成單蹦個的連續中文字元,如 “我/要/去” 會繼續經過HMM模型進行二次分詞,看能不能合併成完整的單詞,這種設計是為了對不在字典中的字元提供一種兜底的分詞方案,可以儘可能的避免單蹦個的分詞結果,優化分詞的效果。
下面是進行分詞的主函式:
private List<String> sentenceProcess(String sentence) { List<String> tokens = new ArrayList<String>(); int N = sentence.length(); long start = System.currentTimeMillis(); // 將一段文字轉換成有向無環圖,該有向無環圖包含了跟字典檔案得出的所有可能的單詞切分 Map<Integer, List<Integer>> dag = createDAG(sentence); Map<Integer, Pair<Integer>> route = calc(sentence, dag); int x = 0; int y = 0; String buf; StringBuilder sb = new StringBuilder(); while (x < N) { // 遍歷一遍貪心演算法生成的最小路徑分詞結果,對單蹦個的字元看看能不能粘合成一個詞彙 y = route.get(x).key + 1; String lWord = sentence.substring(x, y); if (y - x == 1) sb.append(lWord); else { if (sb.length() > 0) { buf = sb.toString(); sb = new StringBuilder(); if (buf.length() == 1) { // 如果兩個單詞之間只有一個單蹦個的字元,新增 tokens.add(buf); } else { if (wordDict.containsWord(buf)) { // 如果連續單蹦個的字元粘合成的一個單詞在字典樹裡,作為一個單詞新增 tokens.add(buf); } else { finalSeg.cut(buf, tokens); // 如果連續單蹦個的字元粘合成的一個單詞不在字典樹裡,使用維特比演算法計算每個字元BMES如何選擇使得概率最大 } } } tokens.add(lWord); } x = y; } buf = sb.toString(); if (buf.length() > 0) { // 處理餘下的部分 if (buf.length() == 1) { tokens.add(buf); } else { if (wordDict.containsWord(buf)) { tokens.add(buf); } else { finalSeg.cut(buf, tokens); } } } return tokens; }
該函式首先通過createDAG將輸入的一段文字轉換成有向無環圖(DAG),該有向無環圖包含了根據字典檔案得出的所有可能的單詞切分,以每個字為單位,比如 “我去五道口吃肯德基” ,經過createDAG處理後會生成每個字和後面字元可能的單詞組合,比如“我/去/五道口/吃/肯德基”/“我去/五道口/吃/肯德基”/“我去/五/道口/吃/肯德基”/“我/去/五道口/吃/肯德/基”等等。
然後經過calc函式,對這個DAG從後向前依據貪婪演算法選擇一種分詞方式。實現比較簡單,從最後一個字開始,找出從該字元前面字元跳轉到當前字元概率最大的切分方式,然後依次往前走,直到完成整句話的切分。概率的大小依據是字典中該單詞的頻率值。
/** * 計算有向無環圖的一條最大路徑,從後向前,利用貪心演算法,每一步只需要找出到達該字元的最大概率字元作為所選擇的路徑 * * @param sentence * @param dag * @return */ private Map<Integer, Pair<Integer>> calc(String sentence, Map<Integer, List<Integer>> dag) { int N = sentence.length(); HashMap<Integer, Pair<Integer>> route = new HashMap<Integer, Pair<Integer>>(); route.put(N, new Pair<Integer>(0, 0.0)); for (int i = N - 1; i > -1; i--) { Pair<Integer> candidate = null; for (Integer x : dag.get(i)) { double freq = wordDict.getFreq(sentence.substring(i, x + 1)) + route.get(x + 1).freq; if (null == candidate) { candidate = new Pair<Integer>(x, freq); } else if (candidate.freq < freq) { candidate.freq = freq; candidate.key = x; } } route.put(i, candidate); } return route; }
經過上面calc函式的切分,整段話會被切分成一些單詞和一些單蹦個的字元的組合,對於單蹦個的字元,再呼叫finalSeg.cut函式進行HMM模型切分,試圖儘可能組合成完整的單詞,優化切詞效果。
finalSeg.cut函式實現如下:
public void cut(String sentence, List<String> tokens) { StringBuilder chinese = new StringBuilder(); StringBuilder other = new StringBuilder(); for (int i = 0; i < sentence.length(); ++i) { char ch = sentence.charAt(i); if (CharacterUtil.isChineseLetter(ch)) { // 遇到一個漢字,就把之前累積的非漢字處理一下加入最終結果 if (other.length() > 0) { processOtherUnknownWords(other.toString(), tokens); other = new StringBuilder(); } chinese.append(ch); } else { if (chinese.length() > 0) { // 遇到一個非漢字元號,就把之前累加的單蹦個漢字處理一下加入最終結果 viterbi(chinese.toString(), tokens); // 處理一串單蹦個漢字的方法是維特比演算法 chinese = new StringBuilder(); } other.append(ch); } } if (chinese.length() > 0) // 處理餘下的漢字 viterbi(chinese.toString(), tokens); else {// 處理餘下的非漢字字元 processOtherUnknownWords(other.toString(), tokens); } }
finalSeg.cut函式考慮了中文字元和非中文字元的情況,將非中文字元利用正則表示式切成單個的英文單詞,將中文字元利用B(Begin)、M(Middle)、E(End)、S(Single)標記方式對每個漢字做出標記,標價的依據是每個漢字選擇一個標記符號,使得整串漢字從頭到尾行程的路徑概率最大。這樣就轉換成了每一字元到下一個字元跳轉概率給定情況下的最短路徑問題。最短路徑問題有兩種標準解法,維特比演算法和迪傑斯特拉演算法。迪傑斯特拉演算法是一種貪心策略,只能保證區域性最優,不能保證全域性最優。維特比演算法能保證求得全域性最優解,所以這裡使用維特比演算法求解。
/** * 利用維特比演算法計算對於一串單蹦個的字元,每個字元到下一個字元如何跳轉,以實現整條路徑的概率最大 * 例如:我去五道口 *BBBBB *MMMMM *EEEEE *SSSSS * @param sentence * @param tokens */ public void viterbi(String sentence, List<String> tokens) { Vector<Map<Character, Double>> v = new Vector<Map<Character, Double>>(); Map<Character, Node> path = new HashMap<Character, Node>(); v.add(new HashMap<Character, Double>()); for (char state : states) { Double emP = emit.get(state).get(sentence.charAt(0)); if (null == emP) emP = MIN_FLOAT; v.get(0).put(state, start.get(state) + emP); path.put(state, new Node(state, null)); } ...... }
這樣就既保證了詞典分詞的準確性,又能對沒有出現在詞典中的單蹦個的漢字進行一定程度的優化分詞,具備了一定的靈活性。
接入方式
具體接入方式可以參照 結巴分詞Android版(Github) 進行接入,既可以原始碼接入,也可以通過gradle接入。
使用的時候首先進行初始化,一般在MyApplication裡進行:
// 非同步初始化 JiebaSegmenter.init(getApplicationContext());
該初始化是非同步進行的,速度僅需1.5秒即可完成包含35萬詞典的字典樹的生成。
該Android分詞器提供了三個介面用於分詞。
下面兩個簡單介面分別是同步和非同步分詞介面:
// 非同步介面 public void getDividedStringAsync(final String query, final RequestCallback<ArrayList<String>> callback) {...} // 同步介面 public ArrayList<String> getDividedString(String query) {...}
同時保留了結巴分詞原有的分詞介面process,可以指定分詞模式是索引模式(INDEX)或搜尋引擎模式(SEARCH),兩者的差別在於搜尋引擎模式分詞更精細,索引模式相對更粗粒度。
public static enum SegMode { INDEX, SEARCH } public List<SegToken> process(String query, SegMode mode) {...}
啟動速度優化
原始的Java版本的結巴分詞在手機上載入詞典速度很慢,35萬的詞典需要28秒,不能直接使用。這是由於需要根據詞典生成字典樹(TireTree),沒加入一個單詞都需要進行查詢和比較,很耗時。為此,在Android版本里,我做了預處理,將載入詞典生成的字典樹按照特定格式儲存到了文字中,實際執行的時候,直接從Asset下載入該中間檔案,將原來單詞隨機插入字典樹的方式該為順序插入,極大的加快了速度。
首先通過loadDict函式載入詞典,生成字典樹:
public boolean loadDict(AssetManager assetManager) { element = new Element((char) 0); // 建立一個根Element,只有一個,其他的Element全是其子孫節點 InputStream is = null; try { long start = System.currentTimeMillis(); is = assetManager.open(MAIN_DICT); if (is == null) { Log.e(LOGTAG, "Load asset file error:" + MAIN_DICT); return false; } BufferedReader br = new BufferedReader(new InputStreamReader(is, Charset.forName("UTF-8"))); long s = System.currentTimeMillis(); while (br.ready()) { String line = br.readLine(); String[] tokens = line.split("[\t ]+"); if (tokens.length < 2) continue; String word = tokens[0]; // eg:一兩千塊 double freq = Double.valueOf(tokens[1]); total += freq; String trimmedword = addWord(word);// 將一個單詞的每個字遞迴的插入字典樹eg:一兩千塊 freqs.put(trimmedword, freq);// 並統計單詞首個字的頻率 } // normalize for (Map.Entry<String, Double> entry : freqs.entrySet()) { entry.setValue((Math.log(entry.getValue() / total))); minFreq = Math.min(entry.getValue(), minFreq); } Log.d(LOGTAG, String.format("main dict load finished, time elapsed %d ms", System.currentTimeMillis() - s)); } catch (IOException e) { Log.e(LOGTAG, String.format("%s load failure!", MAIN_DICT)); return false; } finally { try { if (null != is) is.close(); } catch (IOException e) { Log.e(LOGTAG, String.format("%s close failure!", MAIN_DICT)); return false; } } return true; }
element是整棵字典樹的根節點。
然後通過saveDictToFile函式按層儲存該字典樹:
/** *ROOT *b/-- c$/--d/ *e$/f/ -- #/--g/ *h$/ ---- #/---- i$/ *#/--------- #/ * @param elementArray */ private void saveDictToFile(ArrayList<Element> elementArray) { if (elementArray.size() <= 0) { Log.d(LOGTAG, "saveDictToFile final str: " + dicLineBuild.toString()); try { File file = new File(Environment.getExternalStorageDirectory(), MAIN_PROCESSED); if (!file.exists()) { file.createNewFile(); } FileOutputStream fos = new FileOutputStream(file); // 第一行是字典資料 dicLineBuild.append("\r\n"); // 第二行: 最小頻率 TAB 單詞1 TAB 頻率 TAB 單詞2 TAB 頻率 ... dicLineBuild.append(minFreq); for (Map.Entry<String, Double> entry : freqs.entrySet()) { dicLineBuild.append(TAB); dicLineBuild.append(entry.getKey()); dicLineBuild.append(TAB); dicLineBuild.append(entry.getValue()); } fos.write(dicLineBuild.toString().getBytes()); fos.close(); Log.d(LOGTAG, String.format("字典中間檔案生成成功,儲存在%s", file.getAbsolutePath())); } catch (Exception e) { Log.d(LOGTAG, "字典中間檔案生成失敗!"); e.printStackTrace(); } return; } ArrayList<Element> childArray = new ArrayList(); // elementArray有幾個元素,就要新增TAB分割的幾個資料段,每個資料段是該Element的子節點的字+"/",比如 e/f/ TAB #/ TAB g/ // 如果從根節點到當前節點的路徑表示一個詞,那麼在後面新增$符號,如e$/f/ TAB #/ TAB g/ for (int i = 0; i < elementArray.size(); i++) { Element element = elementArray.get(i); // e/f/ if (element.hasNextNode()) { for (Map.Entry<Character, Element> entry : element.childrenMap.entrySet()) { dicLineBuild.append(entry.getKey()); if (entry.getValue().nodeState == 1) { dicLineBuild.append(DOLLAR);// 從根節點到當前節點的路徑表示一個詞,那麼在後面新增$符號,如e$/f/ TAB #/ TAB g/ } dicLineBuild.append(SLASH); // 將該節點的所有子節點入列表,供下一次遞迴 childArray.add(entry.getValue()); } } else { // #/ dicLineBuild.append(SHARP); dicLineBuild.append(SLASH); } // TAB dicLineBuild.append(TAB); } saveDictToFile(childArray); }
該檔案共兩行,第一行是按層儲存的字典檔案,第二行是每個單詞的頻率。每行都很長。其中第一行的生成比較複雜,我們從根節點element開始,用一個列表儲存每一層的節點。首先將根節點加入該列表,依次遍歷列表中的同層節點,該列表有m個節點,就新增TAB分割的m個數據段,每個資料段是該節點的所有子節點字元,用"/"符號連線,比如 明天/明年/明月 , 哦 , 後天/後面 ,三個單詞,其中 明/哦/後 是同一層的三個根節點,根節點是 明 ,三個子節點分別是 天/年/月 , 那麼會在文字中寫入 天/年/月/ 。 哦 沒有子節點,會寫入 #/ , 後天/後面 會寫入 天/面/ ,通過 TAB 將三部分連線起來,就是你 天/年/月 TAB #/ TAB 天/面/ 。通過這種方式,遞迴將整棵樹按層存入檔案,在手機載入的時候逆向按順序完成字典樹的恢復。
恢復的字典樹的過程程式碼如下,也是遞迴進行的:
/** * d/b/c/g/f/e/#/j/#/h/#/#/ */ private void restoreElement(ArrayList<Element> elemArray, List<String> strArray, int startIndex) { if (elemArray.size() <= 0) { return; } ArrayList<Element> newElemArray = new ArrayList<>(); for (int i = 0; i < elemArray.size(); i++) { String strCluster = strArray.get(startIndex); String[] strList = strCluster.split(SLASH); Element e = elemArray.get(i); // #/ if (strList.length == 1 && strList[0].equalsIgnoreCase(SHARP)) { e.nodeState = 1; e.storeSize = 0; } else { //f/e/ e.childrenMap = new HashMap<>(); for (int j = 0; j < strList.length; j++) { String s = strList[j]; boolean isWord = s.length() == 2; Character ch = new Character(s.charAt(0)); Element childElem = new Element(ch); childElem.nodeState = isWord ? 1 : 0; e.childrenMap.put(ch, childElem); e.storeSize++; newElemArray.add(childElem); } } startIndex++; } restoreElement(newElemArray, strArray, startIndex); }
這樣,載入35萬詞典可以在1.5秒內完成,分詞速度在一秒以內,滿足了Android手機上的可用性。希望該分詞器能夠為大家的Android App提供更多圍繞分詞的功能亮點,做出更優秀的APP。