Jieba分词:算法解析+代码实战
官方说明:
- 基于前缀词典实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图(DAG)
- 采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合
- 对于未登录词,采用了基于汉字成词能力的HMM模型,使用了Viterbi算法
上面的三行,可以看成是分词流程中的关键三步,下面针对每一步,分别从算法原理和工程实现介绍。
Note
前缀词典:本身是一个dict(HashMap, key为前缀,value为词频),对于真前缀,词频为0;非真前缀(词)词频大于0:
- 必须要区分是否真前缀,只有如此,才能快速判断是否非可能词,在DAG构造时剪枝。
DAG是图,图的表示用两种方式,一种是邻接矩阵,一种是邻接表(实现时可用defaultdict(list)表示)
寻找从0到n的最优路径:
- dp[i]表示从0到i的最优分数,+需要以i为结尾的节点列表+
- dp[i]表示从i到n的最优分数,需要以i为开始的节点列表,刚好和前缀词典一致。
1 生成有向无环图(DAG)
算法原理
- 利用了词典信息(dict.txt 第一列), 词典给到了潜在的词语,是基于规则的分词。
- 这张图有T(输入句子的长度)+1个节点, 从起点到终点的任何一条路径,代表一种候选分词方案 。
比如,对于"南京长江大桥", 生成的DAG如下:

工程实现
-
利用前缀词典(实际上就是一个HashMap,存储所有的词前缀,含词自身) 实现高效的词图扫描,构造DAG
- 通过判断是否为训练语料中的词前缀(含词自身),否的话可快速终止扫描, 而不用扫描到句尾
-
通过判断词频是否大于0,若是,则加入对应的边,边的Lable是对应的词
Note
具体实现是通过一个HashMap实现,其中key为词,value为词频,真前缀通过词频=0标识出来
def get_dag_by_prefix_dict(self, sentence: str) -> Dict[int, List[int]]: """ 节点个数为len(sentence)+1, 边 := 节点i:节点j = sentence[i:j]表示潜在的分词 Args: sentence: 待分词的句子 Returns: i -> [j1, j2, ...], 其中s[i:j1], s[i:j2], ...组成词 """ dag = defaultdict(list) n = len(sentence) for i in range(n): # 将i, j理解为DAG的节点V,sentence[i:j]表示边edge(i, j)的label dag[i].append(i + 1) # edge(i, i+1) = sentence[i] 单字作为词 for j in range(i + 2, n + 1): # edge(i, j) candidate_word = sentence[i:j] if candidate_word in self.freq: if self.freq[candidate_word] > 0: dag[i].append(j) else: # 提前终止, 候选词不是任何词的前缀 break dag = dict(dag) return dag
-
通过Trie(字典树)也可以实现,但实测空间效率没有前缀词典高效
- jieba的历史版本中,曾经使用Trie,但后来被优化为用前缀词典,主要考虑是空间复杂度
-
python实现的Trie, 多层HashMap嵌套,内存占用空间比PrefixDict大很多,C++实现的话,预计效率会高很多
内存占用 Trie 164.27 MB prefix_dict 59.43 MB
2 选取一条最优路径
算法原理
- 利用了词频信息(dict.txt 第二列),基于 Unigram语言模型 ,预估每条候选路径的最大概率,选概率最大的路径,作为最终的分词方案
-
公式如下:
\[ bestpath = \arg \max_i \prod_{j} p(w_{i, j}) \]
其中 \(w_{i, j}\) 表示第i条路径中第j条边对应的词
对于上面例子,虽然输入句子长度大小只有7, 但理论上有27-1=64条路径, 由于某些组合不能构成词,最终有24条候选路径,也很大。(后面会用动态规划寻找最优路径,而不是遍历所有的路径。)
| 路径边上对应的词 | 分数log(p) | 路径边上对应的词 | 分数log(p) | |
|---|---|---|---|---|
| 南 京 市 长 江 大 桥 | -55.648 | 南京 市 长 江 大 桥 | -47.741 | |
| 南 京 市 长 江 大桥 | -50.564 | 南京 市 长 江 大桥 | -42.657 | |
| 南 京 市 长江 大 桥 | -47.204 | 南京 市 长江 大 桥 | -39.297 | |
| 南 京 市 长江 大桥 | -42.121 | 南京 市 长江 大桥 | -34.214 | |
| 南 京 市 长江大桥 | -33.898 | 南京 市 长江大桥 | -25.991 | |
| 南 京 市长 江 大 桥 | -49.859 | 南京 市长 江 大 桥 | -41.952 | |
| 南 京 市长 江 大桥 | -44.776 | 南京 市长 江 大桥 | -36.869 | |
| 南 京市 长 江 大 桥 | -56.435 | 南京市 长 江 大 桥 | -41.691 | |
| 南 京市 长 江 大桥 | -51.352 | 南京市 长 江 大桥 | -36.608 | |
| 南 京市 长江 大 桥 | -47.992 | 南京市 长江 大 桥 | -33.248 | |
| 南 京市 长江 大桥 | -42.908 | 南京市 长江 大桥 | -28.164 | |
| 南 京市 长江大桥 | -34.686 | 南京市 长江大桥 | -19.942 |
工程实现
-
利用动态规划寻找最优的路径,比暴力搜索所有路径,要高效很多
- 定义状态dp[i]: 第i个节点开始路径的最大分数(从sentence[i:]开始到句尾)
-
状态转移方程:
\[ dp[i] = \max_{j} \big( score(sentence[i:j]) + dp[j]\big) = \max_{j} \big( \log \frac{freq[sentence[i:j]] }{n_{total-word}} + dp[j]\big) \]
必须用归一化的词频,否则会导致结果错误:
- 之前的理论公式是基于概率的
表面上看,分母似乎是常数项,去掉对结果没影响,但其实不是,因为有累计效应,切分后不同的词长度,分母生效的次数不一样。比如对于词频<中(1) 国(1) 中国(1)>,
以未归一化词频计算:
- score(中 国) = 1 * 1
- score(中国) = 1
以归一化词频计算:
- score(中 国) = 1/3 * 1/3 = 1/9
- score(中国) = 1/3
def find_optimal_path_dp(self, sentence, dag) -> Tuple[float, List[int]]:
"""
寻找最大概率路径
"""
n = len(sentence)
# 图论: 从节点i到节点n的最大分数(log p), 最大分数对应的i+1个节点(下一步位置)
# 物理意义: 以sentence[i]为始点,到句尾的最大分数; 最有路径中,以sentence[i]开始的词结束位置
# dp[i] = \max_{k} score(i, k) + dp[k]
dp = [(None, None) for i in range(n + 1)]
dp[n] = 0, None
for i in range(n - 1, -1, -1):
tmp = []
for j in dag[i]:
candidate_word = sentence[i:j]
tmp.append(
(np.log(self.freq.get(candidate_word, 1)) - np.log(self.n_word) + dp[j][0], j)
)
dp[i] = max(tmp)
path = [0]
i = 0
while i < n:
next_i = dp[i][1]
path.append(next_i)
i = next_i
return dp[0], path3 对于未登录词,用HMM模型分词
算法原理
HMM假设自己是上帝, 认为句子的生成过程是这样子的:
- 抛一个4面的骰子,决定第一个字的隐状态(BMES中的某一个, B词开始位置, M词中间位置, E词结束位置, S单字词), 这个骰子通过初始概率矩阵来表示。
- 有4个V面的骰子, 对应四个状态,在已知某隐状态的情况下,抛对应的骰子,决定产生的下一个字什么。这4个骰子对应的是发射概率矩阵。
- 另外还有4个4面的筛子, 这个筛子用来决定隐状态中间如何转换,对应的是转移概率矩阵。
Note
分词的问题,转换为已知词序列的情况下,找到最优的隐状态序列, 即 \(\hat{Z} = \arg \max p(Z|X) = \arg \max p(X|Z) p(Z)\)
注意,HMM假设自己是上帝,文字序列产生的任何过程都可以通过概率来刻。上面的概率很容易通过初始概率、转移概率、发射概率求解。

工程实现
虽然每个隐状态序列的概率很容易计算,但架不住隐状态序列可能性太多了(\(4^T\), T为句子长度),此时通过动态规划(或者在状态转移中的一个特殊名词,维特比译码算法)来求解,可以将复杂度降低到 \(4^2*T\) 。
- 状态定义:\(score_{t, s}\) 时刻t到达状态s的最优路径得分
-
状态转移方程:
\[ score_{t,s} = \mathop{\max}\limits_{s'} score_{t-1, s'} + p_{t-1, s'}^{trans} + p^{emit}_{s, sentence[t]} \]
def viterbi(sentence, states, p_start, p_trans, p_emit): """ 维特比译码: 根据显状态序列sentence和HMM(p_start, p_trans, p_emit), 利用动态规划算法找出最优路径 Args: sentence: 观察到的显状态序列 states: 隐状态值集合 p_start: 初始概率矩阵 p_trans: 转移概率矩阵 p_emit: 发射概率矩阵 Returns: (best_score, best_path) """ T = len(sentence) score = [{} for _ in range(T)] # score[t][s] ~ 时刻t到达状态s的最优路径得分 bp = [{} for _ in range(T)] # bp[t][s] ~ 时刻t状态s最优路径的上一状态 t = 0 # 起点 for s in states: score[t][s], bp[t][s] = p_start[s] + p_emit[s].get(sentence[t], MIN_FLOAT), None for t in range(1, T): # 递归 for s in states: score[t][s], bp[t][s] = max((score[t - 1][s_prev] + p_trans[s_prev][s] + p_emit[s].get(sentence[t], MIN_FLOAT), s_prev) for s_prev in states if s in p_trans[s_prev]) t = T # 终点 best_score, best_bp = max((score[t - 1][s], s) for s in "ES") # 最后一个词的隐状态只能为E或S # 根据backpoint恢复最优路径 best_path = [best_bp] pointer = best_bp for t in range(T - 1, 0, -1): prev = bp[t][pointer] best_path.append(prev) pointer = prev best_path = best_path[::-1] return best_score, best_path
4 总结
整个流程近似伪代码如下:
def problem_chinese_tokenize(sentence:str, word2freq:Dict[str, int]) -> str:
dag = get_dag(sentence, word2freq) # 构造DAG
best_path = find_optimal_path(sentence, dag) # 寻找最优路径
best_path = adjust_by_hmm(best_path) # HMM调整
result = " ".join(edge["label"] for edege in best_path)
return result核心点:
- DAG有向无环图表示所有可能的分词路径
-
*动态规划 :无论是DAG中寻找最优路径,还是HMM中解码,都依赖于动态规划
- 本质上是很多路径中的计算过程可以复用,通过动态规划,将这些可以复用的状态记录下来,下次直接用
当然,最新版本的jieba还支持百度LAC算法(基于深度学习的序列标注模型)。
5 更新日志
- 2020-12-06 增加寻找最大路径时,对归一化频率的的说明(ntotal_word),这一项不是常数,必须加上,否则会导致结果错误。