简介:面向本科毕业设计场景,该压缩包提供一套基于CNN+Transformer的运动想象脑电信号分类完整实现。项目以PyTorch为框架,利用CNN提取局部时空特征、Transformer捕捉全局依赖,并引入Grad-CAM对脑电地形图进行可视化,同时包含EEGNet、CSP等对比方法与t-SNE降维分析。包内共31个文件,涵盖23个Python脚本(模型定义、训练、评估、绘图)、2个MATLAB预处理脚本、2个Excel权重/通道记录、1个已训练模型权重与1个NPY数据文件等,整体仅16.75MB,结构清晰便于二次开发。目前已有442人学习下载,适合需要完成相关课题的本科生或入门脑机接口的研究者参考。
1. 运动想象脑电分类难在哪:为什么 CNN+Transformer 组合成了标配而不是选修
运动想象(Motor Imagery)脑电分类的毕业设计,最直接的挫败感来自“公开数据集上跑出来的准确率还不如传统 CSP 特征”这种尴尬。脑电信号信噪比极低,运动想象引起的节律变化主要落在 mu(8–13 Hz)和 beta(13–30 Hz)频带,响应窗口只有几百毫秒到几秒。CNN 擅长在时间和通道两个维度上提取局部时空模式,Transformer 则能用一个注意力矩阵把整个 trial 里相隔很远的时间片段重新关联起来。两者组合,是目前做运动想象脑电信号分类最顺手、也最适合在毕设里做成“基线对比 + 消融实验”的框架。这篇笔记覆盖数据预处理、架构原理、PyTorch 实现、训练参数和踩坑排查,给复现和改写成自己的方案留足细节。
2. 预处理与数据集:从 BCI 公开 GDF 文件到可训练张量
2.1 BCI Competition IV 2a:选它做基准的四个理由与数据规格
做运动想象分类,绕不开 BCI Competition IV Dataset 2a。它是这个领域事实上的标准基准:9 名受试者、5 天内的两个会话、22 个 EEG 通道和 3 个 EOG 通道,四类运动想象任务(左手、右手、脚、舌头),单次试验的想象窗口约 4 秒,采样率 250 Hz。相比自己采集数据,它最大的价值是给你一个可以横向对比的官方基线——传统方法用 CSP + LDA 在这个数据集上的四分类准确率大约 60%–75%,你的深度学习模型如果打不过这个数字,先别怀疑模型,去检查数据流。
选择 2a 还有四个实操层面的理由。其一,数据是公开的,GDF 格式可以用 MNE 直接读,不需要自己写解析器;其二,每个受试者训练会话有 288 个 trial,四类样本均衡,交叉验证好做;其三,官方明确区分了训练会话和评估会话,可以直接按受试者内方案做训练/验证切分,也可以玩跨会话迁移;其四,社区里已经积攒了大量复现结果,你跑出一个数字之后,能很快判断出自己的模型在正常范围还是异常偏高/偏低。需要注意,2a 的脑电通道布局以中央区为主,C3、C4、Cz 一带正好覆盖手部运动想象的皮层区域,这也是预处理时最值得单独做可视化的通道组。
2.2 预处理流水线:带通滤波、去 EOG、切段与通道标准化
拿到手的是原始 GDF 文件,第一步不是急着建模型,而是把数据切成“模型能直接吃”的张量。我习惯的预处理顺序是:读取原始文件 → 剔除 EOG → 带通滤波 → 按事件标记切段 → 基线校正 → 通道标准化。带通滤波选 4–38 Hz,既保留 mu 和 beta 两个运动想象核心节律,又滤掉基线漂移和大部分肌电伪迹,这对于后级卷积和 Transformer 都有明显帮助。
import mne import numpy as np # 读取官方 GDF 文件,A01T 是第 1 名受试者的训练会话 raw = mne.io.read_raw_gdf("A01T.gdf", preload=True, verbose=False) # 只保留 22 个 EEG 通道,EOG 通道在预处理阶段先排除 raw.pick_types(eeg=True, eog=False, verbose=False) # 运动想象有用的频谱集中在 4-38 Hz,保留 mu 和 beta 节律 raw.filter(4, 38, fir_design="firwin", verbose=False) # 先打印事件码,确认类别映射(不同 mne 版本间映射名称可能有差异) events, event_id = mne.events_from_annotations(raw, verbose=False) print(event_id)这里解释一下三个关键操作。pick_types去掉 EOG 通道后,剩下来的 22 路 EEG 信号里仍然可能包含眨眼和眼动分量,但 4–38 Hz 的带通滤波已经能压制大部分低频漂移,如果后续发现眨眼伪迹仍然严重影响单 trial 波形,再叠加 ICA 去伪迹,不要一开始就做 ICA,费时且容易把有效脑电分量一起滤掉。filter用的 FIR 设计能在 4 Hz 和 38 Hz 边缘保持较陡的衰减,避免过度削弱节律边界频段。events_from_annotations这一步务必先打印结果,因为 2a 在自己定义事件码的时候,有些版本会把类别编码成 1/2/3/4,有些版本会额外附加几个 event 类型。
切段这一步直接决定了 Transformer 能看到多长的上下文。我通常取tmin=-0.5、tmax=4.5,即以运动想象指令出现的前 0.5 秒开始,一直截到想象结束之后 0.5 秒,250 Hz 下相当于 1250 个采样点。基线校正用baseline=(-0.5, 0),把指令出现之前的信号均值归零,消除不同试次之间缓慢的电位漂移。
# 保留四类运动想象事件,假设打印结果里类别码是 1/2/3/4 target_codes = [1, 2, 3, 4] epochs = mne.Epochs( raw, events, event_id=event_id, tmin=-0.5, tmax=4.5, baseline=(-0.5, 0), preload=True, verbose=False, ) epochs = epochs[target_codes] # 数据形状:(n_trials, 22通道, 1250个采样点) X = epochs.get_data(copy=True) # 触发 copy,避免误改原始数据 y = epochs.events[:, -1] - 1 # 标签从 1-4 映射到 0-3,方便交叉熵epochs[target_codes]这一步很多人会漏,直接导致后续类别数量与模型输出维度对不上。映射完成后,标签为 0/1/2/3,分别对应左手、右手、脚、舌头。get_data(copy=True)是为了防止后续标准化或者增强操作时无意间改到epochs内部缓存的原始数据,这是我在多次因为 in-place 修改导致基线对比串味之后养成的习惯。
最后做通道标准化。最稳妥的方式是按通道、跨时间计算均值和标准差,因为不同通道的幅度差异很大,C3 通道的 mu 节律可能远比额叶通道强,不标准化会让 Transformer 的注意力一开始就被大数值通道主导。
# 按通道标准化:shape 为 (n_trials, 22, 1250) mean = X.mean(axis=(0, 2), keepdims=True) # 每个通道跨时间求均值 std = X.std(axis=(0, 2), keepdims=True) X = (X - mean) / (std + 1e-6)这里的axis=(0, 2)表示在 trial 维度和时间维度上同时求均值,保留通道维度。加入1e-6是为了防止某个通道在滤波后近乎零方差的极端情况下出现除零错误。注意,这个标准化是按受试者独立做的,如果后续做跨受试者实验,应该每个受试者分别计算统计量,不能把所有人混在一起拟合。
2.3 数据划分策略:受试者内划分与跨受试者差异
2a 数据集单个受试者只有 288 个 trial,直接划分容易让验证集波动很大。我建议使用分层五折交叉验证,每一折里四类样本比例保持一致,报告时输出五折均值和标准差。如果追求更高稳定性,可以按 Sessions 划分,用第一个会话训练、第二个会话验证,这种做法模拟了“同一个人不同天使用”的真实 BCI 场景,但也更考验模型的泛化能力。
from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for train_idx, val_idx in skf.split(X, y): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] break # 示意:先拿一折出来调试模型这里random_state=42必须固定,否则每次跑交叉验证的划分不同,结果没法跟自己的工作对比。另有两点经验:第一,不要用随机划分的 test split 来反复调参,否则所谓“验证准确率”其实已经被你无意中背下来了;第二,跨受试者迁移时,模型性能通常会比受试者内掉 10–20%,这不是 bug,而是脑电个体差异的本质,论文里如实报告即可,不必硬追。
3. 架构原理:CNN 先做时空局部特征,Transformer 再建模长程依赖
3.1 时域卷积核 size 怎么定:从采样率反推感受野
设计 CNN 部分时,第一个要定的参数是时间卷积核大小。采样率 250 Hz 意味着一个采样点代表 4 毫秒,卷积核大小为 32 时感受野是 128 毫秒,大约覆盖 8 Hz 信号的一个完整周期;核大小 64 时感受野为 256 毫秒,能覆盖 4 Hz 信号的一个周期,同时包含两个 mu 节律周期。一般经验是,时间卷积核至少要让模型“看见”目标频带的一个完整周期,所以 4–38 Hz 频带对应核大小在 16–64 之间都有道理。
我自己的选择逻辑很简单:先问自己希望第一层卷积捕捉多长的节律片段。如果目标是提取 mu 节律的包络变化,256 毫秒的感受野(核大小 64)更稳;如果希望保留更精细的瞬态特征,128 毫秒(核大小 32)能给出更高时间分辨率。注意,卷积核越大,参数量越大,在 288 个 trial 的小样本上过拟合风险也越高。我的默认配置是核大小 32,后续用池化层压缩时间维,让 Transformer 在一百多个时间 token 上做交互。
3.2 spatial depthwise 技巧:通道间组合与参数量控制
时间卷积之后接一个kernel_size=1的一维卷积,作用是对 22 个 EEG 通道做线性组合,这也是传统空间滤波(CSP)在深度网络里的自然对应物。EEGNet 的做法把这一步做成 depthwise 卷积:先对每个通道分别做时间卷积,再在通道维度上用kernel_size=1做线性混合。这样参数量远小于全连接的空间映射,在样本量小的脑电数据上不容易过拟合。
这里有个容易理解错误的点:通道维度的“空间卷积”不是图像里的二维卷积,而是一维卷积作用于通道方向。输入 shp 是(B, T),经过时间卷积变成(B, F, T),再把通道方向作为一维空间做kernel_size=1卷积,输出(B, F', T)。我常用的配置是时间卷积输出 32 个特征图,空间卷积保持 32 个特征图,并接 BatchNorm 与 ELU 激活。BatchNorm 对脑电尤其重要,因为不同受试者的信号幅度差异大,BatchNorm 能在训练中稳定地做二次归一化。
3.3 Transformer 编码器补上了什么:位置编码、注意力头数与网络深度
CNN 输出的是一个特征序列,序列长度由池化后的时间点数决定。在我的实现里,1250 个采样点经过 4 倍池化变成约 312 个时间 token,每个 token 的特征维度是 32。此刻 Transformer 编码器对这些 token 做自注意力,相当于让模型自由决定“第 50 个时间片”和“第 200 个时间片”之间的关联权重。运动想象的 ERD 现象通常出现在提示后 0.5–2 秒,而试次后期可能有 beta 回升,这些跨时间的依赖正是 Transformer 比纯 CNN 强的地方。
位置编码在这个场景里是必须加的。CNN 是卷积滑窗,天然能感知局部顺序;Transformer 的注意力对 token 位置不敏感,如果不加位置编码,模型无法区分“0.5 秒处出现的 ERD”和“3.5 秒处出现的 beta 回升”。我使用的是经典正弦位置编码,不参与训练,由register_buffer保存,避免把位置信息当可学习参数来增加过拟合风险。
注意力头数、层数、特征维度三者需要联动设置。d_model=32时,注意力头数取 4 意味着每个头只关注 8 维特征,这在小数据集上够用;但如果把d_model提到 128,头数也可以继续加到 8。层数我一般控制在 2–3 层,因为 288 个 trial 不足以训练更深的 Transformer。如果发现验证集准确性随着层数增加反而下降,不要死磕层数,优先回去看 dropot 和位置编码是否生效。
4. 核心代码落地:PyTorch 实现 CNN+Transformer 分类器
4.1 模型定义:CNN 特征提取器 + 正弦位置编码 + Transformer 编码器
下面这段代码是一个完整可运行的模型定义,输入是(batch, 22, 1250)的标准化脑电张量,输出是四类运动想象 logits。核心思路:CNN 把原始时间序列压缩成特征序列,Transformer 在特征序列上建模长程依赖,最后用全局平均池化接一个浅层 MLP 输出分类结果。
import torch import torch.nn as nn class CNNFeatureExtractor(nn.Module): """把多通道 EEG 原始信号压缩成时间特征序列""" def __init__(self, n_channels=22, dropout=0.3): super().__init__() self.temporal = nn.Sequential( # 时域卷积:核大小 32,在 250Hz 下覆盖 128ms 感受野 nn.Conv1d(n_channels, 32, kernel_size=32, stride=1, padding=16), nn.BatchNorm1d(32), nn.ELU(), nn.Dropout(dropout) ) self.spatial = nn.Sequential( # 空间卷积:kernel_size=1,在通道间做线性组合 nn.Conv1d(32, 32, kernel_size=1), nn.BatchNorm1d(32), nn.ELU(), nn.Dropout(dropout) ) self.pool = nn.AvgPool1d(kernel_size=4, stride=4) def forward(self, x): # x: (B, 22, T) x = self.temporal(x) # (B, 32, T) x = self.spatial(x) # (B, 32, T) x = self.pool(x) # (B, 32, T//4) x = x.transpose(1, 2) # (B, T//4, 32) return x class PositionalEncoding(nn.Module): """标准正弦位置编码,Transformer 对顺序不敏感,必须显式加""" def __init__(self, d_model, max_len=512): super().__init__() pe = torch.zeros(max_len, d_model) pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-(torch.log(torch.tensor(10000.0)) / d_model)) ) pe[:, 0::2] = torch.sin(pos * div_term) pe[:, 1::2] = torch.cos(pos * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): # x: (B, L, d_model),把位置编码广播到 batch 维度 return x + self.pe[:, :x.size(1)] class CNNDTransformer(nn.Module): def __init__(self, n_channels=22, n_classes=4, d_model=32, nhead=4, num_layers=2, dropout=0.3): super().__init__() self.cnn = CNNFeatureExtractor(n_channels, dropout) self.pos_enc = PositionalEncoding(d_model, max_len=512) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=256, dropout=dropout, batch_first=True ) self.transformer = nn.TransformerEncoder( encoder_layer, num_layers=num_layers ) self.classifier = nn.Sequential( nn.Linear(d_model, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, n_classes) ) def forward(self, x): x = self.cnn(x) # (B, L, d_model) x = self.pos_enc(x) # (B, L, d_model) x = self.transformer(x) # (B, L, d_model) x = x.mean(dim=1) # 全局平均池化,把 L 维压缩掉 x = self.classifier(x) return x这段代码里有几个参数需要重点说明。第一,padding=16用来保持卷积后时间长度不变,计算公式是(1250 - 32 + 2*16) / 1 + 1 = 1250,这能让后续池化后的 token 数量完全确定。第二,AvgPool1d(kernel_size=4, stride=4)把 1250 个点压成 312 个 token,长度为 312 的序列在两层 Transformer 下计算量约为 31 万次注意力交互,对显存非常友好。第三,dim_feedforward=256是 Transformer 前馈层的隐藏维度,通常取d_model的 4–8 倍,这里 32 到 256 是 8 倍,在小模型里加强非线性表达能力。
4.2 训练与验证循环:损失、批处理、早停与学习率调度
模型定义好后,训练循环的细节决定能不能收敛。损失函数用CrossEntropyLoss,优化器用 Adam,初始学习率1e-3,并配权重衰减1e-4,这在 288 个 trial 的小数据集上是比较稳的组合。学习率调度我用余弦退火,总 epoch 数 50,让学习率从1e-3平滑降到接近 0,降低后期在验证集上的震荡。
import torch from torch.utils.data import DataLoader, TensorDataset device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CNNDTransformer(n_channels=22, n_classes=4).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 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() optimizer.step() total_loss += loss.item() * len(xb) correct += (out.argmax(dim=1) == yb).sum().item() total += len(yb) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) out = model(xb) loss = criterion(out, yb) total_loss += loss.item() * len(xb) correct += (out.argmax(dim=1) == yb).sum().item() total += len(yb) return total_loss / total, correct / total把数据封装成 DataLoader 时,batch_size我取 16 或 32。batch 太小会导致 BatchNorm 统计量抖动厉害,太大又会在小数据集上提前过拟合。16 是一个均衡值,尤其是在 288 个 trial 一个 epoch 只有 18 个 batch 的情况下,BatchNorm 的均值方差估计不会太噪声。推理时务必用model.eval()切换到评估模式,否则 Dropout 和 BatchNorm 的行为会干扰测试结果,这是新手最常犯的错误。
早停的判断依据是验证集准确率连续 10 个 epoch 不上升就停止,并保存最佳模型参数。我的习惯是训练过程中记录每个 epoch 的 val_acc,如果val_acc在上升后连续 10 个 epoch 不再刷新最高值,直接中断训练。
torch.save(model.state_dict(), "best_model.pt")4.3 评估代码:混淆矩阵、Kappa 系数与逐受试者结果
四分类任务只看准确率不够。运动想象分类里,手部类别(左手 vs 右手)之间最容易混淆,而脚和舌头通常比较容易分开。混淆矩阵能直观告诉你模型到底在哪两类之间犹豫,Kappa 系数则能校正随机一致性带来的虚高。
from sklearn.metrics import confusion_matrix, cohen_kappa_score preds, gts = [], [] model.eval() with torch.no_grad(): for xb, yb in val_loader: out = model(xb.to(device)) preds.extend(out.argmax(dim=1).cpu().numpy()) gts.extend(yb.numpy()) cm = confusion_matrix(gts, preds) kappa = cohen_kappa_score(gts, preds, weights="linear") print(cm) print(f"Kappa: {kappa:.3f}")混淆矩阵逐类看:如果cm[0][1]和cm[1][0]明显偏高,说明左手/右手分类不稳,可以考虑在数据预处理阶段针对 C3/C4 通道做更针对性的特征增强。Kappa 系数在论文里通常和准确率一起报,因为准确率在类别均衡的数据上可能虚高。整个训练评估流程跑完后,把所有受试者的结果汇总,按受试者算平均准确率和 Kappa 标准差,这才是一份能写进毕业论文的结果表。
5. 避坑与常见问题:五个让模型翻车的细节
5.1 训练 loss 在降,验证准确率却卡在 25%(四分类随机水平)
现象:训练集 acc 能到 90% 以上,但验证集一直在 25% 附近震荡,好像模型“没学会”。
原因:最常见的是标签错位。BCI IV 2a 的事件码在不同 MNE 版本里映射名称不一样,如果你直接拿网上某篇博客的event_id字典去切段,很可能把类别标签全偏移了一位。另一个可能是 epoch 窗口没切对,比如tmax取到了指令出现之前的休息段,导致模型学到的全是基线噪声。
解决:在预处理阶段先打印events_from_annotations的结果,逐个确认事件码含义。另外,切段后把四类样本的平均波形图分别画出来,目测 C3 通道在右手想象时是否出现事件相关去同步(ERD),C4 通道在左手想象时是否出现类似现象。如果波形图上两类差异不明确,大概率是事件时间轴对齐出了问题,而不是模型的问题。
5.2 验证准确率在训练中期掉下来,之后恢复不了
现象:前 20 个 epoch 训练平稳,第 30 个 epoch 之后 val_acc 突然从 80% 掉到 60%,之后再也回不去。
原因:学习率过高且没有调度。Adam 在小数据集上有时会在 loss 曲面震荡,余弦退火能在后期把学习率压低,减少验证集上的波动;但如果你从头到尾用固定1e-3,后期梯度更新步长仍然偏大,一个不利 batch 就能把模型推离谷底。
解决:给训练加余弦退火调度,甚至再加 5 个 epoch 的线性 warmup,让模型先在小学习率下稳定探索,再进入正常学习率区间。若问题依旧,把weight_decay从 0 改成1e-4,强制约束参数范数,能缓解小样本下的参数震荡。
5.3 同一份代码换台机器重跑,结果差了两个点
现象:在自己的电脑上跑 80% 准确率,放到实验室的 GPU 服务器上重跑同样代码,变成了 77%,怀疑数据或者模型被改过。
原因:PyTorch 默认的 cuDNN 卷积算法是“贪婪选择”,不同 GPU 型号甚至同一型号不同驱动版本下,卷积实现可能不同,导致浮点计算顺序不是完全确定性的。如果没有固定随机种子,数据加载顺序和 Dropout 掩码的变化会让结果波动更大。
解决:在训练脚本最开始加统一种子设置,并把cudnn.deterministic和cudnn.benchmark配好,如下方代码所示。
def set_seed(seed=42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)注意cudnn.benchmark关闭后可能会让训练速度略降,但换来的是跨机器的可复现性。如果论文需要报告多次重复实验的均值和标准差,这个设置是底线。
5.4 Batch size 调大后显存暴涨,代码直接 OOM
现象:batch_size=16训练正常,改成 32 或 64 后 GPU 直接 Out of Memory。
原因:Transformer 注意力矩阵占用的显存是B × L × L × nhead的量级。如果预处理时没有做时间降采样,直接把 1250 个采样点全部序列化,序列长度 1250、batch 32,注意力矩阵大小是32 × 1250 × 1250 × 4,接近数亿个浮点数值,不爆显存才怪。
解决:先确定序列长度再设计模型。常见做法是把 1250 点通过 CNN 池化降到 312 个 token,或者直接在原始序列上做分段:每 64 个采样点作为一个 patch,patch 之间 stride 32,序列长度降到约 40。对脑电数据来说,更激进的降采样不会丢太多信息,因为 4–38 Hz 的频带信息在 125 Hz 采样率下仍然完全保留。
5.5 复现论文时,测试准确率比论文低 5–8%,找不到原因
现象:按论文结构复现出的代码,准确率始终低一截,怎么调都差一口气。
原因:很可能是预处理细节不同。论文用的是完整 4 秒窗口,你截了 2 秒;论文做了 4–40 Hz 滤波,你用了 8–30 Hz;更常见的是论文用了重叠滑动窗口切段做数据增强,把 288 个 trial 扩展成了上千个训练样本,而你还在用原始 trial 数在训练。
解决:复现时先把对方论文的预处理参数列成一个表格逐项对齐,不要贸然改动任何一项。如果对方用了重叠切段,先把切段逻辑实现出来试一轮,通常准确率能立刻提升几个点。我见过太多“模型创新”最后发现差异全在数据增强上的案例,所以在对比实验里一定要把这部分差异写清楚,否则你的模型提升很可能只是吃到了更长窗口的红利。
6. 验证与收尾技巧:消融实验、注意力可视化和毕设报告怎么呈现
6.1 消融实验:证明 CNN 和 Transformer 都缺一不可
如果毕设题目是“基于 CNN+Transformer 的运动想象分类”,那么必须做带消融实验的对比表。三个最小变体建议是:去掉 Transformer、把 CNN 换成线性投影、以及完整模型。去掉 Transformer 的做法是把Mean池化后的特征直接接到分类头上;把 CNN 换成线性投影的做法是直接对原始 1250 点做展平降维,代价大但能说明“CNN 结构确实更适配 EEG 局部特征”。在 BCI IV 2a 上,常见结果是完整模型比纯 CNN 高 2–5 个百分点,但不同受试者差异极大,报告时按受试者拆开列,不要只看均值。
6.2 注意力可视化:验证模型确实在关注运动想象窗口
Transformer 虽然经常被当黑匣子,但注意力权重可以可视化,也能帮你判断模型有没有“学到点子上”。给TransformerEncoderLayer注册一个 forward hook,取出self_attn权重矩阵,计算所有头在 312 个 token 上的平均注意力分布,然后把注意力热点叠加到时间轴上。如果看到峰值集中在运动想象指令出现后的 0.5–2 秒之间,说明模型在利用真正的 ERD 特征;如果注意力分布平坦如散沙,则大概率模型是在靠通道间的静态差异分类。
可视化代码不需要很复杂,核心是 hook 里保存权重矩阵:
attn_cache = {} def hook_fn(module, input, output): attn_cache['weights'] = module.self_attn.attn_weight # (B, heads, L, L) encoder_layer.register_forward_hook(hook_fn)实际命名取决于 PyTorch 版本内部 API,但思路一致。这一步在毕业论文里非常加分,评审老师一眼就能看出你不是把模型当黑箱在用。
6.3 毕设报告里怎么组织基线对比和可复现脚本
报告里建议用一张表格,行是模型(CSP+LDA、EEGNet、DeepConvNet、你的模型),列是受试者 A01–A09 的准确率、平均值、Kappa 系数。表格下方补一句说明:所有模型共用同一套预处理流程和交叉验证划分,确保对比公平。
提交的代码仓库里除了训练脚本外,建议包含一个固定随机种子的说明、一个requirements.txt,以及一份列明关键参数的 README,包括滤波频带、epoch 窗口、采样点数、batch size、初始学习率、epoch 数、注意力头数和 Transformer 层数。这些参数不是摆设,其他人能否复现你的结果全看它。我最惨痛的一次经验是把 4 秒窗口截成 1 秒去跑实验,第一版结果全黄,改回完整窗口后同一套代码直接提升了 4 个百分点,从此之后我养成了一个习惯:任何预处理改动前,先跑一遍官方基线,把数字对齐再做模型创新,否则出了问题根本不知道是哪一层在帮倒忙。希望这份流程能帮你把从头复现到写好毕业论文的时间压缩到两周内。
本文还有配套的精品资源,点击获取