多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CNN+Transformer混合模型:运动想象脑电分类实战与避坑指南

CNN+Transformer混合模型:运动想象脑电分类实战与避坑指南 简介这份资源是面向计算机、通信、人工智能、自动化等专业学生与从业者的运动想象脑电信号分类Python源码采用CNN与Transformer结合的框架通过卷积网络提取局部时间空间特征再借助Transformer建模长程依赖可用于毕业设计、课程大作业或期末项目。压缩包共31个文件约18.45MB以23个py脚本为核心涵盖模型定义、训练与K折验证、可视化及CAM类激活分析等模块另含2个m预处理脚本、2个xlsx统计结果、1个pth权重、1个npy数据及xml、md等配置说明文件。目前已有165人学习下载。项目代码经过调试测试答辩评审分达98分读者可据此复现完整分类流程理解CNN与Transformer的融合思路并参考t-SNE、AUC、箱线图等可视化脚本与空间模式、注意力机制实现快速搭建自己的脑电分析实验。1. 运动想象脑电分类为什么Transformer配CNN成了新基线运动想象脑电信号分类说白了就是让人在脑子里“想”左手或右手动作算法从头皮电极里读出这段意图。传统做法靠CSP加SVM特征工程吃经验跨被试一换人就掉点。这几年Transformer在时序建模上表现抢眼但直接把原始脑电丢进自注意力层效果往往不如预期——因为脑电的局部节律mu波、beta波是短时窗口内的形态特征全局注意力反而抓不住。于是“CNN提局部时空特征 Transformer建模长程依赖”的混合结构成了不少论文和工程方案里的新基线。这篇笔记面向想复现这套方案的开发者从数据格式、模型搭建、训练参数到踩坑排查一步步走通。适合有PyTorch基础、做过简单分类任务、想切入脑电解码方向的读者。2. 数据准备与CNN局部时空特征提取从原始EEG到可训练张量2.1 运动想象数据的标准格式与预处理链路常见公开数据集如BCI Competition IV 2a的格式是.mat或.gdf采样率250Hz通道数22每个试次持续4秒左右。我一般先把数据整理成统一张量形状为(试次, 通道, 时间点)。预处理链路固定为带通滤波0.5-40Hz → 陷波50Hz → 按试次切分 → 基线校正取t0前0.5秒均值→ 标准化按通道做z-score。这里有个血泪经验标准化必须逐试次做不能全局做否则被试间幅值差异会把模型带偏。import numpy as np from scipy.signal import butter, filtfilt, iirnotch def preprocess_eeg(raw_data, fs250): # raw_data: (trials, channels, time) # 带通滤波 0.5-40Hz b, a butter(4, [0.5/(fs/2), 40/(fs/2)], btypeband) filtered filtfilt(b, a, raw_data, axis-1) # 50Hz陷波 b_notch, a_notch iirnotch(50/(fs/2), 30) filtered filtfilt(b_notch, a_notch, filtered, axis-1) # 逐试次z-score标准化 mean filtered.mean(axis-1, keepdimsTrue) std filtered.std(axis-1, keepdimsTrue) 1e-8 normalized (filtered - mean) / std return normalized.astype(np.float32)滤波阶数选4阶是因为再高容易在边缘产生振铃运动想象频段集中在8-30Hz0.5-40Hz足够覆盖。陷波Q值设30窄带抑制工频干扰。标准化加1e-8防止除零。这段代码跑完数据就可以送进CNN了。2.2 CNN提取局部时空特征的卷积核设计脑电有两个维度空间通道和时间。常见做法是用两层卷积第一层沿时间方向卷积提取局部节律第二层沿通道方向卷积融合空间信息。卷积核大小我一般设时间核64约256ms、通道核1第二层时间核1、通道核22全通道融合。这样第一层输出(batch, 16, channels, time)第二层输出(batch, 32, 1, time)再压缩掉通道维。import torch import torch.nn as nn class CNNFeatureExtractor(nn.Module): def __init__(self, n_channels22, n_time1000): super().__init__() # 第一层时间卷积提取局部节律 self.temporal_conv nn.Sequential( nn.Conv2d(1, 16, kernel_size(1, 64), padding(0, 32)), nn.BatchNorm2d(16), nn.ELU(), nn.AvgPool2d(kernel_size(1, 4)) # 时间降采样 ) # 第二层空间卷积融合通道信息 self.spatial_conv nn.Sequential( nn.Conv2d(16, 32, kernel_size(n_channels, 1)), nn.BatchNorm2d(32), nn.ELU(), nn.AvgPool2d(kernel_size(1, 4)) ) def forward(self, x): # x: (batch, 1, channels, time) x self.temporal_conv(x) x self.spatial_conv(x) return x # (batch, 32, 1, time)时间卷积核64对应250Hz下256ms窗口刚好覆盖一个mu波周期。padding设32保持时间维度不变池化核4把时间压缩到1/4。空间卷积核设为全通道一次融合所有电极。ELU比ReLU在负值区有输出对脑电这种零均值信号更友好。BatchNorm放在卷积后激活前稳定训练。输出时间维度约1000/4/462后续送Transformer。2.3 把CNN输出转成Transformer可吃的序列CNN输出是(batch, 32, 1, 62)Transformer需要(batch, seq_len, d_model)。做法是squeeze掉通道维转置成(batch, 62, 32)再用线性层映射到d_model64。位置编码用可学习的位置嵌入因为脑电的时序位置有生理意义正弦编码反而不如可学习的好调。class CNNToSequence(nn.Module): def __init__(self, cnn_out_dim32, d_model64, max_len100): super().__init__() self.proj nn.Linear(cnn_out_dim, d_model) self.pos_embed nn.Parameter(torch.randn(1, max_len, d_model) * 0.02) def forward(self, x): # x: (batch, 32, 1, time) x x.squeeze(2).transpose(1, 2) # (batch, time, 32) x self.proj(x) # (batch, time, d_model) x x self.pos_embed[:, :x.size(1), :] return x位置嵌入初始化用0.02标准差和Transformer原论文一致。max_len设100留余量实际序列长度62。线性映射把32维升到64维给自注意力更多表达空间。这一步做完数据就正式进入Transformer编码器了。3. Transformer编码器搭建自注意力怎么用在脑电序列上3.1 编码器层数与注意力头数的选型依据脑电序列长度只有62比NLP的几百上千短得多所以编码器层数不用堆太深。我试过2层、4层、6层4层在BCI IV 2a上验证集准确率最高6层开始过拟合。注意力头数选4每个头维度16总维度64。头数太多会稀释每个头的表达能力太少又抓不住多尺度依赖。前馈网络维度设128是d_model的2倍比标准的4倍小因为序列短参数量要控制。class TransformerEncoder(nn.Module): def __init__(self, d_model64, nhead4, num_layers4, dim_ff128, dropout0.3): super().__init__() encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwarddim_ff, dropoutdropout, activationgelu, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) def forward(self, x): return self.encoder(x)dropout设0.3脑电数据量小正则要强。激活用GELU比ReLU平滑对梯度更友好。batch_firstTrue让输入形状是(batch, seq, dim)省去转置。4层编码器参数量约20万加上CNN部分总共不到50万单卡就能训。3.2 分类头与损失函数的选择编码器输出(batch, 62, 64)做时间维平均池化得到(batch, 64)再接全连接层到类别数。损失函数用交叉熵但加标签平滑0.1防止模型对某一类过度自信。优化器选AdamW学习率3e-4权重衰减0.01。学习率调度用余弦退火 warmup 5个epoch。class MI_Classifier(nn.Module): def __init__(self, n_classes4): super().__init__() self.cnn CNNFeatureExtractor() self.seq CNNToSequence() self.transformer TransformerEncoder() self.head nn.Sequential( nn.Linear(64, 32), nn.GELU(), nn.Dropout(0.3), nn.Linear(32, n_classes) ) def forward(self, x): # x: (batch, 1, channels, time) x self.cnn(x) x self.seq(x) x self.transformer(x) x x.mean(dim1) # 时间维平均 return self.head(x)平均池化比取最后一个token更稳因为脑电没有明确的“句尾”。分类头两层中间32维参数量小。标签平滑在CrossEntropyLoss里设label_smoothing0.1。AdamW的权重衰减独立于梯度更新比Adam更适合Transformer。3.3 训练循环与早停策略训练循环里每个epoch记录训练损失和验证准确率。早停 patience 设15验证损失连续15轮不降就停。学习率warmup用线性从1e-6升到3e-4用5个epoch之后余弦退火到1e-6。batch size设32太大泛化差太小训练慢。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) # 初始化 model MI_Classifier(n_classes4).to(device) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer AdamW(model.parameters(), lr3e-4, weight_decay0.01) scheduler CosineAnnealingWarmRestarts(optimizer, T_010, T_mult2)梯度裁剪设1.0防止Transformer梯度爆炸。CosineAnnealingWarmRestarts的T_010每10个epoch重启一次学习率帮助跳出局部最优。早停逻辑单独写监控验证损失。这套配置在2a数据集上被试内分类准确率能到75%左右比纯CNN高3-5个点。4. 避坑与排查运动想象分类里最容易翻车的五个地方4.1 准确率卡在随机水平不动现象训练损失下降验证准确率一直在25%附近四分类。原因数据标签没对齐或者预处理时把试次切错了。解决打印每个试次的标签分布确认四类均衡检查切分起点是否在t0之后运动想象窗口一般取0.5-2.5秒。我遇到过标签文件里类别编码是1-4但模型输出是0-3差一位全错。4.2 验证集准确率波动超过10%现象同一被试不同fold之间准确率忽高忽低。原因数据量小试次少划分随机性大。解决用分层K折每折保证各类比例一致增加被试内试次数量或者做数据增强时间裁剪、加高斯噪声。我一般把试次重叠切分步长减半样本量翻倍。4.3 模型过拟合训练准确率99%验证60%现象训练集很快到99%验证集停在60%不涨。原因参数量相对数据量太大dropout不够。解决减小d_model到32编码器层数降到2dropout提到0.5加L2正则权重衰减调到0.05。血泪经验脑电数据被试内通常只有几百个试次模型参数量控制在10万以内比较稳。4.4 注意力权重全是均匀分布现象可视化自注意力矩阵发现每个位置权重差不多。原因位置编码没学好或者序列太短注意力退化成平均。解决检查位置嵌入是否参与训练requires_gradTrue把序列长度通过CNN池化拉长到100以上给注意力更多区分空间。我试过把池化核从4降到2序列长度124注意力开始有聚焦。4.5 跨被试迁移直接崩掉现象被试内75%跨被试掉到40%。原因不同人头皮阻抗、电极位置差异导致分布偏移。解决做被试间标准化用所有被试的均值和方差或者加域适应层如CORAL。常见做法是先在多个被试上预训练再目标被试微调。注意跨被试是脑电的老大难别指望一个模型通吃能到55%就算不错。5. 进阶技巧用注意力可视化验证模型是否学到了运动想象节律训练完模型怎么确认它真的在学运动想象而不是靠噪声我一般做两件事一是画自注意力权重看模型关注的时间段是否落在运动想象窗口0.5-2.5秒二是画CNN第一层卷积核的频响看是否在mu波8-13Hz和beta波13-30Hz有峰值。这两个验证做完心里才有底。注意力可视化代码很简单把TransformerEncoderLayer的self_attn输出拿出来。但PyTorch默认不返回注意力权重需要手动hook或者重写forward。我一般重写一个带return_attn的版本class AttnHookEncoder(nn.Module): def __init__(self, encoder): super().__init__() self.encoder encoder self.attn_weights [] def forward(self, x): self.attn_weights [] for layer in self.encoder.layers: # 手动计算自注意力 x_norm layer.norm1(x) attn_out, attn_w layer.self_attn(x_norm, x_norm, x_norm, need_weightsTrue) self.attn_weights.append(attn_w.detach().cpu()) x x layer.dropout1(attn_out) x x layer._ff_block(layer.norm2(x)) return x拿到注意力权重后对batch维平均得到(seq_len, seq_len)矩阵。用matplotlib画热力图横纵轴都是时间点。如果模型学到东西对角线附近和运动想象窗口对应的区域会亮。如果全图均匀说明注意力没起作用得回去调位置编码或增加序列长度。CNN卷积核频响分析把第一层时间卷积的权重(16, 1, 1, 64)取出来对每个核做FFT看主频。代码import matplotlib.pyplot as plt weights model.cnn.temporal_conv[0].weight.data.cpu().numpy() # (16,1,1,64) for i in range(16): kernel weights[i, 0, 0, :] freq np.fft.rfft(kernel, n256) amp np.abs(freq) plt.plot(amp, labelfkernel {i}) plt.xlabel(Frequency bin) plt.ylabel(Amplitude) plt.legend() plt.show()如果多数核在8-13Hz或13-30Hz有峰值说明CNN确实在提运动想象节律。如果峰值在50Hz或0Hz说明滤波没做好或者卷积核学歪了。这个验证方法比只看准确率靠谱得多。最后一个习惯每次跑完实验把配置文件、随机种子、验证集划分方式存成一个json下次复现不用猜。脑电实验玄学多种子不同结果能差5个点固定种子是后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表