DEAP脑电情绪识别实战:CNN-LSTM模型构建与避坑指南
简介面向脑电信号情感识别研究者的深度学习实践资源基于DEAP数据集实现卷积神经网络与长短期记忆网络混合模型完成情绪状态分类任务。压缩包共35个文件大小约11.86MB涵盖Jupyter Notebook源码、Python脚本、训练好的模型权重h5/npy及预处理可视化图像等覆盖数据清洗、带通滤波、特征提取、模型构建、评估与结果可视化的完整流程。内容中既有LSTM多版本训练记录也提供一维卷积神经网络结合LSTM、GRU、SimpleRNN的对比实验实现同时保留模型权重文件可直接加载用于预测或继续训练。已有2521人学习下载适合具备一定Python与深度学习基础、希望快速上手脑电情感识别项目的学生或研究人员参考复用。压缩包内还附带了频域分布、混淆矩阵、损失曲线等图表更便于直观理解模型效果是快速复现情感分析实验的高效起点。1. DEAP脑电信号识别为什么绕不开CNN-LSTMDEAP 数据集是情绪识别绕不开的公开基准40 名被试观看音乐视频时的 32 通道脑电加 8 路外周信号配上效价、唤醒度评分几乎所有做脑电情绪识别的人都会在它上面跑一遍。CNN-LSTM 则是 DEAP 上闭眼选都不会太差的组合——CNN 提空间特征LSTM 抓时间依赖拼接后效果普遍好过单独用其中任何一个。这次要解决的是具体问题给你一套能直接改、能复现的 DEAPCNN-LSTM 代码思路从 .mat 读取、去基线分段、模型维度对齐到训练时让准确率忽高忽低的坑一次讲透。适合刚入门脑电分类的新手也适合被跨被试实验和数据泄露折磨过的熟练工。2. DEAP数据预处理从.mat原始文件到模型输入张量2.1 DEAP数据集结构与读取方式DEAP 官方发布的是 .mat 和 .bdf 两种格式绝大多数人用 .mat 版本省去 BDF 解析和重参考的麻烦。每个被试对应一个 .mat 文件里面有两个关键变量data 和 labels。data 的形状是 40×40×8064含义是 40 段试次 × 40 个通道 × 8064 个采样点。采样率 128Hz每段试次前 3 秒是基线——被试只看黑屏什么都不做后面 60 秒是看视频时的脑电活动。labels 的形状是 40×4对应每段试次的 valence、arousal、dominance、liking 四个评分取值 1 到 9。读取用 scipy.io 就能搞定不依赖 MATLAB 环境import scipy.io as sio import numpy as np # 读取单个被试数据 mat sio.loadmat(s01.mat) # 假设是第1个被试 data mat[data] # (40, 40, 8064) labels mat[labels] # (40, 4) print(data.shape, labels.shape) print(mat.keys()) # 先看一眼变量名再取这里有个容易踩的坑网上流传的 .mat 文件不一定都是标准命名有些镜像站或转换脚本会把 data 存成 X、labels 存成 Y。读之前先 keys() 确认别硬编码变量名。另外 data 的 dtype 是 float64之后转 PyTorch 张量时统一转 float32能省一半显存训练速度也更快。2.2 预处理核心步骤去基线、分段、归一化拿到原始 data 后不能直接塞模型按顺序做四件事去基线、分段、通道选择、归一化。去基线用的就是每段试次前 3 秒的数据。128Hz 下 3 秒是 384 个点这段反映的是被试的静息态脑电水平把它从后续 60 秒里减掉能消掉大部分由个体差异引起的基线漂移。常见做法是对每个通道计算基线段的均值然后整段 8064 个点逐通道减去这个均值。分段是为了扩充样本量。DEAP 每个被试只有 40 段试次直接拿 40 个样本训 CNN-LSTM 必然过拟合。最常见做法是把 60 秒刺激段按固定窗口切成 1 秒或 2 秒的小段窗口间可设 50% 重叠进一步扩样。我一般用 1 秒窗口、无重叠因为 LSTM 需要足够的时序长度才能体现优势窗口太短时间依赖就丢了。def preprocess_trial(trial_data, fs128, baseline_sec3, win_sec1): trial_data: (40, 8064) 一个试次的全部通道数据 返回: (n_windows, n_channels, win_len) n_channels trial_data.shape[0] baseline_len baseline_sec * fs # 1. 去基线每通道减去前3秒均值 baseline_mean np.mean(trial_data[:, :baseline_len], axis1, keepdimsTrue) stim_data trial_data[:, baseline_len:] - baseline_mean # (40, 7680) # 2. 按1秒窗口切片丢弃末尾不足一个窗口的部分 win_len win_sec * fs n_windows stim_data.shape[1] // win_len windows stim_data[:, :n_windows * win_len].reshape(n_channels, n_windows, win_len) windows windows.transpose(1, 0, 2) # (60, 40, 128) return windows windows preprocess_trial(data[0]) # 第1个试次切成60个窗口去基线放在分段之前做这个顺序不能反。如果先分段再去基线每个窗口都要重新算自己的均值等于把窗口内的有效脑电信息也一并减掉了。参数上win_sec是后续影响最大的一个1 秒窗口下 LSTM 能看到 60 个时间步2 秒窗口只有 30 步但每步输入的时间分辨率更高两类设置各有拥趸。归一化这一步很多人做错。DEAP 每个被试的幅值范围差异很大不归一化的话CNN 卷积核会在某些被试身上学到错误的尺度信息。推荐 z-score 归一化而且必须先用训练集统计量 fit再拿同一组均值方差去 transform 验证集和测试集。绝不能整份数据一起算均值方差否则属于数据泄露后面第 5 章专门讲。2.3 标签二值化与样本组织DEAP 原始标签是 1 到 9 的连续值绝大多数分类任务会把它二值化。最常用的切分阈值是 5valence 大于 5 记为 1正情绪小于等于 5 记为 0负情绪arousal 同理。这样每个试次变成两个独立的二分类任务valence 分类和 arousal 分类。实际使用时也可以按被试内中位数切分但跨被试实验里中位数切分会让标签分布随被试变化复现起来很麻烦我统一用 5。标签和窗口对应起来有个细节一个试次切成 60 个窗口后这 60 个窗口全部继承该试次的二值标签。这就会制造同源样本——如果训练集和验证集不做试次级划分同一个试次的窗口会同时出现在两边验证准确率虚高。后面第 4 章讲实验范式时会再强调。建议预处理阶段就把每个窗口的试次编号记下来后面做 GroupShuffleSplit 或 LOSO 交叉验证都要用它。# 标签二值化与窗口标签生成 raw_label labels[0, 0] # 第1个试次的valence评分 bin_label 1 if raw_label 5 else 0 win_labels np.full(60, bin_label) # 60个窗口共享同一标签 trial_ids np.full(60, 0) # 记录窗口来源的试次编号到这里一个被试的数据就变成了60 段试次 × 60 个窗口 3600 个样本每个样本形状 (40, 128)外加对应的二值标签和试次编号。下面可以进模型了。3. CNN-LSTM模型搭建结构设计与PyTorch实现3.1 CNN与LSTM的分工空间特征与时间依赖先想一个问题脑电信号直接拉平成一维向量丢进全连接网络为什么效果差因为 32 个电极的位置本身有空间结构——额叶、颞叶、顶叶各自对应不同脑区的活动模式一维拉平就把通道位置关系全丢了。CNN 的卷积核天然支持局部感受野可以用一个小卷积核同时覆盖相邻几个通道和相邻几个时间点相当于在空间和时间两个维度上同时做特征提取。但 CNN 有个短板卷积核尺寸固定感受野有限对长距离时间依赖——比如 3 秒前的情绪状态影响当前分类——建模能力偏弱。LSTM 正好补上门控机制允许信息在时间步之间传递理论上能记住几十步之前的有效特征。所以 CNN-LSTM 的常见组合是CNN 先对每个时间窗口做空间特征提取输出一组特征序列LSTM 再在这组序列上做时序建模最后接全连接层分类。两个模块的分工很清楚CNN 回答这一瞬间脑电长什么样LSTM 回答这些瞬间怎么串成一段情绪变化。3.2 模型代码实现与维度推导下面给出一个在 DEAP 上可跑的 PyTorch 实现。输入形状设计为 (batch, seq_len, n_channels, n_timesteps)seq_len 是窗口数量n_channels 是 40n_timesteps 是窗口长度 128。import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_channels40, n_timesteps128, seq_len60, lstm_hidden128, n_classes2, dropout0.5): super().__init__() # CNN部分对每个时间步的 (通道, 时间) 做空间特征提取 self.cnn nn.Sequential( nn.Conv2d(1, 32, kernel_size(5, 3), padding(2, 1)), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d((2, 2)), nn.Conv2d(32, 64, kernel_size(3, 3), padding(1, 1)), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d((2, 2)), nn.AdaptiveAvgPool2d((1, 8)) ) self.cnn_out 64 * 8 self.lstm nn.LSTM(input_sizeself.cnn_out, hidden_sizelstm_hidden, num_layers2, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.fc nn.Linear(lstm_hidden * 2, n_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len, n_channels, n_timesteps) batch, seq_len x.shape[0], x.shape[1] # 把 seq_len 并入 batch让CNN一次处理所有时间步 x x.view(batch * seq_len, 1, x.shape[2], x.shape[3]) feat self.cnn(x) # (batch*seq_len, 512) feat feat.view(batch, seq_len, -1) # (batch, seq_len, 512) out, _ self.lstm(feat) # (batch, seq_len, 256) out self.dropout(out[:, -1, :]) # 取最后一个时间步的隐状态 return self.fc(out)维度推导是重点必须自己走一遍。输入 (2, 60, 40, 128) 先 view 成 (120, 1, 40, 128)第一层卷积核 (5, 3) 加 padding 后特征图尺寸基本不变MaxPool2d((2,2)) 后变 (120, 32, 20, 64)第二层卷积加池化后 (120, 64, 10, 32)AdaptiveAvgPool2d((1,8)) 强制把空间尺寸压成 1×8输出 (120, 64, 1, 8)。view 后每帧 CNN 特征就是 64×8512 维LSTM 的 input_size 就是 512。这里用 AdaptiveAvgPool 而不是固定池化好处是窗口长度变化时不用重新设计全连接层。比如你改用 2 秒窗口输入变成 (batch, 30, 40, 256)CNN 照样输出 (batch, 30, 512)模型代码一行都不用改。LSTM 用了双向和两层。双向的意义在于脑电情绪特征既受过去影响也受整段视频的上下文影响双向 LSTM 能同时看到两个方向的隐藏状态。两层 LSTM 能增加时序建模容量但别超过 3 层小数据集上很容易过拟合训练时间也翻倍。dropout 放在 LSTM 输出和全连接之间注意 LSTM 内部的 dropout 参数只在 num_layers 1 时生效两层正好能用上。3.3 损失函数、优化器与超参数配置二分类任务用交叉熵损失优化器首选 Adam。学习率从 1e-3 起步配合 ReduceLROnPlateau 在验证损失停滞时降一半。权重初始化一般不用操心PyTorch 默认初始化在 DEAP 上够用如果发现损失不降再手动初始化 LSTM 的 hidden 权重为均匀分布、偏置为 0。超参数推荐值说明学习率1e-3超过 3e-3 容易梯度爆炸weight_decay1e-4太小起不到正则作用batch_size32~64显存不够时配合梯度累积LSTM hidden128先试 64再往上加训练轮次20~40小数据集收敛快40 轮足够窗口长度128 (1秒)可调但别短于 64 点model CNNLSTM(n_channels40, n_timesteps128, seq_len60, n_classes2) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 )weight_decay 设 1e-4 是经验值太大容易欠拟合太小没效果。还有一个容易被忽略的细节LSTM 的隐层初始状态默认是零向量但每次 forward 如果不重新声明多轮迭代后隐状态会残留上一批数据的记忆。好在 PyTorch 的 nn.LSTM 在 batch_firstTrue 且未传入 h0 时会自动用全零初始化不需要手动清。但如果你在循环里复用了同一个隐状态变量就要小心了。提示DEAP 原始数据里 data 的幅值范围在几百微伏量级CNN 的 BatchNorm 会做内部标准化但输入 z-score 归一化仍然必要——它让 LSTM 的梯度传播更稳定尤其跨被试时。4. 训练与评估把模型跑起来并验证有效性4.1 训练循环与早停策略训练代码本身没有玄学关键是早停和验证策略。DEAP 数据量小二分类任务通常 20 到 40 个 epoch 内收敛。早停监控验证集损失连续 8 个 epoch 不下降就停止同时保存验证损失最小的模型权重这样即使后面过拟合也有后悔药可吃。best_loss float(inf) patience 8 no_improve 0 for epoch in range(40): model.train() total_loss 0 for x_batch, y_batch in train_loader: optimizer.zero_grad() logits model(x_batch) loss criterion(logits, y_batch) loss.backward() optimizer.step() total_loss loss.item() model.eval() val_loss 0 with torch.no_grad(): for x_val, y_val in val_loader: val_loss criterion(model(x_val), y_val).item() val_loss / len(val_loader) scheduler.step(val_loss) if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), best_model.pth) no_improve 0 else: no_improve 1 if no_improve patience: print(fEarly stop at epoch {epoch}) break训练循环里几个容易翻车的点。第一每次迭代前必须 optimizer.zero_grad()否则梯度累加损失曲线会莫名其妙上涨。第二model.eval() 和 torch.no_grad() 必须成对出现eval 模式关闭了 Dropout 和 BatchNorm 的训练行为no_grad 关闭了梯度计算少任何一个验证集的结果都不准。第三验证集 loss 的计算要除以 len(val_loader)得到每个 batch 的平均损失才有和训练 loss 比较的基准。4.2 评估指标准确率、F1与混淆矩阵DEAP 二分类的标签分布总体均衡valence 高/低差不多各半但具体到某个被试可能偏移。所以评估指标不能只看准确率F1 和混淆矩阵都要看。F1 是精确率和召回率的调和平均能反映少数类被压制的情况。from sklearn.metrics import accuracy_score, f1_score, confusion_matrix y_true, y_pred [], [] model.eval() with torch.no_grad(): for x_batch, y_batch in test_loader: logits model(x_batch) pred torch.argmax(logits, dim1) y_pred.extend(pred.cpu().numpy()) y_true.extend(y_batch.cpu().numpy()) acc accuracy_score(y_true, y_pred) f1 f1_score(y_true, y_pred, averagebinary) cm confusion_matrix(y_true, y_pred) print(fAcc: {acc:.4f}, F1: {f1:.4f}) print(Confusion matrix:) print(cm)如果准确率 90% 但 F1 只有 0.6说明模型把多数类全蒙对了、少数类几乎全错这种模型没有实际价值。混淆矩阵则直接告诉你错在哪类样本上——比如正样本大量被误判为负就要检查标签划分是否合理、类别权重要不要调整。DEAP 的评分是 1 到 9 的整数阈值 5 会让评分为 5 的样本被划到负类这部分边界样本本身就是最容易分错的。4.3 跨被试与同被试两种实验范式DEAP 上存在两种完全不同的实验范式很多人没搞清楚就开跑导致结果没法跟其他工作比较。第一种是同被试within-subject训练集和测试集来自同一个被试的不同试次。模型学到了这个被试个人的脑电模式准确率通常能到 0.85 到 0.95但实际应用意义有限因为你不可能先收集每个新用户的脑电来微调模型。第二种是跨被试cross-subject用前 N 个被试训练留下 1 个被试测试。难度大得多准确率一般只有 0.65 到 0.75因为不同被试的脑电幅值、波形、个体差异非常大。范式划分方式准确率参考适用场景同被试同一被试的试次划分0.85~0.95算法快速验证跨被试按被试划分训练/测试0.65~0.75实际应用评估LOSO每个被试轮流当测试集0.65~0.75论文标准做法写论文或做对比实验时一定要注明用的是哪种范式两者结果差别巨大。我建议在 DEAP 上做研究优先采用跨被试评估并且做 leave-one-subject-outLOSO交叉验证每个被试轮流当测试集报告平均准确率。虽然训练时间成倍增加但结果可信度和说服力完全不同。同被试准确率高得吓人审稿人一眼就能看出范式问题。5. CNN-LSTM训练避坑指南数据泄露与过拟合排查5.1 数据泄露预处理顺序错误导致的虚假高准确率现象测试准确率高达 0.95 以上换一个被试或换一个数据集立刻跌到 0.55接近随机猜测。原因最常见的泄露源有两个。第一个是全局归一化——把整个数据集的均值和方差一起算再划分训练测试集等于测试集的统计信息已经混进了训练过程。第二个是分段前不做试次级划分——同一个试次的窗口同时出现在训练集和测试集CNN-LSTM 记住了该试次的整体模式而不是学到普适的分类规则。解决归一化必须先 fit 训练集、再 transform 验证集和测试集划分数据集以试次为单位保证同一个试次的全部窗口只出现在训练集或测试集中的一个。用 GroupShuffleSplit 可以强制按试次分组from sklearn.model_selection import GroupShuffleSplit # 每个窗口的第2列存它所属的试次编号 groups all_window_meta[:, 1] gss GroupShuffleSplit(n_splits1, test_size0.2) train_idx, val_idx next(gss.split(all_windows, all_labels, groupsgroups))注意 GroupShuffleSplit 是按组划分的保证同一组的全部样本进同一边。还有一个隐藏泄露点通道级的全局 z-score 如果整段计算也会带入测试集信息正确做法是先算每个通道在训练集上的均值和方差再对测试集用同样参数做标准化。5.2 随机种子与划分不一致导致的假复现现象同一个模型、同样的超参数两次运行的准确率一个是 0.85 一个是 0.72差异大得离谱。原因DEAP 样本量小单被试只有 40 个试次 × 60 个窗口 2400 个样本随机划分的微小差异会被放大。PyTorch 的 DataLoader 每个 epoch 默认重新打乱数据不固定种子的话每次训练的数据顺序不同收敛点就不同。解决脚本开头给 torch、numpy、random 全部设置种子并设置 DataLoader 的 generator 参数仅全局 seed 不够因为 DataLoader 内部有独立的随机源。def set_seed(seed42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)cudnn.deterministic 设为 True 会让卷积使用确定性算法速度稍慢但结果可复现。benchmarkFalse 是避免 cuDNN 根据输入形状选择不同卷积算法导致的结果浮动。代价是训练慢 5% 到 10%做实验完全值得。5.3 LSTM梯度爆炸与学习率设置现象训练前期损失正常下降某几个 epoch 后突然变成 nan或者损失值剧烈振荡不收敛。原因LSTM 的时间反向传播存在梯度累积效应序列越长梯度传播路径越长越容易触发梯度爆炸。DEAP 上最常见的原因是学习率超过 1e-3或者 batch size 波动导致某些 batch 的损失特别大。解决先把学习率降到 1e-3 以下再加梯度裁剪把梯度范数限制在 1.0 以内。这是一层保险正常训练时可能永远触发不到但一旦触发就能救回训练。torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)如果加了裁剪仍然出现 nan检查输入数据是否含 NaN。DEAP 的 .mat 文件某些通道可能含 NaN 值预处理时先检查 np.isnan(data).any()有就做通道插值或就近填充。一个反直觉的点BN 层对 NaN 输入不会报错它会一路把 NaN 传播下去直到损失变成 nan 你才发现。5.4 过拟合训练准确率99%但测试准确率60%现象训练集 loss 降得又快又低验证集 loss 先降后升准确率跟训练集差 20 个百分点以上。原因DEAP 样本量小模型容量大——双向两层 LSTM 加 64 通道 CNN 特征——模型很容易把训练样本背下来。特别是窗口重叠率设太高比如 75% 重叠相邻窗口几乎一样模型学到的全是重复模式的记忆。解决按优先级做三件事。降容量LSTM hidden 从 128 降到 64CNN 第一层通道从 32 降到 16先看验证集准确率是否提升。提 dropout把 LSTM 输出端的 dropout 从 0.5 提高到 0.7。做数据增强对窗口加少量高斯噪声或随机丢弃部分通道脑电领域这是常见做法注意噪声幅度别太大标准差取信号标准差的 5% 到 10% 即可过强会破坏标签语义。5.5 显存不足与训练时间过长现象batch size 设 128 直接爆显存设 16 训练时间翻倍还不收敛。原因CNN-LSTM 的计算图跨度大中间特征张量多显存开销远大于同等参数量的纯 CNN。batch 太小则 BatchNorm 的统计量不稳定模型收敛慢。解决显存优先时batch size 调小后同步调整训练策略。两个办法一是用梯度累积模拟大 batch二是把所有预处理结果提前保存成 .npy 文件训练时直接加载避免每次启动都重新做去基线和分段。accum_steps 4 # 实际batch16模拟batch64 optimizer.zero_grad() for i, (x_batch, y_batch) in enumerate(train_loader): logits model(x_batch) loss criterion(logits, y_batch) / accum_steps loss.backward() if (i 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()这里有个细节梯度累积时 loss 要除以 accum_steps因为每个 batch 的梯度会累加到一起不除的话等效学习率变成了原来的 accum_steps 倍可能直接导致梯度爆炸。另一个提速技巧是 DataLoader 的 num_workers 设成机器逻辑核心数的一半别设 0否则数据加载会成为训练瓶颈。注意第 5.1 和第 5.4 的现象都是准确率虚高但一个来自数据泄露、一个来自过拟合。区分方法很简单把训练好的模型换到另一个被试上测试——泄露的模型立刻崩到随机水平过拟合的模型虽然下降但不会完全崩。6. 进阶调优注意力机制与多模态融合提升识别率当你的 CNN-LSTM 基线在跨被试二分类上能稳定跑到 0.70 附近想再往上推性价比最高的两个方向是注意力机制和多模态融合。注意力机制最常见的改法是给 LSTM 输出加 soft attention不再只取最后一个时间步的隐藏状态而是把所有时间步的隐藏状态做加权求和权重由每个时间步对当前分类的贡献决定。实现上就是一个可学习的注意力向量 softmaxclass AttentionPooling(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn nn.Linear(hidden_dim, 1) def forward(self, lstm_out): # lstm_out: (batch, seq_len, hidden_dim) scores self.attn(lstm_out).squeeze(-1) # (batch, seq_len) weights torch.softmax(scores, dim1) return torch.sum(lstm_out * weights.unsqueeze(-1), dim1)这个改法一般能带来 2 到 4 个百分点的提升参数量增加很小。逻辑上也更合理DEAP 的 60 秒视频刺激中情绪不是稳定持续的被试可能在中间某个片段情绪反应最强注意力自动找到那个片段比固定取最后一步合理得多。多模态融合是另一个方向。DEAP 除了 32 通道脑电还有 8 路外周信号包括眼电、肌电、皮电、呼吸等。我的做法是脑电走 CNN-LSTM 分支8 路外周信号走一个轻量 1D CNN 分支两个分支特征拼接后过全连接分类。简单 concat 就能比单用脑电高 3 到 5 个点没必要一上来就上复杂的跨模态注意力数据量不够反而翻车。最后说一个做了大半年 DEAP 后攒下的教训这数据集的复现地狱几乎全在预处理不在模型。基线去没去、窗口多长、重叠率多少、归一化怎么做、通道选哪几个任何一个微调都会让准确率浮动 3 到 8 个百分点。所以做实验的习惯是把所有预处理参数写进一个配置字典每次跑实验自动保存到日志文件包括种子、划分方式、超参数。这样回头排查问题、复现结果、对比论文都不会一头扎进黑匣子里。希望帮到你。本文还有配套的精品资源点击获取