简介:面向脑电情感识别方向的深度学习实践者,这套代码围绕DEAP数据集,用CNN与LSTM组合模型完成从脑电信号到情绪状态的分类任务,适合已具备一定深度学习基础、希望复现或改进EEG情感识别流程的学习者。压缩包共35个文件、约11.86MB,以15张png可视化图、6个ipynb实验笔记、3个py脚本及xml工程配置为主,另含训练好的h5与npy模型权重,覆盖数据预处理、特征提取、模型构建、训练评估到权重保存的完整链路。其中notebook记录了LSTM单模型与CNN1D结合LSTM、GRU、SimpleRNN的多组对比实验,png图则呈现带通滤波、频段分布、混淆矩阵与损失曲线等中间结果,便于对照理解每一步的数据形态与模型表现。目前已有2522人学习,可作为脑电情感识别课题的参考实现与排错对照。
1. 脑电信号识别:为什么 CNN 和 LSTM 要拼在一起用
脑电信号识别的难点从来不在"分类器选哪个",而在于信号本身的性质:它是多通道的、毫秒级的、信噪比极低的时序数据。DEAP 数据集把这件事摆到了台面上——32 名被试、每人 40 段一分钟的脑电记录,每段配 8 个通道的生理信号和主观情绪评分。你要做的不是"识别脑电",而是从一堆被眼电、肌电、工频干扰反复污染的时间序列里,抠出与情绪维度相关的稳定模式。
单用 CNN 会怎样?卷积核在时间轴上滑动,能抓到局部波形形态,比如某个 200ms 窗口内的节律突变,但它对"这个突变出现在整段信号的第几秒、前后文是什么"没有记忆。单用 LSTM 会怎样?它能建模长程依赖,但把 8 通道 × 数千采样点直接喂进去,参数量爆炸,而且原始波形里的高频噪声会淹没梯度。所以常见的做法是:CNN 先做局部特征提取和降维,把每个时间片压成一个特征向量,LSTM 再在这些向量序列上建模时间演化。这个"先局部后全局"的拼接不是拍脑袋,而是由脑电信号的物理特性决定的——局部形态有判别力,时间演化有判别力,两者缺一不可。
这份 DEAP 脑电信号识别 CNN-LSTM 代码资源,价值就在于它把这条链路完整落地了:从数据加载、通道选择、滑窗切分,到 CNN 前端、LSTM 后端、训练循环和评估。搞懂它,你拿到的不只是一份能跑的脚本,而是"为什么这样切、为什么这样拼、哪里最容易翻车"的完整认知。
2. 信号到张量:DEAP 数据为什么不能直接喂进网络
2.1 原始数据结构与通道选择的取舍
DEAP 每段记录是一个 40 × 8064 的矩阵(40 通道 × 60 秒 × 128Hz 采样率),其中前 32 通道是脑电,后面是眼电、肌电等外周信号。直接全通道输入的问题在于:外周信号与情绪标签的相关性不稳定,而且不同被试的电极阻抗差异会让某些通道整体漂移。常见的实现思路是只取脑电通道的一个子集,比如额区、颞区、顶区各选几个,既保留空间分布信息,又把输入维度压到可控范围。
代码里通常会有类似这样的通道筛选逻辑:
# 只保留脑电通道,去掉眼电/肌电等外周信号 EEG_CHANNELS = list(range(32)) # DEAP 前 32 通道为脑电 # 进一步按区域筛选,降低维度同时保留空间信息 SELECTED = [1, 2, 3, 4, 6, 11, 13, 17] # 额/颞/顶区代表通道 def load_subject(data_path): raw = np.load(data_path) # 形状 (40, 40, 8064) eeg = raw[:, SELECTED, :] # 取选定通道 -> (40, 8, 8064) labels = raw[:, -1, :] # 最后一维存标签的约定需按实际文件核对 return eeg, labels这里的关键不是选哪 8 个通道,而是理解"通道选择本身就是一种正则化"。通道越少,模型越不容易记住被试特有的伪迹模式,跨被试泛化反而可能更好。参数上,SELECTED的改动会直接改变后续 CNN 输入通道数,改完必须同步改网络第一层的in_channels,这是最常见的维度不匹配来源。
2.2 滑窗切分:为什么 60 秒要切成小段
一整段 60 秒信号直接送进 LSTM,序列长度是 8064,梯度回传路径太长,训练几乎不可行。更实际的做法是滑窗切分:用固定长度(比如 512 采样点,约 4 秒)的窗口,按步长(比如 256)滑动,每段继承整段的标签。这样一条记录变成几十个样本,既扩充了数据量,又让 LSTM 的序列长度落在可训练范围内。
def sliding_window(eeg, window=512, step=256): # eeg: (trials, channels, samples) segments = [] for start in range(0, eeg.shape[-1] - window + 1, step): seg = eeg[:, :, start:start + window] segments.append(seg) return np.stack(segments, axis=1) # (trials, n_seg, channels, window)窗口长度是个需要权衡的参数:太短(如 128 点,1 秒)抓不到低频节律的完整周期;太长(如 1024 点,8 秒)则一段里可能混入多种情绪状态,标签噪声变大。步长决定样本重叠度,步长小于窗口能起到数据增强作用,但重叠过高会让训练集和验证集泄漏——如果按样本随机划分而不是按 trial 划分,同一段信号的不同窗口会同时出现在两边,验证指标虚高。这是血泪经验里排第一的坑。
2.3 归一化:逐 trial 还是逐通道
脑电幅值在不同被试间能差一个数量级,逐 trial 做 z-score 是常见选择:对每个 trial 的每个通道单独减均值除标准差。这样做的副作用是会抹掉绝对幅值信息,但对情绪分类这种更依赖波形形态而非绝对能量的任务,利大于弊。要注意的是归一化必须只在训练集上拟合统计量,验证集用训练集的均值和方差,否则又是一处隐蔽的泄漏。
3. CNN 前端:卷积核在脑电波形上到底抓什么
3.1 一维卷积的物理含义
脑电是时间序列,所以前端用一维卷积而不是二维。一个长度为 25、步长为 1 的卷积核,在 128Hz 采样下覆盖约 200ms 的波形,正好对应一个短促的节律片段。多个这样的核并行,相当于一组可学习的带通滤波器——浅层学到的往往是特定频段的振荡形态,深层则组合出更抽象的局部模式。这正是 CNN 适合做前端的根本原因:它把"手工设计滤波器组"这件事变成了端到端学习。
class CNNFrontend(nn.Module): def __init__(self, in_ch=8, feat_dim=64): super().__init__() self.conv1 = nn.Conv1d(in_ch, 16, kernel_size=25, padding=12) self.bn1 = nn.BatchNorm1d(16) self.conv2 = nn.Conv1d(16, 32, kernel_size=15, padding=7) self.bn2 = nn.BatchNorm1d(32) self.pool = nn.MaxPool1d(4) self.proj = nn.Linear(32, feat_dim) # 每时间片压成 feat_dim 维 def forward(self, x): # x: (batch, channels, window) x = F.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = F.relu(self.bn2(self.conv2(x))) x = self.pool(x) # 时间维进一步下采样 x = x.transpose(1, 2) # (batch, time, 32) return self.proj(x) # (batch, time, feat_dim)padding设为核长的一半是保持时间维不变的常用手法,两次MaxPool1d(4)把 512 点压到 32 个时间片,正好给 LSTM 一个长度适中、每片信息密度高的序列。BatchNorm1d在这里不只是加速收敛,它对通道间的幅值差异有稳定作用,相当于在特征层面再做一次归一化。
3.2 池化位置与感受野的连锁反应
池化放在卷积之后还是之前,会改变感受野的增长方式。上面这种"卷积-池化-卷积-池化"的堆叠,第二层卷积核虽然只有 15,但作用在已下采样的特征上,等效感受野覆盖了更长的原始时间跨度。如果池化过多,时间分辨率损失严重,LSTM 拿到的序列太短,长程建模能力发挥不出来;池化过少,序列太长,训练慢且容易过拟合。32 个时间片是个经验上比较平衡的值,改动窗口长度时要按比例调整池化次数。
3.3 特征投影层的作用
self.proj这一层容易被忽略,但它承担了"把卷积特征对齐到 LSTM 输入维度"的职责。没有它,LSTM 的input_size就得等于卷积输出通道数,灵活性差。有了这层线性投影,你可以独立调节 CNN 宽度和 LSTM 隐层大小,做消融实验时不用牵一发动全身。代价是多了少量参数,在数据量不大的 DEAP 上需要配合 dropout 控制过拟合。
4. LSTM 后端:时间片序列上的记忆与遗忘
4.1 为什么用 LSTM 而不是普通 RNN
普通 RNN 在长序列上梯度会指数衰减,32 个时间片虽然不算极长,但脑电特征之间的依赖往往跨越数秒,梯度消失会让模型只关注最近几个时间片。LSTM 的门控机制——输入门、遗忘门、输出门——本质上是在每个时间步决定"记住多少旧状态、写入多少新信息",这让它能在整段窗口内维持一个相对稳定的记忆向量。对于情绪这种缓慢变化的标签,这种记忆能力是必要的。
class LSTMBranch(nn.Module): def __init__(self, feat_dim=64, hidden=64, layers=1, num_classes=2): super().__init__() self.lstm = nn.LSTM(feat_dim, hidden, num_layers=layers, batch_first=True, bidirectional=False) self.drop = nn.Dropout(0.3) self.fc = nn.Linear(hidden, num_classes) def forward(self, x): # x: (batch, time, feat_dim) out, (h_n, c_n) = self.lstm(x) last = out[:, -1, :] # 取最后时间片的隐状态 return self.fc(self.drop(last))取out[:, -1, :]而不是对时间维做平均,是因为最后时间片的隐状态已经聚合了前面所有时间片的信息(通过门控传递),平均反而会稀释判别性强的片段。num_layers=1在 DEAP 这种规模上是合理起点,堆到 2 层以上很容易过拟合,除非配合较强的正则。
4.2 双向 LSTM 的适用边界
bidirectional=True能让每个时间片同时看到前后文,对离线分类任务通常有提升。但要注意:如果将来要做实时推理,双向结构不可用,因为它在时刻 t 需要未来时刻的输入。这份代码面向的是离线情绪识别,双向是可选增强项;做在线场景时记得切回单向,否则部署时会翻车。
4.3 隐层维度与过拟合的拉锯
hidden=64配合feat_dim=64,参数量在几万量级,对 DEAP 单被试几十到几百个样本来说已经偏大。常见的补救是 dropout 加在 LSTM 输出上(如上),以及早停。隐层再往上加,训练损失会继续降,验证损失却开始抬头,这是典型的过拟合信号。判断标准不是训练准确率,而是验证集上按 trial 划分的指标是否同步改善。
5. 训练链路避坑:那些让指标虚高或直接崩掉的地方
5.1 数据泄漏:按样本划分验证集的陷阱
现象:验证准确率轻松到 90% 以上,换一批被试就掉到 50% 出头。原因:滑窗产生的样本高度重叠,随机划分让同一 trial 的不同窗口同时进入训练和验证,模型实际上在"背"这段信号。解决:按 trial 或按被试划分,训练集和验证集在原始记录层面就不相交。跨被试评估时更严格,留一被试交叉验证才是可信指标。
5.2 标签处理:二分类阈值与类别不平衡
现象:模型总预测多数类,准确率看着还行但 F1 很低。原因:DEAP 的 valence/arousal 评分以 5 为界做二分类时,两类样本数可能不均衡,且边界附近的样本标签本身模糊。解决:明确阈值定义,报告 F1 或 AUC 而不只看准确率;对边界样本(评分接近 5)可以选择剔除或做软标签,减少标签噪声。
5.3 梯度爆炸与序列长度
现象:训练几个 batch 后 loss 变 NaN。原因:LSTM 在较长序列上遇到大幅值输入时梯度容易爆炸,归一化不彻底或某个通道异常值都会触发。解决:确认逐 trial 归一化已生效,加torch.nn.utils.clip_grad_norm_裁剪梯度,学习率从 1e-3 往下调。排查时打印每个 batch 的梯度范数,能快速定位是哪一层先炸。
5.4 维度顺序错位
现象:RuntimeError: Expected input of shape (batch, channels, length)或 LSTM 报输入维度不符。原因:CNN 要(batch, channels, time),LSTM 要(batch, time, feat),中间必须转置,滑窗切分后的张量轴顺序也容易搞混。解决:在每个模块入口打印x.shape,把形状约定写成注释固定在类定义上方,改任何一层都先核对这条约定。
5.5 随机种子与结果不可复现
现象:同一份代码跑两次,准确率差好几个点。原因:数据切分、权重初始化、dropout 都带随机性,没固定种子。解决:在训练脚本开头统一设torch.manual_seed、np.random.seed,并记录切分索引。做对比实验时,固定切分比固定初始化更重要,否则不同模型根本不在同一批数据上比较。
6. 设计取舍:CNN-LSTM 不是万能钥匙,什么时候该换思路
把 CNN 和 LSTM 串起来,本质是在"局部形态"和"时间演化"之间做分工。这个结构在 DEAP 上能 work,是因为情绪状态在数秒尺度上确实有可辨识的波形模式,且窗口内的演化有信息量。但它有三个明确的边界,值得在设计阶段就想清楚。
第一,数据量。DEAP 单被试样本有限,CNN-LSTM 的参数量相对偏大,跨被试泛化往往不理想。如果目标是跨被试,更稳的路线是先用 CNN 做特征提取、把特征送进传统分类器(SVM、随机森林),或者引入领域自适应,而不是硬堆端到端深度模型。第二,实时性。双向 LSTM 和较长的滑窗都偏向离线场景,在线推理要重新设计窗口策略和单向结构。第三,可解释性。卷积核学到的滤波器可以和频段对应,但 LSTM 的记忆向量很难解释,做脑机接口应用时,特征重要性分析往往比端到端精度更被看重。
| 方案 | 优势 | 代价 | 适用场景 |
|---|---|---|---|
| CNN-LSTM 端到端 | 自动学时空特征,精度上限高 | 参数多,跨被试易过拟合 | 单被试、离线分类 |
| CNN + 传统分类器 | 泛化稳,可解释 | 时间建模能力弱 | 跨被试、小样本 |
| 纯 LSTM | 长程建模直接 | 原始波形噪声敏感 | 特征已预处理的场景 |
验证一个改动是否真的有效,我习惯用固定切分加多次种子取均值,而不是看单次跑出来的最高分。消融时一次只动一个变量:先固定 CNN 结构调 LSTM 隐层,再反过来,否则指标变化归因不清。从那以后我每次改网络结构,都强制先跑一遍基线并记录切分索引,确认对比是在同一批数据上做的,才敢下结论。希望这份代码和上面的拆解,能帮你在自己的脑电项目里少走几段弯路。
本文还有配套的精品资源,点击获取