资讯动态

基于UNet与小波变换的心电P/QRS/T波形分割实战

发布时间:2026/10/30 5:06:29 来源:尧图企业网站定制
简介面向心电信号分割与深度学习实践者的Unet-ECG-Segmentation-Wavelet代码包围绕QTDB数据集将ECG转换至小波域并用U-Net识别P、QRS、T分量适合具备PyTorch基础、希望复现心电波形分割流程的学习者。包内共27个文件以Python脚本和Notebook为主辅以Markdown说明、许可证及编译缓存压缩包约197KB其中Notebook承担小波特征提取实验入口脚本覆盖数据加载、U-Net网络定义、二维架构、训练与测试等环节并保留小波变换相关备份模块便于按模块阅读和改造。已有785人学习说明该方向具有一定关注度。读者可据此梳理从QTDB数据获取、小波域特征构造、P/QRS/T标签映射到模型训练评估的完整链路也能参考作者对标注方式与数据集适配问题的处理思路并留意标签映射与数据集差异可能带来的误差来源用于课程实验、课题预研或心电分割原型的快速搭建。1. 拿到这份 UNet 心电分割 Notebook 时我先跑通了三分类很多心电算法卡在同一个地方R 峰好找P 波和 T 波的起止点总在漂。这份 UNet-ECG-Segmentation-Wavelet 的 Jupyter Notebook 就是冲着这件事来的——用 UNet 做逐采样点语义分割把一段心电切成 P、QRS、T 和背景四类再叠一层小波变换做去噪和多尺度特征让 P/T 边界不再靠经验阈值硬扛。它是 Notebook 形态从头跑下来能看到数据装载、掩码构造、模型搭建、训练、逐类评估的完整链路适合已经能稳定检 R 峰、想再往前拿波形分段结果的人也适合拿它当基线去改损失、换主干做对比实验。下面按我实际跑的顺序拆先讲掩码怎么造再讲网络怎么搭最后讲哪里最容易翻车。2. 数据到掩码把一段心电变成 P/QRS/T 三类标签分割任务里最容易被低估的一步不是模型是标签。很多人拿到这份 Notebook 直接改路径就开跑结果训练集上 loss 一路下降、验证集上 P 波 IoU 是 0回头查半天才发现掩码里根本没有 P 类像素。所以先把这个环节钉死。2.1 三分类标签怎么定从波形到 mask 的映射先说标签体系。心电分割常见的做法是「背景 P QRS T」四类标签 id 依次是 0/1/2/3。不要图省事做成「波形 vs 背景」二分类因为二分类学出来的东西没法告诉你哪一段是 P、哪一段是 T落不到下游的 PR 间期、QT 间期计算上。四类的代价是背景像素占比会到 90% 以上后面损失函数要专门处理但这是必要成本。如果你依赖的数据库只给了 R 峰位置很多公开心电记录就只标这个掩码得按生理窗口反推QRS 以 R 峰为中心左右对称取P 波取 R 峰之前的一段回看窗口T 波取 R 峰之后的一段前看窗口。有 onset/offset 标注的记录当然优先用真实标注没有才走窗口反推两者的边界误差不是一个量级。import numpy as np # 全流程唯一的标签定义训练、评估、可视化都必须引用这一处 LABEL_BG, LABEL_P, LABEL_QRS, LABEL_T 0, 1, 2, 3 def build_mask(n_samples, beats, fs360, qrs_half_ms90, p_win_ms(220, 120), t_win_ms(120, 320)): beats: 每个心搏一个 dict至少包含 rR 峰采样点下标 若有 p_on,p_off,t_on,t_off优先使用真实标注 mask np.full(n_samples, LABEL_BG, dtypenp.uint8) qrs_half int(qrs_half_ms * fs / 1000) for b in beats: r int(b[r]) # QRS以 R 峰为中心左右对称截取 mask[max(0, r - qrs_half): min(n_samples, r qrs_half 1)] LABEL_QRS if p_on in b and p_off in b: mask[max(0, int(b[p_on])): min(n_samples, int(b[p_off]))] LABEL_P else: # 无标注时回推R 峰前 220ms 到 R 峰前 120ms p_s r - int(p_win_ms[0] * fs / 1000) p_e r - int(p_win_ms[1] * fs / 1000) mask[max(0, p_s): min(n_samples, p_e)] LABEL_P if t_on in b and t_off in b: mask[max(0, int(b[t_on])): min(n_samples, int(b[t_off]))] LABEL_T else: t_s r int(t_win_ms[0] * fs / 1000) t_e r int(t_win_ms[1] * fs / 1000) mask[max(0, t_s): min(n_samples, t_e)] LABEL_T return mask这段代码做的是「按心搏逐个写入标签」顺序很重要先写 QRS再写 P 和 T——如果反过来P 波窗口在手快的人身上会压到 QRS 起始部把 QRS 的开头几个采样点覆盖成 P模型的边界判断就彻底乱了。写入时用max/min夹住边界避免记录头尾的心搏把下标写越界。参数上fs必须和你的记录采样率一致360Hz 和 500Hz 下同一套毫秒参数换算出来的采样点数量完全不同这是最常见的错位来源。qrs_half_ms默认 90ms 是个偏宽的保守值窄 QRS 会多标一点背景但比标窄了把 QRS 尾部漏掉好——漏标的尾部样本会让模型学到「QRS 可以提前结束」。p_win_ms和t_win_ms都是 (起, 止) 的毫秒对P 波窗口回看 220ms 覆盖了绝大多数正常 PR 间期遇到一度房室传导阻滞的长 PR 要往上放宽到 300ms。提示造完掩码一定做一次逐类像素统计P 类占比低于 1% 的记录要单独拎出来看大概率是窗口参数和该记录的节律不匹配。2.2 小波去噪与 R 峰对齐预处理链路怎么串这份 Notebook 名字里的 Wavelet 不是装饰。心电的噪声分两类基线漂移0.5Hz 以下的慢变和肌电/工频干扰高频。前者会让不同记录的绝对幅值差出好几倍后者会把小波的陡沿磨平。常见做法是先小波分解把最低频的近似分量丢掉去基线再对各层细节分量做软阈值去高频最后重构。import numpy as np import pywt def wavelet_clean(sig, fs360, waveletdb6, level5, thr_scale0.8): 小波分解 - 近似分量置零去基线 - 细节分量软阈值去高频 - 重构 coeffs pywt.wavedec(sig, waveletwavelet, levellevel, modesymmetric) # coeffs[0] 是最低频近似分量置零等价于高通去掉基线漂移 coeffs[0] np.zeros_like(coeffs[0]) # 用最细尺度细节分量的中位数估计噪声标准差MAD 估计 sigma np.median(np.abs(coeffs[-1])) / 0.6745 for i in range(1, len(coeffs)): thr thr_scale * sigma * np.sqrt(2 * np.log(len(sig))) coeffs[i] pywt.threshold(coeffs[i], thr, modesoft) return pywt.waverec(coeffs, waveletwavelet, modesymmetric)[:len(sig)]逻辑上分三段分解、逐层处理、重构。近似分量直接清零是最粗暴也最有效的去基线方式代价是信号整体均值被拉回 0幅值信息丢失——这对分割任务无所谓因为我们要的是形状和位置不是毫伏值。参数选择有几个讲究。waveletdb6的消失矩是 6对 QRS 这种陡峭上升沿的保持比 db2/db4 好代价是支撑长度变长、边界效应更明显所以mode用symmetric而不是默认的零填充。level在 360Hz 采样下取 4~5 够用再高就是给噪声做分解了。thr_scale是阈值缩放系数0.8 比标准的 1.0 温和一些——心电的 QRS 能量本来就集中在少数几个大系数上阈值压太狠会把 R 峰的尖顶削掉边界整体往内缩。去噪之后再做 R 峰检测用一阶差分平方做能量增强让 QRS 的斜率优势放大、T 波被压下去import numpy as np from scipy.signal import find_peaks def detect_r_peaks(sig_clean, fs360, min_rr_ms250): # 一阶差分平方QRS 的陡沿能量远高于 P/TT 波不会被误判成 R d np.diff(sig_clean, prependsig_clean[0]) energy d ** 2 win max(1, int(0.10 * fs)) energy np.convolve(energy, np.ones(win) / win, modesame) peaks, _ find_peaks( energy, distanceint(min_rr_ms * fs / 1000), # 最短 RR 间期防重复检 heightnp.percentile(energy, 90), # 只取能量前 10% 的峰 ) return peaksmin_rr_ms250对应 240bpm正常人不可能再快这个下限用来压重复检测。height用 90 分位数做自适应门限比固定阈值更能适应不同记录的幅值差异但如果一段记录里有大量低幅 QRS比如某些导联可以降到 85 分位。2.3 滑窗切分与 Dataset窗口长度、重叠和归一化整段心电动辄几分钟、几十万个采样点不可能一次喂进网络。标准做法是滑窗切段训练时窗口之间留 50% 重叠增加样本量推理时再用重叠拼接消除接缝。import numpy as np import torch from torch.utils.data import Dataset class ECGSegDataset(Dataset): def __init__(self, records, fs360, win_sec4.0, stride_sec2.0): self.fs fs self.win int(win_sec * fs) self.stride int(stride_sec * fs) self.records records self.index [] # (记录下标, 窗起点) for rid, rec in enumerate(records): n len(rec[signal]) for s in range(0, max(1, n - self.win 1), self.stride): self.index.append((rid, s)) def __len__(self): return len(self.index) def __getitem__(self, i): rid, s self.index[i] sig self.records[rid][signal][s:s self.win].astype(np.float32) mask self.records[rid][mask][s:s self.win].astype(np.int64) # 按窗 z-score不同记录基线差异极大全局归一化会把模型带偏 sig (sig - sig.mean()) / (sig.std() 1e-8) if len(sig) self.win: # 尾部不足补零掩码补背景 pad self.win - len(sig) sig np.pad(sig, (0, pad), modeconstant) mask np.pad(mask, (0, pad), modeconstant, constant_values0) # 增加通道维(1, N)与 1D UNet 的输入约定一致 return torch.from_numpy(sig[None, :]), torch.from_numpy(mask)窗口长度 4 秒在 360Hz 下是 1440 个采样点正常心率下能装下 3~5 个完整心搏足够让模型看到 P-QRS-T 的相对位置关系。窗口再短1 秒会频繁切断 T 波模型学到的 T 类样本大量是残缺的再长10 秒则类间不平衡进一步加剧P 波占比被稀释到千分之几。按窗 z-score 而不是全局归一化是我踩过坑之后改的同一批记录里有的基线漂在 2mV有的在 -1mV全局标准化之后模型把「绝对电平」当成了判别特征换一台设备采集的数据直接崩。按窗归一把这个信息彻底抹掉只保留波形形状。DataLoader 侧我一般这么配from torch.utils.data import DataLoader train_dl DataLoader( ECGSegDataset(train_records), batch_size32, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastFalse, )batch_size32在 8GB 显存的卡上跑 base16 的 1D UNet 绰绰有余显存紧张就降到 16 并把学习率同步减半。num_workers取 CPU 核数的一半左右设太大反而因为进程切换拖慢 IO。3. UNet 主干与输入表示一维卷积还是时频双通道3.1 一维 UNet 的结构取舍心电是一维时间序列主干就该用 1D 卷积。有人喜欢先做 CWT 转成时频图再喂 2D UNet视觉上很漂亮但时间分辨率会被时频窗宽度吃掉——一个 30ms 的 P 波起始点在时频图上是一片模糊的能量团你没法从里面精确抠出采样点级的边界。分割任务的输出是逐采样点标签时间分辨率就是命根子。1D UNet 的结构和 2D 版本同构编码器逐层卷积 池化降采样解码器逐层转置卷积上采样 跳跃连接拼接。区别只在卷积核从方形变成一维池化只沿时间轴。import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, cin, cout, k7): super().__init__() self.net nn.Sequential( nn.Conv1d(cin, cout, k, paddingk // 2, biasFalse), nn.BatchNorm1d(cout), nn.ReLU(inplaceTrue), nn.Conv1d(cout, cout, k, paddingk // 2, biasFalse), nn.BatchNorm1d(cout), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.net(x) class UNet1D(nn.Module): def __init__(self, in_ch1, n_cls4, base16): super().__init__() chs [base, base * 2, base * 4, base * 8] # 16/32/64/128 self.enc nn.ModuleList() c in_ch for ch in chs: self.enc.append(ConvBlock(c, ch)) c ch self.pool nn.MaxPool1d(2) self.up nn.ModuleList( [nn.ConvTranspose1d(chs[i], chs[i - 1], 2, 2) for i in range(3, 0, -1)] ) self.dec nn.ModuleList( [ConvBlock(chs[i - 1] * 2, chs[i - 1]) for i in range(3, 0, -1)] ) self.head nn.Conv1d(base, n_cls, 1) def forward(self, x): skips [] for i, blk in enumerate(self.enc): x blk(x) if i len(self.enc) - 1: skips.append(x) x self.pool(x) for up, blk, sk in zip(self.up, self.dec, reversed(skips)): x up(x) x torch.cat([x, sk], dim1) x blk(x) return self.head(x)几个关键选择值得说。卷积核取 7 而不是 2D UNet 里常见的 3是因为 360Hz 下 7 个采样点才 19ms而 P 波的典型宽度是 80~110ms——核太小学不到一个完整波形只能看到局部斜率。base16 是显存和效果的平衡点心电分割的数据量通常不大几万到几十万窗通道数再翻倍很容易过拟合。编码器只做 3 次池化总降采样倍数 8对应 360Hz 下 45ms 的感受野粒度再往下池化 P 波就只剩两三个特征点了。最后那层nn.Conv1d(base, n_cls, 1)是 1×1 卷积做逐点分类输出的(B, 4, N)和输入的(B, 1, N)在时间维严格对齐不加任何上采样倍数误差——这一点在 2D 版本里经常因为尺寸不整除出问题。3.2 把 Wavelet 落到实处多尺度通道怎么拼小波在这份 Notebook 里有两个用法一个是 2.2 讲的去噪另一个是把离散小波变换的多尺度细节系数当作额外输入通道。后者的直觉是不同尺度的小波系数分别对应不同宽度的波形结构粗尺度抓 T 波的整体隆起细尺度抓 QRS 的陡沿把这些系数拼成多通道喂给网络等于把「多尺度视野」直接塞进了第一层。import numpy as np import pywt def wavelet_channels(sig, level3, waveletdb4): 返回 shape(level, len(sig)) 的多尺度细节通道 coeffs pywt.wavedec(sig, waveletwavelet, levellevel, modesymmetric) chans [] for c in coeffs[1:]: # coeffs[0] 是最低频近似丢弃 # 各尺度系数长度不同线性插值回原长后才能按通道拼接 ch np.interp( np.linspace(0, 1, len(sig)), np.linspace(0, 1, len(c)), c, ) chans.append(ch) return np.stack(chans, axis0).astype(np.float32)pywt.wavedec返回的列表是[cA_n, cD_n, ..., cD_1]coeffs[0]是最低频近似就是我们要丢的基线部分coeffs[1]是最粗的细节系数、coeffs[-1]是最细的。level3在 360Hz 下覆盖大约 22~180Hz 的频段范围正好把 QRS 的主要能量和 P/T 的低频成分分到不同尺度上。注意插值回原长这一步会引入一点相位延迟严格做法是用pywt.upcoef做零相位重构。如果插值后训练发现 QRS 边界系统性偏移几个采样点就把这里换成pywt.upcoef(d, coeffs[i], wavelet, level..., takelen(sig))。接进模型时把 Dataset 的返回改成torch.cat([sig[None, :], torch.from_numpy(wavelet_channels(sig))], dim0)同时把UNet1D的in_ch改成1 level。我一般不加这段因为按窗 z-score 之后的小波系数幅值和原始波形不在一个量级得再单独归一化一次容易把训练搞乱但如果你的数据里 P 波特别弱、纯时域模型怎么调都学不出来这招值得试。3.3 损失函数QRS 太容易学P 波样本太少四类分割的像素分布极端不均背景占 90% 以上QRS 占 3%~5%P 和 T 各占 2% 左右。直接上交叉熵模型会迅速学会「全猜背景」这个退化解loss 降到 0.3 就再也不动了——这是新手最常遇到的「训练不收敛」假象其实是收敛到了一个没用的局部最优。我一般用加权交叉熵 分通道 Dice 的组合。加权 CE 负责给稀有类撑腰Dice 负责直接优化重叠度、不受像素数量影响。import torch import torch.nn as nn import torch.nn.functional as F class WeightedCEDice(nn.Module): def __init__(self, cls_weight(0.3, 2.0, 1.0, 1.2), dice_w0.5): super().__init__() # 权重按 (背景, P, QRS, T) 排背景压低P 波抬最高 self.register_buffer(w, torch.tensor(cls_weight).float()) self.dice_w dice_w def forward(self, logits, target): ce F.cross_entropy(logits, target, weightself.w) prob torch.softmax(logits, dim1) onehot F.one_hot(target, logits.shape[1]).permute(0, 3, 1, 2).float() inter (prob * onehot).sum(dim(0, 2)) union prob.sum(dim(0, 2)) onehot.sum(dim(0, 2)) dice 1 - ((2 * inter 1e-6) / (union 1e-6)).mean() return (1 - self.dice_w) * ce self.dice_w * dice权重 (0.3, 2.0, 1.0, 1.2) 是我在一份 12 导联数据上调出来的经验值背景压到 0.3 是因为它太容易学不压的话梯度会被它主导P 波给 2.0 是因为它的样本最少、边界最模糊QRS 给 1.0 保持原样它本来就学得好加权重反而会让模型过度关注 QRS 而忽略 P/TT 波 1.2 是因为它的尾部经常和 U 波混在一起。Dice 那部分按通道分别算再平均而不是把所有通道揉成一个标量——揉在一起的话 QRS 的 Dice 会接近 0.95把 P 波的 0.4 平均掉你就看不到 P 波学得差这件事了。1e-6的平滑项在 P 波这种小目标上是必须的否则空预测会直接除零。dice_w0.5是两项各占一半如果你的数据 P 波特别少可以提到 0.7 让 Dice 主导。4. 训练、验证与指标别让整体准确率骗了你4.1 训练循环与学习率调度训练循环本身没什么特殊几个细节值得抄梯度裁剪、余弦退火、按验证集 mIoU 存最优权重。import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR def train(model, train_dl, val_dl, epochs60, lr1e-3, devicecuda): model.to(device) crit WeightedCEDice().to(device) opt AdamW(model.parameters(), lrlr, weight_decay1e-4) sched CosineAnnealingLR(opt, T_maxepochs, eta_minlr * 0.01) best 0.0 for ep in range(epochs): model.train() for x, y in train_dl: x, y x.to(device), y.to(device) opt.zero_grad() loss crit(model(x), y) loss.backward() # 1D UNet 在个别长窗上偶发梯度爆炸必须裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) opt.step() sched.step() metrics evaluate(model, val_dl, devicedevice) if metrics[mIoU] best: best metrics[mIoU] torch.save(model.state_dict(), unet_ecg_best.pt) return bestclip_grad_norm_的max_norm1.0不是可选项。我在一批含大量室性早搏的记录上遇到过 loss 突然从 0.4 跳到 nan查了两天才定位到是某个窗里连续几个形态异常的 QRS 让某一层的梯度范数到了 80 多加了裁剪之后再没复现过。余弦退火比ReduceLROnPlateau更适合这个场景分割任务的验证指标曲线比较毛躁plateau 策略经常在指标小幅震荡时误判为停滞、提前降 lr后期就卡在一个次优解上。余弦退火按 epoch 平滑下降eta_minlr*0.01保证最后阶段还能有微调空间。weight_decay1e-4对 1D UNet 来说偏保守如果训练集小于 5000 个窗可以提到 1e-3 增强正则。epochs60 是配合 batch32 的经验值数据量翻倍就相应减少。4.2 逐类 IoU / F1QRS 高分不代表模型可用评估环节是最容易自欺欺人的地方。整体像素准确率能到 95% 以上——因为背景占了 90%——但 P 波 IoU 可能只有 0.3。所以指标必须按类拆开算而且要在整个验证集上累积混淆矩阵后一次算不能对 batch 的指标取平均否则小类会被大 batch 稀释。import torch torch.no_grad() def evaluate(model, dl, devicecuda, n_cls4): model.eval() cm torch.zeros(n_cls, n_cls, dtypetorch.long, devicedevice) for x, y in dl: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) # 把 (B,N) 的预测/标签拍平成混淆矩阵下标 idx y.reshape(-1) * n_cls pred.reshape(-1) cm torch.bincount(idx, minlengthn_cls ** 2).view(n_cls, n_cls) cm cm.cpu() iou, f1 {}, {} for c in range(n_cls): tp cm[c, c].item() fp cm[:, c].sum().item() - tp fn cm[c, :].sum().item() - tp iou[c] tp / (tp fp fn 1e-8) f1[c] 2 * tp / (2 * tp fp fn 1e-8) # 背景不参与 mIoU它占 90% 像素算进去会让指标虚高十几个点 miou sum(iou[c] for c in range(1, n_cls)) / (n_cls - 1) return {iou: iou, f1: f1, mIoU: miou}混淆矩阵累积这一步是整个评估的核心。假设某个 batch 里恰好没有 P 波像素滑窗切分时完全可能直接对这个 batch 算 P 类 IoU 会得到 0/0不同的处理方式跳过、置 0、置 1会让最终指标差出好几分。累积成混淆矩阵再算就绕开了这个问题。mIoU 排除背景这一点很重要。我第一次跑的时候没排除mIoU 显示 0.78看着挺好拆开一看背景 0.98、QRS 0.81、T 0.65、P 0.31实际 P 波基本没学会。判断模型能不能用我的经验阈值是QRS IoU 0.85、T 波 IoU 0.65、P 波 IoU 0.55。低于这个线的先别调模型回去查掩码质量和类别权重。4.3 推理与后处理碎片合并与最短段过滤网络输出是逐点的 argmax直接拿去用会出现两类毛病一类是 P 波中间被抠出一两个采样点的窟窿模型在边界附近摇摆另一类是背景里冒出几个孤立的小段假阳性。这两类都能用简单的后处理压下去。import numpy as np from scipy.ndimage import binary_closing, binary_opening def postprocess(mask, fs360, min_msNone, close_ms30): 闭运算补窟窿 - 开运算去毛刺 - 按类最短长度过滤碎片 if min_ms is None: min_ms {1: 40, 2: 60, 3: 40} # P/QRS/T 各自的最短生理宽度 out mask.copy() k_close max(1, int(close_ms * fs / 1000)) for cls, ms in min_ms.items(): m out cls # 闭运算把段内的小窟窿填上避免一个 P 波被切成两段 m binary_closing(m, structurenp.ones(k_close)) # 按连续段长度过滤短于生理下限的视为噪声 edges np.flatnonzero(np.diff(np.r_[0, m.astype(np.int8), 0])) for s, e in zip(edges[::2], edges[1::2]): if (e - s) ms * fs / 1000: m[s:e] False out[m] cls return outmin_ms里的三个数值不是随便填的P 波正常宽度 80~110ms下限给 40ms 是留了一半余量QRS 正常 60~100ms下限 60ms 是因为窄于 60ms 的段几乎一定是误检T 波 120~160ms下限 40ms 是因为 T 波尾部经常被判成背景剩下的一段本来就短。这几个数按 fs 换算成采样点后在 360Hz 下分别是 14/21/14 个点。close_ms30控制闭运算的结构元素长度30ms 大约能补上 10 个采样点以内的窟窿。设太大会把相邻的 P 和 QRS 之间的间隔也填上那就把两个类粘一起了所以这个值不要超过最短的 PR 段长度。提示后处理只改标签、不改模型所以调参时可以先存下原始 argmax 结果离线反复试不同的min_ms不用重跑推理。5. 排查心电分割跑不起来或者跑不准的五个常见问题这一节全是我和同事实际翻过的车按「现象 → 原因 → 解决」写。遇到问题先在这里对号入座比漫无目的地改超参快得多。5.1 数据和标签侧的三个坑坑一训练几个 epoch所有样本的 mask 全是背景。现象是 loss 很快降到 0.3 左右然后一动不动可视化出来模型对任何输入都输出全 0。原因通常有两个一是掩码生成时标签 id 和损失函数的n_cls对不上比如掩码里用的是 0/1/2模型输出 4 类argmax 永远落在 0二是滑窗切分的起点和 R 峰位置错位窗口里确实一个标注点都没有。解决方式是先做一次离线自检随机抽 20 个窗打印每类像素的占比。如果所有窗的 P 类占比都是 0回去查build_mask的输入beats里的r是不是已经换算成了当前记录的采样点下标——这是最高频的错误标注文件里的位置单位是毫秒、采样点、还是相对某个起点的偏移每种数据库都不一样。自检通过之后再跑训练。坑二训练 loss 正常降但验证集上 P 波 IoU 一直是 0。现象很迷惑loss 从 0.9 降到 0.25QRS 和 T 的 IoU 都上来了唯独 P 类是 0混淆矩阵里 P 类那一行全是 0。原因就是 P 波像素占比太低通常 1%即使加了类权重模型也倾向于把所有 P 判成背景——因为判错 1% 的像素对整体 loss 影响很小。解决分两步先把cls_weight里 P 类的权重从 2.0 往上加到 4.0~5.0观察 P 的 recall 是否开始上升如果加了权重还是 0说明梯度信号根本没传回来改用对含 P 的窗做加权采样——统计每个窗的 P 类像素数按像素数的倒数作为采样概率让含大量 P 波的窗被更频繁地抽到。我在一份数据上把这招和权重调整叠加使用P 波 IoU 从 0 直接跳到 0.48。坑三换一条记录跑IoU 掉一半。现象是训练集和验证集来自不同采集设备验证集指标断崖式下跌。原因是不同设备的基线漂移幅度、工频噪声强度、导联位置都不同模型学到了设备相关的伪特征。解决是加固预处理确认按窗 z-score 已经生效不是全局归一化再加一层 0.5~40Hz 的带通滤波把设备间差异最大的频段压平。如果条件允许训练时做幅值随机缩放0.8~1.2 倍和高斯噪声注入让模型对这些变化不敏感。这类跨设备泛化问题没有银弹只能靠数据增强和多样化训练集缓解。5.2 训练和推理侧的两个坑坑四验证集指标比训练集还高但推理出来的结果碎得没法看。现象是训练 loss 一直在降但训练集 IoU 低于验证集同时推理时的分割结果边缘全是锯齿和小碎片。原因是模型里的 BatchNorm 在训练时用 batch 统计量、推理时用滑动平均小 batch尤其是最后几个不满 32 的 batch的统计量噪声很大导致训练和推理的行为不一致叠加滑窗重叠拼接时的硬边界碎片就被放大了。解决有两个方向把batch_size提到 64 以上或者把 BN 换成 GroupNorm1D 场景下本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑