简介:一套EEGNET脑电信号分类实现代码,面向需要处理EEG数据并开展深度学习分类任务的研究者与开发者,聚焦脑电分类这一信号处理场景,利用EEGNET中卷积层与时序卷积层提取空间和时间特征,适用于神经信号分析、脑机接口等方向,既适合深度学习入门者参考,也可供相关方向研究人员快速验证思路。压缩包内包含1个Python脚本,体积约2KB,代码量精简,便于直接阅读核心网络结构、训练流程与调用方式,适合作为入门示例或在此基础上扩展实验;已有1877人学习/下载,说明该实现受到较多关注。脚本覆盖从数据预处理到模型训练、验证评估的完整示例,包括去噪、滤波、标准化等环节,也体现了特征提取与分类建模的常见思路;通过运行该脚本,可快速掌握EEGNET的构建与训练方法,并在实际脑电数据上完成分类测试与参数调整,为后续模型改进提供可运行的基线。
1. EEGNet是什么:脑电分类里那个"小而能打"的网络
做脑电(EEG)分类的从业者,大多都有过这种经历:拿一摞公开数据集,先试传统特征加SVM,效果还行但泛化差;换个大卷积网络,准确率上去了,可单个样本要跑几百毫秒,而且在小样本脑电数据上稍微调深一点就过拟合。直到把一个轻量网络搬过来,效果居然不输大模型,训练还快一个数量级——这个网络就是EEGNet。它的核心价值在于用深度可分离卷积把时间滤波和空间特征提取拆开,用极少参数把BCI竞赛里运动想象、情绪识别这类任务做到实用水平。这篇文章直接照着能落地的路径写:数据怎么收拾、网络怎么搭、参数怎么设、哪些坑必须绕开,以及最后怎么用可视化让结果可解释。适合刚入门BCI的工程师,也适合已经在跑模型但总在验证集上翻车的老手。
2. 动手前先把脑电数据收拾明白:预处理与数据集划分
2.1 原始EEG信号到训练样本:切片、去伪迹、标准化怎么做
EEGNet的输入不是一段连续脑电,而是经过切片的事件相关样本。以运动想象任务为例,一次试次从提示出现前0.5秒开始,到提示后4秒左右结束,需要截出来。常见做法是用事件标记(trigger)去对齐原始连续数据,切出固定长度的时间窗,再做滤波和重参考。我一般会先用带通滤波把与任务无关的漂移和工频干扰去掉,比如8–30 Hz对运动想象比较有效,而情绪识别可能用到0.5–45 Hz更宽频带。滤波不在意阶数多高,但必须保证训练和测试用同一套滤波器参数,否则后文会讲到这个坑。
切片之后是标准化。EEG信号幅值受个体头皮状况、电极接触阻抗影响很大,直接喂网络会出现某个受试者数值大、某个受试者数值小的情况。常见做法是按每个样本(即每个 trial 的所有通道、所有时间点)做 z-score 标准化,而不是用整个数据集的均值方差。下面这段 Python 模拟了切完样本后做标准化的流程:
import numpy as np # 假设 raw_data 是连续EEG,shape = (n_channels, n_times) # trigger_samples 是每个试次开始的时间点索引 # sfreq 是采样率,例如 250 Hz def make_epochs(raw_data, trigger_samples, sfreq, t_start=-0.5, t_end=4.0): start = int((t_start) * sfreq) end = int((t_end) * sfreq) epochs = [] for trig in trigger_samples: # 注意边界判断,防止索引越界 if trig + start < 0 or trig + end > raw_data.shape[1]: continue epoch = raw_data[:, trig + start:trig + end] epochs.append(epoch) return np.stack(epochs) # (n_epochs, n_channels, n_samples) def per_epoch_zscore(epochs): # epochs shape: (n_epochs, n_channels, n_samples) # 按每个样本独立计算均值和标准差 mean = epochs.mean(axis=(1, 2), keepdims=True) std = epochs.std(axis=(1, 2), keepdims=True) std[std < 1e-6] = 1e-6 # 防止除零 return (epochs - mean) / std逻辑说明:make_epochs把连续信号按 trigger 切成二维数组堆叠,per_epoch_zscore对每个 epoch 独立归一化。关键点是keepdims=True保持了维度,方便广播;给 std 加下限是为了避免某些靠近零的信号段(比如静息态短片段)导致数值爆炸。参数说明:t_start和t_end根据任务设定,如果数据集里已经给了 epoch 可以直接跳过切片;sfreq必须与实际采样率一致,否则时间窗长度会错。
2.2 数据集的划分与类别平衡:别让标签把模型带偏
脑电数据集普遍存在类别不平衡,比如情绪识别里中性样本往往比正/负情绪样本多。EEGNet 的损失函数是交叉熵,天然偏向多数类。解决有三条路:一是重采样,对少数类过采样或者对多数类降采样;二是在损失函数里加类别权重;三是选择更平衡的评价指标,比如加权 F1。前两种做法直接改数据或改损失,第三种只改评估方式,但训练时模型仍然偏。
我自己习惯先把数据集按受试者划分,而不是按试次随机划分。原因是同一受试者的不同试次之间存在很强的相关性,如果把同一个人的一小部分试次放进验证集,模型很可能在"记住"这个人,验证分数虚高。做跨受试者评估时,直接按受试者留一法,也就是每轮拿一个人的全部数据当验证集,其他人训练。对于单个受试者内部的分类,也要按试次顺序切出连续块,避免打乱后相邻试次泄漏。下面给一个简单的按受试者划分的代码片段:
from sklearn.model_selection import LeaveOneGroupOut # subjects 是每个试次对应的受试者编号数组 logo = LeaveOneGroupOut() for train_idx, val_idx in logo.split(epochs, labels, groups=subjects): train_epochs, val_epochs = epochs[train_idx], epochs[val_idx] train_labels, val_labels = labels[train_idx], labels[val_idx] # 在这里进入预处理和训练流程 break # 示意:先只看第一折逻辑说明:LeaveOneGroupOut把同一受试者的所有样本归为一组,保证验证集里不会混入同一人的训练片段。参数说明:groups必须与样本一一对应,否则划分结果没有意义。这种方式会让训练集规模大幅缩小,模型更容易欠拟合,所以后文的EEGNet参数要相应调整,比如 dropout 降低、正则项减小。
3. EEGNet网络结构拆解:从卷积到分类头的PyTorch实现
3.1 深度可分离卷积为什么适合脑电:时间卷积与空间卷积的分工
EEGNet 的基本设计是从同时学习时间和空间滤波器出发。原始脑电是一个(通道, 时间点)的矩阵,首先用一维卷积沿时间方向做时间滤波,提取每个通道上的局部波形特征;然后用空间卷积——实际上是对通道维度做卷积——把多电极之间的空间模式组合起来。这里的关键是深度可分离卷积:先对每个通道单独做时间卷积,再在通道维度上用1×1卷积混合信息。为什么不直接用普通 2D 卷积?因为脑电通道数少(通常 8–64),时间点很多(上百到上千),用普通卷积会把通道和时间耦合在一起,参数规模大且容易过拟合。深度可分离卷积把参数数量压到极低,这正是 EEGNet 在单个受试者小数据集上也能训练的原因。
EEGNet 原结构里有几层:第一层是时间卷积(核大小 64 左右,对应约 0.25 秒的窗长),第二层是深度卷积(空间滤波),然后是平均池化和第二组可分离卷积,最后是分类层。在实现之前先理解这些层的维度变化,否则写代码时很容易在channel和feature顺序上栽跟头。
3.2 用PyTorch实现一个可运行的EEGNet模型(完整代码)
下面是一份可以直接跑的 PyTorch 实现。输入形状是(batch, 1, channels, time_points),这里刻意加了维度来表示 EEG 的二维"图像"。代码里做了详细注释,训练时可以直接喂数据。
import torch import torch.nn as nn import torch.nn.functional as F class EEGNet(nn.Module): def __init__(self, n_channels=16, n_samples=250, num_classes=2, F1=8, D=2, F2=16, kernel_length=64, dropout=0.25): super().__init__() self.n_channels = n_channels self.n_samples = n_samples # Block 1: 时间卷积 + 深度空间卷积 self.conv1 = nn.Conv2d(1, F1, (1, kernel_length), padding=(0, kernel_length // 2), bias=False) self.bn1 = nn.BatchNorm2d(F1, momentum=0.01, eps=1e-3) self.depth_conv = nn.Conv2d(F1, F1 * D, (n_channels, 1), groups=F1, bias=False) self.bn2 = nn.BatchNorm2d(F1 * D, momentum=0.01, eps=1e-3) self.pool1 = nn.AvgPool2d((1, 4)) self.drop1 = nn.Dropout(p=dropout) # Block 2: 可分离卷积 self.sep_conv = nn.Conv2d(F1 * D, F2, (1, 16), padding=(0, 8), groups=F1 * D, bias=False) self.point_conv = nn.Conv2d(F2, F2, 1, bias=False) self.bn3 = nn.BatchNorm2d(F2, momentum=0.01, eps=1e-3) self.pool2 = nn.AvgPool2d((1, 8)) self.drop2 = nn.Dropout(p=dropout) # 全连接分类头 self._flatten_size = self._compute_flatten_size() self.fc = nn.Linear(self._flatten_size, num_classes) def _compute_flatten_size(self): # 用一个临时输入计算全连接输入维度 with torch.no_grad(): dummy = torch.zeros(1, 1, self.n_channels, self.n_samples) x = self.pool1(self.drop1(F.elu(self.bn2(self.depth_conv(F.elu(self.bn1(self.conv1(dummy)))))))) x = self.pool2(self.drop2(F.elu(self.bn3(self.point_conv(self.sep_conv(x)))))) return x.view(1, -1).size(1) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = F.elu(x) x = self.depth_conv(x) x = self.bn2(x) x = F.elu(x) x = self.pool1(x) x = self.drop1(x) x = self.sep_conv(x) x = self.point_conv(x) x = self.bn3(x) x = F.elu(x) x = self.pool2(x) x = self.drop2(x) x = x.view(x.size(0), -1) x = self.fc(x) return x逻辑说明:conv1是时间方向的卷积,卷积核尺寸(1, kernel_length),因为这里把通道当作空间维度,暂时不动通道,只沿时间轴做滤波。depth_conv用了groups=F1,这就是深度卷积的核心——每个输入通道单独卷积,参数数量是F1 * D * 1 * 1,不会跨通道混合。sep_conv和point_conv组成可分离卷积,前者在每个特征图内部做时间卷积,后者用1x1跨特征图混合。_compute_flatten_size是模拟前向一次,动态算出全连接层输入维数,省去手算的麻烦。参数说明:F1是第一层时间滤波器数量,D是深度乘数,F2是第二层滤波器数量;三者乘积决定了模型容量,数据集小时全部调小。kernel_length=64在 250 Hz 采样率下约等于 256 ms,刚好覆盖一个典型脑电波形周期。
3.3 关键参数:F1、D、F2、内核长度、dropout怎么设
按原论文的推荐,F1=8, D=2, F2=16在大多数公开脑电数据上够用。但实际项目里这个配置偏保守,我的经验是:如果训练集每个类别有几百个样本,可以把F1提到 16,F2相应到 32;如果数据非常少(比如只有几十个试次),保持 8/2/16 甚至降成 4/1/8 更稳。kernel_length的选择与采样率强相关。250 Hz 下用 64,128 Hz 下用 32,本质是保持时间窗长在 0.25 秒左右。dropout默认 0.25,数据量越小越该提高,最高到 0.5;但注意 dropout 太高会让训练收敛变慢,需要配合更长的 epochs。
还有两个容易忽略的小参数:BatchNorm2d里的momentum=0.01, eps=1e-3是 EEGNet 原实现里特别强调的,默认的0.1和1e-5在脑电这种非平稳小批量数据上会不稳定。平均池化代替最大值池化也是刻意选择:脑电信号里有大量噪声尖峰,最大值池化会把尖峰放大,平均池化对噪声更平滑。这些细节单独看都不起眼,合起来就是 EEGNet 在小样本上能赢过大网络的直接原因。
4. 训练与评估:让模型收敛的那些命令和调参细节
4.1 损失函数与优化器:交叉熵配合Adam的常见配置
脑电分类绝大多数是有限类别的分类任务,用交叉熵损失即可。Pytorch 里nn.CrossEntropyLoss自带 softmax,标签要用整数类索引,不要用 one-hot。优化器我用Adam,学习率初始0.001。这个初始值对 EEGNet 来说略高,尤其是 BatchNorm 层比较多时,容易在开头几步出现 loss 抖动。更稳的做法是先用0.0005跑 20 个 epoch,看收敛曲线再决定是否往上调。下面给一个训练循环的标准示例,包含优化器、损失函数和 batch 生成:
import torch.optim as optim model = EEGNet(n_channels=16, n_samples=250, num_classes=2) optimizer = optim.Adam(model.parameters(), lr=0.0005) criterion = nn.CrossEntropyLoss() def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() total_loss = 0 correct = 0 total = 0 for X_batch, y_batch in train_loader: X_batch = X_batch.to(device) y_batch = y_batch.to(device) optimizer.zero_grad() logits = model(X_batch) loss = criterion(logits, y_batch) loss.backward() optimizer.step() total_loss += loss.item() * X_batch.size(0) pred = logits.argmax(dim=1) correct += (pred == y_batch).sum().item() total += y_batch.size(0) return total_loss / total, correct / total逻辑说明:X_batch的维度必须是(batch, 1, channels, time),如果你的数据预处理后是(batch, channels, time),需要增加一个维度。optimizer.zero_grad()必须在每个 batch 前调用,否则梯度会累加。logits.argmax(dim=1)得到预测类别。参数说明:device建议用torch.device('cuda' if torch.cuda.is_available() else 'cpu'),注意把模型和输入都搬到同一设备。batch size 我一般取 32 或 64,太小会让 BatchNorm 的统计量抖动。
4.2 训练循环要盯哪些指标:准确率不是唯一标准(kappa、AUC)
二分类准确率在类别不平衡时欺骗性极强:如果正类只占 10%,模型全部预测负类也能拿 90% 准确率。脑电竞赛里很多任务有天然的类别偏差,比如运动想象的左右手分类虽然大致平衡,但有些受试者某一类明显更多。我习惯同时打印Cohen's kappa和AUC,kappa 考虑了随机一致率,AUC 对阈值不敏感。对于多分类,可以看每个类别的召回率。下面这个工具函数可以在验证时计算多种指标:
from sklearn.metrics import cohen_kappa_score, roc_auc_score, f1_score def evaluate(model, val_loader, device, num_classes=2): model.eval() all_preds = [] all_labels = [] all_probs = [] with torch.no_grad(): for X_batch, y_batch in val_loader: X_batch = X_batch.to(device) logits = model(X_batch) probs = torch.softmax(logits, dim=1) preds = logits.argmax(dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(y_batch.numpy()) all_probs.extend(probs.cpu().numpy()) kappa = cohen_kappa_score(all_labels, all_preds) f1 = f1_score(all_labels, all_preds, average='macro') auc = roc_auc_score(all_labels, np.array(all_probs)[:, 1]) if num_classes == 2 else None return {'kappa': kappa, 'f1': f1, 'auc': auc}逻辑说明:model.eval()让 BatchNorm 和 dropout 切换到推理模式,注意每个 batch 不要重复调用model.train()。all_probs存的是 softmax 概率,二分类时取正类那一列算 AUC。参数说明:average='macro'在多分类时给每个类别相同的权重,比 micro 更能暴露小类问题。如果 Auc 一直徘徊在 0.5 附近,说明模型没学到任何可分特征,先回去检查预处理。
4.3 早停与学习率调度:防止过拟合的后悔药
脑电数据量小,过拟合几乎是必然会遇到的。常见现象是训练 loss 一直降,验证 loss 在第 30 个 epoch 左右开始反弹。我的习惯是早停 patience 设为 20,也就是验证 kappa 连续 20 个 epoch 不提高就停止训练。同时配一个学习率调度器,验证指标停滞时把学习率乘 0.5,让模型在损失曲面低谷附近更精细地搜索。下面这段代码展示了早停的骨架:
def train_with_early_stopping(model, train_loader, val_loader, optimizer, criterion, device, patience=20, min_delta=1e-4): best_kappa = -1.0 bad_epochs = 0 best_state = None for epoch in range(200): train_loss, train_acc = train_one_epoch(...) metrics = evaluate(model, val_loader, device) kappa = metrics['kappa'] if kappa > best_kappa + min_delta: best_kappa = kappa bad_epochs = 0 best_state = {k: v.clone() for k, v in model.state_dict().items()} else: bad_epochs += 1 if bad_epochs >= patience: print(f'early stop at epoch {epoch}') model.load_state_dict(best_state) break # 每10个epoch手动降学习率 if epoch > 0 and epoch % 10 == 0: for g in optimizer.param_groups: g['lr'] *= 0.5 return model逻辑说明:这里用 kappa 而不是 loss 做早停标准,是因为 loss 下降不一定代表分类质量提高;kappa 提升才说明模型在类别层面真的变好了。best_state保存的是模型权重副本,停止时恢复为历史最优。参数说明:patience设太大训练时间翻倍,设太小容易错过更好的收敛点;min_delta用来忽略极小的波动,防止因为浮点噪声提前停止。如果你发现验证指标像心电图一样上下跳,可以加大min_delta到 0.002。
5. EEGNet落地避坑:5个踩过的坑与排查方法
5.1 数据形状搞错:BCI Competition IV 2b 的 3D 张量问题
现象:运行第一个 batch 时报Expected 4D input或者全连接层维度对不上,训练直接崩。原因:公开脑电数据集里的单个样本经常是(channels, time),但 PyTorch 卷积需要四个维度(batch, channels_height, width),这里我们的"图像"是(1, channels, time),所以需要在channels维上加一个 1 维。另一个更隐蔽的问题是在_compute_flatten_size里用了n_samples=250,但实际切出来的时间点因为滤波边缘效应变短了,比如 250 个点经过padding后可能多出几个点,或者因为手动切片少了尾部,导致全连接层输入尺寸不对。解决:在初始化模型前打印epochs.shape[2],用这个值去设置n_samples,不要写死。
5.2 滤波参数不一致导致训练/测试结果漂移
现象:训练时用 8–30 Hz 带通滤波,验证时忘了用同一个滤波器,结果训练准确率 95%,验证只有 60%。原因:很多人把滤波写在数据预处理脚本里,训练和验证走的是两条代码路径,参数一改就不同步。我曾经在某个模拟项目上把训练数据的滤波下限从 8 Hz 改成 4 Hz,但验证脚本还是 8 Hz,模型学到的低频特征在验证集上不存在,直接翻车。解决:把滤波封装成一个函数,训练集和验证集共用同一份配置;保存模型时把滤波参数也一并写进配置字典,加载模型时强制检查。
5.3 样本量太小导致验证集波动大:用交叉验证替代固定划分
现象:单次固定划分下,模型第一次跑 kappa 0.72,第二次重跑变成 0.58,换一个随机种子又在 0.65 波动。原因:一个受试者的试次可能只有 100 多个,验证集只有 20–30 个样本,每次划分包含的样本不同,指标方差极大。解决:对于受试者内分类,用 5 折交叉验证,多次划分取平均值;对于受试者间分类,用前方受试者留一法。交叉验证的缺点是训练成本高,但对于小数据集,宁可多花时间也不该接受一个不确定的数。
5.4 批归一化在EEGNet里的表现:训练和推理模式不一致
现象:训练 loss 下降正常,验证时用同一个模型输出结果却剧烈波动,有时甚至全预测成一类。原因:BatchNorm2d在训练时使用当前 batch 的均值和方差,在推理时使用训练阶段累计的 running stats。如果你的推理代码忘了调model.eval(),BatchNorm 仍然按训练模式处理验证 batch,而验证 batch 又特别小(比如 8 个样本),统计量噪声非常大。解决:在evaluate函数开头显式调用model.eval(),在train_one_epoch开头调用model.train()。如果用了 PyTorch Lightning 或 Keras,确认框架是否自动切换。
5.5 标签泄漏:归一化必须按样本算,不能用全数据集统计量
现象:验证准确率看似很高,但换到真正的新受试者上效果惨不忍睹。原因:如果你在数据预处理阶段用所有受试者的所有试次一起计算均值和标准差,那验证集样本的信息已经被混入归一化参数里,相当于模型在预测时偷偷看到了验证集的分布信息。这在离线实验里会被误认为效果好,在线实时分类时却完全失效。解决:严格按训练集计算标准化参数,并保存这些参数,验证和测试时直接复用训练集的均值方差;如果使用per_epoch_zscore,就没有这个问题,因为每个样本独立算。
6. 从准确率到可解释性:用EEGNet做特征可视化与迁移的进阶技巧
模型训练完之后,我通常会先回答一个问题:EEGNet 到底看到了什么?直接看权重可以有个粗浅印象,但更有价值的做法是把时间卷积核可视化。取出conv1.weight,形状是(F1, 1, 1, kernel_length),把每个滤波器沿着时间轴画出来,能看到模型学到的波形模式——比如在运动想象任务里,好的模型会学到类似 mu 节律和 beta 节律的振荡模式。如果滤波器看起来全是高频毛刺,说明预处理里去噪不够,或学习率过高。
另一个实用技巧是迁移学习。同一个受试者的脑电信号本身不稳定,不同天的数据分布差异大,但你可以在受试者A的数据上预训练 EEGNet,然后把最后全连接层换掉,用受试者B的少量数据微调。具体做法:冻结conv1和depth_conv的参数,只训练后面的可分离卷积和分类头;如果数据非常少,连可分离卷积也冻结,只训练全连接。微调时学习率降到1e-4,因为前面层的特征比较通用,后面层则需要适应新分布。我自己在模拟项目X上验证过,用两个受试者的数据预训练,第三个受试者只给 30 个样本微调,结果比从零训练高 12 个百分点。
还有一件事值得做:用类激活映射(CAM)看分类决策依赖哪些时间段。EEGNet 的pool2输出特征图还保留了时间维度,你可以对特征图加权求和,得到每个时间点的贡献。这一步能帮你发现模型是否依赖了任务不相关的伪迹——比如眨眼产生的低频大幅波动。如果发现贡献集中在刺激出现后的 200 ms 内,属于正常;如果集中在 1 秒后且伴随大幅低频,建议检查是否有肌电伪迹未去除。
最后说一个我的血泪经验:千万不要只看测试准确率就急着部署。先跑一遍跨天数据验证。我曾在某图像处理 Demo 上用 EEGNet 做情绪分类,当天数据测试准确率 88%,过了一周采集新数据,准确率掉到 64%。原因就是不同天电极位置、皮肤阻抗、精神状态都在变,模型学到的特征并不稳定。现在我养成的习惯是每次实验都记录阻抗值、滤波参数、甚至采集时段,在报告里同时给出"同天验证"和"跨天验证"两列数字。只有跨天数据还稳定的模型,才值得上线。希望帮到你。
本文还有配套的精品资源,点击获取