ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN与Transformer融合实现运动想象EEG分类的完整实战指南

CNN与Transformer融合实现运动想象EEG分类的完整实战指南 简介一个基于CNN与Transformer融合架构的运动想象脑电信号分类项目面向计算机科学、信息工程、智能系统等专业的学生与研究人员适合用作课程实践、综合训练或学位课题参考。方案利用卷积模块提取脑电时空局部特征结合Transformer建模长程依赖并引入梯度加权类激活映射实现脑电空间模式的可视化解析学术创新性与工程可复现性兼顾可支持进一步的算法优化与功能拓展。压缩包共38个文件约18.47MB主要包含Python源码、MATLAB预处理脚本、模型权重、训练数据、实验结果表格及说明文档覆盖数据预处理、模型构建、交叉验证训练、脑电热图与t-SNE可视化等完整流程并提供有无Transformer的对比模型代码便于开展消融实验与性能验证。已有67人学习下载。读者可基于现有框架直接运行测试也可对模型结构、特征提取方式与可视化作图进一步改进快速搭建自己的脑电信号分类实验环境。1. 从猜到认运动想象脑电分类为什么非得CNN和Transformer一起上运动想象脑电分类以前靠人工特征和SVM后来靠CNN硬扫时域波形但单个CNN抓不住持续两秒以上试次里的全局时序依赖单Transformer又容易忽略导联间的局部空间响应。把CNN和Transformer融合正好让CNN先做局部粗筛让Transformer再找长程关系最后把注意力权重画到头皮位置上做可视化解释模型为什么判成左手或右手。这条路适合手上有公开EEG数据集或自采数据、想刷分类准确率并做展示系统的人。下面我按数据预处理、模型结构、调参、避坑和可视化实现的顺序展开全部是能直接落地的做法。2. 数据预处理与公共数据集选择没有干净的EEG融合模型就是空中楼阁2.1 用BCI Competition IV 2a还是自采数据先认清标签与通道布局常见做法是先用公开数据验证。BCI Competition IV Dataset 2a简称BCI-IV-2a有9名受试者、4类运动想象左手、右手、双脚、舌头22个EEG通道加3个EOG通道采样率250Hz每个受试者两个session每个session每个类别48次试次。这套数据的价值在于标签干净、试次起止点明确而且网上能直接下到原始信号和标签文件不需要自己处理同步问题。文件格式通常是GDF或者MAT里面除了信号矩阵还有一个classlabel向量和trial_start这种事件标记。拿到手第一步不是跑模型而是把事件标记和原始信号一一对起来画几张图确认每个试次的时间戳没有偏移。自采数据的情况就麻烦得多。运动想象任务通常一个试次持续4秒前2秒是提示和准备后2秒才是真正的想象期。你需要在标记通道里记录事件时间戳还要同时记录受试者状态。很多团队第一步就翻车把提示音时刻当成想象起点实际上受试者看到箭头后还要反应几百毫秒后2秒窗口如果取不对模型学的就是反应延迟而不是运动想象。我见过有人用只是提示音触发、没有实际想象确认的数据训了一版测试准确率只有55%后来把窗口往后挪了500毫秒直接涨到72%。我一般会在预处理前先做一件事把每个试次的标签、时间戳、通道顺序、采样率写成一个清单文件再画一遍原始信号。这一步看起来费时间但能挡掉八成后续玄学问题。对融合模型来说通道顺序尤其要小心——CNN的空间卷积依赖通道排列如果你用的电极帽是标准的10-20系统建议固定一个顺序比如Fz、FC1、FC2、C3、Cz、C4、CP1、CP2等不要按厂家默认导出顺序直接喂给模型。BCI-IV-2a的通道顺序是固定的自采数据一定要做重排否则空间卷积核学习的左右手分布根本对应不上你的电极帽。2.2 滤波、分段与坏导剔除最容易翻车的一步运动想象EEG的敏感频段主要在8-30Hzmu节律约8-12Hzbeta节律约13-30Hz。常见处理顺序是先做带通滤波再做坏导剔除然后按事件分段。滤波建议用FIR或零相位IIR避免脑电信号产生相位偏移。我用的是scipy.signal的butter阶数4filtfilt做零相位滤波带通设为8-35Hz。范围稍微放宽一点因为不同受试者的特征峰有偏移卡死了反而丢信息。如果数据里混有50Hz工频干扰在带通之前加一个窄带陷波中心频率50Hz品质因数Q大概30。注意陷波会连带削掉一部分beta段的信息所以只在工频明显时才加别默认加。分段时不建议只用想象期的最后2秒更稳的做法是从想象起始时刻取0.5s到3.5s长度3秒共750个采样点250Hz采样率。这样前面保留一点准备期的基线后面留出余量。如果每个试次只取2秒Transformer分支的时间token就太少全局注意力能看的东西有限。分段后的每个试次是一个(22, 750)的矩阵后续所有增强和模型操作都基于这个形状。坏导剔除的标准做法是计算每个通道的方差和功率谱把超出全通道中位数3倍方差的通道标记为坏导然后用相邻通道插值替换。不要直接把坏导置零因为空间卷积会把零值当成有效响应。插值方法用球面插值或者相邻通道平均都行我习惯用相邻通道的平均简单且不容易引入远端通道的伪迹。做完坏导替换再做一次公共平均参考CAR减去所有通道在同一时刻的平均值。CAR能显著消除全脑共模噪声比如肌电和电极漂移尤其适合自采数据。眼电伪迹是另一个高频坑。BCI-IV-2a里有EOG通道可以直接用回归法去除自采数据如果没有同步EOG可以考虑用ICA。但ICA在小样本上很容易过拟合而且运动想象本身的前额叶活动可能被ICA当成噪声去掉。我的经验是先看数据里有没有明显的眨眼尖峰如果没有太大干扰能不做ICA就不做。做个简单的高通滤波到1Hz就能去掉直流漂移眼电伪迹如果幅度不大交给CNN的空间卷积去学反而更稳。2.3 数据增强在时序上做扰动别在空间上做花活EEG数据量通常很小BCI-IV-2a每个受试者也就约288次试次。数据增强是融合模型能训起来的必要条件但方向有讲究。我试过在通道维度加高斯噪声、做随机的电极扰动效果都不稳定真正稳定提升的是时序上的增强。三类增强我比较推荐时间裁剪在每个试次中随机裁剪一段比如从0.5s到3.5s之间随机取2.5s窗口然后缩放到原始长度。这等于让模型认识不同起始位置的想象特征。幅值扰动对整段信号乘以一个在[0.9, 1.1]之间随机取的放大系数模拟不同受试者的电极接触阻抗差异。频段扰动在滤波后对8-12Hz或13-30Hz频段的能量做小幅随机缩放增强对个体节律差异的鲁棒性。实现上可以用滤波再叠加的方式但注意不要破坏相位。不建议做的增强是随机交换导联位置。通道位置一旦互换CNN的空间卷积学到的东西就全乱了而且这种增强没有物理意义——头皮上的电极位置是固定的交换通道等于伪造一个不存在的空间分布。另外mixup这类样本混合增强在EEG上要小心因为不同试次的时间点基线和相位差异很大硬插值可能混合出既不像左手也不想右手的假信号。提示增强对象是原始时间序列还是在CNN输出后的特征序列上做效果差别很大。早期我试过在特征层面做mixup融合模型反而不如不做。后面对比下来时序信号层面的增强最稳。3. 模型怎么搭CNN分支提局部特征Transformer分支抓全局依赖3.1 先让CNN分支把时域卷积和空间卷积分开做很多入门代码把EEG当图像处理直接用二维卷积在通道×时间矩阵上扫这其实浪费了EEG的结构特点。EEG数据通常表示为(C, T)C是通道数T是时间采样点数。真正的物理结构是每个通道是一个时间序列通道之间有空间关系但通道间距离又不是规则网格。我习惯把CNN分支拆成两步先用时域卷积核去提取每个通道上的波形特征再做一次空间卷积去融合通道信息。时域卷积核长度可以设为64个采样点250Hz下约256ms这一步能捕捉mu节律的单个振荡周期空间卷积核大小设为(C, 1)也就是一次覆盖全部通道类似于EEGNet里的深度可分离卷积思路。代码示意import torch.nn as nn class CNNBranch(nn.Module): def __init__(self, n_channels, n_samples): super().__init__() # 时域卷积每个通道独立做时间滤波输出16个特征通道 self.time_conv nn.Sequential( nn.Conv2d(1, 16, kernel_size(1, 64), padding(0, 32)), nn.BatchNorm2d(16), nn.ReLU() ) # 空间卷积把全部通道合并成一个空间映射用n_channels x 1卷积实现 self.spatial_conv nn.Sequential( nn.Conv2d(16, 16, kernel_size(n_channels, 1), biasFalse), nn.BatchNorm2d(16), nn.ReLU() ) self.dropout nn.Dropout(0.5) def forward(self, x): # x: (batch, 1, n_channels, n_samples) x self.time_conv(x) # 空间卷积后输出(batch, 16, 1, n_samples) x self.spatial_conv(x) x self.dropout(x) return x # 形状 (batch, 16, 1, n_samples)这里第一层时域卷积选用的是(1, 64)的卷积核不会跨通道让每个通道先独立表达自己的波形第二层空间卷积的核大小为(n_channels, 1)只做空间维度的融合不做时序混合。这样设计的好处是参数少、语义明确时间卷积学的是节律振荡模板空间卷积学的是导联组合权重。如果你直接用一个大卷积核同时在时间和空间上扫模型容量更高但很容易过拟合小样本EEG而且解释性差——你没法说清楚某个卷积核到底在找时间模式还是空间模式。3.2 Transformer分支把时间步当成tokens位置编码用相对位置Transformer分支的输入是CNN分支输出的特征序列。CNN分支的输出形状是(batch, feat_dim, 1, n_samples)经过维度重整后变成(batch, n_samples, feat_dim)也就是把每个时间点看作一个token每个token的特征维度是CNN提取的16维或更高看你怎么设置。这里有一个关键参数token长度。每个采样点250Hz下是4毫秒如果把750个采样点全部当tokenTransformer的计算复杂度是序列长度的平方750个token在普通显卡上跑起来会很吃力。常见的做法是先把时间维度做一次池化或步长卷积把序列长度降到125或150个token大约对应20毫秒一个token。这样既保留了时序精度又让注意力矩阵在一个可计算的范围内。位置编码是EEG上最容易踩坑的地方。标准的绝对正弦位置编码在EEG上表现一般因为运动想象的脑电特征不是按绝对时间点对齐的不同受试者、不同试次的节律相位本身就有漂移。我一般用相对位置编码让模型关注距离当前token 200毫秒之前和之后的关系而不是第300个采样点这种绝对位置。import torch import torch.nn as nn from torch.nn import TransformerEncoder, TransformerEncoderLayer class TransformerBranch(nn.Module): def __init__(self, feat_dim, nhead4, num_layers2, dropout0.1): super().__init__() self.input_proj nn.Linear(feat_dim, feat_dim) # 相对位置偏置表最大相对距离100个token self.rel_pos_bias nn.Parameter(torch.zeros(2 * 100 1)) encoder_layer TransformerEncoderLayer( d_modelfeat_dim, nheadnhead, dim_feedforwardfeat_dim * 4, dropoutdropout, batch_firstTrue ) self.encoder TransformerEncoder(encoder_layer, num_layers) def forward(self, x): # x: (batch, seq_len, feat_dim) seq_len x.size(1) x self.input_proj(x) # 相对位置偏置在实现中会加到注意力分数矩阵上 # 这里因为PyTorch原生未暴露需要在自注意力内修改或者采用已有实现 x self.encoder(x) # 取全局平均池化作为分类特征 return x.mean(dim1)逻辑说明这里把CNN输出特征经过一个线性投影后送入TransformerEncoder。rel_pos_bias是长度有限的相对位置偏置表表示当前token与另一token的相对距离在真正的实现中你需要把相对距离索引到这个偏置表并加到注意力分数上。为了演示简洁代码里没有展开完整的相对位置注意力计算实际实现时可以参考开源代码中relative position embedding的写法比如用torch.einsum或者自定义nn.Module重写前向。注意batch_firstTrue是PyTorch 2.x常用的设置这样输入形状好理解少转置不容易错。nhead4是一个保守值EEG特征维度本身不高头数太多每个头分到的维度太少注意力反而学不稳。如果feat_dim16我建议你提到32让每个头有8维输入收敛稳定度会好很多。3.3 融合层加法拼接都不够试试门控加权CNN分支和Transformer分支各自输出特征后怎么融合是一个经常被低估的问题。最土的做法是直接把两个分支的输出拼接起来过一个全连接层这样能工作但问题在于CNN分支的输出尺度跟Transformer分支的输出尺度经常不一致——CNN输出已经过了ReLU数值范围在[0, ∞)而Transformer的注意力加权输出接近均值为0的分布。两个尺度不一致的特征拼到一起全连接层很容易被大数值的分支主导。我试过几种融合方式效果排序大概是门控融合 拼接后加LayerNorm 直接相加 逐元素相乘。门控融合的做法是先让两个分支的特征各自过一个Linear层得到两个标量权重再用sigmoid把权重压到(0,1)之间最后加权求和。class GatedFusion(nn.Module): def __init__(self, feat_dim, n_classes): super().__init__() self.cnn_proj nn.Linear(feat_dim, feat_dim) self.tf_proj nn.Linear(feat_dim, feat_dim) self.gate nn.Linear(feat_dim * 2, 2) self.classifier nn.Linear(feat_dim, n_classes) def forward(self, cnn_feat, tf_feat): # cnn_feat与tf_feat都是(batch, feat_dim) gate_weights torch.sigmoid(self.gate(torch.cat([cnn_feat, tf_feat], dim-1))) fused gate_weights[:, 0:1] * cnn_feat gate_weights[:, 1:2] * tf_feat out self.classifier(fused) return out, gate_weights这里还有一个细节CNN分支的特征要做一次全局平均池化才能得到固定长度的向量而Transformer分支本身就是按序列输出后我们做了均值池化。两个分支的语义层次不同——CNN池化后的特征是局部模式的整体出现度Transformer池化后的特征是全局依赖关系的汇总。门控融合让模型自己决定当前输入更相信哪个分支这个权重本身也可以作为一个可视化维度用来观察不同类别的试次下模型更依赖局部还是全局。3.4 一个可跑通的最小模型结构PyTorch风格伪代码把上面三块串起来一个最小可跑的模型并不复杂。以BCI-IV-2a为例输入形状是(batch, 1, 22, 750)22个通道750个采样点。class FusedEEGNet(nn.Module): def __init__(self, n_channels22, n_samples750, n_classes4): super().__init__() self.cnn CNNBranch(n_channels, n_samples) # CNN输出形状 (batch, 16, 1, 750)我们压缩为(batch, 750, 16) # 做一个步长1的池化把序列缩短到150 self.temporal_pool nn.AvgPool2d(kernel_size(1, 4), stride(1, 4)) self.transformer TransformerBranch(feat_dim16) self.fusion GatedFusion(feat_dim16, n_classesn_classes) def forward(self, x): # x: (batch, 1, n_channels, n_samples) cnn_out self.cnn(x) # (batch, 16, 1, 750) pooled self.temporal_pool(cnn_out) # (batch, 16, 1, 187) # 转成序列形式 seq pooled.squeeze(2).transpose(1, 2) # (batch, 187, 16) tf_feat self.transformer(seq) # (batch, 16) cnn_feat pooled.squeeze(2).mean(dim2) # (batch, 16) out, gate self.fusion(cnn_feat, tf_feat) return out, gate这个模型参数量很小在GTX 1060这种老显卡上也能跑得动。AvgPool2d把750降采样到187个token这个长度对Transformer来说是舒服的。如果你显存够也可以不降采样但训练速度会明显变慢。另外要注意TransformerBranch里的input_proj输入输出维度都是feat_dim16如果你想增加容量可以在CNN分支里把特征数从16提到32对应这里的feat_dim也要改。4. 训练参数与调参路线这批EEG数据吃哪一套默认值4.1 损失函数与标签不平衡Focal Loss不是必须但类别权重是运动想象四分类在公开数据集上类别基本均衡每个类别都是48次试次直接用交叉熵损失就行。但换了自采数据往往会出现某一类试次数偏少因为受试者对某些想象任务的成功率低或者标记丢失。这时候第一个要调的不是损失函数而是类别权重。PyTorch里CrossEntropyLoss自带weight参数可以直接传入每个类别的样本数倒数。比如自采数据中左手类有100次右手类50次权重设为[0.5, 1.0, ...]。不要一上来就上Focal Loss——它需要调两个超参数gamma和alpha在EEG小样本上很容易把置信度调成一团浆糊。我见过不少项目在类不平衡不严重的情况下用Focal Loss结果准确率反而掉了两个多点就是因为gamma调大了把难样本的梯度过度放大而EEG里很多难样本本身是噪声。另外还有一个选择用加标签平滑的交叉熵。运动想象EEG存在大量人工标注的边界噪音有些试次可能是受试者根本没想象到位标签平滑给模型一点容错空间能避免过度自信。平滑系数设0.1比较合适。我看过在BCI-IV-2a上标签平滑让准确率涨了1.5个点的案例但不要设太大否则类别间的决策边界会被压平。4.2 学习率、Batch Size与EpochEEG上的经验区间我在这类融合模型上试过的组合不算少比较稳的默认值是这样的优化器AdamWweight_decay设1e-4。初始学习率3e-4配合余弦退火调度。EEG模型一般不需要像图像分类那样大的初始学习率因为特征维度低、样本少用1e-3很容易在第一轮就把loss打飞。Batch Size32或64。BCI-IV-2a一个受试者只有288次试次batch64意味着一次迭代只看到4个batch加上数据增强的随机性一个epoch可能不够稳定。我倾向于batch32让梯度更新更频繁。Epoch50到80。融合模型不是越训越好通常在第20到40个epoch之间验证准确率会到一个平台。我习惯每5个epoch存一次checkpoint不要等最后才保存。学习率这一块有个血泪教训Transformer分支对学习率比CNN分支敏感得多。如果两个分支共用同一个学习率CNN已经收敛了Transformer还在震荡。我建议给Transformer分支单独设一个较小的学习率比如主学习率的0.1倍或者在每个epoch给Transformer分支的学习率打一个0.95的衰减。用两个优化器分别管理两组参数代码上并不复杂cnn_params list(model.cnn.parameters()) tf_params (list(model.transformer.parameters()) list(model.fusion.parameters()) list(model.temporal_pool.parameters())) optimizer torch.optim.AdamW([ {params: cnn_params, lr: 3e-4}, {params: tf_params, lr: 3e-5}, ], weight_decay1e-4)如果发现Transformer分支仍然不收敛可以加一个warmup步骤前5个epoch把学习率从0线性升到目标值这样能让Transformer的层归一化和注意力投影先稳定下来避免开局震荡。4.3 交叉验证怎么划分按试次乱分就是作弊按被试或按天分才是真泛化这是一个反复被人忽视、但发论文和做产品都必须面对的问题。如果你把所有试次混在一起随机划分训练集和测试集同一个人的相邻试次可能同时出现在两边模型会通过试次的整体噪声水平或者同一session的伪迹模式作弊测试准确率虚高。我一般用两种划分方式之一按被试划分多受试者数据中直接拿某几个被试做测试集其他人做训练集。这种方式考验的是模型的跨被试泛化能力也是BCI竞赛中最接近真实场景的评价方式。按时间划分单被试数据中用前70%时间的试次做训练后30%做测试。这能避免同一个session相邻试次的泄漏。最不推荐的做法是随机打乱试次后做K折交叉验证。除非你的样本量非常少否则这种划分出来的结果没有说服力。我在自己的复现中发现随机划分的准确率比分被试或分时间高5到8个点这个数字看起来很爽但一旦你打算部署到新用户身上就会原形毕露。提示如果一定要做K折建议按连续块划K折比如把整个session分成K段连续时间区间每段内部不混合训练集和测试集之间留出至少1秒的间隔。这样至少能挡掉一部分时间泄漏。5. 避坑与排查运动想象EEG分类里常见的五个坑5.1 坑一模型在训练集上99%验证集却只有60%大概率是泄漏了EEG分段边界现象训练准确率接近满分验证准确率上不去或者验证loss先降后升。我见过最典型的案例是把每个试次用滑窗切成多个小段直接随机分入训练集和验证集结果同一试次的片段在两边几乎一模一样模型记住了试次ID而不是运动想象的模式。换个受试者就现原形。原因分段窗口有重叠或者训练集和验证集来自同一个试次的重叠片段。滑窗增强虽然增加了样本量但破坏了试次独立性。如果不用滑窗只用完整试次则样本数太少很容易过拟合。解决把分段放在交叉验证划分之后。先按试次划分再对训练集内部做滑窗增强验证集只用完整试次绝不做任何重叠采样。如果已经发生过泄漏别想着靠加Dropout补救回去改数据管线才是正路。还有一个自查方法随机抽10个训练样本观察它们的标签是否与验证集某个样本是连续时间段。如果出现这个试次的前半段在训练后半段在验证泄漏已经发生了。5.2 坑二Transformer分支怎么训都不收敛先查位置编码和初始化现象loss卡在一个高位或者验证准确率一直在一个类别附近。有次我训练融合模型CNN分支单独跑能到75%接上Transformer后全程loss在1.4附近抖动怎么调学习率都没用。原因有两类。一类是位置编码用了正弦绝对位置在EEG这种高噪声信号上注意力很难对齐另一类是初始化不当TransformerEncoderLayer里默认的初始化方法对超长序列不够友好尤其是输入特征没有被归一化时注意力分数容易饱和在softmax的平坦区。解决换成相对位置编码或者干脆把序列长度降到100以下。初始化上检查nn.TransformerEncoder的norm参数是否给了LayerNorm以及feat_dim是否被head数整除。我的经验是feat_dim16nhead4时每头只有4维信息太少建议把feat_dim提到32nhead4每头8维收敛会明显顺畅。还有一个玄学点如果输入序列长度是187这种不被任何数整除的长度注意力矩阵的形状会让某些实现报错最好把序列长度池化到128或192这在代码里就是AvgPool2d的kernel_size改一下的事。5.3 坑三CNN分支输出被Transformer分支的softmax吞掉梯度消失现象融合模型能训练但CNN分支的梯度几乎为零checkpoint里CNN层的权重基本没变化。从训练日志看验证准确率只比随机高一点点而且无论怎么加epoch都上不去。原因在Transformer分支里注意力权重经过softmax后是(0,1)区间的分布如果CNN分支的输出直接作为token输入并且没有做归一化那么CNN输出的数值范围可能很大进入softmax后概率被压平反向传播到CNN的梯度就变得极小。解决在CNN分支输出进入Transformer之前加一个LayerNorm层把特征拉回均值0方差1。同时检查门控融合里的gate权重是不是一直偏向Transformer那一侧如果是说明CNN分支的梯度确实没传上来。可以在训练代码里注册一个hook打印两个分支参数的梯度范数长期小于1e-5就该查归一化了。5.4 坑四可视化结果全是色块看不出差别问题在归一化方式现象画出来的注意力热图或者Grad-CAM图是一整片红色或者一整片蓝色完全看不出哪个区域对分类贡献大。甚至有次我画出来的topoplot除了中间一个亮点周围全是深蓝怎么看都像采集故障但模型准确率又正常。原因特征图数值范围差异过大用matplotlib的默认色彩映射时一两个极端值把颜色标尺拉伸了其余区域自然变成同一颜色。比如Grad-CAM里某个通道梯度是100其他是0.1归一化后所有小于1的值都被压到蓝色热图自然废了。解决在可视化前先对特征图做逐样本的min-max归一化再把颜色下限设为第5百分位数、上限设为第95百分位数。另外EEG的注意力热图应该画在头皮空间上也就是22个通道的位置你最好有一个标准的通道坐标表比如sensor_positions再把注意力值通过插值扩散到整个头皮。热图上有明显高亮区域且随类别变化才算可视化成功。还有一个技巧画topoplot时用griddata插值到网格同时把电极点用白色散点标出来这样能直观看到高亮区域落在C3还是C4。5.5 坑五自采数据上效果崩盘检查电极位置和对齐现象在公开数据集上做得好好的模型迁移到自采数据上准确率掉到略高于随机。这不是模型玄学而是信息对齐问题。原因电极位置摆放不一致、采样时间戳不同步、以及事件标记的延迟。BCI公开数据的通道顺序是标准排列但你的采集软件导出的可能是参考电极被移除后的重排甚至左右半脑顺序颠倒。还有一种常见情况是采集时的采样率标称250Hz但实际时钟有抖动导致750个采样点并不严格对应3秒。解决先用数据清单核对通道名称和顺序再画一次原始信号的topoplot确认C3、C4、Cz这些关键通道的位置跟想象任务对应。训练前用公共平均参考CAR重参考一遍能减小不同采集设备之间的差异。再有一个容易忽略的点如果采集时用了50Hz陷波要确认滤波管线和训练时的管线一致否则模型的频率特征全部错位。时间戳对齐这一步没法省我一般会用外部事件同步信号校正一次保证trial_start的精度至少达到一个采样周期4ms。6. 可视化实现的三种落地方式从注意力热图到决策解释6.1 注意力权重可视化把Transformer的Attention Map贴到头皮位置最直接的可视化来自Transformer分支自身的注意力权重。推理时把注意力权重抽出来对注意力头取平均得到时间token之间的注意力矩阵再按通道映射到头皮坐标。具体是把每个时间token的注意力沿通道维度累加用griddata插值画出topoplot。import numpy as np def plot_attention_topomap(attention_matrix, sensor_positions, time_idx): # attention_matrix: (n_heads, seq_len, seq_len) attn attention_matrix.mean(axis0) # 平均所有头 attn_slice attn[time_idx, :] # 当前时间点对所有历史token的注意力 # 把token维注意力映射到22个通道的坐标空间 # 这里用线性插值实际可以用griddata channel_weights np.interp( np.linspace(0, 1, 22), np.linspace(0, 1, len(attn_slice)), attn_slice ) return channel_weights注意平均头可能掩盖差异最好单独看第一头和最后一头运动想象中不同注意力头往往对应不同的时间尺度。画出图来如果高亮集中在运动皮层对应通道说明Transformer找到了生理依据。6.2 类别激活映射与梯度热图对CNN分支做Grad-CAMTransformer注意力表达的是序列内部关系对分类决策的局部证据不够直观。补充做法是对CNN分支做Grad-CAM。取目标类别的得分回传到CNN分支最后一层卷积特征图计算每个通道的梯度均值作为权重def grad_cam(model, x, target_class): model.eval() x.requires_grad_() out, _ model(x) score out[0, target_class] score.backward() # 通过hook拿到CNN最后一层输出和梯度 cnn_feat model.cnn_activations # 注册的hook grads model.cnn_activations.grad weights grads.mean(dim-1, keepdimTrue) cam torch.relu((weights * cnn_feat).sum(dim1)) return cam.squeeze().detach().numpy()Grad-CAM图上高亮区域如果集中在手运动对应的C3或C4侧说明模型找到了合理的神经生理依据而不是在识别伪迹。这两类可视化结合使用比单一注意力图更能解释融合模型的决策。我过去常犯一个错误把可视化留到模型完全训好才动手结果发现效果不好要重新调模型结构前功尽弃。现在的习惯是把可视化和训练管线一起搭每5个epoch存一次注意力图和Grad-CAM图观察模型从全局乱看变成聚焦运动区基本就能判断训练方向对不对。这个习惯帮我省了至少两周的返工时间也希望帮你在做运动想象脑电分类时少走几个弯路。本文还有配套的精品资源点击获取
返回列表