
简介这是一份面向毕业设计的运动想象脑电信号分类项目源码包采用卷积神经网络与Transformer混合框架先用卷积网络提取局部时空特征再由Transformer建模全局依赖并创新加入梯度加权类激活映射实现脑电地形图可视化适合人工智能、电子信息、自动化等专业学生用于毕设、课设或脑机接口入门。压缩包共31个文件大小18.45MB以23个脚本文件为主涵盖模型构建、数据读取、交叉验证、样本降维投影、箱线图与受试者工作特征曲线分析另含两个预处理脚本、已训练模型权重、样本数据、结果表格和项目说明结构清晰。代码均经过调试运行答辩评审平均分达96分已有1254人浏览学习可视化部分能帮助初学者理解模型对不同脑电通道的分类依据。整体覆盖数据预处理、特征提取、模型对比和结果绘图适合二次开发与课题深入复现。1. 做运动想象脑电信号分类为什么绕不开CNNTransformer框架运动想象脑电信号分类是个老问题被试在脑子里默想“动左手”“动右手”“动脚”或“动舌头”脑电会在mu节律8-12Hz和beta节律14-30Hz上出现短暂的功率下降或上升也就是ERD/ERS现象。信号本身非常弱、信噪比低、个体差异大传统CSPLDA手工特征的做法上限很明显。毕设里真正卡人的是公开数据集通常只有几百个有效trial纯CNN能提局部时空特征但对长程依赖捕捉不足纯Transformer在小样本脑电上又特别容易过拟合。CNNTransformer框架的动机很简单——CNN先把脑电拆成局部时空表征Transformer再在全局时间维度建模依赖。这篇笔记按BCI Competition IV 2a数据集讲从预处理到模型搭建再到调参踩坑给你一套能直接落地跑通的方案。2. 选型先立住CNN在提什么Transformer在补什么2.1 运动想象的神经信号长什么样为什么难分类运动想象分类的生理基础是事件相关去同步/同步。被试在真实执行或想象肢体运动时对侧运动皮层的mu节律和beta节律会出现幅度衰减叫ERD想象结束后又会出现短暂增强叫ERS。这个现象在C3、C4、Cz这些靠近运动皮层的电极上最明显但幅度只有几微伏完全淹没在自发脑电、肌电和工频干扰里。信噪比低带来两个直接后果一是单次trial分类本来就抖二是特征高度依赖频带位置而不同被试的mu节律中心频率可能差出2-3Hz。难点不止在信号弱。脑电是典型的非平稳信号同一个被试上午和下午的基线都可能漂移更不用说不同被试、不同session之间的差异。BCI Competition IV 2a数据集里每名被试只有288个训练trial这点数据量对深度学习来说非常紧张——图像分类动辄上百万张图而运动想象分类通常只有几百到几千个样本。在这个约束下选模型第一原则不是精度上限有多高而是“在有限样本下不翻车”。2.2 纯CNN和纯Transformer各自的边界在哪CNN的优势是局部感受野和参数共享。在脑电上一个沿时间轴的卷积核可以看成在学一组滤波器一个沿通道轴的卷积核可以看成在做空间滤波这和传统CSP的思路在结构上是一致的。EEGNet这类模型就是靠两层小卷积在BCI 2a上拿到不错的基线。但CNN的问题也明确单层能看到的上下文就是卷积核尺寸要覆盖1秒以上的时间依赖要么把卷积核拉长到几百个采样点要么堆很多层而这两种做法在小样本上都很容易过拟合或者让训练不稳定。Transformer的长处是自注意力序列里任意两个位置可以直接交互不靠堆层数也能建模长距离依赖理论上很适合时间序列里的全局关系。但Transformer有两个明显短板。第一个是归纳偏置弱它不像卷积那样天然假设相邻位置相关直接在原始信号上切片做patchify再进Transformer效果往往不如CNN。第二个是数据需求量大自注意力参数多在几百个trial的脑电数据上纯Transformer经常是训练集loss一路降到接近0验证集纹丝不动过拟合得明明白白。所以CNNTransformer不是“为了堆模型而堆”而是互补CNN在原始EEG上先做局部时间卷积和空间滤波把每个时间节点上的局部表征整理干净Transformer再对这些表征序列做全局建模捕捉CNN单层看不到的长程依赖比如一次完整运动想象中ERD到ERS的时序演化。这和Vision Transformer在图像上work的逻辑一致——底层特征交给卷积或patch embed全局关系交给自注意力。2.3 常见的融合结构与选型建议论文和开源实现里CNNTransformer用于EEG分类的融合方式大致有三种。第一种是串行结构CNN做前端特征提取器输出一个特征序列接Transformer编码器做序列建模最后接分类头这也是本方案采用的结构。第二种是双分支并行结构一路CNN提局部特征一路Transformer直接处理原始序列分类前做特征拼接这种结构参数多在样本少的BCI 2a上容易过拟合。第三种是把卷积嵌入Transformer内部比如用卷积生成Q/K/V本质上是给注意力加局部偏置实现复杂调试成本高。毕设场景我建议优先选串行结构理由很实际两个子模块职责拆得干净CNN部分可以直接复用EEGNet的成熟设计Transformer部分可以独立调参训练出问题也容易定位到是前半段还是后半段。融合结构CNN职责Transformer职责适用场景串行局部时空特征提取与降维全局时间依赖建模样本少推荐毕设首选并行双分支局部特征分支全局特征分支样本充足特征维度高卷积注意力同时承担卷积与注意力局部偏置注入调试周期充裕有基线对比需求选型还要看计算量。BCI 2a上输入是22×500的矩阵数据量本身不大但Transformer的自注意力复杂度是序列长度的平方。好在经过CNN池化后序列长度通常能压到25到30个token这个量级下注意力计算完全不是瓶颈CPU都能跑。真正决定模型能不能收敛的是d_model和层数这两个参数在第四章会给出具体取值范围别一上来就堆一个8层大模型那基本是给过拟合送人头。3. 把BCI 2a原始数据变成模型输入预处理、基线划分与增强3.1 数据集与加载方式BCI Competition IV Dataset 2a是运动想象分类最常用的公开benchmark之一官方提供GDF和MAT两种格式。数据集包含9名被试4类任务左手、右手、双脚、舌头。每名被试训练阶段有两个session每个session 288个trial合计576个trial。很多毕设demo只取其中一个session做训练和验证也完全够用。22个Ag/AgCl电极按国际10-20系统排布采样率250Hz原始记录带宽0.1-100Hz。用MNE读GDF是最省事的路径Windows下也很稳定import mne import numpy as np def load_bci2a(gdf_path, tmin0.5, tmax2.5, n_trimNone): raw mne.io.read_raw_gdf(gdf_path, preloadTrue, verboseFalse) raw.pick_types(eegTrue) # 只保留22个EEG通道 raw.filter(4, 38, methodiir, verboseFalse) # 运动想象相关频带 raw.notch_filter(50, verboseFalse) # 工频陷波 events, event_id mne.events_from_annotations(raw, verboseFalse) epochs mne.Epochs( raw, events, event_id{left: 1, right: 2, foot: 3, tongue: 4}, tmintmin, tmaxtmax, # cue后0.5s到2.5s共2s baselineNone, preloadTrue, verboseFalse, ) X epochs.get_data() # (n_trials, 22, 501) y epochs.events[:, 2] - 1 # 标签映射到0/1/2/3 if n_trim is not None: X X[:, :, :n_trim] # 截成整长度 return X, y, raw.info[sfreq]逻辑说明read_raw_gdf返回Raw对象pick_types(eegTrue)把EOG等非脑电通道丢掉保证后面通道维度统一是22。filter用IIR实现4-38Hz带通这是运动想象ERD/ERS最集中的频带notch_filter再做一次50Hz陷波虽然数据集录制时已经陷过但重放一遍能让后续卷积特征更稳定。Epochs按事件ID分段tmin0.5, tmax2.5表示从cue出现后0.5秒开始取2秒这个窗口是运动想象研究中最好用的默认值。参数说明tmin/tmax控制分析窗口我一般先固定窗口再调模型不要两头一起动。n_trim用于把时间维度截整比如滤出来的501个采样点截成500让后续池化能整除少掉一个点对结果没有可感知的影响。3.2 滤波、分段与归一化顺序不能乱预处理的顺序是有讲究的。滤波一定要放在分段之前因为滤波在信号两端会产生边界效应先分段再滤波会让每个trial的边界都出现一次假信号。归一化则必须放在train/val划分之后用训练集的统计量去处理验证集这一步做反了验证集的性能就是虚高的。from sklearn.model_selection import train_test_split X, y, fs load_bci2a(A01T.gdf, n_trim500) # 先按trial独立划分再做归一化顺序不能反 X_tr, X_va, y_tr, y_va train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) # 用训练集统计量归一化验证集只做平移缩放 mean_tr X_tr.mean(axis(0, 2), keepdimsTrue) # 每个通道一个均值 std_tr X_tr.std(axis(0, 2), keepdimsTrue) X_tr (X_tr - mean_tr) / (std_tr 1e-6) X_va (X_va - mean_tr) / (std_tr 1e-6) print(X_tr.shape, X_va.shape) # (460, 22, 500) / (116, 22, 500)逻辑说明数据是n_trials×n_channels×n_times的结构mean沿trial和时间维度求每个通道的均值得到形状(1, 22, 1)的统计量。加1e-6防止某个通道方差为0导致除零。验证集归一化用的是训练集的mean_tr和std_tr不是验证集自己的统计量这是很多复现结果对不上的血泪来源——一旦把全量数据的统计量算完再划分信息就从训练集漏到验证集里去了。提示如果后续做跨被试验证比如留一被试leave-one-subject-out归一化统计量只能从训练被试里算不能把测试被试的数据混进来。跨被试归一化是运动想象分类里最容易翻车的细节之一。3.3 训练/验证拆分与数据增强BCI 2a一个被试只有几百个trial这点样本直接喂给Transformer肯定不够数据增强是必须的。脑电数据增强不能像图像那样随便翻转裁剪因为翻转相当于把运动想象的对侧映射搞反了。最稳的是随机时间平移和加高斯噪声def augment_eeg(X_batch, y_batch, shift_max8, noise_std0.05): B, C, T X_batch.shape X_aug np.empty_like(X_batch) for i in range(B): shift np.random.randint(-shift_max, shift_max 1) if shift 0: # 左侧补零右侧截掉 X_aug[i] np.concatenate( [np.zeros((C, shift)), X_batch[i, :, :-shift]], axis1 ) elif shift 0: s -shift # 右侧补零左侧截掉 X_aug[i] np.concatenate( [X_batch[i, :, s:], np.zeros((C, s))], axis1 ) else: X_aug[i] X_batch[i] X_aug X_aug np.random.normal(0, noise_std, sizeX_aug.shape) return X_aug, y_batch逻辑说明这个增强函数在PyTorch的DataLoader里在线执行每个epoch对同一批数据产生不同的扰动等效于扩充训练集。shift_max8对应250Hz下32ms的时间平移这个量级不会破坏ERD/ERS的节律结构但能打乱与cue精确对齐的伪迹。noise_std0.05是因为前面的z-score归一化让信号方差大约为1加0.05相当于一个幅度很小的高斯扰动不会把信号淹没。参数说明增强强度不要一开始就拉满先开平移、噪声开0.02跑一个epoch看训练loss曲线如果训练loss下降变慢再往上加。EEG数据增强的参数选择有几分玄学成分在最稳的做法是固定一组参数后就不再乱动把精力留给模型本身和超参调优。4. 搭一个能跑的CNNTransformer模型结构、代码与超参4.1 整体结构与维度流转这一章给的模型是串行结构CNN提取局部时空特征并把序列长度压到25Transformer在这25个token上做全局建模最后用全局平均池化接分类头。先看全流程维度流转所有层都用PyTorch的batch_first约定层名输出形状作用说明输入(B, 1, 22, 500)单通道扩展时间卷积 空间卷积(B, 16, 1, 500)时间滤波 空间融合平均池化1(B, 16, 1, 125)4倍下采样深度可分离卷积(B, 32, 1, 125)通道升维时序平滑平均池化2(B, 32, 1, 25)5倍下采样reshape(B, 25, 32)转成token序列Transformer编码器(B, 25, 32)全局时间建模全局平均池化(B, 32)聚合token分类头(B, 4)全连接输出序列长度25是经过两次池化后得到的。这个长度下自注意力计算量完全可以忽略但也意味着Transformer的“容量”不大——这是故意的样本少的时候容量小反而更稳。4.2 CNN特征提取部分CNN部分借鉴EEGNet的成熟设计但做了精简时间卷积学频带滤波空间卷积融合通道深度可分离卷积进一步时序平滑。import math import torch import torch.nn as nn class CNNFeatureExtractor(nn.Module): def __init__(self, n_channels22, f18, f216, d_model32, dropout0.3): super().__init__() # 时间卷积沿时间轴做卷积等价于学一组带通滤波器 self.temporal_conv nn.Conv2d(1, f1, (1, 64), padding(0, 32), biasFalse) # 空间卷积在通道轴上融合所有电极等价于空间滤波 self.spatial_conv nn.Conv2d(f1, f2, (n_channels, 1), biasFalse) self.bn1 nn.BatchNorm2d(f2) self.pool1 nn.AvgPool2d((1, 4)) # 深度可分离卷积每个特征图独立做时序平滑再用1x1融合 self.depthwise nn.Conv2d( f2, f2, (1, 16), padding(0, 8), groupsf2, biasFalse ) self.pointwise nn.Conv2d(f2, d_model, (1, 1), biasFalse) self.bn2 nn.BatchNorm2d(d_model) self.pool2 nn.AvgPool2d((1, 5)) self.act nn.ELU() self.dropout1 nn.Dropout(dropout) self.dropout2 nn.Dropout(dropout) def forward(self, x): x self.act(self.bn1(self.spatial_conv(self.temporal_conv(x)))) x self.pool1(x) x self.dropout1(x) x self.act(self.bn2(self.pointwise(self.depthwise(x)))) x self.pool2(x) x self.dropout2(x) # (B, d_model, 1, T) - (B, T, d_model) x x.squeeze(2).transpose(1, 2) return x逻辑说明输入是(B, 1, 22, 500)temporal_conv的卷积核(1, 64)只在时间轴上滑动64个采样点对应约256ms覆盖一个alpha周期。padding(0, 32)让时间维度输出仍为500。spatial_conv的卷积核(22, 1)一次扫过全部通道这一步在仿CSP的空间投影。depthwise用groupsf2让每个特征图独立做时序卷积pointwise负责跨通道融合并把维度升到d_model32。两次AvgPool2d把时间维度从500压到125再压到25。参数说明f18是第一层时间卷积的滤波器个数对应8组频率响应f216是空间卷积输出通道数。想要更强的时间特征提取能力可以调大f1但样本少时不建议超过16。d_model32是给Transformer的token维度也是特征图的通道数这里直接复用CNN的输出通道数省去一层额外的投影。4.3 Transformer编码器与分类头Transformer部分用了标准的位置编码和nn.TransformerEncoder分类头直接做全局平均池化不引入CLS token。CLS token是ViT里配合预训练用的在几百个样本的小数据集上全局平均池化更稳收敛更快。class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len64): super().__init__() pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(pos * div) pe[:, 1::2] torch.cos(pos * div) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:x.size(1)] class CNNTransformerMI(nn.Module): def __init__(self, n_channels22, n_classes4, f18, f216, d_model32, nhead4, num_layers2, dropout0.3): super().__init__() self.cnn CNNFeatureExtractor(n_channels, f1, f2, d_model, dropout) self.pos_enc PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 4, dropoutdropout, activationgelu, batch_firstTrue ) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.norm nn.LayerNorm(d_model) self.classifier nn.Linear(d_model, n_classes) def forward(self, x): x self.cnn(x) # (B, 25, d_model) x self.pos_enc(x) x self.transformer(x) x self.norm(x) x x.mean(dim1) # 全局平均池化 return self.classifier(x)逻辑说明CNN输出(B, 25, 32)表示25个时间token每个token的32维向量是CNN在该时间位置整合出的全通道融合特征。PositionalEncoding加一个固定的正余弦位置编码让Transformer知道token的先后顺序。TransformerEncoderLayer里的batch_firstTrue指定输入输出都是(B, T, d_model)。dim_feedforwardd_model * 4是前馈网络的隐藏层维度128在所有小模型里都是够用的。mean(dim1)对所有时间token取平均得到整个运动想象窗口的全局特征最后接4分类线性层。参数说明nhead4要求d_model能被4整除32 / 4 8每个注意力头8维这是合理配置。num_layers2是给BCI 2a这种小样本用的保守值如果加数据增强后验证集还在涨可以试3层但不要一上来就4层以上。dropout0.3同时作用在CNN和Transformer内部小样本任务里0.3到0.5之间都是安全区。4.4 训练配置与关键超参表训练配置是另一个决定成败的半壁江山。损失函数用交叉熵优化器用AdamW而不是SGD学习率调度用余弦退火。运动想象脑电任务里AdamW CosineAnnealing的组合比固定学习率稳得多能显著减少后期loss震荡。import torch from torch import nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model CNNTransformerMI() criterion nn.CrossEntropyLoss() optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-5) for epoch in range(100): model.train() # 训练循环Xb.unsqueeze(1) - (B, 1, 22, 500) # 增强后的Xb先转成torch.FloatTensor再送到device # loss.backward() - optimizer.step() - 清空梯度 scheduler.step()训练循环里有两个点值得注意。第一输入在进CNN前必须unsqueeze(1)把(B, 22, 500)变成(B, 1, 22, 500)因为二维卷积期望4维输入。第二数据增强要在batch内在线完成并且只对训练集做验证集永远用干净数据。超参数建议取值范围说明learning rate5e-4 ~ 1e-3AdamW下1e-3起步不稳就降weight_decay1e-4 ~ 1e-2过拟合明显时加大dropout0.3 ~ 0.5小样本0.3起步d_model32 / 64首选3264需配合更强增强num_layers1 ~ 32层起步nhead4 / 8需整除d_modelbatch_size32 / 64以显存和batch norm稳定性为准注意不要一上来就追求“大模型”。BCI 2a单被试几百个triald_model 64加4层Transformer的下场几乎一定是训练loss降到0.01、验证集55%准确率。小模型配强正则是这个数据集的生存法则。5. 训练与实验避坑记录从loss不降到跨被试泛化失败5.1 loss震荡不收敛学习率与warmup现象训练前50个epoch的loss在高位反复横跳像心电图一样准确率始终徘徊在35%到45%之间。原因学习率偏大AdamW在1e-3以上的学习率对这个小模型来说步子太大加上脑电数据本身的噪声很大每批样本间的梯度方向不一致导致优化过程在局部震荡。解决先把学习率降到5e-4如果还抖就再降。另一个通用做法是加warmup——前5到10个epoch让学习率从1e-4线性爬到目标值。PyTorch里可以用SequentialLR把一个线性的warmup调度器和一个CosineAnnealingLR串起来。我自己的经验是运动想象脑电信任务里5e-4的AdamW配合余弦退火比任何花哨的学习率策略都稳。5.2 过拟合小样本是Transformer的“天敌”现象训练集准确率很快到98%以上验证集卡在60%出头而且随着epoch推进两者的差距越拉越大。原因这是BCI 2a上Transformer最经典的死法。几百个trial的数据量喂一个带自注意力的模型模型容量远大于数据能支撑的信息量它开始记忆训练集里的个体噪声而不是运动想象的共同特征。解决三板斧按顺序用。第一把dropout从0.3加到0.5Transformer和CNN部分都加第二把weight_decay从1e-4加到1e-3第三把num_layers从2降回1d_model从64降到32。如果三板斧用完验证集还没有改善优先检查数据增强是否只作用在训练集以及归一化是否漏了验证集。5.3 验证集虚高时间窗口增强造成的数据泄露现象验证集准确率高达90%但你心里知道这个结果不真实换个被试一测直接掉到50%。原因这是最隐蔽的坑。有些实现会把一个trial切出多个重叠窗口作为增强如果不按trial分组就直接切分train/val同一个trial的窗口可能同时出现在训练集和验证集里。Transformer看到的是同一个原始信号的两种加噪版本验证集的“高分”是记忆的结果不是泛化的结果。解决所有划分都必须以trial为单位一个trial的所有增强窗口只能进训练集或只能进验证集。我在前面的预处理代码里是先按trial切分了train/val再做增强这个顺序不能反。如果你用交叉验证也建议用GroupKFold按trial分组。5.4 维度对不上EEG的4维期望与5维现实现象模型一跑forward就报Expected 4-dimensional input for 4-dimensional weight [8, 1, 1, 64], but got 5-dimensional input instead。原因PyTorch的Conv2d期望输入是(B, C, H, W)而EEG数据原始形状是(B, C, T)。如果直接把这个三维数据喂进去PyTorch会报维度错。另一个常见版本是Transformer的batch_first没设对输入变成(T, B, d_model)输出错位。解决在模型forward的第一行加一个断言比如assert x.dim() 4然后在数据进模型前统一做x.unsqueeze(1)。batch_firstTrue要同时传给TransformerEncoderLayer不然forward里的(B, T, d_model)会被当成(T, B, d_model)处理sequence和batch两个维度直接串位。5.5 结果复现不了随机种子和数据顺序现象同一份代码昨天跑acc是78%今天跑变成75%甚至同一个结果都复现不出来。原因深度学习里至少有三个随机源PyTorch的参数初始化、NumPy的数据增强、DataLoader的shuffle顺序。BCI 2a数据集小任何随机源的影响都会被放大。GPU上的某些算子比如cuDNN的卷积在非确定性模式下也会带来误差。解决固定所有种子训练前把torch.manual_seed、np.random.seed、random.seed都设好并设置torch.backends.cudnn.deterministic True。跑实验时同一配置至少跑3次取均值运动想象这个任务上单次结果上下浮动3到5个百分点都算正常只看一次实验结果下结论很容易被随机性骗了。6. 结果验证与进阶注意力可视化与轻量化Transformer模型训练完别急着看准确率就收工。我建议至少做三件事第一件是打印混淆矩阵。BCI 2a四分类里左手和右手这对类别最容易混淆脚和舌头相对好分。如果混淆矩阵显示左脚和右手互相串往往是C3/C4的空间信息没学到位回去看CNN的空间卷积是不是被dropout打太狠了。第二件事是注意力权重可视化。这一步有诊断价值把Transformer最后一层所有head的attention map平均按token位置映射回时间轴看权重集中在哪个时间段。如果模型真的学到了ERD/ERS权重应该集中在运动想象窗口的中段而不是cue刚出现的时刻。如果注意力全在序列开头说明位置编码或数据分段窗口还需要调整。具体做法不复杂注册forward_hook把self.transformer的输出抓出来对最后一层attn做平均后插值回原始时间分辨率。第三件事是面向落地的轻量化尝试。把num_layers从2降到1d_model从32降到16nhead降为4参数量会直接少掉一半以上而BCI 2a上的准确率可能只掉2到3个点。原因前面说过小样本本身撑不起大Transformer压缩容量有时候反而提升泛化。这一步跑通了后续接嵌入式实时脑机接口的部署就有底了。我在自己的毕设里翻车最狠的一次就是把全部精力耗在堆模型结构上结果发现数据预处理的顺序错了。从那以后我养成了个习惯先冻结数据管线和划分逻辑再动模型结构和超参每次只改一个变量用同一个固定种子跑三个重复取均值把所有实验记录在表格里。做运动想象分类真正的门槛往往不在模型多新颖而在你能不能把一条线稳定地复现两遍。希望帮到你。本文还有配套的精品资源点击获取