HanLP中文分词实战:词典匹配、二元语法与Viterbi全流程
简介本资源为南京邮电大学自然语言处理课程实验一的完整实验报告面向修读该课程的学生及希望入门中文分词与句法分析的初学者。内容围绕词典分词和二元语法分词两大核心任务展开涵盖HanLP分词指令segment的使用、词性标注与--no-tag参数、文件输入输出处理、句法分析parse指令以及前向最长匹配、后向最长匹配、双向最长匹配三种词典分词算法的对比并附有Python代码实现示例。资源包为1个doc文档大小约232KB结构清晰可直接作为实验参考与报告模板。目前已有424人学习下载。读者可借此掌握HanLP工具的基本操作、理解不同分词算法的差异与适用场景并对照教材第27页示例完成代码编写为后续自然语言处理学习打下实践基础。1. 从一份实验报告说起HanLP 词典分词与二元语法分词到底怎么落地很多人第一次接触中文分词都是从一份课程实验报告开始的。你拿到手的这份资源就是围绕 HanLP 展开的完整实验记录——从命令行调用hanlp segment做词性标注分词到用 Python 调pyhanlp实现前向/后向/双向最长匹配再到训练二元语法模型并对“商品和服务”做 Viterbi 分词。它适合正在学自然语言处理基础、需要跑通 HanLP 分词全流程的开发者也适合想拿一份可复现代码做二次改造的人。整份材料覆盖了词典分词、停用词过滤、二元语法训练与预测四条主线每一步都有可抄的代码和实际输出不是空讲原理。2. 环境搭建与 HanLP 命令行初探从安装到 segment 分词2.1 环境准备与 HanLP 安装实验环境写的是 Windows Python 3.7/3.8这个组合在 HanLP 1.x 系列上最稳。我一般会先建一个干净的虚拟环境避免和系统里其他包打架# 创建虚拟环境Python 3.8 python -m venv hanlp_env hanlp_env\Scripts\activate # 安装 pyhanlp它会自动拉取 jpype 和 HanLP 的 jar 包 pip install pyhanlp装完之后第一次运行from pyhanlp import *时它会自动下载 data 包大概几百 MB下载路径默认在pyhanlp/static/data/下。如果网络环境不好这一步容易卡住常见做法是手动把 data 包放到对应目录或者设置HANLP_ROOT环境变量指向已有数据目录。命令行工具hanlp是随包一起装的直接在终端输入hanlp就能看到所有子命令。实验里用到的segment和parse是最常用的两个。2.2 segment 分词与词性标注先看最基本的调用方式# 进入交互模式输入句子后回车 hanlp segment输入“商品和服务”输出是商品/n, 和/cc, 服务/vn。这里的/n、/cc、/vn是词性标签n 是名词cc 是并列连词vn 是动名词。如果你只想要词不想看词性加--no-taghanlp segment --no-tag再输入“商品和服务”输出就变成商品 和 服务干净很多。这个参数在后续做文本预处理时很实用因为大多数下游任务不需要词性。文件输入输出也很直接hanlp segment input1.txt output1.txt -a crf-a crf指定用 CRF 模型做分词比默认的 Viterbi 更准一些但速度稍慢。输出文件里每行对应输入的一行词和词性用空格隔开。实验里对一段春分文字做分词结果里“春分/t”“灵性/n”“蛰伏/v”这些标注都挺合理说明 CRF 模型对文学性文本也有不错的切分能力。2.3 parse 句法分析句法分析用hanlp parse输入方式有点特别用的是 here-stringhanlp parse 徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。输出是一张依存句法表每行包含词序号、词本身、词性、依存弧的 head 位置和关系类型。比如“帮助”是核心关系head 为 0“徐先生”是主谓关系head 指向“帮助”“他”是兼语。这张表把句子的骨架拆得很清楚做信息抽取或问答系统时依存关系是很有用的特征。2.4 Python 调用 pyhanlp 做分词和句法分析命令行适合快速验证真正写代码还是用 Python API。实验里给了两段核心代码from pyhanlp import * # 分词返回 Term 列表每个 Term 带 word 和 nature print(HanLP.segment(商品和服务)) print(HanLP.segment(结婚的和尚未结婚的确实在干扰分词啊)) # 依存句法分析返回 CoNLL 格式的字符串 print(HanLP.parseDependency(徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。))HanLP.segment返回的是Term对象列表直接 print 会显示“词/词性”的格式。如果你想拿纯文本可以遍历term.word。HanLP.parseDependency返回的是标准 CoNLL 格式每行一个词列之间用 tab 分隔方便后续用 pandas 或自己写解析器处理。提示HanLP.segment默认用的是 Viterbi 分词如果你在配置文件里改了CoreDictionaryPath它会加载你自定义的词典这一点在后面的二元语法实验里会用到。3. 词典分词三种匹配算法前向、后向、双向最长匹配实战3.1 三种算法的核心差异词典分词的本质是在一个词典里找最长的匹配词。前向最长匹配从左往右扫每次取能匹配上的最长词后向最长匹配从右往左扫双向最长匹配则同时跑前向和后向然后按“词数最少优先、单字最少优先”的规则选一个结果。实验里对“结婚的和尚未结婚的”这句话做分词三种算法的结果分别是算法分词结果前向最长匹配[结婚, 的, 和尚, 未, 结婚, 的]后向最长匹配[结婚, 的, 和, 尚未, 结婚, 的]双向最长匹配[结婚, 的, 和, 尚未, 结婚, 的]前向匹配把“和尚”切出来了但“尚未”被拆成“尚”和“未”后向匹配则正确切出“尚未”但“和尚”没了。双向匹配选了后向的结果因为它的词数更少6 个词 vs 前向的 6 个词但单字更少。这个例子很经典说明单向匹配在歧义切分上容易翻车。3.2 用 Python 实现三种匹配算法下面是我按课本思路写的简化版实现词典用一个 set 存# 词典实际使用时可以从文件加载 DICT {结婚, 的, 和, 和尚, 尚未, 未, 了} def forward_max_match(text, max_len5): 前向最长匹配 result [] i 0 while i len(text): # 从 min(max_len, 剩余长度) 开始往下找 for j in range(min(max_len, len(text) - i), 0, -1): word text[i:ij] if word in DICT: result.append(word) i j break else: # 一个词都没匹配上单字成词 result.append(text[i]) i 1 return result def backward_max_match(text, max_len5): 后向最长匹配 result [] i len(text) while i 0: for j in range(min(max_len, i), 0, -1): word text[i-j:i] if word in DICT: result.append(word) i - j break else: result.append(text[i-1]) i - 1 return result[::-1] # 反转回正序 def bidirectional_max_match(text): 双向最长匹配词数少优先单字少优先 fwd forward_max_match(text) bwd backward_max_match(text) if len(fwd) ! len(bwd): return fwd if len(fwd) len(bwd) else bwd # 词数相同选单字少的 fwd_single sum(1 for w in fwd if len(w) 1) bwd_single sum(1 for w in bwd if len(w) 1) return fwd if fwd_single bwd_single else bwdmax_len是词典里最长词的长度设成 5 是个经验值覆盖大多数中文词。前向匹配的for...else结构是 Python 特有的当 for 循环正常结束没 break时走 else这里用来处理单字成词的情况。后向匹配从右往左扫最后要反转结果。双向匹配的选优规则是“词数少优先单字少优先”这是 HanLP 内部也在用的策略。3.3 DoubleArrayTrieSegment 与 AhoCorasickDoubleArrayTrieSegmentHanLP 提供了两个基于 Trie 的词典分词器实验里都跑了一遍from pyhanlp import * # 方式一DoubleArrayTrieSegment基于双数组 Trie segment1 DoubleArrayTrieSegment() print(segment1.seg(春分之特别在一个“均”字均分了昼夜...)) # 方式二AhoCorasickDoubleArrayTrieSegment基于 AC 自动机 HanLP.Config.ShowTermNature False segment2 JClass(com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment)() print(segment2.seg(春分之特别在一个“均”字均分了昼夜...))DoubleArrayTrieSegment的输出带词性虽然都是 null而AhoCorasickDoubleArrayTrieSegment把ShowTermNature关掉后只输出词。两者的核心区别在数据结构双数组 Trie 适合做前缀匹配AC 自动机适合多模式串匹配。实际分词时AC 自动机版本在长文本上通常更快因为它能一次性扫描出所有匹配位置。注意AhoCorasickDoubleArrayTrieSegment需要手动设置HanLP.Config.ShowTermNature False否则输出里会带一堆 null 词性看着很乱。3.4 停用词过滤的完整实现停用词过滤是文本预处理的标准步骤。实验里用DoubleArrayTrie加载停用词表然后用最长匹配搜索替换from jpype import JString from pyhanlp import * HanLP.Config.ShowTermNature False def load_from_file(path): 从文件加载停用词到 DoubleArrayTrie 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 result trie load_from_file(HanLP.Config.CoreStopWordDictionaryPath) text 走在春分二候的京城植物园的玉兰在蓝天下盛开... print(replace_stopwords_text(text, *, trie))load_from_file把停用词表读进 Java 的TreeMap再构建DoubleArrayTrie。replace_stopwords_text用getLongestSearcher做最长匹配扫描把匹配到的停用词替换成*。offset变量记录上一次替换的结束位置保证不重复处理。这个实现比逐词查表快得多因为 Trie 的搜索是 O(n) 的。提示打开停用词文件时一定要指定encodingutf-8实验里就有人因为默认 gbk 编码报了UnicodeDecodeError这个坑很常见。4. 二元语法分词从语料训练到 Viterbi 预测4.1 语料准备与模型训练二元语法分词的核心思想是一个词的出现概率不仅取决于它自己还取决于前一个词。训练时需要一份分好词的语料实验里用的是商品 和 服务 商品 和服 物美价廉 服务 和 货币训练代码用 HanLP 的NatureDictionaryMakerfrom 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)CorpusLoader.convert2SentenceList把文本转成Sentence列表每个Sentence包含多个Word。setLabel(n)是给词性标注用的分词本身不关心词性但 HanLP 的模型格式需要这个字段。maker.compute会统计一元词频和二元组频次saveTxtTo生成三个文件文件作用my_cws_corpus_model.txt一元语法模型每行是“词 词性 频次”my_cws_corpus_model.tr.txt词性标注相关数据my_cws_corpus_model.ngram.txt二元语法模型每行是“词1词2 频次”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。因为语料里“商品”出现了两次“商品 和 服务”和“商品 和服 物美价廉”“和”也出现了两次。getTermFrequency返回的是词在一元模型里的频次这个值在 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 # 加载 Viterbi 分词器 ViterbiSegment JClass(com.hankcs.hanlp.seg.Viterbi.ViterbiSegment) segment ViterbiSegment() s segment.seg(商品和服务) print(s) predict()输出是[商品/n, 和/n, 服务/n]。Viterbi 算法会在所有可能的分词路径里找概率最大的那条。因为训练语料里“商品 和 服务”是作为一个句子出现的所以模型学到了“商品”后面跟“和”、“和”后面跟“服务”的概率较高分词结果就正确了。注意自定义模型路径一定要在创建ViterbiSegment之前设置否则它会加载默认的大词典你的小语料模型就不生效了。5. 避坑与常见问题排查5.1 UnicodeDecodeError: gbk codec cant decode现象打开停用词文件或语料文件时报UnicodeDecodeError: gbk codec cant decode byte 0x90 in position 2519。原因Windows 上 Python 的默认编码是 gbk但文件本身是 utf-8 编码用 gbk 去解码就炸了。解决所有open()调用都显式加encodingutf-8。这是血泪经验我后来写任何文件读写都强制带这个参数。5.2 HanLP data 包下载失败现象第一次from pyhanlp import *时卡在下载 data 包或者报连接超时。原因data 包托管在外部服务器网络不稳定时容易失败。解决手动下载 data 包解压后放到pyhanlp/static/目录下或者设置环境变量HANLP_ROOT指向数据目录。也可以先用hanlp命令行工具测试它会提示数据目录位置。5.3 自定义模型不生效现象训练完二元语法模型后ViterbiSegment的分词结果和默认词典一样没有用到小语料。原因HanLP.Config.CoreDictionaryPath和BiGramDictionaryPath的设置时机不对或者路径写错了。解决在JClass(...ViterbiSegment)之前设置路径并且用绝对路径或确认相对路径的基准目录。可以在设置后打印HanLP.Config.CoreDictionaryPath确认。5.4 AhoCorasickDoubleArrayTrieSegment 输出带 null 词性现象用AhoCorasickDoubleArrayTrieSegment分词结果里每个词后面都跟/null。原因HanLP.Config.ShowTermNature默认为 True但这个分词器不提供词性。解决在创建 segment 之前设置HanLP.Config.ShowTermNature False。5.5 双向最长匹配结果不符合预期现象自己实现的双向最长匹配和前向/后向结果一样没有起到“选优”作用。原因选优规则写错了或者词典里没有覆盖歧义切分的词。解决检查选优逻辑是否按“词数少优先、单字少优先”执行。另外确认词典里确实包含“和尚”“尚未”这些歧义词否则三种算法结果会完全一致。6. 二次开发改造把实验代码变成可复用分词工具实验代码跑通只是第一步真正有价值的是把它改造成能复用的工具。我一般会做三件事封装成类、加配置、补测试。先看封装。把词典分词和二元语法分词统一到一个Segmenter类里from pyhanlp import * class Segmenter: def __init__(self, dict_pathNone, bigram_pathNone, use_crfFalse): dict_path: 自定义一元词典路径None 则用默认 bigram_path: 自定义二元模型路径None 则用默认 use_crf: 是否用 CRF 分词器 if dict_path: HanLP.Config.CoreDictionaryPath dict_path if bigram_path: HanLP.Config.BiGramDictionaryPath bigram_path if use_crf: self.seg JClass(com.hankcs.hanlp.seg.CRF.CRFSegment)() else: self.seg JClass(com.hankcs.hanlp.seg.Viterbi.ViterbiSegment)() def cut(self, text, no_tagTrue): 分词返回词列表 HanLP.Config.ShowTermNature not no_tag terms self.seg.seg(text) if no_tag: return [t.word for t in terms] return [(t.word, t.nature) for t in terms] def cut_file(self, input_path, output_path): 对文件逐行分词并输出 with open(input_path, encodingutf-8) as fin, \ open(output_path, w, encodingutf-8) as fout: for line in fin: words self.cut(line.strip()) fout.write( .join(words) \n)这个类把模型路径、分词器类型、是否输出词性都参数化了。cut_file方法直接处理文件适合批量分词场景。改造时要注意几个点。第一HanLP.Config是全局的多个Segmenter实例会互相覆盖配置如果要在同一个进程里用不同模型得在每次cut前重新设置路径或者用HanLP.newSegment()创建独立实例。第二自定义模型只适合特定领域通用文本还是用默认大词典更稳。第三CRF 分词器比 Viterbi 慢但准确率更高线上服务如果 QPS 要求高建议用 Viterbi 加自定义词典。验证改造是否成功我习惯跑一个最小测试集def test_segmenter(): seg Segmenter() assert seg.cut(商品和服务) [商品, 和, 服务] seg2 Segmenter(dict_pathmy_cws_corpus_model.txt, bigram_pathmy_cws_corpus_model.ngram.txt) assert seg2.cut(商品和服务) [商品, 和, 服务] print(all tests passed) test_segmenter()从那以后我每次改分词配置都强制先跑一遍这个测试集确认默认模型和自定义模型都能正常工作。希望帮到你。本文还有配套的精品资源点击获取