简介:这份资源面向从事心电信号处理与深度学习分割实践的学习者,提供一套基于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分割网络搭建方法以及QTDB数据标注与训练测试的完整参考,并了解作者在输入输出尺寸匹配与标注方式上的实践经验,适合作为心电分割入门与改进的起点。
1. 心电分段为什么不能只靠阈值:从一条波形到三个波形的语义鸿沟
拿到一条心电图,人眼扫过去,P 波、QRS 波群、T 波清清楚楚,于是很多人第一反应是:找峰值、定阈值、切窗口,不就行了?真动手就会发现,阈值法在干净的信号上勉强能用,一旦基线漂移、肌电噪声、导联差异叠上来,同一个阈值在病人 A 身上切得准,到病人 B 身上就把 T 波尾巴算进了 QRS,或者把低平的 P 波整个漏掉。问题的本质不是「找峰」,而是「给每个采样点分配语义」——这是一场逐点的分类任务,而不是一次峰值检测。
Unet-ECG-Segmentation-Wavelet 这个项目正是把心电分段当成语义分割来做:用 Unet 的编码器-解码器结构,对 ECG 序列逐点判定它属于 P、QRS、T 还是背景,再叠加小波变换做多尺度特征增强。搞懂它为什么这么设计、编码器怎么压缩、小波在哪一层介入、损失怎么定义,你就能明白语义分割这套思路迁移到一维生理信号上时,哪些地方能直接搬、哪些地方必须改。这也是理解「深度学习做生理信号分析」这条路线底层逻辑的一个很好的切口。
2. Unet 的一维化改造:编码器到底在压缩什么
2.1 从二维图像到一维序列:卷积核与下采样的语义变化
原始 Unet 是为二维医学图像分割设计的,编码器用 3×3 卷积加最大池化逐级降分辨率,解码器用转置卷积逐级恢复,再通过跳跃连接把浅层的高分辨率特征拼回深层。搬到 ECG 上,最直接的变化是卷积核从二维变一维:3×3 变成 1×3 或 1×5,池化从 2×2 变成 1×2。这个改动看起来只是维度降了一档,但它改变了「感受野」和「时间分辨率」之间的权衡关系。
在二维图像里,一个 3×3 卷积看的是空间邻域;在一维 ECG 里,一个 1×5 卷积看的是前后各两个采样点。假设采样率是 250 Hz,QRS 波群宽度大约 80~120 ms,也就是 20~30 个采样点;P 波和 T 波更宽、更平缓。这意味着单层小卷积核根本覆盖不了一个完整波形,必须靠堆叠和下采样把感受野撑大。编码器每下采样一次,时间分辨率减半、感受野翻倍,到最深层时一个特征点可能对应原始信号里上百个采样点——这时候它「看到」的已经不是某个波的局部形状,而是「这一段大概是个 QRS 还是段基线」这种粗粒度判断。
常见的实现思路是:编码器用 4~5 级下采样,每级两到三个 1×3 卷积加 BatchNorm 和 ReLU,通道数从 16 或 32 起步逐级翻倍。这样最深层感受野能覆盖数百毫秒,足以容纳一个完整的心动周期片段。但代价是时间精度丢失——而分割任务恰恰要求逐点输出,所以解码器和跳跃连接必须把丢掉的精度补回来。
2.2 跳跃连接为什么是分段精度的命门
Unet 最核心的设计不是编码解码本身,而是跳跃连接。编码器在下采样过程中虽然获得了语义,但每个特征点对应的时间范围变粗了;解码器上采样时如果只靠深层特征,恢复出来的边界一定是模糊的。跳跃连接把编码器同层的高分辨率特征直接拼到解码器对应层,等于告诉解码器:「这是原始尺度上的细节,你结合深层的语义判断一起用。」
对 ECG 分段来说,这个机制尤其关键。P 波和 T 波幅度低、斜率缓,和基线噪声在局部形态上非常接近;QRS 波群陡峭,边界相对好找。如果只靠深层语义,模型容易把低平的 P 波整段判成背景;如果只靠浅层细节,又会被噪声带偏。跳跃连接让模型在「这个位置有微小起伏」和「这个位置处于心动周期的哪个阶段」之间做联合判断,边界精度才能上来。
一个容易翻车的点是拼接方式。二维 Unet 里特征图是 H×W×C,拼接在通道维;一维化之后是 L×C,同样在通道维拼。但要注意编码器和解码器对应层的时间长度必须严格对齐——如果编码器用了奇数长度池化或者 padding 设置不一致,拼接时长度差一个点就会直接报错。血泪经验是:下采样用 kernel=2、stride=2 的池化,上采样用 kernel=2、stride=2 的转置卷积,并且所有卷积都设 padding 保持长度不变,这样长度天然对齐,不用手工裁剪。
2.3 输出层与逐点分类:把分割变成每个采样点的多分类
分割头的设计决定了任务怎么被形式化。项目里每个采样点要被判成 P、QRS、T 或背景,本质是一个四分类问题。输出层用 1×1 卷积把通道数压到类别数,得到形状为 L×4 的张量,再对每个时间点做 softmax,取概率最大的类别作为该点的标签。
这里有个细节值得说清楚:为什么不用「每个波形一个边界回归」的思路?因为边界回归要预测起点和终点两个坐标,一旦某个波漏检,整个样本的损失计算就会错位;而逐点分类对漏检更宽容——漏掉一个 P 波只是那几个点分错,不会让整条样本的监督信号崩掉。这也是语义分割思路在生理信号上更稳的原因之一。
损失函数通常用逐点交叉熵,但 ECG 有个天然的类别不平衡问题:背景点远多于波形点,QRS 点又比 P、T 少。直接算交叉熵,模型会倾向于全判背景也能拿到不错的准确率。常见的补救是给每个类别加权,权重和该类别的采样点频率成反比;或者用 Dice loss 这类区域重叠度量,它对前景比例不敏感。实际训练里往往把加权交叉熵和 Dice 混着用,前者管逐点判别,后者管整体区域形状。
3. 小波变换的介入位置:多尺度特征到底加在哪一层
3.1 为什么频域信息对 P/T 波识别是刚需
ECG 的三个波形在频带上分布不同:QRS 波群能量集中在 10~40 Hz,P 波和 T 波主要在 0.5~10 Hz 的低频段,基线漂移在 0.5 Hz 以下,肌电噪声则铺在更宽的高频。时域卷积核擅长抓局部形态,但对「这段信号整体偏低频还是偏高频」这种全局属性不敏感。小波变换的价值在于它同时给出时间和频率的局部化信息——用不同尺度的小波基对信号做分解,得到一系列近似系数和细节系数,每个尺度对应一个频带。
这正好补上了纯时域 Unet 的短板。P 波和 T 波幅度小、频率低,在时域上和基线起伏容易混淆;但如果看小波分解后的低频近似系数,P/T 波所在区间的能量变化会比原始波形更明显。把这种多尺度表示喂给网络,等于给模型加了一双「看频带」的眼睛。
3.2 小波分解的工程实现:分解层数、基函数与系数对齐
项目里小波部分的核心是把原始 ECG 做离散小波变换,得到多层系数,再和时域特征融合。实现上通常用 PyWavelets 这类库,关键参数是分解层数和小波基。分解层数决定频带划分的粗细:采样率 250 Hz 时,奈奎斯特频率 125 Hz,一层分解把信号分成 0~62.5 Hz 和 62.5~125 Hz,两层分成四段,依此类推。要覆盖 P/T 波的低频段,一般做 4~5 层分解,最深层近似系数对应 0~8 Hz 左右,正好落在 P/T 波的主频区间。
小波基的选择上,sym 系列或 db 系列比较常用,因为它们在生理信号上的时频局部化表现均衡。但这里有个必须注意的对齐问题:小波分解后的系数长度和原始信号长度不一定相等,尤其是做了边界延拓之后。如果要把小波特征和时域特征逐点拼接,必须先做长度对齐——要么对系数做插值恢复到原始长度,要么在时域侧做对应的裁剪。常见的实现思路是把各层细节系数和最深近似系数分别上采样回原始长度,再在通道维拼接,这样每个时间点都有对应的多尺度表示。
import pywt import numpy as np def wavelet_features(signal, wavelet='sym4', level=4): # 对一维 ECG 做多层小波分解 coeffs = pywt.wavedec(signal, wavelet, level=level) # coeffs[0] 是最深近似系数,coeffs[1:] 是各层细节系数(从深到浅) features = [] for c in coeffs: # 将每层系数插值回原始长度,保证逐点对齐 upsampled = np.interp( np.linspace(0, 1, len(signal)), np.linspace(0, 1, len(c)), c ) features.append(upsampled) # 堆叠成 L x (level+1) 的多尺度特征矩阵 return np.stack(features, axis=-1)这段代码的关键在np.interp那一步:它把不同长度的系数统一映射回原始采样点数量,使得后续和时域特征拼接时每个时间点一一对应。level=4对应四层分解,输出五个通道(一个近似加四个细节)。实际使用时这个函数通常放在数据预处理阶段,把结果和原始信号一起送进网络,而不是在模型内部动态计算——因为小波变换是确定性的,提前算好能省训练时的计算开销。
3.3 融合策略:早期拼接、中期注入还是后期加权
小波特征怎么和 Unet 结合,有三种典型位置。早期拼接是把多尺度特征和原始信号在输入通道维直接拼起来,一起送进编码器;中期注入是在编码器的某一层把对应尺度的小波系数加进去或拼进去;后期加权是在解码器输出前用一个小分支处理小波特征,再和主分支融合。
早期拼接实现最简单,但有个隐患:小波特征和原始信号的数值尺度差异大,直接拼在一起会让网络在初期训练时被大数值通道主导。稳妥的做法是先对每个通道做标准化。中期注入更符合多尺度直觉——编码器浅层对应高频细节,深层对应低频语义,把对应尺度的小波系数注入对应层,语义上更匹配,但实现上要处理每层特征长度和系数长度的对齐,复杂度高。后期加权最灵活,但融合发生在语义已经成型之后,对浅层细节的补救有限。
从设计取舍看,如果目标是快速验证小波到底有没有用,早期拼接加标准化是性价比最高的起点;如果追求精度,中期注入更值得投入。项目里具体用哪种,取决于它的网络结构定义——读源码时重点看输入通道数是不是大于 1、编码器各层是否有额外的特征拼接操作,就能反推出融合位置。
4. 训练与推理链路:标签怎么造、模型怎么收敛
4.1 逐点标签的生成:从 R 峰标注到三类别掩码
语义分割需要逐点标签,但原始 ECG 数据集通常只给 R 峰位置或者波形起止点。把稀疏标注变成逐点掩码,是这条链路里最容易被低估的一步。常见做法是:以 R 峰为锚点,按经验窗口划定 QRS 区间(比如 R 峰前后各 50 ms),再在 QRS 前后按生理先验找 P 和 T 的搜索范围,结合波形检测算法确定边界,最后把每个采样点标成对应类别。
这个过程的精度直接决定模型上限。如果标签本身把 T 波终点标早了,模型学到的边界就是错的,再怎么调网络也补不回来。所以合格的做法是至少做一轮人工抽检,看标签掩码叠在原始波形上是否贴合。另一个细节是类别编号要固定,P、QRS、T、背景的索引在整个训练和推理链路里保持一致,否则评估时混淆矩阵会对不上。
4.2 训练稳定性:类别加权、学习率与早停
ECG 分段的训练有几个反复踩的坑。第一是类别不平衡,前面提过,加权交叉熵的权重建议按训练集统计的类别频率取倒数再归一化,不要凭感觉设。第二是学习率,一维 Unet 参数量比二维小,学习率可以比图像任务稍大,但 BatchNorm 在小 batch 下方差估计不稳,batch size 太小时要么调大要么换成 GroupNorm。第三是早停指标不能只看准确率,因为背景点占多数,准确率很容易虚高;应该看前景类别的平均 Dice 或 IoU,按这个指标做模型选择。
import torch import torch.nn as nn # 按类别频率设置权重:背景多则权重低,QRS 少则权重高 class_weights = torch.tensor([0.1, 1.5, 2.0, 1.2]) # 顺序: 背景, P, QRS, T criterion = nn.CrossEntropyLoss(weight=class_weights) # 前向: logits 形状 [B, C, L],标签形状 [B, L] loss = criterion(logits, labels)权重张量的顺序必须和类别索引严格一致,这是排查损失异常时第一个要核对的地方。如果训练 loss 下降但验证集前景 Dice 不涨,多半是权重把模型推向了某一类,需要重新统计频率。
4.3 推理后处理:从逐点概率到波形区间
模型输出的是每个点的类别概率,但下游应用往往要的是「P 波从第几个点到第几个点」这样的区间。直接把逐点 argmax 的结果当区间用会有碎片:同一个波内部可能夹杂几个被误判成背景的点。常见的后处理是先对概率序列做平滑(滑动平均或中值滤波),再按类别做连通域分析,把连续同标签的段提取出来,过滤掉过短的碎片,最后按生理约束校验——比如一个心动周期里 QRS 应该只有一个主段,P 在 QRS 之前、T 在之后,不符合顺序的段可以降权或合并。
这套后处理不是可有可无的装饰,它把模型的逐点判断和心电的生理结构约束接了起来,往往能挽回几个点的 Dice。读项目源码时留意推理脚本里有没有平滑和连通域相关的逻辑,就能判断它的输出是「原始概率」还是「可直接用的区间」。
5. 避坑与排查:这条链路上最容易翻车的几个地方
5.1 现象:训练 loss 正常下降,验证集 Dice 却卡在低位不动
原因通常是标签和输入没对齐。小波特征如果做了插值但时域信号做了裁剪,两者长度差几个点,拼接后每个时间点的语义就错位了;或者标签掩码在生成时用了和信号不同的采样率。解决方法是写一个可视化脚本,把一条样本的原始波形、小波特征第一通道、标签掩码画在同一时间轴上,肉眼确认三者对齐。这个检查我一般放在训练前强制走一遍,比训练几轮再回头查省时间。
5.2 现象:P 波几乎全被预测成背景
原因是类别不平衡加上 P 波幅度低,模型在加权交叉熵下仍然倾向于牺牲少数类。先确认权重是否按真实频率设置,再看输入是否做了归一化——如果不同样本幅度差异大,低幅 P 波在归一化后可能被压得更不明显。解决方法是按样本做幅度归一化(除以该样本的鲁棒标准差),并适当提高 P 类权重,同时用 Dice loss 补足区域重叠的监督。
5.3 现象:QRS 边界系统性偏移,起点偏早或终点偏晚
原因是感受野和标签定义不匹配。如果标签按固定窗口划 QRS,而模型深层感受野远大于这个窗口,边界就会被平滑掉。解决方法是检查标签的边界定义是否和临床习惯一致,并在解码器末端保留足够的高分辨率特征——跳跃连接是否真的生效、拼接后有没有被后续卷积过度平滑,都要逐层看特征图确认。
5.4 现象:换一个数据集或导联,性能断崖式下跌
原因是模型学到了数据集特有的基线或噪声模式,而不是波形本身的形态。小波特征如果没做标准化,不同数据集的频带能量分布差异会直接传导进网络。解决方法是在预处理阶段统一滤波(比如 0.5~40 Hz 带通)、统一重采样率、对小波特征逐通道标准化,并且在划分训练验证集时按受试者划分而不是按片段随机划分,避免同一受试者的片段同时出现在两边造成虚高。
5.5 现象:推理结果碎片化,一个波被切成好几段
原因是逐点 argmax 没有利用生理连续性。解决方法是加概率平滑和连通域过滤,再按 P-QRS-T 的先后顺序做约束校验。这一步不需要改模型,纯后处理就能明显改善输出观感,但如果碎片特别严重,说明模型本身的逐点判别就不稳,要回头查训练数据和损失权重。
6. 设计取舍的再思考:语义分割范式在生理信号上的边界
把 Unet 加小波这套组合拆到最后,会发现它的核心赌注是:ECG 分段可以被建模成逐点语义分类,而多尺度频域特征能补上时域卷积对低频弱波形不敏感的短板。这个赌注在多数情况下成立,但它有明确的边界。逐点分类假设每个采样点的类别只依赖局部上下文和整体节律,可现实中某些心律失常的波形形态会打破这种假设——比如宽大畸形的 QRS 和 T 波融合时,逐点标签本身就存在标注歧义,模型学到的只能是标注者的折中。
另一个取舍在感受野和分辨率之间。编码器下采样越深,语义越稳,但边界越糊;跳跃连接缓解了这个矛盾,却没有消除它。小波特征的引入本质上是在频域上给模型补了一层「全局视野」,但它也带来了新的超参数:分解层数、基函数、融合位置、标准化方式,每一个都会影响最终精度。这些参数没有普适最优解,只能按数据集的采样率和波形特征去调。
如果要验证这套设计到底有没有用,我建议做消融而不是只看最终指标:去掉小波分支看 Dice 掉多少,把跳跃连接断开看边界误差涨多少,把加权交叉熵换成普通交叉熵看少数类召回怎么变。三个消融做完,你对「哪个模块在真正起作用」就有底了,而不是对着一个漂亮的总体数字猜。
从那以后我每次碰这类逐点分割的生理信号项目,都会先把「标签对齐可视化」和「消融基线」这两步强制走一遍,再谈调参和加模块——因为翻车往往不是模型不够深,而是链路里某个对齐假设悄悄错了。希望帮到你。
本文还有配套的精品资源,点击获取