做文本挖掘项目的时候,我遇到过一个特别朴素的问题:用户搜“苹果”,到底是在找水果还是找手机?后来我发现,解决这类歧义的第一步往往不是上大模型,而是先把词性搞清楚。词性标注(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个词性到第i+1个词性的转移次数,汇总成一个大矩阵,再对每一行做归一化。
例如“名词→动词”的转移概率通常很高,因为中文句子里“主语+谓语”结构实在太常见了;而“助词→量词”这种转移就很少(我不会说“的了只”)。这些统计规律不需要你懂任何语法规则,只要语料量够大,概率表会自动把这些“语法直觉”编码进去,这是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), dtype=int) # 记录前驱状态 # 初始化:第一个词 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词性标注器,我建议一开始就把这个习惯养成,后面一定会感谢自己。