资讯动态

中文分词实战:词典匹配与二元语法Viterbi解码对比

发布时间:2026/10/16 14:47:24 来源:尧图企业网站定制
简介本资源为南京邮电大学自然语言处理课程实验一的完整实验报告面向修读NLP基础课程的高校学生及希望入门中文分词与句法分析的初学者。内容围绕词典分词与二元语法分词两大核心任务展开涵盖HanLP分词指令segment的使用、词性标注与--no-tag参数、文件输入输出处理、句法分析parse指令的依存关系解读以及前向最长匹配、后向最长匹配、双向最长匹配三种词典分词算法的对比并附有基于pyhanlp的Python代码实现示例。资源包内含1个doc文档大小约232KB完整记录了实验目的、环境配置、操作步骤与结果分析可直接作为实验报告参考或复习提纲。目前已有424人学习下载适合需要快速掌握HanLP工具链、理解分词歧义与句法依存关系的学习者对照练习与查漏补缺。1. 分词不是查字典为什么“结婚的和尚未结婚的”会让机器崩溃“结婚的和尚未结婚的”这句话人一眼就能读懂但扔给分词器它可能切成“结婚/的/和尚/未/结婚/的”。这不是段子是中文分词里最经典的歧义陷阱。自然语言处理的第一步从来不是模型多深而是怎么把一串连续的汉字切成有意义的词。词典分词看起来最直接——拿词典去匹配就行但匹配方向、匹配长度、歧义消解策略每一步都在做取舍。二元语法分词则换了个思路不靠词典硬匹配而是用统计语言模型算哪种切分概率最大。这份实验资源把两种方法放在一起对照从 HanLP 命令行到 Python 代码从前向最长匹配到 Viterbi 解码完整走了一遍中文分词从规则到统计的底层逻辑。搞懂它你就明白为什么分词器不是“查字典”那么简单以及为什么“商品和服务”和“商品和服物美价廉”需要完全不同的处理策略。2. 词典分词最长匹配为什么会在歧义面前翻车2.1 前向、后向、双向最长匹配的机制差异词典分词的核心逻辑是从左到右或从右到左扫描句子每次取词典中最长的匹配词。前向最长匹配从句子开头开始找到能匹配的最长词就切出来然后从下一个位置继续。后向最长匹配反过来从句子末尾往前扫。双向最长匹配则同时跑前向和后向再按一定规则选一个结果。拿“结婚的和尚未结婚的”来跑三种算法的输出直接暴露了各自的盲区前向最长匹配[结婚, 的, 和尚, 未, 结婚, 的]后向最长匹配[结婚, 的, 和, 尚未, 结婚, 的]双向最长匹配[结婚, 的, 和, 尚未, 结婚, 的]前向匹配在“和尚”处翻车因为它从左往右扫时“和尚”比“和”长优先匹配了“和尚”结果“尚未”被拆成“未”。后向匹配从右往左扫先切出“结婚的”再往前看到“尚未”能成词于是切出“和/尚未/结婚/的”语义正确。双向匹配发现前向和后向结果不一致按“词数最少优先”的规则选了后向的结果。这里的关键机制是最长匹配只保证局部最优不保证全局语义正确。前向匹配的“和尚”在局部确实是最长匹配但放到整句里就是错的。双向匹配的消歧规则通常是“词数最少优先”或“单字最少优先”但这套规则本身也是启发式的遇到更复杂的歧义照样翻车。2.2 代码实现从词典加载到匹配扫描实验里用 HanLP 的DoubleArrayTrieSegment做词典分词底层是双数组 Trie 树。双数组 Trie 把词典的每个字映射到两个数组base和check上查询时通过状态转移快速判断某个前缀是否在词典中。相比哈希表双数组 Trie 更省内存查询也更快适合大规模词典。from pyhanlp import * # 加载核心词典构建双数组 Trie segment DoubleArrayTrieSegment() text 春分之特别在一个“均”字均分了昼夜均分了寒暑均分了春天。 result segment.seg(text) print(result)这段代码的输出里每个词后面都带/null因为DoubleArrayTrieSegment默认不加载词性标注模型只做纯词典匹配。seg方法内部调用双数组 Trie 的getLongestSearcher从当前位置开始找最长匹配词找到后把指针移到词尾继续。参数说明DoubleArrayTrieSegment构造函数可以传入自定义词典路径不传就用 HanLP 默认的核心词典。seg返回的是Term列表每个Term包含词面和词性词性为null表示未标注。2.3 Aho-Corasick 自动机多模式匹配的另一种思路实验里还用了AhoCorasickDoubleArrayTrieSegment输出结果全是单字。这不是 bug而是因为 Aho-Corasick 自动机做的是“多模式匹配”它会在一次扫描中找出所有出现在词典中的子串但不保证切分覆盖整个句子。如果词典里没有“春分”这个词它就只输出单字。Aho-Corasick 的核心是 Trie 树加失败指针。构建时每个词典词插入 Trie然后为每个节点计算失败指针指向最长后缀对应的节点。匹配时沿着文本走 Trie走不通就跳失败指针这样能在 O(n) 时间内找出所有匹配位置。它适合做关键词过滤、敏感词检测但做分词需要额外加切分逻辑否则就会像实验里那样输出一堆单字。注意AhoCorasickDoubleArrayTrieSegment的输出结果取决于词典覆盖度。如果词典里没有“春分”它就不会切出“春分”只会输出“春”和“分”。这不是算法错误是词典和算法的匹配问题。3. 二元语法分词Viterbi 解码怎么在词图上找最优路径3.1 从一元语法到二元语法概率模型的引入词典分词靠规则二元语法分词靠统计。一元语法只统计每个词单独出现的频率二元语法统计相邻两个词一起出现的频率。比如语料里有“商品 和 服务”和“商品 和服 物美价廉”一元语法知道“商品”出现 2 次“和”出现 2 次“服务”出现 1 次“和服”出现 1 次。但“商品和服务”到底该切成“商品/和/服务”还是“商品/和服/物美价廉”里的“商品/和服”一元语法给不出答案因为两种切法的词频乘积可能差不多。二元语法引入转移概率P(和|商品) 表示“商品”后面跟“和”的概率P(和服|商品) 表示“商品”后面跟“和服”的概率。在语料里“商品 和”出现 2 次“商品 和服”出现 1 次所以 P(和|商品) P(和服|商品)Viterbi 解码就会选“商品/和/服务”。3.2 训练代码从语料到模型文件实验里用NatureDictionaryMaker训练二元语法模型代码逻辑是先把语料转成Sentence列表给每个词打上词性标签“n”然后compute统计词频和二元组频次最后saveTxtTo保存成三个文件。from pyhanlp import * CorpusLoader SafeJClass(com.hankcs.hanlp.corpus.document.CorpusLoader) NatureDictionaryMaker SafeJClass(com.hankcs.hanlp.corpus.dictionary.NatureDictionaryMaker) def train_bigram(corpus_path, model_path): # 将语料文件转成 Sentence 列表 sents CorpusLoader.convert2SentenceList(corpus_path) # 给每个词打上词性标签这里统一标为名词 for sent in sents: for word in sent: word.setLabel(n) # 统计一元和二元频次 maker NatureDictionaryMaker() maker.compute(sents) # 保存模型到指定路径 maker.saveTxtTo(model_path) train_bigram(my_cws_corpus.txt, my_cws_corpus_model)CorpusLoader.convert2SentenceList按行读取语料每行按空格切分成词构建Sentence对象。NatureDictionaryMaker.compute遍历所有句子统计每个词的出现次数一元和每对相邻词的出现次数二元。saveTxtTo生成三个文件.txt存一元模型每行格式是“词 词性 频次”.ngram.txt存二元模型每行格式是“词1词2 频次”.tr.txt存词性转移模型用于词性标注。参数说明corpus_path是语料文件路径每行一个句子词之间用空格分隔。model_path是模型保存路径前缀实际会生成三个文件。词性标签“n”是占位符实际使用时可以换成真实词性。3.3 Viterbi 解码在词图上找概率最大的路径训练好模型后用ViterbiSegment对“商品和服务”分词。Viterbi 算法的核心是动态规划把句子所有可能的切分方式画成一张有向无环图每个节点是一个词边是转移概率然后找一条从起点到终点的概率最大路径。from pyhanlp import * def predict(): # 指定一元和二元模型路径 HanLP.Config.CoreDictionaryPath my_cws_corpus_model.txt HanLP.Config.BiGramDictionaryPath my_cws_corpus_model.ngram.txt # 加载 Viterbi 分词器 ViterbiSegment JClass(com.hankcs.hanlp.seg.Viterbi.ViterbiSegment) segment ViterbiSegment() s segment.seg(商品和服务) print(s) predict()输出结果是[商品/n, 和/n, 服务/n]。Viterbi 解码时先根据一元模型构建词图从位置 0 开始查词典找到所有可能的词比如“商”“商品”“商品和”等每个词作为一条边。然后计算每条边的权重一元模型给词频二元模型给转移概率。最后用动态规划从右往左算每个位置的最大概率记录最优前驱回溯得到最优路径。参数说明CoreDictionaryPath指向一元模型文件BiGramDictionaryPath指向二元模型文件。ViterbiSegment默认使用Viterbi算法也可以换成NShortSegment等其他解码器。seg返回Term列表每个Term包含词面和词性。注意二元语法分词的效果高度依赖语料规模。实验里只有 3 行语料模型只能区分“商品/和/服务”和“商品/和服/物美价廉”这种简单歧义。真实场景需要百万级甚至亿级语料否则模型会过拟合遇到未见过的词对就退化成随机切分。4. 避坑与排查HanLP 实验里最容易翻车的几个点4.1 编码报错UnicodeDecodeError: gbk codec cant decode现象读取停用词表或语料文件时Python 报UnicodeDecodeError: gbk codec cant decode byte 0x90 in position 2519。原因Windows 默认用 GBK 编码打开文件但文件本身是 UTF-8 编码GBK 解码器遇到 UTF-8 的多字节序列就报错。解决在open函数里显式指定encodingutf-8。实验里的停用词加载代码就是这么修的with open(path, encodingutf-8) as src: for word in src: word word.strip() map[word] word4.2 模型路径不对CoreDictionaryPath 指向错误文件现象加载自定义模型后分词结果全是单字或者报FileNotFoundException。原因HanLP.Config.CoreDictionaryPath和BiGramDictionaryPath必须指向训练生成的.txt和.ngram.txt文件路径写错或文件不存在都会导致加载失败。解决先用os.path.exists检查文件是否存在再设置路径。路径最好用绝对路径避免相对路径的歧义。4.3 词性标签缺失输出全是 /null现象用DoubleArrayTrieSegment分词每个词后面都是/null。原因DoubleArrayTrieSegment只加载词典不加载词性标注模型所以词性为null。解决如果需要词性改用HanLP.segment或ViterbiSegment它们会加载完整的词性和二元模型。或者手动加载词性词典设置HanLP.Config.CoreDictionaryPath指向带词性的词典文件。4.4 Aho-Corasick 输出单字词典覆盖不足现象用AhoCorasickDoubleArrayTrieSegment分词输出全是单字。原因Aho-Corasick 自动机只匹配词典中存在的词如果词典里没有“春分”“均分”等词它就只输出单字。解决换用DoubleArrayTrieSegment或ViterbiSegment它们有更完整的切分逻辑。或者扩充词典把需要的词加进去。4.5 二元模型过拟合语料太少导致切分错误现象用 3 行语料训练二元模型对“商品和服务”分词正确但对其他句子分词全错。原因语料太少二元模型只见过“商品 和”“和 服务”等少数词对遇到未见过的词对就退化成按一元模型切分甚至随机切分。解决增加语料规模至少几千行。或者用 HanLP 预训练的大模型不要自己从零训练。5. 设计取舍规则、统计与工程现实的三角博弈词典分词和二元语法分词代表了两种截然不同的设计哲学。词典分词是规则驱动核心是“匹配”优点是速度快、可解释性强、对未登录词不敏感缺点是歧义消解靠启发式规则遇到“结婚的和尚未结婚的”这种句子就翻车。二元语法分词是统计驱动核心是“概率”优点是能利用语料自动学习歧义消解策略对未见过的句子也有一定泛化能力缺点是依赖语料规模语料少就过拟合语料多就训练慢。HanLP 的工程实现里这两种方法不是互斥的而是互补的。ViterbiSegment底层用双数组 Trie 做词典匹配构建词图然后用 Viterbi 解码找最优路径。这样既保留了词典匹配的速度又引入了统计模型的歧义消解能力。DoubleArrayTrieSegment则纯词典匹配适合对速度要求极高、对歧义不敏感的场景比如关键词提取。从源码层面看HanLP 的Segment抽象类定义了seg接口子类实现具体的切分逻辑。ViterbiSegment继承Segment在seg方法里先调用generateWordNet构建词图再调用Viterbi.compute解码。DoubleArrayTrieSegment也继承Segment但seg方法直接调用双数组 Trie 的getLongestSearcher不做概率计算。这种设计把“切分”和“解码”解耦方便替换不同的解码器。我一般会这么理解分词器的设计取舍本质是在“准确率”“速度”“内存”“可维护性”之间找平衡。词典分词准确率中等、速度极快、内存小、可维护性高二元语法分词准确率高、速度中等、内存大、可维护性中等。实际工程里通常先用词典分词做粗切分再用统计模型做歧义消解最后用规则做后处理。HanLP 的ViterbiSegment就是这个思路的典型实现。从那以后我每次做分词实验都强制走一遍“词典匹配 → 词图构建 → Viterbi 解码 → 结果验证”的流程先确认词典覆盖度再检查模型文件最后看解码路径。这套流程帮我省了很多排查时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑