☰
Unet+小波变换的ECG波形分割:PQRST逐点定位实战解析
2026/10/23 9:26:50 网站建设 项目流程

简介:一份面向心电图信号处理与深度学习研究者的U-Net ECG分段实现,基于PyTorch完成,使用小波变换对QT-Database数据进行预处理,自动识别P波、QRS波群与T波。压缩包共27个文件,以Python脚本为主,覆盖数据加载、小波特征提取、U-Net网络定义、训练与测试等环节;另附Jupyter Notebook演示、Markdown说明文档和开源许可,整体约197KB。已有784人学习下载,适合正在探索医学时间序列分割、心电信号分类或U-Net变体应用的医工交叉开发者参考。这套代码不仅给出完整的训练/推理流程,还展示了将ECG信号转换到小波域并保存尺度、实部与虚部特征的具体做法,以及使用WFDB将P、QRS、T段标记为1、2、3的标注思路;作者同时指出当前标记方式仍存在一些问题,并提示可换用其他数据集继续优化,具有不错的起点价值。

1. 从P、QRS、T的分段说起:这个Unet-ECG-Segmentation-Wavelet资源能解决什么问题

做心电信号分析的人迟早会遇到同一个坎:想定位一段ECG里的P波、QRS波群和T波,阈值法在干净数据上还能凑合,一旦混入基线漂移、肌电干扰,或者遇到P波幅度偏小的样本,检测结果就各种错位。这个名为Unet-ECG-Segmentation-Wavelet的资源,思路是把问题从“找波峰”改成“逐点分类”——用Unet对整条心电信号做语义分段,网络输出的每个采样点都带一个类别标签,P、QRS、T各归各段。配合小波预处理先做去噪和特征增强,再喂给网络,整体效果比传统的差分阈值法稳定得多。适合正在做心电自动分析、睡眠分期、心率变异性研究,或者想把深度分割模型用到一维生物医学信号上的开发者。这份资源给了现成的网络结构和处理流程,可以直接在JupyterNotebook里复现,不用从头搭。

2. 为什么是Unet:波形分割任务的网络选型与小波预处理定位

2.1 Unet的编码器-解码器结构为什么适配一维心电信号

Unet最早是给医学图像分割设计的,但它的核心思想搬到一维心电信号上完全成立。ECG分段本质上是序列标注任务:输入一段长度为N的采样点序列,输出同样长度为N的标签序列,每个标签对应背景、P波、QRS波群、T波中的一个类别。这种输入输出尺寸严格对齐的结构,恰好是Unet的编码器-解码器架构擅长的事。

编码器部分通过下采样逐步扩大感受野。以一段采样率250Hz、时长10秒的信号为例,输入长度是2500个采样点,经过两次步长为2的下采样后,特征图长度缩到625,再往下缩到312左右。这时候每个位置能看到的信息范围大大增加,网络能够学到“QRS波群之后大概间隔多少毫秒出现T波”这种时序依赖。但下采样必然丢失细节,尤其是P波的起点和终点,幅度只有0.1到0.2mV,在深层特征图里几乎被抹平。解码器的作用就是通过上采样逐级恢复分辨率,而跳跃连接把浅层的高分辨率特征直接拼到解码器对应层上,让网络既看得到全局形态,又守得住局部边界。

对比其他常见选型:LSTM/GRU这类循环网络虽然天生适合序列,但训练速度慢,长序列上梯度传播容易出问题,而且对起止边界这类“逐点分类”任务并不比卷积网络更有优势;纯粹用一维卷积做全卷积网络(FCN)也能做,但没有跳跃连接的对称结构,小波形的边界恢复能力明显弱一截。Unet在一维信号上的改进版本通常被称为1D-Unet,把图像里的二维卷积核换成时间维度上的一维卷积核即可,参数量不大,在CPU上都能跑得动推理。这也是这个资源选择Unet而不是其他结构的一个很实际的原因。

2.2 小波预处理在分段流程里的角色:去噪与特征保留

小波变换在这条流程里不是可选项,而是直接影响分割质量的前置步骤。心电信号最常见的干扰有三种:基线漂移(频率通常在0.5Hz以下)、工频干扰(50/60Hz及其谐波)、肌电干扰(频率范围宽,和高频P波细节重叠)。带通滤波器能解决前两类,但肌电干扰和P波高频分量频带有重叠,一刀切的滤波往往把P波边界一起削掉。

离散小波变换的做法是把信号分解成不同尺度下的近似分量和细节分量。近似分量对应低频趋势,细节分量对应不同频带的高频成分。实现时常用pywt库,选择一个小波基(比如db4或sym8),设定分解层数,然后把代表基线漂移的最低层近似分量置零、代表高频噪声的最细节分量置零,再重构信号。这个过程比普通带通滤波更精细:它保留了包含QRS主能量和P波边界的中间频带细节,同时去掉基线和毛刺。代码上通常只需这么几行:

import pywt import numpy as np def wavelet_denoise(signal, wavelet='db4', level=6): # 对一维心电信号做小波分解 coeffs = pywt.wavedec(signal, wavelet, level=level) # 把最低层近似分量置零,消除基线漂移 coeffs[0] = np.zeros_like(coeffs[0]) # 把最后一层细节分量置零,抑制高频噪声 coeffs[-1] = np.zeros_like(coeffs[-1]) # 用处理后的系数重构信号 denoised = pywt.waverec(coeffs, wavelet) return denoised

逻辑说明:pywt.wavedec返回一个列表,第0项是最低层近似系数,对应信号最平缓的趋势;最后一项是最高频细节系数,对应最快变化的成分。置零后重构,相当于只保留中间频带的分量。

参数说明:level的取值直接影响去噪强度。level=4时只滤掉极低频基线,对工频干扰抑制较弱;level=6以上会把一部分P波高频边界也当作噪声滤掉。采样率越高,可用的分解层数越多。250Hz的信号一般用level=5或6,500Hz信号可以到level=7。小波基的选择上,db4和sym8都是心电信号处理里的常用选择,sym8的对称性更好,边界失真更小,但计算量也略高一些。

需要特别注意的是,去噪后的信号长度可能比原始信号少几个采样点。这是小波重构的边界效应,属于正常现象,但后续送入网络前需要做长度对齐,否则会报维度不匹配的错误。具体处理方式在第四章的避坑部分单独说明。

2.3 标签怎么来:分段任务和分类任务在标注粒度的本质差异

很多第一次接触ECG分段的人会拿心电分类的思维来理解标注,这是个容易踩的认知误区。心电分类(例如判断是否早搏、是否房颤)的标签是“一条记录一个类别”,甚至“一个心拍一个类别”;而分段任务是“每个采样点一个类别”,标签不是数值,而是一个和信号等长的整数数组。

以PQRST分段为例,标注规范通常如下:

标签值含义典型标注范围
0背景(非P、非QRS、非T)两个心拍之间的静息段
1P波从P波起点到P波终点
2QRS波群从Q波起点到S波终点
3T波从T波起点到T波终点

生成标签的方式有两种。一种是有标注文件的数据集,比如某些公开心电数据库自带P、QRS、T的起止点标注,可以直接把这些起止点展开成逐点标签数组。另一种是先用现成的QRS检测器找到R峰,再按固定窗口偏移估计P波和T波范围,这种办法精度低,只适合做弱标签预训练。实践中还会遇到一个细节:P波并不总是存在,比如房颤信号里P波消失;T波在部分导联上可能倒置,但分段任务依然要把倒置T波的区域标出来。因此标签生成脚本需要支持空P波和倒置T波的情况。

“逐点标签如何构造”这步是资源里最值得读源码的部分,因为标注逻辑一旦出错,网络训练得再好也白搭。常见做法是先把心拍边界标出来,再在边界内部按形态学规则细分P、QRS、T。训练时损失函数使用逐点的交叉熵,配合类别权重,以缓解QRS占比过小的问题。这一点在第四章展开。

3. 让模型跑起来:Jupyter环境、数据准备与一次完整推理

3.1 环境与依赖:JupyterNotebook下的复现前提

这份资源的核心工作流是围绕JupyterNotebook组织的,好处是每个处理阶段都能可视化验证。打开notebook后,首先需要确认环境完备。依赖库主要包括PyTorch(作为Unet的深度学习框架)、NumPy(数组运算)、PyWavelets(小波变换)、Matplotlib(波形可视化),以及用于读取心电文件的wfdb库。如果机器上没有装齐,第一格代码通常会报ModuleNotFoundError,解决方式如下:

pip install torch numpy pywavelets matplotlib pip install wfdb

逻辑说明:第一条命令安装深度学习框架和信号处理库,第二条命令安装心电数据读取库。PyWavelets在PyPI上的包名是pywavelets,import时写pywt,这个差异经常让人装完仍然报错。

参数说明:torch的安装版本需要和CUDA版本对应。如果只是CPU环境跑推理,直接装CPU版即可;如果要训练,建议先查清楚本机显卡驱动的CUDA版本再选择对应安装命令,不要盲目装最新版。

安装完成后,在notebook里执行一次环境自检,把关键import放在同一个单元格里,任何一个失败都能立即看到。

import torch import numpy as np import pywt import matplotlib.pyplot as plt import wfdb print("torch:", torch.__version__) print("cuda available:", torch.cuda.is_available())

逻辑说明:这个单元格同时验证了五个关键依赖是否可用,torch.cuda.is_available()返回False不代表不能跑,只是训练会慢。

3.2 数据准备:从心电文件到模型输入张量

做推理的第一步是读入心电信号。以标准心电记录格式为例,wfdb库可以同时读取信号和标注文件。假设有一条10秒、采样率250Hz的单导联记录,读取后得到长度为2500的一维数组。

record = wfdb.rdrecord('sample_record', channels=[0]) signal = record.p_signal[:, 0].flatten() fs = record.fs print("signal length:", len(signal)) print("sampling rate:", fs)

逻辑说明:rdrecord读取记录文件,channels=[0]指定只用第一个导联,p_signal是二维数组,取第0列并展平为一维信号。

参数说明:采样率fs决定了后续所有窗口长度和标注范围换算。250Hz下1秒=250个采样点,10秒=2500点。如果数据是CSV格式,用np.loadtxt或pandas读取即可,但必须确认单位一致——ECG信号通常以mV为单位,有些导出文件可能是uV,数值会差1000倍。

拿到原始信号后,分两步处理:重采样对齐到模型期望的采样率,然后执行小波去噪。训练该网络的项目组通常在数据准备阶段就把所有样本重采样到统一采样率(比如125Hz或250Hz),这个信息在notebook的说明格里会标注,推理时必须保持一致。

from scipy.signal import resample target_fs = 250 if fs != target_fs: num_target = int(len(signal) * target_fs / fs) signal = resample(signal, num_target) denoised = wavelet_denoise(signal, wavelet='db4', level=6)

逻辑说明:如果原始采样率和模型期望值不一致,用resample做等比例重采样;然后用第二章定义的小波去噪函数进行预处理。

参数说明:resample是按时间等比插值,不是按秒截断。如果原始信号是8.4秒,重采样后长度会变成8.4×250=2100点,这个长度后面还要再做分段切窗。

接下来把信号切成模型输入的固定长度。1D-Unet通常接受固定长度的输入,拿这个资源为例,切窗长度常见取值为1024或2048个采样点。切窗时推荐滑窗法加部分重叠,长信号被切成多段,分别预测后再拼接回完整标签序列。

def sliding_window(signal, window_size=1024, stride=512): windows = [] for start in range(0, len(signal) - window_size + 1, stride): windows.append(signal[start:start + window_size]) return np.array(windows) windows = sliding_window(denoised, window_size=1024, stride=512) print("windows shape:", windows.shape)

逻辑说明:每次移动512个采样点,取1024点窗口,相邻窗口有50%重叠,避免漏掉落在窗口边界上的波形。

参数说明:window_size取1024时,模型能同时看到约4秒的心电活动(250Hz下),覆盖两个以上的完整心拍,对P波和T波的上下文判断足够。stride越小重叠越大,预测越平滑但计算量越大;stride等于window_size则完全不重叠,速度快但边界处容易出错。

3.3 跑一次推理:加载权重、分段输出与可视化

数据准备好之后就到了整个流程的核心:把窗口送入Unet,得到每个采样点的分类概率。模型的forward输出通常是形状为(batch, num_classes, sequence_length)的张量,需要沿类别维度取argmax,转换成(batch, sequence_length)的标签数组。

import torch import torch.nn.functional as F model.eval() probabilities = [] with torch.no_grad(): for w in windows: input_tensor = torch.from_numpy(w).float().unsqueeze(0).unsqueeze(0) logits = model(input_tensor) probs = F.softmax(logits, dim=1) probabilities.append(probs.cpu().numpy()) probs_all = np.concatenate(probabilities, axis=-1).squeeze(0) labels_all = np.argmax(probs_all, axis=0) print("labels shape:", labels_all.shape)

逻辑说明:模型输入需要(batch, channel, length)四维张量,单条信号要加两个维度。softmax把logits转成每个类别的概率,argmax取概率最大的类别作为该采样点的预测标签。

参数说明:如果模型是在不同采样率下训练的,输入长度和归一化方式必须严格对应用训练时的统计值。常见做法是训练时对信号做z-score归一化(减去均值除以标准差),推理时也要对每个窗口做同样的操作,否则概率分布会失真。

可视化这一步直接用matplotlib画上下两幅图:上图是去噪后的信号,下图是分段标签。标签用填充色块表示不同的波段,一眼就能看出预测的P、QRS、T是否和信号形态吻合。此处常见问题包括:标签边界和波形实际起止点错位、QRS被分成两段、P波完全没预测出来等,具体原因在第四章逐一排查。

3.4 预测结果的后处理:连通域合并与波群边界修正

模型直接输出的标签序列往往是粗糙的,存在不少单个采样点的毛刺和断裂段。比如一个完整的QRS波群,中间可能有一两个点被预测成背景,导致标签数组出现“背景-QRS-背景-QRS”的交替片段。这种高频抖动直接用中值滤波就能压掉,但降噪之后会出现新的问题:滤波可能把真实的短PR间期也磨平,因此更推荐使用连通域分析。

思路很简单:把标签序列按连续相同值切分成若干段,统计每一段的长度,然后按医学常识做合并和过滤。QRS波群在250Hz采样率下正常持续80到120毫秒,即至少20个采样点;小于这个长度且夹在两个QRS段之间的孤立QRS标签,几乎可以确定是毛刺。P波更短但也不应小于10个采样点。具体实现可以用如下代码:

def refine_labels(labels, fs=250, min_qrs_points=20, min_p_points=10): refined = labels.copy() # 找出所有类别变化的位置 change_idx = np.where(np.diff(refined) != 0)[0] + 1 segments = np.split(refined, change_idx) for i, seg in enumerate(segments): label = seg[0] length = len(seg) if label == 2 and length < min_qrs_points: # 过短的QRS段,视为噪声,替换为背景 refined[change_idx[i-1] if i > 0 else 0: change_idx[i] if i < len(segments)-1 else len(refined)] = 0 return refined

逻辑说明:np.diff计算相邻标签差异,非零位置就是类别切换点。对每一段独立判断长度,小于阈值的段被替换为背景。

参数说明:min_qrs_points和min_p_points的取值依赖采样率。上面的数值以250Hz为准,如果重采样到125Hz,QRS的最短采样点数要缩减到10个点,否则会把低采样率下的正常QRS误删。这个阈值是整个后处理流程里最需要根据实际数据调整的部分。

4. 避坑:ECG分段里最常见的五个翻车点与处理办法

4.1 信号长度对齐:重采样和小波重构各削掉一段

现象:训练时loss正常下降,但推理时模型报维度不匹配,或者预测结果和输入信号对不上——标签长度比信号少了几十个采样点。

原因:重采样会把信号长度按比例变换,产生小数后取整,长度差随之而来;小波变换的边界效应也会在重构时让信号缩短。多个处理步骤叠加后,标签和原始信号自然错位。

解决:在每次重采样和小波重构之后立即打印长度,做显式对齐。小波重构后如果长度少了k个点,在末尾补k个零,或者直接从原信号对应位置截取。我的习惯是定义一个ensure_length(signal, target_len)函数,在进入模型前统一强制对齐,避免错误埋到后处理阶段才暴露。

4.2 类别不平衡:QRS占比太小,模型趋向于全预测背景

现象:训练曲线上loss在下降,准确率也高,但可视化一看,几乎所有采样点都被预测成背景,QRS偶尔冒出来一段,P波和T波完全没有。

原因:正常心电信号里背景段占绝大部分比例。一段10秒的信号,QRS波群累计可能只有300到400个采样点,占比不到15%,P波占比更小。如果不做类别权重,网络只要学会输出背景就能把loss压得很低。

解决:训练时在损失函数里给不同类别加权,QRS权重最高、P波其次、T波再次、背景权重最低。权重数值一般按各类别占比的倒数来算,例如背景占比60%时权重设为0.5,QRS占比15%时权重设为2.5。模型输出概率也可以用温度系数调节,降低背景类的绝对优势,但这是推理侧的手段,根本解法还是训练时把类别权重调对。

4.3 小波分解层数设置不当:滤掉噪声的同时也滤掉了P波

现象:去噪后的信号看起来干净了,但送入模型后P波完全检测不到;回看信号,P波位置平得像个平台,波形细节没了。

原因:level设置过高时,小波重构会丢弃包含P波边界的中频成分。P波的频率范围约在5到20Hz,如果分解层数对应的高频截止频率低于这个范围,P波就被当成了噪声。

解决:用不同level做对比试验。做法是分别用level=4、5、6、7去噪同一段信号,画出波形对比,重点观察P波是否存在幅度变化、边界是否锐利。250Hz采样率下从level=5开始试起,如果发现P波变平,降一层。记住一个规律:采样率越高,可用的分解层数越多,但不能为了去噪效果一味调大level。

4.4 归一化方式不一致导致边界漂移

现象:同一段信号,在训练集上预测效果良好,在另一批新数据上,QRS边界整体向右偏移几个采样点,偏移量在不同心拍之间还不一样。

原因:模型训练时用的是窗口内z-score归一化,而推理时如果直接对整段信号做全局归一化,每个窗口的均值和标准差截然不同,输入分布不一致,模型输出的边界自然发生偏移。

解决:训练和推理的归一化方式必须完全一致,且归一化操作要在切窗之后对每个窗口独立执行,而不是对整段信号执行。代码上保证window = (window - window.mean()) / (window.std() + 1e-6)在训练和推理使用同一实现。这个坑最容易在更换数据来源时踩到,因为新数据的基础电平和旧数据集不同,全局统计值一变,边界位置就跟着变。

4.5 评估指标别只看准确率

现象:验证集准确率上了98%,但实际应用时医生反馈“T波末端老是提前切掉”,回头检查才发现准确率高是因为背景占比大,T波预测错了也被淹没在背景的“正确”里。

原因:逐点准确率在类别极不平衡时是虚高指标,它不能反映P波和QRS边界是否准确,也不能反映每个心拍的检出率。

解决:用Dice系数和交并比(IoU)按类别分别计算,重点看P波类别和QRS类别的IoU。还要按心拍维度统计:检测到的QRS数除以标注的QRS总数得到召回率,误检的QRS数除以检测总数得到精确率。如果Dice高但心拍级精确率低,说明边界对但存在重复检测,需要加强后处理的连通域合并。这个资源里如果只提供了准确率脚本,建议自己补一份心拍级评估,否则模型的真实水平很难看清。

5. 进阶:验证分段结果、调整输出阈值与扩展边界

5.1 波形分段质量的快速验证清单

模型跑通之后,不要急着部署,先花十分钟做一次目视验证。我的固定流程是:从测试集里随机抽五段信号,每段打印信号波形图和标签着色图,按三个标准检查。第一,QRS波群的起点应该在Q波下折处而不是R峰上升段中部;第二,P波如果存在,起点到终点之间不能有断裂;第三,相邻心拍的PR间期和QT间期在数值上不应出现突变,如果连续两个心拍的QT间期差了100毫秒以上,多半是T波边界识别出问题。

除了目视,还要做一次数值验证:利用R峰位置把分段结果切分成单个心拍,计算每个心拍的P波宽度、QRS宽度、QT间期,对照该导联的医学参考范围。P波宽度正常不超过120毫秒,QRS宽度在80到120毫秒之间,超过这些范围的样本基本都能定位到预测错误的具体位置。

5.2 让模型适配不同导联:用输出阈值代替重新训练

换一组导联数据时,模型效果往往下降,常见做法是重新训练,但先尝试调节softmax输出的温度系数往往更省时间。温度系数大于1时,各类别概率分布趋于平滑,边界处的标签切换点会向后推迟;小于1时,概率分布变尖锐,标签切换更激进。这个参数不需要重新训练,只在推理时对logits做缩放:

temperature = 1.2 probs = F.softmax(logits / temperature, dim=1)

温度调大会让低置信度的P波更容易被预测出来,代价是边界可能会拖长几十毫秒;调小则边界紧贴波形,但弱信号可能直接丢掉。我通常以50毫秒边界误差为容忍上限,在验证集上网格搜索最合适的温度值。

5.3 把工作流扩展到更多任务

这套“小波预处理 + 1D-Unet + 连通域后处理”的工作流不止能分段PQRST。换成房颤分析时,把P波类别改成f波颤动段;做睡眠分期时,把标签改成清醒、浅睡、深睡、REM四类,输入换成单导联脑电信号,网络结构和后处理框架都不用伤筋动骨。改动最大的其实是标签生成脚本,而不是模型本身。如果你手里有新的标注数据,尝试冻结Unet的前三层、只训练解码器,可以在新任务上快速收敛——这是我在另一个模拟项目X上验证过的捷径,效果比从头训练稳定得多。

把这个资源从头到尾跑通一遍之后,我养成了一个习惯:每次拿到新的心电数据,先花五分钟检查采样率、量纲和标注文件的起止点格式,再决定要不要动预处理参数。这套流程看着不复杂,但每一处参数都对应着真实的坑,跳过任何一步都可能在后处理阶段付出更惨痛的代价。希望这篇拆解能让你少走一段弯路,尽快把这套Unet心电分段流程跑出自己的结果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询