
简介这是南邮自然语言处理基础课程实验一的完整实验报告主要围绕词典分词与二元语法分词展开面向需要完成同类实验或入门中文分词与依存句法分析的本科生。报告记录了使用HanLP工具进行命令行分词、关闭词性标注、文件输入输出、句法分析等操作并附有Python调用pyhanlp实现相应功能的示例代码。此外报告还针对“结婚的和尚未结婚的”等经典歧义句演示了前向最长匹配、后向最长匹配与双向最长匹配的三种分词算法及区别并展示了HanLP核心词典路径的查询方法。资源为Word文档格式总共1个文件压缩包大小232KB已有421人学习过。对于正在修读NLP课程、需要参考实验报告格式或验证分词算法细节的同学这份材料能提供直接的参考与排错思路帮助理解词典分词和统计语言模型的基本应用。1. 先搞懂这份南邮自然语言处理实验一词典与二元语法到底练了什么做过中文分词的都知道入门第一道坎不是模型选型而是“一句话能被切出两种意思”——比如“结婚的和尚未结婚的”前向匹配会切出“和尚”后向匹配和双向匹配却能正确切出“和/尚未”。这份南邮自然语言处理实验一的完整报告练的正是 HanLP 这套工具链里词典分词、停用词过滤、句法分析和二元语法模型训练这一连串操作。它不是 PPT 式的理论讲解而是把命令行、Python 代码、输出结果、踩坑记录全部按实验步骤整理好了你照着敲一遍就能跑通。适合正在上自然语言处理课、需要交实验报告或者在补分词基础的人也适合工作里要快速验证 HanLP 分词效果的工程师。我拆完这份实验报告后发现真正值钱的不是那几条命令而是报告里关于编码错误、词性标注开关、模型文件生成逻辑这些细节——这些恰恰是新手最容易翻车的地方。2. 从命令行走起HanLP 的 segment 和 parse 怎么用才不出洋相2.1 先跑通 hanlp segment看懂词性标注和 --no-tag 的作用实验报告的第一步是让用户在命令行敲hanlp segment然后一句一句输入文本看分词结果。这个步骤看起来简单但它其实在做三件事验证 HanLP 装好了没有、让你直观感受词性标注长什么样、让你知道默认输出是带词性的。hanlp segment回车后输入“商品和服务”你会看到商品/n, 和/cc, 服务/vn这里的/n、/cc、/vn是词性标注分别代表名词、连词、动词名物化后缀。机房老师让你注意这一点是因为后续实验里很多地方要关闭词性标注避免输出结果太啰嗦。不想输出词性就在命令后面加参数hanlp segment --no-tag注意这个参数是两个短横线加no-tag不是-notag也不是--no_tag。实验报告里写的是“—no-tag”但实际命令行里中文破折号是跑不起来的这是报告里一个隐蔽的小坑。还有一种更常见的用法是从文件输入、往文件输出适合批量处理文本hanlp segment input1.txt output1.txt -a crf-a crf表示用 CRF 模型做分词比默认的感知机模型在部分场景下准确率更高代价是速度慢一些。输出文件里每一行就是一句分词结果词和词之间用空格隔开每个词带着词性和斜杠。2.2 hanlp parse 做句法分析输出表里的每一列到底代表什么句法分析指令hanlp parse是实验里第二个命令行操作用来输出依存句法树。实验里的例句是“徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标”输出是一个 8 列左右的表格。hanlp parse 徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。输出里每个词一行依次是序号、词语、词性、依存父节点序号、依存关系标签。比如“帮助”这一行是4 帮助 帮助 vv _ 0 核心关系 _ _意思是“帮助”是整个句子的核心谓词依存父节点是 0根节点。这个指令做两件事一是分词二是构建词与词之间的依存关系。和segment最大的区别在于输出不再是简单的词序列而是一张带有结构信息的表。初学的时候容易把“主谓关系”“动宾关系”这些标签当作词性实际上它们是句法角色两者不是一个维度。我在跑这段指令时习惯先hanlp segment看看分词对不对再hanlp parse看句法结构。因为如果分词本身错了后面的依存关系大概率也跟着错。比如“把画雄鹰”这一串HanLP 会切成“把/画/雄鹰”而不是“把画/雄鹰”这就是句法分析对分词结果的高度依赖。2.3 命令行版本和 Python 版本的差别什么时候用哪个实验报告里同时给了命令行写法和 Python 写法很多人会纠结到底学哪个。我的判断是命令行适合快速验证和批处理Python API 适合写进实验代码和后续工程。命令行的优势是完全不依赖代码环境装好 HanLP 就能用劣势是参数不够灵活比如你要自定义词典路径就得靠 Python。Python 版本的核心入口是pyhanlp包实验里最常见的两行代码是from pyhanlp import * print(HanLP.segment(商品和服务)) print(HanLP.parseDependency(徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。))这两行就是命令行segment和parse的 Python 封装输出格式也保持一致。实际写实验代码时我还发现一个问题HanLP.segment返回的是List[JClass]类型对象直接打印会带[]和/词性后缀如果想拿纯词列表需要遍历取word.toString().split(/)[0]或者用str(HanLP.segment(...))再处理。实验报告里直接print是可以的但如果你想把这些词再喂给下游模型就得先转换类型。3. 词典分词的四种写法前向、后向、双向和双数组字典树3.1 前向后向双向最长匹配同一句话三种结果实验里有一道经典题对“结婚的和尚未结婚的”这句话分别用前向最长匹配、后向最长匹配、双向最长匹配分词然后对比结果。前向最长匹配的思路是从句子开头开始每次尝试匹配词典里最长的词匹配成功就切出来然后从该词末尾继续向后扫描。“结婚的和尚未结婚的”会被切成[结婚, 的, 和尚, 未, 结婚, 的]问题出在“尚未”被切成了“和尚”加“未”因为从前向后匹配时在“和”这个位置优先尝试“和尚”这个词。这就是前向最长匹配的典型毛病——它只看到局部最长不考虑整句话的语义。后向最长匹配从句子末尾开始反向扫描得到[结婚, 的, 和, 尚未, 结婚, 的]结果正确。原因是在反向扫描时句子末尾的“的”先被切掉然后是“结婚”再往前碰到“尚未”“尚未”这个词在词典里存在且比“尚”更长于是优先匹配“尚未”最后剩下“和”。同一个句子前向和后向结果不同本质上是匹配方向导致不同的贪心路径。双向最长匹配的代码逻辑是同时跑前向和后向然后根据两个标准选一个结果——单字词数量更少的结果优先如果单字词数量相同就取后向结果。from pyhanlp import * from pyhanlp.static import HanLP text 结婚的和尚未结婚的 forward HanLP.segment(text) # 这里 HanLP.segment 默认是 ViterbiSegment # 想严格验证前向/后向算法需要自己写或调用 jclass 里的 Segment 实现实验报告里要求手写三种算法但 HanLP 实际提供的DoubleArrayTrieSegment可以理解成一个可配置的词典分词器。我在复现时是直接基于 Python 手写的把词典存成 list每次从当前位置取最大长度max_len做切片匹配。def forward_max_match(text, dictionary, max_len5): index 0 result [] text_len len(text) while index text_len: matched False for size in range(max_len, 0, -1): if index size text_len: word text[index:index size] if word in dictionary: result.append(word) index size matched True break if not matched: result.append(text[index]) index 1 return result这个函数里max_len5是关键参数表示词典里最长词的长度。如果词典里有超过 5 个字的词这个参数要调大否则长词永远匹配不上。后向版本只需要把循环改成从text_len往 0 走切片逻辑整体镜像一下即可。3.2 DoubleArrayTrieSegment 和 AhoCorasickDoubleArrayTrieSegment一个输出带 null一个把词全拆了实验报告第二个核心任务是对春分那段长文本用两种词典分词器切词。第一种是DoubleArrayTrieSegmentfrom pyhanlp import * segment DoubleArrayTrieSegment() print(segment.seg(春分之特别在一个“均”字均分了昼夜均分了寒暑均分了春天。))输出结果里每个词后面都带/null这不是 bug而是默认分词器没加载词性标注信息所以词性为空。看起来有点丑但不影响后续处理。第二种是AhoCorasickDoubleArrayTrieSegment这个类要用JClass来调用from pyhanlp import * HanLP.Config.ShowTermNature False segment JClass(com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment)() print(segment.seg(春分之特别在一个“均”字均分了昼夜均分了寒暑均分了春天。))这里有个前提条件代码里必须写上HanLP.Config.ShowTermNature False否则输出会带词性。但重点是这个分词器是“纯词典匹配”不加载核心词典以外的任何语言模型所以它会拼命把字逐个切成单字比如“春”“分”“之”“特”“别”全部分开——因为“春分”“特别”这些词在默认词典里没有完全命中或被某些词干扰。实验报告里让你同时跑这两个分词器本质是想让你直观感受“基于词典的分词器”和“带语言模型的分词器”在召回率上的差距。DoubleArrayTrieSegment至少能认出“春分”“均分”“昼夜”这些词而AhoCorasickDoubleArrayTrieSegment的“暴力匹配”特性导致它几乎只认单字。我在实操时发现想让 AhoCorasick 版本分得好一点必须在实例化前设置自定义词典否则它就是纯字切分器实战价值有限。3.3 加载自定义词典让顽强的“未登录词”变成认识词实验报告虽然没直接写自定义词典的代码但词典分词要落地这一步躲不开。尤其是 AhoCorasick 分词器不加载自定义词典就是一个纯字切分器。from pyhanlp import * CustomDictionary JClass(com.hankcs.hanlp.dictionary.CustomDictionary) CustomDictionary.add(春分) CustomDictionary.add(均分) CustomDictionary.add(寒暑) segment JClass(com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment)() print(segment.seg(春分之特别在一个“均”字均分了昼夜均分了寒暑均分了春天。))CustomDictionary.add是动态添加一个词到内存词典好处是即时生效、不用重启进程坏处是每次运行都要重新添加。如果你有几百个自定义词建议写到data/dictionary/custom/CustomDictionary.txt里每行一个词加一个词性比如“春分 n”然后重启进程自动加载。这里有个细节CustomDictionary.add添加的词如果不带词性分词结果里这个词的词性会是null或空和DoubleArrayTrieSegment的输出风格一致。想让自定义词带词性要用“词/词性”的写法比如CustomDictionary.add(春分/n)。这个区别在输出结果里很明显我第一次没注意还以为模型出问题了。4. 停用词过滤编码坑、字典树加载和替换策略一次说清4.1 加载 stopwords.txt 的正确姿势编码必须指定 utf-8实验里要求找停用词表stopwords.txt的路径然后写代码去掉文本里的停用词。HanLP 的停用词路径可以通过配置对象拿到from pyhanlp import * print(HanLP.Config.CoreStopWordDictionaryPath)我机器上输出的是D:/miniconda3/envs/myenv/lib/site-packages/pyhanlp/static/data/dictionary/stopwords.txt。不同环境路径不同但一定在 pyhanlp 包安装目录的static/data/dictionary下。停用词过滤的常见做法是建一个字典树然后扫描文本命中词典里的词就替换成占位符。实验报告给了一段基于DoubleArrayTrie的封装代码我把它整理精简后是这样from jpype import JString from pyhanlp import * HanLP.Config.ShowTermNature False def load_from_file(path): map JClass(java.util.TreeMap)() with open(path, encodingutf-8) as src: for word in src: word word.strip() map[word] word return JClass(com.hankcs.hanlp.collection.trie.DoubleArrayTrie)(map) def replace_stopwords_text(text, replacement, trie): searcher trie.getLongestSearcher(JString(text), 0) offset 0 result while searcher.next(): begin searcher.begin end begin searcher.length if begin offset: result text[offset:begin] result replacement offset end if offset len(text): result text[offset:] return resultload_from_file里有个关键点encodingutf-8必须写。实验报告里提到一个报错UnicodeDecodeError: gbk codec cant decode byte 0x90原因就是 Windows 环境下open()默认用 GBK 解码而词典文件是 UTF-8 编码。这个坑在 pandas 读取 CSV、open读词典时几乎百分之百会遇到养成显式指定编码的习惯能省掉不少排查时间。4.2 为什么用 DoubleArrayTrie 而不是直接遍历字典很多人会问停用词表就几百个词为什么不直接遍历替换写个if word in stopword_list不行吗行但对长文本性能很差。每次分词都要把整本停用词表遍历一遍复杂度是 O(文本长度 × 停用词表大小)。用DoubleArrayTrie构建的前缀树扫描文本时可以在一次遍历中同时匹配所有停用词时间复杂度接近 O(文本长度)。实验报告里的代码虽然看起来绕但它是 HanLP 内部真正在用的数据结构。替换函数里getLongestSearcher是核心它从文本的offset0开始返回一个“最长匹配”的搜索器每次searcher.next()移动到一个命中词searcher.begin是词起点searcher.length是词长度。通过begin offset判断当前位置和上一个命中词之间有没有未命中文本有就原样保留命中部分用replacement替换最后处理尾部剩余文本。我自己跑这段代码时发现一个问题停用词表里如果包含“的”“了”“在”这类高频单字替换结果会变成一大片占位符比如“走春分二候京城植物园玉兰蓝天盛开”——看起来像是把主体内容都替换掉了。这其实是实验的正常效果因为停用词的定义就是“对语义贡献极小的词”过滤得越狠剩下的词越聚焦。但如果你的下游任务是情感分析过度过滤会把转折词“但是”“然而”也干掉影响情感极性判断。所以停用词表要根据任务调整不能用默认表一把梭。4.3 替换符号的选择星号、空格还是直接删除实验报告用*作为替换占位符好处是能直观看到哪些位置被过滤了。但实际项目里我一般不推荐*因为它是合法文本字符下游分词器会把它当成独立 token更常见的是替换成空字符串也就是直接删除停用词。result replace_stopwords_text(text, , trie)直接删除的问题在于会让相邻词拼接比如“的”被删掉后“美丽 的 风景”变成“美丽风景”改变了原文的 token 边界。如果后续要做词频统计删词拼接的影响不大但如果要做序列标注拼接会引入错误标签。所以我的习惯是分析任务用空格替换索引任务用*或_这类不可能出现在正常文本里的符号。这段代码里的JString是 jpype 的类型转换因为trie.getLongestSearcher是 Java 方法不接受 Python 原生 str 类型。如果你省略JString(text)直接传 Python 字符串会报类型不匹配异常这是 jpype 调用 Java 库的一个经典报错报错信息里会提到No matching overloads found。5. 二元语法分词从三行语料训练出自己的模型5.1 训练语料格式为什么每个词后面要跟一个空格实验第三步是建一个my_cws_corpus.txt写入三行语料商品 和 服务 商品 和服 物美价廉 服务 和 货币注意每行里词和词之间是空格分隔这表示一个已经分好词的句子。HanLP 的CorpusLoader.convert2SentenceList会按行读取把每一行拆成词列表。训练二元语法的代码分两步先构造语料再保存模型。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) 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.ngram.txt二元语法模型记录相邻两个词的共现频次my_cws_corpus_model.tr.txt词性转移矩阵记录词性之间的转移频次word.setLabel(n)把所有词都标成名词是因为实验只关注分词不关注词性。如果想让模型带真实词性这里应该换成语料里标注好的标签。my_cws_corpus_model.txt的内容大致是商品 n 2、和 n 2、服务 n 2这种结构数字就是频次。.ngram.txt里的行像商品和 n n 1表示“商品”后面接“和”出现了 1 次。5.2 加载模型看词频路径拼错是头号翻车点实验里要求输出“商品”和“和”这两个词的频率代码是把一元和二元模型路径设置到全局配置里from pyhanlp import * def load_bigram(model_path): 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 次“和”也出现 2 次。这里的坑在于model_path不能带扩展名。你要是写成load_bigram(my_cws_corpus_model.txt)实际加载的文件就变成my_cws_corpus_model.txt.txt直接报文件不存在。我在第一次跑这段代码时盯着报错看了五分钟才发现是路径重复拼接的问题。还有一个容易忽略的点HanLP.Config.CoreDictionaryPath一旦被换成实验生成的模型全局默认词典就被覆盖了。也就是说跑完这段代码后你再调用HanLP.segment用的就是这三行语料训练出来的小模型而不是 HanLP 自带的千万级词典。想恢复默认需要重新设置CoreDictionaryPath指向CoreNatureDictionary.txt。这种“全局配置污染”问题在实验代码里经常出现做完一步最好及时复位。5.3 ViterbiSegment 预测“商品和服务”为什么能分对二元语法的预测用的是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]切分正确。但注意如果词典里有“和服”这个词而语料里没有显式记录Viterbi 也可能切出“商品/和服/务”——因为二元模型里“商品”后接“和”的转移概率和“商品”后接“和服”的转移概率会打架。实验语料里“商品 和 服务”出现了一次所以在“商品”后接“和”的路径上有真实计数Viterbi 会选择概率更高的那条路径。Viterbi 算法的核心是把分词当成一个序列标注问题每个可能的词边界是状态相邻词的共现频次是转移概率最终找一条概率最大的切分路径。和前面的词典匹配算法相比Viterbi 能利用语料统计信息来消解歧义。比如“商品和服务”里“和”后面接“服务”的二元频次来自语料而“和服”后面接“务”没有统计模型自然倾向选择前者。我在复现时试过在语料里加一行“商品 和服 物美价廉”再预测“商品和服务”结果变成了[商品/n, 和服/n, 务/n]。这说明二元语法模型对语料极其敏感语料里的噪声会直接带偏预测结果。实验里的三行语料是有意设计的故意制造“和服”这个词来观察模型怎么选。6. 实验报告里的三个隐藏坑路径污染、编码错乱和类型转换6.1 全局配置被覆盖后怎么恢复默认词典实验里反复出现HanLP.Config.CoreDictionaryPath ...这种全局赋值一旦跑完load_bigram再跑别的分词代码分词结果会变得非常奇怪因为核心词典已经被替换成三行语料的小模型。恢复默认配置的方法是重新设置路径from pyhanlp import * HanLP.Config.CoreDictionaryPath HanLP.Config.CoreDictionaryPath # 或者手动指向原始路径 HanLP.Config.CoreDictionaryPath D:/miniconda3/envs/myenv/lib/site-packages/pyhanlp/static/data/dictionary/CoreNatureDictionary.txt但手动写死路径的问题是你换了机器就得改。我自己的习惯是写一个reset_config()函数把配置项保存成全局变量实验开头备份、实验结束恢复。尤其在做多个实验挨着跑的时候配置污染是排查最花时间的坑因为报错信息根本不提示“你的词典错了”。6.2 UnicodeDecodeError 的完整排查路径实验报告里记录的UnicodeDecodeError: gbk codec cant decode byte 0x90 in position 2519是 Windows 中文环境的经典报错。原因就一句话文件是 UTF-8 编码而 Python 的open()在 Windows 上默认用 GBK 解码。排查路径我总结成三步第一步报错信息里看位置position 2519表示解码到第 2519 个字节时失败这是一个中文字符的 UTF-8 三字节序列被 GBK 拆散导致的。第二步打开文件确认编码。用 Notepad 或 VS Code 右下角能看到编码格式如果是 UTF-8就在open()里显式声明encodingutf-8。第三步如果文件编码本身是 GBK 但你在 Linux 上跑反而会报UnicodeDecodeError: utf-8 codec cant decode。所以不要无脑复制别人的代码要看你所在环境的默认编码。import sys print(sys.getdefaultencoding())这段代码会输出你当前环境的默认编码在 Windows 中文环境通常是utf-8但open()的默认编码可能还是gbk。两者不一致就是困惑的来源。6.3 jpype 类型转换为什么 JString 不能省实验里所有涉及 Java 方法的调用都离不开JString比如trie.getLongestSearcher(JString(text), 0)。如果不转类型直接传 Python 的strjpype 会尝试自动转换但偶尔会失败尤其是字符串里包含中文或特殊符号时。from jpype import JString # 正确写法 searcher trie.getLongestSearcher(JString(text), 0) # 可能报错的写法 searcher trie.getLongestSearcher(text, 0)报错信息通常是TypeError: No matching overloads found。解决方法是所有传给 Java 方法的字符串都包一层JString()所有从 Java 方法返回的java.lang.String对象如果想在 Python 里做字符串操作需要用str()转换。除了字符串Java 的List返回给 Python 后类型是jpype._jclass.java.util.ArrayList不能直接用 Python 的切片和索引语法。我一般会先转成 Python listseg_result [str(word) for word in segment.seg(text)]这一步在跑 ViterbiSegment 预测时特别有用因为直接print(segment.seg(...))输出的是 Java 对象列表转成 Python 字符串后就可以正常做后续处理了。从这份实验报告的经验往后推我遇到自然语言处理的作业或者小项目都会强制走一遍这套底层的流程——先命令行跑通再写 Python 封装先看默认词典效果再决定要不要自定义词典先确认编码再动代码。这样做的好处是每一步都有明确的验证点出了问题能快速定位是环境问题、数据问题还是代码问题。希望这篇拆解能帮你在做词典分词和二元语法实验时少踩几个坑把时间花在理解分词原理而不是调试报错上。本文还有配套的精品资源点击获取