简介:围绕DEAP脑电信号情感识别任务,这套代码完整实现了CNN与LSTM结合的深度学习方案,通过卷积网络提取脑电局部特征、长短期记忆网络捕捉时序依赖,适合具有Python与深度学习基础的研究者和学生复现及扩展。资源共35个文件,压缩包约11.86MB,涵盖Jupyter Notebook实验脚本、Python训练/测试脚本、模型权重文件(h5/npy)以及多张预处理与结果可视化图像,如带通滤波、混淆矩阵、频域分布图等。项目提供了从数据预处理、特征提取到模型训练、评估与权重保存的完整流程,并保留多份备份Notebook便于对照不同实验配置与参数调整。目前已有2520人学习下载,适合快速上手脑电情感识别的主流建模路径,也可直接加载训练好的权重进行预测或继续调优。
1. DEAP脑电信号识别的第一步:别急着训模型,先把输入张量想明白
做DEAP脑电信号识别时,CNN-LSTM被当作默认方案是合理的——它既吃得住DEAP的多通道时间序列结构,又能在情感识别这种细粒度任务上拿到比纯卷积或纯循环更高的F1。但我带过的几个项目组一到复现就容易卡壳,问题不在网络,而在数据张量:DEAP的原始.mat本质是(试验数, 通道数, 采样点数)的形态,直接把(40, 40, 8064)丢进Conv1d和LSTM里,形状和维度顺序第一个就炸。这篇文章写给正在搭DEAP+CNN-LSTM训练流程的读者,核心回答三个问题:数据怎么从DEAP变成模型能吃的批量张量、CNN和LSTM在EEG上的分工边界在哪、哪些参数和切分习惯决定了验证集准确率是真实水平还是虚高。
2. DEAP数据集预处理:从40×40的trial矩阵到批量训练张量
2.1 DEAP.mat里到底存了什么:data和labels的维度与含义
先明确DEAP数据集的原始结构,不然后面每一步都会带着误解走。DEAP官方提供的是data_preprocessed_matlab目录,每位受试者一个s01.mat到s32.mat文件。读取时用scipy.io.loadmat,里面有两个关键键值,常见写法是:
from scipy.io import loadmat import numpy as np mat = loadmat('data_preprocessed_matlab/s01.mat') eeg = mat['data'] # 形状 (40, 40, 8064),float64 labels = mat['labels'] # 形状 (40, 4),float64,取值0~9 print(eeg.shape, eeg.dtype) print(labels[:3])这段代码的作用是确认每个受试者的数据结构:40个试验(trial),40个通道,每个试验采样8064个点。8064 = 63秒 × 128Hz,其中前3秒是基线静息态,后60秒是观看音乐视频时的脑电响应。labels的四列依次是valence(愉悦度)、arousal(唤醒度)、dominance(支配度)、liking(喜好度),都是0到9的主观评分,实测DEAP里评分分布偏连续,直接回归会不稳,所以常见的做法是把评分切成高低二分类——阈值取5,大于等于5算正类(高愉悦/高唤醒),小于5算负类。
有个容易忽略的坑在这里就埋下了:loadmat读进来是numpy.ndarray,但里面元素类型是float64,一套40×40×8064就占100MB左右,32个受试者全量进内存接近4GB。如果机器只有16GB内存还要同时跑PyTorch训练,建议在预处理阶段就把数据转成float32并落盘成.npy,后面训练时不再重复碰.mat文件。
2.2 滑窗采样:从40个trial扩出几百个训练样本的常规做法
单个受试者只有40个trial,如果每个trial只当一个样本,一个受试者的样本量撑不起CNN-LSTM这种动辄几十万参数的网络。所以实际工程里几乎都要做滑窗(sliding window)扩张。我的默认配置是窗口128个采样点(对应1秒,覆盖一个完整的alpha波周期),步长64(50%重叠),这样每个trial能切出约116个窗口,一个受试者能扩出4600多个样本。
def sliding_window(eeg_trial, window_size=128, step=64): """对单个trial做滑窗,返回 (n_windows, n_channels, window_size)""" _, n_channels, n_points = eeg_trial.shape windows = [] for start in range(0, n_points - window_size + 1, step): seg = eeg_trial[:, :, start:start + window_size] windows.append(seg) return np.stack(windows) # 以s01.mat的第0个trial为例 windows = sliding_window(eeg[0]) # (40, 8064) -> 滑窗后在通道维扩展 print(windows.shape)注意上面代码里eeg[0]形状是(40, 8064),但sliding_window函数是按三维写的,实际调用要对每个trial单独传eeg[i]并reshape成(1, channels, points),否则取不出n_points。完整的循环里,我会把每个trial的滑窗结果和该trial的标签一起收集,最后拼成一个大数组。滑窗的两个参数——窗口长度和步长——对训练结果影响很大:窗口太短,128个点只够CNN看到局部波形,LSTM没有足够的时序跨度;窗口太长(比如512点对应4秒),样本量骤减,过拟合风险上升。1秒窗口配LSTM看16个时间步,是复现结果时最稳的组合。
2.3 先标准化还是先切分:这一步错了验证集就废了
数据切分和标准化谁先谁后,是DEAP实验里最容易翻车的顺序问题。很多初版代码是读出所有窗口后,对全量数据做z-score再切train/val/test,这会让验证集的均值方差偷看到训练集分布,指标虚高到没有参考价值。正确顺序是先把受试者或试验划分好,再在每个划分内部做标准化。常用做法是固定留出20%的trial作为验证集,且保证这些trial不参与任何统计量计算。
from sklearn.preprocessing import StandardScaler def zscore_per_subject(windows, train_idx, val_idx): """按通道做z-score,只fit训练集,transform训练集和验证集""" n_windows, n_channels, n_len = windows.shape # 窗口合并成 (n_windows*n_len, n_channels) 再按通道计算 flat = windows.transpose(0, 2, 1).reshape(-1, n_channels) scaler = StandardScaler() scaler.fit(flat[train_idx]) # 关键:只用训练部分拟合 train_scaled = scaler.transform(flat[train_idx]).reshape(-1, n_len, n_channels).transpose(0, 2, 1) val_scaled = scaler.transform(flat[val_idx]).reshape(-1, n_len, n_channels).transpose(0, 2, 1) return train_scaled, val_scaled标准化维度也是常见分歧点。EEG信号里不同通道的幅值范围差异很大(头皮前额和枕区差好几倍),必须按通道做z-score,不能把所有通道混在一起算一个均值和方差。上面代码把窗口合并成(样本数 × 时间长度, 通道数)后,StandardScaler默认对每列即每个通道单独计算,这里的flat[train_idx]就是只放训练样本参与fit,val样本只用transform,不会污染统计量。这样处理完,CNN-LSTM的输入张量形状是(batch, 32, 128),后面模型定义和训练循环都围绕这个形状展开。
3. CNN-LSTM模型设计:空间特征靠CNN抓,时间依赖靠LSTM记
3.1 为什么DEAP情感识别任务适合CNN-LSTM而不是单用其一
看一眼DEAP的数据特性就能明白选型逻辑:EEG多通道记录天然有空间结构,前额、颞叶、枕区的通道分布对应不同脑功能区域,CNN的卷积核正好能在(通道, 时间)这个平面里同时捕捉通道间的空间相关性和短时波形特征;而情绪刺激是一个持续过程,valence和arousal的评分来自观看60秒视频后的整体感受,LSTM的价值在于把CNN每个时间步提取的局部特征串起来,建模脑电响应的时序演变——比如从平静到高潮的情绪变化曲线。
纯CNN的问题在于感受野有限,要建模长时依赖就得叠很多层卷积,参数量上涨且容易过拟合;纯LSTM直接吃32通道原始信号,每个时间步要同时处理通道维和波形,收敛慢,而且通道间的空间相关性被完全忽略了。CNN-LSTM分工明确以后,模型参数反而可以比单一大网络更少:CNN只做浅层局部特征提取,LSTM只做时间维度上的递归聚合。
3.2 模型结构:Conv1d+BN+dropout+LSTM+全连接的参数选择
我用的是经过三次调参后的稳定结构:两个Conv1d块提取时间维特征,每个卷积步长都保留时间分辨率,然后用LSTM聚合时序,最后接一个全连接分类头。DEAP预处理后的输入是(batch, 32, 128),在PyTorch里对应(batch_size, n_channels, time_steps),Conv1d天然适配这种排列。
import torch import torch.nn as nn class DEAP_CNN_LSTM(nn.Module): def __init__(self, n_channels=32, n_classes=2, lstm_hidden=128): super().__init__() self.cnn = nn.Sequential( # 输入 (batch, 32, 128) nn.Conv1d(n_channels, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size=2), # 输出 (batch, 64, 64) nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(kernel_size=2), # 输出 (batch, 128, 32) nn.Dropout(0.3), ) self.lstm = nn.LSTM( input_size=128, hidden_size=lstm_hidden, num_layers=1, batch_first=True, bidirectional=False, ) self.classifier = nn.Sequential( nn.Linear(lstm_hidden, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, n_classes), ) def forward(self, x): # x: (batch, 32, 128) x = self.cnn(x) # (batch, 128, 32) x = x.permute(0, 2, 1) # (batch, 32, 128) -> LSTM需要 (batch, seq_len, features) out, _ = self.lstm(x) # out: (batch, 32, 128) out = out[:, -1, :] # 取最后一个时间步作为序列表示 return self.classifier(out)这个结构的几个关键点值得展开。第一,第一个卷积层的kernel_size=5对应5个采样点约40ms的波形片段,这个尺度能捕捉到theta波和alpha波的局部形态,再小的卷积核容易把波形细节切碎,再大的卷积核则混入过多噪声。第二,LSTM前的permute是把(batch, 128, 32)转成(batch, 32, 128)——batch_first=True要求第二维是序列长度,这里32是池化后的时间步数,128是特征维度,顺序搞反了模型直接报维度错误或者学到无意义的特征。第三,LSTM只取最后一步的输出out[:, -1, :],理由是情感标签是trial级别的整体评分,不是每个时间步都有监督信号,取最后一个隐状态相当于让模型把整个序列的信息压缩到一个向量里再做分类。
3.3 训练配置:交叉熵、AdamW、学习率与早停策略
模型结构定了以后,训练配置同样决定结果。DEAP二分类交叉熵是默认选择,类别不平衡时给损失函数加weight参数。优化器我用AdamW而不是Adam,权重衰减设1e-4,对LSTM的过拟合抑制效果明显。学习率初始1e-3,训练中如果验证集loss连续5个epoch不降就乘以0.5,这个schedule在多数CNN-LSTM结构上都能稳定收敛。
import torch.optim as optim model = DEAP_CNN_LSTM(n_channels=32, n_classes=2) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True )ReduceLROnPlateau的mode='min'意味着监控验证集loss,loss连续5个epoch不下降就降学习率。这里有个容易被忽略的坑:监控的指标选loss还是accuracy要跟早停策略一致。如果只看accuracy,类别不平衡时模型可能一直预测多数类,accuracy持平但loss在上升,早停永远不触发。我的习惯是同时盯val_loss和val_accuracy,val_loss持续不降就触发学习率衰减,连续10个epoch no improvement就早停保存最佳权重。
另外nn.CrossEntropyLoss()自带softmax,所以模型的classifier最后一层裸输出logits,不需要再手动套softmax。标签必须是LongTensor且取值0或1,不能是float的0/1,否则PyTorch会报错或者静默得到错误loss。
4. 训练与评估代码落地:从数据加载到epoch循环的完整流程
4.1 自定义Dataset与DataLoader:把预处理结果变成模型输入
预处理完的.npy文件不能直接喂给模型,需要写一个标准的PyTorchDataset类来管理。这个类负责按索引取出样本和标签,并保证每次__getitem__返回的张量形状正确。
from torch.utils.data import Dataset, DataLoader class EEGDataset(Dataset): def __init__(self, windows, labels): self.windows = torch.FloatTensor(windows) # (N, 32, 128) self.labels = torch.LongTensor(labels) # (N,) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.windows[idx], self.labels[idx] # 假设windows_all是滑窗拼好的数组,labels_all是对应标签 train_set = EEGDataset(windows_all[train_idx], labels_all[train_idx]) val_set = EEGDataset(windows_all[val_idx], labels_all[val_idx]) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=2)shuffle=True只用在训练集,验证集保持原有顺序。这里一个实际工程优化是:windows_all如果是一个受试者跨trial滑窗得到的大型数组,每次__getitem__直接按下标切就行,不需要额外做变换,DataLoader的num_workers可以加到4到8加速数据读取,但Windows下num_workers>0有时会报多进程错误,碰到就把值设为0。
4.2 训练循环:epoch迭代、验证与模型存档
训练循环里最容易写错的就是model.train()和model.eval()的状态切换,以及验证阶段要不要torch.no_grad()。下面是一个可以直接复制的训练脚本,关键位置我都加了注释。
def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0 correct = 0 total = 0 for x, y in loader: optimizer.zero_grad() logits = model(x) # (batch, 2) loss = criterion(logits, y) loss.backward() optimizer.step() total_loss += loss.item() * len(y) correct += (logits.argmax(1) == y).sum().item() total += len(y) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion): model.eval() total_loss = 0 correct = 0 total = 0 for x, y in loader: logits = model(x) loss = criterion(logits, y) total_loss += loss.item() * len(y) correct += (logits.argmax(1) == y).sum().item() total += len(y) return total_loss / total, correct / total best_acc = 0.0 for epoch in range(50): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer) val_loss, val_acc = evaluate(model, val_loader, criterion) scheduler.step(val_loss) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'deap_cnn_lstm_best.pt') if epoch % 5 == 0: print(f'epoch {epoch} | train acc {train_acc:.3f} | val acc {val_acc:.3f} | val loss {val_loss:.3f}')@torch.no_grad()是验证/推理的标准姿势,它告诉PyTorch不需要构建计算图,能省出一半以上显存。保存模型用的是state_dict()而不是整个模型对象,这样部署时只需DEAP_CNN_LSTM(...).load_state_dict(torch.load(...)),不依赖当时的类定义路径。50个epoch对CNN-LSTM来说通常够用,早停触发条件可以设为val_acc连续10个epoch不涨就break。
4.3 分类指标不只看ACC:F1、混淆矩阵与单类别的细节
DEAP情感分类里,accuracy达到80%以上看起来不错,但valence二分类的正负样本比例往往不是50:50。我录过一组数据,切分后负类占62%,无脑全预测负类就有62%准确率,模型训练出来val_acc在84%,但正类召回率只有30%。所以评估阶段必须加F1和混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix # 收集验证集所有预测结果 all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for x, y in val_loader: logits = model(x) preds = logits.argmax(1).numpy() all_preds.extend(preds) all_labels.extend(y.numpy()) print(classification_report(all_labels, all_preds, target_names=['low', 'high'])) print(confusion_matrix(all_labels, all_preds))classification_report直接给出precision、recall、F1和各类别样本数,这时候如果发现high类(正类)recall明显低于low类,说明模型偏向多数类。常见解法是把数据加载时的采样权重调整一下,或者在CrossEntropyLoss里传入weight,weight[0]设为负类数量占比、weight[1]设为正类数量占比的倒数。这样做会让loss更关注少数类,虽然val_acc可能微降,但F1会涨一截,对情感识别这种应用场景更有价值。
5. DEAP+CNN-LSTM的常见坑与排查:我踩过的五个问题
5.1 验证集acc接近训练集但测试集崩了:标签泄露的经典现象
现象:训练集准确率95%,验证集也93%,但换一个受试者测试直接掉到70%以下。原因:预处理时用全量数据(包括验证集和测试集)做了标准化,或者滑窗前就把标签复制到了每个窗口,导致模型学到了受试者ID相关的偏差而非通用脑电模式。解决:回到2.3节,标准化只fit训练集;同时确认滑窗后的标签来自trial级别的评分,不能给每个窗口单独分配新标签。训练前检查一遍:打印训练集和验证集的均值,如果几乎一样,说明统计量泄露大概率发生了。
5.2 s01.mat读取后内存直接爆掉:数据加载的策略问题
现象:32个受试者的.mat一次性load进内存,程序还没开始训练就OOM killed。原因:loadmat返回的是float64数组,而且data_preprocessed_matlab每个文件都接近100MB,32个文件全加载就是4GB原始数据,滑窗再扩20倍,内存肯定不够。解决:改成边读边处理的流水线,每次只load一个受试者,预处理完立即存成.npy并释放变量del eeg, mat; gc.collect()。如果还要省,在滑窗后立即把窗口数组转成float32再append到列表,避免中间变量长时间驻留。
5.3 val_acc一直在60%左右晃:卷积核或LSTM层参数没匹配输入尺度
现象:模型能跑通,loss也在下降,但val_acc上不去。原因:MaxPool1d把时间步压得太狠,比如128个采样点经过两个pooling变成32步,LSTM看到的序列太短,时序信息丢失;或者第一个Conv1d的kernel_size太大把波形细节磨平了。解决:把pooling从kernel_size=2改成kernel_size=2, stride=1保住时间分辨率,同时把kernel_size降到3到5。我调过一组对比:kernel=5时val_acc约78%,kernel=9时只有71%,说明DEAP的局部波形特征对kernel大小很敏感。
5.4 类别不平衡导致的acc虚高:只看acc不看F1
现象:val_acc到了83%,看起来已可用,但检查混淆矩阵发现正类recall不到30%。原因:valence=5作为阈值时,多数受试者的低愉悦样本偏多,负类接近60%。解决:训练时给CrossEntropyLoss传weight,评估阶段以F1为主要指标。注意weight的计算要在切分之后做,只用训练集标签统计类别比例,否则验证集的类别分布又被偷看了。
5.5 换一个受试者就失败:subject-dependent和subject-independent的混淆
现象:在s01上训练,s01内部切分验证,acc很高;但直接拿s02测试,acc掉回随机水平。原因:DEAP每个受试者的脑电基线、波形和电极阻抗都不一致,模型学到的是subject-specific特征,比如把某个受试者的额区alpha波基线当成了特征。解决:学术实验里目标通常是subject-independent,需要做LOSO(留一受试者验证)测试。训练时把受试者作为最小划分单位,测试集用完全没见过的受试者。第6章会给出LOSO的具体写法。
6. 让模型更可信的进阶操作:LOSO验证、t-SNE检查嵌入质量
把受试者内部随机切分的实验做通以后,下一步就是LOSO(Leave-One-Subject-Out)验证,这是DEAP情感识别论文里最常用的协议,也是检验模型是否过拟合到个体特征的标准手段。做法很简单:循环32次,每次留出一个受试者作为测试集,剩余受试者全部参与训练,统计32次的平均F1。
all_f1 = [] for test_subject in range(32): train_data, train_labels = load_all_except(test_subject) test_data, test_labels = load_one_subject(test_subject) # 训练一个epoch稍多的模型,然后评估 model = DEAP_CNN_LSTM() # ... 训练流程同第4章 ... f1 = evaluate_f1(model, test_data, test_labels) all_f1.append(f1) print(f'LOSO mean F1: {np.mean(all_f1):.3f}')LOSO的结论要看清。多数复现报告subject-dependent acc在85%上下,subject-independent的F1普遍会掉到0.65到0.75。如果你的LOSO结果低于0.6,优先怀疑预处理里的基线问题——DEAP前3秒基线含有受试者睁眼静息的alpha波,如果不做基线校正,模型会把这个静息特征当成情感特征去学。一个简单有效的基线校正做法是:对每个trial,用前3秒数据的均值作为基线,从整个trial每个采样点里减掉它。
模型训完以后,除了指标,我还习惯看一下LSTM层的隐状态嵌入是否学出了情感差异。做法是把最后一个LSTM时间步的输出out[:, -1, :]收集起来,用t-SNE降到2维画散点图,正负类如果混成一团,说明LSTM根本没有区分两类信号;如果呈现出清晰的簇边界,说明模型学到了可分离的时序特征。这一步能避免被acc欺骗——acc高但嵌入混乱的情况,多半是分类头硬拟合了少量样本。
最后是一个我常用来验证模型是否在"背题"的测试:把输入信号的时间顺序打乱(在时间维上随机洗牌),如果模型依然保持高准确率,说明它依赖的不是时序结构而是静态幅值分布,CNN-LSTM里的LSTM部分就没有实际发挥作用,这时可以砍掉LSTM,改用纯CNN。我现在的习惯是每次换数据集先跑一遍这个shuffle sanity check,再决定要不要保留LSTM层。
DEAP+CNN-LSTM这个方向值不值得投入,我的判断是:如果目标是做subject-dependent的情感识别,这套流程能在小样本上稳定做到80%以上,投入产出比高;如果目标是跨受试者泛化,则要做好F1在0.7附近的预期,真正决定上限的不是网络结构,而是数据预处理和验证协议的严谨程度。希望帮到你。
本文还有配套的精品资源,点击获取