☰
声纹识别算法实战:GMM-UBM、i-vector与x-vector全解析
2026/10/17 10:35:39 网站建设 项目流程

简介:这是一份面向语音识别与声纹识别入门及进阶开发者的算法实现合集,围绕说话人识别任务,覆盖GMM、GMM-UBM、i-vector以及基于深度学习的自注意力(self-attention)方案,可帮助读者对比传统统计模型与深度模型的差异,并借助Python代码快速搭建实验环境。资源包共20个文件,以15个Python脚本为核心,辅以Jupyter Notebook示例、SQLite数据库、JSON配置及Markdown说明文档,整体大小约144KB,结构紧凑、便于按模块检索。目前已有769人学习下载,适合作为课程设计或科研入门的参考实现。除核心算法脚本外,还包含self-attention说话人识别Notebook、GMM基线脚本及README说明,能够帮助读者理解特征处理、模型训练与评估流程,同时通过SQLite和JSON文件了解数据组织方式,方便二次开发与复现实验。

1. 从一次误报说起:这个声纹项目把三种算法打包成了什么

从一段几秒钟的语音判断说话人是谁,听起来不难,但真正把声纹识别算法完整跑一遍的人都知道:同一个说话人,换个录音设备,识别分数就可能腰斩。接手这份“基于声纹识别的多种算法实现”时,我刚被门禁语音在楼道里识别率跌破及格线的问题折腾了一周。这份仓库没有追单一模型的Benchmark,而是把GMM-UBM、i-vector、x-vector三条路线从特征提取做到打分判决,每一步都有可跑的脚本和可读的代码。适合谁?一类是刚接触说话人识别、想搞懂全链路的新手,一类是给门禁、呼叫中心、取证系统接声纹能力、需要在算法间选型和替换的工程师。下面按目录、核心代码、训练流程和踩坑点拆开讲。

2. 项目全景:技术选型、依赖与目录的落地安排

2.1 为什么同时保留三套算法

很多仓库只写一个模型,跑通就给个准确率,这个项目不一样。我拆开后的第一感觉是,它更像一份“声纹算法对照实验”。GMM-UBM解决的是小样本冷启动问题,适合注册语音只有几秒、目标说话人只有十几个的场景;i-vector是传统方案里的标准中坚,先把整段语音压成一个固定维度的低维向量,再用余弦或PLDA打分,训练快、可解释性强;x-vector则是深度嵌入路线,用TDNN在帧级和句级之间做信息聚合,数据量上去之后,它在跨设备、跨信道场景下的表现通常最好。

三套算法同时保留的价值不在于“哪个更准”,而在于它们共享同一条数据管线和同一套打分评估流程。这样横向对比才有意义:同一个特征文件,喂给三个后端,出来的EER可以直接比。很多项目翻车,不是因为单个算法不行,而是三套算法的预处理、注册方式、指标口径各不相同,比出来的结果根本不可信。

2.2 目录结构:从data到deploy的一条主线

目录布局很规矩,按“数据准备 → 特征 → 模型 → 工具脚本 → 部署”分层,我拿到手基本不用改就能直接跑:

voice_recognition_algo/ ├── configs/ │ ├── feature.yaml # 采样率、MFCC维度、VAD参数 │ ├── gmm_ubm.yaml # GMM分量数、MAP自适应rho │ ├── ivector.yaml # i-vector维度、EM迭代轮数 │ └── xvector.yaml # TDNN结构、训练超参 ├── data/ │ ├── raw_wav/ # 原始音频 │ ├── list/ # train/enroll/test的名单与说话人映射 │ └── features/ # 特征缓存,中间产物 ├── core/ │ ├── feature/ # 重采样、VAD、MFCC与delta │ ├── gmm_ubm/ # UBM训练与MAP自适应 │ ├── ivector/ # 统计量计算、T矩阵训练、提取 │ └── xvector/ # TDNN模型、AM-Softmax损失、池化层 ├── tools/ │ ├── prepare_data.sh # 检查音频格式、生成list │ ├── train_gmm_ubm.sh │ ├── train_ivector.sh │ ├── train_xvector.sh │ └── score.sh # 注册、打分、输出EER └── deploy/ └── infer.py # 单条音频推理入口

configs和core是解耦的,调参不用碰代码。data/features作为特征缓存目录很关键,因为特征提取一次要跑很久,后面反复调模型时不需要重新提特征。tools下的脚本只是薄封装,真正逻辑都在core里,方便你在IDE里断点调试。

2.3 依赖与运行环境选型

这份项目的依赖选型整体偏“工程够用就好”,没有炫技。音频读取和特征计算主要靠librosa,GMM-UBM用了scikit-learn的GaussianMixture做底座,i-vector的矩阵运算是numpy手写,x-vector用torch。我列一张实际清单:

组件用途备注
librosa音频读取、重采样、MFCC/delta计算版本差异大,读取后强制重采样
scikit-learnKMeans初始化GMM、GaussianMixture主要给GMM-UBM用
numpy统计量聚合、T矩阵求解、cosine打分i-vector的数学核心
torchTDNN、统计池化、AM-Softmaxx-vector专用
soundfile音频写入与格式校验预处理阶段会用到

提示:torchaudio在这份代码里反而用得少,因为音频解码带来的采样率不一致问题最容易出在边界环节。我一般会在代码里显式声明target_sr=16000,音频读取后马上重采样,而不是依赖录音文件本身的属性。

依赖安装本身不算难,但如果用官方最新版本,librosa的接口拆过几次,delta的写法略有变化。我建议锁一个稳定版本跑通之后再考虑升级,省得在最不相关的地方花时间。

3. 核心模块拆解:三套算法的特征与模型实现

3.1 语音预处理与统一特征入口

三套算法共用同一个特征入口,这算整份代码里最舒服的设计。音频进来先统一采样率、做长度截断,然后提MFCC,并拼上delta和delta-delta特征。声纹任务和语音识别不同,通常不拼音素级别的信息,而是更看重说话人音色在频域上的稳定性,所以MFCC加动态差分是绝大多数后端的标准输入。

import librosa import numpy as np def load_and_pad(wav_path, target_sr=16000, max_len=32000): # 统一采样率到16k,避免混采样的隐患 wav, sr = librosa.load(wav_path, sr=None, mono=True) if sr != target_sr: wav = librosa.resample(wav, orig_sr=sr, target_sr=target_sr) # 截断或补零到固定帧长,方便批量训练 if len(wav) > max_len: wav = wav[:max_len] else: wav = np.pad(wav, (0, max_len - len(wav)), mode='constant') return wav def compute_mfcc(wav_path, n_mfcc=40, n_fft=512, hop_length=160): wav = load_and_pad(wav_path) mfcc = librosa.feature.mfcc( y=wav, sr=16000, n_mfcc=n_mfcc, n_fft=n_fft, hop_length=hop_length ) # delta描述帧间变化,叠加后对信道差异更鲁棒 mfcc_d = librosa.feature.delta(mfcc) mfcc_dd = librosa.feature.delta(mfcc, order=2) feat = np.vstack([mfcc, mfcc_d, mfcc_dd]).T return feat

这段代码有三个细节值得注意。max_len取32000指的是2秒音频的采样点数,超过就截断、不足就补零,这样训练时张量形状固定,不会因为音频长短不一批量报错。n_mfcc取40,拼上两阶delta后维度是120,这是声纹任务里比较常见的配置;有些仓库用13维MFCC是照搬语音识别的习惯,在声纹里明显偏薄。hop_length取160,在16k采样率下对应每10毫秒一帧,和传统语音特征提取的帧移习惯对齐。

3.2 GMM-UBM:小数据量下的兜底方案

GMM-UBM的核心不是直接给每个说话人训练一个GMM,而是先用大量非目标说话人的语音训练一个通用背景模型,再用目标说话人的少量语音在这个UBM上做MAP自适应。自适应时只更新均值,方差和权重沿用UBM,这样做的目的是用小样本估计说话人个性部分,而把共性部分牢牢锁在UBM里,避免过拟合。

from sklearn.mixture import GaussianMixture def train_ubm(feature_list, n_components=256, max_iter=20): # 把所有说话人的特征拼在一起训练一个全局GMM feats = np.vstack(feature_list) ubm = GaussianMixture( n_components=n_components, covariance_type='diag', max_iter=max_iter, random_state=0 ) ubm.fit(feats) return ubm def map_adapt(ubm, spk_feats, rho=16): # 只做MAP均值自适应,不重新训练整个GMM posterior = ubm.predict_proba(spk_feats) w = posterior.sum(axis=0) mean_stat = (posterior.T @ spk_feats) / w[:, None] adapted_mean = (rho * ubm.means_ + w[:, None] * mean_stat) / (rho + w[:, None]) adapted_gmm = GaussianMixture( n_components=ubm.n_components, covariance_type='diag' ) # 只替换均值,方差与权重保持UBM不变 adapted_gmm.means_ = adapted_mean adapted_gmm.covariances_ = ubm.covariances_ adapted_gmm.weights_ = ubm.weights_ adapted_gmm.precisions_cholesky_ = ubm.precisions_cholesky_ return adapted_gmm

MAP自适应里的rho是个关键超参。rho越大,自适应结果越贴近UBM,适合注册语音很短的情况;rho越小,说话人个性加得越多,适合注册语音比较充分的情况。我在实际使用中一般设8到16,语音少于5秒时倾向调大到20,减少噪声统计量对均值的污染。打分时用目标GMM和UBM的log-likelihood之比,比直接取目标GMM的log-likelihood更稳,因为后者会受绝对音量影响。

3.3 i-vector:平均超向量上的低维投影

i-vector的思路是把GMM的所有均值拼成一个超向量,再用一个全局因子矩阵T把它投影到低维空间。与GMM-UBM直接做模型相似度不同,i-vector先把每个说话人压成一个固定维度的向量,后续用cosine甚至PLDA打分,计算效率高很多。核心在于从帧级后验统计量推导出ivector。

def xy_stats(feats, ubm): # 计算零阶和一阶Baum-Welch统计量 posteriors = ubm.predict_proba(feats) N = posteriors.sum(axis=0) # 每个GMM分量的帧计数 F = posteriors.T @ feats # 每个GMM分量的一阶统计量 return N, F def extract_ivector(feats, ubm, T, mean, sigma_inv): N, F = xy_stats(feats, ubm) # 对一阶统计量做中心化 F_hat = F - N[:, None] * mean # 解线性方程得到i-vector A = np.eye(T.shape[1]) + T.T @ (sigma_inv * N[:, None]) @ T B = T.T @ (sigma_inv * F_hat.flatten()) ivector = np.linalg.solve(A, B) return ivector

这段代码里mean是UBM所有分量均值的拼接,sigma_inv是所有分量方差倒数的拼接。T矩阵是训练阶段用EM迭代估计的,维度一般为400或600。A矩阵的维度等于i-vector维度,求逆的开销不大,真正耗时的是在训练T矩阵时反复前向计算统计量。需要说明的是,这里手写是为了让你看清数学结构,工程上可以换成kaldi或对应封装库的实现,速度和稳定性都更好。

3.4 x-vector:用TDNN做帧级到句级的信息聚合

x-vector在说话人识别里常用的主体是TDNN加统计池化。前面几层卷积在帧级工作,通过增大dilation扩大时间感受野,然后统计池化层把整段语音的帧级输出聚合成一个句级向量,后面再接全连接层生成embedding。训练时加一个说话人分类头,用AM-Softmax拉大类间距离。

import torch import torch.nn as nn class TDNN(nn.Module): def __init__(self, input_dim=120, embedding_dim=512, num_speakers=100): super().__init__() # 帧级特征提取,通过dilation扩大感受野 self.tdnn1 = nn.Conv1d(input_dim, 512, kernel_size=5, dilation=1) self.tdnn2 = nn.Conv1d(512, 512, kernel_size=3, dilation=2) self.tdnn3 = nn.Conv1d(512, 512, kernel_size=3, dilation=3) # 统计池化:拼接均值与标准差 self.fc1 = nn.Linear(512 * 2, 512) self.fc2 = nn.Linear(512, embedding_dim) # 说话人分类头 self.classifier = nn.Linear(embedding_dim, num_speakers) def forward(self, x, apply_softmax=True): # x: [B, T, D],Conv1d需要[B, D, T] x = x.transpose(1, 2) x = torch.relu(self.tdnn1(x)) x = torch.relu(self.tdnn2(x)) x = torch.relu(self.tdnn3(x)) # 统计池化:把帧序列压缩成一个句级向量 mean = x.mean(dim=2) std = x.std(dim=2) stats = torch.cat([mean, std], dim=1) x = torch.relu(self.fc1(stats)) embedding = self.fc2(x) if apply_softmax: logits = self.classifier(embedding) return logits, embedding return embedding

dilation设计成1、2、3,每一层看到的上下文逐步变宽,第三层已经能看到较长时间范围内的语音节奏特征。统计池化用均值加标准差而不是单纯均值,是因为说话人个性不仅体现在平均音色上,也体现在发声稳定程度这一维度上,标准差正好捕捉这种波动。训练完成后,推理时取embedding层输出并做长度归一化,后面接cosine打分就行了。

4. 训练与推理:五阶段流水线怎么串联

4.1 数据清单与说话人划分

跑这个项目第一步不是直接训练,而是把数据整理成固定格式。data/list下需要三个文件:train.list、enroll.list、test.list。每行是一段WAV路径加一个说话人ID,比如audio/001.wav spk001。train用于训练UBM、T矩阵和x-vector分类头,enroll用于注册目标说话人,test用于模拟真实识别。

划分时要注意同一个人的语音片段不能既出现在train又出现在test,否则x-vector会直接通过记忆说话人编号作弊,EER好看但完全失真。我一般按录音session划分,同一时间、同一设备录的语音整体放进同一个集合,宁缺毋滥。这个项目里已经给了划分脚本,但数据格式是你自己录音时还是要先过一遍未重合检查。

4.2 从特征到模型:一个训练脚本的分阶段实现

tools下每个train脚本内部都带了stage开关,从特征提取一路跑到评估,适合调参时频繁重跑部分流程。五阶段的bash结构大致是这样:

stage=$1 if [ -z "$stage" ]; then stage=1; fi # 阶段1:重采样、VAD、MFCC,并缓存到data/features if [ $stage -le 1 ]; then python tools/extract_feat.py --config configs/feature.yaml fi # 阶段2:KMeans初始化GMM并训练UBM if [ $stage -le 2 ]; then python tools/train_ubm.py --config configs/gmm_ubm.yaml fi # 阶段3:EM训练全因子T矩阵,再提取训练集i-vector if [ $stage -le 3 ]; then python tools/train_ivector.py --config configs/ivector.yaml fi # 阶段4:训练TDNN x-vector模型 if [ $stage -le 4 ]; then python tools/train_xvector.py --config configs/xvector.yaml fi # 阶段5:对测试集打分并输出EER if [ $stage -le 5 ]; then python tools/evaluate.py --config configs/evaluate.yaml fi

用stage而不是一个脚本从头跑到尾,是为了省时间。特征提取在大规模数据上往往要几个小时,模型调参却只需要改两个yaml里的参数;如果每次重跑全部阶段,大部分时间都浪费在重复计算上。我自己的习惯是stage 1只在数据变更后才跑,后面单独用bash train_xvector.sh 4只做x-vector训练,速度立竿见影。

4.3 注册、打分与判定

训练完成之后进入使用阶段。注册时每个目标说话人取若干条语音提特征,转换为embedding后取平均,再做一个关键的长度归一化;打分时把待识别语音的embedding和注册向量做内积。长度归一化就是除以向量的模长,让比较焦点落在方向相似度而不是音量上,这在跨设备场景里能明显缓冲音量差带来的分数漂移。

def enroll(embeddings): # 多条注册语音的embedding取均值 enroll_vec = np.mean(embeddings, axis=0, keepdims=True) # 长度归一化:去掉能量差异带来的分数漂移 return enroll_vec / np.linalg.norm(enroll_vec) def score(probe_emb, enroll_vec): probe_emb = probe_emb / np.linalg.norm(probe_emb) return np.dot(probe_emb, enroll_vec.T)

这里说的embedding,对i-vector后端就是提取出来的ivector,对x-vector后端就是TDNN的fc2输出。两种向量的维度不同,但打分逻辑完全一致。阈值的选择要放到验证集上做,不要拍脑袋定0.5;实际项目中,我会在验证集上画出FAR、FRR随阈值变化的曲线,找一个满足业务容忍度的交叉点。比如门禁场景宁可多验证一次也不放陌生人进,阈值就会调高一点。

4.4 指标评价:EER与DET曲线的正确打开方式

这个项目的评估脚本实现了EER和DET曲线,逻辑不复杂,但很容易写错。EER是当错误接受率与错误拒绝率相等时的数值,越低代表算法越好。关键点是threshold要从大到小扫一遍,把所有可能的判决边界都覆盖到。

import numpy as np def compute_eer(scores, labels): # labels: 1代表同一人,0代表不同人 thresholds = np.linspace(scores.min(), scores.max(), 200) far_list = [] frr_list = [] for th in thresholds: far = ((scores >= th) & (labels == 0)).sum() / (labels == 0).sum() frr = ((scores < th) & (labels == 1)).sum() / (labels == 1).sum() far_list.append(far) frr_list.append(frr) far_arr = np.array(far_list) frr_arr = np.array(frr_list) idx = np.argmin(np.abs(far_arr - frr_arr)) return (far_arr[idx] + frr_arr[idx]) / 2, thresholds[idx]

注意scores排列顺序和labels一一对应,混了顺序EER就是错的。用numpy向量化可以替代for循环,但这份代码保留循环是为了明确展示判定边界怎么找。EER只给一个点,DET曲线则能看到算法在不同业务倾向下的表现,比如FAR降到0.1%时FRR会涨到多少,这才是选型时真正要看的。

5. 避坑指南:声纹识别项目最常翻车的五个现场

5.1 采样率不统一:分数高低全看录音设备

现象:注册时用某台手机录的16k语音,测试时换成44.1k的麦克风录,同一个人的相似度掉到0.3以下,换个人反而0.6。原因:混采样率的语音进入特征提取后,频谱整体变形,MFCC分布直接偏移;更隐蔽的是有些代码读取音频时指定了原生采样率,有些则默认重采样到44.1k,导致两个阶段特征空间根本不在一起。解决:所有音频读取入口强制resample到16000单声道,并且在prepare_data阶段就检查每个文件的采样率和时长,凡是采样率异常的单独列出来,先统一再进流程。

5.2 VAD阈值设太高:静音前的辅音全被切掉了

现象:训练集EER还不错,测试集却偏高,逐段听发现很多语音只剩“韵母段”,整句的声母都被VAD当静音切掉了,每个样本有效语音不到0.3秒。原因:能量阈值设太高,低能量的清辅音和浊音起始部分被误杀;如果阈值过低,键盘声、空调声又全被当语音保留下来。解决:用能量加过零率双维度判断,截取时前后各保留0.25秒,宁可多留一段静音也不要切断音节。从那以后我每次调VAD都会先随机抽10段语音画出能量曲线,确认阈值附近没有大量语音起始点被误杀。

5.3 GMM分量和训练数据规模不匹配

现象:训练集只有几十个说话人、每人不到一分钟,把UBM分量从64调到256,EER反而涨了2个点。原因:GMM分量多意味着模型容量大,而小数据下每个分量分到的训练帧太少,协方差估计全是噪声,MAP自适应时噪声被进一步放大。解决:注册数据少时UBM用64或128分量,数据量真正上来后再用256或512。GMM-UBM适合小样本,指的是它能把有限数据映射到UBM先验上,不代表可以无脑加大模型容量。

5.4 长度归一化的位置不对,直接导致分数漂移

现象:开发环境里EER在3%以内,换台机器跑同一套模型,EER掉到7%。原因:代码里某处对embedding做了L2归一化,另一处打分前又做了一次归一化,或者压根没做;两端统计口径不一致,分数分布整体偏移,阈值全部失效。解决:把归一化收成一个公共函数,在enroll和score里都强制调用。凡是涉及embedding比较的环节,统一在向量进入cosine之前做长度归一化,不要在模型内部做,也不要在训练时做推理时不。

5.5 GPU训练随机性:同一个seed结果还能不一样

现象:固定随机种子训练同一个x-vector配置,第一次EER是3.2%,重跑一次变3.6%,再跑一次又变3.4%。原因:CUDA的卷积算法选择在benchmark模式下是非确定性的,不同硬件上选择不同卷积实现,浮点累加顺序也不同。解决:训练开始前设置torch.backends.cudnn.deterministic=True,把benchmark设为False,再固定Python和numpy的seed;评估时同一配置跑三次取均值,不要拿单次结果当最终分数。这条血泪经验让我对“复现”这件事再也不敢大意。

6. 改造思路:把三套算法包成一个统一embedding服务

这份项目拿来直接跑通只是第一步,多数人二次开发的目标是把声纹能力接进业务系统。我的做法是先封装一个统一向量提取接口,上层只关心输入音频路径、输出embedding,后端算法可以随时切换,业务代码完全不用改。

class EmbeddingExtractor: def __init__(self, method, model_dir, device='cpu'): if method == 'ivector': self.backend = IVectorBackend(model_dir) elif method == 'xvector': self.backend = XVectorBackend(model_dir, device) elif method == 'gmm_ubm': self.backend = GMMUBMBackend(model_dir) else: raise ValueError(f"unknown backend: {method}") def extract(self, wav_path): feat = compute_mfcc(wav_path) emb = self.backend.infer(feat) return emb / np.linalg.norm(emb)

接口统一之后,三个算法就变成了同一个服务的三个后端。注册阶段走gmm_ubm做冷启动,等采集到足够语音再切换到x-vector;i-vector当交叉验证后端,三个输出如果出现明显分歧,就触发人工复核。这个小机制在门禁和呼叫中心场景里很实用,能显著降低单一算法误判带来的损失。如果想进一步提速,可以把x-vector模型导出成ONNX格式,CPU上推理延迟能压到几十毫秒量级。

改造时另一个值得做的事是缓存embedding。同一个说话人短时间内的语音特征基本稳定,不必每次识别都重新过一遍模型,用说话人ID做key把向量缓存起来,命中直接取,能省下大量算力。从那以后我每次接新的声纹需求,都会强制先把采样率、VAD阈值和长度归一化三条基线跑一遍再上模型,等着三个后端输出对齐后再动业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询