CNN与Transformer融合:运动想象脑电信号分类实战解析
简介一份基于Transformer的运动想象脑电信号分类毕设项目采用CNNTransformer框架结合Grad-CAM实现脑电地形图可视化面向计算机、通信、人工智能、自动化等相关专业学生与从业者可作为毕业设计、课程设计或大作业参考。压缩包共31个文件以Python源码为主23个py另有MATLAB预处理脚本、Excel结果统计、模型权重、npy数据文件及说明文档整体约18.45MB。代码经调试可运行答辩评审98分已有338人学习下载。CNN负责局部时空特征Transformer建模全局依赖并包含EEGNet、CSP、Morlet小波、tSNE等对比与可视化模块。适合小白入门及在此基础上二次开发。1. 从脑电到运动想象为什么这个题目值得认真做运动想象脑电分类是脑机接口里最经典也最棘手的问题之一受试者想象左手、右手、双脚或舌头运动不真的动只在大脑皮层产生对应的感觉运动节律变化算法要从几十毫伏级别的EEG信号里把这些意图认出来。传统做法用CSP共空间模式手工提特征效果天花板明显深度学习流行之后EEGNet这类CNN结构把端到端学习带了进来。而Transformer凭借自注意力机制在序列建模上的优势这几年也被大量引入EEG分析本科毕业设计里把CNN和Transformer放一起做对比、做融合是当前性价比最高的选题方向之一——数据有公开benchmark代码有成熟开源基线理论深浅适中四类运动想象分类在标准数据集上做到70%以上准确率并不难写到论文里工作量也扎实。不过要提前说一个反直觉的结论直接照搬ViT那一套把信号切成patch扔进Transformer在四类运动想象任务上往往打不过精心调参后的CNN基线。原因不是Transformer不行而是EEG信号样本量小、信噪比低、个体差异大注意力矩阵很容易被噪声主导。真正有效的路径是把CNN的局部特征提取能力和Transformer的长程依赖建模结合起来这个写作过程我会给出可复现的最小方案、参数设置和踩坑记录。如果你正在跑这个方向这篇笔记能帮你少走两周弯路。2. 运动想象脑电数据从哪来、怎么预处理先搞清楚要喂给模型什么2.1 BCI Competition IV 2a 数据集的结构与读取方式做运动想象分类99%的本科毕设会落在公开数据集上最常用的就是BCI Competition IV dataset 2a。这个数据集包含9个受试者每人做5轮实验每轮有4类运动想象任务左手、右手、双脚、舌头总共288个试次trial。每个试次记录开始后0到2秒是提示准备阶段2秒时出现运动想象指令想象持续4秒最后2秒休息。EEG用22个Ag/AgCl电极按国际10-20系统排布采样采样率250Hz。这个数据集最方便的地方在于它已经划分好了训练集和测试集每个受试者的数据被拆成训练部分和评估部分各288个试次。你需要自己做的只是读取、滤波、切epoch、归一化。用mne库读数据是标准做法import mne import numpy as np # 读取单受试者原始数据 raw mne.io.read_raw_gdf(A01T.gdf, preloadTrue) raw.set_eeg_reference(average, projectionFalse) # 全脑平均参考 # 带通滤波运动想象的有效成分集中在8-30Hz raw.filter(8, 30, methodiir, pickseeg) # 提取事件标签运动想象指令对应的event_id events, event_id mne.events_from_annotations(raw) print(event_id) # 通常769左手, 770右手, 771脚, 772舌头 # 切epoch从指令出现后0.5s到3.5s共3秒数据 epochs mne.Epochs( raw, events, event_id, tmin0.5, tmax3.5, baselineNone, pickseeg, preloadTrue ) # 取数据并转成numpy数组 # 返回形状: (n_trials, n_channels, n_times) X epochs.get_data() y epochs.events[:, -1] - 769 # 标签归一到0,1,2,3滤波这一段值得展开说。运动想象引起的mu节律8-12Hz和beta节律13-30Hz是分类的主要依据所以把带通范围设在8-30Hz是安全的。但如果你打算用Transformer做全频段分析可以考虑保留更宽的频段比如4-38Hz让注意力机制自己去关注频率成分。这里用IIR滤波是因为数据量不大滤波器阶数可以设高一些过渡带更窄如果使用FIR需要小心边界效应对短epoch的影响。切epoch时注意两点一是基线校正这里直接关了因为前面已经做了带通滤波直流漂移基本被滤掉了二是起始点设在指令后0.5秒而不是0秒因为受试者对指令做出运动想象反应本身有延迟前500ms内大多是视觉诱发电位和准备电位对分类是干扰。2.2 数据增强与归一化小样本条件下让Transformer吃饱运动想象分类的小样本困境非常突出。单受试者训练集只有288个试次分到4类每类只有72个样本这点数据对CNN还算友好但对Transformer来说明显不够。数据增强有两个常用手段滑动窗口裁剪和噪声注入。滑动窗口裁剪的思路是把一个3秒的epoch按时间轴切成多个有重叠的子窗口每个窗口单独作为一个训练样本。比如窗口长度2秒、步长0.2秒一个3秒的epoch能切出6个样本。推理时对同一epoch切出的所有窗口预测结果做平均投票还能稳定测试效果。def sliding_window_crop(X, window_len, stride): 对X做时间维度的滑动窗口裁剪 X: (n_trials, n_channels, n_times) 返回: (n_trials, n_windows, n_channels, window_len) n_trials, n_channels, n_times X.shape n_windows (n_times - window_len) // stride 1 windows np.zeros((n_trials, n_windows, n_channels, window_len)) for i in range(n_trials): for j in range(n_windows): start j * stride end start window_len windows[i, j, :, :] X[i, :, start:end] return windows # 使用把3秒(750点)裁成2秒(500点)窗口步长0.4秒(100点) X_windows sliding_window_crop(X, window_len500, stride100) # 重塑成二维样本用于训练 X_train X_windows.reshape(-1, X_windows.shape[2], X_windows.shape[3]) y_train np.repeat(y, X_windows.shape[1])归一化这里有个容易翻车的细节。标准做法是z-score标准化(x - mean) / std但mean和std应该只用训练集的统计量计算再应用到验证集和测试集。如果你对整个数据集一起算mean和std会有轻微的数据泄漏虽然对分类准确率影响不大但在论文中容易被答辩老师抓住把柄。from sklearn.preprocessing import StandardScaler # 先切好训练和测试集再做标准化 train_samples X_windows.reshape(-1, X_windows.shape[2] * X_windows.shape[3]) test_samples X_test.reshape(X_test.shape[0], X_test.shape[1] * X_test.shape[2]) scaler StandardScaler() train_norm scaler.fit_transform(train_samples) test_norm scaler.transform(test_samples) # 注意这里不是fit_transform # 还原为2D形状 (n_samples, n_channels, n_times) X_train_norm train_norm.reshape(-1, n_channels, window_len) X_test_norm test_norm.reshape(-1, n_channels, test_window_len)另一个增强手段是加噪声。给训练样本加上小幅度高斯噪声标准差取原始信号标准差的1%到2%相当于给模型做了个正则化对Transformer这类参数量大的模型很有帮助。这不是什么高深操作但实测能把准确率稳定提升1到2个百分点。3. 用CNN搭运动想象基线EEGNet结构拆解与PyTorch实现3.1 为什么运动想象任务里CNN仍然是硬基线任何一种新结构要让人信服都得先有打得过的基线。运动想象分类的深度学习基线几乎绕不开EEGNet它的设计思路是先用时间卷积在每个电极上独立提取频段特征再用深度卷积融合空间维度上所有电极的信息最后用逐点卷积做分类。整个过程只需要几千个参数在单受试者小样本上几乎不过拟合而且效果稳定得让人惊讶。从原理上说CNN适合EEG信号是因为运动想象的神经生理基础——事件相关去同步/同步ERD/ERS——本质上是特定频段功率在特定脑区的变化这对应着时域上的局部模式。第一层时间卷积等效于一组可学习的带通滤波器第二层空间卷积把不同电极在同一时刻的激活模式组合起来正好对应着感觉运动皮层在想象不同肢体动作时的空间拓扑差异。用PyTorch实现一个简化版EEGNet核心代码大概这样import torch import torch.nn as nn class EEGNet(nn.Module): def __init__(self, n_channels22, n_times500, n_classes4, F18, D2, F216): super().__init__() self.n_times n_times # 第一块时间卷积 深度卷积深度可分离 self.block1 nn.Sequential( # 每个电极独立做时间卷积提取频段特征 nn.Conv2d(1, F1, (1, 64), padding(0, 32), biasFalse), nn.BatchNorm2d(F1), # 深度卷积对每个特征图做空间卷积融合电极信息 nn.Conv2d(F1, F1 * D, (n_channels, 1), groupsF1, biasFalse), nn.BatchNorm2d(F1 * D), nn.ELU(), nn.AvgPool2d((1, 4)), nn.Dropout(0.5) ) # 第二块逐点卷积降维 分类 self.block2 nn.Sequential( # 逐点卷积跨特征图融合 nn.Conv2d(F1 * D, F2, (1, 1), biasFalse), nn.BatchNorm2d(F2), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Dropout(0.5), nn.Flatten() ) # 计算全连接层输入维度 self._calculate_fc_input(n_channels, n_times) self.classifier nn.Linear(self.fc_input, n_classes) def _calculate_fc_input(self, n_channels, n_times): x torch.zeros(1, 1, n_channels, n_times) x self.block1(x) x self.block2(x) self.fc_input x.shape[1] * x.shape[2] def forward(self, x): x x.unsqueeze(1) # (B, 1, C, T) x self.block1(x) x self.block2(x) return self.classifier(x) # 实例化模型 model EEGNet(n_channels22, n_times500, n_classes4) print(f参数量: {sum(p.numel() for p in model.parameters())}) # 前向测试 x_tmp torch.randn(4, 22, 500) print(model(x_tmp).shape) # torch.Size([4, 4])这段代码里有几个参数需要重点关注。时间卷积核大小64对应采样率250Hz下0.256秒的窗口刚好覆盖mu节律一个周期多一点F18表示用8个不同的时间滤波器深度卷积的groupsF1把每个特征图独立做空间卷积参数量只和电极数有关这是EEGNet参数量极小的关键池化层把时间维度逐步压缩到原来的1/32让最后的全连接层输入维度非常小。设置参数时有个血泪经验F1、D、F2这三个超参对结果影响很大。EEGNet原始论文里F18、D2、F216是经过多数据集验证的默认值但如果你的epoch窗口长度和我这里不一样池化后的时间维会变需要相应调整。一个常见翻车点是时间卷积padding算错导致输出长度不对建议先用_calculate_fc_input里的零张量前向逻辑验证维度再开始训练。3.2 CNN训练策略小样本下怎么调学习率与batch sizeEEGNet在小样本上训练标准配置是Adam优化器学习率0.001到0.01之间batch size设在16到32。这里有一个很微妙的问题是运动想象数据受试者间方差极大同一个超参在不同人身上效果可能差异很大。我习惯的做法是先用默认配置跑一遍9个受试者记录每个人的验证准确率再针对效果差的受试者单独调学习率。from torch.utils.data import DataLoader, TensorDataset device torch.device(cuda if torch.cuda.is_available() else cpu) model EEGNet(n_channels22, n_times500, n_classes4).to(device) dataset TensorDataset( torch.FloatTensor(X_train_norm), torch.LongTensor(y_train) ) dataloader DataLoader(dataset, batch_size16, shuffleTrue) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.005) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max80) # 训练循环 epochs 80 for epoch in range(epochs): model.train() total_loss 0 for batch_x, batch_y in dataloader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() outputs model(batch_x) loss criterion(outputs, batch_y) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() # 余弦退火逐步降低学习率 if (epoch 1) % 20 0: print(fEpoch {epoch1}/{epochs}, Loss: {total_loss/len(dataloader):.4f})余弦退火学习率调度在小样本训练里特别管用它让模型在前半段大步探索后半段慢慢收敛比固定学习率多跑出1到2个百分点。batch size选16有讲究这个数据集本身的类别分布是均衡的batch里大概率每个类别都有样本梯度更新方向更稳定。不要用太大batch size因为运动想象数据样本量本来就少一个batch如果超过32梯度会被少数异常样本主导。4. 把Transformer落到脑电上位置编码、注意力头与CNN混合框架4.1 位置编码怎么选绝对位置编码和EEG场景的冲突Transformer不像CNN那样天然具备空间位置感知能力它通过位置编码把时序信息注入到输入序列中。但在运动想象EEG里位置编码的选择是个容易被低估的问题。先看Transformer手写实现里最常见的做法——Sinusoidal绝对位置编码。它是用不同频率的正弦和余弦函数为每个位置生成一个固定向量def sinusoidal_positional_encoding(seq_len, d_model): 标准Sinusoidal位置编码 seq_len: 序列长度 d_model: 特征维度 返回: (seq_len, d_model) pe torch.zeros(seq_len, d_model) position torch.arange(0, seq_len, dtypetorch.float).unsqueeze(1) div_term torch.exp( torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model) ) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) return pe # 示例250Hz采样率下1秒信号d_model64 pe sinusoidal_positional_encoding(250, 64) print(pe.shape) # torch.Size([250, 64])这种位置编码的问题在于它是绝对位置——它告诉模型这里是第35个时间点而不是这里距离上一个关键事件间隔了多久。运动想象的判别信息很大程度依赖事件发生后特定时间窗内ERD/ERS的变化比如想象开始后0.5秒到2秒是mu节律最明显的抑制期。Sinusoidal绝对位置编码含有的相对距离信息需要模型自行从attention矩阵中学习在小样本条件下这个学习过程容易在训练中期崩溃。实际工程里我最常用的方案是先用绝对位置编码做基线如果验证集准确率和纯CNN比没有明显优势就换成可学习的相对位置编码如relative attention bias。但在本科毕设场景下更务实的选择是干脆不用额外实现相对位置编码——直接把CNN做前端让卷积层负责粗粒度的时间特征提取Transformer只对CNN输出的特征图建模时序依赖。这样位置编码的压力被大幅减轻。4.2 把EEG信号组织成序列输入patch划分与嵌入维度Transformer的输入需要一个(seq_len, d_model)的二维序列而原始EEG是(channels, time)的二维矩阵需要设计一个映射方案。常见做法有三种把每个电极当作一个token把每个采样点当作token或者先做时间维度的patch化。逐个电极做token的方式最直观序列长度就是电极数量22但信息粒度太粗每个token里包含着完整时长的时间序列Transformer要自己从高维向量里找线索逐采样点做token则序列长度直接爆炸250Hz采样率下一秒就250个token计算负担大且训练样本不够。最实用的方案是时间patch化把每个电极信号按时间轴切成若干个长度为patch_len的小段每个小段经过一个线性映射变成embedding再拼上位置编码。class EEGPatchEmbedding(nn.Module): def __init__(self, n_channels22, patch_len125, d_model64): 将每个电极信号按时间切成patch线性映射到d_model维度 n_channels: 电极数 patch_len: 每个patch包含的时间点个数 d_model: Transformer输入维度 super().__init__() self.n_channels n_channels self.patch_len patch_len self.proj nn.Sequential( nn.Linear(n_channels * patch_len, d_model), nn.GELU(), nn.Linear(d_model, d_model) ) def forward(self, x): x: (B, C, T)eeh信号 返回: (B, seq_len, d_model) B, C, T x.shape n_patches T // self.patch_len # 切patch并整理形状 x x[:, :, :n_patches * self.patch_len] x x.view(B, C, n_patches, self.patch_len) x x.permute(0, 2, 1, 3) # (B, n_patches, C, patch_len) x x.reshape(B, n_patches, C * self.patch_len) x self.proj(x) return x # (B, n_patches, d_model) # 使用示例3秒数据750点patch_len125即0.5秒一个patch共6个patch embed EEGPatchEmbedding(n_channels22, patch_len125, d_model64) x_tmp torch.randn(4, 22, 750) x_embed embed(x_tmp) print(x_embed.shape) # torch.Size([4, 6, 64])patch_len的选择直接决定Transformer看到的时序分辨率。125个点对应0.5秒这个尺度能覆盖mu节律的两个完整周期如果缩到62个点0.25秒序列长度翻倍到12能捕捉更细的时序变化但对噪声更敏感。实测下来在BCI 2a数据集上patch_len125效果最稳因为运动想象的ERD/ERS效应本身就是秒级变化过细的时间分辨率反而引入噪声。通道拼接有个容易被忽视的点——所有电极的同一时间窗的patch拼在一起做线性映射相当于让embedding层同时做了一次空间特征融合这和CNN里的逐点卷积功能类似。因此在混合框架里Transformer前端的CNN输出通道数可以适当降低。4.3 CNN和Transformer怎么融合先局部后全局的级联架构把EEG信号组织成序列之后Transformer结构本身直接用标准编码器就行。这里的难点在于CNN和Transformer之间的衔接以及整体架构怎么组织才不至于在小样本上过拟合。我搭过几种方案把效果最好的一个贴出来CNN浅层负责提取局部时频特征并降采样把Transformer编码器放在压缩后的特征序列上。class CNNTransformerEEG(nn.Module): CNN提取局部时频特征 Transformer建模全局时序依赖 def __init__(self, n_channels22, n_times750, n_classes4, d_model64, nhead4, num_layers2): super().__init__() # CNN前端类似EEGNet的前两层只做特征提取不分类 self.cnn_front nn.Sequential( nn.Conv2d(1, 8, (1, 64), padding(0, 32), biasFalse), nn.BatchNorm2d(8), nn.Conv2d(8, 16, (n_channels, 1), groups8, biasFalse), nn.BatchNorm2d(16), nn.ELU(), nn.AvgPool2d((1, 4)), # 时间维缩到 187 nn.Dropout(0.3) ) # Patch Embedding把16个通道的CNN特征按时间切成patch self.pool_scale 4 self.n_times_pooled n_times // self.pool_scale self.patch_len 31 # 池化后的31点约为原125点 self.patch_embed nn.Linear(16 * self.patch_len, d_model) # Transformer编码器 self.position_enc nn.Parameter( torch.randn(1, self.n_times_pooled // self.patch_len, d_model) ) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward128, dropout0.1, batch_firstTrue ) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) # 分类头取[CLS]位置第一个token做分类 self.classifier nn.Sequential( nn.LayerNorm(d_model), nn.Linear(d_model, n_classes) ) def forward(self, x): B x.shape[0] # CNN特征提取 x x.unsqueeze(1) x self.cnn_front(x) # (B, 16, 1, T/4) x x.squeeze(2) # (B, 16, T/4) # 切patch并转成token B, C, T x.shape n_patches T // self.patch_len x x[:, :, :n_patches * self.patch_len] x x.view(B, C, n_patches, self.patch_len) x x.permute(0, 2, 1, 3).reshape(B, n_patches, -1) # (B, n_patches, C*patch_len) x self.patch_embed(x) # (B, n_patches, d_model) # 加位置编码进Transformer x x self.position_enc[:, :n_patches, :] x self.transformer(x) # (B, n_patches, d_model) # 取第一个token做分类 x self.classifier(x[:, 0, :]) return x # 实例化并测试维度 model CNNTransformerEEG(n_channels22, n_times750, n_classes4) x_tmp torch.randn(4, 22, 750) print(model(x_tmp).shape) # torch.Size([4, 4]) print(f参数量: {sum(p.numel() for p in model.parameters())})这个架构的设计逻辑分三层。CNN前端用的是深度可分离卷积参数量和EEGNet近似但只做到中间层不接分类头它输出的16个特征图在时间维上被压缩了4倍把250Hz的原始信号粗化为约62Hz的低维特征patch_embed层把这些特征组织成序列token16个通道乘以31个时间点拼成一个509维向量线性映射到64维Transformer编码器用两层、4个注意力头在6个token上建模全局依赖最后取第一个token过分类头。注意力头数和层数的设置要特别小心。这里用了4个注意力头和2层编码器比标准Transformer小很多原因是运动想象分类从本质上不是特别复杂的序列到序列任务它只需要捕捉几十毫秒到几秒尺度的节律动态过大的模型在这个数据量上纯属浪费。nhead4意味着注意力被分成4个子空间分别关注不同时间尺度上的依赖关系——有的关注相邻patch的局部变化有的关注首尾patch的跨时间联系。一个小细节是关键技巧分类头只用第一个token。这省去了额外实现CLS token的步骤因为位置编码时第一个token已经隐式聚合了所有patch的信息——Transformer编码器在forward过程中每个position都会attend到全局无论取哪个token理论上都可以但取第一个token的梯度传播路径最干净因为它是所有attention的汇聚点。4.4 混合框架的训练参数比纯Transformer少一半的epoch数这个混合网络在小样本上的训练配置和纯CNN差别较大。因为Transformer部分存在软注意力聚合的前置学习阶段batch size太小会让注意力权重的分布非常不稳定。建议batch size设为24到32比EEGNet略大。学习率用Adam时从0.001开始配合OneCycleLR策略做warmup和衰减。from torch.optim.lr_scheduler import OneCycleLR epochs 60 # 混合框架收敛更快epoch数可以少于纯Transformer optimizer torch.optim.AdamW(model.parameters(), lr0.001, weight_decay5e-4) scheduler OneCycleLR( optimizer, max_lr0.003, total_stepsepochs * len(dataloader), pct_start0.3, anneal_strategycos ) for epoch in range(epochs): model.train() for batch_x, batch_y in dataloader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() output model(batch_x) loss nn.functional.cross_entropy(output, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪防抖 optimizer.step() scheduler.step()有一个值得注意的观察训练到中期大约30轮前后验证准确率会出现一次跳变这通常是注意力机制从近似均匀分布切换到任务相关分布的时刻。在这个拐点之前过早保存的模型权重往往很差所以建议跑完完整epoch数后选验证集最优的那个checkpoint而不是每个epoch都记录模型。5. 运动想象分类的5个高频踩坑记录每个都是真实翻车现场5.1 数据泄漏StandardScaler拟合了全量数据现象验证集准确率出奇地高训练集和验证集都超过95%但换到测试集上准确率骤降到60%。原因是在做归一化时对全部样本一起做了fit_transform让标准化过程偷看到了验证集和测试集的均值和方差。解决严格按先划分数据集再对训练集fit对验证集和测试集transform的顺序执行。归一化参数只能来自训练集。5.2 时间卷积核大小不对导致判别频率偏移现象模型在单受试者上表现不错但换到另一个受试者上准确率暴跌10个百分点以上。原因是运动想象的mu节律频率在不同受试者之间有差异有人偏8Hz有人偏12Hz而时间卷积核尺寸固定就约等于一个固定带通滤波器。解决把第一层卷积核设大一些比如80到96个时间点0.32到0.38秒让一个卷积核覆盖更宽的频率范围或者把原始信号滤波范围放宽到4到40Hz把频率选择交给模型而不是预处理阶段。5.3 训练Transformer时梯度爆炸导致loss变成NaN现象训练到第10个epoch附近loss突然变成NaN之后无论怎么调学习率都无法恢复。原因Transformer编码器中的LayerNorm在前向传播时如果遇到极端激活值数值稳定性比BatchNorm差加上学习率偏大梯度一冲就飞了。解决启用梯度裁剪clip_grad_norm_(max_norm1.0)同时把学习率降到0.0005以下如果还炸就把dim_feedforward从默认的2048降到128到256。5.4 注意力可视化全部均匀分布看不出任务相关模式现象训练完成后把注意力权重矩阵画出来发现几乎每个token对其他token的注意力权重都差不多是1/N看不出任何结构性。原因模型根本没学到注意力为什么要有偏向因为任务简单到只需要某个特定token里的局部特征就能分类不需要跨长距离聚合信息。解决如果纯Transformer出现这个现象说明CNN前端已经把关键特征提取得太好了Transformer只是起了个线性分类的作用这时可以考虑删掉Transformer部分或者加深CNN前端并让Transformer的输入特征维度更低比如d_model从64降到32。5.5 随机种子对结果影响大到今天能跑出80%明天只有70%现象固定了torch.manual_seed(0)但每次运行结果还是不同。原因一个是GPU上的矩阵运算本身有非确定性另一个是数据加载的shuffle顺序播了不同的随机数种子。解决在训练前设置torch.use_deterministic_algorithms(True)并设置所有相关seed如果模型参数量小、训练速度快还可以采用多个随机种子训练取平均结果的策略——跑5个种子每个训练一次最终预测取五次的平均概率分布。这个策略对运动想象数据特别有价值因为单次训练的随机性在小样本下被放大了。6. 验证结果的三板斧混淆矩阵、交叉验证和模型可解释性模型训练完离一篇合格的毕业论文还差最后一步怎么验证模型真的学到了运动想象的神经机制而不是在数据上硬背答案。第一板斧是混淆矩阵。四类运动想象分类里最容易混淆的通常是左手vs右手其次是脚vs舌头。如果你的混淆矩阵里左脚和右脚错分极其严重先不要急着调模型回到数据增强环节检查受试者本身的运动想象能力——BCI 2a数据集中有部分受试者对某两类任务的区分度天然很差这不是模型的问题是大脑信号里本来就没差。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in test_loader: batch_x batch_x.to(device) outputs model(batch_x) preds torch.argmax(outputs, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch_y.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) # 打印每个类别的precision/recall/F1 print(classification_report(all_labels, all_preds))第二板斧是交叉验证策略选择。BCI 2a数据集的9个受试者之间差异非常大常见做法是受试者内交叉验证每个人的数据单独切训练/测试以及受试者间迁移验证用前8个人的数据训练测第9个人。这两条路线的分数差别能揭示你的模型到底学到了普适特征还是受试者特异的模式。如果受试者间验证准确率只有30%多略高于随机25%也不用灰心这在运动想象EEG领域是普遍难题跨受试者域适应本身就是脑机接口研究的核心主题。第三板斧是注意力权重可视化。把Transformer最后一层的注意力权重提出来按patch位置画成热度图看模型在做分类决策时重点关注的时序位置。如果注意力集中在运动想象指令后1秒到2.5秒的区间说明模型学到了合理的ERD/ERS时序模式这个可视化图片放在毕业论文里比任何指标都有说服力。# 提取单样本的注意力权重 def extract_attention(model, x, layer_idx-1): 返回第layer_idx层编码器的注意力权重 model.eval() with torch.no_grad(): x x.unsqueeze(0) # 手动走forward到Transformer层 x_cnn model.cnn_front(x.unsqueeze(1)).squeeze(2) B, C, T x_cnn.shape n_patches T // model.patch_len x_patch x_cnn[:, :, :n_patches * model.patch_len] x_patch x_patch.view(B, C, n_patches, model.patch_len) x_patch x_patch.permute(0, 2, 1, 3).reshape(B, n_patches, -1) x_token model.patch_embed(x_patch) x_token x_token model.position_enc[:, :n_patches, :] # 手动调用每一层编码器并捕获注意力矩阵 attn_weights [] for layer in model.transformer.layers: x_token, attn layer.self_attn( x_token, x_token, x_token, need_weightsTrue, average_attn_weightsFalse ) attn_weights.append(attn) return attn_weights[layer_idx] # 取一个右手想象的样本看注意力分布 sample X_test_norm[5].unsqueeze(0) attn extract_attention(model, sample) print(attn.shape) # (1, nhead, n_patches, n_patches)最后说一个我自己的教训做实验时把每次运行的超参数、随机种子、预处理参数、数据划分方式全部记录在一个配置文件里包括你尝试过但失败的组合。运动想象分类这个任务某一组超参在受试者A上跑出85%准确率换到受试者B上可能只剩65%你以为是自己代码写错了其实是数据本身方差太大。有完整的实验记录你才能判断是模型问题还是数据问题。希望这篇笔记能帮你在毕设路上少踩几个坑把精力放到真正值得研究的CNN与Transformer融合策略上去。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →