简介:这份压缩包围绕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) # preload=False:只解析 EDF header,信号数据留在磁盘上 # verbose="ERROR":屏蔽大批文件的解析日志 raw = mne.io.read_raw_edf(path, preload=False, verbose="ERROR") 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, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows)关键参数只有两个:preload=False保证不加载波形,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_samples=None, sfreq=None): """解析 .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_s=4.0, stride_s=2.0, pos_thr=0.5, margin_s=2.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, dtype=np.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_thr=0.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, fmin=45, fmax=65, topk=3): """在 45-65 Hz 区间找功率最大的几个频点,用来确定陷波频率 x : 一维信号(单通道即可) sfreq : 采样率 topk : 返回功率最高的前 k 个频点 """ f, p = welch(x, fs=sfreq, nperseg=int(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_freq=0.5, h_freq=40.0, notch=None): """标准化预处理:选通道 -> 带通 -> 可选陷波 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) # method="iir" 比默认 fir 快得多,适合批量处理长信号 raw.filter(l_freq, h_freq, method="iir", verbose="ERROR") if notch is not None: raw.notch_filter(notch, verbose="ERROR") return rawmethod="iir"是为了速度,代价是相位响应不如 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_ratio=0.3, seed=0): """按病例划分,返回 (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_mode="r")读,内存不够也不会爆。缓存文件名里带上滤波参数和窗口长度的哈希,改参数自动失效,免得拿旧缓存跑新实验还不自知。
我最早做这类任务时吃过一次大亏:跑了两周实验,指标一路走高,最后发现测试集里有三个患者和训练集重叠,那批结果全部作废。从那以后我每次建完划分都强制走一遍断言,把训练集和测试集的患者集合打出来人工核对一遍,多花三十秒,省下两周。希望帮到你。
本文还有配套的精品资源,点击获取