简介脑电情绪识别是计算神经科学与深度学习交叉领域的研究热点其工程落地高度依赖高质量数据与合理的特征表达。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 EntropyDE。DE本质上是信号在某个频带上的复杂度度量。对近似服从高斯分布的脑电信号来说微分熵可以近似为0.5乘以log(2πeσ²)也就是说本质上只和信号的方差有关。方差越大DE值越大说明该频带的能量越高。为什么要分频带因为不同情绪状态下不同节律波的变化模式不一样。比如alpha波8到13Hz和放松状态相关beta波14到30Hz和兴奋、焦虑相关gamma波31到45Hz通常与高级认知活动相关。把信号分解到delta1到4Hz、theta4到8Hz、alpha8到14Hz、beta14到31Hz、gamma31到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, order4): nyq 0.5 * fs low low / nyq high high / nyq b, a butter(order, [low, high], btypeband) 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, fs128, window1.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_dim160, hidden_dims(256, 128, 64), dropout0.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(inplaceTrue)) 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通道数32seq_len是一个窗口内的采样点数。如果你用1秒窗口128Hzseq_len就是128。如果希望输入包含DE特征也可以把通道扩展到160维seq_len保持不变但这类方案需要更多实验。1D-CNN模型代码import torch.nn as nn class EmotionCNN1D(nn.Module): def __init__(self, in_channels32, seq_len128, num_classes1): super().__init__() self.conv nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size3, padding1), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(2), nn.Conv1d(128, 256, kernel_size3, padding1), nn.BatchNorm1d(256), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(1), ) self.head nn.Sequential( nn.Flatten(), nn.Linear(256, 64), nn.ReLU(inplaceTrue), 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-4batch 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_size64, shuffleTrue) model EmotionDNN(input_dim160).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-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参数量DNN160维DE特征0.840.83约6.2万CNN1D32×128原始EEG0.810.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(seed42): 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不过要提醒一下即便固定了seedDataLoader的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到9BCEWithLogitsLoss不会报错但loss会一直在高位波动。这个坑我犯过排查半天没想到是标签的问题印出来才发现有7、8这样的值。GPU显存不足在CNN上经常出现尤其是seq_len较长的时候。两个解决办法一是降低batch size如果64不行就3232不够就16二是用torch.cuda.amp混合精度训练显存占用能减少近半而且对这类任务精度基本没有负面影响。6. 再往前一步从基线到进阶的路线跑通DNN和CNN之后如果还想继续深入有几个方向是我觉得性价比最高的。一个是注意力机制。在CNN提取特征后加一个SE模块或者自注意力层让模型自动关注更关键的频带和通道。EEG信号本身是强噪声的注意力能显著提升分类稳定性。另一个是图神经网络。把EEG电极按大脑皮层位置构建邻接矩阵用GCN建模通道之间的空间依赖关系。这个方向的想象空间很大但对代码能力要求也高建议先把CNN和DNN的基线跑稳再碰。还有一个就是跨数据集泛化。DEAP和MAHNOB的数据分布差异大如果能把迁移学习或领域自适应方法用上模型在真实场景中的可用性会更强。这些都是后话但每一步都建立在你先把基础流程吃透的基础上。我个人的经验是脑电情绪识别这个方向数据准备好了就完成了70%的工程问题。把DEAP和MAHNOB的预处理流程吃透模型反而是顺水推舟的事。先跑通基线再谈创新这样的路线最稳也最容易出东西。本文还有配套的精品资源点击获取