简介:本资源为基于机器学习的心血管疾病风险预测系统设计与实现项目资料,面向医疗信息化学习者、人工智能入门者及希望了解疾病风险识别完整流程的开发者。内容围绕临床数据处理与智能算法融合展开,涵盖数据采集整理、变量提取、逻辑回归与决策树集成建模、特征筛选、多维度评价指标及参数调优等关键环节,并借助可视化图表呈现分析结果,便于理解患者健康趋势。资源包共12个文件,约4.63MB,包含Python脚本与Jupyter Notebook用于模型训练和数据分析,csv数据文件提供建模样本,md与docx文档记录项目说明与提案,另有日志、动态链接库及备份文件辅助运行与调试。目前已有90人学习下载,适合作为医疗人工智能场景的实践参考,帮助读者掌握从数据准备到模型验证的完整流程,为疾病预防与干预提供科学依据。
1. 心血管风险预测系统:从一份体检报告说起
体检报告上那一列血脂、血糖、血压数值,单独看都在参考范围内,合在一起却可能指向一个高风险的心血管事件。传统评分表(比如基于年龄、性别、吸烟、血压的模型)能给出一个粗略分层,但面对合并糖尿病、慢性肾病、家族史的人群,区分度经常不够用。基于机器学习的心血管疾病风险预测系统,要解决的就是把多源异构的临床指标、生活方式问卷、随访记录整合成一个可迭代的风险打分器,输出个体化的概率值,而不是一个粗糙的“低危/中危/高危”标签。
这套系统适合三类人:一是做医疗信息化或临床科研的工程师,需要把模型嵌进现有 HIS 或体检系统;二是数据科学从业者,手里有脱敏队列数据,想验证特征工程和模型选型;三是临床研究者,想用可解释的模型辅助入组筛选。它不替代医生诊断,但能把“谁该优先做冠脉 CTA”这件事量化。下面按数据、特征、模型、部署、避坑的顺序,把一条能复现的路径拆开讲。
2. 数据从哪来、怎么洗:队列字段与标签定义
2.1 公开数据集与院内数据的取舍
做心血管风险预测,第一步不是选模型,而是确定标签和字段口径。常见做法是先用公开数据集跑通流程,比如 UCI 的 Cleveland Heart Disease 数据集,它包含 13 个特征(年龄、性别、胸痛类型、静息血压、胆固醇、空腹血糖、静息心电图、最大心率、运动诱发心绞痛、ST 段压低、坡度、主血管数、thal 缺陷类型),标签是是否患病。这个数据集样本量只有 303 条,适合验证代码,不适合直接上生产。
院内数据通常来自电子病历和体检系统,字段更全但噪声更大。我一般会先拉一份字段清单,按三类整理:人口学(年龄、性别)、临床指标(血压、血脂四项、空腹血糖、糖化血红蛋白、肌酐、eGFR)、行为与病史(吸烟、饮酒、糖尿病史、高血压史、家族史)。标签定义要明确时间窗,比如“未来 10 年是否发生主要不良心血管事件(MACE)”,而不是“当前是否患病”。时间窗不写清楚,模型学到的就是横断面关联,不是风险预测。
提示:如果院内数据没有随访结局,只有一次体检记录,那只能做患病风险分层,不能叫“预测系统”。标签定义决定了整个项目的天花板。
2.2 缺失值、异常值与类别不平衡的处理
临床数据缺失是常态,血脂、糖化血红蛋白的缺失率经常在 20% 以上。直接删行会损失大量样本,直接填均值又会低估方差。我一般按缺失比例分档处理:缺失低于 5% 的字段,用中位数填充并加一个缺失指示列;缺失 5% 到 30% 的字段,用多重插补(MICE)或 KNN 插补;缺失超过 30% 的字段,先考虑是否纳入模型,或者单独做敏感性分析。
异常值不要急着删。血压 300 mmHg 可能是录入错误,也可能是真实危重病例。我的做法是先用临床合理范围做标记,比如收缩压低于 60 或高于 260、舒张压高于 150、胆固醇高于 20 mmol/L,标记为可疑,再人工抽查。确认是录入错误的修正,确认是真实极端值的保留,因为极端值往往对应高风险,删掉反而削弱模型。
类别不平衡在心血管事件预测里很常见,10 年 MACE 发生率可能只有 5% 到 10%。处理方式有三种:调整类别权重(class_weight='balanced')、过采样少数类(SMOTE)、欠采样多数类。我一般先用类别权重,因为它不改变数据分布,解释性更好。如果 AUC 仍然偏低,再试 SMOTE,但要注意 SMOTE 只能在训练集上做,不能对测试集过采样。
import pandas as pd import numpy as np from sklearn.impute import SimpleImputer, KNNImputer from sklearn.model_selection import train_test_split # 读取脱敏后的队列数据 df = pd.read_csv("cvd_cohort.csv") # 定义特征列和标签列 feature_cols = ["age", "sex", "sbp", "dbp", "chol", "hdl", "ldl", "glucose", "hba1c", "smoke", "diabetes", "egfr"] label_col = "mace_10y" # 按缺失比例分档:低于5%用中位数,5%-30%用KNN low_missing = [c for c in feature_cols if df[c].isna().mean() < 0.05] mid_missing = [c for c in feature_cols if 0.05 <= df[c].isna().mean() < 0.30] df[low_missing] = SimpleImputer(strategy="median").fit_transform(df[low_missing]) df[mid_missing] = KNNImputer(n_neighbors=5).fit_transform(df[mid_missing]) # 划分训练集和测试集,stratify保证标签分布一致 X_train, X_test, y_train, y_test = train_test_split( df[feature_cols], df[label_col], test_size=0.2, random_state=42, stratify=df[label_col] )这段代码的关键点有三个:SimpleImputer和KNNImputer都只在训练集上拟合,再变换测试集,避免数据泄漏;stratify保证训练集和测试集的事件率一致;缺失指示列没有在这里加,实际项目中我会对每个有缺失的字段额外生成一个_isna列,让模型能区分“值低”和“没测”。
参数上,KNNImputer的n_neighbors默认是 5,样本量小于 500 时可以降到 3,样本量大时可以升到 10。SimpleImputer的中位数比均值稳健,尤其是血脂这类偏态分布字段。
3. 特征工程:把临床知识塞进模型
3.1 连续变量的分箱与临床阈值
机器学习模型对连续变量的处理方式不同。逻辑回归假设线性关系,树模型可以自动切分,但切分点未必符合临床阈值。我一般会把关键连续变量做临床分箱,比如血压按 120/80、140/90 切,血脂按 LDL-C 1.8、2.6、3.4 mmol/L 切,血糖按 6.1、7.0 切。分箱后的类别变量既能保留临床意义,又能让逻辑回归捕捉非线性。
但分箱会损失信息,所以我的做法是“原始值 + 分箱标签”同时保留。树模型用原始值,逻辑回归用分箱标签,最后做模型融合。这样既不让树模型被分箱限制,也不让逻辑回归漏掉阈值效应。
# 基于临床阈值生成分箱特征 df["sbp_cat"] = pd.cut(df["sbp"], bins=[0, 120, 140, 160, 300], labels=["normal", "elevated", "high", "very_high"]) df["ldl_cat"] = pd.cut(df["ldl"], bins=[0, 1.8, 2.6, 3.4, 20], labels=["optimal", "near_optimal", "borderline", "high"]) df["glucose_cat"] = pd.cut(df["glucose"], bins=[0, 6.1, 7.0, 30], labels=["normal", "prediabetes", "diabetes"]) # 交互特征:年龄与血压的乘积,捕捉年龄放大效应 df["age_sbp"] = df["age"] * df["sbp"] df["age_ldl"] = df["age"] * df["ldl"]pd.cut的bins边界要按临床指南来,不要用等频分箱。等频分箱会把 118 和 122 分到不同箱,但临床上这两个值差别不大。age_sbp这类交互特征在心血管模型里很常见,因为年龄对血压风险的放大效应不是线性的。
3.2 用 SHAP 做特征筛选与可解释性
特征不是越多越好。我见过有人把 200 多个字段全塞进去,AUC 只涨了 0.01,但模型变得无法解释。我的做法是先用 SHAP 值看每个特征的贡献,再结合临床知识做筛选。SHAP 的好处是能给出每个样本每个特征的贡献方向,不只是全局重要性。
import shap from sklearn.ensemble import GradientBoostingClassifier model = GradientBoostingClassifier(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=42) model.fit(X_train, y_train) explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test) # 全局重要性:按平均绝对SHAP值排序 shap.summary_plot(shap_values, X_test, plot_type="bar") # 单样本解释:看某个高风险样本的驱动因素 shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0])GradientBoostingClassifier的max_depth=3是刻意压低的,心血管数据样本量通常不大,树太深容易过拟合。learning_rate=0.05配合n_estimators=200是常见的慢学习率组合,比默认的 0.1 更稳。SHAP 的TreeExplainer对树模型是精确计算,不是近似,所以速度可以接受。
筛选规则:如果某个特征的 SHAP 重要性排在后 30%,且临床上也说不通,就删掉。如果临床意义强但 SHAP 重要性低,先检查是不是编码方式不对,比如把吸烟写成 0/1 但实际应该分“从不/已戒/当前”。特征筛选不是一次性的,每换一次模型都要重新看 SHAP。
4. 模型选型与调参:逻辑回归、XGBoost 还是深度学习
4.1 基线模型:逻辑回归为什么不能跳过
很多人一上来就上 XGBoost 或深度学习,觉得逻辑回归太简单。但在心血管风险预测里,逻辑回归是必须跑的基线。原因有两个:一是它的系数可以直接转成 OR 值,临床医生能看懂;二是它的校准性通常比树模型好,而风险预测系统最看重的不是排序能力,是概率校准。
我一般用LogisticRegression配合penalty='l2',C取 0.1 到 1.0 之间。C越小正则化越强,防止共线性字段(比如收缩压和舒张压)导致系数不稳定。如果要做特征选择,可以用penalty='l1',但 L1 会把相关特征中的一个系数压到 0,解释时要小心。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.metrics import roc_auc_score, brier_score_loss # 逻辑回归需要标准化,树模型不需要 lr_pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(penalty="l2", C=0.5, class_weight="balanced", max_iter=1000, random_state=42)) ]) lr_pipe.fit(X_train, y_train) y_prob_lr = lr_pipe.predict_proba(X_test)[:, 1] print("LR AUC:", roc_auc_score(y_test, y_prob_lr)) print("LR Brier:", brier_score_loss(y_test, y_prob_lr))class_weight="balanced"会自动按类别频率反比加权,适合事件率低的数据。max_iter=1000是防止默认 100 次迭代不收敛。Brier 分数衡量概率校准,越低越好,逻辑回归的 Brier 通常比未校准的 XGBoost 低。
4.2 XGBoost 调参:三个必须动的参数
XGBoost 在表格数据上通常比逻辑回归 AUC 高 0.02 到 0.05,但调参不当会过拟合。我一般只动三个参数:max_depth、learning_rate、subsample。max_depth控制在 3 到 5,心血管数据特征不多,树太深没有意义。learning_rate取 0.01 到 0.1,配合n_estimators用早停确定。subsample取 0.7 到 0.9,增加随机性防止过拟合。
import xgboost as xgb from sklearn.model_selection import StratifiedKFold # 用早停确定最佳树数量 dtrain = xgb.DMatrix(X_train, label=y_train) dtest = xgb.DMatrix(X_test, label=y_test) params = { "max_depth": 4, "learning_rate": 0.05, "subsample": 0.8, "colsample_bytree": 0.8, "objective": "binary:logistic", "eval_metric": "auc", "scale_pos_weight": (y_train == 0).sum() / (y_train == 1).sum() } cv_results = xgb.cv(params, dtrain, num_boost_round=500, nfold=5, stratified=True, early_stopping_rounds=30, seed=42) best_rounds = cv_results["test-auc-mean"].idxmax() + 1 model_xgb = xgb.train(params, dtrain, num_boost_round=best_rounds) y_prob_xgb = model_xgb.predict(dtest)scale_pos_weight是 XGBoost 处理不平衡的推荐方式,值等于负样本数除以正样本数。early_stopping_rounds=30表示如果 30 轮内验证集 AUC 没提升就停。colsample_bytree=0.8让每棵树随机选 80% 特征,增加多样性。
调参顺序:先定max_depth,再定learning_rate和n_estimators,最后调subsample和colsample_bytree。不要用网格搜索一次性搜所有参数,计算量大且容易过拟合验证集。我一般用StratifiedKFold做 5 折,每折内再早停,最后取平均。
4.3 深度学习什么时候值得上
深度学习在心血管风险预测里不是首选。表格数据上,XGBoost 和逻辑回归的组合通常已经够用。深度学习值得上的场景有两个:一是数据量超过 10 万条且有大量非结构化字段(比如心电图波形、影像报告文本);二是要做多任务学习,比如同时预测 MACE、卒中、心衰。
如果确实要上,我一般用简单的 MLP,而不是 Transformer。MLP 的hidden_layer_sizes取 (64, 32) 或 (128, 64),dropout取 0.2 到 0.5,batch_size取 256 到 1024。输入层对连续变量做标准化,对类别变量做 embedding。训练时用Adam,学习率 1e-3,配合ReduceLROnPlateau。
但要注意,深度学习模型的校准通常不如逻辑回归,需要额外做 Platt scaling 或 isotonic regression。如果团队没有深度学习部署经验,我建议先用 XGBoost 上线,把数据管道和监控跑通,再考虑换模型。
5. 避坑与排查:五个血泪教训
5.1 数据泄漏:AUC 0.95 的模型为什么上线就崩
现象:离线 AUC 0.95,上线后实际事件率远低于预测,模型几乎失效。
原因:特征里混入了标签泄漏字段。比如“是否服用他汀”这个字段,很多 MACE 患者是在事件后才开始服药,但数据里记录的是“当前用药”,模型学到的其实是“已经发生事件”。类似的还有“是否做过冠脉造影”“是否住院”。
解决:做特征可用性审查,任何在预测时间点之后才能获得的字段一律剔除。具体做法是画一张时间线,标出每个字段的采集时间,只保留预测时间点之前采集的字段。如果不确定,做一次“去掉该字段后 AUC 变化”的测试,如果 AUC 掉很多,大概率有泄漏。
5.2 校准翻车:AUC 高不代表概率准
现象:XGBoost 的 AUC 比逻辑回归高 0.03,但预测概率整体偏高,把 5% 风险的人预测成 15%。
原因:XGBoost 优化的是排序损失,不是概率校准。scale_pos_weight会进一步扭曲概率输出。
解决:用CalibratedClassifierCV做 Platt scaling 或 isotonic regression。Platt scaling 适合样本量小的情况,isotonic 适合样本量大的情况。校准后重新算 Brier 分数和校准曲线,确保预测概率和实际事件率对齐。
from sklearn.calibration import CalibratedClassifierCV # 用交叉验证做校准,避免过拟合 calibrated = CalibratedClassifierCV(model_xgb, method="isotonic", cv=5) calibrated.fit(X_train, y_train) y_prob_cal = calibrated.predict_proba(X_test)[:, 1]5.3 类别不平衡处理顺序错误
现象:先对全量数据做 SMOTE,再划分训练测试集,AUC 虚高。
原因:SMOTE 生成的少数类样本可能和测试集里的原始样本高度相似,导致测试集泄漏。
解决:先划分训练测试集,再只在训练集上做 SMOTE。更稳妥的做法是用imblearn的Pipeline,把采样步骤和模型步骤串起来,交叉验证时采样只在训练折内进行。
5.4 缺失值填充引入偏差
现象:用全量数据的中位数填充,测试集分布和训练集不一致。
原因:填充器在全量数据上拟合,测试集的信息泄漏到了训练过程。
解决:所有填充器、标准化器、编码器都只在训练集上fit,再transform测试集。用Pipeline可以自动保证这一点,但手动写代码时容易忘。
5.5 外部验证缺失导致过拟合
现象:在单中心数据上 AUC 0.85,换一家医院数据 AUC 掉到 0.65。
原因:模型学到了单中心的编码习惯或人群特征,没有外部验证。
解决:至少留一个外部中心的数据做验证。如果没有外部数据,用时间切分:用前 5 年数据训练,后 2 年数据验证。时间切分比随机切分更接近真实部署场景。
6. 部署与监控:把模型变成能用的评分接口
模型训练完只是开始,真正落地要解决三件事:推理速度、接口设计、监控回退。
推理速度上,XGBoost 和逻辑回归在 CPU 上单条推理都在毫秒级,不需要 GPU。如果要做批量评分,用xgb.DMatrix批量预测比逐条快很多。接口设计上,我一般用 FastAPI 包一层,输入是 JSON 格式的字段字典,输出是风险概率和 SHAP 解释。SHAP 解释不要每次请求都算,可以预计算一个全局解释表,或者只对高风险样本算。
from fastapi import FastAPI from pydantic import BaseModel import numpy as np app = FastAPI() class PatientFeatures(BaseModel): age: float sex: int sbp: float dbp: float chol: float hdl: float ldl: float glucose: float hba1c: float smoke: int diabetes: int egfr: float @app.post("/predict") def predict(features: PatientFeatures): x = np.array([[features.age, features.sex, features.sbp, features.dbp, features.chol, features.hdl, features.ldl, features.glucose, features.hba1c, features.smoke, features.diabetes, features.egfr]]) prob = calibrated.predict_proba(x)[0, 1] return {"risk_probability": round(float(prob), 4), "risk_level": "high" if prob > 0.2 else "moderate" if prob > 0.1 else "low"}监控上,我一般盯三个指标:输入字段的缺失率、预测概率的分布、实际事件率。如果缺失率突然上升,说明上游数据管道有问题;如果预测概率分布整体偏移,说明人群特征变了;如果实际事件率远低于预测,说明模型需要重新校准。回退策略是保留一个逻辑回归基线模型,当 XGBoost 的监控指标异常时自动切回基线。
最后说一个我自己的习惯:每次上线新模型前,先跑一周的影子模式,让新模型和旧模型同时打分,但不影响临床决策。对比两者的分歧样本,人工看 50 到 100 例,确认新模型的判断更合理再切换。这个习惯帮我拦住了至少两次因为字段口径变化导致的翻车。希望帮到你。
本文还有配套的精品资源,点击获取