ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

DEAP脑电情绪识别实战:PyTorch实现DNN与CNN分类全流程解析

2026/9/17 0:01:27 拓冰建站 浏览量
DEAP脑电情绪识别实战:PyTorch实现DNN与CNN分类全流程解析 做脑电情绪识别的人十个里有八个绕不开 DEAP 这个数据集。我第一次跑它的经历并不顺利倒不是模型有多难搭而是栽在数据读取和维度理解上——.mat 文件一加载出来40×40×8064 这个形状直接让我愣了半天哪个维度是 trial哪个是 channel哪个是采样点标签明明是 1 到 9 的连续打分论文里的 ACC 又是怎么算出来的这些问题不搞清楚后面用 PyTorch 搭 DNN 还是 CNN 都白搭。所以这篇我把从 DEAP 数据加载、预处理、特征工程到用 PyTorch 实现深度神经网络和卷积神经网络做情绪分类的完整链路拆开讲代码给到能直接跑的程度重点是解释每一步为什么要这么做。我自己踩过不少坑比如归一化顺序错误导致的结果虚高、标签阈值定错导致评估失真这些也一并写出来希望能帮刚接触脑电分类的在校学生以及想在 DEAP 上快速验证新点的研究者节省几个星期的试错时间。1. 动手之前先看透DEAP 数据的维度设计和标签含义1.1 32 名被试、40 段视频实验设计决定了数据形状DEAP 全称是 Database for Emotion Analysis using Physiological Signals由伦敦玛丽女王大学等机构发布是脑电情绪识别领域用得最广的基准数据集之一。它的记录方式是让 32 名被试观看 40 段一分钟左右的音乐视频同时同步记录脑电和外围生理信号。你可能会问为什么偏偏是 40 段视频因为研究人员预先从大量音乐视频里筛选出了能稳定诱发不同情绪强度的片段覆盖效价Valence和唤醒度Arousal两个维度的不同水平。这个设计的好处是每个被试都经历了完全相同的刺激跨被试比较时干扰因素减少了坏处是样本量上限就被锁死了——每位被试最多只有 40 个 trial这直接决定了后面做深度学习时不能直接拿40 个样本去训练必须做窗口切分来扩充样本。数据采集用的设备是 32 通道的 Brain Products 脑电放大器和 8 通道的外围生理记录仪。脑电通道按照国际 10-20 系统排布覆盖前额、颞叶、顶叶、枕叶区域。外围生理信号包括眼电EOG、肌电EMG、皮电GSR、呼吸、温度等。这一点很关键你下载到手的 .mat 文件里data 数组的第二个维度是 40不是 32因为除了 32 个 EEG 通道之外还有 8 个外围信号通道。很多新手一看 40 通道就直接当脑电用结果特征里混入了大量非脑电信息还浑然不觉。原始的生理信号以 512Hz 采样率记录官方预处理之后做了一次降采样发布出来的版本是 128Hz。我建议新手直接用官方预处理好的版本别去碰原始 .bdf 文件因为你得自己处理眼电伪迹和工频干扰而官方版本已经做了去眼电去除眼电成分和带通滤波省掉很大一块工作量。当然代价是你没法再做更个性化的滤波了属于按需取舍。1.2 每个文件的内部结构data 和 labels 的前世今生官方预处理版本的文件是data_preprocessed_matlab文件夹下的s01.mat到s32.mat。用scipy.io.loadmat加载后你会看到两个核心变量data形状为 (40, 40, 8064)含义依次是 40 个 trial、40 个通道32 个脑电 8 个外围、8064 个采样点labels形状为 (40, 4)对应 40 个 trial 的 4 维情感评分8064 这个数字值得解释一下。128Hz 采样率乘以 63 秒等于 8064。为什么是 63 秒因为每段视频刺激时长是 60 秒前面还有 3 秒的静息基线。基线数据就是用来做基线校正的下文预处理部分会细说。很多人在读数据时直接把 8064 个点全用作信号特征没有去掉基线或者没有做基线减法这样做出来的特征会包含较大的个体差异。labels 的 4 列依次是 Valence效价、Arousal唤醒度、Dominance支配度、Liking喜欢度每项都是被试在观看完视频后做的 1 到 9 分的自我评估。传统情绪心理学常用的维度模型就是效价-唤醒度二维平面效价衡量愉悦程度唤醒度衡量生理激活程度。DEAP 额外加入了支配度和喜欢度不过大多数论文只取前两个维度。那为什么明明评分是 1 到 9论文里做的却是二分类因为多数研究把问题简化为高/低效价或高/低唤醒度以 5 分为阈值分数 5 归为正类5 归为负类。这样每个被试的每个维度上40 个 trial 会被划分成两个类别的样本。你也会看到有人用 4 分类把 1-9 分成 4 段或者做连续值回归但二分类仍然是复现成本最低、跨论文可比性最强的设定。我后面给出的代码也以二分类为主。1.3 通道选择到底用 32 通道还是 40 通道前面提到 data 里有 40 个通道其中只有 32 个是真正的脑电通道。在实际建模时大部分研究只保留前 32 个通道也就是截取data[:, :32, :]。但也有少数论文故意把全部 40 个通道都用上理由是外围信号可能对情绪识别有辅助作用。我的建议是第一阶段实验结果里先用纯脑电通道把模型基线跑通有需要再对比加入外围信号的效果。因为当你做单被试或跨被试研究时外围信号的个体差异非常大容易让模型学到与情绪无关的皮肤电基线水平等信息导致跨被试泛化能力虚高或崩坏。通道选择还会影响数据可视化。你可以用mne库把 32 通道映射到标准脑电头皮布局上画出每个 trial 的拓扑图这对检查数据质量和初步观察情绪诱发的脑区分布很有帮助。不过为了不走偏我这里直接用 numpy 和 PyTorch 完成整条流水线把可视化这个可选项留给有需要的朋友。2. 把原始脑电变成模型输入切窗、基线与特征提取2.1 先做基线扣除还是先切窗顺序很讲究拿到 8064 个采样点后第一步应该是做基线校正。参考基线是每个 trial 的前 3 秒对应的 384 个采样点128Hz × 3s。基线校正最朴素的做法是用整段数据的每个通道减去基线段的均值公式是X[t] X[t] - mean(X_baseline_per_channel)。这样做的目的是把静息状态下的直流漂移和个体绝对电位差异去掉让后续分类器更关注刺激诱发的变化量。有人会问先切窗再逐窗口做基线校正行不行从数学上看两种做法等价因为均值相减是线性操作但我仍然建议先做全局基线校正再做窗口切分原因只有一个代码更清晰不容易搞混每个窗口的边界。DEAP 发布时其实已经做了一次预处理你可以把基线校正理解为针对情绪识别任务做的二次精炼。实测下来做与不做基线校正对 DEAP 上二分类的准确率影响通常在 1 到 3 个百分点之间幅度不大因为同步去均值本来就在许多深度学习模型的隐层中被隐式处理了但做了之后结果更稳尤其是跨被试实验。下一步是切窗。为什么必须切窗因为每个 trial 只有 1 个样本32 个被试一起也才 1280 个样本这点数据喂给深度学习模型过拟合会非常严重。切窗把每个 trial 切成长度更短的片段样本数成倍增长。标准做法是 1 秒窗口、步长 0.5 秒即相邻窗口重叠 50%这样做 60 秒的刺激段能得到大约 119 个窗口。为什么用重叠因为相邻脑电片段高度相关重叠能够平滑样本间分布差异还相当于一种数据增强。当然重叠也不宜过大否则训练集和验证集之间信息重叠严重评估结果偏乐观。我常用 1 秒窗口、0.5 秒步长这个配置在样本量和独立性之间取平衡。如果你设备内存充足也可以试试 4 秒窗口因为脑电情绪响应往往在更长的时间尺度上更稳定但样本数会减少到 15 个每个试验左右。记住win_len 和 step_len 一旦确定就要作为数据预处理的一部分记录好因为后续复现时所有结果都建立在窗口定义之上。2.2 特征化策略直接用原始波形还是提取频域特征切完窗之后你手上是一堆形状为 (通道数, 窗口长度) 的连续信号片段。下一步的选择是直接用原始时序信号喂模型还是先做特征提取。两条路线各有拥趸我的判断是如果模型是 DNN优先做特征提取如果模型是 CNN原始时序信号更合适。先讲特征提取这里不得不提微分熵这个概念。微分熵Differential EntropyDE是目前 DEAP 情绪识别里最常见的输入特征。它衡量的是连续随机变量的信息量在脑电领域研究表明 DE 特征比功率谱密度特征在情绪识别上更稳定。DEAP 预处理后的脑电信号经过滤波后保留了 4Hz 到 45Hz 的频段我们通常把它划分成五个频带Delta1 到 3Hz与深度睡眠和注意力缺失有关Theta4 到 7Hz与记忆和认知负荷相关Alpha8 到 13Hz闭眼静息状态的主要节律Beta14 到 30Hz与主动思考和兴奋状态相关Gamma31 到 50Hz与高级认知加工相关对每个窗口的每个通道分别在这 5 个频带上计算 DE 特征就能得到 32 通道 × 5 频带 160 维特征向量。对一维高斯信号DE 的计算可以简化为DE 0.5 * log(2πeσ²)其中 σ² 是某个频带内信号的方差或者用带通滤波后的信号能量近似。代码实现也不复杂先用scipy.signal.butter设计带通滤波器每个频带滤波后计算能量方差就能得到 DE 值。但如果做的是 CNN尤其是时域卷积再用 DE 特征就有点绕了。CNN 的卷积核本身就具备滤波能力第一层卷积会学出类似带通滤波器的参数所以你直接把经过简单归一化的原始窗口喂进去让网络自己学特征就行。这体现了端到端深度学习的优势手动特征工程不再是必需项。不过这也意味着训练数据的量必须足够大否则网络学不到稳定的频域特征反而比手动特征差。实践中的折中方案是DNN 用 DE 特征160 维向量CNN 用原始时序段32 通道 × 128 采样点两个模型在同一个验证协议下对比这一对比本身就很有说服力。2.3 归一化放在哪一步一不小心就会数据泄露归一化是个看着不起眼、实际影响巨大的步骤。DEAP 数据的量纲问题很现实不同被试、不同通道的信号幅值差异很大不归一化的话模型会把主要精力花在拟合幅值差异上而不是学情绪相关的模式。这里有一个非常经典的坑如果把所有样本包括测试集放在一起计算均值和标准差再归一化测试集的信息就已经通过统计量泄漏到训练过程里了最终的准确率会虚高。正确做法是只在训练集上计算均值和标准差然后用这套统计量去归一化验证集和测试集。放到代码里就是scaler.fit(train_features)再scaler.transform(test_features)禁止对全量数据套fit_transform。我见过不少开源代码直接对全部数据做标准化然后跑出 90% 以上的准确率拿去和别人的结果一比就露馅了因为别人用的是严格的被试独立划分。在脑电情绪识别里更严格的协议是跨被试评估训练集来自部分被试测试集来自从未参与训练的被试。这时候归一化统计量只能在训练集对应的那些被试数据上计算不能混入测试集被试的一丁点信息。道理本质上和普通机器学习一样但脑电数据因为被试间差异大这个问题会被放大一旦泄漏结果会特别好看但不真实。后面我会在代码里专门演示这个细节。3. DNN 还是 CNN输入形状如何决定网络结构3.1 为什么 DNN 在这个任务里依然能打很多人觉得都 2025 年了做脑电分类还用 DNN 是不是太土了其实不然。DNN 在 DEAP 上的表现并不差关键在于它处理的是高质量的 DE 特征。160 维的 DE 特征已经浓缩了每个窗口里最重要的频域信息一个三层全连接网络足以在这个特征空间里学到不错的分类边界。DNN 的优势是稳定、训练快、超参数少。用一个 160 → 128 → 64 → 1 的三层网络配合 ReLU 激活和 Dropout在单个被试的数据上就能达到 70% 到 80% 的准确率。对于许多需要快速验证新想法比如情感标签设计、被试选择策略的阶段DNN 是完美的基线模型。我在做实验时往往先跑通 DNN确认数据预处理和验证流程没问题再上更复杂的 CNN。这样出问题的时候可以快速定位是数据问题还是模型问题。DNN 的劣势也明显它对原始时序信号无能为力。窗口长度如果是 128 个采样点32 个通道直接展开就是 4096 维参数爆炸不说向量内部还丢失了通道和时间的结构化关系。所以 DNN 路线必须搭配特征压缩把 4096 维原始信号压成 160 维 DE 特征。3.2 CNN 的三种输入排布方式1D 时序、2D 仿图、2D 频域图CNN 能利用脑电信号的结构信息这是它相对 DNN 的核心理由。但 CNN 怎么处理多通道脑电有几种不同的思路理解它们的区别才能选对网络结构。第一种是 1D 时序卷积。把脑电视为32 个通道的多变量时间序列输入形状是 (batch, 32, time_steps)。卷积核沿着时间轴滑动同时覆盖所有通道。这相当于让每一层卷积先在每个通道内提取局部时间模式再用跨通道卷积组合出不同脑区之间的协同信息。这种做法的实现最自然代码量最小我在后面的完整示例里也用了这个方案。第二种是 2D 仿图卷积。把 32 个脑电通道按照头皮位置排列成类似图像的结构比如 9×9 的电极网格然后对每个时间点或短时窗口生成一张伪图像。这种做法需要将通道映射到二维网格没有电极的位置补零。它的问题是脑电通道的真实空间关系并不完全等同于图像像素的欧氏距离用普通 2D 卷积分辨率利用率不高很多位置是空的除非用专门的图神经网络处理电极关系。第三种是 2D 频域图输入。先对每个窗口做 STFT 或小波变换得到 (通道, 频率, 时间) 的三维张量再把它作为 2D 图像输入网络。这种输入信息量最大但对算力的需求也最高而且训练不稳定在小数据集上容易过拟合。选择哪种排布取决于你的计算资源和实验目标。1D 卷积是性价比最高的起点它天然适配脑电的时序本质不需要构造虚假的空间网格也不像频域图那样要求大量数据。所以我的建议是先用 1D CNN 把流程跑通再在它的基础上加入额外的频段特征或注意力机制逐步改进。3.3 模型规模在小数据上不要盲目堆参数DEAP 即使切窗之后一个被试的数据也就几千个窗口40 个 trial × 约 120 个窗口等于 4800 个去掉重叠影响有效独立信息其实更少。用动辄几百万参数的 ResNet 级网络去拟合这种规模的数据得到的只有过拟合。经验法则是单被试模型参数量控制在几十万以内第一层卷积核数量 16 到 32 就够全连接隐藏层维度不要超过 128。跨被试实验的数据量更大一些可以把网络加宽到 32 到 64 个卷积核但也没必要堆到 128 以上。模型复杂度与数据量的匹配是深度学习落地中最常被忽视但决定性最强的因素之一。4. PyTorch 代码实战从 Dataset 到训练循环一次跑通4.1 自定义 Dataset把预处理后的窗口封装成标准输入下面的代码以单被试建模为例你也可以扩展到多被试。核心思路是加载 .mat → 截取前 32 个 EEG 通道 → 删除前 3 秒基线并切成窗口 → 计算 DE 特征供 DNN 用或保留原始窗口供 CNN 用→ 按标签阈值生成二分类目标。import numpy as np import scipy.io as sio import torch from torch.utils.data import Dataset class DEAPDataset(Dataset): def __init__(self, mat_path, modednn, win_len128, step_len64, selected_channels32): # 加载数据 mat sio.loadmat(mat_path) data mat[data] # (40, 40, 8064) labels mat[labels] # (40, 4) # 只保留前 selected_channels 个真实脑电通道 eeg data[:, :selected_channels, :] # (40, 32, 8064) # 去掉前 3 秒基线384 个采样点 eeg eeg[:, :, 384:] self.mode mode self.win_len win_len self.step_len step_len self.features [] self.targets [] for trial_idx in range(eeg.shape[0]): trial_data eeg[trial_idx] # (32, 7680) trial_label labels[trial_idx] # 二分类标签valence 5 为 1否则为 0这里也可以换成 arousal valence trial_label[0] y 1 if valence 5.0 else 0 # 切窗 n_samples trial_data.shape[1] for start in range(0, n_samples - win_len 1, step_len): win trial_data[:, start:start win_len] # (32, win_len) if mode dnn: # 提取 DE 特征每个通道5个频带得到160维 win_feat extract_de_features(win, 128) self.features.append(win_feat) else: # CNN 使用原始时域片段 self.features.append(win) self.targets.append(y) if mode dnn: self.features np.array(self.features, dtypenp.float32) else: self.features np.array(self.features, dtypenp.float32) self.targets np.array(self.targets, dtypenp.int64) def __len__(self): return len(self.targets) def __getitem__(self, idx): return torch.tensor(self.features[idx]), torch.tensor(self.targets[idx])这段代码有几个细节需要注意。selected_channels32用得好不好直接影响模型看到的数据win_len128对应 1 秒窗口step_len64对应 0.5 秒步长。切窗时的重叠比例由(win_len - step_len) / win_len决定这里就是 50%。如果你想做 4 秒窗口直接把win_len改成 512 即可同时step_len建议设为 128 到 256 之间。4.2 微分熵特征提取函数extract_de_features这个函数在 DNN 模式里是核心。这里给出一个基于滤波的实现方式虽然用到了 scipy但它稳定且容易理解。from scipy.signal import butter, lfilter def butter_bandpass(lowcut, highcut, fs128, order4): nyquist 0.5 * fs low max(lowcut, 0.5) / nyquist high min(highcut, nyquist - 0.5) / nyquist b, a butter(order, [low, high], btypeband) return b, a def bandpass_filter(data, lowcut, highcut, fs128): b, a butter_bandpass(lowcut, highcut, fs) return lfilter(b, a, data, axis-1) def extract_de_features(window, fs128): # window: (n_channels, win_len) bands [(1, 3), (4, 7), (8, 13), (14, 30), (31, 50)] n_channels window.shape[0] de_features [] for ch in range(n_channels): for lowcut, highcut in bands: filtered bandpass_filter(window[ch], lowcut, highcut, fs) variance np.var(filtered) de 0.5 * np.log(2 * np.pi * np.e * variance 1e-6) de_features.append(de) return np.array(de_features, dtypenp.float32)这个实现里我加了1e-6的微小常数避免方差为 0 时对数函数崩溃。实际脑电信号方差几乎不可能为 0但做防御性编程能避免不必要的调试痛苦。每个通道 5 个频带32 个通道一共 160 维特征modednn的 Dataset 输出形状就是 (样本数, 160)。4.3 DNN 和 CNN 的模型定义DNN 模型很简单3 层全连接加上 Dropout。Dropout 对 DEAP 这种小数据任务是刚需它能显著抑制过拟合。import torch.nn as nn import torch.nn.functional as F class DNN(nn.Module): def __init__(self, input_dim160, hidden_dims[128, 64], dropout0.5): super().__init__() layers [] prev_dim input_dim for hdim in hidden_dims: layers.append(nn.Linear(prev_dim, hdim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) prev_dim hdim layers.append(nn.Linear(prev_dim, 1)) self.net nn.Sequential(*layers) def forward(self, x): return self.net(x).squeeze(-1)CNN 模型用的是 1D 卷积结构。输入形状是 (batch, 32, 128)即 32 个通道作为输入通道128 个时序点作为序列长度。我设计了三层卷积通道数从 32 逐层增加最后接全局平均池化和全连接层。class EEGCNN(nn.Module): def __init__(self, n_channels32, time_points128, n_classes1): super().__init__() self.conv1 nn.Sequential( nn.Conv1d(n_channels, 32, kernel_size7, padding3), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2) # 输出长度 64 ) self.conv2 nn.Sequential( nn.Conv1d(32, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2) # 输出长度 32 ) self.conv3 nn.Sequential( nn.Conv1d(64, 64, kernel_size3, padding1), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2) # 输出长度 16 ) self.global_pool nn.AdaptiveAvgPool1d(1) self.fc nn.Linear(64, n_classes) def forward(self, x): # x: (batch, 32, 128) x self.conv1(x) x self.conv2(x) x self.conv3(x) x self.global_pool(x).squeeze(-1) out self.fc(x).squeeze(-1) return out为什么卷积核从 7 降到 5 再降到 3这是模仿感受野逐步扩大的设计前几层用较大的核捕捉局部时域模式后面用小核加深非线性。BatchNorm1d 在脑电时序数据上效果明显它能把不同通道的分布拉齐加速收敛。最后用全局平均池化替代展平操作好处是无论输入序列长度怎么变全连接层的输入维度都是 64便于调整窗口长度时保持模型兼容。4.4 归一化、训练循环和评估协议这是全篇最容易出问题的地方归一化的位置直接决定你的结果可不可信。下面代码演示如何构造干净的单被试训练、验证划分。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 以 DNN 模式为例构造数据集 dataset_dnn DEAPDataset(data_preprocessed_matlab/s01.mat, modednn) X dataset_dnn.features y dataset_dnn.targets # 按窗口样本随机划分注意这是单被试内部的切分方式 X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42, stratifyy) # 关键只 fit 训练集再 transform 验证集 scaler StandardScaler() X_train scaler.fit_transform(X_train) X_val scaler.transform(X_val) # 转成 tensor 并创建 DataLoader from torch.utils.data import TensorDataset, DataLoader train_dataset TensorDataset(torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.long)) val_dataset TensorDataset(torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.long)) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) val_loader DataLoader(val_dataset, batch_size256, shuffleFalse)对 CNN 模式只需要把modednn换成modecnn然后对每个样本做类似的标准化。注意 CNN 的标准化对象是多通道时序可以逐通道计算 mean/std不要粗暴地压平整个样本再算。下面的代码演示了逐通道标准化dataset_cnn DEAPDataset(data_preprocessed_matlab/s01.mat, modecnn) X_c dataset_cnn.features # (N, 32, 128) y_c dataset_cnn.targets # 逐通道计算训练集的均值和标准差 X_train_c, X_val_c, y_train_c, y_val_c train_test_split( X_c, y_c, test_size0.2, random_state42, stratifyy_c) train_mean X_train_c.mean(axis(0, 2), keepdimsTrue) train_std X_train_c.std(axis(0, 2), keepdimsTrue) 1e-8 X_train_c (X_train_c - train_mean) / train_std X_val_c (X_val_c - train_mean) / train_std训练循环直接使用 PyTorch 标准写法。我用 BCELoss 需要配合 Sigmoid但模型已经直接输出了 logits所以用 BCEWithLogitsLoss 更合适。每个 epoch 计算训练集和验证集的准确率并把验证集准确率最高的模型保存下来。device torch.device(cuda if torch.cuda.is_available() else cpu) model DNN(input_dim160).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) criterion nn.BCEWithLogitsLoss() def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for xb, yb in loader: xb, yb xb.to(device), yb.float().to(device) optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() optimizer.step() total_loss loss.item() * len(xb) preds (logits 0).long() correct (preds yb.long()).sum().item() total len(xb) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0, 0, 0 with torch.no_grad(): for xb, yb in loader: xb, yb xb.to(device), yb.float().to(device) logits model(xb) loss criterion(logits, yb) total_loss loss.item() * len(xb) preds (logits 0).long() correct (preds yb.long()).sum().item() total len(xb) return total_loss / total, correct / total best_acc 0 for epoch in range(50): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) if (epoch 1) % 10 0: print(fEpoch {epoch1}: train_loss{train_loss:.4f}, train_acc{train_acc:.4f}, fval_loss{val_loss:.4f}, val_acc{val_acc:.4f}) print(fBest val acc: {best_acc:.4f})这段代码跑出来单被试二分类的最好准确率一般落在 0.70 到 0.82 区间。如果低于 0.65先检查前面几个环节标签做的是 valence 还是 arousal 划分、切窗是否从窗口上漏了、归一化是否正确、随机种子是否固定。如果高于 0.9先别高兴很可能是数据泄漏比如把不同试次的相同窗口混到了训练和验证集里。4.5 评估指标准确率之外的多看几个数DEAP 二分类的类别比例不是严格的 1:1因为对每个被试来说40 个 trial 按照 valence 5 划分后正负类数量可能一个是 24 一个是 16。这时候只看准确率容易被带偏。建议在测试时额外输出精确率、召回率、F1 值和混淆矩阵。from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix # 收集所有预测结果 all_preds, all_labels [], [] model.load_state_dict(torch.load(best_model.pt)) model.eval() with torch.no_grad(): for xb, yb in val_loader: xb xb.to(device) logits model(xb) preds (logits 0).long().cpu().numpy() all_preds.extend(preds) all_labels.extend(yb.numpy()) print(Accuracy:, accuracy_score(all_labels, all_preds)) print(Precision:, precision_score(all_labels, all_preds)) print(Recall:, recall_score(all_labels, all_preds)) print(F1:, f1_score(all_labels, all_preds)) print(Confusion Matrix:) print(confusion_matrix(all_labels, all_preds))多个指标一起看能帮你识别出模型是否在偷懒。比如准确率还行但召回率很低说明模型倾向于把所有样本都预测为多数类这时候调整损失权重或者重采样会更有帮助。5. 我在复现 DEAP 实验中踩过的最深几个坑5.1 全局归一化导致的假高精度这个坑我在前面已经反复提醒过。它之所以隐蔽是因为代码看起来没有任何异常加载数据、归一化、划分、训练。但如果你先归一化再划分训练验证集或者干脆对全量数据做StandardScaler().fit_transform(X)验证集的信息就已经渗入了训练阶段。在 DEAP 这种被试数量少、试次内部高度自相关的数据上这个泄漏会把准确率从合理的 0.74 抬高到 0.88 甚至 0.93。怎么自检有一个简单办法把训练标签随机打乱再用同一个流程跑一遍。如果打乱标签后准确率还显著高于 0.5那你的验证流程一定有问题赶紧回去查归一化和数据划分顺序。我建议所有 DEAP 实验都把这个随机标签 sanity check写进代码里能省去大量被假结果误导的时间。5.2 切窗重叠导致的相邻窗口泄漏1 秒窗口、0.5 秒步长的配置下相邻窗口重叠了 50 个采样点。如果你用train_test_split随机划分这些窗口来自同一个 trial 的相邻窗口很可能一个落在训练集、一个落在验证集。因为两个窗口共享了大量时间信号验证集的预测自然更准。这个泄漏比较隐蔽许多论文都不交代。更严格的折衷方案是保证划分粒度落在 trial 级别而不是 window 级别也就是先按 trial 划分再把该 trial 产生的所有窗口归入同一个集合。这样每个窗口要么全在训练集要么全在验证集。如果做跨被试实验这个问题天然被规避了因为不同被试之间的信号完全不重叠。但单被试实验必须面对它。我的建议是单被试实验里用 trial 级别的GroupShuffleSplit把 40 个 trial 按比例划分为训练和验证然后在该验证协议下比较 DNN 和 CNN 的差异这样的结论才站得住脚。5.3 标签阈值和标签维度的选择不同结果不可直接对比DEAP 的标签有 4 列Valence、Arousal、Dominance、Liking。你用了哪一列做分类用的是 5 分阈值还是按中位数划分都会让结果不可直接对比。不少论文只含糊地写着valence 5但有人会用 5 作为严格阈值有人用每被试中位数划分两者在类别数量分布上差异很大。实际复现时建议把实验配置写详细标签类型、threshold、win_len、step_len、channel 数量、归一化方式、被试划分方式这些信息缺一个结果都无法复现。我自己写代码时会把这些都写进一个配置字典里一并保存模型 checkpoint这样回头查问题是才知道当初到底跑了什么。5.4 大模型在单个被试上不会更好如果你把 ResNet18 搬到单被试 DEAP 上得到的结果大概率不如上面那个简单 CNN。原因不复杂单个被试的独立样本就那么几百个有效 trial小网络已经足以拟合大网络的强大表达能力在这里只是过拟合的工具何况还拖慢了训练速度。我见过有人拿着几十层的网络在 DEAP 上调试整夜开 GPU 挂着训练最后准确率还不如一个 3 层卷积。模型复杂度要和数据规模匹配这在脑电这种小样本场景里是铁律。先跑通简单模型拿到可复现的基线再在这个基线上做渐进式修改才是效率最高的路径。最后再分享一个我在实验里养成的小习惯每次跑完一个配置我会顺手把模型的预测结果和对应的 trial 编号一并存下来而不是只存一个准确率数字。因为当你想分析模型在哪些视频片段上犯错误时这些细粒度的结果比一个平均指标有用得多。情绪识别不是只看 ACC 纳秒级的涨跌理解错误样本背后的生理和实验设计因素往往才是做出真正有价值改进的起点。