CHB-MIT脑电数据集处理全攻略:从EDF解析到癫痫检测建模 简介CHB-MIT脑电数据集是麻省理工学院与波士顿儿童医院合作建立的癫痫EEG公开资源广泛用于癫痫发作检测、特征提取与机器学习算法验证。这份压缩包正是围绕该数据集的实用代码包共145个文件以141个MATLAB脚本为主、4个Python脚本为辅整体仅159KB轻量易部署。MATLAB脚本针对CHB01/CHB03等多位受试者提供样本熵等特征计算与发作期对比实现可直接复现脑电信号分析流程便于横向比较不同受试者的发作特征Python脚本则可衔接预处理或分类建模适合生物医学信号处理、模式识别方向的研究者与中高阶开发者。包内脚本覆盖多位受试者与多种发作状态可在此基础上调整参数、扩展至癫痫检测分类任务。资源面向需要在实际EEG数据上验证算法的人群已有832人学习下载是进入癫痫脑电分析、开展跨学科研究的实用起点。1. CHB-MIT 数据集到底是什么为什么做癫痫检测的人都绕不开它第一次在论文里看到 CHB-MIT 这四个字母很多人以为是什么国产工具包实际上它是脑电EEG领域流传最广的公开癫痫头皮数据集全称可理解为某儿童医院与某理工学院合作的记录成果收录了几十位癫痫患者长期监测的头皮脑电信号。凡是做发作检测、发作预测、脑电分类的研究者几乎都拿它当基准数据跑实验。标题里的「CHB-MIT数据集.rar」解压后就是一批 EDF 原始信号文件和配套的发作标注文件。但我要先泼一盆冷水这个数据集与其说是开箱即用不如说是一个需要仔细处理的原始仓库。它的标注和信号是分离存储的采样率不统一、导联顺序不统一、发作时间长短悬殊直接按图像分类那套流程处理会踩一连串坑。这篇笔记的意义正在于此从解压rar开始一步步把数据变成能喂给模型、能给出可信指标的训练样本。如果你只是想快速验证一个模型结构或者需要跑论文里的对比实验这篇笔记能帮你少走弯路如果你打算直接把它当标准数据集开训我建议先读完第 5 章的避坑清单再动手。下面的内容覆盖数据组织方式、Python 读取流程、训练集制作和评估口径按顺序读就能直接跟着操作。2. 数据集的目录结构与标注体系从 EDF 文件到 .summary 的元数据2.1 目录组织与命名规则chb01 到 chb24 的作用解压后你会看到一系列以chb开头、两位数字结尾的文件夹这是按患者编号组织的常见编号从 01 到 24每个文件夹代表一个病例里面包含若干.edf文件和至少一个以summary命名的文本文件。EDF 是 European Data Format 的缩写是脑电记录设备保存信号的标准格式之一Python 里的 MNE 和 pyedflib 都能直接识别这种格式。文件命名有固定规律比如chb01_01.edf表示该病例的第一个记录片段chb01_03.edf就是第三个片段。同一个病例的片段之间通常没有严格的时间连续性每个片段是一段独立的采集记录长度从几十分钟到几小时不等。长记录被拆成多个 EDF 文件更多是为了控制文件大小而不是信号本身有中断这一点会影响后面标签对齐的方式。有些病例文件夹下除了.edf还会出现带.seizures或.txt后缀的文件最常见的是chb01-summary.txt这种命名。不同病例的摘要文件格式存在细节差异这也是为什么后面解析代码要写容错分支而不是死板地按固定行数读取。2.2 .summary 文件发作标注到底怎么读打开 summary 文件看到的内容有点像表格核心行由三列组成文件名、发作开始时间、发作结束时间单位是秒坐标是相对该 EDF 文件第一秒的距离。比如下面这一行表示在chb01_03.edf的第 1466 秒到第 1524 秒之间存在一次发作chb01_03.edf 1466 1524解析这段文本要注意两点。一是行首的文件名可能带路径也可能不带按.edf结尾判断最稳妥二是有的 summary 文件头部带统计说明例如Number of Seizures这类文字行解析时要跳过不是三列格式的行。用 Python 读取时按空白字符切分判断字段数量即可split()默认会吃掉多余空格和制表符。还有一类变体是独立汇总文件把整个病例的发作情况统一记录在一个地方内容格式和逐文件的方式一致。我一般会写一个通用函数只认「三列且第三列能转成浮点数」的行其余全部忽略这样无论文件头怎么写都能稳定解析。解析完成后建议立刻打印与原始文件做的预检确认标注区间是否落在信号时间范围内这个检查能在后续节省大量排查时间。2.3 元数据清单采样率、通道数与信号单位这是动手写代码前必须确认的三项元数据。CHB-MIT 的主干记录采样率是 256 Hz但部分病例或部分片段是 512 Hz极少数文件头给出的采样率数值与实际信号长度并不完全吻合。通道数也不是固定的常见配置有 23 导、24 导个别文件里混入了心电或温度等非脑电通道。信号单位以微伏μV为主但不同设备导出 EDF 时可能把单位写成其他形式MNE 读取后raw.info[units]未必一致。建议在预处理阶段统一把这些通道映射到一个标准名称列表否则后面做通道选择和特征计算时同样的通道在不同文件里可能出现在不同位置。下面列一个解析 EDF 后需要核对的信息清单项目对应字段注意点采样率raw.info[sfreq]同一病例不同片段可能不同通道名raw.ch_names顺序与数量会变先统一顺序总时长raw.times[-1]与 summary 秒数比较时用通道单位raw.info[chs]提取特征前统一单位表格里前三项是每次加载文件都要记录的基础信息第四项影响后续特征数值的量级。把这些信息打印出来核对一遍相当于给数据管线做了体检。很多情况下模型指标异常根源就在于某几个文件的采样率没有被动态读取而是被写死成了固定值。3. 用 Python 把 EDF 读取流程跑通MNE 的最小可运行方案3.1 安装与加载mne、numpy、scipy 够用了MNE 是处理脑电和脑磁图的 Python 库安装时会自动带上 numpy 和 scipy。我建议用虚拟环境安装避免和系统 Python 里的其他包互相干扰。安装命令很简单三行就能把环境准备好python -m venv eeg_env source eeg_env/bin/activate pip install mne numpy scipyMNE 读取 EDF 走的是内置解析器。先读单个文件验证环境是否正常代码里打开chb01_01.edf并打印采样率、通道数和总时长import mne raw mne.io.read_raw_edf(chb01/chb01_01.edf, preloadFalse, verboseERROR) print(采样率:, raw.info[sfreq]) print(通道数:, len(raw.ch_names)) print(时长(秒):, raw.times[-1])逻辑说明preloadFalse表示只读文件头和数据元信息不把信号加载进内存后续真正取数时才载入好处是打开大文件快、内存占用可控verboseERROR让输出干净不然 MNE 会打印大段 EDF 头解析结果。raw.times[-1]是最后一个采样点对应的时间等于总时长减一个采样周期用这个值和 summary 里的秒数做对照能第一时间发现单位问题。如果 MNE 读不了某个文件我会直接用 pyedflib 兜底它是专门读写 EDF 的底层库对格式兼容性更宽松import pyedflib f pyedflib.EdfReader(chb01/chb01_01.edf) print(f.getSignalLabels()) print(f.getSampleFrequency(0)) f.close()pyedflib 返回的通道标签是设备原始名称不像 MNE 会做部分标准化但好处是极少被头文件格式问题卡住。遇到解析异常时先用 pyedflib 试试能不能打开就能判断问题出在文件本身还是 MNE 的解析逻辑上。3.2 读入单个 EDF 并核对时间轴拿到raw之后第一步不是急着切数据而是把时间轴和 summary 对齐。MNE 里raw.n_times是信号总采样点数raw.times是每个采样点的时间数组。核对总时长的标准写法是用采样点数除以采样率total_sec raw.n_times / raw.info[sfreq] print(总时长(秒):, total_sec)如果raw.times[-1]和total_sec之差在一个采样周期内属于正常现象因为索引从 0 开始如果差很多就得回头检查采样率读取是否正确。另一个常见情况是某些 EDF 文件头记录的是整数采样率但实际信号长度与之相除后不是整数这种文件建议直接按实际长度计算不要过度信任文件头。时间轴核对通过后把 summary 里读到的发作区间在raw上做一次换算用采样点索引表示seizures load_seizure_marks(chb01/chb01-summary.txt) marks seizures.get(chb01_03.edf, []) print(发作区间(秒):, marks) for s, e in marks: print(f发作占用采样点: {int(s * raw.info[sfreq])} - {int(e * raw.info[sfreq])})这里把秒换算成采样点是为了后续滑窗时直接用整数索引。换算公式是sample int(second * sfreq)没有别的花样但很多人在这里把采样率写成 256遇到 512 的文件就全部错位。换算时一定从raw.info动态取数这也是代码里反复强调sfreq的原因。3.3 用 summary 在信号上标注发作区间现在把整个病例的所有文件遍历一遍建立「文件名 - 发作列表」的完整映射为下一步切窗做准备。这个函数会自动寻找 summary 文件不写死文件名因为不同病例的 summary 命名有差异import os def build_seizure_map(folder): seizure_map {} summary_path None for name in os.listdir(folder): if name.endswith(summary.txt): summary_path os.path.join(folder, name) break if summary_path is None: return seizure_map with open(summary_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) 3 and parts[0].endswith(.edf): fname parts[0] if not os.path.isabs(fname): fname os.path.join(folder, parts[0]) t0, t1 float(parts[1]), float(parts[2]) seizure_map.setdefault(fname, []).append((t0, t1)) return seizure_map这段代码解决两个问题。一是自动搜索以summary.txt结尾的文件兼容chb01-summary.txt和chb01seizure-summary.txt这类命名差异二是发作区间按文件名聚合后面按文件切窗时直接查表。还要处理路径拼接如果 summary 里的文件名不带目录前缀就手动拼接完整路径带绝对路径的情况直接使用。实际项目中我还见过标注行里混入制表符或全角空格的情况split()默认按任意空白切分正好兼容这类脏数据。解析完成后建议打印映射的键值数量确认每个文件都对应了正确的发作标注这一步能提前暴露路径拼接或文件命名不一致造成的静默错误。4. 制作训练集滑窗、标签对齐与类别均衡的落地姿势4.1 滑窗切分窗口长度与重叠率怎么定原始信号是连续的长序列模型不能直接吃整个文件需要切成短窗口。窗口长度通常选 2 到 10 秒具体取决于任务目标。做发作检测一般 4 秒比较平衡太短频率信息不够太长发作边界会被切进多个窗口标签变得模糊。做发作预测时窗口可以适当缩短因为要捕捉发作前的短暂变化。重叠率的常见取值是 50% 到 75%。重叠的作用是增加样本量但也让相邻窗口高度相关评估时容易造成数据泄漏。我一般先把重叠设为 0把数据管线和基线跑通再逐步增加重叠做数据增强这样排查问题时维度更少。import numpy as np def sliding_window_epochs(raw, window_sec4.0, overlap0.0): sfreq raw.info[sfreq] win int(window_sec * sfreq) step int(win * (1 - overlap)) epochs [] for start in range(0, raw.n_times - win, step): epochs.append(raw.get_data(startstart, stopstart win)) return np.array(epochs)这个函数返回三维数组形状是样本数通道数窗口采样点数。注意用raw.get_data而不是raw[:, start:stop]后者返回的元组处理起来更绕。step的计算有一个细节当overlap0.5时step是win的一半保证窗口有一半交叠当overlap0时stepwin窗口首尾相接不会丢中间数据但最后一个不足窗口长度的尾段会被丢弃。窗口长度和重叠率其实是一对需要一起调参的量增大窗口会提高频率分辨率但降低对较短发作的分辨能力提高重叠率会增加样本数同时放大样本间相关性。参数记录里务必同时写下这两个值只写其一后面复现时对不上。4.2 标签对齐把秒级标注映射成逐样本标签切窗只是第一步关键是把每个窗口标成发作或非发作。如果窗口与一个发作区间有任何重叠就标为发作。这里说的「任何重叠」用的是区间相交判断窗口开始秒小于发作结束秒且窗口结束秒大于发作开始秒两个条件同时成立才算重叠def window_is_seizure(window_start, window_end, seizure_intervals): for s, e in seizure_intervals: if window_start e and window_end s: return True return False判断逻辑里最容易写错的是边界条件。窗口正好在发作前一秒结束window_end s不算发作窗口从发作结束的那一秒开始window_start e也不算。所以我用严格小于和严格大于避免把紧贴发作边界的正常窗口误标为正样本。还有一种做法是要求窗口有超过一定比例面积落在发作区间内才标为正样本。当发作持续时间比窗口短时4 秒窗口只覆盖到 0.2 秒的发作也被标成阳性会引入大量噪声标签。具体阈值选 0.1 还是 0.5取决于你更容忍漏报还是更容忍标签噪声。我建议先按「任意重叠」跑一版看模型在发作边界的预测情况再决定是否收紧标准。生成标签时还有一个效率问题如果每个窗口都遍历一遍所有发作区间数据量大时很慢。可以先按窗口起始秒排序再对每个文件的发作区间做二分查找或者直接用一个布尔掩码标记发作段所在的采样点范围滑窗时查看窗口内是否有被标记的点。后一种做法更直观也更容易调试。4.3 发作样本太少过采样与数据增强的常见做法CHB-MIT 的现实问题是大多数病例发作次数有限有的病例只有两三次发作切窗后发作样本可能只有几十个而正常样本有几千个。类别不均衡到这个程度直接训练模型会完全偏向大多数类准确率虚高但几乎抓不住发作。常见做法是先统计每个病例的正负样本比例然后分策略处理。如果只是做二元分类先试欠采样把正常样本随机抽到和发作样本接近的数量。缺点是把大量数据丢弃模型容易欠拟合。如果嫌欠采样浪费数据用加噪增强def add_noise(epoch, snr1.0): noise np.random.normal(0, np.std(epoch) * 0.1, epoch.shape) return epoch noise * snr加噪声增强的假设是脑电信号本身受噪声污染模型应该对小幅扰动保持鲁棒。另一种常见增强是时间位移把一个窗口往左或往右平移几个采样点再截断。这两种做法都不会改变信号的频率结构比直接对原始信号做 SMOTE 更安全因为脑电通道间相关性很强插值容易生成极不自然的波形。如果你坚持用 SMOTE建议在特征层面做不要做在原始信号上。原始信号是通道时间矩阵通道间不是简单的高斯分布SMOTE 按样本间线性插值会造出许多不可能出现的脑电波形。数据增强的目标是增加特征空间的覆盖而不是虚构生理信号这个边界要守清楚。5. CHB-MIT 避坑指南5 个让模型翻车的典型问题与排查方法5.1 发作标注秒数远超信号长度时间单位与采样率混用现象解析 summary 后发现某次发作的开始时间比 EDF 文件总时长还大滑窗时直接数组越界或者生成的标签全部落在文件结尾之外。原因summary 里的时间确实是秒但换算到采样点时容易被当成采样点序号甚至把其他文件的采样率套到当前文件上。还有一种情况是标注对应的是记录设备内部时钟的绝对秒数而 EDF 文件从某个相对时刻才开始记录导致标注区间与文件时间轴整体偏移。解决每次读取都用raw.info[sfreq]动态换算不要写死 256。读文件后立刻打印raw.times[-1]与 summary 里该文件的最大结束秒对比一次。如果完全对不上先确认打开的文件名与 summary 里写的是否一致再看路径拼接有没有指向其他病例的同类文件最后才考虑单位换算问题。5.2 同病例不同文件采样率不一样重采样是必须的预处理现象训练时某几个 epoch 的维度比其他 epoch 少一半或者模型在部分样本上特征计算异常报错提示数组维度不一致。原因CHB-MIT 并不是所有文件都是 256 Hz部分片段采样率是 512 Hz 或更高同一个病例的不同记录会话也可能不同。解决在加载数据阶段统一重采样到目标采样率。MNE 里用一行raw.resample(256)完成注意要在构建 epoch 之前执行否则每个 epoch 的窗口采样点数不一致后面的特征提取全部乱套。我的习惯是把目标采样率设为全局变量所有文件加载后先重采样到这个值再进入切窗流程这样后续代码里可以安全地假设所有数据采样率一致。5.3 通道顺序不同同样名字的导联在不同文件里不在同一列现象用固定索引取通道比如epoch_data[0]取到的不是预期中的前额通道特征和标签集体错位训练出来的模型完全不可解释。原因EDF 文件的通道顺序由采集设备当时的配置决定不同会话、不同病例都可能不同。MNE 会标准化部分通道名但不会改变通道在数组中的位置。解决每次读取后用raw.ch_names建立「通道名 - 索引」的映射再按统一顺序重排数据。只保留所有文件都存在的公共通道通常是一组头皮脑电通道排除心电、肌电等辅助通道。千万不要用数字索引写死通道位置否则换一个病例就悄悄出错。5.4 MNE 读文件报错或警告刷屏EDF 头字段的兼容性问题现象mne.io.read_raw_edf抛出无法解析的异常或者虽然能读但打印大段警告严重时直接中断脚本运行。原因EDF 文件头中有些字段是设备导出时留空的或者写了非标准字符MNE 的解析器对这些情况要求较严格而 pyedflib 的实现更宽松。解决先用 pyedflib 打开文件打印信号标签和采样频率确认文件本身没有损坏。如果 pyedflib 能正常读就用它读出数据再用 MNE 的RawArray重新包装手动指定采样率和通道名。这样既保留 MNE 后续的预处理能力又绕开了解析器对头部字段的严格限制。5.5 评估指标虚高同一个病人的相邻窗口同时进了训练集和测试集现象测试集准确率超过 0.95换到其他病人或新数据上直接跌到 0.6 以下甚至不如随机猜测。原因滑窗产生的时间相关性没有被尊重。无论有没有重叠同一个 EDF 文件切出来的窗口天然高度相似如果训练集和测试集都从同一段记录里取模型等于记住了相邻片段的模式这就是典型的数据泄漏。解决严格按病例划分数据。训练集和测试集绝不能包含同一个病例的窗口跨患者的评估应该用留一患者法一次把一个病例的全部数据当测试集其余病例当训练集。做患者内评估时也要按记录文件分组别让同一个 EDF 切出的窗口跨集合出现这同样会高估性能。6. 把 CHB-MIT 用出价值用 STFT 特征跑通基线并验证训练没白费6.1 先定评估口径患者内还是跨患者做 CHB-MIT 实验前先问自己一个问题模型部署时面对的是新患者还是老患者。如果是长期监测同一个患者做患者内评估合理如果想验证模型泛化能力必须做跨患者评估。论文里两个数字都能看到但跨患者的敏感性、特异性才更能说明模型的实际价值因为真实场景里你不可能提前录好每个患者的发作样本。6.2 用 STFT 特征 随机森林跑通基线先用轻量特征跑出基线再用深度学习模型对比。STFT 特征计算量小能快速验证数据管线是否正确。下面这段代码把每个 epoch 按四个频带分别取短时傅里叶变换的幅度均值from scipy.signal import stft import numpy as np def extract_band_features(epoch, sfreq256): f, _, Zxx stft(epoch, fssfreq, nperseg128, noverlap64) mag np.abs(Zxx) bands {delta: (1, 4), theta: (4, 8), alpha: (8, 13), beta: (13, 30)} feats [] for band, (lo, hi) in bands.items(): mask (f lo) (f hi) feats.extend(mag[mask].mean(axis0)[:5]) return np.array(feats)这里取每个频带前 5 个时间窗的均值是为了把特征维度固定下来不同采样率和窗长下都能对齐。实际使用时特征要按通道堆叠维度会比较大先在少量数据上验证特征能区分发作与非发作再考虑是否降维。然后套一个随机森林分类器训练测试严格按病例划分from sklearn.ensemble import RandomForestClassifier clf RandomForestClassifier(n_estimators100, max_depth8, random_state42) clf.fit(X_train, y_train) print(训练集准确率:, clf.score(X_train, y_train)) print(测试集准确率:, clf.score(X_test, y_test))看训练集和测试集准确率的差距如果训练集高而测试集低是过拟合如果两者都低说明特征不够判别性。刚开始复现时先把目标放低比如只在单个病例上做患者内二分类能稳定到 0.8 以上再扩大到跨患者场景这样排错范围小问题定位更快。6.3 记录这几个字段防止自己后面返工实验跑完别急着关终端把这几样记下来病例编号、目标采样率、窗口长度与重叠率、标签对齐策略、特征参数。同一个数据集这些参数不同的人会得到差很远的指标不记录的话下次调参或写论文时就是一场灾难。常见的情况是隔几天再跑已经忘了当时的配置指标从 0.9 掉到 0.7最后发现只是重叠率被无意改掉。我的习惯是每跑一组实验就把参数和结果写进注释形成一个可复现的配置快照切换实验时从配置文件读取而不是靠记忆。这套流程走下来CHB-MIT 在你手里就不再是一个解不开的压缩包而是能被你控制、能给出可信结论的数据集。希望帮到你。本文还有配套的精品资源点击获取