ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CNN+Transformer运动想象脑电分类实战:从预处理到跨被试调参

CNN+Transformer运动想象脑电分类实战:从预处理到跨被试调参 简介这份毕业设计资源面向电子信息、生物医学工程及人工智能方向的本科生与研究生聚焦运动想象脑电信号分类任务采用CNNTransformer混合框架CNN负责提取局部时空特征Transformer捕捉全局依赖关系并创新性地引入Grad-CAM对脑电地形图进行可视化帮助理解模型关注区域。资源包共31个文件约16.75MB包含23个Python脚本涵盖数据预处理、模型定义、训练与可视化流程、2个MATLAB脚本用于数据转换与预处理、2个Excel统计表、1个PyTorch权重文件及npy数据文件等结构完整便于复现与二次开发。目前已有442人学习下载。读者可获得从数据加载、四分类数据构造、K折训练到t-SNE降维、AUC曲线、箱线图及脑电热力图生成的完整代码链路适合作为毕业设计参考或脑机接口入门实践素材。1. 从一段 4 秒的脑电说起为什么 CNNTransformer 成了运动想象分类的新默认运动想象脑电信号分类说白了就是让人在脑子里“想”左手或右手动作机器从头皮电极读到的微弱电压里把意图猜出来。这件事的难点不在模型深不深而在信号本身单次试验只有几秒信噪比低到肉眼几乎看不出差别不同人、不同天、不同电极贴法都会让分布漂移。过去十年CSPLDA 这类传统方法一直是基线后来 EEGNet、DeepConvNet 把 CNN 带进来靠局部卷积抓 μ 节律和 β 节律的 ERD/ERS 模式效果稳定但感受野有限长程通道间的依赖关系抓不住。Transformer 的自注意力恰好补这一块——它能把 C3、C4、FCz 这些空间上离得远的电极在时间维度上做全局关联。所以现在做毕业设计CNNTransformer 混合框架几乎成了默认选项CNN 当局部特征提取器Transformer 当跨通道跨时间的融合器。这篇笔记面向的是要动手复现、要调参、要写论文的本科和硕士同学也适合刚转进 BCI 方向的工程师。我会把数据预处理、模型搭建、训练策略、避坑点按能跑通的顺序讲一遍参数给到具体数值坑按我踩过的写。2. 数据准备与预处理从原始 EEG 到模型能吃的张量2.1 选哪个数据集以及为什么多数人从 BCI Competition IV 2a 起步公开的运动想象数据集里BCI Competition IV Dataset 2a 是毕业设计用得最多的一个。它包含 9 名被试每人 2 个 session每个 session 576 次试验四类任务左手、右手、双脚、舌头。采样率 250 Hz22 个 EEG 通道加 3 个 EOG 通道。选它的理由很实际被试数量够做跨被试验证通道数适中不会让显存爆炸标签干净社区里可对比的基线多。另一个常见的是 2b 数据集只有左右手二分类3 个通道适合先跑通流程再换数据。我一般建议先用 2b 把 pipeline 跑通再上 2a 做正式实验这样调试成本低很多。数据下载后是 .gdf 或 .mat 格式需要自己切分。每个 trial 的时间结构是0 到 2 秒提示准备2 到 6 秒执行运动想象6 秒后休息。真正有判别信息的是 2 到 6 秒这段但开头 0.5 秒往往有视觉诱发的瞬态通常截取 2.5 到 6 秒也就是 3.5 秒窗口875 个采样点。2.2 滤波、重参考与伪迹处理的具体参数预处理每一步都会影响最终精度这里给一套我反复验证过的参数组合。带通滤波用 8 到 30 Hz这个范围覆盖 μ 节律8-13 Hz和 β 节律13-30 Hz运动想象的 ERD 主要发生在这两个频段。用零相位 FIR 滤波器阶数设 4 倍采样率左右避免相位失真。陷波滤波 50 Hz 去工频如果采集环境好可以省掉但国内实验室基本都要加。重参考用 CARCommon Average Reference也就是每个时间点减去所有 EEG 通道的均值。这一步能压掉共模噪声对后续空间特征提取帮助明显。伪迹处理上EOG 通道用来做回归去眼电或者直接用 ICA 剔除眼动成分。如果嫌麻烦用幅值阈值做 trial 拒绝也行任一通道峰峰值超过 100 μV 就丢掉该 trial。2a 数据集里通常丢 5% 到 15% 的 trial丢太多要检查电极阻抗。import numpy as np from scipy.signal import butter, filtfilt, iirnotch def preprocess_eeg(raw_data, fs250, low8, high30, notch_freq50): raw_data: shape (n_channels, n_times)单位 μV fs: 采样率 返回滤波和重参考后的数据 # 工频陷波 b_notch, a_notch iirnotch(notch_freq, Q30, fsfs) data filtfilt(b_notch, a_notch, raw_data, axis-1) # 带通滤波零相位 b, a butter(4, [low, high], btypeband, fsfs) data filtfilt(b, a, data, axis-1) # CAR 重参考只对 EEG 通道做EOG 不参与 eeg_data data[:22, :] eeg_data eeg_data - np.mean(eeg_data, axis0, keepdimsTrue) return eeg_data这段代码里butter(4, ...)的 4 是滤波器阶数配合filtfilt做前后向滤波等效阶数翻倍但相位为零。iirnotch的 Q 值设 30Q 越高陷波越窄避免误伤 50 Hz 附近的 β 节律。CAR 那一步只对前 22 个 EEG 通道做EOG 通道保留原样供后续伪迹检测用。注意filtfilt要求数据长度至少是滤波器阶数的 3 倍3.5 秒窗口 875 点完全够。2.3 切分、归一化与数据增强的落地写法切分按 trial 做每个 trial 取 2.5 到 6 秒得到 (22, 875) 的矩阵。归一化有两种做法全局 z-score 和逐 trial z-score。逐 trial 归一化能消除被试间的幅值差异跨被试实验里更稳但会损失绝对幅值信息。我一般用逐 trial z-score公式是 (x - mean) / std按通道独立算。数据增强在脑电里很关键因为样本量小。常用的有三种加高斯噪声SNR 设 10 到 20 dB、时间裁剪随机截取 3 秒、通道 dropout随机置零 1 到 2 个通道。滑动窗口也是常用手段把 3.5 秒切成多个 2 秒窗步长 0.5 秒样本量能翻几倍。但要注意同一 trial 切出的窗口不能跨训练集和验证集否则数据泄漏验证精度会虚高这是血泪经验。def slice_trials(data, labels, start_sec2.5, end_sec6.0, fs250): 按时间窗切分每个 trial start int(start_sec * fs) end int(end_sec * fs) X data[:, :, start:end] # (n_trials, n_channels, n_times) y labels.copy() return X, y def zscore_per_trial(X): 逐 trial 逐通道 z-score mean X.mean(axis-1, keepdimsTrue) std X.std(axis-1, keepdimsTrue) 1e-8 return (X - mean) / std def augment_gaussian(X, y, snr_db15): 加高斯噪声做增强 signal_power np.mean(X ** 2) noise_power signal_power / (10 ** (snr_db / 10)) noise np.random.randn(*X.shape) * np.sqrt(noise_power) return X noise, yslice_trials里 start 和 end 按秒换算成采样点2a 数据集用 2.5 到 6 秒是经验值太早会混入视觉诱发电位太晚样本太短。zscore_per_trial的1e-8防止除零。augment_gaussian里 SNR 设 15 dB 是折中太低噪声盖过信号太高等于没加。增强只在训练集做验证和测试集保持原始分布。3. CNNTransformer 模型搭建局部特征与全局注意力的拼接方式3.1 为什么不是纯 Transformer也不是纯 CNN纯 Transformer 直接吃原始脑电patch embedding 之后自注意力能建模全局但脑电的局部节律模式比如 C3 通道的 μ 节律抑制需要卷积核去抓纯注意力在小样本上容易过拟合。纯 CNN 反过来感受野受限跨通道的长程依赖要靠堆层数硬扩参数效率低。混合框架的分工很清晰CNN 前端做通道内和通道间的局部卷积把 (22, 875) 压成 (n_channels_feat, n_time_feat) 的特征图Transformer 后端在这个特征序列上做自注意力建模任意两个时间-通道位置的关系。这样既保留了局部节律的归纳偏置又拿到了全局融合能力。具体到结构我用的方案是时间卷积 空间卷积 Transformer 编码器 分类头。时间卷积核沿时间轴滑动每个通道独立抓的是节律波形空间卷积核跨通道抓的是空间分布模式类似 CSP 但可学习。这两步之后特征图维度降到 (F, T)F 是空间滤波器个数T 是降采样后的时间点数。然后展平成序列送进 Transformer。3.2 时间卷积与空间卷积的核大小、通道数怎么定时间卷积的核大小设 25对应 100 ms250 Hz 下这个尺度能覆盖一个 μ 节律周期。通道数从 1 扩到 8 或 16我一般用 8再多参数增长快收益不明显。空间卷积核大小设 (22, 1)也就是把全部 22 个通道一次卷完输出 F 个空间滤波器。这一步等价于可学习的 CSPF 设 16 比较稳。之后接一个平均池化时间轴降 4 倍875 点变 218 点序列长度可控。import torch import torch.nn as nn class CNNFrontend(nn.Module): def __init__(self, n_channels22, n_times875, F18, F216, kernel_time25, pool4): super().__init__() # 时间卷积每个通道独立输入 (B, 1, C, T) self.temporal nn.Sequential( nn.Conv2d(1, F1, (1, kernel_time), padding(0, kernel_time // 2), biasFalse), nn.BatchNorm2d(F1), nn.ELU() ) # 空间卷积跨全部通道 self.spatial nn.Sequential( nn.Conv2d(F1, F2, (n_channels, 1), biasFalse), nn.BatchNorm2d(F2), nn.ELU(), nn.AvgPool2d((1, pool)) ) def forward(self, x): # x: (B, 1, C, T) x self.temporal(x) # (B, F1, C, T) x self.spatial(x) # (B, F2, 1, T//pool) x x.squeeze(2) # (B, F2, T//pool) return xtemporal里padding(0, kernel_time//2)保证时间维度不变biasFalse因为后面跟 BatchNorm偏置会被吸收。spatial的卷积核是(n_channels, 1)把通道维压成 1输出(B, F2, 1, T)再池化降时间。ELU比 ReLU 在脑电上略稳负半轴有输出梯度更平滑。池化倍数 4 是折中太大丢时间分辨率太小序列长注意力计算贵。3.3 Transformer 编码器的维度、头数与位置编码选择CNN 前端输出 (B, F2, T)T 是池化后的时间点数。送进 Transformer 前要转置成 (B, T, F2)把时间当序列维F2 当特征维。位置编码用可学习的位置嵌入比正弦编码在脑电上更灵活因为脑电的时间结构不是严格周期性的。编码器层数设 2 到 4我一般用 2头数 4 或 8特征维 F216 时头数设 4每头 4 维。前馈网络隐藏维设 64是特征维的 4 倍。Dropout 设 0.3 到 0.5脑电小样本必须强正则。class TransformerEncoder(nn.Module): def __init__(self, feat_dim16, n_heads4, n_layers2, ff_dim64, dropout0.4, max_len256): super().__init__() self.pos_embed nn.Parameter(torch.randn(1, max_len, feat_dim) * 0.02) encoder_layer nn.TransformerEncoderLayer( d_modelfeat_dim, nheadn_heads, dim_feedforwardff_dim, dropoutdropout, activationgelu, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersn_layers) self.norm nn.LayerNorm(feat_dim) def forward(self, x): # x: (B, T, feat_dim) x x self.pos_embed[:, :x.size(1), :] x self.encoder(x) x self.norm(x) return xpos_embed用randn * 0.02初始化标准差小避免一开始就盖过内容特征。batch_firstTrue让输入输出都是 (B, T, D)省得来回转置。activationgelu比 ReLU 在 Transformer 里更常见梯度更平滑。max_len256是预留实际 T 约 218够用。分类头接在编码器输出上做时间维平均池化再过一个全连接比取 [CLS] token 更稳因为脑电没有明确的全局 token 语义。3.4 分类头与损失函数为什么用标签平滑和类别权重分类头是 LayerNorm 平均池化 Linear。平均池化把 (B, T, D) 压成 (B, D)再线性映射到类别数。损失函数用交叉熵加标签平滑平滑系数 0.1。脑电标签虽然干净但 trial 边界模糊硬标签会让模型过度自信。类别不均衡在 2a 里不严重四类各 144 次但如果做二分类且某类 trial 被伪迹剔除多了要加类别权重。class MI_CNNTransformer(nn.Module): def __init__(self, n_classes4, n_channels22, n_times875): super().__init__() self.cnn CNNFrontend(n_channels, n_times) self.transformer TransformerEncoder(feat_dim16, n_heads4, n_layers2) self.head nn.Sequential( nn.LayerNorm(16), nn.Linear(16, n_classes) ) def forward(self, x): # x: (B, 1, C, T) x self.cnn(x) # (B, F2, T) x x.permute(0, 2, 1) # (B, T, F2) x self.transformer(x) # (B, T, F2) x x.mean(dim1) # (B, F2) return self.head(x) # 损失 criterion nn.CrossEntropyLoss(label_smoothing0.1)permute(0, 2, 1)把通道维换到最后一维符合 Transformer 输入格式。x.mean(dim1)对时间平均比 max 池化稳因为脑电判别信息分布在整段窗口而非某一瞬间。label_smoothing0.1是经验值再大欠拟合再小正则不够。4. 训练策略与调参让模型在 9 个被试上稳定收敛4.1 优化器、学习率与 batch size 的组合优化器用 AdamW权重衰减 0.01比 Adam 的 L2 正则更干净。学习率设 1e-3配合余弦退火最低降到 1e-5。batch size 设 32 或 64脑电 trial 数少batch 太大梯度噪声小反而容易陷局部。训练轮数 200 到 300早停 patience 设 30监控验证集 kappa 而不是准确率因为类别不均衡时准确率会骗人。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model MI_CNNTransformer(n_classes4) optimizer AdamW(model.parameters(), lr1e-3, weight_decay0.01) scheduler CosineAnnealingLR(optimizer, T_max200, eta_min1e-5) for epoch in range(300): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() # 梯度裁剪防脑电尖峰导致梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() # 验证与早停逻辑略clip_grad_norm_的 max_norm 设 1.0脑电里偶发的高幅伪迹会让梯度突然变大不裁剪容易训练发散。CosineAnnealingLR的T_max和总轮数对齐eta_min留 1e-5 保底。验证时用 kappa 计算公式是 (Po - Pe) / (1 - Pe)Po 是准确率Pe 是随机猜测的期望准确率四分类时 Pe0.25。4.2 跨被试验证怎么切以及被试内验证的差距毕业设计里两种验证都要做。被试内验证是同一被试的 trial 随机分训练测试精度通常能到 75% 到 85%但这是乐观估计。跨被试验证是留一被试法LOSO8 个被试训练1 个测试轮换 9 次取平均精度掉到 55% 到 65% 是常态。差距这么大是因为脑电的个体差异极强电极位置、头型、皮层折叠都不同。论文里如果只报被试内精度审稿人会质疑泛化性所以两个都要报并说明差距来源。跨被试时可以在训练集上做被试间对齐比如欧氏对齐EA先算每个被试协方差矩阵的均值白化后再对齐。这一步能把跨被试精度提 5 到 10 个百分点代码不复杂但要注意对齐参数只能从训练集估计测试集用训练集的均值否则泄漏。4.3 学习率预热与早停的实操细节Transformer 部分对学习率敏感前 10 个 epoch 用线性预热从 1e-5 升到 1e-3再接余弦退火。预热能避免训练初期注意力权重随机时梯度太大。早停监控验证 kappa连续 30 轮不升就停同时保存 kappa 最高的权重。如果训练集 loss 一直降但验证 kappa 平说明过拟合加 dropout 或减层数。如果两者都平学习率可能太小或特征没提取到检查预处理滤波范围。def warmup_lambda(epoch, warmup_epochs10): if epoch warmup_epochs: return (epoch 1) / warmup_epochs return 1.0 # 配合 LambdaLR 使用 from torch.optim.lr_scheduler import LambdaLR warmup_scheduler LambdaLR(optimizer, lr_lambdawarmup_lambda)预热和余弦退火可以串联前 10 轮用 LambdaLR之后切 CosineAnnealingLR。实际写的时候用一个 scheduler 包住更简单但分开写逻辑清楚。注意预热期间不要做早停判断前几轮 kappa 低是正常的。5. 避坑与排查那些让精度莫名掉 10 个点的细节5.1 数据泄漏滑动窗口切分最容易翻车的地方现象是验证集精度高得离谱比如被试内到 95%但测试集一跑就崩。原因通常是滑动窗口切分时同一 trial 的不同窗口被分到了训练集和验证集模型记住了这个 trial 的噪声模式。解决是按 trial 划分训练验证测试再在各自集合内做窗口切分。另一个隐蔽泄漏是归一化参数从全量数据估计正确做法是只用训练集算 mean 和 std应用到验证测试集。5.2 滤波参数与模型感受野不匹配现象是模型学不动loss 降得很慢精度卡在随机水平附近。原因是带通滤波范围太窄或太宽。8 到 30 Hz 是运动想象的标准范围但如果滤到 4 到 40 Hz会混入 δ 和 γ 频段噪声CNN 的时间卷积核抓不到有效节律。反过来滤到 10 到 14 Hz 只剩 μ 节律信息量不够。检查方法是画滤波后的功率谱确认 8 到 30 Hz 内有明显能量且工频被压掉。5.3 位置编码长度不够导致静默截断现象是训练时序列长度对不上或者模型对靠后的时间点完全不敏感。原因是pos_embed的max_len设小了比如设 128 但实际 T 是 218切片时后面的位置编码被截掉模型看到的位置信息不完整。解决是把max_len设成大于实际 T 的值或者用插值动态生成位置编码。这个坑很隐蔽因为 PyTorch 不会报错只是静默切片。5.4 跨被试实验忘了做被试间对齐现象是 LOSO 精度只有 50% 出头接近随机。原因是不同被试的脑电幅值和协方差差异大模型在训练集上学到的空间滤波器到测试被试上失效。解决是加欧氏对齐或批量归一化层把每个被试的特征分布拉到同一尺度。注意对齐参数只能从训练被试估计测试被试直接用不能反过来。5.5 早停监控指标选错导致选了过拟合模型现象是最终测试精度比验证低很多但训练过程看起来正常。原因是早停监控的是验证 loss 或准确率而脑电里 loss 和 kappa 的走势可能不一致loss 最低的点未必 kappa 最高。解决是监控验证 kappa保存 kappa 最高的权重。另外验证集要足够大至少占总 trial 的 20%否则 kappa 波动大早停点随机。6. 进阶技巧把跨被试 kappa 从 0.55 推到 0.65 的几个动作先说一个验证方法不管加什么技巧都用 LOSO 跑一遍报平均 kappa 和标准差。标准差比均值更能说明方法稳不稳如果某个被试 kappa 特别低单独看他的数据往往是伪迹多或电极阻抗高。我一般会画每个被试的混淆矩阵看是哪两类容易混运动想象里左手和右手混得多双脚和舌头混得多这是生理上就难分的。第一个动作是欧氏对齐前面提过实现上就是算每个被试的协方差均值做白化矩阵再对齐到全局均值。代码大概十行但要注意用 scipy 的sqrtm算矩阵平方根数值不稳时加正则项。第二个动作是域对抗训练在 Transformer 输出后接一个梯度反转层和域分类器让特征在被试间不可分。这个训练要调域损失的权重设 0.1 到 0.5太大主任务学不好。第三个动作是测试时自适应用测试被试的无标签数据更新 BatchNorm 的 running mean 和 var只更新这两项不动权重几行代码就能提 2 到 3 个点。def euclidean_alignment(X, eps1e-6): X: (n_trials, n_channels, n_times) 返回对齐后的数据 # 算每个 trial 的协方差均值 covs np.array([np.cov(trial) for trial in X]) mean_cov covs.mean(axis0) # 白化矩阵 eigvals, eigvecs np.linalg.eigh(mean_cov) eigvals np.maximum(eigvals, eps) whiten eigvecs np.diag(1.0 / np.sqrt(eigvals)) eigvecs.T # 对齐 X_aligned np.array([whiten trial for trial in X]) return X_alignednp.linalg.eigh用于对称协方差矩阵比eig稳。eps防止特征值太小导致数值爆炸。对齐后每个 trial 的协方差接近单位阵被试间分布拉齐。注意这个函数要在训练集上算mean_cov测试集用训练集的whiten矩阵不能各自算各自的。最后一个技巧是模型集成训 5 个不同随机种子的模型推理时对 softmax 输出平均。单模型 kappa 0.60集成后能到 0.65 左右代价是推理时间翻 5 倍但毕业设计不追求实时的话完全值得。集成时注意每个模型用相同的预处理和对齐参数只变随机种子和初始化。我自己的习惯是每改一个变量只跑一次 LOSO记录 kappa 和标准差不一次改多个否则出了问题不知道是哪个引起的。这个习惯帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进