
简介基于Python实现的说话人识别声纹识别算法工程系统覆盖GMM、GMM-UBM、ivector以及基于深度学习的声纹识别几条主流技术路线完整呈现从经典统计建模到深度表示学习的发展脉络适合计算机、数学、电子信息等专业学生用于课程设计、期末大作业或毕设项目也适合声纹识别初学者作为入门实战与算法对比的研究样例。压缩包内含20个文件其中15个为Python脚本另包含Jupyter Notebook交互式演示、SQLite数据库文件、JSON配置及Markdown说明文档整体仅144KB代码精炼、模块边界清楚便于快速阅读、功能裁剪与二次开发。截至目前已有229人学习下载。借助这套资源读者可以对照GMM与GMM-UBM在背景模型和自适应上的差异理解ivector因子分析如何刻画说话人全局特征并通过基于self-attention的深度学习模型体验端到端声纹识别流程配套的README和脚本能帮助完成环境搭建、数据组织、模型训练与结果复现对毕业设计、课程汇报或竞赛调试都有直接的参考价值。1. 声纹识别项目拆解从GMM到ivector再到self-attention一条线看懂这门手艺说实话声纹识别是个容易被小看的领域——外行觉得不就是“听声辨人”真动手才发现从特征到模型再到打分每一层都塞满了细节。这个压缩包带你把整条路线走了一遍GMM、GMM-UBM、ivector、基于self-attention的深度学习方案四种技术在一个项目里串起来非常适合做毕设、课程设计或者作为研究生阶段“从传统方法过渡到深度学习”的第一份练手代码。下载解压后能直接跑通重点是你能在对比中看到每一步升级到底在解决什么问题传统方法哪里吃亏深度模型又是在哪一环补上了短板。下面我就按拆包顺序把这套代码的核心逻辑、参数设置和踩坑点全部过一遍。2. MFCC特征提取声纹系统最容易被忽视的地基2.1 为什么MFCC能代表“谁在说话”说话人识别的输入是一段wav波形但你不能直接把波形丢给分类器——采样率16kHz一秒就有16000个采样点维度太高而且原始波形里大部分信息其实跟说话人无关比如情绪、语速、环境噪声。MFCC做的事情是把人耳听觉特性映射到频域用一组倒谱系数抓住发声道的形状特征。每个人的声道长短、粗细不一样共振峰位置就不一样MFCC正好能把这些差异量化出来。项目里所有GMM、ivector、深度模型用的都是MFCC特征区别只在特征的组装方式。你如果只跑通了模型但没搞懂MFCC怎么来的后面调参基本就是玄学。2.2 MFCC提取的完整流程与参数设置这个项目里提取MFCC的脚本在scripts目录下核心逻辑是下面这个流程import librosa import numpy as np def extract_mfcc(wav_path, sr16000, n_mfcc40, n_fft1024, hop_length160, n_mels80): # 1. 加载音频并强制重采样到16k保留单声道 y, sr librosa.load(wav_path, srsr, monoTrue) # 2. 预加重人声高频能量衰减用一阶高通滤器把高频补回来 y librosa.effects.preemphasis(y, coef0.97) # 3. 分帧加窗每帧25ms帧移10ms加汉明窗避免频谱泄漏 frames librosa.util.frame(y, frame_lengthn_fft, hop_lengthhop_length) frames frames * np.hanning(n_fft) # 4. 对每帧做短时傅里叶变换取幅度谱 stft np.abs(np.fft.rfft(frames, nn_fft, axis1)) # 5. 通过Mel滤波器组再取对数模拟人耳听觉 mel_basis librosa.filters.mel(srsr, n_fftn_fft, n_melsn_mels) log_mel np.log(np.dot(stft, mel_basis.T) 1e-10) # 6. DCT得到MFCC一般取前40维 mfcc librosa.feature.mfcc(Slog_mel, n_mfccn_mfcc) return mfcc.T # 返回形状为 (帧数, 40)这段代码里几个参数值得说清楚。n_mfcc40是关键经典Kaldi基线里多半用20维但项目里统一用40维因为后面ivector和深度模型对特征分辨率更敏感多出的20维包含更多说话人细节。hop_length160对应10ms帧移帧与帧之间重叠60%这是语音识别和声纹任务里的通行设置。Mel滤波器组数量n_mels80也比常见值大目的是让低频区说话人基频和共振峰集中区保留更多区分度。我一般会建议把MFCC里第0维能量维去掉因为能量受麦克风距离影响很大同一个说话人换设备后第0维漂移最严重。项目代码里如果保留了你可以自己在预处理阶段切一刀。2.3 特征组织方式传统方法和深度学习模型的差别同样的MFCC怎么喂给后续模型的差别很大这是新手最容易懵的地方。GMM和GMM-UBM的做法是把一个说话人的所有训练语音切成帧然后把所有帧的MFCC堆成一个大矩阵传递给GMM去估计分布。ivector模型会进一步做对齐统计把每个说话人的特征按高斯分量聚合。深度模型则完全以帧序列为单位保持时间维度不变送入神经网络提取embedding。一个典型的自注意声纹网络输入形状是(batch, T, 40)其中T是帧数40是MFCC维数模型输出一个固定维度的说话人向量。这里必须提前说明你下载的zip里self-attention_speaker_rec.ipynb这个notebook里已经内置了特征预处理的单元它会自动完成上面的分帧和MFCC提取你只需要把音频路径指对就行。3. GMM与GMM-UBM几十行代码跑通第一个声纹基线3.1 为什么高斯混合模型能做说话人建模GMM把一个说话人的声学特征空间用K个高斯分量去覆盖。直观理解每个人的发音习惯会落在特定声学区域——有人说话靠后共振峰偏低有人齿音重高频区能量占比大。GMM就是在这些区域上铺K个高斯分布参数化地刻画说话人的声学特征分布。训练GMM用的是最大似然估计通过EM算法迭代更新均值、协方差和权重。但这里有个问题如果每个说话人都从零开始训一个GMM需要的数据量很大而且训练慢。课程设计的数据集往往每人只有几十条语音直接训容易过拟合。3.2 GMM-UBM的MAP自适应小样本声纹训练的通用解法这个项目里GMM-UBM模块的用法是先用所有人的语音混合在一起训练一个较大的通用背景模型UBM通常256或512个高斯分量。然后每个目标说话人注册时不重新训练而是把UBM的参数拿过来用自己的语音做一次MAP自适应本质上是把UBM的均值向量往新说话人的特征方向拉近一点。MAP自适应的核心公式长这样import numpy as np from sklearn.mixture import GaussianMixture # 假设ubm是训练好的Universal Background Model # X是当前说话人的全部MFCC特征 (N, D) def map_adapt(ubm, X, relevance_factor16.0): # 对每条特征找到最匹配的高斯分量后验 posteriors ubm.predict_proba(X) # (N, K) N_k posteriors.sum(axis0) # 每个分量的v-r统计量 # 计算每个分量上数据更新的均值 weighted_sum np.dot(posteriors.T, X) # (K, D) data_mean weighted_sum / N_k[:, None] # MAP公式alpha N_k / (N_k relevance_factor) alpha N_k / (N_k relevance_factor) new_means alpha[:, None] * data_mean (1 - alpha)[:, None] * ubm.means_ adapted_gmm GaussianMixture( n_componentsubm.n_components, covariance_typediag, max_iter10, reg_covar1e-4 ) adapted_gmm.means_ new_means adapted_gmm.covariances_ ubm.covariances_ adapted_gmm.weights_ ubm.weights_ adapted_gmm.precisions_cholesky_ ubm.precisions_cholesky_ return adapted_gmmrelevance_factor是MAP自适应里最需要调的参数。它控制新数据对模型的重塑力度取值越小模型更贴合当前说话人但如果数据量少很容易过拟合取值越大模型越保守越发接近UBM。项目默认取16这是Kaldi里的常见设置。我自己的血泪经验是当每人训练语音少于10条时把relevance_factor提到32EER一般能降半个点。协方差和权重不更新只更新均值这能显著压低过拟合也是算法稳定性的关键。3.3 打分环节对数似然差决定你是谁识别阶段对一段测试语音提取MFCC后分别计算它在两个说话人GMM下的对数似然差值大的那个就是最终判定。实际项目中常用“似然归一化”避免不同句子长短对似然值的影响def score_log_likelihood(gmm, mfcc_frames): log_likelihood gmm.score(mfcc_frames) # sklearn内置均值化打分 return log_likelihood # 注册时target_gmm_A map_adapt(ubm, features_A) # 预测时ll_A score_log_likelihood(target_gmm_A, test_frames) # ll_B score_log_likelihood(target_gmm_B, test_frames) # 若 ll_A ll_B则判定为A注意代码里gmm.score返回的是“平均对数似然”是sklearn自动按帧数归一化后的结果。这也是血泪教训如果自己手写对数似然求和长语音的似然值天然会更大导致判什么都往高分的模型偏语音一长系统就出问题。项目在GMM部分用的是归一化打分深度模型部分则采用了另一种策略下面会讲。4. ivector把整段语音压缩成一条低维说话人指纹4.1 从超向量到ivector解决GMM打分不稳的瓶颈GMM-UBM在几十个人的小型任务上表现尚可但一旦说话人数变多或者电话信道噪声大准确率就会明显掉。原因在于GMM直接对特征分布建模对信道和噪声过于敏感。ivector的思路是一个说话人的一段语音不只是被GMM当作一个分布来处理而是看作“说话人因子 信道因子”混合生成的结果。通过因子分析把整段语音降到一个低维向量。这个向量就是ivector通常是400到600维。它把说话人特征从“一组高斯分布的参数”压缩成一段固定长度的向量之后说话人比对就从“算两个复杂模型的距离”简化为“算两条向量的夹角”稳定性和速度都有提升。4.2 提取ivector前的统计量计算这一步比模型本身更重要ivector提取前必须计算Baum-Welch统计量。用UBM做后验对齐后把每一帧的特征贡献累积起来def baum_welch_stats(ubm, mfcc_frames): # mfcc_frames: (T, D) 当前语音的所有帧 # 后验概率形状 (T, K) post ubm.predict_proba(mfcc_frames) # 零阶统计量 N_c每个高斯分量的累积帧数 N_c post.sum(axis0) # (K,) # 一阶统计量 F_c对每个分量累积特征偏移量 # 注意这里计算的是相对于UBM均值偏移量后续要用 F_c np.zeros((ubm.n_components, mfcc_frames.shape[1])) for c in range(ubm.n_components): diff mfcc_frames - ubm.means_[c] F_c[c] np.sum(post[:, c][:, None] * diff, axis0) # 二阶统计量一般保存在内存里计算总散射矩阵这里先不展开 return N_c, F_c统计量算好后ivector的计算公式是phi M T * w其中T是总变化因子矩阵w就是ivector。在实际Python项目中T矩阵通常由训练集统计量用EM算法迭代估计这段代码比较长项目里直接放在了scripts下的ivector脚本中不需要你自己实现但你要明白输入是MFCC输出是几百维向量。4.3 ivector的后端处理余弦相似度与PLDA拿到ivector还不够直接用余弦相似度打分时信道影响仍然明显。常见的做法是先做长度归一化def length_norm(x): return x / np.linalg.norm(x, axis-1, keepdimsTrue) # 比对两段语音的ivector cos_sim np.dot(length_norm(ivector_A), length_norm(ivector_B))如果数据集足够大、说话人数在几百以上建议用PLDA概率线性判别分析替代余弦打分。PLDA会显式建模说话人间差异和信道分布在多说话人、跨设备场景下通常能比余弦相似度带来10%-20%的相对EER下降。这对毕设来说是个很好的加分项写在论文里完全说得通。5. 深度学习声纹模型self-attention实现与三个典型翻车点排查5.1 为什么深度模型里面需要有self-attention深度学习声纹模型通常先把每帧MFCC映成一个局部embedding但一整段语音的帧数从几百到几千都有。如果直接对帧特征做平均池化长语音中的关键说话人特征会被淹没。平均池化默认每帧重要程度一样这在说话人识别里很吃亏因为中间某些帧可能正好是清音、咳嗽或喷麦噪声。self-attention机制做的事情是让模型自己学会“哪些帧对说话人身份更重要”然后对帧级embedding做加权平均生成整段语音的说话人embedding。这个项目里的self-attention_speaker_rec.ipynb核心就是这个池化结构。5.2 自注意力声纹网络的核心实现属于必须能看懂的模块。用PyTorch实现的一个最小可用自注意力声纹模型长下面这样import torch import torch.nn as nn class SelfAttentionPooling(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() self.attention nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1), nn.Softmax(dim1) ) def forward(self, frame_embeddings): # frame_embeddings: (batch, T, input_dim) weights self.attention(frame_embeddings) # (batch, T, 1) pooled torch.sum(weights * frame_embeddings, dim1) return pooled class SpeakerNet(nn.Module): def __init__(self, n_mfcc40, hidden512, embedding_dim256, n_speakers100): super().__init__() self.encoder nn.Sequential( nn.LSTM(n_mfcc, hidden, batch_firstTrue, bidirectionalTrue), ) self.pooling SelfAttentionPooling(hidden * 2, 128) self.embedding_layer nn.Linear(hidden * 2, embedding_dim) self.classifier nn.Linear(embedding_dim, n_speakers) def forward(self, x): # x: (batch, T, 40) lstm_out, _ self.encoder(x) pooled self.pooling(lstm_out) embedding self.embedding_layer(pooled) logits self.classifier(embedding) return logits, embedding这段代码里的SelfAttentionPooling就是核心区别点Softmax(dim1)是对T维做归一化得出每帧的权重pooled就是模型最终拿去做分类和比对的声音指纹。训练时用的损失是交叉熵验证/测试时则只取embedding向量做相似度比对。5.3 【避坑注意】深度学习声纹训练常见的三个翻车点翻车点一训练集和测试集划分不留心导致EER虚高。现象自己按“文件顺序”划分数据集训练时loss正常下降验证集上识别率高达98%但到了真实录音测试准确率急剧掉到70%不到。原因同一个说话人的若干条录音被同时分到了训练集和测试集模型实际是“见过”测试语音的说话人的。声纹任务的正确划分必须保证训练集和测试集的说话人完全无交集——意思是训练集里的人不能出现在测试集。这是声纹识别区别于一般分类任务的最大不同点。解决按说话人ID切分数据集而不是按音频文件切。可以用sklearn.model_selection.GroupShuffleSplit分组字段传说话人ID。翻车点二验证阶段直接拿softmax分类做判断而不是用embedding相似度。现象训练集准确率99%搭建demo时却怎么都觉得不好用。原因分类器只见过训练集里的说话人对新说话人无能为力但项目真正的使用场景是“我是老用户要解锁”不是“你是100个人的哪一个”。所以模型训练完需要把分类器剥掉只用embedding输出做余弦比对之前training时验证集上显示的高分实际上是模型记住说话人ID的结果。解决训练完成后单独提取embedding用余弦相似度按阈值判断回落到传统方式。翻车点三亚力山大般的“模型可复现性问题”。现象同一份数据、同一个脚本跑两次EER差了两个百分点感觉深度模型像黑匣子。原因可能是没固定随机种子但更常出现在数据shuffle时样本不均衡导致某些说话人很少被抽到训练batch或batch size太小导致梯度更新抖动大。解决固定torch.manual_seed(0)此外强烈建议每个batch里采用“每个说话人随机取若干条样本”的采样策略而不是纯随机采样。项目里默认用torch.utils.data.Sampler实现了说话人均衡采样修改这个类时要保留均衡逻辑不要轻易换成普通随机采样。6. 模型验证与阈值选择最后一个决定系统能不能用的细节EER等错误率是声纹识别最核心的指标它表示误接受率FAR和误拒绝率FRR相等的时候的那个值。EER越低系统越好。计算EER的代码逻辑比想象中简单def compute_eer(scores, labels): # scores是相似度分数列表labels是真实标签1为同一说话人0为不同 thresholds np.linspace(min(scores), max(scores), 1000) eer float(inf) for thr in thresholds: pred scores thr far np.sum((pred 1) (labels 0)) / np.sum(labels 0) frr np.sum((pred 0) (labels 1)) / np.sum(labels 1) eer min(eer, abs(far - frr)) return eer但真正决定一个声纹系统能不能落地的是阈值不是EER。EER只是告诉你模型的平衡潜力而实际场景往往需要偏向某一侧。看下面这个几个典型场景应用场景打分方式阈值策略备注门禁考勤余弦相似度取EER点附近偏严一侧宁可误拒不可误闯手机声纹解锁余弦相似度远松于EER点允许交互重试用户体验优先银行核身辅助PLDA打分远严于EER点结合其他因子做最终决策法庭证据比对PLDA打分极高置信区间才下结论需要大量调试我自己的教训是在做过一次门禁项目时直接把EER点当生产阈值用结果误闯率高得离谱挨了一周骂。后来我才明白EER是一个模型能力的度量而阈值要结合真实使用场景和“误闯代价”去调。从那以后我每次做声纹项目都会建一个小的评估集模拟最恶劣信道和最短语音把阈值放在评估结果的一半位置再开放测试。这套流程说不上巧妙但真的能让你少踩很多坑希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。