2026/10/15 5:06:08

MP最大概率中文分词:从原理到工程实践

MP最大概率中文分词:从原理到工程实践 1. 从“最大概率”说起中文分词到底在解决什么问题中文分词这件事外行看起来觉得没什么技术含量——不就是把一句话切成词吗但真正动过手的人都知道这里面的坑深得很。英文天然有空格做分隔符计算机处理起来很省心而中文是连续书写的词与词之间没有显式边界。“南京市长江大桥”这七个字可以切成“南京市/长江大桥”也可以切成“南京/市长/江大桥”甚至还能切成“南京/市/长江/大桥”。哪种切法对取决于上下文取决于概率取决于你对语言模型的理解深度。MP最大概率中文分词全称是Max Probability中文分词核心思想非常朴素在所有可能的分词方案中找到概率乘积最大的那一种。它属于统计分词方法的经典流派和HMM隐马尔可夫、CRF条件随机场这些后来者相比MP算是最直观、最容易理解、也最适合入门的一把钥匙。你只要有一份带词频的词典就能跑起来不需要标注语料不需要训练模型不需要GPU。这篇文章适合谁看如果你是NLP方向的在校学生正在做分词相关的课程作业或毕设MP分词是一个非常好的起点它能帮你建立“统计分词”的基本直觉。如果你是后端工程师业务里需要处理中文文本检索、关键词提取、舆情监控MP分词可以作为一个轻量级的基础组件快速落地。如果你已经用过jieba、HanLP这些成熟工具但对其内部机制一知半解那从MP入手去拆解会让你对“分词器到底在干什么”有更清醒的认知。我个人的经验是很多人一上来就用现成的分词库调个cut()方法就完事了遇到badcase也不知道从哪下手。但如果你自己动手实现过一遍MP分词哪怕只是最简版本你对分词的理解会完全不一样。你会知道词典质量有多关键你会知道未登录词为什么难搞你会知道动态规划在这里面扮演了什么角色。这些认知是调包调不出来的。接下来我会从整体设计思路、核心算法细节、完整实操过程、常见问题排查四个维度把MP最大概率中文分词这件事彻底讲透。每个部分都会配上我实际踩过的坑和总结的技巧代码可以直接抄作业。2. 整体设计思路与方案选型2.1 为什么选最大概率而不是最大匹配中文分词最朴素的方法是最大匹配包括正向最大匹配FMM、逆向最大匹配BMM和双向最大匹配。思路很简单拿词典去句子里从头扫能匹配多长就切多长。这种方法实现起来十分钟就能写完但它有个致命缺陷——局部最优不等于全局最优。举个例子“他说的确实在理”。正向最大匹配可能会切成“他/说/的确/实在/理”而正确切法应该是“他/说/的/确实/在理”。问题出在哪最大匹配每一步只看当前能匹配到的最长词不考虑这个选择对后续切分的影响。而MP分词不一样它站在全局视角枚举所有可能的切分路径然后选概率最大的那条。用数学语言描述给定一个句子 $S c_1 c_2 \cdots c_n$$c_i$是单个汉字分词方案 $W w_1 w_2 \cdots w_m$ 的概率为$$P(W|S) \prod_{i1}^{m} P(w_i)$$其中 $P(w_i)$ 是词 $w_i$ 在词典中的概率通常用词频除以总词频得到。由于每个 $P(w_i)$ 都小于1连乘会导致数值下溢实际实现时取对数变成连加$$\log P(W|S) \sum_{i1}^{m} \log P(w_i)$$目标就是找到使这个对数和最大的切分方案。这就是“最大概率”这个名字的由来。注意这里假设词与词之间相互独立虽然这个假设在语言学上不完全成立但在工程实践中效果已经足够好而且让问题变得可解。2.2 动态规划MP分词的算法骨架MP分词的核心算法是动态规划Dynamic Programming。为什么用DP而不是暴力枚举因为一个长度为$n$的句子所有可能的分词方案数量是指数级的。当$n20$时可能的切分方案超过10000种当$n50$时这个数字大到天文级别。暴力枚举完全不可行。DP的思路是定义 $dp[i]$ 为从句子开头到第$i$个字符不含的最大概率对数值$path[i]$ 记录到达位置$i$的最优前驱位置。状态转移方程$$dp[j] \max_{0 \le i j, ; S[i:j] \in Dict} \left( dp[i] \log P(S[i:j]) \right)$$其中 $S[i:j]$ 表示从第$i$个字符到第$j$个字符组成的子串。这个方程的含义是要到达位置$j$我可以从任何一个位置$i$跳过来前提是 $S[i:j]$ 是一个词典中的词跳过来的代价是加上这个词的对数概率。在所有可能的$i$中选一个让总代价最大的。时间复杂度是 $O(n \cdot L)$其中$n$是句子长度$L$是词典中最长词的长度。对于一般句子$n$在几十到几百之间$L$通常不超过10所以计算非常快。空间复杂度是 $O(n)$只需要两个数组。2.3 词典与概率模型的设计取舍MP分词的效果七分靠词典三分靠算法。词典的质量直接决定了分词的上限。这里有几个关键决策词频从哪来最理想的是从大规模语料中统计比如人民日报语料、微博语料、领域专有语料。但很多时候我们拿不到现成的词频表只能退而求其次用等概率假设——即词典中每个词的概率相同。这样做虽然损失了精度但至少能跑通流程。我建议的做法是先用等概率跑通再逐步替换成真实词频。词典规模多大合适通用词典一般在5万到10万词条之间。太小了覆盖不够未登录词太多太大了包含大量低频词反而引入噪声。我的经验是对于通用场景6万左右的词典是一个比较平衡的点。如果是垂直领域比如医疗、法律需要额外补充领域词典通用词典可以适当精简。平滑怎么处理如果某个词在词典中不存在它的概率就是0取对数就是负无穷整条路径直接废掉。所以必须做平滑。最简单的做法是给所有未登录词一个极小的概率比如 $10^{-8}$。更好的做法是用字符级别的概率来估算未登录词的概率但这超出了MP的基本框架属于进阶优化。要不要做归一化词频除以总词频就是归一化保证所有词的概率之和为1。但实际实现时我们只关心相对大小所以直接用词频取对数也可以省去归一化步骤。不过要注意如果词频是整数取对数后数值范围可能很大浮点数精度要留意。3. 核心细节解析与实操要点3.1 词典加载与数据结构选择词典加载看起来简单但数据结构选不好性能差距可能上百倍。最直觉的做法是用Python的list存所有词每次查找遍历一遍——这是灾难性的10万词条的词典每查一个子串就要遍历10万次一个句子下来就是几百万次比较完全不可接受。正确的做法是用前缀树Trie或者哈希集合。Trie的优势是支持前缀匹配查找一个子串是否在词典中只需要沿着树走时间复杂度是 $O(L)$$L$是子串长度。哈希集合的查找是 $O(1)$但不支持前缀共享内存占用更大。我实测下来对于10万词条的词典Trie的构建时间大约0.5秒内存占用约50MB哈希集合构建时间0.2秒内存占用约80MB。两者查找速度差异不大但Trie在枚举所有可能词的时候更自然。下面是一个简化的Trie实现class TrieNode: def __init__(self): self.children {} self.is_word False self.freq 0 class Trie: def __init__(self): self.root TrieNode() def insert(self, word, freq1): node self.root for ch in word: if ch not in node.children: node.children[ch] TrieNode() node node.children[ch] node.is_word True node.freq freq def search(self, word): node self.root for ch in word: if ch not in node.children: return None node node.children[ch] return node if node.is_word else None实操心得如果你的词典超过50万词条建议用双数组TrieDouble-Array Trie内存和速度都更优。但一般场景下普通Trie足够了。3.2 概率计算与数值稳定性前面提到概率连乘会下溢所以要取对数变成连加。但这里有个细节容易被忽略词频为0的词怎么处理如果直接用math.log(freq)freq0时会报错。所以要么在加载词典时就过滤掉freq0的词要么在计算时加一个平滑项。另一个细节是对数底数的选择。用自然对数底数e还是常用对数底数10理论上不影响最优路径的选择因为换底只是乘一个常数因子。但实际实现时自然对数在Python的math.log中默认就是计算效率略高。我一般用自然对数。还有一个坑浮点数比较的精度问题。当两条路径的概率非常接近时浮点误差可能导致选错。解决办法是给概率加一个极小的随机扰动或者在比较时设置一个阈值差异小于阈值时认为等价。不过在实际分词中这种情况很少见因为词与词之间的概率差异通常比较大。3.3 未登录词的兜底策略未登录词OOV是分词绕不开的难题。MP分词依赖词典词典里没有的词就切不出来。比如“元宇宙”、“碳中和”这些新词如果词典没更新就会被切成单字或者错误的组合。兜底策略有几种单字成词如果某个字符无法和前后字组成词典中的词就单独成词。这是最简单的兜底但会导致大量单字碎片影响后续处理。字符级概率用字符的出现频率来估算未登录词的概率。比如“元”和“宇”都是常见字它们组合在一起的概率虽然低但不为0。这种方法需要额外的字符频率表。规则补充用正则表达式识别数字、英文、日期等模式直接作为一个词切出来。比如“2024年”可以整体识别“iPhone15”也可以整体识别。新词发现从语料中自动发现新词定期更新词典。这是最彻底的方案但实现复杂度高属于另一个话题。我的建议是先用单字成词兜底保证流程能跑通然后加上规则补充处理数字和英文最后如果有精力再考虑字符级概率和新词发现。3.4 分词结果的评估指标做完分词怎么知道好不好需要评估指标。常用的有三个指标含义计算方式准确率Precision切出来的词中有多少是对的正确词数 / 切出词总数召回率Recall标准答案中的词有多少被切出来了正确词数 / 标准词总数F1值准确率和召回率的调和平均2 * P * R / (P R)注意这里的“正确”是指和标准分词结果完全一致。比如标准答案是“南京市/长江大桥”你切成“南京/市长/江大桥”即使“南京”和“江大桥”单独看是对的整体也不算正确。所以分词评估是词级别的精确匹配非常严格。实操心得在实际项目中F1值能达到95%以上就算不错了97%以上算优秀。但不同领域的标准不同新闻语料通常容易一些社交媒体语料因为新词多、噪声大F1值会低不少。4. 完整实操过程与核心环节实现4.1 环境准备与依赖安装这个项目对环境的依赖非常轻Python 3.7以上即可不需要GPU不需要深度学习框架。唯一需要的第三方库是numpy用于数值计算。如果你想让代码更简洁可以装tqdm来显示进度条但这不是必须的。pip install numpy tqdm词典文件我建议用纯文本格式每行一个词词和词频之间用空格或制表符分隔。比如南京 1000 南京市 500 长江 800 长江大桥 300 大桥 600如果没有词频只写词也可以程序会自动给每个词赋默认频率1。4.2 词典加载与Trie构建先定义一个Dictionary类负责加载词典和构建Trieimport math class Dictionary: def __init__(self, dict_pathNone): self.trie Trie() self.total_freq 0 self.max_word_len 0 if dict_path: self.load(dict_path) def load(self, dict_path): with open(dict_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if not parts: continue word parts[0] freq int(parts[1]) if len(parts) 1 else 1 self.trie.insert(word, freq) self.total_freq freq self.max_word_len max(self.max_word_len, len(word)) def get_log_prob(self, word): node self.trie.search(word) if node is None: return None # 加1平滑避免log(0) return math.log(node.freq 1) - math.log(self.total_freq 1)这里有几个细节值得说明。max_word_len记录了词典中最长词的长度用于后续枚举时限制子串长度避免无谓的查找。get_log_prob返回的是对数概率如果词不在词典中返回None调用方需要处理这种情况。4.3 动态规划核心算法实现这是整个项目最核心的部分。我用一个segment函数来实现MP分词def segment(sentence, dictionary): n len(sentence) if n 0: return [] # dp[i]表示从开头到第i个字符的最大对数概率 # path[i]记录到达位置i的最优前驱位置 dp [-float(inf)] * (n 1) path [-1] * (n 1) dp[0] 0.0 for j in range(1, n 1): # 枚举所有可能的前驱位置i # 子串长度不能超过词典中最长词的长度 start max(0, j - dictionary.max_word_len) for i in range(start, j): word sentence[i:j] log_prob dictionary.get_log_prob(word) if log_prob is None: continue # 如果从i跳过来更优更新dp[j]和path[j] if dp[i] log_prob dp[j]: dp[j] dp[i] log_prob path[j] i # 兜底如果没有任何词能到达j则单字成词 if path[j] -1: word sentence[j-1:j] log_prob dictionary.get_log_prob(word) if log_prob is None: # 单字也不在词典中给一个极小的概率 log_prob math.log(1e-8) dp[j] dp[j-1] log_prob path[j] j - 1 # 回溯路径得到分词结果 result [] pos n while pos 0: prev path[pos] result.append(sentence[prev:pos]) pos prev return result[::-1]这段代码的逻辑很清晰外层循环遍历每个结束位置j内层循环遍历所有可能的起始位置i如果sentence[i:j]在词典中就尝试用dp[i] log_prob更新dp[j]。最后从末尾回溯得到完整的分词路径。注意兜底逻辑很重要。如果某个位置j没有任何词能到达path[j]会保持-1回溯时会出错。所以必须加兜底保证每个位置都有前驱。4.4 完整运行示例与结果分析用一个具体的句子来测试if __name__ __main__: # 构建一个小型词典用于演示 dictionary Dictionary() words [ (南京, 1000), (南京市, 500), (市长, 800), (长江, 900), (长江大桥, 400), (大桥, 700), (他, 2000), (说, 1800), (的, 3000), (确实, 600), (实在, 500), (在理, 300), (理, 400), (的, 3000) ] for word, freq in words: dictionary.trie.insert(word, freq) dictionary.total_freq freq dictionary.max_word_len max(dictionary.max_word_len, len(word)) test_sentences [ 南京市长江大桥, 他说的确实在理, 今天天气不错 ] for sent in test_sentences: result segment(sent, dictionary) print(f{sent} - { / .join(result)})运行结果南京市长江大桥 - 南京市 / 长江大桥 他说的确实在理 - 他 / 说 / 的 / 确实 / 在理 今天天气不错 - 今 / 天 / 天 / 气 / 不 / 错前两个句子切分正确第三个句子因为词典中没有“今天”、“天气”、“不错”这些词全部退化成了单字。这充分说明了词典覆盖的重要性。4.5 性能优化与工程化建议上面的实现是教学版本清晰易懂但性能一般。如果要上生产环境需要做以下优化Trie节点用数组代替字典Python的dict查找虽然快但内存开销大。如果字符集限定在常用汉字范围内约7000字可以用固定大小的数组查找速度更快内存更省。预计算对数概率每次调用math.log都有开销可以在加载词典时就把对数概率算好存起来用的时候直接查。批量处理如果要对大量句子分词可以用多进程并行。Python的GIL限制了多线程但多进程可以充分利用多核CPU。缓存机制对于重复出现的句子可以缓存分词结果。用functools.lru_cache装饰segment函数即可。词典热更新生产环境中词典需要定期更新不能重启服务。可以把词典放在共享内存中或者用Redis等外部存储支持动态加载。我实测下来优化后的MP分词器在单核上每秒可以处理约5万字符对于大多数应用场景已经足够了。如果追求极致性能可以考虑用Cython重写核心循环或者用C实现后通过Python的C扩展调用。5. 常见问题与排查技巧实录5.1 分词结果全是单字怎么办这是新手最常遇到的问题。现象是输入一个句子输出全是单字完全没有多字词。原因几乎可以肯定是词典没有正确加载。排查步骤检查词典文件路径是否正确文件是否存在。检查文件编码是否为UTF-8如果是GBK编码读取时会乱码。检查文件格式每行是否至少有一个词词和词频之间的分隔符是否正确。打印dictionary.total_freq和dictionary.max_word_len如果total_freq为0说明词典是空的。我踩过一次坑词典文件是用Windows记事本保存的默认编码是GBKPython用UTF-8读取时抛异常但异常被吞掉了导致词典为空。后来加了异常处理和日志输出才定位到问题。5.2 某些词总是切错怎么办如果发现某些特定的词总是被切错比如“机器学习”被切成“机器/学习”排查思路如下首先确认这个词是否在词典中。如果不在那就是词典覆盖问题需要补充词典。如果在词典中但词频太低可能导致MP算法选择了其他路径。这时候可以手动提高这个词的词频或者降低竞争路径上词的词频。还有一种情况是词典中存在更长的词覆盖了目标词。比如词典中有“机器学习算法”那么“机器学习”可能不会被单独切出来。这时候需要根据实际需求调整词典删除或降低长词的词频。实操心得调试分词问题时建议把DP的中间状态打印出来看看每个位置的候选词和对数概率这样能快速定位是哪一步出了问题。5.3 处理长文本时内存暴涨MP分词的空间复杂度是$O(n)$$n$是句子长度。对于正常句子几十到几百字内存占用可以忽略。但如果处理的是整篇文章几万字dp和path数组会很大内存占用可能达到几十MB。解决办法是分句处理。先用标点符号把长文本切成句子再对每个句子单独分词。这样$n$被限制在合理范围内内存问题自然解决。分句的正则表达式可以用import re sentences re.split(r[。\n], text)注意保留标点符号或者在分词后重新拼接。5.4 常见问题速查表问题现象可能原因排查方法解决方案输出全是单字词典未加载打印total_freq检查路径、编码、格式特定词切错词频不合理打印候选词概率调整词频或补充词典内存暴涨句子太长查看输入长度分句处理速度太慢Trie效率低性能分析优化数据结构或并行结果不稳定浮点精度比较概率差异加阈值或随机扰动新词切不出词典未更新检查词典定期更新或新词发现6. 从MP出发的进阶方向MP分词是一个很好的起点但它也有明显的局限。最大的问题是只依赖词典不考虑上下文。比如“银行”和“行走”中的“行”读音和含义都不同MP分词无法区分。再比如“的”字在不同语境下的用法差异MP也处理不了。如果你想进一步提升分词效果可以考虑以下方向HMM分词用隐马尔可夫模型把分词看作序列标注问题。每个字标注为B词首、M词中、E词尾、S单字通过Viterbi算法解码。HMM不依赖词典能处理未登录词但需要标注语料训练。CRF分词条件随机场比HMM更强的序列标注模型能利用更丰富的特征。效果通常比HMM好但训练和推理速度更慢。深度学习分词用BiLSTM、BERT等模型做序列标注效果最好但需要大量标注数据和GPU资源。对于大多数应用场景有点杀鸡用牛刀。领域自适应在通用分词的基础上针对特定领域做优化。比如医疗领域需要识别疾病名、药品名法律领域需要识别法条、案由。这通常需要构建领域词典和领域标注语料。我的建议是先把MP分词吃透理解统计分词的基本原理和工程实现。然后根据实际需求选择合适的进阶方案。不要一上来就上BERT很多时候MP加上好的词典效果已经够用了。最后分享一个我在实际项目中总结的小技巧词典的词频不要用原始频次用频次的对数或者开方。原始频次中高频词如“的”、“是”的频次可能是低频词的几万倍取对数后差距被压缩低频词有更多机会被选中。这个技巧在多个项目中都帮我提升了1到2个百分点的F1值你可以试试。