中文分词

中文分词功能是一项常用的基础功能,有很多开源的工程实现,目前能应用于Android手机端的中文分词器没有很完善的版本。经过调研,我选择了结巴分词,该开源工程思路简单,易于理解,分词效果也还不错,目前有众多语言版本,PYTHON、C++、JAVA、IOS等,暂时还没有Android版本,所以我在Java版本的基础上进行了移植,开发了适用于Android手机的结巴分词Android版(Github)。

效果展示1

效果展示2

相比于Java版本的实现,Android版将字典文件存放在Asset目录下进行读取,同时对字典加载速度进行了大幅优化。原始的Java版本加载完整的字典文件在测试手机上需要28秒,时间太长,经过优化,成功将加载时间降到1.5秒,分词速度1秒以内,满足了Android手机的启动速度要求。

本文将结合代码通过以下三个方面展开介绍:结巴分词的基本原理,Android版的接入方式,以及启动速度优化的实现。

结巴分词的原理

结巴分词采用两种方式进行分词,基于字典的分词和基于HMM(隐马尔科夫模型)的分词。模型会首先加载词典文件生成一个字典树,并利用该字典树进行一段中文的分词,比如“我要去五道口吃肯德基”被分词成“我/要/去/五道口/吃/肯德基”,其中被分成单蹦个的连续中文字符,如“我/要/去”会继续经过HMM模型进行二次分词,看能不能合并成完整的单词,这种设计是为了对不在字典中的字符提供一种兜底的分词方案,可以尽可能的避免单蹦个的分词结果,优化分词的效果。

下面是进行分词的主函数:

private List sentenceProcess(String sentence) {

List tokens = new ArrayList();

int N = sentence.length();

long start = System.currentTimeMillis();

// 将一段文字转换成有向无环图,该有向无环图包含了跟字典文件得出的所有可能的单词切分

Map> dag = createDAG(sentence);

Map> 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> calc(String sentence, Map> dag) {

int N = sentence.length();

HashMap> route = new HashMap>();

route.put(N, new Pair(0, 0.0));

for (int i = N - 1; i > -1; i--) {

Pair 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(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 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)标记方式对每个汉字做出标记,标价的依据是每个汉字选择一个标记符号,使得整串汉字从头到尾行程的路径概率最大。这样就转换成了每一字符到下一个字符跳转概率给定情况下的最短路径问题。最短路径问题有两种标准解法,维特比算法和迪杰斯特拉算法。迪杰斯特拉算法是一种贪心策略,只能保证局部最优,不能保证全局最优。维特比算法能保证求得全局最优解,所以这里使用维特比算法求解。

/**

* 利用维特比算法计算对于一串单蹦个的字符,每个字符到下一个字符如何跳转,以实现整条路径的概率最大

* 例如:我 去 五 道 口

* B B B B B

* M M M M M

* E E E E E

* S S S S S

* @param sentence

* @param tokens

*/

public void viterbi(String sentence, List tokens) {

Vector> v = new Vector>();

Map path = new HashMap();

v.add(new HashMap());

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> callback) {...}

// 同步接口

public ArrayList getDividedString(String query) {...}

同时保留了结巴分词原有的分词接口process,可以指定分词模式是索引模式(INDEX)或搜索引擎模式(SEARCH),两者的差别在于搜索引擎模式分词更精细,索引模式相对更粗粒度。

public static enum SegMode {

INDEX,

SEARCH

}

public List 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 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 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 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 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 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 elemArray, List strArray, int startIndex) {

if (elemArray.size() <= 0) {

return;

}

ArrayList 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。

ezgif-1-974573a50cfa (1).gif

Android 分词功能,Android版中文分词:原理、接入和启动优化相关推荐

  1. Android版中文分词器:原理、接入和启动优化

    Android版中文分词器:原理.接入和启动优化 结巴分词的原理 接入方式 启动速度优化 中文分词功能是一项常用的基础功能,有很多开源的工程实现,目前能应用于Android手机端的中文分词器没有很完善 ...

  2. python 分词工具训练_中文分词入门和分词工具汇总攻略

    [注意]如果寻找分词和文本分析软件,为了完成内容分析和其他文本研究任务,直接使用集搜客分词和文本分析软件就可以了.本文是为了讲解集搜客分词和文本分析的实现原理,是给产品设计者和开发者看的. 最近在整理 ...

  3. 基于条件随机场模型的中文分词改进(Python中文分词)

    目录 改进分词速度 一.更改存储特征值的数据结构 二.缩短对语料库的遍历时间(对语料库的预处理) 三.先将所有的特征值保存到数据库中 改进分词的准确度 实验项目和结果截图 实验项目 保存特征值时采用多 ...

  4. Lucene分词器,使用中文分词器,扩展词库,停用词

    2019独角兽企业重金招聘Python工程师标准>>> Lucene分词器,使用中文分词器,扩展词库,停用词 博客分类: 搜索引擎,爬虫 停止词:lucene的停止词是无功能意义的词 ...

  5. android远程打电话,Android打电话功能 Android实战教程第三篇之简单实现拨打电话功能...

    想了解Android实战教程第三篇之简单实现拨打电话功能的相关内容吗,杨道龙在本文为您仔细讲解Android打电话功能的相关知识和一些Code实例,欢迎阅读和指正,我们先划重点:Android拨打电话 ...

  6. 分词器以及ik中文分词器

    文章目录 分词器以及ik中文分词器 概念 ik分词器的安装 环境准备 设置jdk环境变量 下载maven安装包并解压 设置path 验证maven是否安装成功 下载IK分词器并安装 使用IK分词器 查 ...

  7. Android版中文分词:原理、接入和启动优化

    中文分词功能是一项常用的基础功能,有很多开源的工程实现,目前能应用于Android手机端的中文分词器没有很完善的版本.经过调研,我选择了结巴分词,该开源工程思路简单,易于理解,分词效果也还不错,目前有 ...

  8. Java版中文分词 IKAnalyzer

    效果:中文分词统计出现次数并排序 直接看代码: import org.wltea.analyzer.core.IKSegmenter; import org.wltea.analyzer.core.L ...

  9. python 分词工具训练_Python中文分词工具大合集:安装、使用和测试

    这篇文章事实上整合了前面两篇文章的相关介绍,同时添加一些其他的Python中文分词相关资源,甚至非Python的中文分词工具,仅供参考. 首先介绍之前测试过的8款中文分词工具,这几款工具可以直接在AI ...

最新文章

  1. 关于申请由中国自动化学会主办“全国大学生智能汽车竞赛”的回函
  2. 将一列数据转换成一个字段数据,逗号分隔
  3. python爬取百度百科表格_第一个python爬虫(python3爬取百度百科1000个页面)
  4. PyQt4学习资料汇总
  5. python3网络爬虫开发实战豆瓣_大牛程序员教你1天入门Python3 网络爬虫例子
  6. XTU 1252 Defense Tower
  7. 集群节点数和分片数关系_离子晶体配位数与离子半径比的关系
  8. .Net脱壳工具 de4dot参数说明/简易教程
  9. bzoj1047-理想的正方形(二维单调队列)
  10. java毕业设计对外汉语教学辅助平台Mybatis+系统+数据库+调试部署
  11. 机器学习项目的实例分析设计(附源码)
  12. MFC的消息处理模式
  13. 房产过户给子女,那种方式比较合适
  14. SpringCloud版本Hoxton SR5 --- 第二讲:eureka 、eureka与zookeeper的区别与使用场景。
  15. 广讯通系统地址和服务器,广讯通OA办公系统
  16. A-Level经济真题(7)
  17. 使用AirCrack破解wifi密码(wpa/wpa2)
  18. som聚类用matlab实现,使用SOM对数据进行聚类
  19. 第一台数字电子计算机占地面积为,计算机应用基础教学课件作者刘凤第1章.ppt...
  20. 独家 | 当热钱不再涌动——2019人工智能行业冷暖观察

热门文章

  1. 大学时期的反思,毕业了我们一无所有
  2. 华为硬件配置命令,很全
  3. 【图像处理通道分离去除印章】
  4. 优雅的二维数组循环赋值
  5. ICPC Latin American Regional Contests 2019 K.Know your Aliens菜鸡版
  6. Excel文档生成方案
  7. 数商云:打通产销对接,构建新型数字化农副产品供应链
  8. 如何查看端口状态及端口占用情况
  9. 在linux下运行英雄,在 Linux 下玩《英雄无敌 3》游戏
  10. 2023年天津理工大学中环信息学院专升本专业课报名考试须知