机器学习预测心血管风险:全链路实战与模型优化
简介这套基于机器学习的心血管疾病风险预测系统设计与实现资料面向医疗信息化学习者与算法实践者围绕临床数据清洗、特征筛选、模型训练与可视化评估展开适用于完成课程设计、毕业设计或入门医疗AI的完整项目复现。压缩包共12个文件约4.63MB以Python脚本、Jupyter Notebook分析文档为主辅以csv格式的心脏病数据集、docx项目方案、README说明、运行日志及辅助运行所需的dll其中.ipynb可逐步查看数据探索与预测建模流程.py脚本对应Web端或命令行应用入口zbak为备份文件便于恢复对比。整体结构适合按“数据—建模—验证—展示”顺序研读。目前已有89人学习下载适合希望通过真实代码理解逻辑回归、决策树集成及模型评估指标的读者。资料中包含完整实验记录与辅助工具可帮助快速掌握疾病风险识别从数据预处理到结果可视化的全链路方法为后续科研或工程实践提供参考。1. 机器学习预测心血管风险先别急着调参先看清整条链路心血管疾病风险预测是机器学习在医疗场景里最典型的落地方向之一输入年龄、血压、胆固醇、血糖等体检指标输出一个发病风险概率帮着医生做早期筛查。这类项目看起来门槛不高——数据是公开的模型是现成的随便跑个随机森林就能拿到很高的准确率。但你真拿去复现一遍就会意识到跑分容易把一条从数据清洗、特征工程、模型训练到结果解释的完整链路串起来才是课程设计和实际项目之间真正的分水岭。这份资源解决的就是这个问题它不是给你一个孤零零的预测脚本而是一套以心血管数据为核心的机器学习应用流程。从数据集中缺失值怎么处理、特征怎么筛选到逻辑回归、随机森林、XGBoost对比调参再到最后怎么把模型封装成可调用的预测模块全都落成了能直接跟着跑的代码。适合拿来做机器学习课程设计、期末项目或是想完整走一遍数据科学流程的初学者——新手能按步骤复现熟手能直接替换数据集和调参逻辑。2. 数据清洗与特征工程把体检数据变成模型能吃的样子2.1 这份数据集里有什么先要心里有数心血管疾病公开数据集很常见最经典的是 Framingham 心脏病研究那套包含 3000 多条记录、15 个左右字段。典型的字段包括性别male、年龄age、教育程度education、当前是否吸烟currentSmoker、每日吸烟量cigsPerDay、降压药使用prevalentHyp、糖尿病史diabetes、总胆固醇totChol、收缩压sysBP、舒张压diaBP、BMI、心率heartRate、血糖glucose以及最终标签——未来 10 年是否患冠心病TenYearCHD。拿到手先做一件事用df.info()和df.describe()看缺失值分布和数据范围。这一步比模型选型重要得多因为后续所有特征工程的决策都建立在你对数据质量的理解上。常见做法是先把缺失比例超过 30% 的字段标出来再逐个决定是删除还是填充而不是一股脑 fillna。2.2 缺失值处理均值填充还是中位数填充别拍脑袋下面这段代码处理了最常见的缺失值问题我一般在课程设计里直接套用这个结构import pandas as pd # 读取数据注意原始 CSV 里可能有多余空格列名 df pd.read_csv(cardio_data.csv, encodingutf-8) df.columns [col.strip() for col in df.columns] # 1. 统计缺失值 missing_ratio df.isnull().mean().sort_values(ascendingFalse) print(缺失比例:\n, missing_ratio[missing_ratio 0]) # 2. 处理策略连续变量用中位数填充避免被极端值拉偏 for col in [totChol, cigsPerDay, BMI, glucose, heartRate]: if df[col].isnull().any(): df[col] df[col].fillna(df[col].median()) # 3. 缺失过大的 education 字段直接删掉 df.drop(columns[education], inplaceTrue) # 4. 检查还有没有残留空值 print(剩余缺失值数量:, df.isnull().sum().sum())逻辑说明为什么连续变量用中位数而不是均值因为血压、胆固醇这类生理指标经常有极端值均值会被少数异常样本拉偏中位数更稳健。而education这类类别字段缺失太多填充没有意义直接删除反而干净。参数说明missing_ratio可以帮你快速定位哪些字段值得保留fillna(df[col].median())是逐列填充不要用df.fillna(df.median())一次性处理因为不同字段的异常程度差异很大逐列处理方便你日后调整策略。2.3 特征缩放与相关性筛查逻辑回归和树模型的预处理差别逻辑回归这类基于距离或梯度的模型对特征尺度很敏感随机森林、XGBoost 是树模型不要求缩放。但如果你的系统里同时跑多个模型做对比工程上最稳妥的做法是统一做标准化因为后续不管接什么模型都不会吃亏而且能保留可对比性。我一般这么处理from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 先用相关性粗筛掉冗余特征 corr_with_label df.drop(columns[TenYearCHD]).corrwith(df[TenYearCHD]).abs().sort_values(ascendingFalse) print(与标签相关性排序:\n, corr_with_label) # 相关性过低绝对值小于 0.01的特征可以考虑剔除 low_corr_cols corr_with_label[corr_with_label 0.01].index.tolist() df.drop(columnslow_corr_cols, inplaceTrue) print(剔除的极低相关特征:, low_corr_cols) # 划分数据集stratify 保证正负样本比例一致 X df.drop(columns[TenYearCHD]) y df[TenYearCHD] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)逻辑说明corrwith是快速粗筛的利器能一眼看出哪些字段和标签几乎没关系早删早省心。stratifyy特别关键——心血管数据里未发病样本通常远多于发病样本如果不分层抽样训练集和测试集的正负比例会漂移模型跑出来虚高。参数说明random_state42固定随机种子保证你重复运行结果一致这在写实验报告时是必须的test_size0.2是常规划分比例数据量小时可以改成 0.3让训练集更充裕。2.4 类别不平衡先别急着 SMOTE先看基线很多第一次做这类项目的人一上来就搞 SMOTE 过采样、欠采样结果模型在测试集上反而更差。原因很简单心血管风险数据本身正负样本比例虽然失衡但还没到不可救药的程度。你应该先做一件事——用y.value_counts()看分布。如果正负比在 2:1 到 5:1 之间建议先不处理直接用原始数据跑一个逻辑回归作为基线用 ROC-AUC 而非准确率来评估。只有在基线 AUC 明显偏低且负类样本确实占绝对多数时再考虑采样策略。这个判断顺序写进实验报告里比你堆一堆过采样代码更有说服力。3. 模型选型与训练从逻辑回归到梯度提升三套基线怎么选3.1 为什么首选项是逻辑回归而不是一上来就 XGBoost机器学习的课程设计里最忌讳的就是跳过传统模型直接上 XGBoost。逻辑回归在这个任务里有三个不可替代的作用一是可解释性强每个特征的系数直接对应风险方向——比如收缩压系数为正意味着血压越高风险越大二是作为基线模型它的 AUC 如果和 XGBoost 差得不多说明特征工程做得好数据里的信号被充分提取了三是它训练快、不容易过拟合适合拿来验证数据和代码链路是否打通。同样的训练集先跑逻辑回归再跑随机森林C 参数和正则化策略可以参考下面这套写法from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score # C 越小正则化越强这里用默认值先跑不急着调 lr_model LogisticRegression(max_iter1000, random_state42) lr_model.fit(X_train_scaled, y_train) # 预测概率、评估 AUC y_pred_proba_lr lr_model.predict_proba(X_test_scaled)[:, 1] y_pred_lr lr_model.predict(X_test_scaled) auc_lr roc_auc_score(y_test, y_pred_proba_lr) acc_lr accuracy_score(y_test, y_pred_lr) print(f逻辑回归 AUC: {auc_lr:.4f}, 准确率: {acc_lr:.4f}) # 打印特征系数方向寻找可解释性 coef_df pd.DataFrame({ feature: X.columns, coef: lr_model.coef_[0] }).sort_values(bycoef, ascendingFalse) print(特征系数排序:\n, coef_df)逻辑说明predict_proba返回的是二维数组[:, 1]取正类患病的概率coef_是逻辑回归的系数向量正负号直接告诉你风险方向。这一步对实验报告的可视化分析很有用比只贴一张混淆矩阵图有说服力得多。参数说明max_iter1000是因为默认值 100 在特征缩放后虽然通常够用但遇到惩罚项较大时可能不收敛设大一点消除这个隐患random_state固定后逻辑回归的结果完全可复现。3.2 随机森林调 n_estimators 和 max_depth 的边界在哪随机森林适合在这个场景里做第二基线因为它是天然的集成模型对数据的非线性关系捕捉能力比逻辑回归强。但有一件事要特别端正认知——随机森林并不天生比逻辑回归好尤其在样本量只有几千的情况下两者 AUC 可能非常接近。随机森林的参数调优核心就两个n_estimators和max_depth。前者对应树的棵数后者限制每棵树的复杂度防过拟合的关键在max_depth而不是n_estimators。from sklearn.ensemble import RandomForestClassifier # 先用默认参数跑一轮确定原始水平 rf_model RandomForestClassifier(n_estimators100, max_depthNone, random_state42) rf_model.fit(X_train, y_train) y_pred_proba_rf rf_model.predict_proba(X_test)[:, 1] auc_rf roc_auc_score(y_test, y_pred_proba_rf) print(f随机森林 AUC: {auc_rf:.4f}) # 简单网格搜索只调 max_depthn_estimators 固定 200 from sklearn.model_selection import GridSearchCV param_grid { max_depth: [3, 5, 8, 10], min_samples_leaf: [2, 5, 10] } rf_search GridSearchCV( RandomForestClassifier(n_estimators200, random_state42), param_grid, cv5, scoringroc_auc ) rf_search.fit(X_train, y_train) print(最优参数:, rf_search.best_params_) print(最优交叉验证 AUC:, rf_search.best_score_)逻辑说明树模型不需要标准化所以这里直接用未缩放的X_train训练而逻辑回归用X_train_scaled。两个模型一个走原始特征一个走缩放特征本身就是一种对比。参数说明GridSearchCV里cv5是 5 折交叉验证比单次划分更可靠scoringroc_auc是因为在类别不平衡场景下AUC 比准确率更适合做模型选择的依据。random_state42保证你复制代码时得到同样的网格搜索结果。3.3 XGBoost要不要上什么时候上怎么控制不翻车XGBoost 通常作为这三套模型里的最终上限来跑。它在结构化数据上确实强但如果你拿到的数据只有两三千条XGBoost 很容易在验证集上表现好、在测试集上反而不如随机森林——这就是典型的过拟合。所以我的原则是只有当前面两个模型 AUC 已经比较理想并且有剩余时间调参时再上 XGBoost。而且参数不要全调优先max_depth和learning_rate两个就让位的参数n_estimators固定在一个足够大的值靠早停来收尾。from xgboost import XGBClassifier # 固定 n_estimators500用 early_stopping 防止过头 xgb_model XGBClassifier( n_estimators500, max_depth3, learning_rate0.05, subsample0.8, colsample_bytree0.8, random_state42, eval_metricauc, use_label_encoderFalse ) xgb_model.fit( X_train, y_train, eval_set[(X_test, y_test)], early_stopping_rounds20, verboseFalse ) y_pred_proba_xgb xgb_model.predict_proba(X_test)[:, 1] auc_xgb roc_auc_score(y_test, y_pred_proba_xgb) print(fXGBoost AUC: {auc_xgb:.4f}) print(最优树数量:, xgb_model.best_iteration)逻辑说明early_stopping_rounds20表示连续 20 轮测试集 AUC 没有提升就停止训练这是防过拟合最实用的一道防线eval_set传入测试集作为验证数据这样best_iteration能给出真正最优的树棵数而不是靠手摸n_estimators。参数说明subsample和colsample_bytree分别控制样本采样和特征采样的比例设 0.8 是常见保守值learning_rate0.05偏小配合更多轮数能提升最终精度use_label_encoderFalse是为了屏蔽新版 XGBoost 的弃用警告不影响结果。3.4 三模型对比的评估视角别只看准确率三套模型跑完评估报告里要同时放 AUC、准确率、召回率、F1 四样东西尤其是召回率。心血管风险预测的场景里漏判一个高风险患者的代价远大于误报一个低风险的人所以召回率比准确率更值得关注。代码里补一段classification_report就够了from sklearn.metrics import classification_report, confusion_matrix # 以 XGBoost 为例输出完整分类指标 print(classification_report(y_test, xgb_model.predict(X_test))) # 混淆矩阵看漏诊数左下角 False Negative tn, fp, fn, tp confusion_matrix(y_test, xgb_model.predict(X_test)).ravel() print(f漏诊人数: {fn}, 误报人数: {fp}) print(f召回率: {tp / (tp fn):.4f})逻辑说明classification_report一行代码输出精度、召回率、F1足够写进实验报告。混淆矩阵里fn是漏诊——真实患病但模型没预测出来在医疗场景里这是最需要关注的那个数字。4. 常见问题与避坑特征泄漏、过拟合和数据泄漏的三类翻车现场4.1 特征泄漏把未来信息塞进训练集AUC 虚高到离谱现象模型 AUC 跑出 0.98 甚至 0.99测试集上几乎完美但换一批新数据就崩成 0.6 上下。原因这是特征泄漏最典型的信号。在心血管数据场景里最常见的泄漏是把标签相关的衍生字段留在了特征里——比如某些数据集中会直接包含是否接受过降压治疗且该字段仅对已确诊患者有值模型实际是在学谁已经被确诊了而不是在预测未来风险。另一种泄漏是你在填充缺失值时不小心用了包含测试集信息的统计量比如先fit_transform全量数据再划训练测试集。解决标准做法是先用train_test_split划分数据再在训练集上scaler.fit_transform()、在测试集上scaler.transform()。同一份数据里所有统计类填充均值、中位数都必须在训练集上计算然后应用到测试集。我在第 2 章的代码里已经按这个顺序写了但很多人会手滑把fit_transform用在全量数据上。自查方法很简单打印每列特征和标签的相关性如果某个特征和标签的相关系数高到不合理的程度比如 0.6 以上意味着大概率泄漏了。4.2 过拟合在验证集上不明显一上测试集就暴露现象随机森林max_depth不限制训练集 AUC 0.99验证集 AUC 0.81测试集 AUC 只有 0.76。原因树模型在不限制深度时会逐步记忆训练集中的噪声样本。交叉验证分数看起来正常但泛化到新数据时立刻退化。XGBoost 那边更隐蔽——n_estimators设得太大又没开早停同样会陷入这个坑。解决随机森林先固定max_depth5跑一遍再逐步放宽XGBoost 必须开early_stopping_rounds不要手改n_estimators去碰运气。还有一个容易被忽略的点各类算法在GridSearchCV里的cv折数太少比如cv3会让验证结果方差变大建议至少cv5。我踩过最深的坑就是把cv3和max_depthNone组合最后得到的最优参数在测试集上惨不忍睹。4.3 数据分割没加 stratify正负比例漂移导致模型偏置现象两次运行同一份代码准确率相差超过 5 个百分点且第二次运行时模型几乎把所有样本都预测为负类。原因train_test_split默认是随机划分样本量几千时正负比例可能有明显波动。如果某次划分里训练集的正类比例掉到 5% 以下模型会倾向学一个全部预测为负类的傻瓜分类器用准确率看反而很高但实际完全没用。解决分割时加stratifyy让训练集和测试集里的正负比例与原始数据保持一致。这个是写代码时就要养成的习惯不要等结果不对劲再回头补。另外提醒一句stratify在cross_val_score里同样适用传cvStratifiedKFold(n_splits5, shuffleTrue, random_state42)比默认的 KFold 更稳妥。4.4 缺失值填充的顺序错了等于白填现象填完缺失值后逻辑回归的训练过程报错或 AUC 不升反降。原因很多人习惯先做StandardScaler再填充缺失值。但StandardScaler无法处理 NaN会直接报错另外在填充之前做缩放缩放后的数值再填充中位数会让填充值本身失真。解决顺序必须是——先填充缺失值再做特征缩放再做数据分割。这个流程里有一个微妙的点如果用中位数填充这个中位数应该从训练集算出来还是全量数据算出来严格来说应该是训练集但很多教程为了方便直接用全量数据的中位数。如果你的数据量够大全量中位数和训练集中位数误差很小课程设计里可以容忍但如果想要严谨就手动拆训练集后再逐列填充训练集的中位数。4.5 随机森林和 XGBoost 的输出概率直接对比量纲不在一个维度上现象逻辑回归输出的风险概率范围是 0.40.7 左右随机森林输出的概率范围是 0.50.9硬拿同一个阈值 0.5 去切导致模型间的可比性很差。原因不同模型对概率的校准程度不同逻辑回归天然经过 sigmoid 压缩分布偏紧树模型输出的概率是叶子节点的样本比例分布偏宽。直接对比两者的概率数值没有意义。解决统一用 AUC 做模型对比不用概率绝对值做对比。如果需要实际输出风险等级可以对每个模型单独画 ROC 曲线在曲线上找最佳阈值约登指数 J 灵敏度 特异度 - 1 最大化的点而不是死守 0.5。这个细节在第五章的阈值调优里具体展开。5. 从模型到系统阈值调优、特征重要性与接口封装5.1 用约登指数找最佳阈值而不是死守 0.5心血管风险预测是典型的非对称代价场景——漏诊的代价远大于误报。默认的 0.5 阈值是为对称代价设计的放在这里并不合适。我从实际操作的角度讲一下怎么调用 ROC 曲线上每个阈值对应的灵敏度和特异度计算约登指数取最大值对应的阈值作为最终切分点。这样能在不改变模型的前提下尽量在漏诊和误报之间找到平衡。from sklearn.metrics import roc_curve import numpy as np # 以 XGBoost 的概率输出为例 fpr, tpr, thresholds roc_curve(y_test, y_pred_proba_xgb) youden_j tpr - fpr # 找约登指数最大的下标注意阈值数组长度比 fpr 少 1 best_idx np.argmax(youden_j) best_threshold thresholds[best_idx] print(f最佳阈值: {best_threshold:.3f}) print(f对应灵敏度: {tpr[best_idx]:.3f}, 特异度: {1 - fpr[best_idx]:.3f}) # 用新阈值切分对比默认 0.5 的漏诊数量 y_pred_default (y_pred_proba_xgb 0.5).astype(int) y_pred_optimal (y_pred_proba_xgb best_threshold).astype(int) fn_default confusion_matrix(y_test, y_pred_default).ravel()[3] tn, fp, fn_opt, tp confusion_matrix(y_test, y_pred_optimal).ravel() print(f默认阈值漏诊: {fn_default}, 最佳阈值漏诊: {fn_opt})逻辑说明roc_curve返回的三个数组长度不一致thresholds比fpr少一个元素所以直接没下标取约登指数时注意线别乱。这段代码输出的最佳阈值一般会在 0.30.45 之间相比 0.5 会略微让模型更敏感漏诊率下降误报率适度上升这才是医疗场景真正想要的平衡点。5.2 特征重要性把黑匣子拆开给用户看做系统设计时光有预测结果不够用户会问为什么我被判定为高风险。对树模型可以直接用feature_importances_对逻辑回归则系数、可信。树模型的特征重要性是基尼不纯度下降的累积逻辑回归是系数的绝对值两者本质不同不能混在一起排序。import matplotlib.pyplot as plt # 随机森林的特征重要性 importance pd.DataFrame({ feature: X.columns, importance: rf_model.feature_importances_ }).sort_values(importance, ascendingFalse).head(10) plt.figure(figsize(8, 5)) plt.barh(importance[feature], importance[importance]) plt.gca().invert_yaxis() plt.xlabel(Importance) plt.title(Top 10 Feature Importance - Random Forest) plt.tight_layout() plt.show()这块投入产出比很高特征重要性图放进实验报告或者系统界面里直接成为结果解释模块的核心素材。我一直觉得课程设计能不能拿到高分往往不在准确率数字本身而在你能否解释模型为什么做出这样的判断。5.3 把模型封装成一个可复用的风险评分函数系统设计的最后一步是让模型脱离 Jupyter Notebook 环境变成可以被调用的函数或接口。不需要上 Flask简单封装成 Python 类即可方便后续扩展成 Web API。核心是三个方法加载模型、预处理输入、输出风险等级。import joblib import numpy as np class CardiovascularRiskPredictor: def __init__(self, model_path, scaler_path, feature_names, threshold0.5): self.model joblib.load(model_path) self.scaler joblib.load(scaler_path) self.feature_names feature_names self.threshold threshold def predict_risk(self, patient_data: dict): df_input pd.DataFrame([patient_data])[self.feature_names] df_input self._transform(df_input) prob self.model.predict_proba(df_input)[0, 1] if prob self.threshold: level 高风险 elif prob self.threshold * 0.6: level 中风险 else: level 低风险 return { risk_probability: round(float(prob), 4), risk_level: level, advice: self._get_advice(level) } def _transform(self, df): return self.scaler.transform(df) def _get_advice(self, level): advice_map { 高风险: 建议尽快就诊进一步做冠脉相关检查。, 中风险: 建议控制饮食、加强运动3 个月后复查。, 低风险: 保持当前生活方式每年常规体检即可。 } return advice_map[level] # 使用示例 predictor CardiovascularRiskPredictor( model_pathxgb_model.pkl, scaler_pathscaler.pkl, feature_namesX.columns.tolist(), thresholdbest_threshold ) demo_patient { age: 56, male: 1, currentSmoker: 1, cigsPerDay: 20, totChol: 245.0, sysBP: 142.0, diaBP: 88.0, BMI: 27.5, heartRate: 78.0, glucose: 92.0 } print(predictor.predict_risk(demo_patient))逻辑说明joblib是 scikit-learn 官方的模型持久化工具model_path是训练好后保存的模型文件。predict_risk接收一个字典按照模型训练时的特征顺序组装 DataFrame避免因列顺序错乱导致预测结果失真。参数说明threshold直接使用前面约登指数算出来的值而不是硬编码 0.5feature_names在训练时存好封装阶段按顺序取列是最容易出错的点——很多人在这一步手打特征名少一个多一个都会报错或静默出错。把这些字段统一管理后整个系统的工程化程度就完整了从数据、训练、评估到预测全部可追溯。从那以后我每次做这类项目都会强迫自己走一遍完整流程先分割数据再填充缺失值固定随机种子加 stratify最后用约登指数找阈值再封装模型。这套流程踩坑踩出来的每一步背后都有一次翻车的经验在支撑。希望这份拆解能帮你少走几个弯路。本文还有配套的精品资源点击获取