
简介这份资源面向希望将机器学习落地到医疗健康场景的开发者与学习者聚焦连续血压估计这一典型时间序列回归问题。包内以Python实现为主围绕数据预处理、特征工程、模型训练与评估等环节展开适合具备一定Python与机器学习基础、想了解无创连续血压监测思路的读者参考。压缩包共2个文件均为py脚本整体约2KB体量轻便便于快速阅读与二次修改。内容涉及主流程脚本与特征处理脚本可帮助读者理解如何组织血压数据的清洗、标准化与特征提取并据此搭建回归或时序预测模型。目前已有116人学习下载可作为入门连续血压估计、梳理项目结构的小型实践素材。1. 从 PPG 到血压为什么连续估计值得用机器学习啃下来连续血压估计这件事真正做过的人都知道难点不在“能不能算出一个数”而在“能不能在不用袖带的前提下把每搏的收缩压和舒张压稳定地追出来”。传统示波法只能给单点值动态血压计戴着睡觉都能把人吵醒而基于光电容积脉搏波PPG的方案只要一颗 LED 加一个光电二极管就能采到信号成本低、可穿戴、能连续。问题在于PPG 波形和血压之间没有解析公式脉搏波传导时间、波形形态、外周阻力这些因素耦合在一起线性回归根本兜不住。机器学习在这里的价值是把“波形形态 → 血压”这个非线性映射用数据驱动的方式拟合出来让连续估计在精度和鲁棒性上同时往前走一步。这篇面向的是想用公开数据集或自采 PPG 做连续血压估计的工程师和研究生从信号预处理、特征工程、模型选型一路讲到部署时的坑目标是让你能照着搭出一套可复现的基线而不是停在“调包跑了个 demo”。2. 数据与信号连续血压估计的原料从哪来2.1 公开数据集怎么选PPG 与 ABP 怎么对齐连续血压估计的监督学习标签必须是逐拍的动脉血压波形ABP而不是袖带打出来的单点值。常见做法是用 MIMIC、VitalDB 这类带同步 PPG 和 ABP 的 ICU 数据集或者用 UC Irvine 的 Cuff-Less Blood Pressure Estimation 数据集。选数据集的第一个硬指标是采样率PPG 至少 125 HzABP 至少 125 Hz低于这个数波形细节就丢了收缩压上升沿的斜率根本算不准。第二个指标是同步质量PPG 和 ABP 必须来自同一台监护仪、同一时间戳否则你算出来的脉搏波传导时间全是噪声。对齐这件事我一般分三步走。第一步按时间戳做粗对齐把两个通道截到公共时间段第二步用互相关找 PPG 波峰和 ABP 波峰之间的固定延迟这个延迟通常在 100 到 250 毫秒之间取决于传感器位置第三步逐拍配对以 ABP 的收缩压峰值为锚点往前找最近的 PPG 波峰配对成功后记录这一拍的收缩压、舒张压和平均压。下面这段代码是配对逻辑的核心用的是scipy.signal.find_peaksimport numpy as np from scipy.signal import find_peaks def pair_ppg_abp(ppg, abp, fs125, search_window0.4): ppg, abp: 同长度、已粗对齐的一维数组 fs: 采样率 search_window: 在 ABP 峰前多远范围内找 PPG 峰单位秒 # 找 ABP 收缩压峰 prominence 按实际幅值调 abp_peaks, _ find_peaks(abp, distanceint(0.3*fs), prominencenp.std(abp)*0.5) ppg_peaks, _ find_peaks(ppg, distanceint(0.3*fs), prominencenp.std(ppg)*0.3) pairs [] win int(search_window * fs) for ap in abp_peaks: # 在 ABP 峰前 win 个采样点内找最近的 PPG 峰 candidates ppg_peaks[(ppg_peaks ap) (ppg_peaks ap - win)] if len(candidates) 0: continue pp candidates[-1] # 取最近的 # 记录PPG 峰位置、ABP 峰位置、该拍收缩压 pairs.append((pp, ap, abp[ap])) return np.array(pairs)逻辑说明find_peaks的distance参数防止把重搏波切迹误判成峰prominence用信号标准差的比例来设比固定阈值更抗幅度漂移。search_window设 0.4 秒是经验值覆盖了从主动脉到外周脉搏的传导延迟范围。配对后你会得到一个(PPG峰位置, ABP峰位置, 收缩压)的数组舒张压可以用 ABP 的下一个谷值来取平均压用波形积分算。参数上唯一需要按数据集调的是prominence系数ICU 数据噪声大就调高干净数据可以降到 0.2。2.2 逐拍特征工程从波形里抠出 12 个可解释量配对完成后每一拍要变成特征向量。我一般固定抽三类特征时间域、形态域、频域。时间域包括脉搏波传导时间ABP 峰位置减 PPG 峰位置除以采样率、心率60 除以 RR 间期、PPG 上升沿时间波峰到波谷的下降时间。形态域包括 PPG 波峰幅值、波谷幅值、上升沿斜率最大值、下降沿斜率最小值、重搏波切迹的相对高度。频域做 0.5 到 5 Hz 的功率谱取低频功率和高频功率的比值。加起来 12 个特征足够撑起一个基线模型又不会因为维度太高在小数据集上过拟合。这里有个血泪经验PPG 的幅值受接触压力和皮肤灌注影响极大绝对幅值不能直接用必须做逐拍归一化。我一般把每一拍的 PPG 段减去波谷、再除以波峰减波谷得到 0 到 1 的归一化波形然后再算斜率和切迹高度。下面这个函数把单拍特征一次算完def extract_beat_features(ppg_seg, abp_seg, fs125): ppg_seg: 单拍 PPG 段从波谷到下一个波谷 abp_seg: 对应的 ABP 段 feats {} # 归一化 PPG ppg_norm (ppg_seg - ppg_seg.min()) / (ppg_seg.max() - ppg_seg.min() 1e-8) # 时间域 feats[hr] 60.0 * fs / len(ppg_seg) feats[rise_time] np.argmax(ppg_norm) / fs feats[fall_time] (len(ppg_norm) - np.argmax(ppg_norm)) / fs # 形态域 feats[max_slope] np.max(np.diff(ppg_norm)) * fs feats[min_slope] np.min(np.diff(ppg_norm)) * fs # 重搏波归一化波形二阶导的局部极大 d2 np.diff(ppg_norm, n2) feats[dicrotic_ratio] np.max(d2[len(d2)//2:]) if len(d2) 4 else 0.0 # 频域 f, pxx welch(ppg_norm, fsfs, npersegmin(64, len(ppg_norm))) lf np.trapz(pxx[(f0.5)(f1.5)], f[(f0.5)(f1.5)]) hf np.trapz(pxx[(f1.5)(f5.0)], f[(f1.5)(f5.0)]) feats[lf_hf_ratio] lf / (hf 1e-8) return feats参数说明nperseg取 64 是因为单拍长度通常不到 1 秒125 Hz 下约 100 个点64 点的 Welch 谱分辨率够用。lf_hf_ratio这个特征在血压估计里争议不小有人觉得它反映自主神经调节有人觉得是伪相关我的做法是先放进去后面用特征重要性筛。注意dicrotic_ratio只在波形质量好的时候有意义如果 PPG 信噪比低这个特征会变成纯噪声需要在预处理阶段用信号质量指数SQI把差拍剔掉。3. 模型选型从随机森林到一维卷积哪个更扛造3.1 树模型做基线为什么 XGBoost 仍然是第一选择拿到特征矩阵之后第一个该跑的模型不是 LSTM也不是 Transformer而是梯度提升树。原因很实际连续血压估计的公开数据集体量通常不大几千到几万拍树模型在小样本上的稳定性远好于深度网络而且特征重要性直接告诉你哪些特征在干活。XGBoost 或 LightGBM 都行我一般用 LightGBM训练快对缺失值友好。输入是 12 维特征输出是收缩压和舒张压两个回归头或者训练两个独立模型。关键参数上num_leaves控制在 31 到 63 之间再大就过拟合learning_rate设 0.05配合n_estimators500 到 1000用早停min_child_samples设 20 到 50防止叶子节点记住单拍噪声。下面是一个最小可跑的 LightGBM 训练脚本import lightgbm as lgb from sklearn.model_selection import train_test_split # X: (n_beats, 12), y_sbp: (n_beats,), y_dbp: (n_beats,) X_tr, X_te, y_tr, y_te train_test_split(X, y_sbp, test_size0.2, random_state42) params { objective: regression_l2, num_leaves: 31, learning_rate: 0.05, min_child_samples: 30, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, verbose: -1 } dtrain lgb.Dataset(X_tr, labely_tr) dval lgb.Dataset(X_te, labely_te, referencedtrain) model lgb.train( params, dtrain, num_boost_round1000, valid_sets[dval], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] )逻辑说明feature_fraction和bagging_fraction都设 0.8 是为了在特征相关性强的时候降低方差血压特征里心率和上升时间相关性不低全用容易过拟合。early_stopping(50)是后悔药防止验证集误差反弹后还在跑。评估指标别只看 MSE要看 MAE 和标准差因为血压估计的临床可接受误差是 ±5 mmHg 以内MSE 会被大误差主导。跑完看特征重要性如果lf_hf_ratio排在前三要警惕可能是数据集里存在和设备相关的伪影。3.2 一维 CNN 端到端什么时候值得上深度学习树模型的天花板在于特征是你手工定的波形里那些说不清的形态变化它抓不到。如果你有 10 万拍以上的数据或者能做数据增强一维 CNN 端到端是值得试的。结构不用复杂三层卷积每层 kernel size 7 到 11通道数 32、64、128每层后面接 BatchNorm 和 ReLU最后全局平均池化接全连接输出两个值。输入是原始 PPG 段长度统一到 1 秒125 个点不做手工特征。我一般会加一个辅助任务让网络同时预测这一拍的收缩压、舒张压和平均压三个头共享卷积层。这样梯度信号更丰富收敛更稳。损失函数用 Huber loss对异常拍鲁棒。训练时学习率 1e-3Adambatch size 64数据量小的时候用 5 折交叉验证别只切一次训练测试集。下面是一维 CNN 的核心结构import torch import torch.nn as nn class BPNet(nn.Module): def __init__(self): super().__init__() self.conv nn.Sequential( nn.Conv1d(1, 32, 11, padding5), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, 9, padding4), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 7, padding3), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.head nn.Linear(128, 3) # SBP, DBP, MAP def forward(self, x): # x: (B, 1, 125) z self.conv(x).squeeze(-1) return self.head(z)参数说明padding都设成 kernel 的一半保证卷积后长度不变省去手动算尺寸。AdaptiveAvgPool1d(1)把时间维压掉对输入长度的小波动不敏感。三个输出头没有加激活函数因为血压是连续值线性输出最直接。训练时如果发现 DBP 的误差比 SBP 大很多这是正常的舒张压的波形特征更弱可以考虑给 DBP 的损失加权比如权重 1.5。4. 避坑与排查连续血压估计里最容易翻车的五件事4.1 现象模型在测试集上 MAE 只有 3 mmHg换一个人就崩到 15 mmHg原因数据泄漏。最常见的是同一受试者的数据同时出现在训练集和测试集里模型记住了这个人的波形形态而不是学到了血压和波形的通用关系。另一个泄漏源是特征计算时用了全局统计量比如用整个记录的最大值做归一化测试时又用测试集的最大值信息就串了。解决按受试者划分数据集同一个人要么全在训练集要么全在测试集。归一化只用训练集的统计量测试时套用训练集的均值和方差。如果受试者数量太少做留一受试者交叉验证LOSO报告跨受试者的 MAE这个数才是真实泛化能力。4.2 现象训练 loss 一直降验证 loss 从第 10 轮开始涨原因过拟合而且往往是因为特征里混了噪声特征。12 个特征里如果有三四个是纯噪声树模型会拼命去拟合这些噪声在训练集上的随机模式。深度网络更严重参数量远大于样本量。解决先做特征筛选用 LightGBM 的特征重要性或者互信息把重要性低于阈值的特征砍掉通常 12 个能砍到 7 到 8 个。然后加正则树模型加lambda_l2CNN 加 Dropout0.3 到 0.5和权重衰减1e-4。如果还不行就是数据量不够考虑做数据增强比如对 PPG 段做时间轴轻微拉伸、加高斯噪声、幅值随机缩放。4.3 现象收缩压估得还行舒张压误差大得离谱原因舒张压对应的波形特征主要在 PPG 的下降沿和重搏波区域这部分信号幅值小、受呼吸调制明显信噪比低。而且舒张压在 ABP 波形上取的是谷值谷值位置对噪声敏感标签本身就有抖动。解决第一检查 ABP 谷值检测是否稳定用低通滤波截止 10 Hz平滑后再找谷值。第二给舒张压单独做特征重点抽下降沿时间和重搏波切迹的深度、位置。第三损失函数里给 DBP 加权或者在网络里让 DBP 头接更多的全连接层。如果 DBP 的 MAE 始终比 SBP 高 3 mmHg 以上先别怀疑模型回去查标签质量。4.4 现象换一个数据集模型完全不能用原因域偏移。不同数据集的 PPG 传感器位置指尖、手腕、耳垂、采样率、滤波截止频率都不一样波形形态差异很大。在一个数据集上训的模型换到另一个数据集特征分布整个偏了。解决做域适应最简单的是在目标数据集上做微调只用少量标注拍。或者做特征标准化把每个数据集的 PPG 幅值、心率范围对齐到同一尺度。更彻底的做法是只保留对传感器位置不敏感的特征比如脉搏波传导时间、上升沿时间占比把绝对幅值相关的特征全去掉。4.5 现象推理时单拍延迟超过 100 毫秒连续估计变成断续估计原因特征计算用了整段信号比如 Welch 谱需要累积多拍才能算或者模型输入要求固定长度但实际拍长在变做了大量 padding 和截断。解决把特征计算改成流式每来一拍就算这一拍的特征频域特征用滑动窗口窗口长度固定 8 秒每拍更新一次。模型推理用 ONNX Runtime 或 TensorRT 加速树模型转成 ONNX 后单拍推理能压到 1 毫秒以内。如果必须用 CNN把输入长度固定到 125 点用环形缓冲区攒够一拍就推一次别等整段。5. 把基线推到可用的最后几步校准、验证与部署习惯走到这里你手里应该有一个在留一受试者交叉验证下 MAE 在 5 到 8 mmHg 的基线模型。但离“能用”还差一步个体校准。连续血压估计的绝对精度受个体差异影响极大同一个模型A 的 MAE 是 4B 可能是 12。我一般会在部署时加一个轻量校准层用受试者前 30 秒的袖带读数作为锚点算一个线性偏移量后续每拍的估计值都加上这个偏移。这个操作能把跨受试者的 MAE 压下去 2 到 3 mmHg代价是每换一个人要打一次袖带。校准的实现很简单但要注意别把校准数据又泄漏进训练。校准只用推理时的前几拍不参与模型更新。下面是一个校准层的示例class Calibrator: def __init__(self, window30): self.window window self.offset_sbp 0.0 self.offset_dbp 0.0 self.buffer_sbp [] self.buffer_dbp [] def update(self, pred_sbp, pred_dbp, ref_sbpNone, ref_dbpNone): if ref_sbp is not None: # 有袖带参考时直接算偏移 self.offset_sbp ref_sbp - pred_sbp self.offset_dbp ref_dbp - pred_dbp return pred_sbp self.offset_sbp, pred_dbp self.offset_dbp参数说明window是校准窗口30 秒对应大约 30 到 40 拍足够算一个稳定的偏移。ref_sbp和ref_dbp来自袖带只在初始化时给一次。之后每拍调用update时不传参考值直接返回校准后的估计。这个类的逻辑是“一次校准全程偏移”简单但有效。如果你想要更精细的校准可以用前 30 秒的数据拟合一个线性回归把估计值映射到参考值但要注意别过拟合这 30 秒的噪声。验证方法上别只看 MAE。我习惯画三张图Bland-Altman 图看偏差和一致性界限散点图看估计值和参考值的相关性时序图看跟踪趋势。Bland-Altman 的界限如果在 ±10 mmHg 以内说明模型有临床参考价值如果界限宽到 ±20 mmHg回去查特征和标签对齐。时序图重点看血压突变的时候模型跟不跟得上比如从坐姿到站姿的瞬间PPG 形态会变模型如果滞后超过 10 秒说明特征窗口太长。最后说一个我自己的习惯每次跑完实验把当次的配置文件、随机种子、特征列表、模型超参全部存成一个 JSON和模型文件放一起。连续血压估计这个方向复现性是个大坑同一个脚本换个随机种子 MAE 能差 1.5 mmHg。不存配置两周后你自己都复现不出来。这个习惯帮我省了无数次返工希望帮到你。本文还有配套的精品资源点击获取