简介:这份资源是南京邮电大学自然语言处理课程实验一的完整报告文档,面向正在修读NLP基础课程的高校学生及需要巩固中文分词技术的自学者。内容围绕词典分词与二元语法分词两大核心任务展开,涵盖HanLP工具的分词指令、词性标注、文件输入输出、句法分析及Python代码实现,并对比前向、后向、双向最长匹配算法的差异,附有教材第27页例程的代码复现与核心词典路径说明。资源包为1个doc文件,大小约232KB,结构紧凑,便于直接参考实验报告格式与结果记录。目前已有421人学习下载,适合需要完成同类实验、理解统计语言模型分词原理或查阅HanLP实操示例的读者,可帮助快速掌握分词与句法分析的基本流程,为后续NLP学习打下基础。
1. 南邮自然语言处理实验一:从词典分词到二元语法,一次把中文分词讲透
如果你正在上南邮的自然语言处理课,实验一大概率会让你用 Python 实现中文分词,而且绕不开两个关键词:词典分词和二元语法分词。这不是随便选的题目——中文没有天然空格,分词是几乎所有 NLP 任务的第一步,而这两个方法恰好代表了两种截然不同的思路:一个是基于规则查表,一个是基于统计概率。很多同学第一次做的时候,直接把句子按最大匹配切完就交差了,结果发现“研究生命起源”被切成“研究/生命/起源”还算对,但“南京市长江大桥”就翻车了。这个实验真正要你搞明白的是:词典分词为什么快但死板,二元语法为什么灵活但依赖语料,以及两者怎么结合才能在实际场景里跑通。适合正在做课程设计、想搞懂 nlp 自然语言处理入门实操的人,也适合已经工作但没系统写过分词模块的工程师补基础。
2. 词典分词:最大匹配、最小匹配和那棵没建完的 Trie 树
2.1 正向最大匹配到底在匹配什么
词典分词的核心逻辑非常直白:给你一个词典,再给你一个句子,从左到右尽量切出最长的词。正向最大匹配(FMM)的做法是,设定一个最大词长,比如 5,然后从句子开头取 5 个字去词典里查,查不到就减到 4 个,直到查到或者只剩 1 个字为止。切掉这个词之后,剩下的部分重复这个过程。
我一般会先写一个最朴素的版本,不搞任何优化,先把逻辑跑通:
# 朴素正向最大匹配 def fmm(text, word_dict, max_len=5): result = [] i = 0 while i < len(text): # 从最长可能词长开始尝试 for length in range(min(max_len, len(text) - i), 0, -1): word = text[i:i+length] if word in word_dict: result.append(word) i += length break else: # 词典里一个都没匹配上,单字成词 result.append(text[i]) i += 1 return result这段代码里max_len是个关键参数。设得太小,长词切不出来;设得太大,每次循环都要多查几次,性能下降。常见做法是取词典里最长词的长度,但实际语料里超过 7 个字的词很少,所以设 5 到 7 都合理。word_dict用 Python 的set就行,查找是 O(1)。注意那个for...else结构,当 for 循环正常结束(没 break)时走 else,表示当前字符没法组成任何词,只能单字切分。
跑一下“南京市长江大桥”,如果词典里有“南京市”“长江大桥”“南京”“市长”这些词,FMM 会先匹配到“南京市”,然后剩下“长江大桥”,再匹配到“长江大桥”,结果就是“南京市/长江大桥”。但如果你词典里没有“长江大桥”只有“长江”和“大桥”,那结果就变成“南京市/长江/大桥”。这就是词典分词的第一个玄学:结果完全取决于词典里有什么。
2.2 双向匹配和评价指标怎么算
正向最大匹配有个对称的兄弟叫逆向最大匹配(BMM),就是从右往左扫。中文里偏正结构多,逆向匹配往往更准。实际做实验的时候,老师一般会让你把两种都实现,然后比较准确率、召回率和 F1。
评价指标的计算需要标准答案,也就是人工切分好的语料。假设标准切分是[“南京市”, “长江大桥”],你的 FMM 输出是[“南京市”, “长江”, “大桥”],那么:
- 正确切分的词数量:1(南京市)
- 你的输出词总数:3
- 标准答案词总数:2
- 准确率 = 1/3 ≈ 0.333
- 召回率 = 1/2 = 0.5
- F1 = 2 * 0.333 * 0.5 / (0.333 + 0.5) ≈ 0.4
代码实现就是遍历两个列表,统计交集大小。注意这里按词本身匹配,不按位置,因为分词结果的位置本来就可能对不上。我一般会写一个evaluate(gold, pred)函数,返回这三个值,方便后面调参对比。
双向匹配则是同时跑 FMM 和 BMM,然后选词数更少的那一个作为最终结果。词数少通常意味着切出来的词更长,更符合中文习惯。但这不是绝对规则,有些句子正向对,有些逆向对,所以还有一种策略是:如果两者词数相同,优先选逆向,因为逆向匹配在多数中文语料上表现略好。
2.3 用 Trie 树把词典查词从 O(n) 降到 O(1)
上面那个朴素版本,每次匹配都要拿子串去 set 里查,虽然 set 查找是 O(1),但子串切片本身是 O(k),k 是词长。当词典很大、句子很长时,整体复杂度是 O(n * max_len * k)。更优雅的做法是把词典建成 Trie 树,也叫前缀树。
Trie 树的每个节点是一个字符,从根到某个节点的路径构成一个前缀,如果某个节点被标记为词尾,就表示这是一个完整的词。匹配的时候从句子当前位置出发,沿着 Trie 往下走,能走多远就走多远,记录最后一个词尾节点的位置,那就是最长匹配。
# Trie 树节点 class TrieNode: def __init__(self): self.children = {} self.is_word = False def build_trie(word_dict): root = TrieNode() for word in word_dict: node = root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_word = True return root def fmm_trie(text, root, max_len=7): result = [] i = 0 while i < len(text): node = root j = i last_match = -1 # 沿着 Trie 走,记录最远匹配位置 while j < len(text) and j - i < max_len: if text[j] not in node.children: break node = node.children[text[j]] if node.is_word: last_match = j j += 1 if last_match != -1: result.append(text[i:last_match+1]) i = last_match + 1 else: result.append(text[i]) i += 1 return resultTrie 树的好处是查词和词长无关,只和实际匹配到的前缀长度有关。max_len在这里主要是防止死循环和限制最大词长,设 7 足够覆盖绝大多数中文词。构建 Trie 的时间是 O(词典总字符数),之后每次查询接近 O(1)。这个结构在后面做二元语法的时候也会用到,因为你需要快速判断一个词是否在词典里。
提示:Trie 树用 Python 字典实现最方便,但内存占用比 set 大。如果词典有几十万词,可以考虑用双数组 Trie,不过实验一一般用不上。
3. 二元语法分词:用概率说话,但语料从哪来
3.1 从“词与词独立”到“词与词有关”
词典分词假设每个词的出现是独立的,只要词典里有就能切。但语言不是这样的,“今天天气不错”和“今天天气不好”,切分方式应该一样,但“研究生命起源”和“研究生命科学”,切分方式可能不同。二元语法(Bigram)的核心思想是:一个词的出现概率依赖于前一个词。我们要找的是使整个句子的联合概率最大的切分方案。
具体来说,对于句子 ( S = w_1 w_2 … w_n ),二元语法模型计算:
[ P(S) = P(w_1) \cdot P(w_2|w_1) \cdot P(w_3|w_2) \cdots P(w_n|w_{n-1}) ]
实际计算时用对数概率防止下溢,把乘法变成加法。然后对所有可能的切分方案,选概率最大的那个。这就是一个动态规划问题。
3.2 用动态规划找最大概率路径
假设句子长度为 n,我们定义dp[i]为从第 i 个字符到末尾的最大对数概率,同时记录切分点。从后往前推:
import math def bigram_segment(text, word_prob, bigram_prob, max_len=5): n = len(text) # dp[i] 表示从 i 到末尾的最大对数概率 dp = [-float('inf')] * (n + 1) dp[n] = 0 # path[i] 记录 i 处的最佳切分终点 path = [-1] * (n + 1) for i in range(n - 1, -1, -1): for j in range(i + 1, min(i + max_len, n) + 1): word = text[i:j] if word not in word_prob: continue # 当前词的对数概率 log_p = math.log(word_prob[word]) # 如果后面还有词,加上二元概率 if j < n and path[j] != -1: next_word = text[j:path[j]] if (word, next_word) in bigram_prob: log_p += math.log(bigram_prob[(word, next_word)]) else: # 未见过的二元组,加一个很小的平滑值 log_p += math.log(1e-8) total = log_p + dp[j] if total > dp[i]: dp[i] = total path[i] = j # 回溯切分结果 result = [] i = 0 while i < n: j = path[i] if j == -1: j = i + 1 result.append(text[i:j]) i = j return result这段代码的关键在于word_prob和bigram_prob这两个概率表。word_prob是每个词的一元概率,bigram_prob是词对的条件概率。max_len限制每次尝试的词长,避免 O(n^2) 的复杂度。dp数组从后往前填,path记录每个位置的最佳切分终点。最后从 0 开始回溯,得到完整切分。
注意那个平滑处理:如果某个二元组在训练语料里没出现过,直接给概率 0 会导致整个路径概率变成负无穷,所以给一个极小的值 1e-8。实际做实验时,老师可能会让你用 Add-1 平滑或者更复杂的 Kneser-Ney 平滑,但实验一一般用最简单的加一平滑就够了。
3.3 训练语料和词典从哪来
二元语法需要统计概率,所以你得有训练语料。南邮实验一通常会提供一个小的标注语料,比如几百句已经分好词的中文句子。如果没有提供,可以用人民日报语料或者结巴分词自带的词典作为替代。我一般会先把语料读进来,统计词频和二元组频次:
from collections import Counter def train_bigram(corpus): word_freq = Counter() bigram_freq = Counter() total_words = 0 for sentence in corpus: words = sentence.strip().split() words = ['<s>'] + words + ['</s>'] # 加边界标记 for i, word in enumerate(words): word_freq[word] += 1 total_words += 1 if i > 0: bigram_freq[(words[i-1], word)] += 1 # 计算概率 word_prob = {} for word, freq in word_freq.items(): word_prob[word] = freq / total_words bigram_prob = {} for (w1, w2), freq in bigram_freq.items(): bigram_prob[(w1, w2)] = freq / word_freq[w1] return word_prob, bigram_prob这里加了<s>和</s>作为句子边界,这样每个句子第一个词也有前一个词可以依赖。word_prob是一元概率,bigram_prob是条件概率。注意bigram_prob的分母是word_freq[w1],不是总词数,因为条件概率 ( P(w_2|w_1) = \frac{count(w_1, w_2)}{count(w_1)} )。
训练语料的大小直接决定分词效果。几百句的语料只能覆盖很有限的词和二元组,遇到没见过的词就只能靠一元概率硬撑。所以实际做实验时,二元语法的效果往往不如词典分词稳定,尤其是在小语料上。但它的优势在于能处理歧义和未登录词,只要概率表足够大。
注意:训练语料里的词必须和测试时的词典一致,否则会出现大量未登录词,导致分词结果全是单字。
4. 避坑与排查:分词实验里最容易翻车的五个地方
4.1 现象:FMM 切出来的结果全是单字
原因:词典没有正确加载,或者词典里的词没有去掉换行符和空格。Python 读文件时每行末尾有\n,如果不 strip,词典里存的词就是“南京市\n”,和句子里的“南京市”匹配不上。
解决:读词典时统一line.strip(),并且过滤空行。另外检查词典编码,中文词典一般是 UTF-8,用open(path, encoding='utf-8')打开。
4.2 现象:二元语法分词结果比词典分词还差
原因:训练语料太小,概率表稀疏,大量二元组概率为 0,平滑值又设得太小,导致模型倾向于切出很多短词来规避未知二元组。
解决:增大训练语料,或者把平滑值调大一点,比如 1e-6 到 1e-4 之间。另一个办法是混合模型:先用词典分词得到候选切分,再用二元语法在候选里选最优。这样既保证了词典覆盖,又利用了统计信息。
4.3 现象:评价指标算出来是 0
原因:标准答案和预测结果的词顺序不一致,或者标准答案里用了不同的分隔符。比如标准答案用空格分隔,你的输出用列表,直接比较列表元素时因为位置对不上导致交集为空。
解决:统一把两者都转成词列表,然后用集合交集计算正确数。注意不要用zip按位置比较,因为分词结果的长度可能不同。
4.4 现象:Trie 树构建后查询报 KeyError
原因:Trie 节点的children字典在查询时直接用了node.children[ch],但某个字符不在子节点里。
解决:查询前先判断if ch in node.children,或者用node.children.get(ch)返回 None 再处理。构建的时候用if ch not in node.children来创建新节点,查询的时候用in来判断是否存在。
4.5 现象:动态规划回溯时死循环
原因:path[i]记录的是切分终点,但如果某个位置没有找到任何词,path[i]保持 -1,回溯时i没有前进。
解决:在回溯循环里加一个判断,如果path[i] == -1,就强制i += 1,并且把当前字符单字成词。另外在 DP 填充时,如果某个位置所有词长都试过了还是没找到词,也要保证dp[i]有一个有效值,不能让它是负无穷。
5. 进阶技巧:把词典分词和二元语法叠在一起用
5.1 混合分词:词典兜底,统计选优
单独用词典分词,遇到歧义就抓瞎;单独用二元语法,遇到未登录词就崩。实际工程里最常见的做法是混合:先用词典分词生成所有可能的切分路径,再用二元语法给每条路径打分,选分数最高的。这样既保证了词典里有的词一定能被切出来,又能在多个合法切分里选最符合语言习惯的那个。
实现上,可以用一个递归函数枚举所有切分,但句子长了会爆炸。更实际的做法是在 DP 里同时考虑词典匹配和二元概率:dp[i]还是最大对数概率,但转移时只考虑那些在词典里出现过的词。如果某个位置词典里一个词都匹配不上,就退化成单字,并且给一个惩罚分。
def hybrid_segment(text, word_dict, word_prob, bigram_prob, max_len=5): n = len(text) dp = [-float('inf')] * (n + 1) dp[n] = 0 path = [-1] * (n + 1) for i in range(n - 1, -1, -1): for j in range(i + 1, min(i + max_len, n) + 1): word = text[i:j] if word not in word_dict: continue # 词典词给一个基础分,避免未登录词被过度惩罚 base_score = 0.5 if word in word_prob: base_score = math.log(word_prob[word]) else: base_score = math.log(1e-6) if j < n and path[j] != -1: next_word = text[j:path[j]] if (word, next_word) in bigram_prob: base_score += math.log(bigram_prob[(word, next_word)]) else: base_score += math.log(1e-8) total = base_score + dp[j] if total > dp[i]: dp[i] = total path[i] = j result = [] i = 0 while i < n: j = path[i] if path[i] != -1 else i + 1 result.append(text[i:j]) i = j return result这个版本里,word_dict决定了哪些词是合法的,word_prob和bigram_prob决定了哪个合法切分更好。如果词典里没有某个词,但二元语法强烈暗示它应该是一个词,混合模型还是切不出来——这是词典分词的硬边界。要突破这个边界,就得引入未登录词识别,比如基于字符的序列标注,但那已经超出实验一的范围了。
5.2 用混淆矩阵看分词错在哪
评价分词不能只看 F1,还得看错误类型。我一般会统计三种错误:切多了(一个词被切成多个)、切少了(多个词被合成一个)、切错了(边界位置不对)。用一个简单的混淆矩阵就能看出来:
| 错误类型 | 例子(标准 → 预测) | 常见原因 |
|---|---|---|
| 切多 | 长江大桥 → 长江/大桥 | 词典缺长词 |
| 切少 | 南京市 → 南京市长 | 最大匹配过长 |
| 边界错 | 研究/生命 → 研究生/命 | 歧义未消解 |
把测试集上所有错误按这三类统计,就能知道你的分词器短板在哪。如果切多占多数,就去补词典;如果切少占多数,就调小max_len或者换逆向匹配;如果边界错多,就上二元语法或者混合模型。
5.3 一个我踩过的坑:别在测试集上调参数
做实验一的时候,我为了刷高 F1,在测试集上反复调max_len和平滑值,结果答辩时老师换了一个新句子,效果直接崩了。后来才明白,参数应该在开发集上调,测试集只能用一次。如果实验没给开发集,就自己从训练语料里切 10% 出来当开发集。这个习惯后来在工作里也救了我很多次——线上模型的效果评估,永远不能用调参用的那批数据。
希望帮到你。
本文还有配套的精品资源,点击获取