☰
DEAP脑电情绪识别:CNN-LSTM跨被试分类实战
2026/11/2 2:14:33 网站建设 项目流程

简介:面向脑电情感识别学习者与深度学习研究者的CNN-LSTM代码包,围绕DEAP数据集展示从信号预处理到情绪分类的完整实现思路。内容涉及脑电信号去噪、带通滤波、特征提取,以及一维卷积结合LSTM、GRU、SimpleRNN的对比实验,并配有训练测试脚本和准确率、损失、混淆矩阵等结果图,便于理解时空特征建模与模型评估流程。压缩包共35个文件,以ipynb实验笔记、png可视化图、py脚本和xml工程配置为主,另含h5、npy模型权重,整体约11.86MB,目录组织兼顾代码复现与结果查看。已有2522人学习下载,适合作为课程设计、毕业设计或脑电情绪识别入门项目的参考底稿,也可用于对照调整网络结构、复现训练过程,并排查数据预处理和模型调参中的常见问题。对希望快速搭建基线、比较不同循环网络效果的自学者也较为友好。

1. DEAP 数据集摆在那里,这套 CNN-LSTM 代码解决的是“换个人还能不能认”的问题

DEAP 数据集一解压,32 个.dat、每人 40 个 trial、32 个导联、8064 个采样点,很多人第一反应是丢个 CNN 进去,训练集 99%、换个人直接掉到 50%。这份脑电信号识别代码就是冲着这个落差来的:用 CNN 在导联维度上提空间特征,再用 LSTM 把切窗后的时间步串起来,做 valence / arousal 的二分类,同时把数据划分、归一化、评估口径都写死在流程里,避免“自己骗自己”的高分。

它适合三类人:刚接触 DEAP、想找个能跑通的基线的人;做完纯 CNN 想加时序建模做对比实验的人;以及要把情绪识别塞进课程设计、毕设或者一个小 demo 的人。代码结构不花哨,一个预处理脚本、一个模型文件、一个训练入口,读一遍就能知道每个张量从哪来到哪去。下面按“项目长什么样 → 数据怎么进 → 模型怎么搭 → 哪里会翻车 → 怎么改成自己的”这条线拆。

2. 项目全景与技术选型:CNN 提空间特征、LSTM 管时序的职责划分

先把这个项目的骨架摆清楚。它不是一个“训练完就完事”的脚本集合,而是一条四段式流水线:原始.dat→ 滑窗样本 → 模型前向 → 按被试评估。每一段都有独立的产物落盘,好处是调模型的时候不用反复解码 pickle,坏处是中间文件占了几个 G,第一次跑别忘了看一眼磁盘。

技术栈方面没什么意外的:Python 3.9 起步,PyTorch 做模型和训练(1.10 以上就行,nn.LSTM的batch_first参数行为在 1.x 各版本一致,不用太纠结),numpy 处理数组,scikit-learn 只用来算混淆矩阵和折外指标,pyyaml 管配置,tqdm 看进度。没有 TensorFlow 版本,也没做多卡,单卡 8G 显存足够——32 导联 128 采样点的单样本才 4096 个 float,batch 开到 256 也不到 1G。

2.1 一次前向传播要走完的四段路

从拿到一个 batch 开始算:

  1. 输入张量形状是(B, 32, 128),32 是导联数,128 是 1 秒窗在 128Hz 下的采样点数。注意这里是“通道在前”,跟图像那种(B, C, H, W)的思路一致,所以卷积用Conv1d而不是Conv2d——时间维是卷积的宽度,导联维是通道。
  2. 两层Conv1d加池化,把 128 个时间点压到 32 个,通道数从 32 抬到 128。这一步在做的事是:在相邻导联之间找共现模式,比如额区几个电极同时出现的慢波成分。
  3. transpose(1, 2)之后变成(B, 32, 128),语义从“通道×时间”翻成“时间步×特征”,正好是 LSTM 要的形状。双向 LSTM 走一遍,取最后时间步的隐藏状态。
  4. 两层全连接出 2 类 logits。

这个职责划分是有讲究的:卷积核在时间轴上滑动时看的是局部波形,感受野有限,长于 1 秒的情绪唤起趋势它抓不到;LSTM 在池化后的 32 个时间步上跑,等效感受野覆盖整段窗口,补的正是这块。反过来,如果直接把(B, 32, 128)喂 LSTM,序列长度 128、特征维 32,参数量翻几倍不说,还容易在 40 个 trial 的小数据上过拟合。

2.2 目录结构与技术栈:每个文件负责什么

deap-cnn-lstm/ ├── configs/ │ └── base.yaml # 窗口长度、步长、被试划分、训练超参 ├── data/ │ ├── raw/ # s01.dat ... s32.dat,原始文件不动 │ └── processed/ # 滑窗后的 X.npy / y.npy / subject_ids.npy ├── src/ │ ├── preprocess.py # 解码 + 滑窗 + 归一化 + 落盘 │ ├── dataset.py # Dataset / DataLoader 封装 │ ├── models/ │ │ ├── blocks.py # 卷积块、注意力池化等可复用组件 │ │ └── cnn_lstm.py # 主模型 │ ├── train.py # 训练入口,含早停与 checkpoint │ └── evaluate.py # 留一被试评估、混淆矩阵 ├── scripts/ │ ├── build_dataset.sh │ └── train.sh └── requirements.txt
文件职责改动频率
preprocess.py唯一接触原始.dat的地方换数据集时才动
dataset.py把 npy 包成 Dataset,负责划分换评估协议时动
cnn_lstm.py网络结构做对比实验时动得最多
train.py优化器、学习率、早停调参阶段动
evaluate.py指标口径基本不动

这种拆法的实际收益是:做消融实验(比如把 LSTM 换成 GRU、把双向改单向)时,只需要动cnn_lstm.py一个文件,预处理产物复用,一轮实验从“半小时预处理 + 五分钟训练”变成“五分钟训练”。我见过不少人把所有逻辑塞进一个main.py,改一行模型要重跑一遍解码,一天下来跑不了几组对比——血泪经验:预处理和训练一定要分家。

2.3 为什么不是纯 CNN 或者纯 LSTM

纯 CNN 的基线在这个数据集上并不难看,很多人能刷到 60% 出头(二分类,随机是 50%),但再往上就很吃力。原因是卷积池化之后时间维被压得太狠,1 秒窗压到 32 步还行,2 秒窗压到 8 步就基本丢了时序结构。

纯 LSTM 反过来,把 128 个时间点当序列,特征维只有 32,模型容量全堆在循环层上,小样本下 loss 曲线抖得厉害,学到的多半是“这个被试的基线漂移长什么样”。

CNN-LSTM 的组合在这类“多通道生理信号 + 短窗分类”的场景里属于常见做法,本质是先用卷积做一次带先验的降采样(相邻采样点强相关,用池化换感受野比让 LSTM 自己学划算),再让循环层在压缩后的表示上建长程依赖。参数量的账也划算:LSTM 的输入维从 32 涨到 128,但因为序列长度从 128 降到 32,实际计算量反而下来了。

提示:如果你的窗长只有 0.5 秒(64 个采样点),池化两层之后就剩 16 步,LSTM 的收益会很有限,这时候优先考虑加深卷积或者换注意力池化,别硬套 CNN-LSTM。

3. 数据管线拆解:从 .dat 原始文件到 (N, 32, 128) 张量的四步

数据这一层是这个项目里最容易出问题的地方,也是“为什么我的准确率跟别人差 20 个点”这类问题的根源。四个步骤:解码、滑窗、标签与归一化、按被试划分,每一步都有坑。

3.1 读原始文件:pickle 的 latin1 与键名

DEAP 的.dat本质上是一个 pickle 序列化的字典,而且是 Python 2 时代存的。Python 3 直接pickle.load会抛UnicodeDecodeError,因为里面存了字节串形式的字符串;另外键名是data和labels,data的形状是(40, 40, 8064)——前 40 是 trial 数,中间 40 是通道数(前 32 个是 EEG,后面是外周生理信号和状态位),最后 8064 是 63 秒 × 128Hz。

import pickle import numpy as np EEG_CHANNELS = list(range(32)) # 只取前 32 个导联,其余是 GSR/PPG/EOG 等 FS = 128 # 数据集已降采样到 128Hz TRIAL_LEN = 8064 # 63s * 128Hz,前 3s 是基线 def load_subject(dat_path): """读单个被试的 .dat,返回 EEG 与四类标签。""" with open(dat_path, "rb") as f: # 关键:encoding="latin1",否则 py2 存的字节串在 py3 下解不出来 record = pickle.load(f, encoding="latin1") eeg = record["data"][:, EEG_CHANNELS, :].astype(np.float32) # (40, 32, 8064) labels = record["labels"].astype(np.float32) # (40, 4) return eeg, labels

record["data"]是 float64,转成 float32 之后内存直接减半——32 个被试全读进来是32×40×32×8064×4字节,差不多 1.3G,float64 就是 2.6G,笔记本上很容易直接 OOM。record["labels"]的四列依次是 valence、arousal、dominance、liking,取值 1~9,这个项目默认只取第一列做二分类,想换成唤醒度就把索引用 1。

还有一个不显眼但会出事的点:前面 3 秒是基线静息段,一般不参与训练。滑窗的时候从3*FS开始,也就是跳过前 384 个采样点,否则样本里混进静息态,模型学到的是“这段没刺激”,指标会虚高。

3.2 滑窗切分:窗口长度和步长怎么定

40 个 trial 太少了,直接一个 trial 一个样本,训出来的模型基本是记忆。常规做法是滑窗增广:1 秒窗、0.5 秒步长,每个 trial 能切出 120 个窗左右,32 个被试合计约 15 万样本。

WIN = 128 # 1s @ 128Hz STRIDE = 64 # 0.5s,50% 重叠 BASELINE = 3 * FS def sliding_window(eeg, labels, win=WIN, stride=STRIDE): """把 (40, 32, 8064) 切成 (N, 32, win) 的样本堆。""" trials, ch, total = eeg.shape xs, ys = [], [] for i in range(trials): # valence 打分 >= 5 记作高分组,这是最常用的二值化口径 y = int(labels[i, 0] >= 5) for start in range(BASELINE, total - win + 1, stride): xs.append(eeg[i, :, start:start + win]) ys.append(y) return np.stack(xs), np.array(ys, dtype=np.int64)

参数上的取舍:WIN太短(0.5 秒)时频域分辨率不够,情绪相关的慢波成分展不开;太长(4 秒以上)时一个窗里混了多种情绪状态,标签噪声上来。1~2 秒是这个数据集上比较稳的区间,STRIDE取WIN的一半是惯用值,重叠 50% 既增广又不会让相邻样本几乎相同。

注意:重叠滑窗会让训练集和验证集里的样本高度相关。如果后面按样本随机划分,等同于把同一段信号拆到两边,指标会虚高十几个点。划分必须按 trial 或被试做,见 3.4。

3.3 标签二值化与标准化:阈值 5 的取舍

标签二值化用>= 5是最常见的口径,但这里有个隐形的类不平衡问题:DEAP 里 valence 高分的人数分布并不均匀,某些被试 40 个 trial 里只有 12 个高分,切完窗之后正负比能到 1:3。如果不管,模型会倾向于全预测多数类,准确率看着有 65%,实际上召回率惨不忍睹。

标准化的关键是不能跨被试算均值和方差。每个被试的电极阻抗、皮肤电位、基线漂移都不一样,用全局统计量做 z-score,等于把被试身份信息泄漏进了特征里——训练集里见过这个被试,验证时自然分得开。正确做法是每个被试、每个导联单独算:

def normalize_per_subject(x): """x: (N, 32, win),按导联维度做 z-score,逐被试调用。""" mu = x.mean(axis=(0, 2), keepdims=True) # (1, 32, 1) sd = x.std(axis=(0, 2), keepdims=True) + 1e-8 return (x - mu) / sd

keepdims=True是为了让广播维度对齐,1e-8防止某些导联方差为 0(比如接触不良导致整段是常数)时除零。这一步必须在滑窗之后、拼接之前做,先拼再归一化就退回全局统计量了。

3.4 按被试划分数据集:先把泄漏的口子堵上

评估协议决定了你报出来的数字有没有意义。这个项目默认用留一被试交叉验证(leave-one-subject-out):32 个被试,每次留 1 个当测试集,剩下 31 个训练,跑 32 轮取平均。这是跨被试场景下最严格也最诚实的口径。

def save_processed(out_dir, all_x, all_y, all_sid): """落盘三件套,后面的训练脚本只认这三个文件。""" np.save(f"{out_dir}/X.npy", all_x) # (N, 32, 128) float32 np.save(f"{out_dir}/y.npy", all_y) # (N,) int64 np.save(f"{out_dir}/subject_ids.npy", all_sid) # (N,) int64,1~32

subject_ids.npy是整条流水线里最重要的一个文件,没有它就没法做按被试划分,也没法在评估阶段按被试聚合指标。很多人图省事只存 X 和 y,后面想改成跨被试评估就得重跑一遍预处理,非常难受。

划分的写法大概是这样,注意训练集内部还要再切一小块做验证集用于早停,验证集也必须来自训练被试:

def loso_split(subject_ids, test_subj): test_mask = subject_ids == test_subj train_pool = np.where(~test_mask)[0] # 从训练被试里再抽 2 个当验证集,不能碰测试被试 val_subjs = np.unique(subject_ids[train_pool])[:2] val_mask = np.isin(subject_ids, val_subjs) train_idx = train_pool[~val_mask[train_pool]] val_idx = train_pool[val_mask[train_pool]] return train_idx, val_idx, np.where(test_mask)[0]

4. 核心模块实现:CNN-LSTM 网络结构、训练循环与评估口径

模型部分不复杂,但每个参数背后都有理由,写清楚比堆层数有用。

4.1 网络结构:Conv1d 堆叠后接双向 LSTM

import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_ch=32, hidden=64, n_classes=2, dropout=0.5): super().__init__() self.cnn = nn.Sequential( nn.Conv1d(n_ch, 64, kernel_size=7, padding=3), # 感受野 7 个采样点,约 55ms nn.BatchNorm1d(64), nn.ELU(inplace=True), nn.MaxPool1d(2), # 128 -> 64 nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ELU(inplace=True), nn.MaxPool1d(2), # 64 -> 32 nn.Dropout(dropout), ) self.lstm = nn.LSTM( input_size=128, hidden_size=hidden, num_layers=1, batch_first=True, bidirectional=True, ) self.head = nn.Sequential( nn.Linear(hidden * 2, 64), nn.ELU(inplace=True), nn.Dropout(dropout), nn.Linear(64, n_classes), ) def forward(self, x): # x: (B, 32, 128) —— 导联在通道维,时间是宽度 h = self.cnn(x) # (B, 128, 32) h = h.transpose(1, 2) # (B, 32, 128) 时间步在前 out, _ = self.lstm(h) # out: (B, 32, 2*hidden) return self.head(out[:, -1, :]) # 取最后时间步

几个参数的说明:

  • kernel_size=7对应约 55 毫秒,是脑电里 theta/alpha 波段一个周期的量级,再小就是纯高频噪声,再大第一层池化后时间维不够。
  • 用ELU不用ReLU,是因为脑电信号本身有负值,ELU 在负半轴有梯度,早期训练更稳;这个差别在小数据上能看出来。
  • BatchNorm1d放在卷积之后、激活之前,是对每个通道在 batch 维和时间维上做归一化,能缓解不同被试之间的幅值差异。
  • bidirectional=True之后hidden*2,因为两个方向各出一个 64 维向量,拼接后是 128 维。双向在这里是合理的——离线分类不要求因果性,整段窗都在手上。
  • 取out[:, -1, :]是取最后一个时间步。另一种常见做法是沿时间维做平均池化(out.mean(dim=1)),在窗长较短时更稳,可以用作对比实验。

参数量大概 20 万出头,在 15 万样本上是够用的。如果显存紧张或者想跑得更快,把第二层卷积的 128 降成 96,LSTM 的 hidden 降到 48,指标掉一两个点,速度能快三成。

4.2 训练循环:类别权重、早停与学习率

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() loss_sum, correct, total = 0.0, 0, 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() # LSTM 在长序列上容易出现梯度爆炸,裁剪是保险丝 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() loss_sum += loss.item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() total += y.size(0) return loss_sum / total, correct / total

类别权重按训练集里的正负比反比给:

pos = float((train_y == 1).mean()) # 高 valence 样本占比 w = torch.tensor([1.0 / (1 - pos), 1.0 / pos], dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=w / w.mean()) # 归一化,避免整体 loss 缩放

优化器用Adam(lr=1e-3, weight_decay=1e-4),配ReduceLROnPlateau(patience=5, factor=0.5),早停patience=10,监控验证集的宏 F1 而不是准确率——类不平衡的时候准确率会骗人。batch size 取 128,一轮大概 900 个 batch,单卡上几分钟跑完。

训练循环里那个clip_grad_norm_不是可有可无的。双向 LSTM 在序列长度 32 上不算长,但配合ELU和较高的初始学习率,前几个 epoch 偶尔会冒出loss=nan,加上裁剪之后就没再出现过。这是典型的玄学现象,遇到先别怀疑数据。

4.3 评估:留一被试交叉验证与混淆矩阵

def evaluate(model, loader, device): model.eval() preds, gts = [], [] with torch.no_grad(): for x, y in loader: logits = model(x.to(device)) preds.append(logits.argmax(1).cpu()) gts.append(y) return torch.cat(preds).numpy(), torch.cat(gts).numpy()

外层套 32 折的留一被试循环,每折记录四个数:该被试的准确率、宏 F1、混淆矩阵、样本数。最后报的是 32 个被试的均值和标准差。一定要给标准差——某些被试能到 70%,某些只有 52%,均值 61% 但标准差 8%,说明模型在某些人身上基本没学到东西,这个信息比单看均值重要得多。

评估口径上有两个必须守住的原则:测试被试的任何一个窗都不能出现在训练集里(滑窗重叠会跨 trial 边界泄漏);归一化的均值方差只能来自训练被试。这两条守住,报出来的 60% 左右才是真的。

5. 避坑与排查:跑不起来、指标虚高、loss 不降的四个现场

这一章是从实际跑的过程中攒下来的,每个都写了现象、原因和处理方式,遇到问题可以先对一遍。

5.1 解码直接崩:pickle 的编码与非法字节串

现象:一执行加载就抛UnicodeDecodeError: 'ascii' codec can't decode byte 0x80 in position 0,堆栈指向pickle.load。

原因:原始文件是用 Python 2 的 pickle 协议序列化的,字符串是字节串不是 unicode,Python 3 默认用 UTF-8 去解,键名里的非 ASCII 字节直接炸。

解决:pickle.load(f, encoding="latin1")。latin1 是单字节映射,任何字节都能解出对应字符,不会报错。另一种做法是encoding="bytes",但那样键名会变成b'data',后面取值要全部改成字节键,反而麻烦。

5.2 准确率 98%:泄漏藏在你没注意的地方

现象:第一版跑出来测试集准确率 98%,宏 F1 0.97,激动了半小时,换成另一个被试的划分方式立刻掉到 55%。

原因:当时偷懒,滑窗之后直接train_test_split(random_state=0),重叠 50% 的相邻窗几乎一样,一半进了训练一半进了测试,模型只是在做最近邻匹配。另外归一化图省事用全量数据算了均值和方差,测试被试的统计量也进去了。

解决:划分按subject_ids做;归一化挪到划分之后,只用训练被试的统计量;数据落盘时就带上subject_ids.npy。改完之后准确率掉到 62% 左右,但换被试波动明显小了。后悔药是提前把评估协议写进 README 再动手写代码,别等刷出高分再回头怀疑。

5.3 loss 不降、预测全是同一类

现象:训练 loss 卡在 0.69 附近不动(正是ln2),验证集上所有样本都被预测成同一类。

原因:类不平衡太严重,某个被试的 40 个 trial 里只有 9 个高 valence,切窗后正负比接近 1:4,模型退化成常数预测器,梯度被多数类主导。

解决:CrossEntropyLoss(weight=...)给少数类加权;采样时用WeightedRandomSampler做正负均衡;监控指标换成宏 F1。另外确认一遍标签索引没取错——labels[i, 0]是 valence,labels[i, 1]是 arousal,取错列会让正负比完全反过来。

5.4 训练中途 loss 突然变 nan

现象:前 5 个 epoch 正常下降,第 6 个 epoch 开始 loss 变 nan,参数全变成 nan,之后再也恢复不了。

原因:LSTM 的门控里有个sigmoid和一个tanh,序列较长、学习率偏高时梯度会累积放大;加上数据里偶尔有电极脱落造成的极大值,归一化之后仍然是个离群点,一进网络就把激活推爆。

解决:clip_grad_norm_(max_norm=1.0)必加;预处理阶段做一次粗筛,把单个导联幅值超过 ±500μV 的窗丢掉(正常脑电在这个量级以内);学习率从 1e-3 降到 5e-4。还不行就把BatchNorm挪到激活之后,或者在 LSTM 输出后加一层LayerNorm。

6. 二次开发:换标签、换骨干、上在线推理的三条改造路线

拿到这套代码之后最常做的三件事,按改动成本从低到高排。

第一条,换标签。labels有四列,改个索引就能从 valence 换到 arousal,但阈值别照搬 5。arousal 的分布和 valence 不一样,某些被试的 40 个 trial 全部挤在 4~6 之间,用 5 当阈值会切出极度不平衡的两类。我的做法是先画一遍全体被试的标签直方图,再定阈值,或者干脆改成回归任务,把最后一层换成单输出、损失换成SmoothL1Loss,输出的连续分数再自己卡阈值。

第二条,换骨干。做对比实验时把CNNLSTM里的self.lstm换成nn.GRU、nn.TransformerEncoderLayer,或者直接换成注意力池化。换成 Transformer 时注意要加位置编码,nn.TransformerEncoderLayer(d_model=128, nhead=4, batch_first=True),序列长度 32 很快。我在同一份预处理产物上对比过 CNN+LSTM、CNN+GRU、纯 CNN 三个版本,跨被试均值大概差 1~3 个点,LSTM 版本在这个数据规模上有微弱优势,但没有到“必须用”的程度,反而是训练稳定性差别更明显。

第三条,改成在线推理。train.py里保存 checkpoint 的时候顺手把模型结构和归一化用的mu/sd一起存进去,推理时只需要一个滑窗缓冲:

@torch.no_grad() def infer_stream(model, buf, mu, sd, device): """buf: (32, 128) 最新 1 秒数据,逐 0.5 秒调用一次。""" x = (buf - mu) / sd # mu/sd 来自训练被试 x = torch.from_numpy(x).float().unsqueeze(0).to(device) prob = torch.softmax(model(x), dim=1)[0, 1].item() return prob # 输出高 valence 的概率

验证改造有没有跑偏,我的习惯是拿一个小被试做“过拟合测试”:只用 1 个被试的数据训练 50 个 epoch,训练集准确率必须能到 95% 以上,到不了就是数据管线或者梯度传播有 bug,别急着调超参。这条过了,再放开到全部被试做留一交叉验证。从那以后我每次改完模型都强制走一遍这个过拟合测试,比盯着验证集曲线猜问题快得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询