ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于PTB-XL的深度学习心电信号分类:从数据预处理到1D-CNN模型实践

基于PTB-XL的深度学习心电信号分类:从数据预处理到1D-CNN模型实践 心电图自动分类这个方向放在十年前还是个“论文好发、落地难搞”的领域。最大的痛点是数据想做个正经的深度学习模型手头却只有几十上百条心电记录模型一深就过拟合一浅特征又学不进去。PTB-XL数据集的发布基本改变了这个局面——超过两万条十二导联记录、带层次化诊断标签既兼容经典信号处理流程又能喂饱一维卷积神经网络现在已经是心电信号深度学习分类绕不开的基准数据。我最早接触PTB-XL是在做心电自动筛查项目时当时要同时处理十二导联数据、异常检测和可解释性输出中间踩了不少坑。这篇文章就以“基于PTB-XL数据集的深度学习心电信号分类”为主线把从数据解析、预处理、模型设计到训练评估的完整流程梳理一遍适合刚入门的同学也适合正在做医疗AI落地的工程师参考。我会把实测过程中涉及的参数、代码、踩坑点都写出来尽量让这篇文章能直接照着跑。1. PTB-XL数据集全景解析为什么它成了心电分类的标准选型1.1 数据集规模与基本信息PTB-XL由柏林工业大学等机构发布是目前规模最大的公开心电诊断数据集之一。它包含超过两万条十二导联心电记录来自近两万名患者每条记录时长约十秒原生采样率是500Hz官方同时提供了重采样到100Hz的版本。相比早期的MIT-BIH数据集只有几十条记录、单导联且以心律失常注释为主PTB-XL在样本量和诊断标签覆盖上都迈进了一大步。数据集在官网发布时提供了两个主要目录records500和records100。每个病人对应多个文件后缀为.hea的是头文件记录采样率、导联数和患者元信息.dat是二进制信号数据.xwx是注释文件。标签信息除了写在头文件里还配了一个独立的csv表格包含ecg_id、patient_id、report、scp_code等字段。实际使用时我建议直接读csv拿标签再用wfdb库按ecg_id读取信号这样最省事也最不容易出错。关于采样率的选择我的建议是先在100Hz版本上做实验。原因很简单十二导联十秒数据在500Hz下就是每个样本六万个点模型输入维度大不说训练速度也明显变慢。100Hz下每个样本只有一千个点卷积层处理起来很舒服而心电诊断相关的有效频率范围大致在0.5到45Hz之间100Hz采样率已经足够覆盖信息损失对于大多数分类任务来说可以忽略。1.2 层次化标签体系PTB-XL的标签体系是这套数据最值钱的地方。原始标注采用SCP编码这是一种细分的心电图诊断编码非常专业大概有七十多类。为了让通用深度学习模型更好上手官方把SCP编码映射到了五个超类通常被称为superclass包括正常心电图NORM、心肌梗死MI、传导障碍CD、心肌肥大HYP和ST-T改变STTC。我在实际处理时发现一个样本的标签不是互斥的而是可以同时属于多个超类。比如一个心肌梗死患者可能同时伴有ST段改变他的标签就会同时包含MI和STTC。这就意味着在建模时不能简单当成单标签多分类更多时候要用多标签二分类思路来设计输出层和损失函数。如果你想做更细的诊断也可以直接使用SCP编码但要注意类别数量多了之后数据分布会非常稀疏很多细分类别样本只有几十条模型很容易学不动。1.3 与其他公开心电数据集的对比我用下面这个表格整理几个常见数据集的差异方便大家理解为什么PTB-XL在深度学习任务里更受青睐。数据集样本量导联数标签类型主要用途MIT-BIH48条单导联心律失常现象异常搏动检测PTB549条十二导联心肌梗死经典小样本研究PTB-XL21799条十二导联SCP编码及超类深度学习诊断分类MIT-BIH的问题在于样本太少神经网络很难训练只能用在传统特征加浅层模型的方案里。PTB是PTB-XL的前身质量高但规模小。PTB-XL补齐了规模化短板两万多条记录足够训练一个中等规模的卷积网络而且标签层次清晰细标签和粗分类都能做。可以说进入深度学习时代之后PTB-XL已经成了心电自动分析的默认起点。2. 数据预处理细节喂给模型之前必须处理的信号问题2.1 心电信号里的噪声从哪里来心电信号本质上是低频微弱电生理信号幅度一般在毫伏级别非常容易被各种噪声干扰。我处理PTB-XL时遇到的主要噪声来源有三个基线漂移、工频干扰和肌电干扰。基线漂移通常由呼吸、电极移动引起在信号上表现为一种低频缓慢起伏频率常在0.5Hz以下工频干扰来自市电典型是50Hz或60Hz的周期性干扰肌电干扰则是患者紧张或移动时产生的随机高频毛刺。这些噪声如果直接送进模型模型可能会学到噪声相关的假特征尤其是卷积层对局部纹理很敏感很容易把噪声误当成心电形态特征。正规做法是在预处理阶段做带通滤波把有效频率范围外的成分滤掉。常见选择是0.5到45Hz的带通滤波这能保留心电主要诊断波段同时压掉基线漂移和高频肌电干扰。2.2 滤波与重采样的实操配置滤波我推荐用scipy.signal里的butter和filtfilt组合。这里有个细节为什么不用filter而是用filtfilt因为filtfilt是零相位滤波正向和反向各过一次可以消除滤波本身引入的相位偏移。心电诊断非常看重波形形态比如ST段抬高或压低相位偏移会改变波形的相对位置影响诊断特征所以零相位处理很有必要。from scipy.signal import butter, filtfilt def bandpass_filter(signal_data, lowcut0.5, highcut45.0, fs100): nyquist 0.5 * fs low lowcut / nyquist high highcut / nyquist b, a butter(2, [low, high], btypeband) return filtfilt(b, a, signal_data, axis0)这段代码里我用了二阶巴特沃斯滤波器。为什么用二阶而不是更高阶因为高阶滤波器在阻带下降更陡峭但会带来明显的振铃效应用在心电信号上容易在QRS波群附近产生虚假振荡。二阶巴特沃斯的过渡帯已经够用而且相位响应相对平滑。滤波后所有样本再统一做重采样到100Hz这一步用scipy.signal.resample_poly可以避免混叠问题。2.3 标准化与序列长度统一滤波之后还有一个关键步骤就是导联标准化。十二导联信号之间幅度差异比较大如果不做标准化卷积层会把注意力集中在电压高的导联上而心电诊断恰恰要看不同导联之间的形态关系。我常用的做法是每个导联独立做z-score标准化即减掉该导联均值、除以该导联标准差。这样做的好处是把每个导联放在同一尺度上又不会破坏导联内部的形态特征。def standardize_per_lead(x): # x shape: (leads, length) mean x.mean(axis-1, keepdimsTrue) std x.std(axis-1, keepdimsTrue) std[std 1e-8] 1.0 return (x - mean) / std序列长度方面PTB-XL在100Hz下每条记录是1000个点左右大多数样本长度一致。但不同样本之间偶尔会有长度差异我一般统一截断或补零到1000个点。截断时优先保留中间部分因为头尾常常包含导联切换产生的伪迹补零时只补在序列末尾并在模型中用mask让模型忽略这些无效区域如果模型没有mask机制简单补零也不会造成特别严重的问题毕竟超出信号范围的卷积值很快会被后续层稀释。2.4 数据划分与数据增强数据划分是这个任务里特别容易踩坑的一环。很多初学者直接把所有样本shuffle后随机划分训练集和测试集但这样做会带来严重的数据泄漏。同一个患者可能有多条心电记录如果一部分记录进了训练集、另一部分进了测试集模型实际上是在记忆这个病人的个体特征而不是学习泛化的诊断规则。正确做法是先按patient_id分组确保同一个患者的所有记录只出现在训练集或测试集其中一边。from sklearn.model_selection import GroupKFold cv GroupKFold(n_splits5) # labels: 二分类或多标签标签 # groups: patient_id 数组数据增强方面心电信号不像图像那样可以随意旋转裁剪。比较安全的增强策略包括在信号上叠加低强度高斯噪声、做小幅度的时序缩放、沿时间轴随机平移几个采样点。我用下来效果最好的是随机裁剪加比例缩放把原始序列随机裁出80%到100%的长度再缩放到标准长度这样既能避免过拟合又不会严重改变波形形态。注意幅度不要做大尺度缩放因为心电图诊断很大程度上依赖电压幅度比如左心室肥厚要看电压是否增高乱缩放会破坏这类临床特征。3. 模型选型与结构设计思路3.1 为什么先从一维卷积入手心电信号本质上是时间序列但它在很长一段时间里都是用图像领域的方法处理的——不少早期深度学习方法先把信号转换成时频图再用二维卷积分类。这种方法有效但会丢失原始波形中的一些相位信息。当我做PTB-XL分类时更推荐直接用一维卷积网络处理原始信号因为十二导联心电数据天然是多通道时间序列把导联维度当作输入通道一维卷积在时间轴上滑动既能提取局部形态特征又避免了时频图转换带来的信息损失。一维卷积的另一大优势是计算效率高。十二导联加上一千个采样点输入规模并不算大用一张中端显卡就能训练得很快。我在处理PTB-XL时用的是一个六层的一维卷积网络整体参数量不到两百万训练一轮只需要几十秒这比起图像模型动辄几亿参数来说十分轻量。3.2 CNN、循环网络与Transformer如何取舍心电分类模型的选择主要有三条路线纯CNN、CNN加循环网络、以及基于自注意力机制的Transformer结构。纯CNN计算快、容易训练适合捕捉QRS波群这种局部短时形态特征但没有显式建模长时间依赖的能力。CNN后面接一个双向LSTM或GRU可以同时利用卷积的局部特征提取和循环网络的长程依赖建模理论上看更全面缺点是训练时间变长而且在数据集不够大时反而容易过拟合。Transformer结构近年很火它的自注意力机制可以灵活建模序列任意位置的关系理论上非常适合心电这种全局相关性的数据。但是从我在PTB-XL上的实测来看从零训练一个Transformer模型的效果通常不如同量级的CNN因为心电数据集规模虽然有上万条但对于Transformer这样的参数量级还是不够需要配合预训练。我的建议是如果只是想快速跑通并拿到一个可靠的基线直接用1D-CNN如果追求高精度且不介意花时间调参可以试试CNN加轻量注意力机制的组合或者在预训练模型基础上做微调。3.3 多标签分类任务的输出设计如果只做正常和异常二分类输出层一个节点就够了。但PTB-XL的价值恰恰在于它能同时输出多种诊断标签这时候就必须把任务建模为多标签分类。在我的实现里网络最后一层输出五个节点对应五个超类。每个节点经过sigmoid函数独立计算概率阈值怎么定很关键。默认阈值定在0.5但不同类别的最佳阈值差异很大我建议在验证集上对每个类别单独搜索最优阈值优先提高宏平均F1分数。损失函数我用的是BCEWithLogitsLoss这是PyTorch里把sigmoid和二元交叉熵合在一起的实现数值上更稳定。类别不均衡时还可以给每个类别设置不同的正样本权重比如某些超类样本特别少就把它的损失权重调高否则模型会倾向于把所有样本都预测成负类看起来准确率很高实际AUC会很难看。class_counts np.array([count_c0, count_c1, count_c2, count_c3, count_c4]) total class_counts.sum() weights total / (len(class_counts) * class_counts) weights torch.tensor(weights, dtypetorch.float32) criterion nn.BCEWithLogitsLoss(pos_weightweights)4. 端到端实操从数据加载到模型训练评估4.1 环境与依赖安装整个项目我建议使用conda创建一个独立环境Python版本选3.9左右比较稳。核心依赖有四块PyTorch、wfdb、numpy与pandas、scikit-learn与scipy。wfdb是读取心电信号的关键库install直接pip就能装。PyTorch的版本选择主要看你的显卡和CUDA版本建议直接用官网给出的命令安装避免因为版本不匹配出现CUDA运行时报错。pip install wfdb numpy pandas scipy scikit-learn matplotlib pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118我在第一次跑这个项目时因为wfdb版本较老读取100Hz数据时有点解析问题升级到最新版本后就好了。如果你遇到读出来的信号长度不对先检查一下wfdb版本和头文件里的采样率字段是否一致这是最常见的问题来源。4.2 写一个可复用的PyTorch数据集类数据加载是心电分类工程里最繁琐的部分但它直接影响整个训练流程的效率。我把数据集封装成PyTorch的Dataset类在初始化阶段就完成标签读取和信号索引构建这样做的好处是训练时不用反复读csv内存占用也小。import wfdb import torch from torch.utils.data import Dataset class PTBXLData(Dataset): def __init__(self, records, label_df, base_path, transformNone): self.records records self.base_path base_path self.transform transform self.records self.records.reset_index(dropTrue) self.labels label_df.reindex(self.records[ecg_id]).values def __len__(self): return len(self.records) def __getitem__(self, idx): ecg_id int(self.records.iloc[idx][ecg_id]) signal, _ wfdb.rdsamp(f{self.base_path}/records100/{ecg_id}) signal signal.T # (leads, length) signal standardize_per_lead(signal) signal signal.astype(np.float32) label torch.tensor(self.labels[idx], dtypetorch.float32) return torch.tensor(signal), label这里有一个效率踩坑点wfdb.rdsamp每次读取都要做文件IO如果每次都现场读数据加载会成为训练瓶颈。我的做法是把四万条以内的数据全部预读进内存毕竟单条记录只有一百二十个浮点数组内存压力很小。如果你还是想分批读取记得把DataLoader的num_workers调大否则GPU会长时间空转。4.3 搭建一个轻量但有效的1D-CNN模型我设计的模型结构并不复杂核心由三个卷积模块组成每个模块包含卷积、批归一化、ReLU激活和最大池化。最后一层用全局平均池化替代flatten加全连接的大参数量结构再输出到五个分类节点。这样做既能大幅减少参数量又能提高对输入序列长度变化的鲁棒性。import torch.nn as nn class ECGNet(nn.Module): def __init__(self, num_classes5, input_leads12): super().__init__() self.features nn.Sequential( nn.Conv1d(input_leads, 64, kernel_size7, stride2, padding3), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2), nn.Conv1d(64, 128, kernel_size5, padding2), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2), nn.Conv1d(128, 256, kernel_size3, padding1), nn.BatchNorm1d(256), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(1), ) self.classifier nn.Linear(256, num_classes) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)结构里的小细节是第一个卷积层用的kernel_size7、stride2因为原始信号长度1000先降采样一下可以增大后续感受野并减少计算量中间的kernel_size5和3形成多尺度感受野能同时捕捉QRS波群的尖锐形态和ST段的平缓变化。批归一化层放在卷积和激活之间比放在激活之后收敛速度更快这一点在心电信号这类数值范围变化较大的数据上尤其明显。4.4 训练配置、评估指标与结果参考训练配置我会列出一组比较稳的参数可以直接作为起点。优化器用Adam初始学习率设置成1e-3batch size取64训练40个epoch在验证集上进行早停patience设为6个epoch。学习率在训练到第20个epoch时降到1e-4这样前段快速收敛、后段精细调整。完整训练循环代码不展开了PyTorch标准写法就可以关键点是在每个epoch后计算验证集的AUC和F1不要只看loss。评估指标上准确率对心电分类的参考价值有限因为正常样本占比高准确率虚高。我比较关注两个指标宏平均AUC和多标签宏平均F1。AUC不依赖阈值选择能从整体上反映模型对正负样本的区分能力F1在阈值确定后能反映实际诊断效果。我在PTB-XL五超类上的一个参考结果是二分类正常与异常的宏AUC约0.95五分类宏AUC约0.91宏F1约0.62到0.68。这个结果会受到类别分布和数据划分影响但如果你按类似结构去复现应该能落在这个区间附近。5. 实操阶段常见的坑与排查经验5.1 类别不平衡问题PTB-XL的类别分布很不均匀正常样本占了三分之一以上传导障碍、心肌肥大这些类别明显偏少。如果不做任何处理模型会倾向于把少数类全预测成负类导致每个类别的AUC差距过大。我的处理方式有三个一是给损失函数加类别权重二是对少数类样本做重采样三是把评估指标锁定在AUC和F1上而不是准确率。这三个方法可以叠加使用但注意重采样时不要在同一患者的多条记录里做插值那会产生数据泄漏。5.2 过拟合虽然PTB-XL有两万多条记录但深度学习模型还是会过拟合特别是当你用大模型并且训练轮数过多时。训练集AUC接近1.0、验证集停滞不前时基本就是过拟合了。我的标准对策是早停加羰弱数据增强并且限制模型容量。我见过一些论文用很大的ResNet或Transformer训练这种量级的数据效果反而差因为模型容量远超任务需求。对心电信号分类来说轻量模型加合理增强效果往往比暴力上大模型更好。5.3 患者级别的数据泄漏这个坑我在1.3里提到过但值得单独拿出来再说一遍。如果不按患者分组进行数据划分表面看测试集AUC可能很高实际上模型很大程度是靠记忆患者整体特征完成的一旦换到新病人身上性能断崖式下降。我的验证做法是在代码里打印训练集和测试集的patient_id是否有交集确保交集为空。GroupKFold是实现这个目标最省事的方法折叠数建议用5或10取宏AUC均值作为最终结果。5.4 标签映射错误PTB-XL的csv标签字段中包含多个SCP编码如果把字符串编码直接当作分类标签是行不通的。我的做法是先筛选出需要的五个超类标签然后为每个样本生成一个五位二进制向量。特别注意标签为空的情况有些记录没有对应的超类标签严格来说应当从训练集中剔除或者单独作为无法归类样本处理。我在实验中遇到的另一个问题是一部分样本虽然有其它诊断信息但只能归到其它类而不是五个超类中的任何一个这部分样本的分类目标本身就不明确建议按官方推荐的筛选规则处理不要硬塞进五个类里。5.5 保证实验可复现深度学习实验里跑出的结果可能因随机种子不同而有明显波动这在信号分类中很常见。我在代码里固定了Python、numpy、PyTorch三个层次的随机种子并且设置cudnn的benchmark为False。不过即使这样GPU上的某些算子仍然可能带来微小不确定性所以我的做法是做多次重复实验报告均值正负标准差而不是单次跑出来的数字。6. 从实验到项目后续可以怎么扩展6.1 迁移学习与预训练模型PTB-XL最牛的贡献之一是让预训练模型成为可能。你可以先用两万多条记录预训练一个自编码器或对比学习编码器再在下游少样本分类任务上微调。这类方法在节奏异常检测、个体化心电分析等样本量较小的场景里特别有用。我自己试过用掩码自编码方式预训练一维卷积编码器再在PTB-XL的细分标签上做微调发现比从零训练能提升两到三个百分点的AUC特别是在样本量很小的子类上提升更明显。6.2 可解释性分析拿到高AUC只是第一步医疗场景里模型必须解释为什么给出这个判断。对一维心电模型最简单的可解释方法是Grad-CAM或注意力可视化把模型认为重要的时间片段高亮出来。我常把模型预测时的重要时间区域和诊断报告中的关键波形对应起来看看它关注的到底是不是ST段或Q波区域。如果模型关注的是信号边缘或噪声区域说明特征学习出了问题需要回头检查预处理和标签。这种可解释性分析对建立与医生之间的信任特别有用。6.3 部署到边缘设备的思路如果要把模型部署到移动心电设备上一般需要考虑模型量化和剪枝。一维卷积模型参数量小更适合这类场景。我通常先把PyTorch模型导出为ONNX格式再用ONNX Runtime做推理这样可以在CPU上获得不错的速度提升。如果设备上有专门的NPU或GPU可以考虑用TensorRT进行更进一步的优化。部署时还要处理一个输入格式问题原始设备出来的采样率可能不是100Hz让模型直接接收不同采样率的数据会带来性能下降所以设备端必须加上重采样模块。我在实际部署中遇到过因为预滤波参数和设备端滤波器不同导致结果和实验室对不上的情况最终统一了预处理流程才解决这一点在工程化时务必提前考虑。这个方向我一直觉得还有不少可以挖掘的空间。大家在做PTB-XL分类时如果遇到数据处理或模型调试的问题欢迎多交流用同一份数据集跑出不同结果再相互印证是提升模型鲁棒性很快的方式。希望这篇记录能帮你在心电信号分类这条路上少踩几个坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进