简介:这份本科毕业设计资源聚焦于基于Transformer的运动想象脑电信号分类,面向人工智能与生物医学工程交叉方向的本科生及脑机接口入门研究者。项目采用CNN+Transformer混合框架,由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. 拆开这个本科毕业设计:CNN+Transformer 做运动想象脑电分类,到底能不能跑
运动想象脑电信号分类这个方向,每年毕业季都有大量同学在找可复现的代码包。我拿到这个压缩包的第一反应是:目录结构比想象中完整——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 数据集 % 采样率 250Hz,9 个被试,每个被试 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(f'sub{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:start+window_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(f'train_data_sub{subject_id}.npy', X) np.save(f'train_labels_sub{subject_id}.npy', y) print(f'Subject {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是某个性能指标(准确率或 AUC),sub1表示被试 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. CNN+Transformer 模型架构:局部特征提取与全局依赖建模怎么配合
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_channels=40, n_timepoints=300, d_model=64): 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_model=64, nhead=4, num_layers=2, dim_feedforward=128, dropout=0.1): super().__init__() # 位置编码:可学习的位置嵌入 self.pos_embedding = nn.Parameter(torch.randn(1, 100, d_model) * 0.02) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=dropout, activation='gelu', batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_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(dim=1) # (batch, d_model) return x这里有几个设计选择值得展开。nhead=4、num_layers=2是一个轻量配置,参数量不大,适合 EEG 这种小样本场景。如果层数加到 6 层以上,在没有大规模预训练的情况下几乎必然过拟合。位置编码用的是可学习的位置嵌入,而不是正弦位置编码——对于固定长度的 EEG 片段,可学习嵌入更灵活,但要求输入序列长度固定。
分类头接在 Transformer 输出后面:
class CNNTransformer(nn.Module): def __init__(self, n_channels=40, n_timepoints=300, n_classes=4, d_model=64): 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_splits=5, epochs=100, batch_size=32, lr=1e-3): skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) 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_size=batch_size, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=batch_size) # 初始化模型 model = CNNTransformer(n_channels=X.shape[1], n_timepoints=X.shape[2]) optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = torch.optim.CosineAnnealingLR(optimizer, T_max=epochs) 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_norm=1.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(dim=1) 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(), f'best_model_fold{fold}.pth') fold_results.append(best_val_acc) print(f'Fold {fold}: best val acc = {best_val_acc:.4f}') print(f'Mean acc: {np.mean(fold_results):.4f} +/- {np.std(fold_results):.4f}') return fold_results学习率用1e-3配合 AdamW 和余弦退火,这是 Transformer 类模型的常见配置。梯度裁剪的max_norm=1.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, device='cpu'): 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, axis=0) labels = np.concatenate(all_labels, axis=0) # tSNE 降维 tsne = TSNE(n_components=2, perplexity=30, n_iter=1000, random_state=42) embedded = tsne.fit_transform(features) # 绘图 plt.figure(figsize=(8, 6)) scatter = plt.scatter(embedded[:, 0], embedded[:, 1], c=labels, cmap='tab10', s=10, alpha=0.7) plt.colorbar(scatter, label='Class') plt.title('tSNE Visualization of Transformer Features') plt.savefig('tsne_result.png', dpi=300, bbox_inches='tight') 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_class=None): output = self.model(input_tensor) if target_class is None: target_class = output.argmax(dim=1).item() self.model.zero_grad() one_hot = torch.zeros_like(output) one_hot[0, target_class] = 1 output.backward(gradient=one_hot) # 全局平均池化梯度,得到通道权重 weights = self.gradients.mean(dim=(2, 3), keepdim=True) cam = (weights * self.activations).sum(dim=1, keepdim=True) cam = F.relu(cam) cam = F.interpolate(cam, size=input_tensor.shape[2:], mode='bilinear', align_corners=False) 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 groups=1, 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_size=32配合 40 通道、300 时间点的输入,如果模型参数量大或者 GPU 显存小(比如 4GB),很容易爆显存。
解决:把batch_size降到 16 或 8,或者用梯度累积模拟大 batch。也可以在 CNN 前端增加池化层,进一步压缩时间维度。如果实在不够,把d_model从 64 降到 32,num_layers从 2 降到 1。
6. 进阶技巧:用预训练权重做迁移学习与模型微调
项目里附带的conformer_40x300x5x81.6_sub1.pth是一个已经训练好的权重文件。如果你的数据通道数和时间点与它一致(40 通道、300 时间点),可以直接加载做微调,省掉从零训练的时间。
# 加载预训练权重并微调 import torch def load_pretrained_and_finetune(model, pretrained_path, freeze_cnn=True): state_dict = torch.load(pretrained_path, map_location='cpu') # 加载权重(忽略分类头,因为类别数可能不同) 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()), lr=5e-4, # 微调学习率比从零训练小一个量级 weight_decay=1e-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(kernel='rbf', C=1.0, gamma='scale')) clf.fit(train_features, y_train) accuracy = clf.score(test_features, y_test) print(f'SVM accuracy on extracted features: {accuracy:.4f}') return clf这个思路在 EEG 领域很常见,因为 EEG 数据标注成本高,每个被试的可用试次有限。预训练模型加大间隔分类器的组合,往往能在小样本下拿到比端到端微调更好的结果。
从那以后我每次拿到带预训练权重的 EEG 项目,都会先跑一遍特征提取加 SVM 的基线,再决定要不要做端到端微调。这个习惯帮我省了很多调参时间,也避免了在小数据集上盲目训练大模型。希望帮到你。
本文还有配套的精品资源,点击获取