资讯动态

RNN+CNN混合模型实战:脑电情绪识别跨被试泛化技巧

发布时间:2026/10/9 20:32:26 来源:尧图企业网站定制
简介这份资源面向脑电情绪识别方向的研究者与深度学习实践者提供RNN与CNN双模型融合的完整论文与配套源码。方案通过层次RNN建模脑电通道间的空间关系同时用CNN提取频带功率序列与图像化特征向量两种表示并借助显著性分析实现两部分模型的联合学习已在SEED、SEED-IV、DEAP、MPED等公开数据集上完成验证。压缩包共21个文件约9.45MB包含7个Python脚本用于模型构建与训练、8个npy数据文件保存电极位置与示例输入、1份PDF论文及README、环境配置yml与依赖清单等便于复现实验与二次开发。目前已有2691人学习下载。读者可据此掌握双分支网络的设计思路、特征图构造方式与多数据集评估流程并参考现成脚本快速搭建自己的脑电情绪识别实验环境。1. 脑电情绪识别为什么要把 RNN 和 CNN 拼在一起单用 CNN 做脑电情绪识别很多人第一次跑 SEED 数据集就能拿到 80% 以上的准确率于是觉得这事成了。但把同一份权重拿去跑 DEAP或者把被试换一批准确率经常掉到 60% 出头接近随机猜。问题不在 CNN 本身而在于脑电信号同时有两个维度的结构电极之间的空间关系以及时间上的动态演化。CNN 擅长抓空间模式比如不同脑区在某一情绪下的同步激活RNN 擅长抓时间依赖比如情绪唤起后几百毫秒内的波形变化。把两者串起来本质上是让模型先看清「哪个脑区在动」再判断「这个动作怎么随时间变化」。这篇笔记就围绕 SEED、DEAP、SEED-IV 三个常用数据集把 RNNCNN 混合模型的搭建、训练、调参和排错讲清楚适合已经跑过单模型、想进一步提升跨被试泛化的从业者。2. 三个数据集怎么选、怎么读、怎么对齐2.1 SEED、DEAP、SEED-IV 的差异与选型逻辑选数据集不是看哪个名字顺眼而是看你的任务定义和算力预算。SEED 是三类离散情绪积极、中性、消极62 通道采样率 200Hz被试内数据量适中适合验证模型结构是否有效。SEED-IV 是四类情绪增加了「恐惧」维度类别边界更模糊对时序建模能力要求更高。DEAP 是连续维度标注效价、唤醒度32 通道采样率 128Hz信号质量参差适合做回归或二分类但跨被试方差大容易让模型学成「被试分类器」而不是「情绪分类器」。我一般建议先用 SEED 把 RNNCNN 的 pipeline 跑通确认准确率能稳定超过单 CNN 基线 3 到 5 个百分点再换 SEED-IV 看模型在细粒度分类上的鲁棒性最后用 DEAP 做跨库验证这时候重点看的是泛化差距而不是绝对准确率。2.2 用 Python 读取 SEED 与 DEAP 的最小代码SEED 的原始格式是.mat文件每个被试一个文件内部包含X样本×通道×时间点和y标签。DEAP 是.dat文件用pickle加载后取data和labels。下面这段代码把两者统一成(batch, channels, time)的 numpy 数组方便后续送进模型。import numpy as np import scipy.io as sio import pickle def load_seed(path): 加载 SEED 单个被试 .mat 文件 mat sio.loadmat(path) X mat[X] # shape: (n_samples, 62, 200) y mat[y].squeeze() # shape: (n_samples,) # 标签从 1/0/-1 映射到 0/1/2 y y 1 return X.astype(np.float32), y.astype(np.int64) def load_deap(path): 加载 DEAP 单个被试 .dat 文件 with open(path, rb) as f: subject pickle.load(f, encodinglatin1) X subject[data] # shape: (n_trials, 40, 8064) y subject[labels] # shape: (n_trials, 4) # 只取前 32 个脑电通道降采样到 128Hz 对齐 X X[:, :32, :] # 效价大于 5 记为正类否则负类 y_bin (y[:, 0] 5).astype(np.int64) return X.astype(np.float32), y_bin逻辑说明SEED 的标签原本是-1/0/1加 1 后变成0/1/2直接适配 PyTorch 的CrossEntropyLoss。DEAP 的data维度是 40 通道前 32 个是脑电后面是外周信号必须切掉。降采样这一步在加载时不做放到后续统一处理避免不同数据集采样率混用。参数说明sio.loadmat默认会加载所有变量如果文件里有无关变量可以用variable_names指定只读X和y减少内存占用。DEAP 的pickle加载必须加encodinglatin1否则在 Python 3 下会报UnicodeDecodeError这是血泪经验。2.3 滑动窗口与标签对齐的四个参数脑电信号不能整段送进模型必须切窗。窗口长度、重叠率、基线去除、归一化方式这四个参数直接决定模型能不能学到东西。参数SEED 常用值DEAP 常用值说明窗口长度1s200 点2s256 点太短抓不到情绪动态太长丢失时间分辨率重叠率50%50%增加样本量但过高会导致训练集泄漏基线去除每 trial 前 3s 均值每 trial 前 3s 均值消除个体差异和电极漂移归一化逐通道 z-score逐通道 z-score不能全局归一化否则通道间幅值差异被抹掉滑动窗口的代码实现如下def sliding_window(X, y, window, step): X: (n_trials, channels, time), 返回 (n_windows, channels, window) segments, labels [], [] for i in range(X.shape[0]): start 0 while start window X.shape[2]: seg X[i, :, start:startwindow] segments.append(seg) labels.append(y[i]) start step return np.stack(segments), np.array(labels)逻辑说明外层循环遍历 trial内层按step滑动。step window * (1 - overlap)50% 重叠就是step window // 2。注意标签对齐一个 trial 内所有窗口共享同一个情绪标签这是 SEED 和 DEAP 的通用做法但会引入标签噪声因为情绪在 trial 内可能变化。如果追求更细的粒度可以用连续效价标注做回归但那是另一个话题。参数说明window和step的单位是采样点不是秒。SEED 采样率 200Hz1s 窗口就是 200 点。DEAP 采样率 128Hz2s 窗口是 256 点。切完窗口后样本量会膨胀 10 到 20 倍训练前记得打乱顺序否则同一 trial 的窗口连续出现BatchNorm 统计量会偏。3. RNNCNN 混合模型的搭建与训练3.1 为什么用 CNN 提空间特征、RNN 提时序特征脑电的 62 个电极不是随机分布的它们在头皮上有固定的空间拓扑。CNN 的卷积核在电极维度上滑动等价于学习不同脑区的局部同步模式。但卷积核的感受野有限只能看到相邻几个电极全局空间关系需要堆叠多层或者用大卷积核。我一般用两层Conv2d第一层核大小(1, 5)抓时间方向上的局部变化第二层核大小(62, 1)抓全通道空间融合。这样设计的原因是先时间后空间避免在早期就把空间维度压掉丢失电极间的相位信息。RNN 部分用GRU而不是LSTM因为脑电样本量通常不大GRU 参数少不容易过拟合。把 CNN 输出的特征序列按时间步展开送进 GRU取最后一个时间步的隐状态作为情绪表征再接全连接分类。3.2 用 PyTorch 写一个可复现的混合模型下面这个模型在 SEED 上被试内分类能到 95% 左右跨被试用留一法大概 85%。结构不复杂关键是维度对齐和 dropout 的位置。import torch import torch.nn as nn class EEGCNNGRU(nn.Module): def __init__(self, n_channels62, n_classes3, hidden64): super().__init__() # 时间方向卷积核大小 (1, 5) self.conv1 nn.Conv2d(1, 16, (1, 5), padding(0, 2)) # 空间方向卷积核大小 (n_channels, 1) self.conv2 nn.Conv2d(16, 32, (n_channels, 1)) self.bn nn.BatchNorm2d(32) self.dropout nn.Dropout(0.5) # GRU 输入维度 32时间步为窗口长度 self.gru nn.GRU(32, hidden, batch_firstTrue) self.fc nn.Linear(hidden, n_classes) def forward(self, x): # x: (batch, 1, channels, time) x torch.relu(self.conv1(x)) # (batch, 16, channels, time) x torch.relu(self.conv2(x)) # (batch, 32, 1, time) x self.bn(x) x x.squeeze(2) # (batch, 32, time) x x.permute(0, 2, 1) # (batch, time, 32) x, _ self.gru(x) # (batch, time, hidden) x x[:, -1, :] # 取最后时间步 x self.dropout(x) return self.fc(x)逻辑说明输入必须手动扩一维成(batch, 1, channels, time)因为Conv2d要求 4D 输入。conv1的padding(0, 2)保证时间维度不变conv2的核大小等于通道数输出空间维度变成 1直接squeeze掉。permute把通道维换到最后适配 GRU 的batch_firstTrue。取最后时间步而不是平均池化是因为情绪标签对应整个窗口最后时间步的隐状态已经聚合了前面的信息。参数说明hidden64是经验值再大容易过拟合再小欠拟合。dropout0.5放在 GRU 之后、全连接之前不要放在卷积层后面否则会破坏空间特征。BatchNorm2d的动量默认 0.1如果 batch size 小于 16建议调到 0.01否则统计量波动大。3.3 训练循环与早停策略训练时用Adam学习率 1e-3权重衰减 1e-4。早停的 patience 设 10 个 epoch监控验证集损失而不是准确率因为准确率在类别不平衡时会虚高。def train(model, train_loader, val_loader, epochs100, patience10): optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) criterion nn.CrossEntropyLoss() best_loss, wait float(inf), 0 for epoch in range(epochs): model.train() for x, y in train_loader: optimizer.zero_grad() loss criterion(model(x), y) loss.backward() optimizer.step() model.eval() val_loss 0 with torch.no_grad(): for x, y in val_loader: val_loss criterion(model(x), y).item() if val_loss best_loss: best_loss, wait val_loss, 0 torch.save(model.state_dict(), best.pth) else: wait 1 if wait patience: break逻辑说明每个 epoch 结束后在验证集上算总损失保存最优权重。早停触发后直接跳出循环不继续训练。注意model.eval()和torch.no_grad()必须同时用否则 BatchNorm 会更新统计量Dropout 会随机丢弃导致验证结果不稳定。参数说明patience10是针对 SEED 这种小数据集的经验值DEAP 噪声大可以放宽到 15。学习率如果 loss 震荡降到 5e-4如果收敛太慢升到 2e-3但不要超过 5e-3否则 GRU 容易梯度爆炸。4. 避坑与常见问题排查4.1 准确率虚高但跨被试崩盘现象被试内 10 折交叉验证准确率 95%留一被试交叉验证掉到 60%。原因模型学到了被试特有的伪迹模式比如某个被试的眨眼频率、电极阻抗差异而不是情绪本身。解决在训练时加入被试间归一化比如用Euclidean Alignment对齐协方差矩阵或者用Domain Adversarial训练让特征对被试不可分。我一般先做欧氏对齐成本低通常能拉回 10 到 15 个百分点。4.2 损失不下降或变成 NaN现象训练几个 batch 后 loss 变成 NaN。原因脑电信号幅值差异大某些通道的梯度爆炸。解决在送进模型前做逐通道 z-score并且把输入裁剪到[-5, 5]。另外检查 GRU 的hidden是否太大超过 128 时梯度爆炸概率显著上升。如果已经 NaN把学习率降到 1e-4 重新跑不要试图从 NaN 恢复。4.3 验证集准确率波动超过 10%现象同一个模型跑三次验证准确率分别是 82%、75%、88%。原因小数据集下随机种子影响太大尤其是被试划分方式不同。解决固定随机种子并且用StratifiedKFold按被试分层而不是按样本分层。如果还是波动把 batch size 调到 32 以上减少 BatchNorm 统计量的噪声。4.4 训练集准确率远高于验证集现象训练集 99%验证集 70%。原因模型参数量相对于样本量太大或者 dropout 没生效。解决先看model.train()和model.eval()是否切换正确再看 dropout 是否放在全连接之前。如果都没问题减少 GRU 的层数从 2 层降到 1 层或者把hidden从 128 降到 64。数据增强也可以加比如在时间维度上随机裁剪、加高斯噪声但噪声标准差不要超过 0.1否则会破坏情绪相关成分。4.5 用错标签映射导致类别混淆现象SEED 三分类准确率只有 50%但二分类正常。原因SEED 的标签是-1/0/1如果直接送进CrossEntropyLoss负标签会被当成忽略索引导致模型只学两类。解决加载时统一加 1变成0/1/2。DEAP 的效价标签是 1 到 9 的连续值二分类时阈值选 5但要注意 5 附近的样本噪声大可以丢弃 4.5 到 5.5 之间的样本能提升 2 到 3 个百分点。5. 把跨被试泛化再推一步的具体技巧如果你已经把上面的 pipeline 跑通SEED 被试内到 95%、跨被试到 85%接下来最值得投入的方向是域适应。我试过几种方案按性价比排序欧氏对齐最便宜几行代码就能做然后是CORAL损失在特征层加协方差对齐最后是DANN用对抗训练让被试分类器失效。欧氏对齐的做法是对每个被试的每个 trial计算协方差矩阵的均值然后白化再重新着色到单位矩阵。代码不复杂但效果稳定。def euclidean_align(X): X: (n_trials, channels, time) X_aligned np.zeros_like(X) for i in range(X.shape[0]): cov np.cov(X[i]) # 白化 eigvals, eigvecs np.linalg.eigh(cov) whiten eigvecs np.diag(1.0 / np.sqrt(eigvals 1e-6)) eigvecs.T X_aligned[i] whiten X[i] return X_aligned逻辑说明对每个 trial 单独做白化消除被试间的协方差差异。eigvals加 1e-6 是防止除零。白化后所有 trial 的协方差矩阵变成单位矩阵被试间的二阶统计量被对齐。注意这一步要在滑动窗口之前做否则窗口内的协方差估计不准。参数说明np.cov默认按行算协方差输入是(channels, time)输出(channels, channels)。如果通道数大于时间点协方差矩阵秩亏eigh会报错这时候要先降采样或者用np.linalg.pinv求伪逆。我一般把窗口长度设成至少 2 倍通道数SEED 是 62 通道窗口至少 124 点1s 窗口 200 点刚好够。另一个技巧是标签平滑把硬标签[0, 0, 1]变成[0.05, 0.05, 0.9]在CrossEntropyLoss里设label_smoothing0.1。这个对 SEED-IV 这种类别边界模糊的数据集特别有用能提升 1 到 2 个百分点而且几乎不增加计算量。但注意标签平滑会降低模型置信度如果后续要做阈值决策需要重新校准。最后一个习惯每次改模型结构或参数只改一个变量跑三次取平均。脑电数据方差大单次结果没有参考价值。我见过太多人改了一堆参数结果好了不知道哪个起作用坏了也不知道哪个背锅。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑