CASIA语音情感识别实战:1D-CNN+GRU结合数据增强提升泛化
2026/9/15 16:33:20 网站建设 项目流程

简介:面向从事语音情感识别与深度学习实践的开发者与研究者,本套代码基于CASIA语音增强数据集,融合一维卷积神经网络(1D-CNN)与门控循环单元(GRU),并引入注意力机制优化模型性能。压缩包内共7个Python文件,总体积仅20KB,涵盖模型搭建、特征融合、混淆矩阵可视化等核心环节,代码模块划分清晰,便于替换数据集或调整网络结构。目前已有421人浏览学习,适合作为课程设计、毕业设计或论文实验的对照实现,也可为工业级音频分类项目提供轻量参考。资源内集成1D-CNN、双向GRU、CNN-GRU-Attention等典型网络配置,通过对比可直观分析注意力机制对情感识别准确率的影响,同时帮助理解语音特征提取、序列建模及模型调优的完整流程。从数据增强到评估输出形成端到端链路,特别适合希望快速上手的初、中级深度学习爱好者参考。

1. 把 CASIA 语音情感识别做成 1D-CNN+GRU 之前,先想清楚数据增强要解决什么

CASIA 中文情感语料库在语音情感识别任务里几乎是绕不开的基准集:6 种情感、4 位说话人、总共约 9600 条音频。单看数量不算少,但分配到每种情感后,每个说话人每种情感只有 400 条左右,而且部分情感(比如 fear 和 sad)在韵律特征上高度重叠。直接拿原始音频训练 1D-CNN+GRU,模型很快会把说话人音色、录音环境当成情感线索,验证集涨到 90% 以上,换一批说话人就掉到 70% 出头。这不是模型结构的问题,是数据分布太窄。数据增强在这里的作用不是“把样本变多”这么简单,而是把韵律、音色、时序扰动解耦,让 1D-CNN+GRU 去学“情感”而不是“这个人”。

这篇文章不会只贴一份训练代码。我会从 CASIA 的文件组织、特征提取、增强操作、模型结构到训练参数逐层拆开,给出能直接跑的 PyTorch 实现,并说清楚每一个参数为什么这样设、改大会发生什么、改小会丢掉什么。适读对象是做过基本语音分类、想往情感识别深挖的工程师;如果你已经跑通过一些音频分类任务,可以直接跳到第 3 章看增强组合和第 5 章的注意力验证。

2. 语音情感识别任务拆解:从 CASIA 原始音频到 1D-CNN+GRU 能吃的特征

2.1 CASIA 数据集的结构与情感标签

CASIA 的目录通常按说话人/情感/音频.wav组织,比如wang/angry/1.wav。情感标签一共 6 类:angry、fear、happy、neutral、sad、surprise。做训练前第一步不是写模型,而是把文件路径和标签整理成稳定的映射,避免每次都要遍历目录。我的做法是生成一个 CSV,字段包括pathspeakeremotionemo_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_idspeaker字段不能丢,后面做说话人独立验证时要用:按说话人划分训练/测试集,才能评估模型是否学到了情感而非音色。CASIA 的采样率是 16kHz,单声道,后续所有特征提取都基于这个设定。

2.2 预加重、分帧、加窗:把 1 秒语音变成帧序列

语音情感识别的输入很少直接用原始波形。常见做法是提取 Fbank(滤波器组特征)或者 MFCC。在 1D-CNN+GRU 的结构里,我喜欢用 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_mels=80, n_fft=512, hop_length=160, win_length=400): y, sr = librosa.load(wav_path, sr=16000) # 归一化到 [-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( y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, win_length=win_length, n_mels=n_mels, fmin=0, fmax=8000 ) log_fbank = np.log(fbank + 1e-6) # 一阶差分,和原特征拼接后得到 (2*n_mels, T) delta = librosa.feature.delta(log_fbank) feat = np.concatenate([log_fbank, delta], axis=0) return feat.T # (T, 160)

参数里n_mels=80fmax=8000是对齐的:采样率 16kHz 的奈奎斯特频率就是 8000Hz,设置 0-8000Hz 的 mel 滤波器组覆盖完整频谱。n_fft=512在 16kHz 下频率分辨率为 31.25Hz,对情感识别来说足够;如果算力紧张可以降到 256,但高频细节会变糊。hop_length=160意味着 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_len=128): 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)), mode='constant') # 使用示例 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_level=0.005, snr_db=None): """加入高斯白噪声。可以用固定标准差,也可以按信噪比计算。""" 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_ratio=0.1, sr=16000): """沿时间轴随机平移,模拟录音起点偏移。""" shift = int(len(y) * shift_ratio * (np.random.random() - 0.5) * 2) if shift > 0: return np.pad(y, (shift, 0), mode='constant')[:len(y)] else: return np.pad(y, (0, -shift), mode='constant')[:len(y)] def pitch_shift(y, steps=2, sr=16000): """音高平移,steps 为正则音高上升,负则下降。""" return librosa.effects.pitch_shift(y, sr=sr, n_steps=steps) def speed_change(y, rate=1.1, sr=16000): """变速不变调。rate>1 变快,<1 变慢。""" return librosa.effects.time_stretch(y, rate=rate)

参数说明:noise_level=0.005对幅值归一化后的信号来说属于轻噪声,人耳几乎无感,但足以让模型的频谱边界不那么锐利。snr_db=10是比较强的加噪,适合做鲁棒性测试。time_shiftshift_ratio=0.1表示最大平移量为总时长的 10%,超过这个值容易截断句首的爆发音。pitch_shiftn_steps是半音数,2 个半音已经能明显改变音高感知,超过 3 会让男声变女声,情感标签可能不成立。speed_changerate最好控制在 0.9-1.2 之间,太快会丢失尾音细节,太慢会让模型学会对时长偷懒。

3.3 增强后的数据量、类别平衡与保存策略

增强不是越多越好。常见的做法是为每条原始样本生成 4-8 条增强样本,然后与原始样本一起参与训练。CASIA 6 类情感分布本身比较均衡,所以不需要做重采样,但增强时要保证每个情感类别内都应用同样比例的增强,避免类别间数量出现倾斜。

增强方式推荐概率参数范围对情感的影响
加噪0.5snr_db=10~20提升噪声鲁棒性,过强会掩盖韵律
时移0.3shift_ratio=0.05~0.15缓解起点对齐偏差,过大丢失语音开头
音高平移0.4n_steps=±1~2减少音色依赖,过大改变性别感知
速度变化0.3rate=0.9~1.2改变时长节奏,过大影响情感强度

保存策略上,我建议“运行时增强”而不是“离线增强”。离线增强会把所有增强样本写入磁盘,虽然 DataLoader 加载快,但会占大量空间,而且无法在训练过程中动态改变增强参数。运行时增强的做法是在Dataset.__getitem__中读取原始 wav,按概率选择增强方式,再提取特征。这样每个 epoch 看到的增强样本都不同,相当于无限数据。

class AugmentedDataset(torch.utils.data.Dataset): def __init__(self, df, max_len=128, augment=True): 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, sr=16000) y = y / (np.max(np.abs(y)) + 1e-9) if self.augment: if np.random.rand() < 0.5: y = add_noise(y, snr_db=15) if np.random.rand() < 0.3: y = time_shift(y, shift_ratio=0.1) if np.random.rand() < 0.4: y = pitch_shift(y, steps=np.random.choice([-2, -1, 1, 2])) if np.random.rand() < 0.3: y = speed_change(y, rate=np.random.choice([0.9, 1.1])) feat = extract_fbank_from_wave(y) feat = pad_or_truncate(feat, self.max_len) return torch.tensor(feat, dtype=torch.float32), row.emo_id

注意speed_change会改变信号长度,所以必须在提取特征之前做,然后由pad_or_truncate统一帧数。如果先提取特征再做速度扰动,就得对帧坐标做重采样,容易出错。还有一点:验证集和测试集不要做任何增强,AugmentedDataset 里augment=False即可。

4. 1D-CNN+GRU 混合模型:把 MFCC 序列当成时间序列建模

4.1 模型结构:1D-CNN 捕捉局部频谱纹理,GRU 捕捉时序上下文

语音情感识别可以看作序列分类问题。输入特征形状是(T, F),其中 T 是帧数,F 是特征维度(用 80 维 Fbank + 差分后是 160)。如果直接把整条序列丢给 GRU,GRU 需要自己学习每个频率点的局部关系,参数多且容易过拟合。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_channels=160, hidden_size=128, num_classes=6): super().__init__() self.cnn = nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size=2, stride=2), nn.Conv1d(64, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size=2, stride=2), nn.Conv1d(64, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), ) self.gru = nn.GRU( input_size=64, hidden_size=hidden_size, num_layers=2, batch_first=True, bidirectional=True, dropout=0.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), dim=1) attn_out = (gru_out * attn_w).sum(dim=1) return self.fc(attn_out)

hidden_size=128对 CASIA 这种数据规模是合适的。双向 GRU 能同时看到句子前后的上下文,对情感识别特别重要,因为情感往往依赖整句的韵律走向,而不是某一帧。dropout=0.3加在 GRU 层之间,用来缓解过拟合。attention模块没有打印维度时容易出错:self.attention(gru_out)输出的维度是(batch, T', 1)softmax(dim=1)对时间步做归一化,确保每帧权重和为 1。

4.2 训练参数与优化器选择:学习率、batch、早停

模型结构定了,训练超参就是决定成败的关键。我的经验是:CASIA 这种小数据集上,AdamW 比 SGD 更稳,初始学习率 1e-3,配合余弦退火。batch size 我固定为 32,因为特征帧数是 128,序列不算长,显存压力小。重点要关注的是“说话人独立”的划分方式。

from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df['speaker'])) train_df, val_df = df.iloc[train_idx], df.iloc[val_idx]

这里使用GroupShuffleSplit并传入groups=df['speaker'],保证同一个说话人的所有音频不会同时出现在训练集和验证集中。如果你用普通的随机划分,验证集里会出现与训练集相同说话人的音频,模型记住音色就能拿高分,准确率虚高 10 个百分点以上,等到实际部署遇到新说话人就会崩溃。

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) 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(f'early stop at epoch {epoch}') break

weight_decay=1e-4用来压住全连接层的过拟合。clip_grad_norm_(5.0)防止 GRU 反向传播时梯度爆炸,这是序列模型最容易出的问题。CosineAnnealingLRT_max=50表示 50 个 epoch 内学习率从 1e-3 降到接近 0;如果你的训练轮次不同,记得把T_max改成对应的 epoch 数。早停 patience 设为 10,也就是说验证集连续 10 轮不涨就停,节省时间。

4.3 GRU 层数与双向性对结果的影响

GRU 的num_layers不是越大越好。CASIA 只有几千条训练数据(增强后虽然多,但原始多样性有限),2 层双向 GRU 已经能覆盖句子级的韵律依赖。3 层以上会让梯度路径变长,训练变慢,而且容易把训练集上的说话人特征背下来。如果你想压榨性能,优先调hidden_size(96/128/192),而不是加深 GRU 层数。

另外要注意batch_first=True的含义:输入张量的第一维是 batch。PyTorch 的 GRU 默认第一维是序列长度,如果你不设这个参数,forward里的输入维度就得换成(T, batch, F),很容易弄混。我的建议是固定写batch_first=True,并在数据加载后检查一次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_melshop_lengthfmax和预加重系数。如果你在训练脚本里写了librosa.load(sr=16000),部署时却用了默认的 22050,所有特征的频率轴含义都变了,模型会输出随机结果。建议把特征提取写成一个单独的函数,训练和推理都调用同一个实现。

第三个坑是模型的实时性。1D-CNN+GRU 的时间复杂度随帧数线性增长,但双向 GRU 在流式场景下无法直接使用,因为双向需要完整序列。如果你要做实时语音情感识别,要么把模型改成单向 GRU,要么等待一个完整窗口再推理。我的做法是取 64 帧(约 0.6 秒)作为最短窗口,用 64 帧的前向结果先输出一个低置信度预测,等完整 128 帧再更新。这样在交互场景能兼顾响应速度和准确性。验证方法很简单:用一个不在训练里的说话人录音,分别用 0.6 秒和 1.3 秒窗口预测,对比情感标签的稳定性;如果 0.6 秒窗口翻来覆去变标签,说明窗口太短,至少要覆盖一个完整的短语。

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

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

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

立即咨询