ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

简介:围绕 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, encoding='latin1') 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)

这段加载函数有两个地方不能省。encoding='latin1'是最经典的翻车点,报错信息长得像编码问题,实际上跟你的文件路径毫无关系,就是序列化协议的版本差异。astype(np.float32)则是内存考量:原始文件是 float64,32 个被试全量读进来再切窗,float64 会白白多占一倍内存,在显存有限的机器上这一步能把 batch size 卡死。

2.2 目录结构:一份可维护的代码包一般怎么分层

代码包的分层决定了你后面改起来顺不顺手。常见的组织方式是把数据、模型、训练、配置四块拆开,而不是全塞进一个main.py:

deap-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 Hz,50 Hz 工频和 0.5 Hz 以下的慢漂移都是干扰。常见做法是用 4 阶 Butterworth 带通,并且用二阶节(SOS)形式做零相位滤波:

from scipy.signal import butter, sosfiltfilt def bandpass(eeg, fs=128, low=1.0, high=45.0, order=4): # 用 SOS 而不是 b/a 系数:高阶滤波器用 b/a 在 float32 下容易数值不稳定 sos = butter(order, [low, high], btype='bandpass', fs=fs, output='sos') 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, fs=128, baseline_sec=3): return eeg[..., int(baseline_sec * fs):] # (40, 32, 7680)

第三步滑窗。这一步决定了样本量,也埋着整篇文章最大的一个坑(第 5 章会细说)。设窗长 4 秒、步长 2 秒,60 秒有效信号能切出 29 个窗:

def windowize(eeg, fs=128, win_sec=4, step_sec=2): 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, col=0, threshold=5.0): # col=0 取 valence,col=1 取 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_channels=32对应电极,卷积核沿时间轴滑动,kernel_size=7意味着一次看 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_size=64就够。双向在离线分析里能用,但会引入「看到未来」的问题,如果你后面要做实时推理,双向结构直接就不能用。输出取最后一步的隐状态out[:, -1, :],而不是对全序列做平均池化——平均池化会丢掉「情绪在窗口末尾趋于稳定」这个先验,取最后一步更符合直觉。分类头就是一个Linear(64, 2),前面接 dropout。

3.4 完整模型代码与张量形状对照

import torch import torch.nn as nn class EEGCNNLSTM(nn.Module): def __init__(self, n_ch=32, n_cls=2, conv_ch=64, hidden=64, dropout=0.3): super().__init__() # 第一层卷积:沿时间轴看 7 个采样点,32 电极 -> 64 特征图 self.conv1 = nn.Conv1d(n_ch, conv_ch, kernel_size=7, stride=1, padding=3) self.bn1 = nn.BatchNorm1d(conv_ch) self.pool1 = nn.MaxPool1d(kernel_size=2, stride=2) # 第二层卷积:感受野更大,64 -> 128 特征图 self.conv2 = nn.Conv1d(conv_ch, conv_ch * 2, kernel_size=5, padding=2) self.bn2 = nn.BatchNorm1d(conv_ch * 2) self.pool2 = nn.MaxPool1d(kernel_size=2, stride=2) self.act = nn.ELU() self.drop = nn.Dropout(dropout) # LSTM 吃的是 (B, T, C) 序列,输入维度等于卷积输出通道数 self.lstm = nn.LSTM(input_size=conv_ch * 2, hidden_size=hidden, num_layers=1, batch_first=True, bidirectional=False) 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,数据充足可升到 128
hidden64LSTM 隐状态维度,超过 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, optimizer=None): 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_norm=1.0 是常用的保守值 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * xb.size(0) preds.append(logits.argmax(dim=1).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, average='macro'), }

返回值里我把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_splits=5) for tr_idx, te_idx in gkf.split(all_trials, all_labels, groups=all_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%

现象:同样的代码,seed=42 跑出 0.85,seed=2024 跑出 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_norm=1.0);学习率从 1e-3 起步而不是 1e-2。如果加了这三层还炸,检查一下滤波后的信号有没有残留的直流分量,sosfiltfilt之后建议再做一次按通道的 z-score。

5.4 全量加载 32 个被试时内存直接爆掉

现象:跑单被试没问题,改成跨被试训练后进程被系统 kill,或者报MemoryError。

原因:data是 float64,32 个被试 × 40 试次 × 32 通道 × 8064 点 ≈ 400 MB,看着不多。但切窗之后样本数变成 32 × 1160 ≈ 37000 条,每条 32 × 512,如果还是 float64,光输入张量就要 4.8 GB,再加上中间激活值,内存直接见底。

解决:三个动作一起做。加载时立刻转float32;切窗结果按被试分别存成.npy文件,Dataset 里用np.load(mmap_mode='r')做内存映射按需读取;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_ch=32, n_bands=4, n_cls=2): 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], dim=1))

band_power在 Dataset 里预先算好,形状(B, 128),别在forward里现算 FFT,那样每个 epoch 都要重算一遍,纯浪费。频域特征的归一化统计量同样只能从训练集算,这一点和时域特征完全一致。

第三条改动最大但价值也最高:做被试自适应。跨被试掉点的主要原因是不同人的信号分布偏移,常见做法是在训练时给每个被试学一个小的仿射变换(缩放加偏移),只在训练阶段生效,推理时用全体被试的平均参数。这块可以和域对抗训练结合,但先别一上来就做复杂的,从最简单的「按被试做特征标准化 + 少量目标被试无标签数据做 BN 统计量适配」试起,往往能免费拿到两三个点。

改造完一定要做验证,而且验证的方式要跟着改造目标走。加了频域分支,就在同样的留一被试划分上跑一遍,和原始结构逐被试对比,看提升是普遍发生还是在某几个被试上暴涨——后者往往意味着那几个人身上恰好有可被频域特征利用的伪迹。改完之后我会强制自己跑一次「打乱标签」的对照实验:把 y 随机 shuffle 后重训,如果模型还能到 65% 以上,说明管线里还有泄漏,之前所有的提升都不可信。这个习惯是被坑出来的,早些年我改完一个模块看着准确率涨了 8 个点,兴奋了半宿,最后发现是缓存文件没更新,读的还是旧的特征。从那以后我每次动数据管线或模型结构,都强制走一遍「删缓存、打乱标签、重跑」的三步验证,麻烦是麻烦,但比写完一版结论又推翻重来强。希望这套拆法和这些坑能帮你少走几段弯路。

本文还有配套的精品资源,点击获取

返回列表