隐马尔可夫模型(Hidden Markov Model,简称HMM)是统计学习中的经典模型,它的特点是系统真实状态隐藏在幕后无法直接观测,只能通过一系列可观测的外在表现去推断。典型的例子是天气与海藻湿度:天气是隐藏状态,海藻的干湿是可观测的输出。很多人以为HMM只能用Python实现,实际上Node.js配合JavaScript的数组能力同样可以写出结构清晰、性能不错的HMM。本文将从模型参数讲起,逐步实现前向算法和维特比算法,并给出完整的可运行代码。

一、隐马尔可夫模型的核心参数与数学定义
一个完整的隐马尔可夫模型由三个矩阵(或向量)唯一确定,通常简称为三元组 λ = (A, B, π)。理解这三个参数是写代码之前必须完成的功课,否则后续算法只是照抄公式而已。
第一个参数是状态转移概率矩阵 A。假设隐藏状态有 N 个,那么 A 是一个 N 行 N 列的矩阵,元素 A[i][j] 表示在时刻 t 处于状态 i 的条件下,时刻 t+1 转移到状态 j 的概率。每一行的概率之和必须等于 1。比如在天气模型中,A[晴天][雨天] 就表示今天晴天时明天转雨的概率。
第二个参数是观测概率矩阵 B,也叫发射概率矩阵。它是一个 N 行 M 列的矩阵,M 是观测符号的数量,B[i][k] 表示处于状态 i 时观测到符号 k 的概率。例如雨天观测到海藻湿润的概率、晴天观测到海藻干燥的概率,都记录在这个矩阵里。
第三个参数是初始状态概率向量 π,π[i] 表示序列第一个时刻处于状态 i 的概率。在JavaScript中,我们可以很自然地用二维数组表示矩阵,用一维数组表示向量,这比很多语言的实现要直观得多。需要注意的是,真实项目中这些概率往往不是手工指定的,而是通过鲍姆-韦尔奇算法从训练语料中学习得到,本文先聚焦于参数已知时的推理问题。
二、用JavaScript描述模型结构
在动手实现算法之前,先设计好数据结构。JavaScript的对象和数组可以非常优雅地表达HMM的三元组。下面定义一个HMM类,构造函数接收状态列表、观测列表和三个概率参数,并提供一个辅助方法对观测序列做编码转换。
class HMM {
constructor(states, observations, A, B, pi) {
this.states = states; // 隐藏状态列表,如 ['晴天','雨天']
this.observations = observations; // 观测符号列表,如 ['干燥','湿润']
this.A = A; // 状态转移概率矩阵
this.B = B; // 观测概率矩阵
this.pi = pi; // 初始状态概率向量
}
// 将观测符号转换为索引,方便矩阵运算
obsIndex(obs) {
return this.observations.indexOf(obs);
}
}
// 构造一个天气模型的实例
const model = new HMM(
['晴天', '雨天'], // 隐藏状态
['干燥', '微湿', '湿润'], // 观测符号
[ // 转移矩阵 A
[0.7, 0.3],
[0.4, 0.6]
],
[ // 观测矩阵 B
[0.6, 0.3, 0.1],
[0.1, 0.3, 0.6]
],
[0.6, 0.4] // 初始概率 pi
);
这段代码的关键点在于把概率矩阵写成嵌套数组的结构,访问方式为this.A[i][j],与数学下标完全对应,阅读算法代码时不容易混乱。另外,把状态和观测都保存为字符串列表,可以在输出结果时直接还原成可读的中文标签,而不必维护一张额外的索引对照表。如果状态数量很多(比如中文分词中的B、M、E、S标注体系),这种结构依然适用。
一个容易踩的坑是概率为零的情况。如果某个转移概率恰好为0,后续做连乘时整个路径概率会变成0,这在维特比解码中通常没什么问题,但在前向算法和参数学习中会导致信息丢失。工程上常用的做法是给所有概率加一个极小的平滑值,例如1e-10,再重新归一化。
三、前向算法:计算观测序列出现的概率
HMM的第一个经典问题是评估问题:给定模型参数和一段观测序列,计算这个序列出现的概率。它的难点在于隐藏状态有 N 种可能,长度为 T 的序列对应 N 的 T 次方条路径,暴力枚举在 T 稍大时就完全不可行。前向算法通过动态规划把复杂度降到了 O(N²T)。
前向算法定义了一个前向变量 α[t][i],含义是:观察到前 t 个符号,且时刻 t 处于状态 i 的联合概率。它有两种来源:t=0 时直接用 π[i] 乘以发射概率;t>0 时,把上一时刻所有状态的前向值乘以转移到 i 的概率再求和。得到递推公式之后,最终的序列概率就是最后一列 α 值的总和。代码实现如下。
forward(obsSeq) {
const T = obsSeq.length;
const N = this.states.length;
const alpha = [];
// 初始化:t = 0
alpha[0] = [];
for (let i = 0; i < N; i++) {
alpha[0][i] = this.pi[i] * this.B[i][this.obsIndex(obsSeq[0])];
}
// 递推:t = 1 到 T-1
for (let t = 1; t < T; t++) {
alpha[t] = [];
for (let i = 0; i < N; i++) {
let sum = 0;
for (let j = 0; j < N; j++) {
sum += alpha[t - 1][j] * this.A[j][i];
}
alpha[t][i] = sum * this.B[i][this.obsIndex(obsSeq[t])];
}
}
// 终止:最后一列求和
let prob = 0;
for (let i = 0; i < N; i++) {
prob += alpha[T - 1][i];
}
return prob;
}
以天气模型为例,调用model.forward(['干燥', '微湿', '湿润'])会返回一个介于0和1之间的概率值,表示在这套参数下连续三天观测到该海藻状态的可能性。数值越小,说明观测序列与模型的匹配程度越差,这正是异常检测场景利用HMM的基本思路。
需要提醒的是浮点数下溢问题。当序列较长时,每个α值都是大量小概率连乘的结果,很容易小于JavaScript双精度浮点数能表示的最小值而变成0。解决方案是对每一步的结果取对数,或者做缩放归一化并同时记录缩放系数。在一般教学演示中序列较短可以忽略,但用于真实数据时必须处理。
四、维特比算法:找出最可能的隐藏状态序列
HMM的第二个经典问题是解码问题:已知观测序列,最可能的隐藏状态序列是什么。这正是中文分词、词性标注的核心逻辑。维特比算法与前向算法结构几乎一样,唯一区别是把求和换成了取最大值,并且额外维护一个回溯指针数组,记录每个时刻每个状态的最优前驱。
viterbi(obsSeq) {
const T = obsSeq.length;
const N = this.states.length;
const delta = []; // 最优路径概率
const psi = []; // 回溯指针
// 初始化
delta[0] = [];
for (let i = 0; i < N; i++) {
delta[0][i] = this.pi[i] * this.B[i][this.obsIndex(obsSeq[0])];
psi[0] = psi[0] || [];
psi[0][i] = 0;
}
// 递推
for (let t = 1; t < T; t++) {
delta[t] = [];
psi[t] = [];
for (let i = 0; i < N; i++) {
let maxProb = -1, maxIndex = 0;
for (let j = 0; j < N; j++) {
const p = delta[t - 1][j] * this.A[j][i];
if (p > maxProb) { maxProb = p; maxIndex = j; }
}
delta[t][i] = maxProb * this.B[i][this.obsIndex(obsSeq[t])];
psi[t][i] = maxIndex;
}
}
// 找到最后一个时刻概率最大的状态
let best = 0;
for (let i = 1; i < N; i++) {
if (delta[T - 1][i] > delta[T - 1][best]) best = i;
}
// 回溯路径
const path = [best];
for (let t = T - 1; t > 0; t--) {
best = psi[t][best];
path.unshift(best);
}
return {
prob: delta[T - 1][path[path.length - 1]],
states: path.map(i => this.states[i])
};
}
调用model.viterbi(['干燥', '微湿', '湿润'])会返回类似{ prob: 0.01296, states: ['晴天', '晴天', '雨天'] }的结果,即最可能的天气变化路径及其概率。回溯部分使用了unshift方法从后往前构建数组,对于短序列性能没有问题;如果序列长达几千个观测值,更好的做法是先push再reverse,避免频繁移动数组元素。
维特比算法的正确性建立在最优路径的子路径也必然是最优的这一性质上,这与动态规划的最优子结构原理完全一致。因此在处理中文分词时,只需把隐藏状态换成B(词首)、M(词中)、E(词尾)、S(单字词)四种,观测换成具体汉字,整个模型无需任何结构性修改即可直接复用,这正是HMM设计的精妙之处。
五、性能优化与工程化建议
上述实现清晰易读,但还有不少优化空间。首先是数值稳定性,推荐把所有概率预先取自然对数,乘法改成加法。对数域下的维特比算法不会下溢,且加法在CPU层面比乘法更快。改造后的核心递推式变为delta[t][i] = max(delta[t-1][j] + logA[j][i]) + logB[i][obs],代码结构不变,只是初始化时多做一次Math.log转换。
其次是数据结构层面。当状态数和观测词表很大时(比如分词任务中观测词表有几万个汉字),把B矩阵从嵌套数组改成Map或者用一维扁平数组配合索引计算i * M + k访问,可以显著降低内存碎片。Node.js的V8引擎对连续存储的小整数索引数组有专门优化,扁平化处理往往能带来数倍的吞吐提升。
最后是参数获取问题。手工设定的概率矩阵只适合演示,真实应用需要用鲍姆-韦尔奇算法(即EM思想在HMM上的具体化)从标注或未标注语料中迭代学习参数。该方法引入前向变量和后向变量β,思路是先算出每个时刻处于每个状态的后验概率,再用它重新估计A、B、π,循环直到收敛。学习部分的代码量大约是推理部分的三倍,建议在掌握本文的推理算法后再去挑战。
总结来看,用Node.js实现隐马尔可夫模型并不神秘:三元组参数定义模型,前向算法解决评估,维特比算法解决解码,三者层层递进。把这套代码跑通之后,再去理解条件随机场、最大熵马尔可夫模型等更现代的序列标注方法,也会顺畅许多。