简介:这份资源面向从事生物医学信号处理、机器学习与癫痫自动检测研究的学习者,提供CHB-MIT脑电数据集配套的样本熵特征提取脚本,可用于发作间期与发作期EEG信号的复杂度分析与分类实验。压缩包共145个文件,以141个.m函数文件和4个.py脚本为主,.m文件按受试者与嵌入维度组织样本熵计算流程,.py脚本便于在Python环境中调用或复现特征提取,整体约159KB,体积轻量、便于快速部署。已有846人学习下载,说明其在EEG特征工程方向具有一定参考价值。读者可借助这些脚本理解样本熵在癫痫脑电上的参数设置与计算细节,将其作为特征提取环节接入自己的分类模型,或对照不同受试者、不同维度的结果排查实现问题,适合具备一定信号处理与编程基础的研究者使用。
1. CHB-MIT数据集.rar:一份压缩包背后,癫痫检测从数据到模型的完整链路
拿到一个名为 CHB-MIT数据集.rar 的压缩包,很多人第一反应是解压、找 CSV、丢进模型跑一遍。但真正做过癫痫脑电检测的人都知道,这个包里装的不是「一份数据」,而是一整套需要你先理解再动手的工程约束:多导联、长时程、发作事件稀疏、标注以文件为单位、采样率不统一、通道命名不统一。你如果直接把它当成普通时序分类数据集,训练出来的模型大概率在验证集上 AUC 很好看,一换受试者就翻车。这篇笔记就顺着这个压缩包往下拆:它到底是什么结构、怎么把它整理成可训练的样本、窗口和标签怎么设、跨受试者评估怎么做、哪些坑我踩过。适合已经有一点信号处理或深度学习基础、准备把 CHB-MIT 真正用起来的从业者,也适合刚接手这个数据集、不想在格式转换上浪费一周的新手。
2. 先搞清楚压缩包里到底是什么:CHB-MIT 的数据组织与选型判断
2.1 解压后你会看到的目录结构与文件语义
CHB-MIT 的常见组织方式是按受试者分目录,每个受试者目录下是一批 EDF 格式的连续脑电记录,外加若干总结性文本。真正决定你能不能跑通模型的,不是 EDF 本身,而是配套的标注信息:发作起止时间通常记录在按受试者汇总的文本里,通道列表和采样率则写在每个 EDF 的头信息中。也就是说,你面对的是「连续长记录 + 事件级标注」,而不是「一条样本一个标签」的表格数据。
我一般会先做一次盘点,把每个受试者的文件数、每个文件的时长、通道数、采样率、是否有发作标注全部列出来。这一步不做,后面窗口切分一定出问题。常见做法是用 MNE 读取 EDF 头信息,只读 header 不读数据,速度快,也不会因为内存爆掉。下面这段脚本就是干这件事的,输出一张清单表,后面所有处理都以它为准。
import mne import os import pandas as pd root = "./chbmit" # 解压后的根目录,按受试者分文件夹 rows = [] for subj in sorted(os.listdir(root)): subj_dir = os.path.join(root, subj) if not os.path.isdir(subj_dir): continue for f in sorted(os.listdir(subj_dir)): if not f.lower().endswith(".edf"): continue path = os.path.join(subj_dir, f) # 只读头信息,preload=False 避免把整段长记录读进内存 raw = mne.io.read_raw_edf(path, preload=False, verbose="ERROR") rows.append({ "subject": subj, "file": f, "sfreq": raw.info["sfreq"], "n_channels": len(raw.ch_names), "duration_sec": raw.n_times / raw.info["sfreq"], "ch_names": "|".join(raw.ch_names), }) raw.close() df = pd.DataFrame(rows) df.to_csv("inventory.csv", index=False) print(df.groupby("subject")[["sfreq", "n_channels"]].agg(["min", "max"]))这段代码的关键不在读取,而在三个字段:sfreq告诉你采样率是否统一,n_channels告诉你通道数是否一致,duration_sec告诉你单个文件有多长。CHB-MIT 里采样率常见为 256 Hz,但不同记录之间通道数和命名会有差异,ch_names这一列就是给你做通道对齐用的。参数上preload=False必须保留,长时程 EDF 全量读入很容易把内存打满;verbose="ERROR"只是压掉无关日志,不影响数据。跑完这张清单,你才有资格谈后面的窗口切分。
2.2 为什么不能直接拿整段 EDF 训练:事件稀疏与类别失衡
很多人第一次用 CHB-MIT 会犯一个直觉错误:把每个 EDF 文件当成一个样本,有发作标 1,没发作标 0。这样做的后果是标签极度不平衡,而且模型学到的是「这段记录整体像不像发作期」,而不是「此刻这段脑电是不是发作」。癫痫发作在长时程记录里占比通常很低,整段文件级标签会把大量正常片段和少量发作片段混在一起,梯度被正常样本淹没。
正确的思路是把它当成事件检测问题:在时间轴上滑动窗口,每个窗口根据是否落在发作区间内打标签。这样样本量上来了,正负样本比例也可控。但窗口一滑,新的问题就来了:窗口多长、步长多大、发作边界怎么处理。我的经验是窗口长度取 2 到 4 秒比较稳,太短频谱分辨率不够,太长会把发作起始的过渡段稀释掉;步长取窗口的一半做重叠,既能增加正样本,又不至于让相邻窗口高度冗余。这些参数不是拍脑袋,后面第 4 章会给出可调的对照表。
2.3 通道选择:23 导联全用还是只留双极导联
CHB-MIT 的通道命名沿用的是临床双极导联体系,常见形如「FP1-F7」「F7-T7」这类前后串联的组合。全通道直接喂给模型不是不行,但会带来两个现实问题:一是不同受试者通道顺序和数量不完全一致,二是部分通道质量差、伪迹重。我一般会先固定一个通道子集,优先保留覆盖额区、颞区、顶区的双极导联,因为这些区域和癫痫放电关系更直接。
选型理由说清楚:全通道方案信息最全,但对模型容量和通道对齐要求高;固定子集方案牺牲一点信息,换来跨受试者的一致性,工程上更容易复现。新手建议先走固定子集,把链路跑通,再回头做全通道消融。通道名匹配时不要用硬编码下标,要用名称匹配,因为不同文件的通道顺序可能不同。这一点在跨受试者评估时尤其致命,第 5 章的避坑会专门讲。
3. 把 EDF 变成可训练样本:窗口切分、标签对齐与通道统一
3.1 从标注文本到时间区间:解析发作起止时间
CHB-MIT 的发作标注通常写在按受试者汇总的文本里,格式是「文件起始时间 + 发作开始秒数 + 发作结束秒数」这类记录。解析时最容易翻车的地方是时间基准:标注里的秒数是相对该文件起点,还是相对某个绝对时间。我的做法是先按文件解析出「文件内相对秒数」的区间列表,再和 EDF 的时长做校验,任何超出文件时长的区间都打印出来人工确认,绝不静默丢弃。
import re def parse_seizures(txt_path): """返回 {文件名: [(start_sec, end_sec), ...]},秒数为文件内相对时间""" seizures = {} cur_file = None with open(txt_path, "r", encoding="utf-8", errors="ignore") as fh: for line in fh: line = line.strip() m = re.search(r"File Name:\s*(\S+)", line) if m: cur_file = m.group(1) seizures.setdefault(cur_file, []) continue m = re.search(r"Seizure(?:\s+\d+)?\s+Start Time:\s*(\d+)\s*seconds?", line, re.I) if m and cur_file: start = int(m.group(1)) seizures[cur_file].append([start, None]) continue m = re.search(r"Seizure(?:\s+\d+)?\s+End Time:\s*(\d+)\s*seconds?", line, re.I) if m and cur_file and seizures[cur_file]: seizures[cur_file][-1][1] = int(m.group(1)) # 清理未闭合区间,避免后面切窗口时越界 for k, v in seizures.items(): seizures[k] = [(s, e) for s, e in v if e is not None and e > s] return seizures这段解析的关键是「先建区间、后校验」。正则只负责抓数字,真正的可靠性来自后面的边界检查:e > s过滤掉异常区间,超出文件时长的区间在切窗口阶段再拦一次。参数上不要试图用一条正则覆盖所有格式变体,标注文本里编号、空格、大小写都可能有差异,用re.I放宽大小写,用可选分组兼容「Seizure 1 Start Time」和「Seizure Start Time」两种写法,比写一个巨型正则更稳。
3.2 滑窗切分与标签生成:窗口长度、步长、边界处理
有了区间列表,就可以对每个 EDF 做滑窗。核心逻辑是:对每个窗口,计算它与所有发作区间的重叠比例,超过阈值就标正样本。阈值怎么定?我一般用 0.5,也就是窗口内一半以上时间落在发作区间才算正样本。这样做的理由是发作起始和结束附近存在过渡段,标得太激进会把非发作段拉进正类,标得太保守正样本又太少。
import numpy as np def make_windows(raw, seizures, win_sec=4.0, step_sec=2.0, overlap_thr=0.5): sfreq = raw.info["sfreq"] win = int(win_sec * sfreq) step = int(step_sec * sfreq) data = raw.get_data() # shape: (n_channels, n_times) n_times = data.shape[1] X, y, meta = [], [], [] for start in range(0, n_times - win + 1, step): end = start + win t0, t1 = start / sfreq, end / sfreq overlap = 0.0 for s, e in seizures: overlap += max(0.0, min(t1, e) - max(t0, s)) label = 1 if overlap / win_sec >= overlap_thr else 0 X.append(data[:, start:end]) y.append(label) meta.append((t0, t1)) return np.stack(X), np.array(y), meta这段代码里三个参数直接决定样本分布:win_sec控制单样本时间跨度,step_sec控制样本密度,overlap_thr控制正样本纯度。我建议先用 4 秒窗、2 秒步长跑一版,统计正负比例;如果正样本占比低于 5%,可以把步长调小或阈值降到 0.3,但不要低于 0.3,否则过渡段污染会很严重。raw.get_data()这里会把整段读进内存,所以实际工程里要按文件分块处理,处理完一个文件就释放,别一次性把所有受试者都堆在内存里。
3.3 通道统一与重采样:让不同记录能拼进同一个张量
不同 EDF 的通道顺序、通道数、采样率可能不一致,直接np.stack会报错或者悄悄错位。我的做法是维护一张「目标通道表」,按名称从每个记录里挑出对应通道,缺失的通道用零填充并记录掩码,采样率不一致的统一重采样到 256 Hz。通道名匹配要先去空格、统一大小写,再做精确匹配,不要用模糊匹配,否则「T7」和「T8」这种容易串。
TARGET_CH = ["FP1-F7", "F7-T7", "T7-P7", "P7-O1", "FP2-F8", "F8-T8", "T8-P8", "P8-O2"] def align_channels(raw, target=TARGET_CH, target_sfreq=256): if abs(raw.info["sfreq"] - target_sfreq) > 1e-6: raw = raw.resample(target_sfreq, verbose="ERROR") name_map = {c.strip().upper(): c for c in raw.ch_names} picks, mask = [], [] for ch in target: key = ch.strip().upper() if key in name_map: picks.append(name_map[key]) mask.append(1) else: picks.append(None) mask.append(0) data = np.zeros((len(target), raw.n_times), dtype=np.float32) for i, p in enumerate(picks): if p is not None: data[i] = raw.get_data(picks=[p])[0] return data, np.array(mask, dtype=np.int8)TARGET_CH只是我常用的一个子集,你可以按自己的研究问题调整,但一旦定了就不要中途改,否则前面切好的样本全部作废。mask的作用是告诉模型哪些通道是真实数据、哪些是填充,训练时可以把掩码一起喂进去,或者在损失里对缺失通道降权。重采样放在通道对齐之前做,顺序不能反,否则时间轴对不上。这套流程跑下来,你得到的是形状统一的(n_channels, n_times)数组,后面才能拼 batch。
4. 训练与评估:跨受试者划分、模型输入形态与必调参数
4.1 为什么必须按受试者划分,而不是随机划分窗口
这是 CHB-MIT 上最容易自欺欺人的一步。如果你把所有窗口打乱后随机划分训练集和验证集,同一个受试者、同一段记录切出来的相邻窗口会同时出现在两边,模型等于在背答案,指标虚高得离谱。真正有意义的评估是「留一受试者」或者「按受试者分组划分」:训练集里没见过的受试者,才是模型泛化能力的试金石。
我一般用分组划分,把受试者按编号分成训练组和测试组,确保同一个受试者的所有窗口只出现在一边。评估指标也不要只看准确率,正样本稀疏时准确率没有意义,要看召回率、精确率和事件级检测率。事件级的意思是:一段连续发作只要被模型在任意窗口检出,就算这次发作被捕获,这比窗口级指标更贴近临床使用场景。
4.2 模型输入形态:原始时序、频谱图还是特征向量
输入形态的选择直接决定你后面调参的方向。原始时序输入保留全部信息,但对模型结构敏感,一维卷积或时序 Transformer 都能吃;频谱图输入把时频结构显式化,适合卷积网络,但窗长和频率分辨率要权衡;手工特征向量最轻量,适合快速基线,但会丢时间细节。
我的建议是先用频谱图或原始时序跑一个基线,别一上来就堆复杂结构。频谱图的常见做法是对每个窗口做短时傅里叶变换,取对数幅度,得到通道 × 频率 × 时间的张量。参数上,窗长和滑窗切分的窗长保持一致,频率上限截到 40 Hz 左右就够,癫痫放电的主要能量集中在这个范围。下面是一个最小可跑的频谱图生成片段,重点看参数而不是结构。
from scipy.signal import stft def to_spectrogram(window, sfreq=256, nperseg=128, max_freq=40): # window: (n_channels, n_times) f, t, Z = stft(window, fs=sfreq, nperseg=nperseg, noverlap=nperseg // 2) keep = f <= max_freq mag = np.abs(Z[:, keep, :]) return np.log1p(mag).astype(np.float32) # 对数压缩,稳住动态范围nperseg=128在 256 Hz 下对应 0.5 秒的时间分辨率,频率分辨率 2 Hz,对发作节律来说够用。max_freq=40是截断上限,别把 50 Hz 工频和更高频噪声带进来。np.log1p是对数压缩,脑电幅度动态范围大,不压缩的话弱信号会被强信号压掉。这些参数不是唯一解,但作为第一版基线足够稳,后面再按验证集表现微调。
4.3 三个必调参数:窗口长度、正负样本比、学习率策略
窗口长度前面说过,2 到 4 秒是常用区间,短了频谱不稳,长了事件定位变糊。正负样本比是第二个关键,原始切分出来的正样本往往很少,直接训练会让模型偏向全判负。常见做法是对正样本做重采样或者在损失里加类别权重,我倾向后者,因为重采样容易让同一段发作被重复采样多次,增加过拟合风险。学习率策略是第三个,脑电信号噪声大,固定学习率容易在后期震荡,用带 warmup 的余弦退火或者简单的阶梯衰减都行,关键是别用太大的初始学习率。
这三个参数要一起看:窗口变长,单样本信息量变大,正样本比例可能上升,学习率可以适当调小;正样本权重调高,召回率上去但精确率可能下来,要结合事件级指标一起判断。没有一组放之四海皆准的值,但有一套可复现的调参顺序:先固定窗口和样本比,调学习率把训练跑稳;再动样本比看召回和精确的平衡;最后回头微调窗口长度。
5. 避坑与排查:CHB-MIT 上最容易翻车的五个地方
5.1 现象:验证集指标很高,换受试者就崩
原因几乎总是划分方式错了,相邻窗口泄漏或者同一受试者跨了训练和验证。解决方法是强制按受试者分组,划分前先打印每个受试者的窗口数和正样本数,确认没有交叉。这个坑我见过太多次,指标好看不代表模型有用。
5.2 现象:通道对齐后模型完全不收敛
原因多半是通道顺序错位或者通道名匹配失败后被零填充,模型看到的全是无效通道。排查方法是随机抽几个样本,把对齐后的通道波形画出来或者打印每通道方差,方差全为零的通道就是填充通道。解决方法是把通道名匹配的日志打出来,确认每个目标通道都命中了真实通道。
5.3 现象:正样本少到无法训练
原因是窗口切分阈值太严或者步长太大。解决方法是先统计正负比例,再决定是降阈值、缩步长还是加类别权重。注意不要为了凑正样本把阈值降到 0.3 以下,过渡段污染会让模型学到错误的边界模式。
5.4 现象:训练损失正常但事件级检测率很低
原因是模型在窗口级学到了东西,但发作起始的短窗口被漏检。解决方法是评估时按事件聚合,一段发作内任意窗口检出即算命中,同时检查窗口步长是否太大导致起始段被跳过。必要时对疑似发作段做更细的滑窗二次扫描。
5.5 现象:不同文件采样率不一致导致时间轴错乱
原因是重采样放在了通道对齐之后,或者根本没做重采样。解决方法是统一在读取后立刻重采样到目标采样率,再做通道对齐和切窗,顺序不能乱。排查时打印每个文件的sfreq和重采样后的n_times,对不上就是这一步出了问题。
6. 进阶技巧:用事件级后处理把窗口预测变成可用的发作报警
窗口级模型输出是一串概率,直接拿阈值卡会得到碎片化的报警,临床或工程上都不好用。我一般会加一层事件级后处理:对同一文件的概率序列做平滑,再用「连续若干窗口超过阈值」作为触发条件,最后把触发区间合并成事件。这样做的好处是把孤立的假阳性压下去,同时保住连续发作的召回。
具体做法是:先对概率做长度为 3 的滑动平均,阈值取 0.6 左右,要求连续 2 个窗口超过阈值才触发,触发后向后合并间隔小于 10 秒的事件。这几个数字不是固定的,要按你的窗口步长换算成时间:步长 2 秒时,连续 2 个窗口相当于至少 4 秒的持续高概率,能过滤掉大部分瞬态噪声。合并间隔设 10 秒是为了把同一段发作被切碎的几个事件拼回去。
验证这套后处理有没有用,不要只看总体指标,要分别统计「事件召回率」和「每小时误报次数」。后者更贴近实际使用,因为一个动辄每小时几十次误报的系统没人愿意用。我习惯在测试集上按受试者分别算这两个数,再看分布,而不是只看平均值,平均值会掩盖个别受试者上的灾难性表现。
还有一个容易被忽略的点:模型概率的校准。不同受试者、不同记录段上概率分布可能偏移,固定阈值在有的受试者上偏松、有的偏紧。简单做法是在验证集上按受试者做分位数校准,或者直接用事件级指标反推阈值,而不是死守一个全局阈值。这一步做完,你会发现同一套模型在不同受试者上的可用性差别很大,这也是为什么我坚持按受试者看结果。
血泪经验是:别在窗口级指标上自我陶醉,事件级后处理和按受试者的误差分析才是决定这套东西能不能用的关键。我现在的习惯是每跑完一版模型,先看事件召回和每小时误报,再看窗口级指标,顺序反过来很容易被好看的数字带偏。希望帮到你。
本文还有配套的精品资源,点击获取