心电图自动分类这个方向,放在十年前还是个“论文好发、落地难搞”的领域。最大的痛点是数据:想做个正经的深度学习模型,手头却只有几十上百条心电记录,模型一深就过拟合,一浅特征又学不进去。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-BIH | 48条 | 单导联 | 心律失常现象 | 异常搏动检测 |
| PTB | 549条 | 十二导联 | 心肌梗死 | 经典小样本研究 |
| PTB-XL | 21799条 | 十二导联 | 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, lowcut=0.5, highcut=45.0, fs=100): nyquist = 0.5 * fs low = lowcut / nyquist high = highcut / nyquist b, a = butter(2, [low, high], btype='band') return filtfilt(b, a, signal_data, axis=0)这段代码里我用了二阶巴特沃斯滤波器。为什么用二阶而不是更高阶?因为高阶滤波器在阻带下降更陡峭,但会带来明显的振铃效应,用在心电信号上容易在QRS波群附近产生虚假振荡。二阶巴特沃斯的过渡帯已经够用,而且相位响应相对平滑。滤波后,所有样本再统一做重采样到100Hz,这一步用scipy.signal.resample_poly可以避免混叠问题。
2.3 标准化与序列长度统一
滤波之后还有一个关键步骤,就是导联标准化。十二导联信号之间幅度差异比较大,如果不做标准化,卷积层会把注意力集中在电压高的导联上,而心电诊断恰恰要看不同导联之间的形态关系。我常用的做法是每个导联独立做z-score标准化,即减掉该导联均值、除以该导联标准差。这样做的好处是把每个导联放在同一尺度上,又不会破坏导联内部的形态特征。
def standardize_per_lead(x): # x shape: (leads, length) mean = x.mean(axis=-1, keepdims=True) std = x.std(axis=-1, keepdims=True) 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_splits=5) # 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, dtype=torch.float32) criterion = nn.BCEWithLogitsLoss(pos_weight=weights)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, transform=None): self.records = records self.base_path = base_path self.transform = transform self.records = self.records.reset_index(drop=True) 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], dtype=torch.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_classes=5, input_leads=12): super().__init__() self.features = nn.Sequential( nn.Conv1d(input_leads, 64, kernel_size=7, stride=2, padding=3), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2), nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2), nn.Conv1d(128, 256, kernel_size=3, padding=1), nn.BatchNorm1d(256), nn.ReLU(inplace=True), 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_size=7、stride=2,因为原始信号长度1000,先降采样一下可以增大后续感受野并减少计算量;中间的kernel_size=5和3形成多尺度感受野,能同时捕捉QRS波群的尖锐形态和ST段的平缓变化。批归一化层放在卷积和激活之间,比放在激活之后收敛速度更快,这一点在心电信号这类数值范围变化较大的数据上尤其明显。
4.4 训练配置、评估指标与结果参考
训练配置我会列出一组比较稳的参数,可以直接作为起点。优化器用Adam,初始学习率设置成1e-3,batch 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分类时如果遇到数据处理或模型调试的问题,欢迎多交流,用同一份数据集跑出不同结果再相互印证,是提升模型鲁棒性很快的方式。希望这篇记录能帮你在心电信号分类这条路上少踩几个坑。