简介:这份资源是南京邮电大学自然语言处理课程实验一的完整实验报告,面向正在学习中文分词与句法分析的高校学生及入门开发者,帮助其理解词典分词与统计语言模型的实现思路。内容围绕HanLP工具链展开,涵盖segment分词指令与--no-tag参数用法、文件输入输出重定向、parse句法依存分析,以及教材第27页例子的Python代码复现;同时对比前向、后向与双向最长匹配法对「结婚的和尚未结婚的」的切分差异,并涉及DoubleArrayTrieSegment、AhoCorasickDoubleArrayTrieSegment等词典分词类与核心词典路径的查看方式。资源包共1个doc文件,约232KB,为原始实验报告文档,含实验目的、环境配置、原理与逐题作答记录,可直接对照填写或复盘。目前已有429人学习下载,适合需要完成同类实验、查漏补缺分词算法细节的读者参考。
1. 一份 2024 年的 HanLP 实验报告,值得复现的是哪几行
自然语言处理课的第一节实验,多数人交完就忘:敲一条hanlp segment,抄几行输出,凑两段心得。我重新把这份实验报告拆了一遍,发现它其实是两套东西拼起来的——前一半是词典分词,靠字典和最长匹配硬切;后一半是二元语法分词,靠语料统计加维特比解码软切。分界线特别清楚:一头是CoreNatureDictionary.txt,另一头是自己训出来的my_cws_corpus_model.ngram.txt。看懂这条线,才知道什么时候该信字典、什么时候该信概率。适合三类人:要交实验但想弄明白每一步在干什么的、要把 HanLP 接进老项目的、想手写一遍 Viterbi 分词但被接口绕晕的。
2. 先把命令行跑通:segment、parse 与它们的参数
命令行是这套工具里最便宜的一次验证。Python 侧要经过 JPype 起 JVM,装错版本直接抛一串 Java 堆栈,先走 CLI 能把「环境问题」和「代码问题」分开。这一章把报告里出现的每一条指令都跑一遍,顺便把参数含义钉死。
2.1 先验证 Java 和模型,再碰 Python
pyhanlp 本质是 Java 工具包的 Python 绑定,第一次import时会在进程内拉起 JVM,慢且容易出玄学问题。稳妥顺序是先确认命令行可用,再动脚本。
# Python 3.7 / 3.8 环境里安装 pip install pyhanlp # 不带参数运行,会列出全部子命令 hanlp如果提示hanlp 不是内部或外部命令,换成python -m pyhanlp;如果第一次运行卡在下载,是它在拉 data 数据包,网络不稳就手动下载后放到pyhanlp/static/data下。这一步的关键产物是核心词典和模型文件,后面所有分词都依赖它们。
hanlp的输出里,实验要用的只有两个:segment(分词)和parse(依存句法分析)。其余子命令属于加分项,不影响本次任务。
提示:确认 Python 版本别用 3.9 以上去凑,pyhanlp 依赖的 JPype 版本和老版本解释器绑定较紧,版本错配的报错信息通常和分词本身毫无关系。
2.2 五句测试句:词性标签集是第一道校验
进入交互模式后,逐句输入下面五行。这些句子的价值不在结果本身,而在于它们覆盖了分词里最常见的几类歧义。
hanlp segment # 之后逐行输入句子,回车出结果 商品和服务 当下雨天地面积水分外严重 王总和小丽结婚了 欢迎新老师生前来就餐 结婚的和尚未结婚的确实在干扰分词啊| 输入句子 | 切分与词性 | 校验点 |
|---|---|---|
| 商品和服务 | 商品/n,和/cc,服务/vn | 单字连词与名词的边界 |
| 当下雨天地面积水分外严重 | 当/p,下雨天/n,地面/n,积水/n,分外/d,严重/a | 「下雨天」与「地面」的切分粒度 |
| 王总和小丽结婚了 | 王总/nr,和/cc,小丽/nr,结婚/vi,了/ule | 人名标记 nr 是否生效 |
| 欢迎新老师生前来就餐 | 欢迎/v,新/a,老/a,师生/n,前来/vi,就餐/vi | 「新老师生」的歧义消解 |
| 结婚的和尚未结婚的确实在干扰分词啊 | 结婚/vi,的/ude1,和/cc,尚未/d,结婚/vi,的/ude1,确实/ad,在/p,干扰/vn,分词/n,啊/y | 连词与副词的边界 |
先把这套标签记住:n 名词、vn 动名词、nr 人名、vi 不及物动词、a 形容词、d 副词、ad 副词、p 介词、cc 连词、ude1 的、ule 了、y 语气词、t 时间词、w 标点。后面自训模型输出词性时,对照的就是这一套。
最后一句值得多看两眼:HanLP 给出的是「和/cc 尚未/d」,而手写前向最长匹配会切成「和尚/未」。同一个句子,两条路走出两种结果,这就是第 3 章要拆的东西。
2.3--no-tag与输入输出重定向
不想看词性时,加一个开关即可,注意是两条短横线。
# 只出词,不出词性 hanlp segment --no-tag # 从文件读、往文件写,并指定切分算法为 crf hanlp segment < input1.txt > output1.txt -a crf参数逐个说清:--no-tag关掉词性标注,输出只剩词语本身;<和>是 shell 的重定向,不是 HanLP 的参数,Windows 的 cmd 支持,PowerShell 不支持<输入重定向,这一条后面会单独讲;-a crf指定切分算法为 CRF 序列标注模型,可选值还有viterbi、nshort等,换算法就是换一套切分策略,词性结果也会跟着变。
跑出来的 output1.txt 大致长这样(截取开头):
春分/t 最/d 具/vg 灵性/n 的/ude1 美/b ,/w 是/vshi 千/m 色/ng 万/m 彩/ng 在/p 此时/r 终于/d 迎来/v 了/ule 曾经/d 在/p 冬日/n 寒冷/a 中/f 蛰伏/v 的/ude1 土地/n ,/w 走过/v 立春/t ,/w 走过/v 雨水/n ,/w 走过/v 惊蛰/t有个细节:CRF 算法把「春分」「立春」「惊蛰」标成了t(时间词),把「千」「万」标成了m(数词),这些细粒度词性在纯词典分词里是拿不到的。做命名实体或时间抽取时,这类标签比切分本身更有用。
2.4 parse 的 18 行输出怎么读
句法分析用parse子命令,把句子作为标准输入喂进去。
hanlp parse <<< '徐先生还具体帮助他确定了把画雄鹰、松鼠和麻雀作为主攻目标。'<<<是 bash 的 here-string,只存在于 bash/zsh。Windows 的 cmd 不认这个符号,PowerShell 里也不认,替代写法是echo 句子 | hanlp parse,或者干脆进交互模式粘贴句子。
输出是每行一个词,字段依次为序号、词形、词性、中心词编号、依存关系。整理后关键几行如下:
| 序号 | 词 | 词性 | 中心词 | 依存关系 |
|---|---|---|---|---|
| 1 | 徐先生 | nh | 4 | 主谓关系 |
| 2 | 还 | d | 4 | 状中结构 |
| 3 | 具体 | ad | 4 | 状中结构 |
| 4 | 帮助 | v | 0 | 核心关系 |
| 5 | 他 | r | 4 | 兼语 |
| 6 | 确定 | v | 4 | 动宾关系 |
| 8 | 把 | p | 15 | 状中结构 |
| 10 | 雄鹰 | n | 9 | 动宾关系 |
| 17 | 目标 | n | 15 | 动宾关系 |
中心词为 0 的那个词就是全句根节点,这里是「帮助」。「徐先生」挂在 4 号上作主语,「他」是兼语,「把」作为介词指向 15 号「作为」,形成处置式结构。
这套标注是给上层任务用的:关系抽取、事件抽取、问答里的主谓宾定位,都依赖中心词索引来还原结构。复制粘贴时容易把列挤在一起,看到「徐先生徐先生nhnr_4主谓关系」这种连成一片的输出是正常的,断列按空格或制表符重新对齐即可。
3. 三种最长匹配与双数组字典:从手写 MR 到停用词替换
命令行只能验证「能跑」,验证不了「为什么这么切」。这一章把报告里三种最长匹配手写一遍,再上到 HanLP 真实使用的双数组 Trie,最后用同一套匹配逻辑做停用词替换——你会发现它们是同一个东西的三种外衣。
3.1 前向最长匹配:20 行代码和它的固有缺陷
前向最长匹配(FMM)的思路朴素到不用解释:从左往右扫,在每个位置尝试词典里最长的词,命中就切走,不命中就单字成词。
# 前向最长匹配:从左往右,每个位置取词典里能命中的最长词 def forward_max_match(text, word_dict, max_word_len=4): result = [] i = 0 n = len(text) while i < n: matched = None # 内层从长到短试,第一个命中的就是最长匹配 for length in range(min(max_word_len, n - i), 0, -1): piece = text[i:i + length] if piece in word_dict: matched = piece break if matched: result.append(matched) i += len(matched) else: result.append(text[i]) # 词典里没有,单字成词 i += 1 return result word_dict = set(['结婚', '的', '和尚', '尚未', '和', '未', '商品', '和服', '服务', '货币']) print(forward_max_match('结婚的和尚未结婚的')) # ['结婚', '的', '和尚', '未', '结婚', '的']逻辑说明:外层while控制扫描位置,内层for从最长候选长度递减,保证「长词优先」;in word_dict是哈希集合查询,平均 O(1);匹配失败时退化为单字,这一步是必须的,否则遇到未登录字会死循环。
参数说明:max_word_len是词典最长词长的上限,设小了会漏掉长词匹配,设大了只是多几次无用的切片和查表,工程上一般取词典里的最大词长。真实项目里这个集合要换成 Trie,原因在 3.3 节说。
输出里「和尚」被切出来了,这就是 FMM 的典型翻车点:它只对当前位置负责,不管右边还有没有更合理的组合。
3.2 后向与双向:改动只有两行,结论却相反
后向最长匹配(BMM)把扫描方向反过来,从右往左,最后把结果翻转。
# 后向最长匹配:从右往左扫,结果最后翻转 def backward_max_match(text, word_dict, max_word_len=4): result = [] i = len(text) while i > 0: matched = None for length in range(min(max_word_len, i), 0, -1): piece = text[i - length:i] if piece in word_dict: matched = piece break if matched: result.append(matched) i -= len(matched) else: result.append(text[i - 1]) i -= 1 return result[::-1] # 逆序切出来的,翻转回正常语序 def bidirectional_max_match(text, word_dict, max_word_len=4): forward = forward_max_match(text, word_dict, max_word_len) backward = backward_max_match(text, word_dict, max_word_len) # 启发式:单字越少越好,一样多就取后向 f_single = sum(1 for w in forward if len(w) == 1) b_single = sum(1 for w in backward if len(w) == 1) return forward if f_single < b_single else backward print(backward_max_match('结婚的和尚未结婚的')) # ['结婚', '的', '和', '尚未', '结婚', '的'] print(bidirectional_max_match('结婚的和尚未结婚的')) # ['结婚', '的', '和', '尚未', '结婚', '的']逻辑说明:后向匹配在「和尚未」这个位置先看到的是「尚未」,切走之后左边只剩「和」,无从拼出「和尚」。差别不在算法谁更聪明,而在于歧义位置由谁先拍板——前向让左边的「和」先决定,后向让右边的「未」先决定。
参数说明:双向匹配的裁决规则是可以换的。常见启发式有三种:单字总数少的胜出、词数少的胜出、平局取后向。这里用的是第一种,报告里双向结果与后向一致,说明这句上后向的单字更少。
顺带把和 HanLP 的对比补上:默认 Viterbi 分词给出的正是「和/cc 尚未/d」,和后向一致。统计模型不显式做方向选择,但它用词频和二元概率投票,投出来的结果更接近后向的直觉。
3.3 DoubleArrayTrieSegment 与 AC 自动机:两种字典结构的取舍
手写版本用的是 Python 集合,词典上万条之后内存和构建速度都撑不住。HanLP 用的是双数组 Trie(Double Array Trie),把整棵 Trie 压进base和check两个整型数组里,一次查询就是若干次数组下标跳转,几乎没有对象开销。核心词典CoreNatureDictionary.txt加载后就是这个结构。
from pyhanlp import * text = '春分之特别,在一个“均”字:均分了昼夜,均分了寒暑,均分了春天。' # 双数组 Trie 分词器:按词典做匹配 segment = DoubleArrayTrieSegment() print(segment.seg(text)) # AC 自动机分词器:全切分,把文本里所有命中的词都切出来 HanLP.Config.ShowTermNature = False ACSegment = JClass('com.hankcs.hanlp.seg.Other.AhoCorasickDoubleArrayTrieSegment') ac_segment = ACSegment() print(ac_segment.seg(text))逻辑说明:DoubleArrayTrieSegment在 Trie 上做匹配,输出的是切分结果,报告里第一段落的输出形如[春分/null, 之/null, 特别/null, ...],因为关掉了词性所以词性字段是 null。AhoCorasickDoubleArrayTrieSegment是在 Trie 上加了 fail 指针,一次扫描能找出文本中出现的所有模式串,属于全切分思路,不做最长优先。
参数说明:HanLP.Config.ShowTermNature是全局开关,一旦置 False,同一进程内后续所有分词器都不再输出词性。这是共享状态,不是实例属性,实验里连跑多段代码时要留意。
一个必须指出的现象:报告里 AC 分词器那段输出几乎全是单字。这说明它拿到的词典不是你以为的那一本,可能是路径被改过,也可能是默认词典没加载成功。排查方法很简单——先用同一进程跑一句print(HanLP.Config.CoreDictionaryPath)看路径,再切一句「商品和服务」做冒烟测试,能切出「商品」才说明词典是好的。这件事在第 5 章展开。
3.4 停用词替换:同一套最长匹配的另一个用途
停用词过滤看起来是「删词」,本质是「在原文里定位停用词区间并替换」,用的还是最长匹配,只不过匹配对象换成了停用词表。
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() if word: map[word] = word return JClass('com.hankcs.hanlp.collection.trie.DoubleArrayTrie')(map) def replace_stopwords(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, '*', trie))逻辑说明:getLongestSearcher是一次「最长匹配扫描器」,每调一次next()就前进到下一个命中的停用词区间,begin和length给出区间端点;offset记录上一次替换结束的位置,用它把两次命中之间没被命中的原文原样搬过去;循环结束后可能还有一段尾巴没处理,必须补上,否则句尾会被吞掉。
参数说明:replacement传'*'是打码、保留位置信息,传''就是直接删除;JString是 jpype 对 Java String 的包装,直接传 Python 的 str 会类型不匹配报错;encoding='utf-8'不是可选项,Windows 默认用 cp936 打开文件,遇到非 GBK 字符立刻抛UnicodeDecodeError。
输出的形态是「走春分二候京城植物园玉兰蓝天盛开*」,能看出来停用词表里连「在」「的」这类单字都收录了,而「春分二候」这种没被收录的整块被原样保留。注意这不是分词结果,别拿它当分词输出用。
4. 二元语法分词:从三行语料到 Viterbi 解码
前面全是「查字典」,这一章换成「算概率」。实验给的三行语料小到不像话,但它把训练、存储、加载、解码四个环节全串起来了,跑通一遍比看十页公式有用。
4.1 语料格式:为什么必须手工分词再标词性
新建my_cws_corpus.txt,内容如下,每行一句,词与词之间用空格分开:
商品 和 服务 商品 和服 物美价廉 服务 和 货币这种格式叫「已分词语料」,HanLP 的CorpusLoader.convert2SentenceList直接按空格读成 Sentence 和 Word 对象,不做二次切分。所以空格打错,后面全错。
三行语料的用意很明确:制造「商品/和/服务」与「商品/和服/物美价廉」的对照,让二元语法学到「商品 和」与「和 服务」共现的频次高,从而在解码「商品和服务」时压过「商品 和服」这条路径。
语料本身不带词性,但训练器要生成词性文件,所以必须给每个词兜底打一个标签。实验里统一打n,这是一种粗暴但有效的做法:本次任务是分词,词性只要不为空即可,标错不影响切分结果,只影响.tr.txt的内容。
4.2 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把文本读成对象列表;setLabel补词性;compute一次性统计三类频次;saveTxtTo按传入的前缀生成文件,前缀不带后缀。
参数说明:SafeJClass在类名找不到时返回一个安全的包装,避免 import 阶段就抛异常,调试期比JClass友好;model_path只写前缀,后缀由方法自己拼。
| 文件 | 内容 | 用途 |
|---|---|---|
| my_cws_corpus_model.txt | 每行「词 词性 频次」 | 一元模型,加载后充当核心词典 |
| my_cws_corpus_model.tr.txt | 词性之间的转移频次 | 词性标注用,分词链路不直接读 |
| my_cws_corpus_model.ngram.txt | 每行「词A 词B 频次」 | 二元语法模型,解码时算转移概率 |
用这三行语料训出来的词频是「商品 2、和 2」:「商品」在第一行和第二行各出现一次,「和」在第一行和第三行各出现一次。数字对不上,说明语料被改过或者读的时候空格没对齐,先回头查语料。
4.3 加载模型、查词频、跑 ViterbiSegment
训练完要验证模型能不能被读回来,最直接的办法是查词频。
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逻辑说明:CoreDictionaryPath指向一元模型文件,BiGramDictionaryPath指向二元模型文件,两个路径都必须在CoreDictionary第一次被访问之前赋值。
参数说明:model_path传前缀,代码里自己补.txt和.ngram.txt。如果训练时前缀叫my_cws_corpus_model,这里就不能写成my_cws_corpus_model.txt,否则拼出来是my_cws_corpus_model.txt.txt。
真正切分用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() print(segment.seg('商品和服务')) predict() # [商品/n, 和/n, 服务/n]逻辑说明:Viterbi 分词把句子展开成词图,节点是候选词,边的权重来自二元概率,最后找一条总代价最小的路径
本文还有配套的精品资源,点击获取