☰
EEGNET脑电分类实战:从信号预处理到模型训练与调参
2026/10/2 18:00:38 网站建设 项目流程

简介:这份资源面向脑电信号处理与深度学习入门者,提供EEGNET网络的完整Python实现,用于脑电分类任务。EEGNET借鉴CNN与CRNN思想,由空间卷积层和时序卷积层组成,能同时捕捉EEG数据的空间与时间依赖特征,是生物信号分析中处理复杂非线性数据的实用模型。压缩包内共1个py文件,大小约2KB,核心脚本涵盖数据预处理、模型构建、训练优化、验证评估、超参数调优及推理应用等环节,并涉及小波变换、短时傅里叶变换、功率谱分析等时频特征提取方法,以及PCA、ICA等降维思路。已有1876人学习下载,适合希望快速理解EEGNET结构并动手实践脑电分类的读者参考,可据此搭建实验流程、复现训练与评估步骤,并在此基础上迁移到其他生物信号分析场景。

1. EEGNET网络实现脑电分类:从原始信号到可复现的最小闭环

脑电分类这件事,很多人第一次做都会卡在同一个地方:数据读进来了,模型也搭起来了,但训练出来的准确率在 50% 上下晃,跟掷硬币差不多。EEGNET 这个网络结构之所以在脑电分类圈子里被反复提起,恰恰是因为它用极少的参数量,在运动想象、P300、SSVEP 这几类典型任务上都能跑出一个能看的结果。它不是什么新架构,本质是一个专门为脑电信号设计的紧凑型卷积神经网络,把通道维度和时间维度的卷积拆开做,再配合深度可分离卷积压缩参数量。

如果你手头有 OpenBCI、BrainProducts 或者公开数据集(比如 BCI Competition IV 2a)的脑电数据,想用一个不折腾、能复现的深度学习方案做分类,EEGNET 是一个很合适的起点。它不需要 GPU 集群,单卡甚至 CPU 都能跑通,代码量也不大。接下来我会按「信号怎么进网络 → 网络怎么搭 → 训练怎么调 → 坑在哪」的顺序,把这条链路拆开讲清楚。信号处理和深度学习在这里不是两个割裂的环节,而是一条流水线,前面滤波没做对,后面网络再深也白搭。

2. 脑电信号进网络之前:预处理管线的搭建与参数选择

2.1 为什么原始脑电不能直接喂给 EEGNET

脑电信号是微伏级别的电压波动,采集时混着工频干扰、眼电、肌电、电极接触噪声。直接把原始时序丢进卷积网络,模型大概率会去学那些跟任务无关的伪迹。常见做法是先带通滤波到 0.5–40 Hz 或 4–38 Hz(运动想象任务常用 8–30 Hz),再做通道级标准化。EEGNET 原文里用的是 2–32 Hz 带通加指数移动标准化,这个配置在多数运动想象数据集上表现稳定。

滤波器的选择上,FIR 和 IIR 各有拥趸。IIR 阶数低、计算快,但相位非线性可能让波形发生畸变;FIR 可以做到线性相位,但阶数高、延迟大。我一般用 MNE 的filter函数做零相位 FIR 滤波,虽然慢一点,但省心。下面这段代码是一个可复用的预处理函数:

import numpy as np from scipy.signal import butter, filtfilt def bandpass_eeg(data, fs, low=0.5, high=40.0, order=4): """ data: shape (n_channels, n_times) 或 (n_epochs, n_channels, n_times) fs: 采样率 返回同形状的滤波后数据 """ nyq = fs / 2.0 b, a = butter(order, [low / nyq, high / nyq], btype='band') # filtfilt 做零相位滤波,避免波形时移 return filtfilt(b, a, data, axis=-1) def normalize_per_channel(epochs): """ epochs: shape (n_epochs, n_channels, n_times) 对每个 epoch 的每个通道做零均值单位方差 """ mean = epochs.mean(axis=-1, keepdims=True) std = epochs.std(axis=-1, keepdims=True) + 1e-8 return (epochs - mean) / std

butter的order=4是一个折中值,阶数太高容易在通带边缘产生振铃,阶数太低则阻带衰减不够。filtfilt做的是前向-反向两次滤波,等效阶数翻倍,但相位为零,适合离线处理。标准化按通道独立做,是因为不同电极的阻抗和增益差异可能很大,不归一化的话网络会偏向数值大的通道。

2.2 数据分段与标签对齐的实操细节

脑电分类任务里,epoch 的切分方式直接影响样本量和类别平衡。运动想象通常取 cue 后 0.5–2.5 秒这一段,P300 取刺激后 0.3–0.8 秒。切早了包含视觉诱发电位,切晚了信号已经衰减。下面是一个按事件切分的示例:

def make_epochs(raw_data, events, fs, tmin, tmax, baseline=None): """ raw_data: (n_channels, n_times) events: list of (sample_index, label) tmin/tmax: 相对于事件起点的秒数 """ start_offset = int(tmin * fs) end_offset = int(tmax * fs) epochs, labels = [], [] for onset, label in events: s = onset + start_offset e = onset + end_offset if s < 0 or e > raw_data.shape[1]: continue # 边界样本丢弃 seg = raw_data[:, s:e].copy() if baseline is not None: b_start = int(baseline[0] * fs) b_end = int(baseline[1] * fs) seg = seg - seg[:, b_start:b_end].mean(axis=1, keepdims=True) epochs.append(seg) labels.append(label) return np.stack(epochs), np.array(labels)

这里有两个容易翻车的点。一是边界样本的处理,如果事件靠近记录末尾,切出来的段长度不够,直接continue丢掉比补零更安全,补零会引入虚假的静息段。二是 baseline 校正,用 cue 前 0.5 秒的均值做基线,能去掉慢漂移,但如果 baseline 窗口里本身有噪声,反而会引入偏差,所以 baseline 区间要选在刺激之前、相对干净的一段。

2.3 通道选择与重参考对分类的影响

不是所有电极都对任务有贡献。运动想象主要看 C3、Cz、C4 及其周围,P300 看 Pz、Fz、Cz。全通道输入不是不行,但会增加噪声通道的干扰。常见做法有两种:一是按先验知识手动选 8–16 个通道,二是用 CSP(共空间模式)做通道加权。EEGNET 本身有通道维度的卷积,理论上能学到通道权重,但前提是数据量够。小样本情况下,手动选通道更稳。

重参考方面,常见的有 CAR(共同平均参考)和 Laplacian。CAR 简单,把所有通道减去平均,能抑制全局噪声,但也会削弱局部信号。Laplacian 用邻近电极做局部平均,空间分辨率更高,但对电极布局有要求。我一般先用 CAR,如果分类效果不理想再试 Laplacian。这一步没有绝对最优,跟数据集和任务相关。

3. EEGNET 网络结构拆解:从论文到可运行代码

3.1 两个关键模块:时间卷积与深度可分离卷积

EEGNET 的结构可以概括为三个块。第一块做时间维度的卷积,用一个(1, kernel_length)的卷积核提取频率特征,等价于对每个通道做带通滤波。第二块做深度卷积,用(n_channels, 1)的卷积核在每个时间点上跨通道融合,这一步学的是空间滤波。第三块用可分离卷积,先对每个特征图做时间卷积,再做 1x1 的跨特征图混合,压缩参数量的同时保留表达能力。

这个设计的核心思想是:脑电信号的空间和时间特征可以解耦。传统 CNN 用(n_channels, kernel_length)的二维卷积核,参数量是n_channels × kernel_length × n_filters,而 EEGNET 把这一步拆成深度卷积加可分离卷积,参数量降到n_channels × depth_multiplier + n_filters × kernel_length的量级。对于通道数 64、时间点 128 的输入,参数量差距能到十倍以上。

3.2 用 PyTorch 搭一个最小可用的 EEGNET

下面是一个可以直接跑的 EEGNET 实现,输入形状是(batch, 1, n_channels, n_times):

import torch import torch.nn as nn class EEGNet(nn.Module): def __init__(self, n_channels=64, n_times=128, n_classes=4, F1=8, D=2, F2=16, kernel_length=64, dropout=0.5): super().__init__() # Block 1: 时间卷积 + 深度卷积 self.block1 = nn.Sequential( nn.Conv2d(1, F1, (1, kernel_length), padding=(0, kernel_length // 2), bias=False), nn.BatchNorm2d(F1), # 深度卷积:每个通道独立做空间滤波 nn.Conv2d(F1, F1 * D, (n_channels, 1), groups=F1, bias=False), nn.BatchNorm2d(F1 * D), nn.ELU(), nn.AvgPool2d((1, 4)), nn.Dropout(dropout) ) # Block 2: 可分离卷积 self.block2 = nn.Sequential( nn.Conv2d(F1 * D, F1 * D, (1, 16), padding=(0, 8), groups=F1 * D, bias=False), nn.Conv2d(F1 * D, F2, (1, 1), bias=False), nn.BatchNorm2d(F2), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Dropout(dropout) ) # 分类头 self.classifier = nn.Linear(F2 * (n_times // 32), n_classes) def forward(self, x): x = self.block1(x) x = self.block2(x) x = x.flatten(start_dim=1) return self.classifier(x)

F1=8是时间卷积的输出特征图数,D=2是深度乘数,F2=16是可分离卷积的输出通道数。这三个参数是 EEGNET 原文的默认值,在多数数据集上不需要大改。kernel_length=64对应采样率 128 Hz 下的 0.5 秒时间窗,如果采样率是 250 Hz,这个值要按比例调整到 125 左右。padding保持时间维度不变,AvgPool2d逐步降采样,最后flatten后的维度是F2 × (n_times // 32),这个数要跟Linear的输入对上,否则会报维度错误。

3.3 参数量与感受野的权衡

EEGNET 的参数量通常在 2k–10k 之间,具体取决于F1、D、F2和kernel_length。参数量小是优点也是限制:小样本下不容易过拟合,但如果任务复杂、类别多,表达能力可能不够。我试过在 4 类运动想象上把F1从 8 加到 16,F2从 16 加到 32,参数量翻了三倍,准确率只涨了不到 2 个百分点,但训练时间明显变长。所以除非数据量很大,否则不建议盲目加宽。

感受野方面,第一层时间卷积的kernel_length=64覆盖 0.5 秒,第二层可分离卷积的kernel_length=16覆盖 0.125 秒。这个组合对运动想象和 P300 都够用,但如果你做的是高频 SSVEP,可能需要缩短第一层卷积核,让网络更关注快速振荡。感受野不是越大越好,脑电信号的有效信息往往在特定时间尺度上,卷积核太长反而会平滑掉细节。

4. 训练配置与调参:学习率、批大小和正则化的实际取值

4.1 优化器与学习率调度

EEGNET 原文用的是 Adam,学习率 0.001,没有用学习率衰减。我在实际训练中发现,加一个余弦退火或者ReduceLROnPlateau能让后期收敛更稳。下面是一个训练循环的骨架:

from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau model = EEGNet(n_channels=64, n_times=128, n_classes=4) optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=10) criterion = nn.CrossEntropyLoss() for epoch in range(200): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() # 梯度裁剪,防止深度卷积层梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() # 验证 model.eval() val_loss = 0.0 with torch.no_grad(): for xb, yb in val_loader: val_loss += criterion(model(xb), yb).item() scheduler.step(val_loss)

weight_decay=1e-4是 L2 正则,配合 dropout 一起用。梯度裁剪的max_norm=1.0在深度卷积层上很有必要,因为groups=F1的卷积反向传播时梯度容易累积。ReduceLROnPlateau的patience=10意味着验证损失 10 个 epoch 不降就砍半学习率,这个值可以根据数据集大小调,小数据集可以设小一点,比如 5。

4.2 批大小与样本量的关系

批大小直接影响梯度估计的噪声。EEGNET 参数量小,批大小设 16–64 都行。如果样本量只有几百,批大小设 16 或 32,让每个 epoch 有足够多的更新次数。如果样本量上万,可以设 64 或 128,训练更快。注意批大小和学习率要联动:批大小翻倍,学习率通常也要适当增大,否则收敛变慢。

类别不平衡是脑电分类的常见问题。运动想象里,左手和右手样本数可能差一倍。处理方式有两种:一是用WeightedRandomSampler过采样少数类,二是在损失函数里加类别权重。我一般先用采样器,因为它不改变损失函数的形状,调起来更直观。

4.3 早停与模型选择的判断标准

早停的触发条件不能只看验证集准确率,因为准确率在类别不平衡时会失真。更稳的做法是看验证集的损失或者平衡准确率(balanced accuracy)。如果验证损失连续 20 个 epoch 不降,就停。保存模型时,保存验证损失最低的那个 checkpoint,而不是最后一个 epoch 的。下面是一个简单的早停实现:

class EarlyStopping: def __init__(self, patience=20, min_delta=1e-4): self.patience = patience self.min_delta = min_delta self.best_loss = float('inf') self.counter = 0 self.best_state = None def step(self, val_loss, model): if val_loss < self.best_loss - self.min_delta: self.best_loss = val_loss self.counter = 0 self.best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} else: self.counter += 1 return self.counter >= self.patience

min_delta=1e-4是为了避免验证损失在极小范围内波动时误判为「不降」。best_state保存的是 CPU 上的副本,避免 GPU 显存被占满。这个早停逻辑配合ReduceLROnPlateau一起用,基本能覆盖多数训练场景。

5. 避坑与排查:脑电分类训练中最容易翻车的五个地方

5.1 准确率卡在随机水平,损失不降

现象:训练集和验证集的准确率都在 25% 左右(4 分类),损失从第一个 epoch 开始就不怎么动。原因通常是标签和样本没对齐,比如 events 里的 sample_index 是相对于原始记录的,但 raw_data 已经做过裁剪或重采样,索引偏移了。解决方法是打印几个样本的标签和对应的波形,人工确认一下。另一个可能是输入数据的形状不对,比如把(n_channels, n_times)直接喂给了期望(1, n_channels, n_times)的网络,PyTorch 会广播成错误的结果但不报错。

5.2 验证集准确率远高于训练集

现象:训练集准确率 60%,验证集 85%。这听起来是好事,但通常是数据泄漏。检查一下切分数据集时有没有把同一个 trial 的相邻 epoch 分到训练集和验证集两边。脑电信号的相邻时间段高度相关,如果随机按 epoch 切分,验证集里的样本可能跟训练集里的几乎一样。正确做法是按 trial 或按 session 切分,确保验证集的 trial 在训练集中没出现过。

5.3 训练后期损失突然变成 NaN

现象:前几十个 epoch 正常,然后损失突然变成 NaN。原因可能是学习率太大导致梯度爆炸,或者filtfilt滤波后的数据里有极端值。先检查输入数据的最大值和最小值,如果超过 ±1000 微伏,说明有伪迹没处理干净。然后在训练循环里加梯度裁剪,把max_norm设成 1.0 或 0.5。如果还不行,把学习率降到 1e-4 再试。

5.4 不同随机种子下结果波动超过 10%

现象:换个随机种子,准确率从 75% 掉到 62%。这是小样本脑电分类的常态。解决方式不是去调网络,而是做交叉验证。用 5 折或 10 折交叉验证,报告平均准确率和标准差。如果标准差超过 5 个百分点,说明模型不稳定,可能需要增加数据量或者简化网络。另外,固定 PyTorch 的随机种子、NumPy 的随机种子和 CUDA 的随机种子,能减少一部分波动,但不能完全消除。

5.5 推理时单样本预测结果与批量预测不一致

现象:把单个样本喂给模型,输出跟批量预测的结果对不上。这通常是 BatchNorm 在作怪。训练模式下 BatchNorm 用当前批的均值和方差,推理模式下用滑动平均。如果模型没调eval(),单样本推理时 BatchNorm 会用样本自己的统计量,导致输出偏移。解决方法是推理前务必调model.eval(),并且用torch.no_grad()包住前向传播。

6. 把 EEGNET 推到更高精度:迁移学习与集成策略的实操

EEGNET 在单数据集上做到 70%–80% 不算难,但想再往上走,单靠调参空间有限。我试过两个方向,效果比较实在。第一个是跨被试迁移:先在多个被试的数据上预训练,再在目标被试的小样本上微调。具体做法是把预训练模型的分类头换成目标被试的类别数,冻结前两个 block,只训练分类头和最后一个 block。微调时学习率设成预训练的十分之一,epoch 控制在 50 以内,避免过拟合。

第二个是集成。EEGNET 的随机性主要来自权重初始化和 dropout,训练 5–10 个不同种子的模型,推理时对 softmax 输出取平均,通常能涨 2–4 个百分点。如果嫌训练多个模型太慢,可以用 snapshot ensemble:在一个训练循环里,用余弦退火让学习率周期性回升,在每个周期的最低点保存模型,最后集成这些 snapshot。这样只训练一次就能拿到多个模型。

验证集成效果时,不要只看准确率,还要看混淆矩阵。脑电分类里,某些类别的样本容易被混在一起,比如左手和双脚的运动想象。如果集成后混淆矩阵的对角线更集中,说明集成确实在起作用。最后分享一个我踩过的坑:微调时如果解冻太多层,目标被试的少量数据会把预训练学到的通用特征覆盖掉,验证损失会在几个 epoch 内快速上升。所以微调的第一原则是「少动」,先只调分类头,不够再逐层解冻。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询