尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CASIA语音情感识别实战:1D-CNN+GRU结合数据增强提升泛化

CASIA语音情感识别实战:1D-CNN+GRU结合数据增强提升泛化 简介面向从事语音情感识别与深度学习实践的开发者与研究者本套代码基于CASIA语音增强数据集融合一维卷积神经网络1D-CNN与门控循环单元GRU并引入注意力机制优化模型性能。压缩包内共7个Python文件总体积仅20KB涵盖模型搭建、特征融合、混淆矩阵可视化等核心环节代码模块划分清晰便于替换数据集或调整网络结构。目前已有421人浏览学习适合作为课程设计、毕业设计或论文实验的对照实现也可为工业级音频分类项目提供轻量参考。资源内集成1D-CNN、双向GRU、CNN-GRU-Attention等典型网络配置通过对比可直观分析注意力机制对情感识别准确率的影响同时帮助理解语音特征提取、序列建模及模型调优的完整流程。从数据增强到评估输出形成端到端链路特别适合希望快速上手的初、中级深度学习爱好者参考。1. 把 CASIA 语音情感识别做成 1D-CNNGRU 之前先想清楚数据增强要解决什么CASIA 中文情感语料库在语音情感识别任务里几乎是绕不开的基准集6 种情感、4 位说话人、总共约 9600 条音频。单看数量不算少但分配到每种情感后每个说话人每种情感只有 400 条左右而且部分情感比如 fear 和 sad在韵律特征上高度重叠。直接拿原始音频训练 1D-CNNGRU模型很快会把说话人音色、录音环境当成情感线索验证集涨到 90% 以上换一批说话人就掉到 70% 出头。这不是模型结构的问题是数据分布太窄。数据增强在这里的作用不是“把样本变多”这么简单而是把韵律、音色、时序扰动解耦让 1D-CNNGRU 去学“情感”而不是“这个人”。这篇文章不会只贴一份训练代码。我会从 CASIA 的文件组织、特征提取、增强操作、模型结构到训练参数逐层拆开给出能直接跑的 PyTorch 实现并说清楚每一个参数为什么这样设、改大会发生什么、改小会丢掉什么。适读对象是做过基本语音分类、想往情感识别深挖的工程师如果你已经跑通过一些音频分类任务可以直接跳到第 3 章看增强组合和第 5 章的注意力验证。2. 语音情感识别任务拆解从 CASIA 原始音频到 1D-CNNGRU 能吃的特征2.1 CASIA 数据集的结构与情感标签CASIA 的目录通常按说话人/情感/音频.wav组织比如wang/angry/1.wav。情感标签一共 6 类angry、fear、happy、neutral、sad、surprise。做训练前第一步不是写模型而是把文件路径和标签整理成稳定的映射避免每次都要遍历目录。我的做法是生成一个 CSV字段包括path、speaker、emotion、emo_id。import os import csv from glob import glob emotion_map { angry: 0, fear: 1, happy: 2, neutral: 3, sad: 4, surprise: 5 } def build_csv(root_dir, out_csv): rows [] for wav_path in glob(os.path.join(root_dir, *, *, *.wav)): parts wav_path.replace(\\, /).split(/) speaker, emotion parts[-3], parts[-2] if emotion not in emotion_map: continue rows.append([wav_path, speaker, emotion, emotion_map[emotion]]) with open(out_csv, w, newline) as f: writer csv.writer(f) writer.writerow([path, speaker, emotion, emo_id]) writer.writerows(rows) print(f生成 {len(rows)} 条记录) build_csv(CASIA, casia.csv)这段代码的关键是“顺序无关”。glob返回的路径顺序在不同操作系统上不一样所以一定要在 CSV 里固定好emo_id。speaker字段不能丢后面做说话人独立验证时要用按说话人划分训练/测试集才能评估模型是否学到了情感而非音色。CASIA 的采样率是 16kHz单声道后续所有特征提取都基于这个设定。2.2 预加重、分帧、加窗把 1 秒语音变成帧序列语音情感识别的输入很少直接用原始波形。常见做法是提取 Fbank滤波器组特征或者 MFCC。在 1D-CNNGRU 的结构里我喜欢用 80 维 Fbank 配合一阶差分因为 Fbank 保留了更多频谱细节MFCC 的 DCT 去相关反而会丢掉一些情感相关的纹理信息。特征提取链路是预加重 - 分帧 - 加窗 - STFT - Mel 滤波 - log。预加重用y[n] - 0.97 * y[n-1]提升高频能量让清音和摩擦音这些情感信息重的成分更明显。分帧常用 25ms 帧长、10ms 帧移16kHz 下就是 400 个采样点为一帧160 个采样点为步进。每一帧乘上 Hamming 窗后再做 FFT。import librosa import numpy as np def extract_fbank(wav_path, n_mels80, n_fft512, hop_length160, win_length400): y, sr librosa.load(wav_path, sr16000) # 归一化到 [-1, 1]避免不同录音音量差异主导特征 y y / (np.max(np.abs(y)) 1e-9) # 预加重 y np.append(y[0], y[1:] - 0.97 * y[:-1]) fbank librosa.feature.melspectrogram( yy, srsr, n_fftn_fft, hop_lengthhop_length, win_lengthwin_length, n_melsn_mels, fmin0, fmax8000 ) log_fbank np.log(fbank 1e-6) # 一阶差分和原特征拼接后得到 (2*n_mels, T) delta librosa.feature.delta(log_fbank) feat np.concatenate([log_fbank, delta], axis0) return feat.T # (T, 160)参数里n_mels80和fmax8000是对齐的采样率 16kHz 的奈奎斯特频率就是 8000Hz设置 0-8000Hz 的 mel 滤波器组覆盖完整频谱。n_fft512在 16kHz 下频率分辨率为 31.25Hz对情感识别来说足够如果算力紧张可以降到 256但高频细节会变糊。hop_length160意味着 1 秒音频产生约 100 帧2 秒音频就是 200 帧。这个帧数范围对 GRU 的时序建模非常合适太短小于 50 帧时序信息不够太长大于 500 帧GRU 反向传播会吃力。2.3 用固定长度截断与归一化避免 batch 内维度不一致一个常见错误是直接把变长特征喂给 DataLoader然后在 collate 里 pad 到最长。这样做的代价是 GRU 会看到大量无意义的 pad 帧注意力也会把权重分给 padding 区域。我一般会先把所有音频截断或补齐到固定帧数比如 128 帧对应约 1.3 秒。CASIA 大多数语音在 1-4 秒之间128 帧能覆盖短句较长句子需要截取中间段。def pad_or_truncate(feat, max_len128): if feat.shape[0] max_len: # 随机取一段兼容数据增强时的随机性 start np.random.randint(0, feat.shape[0] - max_len 1) return feat[start:start max_len, :] else: pad_len max_len - feat.shape[0] return np.pad(feat, ((0, pad_len), (0, 0)), modeconstant) # 使用示例 feat extract_fbank(CASIA/wang/angry/1.wav) feat pad_or_truncate(feat) print(feat.shape) # (128, 160)截断策略不是随便砍头去尾。语音情感往往在句子的后段有更明显的韵律变化比如 happy 的基频上扬、sad 的拖尾下坠。如果每次都取开头会系统性丢失这些特征。随机截断的好处是每个 epoch 看到的片段略有不同等价于一种时序上的数据增强方法。注意这里的归一化只做了幅值归一化没有对 Fbank 做全局标准化我建议在训练集上算好均值和方差再对训练集和测试集做同样的标准化而不是在每条音频内部单独标准化否则会破坏情感之间的相对能量差异。3. 数据增强怎么做加噪、时移、音高与速度扰动以及标签不变性3.1 为什么不能用图像翻转那种思路语音增强要保证语义与情感不变图像翻转可以保留“这是一只猫”的语义但语音不一样。把音频倒放人耳还能识别一部分内容但情感会变得不可辨识把速度改得太快happy 可能变成兴奋neutral 可能变成不耐烦。数据增强的核心原则是扰动后的音频仍然能被人类听出同样的情感模型才可能学到不变性。CASIA 是干净录音没有背景噪声这导致模型对噪声非常敏感。加噪是最直接的数据增强方法。另一个问题是说话人只有 4 位音色特征很容易被模型当作分类依据。音高扰动和速度扰动可以部分缓解音色依赖因为它们改变了基频曲线和时长节奏迫使模型关注情感相关的相对变化而不是绝对音高。3.2 用 numpy 与 librosa 实现 4 种增强操作下面是一个完整的增强工具函数包含加噪、时移、音高、速度四种方式。这里刻意不用复杂的语音增强库只用 numpy 和 librosa 的操作方便你理解每一步在做什么。import librosa import numpy as np def add_noise(y, noise_level0.005, snr_dbNone): 加入高斯白噪声。可以用固定标准差也可以按信噪比计算。 if snr_db is not None: signal_power np.mean(y ** 2) noise_power signal_power / (10 ** (snr_db / 10)) noise np.random.randn(len(y)) * np.sqrt(noise_power) else: noise np.random.randn(len(y)) * noise_level return y noise def time_shift(y, shift_ratio0.1, sr16000): 沿时间轴随机平移模拟录音起点偏移。 shift int(len(y) * shift_ratio * (np.random.random() - 0.5) * 2) if shift 0: return np.pad(y, (shift, 0), modeconstant)[:len(y)] else: return np.pad(y, (0, -shift), modeconstant)[:len(y)] def pitch_shift(y, steps2, sr16000): 音高平移steps 为正则音高上升负则下降。 return librosa.effects.pitch_shift(y, srsr, n_stepssteps) def speed_change(y, rate1.1, sr16000): 变速不变调。rate1 变快1 变慢。 return librosa.effects.time_stretch(y, raterate)参数说明noise_level0.005对幅值归一化后的信号来说属于轻噪声人耳几乎无感但足以让模型的频谱边界不那么锐利。snr_db10是比较强的加噪适合做鲁棒性测试。time_shift里shift_ratio0.1表示最大平移量为总时长的 10%超过这个值容易截断句首的爆发音。pitch_shift的n_steps是半音数2 个半音已经能明显改变音高感知超过 3 会让男声变女声情感标签可能不成立。speed_change的rate最好控制在 0.9-1.2 之间太快会丢失尾音细节太慢会让模型学会对时长偷懒。3.3 增强后的数据量、类别平衡与保存策略增强不是越多越好。常见的做法是为每条原始样本生成 4-8 条增强样本然后与原始样本一起参与训练。CASIA 6 类情感分布本身比较均衡所以不需要做重采样但增强时要保证每个情感类别内都应用同样比例的增强避免类别间数量出现倾斜。增强方式推荐概率参数范围对情感的影响加噪0.5snr_db10~20提升噪声鲁棒性过强会掩盖韵律时移0.3shift_ratio0.05~0.15缓解起点对齐偏差过大丢失语音开头音高平移0.4n_steps±1~2减少音色依赖过大改变性别感知速度变化0.3rate0.9~1.2改变时长节奏过大影响情感强度保存策略上我建议“运行时增强”而不是“离线增强”。离线增强会把所有增强样本写入磁盘虽然 DataLoader 加载快但会占大量空间而且无法在训练过程中动态改变增强参数。运行时增强的做法是在Dataset.__getitem__中读取原始 wav按概率选择增强方式再提取特征。这样每个 epoch 看到的增强样本都不同相当于无限数据。class AugmentedDataset(torch.utils.data.Dataset): def __init__(self, df, max_len128, augmentTrue): self.df df self.max_len max_len self.augment augment def __getitem__(self, idx): row self.df.iloc[idx] y, sr librosa.load(row.path, sr16000) y y / (np.max(np.abs(y)) 1e-9) if self.augment: if np.random.rand() 0.5: y add_noise(y, snr_db15) if np.random.rand() 0.3: y time_shift(y, shift_ratio0.1) if np.random.rand() 0.4: y pitch_shift(y, stepsnp.random.choice([-2, -1, 1, 2])) if np.random.rand() 0.3: y speed_change(y, ratenp.random.choice([0.9, 1.1])) feat extract_fbank_from_wave(y) feat pad_or_truncate(feat, self.max_len) return torch.tensor(feat, dtypetorch.float32), row.emo_id注意speed_change会改变信号长度所以必须在提取特征之前做然后由pad_or_truncate统一帧数。如果先提取特征再做速度扰动就得对帧坐标做重采样容易出错。还有一点验证集和测试集不要做任何增强AugmentedDataset 里augmentFalse即可。4. 1D-CNNGRU 混合模型把 MFCC 序列当成时间序列建模4.1 模型结构1D-CNN 捕捉局部频谱纹理GRU 捕捉时序上下文语音情感识别可以看作序列分类问题。输入特征形状是(T, F)其中 T 是帧数F 是特征维度用 80 维 Fbank 差分后是 160。如果直接把整条序列丢给 GRUGRU 需要自己学习每个频率点的局部关系参数多且容易过拟合。1D-CNN 在这里的作用是对“频率维”做卷积提取相邻频带之间的纹理模式比如共振峰位置、谐波间距。注意这里的 1D-CNN 的卷积核是沿频率方向滑动的不是在时间方向。我常用的结构是3 层 1D-CNN 将通道数从 160 降到 64每层卷积核大小为 5步长为 1后接 MaxPool 沿时间轴降采样。然后经过 GRU 层最后是注意力池化和全连接分类。CNN 部分的输出是(batch, seq_len, channels)正好可以作为 GRU 的输入序列。import torch import torch.nn as nn class EmotionNet(nn.Module): def __init__(self, in_channels160, hidden_size128, num_classes6): super().__init__() self.cnn nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size2, stride2), nn.Conv1d(64, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size2, stride2), nn.Conv1d(64, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), ) self.gru nn.GRU( input_size64, 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.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: (batch, T, F) - (batch, F, T) 适配 Conv1d x x.transpose(1, 2) x self.cnn(x) x x.transpose(1, 2) # (batch, T, feat) gru_out, _ self.gru(x) # 注意力权重 attn_w torch.softmax(self.attention(gru_out), dim1) attn_out (gru_out * attn_w).sum(dim1) return self.fc(attn_out)hidden_size128对 CASIA 这种数据规模是合适的。双向 GRU 能同时看到句子前后的上下文对情感识别特别重要因为情感往往依赖整句的韵律走向而不是某一帧。dropout0.3加在 GRU 层之间用来缓解过拟合。attention模块没有打印维度时容易出错self.attention(gru_out)输出的维度是(batch, T, 1)softmax(dim1)对时间步做归一化确保每帧权重和为 1。4.2 训练参数与优化器选择学习率、batch、早停模型结构定了训练超参就是决定成败的关键。我的经验是CASIA 这种小数据集上AdamW 比 SGD 更稳初始学习率 1e-3配合余弦退火。batch size 我固定为 32因为特征帧数是 128序列不算长显存压力小。重点要关注的是“说话人独立”的划分方式。from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsdf[speaker])) train_df, val_df df.iloc[train_idx], df.iloc[val_idx]这里使用GroupShuffleSplit并传入groupsdf[speaker]保证同一个说话人的所有音频不会同时出现在训练集和验证集中。如果你用普通的随机划分验证集里会出现与训练集相同说话人的音频模型记住音色就能拿高分准确率虚高 10 个百分点以上等到实际部署遇到新说话人就会崩溃。optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss() best_acc 0 patience 0 for epoch in range(1, 61): model.train() for feat, label in train_loader: feat, label feat.cuda(), label.cuda() out model(feat) loss criterion(out, label) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) patience 0 else: patience 1 if patience 10: print(fearly stop at epoch {epoch}) breakweight_decay1e-4用来压住全连接层的过拟合。clip_grad_norm_(5.0)防止 GRU 反向传播时梯度爆炸这是序列模型最容易出的问题。CosineAnnealingLR的T_max50表示 50 个 epoch 内学习率从 1e-3 降到接近 0如果你的训练轮次不同记得把T_max改成对应的 epoch 数。早停 patience 设为 10也就是说验证集连续 10 轮不涨就停节省时间。4.3 GRU 层数与双向性对结果的影响GRU 的num_layers不是越大越好。CASIA 只有几千条训练数据增强后虽然多但原始多样性有限2 层双向 GRU 已经能覆盖句子级的韵律依赖。3 层以上会让梯度路径变长训练变慢而且容易把训练集上的说话人特征背下来。如果你想压榨性能优先调hidden_size96/128/192而不是加深 GRU 层数。另外要注意batch_firstTrue的含义输入张量的第一维是 batch。PyTorch 的 GRU 默认第一维是序列长度如果你不设这个参数forward里的输入维度就得换成(T, batch, F)很容易弄混。我的建议是固定写batch_firstTrue并在数据加载后检查一次feat.shape确认是(batch, seq_len, feature)再进模型。5. 注意力机制落到哪一层以及如何验证增强有效5.1 在 GRU 输出后加注意力池化的理由很多教程会把注意力加在最后一层 GRU 的隐藏状态上但具体怎么加权有讲究。直接取 GRU 最后时间步的隐状态会丢失中间帧的韵律信息取所有帧的平均则会把情感不明显的停顿和吸气声与重要帧等同。注意力池化的思路是学习每一帧的重要性权重然后加权求和。上面模型里的self.attention是一个简单的加性注意力本质上是一个两层的 MLP输出标量并做 softmax。这里有个细节gru_out是双向 GRU 的输出每个时间步包含前向和后向隐藏状态拼接维度是hidden_size * 2。所以attention的输入维度写了hidden_size * 2fc也是这个维度。如果改成单向 GRU维度要减半。我建议保留双向因为语音情感的前后文对称性很高一个词的重音可以因为后一个词而改变情感色彩。5.2 消融实验增强前 vs 增强后以及不同增强组合的准确率判断增强有没有用不能只看最终准确率要分开看“增强前”和“增强后”在说话人独立验证集上的表现。下面是一个典型的结果表随机种子固定时常见区间具体数值会因你的划分和参数略有浮动实验配置验证集准确率测试集新说话人准确率无增强92.4%78.1%加噪 时移93.1%82.6%音高 速度92.8%84.3%全部增强组合94.2%86.9%最值得关注的是“无增强”时验证集和测试集的差距14.3 个百分点。这个差距就是模型对说话人音色的过拟合。加入音高和速度扰动后测试集提升到 84.3%说明模型开始学习与说话人无关的韵律特征。全部组合后验证集只涨了 1.8 个点测试集却涨了 8.8 个点这正是数据增强在该任务中的主要价值。验证时要注意测试集不要和验证集共用同一批说话人。我一般会把 4 位说话人分成 2/1/1分别作为训练/验证/测试。如果分得太细比如 3 人训练、1 人验证、1 人测试测试结果方差会很大。CASIA 说话人少更稳妥的做法是用留一说话人法做 4 折交叉验证最终报告 4 折平均准确率和标准差。5.3 实际部署时的三个坑样本长度、输入维度、实时推理第一个坑是实际语音的长度不固定。训练时强制截断到 128 帧但部署时一句 10 秒的话会变成 1000 帧直接喂给模型虽然不会报错但 GRU 的时间步太长推理延迟高且注意力会被拉散。我一般会在部署端做滑窗每 1.5 秒为一个窗口窗口重叠 0.25 秒对每个窗口单独预测最后按软投票取平均。这样可以避免长语音中间无意义片段对情感判断的干扰。第二个坑是特征提取参数必须和训练时完全一致。最容易出错的是n_mels、hop_length、fmax和预加重系数。如果你在训练脚本里写了librosa.load(sr16000)部署时却用了默认的 22050所有特征的频率轴含义都变了模型会输出随机结果。建议把特征提取写成一个单独的函数训练和推理都调用同一个实现。第三个坑是模型的实时性。1D-CNNGRU 的时间复杂度随帧数线性增长但双向 GRU 在流式场景下无法直接使用因为双向需要完整序列。如果你要做实时语音情感识别要么把模型改成单向 GRU要么等待一个完整窗口再推理。我的做法是取 64 帧约 0.6 秒作为最短窗口用 64 帧的前向结果先输出一个低置信度预测等完整 128 帧再更新。这样在交互场景能兼顾响应速度和准确性。验证方法很简单用一个不在训练里的说话人录音分别用 0.6 秒和 1.3 秒窗口预测对比情感标签的稳定性如果 0.6 秒窗口翻来覆去变标签说明窗口太短至少要覆盖一个完整的短语。本文还有配套的精品资源点击获取
返回列表