简介针对DEAP脑电数据集的情感识别项目面向脑电信号处理与深度学习入门及进阶研究者提供了一整套使用CNN和LSTM等模型进行情绪分类的Python代码。压缩包共35个文件约11.86MB包含Jupyter Notebook实验脚本、Python训练/测试脚本、已保存的模型权重文件H5/NPY以及多张预处理与结果可视化PNG图片可直接查看并复现完整流程。已有2521人学习下载。项目覆盖数据读取、带通滤波、特征提取、模型构建、训练评估与权重保存等环节除LSTM外还尝试了CNN1DLSTM、GRU、SimpleRNN等结构对比并通过多个notebook备份保留调参过程。配套的可视化图涵盖带通滤波效果、混淆矩阵与损失曲线有助于理解信号预处理和模型性能变化适合需要参考完整脑电情感识别代码或在此基础上做进一步实验的读者。1. DEAP脑电信号识别CNN-LSTM先搞清楚这套代码解决什么问题“Deap脑电信号识别CNN-LSTM代码”这个搜索词浓缩了EEG深度学习入门最常见的一套实践路径DEAP数据集提供标准情绪脑电“deap数据集下载”之后模型端从“lstm模型代码”一路搭到CNN-LSTM的“代码复现”几乎成为情绪识别领域的默认baseline。实际数据是40个被试、40通道、128Hz采样模型用一维卷积提取局部时间特征再用双向LSTM捕捉长时间依赖。这套组合不是花活稳定跑能到二分类准确率80%上下、四分类70%上下前提是数据切分和验证划分别出错。本文从原始.mat文件一路走到训练循环代码可直接复制运行也会把复现中翻车最多的几个暗坑单独点名。2. DEAP数据预处理从40×40×8064到批量训练样本2.1 先确认文件的真实形状loadmat之后别急着跑模型DEAP官网申请下载后解压出来的data_preprocessed_matlab目录是标准输入。这里的.mat文件不是一张平面表而是嵌套数组第一步务必用scipy读出来看形状别凭博客里的旧截图写死维度。import scipy.io as sio mat sio.loadmat(data_preprocessed_matlab/s01.mat) print(mat.keys()) # 里面有 data、labels还可能有 stimuli 等键 data_all mat[data] # (40, 40, 8064) labels mat[labels] # (40, 4) print(data_all.shape, labels.shape)输出里40是每个被试的trial数第二个40是通道数32个EEG通道加8个外围生理信号8064是128Hz采样率乘63秒。labels每行四列顺序固定为valence、arousal、dominance、liking评分都是1到9。很多复现代码在这里把列序对错后期所有标签全部漂移这一类低级错误最不好排查。如果下载的是.bdf原始格式还得自己走一遍降采样、去工频和伪迹剔除我一般直接用预处理版本把精力留给模型和验证。处理通道时除了切片长度还要决定外围信号怎么处理。32个EEG通道按国际10-20系统排列从Fp1到O2有明确空间含义后8个通道是EOG、EMG、GSR等外周信号。对于效价、唤醒识别常见做法是只取前32通道避免肌电和眼电的幅值扰动盖过脑电成分。若做多模态融合可以保留并单独开一路特征但baseline阶段建议先隔离。2.2 切片与标准化把每一段脑电变成可计算的特征每个trial的真实长度是63秒前3秒是刺激开始前的基线。所谓基线是安静状态下记录的脑电不做基线校正模型会把被试的静息水平当成刺激响应的一部分。常见做法是取刺激开始后第0到第40秒作为样本做逐通道z-score标准化。import numpy as np def make_trial_sample(trial_data, trial_label, fs128, stim_offset3, win_len40): start int(stim_offset * fs) end int((stim_offset win_len) * fs) seg trial_data[:, :, start:end] # 保留全部通道 mu seg.mean(axis2, keepdimsTrue) std seg.std(axis2, keepdimsTrue) seg (seg - mu) / (std 1e-6) # 每个通道独立标准化 return seg.astype(np.float32), trial_label[:2]窗口取40秒是折中。短窗10秒能增大样本量但会把情绪刺激的累积过程截断长窗拉到60秒LSTM要面对更多时间步训练变慢且容易过拟合。多数公开复现在20到40秒之间选40秒对DEAP的效价识别是一个比较稳的起点。1e-6是防除零个别通道电极接触不良时采样值可能是常数不保护会算出NaN。基线校正更完整的做法是用前3秒的均值做减法再除以整段标准差上面代码直接裁掉前3秒。裁掉简单能跑通但跨被试泛化时会发现模型对静息电位水平特别敏感。如果你看到“同一份代码换个被试就不行”先检查这里是不是只做了裁切没做基线减法。标准化放在切片之后、入模型之前别放到整个数据集上统一算否则会造成跨trial的数值泄漏。2.3 数据集封装用PyTorch Dataset把样本和标签对齐样本切好了还需要一个能交给DataLoader的接口。下面这个类按文件路径逐个读被试把trial维度拆成独立样本标签按valence大于5转成二分类。注意它不是一个被试一个样本而是每个被试40个trial各出一个样本40个被试合计1600个样本。import torch from torch.utils.data import Dataset class DEAPDataset(Dataset): def __init__(self, subject_paths, eeg_onlyTrue, threshold5): self.segments [] self.targets [] for path in subject_paths: mat sio.loadmat(path) data, labels mat[data], mat[labels] for t in range(data.shape[0]): seg, lab make_trial_sample(data[t:t1], labels[t]) if eeg_only: seg seg[:, :32, :] # (32, 5120) self.segments.append(seg.squeeze(0)) self.targets.append((lab[0] threshold).astype(np.int64)) def __len__(self): return len(self.segments) def __getitem__(self, idx): x torch.from_numpy(self.segments[idx]) y torch.tensor(self.targets[idx], dtypetorch.long) return x, y这里有个容易看晕的细节make_trial_sample的输入保留了trial维度所以循环里传data[t:t1]而不是data[t]切出来的seg形状是(1, 40, 5120)squeeze(0)之后变成(40, 5120)取前32通道后是(32, 5120)正好是Conv1d期望的(channel, length)。标签取lab[0]对应valence维度。阈值设5会让正负样本接近均衡想定义更严格的积极情绪可以抬到6但负样本会明显增多后面必须处理类不平衡。二分类跑通整条管线后再扩展四分类或回归会顺很多。上来直接做四分类准确率卡在60%上下你很难判断是模型问题、标签噪声还是样本量不够。先确保数据、模型、验证流程没有暗病再往细粒度走是我做这个方向的一条血泪经验。3. CNN-LSTM模型结构一维卷积在前双向LSTM收尾3.1 为什么用Conv1d而不是Conv2d脑电通道不是图像像素很多人从图像任务切过来第一反应是把40个通道当成图像的宽时间轴当成高直接上Conv2d。这个直觉在少数多通道EEG任务里能跑但对DEAP并不友好脑电通道的排列不是空间连续的像素质点Fp1和Fp2相邻但P3和O1在10-20系统里隔着很远Conv2d的卷积核会把这种非均匀距离强行当成均匀网格。更合理的做法是把通道留作输入特征维用Conv1d沿时间轴滑动让每个卷积核在每个通道内部做短时模式提取。后续融合发生在通道维上由第一层卷积的输出通道数接管。一维卷积在脑电上的作用可以拆成两层短期滤波和局部模式提取。128Hz下一个7点的卷积核覆盖约55毫秒能捕捉单个短时波形片段alpha波8到13Hz一个周期约10到15个采样点7点核大概覆盖半个周期。再堆一层大卷积核感受野扩大就能组合出“慢波上升沿之后紧跟一个快波”这类复合模式。卷积响应再交给LSTM让它去消化这些局部特征在数十秒内的先后关系。3.2 LSTM的输入输出形状卷积之后的序列该怎么喂Conv1d输出形状是(batch, channels, length)而PyTorch的LSTM期望输入是(batch, seq_len, input_size)。两者之间缺一次转置。这个转置看着不起眼实际是新手最容易报错的地方报错信息往往是“Expected 3D tensor, got 2D”之类。转置之后length变成了时间步channels变成了每个时间步的特征维。这里的时间步已经不是原始的5120点而是经过两次MaxPool之后压缩过的长度LSTM不必面对上千步的原始序列训练稳定许多。选择双向LSTM而不是单向原因是情绪状态在当前时刻的编码可能依赖之后几秒的脑电变化模式比如一个突然的效价提升往往伴随前后各一小段时间的节律变化。双向结构让每个时间步同时看到前文和后文对这个场景有实际收益。代价是参数量翻倍以及对未来信息的依赖——如果你要在线实时识别双向模型在推理时需要一个完整窗口才能输出DEAP这种离线实验场景不受影响。3.3 完整CNN-LSTM模型代码与可复现的超参基线下面是我在DEAP上常用的一个基线模型结构尽可能精简方便你在此基础上加注意力或改通道融合。import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_channel32, n_classes2): super().__init__() self.conv nn.Sequential( nn.Conv1d(n_channel, 64, kernel_size7, stride1, padding3, biasFalse), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size4), nn.Conv1d(64, 128, kernel_size5, stride1, padding2, biasFalse), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size4), ) self.lstm nn.LSTM( input_size128, hidden_size64, num_layers2, batch_firstTrue, bidirectionalTrue, dropout0.3 ) self.classifier nn.Sequential( nn.Dropout(0.4), nn.Linear(128, n_classes) ) def forward(self, x): # x: (B, 32, 5120) x self.conv(x) # (B, 128, 320) x x.transpose(1, 2) # (B, 320, 128) out, _ self.lstm(x) # (B, 320, 128) out out[:, -1, :] # 取最后一个时间步 out self.classifier(out) return out几个参数的选择理由拆开说。第一层卷积核7第二层5padding都设成保持长度不变第一层bias设为False因为后面紧跟BatchNormbias会被归一化操作抵消。MaxPool1d(4)连续两次把5120点压缩到320点LSTM时间步降到320训练速度明显提升。hidden_size取64双向之后LSTM输出的最后一维是2×64128所以classifier的Linear输入是128。如果改成单向这里需要相应改成64。dropout取0.3只在多层LSTM层之间生效分类头前面再单独加一个dropout 0.4是我测试下来在1600个样本规模下不容易过拟合的组合。num_layers取2而不是更深原因是数据量约束。40个被试每个40个trial一共1600个样本深层LSTM在这种量级下很容易记住训练集。out[:, -1, :]取序列最后一步是常见做法它聚合了双向信息在末端的状态你也可以改成out.mean(dim1)做全局平均池化两种都能用使用mean在部分情绪任务里多1到2个点但不稳定我一般以最后一步为默认。4. 训练配置与指标评估准确率不是唯一参考4.1 训练主循环优化器、损失函数与学习率调度模型定了训练部分不要搞花哨。Adam加lr1e-3是这类小规模数据的稳选。损失用CrossEntropyLoss类别不均衡时给它传weight参数比想各种采样技巧更直接。import torch.optim as optim def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 correct 0 total 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) correct (logits.argmax(1) y).sum().item() total y.size(0) return total_loss / total, correct / total优化器里有个小细节LSTM的权重梯度尺度通常比卷积层大如果损失出现震荡把optimizer换成按参数分组、对LSTM参数单独设lr1e-4往往比调全局学习率更有效。我早期直接全局调低学习率结果卷积层学不动整个模型精度上不去后来改成参数分组才稳下来。加载数据时设置DataLoader(..., shuffleTrue)和固定worker数量训练循环里没有额外随机操作。学习率调度先别急着上CosineAnnealing。1600个样本的训练量通常30到50个epoch就收敛固定学习率跑完看曲线再决定要不要调度。如果验证loss在第20个epoch附近开始震荡用ReduceLROnPlateau按验证loss降学习率因子0.5比预先定死调度策略更实用。4.2 评估矩阵与报告混淆矩阵、F1和类别占比准确率在这个数据集上是有点欺骗性的指标。DEAP二分类如果类别恰好是55对45模型把多数类全猜对也有55%准确率很多复现文章报出80%以上准确率但F1只有60%说明模型实际是“偏科选手”。我每次训练完第一件事是打印完整分类报告和混淆矩阵而不是只看准确率。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, loader, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for x, y in loader: x x.to(device) logits model(x) y_true.extend(y.tolist()) y_pred.extend(logits.argmax(1).cpu().tolist()) print(classification_report(y_true, y_pred, digits3)) print(confusion_matrix(y_true, y_pred))看报告时先看support也就是每个类别的样本数。DEAP的二分类任务里valence标签按5分界不同被试的评分分布差异很大有的被试40个trial里只有8个正样本。如果报告里少数类recall低于0.5先别怪模型去看看这个被试的标签分布很多人到这一步才发现自己的数据集划分里某个被试根本没有正样本。这属于划分逻辑的问题不是模型能力问题。4.3 早停与检查点什么时候该停止训练早停不是可选项是防止过拟合的必需手段。训练循环里记录最佳验证指标连续多个epoch没有改善就停并把最佳权重单独保存。我自己习惯按验证F1而不是准确率做早停指标因为F1对类别分布更敏感。best_f1 0.0 patience 0 for epoch in range(max_epochs): train_loss, train_acc train_epoch(...) val_loss, val_f1 evaluate_metrics(model, val_loader, device) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_cnn_lstm.pt) patience 0 else: patience 1 if patience 7: print(fearly stop at epoch {epoch}) break保存检查点有个常被忽略的点torch.save要连同优化器状态和当前epoch一起存方便断点续训。只存模型权重后面想微调或继续训练时优化器的动量状态丢了学习率状态也要重新算。如果机器中途断电或显存不够被杀进程有完整检查点会省掉一整天重跑时间。我一般存成dicttorch.save({ epoch: epoch, model: model.state_dict(), optimizer: optimizer.state_dict(), best_f1: best_f1, }, fcheckpoint_epoch{epoch}.pt)早停阈值设7个epoch是一个稳妥起点。DEAP这类小数据收敛快5个epoch没改善基本就到平台期设太长会把时间耗在震荡区间设太短容易在训练中段的正常波动里误停。5. 避坑与排查五个让脑电识别模型翻车的常见原因5.1 数据泄漏同一个被试的连续片段被切进训练和测试现象训练准确率85%验证准确率直接飙到95%比训练集还高怎么看都不合理。 原因这是最隐蔽的泄漏。DEAP的每个trial来自同一段连续记录的脑电脑电信号有强自相关性相邻时间窗的波形高度相似。如果随机切分时把同一个trial的不同窗口同时分给训练集和验证集模型等于提前见过答案。很多人做窗口滑动扩充样本时没按trial边界切分就掉进这个坑。 解决切分必须按trial甚至按被试分组。用GroupKFold按subject_id分组验证集里整段trial不参与训练。最简单的做法是文件级切分训练集用前32个被试测试集留8个被试每个被试的40个trial作为一个整体。这样测出来的才是跨被试泛化能力而不是“记住同一段脑电”的能力。5.2 内存溢出40个被试不要一次全装现象程序在读取数据阶段运行了十几分钟然后Python进程直接被系统杀掉报Killed无堆栈。 原因DEAP全量数据load进来每个被试是(40, 40, 8064)的float64大约103MB40个被试超过4GB。再加上窗口重叠切片和转成float32的过程内存直接翻几倍。你以为自己只加载了4GB实际中间变量会推高到十几GB。 解决按被试流式处理每个被试读完、切完样本就释放原始数组。样本本身可以缓存在磁盘上用np.savez或torch.save分批写入训练时再np.load逐个文件。如果一定要全部放内存至少把数据转成float32、只保留EEG通道、去掉未使用的trial后再组装成一个大数组。5.3 评分阈值与随机种子玄学精度的根源现象同一份代码跑两次准确率差5个百分点有人把这个归因于“深度学习玄学”。 原因DEAP的1到9评分本身有主观性同一个trial的valence在不同被试那里的分布差异很大。更常见的问题是没有固定PyTorch和NumPy的随机种子模型初始化、数据打乱顺序每次都不同小数据集上这种波动会被放大。1600个样本的模型随机种子不同导致2到3个点的波动是正常的但如果没固定种子5个点的波动也会出现。 解决训练脚本开头固定所有种子同时设置torch.backends.cudnn.deterministic True。另外阈值选择要写进实验日志。按5分界和按被试中位数分界得到的是两个不同的任务精度不能直接对比。我踩过最冤的一次是基线模型看似比论文高3个点后来发现是阈值从5改成了被试中位数类别均衡性变好导致指标虚高。5.4 滤波少一步模型多走弯路现象卷积层学到的第一层特征图全是高频毛刺模型loss降不下去验证准确率在60%附近打转。 原因DEAP预处理版本虽然做了降采样但工频干扰和直流漂移仍然存在。直接z-score之后50Hz工频的高幅振荡会主导第一层卷积的梯度真正有判别力的theta、alpha波段被淹没了。我见过不少人把问题归给LSTM“记不住东西”实际是输入信噪比太低。 解决在切片之后、标准化之前加一个4到45Hz的带通滤波。用scipy的Butterworth滤波一次性处理整段trial再切片避免在切片边界产生振铃。from scipy.signal import butter, sosfiltfilt def bandpass(data, fs128, low4, high45): sos butter(4, [low, high], btypebandpass, fsfs, outputsos) return sosfiltfilt(sos, data, axis-1)sosfiltfilt做零相位滤波不会引入相位偏移处理离线数据足够。滤波后重新检查一次通道方差如果某个通道方差比其他通道小几个数量级说明信号质量差考虑剔除而不是硬留。5.5 类别不平衡多数类“懂事”地赢了现象验证集准确率78%看起来不错但F1只有0.52分类报告里负样本recall接近0.9正样本recall不到0.3。 原因DEAP按5分阈值划分时很多被试的trial评分集中在4到6之间阈值附近的样本本身可分性就差。权重偏向负类时模型学会了“无脑输出负类”准确率被多数类抬高。 解决先给CrossEntropyLoss传weight参数权重按1 - class_frequency计算如果还不行再考虑WeightedRandomSampler。改分类阈值也有用——模型输出概率后在验证集上搜索让F1最大的阈值而不是默认0.5。这个阈值搜索代码很简单但很多复现都没做白白丢掉两三个点。6. 进阶技巧跨被试评估与可解释性验证6.1 把LSTM最后一步换成注意力池化out[:, -1]只用了最后一个时间步情绪脑电里真正有判别力的时段可能出现在中间的某几秒。常见改进是把最后一步换成注意力池化让模型自己学一组时间步权重。代码改动不大效果通常是稳定提升而不是暴涨。class AttentionPooling(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn nn.Linear(hidden_dim, 1) def forward(self, lstm_out): # (B, T, H) score torch.softmax(self.attn(lstm_out).squeeze(-1), dim1) return torch.bmm(score.unsqueeze(1), lstm_out).squeeze(1)用法上把out[:, -1, :]替换成AttentionPooling对全序列做加权求和。注意力权重还能顺带做可解释性分析画出哪些时间段的脑电对最终判断贡献最大。注意注意力权重不要直接当“重要性”解读它只能说明模型在决策时参考了哪个时段不表示那段脑电在生理学上必然关键。6.2 两个让结果可信的小验证模型调完优先做两件事。第一件把标签随机打乱再跑一遍同样的训练流程这个“乱标签baseline”准确率如果高于55%说明你的训练管线里有数据泄漏或者是数据加载顺序出了问题正常应该在50%附近徘徊。第二件用同样的数据训练一个传统模型做对照比如xgboost对提取好的统计特征做分类。深度模型在DEAP上比XGBoost高出的幅度通常不大如果CNN-LSTM反而低说明问题大概率不在模型而在数据预处理。跨被试评估也别忘了做。我个人的习惯是最后留8个被试完全不参与训练和早停只做最终测试用这组数据报告最终的F1。留出来的被试在调参过程中一次都不碰否则你会在不经意间把超参拟合到测试集上得到一张好看但守不住的指标。这个方向最大的陷阱就是让指标看起来漂亮希望这篇能把你的复现路径铺得稳一点少走我走过的弯路希望帮到你。本文还有配套的精品资源点击获取