简介:这套基于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_id=1): """ 把一段区间列表转成逐样本掩码 signal_len: 信号长度(采样点数) regions: 区间列表,每个元素是 (start, end),左闭右开 class_id: 该区间对应的类别编号 """ mask = np.zeros(signal_len, dtype=np.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_len=2500, stride=1250): """ 滑动窗口切分信号和掩码 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 的尖锐边缘磨平。内层补零用 padding=3,保证卷积前后长度一致。
编码器用 stride=2 的卷积做下采样,比 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_size=7, padding=3) self.bn1 = nn.BatchNorm1d(out_ch) self.conv2 = nn.Conv1d(out_ch, out_ch, kernel_size=7, padding=3) 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_size=3, stride=2, padding=1) 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_size=2, stride=2) 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], dim=1) 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, weight=class_weights)权重我按 1 / sqrt(class_freq) 来算,而不是直接用 1 / class_freq。频率倒数直接加权会让 P 波的权重变得非常大,训练早期梯度震荡严重,模型先疯狂去认 P 波,又把 QRS 忘了。用平方根可以压住极端权重。具体数值上,背景权重 0.5 左右,P 波 4 到 6,QRS 和 T 波 2 到 3,是个不错的起点。
评估指标不能只看 overall accuracy。背景占 70% 以上,模型把所有点都预测成背景也有 70% 的准确率,看起来不差,实际什么也没做。我按类别分别算 Dice 和 F1,重点关注 P 波的 F1。另一个被低估的指标是事件级检测率:把预测掩码中连续同类片段当成一个事件,和真实区间算 IOU,IOU 大于 0.5 就算命中。这个指标比逐样本准确率更贴近临床需求。
4. 小波变换预处理:去噪、基线校正与参数选择
4.1 用小波做去噪与基线校正:db4 和分解层数
ECG 里的噪声主要是两种:基线漂移和肌电干扰。基线漂移是频率低于 0.5Hz 的缓慢波动,会让整个波形上下移动;肌电干扰是高频毛刺,会让 QRS 边缘变得粗糙。用傅里叶滤波的问题是波形边缘容易产生振铃,R 峰附近会多出假震荡。小波变换同时保留时域位置,在这类任务上更稳。
我常用的去噪流程是 PyWavelets 库:
import pywt import numpy as np def wavelet_denoise(signal, wavelet='db4', level=8, mode='soft'): """ 小波软阈值去噪 signal: 一维 ECG 信号 wavelet: 小波基,db4 与 QRS 形态接近 level: 分解层数,250Hz 信号建议 8 层 mode: 软阈值还是硬阈值 """ coeffs = pywt.wavedec(signal, wavelet, level=level) # 用最高频细节系数估计噪声标准差 sigma = np.median(np.abs(coeffs[-1])) / 0.6745 thr = sigma * np.sqrt(2 * np.log(len(signal))) coeffs[1:] = [pywt.threshold(c, thr, mode=mode) 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, wavelet='db4', level=8): """ 去除基线漂移 做法:分解后把近似系数清零,重建得到基线,原信号减去基线 """ coeffs = pywt.wavedec(signal, wavelet, level=level) 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), sharex=True) axes[0].plot(signal[:500], linewidth=0.8) axes[0].set_title("raw") axes[1].plot(denoised[:500], linewidth=0.8) axes[1].set_title("denoised") axes[2].plot(signal[:500] - denoised[:500], linewidth=0.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.1,precision 也没有意义。
原因:P 波持续时间短、幅度低,和背景的边界模糊,同时训练样本量又是四个类别里最少的。普通交叉熵被背景和 T 波主导,模型学到一个“保守策略”:只要不确定,就预测成背景,这样整体损失最低。
解决:先统计类的频率,然后给加权交叉熵设置权重。我习惯从 1 / sqrt(频率) 起步,P 波权重在 4 到 6 之间。也可以用 Dice Loss,它对小目标的梯度更友好,但训练初期容易震荡,我一般前 20 个 epoch 用加权 CE,后面切成 Dice 或两者按比例相加。这个组合在数据量不足时比单纯换损失函数更稳。
5.3 验证集指标很高,新记录却崩了:划分方式的问题
现象:训练时验证集 Dice 能到 0.9 以上,一测新记录直接掉到 0.4,P 波几乎找不到。
原因:这是典型的按“窗口”随机划分导致的数据泄露。同一个人的相邻心跳窗口被随机分到了训练和验证两边,模型在训练时已经见过这些心跳形态的上下文,验证集指标虚高。真实心电记录中,同一个人不同时段的波形高度相关,独立采样窗口并不独立。
解决:按记录 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_len=2500, stride=1500, margin=500): """ 重叠滑窗推理 model: 训练好的 Unet signal: 整条心电信号 win_len: 窗口长度,2500 @250Hz 对应 10 秒 stride: 步长,配合 margin 控制重叠区 margin: 每边丢弃的长度 """ model.eval() pred = np.zeros(len(signal), dtype=np.int64) count = np.zeros(len(signal), dtype=np.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(dim=1).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 这些心率变异性指标。这一步的价值比单纯画出来好看要大得多。从那以后我每次训练完都会先跑一遍重叠滑窗推理,确认边界预测稳定再去看指标,否则指标再高也可能是窗口边界堆出来的假象。希望这个技巧帮到你。
本文还有配套的精品资源,点击获取