
拿到CHB-MIT癫痫数据集做CNN分类是很多刚接触脑电EEG深度学习的同学的第一道坎。我当初踩过不少坑最深的体会是模型本身并不是最大的障碍真正麻烦的是“数据怎么读、标签怎么对准、训练集怎么切”。CHB-MIT作为公开的癫痫脑电数据集包含了波士顿儿童医院23位癫痫患者的长时间连续记录配合CNN来做发作检测/分类几乎是该领域入门最经典的一条技术路线。这篇是系列的第一篇目标很明确把数据链路完整跑通训练一个能用的CNN基线模型并且把所有关键选择背后的原因说清楚。如果你正准备拿EEG做深度学习分类这篇可以帮你少走大半年的弯路。1. 项目定位为什么选CHB-MIT和CNN1.1 EEG分类要解决的实际问题EEG脑电图是通过头皮电极记录大脑皮层电活动的信号时间分辨率极高临床上广泛用于癫痫诊断、睡眠分期、脑机接口等场景。癫痫发作时大脑神经元会同步异常放电在EEG上表现为棘波、尖波、棘慢复合波等特征波形。传统做法是让神经科医生盯着几十个小时的连续记录肉眼找发作段费时费力且不同医生判读一致性有限。用CNN自动分类的目的就是代替人工完成“发作段 vs 非发作段”的判别这也是CHB-MIT数据集最主流的研究方向。在动手之前你需要清楚一件事这里说的“分类”本质上是时间序列上的二分类——给定一段固定长度的EEG窗口判断它属于发作期ictal还是非发作期interictal/normal。把连续信号切成窗口再逐个分类是最常见、最容易上手的方案也是后续做事件检测、发作预警的基础。1.2 CHB-MIT数据集的家底CHB-MIT数据集由美国波士顿儿童医院公开收录了23位名义上24位其中chb12和chb13是同一人两次记录药物难治性癫痫患者的头皮EEG数据。整个数据集约844小时连续记录包含198次左右的发作事件。文件格式是EDFEuropean Data Format每个文件对应一段几十分钟到几小时的连续记录采样率为256Hz通道数从16到19不等参考电极和通道排列在不同患者间存在差异。有一点需要特别注意CHB-MIT并不是所有EDF文件里都内嵌了标准标注很多文件的发作起止时间是通过独立的文本摘要文件比如chb01-summary.txt记录的。你需要在读取EDF数据的同时手动解析这些摘要文件把发作起止时间对齐到对应的文件上。这一步骤极其容易出错一旦标签对齐错位模型再花哨也白搭。1.3 为什么CNN能用来做EEG分类很多人一听“脑电信号是时间序列”第一反应是用LSTM或Transformer。但在EEG分类任务上CNN依然是性价比最高的基线模型原因有三一是CNN的局部感受野天然适合提取EEG中的短时波形模式比如持续几十到几百毫秒的棘波卷积核在时间维度滑动就等价于用一组可学习的滤波器扫描信号这和EEG分析里的带通滤波、小波变换等在思路上有异曲同工的地方二是CNN参数共享让模型规模远小于全连接网络不容易在小数据集上过拟合三是实现和调参相对成熟训练稳定适合做基线。CHB-MIT的数据量相比图像领域的百万级数据集并不算大因此浅层CNN往往比深层网络更实用。一维卷积直接作用在时间轴上把多通道当作输入通道是最自然的做法。本系列就采用这种路线窗口切片 → 一维CNN → 二分类输出。2. 数据预处理从EDF文件到训练张量2.1 读取EDF文件与事件标注工欲善其事必先利其器。预处理代码建议用MNE-Python库它对EDF格式支持很好直接一步到位。不过CHB-MIT的标注文件是独立的所以核心读取逻辑要分两步。import mne import numpy as np import re def read_edf(file_path): raw mne.io.read_raw_edf(file_path, preloadTrue, verboseERROR) return raw def parse_summary(summary_path): # chb01-summary.txt 里记录了每个文件中的发作起止时间单位是秒 with open(summary_path, r) as f: content f.read() file_seizures {} current_file None for line in content.splitlines(): m_file re.match(rFile Name:\s*(\S), line) if m_file: current_file m_file.group(1) file_seizures[current_file] [] m_seiz re.match(rSeizure Start Time:\s*([\d.])\s*seconds, line) m_end re.match(rSeizure End Time:\s*([\d.])\s*seconds, line) if m_seiz: start float(m_seiz.group(1)) file_seizures[current_file].append([start, None]) if m_end and current_file: file_seizures[current_file][-1][1] float(m_end.group(1)) return file_seizures这段代码做的事情很简单把每个EDF文件对应的发作起止时间段解析成一个字典。注意有些文件里有多次发作所以要按列表追加。在实际使用中我一般还会加一层校验确保发作结束时间大于开始时间并且不超过文件的真实时长。mne读取EDF后raw.annotations里其实也有一些信息但CHB-MIT的EDF文件经常不带发作标注只有背景和测试信号标记所以不要依赖annotations老老实实解析summary文件最靠谱。2.2 滤波、降采样与坏道处理原始EEG信号里干扰很多比如50/60Hz工频干扰CHB-MIT是美国数据工频是60Hz、肌电伪迹、基线漂移等。CNN虽然理论上能自己学出有效特征但提前做合理的频带限制可以显著降低模型的学习难度也能减少噪声过拟合。raw.filter(0.5, 50, fir_designfirwin, verboseERROR) raw.notch_filter(60, verboseERROR)这里带通滤波选0.5~50Hz原因是癫痫相关的异常放电主要分布在这个范围内而0.5Hz以下的多是基线漂移50Hz以上的多是肌电伪迹。采样率256Hz奈奎斯特频率是128Hz所以50Hz低通完全安全。陷波滤波放在60Hz这是北美地区的工频频率——如果你用的是国内采集的EEG数据陷波频率应该改成50Hz这一点特别容易搞错。坏道处理容易被忽略。CHB-MIT某些文件的部分通道存在长时间饱和、平直线或极端高幅值的坏段。我的做法是先检查每个通道的方差和峰值如果某个通道在整个文件里方差为0或明显低于其他通道一个数量级就标记为坏道在分段后直接丢弃该通道数据。不要试图差值填补因为对分类任务来说与其补出不真实的数据不如让模型适应“缺了一个通道”的输入模式当然前提是所有样本保持同一组通道。2.3 滑动窗口切片与标签生成窗口长度和滑动步长是EEG分类里两个最关键的参数。窗口太短比如0.5秒单窗信息量不足窗口太长比如10秒样本数变少而且发作起止边界处的混合窗口会污染标签。我实测下来2秒到5秒是比较合理的区间。这里选4秒对应1024个采样点既能覆盖几个完整的棘慢波周期又不会把非发作段误标成正样本。window_sec 4 window_len int(256 * window_sec) step_sec 2 step_len int(256 * step_sec) def make_windows(raw, seizures, file_duration, eeg_data_np): labels [] windows [] times np.arange(0, file_duration - window_sec, step_sec) for t in times: start int(t * 256) end start window_len is_seizure False for (s_start, s_end) in seizures: if s_end t or s_start t window_sec: continue # 窗口与发作段的交集占比超过阈值才算正样本 overlap min(s_end, t window_sec) - max(s_start, t) if overlap 0.5 * window_sec: is_seizure True break labels.append(1.0 if is_seizure else 0.0) windows.append(eeg_data_np[:, start:end]) return np.array(windows), np.array(labels)标签生成上有个容易犯的错直接用“窗口起点是否落在发作段内”来判断标签。如果发作从窗口中段开始窗口前半段还是正常脑电模型会学到很混乱的边界模式。我的做法是算窗口与发作段的交集交集占比超过窗口长度的50%才标为发作窗口。这么做虽然会让边界处的窗口标签偏保守但训练出来的模型稳定得多。步长选2秒窗口4秒也就是相邻窗口有50%重叠。重叠切片在小数据集上相当于一种隐式的数据增强能让训练样本更充足。但要注意重叠窗口会让相邻样本高度相关这在划分训练集和测试集时是个隐患稍后细说。2.4 数据集划分别让数据“穿帮”CHB-MIT是患者级别的数据不同患者的EEG波形差异非常大。如果按窗口随机划分训练集和测试集同一个患者的相邻窗口会同时出现在两边模型相当于“见过”测试数据的邻近片段测试指标会虚高得很厉害。正确做法是按记录文件或者按患者划分。我建议第一阶段先做患者独立patient-independent的简化版本从23个患者里挑出2~3个患者的全部数据作为测试集其余患者用于训练和验证。这样评估的是模型的跨患者泛化能力更能反映真实临床场景。代码上先按患者分组再在患者内部把连续的记录文件按7:3切出训练和验证集测试集完全独立。训练集患者A、B、C...排除测试患者 验证集从训练患者中抽取连续记录段 测试集患者X、Y完全未参与训练这里还有一个细节同一个患者的多个EDF文件可能是连续采集的文件之间的时间间隔很小。所以按文件划分时训练和验证的文件若在时间上相邻也会有数据泄露风险。稳妥的做法是优先选择不同日期的文件做验证集如果实在分不开接受轻度泄露也可以但报告指标时要心里有数。在专业论文里这一步通常用留一患者交叉验证LOSO来做不过作为基线实验先简单划分就够用了。3. CNN基线模型从零搭建并解释每个选择3.1 模型结构设计思路EEG的一维CNN模型没必要一上来就堆ResNet。我常用的基线结构分四层第一层用较大的卷积核捕捉基本波形第二、三层缩小卷积核提取更细的模式最后接全局平均池化和全连接层输出二分类概率。这里给出一个PyTorch实现输入张量形状是(batch_size, 通道数, 时间点数)以16通道、每个窗口1024个采样点为例。import torch import torch.nn as nn class EEGCnnBaseline(nn.Module): def __init__(self, n_channels16, n_samples1024, n_classes1): super().__init__() self.features nn.Sequential( nn.Conv1d(n_channels, 32, kernel_size65, stride2, padding32), nn.BatchNorm1d(32), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size4, stride4), nn.Conv1d(32, 64, kernel_size15, stride1, padding7), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size4, stride4), nn.Conv1d(64, 128, kernel_size5, stride1, padding2), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(1), ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, n_classes), ) def forward(self, x): return self.classifier(self.features(x))第一层卷积核大小定为65个采样点换算成时间大约是0.25秒。为什么是0.25秒因为癫痫棘波、尖波的典型时宽是70~200毫秒左右一个0.25秒的卷积核刚好能覆盖一个完整的棘波形态太大则分辨率不够太小则容易过拟合于高频噪声。stride2配合后续的池化把1024个采样点逐级压到特征长度很小最后用AdaptiveAvgPool1d(1)把所有信息汇聚成一个128维向量。这个设计在信号长度变化时还有一定自适应能力换个数据集窗口长度变了也能跑。3.2 关键参数计算不要觉得抄个网络结构就能跑你得能算清楚每一层之后的特征形状变化否则改窗口长度时一问三不知。以窗口1024点、16通道为例逐层推演如下。第一层卷积kernel_size65stride2padding32所以输出长度是 (1024 2×32 - 65) / 2 1 512。通道数变为32。MaxPool1d(kernel_size4, stride4)之后长度变成128。第二层卷积kernel_size15stride1padding7输出长度保持128通道数变成64。再一次MaxPool后长度变为32。第三层卷积kernel_size5padding2长度仍是32通道数128。最后AdaptiveAvgPool1d(1)把时间维度压成1得到128维向量。整体来看感受野是逐步放大的第一层约65个采样点0.25秒经过两层池化和卷积后后面每层看到的实际信号范围已经覆盖数秒级别这对于判断整段窗口是否为发作足够了。参数量方面第一个卷积层有16×32×6532个参数约3.3万整个模型加起来不超过15万在CHB-MIT这种小数据集上不容易过拟合。3.3 训练配置详解训练策略直接决定模型能否收敛。优化器我选Adam初始学习率1e-3配合ReduceLROnPlateau当验证集损失连续3个epoch不下降时就把学习率乘以0.5。Batch size设64epoch 30。损失函数用BCEWithLogitsLoss这在输出层不加Sigmoid时直接计算二分类交叉熵数值上更稳定。def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() losses [] for X, y in dataloader: X, y X.to(device), y.to(device).unsqueeze(1).float() optimizer.zero_grad() logits model(X) loss criterion(logits, y) loss.backward() optimizer.step() losses.append(loss.item()) return np.mean(losses)这里有个不太起眼但很关键的细节虽然标签是二分类0/1但CNN输出的是线性logits不经过Sigmoid。评估模型时阈值默认取0.5也就是说sigmoid(logits) 0.5判为发作。但实际做EEG分类时0.5往往不是最优阈值因为正负样本不平衡后面会专门讲怎么调阈值。还有就是每次训练前要把DataLoader的shuffle设为True并且固定随机种子方便复现。4. 训练流程与评估指标4.1 损失函数选择在EEG发作分类任务里BCEBinary Cross Entropy是最常用的损失函数。它把每个窗口独立看待输出一个概率非常适合“整窗是否发作”这种建模方式。如果你后续要做序列标注或者事件级别检测再换成带时序建模的损失函数才对。不要一上来就用Focal Loss之类的“高级货”基线阶段BCE足够而且方便和别人的结果对比。等遇到严重的类别不平衡且欠采样之后仍然效果差时再考虑Focal Loss也不迟。4.2 评估指标准确率之外还要看什么初学最容易犯的错误是只看准确率。但癫痫发作段在整段记录里占比非常低可能不到2%。一个“永远输出非发作”的傻瓜模型准确率都能到98%以上完全没意义。所以必须引入对不平衡更敏感的指标召回率Sensitivity、特异度Specificity、F1分数和AUC。指标含义为什么重要准确率 Accuracy全部样本中预测正确的比例类别不平衡时参考价值低召回率 Sensitivity/Recall发作窗口中被正确检测出的比例漏检发作在临床上不可接受最关键特异度 Specificity非发作窗口中被正确判负的比例误报过多会导致模型无法实用F1 Score精确率和召回率的调和平均综合衡量查准和查全AUCROC曲线下面积不依赖阈值衡量模型排序能力实际训练完我会先打印AUC和F1再看混淆矩阵。AUC高而F1低说明阈值没调好F1高而AUC一般说明模型本身判别力有限。两者结合才不会自我感觉良好。4.3 一次实际训练日志解读跑一个30轮的基线训练正常情况下你能看到训练损失从0.6左右一路降到0.1以下验证损失先降后可能反弹。如果验证损失在10轮之后开始上升训练损失还在降那就是过拟合了。此时先不要急着加数据增强检查一下训练集和验证集是否来自同一个患者、有没有窗口重叠泄露通常这才是验证损失“虚低”的元凶。以我实际跑的一个患者独立划分实验为例训练集约4000个发作窗口和4000个非发作窗口欠采样后验证集500个窗口测试集选择两个从未参与训练的患者约2000个窗口。训练20轮后验证集AUC达到0.93但测试集AUC只有0.81。差距说明跨患者的泛化确实更难EEG个体差异很大同样的发作波形在不同患者上表现不一。这个0.81的AUC作为基线是合格的后面所有模型改进都要围着它比较。阈值调整经验测试集上先用验证集找到F1最大的阈值比如0.3或者0.6再去套测试集。千万不要拿测试集反复调阈值那等于在测试集上做参数拟合指标会虚高。5. 常见问题与避坑记录5.1 类别不平衡怎么处理CHB-MIT里发作窗口和非发作窗口的比例可能有1:100甚至更夸张。我的经验是先用欠采样把训练集的正负比控制在1:1到1:2之间这是最直接有效的办法。缺点是会丢掉大量非发作数据但CHB-MIT本身记录时长够长欠采样后样本量依然足够。不要在一开始就尝试复杂的数据增强先把基线跑稳。加时间扰动、幅值缩放、通道置换这些增强手段留到后续优化时再上。还有一个细节不要对整个数据集做全局欠采样而是先按患者分组在每个患者内部做欠采样。否则可能出现某个患者的非发作段全被丢掉模型完全没见过这个患者的正常脑电形态泛化更差。5.2 数据泄露EEG分类最容易踩的暗坑除了前面说的按患者划分、避免重叠窗口外还有一个隐蔽的泄露渠道标准化。如果你先在全数据集上计算均值和标准差再分训练测试集那测试集的统计信息就已经泄露到训练过程中了。正确写法是只在训练集上统计均值方差用它去标准化验证集和测试集。train_mean train_windows.mean(axis(0, 2), keepdimsTrue) train_std train_windows.std(axis(0, 2), keepdimsTrue) 1e-6 train_windows (train_windows - train_mean) / train_std val_windows (val_windows - train_mean) / train_std test_windows (test_windows - train_mean) / train_std另一种泄露是人类直觉上的某个患者的训练文件和测试文件是同一段连续记录拆开的两边的窗口大量重叠。我在第一次实验里就吃过这个亏验证集AUC一度高达0.99后来发现是文件切分太粗糙。教训就是宁可训练集少一点也要保证数据独立。5.3 通道数量不一致、通道顺序错乱CHB-MIT不同患者、不同文件的通道数不一样多的19通道少的16通道。训练时输入通道必须固定。我的做法是选一个固定的通道子集比如统一取FP1-F7、F7-T7、T7-P7、P7-O1等16个常见导联如果一个文件缺某个通道就放弃这个文件。听起来有点浪费数据但其实对基线实验来说稳定的输入格式比多几个样本更重要。通道顺序也必须保持一致。MNE读取EDF时返回的通道顺序可能和文件里不一样如果你按索引切片两次读取同一文件顺序不同就乱了。解决办法是显示指定ch_names顺序用raw.reorder_channels(selected_channels)固定顺序。5.4 报错速查表最后整理几个高频率报错和对应处理都是我实际运行中遇到过的。报错信息原因解决办法ValueError: Channel location not available某些通道名无法识别设置MNE的montage为标准10-20系统或直接传montageNoneIndexError: too many indices for array窗口切片时数组维度不对确认eeg_data_np是(通道数, 时间)的二维数组而不是三维RuntimeError: size mismatch for Linear全连接层输入维度和特征长度不匹配用AdaptiveAvgPool1d兜底或打印中间层shape调试CUDA out of memorybatch_size或卷积通道数过大减小batch_size到32或16或减少第一层通道数NaN loss学习率太大或数据含NaN检查坏道是否被填充为NaN降低学习率到1e-4其中NaN loss这个问题我记忆中是最折腾人的。CHB-MIT个别文件里确实存在整段数据为0或信号严重饱和的情况如果不做坏道检测NaN或极端值传到模型里训练一轮就崩。所以我的建议是数据预处理阶段就做好坏道和方差检查训练前再打印一次数据的最小值和最大值确保没有极端异常值。实操过程中还有一个让我印象深刻的细节Mind the epoch size。CHB-MIT一个患者的发作次数很少比如chb01有7次发作但chb10可能只有3次。如果用全量数据做训练每次迭代里正样本非常有限模型很容易学成一个“保守派”——永远预测非发作。我后来把训练集的负样本下采样到和正样本等量模型才真正开始学发作特征。可以这么说CHB-MIT加CNN这个组合入门EEG深度学习核心在于数据工程而非模型创新。把EDF文件读明白、把summary标注解析清楚、把窗口和划分策略设计稳妥基线模型自然能跑出像样的结果。下一篇我会在这个基础上试试更精巧的网络结构和数据增强策略重点看能不能把跨患者的AUC从0.81再往上提一截。如果你现在按这篇文章把你的第一版模型跑通了你会发现后面所有优化都是在给一个干净、可靠的数据管道上添砖加瓦。