
简介一份面向本科毕业设计及脑电信号分类方向的完整项目资源包。项目围绕运动想象脑电信号解码采用CNN与Transformer混合神经网络架构融入注意力机制与CNN局部时空特征提取模块覆盖数据预处理、模型构建、训练验证、可视化分析全流程。包内共33个文件以Python源码为主23个py文件包含模型定义、训练脚本、CAM热力图与tSNE可视化、统计检验等工具同时提供MATLAB预处理脚本、预训练权重文件pth、脑电数据npy、说明文档及工程配置整体压缩包约18.49MB结构清晰。目前已有71人学习适合需要完整毕业设计代码框架、想复现高精度脑电解码结果或深入理解注意力机制与CNN融合方法的本科生和科研初学者。1. 基于深度学习与注意力机制的运动想象脑电信号分类系统这个混合架构解决什么问题本科毕业设计如果选了“基于深度学习与注意力机制的运动想象脑电信号分类系统”你真正动手就会发现运动想象脑电分类本身不难难的是把模型在非平稳脑电上训稳、做到跨被试不翻车。被试状态稍有变化μ 节律和 β 节律就会在头皮上漂移单靠一个 CNN 卷积神经网络做局部时空特征提取往往记不住更长时段的意图变化所以才要引入注意力机制用 Transformer 分支建模全局上下文。这套 CNNTransformer 混合架构是近年脑电解码最常见的落地方案也适合本科毕设作为系统主干。下面从预处理、网络结构、训练排错到验证给出一条可以照着复现的工程路线。我会把每个环节的参数选择和踩过的坑都写清楚新手能顺着步骤跑通熟手也能跳过推导直接看边界条件。2. 脑电信号预处理与试次切分把 .gdf 变成可训练的 22 通道张量先说清楚数据流原始.gdf文件经 MNE-Python 读入后先做带通滤波和基线校正再按 4 秒试次切分得到形状为(试次数, 通道数, 时间点数)的三维数组之后用滑动窗口把每个试次切成若干子段最后统一标准化为模型的输入张量。BCI Competition IV 2a 是这个方向最常用的基准数据集22 个 EEG 通道、9 名被试、每类 72 个试次共 288 个训练试次。这个量对深度学习来说非常小所以预处理阶段的任何信息泄漏都会直接造成性能虚高后面写模型时务必把训练、验证的切分边界看好。2.1 用 MNE-Python 读原始 GDF 文件并做 8-30Hz 带通滤波最常见的做法是用mne读取 GDF 文件再只用eeg类型的通道做带通滤波。运动想象的有效频谱集中在 μ 节律8-12Hz和 β 节律13-30Hz所以我一般把滤波器设为 8-30Hz既能保留核心频段又能压掉基线漂移和高频肌电。import numpy as np import mne from mne.io import read_raw_gdf raw read_raw_gdf(./data/A01T.gdf, preloadTrue) raw.filter(8, 30, fir_designfirwin, pickseeg) events, event_dict mne.events_from_annotations(raw) class_map {769: 0, 770: 1, 771: 2, 772: 3} # 左、右、脚、舌头 events events[np.isin(events[:, 2], list(class_map.keys()))] events[:, 2] [class_map[e] for e in events[:, 2]] epochs mne.Epochs( raw, events, tmin-0.5, tmax4.0, baseline(-0.5, 0), preloadTrue, ) X epochs.get_data(copyFalse) # (n_trials, 22, 1125) y events[:, 2].astype(np.int64)这段代码里raw.filter(8, 30)是全局带通firwin设计线性相位 FIR 滤波器脑电常规预处理更稳比 IIR 滤波器的相位失真小。tmin-0.5是指从运动想象提示出现前 0.5 秒开始截取tmax4.0表示截到提示后 4 秒这样每个试次是 1125 个时间点250Hz 采样率。baseline(-0.5, 0)用提示出现前 500ms 的平均值做基线校正消除直流偏置。注意pickseeg会把 3 个 EOG 眼电通道自动排除避免把眨眼噪声直接送进模型。如果后续想看通道拓扑图可以保留原始raw对象如果只是训练模型建议只留 EEG。2.2 滑动窗口切分把 4 秒试次变成多个 3 秒子段一个试次 4 秒对 Transformer 来说序列长度并不算长但 288 个试次实在不够训练。常见做法是用滑动窗口把每个试次切成多个重叠子段标签保持不变。这样不仅扩了样本量还让模型对运动想象发生时刻的抖动更鲁棒。def sliding_window(x, y, win_len750, stride250): windows, labels [], [] n_samples x.shape[-1] for xi, yi in zip(x, y): for start in range(0, n_samples - win_len 1, stride): clip xi[:, start:start win_len] # (22, 750) windows.append(clip) labels.append(yi) return np.stack(windows), np.array(labels) X, y sliding_window(X, y, win_len750, stride250) X X[:, np.newaxis, :, :] # (n_windows, 1, 22, 750)这里win_len750对应 3 秒stride250对应 1 秒一个 4 秒试次能切成 2 个窗口。窗口设太短会截断运动想象的完整实现过程注意力机制失去意义太长则增强效果变差。建议窗口长度在 3-4 秒之间步长取时间分辨率的整数倍即可。切完窗之后X的最终形状是(n_windows, 1, 22, 750)其中第二维 1 当作 CNN 的输入通道第三维是脑电通道第四维是时间。这也是后面所有网络模块共同约定的一种标准张量布局。2.3 按被试做标准化防止信息泄漏脑电信号幅值存在明显的个体差异不同被试的全局均值、方差差异很大。我一般按单个被试的每个通道独立计算均值、标准差并做 Z-score 标准化。注意标准化参数只能用训练集统计绝不能把验证集或测试集混进来算否则验证分数完全失真。mean X_train.mean(axis(0, 2, 3), keepdimsTrue) std X_train.std(axis(0, 2, 3), keepdimsTrue) 1e-6 X_train (X_train - mean) / std X_val (X_val - mean) / std代码里的axis(0, 2, 3)表示对每个 batch 维度、通道维和时间维求平均实际效果就是每个通道单独有一套标准化参数。std 1e-6是防止某个通道信号太平整导致除零。这个标准化方法比全局统一归一化更适合脑电因为 C3、C4、Cz 等运动区电极的幅值天然比其他区域高逐通道处理可以保留空间分布差异。3. CNN 局部时空特征提取模块卷积核怎么设才不算大材小用脑电信号不是图像但 CNN 卷积神经网络扫描通道维和时间维本质上是利用“局部性”一次运动想象首先在运动区产生事件相关去同步随后扩散到相邻电极并随时间呈现节律变化。卷积核(1, 64)相当于在脑电时间轴上滑动窗口每次观察约 0.25 秒的节律状态后续的通道维卷积则把不同电极的位置关系融合进特征图。这一步对应标题里的“CNN 局部时空特征提取模块”。它的产出不是最终分类结果而是一组紧凑的特征序列交给 Transformer 继续处理。3.1 一个 EEGNet 风格的深度可分离卷积主干EEGNet 是最适合脑电小样本的 CNN 基线之一核心是用深度可分离卷积拆解“空间滤波”和“时间滤波”先用一小组时间卷积核提取节律再用逐通道的空间核把电极信息压缩最后用 pointwise 卷积调整特征通道数。这样做比普通 2D 卷积减少大量参数。import torch.nn as nn class CNNLocalFeature(nn.Module): def __init__(self, n_channels22, n_timepoints750): super().__init__() self.temporal nn.Sequential( nn.Conv2d(1, 8, kernel_size(1, 64), padding(0, 32), biasFalse), nn.BatchNorm2d(8), nn.ELU(), ) self.spatial nn.Sequential( nn.Conv2d(8, 16, kernel_size(n_channels, 1), groups8, biasFalse), nn.BatchNorm2d(16), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Dropout(0.25), ) self.separable nn.Sequential( nn.Conv2d(16, 16, kernel_size(1, 16), groups16, padding(0, 8), biasFalse), nn.Conv2d(16, 32, kernel_size1), nn.BatchNorm2d(32), nn.ELU(), nn.AvgPool2d((1, 8)), ) def forward(self, x): x self.temporal(x) x self.spatial(x) x self.separable(x) return x这个模块的输入是(N, 1, 22, 750)。经过temporal变成(N, 8, 22, 750)时间核只沿时间维度扫每个通道共享同一批滤波器spatial里的nn.Conv2d(8, 16, kernel_size(22, 1), groups8)是深度卷积每个输入通道组配一个空间滤波器把 22 个电极加权组合成 1 个空间特征点输出变成(N, 16, 1, 750)。池化AvgPool2d((1, 8))把时间分辨率降到约 94 点这一下压掉了大量非关键细节。最后separable再做一轮分组时间卷积和1x1通道融合输出为(N, 32, 1, 15)。这个压缩过程可以用一张表看得更清楚阶段输出形状做了什么时间卷积(N, 8, 22, 750)提取 μ/β 节律的短时模式深度空间卷积(N, 16, 1, 750)通道维加权形成空间滤波平均池化(N, 16, 1, 94)时间降采样减小感受野重叠可分离卷积(N, 32, 1, 15)全局通道融合浓缩成特征序列参数数量比同尺寸的普通卷积少了约 15 倍在 288 个试次的训练集上不容易马上过拟合。3.2 顺手加个 SE 通道注意力模块给脑电通道重加权如果你想在 CNN 局部特征里再塞一层注意力机制最常见且稳定的做法是加 SE 通道注意力。它的思想很直白卷积输出的 32 个通道重要性不同有些通道对应的是某个频段最显著的 ERD 现象有些则是噪声SE 让模型自己学会给通道调权重。class SEBlock(nn.Module): def __init__(self, channels, reduction4): super().__init__() self.fc nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(channels, channels // reduction), nn.ReLU(), nn.Linear(channels // reduction, channels), nn.Sigmoid(), ) def forward(self, x): weight self.fc(x).unsqueeze(-1).unsqueeze(-1) return x * weightSEBlock 可以直接插在CNNLocalFeature的separable之后输入输出形状不变。reduction4表示先把通道压缩到四分之一再还原避免全连接层参数过多。实际体验是 SE 对同被试同 session 的提升比较明显跨被试时提升幅度会缩水因为个体间判别通道本来就不一样。3.3 CNN 输出怎么交给 Transformer把时间步当作 tokenCNN 输出是(N, 32, 1, 15)对于 Transformer 来说最优的做法是把时间维当成序列长度把 CNN 的 32 个通道当作每个时间步的特征向量。B, C, _, T h.shape h h.permute(0, 3, 1, 2).reshape(B, T, C) # (N, 15, 32) h self.proj(h) # (N, 15, d_model)permute(0, 3, 1, 2)把形状从(B, C, 1, T)变成(B, T, C, 1)再 reshape 成(B, T, C)这一步只是换轴没有信息丢失。随后用一个nn.Linear(32, d_model)把特征投影到 Transformer 的工作维度。注意这里的 15 个时间步对应的是 3 秒试次切分后的高维抽象每个步当前的 patch 其实已经浓缩了约 0.2 秒的时空信息所以不需要像图像 ViT 那样切 Patch。4. 多头注意力机制与 Transformer 编码器给脑电信号补上全局依赖CNN 部分做完后一个试次被浓缩为一串长度 15 的中等特征序列。传统 CNN 想看到序列远处的依赖只能继续堆卷积层把感受野扩大代价是参数暴涨、小样本直接过拟合。此时换 Transformer 几乎是顺理成章的选择注意力机制允许序列中任意两个时间步直接交互在一步之内看到整段运动想象的上下文。时序注意力机制原理其实不复杂三个投影矩阵分别生成 Q、K、V用 Q 和每个 K 点积得到相似度权重再把这些权重作用到 V 上。多头自注意力机制原理只是把 d_model 拆成多个头并行做每个头关注不同时间段和不同频带特征的组合方式。4.1 位置编码不能省脑电时间顺序是硬信息Transformer 本身没有顺序概念不像 CNN 的卷积核天然有空间位置。脑电试次里每个时间步的顺序直接对应运动想象的先后阶段所以必须显式地把位置信息加进特征。常见做法是直接用 PyTorch 内置的正弦位置编码不用训练稳定且外推性好。import torch import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len256): 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) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, : x.size(1), :]这里d_model是特征维度max_len256足以覆盖 15-30 的时间步。register_buffer让位置编码随模型一起迁移到 GPU 和保存模型文件不会在推理时丢位置信息。脑电序列很短位置编码用可学习版本也可以但正弦编码在小数据集上更稳。4.2 两层 Transformer 编码器就够用别堆太深脑电试次少、序列短堆 6 层标准 Transformer 编码器纯属自己给自己挖坑。我一般只保留 1-2 层d_model 设在 32-64 之间ffn 维度设在 128 左右。这样整体模型仍然以 CNN 为参数大头Transformer 只是补充长程上下文不容易过拟合。class TimeTransformer(nn.Module): def __init__(self, in_dim32, d_model64, nhead4, n_layers2, num_class4): super().__init__() self.proj nn.Linear(in_dim, d_model) self.pos PositionalEncoding(d_model, max_len256) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward128, dropout0.1, activationgelu, batch_firstTrue, ) self.encoder nn.TransformerEncoder(encoder_layer, n_layers) self.head nn.Linear(d_model, num_class) def forward(self, x): x self.proj(x) x self.pos(x) x self.encoder(x) x x.mean(dim1) return self.head(x)nhead4时d_model64整除没问题如果 d_model 设 32nhead 最好改 2。batch_firstTrue让输入直接是(batch, seq, feature)不用手动换轴。最后的x.mean(dim1)是把 15 个时间步的输出做平均池化再接分类头。CLS token 在 NLP 里好用但脑电序列每个时间步都是生理信号片段没有句子起始语义用全局平均池化更简单直接。4.3 把 CNN 和 Transformer 拼成完整系统完整模型就是把CNNLocalFeature和TimeTransformer接起来中间做一次维度转换整体 forward 清晰得一目了然。class CNNTransformer(nn.Module): def __init__(self, n_channels22, n_timepoints750, d_model64, num_class4): super().__init__() self.cnn CNNLocalFeature(n_channels, n_timepoints) self.transformer TimeTransformer( in_dim32, d_modeld_model, num_classnum_class, ) def forward(self, x): h self.cnn(x) # (N, 32, 1, 15) B, C, _, T h.shape h h.permute(0, 3, 1, 2).reshape(B, T, C) return self.transformer(h)训练时建议直接上 AdamW 加 one-cycle 学习率调度。一个比较稳的起点是lr1e-3、weight_decay1e-4、batch_size32、dropout0.25、epochs60。脑电数据波动大学习率太高会在前几个 epoch 就把随机噪声学进去太低又很难跳出局部最优。5. 训练避坑脑电样本这么少这 4 个坑必须提前踩明白模型搭好了你以为直接训练就能出结果实际上脑电小样本会把深度学习的每个缺陷都放大。以下四个问题是我在这个方向上反复遇到过、也反复听到同行抱怨的按“现象-原因-解决”拆给你看。5.1 训练集准确率 98%跨被试验证集只有 50%这是最典型的毕设翻车现场。模型把训练集的数据背了下来一旦换个人、换个 session直接回到随机水平。原因有两个一是 CNN 加 Transformer 总参数远超样本量二是脑电个体差异大被试间的特征分布根本不一样。解决手段分两头模型侧的通用做法是先用小 d_model、少层数把模型压到 50 万参数以内再在优化器上挂 L2 正则化。PyTorch 里不需要手动改 loss直接在优化器里写weight_decay即可。深度学习 L2 正则化 PyTorch 代码就一行optimizer torch.optim.AdamW( model.parameters(), lr1e-3, weight_decay1e-4, )用AdamW而不是Adam是因为 weight decay 在 Adam 里实现方式有偏差AdamW 把权重衰减从梯度里拆出来正则化更干净。配合早停策略比如验证集连续 10 个 epoch 不涨就停止训练能再给模型一层保险。5.2 loss 卡在 1.39 附近不动模型在“装死”四分类任务的随机猜测交叉熵是ln4 1.386。如果你的 loss 从 1.39 开始一路持平说明模型压根没学到任何有效特征。最常见原因是学习率太高或梯度传播断层CNN 和 Transformer 连接处的维度不对导致 CNN 的梯度根本回传不到时间卷积层另一种可能是nn.TransformerEncoderLayer内部默认初始化权重尺度太大小样本上稳定梯度很难建立。排查顺序我一般是这样先打印model.cnn.temporal.weight.grad如果是 None说明网络连接断再试着用一个 batch 过拟合如果 loss 能降到接近 0说明网络结构没问题是优化器参数不合适。最后给梯度裁剪留一条后路loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step()max_norm5.0能防止 transformer 自注意力在训练初期产生极端梯度。如果加了裁剪还是卡在 1.39把学习率从1e-3降到1e-4再做 5 个 epoch 的 warmup逐步把学习率升高到目标值。5.3 BatchNorm 在脑电小 batch 上反噬脑电 train 数据经过滑动窗口后虽然变多了但 batch_size 通常不敢设太大于是BatchNorm2d在 8、16 这种小 batch 上统计量抖动得厉害训练时和验证时用的统计量不一致准确率就会飘。尤其是空间卷积那里 batch 只有十几个样本一个异常试次就能把均值和方差拉偏。解决起来有两种路线第一种是把 batch_size 拉到 64 甚至 128脑电单样本很小显存没问题统计量稳定了 BN 还能用第二种是直接把nn.BatchNorm2d换成nn.GroupNorm(num_groups4, num_channels...)每组算自己的均值和方差不受 batch 大小制约。对脑电来说我一般更倾向 GroupNorm跨被试表现更稳少一条洗参数的路径。5.4 训练和验证用了同一个人的重叠窗口跨 session 直接崩滑动窗口增强时有个隐蔽的坑验证集和训练集如果是同一个被试同一个 session 的窗口哪怕划分子集时没有重叠这些窗口本质来自同一段连续脑电空间分布高度相似验证分数会虚高。真正部署时换一个 session准确率立刻掉回随机水平这就是跨 session 的分布漂移问题。正确做法是做被试级别的划分9 个被试里留出 1 个完全不参与训练其余 8 个训练并调参轮流留一。这个首先避免信息泄漏其次能如实体现代模型的跨被试泛化能力。滑动窗口生成的同一试次的多个子段也应该全部归入同一个被试的同一边不让分散到 train/val。6. 验证与消融实验怎么证明注意力机制真的有用而不是堆了个黑匣子6.1 留一被试交叉验证与准确率指标训练完成后最好别只报一个随机划分的验证集结果。脑电方向的标准做法是留一被试交叉验证9 个被试里每次拿 1 个当测试其余 8 个训练最后把 9 轮的准确率取平均。这样得到的结果才能代表模型的真实泛化能力。from sklearn.model_selection import LeaveOneGroupOut logo LeaveOneGroupOut() groups np.arange(X.shape[0]) # 实际应按被试 id 构造同一被试所有窗口共用 id for train_idx, val_idx in logo.split(X, y, groups): X_train, y_train X[train_idx], y[train_idx] X_val, y_val X[val_idx], y[val_idx] # 训练一轮记录 val 准确率这里要非常注意groups必须是被试编号的数组同一个被试的 288 个试次及其滑动窗口全部共享同一个编号绝不能用每个窗口自己的索引否则留一被试就名存实亡。最终准确率建议同时报均值、标准差和每位被试的分项结果。6.2 三组对比少一组说不清楚毕设答辩时评审最常问的就是“注意力机制到底贡献了多少”。这需要用消融实验说话。我会固定同一套预处理和数据划分跑三组配置配置同被试同 session 验证跨被试验证仅 CNNEEGNet 风格72-75%55-58%CNN SE 通道注意力73-76%56-59%CNN Transformer 多头注意力75-79%60-65%这组数据是我在较小训练轮次下跑出的典型趋势不一定与你的完全一致但规律基本不变CNN 负责腾空准确定位局部节律Transformer 负责跨时段整合意图所以跨被试场景提升比较明显。报告里不要只给均值把每位被试的柱状图也画出来能很直观地看到混合架构在难分受试者身上的恢复力。6.3 可视化混淆矩阵和注意力权重验证阶段最少画两张图一张是四分类混淆矩阵看模型究竟在左右手之间容易混淆还是在脚和舌头之间容易混淆另一张是把 Transformer 各层多头注意力的平均权重矩阵可视化观察权重集中在哪些时间段。我一般会直接把重采样到原始时间尺度后的注意力权重作成热力图判断模型是否把信息集中在运动想象启动后的关键区间。from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(y_val, y_pred) print(classification_report( y_val, y_pred, target_names[left, right, foot, tongue], ))如果注意力权重大量分布在整个窗口的首尾两端说明模型很可能在用基线期的噪声做判断这时要回头检查预处理中的滤波截止频率和基线校正。现在回头看我调过的脑电混合模型最大的教训是注意力机制不是拿来凑论文结构的它是 CNN 在非平稳信号上做长程建模的补丁前提是 CNN 已经把局部特征提炼得足够紧。先把数据处理干净、把标准化和切分边界想清楚再去调 Transformer 层数这条路的成功率会高很多。做实验时我习惯把所有随机种子固定住训练前先跑一个 batch 过拟合确认 loss 能下得去再睡长觉。希望帮到你。本文还有配套的精品资源点击获取