简介:这份资源是面向计算机相关专业本科生与项目实战学习者的脑电信号分类系统源码,采用CNN与Transformer混合框架,可作为毕业设计、课程设计或期末大作业的完整参考方案。压缩包共31个文件,约18.46MB,以23个Python脚本为核心,覆盖模型定义、训练与可视化流程;另含2个MATLAB预处理脚本、2个Excel统计表、1个npy数据文件、1个pth权重文件及xml、md等配置说明,兼顾算法实现与实验记录。项目围绕EEGNet、空间时序注意力、共空间模式、Morlet变换等模块展开,并提供t-SNE降维、AUC曲线、脑电热力图、CAM类激活图与统计检验等分析脚本,便于理解从数据预处理到结果呈现的完整链路。目前已有64人学习下载,适合希望快速掌握脑电分类建模思路、对照复现实验并完成论文撰写的读者参考。
1. 从一份本科毕设源码说起:CNN+Transformer 怎么做脑电信号分类
脑电信号分类这个方向,每年毕业季都有大量同学在找可跑的源码。我拿到这份「基于 Transformer 脑电信号分类系统源码(CNN+Transformer 框架)」时,第一反应是:它解决的不是「从零训练一个 SOTA 模型」的问题,而是「在有限算力和有限数据下,把一段多通道 EEG 时序稳定地分到若干类别」的问题。EEG 本身信噪比低、通道间相关性强、样本量通常只有几百到几千条,纯 Transformer 容易过拟合,纯 CNN 又抓不住长程依赖,所以 CNN 提局部特征、Transformer 建模全局时序,是这类任务里比较务实的组合。它适合正在做计算机、电子信息、人工智能方向毕业设计,需要一套能读懂、能改、能写进论文的实验框架的同学,也适合想快速验证自己采集的 EEG 数据能不能被分类的从业者。
2. 拆开这套 CNN+Transformer 框架:数据流、张量形状与选型理由
2.1 为什么不是纯 Transformer,也不是纯 CNN
先讲清楚选型。EEG 是典型的多通道时间序列,假设采样率 250Hz,一次试验 4 秒,那就是 1000 个时间点,通道数常见 8、16、32、64。如果直接把这 1000×C 的矩阵拉平送进全连接,参数量爆炸且丢掉了时间结构;如果只用 CNN,卷积核感受野有限,堆很多层才能覆盖整段时序,而且池化会丢掉相位类信息。
Transformer 的自注意力天然能建模任意两个时间点的关系,对 EEG 里跨区域的同步放电模式很合适。但问题在于,自注意力的参数量和计算量随序列长度平方增长,1000 个时间点直接做全局注意力,显存和过拟合都扛不住。所以常见做法是先用 CNN 做下采样和局部特征提取,把 1000 个时间点压到 100 到 250 个 token,再送进 Transformer 编码器。这样既保留了局部波形特征,又让注意力在可接受的序列长度上工作。
这份源码走的就是这个思路:前端是若干层一维卷积加池化,后端是标准 Transformer Encoder,最后接一个分类头。理解这条主线,后面看代码就不会迷路。
2.2 数据加载与张量形状约定
EEG 数据常见的存储格式有.npy、.mat、.csv,也有用 MNE 读.edf、.fif的。这份源码默认走的是「已经切好 epoch 的数组」,形状约定为(N, C, T),即样本数、通道数、时间点。这个顺序很关键,因为 PyTorch 的Conv1d要求输入是(batch, channels, length),如果你的数据是(N, T, C),必须先转置,否则卷积会把时间点当通道,结果完全错乱。
下面是我一般会先跑一遍的数据检查脚本,确认形状、标签分布和是否有 NaN:
import numpy as np # 假设数据已经存成 npy,X 是信号,y 是标签 X = np.load('eeg_data.npy') # 期望形状 (N, C, T) y = np.load('eeg_labels.npy') # 期望形状 (N,) print('X shape:', X.shape) print('y shape:', y.shape) print('类别分布:', np.bincount(y)) print('是否存在 NaN:', np.isnan(X).any()) print('单样本均值/标准差:', X[0].mean(), X[0].std()) # 如果形状是 (N, T, C),这里转成 (N, C, T) if X.shape[1] > X.shape[2]: X = np.transpose(X, (0, 2, 1)) print('已转置为 (N, C, T):', X.shape)逻辑说明:先打印形状,人眼确认通道维和时间维哪个在前;np.bincount看类别是否严重不平衡,如果某一类占 90%,后面训练必须加权重或重采样;NaN 检查是血泪经验,EEG 采集断连经常产生 NaN,不处理会让 loss 直接变 nan。参数上,X.shape[1] > X.shape[2]这个判断只是启发式,实际要以你的采集协议为准,通道数一般远小于时间点数,所以这个判断在多数场景成立,但别当成万能。
2.3 模型结构:CNN 前端与 Transformer 编码器怎么接
前端 CNN 的作用是降维和提特征。常见配置是三层一维卷积,通道数从输入的 C 逐步升到 64、128,每层后接 BatchNorm、ReLU 和最大池化,池化核一般取 2 或 4。这样时间维从 1000 降到 1000/8 到 1000/64 之间。卷积核大小取 7 到 15 比较常见,太小抓不到波形,太大参数量上升。
后端 Transformer 用nn.TransformerEncoder,d_model要和 CNN 输出通道数对齐,nhead要能整除d_model,常见d_model=128, nhead=8,层数 2 到 4 层足够,再多在小数据集上就是过拟合。位置编码用可学习的位置嵌入,因为 EEG 的绝对位置有意义(比如刺激后 300ms 的成分)。
import torch import torch.nn as nn class CNNTransformer(nn.Module): def __init__(self, in_chans, num_classes, d_model=128, nhead=8, num_layers=3): super().__init__() # CNN 前端:逐步升通道、降时间 self.cnn = nn.Sequential( nn.Conv1d(in_chans, 64, kernel_size=15, padding=7), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=7, padding=3), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(128, d_model, kernel_size=3, padding=1), nn.BatchNorm1d(d_model), nn.ReLU(), nn.MaxPool1d(2), ) # 可学习位置编码,长度按最大时间步给足 self.pos_embed = nn.Parameter(torch.zeros(1, 512, d_model)) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=256, dropout=0.3, batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.head = nn.Linear(d_model, num_classes) def forward(self, x): # x: (B, C, T) x = self.cnn(x) # -> (B, d_model, T') x = x.transpose(1, 2) # -> (B, T', d_model) x = x + self.pos_embed[:, :x.size(1), :] x = self.encoder(x) # -> (B, T', d_model) x = x.mean(dim=1) # 时间维平均池化 return self.head(x)逻辑说明:batch_first=True让 Transformer 接受(B, T, d_model),省去手动转置的麻烦。位置编码用nn.Parameter而不是正弦编码,是因为 EEG 的绝对时间位置和任务强相关,可学习编码在数据量够时更灵活。dropout=0.3是这类小数据集的常用值,如果你数据量上千,可以降到 0.1。x.mean(dim=1)是最简单的聚合方式,也可以换成取最后一个 token 或注意力池化,但平均池化最稳,先跑通再换。
参数上,d_model必须等于 CNN 最后一层输出通道数,否则相加会报维度错误;nhead必须整除d_model,128/8=16 没问题,如果你改成d_model=100,nhead=8就会报错。pos_embed的长度 512 是上限,如果你的时间步超过 512,要么加大这个数,要么在 CNN 里多池化一次。
2.4 训练循环与关键超参
训练部分没什么玄学,但有几个参数直接决定能不能收敛。优化器用 AdamW,学习率 1e-3 到 1e-4,权重衰减 1e-4。损失函数用交叉熵,类别不平衡时加weight。batch size 受显存限制,EEG 数据一般 32 或 64。早停 patience 设 10 到 15,因为小数据集上验证 loss 波动大,patience 太小会误停。
from torch.utils.data import DataLoader, TensorDataset import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') X_t = torch.tensor(X, dtype=torch.float32) y_t = torch.tensor(y, dtype=torch.long) loader = DataLoader(TensorDataset(X_t, y_t), batch_size=32, shuffle=True) model = CNNTransformer(in_chans=X.shape[1], num_classes=len(np.unique(y))).to(device) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(100): model.train() total_loss = 0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() out = model(xb) loss = criterion(out, yb) loss.backward() # 梯度裁剪,防止 Transformer 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() print(f'epoch {epoch}, loss {total_loss/len(loader):.4f}')逻辑说明:clip_grad_norm_是 Transformer 训练的后悔药,EEG 数据里偶尔有幅度异常的样本,不裁剪容易梯度爆炸。shuffle=True必须开,否则同类样本连续进入,BatchNorm 统计量会偏。学习率 1e-3 是起点,如果 loss 前几个 epoch 不降,先降到 1e-4 试。这段代码没写验证集,实际做毕设必须切出 20% 做验证,否则论文里的准确率没有说服力。
3. 把源码跑起来:环境、数据替换与训练排错
3.1 环境依赖与版本对齐
这类源码最常见的翻车点不是模型,是环境。PyTorch 版本和 CUDA 版本不匹配,torch.cuda.is_available()返回 False,代码默默跑在 CPU 上,一个 epoch 跑半小时。我一般先固定一套组合:Python 3.9 到 3.10,PyTorch 2.0 以上,CUDA 11.8 或 12.1,numpy 1.24 左右。不要盲目装最新版,Transformer 相关 API 在 1.x 和 2.x 之间有变化,源码如果写的是旧写法,装新版会报batch_first相关错误。
# 建议用 conda 建独立环境,避免污染系统 Python conda create -n eeg_transformer python=3.10 -y conda activate eeg_transformer # 按你的 CUDA 版本选对应命令,这里以 CUDA 11.8 为例 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy scipy scikit-learn matplotlib # 验证 GPU 是否可用 python -c "import torch; print(torch.__version__, torch.cuda.is_available())"逻辑说明:--index-url指定 PyTorch 官方 wheel 源,避免 pip 默认源装到 CPU 版。最后一行验证必须打印True,如果是False,先别急着跑训练,去查显卡驱动和 CUDA 版本对应关系。这一步省不得,我见过太多人训练一晚上发现用的是 CPU。
3.2 替换成自己的数据:三个必须改的地方
源码默认带一份示例数据,你要换成自己采集或公开数据集(比如 BCI Competition IV 2a)。需要改三处:数据路径、通道数、类别数。通道数在模型初始化时传入,类别数决定分类头输出维度。如果你的数据通道数和源码不一致,CNN 第一层in_chans不改会直接报维度错误。
# config 里通常有这几个字段,按你的数据改 DATA_PATH = 'your_data.npy' # 换成你的数据路径 LABEL_PATH = 'your_labels.npy' IN_CHANS = 22 # 你的电极通道数,比如 22 或 64 NUM_CLASSES = 4 # 你的分类类别数 SEQ_LEN = 1000 # 你的单样本时间点数 # 如果数据是 (N, T, C),加载后转置 X = np.load(DATA_PATH) if X.shape[1] != IN_CHANS: X = np.transpose(X, (0, 2, 1)) assert X.shape[1] == IN_CHANS, f'通道数不匹配: {X.shape}'逻辑说明:assert这行是保险丝,形状不对立刻停,别让它跑到模型里再报错,那时候错误信息很难定位。SEQ_LEN影响位置编码长度和 CNN 池化后的时间步,如果你的序列比源码短很多,池化层数要相应减少,否则最后时间维变成 1,Transformer 就没意义了。
3.3 训练不收敛时先看什么
loss 不降或震荡,按这个顺序排查:第一,学习率是不是太大,先降到 1e-4;第二,数据有没有归一化,EEG 原始值可能在微伏级,不做 z-score 或除以标准差,梯度会很小;第三,标签有没有对错位,np.bincount看分布是否合理;第四,BatchNorm 在 batch size 太小时不稳定,如果 batch 只有 8,考虑换 GroupNorm 或加大 batch。
验证准确率远高于训练准确率,通常是 dropout 太强或数据泄漏,检查切分时有没有把同一被试的样本同时放进训练和验证。EEG 做被试独立划分才严谨,同一被试的样本混在一起,准确率会虚高十几个点,这是论文里常见的硬伤。
4. 避坑与常见问题:EEG 分类里那些容易翻车的地方
4.1 现象:训练 loss 正常下降,验证准确率一直在 25% 左右
原因:类别数如果是 4,25% 就是随机水平,说明模型没学到东西。最常见的原因是标签和数据没对齐,比如数据做了 shuffle 但标签没跟着 shuffle,或者标签从 0 开始还是从 1 开始和模型输出对不上。
解决:先固定一个 batch 打印xb[0]和yb[0],人眼确认信号和标签对应;再用一个极小数据集(比如 20 条)过拟合测试,如果连 20 条都拟合不到 100%,说明模型或数据管道有 bug,不是超参问题。
4.2 现象:训练到一半 loss 突然变成 nan
原因:EEG 数据里存在幅度异常大的样本,或者某段信号全零导致 BatchNorm 方差为零,反向传播时梯度爆炸。
解决:加载数据后做幅度检查,超过均值 5 倍标准差的样本截断或剔除;在 BatchNorm 里加eps=1e-5;训练时加梯度裁剪,max_norm=1.0。如果还出现,把学习率再降一个数量级。
4.3 现象:显存不够,batch size 只能设到 8
原因:序列太长或d_model太大,Transformer 的注意力矩阵是T'×T',T' 是池化后的时间步,如果池化不够,T' 还有 500,注意力矩阵就是 500×500,显存占用不小。
解决:在 CNN 前端多加一层池化,把 T' 压到 128 以内;或者用torch.utils.checkpoint做梯度检查点,用时间换显存;再不行就减小d_model到 64。别硬扛,毕设阶段跑通比堆参数重要。
4.4 现象:换了自己的数据后,准确率比源码示例低很多
原因:不同数据集的采样率、通道数、类别难度差异很大,源码示例可能是在一个很容易的数据集上调过参的。直接套用超参,效果差是正常的。
解决:先确认你的数据和示例数据的采样率是否一致,不一致要重采样;再检查是否需要带通滤波(常见 0.5 到 40Hz),原始 EEG 不做滤波噪声很大;最后重新调学习率和 dropout,别指望一套参数打天下。
4.5 现象:论文里写的准确率很高,但复现不出来
原因:随机种子没固定,或者数据切分方式在论文里没写清楚,同一被试样本泄漏导致虚高。
解决:在代码开头固定torch.manual_seed(42)、np.random.seed(42);数据切分按被试划分,训练集和测试集被试不重叠;报告结果时给出多次运行的均值和标准差,而不是挑一次最高的写。
5. 进阶技巧:把准确率再往上推一推的验证方法
跑通之后,如果想把结果做得更扎实,我一般会从三个方向入手。第一是数据增强,EEG 常用的有加高斯噪声、时间平移、通道随机丢弃、幅度缩放,这些在训练时在线做,能明显缓解过拟合。第二是学习率调度,用余弦退火或ReduceLROnPlateau,验证 loss 不降时自动降学习率,比固定学习率稳。第三是集成,训练 3 到 5 个不同随机种子的模型,推理时对 logits 取平均,准确率通常能涨 1 到 3 个点,代价是训练时间翻倍。
验证方法上,别只看准确率。EEG 分类里类别不平衡很常见,要看混淆矩阵和每类的 F1。如果某一类 F1 特别低,去听一下那类样本的信号,很多时候是采集时被试动了或者电极松了,数据本身有问题,模型再调也没用。
from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for xb, yb in val_loader: xb = xb.to(device) preds = model(xb).argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(yb.numpy()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, digits=4))逻辑说明:model.eval()必须调用,否则 dropout 和 BatchNorm 还在训练模式,验证结果不可信。torch.no_grad()省显存。混淆矩阵看哪两类容易混,分类报告看每类 F1,比单一准确率有信息量得多。
从那以后我每次拿到一份新源码,都强制先跑一遍数据形状检查和 20 条过拟合测试,确认管道通了再动超参。这个习惯帮我省了无数个白跑的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取