4、基于统计的NER:隐马尔可夫模型(HMM)原理、HMM在NER中的应用、维特比解码算法
说到统计NER,HMM(隐马尔可夫模型)是绕不开的基石。我当年刚入行时,觉得深度学习才是王道,HMM这种“老古董”没必要深究。直到有一次做医疗病历的实体识别,标注数据少得可怜,深度学习模型根本训不动。后来硬着头皮上了HMM,效果居然还不错。从那以后,我就明白了——理解HMM,是理解序列标注问题的第一块敲门砖。
4.1 隐马尔可夫模型的核心思想
HMM说白了,就是用来描述一个“隐藏的”状态序列,如何生成我们看到的观测序列。在NER场景里,隐藏状态就是每个词的标签(比如B-PER、I-PER、O),观测序列就是句子里的词本身。
你想想看,我们看到的是一句话:“张三去北京了”。但背后真正想找的,是每个词对应的实体标签。HMM就是帮我们从“看得见的词”反推“看不见的标签”。
HMM的两个核心假设:
- 齐次马尔可夫假设:当前时刻的状态只依赖于前一时刻的状态,与更早的状态无关。说白了就是“只看前一步”。
- 观测独立性假设:当前时刻的观测只依赖于当前时刻的状态,与其他状态和观测无关。说白了就是“只看自己”。
这两个假设虽然简化了问题,但实际应用中确实有效。我在项目中遇到过不少质疑:“这么简单的假设能行吗?”嗯,对于大多数NER任务,尤其是标签之间依赖关系较强的场景,HMM的表现其实相当稳健。
4.2 HMM的三要素
要玩转HMM,必须搞清楚它的三个核心参数。我习惯把它们记成“π、A、B”,就像三块积木。
| 参数 | 符号 | 含义 | NER中的例子 |
|---|---|---|---|
| 初始状态概率 | π | 序列第一个状态的概率分布 | 句子第一个词是B-PER的概率 |
| 状态转移概率 | A | 从一个状态转移到另一个状态的概率 | B-PER后面跟I-PER的概率 |
| 发射概率 | B | 在某个状态下生成某个观测的概率 | 在B-PER状态下看到“张”字的概率 |
我的小技巧: 训练HMM时,π、A、B都是从标注数据里统计出来的。说白了就是数数——数每个标签出现的次数、标签转移的次数、标签和词共现的次数。简单粗暴,但有效。
4.3 HMM在NER中的完整流程
把HMM用到NER上,其实就三步。我当年第一次跑通这个流程时,感觉就像打通了任督二脉。
- 训练阶段: 从标注语料中统计出π、A、B三个矩阵。
- 解码阶段: 给定一个句子,用维特比算法找出最可能的标签序列。
- 输出阶段: 把标签序列还原成实体边界和类型。
这里有个坑,我曾经踩过——零概率问题。如果某个词在训练集中从未出现过,发射概率就是0,整个序列的概率就变成0。解决办法很简单:加平滑。我习惯用加1平滑(拉普拉斯平滑),简单又好用。
4.4 维特比解码算法
维特比算法是HMM解码的核心。它的本质是动态规划,说白了就是“记住每一步的最优路径,最后回溯出全局最优”。
为什么会需要动态规划?你想想看,如果暴力枚举所有可能的标签序列,复杂度是O(N×S^T),其中S是标签数,T是序列长度。对于中文NER,标签数至少十几个,句子长度几十个字,这个计算量根本扛不住。维特比算法把复杂度降到了O(N×S^2),这才是工程上可行的方案。
算法的核心思想就两句话:
- 递推: 每一步只保留到达每个状态的最优路径。
- 回溯: 从最后一步的最优状态,倒推出完整路径。
避坑指南: 我曾经在实现维特比时,忘记了对数化概率。直接乘概率会导致浮点数下溢,尤其是长句子。正确的做法是取对数,把乘法变成加法。这样既避免了数值问题,又保持了单调性。
4.5 代码实现:从零搭建HMM-NER
下面是我手写的一个简化版HMM-NER实现。代码不长,但核心逻辑都在里面了。
import numpy as np
class HMM_NER:
def __init__(self, tag2id, word2id):
self.tag2id = tag2id
self.word2id = word2id
self.N = len(tag2id) # 标签数
self.M = len(word2id) # 词表大小
def train(self, sentences, tags):
"""统计π, A, B"""
# 初始化计数
pi = np.zeros(self.N)
A = np.zeros((self.N, self.N))
B = np.zeros((self.N, self.M))
for sent, tag_seq in zip(sentences, tags):
# 统计初始状态
pi[tag_seq[0]] += 1
# 统计转移
for t in range(1, len(tag_seq)):
A[tag_seq[t-1], tag_seq[t]] += 1
# 统计发射
for t in range(len(sent)):
B[tag_seq[t], sent[t]] += 1
# 加1平滑并转概率
pi = (pi + 1) / (len(sentences) + self.N)
A = (A + 1) / (A.sum(axis=1, keepdims=True) + self.N)
B = (B + 1) / (B.sum(axis=1, keepdims=True) + self.M)
# 取对数,防止下溢
self.log_pi = np.log(pi)
self.log_A = np.log(A)
self.log_B = np.log(B)
def viterbi(self, sentence):
"""维特比解码"""
T = len(sentence)
# dp[t][i]:到达第t步、状态为i的最大对数概率
dp = np.zeros((T, self.N))
# back[t][i]:记录前一步的状态
back = np.zeros((T, self.N), dtype=int)
# 初始化
dp[0] = self.log_pi + self.log_B[:, sentence[0]]
# 递推
for t in range(1, T):
for j in range(self.N):
# 从所有前驱状态中找到最优的
scores = dp[t-1] + self.log_A[:, j] + self.log_B[j, sentence[t]]
dp[t, j] = np.max(scores)
back[t, j] = np.argmax(scores)
# 回溯
best_path = [np.argmax(dp[T-1])]
for t in range(T-1, 0, -1):
best_path.insert(0, back[t, best_path[0]])
return best_path
关于代码的几点说明:
- 训练时用了加1平滑,避免零概率。实际项目中可以换成更复杂的平滑策略,比如Good-Turing。
- 所有概率都取了对数,这样长句子的计算也不会溢出。
- 维特比部分用了numpy的向量化操作,比纯Python循环快很多。
4.6 HMM的优缺点
做了这么多年NER,我对HMM的评价是:简单、高效、但天花板低。
| 优点 | 缺点 |
|---|---|
| 训练速度快,小数据也能训 | 观测独立性假设太强,忽略了上下文词的信息 |
| 解码效率高,适合在线服务 | 只能建模相邻标签的依赖,长距离依赖无能为力 |
| 可解释性强,参数含义清晰 | 特征工程能力弱,无法利用词性、词典等外部特征 |
| 实现简单,调试方便 | 对未登录词敏感,OOV问题严重 |
我个人习惯把HMM作为基线模型。每次接到新的NER任务,先用HMM跑一遍,看看数据质量怎么样、标签分布是否合理。如果HMM效果就很好了,说明任务简单;如果HMM效果很差,那就要考虑更复杂的模型了。
4.7 本章知识体系总览
下面这张图把HMM在NER中的完整流程串起来了。从训练到解码,每一步都对应着具体的数学操作。
嗯,到这里HMM在NER中的应用就讲完了。说实话,HMM虽然简单,但它的思想——用概率图建模序列依赖、用动态规划高效解码——是后续所有序列标注模型的基础。理解了HMM,再去看CRF、BiLSTM-CRF,你会发现很多概念都是相通的。