简介:这份资源面向从事心电信号处理与深度学习分割实践的学习者,提供一套基于U-Net的ECG波形分段方案,用于识别给定心电信号中的P波、QRS复合波与T波分量。实现上借助PyTorchWavelets的修改版本,将QTDB数据集中的ECG转换到小波域,把原始信号与标签作为输入,并保存对应尺度及小波实部、虚部信息;标签按P段为1、QRS为2、T段为3的方式标注,训练与测试脚本、网络结构定义、数据加载模块及小波特征提取笔记均有对应文件。压缩包共27个文件,以14个py源码为主,辅以8个pyc编译文件、3个md说明文档、1个license与1个ipynb交互笔记,整体约197KB,结构紧凑便于快速复现与二次修改。目前已有786人学习下载,适合希望理解小波域特征与U-Net结合思路、并参考标注与训练流程的读者。
1. 拿到一份 ECG 分割源码,先别急着跑:它到底在切什么
心电图自动分析里最容易被低估的一步,不是分类,而是分段。你手上有一段十秒的单导联信号,模型要回答的不是「有没有异常」,而是「P 波从第几个采样点开始、QRS 复合波到哪里结束、T 波峰值落在哪」。这份 Unet-ECG-Segmentation-Wavelet 资源,做的就是这件事:用 U-Net 做像素级(严格说是采样点级)语义分割,把一条 ECG 波形逐点标注成 P、QRS、T 和背景四类,再配合小波变换做预处理或特征增强。它适合两类人:一类是想把 ECG 分段从「找 R 峰 + 固定窗口」这种玄学阈值法升级成学习式方法的人;另一类是已经跑过 U-Net 分割图像、想把它迁移到一维生理信号上的从业者。Jupyter Notebook 的组织形式意味着每个步骤都能单独跑、单独看中间结果,这对调试波形类任务非常关键——你总得亲眼看看模型把哪一段切歪了。
2. 数据与标签怎么进网络:一维 U-Net 的输入管线拆解
2.1 为什么 ECG 分段适合 U-Net 而不是逐拍分类
逐拍分类的思路是先把心搏切出来,再判断这一拍属于什么类型,它天然丢掉了「边界在哪」的信息。而 P、QRS、T 的分段本质是逐采样点打标签,输出长度和输入长度一致,这正是 U-Net 的编码器-解码器结构擅长的事:编码器逐层下采样提取形态特征,解码器逐层上采样恢复分辨率,跳跃连接把浅层的高频细节直接送到对应层级,避免 QRS 这种陡峭边界的定位被池化抹平。
一维版本和二维图像版的差别主要在卷积核形状。二维用 3×3,一维就是 3×1 或者直接写Conv1d(kernel_size=3);池化从 2×2 变成MaxPool1d(2)。这里有个容易被忽略的点:ECG 采样率常见是 250 Hz 或 360 Hz,十秒信号就是 2500 到 3600 个点,下采样四次后长度还剩 150 到 225,再往下采就太短了,所以编码器深度一般控制在 4 层左右,比图像 U-Net 浅。资源里用 Jupyter Notebook 分步展示,正好方便你按自己的采样率调整层数。
小波变换在这个流程里的角色,常见做法有两种:一是把原始信号做离散小波分解,取近似系数和细节系数拼成多通道输入,让网络同时看到低频轮廓和高频突变;二是把小波去噪当作预处理,压掉基线漂移和肌电干扰再送进网络。前者是特征增强,后者是清洗,选哪个取决于你的数据噪声水平。如果原始数据已经过带通滤波,直接上单通道原始波形也够用,不必为了用上小波而硬加。
2.2 从原始波形到定长张量:预处理与滑窗切分
ECG 记录长度不一,直接整段送进网络不现实,标准做法是滑窗切分成固定长度片段。窗口长度要覆盖至少一个完整心动周期,成人静息心率 60 到 100 次/分对应 RR 间期 0.6 到 1.0 秒,取 2 到 4 秒窗口比较稳妥,既能包含完整 P-QRS-T,又不至于让一个窗口里塞进太多心搏导致边界样本比例失衡。
import numpy as np from scipy.signal import butter, filtfilt def bandpass_filter(sig, fs=250, low=0.5, high=40.0, order=3): # 0.5 Hz 压基线漂移,40 Hz 截高频肌电噪声 b, a = butter(order, [low / (fs / 2), high / (fs / 2)], btype='band') return filtfilt(b, a, sig) def sliding_window(sig, label, win=1000, stride=500): # win=1000 对应 250Hz 下 4 秒,stride 取一半保证重叠 xs, ys = [], [] for start in range(0, len(sig) - win + 1, stride): xs.append(sig[start:start + win]) ys.append(label[start:start + win]) return np.array(xs), np.array(ys) def normalize(x): # 逐片段 z-score,避免不同记录幅度差异影响收敛 return (x - x.mean()) / (x.std() + 1e-8)上面三段分别对应滤波、切窗、归一化。butter的阶数取 3 是折中,阶数太高会引入相位失真,filtfilt做零相位正反向滤波可以抵消这部分影响。窗口 1000 点、步长 500 点是常见配置,重叠一半能增加样本量,也让边界心搏至少完整出现在某个窗口里。归一化必须逐片段做而不是全局做,因为不同记录之间的电极接触和增益差异很大,全局均值会被长记录主导。
标签这边,P、QRS、T、背景四类通常编码成 0 到 3 的整数,损失函数用交叉熵即可。要注意类别极度不平衡:一个心动周期里 QRS 只占几十个采样点,背景占大头。常见做法是给 QRS 类加权,权重按类别频率的倒数估算,或者用 Dice Loss 和交叉熵组合。资源里如果只给了基础交叉熵,你自己加一层权重是必要的一步,否则模型会倾向于全预测背景,训练 loss 降得很快但分割结果一片空白,这是新手最容易翻车的地方。
2.3 网络结构的关键参数:通道数、深度与输出头
一维 U-Net 的通道数一般从 16 或 32 起步,每下采样一次翻倍,到最底层 256 或 512 封顶。ECG 信号的信息量远小于自然图像,通道开太大纯属浪费显存还容易过拟合,16-32-64-128 这个配置在几千条记录的量级上通常够用。
import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, cin, cout): super().__init__() self.net = nn.Sequential( nn.Conv1d(cin, cout, 3, padding=1), nn.BatchNorm1d(cout), nn.ReLU(inplace=True), nn.Conv1d(cout, cout, 3, padding=1), nn.BatchNorm1d(cout), nn.ReLU(inplace=True), ) def forward(self, x): return self.net(x) class UNet1D(nn.Module): def __init__(self, in_ch=1, n_classes=4, base=32): super().__init__() self.e1 = ConvBlock(in_ch, base) self.e2 = ConvBlock(base, base * 2) self.e3 = ConvBlock(base * 2, base * 4) self.e4 = ConvBlock(base * 4, base * 8) self.pool = nn.MaxPool1d(2) self.up3 = nn.ConvTranspose1d(base * 8, base * 4, 2, stride=2) self.d3 = ConvBlock(base * 8, base * 4) self.up2 = nn.ConvTranspose1d(base * 4, base * 2, 2, stride=2) self.d2 = ConvBlock(base * 4, base * 2) self.up1 = nn.ConvTranspose1d(base * 2, base, 2, stride=2) self.d1 = ConvBlock(base * 2, base) self.head = nn.Conv1d(base, n_classes, 1) def forward(self, x): c1 = self.e1(x) c2 = self.e2(self.pool(c1)) c3 = self.e3(self.pool(c2)) c4 = self.e4(self.pool(c3)) u3 = self.d3(torch.cat([self.up3(c4), c3], dim=1)) u2 = self.d2(torch.cat([self.up2(u3), c2], dim=1)) u1 = self.d1(torch.cat([self.up1(u2), c1], dim=1)) return self.head(u1)base=32是起点,显存紧张就降到 16。ConvTranspose1d的stride=2必须和前面的MaxPool1d(2)对齐,否则拼接时长度对不上,这是改结构时最常见的报错来源。输出头用 1×1 卷积把通道压到类别数,不做 softmax,因为交叉熵损失内部会处理。跳跃连接用torch.cat沿通道维拼接,要求编码器和解码器对应层的空间长度严格相等,输入长度最好是 16 的倍数(四次下采样),切窗时把窗口设成 1024 而不是 1000 就能省掉一堆 padding 的麻烦。
3. 训练与评估:让模型真的学会找 P、QRS、T
3.1 损失函数与类别权重的实操配置
前面提到类别不平衡,具体到数字上:一个 4 秒窗口 1000 个点,QRS 大约占 40 到 80 点,P 和 T 各占 100 到 150 点,剩下全是背景。如果按原始频率算,背景权重是 QRS 的十几倍。直接训练的结果是模型学会「全预测背景」这个偷懒解,准确率看着有 80% 以上,但 QRS 的召回率接近零。
import torch import torch.nn as nn # 按经验频率估算的类别权重:背景、P、QRS、T class_weights = torch.tensor([0.2, 1.5, 4.0, 1.5]) ce = nn.CrossEntropyLoss(weight=class_weights) def dice_loss(logits, target, n_classes=4, eps=1e-6): probs = torch.softmax(logits, dim=1) loss = 0.0 for c in range(n_classes): p = probs[:, c] t = (target == c).float() inter = (p * t).sum() loss += 1 - (2 * inter + eps) / (p.sum() + t.sum() + eps) return loss / n_classes def total_loss(logits, target): return ce(logits, target) + 0.5 * dice_loss(logits, target)权重[0.2, 1.5, 4.0, 1.5]是量级参考,实际要按你数据集的统计结果调,思路是让 QRS 的权重明显高于背景。Dice Loss 直接优化重叠度,对不平衡更鲁棒,和交叉熵按 1:0.5 组合是常见配方。注意 Dice 里用的是 softmax 后的概率而不是 argmax 后的硬标签,否则梯度传不回去。
训练时还有一个细节:验证指标不能只看整体像素准确率,要看每一类的 F1 或者 IoU,尤其是 QRS 的。整体准确率在类别不平衡下几乎没有参考价值,这一点在波形任务上比图像分割更极端。
3.2 评估指标与边界容差:分段任务不能只看逐点重合
ECG 分段的评估有个特殊之处:边界附近差几个采样点,临床上完全可以接受,但逐点准确率会把它当成错误。所以除了逐点 F1,还要看基于事件的重合度。常见做法是设定一个容差窗口,比如 ±25 毫秒(250 Hz 下约 ±6 个点),预测的 QRS 起点落在真实起点容差内就算命中。
def event_f1(pred, true, cls, tol=6): # pred/true 为逐点标签数组,cls 为目标类别 def onsets(arr): idx = np.where((arr == cls) & (np.r_[0, arr[:-1]] != cls))[0] return idx p_on, t_on = onsets(pred), onsets(true) matched = 0 used = set() for p in p_on: for j, t in enumerate(t_on): if j not in used and abs(p - t) <= tol: matched += 1 used.add(j) break precision = matched / (len(p_on) + 1e-8) recall = matched / (len(t_on) + 1e-8) return 2 * precision * recall / (precision + recall + 1e-8)这段按「事件起点」匹配,tol=6对应 250 Hz 下的 24 毫秒。实际用的时候对 P、QRS、T 分别算一遍,你会发现 QRS 的 F1 通常最高,P 波最差——P 波幅度小、形态多变,和基线噪声容易混,这是 ECG 分段的固有难点,不是模型没训好。评估报告里把三类分开列,比一个总分有信息量得多。
3.3 小波预处理到底加在哪一步
资源名里带 Wavelet,说明小波不是摆设。落地时有两条路,选错了会白忙。第一条是把小波系数当额外通道:对原始片段做 3 到 4 层 db4 小波分解,把近似系数和各级细节系数重采样回原长度,和原始波形拼成多通道输入。好处是网络能直接看到不同频段的能量分布,QRS 的高频突变在细节系数里非常突出。坏处是输入通道变多、预处理变重,而且小波分解本身有边界效应,片段两端要小心。
第二条是把小波当去噪器:对信号做小波阈值去噪,再送单通道进网络。这条路实现简单,适合噪声大的数据,但对干净数据收益有限,甚至可能把 P 波这种低幅成分一起削掉。
import pywt import numpy as np def wavelet_channels(sig, wavelet='db4', level=3): # 返回原信号 + 各级细节系数重采样后的多通道输入 coeffs = pywt.wavedec(sig, wavelet, level=level) chans = [sig] for c in coeffs[:-1]: # 跳过最底层近似系数 up = np.interp(np.linspace(0, 1, len(sig)), np.linspace(0, 1, len(c)), c) chans.append(up) return np.stack(chans, axis=0)level=3在 250 Hz 下对应最低频段约 15 Hz 左右,正好覆盖 QRS 的主要能量。np.interp做线性重采样把系数拉回原长度,虽然不如小波重构精确,但作为输入特征够用且省事。如果你选的是去噪路线,用pywt.threshold对细节系数做软阈值再重构即可,阈值取细节系数标准差的 0.5 到 1 倍是常见起点。我的建议是先用单通道原始信号跑一版基线,确认流程通了,再决定要不要上小波通道,别一上来就把两条路混在一起调,出了问题分不清是谁的锅。
4. 避坑与排查:这份源码跑不通时先看这几处
4.1 训练 loss 正常下降但预测全是背景
现象是每个 epoch 的 loss 都在降,验证集整体准确率能到 80% 以上,但把预测结果画出来一看,整条波形全被标成背景。原因几乎总是类别不平衡加上没加权,模型找到了「全预测背景」这个局部最优。解决办法是先统计你数据集里各类别的采样点占比,按倒数关系设CrossEntropyLoss的weight,再叠加 Dice Loss。改完重新训练,如果 QRS 的召回率还是上不去,检查标签编码是不是把 QRS 和背景搞反了,这种低级错误在手工整理标签时并不少见。
4.2 拼接时张量长度不匹配报错
现象是torch.cat那行抛出尺寸不一致的错误,提示两个张量的最后一维不同。原因是输入片段长度不是 2 的下采样次数的整数倍,比如窗口设了 1000 点、下采样四次,1000 除以 16 不是整数,池化和转置卷积来回一趟长度就对不上。解决办法是把窗口长度改成 16 的倍数,1024 是最省事的选择;或者在每次池化前手动 pad 到偶数长度。改窗口比改网络结构安全,优先选前者。
4.3 小波通道加进去后效果反而变差
现象是加了小波系数通道,验证集 F1 不升反降,训练也更慢。原因通常有两个:一是小波分解的边界效应在片段两端产生伪影,而 P 波和 T 波经常就落在片段边缘;二是细节系数重采样时用了简单插值,引入了和原始波形不同步的相位偏移,网络学到的是错位特征。解决办法是切窗时让相邻窗口多重叠一些,保证边缘心搏在某个窗口里处于中间位置;重采样改用小波重构而不是线性插值。如果这两条都做了还没改善,说明你的数据本身够干净,小波通道是负收益,果断退回单通道。
4.4 换采样率后模型直接失效
现象是在 250 Hz 数据上训好的模型,拿到 360 Hz 的记录上预测,QRS 宽度全部偏窄、边界错位。原因是 U-Net 的感受野是按采样点数定义的,采样率一变,同样数量的采样点对应的时间长度就变了,网络的「时间视野」跟着漂移。解决办法是统一重采样到训练时的采样率再推理,用scipy.signal.resample_poly做有理数倍重采样,比直接插值保真。如果必须支持多采样率,就在训练时做采样率增强,把同一段信号重采样到几个常见频率一起训。
4.5 Notebook 里单步能跑、顺序执行就报错
现象是逐个 cell 手动跑没问题,从头到尾一键运行却在某个 cell 报变量未定义或形状错误。原因是 Notebook 的隐藏状态:你之前手动跑过某个 cell,变量还留在内存里,顺序执行时那个 cell 还没轮到,变量自然不存在。解决办法是每次改动后重启内核再全部运行一遍,这是 Jupyter 环境下的血泪经验,别信「刚才明明跑通了」。另外把关键中间变量的形状打印出来,比如切窗后的xs.shape应该是(样本数, 窗口长度),多一个维度少一个维度都会在进网络时才暴露。
5. 从能跑到好用:把分段结果接回临床可读的波形标注
模型输出的是逐点类别概率,要变成能看、能核对的东西,还得做后处理。最直接的一步是 argmax 加形态学清理:把长度小于 3 个采样点的孤立类别段合并到相邻类,避免出现锯齿状的碎片标签。然后按类别提取连续段,转成「起点采样点、终点采样点、类别」的三元组,再换算成毫秒时间戳,这样就能和原始波形叠在一起画出来核对。
import numpy as np def clean_and_extract(prob, min_len=3): pred = prob.argmax(axis=0) # 合并短于 min_len 的碎片段 out = pred.copy() i = 0 while i < len(out): j = i while j < len(out) and out[j] == out[i]: j += 1 if j - i < min_len and i > 0: out[i:j] = out[i - 1] i = j # 提取连续段 events = [] start = 0 for k in range(1, len(out) + 1): if k == len(out) or out[k] != out[start]: events.append((start, k - 1, int(out[start]))) start = k return out, eventsmin_len=3是经验值,采样率越高可以适当调大。events里的起止点是采样点索引,除以采样率再乘 1000 就是毫秒。拿到这个列表后,我一般会抽十几条记录,把预测边界和真实标注画在同一张图上肉眼比对,重点看 P 波起点和 T 波终点——这两处是模型最容易含糊的地方,也是临床上判读时最在意的位置。
验证这套流程是否真的可用,有个笨但有效的办法:拿一批没参与训练的记录,先让模型跑,再人工标一遍,算事件级 F1。如果 QRS 的 F1 能稳定在 0.95 以上、P 和 T 在 0.85 左右,基本可以认为分段结果能作为辅助标注用;低于这个水平就先回去查标签质量和预处理,别急着调网络结构。参数上还有两个可以试的方向:一是把窗口从 4 秒缩到 2 秒,让每个窗口里的心搏数更少、边界样本更纯;二是把base通道从 32 降到 16 再配一点 dropout,小数据集上往往比大模型更稳。
从那以后我每次拿到这类分割源码,都强制先跑一遍「全背景预测」的基线,把它的准确率和 F1 记下来,后面任何改动都必须明显超过这个基线才算数——不然你永远不知道模型是真学到了东西,还是只是学会了偷懒。希望这份拆解能帮你在自己的 ECG 数据上少走几个弯路。
本文还有配套的精品资源,点击获取