ARTICLE DETAIL

资讯详情

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

PyTorch语音情感识别实战:从FBank到CNN+自注意力模型

PyTorch语音情感识别实战:从FBank到CNN+自注意力模型 简介面向语音情感识别入门与进阶开发者的Pytorch项目源码覆盖语音降噪、特征提取、LSTM/GRU模型训练与推理全流程适用于智能客服、心理健康辅助、人机交互等场景的情绪分析。压缩包共29个文件以25个py源码为主另含yml配置、md说明、png结构图与txt依赖清单源码模块划分清晰便于按需阅读与二次开发。项目实现了从原始语音到情感标签的完整链路涵盖喜怒哀乐等基本情感识别也可扩展到惊讶、恐惧等细分情绪配置了Bi-LSTM模型参数、训练评估与推理脚本并附运行说明。已有577人学习下载代码规范性和文档完整性较为突出适合希望快速搭建语音情感识别基线系统并进一步理解时序建模与情感分类方法的研究者和工程师参考使用。1. 语音情感识别要解决的三个问题分类、上下文与标注噪声接到一个语音情感识别的活儿刚开始我总习惯问一句数据里有多少个说话人因为这个任务和图像分类不太一样同一句“今天天气不错”不同的人说出来的情感几乎完全不同甚至连标注的人都可能在生气、高兴之间各执一词。公开数据集里RAVDESS也就1400多条IEMOCAP大概一万条这点量想学到一个通用的语音情感表示基本不大可能。所以基于PyTorch做语音情感识别本质上是解决三件事把音频变成网络能吃的特征用足够小的模型在有限数据里拟合标签分布然后让输出分数在真实场景里可解释。这篇就按这三步讲这类打包源码里最有价值的是预处理和训练两段但别急着解压跑先把链路走一遍后面排查问题会顺得多。2. 从音频到特征语音情感识别提参用MFCC还是FBank2.1 为什么先提参不直接把波形丢给网络语音情感识别跟语音识别不一样情感主要藏在音高、能量和韵律变化里这些属于频域特征。直接送波形网络得自己学傅里叶变换这在情感数据量下学不动先提频域特征就是在给网络指路。这里最常用的两个参数集是 MFCC 和 FBank。MFCC 是 FBank 再做一次 DCT 得到的特征是去相关、维度低早期机器学习时代很吃香FBank 保留了更多原始声学信息在深度学习时代反而更常用尤其在情感识别上FBank 往往比 MFCC 高 1~2 个百分点。PyTorch 环境搭好后我一般直接上 FBank。如果你的数据特别少或者下游要接 GMM 这类小模型再考虑 MFCC 也不迟。2.2 提参脚本从 wav 到 (n_mels, T) 张量下面这段脚本是我在实际项目里固定用的librosa 抽 mel 谱torch 的 Dataset 负责切帧和打标签整体逻辑可以直接抄进自己的数据管线。import librosa import numpy as np import torch from torch.utils.data import Dataset SAMPLE_RATE 16000 N_MELS 64 N_FFT 512 HOP_LENGTH 160 # 10ms一帧16k采样对应160个采样点 def load_wav(path): # 统一重采样到16k绝大多数中文情感语料都是16k或8k wav, _ librosa.load(path, srSAMPLE_RATE) return wav def extract_fbank(wav): mel librosa.feature.melspectrogram( ywav, srSAMPLE_RATE, n_fftN_FFT, hop_lengthHOP_LENGTH, n_melsN_MELS, fmin50, fmax7600 ) log librosa.amplitude_to_db(mel, refnp.max) # 样本内标准化避免不同录音设备音量差异影响模型 mean, std log.mean(), log.std() return (log - mean) / (std 1e-6) class SpeechEmotionDataset(Dataset): def __init__(self, items, fix_len200): self.items items # [(wav_path, label_id), ...] self.fix_len fix_len # 200帧 2秒 def __len__(self): return len(self.items) def __getitem__(self, idx): path, label self.items[idx] feat extract_fbank(load_wav(path)) # (64, T) T feat.shape[1] if T self.fix_len: feat feat[:, :self.fix_len] # 超过2秒直接截断 else: pad_width self.fix_len - T feat np.pad(feat, ((0, 0), (0, pad_width)), modeconstant) return torch.from_numpy(feat).float(), label # (64, T)代码里用 librosa.load 统一采样率melspectrogram 输出是 (n_mels, T)amplitude_to_db 把幅度转成对数刻度贴近人耳响度。样本内标准化是刻意做的实测比全局标准化稳定原因见下面的表格。Dataset 里 fix_len 设为 200 帧刚好是 2 秒情感识别里 2 秒的窗足够覆盖一句话的核心语气超过就截断不足补零。返回的张量没有额外加通道维DataLoader 在 batch 时会自动变成 (B, 64, T)和后面模型输入的约定一致。2.3 特征处理里的坑位与解法特征这块的坑不在算法多深而在数据本身我把常见的四个问题收敛成一张表。坑位现象解法带静音直接喂模型把静音段学成“中性”用 energy threshold 或 webrtcvad 裁掉首尾静音SpecAugment mask 给太大短音频被 mask 成接近空白时间 mask 不超过 4 帧频率 mask 不超过 16增强在校验集上重复做验证集和训练集分布不一致评估失真增强只放训练侧验证集仅做标准化同一个人的同一句话出现在训练和验证准确率虚高换人直接失效按说话人或录音会话切分数据单条 wav 随机切无效提示这四条踩中任何一条最后 F1 都会比正常做法虚高或虚低至少 5 个点排错时先查数据切分再查增强。3. 基于PyTorch搭建语音情感识别网络小CNN配合自注意力3.1 模型怎么选小数据集撑不起大网络语音情感识别的网络结构这几年被 Wav2Vec2 这类预训练模型抢了很多风头但预训练模型动辄上亿参数加载都要几个 G 内存公开情感数据集普遍只有几千到一万条直接微调必过拟合。实际项目里更稳的反而是小卷积网络几个 Conv1d 叠 BatchNorm最后接一个自注意力做时序建模。这套结构在 CPU 上训练也就几十秒一个 epoch和 PyTorch 入门级别的学习成本正好匹配。卷积负责局部音高和能量模式注意力负责“这句话前段情绪和后段情绪的关系”比单纯堆 LSTM 更容易调也比 Transformer 省资源。3.1.1 二维卷积还是时序一维卷积两种写法都可行。二维卷积把 mel 谱当成图像kernel(5,5) 同时扫频率和时间时序一维卷积只在时间轴上扫频率轴当通道。推荐后者因为 Conv1d 的通道语义清晰最后接注意力也顺参数还少一半以上。下面代码就按这个方案输入从 Dataset 出来是 (B, 64, T)频率维 64 作为通道数。3.2 模型源码与输入维度推演import torch import torch.nn as nn import torch.nn.functional as F class EmotionCnnAttn(nn.Module): 输入: (B, 64, T) FBank特征 输出: (B, num_classes) def __init__(self, num_classes4, feat_dim64): super().__init__() self.conv_stack nn.Sequential( # 第一层: 64 - 32, 捕捉局部语调 nn.Conv1d(feat_dim, 32, kernel_size5, padding2), nn.BatchNorm1d(32), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2), # 第二层: 32 - 64, 短语级韵律 nn.Conv1d(32, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2), # 第三层: 64 - 128, 句子级模式 nn.Conv1d(64, 128, kernel_size5, padding2), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2), ) self.attn nn.MultiheadAttention( embed_dim128, num_heads4, batch_firstTrue ) self.fc nn.Linear(128, 128) self.head nn.Linear(128, num_classes) def forward(self, x): x self.conv_stack(x) # (B, 128, T//4) x x.transpose(1, 2) # (B, T//4, 128) x, _ self.attn(x, x, x) # 自注意力, 不需要mask x x.mean(dim1) # 时间维平均池化 - (B, 128) x F.relu(self.fc(x)) return self.head(x)维度推演走一遍2 秒输入 T200三层 MaxPool1d(2) 之后时间维变成 200//825卷积核都是 5 且 padding2所以时间长度只被池化缩小。最后一层卷积输出 (B, 128, 25)转置后是 25 个时间步每步带 128 维特征。自注意力的序列长度只有 25计算量可以忽略。全局平均池化把 25 步压缩成一个 128 维向量最后全连接出类别分数。这样设计的好处是输入长度不固定只要大于 3 帧都能跑。方案参数量单 epoch 耗时(CPU)小数据表现ResNet18 2D卷积~11M3~4分钟容易过拟合本方案 CNN Attention~0.5M40秒稳定F1 高 2-4 个点Wav2Vec2 微调~95M无法 CPU 训练数据量大才划算3.3 损失函数选型交叉熵够用也可以加点正则四分类情况直接用 CrossEntropyPyTorch 里这个 Loss 自带 softmax模型尾部不需要再接激活。类别不平衡比较严重比如“惊喜”只有“中性”的十分之一时我给 loss 加一个类别权重from sklearn.utils.class_weight import compute_class_weight weights compute_class_weight( balanced, classesnp.array(CLASS_IDS), ytrain_labels ) criterion nn.CrossEntropyLoss( weighttorch.tensor(weights, dtypetorch.float32) )这个权重的含义是样本少的类别loss 放大倍数更大梯度更新更用力。但注意权重别调太大放大超过 5 倍会把模型推向“什么都预测成少数类”验证集 F1 反而下降。另外权重的计算要在划分训练/验证集之后单独用训练集的标签算千万不要用全量标签否则验证集的信息会通过权重泄漏到训练过程最后评估出来的收益是虚高的。4. 训练闭环PyTorch训练脚本、学习率策略与过拟合控制4.1 训练循环的最小骨架模型、数据、Loss 都齐了训练部分反而最简单。下面这段是 PyTorch 实战里可以直接改用的骨架其中 warmup cosine 学习率是我在情感任务上固定使用的方案。实测下来固定学习率调出来的模型验证集 F1 比 cosine 调度低 2~3 个点因为情感数据本身噪声大后期需要更低的学习率稳定收敛。为什么不换 ReduceLROnPlateau它在小数据集上很容易被噪声触发一次偶然的验证集波动就把学习率降下去之后再也上不来cosine 调度把学习率曲线提前定死不依赖验证集反馈确定性更强。import numpy as np import torch import torch.nn.utils as utils device torch.device(cuda if torch.cuda.is_available() else cpu) model EmotionCnnAttn(num_classes4).to(device) opt torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) epochs 40 total_steps len(train_loader) * epochs warmup_steps int(total_steps * 0.1) def lr_lambda(step): if step warmup_steps: return step / max(1, warmup_steps) progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 np.cos(np.pi * progress)) scheduler torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)训练时每次 backward 之前要先调用optimizer.zero_grad()backward 之后用clip_grad_norm_限制梯度最大范数。语音数据里偶尔会混进来一段异常响度或爆音导致某几个 batch 的梯度爆炸clip 能把这几个 batch 的影响兜住不至于让前面十几个 epoch 白训。best_f1 0.0 no_improve 0 for epoch in range(epochs): model.train() run_loss 0.0 for feats, labels in train_loader: feats, labels feats.to(device), labels.to(device) opt.zero_grad() logits model(feats) loss criterion(logits, labels) loss.backward() utils.clip_grad_norm_(model.parameters(), max_norm5.0) opt.step() scheduler.step() # 每个 step 更新一次学习率 run_loss loss.item() * feats.size(0) val_f1 evaluate(model, val_loader, device) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), emotion_best.pt) no_improve 0 else: no_improve 1 if no_improve 8: break4.2 一组能直接用的训练参数下面是常用配置按 CPU 能训练为标准参数推荐值说明optimizerAdamW比 SGD 收敛快weight decay 给 1e-4base_lr1e-3小模型配大学习率换大模型降到 3e-4weight_decay1e-4太小防不住过拟合太大会欠拟合batch_size32小于 16 BN 会不稳大于 64 收敛变慢epochs40配合早停一般 25 轮左右就稳定max_grad_norm5.0保护性选项宁小勿大音频长度2 秒200帧覆盖短句长句靠推理端切窗4.3 早停、EMA 与模型保存策略早停在第 4.1 代码里已经实现patience8 表示连续 8 个 epoch 验证指标没刷新就停这个习惯适合小数据。另一个实用技巧是 EMA给模型参数做滑动平均推理时用平均权重而非最后一轮的权重能有效降低验证集上震荡带来的误差import copy ema_model None torch.no_grad() def update_ema(model, alpha0.995): global ema_model if ema_model is None: ema_model copy.deepcopy(model) else: for ema_p, p in zip(ema_model.parameters(), model.parameters()): ema_p.data.mul_(alpha).add_(p.data, alpha1 - alpha)EMA 的 alpha 越大历史影响越长0.995 的衰减半衰期约 138 步100 步之前的权重贡献只剩一半左右这个特性在小数据集上特别合适。每个 step 结束后调用 update_ema每个 epoch 验证时用 ema_model 代替 model 跑 evaluate如果 F1 更高最终保存 ema_model 的权重。evaluate 函数记得从 sklearn.metrics 导入 f1_score验证时不要加任何增强只用 argmax 预测和真实标签算 macro F1。5. 推理端长音频滑窗与输出阈值校准5.1 长音频的滑窗推理与得分聚合训练吃的是 2 秒固定窗推理时音频可能是半分钟。滑窗是最常见的处理窗口 2 秒、步长 1 秒逐段过模型把每一段的概率取平均。窗口重叠相当于给相邻片段做了平滑不会因为切在“气话说了一半”的位置丢掉关键信息。def infer_file(path, model, win_sec2.0, hop_sec1.0): wav load_wav(path) win_len int(win_sec * SAMPLE_RATE) hop_len int(hop_sec * SAMPLE_RATE) probs [] model.eval() with torch.no_grad(): for start in range(0, len(wav) - win_len 1, hop_len): seg wav[start:start win_len] feat torch.from_numpy(extract_fbank(seg)).unsqueeze(0) # (1,64,T) p F.softmax(model(feat), dim-1) probs.append(p.numpy()[0]) if not probs: # 短于窗口时直接补零跑一次 feat torch.from_numpy(extract_fbank(wav)).unsqueeze(0) probs [F.softmax(model(feat), dim-1).numpy()[0]] mean_prob np.mean(probs, axis0) return mean_prob, int(np.argmax(mean_prob))5.2 阈值校准不要用 0.5softmax 输出的 0.5 对每个类别没有意义尤其在类别不平衡时模型概率分布有明显偏向。我习惯在验证集上给每个类别单独扫描阈值用 F1 而不是准确率做指标因为漏报一种情绪的代价比整体分错更大。best_thr np.ones(num_classes) * 0.5 for c in range(num_classes): best_f1, best_t 0.0, 0.5 y_true (val_labels c).astype(int) for t in np.arange(0.30, 0.95, 0.05): f1 f1_score(y_true, (val_probs[:, c] t).astype(int)) if f1 best_f1: best_f1, best_t f1, t best_thr[c] best_t调完之后每条音频的判定条件是pred[i] 1 if prob[i] best_thr[i]而不是直接 argmax。如果所有类别的概率都低于各自阈值就返回“未识别出明显情感”这类样本在业务上通常当作“中性”处理。把 best_thr 存成 json 和模型权重放在一起部署时两个文件同时加载避免换环境后行为不一致。本文还有配套的精品资源点击获取
返回列表