CHB-MIT数据集实战:EDF解析、滑窗建样本与癫痫发作检测
简介CHB-MIT数据集是癫痫脑电图研究领域的经典公开数据由麻省理工学院与波士顿儿童医院合作采集适合生物医学信号处理、机器学习和模式识别方向的研究者用于癫痫发作检测、分类及长期监测等任务。资源包共145个文件以141个m脚本为主并含4个py程序对应MATLAB与Python两种常用分析环境便于在特征提取、样本熵计算等环节直接复用压缩包仅159KB轻量易获取。目前已有832人学习下载。数据集覆盖23位儿童受试者的多通道脑电记录包含复杂部分性发作、简单部分性发作及二次全面性发作等多样类型并提供发作起止时间戳等临床信息可用于验证和对比不同算法的识别性能。无论是入门脑电分析还是开展算法实验打包好的分析脚本与多通道数据都能帮助快速上手。1. CHB-MIT数据集.rar儿童癫痫脑电研究绕不开的那个压缩包CHB-MIT数据集.rar 这个压缩包几乎每个做癫痫脑电分析的人都下载过。它是公开的儿童癫痫头皮脑电数据集里面是一批 EDF 格式的脑电记录和对应的发作标注解压出来之后你会看到一堆.edf文件和几个文本文件。很多人卡在第一步不知道压缩包里到底是什么结构、标注怎么读、怎么把数据变成能喂给模型的矩阵。这篇笔记不讲空泛原理直接带你做三件事把压缩包解压并读成数组、把发作标注解析成时间段、按滑窗构建训练样本再把我这些年在这个数据集上踩过的坑按“现象→原因→解决”写清楚。适合刚入手 EEG 分类、想拿公开数据集做复现的开发者也适合已经在用 CHB-MIT 但总在数据处理阶段反复返工的人。2. 拆开CHB-MIT的目录结构EDF文件、汇总标注与通道布局2.1 24位患者、800小时EEG与目录到底长什么样CHB-MIT 数据集包含 24 位患者的头皮脑电记录编号从 chb01 到 chb24每个患者一个目录累计时长超过 800 小时里面有上百次标注发作。这是整个数据集的价值基线有真实标注的癫痫发作段也有大量看似正常的间期数据正好用来做发作检测和分类任务。我一般先把整个压缩包解压出来第一件事不是去看 EDF而是先扫一遍目录结构。每个患者目录里放着若干.edf文件和一个汇总标注文本EDF 文件按记录顺序编号例如chb01_01.edf、chb01_02.edf这样依次递增。这里有个容易忽略的地方不同患者的 EDF 文件数量差别很大某个患者可能只有两个记录另一个患者可能有十几个记录所以后面做数据划分时不能想当然地按编号均衡取。汇总标注文本和患者目录同名路径大概长这样CHB-MIT/ ├── chb01/ │ ├── chb01_01.edf │ ├── chb01_02.edf │ └── chb01.txt ├── chb02/ │ ├── chb02_01.edf │ └── chb02.txt逻辑说明每个患者目录下独立的.txt就是该患者所有记录的标注摘要包含文件名、记录开始时间、每段发作的起止时间。目录名、EDF 文件名和文本文件名三者对应写脚本时尽量用Path.glob或目录扫描生成文件清单不要手写路径列表。参数说明部分镜像站在打包时会把标注字段重排有的把汇总文本放在根目录有的直接为每个 EDF 附带同名.seizures文件。我建议以你解压出的实际文件为准先cat一个文本看字段名再决定解析逻辑。2.2 EDF文件头里藏着采样率、通道数和物理单位EDF 是欧洲数据格式文件头固定 256 字节后面逐通道存储信号头最后才是采样点数据。你不用手动解析文件头直接用解析库读取即可但文件头里几个关键字段决定了后面的所有处理参数采样率、通道数、每通道的物理单位和 ADC 增益。CHB-MIT 里的记录绝大多数采样率是 256 Hz即每秒 256 个采样点。通道数不固定常见是 23 通道也有记录是 24 通道多出来的那一路可能是 ECG 或非 EEG 辅助信号。这一点非常关键后面做多患者合并时通道数不一致是第一个大坑。我用 Python 快速看一个文件的信息import mne raw mne.io.read_raw_edf(chb01_01.edf, preloadFalse, verboseERROR) print(采样率:, raw.info[sfreq]) print(通道数:, len(raw.ch_names)) print(通道名:, raw.ch_names) print(总时长(s):, raw.n_times / raw.info[sfreq])逻辑说明read_raw_edf只读文件头返回一个Raw对象preloadFalse时波形数据还留在磁盘上只有访问到具体位置才实际读取这样几千个 EDF 的批量扫描不会撑爆内存。参数说明verboseERROR用来屏蔽 MNE 对部分 EDF 文件头的告警CHB-MIT 里的很多文件头并不完全标准不屏蔽会有大段警告刷屏。raw.n_times是总采样点数除以采样率得到总时长这个值在preloadFalse下也能正常拿到。2.3 双极导联与通道数不统一第一个隐蔽的坑CHB-MIT 的通道布局是双极导联通道名像FP1-F7、F7-T7、T7-P7这种每个通道表示两个电极之间的差值而不是单电极对参考的电压。这对后续做空间特征提取有影响你不能直接套用单极导联的 10-20 系统模板通道名本身就携带了空间拓扑信息。更麻烦的是通道数不统一。同一个患者的不同记录通道数可能不同不同患者之间公共通道集合也可能不同。我见过很多人把两个 EDF 直接np.concatenate然后维度对不上报错。正确的处理方式是用通道名做交集只保留所有文件都存在的公共通道再统一通道顺序。我一般会在批量读取后先打印每个文件的通道列表确认哪些是公共通道再决定是否丢通道。这个动作看似多余实际能省掉后面大量的 debug 时间。3. 从.rar到能算的矩阵解压校验、EDF读取与通道对齐3.1 解压前先做完整性测试7z t与文本编码很多人拿到 CHB-MIT数据集.rar 直接双击解压解出来发现文件缺失或打不开回头怀疑数据集有问题。实际上一部分原因是压缩包传输损坏另一部分是中文 Windows 下解压工具把文件名编码搞乱了。我建议解压前先做完整性校验不要跳过这一步。用 7-Zip 命令行测试7z t CHB-MIT数据集.rar逻辑说明t是 test 的意思会逐个测试压缩包内文件的 CRC 校验值输出OK表示压缩包完整。这一步几十秒能避免解压到一半才发现 pack 损坏的尴尬。参数说明如果系统装的是 WinRAR对应命令是unrar t CHB-MIT数据集.rar。如果压缩包来自网盘传输文件名出现过中文乱码建议解压时注意编码选项在 Windows 下尽量不要用系统自带右键解压我一般用 7-Zip 并手动指定以 UTF-8 处理文件名避免解出来一堆乱码目录名。解压命令7z x CHB-MIT数据集.rar -oCHB-MIT参数说明-o后面跟输出目录名注意-o和目录名之间不要有空格。解压完成后先数一下目录里的 EDF 数量再开始写代码。3.2 用MNE一个函数读EDF从头信息到波形MNE 是 EEG/MEG 处理的事实标准库读取 CHB-MIT 这种老式 EDF 文件最省事。拿到Raw对象后可以按时间窗口切片取出数据也可以直接转成 numpy 数组。读取并取出一段数据import mne import numpy as np raw mne.io.read_raw_edf(chb01_01.edf, preloadTrue, verboseERROR) raw.pick_types(eegTrue) # 取第10秒到第12秒的数据 start int(10 * raw.info[sfreq]) end int(12 * raw.info[sfreq]) data, times raw[:, start:end] print(数据形状:, data.shape) # (通道数, 采样点数)逻辑说明pick_types(eegTrue)只保留 EEG 类型通道把 ECG 和辅助通道滤掉raw[:, start:end]返回两个值第一个是形状为(通道数, 采样点数)的二维数组第二个是对应的时间轴。这是后续滑窗取样本的标准操作。参数说明这里preloadTrue会把整个文件读入内存。单个 EDF 文件通常几十到几百 MB读一个没问题批量循环时要小心内存累计建议循环里用完后及时释放。3.3 批量统一通道顺序与采样率跨患者分析的前置步骤跨患者分析是 CHB-MIT 最常见的用法但通道不统一、采样率不统一会直接让批量处理翻车。我在做这类处理时会先把一批 EDF 对齐到相同的通道集合和采样率再进入特征提取或模型输入环节。通道对齐我一般用通道名交集def pick_common_channels(raw_list): common set(raw_list[0].ch_names) for raw in raw_list[1:]: common set(raw.ch_names) common sorted(common) return [raw.pick_channels(common) for raw in raw_list]逻辑说明common从第一个文件的通道集合开始逐个与后面的文件求交集最终得到所有文件共有的通道名。pick_channels会改变对象的通道顺序为传入列表的顺序这里排序保证每个文件通道顺序一致。参数说明如果你只需要 23 个标准 EEG 通道可以在公共通道基础上再过滤。注意sorted是按字符串字典序排序实际通道物理顺序可能与 10-20 布局不一致如果后续要做空间卷积或拓扑图需要额外按标准导联顺序重排。采样率统一用 MNE 的resampletarget_sfreq 256 for i, raw in enumerate(aligned_list): if int(raw.info[sfreq]) ! target_sfreq: aligned_list[i] raw.copy().resample(target_sfreq)逻辑说明resample会做抗混叠滤波再重采样copy()防止修改原对象。CHB-MIT 绝大多数记录本身就是 256 Hz这一步主要是防御性处理防止个别文件采样率不同导致后续滑窗窗口长度不一致。参数说明重采样前对象必须已经preloadTrue否则 MNE 会报错。降采样到 128 Hz 能显著减少数据量但会丢失高频信息癫痫发作检测常用的 gamma 频段受影响建议先确认特征需求再决定。4. 把发作时间点变成训练标签摘要解析、滑窗与数据划分4.1 汇总文本的字段格式与一个可靠的解析脚本CHB-MIT 的标注文本是按记录组织的每个记录一段。字段一般长这样File Name: chb01_03.edf File Start Time: 16:16:29 File End Time: 16:25:37 Number of Seizures: 1 Seizure 1 Start Time: 16:16:29 Seizure 1 End Time: 16:25:37其中File Start Time是记录内相对零点Seizure Start/End Time也是相对该记录开头的偏移时间单位是秒。注意Seizure 1 Start Time中间带有编号解析时不要用简单的字符串切割。解析脚本from pathlib import Path def parse_chb_summary(txt_path): records [] current None for line in Path(txt_path).read_text(encodingutf-8, errorsignore).splitlines(): line line.strip() if line.startswith(File Name): current {file: line.split(:, 1)[1].strip(), seizures: []} records.append(current) elif line.startswith(Number of Seizures): current[num_seizures] int(line.split(:, 1)[1].strip()) elif Start Time in line and Seizure in line: current[seizures].append({start: line.split(:, 1)[1].strip()}) elif End Time in line and Seizure in line: current[seizures][-1][end] line.split(:, 1)[1].strip() return records逻辑说明逐行匹配字段前缀用split(:, 1)只按第一个冒号切割避免时间字符串里的冒号干扰。Start Time in line and Seizure in line排除了File Start Time的干扰保证只有发作段标注被记录。参数说明utf-8编码读取是稳妥选择老数据文本偶有编码问题时可加errorsignore。如果解压出的文件名乱码先转码文件再解析不要在解析脚本里做编码猜测。把时间字符串转成秒def time_to_seconds(t): h, m, s t.split(:) return int(h) * 3600 int(m) * 60 float(s)逻辑说明格式固定为时:分:秒直接拆分后换算。返回 float 是为了兼容可能带小数秒的标注虽然 CHB-MIT 标注一般是整数秒。4.2 从时间点到标签滑窗生成样本与控制类别比例拿到发作起止时间后下一步是把连续信号切成固定长度的窗口为每个窗口打标签。窗口和发作段有重叠就算正样本完全没有重叠就是负样本。import numpy as np def build_samples(raw, seizures, win_sec2, step_sec1): sfreq int(raw.info[sfreq]) win_len win_sec * sfreq step step_sec * sfreq total raw.n_times starts range(0, total - win_len, step) X, y [], [] for s in starts: e s win_len label 0 for se_start, se_end in seizures: if not (e se_start or s se_end): label 1 break X.append(raw.get_data()[:, s:e]) y.append(label) return np.stack(X), np.array(y)逻辑说明每个窗口与所有发作段逐一判断是否重叠。重叠判定用反向条件如果窗口结束早于发作开始或者窗口开始晚于发作结束则不相交否则相交标记为正样本。参数说明win_sec2、step_sec1表示窗口 2 秒、步长 1 秒即 50% 重叠。短窗口适合事件检测长窗口适合分类任务正负样本不平衡严重时可以增大窗口长度或对负样本降采样。类别比例是 EEG 分类里绕不开的问题。CHB-MIT 的发作段占比通常只有 1%~5%直接训练会让模型把所有样本都预测为正常。常见做法是把负样本下采样到正样本的 5~10 倍或者给损失函数加正样本权重。pos_idx np.where(y 1)[0] neg_idx np.where(y 0)[0] neg_sampled np.random.choice(neg_idx, sizelen(pos_idx) * 5, replaceFalse) idx np.concatenate([pos_idx, neg_sampled]) X, y X[idx], y[idx]逻辑说明随机抽取负样本到正样本的 5 倍既保留类别区分度又避免训练集过大。replaceFalse保证不重复采样负样本不足时才用replaceTrue。参数说明比例 5:1 是一个起步值实际任务里可以按验证集表现调整。更稳妥的做法是用StratifiedKFold在患者级别上做分层避免同一患者的数据同时出现在训练和验证集。4.3 数据划分患者独立划分比记录随机划分更接近真实部署划分数据是 CHB-MIT 实验设计里最容易被做错的地方。很多人直接把所有窗口随机打乱按 8:2 分训练集和测试集。这样做的结果是同一个患者的发作段同时出现在两边模型测试时等于见过了同一个人的脑电特征准确率虚高。我常用的划分方式是患者独立划分训练集、验证集、测试集各包含完全不同的患者。这种方法也叫 leave-one-patient-out一次拿一个患者做测试、其余做训练循环 24 次最后取平均指标。patients [fchb{p:02d} for p in range(1, 25)] test_patient patients[0] train_patients [p for p in patients if p ! test_patient]参数说明具体选哪个患者做测试要看你的数据量。如果某个患者只有两个记录、发作段特别少用它做测试集指标方差会很大建议先统计每个患者的发作时长再做选择。记录随机划分只能用来做模型调参的内部验证最终评估一定要看患者独立划分的结果。5. 处理CHB-MIT的避坑清单解压、解析与标注对齐的五个真实故障5.1 解压后EDF读取失败中文文件名乱码是头号原因现象压缩包解压后目录名和文件名变成一串乱码MNE 读取 EDF 时直接报文件不存在或文件头解析失败。原因.rar包在制作时使用 GBK 编码记录文件名Windows 下部分解压工具默认用本地编码解压路径里的中文变成乱码还有一部分情况是压缩包传输过程丢字节文件名不完整。解决用 7-Zip 解压时保持默认的 UTF-8 处理如果已经解压乱了用convmv批量转码或者直接在 Linux 下重新解压原包。这个步骤没有技术含量但能省掉后面所有路径相关的报错。5.2 MNE能读而pyedflib报错EDF头不合规的兼容处理现象同一个 EDF 文件mne.io.read_raw_edf能正常读换成pyedflib.EdfReader却抛异常或者反过来。原因CHB-MIT 是老数据集部分 EDF 文件头的保留字段和信号头并不严格遵守 EDF 规范两个库的容错策略不同。这类文件不是损坏只是不太标准。解决读写两条路都准备。数据探索和预处理主用 MNE要做自定义信号读取时再用 pyedflib如果 pyedflib 打不开某个文件不要硬修直接用 MNE 读出来转成数组再继续。没必要纠结哪个库更好哪个能读通就用哪个。5.3 把“Seizure 1 Start Time”的冒号误当成字段分隔符现象解析标注文本后时间字段变成16:16:29被拆得七零八落发作起止时间对不上。原因标注行里既有字段分隔冒号又有时间本身的冒号。直接line.split(:)会把时间也给拆了得到四个甚至更多片段。解决用split(:, 1)只切第一个冒号前半部分是字段名后半部分是完整时间值。这是解析这种文本的基本功也是我最早在这个数据集上翻车的点后面写解析脚本时我无条件用maxsplit1。5.4 标注时间超出EDF时长切片越界和末尾丢数据的成因现象按标注时间切片时窗口结束位置超出了raw.n_times报索引越界或者数据尾段总是出现缺样本。原因摘要文本里的Seizure End Time有时会晚于实际记录的波形末尾。可能原因包括记录中断、文件截断、标注本身含后放电段。我在好几个患者文件里都碰到过越靠后的记录越容易出现。解决切片前做边界钳制end min(end, raw.n_times)窗口长度不足的直接丢弃。另外要在构建样本时检查窗口实际数据量小于窗口长度的样本不进入训练集避免模型学到半截数据。5.5 不同记录通道数不一致批量拼接在reshape时翻车现象循环读取多个 EDF 后把数据堆成np.stack时维度对不上报通道数不一致的错误。原因前面说过CHB-MIT 部分记录的通道数和其他记录不同有的多一条辅助通道。直接按数组维度拼接必炸。解决批量处理前先做通道名交集统一这是我在第 3 章写过的流程。血泪经验是不要相信“数据集应该是统一的”这种假设一定要在代码里显式做通道对齐并且打印对齐后的通道列表供人工确认。6. 用CHB-MIT做发作检测的验证习惯事件级指标、频谱基线与可视化解剖6.1 逐样本准确率不可信事件级敏感性、误报率与重叠判定做发作检测时窗口级别的准确率是被用滥的指标。因为发作段占比极低模型把所有窗口都判为正常也有 95% 以上的准确率这个数毫无意义。我习惯用事件级敏感性一个发作事件被至少一个预测段命中就算检测成功。def event_sensitivity(pred_segments, true_segments): hits 0 for ts, te in true_segments: for ps, pe in pred_segments: if ps te and pe ts: hits 1 break return hits / len(true_segments)同时统计每小时的误报率即未命中任何真实发作的预测段数量除以评估数据总时长。这两个指标组合起来才反映检测器在临床场景下的可用性。6.2 先跑一个频带能量阈值基线再决定要不要上深度学习很多人拿到 CHB-MIT 直接上 CNN 或 LSTM模型还没调通就开始调参。我建议先做一个简单的频带能量阈值检测器作为基线和排错工具。发作期相对功率会显著变化尤其 delta 和 theta 频带能量在发作期上升光用这个特征就能在部分患者上检测出相当比例的发作。具体做法对每个窗口计算 delta(0.5-4Hz)、theta(4-8Hz)、alpha(8-13Hz)、beta(13-30Hz) 的相对能量取 delta 相对能量做滑动平均超过阈值判定为发作。这个基线能让你快速验证数据读取、标注解析、滑窗拼接整条流程是否贯通再换复杂模型时也有了对比对象。6.3 可视化自检把标注画在原始波形上最后是我个人强烈建议养成的习惯写一个绘图函数把解析出的发作段覆盖在原始波形上随机挑几个文件肉眼核对。标注偏移、时间单位错误、切片错位这类问题光看数值很难发现画出来一眼就能看出标注是否对齐了波形形态上的异常放电。raw.plot(duration30, start10, scalingsauto, blockTrue)逻辑说明start10表示从第 10 秒开始绘制duration30显示 30 秒窗口配合标注时间段比对波形和标注是否吻合。参数说明scalingsauto让 MNE 自动调整各通道显示幅度避免部分通道幅值过大压缩其他通道的波形。这一步不属于建模但我每一次用 CHB-MIT 做实验都会先做一遍它帮我拦下了好几处解析脚本的隐性 bug。数据管道通了模型才有讨论的意义。希望帮到你。本文还有配套的精品资源点击获取