ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python实战声纹识别:预处理、模型选型与EER评估

Python实战声纹识别:预处理、模型选型与EER评估 简介面向毕业设计、课程设计与项目开发场景一个基于Python的声纹识别项目集成了EcapaTdnn、ResNetSE、ERes2Net、CAM等主流模型并支持MelSpectrogram、Spectrogram、MFCC、Fbank等多种音频预处理方式同时引入ArcFace加性角度间隔损失对应项目中的AAMLoss以及AMLoss、ARMLoss、CELoss等损失函数兼顾识别准确率与工程可实践性。资源包共56个文件以37个Python源码文件为核心涵盖训练、评估、特征提取、数据创建及GUI推理等脚本另含6个wav音频样例、6个yml配置文件、3张jpg图示、README与LICENSE等文档整体仅1.19MB轻量易读。已有564人学习浏览源码经过严格测试目录结构清晰可直接在其基础上扩展新模型或预处理方法对希望快速搭建声纹识别流程、了解多模型对比与损失函数设计的学习者而言是一份高性价比的参考基线。1. 声纹识别不等于语音识别Python 生态把模型和预处理串成了一根链条比模型结构更折腾人的往往是从音频文件到训练样本那段预处理。声纹识别说话人识别的目标是从语音里提取一段“说话人向量”embedding让同一个人的向量相近、不同人的相远。ECAPA-TDNN、ResNetSE、ERes2Net、CAM 这四类模型是目前开源社区和工业落地里出现频率最高的同级别后端差别主要在主干网络、注意力机制和池化层。Python 生态能流行不是因为出现了某个万能库而是 torchaudio、librosa 负责前端PyTorch 负责训练ONNX、Triton 负责部署整条链路可以只用一种语言贯穿。适合正在做身份验证、会议中说话人分离、客服质检语音索引或者想把预训练模型搬进自己业务的工程师。2. 声纹数据预处理把原始音频变成可训练样本先统一采样率和 VAD 再谈增强准备训练数据时最常踩的第一个坑是采样率不一致。VoxCeleb、AISHELL、自己录的通话音频可能分别是 16kHz、44.1kHz、8kHz混在一起会让同一个模型在验证时表现忽高忽低。声纹任务里说话人辨识依赖中低频段的音色信息16kHz 是绝大多数预训练模型的默认输入所以第一步不是“做增强”而是把所有音频统一切到同一个采样率。2.1 用一个干净的 conda 环境把依赖一次性装齐我一般在项目开始时单独建一个 Python 环境避免 torchaudio、librosa 这些库互相污染版本。创建环境和安装依赖的命令如下conda create -n speaker python3.8 -y conda activate speaker pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install librosa soundfile numpy tqdm scikit-learn说明Python 3.8 是语音类项目里兼容性很好的选择PyTorch 的版本跟着 CUDA 版本走如果机器没有 GPU直接把--index-url参数去掉装 CPU 版即可。librosa 和 soundfile 都装是因为 soundfile 读大文件更省内存librosa 做特征对比实验时更方便。导包时注意顺序先用 soundfile 读 WAV再用 torchaudio 做张量运算。2.2 音频读入、立体声合并与重采样读音频时最容易忽略的是声道数。很多会议录音是双声道直接丢给模型会多出一个通道维度训练时 shape 不匹配。下面的函数把音频统一读成单声道、16kHz、float32 张量import torchaudio def load_audio(path, target_sr16000): wav, sr torchaudio.load(path) if wav.shape[0] 1: # 多声道转单声道取均值 wav wav.mean(dim0, keepdimTrue) if sr ! target_sr: wav torchaudio.functional.resample(wav, sr, target_sr) return wav # shape: [1, T]逻辑说明torchaudio 读进来的数据本就是 float32取值范围在 [-1.0, 1.0]所以不需要像 librosa 那样再转一次。resample在 torchaudio 2.x 里是torchaudio.functional.resample旧版本也可以用torchaudio.transforms.Resample但函数式接口在数据加载循环里更省心。target_sr固定为 16000这也是后面所有特征提取器统一使用的采样率。2.3 能量 VAD 去掉句首句尾的静音不加 VAD 直接切帧会让大量静音帧参与训练拉低 embedding 的质量。常见做法是能量门限不引入额外依赖运行速度快def energy_vad(wav, frame_len320, hop_len160, threshold_db-35): wav wav.squeeze(0) frames wav.unfold(0, frame_len, hop_len) # [num_frames, frame_len] rms frames.pow(2).mean(dim-1).sqrt() ref rms.max().clamp_min(1e-10) valid (10 * torch.log10(rms / ref 1e-10) threshold_db) if valid.sum() 0: return wav start valid.nonzero()[0].item() * hop_len end (valid.nonzero()[-1].item() 1) * hop_len return wav[start:end].unsqueeze(0)参数说明frame_len320对应 16kHz 下 20mshop_len160对应 10ms这是语音处理里最常用的帧移。threshold_db-35表示只保留相对最高 RMS 大于 -35dB 的帧能去掉大部分段首段尾静音但不会误伤轻声开头的语音。这个阈值在不同录音设备上可以放宽到 -40dB但太松会让嘈杂环境里的静音段残留太多。2.3.1 大批量预处理时用多进程不要单线程循环VAD 本身很快真正慢的是重采样和大文件 IO。如果要对几万条音频做预处理我会用concurrent.futures.ProcessPoolExecutor把任务分发到多个 CPU 核心from concurrent.futures import ProcessPoolExecutor def process_one(path): wav load_audio(path) wav energy_vad(wav) torchaudio.save(path.replace(.wav, _trim.wav), wav, 16000) return path with ProcessPoolExecutor(max_workers8) as pool: list(pool.map(process_one, wav_paths))Python 多进程在这里能跑满多核GIL 不会成为瓶颈因为音频解码和重采样都发生在 C 扩展层。2.4 提取 Fbank 并做 CMVN入模前的固定格式声纹模型大多吃 Fbank 特征而不是原始波形。80 维 Fbank 是 ECAPA-TDNN、ERes2Net 等模型最常见的输入配置。提取时用 torchaudio 一行完成fbank torchaudio.transforms.MelSpectrogram( sample_rate16000, n_fft512, hop_length160, win_length400, n_mels80, ) feat fbank(wav).squeeze(0) # [80, T] feat torch.log(feat.clamp_min(1e-5)) feat (feat - feat.mean(dim-1, keepdimTrue)) / feat.std(dim-1, keepdimTrue)逻辑说明n_fft512对应约 32ms 的 FFT 窗口win_length400对应 25ms 窗长和hop_length16010ms组合起来是语音识别领域的事实标准。对特征做 CMVN 而不是对波形做可以让模型更关注相对频谱形态弱化信道和音量差异。2.5 数据增强的档位加噪、混响、速度扰动和 SpecAugment增强方式常用参数解决什么问题加噪MUSANSNR 0~20dB 随机跨信道、耐嘈杂环境混响RIRT60 0.3~1.5s 随机真实房间、会议场景速度扰动0.9 / 1.0 / 1.1 倍说话速率多样性SpecAugmentF8T10概率 0.3缓解过拟合、提升泛化音量扰动系数 0.6~1.2录音增益差异在线增强比离线增强更省磁盘空间GPU 训练时 CPU 端实时生成扰动版本。我一般把速度扰动放在最前面因为重采样会影响时长加噪和混响按概率叠加。过强的增强会让短语音样本失真0.3 的概率是比较安全的起点。3. ECAPA-TDNN 的工程落地SE-Res2Block 和多头注意力池化是关键ECAPA-TDNN 是在 TDNN 基础上演进出来的。TDNN 用一维卷积把局部时间上下文叠起来但感受野固定、通道关系没有建模。ECAPA-TDNN 加了三个关键设计SE 模块为通道加权、Res2Net 结构扩大多尺度感受野、多头注意力统计池化替换简单均值池化。3.1 从 TDNN 到 ECAPA-TDNN 的三个改动点第一个改动是引入 SE 模块。对一维卷积输出的每个通道先做全局平均池化再用两个全连接层算出该通道的重要程度把重要通道放大、不重要的压下去。第二个改动是把 ResNet 里的 Res2Net 结构搬到一维卷积上将通道分组后逐组串行卷积让不同组拥有不同大小的感受野。第三个改动在池化层传统方法把帧级特征平均成句级向量ECAPA 用多头注意力给每一帧算一个权重再计算加权均值和加权标准差。3.2 带 SE 的 Res2Block 手写实现下面是 ECAPA-TDNN 核心块的可运行简化版本重点在于展示通道分组、串行卷积和 SE 三个机制的配合方式import torch import torch.nn as nn class SE_Res2Block(nn.Module): def __init__(self, channels512, scale8, kernel_size5, dilation1): super().__init__() self.width channels // scale self.scale scale self.conv1 nn.Conv1d(channels, channels, 1) self.convs nn.ModuleList([ nn.Conv1d(self.width, self.width, kernel_size, dilationdilation, paddingsame) for _ in range(scale) ]) self.bn1 nn.BatchNorm1d(channels) self.bn2 nn.BatchNorm1d(channels) self.se nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Conv1d(channels, channels // 4, 1), nn.ReLU(), nn.Conv1d(channels // 4, channels, 1), nn.Sigmoid(), ) self.relu nn.ReLU() def forward(self, x): residual x x self.relu(self.bn1(self.conv1(x))) splits torch.split(x, self.width, dim1) y splits[0] outputs [] for i, conv in enumerate(self.convs): if i 0: y conv(splits[0]) else: y conv(splits[i] y) outputs.append(y) x torch.cat(outputs, dim1) x self.relu(self.bn2(x)) x x * self.se(x) return residual x这段代码要理解三个地方。scale8表示把 512 维通道分成 8 组每组 64 维第一个组独立卷积后续组会叠加前一组输出再卷积形成链式多尺度结构。paddingsame配合kernel_size5保证时间维度不变多个 block 堆叠时不需要额外处理长度。SE 模块里的第一个Conv1d把 512 通道压缩到 128第二个再升回 512压缩比设为 4 是精度和参数量的折中。3.3 用多头注意力统计池化替代均值池化统计池化在声纹模型里承担一个关键任务把变长的帧序列变成固定维度的句级向量。均值池化对每帧一视同仁但实际语音中有些帧更关键。多头注意力池化先让帧序列自注意力一次再学习帧级权重class MHAStatPool(nn.Module): def __init__(self, embed_dim512, n_heads8): super().__init__() self.attn nn.MultiheadAttention(embed_dim, n_heads, batch_firstTrue) self.linear nn.Linear(embed_dim, 1) def forward(self, x): # x: [B, T, D]D 是帧级特征维度 x, _ self.attn(x, x, x) logits self.linear(x).squeeze(-1) # [B, T] w torch.softmax(logits, dim-1).unsqueeze(1) mu torch.bmm(w, x).squeeze(1) var torch.bmm(w, (x - mu.unsqueeze(1)).pow(2)).squeeze(1) std torch.sqrt(var.clamp_min(1e-8)) return torch.cat([mu, std], dim1)注意这里用的是 PyTorch 内置的MultiheadAttention做简化实现严格复现 ECAPA 原文的多头注意力池化要比这个复杂。真正使用时embed_dim要与前端卷积输出维度一致n_heads8在 512 维上每个头分到 64 维是常见配置。池化输出的维度是2 * embed_dim因为均值向量和标准差向量拼接在一起。ECAPA-TDNN 超参数常见取值调参方向channels512 / 1024越大特征容量越大显存翻倍scale8分组越多多尺度粒度越细kernel_size5 / 7核越大时间邻域越大se_channelschannels / 4压缩比越低SE 参数越多训练段长1.5~4s段长越短短语音鲁棒性要求越高训练时我的默认值是channels5124 秒随机裁剪batch 与显存匹配。先跑通小 batch 再逐步加大比一开始就上 1024 维更省时间。4. ResNetSE 与 ERes2Net残差主干里插 SE 的两种思路ECAPA-TDNN 是一维卷积路线ResNetSE 和 ERes2Net 则是二维主干路线的代表。语音频谱图在时间轴和频率轴上都有局部结构二维卷积天然能同时建模这两个方向。ResNetSE 是把 SENet 的挤压激励模块插进 ResNet 的残差块ERes2Net 则在残差块内部做多尺度通道拆分两者解决的问题有重叠但设计取向不同。4.1 在 ResNet 的哪里插 SE挤压激励的一个最小实现SE 模块的输入是任意形状的特征图先沿空间维度压缩再恢复通道权重。对声纹任务输入特征图一般是[B, C, T]一维或[B, C, F, T]二维下面的写法两种都能处理import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction8): super().__init__() self.fc nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(), nn.Linear(channels // reduction, channels), nn.Sigmoid(), ) def forward(self, x): # x: [B, C, ...]对最后一维做全局平均池化 w x.mean(dimtuple(range(2, x.dim()))) w self.fc(w).view(x.size(0), x.size(1), *([1] * (x.dim() - 2))) return x * w逻辑说明x.mean(dimtuple(range(2, x.dim())))对时间维做全局平均池化得到每个通道的统计量reduction8把通道压缩到原来的八分之一再恢复。把这个SEBlock放在残差块的输出之后、残差相加之前就是 ResNetSE 的标准做法。放在残差相加之前意味着 SE 只对当前层学到的增量特征做重标定保留 skip connection 的原始信息不被破坏。ResNetSE 的优势是结构简单、实现稳定改造成本低。它表现稳定但处理长序列语音时频特征的能力有限因为 SE 只建模了通道关系没有建模时间维度的依赖。4.2 ERes2Net 的多尺度设计通道分组、串行卷积、再加注意力融合ERes2Net 是在 Res2Net 基础上针对说话人任务优化的结构。Res2Net 的核心是前面 ECAPA 章节里出现的通道分组串行卷积但 ERes2Net 做了两点调整。第一ERes2Net 在残差块内部使用了更细粒度的分组与多尺度特征融合。输入先通过 1x1 卷积降维再切分成多个子通道组每组经过不同尺度的卷积后逐级融合。这种设计让网络在同一个 block 内同时看到短时、中时、长时三种尺度的时间信息。第二ERes2Net 在浅层和深层分别引入局部与全局特征融合。浅层特征分辨率高包含发音细节深层特征语义强包含说话人整体音色。常见实现会提取中间层的嵌入拼接到最终表示里这和 ECAPA 在每个残差块后取输出的思路异曲同工。实际工程中我一般直接用开源实现或预训练权重不手写完整结构因为 ERes2Net 的参数组合非常多手写很难一次对。4.3 模型选型对照表与预训练权重加载模型主干形式关键机制适合场景ECAPA-TDNN一维卷积SE-Res2Block MHA 池化长语音、通用验证ResNetSE二维 ResNetSE 插在残差块后有现成 ResNet 权重可复用ERes2Net二维/一维混合多尺度残差 SE跨设备、短语音CAMConformer 风格上下文感知掩码短语音、低延迟加载预训练权重时我习惯把模型结构和 checkpoint 分开管理。checkpoint 文件只存state_dict加载时先实例化模型再load_state_dictmodel ERes2Net(num_class5994) # num_class 与训练数据说话人数一致 state torch.load(eres2net.ckpt, map_locationcpu) state state.get(state_dict, state) model.load_state_dict(state, strictFalse) # 分类头维度不匹配时不影响骨干加载注意strictFalse的价值在于预训练模型的分类头输出维度可能与你任务的说话人数不同去掉分类头只保留骨干参数就够了。加载后把最后一层 embedding 层的输出取出来就是可以直接用于余弦相似度评分的声纹向量。5. CAM用上下文感知掩码把短语音短板补上CAM 是近年来在短语音和跨设备场景下表现突出的模型。它不是一个完全从零设计的网络而是把 Conformer 的卷积与注意力并行结构搬到了说话人任务上并新增了上下文感知掩码机制。短语音场景下输入只有 0.5 到 2 秒传统模型容易把静音帧或未对齐的上下文当成有效信息CAM 的掩码机制专门解决这个问题。5.1 CAM 的构造从 Conformer 借了注意力又加了掩码Conformer 结构里每个 block 同时包含多头注意力和卷积模块两者并行处理输入再融合。CAM 保留了这种并行设计但注意力矩阵不是直接对所有帧计算而是增加了一个上下文掩码。这个掩码的作用是控制每一帧能看到多大的时间邻域。常规自注意力中每一帧可以关注全序列所有帧短语音里相邻帧信息更可靠长距离帧可能跨到静音段或填充区域。CAM 通过可学习的掩码权重让模型自动决定每一帧应该关注多大范围。这样做的好处是训练时不需要额外标注哪些帧是有效的完全由数据驱动学习。5.2 用 FunASR 加载 CAM 模型并提取说话人向量CAM 有现成的预训练权重直接用 FunASR 接口调用最省事。下面的代码示范了加载模型、提取 embedding、用余弦相似度做验证的全过程from funasr import AutoModel import numpy as np model AutoModel(modeliic/speech_campplus_sv_zh-cn_16k-common) enroll model.generate(inputenroll.wav, embeddingTrue)[0][spk_embedding] test model.generate(inputtest.wav, embeddingTrue)[0][spk_embedding] score np.dot(enroll, test) / (np.linalg.norm(enroll) * np.linalg.norm(test))embeddingTrue表示让模型返回倒数第二层的说话人向量而不是直接输出验证分数。拿到 embedding 后自己做余弦相似度而不是调用封装好的 pipeline优点是打分逻辑完全可控切换后端时不用改代码。模型ID 里的zh-cn-16k表示中文普通话、16kHz 采样率如果你的数据是英文或采样率不同需要换对应的预训练模型。5.3 CAM 与 ECAPA-TDNN 的落地差异对比维度ECAPA-TDNNCAM短语音1s 以内性能下降明显掩码机制下保持较好长语音5s 以上较强与 ECAPA 相当推理速度中更快结构更紧凑跨设备泛化依赖训练数据中文预训练覆盖较好实际项目里我一般同时保留 ECAPA-TDNN 和 CAM 两个模型。ECAPA-TDNN 作为长语音和离线批处理的主力CAM 用于短语音验证和实时场景。这样做的原因是两者结构差异大即使后端打分完全相同模型融合后也能降低单模型的误判率。融合方式通常不复杂把两个 embedding 拼接后做线性投射或者直接把两个相似度分数取平均值都能在开发集上看到 EER 下降。6. 四类模型放进同一套评估脚本用 EER 做选型模型好坏不是看训练 loss 收敛得多低而是看在注册-验证流程下的等错误率。EEREqual Error Rate是声纹识别最常用的指标它表示当误拒率等于误纳率时两种错误的平均值。数值越低模型越适合当前数据分布。6.1 在开发集上计算 EER、minDCF先构造正负样本分数对同一个人的两段语音算正样本对不同人的两段语音算负样本对。把四类模型跑出的分数统一放进下面的函数import numpy as np from sklearn.metrics import roc_curve def compute_eer(pos_scores, neg_scores): labels np.concatenate([np.ones_like(pos_scores), np.zeros_like(neg_scores)]) scores np.concatenate([pos_scores, neg_scores]) fpr, tpr, _ roc_curve(labels, scores) fnr 1 - tpr idx np.argmin(np.abs(fpr - fnr)) return (fpr[idx] fnr[idx]) / 2, fpr[idx], fnr[idx] eer, fpr_at_eer, fnr_at_eer compute_eer(pos_scores, neg_scores)逻辑说明roc_curve按分数从高到低滑动阈值逐个计算误报率和召回率。np.argmin(np.abs(fpr - fnr))找到误报率与漏报率最接近的索引该位置的错误率就是 EER。评估时正负样本对的数量要尽量均衡如果负样本过多EER 会被稀释。6.2 阈值调节和分数归一化的两个技巧开发集上算出 EER 后把阈值直接定为 EER 对应阈值作为起点。真正上线时还要考虑业务更在意误拒还是误纳安防场景更怕误纳就调高阈值客服质检更怕误拒就调低阈值。第二个技巧是分数归一化。不同模型的分数分布差异很大ECAPA 的余弦相似度普遍偏高CAM 的分数区间不一定一致。直接对比两个模型的 EER 可以但不能用同一个阈值混用。常见做法是对分数做均值方差归一化或者用 SNorm 按说话人自适应调整这样模型融合时两个分数才有可比性。先在开发集上完成归一化和阈值调优再去跑测试集得到的结果才有参考价值。本文还有配套的精品资源点击获取
返回列表