资讯详情

基于Unet与小波变换的ECG信号自动分段:一维分割工程实践

📅 2026/10/23 3:15:34 | 华诺云谱 👁 阅读
基于Unet与小波变换的ECG信号自动分段:一维分割工程实践
简介基于PyTorch的Unet心电图分段实现面向生物医学信号处理与深度学习初学者。项目以QTDB数据集为输入借助改进版PyTorchWavelets将ECG转换到小波域并利用WFDB工具将P波、QRS波、T波标注为1/2/3类别通过1D卷积与转置卷积完成序列分割同时输出小波变换的尺度与实部、虚部特征便于分析多分辨率信息对分割结果的影响可用于心电信号自动分析、节律识别等相关任务。压缩包共27个文件主要包括Python脚本、Jupyter Notebook、Markdown说明与许可证文件py覆盖数据加载、Unet网络构建、训练与测试流程ipynb演示小波提取及预处理md提供QTDB数据下载方式与复现步骤整体约197KB结构简洁易读。已有784人学习浏览适合想快速上手ECG分割或熟悉小波—深度学习结合思路的研究者直接参考与二次开发。1. 用Unet给心电图自动分段一个把图像分割网络改造成信号分割器的工程拿到一段10秒的ECG要程序自动标出P波、QRS波群、T波的起止位置这事比想象中麻烦。临床医生肉眼能看但遇到24小时动态心电数据靠人眼一个个挑P波挑到后面视觉疲劳标注质量直线下降。这个项目把图像分割领域很成熟的Unet网络改造成一维版本先用小波Wavelet变换把噪声和基线漂移处理掉再对每个采样点输出类别标签P、QRS、T三个分量一次全给。整个工程用JupyterNotebook组织流程从数据预处理到训练再到评估都能直接在notebook里跑通代码结构清楚适合做生物信号深度学习的开发者拿来当基座也适合想复现一条完整信号分割管线的读者。2. 技术选型与架构设计Unet一维化与小波预处理2.1 为什么不用LSTM/CRF而是把Unet搬到一维ECG分段本质上是序列标注任务常见的思路是上LSTMCRF利用时序依赖建模。但实际做下来会发现一个问题P波幅度小、边界模糊QRS波群陡峭尖锐T波平缓宽大这三类形态差异巨大单纯靠循环网络逐步传递信息很容易在长序列上丢失P波这种弱边界。Unet的优势在于编码器-解码器结构加跳跃连接。编码器逐步下采样提取高层语义解码器逐步上采样恢复分辨率跳跃连接把浅层的位置细节直接传给解码器。这种设计对边界敏感恰好对付P波和T波这类弱边界。ECG分割其实和图像分割在任务形态上完全一致输入一个序列输出每个位置的类别。把Conv2d换成Conv1d再调整池化和上采样维度Unet就能直接处理一维信号。从实践效果看Unet在小规模标注集上的表现通常优于LSTMCRF而且训练更稳定调参空间也小。2.2 Wavelet预处理先去掉噪声再谈分割ECG是低信噪比信号常见的干扰有基线漂移、肌电噪声、工频干扰。直接用原始信号训练模型会把噪声特征学进去导致分割结果出现大量碎片。小波变换在这里做的事是把信号分解成不同尺度的近似分量和细节分量对细节分量做阈值收缩再重建信号。这种处理比单纯低通滤波强的地方在于它能在去噪的同时保留QRS波群的陡峭沿——低通滤波会把QRS波群磨圆而小波阈值收缩只压制高频噪声分量保留信号本身的突变特征。Wavelet模块在整个架构里处于Unet之前可以看作一个可插拔的预处理层。训练时对每个窗口先做小波去噪再进网络推理时同样处理保证训练和推理的输入分布一致。另一个可选做法是把小波分解出的细节系数作为额外通道拼到输入上相当于给网络提供多尺度特征这个后面在核心代码里展开。2.3 技术栈与版本选型从仓库的工程组织可以看出这是一个以JupyterNotebook为主线的深度学习项目核心依赖如下。模块用途选型说明Python 3.8基础环境兼容PyTorch和pywtPyTorch深度学习框架动态图方便调试Unet结构pywt小波变换官方维护的Wavelet工具库numpy/scipy信号处理重采样、滤波、数值计算matplotlib可视化绘制ECG波形和分割掩码JupyterNotebook流程组织预处理、训练、评估各占一个notebook版本选型上有一件事要提醒pywt的接口在v1.x之后有过调整pywt.wavedec的mode参数默认值是symmetric这个选择直接决定重构后信号是否等长后面避坑章节会重点讲。PyTorch建议用1.9以上版本Conv1d和ConvTranspose1d的接口很稳定不用太纠结具体小版本。3. 工程结构与数据管线从原始信号到训练张量的完整路径3.1 目录结构notebook与模块如何各司其职这个项目的目录划分干净notebook负责流程展示src里放可复用模块data放原始数据和中间产物。下面是一个典型的布局。Unet-ECG-Segmentation-Wavelet/ ├── data/ │ ├── raw/ # 原始ECG信号按导联和受试者组织 │ ├── processed/ # 切窗和归一化后的npy数组 │ └── labels/ # 标注文件标记P/QRS/T的起止位置 ├── notebooks/ │ ├── 01_data_preprocess.ipynb │ ├── 02_train_unet.ipynb │ ├── 03_evaluate.ipynb │ └── 04_visualize.ipynb ├── src/ │ ├── dataset.py # 数据加载与窗口切分 │ ├── unet_1d.py # Unet一维模型定义 │ ├── wavelet_utils.py # 小波去噪与特征增强 │ ├── loss.py # 组合损失函数 │ └── metrics.py # 逐类Dice/IoU与边界误差 ├── requirements.txt └── README.md模块职责很明确dataset.py负责把原始信号切成固定长度窗口并生成逐点标签unet_1d.py只关心模型结构不关心数据来源wavelet_utils.py提供去噪和特征拼接入口。这种拆分有一个实际好处换数据集时只需要改dataset.py换模型时只需要动unet_1d.py适配成本低。3.2 数据预处理切窗、重采样与标签对齐ECG原始信号长度不固定而Unet要求输入长度基本一致所以第一步是重采样到统一采样率然后按固定窗口切分。常见配置是250Hz采样率10秒一个窗口也就是每窗口2500个采样点。切窗有重叠步长通常设为窗口长度的一半增加样本量同时避免漏掉跨越窗口边界的波形。标注文件里记录的是P波、QRS波群、T波的起止时间点需要转换成逐点类别标签。背景标为0P波标为1QRS波群标为2T波标为3。import numpy as np def labels_to_mask(annotations, signal_length, fs250): mask np.zeros(signal_length, dtypenp.int64) # annotations: list of (start_sec, end_sec, wave_type) # wave_type: P - 1, QRS - 2, T - 3 wave_map {P: 1, QRS: 2, T: 3} for start, end, wtype in annotations: start_idx int(start * fs) end_idx int(end * fs) mask[start_idx:end_idx] wave_map[wtype] return mask这段代码把时间标注转换为模型需要的逐点mask。signal_length是切窗后的采样点数fs是采样率两个参数必须和切窗逻辑保持一致。有一个容易忽略的坑标注文件里某些波形的起止时间可能超出当前窗口范围例如P波起点在窗口外但在窗口内结束这类跨边界样本要么丢弃要么做裁剪后再映射否则边界处会出现类别断裂。归一化也是预处理的关键步骤。ECG信号幅值在不同受试者、不同导联间差异很大直接训练容易让模型学到幅度偏好。常见做法是z-score归一化用训练集的均值和标准差做标准化推理时沿用同一套统计量不能对单个样本单独归一化否则会破坏波形形态一致性。3.3 训练与推理流程设计训练流程从加载窗口数据开始每一步都有明确的数据流。# 伪代码展示训练循环的数据流 for batch in train_loader: x_wavelet wavelet_denoise(batch[signal]) # 小波去噪 logits model(x_wavelet) # Unet前向推理 loss combined_loss(logits, batch[mask]) # 组合损失 loss.backward() optimizer.step()wavelet_denoise在训练和推理时必须使用完全相同的参数包括小波基、分解层数、阈值方式。推理时采用滑窗预测窗口重叠区域取概率平均再做argmax得到最终分段结果。重叠区域取平均能有效抑制窗口边界处的预测抖动尤其是P波这种小幅值波形在窗口边界经常被误切成背景。滑块步长一般取窗口长度的四分之一到一半步长越小结果越平滑但推理耗时也会线性增加。4. 核心代码拆解Unet-1D、小波去噪与PQT评估的实现细节4.1 Unet的1D改造Conv2d换成Conv1dUnet从图像搬到一维信号核心改动是维度下降。Conv2d换成Conv1dMaxPool2d换成MaxPool1dConvTranspose2d换成ConvTranspose1d注意力机制保持一维形态。下面是一段简化但完整的Unet-1D实现。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv1d(in_ch, out_ch, kernel_size3, padding1), nn.BatchNorm1d(out_ch), nn.ReLU(inplaceTrue), nn.Conv1d(out_ch, out_ch, kernel_size3, padding1), nn.BatchNorm1d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class Unet1D(nn.Module): def __init__(self, in_channels1, num_classes4): super().__init__() # 编码器逐层下采样 self.enc1 DoubleConv(in_channels, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.pool nn.MaxPool1d(kernel_size2) # 瓶颈层 self.bottleneck DoubleConv(256, 512) # 解码器逐层上采样并与跳跃连接融合 self.up3 nn.ConvTranspose1d(512, 256, kernel_size2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose1d(256, 128, kernel_size2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose1d(128, 64, kernel_size2, stride2) self.dec1 DoubleConv(128, 64) # 输出层 self.out nn.Conv1d(64, num_classes, kernel_size1) def forward(self, x): e1 self.enc1(x) # 保留浅层特征 e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) b self.bottleneck(self.pool(e3)) d3 self.dec3(torch.cat([self.up3(b), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)in_channels默认是1对应单导联ECG如果要用12导联输入改成12即可。num_classes默认是4对应背景、P波、QRS波群、T波四类。通道数从64起步逐层翻倍到512的瓶颈层这个容量对ECG分割来说足够再大容易过拟合。跳跃连接是Unet的精华所在torch.cat把编码器特征和解码器特征在通道维度拼接让解码器既能拿到高层的语义信息又能拿到浅层的定位信息。ECG的P波幅度小经过多层下采样后细节几乎丢光跳跃连接相当于给解码器开了一条“捷径”让P波的边界特征不至于在池化过程中彻底消失。这也是Unet在信号分割任务上好用的根本原因。输入长度方面因为只有4次下采样输入长度最好是16的倍数2500个采样点需要补齐到2512或者直接改成2560。4.2 Wavelet预处理模块去噪与特征增强小波去噪用pywt实现核心是分解、阈值收缩、重建三步。db4小波是ECG处理里常用的选择它的波形形态和QRS波群有一定相似性能量集中度好。分解层数建议选4到5层太少去不掉基线漂移太多会把P波这种低频分量也压掉。import pywt import numpy as np def wavelet_denoise(signal, waveletdb4, level5, modesoft): coeffs pywt.wavedec(signal, wavelet, levellevel) # 细节系数做阈值收缩近似系数不做 sigma np.median(np.abs(coeffs[-1])) / 0.6745 threshold sigma * np.sqrt(2 * np.log(len(signal))) new_coeffs [coeffs[0]] # 保留近似分量 for i in range(1, len(coeffs)): new_coeffs.append(pywt.threshold(coeffs[i], threshold, modemode)) return pywt.waverec(new_coeffs, wavelet)wavedec返回的列表第一个元素是近似系数后面依次是从细到粗的细节系数。噪声主要存在于细节系数中所以只对细节分量做阈值收缩。sigma用细节系数绝对值的中位数估计噪声标准差这是小波去噪里稳健的经典做法。threshold采用通用阈值公式实际项目中可以根据验证集表现调整倍率比如乘0.8或1.2不必死守公式。mode参数用soft还是hardsoft处理后波形更平滑hard处理保留的突变更多对QRS波群边界更友好。小波去噪之后的输入是重构信号相比原始信号基线漂移被压住肌电噪声被削弱Unet需要学习的特征更干净。如果还想进一步利用小波的多尺度能力可以把最后一层细节系数重建后作为一个额外通道拼到输入上让网络同时看到原始波形和细节增强波形。def wavelet_enhance(signal, waveletdb4, level4): coeffs pywt.wavedec(signal, wavelet, levellevel) detail pywt.waverec( [np.zeros_like(coeffs[0])] [coeffs[i] for i in range(1, len(coeffs))], wavelet ) # 拼接前裁剪到等长 detail detail[:len(signal)] return np.stack([signal, detail], axis0)这里把小波分解的细节系数重建回时域得到纯细节信号和原始信号拼成双通道输入。等长问题要注意默认的symmetric模式下waverec重建结果会比原信号长一点需要在拼接前裁剪。如果用的是periodization模式则不会出现这个问题这也是避坑章节要展开的点。4.3 评估指标P/QRS/T的分段质量怎么量化训练用交叉熵加Dice的组合损失评估则需要对每一类单独算指标。ECG分割里P波样本占比远低于背景和T波整体Dice会被大类带偏逐类计算才能看出真正的短板。def per_class_dice(pred_mask, true_mask, num_classes4): dice_scores [] for cls in range(1, num_classes): # 背景不算 pred (pred_mask cls) true (true_mask cls) intersection (pred true).sum() denominator pred.sum() true.sum() if denominator 0: dice_scores.append(1.0) # 双方都没有该类别 else: dice_scores.append(2 * intersection / denominator) return dice_scores # [P波Dice, QRS Dice, T波Dice]per_class_dice返回三个值P波Dice、QRS波群Dice、T波Dice。P波Dice低说明漏检或过分割QRS波群Dice低通常是因为边界偏移T波Dice低则往往和T波终点难以界定有关。报告指标的时候要把三类分开展示只报一个总体Dice会掩盖P波效果差的问题。边界误差也是一个重要指标。临床更关心的是每个波的起止位置是否准确而不是逐点重叠率。边界误差定义为预测区间边界和标注区间边界之间的绝对时间差以毫秒为单位。QRS波群边界误差要求在20ms以内P波因为形态平缓误差容忍度可以放宽到30ms。这个指标可以在训练后用一段独立脚本做区间匹配逐波统计。5. 避坑与常见问题排查训练不收敛、小波边界与P波漏检5.1 训练不收敛归一化顺序的踩坑记录现象loss在初始值附近震荡train和val的Dice都上不去。原因对原始ECG信号直接做min-max归一化但ECG信号的幅值在不同导联和不同受试者之间差很大min-max会把大部分采样点压到非常窄的区间有效信号被压缩更隐蔽的问题是训练集和测试集单独归一化导致分布不一致。解决用训练集的均值和标准差做z-score归一化推理时沿用同一套参数不要对推理样本重新计算统计量。另外小波去噪要在归一化之后做否则归一化会把去噪重构的幅值变化重新拉平去噪等于白做。5.2 小波重构后信号长度不齐现象waverec之后信号长度比原始信号多了几十个采样点导致标签和信号对不上训练时报shape不匹配。原因pywt.wavedec默认的modesymmetric是镜像延拓分解后系数长度和原信号不完全一致重构后自然会产生长度偏移。这个问题在信号长度刚好是2的幂次时几乎不可见但2500这种长度就明显了。解决用modeperiodization它保证分解和重构后的长度完全等于原信号长度适用于任何长度。需要注意的是wavedec里设定mode后waverec里不需要再传因为系数已经包含了延拓信息。如果必须用symmetric就在重构后手动裁剪到原长度但裁剪位置涉及边界相位偏移不推荐。5.3 P波频繁漏检与过分割现象P波Dice远低于QRS波群和T波预测结果里P波片段断断续续有时一段P波被切成两段。原因P波幅度小只有0.1到0.2mV经过下采样后特征被削弱同时P波在整段信号中占比很少类别不平衡让模型倾向于忽略它。Dice损失在样本占比极低时梯度方向不稳定也容易造成边界抖动。解决损失函数改为交叉熵加Dice加Focal Loss的加权组合Focal Loss会把训练重心压到难分类的P波上。后处理再加一道形态学滤波对预测mask做一维开运算去除长度小于某个阈值的片段。开运算能去掉孤立的P波残片也能把断裂的P波重新连上。from scipy.ndimage import binary_opening def postprocess_mask(mask, min_len30): # 对每个类别分别做开运算 processed np.zeros_like(mask) for cls in range(1, 4): binary (mask cls) opened binary_opening(binary, structurenp.ones(min_len)) processed[opened] cls return processedmin_len按采样点算250Hz下30个点就是120ms小于这个长度的片段大概率是噪声引起的误检。这个参数可以在验证集上扫一遍选P波Dice最高的值。开运算的副作用是会把一些本来就很短的T波片段也抹掉所以min_len需要按类别分别设置不能一刀切。5.4 JupyterNotebook里训练久了内存持续膨胀现象在JupyterNotebook里跑训练循环前十几轮正常越往后越卡最后直接卡死GPU显存没满但系统内存爆掉。原因notebook的kernel会保留所有cell的输出对象matplotlib每次绘图不close()会把Figure对象挂在内存里另外DataLoader的num_workers设置不合理子进程缓存没有被回收。解决绘图后用plt.close(all)主动释放句柄训练循环的loss记录不要每个step都存成list只存batch级别的统计量DataLoader的num_workers设置在Linux上可以用4但每轮epoch结束后调用torch.cuda.empty_cache()。注意empty_cache()不是万能的它只是释放缓存的显存块真正的问题往往是Python内存泄漏需要用tracemalloc定位泄漏的cell。从那以后我做信号训练都习惯把训练循环单独抽成.py脚本notebook只保留调用和可视化内存问题基本绝迹。6. 二次开发从单导联到多导联、从离线到实时的改造路径这个项目最值得改的第一个点把单导联输入扩展成多导联。Unet的in_channels是1直接改成12输入变成12通道每通道对应一个导联。解码器输出不变但模型参数量略增训练数据需要12导联标注。做这个改造时要注意各导联的采样率必须一致如果原始数据里某导联掉线或者噪声过大不能直接补零建议做导联丢弃策略缺失导联用其他导联的加权组合填补。第二个改造方向从离线批处理变成流式推理。心电图监护场景下数据是实时到达的不能等窗口满了再推理。常见做法是维护一个固定长度的环形缓冲区每来一个采样点就追加缓冲区满后走一次推理只保留窗口末尾若干秒的预测结果下一次推理重叠区域做投票融合。这个改法对模型延迟有要求可以考虑把Unet的卷积替换成深度可分离卷积模型大小能压到原来的三分之一推理速度提升明显。第三个方向跨数据集泛化。ECG分割模型最怕换了一个采集设备就崩。验证方法是在训练集上做交叉验证留出某台设备或某批受试者不参与训练看P波Dice下降幅度。如果下降明显基本可以判断是数据分布差异造成的这时要做的是在预处理阶段增加随机幅度扰动和基线漂移模拟让模型学到不敏感的特征。验证一个改造是否成功建议只用一套固定测试集和三个指标逐类Dice、边界误差、推理延迟。Dice看的是分割质量边界误差看的是临床可用性推理延迟看的是能否上实时系统。三张表放在一起对比改造前后数值说话防止“感觉变好了”。我从这个项目里学到的最重要一课是信号分割项目的难点从来不在模型结构而在数据管线和评估口径。从那以后我每次做信号分割都先把数据预处理脚本和评估脚本写完、跑通、复现基线然后才开始调模型这个顺序帮我省掉了大量回头改数据的返工时间。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑