基于Transformer的运动想象脑电分类:CNN-Transformer框架实战
简介面向人工智能、通信工程、自动化等专业的高校学生与科研人员这是一套完整的运动想象脑电信号分类实现核心采用卷积神经网络与Transformer结合的框架覆盖信号预处理、特征提取、模型训练到结果可视化全流程可支撑毕业设计、课程设计。压缩包共三十二个文件大小约十八点五兆主体为二十三个脚本文件按功能分为模型定义、训练评估、特征提取、可视化与统计分析等模块另附两个预处理脚本、两个表格文件、一个预训练权重、一个数据文件以及设计报告和说明文档整体结构清晰便于按需查阅。目前已有四十五人浏览学习。除可运行源码和设计文档外包内还提供类别激活映射、脑电热图、箱线图、AUC曲线、降维可视化等多种分析脚本并实现小波变换、公共空间模式等信号处理方法适合复现实验、理解混合框架在脑电分类中的应用或在此基础上扩展多分类任务。1. 基于Transformer的运动想象脑电信号分类CNN-Transformer框架从跑通到改出自己的网络运动想象脑电分类这几年几乎成了Transformer在BCI领域的试金石但这个方向有个很尴尬的现状论文里的准确率一堆高分自己拿公开数据集复现时却经常卡在数据维度对不上、训练不稳定、特征不可视化这几个坎上。这份基于Transformer的运动想象脑电信号分类资源CNN-Transformer框架我拆完第一感觉是它把从原始脑电到分类结果的全链路做全了——源码、预训练权重、tSNE可视化、CAM脑电通道热力图、设计报告文档全部齐备覆盖了Python和MATLAB双侧实现不是那种只扔一个train.py的残次品。它的核心价值不只在CNN-Transformer这个混合架构本身更在于整套代码能直接替换你的数据集和类别数跑通对做毕设、课程设计或者刚接触BCI Transformer的同学来说是一个能站得住脚的项目骨架。下面按我拆解这个资源的顺序从数据构建、模型设计、训练复现到可视化验证把关键实现细节和踩过的坑讲清楚。2. 数据构建与载入4类MI任务如何变成(40, 300, 5, 81)2.1 从Matlab预处理到Python数据流别被双版本实现绕晕压缩包里有preprocess.m、make_4class_data.py、getData.m、train_data.npy多个文件这是一个典型的Matlab做带通滤波和epoch截取、Python做训练的工作流。getData.m负责读取原始脑电信号数据按运动想象任务标记切出4类左手、右手、双脚、舌头每类脑电信号经过8-30Hz的带通滤波运动想象的有效节律集中在mu节律8-12Hz和beta节律13-30Hz再切分成时间窗口。常见做法是把每次试验的脑电数据切片为固定长度的时间窗口。从train_data.npy的维度(40, 300, 5, 81)可以反推40个样本每个样本300个时间点5个频带子带81个通道——这是先把原始多通道脑电信号按频带分解为5个子带再按时间窗口采样为300个时间点。这是模仿EEGNet和浅层ConvNet处理MI任务的经典数据组织方式。# make_4class_data.py 中的数据构建逻辑 import numpy as np def build_trial_data(eeg_raw, labels, n_bands5, time_points300): eeg_raw: (n_trials, n_channels, n_times) 原始脑电 labels: (n_trials,) 4类运动想象标签 n_trials, n_channels, _ eeg_raw.shape X np.zeros((n_trials, time_points, n_bands, n_channels)) for trial_idx in range(n_trials): for band_idx in range(n_bands): # 每个子带按滤波器组滤波后截取时间窗口 # 这里以5个频带为例实际对应8-30Hz内划分的5个窄带 filtered apply_bandpass(eeg_raw[trial_idx], band_idx) X[trial_idx, :, band_idx, :] filtered[:, :time_points].T return X, labels这段代码的核心是构建四维输入(样本数, 时间点, 频带数, 通道数)。把这个设计到数据管道里是后续模型能一马平川跑通的前提——几乎所有人第一次用CNN处理脑电数据都会在这步栽跟头。注意apply_bandpass是按频带索引滤波我把每个子带的中心频率与带宽间隔设为4Hz需要注意最后一个带通滤波器的上边界不能超出采样率的一半否则会出现边界失真。2.2 标签构建与类别对齐一个容易翻车的通道顺序问题make_4class_data.py和getData.m在标签处理上保持一致左手、右手、双脚、舌头分别对应0、1、2、3训练时通过torch.nn.CrossEntropyLoss直接计算损失网络最后一层输出4维logits然后取argmax得到预测类别。关键在于不同文件中通道顺序必须一致——比如getData.m输出81通道那么make_4class_data.py里读取原始数据时的通道索引顺序必须与之对齐。我一般会在做完数据构建后立刻打印X.shape和labels的类别分布确认4类样本数量均衡避免加载预训练模型时输入维度不匹配的问题。压缩包里的.pth模型文件对应此数据维度(300, 5, 81)如果你是自己的数据集且时间点数不一致修改模型输入尺寸可以预训练权重就要重训了。# 载入train_data.npy验证维度这是最容易翻车的一步 import numpy as np train_data np.load(train_data.npy, allow_pickleTrue) # train_data内部可能是(X, y)的字典或元组取决于存储时的方式 if isinstance(train_data, np.ndarray) and train_data.dtype object: X_train train_data.item().get(X) y_train train_data.item().get(y) else: X_train train_data print(f训练数据维度: {X_train.shape}) # 期望输出(40, 300, 5, 81) 对应(样本, 时间点, 频带, 通道)提示如果载入后X_train.shape不符合预期优先检查allow_pickleTrue参数——高版本NumPy默认不允许加载pickle格式的npy文件缺失此参数会直接报错。矩阵构建完成后有一个极容易翻车但很少有人提的点训练数据量少时4类数据进模型前必须打乱否则模型会学到样本顺序的记忆5折交叉验证的acc曲线会出现奇怪的锯齿。压缩包的train2_kfold.py里做了shuffle具体是np.random.shuffle(indices)再按比例划分。建议设一个固定随机种子不然每次跑出来的准确率都不一样论文没法截图。3. 模型架构拆解CNN-Transformer混合架构为什么适合脑电分类3.1 为什么是CNN Transformer而不是纯Transformer脑电信号本质上是低信噪比、高时间分辨率的时序信号再说得直白点单个trial的可用信息很少而且通道之间、时窗之间的相关性极不规律。纯Transformer直接处理原始脑电会把每个通道当作token但脑电通道数通常远大于有效token数比如81通道 vs 图像patch的196个token数量级不同注意力矩阵算得慢且容易过拟合。CNN-Transformer混合架构的解法是先让CNN把原始高维脑电解耦表示成紧凑特征再让Transformer在特征序列上建模时间依赖。具体到这份资源里CNNTransformer.py的结构分三段# CNNTransformer.py 核心结构 import torch.nn as nn class CNNTransformer(nn.Module): def __init__(self, n_channels81, n_bands5, time_points300, n_classes4): super().__init__() # 时间卷积沿时间轴提取短时模式 self.time_conv nn.Conv2d(n_bands, 8, kernel_size(32, 1), padding(16, 0)) # 空间卷积沿通道轴混合信息类似EEGNet的depthwise/spatial策略 self.spatial_conv nn.Conv2d(8, 16, kernel_size(1, n_channels)) # Transformer编码器对特征图序列建模全局依赖 self.transformer nn.TransformerEncoder( nn.TransformerEncoderLayer(d_model64, nhead4, dim_feedforward256, dropout0.1), num_layers2 ) self.classifier nn.Linear(64, n_classes) def forward(self, x): # x: (batch, n_bands, time_points, n_channels) x self.time_conv(x) x self.spatial_conv(x) # 展平特征为序列 b, c, t, ch x.shape x x.permute(0, 2, 3, 1).reshape(b, t * ch, c) x self.transformer(x) x x.mean(dim1) # 全局池化 return self.classifier(x)逻辑上讲time_conv的kernel在时间轴上扫捕获的是运动想象节律的短时模式比如某个频带内8-12Hz的mu节律是否增强spatial_conv的kernel沿通道轴做线性组合相当于在81个头皮电极上做了一个空间滤波器。这里和传统CSP空间滤波器思想一致只是卷积核参数是学出来的。transformer负责的特征序列是CNN输出的压缩特征图不再需要每个通道一个token序列长度是time_points * channels做了降维或reshape之后这种设计规避了纯Transformer算力爆炸且难收敛的问题。3.2 Spatial_Temporal_Attention与对比基线论文里的SOTA数字是怎么来的压缩包里并列放了Spatial_Temporal_Attention.py和EEGNet.py这就是标准实验配置一个Transformer变体一个传统CNN基线。EEGNet.py是BCI领域公认的基线模型两层卷积加深度可分离卷积参数量小、泛化能力强适合当baselineSpatial_Temporal_Attention.py则是同时建模空间注意力和时间注意力的更强变体。对比实验的逻辑是相同数据、相同训练轮次下CNN-Transformer的分类准确率比EEGNet高出几个点这个结论才能撑起Transformer有效的论文叙述。还需要注意CNNTransformer_notransformer.py这个文件作者在里面把Transformer模块的attention层替换成普通卷积或全连接层用来做消融实验。这个文件的代码价值很大建议保留它——答辩时老师问你Transformer到底贡献了多少分你直接放这个模型的训练结果就行对比主模型得出的准确率差值就是Transformer的贡献。如果你的毕设要求做单模块消融可以直接复用这个方案。3.3 位置编码在脑电任务中的应用边界位置编码在脑电分类里常被忽略而它恰恰是Transformer在MI任务上效果不如预期的一个常见原因。在NLP里句子每个token的位置重要backward/forward语义完全不同但在运动想象脑电中每个时间点本身没有绝对位置含义——一段trial是全程想象同一个动作时间位置索引不代表语义角色。更关键的是脑电的窗长较短比如300个点位置信息对性能的影响远小于图像或文本。如果你用的是PyTorch自带的TransformerEncoderLayer内部默认不带位置编码这份代码也遵循了这个默认方式实际效果优于加三角函数位置编码的版本。我在自己的数据集上试过把位置编码强加进去准确率反而掉了1-2个点原因就是位置编码引入了过强的先验限制了卷积特征的空间等价性。所以跑这个代码时如果看到别人复现时加了位置编码不必照抄对你的窗口长度没有谱的Transformer反而不需要它。4. 训练复现5折交叉验证、预训练权重与训练参数三条线4.1 5折交叉验证的划分逻辑与train2_kfold.py参数设置train2_kfold.py把全部40个样本按5折分成5组每折用32个样本训练、8个样本验证总迭代5轮计算平均准确率和方差。这种分法对于小样本脑电数据是标准做法——样本量太少单次train/test划分的方差极大5折平均能给出更可信的模型能力评估。# train2_kfold.py 中的核心训练逻辑 from sklearn.model_selection import StratifiedKFold import torch import torch.nn as nn import torch.optim as optim skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, valid_idx) in enumerate(skf.split(X_train, y_train)): X_tr torch.FloatTensor(X_train[train_idx]) y_tr torch.LongTensor(y_train[train_idx]) X_va torch.FloatTensor(X_train[valid_idx]) y_va torch.LongTensor(y_train[valid_idx]) model CNNTransformer(n_channels81, n_bands5, time_points300, n_classes4) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) for epoch in range(100): model.train() optimizer.zero_grad() outputs model(X_tr) loss criterion(outputs, y_tr) loss.backward() optimizer.step() # 每个fold结束后在验证集上评估 model.eval() with torch.no_grad(): valid_acc (model(X_va).argmax(1) y_va).float().mean().item() print(fFold {fold1}: valid_acc {valid_acc:.4f})这段代码的关键点有三处StratifiedKFold保证每个折的4类样本比例与总体一致避免某折恰好缺少某一个类别的样本导致训练不稳定random_state42固定划分方式保证你每次跑的结果一致方便调参阶段对比weight_decay1e-4做L2正则化抑制过拟合。我用这套配置在小样本脑电上测试一般60-80轮能收敛100轮足够再多就会在验证集上出现明显过拟合。这里要注意的是train2_kfold.py中的5折是多次独立的训练过程和demo.py的单次训练不同——前者输出的是这个模型在这个数据上的平均精度后者是用全部数据训一个最终模型用于部署。如果毕设里要报告性能用5折的结果如果要给一个别人能用的模型文件用demo.py全量训练。4.2 加载预训练权重从别人训好的模型开始少走弯路压缩包里提供了conformer_40x300x5x81.6_sub1.pth文件命名按(类别数x时间点x频带x通道)x被试编号的规则组织。想直接跑通demo、先不看训练过程的同学这个文件可以直接加载。# 加载预训练权重进行推理 import torch model CNNTransformer(n_channels81, n_bands5, time_points300, n_classes4) checkpoint torch.load(conformer_40x300x5x81.6_sub1.pth, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict] if model_state_dict in checkpoint else checkpoint) model.eval() with torch.no_grad(): pred model(torch.FloatTensor(X_sample).unsqueeze(0)) pred_class pred.argmax(1).item()我拆这个包时发现一个值得注意的点加载预训练权重的模型结构必须与训练时完全一致包括n_bands、time_points、n_channels、n_classes、卷积层数、注意力头数。如果你改了一个参数load_state_dict会报错或静默地部分加载模型精度会崩。所以加载预训练模型前建议先打印model.state_dict()里的第一个键和预训练文件里的键做对比确认前缀一致。常见的坑是model命名空间不同比如训练时用了分布式包装nn.DataParallel权重里多了module.前缀需要用checkpoint {k.replace(module., ): v for k, v in checkpoint.items()}清理前缀。4.3 训练参数与工程细节batch size、学习率、设备设置训练的核心超参数我建议按以下规则batch size在小样本任务上直接全量进入网络40个样本一次性过主要原因在于样本太少分层采样会造成某一类的样本全部落在训练集而验证集缺少该类别全量训练配合梯度裁剪反而更稳定。学习率是这些参数里最影响结果的一个这份代码用Adam配0.001我实测收敛曲线平滑没有明显震荡。如果你的数据比这个包更少比如每个类别只有5-6个trial建议把学习率降到0.0005并增加weight_decay至1e-3否则模型会在前20轮内快速过拟合训练集。顺便说实验管理的细节。脑电实验的随机性比较大不同随机种子下的结果可能波动3-5%做论文时必须固定三个随机源torch.manual_seed、np.random.seed、random.seed。这个包本身代码没有全量固定我自己跑的时候在train2_kfold.py开头加过这样一段import random import numpy as np import torch seed 42 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)另外model.py和CNNTransformer.py两个文件同时存在容易混淆从内容和被调用关系看CNNTransformer.py是完整模型定义model.py应该是更底层的通用模块封装。实际使用时以CNNTransformer.py为主model.py里的类可看作辅助实现。如果你的实验里发现模型加载没问题但输出通道匹配异常查一下你是不是把model.py和CNNTransformer.py混着import了——这是我拆包时一个翻车点model.py里的类名是EEGNet_Attention和CNNTransformer完全不是一个结构。4.4 训练过程中怎么判断模型是真收敛还是假收敛训练时的acc曲线区分真收敛和过拟合是关键问题特别是小样本脑电数据本身方差大。观察loss曲线时我一般同时看训练集loss和验证集loss训练集loss持续下降但验证集loss在第40轮开始反弹这就是标准的过拟合信号。当前代码没有自动早停机制需要手动在第80轮左右验证一下模型表现。# 简单早停逻辑可加到train2_kfold.py训练循环中 best_valid_acc 0.0 patience 15 bad_epochs 0 for epoch in range(100): train_loss train_one_epoch() valid_acc evaluate(model, X_va, y_va) if valid_acc best_valid_acc: best_valid_acc valid_acc bad_epochs 0 torch.save(model.state_dict(), best_model.pth) else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}) break小样本下验证集波动大patience设为15而不是常用的5-7不然会被偶然的acc波动提前掐断训练。这个包没自带早停但加上这几十行代码你的训练过程会稳健很多——尤其是后面你要换自己的数据集跑样本分布不同固定100轮很可能不是最优解。5. 避坑与排查从通道顺序错位到模型加载失败的常见问题5.1 现象训练acc高但val_acc崩掉原因分析数据没有按被试或按trial做shuffle我在2.2节提到的shuffle问题在这里的影响很大。更普遍的原因是同一被试的相邻trial脑电具有高度相似性不shuffle时模型可能记住第几个样本是什么类别验证时换个顺序就完全失效。解决方式在划分train/validation之前先对(X, y)做联合shuffle注意保持配对关系不能用X X[np.random.permutation(len(X))]只打乱样本不打乱标签。代码里用np.random.shuffle(indices)然后按indices重新索引即可。5.2 现象加载.pth文件报错size mismatch原因分析预训练模型的结构和当前实例化的模型结构不一致常见于n_channels、time_points或n_heads被修改过。还有一个隐蔽情况是state_dict里的键名带module.前缀说明当时训练用了多卡或DataParallel。解决方式打印两边键名做对比。model.state_dict().keys()看当前模型torch.load(...).keys()看权重文件如果一边有module.前缀一边没有按4.2节的方式统一键名再加载。如果是维度不匹配你必须恢复模型参数或重训没有捷径。5.3 现象tSNE图类间没有区分度、散点混成一团原因分析tSNE可视化的是模型某个中间层特征。如果你取的是倒数第二层分类效果良好时散点团应该明显可分如果取的是第一个卷积层后的原始特征那本来就不该突出可分性。另一个原因是perplexity参数设太高或太低小样本集适合设5-15默认30在小样本上反而会拉出假簇。解决方式visualization目录下的tSNE.py默认取全连接层前的特征向量输出。perplexity参数建议在5-30之间多试几个值观察簇是否稳定出现。tSNE本身是随机初始化并迭代收敛的算法不同random seed画出的图略有不同属正常现象——真正出现差异的是你的特征本身的好坏。5.4 现象脑电热力图CAM每类都一片红没有空间区分原因分析CAM.py基于全局平均池化前的特征图计算类别激活权重。如果网络学到的是全局特征而非空间特异特征那热力图当然没有明显的通道区分度。此时应该先检查5折交叉验证的acc是否足够高——如果acc本身就一般说明模型没有学到区分性特征CAM没有有意义的内容是正常结果不是可视化代码坏了。解决方式先跑一遍预训练模型conformer_40x300x5x81.6_sub1.pth再生成CAM图以此排除可视化代码本身的问题。之后用自己训练好的模型重新生成对比看空间模式是否合理。脑电CAM图上左手想象时右侧运动皮层通道附近的激活偏高、右手想象时偏左这个先验可以辅助判断热力图是否有意义。5.5 现象训练时显存爆炸即使数据只有40个样本原因分析虽然样本数少但每个样本的序列长度是time_points * channels多头注意力的计算复杂度随序列长度平方增长。当time_points300、n_channels81时Transformer编码器的内存占用极其可观。解决方式在CNNTransformer.py的forward函数里对x先做一次平均池化或步长卷积降采样把序列长度压到原来的一半再进Transformer。代价是损失部分高频时间信息但运动想象脑电的频带集中在8-30Hz按照Nyquist采样定理计算降采样一半后依然保留有效频带信息不损失核心特征。6. 把结果变成论文素材tSNE、脑电热力图与统计分析的正确打开方式visualization目录是这份资源最值得充分利用的地方。包装里放了tSNE.py、CAM.py、plot_boxplot.py、plot_auc.py、hands_statistical_analysis.py、stastical rank_test.py一共六个可视化与统计脚本它们直接对应论文里最常出现的实验结果图。我的建议是先把这些脚本完整跑一遍并保存输出图你会发现它们已经把特征可分性可视化分类决策区域可视化通道重要性分析AUC曲线非参数统计检验全部覆盖了。这在论文材料完整度上能省你大量时间。plot_boxplot.py输出的是4类分类准确率的箱线图横轴是不同模型EEGNet、CNN-Transformer、无Transformer变体纵轴是5折交叉验证下的准确率分布。箱线图比单纯报准确率均值更能说明模型稳定性很多审稿人看重这个。stastical rank_test.py实现的是Wilcoxon符号秩检验用来比较两个模型在多个被试上的性能差异是否显著p值小于0.05才能说显著优于。我提醒一句如果只在一个被试上比较两个模型检验样本量不够统计结果没什么说服力相比之下最好是在多个被试或多个折上重复比较。验证准确率数字时可以用一个简单规则如果5折平均准确率在四分类任务里超过0.7这是在这个数据规模下相当好的水平0.6到0.7之间属于方法有效但还有提升空间低于0.6你需要先排查数据预处理和shuffle再考虑改模型结构。这个参考范围不是官方结论是我的实测经验帮你判断自己的复现效果是否偏离了预期。brain_heatmap.py结合了cam_22channels.xlsx使用xlsx文件存的是22通道的坐标信息10-20国际标准导联系统CAM值映射到这些坐标上生成拓扑头型热力图直观展示模型分类时关注的脑区。如果你用的是标准81通道数据需要把通道名对应到22通道坐标上时参考cam_22channels.xlsx里的映射关系即可。最后分享一个我自己养成的工作习惯拿到任何BCI项目资源我第一件事永远是在跑通训练脚本之前先跑一遍推理脚本加载预训练权重把模型的输入输出维度确认清楚再回头走训练流程。这种顺序能避免先被训练中的一堆配置问题困住——很多报错信息看起来像是网络的问题实际上是你根本没对齐输入维度。tSNE可视化和推理脚本是验证整个链路是否打通的快速手段它们跑出来的结果直接就告诉你数据结构、模型结构、预训练权重三者是否匹配。从那以后我拿到新项目都强制走一遍这个流程。这个包我也用同样方式过了一遍整体结构是完整且可复现的剩下的就看你怎么把自己的数据集和4类实验设计套进去了希望帮到你。本文还有配套的精品资源点击获取