
简介这套毕业设计源码围绕基于Transformer的脑电信号分类任务采用卷积神经网络与变换器结合的混合框架面向计算机相关专业学生适用于本科毕业设计、课程设计或期末大作业等场景。项目来自导师指导的完整设计评审获得九十九分代码结构清晰、可运行性强初学编程者也能快速使用。压缩包共三十一个文件整体约十八点四六兆字节包含二十三个代码文件、两个表格文件、两个预处理脚本另含模型权重、数据文件、配置文件与说明文档。源码涵盖模型定义、训练评估、特征可视化等模块例如卷积变换器模型、脑电网络模型、热力图分析、降维展示、统计检验等脚本负责数据读取与预处理权重与数据文件可直接用于复现实验。目前已有六十四人学习下载。借助这套资源可以快速搭建脑电分类实验环境深入理解时空注意力与卷积特征融合的思路并根据可视化结果分析模型关注区域适合作为毕业设计的基础代码或实战练习项目。1. 为什么毕业设计选 Transformer 做脑电分类它到底比纯 CNN 强在哪先抛结论这份源码把原始多通道脑电EEG先用 CNN 做局部特征提取再转换成 token 序列交给 Transformer 编码器建模全局时序关系最后接分类头输出类别概率是典型的 CNNTransformer 混合框架。我在运动想象四分类任务上按默认参数跑了一遍验证集准确率稳定在 85% 上下比同等参数量的纯 CNN 高 68 个点。对做本科毕设、课程设计或者 BCI 小项目的同学来说它最大的价值是给了完整可训练的数据加载、模型定义、训练循环和推理脚本不用自己从零拼装。接下来我会从原理到跑通再到调参逐步拆开讲新手按章节顺序复现即可老手可以直接看第 5、6 章的边界条件和踩坑记录。2. 架构拆解CNN 抓局部、Transformer 抓全局token 从哪来、注意力往哪打2.1 先搞清楚输入长什么样脑电数据不是图像也不是文本EEG 数据最基本的形态是通道数 C 乘以时间采样点数 T 的矩阵。比如 64 通道、采样率 250Hz、取 4 秒窗单样本就是 64×1000 的二维数组。这个维度直接铺平喂给全连接网络是不现实的因为参数量会爆炸而且会丢失电极之间的空间结构。常见做法是先做滑窗切段把连续记录切成固定长度的短片段每一段整体给一个标签模型对整段做分类。预处理完的数据在内存里的形状是 (N, C, T)N 是样本数C 是电极通道数T 是窗长。代码里我习惯先在通道维上补一维变成 (N, 1, C, T)多出来的这一维是给 Conv2d 当 channel 维度用。这里和图像分类最大的差异在于脑电没有 RGB 通道只有电极维和时间维所以卷积的设计思路也要跟着变。2.2 CNN 前端把通道融合和时间降采样做成 tokenCNN 在这套框架里不是最终分类器而是负责构建 token 序列。常见做法是用两个卷积第一个卷积核是 (C, 1)沿着电极方向做空间混合把全部脑电通道加权融合成若干虚拟通道第二个卷积核是 (1, 25)沿时间方向覆盖 25 个采样点提取类似于 alpha、beta 节律的局部波形模式。第二个卷积之后接平均池化把时间长度压缩到原来的四分之一。这个设计有两个目的一是把 Transformer 要处理的序列长度降下来序列越短自注意力矩阵越小计算量和显存占用都更可控二是让 CNN 先完成局部平移不变特征提取Transformer 只在相对抽象的特征层做长程依赖建模两个模块分工明确。我还会在 CNN 输出后接一个 Linear 层把维度投影到 embed_dim保证进入 Transformer 的 token 有足够的表达能力。import torch.nn as nn import torch class CNNFrontend(nn.Module): def __init__(self, channels, embed_dim): super().__init__() self.spatial nn.Sequential( nn.Conv2d(1, 16, kernel_size(channels, 1), biasFalse), nn.BatchNorm2d(16) ) self.temporal nn.Sequential( nn.Conv2d(16, 32, kernel_size(1, 25), padding(0, 12), biasFalse), nn.BatchNorm2d(32), nn.GELU(), nn.AvgPool2d((1, 4)) ) self.proj nn.Linear(32, embed_dim) def forward(self, x): # x: (B, 1, C, T) x self.spatial(x) # (B, 16, 1, T) x self.temporal(x) # (B, 32, 1, T//4) x x.squeeze(2) # (B, 32, T//4) x x.permute(0, 2, 1) # (B, T//4, 32) return self.proj(x) # (B, T//4, embed_dim)第一个卷积的核是 (channels, 1)每次只看一个时间点、看全部电极输出 16 个虚拟通道第二个卷积在时间维上滑动核长 25 对应 25 个采样点在 250Hz 采样率下就是 100ms足以覆盖一个 alpha 波的四分之一周期左右。padding 设为 12 是为了让时间卷积输出长度不变后面的平均池化才能在不丢边界的情况下做四倍降采样。这里需要改的参数是 channels 和 embed_dimchannels 必须等于真实电极数embed_dim 一般取 32128太小表达能力不够太大会让自注意力矩阵迅速膨胀。2.3 多头自注意力脑电时间点之间为什么需要全局关系脑电信号有个特点一个事件引起的变化不是孤立在某个采样点上的而是持续几百毫秒的一段模式。比如运动想象过程中mu 节律的能量会在一段窗口内持续衰减几个频段之间还存在耦合关系。CNN 卷积核只能看到 100ms 左右的局部想组合跨 500ms 的前后线索就得靠自注意力把任意两个时间位置直接建立连接。多头设计的意义在于让模型同时关注不同尺度的关系。一个头可能在抓同频段信号的自相关另一个头可能在抓不同频段之间的相位关系。对脑电这种非平稳、信噪比低的信号来说这种多视角全局建模比固定感受野的 CNN 更灵活也比 RNN/LSTM 的串行计算更高效——Transformer 一次前向就能让所有时间步两两交互而 LSTM 需要一步步把信息传下去长序列上容易遗忘早期状态。2.4 分类头CLS token 还是全局池化源码里用的是 CLS token 方案在 token 序列前端拼接一个可学习的向量经过全部 Transformer 编码层之后这个位置的隐藏状态相当于整个窗口的全局摘要再接一个全连接层输出类别概率。之所以不直接用全局平均池化是因为序列里有噪声段和有效段平均池化会把有效特征稀释掉而 CLS token 的注意力可以自行决定主要关注哪些位置。class TransformerEncoderBlock(nn.Module): def __init__(self, embed_dim, num_heads, ff_dim, dropout0.1): super().__init__() self.attn nn.MultiheadAttention( embed_dim, num_heads, dropoutdropout, batch_firstTrue) self.norm1 nn.LayerNorm(embed_dim) self.ff nn.Sequential( nn.Linear(embed_dim, ff_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff_dim, embed_dim) ) self.norm2 nn.LayerNorm(embed_dim) def forward(self, x): skip x x self.norm1(x) x, _ self.attn(x, x, x, need_weightsFalse) x x skip skip x x self.norm2(x) x self.ff(x) skip return x这里有一个很多人忽略的选型细节我用了 Pre-LN 结构也就是先做 LayerNorm 再进注意力而不是原版 Transformer 的 Post-LN。原因是脑电输入噪声很强Post-LN 的深层模型容易出现训练震荡loss 波动很大Pre-LN 的残差路径更干净LayerNorm 相当于给每层梯度加了归一化闸门对学习率和初始化不那么敏感。ff_dim 通常取 embed_dim 的 4 倍加宽前馈网络能提升非线性拟合能力但也会增加过拟合风险这一点第 5 章会专门说。3. 环境与快速跑通依赖版本、项目目录、训练命令3.1 依赖清单PyTorch 2.x 就能跑不需要特殊库整个项目基于 PyTorch核心依赖很少torch、numpy、scipy、scikit-learn、matplotlib可选加 mne 用来读脑电原始文件。我推荐的组合是 Python 3.9 PyTorch 2.0 CUDA 11.8。CPU 也能训练但速度会慢很多建议至少有一块 6G 显存的 NVIDIA 显卡。requirements.txt 的内容如下torch2.0.0 numpy1.21 scipy1.9 scikit-learn1.2 mne1.3 matplotlib3.6这里有个值得注意的点PyTorch 2.x 的安装命令跟系统 CUDA 版本强相关建议直接去 PyTorch 官网用生成的命令装不要pip install torch一把装否则很容易装成 CPU 版本跑起来才发现慢得离谱血泪经验。装完之后可以用torch.cuda.is_available()快速验证 GPU 是否可用。3.2 项目目录与数据放法拿到源码后建议先看目录结构再跑避免路径对不上。这份资源的结构大致如下路径作用models/eeg_transformer.pyCNN 前端 Transformer 编码器 分类头dataloader/eeg_dataset.py滑窗切分、标签对齐、数据增强train.py训练循环、验证、模型保存configs/训练参数配置文件utils/日志、指标计算、绘图工具data/放预处理后的数据默认格式是 .npy数据格式方面建议把预处理后的数据整理成 X.npy 和 y.npy 两个文件。X.npy 形状是 (N, C, T)y.npy 是 (N,) 的整数标签。放到 data/ 目录后运行训练脚本dataloader 会在内存里做滑窗切分不需要手动提前切好窗口。如果自己采集的数据是 .edf 或者 .bdf 格式先用 mne 读进来做滤波和分段再导出成 .npy这两步建议写成一个独立的 preprocess.py方便后续复用。3.3 训练命令与日志怎么看python train.py \ --data-dir ./data \ --channels 64 \ --time-len 1000 \ --num-classes 4 \ --embed-dim 64 \ --num-heads 4 \ --depth 4 \ --epochs 80 \ --batch-size 24 \ --lr 3e-4 \ --gpu 0channels 参数必须和数据集的真实电极数一致。很多公开数据集的通道数是 22 或 64但有人在预处理时做了坏通道剔除或者重参考实际通道数变了模型定义没跟着改报错或者结果崩就发生在这一步。time-len 是滑窗长度单位是采样点数不是秒数。如果采样率是 250Hz想要 4 秒窗就填 1000。batch-size 是个敏感参数第 5 章会细说。训练过程中每个 epoch 会打印 loss 和 val_acc。正常现象是前几个 epoch 的 val_acc 缓慢爬升大约 1525 个 epoch 后进入加速期。下面是我跑出来的典型日志片段可以作为参考Epoch 1/80 | loss 1.3582 | val_acc 0.2500 Epoch 10/80 | loss 0.8422 | val_acc 0.4062 Epoch 20/80 | loss 0.4511 | val_acc 0.7281 Epoch 40/80 | loss 0.2264 | val_acc 0.8468 Epoch 60/80 | loss 0.1433 | val_acc 0.8601如果 loss 在初期就不降优先检查数据标准化这个坑在第 5 章第一条。如果 val_acc 一直卡在 0.25 附近多半是标签对齐问题不是模型问题。3.4 用训练好的模型做预测训练结束会在 weights/ 目录下生成 best_model.pth。单独推理时用下面的脚本加载import torch from models.eeg_transformer import EEGTransformer model EEGTransformer( channels64, time_len1000, embed_dim64, num_heads4, depth4, num_classes4 ) ckpt torch.load(weights/best_model.pth, map_locationcpu) model.load_state_dict(ckpt[model]) model.eval() def predict(eeg_window): # eeg_window: (C, T) 的 numpy 数组需要和训练时同样的标准化 x torch.from_numpy(eeg_window).float().unsqueeze(0).unsqueeze(0) with torch.no_grad(): logits model(x) pred logits.argmax(dim1).item() return pred推理时必须复用训练时的预处理流程通道顺序、滤波参数、标准化均值方差任何一个不一致都会让模型输出无意义。我习惯把预处理封装成独立函数放在 utils/preprocess.py 里训练脚本和推理脚本都调用它避免两边各写一套逻辑。注意load_state_dict 默认是严格匹配的如果训练时用了 DataParallel 多卡保存的权重会带上 module. 前缀加载时需要先做 key 处理否则会报 size mismatch 或者 missing keys。4. 核心模块拆解可以复制到你自己的任务里4.1 数据加载器的滑窗切分细节EEG 原始记录通常是连续的模型需要的是固定长度的片段。滑窗切分有两个关键参数窗长 window_len 和步长 stride。窗口内所有采样点共享一个标签所以步长越小生成的样本数越多但相邻窗口的信息重叠也越大训练集和验证集之间的信息泄漏风险越高。import numpy as np from torch.utils.data import Dataset class EEGWindowDataset(Dataset): def __init__(self, X_raw, y_raw, window_len1000, stride500, transformNone): self.window_len window_len self.transform transform samples, labels [], [] for i in range(len(X_raw)): X X_raw[i] # (C, T_total) y y_raw[i] for start in range(0, X.shape[1] - window_len 1, stride): samples.append(X[:, start:start window_len]) labels.append(y) self.samples np.asarray(samples).astype(np.float32) self.labels np.asarray(labels).astype(np.int64) def __len__(self): return len(self.labels) def __getitem__(self, idx): x self.samples[idx] y self.labels[idx] if self.transform is not None: x self.transform(x) return x, y这里 core 逻辑是标签继承整段窗口继承该 trial 的意图标签。但如果你的任务是连续分类比如睡眠分期这种标签逐秒变化的场景就不能用这种整段继承需要做逐点对齐代码逻辑完全不同。另一个要注意的是步长选择stride 等于 window_len 时窗口无重叠样本数最少stride 取一半时样本数翻倍训练更充分但计算量也翻倍。4.2 可分离卷积与参数量控制脑电上的卷积设计推荐用可分离卷积思路先做空间卷积混合电极间信号再做时间卷积提取波形模式。主代码中空间卷积输出 16 个虚拟通道时间卷积输出 32 个特征图总参数量很小。如果一开始就直接用大的二维卷积核参数量会大很多而且容易把电极间的空间相关性和时间特性混在一起学反而更难收敛。这种设计的另一个好处是便于迁移channels 不同的数据集只需要改空间卷积的输入维度时间卷积和 Transformer 部分完全不用动。我从 22 通道数据切到 64 通道数据时只改了 channels 参数和预处理函数模型结构零改动这也是混合框架对跨数据集迁移比较友好的一点。4.3 位置编码和 CLS token 的实现Transformer 本身没有序列顺序概念所以必须对 token 位置做编码。这个源码用的是可学习位置编码直接初始化一个可训练参数矩阵形状是 (N1, embed_dim)加在 token 序列上。为什么不用原版 Transformer 的正弦位置编码因为脑电的时间位置相关性不是对任意位置相同正弦编码假设的是某种固定的相对位置模式而可学习编码可以针对不同时间位置学出不同的重要性权重这在非自然信号上通常效果更好。class EEGTransformer(nn.Module): def __init__(self, channels, time_len, embed_dim64, num_heads4, depth4, num_classes4): super().__init__() self.cnn CNNFrontend(channels, embed_dim) n_tokens time_len // 4 1 # 池化后的 token 数再加 CLS self.pos_embed nn.Parameter( torch.randn(1, n_tokens, embed_dim) * 0.02 ) self.cls_token nn.Parameter( torch.randn(1, 1, embed_dim) * 0.02 ) self.blocks nn.ModuleList([ TransformerEncoderBlock(embed_dim, num_heads, embed_dim * 4) for _ in range(depth) ]) self.head nn.Linear(embed_dim, num_classes) def forward(self, x): x self.cnn(x) # (B, N, D) cls self.cls_token.expand(x.size(0), -1, -1) # (B, 1, D) x torch.cat([cls, x], dim1) # (B, N1, D) x x self.pos_embed # 位置编码 for block in self.blocks: x block(x) return self.head(x[:, 0]) # 取 CLS token 分类这里有几个参数需要按任务调整depth 是 Transformer 编码器层数运动想象这类窗口不太长的任务 4 层足够加到 8 层以上收益很小且训练变慢embed_dim 要和 num_heads 保持整除关系常见组合是 64 / 4 或者 128 / 8初始化乘的 0.02 是为了让位置编码的初始尺度接近 LayerNorm 能接受的量级不要随手去掉。4.4 训练循环里的细节优化器、调度器、验证频率import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_fold(model, train_loader, val_loader, epochs80, lr3e-4): device next(model.parameters()).device criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0.0 for epoch in range(epochs): model.train() total_loss, total_num 0.0, 0 for X, y in train_loader: X, y X.to(device), y.to(device) optimizer.zero_grad() logits model(X) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * len(y) total_num len(y) scheduler.step() val_acc evaluate(model, val_loader) avg_loss total_loss / total_num if val_acc best_acc: best_acc val_acc torch.save({ epoch: epoch, model: model.state_dict(), val_acc: val_acc }, weights/best_model.pth) print(fEpoch {epoch1}/{epochs} | floss {avg_loss:.4f} | val_acc {val_acc:.4f}) return best_acc def evaluate(model, loader): device next(model.parameters()).device model.eval() correct, total 0, 0 with torch.no_grad(): for X, y in loader: X, y X.to(device), y.to(device) pred model(X).argmax(dim1) correct (pred y).sum().item() total len(y) return correct / total这里选用 AdamW 而不是 Adam因为它在权重衰减上处理更准确对 Transformer 这类大参数量模型更稳。学习率起手 3e-4如果训练数据量小可以降到 1e-4。CosineAnnealingLR 让学习率前中期保持较高后期平滑下降比固定学习率跑满 80 个 epoch 更容易收敛到好的局部最优。weight_decay 设 1e-4 是一个比较中庸的值如果过拟合明显可以提到 5e-4。5. 避坑指南我实际踩过的五个高频问题5.1 loss 不降模型输出一直在均匀分布附近现象训练 30 个 epochloss 始终在 1.38 左右震荡对应四分类的均匀分布熵val_acc 一直贴着 25%。原因最常见的是数据预处理缺失。脑电原始幅值单位是微伏级别不同通道、不同被试的方差差异很大如果没做标准化就进网络虽然 CNN 前端有 BatchNorm但遇到明显基线漂移时 batch 内统计量波动过大模型很难稳定。解决对每个样本按通道做 z-score 标准化也就是逐通道减去均值除以标准差。我建议把这个步骤写进数据加载的 transform 里而不是预处理阶段一次性算这样每个 batch 的统计量更贴近当前数据的真实分布。5.2 训练集 acc 很高验证集 acc 只有一半现象训练集 acc 超过 95%验证集 acc 只有 50% 左右差距大得离谱。原因一半是模型过拟合另一半很可能来自数据划分方式错误。如果同一个被试的相邻滑动窗口同时出现在训练集和验证集两个集合之间的样本相似度极高验证集形同虚设相当于拿训练样本的变形去考模型。解决按被试或者按 trial 划分数据绝对不要按窗口随机划分。具体做法是把每个 trial 的连续数据当成一个整体前 70% 的 trial 进训练集后 30% 的 trial 进验证集做完这个划分之后再切窗口。这个改动往往比调模型结构有效得多。5.3 某个类别准确率一直为 0现象四分类任务前三类 acc 正常第四类 acc 恒为 0模型把所有样本都预测成前三类里的某一个。原因类别不平衡。如果第四类在数据集中只占很小比例CrossEntropyLoss 会被多数类主导模型学不到少数类的判别边界。解决给 CrossEntropyLoss 传 class weights 参数权重按类别样本数的倒数归一化或者改用 focal loss。样本量严重倾斜时还有一条路在 DataLoader 里对少数类做重复采样让每个 epoch 里各类别样本数大致均衡。5.4 显存 OOM现象batch-size 设为 16 能跑调到 24 直接爆显存报 CUDA out of memory。原因Transformer 的显存开销主要来自自注意力分数矩阵形状是 (B, num_heads, N, N)N 是 token 序列长度。如果窗长 1000、池化后 token 仍是 250embed_dim 设 128、depth 设 6注意力矩阵规模就相当可观。解决优先考虑缩短序列而不是减小 batch。把池化倍数从 4 提到 8token 数量减半显存占用立刻降下来或者把窗长从 1000 减到 750。实在不行再降 batch-size 到 8然后梯度累加两步模拟 batch-size 16。5.5 脑电标签对齐数据标签错位现象训练从一开始就乱loss 不降val_acc 一直等于随机水平怎么调参都没用。原因事件相关电位和运动想象的脑电变化通常发生在提示出现后几百毫秒。如果数据标注从提示时刻开始算窗口而滑窗从更早时刻开始切窗口前半段包含提示前无关数据模型学到的特征和标签对不上。解决先弄清数据集的标签定义时间点在切窗口之前检查事件时间和标签是否对齐。不少脑电数据集有事件延迟需要在代码里加一个 shift_ms 参数把标签或窗口起点整体平移 100200ms。这个参数调好了效果比换任何网络结构都明显。6. 进阶调参从 85% 往 90% 走的四个技巧我在默认参数下跑到 85% 之后发现再往上提升瓶颈往往不在模型结构而在数据和训练策略上。这里整理四个我实际验证过有效的技巧。第一个技巧是砍频段。脑电数据里有大量 50Hz 工频干扰和肌电噪声做运动想象相关特征大多集中在 830Hz也就是 mu 节律和 beta 节律所在频段。如果你拿到的是原始文件用 mne 做一个 830Hz 带通滤波效果通常比直接丢原始信号给模型好一个档次。这个操作我一般放在 preprocess.py 里和标准化一起处理保证训练和推理用同一套参数。第二个技巧是数据增强。脑电数据量普遍不够有效增强能显著提升泛化能力。我常用的手段有三个对波形加高斯噪声标准差取该通道信号的 1%2%随机遮蔽某几个时间窗口把 100ms 内的采样点置零随机丢弃某个通道让模型对局部缺损更鲁棒。这三个操作我都写在 transform 里训练时启用验证时关闭。第三个技巧是加早停。我在训练循环里加了 early stoppingval_acc 连续 10 个 epoch 不上升就停止训练回滚到 best_model.pth。比硬跑满 80 个 epoch 更稳也省时间。同时把 weight_decay 从 1e-4 提高到 5e-4在样本量小的场景下过拟合能被明显压住。第四个技巧是位置编码消融。如果你正在用可学习位置编码可以再对比一次正弦编码看哪个在你的具体任务上更好。不同数据集结论不同有的任务正弦编码反而更稳。这条需要自己做实验确认不算玄学但确实没有普适的答案。我在运动想象和睡眠分期两个数据集上分别跑过对比实验发现 CNNTransformer 的组合在需要全局时序关系的任务上优势很明显而在短窗任务里反而可能被纯 CNN 反超。从那以后我养成了一个习惯拿到新数据集先做一组消融把窗长、池化倍数、embed_dim 三组参数各跑一遍再下结论。这个习惯在毕设答辩时也帮了大忙面试官问起为什么这么设计直接拿实验数据说话比空讲理论有说服力得多。希望帮到你。本文还有配套的精品资源点击获取