中文分词技术在文本处理分析、搜索引擎、AI自然语言识别等领域都有着非常广泛的应用,最近我也在研究SEO中的内容文本分析,掌握分词的原理可以说是必不可少的。在python机器学习中文处理普遍都用到了 jieba 分词器,后来发现 jieba 还有个java版本的,正好有需要,直接拿来用了,也看了核心的源码,注释倒是没有的,所以自己记录一下阅读源码过程中的理解。
源码介绍
结巴分词(java版) jieba-analysis
源码:https://github.com/huaban/jieba-analysis
支持分词模式
- Search模式,用于对用户查询词分词
- Index模式,用于对索引文档分词
特性
- 支持多种分词模式
- 全角统一转成半角
- 用户词典功能
- conf 目录有整理的搜狗细胞词库
使用介绍
使用起来还是非常简单的
JiebaSegmenter segmenter = new JiebaSegmenter();
String keywords= "很多非技术型的产品经理弄不清楚需求设计边界"
+ ",技术人员更容易接收“确定的”,“完整逻辑覆盖”的需求。"
+ "怎么说呢?每种业务可能出现的情况,产品设计要有对应的解决方案,"
+ "大到一个完整处理流程,小到一个错误提示,都需要你想到,"
+ "不提前设计好这些规则,那么就提前健健身吧";
System.out.println(segmenter.process(keywords, SegMode.INDEX).toString());
System.out.println(segmenter.process(keywords, SegMode.SEARCH).toString());
执行结果:SEARCH是基本模式,而INDEX会将SEARCH模式结果中的长词再次拆分
结果中关键词后边跟着的是该词在整个文本中的位置索引
[[很多, 0, 2], [非, 2, 3], [技术, 3, 5], [技术型, 3, 6], [的, 6, 7], [产品, 7, 9], [经理, 9, 11], [弄, 11, 12], [不, 12, 13], [清楚, 13, 15], [需求, 15, 17], [设计, 17, 19], [边界, 19, 21], [,, 21, 22], [技术, 22, 24], [人员, 24, 26], [技术人员, 22, 26], [更, 26, 27], [容易, 27, 29], [接收, 29, 31], [“, 31, 32], [确定, 32, 34], [的, 34, 35], [”, 35, 36], [,, 36, 37], [“, 37, 38], [完整, 38, 40], [逻辑, 40, 42], [覆盖, 42, 44], [”, 44, 45], [的, 45, 46], [需求, 46, 48], [。, 48, 49], [怎么, 49, 51], [说, 51, 52], [呢, 52, 53], [?, 53, 54], [每种, 54, 56], [业务, 56, 58], [可能, 58, 60], [出现, 60, 62], [的, 62, 63], [情况, 63, 65], [,, 65, 66], [产品, 66, 68], [设计, 68, 70], [产品设计, 66, 70], [要, 70, 71], [有, 71, 72], [对应, 72, 74], [的, 74, 75], [解决, 75, 77], [方案, 77, 79], [解决方案, 75, 79], [,, 79, 80], [大到, 80, 82], [一个, 82, 84], [完整, 84, 86], [处理, 86, 88], [流程, 88, 90], [,, 90, 91], [小到, 91, 93], [一个, 93, 95], [错误, 95, 97], [提示, 97, 99], [,, 99, 100], [都, 100, 101], [需要, 101, 103], [你, 103, 104], [想到, 104, 106], [,, 106, 107], [不, 107, 108], [提前, 108, 110], [设计, 110, 112], [好, 112, 113], [这些, 113, 115], [规则, 115, 117], [,, 117, 118], [那么, 118, 120], [就, 120, 121], [提前, 121, 123], [健, 123, 124], [健身, 124, 126], [吧, 126, 127]]
[[很多, 0, 2], [非, 2, 3], [技术型, 3, 6], [的, 6, 7], [产品, 7, 9], [经理, 9, 11], [弄, 11, 12], [不, 12, 13], [清楚, 13, 15], [需求, 15, 17], [设计, 17, 19], [边界, 19, 21], [,, 21, 22], [技术人员, 22, 26], [更, 26, 27], [容易, 27, 29], [接收, 29, 31], [“, 31, 32], [确定, 32, 34], [的, 34, 35], [”, 35, 36], [,, 36, 37], [“, 37, 38], [完整, 38, 40], [逻辑, 40, 42], [覆盖, 42, 44], [”, 44, 45], [的, 45, 46], [需求, 46, 48], [。, 48, 49], [怎么, 49, 51], [说, 51, 52], [呢, 52, 53], [?, 53, 54], [每种, 54, 56], [业务, 56, 58], [可能, 58, 60], [出现, 60, 62], [的, 62, 63], [情况, 63, 65], [,, 65, 66], [产品设计, 66, 70], [要, 70, 71], [有, 71, 72], [对应, 72, 74], [的, 74, 75], [解决方案, 75, 79], [,, 79, 80], [大到, 80, 82], [一个, 82, 84], [完整, 84, 86], [处理, 86, 88], [流程, 88, 90], [,, 90, 91], [小到, 91, 93], [一个, 93, 95], [错误, 95, 97], [提示, 97, 99], [,, 99, 100], [都, 100, 101], [需要, 101, 103], [你, 103, 104], [想到, 104, 106], [,, 106, 107], [不, 107, 108], [提前, 108, 110], [设计, 110, 112], [好, 112, 113], [这些, 113, 115], [规则, 115, 117], [,, 117, 118], [那么, 118, 120], [就, 120, 121], [提前, 121, 123], [健, 123, 124], [健身, 124, 126], [吧, 126, 127]]
核心流程介绍
整个分词过程大概是这样的
- 基于
trie
树结构实现高效词图扫描 - 生成所有切词可能的有向无环图
DAG
- 采用动态规划算法计算最佳切词组合
- 基于
HMM
模型,采用Viterbi
(维特比)算法实现未登录词识别
核心源码分析
1、JiebaSegmenter.process
整体的分词流程代码,也是代码的入口(我直接在代码里加了关键点注释)
public List<SegToken> process(String paragraph, SegMode mode) {
List<SegToken> tokens = new ArrayList<SegToken>();
StringBuilder sb = new StringBuilder();
int offset = 0;
for (int i = 0; i < paragraph.length(); ++i) {
//全角 to 半角,大写 to 小写
char ch = CharacterUtil.regularize(paragraph.charAt(i));
//如果属于中英文内容
if (CharacterUtil.ccFind(ch))
//暂存
sb.append(ch);
else {
//直到读到标点等句子结束的地方,此时sb中存好了一个子句
if (sb.length() > 0) {
// process
//如果是SEARCH模式
if (mode == SegMode.SEARCH) {
//遍历分词结果 sentenceProcess方法单独展开分析
for (String word : sentenceProcess(sb.toString())) {
//将【词,index_start,index_end】 添加到结果集
tokens.add(new SegToken(word, offset, offset += word.length()));
}
}
else {
//此为细化模式,即对于长词(3以上) 持续进行拆分
for (String token : sentenceProcess(sb.toString())) {
if (token.length() > 2) {
String gram2;
int j = 0;
for (; j < token.length() - 1; ++j) {
gram2 = token.substring(j, j + 2);
if (wordDict.containsWord(gram2))
tokens.add(new SegToken(gram2, offset + j, offset + j + 2));
}
}
if (token.length() > 3) {
String gram3;
int j = 0;
for (; j < token.length() - 2; ++j) {
gram3 = token.substring(j, j + 3);
if (wordDict.containsWord(gram3))
tokens.add(new SegToken(gram3, offset + j, offset + j + 3));
}
}
tokens.add(new SegToken(token, offset, offset += token.length()));
}
}
sb = new StringBuilder();
offset = i;
}
if (wordDict.containsWord(paragraph.substring(i, i + 1)))
tokens.add(new SegToken(paragraph.substring(i, i + 1), offset, ++offset));
else
tokens.add(new SegToken(paragraph.substring(i, i + 1), offset, ++offset));
}
}
/*执行完前面一个步骤后,sb会剩余一些词典库里没查找到的词或者单字,
再针对这些剩余下来的文本重新组合成句子
(因为中间会被拆词出去,此时句子已经不通顺了,但是里面可能会产生新的词)*/
if (sb.length() > 0)
if (mode == SegMode.SEARCH) {
for (String token : sentenceProcess(sb.toString())) {
tokens.add(new SegToken(token, offset, offset += token.length()));
}
}
else {
for (String token : sentenceProcess(sb.toString())) {
if (token.length() > 2) {
String gram2;
int j = 0;
for (; j < token.length() - 1; ++j) {
gram2 = token.substring(j, j + 2);
if (wordDict.containsWord(gram2))
tokens.add(new SegToken(gram2, offset + j, offset + j + 2));
}
}
if (token.length() > 3) {
String gram3;
int j = 0;
for (; j < token.length() - 2; ++j) {
gram3 = token.substring(j, j + 3);
if (wordDict.containsWord(gram3))
tokens.add(new SegToken(gram3, offset + j, offset + j + 3));
}
}
tokens.add(new SegToken(token, offset, offset += token.length()));
}
}
return tokens;
}
以上代码中是整体流程,里面核心的分词实现代码是 sentenceProcess
2、JiebaSegmenter.sentenceProcess
具体分词的执行,句子分词源码:
public List<String> sentenceProcess(String sentence) {
List<String> tokens = new ArrayList<String>();
int N = sentence.length();
//基于Trie树结构实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图(DAG)
Map<Integer, List<Integer>> dag = createDAG(sentence);
//采用动态规划查找最大概率路径, 找出基于词频的最大切分组合
Map<Integer, Pair<Integer>> route = calc(sentence, dag);
sentence为拆分好的某个句子:比如 “很多非技术型的产品经理弄不清楚需求设计边界”
dag变量在过程中的值:
{0=[0, 1], 1=[1], 2=[2, 4], 3=[3, 4, 5], 4=[4], 5=[5], 6=[6], 7=[7, 8], 8=[8], 9=[9, 10], 10=[10], 11=[11, 13], 12=[12], 13=[13, 14], 14=[14], 15=[15, 16], 17=[17, 18], 16=[16], 19=[19, 20], 18=[18], 20=[20]}
也就是说 DAG有向无环图对于这个句子就是这样的:
第1个分词 0=[0,1] : 也就是句子中下标0-1 “很多”,
第2个分词 1=[1] : 也就是句子中下标1 “多”
第3个分词 2=[2, 4] : 也就是句子中下标2-4 “非技术”
关于Trie树 数据结构 ,可以参考:https://blog.csdn.net/qq_37337268/article/details/79843491
route变量在过程中的值:
{0=Candidate [key=1, freq=-105.83061968542464], 1=Candidate [key=1, freq=-103.77708829395444], 2=Candidate [key=2, freq=-97.36740116462788], 3=Candidate [key=5, freq=-89.15587358967926], 4=Candidate [key=4, freq=-92.78823889845086], 5=Candidate [key=5, freq=-82.92291635258887], 6=Candidate [key=6, freq=-74.54015880874623], 7=Candidate [key=8, freq=-69.30100480370396], 8=Candidate [key=8, freq=-71.60451133332668], 9=Candidate [key=10, freq=-61.42401283039645], 10=Candidate [key=10, freq=-60.5095346783189], 11=Candidate [key=11, freq=-51.43580939538924], 12=Candidate [key=12, freq=-42.18710851229048], 13=Candidate [key=14, freq=-37.07033519779972], 14=Candidate [key=14, freq=-37.65429532742047], 15=Candidate [key=16, freq=-28.20082360740684], 17=Candidate [key=18, freq=-18.887860790089707], 16=Candidate [key=16, freq=-28.025254145821112], 19=Candidate [key=20, freq=-10.407710900238412], 18=Candidate [key=18, freq=-19.8090939706965], 21=Candidate [key=0, freq=0.0], 20=Candidate [key=20, freq=-9.223603535098636]}
即 dag 中每个词的在整个中文习惯中“这样切分”的概率
3、JiebaSegmenter.calc
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;
}
在这儿是采用了动态规划的算法去获取一个句子分词方案中可能性最大的一种。首先下标i从句子的尾部开始往前游走,如果从dag中匹配到了分词,也就是下面这样子的情况:
某一次,x获取到 17=[17,20] (假设哈! 我们假设 设计边界 是个常用的词), i=17,x=17。 此时算法会依次去计算 17-18 17-19 17-20 也就是 “设计” “设计边” “设计边界” 他们的频率,取其中最大的作为 i 这个位置的分词方案。
也就是说 在17-20 这个区间上,本来有 设 ,设计,设计边,设计边界这4种分词方案,但是最终选择了只保留 设计边界。
动态规划算法之所以从尾部开始计算,就是先假设之前的步骤已经是最优解,这样只要当前这一步再提供最优解,整体就是最优解了。按这个思路计算到头部,就得出了最佳方案;避免了正序过程中遇到分支需要全量得出每个分支的最终结果。
关于动态规划算法可以参考:https://blog.csdn.net/u013309870/article/details/75193592
4、DictSegment.fillSegment
构造trie树的代码,调用路径如下:
JiebaSegmenter.sentenceProcess
->createDAG
->wordDict.getTrie
->WordDictionary.loadDict
->addWord
->_dict.fillSegment
private synchronized void fillSegment(char[] charArray, int begin, int length, int enabled) {
// 获取字典表中的汉字对象
Character beginChar = new Character(charArray[begin]);
Character keyChar = charMap.get(beginChar);
// 字典中没有该字,则将其添加入字典
if (keyChar == null) {
charMap.put(beginChar, beginChar);
keyChar = beginChar;
}
// 搜索当前节点的存储,查询对应keyChar的keyChar,如果没有则创建
DictSegment ds = lookforSegment(keyChar, enabled);
if (ds != null) {
// 处理keyChar对应的segment
if (length > 1) {
// 词元还没有完全加入词典树
ds.fillSegment(charArray, begin + 1, length - 1, enabled);
}
else if (length == 1) {
// 已经是词元的最后一个char,设置当前节点状态为enabled,
// enabled=1表明一个完整的词,enabled=0表示从词典中屏蔽当前词
ds.nodeState = enabled;
}
}
}
执行的逻辑是扫描词典库的过程中,每读出一个词,然后根据词的前缀(初始就是词的第一个字)去到当前的trie中搜索,没搜到则创建,搜到则直接取出来 ;取出来后 从这个trie节点 往后延申,同时把那个词的前缀扩大,递归执行搜索,直到把这个词扫完,然后打上标记,过程中没搜到的同时顺便执行了创建。
用“软件公司”这个词具体举例吧:
先拿 “软” 去trie结构里搜索,我们假设已经存在了,那么直接返回 “软” 这个字所在的trie节点
再拿 “件”去在 搜索,假设还没有,则直接创建 “件” ,并且成为 “软” 的 后续节点
直到读到 “司”,此时length为1 ,创建同时加上节点状态
关于Trie树 数据结构 ,可以参考:https://blog.csdn.net/qq_37337268/article/details/79843491
先写这么多吧,还有很多细节还未涉及到,关于TF-IDF,词库的结构等等。以后有时间写新的笔记。