ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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-tag:

hanlp 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_len=5): 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

参数说明:max_len是词典中最长词的长度,一般设 5 到 7。dictionary是集合或哈希表,查询 O(1)。这个算法的问题就是“长词优先”会掩盖短词组合的正确切分。

3.2 后向最长匹配:从右往左切出“尚未”

后向最长匹配从右往左扫描,同样取最长词。对同一句话,从右端开始,“的”先切掉,然后“结婚”切掉,再往左遇到“尚未”,词典里有“尚未”就切出来,于是得到['结婚', '的', '和', '尚未', '结婚', '的']。这个结果就对了。后向匹配之所以在这里更好,是因为中文的偏正结构往往中心词在后,从右往左切能先锁定“尚未结婚的”这个整体。

代码只需把前向的循环方向反过来:

def backward_max_match(text, dictionary, max_len=5): 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_len=5): 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, encoding='utf-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 can't decode...,原因是open默认用系统编码。加上encoding='utf-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 can't decode→ 原因:open 没指定编码 → 解决:加encoding='utf-8'

实验报告里专门记了这条。Windows 中文系统默认编码是 GBK,而停用词表是 UTF-8。读文件时写open(path, encoding='utf-8')就行。写文件也一样,open(path, 'w', encoding='utf-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 API

Windows cmd 不支持<<<。要么换 PowerShell,要么直接写 Python:

from 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”这个流程,少一步都可能被某个隐藏的编码或路径问题坑掉。希望帮到你。

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

RELATED READING

延伸阅读

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