ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

HanLP中文分词实战:词典分词与二元语法从实验到落地

HanLP中文分词实战:词典分词与二元语法从实验到落地

简介:本资源为南京邮电大学自然语言处理课程实验一的完整实验报告,面向修读NLP基础课程的高校学生及希望入门中文分词与句法分析的初学者。报告围绕词典分词与二元语法分词两大核心任务展开,涵盖HanLP分词指令segment的使用、词性标注与--no-tag参数、文件输入输出处理、句法分析parse指令的依存关系解读,以及前向最长匹配、后向最长匹配、双向最长匹配三种词典分词算法的对比分析,并附有基于pyhanlp的Python代码实现示例。资源包内含1个doc文档,压缩包约232KB,内容为可直接参考的实验报告模板与操作记录。目前已有424人学习下载,适合需要完成同类实验、理解HanLP工具链或复习分词算法差异的读者对照使用,可帮助快速掌握实验流程与代码写法。

1. 从一份实验报告说起:词典分词和二元语法分词到底怎么落地

很多人第一次接触中文分词,都是从一份课程实验报告开始的。你手里这份资源就是典型:一份完整的自然语言处理基础实验报告,围绕词典分词和二元语法分词两条主线展开,附带可直接运行的 Python 代码、HanLP 命令行操作记录、训练语料和模型文件说明。它解决的不是“分词是什么”这种概念问题,而是“拿到 HanLP 之后,怎么跑通从命令行到 Python API、从词典匹配到二元语法训练的全流程”。适合正在做 NLP 课程实验的学生、需要快速上手 HanLP 的开发者,以及想搞清楚前向/后向/双向最长匹配到底差在哪的从业者。下面我按实际复现顺序拆一遍,把参数、坑和验证方法都摆出来。

2. 环境准备与 HanLP 命令行验证:先让 segment 和 parse 跑起来

2.1 安装与版本选择

这份实验报告明确要求 Python 3.7 或 3.8,Windows 操作系统。这不是随便写的——HanLP 的 Python 封装 pyhanlp 依赖 JPype,而 JPype 在 3.9 以上版本容易出现兼容性问题。我一般会建议用 conda 建一个独立环境,避免和系统里其他 Java 桥接库打架。

conda create -n nlp_exp python=3.8 conda activate nlp_exp pip install pyhanlp

装完之后第一件事不是急着写代码,而是验证 HanLP 的数据文件路径是否正常。pyhanlp 首次运行会自动下载数据包,如果网络环境导致下载失败,后面所有分词都会报“词典文件不存在”。

from pyhanlp import HanLP # 打印核心词典路径,确认数据文件已就位 print(HanLP.Config.CoreDictionaryPath)

正常输出应该指向 site-packages/pyhanlp/static/data/dictionary/CoreNatureDictionary.txt。如果这个路径不存在,说明数据包没下载完整,需要手动补。

2.2 命令行 segment 的三种用法

实验报告里给了三个层次的 segment 操作,我按从简到繁排一下。

第一种,交互式分词,直接看词性标注结果:

hanlp segment

输入“商品和服务”,输出“商品/n, 和/cc, 服务/vn”。这里的 n 是名词,cc 是并列连词,vn 是动名词。词性标注对后续句法分析有用,但如果你只想要纯分词结果,加--no-tag:

hanlp segment --no-tag

第二种,文件输入输出模式。这是实际工程里最常用的方式,因为没人会一条条手敲句子:

hanlp segment < input1.txt > output1.txt -a crf

这里-a crf指定了 CRF 分词算法。HanLP 默认用的是 Viterbi 算法,CRF 在未登录词识别上更稳,但速度慢一些。实验报告里对“春分最具灵性的美”那段文本用了 CRF,输出结果里“春分/t”“灵性/n”“蛰伏/v”都切得比较准。如果你换成默认算法,可能会把“春分”切成“春”和“分”。

第三种,句法分析指令 parse:

hanlp parse <<< '徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。'

输出的是依存句法树,每行包含词序号、词语、词性、依存关系。比如“帮助”是核心关系(HED),“徐先生”是主谓关系(SBV),“确定”是动宾关系(VOB)。这个输出对理解句子结构很有帮助,但要注意:parse 依赖的分词结果和 segment 可能不完全一致,因为句法分析器内部有自己的分词策略。

2.3 Python API 调用与命令行等价写法

实验报告里给了 Python 代码实现,和命令行操作是一一对应的:

from pyhanlp import HanLP # 等价于 hanlp segment 交互模式 print(HanLP.segment('商品和服务')) print(HanLP.segment('当下雨天地面积水分外严重')) print(HanLP.segment('王总和小丽结婚了')) print(HanLP.segment('欢迎新老师生前来就餐')) print(HanLP.segment('结婚的和尚未结婚的确实在干扰分词啊')) # 等价于 hanlp parse print(HanLP.parseDependency('徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。'))

这里有个细节:HanLP.segment()返回的是 Term 对象列表,直接 print 会带词性。如果你想要纯文本,需要遍历取 word 属性。实验报告里没展开这一点,但实际用的时候经常需要。

提示:pyhanlp 的首次调用会触发 JVM 启动,大概需要 3-5 秒。如果你在循环里反复调用 HanLP.segment,建议把结果缓存下来,不要每次都重新初始化。

3. 词典分词的三种匹配算法:前向、后向、双向到底怎么选

3.1 三种最长匹配法的代码实现与结果对比

实验报告的核心任务之一是用前向最长匹配、后向最长匹配、双向最长匹配对“结婚的和尚未结婚的”进行分词。这句话是经典歧义句——“和尚”和“和/尚未”两种切分都合理。

我按课本第二章的思路把三种算法写一遍。先定义词典和最大词长:

# 模拟词典,实际使用时从 CoreNatureDictionary.txt 加载 dictionary = {'结婚', '的', '和', '和尚', '尚未', '未', '了'} max_len = max(len(w) for w in dictionary) def forward_max_match(text, dictionary, max_len): """前向最长匹配:从左到右,每次取最长匹配词""" result = [] i = 0 while i < len(text): matched = False # 从最大词长开始尝试,逐步缩短 for j in range(min(max_len, len(text) - i), 0, -1): word = text[i:i+j] if word in dictionary: result.append(word) i += j matched = True break if not matched: # 单字未登录词,直接切分 result.append(text[i]) i += 1 return result def backward_max_match(text, dictionary, max_len): """后向最长匹配:从右到左,每次取最长匹配词""" result = [] i = len(text) while i > 0: matched = False for j in range(min(max_len, i), 0, -1): word = text[i-j:i] if word in dictionary: result.append(word) i -= j matched = True break if not matched: result.append(text[i-1]) i -= 1 return result[::-1] # 反转回正常顺序 def bidirectional_max_match(text, dictionary, max_len): """双向最长匹配:比较前向和后向结果,按规则选优""" fwd = forward_max_match(text, dictionary, max_len) bwd = backward_max_match(text, dictionary, max_len) # 简单策略:词数少的优先,词数相同取后向 if len(fwd) < len(bwd): return fwd elif len(bwd) < len(fwd): return bwd else: return bwd # 词数相同取后向,也可按单字数量判断 text = '结婚的和尚未结婚的' print('前向:', forward_max_match(text, dictionary, max_len)) print('后向:', backward_max_match(text, dictionary, max_len)) print('双向:', bidirectional_max_match(text, dictionary, max_len))

运行结果和实验报告一致:前向得到['结婚', '的', '和尚', '未', '结婚', '的'],后向得到['结婚', '的', '和', '尚未', '结婚', '的'],双向取后向结果。

3.2 三种算法的差异与选型理由

前向最长匹配从左往右扫,优先匹配长词。在“结婚的和尚未结婚的”里,它先匹配到“结婚”,然后“的”,然后遇到“和尚未”——因为“和尚”在词典里且长度为 2,而“和”单独也是词,前向会优先取“和尚”,导致后面的“未”被单独切出来。

后向最长匹配从右往左扫,先匹配到最后的“结婚”“的”,然后往前遇到“尚未”,因为“尚未”是词,所以取“尚未”而不是“和尚”。这就是两种算法结果不同的根本原因:匹配方向决定了歧义消解的偏好。

双向最长匹配的做法是同时跑前向和后向,然后按启发式规则选一个。常见规则是:词数少的优先,词数相同看单字数量,单字少的优先。实验报告里双向结果和后向一致,说明在这个例子里后向更符合预期。

实际工程里怎么选?我的经验是:如果词典质量高、领域固定,前向匹配够用且速度快;如果文本里未登录词多,后向匹配对尾部歧义更友好;双向匹配适合对精度要求高、能接受两倍计算开销的场景。HanLP 默认的 Viterbi 分词本质上不是最长匹配,而是基于一元语法模型的动态规划,效果通常比纯词典匹配好。

3.3 DoubleArrayTrieSegment 与 AhoCorasickDoubleArrayTrieSegment 的对比

实验报告里还涉及两个 HanLP 内置的词典分词类。DoubleArrayTrieSegment 基于双数组 Trie 树,适合精确匹配;AhoCorasickDoubleArrayTrieSegment 基于 AC 自动机,适合多模式串匹配。

from pyhanlp import * # 方式一:DoubleArrayTrieSegment segment1 = DoubleArrayTrieSegment() result1 = segment1.seg('春分之特别,在一个“均”字:均分了昼夜,均分了寒暑,均分了春天。') print('DoubleArrayTrie:', result1) # 方式二:AhoCorasickDoubleArrayTrieSegment HanLP.Config.ShowTermNature = False segment2 = JClass('com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment')() result2 = segment2.seg('春分之特别,在一个“均”字:均分了昼夜,均分了寒暑,均分了春天。') print('AhoCorasick:', result2)

实测结果差异很大:DoubleArrayTrieSegment 能切出“春分”“特别”“均分”“昼夜”“寒暑”等词,而 AhoCorasickDoubleArrayTrieSegment 几乎全切成了单字。这不是 bug,而是因为 AC 自动机版本默认只加载了核心词典的最长匹配模式,没有启用词性标注和未登录词识别。如果你要用 AC 版本,需要手动配置词典路径和匹配策略。

注意:AhoCorasickDoubleArrayTrieSegment 在 HanLP 的 Python 封装里需要通过 JClass 动态加载,直接 import 是拿不到的。这是 pyhanlp 的常见坑,很多人卡在这一步。

4. 二元语法分词:从语料训练到 Viterbi 解码的完整链路

4.1 训练语料准备与模型文件生成

二元语法分词的核心思想是:一个词的出现概率不仅取决于它自己,还取决于前一个词。实验报告里让新建 my_cws_corpus.txt,内容如下:

商品 和 服务 商品 和服 物美价廉 服务 和 货币

然后调用 HanLP 的 NatureDictionaryMaker 训练:

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): # 加载语料,每行按空格切分成词 sents = CorpusLoader.convert2SentenceList(corpus_path) # 给每个词打上词性标签,这里统一用 n 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")

运行后会生成三个文件:my_cws_corpus_model.txt 是一元语法模型,每行格式为“单词 词性 频次”;my_cws_corpus_model.tr.txt 与词性标注相关;my_cws_corpus_model.ngram.txt 是二元语法模型,记录词对共现频次。这三个文件缺一不可,后面加载模型时都要用到。

4.2 加载模型并查询词频

训练完之后,实验报告要求输出“商品”和“和”的频率:

from pyhanlp import * def load_bigram(model_path): # 覆盖 HanLP 默认词典路径,指向自己训练的模型 HanLP.Config.CoreDictionaryPath = model_path + ".txt" HanLP.Config.BiGramDictionaryPath = model_path + ".ngram.txt" CoreDictionary = SafeJClass('com.hankcs.hanlp.dictionary.CoreDictionary') print("商品: ", CoreDictionary.getTermFrequency("商品")) print("和: ", CoreDictionary.getTermFrequency("和")) load_bigram("my_cws_corpus_model")

输出“商品: 2”和“和: 2”。为什么是 2?因为语料里“商品”出现在第一行和第二行,“和”出现在第一行和第三行。这个频次统计是 Viterbi 解码的基础。

4.3 ViterbiSegment 分词与结果验证

最后一步是用训练好的模型对“商品和服务”进行分词:

from pyhanlp import * def predict(): HanLP.Config.CoreDictionaryPath = "my_cws_corpus_model.txt" HanLP.Config.BiGramDictionaryPath = "my_cws_corpus_model.ngram.txt" ViterbiSegment = JClass('com.hankcs.hanlp.seg.Viterbi.ViterbiSegment') segment = ViterbiSegment() s = segment.seg("商品和服务") print(s) predict()

输出[商品/n, 和/n, 服务/n]。注意这里“和”被标成了 n(名词),因为训练时所有词都统一打了 n 标签。实际使用时应该根据词性标注语料给不同词打不同标签,否则词性标注结果没有参考价值。

ViterbiSegment 的解码过程是这样的:对于“商品和服务”这个句子,所有可能的分词路径包括“商品/和/服务”“商品/和服/务”“商/品/和/服务”等。每条路径的得分由一元词频和二元转移概率共同决定。因为训练语料里“商品 和”出现了 2 次,“和 服务”出现了 1 次,而“商品 和服”只出现了 1 次,所以“商品/和/服务”这条路径得分最高。

提示:自己训练二元语法模型时,语料规模至少要到几千句才能看到明显效果。实验报告里只有三行语料,主要是为了演示流程,不要指望用它处理真实文本。

5. 避坑与排查:停用词去除、编码报错和模型加载的五个血泪经验

5.1 UnicodeDecodeError: 'gbk' codec can't decode

现象:读取停用词表或语料文件时报UnicodeDecodeError: 'gbk' codec can't decode byte 0x90 in position 2519。

原因:Windows 中文版默认用 GBK 编码打开文件,但文件本身是 UTF-8 编码。HanLP 的 CoreStopWordDictionaryPath 指向的停用词表是 UTF-8 格式,用 GBK 解码就会炸。

解决:在 open() 里显式指定 encoding='utf-8'。实验报告里的 load_from_file 函数已经写了encoding='utf-8',但如果你自己写代码忘了加,就会踩这个坑。

# 正确写法 with open(path, encoding='utf-8') as src: for word in src: word = word.strip() map[word] = word

5.2 停用词去除后结果里出现大量星号

现象:用 replace_stropwords_text 处理文本后,输出里“走春分二候京城*”这种星号密集出现。

原因:停用词表里包含“在”“的”“了”等高频词,替换成 * 之后,原文的语义被切碎。实验报告里的输出就是这样,这不是代码错了,而是停用词去除本身的副作用。

解决:如果只是做分词实验,可以跳过停用词去除;如果要做文本特征提取,建议用更精细的停用词表,或者只去除标点和高频虚词,保留“在”“的”等对句法有意义的词。

5.3 HanLP.Config.CoreDictionaryPath 修改后不生效

现象:训练完二元语法模型后,设置了HanLP.Config.CoreDictionaryPath = "my_cws_corpus_model.txt",但分词结果还是用的默认词典。

原因:HanLP 的词典是懒加载的,如果你在设置路径之前已经调用过 HanLP.segment 或任何触发词典加载的方法,JVM 里已经缓存了旧词典,改配置不会重新加载。

解决:把路径设置放在所有 HanLP 调用之前,或者重启 Python 进程。我一般会在脚本开头就统一配置好所有路径,避免中途修改。

5.4 ViterbiSegment 分词结果全是单字

现象:用自己训练的模型跑 ViterbiSegment,输出[商, 品, 和, 服, 务]而不是[商品, 和, 服务]。

原因:模型文件路径不对,或者 .ngram.txt 文件为空。ViterbiSegment 在找不到二元语法模型时会退化成单字切分。

解决:检查 model_path + ".ngram.txt" 是否存在且内容非空。如果文件存在但为空,说明训练语料格式不对——CorpusLoader.convert2SentenceList 要求每行用空格分隔词语,不能用逗号或制表符。

5.5 pyhanlp 首次运行下载数据包超时

现象:pip install pyhanlp之后第一次运行报“下载数据包失败”或卡在“Downloading data package”。

原因:pyhanlp 默认从 GitHub 下载数据包,网络不稳定时容易失败。

解决:手动下载 data 包,解压到 site-packages/pyhanlp/static/ 目录下。或者设置环境变量PYHANLP_STATIC_ROOT指向本地数据目录。这个坑在机房环境里特别常见,因为机房机器通常不能直连外网。

6. 进阶技巧:用自定义词典和词性标注提升分词精度

实验报告里的分词都是基于 HanLP 默认词典,但实际项目里你几乎一定会遇到领域专有名词被切碎的问题。比如“自然语言处理”可能被切成“自然/语言/处理”,而你需要的是“自然语言处理”作为一个整体。这时候就要用自定义词典。

HanLP 支持通过 CustomDictionary 动态添加词条:

from pyhanlp import * # 添加自定义词,第二个参数是词性,第三个参数是频次 CustomDictionary.add("自然语言处理", "nz 1000") CustomDictionary.add("二元语法", "nz 1000") CustomDictionary.add("词典分词", "nz 1000") # 重新分词,观察效果 text = "自然语言处理中的词典分词和二元语法分词" print(HanLP.segment(text))

如果不加自定义词,输出可能是“自然/语言/处理/中/的/词典/分词/和/二元/语法/分词”。加了之后,“自然语言处理”“词典分词”“二元语法”都会被保留为完整词条。频次参数 1000 表示这个词的权重很高,Viterbi 解码时会优先选择包含它的路径。

另一个技巧是调整 ViterbiSegment 的算法参数。HanLP 允许你设置是否启用命名实体识别、是否启用自定义词典、是否启用词性标注:

ViterbiSegment = JClass('com.hankcs.hanlp.seg.Viterbi.ViterbiSegment') segment = ViterbiSegment() # 关闭命名实体识别,加快速度 segment.enableCustomDictionary(True) segment.enableNameRecognize(False) segment.enablePartOfSpeechTagging(True) result = segment.seg("商品和服务") print(result)

这几个开关在批量处理文本时很有用。命名实体识别会额外加载人名、地名、机构名词典,内存占用大概增加 200MB,如果不需要识别人名地名,关掉能省不少资源。

验证分词效果的方法,我一般用两个指标:一是和人工标注结果对比,算准确率和召回率;二是看未登录词的切分情况,特别是数字、英文、专有名词混排的句子。实验报告里的“春分”那段文本就是很好的测试集,里面既有“春分”“惊蛰”这种节气词,也有“自外而内”“满园春色”这种成语,能覆盖大部分分词难点。

从那以后我每次拿到新的分词工具,都会先用“结婚的和尚未结婚的”和“商品和服务”这两句话跑一遍,看前向、后向、双向的结果差异,再决定用哪种算法。这个习惯帮我省了很多调试时间。希望帮到你。

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

RELATED READING

延伸阅读

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