简介本资源为南京邮电大学自然语言处理课程实验一的完整实验报告面向修读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.8Windows 操作系统。这不是随便写的——HanLP 的 Python 封装 pyhanlp 依赖 JPype而 JPype 在 3.9 以上版本容易出现兼容性问题。我一般会建议用 conda 建一个独立环境避免和系统里其他 Java 桥接库打架。conda create -n nlp_exp python3.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-taghanlp segment --no-tag第二种文件输入输出模式。这是实际工程里最常用的方式因为没人会一条条手敲句子hanlp segment input1.txt output1.txt -a crf这里-a crf指定了 CRF 分词算法。HanLP 默认用的是 Viterbi 算法CRF 在未登录词识别上更稳但速度慢一些。实验报告里对“春分最具灵性的美”那段文本用了 CRF输出结果里“春分/t”“灵性/n”“蛰伏/v”都切得比较准。如果你换成默认算法可能会把“春分”切成“春”和“分”。第三种句法分析指令 parsehanlp 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:ij] 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 cant decode现象读取停用词表或语料文件时报UnicodeDecodeError: gbk codec cant decode byte 0x90 in position 2519。原因Windows 中文版默认用 GBK 编码打开文件但文件本身是 UTF-8 编码。HanLP 的 CoreStopWordDictionaryPath 指向的停用词表是 UTF-8 格式用 GBK 解码就会炸。解决在 open() 里显式指定 encodingutf-8。实验报告里的 load_from_file 函数已经写了encodingutf-8但如果你自己写代码忘了加就会踩这个坑。# 正确写法 with open(path, encodingutf-8) as src: for word in src: word word.strip() map[word] word5.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如果不需要识别人名地名关掉能省不少资源。验证分词效果的方法我一般用两个指标一是和人工标注结果对比算准确率和召回率二是看未登录词的切分情况特别是数字、英文、专有名词混排的句子。实验报告里的“春分”那段文本就是很好的测试集里面既有“春分”“惊蛰”这种节气词也有“自外而内”“满园春色”这种成语能覆盖大部分分词难点。从那以后我每次拿到新的分词工具都会先用“结婚的和尚未结婚的”和“商品和服务”这两句话跑一遍看前向、后向、双向的结果差异再决定用哪种算法。这个习惯帮我省了很多调试时间。希望帮到你。本文还有配套的精品资源点击获取