CHB-MIT癫痫脑电数据集处理与患者划分避坑指南 简介这份压缩包围绕CHB-MIT癫痫脑电数据集整理了一套特征分析脚本面向从事生物医学信号处理、机器学习与癫痫自动检测研究的学生和科研人员帮助其跳过繁琐的预处理与样本熵实现环节快速开展发作段与静息段的对比实验。包内共145个文件以141个.m函数/脚本和4个.py脚本为主前者承担样本熵等熵值计算与批处理流程后者可用于辅助调用或结果整理压缩包约159KB体积轻便、便于二次修改。已有849人学习下载说明其在EEG特征工程与癫痫检测入门中具有一定参考价值。脚本按受试者与发作片段命名组织读者可据此批量提取不同通道、不同时间窗的样本熵特征用于分类器训练、算法对比与复现实验也能作为迁移到其他脑电数据集的代码骨架。1. 解压之后先别急着找特征表解压CHB-MIT数据集.rar多数人第一反应是翻 csv、找现成特征表结果只看到一堆.edf和几十个同名.seizure文本——这份 CHB-MIT 癫痫脑电数据集的全部信息就压在这两类文件里。它是公开的儿科头皮脑电癫痫标注数据每个病例若干段一小时左右的连续记录256 Hz 采样标注文件直接告诉你哪几秒在发作。做发作检测、发作预测、通道选择实验甚至只是拿真实长程脑电练手它都是绕不开的一份底料。但它不是开箱即用的。从 EDF 到能喂进模型的样本中间隔着一条完整的工程链路读 header、对齐时间基准、解析标注、滑窗切片、滤波去伪影、按患者划分。这条链路上每一步都藏着能让指标虚高十个百分点的小坑下面按我实际拆包复现的顺序把每一步的代码、参数和翻车点摊开讲。2. 解包先看结构EDF header 里有三个必须先确认的字段拿到压缩包先别急着写read_raw_edf全量加载。一个病例文件夹里动辄三四十个文件全读进内存很容易把机器吃满而且你对里面有什么一无所知。正确顺序是先扫 header把通道数、采样率、时长、有没有标注文件整理成一张表再决定加载策略。2.1 目录与命名规则chb01_03.edf与chb01_03.edf.seizure是一对解压后的典型结构是根目录下按病例编号分文件夹文件夹名形如chb01、chb02每个文件夹里平铺着若干.edf文件和对应的.edf.seizure标注文件。EDF 文件名遵循病例号_记录序号的规则chb01_03.edf表示第 1 个病例的第 3 段记录同一个病例内部的记录在时间上是连续的可以拼起来当成一条更长的信号看。这里第一个坑就来了病例编号不连续。按公开说明这批数据覆盖 23 个病例编号从 01 排到 24中间缺了两个。如果你习惯性写for i in range(1, 25)去拼路径必然会在某个不存在的目录上抛FileNotFoundError而且报错位置和你理解的数据规模对不上很容易误判成压缩包损坏。第二个容易误解的点是标注文件的缺失。不是每个 EDF 都有同名.seizure文件。有人看到缺失就以为下载不完整跑去重新解压。实际含义更简单没有标注文件通常表示这段记录里没有标出发作。处理上应当把它当成“全阴性文件”而不是“跳过文件”——癫痫检测任务里阴性样本本来就是绝大多数直接丢掉等于人为篡改先验分布。第三个是时长不齐。多数记录是一小时个别记录接近两小时具体每段多长必须从 header 里读不能按“平均一小时”估算总时长否则算误报率每小时多少次的时候分母就是错的指标会漂。2.2 用 header 扫一遍再决定要不要全量加载下面这段脚本只读元信息不把信号读进内存跑完能直接回答“一共有多少小时、通道到底几个、哪些文件带标注”。import os import csv import mne RAW_DIR ./chb-mit # .rar 解压后的根目录 OUT_CSV ./chbmit_index.csv rows [] for case in sorted(os.listdir(RAW_DIR)): case_dir os.path.join(RAW_DIR, case) if not os.path.isdir(case_dir): continue for fn in sorted(os.listdir(case_dir)): if not fn.endswith(.edf): continue path os.path.join(case_dir, fn) # preloadFalse只解析 EDF header信号数据留在磁盘上 # verboseERROR屏蔽大批文件的解析日志 raw mne.io.read_raw_edf(path, preloadFalse, verboseERROR) sfreq raw.info[sfreq] rows.append({ case: case, file: fn, sfreq: sfreq, n_ch: raw.info[nchan], dur_s: round(raw.n_times / sfreq, 1), # 精确时长不要估 ch_names: |.join(raw.ch_names), has_seizure_file: os.path.exists(path .seizure), }) raw.close() # 释放文件句柄几千个文件不关会撞句柄上限 print(case, done) with open(OUT_CSV, w, newline) as f: writer csv.DictWriter(f, fieldnameslist(rows[0].keys())) writer.writeheader() writer.writerows(rows)关键参数只有两个preloadFalse保证不加载波形raw.close()保证句柄及时释放。dur_s用raw.n_times / sfreq算比读 header 里的时长字段更可靠因为个别文件的 header 字段和实际采样点数会有出入。扫完这张表先做三件事一是统计n_ch的分布你会看到绝大多数文件是 23 通道少数多出一个非脑电通道这个差异会让后面的通道对齐逻辑炸掉二是把dur_s求和得到真实的总小时数作为后续误报率的分母三是按case分组看文件数确认每个病例的记录段数避免后面写划分代码时假设“每个病人样本数差不多”。提示单文件 23 通道、1 小时、256 Hz 约 921600 个采样点转成 float64 大约 170 MB。一个病例三四十个文件全量加载就是一个量级上的错误务必按文件流式处理。3. 标注文件解析把「起始秒 终止秒 类型」切成可训练样本EDF 只是载体真正决定任务定义的是那些.seizure文本。它的格式极其朴素每行三列发作起始秒、发作终止秒、发作类型。朴素到很多人一眼扫过去就动手写解析然后在标签上栽跟头。3.1 三列格式里最容易读错的两件事第一件事秒数是相对当前 EDF 文件起点的偏移不是绝对时间戳。每个文件各自从 0 开始计跨文件没有统一时钟。这意味着你不能把chb01_03.edf.seizure里的秒数直接用到chb01_04.edf上也不能把多个文件的标注简单拼接——要拼得先把信号按顺序拼起来再按各文件时长累加偏移。第二件事边界。标注行里偶尔会出现终止秒略大于文件实际时长或者起止几乎重合的情况。前者多半是标注时按整秒取整导致的溢出后者是极短的发作片段。不管哪种直接拿去切片都会得到空数组或越界索引所以解析完之后必须 clip 到[0, n_samples]。def parse_seizure_file(path, n_samplesNone, sfreqNone): 解析 .seizure 标注返回 [(onset_s, offset_s, type), ...] path : EDF 文件路径函数内部自动拼 .seizure 后缀 n_samples : 该 EDF 的总采样点数用于越界裁剪None 表示不裁 sfreq : 采样率与 n_samples 配合使用 events [] ann_path path .seizure if not os.path.exists(ann_path): return events # 无标注文件 全阴性不是错误 with open(ann_path, r) as f: for line in f: parts line.split() if len(parts) 3: continue # 跳过空行和残留的注释行 try: onset, offset float(parts[0]), float(parts[1]) except ValueError: continue # 非数值行直接丢弃别让整批任务崩掉 if offset onset: continue # 零长度或倒序视为脏数据 if n_samples is not None: onset max(0.0, onset) offset min(n_samples / sfreq, offset) # clip 到文件时长 if offset onset: continue events.append((onset, offset, parts[2])) return events这段代码的重点不在解析而在三层容错缺文件返回空列表、非数值行跳过、越界时间裁剪。这三层看着琐碎但在上千个文件上跑批的时候少一层就要停下来手工修数据。3.2 滑窗切分窗口、步长与“丢弃带”连续信号要变成分类样本必须切窗。窗口长度的选择是个权衡太短1 秒以内抓不到发作的节律特征太长30 秒以上会让单样本里的发作占比被稀释标签含义变得模糊。工程上常用的起步配置是窗口 4 秒、步长 2 秒50% 重叠既能保证样本量又能覆盖典型的节律演变过程。真正决定标签质量的是标签分配规则。最粗暴的做法是“窗口起点落在发作区间内就标 1”问题在于发作起始和终止附近存在大量过渡态这些窗口里既有发作成分也有背景成分硬标成 0 或 1 都是在给模型喂噪声。我的做法是设一个比例阈值加一条丢弃带import numpy as np def make_windows(n_samples, sfreq, events, win_s4.0, stride_s2.0, pos_thr0.5, margin_s2.0): 返回 (窗起点数组, 标签数组)标签 -1 表示丢弃 pos_thr : 窗内发作采样点占比 该值判为正样本 margin_s : 负样本必须距离任何发作区间至少这么远否则丢弃 win int(win_s * sfreq) stride int(stride_s * sfreq) margin int(margin_s * sfreq) mask np.zeros(n_samples, dtypenp.int8) for onset, offset, _ in events: mask[int(onset * sfreq):int(offset * sfreq)] 1 starts, labels [], [] for s in range(0, n_samples - win 1, stride): ratio mask[s:s win].mean() if ratio pos_thr: label 1 # 明确发作 elif ratio 0 and mask[max(0, s - margin): s win margin].sum() 0: label 0 # 明确背景 else: label -1 # 过渡态丢弃 starts.append(s) labels.append(label) return np.array(starts), np.array(labels)pos_thr0.5意味着窗内一半以上是发作才判正这个阈值可以按任务调做发作检测追求高敏感度就降到 0.3追求低误报就提到 0.7。margin_s是很多人会漏掉的一环加上它之后负样本数量会下降一截但训练出来的模型在真实长程信号上的误报会明显少——因为模型不再被迫去区分“刚发作完的两秒”和“完全平静的两秒”。切完之后务必打一张统计表正样本数、负样本数、丢弃数、正样本占有效样本的比例。这个比例通常在个位数百分比如果算出来是 30% 以上八成是窗口太长或者标注解析串了文件回去查。4. 预处理链路滤波、陷波与双极导联的参考问题原始 EDF 里的信号是带直流漂移和工频干扰的直接切窗喂模型模型学到的第一件事就是“这段信号整体偏移了多少微伏”。预处理不是可选项但参数设错比不做更糟——过度的滤波会把发作起始的细微节律抹平模型在测试集上表现平稳换一批数据就废。4.1 带通与陷波先看频谱再定参数带通范围取决于你要保留什么。发作期的节律成分主要集中在低频段工程上常见的起步配置是 0.5–40 Hz 或 1–30 Hz。下限不要压到 0.1 Hz 以下那会把缓慢的基线漂移留下来上限给到 40 Hz 已经覆盖了主要的节律和谐波再往上多半是肌电噪声。工频陷波是个经典陷阱。很多人默认 50 Hz直接notch_filter(50)就完事。这批数据不是在 50 Hz 电网环境下采的你陷错了频率等于没做频谱上那根尖峰依然在而且因为滤波器相位特性还引入了额外的相位失真。正确做法是先算功率谱看尖峰落在哪个频率再决定import numpy as np from scipy.signal import welch def find_line_noise(x, sfreq, fmin45, fmax65, topk3): 在 45-65 Hz 区间找功率最大的几个频点用来确定陷波频率 x : 一维信号单通道即可 sfreq : 采样率 topk : 返回功率最高的前 k 个频点 f, p welch(x, fssfreq, npersegint(sfreq * 4)) band (f fmin) (f fmax) idx np.argsort(p[band])[-topk:] return np.round(f[band][idx], 2) # 用法抽一个通道算一次全数据集通常一致 # print(find_line_noise(raw.get_data(picks[0])[0], raw.info[sfreq]))抽几个不同病例的文件各算一次如果尖峰位置一致就可以统一设置陷波如果某个病例明显不同说明那段记录的采集条件有差异单独处理。这个检查只花几十秒能省掉后面“为什么模型对这个病人特别差”的一整轮排查。4.2 通道选择与参考双极导联别再叠平均参考这批数据的通道名长这样FP1-F7、F7-T7、T7-P7、P7-O1……名字里带减号说明导联本身已经是两个电极之间的差分是双极纵向导联。这一点非常关键它意味着信号里共模成分已经被减掉一轮了你再去套一个平均参考CAR或者 REST 参考等于在差分信号上再差分一次空间信息被二次扭曲而且不同通道的幅度关系会变得不可解释。少数文件会多出一个非脑电通道名字形如ECG或者不带减号。这类通道要显式剔除否则通道数在病例之间对不齐拼接和批处理全都会失败。import mne def preprocess_raw(raw, l_freq0.5, h_freq40.0, notchNone): 标准化预处理选通道 - 带通 - 可选陷波 l_freq / h_freq : 带通边界Hz notch : 陷波频率由功率谱确定None 表示不做 raw raw.copy() # 只保留双极脑电通道名字含 - 视为已差分 # 这条规则只适用于这类双极命名的数据别当通用写法抄走 eeg_picks [i for i, ch in enumerate(raw.ch_names) if - in ch] raw.pick(eeg_picks) # methodiir 比默认 fir 快得多适合批量处理长信号 raw.filter(l_freq, h_freq, methodiir, verboseERROR) if notch is not None: raw.notch_filter(notch, verboseERROR) return rawmethodiir是为了速度代价是相位响应不如 FIR 线性。做发作检测这类以能量和节律为主的任务够用如果要做精细的波形形态分析或者跨通道时延估计换回默认 FIR 更稳妥。滤波顺序上先带通后陷波因为带通已经把高频段压下去了陷波器面对的干扰更干净收敛更快。提示预处理参数一旦定了就写进配置文件别在实验中途改。改一次参数之前跑的所有结果都不可比这种“不可追溯”是最难排查的一类问题。5. 避坑与排查五条返工记录与触发条件下面这五条每一条我都真实踩过至少一次症状和原因对不上号的时候最耗时间。按现象、原因、解决三段写方便对照自己的报错。5.1 文件层面的坑坑一遍历时报目录不存在。现象是脚本在某个病例上抛FileNotFoundError但压缩包明明解压完整。原因是病例编号不连续range(1, 25)里有两个号根本没有对应目录。解决方式是不要用范围生成路径改成扫描实际存在的目录for case in sorted(os.listdir(RAW_DIR))再判断isdir。同时把这个病例列表存下来后面做划分时直接用避免二次生成又踩一次。坑二通道数对不上拼接或批处理报维度错误。现象是同一批文件里有的 23 通道有的 24 通道堆成数组时形状不匹配。原因是少数记录多了一个非脑电通道。解决方式是读入后立刻按通道名筛选只保留双极脑电通道并且在扫描阶段就把n_ch记进索引表加载前先断言通道数符合预期。断言比事后调试便宜得多。5.2 标注与切片层面的坑坑三标注切出来是空的或者切片越界。现象是某个文件明明有.seizure文件切完一个正样本都没有或者索引报IndexError。原因有两个一是标注的起止秒相对文件起点而你误当成绝对时间二是标注的终止秒超过文件实际时长。解决办法是在解析阶段就传入n_samples和sfreq做 clip并且对每个文件打印“标注条数 / 裁掉条数”裁掉比例异常高的文件单独拎出来看。坑四发作跨文件切出来的片段两边都不完整。现象是某次发作在 A 文件末尾开始标注的终止秒却落在 B 文件里单独处理任一文件都只能拿到半个事件。原因在于这些记录本质上是连续的长时间监测文件切分只是存储手段。解决办法是评估阶段按事件合并把相邻文件按时间顺序拼起来后再判断事件完整性或者干脆在事件级评估时允许跨文件的窗口合并成一个事件。切片阶段不用强行修复但评估口径必须一致否则召回率会被系统性地低估。5.3 划分与评估层面的坑坑五随机划分让指标虚高换患者就崩。现象是随机按文件划分训练集测试集准确率能到 99% 以上感觉任务解决了一换成没见过的病例敏感度直接掉到随机水平。原因是同一个患者的不同记录共享大量个体特征——电极阻抗、颅骨厚度、发作的固有形态模型学的是“这个人长什么样”而不是“发作长什么样”。解决办法只有一个按患者划分训练集和测试集的患者集合完全不相交。这件事没有捷径任何按窗口、按文件、按记录随机划分的做法都会产生同类泄漏。顺带说一句类别不平衡。正样本占比通常在个位数百分比一个全预测为阴性的模型也能拿到 99% 的准确率。所以评估指标里必须同时有敏感度和每小时误报数单看准确率等于自欺欺人。6. 验证口径与提速按患者划分之后怎么判断真能用按患者划分的代码本身不复杂麻烦的是划分完之后怎么解读指标。import numpy as np def patient_wise_split(cases, test_ratio0.3, seed0): 按病例划分返回 (train_cases, test_cases)两者无交集 cases sorted(cases) rng np.random.RandomState(seed) idx rng.permutation(len(cases)) n_test max(1, int(len(cases) * test_ratio)) test [cases[i] for i in idx[:n_test]] train [cases[i] for i in idx[n_test:]] assert not set(train) set(test) # 这条断言比任何注释都管用 return train, test指标口径建议固定成一张表每次实验都按同一套算法算不然跨实验没法比口径定义主要用途片段级敏感度被正确识别的正样本窗口 / 全部正样本窗口看模型对发作段的覆盖能力每小时误报数测试集上的假阳性窗口数 / 总小时数临床可用性的核心约束事件级召回被命中至少一次的真实发作事件 / 全部事件避免被碎片化窗口刷高检测延迟事件首次被命中的时间 - 发作起始时间预警类应用的硬指标片段级指标好看但事件级召回低说明模型只抓到了发作中段最典型的几秒起始段完全漏掉——这种模型在离线评测里很漂亮实际预警价值为零。反过来事件级召回高但每小时误报数爆炸说明阈值设得太松需要回到 ROC 上重新找工作点。最后一个提速技巧预处理和切窗是重复劳动每个 epoch 重算一遍纯属浪费。把切好的窗口按患者存成.npz键名用病例_文件_起点标签单独存一个数组下次直接np.load(..., mmap_moder)读内存不够也不会爆。缓存文件名里带上滤波参数和窗口长度的哈希改参数自动失效免得拿旧缓存跑新实验还不自知。我最早做这类任务时吃过一次大亏跑了两周实验指标一路走高最后发现测试集里有三个患者和训练集重叠那批结果全部作废。从那以后我每次建完划分都强制走一遍断言把训练集和测试集的患者集合打出来人工核对一遍多花三十秒省下两周。希望帮到你。本文还有配套的精品资源点击获取