资讯详情

资讯详情

语音情感识别实战:基于PyTorch的CNN+LSTM模型构建与部署

简介基于Pytorch实现的语音情感识别项目源码面向机器学习开发者与语音技术研究人员提供从语音降噪、特征提取到模型训练与推理的完整流程适用于智能客服、心理健康辅助、人机交互等场景。压缩包共29个文件包含25个Python脚本、1个YAML配置文件、1个Markdown说明、1张图片及1个TXT文本脚本覆盖数据创建、特征抽取、模型定义、训练、评估与推理等模块并采用LSTM、GRU等循环网络处理时序语音特征结合反向传播与梯度下降完成参数优化。资源包仅94KB轻量易读目前已有577人学习。代码注释与使用文档齐全目录结构清晰既可作为课程设计或毕业设计的参考实现也能帮助初学者快速理解情感分类的整体流程是一份适合进阶学习的优质实战资源。1. 从声波到情感为什么语音情感识别比想象中更依赖特征工程语音情感识别Speech Emotion Recognition, SER的目标是让机器从一段语音中判断说话人的情绪状态——愤怒、开心、悲伤、恐惧、平静等。很多初学者拿到 PyTorch 语音情感识别项目的第一反应是“直接上 Transformer”但实际做下来会发现情感识别准确率上不去的瓶颈通常不在模型结构而在特征层面。原因是语音中的情感信息分布极为稀疏一段 3 秒的音频里真正承载情绪的可能是某个音节的基频抖动、某段频谱的能量骤变或者句末的韵律拖着而原始波形经过梅尔滤波器组后这些信息可能被压缩到几乎不可见。这意味着一个可落地、可复现的语音情感识别项目核心工作往往分三块数据与标签的组织、声学特征的提取、模型对时序依赖的建模能力。本文将以 PyTorch 为框架从零到一搭建一个可训练的语音情感识别流程覆盖 RAVDESS、TESS 等常见数据集的目录组织、librosa 特征提取、CNN LSTM 混合模型搭建、训练验证闭环以及最后部署时要注意的种种边界问题。工程上这个项目会让你的 GPU 真的有事情做而不是把时间花在调参和 Debug 上。2. 数据准备与特征抽取——语音情感识别的第一步2.1 情感数据集的目录组织与标签编码无论是 RAVDESS、TESS 还是 CASIA市面上公开的语音情感数据集几乎都是以“文件夹 文件名”的形式存储元信息的。以 RAVDESS 为例文件名是03-01-01-01-01-01-01.wav这样的格式第 3 段数字01代表情感类别第 4 段01代表情感强度。因此写代码的第一步不是直接读音频而是解析这些文件名。import os import pandas as pd def parse_ravdess_filename(filename): parts filename.replace(.wav, ).split(-) emotion_map { 01: neutral, 02: calm, 03: happy, 04: sad, 05: angry, 06: fearful, 07: disgust, 08: surprised } return { emotion: emotion_map.get(parts[2], unknown), emotion_intensity: parts[3], file: filename } def build_dataset_df(root_dir): rows [] for root, dirs, files in os.walk(root_dir): for f in files: if f.endswith(.wav) and f.startswith(03): info parse_ravdess_filename(f) info[path] os.path.join(root, f) rows.append(info) df pd.DataFrame(rows) df[label_id] df[emotion].astype(category).cat.codes return df df build_dataset_df(RAVDESS/) print(df[[emotion, label_id, path]].head())这段代码完成两件事从文件名解析出情感标签并将情感类别映射为整数 ID 供 PyTorch 的交叉熵损失使用。有个非常容易被忽略的坑astype(category).cat.codes生成的编码是按字母排序的angry是 0calm是 1以此类推。这意味着你的模型输出层神经元数量和类别数量必须一致且预测反向映射时要用同一个映射表否则训练完拿到一个label_id根本不知道对应哪种情感。建议额外保存一份映射字典emotion_codes df[[emotion, label_id]].drop_duplicates() emotion_dict dict(zip(emotion_codes.label_id, emotion_codes.emotion)) print(emotion_dict)2.2 用 librosa 抽取 MFCC 与韵律特征MFCC梅尔频率倒谱系数是语音情感识别中最常用的底层特征但它有一个众所周知的短板它主要用于刻画音色和声道形状对语音情感的韵律变化如音高起伏、语速快慢不敏感。语音情感识别的工程实践中常见的做法是将 MFCC、基频F0和短时能量一起送入模型。这里给出一个完整的特征提取函数import librosa import numpy as np def extract_features(audio_path, n_mfcc40, max_len128): y, sr librosa.load(audio_path, sr16000) # 统一长度不足补零超长截断 if len(y) sr * 4: y y[:sr * 4] else: y np.pad(y, (0, sr * 4 - len(y)), constant) mfcc librosa.feature.mfcc(yy, srsr, n_mfccn_mfcc, n_fft512, hop_length256) # 加入一阶差分捕捉时序变化趋势 mfcc_delta librosa.feature.delta(mfcc) # 基频和能量 f0, voiced_flag, _ librosa.pyin(y, srsr, fmin50, fmax400) f0 np.nan_to_num(f0) rms librosa.feature.rms(yy, hop_length256)[0] # 将所有特征对齐到同一时间轴 n_frames mfcc.shape[1] f0_resized np.interp(np.linspace(0, len(f0) - 1, n_frames), np.arange(len(f0)), f0) rms_resized np.interp(np.linspace(0, len(rms) - 1, n_frames), np.arange(len(rms)), rms) features np.vstack([mfcc, mfcc_delta, f0_resized, rms_resized]) # 归一化 features (features - np.mean(features, axis1, keepdimsTrue)) / (np.std(features, axis1, keepdimsTrue) 1e-6) return features[:, :max_len]为什么固定为 4 秒因为 RAVDESS 每个音频样本大约 3 到 4 秒而 TESS 则更短。固定长度是为了让 batch 训练时张量维度一致避免额外做 padding mask 的麻烦。MFCC 采用 40 维、每帧 25mshop 256 / 16000 16ms128 帧覆盖约 2 秒的音频内容在多数数据集上信息够用。参数说明sr16000是绝大多数 SER 基准测试的采样率比原始 wav 的 44.1kHz 小很多好处是特征计算快、显存占用低而且 16kHz 已经覆盖人类语音的绝大部分频率范围。log mel spectrogram也是很好的替代方案但 MFCC 在特征维度和跨数据集泛化上有优势。2.3 PyTorch Dataset 与 DataLoader 的工程实现写数据的正确方式是把特征提取放在 Dataset 的__getitem__里做实时计算而不是提前把所有特征缓存到内存。原因是特征数据量大一个 RAVDESS 数据集就有 2000 多个 wav 文件每个特征矩阵约 42 × 128 5376 个 float全部加载也不过 40MB貌似不大但如果你用更大数据集如 IEMOCAP实时抽取能避免占用大量内存。此外实时抽取允许在训练时做在线数据增强。import torch from torch.utils.data import Dataset, DataLoader class EmotionDataset(Dataset): def __init__(self, df, augmentFalse): self.df df self.augment augment def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] features extract_features(row[path]) # 数据增强高斯噪声 if self.augment: noise np.random.normal(0, 0.005, features.shape) features noise tensor torch.FloatTensor(features) label torch.LongTensor([row[label_id]])[0] return tensor.unsqueeze(0), label train_loader DataLoader( EmotionDataset(train_df, augmentTrue), batch_size32, shuffleTrue, num_workers4, pin_memoryTrue )提示tensor.unsqueeze(0)是为了给 CNN 增加通道维度得到(1, 42, 128)的张量。num_workers设置为 4 到 8 能有效利用多核 CPU 做特征提取否则 GPU 会在每个 batch 之间干等。3. 搭建 CNN LSTM 混合模型——捕捉情感的时间动态3.1 时序建模的必要性为什么不用纯 CNN 或纯 LSTM语音情感不是瞬间的单一事件而是持续数百毫秒甚至数秒的状态变化。一段从平静到愤怒的语音其频谱特征是渐变而非突变的。纯 CNN 通过卷积核捕捉局部频谱模式但感受野有限需要堆叠很多层才能看到长程依赖纯 LSTM 虽然能建模长时间状态但对局部频谱特征的提取效率低直接吃原始 MFCC 容易过拟合。强基线是时间维度上的 CNN LSTM 串联架构先用两层卷积在频域和时域上做局部特征提取再压缩频率轴把时序轴保留作为 LSTM 的输入序列。这样 LSTM 的每个时间步接收的是 CNN 从该时刻多个频带聚合出来的特征向量既有频率上的归纳偏置又有时序上的长程建模。3.2 PyTorch 实现Conv2d BiLSTM Attention以下是一个在日本语音情感挑战赛JSE和 RAVDESS 上都能快速收敛的基线结构import torch.nn as nn import torch.nn.functional as F class EmotionNet(nn.Module): def __init__(self, n_classes8, input_channels1, freq_dim42, hidden_size128): super().__init__() # 卷积块 1: 提取局部声学模式 self.conv1 nn.Sequential( nn.Conv2d(input_channels, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(kernel_size2, stride2) # 42x128 - 21x64 ) # 卷积块 2: 高维特征 self.conv2 nn.Sequential( nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(kernel_size2, stride2) # 21x64 - 10x32 ) self.lstm_input_dim 64 * 10 # 64通道 * 频率轴剩余维度 self.lstm nn.LSTM( input_sizeself.lstm_input_dim, hidden_sizehidden_size, num_layers2, batch_firstTrue, bidirectionalTrue, dropout0.3 ) self.attention nn.Sequential( nn.Linear(hidden_size * 2, hidden_size), nn.Tanh(), nn.Linear(hidden_size, 1) ) self.classifier nn.Linear(hidden_size * 2, n_classes) def forward(self, x): batch_size, _, freq, time x.shape x self.conv1(x) x self.conv2(x) # 合并通道和频率维度保留时间维度 x x.permute(0, 3, 1, 2).contiguous() batch_size, seq_len, c, f x.shape x x.view(batch_size, seq_len, c * f) lstm_out, _ self.lstm(x) # (batch, seq_len, hidden*2) # 注意力权重 attn_w torch.softmax(self.attention(lstm_out).squeeze(-1), dim1) # 加权求和 context torch.bmm(attn_w.unsqueeze(1), lstm_out).squeeze(1) return self.classifier(context) def predict(self, x): with torch.no_grad(): logits self.forward(x) return torch.argmax(logits, dim1)这段代码里最容易出错的是view的维度顺序x.permute(0, 3, 1, 2)把张量从(batch, channels, freq, time)变为(batch, time, channels, freq)然后合并channels和freq确保时间步作为 LSTM 的序列维度。CNN 部分的设计逻辑如下第一层MaxPool2d(2, 2)将频率轴从 42 减半到 21时域从 128 减半到 64第二层池化后频率轴变为 10时域变为 32。此时LSTM时间步只有 32 个对 4 秒语音来说每个时间步代表约 125ms 的音频段信息粒度合理。如果去掉 CNN 直接让 LSTM 吃 128 个 MFCC 帧序列过长会导致反向传播梯度衰减且训练速度慢得多。注意力机制在这里的作用不同帧的情感载荷不同比如“高兴”的峰值通常在语句后半段而“愤怒”的频谱特征在前半段就饱和。注意力让模型学会动态加权这些帧而不只是依赖 LSTM 最后时刻的隐藏状态。3.3 类别不均衡的处理语音情感数据集几乎都有类别不均衡。RAVDESS 里有 8 类情感分布相对均匀但 TESS 有高比例的“中性”样本IEMOCAP 里“愤怒”样本明显少于其他类别。常见做法是在损失函数里用torch.nn.CrossEntropyLoss(weightclass_weights)加权权重等于样本数的倒数归一化from sklearn.utils.class_weight import compute_class_weight classes np.array(sorted(train_df[emotion].unique())) weights compute_class_weight(balanced, classesclasses, ytrain_df[emotion]) class_weights torch.FloatTensor(weights).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)实现时注意compute_class_weight返回的权重顺序必须与classes的顺序一致而模型的输出层是按label_id排列的二者必须严格对齐。一个稳妥的做法是拿train_df[label_id]直接计算weights compute_class_weight( balanced, classesnp.arange(len(classes)), ytrain_df[label_id].values )4. 训练闭环与验证——从损失曲线到混淆矩阵4.1 训练脚本的标准结构训练部分的代码模式在 PyTorch 项目中高度相似但有几个值得注意的细节梯度裁剪、学习率调度、早停和最佳模型保存。语音任务中 LSTM 容易出现梯度爆炸clip_grad_norm_是必选项。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from sklearn.metrics import accuracy_score, confusion_matrix, classification_report def train_one_epoch(model, loader, optimizer, criterion, device, clip1.0): model.train() total_loss 0 all_preds, all_labels [], [] for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() # 关键梯度裁剪防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_normclip) optimizer.step() total_loss loss.item() * x.size(0) all_preds.extend(torch.argmax(logits, dim1).cpu().numpy()) all_labels.extend(y.cpu().numpy()) return total_loss / len(loader.dataset), accuracy_score(all_labels, all_preds) def evaluate(model, loader, criterion, device): model.eval() total_loss 0 all_preds, all_labels [], [] with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) logits model(x) loss criterion(logits, y) total_loss loss.item() * x.size(0) all_preds.extend(torch.argmax(logits, dim1).cpu().numpy()) all_labels.extend(y.cpu().numpy()) return total_loss / len(loader.dataset), accuracy_score(all_labels, all_preds)训练主循环采用 50 个 epoch、AdamW 优化器、初始学习率1e-3、CosineAnnealingLR衰减到1e-5并监控验证集准确率保存最优模型device torch.device(cuda if torch.cuda.is_available() else cpu) model EmotionNet(n_classeslen(df[emotion].unique())).to(device) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-5) criterion nn.CrossEntropyLoss(weightclass_weights.to(device)) best_acc 0 for epoch in range(50): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch}: val_acc improved to {best_acc:.4f}) else: print(fEpoch {epoch}: val_acc {val_acc:.4f}, no improvement)4.2 验证策略按说话人划分而不是按文件划分这是很多语音情感识别项目最大的坑。如果直接train_test_split随机划分音频文件同一说话人的不同录音会同时出现在训练集和测试集模型可能记住说话人的声纹特征而非情感特征测试准确率虚高。如果测试集里出现的新说话人准确率会大幅下降。正确做法是按说话人actor划分数据。RAVDESS 中文件名第 7 段是 actor IDTESS 中是说话人首字母IEMOCAP 中是 session 号。确保同一说话人的所有样本只出现在一个集合中df[actor] df[path].apply(lambda p: os.path.basename(p).split(-)[-1].replace(.wav, ) if RAVDESS in p else None) # 简化处理按 actor 取前 80% 作为训练说话人 actors df[actor].unique() train_actors actors[:int(len(actors) * 0.8)] val_actors actors[int(len(actors) * 0.8):] train_df df[df[actor].isin(train_actors)] val_df df[df[actor].isin(val_actors)]如果不做这个划分训练出来的模型其实是上有说话人特征的“作弊模型”工程上毫无价值。4.3 混淆矩阵与错误样本分析准确率只是第一步要看模型到底把哪两类情感搞混了。典型情况是“calm”和“neutral”几乎无法区分因为二者的声学差异确实小而“disgust”常常被误判为“angry”。通过混淆矩阵能快速定位这些问题。import matplotlib.pyplot as plt import seaborn as sns # val_loader 上收集预测 all_preds, all_labels [], [] model.eval() with torch.no_grad(): for x, y in val_loader: x x.to(device) preds torch.argmax(model(x), dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(y.numpy()) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsemotion_names, yticklabelsemotion_names) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)然后针对混淆严重的类别回去听那些样本。你会发现“calm”误判为“neutral”的样本大多语速偏快、基频偏高“angry”误判为“happy”的可能是说话人带着上扬语调。这些定性结论会反哺特征设计。5. 推理管线与多文件批处理落地5.1 从 .pth 到可用的情感识别函数训练完成后把推理逻辑封装成一个独立的函数。这里有一个关键工程细节特征提取必须和训练时保持完全一致——同样的n_mfcc、同样的max_len、同样的归一化方式。有一个不易察觉的雷是librosa.load默认的monoTrue会混音双声道为单声道如果音频本身已经是单声道没问题但如果是手机录的双声道直接加载会导致音量减半特征分布偏移。import warnings def predict_emotion(audio_path, model, device, emotion_dict, n_mfcc40, max_len128): model.eval() features extract_features(audio_path, n_mfccn_mfcc, max_lenmax_len) tensor torch.FloatTensor(features).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) proba torch.softmax(logits, dim1) pred_id torch.argmax(proba, dim1).item() confidence proba[0][pred_id].item() return emotion_dict[pred_id], confidence, proba.cpu().numpy()[0]这个函数返回三个值预测的情感标签、置信度、全部类别的概率分布。置信度在工程上很有用因为低于某个阈值比如 0.4的时候应该标记为“不确定”而不是硬给一个结果。语音情感本身就有模糊性让系统承认不知道比强行分类更专业。5.2 批量处理文件夹中的音频实际应用中你不会只有一个 wav 文件而是一整个目录。批量处理时最容易出问题的是不同音频采样率不一致。librosa.load内部会重采样到指定sr但如果音频本身是低采样率如 8kHz 电话语音直接重采样到 16kHz 并不会恢复缺失的高频信息MFCC 的高阶系数会失真。此时建议模型重新训练时加入 8kHz 音频做适配而不是硬push 推理。def batch_predict(folder_path, model, device, emotion_dict): results [] for root, _, files in os.walk(folder_path): for fname in sorted(files): if not fname.endswith(.wav): continue path os.path.join(root, fname) try: emotion, conf, _ predict_emotion(path, model, device, emotion_dict) results.append({file: path, emotion: emotion, confidence: round(conf, 4)}) except Exception as e: print(fError processing {path}: {e}) return pd.DataFrame(results) df_results batch_predict(audio_files/, model, device, emotion_dict) df_results.to_csv(predictions.csv, indexFalse)这个函数提供可靠的容错机制单个音频文件损坏如截断的 wav不会让整个批次中断错误信息打出来方便定位。5.3 长音频切片与流式推理如果输入是一段 1 分钟长的访谈录音直接喂给模型是不行的——模型只见过 4 秒以内的样本。常见做法是滑窗切片用 4 秒窗口、2 秒步长滑动对每个切片预测情感然后聚合。聚合方式有两种硬投票majority voting和软投票平均概率。实践中软投票效果好于硬投票因为能保留置信度信息。def predict_long_audio(audio_path, model, device, emotion_dict, window4, step2): y, sr librosa.load(audio_path, sr16000) window_len sr * window step_len sr * step prob_sum None count 0 for start in range(0, len(y) - window_len 1, step_len): segment y[start:start window_len] # 直接调用 extract_features 的变体输入是 np.ndarray features extract_features_from_array(segment, sr) tensor torch.FloatTensor(features).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): prob torch.softmax(model(tensor), dim1).cpu().numpy()[0] prob_sum prob if prob_sum is None else prob_sum prob count 1 avg_proba prob_sum / count pred_id np.argmax(avg_proba) return emotion_dict[pred_id], avg_proba[pred_id]切片的 step 决定了预测的时间分辨率也影响计算量。step2 意味着 1 分钟音频产生约 29 个窗口每个窗口一次 forwardCPU 上大约几十毫秒完全可接受。6. 边界排查与效果提升的两个实操技巧6.1 八分类硬调不上去时先看噪声增强和幅值归一化在 8 分类的 RAVDESS 任务上CNN-LSTM 基线通常能到 65% 到 75% 的准确率。如果你的模型卡在 60% 以下先检查特征提取时的幅值归一化。librosa.load返回的音频幅值范围约为 -1 到 1但不同录音的响度差异大直接抽取 MFCC 会让模型对音量敏感。简单有效地做法是做短时能量归一化def normalize_loudness(y): peak np.abs(y).max() if peak 0: y y / peak return y另一个高频踩坑是数据增强时机不对。如果对整段音频做噪声增强某些低能量情感的 MFCC 帧会被淹没导致模型直接学成“噪声分类器”。推荐把噪声幅度系数设在0.001到0.01之间且只做训练时增强推理时禁掉。6.2 用 SpecAugment 的频域掩码替代高斯噪声效果更稳高斯噪声会破坏所有频带而语音情感的关键频带通常集中在中低频。一种在语音领域更常用且稳定的技巧是 SpecAugment 风格的时间/频率掩码——在 MFCC 的特征矩阵上随机抹掉一个频带或时间段。这样模型不会依赖固定频段泛化性更好def spec_augment(features, freq_mask4, time_mask8): features features.copy() n_freq, n_time features.shape # 随机频带掩码 f0 np.random.randint(0, n_freq - freq_mask) features[f0:f0 freq_mask, :] 0 # 随机时间掩码 t0 np.random.randint(0, n_time - time_mask) features[:, t0:t0 time_mask] 0 return features在 Dataset 的__getitem__中调用spec_augment与高斯噪声轮换使用能显著缓解过拟合。注意掩码值设为 0 会改变特征分布因此训练和推理的特征归一化方式应当一致如果归一化在掩码之前做掩码区域保持 0 是合理的。6.3 模型集成票数不够时靠平均概率单一模型在情感识别上通常波动较大尤其是在验证集中的新说话人上。工程上最稳定的提升手段是训练 3 到 5 个不同随机种子的模型推理时把概率做算术平均。下面给出代码骨架def ensemble_predict(audio_path, model_paths, device, emotion_dict): prob_sum None for path in model_paths: model EmotionNet(n_classeslen(emotion_dict)).to(device) model.load_state_dict(torch.load(path, map_locationdevice)) model.eval() _, _, prob predict_emotion_proba(audio_path, model, device) prob_sum prob if prob_sum is None else prob_sum prob avg_proba prob_sum / len(model_paths) return emotion_dict[np.argmax(avg_proba)], np.max(avg_proba)集成不仅在准确率上平均提升 2 到 3 个百分点更重要的是置信度校准变稳定——单一模型可能对某些样本给出 0.9 的置信度但预测错误集成后概率分布更平滑错误样本的置信度会显著下降。这让阈值判断变得更有意义。测试时用torch.load(path, map_locationcpu)再.to(device)避免在 GPU 上直接加载导致显存峰值翻倍。这个技巧在显存有限的推理机器上非常实用。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →