尧图精选

CNN+Transformer运动想象脑电分类:本科毕设完整代码拆解与避坑指南

🕒 发布时间:2026/9/26 7:36:38 📁 来源:尧图网络
简介这份本科毕业设计资源聚焦于基于Transformer的运动想象脑电信号分类面向人工智能与生物医学工程交叉方向的本科生及脑机接口入门研究者。项目采用CNNTransformer混合框架由CNN提取局部时空特征、Transformer捕捉全局依赖覆盖EEG数据预处理、特征提取、模型构建训练与k折交叉验证评估的完整流程并配有可视化与统计分析模块。压缩包共31个文件约18.45MB以23个Python脚本为核心辅以2个MATLAB预处理脚本、2个xlsx实验数据表、1个pth模型权重、1个npy数据文件及xml、md等配置说明结构清晰便于复现。目前已有275人学习下载。读者可据此获得一套可运行的毕业设计参考方案理解自注意力机制在脑电序列建模中的应用并借助t-SNE、CAM热力图、AUC与箱线图等脚本完成结果分析与论文图表绘制。1. 拆开这个本科毕业设计CNNTransformer 做运动想象脑电分类到底能不能跑运动想象脑电信号分类这个方向每年毕业季都有大量同学在找可复现的代码包。我拿到这个压缩包的第一反应是目录结构比想象中完整——preprocess.m、getData.m、make_4class_data.py负责数据管线CNNTransformer.py、EEGNet.py、Spatial_Temporal_Attention.py是模型定义train2_kfold.py做交叉验证visualization目录下有 tSNE、CAM、脑地形图、箱线图、AUC 曲线一整套分析脚本还附带了一个训练好的conformer_40x300x5x81.6_sub1.pth权重文件。这意味着它不是那种只丢一个模型文件让你自己猜数据怎么进的半成品而是从原始 EEG 到分类结果再到可视化解释的完整链路。适合谁用如果你正在做脑机接口、运动想象分类、或者想把 Transformer 架构套到生理信号上的毕业设计这个包能省掉大量搭框架的时间。但前提是你得先搞清楚它的数据格式约定和预处理流程否则直接跑训练脚本大概率会在数据加载那一步就翻车。下面按我实际拆包的顺序把关键环节一个个讲透。2. 数据管线拆解从.mat原始文件到train_data.npy的完整路径2.1 预处理脚本的分工与调用顺序这个项目的数据处理分两段MATLAB 端和 Python 端。MATLAB 脚本负责从原始 EEG 数据集中提取试次、做基础滤波和分段Python 脚本负责进一步的特征构造和格式转换。先看 MATLAB 端的两个核心文件% preprocess.m 的核心逻辑简化还原 % 假设原始数据来自 BCI Competition IV 2a 数据集 % 采样率 250Hz9 个被试每个被试 2 个 session function preprocess(subject_id) % 1. 加载原始 .gdf 或 .mat 文件 raw load(sprintf(A%02dT.gdf, subject_id)); % 2. 带通滤波 8-30Hz运动想象核心频段 [b, a] butter(4, [8 30]/(250/2), bandpass); filtered filtfilt(b, a, raw); % 3. 按事件标记切分试次时间窗 [0.5s, 3.5s] % 即 cue 出现后 0.5 秒到 3.5 秒共 3 秒 750 个采样点 epochs extract_epochs(filtered, events, 250, 0.5, 3.5); % 4. 保存为 .mat 供 Python 读取 save(sprintf(sub%d_epochs.mat, subject_id), epochs); end这里有几个参数值得注意。滤波频段选 8-30Hz 是运动想象的标准操作mu 节律8-13Hz和 beta 节律13-30Hz是 ERD/ERS 现象的主要载体。时间窗从 cue 后 0.5 秒开始是为了避开视觉诱发电位的干扰。如果你用的是自己的数据这两个参数需要根据实验范式调整。getData.m则负责批量处理多个被试循环调用preprocess并统一保存。我一般会把被试编号列表写成配置数组方便增删。Python 端的make_4class_data.py做的是另一件事把二分类问题扩展为四分类。运动想象标准数据集通常是左手/右手/双脚/舌头四类但很多简化版只做左右手二分类。这个脚本通过组合不同试次构造四类标签具体逻辑如下# make_4class_data.py 核心逻辑 import numpy as np from scipy.io import loadmat def make_4class(subject_id): # 加载 MATLAB 预处理后的数据 data loadmat(fsub{subject_id}_epochs.mat) epochs data[epochs] # shape: (trials, channels, timepoints) labels data[labels].flatten() # 四类标签映射1左手, 2右手, 3双脚, 4舌头 # 如果原始数据只有二分类需要通过滑动窗口增强 # 常见做法将每个试次切成多个子窗口扩充样本量 window_size 300 # 1.2秒 250Hz stride 50 # 步长 0.2秒 augmented_data [] augmented_labels [] for trial, label in zip(epochs, labels): for start in range(0, trial.shape[1] - window_size 1, stride): segment trial[:, start:startwindow_size] augmented_data.append(segment) augmented_labels.append(label) X np.array(augmented_data) # (samples, channels, 300) y np.array(augmented_labels) # 保存为 npy 格式供训练脚本直接加载 np.save(ftrain_data_sub{subject_id}.npy, X) np.save(ftrain_labels_sub{subject_id}.npy, y) print(fSubject {subject_id}: {X.shape[0]} samples, {len(np.unique(y))} classes) return X, y这段代码的关键在于数据增强策略。原始 EEG 试次数量通常很少每个被试每类 30-40 个试次直接训练 Transformer 几乎必然过拟合。滑动窗口切分是最简单的扩充方式但要注意窗口之间不能有数据泄漏——如果后续做交叉验证必须按原始试次划分训练集和验证集而不是按切分后的样本随机划分。这个坑我在后面会详细说。2.2 数据格式约定与加载验证项目里出现的train_data.npy和conformer_40x300x5x81.6_sub1.pth这两个文件名透露了关键信息。40x300大概率对应 40 个通道、300 个时间点5可能是五折交叉验证的折数81.6是某个性能指标准确率或 AUCsub1表示被试 1。在跑训练之前建议先用一段小脚本验证数据加载是否正确import numpy as np # 验证数据形状和标签分布 X np.load(train_data.npy) y np.load(train_labels.npy) print(f数据形状: {X.shape}) # 期望 (samples, channels, timepoints) print(f标签形状: {y.shape}) print(f类别分布: {np.bincount(y)}) # 检查是否类别不平衡 print(f数据范围: [{X.min():.3f}, {X.max():.3f}]) print(f是否存在 NaN: {np.isnan(X).any()}) # 检查每个通道的方差方差过小的通道可能是坏导 channel_var X.var(axis(0, 2)) bad_channels np.where(channel_var 1e-6)[0] print(f疑似坏导通道: {bad_channels})如果X.shape不是三维的或者标签分布严重不均后面的训练脚本大概率会报错或给出无意义的结果。这一步花两分钟能省掉后面半小时的调试。3. CNNTransformer 模型架构局部特征提取与全局依赖建模怎么配合3.1 CNN 前端的设计逻辑与参数含义这个项目的核心模型定义在CNNTransformer.py里。整体思路是先用 CNN 提取局部时空特征再把特征序列送入 Transformer 编码器捕捉全局依赖。这个设计在 EEG 领域是有依据的——CNN 擅长捕捉局部波形模式比如某个通道在特定时间窗内的 ERD 现象而 Transformer 的自注意力机制能建模不同通道、不同时间段之间的长程关系。先看 CNN 前端部分# CNNTransformer.py 中 CNN 前端的关键代码 import torch import torch.nn as nn class CNNFrontend(nn.Module): def __init__(self, n_channels40, n_timepoints300, d_model64): super().__init__() # 时间维度卷积捕捉局部时间模式 self.temporal_conv nn.Sequential( nn.Conv2d(1, 16, kernel_size(1, 25), padding(0, 12)), nn.BatchNorm2d(16), nn.ELU(), nn.AvgPool2d(kernel_size(1, 4)) # 时间降采样 4 倍 ) # 空间维度卷积跨通道特征融合 self.spatial_conv nn.Sequential( nn.Conv2d(16, 32, kernel_size(n_channels, 1)), nn.BatchNorm2d(32), nn.ELU(), nn.Dropout2d(0.25) ) # 投影到 Transformer 的维度 self.projection nn.Linear(32, d_model) def forward(self, x): # x: (batch, 1, channels, timepoints) x self.temporal_conv(x) # (batch, 16, channels, timepoints//4) x self.spatial_conv(x) # (batch, 32, 1, timepoints//4) x x.squeeze(2) # (batch, 32, timepoints//4) x x.permute(0, 2, 1) # (batch, timepoints//4, 32) x self.projection(x) # (batch, timepoints//4, d_model) return x时间卷积的 kernel_size 设为(1, 25)对应 250Hz 采样率下 100ms 的窗口这个尺度刚好覆盖一个完整的 mu 节律周期。AvgPool 的(1, 4)把时间维度压缩到原来的四分之一300 个时间点变成 75 个大幅减少了 Transformer 的序列长度和计算量。空间卷积的 kernel_size 设为(n_channels, 1)这是一个全通道卷积等价于在通道维度上做一次线性组合。这种设计在 EEGNet 里也有类似体现目的是让网络自己学习通道之间的权重关系而不是依赖人工选择的通道子集。3.2 Transformer 编码器的实现细节CNN 前端输出的序列送入 Transformer 编码器class TransformerEncoder(nn.Module): def __init__(self, d_model64, nhead4, num_layers2, dim_feedforward128, dropout0.1): super().__init__() # 位置编码可学习的位置嵌入 self.pos_embedding nn.Parameter(torch.randn(1, 100, d_model) * 0.02) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwarddim_feedforward, dropoutdropout, activationgelu, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.norm nn.LayerNorm(d_model) def forward(self, x): # x: (batch, seq_len, d_model) seq_len x.size(1) x x self.pos_embedding[:, :seq_len, :] x self.encoder(x) x self.norm(x) # 取序列平均作为分类特征 x x.mean(dim1) # (batch, d_model) return x这里有几个设计选择值得展开。nhead4、num_layers2是一个轻量配置参数量不大适合 EEG 这种小样本场景。如果层数加到 6 层以上在没有大规模预训练的情况下几乎必然过拟合。位置编码用的是可学习的位置嵌入而不是正弦位置编码——对于固定长度的 EEG 片段可学习嵌入更灵活但要求输入序列长度固定。分类头接在 Transformer 输出后面class CNNTransformer(nn.Module): def __init__(self, n_channels40, n_timepoints300, n_classes4, d_model64): super().__init__() self.cnn CNNFrontend(n_channels, n_timepoints, d_model) self.transformer TransformerEncoder(d_model) self.classifier nn.Sequential( nn.Linear(d_model, 32), nn.ELU(), nn.Dropout(0.3), nn.Linear(32, n_classes) ) def forward(self, x): # x: (batch, 1, channels, timepoints) features self.cnn(x) features self.transformer(features) logits self.classifier(features) return logits项目里还有一个CNNTransformer_notransformer.py应该是消融实验用的——去掉 Transformer 部分只用 CNN 做分类。这个文件对于写毕业论文很有价值可以直接用来做对比实验证明 Transformer 模块的增益。3.3 训练脚本与五折交叉验证train2_kfold.py是主训练脚本核心流程如下# train2_kfold.py 核心训练循环 import torch from torch.utils.data import DataLoader, TensorDataset from sklearn.model_selection import StratifiedKFold def train_kfold(X, y, n_splits5, epochs100, batch_size32, lr1e-3): skf StratifiedKFold(n_splitsn_splits, shuffleTrue, random_state42) fold_results [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): # 注意这里必须按原始试次划分不能用增强后的样本 X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] # 转换为 PyTorch 张量 train_dataset TensorDataset( torch.FloatTensor(X_train).unsqueeze(1), # 增加通道维度 torch.LongTensor(y_train) ) val_dataset TensorDataset( torch.FloatTensor(X_val).unsqueeze(1), torch.LongTensor(y_val) ) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size) # 初始化模型 model CNNTransformer(n_channelsX.shape[1], n_timepointsX.shape[2]) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.CosineAnnealingLR(optimizer, T_maxepochs) criterion nn.CrossEntropyLoss() best_val_acc 0.0 for epoch in range(epochs): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for batch_x, batch_y in val_loader: logits model(batch_x) preds logits.argmax(dim1) correct (preds batch_y).sum().item() total batch_y.size(0) val_acc correct / total if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), fbest_model_fold{fold}.pth) fold_results.append(best_val_acc) print(fFold {fold}: best val acc {best_val_acc:.4f}) print(fMean acc: {np.mean(fold_results):.4f} /- {np.std(fold_results):.4f}) return fold_results学习率用1e-3配合 AdamW 和余弦退火这是 Transformer 类模型的常见配置。梯度裁剪的max_norm1.0是为了防止训练初期梯度爆炸EEG 信号幅度波动大不加裁剪很容易出现 loss 变成 NaN 的情况。注意交叉验证的划分必须在数据增强之前完成。如果先做滑动窗口增强再随机划分同一个试次的子窗口会同时出现在训练集和验证集里导致验证准确率虚高。这个坑非常隐蔽很多人跑出 95% 的准确率还以为是模型好实际上是数据泄漏。4. 可视化与可解释性tSNE、CAM 和脑地形图怎么用4.1 tSNE 特征降维与类别可分性验证visualization目录下的tSNE.py用来把 Transformer 输出的特征降到二维直观展示不同类别的聚类情况# tSNE.py 核心逻辑 import numpy as np import torch from sklearn.manifold import TSNE import matplotlib.pyplot as plt def visualize_tsne(model, data_loader, devicecpu): model.eval() all_features [] all_labels [] with torch.no_grad(): for batch_x, batch_y in data_loader: batch_x batch_x.to(device) # 提取 Transformer 编码后的特征分类头之前 features model.transformer(model.cnn(batch_x)) all_features.append(features.cpu().numpy()) all_labels.append(batch_y.numpy()) features np.concatenate(all_features, axis0) labels np.concatenate(all_labels, axis0) # tSNE 降维 tsne TSNE(n_components2, perplexity30, n_iter1000, random_state42) embedded tsne.fit_transform(features) # 绘图 plt.figure(figsize(8, 6)) scatter plt.scatter(embedded[:, 0], embedded[:, 1], clabels, cmaptab10, s10, alpha0.7) plt.colorbar(scatter, labelClass) plt.title(tSNE Visualization of Transformer Features) plt.savefig(tsne_result.png, dpi300, bbox_inchestight) plt.show()perplexity 设为 30 是 tSNE 的常用默认值对于几百到几千个样本的数据集比较合适。如果类别在 tSNE 图上混在一起分不开说明模型学到的特征判别性不够可能需要增加 Transformer 层数或调整学习率。4.2 CAM 类激活图与脑区贡献分析CAM.py和cam_method.py实现了类激活映射用来回答“模型在做分类决策时哪些通道、哪些时间段贡献最大”# cam_method.py 核心逻辑基于梯度加权 import torch import torch.nn.functional as F class GradCAM: def __init__(self, model, target_layer): self.model model self.target_layer target_layer self.gradients None self.activations None self._register_hooks() def _register_hooks(self): def forward_hook(module, input, output): self.activations output.detach() def backward_hook(module, grad_input, grad_output): self.gradients grad_output[0].detach() self.target_layer.register_forward_hook(forward_hook) self.target_layer.register_backward_hook(backward_hook) def generate(self, input_tensor, target_classNone): output self.model(input_tensor) if target_class is None: target_class output.argmax(dim1).item() self.model.zero_grad() one_hot torch.zeros_like(output) one_hot[0, target_class] 1 output.backward(gradientone_hot) # 全局平均池化梯度得到通道权重 weights self.gradients.mean(dim(2, 3), keepdimTrue) cam (weights * self.activations).sum(dim1, keepdimTrue) cam F.relu(cam) cam F.interpolate(cam, sizeinput_tensor.shape[2:], modebilinear, align_cornersFalse) cam cam.squeeze().cpu().numpy() return cam生成的 CAM 图叠加到脑地形图上就能看出模型主要依赖哪些脑区做判断。对于运动想象任务如果模型确实学到了 ERD 模式CAM 高亮区域应该集中在感觉运动皮层对应的通道C3、C4、Cz 附近。如果高亮区域散乱分布说明模型可能学到了伪迹或噪声。brain_heatmap.py负责把通道级别的权重映射到二维脑地形图上cam_22channels.xlsx和weights.xlsx存储了通道名称和对应的权重数据。这套可视化链路对于写毕业论文的“结果分析”章节非常有用。5. 避坑与排查跑通这个项目最容易翻车的五个地方5.1 数据泄漏导致验证准确率虚高现象五折交叉验证每折的验证准确率都在 95% 以上但换一批数据测试时准确率骤降到 50% 左右。原因滑动窗口增强在交叉验证之前执行同一个原始试次的多个子窗口被分到了训练集和验证集。模型在训练时“见过”验证集样本的邻近片段导致评估结果严重偏高。解决先按原始试次划分训练/验证集再分别对训练集做增强。验证集不做增强或者只做不重叠的切分。代码层面StratifiedKFold的输入应该是原始试次级别的索引而不是增强后的样本索引。5.2 通道数或时间点不匹配导致模型报错现象运行train2_kfold.py时报RuntimeError: Given groups1, weight of size [16, 1, 1, 25], expected input[32, 40, 300, 1] to have 1 channel。原因数据加载时维度顺序搞错了。PyTorch 的Conv2d期望输入是(batch, channels, height, width)EEG 数据需要整理成(batch, 1, n_channels, n_timepoints)。如果直接把(batch, n_channels, n_timepoints)喂进去或者把通道维和时间维搞反了就会报这个错。解决在TensorDataset构造时用.unsqueeze(1)增加一个维度并确认X.shape是(samples, channels, timepoints)。如果原始数据是(samples, timepoints, channels)需要先transpose。5.3 学习率过大导致 loss 变成 NaN现象训练几个 epoch 后 loss 突然变成 NaN或者准确率剧烈震荡不收敛。原因Transformer 的自注意力层对学习率敏感1e-2以上的学习率很容易导致梯度爆炸。另外 EEG 信号如果没做归一化幅度差异大也会加剧这个问题。解决学习率控制在1e-3到1e-4之间配合梯度裁剪clip_grad_norm_的max_norm设为 1.0。数据预处理阶段做 z-score 归一化每个通道单独减均值除标准差。5.4 MATLAB 和 Python 数据格式不一致现象make_4class_data.py读取.mat文件后数据形状和预期不符或者标签全是 0。原因MATLAB 保存.mat文件时默认使用列优先存储Python 的scipy.io.loadmat读出来的数组维度顺序可能和 MATLAB 里看到的不一样。另外 MATLAB 的标签从 1 开始计数Python 从 0 开始直接拿来用会导致标签越界或全错。解决在 MATLAB 保存时用save(file.mat, data, -v7.3)指定版本Python 读取后用np.transpose调整维度顺序。标签统一减 1 转为 0-indexed。5.5 显存不足导致训练中断现象RuntimeError: CUDA out of memory。原因batch_size32配合 40 通道、300 时间点的输入如果模型参数量大或者 GPU 显存小比如 4GB很容易爆显存。解决把batch_size降到 16 或 8或者用梯度累积模拟大 batch。也可以在 CNN 前端增加池化层进一步压缩时间维度。如果实在不够把d_model从 64 降到 32num_layers从 2 降到 1。6. 进阶技巧用预训练权重做迁移学习与模型微调项目里附带的conformer_40x300x5x81.6_sub1.pth是一个已经训练好的权重文件。如果你的数据通道数和时间点与它一致40 通道、300 时间点可以直接加载做微调省掉从零训练的时间。# 加载预训练权重并微调 import torch def load_pretrained_and_finetune(model, pretrained_path, freeze_cnnTrue): state_dict torch.load(pretrained_path, map_locationcpu) # 加载权重忽略分类头因为类别数可能不同 model_dict model.state_dict() pretrained_dict {k: v for k, v in state_dict.items() if k in model_dict and v.shape model_dict[k].shape} model_dict.update(pretrained_dict) model.load_state_dict(model_dict) # 冻结 CNN 前端只训练 Transformer 和分类头 if freeze_cnn: for param in model.cnn.parameters(): param.requires_grad False # 只优化需要梯度的参数 optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr5e-4, # 微调学习率比从零训练小一个量级 weight_decay1e-4 ) return model, optimizer冻结 CNN 前端的理由是CNN 学到的局部时空特征在不同被试之间有一定的通用性而 Transformer 的全局依赖模式因人而异更需要微调。如果你的数据通道数不同CNN 的空间卷积层无法直接加载这时候只能冻结时间卷积部分空间卷积和 Transformer 一起重新训练。微调时的学习率建议设为从零训练的十分之一到五分之一。另外微调阶段建议用更小的 batch_size 和更多的 epoch让模型有足够的时间适应新数据分布。还有一个实用技巧如果目标被试的数据量很少比如只有几十个试次可以先用预训练权重做特征提取器把 Transformer 输出的特征拿出来训练一个 SVM 或逻辑回归。这种做法在小样本场景下往往比端到端微调更稳定因为可训练参数少过拟合风险低。# 用预训练模型做特征提取 SVM 分类 from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline def extract_features_and_classify(model, X_train, y_train, X_test, y_test): model.eval() with torch.no_grad(): train_features model.transformer( model.cnn(torch.FloatTensor(X_train).unsqueeze(1)) ).numpy() test_features model.transformer( model.cnn(torch.FloatTensor(X_test).unsqueeze(1)) ).numpy() # 用 SVM 做分类 clf make_pipeline(StandardScaler(), SVC(kernelrbf, C1.0, gammascale)) clf.fit(train_features, y_train) accuracy clf.score(test_features, y_test) print(fSVM accuracy on extracted features: {accuracy:.4f}) return clf这个思路在 EEG 领域很常见因为 EEG 数据标注成本高每个被试的可用试次有限。预训练模型加大间隔分类器的组合往往能在小样本下拿到比端到端微调更好的结果。从那以后我每次拿到带预训练权重的 EEG 项目都会先跑一遍特征提取加 SVM 的基线再决定要不要做端到端微调。这个习惯帮我省了很多调参时间也避免了在小数据集上盲目训练大模型。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →