简介面向脑电情绪识别研究者与深度学习入门者这套基于DEAP和MAHNOB数据集的二分类情绪识别项目包含2021年发表于顶级期刊的论文及完整PyTorch实现聚焦DNN与CNN两种模型在多数据集上的性能对比并通过McNemar与5x2cv统计检验验证模型稳健性。压缩包共27个文件涵盖12个Python脚本、8个YAML配置、4个预训练权重、论文PDF及说明文档总大小约324.33MB结构清晰便于对照研究。已有1940人学习下载。资源最大价值在于可直接复现论文实验训练代码与模型细节完整预训练.pt文件支持快速加载验证DEAP与MAHNOB数据集的结果对比可帮助理解模型泛化能力尤其适合需要开展脑电情绪识别实验或比较模型表现的科研场景。1. 脑电情绪识别项目拆解DEAP 与 MAHNOB 数据集上的 PyTorch 双模型实战这套资源解决的是一个很具体的任务拿到 DEAP 或 MAHNOB 的原始脑电数据用 PyTorch 分别搭一个 DNN 和一个 CNN完成二分类情绪识别——高唤醒对低唤醒或者正效价对负效价随你定。适合刚接触脑电深度学习的从业者做基线对比的在校学生以及想快速跑通“数据清洗 → 特征组织 → 模型训练 → 指标评估”这条完整链路再换自己模型的工程师。先说结论这类任务的瓶颈往往不在模型结构而在数据读取和样本组织方式DEAP 的 8064 时间点和 MAHNOB 的 256 Hz 采样格式处理错一步后面所有准确率数字都没有意义。2. 读取与预处理DEAP 的 40×40×8064 矩阵、MAHNOB 的 256 Hz 数据与标签二值化2.1 先看清 DEAP 数据结构再写加载代码DEAP 数据集官方发布的是预处理后的版本每个被试存成一个.dat文件文件名从s01.dat到s40.dat。用 pickle 读出来之后里面是两个对象第一个是 EEG 数据矩阵形状是(40, 40, 8064)第二个是标签矩阵形状是(40, 4)。三个维度的含义分别是40 个试次trial、40 个通道、8064 个采样点。每个 trial 对应一段 63 秒的视频刺激采样率是 128 Hz所以 63 秒 × 128 Hz 8064 个点其中前 3 秒是基线后 60 秒是完整刺激时段。40 个通道里前 32 个是 EEG 通道按照国际 10-20 系统排列后 8 个是眼电、肌电、皮电等外围生理信号。做情绪识别时绝大多数实验只用前 32 个 EEG 通道外围信号当作干扰源直接剔掉。标签矩阵的 4 列分别是 valence、arousal、dominance、liking每个值在 1 到 9 之间是被试观看视频后的主观评分二分类任务通常取 5 作为分界线。加载单个被试的代码可以这样写import pickle import numpy as np def load_deap(subject_id, data_dirdata): # subject_id 从 1 到 40文件名按两位数字编号 file_path f{data_dir}/s{subject_id:02d}.dat with open(file_path, rb) as f: data pickle.load(f, encodinglatin1) eeg_data, labels data[0], data[1] # 只保留前 32 个 EEG 通道丢掉 8 个生理通道 eeg_data eeg_data[:, :32, :] # (40, 32, 8064) print(fsubject {subject_id:02d}: ftrials{eeg_data.shape[0]}, fchannels{eeg_data.shape[1]}, ftimepoints{eeg_data.shape[2]}) return eeg_data, labels加载时注意encodinglatin1这个参数DEAP 的.dat文件是用 Python 2 的 pickle 序列化出来的Python 3 读取时如果不指定编码大概率直接抛 UnicodeDecodeError。这是最常见的第一个坑。另外我建议把只取前 32 通道的逻辑写在加载函数里而不是后面处理这样后续所有代码都基于统一的(trials, channels, timepoints)形状不容易搞混。如果不需要外围信号越早丢弃越好。2.2 滑动窗口切分与基线校正原始 DEAP 数据每个 trial 只有一段 63 秒的信号直接拿整段作为输入样本的话40 个被试 × 40 个 trial 总共只有 1600 个样本对 DNN 和 CNN 来说远远不够。常规做法是滑动窗口切分在时间维度上以固定窗口长度和步长切出短片段每个片段作为一个独立样本标签沿用所属 trial 的标签。窗口长度取 1 秒128 个采样点、步长取 0.5 秒64 个采样点是论文里最常见的一组参数。切分之前先做基线校正。DEAP 每个 trial 的前 3 秒是静息基线被试没有观看任何刺激。基线校正的常见做法是把整个 trial 减去前 3 秒的平均值消除基线漂移的影响。如果不做这一步模型学到的一部分“情绪特征”可能只是不同 trial 之间的整体幅值差异而不是真正的情绪诱发响应。基线校正后再去切窗顺序不能反。def split_windows(eeg_trial, fs128, win_len_sec1.0, step_sec0.5): eeg_trial: (32, 8064) 单个 trial 数据 返回: (num_windows, 32, win_len) baseline_len fs * 3 # 384 个采样点即前 3 秒 signal eeg_trial[:, baseline_len:baseline_len fs * 60] # 基线校正每个通道减去前 3 秒均值 baseline_mean eeg_trial[:, :baseline_len].mean(axis1, keepdimsTrue) signal signal - baseline_mean win_len int(win_len_sec * fs) step int(step_sec * fs) windows [] for start in range(0, signal.shape[1] - win_len 1, step): seg signal[:, start:start win_len] windows.append(seg) return np.stack(windows, axis0)窗口长度和步长这两组参数值得细说。窗口太短比如 0.5 秒单窗口内能捕获的情绪诱发模式不完整模型输入的有效信息少准确率上不去窗口太长比如 2 秒以上样本总数减少而且情绪状态在长时间窗口内可能发生波动反而干扰分类。0.5 秒的步长让相邻窗口有 50% 重叠相当于做了数据增强样本量翻倍。一个 trial 切出的窗口数量大约是(8064 - 384 - 128) / 64 1 119 个40 个被试 × 40 个 trial 下来DNN 和 CNN 的训练样本总数接近 19 万足够喂饱一个中等规模的全连接网络了。2.3 MAHNOB 数据读取差异与标签构造MAHNOB-HCI 数据集和 DEAP 结构上有几个关键差异。第一MAHNOB 包含 30 个被试、20 个试次每个试次对应一段视频数量比 DEAP 少第二原始采集采样率是 256 Hz比 DEAP 的 512 Hz 原始采样低预处理版本通常会重采样到 128 Hz拿到数据后先确认实际采样率再计算窗口长度第三MAHNOB 的.mat文件组织方式在不同课题组发布版本里差异很大字段名不统一需要根据实际文件内容自适应读取。以常见的 BCI 实验组织方式为例.mat文件里会有一个三维数组形状大概是(trials, channels, samples)标签在另一个数组里存着包含 1 到 9 的评分。如果文件是从原版直接导出的还要处理通道顺序和坏通道标记。我一般会写一个自适应的加载函数先遍历.mat文件的所有变量找出维度为 3 且第二维接近 32 的数组作为 EEG 数据再从一个至少 2 列且取值在 1-9 的数组里找标签列。import scipy.io as sio import numpy as np def load_mahnob_mat(mat_path): raw sio.loadmat(mat_path, squeeze_meTrue, struct_as_recordFalse) # 自动找出形状为 (trials, channels, samples) 的候选数据 candidates [] for v in raw.values(): if isinstance(v, np.ndarray) and v.ndim 3 and v.shape[1] in (32, 34): candidates.append(v) if not candidates: raise ValueError(未找到符合 (trials, channels, samples) 结构的数据变量) data candidates[0] # 标签部分按实际情况映射常见的 eeg/valence/arousal 字段 labels None if labels in raw: labels raw[labels] elif valence in raw and arousal in raw: labels np.stack([raw[valence], raw[arousal]], axis-1) return np.asarray(data), np.asarray(labels)这里我不硬编码字段名是因为 MAHNOB 的 mat 文件结构确实存在多个版本写死必翻车。情绪标签的二值化规则和 DEAP 一致valence 大于等于 5 记为 1小于 5 记为 0arousal 同理。实际使用中你也可以参考 MAHNOB 数据库自带的连续标注或分类标签但为了和 DEAP 保持统一评估口径我建议两个数据集的二分类阈值统一取 5这样同一套模型结构在两份数据上的结果可以直接对比。3. PyTorch 环境搭建与 DNN 基线从安装到跑通第一个训练循环3.1 环境配置conda 建环境、PyTorch 安装和 CUDA 版本匹配PyTorch 版本选型这件事没有太多玄学核心原则是确定 CUDA 版本后再选 PyTorch 版本而不是反过来。先用nvidia-smi查看驱动支持的 CUDA 版本再按 CUDA 版本安装对应的 PyTorch。目前 PyTorch 2.x 的默认 wheel 包兼容性已经很稳定Python 3.9 到 3.10 都没问题。如果我是在新机器上从头搭环境用 conda 创建独立环境是控制依赖冲突的最省心方式。conda create -n eeg python3.9 -y conda activate eeg # 安装 CPU 版调试优先 pip install torch torchvision torchaudio # 如果确认有 Nvidia GPU换成对应 CUDA 的安装方式 # pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121安装完第一件事不是建模型而是验证环境是否可用。这一步很关键因为 PyTorch 版本和驱动不匹配时报错信息往往要到第一次前向传播才出现。我会优先跑一次 GPU 可用性检查确认torch.cuda.is_available()是 True再开始写训练代码。如果要用 CPU 调试小数据量时问题不大但 DEAP 切完窗口后样本量达到几十万级CPU 训练一轮就要等很久建议在 10 万样本规模下就开始用 GPU。import torch print(PyTorch:, torch.__version__) print(CUDA:, torch.version.cuda) print(GPU available:, torch.cuda.is_available()) if torch.cuda.is_available(): print(GPU name:, torch.cuda.get_device_name(0))从这里开始所有代码默认按 GPU 可用的情况来写。如果你的机器没有 GPU把模型、数据、标签统一加.cpu()也能跑只是训练速度会慢一个数量级。3.2 DNN 模型定义把脑电窗口展平成向量DNN 在这个项目里的定位是基线模型作用是用最直接的方式验证数据预处理链路是否正确。输入是一个(32, 128)的窗口前面做滑动窗口切分时每个窗口的形状就是 32 个通道乘 128 个时间点。展平后得到 4096 维的向量后面接三层全连接网络。全连接层的设计有一个经验第一层维度可以适当大一些比如 256然后逐层递减到 128、1。中间加 BatchNorm 和 Dropout这是防止过拟合的两个标准手段。import torch.nn as nn class DNNClassifier(nn.Module): def __init__(self, n_channels32, n_samples128): super().__init__() input_dim n_channels * n_samples self.net nn.Sequential( nn.Linear(input_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 1) ) def forward(self, x): # x: (batch, 32, 128) - (batch, 4096) x x.reshape(x.size(0), -1) return self.net(x)写入模型结构时请注意 BatchNorm 的使用时机。BatchNorm 在训练阶段统计当前 batch 的均值方差在 eval 阶段使用训练时的滑动均值所以训练和验证要分别调用model.train()和model.eval()这个细节直接影响验证集指标的可信度。Dropout 同样有这个行为训练时随机丢弃神经元验证时全部保留。如果只调用前向传播而不切换模式BatchNorm 和 Dropout 会给你一份“好看但失真”的验证结果。3.3 数据加载器与训练循环BCEWithLogitsLoss 和二分类输出设计数据加载部分用 PyTorch 的Dataset和DataLoader组织。每个样本是(32, 128)的窗口标签是二分类的 0 或 1。我在训练前会把所有窗口沿通道维度做标准化常见的做法是 z-score。注意标准化应该按被试或者按通道计算而不是全局所有样本算一个均值否则不同被试之间的幅值差异会污染模型。class EEGDataset(torch.utils.data.Dataset): def __init__(self, windows, labels): # windows: (N, 32, 128), labels: (N,) self.windows torch.FloatTensor(windows) self.labels torch.FloatTensor(labels).unsqueeze(1) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.windows[idx], self.labels[idx]训练循环里我固定用BCEWithLogitsLoss模型最后一层不做 sigmoid而是在计算损失时由 BCEWithLogitsLoss 内部完成 sigmoid 加交叉熵。这样数值上更稳定梯度计算也更干净。预测阶段把模型输出的 logit 过一遍 sigmoid按大于 0.5 判为类别 1。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for x, y in dataloader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) # (batch, 1) loss criterion(logits, y) loss.backward() optimizer.step() preds (torch.sigmoid(logits) 0.5).float() correct (preds y).sum().item() total y.size(0) total_loss loss.item() * x.size(0) return total_loss / total, correct / total这里还有一个容易被忽略的细节criterion直接接受 float 类型的标签不需要做 one-hot 编码。二分类本质上是单一目标输出标签维度是(batch, 1)不是(batch, 2)。如果写成nn.CrossEntropyLoss就要输出 2 维 logits 并用整数标签索引那是多分类范式在二分类任务上性能没有本质差别但代码路径会繁琐不少。初次跑通时我建议用 BCE 这一套调试效率高打印 loss 时也直观。4. CNN 模型与特征布局把 32 通道 × 128 时间点当作二维图像来做卷积4.1 为什么 CNN 能比 DNN 更适合脑电窗口DNN 把 4096 维展平后直接全连接通道维度和时间维度的空间结构完全被打散。CNN 保留了这个结构32 个通道沿一个轴排列128 个时间点沿另一个轴排列卷积核同时滑过通道和时间能够学到局部的时间模式以及相邻通道之间的空间相关性。对脑电信号来说额叶和颞叶的电位变化往往在相邻通道间有同步趋势CNN 的局部连接刚好能抓住这种模式。输入组织方式我采用二维卷积把(32, 128)视为一张 32 像素高、128 像素宽的单通道灰度图输入形状为(batch, 1, 32, 128)。这里把通道维度当作图像高度时间维度当作图像宽度。实际使用中有的做法会基于 10-20 系统的电极坐标把 32 个电极映射到二维平面网格得到类似脑地形图的输入。那种方案需要额外计算电极坐标而且网格中大量位置是空白的稀疏矩阵会浪费卷积核参数。在 DEAP 这种 32 通道的规模下直接组织成图像矩阵更简单效果差距并不明显。4.2 实现一个可运行的 2D CNN 模型模型结构按经典的“卷积块 池化 全连接”堆叠两个卷积块后接全局平均池化。第一个卷积块用 16 个3×3卷积核第二个用 32 个第三个用 64 个。每层卷积后跟 BatchNorm 和 ReLU池化用 MaxPool2d核大小(2, 2)会把时间维度和通道维度各压缩一半。两个池化之后通道维度从 32 降到 8时间维度从 128 降到 32。最后接全连接层分二分类。import torch.nn as nn class CNN2DClassifier(nn.Module): def __init__(self, n_channels32, n_samples128): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, kernel_size(3, 3), padding(1, 1)), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(kernel_size(2, 2)), # (16, 16, 64) nn.Conv2d(16, 32, kernel_size(3, 3), padding(1, 1)), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(kernel_size(2, 2)), # (32, 8, 32) nn.Conv2d(32, 64, kernel_size(3, 3), padding(1, 1)), nn.BatchNorm2d(64), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)), # 压缩成 (64, 1, 1) ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, 1, 32, 128) feats self.features(x) return self.classifier(feats)padding(1, 1)保证了卷积操作不改变特征图尺寸只由池化负责下采样。AdaptiveAvgPool2d 在这里的好处是不用计算全连接输入维度它把最后一个卷积块的输出统一压缩成长度为 64 的向量。如果你换用 MaxPool2d 作为最后一个池化层就要手工计算展平维度前面两次池化把 32×128 缩到 8×3264 个卷积核的输出展平后是 64×8×32 16384这个数字写在 Linear 里很容易因为模型结构改动而失配。用 AdaptiveAvgPool 就能省掉这个麻烦模型结构再改分类器入口始终是 64。4.3 1D CNN 方案把通道当作独立流处理2D CNN 把通道和时间都当作卷积维度但有另一个流派认为 32 个通道之间不应该直接卷积因为相邻通道的物理距离不相等卷积核看作空间位置的关系不够严谨。这个流派的做法是用 1D CNN每个输入样本形状是(32, 128)把 32 个通道当作 Conv1d 的输入通道卷积核只在时间维度上滑动步长覆盖多个时间点空间信息靠卷积核跨通道叠加来融合。import torch.nn as nn class CNN1DClassifier(nn.Module): def __init__(self, n_channels32, n_samples128): super().__init__() self.conv nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size32, padding16), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 64, kernel_size16, padding8), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, 32, 128) feats self.conv(x) return self.classifier(feats)注意第一个卷积层kernel_size32意味着一个卷积核在时间维度上覆盖 32 个采样点约 0.25 秒这个跨度对情绪诱发的脑电节律是合理的。padding16保持输出序列长度不变第二层卷积核 16、padding 8覆盖约 0.125 秒。最后用 AdaptiveAvgPool1d 把整个时间序列压缩成一个值。我自己的习惯是先跑 2D CNN因为参数调整直观卷积核、池化、通道数变化对结果的影响容易解释1D CNN 作为替代方案留到后期对比用。如果后续想加入被试间泛化实验1D CNN 往往更受跨被试变异性影响小因为它没有在通道维度上做强空间假设。4.4 优化器、学习率与损失函数的组合CNN 模型和 DNN 在训练配置上基本通用我用 Adam 优化器学习率从 1e-3 起步如果 loss 在 10 个 epoch 内不下降降到 1e-4。DEAP 数据切窗后样本量大batch size 我固定用 128这个规模下 GPU 显存占用不大两三百 MB 就够。损失函数继续用 BCEWithLogitsLoss和 DNN 保持一致这样两个模型最终的准确率、F1 值可以直接对比不受损失函数差异干扰。import torch.optim as optim def build_optimizer(model): optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR( optimizer, step_size20, gamma0.5 ) return optimizer, schedulerStepLR 每 20 个 epoch 把学习率减半这种策略对脑电信号深模型很有效。DEAP 和 MAHNOB 的样本分布不算复杂学习率过低会拖慢收敛学习率过高直接不收敛。如果某个训练轮次 loss 从正常值突然跳成一个大数先检查是不是梯度爆炸把梯度裁剪加上或者降低学习率。经验上用 BCEWithLogitsLoss 时输出 logits 出现几十上百的绝对值就说明学习率配比有问题了。5. 避坑与常见问题脑电数据训练里最常翻车的五个环节5.1 训练指标正常、验证指标异常BatchNorm 和 Dropout 的模式开关没切现象训练集准确率很快到 90% 以上验证集准确率只有 60%而且差距从第一个 epoch 就非常大。如果是在训练过程中逐 epoch 打印的训练指标问题可能不在过拟合而是模型没有在验证时切换到 eval 模式。现象背后的原因很具体BatchNorm 在训练模式用当前 batch 的均值方差做归一化验证模式要用训练阶段累计的滑动均值方差Dropout 在训练模式随机丢弃神经元验证模式不丢弃。如果验证循环里直接model(x)而没有先调model.eval()模型的前向传播行为不一致验证指标完全失真。解决方法是严格在训练循环开头写model.train()验证循环开头写model.eval()并且验证阶段计算梯度前用torch.no_grad()把梯度流关掉。这个坑几乎人人都会踩一次我建议把训练、验证封装成两个函数模式切换固定写在函数入口不要裸写循环。5.2 按 trial 随机划分数据集导致被试数据泄漏现象模型在训练集上极快收敛验证集准确率甚至比训练集还高换一个被试的数据来测指标直接崩到接近随机水平。原因是被试的数据泄漏如果切窗后把所有窗口放在一起随机分训练集和验证集同一个被试的不同 trial、不同窗口可能分别出现在训练和验证里。模型学习到的其实是被试 ID 相关的个体特征。后一种情况更隐蔽验证指标还行但换新被试就失效。解决方法是按被试划分典型的方案是 leave-one-subject-out即每次留一个被试的数据做验证其余被试训练保证验证集里的所有窗口都来自训练中完全没见过的被试。对于 DEAP 这种 40 个被试的数据做 40 次 LOSO 交叉验证最后上报平均指标这样得到的数字才是真实可用水平。论文里如果有准确率 95% 之类的数字先问一句它是按 trial 划分还是按 subject 划分答案基本决定了这个数字的可信度。5.3 标签不均衡导致模型只会预测多数类现象训练 loss 在逐步下降但准确率始终维持在 55% 到 60% 左右查看预测结果发现模型把所有样本都判成了类别 0。原因是二分类标签在窗口级上存在比例偏差。DEAP 中按 5 分阈值二值化后不同被试的 valence 正负比例差异很大有的被试正样本占 70%有的只占 30%总体上接近均衡但按被试切分后某些验证被试的类别比例会明显倾斜。解决方式有两个一是给少数类更高的损失权重BCEWithLogitsLoss(pos_weighttorch.tensor([pos_weight]))权重按训练集中正负样本比例计算二是在评估时同时看准确率、F1 值和 AUC不要只看准确率。我自己的惯例是每次训练前打印训练集和验证集的标签分布如果分布差异超过 10 个百分点就该考虑加类别权重了。5.4 全连接层的输入维度和模型结构不匹配现象模型定义好后训练第一次前向传播报错通常是RuntimeError: mat1 and mat2 shapes cannot be multiplied或者size mismatch。原因是卷积池化后的特征图尺寸和 Linear 层定义的特征维度对不上。前面用的 AdaptiveAvgPool 方案是为了规避这个问题如果用了普通 MaxPool 加 Flatten就需要手工算展平长度。以 2D CNN 为例输入(32, 128)经过两次 MaxPool2d(2, 2)尺寸变成(8, 32)如果最后一个卷积块输出 64 个通道展平后是 64×8×32 16384 维。这个数字写错一位前向传播必挂。解决建议是不要在模型里硬编码数字先向模型传入一个假数据张量torch.rand(1, 1, 32, 128)打印每个 block 输出的 shape把最后的展平维度当作超参传入 Linear 层。5.5 PyTorch 版本和 CUDA 驱动不匹配GPU 跑不起来现象按照默认命令安装 PyTorch 后torch.cuda.is_available()输出 False但nvidia-smi显示 GPU 正常。原因一般是安装了 CPU 版 PyTorch或者 PyTorch 对应的 CUDA 版本高于驱动所支持的版本。解决方法是先查驱动命令行执行nvidia-smi看右上角 CUDA Version。如果驱动 CUDA 版本是 12.1就安装 cu121 版本的 PyTorch如果是 11.8安装 cu118。建议不要用pip install torch的默认 PyTorch那个版本可能没有带上你需要的 CUDA 支持。如果机器上已经装错先在 conda 环境里卸载再重装不要在原环境上折腾干净环境十分钟能解决的问题修改环境可能要花半天。我见过最惨的一次是学生在已有 TensorFlow 环境里直接装 PyTorch结果两个框架的 CUDA 动态库互相干扰最后只能重装 CUDA 工具包。6. 进阶用 leave-one-subject-out 验证模型能跨被试泛化多少把 LOSO 交叉验证跑通是这类资源里最值得做的一件事。DEAP 有 40 个被试每次留 1 个被试的全部窗口作为测试集其余 39 个被试的数据作为训练集循环 40 次最终得到 40 组的准确率和 F1 值。这个结果的均值和方差比任何单次划分指标都更能说明模型真实水平。主体逻辑是先按被试遍历每次构造训练窗口和测试窗口再进入和之前完全相同的训练循环。def loso_evaluate(model_fn, windows_by_subject, labels_by_subject, epochs30): scores [] n_subjects len(windows_by_subject) for test_idx in range(n_subjects): train_windows np.concatenate( [windows_by_subject[i] for i in range(n_subjects) if i ! test_idx], axis0 ) train_labels np.concatenate( [labels_by_subject[i] for i in range(n_subjects) if i ! test_idx], axis0 ) test_windows windows_by_subject[test_idx] test_labels labels_by_subject[test_idx] model model_fn().to(device) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.BCEWithLogitsLoss() train_loader DataLoader(EEGDataset(train_windows, train_labels), batch_size128, shuffleTrue) test_loader DataLoader(EEGDataset(test_windows, test_labels), batch_size128, shuffleFalse) for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion, device) model.eval() with torch.no_grad(): preds_all, labels_all [], [] for x, y in test_loader: x, y x.to(device), y.to(device) logits model(x) preds (torch.sigmoid(logits) 0.5).float().cpu() preds_all.append(preds) labels_all.append(y.cpu()) preds_all torch.cat(preds_all).numpy() labels_all torch.cat(labels_all).numpy() acc (preds_all labels_all).mean() f1 f1_score(labels_all, preds_all, zero_division0) scores.append((acc, f1)) return np.array(scores)LOSO 结果里通常能看到一个现象不同被试间的指标方差比模型结构差异带来的影响还大。某个被试测试时准确率能到 85%另一个被试只有 55%。这个差距不是模型玄学是脑电信号本身的个体差异——不同被试看同一段视频诱发的脑电模式在幅度、相位上都不一致。应对方案是在训练前统一做通道级 z-score窗口切完后每个通道单独标准化能缓解一部分个体幅值差异。如果还想再进一步可以保留每个被试的前几个 trial 做校准集在校准集上做简单的域自适应但那是后话了。跑完 LOSO 之后我会把所有被试的指标按准确率排序打印出来刻意观察最差的那几个被试分布在哪些通道上有异常甚至把它们的原始波形画出来人工排查。从那以后我每次做跨被试实验都强制在看均值之前先看方差一组指标如果标准差超过 8 个百分点均值参考价值就有限。希望这套流程和避坑记录能帮你在 DEAP 和 MAHNOB 上少走几个来回把时间花在真正有用的模型改进上。本文还有配套的精品资源点击获取