简介:这份资源面向神经科学、医学信号处理方向的学习者与研究者,聚焦EDF格式脑电波文件的读取与癫痫数据分析,适合具备一定Python基础、希望上手EEG数据处理的初中级用户。压缩包共3个文件,均为m脚本文件,整体约4KB,涵盖EDF读取、数据读取与轨迹恢复等核心功能模块,可直接在MATLAB环境中调用,帮助读者快速完成脑电信号的导入与初步整理。资源围绕EDF这一生物医学信号标准格式展开,涉及多通道电位记录、时间戳与采样率解析等关键概念,为后续滤波、去噪、功率谱分析及癫痫发作检测等任务打下数据基础。目前已有1293人学习下载,说明其在脑电数据处理入门场景中具有一定参考价值。对于需要从零搭建EEG分析流程、理解EDF文件结构并开展癫痫相关特征提取的读者,这份脚本集合可作为轻量级的起步工具,配合MNE、pyedflib等库使用效果更佳。
1. 从一份 EEG.rar 说起:EDF 脑电文件到底难在哪
你拿到手的往往是一个压缩包,名字可能就叫 EEG.rar,解压后是一堆后缀为 .edf 的文件。双击打不开,拖进播放器没反应,用文本编辑器打开全是乱码。这不是文件坏了,而是 EDF(European Data Format)本身就是一种为生理信号设计的二进制容器格式,它把脑电、心电、眼电、肌电等多通道时序数据连同采集参数一起打包,普通工具根本读不懂。癫痫相关的脑电数据尤其典型:采样率常见 256Hz 或 512Hz,通道数从 19 导联到 64 导联不等,单次记录动辄几小时,文件体积轻松上百 MB。这个标题背后真正要解决的问题是:怎么把 EDF 里的脑电波数据可靠地读出来,变成能送进分析流程的数组,并且不踩那些让人查半天的坑。适合做癫痫检测、睡眠分期、脑机接口预处理的从业者,也适合刚接触生理信号的新手照着复现。
2. EDF 文件结构拆解与读取工具选型
2.1 EDF 头部到底存了什么
EDF 文件分两段:头部和数据段。头部固定 256 字节,后面跟着每个信号通道各 256 字节的通道描述。头部里最关键的是这几个字段:版本号、患者信息、记录信息、起始日期时间、头部字节数、保留字段、记录条数、每条记录持续时间、通道数量。通道描述里则存了通道标签、 transducer 类型、物理单位、物理量上下限、数字量上下限、预滤波信息、每通道每记录的采样点数。
这里有个容易忽略的点:EDF 的采样率不是直接存的,而是用「每通道每记录采样点数 ÷ 每条记录持续时间」算出来的。比如每记录 1 秒、每通道 256 个采样点,采样率就是 256Hz。物理量上下限和数字量上下限决定了原始整数怎么转成微伏。转换公式是:
物理值 = 物理下限 + (数字值 - 数字下限) × (物理上限 - 物理下限) / (数字上限 - 数字下限)这个公式必须记牢,后面读数据全靠它。另外 EDF+ 是 EDF 的扩展,支持事件标注和更规范的患者信息编码,癫痫数据里经常带发作标注,遇到 EDF+ 要按扩展规范解析标注通道。
2.2 Python 生态里读 EDF 的三条路
常见做法有三种。第一种是 MNE-Python,功能最全,一行mne.io.read_raw_edf()就能读,自动处理单位换算和通道类型识别,适合做完整分析流水线。第二种是 pyedflib,更底层,直接暴露头信息和原始采样,适合需要精细控制或做格式转换的场景。第三种是自己按二进制解析,只在需要极致性能或处理非标准文件时才考虑。
我一般先用 MNE 快速验证文件能不能读、通道对不对,再用 pyedflib 做批量预处理。选型理由很简单:MNE 帮你挡掉了大量格式细节,但遇到非标准 EDF 时它的容错策略可能不符合预期;pyedflib 给你完全控制权,代价是要自己处理单位换算和异常。
2.3 用 MNE 读第一个 EDF 文件
先装依赖:
pip install mne pyedflib numpy scipy读取和查看基本信息:
import mne # 读取 EDF 文件,preload=True 表示把数据加载进内存 raw = mne.io.read_raw_edf("seizure_01.edf", preload=True, verbose=False) # 打印基本信息:通道名、采样率、时长 print("通道数:", len(raw.ch_names)) print("通道名:", raw.ch_names) print("采样率:", raw.info["sfreq"], "Hz") print("时长:", raw.times[-1], "秒") # 查看前 5 秒数据形状 data = raw.get_data(start=0, stop=int(5 * raw.info["sfreq"])) print("数据形状 (通道数, 采样点):", data.shape)preload=True会把整个文件读进内存,几小时的数据可能占几个 GB,内存紧张时改成preload=False做惰性加载。verbose=False只是关掉 MNE 的冗余输出,不影响功能。get_data返回的是 numpy 数组,单位默认是伏特,MNE 已经帮你做了物理量换算。
2.4 用 pyedflib 拿到原始头信息
当你需要确认文件是不是标准 EDF、通道物理单位是什么、有没有标注时,pyedflib 更直接:
import pyedflib # 以只读方式打开 f = pyedflib.EdfReader("seizure_01.edf") # 文件级信息 print("信号通道数:", f.signals_in_file) print("记录时长(秒):", f.getFileDuration()) print("起始时间:", f.getStartdatetime()) # 逐通道信息 for i in range(f.signals_in_file): label = f.getLabel(i) sfreq = f.getSampleFrequency(i) unit = f.getPhysicalDimension(i) print(f"通道{i}: {label}, 采样率={sfreq}Hz, 单位={unit}") # 读取某通道全部数据 signal = f.readSignal(0) print("通道0 数据点数:", len(signal), "范围:", signal.min(), signal.max()) f.close()getSampleFrequency返回的就是前面说的计算后的采样率。readSignal返回的已经是物理值,pyedflib 内部做了换算。注意不同通道采样率可能不同,EDF 允许每个通道独立设置,处理多采样率数据时要先统一重采样。
2.5 批量读取与通道筛选
实际项目里通常是一个目录几十上百个 EDF,需要批量处理并只保留脑电通道:
import os import mne import numpy as np # 常见脑电通道名,按实际设备调整 EEG_CHANNELS = ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", "Fz", "Cz", "Pz"] def load_eeg_only(path): raw = mne.io.read_raw_edf(path, preload=True, verbose=False) # 只保留存在的脑电通道 keep = [ch for ch in raw.ch_names if ch in EEG_CHANNELS] if not keep: raise ValueError(f"{path} 未找到标准脑电通道") raw.pick_channels(keep) return raw data_dir = "./edf_data" all_raw = [] for fname in sorted(os.listdir(data_dir)): if fname.lower().endswith(".edf"): try: raw = load_eeg_only(os.path.join(data_dir, fname)) all_raw.append(raw) print(f"{fname}: {len(raw.ch_names)} 通道, {raw.times[-1]:.1f} 秒") except Exception as e: print(f"{fname} 读取失败: {e}")pick_channels会按给定顺序重排通道,保证不同文件通道顺序一致,这对后续拼接和训练至关重要。异常捕获不能省,真实数据里总有那么几个文件格式不标准。
3. 从原始采样到可分析数组:滤波、重采样与分段
3.1 为什么必须做带通滤波
原始脑电里混着基线漂移、工频干扰和肌电噪声。癫痫检测常用的频段是 0.5–40Hz 或 0.5–70Hz,前者够用且计算量小,后者保留高频信息但更容易引入噪声。滤波不是为了好看,而是防止后续特征被低频漂移主导。
# 带通滤波 0.5-40Hz raw_filtered = raw.copy().filter(l_freq=0.5, h_freq=40.0, fir_design="firwin", verbose=False) # 陷波滤除 50Hz 工频(国内电网频率) raw_filtered = raw_filtered.notch_filter(freqs=50.0, verbose=False)fir_design="firwin"是默认的 FIR 设计方法,线性相位,不会造成波形畸变。notch_filter只在工频干扰明显时用,过度陷波会损失 50Hz 附近的真实脑电成分。滤波后建议画一下功率谱确认。
3.2 重采样到统一频率
不同设备采样率不同,模型输入需要统一。从高往低重采样是安全的,从低往高会引入虚假高频。
target_sfreq = 256.0 if raw_filtered.info["sfreq"] != target_sfreq: raw_resampled = raw_filtered.resample(target_sfreq, verbose=False) else: raw_resampled = raw_filtered重采样前必须先做抗混叠滤波,MNE 的resample内部会自动处理,但如果你自己用 scipy 的resample,一定要先低通到目标采样率的一半以下。
3.3 滑动窗口分段与标签对齐
癫痫检测通常把长记录切成固定长度片段,比如 2 秒或 4 秒一段,带重叠。标签来自发作标注文件,需要把时间区间映射到片段上。
import numpy as np def make_windows(raw, win_sec=2.0, overlap=0.5): sfreq = raw.info["sfreq"] data = raw.get_data() # (通道, 采样点) win_len = int(win_sec * sfreq) step = int(win_len * (1 - overlap)) windows = [] starts = [] for start in range(0, data.shape[1] - win_len + 1, step): windows.append(data[:, start:start + win_len]) starts.append(start / sfreq) return np.stack(windows), np.array(starts) def label_windows(starts, win_sec, seizure_intervals): # seizure_intervals: [(start_sec, end_sec), ...] labels = np.zeros(len(starts), dtype=int) for i, s in enumerate(starts): e = s + win_sec for ss, se in seizure_intervals: # 片段与发作区间有重叠即标为正 if s < se and e > ss: labels[i] = 1 break return labels windows, starts = make_windows(raw_resampled, win_sec=2.0, overlap=0.5) seizure_intervals = [(120.0, 180.0), (600.0, 660.0)] # 示例 labels = label_windows(starts, 2.0, seizure_intervals) print("窗口数:", windows.shape, "正样本数:", labels.sum())重叠率 0.5 是常见起点,既能增加样本量又不会让相邻窗口过于相似。标签对齐时用「有重叠即正」是保守策略,会略微高估发作时长,但对检测任务更安全。如果做精确发作起始检测,要改成按窗口中心点判断。
3.4 归一化与伪迹处理
不同受试者、不同设备的信号幅度差异很大,逐通道 z-score 归一化是标配:
def zscore_per_channel(windows): # windows: (n_windows, n_channels, n_points) mean = windows.mean(axis=2, keepdims=True) std = windows.std(axis=2, keepdims=True) + 1e-8 return (windows - mean) / std windows_norm = zscore_per_channel(windows)伪迹处理看需求:简单做法是幅度阈值剔除,超过 ±200μV 的窗口丢掉;严格做法用 ICA 分离眼电和肌电成分。癫痫数据里发作期本身幅度就大,阈值不能设太紧,否则把真发作也滤掉了。
4. 癫痫脑电数据处理的避坑清单
4.1 通道名不统一导致 pick_channels 静默丢数据
现象:批量处理时某些文件读出来通道数变少,但没报错。原因:不同设备通道命名不同,有的用 "T3",有的用 "T7",有的带前缀 "EEG Fp1"。pick_channels找不到就跳过,不抛异常。解决:先统计所有文件的通道名集合,建立映射表,把别名统一到标准名,再执行筛选。处理前打印每个文件保留的通道列表,人工抽查几个。
4.2 物理量换算搞反导致信号幅度离谱
现象:读出来的数据范围是 ±几千甚至 ±几万,画图完全看不出脑电形态。原因:自己解析二进制时把物理上下限和数字上下限用反了,或者忽略了单位是微伏还是伏。解决:用 pyedflib 读同一文件对比,确认换算正确。MNE 读出来默认是伏特,量级在 1e-5 到 1e-4,如果看到 1e-2 以上就要警惕。
4.3 重采样前没滤波引入混叠
现象:降采样后波形出现不存在的低频波动。原因:直接抽取或重采样前没做抗混叠低通。解决:始终用 MNE 的resample,或自己先filter(h_freq=target_sfreq/2)再重采样。顺序不能反。
4.4 发作标注时间基准不一致
现象:标签和信号对不上,发作段标到了正常段。原因:标注文件的时间起点和 EDF 起始时间不一致,有的从 0 开始,有的用绝对时间。解决:读 EDF 时记录getStartdatetime,把标注时间统一转换到相对文件起始的秒数。转换前先拿一条已知发作的记录验证。
4.5 内存爆掉:preload 与批大小
现象:处理几小时记录时进程被系统杀掉。原因:preload=True把全部数据读进内存,多文件并行时内存翻倍。解决:单文件处理完立即释放,用生成器逐文件产出窗口;或preload=False配合get_data按需读取。训练时用 Dataset 惰性加载,不要一次性把所有窗口堆成一个大数组。
5. 进阶:把 EDF 读取接进癫痫检测流水线的一个实用技巧
前面把读取、滤波、分段、归一化串起来了,但真正决定模型能不能用的,往往是训练集和验证集的划分方式。癫痫数据有个特点:同一个受试者的不同片段高度相关,如果随机划分,验证集里会有大量和训练集同源的窗口,指标虚高,上线就翻车。我一般按受试者划分,或者按记录划分,确保验证集里的受试者/记录在训练时完全没见过。
具体做法是维护一个受试者 ID 列表,用GroupKFold或简单的按 ID 切分:
from sklearn.model_selection import GroupKFold import numpy as np # subject_ids: 每个窗口对应的受试者编号 subject_ids = np.array([...]) # 长度等于窗口数 labels = np.array([...]) X = windows_norm # (n_windows, n_channels, n_points) gkf = GroupKFold(n_splits=5) for fold, (train_idx, val_idx) in enumerate(gkf.split(X, labels, groups=subject_ids)): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = labels[train_idx], labels[val_idx] print(f"Fold {fold}: 训练 {len(train_idx)} 验证 {len(val_idx)}") # 这里接模型训练另一个技巧是保存中间结果时用内存映射,避免每次调参都重新读 EDF:
# 第一次处理后保存为 npy,后续直接加载 np.save("windows_norm.npy", windows_norm) np.save("labels.npy", labels) np.save("subject_ids.npy", subject_ids) # 后续实验 windows_norm = np.load("windows_norm.npy", mmap_mode="r")mmap_mode="r"让 numpy 按需从磁盘读取,不占满内存,对几 GB 的窗口数组特别有用。代价是随机访问比内存慢,但顺序读取训练时几乎无感。
验证方法上,除了看准确率,一定要看每个受试者单独的召回率和误报率。癫痫检测里漏报比误报代价高得多,阈值要按临床需求调,不能只看 F1。我习惯先固定一个可接受的误报率上限,再在这个约束下最大化召回。
最后说个血泪经验:EDF 读取阶段埋的坑,到模型阶段会以「指标莫名其妙」的形式爆发,排查成本翻十倍。所以读数据时多打印、多画图、多和 pyedflib 交叉验证,别急着往下跑。希望帮到你。
本文还有配套的精品资源,点击获取