声纹识别技术演进:从GMM-UBM到深度学习,Python实现全解析
简介这是一份基于Python实现的说话人识别算法项目压缩包也称声纹识别包含高斯混合模型、高斯混合模型-通用背景模型、i-vector以及基于深度学习的自注意力声纹识别等多种实现方式适用于计算机、数学、电子信息等专业的课程设计、期末大作业或毕业设计也可作为初学者实战演练和初期项目立项演示的重要参考资料。压缩包内共20个文件其中15个Python脚本是核心算法代码另有一个Jupyter Notebook交互式演示文件、一份Markdown说明文档、一个SQLite数据库以及JSON配置文件等整体容量仅144KB结构精炼易于下载后直接运行和学习。代码覆盖传统统计模型与深度学习两条技术路线从语音特征提取到声纹建模、训练与识别验证均有实现配合说明文档可以帮助读者系统理解说话人识别的原理和工程实现细节也方便在现有基础上进行改进和拓展。目前已有229人学习/下载源码完整、依赖较少适合需要快速搭建声纹识别原型、完成课程作业或深入钻研算法原理的开发者。1. 说话人识别从GMM到深度学习Python落地的技术主线说话人识别和语音识别是两条技术路线前者回答“谁在说话”后者回答“说了什么”。标题把声纹识别领域的四条技术主线与Python实现打包在一起GMM是基线GMM-UBM解决了数据不足下的建模ivector把它变成低维向量检索深度学习则用神经网络重新定义帧与话语级表示。对做语音身份认证、声纹采集的工程师来说中小数据规模下最可靠的路径仍是“先严格做特征、再做统计模型”对入门者它是把声纹识别从理论落到代码的完整参考。下文按“先建模、再做向量、再上深度网络”展开每章都有可运行的Python片段和关键参数。2. GMM与GMM-UBM传统声纹识别的概率模型基础GMM在声纹识别里的定位很多人一开始会理解反它不是在识别一句话的内容而是在描述一个人声音的高斯混合概率分布。语音先被切成25ms短帧每帧提取MFCC参数一个说话人的所有帧在D维特征空间里会形成几个稠密簇簇的轮廓不一定规则GMM就用K个高斯分量按不同权重叠起来去逼近每个簇的形状。由于帧之间天然有时间先后GMM并不给前后帧建模它只关心整体分布形状因此对“谁说的”很敏感对“说了什么”不敏感这正好是说话人识别需要的特性。2.1 从MFCC到对数似然GMM在声纹识别里表达什么特征选择上工程里最稳的组合是13维静态MFCC加一阶二阶差分得到39维特征采样率统一到16kHz。MFCC比原始波形更适合作为GMM输入是因为倒谱变换能把声道激励和声道响应分开前者携带说话人个性后者大多来自录音设备和信道。GMM会关注细碎的分布差别所以特征里混入信道成分时模型会把“信道”也当作说话人特征学进去。这也是为什么同一个人在固定电话和手机上录两段音GMM也会给出不同分数的原因。实际搭建时特征提取和UBM训练完全可以先用现有库跑通。常见做法是librosa负责音频读取与MFCCscikit-learn的GaussianMixture负责EM训练。下面是一个可以直接替换文件路径运行的流程。2.2 用Python训练UBM的最小流程MFCC提取与GMM拟合import librosa import numpy as np from sklearn.mixture import GaussianMixture def extract_mfcc(wav_path, sr16000, n_mfcc13): # 统一采样率是前提否则后续所有MFCC统计量都会偏移 y, _ librosa.load(wav_path, srsr) # n_fft400对应25mshop_length160对应10ms mfcc librosa.feature.mfcc(yy, srsr, n_mfccn_mfcc, n_fft400, hop_length160, n_mels40) delta1 librosa.feature.delta(mfcc, order1) delta2 librosa.feature.delta(mfcc, order2) feat np.concatenate([mfcc, delta1, delta2], axis0) # 39 x T feat feat.T # 按帧标准化抵消录音音量差异 feat (feat - feat.mean(axis0)) / (feat.std(axis0) 1e-6) return feat.astype(np.float32) def build_ubm(wav_list, n_components256): # 用背景语音库训练一个不区分说话人的通用模型 chunks [] for wav in wav_list: feat extract_mfcc(wav) T feat.shape[0] # 只取中间2/3丢弃起止静音造成的异常帧 chunks.append(feat[T // 6: 5 * T // 6]) X np.vstack(chunks) ubm GaussianMixture(n_componentsn_components, covariance_typediag, max_iter100, reg_covar1e-4, random_state0) ubm.fit(X) return ubm这段代码前一部分是标准的MFCC前端n_fft400与hop_length160是16kHz下对应的帧长帧移不要随意改动否则训练和推理的特征空间会不一致。n_mels40表示mel滤波器数量常见40或80差异不大但必须全局一致。中间跳过首尾各六分之一的目的是把静音帧和高能量突变帧过滤掉避免它们进入UBM的统计量。build_ubm里的n_components控制高斯分量数背景库越大可以设得越大一般256已经够用。代码里的标准化是按帧做的而不是全局mean原因是GMM的EM迭代对每个维度的尺度非常敏感按帧标准化能让每帧的能量波动被压平。scikit-learn的GaussianMixture默认用k-means做EM初始化启动较慢但数据量几万帧时完全可接受。训练UBM的语料需要覆盖尽量多的说话人最好超过50人总量时长至少2小时否则后面MAP自适应出来的模型会明显偏向背景库里的少数人。2.3 MAP自适应生成说话人GMM与LLR打分UBM训练好以后每个说话人的模型不需要从头训练。常见做法是做MAP自适应把UBM的均值作为先验用该说话人的几十秒语音把某些高斯的均值向该说话人的实际中心拉近拉近的幅度由该高斯上出现的帧数决定。import copy def map_adapt(ubm, spk_feats, relevance_factor16.0): # 深拷贝一份UBM作为说话人模型的底子 gmm copy.deepcopy(ubm) resp ubm.predict_proba(spk_feats) # 后验概率 (T, K) n_k resp.sum(axis0) # 每个高斯成分上的帧数 f_k resp.T spk_feats # 一阶统计量 (K, D) for k in range(ubm.n_components): if n_k[k] 1e-3: continue data_mean f_k[k] / n_k[k] # 该成分上的数据均值 alpha n_k[k] / (n_k[k] relevance_factor) gmm.means_[k] alpha * data_mean (1 - alpha) * ubm.means_[k] return gmm def llr_score(gmm, ubm, test_feats): # 说话人模型得分减去UBM得分抵消信道和口音的全局偏移 return gmm.score(test_feats) - ubm.score(test_feats)map_adapt只重估均值不重估权重和协方差这是GMM-UBM的标准简化。relevance_factor越大新模型越靠近UBM越小越依赖该说话人的数据。注册语音少于15秒时建议把relevance_factor调到20以上否则只有少数帧参与统计均值容易被极端的音素带偏。llr_score输出负数也正常阈值的绝对值没有参考意义需要在验证集上把正负样本得分的分布画出来找到FAR和FRR交点再定阈值直接拿0当阈值不对。GMM-UBM在只有十几秒注册语音时依然能工作这种抗小样本能力ivector和深度学习不一定具备。2.4 GMM-UBM的参数表与三个常见翻车点参数常见设置影响n_components128-256越大音色刻画越细注册数据少时严重过拟合covariance_typediag全矩阵参数爆炸256个高斯全协方差基本无法用小样本估计relevance_factor8-16越小越信任新数据短语音要调大max_iter50-100迭代过多次容易收敛到局部极值注册语音时长20-60s过短会触发大量 n_k 1e-3 分支第一个翻车点是不做静音过滤直接提取MFCC。手机录音开头几百毫秒通常有呼吸声和背景噪声这些帧在特征空间里集中在低能量区域UBM会分出一个高斯基去专门拟合它占用模型容量。推荐在extract_mfcc前用librosa.effects.split做一次门限分割再去掉最前最后的若干帧。第二个翻车点是背景库说话人不均衡。如果UBM训练集里一个人占了60%的语音这个“世界模型”会被他带偏所有说话人的LLR得分都会整体抬升或下降。训练UBM前按说话人对时长做上限截断每人累计不超过总量的5%。第三个翻车点在采样率。声道不一致时MFCC各维均值会整体漂移前面所有阈值和参数全部失效。所有wav在进入特征提取函数前统一重采样到16k或8k一条流程里只能有一个值。3. ivector低维说话人嵌入的工程化方案GMM-UBM在原型验证阶段很顺手一旦进入在线1:N检索场景就吃力每条测试语音要和每一个注册GMM计算对数似然注册模型多了以后耗时线性增长而且没有地方挂ANN索引和缓存。ivector最大的工程价值是把一段变长语音压缩成一个固定维度的向量注册和检索都退回向量空间后续可以用余弦、PLDA甚至任意向量检索工具。它不是一个用来替代GMM的“新模型”而是一种投影策略。3.1 为什么GMM-UBM要被ivector替代从打分到向量检索ivector思想是从GMM均值超矢量引申来的。假设UBM有K个高斯每个高斯有D维均值拼接后的均值超矢量维度是KD通常256乘39约1万维。早年系统直接把超矢量当特征用维度太高且每维噪声都很大。ivector定义一个总变化子空间T维度为KD行、m列把每条语音的均值超矢量写成M m_ubm T*w其中w就是ivector。这里的T是全局共享的它同时承担说话人变化和信道变化所以训练阶段并不区分这两者这也是“total variability”的含义。比起GMM-UBMivector把问题从“算两个概率分布的相似度”改成了“算两个向量的距离”。这个改变带来两个实际好处一是复杂度从随说话人数线性增长变为固定维度m的向量运算m通常取100-600二是向量化后可以自由组合后端比如先用余弦召回候选集再用PLDA重排工程上非常灵活。3.2 总因子矩阵与ivector提取的Python计算T矩阵的训练不是纯Python几行能完成的常见做法是通过kaldi的ivector-extractor、Bob工具链或预训练资源获得格式都是现成的矩阵文件。下面的代码聚焦提取端假设UBM和T已经就位输入一段语音的特征直接输出m维ivector。class IvectorExtractor: def __init__(self, ubm, T): self.ubm ubm self.K ubm.n_components self.D ubm.means_.shape[1] self.m T.shape[1] # 布局: T按(k, d, m)展开 self.T T.reshape(self.K, self.D, self.m) self.inv_cov 1.0 / ubm.covariances_ # 对角协方差的倒数 def extract(self, feats): resp self.ubm.predict_proba(feats) # (T, K) N resp.sum(axis0) # (K,) F resp.T feats # (K, D) A np.zeros((self.m, self.m)) B np.zeros(self.m) for k in range(self.K): tk self.T[k] # (D, m) # 按公式累加: A N_k * T_k^T * Sigma_k^-1 * T_k A N[k] * tk.T (self.inv_cov[k][:, None] * tk) # B T_k^T * Sigma_k^-1 * F_k B tk.T (self.inv_cov[k] * F[k]) # w (I A)^-1 * B return np.linalg.solve(np.eye(self.m) A, B)这里有几个细节需要解释。predict_proba得到的是每帧对K个高斯的后验概率N_k就是第k个高斯被激活的有效帧数F_k是一阶统计量相当于每帧特征按后验加权求和。A和B的累加把公式里的块运算以逐成分方式展开避免构造巨大的稀疏矩阵。m在100时A只有100乘100单线程下提取一个ivector在毫秒级。注意如果T矩阵是从kaldi的ivector-extractor导出的导出时的维度顺序和本代码的reshape方式一致使用前务必打印T.shape核对第一个维度应等于K*D。实现中的T.reshape顺序假设矩阵保存顺序是“逐成分逐维展开”如果从pickle加载要先用T.shape核对。协方差部分默认是diag如果训练UBM时用full协方差这里要改成对协方差矩阵做cholesky分解再处理不能直接取倒数。3.3 余弦相似度与PLDA打分后端多选一ivector训练出向量后打分有两种主路径。余弦相似度不需要额外训练数据但对信道变化比较敏感PLDA通过类内和类间两部分协方差建模能进一步压缩信道分量但需要一批有标注的说话人ivector来训练。实际项目里常用两段式先余弦召回Top-N再用PLDA重排兼顾性能和准确率。def length_normalize(v): # 长度归一化对两种后端都有益PLDA推导中默认已包含归一化 return v / (np.linalg.norm(v) 1e-6) def cosine_score(enroll_iv, test_iv): # 归一化后内积等价于余弦相似度 return length_normalize(enroll_iv) length_normalize(test_iv)PLDA的完整训练在这里展开很长实际工程一般调用bob.learn.plda或kaldi里的训练脚本得分部分只需要一个函数输入两个ivector输出一个似然比。无论用哪种后端ivector训练和提取时用的UBM、T必须来自同一套数据混用两套不同语料训练的模型向量空间不对齐得分必然虚高。后端训练需要对信道鲁棒性工程复杂度适用场景余弦相似度无一般最低快速验证、小规模1:1PLDA数百个说话人的ivector较好中正式上线、长期系统两段式需要PLDA训练最好中1:N大库检索3.4 ivector训练的数据要求与因子数选择因子数m的选择直接决定系统泛化能力。数据量在500-1000段语音、100人以上时m取100-200比较安全数据规模到几千小时后m可以加到400-600。m过大的直接表现是同一人不同录音的ivector方差变大注册信息不足时EER飙升m过小时不同人的ivector挤在一起系统整体拒绝率升高。工程上可以固定其他条件扫m100/200/400三档在验证集上取EER最低的一档。训练T矩阵的语料最好是和上线场景同领域的语音至少覆盖男女。规模达不到大型评测集没关系只要保证每个说话人至少两条不同场合的录音总量不低于10小时也能训练出可用的T。提取ivector前必须做VAD把音乐、掌声、啸叫消除这些异常帧对F_k的影响会被T放大直接体现在向量尾部几个维度上。最后建议在ivector输出后加上长度归一化不管后面接不接PLDA都能让同一设备的多次打分的分布更集中。4. 基于深度学习的声纹识别x-vector与ECAPA-TDNN的PyTorch路线深度学习路线的本质还是“变长语音到固定向量”但不再手工指定GMM和总变化子空间帧级特征提取、时序建模和话语级聚合都让网络自己学。x-vector是这条路线里最经典的工程基线ECAPA-TDNN是目前开源评测里的常青树。两者的核心差别在两点一是卷积块是否带通道注意力二是统计池化之前是否把多层特征叠加起来。对Python工程师来说PyTorch生态里这两类结构的实现和预训练权重都比较容易拿到落地门槛已经接近传统方法。4.1 帧级网络加统计池化x-vector的核心结构与ECAPA的差异x-vector由三部分构成帧级部分用若干层一维卷积在时间轴上开窗每帧输出512维中间表示统计池化层把整条语音的所有帧做时间维度的均值与标准差得到一个1024维的段级特征段级部分再经过两层全连接压到512维得到说话人嵌入。这里的池化层等效于ivector里的零阶与一阶统计量只是统计的对象从MFCC变为了网络学到的隐含特征。ECAPA-TDNN主要在三个方面改进用SE-Res2Block把通道注意力和多尺度特征结合不同感受野的信息可以按通道加权后再融合统计池化前把前面几层输出拼接最后的平均统计量来自多层语义回退连接和最终的多层聚合让嵌入中同时包含浅层音色和深层时序信息。在VoxCeleb类评测集上ECAPA-TDNN的EER通常比x-vector低30%-50%但参数量和推理耗时也高一个量级。4.2 PyTorch实现x-vector嵌入网络与AAMSoftmax训练声纹网络和图像分类的最大不同在于输入是变长语音batch内要裁成一样长通常随机在每段2-4秒窗口内取。下面给出一个能在GPU上直接训练简化x-vector的最小实现import torch import torch.nn as nn class TDNNBlock(nn.Module): # 一维卷积 BN ReLUpadding按dilation对齐使时间长度不变 def __init__(self, in_dim, out_dim, kernel3, dilation1): super().__init__() self.conv nn.Conv1d(in_dim, out_dim, kernel, dilationdilation, padding(kernel - 1) * dilation // 2) self.bn nn.BatchNorm1d(out_dim) self.relu nn.ReLU() def forward(self, x): return self.relu(self.bn(self.conv(x))) class XVectorBackbone(nn.Module): def __init__(self, feat_dim40, emb_dim512): super().__init__() self.frame nn.Sequential( TDNNBlock(feat_dim, 512, kernel5, dilation1), TDNNBlock(512, 512, kernel3, dilation2), TDNNBlock(512, 512, kernel3, dilation3) ) self.segment nn.Sequential( nn.Linear(512 * 2, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Linear(512, emb_dim), nn.BatchNorm1d(emb_dim) ) def forward(self, x): # x: (B, feat_dim, T) x self.frame(x) mean x.mean(dim-1) std x.std(dim-1) pool torch.cat([mean, std], dim-1) return self.segment(pool)这段网络把时长维度从输入端到统计池化前保持不变dilation逐层扩大让输出帧看到越来越大的上下文第一层5、后续3的kernel组合参考了原始x-vector的配置。统计池化拿均值加标准差等价于估计了每个通道的均值和离散程度后者对说话人区分非常重要不同人在某些频带上的波动模式差异很大。如果输入log-mel是40维这个backbone输出的嵌入是512维。配合的分类头常用AAMSoftmax在余弦相似度上直接加角度margin比普通softmax更容易学到紧凑的类内分布class AAMSoftmax(nn.Module): def __init__(self, emb_dim512, num_speakers1000, scale30.0, margin0.2): super().__init__() self.scale scale self.margin margin self.W nn.Parameter(torch.randn(emb_dim, num_speakers)) def forward(self, emb, label): # 归一化后计算余弦距离 emb nn.functional.normalize(emb, dim-1) w nn.functional.normalize(self.W, dim0) cos (emb w).clamp(-1 1e-5, 1 - 1e-5) theta torch.acos(cos) # 只在目标类别的角度上减去margin one_hot nn.functional.one_hot(label, num_classesself.W.size(1)).float() out torch.cos(theta - margin * one_hot) return self.scale * outscale是归一化后的放大系数常见值30margin控制类间角度间隔0.2是常用起点数据充足时可以逐步调到0.5。AAMSoftmax训练会比普通softmax慢一些但收敛后的嵌入分布明显更聚拢。这里没有写训练循环原因是它和常规分类训练没有区别loss用交叉熵优化器SGD或AdamW均可。4.3 数据增强与训练策略让Embedding对信道和说话风格更鲁棒训练声纹模型时同一个说话人不同录音的差异往往大于两个说话人之间的差异所以数据增强比调网络结构更先见效。常用套路有三类速度扰动0.9到1.1倍速相当于说话风格微变SpecAugment在log-mel时间或频率轴上随机遮挡迫使网络不依赖局部频带随机加噪和RIR混响模拟麦克风变化。下面这段在训练时对log-mel做时间频率掩蔽可以插在batch加载后def spec_augment(x, freq_mask10, time_mask10): # x: (B, F, T) 的log-mel B, F, T x.size() if freq_mask 0: f0 torch.randint(0, F - freq_mask, (B, 1)) mask torch.ones_like(x) for b in range(B): mask[b, f0[b]:f0[b] freq_mask, :] 0 x x * mask if time_mask 0: t0 torch.randint(0, T - time_mask, (B, 1)) mask torch.ones_like(x) for b in range(B): mask[b, :, t0[b]:t0[b] time_mask] 0 x x * mask return x随机掩蔽的宽度不能过大频域掩蔽超过15个bin或时域超过30帧时有效信息被遮断太多训练会不稳定。另一个容易被忽略的是特征一致性训练时log-mel用的是40维还是80维、fmin/fmax是多少、是否做了CMVN推理时全部要一致。建议把特征提取和网络前向封装成同一个infer_embedding函数注册和验证都走这条路。常用参数参考配置项常用值说明log-mel维数40或80x-vector原版40ECAPA常用80训练段长2-4秒随机窗口不足则丢弃batch size128-256尽量让每个batch覆盖多个说话人AAMSoftmax scale/margin30 / 0.2从0.15开始微调学习率1e-3衰减到1e-5建议加warmup 5个epoch4.4 EER与DCF评价阈值不是拍脑袋定的模型训练完之后需要一份和训练集完全分离的测试集里面包含同一批说话人的多次录音打分得到一组相似度。EER是让FAR和FRR相等时的错误率计算时并不需要指定阈值代码很短def compute_eer(scores, labels): # labels: 1表示同人0表示不同人 scores np.asarray(scores) labels np.asarray(labels, dtypebool) best (np.inf, None) for thr in np.sort(scores): far (scores[~labels] thr).mean() frr (scores[labels] thr).mean() diff abs(far - frr) if diff best[0]: best (diff, thr) far (scores[~labels] best[1]).mean() frr (scores[labels] best[1]).mean() return (far frr) / 2, best[1]EER是整体均衡性的一个数字但对认证系统来说上线阈值通常不取EER点而是取满足指定过误率条件的最小错拒也就是minDCF。DCF的权重由业务场景决定门禁系统更怕误放行会压低FAR手机解锁更怕频繁误拒会抬高FAR。所以在正式交付时准确率和阈值是两个独立问题前者在测试集上定后者用一段与生产环境同分布的负样本调。这个校准问题在下一章的阈值归一化里专门处理。5. 四种算法选型对比与上线前的实用校准技巧5.1 声纹识别算法选型的三个决策条件看完前四章的代码以后很多人会问到底该用哪一个。我的经验是先看数据量注册语音只有十几秒、也没有成规模的背景库时GMM-UBM最合适因为它在“没有数据”时是唯一不乱跑的模型如果有一批跨场景语音可供训练ivector是性价比最高的选择数据达到几千段且能用GPU训练时深度学习才有实际优势。其次是看线上硬件和延迟纯CPU低延迟场景下ivector优于深度学习。最后看检索规模1:N超过十万级时深度学习嵌入配合向量索引最合理。方法最小数据要求后端依赖部署体积典型EER区间GMM-UBM每人30sUBM库数小时无需训练后端极小5%-10%ivector10小时以上数十人需要T矩阵小3%-7%x-vector数千段GPU需要标注训练中2%-5%ECAPA-TDNN数千段GPU需要标注训练较大1%-3%EER区间会随语言、信道、录音设备大幅浮动这个表只用来判断量级不要当作验收标尺。5.2 Python工程落地中的依赖与特征对齐Python环境本身不是瓶颈但第一版跑起来常卡在库版本上。建议固定librosa、scikit-learn、torch三件套的版本而不是一直升到最新因为librosa在0.10里调整过部分默认参数。深度学习部分需要GPU时按PyTorch官网给出的命令安装对应CUDA版本不要直接用pip默认的CPU包再到处找加速。开发机上用VSCode配置Python环境时核心是保证命令行和IDE解释器是同一个虚拟环境这类问题通常会在import阶段暴露。到了生产常见做法是把特征提取和模型推理封装成独立服务用ONNX导出深度学习模型避免线上再装一套torch。特征对齐是比环境更隐蔽的坑。训练和推理时MFCC的n_fft、hop_length、n_mels、是否做CMVN必须完全相同一个参数不同EER可能差两个点以上。建议在项目里写一个自检脚本把同一段wav读两遍断言两次特征提取输出的最大误差小于1e-6这个脚本应该进CI。5.3 一个阈值校准技巧用注册集对得分做归一化最后一个技巧针对“校准”这个最容易被忽略的环节。ivector或深度学习嵌入在注册完成后可以先在两两注册样本之间打分得到一类打分的均值和标准差再用一个线性变换把线上得分转到这个尺度上。这样做的意义是不同设备、不同时间采集的注册集得分的整体偏移可以被压缩阈值就能跨设备迁移。def score_norm(register_ivs): # 用注册集内部两两余弦分构造归一化系数 scores [] for i in range(len(register_ivs)): for j in range(i 1, len(register_ivs)): scores.append(cosine_score(register_ivs[i], register_ivs[j])) mu, std np.mean(scores), np.std(scores) def trans(s): # 测试语音与注册集的得分要减去同一个mu和std return (s - mu) / (std 1e-6) return trans这套归一化在注册人数大于等于3时就能用注册样本越多估计越稳。它不能替代阈值选择但能把不同环境下的阈值收敛到接近同一个值。上线后如果发现FAR长期偏高先不看模型去检查麦克风是不是换了型号、注册音频是不是经过了降噪处理这两类环境变化对得分分布的影响远大于模型微调能带来的提升。把归一化系数和注册时间一起存进记录表设备变更后重算一次就能把系统的错误率稳定在阈值附近。本文还有配套的精品资源点击获取