简介:面向脑电情感识别方向的深度学习实践者,这份资源围绕DEAP数据集给出CNN-LSTM情感分类的完整代码实现,适合已具备一定深度学习基础、希望复现或改进脑电情绪识别实验的高校学生与算法开发者。压缩包共35个文件、约11.86MB,以15张png可视化图、6个ipynb实验笔记、3个py脚本及xml工程配置为主,另含h5与npy模型权重文件,覆盖数据预处理、特征提取、模型构建、训练评估到权重保存的完整链路。其中notebook记录了LSTM单独建模以及CNN1D结合LSTM、GRU、SimpleRNN的多种结构尝试,png图则呈现频域分布、混淆矩阵、准确率与损失曲线等结果,便于对照分析模型表现。该资源已有2522人学习下载,读者可据此快速搭建实验环境、理解时空特征联合建模思路,并在此基础上调整超参数或替换模型结构开展自己的研究。
1. 从一份“跑不动”的脑电识别代码说起
去年帮一个做认知负荷实验的团队看代码,他们手里有一份 DEAP 脑电信号识别 CNN-LSTM 的工程,论文里准确率写得漂亮,可自己一跑,loss 不降、标签对不上、GPU 显存还爆。问题不在模型,在于没人把“数据怎么进、张量怎么摆、标签怎么切”讲清楚。这份资源就是干这个的:它把 DEAP 数据集上的情感二分类(高唤醒/低唤醒、正负效价)用 CNN 提局部时频特征、LSTM 建模时间依赖,从预处理到训练评估串成一条能复现的链路。适合两类人——刚接触 lstm 深度学习、想拿脑电信号识别练手的学生,以及要把 DEAP 基线跑通再做二次实验的研究者。下面按“能干什么、怎么跑、怎么改”拆开讲。
2. 项目定位与技术选型:为什么是 CNN 加 LSTM 而不是单一网络
2.1 脑电信号的特点决定了模型结构
DEAP 是公开的情感脑电数据集,32 名被试、每人 40 段一分钟左右的信号,采样率 128Hz,配 32 导联。原始信号是典型的多通道时间序列:单通道看是长序列,多通道看是空间相关。纯 CNN 擅长抓局部波形(比如某个频段的突发),但一分钟 7680 个采样点,卷积核感受野再大也难覆盖长程依赖;纯 LSTM 能建模时序,却对局部时频模式不敏感,而且 32 导联直接喂进去参数量爆炸。所以这个工程走的是“CNN 先降维提特征、LSTM 再串时序”的路子,这也是脑电情感识别里比较稳的基线组合。
技术栈上,深度学习部分用 PyTorch(也有 TensorFlow/Keras 版本,看具体文件),数据处理用 NumPy、SciPy,预处理阶段常配 MNE 做滤波和分段,评估用 scikit-learn 算混淆矩阵和分类报告。选 PyTorch 的理由很实际:动态图调试方便,自定义 Dataset 和训练循环改起来直观,做科研二次开发时不用跟静态图较劲。
2.2 目录结构长什么样
拿到源码先别急着跑,先看目录。这类工程通常是这样组织的:
deap-cnn-lstm/ ├── data/ # 放 DEAP 的 .dat 或预处理后的 .npy ├── preprocessing/ │ ├── load_deap.py # 读 .dat、拼标签 │ └── filter_segment.py # 滤波、去基线、滑窗分段 ├── models/ │ └── cnn_lstm.py # 网络定义 ├── train.py # 训练入口 ├── evaluate.py # 测试与指标 ├── config.py # 超参、路径集中管理 └── requirements.txtconfig.py集中放超参是个好习惯,改实验不用满仓库找数字。preprocessing和models分开,意味着你可以只换模型不动数据管线,这对二次开发很关键。如果实际文件命名略有出入,按“数据、模型、训练、评估”四块去对应即可,逻辑是一致的。
2.3 数据管线的设计取舍
DEAP 原始.dat是 pickle 格式,每个文件是一个被试的字典,键包括data(40×40×8064,通道×采样点)和labels(40×4,唤醒度、效价、支配度、喜好度)。工程里一般会先把它转成.npy缓存,避免每次训练都反序列化。标签处理上,常见做法是把唤醒度或效价按阈值(比如 5)二值化,高于阈值记 1,否则记 0。这里有个容易忽略的点:DEAP 的标签是 1~9 的连续评分,直接当回归目标也行,但分类任务必须先定阈值,阈值选 5 还是按被试中位数,会直接影响正负样本比例。
滑窗分段是另一个设计点。一分钟信号切成若干 2~4 秒的窗口,既能扩样本量,又能让 LSTM 的序列长度可控。窗口重叠 50% 是常见做法,重叠太少样本不够,太多又容易泄漏。这些参数都该在config.py里显式写出来,而不是散落在函数默认值里。
3. 核心模块拆解:从原始 .dat 到模型输入的完整链路
3.1 读取 DEAP 并构造 Dataset
先看数据加载。下面这段是典型的读取与缓存逻辑:
import numpy as np import pickle from torch.utils.data import Dataset def load_subject(dat_path): with open(dat_path, 'rb') as f: subject = pickle.load(f, encoding='latin1') # data: (40, 40, 8064) -> 取前32导联脑电 eeg = subject['data'][:, :32, :] labels = subject['labels'] # (40, 4) return eeg, labels class DEAPDataset(Dataset): def __init__(self, eeg, labels, window=256, stride=128, threshold=5.0): self.samples, self.targets = [], [] for i in range(eeg.shape[0]): trial = eeg[i] # (32, 8064) # 按唤醒度二值化,也可换成效价 labels[i,1] y = 1 if labels[i, 0] >= threshold else 0 for start in range(0, trial.shape[1] - window, stride): seg = trial[:, start:start + window] # (32, window) self.samples.append(seg) self.targets.append(y) def __len__(self): return len(self.samples) def __getitem__(self, idx): x = self.samples[idx].astype(np.float32) return x, self.targets[idx]逻辑说明:load_subject里encoding='latin1'是老版本 pickle 的兼容写法,不加可能直接报编码错。eeg[:, :32, :]只取脑电通道,DEAP 后 8 个是外周生理信号,混进来会干扰。DEAPDataset做两件事——按阈值把连续评分转成 0/1,再滑窗切段。参数window=256对应 2 秒(128Hz×2),stride=128是 50% 重叠,threshold=5.0是二值化分界。这三个数是最该先调的,窗口太短 LSTM 学不到节奏,太长样本数骤减。
3.2 CNN-LSTM 网络定义
模型部分是这个工程的核心,结构一般是“卷积块提特征 → 展平或池化 → LSTM 串时序 → 全连接分类”:
import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_channels=32, n_classes=2, hidden=64): super().__init__() self.conv = nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size=7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), ) self.lstm = nn.LSTM(input_size=128, hidden_size=hidden, num_layers=2, batch_first=True, bidirectional=True, dropout=0.3) self.fc = nn.Linear(hidden * 2, n_classes) def forward(self, x): # x: (B, 32, T) x = self.conv(x) # (B, 128, T/4) x = x.permute(0, 2, 1) # (B, T/4, 128) 适配 LSTM out, _ = self.lstm(x) out = out[:, -1, :] # 取最后时刻 return self.fc(out)逻辑说明:Conv1d的输入是(batch, channels, length),所以通道维在前,这点和图像 CNN 不同,新手最容易在这里把维度搞反。两层卷积把 32 导联压到 128 个特征图,MaxPool1d(2)两次后时间维缩到 1/4。permute(0,2,1)把通道维换到最后一维,因为 LSTM 要的是(batch, seq, feature)。双向 LSTM 输出维度是hidden*2,取out[:, -1, :]是常见做法,也可以做注意力池化。dropout=0.3在脑电小样本上很有必要,不然过拟合来得很快。
3.3 训练循环与评估
训练入口把 Dataset、模型、优化器串起来:
from torch.utils.data import DataLoader import torch.optim as optim model = CNNLSTM().cuda() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) loader = DataLoader(dataset, batch_size=32, shuffle=True) for epoch in range(50): model.train() for x, y in loader: x, y = x.cuda(), y.cuda() optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() optimizer.step()参数说明:lr=1e-3配 Adam 是常规起点,脑电任务上如果 loss 震荡可以降到 5e-4;weight_decay=1e-4做 L2 正则;batch_size=32在显存允许下尽量大一点,小 batch 会让 BatchNorm 统计不稳。评估阶段别只看 accuracy,DEAP 二分类正负样本常不平衡,混淆矩阵和 F1 更能说明问题,sklearn.metrics.classification_report一行就能出。
4. 跑通与避坑:环境、显存、标签三个高频翻车点
4.1 环境准备与最小可跑步骤
先建虚拟环境,装依赖,再确认数据路径:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install torch numpy scipy scikit-learn mne python preprocessing/load_deap.py --data_dir ./data python train.py --config config.pyload_deap.py负责把.dat转成.npy,第一次跑会慢,之后训练直接读缓存。如果脚本没有命令行参数,就改config.py里的DATA_DIR。跑之前先确认data/下每个被试文件都在,缺一个会在拼接时维度对不上。
4.2 常见问题排查
现象一:loss 一直是 0.69 不动。原因多半是标签全被映射成同一类,或者threshold设得让正负样本极度失衡。解决:打印np.bincount(targets),看两类数量;阈值改成按被试中位数动态切。
现象二:CUDA out of memory。原因是一次性把所有滑窗样本读进内存再转张量,或者batch_size太大。解决:Dataset 里存索引、__getitem__时再切片,别在__init__里堆满数组;batch_size先降到 16 试。
现象三:维度报错expected 3D input。原因是从.npy读出来是(N, T, C),没转成(N, C, T)。解决:在 Dataset 里统一np.transpose(seg, (1,0)),或者模型第一层前加permute。
现象四:训练集准确率 99%,测试集 50%。原因是被试间划分没做对,同一被试的窗口同时进了训练和测试,信息泄漏。解决:按被试划分,留出若干被试做测试,这是脑电任务的血泪经验。
现象五:LSTM 输入报input_size不匹配。原因是改了卷积层通道数却没同步LSTM(input_size=...)。解决:把卷积输出通道数抽成变量,两处引用同一个值。
提示:每次改完预处理参数,先跑一个被试、几个 batch,确认张量形状和标签分布再全量训练,能省下大量等待时间。
5. 二次开发:换标签、换模型、做被试独立验证的实操思路
5.1 换任务标签与评估口径
这份代码默认做唤醒度二分类,想改成效价(valence)识别,只需把labels[i, 0]换成labels[i, 1],阈值逻辑不变。但要注意,效价的正负分布和唤醒度不一样,换完先看bincount。如果要做四分类(高唤醒高效价等),把n_classes改成 4,标签用两位组合编码,损失函数仍是交叉熵。评估口径也要跟着换:二分类看 F1,多分类看宏平均 F1 和每类召回,别只报一个总准确率糊弄自己。
5.2 模型改造的两个方向
一是把单向 LSTM 换成带注意力的池化,替代out[:, -1, :],让模型自己学哪些时间片重要:
class AttnPool(nn.Module): def __init__(self, dim): super().__init__() self.w = nn.Linear(dim, 1) def forward(self, x): # (B, T, D) score = torch.softmax(self.w(x), dim=1) # (B, T, 1) return (x * score).sum(dim=1) # (B, D)把它接在 LSTM 输出后面,替换最后时刻取值,参数量增加很少,但在长序列上通常更稳。二是把 CNN 换成多尺度卷积(不同 kernel_size 并行再拼接),适配脑电不同频段的节律。改的时候保持forward输出维度不变,训练脚本一行不用动,这就是前面把模型单独放models/的好处。
5.3 被试独立验证与结果复现
科研场景最该做的是被试独立(subject-independent)验证:留出若干被试完全不参与训练。实现上在构造 Dataset 时按被试 ID 分组,训练集和测试集的被试列表不重叠。这一步做完,准确率通常会比被试内划分低一截,但那个数字才可信。复现时固定随机种子(torch.manual_seed、np.random.seed),并把config.py里的窗口、步长、阈值、学习率一起存档,否则过两周自己都说不清哪组参数出的结果。
| 改造点 | 改动位置 | 影响 |
|---|---|---|
| 换效价标签 | Dataset 标签索引 | 正负分布变化,需重看阈值 |
| 加注意力池化 | models 的 forward | 参数量小增,长序列更稳 |
| 被试独立划分 | 数据划分逻辑 | 指标下降但更可信 |
| 多尺度卷积 | 卷积块 | 显存上升,需调 batch |
我自己的习惯是,任何一次实验开跑前,先把config.py复制一份带时间戳的副本,跑完把混淆矩阵和这组参数放一起。早些年图省事直接改原文件,结果两组结果对不上,回头查了一整晚。从那以后每次动超参都强制走一遍“存档—跑—记指标”的流程,希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取