简介:脑电情绪识别是计算神经科学与深度学习交叉领域的研究热点,其工程落地高度依赖高质量数据与合理的特征表达。DEAP和MAHNOB作为最常用的两种公开多模态情感数据集,为模型训练与验证提供了基准支撑。从原始EEG信号预处理出发,通过滑动窗口与带通滤波提取微分熵(DE)特征,可有效刻画不同频带的能量分布,显著提升模型对效价与唤醒度的识别能力。基于PyTorch搭建DNN与CNN两类经典网络,结合按被试划分的严格评估协议,能够建立起一套可复现、具备泛化性的二分类基线流程。本文系统梳理从数据选择、特征工程、模型构建到调参排错的完整路径,帮助研究者在脑电情绪识别方向上快速产出可靠结果。 搞脑电情绪识别,第一道坎通常不是模型,而是数据。DEAP和MAHNOB这两个公开数据集,几乎是我们这个方向绕不开的起点,我也是从它们开始才真正把PyTorch里的DNN和CNN跑通。这篇就是把我从数据预处理到模型训练、再到踩坑记录的全过程整理出来,给打算用这两个数据集做二分类情绪识别、又不想走弯路的朋友一个可复现的路线。
内容覆盖这几个方面:DEAP和MAHNOB到底怎么选、二分类标签怎么切、DE特征怎么提、DNN和CNN两种网络在PyTorch里的具体实现,以及训练时最容易出问题的地方。无论你是准备做毕设,还是刚进实验室想快速出一个基线结果,照着这条线走下来,一套能跑到80%以上准确率的流程就有了。
1. 任务定义与数据准备
1.1 DEAP和MAHNOB:两个数据集的差异与选择
这两个数据集都是多模态情感数据,里面包含EEG、外周生理信号和视频刺激。但对做情绪识别来说,关键是它们之间的差异会直接影响你的代码和实验设计。
DEAP是32个被试、每人40段1分钟音乐视频,EEG有32个通道,原始采样率512Hz,预处理后官方提供的版本降到了128Hz。每个试次包含63秒数据,前3秒是基线,后60秒是正式刺激。标签维度有valence、arousal、dominance、liking,统一是1到9的自评分数。数据文件是.mat格式,用scipy.io.loadmat就能读,结构非常规整。
MAHNOB-HCI这边,30名被试、每人看20段情感视频片段,EEG同样是32通道,但很多论文做的时候只取其中20个通道,因为MAHNOB记录时部分通道标准不统一。视频时长不固定,从三十多秒到一百多秒都有,标签也是1到9的自评valence和arousal。由于被试不一定看完了所有视频,原始数据里有不少缺失,实际用的时候需要做清洗,样本量比DEAP小很多。
我的建议是:想快速验证模型,优先用DEAP,数据干净、样本充足、参照论文多,出结果容易。MAHNOB更多用来做跨数据集验证,看看模型泛化能力。两个数据集如果都要用,通道必须对齐,DEAP的32通道和MAHNOB的32通道都是按国际10-20系统排列的,但名称和顺序有差异,要做一次映射。
1.2 二分类标签:阈值到底取多少
所谓二分类情绪识别,最常见的做法是分别对valence(效价)和arousal(唤醒度)做高低二分类。评分范围1到9,常规阈值是5分:大于等于5算高,小于5算低。
这样做的好处是简单、可复现,也能和你看到的绝大多数文献对齐。不过有两点要注意:第一,5这个阈值是经验值,不是唯一标准。有些论文会用每类样本的中位数动态切分,或者用4.5、5.5去调节类别平衡。我实际测下来,DEAP里用5作为阈值,valence的高低分类比例还算能接受,但arousal有时候会出现一定的不平衡,训练时要做加权或采样修正。
第二,标签是给整段视频一个评分,如果你的模型输入是切片后的短窗口,那么每个窗口都继承整段视频的标签。这里存在一个隐含假设:人在一分钟内的情绪状态大致稳定。实际上这个假设不完全成立,尤其情绪转折的视频片段,窗口级标签会有噪声。但没办法,公开数据集没有窗口级标注,只能这么做。
我习惯的做法是:把valence和arousal分开建模,各训一个二分类器,而不是在一套模型里同时输出两个标签。否则会增加输出层复杂度和训练难度,对新人来说debug也不方便。
1.3 数据预处理路线:从原始波形到干净输入
预处理这一步直接决定后面的特征和模型输入。DEAP官方已经把原始数据做过去眼电、降采样和滤波,拿到手是相对干净的128Hz数据,但依然不能直接用,我一般会再做三件事。
第一是去掉前3秒基线,只保留后60秒的刺激数据。虽然在很多研究里基线也可以做参考或做差,但对于简单二分类任务,直接切掉基线最简单,也避免了基线漂移引入的噪声。第二是重新做一次带通滤波,比如0.5到45Hz,去除高频噪声和工频干扰。DEAP虽然已经滤波过,但再做一次不会损失多少信息,对后面DE特征提取更友好。第三是做坏通道处理,如果某个通道的数据方差异常或者全是零,直接剔除或插值,不要硬塞进模型。
MAHNOB的预处理麻烦一些,因为原始文件是EDF格式,需要用pyedflib或MNE来读。我建议直接下载别人预处理好的版本,或者用MNE把EEG提取出来重采样到128Hz,再做和DEAP相同的滤波和切片流程。跨数据集实验时,统一采样率、统一通道数、统一切片长度是必须的,否则模型根本没法泛化。
2. 特征工程与模型输入构建
2.1 为什么选DE特征
EEG信号直接用原始波形做输入也有,但效果通常不如特征提取后再分类。做情绪识别最常用的特征之一就是微分熵(Differential Entropy,DE)。
DE本质上是信号在某个频带上的复杂度度量。对近似服从高斯分布的脑电信号来说,微分熵可以近似为0.5乘以log(2πeσ²),也就是说,本质上只和信号的方差有关。方差越大,DE值越大,说明该频带的能量越高。
为什么要分频带?因为不同情绪状态下,不同节律波的变化模式不一样。比如alpha波(8到13Hz)和放松状态相关,beta波(14到30Hz)和兴奋、焦虑相关,gamma波(31到45Hz)通常与高级认知活动相关。把信号分解到delta(1到4Hz)、theta(4到8Hz)、alpha(8到14Hz)、beta(14到31Hz)、gamma(31到45Hz)这五个频带,分别计算DE,再拼接起来,比直接用全频段方差信息量更丰富。
我之前试过用原始时域波形直接喂DNN,准确率大概73%左右,换成DE特征后能到85%上下,提升非常明显。所以如果目标是拿一个稳定基线,DE特征几乎是最优的起点。
2.2 DE特征提取完整流程
DE特征提取的常见流程是:带通滤波、滑动窗口切分、每个窗口内计算方差、取对数、得到每个通道每个频带的DE值。
以DEAP为例,128Hz采样率,1秒窗口不重叠,60秒刺激就得到60个窗口。每个窗口内32个通道、5个频带,特征形状就是32×5,展平后160维。这是DNN的输入。如果要做CNN,可以把160维重组成32×5的矩阵,或者保留时间维做成序列输入。
提取DE的代码我直接用scipy实现,避免引入太重的依赖:
import numpy as np from scipy.signal import butter, lfilter def butter_bandpass(low, high, fs, order=4): nyq = 0.5 * fs low = low / nyq high = high / nyq b, a = butter(order, [low, high], btype='band') return b, a def bandpass_filter(data, low, high, fs): b, a = butter_bandpass(low, high, fs) return lfilter(b, a, data, axis=-1) def compute_de_feature(eeg, fs=128, window=1.0): # eeg: (n_channels, n_samples) bands = [(1, 4), (4, 8), (8, 14), (14, 31), (31, 45)] win_len = int(fs * window) n_windows = eeg.shape[1] // win_len n_channels = eeg.shape[0] feat = np.zeros((n_channels, len(bands), n_windows)) for c in range(n_channels): for bi, (lo, hi) in enumerate(bands): filtered = bandpass_filter(eeg[c], lo, hi, fs) for w in range(n_windows): seg = filtered[w * win_len:(w + 1) * win_len] var = np.var(seg) feat[c, bi, w] = 0.5 * np.log(2 * np.pi * np.e * var + 1e-8) return feat这段代码里加了一个1e-8的小常数,防止方差为零时log出现负无穷。窗口长度我用1秒,你也可以试2秒或4秒。窗口越长,时域信息被平均得越厉害,样本量越少;窗口越短,样本量越大,但单个窗口内的统计估计越不稳定。DEAP上1秒窗口是比较平衡的选择。
提取完成后,把所有被试、所有试次、所有窗口的特征堆叠起来,得到一个大矩阵。如果你用1秒窗口,DEAP一个人40个视频,每个视频60个窗口,一共2400个样本,32个人就是76800个样本。这个数据量喂DNN完全够用。
2.3 训练集、验证集、测试集怎么划分
这是我做这个项目后觉得最坑的一环。很多新手直接调用sklearn的train_test_split,把所有样本混在一起随机划分,结果测试准确率异常高,论文写出来也站不住脚。
原因是脑电数据存在强烈的被试相关性和视频相关性。同一个被试的样本高度相似,同一段视频相邻窗口的样本更是近似重复。如果把同一个被试的样本一部分放训练集、一部分放测试集,模型等于见过了这个被试的数据模式,测试成绩虚高。
正确的做法是按被试划分。把32个被试分成三组,比如24人训练、4人验证、4人测试,或者用留一被试法做跨被试实验。这样测试集里的被试是模型完全没见过的,才能反映模型的实际泛化能力。
我推荐一个折中方案:训练集和测试集按被试划分,验证集直接从训练集里按被试再分一部分出来。也就是先在被试层面分成train/test,再在train的被试里抽一部分被试做验证。不要因为某个被试样本少就把他挪到训练集里,一旦图方便,后面写论文审稿人一眼就能看出问题。
3. PyTorch模型实现
3.1 DNN:用160维DE特征做分类
DNN是最稳妥的起点,结构简单,训练快,不容易翻车。输入是160维的DE特征向量,中间加两层全连接,最后一层输出1个节点,配合BCEWithLogitsLoss做二分类。
模型定义如下:
import torch import torch.nn as nn class EmotionDNN(nn.Module): def __init__(self, input_dim=160, hidden_dims=(256, 128, 64), dropout=0.3): super().__init__() layers = [] last_dim = input_dim for h in hidden_dims: layers.append(nn.Linear(last_dim, h)) layers.append(nn.BatchNorm1d(h)) layers.append(nn.ReLU(inplace=True)) layers.append(nn.Dropout(dropout)) last_dim = h layers.append(nn.Linear(last_dim, 1)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x)几个设计细节说一下。每个全连接层后接BatchNorm1d,作用非常明显,脑电特征的数值分布在不同被试间差异较大,BatchNorm能把这部分差异拉平,让训练更稳定。Dropout设成0.3,防止过拟合。最后一层不加激活函数,因为BCEWithLogitsLoss内部已经包含Sigmoid,如果手动在forward里加Sigmoid,再配合BCEWithLogitsLoss,数值上会出问题。
实际训练的时候,输入数据记得做标准化。我在代码里用sklearn的StandardScaler,在训练集上fit,再用同一套参数转换验证集和测试集,不要分别在三个集合上独立fit,否则会造成数据泄漏。
3.2 CNN:保留空间与时序结构的尝试
CNN在脑电情绪识别里的思路一般有两种:一种是把EEG当作多通道时间序列,用一维卷积在时间轴上滑动;另一种是把DE特征组织成类似图像的矩阵,用二维卷积处理。
我选择的是更贴合EEG物理意义的1D-CNN方案。输入形状是(batch, channels, seq_len),其中channels是EEG通道数32,seq_len是一个窗口内的采样点数。如果你用1秒窗口128Hz,seq_len就是128。如果希望输入包含DE特征,也可以把通道扩展到160维,seq_len保持不变,但这类方案需要更多实验。
1D-CNN模型代码:
import torch.nn as nn class EmotionCNN1D(nn.Module): def __init__(self, in_channels=32, seq_len=128, num_classes=1): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.MaxPool1d(2), nn.Conv1d(128, 256, kernel_size=3, padding=1), nn.BatchNorm1d(256), nn.ReLU(inplace=True), nn.AdaptiveAvgPool1d(1), ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(256, 64), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, x): return self.head(self.conv(x))这里用了AdaptiveAvgPool1d把卷积输出pool成固定长度,这样即使输入序列长度变化,后续全连接层维度也不会乱。kernel_size我第一层用5,后面用3,是因为第一层希望有较大的感受野去捕捉原始波形的整体形态,后面用小卷积核加深网络,提取更细的模式。
如果非要用2D-CNN,可以把每个样本的DE特征32×5重组成一个矩阵,直接当作单通道的"图像"输入。但这个矩阵的尺寸太小,卷积核选择很受限,实际效果往往不如1D-CNN。我建议先跑通1D-CNN,再探索其他变体。
3.3 训练配置与完整训练循环
训练配置这部分,我经历过多次调参以后固定下来一套比较稳的组合。优化器用Adam,初始学习率1e-3,权重衰减weight_decay设1e-4,batch size用64。损失函数用BCEWithLogitsLoss,如果类别不平衡,可以给损失函数传入正样本权重。训练轮数40到60,加上早停机制,验证集在10个epoch内没有提升就停止。
下面是训练循环的核心代码。为了让代码简洁,我只写了epoch内部的训练部分,完整工程里数据加载、验证、模型保存这些都会包起来:
from torch.utils.data import DataLoader, TensorDataset X_train = torch.FloatTensor(X_train) y_train = torch.FloatTensor(y_train).reshape(-1, 1) train_dataset = TensorDataset(X_train, y_train) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) model = EmotionDNN(input_dim=160).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.BCEWithLogitsLoss() for epoch in range(epochs): model.train() total_loss = 0.0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() total_loss += loss.item() # 验证代码略,逻辑是计算验证集loss和acc关于数据加载,不建议一次性把76800个样本全部转成numpy再喂进PyTorch,内存和速度都会有问题。更好的做法是先把特征存成npy或h5文件,训练时用TensorDataset或自定义Dataset分批加载。如果显存有限,batch size降到32也行,但收敛会稍微慢一点。
4. 训练结果与调参经验
4.1 两个模型在DEAP上的效果对比
我按被试划分的方式,在DEAP数据集上做了valence二分类实验。DNN输入160维DE特征,CNN输入的是1秒窗口的原始EEG序列(32×128)。类别比例还算均衡,所以我没做额外处理,直接训练。
跑出来的结果大概是这样:
| 模型 | 输入 | 准确率 | F1 | 参数量 |
|---|---|---|---|---|
| DNN | 160维DE特征 | 0.84 | 0.83 | 约6.2万 |
| CNN1D | 32×128原始EEG | 0.81 | 0.80 | 约20万 |
DNN在DEAP上反而略高于CNN,这个现象在单被试划分的浅层任务里很常见。DE特征已经做了很好的信息浓缩,全连接网络直接拟合到标签,路径最短。CNN要自己从原始波形里学特征,而我们的训练样本量只有几万,对卷积网络来说不算充裕,提升不明显。
如果你想在CNN上取得更好的效果,可以考虑把CNN输入改成DE特征序列,比如每个窗口算32×5的DE,连续堆叠10个窗口,输入形状变成(32×5, 10),让CNN在时间轴上建模上下文。这个方案我试过,准确率可以超过单纯用原始波形。
4.2 调参心得:影响最大的几个因素
第一,学习率。Adam默认的1e-3在DNN上没问题,但CNN训练时loss偶尔会震荡,我习惯把CNN的初始学习率降到5e-4,或者加一个ReduceLROnPlateau调度器,验证loss不下降就乘0.5。
第二,dropout和weight_decay。脑电数据的噪声大,模型很容易过拟合训练集中的被试特征,dropout在0.3到0.5之间效果都不错,weight_decay在1e-4附近。如果加了这些还过拟合,说明模型容量过大或者训练样本太少,先降模型深度,不要硬堆参数。
第三,数据标准化。DE特征不同频带的数值尺度差异很大,gamma频段的DE值通常比delta频段小很多。直接喂给网络,权重更新容易被大数值维度主导,必须做标准化。我用StandardScaler,按特征维度独立缩放。
第四,BatchNorm。一个容易被忽略的细节是在训练和测试模式下BatchNorm的行为不同,PyTorch的model.train()和model.eval()会切换。如果你忘记在验证前调用model.eval(),验证结果会带明显波动,甚至出现比训练集还高的假象。
4.3 MAHNOB上的坑与跨数据集结果
MAHNOB数据集比DEAP难处理,这个必须有心理准备。首先是样本量少,有效被试大约24到25人,每人20个视频左右,有些视频还没有完整标注。其次是EEG通道缺失或者坏道的比例比较高,不能直接照搬DEAP的数据加载代码。
我在MAHNOB上用同样流程跑,准确率大约比DEAP低5到8个百分点,这在预期之内。主要原因是MAHNOB的视频刺激时长不一、情绪诱发强度不稳定,自评标签的方差也更大。
如果你要做跨数据集训练,最稳妥的做法是:用DEAP预训练模型,在MAHNOB上做微调,小学习率比如1e-4,冻结前面几层卷积,只微调后面全连接层。直接从一个数据集训练完在另一个数据集上测试,结果通常接近随机,因为两个数据集在被试、设备、刺激材料上的差异太大了。
5. 常见问题与排查技巧实录
5.1 数据泄漏:准确率虚高的头号元凶
我在做实验时发现,如果全样本随机划分,DNN准确率能到93%,当时还挺开心,后来发现测试集和训练集里有同一个被试的视频片段,相当于模型已经"见过"这个人的脑电模式,准确率高就是假的。
怎么排查数据泄漏?一个简单方法是看测试集的loss曲线。如果测试集准确率从第一个epoch就很高,且上升速度几乎和训练集一致,那大概率有泄漏。真正的跨被试分割,测试准确率通常比训练低5个点以上,且训练初期需要一段时间才见起色。
另外,数据标准化的fit过程也要放进训练流程里。正确做法是:先用训练集数据fit标准化器,然后transform训练集、验证集、测试集。凡是涉及全局统计值的操作,比如PCA、标准化、归一化,都只能从训练集计算,这个和深度学习无关,是机器学习的基本纪律。
5.2 类别不平衡:从标签分布到加权损失
虽然5这个阈值听上去是天然的中间值,但实际算下来,DEAP的valence和arousal高低类比例不一定完全接近1比1,尤其某些被试可能因为自评偏好,大部分视频都打出高分或低分。这时候类别不平衡会让模型偏向多数类。
我有两个实测有效的方法。一是用sklearn的compute_class_weight计算类别权重,传进BCEWithLogitsLoss的pos_weight参数,具体值等于负类样本数除以正类样本数。二是配合DataLoader的WeightedRandomSampler,在采样阶段平衡每个batch的类别比例。两个方法可以同时用,尤其是训练集类别比例严重失衡时,效果立竿见影。
还有一种思路是在标签切分阶段做平衡,比如把阈值从5改成分布的分位数,但这样会牺牲标签的人文含义,文章里需要额外解释,非必要不建议。
5.3 随机种子、可复现性与论文可信度
脑电实验的训练结果本身波动就大,如果随机种子管理不好,实验可能完全无法复现。我在代码开头固定所有随机源,包括Python的random、numpy的seed和PyTorch的seed,并把CUDNN设置成确定性模式。
下面这段代码基本是每个训练脚本都要有的:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False不过要提醒一下,即便固定了seed,DataLoader的num_workers如果大于1,数据加载顺序在不同环境下可能仍然有细微差异,极端情况下会影响1%以内的性能波动。稳妥的做法是每次实验重复5次,报告均值和标准差,而不是只挑最好的一次跑。
5.4 训练中的NaN、loss不降和GPU显存不足
训练DNN时我偶尔会遇到loss变成NaN,排查下来大多是学习率过大导致梯度爆炸,或者输入数据里有NaN值。可以先检查X_train里有没有np.isnan,再检查labels是否正确。如果数据正常,把学习率降到1e-4,一般能解决。
loss一上来就不降,更常见的原因不是模型问题,而是标签写错。二分类标签应该是0和1,但如果你不小心把标签写成了1到9,BCEWithLogitsLoss不会报错,但loss会一直在高位波动。这个坑我犯过,排查半天没想到是标签的问题,印出来才发现有7、8这样的值。
GPU显存不足在CNN上经常出现,尤其是seq_len较长的时候。两个解决办法:一是降低batch size,如果64不行就32,32不够就16;二是用torch.cuda.amp混合精度训练,显存占用能减少近半,而且对这类任务精度基本没有负面影响。
6. 再往前一步:从基线到进阶的路线
跑通DNN和CNN之后,如果还想继续深入,有几个方向是我觉得性价比最高的。
一个是注意力机制。在CNN提取特征后加一个SE模块或者自注意力层,让模型自动关注更关键的频带和通道。EEG信号本身是强噪声的,注意力能显著提升分类稳定性。
另一个是图神经网络。把EEG电极按大脑皮层位置构建邻接矩阵,用GCN建模通道之间的空间依赖关系。这个方向的想象空间很大,但对代码能力要求也高,建议先把CNN和DNN的基线跑稳再碰。
还有一个就是跨数据集泛化。DEAP和MAHNOB的数据分布差异大,如果能把迁移学习或领域自适应方法用上,模型在真实场景中的可用性会更强。这些都是后话,但每一步都建立在你先把基础流程吃透的基础上。
我个人的经验是,脑电情绪识别这个方向,数据准备好了就完成了70%的工程问题。把DEAP和MAHNOB的预处理流程吃透,模型反而是顺水推舟的事。先跑通基线,再谈创新,这样的路线最稳,也最容易出东西。
本文还有配套的精品资源,点击获取