torch_ecg解析:基于PyTorch的ECG深度学习从数据预处理到模型部署 📅 发布时间:2026/9/17 12:40:35 👁 浏览次数: 简介面向人工智能与医疗交叉领域研究者的 PyTorch ECG 深度学习框架聚焦心电图信号处理与识别覆盖数据预处理、模型构建、训练验证与评估部署全流程。框架整合了 CNN、RNN 及混合结构如 ECG_CRNN、ECG_UNET、ECG_SEQ_LAB_NET 等可应用于心律分类、QRS 检测等典型任务适合作为医疗深度学习课题的起点或基线参考。资源包约 22.76MB共 749 个文件。其中 291 个 Python 脚本为主要源码163 个 .mat 与 18 组 .hea/.dat/.atr 对应原始心电数据及标注29 个 PDF 提供文档说明另有配置文件、Dockerfile、Jupyter Notebook 等辅助材料。目录按 data、models、preprocess、train.py、evaluate.py 等模块划分结构清晰便于按需查阅。目前已有 314 人学习下载。除完整工程代码外还附带了多组 TensorBoard 事件文件可直接查看不同模型和超参数下的训练日志与指标曲线对于复现实验、理解模型调参过程或在此基础上开展二次开发都有较高的参考价值。1. 一串 TensorBoard 日志文件把 torch_ecg 的底牌全亮出来了一个多月前的训练日志还躺在磁盘上文件名把模型、数据集、优化器、学习率、batch size 全编码进去了ECG_CRNN_CINC2021_adamw_amsgrad_LR_0.0001_BS_64_multi_scopic旁边还站着 ECG_UNET_LUDB_adamw_amsgrad_LR_0.001_BS_32 和 ECG_SEQ_LAB_NET_CPSC2019。这不是随手取的目录名而是 torch_ecg 这套基于 PyTorch 的 ECG 深度学习框架的自描述痕迹。它的价值在于一个仓库同时覆盖了心电图分类、QRS/心拍序列标注、以及 PQRST 波分割三类任务模型侧横跨 CRNN、UNET、卷积加序列标注网络。对想快速复现 CinC Challenge 2021 或 CPSC 2019/2021 结果的人来说这是一套可以直接落地的基线对只想搞懂 ECG 信号怎么喂给神经网络的人文件名里的每个后缀都是一份索引。在 CUDA 12.x 加 PyTorch 2.x 的环境里这套代码能直接跑起来。2. ECG 数据预处理与 DataLoader 的工程化2.1 先读通 WFDB.atr 不是普通文本文件data_84_1.atr、data_84_2.atr是 WFDB 注解文件。一个完整的 WFDB 记录由.hea头文件、.dat信号数据和.atr注解三部分组成.atr存放的是心拍类别、起止点等标注不能当普通文本直接解析。常见做法是用wfdb库的rdrecord和rdann两个函数分别读信号和注解。import wfdb record wfdb.rdrecord(data_84_1, channels[0, 1], sampto1000) ann wfdb.rdann(data_84_1, atr, sampto1000) signal record.p_signal # shape: (1000, 2) fs record.fs # 采样率 labels ann.symbol # N, L, R, V, ( 等符号 samples ann.sample # 注解对应的采样点索引rdrecord读的是.dat信号rdann读注解两者都依赖.hea头文件里的采样率、导联数和格式信息。channels参数支持按导联读取避免一次性把 12 导联数据全部砸进内存尤其适合在调试阶段先用单导联验证流程。sampto限定读取长度适合先截取前 1000 个采样点确认数据形态。提示wfdb默认在当前目录查找文件。如果传入pn_dir参数它会尝试从公共服务器拉取离线环境会卡住。本地数据直接给绝对路径或把data_84_1.dat等文件复制到工作目录再读。2.2 滤波与标准化带通、陷波、逐导联 Z-Score原始 ECG 信号里有基线漂移、肌电干扰还有 50Hz 工频干扰北美地区是 60Hz。torch_ecg 这类框架通常做两级滤波先做 0.5-45Hz 带通抑制基线漂移和高频噪声再做 50Hz 陷波消除工频。from scipy.signal import butter, iirnotch, sosfiltfilt def preprocess_ecg(signal, fs500): sos_bp butter(2, [0.5, 45], btypebandpass, fsfs, outputsos) b_notch, a_notch iirnotch(50, Q30, fsfs) filtered sosfiltfilt(sos_bp, signal, axis0) filtered filtfilt(b_notch, a_notch, filtered, axis0) return (filtered - filtered.mean(axis0)) / (filtered.std(axis0) 1e-8)sosfiltfilt是零相位滤波信号不会产生相位偏移QRS 波位置不会移动这对后续心拍对齐很关键。陷波用iirnotch(50, Q30, fsfs)Q 值决定陷波带宽Q30 表示只压制 50Hz 附近约 ±1.7Hz 的频率避免伤及 QRS 波的谐波成分。Z-Score 逐导联计算把幅值拉到均值 0、方差 1让 CNN 的 BatchNorm 层在训练初期更稳定。注意滤波顺序不要写反。先带通除掉直流漂移和低频基线陷波器内部状态才不会被直流分量拖住如果先陷波再带通输出可能整体偏置导致后续标准化统计量失真。2.3 分段、重采样与 multi_scopic 视角2.3.1 滑窗切分训练输入不能是长度不一的整条记录否则 batch 无法拼成张量。常见做法是滑窗窗口长度取 10 秒采样率 500Hz则每段 5000 个采样点步长取 5 秒让相邻窗口有 50% 重叠等效做数据增强。def sliding_window(x, win_len5000, stride2500): n x.shape[1] starts range(0, n - win_len 1, stride) return [x[:, s:s win_len] for s in starts] x record.p_signal.T # (channels, samples) segments sliding_window(x) # list of (12, 5000)窗口长度是核心超参数检测 P 波、T 波这类细粒度结构需要短窗口分类任务则需要长窗口提供完整的心拍上下文。步长控制重叠度重叠越多样本越多但相邻样本相关性也越强验证集做划分时必须按病人 ID 分组不能让同一病人的片段同时出现在训练集和测试集否则指标会虚高。2.3.2 为什么文件名里有 multi_scopicmulti_scopic直译是“多视野”指在同一个网络里用多个不同感受野的并行分支。ECG 波形特征尺度差异很大QRS 波只有 80-120ms而 ST-T 段持续 300-500ms单一卷积核尺寸很难同时兼顾。多尺度分支可以这样组织一个 3×3 卷积分支捕捉局部形态一个 5×5 卷积分支捕捉心拍整体一个 7×7 或更大的分支捕捉节律片段最后拼接。这样模型不需要靠堆层数来扩大感受野参数效率更高。2.3.3 leadwise 与 12 导联的排列leadwise指逐导联处理。标准 12 导联有固定的解剖顺序I、II、III、aVR、aVL、aVF、V1-V6。如果通道顺序随意卷积核学到的空间关系就是噪声。各个导联组的临床侧重点不同导联组英文缩写观测侧重双极肢体导联I / II / IIIP 波、QRS 电压、心电轴加压肢体导联aVR / aVL / aVF额面电轴、ST 段变化胸导联V1–V6室壁运动、心肌缺血定位在构造输入张量时保持这个顺序数据增强时不要随机打乱通道。leadwise策略是对每个导联单独提取特征后再融合适合单导联设备的数据而ECG_CRNN_CINC2021这种12, 5000输入则直接把导联当成通道用 2D 卷积按时间-导联联合建模。2.4 DataLoader 与 collate_fn 处理变长输入验证阶段经常要直接处理整条记录长度和训练窗口不一致这时需要自定义collate_fn做 padding 和掩码。def collate_fn(batch): xs, ys zip(*batch) max_len max(x.shape[1] for x in xs) xs_pad torch.zeros(len(xs), xs[0].shape[0], max_len) mask torch.zeros(len(xs), max_len, dtypetorch.bool) for i, x in enumerate(xs): l x.shape[1] xs_pad[i, :, :l] x mask[i, :l] True return xs_pad, torch.stack(ys), mask掩码mask要传给模型或损失函数。序列标注任务里padding 位置的预测结果需要用ignore_index-100或掩码置零的方式排除否则模型会在无意义的位置上学习出错误的梯度。DataLoader的num_workers在 ECG 预处理较重时可以开到 4-8但滤波和标准化最好提前离线做完、存成 NumPy 或 PyTorch 张量在线只做滑窗切片训练吞吐会明显提升。3. torch_ecg 的模型家族CRNN、UNET 与序列标注网络3.1 从文件名建立任务与模型的映射从 events 文件后缀可以直接还原出三组任务配置文件名关键字数据集任务类型网络输出ECG_CRNNCINC2021多标签分类30 类心律失常概率ECG_SEQ_LAB_NETCPSC2019 / CPSC2021序列标注每个采样点的类型标签ECG_UNETLUDB / CPSC2019波形分割PQRST 各波段的起止区间CINC2021 官方任务是 12 导联、30 类多标签分类一个病人可以同时有多种心律失常所以输出是 sigmoid 多标签概率而不是 softmax 单标签。CPSC2019 和 CPSC2021 都包含分类和标注两类任务SEQ_LAB_NET做的是对每个采样点预测类别属于序列标注问题。LUDB 数据集提供的是 P 波、QRS 波、T 波的边界标注UNET 天然适合做这种逐点分割。3.2 CRNN卷积抽局部特征循环拿时序依赖3.2.1 结构设计CRNN 的经典组合是 1D CNN 加循环网络。CNN 沿时间轴扫描卷积核宽度要匹配 ECG 的生理先验500Hz 采样率下15 个采样点是 30ms接近 QRS 波斜率上升的时间尺度32 个采样点约 64ms覆盖一个心拍内关键事件的时间间隔。import torch.nn as nn class ECGCRNN(nn.Module): def __init__(self, n_channels12, n_classes30): super().__init__() self.encoder nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size15, stride2, padding7), nn.BatchNorm1d(64), nn.GELU(), nn.Conv1d(64, 128, kernel_size7, stride2, padding3), nn.BatchNorm1d(128), nn.GELU(), ) self.rnn nn.GRU(128, 64, batch_firstTrue, bidirectionalTrue) self.head nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Flatten(), nn.Linear(128, n_classes), ) def forward(self, x): out self.encoder(x) # (B, 128, T/4) out self.rnn(out.transpose(1, 2))[0] # (B, T/4, 128) return self.head(out.transpose(1, 2))kernel_size15, stride2的组合让第一层卷积后时间分辨率降为 1/2第二层再降为 1/4整体下采样 4 倍。GRU 双向计算每个时间步同时看到心拍前后的完整 PQRST 形态。head里的AdaptiveAvgPool1d(1)把变长序列压成向量避免 RNN 输出在 padding 区域产生噪声。CINC2021 是多标签任务所以最后的线性层输出 30 个节点配合BCEWithLogitsLoss使用而不是交叉熵。3.2.2 为什么用 GRU 而不是 LSTMECG 序列长LSTM 三个门参数量大在 12 导联高维输入下容易过拟合GRU 只有两个门计算量小收敛更快。torch_ecg 的事件文件名里没有写明用的是 LSTM 还是 GRU但从框架的通用性来看GRU 是更常见的默认选择。用 GRU 时注意batch_firstTruePyTorch 默认输入是(seq_len, batch, hidden)不设这个参数会让 batch 维度切分出错这是新手最容易踩的点。3.3 UNET把每个采样点分类UNET 是 encoder-decoder 结构skip connection 把下采样丢掉的精细位置信息在上采样阶段补回来。ECG 分割场景里输入是 (B, 1, 5000)encoder 逐步减半分辨率decoder 逐步恢复最后输出 (B, num_classes, 5000) 的逐点概率。class ECGUNetBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv1d(in_ch, out_ch, 3, padding1), nn.BatchNorm1d(out_ch), nn.ReLU(), nn.Conv1d(out_ch, out_ch, 3, padding1), nn.BatchNorm1d(out_ch), nn.ReLU(), ) def forward(self, x): return self.conv(x)基础 block 是两层 3×1 卷积加 BN 加 ReLU。LUDB 上通常用 4 个下采样层通道数按 32→64→128→256→512 递增下采样用stride2的卷积而不是 maxpool让网络自己学习如何压缩时间信息。padding1保证卷积不改变序列长度skip connection 拼接时形状才对齐。需要注意LUDB 的标注边界是专家手标的评估分割结果时不能只看逐点 Dice。我一般还会做事件级评估检测到的 QRS 波质心和真实质心在 ±75ms 内算命中再算事件级召回和精确率。逐点指标对一两帧边界偏移不敏感但临床关心的其实是“这个心拍检没检到”。3.4 Bottleneck 与 Global Context从后缀读配置resnet_nature_comm_bottle_neck_gc这个长后缀是浓缩的模型配置resnet以 ResNet 的一维变体作为 encoder 骨干bottle_neck残差块内部是 1×1 → 3×1 → 1×1 的瓶颈结构中间的 1×1 卷积先降通道再恢复压缩计算量gcglobal context 模块用全局池化加广播建模 12 导联之间的空间依赖。Bottleneck 块的 PyTorch 实现class Bottleneck1d(nn.Module): def __init__(self, in_ch, mid_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv1d(in_ch, mid_ch, 1), nn.BatchNorm1d(mid_ch), nn.ReLU(), nn.Conv1d(mid_ch, mid_ch, 3, padding1), nn.BatchNorm1d(mid_ch), nn.ReLU(), nn.Conv1d(mid_ch, out_ch, 1), nn.BatchNorm1d(out_ch), ) self.relu nn.ReLU(inplaceTrue) def forward(self, x): return self.relu(x self.conv(x))mid_ch一般取out_ch // 4能省约三分之一的参数量。GC 模块先做全局平均池化再经过两个全连接得到逐通道权重与原特征逐通道相乘后加回残差分支。训练 GC 模块时留意它的 BN 层如果验证集指标反而下降优先冻结 GC 的gamma和beta排查是不是注意力权重在跨层传播时压制了主干梯度。4. AdamW 训练配置与评估从日志名反推一套基线4.1 事件文件名本身就是一份训练配置表把 events 文件后缀按字段拆开就是一个完整的训练记录字段取值含义模型ECG_CRNN / ECG_UNET / ECG_SEQ_LAB_NET网络结构数据集CINC2021 / CPSC2019 / CPSC2021 / LUDB训练数据与任务优化器adamwAdam 解耦权重衰减梯度变体amsgradAMSGrad 修正学习率LR_0.0001 / LR_0.001初始学习率Batch SizeBS_64 / BS_32每步样本数模型变体multi_scopic / leadwise / bottle_neck_gc具体结构后缀运行节点ubuntu / outGPU2训练所在机器标识把学习率和 batch size 放一起看CINC2021 是 12 导联长序列BS64、LR1e-4LUDB 是单导联分割BS32、LR1e-3。这不是随意搭配。batch size 越小梯度噪声越大需要更高的学习率对冲BS 翻倍时一般把 LR 放大 sqrt(2) 倍。CINC 的 12 导联 batch 在显存上等价于 12×64768 个单导联样本所以它的有效样本量并不小学习率自然要保守一些。4.2 AdamW amsgrad 组合为什么适合 ECGAdamW 把权重衰减从梯度更新中解耦只在参数更新时做 L2 惩罚解决了原始 Adam 里二阶矩估计导致的正则化权重随训练步数漂移的问题。amsgradTrue让优化器保留二阶矩的历史最大值避免学习率在后期被高梯度方差拉高。ECG 数据集的类别分布极不均衡——少数心律失常样本占比可能不到 1%这些样本的损失波动大AMSGrad 相当于给学习率上了一道保险。optimizer torch.optim.AdamW( model.parameters(), lr1e-4, weight_decay0.01, amsgradTrue, )weight_decay0.01是 AdamW 常用默认值。CNN 的卷积核参数会做权重衰减但 BatchNorm 的scale和bias不做衰减否则 BN 的归一化能力被正则化压制验证集上会出现“干净信号过拟合、带噪声信号欠拟合”的现象。学习率调度我用 CosineAnnealing 配合前 5 个 epoch 的 warmup先线性升到 1e-4再余弦衰减到 1e-6。4.3 损失函数与类别不平衡CINC2021 的 30 个类别中房颤样本远多于罕见先天性异常。直接用 BCE 会让模型把稀有类全部预测为阴性。常见的做法是给BCEWithLogitsLoss传入pos_weight按类别统计正负样本比例自动计算def make_pos_weight(labels, eps1e-6): pos labels.sum(axis0) neg labels.shape[0] - pos return torch.tensor(neg / (pos eps), dtypetorch.float32) criterion torch.nn.BCEWithLogitsLoss(pos_weightmake_pos_weight(y_train))pos_weight是逐类权重由训练集统计得到值越大表示该类正样本越稀有、惩罚越重。验证集和测试集的分布不一定和训练集一致评估指标时不要复用pos_weight直接以 0.5 为阈值做二值化。如果业务上更关心召回率比如心肌梗死筛查可以把阈值从 0.5 降到 0.3——召回率通常能涨几个点代价是精确率下降两个点左右。4.4 训练循环与梯度裁剪训练循环是 PyTorch 标准写法但 ECG 模型里 GRU 和深卷积混用反向传播很容易梯度爆炸clip_grad_norm_是必须项for epoch in range(n_epochs): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) logits model(x) loss criterion(logits, y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() model.eval() with torch.no_grad(): probs torch.sigmoid(model(x_val)) f1 compute_f1((probs 0.5).int(), y_val)梯度裁剪上限设 5.0过大起不到保护作用过小会压制正常学习。多标签分类里的 F1 有两种统计方式按样本计算再平均和按类别计算再平均。稀有类存在时两者差异非常大我一般报告样本级宏 F1它更能反映真实临床场景下“一个病人的整体诊断对不对”。各任务的评估指标侧重点任务主要指标补充指标多标签分类样本级宏 F1准确率、召回率、混淆矩阵序列标注事件级 F1±75ms 匹配逐点准确率、阳性预测值波形分割Dice / IoU边界距离误差、事件级召回5. TensorBoard 日志解析与模型部署5.1 从 events.out.tfevents 恢复训练曲线events 文件是 TensorBoard 的事件文件文件名格式为events.out.tfevents.Unix时间戳.主机名同一个任务多次运行会追加写入而不覆盖。解析时用EventAccumulator读取标量数据from tensorboard.backend.event_processing.event_accumulator import EventAccumulator ea EventAccumulator(events.out.tfevents.1637332291.ubuntu) ea.Reload() tags ea.Tags()[scalars] loss_values [s.value for s in ea.Scalars(train/loss)]标签名取决于训练脚本写 summary 时填的 tag常见有train/loss、train/f1、val/loss。把多个模型的 loss 曲线拉出来对比能快速看出 LR 和 BS 的匹配是否合理loss 震荡剧烈时优先检查 LR 是否过大loss 平缓下降但验证 F1 不涨时考虑是不是amsgrad让学习率长期偏高。5.2 导出 ONNX 并部署到 CPU训练完成后的部署步骤我会先跑一次torch.onnx.export。ECG 信号在推理时长度是可变的必须用dynamic_axes把时间轴设为动态维度否则导出的模型只能处理固定 5000 点输入。model.eval() dummy torch.randn(1, 12, 5000) torch.onnx.export( model, dummy, ecg_crnn.onnx, input_names[ecg], output_names[logits], dynamic_axes{ecg: {0: batch, 2: time}, logits: {0: batch}}, opset_version17, )opset_version17支持大多数动态形状算子。导出报错时优先检查模型里的 GELU 激活CPU 部署环境对 GELU 的优化不如 ReLU 成熟很多情况下直接把 GELU 替换成 ReLU速度提升明显而 F1 掉点可忽略。ONNX 模型用onnxruntime加载后再做一次数值比对确保输出和 PyTorch 原模型一致。最后一个实战技巧服务端 ECG 推理不要直接用 float32 全精度。先把输入转成 NCHW 格式把时间轴当成 W再用torch.quantization做动态量化Linear和GRU层在 CPU 上通常能快 3-5 倍而 F1 掉点一般控制在 1 个百分点以内。如果做实时监护把 10 秒窗口拆成 2 秒滑窗推理只取中间 1 秒作为有效输出边界段的 QRS 检出不参与投票这样单次推理时延能压到 200ms 以下。本文还有配套的精品资源点击获取