简介:12导联心电图(ECG)训练数据集由39732条原始心电记录组成,覆盖肢体导联与胸导联采集的多角度心电信号,适合医疗AI研究者、数据挖掘从业者及高校学生用于心电信号分析、心律不齐识别与深度学习分类等任务。数据已按7:3划分为训练集与测试集,并附有标签文件,可直接用于模型训练与泛化能力评估。压缩包共45个文件,以csv数据文件为主(37个),xml与iml文件多用于工程配置,md说明文档可辅助理解数据格式,整体大小10.75MB,便于快速下载与使用。已有4081人学习使用,资源热度较高。借助该数据集,可开展心电图异常检测、心率变异性分析及基于CNN/RNN的自动诊断等实验,也可面向心肌缺血、心肌梗死等异常识别与分类场景,还能验证预处理、特征工程与模型调优的完整流程,对构建心电智能分析系统具有实用参考价值。
1. 拿到12导联心电图数据的头一个星期,我几乎什么模型都跑不动
12导联心电图(ECG)数据不是一个“比单导联多十二倍样本”的简单升级,它是一套按心脏电活动空间投影关系组织的多通道时间序列。额面六个导联看下壁和侧壁,胸前六个导联看前壁和室间隔,任何一个导联的形态异常都有独立诊断意义。我第一次拿到一批带注释的12导联数据时,以为是普通的多通道分类任务,结果预处理还没做完就发现采样率不一致、导联顺序错位、标注时间戳对不上三个问题叠加。这篇文章把我后来跑通的全部链路拆开讲:数据结构怎么看、公开数据集怎么选、预处理参数怎么定、模型怎么设计,以及那些花了大量时间才避开的坑,希望能帮你把起步周期从几周压到几天。
2. 12导联的导联体系与数据格式:先搞懂这12条线在测什么
2.1 额面六轴与胸前导联:为什么I导联和V1的波形长得完全不同
12导联心电图不是12个独立传感器测出来的12路信号,而是心脏除极和复极向量在体表不同方向上的投影。标准肢导联I、II、III构成Einthoven三角:I导联测左上臂与右上臂之间电位差,II测左腿减右上臂,III测左腿减左上臂。加压肢导联aVR、aVL、aVF把中心端作为参考,分别指向右下、左上、左下三个方向。胸前导联V1到V6则是以Wilson中心端为参考点,测量心脏在前胸壁不同位置上的电位变化,V1、V2对着右室和前间隔,V3、V4对着前壁,V5、V6对着侧壁。
这套几何关系直接决定了数据处理方式。同一个心拍在不同导联上的P波、QRS波群、T波形态各不相同:V1导联的QRS以负向为主,I导联和V5、V6以正向R波为主,下壁导联II、III、aVF的ST段抬升提示下壁梗死。如果预处理时把导联当作互不相关的普通通道,或者把导联顺序调换了还浑然不知,模型再强也学不到正确的位置对应关系。我见过有人把V1和V6顺序对调之后训练的模型,在测试集上准确率看起来没什么变化,但单独评估每个导联的贡献时发现特征完全错位,这就是导联几何关系被无视的翻车现场。
2.2 数据组织方式与关键参数:采样率、单位、通道顺序
无论数据来自临床监护仪还是公开数据集,12导联数据的底层组织方式大体一致:按时间排列,每一行对应一个采样时刻,每一列对应一个导联。最常见的是12×N的数值矩阵,N为采样点数。但也有按导联分块的存储形式,解析之前先确认轴序,否则读取结果直接错位。
| 参数 | 典型值 | 说明 |
|---|---|---|
| 采样率 | 250 Hz / 500 Hz / 1000 Hz | 500 Hz 最常见,250 Hz 也能做心律分类但QRS起始点精度受限 |
| 幅度单位 | μV 或 mV | 部分数据集存原始ADC码值,需要结合增益和基线换算 |
| 单导联幅度范围 | 0.05~5 mV | QRS波峰可达1~3 mV,P波和T波常在0.1~0.5 mV |
| 通道排列 | I II III aVR aVL aVF V1~V6 | 标准顺序,非标准顺序必须先重排 |
| 量化位数 | 8~24 bit | 12 bit 以上对ST段分析更可靠 |
换算关系也容易踩坑。假设原始文件里存的是16位ADC码,增益为1000,则实际电压(μV)= ADC码 × 增益 ÷ 2^16。不少数据集在头文件里写明了增益和零值偏移,但有些从监护仪导出的CSV直接存了mV值,两套数据混用之前一定要统一单位。我在一次实验里把一组数据的单位从μV当成mV喂给了模型,幅度差了一千倍,归一化之后表面上看不出来,但模型收敛极慢,最后逐导联检查幅值分布才发现。
采样率是第二个高频坑。同一个数据集内部也可能混着500 Hz和1000 Hz的记录,直接拼接会让QRS波形宽度在样本间不一致。常规处理是先统一重采样到目标频率,再进预处理管线。重采样时要注意滤波器设计,直接用线性插值会产生高频毛刺,建议用带抗混叠滤波的多相重采样,等间隔抽取。
3. 三大公开数据集与格式适配:从原始文件到NumPy数组
3.1 常用公开数据集的定位:PTB-XL、CPSC 2018、MIMIC-IV ECG
做12导联算法研究,数据基本绕不开这几个公开来源。PTB-XL是目前综合标注质量较高的12导联数据集,包含约两万一千条10秒静息心电图记录,每条都有SNOMED编码和诊断分类标签,覆盖正常、心梗、传导阻滞、房颤等常见类型,适合做多分类和心律识别。CPSC 2018是心律失常分类竞赛数据,同样是12导联,标注了九类心律,时长从数秒到数十秒不等,适合做类别不均衡场景下的对比实验。MIMIC-IV ECG来自ICU患者,规模更大,但信噪比波动也更大,更接近真实临床设备出数的条件。
选择数据集不是越大越好,而是要和你的目标任务对齐。做心梗定位,PTB-XL的梗死位置标注是现成的;做长序列心律分类,CPSC的类别划分更适合;做噪声鲁棒性验证,MIMIC-IV的ICU背景干扰能提供压力测试样本。我一般会同时准备一个标注干净的小数据集做开发调试,再用另一个噪声更大的数据集做泛化验证,避免把PTB-XL上的表现当成真实环境效果。
3.2 把WFDB格式读成矩阵:读写代码与必查字段
PhysioNet系列数据集的经典存储格式是WFDB,包含.hea头文件、.dat数据文件和可选的.ari注释文件。头文件里记录采样率、导联数、增益、ADC分辨率、基线偏移等关键信息。读取最简单的方式是使用wfdb库。
import wfdb import numpy as np # 读取12导联记录,sampto=5000 表示只读取前5000个采样点 signals, fields = wfdb.rdsamp('ptbxl/00001', sampto=5000) print("通道顺序:", fields['sig_name']) print("采样率:", fields['fs']) print("单位:", fields['units']) print("信号形状:", signals.shape) # (采样点数, 导联数)代码里的fields['sig_name']必须逐个检查,不能默认文件里就是标准I、II、III、aVR、aVL、aVF、V1~V6顺序。fields['units']决定了后续归一化要不要做单位换算,fields['fs']则写入预处理配置,保证所有样本统一采样率。读取之后我还习惯打印每个导联的mean和std,粗看幅度是否在合理范围,如果某个导联标准差为0,基本就是坏导联或数据缺失。
批次读取时要小心内存。500 Hz采样、10秒时长、12导联一条记录就是6000×12的float64矩阵,两万条记录全量加载可达数十GB。常见做法是按需分块读取,先扫描头文件建立索引表,再用生成器逐批load。
def batch_load(record_paths, batch_size=32, sampto=5000): """按批次生成12导联信号""" for i in range(0, len(record_paths), batch_size): batch_signals = [] for path in record_paths[i:i+batch_size]: sig, fields = wfdb.rdsamp(path, sampto=sampto) batch_signals.append(sig) yield np.array(batch_signals) # shape: (batch, time, leads)batch_size按可用内存调节,我用16 GB显卡和64 GB内存时取32比较稳。sampto不是必须参数,如果一条记录长度不一致,要在后续按固定长度切片或padding,不能让不同长度的样本直接堆进同一batch。
3.3 导联缺失时的兜底策略:不是所有数据都有完整12导联
真实项目里经常拿到的不是规范12导联,而是8导联、6导联甚至只有单导联的记录。常见做法有两种:一是训练时用导联掩码(lead masking),让模型学会在部分输入缺失时仍然输出预测;二是用完整导联数据训练一个生成模型补全缺失导联。对起步阶段,先做掩码比做补全更划算。
def apply_lead_mask(x, drop_prob=0.2): """随机遮掉部分导联,训练鲁棒模型""" # x: (batch, time, leads) mask = np.random.rand(x.shape[-1]) < drop_prob x = x.copy() x[:, :, mask] = 0.0 return xdrop_prob取0.2~0.3比较合适,太高会让训练不稳定。把某个导联置零比用噪声填充更保险,因为模型会学会关注剩余导联,而不是试图从噪声里猜信息。这个技巧在只有一个导联数据可用的部署场景下效果显著,但注意训练和测试时都要保持同一种缺失策略。
4. 从原始信号到可训练样本:预处理和基线模型
4.1 预处理链路的顺序与参数:滤波、归一化、切片
很多入门做法是把滤波放在最前面,这没错,但要注意滤波器的顺序:先做50 Hz陷波,再做带通滤波,最后归一化。顺序反了会在陷波时把带通滤波器引入的边缘振铃再次放大。带通滤波我常用0.5 Hz到100 Hz的高通和低通组合,0.5 Hz高通能去掉基线漂移,100 Hz低通能去掉高频肌电干扰。如果只关心QRS检测,低通可以放到40 Hz;如果关注ST段分析,低频截止不能设太高,否则ST段形态会被扭曲。
import numpy as np from scipy import signal def preprocess_ecg(x, fs=500): """输入x: (time, leads),输出:(leads, time)""" # 1. 50Hz陷波,去除工频干扰 b_notch, a_notch = signal.iirnotch(50.0, Q=30, fs=fs) x = signal.filtfilt(b_notch, a_notch, x, axis=0) # 2. 带通滤波,0.5-100Hz b_band, a_band = signal.butter(4, [0.5, 100], btype='bandpass', fs=fs) x = signal.filtfilt(b_band, a_band, x, axis=0) # 3. 按导联归一化 mean = x.mean(axis=0, keepdims=True) std = x.std(axis=0, keepdims=True) + 1e-8 x = (x - mean) / std return x.T # (leads, time)filtfilt是零相位滤波,能消除滤波引入的相位偏移,这是ECG分析里的关键点。iirnotch的Q值设为30,带宽适中,既能去掉50 Hz分量又不会吃掉QRS波群里的高频成分。std加1e-8是为了防止导联为常数时除零。归一化按导联分别做,不是全局做,因为不同导联幅度差异天然存在,全局归一化会弱化V5导联的R波特征。
切片方式影响训练稳定性。原始记录是10秒,直接整段训练显存开销大,而且模型容易学到记录级别的偏移特征。常见做法是以2.5秒或5秒窗口滑动切片,重叠率为50%。2.5秒窗口能覆盖至少两个完整心动周期,对多数心律分类足够;5秒窗口则更适合检测偶发早搏这类稀有事件。切片时需要把标签按窗口重新判定,使用该窗口内的主导标签。
4.2 一个能跑通的最小CNN模型:结构选择与PyTorch实现
12导联ECG的模型不需要一上来就是Transformer。1D卷积天然适合时间序列的局部形态特征,多层小卷积核叠加能覆盖从QRS波(约100 ms)到ST段(约200 ms)的尺度。我常用的是一个带残差连接的小型CNN,输入是12×1250(5秒×250 Hz)或12×2500(5秒×500 Hz)的数据。
import torch import torch.nn as nn class ECGResNet(nn.Module): def __init__(self, num_classes=5): super().__init__() # 第一个卷积把导联维度融合进来 self.conv1 = nn.Conv1d(12, 32, kernel_size=7, padding=3) self.bn1 = nn.BatchNorm1d(32) # 残差块 self.conv2 = nn.Conv1d(32, 64, kernel_size=5, padding=2) self.bn2 = nn.BatchNorm1d(64) self.conv3 = nn.Conv1d(64, 128, kernel_size=5, padding=2) self.bn3 = nn.BatchNorm1d(128) self.relu = nn.ReLU() self.pool = nn.AdaptiveAvgPool1d(1) self.fc = nn.Linear(128, num_classes) def forward(self, x): # x: (batch, leads, time) x = self.relu(self.bn1(self.conv1(x))) x = self.relu(self.bn2(self.conv2(x))) x = self.relu(self.bn3(self.conv3(x))) x = self.pool(x).squeeze(-1) return self.fc(x)第一层卷积的kernel_size=7对应约28 ms的时间窗(500 Hz下7个采样点),刚好覆盖QRS波的起始沿。Conv1d的第一维是导联通道,12个导联在第一层就被混合,模型能学到导联间的空间相关。残差连接在这个小网络里效果不明显,但加深到五层以上时必不可少,否则梯度衰减会严重影响深层特征的学习。AdaptiveAvgPool1d(1)把时间维压成1个值,无论输入时间长度多少都能输出固定维度,方便切换任意采样率。
损失函数根据任务选。心律分为互斥类别时用交叉熵;多标签诊断(比如同时存在心梗和房颤)用BCEWithLogitsLoss。训练时类别不均衡是个大问题,正常样本可能占总量的70%,模型会直接学成“全预测正常”。常规解法是给每个类别一个权重,权重反比于出现频率。
criterion = nn.CrossEntropyLoss(weight=torch.tensor([0.3, 1.0, 1.5, 2.0, 2.5]))weight的取值由训练集各类别数量决定,先统计标签分布再算总样本数 / (类别数×该类别样本数),手工拍脑袋容易低估稀有类权重。对极端不均衡的类别,配合Focal Loss比固定权重更稳,但Focal Loss的两个超参数(γ和α)需要额外调,起步阶段先从固定权重开始。
4.3 验证指标:看AUROC还是看F1
12导联ECG分类的验证指标不能只看准确率。正常心律占比高时,准确率很轻松到90%以上,但房颤这类少数类可能一个都没抓到。心律分类通常看F1值和混淆矩阵,多标签诊断任务看每个标签的AUROC,然后取宏平均。
| 任务类型 | 优先指标 | 注意事项 |
|---|---|---|
| 单标签心律分类 | 宏F1、各类别F1 | 只看准确率会被多数类欺骗 |
| 多标签诊断 | AUROC、宏AUROC | 标签间存在相关性,不要求互斥 |
| 心拍级检测 | 敏感性、阳性预测值 | 阈值取0.5不一定最优,用验证集搜索 |
| 回归任务(如QT间期) | RMSE、Bland-Altman | 输出分布可能右偏,考虑对数变换 |
每次训练结束,不要只保留一次测试的结果,最好把几次不同随机种子的结果取均值和方差。12导联模型对参数初始化和数据划分都很敏感,一次跑出来的高准确率可能是运气,多次复现的平均水平才是真实能力。
5. 避坑:12导联数据实验里最常见的五个翻车点
5.1 患者重叠导致的数据泄漏:训练集和验证集按患者划分
现象:模型在验证集上准确率极高,但换一批患者数据后性能暴跌。原因:同一患者的多条记录被同时分进了训练集和验证集,模型记住了患者级别的个体特征,而不是泛化的疾病特征。解决:划分数据的单位必须是患者,不是记录。先把所有患者ID去重,再按患者ID做分层划分,确保同一患者的所有记录落在同一个集合里。
patient_ids = records['patient_id'].unique() train_patients, val_patients = train_test_split( patient_ids, test_size=0.2, random_state=42 ) train_records = records[records['patient_id'].isin(train_patients)] val_records = records[records['patient_id'].isin(val_patients)]另一个容易被忽略的点是数据增强不能在患者划分之前做。如果先做随机平移、缩放再划分,同一条记录的不同增广版本可能分到两个集合,等于泄漏。先划分、再各自独立增广,才是干净的做法。
5.2 导联顺序错位:文件里的顺序和数据字典对不上
现象:跑通模型后,按导联分别评估,发现某个导联的特征分布和预期完全不符。原因:有的数据生产商把导联顺序定义成I, II, III, aVR, aVL, aVF, V1-V6,有的却是V1-V6, I, II, III...,还有的按床旁监护仪的物理接线顺序存。解决:读取时用fields['sig_name']输出导联名,和标准顺序逐一核对,写一个重排函数,把任意顺序映射成固定顺序。
standard_leads = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] def reorder_leads(x, sig_names): # x: (time, leads),sig_names: 当前通道名列表 idx = [sig_names.index(lead) for lead in standard_leads] return x[:, idx]这段代码必须放在数据读取之后、预处理之前。宁可每次都显式重排,也不要相信“这个数据集顺序应该没问题”。我吃过一次亏:某个文件夹里的数据导联名和文件名不匹配,模型训了两天,最后查出来是V2和V3接反了。
5.3 50 Hz陷波滤波器的Q值过高,QRS波形出现切迹
现象:预处理之后,QRS波群在50 Hz左右出现锯齿状凹陷,看起来像噪声但实际是滤波器的振铃。原因:陷波滤波器的Q值设得太大,比如200,导致在50 Hz附近形成极深极窄的凹槽,脉冲响应拖得很长,QRS波的高频沿被削出振铃。解决:Q值取20到30,不要超过50。滤波完成后随机挑几条记录画图检查QRS形态,V5导览的R波顶端应该光滑无凹陷。
另一个判断技巧是把滤波前后的信号做差,看残差里是否残留明显的QRS形态。残差应该只有工频分量,如果看到完整的QRS波形残影,说明滤波器已经不只是陷波,而是在削信号本身了。
5.4 重采样改变采样率后,ST段评估失效
现象:把1000 Hz数据重采样到250 Hz之后,ST段抬升幅度检测结果系统性偏低。原因:直接降采样会把ST段的微小坡度变化抹平,尤其是低通截止频率设为100 Hz后,ST段本身就处于低频范围,滤波器阶数不够时会衰减低频成分。解决:重采样用scipy.signal.resample_poly并设置高精度滤波,或直接不降采样而只用卷积层对时间维做步长压缩。ST段分析任务尽量保留原始采样率。
也有反向踩坑的情况:把250 Hz数据插值到1000 Hz,模型表现变好,但这只是插值平滑带来的假象。模型可能学到了插值噪声的分布,而不是真实的临床特征。做超分或插值前先确认任务目标,不是采样率越高越好。
5.5 标签体系不统一:同一诊断在不同数据集里分类口径完全不同
现象:两个数据集合并训练后,模型验证集的AUROC很低,且错误集中在肥厚型心肌病这类语义模糊的标签上。原因:不同标注规范下,“心梗”可能被标为ST段抬高型心肌梗死或笼统的心肌梗死,SNOMED编码和中文诊断名之间存在一对多映射。解决:先用标准编码体系(如SNOMED CT或AHA指导下的分类)把标签映射成少数几个大类的集合,再合并训练。合并之前打印两份数据的标签分布,逐一对齐,不要直接取字符串交集。
遇到无法映射的标签,宁可丢弃也不要强行归入相近类别,否则会引入系统性噪声。标签映射表建议单独存成一个CSV,每次训练前校验训练集和验证集的标签集合完全一致。
6. 进阶验证:导联留一法与模型稳定性检查
模型训完不是终点,12导联模型最容易被忽视的两个问题:导联的独立贡献是否合理,以及模型对输入信号的微小变动是否稳定。我一般会做导联留一法验证:每次遮掉一个导联,观察输出置信度的变化幅度。逐导联做一遍,记录哪些导联对预测影响最大。如果影响最大的导联是I而不是II或V5,可能意味着模型学到了某些导联上的伪差异,而不是疾病本身的电生理特征。
def leave_one_lead_out(model, x, lead_names): base_prob = torch.softmax(model(x), dim=-1) for i in range(x.shape[1]): x_masked = x.clone() x_masked[:, i, :] = 0.0 prob = torch.softmax(model(x_masked), dim=-1) diff = (base_prob - prob).abs().sum().item() print(f"{lead_names[i]}: {diff:.4f}")某个导联的差异过大(超过其他导联均值的3倍以上)时,我会检查那个导联的输入波形是否被预处理过度滤波,以及训练集里该导联的信噪比是否异常。模型对缺失导联的宽容度也能在这里暴露:如果mask任意一个导联都会让置信度剧烈变化,说明模型没有学到导联间冗余信息的利用,部署时如果出现导联脱落会表现很差。
另一个检验是平移稳定性。把输入信号整体向右平移几个采样点,模型输出的类别概率应该几乎不变。如果概率波动明显,说明模型依赖了某些与相位强相关的特征,这在真实设备上会导致同一患者不同时刻抓取的数据得出不一致的结论。把这个检查写成单元测试,每次训练完自动跑一遍,比人眼抽查靠谱。
模型校准也是值得养成习惯的一步:用Temperature Scaling在验证集上对输出概率做一次标定,把过于自信或过于保守的概率拉回合理区间。做法很简单,就是对logits除以一个标量温度参数,用验证集交叉熵损失搜索最优温度。对临床辅助决策场景,分类正确率再高,概率输出不校准就没法给医生一个有用的置信度。我用这个习惯避免了至少两次在低置信度样本上误判的尴尬。希望这篇文章能帮你少走一点弯路,把精力花在真正影响效果的地方。
本文还有配套的精品资源,点击获取