资讯动态

HMM词性标注实战:从原理到代码,解决中文NLP歧义问题

发布时间:2026/10/22 10:02:51 来源:尧图企业网站定制
做文本挖掘项目的时候我遇到过一个特别朴素的问题用户搜“苹果”到底是在找水果还是找手机后来我发现解决这类歧义的第一步往往不是上大模型而是先把词性搞清楚。词性标注POS Tagging干的就是这件事而隐马尔科夫模型HMM是我认为最值得先动手实现一遍的方案之一。这篇博文适合三类人刚接触自然语言处理、想弄懂统计模型内部机制的学生做传统文本分析、暂时不想为一个小功能就引入大规模预训练模型的工程师以及所有对“模型到底怎么从语料里学出规律”感到好奇的人。我会从原理讲到代码再把我实测中踩过的坑全部摊开尽量让你看完就能自己复现一套可用的中文词性标注器。1. 为什么在深度学习时代还要手动实现HMM词性标注1.1 词性标注到底解决什么问题词性标注的任务很直白给句子里的每个词打上一个标签说明它是名词、动词、形容词还是其他类别。比如“打”这个词在“打篮球”里是动词在“一打啤酒”里是量词在“打电话”里又偏向动词用法。词性标对了“苹果”的歧义问题至少能往前走一大步——如果后面跟着“发布会”同现概率会把它往“公司产品”的方向拉如果后面跟着“很好吃”它就更倾向是水果。词性标注在真实项目里的用处比想象中广。关键词提取经常依赖词性过滤只保留名词和动词文本情感分析里“这个产品太差了”和“这个产品差异很大”两句结构完全不同词性序列能帮助模型理解否定词和形容词的搭配结构语音合成和文本转语音前置处理也要先判断词性才能决定多音字的读音。可以说词性标注是很多NLP流水线里承上启下的那一环。1.2 都上大模型了为什么还要自己写一个HMM很多人觉得现在BERT、GPT都能做序列标注效果又好何必再折腾HMM这种老古董。但我的看法不太一样。第一HMM只有几百行代码训练时几十万token的语料几秒钟就能完成统计推理时一个动态规划也就是毫秒级的事。在手机端、离线环境、低配服务器上这种轻量方案依然有不可替代的位置。第二HMM的每个参数都有明确含义转移矩阵是词性接词性的概率发射矩阵是词性下生成某个词的概率你能清楚地解释“为什么模型把这里标成了动词”这种可解释性在调试时候太重要了。第三理解HMM是理解CRF、乃至序列到序列模型的基石状态、观测、转移、发射这些概念在后续更复杂的模型里都会反复出现。我自己第一次动手写HMM时最大的收获不是写出一个标注器而是真正懂得了“统计”二字在自然语言里是怎么落地的。语言现象千变万化但一旦把问题形式化成马尔可夫链上的解码问题庞大复杂的语法现象就被压缩成了一张张可以计算的概率表这种感觉非常奇妙。2. 先弄懂HMM在词性标注里到底在算什么2.1 从“隔帘看戏”理解HMM的结构我用一个生活化的类比来解释HMM。想象你隔着窗帘听隔壁房间的人说话只能听到声音的抑扬顿挫但看不到说话人的真实身份。你想根据声音判断现在到底是谁在说话这就是一个典型的隐马尔可夫问题——真实身份是隐藏状态声音特征是观测结果。在词性标注里这个关系完全对应得上。句子的词性序列名词、动词、形容词……就是隐藏状态我们看不到“真实的语法结构”而句子里的每个词就是观测序列我们能看到的只有这些词。HMM要做的就是根据看到的词反推最可能的词性序列。这个结构成立需要两个假设。第一个叫马尔可夫性假设当前词性的出现只依赖上一个词性不依赖更早的历史。第二个叫观测独立性假设当前词只依赖当前词性和前后词没有直接关系。这两个假设在严格意义上当然不成立比如中文里“的”前面几乎总是修饰语这种搭配涉及更长距离的依赖但实践表明把问题简化成这一步已经能拿到相当不错的效果而且极大降低了计算复杂度。2.2 词性标注用的是HMM的哪个问题HMM教科书上通常讲三个问题概率计算、解码、学习。词性标注用到的主要是解码问题——给定观测序列句子词序列找到使概率最大的隐藏状态序列词性序列。为了做解码我们还需要先解决学习问题从带标注的语料里估计出模型的参数。这里的参数一共三块初始概率分布、状态转移概率矩阵、发射概率矩阵。初始概率告诉你一个句子开头更可能是名词还是动词转移概率告诉你“名词后面通常跟动词”这类规律发射概率告诉你“动词位置更可能生成‘打’还是‘篮球’”。训练和解码的关系有点像学一门手艺。训练阶段你抱着一堆已经标好词性的语料统计各种词性搭配的出现次数解码阶段你用统计出来的概率表给一句从未见过的新句子找到最合理的词性搭配。整个过程不需要任何人工规则规律全部从数据里长出来。3. 训练语料怎么处理状态序列和观测序列的对齐3.1 一句带标注的话长什么样常见的中文标注语料是一行一个词词和词性标签之间用分隔符隔开句子之间用空行隔开。我用的实验语料是从某公开开源语料里抽取的中文文本规模大概一百万token格式大致是这样我 r 爱 v 自然语言 n 处理 v 。 w这里的标签并不神秘就是用一套词性标记集给每个词打的类型标签。中文常用标记集会把词分成二十多个类别比如名词n、动词v、形容词a、代词r、数词m、量词q、副词d、介词p、连词c、助词u、标点w等等。实际项目里可以直接用这套相对粗粒度的方案也可以根据业务需要细分成几十个甚至上百个类别标签粒度直接影响模型能区分的语法现象粗细程度。语料的质量直接决定标注效果的上限。如果语料本身标注错误多、标记集不统一统计出来的概率表就会“带病”后续解码自然也会出错。所以第一步是清洗语料去重、处理混入的乱码、统一标点、检查格式异常。尤其是空行和换行符处理不干净会导致句子切分错误状态序列全乱。3.2 从句子到序列建立词表和标签表训练前要做两件事收集所有出现过的词构成词表收集所有出现过的词性标签构成标签表。词表要保留每个词到索引编号的映射标签表类似。这一步最容易被忽略的细节是测试阶段遇到训练里从未见过的词未登录词OOV时怎么办。词表里没有的词发射概率为零会导致解码直接失败。所以训练阶段就要预留一个专门的占位符号比如用“ ”表示所有低频词把出现次数低于某个阈值的词全部替换成这个占位符再重新统计发射概率。这样至少给未知词留了一条生路后面实践的坑还会细说。标签表则更简单遍历一遍语料统计所有标签即可。不过要注意区分中英文标点英文语料里句号是.中文是。如果混在一起需要预先归一化。标签数量影响转移矩阵的大小标签越少矩阵越小数据越稠密统计越稳定。3.3 训练集、验证集和测试集怎么切很多人写HMM时忽略数据集划分直接用全部语料训练再评估这是不太严谨的。我建议按9比1的比例切分训练集和测试集测试集绝不能参与统计。也就是说测试集里的句子在训练阶段是完全没见过的这样才能真实反映模型的泛化能力。切分之后训练集用来统计三大概率矩阵测试集用来计算准确率。评估指标可以简单用词级准确率预测的词性和标注的标准词性一致的词占比多少。我实测下来在一个规范标注且领域一致性较好的中文语料上HMM可以达到90%以上的词级准确率具体数字会因为语料规模、标记集粒度、句子长短分布而波动。这个水平虽然不如深度学习方案动辄97%以上但作为轻量基线已经非常有说服力了。4. 三大概率矩阵的统计与平滑一个都不能少4.1 初始概率句子开头是什么词性初始概率就是每个词性出现在句子开头位置的频率。统计方法很简单遍历语料找到每个句子的第一个词记录它的词性统计所有句子第一个词的词性分布然后做归一化。观察这个分布你会发现中文很有意思。句子开头最常见的是名词、代词、副词这类词性动词直接开头的句子反而没有想象中那么多这和中文的话题优先语序有关。初始概率虽然只影响每个句子的第一个词但它在解码里起的作用是“定调”第一个状态选错了后面通过转移矩阵传导可能一整条路径都歪掉。所以训练时对句子边界的识别要格外小心空行分裂得不对初始概率就统计错了。4.2 转移概率词性接龙的统计规律转移概率描述的是从词性A到词性B的接续概率。统计时逐句扫描词性序列记录每一项从第i个词性到第i1个词性的转移次数汇总成一个大矩阵再对每一行做归一化。例如“名词→动词”的转移概率通常很高因为中文句子里“主语谓语”结构实在太常见了而“助词→量词”这种转移就很少我不会说“的了只”。这些统计规律不需要你懂任何语法规则只要语料量够大概率表会自动把这些“语法直觉”编码进去这是HMM最吸引人的地方。行归一化时要小心除零错误。某个词性如果只出现过几次它后面接的转移肯定会非常稀疏。实现时不妨统计完直接加一平滑可以有效避免零概率行这个我们下面单独说。4.3 发射概率一个词性下能看到哪些词发射概率是词性给定条件下生成某个词的概率。统计方式同样直接对于语料里的每个词性标签统计它下面出现的所有词及次数然后归一化。这个矩阵通常比较大因为词表可能有几万个词标签有二十多个矩阵规模就是几万乘二十几。好在实现时没必要存成完整密集矩阵用一个词典存稀疏结构就够(tag - word - count)再转成归一化概率即可。观测独立性假设在这里体现得最明显模型只关心当前词和当前词性之间的一对一关系不关心这个词左边或右边是什么。这也引出了HMM的一个已知短板它无法建模“打”这个词在不同上下文下的概率变化只能靠整体统计平均。不过只要语料足够大整体统计还是能把典型的用法学得比较像样。4.4 平滑处理概率为0不是一件好事统计完的概率矩阵里会出现大量零值原因很简单语料规模有限总有一些词性组合从来没见过。比如“代词→代词”这种转移很少见但并非绝对不可能出现。如果矩阵里直接存了概率0维特比解码一旦走到这条路径总乘积直接变零整条路径就废了。最常用的办法是加一平滑也叫拉普拉斯平滑。统计时给每个格子基础计数加1再重新归一化。这一做法在实现上非常省事统计完成后对每个概率元素加上一个很小的平滑因子再归一化即可。平滑因子的大小要自己调。平滑太大概率分布变得均匀模型学到的规律被稀释平滑太小稀有事件仍然可能卡死解码。我的经验是发射矩阵和转移矩阵用不同的平滑因子转移矩阵可以适当大一点因为词性接续的组合本来就不多多给一些余量可以明显降低解码失败率。另外UNK占位符的发射概率建议单独设置一个偏小的值不要完全交给统计否则未知词会导致整句概率塌缩。5. 维特比解码用动态规划找最优词性路径5.1 动态规划递推状态空间的压缩有了三大概率矩阵解码就是找一个最可能的词性序列。如果暴力枚举所有可能的词性组合词性有N种句子长度是L组合数就是N的L次方指数爆炸完全不可行。维特比算法的本质就是动态规划每一步只需要保留到当前词为止、以每个词性为结尾的最佳部分路径再往下推进时只需要考虑这些路径。用递推来看就是第t步到达词性j的最佳路径概率等于第t-1步所有词性i的最佳路径概率乘以转移概率、乘以发射概率取最大值。这一步同时要记录这个最优值是从哪个前驱词性i来的保存成一个回溯索引。等把整个句子扫完再从最后一个词的最佳词性开始回溯就能完整还原整条路径。这个递推关系里有个关键思想一条全局最优路径的任意前缀也必须是到达该前缀终点的局部最优路径。如果存在另一条更优的前缀用它替换当前前缀整条路径概率会更大矛盾。这就是动态规划能压缩计算量的理论根基。5.2 Python实现示例我不贴完整几百行工程代码了只把维特比核心函数写出来供参考。这里假设已经训练好了初始概率向量pi、转移矩阵A、发射矩阵B都是numpy数组或字典结构。import numpy as np def viterbi(obs, pi, A, B): L len(obs) # 句子长度 N len(pi) # 标签数量 delta np.zeros((L, N)) # 记录每一步的最优概率 psi np.zeros((L, N), dtypeint) # 记录前驱状态 # 初始化第一个词 for j in range(N): delta[0, j] pi[j] * B[j].get(obs[0], 1e-6) psi[0, j] -1 # 递推 for t in range(1, L): for j in range(N): prob delta[t-1, :] * A[:, j] * B[j].get(obs[t], 1e-6) best np.argmax(prob) delta[t, j] prob[best] psi[t, j] best # 回溯 best_last np.argmax(delta[L-1, :]) tags [best_last] for t in range(L-1, 0, -1): tags.append(psi[t, tags[-1]]) tags.reverse() return tags这个实现里有个实用细节B[j].get(obs[t], 1e-6)直接把未知词的发射概率设成一个很小的固定值可以避免索引越界也能让未登录词仍然有一条可走的路径。正式工程里这个兜底值应该从统计中估计不能拍脑袋给个固定数但调试阶段这样写最直观。5.3 别忽视的工程细节在log空间计算上面的代码里概率直接相乘看着没什么问题但实际语料中概率值会越乘越小几十个词的句子乘下来就超出机器浮点数精度范围轻则结果不准重则下溢成零。给出的解法是给概率取对数把连乘变成求和log(pi) sum(log(A)) sum(log(B))。这样数字范围温和很多性能还更快因为求和对CPU更友好。改用log空间后递推里的“最大值”判断不变但需要把所有概率提前转成对数。我建议在训练阶段就直接把三大矩阵的每个元素取好对数存下来解码时不需要反复做math.log。初始化时把第一列的发射概率也转成对数形式整个流程就会变得非常稳。我当时第一次实现时没加log跑了几个长句子就开始出现NaN排查了很久才发现是浮点数下溢。这个问题几乎每个人都会遇到属于HMM实操必踩的经典坑提前写进代码能省几个小时。6. 实测复盘我跑了一百万token中文语料踩过的坑6.1 坑1未登录词直接让整句标注崩掉我第一次跑测试集时准确率只有七成多最关键的问题就出在未登录词。词典里没有的词在发射概率表中找不到对应项概率为零维特比整条路径变成零结果退化成了完全随机的标签。后来我做了两处修改。第一训练时把所有出现次数小于3的词全部替换为UNK让未知词占位符有充分样本估计概率第二测试时真正没见过的新词也映射到这个占位符号并用一个合理的小概率兜底。改完之后准确率明显回升。这件事让我明白了一个道理对HMM来说“给未知词一条活路”比“死记硬背见过的词”更重要因为真实文本里专有名词和术语永远层出不穷。6.2 坑2标点和数字的标签归一对齐中文语料里标点标注方式很不统一逗号可能是英文半角,也可能是中文全角而数字和日期经常被贴不同的词性标签有的标注为数词有的标注为时间词有的干脆标成其他。这种不一致导致发射矩阵里同一类符号被拆到多个标签下统计概率被稀释。解决方式有两种。一种是在训练之前先做特征归一化把所有逗号都统一为一种形式把所有连续数字替换成占位符再用特殊标记标注。另一种是做标签映射测试阶段也走同样的归一化保证统计分布的一致性。我强烈建议先在语料预处理阶段解决而不是让模型去做这种无谓的分类。6.3 坑3低频标签集体失踪动词形容词混在一起HMM是全局概率最大化的所以它天生偏好高频路径。低频词性搭配比如某些罕见的语法结构即便出现的句子本身比较明显也会被高频邻居带偏。我在检查结果时发现很多动词被标成了名词形容词被标成了名词原因是这些位置上下文不够强而名词→名词的转移概率和名词作为发射词的概率综合起来更“划算”。针对这个问题单纯调平滑系数作用有限。更实用的做法是给标签集做粗粒度合并把业务上不关心区分的细节类别合并成一个降低标签稀疏度或者反过来如果业务真的需要区分那就需要补充对应标签的语料增大样本量。想靠算法技巧完全弥补数据不足是不太可能的。6.4 坑4训练语料风格漂移导致标签乱跳我用了一份以新闻类文本为主的语料训练模型然后拿去标注一批口语化对话文本结果准确率明显下降。原因很好理解新闻语料里时间词、机构名、动宾结构密集出现口语语料里语气词、省略结构、重复现象又多又碎两种文本的词性分布差异很大。这说明HMM对训练语料的领域一致性非常敏感。想建一个通用的标注器就应该混入不同领域的语料保证多样性想服务单一业务场景那最好用贴业务风格的数据做训练。我的实践结论是先确定用途再决定语料构成顺序不能反。6.5 实测效果与调参思路经过以上一轮轮修正我在保留测试集上的词级准确率稳定在90%附近。调参过程主要围绕平滑系数和UNK阈值两个旋钮转移矩阵平滑系数我试过0.1、0.01、0.001最后觉得0.01左右比较稳UNK阈值试过1、3、5数值太大低频词全被压成未知会造成信息丢失太多太小则未知词样本不足3相对折中。对比一下用CRF跑同样语料的基线确实能再涨三四个百分点。但CRF需要特征工程特征设计是否合理极大影响效果而且训练耗时也更长。HMM的优势是代码短、训练快、逻辑透明适合作为第一版标注系统快速上线后续若有更高精度需求再平滑升级到CRF或者序列标注模型。先有一个能跑通、能解释的基线再逐步替换这是我觉得更踏实的工程路线。最后再分享一个小技巧写代码的时候把训练结果持久化保存成中间文件词表、标签表、三个概率矩阵分开存。这样测试阶段不用重复训练改解码逻辑时也不用重新跑统计迭代速度快很多。我做实验时经常要反复对比不同平滑系数带来的效果差异这套缓存设计帮我省了大量时间。如果你也打算动手写一个自己的HMM词性标注器我建议一开始就把这个习惯养成后面一定会感谢自己。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑