
简介基于CNN与Transformer融合的运动想象脑电信号分类工程源自本科毕业设计面向计算机科学、信息工程等专业学习者与研究者。卷积模块提取时空局部特征Transformer捕捉长程依赖并结合梯度加权类激活映射实现脑电空间分布可视化答辩评价优异、代码可稳定运行。压缩包共38个文件以Python源码为主辅以Matlab脚本、xlsx统计表、模型权重pth与训练数据npy整体约18.47MB。内容涵盖CNNTransformer、EEGNet、空间时间注意力、Morlet小波变换、共空间模式、CAM热力图、tSNE降维及AUC对比脚本附带README与备份文件便于复现与扩展。目前已有67人学习下载适合课程实践、综合训练或课题研究参考。1. 运动想象脑电分类为什么卡在特征提取上CNN与Transformer怎么补彼此的短板运动想象Motor Imagery, MI脑电信号分类是脑机接口里最经典也最考验特征工程的任务之一。受试者在脑子里想象左手、右手、双脚或舌头运动时头皮上会诱发特定频段的ERD/ERS现象但单个通道的时域波形几乎看不出规律分类算法真正要捕捉的是8到30Hzmu节律和beta节律的空间-时间耦合模式。传统CSP特征泛化能力差纯CNN能提取空间特征却难以建模长程时序依赖纯Transformer能建模时序又容易在小样本EEG上过拟合。把两者按“CNN先提空间特征、Transformer再建时间依赖”的方式融合是当前在同一数据集上把四分类准确率从70%往上推进的最可靠路线。这篇笔记面向正在跑BCI IV-2a、OpenBMI或自采数据的研究者从预处理到模型训练再到可视化给出一条能直接复现的落地路径。2. 数据准备与预处理从BCI IV-2a原始信号到可训练样本的完整流程2.1 运动想象数据集选择与通道配置BCI Competition IV Dataset 2a后文简称BCI IV-2a是运动想象分类的事实基准。数据集包含9名受试者、四类运动想象任务左手、右手、双脚、舌头、22个Ag/AgCl电极通道采样率250Hz每个受试者训练集和测试集各144个trial。这个数据集的特点非常鲜明同一受试者同一session内能跑到比较高的准确率但跨受试者泛化极差所以评测时必须把“受试者内交叉验证”和“跨受试者留出”两个维度分开报告否则结论会失真。如果是自采数据建议先按10-20国际导联标准检查电极布局。运动想象任务最相关的通道是C3、C4、Cz及其邻近的FC和CP导联。通道数不必追求全头128导22导对于运动想象分类完全够用过多的无关通道只会让Transformer的注意力模块更容易“分心”。可视化和特征分析阶段也建议先盯着这些核心通道看确认ERD/ERS现象真的存在再进模型这样后面排查问题时才有参照系。第一步用MNE库读取数据并做基础检查import mne import numpy as np # 读取BCI IV-2a的GDF文件preloadTrue表示一次性加载到内存 raw mne.io.read_raw_gdf(A01T.gdf, preloadTrue) print(raw.info) # 打印通道数、采样率、滤波状态 # 从annotations提取事件运动想象通常使用769/770/771/772作为事件码 events, event_id mne.events_from_annotations(raw) print(np.unique(events[:, 2])) # 确认事件码不同数据集定义可能不同这段代码里mne.io.read_raw_gdf把GDF格式的原始脑电读入内存preloadTrue是全部加载而非惰性读取方便后续切片和滤波运算。mne.events_from_annotations把文件里的标注转换成事件数组事件数组每一行包含“采样点序号、持续时间、事件码”。BCI IV-2a的标准里769到772分别对应左手、右手、双脚、舌头但其他数据集定义可能不同打印出来确认一次是必须的否则后面切片全错。2.2 带通滤波与参考设置mu节律和beta节律才是信号主线运动想象诱发的ERD/ERS集中在mu节律8-12Hz和beta节律13-30Hz因此带通滤波是整个预处理里最核心的一步。常见做法是统一做8-30Hz带通滤波也有研究把mu和beta分量分别提取再融合但工程上先做一次8-30Hz滤波、后续靠模型自行学习频带权重是复现成本最低的方案。低频漂移是脑电数据里最常见的干扰源高通截止频率设在8Hz能显著滤除漂移如果把上限放到30Hz以上会引入更多肌电噪声需要权衡。滤波器推荐使用FIR而不是IIR。FIR相位线性、不会产生相位畸变这对时序敏感的Transformer模型尤其重要。MNE的filter方法默认使用firwin窗函数设计过渡带宽度按经验保持0.5Hz左右# 带通滤波8-30Hz覆盖mu和beta节律 raw_filtered raw.copy().filter(8, 30, methodfir, fir_designfirwin) # 用标准差阈值粗筛坏通道异常通道幅值波动通常远大于正常通道 channel_std np.std(raw_filtered.get_data(), axis1) bad_mask channel_std np.percentile(channel_std, 95) raw_filtered.info[bads] [raw_filtered.ch_names[i] for i in np.where(bad_mask)[0]] print(标记的坏通道:, raw_filtered.info[bads]) # 设置平均参考运动想象任务默认使用全脑平均参考 raw_filtered.set_eeg_reference(average)filter方法里methodfir指定FIR滤波器避免IIR带来的非线性相位fir_designfirwin是MNE默认的窗函数设计法。坏通道检测这里用的是最简单的标准差阈值实际工程里可以换成MNE内置的坏通道检测函数或人工排查但核心目的一样坏的通道会在空间维度上给CNN引入虚假特征。set_eeg_reference(average)把参考从单极改为全脑平均参考这是大多数运动想象论文的默认选择。如果只关注C3/C4两个通道也可以设双极参考但会丢失脑地形图可视化的全局通道信息。2.3 时间窗切片与样本增强让每个trial进入训练管道BCI IV-2a每个trial从提示出现到结束约3-4秒但并不是所有时间段都有判别力。运动想象分类最有信息量的窗口通常是提示出现后0.5秒到2.5秒之间这段时间ERD/ERS现象最明显。如果把整段3秒都喂给模型反而会引入注意力漂移——Transformer的自注意力机制会花费大量权重在“没有事件发生”的空白段上。切片完成后还有一步常被忽略基线校正。用任务开始前的数据做baseline去除直流漂移虽然带通滤波已经滤掉低频但基线校正能让不同trial的起点一致。之后把trial数据转换成NumPy数组才能进入PyTorch训练管道# 定义切片从提示后0.5秒开始截取2.5秒长度的任务段 tmin, tmax 0.5, 3.0 epochs mne.Epochs(raw_filtered, events, event_idevent_id, tmintmin, tmaxtmax, baselineNone, pickseeg, preloadTrue) X epochs.get_data() # 形状: (trial数, 通道数, 采样点数) y epochs.events[:, 2] # 原始事件码 print(epochs形状:, X.shape)baselineNone是因为带通滤波已经做过低频校正这里不再额外扣基线省去一个调参变量如果后续发现trial间幅值漂移明显可以再改成baseline(None, 0)重新切一次对比。单受试者144个trial对融合模型来说偏少我一般会在trial内部做滑动窗口增强。对250Hz采样率来说2.5秒是625个采样点用1.5秒窗口375点、0.5秒步长125点切每个trial能切出约3个子样本。这样做的好处不仅仅是数据量增加更重要的是模型能见到不同起止位置的时间片段对时间对齐误差的鲁棒性更好# 滑动窗口增强每个trial内部切出重叠子样本 win_len, stride 375, 125 # 1.5秒窗口0.5秒步长 Xs, ys [], [] for trial_idx in range(X.shape[0]): for start in range(0, X.shape[2] - win_len 1, stride): Xs.append(X[trial_idx, :, start:start win_len]) ys.append(y[trial_idx]) X_all np.array(Xs) y_all np.array(ys) - 769 # 事件码转为0/1/2/3四类 print(增强后样本形状:, X_all.shape, 标签分布:, np.bincount(y_all))切片和增强之后X_all的形状大约是(N, 22, 375)N是原始trial数的3倍左右。这里把标签减去769是为了让四类标签变成0-3的整数索引方便PyTorch的CrossEntropyLoss直接使用。注意事件码的偏移量必须按前面打印出来的实际值调整换数据集时这个固定偏移大概率会变。3. 模型结构设计CNN分支做空间特征提取Transformer分支做时间依赖建模3.1 CNN分支通道卷积与时间卷积的分工融合模型的构建原则是先局部后全局。CNN的卷积核天然适合提取局部特征在EEG数据上这个“局部”包括两个维度通道维度的空间模式比如C3和C4的ERD/ERS对立关系和时间维度的短时波形模式。我的做法是先用深度可分离卷积风格的模块做空间特征提取再接时间卷积层做短时特征细化。第一层用(C通道数, 1)的空间卷积核每个输出通道独立跨通道做加权组合相当于让模型自动学习电极之间的空间滤波系数第二层用(1, 时间窗)的时间卷积核捕捉几十个采样点内的微时序模式。空间卷积的输出通道数通常设64时间卷积核大小取64个采样点约0.25秒这个时长大致覆盖一个运动想象相关波形分量的典型宽度import torch import torch.nn as nn import torch.nn.functional as F class SpatialCNN(nn.Module): def __init__(self, C22, T375, ch_out64, kernel_t64): super().__init__() # 空间卷积在通道维度上做加权组合等价于可学习的空间滤波器 self.conv_spatial nn.Conv2d(1, ch_out, (C, 1), biasFalse) # 时间卷积捕捉短时波形模式kernel_t64约0.25秒 self.conv_time nn.Conv2d(ch_out, ch_out, (1, kernel_t), biasFalse) self.bn nn.BatchNorm2d(ch_out) self.dropout nn.Dropout2d(0.3) def forward(self, x): # x: (B, 1, C, T) x self.conv_spatial(x) # (B, ch_out, 1, T) x self.conv_time(x) # (B, ch_out, 1, T - kernel_t 1) x self.bn(x) x F.elu(x) x self.dropout(x) return x # (B, ch_out, 1, T)这里有几个关键的选型理由。空间卷积的kernel设为(C, 1)也就是只在通道维上做卷积、时间维不动目的是把“空间滤波”和“时间建模”两件事彻底分开。如果空间卷积带上了时间宽度它就会同时混入短时时间特征后续Transformer建模长程时间依赖时特征里就会有冗余信息。让空间卷积的时间维为1其输出在每一个时刻点的跨通道加权是独立的纯粹描述空间模式时间依赖全部交给Transformer两个模块的职责边界最干净。kernel_t取64是因为相邻采样点之间高度相关卷积核太短学不到有效模式太长又会把空间特征的时序细化变成全局平滑。3.2 Transformer分支位置编码与自注意力的作用Transformer分支的输入来自CNN分支的输出特征。CNN分支输出形状是(B, ch_out, 1, T)需要展平成(B, T, ch_out)再作为Transformer的输入序列。T是空间卷积加时间卷积后的时间步数对375采样点的输入经过kernel_t64的时间卷积后是312步。这个序列长度对自注意力来说偏长通常先做降采样自适应平均池化或步进卷积把时间步降到50-100左右否则注意力矩阵的计算量和过拟合风险都不可控。位置编码是Transformer里必须处理的一个细节。EEG时序和文本一样自注意力机制本身是位置无关的如果不加位置编码模型会把“0.5秒的位置”和“2秒的位置”看成等价的这直接违背了ERD/ERS随时间演化的规律。对EEG序列来说可学习的绝对位置编码即可满足需求因为每个训练样本的序列长度固定class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len128): super().__init__() # register_buffer不是Parameter位置编码不需要梯度更新 pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp( torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model) ) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): # x: (B, seq_len, d_model) return x self.pe[:, :x.size(1)]位置编码使用正弦余弦函数生成初始值通过register_buffer注册到模型里训练时不会参与梯度更新。d_model64时偶数维度用正弦、奇数维度用余弦不同频率组合起来就能区分不同位置的编码。Transformer编码器层数不用多EEG是典型的小样本场景单个受试者几千个样本2到3层编码器已经足够更多层只会加剧过拟合。注意力头数取4embedding维度取64-128前馈网络hidden dim取embedding的4倍这是Transformer原论文建议的比例在EEG上同样适用不用特意改。3.3 融合策略与完整模型Feature Pyramids和Cross Attention的取舍CNN与Transformer融合的具体方式常见的有三种。第一种是并行双分支CNN和Transformer各自处理原始输入最后把特征拼接送分类器第二种是串行浅融合CNN先提空间特征把时间-通道特征序列交给Transformer这也是我给出的方案第三种是深层cross-attention把CNN每个时间步的输出作为key和value再加一个可学习的query向量让注意力自行决定时间步的权重。第三种方案理论上最灵活但EEG小样本条件下训练不充分的风险高收敛也慢实际收益往往不抵复杂度。我一般用串行浅融合加分类头的形式工程上最稳。完整模型结构如下class FusionTransformer(nn.Module): def __init__(self, C22, T375, n_classes4, d_model64, n_heads4, n_layers2): super().__init__() self.cnn SpatialCNN(C, T, ch_outd_model) # 自适应平均池化把时间步压缩到固定长度64 self.pool nn.AdaptiveAvgPool1d(64) enc_layer nn.TransformerEncoderLayer( d_modeld_model, nheadn_heads, dim_feedforward256, dropout0.1, batch_firstTrue ) self.transformer nn.TransformerEncoder(enc_layer, num_layersn_layers) self.pos_enc PositionalEncoding(d_model, max_len64) self.classifier nn.Sequential( nn.LayerNorm(d_model), nn.Linear(d_model, n_classes) ) def forward(self, x): # x: (B, 1, C, T) feat self.cnn(x) # (B, d_model, 1, T) feat feat.squeeze(2) # (B, d_model, T) feat self.pool(feat).transpose(1, 2) # (B, 64, d_model) feat self.pos_enc(feat) feat self.transformer(feat) # (B, 64, d_model) out feat.mean(dim1) # 全局平均池化 - (B, d_model) return self.classifier(out)AdaptiveAvgPool1d(64)把CNN输出从312个时间步压缩到64个自注意力的计算复杂度从O(312²)降到O(64²)训练速度能差一个数量级同时全局池化保留了整个时间段的信息。batch_firstTrue让TransformerEncoderLayer直接接受(B, seq_len, d_model)的输入形状省去维度转置。最后用全局平均池化而不是CLS token因为EEG小样本下训练一个额外的CLS token风险更高平均池化的归纳偏置更稳。CNN输出通道数设定为d_model64这样进入Transformer前不需要额外的线性投影层避免信息在投影中损耗。3.4 核心参数速查表参数推荐值设置理由空间卷积输出通道数32-64太少无法建模多电极空间模式太多引发过拟合时间卷积核大小64采样点(约0.25s)覆盖一个运动想象相关波形的典型时宽降采样后序列长度64平衡注意力计算成本与时间分辨率Transformer层数2-3EEG样本量少层数加深会过拟合注意力头数4头太多会让每头的embedding维度过细embedding维度64-128与CNN输出通道数保持一致省去投影层dropout0.3-0.5Transformer分支的dropout应不低于CNN分支batch_size16-32EEG每个batch内部方差大太大batch会让BatchNorm不稳定这组参数是我在这个任务上调参的经验汇总。最容易被忽略的是embedding维度和CNN输出通道数的对齐不一致时中间需要加一个矩阵投影层投影在反向传播里很容易变成信息瓶颈。如果你把CNN的ch_out改成128d_model也要跟着改成128池化长度可以不变。4. 训练策略与核心参数学习率、损失函数、评估指标怎么配4.1 训练与验证划分GroupKFold是底线BCI IV-2a每个受试者的数据是独立的先做单受试者分类评估再做跨受试者评估是运动想象论文里的标准评测体系。单受试者分类时用5折交叉验证而不是简单train/valid分割因为EEG数据受试者内部同样存在慢漂移不同时间段的数据分布不完全一致5折能更全面地度量模型鲁棒性。这里有一个新手最容易犯的错误滑动窗口增强之后不做任何处理直接随机划分。滑动窗口产生的相邻样本共享大量重叠信号随机划分会导致训练集和验证集之间数据泄漏。正确做法是让同一个trial的所有子窗口划分到同一个foldfrom sklearn.model_selection import GroupKFold group_ids np.arange(X.shape[0]).repeat( (X.shape[2] - win_len) // stride 1 ) # 每个原始trial对应一组连续的group id gkf GroupKFold(n_splits5) for fold, (train_idx, val_idx) in enumerate(gkf.split(X_all, y_all, groupsgroup_ids)): X_train, X_val X_all[train_idx], X_all[val_idx] y_train, y_val y_all[train_idx], y_all[val_idx] print(ffold {fold}: 训练集 {len(train_idx)} 样本, 验证集 {len(val_idx)} 样本)GroupKFold和普通KFold唯一的区别就是多了一个groups参数它告诉切分器哪些样本属于同一个组切分时保证同组样本要么全部在训练集、要么全部在验证集。这里的group id就是原始trial的索引。如果忽略这一步直接随机切分验证集里会混入训练集trial的相邻切片评估出来的准确率虚高换到真实在线场景立刻翻车。4.2 学习率调度与早停Transformer分支对学习率更敏感融合模型比纯CNN对学习率更敏感。Transformer的自注意力模块在训练前期梯度方差大学习率设置偏高很容易让LayerNorm和attention权重发散设置太低又会让CNN分支收敛过慢。我的经验是初始学习率从1e-3开始配合cosine退火或ReduceLROnPlateau动态调度。如果只固定学习率1e-3是上限低于1e-4的训练速度会慢到影响调参效率。optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max50, eta_min1e-5 ) best_val_acc 0.0 patience 0 for epoch in range(50): train_loss train_one_epoch(model, optimizer, X_train, y_train) val_acc evaluate(model, X_val, y_val) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pth) # 只保存权重 patience 0 else: patience 1 if patience 10: break scheduler.step()AdamW加weight_decay1e-4是对Transformer参数做权重衰减正则比Adam默认的L2正则更贴合Transformer的训练特性。cosine退火让学习率从1e-3平滑降到1e-5后期步长足够小loss能在更精细的尺度上找到较优位置。模型只保存验证集上表现最好的权重而不是最后一个epoch的权重——最后一个epoch往往已经过拟合或学习率过小都是次优解。早停的patience设10个epoch对50个epoch的训练周期来说是一个合理的量级。4.3 损失函数与标签平滑小样本下的正则化技巧四分类运动想象任务直接用CrossEntropyLoss即可。BCI IV-2a每类约72个trial样本量基本均衡不需要手动设置class_weight。但如果是自采数据某类运动想象的trial数偏少就必须对损失函数设置类别权重把有效trial数最少的类别权重提到1.2到1.5否则模型会无条件偏向样本多的类别。另一个容易被忽视的工程细节是标签平滑。把one-hot标签变成软标签引入少量噪声能显著缓解Transformer在EEG小样本上的“过度自信”问题让模型不再追求输出概率逼近1而是保留一点不确定性。在信噪比低的EEG上这反而能提升验证集准确率criterion nn.CrossEntropyLoss(label_smoothing0.1)label_smoothing参数是PyTorch新版CrossEntropyLoss直接支持的把它设为0.1目标one-hot向量会变成(0.9, 0.033, 0.033, 0.033)这样的软化分布。标签平滑率不要设太大超过0.2会让模型欠拟合四分类任务上0.1是一个经过验证的安全值。4.4 评估指标准确率之外一定要看Kappa系数BCI IV-2a官方评估指标是Kappa值而非单纯准确率。Kappa系数衡量的是“除去随机分类后模型真实能拿到的分类一致性”四分类的随机基线Kappa是0完美分类是1。准确率70%的模型Kappa大约是0.6这对应BCI Competition IV 2a的领先水平。如果报告结果时只写准确率审稿人大概率会追问Kappa。from sklearn.metrics import cohen_kappa_score, confusion_matrix y_pred predict(model, X_val) kappa cohen_kappa_score(y_val, y_pred) print(fKappa系数: {kappa:.3f}) cm confusion_matrix(y_val, y_pred) print(混淆矩阵:) print(cm)Kappa的计算很简单但它的价值在于如果模型对某一对类别比如右手和双脚出现系统性误分类会精确反映在Kappa的类别贡献上比看总准确率更容易定位问题。混淆矩阵要常态化打印出来。四分类运动想象里“左手和右手混”与“双脚和舌头混”是两类典型的失败模式前者说明C3/C4空间特征提取不足后者说明这两类任务的ERD/ERS频带非常接近需要补充更精细的频带特征或增加训练数据。5. 运动想象分类避坑指南数据泄漏、过拟合与通道失效5.1 数据泄漏归一化参数必须只从训练集统计现象验证集准确率高达85%部署到在线采集数据只有60%差距大得不合理。原因在滑动窗口增强和划分之前就对全部数据做了z-score标准化或MinMax归一化归一化使用的均值和标准差包含了验证集和测试集的数据信息。这种泄漏在机器学习里最容易出现在预处理阶段影响是隐性的结果虚高而不报错。解决把归一化放进交叉验证的每一折内部只从训练集计算均值和标准差用同一组参数变换验证集from sklearn.preprocessing import StandardScaler X_flat X_train.reshape(len(X_train), -1) scaler StandardScaler().fit(X_flat) # fit只看训练集 X_train_norm scaler.transform(X_flat).reshape(X_train.shape) X_val_flat X_val.reshape(len(X_val), -1) X_val_norm scaler.transform(X_val_flat).reshape(X_val.shape)这里把一个样本拉平成22x375的长向量做标准化等价于对每个通道、每个采样点独立归一化。注意标准的mask和学习率、滤波器参数一样全部要在训练集上计算验证集只能用transform不能重新fit。5.2 过拟合Transformer在EEG小样本上的脆弱性现象训练集准确率99%验证集72%而且随着epoch增加训练集和验证集的差距越来越大。原因单受试者只有几千个样本而Transformer参数量动辄几十万模型有足够容量直接记住训练样本。EEG信号本身噪声大没有强正则化的话模型会把噪声也当作特征学进去这种过拟合在融合模型里比纯CNN出现得更早。解决四管齐下。第一dropout率上调到0.4-0.5第二把Transformer层数从3层减到2层第三在输入层加随机通道丢弃强迫模型不依赖单通道的强特征第四也是最有效的一招给空间卷积的输入加随机通道屏蔽在训练时随机冻结2-3个通道def channel_dropout(x, drop_prob0.1): # x: (B, 1, C, T)在通道维度上随机屏蔽 B, _, C, T x.shape mask torch.rand(B, 1, C, 1, devicex.device) drop_prob return x * mask.float() # 只在训练阶段调用验证时不调用 if model.training: x channel_dropout(x, 0.15)channel_dropout实现很简单但它模拟的是EEG采集中电极脱落或接触不良的实际情况强迫空间卷积不能只依赖少数通道的强特征必须从整体空间模式里判别类别。对泛化能力的提升在跨session评估里尤其明显代价只是训练集准确率下降2-3个点。5.3 通道位置偏差电极帽佩戴导致的性能骤降现象同一个受试者隔天再来采集数据模型准确率从80%掉到65%掉的点主要集中在左手/右手这两个空间上对立的类别。原因电极帽每次佩戴都有几毫米到一厘米的位置误差C3/C4的实际测量点和上一次不一样。CNN的空间卷积学到的空间滤波系数基于之前的电极位置位置一变空间模式失效。这在真实BCI落地场景里非常普遍也是运动想象模型上线难的核心原因之一。解决离线研究阶段训练时给通道加随机重排增强打乱空间卷积对通道绝对位置的依赖# 固定随机种子做通道重排打乱通道与位置的固定对应关系 np.random.seed(42) channel_idx np.random.permutation(C) X_train_aug X_train[:, :, channel_idx, :]通道重排增强的本质是强迫空间卷积学到相邻通道的组合模式而不是某个固定通道位置。训练集准确率会略降但换来的是模型对电极佩戴差异的鲁棒性。如果条件允许更实用的方案是采集阶段做佩戴-脱帽-重戴多次采集把重戴后的数据纳入训练集。5.4 随机种子同一条数据两次训练结果差5个点现象用完全相同的代码和超参数只是换了随机种子或没固定种子两次训练的结果差4-5个准确率点模型看起来不稳定。原因小样本加高噪声的数据对随机性极度敏感数据顺序、dropout序列、权重初始化都会引入随机差异。这不算模型bug但会影响实验结论的可信度尤其在调参阶段会误导方向。解决固定一切种子是基本操作关键是每个实验用至少5个不同种子跑完取平均值和标准差import random def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False seeds [42, 233, 666, 1024, 2048] results [] for seed in seeds: set_seed(seed) acc train_and_evaluate() # 返回验证集准确率 results.append(acc) print(f5个种子平均准确率: {np.mean(results):.2f}% ± {np.std(results):.2f}%)cudnn.deterministic True确保GPU上卷积运算是确定性的代价是训练略慢。如果标准差超过2%说明模型本身在这个数据上不够稳优先去加正则化或简化模型而不是继续调学习率。实验记录里把每个种子的结果都留档最后报告平均值加减标准差这是运动想象分类论文的基本要求。5.5 类别不均衡四分类里某类准确率永远上不去现象四类任务整体准确率尚可但“双脚”和“舌头”这两类的分类准确率明显低于左手和右手混淆矩阵里这两类互相误判特别多。原因双脚和舌头的运动想象都引起大脑中中央区域Cz附近的ERD/ERS空间分布非常接近加上这两类样本量在数据集中天然偏少模型学不到足够区分度。这不是模型代码的问题而是神经生理学层面的类间相似性。解决先用类别权重把样本少的类别拉起来再针对性检查频带。如果右脚和舌头都集中在Cz附近可以在预处理阶段增加一个对C3/C4与Cz通道的空间对比特征通道或者把模型输入从单一时段扩展成mu和beta两个子带的并行输入让模型有机会学习频带上的差异# 给损失函数加类别权重 class_weight torch.tensor([1.0, 1.0, 1.15, 1.15], devicedevice) criterion nn.CrossEntropyLoss(label_smoothing0.1, weightclass_weight)类别权重设1.15是一个比较保守的值能提升低样本类别的召回率又不至于让高样本类别崩掉。如果加了权重之后“双脚”和“舌头”依然分不开那就要回到特征层面检查Cz附近通道的时频图确认这两类是否存在可分离的频带差异存在则考虑多频带分支结构。6. 分类结果可视化落地从注意力权重到脑地形图的具体实现模型训练完成后可视化不是给论文凑图而是验证Transformer分支到底学到了什么。注意力权重是最直接的观察窗口对一个正确的trial注意力矩阵里高权重的区域是否集中在ERD/ERS最明显的时段如果集中在噪声段说明模型学到的是伪特征。下面的代码取出Transformer第一层的自注意力权重画成热力图def plot_attention(model, x, idx0): model.eval() with torch.no_grad(): feat model.cnn(x[idx].unsqueeze(0).unsqueeze(0)) feat feat.squeeze(2) feat model.pool(feat).transpose(1, 2) feat model.pos_enc(feat) # PyTorch 1.9版本self_attn第二返回值是注意力权重 _, attn model.transformer.layers[0].self_attn(feat, feat, feat) attn_map attn[0].mean(dim0).cpu().numpy() # 多head取平均 plt.imshow(attn_map, cmapviridis, aspectauto) plt.colorbar(labelattention weight) plt.xlabel(source position) plt.ylabel(target position)这里把多头注意力的权重取平均得到64x64的注意力矩阵。理想情况下矩阵的对角线附近会有明显的带状聚焦说明模型主要关注相邻时间步如果注意力矩阵一片均匀说明Transformer分支没有学到时序结构大概率是位置编码或序列压缩出了玄学问题需要回去检查降采样层的池化长度。脑地形图是另一个必须做的可视化手段。把某个trial的平均功率按通道画在头皮二维坐标上能直观看出左手想象时右侧运动皮层C4附近的ERD现象也能在切换trial时确认模型输入数据的质量是否可靠# 通道名的功率平均画在头皮地形图上 from mne.viz import plot_topomap power np.mean(X_all[0] ** 2, axis1) # 22个通道的平均功率 plot_topomap(power, epochs.info, showTrue)有一个习惯我一直保持每次跑完训练先画一批正确trial和错误trial的注意力图对照脑地形图看。如果发现某一类错误样本的注意力几乎不在mu/beta频段对应的时段就说明预处理里滤波带宽太宽或太窄。如果重来一次我会在模型训练前先花一天时间把可视化管线搭好而不是等模型训完再补——因为可视化在诊断阶段的价值远大于展示阶段。希望这篇笔记能帮你少走这些弯路。本文还有配套的精品资源点击获取