简介:这是一份面向毕业设计的运动想象脑电信号分类项目源码包,采用卷积神经网络与Transformer混合框架,先用卷积网络提取局部时空特征,再由Transformer建模全局依赖,并创新加入梯度加权类激活映射,实现脑电地形图可视化,适合人工智能、电子信息、自动化等专业学生用于毕设、课设或脑机接口入门。压缩包共31个文件,大小18.45MB,以23个脚本文件为主,涵盖模型构建、数据读取、交叉验证、样本降维投影、箱线图与受试者工作特征曲线分析;另含两个预处理脚本、已训练模型权重、样本数据、结果表格和项目说明,结构清晰。代码均经过调试运行,答辩评审平均分达96分,已有1254人浏览学习;可视化部分能帮助初学者理解模型对不同脑电通道的分类依据。整体覆盖数据预处理、特征提取、模型对比和结果绘图,适合二次开发与课题深入复现。
1. 做运动想象脑电信号分类,为什么绕不开CNN+Transformer框架
运动想象脑电信号分类是个老问题:被试在脑子里默想“动左手”“动右手”“动脚”或“动舌头”,脑电会在mu节律(8-12Hz)和beta节律(14-30Hz)上出现短暂的功率下降或上升,也就是ERD/ERS现象。信号本身非常弱、信噪比低、个体差异大,传统CSP+LDA手工特征的做法上限很明显。毕设里真正卡人的是:公开数据集通常只有几百个有效trial,纯CNN能提局部时空特征,但对长程依赖捕捉不足;纯Transformer在小样本脑电上又特别容易过拟合。CNN+Transformer框架的动机很简单——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,验证集纹丝不动,过拟合得明明白白。
所以CNN+Transformer不是“为了堆模型而堆”,而是互补:CNN在原始EEG上先做局部时间卷积和空间滤波,把每个时间节点上的局部表征整理干净;Transformer再对这些表征序列做全局建模,捕捉CNN单层看不到的长程依赖,比如一次完整运动想象中ERD到ERS的时序演化。这和Vision Transformer在图像上work的逻辑一致——底层特征交给卷积或patch embed,全局关系交给自注意力。
2.3 常见的融合结构与选型建议
论文和开源实现里,CNN+Transformer用于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, tmin=0.5, tmax=2.5, n_trim=None): raw = mne.io.read_raw_gdf(gdf_path, preload=True, verbose=False) raw.pick_types(eeg=True) # 只保留22个EEG通道 raw.filter(4, 38, method='iir', verbose=False) # 运动想象相关频带 raw.notch_filter(50, verbose=False) # 工频陷波 events, event_id = mne.events_from_annotations(raw, verbose=False) epochs = mne.Epochs( raw, events, event_id={'left': 1, 'right': 2, 'foot': 3, 'tongue': 4}, tmin=tmin, tmax=tmax, # cue后0.5s到2.5s,共2s baseline=None, preload=True, verbose=False, ) 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(eeg=True)把EOG等非脑电通道丢掉,保证后面通道维度统一是22。filter用IIR实现4-38Hz带通,这是运动想象ERD/ERS最集中的频带;notch_filter再做一次50Hz陷波,虽然数据集录制时已经陷过,但重放一遍能让后续卷积特征更稳定。Epochs按事件ID分段,tmin=0.5, tmax=2.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_trim=500) # 先按trial独立划分,再做归一化,顺序不能反 X_tr, X_va, y_tr, y_va = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) # 用训练集统计量归一化,验证集只做平移缩放 mean_tr = X_tr.mean(axis=(0, 2), keepdims=True) # 每个通道一个均值 std_tr = X_tr.std(axis=(0, 2), keepdims=True) 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_max=8, noise_std=0.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]], axis=1 ) elif shift < 0: s = -shift # 右侧补零,左侧截掉 X_aug[i] = np.concatenate( [X_batch[i, :, s:], np.zeros((C, s))], axis=1 ) else: X_aug[i] = X_batch[i] X_aug = X_aug + np.random.normal(0, noise_std, size=X_aug.shape) return X_aug, y_batch逻辑说明:这个增强函数在PyTorch的DataLoader里在线执行,每个epoch对同一批数据产生不同的扰动,等效于扩充训练集。shift_max=8对应250Hz下32ms的时间平移,这个量级不会破坏ERD/ERS的节律结构,但能打乱与cue精确对齐的伪迹。noise_std=0.05是因为前面的z-score归一化让信号方差大约为1,加0.05相当于一个幅度很小的高斯扰动,不会把信号淹没。
参数说明:增强强度不要一开始就拉满,先开平移、噪声开0.02跑一个epoch看训练loss曲线,如果训练loss下降变慢再往上加。EEG数据增强的参数选择有几分玄学成分在,最稳的做法是固定一组参数后就不再乱动,把精力留给模型本身和超参调优。
4. 搭一个能跑的CNN+Transformer模型:结构、代码与超参
4.1 整体结构与维度流转
这一章给的模型是串行结构:CNN提取局部时空特征并把序列长度压到25,Transformer在这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_channels=22, f1=8, f2=16, d_model=32, dropout=0.3): super().__init__() # 时间卷积:沿时间轴做卷积,等价于学一组带通滤波器 self.temporal_conv = nn.Conv2d(1, f1, (1, 64), padding=(0, 32), bias=False) # 空间卷积:在通道轴上融合所有电极,等价于空间滤波 self.spatial_conv = nn.Conv2d(f1, f2, (n_channels, 1), bias=False) self.bn1 = nn.BatchNorm2d(f2) self.pool1 = nn.AvgPool2d((1, 4)) # 深度可分离卷积:每个特征图独立做时序平滑,再用1x1融合 self.depthwise = nn.Conv2d( f2, f2, (1, 16), padding=(0, 8), groups=f2, bias=False ) self.pointwise = nn.Conv2d(f2, d_model, (1, 1), bias=False) 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用groups=f2让每个特征图独立做时序卷积,pointwise负责跨通道融合并把维度升到d_model=32。两次AvgPool2d把时间维度从500压到125再压到25。
参数说明:f1=8是第一层时间卷积的滤波器个数,对应8组频率响应;f2=16是空间卷积输出通道数。想要更强的时间特征提取能力可以调大f1,但样本少时不建议超过16。d_model=32是给Transformer的token维度,也是特征图的通道数,这里直接复用CNN的输出通道数,省去一层额外的投影。
4.3 Transformer编码器与分类头
Transformer部分用了标准的位置编码和nn.TransformerEncoder,分类头直接做全局平均池化,不引入CLS token。CLS token是ViT里配合预训练用的,在几百个样本的小数据集上,全局平均池化更稳,收敛更快。
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=64): super().__init__() pe = torch.zeros(max_len, d_model) pos = torch.arange(0, max_len, dtype=torch.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_channels=22, n_classes=4, f1=8, f2=16, d_model=32, nhead=4, num_layers=2, dropout=0.3): super().__init__() self.cnn = CNNFeatureExtractor(n_channels, f1, f2, d_model, dropout) self.pos_enc = PositionalEncoding(d_model) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, activation='gelu', batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_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(dim=1) # 全局平均池化 return self.classifier(x)逻辑说明:CNN输出(B, 25, 32)表示25个时间token,每个token的32维向量是CNN在该时间位置整合出的全通道融合特征。PositionalEncoding加一个固定的正余弦位置编码,让Transformer知道token的先后顺序。TransformerEncoderLayer里的batch_first=True指定输入输出都是(B, T, d_model)。dim_feedforward=d_model * 4是前馈网络的隐藏层维度,128在所有小模型里都是够用的。mean(dim=1)对所有时间token取平均,得到整个运动想象窗口的全局特征,最后接4分类线性层。
参数说明:nhead=4要求d_model能被4整除,32 / 4 = 8,每个注意力头8维,这是合理配置。num_layers=2是给BCI 2a这种小样本用的保守值,如果加数据增强后验证集还在涨,可以试3层,但不要一上来就4层以上。dropout=0.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(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-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 rate | 5e-4 ~ 1e-3 | AdamW下1e-3起步,不稳就降 |
| weight_decay | 1e-4 ~ 1e-2 | 过拟合明显时加大 |
| dropout | 0.3 ~ 0.5 | 小样本0.3起步 |
| d_model | 32 / 64 | 首选32,64需配合更强增强 |
| num_layers | 1 ~ 3 | 2层起步 |
| nhead | 4 / 8 | 需整除d_model |
| batch_size | 32 / 64 | 以显存和batch norm稳定性为准 |
注意:不要一上来就追求“大模型”。BCI 2a单被试几百个trial,d_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.5,Transformer和CNN部分都加;第二,把weight_decay从1e-4加到1e-3;第三,把num_layers从2降回1,d_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_first=True要同时传给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降到1,d_model从32降到16,nhead降为4,参数量会直接少掉一半以上,而BCI 2a上的准确率可能只掉2到3个点。原因前面说过,小样本本身撑不起大Transformer,压缩容量有时候反而提升泛化。这一步跑通了,后续接嵌入式实时脑机接口的部署就有底了。
我在自己的毕设里翻车最狠的一次,就是把全部精力耗在堆模型结构上,结果发现数据预处理的顺序错了。从那以后我养成了个习惯:先冻结数据管线和划分逻辑,再动模型结构和超参;每次只改一个变量,用同一个固定种子跑三个重复取均值,把所有实验记录在表格里。做运动想象分类,真正的门槛往往不在模型多新颖,而在你能不能把一条线稳定地复现两遍。希望帮到你。
本文还有配套的精品资源,点击获取