简介:这份资源是南京邮电大学自然语言处理课程实验一的完整实验报告,面向正在学习NLP基础课程的高校学生及需要完成分词实验的初学者。报告围绕词典分词与二元语法分词两大核心任务展开,涵盖HanLP工具的分词指令、句法分析指令、文件输入输出处理,以及前向最长匹配、后向最长匹配、双向最长匹配三种算法的对比分析,并附有Python代码实现示例。资源包为1个doc文档,大小约232KB,内容包含实验目的、环境配置、原理说明、操作步骤与结果记录,结构完整,可直接作为实验参考模板。目前已有421人学习下载,适合需要快速理解分词原理、对照完成实验报告或复习HanLP用法的读者使用。
1. 从南邮实验一拆开中文分词:词典匹配和二元语法到底怎么落地
很多人第一次接触中文 NLP,都是从“分词”这两个字开始的。看起来简单——把一句话切成词——但真动手写代码,你会发现坑比想象中多得多。南邮自然语言处理实验一就是围绕这个核心问题展开的:用 HanLP 做词典分词和二元语法分词,跑通从命令行到 Python 调用的完整链路。这份实验报告覆盖了 HanLP 的 segment、parse 指令、前向/后向/双向最长匹配算法、DoubleArrayTrie 和 AhoCorasickDoubleArrayTrie 两种词典结构、停用词过滤,以及基于自己构建的小语料训练二元语法模型并用 ViterbiSegment 做分词预测。适合正在上 NLP 课程、需要交实验报告的同学,也适合想快速摸清 HanLP 分词能力边界、不想翻厚重文档的工程师。下面我按实际动手顺序,把每一步拆开讲清楚。
2. HanLP 环境搭建与命令行分词:从 hanlp segment 到文件批量处理
2.1 安装 HanLP 与验证环境
实验环境要求 Windows + Python 3.7 或 3.8。HanLP 的 Python 接口依赖 pyhanlp 包,底层是 Java 实现,所以需要 JDK 环境。常见做法是先用 conda 建一个干净环境,再装 pyhanlp:
conda create -n nlp_lab python=3.8 conda activate nlp_lab pip install pyhanlp装完之后,第一次运行from pyhanlp import *时,pyhanlp 会自动下载 data 包(大约几百 MB),包括核心词典、二元语法模型等。如果网络不稳定,这一步容易卡住。我一般会先手动确认HanLP.Config.CoreDictionaryPath指向的路径下文件是否完整。
验证安装是否成功:
from pyhanlp import * print(HanLP.segment('商品和服务'))如果输出[商品/n, 和/cc, 服务/vn],说明环境没问题。注意词性标注默认是开启的,/n表示名词,/cc表示并列连词,/vn表示动名词。这些词性标签来自 HanLP 内置的 ICTCLAS 兼容标签集。
2.2 命令行分词指令与参数说明
HanLP 安装后会注册一个命令行入口hanlp。直接在终端输入hanlp可以看到所有子指令。实验里重点用到两个:segment和parse。
hanlp segment进入交互模式,输入句子回车即可看到分词结果。如果不想输出词性,加--no-tag:
hanlp segment --no-tag批量处理文件时,用重定向:
hanlp segment < input1.txt > output1.txt -a crf这里-a crf指定使用 CRF 模型做分词,而不是默认的 Viterbi。CRF 在未登录词和歧义切分上通常更稳,但速度慢一些。实验报告里对“春分最具灵性的美……”这段文本的输出结果,就是 CRF 模型跑出来的。你可以对比不加-a crf的结果,会发现某些边界切分不一样。
提示:如果
hanlp命令找不到,检查 pyhanlp 安装后 Scripts 目录是否在 PATH 里,或者直接用python -m pyhanlp替代。
2.3 句法分析指令 parse 的输出解读
hanlp parse做的是依存句法分析,输出的是每个词与其他词的依存关系。实验里对“徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标”的分析结果,每行格式是:序号、词语、词性、依存弧、关系类型。
比如第 4 行“帮助”是核心关系(_0),说明它是整句的根节点。第 1 行“徐先生”指向第 4 行,关系是“主谓关系”。第 5 行“他”也指向第 4 行,关系是“兼语”——因为“帮助他确定”里“他”既是“帮助”的宾语,又是“确定”的主语。
这个输出对理解句子结构很有用,但要注意:HanLP 的依存分析基于预训练模型,对复杂长句不一定百分百准确。实验里这个句子结构清晰,所以结果比较规整。实际用的时候,如果句子里有省略或倒装,依存弧可能会乱。
3. 词典分词三种匹配算法:前向、后向、双向的代码实现与差异
3.1 最长匹配法的核心逻辑
词典分词的本质是:给定一个词典,对句子做切分,使得切分结果尽量合理。最长匹配法是最直观的策略——每次从当前位置开始,找词典里能匹配的最长词。
前向最长匹配:从左到右扫描,每次取最长匹配。 后向最长匹配:从右到左扫描,每次取最长匹配。 双向最长匹配:同时跑前向和后向,然后按规则选一个更优的结果。常见规则是:词数更少优先,词数相同则单字更少优先。
实验里对“结婚的和尚未结婚的”这个经典歧义句,三种方法结果不同:
- 前向:
['结婚', '的', '和尚', '未', '结婚', '的'] - 后向:
['结婚', '的', '和', '尚未', '结婚', '的'] - 双向:
['结婚', '的', '和', '尚未', '结婚', '的']
前向匹配把“和尚”切出来了,但语义上“和/尚未”更合理。后向匹配避免了这个问题。双向匹配选了后向的结果。
3.2 Python 实现三种匹配算法
下面是我自己写的一版,不依赖 HanLP 的词典,用一个小词典演示:
# 词典,实际使用时可以从文件加载 word_dict = {'结婚', '的', '和', '和尚', '尚未', '未', '结', '婚'} def forward_max_match(text, dictionary): result = [] i = 0 while i < len(text): matched = False # 从最长可能长度开始尝试 for j in range(min(len(text) - i, 5), 0, -1): if text[i:i+j] in dictionary: result.append(text[i:i+j]) i += j matched = True break if not matched: result.append(text[i]) i += 1 return result def backward_max_match(text, dictionary): result = [] i = len(text) while i > 0: matched = False for j in range(min(i, 5), 0, -1): if text[i-j:i] in dictionary: result.append(text[i-j:i]) i -= j matched = True break if not matched: result.append(text[i-1]) i -= 1 return result[::-1] def bidirectional_max_match(text, dictionary): fwd = forward_max_match(text, dictionary) bwd = backward_max_match(text, dictionary) # 简单规则:词数少优先,词数相同单字少优先 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 bwd text = '结婚的和尚未结婚的' print('前向:', forward_max_match(text, word_dict)) print('后向:', backward_max_match(text, word_dict)) print('双向:', bidirectional_max_match(text, word_dict))参数说明:max_len我设的是 5,实际词典里最长词可能更长,需要根据词典调整。word_dict用 set 存储,查找是 O(1)。双向匹配的选优规则可以改,比如有些实现会优先选后向结果。
3.3 DoubleArrayTrie 与 AhoCorasickDoubleArrayTrie 的差异
实验里用 HanLP 的DoubleArrayTrieSegment和AhoCorasickDoubleArrayTrieSegment对同一段文字分词,结果差异很大。前者输出的是正常词语,后者几乎切成了单字。
原因在于:DoubleArrayTrieSegment加载的是 HanLP 核心词典,词典里有“春分”“特别”“均分”这些词,所以能正确切分。而AhoCorasickDoubleArrayTrieSegment如果不加载自定义词典,默认词典为空,所以每个字都单独成词。
正确用法是给 AhoCorasick 也加载词典:
from pyhanlp import * # 方式一:DoubleArrayTrieSegment,默认加载核心词典 seg1 = DoubleArrayTrieSegment() print(seg1.seg('春分之特别,在一个“均”字')) # 方式二:AhoCorasickDoubleArrayTrieSegment,需要手动加载词典 AhoCorasickDoubleArrayTrieSegment = JClass('com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment') seg2 = AhoCorasickDoubleArrayTrieSegment() seg2.enableCustomDictionary(True) # 启用自定义词典 print(seg2.seg('春分之特别,在一个“均”字'))AhoCorasick 的优势在于多模式匹配,适合从文本中同时查找多个关键词。如果只是做通用分词,DoubleArrayTrie 更合适。
4. 二元语法分词实战:从语料训练到 ViterbiSegment 预测
4.1 构建语料与训练二元语法模型
实验要求新建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")运行后会生成三个文件:
| 文件名 | 作用 |
|---|---|
my_cws_corpus_model.txt | 一元语法模型,每行格式:单词 词性 频次 |
my_cws_corpus_model.tr.txt | 词性标注相关数据 |
my_cws_corpus_model.ngram.txt | 二元语法模型,记录词对共现频次 |
一元模型里,“商品”出现 2 次,“和”出现 2 次,“服务”出现 2 次。二元模型里会记录“商品 和”“和 服务”等词对的频次。
4.2 加载模型并查询词频
训练完模型后,需要告诉 HanLP 用我们自己的模型而不是默认模型:
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。注意这里修改的是全局配置,会影响后续所有分词操作。如果后面还要用默认模型,记得改回来,或者用独立进程跑。
4.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 算法会根据二元语法概率选择最优路径。在这个小语料下,“商品/和/服务”的路径概率更高。
这个实验的关键点是:二元语法分词的效果高度依赖训练语料的规模和覆盖度。三条语料只能演示流程,实际场景需要大量标注数据。
5. 避坑与排查:HanLP 实验里最容易翻车的五个地方
5.1 UnicodeDecodeError: 'gbk' codec can't decode
现象:读取停用词表或语料文件时,Python 报UnicodeDecodeError: 'gbk' codec can't decode byte 0x90。
原因:Windows 默认编码是 GBK,但文件实际是 UTF-8 编码。open()不指定 encoding 时,Python 用系统默认编码去解码,遇到 UTF-8 字节就崩了。
解决:所有文件读取都显式加encoding='utf-8':
with open(path, encoding='utf-8') as src: for word in src: word = word.strip() # ...5.2 pyhanlp 首次运行卡在下载 data 包
现象:from pyhanlp import *执行后长时间无响应,或者报连接超时。
原因:pyhanlp 需要下载几百 MB 的 data 包,默认从 GitHub 拉取,网络不稳定时容易失败。
解决:手动下载 data 包,放到 pyhanlp 的 static 目录下。或者设置环境变量HANLP_DATA_PATH指向已下载的 data 目录。如果之前装过,检查HanLP.Config.CoreDictionaryPath指向的路径是否存在。
5.3 修改全局配置后默认模型失效
现象:跑完二元语法实验后,再用HanLP.segment()分词,结果变得很奇怪,很多词被切成单字。
原因:HanLP.Config.CoreDictionaryPath和BiGramDictionaryPath被改成了自定义模型路径,但自定义模型只包含三条语料的词汇,覆盖不了通用文本。
解决:实验结束后恢复默认配置,或者把自定义模型相关的代码放在独立脚本里跑。如果要在同一进程里切换,先保存原始路径:
original_core = HanLP.Config.CoreDictionaryPath original_bigram = HanLP.Config.BiGramDictionaryPath # ... 做实验 ... HanLP.Config.CoreDictionaryPath = original_core HanLP.Config.BiGramDictionaryPath = original_bigram5.4 AhoCorasickDoubleArrayTrieSegment 输出全是单字
现象:用 AhoCorasick 分词,结果每个字都是独立的,没有词语。
原因:AhoCorasick 默认不加载任何词典,需要手动启用自定义词典或加载核心词典。
解决:调用enableCustomDictionary(True)或者用DoubleArrayTrieSegment替代。如果确实需要 AhoCorasick 的多模式匹配能力,先加载词典再分词。
5.5 停用词过滤后标点符号也被替换
现象:用停用词表过滤文本,结果标点符号也被替换成了*。
原因:HanLP 的核心停用词表里包含标点符号。如果不想过滤标点,需要自己维护一个停用词表,去掉标点。
解决:加载停用词表后,手动移除标点符号,或者用自定义停用词表:
# 过滤掉停用词表中的标点 stopwords = set() with open(HanLP.Config.CoreStopWordDictionaryPath, encoding='utf-8') as f: for line in f: word = line.strip() if word and not word in ',。!?、;:""''()': stopwords.add(word)6. 进阶技巧:用自定义词典和模型验证分词效果
实验里用到的 DoubleArrayTrie 和 AhoCorasick 都是词典分词的底层结构。实际项目中,更常见的做法是:用通用模型做基础分词,再用自定义词典补充领域词汇。HanLP 支持通过CustomDictionary动态添加词:
from pyhanlp import * # 添加自定义词 CustomDictionary.add("春分二候") CustomDictionary.add("玉渊潭") text = '走在春分二候的京城,植物园的玉兰在蓝天下盛开' print(HanLP.segment(text))如果不加自定义词,“春分二候”可能被切成“春分/二候”或更碎。加了之后,HanLP 会优先匹配自定义词。
另一个实用技巧是:用HanLP.segment的Segment类手动指定模型和词典,避免全局配置污染:
from pyhanlp import * Segment = JClass('com.hankcs.hanlp.seg.Segment') segment = Segment.newSegment() # 或者指定自定义词典 segment.enableCustomDictionary(true) result = segment.seg('商品和服务') print(result)验证分词效果时,我一般会准备一个小测试集,包含歧义句、未登录词、数字和英文混合等情况,跑一遍看切分边界是否符合预期。比如“结婚的和尚未结婚的”这种句子,就是检验分词器歧义处理能力的试金石。
从那以后我每次跑 HanLP 实验,都会先把全局配置备份一遍,实验结束立刻恢复,避免后面调用默认模型时出现玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取