
简介EEGNET网络实现压缩包提供了一套基于EEGNET架构的脑电信号分类Python代码面向脑电图分析、生物信号处理方向的研究人员与深度学习入门者。EEGNET融合卷积神经网络与时序卷积思想能有效提取脑电信号中的空间与时频特征适用于脑电分类、情绪识别等场景。资源包为单个py脚本压缩包仅2KB代码围绕数据预处理、模型搭建、训练评估、超参数调优等关键环节展开结构紧凑便于迁移复用。目前已有1876人学习下载。通过研读并运行该脚本读者可以掌握EEGNET网络结构设计、脑电特征提取思路及深度学习模型在生物信号分析中的完整应用流程为后续科研或项目实践提供可直接参考的实现模板。1. 脑电分类的深度学习入门为什么EEGNet是第一个值得跑通的网络做脑电分类的人桌上大多摆着两条路一条是手工设计特征再用传统分类器另一条是干脆把原始信号扔给神经网络。EEGNet就是第二条路上最有代表性的一个紧凑卷积网络专门为脑电这种“样本少、信噪比低、个体差异大”的信号设计。它在运动想象、睡眠分期、情绪识别这些任务上都能当基线用参数量只有几千单卡CPU都能训练比动不动几十层的卷积网络友好得多。网上流传的“EEGNET网络实现_脑电分类_神经网络_深度学习_信号处理_believedtep_”这类复现工程核心就是模型结构加一套MNE预处理脚本理解这两层自己就能照着搭一套。这篇笔记就沿着“信号怎么处理、网络怎么搭、参数怎么调、坑在哪”的顺序讲清楚适合正要跑通第一个深度学习基线的从业者。2. 拆开EEGNet的网络结构三个卷积模块如何完成脑电信号的逐级抽象2.1 脑电分类的老路线与新路线为什么端到端能少踩特征工程的坑传统脑电分类的常见做法是手动提取特征对多通道脑电做带通滤波分频带计算功率谱密度、Hjorth参数或者自回归系数再用SVM、LDA这类分类器去分。这套路线在运动想象范式上确实能跑出不错的精度但每个环节都要单独调频带选宽了特征混在一起选窄了又丢信息分类器对特征尺度敏感还得再做归一化。更麻烦的是被试换一个最优频带往往就变了特征工程要重做一轮这些经验规则积累起来非常耗时间。深度学习路线的做法是把特征提取和分类一起交给网络。EEGNet属于前馈卷积神经网络输入是“通道×时间”的二维矩阵相当于把脑电信号当成一张特制的图像。网络第一层做时间卷积相当于在学频率信息第二层做空间卷积相当于在学各个电极之间的关系第三层再做一次可分离卷积进一步压缩时域特征。整个过程不需要手工指定频带也不需要单独做CSP、共空间模式这种投影变换预处理退化成滤波、去伪迹、归一化三件事这正好是脑电信号处理的基本盘。很多团队把EEGNet当Baseline就是看中它把传统信号处理的经验都收进了卷积核里。2.2 逐层拆解EEGNet时间卷积、深度卷积、可分离卷积的输入输出EEGNet原版把输入看成形状为(样本数, 通道数, 采样点数)的矩阵深度框架里再加一个通道维度。下表是运动想象四分类场景下输入是64通道、采样率128Hz、窗口取1秒时的层间形状变化。实际项目中采样点数和窗口长度会变但「先时间后空间再可分离」的顺序不变。模块核心操作输出形状设计意图输入原始带通滤波信号(1, 64, 128)通道数在前、时间在后Block1时间卷积 Conv2D核 (1, 64)(F1, 64, 128)学习每个通道上的频域模式Block2深度卷积 DepthwiseConv2D核 (64, 1)(F1×D, 1, 128)学习全部通道之间的空间模式Block3可分离卷积 SeparableConv2D(F2, 1, 32)先逐时间点、再跨通道压缩分类头展平后接全连接(num_classes)Softmax输出分类概率时间卷积的核大小kern_l对应采样率。以128Hz采样率为例核取64相当于覆盖0.5秒的时域窗脑电的节律信息基本都能被扫到如果采样率是256Hz这个核要翻倍到128才等效。深度卷积是整个网络的关键它把F1个时间特征映射成F1×D个空间特征每个特征只在特定通道组合上做卷积参数量从F1×D×chans降到F1×chans这是EEGNet能从小样本里学出来的根本原因。Block3的可分离卷积进一步把标准卷积拆成逐通道卷积和逐点卷积参数量又降一个量级。2.3 从原版Keras实现到PyTorch的差异偏置、分组卷积与自适应池化原版EEGNet用Keras写的迁移到PyTorch时有几个细节容易改错。第一是时间卷积和深度卷积都不要偏置原版设置use_biasFalse很多复现代码图省事直接加nn.Conv2d(..., biasTrue)效果会差一小截。第二是深度卷积要用分组卷积实现groups输入通道数这样每个输入通道只做一次卷积和原版DepthwiseConv2D语义一致。第三是BatchNorm的位置原版在卷积之后、激活之前PyTorch里写成Conv - BatchNorm - ELU顺序不能颠倒。第四是最后全连接层的输入维度原版用Flatten后接Dense依赖输入时窗固定我习惯在展平前加一个自适应平均池化这样窗口长度变化时不用改网络换数据和换被试都更省事。3. 用PyTorch从零复现EEGNet模型代码、训练循环与可复现参数3.1 EEGNet模型类带最大范数约束的完整实现下面这个模型类参考了原论文的设定适合直接抄到自己的项目里。注意Conv2dWithConstraint是给深度卷积加上最大范数约束让卷积核的模长不超过1这是EEGNet在小样本上保持稳定的关键技巧。import torch import torch.nn as nn class Conv2dWithConstraint(nn.Conv2d): def __init__(self, *args, max_norm1, **kwargs): super().__init__(*args, **kwargs) self.max_norm max_norm def forward(self, x): # 每次前向传播前把权重投影到 max_norm 范数球内 self.weight.data torch.renorm(self.weight.data, 2, 0, self.max_norm) return super().forward(x) class EEGNet(nn.Module): def __init__(self, chans64, samples128, kern_l64, F18, D2, F216, dropout0.5, num_classes4): super().__init__() self.block1 nn.Sequential( nn.Conv2d(1, F1, kernel_size(1, kern_l), padding(0, kern_l // 2), biasFalse), nn.BatchNorm2d(F1), ) self.block2 nn.Sequential( Conv2dWithConstraint( F1, F1 * D, kernel_size(chans, 1), groupsF1, biasFalse ), nn.BatchNorm2d(F1 * D), nn.ELU(), nn.AvgPool2d((1, 4)), nn.Dropout(pdropout), ) self.block3 nn.Sequential( nn.Conv2d(F1 * D, F1 * D, kernel_size(1, kern_l // 2), padding(0, kern_l // 4), groupsF1 * D, biasFalse), nn.Conv2d(F1 * D, F2, kernel_size(1, 1), biasFalse), nn.BatchNorm2d(F2), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Dropout(pdropout), ) self.pool nn.AdaptiveAvgPool2d((1, 1)) self.classifier nn.Linear(F2, num_classes) def forward(self, x): x self.block1(x) x self.block2(x) x self.block3(x) x self.pool(x) x x.view(x.size(0), -1) return self.classifier(x)代码里的几个参数说明F18是第一个时间卷积的输出通道数D2是深度卷积的乘数所以Block2输出16个通道F216是最终特征通道数。这套参数在BCI Competition IV 2a的四分类运动想象任务上比较稳数据量大时可以调到F116, D4但参数量和过拟合风险同时涨。kern_l64要按采样率调128Hz和256Hz场景建议分别取64和128。dropout0.5在样本少时不要轻易降低。AdaptiveAvgPool2d把特征压成(F2,1,1)分类头只接受F2维输入这样换窗口长度都不用改网络代价是丢掉一部分时域细节但对脑电这类低信噪比信号来说强行保留完整时间维度反而容易过拟合。3.2 数据加载从MNE的Epochs到DataLoader的最小管线MNE是脑电信号处理最常用的库数据读进来之后一般先构造成Epochs对象再通过下面这段代码转成PyTorch张量。关键步骤有三个取数据、重新映射标签、逐样本归一化。import numpy as np import torch from torch.utils.data import TensorDataset, DataLoader def epochs_to_dataloader(epochs, batch_size64, shuffleTrue): # epochs: MNE Epochs 对象 X epochs.get_data() # (样本数, 通道数, 时间点数) y epochs.events[:, -1] # 事件标签通常是1/2/3/4这种 labels np.unique(y) label_map {lbl: i for i, lbl in enumerate(labels)} y np.array([label_map[lbl] for lbl in y]) # 逐样本标准化减去每个样本的时间均值除以同一时间窗标准差 X (X - X.mean(axis(1, 2), keepdimsTrue)) / (X.std(axis(1, 2), keepdimsTrue) 1e-8) # 加一个单通道维度形状变为 (样本数, 1, 通道数, 时间点数) X X[:, np.newaxis, :, :].astype(np.float32) y y.astype(np.int64) dataset TensorDataset(torch.from_numpy(X), torch.from_numpy(y)) return DataLoader(dataset, batch_sizebatch_size, shuffleshuffle)这里有一个容易踩的细节标准化用的是逐样本而不是全局标准化。不同被试的基线电位幅值差异很大全局标准化会把被试间的幅值差异当成可学习特征跨被试验证时容易虚高。逐样本标准化剥掉绝对幅值保留的是通道间的相对变化模式正好是EEGNet空间卷积想学的东西。epochs.get_data()默认返回副本不会污染原始数据如果后续还要改参数重新分段建议保留原始Epochs对象不要反复在同一个ndarray上做变换。3.3 训练主循环学习率、batch size和早停的设定依据脑电数据集通常只有几千个样本训练循环写起来反而简单真正重要的是早停和随机种子。import torch.nn as nn import torch.optim as optim def train_model(model, train_loader, val_loader, epochs100, lr0.001): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr, weight_decay0.001) best_val_acc 0.0 best_state None patience 10 no_improve 0 for epoch in range(epochs): model.train() total_loss 0.0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() optimizer.step() total_loss loss.item() val_acc evaluate(model, val_loader, device) if val_acc best_val_acc: best_val_acc val_acc best_state {k: v.cpu().clone() for k, v in model.state_dict().items()} no_improve 0 else: no_improve 1 if no_improve patience: print(fearly stop at epoch {epoch}, best val acc {best_val_acc:.4f}) break if best_state is not None: model.load_state_dict(best_state) return model def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for xb, yb in loader: xb, yb xb.to(device), yb.to(device) pred model(xb).argmax(dim1) correct (pred yb).sum().item() total yb.size(0) return correct / total学习率lr0.001是脑电分类的常用起点用Adam时0.002偶尔也能收敛再大就容易在损失面上震荡。weight_decay0.001对应L2正则化脑电样本少这个值有效抑制过拟合但调太大模型会欠拟合验证集和训练集一起往下掉。patience10的意思是验证集连续10个epoch不涨就停早停是脑电深度学习里性价比最高的防过拟合手段比换网络结构更直接。固定随机种子这件事经常被忽略但同一份数据不固定seed两次训练能差出3到5个百分点的准确率这在脑电这种小数据集上很常见。4. 脑电信号预处理与数据划分样本质量决定模型上限4.1 预处理管线带通滤波、重采样、坏通道与基线校正EEGNet虽然宣称端到端但不代表裸信号直接进网络就能用。运动想象任务里原始脑电信号里混着工频干扰、眼电和肌电伪迹。常见的预处理管线是以下四步。第一步是带通滤波。运动想象一般保留4到38Hz这个区间覆盖了mu节律和beta节律又能滤掉大部分肌电高频干扰如果是睡眠分期或情绪识别频带要按任务重新选。第二步是重采样。不同采集设备的原始采样率不一样有250Hz的也有1000Hz的统一到128Hz或256Hz可以在不损失脑电信息的前提下显著减少计算量。第三步是坏通道处理。MNE里可以计算每个通道的方差和相邻通道的相关性方差过大或相关性过低的通道直接插值补上不要让坏通道的噪声进入深度卷积。第四步是基线校正用刺激前200毫秒的均值把每个epoch拉平消除慢漂移的影响。滤波操作在MNE里可以写成raw.filter(4, 38, methodfir)但要注意滤波是线性时不变操作会在信号边缘产生边界效应。分段时最好让epoch比目标窗口多留出滤波余量比如目标窗口是tmin0, tmax4分段时取tmin-0.5, tmax4.5滤波后再裁掉边缘。这一步能明显减少EEGNet在窗口边界处学到的伪特征。4.2 数据划分比模型更重要为什么按被试切而不是按样本随机切脑电分类数据划分的常见翻车点是把同一个被试的所有epoch随机分到训练集和验证集。运动想象任务里同一被试相邻两个epoch只差几百毫秒信号高度相似随机划分会让模型看到“考试原题”验证集准确率能到95%以上换一个被试马上掉到60%左右。正确做法是保证同一个被试的样本全部在训练集或全部在验证集。from sklearn.model_selection import GroupKFold subjects np.array([epochs[i].info[subject_id] for i in range(len(epochs))]) # 这里假设y已经按epoch对齐subjects记录每个epoch归属的被试编号 group_kfold GroupKFold(n_splits5) for train_idx, val_idx in group_kfold.split(X_epochs, y_epochs, groupssubjects): train_loader make_loader(X_epochs[train_idx], y_epochs[train_idx]) val_loader make_loader(X_epochs[val_idx], y_epochs[val_idx]) # 每折重新初始化模型并训练如果项目需要评估跨被试泛化更严格的做法是“被试留一”也就是同一个被试的数据从头到尾不进训练集最后单独评估。这种评估方式直接反映真实使用场景因为模型落地时面对的就是没见过的新被试。代价是被试间差异大的时候分数比较难看但那是真实水平不用因为分数低就换随机划分方式。4.3 类别不平衡与小样本处理加权采样和轻量数据增强脑电分类经常遇到类别不平衡问题比如情绪识别里正负样本比例可能到4比1。处理类别不平衡的第一选择不是改网络而是让每个batch里各类别比例均衡。PyTorch里可以用WeightedRandomSampler实现。第二个选择是在损失函数里加类别权重nn.CrossEntropyLoss(weightclass_weights)。这两个手段可以同时用但权重不要调太大否则模型对少数类过拟合总体准确率反而下降。小样本场景还可以做轻量数据增强常见做法是给原始信号叠加高斯噪声或者把epoch在时间轴上随机平移几个采样点。脑电信号对空间模式敏感时间平移不会改变通道间关系是比较安全的增强方式。这里要注意增强的幅度噪声标准差加在信号标准差的5%到10%左右平移不超过50毫秒超过这个范围会让模型学到错误的时序关系。5. EEGNet训练常见问题排查从损失不降到验证集虚高5.1 损失不降或者训练集直接NaN不是网络结构问题是数据尺度问题现象训练刚开始损失就在好几个epoch内居高不下甚至直接变成NaN。原因最常见的是输入数据里有NaN或极端值标准化没做干净其次是学习率太大脑电数据样本少、loss曲面陡峭0.01以上的学习率很容易把参数推到数值爆炸区间。解决先检查epochs.get_data()里是否有NaN用np.isnan(X).sum()排查坏道和坏样本然后把学习率降到0.0005重新跑一遍最后确认标准化是在模型输入之前做的而不是在MNE里只看统计量。这类问题十次有八次是数据清理问题不要先去调网络结构。5.2 验证集准得离谱换个被试就崩数据泄漏比过拟合更隐蔽现象验证集准确率93%到95%训练集也差不多看起来很健康等真正拿到新被试数据上一测准确率直接落到65%以下。原因几乎可以肯定是数据划分方式错了。按epoch随机划分时同一被试的相邻时段被同时分进训练集和验证集模型记住了这个被试的个体特征而不是跨被试的通用特征。另一个隐蔽泄漏点是预处理阶段用了全局标准化验证集样本的均值和标准差被训练集的全局统计量污染等于模型提前看到了全量数据的分布。解决立刻改成按被试分组的数据划分方式使用上一章介绍的GroupKFold或按被试留一。同时把标准化逻辑改成先按训练集计算均值和标准差再把同样的参数应用到验证集或者在每个样本内部独立标准化并保存即可。5.3 同一份数据每次跑精度波动大随机种子和初始化问题现象代码完全没变只换了一次运行顺序验证集准确率差出4到6个百分点。原因脑电样本量小随机初始化卷积核带来的方差本身就大另一个被忽视的来源是DataLoader的shuffle顺序洗牌顺序变了会造成训练样本到达顺序变化影响Adam的动量轨迹。解决在程序入口固定随机种子PyTorch、NumPy和Python的random都要固定。训练代码里加上下面这段def set_seed(seed42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True还有一个值得做的实验同一组训练超参数下重复5次独立训练报告准确率的均值和标准差而不是只报最好一次的结果。脑电文章里那种“单次运行最高分”往往站不住脚审稿人和老板都会要求看方差。5.4 训练时验证集抖动大早停完全不生效现象验证集准确率忽高忽低早停逻辑跟着乱动模型最后保存的checkpoint不是最优的。原因验证集样本太少或者验证集划分不均衡比如某一折里某个类别只出现了几个样本。脑电分类的epoch数不多单次验证集只有一两百个样本时准确率的波动本身就很大。解决把验证集改成分层采样保证每个类别在每个验证折里占比一致。MNEEpochs对象的事件标签往往不是从0开始的转换成类别索引时直接用np.unique映射即可避免类别错位。验证集样本量至少保持在200个以上否则早停的patience可以加大到20但更好的办法是把验证集按被试组合成一个更大的留出集宁牺牲一点训练数据也要换稳定的评估信号。6. EEGNet的进阶验证交叉验证怎么用、迁移学习怎么搬、什么时候该停手模型能稳定训练之后下一步是做严谨的评估而不是继续调参。BCI这类公开脑电数据一般按被试记录了实验顺序我通常用被试留一交叉验证配合混淆矩阵看结果。混淆矩阵能揭示哪些类别容易混运动想象里左右手分类常和脚想象混淆这往往是预处理时频带没选对回到第4章调滤波参数比加深网络有效。迁移学习在脑电里不是随便拿来预训练模型就能用的。我试过的可行路径是先用大批量数据比如公共睡眠分期数据训练一个EEGNet把权重保存下来再用BCI数据微调。微调时重点操作是冻结前两层只训练后两层因为前两层学的是时域和空间通用表征后三层偏任务特定。如果公共数据的通道数和目标数据不一致需要在加载权重后把深度卷积核重新初始化。EEGNet的PyTorch实现里分类头是nn.Linear直接用model.load_state_dict(torch.load(checkpoint.pt), strictFalse)可以跳过不匹配的层但必须手动确认哪些层被跳过了不要默默接受。关于“什么时候该停手”我有几条经验。如果做了按被试交叉验证之后准确率仍然只比随机高一点那大概率是数据本身质量或任务定义有问题继续往模型里叠模块是浪费时间。如果交叉验证分数稳定但迁移学习没有效果说明特征已经被预处理破坏掉了回看滤波频带和坏通道处理比在PyTorch里加新的正则化更有效。还有一条是从工具链上养成的习惯每次实验把PPV、召回率和Kappa系数存进CSV文件而不是只记最高准确率。脑电分类的最终评判标准从来不是单点最高分而是多次实验的方差和类别粒度上的均衡度这一点在面向真实场景落地时会被无限放大。我做脑电分类项目早期吃过亏把验证集搞虚高后还花了两周调参最后发现是数据划分泄漏检查顺序应该是数据划分在先网络调参在后。希望这篇笔记能帮你绕过那些已经有人踩腻的坑。本文还有配套的精品资源点击获取