做了这么多年心电信号处理,和 MIT-BIH 数据集打交道的时间不算短。最近带几个新人入门,几乎每个人都会卡在同一个环节:数据下载下来,打开文件夹一看,.hea、.dat、.atr一堆不认识的文件,网上代码跑不通,也不知道每个文件到底干嘛用的。这篇文章就把我这些年读 MIT-BIH 数据的经验完整写一遍,从文件作用讲到二进制解析,再到实际项目里最容易踩的坑,你照着做基本能一次跑通。
1. 拿到 MIT-BIH 数据集,先看清楚一个记录由哪几类文件组成
MIT-BIH 心律失常数据库里的每条记录,说白了一个病人 30 分钟左右的动态心电,但为了把这份数据完整描述出来,PhysioNet 把它拆成了好几个配套文件。你不搞清楚这些文件的分工,后面写代码就是在黑箱里瞎试。
1.1 .hea 头文件:整条记录的"说明书"
.hea文件是纯文本,体积很小,但它是读取整条记录的入口。里面写明了记录名、采样点数、采样率、信号通道数、每个信号对应的数据文件名、增益、基线、ADC 分辨率等等。
以常见的 100 号记录为例,.hea内容大概是这个样子:
100 650000 360 200 0 100.dat 200 1024 200 11 1024 861 0 0 x 0 100.dat 200 1024 200 11 1024 961 0 0 x 0第一行是记录总览,关键信息是:记录名100,总共 650000 个采样点,采样率 360 Hz,后面两个数字属于历史遗留的附加字段,普通使用场景下基本可以不用关心。
后面两行分别描述两个导联。以第一个信号行举例,字段含义大致如下:
100.dat:该通道波形数据存放在这个文件里;200:ADC 增益,200 counts/mV;1024:基线值;200:单位相关的校准信息;11:ADC 有效分辨率 11 位;1024:ADC 零点;861:校验和等辅助字段,解析时可忽略。
不同年化版本的 MIT-BIH 头文件书写格式会有一点点差异,但关键信息都在这几个字段里。核心重点就两个:采样率 360 Hz,以及增益 200 counts/mV。这两个数值后面会频繁用到。
1.2 .dat 数据文件:真正的波形二进制
.dat文件是真正的心电波形数据,二进制格式。MIT-BIH 采用的是一种叫 format 212 的压缩格式:每 3 个字节存放两个 12 位采样值。只从文件名完全看不出来这个压缩规则,所以必须先读.hea,再根据其中的格式信息去解析.dat。
100 号记录两个通道共 130 万个采样值,.dat文件大小大约为 650000 × 3 ≈ 1.95 MB。数据本身是整数,不是直接以 mV 为单位的浮点数,这一点初学的人特别容易搞混。读出原始整数之后,还必须结合增益和基线做一次换算,才能得到真实的电压值。
1.3 .atr 注释文件:标注心跳和节律事件
.atr是注释文件,保存的是由心内科医生手工标注的心拍位置、节律类型、信号质量等信息。所谓"MIT-BIH 心律失常数据库",最重要的价值恰恰就在这些注释里。
注释常见符号包括:
| 符号 | 含义 |
|---|---|
| N | 正常心拍 |
| V | 室性早搏 |
| A | 房性早搏 |
| F | 融合心拍 |
| L | 左束支传导阻滞 |
| R | 右束支传导阻滞 |
| · | 无法判断的干扰段 |
.atr也是二进制格式,正常使用中由函数库负责解析。你只要通过接口拿到"第几个采样点是一个心拍、这个心拍属于什么类型"就可以了。
真实项目里,训练心律失常分类模型,就是拿.dat里的波形片段作为输入,拿.atr里的心拍类型作为标签。读懂了.atr,你才有资格做后续的分类、检测、分割。
2. 文件格式的核心数值:采样率、增益、ADC 零点到底做什么用
很多人用 wfdb 库读数据,读出来一个 numpy 数组就直接拿去训练了,但中间有一层"物理单位还原"的逻辑被库封装掉了。如果哪天你必须脱离库自己解析,这层逻辑就必须搞清楚。
2.1 采样率 360 Hz 和记录时长怎么换算
360 Hz 意味着每秒采样 360 个点。一条记录的650000是采样点数,换算成时长就是:
650000 / 360 ≈ 1805.56 秒 ≈ 30.09 分钟所以每条 MIT-BIH 记录大约半小时。这个换算关系在做时间轴定位时很重要。比如注释文件告诉你第 100 号心拍在采样点12345处,那么对应的时间是:
12345 / 360 ≈ 34.29 秒这也是为什么很多画图代码里 x 轴喜欢用sample index / 360来标注秒数。看起来很简单,但如果你用的是别的数据集,采样率是 250、512 甚至 1000,这个换算就必须跟着变,硬编码 360 是新手最容易犯的错误。
2.2 200 counts/mV 增益与 1024 零点,怎么还原真实电压
ADC 采样得到的原始整数不是毫伏数。MIT-BIH 的常见配置是设备每 1 mV 的电压变化,量化成 200 个最小单位,也就是增益 200 counts/mV。而 ADC 零点一般在 1024 附近,对应 0 mV 时的原始读数。
真实电压的换算公式是:
voltage_mV = (raw_value - baseline) / gain基线通常取零点的近似值。比如原始读数 1224,基线 1024,增益 200,那么:
(1224 - 1024) / 200 = 1.0 mV说明这个采样点对应的电压是 1 mV。这里的"基线"在wfdb库里的字段名是base或baseline,在.hea里对应信号行的第三个字段。不同记录的这个值可能有细微差别,所以不要全局硬编码为一个固定值,最好每次从头文件里读。
2.3 为什么即使只有三个文件,也不建议完全从零解析
理论上掌握了采样率、增益、格式 212 的打包规则,你完全可以自己写代码把.dat读出来,把.atr也读出来。但实际做一遍你会发现格式 212 的位操作很容易出错,.atr的注释结构还有变长字段,解析到一半输出全乱的情况非常常见。
我的建议是:自己在小规模数据上写一遍解析逻辑,加深理解,真正做项目时直接使用库。后面第四节我会给出一个不依赖库的最小解析代码,但要记住,那只是学习用的简化版,生产环境请无条件相信wfdb。
3. 用 Python 读 MIT-BIH 的推荐路线:wfdb 库如何安排
目前 Python 生态里读 MIT-BIH 最省事的库就是wfdb。它不是 PhysioNet 官方维护的唯一工具,但文档齐全,函数接口简单,社区使用者最多。
3.1 环境安装与数据获取
安装只需要一条命令:
pip install wfdb我建议同时装好numpy、matplotlib,一个负责计算,一个负责画波形。
数据有两种获取方式。第一种是到 PhysioNet 官网下载mit-bih-arrhythmia-database-1.0.0.zip,解压后你会看到100.dat、100.hea、100.atr这样的文件。第二种是用wfdb库自带的数据库下载工具:
import wfdb wfdb.dl_database('mitdb', dl_dir='./mitdb')这种方式会自动下载整个数据库,包括所有记录。如果你只要 100 号记录测试,用官网下载单个记录的文件就够了。
注意一个细节:wgdb的dl_database返回的下载目录结构里,文件名是100不带扩展名,而.hea、.dat、.atr会以同名前缀存在于同一目录。wfdb库读取时传入记录名100就行,不需要手动补扩展名。
3.2 读取头文件、波形、注释的三条核心命令
读取波形的标准写法:
import wfdb record = wfdb.rdsamp('100', pb_dir='mitdb') signals = record[0] # 二维数组,形状为 (650000, 2) fields = record[1] # 头文件信息字典record[1]里常用的字段包括:
fs:采样率,360;sig_name:通道名列表;units:单位列表;baseline:基线值列表;adc_gain:增益列表。
读取注释的标准写法:
ann = wfdb.rdann('100', 'atr', pb_dir='mitdb') sample_indices = ann.sample # 心拍所在的采样点索引 symbols = ann.symbol # 对应心拍类型符号ann.sample、ann.symbol、ann.subtype、ann.chan覆盖了绝大多数论文里你需要的信息。拿到sample和symbol,你就可以在心拍位置打标签做分类了。
3.3 一口气完成:从记录到可分析的数组
下面这段代码是我给新人的标准模板,读100号记录,把两个导联的波形和注释一起整理出来:
import wfdb import numpy as np signals, fields = wfdb.rdsamp('100', pb_dir='mitdb') ann = wfdb.rdann('100', 'atr', pb_dir='mitdb') fs = fields['fs'] signal_0 = signals[:, 0] signal_1 = signals[:, 1] beat_positions = ann.sample beat_types = ann.symbol print(f"采样率: {fs} Hz") print(f"信号形状: {signals.shape}") print(f"心拍数量: {len(beat_positions)}")输出大致是这样:
采样率: 360 Hz 信号形状: (650000, 2) 心拍数量: 2273100 号记录有 2273 个心拍,这个数字可以作为验证读取是否正常的参考值。如果读出来的心拍数量差很多,十有八九是数据文件下载不完整或者路径传错了。
3.4 可视化:确认波形是否正常
读数据成功的第一个直观标志就是波形图。画前 5 秒的波形:
import matplotlib.pyplot as plt duration_sec = 5 n_show = int(duration_sec * fs) t = np.arange(n_show) / fs plt.figure(figsize=(12, 4)) plt.plot(t, signal_0[:n_show], label='MLII') plt.plot(t, signal_1[:n_show], label='V5') plt.xlabel('时间 (秒)') plt.ylabel('电压 (mV)') plt.legend() plt.grid(True) plt.show()正常波形应该是清晰的心电图形态,QRS 波群肉眼可见,基线基本平稳。如果画出来是一片锯齿或者数值范围巨大,检查一下是不是把原始 ADC 整数当成了 mV 直接用。rdsamp默认已经做了增益换算,返回的数据单位就是 mV,如果你自己解析,就要手动补上上一步换算。
4. 如果不想依赖 wfdb:二进制层怎么读
wfdb确实方便,但理解底层对排查问题极有帮助。尤其是你把代码部署到无网络环境或者换用其他语言时,必须知道 212 格式的位规则。
4.1 逐字节解析 .dat 文件(格式 212 的打包规则)
格式 212 的核心是:每 3 个字节存放两个 12 位采样值。第一个采样值取第 1 字节的全部 8 位,加第 2 字节的低 4 位;第二个采样值取第 2 字节的高 4 位,加第 3 字节的全部 8 位。
用位运算表示:
import numpy as np def read_mit_bih_212(file_path, n_samples): raw = np.fromfile(file_path, dtype=np.uint8) samples = [] for i in range(0, n_samples * 3, 3): b0 = int(raw[i]) b1 = int(raw[i + 1]) b2 = int(raw[i + 2]) val1 = b0 | ((b1 & 0x0F) << 8) val2 = ((b1 & 0xF0) >> 4) | (b2 << 4) samples.append(val1) samples.append(val2) return np.array(samples[:n_samples])注意val2的高位来自b2的全部 8 位,所以左移 4 位后得到的是 12 位值。上面代码是简化版,没有处理符号位,因为 MIT-BIH 的基线通常在 1024,原始值以无符号形式存储,减基线后就得到实际信号偏移。如果换到别的数据库,比如欧标数据,签名格式可能会不同,需要先看.hea里的格式描述。
你的数据文件如果读出来乱码,最常见就是字节顺序搞反了。212 格式是小端风格的低位在前,逐字节解析时必须按照上面规则取位,不能直接把 3 字节解释成一个大整数。
4.2 头文件增益、基线和单位换算的代码
手动读.hea时,只要取信号行的前几个关键字段。解析代码参考:
def parse_hea(file_path): with open(file_path) as f: lines = f.readlines() record_name, n_samples, fs, *_ = lines[0].split() signal_info = [] for line in lines[1:]: if not line.strip(): continue parts = line.split() signal_info.append({ 'file': parts[0], 'adc_gain': float(parts[1]), 'baseline': float(parts[2]), 'resolution': int(parts[4]), 'adc_zero': float(parts[5]), }) return record_name, int(n_samples), float(fs), signal_info拿到这些字段后,把原始整数转换为毫伏:
voltage = (raw_value - info['baseline']) / info['adc_gain']如果.hea里的增益是 200,基线 1024,那么raw_value=1224时电压恰好是 1 mV。我在实际项目中碰到过一个很隐蔽的问题:有些记录的baseline字段是 1024,但adc_zero也是 1024,二者含义不同。ADC 零点是硬件零输入时的读数,基线的概念更偏信号处理中的参考水平。日常换算减法时用哪个都行,因为两者数值几乎一样,但如果你做精确算法对比实验,务必统一使用wfdb返回的baseline。
4.3 手动解析的限制与坑
自己解析.atr是非常不推荐的。.atr有 16 字节的固定头、可变长度的注释块,还涉及时间戳和类型映射。网上有少量解析代码,但几乎都是针对某一版本的记录写的,换个记录可能就挂。
我做过一个实测:手写 212 格式解析 100 号记录,波形与wfdb读出来的一致,但一旦换到某些含格式 16 的记录就出错。格式 16 是另一个存储方案,每 2 字节一个采样值,属于带符号的原始格式,解析方式完全不一样。这也是为什么.hea里必须写明格式号,写代码时不能假设全库都是 212。
5. 实际项目里我反复踩过的 7 个坑
这一节全部来自真实经历。下面这些问题每个都让团队里至少一位同学卡过一整天。
5.1 路径与文件名问题
wfdb.rdsamp传入的记录名是100而不是100.hea。如果你写wfdb.rdsamp('100.hea'),大概率报错File not found。另外pb_dir参数指向数据库根目录,如果你的文件直接放在当前目录,就省略pb_dir。
Windows 环境下,文件路径里的反斜杠容易在字符串里被转义,推荐统一用正斜杠或pathlib.Path。以前我不注意,写'./mitdb\100'导致\1被转成不可见字符,排查了很久。
5.2 读取注释时的索引和时间戳问题
ann.sample返回的是采样点索引,而不是时间。后续画图时一定记得除以采样率fs。另一个容易犯的错是用ann.sample直接做切片时把数组边界超了,因为最后几个心拍可能落在信号末尾附近,切片时最好对n_show做一次min保护。
wfdb.rdann还有一个return_label_elements参数,返回的symbol数组标签类型更完整,但符号数量可能比sample短。排序对齐时提前做好长度检查,不然会得到奇怪的维度不匹配错误。
5.3 Python 包版本差异
wfdb这个包在早期版本和 4.x 系列之间接口有变化。有些老代码用wfdb.io.rdsamp,新版本里wfdb.io依然存在但更推荐直接wfdb.rdsamp。如果你在网上找到的教程同时出现rdann和rdrecord,注意它们返回类型不同:
rdsamp:返回(信号数组, 字段字典),信号数组是 numpy 二维数组;rdrecord:返回一个Record对象,调用.p_signal或.d_signal获取数据。
新手最懵的就是这两种接口混用。我的建议是统一用rdsamp,打印输出最直观,调试成本低。
5.4 内存和批量读取
整个 MIT-BIH 数据库 48 条记录全部加载到内存大概需要 90 MB 左右,看起来不大,但如果你做滑动窗口采样,一次性把 130 万采样点切成几十万个片段,内存就爆了。正确做法是:
- 先读取记录,只保留需要的通道;
- 生成心拍位置列表;
- 根据心拍位置在线切窗,不保留全量片段。
每批只保留几百条片段,训练更稳定,内存占用控制在 2 GB 以内。
5.5 注释质量问题的干扰
.atr里有大量噪声段和不完全定义的注释。做评估指标时,如果用N、V两种类型做二分类,训练集和测试集的类别分布还好。但如果你把?、R、L之类全当真标签,模型会学得很痛苦。
我自己常用的一组筛选规则是:只保留N、V、A、F、L、R六类,其他注释全部丢弃。这样得到的样本更干净,论文结果也更可复现。
5.6 手动解析格式 212 时的符号问题
有些记录不是纯正向偏移,原始值减去基线后可能出现负数。你如果直接按无符号路径解析就会出错。
如果你不得不用手动解析,建议以.hea里记录的baseline为中点,raw_value - baseline如果大于 2047,再手动减 4096。这种偏移处理能从 100 号记录验证,但不保证所有记录都适用。再次强调,能用库就别自己解析。
5.7 从 0 开始索引 vs 从 1 开始索引
wfdb的ann.sample使用的是 0 起始索引,和 numpy 一致,直接用没问题。但 PhysioNet 官方文档的某些描述用的是 1 起始索引,网上零星代码里也可能混用。之前我在对比算法时,因为没注意到这一点,结果整体偏移了一个点,导致心拍检测精度差了一个采样点,在 360 Hz 下相当于 2.8 ms 的偏差,对某些评测指标影响不小。
调试时可以打印ann.sample[:10],心拍位置应该在几十到几百的范围内,如果整体都是 1 偏大,就要留意索引体系是否统一。
6. 把"读取"做成一类可复用的小工具
项目只要碰过 MIT-BIH 一次,后面大概率还会再碰第二次、第三次。与其每次重写读取代码,不如第一次就把读取和预处理封装好。
6.1 一个覆盖主要需求的工具函数
下面这个函数我用了挺久,核心功能是"按记录名读取波形和注释,并返回筛选后的心拍片段":
import wfdb import numpy as np def load_mit_record(record_name, channels=None, keep_types=('N', 'V', 'A', 'F', 'L', 'R')): signals, fields = wfdb.rdsamp(record_name) ann = wfdb.rdann(record_name, 'atr') fs = fields['fs'] if channels is not None: ch_indices = [fields['sig_name'].index(c) for c in channels] signals = signals[:, ch_indices] valid_flag = np.isin(ann.symbol, list(keep_types)) return signals, ann.sample[valid_flag], np.array(ann.symbol)[valid_flag], fs使用示例:
signals, beats, symbols, fs = load_mit_record('100', channels=['MLII'])这样你就拿到了一个单导联的信号数组、心拍位置列表、心拍类型列表和采样率。绝大多数深度学习模型训练都只需要这几个量。
6.2 配合 NumPy 做基本心电预处理的流程
读取只是第一步。真实业务里你还需要去基线漂移、去工频干扰。这里我一般分三步:
第一步,整段信号做高通滤波,截止频率 0.5 Hz 左右,去掉基线漂移; 第二步,用陷波器或小波变换处理 50 Hz 工频干扰; 第三步,以每个心拍位置为中心,截取前后 0.6 秒的片段,作为模型输入。
截取片段时注意索引边界:
def extract_beats(signal, beat_positions, fs, before=0.6, after=0.6): n_before = int(before * fs) n_after = int(after * fs) segments = [] for pos in beat_positions: start = pos - n_before end = pos + n_after if start >= 0 and end <= len(signal): segments.append(signal[start:end]) return np.array(segments)这个流程对大部分对比实验够用,也不复杂,可以快速验证你从.dat里读出的波形质量。
6.3 面向后续扩展的方向
一旦把读取和预处理做成工具函数,后面就可以很自然往几个方向延伸:
- 心拍分类:提取每个心拍片段的时域、频域特征,或者直接给卷积网络输入原始波形;
- 节律分析:结合
.atr里更粗粒度的节律注释,做房颤、心室颤动等长程事件检测; - 多记录交叉验证:用 47 条记录做训练,留 1 条做验证,保证训练集和测试集样本不互相污染。
我在实际项目里踩过最重的一次坑,就是忘了做"按记录划分数据集",结果同一条记录的心拍碎片同时出现在训练集和测试集,模型指标漂亮得离谱,部署后彻底崩盘。MIT-BIH 虽然只有 48 条记录,但正确划分后依然能做出稳定可靠的心律失常分类模型。
读取数据本身不是终点,把数据理解透、文件格式弄明白,后面的每个实验才能稳稳落地。