简介基于高斯混合模型GMM的说话人识别MATLAB工程面向语音处理与模式识别学习者完整覆盖话者语音特征建模、模型训练与身份验证流程。压缩包共15个文件含12个MATLAB脚本与3个数据文件整体大小2.68MB脚本层覆盖从语音分帧、梅尔频率倒谱系数MFCC与滤波器组能量特征提取到GMM初始化、EM迭代训练含后验概率计算及测试阶段识别匹配的完整链路数据文件为预先处理的语音特征可直接驱动训练与识别程序运行。目前已有817人学习/下载。借助训练与识别两个主程序读者既可按步骤跑通从原始语音到说话人判别的全流程也可自行调整混合成分数量、迭代次数或替换特征参数观察不同设置对识别结果的影响从而深入理解高斯混合模型在声纹识别中的建模原理与实践调优方法。1. 这个基于 GMM 的说话人识别解决的到底是什么问题在 MATLAB 里用高斯混合模型GMM做说话人识别是语音课程设计、声纹原型验证里最常见的一条入门路线。它解决的问题是给定一段未知说话人的录音通过训练好的混合高斯模型判断“这段语音是谁说的”或者在验证场景里判断“说话人是不是他所声称的那个人”。整个流程里最核心的两个动作就是 gmm 训练和 calcpost 后验概率计算。很多第一次接触的人以为难点全在模型数学上真正跑过一遍以后才发现花时间最多的地方是特征怎么提、训练集怎么划分、协方差矩阵怎么保住正定性。这篇文章会把 GMM 在说话人识别里的建模思路讲清楚再给出一套能在 MATLAB 里直接落地的训练与打分流程最后列出几个我踩过的典型坑。适合正在做语音相关课题、要搭说话人验证原型或者想快速评估 GMM 方案值不值得继续投入的技术人员。如果你手头有几段 WAV 录音想按说话人把它们区分开这套流程一到两天就能在普通电脑上跑通不需要 GPU也不需要装深度学习框架。2. 说话人识别里的 GMM建模逻辑与 calcpost 的真实角色2.1 一句话讲清 GMM 在话者识别里建模的是什么说话人识别系统的第一步几乎都是把原始波形转成帧级声学特征。用得最多的特征是 MFCC因为人类发声时声道共振结构的信息集中在频谱的低频部分而 MFCC 恰好把这段频谱包络压缩成了十几二十维的参数。实际处理时语音会按 20~30 毫秒一帧切分相邻帧之间有 10 毫秒左右的滑动每帧经过预加重、加窗、FFT、梅尔滤波器组、对数运算和 DCT最终得到一帧特征向量。一段 3 秒的语音大约能变成 200~300 行、每行 12~24 维的特征矩阵。下一步的问题是如何给每个说话人建一个模型让这个模型能算出“这段特征有多大概率来自这个人”。GMM 的做法是假设一个说话人的所有语音特征在特征空间里的分布可以拆成若干个高斯分量的加权和。每个高斯分量对应一种声学模式权重表示这种模式出现的比例。模型参数就是每个分量的权重、均值向量和协方差矩阵。从工程角度看GMM 和模板匹配的本质区别在于GMM 比的是分布形态不是向量距离。同一个人在不同情绪、语速、静音比例下单帧的 MFCC 会抖动得很厉害但分布形态相对稳定。单高斯模型盖不住话者特征的多峰性比如某个人在高频共振峰区域会出现两种典型取值单高斯会把它们强行拉成一个宽峰区分能力自然下降。GMM 用多个高斯分量去贴合这种多峰分布每个分量只管一块区域最终拼出一个更接近真实分布的密度函数。2.2 训练循环在一轮 EM 迭代里参数如何被更新GMM 训练通常用 EM 算法迭代优化对数似然。EM 的思路很直接先用当前参数算每一帧特征属于每个高斯分量的后验概率再用这些后验概率去重新估计参数。这个过程交替进行每一轮都保证训练数据上的对数似然不下降。下面这段代码是一个完整的 EM 迭代函数输入是特征矩阵 X 和一组初始化的 GMM 参数输出是更新后的参数以及每一轮的训练平均对数似然。function [w, mu, Sigma, logL] gmm_em(X, w, mu, Sigma, maxIter) % GMM 训练EM 迭代 % X: T x D 特征矩阵T 为帧数D 为特征维度 % w: 1 x K 权重向量 % mu: K x D 均值矩阵 % Sigma: D x D x K 协方差数组 T size(X, 1); K length(w); logL zeros(maxIter, 1); for iter 1:maxIter % ---------- E 步 ---------- % 计算每一帧在每个高斯分量上的对数概率避免直接算指数溢出 logpdf zeros(T, K); for k 1:K logpdf(:, k) log(w(k)) log_mvnpdf(X, mu(k, :), Sigma(:, :, k)); end % logsumexp把对数概率转回尺度化概率 maxlog max(logpdf, [], 2); logsum maxlog log(sum(exp(logpdf - maxlog), 2)); logL(iter) mean(logsum); % gamma 是后验概率即每帧属于每个分量的责任度 gamma exp(logpdf - logsum); % ---------- M 步 ---------- Nk sum(gamma, 1); % 每个分量的有效样本数 w_new Nk / T; % 更新权重 mu_new (gamma * X) ./ Nk; % 更新均值 Sigma_new zeros(size(Sigma)); for k 1:K Xc X - mu_new(k, :); % 用新均值计算中心化残差 Sigma_new(:, :, k) ( (gamma(:, k) .* Xc) * Xc ) ./ Nk(k); end w w_new; mu mu_new; Sigma Sigma_new; end end参数说明T 通常是数百到数千K 一般是 16~48因此这段代码用两层循环也完全可以接受。M 步里更新协方差时用的是新均值收敛速度会比用旧均值稍好这是经过对比实践得出的经验。每次 M 步结束后还应该对协方差对角线做一个下限约束否则下一轮的 log_mvnpdf 很可能算出 Inf我习惯在循环外统一加上正则项后面第五节会专门讲。2.3 calcpost 不是简单后验它训练和识别共用同一套计算标题里出现的 calcpost是“计算后验概率”的缩写。训练时它对应 E 步里的 gamma也就是每一帧落在每个高斯分量上的责任概率识别时它被用来计算一整段语音在某个 GMM 下的平均对数似然作为这个说话人模型对当前语音的评分。这里有一个容易混淆的点训练和识别虽然使用相同的函数但用途不同。训练阶段要的是后验概率本身因为 M 步里求均值、协方差都要拿它做权重识别阶段要的是后验概率的对数似然因为判决只关心“这段音频更像谁”。把两者统一起来代码结构会干净很多。实现 calcpost 的核心是 logsumexp。如果不做这一步直接计算权重乘密度的和当某帧距离所有高斯分量都很远时概率会下溢成 0对数变成负无穷整个训练链就断了。因此 calcpost 内部一定要先算 logpdf再做 logsumexp得到后验的同时把对数似然也保留下来。下面给出配套的多元高斯对数密度函数这是 calcpost 里最基础的一环。function logp log_mvnpdf(X, mu, Sigma) % 计算 XN x D每一行在 N(mu, Sigma) 下的对数概率密度 % 用 Cholesky 分解代替直接求逆更稳更快 [D, N] deal(size(X, 2), size(X, 1)); [R, p] chol(Sigma); if p 0 error(Sigma 不是正定矩阵请检查协方差下限); end Xc X - mu; % 中心化 z Xc / R; % 等效于 inv(R) * Xc logdet 2 * sum(log(diag(R))); logp -0.5 * (D * log(2*pi) logdet sum(z.^2, 2)); end这段代码值得注意的地方是 chol 的成功条件。当协方差矩阵对角线出现很小的值时chol 可能仍然返回成功但 z 的值会变得很大后面 exp 步骤就会溢出。我的习惯是同时在两个地方设防训练 M 步后对 Sigma 加正则项以及在输入 calcpost 前对特征做标准化。正则量选 1e-4 到 1e-6 之间具体取决于特征幅度幅度大的用大值幅度小的用小值。3. 用 MATLAB 把 GMM 训练跑通从 MFCC 到完成一次训练3.1 训练、验证、测试怎么划分才不会自我欺骗说话人识别项目里最常见的一个错误是拿同一条录音既提取训练特征又提取测试特征。这相当于开卷考试而且复习题就是原题测出来准率 100%换个场合立刻崩掉。标准做法是按文件划分每个说话人准备若干条独立录音一部分进训练集一部分进验证集再留几条进测试集。绝对不能在同一文件的中间切一段训练、末尾切一段测试因为相邻帧之间的声学特征高度相关会被模型当作“见过面”的数据测出来的准确率虚高。如果每个说话人只有一条长录音可以把录音按语义切分成几段独立的文件保证段与段之间隔开至少 1 秒再按文件划分。按录音文件划分的原则优先级高于按说话人划分这是整套实验是否可信的前提。3.2 MFCC 特征提取的最小实现MFCC 提取在很多 MATLAB 工具箱里都有现成实现但如果你想自己控制每一个参数可以按下面这个简化版来做。它没有调用工具箱函数只依赖 MATLAB 基础库帧长、帧移、滤波器个数都可以调。function fea mfcc_basic(x, fs, opts) % 简化 MFCC 实现分帧 - 加窗 - FFT - Mel 滤波 - log - DCT % 返回 T x D 特征矩阵 frameLen round(opts.FrameLen * fs); frameShift round(opts.FrameShift * fs); T floor((length(x) - frameLen) / frameShift) 1; frames zeros(frameLen, T); for t 1:T idx (t - 1) * frameShift 1; frames(:, t) x(idx : idx frameLen - 1); end % 加汉明窗减小频谱泄漏 win hamming(frameLen); frames frames .* win; nfft 512; spec abs(fft(frames, nfft, 1)); spec spec(1 : nfft/2 1, :); % 只保留非负频率部分 % 构造梅尔滤波器组的简化版 melFb build_mel_filterbank(nfft/2 1, 26, fs); logEng log(melFb * spec eps); % 26 维对数能量 fea dct(logEng., 2); % DCT fea fea(:, 1 : opts.Coefs); % 截取前 Coefs 维 end逻辑说明每帧乘汉明窗是必须的直接切帧会在 FFT 时产生频谱泄漏导致特征里混入高频噪声。FFT 点数取 512如果采样率是 16kHz频率分辨率大约 31Hz足够覆盖语音主要信息。梅尔滤波器组把线性频谱压到梅尔尺度上符合人耳听觉特性但即使省略滤波器组、直接用 log 频谱GMM 也能工作只是区分度会差一些。DCT 的作用是去相关MFCC 的最终维度通常取 12~24多取的维度多数是噪声反而会拖累 GMM 的协方差估计。build_mel_filterbank 函数不在这里展开网上和各类工具箱里都有成熟的 me 尔滤波器组实现核心就是把线性频率映射到梅尔频率后生成 26 个三角滤波器覆盖 80Hz 到 4000Hz 的范围。3.3 kmeans 初始化 GMM 参数为什么不能省GMM 训练直接随机初始化会让某些高斯分量落在特征空间的空旷区域协方差被撑得很大在 EM 迭代里长期得不到有效更新等于浪费了混合数量。常见做法是先用 kmeans 把训练特征聚成 K 类用聚类中心做均值初值用每类样本的方差加下限做协方差初值用每类样本占比做权重初值。如果 MATLAB 没有统计工具箱kmeans 也可以自己写或者用一个更偷懒但有效的办法从特征里随机选 K 个不相邻帧做均值协方差统一设为单位矩阵乘以一个较小系数。相比随机初始化kmeans 放进来的好处是收敛快且稳定EM 迭代次数能减少一半以上。3.4 完整训练脚本与收敛日志把数据读取、MFCC 提取、kmeans 初始化、EM 训练串起来就是下面这个脚本。目录结构建议是 data/speakerID/train/ 和 data/speakerID/eval/每条录音一个文件。% 训练脚本train_gmm_demo.m mfccOpts.Coefs 24; % 取 24 维 MFCC mfccOpts.FrameLen 0.025; % 帧长 25ms mfccOpts.FrameShift 0.010; % 帧移 10ms numGauss 32; maxIter 40; spkList {spk01, spk02, spk03}; models cell(length(spkList), 1); for s 1:length(spkList) wavFiles dir(fullfile(data, spkList{s}, train, *.wav)); X []; for f 1:length(wavFiles) [x, fs] audioread(fullfile(wavFiles(f).folder, wavFiles(f).name)); x x(:); % 统一成行向量 X [X; mfcc_basic(x, fs, mfccOpts)]; % 同一说话人的所有训练帧拼接 end % ---- kmeans 初始化 ---- [idx, C] kmeans(X, numGauss, MaxIter, 100, Replicates, 1); mu C; Sigma zeros(size(X, 2), size(X, 2), numGauss); for k 1:numGauss Xk X(idx k, :); if size(Xk, 1) 10 Sigma(:, :, k) diag(max(var(Xk, 0, 1), 1e-4)); else Sigma(:, :, k) diag(0.5 * ones(1, size(X, 2))); end end w histcounts(idx, 1:numGauss 1) / size(X, 1); % ---- EM 训练 ---- [w, mu, Sigma, logL] gmm_em(X, w, mu, Sigma, maxIter); models{s} struct(w, w, mu, mu, Sigma, Sigma, id, spkList{s}); % 把 logL 曲线打印出来确认每轮都在上升 fprintf(%s: 最后 5 轮 logL %s\n, spkList{s}, num2str(logL(end-4:end), %.4f )); end save(gmm_models.mat, models);这段脚本里每个说话人单独训练一个 GMM。kmeans 的 Replicates 参数在数据量小时设 1 就够设多了初始化耗时反而明显。训练帧数如果只有几百帧numGauss 建议降到 16否则每个分量平均只有 20 帧左右协方差估计很不稳。打印 logL 最后五轮是判断训练是否正常的最直接手段健康曲线应当是前几轮快速上升后面涨幅逐渐归于 0.01 以内。4. 用 calcpost 完成一次说话人识别测试打分、阈值与参数4.1 判决规则整段语音的平均对数似然识别阶段把测试语音也用同样的 MFCC 参数提成特征矩阵 X然后对每个说话人模型调用 calcpost得到这一段语音在对应 GMM 下的平均对数似然取最高的那个模型编号作为判决结果。下面是一段最小测试代码。% 测试脚本identify_one_wav.m function [decidedId, scores] identify_one_wav(wavFile, models, mfccOpts) [x, fs] audioread(wavFile); X mfcc_basic(x, fs, mfccOpts); scores zeros(length(models), 1); for m 1:length(models) gmm models{m}; [~, scores(m)] calcpost(X, gmm.w, gmm.mu, gmm.Sigma); end [~, bestIdx] max(scores); decidedId models{bestIdx}.id; end这里 calcpost 返回的 score 必须是对整段语音所有帧的平均对数似然而不是累加结果。平均的目的是去除录音长度的影响否则长语音天然占优。测试语音维数和训练语音不一致时平均就体现出了它的价值。4.2 最小可用实验三个说话人、每人八条录音的划分想把流程完整走通我建议的最小规模是 3 个说话人、每人 8 条录音。前 4 条做训练第 5、6 条做阈值校准第 7、8 条做测试。这里说的“条”是指独立录音文件每条约 3~5 秒。全部录音用同一套 MFCC 参数提取特征维度、帧长、帧移都不许变否则训练和测试的特征空间不一致分数系统会整体混乱。如果你要做开集验证即允许系统拒绝陌生人还需要在验证集里加入几个训练集里不存在的说话人录音用他们的分数来确定拒绝阈值。阈值调整没有一个通用数值因为 logL 的量纲受音频振幅、特征维度和混合数共同影响。常见做法是把注册说话人与未知说话人的分数分布画在一张图上取分布重叠最小的位置当作阈值。4.3 三个必调参数混合数、迭代次数、特征维度参数影响比想象中大。混合数决定了模型对说话人分布的拟合能力但也直接决定过拟合风险特征维度跟训练样本量挂钩维数越高需要的数据越多。我调参时习惯先固定迭代次数然后跑混合数的一组对比实验混合数单点变化时测试准确率会出现一个平台区选平台区起始位置附近的较小值避免无意义的过拟合。参数推荐范围判断依据混合数 K16~48训练语音总量不足 60 秒选 16超过 3 分钟选 32~48迭代次数20~60观察 logL 增量小于 0.01 后停止MFCC 维度12~24越高区分越强但样本不足时反而掉点协方差下限1e-6~1e-3根据特征幅度设置幅度大的用大值迭代次数不要固定死。我见过有人固定 50 次结果录音里混入较长静音帧logL 到 40 次还在缓慢上升。反过来如果 10 次后 logL 已经平了跑 50 次纯粹浪费时间。更好的做法是设置相邻两次 logL 之差小于 1e-4 即退出这样在不同数据上都能自动收敛到一个合理位置。特征维度还有一个常见误区把 MFCC 一阶差分也拼进去导致维度翻倍。对 GMM 来说不一定划算差分特征在安静环境下信噪比略好但噪声环境下会放大高频噪声。建议先用静态 MFCC 做一个 baseline再和拼接差分后的结果对比以实测数据决定是否保留差分。5. GMM 训练常见翻车点现象、原因与解法5.1 现象训练中途提示“Sigma 不是正定矩阵”训练进行到某一步突然报错回看日志发现协方差变成了奇异矩阵。原因通常是某些高斯分量里样本量太少方差估计结果接近 0或者说话人录音太短某些维度的能量几乎为 0。解决方法是双管齐下初始化时给每个分量的协方差对角线设置一个下限比如 1e-4并在每次 EM 的 M 步结束后统一执行Sigma(:, :, k) Sigma(:, :, k) 0.001 * eye(D)。对极端情况某个分量样本帧数少于 10 时直接用类内方差构造对角协方差不要走完整估计流程。5.2 现象同一说话人的两条录音分数差出 20%训练时自我打分很高但换一条录音后分数明显下跌阈值完全定不住。原因是训练录音和测试录音的录制环境、麦克风、信道不同导致整体特征产生偏移GMM 却把这些信道信息当作声纹信息。解决方法是特征侧做倒谱均值归一化也就是把每条录音的特征矩阵按列减去该列均值。这个操作会让特征分布整体归零不同麦克风的系统性偏移被削弱但会丢掉一部分说话人个性信息对验证场景的净收益通常大于损失。5.3 现象闭集识别准确率很高但现实环境里基本不可用模型在测试集上达到 98% 的准确率换成实际对话录音却几乎全错。最常见的原因是训练测试来自同一次录制、同一设备甚至同一时间段模型学到的是信道特征而不是话者特征。要确认这一点可以做一个实验把训练录音和平行的另外一条录音打乱划分重新训练测试看看准确率下降多少。如果大幅下降说明信道过拟合。解决思路是增加录音采集的多样性包括不同时间、不同设备、不同环境同时严格执行按文件划分保证任何测试文件在训练阶段没出现过一帧。5.4 现象calcpost 在测试阶段慢到无法接受有些实现直接在循环里对每一帧调用 mvnpdfT300、K32 时跑一个测试集要十几分钟。原因是 mvnpdf 内部对每个样本都做一次 Cholesky 分解重复计算巨大。解决方法是改用第 2.3 节的 log_mvnpdf提前对 Sigma 做一次 Cholesky 分解再对整帧矩阵做运算。还有一个工程技巧识别阶段只需找最大得分可以只保留后验权重最高的几个分量参与计算牺牲少量精度换来成倍提速。5.5 现象logL 迭代到后期反而下降甚至出现 NaN前面十几轮持续上升之后突然下降或变成 NaN。原因多半是 M 步协方差更新时被离群帧带偏或者某个分量在更新后方差极小下一轮 log 概率变成 Inf。解决方法是先定位是哪轮迭代、哪个分量的 logL 异常再针对处理。常见做法是给协方差矩阵设置更紧的下限并在 MFCC 阶段过滤掉低能量帧把静音段和爆音段剔除。如果问题仍存在就减少混合数分量变少后每个分量能分到更多有效样本估计会更稳定。这条路径排查到最后八成会落在“数据量配不上模型复杂度”这个源头上。6. 最后一道工序用开卷验证检验泛化再顺路走到 GMM-UBM模型做完训练与识别测试以后我总会再做一次开卷验证把训练时用过的录音和全新录音混在一起打分同时混入几个没训练过的说话人测试拒绝能力。闭集识别只验证“能不能认出见过的人”开卷验证才更接近真实上线要求因为现实里总有系统没见过的语音和说话人。验证脚本不复杂从训练集、测试集、未知说话人录音里各取几段分别用 calcpost 打分画出同一说话人的两个分数分布。如果分布重叠面积很大说明模型对录音环境、说话状态等变化过于敏感优先加数据比继续加大混合数更实际。如果分布分离很清楚阈值就可以放心设置在两个分布之间。我会保存一份带 logL 曲线的训练日志并在所有脚本里固定随机种子。这个习惯看起来费事但下次拿到新录音时能拿着日志对照现象排查效率高得多。在开集验证达到预期之后下一步常见方向是 GMM-UBM先用大量不同人的语音训练一个通用背景模型再以它为先验用目标说话人的少量语音做 MAP 自适应。这种做法能把训练数据不足的问题大幅缓解因为在真实项目里目标说话人的录音通常只有几分钟而通用背景模型可以用公开语音库预先训好。把普通 GMM 的 EM 训练、calcpost 打分、阈值验证这些基本功理通以后再过渡到 UBM 和 i-vector思路会顺得多。我现在做新项目时还会保留一个自定义函数记录每个说话人的训练时长、帧数、logL 曲线跑完自动存成 CSV 表格。出问题时先看 table再决定是调特征还是调参数避免了黑匣子式排查。希望这个 MATLAB 实现思路能帮到你顺利跑通手头的第一个说话人识别 demo。本文还有配套的精品资源点击获取