简介:这份资源面向脑电信号处理与深度学习入门者,提供EEGNET网络的完整Python实现,用于脑电分类任务。EEGNET借鉴CNN与CRNN思想,由空间卷积层和时序卷积层组成,能同时捕捉EEG数据的空间与时间依赖特征,是生物信号分析中处理复杂非线性数据的实用模型。压缩包内共1个py文件,大小约2KB,即核心实现脚本,涵盖数据预处理、模型构建、训练优化、验证评估、超参数调优到应用推理的完整流程,并涉及小波变换、短时傅里叶变换、功率谱分析等时频特征提取方法,以及PCA、ICA等降维思路。目前已有1876人学习下载,适合希望快速理解EEGNET结构、动手复现脑电分类实验的读者参考,可据此搭建自己的信号处理与深度学习流程。
1. EEGNET 脑电分类落地:从原始信号到可复现的深度学习流水线
脑电分类这件事,真正上手做过的人都知道,难点从来不在网络结构本身,而在数据怎么进、标签怎么对齐、模型怎么不被被试间的差异带偏。EEGNET 这个网络之所以在运动想象、P300、SSVEP 这些任务里被反复提起,是因为它用极少的参数量把「时域卷积 + 深度可分离卷积 + 空间滤波」串成了一条紧凑的前馈神经网络流水线,训练快、过拟合风险低,特别适合样本量只有几百到几千 trial 的脑电数据集。它属于深度学习里典型的轻量卷积神经网络思路,但针对的是多通道时间序列这种特殊信号,而不是图像。如果你手上有 OpenBCI、BrainProducts 或者公开的 BCI Competition 数据,想跑通一条从信号处理到分类输出的完整链路,EEGNET 是一个性价比很高的起点。这篇笔记按我实际搭环境的顺序讲:先讲清楚它为什么这么设计,再给能直接抄的代码,最后把踩过的坑摊开说。
2. EEGNET 的结构逻辑与数据前置处理
2.1 为什么脑电分类不能直接套普通 CNN
普通二维卷积神经网络吃的是图像那种规整的网格数据,而脑电信号是「通道 × 时间」的二维矩阵,通道之间没有图像里像素那种空间邻接关系,硬套 ImageNet 那套卷积核会浪费大量参数在无意义的空间位置上。EEGNET 的做法是分两步走:先用一个时间卷积核(长度通常取采样率的一半)在时间维度上做滤波,学出频率相关的特征;再用一个深度卷积(depthwise convolution)在通道维度上做空间滤波,相当于给每个时间滤波器配一组通道权重。这个设计直接对应了脑电信号处理里的「先带通滤波、再空间滤波」的传统流程,只不过把这两步变成了可学习的参数。参数量能压到几千级别,这是它不容易过拟合的根本原因。
我一般会把输入整理成(trials, channels, timepoints, 1)这种四维张量,最后一个维度是为了兼容 Keras 的 Conv2D。如果你的数据是(trials, timepoints, channels),记得先 transpose,这个顺序搞反是新手最常见的翻车点,模型照样能跑,但准确率会掉到随机水平附近,而且你从 loss 曲线上看不出任何异常。
2.2 用 MNE 做带通滤波和重参考的最小代码
脑电原始信号里混着工频干扰、眼电、肌电,直接喂给网络等于让模型去学噪声。标准流程是先带通到任务相关频段,再做重参考。运动想象一般取 8–30 Hz,P300 取 0.1–20 Hz,SSVEP 取 3–40 Hz。下面这段是我常用的预处理骨架:
import mne import numpy as np # raw: MNE Raw 对象,sfreq 假设 250 Hz raw = mne.io.read_raw_fif("sub-01_raw.fif", preload=True) # 1. 带通滤波:运动想象取 8-30 Hz raw.filter(l_freq=8.0, h_freq=30.0, fir_design="firwin", verbose=False) # 2. 陷波去工频(国内 50 Hz) raw.notch_filter(freqs=50.0, verbose=False) # 3. 重参考:常用 CAR(共同平均参考) raw.set_eeg_reference("average", projection=False, verbose=False) # 4. 按事件切 epoch events, event_id = mne.events_from_annotations(raw) epochs = mne.Epochs( raw, events, event_id=event_id, tmin=0.5, tmax=3.5, # 运动想象常用时间窗 baseline=None, preload=True ) # 5. 转成 numpy,形状 (trials, channels, timepoints) X = epochs.get_data() # 已经是 (n_epochs, n_channels, n_times) y = epochs.events[:, -1] # 标签 # 6. 标准化:按 trial 做 z-score,避免被试间幅值差异 X = (X - X.mean(axis=2, keepdims=True)) / (X.std(axis=2, keepdims=True) + 1e-6) # 7. 补一个通道维度给 Conv2D X = X[..., np.newaxis] # (trials, channels, timepoints, 1) print(X.shape, y.shape)这段代码里几个参数值得说清楚。tmin和tmax决定了时间窗,运动想象任务里 cue 出现后 0.5 秒到 3.5 秒是 ERD/ERS 现象最明显的区间,取太短会丢信息,取太长会引入无关的静息段。baseline=None是因为后面做了 trial 级 z-score,再做基线校正属于重复操作。标准化那一步用axis=2是按时间维度算均值和方差,这样每个通道每个 trial 都独立归一化,能有效缓解电极阻抗差异带来的幅值漂移。如果你跳过这一步,被试间的幅值差异会让模型把「谁做的」当成特征来学,交叉验证时看着还行,换个人就崩。
2.3 标签对齐与数据集划分的注意点
脑电数据集的标签对齐比图像麻烦,因为事件标记和信号采样点之间可能有偏移。MNE 的events_from_annotations默认按 annotation 的 onset 取最近采样点,如果你的刺激呈现有延迟(比如屏幕刷新导致的 10–20 ms 延迟),需要手动补偿。我一般会在切 epoch 前把 events 的采样点整体平移几毫秒,具体数值靠查设备手册或者用一个光电二极管实测。
划分数据集时,如果同一个被试的数据既有训练又有测试,准确率会虚高,因为模型可能记住了这个人的个体特征。严谨的做法是留一被试交叉验证(LOSO),每次拿一个被试做测试,其余做训练。如果只是想快速验证网络能不能跑通,可以先用单被试的 trial 级划分,但心里要清楚这个数字不能直接写进论文。
3. 用 Keras 搭一个能跑的 EEGNET
3.1 完整网络定义与逐层参数说明
下面是我常用的 EEGNET 实现,基于 TensorFlow/Keras,结构参照原始论文的紧凑版本,但把参数写成了可配置的形式:
import tensorflow as tf from tensorflow.keras import layers, models def build_eegnet(n_channels, n_times, n_classes, F1=8, D=2, F2=16, kernel_length=None, dropout=0.5): """ n_channels: 电极数 n_times: 时间点数 n_classes: 分类数 F1: 时间滤波器数量 D: 深度乘数,控制空间滤波器数量 = F1 * D F2: 逐点卷积输出通道数 """ if kernel_length is None: kernel_length = 64 # 约采样率的一半,250Hz 采样时取 125 更常见 inp = layers.Input(shape=(n_channels, n_times, 1)) # Block 1: 时间卷积 + 深度空间卷积 x = layers.Conv2D(F1, (1, kernel_length), padding="same", use_bias=False)(inp) x = layers.BatchNormalization()(x) x = layers.DepthwiseConv2D((n_channels, 1), use_bias=False, depth_multiplier=D)(x) x = layers.BatchNormalization()(x) x = layers.Activation("elu")(x) x = layers.AveragePooling2D((1, 4))(x) x = layers.Dropout(dropout)(x) # Block 2: 可分离卷积 x = layers.SeparableConv2D(F2, (1, 16), padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("elu")(x) x = layers.AveragePooling2D((1, 8))(x) x = layers.Dropout(dropout)(x) # 分类头 x = layers.Flatten()(x) out = layers.Dense(n_classes, activation="softmax")(x) return models.Model(inp, out) model = build_eegnet(n_channels=22, n_times=750, n_classes=4) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"] ) model.summary()逐层解释一下。第一个Conv2D的卷积核形状是(1, kernel_length),意思是只在时间维度上滑动,通道维度不参与,这就是「时间滤波」。kernel_length取采样率的一半是个经验值,250 Hz 采样时对应 125 个点,覆盖约 2 Hz 以上的频率成分,但实际用 64 也很常见,因为太长的核会显著增加计算量。DepthwiseConv2D的核形状是(n_channels, 1),把每个时间滤波器的输出在所有通道上做加权求和,这就是「空间滤波」,depth_multiplier=D表示每个时间滤波器生成 D 个空间滤波器。SeparableConv2D是深度卷积加逐点卷积的组合,进一步压缩参数。池化用AveragePooling2D而不是最大池化,是因为脑电信号里平均池化对噪声更鲁棒,这一点和图像任务的习惯不同。
3.2 训练循环与早停策略
数据量小的时候,训练轮数不是越多越好,我一般配早停和模型检查点:
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks = [ EarlyStopping(monitor="val_loss", patience=30, restore_best_weights=True, verbose=1), ModelCheckpoint("best_eegnet.keras", monitor="val_loss", save_best_only=True, verbose=0) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=300, batch_size=32, callbacks=callbacks, verbose=1 )patience=30是我在多个数据集上试出来的折中值,太小会因为验证 loss 的随机波动提前停,太大则浪费时间。batch_size=32对几百个 trial 的数据集比较合适,太小梯度噪声大,太大容易过拟合。注意这里用的是sparse_categorical_crossentropy,因为标签是整数而不是 one-hot,如果你把标签转成了 one-hot,要换成categorical_crossentropy,这个不匹配会直接报维度错误。
3.3 评估指标不能只看准确率
脑电分类里类别不平衡很常见,比如 P300 的 target 和 non-target 比例可能是 1:4。这时候准确率会骗人,一个全预测 non-target 的模型也能有 80% 准确率。我一般会同时看混淆矩阵和 Cohen's kappa:
from sklearn.metrics import confusion_matrix, cohen_kappa_score import numpy as np y_pred = np.argmax(model.predict(X_test), axis=1) cm = confusion_matrix(y_test, y_pred) kappa = cohen_kappa_score(y_test, y_pred) print("混淆矩阵:\n", cm) print("Kappa:", round(kappa, 4))Kappa 扣除了随机猜测的贡献,0.6 以上算可用,0.8 以上算不错。如果准确率 85% 但 Kappa 只有 0.3,说明模型基本在猜多数类,需要回去检查数据平衡或者损失函数。
4. 避坑与排查:EEGNET 训练中最容易翻车的五个地方
4.1 输入形状搞反导致准确率卡在随机水平
现象:模型能正常训练,loss 缓慢下降,但验证准确率始终在 1/n_classes 附近晃。原因:把(trials, timepoints, channels)直接喂进去了,网络把时间点当成了通道,空间卷积核作用在了错误维度上。解决:在预处理最后打印X.shape,确认第二维是通道数、第三维是时间点数,然后用np.transpose(X, (0, 2, 1))调整,最后补[..., np.newaxis]。
4.2 标准化用了全局统计量导致数据泄漏
现象:交叉验证准确率很高,但换一批新数据测试时掉得厉害。原因:在划分训练测试集之前就对全体数据做了 z-score,测试集的均值和方差信息泄漏进了训练过程。解决:标准化参数只能从训练集计算,然后应用到验证集和测试集。如果按 trial 做标准化,每个 trial 独立计算,这个问题自然规避,但如果是按通道做全局标准化,必须严格在训练集上 fit。
4.3 学习率太大导致 loss 震荡不收敛
现象:训练 loss 上下剧烈跳动,验证 loss 不降反升。原因:Adam 默认学习率 1e-3 对某些数据集偏大,尤其是通道数多、时间窗长的时候。解决:降到 1e-4 或者 5e-4 试,同时加ReduceLROnPlateau回调,验证 loss 连续 10 轮不降就砍半。我一般会先跑一个 20 轮的小实验,看 loss 曲线是平滑下降还是锯齿状,再决定最终学习率。
4.4 Dropout 加得太猛导致欠拟合
现象:训练 loss 和验证 loss 都居高不下,准确率上不去。原因:EEGNET 参数量本来就少,Dropout 设到 0.5 以上会把有效容量压得太低。解决:从小往大调,先试 0.25,再试 0.5,对比验证曲线。如果训练 loss 都降不下去,说明模型容量不够,优先降 Dropout 而不是加层。
4.5 被试间差异没处理导致 LOSO 崩溃
现象:单被试内准确率 90%,留一被试交叉验证掉到 60%。原因:不同被试的脑电幅值、阻抗、头型差异很大,模型学到了个体特征而非任务特征。解决:除了 trial 级标准化,还可以加被试级的数据增强(比如随机幅值缩放、通道 dropout),或者用域对抗的思路在训练时混淆被试身份。最直接的办法是增加被试数量,每个被试多采一些 trial,让模型见过足够多的个体差异。
5. 把 EEGNET 用到新数据集上的迁移技巧
当你拿到一个新数据集,不要一上来就从头训。我一般会先做两件事:一是用随机初始化的模型跑一个 baseline,确认数据本身可分;二是把在类似任务上预训练好的权重拿过来做微调。微调时冻结前两个 block,只训分类头,学习率设 1e-4,跑 50 轮左右,通常比从头训收敛快很多,尤其是在目标数据集 trial 数少于 500 的时候。
另一个实用技巧是通道适配。EEGNET 的深度卷积核形状和通道数绑定,如果新数据集通道数不同,不能直接加载权重。我的做法是保留时间卷积层,重新初始化深度卷积层,然后用少量数据微调。如果通道数差异不大(比如 22 到 21),也可以对权重做插值或者直接裁掉一个通道对应的切片,实测影响很小。
验证迁移效果时,别只看最终准确率,要看学习曲线的前 20 轮。如果微调的验证 loss 在前 10 轮就降到比从头训低很多,说明预训练特征确实有用;如果两条曲线几乎重合,说明源任务和目标任务差异太大,预训练没帮上忙,不如老老实实从头训。
最后说一个我自己的习惯:每次跑完实验,把数据版本、预处理参数、网络配置、随机种子全部记在一个 yaml 文件里,和模型权重放一起。脑电实验的可复现性很差,同一个脚本换个随机种子结果可能差 5 个百分点,没有记录的话,两周后你自己都说不清哪个配置是最好的。这个习惯帮我省了无数次重跑的时间,希望帮到你。
本文还有配套的精品资源,点击获取