声纹识别算法实战:从GMM到x-vector的说话人识别路线与避坑指南
简介:这是一套基于 Python 的说话人识别源码项目,完整覆盖 GMM、GMM-UBM、ivector 与基于自注意力机制的深度学习方法,能够为计算机、数学、电子信息等专业的课程设计、期末大作业或毕业设计提供可直接运行和二次扩展的起点,也为刚接触声纹识别的开发者提供了从传统统计模型到深度学习方案的对照参考。压缩包内共 20 个文件,总体积约 144KB;主体为 15 个 py 算法脚本,负责特征提取、模型训练与识别流程,配套的 ipynb 文件适合逐步演示声纹比对效果,sqlite 与 json 文件保存训练样本和配置参数,md 文档则梳理了目录结构与运行说明。该项目已有 229 人学习浏览,适用于毕设项目、课程实践或竞赛预研。通过整套源码,读者可以查看多类模型的关键实现细节,也能结合说明文档快速跑通识别流程,并继续改动脚本完成消融实验或扩展新的前端特征,对理解声纹识别技术演进和工程落地都有直接帮助。
1. 说话人识别算法包怎么用:先弄清四条技术路线的差别
会议室录音整理成纪要及时按说话人分开,银行App用声纹做免密登录前的身份核验,电话客服需要判断来电者是不是历史客户——这类任务的核心都指向说话人识别(声纹识别):判断一段语音是谁说的,而不是说了什么。标题里的算法包把四条技术路线放在一起:GMM 最简单,适合理解原理;GMM-UBM 解决“目标说话人只有三五条语音”的现实问题;i-vector 是 2010 年前后工业界的主力方案;基于深度学习的方法是当前效果上限。路线选择不同,踩的坑也完全不同。
文章按四条路线逐一拆解,每一条都落到可复现的 Python 实现、关键参数和边界条件。读完之后,你应该能根据手里数据量快速判断:该用哪个模型、训练时调什么参数、打分用余弦还是 PLDA,以及上线前哪些坑必须绕开。这个方向对做语音产品原型验证的工程师、做声纹评测的算法同学,以及想从统计模型转向深度学习的入门者都适用。
先说一个反直觉的结论:声纹识别真正难的不是模型结构,而是注册语音太短、环境噪声太杂、录音信道不一致。后文所有参数选择和避坑思路,都是围绕这三件事展开的。
2. MFCC特征提取与GMM基线:把最小的说话人识别跑通
2.1 为什么先提 MFCC:特征选择决定了模型上限
语音的原始波形维度高、冗余大,直接拿来训练模型既慢又不稳。声纹识别领域最通用的前端是 MFCC(梅尔倒谱系数):它把人耳对频率的非线性感知压缩成十几到几十维的系数,再叠加一阶和二阶差分,保留语音的动态变化。对说话人识别来说,动态特征往往比静态系数更有区分度,因为每个人说话时的发音过渡习惯不同。
常见做法是 16kHz 采样率下,用 25ms 的窗长、10ms 的帧移提 20 维 MFCC,再加一阶和二阶差分,最后拼接成 60 维特征。电话语音只有 8kHz 带宽,则要先把采样率降到 8kHz,窗长保持 25ms,帧移 10ms,特征维度可以降到 13 维。很多开源代码默认 16k,如果手里是电话录音不重采样,后面模型全都会出问题。
import librosa import numpy as np def load_mfcc(path, sr=16000, n_mfcc=20, n_fft=400, hop=160): y, sr = librosa.load(path, sr=sr, mono=True) mfcc = librosa.feature.mfcc( y=y, sr=sr, n_mfcc=n_mfcc, n_fft=n_fft, hop_length=hop ) delta1 = librosa.feature.delta(mfcc, order=1) delta2 = librosa.feature.delta(mfcc, order=2) feat = np.vstack([mfcc, delta1, delta2]) return feat.T # shape: [帧数, 60]这里有两个参数容易被忽略。n_fft=400在 16kHz 下正好是 25ms,hop=160是 10ms;如果改采样率而不调整这两个值,实际覆盖的时间范围就变了。另一个是librosa.load默认会做重采样,但输入文件本身的采样率不会被检查,建议在训练管线入口统一转成 16kHz 单声道 WAV,避免不同来源音频直接混进特征提取。
2.2 用 sklearn 把 GMM 基线跑通:训练与打分代码
拿到帧级特征后,GMM 的思路是:每个说话人的声学特征分布在特征空间中形成若干团簇,用若干个高斯分量去拟合这个分布。识别时计算待测语音在当前说话人 GMM 下的似然,谁分数高就判给谁。
from sklearn.mixture import GaussianMixture def train_gmm(feat, n_components=64, reg_covar=1e-4, seed=0): gmm = GaussianMixture( n_components=n_components, covariance_type="diag", reg_covar=reg_covar, max_iter=200, n_init=2, random_state=seed, ) gmm.fit(feat) return gmm def score_gmm(gmm, feat): # sklearn 的 score 已经按帧数做了平均 return gmm.score(feat)covariance_type建议选diag:全协方差矩阵参数量是特征维度的平方,训练数据不够时极易过拟合,diag在声纹任务里效果不会差太多但稳得多。n_components的选择要看数据量:单人 1 分钟语音、约 6000 帧特征,64 个分量是合理起点;语音更长可以加到 128,但少于 10 秒时建议降到 32,否则每个分量只分到很少的帧,训练不稳定。reg_covar=1e-4给协方差对角加一个小的正则项,防止某些分量方差趋近于零导致数值崩溃。
注意score_gmm里没有手动除以帧数。sklearn 的GaussianMixture.score返回的是每帧平均对数似然,内部已经做了归一化。很多从 Kaldi 切过来的同学会习惯性再除一次len(feat),结果短语音分数被压得很低,长语音分数虚高,阈值怎么调都不对。这个问题到第 5 章还会展开。
2.3 纯 GMM 什么时候够用:数据量和场景的边界
纯 GMM 并不是一无是处。当注册语音和验证语音都是完整句子、时长在 10 秒以上,且录音环境相对固定时,GMM 的 EER 可以做到个位数。但它的短板非常明显:每个说话人一个独立模型,模型之间没有共享信息;目标说话人只有一两句注册语音时,GMM 会把这句语音里的信道噪声、麦克风频响一并学进去,换个设备立刻翻车。
所以我的经验是:纯 GMM 只用来做原理验证和代码调试,真正面对“每个说话人只有三五条注册语音”的落地场景,直接从 GMM-UBM 开始。如果你只是想跑通整个流程、确认数据管线没问题,GMM 是最快的路径;如果目标是产品原型,看下一章。
3. GMM-UBM 与 i-vector:用通用背景模型解决注册数据不足
3.1 UBM 训练:如何用“所有人的语音”建一个背景模型
GMM-UBM 的核心思想是换一个训练目标:不再为每个目标说话人单独跑 EM,而是先用大量非目标说话人的语音训练一个通用背景模型(UBM),把它当作“所有人声音的公共分布”。目标说话人只有少量注册数据时,不做独立训练,而是把 UBM 的参数向目标说话人的数据做一次 MAP 自适应。因为 UBM 已有的协方差和权重保留了“人声的一般结构”,自适应只是把均值拉向目标,所以不会把噪声和信道也学进来。
UBM 的数据量直接决定上限。常见做法是收集几百个说话人、每人几十条以上不同场景的语音,统一特征提取后拼成一个大矩阵,用 GMM 训练。分量数通常比单说话人 GMM 大一个量级,256 或 512 起步。
def train_ubm(utt_feat_list, n_components=256, seed=0): """utt_feat_list: 每个元素是一段语音的帧级特征 [T, D]""" X = np.vstack(utt_feat_list) ubm = GaussianMixture( n_components=n_components, covariance_type="diag", reg_covar=1e-4, max_iter=100, n_init=1, random_state=seed, ) ubm.fit(X) return ubm注意n_init=1:UBM 训练数据量很大,多 init 的收益有限,但耗时翻倍;max_iter=100通常足够收敛。这里有个容易被忽视的点:UBM 的说话人分布必须覆盖目标场景的性别比例、年龄段、口音范围。如果产品只面向中文用户,UBM 却主要用英语开源数据训练,MAP 自适应后分数仍然可靠,但 i-vector 的因子分析会明显偏向训练数据的说话人空间,后面调 PLDA 时会很痛苦。
3.2 MAP 自适应:把 UBM 的均值拉向目标说话人
MAP 自适应和重新训练 GMM 的区别在于:重新训练会同时更新权重、均值和协方差,最终收敛到目标数据的局部最优;MAP 则把 UBM 参数当先验,只对均值做一次(或少量几次)贝叶斯更新。协方差和权重继承 UBM,因此目标说话人只有 10 秒语音时,模型也不会崩溃。
import numpy as np from copy import deepcopy from scipy.special import logsumexp def compute_posterior(ubm, x): """计算每帧属于每个高斯分量的后验概率""" weights = ubm.weights_ means = ubm.means_ covars = ubm.covariances_ n_comp = ubm.n_components log_lik = np.zeros((x.shape[0], n_comp)) for i in range(n_comp): diff = x - means[i] log_lik[:, i] = ( np.log(weights[i]) - 0.5 * np.sum(np.log(2 * np.pi * covars[i])) - 0.5 * np.sum(diff * diff / covars[i], axis=1) ) logsum = logsumexp(log_lik, axis=1) return np.exp(log_lik - logsum[:, None]) def map_adapt(ubm, feat, tau=10): """用 MAP 把 UBM 均值自适应到目标说话人""" gamma = compute_posterior(ubm, feat) # [T, K] n_i = gamma.sum(axis=0) # 每个分量累计帧数 mean_accum = gamma.T @ feat # [K, D] new_means = ubm.means_.copy() for i in range(ubm.n_components): if n_i[i] > 1e-6: new_means[i] = ( n_i[i] * mean_accum[i] + tau * ubm.means_[i] ) / (n_i[i] + tau) adapted = deepcopy(ubm) adapted.means_ = new_means return adaptedtau是自适应因子,控制先验的强度。tau越大,结果越靠近 UBM;tau越小,越贴近目标说话人的数据。经验上:注册语音只有 2-3 秒时取 15-20,10 秒以上取 5-10,60 秒以上的录音可以取 3 以下。实际调参建议先固定其他环节,只扫描tau的 5、10、20 三档看验证集 EER。
打分时不要直接用adapted.score(feat),因为不同说话人的自适应模型分数基准不同。标准做法是算对数似然比:score = adapted.score(feat) - ubm.score(feat)。这个比值表示“这段语音更像目标说话人,还是更像普通人”,跨说话人可比,阈值也好设。纯 GMM 没有这个先验模型,这是它难以落地的另一个原因。
3.3 从 GMM-UBM 到 i-vector:提取零阶和一阶统计量
i-vector 可以理解为 GMM-UBM 的“升级版”:一个说话人的语音在 UBM 上的偏移,可以表示成一个超向量。但超向量维度太高(256 分量 × 60 维 = 15360 维),直接拿来比相似度容易过拟合。i-vector 用因子分析把这个超向量压到 100-600 维,压缩后的向量既保留说话人差异,又对信道变化有更强的鲁棒性。
第一步是提取 Baum-Welch 统计量。对一段语音,用 UBM 计算每帧的后验,得到两个量:零阶统计量 N(每个分量的帧数累计)和一阶统计量 F(每个分量上特征与均值之差的加权累计)。这一段可以用compute_posterior直接算出来。
def compute_ivector_stats(ubm, feat): gamma = compute_posterior(ubm, feat) n_i = gamma.sum(axis=0) f_i = np.zeros_like(ubm.means_) for i in range(ubm.n_components): diff = feat - ubm.means_[i] f_i[i] = np.sum(gamma[:, i][:, None] * diff, axis=0) return n_i, f_i拿到统计量后,i-vector 本身是从一个训练好的总变化子空间矩阵 T 中提取的:先在整个训练集上估计 T,再对每条语音求解一个低维后验均值。纯 Python 从零训练 T 的 EM 迭代并不难,但收敛不稳定、调参会很耗时,常见做法是直接用 Kaldi 或 sidekit 训练 T 并导出矩阵,Python 里负责统计量计算和打分。如果只是做可行性验证,可以先把每段语音的统计量拼成向量(N 拼接 F),再用 PCA 降维,效果略差但流程通。
3.4 i-vector 打分:余弦相似度与 PLDA 的选择
i-vector 提取完成后,说话人验证变成了一个普通的向量比对问题。最简单的是余弦相似度:
def cosine_score(ivec1, ivec2): ivec1 = ivec1 / np.linalg.norm(ivec1) ivec2 = ivec2 / np.linalg.norm(ivec2) return np.dot(ivec1, ivec2)余弦相似度的前提是 i-vector 的模长与说话人身份相关,但对信道不够鲁棒。经验是:几百个说话人、几千条语音的小数据上,cosine 已经够用且稳定;数据量到几万条以上,PLDA 的概率线性判别分析通常能再降 20%-30% 的 EER。PLDA 需要估计类间和类内协方差,数据太少估不准,换上 PLDA 后 EER 反而变差的案例很常见。因此这个阶段不要迷信“越高级越好”,手头数据量决定后端选择,小数据老老实实用 cosine。i-vector 维度取 200 到 400 之间比较常见;训练说话人越多,维度可以越高,几百人时 200 维足够。
4. 深度学习声纹识别:x-vector 的实现要点
4.1 为什么深度学习路线在短语音上赢
i-vector 路线把整段语音压缩成一个全局向量,本质上是“先建模分布,再降维”。深度学习路线的思路完全不同:用神经网络在帧级特征上逐层提取局部模式,再用池化层把整段语音聚合成一个固定维度的 embedding,最后用分类头训练说话人分类。池化层的存在让模型天然支持任意长度语音,训练时的数据增强又让模型对噪声和信道更不敏感。
实际测试中,在 3 秒以下的短语音场景,x-vector 一类方法的 EER 比 i-vector 通常低 30% 以上;在长语音场景也有稳定优势。这是当前几乎所有开源声纹系统的默认结构。基于深度学习的声纹识别不等于“把音频丢给 CNN”,真正关键的是池化方式和损失函数,这两个点做好,网络结构反而不用太复杂。
4.2 x-vector 结构骨架:TDNN 与统计池化
x-vector 最早是 Snyder 等人提出的结构,核心是时延神经网络(TDNN)加统计池化。TDNN 是一维卷积的变体,通过不同的 dilation 扩大感受野,让每一层看到越来越长的上下文。统计池化不是简单取均值,而是把整段语音的帧级特征聚合为“均值 + 标准差”,这个 2D 统计量能更好保留说话人变化的细节。
import torch import torch.nn as nn class StatsPool(nn.Module): def forward(self, x): # x: [B, C, T] mean = torch.mean(x, dim=2) # [B, C] std = torch.std(x, dim=2) # [B, C] return torch.cat([mean, std], dim=1) # [B, 2*C] class XVector(nn.Module): def __init__(self, n_mfcc=30, embed_dim=512, num_speakers=100): super().__init__() self.tdnn = nn.Sequential( nn.Conv1d(n_mfcc, 512, 5, dilation=1, padding=2), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 512, 3, dilation=2, padding=2), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 512, 3, dilation=3, padding=3), nn.BatchNorm1d(512), nn.ReLU(), ) self.pool = StatsPool() self.embedding = nn.Sequential( nn.Linear(512 * 2, embed_dim), nn.BatchNorm1d(embed_dim), nn.ReLU(), ) self.classifier = nn.Linear(embed_dim, num_speakers) def forward(self, x): x = self.tdnn(x) x = self.pool(x) x = self.embedding(x) logits = self.classifier(x) return logits这个结构去掉了原文里的 segment 层,保留主干,方便改造成 ECAPA-TDNN。ECAPA 的改进主要是三点:用 SE-Res2Net 替换普通卷积、用注意力统计池化替换均值标准差池化、把多层特征融合后再池化。如果手里数据量大,直接上 ECAPA 的实现收益明显;数据量小,这个简化 x-vector 反而更容易调稳。
4.3 训练参数与 AM-Softmax:margin 是声纹的关键
损失函数是深度学习声纹区别于普通图像分类的核心。直接用 softmax 交叉熵也能训出 embedding,但类内距离压得不够紧,验证时同一个人不同录音对之间的余弦相似度波动大。常见做法是换成 AM-Softmax(加性 margin softmax),它在余弦空间里给正确类别加一个 margin,强制类间距离更大、类内更紧凑。
训练参数上,我一般用 2-3 秒随机裁剪的语音片段作为训练输入,batch size 128 到 256,embedding 维度 512,AM-Softmax 的 margin 取 0.2 到 0.35。学习率从 1e-3 开始,余弦衰减到 1e-6,训练 20-30 个 epoch。数据增强不是可选项而是必选项:加随机噪声、加混响、随机变速(0.9、1.0、1.1 倍速)三件套能显著提升跨信道表现。变速增强尤其便宜有效,它把每个说话人的数据量翻了 3 倍,且不改变说话人身份。
帧级特征这里建议用 80 维 FBank 而非 MFCC。MFCC 的 DCT 会丢掉部分信息,神经网络可以从 FBank 里自己学到需要的非线性变换,这在 x-vector 系列的实验里是稳定有效的经验。如果你手里的代码还在用 20 维 MFCC 训深度模型,可以先换成 80 维 FBank,通常能白捡一到两个点的 EER 提升。
4.4 后端比较:embedding 加余弦还是 PLDA
深度学习模型的输出不是最终分数。推理阶段,去掉分类头,取 embedding 层的向量作为该段语音的表示。然后在注册和验证的 embedding 之间做相似度打分,这一步和 i-vector 的后端完全相同。小数据上直接用余弦相似度,数据量大再上 PLDA。
有一个常见的误区:认为深度学习 embedding 效果好,就不需要做长度归一化。实际上无论是 cosine 还是 PLDA 打分,都建议对 embedding 做 L2 归一化,再对全量注册 embedding 做均值减除。做过声纹竞赛的团队都知道,embedding 的均值偏移对说话人分布敏感,去掉全局均值后系统在跨数据集测试时的稳定性明显更好。训练好的模型最好也固定一个特征提取版本,MFCC 的窗口函数、归一化方式一旦更换,整个 embedding 分布都会漂移,打分阈值必须重新标定。
5. 声纹识别上线前的高频坑:现象、原因与排查
5.1 特征与数据侧的三个坑
第一个坑是采样率不一致。现象:训练时用 16kHz 音频,上线后某一路录音是 8kHz 电话线,输入特征维数对不上或模型分数骤降,EER 从 3% 直接飙到 20%。原因:很多语音库的音频来源不同,入口没做统一重采样。解决:所有音频进入管线第一步强制重采样到 16kHz 单声道,并且要检查librosa.load返回的实际采样率,不能只看文件后缀。
第二个坑是注册语音太短。现象:同一说话人两次注册的语音互相打不过,验证阶段误拒率非常高。原因:8 秒以下的语音有效帧只有几百帧,MAP 自适应或深度学习 embedding 都容易出现高方差。解决:注册语音至少 3-5 秒,最好收集 8-12 句、跨时间段和位置的数据;GMM-UBM 路线的tau调大到 15-20,深度学习路线则建议注册时把多句语音的 embedding 取平均。
第三个坑是性别分布偏移。现象:测试集 EER 很好,上线后误拒率翻倍。原因:训练集七成是男性,实际用户六成是女性,声纹模型对性别分布非常敏感。解决:每个评估集都按性别、录音时长、信道分组计算 EER,不要只看一个总数。我见过不止一个团队卡在“测试 3%,上线 10%”的怪圈里,最后定位到的就是性别分布问题。
5.2 模型与评估侧的三个坑
第四个坑是 sklearn 的score二次归一化。现象:短语音分数特别低,长语音分数特别高,且代码看起来没毛病。原因:GaussianMixture.score已经按帧数做了平均,有些从 Kaldi 转过来的实现会再除以len(feat),导致分数被错误压缩。解决:直接调用score,如果要写自己的对数似然实现,先统一确认是否已经归一化。
第五个坑是 PLDA 在小数据上翻车。现象:信心满满把余弦换成 PLDA,EER 反而上涨 20%。原因:PLDA 需要足够多的说话人和语音对来估计类间、类内协方差,几百人几千条语音的规模下估计值方差很大。解决:训练说话人少于几百人时继续用余弦;数据量上万条后再换 PLDA,并在独立验证集上对比后再上线。
第六个坑是把测试集上的 EER 阈值当成上线阈值。现象:离线测试 EER 1.8%,上线后误拒率远高于测试。原因:EER 是在测试集上事后扫描出来的最优点,属于“开卷考试”结果,天然乐观。解决:把数据分成训练集、阈值校准集、测试集三部分,在校准集上按业务需求选阈值——比如银行场景更怕误放,就在校准集上把等错误率阈值往 FAR=0.1% 的方向移动。不要直接在测试集上挑阈值,这是最基础但最容易犯的评估错误。
6. 用 EER 和 DCF 给声纹系统做体检:验证方法与三个实操技巧
验证声纹系统最常用的指标是 EER(等错误率),即误拒率 FRR 等于误纳率 FAR 时的取值。EER 越低越好,但上线时一般不会用 EER 对应的阈值,而是按业务侧重点重新选阈值。DCF(检测代价函数)则把两类错误的代价加权,更贴近真实场景。算 EER 的代码很简单:
import numpy as np from sklearn.metrics import roc_curve def compute_eer(scores, labels): # scores: 所有测试对的相似度分数 # labels: 1 表示同一个人,0 表示不同人 fpr, tpr, _ = roc_curve(labels, scores) fnr = 1 - tpr idx = np.argmin(np.abs(fpr - fnr)) return (fpr[idx] + fnr[idx]) / 2计算 EER 之前,先做好三件事。第一,trials 文件要保持“同一个人不同录音”和“不同人录音”两类样本数量接近,否则 EER 会偏向占比多的一类。第二,注册语音和验证语音在时间上要有间隔,比如注册用周一录的音,验证用周三录的音,这能暴露录音环境变化带来的衰减;如果注册和验证来自同一次录音的相邻片段,测试结果会虚高。第三,做完 embedding 提取后先做全局均值减除和长度归一化,再进打分,这个预处理对 i-vector 和深度学习都有效。
我最早做声纹验证原型时,也踩过“测试 3%,上线 10%”的坑,后来学乖了:每个评估集必须按性别、录音时长、信道三个维度拆开报 EER,总体指标反而只作为参考。现在养成的习惯是先跑一遍“录音重放攻击”测试——用另一台手机录下发声人的语音再放给麦克风,如果这种攻击能骗过系统,说明声纹特征里混入了设备和信道信息,特征提取或增强链路还要再磨。声纹识别没有银弹,数据规模决定技术路线,验证协议决定你能看到多少真实问题。希望帮到你。
本文还有配套的精品资源,点击获取