ARTICLE DETAIL

资讯详情

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

运动想象脑电解码:基于Transformer的EEG分类Python源码实战

运动想象脑电解码:基于Transformer的EEG分类Python源码实战 简介基于Transformer的运动想象脑电信号分类源码采用CNN与Transformer融合架构提取局部时间和空间特征面向计算机、通信、人工智能、自动化等相关专业学生、教师与从业者适用于毕业设计、课程设计及科研入门。压缩包共31个文件以23个Python脚本为主体覆盖预处理、模型训练、评估和可视化环节另有2个MATLAB脚本用于数据准备2个表格文件存放实验数据模型权重与数据文件各1个并附配置和说明文档整体约18.45MB。项目源自个人本科毕业设计答辩评审分98分代码经过调试测试可直接运行模块包含公共空间模式滤波、局部时空特征提取、注意力机制、EEGNet对比、脑热图与tSNE可视化等能够清晰呈现Transformer在运动想象脑电信号分类中的实现思路也便于二次开发调整。已有165人浏览学习对新手入门和毕设参考均有较高价值。1. 运动想象脑电解码这个Transformer源码包到底能做什么你手上如果有脑电信号EEG想区分受试者是在想象左手动还是右手动本质上是在跟一堆混着肌电、眼电和工频干扰的时间序列打交道。传统的机器学习把频带能量提出来丢给SVM能做到70%出头就算不错而基于Transformer的运动想象脑电信号分类python源码用CNN先把局部时间-空间特征“抠”出来再交给Transformer编码器去学全局依赖在公开数据集上往往能比传统方法高十几个点。这套源码正好覆盖从原始EEG到分类结果的全流程对做毕业设计、课程设计或者入门BCI的人来说是最容易复现的那类代码包——不需要自己从头搭网络改改数据路径就能跑通。接下来我按实际拆项目时的顺序把网络设计、训练流程和那几个隐蔽的坑一个个过一遍。2. 为什么是CNNTransformer网络结构设计与局部时空特征提取2.1 运动想象信号里的时间、空间、频率三维信息运动想象Motor Imagery发生时大脑感觉运动皮层会产生事件相关去同步ERD和事件相关同步ERS最明显的特征出现在mu频段8-12Hz和beta频段13-30Hz。想象左手运动时C4通道附近的功率谱会压下去想象右手运动时C3通道附近会有类似变化。也就是说判别信息同时藏在“哪个通道在变”空间以及“哪个时间窗在变”时间这两个维度里。但有一个反直觉的点单看某个时刻的原始波形你根本看不出差别。因为EEG的幅值才微伏级还混着眨眼造成的几十微伏的伪迹。所以源码里第一步必做的不是堆模型而是滤波和切段。我见过不少人跳过预处理直接把原始数据丢给Transformer结果loss死活不降——那不是模型不行是输入的信噪比太低。这个源码在预处理部分用了带通滤波保留8-30Hz并按事件标记切成从提示出现前0.5s到后4s的段这一段在BCI Competition IV dataset 2a里是标准做法。2.2 CNN部分用二维卷积把局部时空特征提出来Transformer理论上可以处理序列但对多通道EEG来说如果直接把原始采样点拉成一维序列喂进去注意力机制很难学到“C3通道在第300个采样点附近功率下降”这种局部模式。原因很简单自注意力是全局交互它擅长捕捉长距离依赖但不会先天地假设邻近的通道或时间点有关联。而CNN的局部感受野恰好补上这个缺口。源码里CNN部分采用了类似EEGNet的思路但做了两个改动。第一个改动是分离卷积先用一维卷积在时间维度上滑动每个通道独立处理再用逐通道卷积Depthwise Convolution跨通道混合。这样参数少而且不会把不同通道的噪声互相污染。第二个改动是在每个卷积块后加了BatchNorm和Dropout这几乎是用脑电数据的标配——否则训练到第20个epoch就会开始震荡。下面这段是源码里CNN特征提取核心代码的精简版import torch.nn as nn class LocalFeatureExtractor(nn.Module): def __init__(self, n_channels22, n_samples1000, dropout_rate0.5): super().__init__() # 时间卷积每个通道独立卷积核长度覆盖约0.125秒假设采样率250Hz self.time_conv nn.Conv2d(1, 16, kernel_size(1, 32), padding(0, 16)) # 空间卷积Depthwise跨通道混合不跨时间 self.depth_conv nn.Conv2d(16, 32, kernel_size(n_channels, 1)) self.bn nn.BatchNorm2d(32) self.act nn.ELU() self.pool nn.AvgPool2d(kernel_size(1, 4)) self.dropout nn.Dropout(dropout_rate) def forward(self, x): # x: (batch, 1, channels, time) x self.time_conv(x) # 提取局部时间特征 x self.depth_conv(x) # 压缩空间维度得到32个特征图 x self.bn(x) x self.act(x) x self.pool(x) x self.dropout(x) # 输出需要转成序列格式 (batch, seq_len, feature_dim) x x.squeeze(2).permute(0, 2, 1) # (batch, time_after_pool, 32) return x这里的核心参数n_channels22对应2a数据集的22个EEG通道kernel_size(1, 32)表示时间维上卷积核跨32个采样点250Hz采样率下正好是128msdepth_conv里的(n_channels, 1)是把所有通道的空间信息压缩成一个值这样后面Transformer拿到的每个时间步都融合了全脑空间信息。池化层把时间维度降了4倍既减少Transformer输入长度又能容忍不同被试的个体差异。2.3 Transformer编码器补足全局时序依赖CNN提取出来的局部特征序列长度大概在250个时间步左右原始1000点池化4次后再切掉边界。直接用全连接去分类也能做但忽略了“早期ERD和晚期ERS之间的先后关系”这种全局语境。Transformer这里的作用就是让每个时间步都能看到整段时间序列的上下文。源码里的Transformer编码器没有做特别复杂的改动就是标准的多头自注意力加前馈网络。重点在于位置编码的处理。EEG不像文本那样有明确的词序但时间顺序肯定重要所以源码采用了可学习的位置编码Learned Positional Embedding而不是固定的正弦编码。这样训练时可以让网络自己决定“第50个时间步相对于第10个时间步有多重要”在运动想象任务里通常比正弦编码稳定1-2个百分点。模型主体代码如下import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len512): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) # (1, max_len, d_model) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, :x.size(1), :] class MI_Transformer(nn.Module): def __init__(self, n_channels22, n_samples1000, num_classes4, d_model64, nhead8, num_layers3, dropout0.1): super().__init__() self.cnn LocalFeatureExtractor(n_channels, n_samples) self.position_emb PositionalEncoding(d_model, max_len512) encoder_layer nn.TransformerEncoderLayer(d_modeld_model, nheadnhead, batch_firstTrue, dropoutdropout) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.global_pool nn.AdaptiveAvgPool1d(1) self.classifier nn.Sequential( nn.Linear(d_model, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes) ) def forward(self, x): # x: (batch, 1, channels, time) x self.cnn(x) # (batch, seq, d_model) x self.position_emb(x) x self.transformer(x) # (batch, seq, d_model) x self.global_pool(x.transpose(1, 2)).squeeze(-1) return self.classifier(x)代码里d_model64是CNN输出特征图的通道数nhead8意味着8个头各自关注不同时间尺度的依赖——有的头可能专门学mu频段的包络变化有的头在学跨通道的空间共变。num_layers3是经验值我试过单层Transformer就能到80%准确率但三层会更稳定。需要强调的是batch_firstTruePyTorch旧版本默认是False遇到维度报错时第一反应就查这里。3. 从源码到训练数据预处理、模型搭建与训练流程3.1 数据准备公开数据集与预处理管线这套源码首选的数据集是BCI Competition IV dataset 2a总共9个受试者4类运动想象左手、右手、双脚、舌头每个受试者有288个试次采样率250Hz22个EEG通道加3个EOG通道。下载后是GDF格式需要用MNE库读取。源码里给出了一个预处理函数按下面几步走读取GDF文件去除EOG通道应用带通滤波通带8-30Hz防止肌电和基线漂移按事件标记切出时间窗通常是刺激出现前0.5秒到后4秒剔除眨眼伪迹这里用的是简单阈值法幅值超过±80μV的试次直接丢弃每个试次做z-score标准化注意标准化参数必须只在训练集上计算。第5点是个特别容易翻车的地方。如果你在预处理阶段直接对整个数据集做了标准化意味着验证集的信息在训练前就已经泄漏给模型了最后得到的测试准确率会虚高几个点跨受试者更是会崩。源码里特意把标准化放在了数据划分之后这一点非常关键。下面是数据加载与预处理的核心代码import mne import numpy as np from sklearn.preprocessing import StandardScaler def load_and_preprocess(gdf_path, tmin-0.5, tmax4.0, l_freq8, h_freq30, reject_thresh80e-6): raw mne.io.read_raw_gdf(gdf_path, preloadTrue, verboseFalse) raw.pick_types(eegTrue) # 只保留EEG通道 raw.filter(l_freq, h_freq, fir_designfirwin, verboseFalse) events, event_id mne.events_from_annotations(raw, verboseFalse) # 映射为标准4类标签1-左手2-右手3-双脚4-舌头 id_mapping {768: 1, 769: 2, 770: 3, 771: 4} labels np.array([id_mapping.get(e, -1) for e in events[:, 2]]) valid labels ! -1 events events[valid] labels labels[valid] epochs mne.Epochs(raw, events, tmintmin, tmaxtmax, baseline(tmin, 0), preloadTrue, reject{eeg: reject_thresh}, verboseFalse) data epochs.get_data() # (n_trials, n_channels, n_samples) return data, labels # 用法示例9个受试者分别加载 X, y load_and_preprocess(A01T.gdf) print(f数据形状: {X.shape}) # (288, 22, 1125) 约1125个采样点4.5秒*250Hz代码里tmin-0.5保留刺激前0.5秒作为基线tmax4.0共4.5秒片段对应1125个采样点。reject_thresh设置80μV阈值超出这个幅值的试次会被MNE自动剔除。我实际跑的时候发现2a数据集中有些受试者会筛掉10%左右的试次这很正常别为了凑数量把阈值放松到200μV那样伪迹的干扰会直接毁掉Transformer的注意力热力图。3.2 模型训练从划分数据到调参训练过程最忌直接用整个数据集一次性跑。源码里提供了两种划分方式一种是按试次随机切分训练集/验证集受试者内另一种是按受试者切分跨受试者。前者简单但会高估模型在同一个人的脑电数据上的表现后者更难也更真实因为每个人的大脑空间分布差异很大跨受试者准确率会掉10-20%。训练循环里需要小心的是样本处理。EEG数据进入模型前要加一个维度变成(batch, 1, channels, time)这里1代表单通道输入CNN的输入通道不要和EEG的22个通道搞混。损失函数用交叉熵优化器我通常选AdamW学习率初始1e-3配合余弦退火。脑电数据噪声大batch size不宜过大16或32比较合适过大容易过拟合到噪声。import torch import torch.nn as nn from torch.utils.data import TensorDataset, DataLoader from sklearn.model_selection import train_test_split def train_model(model, X, y, epochs100, batch_size32, lr1e-3, devicecuda): # 按试次划分80%训练20%验证 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42) # 标准化只拟合训练集 scaler StandardScaler() n, c, t X_train.shape X_train scaler.fit_transform(X_train.reshape(-1, t)).reshape(n, c, t) n_val X_val.shape[0] X_val scaler.transform(X_val.reshape(-1, t)).reshape(n_val, c, t) train_loader DataLoader(TensorDataset( torch.FloatTensor(X_train).unsqueeze(1), torch.LongTensor(y_train)), batch_sizebatch_size, shuffleTrue) val_loader DataLoader(TensorDataset( torch.FloatTensor(X_val).unsqueeze(1), torch.LongTensor(y_val)), batch_sizebatch_size, shuffleFalse) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) criterion nn.CrossEntropyLoss() for epoch in range(epochs): model.train() train_loss 0.0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() optimizer.step() train_loss loss.item() * xb.size(0) scheduler.step() if (epoch 1) % 10 0: model.eval() val_acc evaluate(model, val_loader, device) print(fEpoch {epoch1:3d} | Loss {train_loss/len(train_loader.dataset):.4f} | Val Acc {val_acc:.2f}%) return model def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for xb, yb in loader: xb, yb xb.to(device), yb.to(device) pred model(xb).argmax(dim1) correct (pred yb).sum().item() total yb.size(0) return correct / total * 100这里weight_decay1e-4是个比较安全的正则化强度太大会压垮注意力机制的学习太小会在高噪声的EEG上剧烈震荡。stratifyy保证训练集和验证集的四类比例一致如果忽略这个碰到某个受试者某一类试次特别少时验证集可能只有1个样本准确率随机波动极大。我习惯把random_state42固定下来这样每次跑出来的结果可复现不然换一次种子结果差3-5%都是常有的事根本没法判断模型改动是好是坏。3.3 评估与可视化不要只看准确率运动想象数据里四类样本通常比较均衡但准确率仍然不能反映全部问题。我遇到过模型在“右手”和“舌头”两类上准确率非常低但整体准确率还看得过去的情况——因为这两类对应的脑区激活模式本来就很接近。源码里给出了混淆矩阵和kappa系数的计算这是BCI竞赛的标准评估方式。kappa系数排除了随机猜测的影响四分类任务里随机猜测的kappa是0优秀模型的kappa通常在0.6以上。from sklearn.metrics import confusion_matrix, cohen_kappa_score import seaborn as sns import matplotlib.pyplot as plt def plot_confusion(model, loader, device, class_names): model.eval() all_preds [] all_labels [] with torch.no_grad(): for xb, yb in loader: pred model(xb.to(device)).argmax(dim1).cpu().numpy() all_preds.extend(pred) all_labels.extend(yb.numpy()) cm confusion_matrix(all_labels, all_preds) kappa cohen_kappa_score(all_labels, all_preds) sns.heatmap(cm, annotTrue, fmtd, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted) plt.ylabel(True) plt.title(fConfusion Matrix (Kappa{kappa:.3f})) plt.show()用混淆矩阵能很快定位模型在哪些类上互相打架。如果发现“双脚”和“舌头”频繁混淆可以考虑在预处理时把频带缩窄到更适合这两个任务的mu频段或者增大Transformer层数让它学更细的时间模式。但千万不要根据验证集的表现反复去调整预处理和网络结构那样会陷入过拟合验证集的黑匣子——最后用测试集一测准确率掉回随机水平这种“验证集上越改越好、测试集上越来越差”的血泪经验我一年能见到好几次。4. 避坑指南运动想象EEG分类的五个常见翻车点4.1 数据泄漏标准化和划分顺序搞反现象训练过程loss很低验证集准确率跑到90%以上但换一个新的被试或者跨会话测试直接掉到60%以下。原因最常见的是先对整个数据集做了标准化或归一化再划分训练集和验证集。StandardScaler的均值和方差是在全部数据上计算的验证集的信息已经进入了训练数据的统计量模型看到的验证集分布被“校准”过。另外如果切段时试次边界没对齐前后段之间会有重叠模型学到了重复片段验证自然虚高。解决严格按照“先划分、后标准化”的顺序。如果你要跨受试者就要按被试分组划分保证同一个人的信号不会同时出现在训练集和验证集里。我在代码库的data loader里强制用了GroupShuffleSplit而不是简单的train_test_split。4.2 类别不均衡少数类被完全吞掉现象四类中某一类的召回率只有30%左右但整体准确率还有75%看起来能交差实际模型对那类几乎失效。原因虽然2a数据集整体均衡但经过伪迹剔除后某些受试者的某一类试次可能少删了或多了删了。交叉熵损失会自适应地把概率质量分配给样本多的类少数类从此被忽视。解决训练时给每个类别设置权重哪类样本少哪类的loss惩罚就大。源码里CrossEntropyLoss(weightclass_weights)一行就能解决。另外别在训练集里做addition复制EEG的噪声特性会让复制样本产生重复的绝对时间模式不如用简单的过采样。4.3 随机种子不固定结果像玄学现象同一个模型、同一个参数今天跑85%明天跑了80%你以为是模型bug其实是随机性作祟。更麻烦的是为了“提高准确率”调了一通参数结果只是某次随机种子的好运。原因PyTorch里的Dropout、参数初始化、DataLoader的shuffle都用了伪随机数。默认情况下种子是当前时间每次运行都不一样。另外爱因斯坦卷积和Transformer的初始化也会导致微小差异。解决固定所有能固定的随机源。在训练脚本开头加三行random.seed(42) np.random.seed(42) torch.manual_seed(42)如果你在用GPU还需要torch.backends.cudnn.deterministic True。我自己的习惯是每个实验跑5个种子取平均值和标准差这样才能判断模型改动的真实效果。4.4 Transformer输入维度对齐错误现象RuntimeError: expected shape [B, L, D] but got [B, D, L]或者位置编码拼接报错。原因CNN输出是(batch, features, time)而Transformer期望(batch, time, features)。很多人在拼接编码器的时候忘记做permute。另外PyTorch的TransformerEncoderLayer在旧版本里batch_firstFalse如果你用了(batch, seq, d_model)必须显式声明batch_firstTrue。解决在输入Transformer之前统一执行x x.permute(0, 2, 1)然后检查形状print(x.shape)。我这套源码里已经做了处理但如果你在改网络结构时动了CNN输出记得连带检查。另外注意位置编码的max_len必须大于CNN池化后的时间长度否则越界报错。4.5 过拟合到单个受试者的“电极帽位置”现象受试者内验证准确率85%跨受试者只有55%。原因每个被试的电极帽放置位置、头皮电阻、大脑解剖结构都不一样。CNN的空间卷积核会悄悄记住某个受试者特有的通道空间分布而不是通用的神经模式。这种过拟合在数据量少时尤其严重。解决跨受试者训练后做微调而不是直接拿单一受试者训练。源码里提供了一个简单的域适应技巧先在9个受试者的数据上训练一个通用模型然后冻结CNN层只微调Transformer和分类器用目标受试者的少量数据训练20个epoch。这样既能利用群体共性又能适配个人特性。我测试过这种策略能把跨受试者准确率从55%提到70%左右。5. 进阶验证把注意力权重变成脑区重要性证据模型跑通只是第一步如果毕业设计答辩老师问你“凭什么相信Transformer学到了脑电特征而不是噪声”你需要拿出可视化证据。Transformer最值钱的副产品就是注意力权重——每个时间步对最终分类贡献多少一目了然。这里分享一个我在复现这个源码时常用的可视化方式提取最后一个Transformer层的多头注意力平均权重做成时间步重要性曲线。思路是把所有头的注意力权重针对每个源时间步做平均然后和原始EEG的ERD/ERS时段对照。实现上只要在forward时保存注意力权重class MI_Transformer_WithAttn(MI_Transformer): def forward(self, x): x self.cnn(x) x self.position_emb(x) attn_weights [] for layer in self.transformer.layers: x layer(x, output_attentionsTrue)[0] # 注意自定义层才支持 attn_weights.append(layer_attns) return self.classifier(self.global_pool(x.transpose(1,2)).squeeze(-1)), attn_weights这里用了自定义Transformer层来捕获output_attentionsTrue标准模块不支持。实际代码里需要继承nn.TransformerEncoderLayer并复写forward然后torch.mean(attn, dim0)在头上平均。画出来你会发现注意力高的时间点基本集中在刺激出现后0.3-1.5秒这个和时间窗正好对应mu频段ERD最明显的时段。如果注意力集中点完全在刺激前那就要怀疑是不是模型学到了基线漂移。除了注意力可视化另一个值得做的验证是频带敏感性分析。把预处理改成保留4-8Hz、8-12Hz、12-30Hz三个子带分别重新训练观察准确率变化。通常8-12Hz的贡献最大这是运动想象的核心频段。如果Transformer在4-8Hz上也能达到接近的准确率说明它学到的是慢皮层电位而不是典型的运动想象模式这时候需要回到数据检查伪迹去除是否彻底。从那以后我每次跑完这个源码都会强制自己走一遍“注意力可视化 频带敏感性 跨受试者验证”三件套。不是每次都有惊喜但有一次我发现在某个受试者上模型注意力集中在EOG通道残留的眨眼伪迹上顺着这个线索把阈值从80μV调到60μV准确率直接提了5%。整个过程不复杂但比反复调参靠谱得多。希望这套思路在你复现的时候也能少走几步弯路。本文还有配套的精品资源点击获取
返回列表