
简介这是一套基于 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, sr16000, n_mfcc20, n_fft400, hop160): y, sr librosa.load(path, srsr, monoTrue) mfcc librosa.feature.mfcc( yy, srsr, n_mfccn_mfcc, n_fftn_fft, hop_lengthhop ) delta1 librosa.feature.delta(mfcc, order1) delta2 librosa.feature.delta(mfcc, order2) feat np.vstack([mfcc, delta1, delta2]) return feat.T # shape: [帧数, 60]这里有两个参数容易被忽略。n_fft400在 16kHz 下正好是 25mshop160是 10ms如果改采样率而不调整这两个值实际覆盖的时间范围就变了。另一个是librosa.load默认会做重采样但输入文件本身的采样率不会被检查建议在训练管线入口统一转成 16kHz 单声道 WAV避免不同来源音频直接混进特征提取。2.2 用 sklearn 把 GMM 基线跑通训练与打分代码拿到帧级特征后GMM 的思路是每个说话人的声学特征分布在特征空间中形成若干团簇用若干个高斯分量去拟合这个分布。识别时计算待测语音在当前说话人 GMM 下的似然谁分数高就判给谁。from sklearn.mixture import GaussianMixture def train_gmm(feat, n_components64, reg_covar1e-4, seed0): gmm GaussianMixture( n_componentsn_components, covariance_typediag, reg_covarreg_covar, max_iter200, n_init2, random_stateseed, ) 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_covar1e-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_components256, seed0): utt_feat_list: 每个元素是一段语音的帧级特征 [T, D] X np.vstack(utt_feat_list) ubm GaussianMixture( n_componentsn_components, covariance_typediag, reg_covar1e-4, max_iter100, n_init1, random_stateseed, ) ubm.fit(X) return ubm注意n_init1UBM 训练数据量很大多 init 的收益有限但耗时翻倍max_iter100通常足够收敛。这里有个容易被忽视的点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], axis1) ) logsum logsumexp(log_lik, axis1) return np.exp(log_lik - logsum[:, None]) def map_adapt(ubm, feat, tau10): 用 MAP 把 UBM 均值自适应到目标说话人 gamma compute_posterior(ubm, feat) # [T, K] n_i gamma.sum(axis0) # 每个分量累计帧数 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越大结果越靠近 UBMtau越小越贴近目标说话人的数据。经验上注册语音只有 2-3 秒时取 15-2010 秒以上取 5-1060 秒以上的录音可以取 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(axis0) 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, axis0) 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, dim2) # [B, C] std torch.std(x, dim2) # [B, C] return torch.cat([mean, std], dim1) # [B, 2*C] class XVector(nn.Module): def __init__(self, n_mfcc30, embed_dim512, num_speakers100): super().__init__() self.tdnn nn.Sequential( nn.Conv1d(n_mfcc, 512, 5, dilation1, padding2), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 512, 3, dilation2, padding2), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 512, 3, dilation3, padding3), 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-Softmaxmargin 是声纹的关键损失函数是深度学习声纹区别于普通图像分类的核心。直接用 softmax 交叉熵也能训出 embedding但类内距离压得不够紧验证时同一个人不同录音对之间的余弦相似度波动大。常见做法是换成 AM-Softmax加性 margin softmax它在余弦空间里给正确类别加一个 margin强制类间距离更大、类内更紧凑。训练参数上我一般用 2-3 秒随机裁剪的语音片段作为训练输入batch size 128 到 256embedding 维度 512AM-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 在小数据上翻车。现象信心满满把余弦换成 PLDAEER 反而上涨 20%。原因PLDA 需要足够多的说话人和语音对来估计类间、类内协方差几百人几千条语音的规模下估计值方差很大。解决训练说话人少于几百人时继续用余弦数据量上万条后再换 PLDA并在独立验证集上对比后再上线。第六个坑是把测试集上的 EER 阈值当成上线阈值。现象离线测试 EER 1.8%上线后误拒率远高于测试。原因EER 是在测试集上事后扫描出来的最优点属于“开卷考试”结果天然乐观。解决把数据分成训练集、阈值校准集、测试集三部分在校准集上按业务需求选阈值——比如银行场景更怕误放就在校准集上把等错误率阈值往 FAR0.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总体指标反而只作为参考。现在养成的习惯是先跑一遍“录音重放攻击”测试——用另一台手机录下发声人的语音再放给麦克风如果这种攻击能骗过系统说明声纹特征里混入了设备和信道信息特征提取或增强链路还要再磨。声纹识别没有银弹数据规模决定技术路线验证协议决定你能看到多少真实问题。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。