news 2026/10/12 7:27:41

DEAP脑电信号识别实战:CNN-LSTM代码详解与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DEAP脑电信号识别实战:CNN-LSTM代码详解与避坑指南

简介:面向DEAP脑电情感识别任务的深度学习代码包,提供结合CNN与LSTM的完整训练、验证与预测流程,适合需要基于脑电信号进行情绪分类的研究者、学生或工程师参考。压缩包共35个文件、约11.86MB,结构涵盖Jupyter Notebook实验脚本(.ipynb)、纯Python训练/测试程序(.py)、模型权重备份(.h5/.npy)以及大量数据可视化图(.png)和项目配置文件(.xml/.iml)等,覆盖数据清洗、特征提取、带通滤波、模型构建、训练评估与结果可视化等环节。已有2521人学习下载。资源内不仅实现了标准LSTM,还提供CNN1D与LSTM、GRU、SimpleRNN的多种组合对比实验,多个notebook版本记录了不同超参数和特征的迭代过程;同时附带theta/alpha/beta等频段分布图、混淆矩阵、loss曲线和滤波效果图,便于用户快速理解数据特点与模型行为,可直接作为复现DEAP情感识别结果的基线工程,或进一步改进和迁移学习的起点。

1. DEAP脑电信号识别CNN-LSTM代码:这套组合到底在解决什么问题

之前有人拿着“Deap脑电信号识别CNN-LSTM代码”来问:训练时loss一路降到0.1以下,测试准确率却卡在70%上不去。我看了看数据加载部分,发现他把同一个受试者同一段视频的滑窗片段同时塞进了训练集和测试集,这个数据泄漏不解决,后面换任何模型都白搭。这个标题背后其实是公开数据集加CNN-LSTM的一套标准落地流程:读取固定格式的脑电数据、滑窗构造样本、用CNN提取空间与局部特征、用LSTM捕捉时间依赖,最后落到情绪或唤醒度二分类上。这篇文章把从数据到模型的完整代码讲清楚,新手能照着复现,熟手能对照自己的写法找问题。

DEAP算不上新数据集,但一直是做情绪识别的首选参考,胜在样本形态规范、标签齐全。CNN-LSTM组合在这个场景里几乎是最经济的选择,能解决的关键问题有两类:电极通道上的空间分布特征,以及几十秒内持续变化的时间规律。文章不讨论花哨的注意力模型,只围绕这个标题,把代码里的每一步说透。适合刚接触脑电数据、想快速跑通baseline的初学者,也适合已经有模型但复现效果不稳、怀疑是数据或参数问题的从业者。

2. 读取DEAP数据集:从.mat原始文件到能送进网络的样本

DEAP预处理版本的文件按被试存放,每个.mat文件里有data和labels两个数组。data形状是(40, 40, 8064),40是trail数,40是通道数,8064是采样点(128Hz乘以63秒);labels是(40, 4),对应valence、arousal、dominance、liking四个维度的1到9打分。很多刚上手的人以为这些通道都是脑电,实际前32路是EEG,后8路是眼电、肌电等外围生理信号,直接全用会引入噪声。

2.1 用scipy读取单个被试文件

常见做法是用scipy.io.loadmat读取。这里要注意loadmat解析后的嵌套问题,mat['data']本身是(40, 40, 8064)的数组,不需要额外转置。先写一个只取前32通道的函数:

import scipy.io as sio import numpy as np def load_deap_subject(path, n_channels=32): mat = sio.loadmat(path) eeg = mat['data'] # (40, 40, 8064) labels = mat['labels'] # (40, 4) eeg = eeg[:, :n_channels, :].astype(np.float32) return eeg, labels

loadmat加载后的data是float64,转换成float32能省一半内存。到这里eeg形状为(40, 32, 8064),labels仍是(40, 4)。为什么只取前32路,因为眼电信号与情绪标签的相关性容易让模型学到数据集特有的伪规律,换一批数据就失效,实际部署时也不一定采集外围生理信号。想保留后8路的话,可以单独分开编码,不要和EEG混在一个Conv1d输入里。

2.2 滑窗分段:把1280条trail变成可训练样本

直接拿每条trail整体作为样本,一个被试只有40条,总样本量1280条,对CNN-LSTM来说太少。常见做法是滑窗分段。以4秒窗口512点、步长256点为例,一条8064点的trail能切出30个左右的窗口,样本量放大到3万以上,模型才养得活。滑窗的代价是相邻窗口共享信息,因此训练集和测试集的划分必须按trail而不是按窗口来切,否则测试集失真。

def make_windows(eeg, labels, window_len=512, stride=256): windows, targets = [], [] n_trails, n_channels, n_samples = eeg.shape for t in range(n_trails): signal = eeg[t] label = int(labels[t, 0] >= 5) # valence 二分类 for start in range(0, n_samples - window_len + 1, stride): windows.append(signal[:, start:start + window_len]) targets.append(label) return np.asarray(windows, dtype=np.float32), np.asarray(targets, dtype=np.int64)

参数说明:window_len=512对应4秒,stride=256是2秒,重叠一半。窗口太短比如1秒,CNN提取不到足够的节律变化模式;窗口太长比如8秒,样本量骤减且相邻窗口几乎相同。我一般会先用512/256跑通baseline,再试1024/64做对比。注意stride越小,同一trail产生的窗口越多,训练时越容易过拟合,因为相邻窗口本质上是同一段信号。

另外,DEAP每条trail前3秒是基线,很多论文直接用完整63秒而不单独做基线校正。前3秒能帮助模型估计静息状态,但也会让模型学到“只看开头就知道这个人情绪状态”的捷径。我个人的习惯是保留基线,让模型自己决定哪些时刻的信息有用,同时把当前窗口减去基线段均值作为可选差分特征。

2.3 标签二分类化:阈值切在4.5还是5.0

DEAP原始打分是1到9的连续值,直接回归也可以,但二分类更容易评估。labels[:, 0]是valence,labels[:, 1]是arousal,取哪个维度取决于任务。常见阈值有4.5、5.0两种,还有一种做法是用全体被试的平均值做阈值。阈值直接影响正负样本比例和准确率基线。

def make_binary_labels(labels, dim=0, threshold=5.0): return (labels[:, dim] >= threshold).astype(np.float32).reshape(-1, 1)

阈值5.0时正负样本相对均衡,对应“一般以上算正类”;阈值4.5会把更多样本归为正类,准确率基线略有变化。建议报告结果时把阈值写清楚,并列实验里用同一阈值,否则复现时对不上。另一个常用维度是arousal,它比valence更好分,很多baseline论文的arousal准确率比valence高5到8个百分点,这是数据本身的特性,不是模型优势。

3. 搭建CNN-LSTM主干:空间特征提取与时间依赖建模

CNN与LSTM在DEAP上的分工,不是把整个序列扔给LSTM,而是先用CNN在每个小段时间段内提取局部特征,再用LSTM把这些段的特征串起来。这么做合理的地方在于:脑电信号在几百毫秒尺度上有稳定的节律模式,CNN的局部卷积核正好能捕获;而情绪状态在几十秒内缓慢变化,前后的特征向量存在时间依赖,这是LSTM擅长的地方。

3.1 为什么不用纯CNN或纯LSTM

纯CNN在脑电上也能出结果,靠多层卷积扩大感受野,但要做到几十秒的依赖,需要堆很多层卷积,参数量大且解释性差。纯LSTM直接吃原始脑电,得把128个采样点当输入维度,维度灾难和梯度消失同时出现,训练极不稳定。CNN-LSTM折中后各司其职,参数量相对小,训练速度快。

据观察,DEAP上的多数baseline准确率在60%到75%之间,arousal任务更容易一些。这个区间的天花板不是模型,而是DEAP标签本身的主观性,同一个人看同一段视频两次可能有不同打分。所以发现模型只有70%准确率时,先别怀疑代码,先确认数据划分方式。

3.2 完整网络结构:按段做CNN提特征,再用双向LSTM建模

以4秒窗口512点为例,把512点切成8段,每段64点,每段内用共享的卷积网络提取特征。8个特征向量作为8个时间步送进LSTM。这里的关键是共享权重:同一个CNN作用于所有段,保证不同时间段用相同的模式提取特征,减少参数量。

import torch import torch.nn as nn class SegmentCNN(nn.Module): def __init__(self, n_channels=32, cnn_out=128): super().__init__() self.cnn = nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size=16, stride=4, padding=8), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveMaxPool1d(32), nn.Conv1d(64, cnn_out, kernel_size=8, stride=4, padding=4), nn.BatchNorm1d(cnn_out), nn.ReLU(), nn.AdaptiveMaxPool1d(16), ) self.pool = nn.AdaptiveMaxPool1d(1) def forward(self, x): # x: (B, 32, 64) h = self.cnn(x) # (B, 128, 16) h = self.pool(h).squeeze(-1) # (B, 128) return h class CNNLSTM(nn.Module): def __init__(self, n_channels=32, window_len=512, n_segments=8, cnn_out=128, lstm_hidden=64, num_classes=1): super().__init__() self.n_segments = n_segments self.n_channels = n_channels self.segment_len = window_len // n_segments self.cnn = SegmentCNN(n_channels, cnn_out) self.lstm = nn.LSTM(cnn_out, lstm_hidden, batch_first=True, bidirectional=True) self.classifier = nn.Sequential( nn.Linear(lstm_hidden * 2, 32), nn.ReLU(), nn.Dropout(0.5), nn.Linear(32, num_classes), ) def forward(self, x): # x: (B, 32, 512) B = x.size(0) x = x.view(B, self.n_channels, self.n_segments, self.segment_len) x = x.permute(0, 2, 1, 3).contiguous() x = x.view(B * self.n_segments, self.n_channels, self.segment_len) feat = self.cnn(x) # (B * 8, 128) feat = feat.view(B, self.n_segments, -1) # (B, 8, 128) out, _ = self.lstm(feat) # (B, 8, 128),双向后128维 out = out[:, -1, :] # 取最后时间步 return self.classifier(out)

这里forward里的维度变换是容易写错的地方。x先view成(B, 32, 8, 64),第2维是通道,第3维是段,第4维是段内采样点,需要permute把段维度换到第2维,才能把8个段独立过CNN。permute后还要.contiguous()确保内存连续,否则view会报错。feat前两维合并的目的是让CNN一次处理多个段,比for循环高效。

3.3 关键参数怎么调:卷积核、LSTM隐藏层、dropout

先看卷积核。第一层kernel_size=16, stride=4,对应128Hz下约0.125秒的感受野,适合捕捉α波和β波的局部模式。kernel太到32,会把段内64个点压得太狠,特征分辨率下降。AdaptiveMaxPool1d的作用是把长度不齐的特征对齐到指定长度,避免人工计算池化尺寸出错。

LSTM的lstm_hidden=64,双向后输出128维。DEAP样本量不大,hidden超过128很容易过拟合;低于32则表达力不足。dropout放在分类头最后一层前,值取0.5,同时配合训练时的早停。BatchNorm在这里也承担了稳定训练的作用,但下一章会说它在跨被试场景下的问题。

4. 训练与调参:让模型在DEAP上真正收敛的配置

模型结构确定后,训练配置决定能否收敛。DEAP上最常见的失败不是代码错误,而是训练集内部泄漏和超参不合适。这一章给出一个可以在单卡上跑通的训练流程,并解释每个配置的动机。

4.1 训练集与测试集划分:按被试划分是最稳妥的方案

DEAP有32个被试,有人习惯随机打乱所有trail后按8:2划分,这种做法在复现论文时得分高,但模型见到的是同一批人的不同trail,相当于用户内测试。真正有意义的是跨被试评估:用26个被试做训练,6个被试做测试,测的是模型从未见过的人上的表现。两者准确率差距通常在10个百分点以上。

from torch.utils.data import TensorDataset, DataLoader def build_dataset(subject_files, window_len=512, stride=256, n_channels=32): xs, ys = [], [] for path in subject_files: eeg, labels = load_deap_subject(path, n_channels) wins, tgts = make_windows(eeg, labels, window_len, stride) xs.append(wins) ys.append(tgts) x = np.concatenate(xs, axis=0) y = np.concatenate(ys, axis=0).reshape(-1, 1).astype(np.float32) return TensorDataset(torch.from_numpy(x), torch.from_numpy(y)) train_files = [f"data_preprocessed_matlab/s{i:02d}.mat" for i in range(1, 27)] test_files = [f"data_preprocessed_matlab/s{i:02d}.mat" for i in range(27, 33)] train_ds = build_dataset(train_files) test_ds = build_dataset(test_files) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_ds, batch_size=128, shuffle=False, num_workers=2)

文件路径按实际目录改。构建数据集时把每个文件的窗口拼接起来,批量操作比逐条循环快很多。注意y要reshape成(N, 1),BCEWithLogitsLoss要求目标和预测形状一致。

4.2 损失函数、优化器与梯度裁剪

二分类用BCEWithLogitsLoss,理由是不用在forward里手动sigmoid,数值上更稳定。优化器用AdamW而不用原版Adam,权重衰减效果更好,适合脑电这种小数据场景。梯度裁剪能防止LSTM在长序列上梯度爆炸,clip值取1.0即可。

def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0.0 for x, y in loader: optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader) model = CNNLSTM(n_channels=32, window_len=512, n_segments=8) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=1e-4)

lr=5e-4是DEAP上比较稳的起点,3e-3会在一开始就震荡。weight_decay=1e-4对应L2正则,一定程度上替代了数据增强。如果显存小,batch_size可以降到32,但BN会受影响,避坑章节里专门说这个。

4.3 早停、学习率衰减与epoch设置

DEAP这类小数据集,没有固定的epoch轮数,常见做法是连续5个epoch验证集AUC不升就停。我习惯在前30个epoch内保留最佳模型,而不是用最后一个epoch的结果。

best_auc = 0.0 patience = 0 for epoch in range(50): train_loss = train_one_epoch(model, train_loader, criterion, optimizer) val_metrics = evaluate(model, test_loader) if val_metrics['auc'] > best_auc: best_auc = val_metrics['auc'] torch.save(model.state_dict(), 'best_model.pt') patience = 0 else: patience += 1 if patience >= 5: break print(epoch, train_loss, val_metrics)

这里的evaluate函数在下一章给出。早停应该基于验证集而不是测试集,如果直接用test_loader做早停判断,本质上又是另一次数据泄漏。正确做法是把26个训练被试再拆出一部分做验证,或者用训练集内部的交叉验证。先这样写能把流程跑通。

5. 避坑:DEAP + CNN-LSTM常见的翻车现场

数据量小、标签主观、通道格式固定,这三个特点决定了DEAP上的坑很集中。下面把这些年帮别人排查代码时遇到的高频问题列出来,每条按现象、原因、解决三步说清。

5.1 数据泄漏:滑窗把同一trail切进了训练和测试

现象:训练损失稳定下降,测试准确率高达85%以上,但换到按被试划分后掉到70%以下。常见原因是先对全部窗口做8:2随机划分,测试集里包含大量来自训练trail的相邻窗口,模型等于见过答案。

原因:滑窗生成样本后,相邻窗口共享了80%以上的信号内容,随机划分把强相关样本分到了两侧。解决:划分一定发生在滑窗之前,按trail或按被试切分。写法如上章,窗口维度只存在于一个集合内部,不在两个集合之间共享。

5.2 BatchNorm在跨被试场景失效

现象:同一套代码在batch_size=64时正常,调到32后验证集指标大幅波动,甚至训练集上也开始抖动。原因:BatchNorm统计的是当前batch的均值和方差,被试间的脑电幅值差异大,小batch的统计量不稳定。

解决:跨被试场景把BatchNorm1d换成GroupNorm,或者把batch_size保持在64以上。我一般会把卷积层后的BN换成GroupNorm(num_groups=4),效果稳定且不再依赖batch大小。

5.3 时间步划分错误:LSTM根本没在建模时间

现象:模型结构没问题,但loss下降后又反弹,或者验证集指标始终在60%左右徘徊。原因:把x.view成(B, n_segments, segment_len, n_channels)后忘记permute,段内采样点被当成了时间步,LSTM学到的不是段间变化而是段内噪声。

解决:打印中间张量shape,确认进入LSTM前是(B, n_segments, cnn_out)。n_segments不要取1,取1时LSTM只有一个时间步,直接退化成了全连接层。

5.4 归一化统计量用在了全数据集上

现象:复现他人代码时,先计算全体数据的mean和std再归一化,测试准确率虚高,换环境后性能下降。原因:全数据归一化混入了测试集信息,属于轻度数据泄漏,偷看了测试集的分布。

解决:只从训练集计算mean和std,测试集沿用这两个值。脑电数据幅值差异大,建议按被试分别做z-score后再合并,效果更稳。

5.5 阈值与维度不统一导致复现对不上

现象:用valence做分类时模型只有68%,换arousal后到75%,但和某篇论文对比还是差一截。原因:不是模型问题,是别人用了不同的标签维度和阈值,arousal本来就比valence好分,5.0和4.5的结果也不一样。

解决:报告结果时固定写法,比如“DEAP valence二分类,阈值5.0,按被试划分”。如果只是为了模型对比,统一取arousal更能看出模型差异。

6. 验证与进阶:用指标和跨被试评估证明模型有效

训练完成后不能只打印准确率。DEAP正负样本在阈值5.0下基本均衡,准确率尚可参考,但如果有偏差,AUC和F1更说明问题。这里给出一个完整的验证流程。

from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix def evaluate(model, loader): model.eval() all_logits, all_true = [], [] with torch.no_grad(): for x, y in loader: logits = model(x) all_logits.append(logits) all_true.append(y) logits = torch.cat(all_logits).numpy() y_true = torch.cat(all_true).numpy() probs = 1.0 / (1.0 + np.exp(-logits)) preds = (probs >= 0.5).astype(int) return { 'auc': roc_auc_score(y_true, probs), 'report': classification_report(y_true, preds), 'confusion': confusion_matrix(y_true, preds) }

这里用sigmoid把logits转成概率再算AUC,classification_report输出precision、recall、F1三项。我习惯把confusion_matrix单独打印,看模型是把正类错当成负类还是反过来。情绪识别任务里recall低说明漏报多,实际场景更在意识别出负面情绪的召回率。

进阶部分,如果按被试划分的模型泛化不佳,可以试两件事。一是对窗口做数据增强,给每个窗口加标准差为0.01的高斯噪声,相当于扩充训练样本;二是把LSTM换成单层TransformerEncoder,序列长度只有8,不需要位置编码也能跑,效果与LSTM接近但对超参更敏感。都不理想时,检查标签是否真的有可学习信号,某些被试的valence打分与视频官方打分差异很大,按被试划分时这类被试会拉低整体指标。

我踩过的坑是以为加宽LSTM能提升跨被试效果,结果hidden从64加到256,过拟合反而更早到来,验证集AUC直接掉了3个点。后来固定hidden=64,把精力放在数据划分和数据增强上,指标才稳下来。这套CNN-LSTM代码在本地跑通后,建议记录三件事:标签阈值、数据划分方式、用BN还是GN,这三个变量就能让结果出现巨大差异。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 7:27:14

Python字典操作全解析:从创建、更新到排序与defaultdict实战

1. 字典基础操作与创建方式全解析字典这个结构,在Python里属于那种“平时不觉得多重要,一旦用顺手了就再也回不去”的工具。我刚开始写代码那会儿,习惯用两个列表分别存键和值,靠下标去对应,结果每次增删改查都要同步维…

作者头像 李华
网站建设 2026/10/12 7:27:14

nginx常见功能部署与配置解析

一.安装部署nginx nginx官网 https://nginx.org/en/download.html 1.安装编译依赖 # centos yum install -y gcc gcc-c make zlib zlib-devel pcre pcre-devel openssl openssl-devel2.下载并解压压缩包 wget http://nginx.org/download/nginx-1.30.5.tar.gz tar -zxvf ng…

作者头像 李华
网站建设 2026/10/12 7:24:17

多商户商城系统源码怎么实现三端复用?从选型到避坑全程指南

上周有个朋友拿着已经买好的多商户商城系统源码来问我:我就想让它同时跑在APP、小程序、H5上,这事到底靠不靠谱?我的回复是:靠谱,但它跟“一份代码复制三份”完全是两码事。这篇文章就围绕多商户商城系统源码怎么完成三…

作者头像 李华
网站建设 2026/10/12 7:21:30

JVM垃圾回收算法与收集器全解析:从Minor GC到ZGC

1. 为什么说算法是地基、收集器是算法的“工程实现”先聊点实际的。我见过不少工程师把 GC 调优当成“玄学”,一问到什么 Minor GC、Full GC,能背出概念,但一旦线上频繁 Full GC,就只会把堆调大,或者换个收集器试试运气…

作者头像 李华
网站建设 2026/10/12 7:21:28

Qwen3.8-27B部署实战:量化策略与硬件协同优化指南

1. 为什么“部署Qwen3.8-27B”这件事,现在突然变得既迫切又棘手最近两周,我连续接到三类不同背景的咨询:某高校自然语言处理方向的硕士生A同学,在复现一篇新论文时发现,原作者用的是Qwen3.8-27B做指令微调基座&#xf…

作者头像 李华
网站建设 2026/10/12 7:20:57

claude-mem实战:给Claude Code外挂一个长期记忆库

1. 从"每次都重新自我介绍"说起如果你用过 Claude Code 这类命令行 AI 编程工具,大概率经历过一种"既爽又憋屈"的体验:爽的是它真的能撸代码、改 bug、写测试,憋屈的是——它记性太差。上午刚跟它讨论完项目的模块划分&a…

作者头像 李华