简介:这是一份基于Transformer的运动想象脑电信号分类Python源码,整合CNN与局部时空特征提取,对应个人毕设项目(答辩98分),适合计算机、人工智能、自动化等专业学生用于课程设计、大作业或毕业设计。资源包共31个文件,压缩后18.45MB,以23个py脚本为核心,辅以2个m预处理脚本、2个xlsx结果表,以及pth模型权重、npy数据、xml配置和md说明文档等,覆盖数据预处理、模型训练、可视化分析与评估全流程。目前已有165人学习/下载,代码均调试可运行。包内不仅包含CNNTransformer、Spatial_Temporal_Attention、EEGNet等模型实现,还提供CSP共空间模式、Morlet小波、tSNE降维、脑电地形图(CAM/boxplot/AUC)等脚本,以及demo.py快速演示与README说明,便于从数据读取到结果复现逐层深入理解,也适合在此基础上扩展自己的改进方向。
1. 运动想象脑电信号分类:为什么我放弃纯 CNN 转向 Transformer
运动想象脑电信号分类(左手/右手/双脚/舌头四类)在公开数据集 BCIC IV 2a 上,普通 CNN 能跑到 70% 上下已经是相当不错的成绩,再往上每提 1% 都像玄学——EEG 信噪比低、个体差异大、时序依赖长,这三座山同时压在模型头上。我拆这套 Python 源码时发现,作者的思路是先用 CNN 提取局部时间空间特征,再用 Transformer 编码器建模跨通道、跨时间的全局依赖,混合结构在四个受试者上的准确率比纯 CNN 基线高 5 到 9 个百分点。这套方案同时覆盖了两种特征尺度:局部节律模式归 CNN,全局上下文归自注意力,非常适合毕业设计做对比实验,也适合想把手头 EEG 分类模型往上推一档的从业者。
2. 数据预处理管线:BCIC IV 2a 读取与 epoch 划分的完整实现
2.1 通道布局与采样率:先认识数据长什么样
BCIC IV 2a 是运动想象脑电分类最常用的公开基准,采集了 9 个健康受试者的四类运动想象任务,每类 144 个试次。原始数据用 GDF 格式存储,采样率 250 Hz,一共 22 个 EEG 通道外加 3 个 EOG 眼电通道。文件名按 A01T、A02T 命名,T 表示训练集,E 表示测试集。读取端我用 MNE 库,这是 EEG 领域的事实标准,后面所有预处理都基于它。
import mne import numpy as np # 只保留 22 个 EEG 通道,扔掉 3 个 EOG eeg_channels = mne.channels.read_montage('standard_1005').ch_names raw = mne.io.read_raw_gdf('A01T.gdf', preload=True, verbose=False) raw.pick_types(eeg=True, eog=False) # 运动想象范式:提示出现后 0 秒到 4 秒是想象窗口 # 250 Hz * 4 s = 1000 个采样点 events, event_id = mne.events_from_annotations(raw) epochs = mne.Epochs( raw, events, event_id={'769': 0, '770': 1, '771': 2, '772': 3}, tmin=0, tmax=4, baseline=None, preload=True, verbose=False ) X = epochs.get_data() # 形状 (n_trials, 22, 1000) y = epochs.events[:, -1] # 标签 0-3这段代码里有两个关键参数:tmin=0和tmax=4。运动想象数据里,0 到 4 秒是官方的想象时间段,但实际做分类时很多人只取 0.5 秒到 2.5 秒,因为想象起始阶段信号变化最剧烈。tmax直接决定单样本长度:250 Hz 下 4 秒就是 1000 个采样点,Transformer 序列长度和计算量都跟这个数字挂钩。如果你是新手跑代码,建议先完整保留 4 秒,后面做消融实验时再对比不同窗口长度的影响。
2.2 带通滤波与伪迹去除:前后顺序别看反
EEG 原始信号里包含眨眼、肌电、工频干扰,滤波是最基础也是最重要的去噪手段。运动想象的有效节律集中在 mu 节律(8-12 Hz)和 beta 节律(13-30 Hz),所以带通滤波一般选 8-30 Hz 或 4-38 Hz。这里有个顺序问题:必须先滤波再做 ICA(独立成分分析)去除眼电伪迹,因为 ICA 对低频漂移和高频噪声敏感,先滤波能提升 ICA 分解的稳定性。
# 先带通滤波,再去做 ICA raw_filtered = raw.copy().filter(8, 30, fir_design='firwin', verbose=False) # ICA 去眼电:fit 之后自动识别 EOG 成分并剔除 ica = mne.preprocessing.ICA(n_components=20, random_state=42, max_iter=800) ica.fit(raw_filtered) eog_indices, eog_scores = ica.find_bads_eog(raw_filtered, ch_name='EOG-1', threshold=2.5) ica.exclude = eog_indices raw_clean = raw_filtered.copy().apply(ica)n_components设成 20 而不是 22,因为留两个维度给噪声,太高的max_iter会导致 ICA 收敛极慢而效果没有本质提升。find_bads_eog用 EOG 通道作为参考,threshold=2.5是相关性阈值,低于这个值的成分判定为眼电。实际跑的时候,我发现有些受试者的眨眼成分在第 1 个或第 16 个分量上,不要想当然地认为 EOG 一定是第 0 个。
2.3 epoch 分段与标签对齐:试次对应关系别搞混
BCIC IV 2a 的标签是 769/770/771/772 四个编码,分别对应左手、右手、双脚、舌头。很多人翻车的地方在于用 MNE 读取时事件编码没做映射,直接把 769 当分类标签喂给模型,模型输入不是 0-3 的连续整数,全连接层维度直接对不上。
# 正确的事件ID映射 event_dict = {'769': 0, '770': 1, '771': 2, '772': 3} # 像第 2.1 节那样读取 epochs 后,再做一次 V 字分割 # 训练集有 288 个试次,测试集另有一个 session,需要分别归一化 X_train, y_train = epochs.get_data(), epochs.events[:, -1] X_test, y_test = ... # 按受试者做 z-score 归一化 mean = X_train.mean(axis=(0, 2), keepdims=True) std = X_train.std(axis=(0, 2), keepdims=True) X_train = (X_train - mean) / (std + 1e-6) X_test = (X_test - mean) / (std + 1e-6)这里必须提一个血泪教训:测试集的归一化参数必须来自训练集,不能单独算。如果你把测试集整体做了自己的均值和方差,训练分布和测试分布就不是同一套空间了,测试集准确率会虚高,属于典型的数据泄漏。keepdims=True保住了通道维度,把每个受试者的每个通道在时间维度上做了归一化,这样能保留通道间的相对幅值差异,而不是把不同通道压到同一量纲——通道间的幅值差异本身对运动想象分类是有判别力的。
3. CNN 提取局部时空特征:空间卷积与时间卷积的双分支实现
3.1 双分支结构的设计动机:为什么先 CNN 再 Transformer
Transformer 的自注意力是全局建模利器,但它有两个弱点:一是对局部节律模式不敏感,二是直接处理原始高维时间序列时计算量太大。运动想象脑电里最有判别力的信息恰恰是局部的时间-空间模式——某个时刻某个通道组合出现的事件相关去同步。所以这套源码的架构逻辑很清晰:CNN 先做局部特征提炼,把信号从(22, 1000)降维成紧凑的高层特征序列,Transformer 再在这个序列上做全局关系建模。这也是深度学习脑电分类里目前公认的做法:先局部,后全局。
3.2 空间卷积层:用 Depthwise 卷积替代空间映射矩阵
EEG 的通道之间不是平面像素关系,而是头皮上不均匀分布的电极。早期方法用 CSP(共空间模式)找正交空间滤波器,深度学习时代可以用一个深度可分离卷积直接做这件事。PyTorch 里的Conv2d配合groups参数就能实现 Depthwise 卷积,让每个通道独立学一组空间滤波器,不跨通道共享权重。
import torch.nn as nn class SpatialConv(nn.Module): def __init__(self, in_ch=22, out_ch=32, sampling_rate=250): super().__init__() # 深度卷积:每个通道独立卷积,用 groups 实现 self.depthwise = nn.Conv2d( in_channels=in_ch, out_channels=in_ch, kernel_size=(1, 1), groups=in_ch # 关键参数:每个通道各学各的 ) # 逐点卷积:跨通道线性组合,混信息 self.pointwise = nn.Conv2d( in_channels=in_ch, out_channels=out_ch, kernel_size=(1, 1) ) def forward(self, x): # x 形状: (batch, 22, 1000) -> 补一个维度变成 (batch, 22, 1, 1000) x = x.unsqueeze(2) x = self.depthwise(x) x = self.pointwise(x) return xgroups=in_ch是 Depthwise 卷积的精髓,它强制每个输入通道只被一个卷积核处理,参数数量从22 * 22降到22。后面的逐点卷积再把 22 个通道的信息线性混合成out_ch个抽象空间特征。我一般把out_ch设为 32,太大的通道数在脑电小样本场景下容易过拟合,毕竟单受试者只有 288 个训练样本。
3.3 时间卷积层:卷积核跨时间滑窗提取节律模式
空间卷积之后,每个时间点上都有 32 维的空间特征,接下来要在时间轴上提取局部模式。运动想象的 mu/beta 节律是振荡信号,一个时间卷积核如果覆盖约 100 ms 的窗口(250 Hz 下是 25 个采样点),就能捕捉到大约 2-3 个周期的节律信息,这是特征提取中最关键的超参数之一。
class TemporalConv(nn.Module): def __init__(self, in_ch=32, out_ch=64, kernel_size=25, stride=1): super().__init__() self.conv = nn.Conv2d( in_channels=in_ch, out_channels=out_ch, kernel_size=(1, kernel_size), stride=(1, stride), padding=(0, kernel_size // 2) ) self.bn = nn.BatchNorm2d(out_ch) self.act = nn.GELU() def forward(self, x): # x 形状: (batch, 32, 1, 1000) x = self.conv(x) # -> (batch, 64, 1, 1000) x = self.bn(x) x = self.act(x) return xkernel_size=25对应 100 ms 时间窗,这是参考 EEGNet 论文的经典设置。要不要加 padding 取决于你是否需要保持时间长度不变。如果你的 Transformer 只吃序列长度不管具体采样点对齐,可以直接不用 padding,让序列缩短,节省计算量。这里用了 GELU 激活而不是 ReLU,因为脑电特征是连续振荡的,GELU 的平滑特性在 EEG 信号上比 ReLU 稳定。
3.4 维度规整:CNN 输出怎么喂给 Transformer 是最大分水岭
CNN 输出的形状是(batch, 64, 1, 1000),不能直接喂给 Transformer。Transformer 期望的输入是(batch, seq_len, d_model),即一个 token 序列,每个 token 是一个固定维度的向量。这里的 tokenization 策略直接决定模型效果:我的做法是把时间维度切成多个窗口,每个窗口内的时空特征拉平成一个 token。
class CNNAndTokenization(nn.Module): def __init__(self, in_ch=22, out_ch=64, kernel_size=25, d_model=128, n_tokens=50): super().__init__() self.spatial = SpatialConv(in_ch, 32) self.temporal = TemporalConv(32, out_ch, kernel_size) # 把时间维度分成 n_tokens 段,每段内部做平均池化 self.pool = nn.AdaptiveAvgPool2d((1, n_tokens)) # 拉平后投影到 d_model 维度 self.proj = nn.Linear(out_ch * 1, d_model) def forward(self, x): # x: (batch, 22, 1000) x = self.spatial(x) # (batch, 32, 1, 1000) x = self.temporal(x) # (batch, 64, 1, 1000) x = self.pool(x) # (batch, 64, 1, 50) x = x.permute(0, 3, 1, 2) # (batch, 50, 64, 1) x = x.flatten(2) # (batch, 50, 64) x = self.proj(x) # (batch, 50, 128) return xn_tokens=50是我试过的经验值,把 1000 个时间点压缩成 50 个 token,每个 token 代表 80 ms 的聚合信息。这个超参数非常敏感:token 太少,局部细节被平均池化抹掉;token 太多,Transformer 的自注意力矩阵变大,且每个 token 的信息量不足。50 是平衡点。flatten(2)之前先permute把序列维度换到第 1 维,这是 PyTorch Transformer 的固定输入要求。如果你改用nn.TransformerEncoder,这个形状直接就能用,不用再折腾。
4. Transformer 编码器适配:位置编码与多头注意力的脑电改法
4.1 位置编码的数值边界:正弦编码在 EEG 上的隐患
标准 Transformer 用正弦位置编码,但在脑电这种高频振荡信号上直接套用会出现一个问题:位置编码的波长范围可能跟节律信号共振,把位置信息混进特征信息里。源码作者的处理方法是把位置编码缩放后与 CNN 特征拼接,而不是相加。相加会让位置信息直接污染 CNN 学习到的幅值特征,拼接则保留了两个空间的独立性。
def get_position_encoding(seq_len, d_model): pe = torch.zeros(seq_len, d_model) position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model) ) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return pe.unsqueeze(0) # (1, seq_len, d_model) # 使用时拼接而不是相加 pos_enc = get_position_encoding(n_tokens=50, d_model=128).to(x.device) x = torch.cat([x, pos_enc.expand(batch_size, -1, -1)], dim=-1) # (batch, 50, 256)这段代码的div_term是指数衰减公式,来自原版 Transformer。脑电场景下 seq_len 只有 50,位置编码的波长范围从 2π 到 10000 * 2π,覆盖了不同尺度的位置周期。用拼接的另一个实际收益是 d_model 翻倍到 256,特征容量更大,在小样本数据集上不会像纯相加那样早期饱和。
4.2 注意力头数与前馈网络:参数量控制是关键
脑电分类数据量远小于自然语言处理,所以 Transformer 的参数量必须收敛。源码里没有用标准 BERT 那种 12 层堆叠,而是 2 到 4 层编码器 + 4 个注意力头 + 前馈网络隐藏维度 512。每层自注意力只做特征交互,不做层数堆叠,因为多出来的非线性变换在小数据集上会直接过拟合到噪声上。
import torch.nn as nn import math class EEGTransformerEncoder(nn.Module): def __init__(self, d_model=128, nhead=4, num_layers=2, dim_feedforward=512): super().__init__() # 矩阵缩放因子,注意力分数除以 sqrt(d_k) 防梯度消失 self.d_model = d_model encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=0.1, 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, 50, 256) 来自拼接位置编码 x = self.encoder(x) x = self.norm(x) return xnn.TransformerEncoderLayer是 PyTorch 封装好的编码器层,batch_first=True让输入形状直接是(batch, seq_len, d_model),省去 permute 的麻烦。dropout 设置 0.1 就够了,不要超过 0.3,否则位置编码和 CNN 特征之间的交互会被随机砍掉太多。num_layers=2是我推荐的起点,如果你发现欠拟合再往上加一层,不要一上来就堆 6 层。
4.3 分类头与整体前向流程:序列特征怎么变成四类概率
Transformer 编码器输出的是(batch, seq_len, d_model)形状的特征。分类时需要把序列信息聚合起来。这里用的是全局平均池化 + 线性分类头的组合,跟你见到的 BERT 用[CLS]token 的含义类似,但不需要额外训练一个特殊 token,因为脑电序列没有天然的句首概念。
class MIEEGClassifier(nn.Module): def __init__(self, in_ch=22, n_classes=4, d_model=128): super().__init__() self.cnn_tokenizer = CNNAndTokenization(in_ch, d_model=d_model) self.transformer = EEGTransformerEncoder(d_model=d_model * 2) # 拼接后翻倍 self.pool = nn.AdaptiveAvgPool1d(1) self.classifier = nn.Linear(d_model * 2, n_classes) def forward(self, x): # x: (batch, 22, 1000) x = self.cnn_tokenizer(x) # (batch, 50, 128) x_pos = get_position_encoding(50, 128).to(x.device) x = torch.cat([x, x_pos.expand(x.size(0), -1, -1)], dim=-1) # (batch, 50, 256) x = self.transformer(x) # (batch, 50, 256) x = x.mean(dim=1) # 全局平均池化 (batch, 256) x = self.classifier(x) # (batch, 4) return xd_model * 2是因为前面拼接了位置编码,实际 Transformer 输入维度翻倍。如果你不想记住这个隐含关系,可以把位置编码投影回d_model维度再相加,但前面讲过了,拼接比相加在这个场景下表现更好。整体流程就是:原始信号(22, 1000)→ CNN 局部特征(50, 128)→ 拼接位置编码(50, 256)→ Transformer 全局建模(50, 256)→ 平均池化(256)→ Softmax 四类概率。
5. 避坑指南:运动想象脑电分类最常见的 5 个翻车点
下面这 5 个问题是我在这个源码上实际跑出来的血泪经验,每一条都曾经让模型准确率不升反降。
问题 1:测试集归一化用了自己的统计量,准确率虚高
- 现象:测试集准确率高达 85%,但换一批数据直接掉到 60%。
- 原因:测试集的均值和标准差是单独计算的,导致训练和测试特征分布不一致,模型见过测试分布的均值偏移,等于提前看到了测试集信息。
- 解决:只允许训练集计算 mean 和 std,测试集直接套用训练集的统计量,代码见第 2.3 节。
问题 2:ICA 放在滤波之前,眼电成分始终去不干净
- 现象:ICA 分解出的成分里 EOG 相关性分数一直很低,
find_bads_eog找不到眼电成分。 - 原因:原始信号里有 50 Hz 工频和高频肌电干扰,ICA 的收敛条件被这些分量带偏了,分解出的独立成分混叠严重。
- 解决:先带通滤波(8-30 Hz),再做 ICA,顺序反了就重跑。
问题 3:卷积核大小选成 5 或 3,准确率卡在 65% 上不去
- 现象:时间卷积核设成 5 个采样点(20 ms),模型欠拟合,准确率只有 65% 上下。
- 原因:运动想象节律的最低频率是 8 Hz,周期 125 ms,太小的卷积核覆盖不了一个完整节律周期,学不到振荡特征。
- 解决:内核大小至少 25 个采样点(100 ms),如果你用的是 8-12 Hz 波段,建议 31 个采样点覆盖整个 mu 节律周期。
问题 4:类别不平衡导致模型全预测某一类
- 现象:训练集四类准确率分别是 90%、50%、20%、45%,总准确率看着还行,实际上右手类别完全没学会。
- 原因:BCIC IV 2a 原始数据各类别样本量接近,但如果做了错误的分段或过滤,某个类别的数据可能被误删。
- 解决:训练前打印
np.bincount(y)检查每个类别的样本数,如果差别超过 10%,加WeightedRandomSampler做平衡采样。
问题 5:随机种子没固定,同一次训练两次结果差 5%
- 现象:同一份源码跑两遍,第一次准确率 78%,第二次 73%,以为是代码有 bug。
- 原因:PyTorch 的卷积初始化、数据加载器的打乱顺序都依赖随机数,不固定种子相当于每次从头学。
- 解决:在
main函数开头固定三个随机源。
import random import numpy as np import torch def seed_everything(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # CUDNN 的确定性算法只用于卷积,会牺牲少量性能 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这条排查顺序很重要:先固定种子,再做数据检查,最后调模型结构。如果你犯的错误是丢了固定种子这步,后面的模型调参全是在噪声上做优化。
6. 五折交叉验证与时间窗集成:让准确率再稳 2% 的收尾技巧
模型结构定型之后,最后一个提升准确率且不增加模型复杂度的技巧是五折交叉验证加多时间窗投票。单折训练容易受数据划分影响,我在这套源码上发现,同一个模型用不同的 80/20 划分,准确率波动能到 2.5%。五折交叉验证把每个样本的预测概率取均值作为最终预测,等于训练了 5 个模型的集成,代价只有训练时间翻 5 倍,但对 288 个样本的训练集来说完全可接受。
from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score # 多时间窗集成:训练时用 0-4s,预测时分别取 0.5-2.5s、0-3s、1-4s、0.5-3.5s 四段 windows = [(125, 625), (0, 750), (250, 1000), (125, 875)] # 单位是采样点 def predict_prob(model, X_raw, windows): probs = np.zeros((X_raw.shape[0], 4)) for tmin, tmax in windows: X_win = X_raw[:, :, tmin:tmax] X_win = (X_win - mean[:, :, tmin:tmax]) / (std[:, :, tmin:tmax] + 1e-6) with torch.no_grad(): probs += torch.softmax(model(X_win), dim=1).numpy() return probs / len(windows) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) final_probs = np.zeros((X.shape[0], 4)) for train_idx, val_idx in skf.split(X, y): model = MIEEGClassifier(in_ch=22) # 训练两三个 epoch train_one_epoch(model, X[train_idx], y[train_idx]) final_probs[val_idx] = predict_prob(model, X[val_idx], windows) final_acc = accuracy_score(y, np.argmax(final_probs, axis=1))五折交叉验证里的一个关键点是StratifiedKFold而不是普通KFold,它保证每一折里四类样本比例跟整体一致,避免某一折里恰好缺了某个类别。多时间窗集成的思路是:运动想象的起始反应时间因人而异,有些受试者 1 秒后信号才起来,有些 0.5 秒就开始,用多个窗口让模型对时序对齐不那么敏感。从那以后,我每次跑脑电分类实验都强制走一遍固定随机种子加五折验证的流程,这套源码给我最大的启发是脑电分类的涨点往往不在网络结构上,而在数据划分和时间窗口的处理上。希望帮到你。
本文还有配套的精品资源,点击获取