DEAP数据集在情绪识别领域几乎成了绕不开的第一站。去年我打算在上面跑通一个完整的情绪分类pipeline时,最没想到的是真正花时间的地方不是模型设计,而是环境搭建和数据读取——光是理解那批.mat文件里每个维度的含义,就折腾了一下午。这篇东西我尽量把手摸过的地方都写出来,从文件结构、环境匹配、预处理、模型训练到评估结果,串成一个可以直接照着跑的完整流程。适合刚下载完DEAP但不知道从哪里下手的同学,也适合想快速在DEAP上先跑出一个baseline的人。
1. DEAP数据集解剖:先搞清楚手里的数据到底是什么
很多教程一上来就让人读文件、跑模型,结果读完还是不知道自己在处理什么信号。我觉得先花20分钟把DEAP的构成弄清楚,比直接敲代码重要得多。
1.1 文件与数据结构
DEAP全称是Database for Emotion Analysis using Physiological Signals,50字不到就把这句话记住:32名被试,每名被试看40段1分钟音乐视频,观看过程中记录生理信号,看完后对效价(valence)、唤醒度(arousal)、支配度(dominance)、喜好(liking)四个维度打分。
下载官方预处理版本后,你会得到一个data_preprocessed_matlab目录,里面有participant_01.mat到participant_32.mat,一共32个文件。每个.mat文件内部就两个变量:
data,形状是(40, 40, 8064),三个维度分别是视频数、通道数、采样点个数。labels,形状是(40, 4),对应40段视频在四个评分维度上的分值。
采样率是128Hz,8064个采样点换算一下就是63秒,因为每段视频实际包含3秒基线加上60秒正式观看。很多人第一次会疑惑“为什么不是60秒×128=7680”,多出来那384个点就是这段基线,后面预处理会处理掉。
1.2 40个通道到底分别是什么
这是最容易被忽略的细节。40个通道不是同一种信号,它们分了两组:
- 前32个通道是脑电信号,按国际10-20系统排布,具体顺序大概是Fp1、AF3、F3、F7、FC5、FC1、C3、T7、CP5、CP1、P3、P7、PO3、O1、Oz、Pz,然后是右侧对应位置,完整顺序在DEAP官网的README文件里有。
- 后8个通道是外周生理信号,顺序是水平眼电、垂直眼电、两块面部肌电、皮肤电、呼吸、血容量脉搏和体温。
这对建模的影响非常大。有些人把40个通道全丢进CNN里,EEG和外周信号一起卷,不能说一定不行,但两者的物理含义、噪声特性和数值范围差别很大,混在一起并没有理论上那么自然。我的建议是:第一版模型先用前32个EEG通道,把基础流程跑通,再考虑融合外周信号。
1.3 读取一份数据验证你的理解
检查自己有没有读对,用这段代码就够了:
from scipy import io import numpy as np mat = io.loadmat('data_preprocessed_matlab/participant_01.mat') data = mat['data'] labels = mat['labels'] print(data.shape, labels.shape) print(data.dtype, labels.dtype) # 基线前384个点与正式时段前384个点的均值对比 print(data[0, 0, :384].mean(), data[0, 0, 384:768].mean())打印正确的话,data应该是(40, 40, 8064),labels是(40, 4),dtype是float64。如果后面报错说读不了,大概率是.mat文件的存储格式问题,这个坑我放在下一节讲。
另外提醒一点:网上流传的一些别人二次处理过的DEAP版本,可能把原始矩阵转了维度,有的是(40, 8064, 40),有的把标签也改了格式。拿到新版本先打印shape再往下走,别默认官方格式。
2. 环境搭建的版本匹配:少走依赖弯路
DEAP这套代码不算重,但版本搭配不对,光在装库上就能耗掉半天。我自己就踩过scipy版本太老导致loadmat对.mat文件里的unicode字段解析异常的坑。
2.1 推荐环境与安装命令
我用的组合是Python 3.10 + scipy + numpy + scikit-learn + PyTorch。具体版本范围可以参考这个表:
| 依赖库 | 推荐版本 | 用途 |
|---|---|---|
| Python | 3.8~3.11 | 解释器版本 |
| numpy | 1.24及以上 | 多维数组处理 |
| scipy | 1.10及以上 | 读取.mat文件 |
| scikit-learn | 1.2及以上 | 划分数据、计算指标 |
| PyTorch | 2.0及以上 | 模型训练 |
| matplotlib | 3.7及以上 | 可视化信号 |
安装就直接用conda,强烈建议单独建一个虚拟环境,别往base里硬塞:
conda create -n deap python=3.10 -y conda activate deap pip install numpy scipy matplotlib scikit-learn torch这里我要说清楚为什么用conda而不是一把梭全装。主要倒不在于conda本身多好用,而是pyenv、venv那一套在Windows和Linux上的行为略微不同,团队成员协作时很容易出现“我这边跑得通你那边跑不通”。conda环境切换直觉、包版本锁定清晰,对于这类数据处理项目足够省心。
2.2 about那个.mat读取报错
有些人在io.loadmat这一步就直接翻车,报错信息类似于NotImplementedError: Please use HDF reader for matlab v7.3 files。原因是.mat文件分了几种格式,DEAP官方给的是旧格式,scipy能直接读;但如果你下载的是别人转存过的、或者是自己用新版本MATLAB重新保存过的v7.3格式文件,scipy就无能为力。
这种情况下有两个办法:一是回到官网重新下载原始预处理文件;二是用h5py读,但读出来会多一个转置问题,因为MATLAB按列存储而h5py按行描述,一般需要对矩阵做转置才能恢复成(40, 40, 8064)。我个人建议直接换官方原始文件,最省事。
2.3 关于运行平台和字体的一些废话
我是Windows主力开发、偶尔切WSL Ubuntu跑实验。DEAP这套流程在Windows和Ubuntu上都跑过,并没有平台相关的坑,唯一感受是WSL里如果要用matplotlib画图显示中文,字体配置有点麻烦,建议直接用英文标签,心态会好很多。如果你也很在意写代码时的终端字体,想找接近macOS观感的,我个人在Ubuntu下用JetBrains Mono配合等宽配置,长时间看代码眼睛会舒服一点——但这跟DEAP本身没太大关系,属于开发体验范畴,提一嘴供参考。
3. 从.mat原始文件到标准化训练张量:预处理全流程代码
这节是整个流程里最容易出错也最影响最终结果的部分。我要把每一步都拆开讲清楚,包括那些网上教程很少提的“为什么”。
3.1 读取并合并全部被试数据
先把32个.mat文件全部读进来,合并成一个大数组。官方文件命名是participant_01.mat这种带补零的格式,所以注意一下字符串格式化:
from scipy import io import numpy as np def load_all_subjects(base_dir='data_preprocessed_matlab', n_subjects=32): all_data = [] all_labels = [] for idx in range(1, n_subjects + 1): path = f'{base_dir}/participant_{idx:02d}.mat' mat = io.loadmat(path) all_data.append(mat['data']) all_labels.append(mat['labels']) X = np.concatenate(all_data, axis=0) # (1280, 40, 8064) y = np.concatenate(all_labels, axis=0) # (1280, 4) return X, y X, y = load_all_subjects()合并后X的总样本数是32×40=1280,每个样本是一个40通道的63秒信号。这一下会把约3GB的数据加载进内存,如果你的电脑内存不大,后面转float32会好一些。我建议在读取后直接显式转成float32,节省一半空间。
3.2 剔除3秒基线信号
前3秒基线里被试还没开始看视频,这段信号对情绪识别基本是噪声。裁剪掉前384个点:
X = X[:, :32, 384:] # 只用EEG通道,剔除基线 print(X.shape) # (1280, 32, 7680)这里我直接只取了前32个EEG通道。原因前面说了,第一版模型别急着融合外周信号。X[:, :32, 384:]表示每个样本、取通道0到31、时间点从384到最后,剩下7680个点,正好是60秒×128Hz。
3.3 标签处理:把连续评分变成二分类目标
DEAP的标签是1到9之间的连续值。二分类任务里最常见的做法是选阈值5,高于等于5算正类、低于5算负类。拿valence举例:
threshold = 5.0 y_binary = (y[:, 0] >= threshold).astype(int) # 0或1你当然也可以同时做valence和arousal两个任务,或者用3分类(低、中、高),甚至可以保留连续值做回归。但我建议第一版先做二分类。原因有二:一是论文里大家最常用的对比口径就是二分类准确率,方便你跟别人的结果对比;二是连续情感标签本身主观噪声很大,回归任务的评价容易做不到一致。
3.4 划分训练集与测试集:必须按被试切分
这一步是整个pipeline里最严重也最常见的坑。很多入门教程直接写train_test_split(X, y, test_size=0.2, random_state=42),如果照做,你的准确率很可能飙到85%以上,然后你兴高采烈地以为自己调参调得很好——实际上你已经踩进数据泄漏的坑里了。
原因是同一个被试40段视频的生理信号高度相关,随机切分后,同一被试的部分片段在训练集、部分片段在测试集,模型实际上“见过这个人”。这就好比考试时把同一张卷子拆分成了练习题和考题,分数自然虚高。
正确的做法是按被试编号划分。我一般固定让6名被试做测试、26名做训练:
rng = np.random.RandomState(42) test_subjects = rng.choice(np.arange(32), size=6, replace=False) train_subjects = np.setdiff1d(np.arange(32), test_subjects) train_idx = np.array([i for i in range(len(X)) if i // 40 in train_subjects]) test_idx = np.array([i for i in range(len(X)) if i // 40 in test_subjects]) X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y_binary[train_idx], y_binary[test_idx]这里用i // 40是因为每个被试的40段视频在合并数组中是连续排列的,索引除以40的整数部分就是被试编号0到31。我自己刚做时还踩过一个坑:直接按i // 40可能导致某个被试编号在test_subjects里对不上,因为np.random.choice返回的是数组,用in判断时容易出问题,所以统一用np.setdiff1d来求差集,逻辑更稳。
3.5 归一化:只在训练集上计算统计量
神经网络对输入尺度敏感,所以需要z-score归一化。但有个关键点——必须先用训练集的数据计算均值和标准差,再用这些统计量去标准化训练集和测试集,而不是对全量数据一次性标准化。
mean = X_train.mean(axis=(0, 2), keepdims=True) # 计算各通道均值 std = X_train.std(axis=(0, 2), keepdims=True) X_train = (X_train - mean) / (std + 1e-8) X_test = (X_test - mean) / (std + 1e-8)keepdims=True会保留形状为(1, 32, 1)的维度,这样后面广播运算不会出错。axis=(0, 2)表示在所有样本和时间点上计算每个通道的均值。std + 1e-8是为了防止某个通道的std为0导致除零错误。虽然EEG数据里几乎不会出现std为0,但加上这个常数没有坏处。
如果对全量数据做标准化,测试集的统计信息就提前参与了训练过程,这又是一种隐蔽的数据泄漏。很多论文里准确率虚高,查一下预处理代码,往往就是这里出问题。
3.6 转成PyTorch张量与DataLoader
数据准备好后,需要把numpy数组转成torch张量,并构造DataLoader。EEGNet要求输入形状是(样本数, 1, 通道数, 时间点),所以多加一个维度:
import torch from torch.utils.data import TensorDataset, DataLoader X_train_t = torch.tensor(X_train, dtype=torch.float32).unsqueeze(1) X_test_t = torch.tensor(X_test, dtype=torch.float32).unsqueeze(1) y_train_t = torch.tensor(y_train, dtype=torch.long) y_test_t = torch.tensor(y_test, dtype=torch.long) train_dataset = TensorDataset(X_train_t, y_train_t) test_dataset = TensorDataset(X_test_t, y_test_t) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)如果按26个被试做训练,X_train_t的形状是(1040, 1, 32, 7680),float32下大概占用255MB内存,普通笔记本完全扛得住。测试集6个被试是(240, 1, 32, 7680),更小了。
4. 第一个能跑的情绪分类模型:基于PyTorch的搭建与训练
预处理链路跑通后,模型这部分反而轻松。我不建议第一版就上复杂网络,先用一个结构清晰、参数量小的EEGNet风格模型把端到端流程跑起来再说。
4.1 为什么从EEGNet开始
EEGNet是脑电信号分类里非常经典的轻量模型,核心思路是先做时间卷积,再做空间卷积。它只有几千到几万个参数,几十个epoch就能训练完,非常适合作为baseline。
时间卷积用一维卷积实现,空间卷积更巧妙:在通道这个维度上做卷积核大小为(通道数, 1)的卷积,相当于把每个时间点上的多通道信号融合成一个值。这种设计很符合EEG信号的物理结构——先提取单个通道的时间特征,再融合不同通道的空间特征。与之相比,直接把二维图像风格的CNN硬套到EEG上,通常效果不好,因为EEG通道之间并没有类似图像像素那样的空间邻接关系。
4.2 模型完整代码
下面的代码我实测过,能够直接跑通,不需要手动计算全连接层输入维度:
import torch.nn as nn class EEGNet(nn.Module): def __init__(self, n_chans=32, n_samples=7680, n_outputs=2): super().__init__() self.block1 = nn.Sequential( nn.Conv2d(1, 8, kernel_size=(1, 64), padding=(0, 32), bias=False), nn.BatchNorm2d(8), nn.Conv2d(8, 16, kernel_size=(n_chans, 1), bias=False), nn.BatchNorm2d(16), nn.ELU(), nn.AvgPool2d(kernel_size=(1, 4), stride=4), nn.Dropout(0.25) ) self.block2 = nn.Sequential( nn.Conv2d(16, 32, kernel_size=(1, 16), padding=(0, 8), bias=False), nn.BatchNorm2d(32), nn.ELU(), nn.AvgPool2d(kernel_size=(1, 8), stride=8), nn.Dropout(0.25) ) self.flatten = nn.Flatten() self.fc = nn.Linear(self._calc_features(n_chans, n_samples), n_outputs) def _calc_features(self, n_chans, n_samples): x = torch.zeros(1, 1, n_chans, n_samples) x = self.block1(x) x = self.block2(x) return x.numel() def forward(self, x): x = self.block1(x) x = self.block2(x) x = self.flatten(x) return self.fc(x)有几点需要解释:
bias=False加上后面的BatchNorm2d是标准做法,卷积层去偏置后BN效果更稳定。AvgPool2d比MaxPool2d对EEG更友好,因为脑电信号里突发尖峰不应该是分类决策的核心,平均池化能抑制这类噪声。_calc_features这个辅助函数,用一个全零临时张量跑一次前向,自动算出展平后的特征数,省得手算还容易错。
4.3 训练循环
训练参数我直接用经典的组合:Adam优化器、初始学习率1e-3、交叉熵损失函数、训练30个epoch:
torch.manual_seed(42) model = EEGNet() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.CrossEntropyLoss() epochs = 30 for epoch in range(epochs): model.train() total_loss, correct, total = 0, 0, 0 for Xb, yb in train_loader: optimizer.zero_grad() out = model(Xb) loss = loss_fn(out, yb) loss.backward() optimizer.step() total_loss += loss.item() * len(Xb) correct += (out.argmax(dim=1) == yb).sum().item() total += len(Xb) train_acc = correct / total train_loss = total_loss / total print(f'epoch {epoch + 1:3d} | loss {train_loss:.4f} | acc {train_acc:.4f}')如果你有GPU,可以在模型和每个batch上加上.cuda(),但即使纯CPU跑这个规模的模型,一个epoch也就十几秒,30个epoch几分钟内能跑完,完全无压力。
关于CrossEntropyLoss,它内部自带softmax,所以模型最后一层不需要手动接softmax,直接输出logits即可。out.argmax(dim=1)取logits最大的类别作为预测。
第一次跑的时候,你可能会看到训练集准确率在几个epoch后升到80%+,这是正常的,因为模型在拟合训练集。关键是别高兴太早,看测试集的表现才有意义。另外别在训练循环里加数据增强,EEG上做翻转、加噪之类的增强在DEAP这种小数据场景下容易帮倒忙。
4.4 关于batch size和随机种子
我选batch_size=16是因为DEAP训练集大约1000多个样本,batch太小梯度震荡明显,batch太大一个epoch步数太少,16是个稳妥值。torch.manual_seed(42)可以保证每次跑的初始化一致,方便复现。如果你要对比多个模型或多种预处理方式,建议把随机种子固定,否则结果差异里混着随机性,很难说清是模型变好了还是运气好。
5. 训练之后必看:评估策略与结果解读
模型训练完,接下来是评估环节。这里同样有几个容易埋雷的地方。
5.1 测试集上的评估代码
需要切换到model.eval()模式。这个细节很关键,因为模型里的BatchNorm2d和Dropout在训练和推理时行为不一样,如果继续用model.train()状态跑测试集,结果会有波动。
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix model.eval() with torch.no_grad(): out = model(X_test_t) y_pred = out.argmax(dim=1).numpy() print('accuracy:', round(accuracy_score(y_test, y_pred), 4)) print('f1:', round(f1_score(y_test, y_pred), 4)) print(confusion_matrix(y_test, y_pred))torch.no_grad()会关闭梯度计算,推理时省内存也省时间,这个习惯一定要养成。
5.2 结果长什么样才合理
不同随机种子、不同被试划分,结果会有一点波动。按我让6名被试做测试的切分方式,valence二分类的准确率一般在60%到70%之间,arousal可能稍微高一些。我最快跑出的一个模型准确率在63%左右,F1在0.5到0.6之间,混合矩阵大概长这样:
| 预测负类 | 预测正类 | |
|---|---|---|
| 实际负类 | 78 | 32 |
| 实际正类 | 57 | 73 |
大致就是这种水平。如果你也复现出60%多,别沮丧,这就是DEAP二分类的正常区间。
相比之下,如果有人在论文里报告80%、90%的高准确率,你要先看他用的评估协议。如果没按被试划分,或者把多个被试的数据全部打乱后随机划分,这种高指标基本没有泛化意义。我自己最早踩过这个坑,随机划分时跑出过87%的准确率,心里还挺美,换成按被试划分后立刻掉到63%,这才反应过来之前是数据泄漏。
5.3 为什么情绪分类这么难
EEG信号是高度个体化的,同一段视频在两个人脑内产生的神经响应差异极大,再加上DEAP的标签来自被试自己的主观评分,本身就有很强的噪声。同一段视频,有人觉得愉悦,有人觉得一般,甚至同一个人在不同时间打分都可能不同。
所以情绪识别领域的真实状态就是这样:离线二分类能做到60%~70%已经算得上可用的baseline。后续想提升,通常有几条路线:
- 把时序特征用LSTM、Transformer建模,代替CNN里的简单时间卷积。
- 融合8个外周生理通道,尤其是皮电和心率,这些信号对唤醒度的区分力其实比EEG更稳定。
- 做跨被试域适应或者个体校准,用少量目标被试数据微调模型。
- 把分类任务改回归,用相关系数衡量预测值和真实评分的相关性,这也是一种常用评估方式。
但这些都属于“调优”阶段。在那之前,先把预处理、按被试划分、模型训练这条链路跑通,已经比很多人走得远了。
5.4 记录实验信息的习惯
最后说一个和代码无关但特别重要的习惯:每次实验务必把随机种子、划分方式、归一化统计量、模型结构、学习率这几项记录下来。DEAP这套流程里,可变的细节太多了,稍一改动结果就变。不记录的话,几天后你回头看自己跑的结果,很可能完全复现不出来。我后来整理项目代码时吃过这个亏,现在每跑一次实验就顺手在结果文件名里带上“subjects划分方案+seed+模型名”,建议你也这么做。
写在最后面的一点体会
整套流程跑下来的最大感受是:DEAP情绪检测的复杂度不在模型,而在数据处理和实验设计。按被试划分、去掉基线、只在训练集上归一化,这些细节单独看都不起眼,但任何一个做错了,结果都不具备参考价值。我甚至觉得,能把这些“隐形细节”处理得干净利落的人,比换一个花哨模型的人更容易出靠谱成果。另外如果你第一次跑就遇到了莫名其妙的准确率飙升,先回头检查划分方式,这算是我的肺腑之言。希望这篇能帮你少走几个我走过的弯路。