
简介这套基于Unet的ECG分段方案面向心电图信号处理与深度学习初学者用于自动识别P波、QRS波群和T波分量。作者采用PyTorch实现结合小波变换将来自QTDB数据集的ECG转换到小波域并借助WFDB标注完成P段、QRS段、T段的类别划分能帮助读者理解医学信号分段任务中Unet与小波特征结合的具体做法。压缩包共27个文件以14个Python脚本为主涵盖Unet网络结构、训练测试流程与数据加载等模块另有8个pyc缓存、3个Markdown说明、1个Jupyter Notebook及License文件整体仅197KB结构清晰便于按需查阅。目前已有783人学习下载尤其适合想在心电图分割任务上快速上手Unet并了解小波域预处理细节的研究者或开发者。1. 识别 ECG 的 P、QRS、T 分量为什么我把 Unet 用在一维信号上一份动态心电记录里P 波、QRS 波群、T 波是三个形态差异很大的片段。阈值法可以抓到幅值最大的 QRS但对幅度只有 0.1mV 量级的 P 波和形态多变的 T 波固定阈值基本靠不住。这个项目把 Unet 搬到一维 ECG 信号上做逐样本语义分割配合小波变换做预处理在 Jupyter Notebook 里走完整条训练推理链路。它不是一个大一统框架而是把“波形分段”这个需求拆成数据准备、网络搭建、小波去噪、踩坑修正几个步骤。适合已经会用 PyTorch、但还没在时间序列语义分割上落过地的开发者。2. 分割目标与数据准备从波形到逐样本掩码2.1 ECG 三个波形成分的识别难点先说我最初踩到的第一个误区以为只要把信号输进去Unet 就能自动找到 P、QRS、T。实际上如果不把三个分量的形态差异搞清楚后面训练出来的模型大概率只能学到 QRS。P 波幅度最低通常在 0.1 到 0.2mV 之间持续时间大约 60 到 120 毫秒。它的起点和终点非常模糊很多标注本身就不统一。T 波幅度中等但形态最不稳定可能出现倒置、双峰、平坦持续时间 120 到 200 毫秒。QRS 波群是最好认的部分幅值大、斜率陡在 80 到 120 毫秒内完成一个尖锐的偏转。难点在于边界。医生标注时依赖的是先验认知算法面对的是连续的采样点P 波起点、T 波终点都可能和基线混在一起。如果沿用传统的“先检测 R 峰再向两边圈波段”的流程中间任何一个环节出错都会传导到后续。改成语义分割之后每个采样点单独分类问题就从“找点”变成了“分区域”Unet 的跳跃连接能同时保留低频形态轮廓和高频边界信息这是它比普通卷积分类网络更适合这个任务的根本原因。2.2 掩码生成与窗口采样数据和标签怎么准备数据要选采样率明确的公开心电图数据集一般用 250Hz 或者 360Hz 两档。我习惯用 250Hz每秒钟 250 个采样点一个 10 秒窗口刚好 2500 个点。标签不能只有 R 峰位置而是要把每个心拍的 P 波起止、QRS 起止、T 波起止都标出来然后转成逐样本的掩码。以下是我常用的掩码生成代码import numpy as np def make_mask(signal_len, regions, class_id1): 把一段区间列表转成逐样本掩码 signal_len: 信号长度采样点数 regions: 区间列表每个元素是 (start, end)左闭右开 class_id: 该区间对应的类别编号 mask np.zeros(signal_len, dtypenp.int64) for start, end in regions: start max(0, int(start)) end min(signal_len, int(end)) mask[start:end] class_id return mask这里类别编号我手工约定0 是背景1 是 P 波2 是 QRS 波群3 是 T 波。生成掩码后一定要做一次区间合法性检查起点不能为负、终点不能超过信号长度、区间之间不能相互覆盖。多类别时把三类掩码分别生成后取最大值即可得到组合掩码但前提是同一采样点不会同时属于两个类别。窗口采样我一般用固定长度和固定步长def sample_window(signal, mask, win_len2500, stride1250): 滑动窗口切分信号和掩码 win_len: 窗口长度2500 对应 10 秒 250Hz stride: 步长1250 对应 5 秒 for start in range(0, len(signal) - win_len 1, stride): end start win_len yield signal[start:end], mask[start:end]参数上需要注意两点。第一窗口长度要覆盖至少 2 到 3 个完整心跳否则模型学不会心拍间的上下文关系第二步长不要等于窗口长度留出一半重叠可以让训练样本数量翻倍同时减少过拟合。归一化这步我放在窗口采样之后用窗口内的均值和标准差做 z-score而不是在整条记录上算。整条记录归一化会把记录级别的统计信息泄露给训练过程验证时看起来很好换新记录就崩。3. 一维 Unet 网络结构卷积、跳跃连接与损失函数3.1 一维卷积网络结构编码器、解码器与跳跃连接图像版 Unet 的核心是二维卷积和下采样搬到 ECG 上就是全部换成 Conv1d。输入张量形状是 (batch, 1, L)L 是窗口长度。我用的卷积核是 7不是图像里常见的 3x3。原因是一维信号的点与点之间距离很近卷积核太小感受野不够核太大又容易把 QRS 的尖锐边缘磨平。内层补零用 padding3保证卷积前后长度一致。编码器用 stride2 的卷积做下采样比 maxpooling 更能保留波形的锐利边界。通道数从第一层的 16 开始逐层翻倍到 256一共 4 层。这 4 层的深度对 2500 点输入是合适的经过 4 次下采样后特征图长度从 2500 变成 157 左右还有足够的信息去判断每个片段的类别。再深一层特征图只有七八十个点QRS 这种陡峭波形的细节就保不住了。核心代码骨架如下import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 nn.Conv1d(in_ch, out_ch, kernel_size7, padding3) self.bn1 nn.BatchNorm1d(out_ch) self.conv2 nn.Conv1d(out_ch, out_ch, kernel_size7, padding3) self.bn2 nn.BatchNorm1d(out_ch) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x F.relu(self.bn2(self.conv2(x))) return x class Down(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block ConvBlock(in_ch, out_ch) self.pool nn.Conv1d(out_ch, out_ch, kernel_size3, stride2, padding1) def forward(self, x): skip self.block(x) return skip, self.pool(skip) class Up(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.up nn.ConvTranspose1d(in_ch, out_ch, kernel_size2, stride2) self.block ConvBlock(in_ch, out_ch) def forward(self, x, skip): x self.up(x) diff skip.size(-1) - x.size(-1) if diff 0: x F.pad(x, (0, diff)) x torch.cat([x, skip], dim1) return self.block(x)拼接时最常遇到的情况是解码器上采样后长度比编码器跳跃连接短一个点。上面代码里用 F.pad 在末尾补零这是最简单的方式。要注意如果 diff 是负数说明上采样后长度反而长了那就应该切掉 x 末尾的多余点而不是补齐。跳跃连接的作用是把早期层的位置信息直接送到解码器让模型能精准恢复 P 波和 T 波的起止边界。没有跳跃连接的纯编码解码结构分割结果会像被磨过一层边界全部变成糊的。3.2 损失函数与评估指标分类别看胜负训练时我一开始直接用了普通交叉熵结果 P 波几乎没被预测出来。看分布就明白了背景类别占了大半QRS 和 T 波各占百分之十几P 波只有百分之几。普通交叉熵会为了压低背景的损失而牺牲 P 波。加权交叉熵是比较直接的解决方式对每个类别乘一个权重def weighted_ce(pred, target, class_weights): pred: (B, C, L) 原始 logits target: (B, L) 类别索引 class_weights: 长度为 C 的张量 return F.cross_entropy(pred, target, weightclass_weights)权重我按 1 / sqrt(class_freq) 来算而不是直接用 1 / class_freq。频率倒数直接加权会让 P 波的权重变得非常大训练早期梯度震荡严重模型先疯狂去认 P 波又把 QRS 忘了。用平方根可以压住极端权重。具体数值上背景权重 0.5 左右P 波 4 到 6QRS 和 T 波 2 到 3是个不错的起点。评估指标不能只看 overall accuracy。背景占 70% 以上模型把所有点都预测成背景也有 70% 的准确率看起来不差实际什么也没做。我按类别分别算 Dice 和 F1重点关注 P 波的 F1。另一个被低估的指标是事件级检测率把预测掩码中连续同类片段当成一个事件和真实区间算 IOUIOU 大于 0.5 就算命中。这个指标比逐样本准确率更贴近临床需求。4. 小波变换预处理去噪、基线校正与参数选择4.1 用小波做去噪与基线校正db4 和分解层数ECG 里的噪声主要是两种基线漂移和肌电干扰。基线漂移是频率低于 0.5Hz 的缓慢波动会让整个波形上下移动肌电干扰是高频毛刺会让 QRS 边缘变得粗糙。用傅里叶滤波的问题是波形边缘容易产生振铃R 峰附近会多出假震荡。小波变换同时保留时域位置在这类任务上更稳。我常用的去噪流程是 PyWavelets 库import pywt import numpy as np def wavelet_denoise(signal, waveletdb4, level8, modesoft): 小波软阈值去噪 signal: 一维 ECG 信号 wavelet: 小波基db4 与 QRS 形态接近 level: 分解层数250Hz 信号建议 8 层 mode: 软阈值还是硬阈值 coeffs pywt.wavedec(signal, wavelet, levellevel) # 用最高频细节系数估计噪声标准差 sigma np.median(np.abs(coeffs[-1])) / 0.6745 thr sigma * np.sqrt(2 * np.log(len(signal))) coeffs[1:] [pywt.threshold(c, thr, modemode) for c in coeffs[1:]] return pywt.waverec(coeffs, wavelet)层数选择不是玄学。250Hz 采样率下经过 8 层分解后近似系数的最低频率约为 250 / 2^8 ≈ 0.98Hz其实还没有完全压到 0.5Hz 以下。要更彻底就去到 9 层近似系数最低频率约 0.49Hz才能把基线漂移分离开。同样信号360Hz 采样率建议 9 层。如果分解层数太少基线漂移的成分会漏进细节系数去噪后会残留低频起伏影响 P 波和 T 波边界判断。基线校正更简单把最低频的近似系数整体置零后重建得到的就是基线漂移本身原信号减掉它def remove_baseline(signal, waveletdb4, level8): 去除基线漂移 做法分解后把近似系数清零重建得到基线原信号减去基线 coeffs pywt.wavedec(signal, wavelet, levellevel) coeffs[0] np.zeros_like(coeffs[0]) baseline pywt.waverec(coeffs, wavelet) return signal - baseline[:len(signal)]警告一句waverec 重建回来的长度可能和原始信号差几个点直接用 baseline[:len(signal)] 截断虽然能跑但首尾会有边界效应。这个问题后面单独讲。4.2 预处理顺序与可视化验证别让噪声决定训练结果实际流水线里每个窗口的处理顺序建议是先去基线漂移再做小波软阈值去噪最后 z-score 归一化。顺序错了会出问题。如果先归一化再去噪归一化会让整条信号的人体幅值信息丢失阈值估计和重建后的信号就失真。另外不要在整条记录上先跑小波再去窗口那样相当于把未来信息带进了当前窗口。在 Jupyter Notebook 里验证预处理效果时我一般并排画三张图原始信号、去噪后信号、两者残差。重点关注两件事。第一QRS 的 R 峰幅度有没有变小如果变小超过 5%说明软阈值把有效信号也削掉了需要降低分解层数或换硬阈值。第二残差里是否还有明显的心跳痕迹如果有说明去噪过度把 T 波或 P 波当成噪声抹掉了。import matplotlib.pyplot as plt fig, axes plt.subplots(3, 1, figsize(12, 6), sharexTrue) axes[0].plot(signal[:500], linewidth0.8) axes[0].set_title(raw) axes[1].plot(denoised[:500], linewidth0.8) axes[1].set_title(denoised) axes[2].plot(signal[:500] - denoised[:500], linewidth0.8) axes[2].set_title(residual) plt.tight_layout()如果原始记录本身噪声不大就不用加去噪步骤。过度去噪的副作用比噪声本身更隐蔽它会让 P 波的边界变得更平滑标注原本能辨认的起点直接消失了。我见过一个小伙伴把去噪层数从 8 调到 5结果因肌电干扰太多导致训练震荡他不是小波没选对而是没有先看残差图就直接训练了。5. 避坑指南四个把分割结果搞坏的细节5.1 预测的 QRS 比真实位置晚了几十个采样点现象训练时 loss 不高验证指标也正常但把预测掩码叠到原始波形上QRS 区域总是比真实 R 峰靠后几十个采样点P 波也整体右移。原因这类问题八成出在预处理滤波的相位延迟上。用了 scipy.signal.lfilter 这类普通 IIR 滤波它引入的延迟没有补偿。小波去噪虽然没有固定的线性相位延迟但重建过程在信号两端会有边界效应导致窗口边缘的波形变形间接让 QRS 位置偏移。解决滤波器尽量用零相位滤波比如 scipy.signal.filtfilt或者用小波去噪时把信号通过 reflect 模式扩展再处理。我后来强制规定一套流程所有预处理函数只能返回与输入等长的信号内部用对称扩展补偿边界并且用一个已知 R 峰位置的信号去验证预处理前后峰位差不超过 1 个采样点。每次改预处理参数都要跑一遍这个验证不通过就不进训练。5.2 P 波永远预测成背景类别不平衡怎么治现象混淆矩阵里 QRS 和 T 波的 F1 都正常P 波那一列几乎全被预测成背景recall 不到 0.1precision 也没有意义。原因P 波持续时间短、幅度低和背景的边界模糊同时训练样本量又是四个类别里最少的。普通交叉熵被背景和 T 波主导模型学到一个“保守策略”只要不确定就预测成背景这样整体损失最低。解决先统计类的频率然后给加权交叉熵设置权重。我习惯从 1 / sqrt(频率) 起步P 波权重在 4 到 6 之间。也可以用 Dice Loss它对小目标的梯度更友好但训练初期容易震荡我一般前 20 个 epoch 用加权 CE后面切成 Dice 或两者按比例相加。这个组合在数据量不足时比单纯换损失函数更稳。5.3 验证集指标很高新记录却崩了划分方式的问题现象训练时验证集 Dice 能到 0.9 以上一测新记录直接掉到 0.4P 波几乎找不到。原因这是典型的按“窗口”随机划分导致的数据泄露。同一个人的相邻心跳窗口被随机分到了训练和验证两边模型在训练时已经见过这些心跳形态的上下文验证集指标虚高。真实心电记录中同一个人不同时段的波形高度相关独立采样窗口并不独立。解决按记录 ID 划分数据集保证同一条记录的所有窗口都只在训练集或只在验证集。我用 group_k_fold 而不是 random_split。如果记录数量很少还要检查相邻记录是否来自同一个人是的话也要并到同一侧。这个问题最坑的地方在于它不会报错指标还很好看非常具有迷惑性。5.4 训练正常但推理毛刺多预处理对不上现象训练时预测掩码很干净导出模型单独跑推理脚本后输出结果多出一段段 0-2-0 交替的毛刺QRS 时宽时窄。原因dataloader 里写了归一化和小波去噪但推理脚本里只做了模型调用忘了做同样的预处理。还有一个常见变体是训练时窗口长度 2500推理时因为刻录的心电记录长度不整补零方式不同导致 padding 和小波分解层数对不上。解决把预处理全部封装成一个函数训练和推理都必须走同一个函数。写完推理脚本后拿一条训练时见过的信号分别过一遍训练预处理和推理预处理逐样本对比输出是否一致。我在本地维护了一套 sanity check每次改模型输入长度或预处理参数都会跑一遍跑完再谈训练。6. 进阶重叠滑窗推理把边界误差挡在结果之外长 ECG 记录不能一次性塞进 Unet切成多个窗口后每个窗口两端的样本在卷积时看不到完整上下文预测结果在边界处会出现假阳性比如把 T 波的一部分错判成 QRS。小波去噪的边界效应也会在这里集中爆发。重叠滑窗的思路是推理时窗口步长小于窗口长度每个窗口预测完成后只保留中间区域两边各丢弃一段。丢弃的长度至少等于编码器下采样的总步长加上小波边界影响我习惯取窗口长度的 20%。这样相邻窗口在中间重叠区拼接边界误差被挡在结果之外。def sliding_inference(model, signal, win_len2500, stride1500, margin500): 重叠滑窗推理 model: 训练好的 Unet signal: 整条心电信号 win_len: 窗口长度2500 250Hz 对应 10 秒 stride: 步长配合 margin 控制重叠区 margin: 每边丢弃的长度 model.eval() pred np.zeros(len(signal), dtypenp.int64) count np.zeros(len(signal), dtypenp.float32) with torch.no_grad(): for start in range(0, len(signal), stride): end min(start win_len, len(signal)) segment signal[max(0, start):end] seg_len len(segment) tensor torch.from_numpy(segment).float().view(1, 1, -1) logits model(tensor) seg_pred logits.argmax(dim1).view(-1).numpy() keep_l margin if start 0 else 0 keep_r margin if end len(signal) else 0 assign slice(start keep_l, end - keep_r) src slice(keep_l, seg_len - keep_r) pred[assign] seg_pred[src] count[assign] 1 return pred参数上注意 stride 和 margin 的搭配stride 设成 win_len - 2 * margin相邻窗口刚好首尾衔接中间区。如果记录最后一段不足一个窗口直接取完整段预测只在首尾不丢边缘。count 数组用于统计每个位置被覆盖的次数重叠区得到的是最后覆盖的预测也可以改成加权投票但实测 argmax 一致的情况下加权收益不大。验证这个技巧是否有效拿同一段信号分别做普通单窗口推理和重叠滑窗推理比较边界区域是否存在类别跳变。如果重叠区出现两个窗口预测不一致说明模型在这个位置置信度低实际部署时宁可丢弃这一段也不要硬猜。分割结果落地时别忘了把它转成临床可用的指标。对 QRS 掩码做连通域分析取每个连通域的最大幅值点作为 R 峰位置然后算 RR 间期序列进一步得到 SDNN、RMSSD 这些心率变异性指标。这一步的价值比单纯画出来好看要大得多。从那以后我每次训练完都会先跑一遍重叠滑窗推理确认边界预测稳定再去看指标否则指标再高也可能是窗口边界堆出来的假象。希望这个技巧帮到你。本文还有配套的精品资源点击获取