ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

南邮NLP实验一:词典分词与二元语法中文分词实战

南邮NLP实验一:词典分词与二元语法中文分词实战 简介这份资源是南京邮电大学自然语言处理课程实验一的完整报告文档面向正在修读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_len5): result [] i 0 while i len(text): # 从最长可能词长开始尝试 for length in range(min(max_len, len(text) - i), 0, -1): word text[i:ilength] 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.5F1 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_len7): 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_match1]) 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_len5): 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, encodingutf-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_len5): 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% 出来当开发集。这个习惯后来在工作里也救了我很多次——线上模型的效果评估永远不能用调参用的那批数据。希望帮到你。本文还有配套的精品资源点击获取
返回列表