YAOTU INSIGHTS

HanLP中文分词实战:词典分词与二元语法分词实验全解析

HanLP中文分词实战:词典分词与二元语法分词实验全解析
简介本资源为南京邮电大学自然语言处理课程实验一的完整实验报告面向正在修读NLP基础课程的高校学生及需要巩固中文分词与句法分析实践的学习者。报告围绕词典分词与二元语法分词两大核心任务展开涵盖HanLP分词指令segment的使用、词性标注与--no-tag参数、文件输入输出处理、句法分析parse指令的依存关系解读以及前向最长匹配、后向最长匹配、双向最长匹配三种词典分词算法的对比分析并附有基于pyhanlp的Python代码实现示例。资源包共1个doc文件大小约232KB内容为结构完整的实验报告文档包含实验目的、环境配置、原理说明、操作步骤与结果记录可直接对照教材第27页示例进行复现与验证。目前已有424人学习下载适合需要完成同类实验、理解分词算法差异或熟悉HanLP工具链的读者参考使用。1. 从一份实验报告说起词典分词和二元语法分词到底在解决什么问题很多人第一次接触中文分词是从一份课程实验报告开始的。你手里这份资源就是典型用 HanLP 在 Windows Python 3.7/3.8 环境下把词典分词和二元语法分词从头跑一遍。它不教你训大模型也不碰深度学习而是把分词这件事最底层的两条路线——规则匹配和统计语言模型——摊开给你看。适合谁正在上 NLP 基础课、需要交实验报告的学生以及想搞清楚 HanLP 命令行和 Python API 怎么配合使用的初级工程师。最直接的价值是你能拿到一套可复现的代码和命令把“商品和服务”这种经典歧义句拆明白并且知道前向最长匹配和后向最长匹配为什么会在“结婚的和尚未结婚的”上翻车。下面我按实际动手顺序把这份资源里的关键步骤和参数拆开讲。2. HanLP 命令行与 Python API 双线操作从 segment 到 parse 的完整链路2.1 命令行入口hanlp segment 和 --no-tag 参数实验第一步是打开命令行输入hanlp看它输出哪些指令。这一步很多人会跳过但恰恰是后面所有操作的入口。HanLP 安装好之后命令行工具会暴露segment、parse等子命令。你直接敲hanlp segment然后逐行输入句子它会返回带词性标注的分词结果。比如“商品和服务”输出商品/n, 和/cc, 服务/vn。这里的/n、/cc、/vn是词性标签n 是名词cc 是连词vn 是动名词。如果你只想要词不想看词性加--no-taghanlp segment --no-tag再输入同样的句子输出就只剩商品 和 服务。这个参数在批量处理时特别有用因为后续做词频统计或者喂给下游模型时词性标签反而是噪音。提示命令行里输入中文句子时确保终端编码是 UTF-8否则可能出现乱码。Windows 下用chcp 65001切一下代码页。2.2 文件输入输出重定向和 -a crf 参数实验里要求对input1.txt分词并输出到output1.txt指令是hanlp segment input1.txt output1.txt -a crf这里有两个关键点。第一和是 shell 的重定向把文件内容喂给 hanlp再把结果写到另一个文件。第二-a crf指定了分词算法为 CRF条件随机场。HanLP 默认可能用 Viterbi但 CRF 在未登录词和歧义切分上通常更稳。如果你不加-a crf输出结果可能在某些句子上不一样。实验报告里给出的输出示例中“春分/t 最/d 具/vg 灵性/n”这种切分就是 CRF 模型跑出来的。参数说明-a后面跟算法名常见的有viterbi、crf、nshort。nshort是最短路径分词速度最快但精度略低crf精度高但慢一些。做实验报告时用crf容易得到和教材一致的输出。2.3 句法分析 parse依存关系怎么看hanlp parse做的是依存句法分析输出每个词和它依赖的父节点、关系类型。实验里对“徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标”这句跑hanlp parse 徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。输出是一张表每行有词、词性、父节点索引、依存关系。比如“帮助”是核心关系核心关系“徐先生”是主谓关系“还”和“具体”都是状中结构。这张表能帮你判断分词边界是否合理——如果“主攻目标”被切开依存关系就会乱掉。注意是 here-string把后面的字符串当作标准输入。在 Windows 的 cmd 里可能不支持建议用 PowerShell 或者直接写 Python 脚本调HanLP.parseDependency。2.4 Python API 复现pyhanlp 的 segment 和 parseDependency实验要求根据教材第 27 页的例子写 Python 代码。核心就两行from pyhanlp import * # 分词 print(HanLP.segment(商品和服务)) print(HanLP.segment(当下雨天地面积水分外严重)) print(HanLP.segment(王总和小丽结婚了)) print(HanLP.segment(欢迎新老师生前来就餐)) print(HanLP.segment(结婚的和尚未结婚的确实在干扰分词啊)) # 依存句法分析 print(HanLP.parseDependency(徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。))逻辑说明HanLP.segment返回的是一个Term列表每个Term有word和nature属性。直接print会调用__str__输出词/词性格式。HanLP.parseDependency返回的是 CoNLL 格式的依存树每个词一行包含索引、词、词性、父节点、关系。参数说明pyhanlp默认加载的模型路径在HanLP.Config里。如果你换了环境可能需要手动设置HanLP.Config.CoreDictionaryPath等路径。实验报告里打印过核心词典路径print(HanLP.Config.CoreDictionaryPath)输出类似D:/miniconda3/envs/myenv/lib/site-packages/pyhanlp/static/data/dictionary/CoreNatureDictionary.txt。这个路径下的词典就是后面词典分词要用的基础资源。3. 词典分词三种匹配算法前向、后向、双向在“结婚的和尚未结婚的”上的分歧3.1 前向最长匹配为什么切出“和尚”前向最长匹配从左到右扫描每次取词典中最长的词。对“结婚的和尚未结婚的”词典里如果有“和尚”这个词前向就会在“和”字处尝试匹配“和尚”于是切出[结婚, 的, 和尚, 未, 结婚, 的]。这个结果显然不符合原意——原句是“结婚的 / 和 / 尚未结婚的”但前向匹配被“和尚”这个长词带偏了。代码实现思路参考教材第二章def forward_max_match(text, dictionary, max_len5): 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参数说明max_len是词典中最长词的长度一般设 5 到 7。dictionary是集合或哈希表查询 O(1)。这个算法的问题就是“长词优先”会掩盖短词组合的正确切分。3.2 后向最长匹配从右往左切出“尚未”后向最长匹配从右往左扫描同样取最长词。对同一句话从右端开始“的”先切掉然后“结婚”切掉再往左遇到“尚未”词典里有“尚未”就切出来于是得到[结婚, 的, 和, 尚未, 结婚, 的]。这个结果就对了。后向匹配之所以在这里更好是因为中文的偏正结构往往中心词在后从右往左切能先锁定“尚未结婚的”这个整体。代码只需把前向的循环方向反过来def backward_max_match(text, dictionary, max_len5): 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]注意最后要[::-1]反转因为是从右往左收集的。3.3 双向最长匹配比较两种结果后选优双向最长匹配同时跑前向和后向然后按规则选一个。常见规则是如果前向和后向词数不同选词数少的如果词数相同选单字词少的如果还相同优先后向。对“结婚的和尚未结婚的”前向结果 6 个词后向结果 6 个词但前向有“和尚”这个非单字词后向没有所以选后向。代码框架def bidirectional_max_match(text, dictionary, max_len5): f forward_max_match(text, dictionary, max_len) b backward_max_match(text, dictionary, max_len) if len(f) ! len(b): return f if len(f) len(b) else b else: f_single sum(1 for w in f if len(w) 1) b_single sum(1 for w in b if len(w) 1) if f_single ! b_single: return f if f_single b_single else b else: return b这个算法能降低单向匹配的歧义但不是万能药。遇到“商品和服务”这种前向切商品/和/服务后向也切商品/和/服务双向就一致。真正难的是“当下雨天地面积水分外严重”前向可能切出“下雨天/地面/积水”后向可能切出“下雨/天地/面积/水分”这时候双向的选优规则就很重要。3.4 DoubleArrayTrieSegment 和 AhoCorasickDoubleArrayTrieSegment 的区别实验里还要求用两个类做词典分词。DoubleArrayTrieSegment基于双数组 Trie适合精确匹配输出带词性但实验里显示null因为词典没标词性。代码from pyhanlp import * segment DoubleArrayTrieSegment() print(segment.seg(春分之特别在一个“均”字均分了昼夜均分了寒暑均分了春天。...))输出是[春分/null, 之/null, 特别/null, ...]注意它把“春分”切出来了因为词典里有这个词。AhoCorasickDoubleArrayTrieSegment基于 AC 自动机 双数组 Trie适合多模式匹配速度更快但实验里输出的是单字切分HanLP.Config.ShowTermNature False segment JClass(com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment)() print(segment.seg(春分之特别...))输出[春, 分, 之, 特, 别, ...]。为什么因为 AC 自动机版本默认不加载核心词典只加载你指定的词典。如果你不额外加载它就退化成按字切分。这个坑在实验报告里没写清楚但实际跑的时候很容易懵。提示要用 AC 自动机版本做正常分词得先segment.enablePartOfSpeechTagging(True)并加载词典否则就是单字输出。4. 二元语法分词实战从语料训练到 ViterbiSegment 预测4.1 构建 my_cws_corpus.txt 和训练脚本二元语法分词的核心是统计相邻词共现频率。实验要求新建my_cws_corpus.txt内容商品 和 服务 商品 和服 物美价廉 服务 和 货币然后跑训练代码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)逻辑说明CorpusLoader.convert2SentenceList把每行按空格切成词生成Sentence列表。word.setLabel(n)给每个词统一标成名词因为实验语料没有词性信息。NatureDictionaryMaker.compute统计一元和二元频率saveTxtTo写出三个文件。参数说明model_path是输出前缀会生成my_cws_corpus_model.txt一元模型、my_cws_corpus_model.tr.txt词性标注相关、my_cws_corpus_model.ngram.txt二元模型。一元模型每行是词 词性 频次二元模型每行是词1 词2 频次。4.2 加载模型并查询词频训练完要验证模型是否可用。代码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因为语料里“商品”出现在第一行和第二行“和”出现在第一行和第三行。getTermFrequency查的是一元模型里的频次。注意这里直接改了HanLP.Config.CoreDictionaryPath会影响后续所有分词操作。如果你后面还要用默认词典记得改回来或者用新的 Python 进程。4.3 ViterbiSegment 预测“商品和服务”最后用训练好的模型对“商品和服务”分词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 会算两条路径的概率选大的。参数说明ViterbiSegment默认使用HanLP.Config里的词典路径。如果你不手动设置它会用默认的大词典结果可能和实验不一致。所以实验里显式覆盖了CoreDictionaryPath和BiGramDictionaryPath。4.4 停用词去除DoubleArrayTrie 和最长匹配搜索实验里还有一段停用词去除代码用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_stropwords_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 result trie load_from_file(HanLP.Config.CoreStopWordDictionaryPath) text 走在春分二候的京城植物园的玉兰在蓝天下盛开... print(replace_stropwords_text(text, *, trie))逻辑说明load_from_file把停用词表读进TreeMap然后构建DoubleArrayTrie。getLongestSearcher返回一个迭代器每次找到最长匹配的停用词记录起止位置。replace_stropwords_text把匹配到的停用词替换成*保留非停用词部分。参数说明HanLP.Config.CoreStopWordDictionaryPath是 HanLP 自带的停用词表路径。如果你有自己的停用词表把路径换掉就行。replacement是替换字符实验里用*你也可以用空字符串直接删除。注意这段代码在 Windows 上容易报UnicodeDecodeError: gbk codec cant decode...原因是open默认用系统编码。加上encodingutf-8就能解决。这是实验报告里明确记录的坑。5. 避坑与排查HanLP 实验里最容易翻车的五个地方5.1 现象hanlp命令找不到 → 原因环境变量没配 → 解决用python -m pyhanlp替代装完pyhanlp后命令行敲hanlp提示“不是内部或外部命令”。这是因为hanlp脚本不在 PATH 里。最省事的办法是直接用python -m pyhanlp segment效果一样。或者找到pyhanlp安装目录下的hanlp.bat把路径加到系统环境变量。5.2 现象分词结果全是单字 → 原因AC 自动机版本没加载词典 → 解决显式加载核心词典用AhoCorasickDoubleArrayTrieSegment时输出[春, 分, 之, 特, 别, ...]每个字单独切开。原因是这个类默认不加载CoreNatureDictionary.txt。解决办法是在seg之前调用segment.enablePartOfSpeechTagging(True)或者手动设置HanLP.Config.CoreDictionaryPath并重新初始化。5.3 现象UnicodeDecodeError: gbk codec cant decode→ 原因open 没指定编码 → 解决加encodingutf-8实验报告里专门记了这条。Windows 中文系统默认编码是 GBK而停用词表是 UTF-8。读文件时写open(path, encodingutf-8)就行。写文件也一样open(path, w, encodingutf-8)。5.4 现象训练二元语法后预测结果和默认词典一样 → 原因Config 路径没覆盖成功 → 解决在seg之前设置并检查有时候你改了HanLP.Config.CoreDictionaryPath但ViterbiSegment还是用默认词典。原因是ViterbiSegment在实例化时可能已经缓存了词典。解决办法是在segment ViterbiSegment()之前设置 Config并且用print(HanLP.Config.CoreDictionaryPath)确认路径已经变了。5.5 现象hanlp parse在 cmd 里报错 → 原因是 bash 语法 → 解决用 PowerShell 或 Python APIWindows cmd 不支持。要么换 PowerShell要么直接写 Pythonfrom pyhanlp import * print(HanLP.parseDependency(徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。))这样跨平台都能跑。6. 进阶技巧用自定义词典干预分词边界和词频验证实验做完之后真正想把这套东西用起来得学会用自定义词典干预分词。HanLP 支持CustomDictionary你可以在HanLP.Config.CustomDictionaryPath里指定一个文件每行写词 词性 频次。比如你想让“春分之特别”不被切开就加一行春分之特别 n 1000。频次给高一点Viterbi 就会优先选这条路径。另一个技巧是验证二元模型是否真的生效。除了查getTermFrequency你还可以直接看my_cws_corpus_model.ngram.txt的内容type my_cws_corpus_model.ngram.txt输出里会有商品和 2、和服务 2这样的行。是分隔符前面是前词后面是后词数字是共现频次。如果这个文件是空的说明训练语料没被正确读取检查my_cws_corpus.txt的编码和空格分隔。还有一个实用习惯每次跑完分词把结果和标准答案做一次 diff。实验报告里的输出就是标准答案你可以把HanLP.segment的结果写进文件然后用fc命令对比。差异处往往就是词典没覆盖或者算法选错的地方。从那以后我每次做分词实验都强制走一遍“命令行验证 → Python API 复现 → 自定义词典干预 → 结果 diff”这个流程少一步都可能被某个隐藏的编码或路径问题坑掉。希望帮到你。本文还有配套的精品资源点击获取