ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

DEAP脑电情绪识别:从数据预处理到2DCNN+LSTM模型实现

2026/9/21 1:11:09 拓冰建站 浏览量
DEAP脑电情绪识别:从数据预处理到2DCNN+LSTM模型实现 简介基于DEAP数据集的脑电情绪识别项目面向脑机接口、情感计算与人机交互方向的开发者与学生提供一套融合二维卷积神经网络2D CNN与长短期记忆网络LSTM的完整示例代码。资源内共6个文件以5个Python脚本和1个文本说明为主分别负责数据加载与预处理、模型定义、网络训练和结果记录压缩包仅4KB结构紧凑适合入门级学习者快速阅读和复用。目前已有2300余人学习下载。通过该资源可直观理解如何将多通道EEG信号转换为伪彩色图像以提取空间特征并利用LSTM捕捉脑电时序依赖代码中附有数据滤波、标准化、损失函数与优化器选择等关键步骤便于对照DEAP数据集展开情绪识别实验为后续拓展智能假肢、虚拟现实等应用奠定基础。1. 为什么DEAP上的情绪识别总卡在数据组织上用DEAP做脑电情绪识别模型结构从来不是第一步的难题难在把32通道的时序信号组织成2DCNN和LSTM都能消化的张量。DEAP的原始数据是40个被试、每个被试40个试次每个试次包含3秒基线加60秒刺激采样率128Hz32个电极通道分布并不规则不能直接当图像喂。很多人一上来就堆模型结果准确率只有六成上下问题多半出在样本切片和电极映射上而不是网络深度不够。这篇博文会从数据预处理、空间特征映射、2DCNN加LSTM的模型搭建讲到训练参数和排错技巧覆盖整个落地路径。适合已经会用Python和PyTorch、想把手头EEG数据做成可复现情绪分类任务的工程师也适合刚接触脑电的算法同学照着搭一个baseline。2. 把DEAP数据集切成能喂给2DCNN和LSTM的样本2.1 DEAP数据格式与下载后的原始结构DEAP数据集下载后拿到的是.mat文件每个被试对应一个文件命名类似data_preprocessed_python目录下的s01.mat到s32.mat另外还有s01_emotions.csv之类的标签文件能直接看四个维度打分。用scipy.io.loadmat读进来后字典里主要是data和labels两个键。data的形状是40×40×8064含义是40个试次、40个通道、40秒信号乘128Hz即5120个采样点加3秒基线384个点共8064点。这8064点里的前384点就是基线段标签是40×4每行对应试次的效价、唤醒度、支配度、喜欢度取值范围1到9通常用效价和唤醒度做二分类阈值取5。这里的预处理有个常见分歧不少人直接把8064点全段丢进模型情绪分类结果波动很大。正确做法是把每个试次的基线段单独取出来按通道计算均值再从后面的5120个采样点里逐点减掉这一步是为了消除皮肤电、肌电等缓慢漂移带来的个体差异。import scipy.io import numpy as np data_path data_preprocessed_python/s01.mat raw scipy.io.loadmat(data_path) eeg raw[data] # (40, 40, 8064), 单位µV labels raw[labels] # (40, 4) baseline_len 384 processed [] for trial in range(eeg.shape[0]): baseline eeg[trial, :, :baseline_len].mean(axis1, keepdimsTrue) signal eeg[trial, :, baseline_len:] - baseline processed.append(signal) processed np.array(processed) # (40, 32, 5120), 前8通道是EOG可不取代码里减基线的操作只用了每个试次自己的前384点没有跨试次计算保留了个体差异。这里有个细节DEAP的前8个通道是眼电和肌电参考我在实际项目中通常只保留后32个脑电通道也就是索引8到39视觉表情通道在情绪识别里噪声大于信息面试项目里提一句已经排除、只保留EEG段比全量通道跑出来的效果更稳定。2.2 去基线、切片与样本组织单个试次去掉基线后还剩5120个点直接整体输入LSTM会带来两个问题计算图太长导致反向传播时梯度消失以及一个试次只有一个标签样本数量只有40×40等于1600条不够喂饱深层网络。常见做法是滑窗切片把每个试次切成多个1秒的小段相邻窗口之间可以重叠1278个原始试次能变成上万条样本。对于情绪识别来说窗口长度取1到3秒比较合适太短则单窗口内的脑电节律不完整太长则样本数不够。滑窗步长一般取窗口长度的百分之五十例如窗口1秒、步长0.5秒每个试次能切出127个窗口。分类用的标签是整个试次的效价阈值把试次标签复制给窗口内每个样本这样样本量增大到原来的127倍2DCNN和LSTM都不容易过拟合。2.2.1 时间窗划分时为什么不能跨试次滑窗必须严格限制在单个试次的边界内不允许窗口跨越试次边界。试次与试次之间不是连续情绪状态刺激视频切换时脑电模式会突变这样的样本标签含义模糊而且相当于人为制造了类别边界附近的低质量样本。我在写切窗代码时坚持先按试次遍历再在试次内部切最后拼装成四维张量避免numpy数组切片时无意间跨出边界。win_len, step 128, 64 X, y [], [] for trial_signal in processed: n (trial_signal.shape[1] - win_len) // step 1 for i in range(n): start i * step seg trial_signal[:, start:start win_len] X.append(seg) y.append(1 if labels[trial_counter, 0] 5 else 0)窗口长度128对应1秒步长64对应0.5秒重叠。分段前先计算n确保最后不足一个窗口的余量被丢弃不要做补齐因为EEG信号补零会让模型学到虚假的边界特征。2.3 电极坐标到二维图的映射方式2DCNN要求输入是二维网格而DEAP的32个电极坐标分散在头皮上不是天然的矩形排列。有个很常用的方案是通过球面投影把电极位置映射到9×9或8×8的网格上。DEAP官方文档里带了电极坐标文件可以用双线性插值把不规则位置的电压值插到规则网格的顶点上。除了插值还有一种是把头皮展开成二维平面近似映射成9×9矩阵空位补零。这两种方案效果差别不大但插值后的特征在不同被试之间稳定性更好。我这里使用的是固定蒙版加插值两步先把32通道重排成9×9矩阵每个电极的邻域用高斯权重填充每到一个窗口就执行一次映射得到一个9×9×128的三维张量9×9是空间维度128是时间维度。mapping_matrix build_mapping() # (32, 9, 9), 预计算好的插值权重 sample X[0] # (32, 128) mapped np.einsum(ct,cij-tij, sample, mapping_matrix)这里的einsum沿通道维度加权求和把32个通道的信号投影到9×9网格上时间维保持128不动。很多教程在这步只用最近邻填充最近邻会引入大量零值区域2DCNN会学到电极空位的位置信息而不是真实的空间关系部署在不同采集设备上时性能会崩。预计算权重作为常数矩阵可以让训练和推理完全一致这也是做在线情绪识别时不能省略的一步。3. 2DCNN提取空间特征LSTM吃时序动态3.1 为什么是2DCNN而不是一维卷积脑电信号虽然是一个通道序列沿时间轴展开但电极位置之间的空间排布存在拓扑关系额叶和枕叶在情绪处理上的激活模式差异只用一维卷积是抓不到的。2DCNN在同一时间片内对9×9空间图做卷积相当于在每个时间步对头皮电位分布做一个空间滤波这和一维卷积按通道顺序扫描的物理意义完全不同。我在DEAP项目里把9×9的矩阵当作灰度图卷积核大小取3×3感受野小第一批卷积核提取局部电极对之间的电压差后几层逐渐整合成全局激活模式。把32通道重排成一维序列然后用Conv1d模型看到的通道顺序是随意的除非通道排布本身就有语义顺序否则效果不如二维卷积。3.2 空间特征序列的构造LSTM遗忘门的输入到底长什么样2DCNN处理的是单帧空间分布但情绪是一个随时间展开的过程效价变化往往发生在刺激开始后1到3秒内。要让LSTM看到前面的空间特征同时让遗忘门知道该记住什么、该丢什么需要把2DCNN输出的一帧帧特征图展平成向量按时间顺序组成特征序列。这里直接回答一个常见疑问lstm遗忘门的输入数据在情绪识别任务里是一个向量该向量由当前时刻的空间特征图展平而来再加上上一时刻的隐状态和细胞状态共同拼接成遗忘门的输入。遗忘门不是直接看到原始脑电波形而是看到CNN抽象后的空间特征序列。序列的组织方式有两种先把每个一秒窗口内的128个时间点过2DCNN得到128个向量再作为128步的时间序列送给LSTM或者先用小卷积核跨时间压缩每4帧合并成1帧得到的特征序列更短训练速度更快F1分数通常也会更好。我后面给出的baseline采用第一种方式保留完整时间分辨率方便调试时做可视化。3.3 用PyTorch搭出2DCNN加LSTM的baseline模型结构按数据流方向拆成三个部分空间编码器、时序编码器和分类头。空间编码器用两个卷积层加批归一化把9×9单通道图变成64通道的小特征图再做全局平均池化每个时间步得到64维向量。时序编码器用单层LSTM隐藏单元128个取最后一个时间步的细胞状态送到全连接层做二分类。import torch import torch.nn as nn class EEGEmotionNet(nn.Module): def __init__(self, num_classes2): super().__init__() self.spatial nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d((1, 1)) ) self.lstm nn.LSTM(input_size64, hidden_size128, num_layers1, batch_firstTrue) self.classifier nn.Linear(128, num_classes) def forward(self, x): # x: (batch, time, 9, 9) b, t, h, w x.shape x x.view(b * t, 1, h, w) feat self.spatial(x).view(b, t, 64) out, _ self.lstm(feat) logits self.classifier(out[:, -1, :]) return logits注意view(b * t, 1, h, w)这一步把时间维临时压进批量维让2DCNN同时处理所有时间帧能显著提升GPU利用率。LSTM的input_size64正好对应空间编码器输出的通道数batch_firstTrue表示输入维度顺序是批量、时间、特征。分类时取最后一个时间步的输出对应的语义是模型看到整个1秒窗口后汇总情绪状态。3.3.1 前向传播的数据流与形状变化输入张量从(batch, 128, 9, 9)出发经过重排变成(batch×128, 1, 9, 9)两个卷积层把通道数从1依次升到32再到64全局池化消除空间维度得到(batch×128, 64)再恢复成(batch, 128, 64)。LSTM吃进这段序列内部把每个时间步的64维向量和上一步的隐状态拼接计算遗忘门、输入门、输出门最后一步输出128维隐状态经全连接层映射成两个类别的logits。整个前向过程中有两个容易出错的地方一是view之后忘记在时间维恢复形状导致LSTM输入的序列长度变成batch×128语义错乱二是AdaptiveAvgPool2d输出形状是(batch, 64, 1, 1)需要先view(b, t, 64)压缩掉多余维度否则全连接层报维度不匹配。3.3.2 单向LSTM和双向LSTM怎么选单向LSTM只按时间正方向读取特征当前时刻的隐状态只依赖过去信息。双向LSTM还额外按反方向读取一遍特征两个方向的特征拼接后送给分类器。用户的热搜词里出现了单向lstm足以说明这是个高频疑问在情绪识别这个任务里的答案取决于窗口的性质如果窗口切的是完整试次双向LSTM能看到未来帧效果略好如果要做在线识别当前时刻只能看到已发生的信号双向LSTM会引入未来信息测试时无法复现只能选单向。我的baseline用单向LSTM配合1秒窗口在线部署不牺牲预测质量。参数规模上双向LSTM隐状态维度翻倍全连接层输入从128变成256在小样本上容易过拟合DEAP个体特征泛化性反而下降。4. 训练配置、评估协议和一组能跑的默认参数4.1 训练集、验证集、测试集怎么切才不算数据泄露用滑窗生成样本后相邻窗口之间有重叠同一个试次的样本高度相关。随机划分数据集会把同一试次的前后窗口同时分到训练集和测试集模型其实记住了试次内的短时波动而不是学到的情绪模式分类准确率虚高换被试后性能断崖下跌。DEAP这类数据集的标准做法是把被试作为划分单元或者至少把试次作为划分单元早期DL论文里按试次随机切后来被反复证明是数据泄露。我推荐两种协议按任务目的选择任务要求的泛化对象如果是同一个人的新试次按试次划分保证测试集和训练集不共享同一试次任务要求的泛化对象如果是新用户按被试划分训练集用32个被试中的30个验证集1个测试集1个。按被试划分的结果更贴近真实指标但方差也更大不同被试测试准确率能差15个百分点这是DEAP本身噪声水平不一致导致的不代表模型失效。4.2 训练循环与损失曲线观察方法训练用交叉熵损失和AdamW优化器这里是代码框架from torch.utils.data import DataLoader, TensorDataset dataset TensorDataset( torch.FloatTensor(X_mapped), torch.LongTensor(y) ) loader DataLoader(dataset, batch_size32, shuffleTrue, drop_lastTrue) model EEGEmotionNet() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) criterion nn.CrossEntropyLoss()shuffleTrue只在训练集DataLoader里开验证和测试阶段必须固定为False否则每次评估顺序不同复现困难。drop_lastTrue避免最后一个batch样本数过少导致BatchNorm统计量漂移。StepLR每10轮把学习率减半DEAP这种中等规模数据几百MB学习率衰减过晚会看到loss在80轮以后震荡衰减后稳定得多。训练前先准备一个样本做一次前向和反向确认维度没问题再开始全量训练这一步能省大量调试时间。4.3 默认参数表与两个必调的LSTM超参一组能稳定跑到验证集75%到80%准确率的默认参数参考下表。这个参数组合在按下被试划分的协议下会有波动按试次划分的协议下稳定。参数项建议值调整方向说明输入窗口长度1秒128点加长到2秒通常有小幅提升但计算量翻倍卷积核数量32后接64提升到64后接128在小样本上过拟合LSTM隐藏单元128增大到256只提升高唤醒度类别LSTM层数12层以上召回率下降明显dropout0.5放在LSTM输出到全连接层之间学习率1e-3低于5e-4训练过慢高于2e-3训练不稳定batch大小3216以下收敛慢64以上显存压力大两个必调的LSTM超参依次是隐藏单元数和dropout位置。隐藏单元数量决定时序编码器能记住多久之前的空间特征DEAP单试次刺激时长为60秒情绪启动通常在刺激开始后的1到2秒内128就够用。dropout加在LSTM的输出层比加在输入层更有效输入层dropout会直接丢掉电极点的原始特征模型只能从残余通道推断信息预测方差变大。5. 验证模型学到了情绪特征一个15分钟的排查流程5.1 数据泄露自检打乱标签看Loss训练结束后最该先做的不是看测试集准确率而是做标签打乱实验。把训练样本的标签随机重排保持特征不动重新训练同样的模型观察loss是否还能降下来。如果打乱标签后loss依然显著下降说明模型从特征里学到了和标签无关的规律多半是样本划分有问题或者预处理阶段混入了试次级信息比如对整个数据集做了全局标准化。打乱标签后正常表现是loss长时间居高不下准确率停留在50%附近。5.2 逐层输出可视化定位网络失效点模型训练完成后如果准确率不达标先判断是2DCNN没学到空间模式还是LSTM没学到时序依赖。把测试数据分批输入在feat变量处加一行钩子收集输出计算每个时间步特征向量的方差方差很小说明2DCNN对不同电极位置的激活趋于一致。再用numpy对每个类别做平均特征可视化效价高和效价低的样本在LSTM最后一步隐状态上的欧氏距离如果小于0.1说明时序编码器没有区分能力这时优先调整窗口长度而不是加深网络。5.3 用留一被试法验证泛化性最后我在项目中习惯做一次留一被试交叉验证留出的被试不参与任何训练步骤包括标准化参数的计算都不能用必须只靠训练集拟合。记录每个被试的准确率和加权F1观察异常值。某个被试的准确率远低于平均水平时检查该被试原始信号的基线是否包含大幅漂移常见做法是对该被试信号做一次高通滤波再加到预处理流程里。这轮验证完成后模型从同被试新试次迁移到新被试的能力就有了一组可信的边界后续测试集上的数值得出的结论才被认为是有效的。本文还有配套的精品资源点击获取