简介:这份资源面向从事生物医学信号处理、机器学习与癫痫自动检测研究的学习者,围绕CHB-MIT脑电数据集提供配套的样本熵特征提取脚本,可用于发作间期与发作期EEG信号的对比分析与算法验证。压缩包共145个文件,以141个.m函数文件和4个.py脚本为主,.m文件按受试者与嵌入维度组织样本熵计算流程,.py脚本便于在Python环境中衔接处理,整体包体约159KB,轻量易取用。目前已有846人学习下载,说明其在EEG特征工程方向具有一定参考价值。读者可借助这些脚本理解样本熵在脑电非线性分析中的实现细节,快速搭建特征提取与分类实验的基线流程,并结合数据集附带的发作时间戳信息开展检测方法验证,适合作为课程设计、论文复现或算法入门的辅助材料。
1. 拿到 CHB-MIT 数据集.rar,先别急着解压
很多人第一次接触癫痫脑电研究,都是从 CHB-MIT 数据集开始的。它是一批公开的儿童癫痫患者长程头皮脑电记录,每例动辄几十小时,采样率 256 Hz,通道数 23 路,配套一份标注文件写明每次发作的起止时间。压缩包到手,第一反应往往是双击解压、打开某个 edf 文件、拖进软件看波形——然后就被几十万行数据和一堆看不懂的通道名劝退。这个标题背后真正要解决的问题不是「怎么打开」,而是「怎么把一堆原始 edf 变成能喂给模型、能复现实验、能对得上论文指标的样本集」。适合谁:做癫痫检测、异常检测、时序分类的研究生和算法工程师,以及需要快速搭一个可跑基线的手上项目。下面按我实际处理这套数据的顺序讲,从目录结构、标注解析、切片策略到训练和排错,能抄的部分直接给代码。
2. 先摸清 CHB-MIT 的目录结构与标注格式
2.1 解压后到底有哪些文件
CHB-MIT 的典型组织方式是按患者分目录,每个患者目录下若干 edf 文件,命名形如chb01_03.edf,再加一个chb01-summary.txt记录该患者的发作信息。edf 是欧洲数据格式,存的是多通道时序信号加头信息;summary 是纯文本,人写的,格式不严格统一,这是后面最大的坑之一。先别写解析代码,用几条命令把家底摸清:
# 统计患者目录数量与 edf 总数 find ./chb-mit -maxdepth 1 -type d -name 'chb*' | wc -l find ./chb-mit -name '*.edf' | wc -l # 看单个 edf 的头信息(需要 edf 读取工具,这里用 python 的 mne) python -c " import mne raw = mne.io.read_raw_edf('./chb-mit/chb01/chb01_03.edf', preload=False) print(raw.info['sfreq'], raw.ch_names) print(raw.n_times / raw.info['sfreq'] / 3600, 'hours') "第一段命令是摸底,确认解压完整、没有嵌套压缩包。第二段用 mne 读头信息,重点看三样:采样率是否 256 Hz、通道名是否 23 路、单文件时长。CHB-MIT 里个别文件通道数会少一两路,或者采样率标注异常,这些文件在批量处理时会让你的数组维度对不上,必须提前筛出来。preload=False是关键参数,只读头不加载数据,几十 GB 的数据集也能秒回,别一上来就preload=True把内存吃满。
2.2 summary.txt 的非标准格式怎么解析
summary 文件长这样:File Name: chb01_03.edf,下面跟Seizure Start Time: 2996 seconds、Seizure End Time: 3036 seconds,一个文件可能有多次发作,也可能写Seizure 1 Start Time。不同患者、不同批次的写法有细微差别,直接按固定行号取会翻车。稳妥做法是用正则抓「文件块 + 起止时间」:
import re def parse_summary(path): with open(path, 'r', errors='ignore') as f: text = f.read() # 按 File Name 切块,每块对应一个 edf blocks = re.split(r'File Name:\s*', text)[1:] records = [] for blk in blocks: fname = blk.split('\n', 1)[0].strip() starts = re.findall(r'Seizure\s*\d*\s*Start Time:\s*(\d+)\s*seconds', blk) ends = re.findall(r'Seizure\s*\d*\s*End Time:\s*(\d+)\s*seconds', blk) for s, e in zip(starts, ends): records.append({'file': fname, 'start': int(s), 'end': int(e)}) return records recs = parse_summary('./chb-mit/chb01/chb01-summary.txt') print(len(recs), recs[:2])逻辑说明:先按File Name:把全文切成以文件为单位的块,避免跨文件误匹配;Seizure\s*\d*\s*Start同时兼容「Seizure Start」和「Seizure 1 Start」两种写法;起止时间成对 zip,防止某文件只写了一头导致错位。参数上,时间单位统一是秒,别当成毫秒。解析完一定要打印条数和前两条人工核对,summary 里偶有笔误,比如结束时间小于开始时间,这种记录直接丢弃并在日志里记一笔,别让它污染后面的切片。
2.3 为什么不能直接整文件丢进模型
一个 edf 动辄一小时、23 通道、256 Hz,单文件就是几千万个采样点。整文件输入既不现实也没必要:发作段通常只有几十秒到几分钟,其余全是背景。常见做法是切成固定长度窗口,比如 4 秒一段(1024 点),按窗口是否与发作区间重叠打标签。这里有个选型理由要讲清:窗口太短,频域特征分辨率不够,癫痫的节律性放电看不出来;窗口太长,发作起始的定位精度下降,正负样本也更不平衡。4 秒是这套数据上被反复验证的折中,我一般先用它跑基线,再按任务调。
3. 把 edf 切成可训练的样本:窗口、标签与通道选择
3.1 用 mne 批量读取并切窗的最小实现
import numpy as np import mne def load_and_window(edf_path, recs, win_sec=4, sfreq=256): raw = mne.io.read_raw_edf(edf_path, preload=True, verbose='ERROR') raw.pick_types(eeg=True) # 只保留脑电通道 data = raw.get_data() # shape: (n_ch, n_times) data = data - data.mean(axis=1, keepdims=True) # 逐通道去直流 win = int(win_sec * sfreq) n = data.shape[1] // win segs, labels = [], [] file_recs = [r for r in recs if r['file'] == edf_path.split('/')[-1]] for i in range(n): seg = data[:, i*win:(i+1)*win] t0, t1 = i*win_sec, (i+1)*win_sec # 窗口与任一发作区间有交集即标为正 y = int(any(not (t1 <= r['start'] or t0 >= r['end']) for r in file_recs)) segs.append(seg) labels.append(y) return np.stack(segs), np.array(labels)逻辑说明:pick_types(eeg=True)过滤掉非脑电通道,CHB-MIT 里混有心电、肌电等辅助通道,不筛会让特征维度飘。逐通道减均值是去直流漂移,脑电的直流分量对分类没意义还占动态范围。切窗用整除,尾部不足一窗的丢弃,别补零,补零会在频域引入假边沿。标签用区间交集判断,注意条件是「不满足完全在发作前或完全在发作后」,等价于有重叠。参数win_sec和sfreq要和实际头信息一致,读之前先断言raw.info['sfreq'] == sfreq,不一致就重采样或跳过。
3.2 通道选择与重参考的三个必调参数
23 路通道不是全都有用,颞叶癫痫的重点通道和额叶不一样,但做通用基线时我一般保留全部脑电通道,让模型自己学权重。真正要调的是这三个:
| 参数 | 常用取值 | 作用与调整理由 |
|---|---|---|
| 重参考方式 | 平均参考 / 双耳参考 | 平均参考降共模噪声,双耳参考更贴近临床习惯,切换后指标会变,实验要固定 |
| 带通滤波 | 0.5–40 Hz | 滤掉基线漂移和高频肌电,上限别超过 45 Hz,否则工频干扰进来 |
| 陷波 | 50 Hz 或 60 Hz | 按采集地工频选,选错等于没滤,看频谱上那根尖峰定 |
滤波放在切窗之前做,整段滤完再切,避免窗口边界效应。用 mne 的filter时picks='eeg',verbose='ERROR'压日志。这三个参数一变,同一份数据的准确率能差好几个点,所以论文复现时先确认对方用了哪套,别拿自己的预处理去对别人的指标然后怀疑人生。
3.3 正负样本不平衡怎么处理才不虚高
发作窗口占比通常不到 5%,直接训练模型会全预测为负还拿到 95% 准确率,这是这套数据上最常见的自欺。我的做法是训练集按患者分层、对正样本做窗口级过采样,验证集保持原始分布,指标看敏感度和每患者召回,不看总准确率。另一个细节:同一患者的相邻窗口高度相关,随机划分会让验证集泄漏训练信息,必须按文件或按时间段划分,同一文件的窗口只能进一个集合。这条不做,离线指标再漂亮,换一批数据就原形毕露。
4. 训练一个能跑通的基线并读懂它的输出
4.1 特征工程还是端到端,先选一条路
新手容易纠结用 CNN 端到端还是手工特征加树模型。我的建议是先手工特征跑通全流程,再上深度模型。理由很实在:手工特征(各通道各频段功率、过零率、峰峰值)可解释、训练快、出问题能定位到具体特征;端到端模型调参空间大,一开始就上,指标不动时你分不清是数据错、标签错还是模型错。下面是一个最小特征提取,用 scipy 的 Welch 算频段功率:
from scipy.signal import welch def band_power(seg, sfreq=256, bands=((0.5,4),(4,8),(8,13),(13,30))): freqs, psd = welch(seg, fs=sfreq, nperseg=min(256, seg.shape[-1]), axis=-1) feats = [] for lo, hi in bands: idx = (freqs >= lo) & (freqs < hi) feats.append(psd[:, idx].mean(axis=-1)) return np.concatenate(feats) # 每通道 4 个频段功率 # 对每个窗口的每个通道提取后拼接,得到定长特征向量nperseg取 256 对应 1 秒分辨率,窗口 4 秒时频率分辨率约 0.25 Hz,够分 δ/θ/α/β 四个频段。axis=-1保证对时间轴做谱估计,通道维度保留。提取完把特征和标签存成 npz,训练时直接加载,别每次重算,这套数据全量提特征也要跑一阵。
4.2 用逻辑回归或轻量 GBDT 验证流程是否通
特征有了,先上逻辑回归看流程通不通,再换梯度提升树看上限。评估必须按患者分组,用 GroupKFold,组是患者编号。如果分组后指标断崖式下跌,说明模型学的是患者个体特征而非发作模式,这在跨患者场景是常态,别慌,这正是要解决的问题。基线跑通的标准是:训练集能拟合、验证集敏感度明显高于随机、每患者召回有分布而不是全零。达到这三条,再谈换模型。
4.3 指标怎么读才不被平均数骗
报告敏感度和特异度时,同时给每患者的召回分布(中位数和最小值)。平均值好看但某个患者全漏,临床上就是不可用。另外发作检测有事件级和窗口级两种口径:窗口级算每个 4 秒窗,事件级要求连续多个正窗才算检出一次发作,后者更贴近实际。论文里不写清口径的指标没法比,自己实验时两个都记,报告时注明。
5. 处理 CHB-MIT 时最容易翻车的几个地方
5.1 现象:读 edf 报通道数不一致,数组拼接崩了
原因:个别文件通道数少于 23,或通道名大小写、空格不统一,pick_types后维度变化。解决:批量读取时先收集每个文件的通道名集合,取交集作为统一通道集,读取后按固定顺序重排,缺的通道补零并打标记,别直接 stack。
5.2 现象:summary 解析出的发作时间和波形对不上
原因:summary 里的时间是相对文件起点的秒数,但有人误当成绝对时间;或者文件本身有截断,实际时长小于标注结束时间。解决:解析后断言end <= 文件时长,超出的截断到文件末尾并记日志;抽几个文件把发作段画出来人工看一眼,波形上应有明显节律性高幅放电,对不上就回头查解析。
5.3 现象:离线指标很高,换患者就崩
原因:随机划分导致同患者窗口泄漏,模型记住了个体。解决:严格按患者分组划分,训练集和测试集患者不重叠;如果做的是患者内检测,就在报告里写清,别和跨患者结果混为一谈。
5.4 现象:训练 loss 正常但验证敏感度一直是零
原因:正样本过采样后仍被大量负样本淹没,或者标签交集判断写反。解决:先打印正负样本比例和几个正窗口的波形确认标签没错;再用加权损失或分层采样;最后检查验证集是否真的含正样本,别切出一个全负的验证集还在那调模型。
5.5 现象:换台机器结果复现不了
原因:滤波、重采样、随机划分的种子没固定,或者 mne 版本差异导致默认参数不同。解决:所有随机过程设种子,预处理参数写进配置文件随实验存档,记录库版本;关键步骤输出中间结果的校验和,换环境先比对校验和再谈指标。
6. 把 CHB-MIT 用出价值的几个进阶习惯
走到这一步,流程能跑通了,真正拉开差距的是几个细节习惯。第一,建立患者级的元数据表:每个患者的发作次数、总时长、通道异常情况、用药信息(如果有),训练前先看这张表,能提前发现某个患者数据质量差,避免它拉低整体指标还被误判成模型问题。第二,做消融时固定一切只动一个变量,尤其是预处理参数,滤波上限从 40 改到 30 这种小改动都要单独跑,别一次改三样然后不知道谁起作用。第三,把事件级评估写成独立脚本,输入是窗口级预测和真实区间,输出是每次发作是否被检出、误报率多少,这个脚本比模型本身更值得打磨,因为它决定了你的结论可不可信。
再给一个我常用的验证技巧:留一个患者完全不做任何训练和调参,只在最后跑一次,作为「没见过的人」的检验。如果这个患者的召回和验证集中位数差太多,说明模型对个体分布敏感,得回头做域适应或归一化。这个习惯救过我很多次,避免在验证集上反复调参调出一个过拟合的漂亮数字。
最后说句实在的,这套数据最大的门槛从来不是模型,而是预处理和评估口径的严谨。我早期图快,summary 解析没做校验,跑了两周才发现有几次发作被漏标,所有实验推倒重来,那种感觉比调不出指标难受多了。现在的习惯是:任何解析脚本先在小样本上人工核对,确认无误再全量跑;任何指标先问口径再问数值。希望帮到你。
本文还有配套的精品资源,点击获取