简介一份心血管疾病预测模型的Python建模项目资源面向希望运用机器学习解决医疗健康问题的学习者。案例围绕真实临床数据完整呈现数据清洗、异常值处理、特征构造如BMI指数、吸烟指数以及标准化等预处理流程并演示利用逻辑回归、随机森林、支持向量机等算法完成模型训练、交叉验证、ROC曲线与准确率召回率评估以及通过网格搜索调整超参数。资源内共含3个文件一个Jupyter Notebook演练文档、一个可独立运行的Python脚本和一份csv格式的心衰数据集整体压缩包仅83KB。当前已有1129人学习适合具备Python基础并希望从事医疗数据分析的读者深入研读。得益于完整代码与数据使用者可快速掌握特征选择、模型持久化与可视化等关键技能并可将同一套方法论迁移至其他疾病风险预测场景。1. 这个心血管预测模型源码包解决的不只是“跑通代码”拿一份体检数据想预测心血管风险最尴尬的不是不会装 Python而是装好了却不知道先处理哪一列。这个源码包正好给你一个完整闭环heart_failure.csv 是真实的心衰患者临床记录feature_selection.py 解决“13 个特征哪些能用”的问题附带的 Jupyter Notebook 把数据清洗、模型训练、评估串成一条线。它的定位不是科普翻书而是可以直接照着跑的数学建模实战项目。适合三种人参加数学建模竞赛要选医疗题的学生、刚入门机器学习想练完整流程的开发者、以及需要做风险预测演示的医疗数据从业者。我自己拆完第一遍的感受是代码不算难但每个环节都值得停下来想想“为什么这么写”这比跑通本身更有价值。2. 先摸透 heart_failure.csv字段含义与预处理的关键点2.1 字段清单哪些是风险因素哪些是时间窗口heart_failure.csv 记录的是心衰患者的临床随访数据目标字段是 DEATH_EVENT表示患者在随访期内是否死亡。模型要学的是“哪些因素和死亡风险相关”所以这是一个典型的二分类监督问题。字段名类型含义age数值年龄anaemia0/1是否贫血creatinine_phosphokinase数值肌酸激酶水平diabetes0/1是否糖尿病ejection_fraction数值射血分数衡量心脏泵血能力high_blood_pressure0/1是否高血压platelets数值血小板计数serum_creatinine数值血清肌酐serum_sodium数值血清钠sex0/1性别smoking0/1是否吸烟time数值随访天数DEATH_EVENT0/1随访期内是否死亡目标列这里最容易踩坑的是 time。它表示每个病人的随访时间长度不是临床检验指标。如果把它当普通特征放进模型模型会学到“随访时间短的人死亡风险高”这种时间性泄露AUC 会虚高到没法用。这份源码包里没有把它当特征但你自己扩展数据时很容易忽略。2.2 缺失值和异常值先用 pandas 摸一遍底拿到 CSV 第一步不是建模而是用 pandas 快速查看数据形态。常见做法是先看 info 确认有没有缺失再看 describe 观察数值分布的极端值。心衰数据里肌酸激酶、血小板这类指标通常会有个别极端值直接用均值填充会把特征分布拉歪。import pandas as pd df pd.read_csv(heart_failure.csv) print(df.info()) print(df.describe(percentiles[.25, .5, .75, .95])) # 数值列统一用中位数填充缺失值 num_cols df.select_dtypes(include[float64, int64]).columns for col in num_cols: if df[col].isnull().any(): df[col].fillna(df[col].median(), inplaceTrue) print(f{col} 缺失填充完成)这段代码的重点在 percentiles 参数。describe 默认只给 25%、50%、75% 分位加上 .95 之后能更早发现右侧长尾。心衰场景里肌酸激酶正常范围是 10 到 120 U/L但重症患者可能到几千这种极端值对逻辑回归的梯度影响很大。处理边界时我一般会用 IQR 法标记而不是直接删行因为医学数据里异常值本身可能就代表病情严重。2.3 标准化与归一化逻辑回归必须做树模型无所谓很多资料把标准化说成“必需步骤”但准确说法是对距离类模型和线性模型必需对树模型影响不大。逻辑回归用梯度下降优化特征量纲差异过大会导致收敛慢、系数解读困难。随机森林是基于分裂点选择的特征缩放不改变排序结果。from sklearn.preprocessing import StandardScaler X df.drop(columns[DEATH_EVENT]) y df[DEATH_EVENT] num_features [age, creatinine_phosphokinase, ejection_fraction, platelets, serum_creatinine, serum_sodium] scaler StandardScaler() X_scaled X.copy() X_scaled[num_features] scaler.fit_transform(X[num_features])注意这里只对连续数值特征做标准化0/1 型二值特征不用动。fit_transform 和 transform 的区别是新手最容易出错的点fit_transform 用于训练集transform 用于测试集和未来的新数据。如果你把全量数据一起 fit验证时的数据分布已经包含测试集信息这叫数据泄露后面避坑章会专门讲。3. 特征选择实战feature_selection.py 里的筛选思路能直接用3.1 13 个特征不能全塞进去先看相关性和方差14 个变量里去掉目标和 time还剩 11 个可用特征。不算多但直接全塞进逻辑回归会遇到两个问题一是特征之间可能高度相关比如肌酐和年龄往往有正相关导致回归系数不稳定二是部分特征的预测能力极弱比如性别在这个数据集里可能贡献很低留着只会增加噪声。feature_selection.py 提供的核心思路是先做“粗筛”再做“精筛”。粗筛用相关性和方差目标是快速排除明显没用的列精筛用递归特征消除或随机森林重要性目标是确认哪些组合效果最好。3.2 相关性热图与方差过滤的代码实现相关性分析在 pandas 里一行就能算出来。常见做法是设定阈值 0.7如果两个特征相关系数超过这个值就保留其中一个。import pandas as pd df pd.read_csv(heart_failure.csv) corr df.corr() # 找出与目标列 DEATH_EVENT 相关性绝对值大于 0.1 的特征 target_corr corr[DEATH_EVENT].abs().sort_values(ascendingFalse) print(target_corr[target_corr 0.1]) # 找出两两相关性大于 0.7 的特征对 cols corr.columns.tolist() high_pairs [] for i, col1 in enumerate(cols): for col2 in cols[i1:]: if abs(corr.loc[col1, col2]) 0.7: high_pairs.append((col1, col2, corr.loc[col1, col2])) print(high_pairs)这段代码有两个作用。一是快速定位哪些特征与死亡事件关系密切通常 ejection_fraction、serum_creatinine、age 会排在前列二是发现多重共线性组合。如果 age 和 serum_creatinine 相关性大于阈值可以保留与目标相关性更高的那个。方差过滤更简单直接计算数值列的方差方差接近 0 说明所有样本取值几乎一致对区分死亡与否没有帮助。3.3 递归特征消除与随机森林重要性的取舍递归特征消除RFE是 sklearn 里很成熟的算法。它反复训练模型、淘汰权重最小的特征直到达到指定数量。随机森林的重要性则是基于节点分裂对纯度的贡献两者得出的特征排名经常不一样我会同时跑然后取交集。from sklearn.ensemble import RandomForestClassifier from sklearn.feature_selection import RFE X df.drop(columns[DEATH_EVENT, time]) y df[DEATH_EVENT] rf RandomForestClassifier(n_estimators200, random_state42) rfe RFE(estimatorrf, n_features_to_select6) rfe.fit(X, y) selected X.columns[rfe.support_].tolist() ranking sorted(zip(X.columns, rfe.ranking_), keylambda x: x[1]) print(RFE 选择的特征:, selected) print(特征重要性排序:, ranking)RFE 的关键参数是 n_features_to_select 和 estimator。n_features_to_select 设为 6 是我在这个数据集上试出来的常见合理值因为样本量只有 299 条特征太多会过拟合。estimator 用随机森林比较稳健它对非线性关系更友好比用逻辑回归做 RFE 更不容易因为共线性而波动。跑完你会发现ejection_fraction、serum_creatinine、age、serum_sodium 这几位经常被保留下来和医学认知基本一致。4. 模型构建与评估逻辑回归和随机森林的对比实验4.1 交叉验证不要只用一次 train_test_split299 条样本对机器学习来说非常少如果只做一次训练测试划分结果的随机性会很大。同一份数据换一个 random_stateAUC 可能从 0.72 跳到 0.85。常见做法是用分层 K 折交叉验证既能保证每一折正负样本比例一致又能让模型平均使用全量数据。from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler X df.drop(columns[DEATH_EVENT, time]) y df[DEATH_EVENT] lr make_pipeline(StandardScaler(), LogisticRegression(max_iter1000)) cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(lr, X, y, cvcv, scoringroc_auc) print(逻辑回归交叉验证 AUC: %.3f (/- %.3f) % (scores.mean(), scores.std()))这里用 make_pipeline 把标准化和逻辑回归包在一起可以避免在每一折交叉验证里对测试集提前 fit 标准化器。n_splits 设 5 是因为数据集只有 299 条折数太多每折样本太少折数太少则评估不稳。shuffle 加 True 是为了打乱分布固定 random_state 才能在两次实验间复现结果。4.2 逻辑回归与随机森林的代码实现逻辑回归的优势是可解释性强每个特征的系数能直接对应风险方向随机森林的优势是能自动捕捉非线性关系对特征量纲不敏感。两个模型对比跑一遍你就能看到它们在 AUC 上的差异。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, accuracy_score, recall_score X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 逻辑回归 lr make_pipeline(StandardScaler(), LogisticRegression(max_iter1000)) lr.fit(X_train, y_train) y_pred_lr lr.predict(X_test) print(LR AUC: %.3f, ACC: %.3f, Recall: %.3f % ( roc_auc_score(y_test, lr.predict_proba(X_test)[:, 1]), accuracy_score(y_test, y_pred_lr), recall_score(y_test, y_pred_lr) )) # 随机森林 rf RandomForestClassifier(n_estimators200, max_depth5, random_state42) rf.fit(X_train, y_train) y_pred_rf rf.predict(X_test) print(RF AUC: %.3f, ACC: %.3f, Recall: %.3f % ( roc_auc_score(y_test, rf.predict_proba(X_test)[:, 1]), accuracy_score(y_test, y_pred_rf), recall_score(y_test, y_pred_rf) ))stratifyy 必须在 train_test_split 里加上。不加上这个参数随机划分可能让测试集里的死亡样本太少算出来的指标全是偶然结果。随机森林的 n_estimators 我设到 200max_depth 限制 5是为了防止在 299 条样本上长成完全记忆训练集的树。如果 max_depth 不设树会一直分裂到叶子节点纯训练集 AUC 接近 1测试集掉到 0.65。4.3 ROC/AUC、准确率、召回率医疗模型看重什么医疗预测场景里把真正有风险的患者漏掉比把正常人误判成高风险更严重。所以关注顺序应该是AUC 责任总体区分能力Recall 决定漏诊率准确率最后看。这个数据集的死亡事件占比约 32%不是极端不平衡但如果只盯准确率模型全预测为不死亡也能拿到 68% 的准确率毫无意义。AUC 的代码之前已经出现过核心是用 predict_proba 取正类概率。一定要注意predict 返回的是 0/1 类别用 predict 出来的结果算 AUC 会丢信息。ROC 曲线本身只是不同阈值下真正例率和假正例率的关系图AUC 是曲线下面积。如果两个模型 AUC 接近优先选逻辑回归因为系数能解释好和医疗领域的实际认知对齐。5. 避坑训练心血管预测模型最容易翻车的五个细节5.1 现象AUC 虚高到 0.9原因把 time 字段当成了特征我第一次跑类似数据时把 time 当成普通指标放进了特征列表逻辑回归交叉验证 AUC 直接到 0.94当时以为自己发现了金矿。后来把 time 单独和 DEATH_EVENT 做相关性分析才发现两者高度相关。随访时间长的病人当然更可能存活到随访结束这属于目标泄露因为预测时根本不知道未来的随访时长。解决特征矩阵里永远删掉 time同时检查数据字典里有没有类似“入院天数”“观察周期”这类字段。拿到新数据先做字段溯源再进模型。5.2 现象模型只会预测“不死亡”原因类别不平衡没处理这个数据集死亡事件占 32%还不算严重但如果你换了其他心血管数据集死亡占比可能只有 10%那么逻辑回归会倾向于预测高比例的负样本召回率掉到 0.1 以下。解决思路是先看分类报告确认 recall 和 f1-score不要只看 accuracy。如果确实不平衡可以用 class_weightbalanced 让模型自动调整类别权重或者用 SMOTE 做少数类过采样但过采样要在交叉验证内部做否则又有泄露风险。5.3 现象标准化时 fit 了整个数据集导致测试集信息混入在 4.1 我用了 make_pipeline 就是为了避免这个问题。常见的错误写法是先 scaler.fit_transform(X)然后把整个 X 划分成训练集和测试集。这样标准化器已经看过测试集的均值和方差测试集不在“未知数据”的模拟条件下得到的 AUC 是偏乐观的。解决先划分数据集再在训练集上 fit在测试集上只 transform。如果使用 cross_val_score就一定要用 Pipeline 或 make_pipeline 包装。5.4 现象随机森林调参只动 n_estimators遗憾效果没变化n_estimators 决定的是树的棵数超过一定值后模型性能进入平台期200 棵和 500 棵差别不大。真正影响随机森林性能的是 max_depth、min_samples_leaf、max_features以及是否启用 oob_score。在这个数据量级把 max_depth 从默认的 None 改成 4 到 6min_samples_leaf 设 5 到 10往往能更有效抑制过拟合。解决调参不要只盯 n_estimators优先用网格搜索调 max_depth 和 min_samples_leaf。5.5 现象用准确率评估二分类模型结果被负样本带偏逻辑回归在这个数据集上的准确率可能只有 75% 左右但 AUC 能到 0.85。如果只看准确率你会觉得模型不行如果只看召回率你又可能觉得模型漏诊太严重。准确率在类别分布不均匀时意义不大。解决至少同时输出 AUC、Recall、Precision、F1再画 ROC 曲线综合判断。医疗类项目尤其要把 Recall 放在仅次于 AUC 的位置宁可多转诊几例疑似患者也不要漏掉真病人。6. 把模型用起来joblib 保存、加载与单条预测封装6.1 用 Pipeline 把预处理和模型绑定在一起前面所有实验都是在代码里手写流程真正要落地时必须把标准化和模型绑成一个整体。否则每次预测新数据你都要手动执行一遍填补缺失、标准化、特征选择漏一步结果就全错。sklearn 的 Pipeline 可以把这些步骤串成一个对象保存和加载都只需要面对一个文件。from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler import joblib X df.drop(columns[DEATH_EVENT, time]) y df[DEATH_EVENT] num_features [age, creatinine_phosphokinase, ejection_fraction, platelets, serum_creatinine, serum_sodium] pipe Pipeline(steps[ (scaler, StandardScaler()), (clf, LogisticRegression(max_iter1000)) ]) pipe.fit(X, y) joblib.dump(pipe, heart_failure_model.pkl)这里的 Pipeline 会自动记住它是在哪些列上 fit 的但不会记住列名所以加载之后需要保证新数据的列顺序和训练时完全一致。我一般会把特征列顺序存成一个 JSON 文件和 pkl 放在一起避免日后遗忘。6.2 封装预测函数新病人数据直接出风险概率保存模型只是第一步真正的落地场景是来一条新的患者记录输入原始化验单数据返回一个风险概率。这里我习惯封装一个函数输入一个字典输出风险概率和风险等级。import json import joblib import pandas as pd pipe joblib.load(heart_failure_model.pkl) with open(feature_order.json, r) as f: feature_order json.load(f) def predict_risk(patient_dict): # 按训练时的特征顺序构造 DataFrame input_df pd.DataFrame([{col: patient_dict[col] for col in feature_order}]) # 缺失值用中位数填充 for col in num_features: if input_df[col].isnull().any(): input_df[col].fillna(pipe.named_steps[scaler].mean_[num_features.index(col)], inplaceTrue) prob pipe.predict_proba(input_df)[0][1] risk_level 高风险 if prob 0.5 else 低风险 return {风险概率: round(prob, 4), 风险等级: risk_level} # 示例 sample {age: 68, anaemia: 1, creatinine_phosphokinase: 560, diabetes: 0, ejection_fraction: 25, high_blood_pressure: 1, platelets: 230000, serum_creatinine: 1.9, serum_sodium: 135, sex: 1, smoking: 0} print(predict_risk(sample))这段代码要注意的是缺失值处理方式我直接用标准化器保存的均值来填充准确说应该用训练集的中位数但示例为了简洁用了均值的近似逻辑。实际项目中建议把每列的中位数也存成 JSON。predict_proba 返回的是二维数组[0][1] 表示正类概率。阈值 0.5 只是默认值临床场景下你可能需要调低到 0.3 来提高召回率。从那以后我每做一次医疗模型都会强制把特征顺序、缺失值字典、模型文件三者一起打包再顺手写一个这样的预测函数省掉无数手动转格式的时间。希望帮到你。本文还有配套的精品资源点击获取