很多人第一次接触“用Python做心脏病预测”这个方向时第一反应都是“这得挂三甲医院数据库、搞一堆高深算法吧”。其实真不是。只要手里有一份字段规整的临床表格整个流程在普通笔记本上就能完整跑通数据清洗、特征工程、训练模型、评估效果、解释预测结果。这篇文章把这条链路从头到尾拆给你看每一步都附上可运行的全代码顺便把我踩过的几个坑一块儿说清楚。它适合有Python基础、想拿真实结构化数据做一次完整机器学习实践的人从事医学信息或健康数据相关工作的人也能当入门参考。1. 一份临床表格里到底藏着什么字段与数据形态1.1 心脏病预测任务最常用的数据字段先说数据来源。做演示我们不用医院里的真实数据原因后面专门讲先看一份临床上比较经典的心脏病筛查数据集长什么样。它的核心预测目标是判断该患者是否存在心脏疾病一般用target列标记1代表患病0代表健康。特征列覆盖了患者基础信息、体征指标、心电图结果和既往史这几大类。字段名含义常见取值类型age年龄29-77连续sex性别1男性0女性名义cp胸痛类型0-3四种类型名义trestbps静息血压(mmHg)94-200连续chol血清胆固醇(mg/dl)126-564连续fbs空腹血糖是否大于120mg/dl1是0否二值restecg静息心电图结果0-2名义thalach最大心率71-202连续exang运动是否诱发心绞痛1是0否二值oldpeak运动相对休息时的ST段压低值0-6.2连续slopeST段斜率0-2名义ca主要血管数量(造影)0-4计数thal地中海贫血类型0-3名义第一次接触这批数据时最容易犯的错是看到所有列都是数字就直接全部扔给模型说“开始训练”。这绝对不行。这里面的cp、restecg、slope、thal都是典型的类别变量数字只是编号0、1、2之间没有大小关系。还有ca是“主要血管数量”它虽然有数值意义但分布非常偏大量样本是0偶尔出现3、4这种字段在清洗时要格外留意。1.2 为什么我用公开数据演示而不是“临床真实数据”可能有朋友觉得标题都写了“搜集临床日常数据”那肯定是用真实医院数据效果才真实。这里必须浇一盆冷水非学术机构、非授权环境下拿真实患者数据做建模在数据伦理和合规上都有很大问题患者隐私保护、数据脱敏、知情同意每一个环节都是硬门槛。真实医疗场景里的项目需要过伦理审批、数据使用协议、脱敏流程周期是按月算的。所以这篇文章用的是领域内公开的脱敏学术数据字段结构和真实临床记录高度接近用来学习建模方法完全够用。等你把代码跑通了再拿到有授权、已脱敏的真实数据无非是替换文件路径、调整几个列名的事方法论是通用的。我把项目目录结构先定下来heart-prediction/ ├── data/ │ └── heart.csv ├── requirements.txt └── main.py1.3 环境准备版本和依赖一次装齐我的建议是新建一个虚拟环境别把包装到全局不然过两个月依赖冲突会让人头疼。requirements.txt内容如下pandas1.5.0 numpy1.23.0 scikit-learn1.2.0 matplotlib3.6.0 seaborn0.12.0 shap0.41.0安装命令就是标准的pip install -r requirements.txt。重点说下shap这个库它有些版本对matplotlib版本比较敏感我之前遇到过一次装完shap后画图报“AttributeError”的奇葩问题后来把两个库都升到较新版本就好了。如果你在Windows上装shap遇到编译报错直接换conda安装一般能省很多时间。2. 数据清洗与特征工程决定模型上限的“脏活累活”2.1 第一步永远是先看缺失和异常值而不是直接跑模型拿到CSV文件后我先养成了一个习惯不急着head()而是先看每一列的缺失率、取值分布和异常值。对这份数据来说有两个“经典麻烦”第一ca和thal两列存在缺失值。第二chol和trestbps里可能出现0值这在临床上是不合理的血脂和血压不可能是0基本可以断定是录入错误或者缺省值的另一种表达方式。我的处理规则是chol和trestbps的0值当成缺失值处理oldpeak的0是有效值因为“运动后ST段无压低”本身就是正常表现ca的0更是有效值代表造影没有发现明显血管异常绝对不能把0替换成缺失。import pandas as pd import numpy as np df pd.read_csv(data/heart.csv) print(原始缺失情况\n, df.isnull().sum()) # 将不合常理的0值视为缺失仅针对血压、胆固醇这类生理指标 df[trestbps] df[trestbps].replace(0, np.nan) df[chol] df[chol].replace(0, np.nan) # 连续变量用中位数填充类别变量用众数填充 num_cols [age, trestbps, chol, thalach, oldpeak, ca] cat_cols [sex, cp, fbs, restecg, exang, slope, thal] for col in num_cols: df[col] df[col].fillna(df[col].median()) for col in cat_cols: df[col] df[col].fillna(df[col].mode()[0])为什么不连续变量统统用均值填充因为像ca这种右偏分布均值会被少量大值拉高对整体代表性和后续标准化都不友好中位数更稳健。分类变量用众数本质上是“拿最常见的情况去补缺失”这也算一种合理的朴素策略。2.2 类别变量编码为什么独热编码几乎总是首选数据里cp、restecg、slope、thal这类名义变量如果你直接当成整数放进线性模型模型会认为取值1和取值2之间差1、取值2和取值3之间差1这是在强行给类别编造“距离”非常误导。典型做法是独热编码把每个类别拆成一列0/1。有人为了省事给这些列统统做LabelEncoder这在树模型里问题不大但逻辑回归里很可能带来错误信号。我建议直接用scikit-learn的ColumnTransformer在管道里完成编码和标准化一套代码同时搞定两种类型变量。from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline preprocessor ColumnTransformer( transformers[ (num, StandardScaler(), num_cols), (cat, OneHotEncoder(dropfirst), cat_cols) ] )dropfirst的意思是每个类别变量去掉第一个哑变量列这样能避免“虚拟变量陷阱”防止线性模型因为特征完全共线而出现系数解释混乱。不过要注意这会让后面的SHAP特征名里少一部分字段解释时心里有数就行。2.3 数据集划分stratify这个参数一定要记得机器学习里有个很多教程都不强调的细节划分训练集和测试集时要分层抽样。心脏病人群里患病人数往往比健康人略少如果随机切分可能测试集里的患病比例忽高忽低导致每次评估结果波动很大。这里用stratifyy强制让训练集和测试集中正负例保持相同比例这是医学预测项目里最基础但也最容易被忽略的一步。from sklearn.model_selection import train_test_split X df.drop(columns[target]) y df[target] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print(训练集患病率, y_train.mean().round(3)) print(测试集患病率, y_test.mean().round(3))random_state42是固定随机种子方便复现。如果你希望自己实验里每次结果都完全一致所有可能引入随机性的地方都建议设置这个参数包括后面的交叉验证和随机森林。2.4 相关性初探看热力图能提前发现什么正式建模前我习惯画一张相关性热力图不是为了多么精确地指导特征选择而是快速扫一眼哪些特征和目标关系密切哪些特征之间高度相关。import matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize(10, 8)) corr df.corr() sns.heatmap(corr, annotTrue, fmt.2f, cmapRdBu_r, center0) plt.title(特征相关性矩阵) plt.tight_layout() plt.savefig(corr_matrix.png, dpi150)从很多公开数据集的常规结果看thalach最大心率和目标呈现明显正相关oldpeak、exang则和目标负相关这符合临床直觉心功能较好的患者运动心率能达到更高水平ST段压低越明显风险越高。相关性热力图还能帮你发现共线性特征如果两个特征的相关系数超过0.8线性模型里最好二选一否则系数方差会变大影响稳定性。3. 模型训练与评估基线模型、树模型和“准确率陷阱”3.1 先写一个统一的评估函数模型对比最忌讳一个模型套一套评估代码那样很难公平比较。我先固定出五折交叉验证逻辑训练集上算AUC测试集上输出分类报告、AUC和混淆矩阵。AUC能综合评价模型把患病样本排在前面的能力比单纯看准确率信息量大得多。from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.metrics import classification_report, roc_auc_score, roc_curve, confusion_matrix def evaluate_model(model, X_train, X_test, y_train, y_test, name): model.fit(X_train, y_train) y_pred model.predict(X_test) y_prob model.predict_proba(X_test)[:, 1] print(f {name} ) print(classification_report(y_test, y_pred, target_names[健康, 患病])) print(测试集AUC, round(roc_auc_score(y_test, y_prob), 4)) cv cross_val_score( model, X_train, y_train, cvStratifiedKFold(n_splits5), scoringroc_auc ) print(f训练集5折AUC{cv.mean():.4f} ± {cv.std():.4f}) return y_prob这里有个原则交叉验证只在训练集上做不然测试集的信息会被“偷看”后面评估结果虚高。训练集内部切五份换着训练和验证最后平均的AUC更能反映模型稳定性。3.2 逻辑回归当基线小样本、强解释、易部署第一个模型我几乎总是选逻辑回归。原因很简单在这个样本规模大概两三百人的数据集上逻辑回归的训练时间可以忽略不计特征和结果的关系能直接看系数后续如果要写论文或做汇报逻辑回归的结果最容易向非技术背景的人解释。另外逻辑回归自带正则化对共线性有一定的容忍度。这里用C0.1把正则强度调高一点帮助抑制过拟合加了class_weightbalanced告诉模型两类样本的数量差异让模型更重视少数类。from sklearn.linear_model import LogisticRegression lr_pipe Pipeline([ (prep, preprocessor), (lr, LogisticRegression(max_iter1000, C0.1, class_weightbalanced)) ]) lr_prob evaluate_model(lr_pipe, X_train, X_test, y_train, y_test, 逻辑回归)跑完后你会看到逻辑回归训练集上5折AUC和测试集AUC通常都能到0.85以上这个表现对“新手友好”的基线模型来说已经很能打了。不要一上来就调参调到极致先确认基线稳定再考虑上复杂模型。3.3 随机森林树模型的优势与最常翻车的点随机森林在处理这种有类别变量、变量间关系复杂的表格数据时表现通常不错而且不用做太多特征预处理的精细化操作。但我的经验是随机森林在这个小数据集上极其容易过拟合。如果你看到训练集AUC接近1.0测试集AUC却掉了0.1不止多半是树的深度和数量没控制好。from sklearn.ensemble import RandomForestClassifier rf_pipe Pipeline([ (prep, preprocessor), (rf, RandomForestClassifier( n_estimators300, max_depth6, min_samples_leaf5, random_state42, n_jobs-1 )) ]) rf_prob evaluate_model(rf_pipe, X_train, X_test, y_train, y_test, 随机森林)max_depth6和min_samples_leaf5是我在这个数据集上实践的保守设置。限制树的生长深度和叶子最小样本量能明显降低方差而不是靠盲目加树数量。把树加到一千棵对性能提升微乎其微但训练时间线性增长。如果你追求更高的上限可以再试梯度提升类模型但那个调参空间更大而且对缺失值、类别不平衡更敏感。我的建议是先把随机森林吃透它的调参思路能覆盖掉大部分表格数据项目。3.4 准确率陷阱医疗筛查场景更应该看重什么很多初学者拿到模型开口就是“准确率多少”。但去医院问任何一个医生他们最怕的是什么漏诊。一个健康人被误判为患病后续检查复查还能纠正一个真正患病的人被误判为健康放回家了这是重大医疗事故。所以在心脏病筛查模型里召回率敏感性通常比准确率重要得多。召回率衡量的是“真正的患病者里有多少被识别出来了”。我习惯打印混淆矩阵四个数字就能把问题讲清楚。def plot_confusion(y_test, y_pred, name): cm confusion_matrix(y_test, y_pred) plt.figure() sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabels[预测健康, 预测患病], yticklabels[实际健康, 实际患病]) plt.title(f{name} - 混淆矩阵) plt.tight_layout() plt.savefig(fcm_{name}.png, dpi150) plot_confusion(y_test, lr_pipe.predict(X_test), 逻辑回归) plot_confusion(y_test, rf_pipe.predict(X_test), 随机森林)如果模型默认0.5阈值下召回率不够高可以通过调整决策阈值把门槛降低。比如把“患病概率大于0.3就判定为患病”的阈值往上提升召回率代价是误诊变多这在筛查场景里是合理的取舍。from sklearn.metrics import precision_recall_curve precision, recall, thresholds precision_recall_curve( y_test, rf_prob )用Precision-Recall曲线观察不同阈值下精确率和召回率的变化再结合业务需求选一个合适的操作点。这比死守0.5规则要专业得多。下面这张ROC曲线的绘制代码也可以直接复用def plot_roc(y_true, y_prob_list, label_list): plt.figure(figsize(7, 6)) for prob, label in zip(y_prob_list, label_list): fpr, tpr, _ roc_curve(y_true, prob) auc roc_auc_score(y_true, prob) plt.plot(fpr, tpr, labelf{label} (AUC{auc:.3f})) plt.plot([0, 1], [0, 1], k--, lw1) plt.xlabel(假阳性率) plt.ylabel(真阳性率) plt.legend() plt.tight_layout() plt.savefig(roc_compare.png, dpi150) plot_roc(y_test, [lr_prob, rf_prob], [逻辑回归, 随机森林])4. 模型可以不是黑箱特征重要性与SHAP解释4.1 用排列重要性代替“看一眼feature_importances_”随机森林自带feature_importances_很多教程直接打印了事。但树的特征重要性有个毛病数值型特征往往会被高估类别型独热编码后的多个稀疏列则容易被低估。相比之下排列重要性的思路是“打乱某一列数据看模型指标掉多少掉得越多说明这列越重要”它更贴近真实预测贡献。from sklearn.inspection import permutation_importance X_train_trans preprocessor.fit_transform(X_train) X_test_trans preprocessor.transform(X_test) feature_names preprocessor.get_feature_names_out() rf_clf RandomForestClassifier( n_estimators300, max_depth6, min_samples_leaf5, random_state42, n_jobs-1 ) rf_clf.fit(X_train_trans, y_train) result permutation_importance( rf_clf, X_test_trans, y_test, n_repeats10, random_state42, scoringroc_auc ) import numpy as np imp_df pd.DataFrame({ 特征: feature_names, 重要性: result.importances_mean, 标准差: result.importances_std }).sort_values(重要性, ascendingFalse) print(imp_df.head(10))注意一点排列重要性用了测试集这没问题因为我们不是用它来调参选特征只是观察模型在没见过的数据上的依赖情况。跑出来的结果里oldpeak、thalach、cp这些临床特征通常排名靠前这跟上一节相关性分析的结论是对得上的。看到模型依赖的核心变量符合医学直觉心里会踏实很多。4.2 SHAP解释每一个患者的预测结果排列重要性只能告诉你“整体上哪些特征重要”但无法回答一个更具体的问题“为什么这个样本被判为患病”SHAP可以做到这件事它给每个特征、每个样本都分配一个贡献值正的贡献推动模型往1预测负的贡献往0预测。SHAP值大小不仅反映重要性还反映作用方向。import shap explainer shap.TreeExplainer(rf_clf) shap_values explainer.shap_values(X_test_trans)这里的shap_values对于二分类问题取下标1对应“患病”类别的解释。画summary图可以看到每个特征对预测结果的整体影响分布shap.summary_plot(shap_values[1], X_test_trans, feature_namesfeature_names)图里每行一个特征点位置代表特征值大小颜色越红表示该特征值越高横坐标代表SHAP值越往右侧表示越推动模型预测为患病。oldpeak一眼看过去就是典型的“右侧红点、左侧蓝点”说明ST段压低数值越大患病风险预测越高方向非常直观。4.3 单样本解释把模型说的话翻译成人话我再拿一个测试集中的具体样本演示force_plot这个图在小范围交流汇报时非常好用。先挑一个预测为患病的样本idx 5 shap.force_plot( explainer.expected_value[1], shap_values[1][idx, :], X_test_trans[idx, :], feature_namesfeature_names, matplotlibTrue )force_plot展示的是基线风险所有样本的平均预测值加上各特征的推力组合成最终的预测概率。你看完会直观感受到某个样本被判为高风险不是某个单因素决定的而是oldpeak高、thalach低、cp类型特殊这些因素共同作用的结果。这种表达能力正是医疗场景里医生和患者都很需要的东西纯黑箱模型很难获得信任。SHAP唯一的“坑”是环境依赖和画图速度样本量大时summary_plot会很慢建议先随机抽样几百条画图另外如果你的预处理里有独热编码解释时看到的是cat_cp_2这类拆分后的特征名需要结合编码前的含义做对应。5. 把这条流程搬进真实医疗场景前必须想清楚的三件事5.1 “搜集数据”只是开始数据治理才是成本大头标题里写着“搜集临床日常数据”但实际做过医疗数据项目的人会告诉你最耗时间的往往不是建模而是数据治理。真实临床数据源里有各种让人崩溃的问题不同科室记录口径不同同一个“血压”字段有的记的是入院时、有的记的是用药后单位不统一胆固醇有的是mmol/L有的是mg/dL重复随访记录未去重时间字段格式五花八门。我的建议是在进入模型前用一套固定的数据质量规则卡一遍这些指标缺失率超过20%的字段先讨论要不要纳入重复样本按患者ID和时间戳去重连续变量画分布确认没有极端异常值。这些工作在公开数据集上体现不出来但真实项目里能帮你省下大量返工的时间。5.2 合规和伦理不是“走流程”是项目成立的前提医疗AI和普通代码项目有个本质区别它是一个受严格监管的领域。哪怕模型在历史数据上AUC高达0.95也不代表可以直接用于临床。真实项目必须通过医院伦理委员会的审批确保数据使用范围、患者知情同意、脱敏标准都合规。模型做出来之后定位应当是“辅助筛查工具”而不是“自动诊断系统”最终判断权必须留给医生。回到这篇文章的演示用公开脱敏数据训练模型是安全的但如果你打算把同样的流程用到实习单位或工作单位的真实患者数据上一定要先确认自己是否有权限处理这些数据没有授权的情况下连脱敏后的拷贝都不应该进个人电脑。这个底线需要时刻牢记。5.3 从“能跑通”到“能上线”部署和监控的基本思路本地跑通代码和真正变成可用的服务之间还差一个部署环节。最常规的做法是用FastAPI把训练好的模型包成一个HTTP接口前端页面或院内系统通过请求把患者特征传过来接口返回患病概率以及主要风险因素。from fastapi import FastAPI from pydantic import BaseModel import joblib app FastAPI() # 训练结束后保存模型 # joblib.dump(rf_pipe, heart_model.joblib) model joblib.load(heart_model.joblib) class Patient(BaseModel): age: float sex: int cp: int # ... 其余字段 app.post(/predict) def predict(p: Patient): data pd.DataFrame([p.dict()]) prob model.predict_proba(data)[0, 1] return {probability: round(prob, 4)}上线之后真正麻烦的是漂移监控。真实患者的年龄结构可能随季节变化检验试剂的批次不同可能让胆固醇指标产生系统偏移这些都会让线上模型的准确性一点点下降。所以每隔一段时间要重新评估模型在新数据上的表现必要时用增量数据重新训练。做一个能上线的心血管风险辅助评估工具不难难的是持续维持它的可靠性和合规性。整个流程跑下来我个人最大的体会是模型训练本身其实只是冰山一角数据质量和解释能力才是决定一个医疗预测项目能不能被人接受的关键。初学者最容易把精力全都花在调参上但真正能让模型在现实里站稳脚跟的是每一步都想清楚“为什么”多问自己数据从哪来、错误如何产生、预测结果如何被人理解。先把这个基本功打扎实以后再面对更复杂的数据集你也会觉得轻车熟路。