ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文分词歧义消解:从最长匹配到二元语法与Viterbi

中文分词歧义消解:从最长匹配到二元语法与Viterbi

简介:这份资源是南京邮电大学自然语言处理课程实验一的完整实验报告,面向正在学习中文分词与句法分析的高校学生及自学者,帮助其理解词典分词与二元语法分词的核心流程。报告围绕HanLP工具展开,涵盖segment分词指令、--no-tag参数、文件输入输出、parse句法分析及Python代码实现,并对比前向、后向与双向最长匹配三种词典分词算法的差异,附有核心词典路径查询与DoubleArrayTrieSegment等类的使用示例。资源包共1个doc文件,约232KB,为可直接参考的实验报告文档,结构完整、步骤清晰。已有428人学习下载,适合需要对照实验要求完成报告、复盘分词与依存句法分析操作细节的读者使用。

1. 分词这件事,为什么“最长匹配”反而会翻车

“结婚的和尚未结婚的”——这句话扔给任何一个中文分词器,都是一道送命题。人一眼能读出“结婚/的/和/尚未/结婚/的”,但机器如果只认“最长优先”,就会切出“结婚/的/和尚/未/结婚/的”,凭空造出一个和尚。这个反直觉的现象说明:词典分词的核心矛盾从来不是“词典够不够大”,而是歧义消解的决策顺序。自然语言处理入门的第一道坎就卡在这里——你手里有一部词典、一条规则,却依然切不对句子。这份实验资源把词典分词、二元语法分词、句法分析串成一条线,恰好能让人看清:从“查表切词”到“统计选路”,中间到底换了什么底层机制。搞懂这条演进线,后面再碰任何分词器,你都能一眼看出它在哪一层做取舍。

2. 词典分词:最长匹配为什么会在歧义句上翻车

2.1 前向、后向、双向最长匹配的决策差异

词典分词的骨架极其朴素:维护一个词典,从左到右(或从右到左)扫描,每次取词典里能匹配上的最长词。前向最长匹配从句子开头出发,后向最长匹配从句子末尾倒着走,双向最长匹配则两边各跑一遍,再按“词数最少、单字最少”之类的启发式规则选一个结果。

三种算法在“结婚的和尚未结婚的”上的分歧,正好暴露了各自的盲区:

算法分词结果问题所在
前向最长匹配结婚/的/和尚/未/结婚/的在“和”位置贪心吞下“和尚”,把“尚未”拆散
后向最长匹配结婚/的/和/尚未/结婚/的从右往左时“尚未”先被匹配,结果正确
双向最长匹配结婚/的/和/尚未/结婚/的两路结果比对后取词数更少的一路

前向匹配的失败不是词典缺词,而是贪心策略的局部最优不等于全局最优。扫描到“和”时,词典里同时存在“和”和“和尚”,前向算法按“最长优先”选了“和尚”,后面就只能把“尚未”切成“未”。后向算法从“的”倒着走,先锁定“尚未”,再往前处理“和”,贪心的方向一变,结果就对了。双向匹配的价值不在于它一定对,而在于它提供了两个候选,让启发式规则有机会纠偏——这是“用冗余换准确率”的典型工程思路。

2.2 用 Python 复现三种匹配,看清贪心边界

教材第二章的代码思路可以直接落成可运行的实现。下面这段用同一部小词典跑三种策略,重点看forward_max_match里max_len的收缩逻辑:

# 三种最长匹配的极简实现,词典用 set 模拟 dictionary = {"结婚", "的", "和", "和尚", "尚未", "未", "商品", "和服", "服务", "物美价廉", "货币"} max_word_len = max(len(w) for w in dictionary) def forward_max_match(text): result, i = [], 0 while i < len(text): matched = None # 从最长可能长度往下试,第一个命中即为最长匹配 for length in range(min(max_word_len, len(text) - i), 0, -1): piece = text[i:i + length] if piece in dictionary: matched = piece break if matched is None: # 词典未登录字,单字成词 matched = text[i] result.append(matched) i += len(matched) return result def backward_max_match(text): result, i = [], len(text) while i > 0: matched = None for length in range(min(max_word_len, i), 0, -1): piece = text[i - length:i] if piece in dictionary: matched = piece break if matched is None: matched = text[i - 1] result.insert(0, matched) # 倒序扫描,结果前插 i -= len(matched) return result def bidirectional_max_match(text): fwd, bwd = forward_max_match(text), backward_max_match(text) # 启发式:词数少者优先;词数相同取单字少者 def score(seg): return (len(seg), sum(1 for w in seg if len(w) == 1)) return fwd if score(fwd) <= score(bwd) else bwd sentence = "结婚的和尚未结婚的" print(forward_max_match(sentence)) # ['结婚', '的', '和尚', '未', '结婚', '的'] print(backward_max_match(sentence)) # ['结婚', '的', '和', '尚未', '结婚', '的'] print(bidirectional_max_match(sentence))# ['结婚', '的', '和', '尚未', '结婚', '的']

forward_max_match的内层循环从min(max_word_len, 剩余长度)开始递减,第一个命中的就是最长词——这个“从长到短试”的顺序就是贪心的全部。backward_max_match把扫描方向反过来,result.insert(0, ...)保证输出顺序正确。bidirectional_max_match的score函数是双向匹配的灵魂:词数越少通常越合理,单字越少说明切分越“整”。参数上唯一需要调的是词典本身,max_word_len由词典自动推出,不需要手填。

提示:双向匹配的启发式规则是经验性的,遇到“词数相同但都合理”的句子仍会选错。它降低歧义,但不消除歧义——这正是后面要引入统计模型的动机。

2.3 DoubleArrayTrie 与 AhoCorasick:词典分词的两种数据结构

实验里用DoubleArrayTrieSegment和AhoCorasickDoubleArrayTrieSegment对同一段春分文字分词,结果差异极大:前者切出“春分/之/特别/均分/昼夜”,后者几乎逐字切开。这不是 bug,而是两个类加载的词典不同、匹配策略不同。

双数组 Trie(DoubleArrayTrie)把词典压进两个整型数组base和check,状态转移变成base[s] + c的数组下标运算,查询是 O(词长) 的纯内存跳转,没有哈希冲突、没有指针追逐。它适合“给定起点找最长词”的前向最大匹配。Aho-Corasick 自动机则把词典建成一棵 Trie 再加失败指针,一次扫描能同时命中所有模式串,本质是多模式串匹配,常用于敏感词过滤、停用词替换这类“一次扫全文、命中所有词”的场景。实验里 AhoCorasick 版本逐字输出,是因为它加载的词典规模或匹配模式与前者不同,导致长词没被整体命中——这恰恰说明:同一个“词典分词”名字下,数据结构和词典内容一变,行为就完全不同。

停用词替换那段代码用的就是 AC 自动机的getLongestSearcher:把停用词表建成 DoubleArrayTrie,然后对文本做最长匹配搜索,命中就替换成*。searcher.begin和searcher.length给出命中区间,offset负责把未命中的片段原样拼回。这套“建 Trie → 流式扫描 → 区间替换”的模式,是文本清洗里最常用的底层套路。

3. 二元语法分词:Viterbi 如何在词图上选一条最优路径

3.1 从“查词典”到“算概率”:一元与二元模型的分工

词典分词只回答“这个词在不在词典里”,二元语法分词回答的是“这串字切成这样,概率有多大”。实验里NatureDictionaryMaker.compute()对语料统计后吐出三个文件,各自职责必须分清:

文件内容作用
my_cws_corpus_model.txt单词 + 词性 + 频次一元模型,提供每个词的发射频次
my_cws_corpus_model.ngram.txt相邻词对 + 频次二元模型,提供词与词之间的转移频次
my_cws_corpus_model.tr.txt词性转移相关统计词性标注用的转移矩阵

一元模型解决“这个词本身常不常见”,二元模型解决“这两个词挨在一起合不合理”。语料只有“商品 和 服务 / 商品 和服 物美价廉 / 服务 和 货币”三行,统计出的“商品”频次是 2、“和”频次是 2,这些数字就是后续算路径得分的原料。二元模型的关键价值在于:即使“和服”和“商品”都是词典词,“商品/和服”这个相邻组合在语料里没出现过,它的转移概率就会很低,Viterbi 自然会绕开这条路径。

3.2 ViterbiSegment 的路径打分与回溯

ViterbiSegment的工作分两步:先把句子按词典展开成一张有向无环词图(每个位置作为节点,词典词作为边),再在图上跑动态规划找得分最高的路径。得分通常取对数概率之和,避免浮点下溢:

# Viterbi 在词图上选最优路径的骨架(对数域) import math def viterbi_segment(text, unigram_freq, bigram_freq, total): n = len(text) # score[i] 表示从起点到位置 i 的最优累计得分 score = [-math.inf] * (n + 1) back = [0] * (n + 1) # 回溯指针:记录最优前驱位置 score[0] = 0.0 for j in range(1, n + 1): for i in range(j): # 枚举所有以 j 结尾的词 text[i:j] word = text[i:j] if word not in unigram_freq: continue # 一元得分:log(词频/总词频) emit = math.log(unigram_freq[word] / total) # 二元得分:若存在前驱词,叠加转移概率 trans = 0.0 if i > 0: prev = text[back[i]:i] pair = prev + "/" + word trans = math.log((bigram_freq.get(pair, 0) + 1) / (unigram_freq.get(prev, 1) + total)) cand = score[i] + emit + trans if cand > score[j]: score[j], back[j] = cand, i # 从末尾回溯出整条路径 path, j = [], n while j > 0: path.append(text[back[j]:j]) j = back[j] return list(reversed(path))

score[j]存的是“切到第 j 个字符时的最高累计对数概率”,back[j]记录这个最优值是从哪个位置跳过来的。内层for i in range(j)枚举所有可能以j结尾的词,unigram_freq里没有的词直接跳过——这就是词典在统计分词里的角色:词典负责生成候选边,统计模型负责给边打分。trans里的+1是加一平滑,防止未见二元组概率为零把整条路径打死。最后从n往回走back指针,就还原出最优切分。

实验里对“商品和服务”跑出的结果是[商品/n, 和/n, 服务/n],注意“和”被标成了n——因为训练代码里word.setLabel("n")把所有词性统一设成了名词。这说明词性标注的质量完全取决于训练语料的标注,语料偷懒,模型就跟着偷懒。

3.3 训练语料规模如何决定分词上限

三行语料训练出的模型,对“商品和服务”能切对,是因为这三个词都在语料里出现过。换一个语料里没见过的句子,Viterbi 只能在词典词构成的候选边里选,未见词对全靠平滑兜底,效果会断崖式下跌。这揭示了一个常被忽略的事实:二元语法分词的上限由训练语料的覆盖度决定,词典只决定候选空间。实验用小语料是为了让统计过程可观察,真实场景里语料规模、领域匹配度才是决定分词质量的主变量。理解这一点,就不会迷信“换个分词器就能解决所有问题”。

4. 避坑与排查:实验里那些让人卡住的瞬间

4.1 停用词文件读取报 UnicodeDecodeError

现象:跑停用词替换代码时抛UnicodeDecodeError: 'gbk' codec can't decode byte 0x90。原因:open(path)不指定编码时,Windows 默认用 GBK 解码,而停用词表是 UTF-8。解决:open(path, encoding='utf-8')显式指定编码。这类问题在中文文本处理里高频出现,凡是读文件,编码参数不要省。

4.2 命令行分词结果和 Python 调用不一致

现象:hanlp segment命令行切出的结果,和HanLP.segment()在 Python 里跑出来的不完全一样。原因:命令行默认走 CRF 模型(实验里-a crf就是指定算法),而HanLP.segment()默认走 Viterbi。解决:要么在 Python 里显式指定HanLP.newSegment().enableCustomDictionary(...)并选算法,要么统一用同一种调用方式做对比。对比实验前先确认算法一致,否则结论全是噪音。

4.3 自定义模型路径没生效,还在用默认词典

现象:训练完my_cws_corpus_model,加载后分词结果和默认模型没区别。原因:HanLP.Config.CoreDictionaryPath和BiGramDictionaryPath必须在构造 Segment 实例之前赋值,赋值晚了实例已经绑定了旧路径。解决:把两行 Config 赋值放在ViterbiSegment()实例化之前,顺序不能反。

4.4 词性全是 n,误以为标注模块坏了

现象:训练出的模型对“商品和服务”输出[商品/n, 和/n, 服务/n],所有词性都是 n。原因:训练代码里word.setLabel("n")把语料里每个词性硬编码成了名词。解决:这是实验简化,不是 bug;要真实词性,得用带标注的语料训练,或直接加载 HanLP 预训练模型。

4.5 双向匹配结果“看起来对”但换句就错

现象:双向匹配在“结婚的和尚未结婚的”上表现完美,换一句歧义句又翻车。原因:双向匹配的启发式规则(词数少优先)只是经验法则,不保证全局最优。解决:把它当基线而非终点,歧义密集场景直接上统计模型或序列标注模型,别在规则层反复打补丁。

5. 设计取舍:规则、统计与工程直觉的边界

把这条实验线走完,会发现三种分词思路各自守着一个边界。最长匹配是规则层的方案,靠词典和贪心方向决定结果,优点是零训练、可解释、速度快,缺点是歧义句上贪心的局部性无法自愈;双向匹配用“跑两遍再比”换准确率,本质是用计算冗余对冲规则盲区。二元语法分词是统计层的方案,词典退化成候选生成器,真正做决策的是词频和转移概率,Viterbi 在词图上做全局最优,代价是需要语料、需要平滑、语料覆盖不到的地方照样失效。再往上是序列标注(CRF、BiLSTM、预训练模型),把“切不切”变成逐字分类,彻底摆脱词典候选空间的限制,但训练成本和数据依赖又上了一个台阶。

这里没有“哪个更好”,只有“在哪一层做取舍”。我一般会这么理解:规则层解决“已知词怎么切”,统计层解决“已知词怎么组合最合理”,序列标注层解决“未知词怎么边界”。实验里三行语料训练出的二元模型能切对“商品和服务”,不是模型强,而是测试句恰好落在训练分布里——这个“恰好”就是统计方法最诚实的软肋。反过来,最长匹配在“结婚的和尚未结婚的”上翻车,也不是算法笨,而是它压根没有“全局”这个概念。

验证一套分词方案是否可靠,我的习惯是构造三类测试句:词典全覆盖句(看基础切分)、歧义密集句(看消歧能力)、未登录词句(看泛化边界)。三类都过,才敢说这套方案在目标场景站得住。这份实验资源的价值,不在于教会你调某个 API,而在于它把“规则→统计”的切换过程摊开给你看:同一句话,换一层机制,结果就变,而每一层的变化都能追溯到具体的数据结构和打分函数。想亲手复现这条演进线、把每个中间结果都跑一遍的,资源里的实验步骤和代码片段就是最好的起点,照着走一遍,比读十篇概念文章都扎实。

从那以后我每次碰分词任务,都强制先跑一遍歧义句和未登录词测试,再决定用规则还是上模型——这个习惯帮我省掉了不少“上线才发现切错”的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进