news 2026/10/13 4:24:00

隐马尔可夫模型HMM:核心假设、三大经典算法与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
隐马尔可夫模型HMM:核心假设、三大经典算法与工程实践

HMM(隐马尔可夫模型)是序列建模里最经典、也最容易被学成“背公式”的一门课。我做序列标注和时序分析这些年,遇到不少人能熟练写出前向、维特比、Baum-Welch的递推式,可真要解释“为什么观测独立性假设造就了计算可行性”“维特比为什么不能贪心”这类问题,就卡壳了。这篇笔记我按自己的理解把HMM重新过了一遍,覆盖模型假设、三大经典问题、算法推导直觉和工程实现细节,适合正在学机器学习/NLP、准备面试突击,或者工作中要用序列建模但基础不牢的朋友。涉及代码实现的建议属于通用工程实践,不限定任何特定框架,照思路迁移就行。

1. 一个模型的两个假设:HMM为什么能把复杂序列变简单

1.1 从马尔可夫链到隐马尔可夫

要理解HMM,得先看它爷爷辈的马尔可夫链。马尔可夫链假设状态是直接可见的,你今天知道天晴,明天知道下雨,后天知道阴天,那么明天的天气只依赖今天的天气,和昨天、前天一概无关。这个“当前状态只依赖前一个状态”的约束,就叫马尔可夫性质。

现实里更常见的情况是:状态看不见,你只能看到它产生的表象。比如你想知道接下来几天的天气(隐藏状态),但手头只有每天海藻的干湿程度(观测值);你在做语音识别时,真正关心的是说话人读的哪个音素(隐藏状态),能拿到的只是音频信号的特征(观测值)。这种“状态藏在幕后,只露出观测证据”的模型,就是隐马尔可夫模型。

为什么非要绕这么一圈?因为很多问题里,状态恰恰是你最终想要的答案,只是它不能直接观测。HMM的价值就在于:给我一堆可见的观测序列,我能通过模型推断出背后那条不可见的状态序列。

1.2 两个假设到底约束了什么

HMM能work,靠的是两条核心假设:

第一,齐次马尔可夫假设:t时刻的状态I_t只依赖t-1时刻的状态I_{t-1},不依赖更早的状态,也不依赖任何观测。写成概率是:

P(I_t | I_1, I_2, ..., I_{t-1}, O_1, ..., O_{t-1}) = P(I_t | I_{t-1})

第二,观测独立性假设:t时刻的观测O_t只依赖t时刻的状态I_t,不依赖其它时刻的状态和观测。写作:

P(O_t | I_1, ..., I_T, O_1, ..., O_{t-1}, O_{t+1}, ..., O_T) = P(O_t | I_t)

这两句话看着像废话,但它们才是HMM能算的根基。你想想如果没有这两个假设,一个长度为T的序列,任意时刻的状态都可能受全部历史影响,状态之间互相牵扯,参数数量会直接爆炸。有了这两个假设,整个模型的依赖关系被压缩成一条链:状态之间是链式依赖,每个状态单独决定一个观测。所有条件概率连乘就能表达完整序列的联合分布。

1.3 “化整为零”的意义

两个假设把联合概率变成了连乘积:

P(I_1,...,I_T, O_1,...,O_T) = π(I_1) × b(I_1→O_1) × a(I_1→I_2) × b(I_2→O_2) × ...

每个因子都只涉及局部关系,后面所有算法——前向、后向、维特比、Baum-Welch——都建立在这个可分解的结构上。我个人的理解是:HMM学到的不是一个“全局规则”,而是一堆“局部规则”,靠链式结构把局部串成整体。

有一个经典类比:你在远处看别人下棋,没法直接看到棋盘上的真实局面(隐藏状态),只能观察到对方每次落子的动作(观测)。要想猜测棋盘局面,你不需要记住所有历史步骤——只用知道上一步大概是什么局面,再加上“从那个局面走出这一步动作”的规律就够了。这就是两个假设在现实中的近似逻辑。

需要提醒一点:这两个假设在真实数据里几乎从来都不严格成立,天气之间的相关性、语音音素之间的协同发音,都会打破独立假设。但HMM仍然好用,因为它是“近似正确 + 完全可算”的折中。工程上真正重要的是算得动,而不是一百分精确。

2. 三要素与一条路径:先把坐标定下来再谈算法

2.1 形式化定义

HMM一般用 λ = (A, B, π) 表示,三个符号对应三组参数:

  • 状态集合 Q:隐藏状态的取值,比如 {晴, 雨},共N个取值。
  • 观测集合 V:观测的取值,比如 {海藻干, 海藻湿},共M个取值。
  • 初始状态概率向量 π:序列开头时各个状态的出现概率。π_i = P(I_1 = q_i)。
  • 状态转移矩阵 A:N×N的方阵,a_ij = P(I_{t+1} = q_j | I_t = q_i),从状态i跳到状态j的概率。
  • 观测概率矩阵 B:N×M的矩阵,b_j(k) = P(O_t = v_k | I_t = q_j),处于状态j时观测到v_k的概率。

很多初学者一上来就被符号淹没,其实这些东西就对应一个极简场景。我随手举个例子:状态Q={晴天, 雨天},观测V={海藻干, 海藻湿},π=(0.6, 0.4),A和B各给一组数值。这个例子虽然小到不像话,但足够跑通所有算法,理解递推式的每一步在干嘛。

2.2 “一条路径”的视角

给定λ后,如果固定一条状态序列 I=(i_1, i_2, ..., i_T) 和观测序列 O=(o_1, ..., o_T),那么联合概率可以按链式规则展开:

P(O, I | λ) = π_{i1} · b_{i1}(o_1) · a_{i1,i2} · b_{i2}(o_2) · ... · a_{i_{T-1},i_T} · b_{iT}(o_T)

这个式子看起来就是几项连乘,但把它想成一个图上的路径问题更有用。所有可能的状态序列组合起来,构成了一张以“时刻”为推进轴的网格图,每个状态序列就是图里的一条路径。每个节点上有发射概率,每条边上有转移概率,整条路径的得分就是沿途所有概率的乘积。

一旦你把“状态序列”想象成“路径”,三大经典问题的本质就清楚了:

  • 评估问题:所有路径得分加总,得到P(O|λ)。
  • 解码问题:找得分最高的那条路径,得到最优状态序列。
  • 学习问题:调整图中所有节点和边的权重,让所有路径总得分尽量大。

2.3 状态数N的选择

状态数不是算法自动决定的对参数要先有预期。N太小,模型表达力不足,拟合不了数据的真实结构;N太大,参数暴增,训练容易过拟合,而且收敛极慢。实践里常见的做法是:

  • 凭业务经验给定,比如词性标注就按语言学的词性数量来定,十几到几十个;
  • 用AIC/BIC这类信息准则做小范围搜索,但计算量大;
  • 或者先跑一个小状态数的模型,看结果再逐步增加。

我个人的看法是,序列建模里状态数更像一个“先验业务决策”,而不是纯统计问题。你建模的是词性还是音素、手势还是价格走势,决定了状态大概该有多少个。别指望算法自己告诉你正确答案。

3. 评估问题:前向算法是怎么把指数级计算降下来的

3.1 暴力求解为什么不可行

评估问题的输入是模型λ和观测序列O,输出是P(O|λ)。根据全概率公式:

P(O|λ) = Σ_所有可能的状态序列I P(O, I | λ)

如果你真的把所有状态序列枚举一遍,共有N^T种可能。假设N=5,T=100,这个数字大约是10的70次方,宇宙里的原子数量都远小于它。暴力枚举在数学上没问题,工程上等于不可能。

所以方向很清晰:利用链式结构复用中间计算结果,用动态规划把时间复杂度压到O(N²T)。

3.2 前向变量与递推

定义前向变量:

α_t(j) = P(O_1, O_2, ..., O_t, I_t = q_j | λ)

含义是:到t时刻为止的观测序列都“发生”了,并且t时刻正好落在状态q_j上的联合概率。

初始化:

α_1(j) = π_j · b_j(o_1)

递推式:

α_{t+1}(j) = [ Σ_{i=1..N} α_t(i) · a_{ij} ] · b_j(o_{t+1})

最后:

P(O|λ) = Σ_{j=1..N} α_T(j)

这个递推的直觉是:要到达t+1时刻的j状态,一定是从t时刻的某个i状态转移过来的。把所有可能的“前一步”概率和转移概率相乘再求和,得到“到达j状态但还没发射观测”的累积值,最后乘上当前观测的发射概率。每一步都复用了上一步已经算好的结果,这就是动态规划的核心套路。

3.3 数值下溢:真实工程里第一个坑

前向算法在纸上漂亮,一写代码就出问题。因为α里的概率连乘会随着t增长指数级缩小。T=100时,α数值很容易降到10的负几十次方,直接低于双精度浮点能表示的精度下限,变成0。一旦变0,后续递推就彻底废了。

两种常用解决思路:

第一种,对数域。把所有概率取log,乘法变成加法。即:

log α_{t+1}(j) = log( Σ_i exp( log α_t(i) + log a_ij ) ) + log b_j(o_{t+1})

中间那个“对一堆log值求和”的操作叫log-sum-exp,需要把最大项提出来防止exp溢出。用对数域的好处是数值稳定,坏处是如果要输出真正的概率P(O|λ),最后还得做一次exp,可能反溢出。一般只要比较相对大小或者作为中间量,对数域就够了。

第二种,缩放法。每递推一步,把所有α_t(j)除以它们的总和,强制让每步的α保持在同一量级。最后的总概率等于每步缩放系数的乘积。这种方法写起来稍绕,但数值性质好,很多成熟实现都用它。

我在实际编码时优先用对数域,因为后面维特比和参数学习也都在对数域里做,统一一套环境省事。需要注意对数域里log(0)的问题——如果某个转移概率或发射概率恰好为0,log(0)是负无穷,加法无所谓,但log-sum-exp时要小心正负无穷参与计算。

3.4 后向算法的意义

前向算法是从左往右扫,后向算法是从右往左扫。定义:

β_t(i) = P(O_{t+1}, ..., O_T | I_t = q_i, λ)

初始化 β_T(i) = 1,然后从后往前递推:

β_t(i) = Σ_j a_ij · b_j(o_{t+1}) · β_{t+1}(j)

后向算法本身不直接输出结果,但它是Baum-Welch参数学习的核心组件。前向和后向一结合,就能算出“t时刻处于状态i”和“t时刻在i、t+1时刻在j”的概率,这些是后面估计参数的统计量。所以前向和后向是一对配套工具,缺一个都不行。

4. 解码问题:维特比求的是全局最优,不是每步贪心

4.1 解码问题的正确目标

解码问题的输入不变,输出是让条件概率最大的状态序列:

I* = argmax_I P(I | O, λ)

由于P(O|λ)对所有I都一样,最大化P(I|O)等价于最大化联合概率P(I,O)。有人可能会想:那我每步挑“当前时刻最可能的状态”拼起来不就行了?不行,这犯的是贪心错误。单个时刻的高概率状态可能对应一个极差的转移路径,两步之间几乎没有边相连,整条路径联合概率反而很低。

可以做一个简单的思想实验:状态只有A和B,转移矩阵里“A后面跟B”的概率几乎为0,观测证据却让第一步的B、第二步的A各自单点概率都很高。贪心会拼出“B→A”,但这一步转移几乎不可能,而“A→B”虽然每步单点概率稍低,转移却极其顺畅,联合概率更高。这就是局部最优与全局最优的差别。

4.2 维特比的递推结构

维特比算法的思路跟前向算法镜像,只是把“对前态求和”换成了“对前态取最大值”。

定义维特比变量:

δ_t(j) = max_{I_1..I_{t-1}} P(I_1, ..., I_{t-1}, I_t = q_j, O_1, ..., O_t | λ)

这串定义的意思是:在所有能走到t时刻处于状态j的路径里,联合概率最大的那条路径的得分。

初始化:

δ_1(j) = π_j · b_j(o_1)

递推:

δ_{t+1}(j) = [ max_i δ_t(i) · a_ij ] · b_j(o_{t+1})

同时要记录每个节点的最优来源:

ψ_{t+1}(j) = argmax_i δ_t(i) · a_ij

到终点后,全局最优路径的末尾状态是max_j δ_T(j)对应的j,然后沿着ψ指针一路回溯,得到完整状态序列。

4.3 实现中的关键细节

维特比的工程实现有几点容易被忽略。

第一,必须存指针。很多人初学时只记得递推式,忘了ψ,结果最后一步得到最优概率,却拿不出对应路径。回溯是解码的最后半程,没有指针就前功尽弃。

第二,还是要用log域。δ_t同样是连乘出来的,T一长照样下溢,而且维特比的“比较大小”场景天生适合log——不需要log-sum-exp,直接对log加法求max,实现比前向算法更简单。这也是我偏爱log域的原因。

第三,注意log域里“取max”与“求概率”的差异。维特比返回的是“最可能那条路径”的得分,而前向返回的是“所有路径之和”的概率。当两条路径得分接近时,次优路径也会贡献相当一部分概率,此时维特比路径的置信度并没有想象中那么高。如果下游任务需要“路径置信度”,应该把维特比得分除以P(O|λ)做归一化。

4.4 维特比与前向的本质联系

如果把前向算法看成“所有路径的汇聚”,维特比就是“每条节点只保留一条最优来源的裁剪”。维特比本质上是在一个N×T的网格上做最短路径搜索,边权是log转移概率,节点权是log发射概率。从前缀到后缀,每走一步只保留到当前节点最优的那一条路,把指数级的路径数剪枝到O(N²T)。

这个视角在写大规模实现时会很有用。状态数N大、序列T长时,维特比的瓶颈在于每个时刻都要算N×N的转移矩阵,实际工程里可以提前把转移为0的边剪掉,或者用稀疏矩阵存储。

5. 学习问题:Baum-Welch的朴素直觉与工程实现

5.1 为什么不能直接求导

学习问题的输入是一组观测序列,输出是让P(O|λ)最大的λ=(π, A, B)。这里最大的难点在于,观测似然里隐变量I被求和消掉了:

P(O|λ) = Σ_I P(O, I | λ)

log里面有求和,求导之后没有任何简洁的解析解。直接做梯度类优化也不是不行,但概率图模型里更经典的是EM思路:先猜一套参数,算隐变量的后验期望,然后最大化这个期望,反复迭代直到收敛。Baum-Welch算法就是EM在HMM上的特例。

5.2 两个关键的“后验计数”

Baum-Welch的核心是定义两个中间量,它们都依赖前向α和后向β:

γ_t(i) = P(I_t = q_i | O, λ)

这个量表示“给定观测序列,t时刻真实状态是i”的概率。计算公式:

γ_t(i) = α_t(i) · β_t(i) / Σ_k α_t(k) · β_t(k)

分母就是对所有状态归一化。

ξ_t(i, j) = P(I_t = q_i, I_{t+1} = q_j | O, λ)

这个量表示“相邻两个时刻分别处于i和j”的概率。计算公式:

ξ_t(i, j) = α_t(i) · a_ij · b_j(o_{t+1}) · β_{t+1}(j) / P(O)

分子走的正是“到t为i → 转移i→j → 发射o_{t+1} → 从t+1为j出发看剩余序列”这条完整路径的权重。

有了这两个量,参数更新的式子就变得很直观:

  • π_i = γ_1(i):序列开头处于状态i的期望次数占比。
  • a_ij = Σ_t ξ_t(i,j) / Σ_t γ_t(i):从i跳到j的期望次数,除以从i出发的期望次数。
  • b_j(k) = Σ_{t: o_t = v_k} γ_t(j) / Σ_t γ_t(j):状态j下观测到v_k的期望次数,除以状态j出现的期望次数。

这些不是硬计数,而是“加权软计数”。每个时刻的状态都有可能,只是概率不同,更新时就按这个概率分配贡献。这是EM“软划分”思想的精髓。

5.3 多观测序列的训练细节

实际项目里几乎不会只给一条观测序列。处理多条序列时,E步要对每条序列分别算α、β、γ、ξ,然后把所有序列的统计量按分子分母分别累加,统一更新π、A、B。有一个常见坑:不同序列长度不一样,直接对比它们的P(O)会偏向处理短序列,因为短序列的似然通常受长度影响更小。解决办法通常是在E步分别归一化每条序列,保证每条序列的贡献可比,再汇总更新。

我见过一种偷懒写法,把所有序列拼成一长串当一条序列喂给算法,结果会在拼接处学出一个巨大的“跨序列转移概率”。这是必须避免的错误。序列之间没有状态转移关系,不能共享转移边。

5.4 初值与收敛:最被低估的环节

Baum-Welch是迭代算法,只能保证收敛到某个局部最优点,不保证全局最优。初值的选择对结果影响极大。初值给得离谱,可能收敛到完全没有解释力的烂点上。

常见的初始化策略有几种:

  • 均匀初值:π、A每行均匀分布,B按观测频次给。简单但不稳定。
  • 随机初值:多跑几次,挑训练集似然最高的。最稳但费时。
  • 分段初值:先把序列按状态数粗略分段,统计分段的频次作为B的初值。这在语音识别等场景常用。
  • 基于先验的初值:比如词性标注里直接拿一部分粗规则标好的数据估一遍A、B,再当初值。

训练停止条件一般是看对数似然增量。连续几轮增量小于某个阈值(比如1e-4)就停。但要注意,增量缓慢不等于到全局最优,多换几个初值对比是值得的。

补充一个容易被新手误解的点:Baum-Welch在多数情况下收敛速度很慢,尤其状态数多、序列长时,可能要跑上百轮。工程上除了调阈值,还要batch size的选择——数据量特别大时,可以每轮随机抽一部分序列做E步,用随机近似替代全量统计。

6. 选型判断:HMM的优势边界和现代应用场景

6.1 HMM为什么输给判别式模型

HMM是生成式模型,学的是联合分布P(O, I),然后通过贝叶斯公式转成条件分布做推断。这意味着它必须先建模观测是怎么生成的,即每个状态下的发射概率。当观测是高维的、特征之间强相关的时候,B矩阵会非常庞大,参数估计极不稳定。词性标注里观测是词本身,状态是词性,状态数和词数一乘,B矩阵可能动不动几百万参数,在数据集不够大时很难训好。

判别式模型(比如条件随机场CRF)直接建模P(I|O),绕开了观测生成过程,因此可以随意叠加任意特征,比如前缀、后缀、相邻词、词频统计等,不需要担心观测独立性。这也是现在序列标注领域CRF比HMM更常见的原因。

6.2 什么时候HMM依然是优选

虽然判别式模型在精度上通常占优,HMM仍有它不可替代的场景。

第一,小样本场景。生成式模型在小样本下表现往往比判别式更稳,因为它显式建模的是数据生成过程,结构约束更强。标注数据只有几千条甚至更少时,HMM不容易过拟合。

第二,残缺观测。HMM的观测概率是软性的,即便某个观测值在训练里没见过,也能通过状态分布给出相对合理的评分。某些判别式模型对未登录特征会更敏感。

第三,可解释性要求高。HMM的状态本身有清晰的物理含义——天气、音素、行为阶段。训练完可以直接把每个状态对应的观测分布打印出来,业务方看表就能理解模型学到了什么。CRF的特征权重也能解释,但隐变量的可解释性不如HMM的自然。

第四,时序分段与对齐。语音识别里音素与帧的对齐、手势识别里动作与帧的分段,本质上都需要一个“潜状态随时间跳转”的结构,HMM天然适合。深度模型虽然声学建模能力更强,但时序对齐的部分仍然大量沿用HMM的框架。

6.3 从HMM到更广的概率图模型

把HMM当成贝叶斯网络的一个特例来看,会打开一个更大的视角。它的节点是链状排列的,每条边只连相邻节点,整个推理过程依赖前向、后向这套动态规划。理解了HMM,再去看线性链CRF就会发现推理部分几乎同构,区别只在于“生成式vs判别式”;再去看隐半马尔可夫、分层HMM、输入输出HMM,也都只是在这个基础结构上做扩展。

我自己的经验是:不要只把HMM当作一个孤立的“老算法”,它是理解EM算法、动态规划、生成式与判别式差异的绝佳载体。把这些底层思想弄通,换到新模型上会顺畅很多。

最后分享一个实践体会:我每次在项目里重抄HMM代码,第一件事永远是写对数域的前向算法和维特比,能用这一层做通数据的小规模验证,再决定要不要上Baum-Welch。主要原因在于,HMM代码的调试难点几乎都集中在数值稳定性上——不是逻辑不对,而是概率连乘到后面全变成零了。只要一开始就在log域里跑,下溢问题就绕开了大半,剩下的是把指针、归一化这些细节看仔细。这个习惯帮我省了很多时间去对比调试,也算是一路踩坑攒下的心得了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:23:58

R语言代谢组学数据分析全流程:从数据清洗到通路富集的完整指南

简介:这份资源面向具备一定R语言基础、希望进入代谢组学领域的科研人员与生物信息学初学者,系统整理了从数据导入到结果报告的全流程分析思路。压缩包共61个文件,以52个R脚本为核心,辅以3个Rmd报告文档、1个README说明及若干辅助文…

作者头像 李华
网站建设 2026/10/13 4:23:31

Unity动画控制角色运动:Root Motion与Legacy混用实战指南

1. 为什么“动画控制角色运动”不是简单拖个Animation组件就完事?在Unity项目里,我见过太多新手开发者把角色移动逻辑和动画播放混为一谈:角色要跑,就给Animator加个Run状态;要跳,就切到Jump状态&#xff1…

作者头像 李华
网站建设 2026/10/13 4:23:15

用Python与Twilio构建高可靠短信通知系统:架构设计与实践

短信通知这个东西,听上去简单,真做起来细节相当多。我最近用Python和Twilio把一套完整的短信通知系统搭了下来,从订单提醒、告警推送,到定时报告发送,都跑在一个几千行的轻量服务里。整个过程踩了不少坑,也…

作者头像 李华
网站建设 2026/10/13 4:22:51

3DMax 8 SDK插件开发:环境配置、场景遍历与避坑实战

简介:3DMax 8 SDK是Autodesk 3ds Max 8的软件开发工具包,面向需要扩展三维建模、动画与渲染功能的程序员和插件开发者。它通过头文件、库文件与示例代码,让开发者能够深入调用3ds Max核心API,从而创建自定义命令、材质类型、粒子系…

作者头像 李华
网站建设 2026/10/13 4:22:40

ESP32 ModbusTCP分片缓存实战:解决TCP粘包拆包与数据错乱

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 4:22:31

手写SIFT算法:从高斯金字塔到128维描述子的NumPy实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华