隐马尔可夫模型(HMM)原理与实战:从序列建模到中文分词应用

📅 2026/8/24 1:49:01
隐马尔可夫模型(HMM)原理与实战:从序列建模到中文分词应用
1. 项目概述从“黑盒”到“白盒”理解序列背后的状态机如果你处理过语音识别、词性标注或者分析过股票价格序列那你大概率已经和隐马尔可夫模型打过交道了哪怕你当时并不知道它的名字。这东西听起来挺学术什么“隐”啊“马尔可夫”的感觉离我们很远。但说穿了它就是一个特别擅长处理“一串有顺序的数据”的模型核心思想是我们观察到的一系列现象比如每天听到的声音、看到的股价是由一个我们看不见的、内部的状态序列比如说话人真正的发音器官状态、市场的真实情绪状态按照一定规律跳转所产生的。我最早接触HMM是在做文本信息抽取的时候需要从一堆非结构化的文本里识别出公司名、人名、地点。当时用了一些现成的工具包效果时好时坏调参调到头疼。后来下定决心把HMM的底裤扒下来看看才发现很多所谓的“调参”其实是盲人摸象根本不知道每个参数变动到底影响了模型哪部分的判断逻辑。搞明白原理之后不仅调参有了方向甚至在一些规则简单但数据量不大的场景下自己从头实现一个轻量级的HMM比用重型工具包更灵活、更高效。所以这篇内容的目的不是给你堆砌数学公式而是想和你聊聊作为一个实践者我是怎么理解HMM的以及如何把它用起来。我们会避开最枯燥的数学推导但必要的核心公式会讲聚焦在“它到底是怎么工作的”以及“我该怎么用它”这两个问题上。无论你是算法工程师、数据分析师还是对序列建模感兴趣的研究者相信都能从中获得可以直接上手操作的思路。2. 核心思路拆解一个关于“猜状态”的故事要理解HMM我们可以把它想象成一个你在玩的双层“猜猜看”游戏。这个游戏有两个角色一个是你作为观察者另一个是一个躲在幕后的导演他在操控一个状态机。2.1 模型的三个基本假设HMM之所以能在众多模型里脱颖而出处理序列问题是因为它建立在三个非常聪明又合理的假设之上这大大简化了问题的复杂度。第一齐次马尔可夫性假设。这是关于“状态”跳转的假设。意思是幕后导演让状态机跳转到下一个状态时只关心当前处在什么状态而完全不用管之前的历史状态序列。比如如果当前状态是“晴天”那么下一时刻是“雨天”还是“阴天”的概率是固定的和你之前经历了多少天晴天无关。这就像一个有健忘症的状态跳转机器只记得“现在”忘了“过去”。这个假设是模型可行的基石它把状态序列的联合概率分解成了一个个条件概率的连乘计算量从指数级降到了线性级。第二观测独立性假设。这是关于“你能看到什么”的假设。意思是在任一时刻你能观察到的现象比如看到有人带伞只由当前时刻幕后导演隐藏的状态比如实际天气是雨天决定与其他的观测值、其他的状态都无关。你看到有人带伞只因为现在下雨和昨天是否有人带伞、明天是什么天气都没关系。这个假设同样是为了计算上的可行性它把生成观测序列的概率也分解开了。第三模型参数假设。我们假设整个游戏由一套固定的参数控制不随时间改变。这套参数就是HMM的“五脏六腑”我们待会要详细说。注意这两个“假设”听起来很理想化在现实中几乎不可能完美成立。今天的股价真的和昨天毫无关系吗显然不是。但正是这些“不完美”的假设使得复杂的计算变得可能。在工程上我们追求的是“足够好”而不是“完美”。很多场景下基于这些假设的模型已经能取得惊人的效果。理解这一点你就不会在初次应用时过于纠结理论上的“不严谨”。2.2 模型的五元组λ (A, B, π)这是HMM的“身份证”所有信息都浓缩在这五个要素里。理解了它们你就抓住了模型的命脉。状态集合 Q 幕后导演手里所有可能状态的清单。比如天气预测中Q {晴天 阴天 雨天}。状态数是N。观测集合 V 你能看到的所有可能现象的清单。比如根据人的行为V {散步 购物 带伞}。观测数是M。状态转移概率矩阵 A 一个 N x N 的矩阵。A[i][j]表示从状态 i 跳转到状态 j 的概率。它刻画了状态变化的规律。比如A[晴天][雨天] 0.1表示如果今天是晴天明天有10%的概率会下雨。观测概率矩阵 B 一个 N x M 的矩阵。B[j][k]表示当处于状态 j 时生成观测 k 的概率。它建立了隐藏状态和可见现象之间的桥梁。比如B[雨天][带伞] 0.8表示如果实际下雨你有80%的概率会看到有人带伞还有20%的可能遇到不怕淋雨的或者没带伞的。初始状态概率分布 π 一个长度为 N 的向量。π[i]表示游戏开始时状态机处于状态 i 的概率。比如π[晴天] 0.6表示第一天有60%的概率是晴天开局。一个生动的类比你可以把HMM想象成一个有偏见的骰子工厂。工厂里有N种不同的机器状态每种机器生产M种不同图案的骰子观测。π决定了第一天启动哪台机器。A决定了每天结束时是继续用这台机器还是换到另一台机器。B决定了当前这台机器生产出每种图案骰子的概率。你作为质检员每天只能看到生产出来的骰子图案观测序列而看不到背后是哪台机器在生产状态序列。你的任务就是通过看到的骰子序列去推测机器切换的规律A, B, π或者某一天最可能是哪台机器在工作。3. 三大核心问题与算法解析HMM的所有应用最终都归结为求解以下三个基本问题。这三个问题由前向后构成了我们使用HMM的完整工作流。3.1 评估问题计算观测序列的概率问题定义已知模型参数 λ(A, B, π) 和一段观测序列 O (o1, o2, ..., oT)计算这段观测序列由该模型生成的概率 P(O|λ)。为什么重要这是模型的基础能力。比如我们有多个HMM模型一个对应中文语音一个对应英文语音当接收到一段语音观测序列时我们可以分别计算它由每个模型生成的概率概率最大的那个模型就最可能是这段语音所属的类别。这就是一个简单的分类器。暴力算法不可行最直接的想法是列举所有可能的状态序列计算每个状态序列生成观测序列的概率然后求和。但状态序列有 N^T 种可能这是天文数字。前向算法为了解决这个计算灾难我们引入了“前向概率”的概念。定义 α_t(i) P(o1, o2, ..., o_t, q_t i | λ)表示在时刻 t观测到前 t 个观测值并且此时状态为 i 的概率。它的计算采用动态规划的思想优雅地避免了穷举初始化α_1(i) π_i * b_i(o1) 即第一时刻的状态概率乘以生成第一个观测的概率。递推对于 t 1, 2, ..., T-1 α_{t1}(j) [ Σ_{i1}^{N} α_t(i) * a_{ij} ] * b_j(o_{t1})。 这个公式是核心时刻 t1 状态为 j 的前向概率等于所有在时刻 t 可能的状态 i 的前向概率乘以从 i 跳到 j 的转移概率求和后再乘以在状态 j 下生成当前观测的概率。终止P(O|λ) Σ_{i1}^{N} α_T(i)。将最终时刻所有可能状态的前向概率相加即得到整个序列的概率。我个人的理解窍门把前向算法想象成一股“概率流”。在初始时刻概率流根据π分布到各个状态节点上。随着时间步推进每个节点上的概率流会沿着A矩阵定义的路径分流到下一个时刻的各个节点同时每流经一个节点都要乘以该节点“发射”出当前观测的B概率。最终在最后一刻所有节点上积累的概率流总和就是生成整个观测序列的总概率。3.2 解码问题预测最可能的状态序列问题定义已知模型参数 λ 和观测序列 O求最有可能产生此观测序列的隐藏状态序列 Q* (q1*, q2*, ..., qT*)。即求 argmax_Q P(Q, O|λ)。为什么重要这是HMM最核心的应用之一。在语音识别中观测是声学特征状态是音素或词解码就是找出最可能的词序列。在词性标注中观测是词语状态是词性标签解码就是为每个词标注最可能的词性。维特比算法同样采用动态规划但它找的是路径而不是概率和。它定义 δ_t(i) 为在时刻 t所有到达状态 i 的路径中概率最大的那条路径的概率值。同时用 ψ_t(i) 来记录这条最优路径在时刻 t-1 的状态。初始化δ_1(i) π_i * b_i(o1) ψ_1(i) 0。递推对于 t 2, ..., T δ_t(j) max_{1≤i≤N} [δ_{t-1}(i) * a_{ij}] * b_j(o_t) ψ_t(j) argmax_{1≤i≤N} [δ_{t-1}(i) * a_{ij}]。 这里不再是求和而是取最大值并记录下这个最大值是从哪个前驱状态 i 来的。终止与路径回溯最优路径概率 P* max_{1≤i≤N} δ_T(i) 最终状态 qT* argmax_{1≤i≤N} δ_T(i)。然后根据 ψ 数组向前回溯q_t* ψ_{t1}(q_{t1}*) 即可得到整个最优状态序列。实操心得维特比算法在实现时δ 和 ψ 通常用对数概率来计算即把乘法和取最大值换成加法和取最大值。这是因为概率值连乘很容易下溢变成极小的浮点数被计算机视为0。log(a*b) log(a) log(b) 加法运算稳定得多。这是实现HMM相关算法时的一个必做技巧能避免很多莫名其妙的数值计算错误。3.3 学习问题从数据中估计模型参数问题定义已知观测序列 O或者多组观测序列如何调整模型参数 λ(A, B, π)使得该模型生成观测序列的概率 P(O|λ) 最大这是一个无监督学习过程。为什么重要绝大多数时候我们无法直接知道状态转移概率A和观测概率B。比如我们有一大堆语音波形观测和对应的文本状态可以人工标注但A和B的细节音素间如何跳转、音素如何生成声学特征是未知的。学习问题就是让模型从数据中自己总结出这些规律。鲍姆-韦尔奇算法这是解决学习问题的经典方法它是期望最大化算法在HMM情境下的一个特例。算法通过迭代来逐步优化参数。算法的核心是定义两个中间变量ξ_t(i, j)在给定模型和观测序列的条件下时刻 t 处于状态 i 且时刻 t1 处于状态 j 的概率。γ_t(i)在给定模型和观测序列的条件下时刻 t 处于状态 i 的概率。算法流程如下初始化随机或根据先验知识给模型参数 λ(A, B, π) 赋初值。E步期望步基于当前参数 λ利用前向-后向算法计算所有时刻的 ξ_t(i, j) 和 γ_t(i)。后向算法类似于前向算法但从序列末尾向开头计算。M步最大化步利用E步计算出的期望值重新估计模型参数 λ̄π_ī γ_1(i) 初始状态概率等于第一个时刻处于状态 i 的期望概率a_{ij}̄ (从 i 到 j 的转移期望次数) / (从 i 转移出去的期望总次数) Σ_{t1}^{T-1} ξ_t(i, j) / Σ_{t1}^{T-1} γ_t(i)b_j(k)̄ (在状态 j 下观察到符号 k 的期望次数) / (处于状态 j 的期望总次数) Σ_{t1, s.t. o_tv_k}^{T} γ_t(j) / Σ_{t1}^{T} γ_t(j)迭代用 λ̄ 替换 λ重复E步和M步直到 P(O|λ) 的变化小于某个阈值或参数收敛。我的经验之谈鲍姆-韦尔奇算法对初始值敏感。糟糕的初始值可能导致模型收敛到局部最优效果很差。在实践中我通常会尝试几种初始化策略均匀初始化最简单但效果往往一般。基于先验知识初始化如果有领域知识比如某些状态不可能相互转移可以在A矩阵中对应位置赋0或极小值。多次随机初始化常用策略。随机初始化模型运行BW算法重复多次选择最终似然概率 P(O|λ) 最大的那组参数。用有监督数据初始化如果有一小部分标注了状态的数据可以直接用频率统计来估算A和B作为初始值再用大量无标注数据精调。这通常效果最好。4. 实战演练用Python实现一个简易中文分词器理论说再多不如动手做一遍。我们用一个经典的例子——基于字符标注的中文分词来串联HMM的三大问题。分词的本质就是给句子中的每个汉字打上一个标签状态表示它在词中的位置。4.1 问题定义与数据准备我们采用经典的“BMES”标注集B (Begin) 表示一个词的开始M (Middle) 表示一个词的中间部分E (End) 表示一个词的结尾S (Single) 表示单字成词例如句子“隐马尔可夫模型”应该被标注为“B M M E B E”。对应的分词结果就是“隐/马尔可夫/模型”。数据准备我们需要一个已分好词的大型语料库比如人民日报语料。从分好词的句子中我们可以很容易地生成“字符-标签”对的训练数据。# 示例将分词后的句子转化为标注序列 def sentence_to_states(sentence): sentence: 已分词的句子如 隐 马尔可夫 模型 words sentence.split() chars [] tags [] for w in words: if len(w) 1: chars.append(w) tags.append(S) else: chars.extend(list(w)) tags.append(B) tags.extend([M] * (len(w) - 2)) tags.append(E) return list(zip(chars, tags)) # 返回 [(隐,B), (马,M), ...] # 假设我们有大量这样的句子就可以统计出 # 1. 状态集合 Q {B, M, E, S} # 2. 观测集合 V 所有出现过的汉字 # 3. 通过频率统计估算初始概率π转移概率A发射概率B4.2 模型训练鲍姆-韦尔奇算法的简化版应用在完全无监督的场景下我们可以用BW算法。但在有大量标注数据的情况下我们可以用更简单直接的极大似然估计频率统计来“学习”模型参数这相当于BW算法一步收敛到最优解。import numpy as np from collections import defaultdict, Counter class SimpleHMMSegmenter: def __init__(self): self.states [B, M, E, S] self.state2idx {s: i for i, s in enumerate(self.states)} self.idx2state {i: s for i, s in enumerate(self.states)} self.vocab set() # 观测集合汉字 self.char2idx {} self.idx2char {} # 参数 self.pi None # 初始概率向量 self.A None # 转移概率矩阵 self.B None # 发射概率矩阵 def train(self, labeled_data): labeled_data: list of list of (char, state) pairs. 例如: [[(隐,B), (马,M), (尔,M), (可,M), (夫,E), (模,B), (型,E)], ...] # 1. 构建观测词典 all_chars {char for seq in labeled_data for char, _ in seq} self.vocab sorted(all_chars) self.char2idx {c:i for i,c in enumerate(self.vocab)} self.idx2char {i:c for i,c in enumerate(self.vocab)} n_states len(self.states) n_obs len(self.vocab) # 初始化计数矩阵/向量 pi_count np.zeros(n_states) A_count np.zeros((n_states, n_states)) B_count np.zeros((n_states, n_obs)) # 2. 遍历标注数据进行计数 for seq in labeled_data: if not seq: continue # 计数初始状态 first_state seq[0][1] pi_count[self.state2idx[first_state]] 1 # 计数状态转移和发射 for i in range(len(seq)): char, state seq[i] state_idx self.state2idx[state] char_idx self.char2idx[char] # 发射计数 B_count[state_idx, char_idx] 1 # 转移计数 (如果不是最后一个状态) if i len(seq) - 1: next_state seq[i1][1] next_state_idx self.state2idx[next_state] A_count[state_idx, next_state_idx] 1 # 3. 归一化得到概率 (加平滑避免零概率) # 拉普拉斯平滑加1 self.pi (pi_count 1) / (pi_count.sum() n_states) self.A (A_count 1) / (A_count.sum(axis1, keepdimsTrue) n_states) self.B (B_count 1) / (B_count.sum(axis1, keepdimsTrue) n_obs) # 转换为对数空间方便后续维特比计算 self.log_pi np.log(self.pi) self.log_A np.log(self.A) self.log_B np.log(self.B) def viterbi_decode(self, sentence): 使用维特比算法对输入句子进行解码返回状态序列 sentence: 字符串如 隐马尔可夫模型 T len(sentence) N len(self.states) # 初始化delta和psi矩阵 delta np.full((T, N), -np.inf) psi np.zeros((T, N), dtypeint) # 初始化第一步 first_char sentence[0] if first_char in self.char2idx: first_char_idx self.char2idx[first_char] delta[0, :] self.log_pi self.log_B[:, first_char_idx] else: # 处理未登录词假设所有字符发射概率均等实际中可用更复杂的回退策略 delta[0, :] self.log_pi np.log(1.0 / len(self.vocab)) # 递推 for t in range(1, T): char sentence[t] char_idx self.char2idx.get(char, -1) for j in range(N): # 计算转移到状态j的最佳路径概率 trans_probs delta[t-1, :] self.log_A[:, j] best_prev_state np.argmax(trans_probs) delta[t, j] trans_probs[best_prev_state] psi[t, j] best_prev_state # 加上发射概率 if char_idx ! -1: delta[t, j] self.log_B[j, char_idx] else: # 未登录词处理 delta[t, j] np.log(1.0 / len(self.vocab)) # 回溯 best_path np.zeros(T, dtypeint) best_path[-1] np.argmax(delta[-1, :]) for t in range(T-2, -1, -1): best_path[t] psi[t1, best_path[t1]] # 转换为状态标签 state_seq [self.idx2state[i] for i in best_path] return state_seq def segment(self, sentence): 将状态序列转换为分词结果 state_seq self.viterbi_decode(sentence) words [] word_start 0 for i, (char, state) in enumerate(zip(sentence, state_seq)): if state B: word_start i elif state E: words.append(sentence[word_start:i1]) elif state S: words.append(char) # 处理以M结尾的异常情况理论上不应出现但可容错 if state_seq[-1] B or state_seq[-1] M: words.append(sentence[word_start:]) return words4.3 模型使用与评估训练好模型后我们就可以对新句子进行分词了。# 假设我们已经用大量数据训练好了模型 hmm_seg hmm_seg SimpleHMMSegmenter() # hmm_seg.train(training_data) # 这里省略训练数据加载过程 test_sentence 隐马尔可夫模型非常有用 segmented hmm_seg.segment(test_sentence) print(/.join(segmented)) # 期望输出隐/马尔可夫/模型/非常/有用评估指标通常使用准确率(Precision)、召回率(Recall)和F1值来评估分词效果。需要一份标准的分词测试集进行对比。5. 避坑指南与进阶思考在实际应用中直接套用上述基础模型可能会遇到各种问题。这里分享几个我踩过的坑和对应的解决思路。5.1 未登录词问题这是基于统计的分词模型包括HMM的头号敌人。未登录词即训练语料中没有出现过的词如新出现的网络用语、专业名词、人名、地名等。对于未登录词模型无法获得其合理的状态转移B-M-E模式和发射概率导致切分错误。应对策略回退平滑如上述代码所示当遇到字典中不存在的字符时我们假设其发射概率均匀分布。这是一种非常弱的平滑。更好的方法是采用更复杂的平滑技术如Good-Turing、Katz回退或Kneser-Ney平滑这些在语言模型中常用。混合模型将HMM与基于规则或词典的方法结合。例如先利用一个大型词典进行最大匹配对匹配失败的片段再用HMM处理。或者将词典信息以特征的形式融入到模型中这就导向了更复杂的模型如条件随机场。增量更新建立在线学习机制当发现高频的新词组合时动态地更新模型的发射概率矩阵B甚至扩展状态转移模式。5.2 参数初始化与局部最优鲍姆-韦尔奇算法是一种EM算法它只能保证收敛到局部最优解而非全局最优。糟糕的初始化会导致模型学到无意义的规律。实操建议多次随机重启这是最常用且有效的方法。用不同的随机种子初始化参数训练多个模型在开发集上选择性能最好的一个。利用领域知识初始化在中文分词中我们可以利用“大多数汉字作为单字词的概率”或“某些字几乎只作为词首出现”等启发式知识来设置初始的π和B。先用有监督数据预热如果可能收集一小部分高质量标注数据用频率统计得到一组较好的初始参数再用大量无标注数据通过BW算法精调。5.3 模型复杂度与数据稀疏性HMM的参数规模是N^2 N*M。当状态数N如分词中的标签集或观测数M如汉字字符集很大时参数数量剧增。如果训练数据不足会导致严重的数据稀疏问题即很多转移或发射事件在训练集中从未出现其概率被估计为0或接近0影响模型泛化能力。解决方法平滑技术如前所述平滑是必须的。拉普拉斯平滑加一平滑是最简单的但可能不是最优的。对于A矩阵可以考虑使用更精细的平滑。状态聚类对于观测符号如汉字可以按其本身特征字形、字频或上下文分布进行聚类将M个观测归约为K个聚类KM用聚类ID作为观测从而大幅减少B矩阵的参数。这其实就是引入了“子词”或“字符类别”的概念。特征化HMM这是向判别式模型迈进的一步。传统的HMM的发射概率B[j][o]是生成式的。我们可以将其替换为基于特征的函数例如P(o|j) ∝ exp(θ·f(j, o))其中f是特征向量。这样可以利用丰富的特征且参数θ的数量与特征维度相关而非与M直接相关。这已经非常接近线性链条件随机场的思路了。5.4 超越一阶马尔可夫假设标准HMM是一阶的即当前状态只依赖于前一个状态。但在很多语言现象中依赖关系可能更长。例如在分词中一个“B”标签后面出现“E”的概率可能很低因为中间通常需要“M”这种约束涉及了更长的上下文。扩展思路高阶HMM直接建模当前状态对前k个状态的依赖。但这样会导致状态空间爆炸状态数变为N^k计算和训练复杂度急剧上升。隐式建模长依赖使用更强大的序列模型如条件随机场。CRF是判别式模型可以直接定义整个标签序列的全局特征例如“如果标签序列中出现‘B’后面直接跟‘E’则罚分”从而以更灵活的方式捕捉长距离依赖和约束这在分词、命名实体识别等任务上比HMM表现通常更好。从HMM到CRF是序列标注任务中一个非常自然的进阶路径。HMM模型以其清晰的数学模型和高效的学习、解码算法为序列问题提供了一个坚实的入门框架。理解它不仅是为了解决那些可以直接用HMM完美建模的问题更是为了给你脑中建立起一套处理序列数据的“元认知”。当你遇到更复杂的问题时你会知道HMM的哪些假设被打破了从而能更准确地选择或设计下一代模型比如CRF、RNN/LSTM乃至Transformer。从这个角度看熟练掌握HMM是通向更广阔序列建模世界的一张不可或缺的船票。