资讯动态

DEAP脑电CNN-LSTM情感识别:数据管线、评估协议与调参避坑

发布时间:2026/11/6 5:32:31 来源:尧图企业网站定制
简介围绕 DEAP 脑电数据集情感识别任务整理的 CNN-LSTM 代码包面向已具备 Python 与深度学习基础、希望把神经网络理论落到生物信号项目中的高校学生和算法实践者。包内以 6 个 Jupyter Notebook 和 3 个 Python 脚本串联数据清洗、带通滤波、时频域特征提取、模型搭建、训练与测试流程另附 15 张 png 结果图、h5 与 npy 模型权重文件以及 xml 工程配置共 35 个文件压缩后约 11.86MB目录按数据、代码、输出结果分区便于逐模块查阅。实现上除基础 LSTM 外还对比了一维卷积结合 LSTM、GRU、SimpleRNN 的时空特征建模方案并保留准确率、损失和混淆矩阵等可视化结果可帮助读者理解脑电情绪分类的预处理细节、网络结构选择与实验复盘方法适合希望快速跑通从原始信号到分类结果完整链路的实践者。已有 2523 人学习下载可作为课程设计、毕业设计或脑电信号入门复现的参考底稿。1. 先把这堆文件跑明白DEAP 脑电信号识别 CNN-LSTM 到底在做什么拿到一份 DEAP 脑电信号识别 CNN-LSTM 代码很多人的第一反应是直接python train.py然后盯着终端里那个在 50% 上下反复横跳的准确率发呆怀疑是不是模型写错了。其实这个项目要做的事情非常清楚把公开的 DEAP 情感脑电数据集读进来截掉开头基线、做带通滤波、按固定长度滑窗切段用一维卷积把每段信号里的局部波形特征压出来再交给 LSTM 去建模片段内部的长程依赖最后输出效价valence或唤醒度arousal的二分类结果。它面向两类人一类是拿深度学习当工具、需要一条能跑通的情感识别基线来对比自己方法的同学另一类是已经有一批自采脑电数据、想直接复用这套数据管线和模型骨架做二次开发的工程侧开发者。整条链路从读文件到出混淆矩阵大概两百来行核心代码不算长但每一步的取舍都会实打实地反映在最后那几个百分点上。2. 数据管线拆解32 通道原始信号怎么变成模型能吃进去的张量很多人低估了这一层。觉得数据加载无非pickle.load加个 transpose真正动手才发现 DEAP 的坑几乎全在管线里文件是 Python2 时代存的、通道顺序和外周信号混在一起、每个试次前面还挂着 3 秒基线、切完窗之后样本数从 40 条变成 1160 条内存和时间开销全变了。所以这一章先把数据这一侧捋顺模型反而是后面的事。2.1 先看清数据形状40 个文件、40 个试次、8064 个采样点DEAP 的组织方式是按被试存放每个被试一个.dat文件全部是序列化后的字典。字典里两个关键键data和labels。data的形状是(40, 40, 8064)三个维度依次是试次数、通道数、采样点数labels的形状是(40, 4)四列分别对应 valence、arousal、dominance、liking 的 1 到 9 分打分。8064 这个数字值得单独说一句因为它是后面所有切窗代码的基数采样率 128 Hz每个试次播放 60 秒音乐视频加上开头 3 秒静息基线(60 3) × 128 8064。这 3 秒基线在正式分析里一般要裁掉一是被试此时还没进入情绪状态二是部分试次的基线段存在明显的电极漂移。40 个通道的构成也要记牢前 32 个是 EEG 电极通道后 8 个是外周生理信号眼电、肌电、皮肤电、血容量脉搏、呼吸、体温之类。CNN-LSTM 做脑电识别通常只取前 32 个把后 8 个丢掉——它们采样特性不同、频段也不一样硬塞进同一个卷积核里反而会稀释特征。import pickle import numpy as np def load_subject(path): with open(path, rb) as f: # DEAP 是 Python2 时代序列化的不指定 latin1 会直接抛 UnicodeDecodeError obj pickle.load(f, encodinglatin1) data obj[data] # (40, 40, 8064) float64 labels obj[labels] # (40, 4) 打分 1~9 eeg data[:, :32, :] # 只保留前 32 个 EEG 通道丢掉外周生理信号 return eeg.astype(np.float32), labels.astype(np.float32)这段加载函数有两个地方不能省。encodinglatin1是最经典的翻车点报错信息长得像编码问题实际上跟你的文件路径毫无关系就是序列化协议的版本差异。astype(np.float32)则是内存考量原始文件是 float6432 个被试全量读进来再切窗float64 会白白多占一倍内存在显存有限的机器上这一步能把 batch size 卡死。2.2 目录结构一份可维护的代码包一般怎么分层代码包的分层决定了你后面改起来顺不顺手。常见的组织方式是把数据、模型、训练、配置四块拆开而不是全塞进一个main.pydeap-cnn-lstm/ ├── configs/ │ └── base.yaml # 采样率、窗长、通道数、学习率等超参 ├── data/ │ ├── raw/ # s01.dat ... s32.dat 原始文件 │ └── processed/ # 切窗后的 .npy 缓存 ├── datasets/ │ └── deap_dataset.py # Dataset 预处理 标签二值化 ├── models/ │ └── cnn_lstm.py # 模型定义 ├── utils/ │ ├── metrics.py # 平衡准确率、F1、混淆矩阵 │ └── seed.py # 固定随机种子 ├── train.py # 单被试训练入口 └── evaluate.py # 跨被试评估入口这个结构最大的好处是「缓存」和「实验」解耦。预处理一旦跑通切好的窗口存成processed/s01_win4_step2.npy后面调模型结构、调学习率时直接读缓存不用每次重跑滤波。滤波这一步在 32 个被试上跑一遍大概几十秒单看不长但你一天调二十次参就是十几分钟的纯等待血泪经验告诉我这个缓存一定要做而且缓存文件名里必须带上窗长和步长参数不然改了参数还在读旧缓存结果对不上还查不出来。2.3 预处理三步带通滤波、去基线、滑窗分段滤波是整条管线里最影响结果的一步。脑电有用的成分主要集中在 1 到 45 Hz50 Hz 工频和 0.5 Hz 以下的慢漂移都是干扰。常见做法是用 4 阶 Butterworth 带通并且用二阶节SOS形式做零相位滤波from scipy.signal import butter, sosfiltfilt def bandpass(eeg, fs128, low1.0, high45.0, order4): # 用 SOS 而不是 b/a 系数高阶滤波器用 b/a 在 float32 下容易数值不稳定 sos butter(order, [low, high], btypebandpass, fsfs, outputsos) return sosfiltfilt(sos, eeg, axis-1) # 零相位不引入群延迟sosfiltfilt是前向加后向各滤一遍相位不失真代价是边界处会有轻微畸变所以后面切窗时要把首尾各丢掉一小段。low不建议压到 0.1 Hz阶数一高就有振铃high设 45 Hz 是因为 128 Hz 采样下奈奎斯特频率才 64 Hz留一点过渡带比卡在 50 Hz 稳当。第二步去基线直接按索引裁掉前 3 秒def drop_baseline(eeg, fs128, baseline_sec3): return eeg[..., int(baseline_sec * fs):] # (40, 32, 7680)第三步滑窗。这一步决定了样本量也埋着整篇文章最大的一个坑第 5 章会细说。设窗长 4 秒、步长 2 秒60 秒有效信号能切出 29 个窗def windowize(eeg, fs128, win_sec4, step_sec2): w, s int(win_sec * fs), int(step_sec * fs) n_trial, n_ch, n_t eeg.shape segs [] for i in range(n_trial): for start in range(0, n_t - w 1, s): segs.append(eeg[i, :, start:start w]) return np.stack(segs).astype(np.float32) # (n_trial*29, 32, 512)窗长的选择是个权衡4 秒、512 个采样点LSTM 序列长度经过两次池化后变成 128既不会太短导致时序信息不足也不会太长让 LSTM 训练变慢。有论文用 1 秒窗样本量翻四倍但单个窗口里能覆盖的脑电节律周期太少alpha 波一个周期就要 0.1 秒1 秒里只有 10 个周期特征稳定性明显下降。我一般从 4 秒起步如果你做的是被试内任务、数据量本来就少可以降到 2 秒换样本量。2.4 标签二值化阈值口径必须从一而终DEAP 的标签是 1 到 9 的主观打分做分类必须先二值化。最通用的口径是以 5 为界大于等于 5 记为正类def binarize(labels, col0, threshold5.0): # col0 取 valencecol1 取 arousal y (labels[:, col] threshold).astype(np.int64) return y这里有个容易被忽略的细节DEAP 的打分实际取值范围是 1 到 9 的整数所以「大于等于 5」和「大于 5」会得到完全不同的类别分布后者会丢掉所有打 5 分的样本。更要命的是不同人写的代码包里这个阈值口径可能不一样你在 A 脚本里二值化一次、在 B 脚本里又二值化一次标签就彻底乱了。我的习惯是把二值化集中到一个函数里阈值写进配置文件全流程只调用它一次切完窗的标签和窗口一一对应地存下来后面谁都不许再动。还有一点二值化之后的类别分布通常并不均衡valence 上正类可能占到六成以上。这直接影响你后面选什么指标第 4 章会展开。3. 模型结构拆解一个样本从 Conv1d 到 LSTM 的完整流向数据管线跑通之后模型本身反倒是最清晰的部分。CNN-LSTM 这类结构在脑电上已经用了很多年思路是把「局部波形特征提取」和「片段内时序演化」拆给两种网络分别干。真正需要想清楚的是卷积到底沿哪个维度卷、池化池几次、LSTM 输出取哪一步。3.1 为什么是 CNN 打头、LSTM 收尾纯 CNN 在脑电上能拿到不差的成绩因为它擅长抓局部形态比如某个 200 毫秒窗口内的纺锤波形状。但情绪状态是个随时间缓慢演化的过程一个 4 秒窗口内部前 2 秒和后 2 秒的特征分布可能就不一样CNN 的池化会把这个顺序信息抹平。纯 LSTM 反过来它有时序建模能力但把 32 通道 × 512 点的原始信号直接按时间步喂进去序列长度 512、每步 32 维参数量和训练难度都会上去而且它不擅长从原始波形里提局部特征。所以最常见的组合是前面用两层一维卷积在时间轴上做局部特征提取并降采样把 512 个点压到 128 个时间步每个时间步是一个 128 维的特征向量后面用一层 LSTM 在这 128 步上扫一遍取最后一步的隐状态做分类。这样 LSTM 面对的序列长度降到了原来的四分之一既保留了时序信息又不至于训不动。3.2 卷积前端在电极维和时间维之间选一个这里有个容易走岔的地方。nn.Conv1d的输入是(batch, channels, length)很多人一看「32 通道」就想把通道数当成卷积的输入通道然后沿时间轴卷——这个理解是对的也是主流做法。但还有另一种思路是把时间当成通道、把电极当成序列长度让卷积去学电极之间的空间关系。两种都能跑差别在于你更想抓时间局部性还是空间局部性。主流代码包普遍选前者in_channels32对应电极卷积核沿时间轴滑动kernel_size7意味着一次看 7 个采样点约 55 毫秒这个尺度大致覆盖一个短促的脑电瞬态。两层卷积之后通道数从 32 涨到 64 再涨到 128时间长度从 512 降到 256 再降到 128。用 ELU 而不是 ReLU是因为脑电信号本身有正负对称性ELU 在负半轴有饱和区实测收敛更稳一些。提示如果你的电极数量很少比如自己采集只有 8 导把第一层卷积的kernel_size调小到 3 或 5并且适当降低conv_ch否则参数量相对样本量会严重过剩。3.3 LSTM 后端与分类头卷积输出(B, 128, 128)要喂给 LSTM 得先转置成(B, 128, 128)——注意这里两个维度数值恰好一样很容易把转置写漏还不报错因为形状对得上。所以代码里务必用transpose(1, 2)而不是靠数值巧合蒙混过关最好在注释里把每一步的形状写清楚。LSTM 用单层单向、hidden_size64就够。双向在离线分析里能用但会引入「看到未来」的问题如果你后面要做实时推理双向结构直接就不能用。输出取最后一步的隐状态out[:, -1, :]而不是对全序列做平均池化——平均池化会丢掉「情绪在窗口末尾趋于稳定」这个先验取最后一步更符合直觉。分类头就是一个Linear(64, 2)前面接 dropout。3.4 完整模型代码与张量形状对照import torch import torch.nn as nn class EEGCNNLSTM(nn.Module): def __init__(self, n_ch32, n_cls2, conv_ch64, hidden64, dropout0.3): super().__init__() # 第一层卷积沿时间轴看 7 个采样点32 电极 - 64 特征图 self.conv1 nn.Conv1d(n_ch, conv_ch, kernel_size7, stride1, padding3) self.bn1 nn.BatchNorm1d(conv_ch) self.pool1 nn.MaxPool1d(kernel_size2, stride2) # 第二层卷积感受野更大64 - 128 特征图 self.conv2 nn.Conv1d(conv_ch, conv_ch * 2, kernel_size5, padding2) self.bn2 nn.BatchNorm1d(conv_ch * 2) self.pool2 nn.MaxPool1d(kernel_size2, stride2) self.act nn.ELU() self.drop nn.Dropout(dropout) # LSTM 吃的是 (B, T, C) 序列输入维度等于卷积输出通道数 self.lstm nn.LSTM(input_sizeconv_ch * 2, hidden_sizehidden, num_layers1, batch_firstTrue, bidirectionalFalse) self.fc nn.Linear(hidden, n_cls) def forward(self, x): # x: (B, 32, 512) —— batch、电极、采样点 x self.act(self.bn1(self.conv1(x))) # (B, 64, 512) x self.pool1(x) # (B, 64, 256) x self.act(self.bn2(self.conv2(x))) # (B, 128, 256) x self.pool2(x) # (B, 128, 128) x self.drop(x) x x.transpose(1, 2) # (B, 128, 128) 时间步在前 out, _ self.lstm(x) # out: (B, 128, 64) feat out[:, -1, :] # 只取最后一个时间步 return self.fc(self.drop(feat)) # (B, n_cls)代码后面的参数逐个交代一下这些是调参时最常动的旋钮参数默认值作用与调整建议n_ch32EEG 电极数与数据管线输出的通道数必须严格一致改了预处理要同步改conv_ch64第一层卷积输出通道样本量小时降到 32数据充足可升到 128hidden64LSTM 隐状态维度超过 128 在小数据集上基本只增参不增准确率dropout0.3被试间任务建议提到 0.5被试内 0.2~0.3 即可kernel_size7 / 5第一层对应约 55 ms 感受野电极数少于 16 时降到 3整个前向过程里形状是唯一的黑匣子。我调试时习惯在forward里临时打印一次x.shape跑通一遍再删掉。比事后对着Expected input batch_size (xx) to match target batch_size这类报错猜半天效率高得多。BatchNorm 那一层在 batch size 小于 8 的时候统计量非常不稳训练损失会剧烈震荡小 batch 场景建议换成 GroupNorm。4. 训练与评估把脚本跑出可信数字模型定义好只是开始真正拉开差距的是评估协议。同一个模型、同一份数据评估方式换个写法准确率能从 60% 跳到 85%而多出来的那 25 个点全都是假的。这一章把训练循环和两种评估协议说清楚。4.1 训练循环与损失函数训练部分没什么玄学几个固定动作交叉熵损失、Adam 优化器、梯度裁剪、学习率衰减。梯度裁剪对 LSTM 是必需品脑电信号里偶发的伪迹会让梯度瞬间放大不加裁剪很容易在第 3 个 epoch 看到 loss 直接变 NaN。import torch from sklearn.metrics import balanced_accuracy_score, f1_score def run_epoch(model, loader, criterion, device, optimizerNone): training optimizer is not None model.train() if training else model.eval() total_loss, preds, gts 0.0, [], [] with torch.set_grad_enabled(training): for xb, yb in loader: xb, yb xb.float().to(device), yb.to(device) logits model(xb) loss criterion(logits, yb) if training: optimizer.zero_grad() loss.backward() # LSTM 对梯度爆炸很敏感max_norm1.0 是常用的保守值 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() * xb.size(0) preds.append(logits.argmax(dim1).cpu()) gts.append(yb.cpu()) preds torch.cat(preds).numpy() gts torch.cat(gts).numpy() return { loss: total_loss / len(loader.dataset), bacc: balanced_accuracy_score(gts, preds), # 类别不均衡下更可信 f1: f1_score(gts, preds, averagemacro), }返回值里我把balanced_accuracy_score放在了普通 accuracy 前面这是有原因的。第 2 章提过二值化之后正类占比常常超过六成一个永远输出正类的退化模型也能拿到 60% 的普通准确率。平衡准确率是正负类召回率的平均退化模型在它面前只能拿 50%一眼就能识别出「模型其实什么都没学到」。f1_score用 macro 平均也是同一个道理避免多数类主导。4.2 两种评估协议被试内和被试间这是整篇文章最需要说清楚的一段。DEAP 上做二分类你看到的「准确率 90%」和「准确率 62%」很可能是同一份代码跑出来的区别只在评估协议。被试内subject-dependent评估每个被试的数据单独训练一个模型用同一个被试的另一部分试次做测试。因为训练和测试来自同一个人、同一批电极阻抗状态、同一天的情绪基线模型很容易学到个体特有的信号模式准确率普遍偏高valence 上做到 75% 到 85% 不稀奇。被试间subject-independent评估留一个被试完全不出现在训练集里用剩下 31 个人训练在这一个人身上测试轮流留一遍。这才是真正衡量「模型能不能泛化到新用户」的协议准确率通常会掉到 55% 到 65%个别被试甚至低于随机。from sklearn.model_selection import GroupKFold def subject_independent_split(all_trials, all_labels, all_groups): # all_groups 存的是每条窗口属于哪个被试按被试分组切分杜绝泄漏 gkf GroupKFold(n_splits5) for tr_idx, te_idx in gkf.split(all_trials, all_labels, groupsall_groups): yield tr_idx, te_idx这里必须用GroupKFold而不是普通的KFold。分组变量是被试 ID试次 ID的联合键——只按被试分还不够因为同一个试次切出来的 29 个窗口高度相关它们必须整体落在同一侧。4.3 指标别只看 accuracy也别只看一个被试拿到被试间结果后正确做法是把 32 个被试的平衡准确率都列出来看分布而不是报一个平均数了事。DEAP 里的个体差异极大有的被试情绪诱发效果很好被试间能到 70%有的被试全程打分都在 5 附近徘徊标签本身就接近噪声模型在他身上只能到 52%。如果你只报均值很容易掩盖「模型其实只在少数几个人身上有效」这个事实。调参的时候也建议每个被试单独归一化而不是全局归一化。不同被试的电极阻抗、头皮厚度差异会让信号幅值差出几倍全局归一化等于让幅值大的被试主导了统计量。正确做法是拿训练集当前折的训练被试统计出均值和标准差再应用到测试被试上——这一步如果写成对全体数据 fit就是标准的数据泄漏。4.4 训练配置与耗时参考一份能跑起来的配置大致是这样batch size 32、Adam 初始学习率 1e-3、weight decay 1e-4、训练 60 个 epoch、学习率在第 30 和 45 个 epoch 各衰减 0.1 倍。单被试切窗后大约 1160 条样本一张消费级显卡上一个 epoch 不到 2 秒被试内十折交叉验证整体几分钟就跑完。被试间协议是 32 折轮流耗时乘以 32大概一两个小时。如果发现验证集 loss 在前 10 个 epoch 就停止下降、训练集 loss 继续往下走那基本就是过拟合了优先调 dropout 和 weight decay别急着加网络层数。反过来如果训练集 loss 都降不下去先检查标签和输入是否对齐——我遇到过标签按试次顺序存、窗口按被试顺序拼两边错位了一整个被试的情况模型表现就是稳定在随机水平怎么调参都没用。5. 常见问题排查几个让准确率原地踏步的坑这一章按现象、原因、解决三段式记录都是我在拆这类代码和复现别人结果时真实遇到过的问题顺序大致按出现频率排。5.1 准确率卡在 50% 附近loss 也几乎不降现象训练跑完训练集和验证集的平衡准确率都在 0.50 到 0.52 之间loss 从 0.693 缓慢降到 0.690 就不动了。原因最常见的是标签和样本没对齐。切窗之后样本数从 40 变成 1160如果标签还用原来的 40 长度数组去索引要么直接越界报错要么被 numpy 的广播机制悄悄错位。另一种可能是二值化阈值定在 5 但数据里某个被试的 valence 打分全部小于 5导致这个被试的标签全为 0模型学不到区分边界。解决在 Dataset 的__getitem__里断言len(self.y) len(self.x)并且在切窗函数里同步复制标签np.repeat(y_trial, n_win)让一条窗口对应一个标签。另外在训练前打印一次正负类比例如果某一类占比低于 15% 或者高于 85%先回头检查阈值口径。5.2 被试内准确率 85%换个随机种子掉到 60%现象同样的代码seed42 跑出 0.85seed2024 跑出 0.61波动大到没法写进报告。原因数据集本身太小单被试 1160 条窗口且高度相关单次划分的方差极大。再加上如果划分时按窗口随机打乱同一个试次的窗口会同时出现在训练和测试两侧模型实际上是在背诵训练样本换个种子背诵关系断了成绩就崩。解决改用分组交叉验证把同一个试次的窗口锁在同一侧所有结果报 5 折或 10 折的均值加减标准差不报单次结果。同时在utils/seed.py里把random、numpy、torch、torch.cuda四个种子全部固定并设置torch.backends.cudnn.deterministic True。5.3 训练到一半 loss 突然变成 NaN现象前几个 epoch 正常第 3 或第 4 个 epoch 之后 loss 打印出nan之后所有权重都废了。原因脑电数据里存在幅值很大的瞬态伪迹电极被碰、被试大幅动作单个样本的梯度能把 LSTM 的权重一次性推爆。另外如果学习率用 1e-2 起步在 LSTM 上也很容易炸。解决三层防护。预处理阶段对每个通道做幅值裁剪比如限制在 ±100 μV训练时加clip_grad_norm_(max_norm1.0)学习率从 1e-3 起步而不是 1e-2。如果加了这三层还炸检查一下滤波后的信号有没有残留的直流分量sosfiltfilt之后建议再做一次按通道的 z-score。5.4 全量加载 32 个被试时内存直接爆掉现象跑单被试没问题改成跨被试训练后进程被系统 kill或者报MemoryError。原因data是 float6432 个被试 × 40 试次 × 32 通道 × 8064 点 ≈ 400 MB看着不多。但切窗之后样本数变成 32 × 1160 ≈ 37000 条每条 32 × 512如果还是 float64光输入张量就要 4.8 GB再加上中间激活值内存直接见底。解决三个动作一起做。加载时立刻转float32切窗结果按被试分别存成.npy文件Dataset 里用np.load(mmap_moder)做内存映射按需读取DataLoader 的num_workers设成 4 而不是 0让数据搬运和计算并行。做完这三步同样的数据内存占用能压到 1 GB 以内。5.5 验证集准确率远高于测试集现象交叉验证时每一折的验证集都能到 75%但拿一个完全没参与训练的留出被试一测只有 55%。原因验证集和训练集来自同一个被试池个体特征被模型记住了。这种情况在「被试内切分 跨被试报告」的混搭写法里特别常见本质是评估协议和结论不匹配。解决先明确你要回答哪个问题。要做用户无关的产品就用留一被试交叉验证并且训练阶段加入被试维度的数据增强随机幅值缩放、通道随机丢弃。要做个体校准方案就保留被试内协议但必须在报告里写清楚「需要该用户的一批标注数据做校准」。两种结论不能混着说。6. 二次开发换数据、换标签、加模块的三条改造路线代码跑通之后多数人要做的不是复现而是改。这个骨架的可改造点其实很集中我按改动量从小到大排三条路。第一条最省事换标签列做回归。现在分类头输出 2 类把self.fc换成nn.Linear(hidden, 1)损失从CrossEntropyLoss换成nn.SmoothL1Loss标签直接用原始的 1 到 9 分不二值化。改动不到十行但要注意回归任务的指标完全不同别看准确率了改看 MAE 和皮尔逊相关系数。这条路适合想验证「模型输出的连续值和主观打分有没有相关性」的场景。第二条是加频域分支。时域卷积擅长抓波形形态但对节律功率不敏感而情绪和 alpha、beta 频段功率的关系在文献里被反复验证过。做法是在模型里并一条支路对输入做 FFT 或 Welch 功率谱取四个频段theta 4-8、alpha 8-13、beta 13-30、gamma 30-45的平均功率得到一个 32 × 4 的向量过两层全连接压到 32 维和 LSTM 输出的 64 维在最后一层前拼接。拼接时记得两条支路的特征先各自做一次 LayerNorm否则量纲差异会让梯度全部流向数值大的那一边。这条改造大概三四十行收益在跨被试协议上通常比时域单支路高 2 到 4 个点。class DualBranchEEG(nn.Module): def __init__(self, backbone, n_ch32, n_bands4, n_cls2): super().__init__() self.backbone backbone # 复用原来的 CNN-LSTM 主干 self.norm_t nn.LayerNorm(64) # 时域支路归一化 self.freq_fc nn.Sequential( nn.Linear(n_ch * n_bands, 64), nn.ELU(), nn.Dropout(0.3), nn.Linear(64, 32), ) self.norm_f nn.LayerNorm(32) # 频域支路归一化 self.head nn.Linear(64 32, n_cls) # 两路拼接后再分类 def forward(self, x, band_power): t_feat self.norm_t(self.backbone.extract(x)) # (B, 64) f_feat self.norm_f(self.freq_fc(band_power)) # (B, 32) return self.head(torch.cat([t_feat, f_feat], dim1))band_power在 Dataset 里预先算好形状(B, 128)别在forward里现算 FFT那样每个 epoch 都要重算一遍纯浪费。频域特征的归一化统计量同样只能从训练集算这一点和时域特征完全一致。第三条改动最大但价值也最高做被试自适应。跨被试掉点的主要原因是不同人的信号分布偏移常见做法是在训练时给每个被试学一个小的仿射变换缩放加偏移只在训练阶段生效推理时用全体被试的平均参数。这块可以和域对抗训练结合但先别一上来就做复杂的从最简单的「按被试做特征标准化 少量目标被试无标签数据做 BN 统计量适配」试起往往能免费拿到两三个点。改造完一定要做验证而且验证的方式要跟着改造目标走。加了频域分支就在同样的留一被试划分上跑一遍和原始结构逐被试对比看提升是普遍发生还是在某几个被试上暴涨——后者往往意味着那几个人身上恰好有可被频域特征利用的伪迹。改完之后我会强制自己跑一次「打乱标签」的对照实验把 y 随机 shuffle 后重训如果模型还能到 65% 以上说明管线里还有泄漏之前所有的提升都不可信。这个习惯是被坑出来的早些年我改完一个模块看着准确率涨了 8 个点兴奋了半宿最后发现是缓存文件没更新读的还是旧的特征。从那以后我每次动数据管线或模型结构都强制走一遍「删缓存、打乱标签、重跑」的三步验证麻烦是麻烦但比写完一版结论又推翻重来强。希望这套拆法和这些坑能帮你少走几段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑