简介:一套12导联心电图(ECG)数据集资源,面向医学数据分析、机器学习及深度学习入门与进阶学习者,适用于异常检测、心率变异分析、心电分类等典型任务;数据规模为39732条记录,已按7:3划分为训练集与测试集,便于直接用于模型训练与泛化验证。包内共45个文件,以37个csv数据文件为主体,含各样本心电记录及标签文件train_label.csv,另有XML配置、README中文说明、.gitignore及项目配置文件等辅助内容,压缩包大小约10.75MB,已有4081人学习/下载。使用时可参考描述中提到的预处理要点,如去噪、标准化与时间序列对齐,也可利用CNN、RNN等方式建模。这份资源适合作为算法练习与科研实验的基础数据,能帮助读者省去数据整理时间,专注于特征工程、模型构建与结果解释。
1. 12导联心电图数据:比想象中难啃,也比想象中值钱
做心电AI的团队,手里要是有一套完整的12导联心电图数据,一半人当它是“三个通道的波形”直接丢进CNN,另一半人卡在文件解析上就先放弃了。真正的问题是:12导联不是传感器数量翻倍,它是一组由心电向量在不同方位投影出来的时序信号,导联之间自带电轴、空间和时间关系,导联与导联的相位差和幅值比本身就能反映心肌缺血或梗死的位置。这篇不聊算法paper,聊一线落地时怎么把它当数据工程来做——格式怎么读、预处理怎么做、样本怎么切、坑在哪,以及如何做导联子集验证,适合正要拿12导联数据开训的算法工程师和做可穿戴心电的开发者。
2. 解析12导联原始格式:从.hea/.dat/.atr到numpy数组的最小流程
2.1 WFDB格式与三类文件的真实分工
大多数公开的12导联心电图数据用的是WFDB格式,PTB-XL这类大型公开集就是典型代表。一套完整记录由三类文件组成:.hea头文件、.dat数据文件、.atr注释文件。搞懂它们的分工,比急着写加载代码更重要。
.hea是纯文本,记录采样率、导联数、每个导联的名字、ADC增益、基线和每导联的存储格式。.dat是二进制波形数据,按“每个采样点逐导联交错”的方式一行行写下去,不经过头文件你根本不知道里面是int16还是float64、一帧几个字节。.atr是注释文件,存R峰位置、Beat类型、节律标注和诊断结论。很多数据集的诊断标签并不在.atr里,而在单独的csv或xlsx里,拿到数据的头一天,先花十分钟用记事本打开一个.hea,再对着数据说明手册找标签表,这个习惯能省下后面几天排错时间。
常见坑是拿到病理数据后不看存储位宽直接按float读。有的数据集为了省空间把信号存成8位整数,有的用16位,还有的存成物理量mV,读出来直接就是小数。判断方法很简单:看.hea里第2行的ADCRate和后面每个通道的ADC Gain,如果gain是1000或200这类整数,说明.dat里存的是ADC原始值,要除以gain才得到mV,否则你画出来的波形幅值全是错的。
2.2 最小读取代码:用wfdb库从原始文件到numpy数组
解析WFDB格式不需要自己手写二进制解析,用wfdb这个python包就够了。下面这段代码是从12导联记录里读取前10秒信号和R峰注释的最小流程。
import wfdb import numpy as np # 读信号:返回record对象,sampto=5000表示只读前5000个采样点 record = wfdb.rdrecord( 'path/to/record_001', sampto=5000, physical=True # True返回mV物理量,False返回ADC原始值 ) sig = record.p_signal # shape = (5000, 12),float数组,单位mV fs = record.fs # 采样率,常见500Hz或100Hz ch_names = record.sig_name # 导联名列表,按文件里的顺序 # 读注释:atr是R峰和beat标注的注释类型 ann = wfdb.rdann('path/to/record_001', 'atr', sampto=5000) r_peaks = ann.sample # R峰所在的采样点索引 sym = ann.symbol # 每个注释的类型符号,如'N'正常、'V'室早 # 用通道名把信号重排到标准12导联顺序 std_order = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] idx = [ch_names.index(c) for c in std_order if c in ch_names] sig = sig[:, idx]rdrecord的sampto参数是按采样点截断,500Hz下5000个点正好10秒。physical=True时返回的是已经除以增益、减掉基线的毫伏值,做深度学习输入时优先用这个,避免自己处理单位。rdann读出的sample是采样点索引,不是时间秒,后面的窗口切分和滤波都必须保持在这个索引体系里操作,混用会出大问题。
信号重排那段容易被忽略。不同来源的12导联记录导联顺序并不统一,有的按I、II、III、aVR、aVL、aVF、V1到V6排,有的先排胸前导联再排肢体导联。如果模型输入要求固定顺序,必须在读取时就归一化到std_order,否则同一个样本在不同批次里的通道语义不一致,模型只能学到位置噪声。
2.3 导联顺序与时间轴:两个必须当场确认的细节
读数据时就要确认导联顺序,不要等训练曲线反常才回头查。record.sig_name返回的列表就是数据文件里的真实顺序,打印出来跟标注文件、论文描述对比一遍。肢体导联里还有个经典差异:有些数据集的aVR、aVL、aVF是直接采集的,有些是软件从I、II导联合成计算的,数值上会有细微差别,但一般不影响模型训练;真正影响大的是左右手电极接反,那种数据里I导联和aVR导联的波形会整体倒置,这种记录要当异常样本处理。
时间轴要注意的是起始偏移。有的记录从心电采集开始就写入,前几百毫秒可能是空白或起搏器伪迹;有的数据集已经把记录裁剪到以第一个QRS波群开头。统一做法是先读.ann的R峰列表,看第一个R峰与采样起点的距离是否在合理范围(比如500Hz下应小于1秒),偏移过大的记录在切窗口时要丢弃开头片段,别让模型学到“开始的0.5秒永远是平的”。
3. 12导联预处理三板斧:重采样、滤波与导联对齐
3.1 采样率不统一是第一个坎
同一批模型要吃的训练数据如果来自多个数据集,采样率几乎一定不统一。有的公开集给500Hz,有的给100Hz,自己采集的设备可能帧率还是250Hz。直接把不同采样率的数据混着喂给模型,等于让模型同时学两种时间语义。
我的处理顺序是:先重采样到统一目标,再滤波。目标采样率看任务,做房颤、早搏这类节律识别,250Hz足够;做QRS精细形态分析,统一到500Hz更稳。重采样用scipy.signal.resample_poly,它比resample更适合心电,因为它不会在信号两端引入明显的傅里叶振铃。
from scipy import signal as ss def resample_ecg(sig, src_fs, dst_fs): # 计算上采样和下采样倍数,先上后下,保证质量 from math import gcd g = gcd(src_fs, dst_fs) up = dst_fs // g down = src_fs // g # 默认axis=-1按最后一维(时间维)重采样 return ss.resample_poly(sig, up, down, axis=-1)参数上,up和down是整数倍关系,不要写成小数。比如500Hz转250Hz,up=1, down=2。重采样后R峰位置会偏移一两个采样点,所以对带注释的数据,不要在重采样后再直接用旧的R峰索引做窗口中心,需要按比例换算并重新检测,这一点在避坑章会展开。
3.2 带通滤波与工频陷波:参数别照抄
心电信号本身的能量集中在0.5Hz到40Hz之间,P波、QRS、T波的主要分量都在这个范围。滤波的目的不是“把波形变好看”,而是去掉基线漂移和高频肌电干扰,这两个东西会让归一化和波形分类的输入分布不稳定。
from scipy.signal import butter, filtfilt def bandpass_ecg(sig, fs, low=0.5, high=40, order=3): # 0.5Hz高通去掉呼吸引起的基线漂移 # 40Hz低通去掉肌电和高频噪声,又不损伤QRS形态 b, a = butter(order, [low, high], btype='bandpass', fs=fs) # filtfilt是零相位滤波,避免R峰位置被相位延迟拉偏 return filtfilt(b, a, sig, axis=-1) def notch_ecg(sig, fs, f0=50): # 中国电网50Hz工频,欧美部分数据是60Hz b, a = butter(2, [f0 - 1, f0 + 1], btype='bandstop', fs=fs) return filtfilt(b, a, sig, axis=-1)关键参数是低通上限。很多论文写40Hz,但实际看波形,有些记录在30Hz以上几乎没有能量,这时候把上限放到45Hz反而会引入高频毛刺。经验做法是先对一条干净记录做频谱分析,看能量在哪个频率截止再用。滤波阶数用3阶就够,阶数越高过渡带越陡,但零相位滤波后边缘伪影越明显。处理12导联时,要整条记录一起滤波,不要逐导联单独滤波,因为filtfilt在信号首尾会产生边界效应,逐导联处理会让边界位置在导联间出现不齐。
3.3 时间对齐:R峰、标签与窗口的偏移检查
做完重采样和滤波,必须重新检查R峰注释是否还在合理位置。办法是打印一条10秒波形,在R峰采样点处画竖线,用肉眼看三条不同导联的R峰是否都对齐在竖线上。这个“肉眼检查”不是玄学,是最便宜的排错手段,能同时发现采样率换算错误、时间轴偏移和滤波延迟三个问题。
标签对齐是另一个隐蔽问题。如果诊断标签是“这一段有房颤”,标签的时间范围通常由两个annotator分别标了起止采样点,切窗口时要保证窗口完全落在标签区间内,或者落在R峰序列的决策区间里。我只踩过一次这样的坑:标注文件里写着起始点是“10秒采样点5000”,但实际那个文件的采样率是250Hz,5000点是20秒,窗口直接切到了别的节律段上。所以每次切窗口前,先用ann.sample / fs把注释点换算成秒,再跟标签文件的时间戳核对。
3.4 完整预处理流水线示例
把上面几步串起来,一个典型的最小预处理流程如下。这里的顺序不是随便定的:先去基线漂移再做陷波,是为了避免工频干扰在漂移校正后被放大;重采样放在滤波前,能减少重采样引入的混叠。
def preprocess_12lead(sig, fs, target_fs=500): # 1) 重采样到统一采样率 if fs != target_fs: sig = resample_ecg(sig, fs, target_fs) fs = target_fs # 2) 先带通,去掉基线漂移和肌电 sig = bandpass_ecg(sig, fs) # 3) 再陷波,处理工频 sig = notch_ecg(sig, fs) # 4) 每个导联做z-score归一化 mean = sig.mean(axis=-1, keepdims=True) std = sig.std(axis=-1, keepdims=True) sig = (sig - mean) / (std + 1e-6) return sig, fs归一化时按“每个导联自己”做,不要按全部12个通道的全局均值和方差做。因为肢体导联和胸前导联的幅值天然不一样,全局归一化会把本来就小的V3导联信号压得更小,等于削弱了部分空间信息。std + 1e-6是为了避免静息记录里某导联方差接近0时除零报错,这个细节在批量处理大批数据时常遇到,不加这一项会在某个安静样本上报RuntimeWarning。
4. 标注与数据切分:把12导联记录变成能训模型的样本集
4.1 标注体系:先搞清标签是给“记录”还是给“节律段”
12导联数据的标签体系在不同数据集里差别很大。有的给的是整个记录的诊断结论,比如“前壁心肌梗死”“正常”,这种标签对应整条10秒记录;有的标签是逐拍的,把每个R峰标成正常、室早、房早;还有的给的是节律标签,标明某段时间是房颤还是窦性心律。拿到数据第一步,先看标签的最小粒度是什么,直接决定切窗口的策略。
如果标签是记录级,那切窗口时窗口内的标签直接用整条记录的标签;如果标签是逐拍或逐节律段,窗口标签需要由窗口内的R峰注释聚合而来,比如统计窗口内室早比例超过多少才算正样本。我见过有人把逐拍标签直接复制到整条记录的所有窗口里,导致正样本比例虚高、模型学到的其实是“记录里只要有一个室早,所有窗口都算室早”。这类问题很难从训练曲线上看出来,只能靠验证集上逐窗口的bad case分析发现。
4.2 按患者切分是最低要求,按记录切分等于白切
医疗数据的切分规则和图像数据不一样。同一个病人的多份记录在导联波形上高度相似,如果按记录随机分train/test,同一个人的不同记录会同时出现在两边,模型记住这个人的基线和电极位置就能刷高指标。这类数据泄漏在验证集上表现是AUC轻松上0.98,一换新医院的真实数据就打回原形。
正确做法是按患者ID分组建独立分层切分。
patient_ids = sorted(set(raw_df['patient_id'])) np.random.seed(42) # 切分组,不切开记录 test_pts = set(np.random.choice(patient_ids, size=int(len(patient_ids)*0.2), replace=False)) train_pts = [p for p in patient_ids if p not in test_pts] train_records = raw_df[raw_df['patient_id'].isin(train_pts)] test_records = raw_df[raw_df['patient_id'].isin(test_pts)]核心是patient_id,不是记录文件名。采集时一个病人做两次心电,文件名不同但patient_id相同,这类记录要么全在训练集,要么全在测试集。验证集再用同样逻辑从训练患者里划出15%,保证三个集合的患者完全不重叠。
4.3 滑窗切分与样本筛选要点
切窗口时,窗口长度跟任务强相关。做单拍分类,窗口取R峰前0.3秒到R峰后0.5秒;做节律分类,窗口取5到10秒;做心肌梗死定位,最少10秒起步。重叠率常用50%,数据量不够时可以提到75%,但要注意重叠窗口之间的样本不是独立的,评估时最好按记录聚合结果,别按窗口数算准确率。
def make_windows(sig, window=5000, stride=2500): # sig: (n_leads, n_samples) n_samples = sig.shape[1] starts = range(0, max(1, n_samples - window + 1), stride) for st in starts: piece = sig[:, st:st + window] if piece.shape[1] < window: continue yield piece, st切完窗口必须做质量筛查。最简单有效的指标是峰峰幅度和方差:正常12导联每导联峰峰值在0.5mV到3mV之间,整段全零、恒定平台或者R峰检测器一个波都没检出来的窗口直接丢弃。这个筛除动作看似粗暴,实际能去掉一大批电极脱落产生的垃圾样本,这些样本如果留着,会让模型的注意力被“导联掉了”这种设备伪迹带走。
5. 12导联数据使用避坑:五个让训练翻车的真实问题
5.1 数据泄漏:验证集AUC虚高,部署到新数据直接崩
现象:训练还在正常过拟合,验证集AUC却好得异常,比如二分类轻松到0.99,但换一批来自其他医院的记录做外部测试时,性能掉到0.7以下。 原因:按记录而不是按患者分train/test,同一个人的多次记录被拆到两个集合里。模型记住的是这个人固定的心电基线、电极贴放位置和个人生理特征,不是疾病模式。 解决:严格按patient_id分组切分,并把切分代码写进数据pipeline而不是一次性脚本,确保每次重跑实验的划分一致,测试集从始至终不参与任何归一化或超参调优。
5.2 导联顺序错位:模型学到的是排列,不是心电
现象:训练loss下降正常,但swap某个导联的顺序后模型预测完全不敏感,或者换一个数据集训练直接发散。 原因:读取时没按标准导联顺序重排,模型在某个中间层把“位置1”学成了“V4导联”,本地测试集恰好也是同一个乱序,所以指标看着正常。 解决:读取后立即用std_order重排,并打印每个导联的均值幅度做二次校验。V1到V6的胸前导联幅值通常逐导联变化,如果重排后序列和幅度变化看起来不连续,再回查映射逻辑。
5.3 滤波把P波滤没了,房颤检测跟着报废
现象:预训练时用0.5-40Hz带通滤波,模型对房颤识别很差,检查波形时发现P波位置几乎是平的。 原因:对老年人或房颤患者的记录,P波本身很微弱,幅值常在0.05mV量级,30Hz以上的低通对噪声是好事,但3阶butter在40Hz处非理想衰减,会把P波的高频起始部分连带滤掉。更隐蔽的是滤波后做了z-score归一化,微弱的P波被整体压得更小。 解决:滤波后抽查房颤和窦性两类样本的P波,肉眼确认P波还存在;对幅度差异大的导联,考虑用阈值归一化代替z-score,或保留[0.5, 30]Hz之外的高频分量单独做一路输入。
5.4 重采样后R峰位置偏移,窗口中心对不准QRS
现象:重采样后做拍分类,输入窗口中心不是QRS波群,模型学不到稳定的QRS形态,准确率始终上不去。 原因:resample_poly的滤波过程会引入相位变化,虽然整体线性相位,但R峰索引不经换算直接沿用旧位置,误差可能达到几个采样点,在250Hz下相当于几十毫秒的偏移。 解决:重采样后再跑一次QRS检测,比如用wfdb.processing.gqrs_detect,或者把旧R峰索引乘上dst_fs/src_fs后再按检测到的R峰重新对齐一次。
5.5 坏记录与零基线:设备电极脱落混进训练集
现象:训练loss前期降得很快,但验证集上模型对“正常人”的误报率极高,输出概率偏向阳性。 原因:数据里混入了大量电极脱落、导联线断开、患者剧烈运动产生的平台状或锯齿状记录。模型学到的是“高质量信号=病理”,因为训练集里高质量样本大多是心内科病人,健康人反而少。 解决:批量筛查时计算每个记录的零段比例和峰峰值。零段超过总时长30%的直接剔除;所有导联峰峰值都低于0.2mV的记录也不要保留。这一步要放在切窗口之前,避免垃圾样本被滑窗复制出几百个近重复样本。
6. 进阶:逐导联AUC验证与12导联到单导联的迁移
6.1 用逐导联AUC找出最佳子集
12导联数据的一个被低估的用法是量化每个导联对任务的贡献,这能直接告诉你该不该为这个任务去做单导联可穿戴设备。做法很简单:把12导联数据拆成12份单导联输入,用同一个模型结构分别训练,然后比较每个导联在独立测试集上的AUC。操作上,把模型第一层卷积的输入通道数改成1即可,其他结构不变。
from sklearn.metrics import roc_auc_score for ch in range(12): X_ch = X[:, ch:ch+1, :] # 只取一个导联 model_ch = build_model(in_channels=1) model_ch.fit(X_ch, y) auc = roc_auc_score(y_true, model_ch.predict_proba(X_ch_val)) print(f'{lead_names[ch]}: {auc:.4f}')注意,训练时每个单导联模型要在同样的患者划分上训练,否则导联间的比较会被划分差异污染。常见的结论是II导联和V5导联对缺血检测贡献最大,aVR对某些节律异常有效,但这个结论在不同数据集上会变,一定要用自己的数据跑一遍,而不是照搬文献结论。做完这个实验,还能得到一个额外价值:如果某个导联单独训练就能接近全导联AUC,说明12导联模型里对这个任务起作用的信号其实很集中。
6.2 12导联到单导联迁移的落地取舍
最后给一个我养成的习惯动作:训练完12导联模型,我会先砍掉最没用的三四个导联,把模型输入通道从12改成8或9,重训一次,对比性能衰减。如果掉点不超过0.5%,就说明数据里存在大量冗余导联,后续部署到单导联设备时心里有底。
如果目标是做单导联设备,迁移顺序不要直接用单导联预训练模型,而是先拿12导联模型做知识蒸馏:把12导联模型在大量无标注记录上的logits作为软标签,去训练一个轻量单导联模型。这样比直接拿单导联数据从头训练收敛快很多,也更容易保住12导联里的空间信息。
我从第一套12导联数据开始就养成了两个习惯:所有切分逻辑写进配置文件而不是散落在notebook里;任何预处理改动后都强制肉眼抽查十组波形。这两个习惯帮我挡掉了好几次数据泄漏和滤波翻车。做12导联数据,慢就是快,前期多花一天确认格式和切分,后面省下的重训时间往往以周计。希望这篇笔记能帮你少走一段弯路。
本文还有配套的精品资源,点击获取