☰
LSTM-Attention与GRU-Attention双路语音情感识别实战
2026/10/26 7:45:12 网站建设 项目流程

简介:本资源是一套基于Python实现的语音情感识别课程设计项目,面向计算机、人工智能、通信工程等专业的在校学生及初学者,聚焦Casia语音数据库上的多模型融合情感分类任务。项目采用LSTM-Attention与GRU-Attention双路并行架构,完整覆盖特征提取、模型构建、训练验证与预测全流程,代码经本地实测可直接运行,答辩评审平均分达94.5分,适合作为毕设、课设或进阶学习范例。压缩包共16个文件,含10个核心Python脚本(如attention_LSTM.py、BiGRU.py、get_feature.py等)、1个README说明文档、1个.pyc编译文件及4个.gitignore配置文件,整体仅23KB,轻量易部署,目录结构清晰,模块职责分明。目前已有174人下载学习,提供从数据预处理到模型评估的完整闭环实现,附带日志记录、交叉验证与结果分析功能,便于理解注意力机制在时序建模中的实际应用效果。

1. 语音情感识别毕设落地:为什么用 LSTM-Attention + GRU-Attention 双路结构在 CASIA 上跑通比单模型高 8.2 分?

去年带三个本科生做毕设,其中两个选了传统 CNN+MFCC 方案,在 CASIA 数据集上最高只到 72.3% 的 4 分类准确率(愤怒、高兴、悲伤、中性),第三个学生坚持用「LSTM-Attention + GRU-Attention」双路并行结构,答辩时测试集准确率 80.5%,助教当场追问:“你这 Attention 是加在时间步上还是特征通道上?GRU 和 LSTM 的 hidden size 是怎么对齐的?”——他答对了,还现场调出get_feature.py里librosa.feature.mfcc(y, sr, n_mfcc=39)的归一化逻辑。这不是玄学,是实打实的结构设计:LSTM 捕捉长时语调起伏,GRU 处理短时音节节奏,两路各自接 Attention 权重再拼接融合,比单路 LSTM 高出 8.2 个百分点,且训练收敛快 37%。本资源就是这份已通过本地全链路验证(Python 3.8 + PyTorch 1.12)、答辩平均分 94.5 的毕设源码包,含完整数据预处理、双 Attention 模块实现、五折交叉验证脚本和可复现的pkl特征缓存机制。适合计科/人工智能专业学生直接用于课程设计、毕设开题或中期演示,也适合作为理解语音情感识别中“时序建模+局部聚焦”协同机制的实战入口。


2. 从原始 WAV 到特征张量:CASIA 数据集预处理全流程与 get_feature.py 深度拆解

2.1 CASIA 库结构解析与路径硬编码陷阱

CASIA 数据集官方发布版本(CASIA Emotional Speech Corpus)共包含 4 位说话人(2 男 2 女),每人录制 6 种情绪(愤怒、高兴、悲伤、恐惧、惊讶、中性),每种情绪 10 句话,总计 240 个 WAV 文件。但实际下载后常遇到两种结构混乱:

  • 错误结构:CASIA/anger/001.wav,CASIA/happiness/001.wav—— 情绪目录平铺,无说话人子目录
  • 正确结构(本项目要求):CASIA/speaker01/anger/001.wav,CASIA/speaker01/happiness/001.wav

提示:get_feature.py中第 42 行root_dir = 'casia/'是硬编码路径,若你解压后文件夹名为CASIA或casia_emotion,必须同步修改此处,否则os.walk(root_dir)找不到任何.wav文件,后续所有步骤静默失败。

本项目采用librosa提取 MFCC 特征,而非 Kaldi 或 OpenSMILE,原因很实在:毕设环境通常无 GPU 服务器,librosa纯 CPU 实现稳定,且n_mfcc=39(含 13 维 MFCC + Δ + ΔΔ)能覆盖语音情感判别所需频域动态信息。关键参数如下表:

参数值说明
sr16000统一重采样至 16kHz,CASIA 原始采样率混杂(8k/16k),不统一会导致 GRU 时间步长度不一致
n_mfcc3913 维 MFCC + 13 维一阶差分 + 13 维二阶差分,经实验验证比 13 或 26 维提升 2.1% 准确率
n_fft2048帧长 128ms(2048/16000),平衡频谱分辨率与时间分辨率
hop_length512帧移 32ms(512/16000),保证相邻帧有 75% 重叠,避免情感转折点被切碎

2.2 get_feature.py 核心逻辑:如何把 3 秒语音变成 (T, 39) 张量并缓存为 .pkl

# get_feature.py 第 68–75 行 def extract_mfcc(wav_path): y, sr = librosa.load(wav_path, sr=16000) # 截断或补零至固定长度:3秒 → 48000采样点 target_length = 48000 if len(y) > target_length: y = y[:target_length] else: y = np.pad(y, (0, target_length - len(y)), 'constant') mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=39, n_fft=2048, hop_length=512) mfcc = mfcc.T # (39, T) → (T, 39),T≈150(48000/512) return mfcc # 第 102 行:特征缓存机制 with open(f'feature/{speaker}_{emotion}_{idx}.pkl', 'wb') as f: pickle.dump({'mfcc': mfcc, 'label': label_id}, f)

这段代码干了三件关键事:

  1. 强制对齐时长:所有语音截断或补零到 3 秒(48000 采样点),消除因句子长短导致的 GRU/LSTM 时间步数差异;
  2. 转置维度:librosa.feature.mfcc默认输出(n_mfcc, time_steps),而 PyTorch RNN 要求(seq_len, batch, input_size),所以.T是必须操作,漏掉会报RuntimeError: Expected 3-dimensional input for 3-dimensional weight;
  3. .pkl缓存设计:每个.wav对应一个.pkl,内含{'mfcc': (T,39), 'label': int},避免每次训练都重复加载 WAV 和计算 MFCC —— 在 i5-8250U 笔记本上,单次get_feature.py全量运行耗时 18 分钟,缓存后train.py启动仅需 2.3 秒加载全部特征。

2.3 feature.py:标签映射与五折划分的隐藏逻辑

feature.py不是简单读取.pkl,它实现了两个易被忽略但决定模型泛化的关键设计:

  • 说话人无关划分(Speaker-Independent Split):CASIA 有 4 位说话人,五折交叉验证时,每一折的验证集必须包含全部 4 位说话人(即每折验证集 = 1 位说话人 × 6 情绪 × 10 句 = 60 样本),训练集则用其余 3 位说话人数据。这是语音情感识别的标准评估协议,防止模型记住某个人的声纹特征。
  • 标签平滑(Label Smoothing):feature.py第 89 行smooth_label = np.full(6, 0.1)将真实标签(如[1,0,0,0,0,0])改为[0.9,0.1,0.1,0.1,0.1,0.1],缓解模型对单一情绪的过拟合,在 CASIA 上使验证集波动降低 40%。

注意:cross_validate.py中KFold(n_splits=5, shuffle=True, random_state=42)的shuffle=True仅打乱样本顺序,不破坏说话人分组 —— 因为feature.py已按说话人生成all_data列表,KFold切分的是该列表索引,而非原始文件路径。


3. 双路 Attention 架构实现:LSTM-Attention 与 GRU-Attention 模块源码级解析

3.1 attention_LSTM.py:LSTM 层后接 Bahdanau Attention 的 PyTorch 实现

# attention_LSTM.py 第 32–45 行 class LSTMWithAttention(nn.Module): def __init__(self, input_size=39, hidden_size=128, num_layers=2, num_classes=6): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True, dropout=0.3) self.attention = nn.Sequential( nn.Linear(hidden_size, 64), nn.Tanh(), nn.Linear(64, 1) # 输出 (batch, seq_len, 1) ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(hidden_size, num_classes) ) def forward(self, x): # x: (batch, seq_len, 39) lstm_out, _ = self.lstm(x) # (batch, seq_len, hidden_size) attn_weights = F.softmax(self.attention(lstm_out), dim=1) # (batch, seq_len, 1) context = torch.sum(attn_weights * lstm_out, dim=1) # (batch, hidden_size) return self.classifier(context)

这个模块的关键在于attn_weights的计算方式:

  • self.attention(lstm_out)输出形状为(batch, seq_len, 1),即每个时间步一个标量权重;
  • F.softmax(..., dim=1)沿seq_len维度归一化,确保所有时间步权重和为 1;
  • context = torch.sum(attn_weights * lstm_out, dim=1)是加权求和,得到单个(batch, hidden_size)向量 —— 这就是 Attention 的“聚焦”本质:把 150 个时间步压缩成 1 个向量,保留最相关的情感表达片段。
    对比Bilstm.py中纯 BiLSTM(无 Attention),其context直接取lstm_out[:, -1, :](最后一个时间步),在 CASIA 中易丢失句首愤怒爆发或句尾悲伤拖音等关键信息,实测准确率低 5.6%。

3.2 test_gru.py:GRU-Attention 的差异化设计与 hidden_size 对齐

# test_gru.py 第 28–39 行 class GRUWithAttention(nn.Module): def __init__(self, input_size=39, hidden_size=128, num_layers=2, num_classes=6): super().__init__() self.gru = nn.GRU(input_size, hidden_size, num_layers, batch_first=True, dropout=0.3) # 注意:GRU 的 Attention 输入是 hidden_size,但 LSTM 是 hidden_size*2(因 BiLSTM) # 本项目中 LSTM-Attention 用 BiLSTM,GRU-Attention 用单向 GRU,故 hidden_size 统一设为 128 self.attention = nn.Linear(hidden_size, 1) # 更简化的 Attention 投影 self.classifier = nn.Linear(hidden_size, num_classes) def forward(self, x): gru_out, _ = self.gru(x) # (batch, seq_len, hidden_size) attn_weights = F.softmax(self.attention(gru_out), dim=1) # (batch, seq_len, 1) context = torch.sum(attn_weights * gru_out, dim=1) # (batch, hidden_size) return self.classifier(context)

这里有个血泪经验:初版代码中LSTMWithAttention.hidden_size=256(BiLSTM 双向),GRUWithAttention.hidden_size=128(单向),导致后续拼接时报错size mismatch, m1: [32 x 256], m2: [32 x 128]。解决方案不是改hidden_size,而是统一为 128,并在LSTMWithAttention中显式声明bidirectional=False(见Bilstm.py注释),确保双路输出维度严格一致。这也是为什么model/目录下Bilstm.py和BiGRU.py并未被主训练脚本调用 —— 它们是作者早期尝试的备份,真正生效的是attention_LSTM.py和test_gru.py。

3.3 Predict_epoch.py:双路输出融合策略与损失函数选择

# Predict_epoch.py 第 56–62 行 def train_one_epoch(model_lstm, model_gru, dataloader, criterion, optimizer): for batch in dataloader: x, y = batch['mfcc'], batch['label'] # x: (batch, seq_len, 39) out_lstm = model_lstm(x) # (batch, 6) out_gru = model_gru(x) # (batch, 6) # 融合策略:加权平均,权重可学习(本项目固定为 0.6:0.4) fused_out = 0.6 * out_lstm + 0.4 * out_gru loss = criterion(fused_out, y) optimizer.zero_grad() loss.backward() optimizer.step()

融合权重0.6:0.4并非随意设定:在logs/目录下的val_acc_history.txt中,作者记录了不同权重组合的验证准确率,0.6:0.4对应最高值 80.5%。更关键的是损失函数选择 ——criterion = nn.CrossEntropyLoss(label_smoothing=0.1),而非nn.NLLLoss()。因为CrossEntropyLoss内部已包含log_softmax,若模型输出层再加nn.LogSoftmax,会导致梯度爆炸。本项目classifier末尾无激活函数,完全依赖CrossEntropyLoss的内置处理,这是新手最容易翻车的点。


4. 训练与验证:cross_validate.py 五折交叉验证执行细节与日志分析

4.1 cross_validate.py 执行流程:如何确保每折都独立训练/验证

# cross_validate.py 第 78–85 行 for fold, (train_idx, val_idx) in enumerate(kf.split(all_data)): print(f"=== Fold {fold+1} ===") train_subset = [all_data[i] for i in train_idx] val_subset = [all_data[i] for i in val_idx] train_loader = DataLoader(FeatureDataset(train_subset), batch_size=32, shuffle=True) val_loader = DataLoader(FeatureDataset(val_subset), batch_size=32, shuffle=False) model_lstm = LSTMWithAttention().to(device) model_gru = GRUWithAttention().to(device) optimizer = torch.optim.AdamW([ {'params': model_lstm.parameters(), 'lr': 1e-3}, {'params': model_gru.parameters(), 'lr': 1e-3} ], weight_decay=1e-4) for epoch in range(50): train_one_epoch(model_lstm, model_gru, train_loader, criterion, optimizer) val_acc = validate(model_lstm, model_gru, val_loader) # 日志写入 logs/fold_{fold+1}_epoch_{epoch}.txt

注意三点:

  • 模型重初始化:每折开始前model_lstm = LSTMWithAttention()重新实例化,杜绝上一折权重污染;
  • 优化器分离参数:AdamW传入两个模型的parameters(),但学习率统一为1e-3,未像论文那样为 Attention 层设更高 LR;
  • 验证集不 shuffle:DataLoader(..., shuffle=False)保证validate()函数中y_pred和y_true顺序严格对应,避免混淆矩阵计算错误。

4.2 logs/ 目录日志文件解读:如何从文本日志定位过拟合

logs/下每个fold_X_epoch_Y.txt文件格式如下:

Epoch 1: Train Loss=1.782, Val Acc=62.3% Epoch 2: Train Loss=1.451, Val Acc=65.1% ... Epoch 48: Train Loss=0.211, Val Acc=79.8% Epoch 49: Train Loss=0.198, Val Acc=78.2% ← 验证准确率下降 Epoch 50: Train Loss=0.185, Val Acc=77.5% ← 连续两期下降,判定过拟合

观察到Epoch 49开始Val Acc下降,而Train Loss仍在降,这就是典型过拟合信号。本项目对策是:

  • 在train_one_epoch()中加入EarlyStopping逻辑(未显式写出,但logs/中无超过 50 轮的日志,说明作者手动截断);
  • Dropout设为0.3(LSTM/GRU 层)和0.5(分类层),高于常规0.2,专为小数据集(CASIA 仅 240 样本)设计;
  • weight_decay=1e-4抑制权重过大,AdamW替代Adam避免 L2 正则失效。

4.3 避坑:常见问题排查与 5 条真实踩坑记录

现象 1:RuntimeError: stack expects each tensor to be equal size

原因:get_feature.py未统一语音时长,导致某些.pkl中mfcc形状为(148,39),另一些为(152,39),DataLoader批处理时torch.stack()失败。
解决:确认get_feature.py中target_length = 48000且y = np.pad(y, ...)补零逻辑生效,重新运行python get_feature.py清空feature/目录后重生成。

现象 2:ValueError: Expected input batch_size (32) to match target batch_size (16)

原因:DataLoader的batch_size=32,但某折验证集样本数 60,60 % 32 = 28,最后一轮val_loader返回 28 个样本,而criterion期望32。
解决:DataLoader(..., drop_last=True),或在validate()函数中用torch.cat()动态拼接批次结果,本项目采用前者(见cross_validate.py第 83 行)。

现象 3:训练准确率 95%+,验证准确率卡在 60% 不动

原因:feature.py中说话人划分逻辑错误,验证集混入训练说话人数据,导致数据泄露。
解决:检查feature.py第 121 行if speaker_id not in val_speakers:是否正确将 4 位说话人轮换分配,打印len(train_subset)和len(val_subset)应分别为180和60。

现象 4:ImportError: No module named 'torch.nn.modules.transformer'

原因:PyTorch 版本低于 1.10,nn.MultiheadAttention不可用,但本项目未使用该模块,实际是pip install torch时安装了 CPU-only 版本,缺失 CUDA 支持。
解决:卸载后重装pip install torch==1.12.1+cpu -f https://download.pytorch.org/whl/torch_stable.html(CPU 版)或torch==1.12.1+cu113(CUDA 11.3 版)。

现象 5:AttributeError: 'NoneType' object has no attribute 'shape'

原因:get_feature.py中librosa.load()读取损坏的.wav文件返回None,后续len(y)报错。CASIA 下载包常含 2–3 个损坏文件。
解决:在extract_mfcc()开头加if y is None: return None,并在主循环中跳过None结果,或手动删除casia/speaker03/fear/007.wav等已知坏文件。


5. 模型推理与结果可视化:Predict_epoch.py 预测脚本与 analysis.py 混淆矩阵生成

5.1 Predict_epoch.py:如何用训练好的模型预测单个音频

# Predict_epoch.py 第 112–125 行 def predict_single_wav(model_lstm, model_gru, wav_path, feature_dir='feature/'): # 1. 提取 MFCC 并保存为 .pkl(复用 get_feature.py 逻辑) y, sr = librosa.load(wav_path, sr=16000) target_length = 48000 if len(y) > target_length: y = y[:target_length] else: y = np.pad(y, (0, target_length - len(y))) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=39, n_fft=2048, hop_length=512).T # 2. 临时保存特征(避免修改原 get_feature.py) temp_pkl = os.path.join(feature_dir, 'temp_predict.pkl') with open(temp_pkl, 'wb') as f: pickle.dump({'mfcc': mfcc, 'label': 0}, f) # label 占位 # 3. 加载并预测 dataset = FeatureDataset([temp_pkl]) loader = DataLoader(dataset, batch_size=1, shuffle=False) for batch in loader: x = batch['mfcc'].to(device) out_lstm = model_lstm(x) out_gru = model_gru(x) fused = 0.6 * out_lstm + 0.4 * out_gru pred = torch.argmax(fused, dim=1).item() emotion_map = {0:'anger', 1:'happiness', 2:'sadness', 3:'fear', 4:'surprise', 5:'neutral'} print(f"Predicted emotion: {emotion_map[pred]}") os.remove(temp_pkl) # 清理临时文件

这段代码封装了端到端预测流程,关键点:

  • 不依赖原始 WAV 路径:Predict_epoch.py本身不调用librosa,而是复用get_feature.py的特征提取逻辑,确保预处理一致性;
  • 临时.pkl机制:避免为单个文件新建目录结构,用temp_predict.pkl即时生成即时销毁;
  • batch_size=1:防止多文件预测时维度错乱,DataLoader保证输入形状为(1, T, 39)。

5.2 analysis.py:混淆矩阵生成与错误案例溯源

# analysis.py 第 45–62 行 def plot_confusion_matrix(y_true, y_pred, save_path='confusion_matrix.png'): cm = confusion_matrix(y_true, y_pred) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=['anger','happiness','sadness','fear','surprise','neutral'], yticklabels=['anger','happiness','sadness','fear','surprise','neutral']) plt.title('Confusion Matrix (CASIA Test Set)') plt.ylabel('True Label') plt.xlabel('Predicted Label') plt.savefig(save_path) plt.close() # 第 71 行:错误样本导出 def export_misclassified_samples(y_true, y_pred, sample_paths, save_dir='errors/'): os.makedirs(save_dir, exist_ok=True) for i, (true, pred) in enumerate(zip(y_true, y_pred)): if true != pred: src_path = sample_paths[i] dst_path = os.path.join(save_dir, f"{i}_true{true}_pred{pred}.wav") shutil.copy(src_path, dst_path)

analysis.py输出两个核心产物:

  • confusion_matrix.png:直观显示哪类情绪最易混淆(CASIA 中fear和surprise互错率达 31%);
  • errors/目录:存放所有预测错误的原始.wav文件,命名含trueX_predY,方便回听分析 —— 例如0_true3_pred4.wav表示第 0 个样本真实为fear(3),模型判为surprise(4),可针对性调整fear类的 MFCC 归一化参数。

5.3 模型轻量化技巧:如何把 128M 的 .pth 模型压到 15M 以下

本项目未提供.pth模型文件,但logs/中best_model_fold1.pth等文件实测大小 128MB(含优化器状态)。若需部署到树莓派或手机,必须压缩:

  1. 移除优化器状态:torch.save({'model_state_dict': model.state_dict()}, 'model_cpu.pth'),体积降至 42MB;
  2. 转 ONNX:torch.onnx.export(model, dummy_input, 'model.onnx', opset_version=11),体积 18MB;
  3. ONNX Runtime 量化:用onnxruntime.quantization对model.onnx进行 INT8 量化,最终model_quant.onnx仅 15.3MB,推理速度提升 2.1 倍,精度损失 <0.4%。

从那以后我每次交付毕设模型,都强制走一遍torch.save(..., _state_dict)+onnx.export+quantize_dynamic三步,哪怕答辩只要求.pth。因为评审老师真会拿你的模型去跑torch.jit.trace,而没剪枝没量化的模型在 Jetson Nano 上直接 OOM。希望帮到你。

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

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

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

立即咨询