ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python实现DNN与CNN二分类脑电情绪识别:基于PyTorch复现DEAP和MAHNOB

Python实现DNN与CNN二分类脑电情绪识别:基于PyTorch复现DEAP和MAHNOB 简介面向脑电情感计算与深度学习交叉领域的研究者本压缩包提供基于DEAP与MAHNOB数据集的二分类情绪识别完整实现方案源自2021年顶级期刊论文采用PyTorch框架搭建深度神经网络DNN与卷积神经网络CNN两类模型并配套清晰的代码结构与统计检验流程。读者可复现DNN在特定训练集达最高精度、CNN平均更优以及两数据集上表现稳健的结论。压缩包共27个文件包括12个Python脚本、8个YAML实验配置、4个预训练权重文件另有论文PDF与说明文档大小约324.33MB。已有1940人学习。目录按预训练模型、神经网络定义、统计分析、数据加载等模块划分支持直接加载权重执行效价/唤醒二分类并调用McNemar与5x2cv检验对模型和数据集交叉比较适合需要快速开展脑电情绪识别实验的研究者与进阶学习者。1. 用DEAP和MAHNOB做二分类脑电情绪识别PyTorch下的DNN和CNN为什么值得复现跑过DEAP或MAHNOB的人应该都有同感论文里“二分类脑电情绪识别”看着只有两个输出真正复现时却总在一堆预处理细节上翻车。这个标题正好把边界卡死了——数据集固定为DEAP和MAHNOB模型固定为PyTorch下的DNN和CNN任务固定为二分类不做多分类也不做回归。换句话说它就是一整套可以直接照做的情绪识别基线从公开数据集下载、清洗打标签到用深度神经网络和卷积神经网络对比精度。适合两类人来读第一次做脑电加深度学习的想拿公开数据快速跑通一条流水线以及已经在做情绪识别但还没确定DNN和CNN哪个更适合自己特征规模的。2. 把DEAP和MAHNOB整理成二分类数据集预处理、标签阈值与滑窗代码2.1 DEAP和MAHNOB的文件结构拿到.mat先确认维度DEAP数据集下载之后解压出来的是一个data_preprocessed_matlab目录每个被试一个mat文件文件名从s01.mat一直排到s32.mat。这个目录是官方预处理过的版本每个mat里通常有两个关键键data和labels。data的形状是[40, 40, 8064]含义是40个视频片段、40个通道、8064个采样点。40个通道里前32个是脑电通道后8个是眼电、肌电、呼吸、体温这类外围信号8064等于128Hz采样率乘63秒其中前3秒是刺激开始前的基线后60秒才是视频刺激本身。labels的形状是[40, 4]四列分别是唤醒度、价效、支配度和喜欢度每个维度的评分是1到9。MAHNOB的情况类似但不完全相同。MAHNOB的mat文件在不同发布版本里变量名并不统一有的叫EEG有的直接以session为单位拆开。用scipy.io.loadmat加载之后不要急着写预处理先把所有键名和形状打出来看一眼。最简单的一步是import scipy.io as sio raw sio.loadmat(DEAP/data_preprocessed_matlab/s01.mat) print(raw.keys()) print(raw[data].shape, raw[labels].shape)打印结果是判断后续代码怎么写的主要依据。DEAP的字段名几乎不会变MAHNOB则没那么稳定如果你直接套用网上某个仓库的load函数大概率会在键名上撞出KeyError。我一般会把这一步放在所有复现工作的最前面顺便把原始文件和后续生成的npy文件路径都记录下来省得写论文时说不清数据版本。2.2 二分类标签怎么切固定阈值5还是中位数分割情绪二分类最常见的做法是对价效或唤醒度做高低切分。DEAP的labels里第0列是唤醒度第1列是价效第2列支配度第3列喜欢度按顺序取值就能得到二分类目标。MAHNOB采用同样的1到9评分范围所以标签定义可以和DEAP共用一套逻辑。阈值怎么定这件事网上讨论很多。标准做法是取固定阈值5因为评分范围1到9的中间点就是5大于5算高唤醒度或高效价小于等于5算低。这个做法的好处是DEAP和MAHNOB之间标准一致论文里也好解释。另一种常见做法是按每个被试的中位数分割把每个被试自己的评分从中间砍成两类。中位数分割会让正负样本完全均衡准确率数字看起来更好看但代价是不同被试之间“高”和“低”的语义不再一致——一个人4分就算高另一个人可能7分才算高。固定阈值5虽然会让部分被试出现类别不平衡但在跨被试、跨数据集对比时更站得住脚。我的习惯是以固定阈值5为主结论中位数分割作为敏感性分析放在论文的补充表格里。这样既不会被审稿人质疑阈值挑选也能展示结果在不同标签定义下是否稳定。2.3 滑窗与PyTorch Dataset封装窗口长度、步长和归一化的坑原始数据是每段60秒的完整刺激直接整段输入模型也行但样本量只有40乘32等于1280个对深度神经网络来说太少。常见做法是用滑窗把长段切成短片段比如4秒一个窗口1秒滑动一次相当于把每段60秒的刺激切成57个样本。滑窗在样本量上提供两个数量级的收益但也让相邻窗口之间高度相关后面做交叉验证时需要注意划分方式这个问题我会在第5章详细展开。标准的数据集封装可以写成这样import numpy as np import torch from torch.utils.data import Dataset class EEGEmotionDataset(Dataset): def __init__(self, eeg_array, label_array, window_sec4, step_sec1, fs128): # eeg_array: [n_trials, n_channels, n_times] self.window_len window_sec * fs self.step_len step_sec * fs self.x [] self.y [] for i in range(eeg_array.shape[0]): data eeg_array[i] y label_array[i] for start in range(0, data.shape[1] - self.window_len 1, self.step_len): patch data[:, start:start self.window_len] self.x.append(patch) self.y.append(int(y)) self.x np.stack(self.x).astype(np.float32) self.y np.asarray(self.y).astype(np.int64) def __len__(self): return len(self.y) def __getitem__(self, idx): return torch.from_numpy(self.x[idx]), self.y[idx]这段代码的输入是[n_trials, n_channels, n_times]的三维数组输出是若干个[32, window_len]的窗口张量。参数里最值得调的是window_sec和step_sec。窗口太短会丢失情绪诱发的时间上下文窗口太长又会让卷积神经网络只能看到极少的样本数。常见配置是3秒到6秒步长等于窗口长度的一半这样相邻窗口既有一半重叠也不会让样本量膨胀到训练测试高度相关。fs要严格跟着数据集来DEAP预处理后是128HzMAHNOB的原始记录可能是256Hz如果你把256Hz数据当128Hz读窗口实际覆盖的时间长度就翻倍了。2.4 基线段怎么处理直接丢掉还是做差分预处理后的DEAP里前3秒是基线。大多数论文的做法是直接丢掉这3秒再滑窗因为刺激开始前的一段静息脑电对情绪识别的贡献并不稳定。但也有一种做法是把每个trial的后60秒信号减去自身基线段的均值做一次去漂移。这样处理对跨被试有好处因为不同被试在基线段的绝对幅值差异很大减去基线后能把大部分个体差异消掉。这里容易踩的坑是归一化时机。Dataset里如果直接对全部数据做z-score也就是对所有被试、所有窗口一起计算均值和标准差那这个均值和标准差就已经偷偷看到了测试集的信息。正确做法是先把训练集和测试集按被试切开再在训练集上拟合标准化参数把参数保存下来套用到测试集。很多第一次跑的代码看着准确率特别高一换被试直接崩多半就是在这里埋的雷。3. PyTorch里的DNN和CNN网络结构、参数与输入形状设计3.1 DNN基线展平后的三层全连接加Dropout用PyTorch基础框架搭DNN其实很简单核心就一句话把[32, 512]的窗口展平成16384维向量再过几个全连接层。以4秒窗口、128Hz采样率为例每个样本是32通道乘512个时间点展平就是16384。这个维度比图像小得多但直接喂给全连接层仍然需要控制参数量。一个实用的DNN基线如下import torch import torch.nn as nn class DNN(nn.Module): def __init__(self, input_dim, hidden_dims(256, 128), dropout0.5): super().__init__() layers [] prev input_dim for h in hidden_dims: layers.append(nn.Linear(prev, h)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) prev h layers.append(nn.Linear(prev, 1)) self.net nn.Sequential(*layers) def forward(self, x): return self.net(x.flatten(start_dim1))hidden_dims选择(256, 128)时的参数量主要在第一层16384乘256大约是420万个参数。这么大规模的参数放到只有几千个窗口的数据集上几乎必然过拟合所以Dropout是DNN最关键的组件不要省。dropout默认0.5对情绪识别这种小样本任务还算是合理的起点。DNN在这里的作用是作为对比基线它不利用通道之间的空间关系也不利用时间维度的局部性。如果DNN和CNN在同一个划分下准确率非常接近说明数据中真正有效的可能主要来自幅值分布这类全局特征而并非精细的时序模式。3.2 CNN 1D还是CNN 2D先按时间维做卷积熟悉CNN基本结构的人都知道经典的组合是卷积、批归一化、激活、池化。把图像换成脑电最容易出错的不是结构而是输入形状。脑电的天然形式是[通道数, 时间长度]也就是[32, 512]这是一个典型的1D卷积输入通道放在通道维卷积核沿时间方向滑动。每个卷积核同时看见所有电极所以它学的是多个电极在某个短时间段内的联合模式。常见的1D卷积网络可以这样写import torch.nn as nn class CNN1D(nn.Module): def __init__(self, n_ch32, n_classes1): super().__init__() self.features nn.Sequential( nn.Conv1d(n_ch, 32, kernel_size9, padding4), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(4), nn.Conv1d(32, 64, kernel_size7, padding3), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.classifier nn.Linear(64, n_classes) def forward(self, x): x self.features(x) return self.classifier(x.view(x.size(0), -1))这里用的是kernel_size9和7而不是图像任务里惯用的3x3。原因在于脑电信号在128Hz采样下一个9点的卷积核只能覆盖约70毫秒两层堆叠后感受野大约100到150毫秒。这个长度能捕获ERP和alpha波的部分特征但对更长的delta波或theta节律来说仍然偏短。如果窗口是4秒建议把第一层kernel加大到15甚至31或者堆到三层卷积让最终感受野覆盖300到500毫秒。EEG不是图像小卷积核在这里并不总是优势。CNN2D是另一种路线把[1, 32, 512]当作单通道图像用Conv2d同时卷通道维和时间维。这种做法的风险在于通道顺序本身就是随意排列的额叶和枕叶电极在数组里可能相距很远卷积核却把它们当成了相邻关系。除非你有明确的通道排布依据比如根据电极坐标重排成二维地形图否则我建议优先尝试1D卷积它更容易解释。3.3 从SVM到CNN手工特征和自动特征之间的距离早期脑电情绪识别的主流不是神经网络而是提取功率谱密度、差分熵、Hjorth参数这类手工特征再用SVM分类。很多人问svm和cnn原理的区别是什么落在脑电这个具体问题上区别不在分类器本身的数学形式而在特征是怎么来的。SVM路线中一段60秒脑电会被压缩成几十个统计量情绪诱发过程中局部的时间演化信息基本被抹掉了。CNN路线则直接对原始波形做时间卷积保留短时模式再通过池化逐步聚合。这不是说CNN一定更好。DEAP只有32个被试样本量有限CNN在这种数据上很容易被SVM追平甚至超过。但CNN的好处是特征随任务一起优化换到另一个情绪数据集时不需要重新设计特征。所以这套复现方案里DNN和CNN并列是有意义的先用DNN确认全局特征的有效性再用CNN验证局部时序模式能不能带来增益。3.4 模型实例化与参数量核对搭建完模型后第一步不是训练而是确认输入输出形状和参数量。一个2080Ti级别的显卡跑这种小规模数据毫无压力但如果模型代码有拼写错误往往要跑几分钟才报错很浪费时间。先打印模型结构和参数能提前拦下大多数低级错误。def inspect_model(model, sample_input): out model(sample_input) print(output shape:, out.shape) total_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(trainable params:, total_params) dnn DNN(input_dim32 * 4 * 128) cnn CNN1D(n_ch32, n_classes1) x torch.randn(8, 32, 512) inspect_model(dnn, x) inspect_model(cnn, x)输出形状都应该是[8, 1]。如果CNN这边因为池化把时间维度压成0而报错优先检查输入长度是不是小于kernel加padding的约束。参数量方面这个DNN大约五百万左右CNN1D只有几万到十几万量级差距非常明显。DNN参数多但训练快CNN参数少却需要更多轮次才能收敛这也是后面调训练策略时要考虑的差异。4. PyTorch训练循环与跨被试评估损失、随机种子和指标怎么设4.1 跨被试还是被试内划分方式直接决定结论训练集和测试集怎么划分是这篇复现文章里最重要的一个决定。常见做法有两种。第一种是试验内划分把每个被试的视频按比例打乱一部分试验训练一部分试验测试。这种划分简单准确率也高但实际使用场景中你不太可能拿到同一个人的大量情绪脑电再去做预测所以论文里的说服力有限。第二种是跨被试划分训练集和测试集来自不同被试比如DEAP的32个被试里留下一个做测试其余31个做训练轮流做32次最后平均。这种划分方式才真正模拟“对新用户做情绪识别”的现实需求。推荐用第二种也就是留一被试交叉验证。代码里要注意滑窗之后同一个个体的不同窗口会同时出现在训练和测试集里导致评估结果虚高。正确做法是按照被试编号来切分而不是按窗口索引切分。先把被试列表划分成训练组和测试组再分别构造Dataset。4.2 损失函数、优化器和学习率BCE配Adam是默认配置二分类任务的默认损失是BCEWithLogitsLoss。这个损失内部把sigmoid和二元交叉熵合在一起前向传播时直接输出logits不要在模型最后一层加sigmoid。优化器第一选择是Adam学习率初始值给1e-3weight_decay给1e-4。在小规模脑电数据上不调任何参数直接用SGD大概率收敛慢Adam在头几个epoch就能看到明显的loss下降。学习率策略我一般配一个ReduceLROnPlateau当验证loss连续三个epoch不降时把学习率乘以0.5。这个方法在脑电情绪识别上比固定epoch数更稳因为不同被试的loss曲线差异很大固定学习率很容易在某个折上震荡。4.3 一个完整的最小训练循环把前面的模块组合起来就是下面这个最小可用的训练脚本。关键部分包括固定随机种子、把数据放到设备上、每个epoch里交替训练和验证。import os import random import numpy as np import torch from torch.utils.data import DataLoader def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, seen 0.0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x).view(-1) loss criterion(logits, y.float()) loss.backward() optimizer.step() total_loss loss.item() * len(y) preds (torch.sigmoid(logits) 0.5).int() correct (preds y).sum().item() seen len(y) return total_loss / seen, correct / seen def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, seen 0.0, 0, 0 preds_all, y_all [], [] with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) logits model(x).view(-1) loss criterion(logits, y.float()) total_loss loss.item() * len(y) preds (torch.sigmoid(logits) 0.5).int() preds_all.extend(preds.cpu().numpy()) y_all.extend(y.cpu().numpy()) correct (preds y).sum().item() seen len(y) return total_loss / seen, correct / seen, np.array(preds_all), np.array(y_all)这段代码里最容易被忽略的是模型输出和标签的形状。BCEWithLogitsLoss要求logits和labels形状一致所以model(x)返回的是[batch, 1]时要用view(-1)压成[batch]。y.float()也是必要的一步因为标签从Dataset里出来是int64损失计算需要浮点数。随机种子的设置要放在数据加载和模型初始化之前。CNN里有很多用确定性算法实现的算子不设种子的话即使参数全固定每次跑出来的结果也可能因为CuDNN的随机性而漂移。4.4 指标不只是准确率加F1和AUC二分类数据如果存在类别不平衡准确率会因为多数类占比高而虚高。DEAP取固定阈值5时唤醒度或价效的高和低往往不是50比50所以评估时要额外算F1和AUC。上面evaluate函数已经返回了preds_all和y_all把它们交给sklearn就能得到完整指标from sklearn.metrics import f1_score, roc_auc_score, balanced_accuracy_score def report_metrics(y_true, y_pred, y_score): print(acc:, balanced_accuracy_score(y_true, y_pred)) print(f1:, f1_score(y_true, y_pred)) print(auc:, roc_auc_score(y_true, y_score))注意这里的y_score应该是sigmoid之后的概率而不是logits。如果直接在logits上算AUC其实也可以因为logits和概率是单调变换关系AUC不会改变但阈值0.5是定义在概率域上的为了统一还是先过一层sigmoid。写论文时我一般报告平衡准确率加AUC这两个指标对类别不平衡最不敏感。4.5 留一被试交叉验证的组织方式最后把上面所有部分串成一个循环。DEAP有32个被试就留出32折MAHNOB按可用被试数来定。每折先训练一个CNN或DNN记录测试指标最后汇总成均值加减标准差。for test_subj in range(n_subjects): train_subjects [s for s in range(n_subjects) if s ! test_subj] train_ds EEGEmotionDataset(load_subjects(train_subjects)[eeg], load_subjects(train_subjects)[label]) test_ds EEGEmotionDataset(load_subjects([test_subj])[eeg], load_subjects([test_subj])[label]) train_loader DataLoader(train_ds, batch_size128, shuffleTrue) test_loader DataLoader(test_ds, batch_size256, shuffleFalse) model CNN1D(n_ch32, n_classes1).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) criterion torch.nn.BCEWithLogitsLoss() for epoch in range(30): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) test_loss, test_acc, preds, y_true evaluate(model, test_loader, criterion, device)每折都重新初始化模型和优化器不要跨折延续上一步训练好的权重。训练轮数30到50就够DEAP上再多轮次也不会带来明显提升反而容易在测试集上过拟合到训练被试的个体特征。5. 复现这套二分类情绪识别时的5个避坑记录5.1 torch.cuda.is_available()为False环境没配好还是装错了包现象代码跑在GPU服务器上但torch.cuda.is_available()返回False训练始终用CPU速度慢到难以接受。原因最常见的是用pip install torch安装了CPU版本的PyTorch。PyTorch的默认PyPI包在某些系统上就是CPU构建另一个常见问题是CUDA版本和PyTorch要求的运行时不一致导致驱动识别不了。解决用anaconda配置pytorch环境新建一个独立环境再安装不要动系统全局环境。安装前先确认显卡驱动支持的CUDA版本然后到PyTorch官网复制对应CUDA版本的安装命令。装完立刻用torch.cuda.is_available()验证。如果只是复现DEAP和MAHNOB这种几千个窗口的小数据集其实CPU也够训练一轮可能就十几秒我经常就在CPU上跑搜索超参只在完整实验时才切GPU。5.2 归一化泄漏为什么验证集准确率比训练集还高现象在某个被试上测试时准确率达到80%以上甚至超过训练折但换一个被试立刻跌到55%完全不稳定。原因预处理时对所有被试、所有窗口一起做了z-score相当于测试集的均值和标准差已经参与了训练数据的变换形成了数据泄漏。CNN对输入幅值很敏感泄漏会帮模型提前拿到测试分布的信息换成新被试就原形毕露。解决先按被试切分训练折和测试折再在训练折上计算每个通道的均值和标准差保存下来用这组参数去标准化测试折。写成代码就是fit在训练集上调用transform在训练集和测试集上分别调用绝不把两组数据合在一起fit。这一步看起来简单但几乎是我见过最多的翻车点。5.3 固定阈值5和中位数分割的结果对不上现象用固定阈值5跑出来的F1只有0.62换成中位数分割之后变成0.71很多人就开始怀疑是不是模型有问题。原因固定阈值5会天然产生类别不平衡中位数分割则是硬性把每类凑成50比50。两个实验回答的问题不一样前者是“7分以上才算高兴”后者是“在每个人自己的相对情绪上做高低切分”结果差异是正常的不是bug。解决主实验用固定阈值5补充实验报告中位数分割两个都写在论文里。正文里不要混着用更不要因为中位数分割效果好就只报它审稿人一定会追问阈值怎么选的。5.4 滑窗重叠导致训练集和测试集互相“偷看”现象同一个被试的测试窗口和训练窗口高度重叠模型似乎记住了这个被试的噪声模式单折准确率异常高留一被试交叉验证时只有少数被试拉低整体分数。原因4秒窗口配1秒步长相邻窗口有3秒重叠。如果测试被试的某些窗口恰好和训练被试的某个窗口来自同一段视频的时间邻域特征高度相似造成空间泄漏。更严重的错误是把同一个被试同时放进了训练集和测试集。解决划分的最小单位是被试不是窗口。先按被试列表分成train_subj和test_subj再用各自的窗口构建Dataset。如果做的是被试内划分也要按video或trial来切不能让来自同一个trial的窗口跨越训练测试集合。5.5 换一个随机种子结果抖动超过5个点现象同一个模型、同一个数据划分把seed从42改成2024准确率从0.72掉到0.66不知道哪个数是真实水平。原因脑电数据本身就存在很大的个体差异DEAP只有32个被试留一交叉验证的均值和标准差都不小。加上滑动窗口让样本之间不独立有效样本量并没有表面看起来那么大。解决不要只跑一个随机种子。至少跑三个不同种子报告均值加减标准差。如果标准差太大说明模型没有在真正的情绪模式上收敛优先检查标签是否平衡、数据划分是否泄漏、窗口长度是否合适而不是急着换网络结构。6. 让复现结果更稳定的三个验证技巧和一个习惯第一个技巧是验证CNN感受野。搭建好模型后不要急着训练先算一下堆叠卷积层在128Hz下覆盖了多少毫秒。公式很简单每层感受野近似等于前一层感受野加当前层kernel_size减去1再乘以前面所有步长的乘积。如果最终感受野只有100毫秒网络可能只看到极短的波形片段对情绪这种几秒尺度的刺激来说信息量不足。我的做法是把窗口长度、kernel大小、层数和最终感受野做成一张表贴在实验记录里。第二个技巧是加一个平凡基线。在训练任何模型之前先跑一个“全部预测多数类”的规则如果训练集里正类占60%测试时永远预测正类。这样得到的准确率就是60%是所有模型必须超过的下限。很多时候DNN或CNN跑了半天准确率55%说明模型根本没有学到比多数类更好的信号问题大概率在标签定义或特征预处理上而不在模型代码里。第三个技巧是检查输入输出形状的断言。现在我的习惯是在模型forward的第一行写清楚x.shape的注释并在训练脚本开头用一个随机张量做前向传播测试确认输出shape是[batch, 1]。这个动作每天至少帮我省下半小时的排错时间。凡是遇到mat文件加载后维度不对、窗口计算边界越界这类问题assert通常能直接指出错在哪一行。最后说说我个人的复现习惯拿到一个新数据集第一件事不是跑模型而是打印所有mat文件的形状快照保存成一份txt放在项目根目录。训练脚本里写死输入维度任何数据路径更换导致的形状变化都会直接报错而不是默默把通道或时间点对齐错。这个习惯让我在DEAP和MAHNOB之间切换时没踩过输入维度的坑。模型结构可以反复换但数据入口必须稳定否则一次错误的通道对齐会让后面所有实验结果失去意义。希望帮到你。本文还有配套的精品资源点击获取
返回列表