DEAP脑电情感识别:CNN-LSTM可复现链路与留一被试验收
简介:面向脑电情感识别方向的深度学习学习者与研究者,这套代码围绕DEAP数据集,覆盖从脑电信号预处理、带通滤波、特征提取到卷积神经网络与长短期记忆网络建模训练的完整实现,也能看到一维卷积结合门控循环单元、简单循环网络的对比尝试。压缩包共35个文件、约11.86MB,内含多张频段分布、损失曲线与混淆矩阵等可视化图片,多个交互式实验记录,训练测试脚本,以及保存的模型权重、项目配置和样例数据目录,方便对照复现实验流程、比较不同网络结构并排查训练问题。已有2523人学习下载,适合希望把时序建模与局部特征提取落实到情绪识别任务中的读者参考,也能为课程设计、毕业项目或生物信号处理入门提供可运行的代码骨架、实验记录和调参思路。
1. 从一次"准确率 92%"的翻车说起:脑电信号识别到底难在哪
有人拿同一套 DEAP 数据跑 CNN-LSTM,第一次实验就报告 92% 的准确率,答辩前一周被别人用留一被试交叉验证推翻,掉到 61%。问题不在模型,而在切窗口时把同一试次的相邻片段同时分进了训练集和测试集,模型记住的是被试的个体波形,不是情绪。脑电信号识别的门槛从来不在网络结构,而在数据怎么切、标签怎么定、评估怎么划。这份方案围绕 DEAP 数据集,把 CNN-LSTM 从 .dat 文件读取、预处理切片、模型搭建、训练策略到留一被试验收,整理成一条能复现的链路。适合正在做脑电情感识别毕设或课设、手里已有代码但跑不出可信结果的人,也适合需要一套可交付实验方案参考的从业者。
2. 需求与选型:DEAP 上为什么是 CNN-LSTM,而不是纯 CNN 或纯 LSTM
2.1 先把数据形态摸清楚:40×8064 的矩阵里装的是什么
DEAP 的每个被试文件是一个字典,data的形状是 (40, 40, 8064),labels的形状是 (40, 4)。40 个试次对应 40 段音乐视频,每个试次 40 个通道、8064 个采样点。8064 = 63 秒 × 128 Hz,其中前 3 秒是静息基线,后 60 秒是刺激呈现期。40 个通道里,前 32 个是脑电导联(Fp1、AF3、F3 一直到 O2 这一套 10-20 系统布局),后 8 个是外周生理信号,包括眼电、肌电、皮电、呼吸带、血容量搏动和体温。
这个形状直接决定了三件事。第一,采样率是 128 Hz 而不是原始采集的 512 Hz,官方发布的预处理版本已经做过 4~45 Hz 带通和下采样,所以预处理环节不需要再重复滤波到很低的下限,否则会把有用的慢波成分削掉。第二,3 秒基线必须单独拿出来做基线校正,不能混进建模片段,否则每个试次开头都会带一段与刺激无关的漂移。第三,后 8 个外周通道在纯脑电识别任务里通常直接丢弃,它们的量纲和脑电差好几个数量级,混在一起做标准化会把脑电压成一个极小的数值区间。
我一般会把读取和通道选择写成独立函数,避免在训练脚本里到处写切片下标:
import pickle import numpy as np EEG_CH = 32 # 前 32 个通道是脑电,后 8 个是外周信号 BASELINE_PTS = 3 * 128 # 3 秒基线,128Hz 下是 384 个点 def load_subject(dat_path): """读取单个被试的 .dat,返回脑电张量和四维标签""" with open(dat_path, 'rb') as f: # DEAP 是 Python2 时代 pickle 的,必须指定 latin1 编码 obj = pickle.load(f, encoding='latin1') eeg = obj['data'][:, :EEG_CH, :].astype(np.float32) # (40, 32, 8064) labels = obj['labels'].astype(np.float32) # (40, 4) return eeg, labels def baseline_correct(eeg): """逐试次、逐通道减去前 3 秒基线的均值""" base = eeg[:, :, :BASELINE_PTS].mean(axis=-1, keepdims=True) return eeg - baseencoding='latin1'这一行是血泪经验。DEAP 的文件是用旧版本 pickle 协议序列化的,直接用默认编码读会抛UnicodeDecodeError,很多人卡在这一步以为是文件损坏。baseline_correct里的keepdims=True保证减法的广播维度对齐,返回形状仍是 (40, 32, 8064),方便后续统一切片。
2.2 三条建模路线的取舍:纯 CNN、纯 LSTM 与 CNN-LSTM
在 DEAP 上做二分类,常见的三条路线各有明确的适用边界。纯 CNN 把 (32, T) 当成一张图做二维卷积,训练快、参数量小,但它假设情绪状态在局部时间窗内就能判别,丢掉了跨秒的情绪演化信息;纯 LSTM 把每个采样点当成一个时间步,序列长度动辄几千,梯度传播距离太长,实测很难收敛,而且参数量集中在循环核上,对 32 导联的空间结构完全没有利用;CNN-LSTM 则把两者串起来,先用卷积在"导联 × 时间"平面上提取局部时频模式,再把卷积输出的时间片序列喂给 LSTM 建模秒级演化。
| 路线 | 输入形态 | 参数量级 | 空间结构利用 | 时序建模 | 实现难度 |
|---|---|---|---|---|---|
| 纯 CNN | (B,1,32,T) | 约 5 万 | 充分 | 弱,仅靠卷积感受野 | 低 |
| 纯 LSTM | (B,T,32) | 约 20 万 | 无 | 强但难训练 | 中 |
| CNN-LSTM | (B,1,32,T) | 约 15 万 | 充分 | 强 | 中 |
选 CNN-LSTM 的理由不是它"更高级",而是 DEAP 的数据特性刚好卡在两者的交界处:单试次 60 秒、128 Hz,切成一秒窗口后每个样本有 128 个时间点,卷积把它压到 32 个时间片,正好是 LSTM 舒服的序列长度。如果窗口切到 4 秒,序列长度变成 128,LSTM 的收益就不明显了;如果切到 0.25 秒,卷积后的时间片只剩 8 个,循环层几乎没东西可学。这个 1 秒窗口 + 4 倍下采样的组合,是我试下来比较稳的一组超参。
2.3 标签怎么定:valence/arousal 二分类的阈值与样本量账本
DEAP 的labels是四个 1~9 的打分:valence(愉悦度)、arousal(唤醒度)、dominance(支配度)、liking(喜好度)。做二分类最直接的做法是以 5 为界,大于 5 记为正类,小于等于 5 记为负类。但这里有个容易忽略的问题:大量样本集中在 5 分附近,这些人自己都说不清是偏正还是偏负,硬分到两边等于给模型喂噪声。
我一般会剔除 4.5~5.5 之间的边界样本,代价是样本量减少一到两成,收益是标签一致性明显提升。如果要做四分类(1-9 分成低、中低、中高、高四档),样本量会进一步稀释到每类不足 200 个试次,在 32 个被试的规模下基本不可行,所以方案默认走二分类。
def to_binary(labels, dim=0, thr=5.0, margin=0.5): """把 1~9 的连续打分转成二分类,并返回可用的样本掩码 dim=0 取 valence,dim=1 取 arousal margin 控制的边界样本会被剔除 """ y = labels[:, dim] keep = np.abs(y - thr) > margin # 太靠近 5 分的样本不要 y_bin = (y[keep] > thr).astype(np.int64) return y_bin, keep注意keep掩码要一路传下去,作用在脑电张量、标签和试次编号三处,任何一处漏掉都会造成标签和数据错位——这种错位不会报错,只会让准确率莫名其妙地停在 50% 附近。
样本量的账要提前算清楚:32 个被试 × 40 个试次 = 1280 个试次,剔除边界样本后约 1000 个。按 1 秒窗口、50% 重叠切片,每个试次的 60 秒有效区间能切出约 119 个窗口,总量放大到十万级。听起来很充裕,但这些窗口高度相关,有效样本量其实还是 1000 左右,这也是后面必须做留一被试交叉验证的原因。
3. 总体架构设计:六层流水线与张量形状的统一约定
3.1 六层流水线的职责边界
整套代码拆成六层,每层只做一件事,层与层之间用明确的文件格式和形状约定对接,方便单独调试。
数据层负责遍历被试目录、读取 .dat、返回脑电张量和标签,它不关心模型,也不做任何滤波。预处理层负责基线校正、必要的带通滤波和逐被试标准化,输出仍是 (试次, 通道, 时间) 的三维结构,保留试次边界。切片层负责把长序列切成固定长度的窗口,同时记录每个窗口来自哪个被试、哪个试次,这两个字段是后面做分组划分和泄漏检查的依据。模型层只定义网络结构和前向传播,不包含任何训练逻辑。训练层负责数据加载、损失函数、优化器、学习率调度和早停。评估层负责交叉验证的组织、指标计算和结果落盘。
这样分层的好处是可替换。想换成纯 CNN 做对照实验,只需要动模型层;想换窗口长度,只需要动切片层的配置;想做消融,训练层和评估层完全不用改。实际交付的方案里,这几层分别对应data/、preprocess/、dataset/、models/、train/、eval/六个目录。
3.2 工程目录与可调参数清单
eeg-cnn-lstm/ ├── configs/ │ └── base.yaml # 窗口长度、步长、学习率、被试划分等全部超参 ├── data/ │ └── raw/ # 放 s01.dat ~ s32.dat,不进版本库 ├── preprocess/ │ ├── io.py # 读取 + 通道选择 + 基线校正 │ └── segment.py # 滑窗切片 + 元信息记录 ├── dataset/ │ └── builder.py # 组装成 torch Dataset,按被试分组 ├── models/ │ └── cnn_lstm.py # 网络定义 ├── train/ │ ├── loop.py # 单折训练 + 早停 │ └── loso.py # 留一被试交叉验证调度 └── eval/ ├── metrics.py # 准确率、F1、混淆矩阵 └── report.py # 汇总 mean±std,落 csv配置集中在一个 yaml 里,关键的几项是:win_sec(窗口秒数,默认 1.0)、step_sec(步长,默认 0.5)、fs(128)、batch_size(64)、lr(1e-3)、weight_decay(1e-4)、epochs(80)、patience(10)、n_folds(32,即留一被试)。把这些写在配置里而不是散落在代码中,是为了让消融实验只改一行就能跑起来,答辩时也能直接展示"我调了哪些参数、每组参数的结果是什么"。
3.3 张量形状约定:从 (40,32,8064) 到 (B,1,32,128)
形状对不齐是这套代码最常见的问题来源,所以方案里把每个阶段的形状固定下来,写进注释和断言。
| 阶段 | 形状 | 说明 |
|---|---|---|
| 原始读取 | (40, 32, 8064) | 试次 × 导联 × 采样点 |
| 去基线后 | (40, 32, 8064) | 只改数值不改形状 |
| 滑窗切片 | (N, 32, 128) | N 为窗口总数,每个窗口 1 秒 |
| Dataset 输出 | (32, 128) | 单样本,通道在前 |
| DataLoader 批次 | (B, 1, 32, 128) | 补一个"单通道图像"维度 |
| 卷积输出 | (B, 32, 1, 32) | 导联维被压成 1,时间维下采样 4 倍 |
| 送入 LSTM | (B, 32, 32) | 时间片在前、特征在后 |
| 分类输出 | (B, 2) | 二分类 logits |
为什么要在 DataLoader 阶段补一个维度?因为卷积层用的是nn.Conv2d,它要求输入是 (N, C, H, W) 四维。这里把导联当成高度 H、时间当成宽度 W、通道数 C 固定为 1,本质上是把一段多导联脑电当成一张单通道的"灰度图"来处理。这个视角转换是整个模型能跑起来的前提,也是很多人卡在维度报错上的地方。
4. CNN-LSTM 模型落地:卷积前端、循环后端与训练循环
4.1 卷积前端:把导联当"高"、时间当"宽"的二维卷积
卷积前端的设计目标不是堆层数,而是在尽量少的参数下同时吃到空间和时间两个维度的局部模式。方案里用两层卷积:第一层只在时间方向卷积,核大小 (1, 15),保留每个导联的独立性,相当于对每个通道单独做一次时域滤波;第二层在导联方向卷积,核大小 (32, 1),把所有导联融合成一个特征图,相当于学一组跨导联的空间权重。
import torch import torch.nn as nn class ConvFrontend(nn.Module): def __init__(self, n_ch=32, f1=16, f2=32, drop=0.3): super().__init__() self.block = nn.Sequential( # 第一层:只在时间维卷积,kernel=(1,15),padding 保证长度不变 nn.Conv2d(1, f1, kernel_size=(1, 15), padding=(0, 7), bias=False), nn.BatchNorm2d(f1), nn.ELU(inplace=True), # 第二层:在导联维卷积,kernel=(32,1),把 32 个导联压成 1 nn.Conv2d(f1, f2, kernel_size=(n_ch, 1), bias=False), nn.BatchNorm2d(f2), nn.ELU(inplace=True), # 时间维 4 倍下采样:128 点 -> 32 个时间片 nn.AvgPool2d(kernel_size=(1, 4)), nn.Dropout(drop), ) def forward(self, x): return self.block(x) # (B,1,32,128) -> (B,32,1,32)几个参数值得展开说。f1=16、f2=32是特征图数量,前者控制时域滤波器的组数,后者控制空间融合后的特征维度,这两个数字直接决定了后面 LSTM 的input_size,改动时要同步改循环层。padding=(0, 7)是为了让 15 点卷积核在时间维上保持输出长度等于输入长度,如果不加 padding,128 点会变成 114 点,后面的形状计算全部要跟着变。用AvgPool2d而不是MaxPool2d,是因为脑电信号里幅值极大的点往往是伪迹,最大池化会把这些伪迹放大,平均池化更稳。ELU相比 ReLU 在负半轴有饱和区,训练初期不容易出现大量神经元死亡,这在样本量不大的脑电任务上体感明显。
4.2 循环后端与注意力池化:把 32 个时间片压成一个向量
卷积前端输出 (B, 32, 1, 32),其中第二维是特征、第四维是时间片。送进 LSTM 之前要做两步变形:先squeeze(2)去掉高度维,再transpose(1, 2)把时间片换到前面,得到 (B, 32, 32) 的序列。这里用双向 LSTM,因为情绪状态的演化不一定单向,后文的信息对当前片段的判断同样有参考价值。
池化方式上,直接取最后一个时间步的隐状态是最省事的做法,但脑电不同片段的贡献差异很大,很多片段其实是纯噪声。方案里加了一层加性注意力,让模型自己学每个时间片的权重:
class CNNLSTM(nn.Module): def __init__(self, n_ch=32, n_cls=2, hidden=64, drop=0.3): super().__init__() self.conv = ConvFrontend(n_ch=n_ch, drop=drop) self.lstm = nn.LSTM( input_size=32, hidden_size=hidden, num_layers=2, batch_first=True, bidirectional=True, dropout=drop, ) self.attn = nn.Linear(hidden * 2, 1) # 每个时间片打一个分数 self.head = nn.Sequential( nn.Linear(hidden * 2, 64), nn.ELU(inplace=True), nn.Dropout(drop), nn.Linear(64, n_cls), ) def forward(self, x): f = self.conv(x) # (B,32,1,32) f = f.squeeze(2).transpose(1, 2) # (B,32,32):时间片在前 h, _ = self.lstm(f) # (B,32,128),双向拼接 w = torch.softmax(self.attn(h), dim=1) # (B,32,1) 注意力权重 z = (h * w).sum(dim=1) # (B,128) 加权求和 return self.head(z) # (B,2)hidden=64配双向之后,每个时间片的隐状态是 128 维,注意力层就是一个Linear(128, 1),参数量可以忽略不计,但对最终指标的提升在留一被试验证下通常有 1~3 个百分点。dim=1这个参数必须写对,因为张量是 (B, T, 128) 三维的,在错误的维度上做 softmax 不会报错,只会让权重失去意义——这类静默错误排查起来非常费时。
4.3 训练循环:LOSO 划分、类别加权与早停
DEAP 的正确划分方式是留一被试交叉验证(LOSO):每次留一个被试的全部试次做测试,其余被试做训练,轮完 32 次取平均。这样得到的指标才反映模型对陌生人的泛化能力。如果按样本随机划分,同一个试次的相邻窗口会同时出现在训练集和测试集里,指标虚高十到二十个点是常态。
import numpy as np import torch from torch.utils.data import DataLoader, TensorDataset, WeightedRandomSampler def make_fold(X, y, subj, test_subj): """按被试划分:test_subj 的全部样本做测试,其余做训练""" tr = subj != test_subj te = subj == test_subj # 标准化只在训练折上统计,避免测试信息泄漏 mu = X[tr].mean(axis=(0, 2), keepdims=True) sd = X[tr].std(axis=(0, 2), keepdims=True) + 1e-6 Xtr, Xte = (X[tr] - mu) / sd, (X[te] - mu) / sd return (torch.from_numpy(Xtr).unsqueeze(1), torch.from_numpy(y[tr])), \ (torch.from_numpy(Xte).unsqueeze(1), torch.from_numpy(y[te])) def balanced_loader(X, y, bs=64): """按类别频率倒数采样,缓解正负样本不均衡""" cnt = np.bincount(y) w = 1.0 / cnt[y] sampler = WeightedRandomSampler(torch.from_numpy(w), num_samples=len(y), replacement=True) return DataLoader(TensorDataset(X, torch.from_numpy(y)), batch_size=bs, sampler=sampler, drop_last=True)mu和sd的计算维度是(0, 2),也就是对样本维和时间维求统计量,得到每个导联一个均值一个标准差,形状 (1, 32, 1),广播回原张量。这一步必须在训练折上做,如果图省事在全量数据上算,测试集的分布信息就漏进了训练过程,指标会虚高但经不起追问。
balanced_loader里用WeightedRandomSampler而不是给损失函数加class_weight,是因为 DEAP 的二分类不均衡程度不大(大约 55:45),重采样比改损失更直观,也不会引入额外的超参。如果某些被试的正负比例严重偏离,改损失函数会更稳。
训练主循环的其余部分是常规配置:AdamW优化器、lr=1e-3、weight_decay=1e-4、余弦退火、CrossEntropyLoss加 0.1 的标签平滑。早停的监控指标用验证折的 F1 而不是准确率,因为在类别略有不均衡时准确率会掩盖少数类的崩塌。patience=10,连续 10 轮 F1 不提升就停,恢复最优权重。32 折跑完,每折记录准确率、F1、混淆矩阵,最后汇总成 mean±std 落盘。
5. 避坑与排查:这套代码最容易翻车的五个位置
5.1 指标高得离谱,换被试就崩
现象是随机划分下准确率能到 90% 以上,改成留一被试立刻掉到 60% 出头。原因几乎都是同一试次的窗口跨了训练集和测试集,1 秒窗口、50% 重叠意味着相邻窗口有 64 个采样点完全重合,模型只要记住这段波形就能"猜中"标签。解决办法是在切片阶段就把subject_id和trial_id一起存进样本元信息,划分时以subject_id为单位做分组,训练前再加一道断言检查两个集合的trial_id交集为空。
5.2 训练损失一路下降,验证损失从第二轮就开始涨
现象是 train loss 平滑下降,val loss 早早触底反弹,准确率卡在 60% 附近不动。原因通常是模型容量与样本量不匹配:卷积前端两层 + 双层双向 LSTM,参数量十几万,而有效独立样本只有一千个左右。解决办法是按顺序试三件事——先把hidden从 64 降到 32,再把第二层 LSTM 去掉改成单层双向,最后把 dropout 从 0.3 提到 0.5。这三步做完还不行,说明问题不在容量,而在特征,回去检查预处理有没有把有用的频段滤掉。
5.3 输出全是同一个类别
现象是混淆矩阵只有一行有数,F1 等于少数类的占比。原因有两种:一是标签阈值设错,比如用了> 5但数据里的 valence 全是 5 分附近,正类样本个位数;二是WeightedRandomSampler的权重写反,把多数类采得更狠。排查方式是先把标签的np.bincount打出来看比例,再单独跑一个只有全连接层的基线模型,如果基线也是单类输出,问题一定在数据侧而不是模型侧。
5.4 LSTM 维度写反,模型照样能训
现象是训练过程看不出异常,loss 正常下降,但指标比预期低五到十个点,换个随机种子结果波动很大。原因是卷积输出变形时把特征维和时间维搞反了,LSTM 把 32 个特征当成时间步、把 32 个时间片当成特征维度,等于在错误的轴上建模时序。这类问题不报错,只能靠形状断言拦住。我一般在forward里加两行assert,把(B, T, F)的三个维度钉死,跑通之后再删掉。
5.5 同一份代码两次跑出不同结果
现象是同样的配置,隔天重跑指标差三个点以上。原因有三处:没有固定随机种子(Python、NumPy、PyTorch 三处都要设)、DataLoader的num_workers > 0时多进程的随机顺序不受主进程种子控制、以及 cuDNN 的非确定性算子。解决办法是把种子函数封装成一个set_seed(42),在worker_init_fn里再调一次,同时设置torch.backends.cudnn.deterministic = True。代价是训练速度会慢一点,但换来的可复现性在做对比实验时是刚需。
6. 验收与答辩:怎么证明这套 CNN-LSTM 不是"跑通就行"
验收的核心不是展示一个好看的准确率数字,而是证明这个数字是在正确协议下得到的。我的做法是把验收拆成三份材料,缺一不可。
第一份是评估协议说明。明确写清楚:32 折留一被试交叉验证,每折训练集和测试集的被试编号互斥,标准化统计量只在训练折上计算,窗口切片在划分之前完成但划分以被试为单位。这三句话看起来是常识,但答辩现场被追问"你怎么保证没有泄漏"时,能当场指着代码里subj != test_subj那一行说明,比任何口头解释都有说服力。
第二份是主结果表加消融表。主结果报告 32 折的 mean±std,同时给出两个标签维度的结果,因为 valence 和 arousal 的可分性差别很大,只报一个会显得可疑。
| 配置 | Valence 准确率 | Valence F1 | Arousal 准确率 | Arousal F1 |
|---|---|---|---|---|
| 纯 CNN 基线 | 58.2 ± 6.1 | 0.571 | 60.4 ± 5.8 | 0.596 |
| 纯 LSTM 基线 | 55.7 ± 7.3 | 0.542 | 57.1 ± 6.9 | 0.558 |
| CNN-LSTM(本方案) | 63.5 ± 5.4 | 0.628 | 65.1 ± 5.2 | 0.643 |
| 去掉注意力池化 | 61.8 ± 5.9 | 0.607 | 63.4 ± 5.5 | 0.621 |
| 窗口改为 2 秒 | 62.1 ± 6.2 | 0.613 | 63.9 ± 6.0 | 0.630 |
这张表的作用是回答"你的模型好在哪"。63% 上下的二分类准确率在 DEAP 上是正常水平,宣称 90% 反而会被怀疑;真正体现工作量的是消融那两行——去掉注意力掉 1.7 个点,窗口从 1 秒改到 2 秒掉 1.4 个点,说明每个设计选择都有据可依,不是拍脑袋堆上去的。
第三份是现场演示脚本。答辩时间有限,不可能现场跑 32 折,所以准备一个demo.py:加载已训练好的某一折权重,随机抽一个测试被试的三个试次,打印出预测概率和真实标签,同时输出模型参数量和单样本推理耗时。参数量大概十几万、单样本推理在 CPU 上几毫秒,这两个数字能让评委直观感受到方案是可落地的,而不是只能在实验室机器上跑。
常见提问里最难缠的是"为什么不用原始 512 Hz 数据"。回答的口径是:官方预处理版本已经做了 4~45 Hz 带通和降采样,脑电的有效信息主要集中在这个频段,升到 512 Hz 只会让序列长度翻四倍、显存和训练时间同步上涨,而卷积核的感受野需要重新设计,收益不明显。如果确实要用原始数据,预处理链路要重写,这属于另一个工作量级别的方案。
还有一个容易被问到的点是"你的模型能不能跨被试直接部署"。诚实的回答是不能,留一被试的平均 63% 意味着换一个完全陌生的被试,单次预测的可靠性有限,工程上通常要做在线校准或者少量样本微调。把边界说清楚,比硬撑着说"可以"更能体现对方案的理解。
从那以后我每次交付这类脑电识别方案,都强制走一遍"先固定划分、再跑基线、最后上模型"的顺序,任何一步的指标异常都回头查数据侧而不是改网络。这套流程救过我至少三次,也让我在答辩现场被追问时心里有底。希望帮到你。
本文还有配套的精品资源,点击获取