
先说个结论DEAP这个数据集只要是做过脑电情绪识别的人大概率都绕不开它。网上能搜到的教程很多但大多数要么只放一段特征提取代码要么直接把整个深度学习流程糊上去新手跟着跑完也不知道每一步在干嘛。这篇博客我不会讲太多花哨理论就按我自己实际跑通的路径来从DEAP数据格式讲起到PyTorch环境配置再到DNN和CNN两种模型从数据处理到训练评估的完整代码最后把我踩过的坑一并放出来。如果你正准备拿DEAP练手或者想用脑电信号做分类任务却不知道怎么把数据送进网络这篇应该能帮你省下不少时间。先说清楚这篇内容适合谁刚接触脑电深度学习、会用Python但没怎么碰过PyTorch的人最合适如果你已经用PyTorch做过图像分类那只需要重点看DEAP数据怎么预处理、脑电数据怎么设计成网络输入这两块。整个流程我会用单个被试的40个试次做演示保证代码在普通CPU笔记本上就能跑不会一上来就被显存卡死。1. 认识DEAP数据集不知道这些细节后面白折腾1.1 数据格式与通道布局DEAP全称是Database for Emotion Analysis using Physiological Signals由伦敦玛丽女王大学发布是目前脑电情绪识别领域最常用的基准数据集之一。它记录了32名被试在观看40段1分钟音乐视频时的生理信号采样率128Hz。数据维度(40试次, 40通道, 8064采样点)每个试次包含前3秒基线静息态数据384个采样点前32个通道是EEG脑电信号后8个通道是眼电、肌电、呼吸等外围生理信号这8064个采样点是怎么来的1分钟视频 3秒基线 63秒63 × 128Hz 8064点。所以实际有效脑电数据是后面的60秒即7680点。很多人第一次加载DEAP时容易忽略这个基线问题直接把8064点全部作为输入结果模型被判别的不是情绪而是基线漂移和噪声这个后面会详细说。1.2 标签含义与任务定义DEAP的标签是四维评分每个试次对应一组标签含义取值范围valence效价积极/消极1~9arousal唤醒度平静/兴奋1~9dominance支配度服从/主导1~9liking喜好程度1~9最常见的任务是把valence或arousal做二分类。比如对valence设定阈值5大于5算积极情绪小于等于5算消极情绪。这样每个被试40个试次就对应40个二分类标签。有人会纠结阈值到底是5还是4.5实际上不同论文选取方式略有差异。我自己习惯以5为分界理由很简单DEAP评分范围是1-99等分的中点位置更自然而且这样正负样本比例通常不会太离谱。你也可以尝试把标签转成回归任务但二分类更适合入门评估指标也更直观。1.3 任务定义与模型路线选择确定标签维度之后下一个要解决的问题是模型路线。传统做法是提取功率谱密度、微分熵等特征然后丢给SVM或随机森林但这类方法对特征的工程化要求很高特征提得好坏直接影响上限。而深度学习路线可以分成两种思路DNN输入的是人工设计的统计特征网络结构简单容易跑通CNN输入的是经过整理的原始信号片段省去部分人工特征工程但需要设计好输入布局我的建议是两条路线都做一遍。先用DNN建立基线结果确认数据流程没有问题再上CNN看能不能进一步提升。这样如果模型效果不理想你能分清是数据预处理的问题还是网络结构的问题而不是两头抓瞎。2. 环境搭建与数据预处理决定成败的前置环节2.1 10分钟把PyTorch环境跑起来PyTorch的安装有很多种方式我用Anaconda管理环境实测最顺手。注意不要直接在base环境里装容易和已有包冲突。conda create -n eeg python3.9 conda activate eeg pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu如果机器有NVIDIA显卡先去官网根据CUDA版本选择对应的安装命令。CPU版本跑这个小项目完全够用因为单个被试的数据量并不大。接着安装数据处理需要的库pip install numpy scipy scikit-learn matplotlib pandasscipy主要负责加载.mat数据scikit-learn用于标准化和评估指标matplotlib用来画训练曲线。这些库搭配起来后面写代码时基本不用再回头补装。2.2 加载.mat文件与基线去除DEAP官网提供两种版本的数据预处理版和未预处理版。预处理版本已经做了降采样到128Hz、去除眼电伪迹等操作文件放在data_preprocessed_python目录下每个被试一个文件命名格式是s01.mat、s02.mat一直到s32.mat。用scipy.io.loadmat就能直接读取import numpy as np import scipy.io as sio def load_deap(subject_id, base_pathdata_preprocessed_python): mat sio.loadmat(f{base_path}/s{subject_id:02d}.mat) data mat[data].astype(np.float32) # (40, 40, 8064) labels mat[labels].astype(np.float32) # (40, 4) # 只保留前32个EEG通道去掉前3秒基线384点 eeg data[:, :32, 384:] return eeg, labels这一步有两个关键细节第一mat[data]默认是double类型要转成float32否则后面输入PyTorch时会多一步类型转换还会白白多占一倍内存。第二去掉前3秒基线不是可选项。基线段的脑电本身就包含被试在视频开始前的静息状态如果保留它模型完全可能学习到“当前是否处于任务状态”这种与情绪无关的特征导致结果虚高。注意DEAP文件下载需要在其官网填写申请表单之后链接会发送到邮箱。下载的是压缩包解压后文件都是.mat格式Windows和macOS都能正常识别。2.3 DNN版输入统计特征提取DNN不能直接吃原始信号因为如果把单个试次的7680个采样点全部展平32通道就是245760维小样本撑不起这么大的输入维度。所以要先做特征提取把每个试次压缩成一个低维向量。我在这个项目里用的特征组合是每个通道提取均值、标准差、四个频带功率theta 4-8Hz、alpha 8-12Hz、beta 12-30Hz、gamma 30-45Hz每个通道6个特征32个通道一共192维。这个特征组合兼顾了时域统计和频域能量提取成本低信息覆盖相对完整。def compute_band_power(signal, fs128): spectrum np.fft.rfft(signal) power np.abs(spectrum) ** 2 freqs np.fft.rfftfreq(len(signal), d1/fs) bands { theta: (4, 8), alpha: (8, 12), beta: (12, 30), gamma: (30, 45), } result [] for lo, hi in bands.values(): mask (freqs lo) (freqs hi) result.append(np.mean(power[mask]) if mask.any() else 0.0) return result def extract_features_from_trial(trial): feats [] for ch in range(trial.shape[0]): signal trial[ch] feats.append(np.mean(signal)) feats.append(np.std(signal)) feats.extend(compute_band_power(signal)) return np.array(feats, dtypenp.float32)关于频带功率这里用的是直接FFT之后取对应频段的平均功率。理论上应该用Welch方法或者多窗谱估计更稳健但对入门项目来说简单FFT已经能提供足够有区分度的特征。想要复现更严格的频带特征可以把compute_band_power内部换成scipy.signal.welch。2.4 数据标准化与训练集划分特征提取完之后一定要做标准化。脑电信号的幅值在不同被试、不同通道之间差异很大不做标准化会让网络训练变慢甚至不收敛。标准化的关键点在于只用训练集的数据去计算均值和标准差然后用这组统计量去转换测试集。这是机器学习的基本规矩但很多初学者会顺手对整个数据集做StandardScaler造成轻微的信息泄漏。from sklearn.preprocessing import StandardScaler def build_dnn_dataset(eeg, labels, valence_threshold5.0): feats [] for trial_idx in range(eeg.shape[0]): feats.append(extract_features_from_trial(eeg[trial_idx])) feats np.stack(feats) # (40, 192) y (labels[:, 0] valence_threshold).astype(np.int64) # valence二分类 return feats, y # 单个被试40个试次 X_all, y_all build_dnn_dataset(X, y_raw) # 前32个试次训练后8个试次测试 X_train, X_test X_all[:32], X_all[32:] y_train, y_test y_all[:32], y_all[32:] scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test)这里按试次顺序划分而不是随机打乱是为了复现简单。实际做交叉验证时最好打乱后按比例划分或者用StratifiedKFold保证类别比例一致。后面讲到CNN时会说明为什么窗口化之后更要小心划分。3. DNN模型从特征向量到情绪类别3.1 全连接网络为什么能用在脑电上全连接网络DNN的假设很简单输入特征之间两两都有交互网络通过隐藏层的非线性组合来拟合从特征到标签的映射。在脑电任务里192维的特征向量已经浓缩了每个通道的统计属性和频带能量DNN要做的就是自动学习这些特征的高阶组合规律。比如某个情绪状态下前额叶通道的theta频带功率升高、颞叶通道的beta频带功率降低这种跨通道的联合模式就是DNN擅长捕捉的。传统SVM得手动构造这些组合而DNN可以在训练中自适应地调整权重组合。DNN的结构不需要太深。输入维度才192堆个五六层全连接反而容易过拟合毕竟单个被试只有40个试次。我的经验是两到三个隐藏层就足够配合Dropout和BatchNorm来控制过拟合。3.2 模型结构、损失函数与评估指标import torch import torch.nn as nn class DNNClassifier(nn.Module): def __init__(self, input_dim, hidden_dims[128, 64], num_classes2): super().__init__() layers [] for dim in hidden_dims: layers.append(nn.Linear(input_dim, dim)) layers.append(nn.ReLU()) layers.append(nn.BatchNorm1d(dim)) layers.append(nn.Dropout(0.3)) input_dim dim layers.append(nn.Linear(input_dim, num_classes)) self.net nn.Sequential(*layers) def forward(self, x): return self.net(x)损失函数用交叉熵损失CrossEntropyLoss这是分类任务的标准选择。优化器用Adam初始学习率设为0.001。评估指标主要看准确率同时计算混淆矩阵因为分类结果只有两个类别只看准确率可能会被类别不平衡误导。BatchNorm1d在这里需要注意如果某个batch只有1个样本它无法计算均值方差。但我们用的是Mini-batch训练batch_size至少16这个问题基本不会遇到。3.3 DNN训练主代码import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader def train_model(model, X_train, y_train, X_test, y_test, epochs30, batch_size16): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) train_dataset TensorDataset( torch.from_numpy(X_train).float(), torch.from_numpy(y_train).long() ) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) for epoch in range(epochs): model.train() total_loss, correct, total 0.0, 0, 0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() optimizer.step() total_loss loss.item() * xb.size(0) correct (out.argmax(1) yb).sum().item() total yb.size(0) train_acc correct / total model.eval() with torch.no_grad(): test_out model(torch.from_numpy(X_test).float().to(device)) test_acc (test_out.argmax(1).cpu().numpy() y_test).mean() if (epoch 1) % 5 0: print(fEpoch {epoch1:02d} | Loss {total_loss/total:.4f} | fTrain Acc {train_acc:.4f} | Test Acc {test_acc:.4f}) return model这段代码是完整的训练循环。如果你完全是个PyTorch新手可以关注几个核心点model.train()和model.eval()切换影响BatchNorm和Dropout的行为torch.from_numpy(...).float()把NumPy数组转成PyTorch张量out.argmax(1)取每个样本预测概率最大的类别with torch.no_grad()推理阶段不需要计算梯度省内存提速在单个被试、40个试次的数据上这套DNN的测试准确率大概在60%-75%之间波动具体取决于被试和随机种子。别指望一步登天到90%那是全部32个被试联合训练才可能达到的效果。4. CNN模型把脑电当作图像来处理4.1 脑电信号如何转成图像输入CNN在脑电领域的核心思想是把每个试次的通道信号组织成一个二维矩阵形状是(通道数, 时间点数)然后把这个矩阵当成单通道图像输入网络。这样做的动机很直接卷积核可以同时捕捉时间维的局部波形模式和空间维的通道间同步关系。比如一个(32, 256)的输入可以理解为一张宽256像素、高32像素的单通道灰度图水平方向是时间轴垂直方向是通道轴。卷积核在水平方向移动时就是在提取不同时间窗口的波形特征在垂直方向移动时就是在整合相邻通道的特征。不过需要强调一点CNN并没有真正利用脑电通道的空间拓扑位置。通道顺序在DEAP里虽然是按国际10-20系统排布的但我们把它放进矩阵后相邻关系并不完全等同于大脑皮层的物理相邻关系。所以CNN在这里更多是提取时间局部特征和通道间的统计相关而非严格的空间拓扑特征。这也是为什么EEG领域有图神经网络的方向。4.2 窗口滑动的正确姿势如果直接把单个试次的7680点组织成(32, 7680)输入CNN计算量偏大且时间维度过长卷积核要覆盖足够大的感受野才能看到有意义的波形。更实用的做法是窗口化把60秒的数据切成固定长度的时间窗每个窗口作为独立样本。def make_window_data(eeg, labels, window_len256, step128, zscoreTrue): samples, targets [], [] for trial_idx in range(eeg.shape[0]): trial eeg[trial_idx] for start in range(0, trial.shape[1] - window_len 1, step): window trial[:, start:start window_len] if zscore: mean window.mean(axis1, keepdimsTrue) std window.std(axis1, keepdimsTrue) 1e-8 window (window - mean) / std samples.append(window) targets.append(labels[trial_idx]) samples np.array(samples)[:, np.newaxis, :, :] # (N, 1, 32, 256) targets np.array(targets) return samples, targets这里window_len256对应2秒的脑电信号step128表示相邻窗口有50%重叠。2秒窗口包含的信息足够计算频带特征50%重叠用来增加样本数量。每个试次能产生约59个窗口40个试次就是2360个样本这对CNN来说才算勉强够用。zscoreTrue表示对每个窗口单独做标准化。这个细节很重要脑电信号存在漂移不同试次、不同时间段的基线幅值不一样按窗口内均值方差归一化可以有效缓解这个问题。4.3 网络结构、训练与关键经验import torch.nn.functional as F class EEGCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)) ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, num_classes) ) def forward(self, x): x self.conv(x) x x.view(x.size(0), -1) return self.classifier(x)这里我故意用了AdaptiveAvgPool2d它能把任意尺寸的特征图池化成(1, 1)省去手动计算全连接层输入维度这一步。很多新手写CNN最痛苦的就是最后Flatten之后维度对不上用自适应池化可以从根上避免这个问题。训练代码和DNN几乎一样只需要换成对应的DataLoaderfrom torch.utils.data import Dataset, DataLoader class EEGWindowDataset(Dataset): def __init__(self, X, y): self.X torch.from_numpy(X).float() self.y torch.from_numpy(y).long() def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx] # 按试次划分前32个试次的窗口用于训练后8个试次的窗口用于测试 X_w, y_w make_window_data(X, y_all) train_mask np.repeat(np.arange(40) 32, 59)[:len(y_w)]这里有个大坑一定要说清楚窗口化之后样本数暴增但同一试次产生的窗口高度相关。如果随机划分训练集和测试集同一个试次的窗口可能一部分在训练、一部分在测试模型相当于见过几乎一样的样本测试准确率会虚高到80%甚至90%。正确的做法是先按试次划分再窗口化。为了避免上面这种手动mask容易出错的写法更稳妥的做法是先切分再窗口化X_train_raw, X_test_raw X[:32], X[32:] y_train_raw, y_test_raw y_all[:32], y_all[32:] X_train_w, y_train_w make_window_data(X_train_raw, y_train_raw) X_test_w, y_test_w make_window_data(X_test_raw, y_test_raw) train_dataset EEGWindowDataset(X_train_w, y_train_w) test_dataset EEGWindowDataset(X_test_w, y_test_w) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)CNN在同样单个被试数据上的测试准确率通常比DNN高几个点我实测大概在65%-80%之间。提升不算夸张但胜在不需要手工做频带特征提取端到端学习的能力在更多被试联合训练时会更明显。5. 训练效果对比与坑点复盘5.1 两种模型在DEAP上的表现对比以单个被试为例跑同一组随机种子我的结果大致如下模型输入形式样本数测试准确率参考训练耗时DNN192维统计特征40试次60%-75%秒级CNN32×256窗口图像2360窗口65%-80%分钟级注意这个准确率范围没有任何保证性。DEAP数据的个体差异非常大有的被试天生正负样本可分性强随便跑都能到85%有的被试数据噪声大怎么调参都徘徊在60%。所以别看到某个数字就焦虑做这个任务应该关注的是流程是否跑通、模型是否学到了有效特征以及后续如何扩展。如果要用全部32个被试做分类通常有两种组织方式。一种是按被试划分留出几个被试作为测试集考察模型的跨被试泛化能力另一种是把所有被试的数据混合按试次划分考察整体识别能力。前者更难也更接近实际应用场景建议有精力的话向这个方向进阶。5.2 防止数据泄漏的实操细节数据泄漏在脑电深度学习里是个隐蔽且致命的问题我自己犯过不止一次。最典型的就是窗口化之后的随机划分。前面说过同一试次的两个相邻窗口高度重叠如果它们一个在训练集一个在测试集卷积核只要记住该试次的整体幅值水平就能得到不错的效果这样的模型换个新被试就废了。另一个容易忽略的泄漏点是特征标准化。做窗口级或者试次级标准化时如果先在整个数据集上算mean和std再划分训练测试测试集的信息就偷偷混进了训练过程。正确顺序永远是先划分再在训练集上fit标准化器最后transform测试集。还有一点是超参数调优时反复使用同一个测试集。比如你跑了20组参数每次都看测试准确率最好的那组一定会在测试集上偏高。这是隐式过拟合无法完全避免但至少要知道它存在。5.3 内存、随机种子和复现性EEG深度学习项目里内存爆掉是绕不开的话题。DEAP单个被试全窗口化后样本量不大但如果扩展到32个被试每次窗口都要存一份独立副本内存占用会成倍增长。我实际遇到的情况是全量窗口化后NumPy数组占用超过8GB直接把训练前筵吃光了。解决办法有两个方向一是用生成器在__getitem__里按需切窗口而不是一次性生成全量数组二是用更小的窗口长度和更大的步长来控制样本总数。两者可以组合使用。随机种子的问题也值得专门说def set_seed(seed42): np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)加上随机种子能保证你自己复现但跨机器、跨PyTorch版本仍然可能有细微差异。所以在报告结果时不要只报一次运行的数字最好跑5次取均值。这个表格里的准确率看起来不高原因之一就是训练集太小单次运行波动极大取均值后数字会更朴素也更诚实。5.4 CNN训练参数建议CNN训练和DNN有一些不同。由于窗口化后样本量变大batch_size可以设到64学习率维持0.001epochs我会多跑一点比如50轮。如果loss在验证集上连续5轮不下降就提前停止避免过拟合。Dropout比例在DNN和CNN里我都用了0.3到0.5。脑电信号信噪比低模型很容易记住噪声中的伪规律加大Dropout反而稳定。卷积核大小也是一个可以调节的点。kernel_size3只感知极小的时间邻域如果想捕捉节律性特征可以考虑把卷积核改成(1, 64)即通道维度保持1、时间维度扩展到0.5秒。这种非对称卷积核在处理脑电信号时往往比方形卷积核更有效。我把这个作为建议写出来因为DEAP上的很多实验都验证过这对提升准确率有帮助。6. 常见问题速查表与避坑技巧6.1 常见问题与解决方案表格里整理的是我在这个项目中实际遇到过、并且确认能解决的问题问题现象解决思路DataLoader在Windows下报错出现BrokenPipeError或进程卡死Windows上num_workers设为0不要用多线程加载数据mat文件加载失败sio.loadmat报错或读取到乱码确认文件路径没有中文和空格必要时改为绝对路径维度对不上进入全连接层时view尺寸错误用AdaptiveAvgPool2d替代手动计算或打印中间层shapeLoss不下降训练和测试准确率都在50%附近检查标签是否被标准化覆盖、类别分布是否严重失衡测试准确率虚高测试集Acc超过90%明显不合理检查是否窗口化前就随机划分是否标准化时混入测试集训练速度变慢epoch耗时越来越长PyTorch默认缓存机制导致减少DataLoader预取或调小batch_size第一个问题非常重要。Windows上DataLoader的num_workers参数千万别设置成大于0Python多进程在Windows上的实现方式和Linux不同经常会导致DataLoader卡死。开发环境是Windows的话所有DataLoader都不传num_workers或者直接写num_workers0。6.2 我的几条实在建议单被试数据跑出结果之后不要急着堆模型复杂度。先做最基础的稳定性验证换几个随机种子看结果方差大不大画一下训练曲线看有没有明显的过拟合迹象。如果方差极大说明数据量撑不起当前模型这时候加正则化比加深网络更有效。另外脑电情绪识别是一个非常大的方向DEAP只是入门数据。等你在DEAP上把流程跑顺了可以尝试跨被试实验或者用更细粒度的情绪标签做回归。模型也可以从CNN换到LSTM或者Transformer避免被我的示例框架束缚住。我觉得初学者最容易犯的错误是过度关注准确率数字而忽略了整个pipeline的可靠性。数据划分是否干净、特征提取是否泄漏、评估指标是否合理这些环节只要有一个不严谨任何高性能结果都是空中楼阁。我自己做完这个项目后的最大体会是脑电深度学习的难点不在模型而在数据处理和实验设计上把这两块做扎实模型反而是水到渠成的事。