说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别建模,最后算一下谁的概率最大。这个思路用在小型语音识别项目里,比如中文数字识别、孤立词识别,或者说话人身份确认,都非常舒服,既不需要深度学习那样的大算力,也能把原理讲得很清楚。
这篇文章我想从一个实际做过的项目出发,把GMM语音识别系统的核心原理、MATLAB实现步骤、训练细节和踩坑经历都梳理出来。适合刚接触语音信号处理的人,也适合想快速在MATLAB里搭一个可以演示的识别原型的同学。我会尽量用干活的口吻讲,代码也会给到能直接改着用的程度。
1. 先聊聊为什么用GMM做语音识别
1.1 一个最通俗的理解方式
你可以把GMM理解成“用一堆高斯分布去拼凑任意形状的分布”。每个语音类别,比如数字“一”、数字“二”,它的一批特征向量在高维空间里并不是规规矩矩地聚成一坨,而是可能有几个不同的聚集区域。比如同样说一个“三”,有人声音低沉,有人声音偏亮,环境噪声不同,录音音量不同,表现在特征空间里就会形成几团“各有偏向”的数据。
单个高斯分布只有一个均值和一个协方差,表达这种多峰特征会力不从心。GMM的思路很简单:我不再用一个分布硬扛,而是用多个高斯分布加权组合,每个高斯负责描述特征空间中的一块区域,最后把它们的贡献加起来,形成一个整体的概率密度模型。
这个想法放到识别任务里,就变成很自然的做法:每个类别训练一个GMM,识别新语音时把它的特征依次丢进每一个GMM里,算概率密度得分,哪个模型给分最高,就判定为哪个类别。
1.2 GMM在语音任务里到底扮演什么角色
在深度学习流行起来之前,GMM是语音领域里的主力模型之一。最经典的是HMM-GMM架构,HMM负责建模语音时序变化,GMM负责建模每一帧语音特征的概率分布。但如果只是做小规模的孤立词识别,可以不引入HMM,直接对每个词训练一个GMM,也能获得不错的基线效果。
这个方案特别适合MATLAB环境下的课程设计、实验室原型验证和个人学习。原因有几个:
- GMM本身是生成模型,训练过程直观,似然值可以直接解释。
- MATLAB的统计工具箱已经把EM算法封装好了,用几行代码就能完成模型训练。
- 特征提取部分,用信号处理工具箱也可以比较轻松地搭出一个MFCC流程。
- 相比神经网络,GMM参数量小,训练时间短,对小样本数据更友好。
我当年在某高校实验室做语音识别原型时,就是用这个方案快速搭建了第一版系统,验证完算法思路之后,再决定要不要上更复杂的模型。这个“先做基线,再迭代”的思路,用在个人项目里非常值得参考。
1.3 系统整体流程
整个系统前后串起来是这样的:
- 录音并读取音频文件。
- 对音频做预加重、分帧、加窗。
- 提取MFCC特征(一般取13维,可加一阶差分)。
- 对每个类别收集多段录音的特征,训练一个GMM。
- 识别时,把测试音频按同样方式提取特征,然后分别计算它在每个GMM下的对数似然。
- 取对数似然最大的类别作为识别结果。
流程本身并不复杂,但每一步都有不少细节。很多项目跑不通,往往不是GMM理论的问题,而是前面特征提取或者后面对数似然计算时埋了坑。这一点我在后面会专门讲。
2. 核心原理:从MFCC到高斯混合模型
2.1 声音怎么变成特征向量
语音信号本身是时域波形,直接丢给模型是不行的。一方面维度太高,另一方面冗余信息太多。所以要先转换成更紧凑、更能反映发音特点的特征。
最常用的特征就是MFCC,中文叫梅尔频率倒谱系数。它的计算流程大致如下:
- 预加重:语音信号的高频部分通常能量较弱,用一阶高通滤波器提升高频。
- 分帧:语音是短时平稳的,一般取25ms一帧,帧移10ms。
- 加窗:每帧乘一个汉明窗,减少频谱泄漏。
- FFT:把每一帧变换到频域,得到频谱。
- Mel滤波器组:把频谱按人耳感知特性映射到梅尔刻度上,得到每个频带的能量。
- 取对数:模拟人对声音强度的对数感知。
- DCT:对对数能量做离散余弦变换,得到倒谱系数,通常取前13维。
MFCC的低维系数描述的是语音频谱的平滑形状,正好能反映声道滤波特性,对说话内容区分度很好。后面再加一阶差分,可以补充帧与帧之间的动态变化信息。
2.2 高斯混合模型的数学骨架
一个包含K个分量的GMM可以写成:
p(x) = sum_{i=1..K} w_i * N(x; mu_i, Sigma_i)
其中w_i是第i个高斯的权重,mu_i是均值向量,Sigma_i是协方差矩阵。N(x; mu, Sigma)表示多元高斯分布的概率密度函数:
N(x; mu, Sigma) = 1 / ((2*pi)^(D/2) * det(Sigma)^(1/2)) * exp(-0.5 * (x-mu)' * inv(Sigma) * (x-mu))
这里D是特征维数。GMM就是要根据一批训练数据,估计出每个分量的权重、均值、协方差,使得这批数据的总体似然最大化。
在语音识别任务里,一般对每个类别单独训练一个GMM。比如类别“零”一个模型,类别“一”一个模型。每个模型在训练时只用自己的样本,所以模型之间的区分度完全靠数据驱动。
2.3 EM算法在干什么
GMM的参数不能像单高斯那样直接算闭式解,因为每个样本到底是“属于”哪个高斯分量这件事是未知的。EM算法就是解决这个问题的标准方法。
EM的全称是期望最大化。它分成两步交替迭代:
- E步:根据当前参数,计算每个样本属于每个高斯分量的后验概率。这个概率叫responsibility,可以理解为软归属。
- M步:利用这些软归属,重新加权计算每个高斯的均值、协方差和权重。
E步和M步反复执行,每次迭代都能保证训练数据的总似然不下降。到收敛后,就得到一组比较可靠的GMM参数。
在MATLAB里,你完全不用手动写EM循环,用统计工具箱里的函数就能完成。但理解EM过程仍然很重要,因为它决定了你要怎么初始化参数、怎么设置迭代次数,以及为什么某些情况下训练会失败。
3. MATLAB实现:从零搭一个识别系统
3.1 模块划分与工具准备
我建议把系统拆成几个独立模块,这样调试和扩展都方便:
extract_mfcc.m:输入音频和采样率,输出MFCC特征矩阵。train_gmm.m:输入某类别的所有特征,输出训练好的GMM模型对象。recognize.m:输入测试音频,输出识别类别。
在MATLAB里,最简单的方式是用自带的音频读取函数和统计建模函数。如果你有相关的工具箱,训练GMM可以直接用封装好的拟合函数。如果没有,也可以手写EM,代码量不算大,后面我会给出关键片段。
3.2 MFCC特征提取代码
下面是一个比较完整的MFCC提取函数,适合直接用在MATLAB里:
function mfccs = extract_mfcc(audio, fs) % 参数设置 frameLen = round(0.025 * fs); % 25ms 帧长 frameShift = round(0.010 * fs); % 10ms 帧移 numMelFilters = 26; % Mel滤波器个数 numCepstral = 13; % 保留13维倒谱系数 % 预加重 audio = filter([1, -0.97], 1, audio); % 分帧:用buffer函数实现 frames = buffer(audio, frameLen, frameLen - frameShift, 'nodelay'); frames = frames(:, 1:floor((length(audio) - frameLen) / frameShift) + 1); % 加窗 window = hamming(frameLen, 'periodic'); frames = frames .* repmat(window, 1, size(frames, 2)); % FFT NFFT = 2^nextpow2(frameLen); spec = abs(fft(frames, NFFT, 1)); % Mel滤波器组 melFiltBank = designMelFilterBank(fs, NFFT, numMelFilters); melEnergy = melFiltBank * spec; % 取对数 logMelEnergy = log(melEnergy + eps); % DCT mfccs = dct(logMelEnergy')'; mfccs = mfccs(1:numCepstral, :); end完整的Mel滤波器组生成函数比较长,这里给一个简化版:
function melFiltBank = designMelFilterBank(fs, NFFT, numFilters) fMin = 0; fMax = fs / 2; melMin = 2595 * log10(1 + fMin / 700); melMax = 2595 * log10(1 + fMax / 700); melPoints = linspace(melMin, melMax, numFilters + 2); hzPoints = 700 * (10.^(melPoints / 2595) - 1); binPoints = floor((NFFT + 1) * hzPoints / fs); melFiltBank = zeros(numFilters, NFFT / 2 + 1); for m = 2:numFilters+1 left = binPoints(m - 1); center = binPoints(m); right = binPoints(m + 1); for k = left+1:center if k <= NFFT/2 melFiltBank(m-1, k) = (k - left) / (center - left); end end for k = center+1:right if k <= NFFT/2 melFiltBank(m-1, k) = (right - k) / (right - center); end end end end这段代码在测试中表现稳定。注意buffer分帧时,如果音频短于一个帧长,会出现问题,所以在调用前最好先检查音频长度。
3.3 GMM训练与保存
提取完特征后,训练GMM可以非常简洁。假设我们已经把所有“类别一”的特征拼成一个大的矩阵trainData,每行是一帧特征,每列是一个维度,那么训练代码长这样:
function gmmModel = train_gmm(trainData, numComponents) options = statset('Display', 'final', 'MaxIter', 300); gmmModel = fitgmdist(trainData, numComponents, ... 'CovarianceType', 'diagonal', ... 'RegularizationValue', 0.01, ... 'Options', options); end这里我特意用了对角协方差。语音特征维度之间往往有相关性,但为了训练稳定、减少参数数量,对角协方差是一个很实用的选择。RegularizationValue用来避免协方差奇异,如果数据量少,这个值很关键。
如果没有统计工具箱,也可以手动实现EM。核心代码是这几个步骤的循环:
% 初始化:用kmeans把数据粗聚类 [idx, centers] = kmeans(trainData, numComponents, 'MaxIter', 100); weights = ones(1, numComponents) / numComponents; mu = centers'; sigma = repmat(var(trainData)', 1, numComponents); for iter = 1:100 % E步 resp = zeros(size(trainData, 1), numComponents); for k = 1:numComponents resp(:, k) = weights(k) * mvnpdf(trainData, mu(:, k)', sigma(:, k)'); end resp = resp ./ (sum(resp, 2) + eps); % M步 Nk = sum(resp, 1); weights = Nk / sum(Nk); mu = (trainData' * resp) ./ Nk; for k = 1:numComponents diff = trainData - repmat(mu(:, k)', size(trainData, 1), 1); sigma(:, k) = sum(resp(:, k) .* (diff.^2), 1)' ./ Nk(k); end end这段手动版本没有加正则,只适合数据量充足的小规模实验。在实际项目中,我更推荐直接用工具函数,因为它内部做了很多数值保护。
训练好模型后,直接把模型对象保存到mat文件里即可:
save('gmm_models.mat', 'gmmModel1', 'gmmModel2');3.4 识别打分与判决
识别的时候,先提取测试音频的MFCC特征,然后计算它在每个GMM下的对数似然。为了防止不同长度音频导致分数失真,可以把总的对数似然除以帧数,得到平均对数似然。
function label = recognize(features, gmmModels) numClasses = length(gmmModels); logLikelihood = zeros(1, numClasses); for c = 1:numClasses [~, logLikelihood(c)] = gmmModels{c}.cluster(features); % 有些版本没有直接暴露pdf函数,可以用cluster来近似打分 end [~, label] = max(logLikelihood); end更精确的做法是用pdf方法或者自己算对数密度:
function logLik = computeLogLikelihood(gmm, X) [P, ~] = gmm.cluster(X); % P是后验概率矩阵 logLik = sum(log(sum(P, 2) + eps)); end之所以强调用对数似然,是因为一组语音特征帧很多,直接乘起来会小到数值下溢,连电脑都表示不了。取对数之后,乘法变成了加法,数值稳定性会好很多。
4. 训练细节与调参心得
4.1 高斯分量数量怎么选
这是很多人第一个会问的问题:GMM到底应该选几个高斯分量?
我给一个从实际测试中得到的经验值:对于单个数字或单词识别,每个类别用8到16个高斯分量通常就够用了。数据量越少,分量越多越容易过拟合。如果每个类只有10段录音,建议分量控制在8个以内;如果每个类有50段以上的录音,可以用16个甚至32个。
分量数太少,模型表达力不够,类内方差描述不细致。分量数太多,训练数据不够,部分分量会被分配到极少数样本上,协方差矩阵容易变成奇异矩阵。这个平衡点需要实验来确定,可以画一下每个分量数下的测试集识别率,取一个拐点位置。
4.2 协方差矩阵的三种约束
GMM的协方差矩阵有三种常用形式:
- 完整协方差:能描述维度之间的相关性,但参数量大,容易过拟合。
- 对角协方差:假设维度相互独立,参数量小,训练快,语音识别里用得最多。
- 共享协方差:所有高斯分量共用同一个协方差矩阵,进一步减少参数量,适合小样本场景。
在实际项目中,我第一次用的是完整协方差,结果训练数据稍少一点就频繁报错。后来换成对角协方差之后,系统立刻稳定了,识别率也没有明显下降。我的建议是优先对角协方差,只有在数据量很充足并且对角模型明显不够用时,再考虑完整协方差。
4.3 特征归一化与端点检测
很多人在提取MFCC后直接丢给GMM训练,这其实很容易出问题。不同录音的音量大小不一样,导致特征分布整体偏移。某一次录音离麦克风近一点,模型就可能被带偏。
我的做法是,在拼接所有训练特征之前,先对每一维特征做均值和方差归一化:
trainData = (trainData - mean(trainData)) ./ std(trainData);这个操作对提升系统稳定性很有帮助。需要注意的是,归一化参数应该只用训练集计算,然后应用到测试特征上,否则会把测试集信息提前泄露给模型。
另一个容易忽略的点是端点检测。录音文件前后经常有一段静音,静音的MFCC特征和语音内容本身差异很大,如果把这些静音帧也纳入训练,模型会分出一部分高斯分量去描述静音,白白浪费表达能力。
简单起见,可以用短时能量做门限:
frameEnergy = sum(frames.^2, 1); validIdx = frameEnergy > max(frameEnergy) * 0.05; frames = frames(:, validIdx);这样可以把低能量的静音帧去掉。如果项目里语音都是干净的,这一步必要性不大,但如果录音环境杂一点,就值得加。
5. 常见问题与排查技巧实录
5.1 训练发散或者聚类失败
现象:训练GMM时,某次迭代后参数变NaN,或者所有样本都被分到了同一个高斯分量里。
排查方向:
- 检查特征矩阵里是否存在NaN或Inf。这种情况经常来自log(0),所以在取对数时我加了
eps。 - 检查协方差是否奇异。如果特征维数很高但训练帧数很少,就容易发生。解决办法是增加正则化系数,或者切换为对角协方差。
- 检查初始化方式。直接随机初始化很容易陷入不好的局部解。用kmeans初始化是目前非常稳妥的做法,工具函数内部默认就用了类似策略。
5.2 数值下溢与对数域计算
现象:识别时,某个模型得分算出来是0,或者训练时似然值出现-Inf。
原因很直接:帧数乘以维数,似然概率可能远远小于浮点数能表示的最小值。解决办法是把所有概率运算都放到对数域。
比如,GMM的概率密度函数可以写为:
log p(x) = log(sum_i w_i * exp(logN_i))
直接展开会有数值风险,可以先用logsumexp技巧抵消误差。MATLAB里用fitgmdist训练时,内部已经做了数值保护,手动实现EM时一定要自己注意。
5.3 说话人混淆与录音条件影响
如果系统在训练时用A同学的声音,测试时换B同学的声音,识别率往往会下降。这不是GMM模型的错,而是训练集和测试集的数据分布不一致。
缓解办法有几个:
- 多采集几个人的录音混合训练,让模型尽量覆盖不同说话人的特征。
- 录音时固定设备、固定距离、固定环境,减少信道差异。
- 对特征做倒谱均值减(CMS),可以在一定程度上抵消信道和麦克风的影响。
倒谱均值减的实现非常简单:
mfccs = mfccs - mean(mfccs, 2);这个操作对消除录音通道差异效果明显,我在一个跨设备测试中试过,识别率能提升几个百分点。
5.4 内存爆炸与小样本训练
如果训练数据很大、GMM分量很多,fitgmdist可能会占掉大量内存。这时候可以用自带的statset限制迭代次数,或者对训练特征随机抽样一部分帧来训练。每一帧之间本身有大量冗余,抽样后模型质量不会下降太多。
反过来,小样本训练时,比如每个类只有几十秒录音,就不要追求大GMM。我有一个原则:每个高斯分量至少要有几十帧有效数据去支撑,否则统计量不可靠。如果总帧数只有500帧,用16个分量,平均每个分量只有30帧数据,结果会非常不稳。
6. 让系统更稳的几件事(实操经验)
6.1 加一点模型自适应
如果项目需要做说话人自适应,又不想引入太复杂的算法,可以试试最大后验(MAP)自适应。思路很简单:先用大规模通用数据训练一个背景模型,再用某个特定说话人的少量数据去微调均值。
在MATLAB里实现MAP自适应,其实就是按后验概率把旧均值和新的统计量加权融合:
new_mu = (alpha * new_stat + (1 - alpha) * old_mu)
这个alpha根据新数据的量动态调整,数据越多,alpha越大。虽然这里只是提一句,但如果你的GMM识别系统遇到新用户时表现不好,这招往往立竿见影。
6.2 声音活动检测不能省
我在早期版本里跳过端点检测,结果发现识别错误多数发生在录音首尾的静音段。后来加了一个很简单的能量门限,整个系统识别率立刻提升了一截。
当然,如果环境噪声比较强,单纯用能量门限会误判,可以换成过零率加能量的双重判断。更复杂的也可以用语音活动检测模型,但对小型项目来说,先把能量门限调好就够用了。
6.3 评估指标与常见指标误区
最后提醒一下评估方法。很多同学在汇报识别率时,直接用训练集来测,结果自然是百分之九十多,但这没有任何说服力。正确的做法是留出独立的测试集,或者做几折交叉验证。
对每个类别,建议至少留出20%的数据不参与训练,专门用来测试。如果数据量少,可以用留一法:每一次取其中一段做测试,其余全部训练,最后把结果平均。
我在实际项目中还发现,单独看整体识别率会掩盖问题。建议同时看每个类别的识别率。某个数字识别率偏低时,可以输出混淆矩阵,看看它最容易被误判成哪个类别,然后针对性分析是不是特征相似度过高。
写到这里,我自己最深的体会是:GMM语音识别系统的代码工程量并不大,真正花时间的地方永远在数据准备、特征处理和调试细节上。MATLAB给了你非常方便的封装函数,但如果你不理解底层原理,出了问题会完全找不到方向。把这个系统完整做一遍,你会对语音特征、生成模型、概率打分这些概念有一种“打通了”的感觉。之后再去学深度学习语音识别,也会轻松很多。