简介:这份资源面向语音识别初学者与信号处理方向的学生、研究者,聚焦基于隐马尔可夫模型(HMM)的孤立字语音识别实验,帮助读者理解如何用概率统计模型模拟发音过程并完成单字识别任务。压缩包共17个文件,以15个m脚本和2个mat数据文件为主,整体约593KB,脚本覆盖特征提取、模型训练与识别全流程,数据文件则用于训练与测试语料。资源围绕MFCC特征提取、Baum-Welch参数估计、左右型HMM构建以及维特比算法识别等核心环节展开,读者可借助完整MATLAB代码复现从建模到识别的实验过程,并通过调整状态数与实验设置观察识别性能变化。目前已有1663人学习下载,适合希望动手实践HMM语音识别、加深对状态转移与发射概率理解的读者参考。
1. 从一段“听不清”的录音说起:这套 HMM 孤立字识别资源到底能干什么
如果你手头有一段干净的单人录音,想把里面孤立说的每个字自动转成文本,又不想一上来就碰深度学习那套需要 GPU 和大数据的方案,那这份基于隐马尔可夫模型(HMM)的孤立字语音识别资源就是为你准备的。它解决的是最经典的语音识别入门问题:把每个字当成一个独立单元,用 HMM 对每个字的声学特征序列建模,识别时算哪个字的模型对当前音频的似然最高。适合做课程设计、实验复现、算法原理验证的从业者和学生,也适合想搞懂语音识别底层状态转移、观测概率、Viterbi 解码到底怎么落地的人。它不追求大词汇量连续识别,而是把孤立字这个边界清晰的问题吃透,让你能亲手跑通特征提取、模型训练、识别解码的完整链路。
2. 拆开这套资源:HMM 孤立字识别的技术骨架与选型理由
2.1 为什么孤立字识别仍然值得用 HMM 做一遍
孤立字语音识别指的是每次只识别一个单独发音的字,字与字之间有明确停顿。这个约束把问题简化了很多:不需要处理连续语音里的协同发音和切分歧义,也不用上 CTC 或注意力机制。HMM 在这里的优势是数学形式干净、训练数据需求小、每个字一个模型,识别时就是比似然。常见做法是每个字建一个从左到右的 HMM,状态数取 3 到 5,观测用 MFCC 特征,概率密度用混合高斯(GMM)拟合。这套资源的核心就是把这个流程完整实现出来,让你看到状态转移矩阵、发射概率、初始概率怎么从数据里估出来,而不是只背公式。
选型上,HMM 对孤立字的建模假设是:一个字内部的声学特征随时间变化,可以用有限个状态来近似,每个状态对应一个稳定的发音片段。比如“八”这个字,起始辅音、过渡、元音主体可以对应三个状态。状态之间只能从左往右或原地停留,不允许回退,这符合语音的时间单向性。相比深度模型,HMM 的可解释性强,你能直接检查每个状态对应的均值方差,知道模型学到了什么。对于资源有限、想快速验证识别流程的场景,这是很务实的选择。
2.2 资源里的模块划分与数据流
这套资源通常包含几个核心模块:特征提取、模型初始化、参数重估、识别解码。特征提取负责把 WAV 转成 MFCC 序列,一般取 13 维 MFCC 加一阶差分和二阶差分,凑成 39 维。模型初始化给每个字建 HMM 结构,设定状态数和混合数。参数重估用 Baum-Welch 算法迭代更新,直到似然收敛。识别解码用 Viterbi 算法算每个模型对测试音频的得分,取最高分对应的字。
数据流是这样的:原始音频先做预加重、分帧、加窗,然后 FFT 得到频谱,再经 Mel 滤波器组和 DCT 得到 MFCC。每个字的训练样本是多段该字的音频,提取出的 MFCC 序列用来训练对应的 HMM。识别时,测试音频同样提 MFCC,然后对每个字的 HMM 跑 Viterbi,得到该模型产生这段观测序列的最大概率,概率最大的字就是识别结果。整个流程不涉及语言模型,因为孤立字之间没有上下文依赖。
2.3 关键参数怎么设:状态数、混合数、帧长
参数设置直接决定识别率。状态数方面,孤立字一般取 3 到 5 个状态。状态太少,模型无法刻画字内音变;状态太多,每个状态的训练样本不足,参数估计不准。我一般会从 3 状态起步,如果发现某些字识别率明显低,再单独增加到 4 或 5。混合数指每个状态用几个高斯分量拟合观测,常见取 1、2、4、8。数据量小的时候混合数取 1 或 2,数据量充足可以加到 4 以上。混合数越大,模型容量越高,但过拟合风险也越大。
帧长和帧移是特征提取的参数。常见设置是帧长 25ms,帧移 10ms。采样率 16kHz 时,一帧 400 个采样点,帧移 160 个点。这个设置对孤立字足够,因为字音通常持续 200ms 到 500ms,能切出 20 到 50 帧。预加重系数一般取 0.97,Mel 滤波器组个数取 24 到 40,DCT 后保留 13 维。这些参数在资源里通常有默认值,但你要知道改哪里、为什么改。
2.4 训练流程的可执行步骤
下面用 Python 伪代码展示训练一个字的 HMM 的核心逻辑。实际资源里可能用 hmmlearn 或自己实现,这里以自己实现为例说明参数更新。
import numpy as np # 假设 feats 是 list,每个元素是一段该字的 MFCC 序列,形状 (T, D) # N 是状态数,M 是混合数,D 是特征维度 def init_hmm(N, M, D): # 初始概率:只从第一个状态开始 pi = np.zeros(N) pi[0] = 1.0 # 转移矩阵:从左到右,允许自环 A = np.zeros((N, N)) for i in range(N): A[i, i] = 0.5 if i < N - 1: A[i, i + 1] = 0.5 A[N - 1, N - 1] = 1.0 # 混合高斯参数:均值、方差、混合权重 means = np.random.randn(N, M, D) vars = np.ones((N, M, D)) weights = np.ones((N, M)) / M return pi, A, means, vars, weights # Baum-Welch 重估,迭代直到收敛 def baum_welch(feats_list, N, M, D, max_iter=20, tol=1e-4): pi, A, means, vars, weights = init_hmm(N, M, D) prev_ll = -np.inf for it in range(max_iter): # 累积统计量 gamma_sum = np.zeros((N, M)) mean_acc = np.zeros((N, M, D)) var_acc = np.zeros((N, M, D)) xi_sum = np.zeros((N, N)) total_ll = 0.0 for feats in feats_list: T = feats.shape[0] # 前向-后向算法计算 gamma 和 xi alpha, scale = forward(feats, pi, A, means, vars, weights) beta = backward(feats, pi, A, means, vars, weights, scale) gamma = alpha * beta gamma = gamma / gamma.sum(axis=1, keepdims=True) # 累积 gamma 和 xi for t in range(T): for i in range(N): for m in range(M): w = weights[i, m] * gaussian_pdf(feats[t], means[i, m], vars[i, m]) w = w / sum(weights[i, k] * gaussian_pdf(feats[t], means[i, k], vars[i, k]) for k in range(M)) gamma_sum[i, m] += gamma[t, i] * w mean_acc[i, m] += gamma[t, i] * w * feats[t] var_acc[i, m] += gamma[t, i] * w * (feats[t] - means[i, m]) ** 2 # 更新 xi 用于转移矩阵 for t in range(T - 1): denom = 0.0 for i in range(N): for j in range(N): denom += alpha[t, i] * A[i, j] * gaussian_pdf(feats[t + 1], means[j, 0], vars[j, 0]) * beta[t + 1, j] for i in range(N): for j in range(N): xi_sum[i, j] += alpha[t, i] * A[i, j] * gaussian_pdf(feats[t + 1], means[j, 0], vars[j, 0]) * beta[t + 1, j] / denom total_ll += np.log(scale).sum() # 更新参数 for i in range(N): for m in range(M): weights[i, m] = gamma_sum[i, m] / gamma_sum[i].sum() means[i, m] = mean_acc[i, m] / gamma_sum[i, m] vars[i, m] = var_acc[i, m] / gamma_sum[i, m] for i in range(N): for j in range(N): A[i, j] = xi_sum[i, j] / xi_sum[i].sum() # 检查收敛 if abs(total_ll - prev_ll) < tol: break prev_ll = total_ll return pi, A, means, vars, weights这段代码的逻辑是:初始化每个字的 HMM 参数,然后用 Baum-Welch 算法迭代重估。前向算法计算 alpha,后向算法计算 beta,两者结合得到 gamma 和 xi,分别用于更新混合高斯参数和转移矩阵。收敛条件是总对数似然变化小于阈值。参数说明:N 是状态数,M 是混合数,D 是特征维度,max_iter 是最大迭代次数,tol 是收敛阈值。实际跑的时候,你会看到似然值先快速上升后趋于平稳,如果一直震荡,可能是混合数太大或数据太少。
2.5 识别解码:Viterbi 怎么算、结果怎么看
识别时对每个字的 HMM 跑 Viterbi,得到该模型产生测试观测序列的最大概率。下面是对一个模型算 Viterbi 得分的函数。
def viterbi(feats, pi, A, means, vars, weights): T, D = feats.shape N = len(pi) M = means.shape[1] # 初始化 delta = np.zeros((T, N)) psi = np.zeros((T, N), dtype=int) for i in range(N): delta[0, i] = np.log(pi[i]) + np.log(sum(weights[i, m] * gaussian_pdf(feats[0], means[i, m], vars[i, m]) for m in range(M))) # 递推 for t in range(1, T): for j in range(N): probs = [] for i in range(N): if A[i, j] > 0: probs.append(delta[t - 1, i] + np.log(A[i, j])) else: probs.append(-np.inf) best = np.argmax(probs) delta[t, j] = probs[best] + np.log(sum(weights[j, m] * gaussian_pdf(feats[t], means[j, m], vars[j, m]) for m in range(M))) psi[t, j] = best # 终止 best_last = np.argmax(delta[T - 1, :]) score = delta[T - 1, best_last] return score逻辑说明:delta 存的是到当前帧为止,以某个状态结束的最大对数概率。psi 记录路径回溯。每帧对每个状态,找前一帧哪个状态转移过来得分最高。最后取最后一帧所有状态的最大值作为该模型的得分。识别时对所有字的模型算得分,取最高分对应的字。参数说明:feats 是测试音频的 MFCC 序列,pi、A、means、vars、weights 是训练好的模型参数。注意 gaussian_pdf 要对角协方差,计算时用对数避免下溢。
3. 从零跑通:数据准备、训练脚本与识别验证
3.1 音频数据怎么组织、怎么切
孤立字识别的数据组织很关键。常见做法是每个字一个文件夹,文件夹名就是字标签,里面放该字的多个 WAV 样本。采样率统一到 16kHz,单声道,16bit。每个样本时长控制在 0.3 到 0.8 秒,太长包含静音,太短可能切掉字音。如果原始音频有静音段,用短时能量或过零率做端点检测,把字音切出来。我一般会先跑一遍端点检测,把每个字的有效段提取出来,再统一长度或保留原始长度。训练时每个字至少准备 10 到 20 个样本,太少模型学不到变化,太多也没必要,孤立字本身变化有限。
数据增强方面,可以加一点高斯白噪声、轻微变速、音量扰动,但不要过度,否则 HMM 的混合高斯拟合会变差。常见做法是每个字原始样本 10 个,增强后 20 到 30 个。测试集要单独留,不能和训练集重叠。如果总样本少,用留一法交叉验证,每次留一个样本做测试,其余训练,最后统计识别率。
3.2 特征提取的代码实现与参数检查
MFCC 提取可以用 python_speech_features 或 librosa。下面用 python_speech_features 展示。
from python_speech_features import mfcc, delta import scipy.io.wavfile as wav import numpy as np def extract_mfcc(wav_path, num_ceps=13, winlen=0.025, winstep=0.010): # 读取音频 sr, sig = wav.read(wav_path) # 如果是双声道,转单声道 if len(sig.shape) > 1: sig = sig.mean(axis=1) # 提取 13 维 MFCC mfcc_feat = mfcc(sig, samplerate=sr, numcep=num_ceps, winlen=winlen, winstep=winstep, nfilt=26, nfft=512) # 一阶差分和二阶差分 d1 = delta(mfcc_feat, 2) d2 = delta(d1, 2) # 拼接成 39 维 feat = np.hstack([mfcc_feat, d1, d2]) # 倒谱均值归一化,减少信道影响 feat = feat - feat.mean(axis=0) return feat逻辑说明:先读 WAV,转单声道,然后提 MFCC。num_ceps 是倒谱系数个数,winlen 是帧长秒数,winstep 是帧移秒数,nfilt 是 Mel 滤波器个数,nfft 是 FFT 点数。提完 MFCC 后加一阶和二阶差分,拼成 39 维。最后做倒谱均值归一化,这是常见做法,能提升鲁棒性。参数检查:如果采样率不是 16kHz,mfcc 函数会自动适配,但最好统一重采样。如果音频太短,帧数不够,Viterbi 会报错,所以端点检测后要过滤掉过短样本。
3.3 训练脚本的调用与日志观察
训练脚本一般会遍历每个字的文件夹,提取所有样本的 MFCC,然后对每个字训练一个 HMM。下面是一个调用示例。
import os import numpy as np def train_all(data_root, N=3, M=2, max_iter=20): models = {} for label in os.listdir(data_root): label_dir = os.path.join(data_root, label) if not os.path.isdir(label_dir): continue feats_list = [] for fname in os.listdir(label_dir): if fname.endswith('.wav'): feat = extract_mfcc(os.path.join(label_dir, fname)) feats_list.append(feat) print(f'训练 {label},样本数 {len(feats_list)}') pi, A, means, vars, weights = baum_welch(feats_list, N, M, feats_list[0].shape[1]) models[label] = (pi, A, means, vars, weights) return models逻辑说明:遍历数据根目录下的每个字文件夹,提取所有 WAV 的 MFCC,然后调用 baum_welch 训练。N 和 M 是状态数和混合数,max_iter 是迭代次数。训练过程中打印每个字的样本数,方便检查数据是否均衡。如果某个字样本数明显少,识别率会偏低。日志里还会看到似然值变化,如果某次迭代后似然下降,说明参数更新有问题,常见原因是混合高斯方差太小导致数值不稳定,可以加一个方差下限。
3.4 识别验证与混淆矩阵
识别时对测试集每个样本,算所有字的 Viterbi 得分,取最高分。下面是一个批量识别的函数。
def recognize(test_feats, models): scores = {} for label, (pi, A, means, vars, weights) in models.items(): score = viterbi(test_feats, pi, A, means, vars, weights) scores[label] = score # 返回得分最高的字 return max(scores, key=scores.get), scores def evaluate(test_root, models): correct = 0 total = 0 confusion = {} for label in os.listdir(test_root): label_dir = os.path.join(test_root, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): if fname.endswith('.wav'): feat = extract_mfcc(os.path.join(label_dir, fname)) pred, scores = recognize(feat, models) total += 1 if pred == label: correct += 1 confusion[(label, pred)] = confusion.get((label, pred), 0) + 1 print(f'识别率: {correct / total:.2%}') return confusion逻辑说明:对每个测试样本,遍历所有模型算 Viterbi 得分,取最高分对应的字作为预测。统计正确数和总数得到识别率。混淆矩阵记录每个真实字被预测成什么,方便分析哪些字容易混。常见混淆发生在发音相近的字之间,比如“四”和“十”。如果混淆严重,可以增加状态数或混合数,或者检查端点检测是否切掉了关键音段。
4. 避坑与排查:HMM 孤立字识别里最容易翻车的五件事
4.1 识别率极低,所有样本都预测成同一个字
现象:测试时不管输入什么字,输出都是同一个标签。原因:某个字的模型参数初始化后没有有效更新,或者该字的似然计算因为数值下溢变成负无穷,导致其他字得分更低。更常见的是训练时某个字样本太少,混合高斯方差趋近于零,Viterbi 得分异常高。解决:检查每个字的训练样本数,少于 5 个的先补数据。在 gaussian_pdf 里加方差下限,比如 1e-3。训练后打印每个字的平均似然,如果某个字明显偏离,单独排查。
4.2 训练过程中似然值震荡不收敛
现象:Baum-Welch 迭代时对数似然先升后降,或者来回跳。原因:混合数设得太大,数据量不够,参数过拟合。或者方差更新时没有加下限,导致某些高斯分量方差趋近零,数值不稳定。解决:把混合数从 4 或 8 降到 1 或 2。在方差更新后加vars = np.maximum(vars, 1e-3)。如果还震荡,减少状态数,从 5 降到 3。另外检查特征是否做了均值归一化,没做的话不同样本的均值差异会干扰训练。
4.3 端点检测切掉了字音开头或结尾
现象:识别率不高,但训练似然正常,测试时某些字总是错。原因:端点检测的阈值太激进,把字音的起始辅音或结尾元音切掉了。孤立字的辅音段能量低,容易被当成静音。解决:用短时能量和过零率双门限,能量阈值不要设太高,保留前后各 50ms 的余量。或者手动检查几个样本的波形,确认切出来的段包含完整字音。如果资源里自带端点检测,先跑一遍听一下切出来的音频,确认没切错。
4.4 采样率或声道数不统一导致特征错乱
现象:训练时正常,测试时识别率骤降。原因:训练集是 16kHz 单声道,测试集是 44.1kHz 双声道,MFCC 提取时帧长对应的采样点数变了,特征分布偏移。解决:统一重采样到 16kHz,双声道转单声道。在 extract_mfcc 里加判断,如果采样率不是 16000,用 scipy.signal.resample 重采样。这个坑很隐蔽,因为代码不会报错,但特征已经不对了。
4.5 模型保存与加载时参数顺序错位
现象:训练完保存模型,重新加载后识别结果全乱。原因:保存时把 pi、A、means、vars、weights 按顺序存,加载时读的顺序不一致,或者 numpy 数组形状变了。解决:用字典保存,每个参数带键名,加载时按键名取。或者用 pickle 直接存整个模型对象。加载后打印每个参数的形状,确认和训练时一致。这个坑在跨脚本调用时特别常见,血泪经验是每次加载后先跑一个训练样本,看得分是否和训练时一致。
5. 进阶技巧:用后验概率和模型融合把识别率再提一截
5.1 用前向-后向算后验概率做置信度
Viterbi 给的是最大路径概率,但有时候最大路径不代表模型整体匹配好。更稳的做法是算前向-后向得到的观测序列总似然,或者算每个帧的后验概率。具体来说,对测试音频,用前向算法算 alpha,后向算法算 beta,总似然是 alpha 最后一帧的和。这个总似然比 Viterbi 得分更平滑,对噪声鲁棒。我一般会同时算 Viterbi 得分和总似然,两个得分加权融合,权重用验证集调。常见做法是总似然权重 0.6,Viterbi 权重 0.4,识别率能提 2 到 5 个百分点。
5.2 多模型融合:不同状态数和混合数的模型投票
单个 HMM 的参数选择有随机性,状态数 3 和 4 可能各有优劣。可以训练多个配置的模型,比如 (N=3, M=1)、(N=3, M=2)、(N=4, M=2),识别时每个配置投一票,得票最多的字作为最终结果。如果票数相同,看得分总和。这个做法在数据量小的时候特别有效,因为不同配置的模型偏差不同,融合能抵消一部分过拟合。代价是训练和识别时间成倍增加,但孤立字识别本身计算量不大,多训几个模型完全可行。
5.3 用对数域计算避免下溢
HMM 的前向-后向和 Viterbi 都涉及大量概率连乘,很容易下溢。常见做法是全程用对数域,加法代替乘法。但混合高斯求和时,对数域加法要用 logsumexp。下面是一个 logsumexp 的实现。
def logsumexp(x): # x 是数组 max_x = np.max(x) return max_x + np.log(np.sum(np.exp(x - max_x)))在计算每个状态的发射概率时,先算每个混合分量的对数概率,然后用 logsumexp 合并。这样整个 Viterbi 和前向-后向都在对数域,不会出现下溢。参数说明:x 是混合分量的对数概率数组,max_x 是最大值,减去最大值再 exp 求和取对数,避免 exp 溢出。这个技巧在混合数大于 4 时尤其重要,因为多个小概率相乘很容易变成 0。
5.4 验证方法:留一交叉验证和混淆矩阵分析
数据少的时候,留一交叉验证比固定划分测试集更可靠。每次留一个样本做测试,其余训练,统计所有样本的识别率。虽然训练次数多,但孤立字样本通常几十个,跑一遍几分钟。混淆矩阵要重点看,如果某两个字互相混淆严重,说明它们的声学特征太接近,可以考虑合并状态或者增加区分性特征,比如加入基频或共振峰。我一般会先跑留一法得到基线识别率,然后针对混淆最多的字对做参数调整,再跑一遍看提升。
从那以后我每次跑 HMM 孤立字识别,都强制先做三件事:检查采样率和声道数是否统一、打印每个字的训练样本数和平均似然、跑一遍留一交叉验证看混淆矩阵。这三步走完,基本能避开八成翻车。希望帮到你。
本文还有配套的精品资源,点击获取