简介:这份资源是南京邮电大学自然语言处理课程实验一的完整实验报告,面向正在学习中文分词与句法分析的高校学生及自学者,帮助其梳理词典分词、二元语法分词两大核心任务的原理与实操流程。压缩包内仅含1个doc文档,约232KB,按实验目的、环境配置、原理内容与结果记录的顺序组织,可直接对照填写或复盘。报告覆盖HanLP的segment分词指令与--no-tag参数用法、文件输入输出批处理、parse句法分析依存关系解读,并给出前向最长匹配、后向最长匹配、双向最长匹配三种词典分词算法的结果对比与差异说明,以及DoubleArrayTrieSegment、AhoCorasickDoubleArrayTrieSegment等类的Python实现代码。已有428人学习,适合需要完成同类实验、理解分词歧义与依存句法输出的读者参考借鉴。
1. 一份能跑通的分词实验包:从词典匹配到二元语法
拿到一份自然语言处理课程实验包,最怕的不是代码难,而是环境跑不起来、结果对不上、报告写不出。这份实验一资源围绕中文分词的两个基础任务展开:词典分词和二元语法分词,配套 HanLP 工具链、Python 脚本和语料文件,覆盖命令行调用、Python API 调用、模型训练与加载全流程。它适合正在学 NLP 基础课、需要交实验报告的学生,也适合想快速摸清中文分词底层逻辑的开发者。核心价值在于:所有步骤都有可复现的命令和代码,分词结果、词频统计、模型文件作用都能直接对照验证,不是纸上谈兵。
2. 环境搭不起来,后面全是白搭:HanLP 安装与命令行验证
2.1 为什么选 HanLP 而不是自己手写分词器
中文分词入门阶段,很多人第一反应是拿词典加规则自己写一个最长匹配。但真跑起来会发现,光一个“结婚的和尚未结婚的”就能让前向匹配翻车——它切出“和尚”而不是“和/尚未”。这类歧义靠手写规则越补越乱,而 HanLP 内置了词典、二元语法模型和多种分词算法,命令行和 Python 接口都能直接调,省掉大量造轮子的时间。实验环境要求 Windows 加 Python 3.7 或 3.8,这个组合在机房和老笔记本上都稳,不建议上太高版本,pyhanlp 对 3.9 以上偶尔会有 JPype 兼容问题。
安装分两步:先装 pyhanlp,再让它自动下载数据包。常见做法是:
pip install pyhanlp hanlp第一条命令装 Python 包,第二条命令触发 HanLP 的初始化,它会提示你下载 data 包。如果机房网络受限,可以手动把 data 目录放到pyhanlp/static/data下,路径后面会用到。装完后在命令行输入hanlp,能看到 segment、parse 等子命令说明,说明环境通了。
2.2 命令行分词与词性标注的开关
实验里第一个任务是观察hanlp segment的输出。直接输入句子,它会返回“词/词性”的格式,比如“商品/n, 和/cc, 服务/vn”。词性标注对后续句法分析有用,但如果你只想要纯分词结果,加--no-tag就行:
hanlp segment --no-tag输入“结婚的和尚未结婚的确实在干扰分词啊”,不加参数时输出带词性,加了之后只留词语。这个开关在批量处理文本时很实用,能减少输出体积。注意命令行交互模式下是逐行输入的,粘贴多行句子时别带多余空行,否则会多出空结果。
文件输入输出是实验里容易卡住的一步。要求对input1.txt分词并输出到output1.txt,指令是:
hanlp segment < input1.txt > output1.txt -a crf这里-a crf指定用 CRF 模型做分词,比默认的词典匹配更适合处理未登录词。重定向符号<和>在 Windows 的 cmd 和 PowerShell 里都支持,但 PowerShell 对编码更敏感,如果输出文件乱码,优先换 cmd 执行。跑完后打开 output1.txt,应该能看到“春分/t 最/d 具/vg 灵性/n”这样的逐词标注,和实验报告里的示例对得上,说明流程没问题。
2.3 Python 接口调用:segment 和 parse 的最小可用代码
命令行验证通过后,实验要求用 Python 代码复现分词和句法分析。pyhanlp 的接口很直接:
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 就是“词/词性”格式。HanLP.parseDependency返回依存树,输出每行的格式是“序号 词语 词语 词性 中心词序号 依存关系”,比如“徐先生”对应“主谓关系”,中心词指向“帮助”。这段代码跑通,说明 Python 环境和 HanLP 数据包都正常。如果报ModuleNotFoundError,检查是不是在错误的 conda 环境里执行;如果报 JPype 相关错误,多半是 JDK 版本不匹配,装个 JDK 8 或 11 再试。
3. 词典分词三种匹配算法:前向、后向、双向到底差在哪
3.1 最长匹配法的实现逻辑与分词结果对比
实验要求对“结婚的和尚未结婚的”分别用前向最长匹配、后向最长匹配、双向最长匹配切分。这三种算法的核心区别在于扫描方向和歧义消解策略。前向最长匹配从左往右,每次取词典中最长的词;后向从右往左;双向则同时跑前向和后向,再按规则选一个更优的结果。
用 Python 实现时,核心是加载词典并做最大长度匹配。常见做法是把词典读成集合,记录最大词长,然后逐位置尝试匹配:
def forward_max_match(text, word_dict, max_len): result = [] i = 0 while i < len(text): matched = None for length in range(min(max_len, len(text) - i), 0, -1): candidate = text[i:i + length] if candidate in word_dict: matched = candidate break if matched: result.append(matched) i += len(matched) else: result.append(text[i]) i += 1 return result这段代码里max_len是词典中最长词的长度,用来限制每次匹配的窗口。word_dict是词典集合,text是待分词句子。前向匹配从左往右扫,后向匹配把 text 反转后同样处理再反转结果。双向匹配则分别跑前向和后向,然后比较两者结果:如果词数不同,取词数少的;如果词数相同,取单字词更少的;还相同就取后向结果。
实际跑“结婚的和尚未结婚的”,前向结果是['结婚', '的', '和尚', '未', '结婚', '的'],后向和双向都是['结婚', '的', '和', '尚未', '结婚', '的']。前向翻车的原因很直观:它从左往右贪心匹配,到“和”字时发现“和尚”在词典里,就直接切走了,完全不管后面“尚未”能不能成词。后向从右往左,先锁定“结婚的”,再往前处理“和尚未”,自然切出“和/尚未”。双向匹配在这里选了后向结果,因为后向的单字词更少。
3.2 DoubleArrayTrieSegment 与 AhoCorasick 分词器的差异
实验还要求用DoubleArrayTrieSegment和AhoCorasickDoubleArrayTrieSegment两个类对同一段春分文字做词典分词。这两个类的底层数据结构不同:前者用双数组 Trie 做前缀匹配,后者用 Aho-Corasick 自动机做多模式匹配。对普通文本,两者结果接近,但 AC 自动机在长文本和大量模式串场景下更快。
调用方式:
from pyhanlp import * # DoubleArrayTrieSegment segment = DoubleArrayTrieSegment() print(segment.seg('春分之特别,在一个“均”字:均分了昼夜,均分了寒暑,均分了春天。')) # AhoCorasickDoubleArrayTrieSegment HanLP.Config.ShowTermNature = False segment_ac = JClass('com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment')() print(segment_ac.seg('春分之特别,在一个“均”字:均分了昼夜,均分了寒暑,均分了春天。'))DoubleArrayTrieSegment的输出里每个词后面带/null,因为词典分词不附带词性,ShowTermNature默认开着就会补 null。把HanLP.Config.ShowTermNature设为 False 后,AC 分词器输出纯词语列表。注意 AC 分词器在这个例子里切得很碎,出现“春/分/之/特/别”这种单字结果,原因是它的词典加载方式和 DAT 不同,对未登录词的处理更保守。这不是 bug,而是两种分词器设计目标的差异:DAT 适合通用分词,AC 适合关键词提取和敏感词过滤这类多模式匹配任务。
3.3 停用词过滤:从 CoreStopWordDictionaryPath 到替换脚本
停用词去除是文本预处理的高频操作。实验要求找到stopwords.txt路径并写代码过滤。HanLP 的停用词表路径可以通过HanLP.Config.CoreStopWordDictionaryPath拿到,通常在pyhanlp/static/data/dictionary/stopwords.txt。过滤代码用双数组 Trie 做最长匹配替换:
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_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把停用词逐行读入 TreeMap,再构建 DoubleArrayTrie。replace_stopwords_text用getLongestSearcher在文本里找最长匹配的停用词,命中就替换成*,没命中的片段原样保留。这里有个血泪经验:打开停用词文件必须指定encoding='utf-8',否则 Windows 默认 GBK 解码会报UnicodeDecodeError,实验报告里也记录了这个坑。替换符用*只是方便肉眼确认哪些词被干掉了,实际预处理时直接删掉或替换成空格更常见。
4. 二元语法分词:从语料训练到 Viterbi 解码
4.1 训练语料的格式与 NatureDictionaryMaker 的三个输出文件
二元语法分词的核心是用统计模型算词频和相邻词共现频率,再用 Viterbi 算法找最优切分路径。实验给了一份极小的语料my_cws_corpus.txt:
商品 和 服务 商品 和服 物美价廉 服务 和 货币每行是一句已分好词的句子,词之间用空格隔开。训练代码用CorpusLoader读语料,给每个词打上词性标签“n”,再交给NatureDictionaryMaker统计:
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")convert2SentenceList把每行转成 Sentence 对象,setLabel("n")统一词性,避免词性不统一导致统计分散。maker.compute统计一元词频和二元共现,saveTxtTo落盘生成三个文件:my_cws_corpus_model.txt是一元语法模型,每行“单词 词性 频次”;.tr.txt和词性标注相关;.ngram.txt是二元语法模型,记录相邻词对的频次。这三个文件里,一元模型决定单个词的概率,二元模型决定相邻词搭配的合理性,Viterbi 解码时两者一起用。
4.2 加载模型查词频:CoreDictionary 的路径覆盖技巧
训练完模型后,实验要求查“商品”和“和”的频率。关键操作是覆盖 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")CoreDictionaryPath指向一元模型,BiGramDictionaryPath指向二元模型。getTermFrequency返回词频,这里“商品”和“和”各出现 2 次,和语料对得上。注意路径覆盖必须在加载 CoreDictionary 之前完成,否则 HanLP 已经用默认词典初始化过了,改路径不生效。这个顺序问题在实验里很容易翻车,表现就是查出来的频次和语料对不上。
4.3 ViterbiSegment 解码“商品和服务”的完整链路
最后一步是用训练好的模型对“商品和服务”做分词。代码把训练和预测串在一起:
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) 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) train_bigram("my_cws_corpus.txt", "my_cws_corpus_model") predict()输出是[商品/n, 和/n, 服务/n]。ViterbiSegment 在解码时枚举所有可能切分,用一元词频和二元共现频次算路径概率,取概率最大的那条。语料里“商品 和 服务”出现过,所以“商品/和/服务”这条路径得分最高。如果语料里只有“商品 和服”,结果可能变成“商品/和服”,这就是二元语法模型对语料分布的敏感性。参数上,CoreDictionaryPath和BiGramDictionaryPath必须成对指向同一套模型,混用默认词典和自训练二元模型会导致概率计算错位。
5. 避坑与排查:五个让实验卡住的真实问题
5.1 停用词文件读取报 UnicodeDecodeError
现象:运行停用词过滤代码时抛UnicodeDecodeError: 'gbk' codec can't decode byte。原因:Windows 下open()默认用 GBK 解码,而停用词表是 UTF-8 编码。解决:打开文件时显式加encoding='utf-8',所有读写文本文件的地方都统一加上,别依赖系统默认编码。
5.2 模型路径覆盖后词频查询结果不对
现象:训练完模型,查“商品”频次返回的是默认词典的值,不是语料里的 2。原因:HanLP.Config.CoreDictionaryPath的赋值发生在 CoreDictionary 已经初始化之后,HanLP 内部缓存了旧词典。解决:把路径覆盖放在所有 HanLP 词典调用之前,或者在新进程里先设路径再查词频。实验代码里load_bigram函数先改路径再SafeJClass加载,顺序不能反。
5.3 命令行重定向在 PowerShell 下输出乱码
现象:hanlp segment < input1.txt > output1.txt在 PowerShell 里跑完,output1.txt 打开是乱码。原因:PowerShell 的默认输出编码和 HanLP 的 UTF-8 输出不一致。解决:换 cmd 执行,或者在 PowerShell 里先执行chcp 65001切到 UTF-8 代码页。更稳的做法是用 Python 脚本读写文件,绕开 shell 编码问题。
5.4 AhoCorasick 分词器切出大量单字
现象:用AhoCorasickDoubleArrayTrieSegment对春分文本分词,结果全是“春/分/之/特/别”这种单字。原因:AC 分词器加载的词典和 DAT 不同,对未登录词不做额外合并,只做严格的多模式匹配。解决:这不是错误,是设计差异。需要通用分词就用DoubleArrayTrieSegment或HanLP.segment,AC 分词器留给关键词匹配场景。
5.5 pyhanlp 导入报 JPype 相关错误
现象:from pyhanlp import *时报jpype._jexception或找不到 JVM。原因:机器上没装 JDK,或者 JDK 版本和 pyhanlp 不兼容。解决:装 JDK 8 或 11,设置JAVA_HOME,确保java -version能正常输出。conda 环境里如果装了多个 Python 版本,确认 pip 装到了当前激活的环境。
6. 把实验包用出报告之外的价值:三个可复用的验证习惯
这份实验包的价值不止于交一次作业。我带人做分词项目时,习惯把里面的验证思路固化下来,后面换语料、换模型都能直接套。第一个习惯是每次训练完二元模型,先查几个高频词和低频词的频次,和语料肉眼对一遍。频次对不上,后面 Viterbi 解码全是错的,这一步相当于黑匣子前的自检。第二个习惯是分词结果不只看整体,专门挑歧义句跑前向、后向、双向三组对比,记录哪种算法在什么句子上翻车。时间长了你会对“最长匹配的贪心边界”有肌肉记忆,看到“和尚未”这种结构就知道前向要出事。
第三个习惯是路径覆盖类操作一律写成一个函数,函数里先改HanLP.Config再加载类,绝不把赋值散落在代码各处。下面这个模板可以直接抄:
def load_custom_model(model_prefix): HanLP.Config.CoreDictionaryPath = model_prefix + ".txt" HanLP.Config.BiGramDictionaryPath = model_prefix + ".ngram.txt" CoreDictionary = SafeJClass('com.hankcs.hanlp.dictionary.CoreDictionary') ViterbiSegment = JClass('com.hankcs.hanlp.seg.Viterbi.ViterbiSegment') return CoreDictionary, ViterbiSegment()调用时先load_custom_model("my_cws_corpus_model"),再查频次或分词,顺序固定,不会出现“改了路径但没生效”的玄学问题。另外,停用词过滤的替换符建议从*改成空字符串或空格,方便直接进入下游任务;如果要做词云或关键词统计,保留*反而会污染词表。
从那以后我每次跑分词实验,都强制先做三件事:命令行hanlp segment验证环境、查两个词的频次验证模型路径、拿一个歧义句跑三种匹配算法验证理解。这三步走完,后面写代码基本不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取