
简介脑电信号记录了大脑神经元电活动是癫痫诊断与发作检测的重要依据。面向脑电信号处理与癫痫自动检测需求该压缩包提供基于一维卷积神经网络1DCNN的原始时间序列分类代码。资源聚焦脑电分类与癫痫发作检测适合生物医学工程、神经信号分析领域的初学者及研究人员参考。包内仅含1个Python脚本大小2KB结构紧凑完整覆盖数据预处理、模型构建、训练验证与评估应用等流程。1DCNN的设计思路是通过沿时间轴滑动的卷积核直接作用于原始脑电波形捕获不同频率成分及时序变化特征多层叠加后可逐步抽象出与癫痫发作相关的复杂模式相比传统手工特征方法具有更强的自动特征提取能力。脚本中给出了网络结构定义、损失函数与优化器配置并对输入数据的分段、归一化等预处理步骤作了清晰演示便于读者替换自己的脑电数据快速开展实验。目前已有720人学习下载。借助该脚本可直观理解1DCNN在脑电时序分析中的运行机制掌握从原始EEG输入到分类输出的端到端建模方法也为进一步开发脑电监测与实时癫痫检测系统提供了简洁可用的代码基础。1. 1DCNNforRawTimeSeries癫痫发作检测为什么需要从原始脑电直接学特征传统脑电分类的套路是先做频域变换再手工提取各频带能量、过零率、样本熵之类的特征最后丢给SVM或随机森林去分类。而1DCNNforRawTimeSeries的思路完全绕开这一步一维卷积神经网络直接吃掉原始时间序列把特征提取器和分类器合成同一个模型。落到癫痫发作检测这个场景好处非常直接——发作时EEG会出现节律性放电和幅度爆发1DCNN在原始波形上做局部卷积恰好能捕捉这种秒级的时间模式再加上发作段样本稀缺、患者差异大手工特征工程很容易过拟合到某个中心的数据分布上端到端学习反而更容易泛化。这篇笔记写给正在做脑电信号处理、想从传统特征分类转向深度学习的工程师。你不需要提前准备复杂特征库只需要原始的EDF或CSV格式的EEG数据、一个PyTorch环境就能把发作检测模型跑起来。全文按“为什么可信、数据怎么切、模型怎么训、坑在哪、上线前怎么验”推进最后我会给出建议的基线参数和后处理规则。2. 一维卷积网络的感受野设计让卷积在脑电时间轴上找到发作放电2.1 一维卷积在EEG上到底看到了什么局部时间模式与幅度变化一维卷积核本质上是一组可学习的带通滤波器。EEG信号是幅值在几十微伏到几百微伏的时间序列卷积核在时间轴上滑动时它对窗口内的采样点做加权求和等价于对某一段频率范围做出响应。浅层卷积核学到的是棘波、尖波这种持续几十毫秒的瞬态波形深层卷积核通过堆叠把这些瞬态拼成“节律性放电-幅度爆发-波形逐渐走低”的整体发作模式。理解这一点很重要1DCNN并不关心某个通道的绝对幅值它关心的是局部波形的形状和相对幅度差。所以归一化策略必须保留这种局部对比不能把每个通道都压成单位方差否则棘波和背景噪声会变得无法区分。我见过一些团队把EEG当作图像那样做全局标准化结果模型对发作段的响应大幅钝化这就是没搞明白一维卷积在时间轴上看什么。2.2 感受野设计kernel尺寸、层数与采样率之间的关系感受野决定了一个输出点能看到多长的原始波形。EEG数据采样率常见250Hz或500Hz假设采样率250Hz、窗口长度2秒那么输入长度是500个采样点。把一维卷积核设为16步长1经过一层卷积和池化后感受野大概是21个点约84毫秒正好覆盖一个棘慢复合波再堆两层感受野能到100多个点约0.5秒足以看到2到3个发作波周期。给出一个通用计算方式第n层的感受野大约等于上一层的感受野加上“当前核大小-1乘以前面所有层的步长累积”。我一般会按这个表来推基线网络层卷积核池化核输入长度输出长度感受野采样点对应时长250Hz第1层Conv1d1625002422184ms第2层Conv1d16224211353212ms第3层Conv1d16211349117468ms如果要识别持续3到5秒的完整发作节律就得靠后续的全局池化层把所有窗口信息汇总。这里有个经验值卷积核覆盖的真实时间长度建议在50到100毫秒之间采样率翻倍到500Hz时同样的核大小对应的真实时间会减半所以必须调整kernel。直接把500Hz数据喂给为一个250Hz模型调好的网络等于让模型“近视”后期无论怎么调超参都很难救回来。2.3 基线1DCNN网络结构以BN、ReLU、MaxPool组成的最小可复现堆叠采用一个三层一维卷积堆叠做基线。模型吃进去的是原始EEG段形状是(N, C, L)其中C是脑电通道数L是采样点长度。中间每层依次做卷积、BatchNorm、ReLU和MaxPool。最后接全局平均池化和全连接层输出两类概率。import torch import torch.nn as nn class Base1DCNN(nn.Module): def __init__(self, n_channels, window_len, n_classes2): super().__init__() self.features nn.Sequential( # 第一层局部瞬态波形如棘波kernel16在250Hz下对应64ms nn.Conv1d(n_channels, 32, kernel_size16, stride1, padding7), nn.BatchNorm1d(32), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), # 第二层把相邻瞬态组合成节律模式 nn.Conv1d(32, 64, kernel_size16, stride1, padding7), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), # 第三层覆盖更长的背景上下文 nn.Conv1d(64, 128, kernel_size16, stride1, padding7), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), ) # 计算特征图长度window_len / 2^3再根据pad调整 conv_out_len window_len // 8 self.classifier nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, n_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x这段代码的padding7配合kernel16时能保持每层输出长度基本不变否则三层池化后长度缩得太快小窗口会直接退化。窗口长度必须是8的倍数才能整除窗口250Hz下2秒是500能被8整除没有问题。Dropout只放在最后的分类器前卷积层里不放假因为EEG通道数少卷积参数本来就不多加Dropout反而容易训练不稳定。需要注意这个模型没有条件偏置所以输入必须已经完成通道排序和坏导剔除这一点下一章会细说。3. 把原始EEG切成训练样本分段、对齐与归一化的落地细节3.1 发作段标注与滑窗分段2秒还是5秒癫痫发作标注一般由医生在长程EEG上标出起始和结束时间。模型要学的不是一整段发作视频而是“某一小段信号里是否存在发作模式”。所以需要做滑窗分段。窗口长度选2秒还是5秒直接决定标注对齐的粒度和样本量。如果窗口太长比如10秒一段标注为3秒的短暂发作在图谱上会被严重稀释正样本的标签近似随机噪声如果窗口太短比如0.5秒单个棘波就能触发检出伪影误报会非常多。我一般用2秒作为默认窗口。2秒在250Hz下就是500个点能覆盖多个棘慢复合波且发作起始的延迟控制在2秒以内符合临床报警的实时性期待。滑窗步长与窗口重叠需要谨慎设置常见做法是做50%重叠来扩充样本但重叠窗口会让相邻样本高度相似必须先按患者划分数据再滑窗否则同一条信号会同时落进训练集和验证集后面的性能评估全部失真。这里有一份窗口与数据量估算参考表窗口长度重叠比例每小时样本数适用场景2秒0%1800基线评估无泄漏2秒50%3600数据增强需按患者分组5秒0%720片段级识别延迟较高3.2 电极通道与数据形状从EDF到(N, C, L)张量的整理路径原始EEG数据通常以EDF格式存储里面包含几十个通道、采样率和标注事件。使用MNE库读取后先剔除坏通道和持续平坦的通道然后提取需要的时间段。注意保留原始时间序列不需要做小波去噪或带通滤波之外的过度平滑。带通滤波可以设置0.1到70Hz再加一个50Hz陷波这能滤掉工频干扰但不要动30到70Hz范围——肌电伪影和部分高频棘波在这个频段重叠过度滤波会直接把发作特征抹掉。import numpy as np import mne def edf_to_windows(edf_path, onset_sec, offset_sec, window_sec2): raw mne.io.read_raw_edf(edf_path, preloadTrue) raw.pick_types(eegTrue, eogFalse, stimFalse) # 坏通道剔除保留有效脑电通道 raw.drop_bad_channels() # 带通滤波0.1Hz高通去掉基线漂移70Hz低通去掉高频噪声 raw.filter(0.1, 70, methodfir, fir_windowhamming) # 提取发作段与非发作段 event_data, _ raw[:, int(onset_sec * raw.info[sfreq]):int(offset_sec * raw.info[sfreq])] # 滑动窗口切分event_data形状为(C, L) sfreq raw.info[sfreq] win_len int(window_sec * sfreq) windows [] for start in range(0, event_data.shape[1] - win_len, win_len // 2): windows.append(event_data[:, start:start win_len]) return np.stack(windows, axis0), sfreq这段代码把EDF读成(C, L)的矩阵再用半窗步长滑动切片。这里有几个参数要说明drop_bad_channels需要配合手工记录使用完全靠自动算法会误删真正的发作通道filter的fir_windowhamming比默认窗泄漏更少滑窗步长是win_len的一半如果不想做重叠扩样把步长改成win_len即可。输出的窗口数组需要再转成(N, C, L)浮点张量标签从onset和offset中生成同长度的0/1标记再做一次时间对齐保证窗口标签和信号采样点对齐。3.3 归一化策略幅度缩放、通道独立归一化与全脑归一化的差别归一化是1DCNNforRawTimeSeries最容易翻车的环节。EEG的绝对幅值受电极阻抗、头皮厚度影响很大同一个患者不同天的记录可能整体幅值差好几倍。如果直接喂原始微伏值模型会试图用幅值判断发作换一个患者就失效。最稳妥的全局做法是用全脑z-score归一化把所有样本的幅值均值归零、标准差归一。计算公式是(信号-全体均值)/全体标准差这个统计量在整个训练集上计算验证集复用训练集的均值和标准差避免信息泄漏。反对按通道独立归一化。原因很直接发作时通常是多个通道同时出现节律性放电通道间的相对幅度关系是重要模式。按通道归一化后背景噪声和发作放电在各自通道内都被压成相似分布等价于告诉模型“所有通道的幅值变化同等重要”这会显著增加误检率。也有团队用绝对中位值缩放这类方法对离群尖波更稳健实际效果和z-score接近看具体数据分布。我现在的习惯是训练时先全局z-score再做幅度裁剪限制在±5个标准差内防止个别通道的突发漂移主导loss。4. 从零跑通癫痫发作检测PyTorch实现1DCNN的完整流程4.1 数据加载与批量采样代码预处理完成后数据通常保存为numpy数组形状为(N, C, L)还有一份对应的(N,)标签数组。训练数据要考虑类别不平衡发作段在长程EEG中的占比通常不到10%少数患者甚至只有1%。直接随机采样会让模型学会把多数类全部判为正常。需要写一个能按类别比例采样的Dataset。import numpy as np import torch from torch.utils.data import Dataset class EpochWindowDataset(Dataset): def __init__(self, windows, labels, balance_ratio0.5): # windows: (N, C, L) float32 # labels: (N,) 0表示正常1表示发作 self.X torch.from_numpy(windows).float() self.y torch.from_numpy(labels).long() self.pos_idx np.where(labels 1)[0] self.neg_idx np.where(labels 0)[0] self.pos_num len(self.pos_idx) self.neg_num len(self.neg_idx) self.balance_ratio balance_ratio def __len__(self): # 以正样本数量决定epoch轮次 return max(self.pos_num * 2, 64) def __getitem__(self, idx): rng np.random.default_rng() # 按平衡比例决定这轮采样正样本还是负样本 if rng.random() self.balance_ratio: i int(rng.integers(0, self.pos_num)) sample_idx self.pos_idx[i] else: i int(rng.integers(0, self.neg_num)) sample_idx self.neg_idx[i] return self.X[sample_idx], self.y[sample_idx]这个Dataset通过balance_ratio控制正样本出现概率。批量训练时设置batch_size32一个batch里正负样本比例大致为1:1或者2:3而不是天然数据的1:9。正样本太少时需要配合数据增强常见做法是在窗口上做窄幅平移、幅值抖动和通道子采样。数据增强要在加载时完成不能直接改原始数组否则多条样本共享同一块内存模型学到的模式会被重复放大。4.2 模型定义与训练循环训练循环使用AdamW优化器配合带权重的交叉熵损失。类别权重直接用正负样本数量的倒数比。学习率我一般从1e-3起步每5个epoch验证一次如果验证损失不下降就乘以0.5。最大epoch数30到40足够EEG数据集样本数通常几万到几十万深过头就会过拟合。import torch.nn as nn import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model Base1DCNN(n_channels19, window_len500).to(device) pos_count len(ds.pos_idx) neg_count len(ds.neg_idx) weight torch.tensor([neg_count / pos_count, 1.0]).float().to(device) criterion nn.CrossEntropyLoss(weightweight) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience2) for epoch in range(30): model.train() train_loss 0 for xb, yb in dataloader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() optimizer.step() train_loss loss.item() # 验证 model.eval() val_loss validate(model, val_loader, criterion) scheduler.step(val_loss) print(fepoch {epoch}: train_loss{train_loss:.4f}, val_loss{val_loss:.4f})这段代码里需要重点说明两个参数weight的构造是反直觉的正类权重更大因为正样本少criterion的weight与logits一起传入后自动计算加权平均不需要再手动乘以pos_count。weight_decay设1e-4即可太大会把BatchNorm的gamma拉向零导致模型容量下降。scheduler在验证损失不降时自动降低学习率这是癫痫检测场景下最有效防止loss震荡的手段。4.3 结果指标怎么看敏感性、特异性与AUC在类别不平衡下的意义癫痫发作检测只看accuracy是典型误判。在一个正样本只占5%的数据集里模型全输出负样本准确率也能到95%看上去很远实际毫无临床价值。正确指标是敏感性召回发作段的比例、特异性正常被误报的比例和AUC阈值无关的综合排序能力。正向模型需要在敏感性和特异性之间平衡一般用Youden指数选阈值。指标计算公式这个场景下的含义敏感性RecallTP/(TPFN)漏报的发作段比例漏报代价高特异性SpecificityTN/(TNFP)误报比例ICU场景误报会疲劳AUC排序统计量反映模型对正负样本排序能力与阈值无关计算敏感性和特异性时需要先对输出logits做argmax或者按阈值判决不能直接拿概率和标签比。还要注意按患者计算而非按窗口计算同一个患者多个窗口的误报会被重复放大最终给临床上报的是患者级或分钟级报警直接用窗口级指标会让产品预期和实测偏差很大。我习惯在报告里同时给窗口级和患者级两套指标部署时以后者为准。5. 癫痫检测模型的避坑记录数据泄漏、阈值失效与网络退化三类高频翻车5.1 滑动窗口重叠导致数据泄漏同段信号同时出现在训练集与测试集现象验证集AUC高达0.98上线后发现面对新患者时敏感性和特异性同时塌陷几乎回到随机水平。原因滑窗按50%重叠切分然后直接在全部窗口里随机划分训练集和验证集重叠窗口的高相似度让模型在训练时已经“看过”验证数据的几乎相同版本。解决必须先把患者分组同一患者的全部窗口只进训练集或验证集组内窗口重叠不跨集。# 错误示范先切窗口再随机划分 # X_train, X_val train_test_split(windows, test_size0.2, random_state42) # 正确示范按患者留出 patient_ids np.array([window_meta[i][patient_id] for i in range(len(windows))]) unique_patients np.unique(patient_ids) train_patients, val_patients train_test_split(unique_patients, test_size0.2, random_state42) train_idx np.isin(patient_ids, train_patients) val_idx np.isin(patient_ids, val_patients)这段划分逻辑的关键是patient_ids的数量远小于窗口数量但它是独立的划分单元。随机种子固定后不同实验之间可比换患者数少的数据集比如只有5个患者留一患者交叉验证比一次划分更可靠。我踩过这个坑之后所有EEG实验都采用患者级留出窗口重叠只用于数据增强绝不过滤划分边界。5.2 全局固定阈值失效不同患者的发作幅度差异现象在训练集上挑出的最优阈值比如概率大于0.5判为发作在患者A身上表现正常到患者B身上误报率猛增。原因不同患者的背景EEG幅值、发作波形态差异很大模型对每个患者输出的概率分布整体偏移全局阈值在a患者身上处于分布右尾在b患者身上可能位于分布中部。解决做患者级自适应性阈值用该患者的前几分钟正常脑电作为参考基线动态调整判决阈值至少做数据层面上的通道幅值归一化来削弱患者间偏移。Youden指数选阈值的做法是在验证集上扫描所有可能阈值敏感性减去特异性最大的点就是最优阈值。这个方法比固定0.5更合理尤其适合类别不平衡。但要注意阈值的有效性仅在验证集所在数据分布内成立跨患者部署时必须用患者基线段重新校准这也是把所有患者数据混在一起做阈值选择会翻车的原因。更稳健的做法是模型输出平滑概率后再阈值化跳跃的单窗口输出不可信常见做法是取最近10个窗口均值。5.3 BatchNorm在短窗口与病人组不均衡时的退化现象训练过程前期loss正常下降中期开始验证集指标波动剧烈最终val_loss不降反升。原因BatchNorm依赖batch内统计量当batch中正类样本过少、batch尺寸不足16时batch统计量噪声大训练与验证时行为不一致模型在短窗口256点左右和小batch8组合下尤其严重。解决保证bataion容量至少32并用正负样本平衡采样如果显存限制导致batch无法加大把BatchNorm1d换成GroupNorm分组数设为8不依赖batch统计量训练稳定得多。# 替换 BatchNorm1d 为 GroupNorm 的示例 nn.GroupNorm(num_groups8, num_channels32)这里num_groups8意味着每4个通道一组计算统计量。Channel数不是8的倍数时会报错常见通道数19时这里需要把第一个卷积输出通道从32调成24或40。这个换法不需要修改其他部分损失的收敛曲线一般会比BN更平缓。我建议在短窗口、小样本、跨受试者变异大这三种场景下直接默认用GroupNorm而不是BatchNorm。5.4 采样率不一致250Hz与500Hz数据混用造成的感受野失真现象模型在混合数据集上训练同一形态的棘波出现在两个患者身上一个被检出另一个被忽略。原因两个患者数据分别来自250Hz和500Hz的采集设备原始时间序列长度相同但对应真实时间不同固定卷积核看到的时间窗长度不一样。解决所有数据统一重采样到同一采样率再做滑动窗口无法统一时kernel大小按采样率比例调整250Hz用kernel16500Hz用kernel32。import mne def resample_raw(raw, target_sfreq250): # 统一采样率同时保留标注事件的时间轴 raw.resample(sfreqtarget_sfreq, npadauto) return rawMNE的resample会同步更新事件标注时间不会出现标注与数据错位的情况。混用采样率的另一个隐含问题是滤波器的截止频率也需要与采样率匹配统一采样率之后一切配置都能沿用250Hz下调好的参数不需要为不同采样率维护两套网络配置。5.5 锐波误报把睡眠纺锤波、肌电影像当作发作现象模型在白天清醒数据上表现正常在夜间睡眠数据上频繁报警轻点开波形发现大量“尖锐”但不是癫痫的波形。原因单窗口2秒内模型只看局部形态睡眠纺锤波、眨眼肌肉伪影、电极接触不良产生的锐波与癫痫棘波在400到1000毫秒的形态上非常相似。解决引入发作的上下文先验——真实发作通常持续数秒且多通道同步出现所以模型输出后做一段后处理要求持续超过3秒才报警。def postprocess(probs, threshold0.5, min_duration_sec3.0, sfreq250): preds (probs threshold).astype(int) min_len int(min_duration_sec * sfreq) # 连续报警长度不足min_len的片段全部抑制 out preds.copy() start 0 for i in range(len(preds) 1): if i len(preds) or preds[i] ! 1: if i - start min_len: out[start:i] 0 start i return out这里的min_duration_sec是一个可调业务参数。发作报警不是越灵敏越好ICU场景里误报会拉低医护人员的信任度我一般先用min_duration3秒做默认如果敏感性偏低再降为2秒如果误报偏多就升到5秒。另外这类误报可以通过多通道一致性来抑制发作放电通常多个通道同步出现而肌电伪影往往只有少数通道能量突出。给模型加一个“至少两个通道同时激活才报警”的规则约束是成本最低但效果显著的后处理手段。6. 进阶验证与效率调优多尺度卷积、可解释性输出与部署前检查6.1 多尺度核与注意力池化单个kernel16的模式只能覆盖一类波形形态实际EEG存在早、中、晚不同阶段的发作模式有的棘波尖锐有的慢波平滑。多尺度卷积在同一层并行使用大小不同的卷积核比如kernel8、16、32三个分支分别捕捉50毫秒以内的瞬态、100毫秒左右的节律波、以及更长的背景慢波最后把三路特征在通道维拼接。这个结构对脑电分类尤其有效因为发作模式本身就横跨多个时间尺度。但多尺度核的参数数量会增加约2倍调整时要注意感受野不要超出窗口长度500点的窗口最大的核32仍安全如果窗口只有128点最大核建议降到16。注意力池化可以替代全局平均池化用网络自己学出的权重决定哪一段时窗的波形对最终分类贡献更大。典型做法是对卷积层输出的特征图在时间维做加权平均权重由一个小型全连接层产生。这比简单GAP更贴合癫痫检测的语义发作可能在窗口内的中间段持续全局平均池化会把前0.5秒的正常背景和后面1.5秒的发作放电平均在一起导致发作信息被稀释。注意力池化则能自动把权重集中到放电明显的时段。6.2 患者独立交叉验证与性能-延迟平衡最后一道验证推荐用留一患者交叉验证。对每个患者用其余患者训练在当前患者测试把所有患者的结果拼在一起算总体指标。相比单次划分这个策略能更真实反映“模型从未见过这个人的脑电”时的表现代价是训练次数多假设有10个患者就要训练10轮。为了控制时间每折可以只做15个epoch早停用验证患者集的AUC控制。新患者加入时只需重跑其对应的一折不需要全量重训。部署层面的验证指标是延迟和内存。250Hz下2秒窗口为500点三层卷积、32到128通道的模型在CPU上推理单条样本大约需要1到3毫秒GPU上可以忽略不计瓶颈一般在预处理和后处理。常见做法是用torch.onnx.export导出ONNX再用ONNX Runtime跑推理能比PyTorch动态图快2到3倍并减少依赖体积。model.eval() dummy_input torch.randn(1, 19, 500) torch.onnx.export( model, dummy_input, eeg_1dcnn.onnx, input_names[eeg_window], output_names[prob], dynamic_axes{eeg_window: {0: batch, 2: window_len}} )这段代码把模型导出成静态格式注意dynamic_axes里声明了窗口长度可变化这样不同采样长度比如400点或512点整的窗口都能直接推理。部署前我还会做最后一项检查——把模型对验证集发作段的输出概率全部导出肉眼抽查概率排名前20的样本。这一步看起来费时间但能发现很多指标上看不出的问题比如某一位患者的直流漂移被误认为节律波。我现在的习惯是每次换网络结构都会做一轮这种人工抽查它比tensorboard曲线更可靠。关于阈值校准还有个实用技巧在验证集上生成所有患者的输出概率分布按每个患者单独计算90分位数当作自适应阈值基线比全局阈值在跨中心数据上效果稳得多。把这个阈值策略写进部署配置面对新患者时先采集5分钟平静段数据做初始化之后随着数据流动态更新误报率通常能在原有基础上再降30%。希望帮到你。本文还有配套的精品资源点击获取