很多朋友一听到“语音识别”四个字,第一反应就是神经网络、端到端、大模型。但如果往根上刨,语音识别真正普及的经典底座,其实是一套非常朴实、也非常优雅的概率统计框架。最近我在做一个小型孤立词识别项目,标题就叫“基于高斯混合模型(GMM)的语音识别系统:MATLAB实现与核心原理”,这里头没有用任何深度学习的组件,纯粹靠GMM把数字0到9的发音给认出来。这篇文章就把整个项目怎么从原理走到落地的过程完整拆一遍,包括特征怎么提、GMM怎么训、EM算法到底在干什么、MATLAB代码怎么组织、以及我踩过的那些坑。想动手写一个自己的语音识别Demo、或者想搞懂统计声学模型底层逻辑的同学,可以把它当成一份带注释的“作业参考答案”来看。
要用GMM做语音识别,首先要明确一个边界:GMM本身不是一个时序模型,它描述的是“一帧特征长什么样”的概率分布。语音是一段随时间变化的信号,所以这个系统最适合的场景是孤立词识别、小词汇量指令识别,或者配合HMM(隐马尔可夫模型)去做连续语音识别中的观测概率建模。我这次做的是孤立词识别,好处是结构清晰、训练简单,能把GMM的核心逻辑完整暴露出来,非常适合用来理解语音识别最底层的数学直觉。
1. 项目定位与整体技术路线
1.1 这个系统到底在解决什么问题
先说说我在这个项目里定义的任务:输入一段录音,内容是中文数字“0”到“9”中的一个,系统判断它到底是哪个数字。从表面上看,这像是一个分类问题:把音频映射到10个类别之一。但实际上它比普通的图像分类要恶心不少,因为语音不是一张静态的像素图,而是一长串随时间和声带状态连续变化的数据。
同样一个“三”,不同的人说出来,长度不一样、基频不一样、共振峰位置不一样、语速不一样,甚至同一个人在不同情绪下说出来的声学特征都有明显差异。GMM干的事情,就是针对每个类别,训练出一个概率密度函数,用来回答一个问题:假如已知这串特征来自“三”这个类别,那么观测到当前这段特征的概率有多大?到了识别阶段,我处理完测试音频,提取出特征序列,然后分别计算它在10个GMM模型下的对数似然,哪个模型给出的似然最高,就判定为哪个数字。
有一点需要提前讲清楚:GMM假设观测样本之间相互独立。这意味着我输出的每一帧特征,都被当成一个独立样本,不考虑句子内部的时序依赖。这当然是简化,但这恰恰是入门语音识别最好的切入点。等你把GMM这套东西完全玩明白了,再去接触HMM-GMM、GMM-UBM说话人识别,或者直接跨到端到端模型,都会有很扎实的底子。
1.2 都2025年了,为什么还要选GMM而不是端到端模型
每次我拿这个项目跟别人聊,总有人问:现在随便拿个开源框架训一个深度学习模型,识别准确率秒杀GMM,为什么还要费力去研究它?我的回答很简单:因为GMM能把“特征空间里概率分布长什么样”这件事用可视化、可解释的方式展现出来,而神经网络本质上是一个难以直接理解的高维函数逼近器。
另一个很实际的原因是训练成本和硬件门槛。训练一个GMM的EM算法,在普通CPU上跑完只需要几十秒到几分钟,数据量要求也远低于深度模型。它非常适合做教学、做原型验证,也适合嵌入式场景里快速训练一个跟设备相关的模型。像我这次用的MATLAB环境,一把循环就能写完EM,不需要装深度学习工具箱,不需要GPU,不需要调学习率,连CUDA都跟你没关系。
还有一点容易被忽略:GMM是最简单的一类生成式模型。它能告诉你每个特征的分布中心在哪、离散程度如何、哪些维度之间相关性强。这些信息在做特征分析、模型剪枝、异常检测的时候都很有价值。比如我后来分析混淆原因,就是靠观察两个类别GMM的均值向量和协方差对角元素,直接定位到哪个频段的特征区分度不够。这个能力是黑盒分类器给不了的。
1.3 系统结构与模块划分
整个系统的数据流是典型的“前端特征提取 + 后端统计建模”结构。
音频输入之后,第一步是预处理,包括预加重、分帧、加窗,目的是把声门激励和唇辐射带来的高频衰减补偿回来,同时把连续信号切成一帧一帧的短时平稳片段。第二步是特征提取,从每一帧里计算MFCC系数,得到一组低维、去相关、符合人耳听觉特性的特征向量。第三步是训练阶段,把同一类别的所有特征帧收集起来,用EM算法迭代估计GMM参数。第四步是识别阶段,对测试语音同样提特征,然后计算每个类别的对数似然,取最大类别作为输出。
我建议你把这个流程牢牢记在心里,因为不管是GMM、HMM-GMM还是深度学习方法,前端部分几乎都是这套路子。Feature extraction做得好不好,往往比分类器选什么对最终准确率影响更大。我后面会把每一层细节都拆开讲。
2. 语音特征提取:MFCC为什么是标准答案
2.1 从波形到特征:预加重、分帧、加窗
很多人第一次接触语音识别就被“预加重、分帧、加窗”这三个词劝退了。其实它们一点都不神秘,只是为了把一段粗糙的波形整理成适合建模的样本集合。
首先说预加重。人在发声时,声门波的能量大致按6dB/倍频程的斜率衰减,也就是说高频部分的能量天然偏低。如果不做处理,后续MFCC计算时高频段的特征会被低频淹没。预加重本质上就是一个高通滤波器,我在这里用了一阶差分:
% 预加重系数通常取0.95~0.98 y_filtered = filter([1, -0.97], 1, x);这段代码的意思是把当前样本减去前一个样本的0.97倍,这会让信号的相对变化部分被放大,高频能量得到补偿。
然后是分帧。语音信号是时变的,但在20~30毫秒这个尺度内可以近似看成平稳信号。所以我用25毫秒的帧长、10毫秒的帧移把整段信号切成很多个小块,相邻帧之间有一半以上的重叠。这里必须给新手提个醒:帧移小于帧长不是闲着没事干,而是为了让相邻帧之间平滑过渡,避免特征序列在时间轴上发生突变。
frame_len = 25/1000 * fs; % 每帧采样点数 frame_shift = 10/1000 * fs; % 帧移采样点数 frame_number = floor((length(x) - frame_len) / frame_shift) + 1; frames = zeros(frame_number, frame_len); for i = 1:frame_number start_idx = (i-1)*frame_shift + 1; frames(i,:) = y_filtered(start_idx : start_idx+frame_len-1); end分完帧之后必须加窗,否则截断处会出现频谱泄漏,本来应该集中在某个频率上的能量会散到旁瓣去。常用的窗函数有汉明窗、汉宁窗。我选汉明窗,因为它的主瓣宽度和旁瓣衰减在语音识别场景中表现均衡:
window = hamming(frame_len, 'periodic'); frames = frames .* repmat(window.', size(frames,1), 1);这里有个容易忽略的细节:MATLAB里hamming默认是对称窗,我加了'periodic'参数,因为后续要做FFT分析,周期窗能避免IDFT后的边缘不连续问题。
2.2 MFCC计算流程与MATLAB实现
MFCC是目前语音识别和说话人识别领域最经典的特征。它提取的核心思路是:人耳对频率的感知不是线性的,低频区域分辨率高,高频区域分辨率低。于是MFCC先把信号的频谱映射到Mel刻度上,再取对数、做DCT,最终得到一组近似解相关的系数。
完整流程分四步。第一步,对每一帧加窗后的信号做FFT,取幅度谱的平方得到功率谱。第二步,将功率谱通过一组Mel滤波器组,得到每个频带的能量。第三步,对每个频带的能量取对数。第四步,对对数能量做离散余弦变换,取前若干个数作为MFCC系数。
function mfcc_feat = compute_mfcc(x, fs) % MFCC参数设置 num_filters = 26; % 滤波器个数 num_ceps = 13; % 取前13维系数 frame_len = 25/1000 * fs; frame_shift = 10/1000 * fs; % 预加重 x = filter([1, -0.97], 1, x); % 分帧加窗 n_frames = floor((length(x)-frame_len)/frame_shift) + 1; frames = zeros(n_frames, frame_len); for i = 1:n_frames start_idx = (i-1)*frame_shift + 1; frames(i,:) = x(start_idx:start_idx+frame_len-1); end frames = frames .* hamming(frame_len, 'periodic').'; % FFT和功率谱 NFFT = 512; power_spectrum = abs(fft(frames, NFFT, 2)).^2; % Mel滤波器组 mel_filter = design_mel_filterbank(fs, NFFT, num_filters); mel_energies = power_spectrum * mel_filter'; % 对数压缩 log_mel_energies = log(mel_energies + eps); % DCT mfcc_feat = dct(log_mel_energies')'; mfcc_feat = mfcc_feat(:, 1:num_ceps); end这里design_mel_filterbank是自实现的Mel滤波器组构造函数。它首先要定义Mel刻度公式,Mel频率和普通频率之间的关系是mel = 2595 * log10(1 + f/700)。然后在这个刻度上均匀放置三角滤波器。很多老代码会直接调用melbank函数,新版MATLAB里它已经被移除,所以我会建议自己写一个,也不复杂。
我自己习惯用26个滤波器组,这个数量不算多,但经实验足够支撑10个数字的孤立词识别。如果你觉得识别率偏低,可以尝试加大到40,但训练特征维度也会跟着涨,GMM参数估计所需的样本量同样要增加。
2.3 特征拼接与归一化
只提取13维静态MFCC,识别时总感觉抓不住语音的动态变化。语音识别里的常见做法是把13维MFCC和一阶差分、二阶差分拼起来,组成39维的特征向量。差分系数相当于语音特征在时间轴上的“速度”和“加速度”,能捕捉到声学特征的动态变化趋势。
% 计算一阶差分 delta = zeros(size(mfcc_feat)); delta(2:end, :) = diff(mfcc_feat, 1, 1); % 计算二阶差分 delta2 = zeros(size(delta)); delta2(2:end, :) = diff(delta, 1, 1); % 拼接 feat_all = [mfcc_feat, delta, delta2];不过我说实话,在这个项目的背景下,加差分特征带来的收益没有想象的那么大,大约只提升了两三个百分点的识别率,而特征维度从13翻到39,GMM参数数量暴涨,训练时间也变长了。我最后保留了差分,是因为在噪声环境下差分特征能略微提高鲁棒性。如果你只是为了快速跑通,只保留静态13维就够了。
特征归一化也很重要。不同录音的音量差异会导致MFCC的幅度分布不一致,所以在训练之前我会对每个维度做均值方差的标准化。注意这里必须用训练集统计出来的均值和标准差去归一化测试集,千万不要混在一起算,否则会把测试集信息泄漏到训练过程中,得到虚高的识别率。
3. GMM核心原理与MATLAB实现
3.1 高斯混合模型是怎么描述语音的
GMM的出发点很简单:单高斯分布只能描述一个中心、一种聚集程度的数据。但人的语音特征分布是典型的“多峰”形态。比如“四”这个数字包含高频的擦音段和较低的元音段,特征空间里会呈现出好几个聚集区域。这时你用单个高斯去拟合,结果就是所有数据被平均到一个中心,既描述不好擦音区域,也描述不好元音区域,似然很低。
GMM的做法是准备K个高斯分量,每个高斯有自己的均值向量和协方差矩阵,同时有一个权重表示这个分量在所有样本中的占比。整个模型变成这些高斯分量的加权求和:
$$p(x) = \sum_{k=1}^{K} \pi_k \cdot \mathcal{N}(x \mid \mu_k, \Sigma_k)$$
你可以把GMM想象成:“特征空间里有好几座山,每座山是一个高斯,山的高度由权重决定”。我最初的实验里K=8,也就是用8座高斯山去描述每个数字的特征分布。
这里有一个特别重要的点需要解释清楚:每个高斯分量其实在某种程度上对应了语音中一个“隐状态”。比如某个分量可能主要描述清辅音部分的特征,另一个分量描述元音部分的特征。通过EM算法,模型自己会学会把数据划分到不同分量上,我们不需要手工标注每一帧到底属于哪一类。
3.2 EM算法:期望与最大化
GMM的参数不能直接求导拿解析解,因为每个样本来自哪个分量是一个未知的隐变量。EM算法就是专门处理这种含隐变量问题的迭代优化算法。
整个训练过程可以这样理解:我先随便给出一组GMM参数的初始值,然后反复执行两步。E步,固定当前参数,对每个特征样本计算它落在每个分量上的后验概率,相当于“猜测这个样本属于哪个高斯分量”;M步,用这些后验概率作为权重,重新计算每个分量的均值、协方差和混合权重,让似然函数在这组新的参数下更大。重复这个过程,直到对数似然的变化量小于预设阈值。
% 单类GMM训练的EM核心循环 max_iter = 50; log_likelihood_old = -inf; for iter = 1:max_iter % E步:计算后验概率矩阵 N x K resp = zeros(N, K); for k = 1:K resp(:, k) = weight(k) * mvnpdf(data, mu(k,:), sigma(:,:,k)); end resp = resp ./ sum(resp, 2); % M步:更新参数 Nk = sum(resp, 1); weight_new = Nk / N; mu_new = zeros(K, D); sigma_new = zeros(D, D, K); for k = 1:K mu_new(k, :) = sum(resp(:, k) .* data, 1) / Nk(k); diff_mat = data - mu_new(k, :); sigma_new(:, :, k) = (resp(:, k) .* diff_mat)' * diff_mat / Nk(k); end % 更新参数 weight = weight_new; mu = mu_new; sigma = sigma_new; % 计算当前对数似然 log_likelihood = sum(log(sum(resp, 2) + eps)); if abs(log_likelihood - log_likelihood_old) < 1e-4 break; end log_likelihood_old = log_likelihood; end这段代码里我故意用了最朴素的方式实现,它很好理解,但性能不是最优。如果你的训练数据特别多,建议用gmfit函数或者统计工具箱里的fitgmdist,它在数值稳定性上做了很多优化,比如防止协方差矩阵奇异、防止某分量退化。实际项目里能用现成的函数就不要自己手写,但原理一定要懂,否则出了问题你根本不知道怎么排查。
有一点我必须强调:EM算法对初始值非常敏感。如果初始K个高斯分量全堆在同一个位置,迭代半天可能只更新出一个有效分量,其他分量权重变成0,模型退化成一个单高斯。所以在EM之前,我会先用K-means对整个训练数据做聚类,把聚类中心作为GMM的初始均值,再用各簇内样本的协方差作为初始协方差。
3.3 混合数选择与协方差约束
GMM超参数里对结果影响最大的是混合数K。K太小,模型拟合能力不够,描述不了语音特征的多峰分布;K太大,模型复杂度上去了,但每个分量的训练样本变少,容易发生过拟合。我针对10个数字做过一组对照实验,K从2、4、8、16、32往上加,结论是最优区间在8到16之间。
协方差矩阵的处理方式同样关键。理论上每个高斯分量可以拥有独立的满协方差矩阵,但这对训练样本量要求很高。比如39维特征、8个分量,满协方差矩阵需要估计39*40/2 ≈ 780个参数,8个分量就有6240个参数要估计。如果每类数字只有几百帧特征,直接用满协方差大概率得到奇异矩阵。所以我实际使用的是对角协方差矩阵,也就是说假设每个特征维度之间条件独立。这当然损失了一部分相关性信息,但对绝大部分语音识别工程而言,对角协方差在效果和稳健性之间是最划算的,我的实验也证实了这一点。
% 使用对角协方差训练GMM for k = 1:K sigma(:, :, k) = diag(var(data, 1, 1)); end3.4 GMM训练代码解析
训练逻辑本身不复杂:读取所有类别为c的训练音频文件,对每个文件提MFCC特征,把所有帧拼接成一个大的特征矩阵,然后对这个矩阵执行一次GMM训练,得到一个类专属的GMM模型。整个过程可以用一个循环完事。
gmm_models = cell(10, 1); for digit = 0:9 feat_all = collect_features(digit, train_files, train_labels); gmm_models{digit+1} = train_gmm(feat_all, K, 'diag'); endcollect_features函数负责遍历该类别的所有训练音频,提取静态和动态MFCC特征,最后返回一个大矩阵。train_gmm负责执行高斯混合模型参数估计,输出一个包含权重、均值、协方差的结构体。这里有一个工程细节:不同的录音片段长度差异很大,有的说一遍“零”可能0.4秒,有的话唠一点的能到0.8秒。所以每类的总帧数会不同。我在训练时不会因为帧数差异做任何加权,这是世界通用的常规做法,因为每类数字的先验概率本来就不同。
另外一个我特别想提醒的坑:如果你的训练集只有一个人发音,那么这套模型本质上就是一个“特定人识别器”。换一个人来测试,识别率可能掉得惨不忍睹。因为这个人的音色、语速、基频都已经写死在GMM的均值向量里了。正规做法是录多个人的声音混在一起训练,每个数字至少10个以上不同人的样本,这样GMM才能学到“类别共性”而不是“某个人说话的特征”。
4. 模型训练与识别解码全过程
4.1 数据准备与标注
数据是一切实验的前提。我用的数据包括若干位发音人录制的数字0到9,每人每个数字录了多遍,采样率统一为16kHz、16bit单声道WAV。录音环境在普通的室内,有一些轻微的底噪。
数据必须严格切分训练集和测试集。我的做法是:按人划分,保证训练集和测试集里没有同一个人同一条语音的重复。也就是说训练集里出现了某人的录音,测试集里还可以出现这个人没录过的新样本。这样做能避免“记忆”效应,测出来的结果更真实。如果你把同一条录音既放训练又放测试,识别率能虚高到99%,但没有任何实用价值,这不是能力问题,这是作弊。
表如下,是我常用的数据划分方式:
| 数据角色 | 内容说明 | 数量 |
|---|---|---|
| 训练集 | 用于估计GMM参数,也就是EM迭代数据 | 720条(8人×10数字×9遍) |
| 验证集 | 用于调整K值、特征维度等超参数,不参与训练 | 90条(新录制) |
| 测试集 | 最终评估识别效果,全程不接触 | 180条(多人的新数据) |
4.2 训练流程设计
训练流程并不复杂,但有一个关键点容易被新手忽略:GMM描述的特征必须是“同一类别的共性特征”,而不是“每个录音片段的专属特征”。所以我把同一类别下所有文件的特征全部拼起来一起训练,而不是对每个文件各自训一个GMM再把GMM参数平均。后者看着合理,实则大错特错,因为不同录音长度不同,各自GMM的分量对应关系是错乱的,平均参数毫无意义。
训练之前还有一步:对特征做标准化。我用训练集各维的均值和标准差对特征做归一化,然后训练GMM。这里必须把训练集的均值和标准差保存下来,测试阶段直接使用相同数值。如果测试时重新计算测试集的统计量,相当于给模型泄漏了测试集的分布信息,结果一样会被污染。
训练过程中还可加一个条件:如果某类数据量特别少,可以适当减少混合数。比如“8”这个音在某些语速下发音非常短,特征帧数比“零”少了一半,给K=16的GMM会导致分量快速退化。我的策略是按类别做一次基础的帧数统计,低于阈值的类别自动把K下调一档,效果比统一用固定K要好一些。
4.3 识别解码与似然比对
识别阶段是整个系统的临门一脚。测试音频输入后,先做和训练阶段完全相同的前端处理:预加重、分帧、加窗、提取MFCC、拼接差分、用训练集的统计量做归一化。
然后进入核心决策部分。对每一类数字的GMM,计算该测试音频每一帧特征的对数似然,把所有帧的对数似然求和再除以帧数,得到平均对数似然。选平均对数似然最大的类别作为识别结果。除以帧数非常关键,因为有的词音长长,有的词音长短,如果不归一化,长音频的似然天然占优,识别结果会整体偏向音量长、帧数多的类别。
% 识别单个测试文件 function predict_label = recognize_one(file_path, gmm_models, norm_params) feat = compute_mfcc(file_path, fs); feat = apply_norm(feat, norm_params); n_frames = size(feat, 1); log_likelihoods = zeros(10, 1); for digit = 0:9 LL = 0; for i = 1:n_frames for k = 1:K LL = LL + log(gmm_models{digit+1}.weight(k)) + ... logmvnpdf_single(feat(i,:), gmm_models{digit+1}.mu(k,:), ... gmm_models{digit+1}.sigma(:,:,k)); end end log_likelihoods(digit+1) = LL / n_frames; end [~, predict_label] = max(log_likelihoods); predict_label = predict_label - 1; end这段代码里我用了logmvnpdf_single这个函数,避免直接用mvnpdf也算完又取log导致数值下溢。实际计算时,log高斯密度公式展开后无非是二次项加常数项,完全可以把矩阵求逆运算提前到训练阶段做好。否则测试时每帧每个分量都做一次矩阵求逆,计算量会非常扎眼,几十条测试数据跑下来就开始冒汗了。
我实际测试了识别速度:10个类、K=8、每帧39维特征,单条0.5秒的语音大约几百帧,MATLAB下识别耗时在几十毫秒量级。这个速度完全可以做到准实时。
5. 实验结果与参数调优记录
5.1 基线实验结果
我的基线配置是:训练集720条,MFCC静态13维加一阶差分和二阶差分共39维,K=8,对角协方差,EM迭代最大50次,收敛阈值1e-4。在这个配置下,测试集180条音频的整体识别准确率大概在91.7%左右。乍一看还行,但仔细看混淆矩阵,问题还是很明显。
| 预测实际 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 17 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
| 1 | 0 | 18 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2 | 0 | 0 | 16 | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
| 3 | 0 | 0 | 1 | 16 | 0 | 0 | 0 | 1 | 0 | 0 |
| 4 | 0 | 0 | 1 | 0 | 17 | 0 | 0 | 0 | 0 | 0 |
| 5 | 0 | 0 | 0 | 0 | 1 | 15 | 2 | 0 | 0 | 0 |
| 6 | 0 | 0 | 0 | 0 | 0 | 2 | 16 | 0 | 0 | 0 |
| 7 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 17 | 0 | 0 |
| 8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 18 | 0 |
| 9 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 17 |
最容易混淆的是“5”和“6”,这俩韵母部分都带高元音特征,且语速稍快时元音时长缩短,区分度明显下降。“2”和“3”的混淆则是因为两者以卷舌音开头,辅音段特征非常接近,GMM没办法把辅音段的差异拉开。
5.2 混合数与特征维度的影响
我后来针对K值做了几轮实验。固定其余条件不变,K=4时准确率87.2%,K=8时91.7%,K=16时92.2%,K=32时又掉回88.9%。这类曲线在统计分类器里极其典型:K太小欠拟合,K太大过拟合。对这个小样本任务来说,8到16是一个合理的区间。
特征维度的实验结果也很有意思。只用静态13维MFCC,准确率88.4%;加上一阶差分后提高到91.1%;再加上二阶差分只到91.7%。二阶差分的边际收益已经非常微弱,但训练时间涨了不少,因为特征维度越大,每次M步协方差估计的计算量越高。如果你的目标是快速复现,建议直接13维或者26维就好,性价比高很多。
5.3 时间信息缺失的补偿手段
GMM最大的缺点是“只管帧、不管顺序”。为了补偿时间信息缺失,我在项目里做了一个轻量级的扩展:把一条语音的MFCC特征先用向量量化方式聚类成几个“时序块”,再统计每个块里的特征分布,把块间的转移频次作为额外特征输入到GMM中。但这本质上已经长得很像HMM了,所以项目做了两三天就停了,主要目的是确认“GMM丢失了多少时序信息”。
我的结论是:在孤立词识别里,单纯用GMM足够应付大多数情况,因为每个词的发音在声学特征上本身就有许多独特之处。但到了连续语音、大词汇量、多说话人场景,GMM不做时序建模是不行的,必须跟HMM结合。如果有同学想往下深入,我建议把HMM-GMM这套联合训练弄明白,那才是经典语音识别最核心的一块拼图。
6. 常见问题与排错经验
6.1 对数似然出现NaN或者Inf
这是GMM新手必踩的坑。对数似然变成NaN,绝大部分原因是协方差矩阵奇异或趋近奇异。特征中有某个维度的方差接近0,或者训练样本太少而特征维度过高,在高斯密度计算中就会出现sqrt(det(2*pi*sigma))为Inf或0,进而导致log为NaN。
解决办法有这么几个。最立竿见影的是强制协方差矩阵加一个小的对角扰动,比如reg_covar=1e-6。在MATLAB中就是sigma = sigma + 1e-6 * eye(D)。另外就是改用对角协方差,并检查数据里是否存在全零列——比如某一段录音静音过长,提取出的MFCC某维基本恒定为0,标准化后依然会出现方差为0的风险。我排查这类问题的方法是打印每个GMM分量的协方差对角值,凡是出现数量级小于1e-10的元素,就说明那一维特征分量为零或者被归一化到几乎为零,要检查前端特征提取。
6.2 识别结果总是偏向某一个类别
这种现象通常不是因为“那类模型特别好”,而是因为某一个类别的训练帧数特别多,导致其GMM的对数似然天然偏高。虽然我在识别阶段对总帧数做了归一化,但不同类别的模型在相同输入下仍然会有绝对对数似然水平的系统性差异。
解决办法是给各类模型的似然值做一个校准。我采用的方法是:在验证集上统计每个类别模型的平均对数似然,然后为每个类别算一个减掉的偏置项,让验证集上每个类别的平均加速接近一致。这其实就是简单的先验补偿。实际效果很明显,怪异偏向问题立刻消失。
6.3 更换说话人后识别率断崖式下跌
换人之后的识别率能跌到60%以下,属于特定人模型的正常现象。GMM的均值向量包含了训练说话人的声道长度和音色信息,新说话人的共振峰位置整体平移之后,特征分布会偏移出原有高斯的有效覆盖范围。解决思路是扩充训练集说话人数量,或者在特征层面做说话人归一化。
在实践中最直接的做法是录音环节就覆盖多个男女声、不同年龄段,并且在训练时把每个人的录音条数基本对齐,防止主流音色占据主导。如果想更进一步,可以把MFCC通过声道长度归一化(VTLN)或者用倒谱均值减(CMS)消除录音通道和说话人带来的静态偏移。CMS实现起来很便宜,就是减掉整条语音特征各维度的均值,在我的实验里光这一招就让跨说话人测试准确率提升了将近五个百分点,强烈建议你加上。
6.4 快速自检清单
| 现象 | 优先排查项 |
|---|---|
| 训练过程NaN | 协方差奇异、特征恒定、初始化不当 |
| 训练收敛太慢 | 迭代次数太少、K值过大、没有用K-means初始化 |
| 识别偏向一类 | 帧数不均、未校准、先验不平衡 |
| 换人识别率低 | 训练集说话人少、未做CMS、特征归一化参数错误 |
| 耗时严重膨胀 | 特征维度过高、预测时重复求逆、K值设定过大 |
最后一个工程建议:整个项目里要养成“每个过程都留一个可视化”的习惯。训练完GMM后,把前两维特征散点图和GMM等高线叠加打印出来看一下,能帮助你快速判断EM是否收敛到了合理位置。我在做K=8的实验时,就是通过可视化发现其中一个分量的均值点悬在了两个簇之间的低密度区域,顺着查才发现是初始化随机种子没设好,导致EM掉进局部最优。这种问题只看准确率数字是完全发现不了的。
我自己的实操体会是,GMM这个模型看起来很简单,但背后牵扯的隐性工程问题非常多。从数据划分、特征归一化、协方差约束到EM初始化,任何一个环节的马虎都会在最终准确率上狠狠报复你。当你把这些问题一个个都解决完,你对“模型鲁棒性”这几个字的理解会变得完全不一样。这个项目的完整代码核心其实也就几百行MATLAB,但我建议你读代码的时候不要上来就跑,先把每一步的原理在纸上画一遍,否则只是一个调包侠,遇到问题完全无从下手。如果后面有时间,我打算把HMM-GMM融合进来替换掉独立的GMM分类器,那样系统就能支持连续词序列识别了,这个坑到时候再出一篇详细记录。