ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

运动想象脑电四分类:CNN+Transformer时序建模与EEG信号处理全流程解析

运动想象脑电四分类:CNN+Transformer时序建模与EEG信号处理全流程解析 简介面向本科生毕业设计的运动想象脑电信号分类项目围绕Transformer模型展开采用卷积神经网络与Transformer的混合框架卷积网络提取局部时空特征Transformer捕获全局依赖为解决脑电信号分类问题提供完整范例。压缩包内共31个文件大小18.45MB以23个Python脚本为主配合2个MATLAB脚本完成数据处理另有表格存放权重与通道信息以及模型权重、训练数据、说明文档和工程配置便于复现与阅读。已有276人浏览学习非常适合完成相关毕业设计、开展脑电分类实验或入门脑机接口研究的读者尤其适合有编程和深度学习基础的人群。包内包含多种模型代码及特征可视化、脑热图、类激活图、统计分析脚本覆盖数据预处理到结果分析全流程有助于理解算法细节并快速搭建自己的实验。1. 运动想象脑电信号四分类CNNTransformer 不是玄学是时序建模路线这套毕业设计资源最戳我的点不是它把 Transformer 塞进了脑电分类而是它把一整条链路都留了下来MATLAB 预处理、CSP 基线、CNNTransformer 主模型、五折交叉验证、tSNE 和脑电热图可视化连对比用的 EEGNet 和去掉 Transformer 的消融变体都在。运动想象脑电要做的是让模型从想象左手、右手、双脚、舌头四种动作时的 EEG 信号里学出可分的模式传统难点是信噪比极低、个体差异大单一 CNN 容易只顾局部纹理、抓不住跨时间步的依赖。Transformer 的自注意力恰好补上这一层。这份资源适合两类人下学期要交毕设、需要能跑通全流程代码的本科生以及刚进 BCI 方向、想抄一份完整训练评估框架的研究生。下文按数据链路、模型结构、训练评估、踩坑记录逐层拆最后讲可视化验证怎么做才不假。2. 数据处理链路22通道 EEG 怎么切成模型能吃的时间窗2.1 从 preprocess.m 到 make_4class_data.py这一串 MATLABPython 在干什么运动想象 EEG 的原始数据几乎不会直接进网络。头皮上采到的信号里有工频干扰、眼电伪迹、肌电噪声还有个体间基线漂移。这份资源把预处理放在 MATLAB 里做getData.m 负责读原始记录preprocess.m 做滤波和分段这是 BCI 方向很常规的分工MATLAB 的 EEGLAB 生态成熟处理伪迹手段多Python 侧专注建模。preprocess.m 里我预计是这么一套流程这也是运动想象任务最常见的参数组合% preprocess.m 核心流程 cfg []; cfg.dataset raw_data.edf; cfg.channel {all}; cfg.demean yes; cfg.bpfilter yes; % band-pass filter cfg.bpfreq [8 30]; % mu/beta rhythm 主频段 8-30 Hz cfg.detrend yes; data_filt ft_preprocessing(cfg); % 砍掉刺激前 0.5s刺激后取 4s 作为 trial cfg_trl []; cfg_trl.dataset raw_data.edf; cfg_trl.trialfun ft_trialfun_general; cfg_trl.trialdef.eventtype STIM; cfg_trl.trialdef.eventvalue [1 2 3 4]; % 四类运动想象标签 cfg_trl.trialdef.offset -0.5; cfg_trl.trialdef.duration 4.0; trl ft_definetrial(cfg_trl);这里最值得说的参数是滤波频段 8–30Hz。运动想象会导致感觉运动节律的幅值变化mu 节律约 8–12Hzbeta 节律约 13–30Hz把频带切在这一段能直接丢掉大量低频漂移和高频肌电。如果你用的是自己的数据先看一眼功率谱再决定频段曾有同学直接照搬 0.5–40Hz 全带通结果 Transformer 花了大半容量去拟合噪声。到 Python 侧make_4class_data.py 做的事就是把 MATLAB 导出的 trial 数据变成网络输入。常见做法是每个 trial 截成固定长度多通道按 [样本数, 通道数, 时间点数] 排好再把标签映射成 one-hot 或整数索引。这份资源里数据最终被整理成了 .npy训练脚本直接 np.load 就能用。# make_4class_data.py 的数据组织方式简化版 import numpy as np X np.load(train_data.npy) # shape: [n_trials, n_channels, n_times] y np.load(train_label.npy) # shape: [n_trials] # 四类映射左手/右手/双脚/舌头 - 0/1/2/3 class_map {left: 0, right: 1, feet: 2, tongue: 3} y_enc np.array([class_map[v] for v in y]) # 归一化不要整批一起算按通道独立做 z-score mean X.mean(axis(0, 2), keepdimsTrue) std X.std(axis(0, 2), keepdimsTrue) 1e-6 X (X - mean) / std通道维度的归一化是这里容易翻车的地方。EEG 不同通道的幅值差异可以很大枕区通道和中央沟附近的通道基线完全不在一个量级跨通道混在一起算均值和方差会让模型误以为幅值大的通道更重要。按通道独立做 z-score 是最稳的预处理基线之后无论换 CNN 还是 Transformer 都不用回头改数据。2.2 用 CSP 先做一个不依赖 Transformer 的基线common_spatial_pattern.py 是这份资源里一个清醒的存在。CSP公共空间模式是运动想象 EEG 分类的老牌特征提取方法它通过求两类信号的协方差矩阵联合对角化找出一组空间滤波器让一类信号投影后方差最大、另一类方差最小。虽然 CSP 没有非线性建模能力但它对空间模式的刻画极其精准很多论文里 CNN 打不过精心调参的 CSPC-SVM 也不稀奇。# common_spatial_pattern.py 核心逻辑以 sklearn 风格演示 from sklearn.base import BaseEstimator, TransformerMixin import numpy as np from scipy import linalg class CSP(BaseEstimator, TransformerMixin): def __init__(self, n_components8): self.n_components n_components def fit(self, X, y): classes np.unique(y) covs [] for c in classes: Xc X[y c] covs.append(np.mean([np.cov(x.T) for x in Xc], axis0)) # 广义特征值分解找让两类协方差比值最大的方向 eigvals, eigvecs linalg.eigh(covs[0], covs[0] covs[1]) idx np.argsort(eigvals)[::-1] self.filters_ eigvecs[:, idx[:self.n_components]] return self def transform(self, X): # 投影后取 log-variance 作为特征 X_p X self.filters_ return np.log(np.var(X_p, axis2) 1e-8)CSP 之后的特征通常是几十维的 log-variance 向量配合线性 SVM 或 LDA 就能拿到一个不差的基线。在脑电这类小样本任务上先跑 CSP 基线有两个好处一是确认预处理阶段没有把类别信息洗掉二是给 Transformer 设一个要超越的靶子。如果 CSP 已经 85% 了Transformer 至少得 88% 以上才有论文说服力。3. CNNTransformer 结构拆解局部特征和全局依赖的配合3.1 为什么 CNN 负责局部、Transformer 负责全局脑电信号和自然语言有个相似之处trial 内的时序依赖长度很长。一个 4 秒、250Hz 采样的 trial 就是 1000 个时间点而运动想象的关键特征往往分布在 0.5 到 2.5 秒之间的多个时段且这些时段之间不是孤立的——想象运动的准备、执行、结束三个阶段在脑电上有明显的先后关联。CNN 的卷积核擅长提取局部模式比如某个 100ms 窗口内的幅值突变、某两个通道之间的同步性变化但要让 CNN 自己去关联相距 1 秒以上的两个特征就得堆很多层参数量很快就失控了。Transformer 的自注意力机制把序列上任两点的关联距离压缩成一次计算恰好补上这一环。这解释了为什么这份资源的模型叫 CNNTransformer 而不是纯 Transformer。纯 Transformer 直接吃 22 通道的原始时序也不是不行但局部噪声会干扰注意力权重的计算而且自注意力的计算量随序列长度平方增长1000 个时间点一进去注意力矩阵就 100 万级别了。先让 CNN 把局部模式抽象成更紧凑的特征序列再让 Transformer 在这层抽象上建模全局依赖是更省算力的做法。3.2 CNNTransformer.py 的关键层与参数从文件命名和训练配置来看主模型用的是一个带时空卷积的 CNN 分支加上 Transformer 编码器。结构上大致可以拆成三个模块# CNNTransformer.py 结构示意 import torch import torch.nn as nn class CNNTransformer(nn.Module): def __init__(self, n_channels22, n_times1000, n_classes4, d_model64, nhead4, num_layers2, dropout0.3): super().__init__() # 1. 空间卷积融合通道间信息类似 CSP 的可学习版本 self.spatial_conv nn.Conv2d(1, 16, kernel_size(n_channels, 1)) # 2. 时间卷积提取局部时间模式 self.temporal_conv nn.Sequential( nn.Conv2d(16, 32, kernel_size(1, 25), padding(0, 12)), nn.BatchNorm2d(32), nn.ELU(), nn.AvgPool2d((1, 4)) ) # 3. Transformer 编码器建模全局时间依赖 self.proj nn.Conv2d(32, d_model, kernel_size1) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward128, dropoutdropout, batch_firstTrue ) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.classifier nn.Sequential( nn.Linear(d_model, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, n_classes) ) def forward(self, x): # x: [batch, 1, n_channels, n_times] x self.spatial_conv(x) x self.temporal_conv(x) x self.proj(x) # 压缩通道维变成 [batch, time_steps, d_model] x x.squeeze(2).permute(0, 2, 1) x self.transformer(x) # 全局池化后分类 x x.mean(dim1) return self.classifier(x)几个参数值得展开说。空间卷积核的尺寸是 (n_channels, 1)也就是说每个时间点上做一次跨通道的线性组合这相当于 CSP 滤波器的可学习版本参数量只有 22 个输入通道乘 16 个输出滤波器非常轻。时间卷积核用 25 个点在 250Hz 采样率下是 100ms 的窗口能捕捉到一个感觉运动节律周期的局部形态。Transformer 的 d_model 只有 64head 数为 4层数设到 2——这是故意压小的脑电样本量通常几百到几千模型一大就过拟合。如果换到更大数据集d_model 加到 128、层数加到 3 会更稳但需要配合更强的 dropout。3.3 对照模型 EEGNet 和去掉 Transformer 的变体这份资源里还带了 EEGNet.py 和 CNNTransformer_notransformer.py这两个文件的价值被很多人低估了。EEGNet 是脑电深度学习的经典紧凑网络它的核心设计是深度可分离卷积先做空间卷积再做逐通道的时间卷积参数量远小于通用 CNN但效果往往出奇地好。拿它当对照模型能让你在论文里回答一个问题Transformer 带来的提升到底是注意力机制的功劳还是单纯的参数量增加。CNNTransformer_notransformer.py 是消融实验的标准做法。把 Transformer 编码器去掉只保留 CNN 分支保持卷积部分参数一致再和完整模型对比。如果完整模型只高 1%那这 1% 就是 Transformer 的真实贡献如果反而更低你该检查是不是注意力权重在短序列上学到了噪声。消融对比应当在同一个五折划分下跑否则不同折之间的随机差异会盖过模型差异。4. 五折交叉验证与评估准确率、AUC 和统计检验一起看4.1 train2_kfold.py 怎么组织训练train2_kfold.py 是这份资源的训练主入口。名字里的 2 大概率指五折中的训练验证两阶段。运动想象数据通常按受试者组织同一个人的不同 trial 之间有较强的相关性随机打乱后划分训练集和验证集容易造成乐观估计。更稳妥的做法是按 trial 所在 block 分层或者直接用 StratifiedKFold 保证每个折里四类样本比例一致。# train2_kfold.py 训练框架 import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score, roc_auc_score import torch import torch.nn as nn X np.load(train_data.npy) y np.load(train_label.npy) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) fold_scores [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): train_data torch.from_numpy(X[train_idx]).float() val_data torch.from_numpy(X[val_idx]).float() model CNNTransformer(n_channels22, n_times1000, n_classes4) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss() best_val_loss float(inf) patience 10 wait 0 for epoch in range(100): model.train() # 这里省略 dataloader 包装实际代码里按 batch 迭代 for batch_x, batch_y in train_loader: optimizer.zero_grad() out model(batch_x) loss criterion(out, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() model.eval() val_loss compute_val_loss(model, val_loader) if val_loss best_val_loss: best_val_loss val_loss best_model model.state_dict() wait 0 else: wait 1 if wait patience: break model.load_state_dict(best_model) val_acc evaluate_acc(model, val_loader) fold_scores.append(val_acc) print(fFold {fold1}: val_acc{val_acc:.4f}) print(fMean acc: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f})这套训练配置里有几个值得抄走的设计。学习率用 1e-3 配合 AdamWweight_decay1e-4 做 L2 正则梯度裁剪 max_norm5.0 防止偶发的极端梯度把模型权重打飞CosineAnnealing 让学习率在 50 个 epoch 内从 1e-3 平滑降到接近 0比固定学习率更容易收敛。Early stopping 的 patience 设 10以验证损失为准只有当验证损失刷新最低时才保存模型权重。注意这里的最佳模型在每折训练结束后没有重新跑一遍训练集评估训练集准确率通常虚高论文里只报验证集均值是诚实的做法。4.2 评估指标、boxplot 与统计检验的组合分类准确率是最直观的指标但在类别不平衡时容易被骗。plot_auc.py 计算每个类别的 ROC 曲线和 AUC脑电四分类的 AUC 通常按 one-vs-rest 方式算也就是把目标类当正类、其余三类合并当负类。AUC 的意义在于它不受分类阈值影响能反映模型对每个类别的置信度排序质量。plot_boxplot.py 把多个折的验证准确率画成箱线图用中位数和四分位距代替单一的均值。脑电实验个体差异极大有的折可能到 90%有的只有 70%只报均值很容易被极端值带偏。箱线图能直观看到分布的集中趋势和离群点这在论文里比一个数字有说服力得多。stastical_rank_test.py 做的是多个模型之间的显著性检验。常见做法是 Wilcoxon 符号秩检验或 Friedman 检验比较 CNNTransformer、EEGNet、CSP 基线和消融模型在同五个折上的性能是否有显著差异。需要注意五折样本量很小Wilcoxon 检验的 p 值往往很难小于 0.05不要因为 p0.06 就否定 Transformer 的价值把原始数据和检验结果都放出来审稿人自会判断。4.3 权重文件 weights.xlsx 与模型参数文件weights.xlsx 存的是各折预测结果的汇总表包括每个 trial 的真实标签和各类别的预测概率这是画 ROC 和箱线图的原始材料。conformer_40x300x5x81.6_sub1.pth 是训练好的模型权重文件名的前半段对应输入数据的形状配置sub1 表示受试者编号。加载这个权重时要注意它对应的数据形状必须与当前输入一致否则会报 size mismatch。如果你要在自己的数据上微调可以只加载卷积部分的参数Transformer 层重新初始化这样既继承了低层特征提取能力又避免高层特征被源数据绑架。5. 避坑指引从头到尾踩过的四个隐形问题5.1 数据泄漏归一化和划分的顺序颠倒现象训练集准确率一路涨到 0.99验证集却只有 0.65换到测试集甚至不到 0.5。原因这类问题八成出在数据预处理阶段。最常见的是先把整批 X 做了 z-score再做 train/val 切分。验证集的均值和方差已经被训练集数据影响模型在验证集上的表现被高估。更隐蔽的是用 np.random.shuffle 直接打乱所有 trial 再划分同一受试者的相邻 trial 被拆散进训练集和验证集造成跨 trial 的信息泄漏。解决把归一化放进 KFold 循环内部只用训练算出的 mean/std 去变换验证集。划分用 StratifiedKFold 且 shuffleTrue不要手动打乱后按比例硬切。5.2 预训练权重加载失败shape mismatch 和参数名对不上现象加载 conformer_40x300x5x81.6_sub1.pth 时报 size mismatch或者报 missing key 和 unexpected key。原因state_dict 的 key 名跟模型类里的层名不一致。常见情况是你改过模型结构比如把 spatial_conv 的 kernel_size 从 (22, 1) 改成 (22, 3)权重形状就变了。另一个原因是保存时用了 model.module.state_dict()DataParallel 包裹加载时模型没有用 parallel 包裹key 名多了 module. 前缀。解决严格保证模型类定义和保存时一致。如果只是 DataParallel 前缀问题一句代码就能处理model.load_state_dict({k.replace(module., ): v for k, v in ckpt.items()})。如果形状不匹配退一步只加载 CNN 部分的卷积核Transformer 层重新初始化。5.3 脑电热图与通道坐标错位高亮区域全是乱的现象brain_heatmap.py 画出来的脑地形图高亮点不在该在的脑区想象右手时激活区却落在枕叶。原因cam_22channels.xlsx 里的电极坐标顺序和网络输入 X 的通道顺序不一致。EEG 数据在预处理时可能被重排过通道比如把坏通道去掉后没更新坐标表。热图按坐标表逐通道画色块通道一对不上图上的空间分布就全错了。解决预处理的第一步先把通道顺序固定坐标表从同一个通道列表生成。画图前做一个简单校验取一个已知特征的通道如 C3对应右手想象把它的平均能量和坐标表中的 C3 行对齐再决定要不要重排。5.4 脑电样本量太小导致 AUC 虚高置信区间比数值更可信现象某折的 AUC 高达 0.98但另一折只有 0.72折间波动极大。原因运动想象数据通常只有几百到一千个 trial五折划分后每个验证折只有一百多个样本。个别 trial 的标签噪声就会显著改变 AUC 数值。这也是为什么只报平均 AUC 不够一定要带标准差和箱线图。解决多跑几个不同的 random_state 做重复五折实验取多次划分的平均结果。如果 42 这个种子恰好产生了偏乐观的数据划分换 2024 可能就露馅了。论文里报 5 次重复五折的均值比单次五折的说服力强得多。6. 把模型翻给外行看tSNE 与脑电热图的可视化验证tSNE.py 和 brain_heatmap.py 是这套资源最后一道关卡。模型在验证集上拿到 85% 准确率后不等于它学到的特征是合理的。tSNE 把高维特征压到二维平面上展示理想的运动想象分类是四类各自成簇、界限清晰。如果 tSNE 图上四类完全混在一起但分类准确率很高那你多半是过度拟合了训练集的噪声换个受试者的数据就会崩。# tSNE.py 特征可视化特征取自分类层前的 embedding from sklearn.manifold import TSNE import matplotlib.pyplot as plt # features: (n_samples, n_features) tsne TSNE(n_components2, perplexity30, learning_rate200, random_state42) embed tsne.fit_transform(features) plt.figure(figsize(8, 6)) for c in range(4): plt.scatter(embed[y c, 0], embed[y c, 1], labelfclass {c}, s12, alpha0.7) plt.legend() plt.title(tSNE of features before classifier) plt.show()参数上perplexity30 适合几百个样本的中等数据集样本数少于 100 时降到 5–10 更稳。tSNE 是随机初始化算法换个种子结果就不同如果两张图的簇结构差异很大说明特征表示不稳定需要回去看训练收敛情况。CAM 和 brain_heatmap 则是反过来做空间上的验证。CAM类激活映射把分类层对某个类别的梯度传回特征图算出一个 22 通道上的权重分布画到头皮坐标上。想象右手时C3 和 C5 通道附近中央沟左侧应当有高激活因为感觉运动皮层对侧控制肢体。如果你的 CAM 热图高亮区完全不符合认知神经科学常识模型可能学到了伪迹模式而非真实脑电模式。cam_22channels.xlsx 里的坐标是按 10-20 国际标准系统布置的画图前确认坐标单位厘米还是归一化与画布尺寸匹配。我这套代码的最后一步永远是拿验证集里分错的样本逐个看原始波形。挑一个把右脚错分成左脚的样本切出 8–30Hz 滤波后的曲线跟分类正确的样本做对比。很多时候你会发现错误样本的幅值明显偏小或是在刺激起始点附近有残留的眼电伪迹这种错误是数据问题不是模型问题。从那以后我每次跑完训练都强制走一遍「先 tSNE 看簇再 CAM 看脑区最后抽查错分类波形」的验证三连看似只多花十分钟但省掉的却是答辩时被老师一个问题问住的尴尬。希望帮到你。本文还有配套的精品资源点击获取
返回列表