用Python实战HMM中文分词从概率计算到Viterbi解码自然语言处理中的中文分词一直是个有趣且实用的课题。想象一下你正在开发一个电影评论分析系统用户输入这部电影太好看了如何让计算机理解这部/电影/太/好看/了这样的合理切分传统方法依赖词典匹配但遇到新词就束手无策。隐马尔可夫模型(HMM)通过概率统计的方式能自动学习分词规律即使面对未登录词也有不错的表现。今天我们不谈复杂的数学推导直接动手用Python实现一个完整的HMM分词器。你会看到从原始语料到最终分词结果整个过程就像搭积木一样清晰有趣。我们将使用一个小型电影评论语料库作为示例这样你可以立即看到模型在真实场景中的应用效果。1. 环境准备与数据理解首先确保你的Python环境安装了这些基础包pip install numpy tqdm我们使用的训练数据是人工标注的电影评论每个字后面跟着它的B/M/E/S标签B(词首)、M(词中)、E(词尾)、S(单字词)示例数据片段这/B 部/E 电/B 影/E 真/S 的/S 好/B 看/E /S 我/S 喜/B 欢/E 这/B 种/E 剧/B 情/E 片/E关键理解HMM认为每个字的标签(状态)只与前一个标签有关而当前字(观测值)只与当前标签有关。这种马尔可夫假设大大简化了问题复杂度。提示实际项目中建议至少准备10万字的标注数据。我们这里为演示简化只使用几十条评论。2. 概率统计的三部曲2.1 初始化概率矩阵我们需要三个核心概率矩阵import numpy as np from collections import defaultdict # 初始化计数器 init_count defaultdict(int) # 初始状态计数 trans_count defaultdict(int) # 状态转移计数 emit_count defaultdict(int) # 发射计数 state_count defaultdict(int) # 状态出现总次数统计过程示例def count_tags(sentences): for sentence in sentences: prev_tag None for word, tag in sentence: if prev_tag is None: # 句首字 init_count[tag] 1 else: # 非句首字 trans_count[(prev_tag, tag)] 1 emit_count[(tag, word)] 1 state_count[tag] 1 prev_tag tag统计后的原始计数示例初始状态计数{B: 15, S: 32} 转移计数{(B,E):12, (B,M):3, (E,B):7, ...} 发射计数{(B,电):5, (E,影):8, (S,我):6, ...}2.2 概率计算与平滑处理原始统计会遇到零概率问题需要拉普拉斯平滑def prob_with_smoothing(count, total, alpha1.0, states4): return (count alpha) / (total alpha * states)生成概率矩阵的核心代码tags [B, M, E, S] # 初始概率 init_prob {tag: prob_with_smoothing(init_count[tag], sum(init_count.values())) for tag in tags} # 转移概率矩阵 trans_prob {} for prev in tags: for curr in tags: key (prev, curr) trans_prob[key] prob_with_smoothing(trans_count.get(key,0), sum(v for k,v in trans_count.items() if k[0]prev)) # 发射概率 emit_prob {} for (tag, word), cnt in emit_count.items(): emit_prob[(tag, word)] cnt / state_count[tag]注意实际应用中发射概率对生僻字要做特殊处理比如统一赋予一个极小值。3. Viterbi算法实现3.1 算法原理图解Viterbi算法的精妙之处在于它通过动态规划找到概率最大的状态路径。想象你在玩一个格子游戏每个格子代表一个字可能的状态(B/M/E/S)格子之间的箭头代表转移概率格子本身的亮度代表发射概率从起点到终点要找出一条最亮的路径3.2 Python代码实现完整Viterbi实现def viterbi(sentence, init_prob, trans_prob, emit_prob, tags): # 初始化DP表格 dp [{} for _ in range(len(sentence))] # 每个字的每个状态的最大概率 path {} # 记录路径 # 初始化第一个字 for tag in tags: dp[0][tag] init_prob.get(tag, 1e-6) * emit_prob.get((tag, sentence[0]), 1e-6) path[tag] [tag] # 递推计算 for i in range(1, len(sentence)): new_path {} for curr_tag in tags: max_prob -1 best_prev_tag None for prev_tag in tags: prob dp[i-1][prev_tag] * \ trans_prob.get((prev_tag, curr_tag), 1e-6) * \ emit_prob.get((curr_tag, sentence[i]), 1e-6) if prob max_prob: max_prob prob best_prev_tag prev_tag dp[i][curr_tag] max_prob new_path[curr_tag] path[best_prev_tag] [curr_tag] path new_path # 回溯最佳路径 best_tag max(dp[-1], keydp[-1].get) return path[best_tag]测试用例text 这部电影太精彩了 tags viterbi(text, init_prob, trans_prob, emit_prob, [B,M,E,S]) print(字\t标签) for char, tag in zip(text, tags): print(f{char}\t{tag})输出示例字 标签 这 B 部 E 电 B 影 E 太 S 精 B 彩 E 了 S4. 后处理与性能优化4.1 从标签序列到分词结果将标签序列转换为最终分词def tags_to_segs(sentence, tags): segs [] word [] for char, tag in zip(sentence, tags): word.append(char) if tag in [E, S]: # 词结束或单字 segs.append(.join(word)) word [] return segs4.2 工程优化技巧对数空间计算避免概率下溢math.log(prob) # 将乘法转为加法剪枝策略每步只保留top-k路径# 在Viterbi中增加 if len(dp[i]) beam_size: dp[i] dict(sorted(dp[i].items(), keylambda x: -x[1])[:beam_size])模型持久化训练后保存概率矩阵import pickle with open(hmm_model.pkl, wb) as f: pickle.dump((init_prob, trans_prob, emit_prob), f)在实际项目中我通常会先用jieba等成熟工具生成标注数据来训练自己的HMM模型这样既能保证数据质量又能理解底层机制。当遇到特定领域文本时这种自训练的模型往往比通用模型表现更好。
别再死记硬背B/M/E/S了!用Python手把手带你跑通HMM中文分词(附完整代码与语料)
发布时间:2026/6/3 21:33:18
用Python实战HMM中文分词从概率计算到Viterbi解码自然语言处理中的中文分词一直是个有趣且实用的课题。想象一下你正在开发一个电影评论分析系统用户输入这部电影太好看了如何让计算机理解这部/电影/太/好看/了这样的合理切分传统方法依赖词典匹配但遇到新词就束手无策。隐马尔可夫模型(HMM)通过概率统计的方式能自动学习分词规律即使面对未登录词也有不错的表现。今天我们不谈复杂的数学推导直接动手用Python实现一个完整的HMM分词器。你会看到从原始语料到最终分词结果整个过程就像搭积木一样清晰有趣。我们将使用一个小型电影评论语料库作为示例这样你可以立即看到模型在真实场景中的应用效果。1. 环境准备与数据理解首先确保你的Python环境安装了这些基础包pip install numpy tqdm我们使用的训练数据是人工标注的电影评论每个字后面跟着它的B/M/E/S标签B(词首)、M(词中)、E(词尾)、S(单字词)示例数据片段这/B 部/E 电/B 影/E 真/S 的/S 好/B 看/E /S 我/S 喜/B 欢/E 这/B 种/E 剧/B 情/E 片/E关键理解HMM认为每个字的标签(状态)只与前一个标签有关而当前字(观测值)只与当前标签有关。这种马尔可夫假设大大简化了问题复杂度。提示实际项目中建议至少准备10万字的标注数据。我们这里为演示简化只使用几十条评论。2. 概率统计的三部曲2.1 初始化概率矩阵我们需要三个核心概率矩阵import numpy as np from collections import defaultdict # 初始化计数器 init_count defaultdict(int) # 初始状态计数 trans_count defaultdict(int) # 状态转移计数 emit_count defaultdict(int) # 发射计数 state_count defaultdict(int) # 状态出现总次数统计过程示例def count_tags(sentences): for sentence in sentences: prev_tag None for word, tag in sentence: if prev_tag is None: # 句首字 init_count[tag] 1 else: # 非句首字 trans_count[(prev_tag, tag)] 1 emit_count[(tag, word)] 1 state_count[tag] 1 prev_tag tag统计后的原始计数示例初始状态计数{B: 15, S: 32} 转移计数{(B,E):12, (B,M):3, (E,B):7, ...} 发射计数{(B,电):5, (E,影):8, (S,我):6, ...}2.2 概率计算与平滑处理原始统计会遇到零概率问题需要拉普拉斯平滑def prob_with_smoothing(count, total, alpha1.0, states4): return (count alpha) / (total alpha * states)生成概率矩阵的核心代码tags [B, M, E, S] # 初始概率 init_prob {tag: prob_with_smoothing(init_count[tag], sum(init_count.values())) for tag in tags} # 转移概率矩阵 trans_prob {} for prev in tags: for curr in tags: key (prev, curr) trans_prob[key] prob_with_smoothing(trans_count.get(key,0), sum(v for k,v in trans_count.items() if k[0]prev)) # 发射概率 emit_prob {} for (tag, word), cnt in emit_count.items(): emit_prob[(tag, word)] cnt / state_count[tag]注意实际应用中发射概率对生僻字要做特殊处理比如统一赋予一个极小值。3. Viterbi算法实现3.1 算法原理图解Viterbi算法的精妙之处在于它通过动态规划找到概率最大的状态路径。想象你在玩一个格子游戏每个格子代表一个字可能的状态(B/M/E/S)格子之间的箭头代表转移概率格子本身的亮度代表发射概率从起点到终点要找出一条最亮的路径3.2 Python代码实现完整Viterbi实现def viterbi(sentence, init_prob, trans_prob, emit_prob, tags): # 初始化DP表格 dp [{} for _ in range(len(sentence))] # 每个字的每个状态的最大概率 path {} # 记录路径 # 初始化第一个字 for tag in tags: dp[0][tag] init_prob.get(tag, 1e-6) * emit_prob.get((tag, sentence[0]), 1e-6) path[tag] [tag] # 递推计算 for i in range(1, len(sentence)): new_path {} for curr_tag in tags: max_prob -1 best_prev_tag None for prev_tag in tags: prob dp[i-1][prev_tag] * \ trans_prob.get((prev_tag, curr_tag), 1e-6) * \ emit_prob.get((curr_tag, sentence[i]), 1e-6) if prob max_prob: max_prob prob best_prev_tag prev_tag dp[i][curr_tag] max_prob new_path[curr_tag] path[best_prev_tag] [curr_tag] path new_path # 回溯最佳路径 best_tag max(dp[-1], keydp[-1].get) return path[best_tag]测试用例text 这部电影太精彩了 tags viterbi(text, init_prob, trans_prob, emit_prob, [B,M,E,S]) print(字\t标签) for char, tag in zip(text, tags): print(f{char}\t{tag})输出示例字 标签 这 B 部 E 电 B 影 E 太 S 精 B 彩 E 了 S4. 后处理与性能优化4.1 从标签序列到分词结果将标签序列转换为最终分词def tags_to_segs(sentence, tags): segs [] word [] for char, tag in zip(sentence, tags): word.append(char) if tag in [E, S]: # 词结束或单字 segs.append(.join(word)) word [] return segs4.2 工程优化技巧对数空间计算避免概率下溢math.log(prob) # 将乘法转为加法剪枝策略每步只保留top-k路径# 在Viterbi中增加 if len(dp[i]) beam_size: dp[i] dict(sorted(dp[i].items(), keylambda x: -x[1])[:beam_size])模型持久化训练后保存概率矩阵import pickle with open(hmm_model.pkl, wb) as f: pickle.dump((init_prob, trans_prob, emit_prob), f)在实际项目中我通常会先用jieba等成熟工具生成标注数据来训练自己的HMM模型这样既能保证数据质量又能理解底层机制。当遇到特定领域文本时这种自训练的模型往往比通用模型表现更好。