
简介基于Transformer的运动想象脑电信号分类本科毕业设计项目面向本科毕业生、脑机接口或EEG信号处理方向初学者解决运动想象任务中脑电信号特征提取与分类问题并提供可直接运行的完整方案。压缩包共31个文件以Python与MATLAB脚本为主涵盖数据预处理、模型构建、训练评估和可视化模块另含Excel权重表、npy样本数据、pth预训练模型及说明文档大小18.45MB。代码采用CNNTransformer混合框架CNN负责提取局部时空特征Transformer建模全局依赖同时集成EEGNet、CSP、CAM、tSNE、脑热图与AUC曲线等方法支持k折交叉验证与准确率、F1分数等指标评估附带的README说明了目录结构、运行方式与环境依赖MATLAB脚本可完成原始EEG数据预处理和四分类数据生成与Python流程良好衔接。项目已有276人学习/下载预训练权重和示例数据有助于快速上手跳过训练直接评估效果适合毕业设计复现、课题拓展或进一步算法改进。1. 运动想象脑电遇上 Transformer先从公开数据跑出第一个准确率运动想象脑电分类这几年成了 Transformer 在时间序列上最有说服力的落地方向之一信号长、通道多、个体差异大传统 CNN 吃局部感受野LSTM 吃长程依赖但难并行而 Transformer 正好能用全局注意力把想象右手时 C3 通道在 8~12Hz 能量下降这类跨通道、跨时间的线索直接连起来。不过先泼盆冷水公开论文里 BCI Competition IV 2a 的四分类平均准确率大多落在 70%~90%个别受试者能到 90% 以上也有受试者怎么调都上不去。这个方向适合本科毕设是因为它数据公开、任务明确、有清晰的对比基线难的恰恰不是模型结构而是数据预处理和实验设计。这篇文章会把一条能完整复现的路径拆开讲从数据读取到模型搭建再到避坑和答辩用的消融验证。2. 信号到序列为什么运动想象脑电适合拿 Transformer 做分类2.1 想象运动时脑电里发生了什么ERD/ERS 与 C3/C4 通道运动想象和真实运动共享大脑皮层的感觉运动区。受试者在脑子里模拟右手握拳时左侧中央区的 C3 通道附近会出现 mu 节律8~12Hz和 beta 节律13~30Hz的能量下降这叫事件相关去同步ERD想象结束或放松时能量又会短暂上升叫事件相关同步ERS。左手对应右侧 C4双脚对应 Cz 附近舌头对应的激活模式更靠近面运动区。这套神经生理基础决定了分类特征不是某一瞬间的幅值而是一段时间内能量在特定通道、特定频段的时空分布。正因为判别信息分散在多个通道和数百毫秒的时间窗口里单纯看单点波形没有意义需要模型对通道空间和时间上下文同时建模。传统做法是人工设计 CSP公共空间模式特征再喂给分类器Transformer 的路线则希望直接从原始信号里学到这套时空关系。需要注意的是Transformer 不像 CSP 那样显式利用空间先验它靠数据自己发现 C3/C4 的差异化模式——数据量足够时这是一个优势数据量少时就容易学偏。2.2 Transformer 捕获全局时域依赖CNN 和 LSTM 缺在哪卷积网络在 EEG 分类里最成功的代表是 EEGNet 和 DeepConvNet。它们用二维卷积同时处理通道维和时间维局部感受野能抓住短时特征但要让信息覆盖整个想象窗口需要堆很多层而且池化会丢掉时序边界。LSTM 能处理变长依赖但它是顺序计算的训练效率低长序列上早期信息容易衰减。Transformer 不一样。自注意力机制让任意两个时间位置可以直接交互一步到位建立全局依赖。运动想象中 cue 出现后约 0.5~2 秒的 ERD 模式和 2~3 秒后的持续抑制状态之间存在相关性这类长程关系正是 Transformer 擅长的。另一个实际优势是训练效率自注意力在 GPU 上可以高度并行毕设阶段迭代实验速度快很多。代价是计算复杂度随序列长度平方增长所以直接把 3 秒 250Hz 的 750 个采样点全部当成 token 会非常浪费需要先做 patch 化这和 ViTVision Transformer把图像切成 patch 的思路一致。2.3 公开数据集 BCI IV 2a文件格式与读取代码目前做运动想象 Transformer 分类最常用的公开基准是 BCI Competition IV 2a。9 个受试者每人两个 session每个 session 有 288 次试次四类任务左手、右手、双脚、舌头每类 72 次22 个 EEG 通道加 3 个 EOG 通道采样率 250Hz。毕设用它有几个好处标注规范、类别均衡、论文里可对比的结果多。文件是 GDF 格式用 MNE-Python 可以直接读。import mne raw mne.io.read_raw_gdf( A01T.gdf, preloadTrue, eog[EOG-left, EOG-central, EOG-right] ) raw.pick_types(eegTrue) # 只保留 22 个 EEG 通道丢掉 3 个 EOG raw.filter(8, 30, methodfir, fir_designfirwin) events, event_id mne.events_from_annotations(raw) print(events[:5]) print(event_id)逻辑说明preloadTrue把数据一次性读到内存后续滤波和切片都基于这份数组pick_types(eegTrue)是必须做的否则 EOG 通道会和 EEG 混在一起进入模型8~30Hz 带通滤掉了低频漂移和高频肌电保留 mu/beta 节律。mne.events_from_annotations会把 GDF 里的标注转成事件数组BCI IV 2a 的标准标注里事件码 1/2/3/4 分别对应左手/右手/双脚/舌头但不同工具链解析结果可能有差异拿到数据后先用print确认事件码不要凭记忆写死。3. 把原始脑电切成模型能吃的序列预处理与 Patch 化3.1 滤波与截取8–30Hz 频段和目标窗口怎么定预处理环节决定了模型看到的是信号还是噪声。除了 8~30Hz 带通还要做两步一是把每个试次对齐到运动想象提示发生的时刻二是截取一个既能包含完整想象过程、又不混入下一段静息状态的窗口。BCI IV 2a 的范式里提示出现后受试者有约 3 秒的想象时间我一般取提示后 0 到 3 秒得到每个试次 22 通道 × 750 采样点的三维张量。epochs mne.Epochs( raw, events, event_id{left: 1, right: 2, feet: 3, tongue: 4}, tmin0, tmax3.0, baseline(None, 0), # 用 cue 前的短时均值做基线校正 pickseeg, ) data epochs.get_data() # shape: (n_trials, 22, 750) labels epochs.events[:, -1] - 1 # 事件码 1~4 转成 0~3 print(data.shape, labels.shape)逻辑说明tmin0, tmax3.0对齐在运动想象提示发生的时刻这样每个样本都是提示后 3 秒的信号段标签语义一致。baseline(None, 0)会对每个通道减去提示出现前一段时间的均值能消除一部分直流漂移。有一点要提醒有些 Transformer 实现不做 baseline 反而效果更好因为自注意力可以自己学到基线偏移是否保留建议跑一组消融再定。3.2 Patch 化与位置编码参考 ViT 把整段信号切成时间片预处理后的 750 个时间点如果直接作为 token序列长度 750自注意力的计算和显存开销都不小而且单采样点本身几乎没有语义这会拖慢收敛。参考 ViT 的做法把连续的时间窗当成一个 patch每个 patch 内部的空间信息22 通道的短时波形压成一个向量这样既降低了序列长度又给模型提供了局部时间结构。常见实现是用一维卷积完成 patch 嵌入import torch import torch.nn as nn x torch.randn(1, 22, 750) patch_embed nn.Conv1d( in_channels22, out_channels128, kernel_size50, # 每个 patch 覆盖 50 个采样点 200ms stride25, # 相邻 patch 起点间隔 25 点 100ms重叠 50% ) x_patch patch_embed(x) # (1, 128, 29) x_token x_patch.permute(0, 2, 1) # (1, 29, 128) print(x_token.shape)逻辑说明kernel_size50相当于每 200ms 一个 patchstride25让相邻 patch 有 50% 重叠边界处的时间特征不会因为硬切而丢失。750 个采样点经过这一步变成 29 个 token每个 token 是 128 维向量。参数上22 通道的信号被展平进卷积的通道维度卷积核实际上同时在时间和空间上做特征提取。out_channels就是 Transformer 的d_model这个 128 是常见起点毕设阶段可以按数据量调小到 64。Patch 化之后必须加位置编码。Transformer 的自注意力本身是排列不变的如果不告诉它 token 之间的先后关系它分不清第 3 个 patch 和第 20 个 patch 谁先谁后。EEG 的时序顺序恰恰是关键信息所以这一步不能省。3.3 训练/验证切分按试验(trial)切还是按受试者切这里有两种切分策略对应不同的实验目标。快速验证模型能不能跑通时可以在同一个 session 内按试次随机切分比如 288 个试次拿 230 个训练、58 个验证这样迭代快适合调参。但要放到论文里的正式结果必须做跨 session 验证用 A01T.gdf 的 288 条训练A01E.gdf 的 288 条测试这是 BCI 竞赛的原始评估方式最能反映模型的真实泛化能力。train_data, train_labels load_bci(A01T.gdf) # 按 3.1 的 pipeline 处理 test_data, test_labels load_bci(A01E.gdf) # 快速原型随机切分仅用于调试 split int(len(train_data) * 0.8) train_set, val_set train_data[:split], train_data[split:] # 正式实验跨 session train_set, val_set train_data, test_data我强烈建议两种都跑。随机切分能快速暴露代码 bug跨 session 结果才是答辩时真正要展示的指标。如果跨 session 结果比随机切分低很多这不一定是模型问题而可能是受试者状态在两个 session 间有漂移这是运动想象数据里的常见现象。另外注意任何标准化操作都要在训练集上计算均值和方差再应用到验证集一不小心就会造成数据泄露第 5 章会专门展开。4. 用 PyTorch 写出一个能跑的 EEG Transformer模型与训练代码4.1 PatchEmbed 与可学习位置编码把 22 通道 3 秒信号变成 token把 3.2 的思路封装成模型组件。这里用一个EEGPatchEmbed类内部包含卷积投影和可学习位置编码。位置编码初始化为小方差随机值训练中会逐步调整到适配当前任务的状态。class EEGPatchEmbed(nn.Module): def __init__(self, in_channels22, d_model128, patch_len50, stride25, T750): super().__init__() self.proj nn.Conv1d( in_channels, d_model, kernel_sizepatch_len, stridestride, ) n_patches (T - patch_len) // stride 1 self.pos_embed nn.Parameter( torch.randn(1, n_patches, d_model) * 0.02 ) def forward(self, x): # x: (B, C, T) x self.proj(x) # (B, d_model, P) x x.permute(0, 2, 1) # (B, P, d_model) return x self.pos_embed逻辑说明proj是共享权重的 patch 投影每个 patch 内部 22 通道 × 50 时间点被压缩成 128 维向量permute把维度顺序从(batch, d_model, patches)转成(batch, patches, d_model)这是 PyTorch Transformer 层默认的batch_firstTrue格式。位置编码和 token 直接相加这一步把时间顺序信息注入每个 token。如果之后发现模型对时间顺序不敏感可以尝试把位置编码换成相对位置编码但可学习绝对位置编码在这个任务上通常够用。4.2 TransformerBlock 与分类头Pre-LN、多头注意力、全局池化这里不使用nn.TransformerEncoderLayer的内置实现而是手写一个简化块原因有两个一是方便后续拿注意力权重做可视化二是用 Pre-LN 结构精度更稳定。代码里保留了 attention 矩阵的返回路径。class TransformerBlock(nn.Module): def __init__(self, d_model, nhead, ff_dim, dropout0.1): super().__init__() self.norm1 nn.LayerNorm(d_model) self.attn nn.MultiheadAttention( d_model, nhead, dropoutdropout, batch_firstTrue ) self.norm2 nn.LayerNorm(d_model) self.ffn nn.Sequential( nn.Linear(d_model, ff_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff_dim, d_model), nn.Dropout(dropout), ) def forward(self, x, return_attentionFalse): x_norm self.norm1(x) attn_out, attn_weight self.attn(x_norm, x_norm, x_norm) x x attn_out x x self.ffn(self.norm2(x)) if return_attention: return x, attn_weight return x class EEGTransformer(nn.Module): def __init__(self, in_channels22, T750, d_model128, depth3, nhead8, num_classes4, dropout0.1): super().__init__() self.patch_embed EEGPatchEmbed(in_channels, d_model, TT) self.blocks nn.ModuleList([ TransformerBlock(d_model, nhead, d_model * 4, dropout) for _ in range(depth) ]) self.head nn.Sequential( nn.LayerNorm(d_model), nn.Linear(d_model, num_classes), ) def forward(self, x, return_attentionFalse): x self.patch_embed(x) attn_weights [] for block in self.blocks: if return_attention: x, attn block(x, return_attentionTrue) attn_weights.append(attn) else: x block(x) x x.mean(dim1) # 对 29 个 token 做全局平均池化 return self.head(x)逻辑说明多头注意力里nhead8会把 128 维分成 8 组 16 维的子空间每组独立计算注意力后再拼接。Pre-LN 的写法是先归一化再进注意力或前馈网络梯度流更稳。分类头没有先接全局池化再归一化而是先归一化再映射到 4 类得分。depth3是相对保守的层数EEG 样本少模型太深几乎没有收益反而更容易过拟合。4.3 训练循环损失函数、AdamW、早停和验证指标from torch.optim import AdamW from torch.utils.data import DataLoader, TensorDataset dataset TensorDataset( torch.tensor(train_data, dtypetorch.float32), torch.tensor(train_labels, dtypetorch.long), ) loader DataLoader(dataset, batch_size32, shuffleTrue) model EEGTransformer() criterion nn.CrossEntropyLoss() optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) best_val_acc, patience_counter 0.0, 0 for epoch in range(100): model.train() for xb, yb in loader: optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() model.eval() with torch.no_grad(): val_out model(torch.tensor(val_data, dtypetorch.float32)) val_acc (val_out.argmax(1) torch.tensor(val_labels)).float().mean().item() print(fepoch {epoch:02d}, loss {loss.item():.4f}, val_acc {val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc patience_counter 0 torch.save(model.state_dict(), best_model.pt) else: patience_counter 1 if patience_counter 15: print(early stop) break逻辑说明lr1e-4在这个任务上是我常用的起点比视觉任务的 1e-3 更稳。clip_grad_norm_把梯度范数限制在 1.0能防止个别异常样本把参数顶飞。早停的 patience 设为 15 个 epoch多试几次会发现 8~15 是合理区间。weight_decay1e-4配合 dropout 一起控制过拟合。如果发现 loss 下降太慢先确认数据是否做了标准化再试着把学习率提到3e-4不要一上来就开大学习率。4.4 跑通后的第一张 Loss 曲线怎么判断模型真的在学第一个实验跑完后别急着看准确率先看训练集和验证集的 loss 曲线。理想状态是训练 loss 稳步下降验证 loss 同步下降然后进入平台期如果训练 loss 降但验证 loss 从第 5 个 epoch 就开始反弹这是过拟合的典型信号如果两条 loss 都在原地抖动大概率是学习率太大或数据没有标准化。验证准确率方面跨 session 评估下BCI IV 2a 单个受试者四分类能到 70% 以上就说明模型学到的东西有真实信号了。有些受试者就是难换谁都难不要因为一个 subject 效果差就否定整个方案。接下来要做的不是无脑调参而是把能出问题的地方逐个排查。5. 训练避坑指南EEG 小而脏五个常见翻车点5.1 数据泄露验证集准确率 95% 却没法复现现象随机切分后验证准确率非常高但跨 session 或换受试者直接掉到随机水平。原因最常见的不是模型问题而是预处理阶段不小心让验证集信息进入了训练过程比如先对全部 288 条样本做StandardScaler再切分验证集也参与计算了均值和标准差模型在验证集上看到的特征分布已经被训练信息修正过了。还有一个隐蔽来源是把 ICA 或自动伪迹剔除在全量数据上拟合。解决所有可学习的预处理参数只能在训练集上拟合后应用到验证集。标准化用sklearn.pipeline.Pipeline串起来在交叉验证里重放最省心。5.2 过拟合样本只有 288 条模型参数却上百万现象训练 loss 降到 0.1验证准确率只有 40%。原因d_model128、depth3的 Transformer 参数量已经接近 40 万BCI 单受试者可用样本只有 288 条参数容量远大于有效信息量。解决第一优先降模型容量——d_model降到 64depth降到 2往往效果不降反升第二加正则dropout 0.2~0.3、weight_decay 1e-4第三做数据增强对每个试次做小幅时间平移±8 个采样点、幅度缩放0.9~1.1和通道加性高斯噪声这些在 EEG 分类里是经过验证的增强方式。5.3 伪迹干扰眨眼和肌肉噪声比想象信号还强现象准确率在部分受试者上虚高画注意力热图发现模型在盯着前额通道看。原因眨眼会产生大幅低频偏转在 Fp1、Fp2 等前额通道非常明显咬牙、转头产生的肌电噪声覆盖 30Hz 以上频段而带通滤波只挡到 30Hz 以下。模型不需要学运动想象只要学会检测到一次眨眼就猜左脚就能在训练集上拿高分跨 session 或跨受试者就立刻翻车。解决训练前对每个试次做峰值检测超过阈值的试次剔除或单独标记更彻底的是用 ICA 去掉眼电分量但要注意 ICA 拟合必须基于训练数据。肉眼排查的方式是叠加平均 ERP 图看 C3/C4 通道是否有持续的 ERD 模式而不是只有前额瞬间噪声。5.4 类别不平衡与个体差异为什么你的模型只认识 subject A现象混淆矩阵里双脚和舌头两组来回错或者模型在 subject 1 上效果好、subject 2 上完全失效。原因BCI IV 2a 的四个类别本身是均衡的但类别间的可分性差异很大——手部想象通常比脚部更稳定舌头和脚的空间模式又比较接近而不同受试者运动想象的熟练度、注意力状态差异极大导致模型权重被个别受试者的分布主导。解决类别层面用CrossEntropyLoss(weightclass_weight)或 labelsmoothing受试者层面做正式的 subject-dependent 实验就明确报告单调试者结果同时跑一个跨受试者版本对所有受试者数据做受试者归一化减去个人静息均值观察领域漂移的幅度。答辩时与其掩盖个体差异不如把它作为研究结论写清楚。5.5 Loss 震荡不收敛学习率、初始化与梯度裁剪现象loss 曲线像锯齿每 10 个 epoch 掉一次又弹回去或者完全不动靠玄学碰运气。原因学习率偏大导致参数在最优解附近来回摆动batch size 小导致梯度噪声大。解决先固定lr1e-4把 batch size 从 32 提到 64观察变化。如果 loss 曲线一直有突发尖峰检查是否有个别试次的幅值异常高对数据做逐试次的 z-score 标准化能压住这个问题。如果真的不收敛在训练循环里加clip_grad_norm_并把模型重新初始化几次——Transformer 在小数据上的收敛对随机种子比较敏感多试 3 个种子取平均结果这也是最后论文里可以交代的实验细节。6. 从跑通到答辩高分注意力可视化与消融实验模型结构里保留了return_attentionTrue的出口就是为了把注意力权重取出来画热图。对单条测试样本让它过一遍模型收集每层编码器的注意力矩阵在 8 个头上取平均得到一个 29×29 的矩阵。第 i 行第 j 列表示第 i 个时间 patch 在计算表示时对第 j 个 patch 的关注强度。model.eval() with torch.no_grad(): xb torch.tensor(test_data[:1], dtypetorch.float32) _, attn_list model(xb, return_attentionTrue) attn_map attn_list[0].mean(dim1)[0] # (29, 29) import matplotlib.pyplot as plt plt.imshow(attn_map.numpy(), cmaphot, aspectauto) plt.xlabel(key patch index) plt.ylabel(query patch index) plt.show()如果热图里所有 patch 均匀发亮说明模型没学到选择性关注如果只盯着前额通道对应的 patch说明学的是眼电伪迹合理的模式是大部分注意力集中在想象开始后 200~800ms 的 patch 上同时存在一条清晰的对角线基线。可视化能直接佐证模型是真的在分析运动想象信号这点在毕设答辩里比准确率数字更有说服力。消融实验做四组就够去掉位置编码、depth 从 3 降到 1、patch stride 从 25 增加到 50、不滤波保留 0.5~40Hz。每组在同一个跨 session 划分下跑报告准确率和 macro-F1。结果通常能看出位置编码和滤波是最敏感的部分patch size 次之depth 影响最小。指标上记得把混淆矩阵和受试者维度列出来单受试者的 macro-F1 比整体准确率更能暴露模型在弱势类别上的问题。我现在做这类任务第一版就会把注意力返回值留好宁可后面没用到也别回头改 forward。希望帮到你。本文还有配套的精品资源点击获取