基于CNN+RNN混合网络的脑电情绪识别实战:SEED/DEAP/SEED-IV数据集与PyTorch实现 简介这份资源面向脑电情绪识别方向的研究者与深度学习实践者提供一套将RNN与CNN结合的完整论文与源码实现覆盖SEED、SEED-IV、DEAP、MPED四个公开数据集。核心思路是双模型框架一路用层次RNN建模脑电通道间的空间关系另一路用CNN提取图像化特征向量并借助显著性分析实现两部分模型的联合学习适合具备一定深度学习基础、希望复现或改进脑电情绪分类方案的中高级学习者。压缩包共21个文件约9.45MB包含7个Python脚本用于模型构建与训练、8个npy数据文件保存电极位置与特征矩阵、1份PDF论文、1份README说明及环境配置yml与依赖清单结构清晰便于按模块查阅。目前已有2691人学习下载读者可据此获得可运行的论文配套代码、双模型联合学习的具体实现路径以及多数据集上的实验配置参考为复现与二次开发提供直接支撑。1. 脑电情绪识别的混合网络为什么 CNN 和 RNN 要一起用做脑电情绪识别的人大概率都经历过这样的场景单用 CNN 跑 DEAP 数据集准确率卡在 85% 上下死活上不去换成 LSTM 单独跑训练慢不说效果还更差。问题出在哪脑电信号本质上是空间-时间双维度的数据——电极分布在头皮不同位置空间信号随时间演化时间。CNN 擅长提取空间特征RNN 擅长捕捉时间依赖单独用任何一个都是在砍掉一半信息。这个方向要解决的核心问题很明确给定一段多通道脑电信号判断被试当前的情绪状态效价/唤醒度二分类或三分类。SEED、DEAP、SEED-IV 是三个最常用的公开数据集前两个偏离散情绪标签SEED-IV 则是四类情绪。适合谁做做情感计算、脑机接口、人机交互方向的研究生和工程师尤其是需要快速复现 baseline 再改进的人。我自己的经验是混合模型不是简单拼接就完事拼接方式、特征对齐、时序窗口的选择每一个都会让结果差出 5 个点以上。下面把从数据预处理到模型训练再到调参的完整路径拆开讲。2. 三个数据集怎么选、怎么读、怎么对齐2.1 SEED / DEAP / SEED-IV 的差异与选型逻辑很多人拿到这三个数据集不知道从哪个下手。直接说结论做方法验证用 SEED做跨被试泛化用 DEAP做多分类挑战用 SEED-IV。维度SEEDDEAPSEED-IV通道数623262采样率200Hz128Hz200Hz被试数153215情绪类别3积极/中性/消极2高效价/低效价4喜/悲/恐/中每被试试验数154024标签粒度片段级被试级片段级选型的关键在于你的研究问题如果只是验证一个新的特征提取模块有没有用SEED 的 62 通道和清晰的三分类标签最省事如果论文要强调跨被试迁移能力DEAP 的 32 被试量更有说服力SEED-IV 的四分类难度大但做出来故事更完整。2.2 用 Python 加载三种数据格式的最小代码三个数据集的原始格式各不相同SEED 是.mat文件DEAP 是 pickle 序列化的.datSEED-IV 也是.mat但目录结构不同。下面是我常用的统一加载脚本import scipy.io as sio import pickle import numpy as np import os def load_seed(data_dir, subject_id): 加载 SEED 数据集单个被试的 EEG 数据 data_dir: 数据根目录 subject_id: 被试编号 1-15 返回: eeg_data shape(15, 62, 200*T), labels shape(15,) # SEED 每个被试一个 .mat 文件key 为 X 和 y filepath os.path.join(data_dir, f{subject_id}.mat) mat sio.loadmat(filepath) eeg mat[X] # shape: (15, 62, 采样点) labels mat[y].flatten() - 1 # 原始标签 1/2/3 转为 0/1/2 return eeg, labels def load_deap(data_dir, subject_id): 加载 DEAP 数据集单个被试 DEAP 是 pickle 格式每个被试一个 .dat 返回: eeg shape(40, 32, 8064), labels shape(40,) filepath os.path.join(data_dir, fs{subject_id:02d}.dat) with open(filepath, rb) as f: data pickle.load(f, encodinglatin1) # data[data] shape: (40, 40, 8064)前32通道是EEG eeg data[data][:, :32, :] # data[labels] shape: (40, 4)取 valence 维度做二分类 valence data[labels][:, 0] labels (valence 5).astype(int) # 阈值5分界 return eeg, labels def load_seed_iv(data_dir, subject_id, session_id): 加载 SEED-IV 数据集 结构: data_dir/subject_id/session_id.mat 返回: eeg shape(24, 62, 采样点), labels shape(24,) filepath os.path.join(data_dir, str(subject_id), f{session_id}.mat) mat sio.loadmat(filepath) eeg mat[X] labels mat[y].flatten() - 1 return eeg, labels逻辑说明SEED 和 SEED-IV 的.mat文件里 key 名称可能因版本不同有差异加载前先用mat.keys()确认一下。DEAP 的 pickle 加载必须加encodinglatin1否则 Python 3 下会报编码错误——这是血泪经验我第一次跑的时候卡了半天。参数说明DEAP 的标签是 1-9 连续值做二分类时阈值取 5 是常见做法也有取中位数做被试内标准化的后者更严谨但代码多几行。SEED 系列的标签已经是离散的减 1 是为了适配 PyTorch 的CrossEntropyLoss要求标签从 0 开始。2.3 预处理流水线滤波、分段、归一化原始信号不能直接喂网络。标准流程是带通滤波1-50Hz 去掉工频和基线漂移→ 分段SEED 用 1s 无重叠DEAP 用 1s 或 2s 滑窗→ 归一化逐通道 z-score。from scipy.signal import butter, filtfilt def bandpass_filter(data, lowcut1.0, highcut50.0, fs200, order4): 巴特沃斯带通滤波 data: (n_trials, n_channels, n_samples) fs: 采样率SEED为200DEAP为128 nyq fs / 2.0 b, a butter(order, [lowcut/nyq, highcut/nyq], btypeband) # 沿时间轴滤波 filtered filtfilt(b, a, data, axis-1) return filtered def normalize_per_channel(data): 逐通道 z-score 归一化 每个 trial 的每个通道单独减均值除标准差 mean data.mean(axis-1, keepdimsTrue) std data.std(axis-1, keepdimsTrue) 1e-8 return (data - mean) / std def segment_signal(data, window_size, step_size): 滑动窗口分段 data: (n_trials, n_channels, n_samples) window_size: 窗口长度采样点数 step_size: 步长 返回: (n_segments, n_channels, window_size) segments [] n_samples data.shape[-1] for start in range(0, n_samples - window_size 1, step_size): seg data[:, :, start:startwindow_size] segments.append(seg) return np.concatenate(segments, axis0)这里有个容易翻车的点归一化必须在分段之后做还是之前做我的做法是分段后逐段归一化。原因是如果先对整段信号归一化再切窗每个窗口的统计特性会被全局统计“污染”模型学到的分布和推理时不一致。逐段归一化虽然计算量大一点但训练和推理的行为一致。提示DEAP 的采样率是 128Hz滤波的 highcut 不要超过 60Hz否则接近奈奎斯特频率会导致滤波器不稳定。3. CNNRNN 混合模型的搭建从特征提取到时序建模3.1 为什么是 CNN 在前、RNN 在后脑电信号的维度是通道×时间。CNN 沿通道维度做卷积等价于学习不同电极组合的空间模式——比如额叶和顶叶的协同活动。RNN 沿时间维度展开捕捉情绪状态随时间的演化。顺序不能反。如果先 RNN 再 CNN等于先对每个通道独立做时序建模再混合通道——这丢掉了通道间的空间结构信息。常见做法是CNN 做空间滤波 → 池化降维 → RNN 做时序聚合 → 全连接分类。3.2 PyTorch 实现一个可跑的混合网络import torch import torch.nn as nn class EEGCNNRNN(nn.Module): def __init__(self, n_channels62, n_timepoints200, n_classes3, cnn_out64, rnn_hidden128, rnn_layers2, dropout0.5): super().__init__() # CNN 模块沿时间维度做一维卷积提取局部时序-空间模式 self.cnn nn.Sequential( # 第一层通道数从 n_channels 映射到 cnn_out nn.Conv1d(n_channels, cnn_out, kernel_size5, padding2), nn.BatchNorm1d(cnn_out), nn.ReLU(), nn.MaxPool1d(2), # 时间维度减半 # 第二层加深特征 nn.Conv1d(cnn_out, cnn_out*2, kernel_size3, padding1), nn.BatchNorm1d(cnn_out*2), nn.ReLU(), nn.MaxPool1d(2), # 再减半 nn.Dropout(dropout) ) # RNN 模块输入维度为 CNN 输出通道数 self.rnn nn.LSTM( input_sizecnn_out*2, hidden_sizernn_hidden, num_layersrnn_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if rnn_layers 1 else 0 ) # 分类头 self.classifier nn.Sequential( nn.Linear(rnn_hidden*2, 64), # 双向所以乘2 nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, n_classes) ) def forward(self, x): # x shape: (batch, n_channels, n_timepoints) x self.cnn(x) # (batch, cnn_out*2, T) x x.permute(0, 2, 1) # 转为 (batch, T, features) 适配 LSTM x, _ self.rnn(x) # (batch, T, rnn_hidden*2) x x[:, -1, :] # 取最后时间步 x self.classifier(x) return x逻辑说明CNN 部分用Conv1d而不是Conv2d因为脑电是通道×时间的一维序列沿时间轴卷积等价于提取每个通道的局部波形特征同时通过通道间的权重共享实现空间滤波。池化层把时间维度压缩减少 RNN 的序列长度降低计算量。参数说明cnn_out64是起点SEED 的 62 通道用 64 比较自然rnn_hidden128配合双向 LSTM实际输出维度是 256dropout0.5在脑电小数据集上是常规操作太大欠拟合、太小过拟合。kernel_size5对应 200Hz 下 25ms 的窗口覆盖一个完整的 ERP 成分。3.3 训练循环与关键超参设置from torch.utils.data import DataLoader, TensorDataset from sklearn.model_selection import train_test_split def train_model(eeg_data, labels, n_classes3, epochs100, lr1e-3, batch_size32): 训练 CNNRNN 混合模型 eeg_data: (n_samples, n_channels, n_timepoints) labels: (n_samples,) device torch.device(cuda if torch.cuda.is_available() else cpu) # 划分训练/验证集stratify 保证类别比例一致 X_train, X_val, y_train, y_val train_test_split( eeg_data, labels, test_size0.2, stratifylabels, random_state42 ) train_ds TensorDataset(torch.FloatTensor(X_train), torch.LongTensor(y_train)) val_ds TensorDataset(torch.FloatTensor(X_val), torch.LongTensor(y_val)) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_ds, batch_sizebatch_size) model EEGCNNRNN(n_channelseeg_data.shape[1], n_timepointseeg_data.shape[2], n_classesn_classes).to(device) optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(epochs): model.train() for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) pred model(xb).argmax(dim1) correct (pred yb).sum().item() total yb.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) return best_acc逻辑说明weight_decay1e-4是 L2 正则配合 dropout 一起压制过拟合。CosineAnnealingLR让学习率从 1e-3 余弦下降到接近 0比固定学习率稳定。梯度裁剪的max_norm1.0对 LSTM 是必须的不裁剪的话 loss 经常在 10 个 epoch 后突然变成 NaN。参数说明batch_size32在 12GB 显存下跑 SEED 的 62 通道 200 时间点没问题如果显存不够降到 16。epochs100配合早停这里用 best_acc 保存最优通常 60-80 轮收敛。4. 避坑与排查那些让准确率掉 10 个点的细节4.1 标签泄漏分段后不能跨 trial 混洗现象验证集准确率 95%换一批数据测试掉到 60%。原因滑动窗口分段后同一个 trial 的相邻窗口高度相似。如果分段后直接train_test_split训练集和验证集里会有来自同一 trial 的窗口模型实际上在“背答案”。解决先按 trial 划分训练/验证集再对各自内部的 trial 做分段。代码上就是先 split 原始 trial 列表再分别调segment_signal。4.2 归一化方式不匹配导致推理翻车现象训练时 loss 正常下降部署推理时输出全是同一类。原因训练用了逐段 z-score推理时忘了做同样的归一化或者用了全局均值方差。解决把归一化参数均值和标准差保存下来推理时用训练集的统计量。更稳妥的做法是把归一化写进模型的前向传播里用nn.BatchNorm1d替代手工归一化。4.3 LSTM 序列过长导致梯度消失现象RNN 部分学不到东西梯度范数接近 0。原因SEED 一个 trial 有几千个采样点即使 CNN 池化两次序列长度仍有几百。LSTM 在超过 200 步后梯度衰减严重。解决要么加大 CNN 的池化倍数比如三次 MaxPool要么在 RNN 前再做一次降采样。我的习惯是控制 LSTM 输入序列长度在 50-100 之间。4.4 类别不平衡被忽略现象SEED-IV 四分类准确率虚高但混淆矩阵显示全预测成多数类。原因SEED-IV 的四类样本数不完全均衡CrossEntropyLoss默认等权重。解决给CrossEntropyLoss传weight参数按类别频率的倒数设置。或者用WeightedRandomSampler在 DataLoader 层面做重采样。4.5 跨被试实验直接混数据现象被试内准确率 90%跨被试掉到 55%。原因不同被试的脑电信号幅度、阻抗、电极位置都有差异直接把所有被试数据混在一起训练模型学到的是被试身份而不是情绪模式。解决做跨被试实验时用留一被试交叉验证LOSO并且在训练集内做被试级标准化。进阶做法是加域适应模块但那是另一个话题了。5. 把准确率再推 3 个点的几个实操技巧5.1 用注意力池化替代“取最后时间步”前面代码里 RNN 输出取的是x[:, -1, :]这是最粗暴的做法。改成注意力池化通常能涨 1-2 个点class AttentionPool(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn nn.Linear(hidden_dim, 1) def forward(self, rnn_output): # rnn_output: (batch, seq_len, hidden_dim) weights torch.softmax(self.attn(rnn_output), dim1) # (batch, seq_len, 1) return (rnn_output * weights).sum(dim1) # (batch, hidden_dim)逻辑很直白让模型自己学每个时间步的重要性权重而不是假设最后一步最重要。对于情绪识别中间段的信号往往比首尾更有判别力。5.2 差分熵特征作为 CNN 的额外输入通道原始时域信号之外把差分熵DE特征拼进去。DE 在脑电情绪识别里被验证过多次有效。做法是对每个窗口的五个频段delta/theta/alpha/beta/gamma分别算 DE得到n_channels × 5的特征图和原始信号一起送进 CNN。def compute_de_features(data, fs200): 计算差分熵特征 data: (n_segments, n_channels, n_timepoints) 返回: (n_segments, n_channels, 5) 对应五个频段 from scipy.signal import welch bands [(1,4), (4,8), (8,14), (14,31), (31,50)] features [] for seg in data: seg_feat [] for ch in range(seg.shape[0]): freqs, psd welch(seg[ch], fsfs, npersegmin(256, seg.shape[1])) ch_feat [] for low, high in bands: idx (freqs low) (freqs high) # 差分熵 0.5 * log(2*pi*e*sigma^2)用功率谱近似 de 0.5 * np.log(2 * np.pi * np.e * (psd[idx].mean() 1e-8)) ch_feat.append(de) seg_feat.append(ch_feat) features.append(seg_feat) return np.array(features)参数说明五个频段的划分是脑电研究的惯例delta 到 gamma 覆盖了情绪相关的主要频段。nperseg256对应 200Hz 下约 1.28s 的窗口频率分辨率约 0.78Hz够用。5.3 验证方法别只看准确率最后说一个我踩过的坑。早期做实验只看准确率论文投出去被审稿人问“混淆矩阵呢F1 呢kappa 系数呢”——单看准确率在类别不平衡时完全不可靠。建议的验证组合准确率 加权 F1 Cohens kappa 混淆矩阵。kappa 系数能排除随机猜测的影响在脑电这种信噪比低的任务上特别有说服力。SEED 三分类的 kappa 能到 0.7 以上就算不错了SEED-IV 四分类能到 0.5 以上就值得写。我现在的习惯是每跑完一组实验先把混淆矩阵画出来看一眼确认没有某一类被完全忽略再看 F1 和 kappa。这个习惯帮我省了很多次“以为效果好其实模型在偷懒”的后悔药。希望帮到你。本文还有配套的精品资源点击获取