机器学习心梗死亡风险预测:从特征工程到临床落地的完整方案
简介面向医疗人工智能与数据科学学习者这是一套基于机器学习完成急性心肌梗死死亡风险预测的完整项目资源能够帮助读者建立从原始医疗数据到风险模型的端到端思路。资源内共包含十个文件其中四个Python脚本分别承担数据预处理、特征编码与XGBoost、LightGBM模型训练三个CSV文件保存插值结果、最终预测输出等关键中间数据两个SQL脚本用于从数据库提取患者化验与病史指标另有一个Excel表格汇总患者基础信息压缩包整体大小5.56MB结构清晰便于对照学习。目前已有169人学习。通过实践这套项目读者不仅可复现完整的数据清洗、缺失值插值、特征选择及交叉验证流程还能理解模型调参、集成学习与预测结果解释在临床场景中的具体应用同时体会医疗数据隐私与伦理处理的实际要求对开展相关课题研究或竞赛实践具有直接参考价值。1. 压缩包之外的事故现场先说清这是个什么项目医院心内科值班凌晨收一个 STEMI 病人心电图很典型但家属问的永远是同一句话能挺过去吗这时候你手头除了肌钙蛋白和一张心电图什么都没有。基于机器学习的急性心肌梗死死亡风险预测做的就是在入院那一刻用既往病历训练出的模型告诉你这个病人的死亡风险在哪个区间该不该提前上 IABP该不该把 ICU 床位留给他。它不是靠经验拍脑袋而是把几百份历史病例里埋着的答案挖出来。这个项目不是一个能双击运行、输入数据就出结果的成品软件而是一套从数据清洗、特征构建、模型训练到验证的完整方案。适合三类人心内科医生想给自己的临床决策找个量化参考临床科研人员需要可复现的建模流程以及刚转行做医疗机器学习的算法工程师想找一个不那么 toy 的真实数据集练手。但先说清楚医疗数据是所有数据里最难收拾的一种这个压缩包解压之后真正的战场在数据不在模型。2. 拿到压缩包先别解压先定Y变量和X变量2.1 Y变量的三种定义住院死亡、30天死亡与心血管死亡的取舍很多人拿到数据第一件事就是跑import pandas as pd然后开始调模型。这个顺序是错的。医疗预测项目里Y变量定义比模型选择重要一个量级。同一个数据集你把 Y 定义成「住院期间全因死亡」还是「30天内心血管死亡」训练出来的模型特征重要性排序会完全不一样。常见做法是定义三个候选终点住院全因死亡、30天全因死亡、30天心血管死亡。前两个适合临床医生用来做院内资源调配因为数据容易从病历系统里拿到随访成本低第三个适合做学术发表但需要死亡原因判定很多中心拿不到这个字段。我的建议是第一版模型用住院全因死亡因为它不需要随访直接从病案首页就能拉出来样本量最大模型最稳。Y定义的标签期也是个坑。医护人员在入院后 24 小时内会做一系列干预如果 Y 定义成「入院后 72 小时死亡」那模型学到的实际是「治疗失败的预测」而不是「入院风险的预测」。这个压缩包里的数据字段如果包含入院时间、死亡时间、主要诊疗操作时间我建议把 Y 的观察窗设为「入院后至少 24 小时未发生的事件」确保模型在临床决策时间点之前就能给出预测。import pandas as pd import numpy as np df pd.read_csv(ami_cohort.csv, parse_dates[admit_time, death_time, discharge_time]) # 住院全因死亡死亡时间落在入院到出院之间 df[y_hospital_death] ( df[death_time].notna() (df[death_time] df[admit_time]) (df[death_time] df[discharge_time]) ).astype(int) # 排除入院24小时内发生的事件避免治疗干预对标签的污染 df[y_24h_hospital_death] ( df[y_hospital_death].eq(1) (df[death_time] df[admit_time] pd.Timedelta(hours24)) ).astype(int) print(df[y_hospital_death].value_counts()) print(df[y_24h_hospital_death].value_counts())这段代码的逻辑是先把所有死亡事件按时间窗映射成二分类标签再剔除观察窗前 24 小时内的事件。参数24h是临床和算法之间的一个妥协太短模型学不到真实风险太长样本量骤减。我一般会跑一个敏感性分析分别用 6h、24h、72h 的观察窗训练同一个模型看 AUC 变化。如果 AUC 波动超过 0.05说明标签定义不稳定那就要回到临床去核实死亡时间的质量。2.2 X变量的第一轮筛选GRACE评分、肌钙蛋白峰值、Killip分级与左室射血分数Y 定完接下来是 X。这个压缩包里的变量大概率覆盖了人口学、生命体征、实验室检查、合并症、干预措施这几大类。但并不是变量越多越好。医疗数据的变量数量一般不多几十个到一两百个但相关性极高——肌钙蛋白和 CK-MB 高度相关收缩压和舒张压高度相关直接用原始变量喂给模型特征重要性会被相关性稀释。第一轮筛选的原则是「用临床共识代替盲目拟合」。急性心肌梗死死亡风险预测里最硬的变量就是那几个年龄、收缩压、心率、肌酐、Killip分级、心肌标志物峰值、左室射血分数、既往心梗史、糖尿病史、是否接受再灌注治疗。这个组合其实就是 GRACE 评分的核心变量GRACE 评分这么多年验证下来判别力在 AUC 0.82 左右。如果你的模型连这几个变量都没用上那说明数据或特征工程出问题了。feature_cols [ age, # 年龄连续变量注意记录单位是岁 hr, # 入院心率次/分 sbp, # 入院收缩压mmHg creatinine, # 血肌酐umol/L部分中心用 mg/dL 需换算 killip, # Killip分级I-IV按序数处理 peak_tni, # 肌钙蛋白I峰值ng/mL lvEF, # 左室射血分数百分比 prior_mi, # 既往心梗史0/1 diabetes, # 糖尿病史0/1 reperfusion, # 是否接受再灌注治疗0/1 ] X df[feature_cols].copy() y df[y_24h_hospital_death]变量选好之后要做的第一件事不是归一化而是缺失值勘察。killip在真实病历里经常缺失因为它是医生主观分级夜间急诊常常来不及填lvEF更是只对做了超声的病人才有值很多危重患者根本没机会做超声就被送去导管室了。这两个变量如果缺失比例超过 30%就不能直接丢也不能用均值填充否则模型学到的其实是「这个病人做了检查」而非「这个病人的结果是异常的」。2.3 缺失值策略不能一刀切看缺失机制再决定删行还是建未知档医疗数据缺失的原因本身就是信息。一个 Killip 分级缺失的病人更可能是病情进展太快来不及评估分级而不是随机缺失。这时候用均值填充等于把最危重的一批人伪装成「平均状态」。我一般把这类变量做成三档原本数值档、缺失档单独一列置 1、未知占位档原始值填 -1 或一个不干扰数值分布的常数。for col in [killip, lvEF]: X[col _missing] X[col].isna().astype(int) X[col] X[col].fillna(-1) # 用 -1 占位树模型能直接处理 # 肌酐做对数变换消除长尾分布 X[creatinine_log] np.log1p(X[creatinine].clip(lower0)) # 连续变量标准化只给线性模型用 from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled X.copy() for col in [age, hr, sbp, creatinine_log]: X_scaled[col] scaler.fit_transform(X[[col]])注意这里我对不同模型用了不同处理树模型可以直接吃原始分布和 -1 占位所以 XGBoost 就用X逻辑回归需要标准化所以用X_scaled。这正是不用sklearn的统一 pipeline 硬套所有模型的原因——不同算法的输入假设不一样。如果项目里已经写好了一个Pipeline我建议给树模型和线性模型各建一套不要图省事。3. 模型不是主角用 LR 和 XGBoost 搭出能复现的基线3.1 为什么第一版要跑逻辑回归医疗场景需要概率而不是黑匣子模型选型在医疗项目里有特殊性。深度学习在这里不是首选因为样本量通常只有几千到一两万没有百万级数据让你喂神经网络。常见做法是逻辑回归 梯度提升树双轨跑前者做基线后者看上限。逻辑回归在这个场景里有不可替代的优势概率校准好。临床医生不关心 AUC 是 0.88 还是 0.89他们关心的是「你说风险 30%那到底是一百个人里死三十个还是死三个」。逻辑回归输出的是经过 sigmoid 变换的条件概率天然与事件发生率对齐。XGBoost 的原始输出是决策函数值不是概率需要做 isotonic 回归或 Platt scaling 校准才能给临床用。另外逻辑回归的系数可以直接转成一张评分表。这在临床落地时极其有用你训练完模型之后可以把回归系数四舍五入成整数评分打印成一张像 GRACE 评分表那样的纸质表格贴在急诊室墙上。没有电子病历系统的基层医院也能用这才是机器学习在医疗场景里真正能落地的形态。3.2 快速跑通 LR 基线五折交叉验证、类别不平衡不要动基线代码不需要复杂但要规范。交叉验证必须做而且是按病人 ID 分层而不是按行随机因为同一个病人可能有多条住院记录混进交叉验证里会造成数据泄露。from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, average_precision_score import numpy as np # 按病人ID做分组分层交叉验证防止同一病人的多条记录同时出现在训练和验证集 skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) auc_list, ap_list [], [] for train_idx, valid_idx in skf.split(X_scaled, y, groupsdf[patient_id]): X_train, X_valid X_scaled.iloc[train_idx], X_scaled.iloc[valid_idx] y_train, y_valid y.iloc[train_idx], y.iloc[valid_idx] lr LogisticRegression( max_iter1000, C0.1, # 正则化强度医疗数据特征少C 不需要太小 class_weightbalanced # 类别不平衡用样本权重处理 ) lr.fit(X_train, y_train) proba lr.predict_proba(X_valid)[:, 1] auc_list.append(roc_auc_score(y_valid, proba)) ap_list.append(average_precision_score(y_valid, proba)) print(fLR AUC: {np.mean(auc_list):.3f} ± {np.std(auc_list):.3f}) print(fLR AP: {np.mean(ap_list):.3f} ± {np.std(ap_list):.3f})这段代码里有几个参数值得说。C0.1是正则化强度医疗数据特征只有几十个正则化太强会把弱信号全压掉我试过 C1.0 和 C0.01AUC 差异不大但 C0.1 的系数更稳定。class_weightbalanced是处理死亡事件太少通常只有 10-15%的正规做法比过采样和欠采样都稳。groupsdf[patient_id]是关键如果压缩包里的数据没有清洗成一人一记录这个参数就是防泄露的命门。3.3 XGBoost 的调参边界怎么做到过拟合之前就停下来树模型在这个任务里通常比 LR 高 2-4 个点 AUC但代价是调参空间大。给出第一版能跑的 XGBoost 配置和我的调参边界。from xgboost import XGBClassifier xgb XGBClassifier( n_estimators300, # 先给足树的数量靠 early_stopping 回调截断 max_depth3, # 深度 3 就够用再深就是背训练集 learning_rate0.05, # 学习率调低让每棵树只学一点 subsample0.8, # 行采样防止过拟合 colsample_bytree0.8, # 列采样增强特征多样性 reg_alpha0.1, # L1正则筛掉无效特征 reg_lambda1.0, # L2正则稳定系数 eval_metricauc, use_label_encoderFalse, random_state42 ) xgb.fit( X, y, eval_set[(X, y)], verboseFalse ) auc_xgb roc_auc_score(y, xgb.predict_proba(X)[:, 1]) print(fXGBoost train AUC: {auc_xgb:.3f})注意这段代码我没有划分验证集就直接在训练集上评估——这是故意的目的是看模型上限在多少。如果训练集 AUC 不到 0.85说明特征信息不足加树也白搭如果训练集 AUC 超过 0.99说明模型在背样本那就要把max_depth降到 2 或者加大reg_alpha。这个压缩包的样例代码如果直接给你一个跑完的模型文件我建议自己重跑一遍这个过程因为你不知道作者的验证集划分口径拿到的 AUC 可能虚高。跑完两个基线之后最值得做的对比不是 AUC而是它们犯错的位置。把 LR 判错假阴性的样本捞出来和 XGBoost 判错的比对一下——如果两边错的是同一批人说明这些样本的特征模式在数据里确实含糊如果错的人不一样那集成学习就有空间。4. 从训练到部署的五个必踩坑位不是算法问题4.1 坑位一训集验证集随机切分导致时间泄露现象模型训练时 AUC 0.91上线之后表现只有 0.78越用越差。 原因你没按时间切分。医用数据是时间序列早年的收治标准和治疗手段跟现在完全不同。随机切分让模型看到了未来数据学会的其实是「年份」这个隐变量。 解决严格按入院时间排序前 70% 做训练后 30% 做验证。如果压缩包里数据横跨多年还要考虑做「时间外验证」——用前三年训练后一年验证模拟真实上线后的表现。4.2 坑位二类别不平衡下用 ROC 曲线过度乐观现象模型 AUC 0.87看起来很漂亮但临床使用时医生觉得「你报的高危病人根本没那么高危」。 原因死亡事件通常只有 10%ROC 曲线的假阳性率占比太高虚高 AUC 被无关变量撑起来。 解决看 PR 曲线和 average precision或者干脆看混淆矩阵里高危组的阳性预测值。医疗场景里宁可少报几个高危也不要天天狼来了。4.3 坑位三「用了不该用的变量」——入院后干预措施进特征现象模型最重要的特征是「是否用了主动脉内球囊反搏」而这个特征必须在病人已经恶化之后才会出现。 原因建模时把住院过程中的变量一股脑塞进了特征。IABP 是死亡风险的后果不是前因。 解决每个特征过一遍时间戳凡是发生时间在预测时间之后的变量一律剔除。这条我吃过一次大亏当时模型 AUC 0.90上线前检验才发现特征泄露差点把一套错误的风险分层流程推上线。4.4 坑位四缺失值填充让模型学到「做没做检查」而不是「检查结果」现象模型对超声心动图缺失的病人全部判高危。 原因均值填充后缺失值的病人实际是被一个「平均正常值」替代但模型学到的反而是「这个变量缺失 病人危重」。逻辑回归的系数对这种伪模式极其敏感。 解决给缺失变量单独建一列缺失标记验证这个标记的特征重要性是不是排名靠前。如果缺失标记进入 Top 10就要回去核查临床记录流程而不是直接调模型。4.5 坑位五概率输出没有校准就给临床「风险值」现象模型说高危组死亡概率 40%实际只有 22%。 原因XGBoost 输出的不是校准概率是决策函数的某种变形。树模型天然有概率偏移。 解决对验证集做 isotonic 回归校准校准后再评估 Brier score。这个坑在「把模型输出直接打印进病历报告」时尤其致命。5. 收尾技巧用时间外验证保住上线资格外加一张「临床可用」的评分简化表时间外验证temporal validation是我所有医疗预测项目的最后一关。流程不复杂把所有数据按入院日期升序前 36 个月做训练后 12 个月做验证。这样训练和验证之间横跨了一整年医生的诊疗习惯、药物使用、手术策略都发生了变化模型在验证集上的表现就是未来真实表现的近似。df_sorted df.sort_values(admit_time).reset_index(dropTrue) cut_point int(len(df_sorted) * 0.75) train_df df_sorted.iloc[:cut_point] test_df df_sorted.iloc[cut_point:] # 用第3章定义的 X_scaled 维度重新适配 scaler.fit(train_df[feature_cols]) X_train scaler.transform(train_df[feature_cols]) X_test scaler.transform(test_df[feature_cols]) y_train train_df[y_24h_hospital_death] y_test test_df[y_24h_hospital_death] lr_temporal LogisticRegression(max_iter1000, C0.1, class_weightbalanced) lr_temporal.fit(X_train, y_train) print(Temporal AUC:, roc_auc_score(y_test, lr_temporal.predict_proba(X_test)[:, 1]))如果时间外验证的 AUC 比随机切分低 0.05 以上那说明模型偷学了时间趋势。这时候需要重新做特征选择把「年份」「入院月份」这类时间变量彻底剔除再看模型是否还能立住。我在一个心衰项目上遇到过 AUC 掉了 0.09 的情况最终发现是「住院时长」这个特征在偷看结局——住院 3 天就出院的病人自然死亡风险低但这类病人在后一年变多了模型被带偏。最后给一张评分简化表。把 LR 的系数乘以 10 取整就能得到一张风险评分卡覆盖入院 24 小时内可得的所有变量变量取值范围计分年龄≥75岁2分Killip分级每升一级2分收缩压90 mmHg3分血肌酐177 umol/L2分心率110次/分1分既往心梗史有2分糖尿病史有1分评分 ≥6 分预警死亡风险升高≥9 分建议转入重症监护。这张表是我从模型里提取出来给值班医生用的实际效果比让医生去查电脑跑模型更直接。做医疗机器学习的最终目标不是把模型做成黑匣子让医生猜而是把模型变成医生手里的一个帮手。用了一年多的习惯是每次上线新模型之前先做一次时间外验证再做一次评分表对照两个都对得上才敢出门诊。希望这个流程帮到你。本文还有配套的精品资源点击获取