机器学习心脏病预测全流程:特征工程、交叉验证与SHAP解释
2026/9/11 2:58:17 网站建设 项目流程

前两天帮人改一个期末项目,打开压缩包时,里面躺着一个经典的“机器学习心脏病预测”作业:三百多条数据,一个跑完准确率就交差的Notebook。我忽然想起自己第一次做这个题目时也是这个状态——模型像模像样地输出0.9的准确率,可当被追问“到底是哪些指标在推高这个人的风险”时,整个人直接愣在当场。这篇文章想把完整链路重讲一遍:从数据集结构、EDA、特征工程、模型选型,到评估指标、SHAP解释和网页Demo落地。如果你正卡在课程设计、期末复习,或者单纯想找一个能写进简历的机器学习项目,这篇内容应该对你有用。

1. 这个数据集到底在预测什么:任务定义与数据“潜规则”

1.1 为什么心脏病是机器学习项目的“黄金题源”

很多人入门机器学习时先拿波士顿房价练手,但房价预测是回归任务,做来做去只是在拟合一条曲线。心脏病发作分析则是一个标准的二分类问题:根据体检指标判断一个人是否已经存在心脏病高风险。它比房价数据更有“嚼头”的地方在于,特征里同时有数值型(年龄、血压、胆固醇)、类别型(胸痛类型、心电图结果)和带缺失值的列,模型评估需要看混淆矩阵、敏感度、特异度、AUC,最后还能用SHAP做解释。

更重要的是,这个问题的业务意义足够直白。做完之后你可以跟别人说“我做了个心脏病风险预测模型”,而不是“我做了一个房价预测模型”——前者天然自带可解释性和落地场景,在课程答辩和面试里都更容易聊出深度。

1.2 字段逐个拆解:拿到三百多行数据先别急着跑

这个数据集的常见版本通常有303条样本、13个输入特征和1个目标列。如果你只用df.info()看一眼就开跑,大概率会忽略很多细节。我第一次做的时候连“cp”是胸痛类型都没搞明白,直接把0到3当成连续数值丢进逻辑回归,结果系数解释起来极其别扭。

下面是我这份版本里的字段语义,建议建模前先把这张表印在脑子里:

字段名含义建模时建议类型
age年龄数值型
sex性别(1=男性,0=女性)二分类
cp胸痛类型(0-3)类别型
trestbps静息血压(mm Hg)数值型
chol血清胆固醇(mg/dl)数值型
fbs空腹血糖是否大于120 mg/dl二分类
restecg静息心电图结果(0-2)类别型
thalach运动时最大心率数值型
exang运动是否诱发心绞痛二分类
oldpeak运动相对休息时ST段压低值数值型
slope峰值运动ST段斜率(0-2)类别型
ca荧光透视检查显示的主要血管数量(0-3)数值/类别型
thal地中海贫血类型(1-3)类别型
target是否患病(1=是,0=否)目标列

注意cprestecgslopethal虽然读进来是数字,但它们本质是等级或类别。如果不做处理直接当成连续特征喂给线性模型,等于默认了数值间距的线性关系,这在医学指标里往往站不住脚。

1.3 读入数据时最容易翻车的两个点

第一点是占位符。很多从公开渠道下载到的版本里,cathal两列夹杂着问号,比如“?”。pd.read_csv()会把整列读成字符串对象,后续算均值、跑模型时直接报错。我第一次跑的时候被打了个措手不及,最后排查半天才发现是脏数据在捣鬼。

处理方式很简单:

import pandas as pd import numpy as np data = pd.read_csv("heart.csv") data = data.replace("?", np.nan) data["ca"] = data["ca"].astype(float) data["thal"] = data["thal"].astype(float)

第二点是看漏类别分布。拿到数据后先对cathalcp做一次value_counts(),能直观发现缺失情况和取值是否集中在某个档位。比如ca大多数人都是0,少数是1、2、3,这种情况下如果把缺失直接填0,其实隐含了“这条记录没做检查=没有血管病变”的假设,这个假设未必成立,但你心里必须有数。

2. 可视化阶段挖出的几个关键信号:胸痛和最大心率不是玄学

2.1 目标列有没有失衡:这个数据集“过分平衡”了

先看目标分布。我这版数据里target=1大概占51%,target=0占49%,可以说非常平衡。这对建模训练是个好事,因为不用一上来就处理严重的类别不平衡问题。

但这里要泼一盆冷水:真实世界的心血管疾病筛查中,患病率远没有这么高。很多体检场景下阳性率可能只有5%甚至更低。这个数据集之所以平衡,是因为当初做病例对照研究时专门匹配了阴性样本,而不是流行病学调查中的自然发生率。所以你在课程项目里可以忽略类别不平衡,但心里要门儿清:真实场景里准确率会严重失真,后面我细说。

2.2 年龄和血压:直觉可能骗你

我画了年龄与目标关系的箱线图,发现50岁左右的患病风险开始往上走,但四五十岁这个区间并不像“50岁以上必然高风险”那样一刀切。事实上,年轻组里也有不少阳性样本,这提醒我们:单看年龄无法做判断,必须和其他指标组合。

血压和胆固醇也有类似问题。许多初学者会默认“血压越高风险越高”,但从相关系数上看trestbpschol与目标的相关性并不强,因为高血压和高胆固醇更像是长期风险因素,而数据集里的特征采集的是某一时点的静态数值,它的解释力被其他更直接的指标压过了。这也解释了为什么EDA阶段一定要做多变量交叉,而不是盯着单变量下结论。

2.3 三条与标签相关性最高的线索

我这次跑出来的相关性排名里,最抓眼的三条是:

  • thalach(最大心率)与target正相关,大约在0.42左右。直觉上会觉得“心率快不是坏事吗”,这里的thalach不是静息心率,而是运动时能达到的最大心率。它代表心脏的代偿能力,最大心率上不去往往提示心脏功能储备差,反而是风险信号。
  • oldpeak与target负相关,大约在-0.43。oldpeak是运动恢复期ST段压低的数值,压低越明显,心肌缺血风险越高。这个指标在临床上本来就是心电负荷试验的核心判据,模型学到这一点完全在情理之中。
  • cp(胸痛类型)与target相关度也很高。不同胸痛类型对应的患病比例差异明显,有典型心绞痛症状的人群阳性率显著更高。

这三条结论给我最大的启发是:机器学习在这个项目里并不是发现什么反常识的黑魔法,它只是把医生已经用了很多年的临床经验重新量化了一遍。

2.4 相关矩阵不是万能钥匙

做热力图没问题,但不要只盯着皮尔逊相关系数看。皮尔逊相关假设变量之间是线性关系,而cpslope这类类别变量本质上不该参与线性相关计算。我见过不少人把所有特征扔进df.corr(),然后把热力图上颜色浅的特征全部删掉,这种做法非常危险。

更稳妥的做法是分类讨论:连续变量用散点图或箱线图看分布,类别变量用堆叠柱状图看阳性率差异。用seabornboxplot(x="target", y="thalach")countplot(x="cp", hue="target"),比一张大热力图信息量大得多。

3. 特征工程里的三个坑:缺失值、类别编码和数据泄漏

3.1 缺失值都藏在ca和thal里

把问号替换成NaN后,cathal的缺失比例并不高,在我这版里一个缺了4行、一个缺了2行。处理方式我尝试了两种:

第一种是直接众数填充。用SimpleImputer(strategy="most_frequent"),简单省事,对树模型影响不大。第二种是删除缺失行,因为只少了6条,删掉之后还剩297条,对整体影响很小。

我的建议是:小样本教学项目选众数填充就行,但在报告里写清楚你是如何处理缺失的、为什么这样处理。如果以后换成真实医院数据,缺失模式会很复杂,直接众数填充往往会造成偏倚,那时候就要结合随访记录、检查时间等因素综合判断。

3.2 类别特征别一股脑丢进模型:先分清有序和无序

sexfbsexang是天然的二分类0/1,可以直接用。但cprestecgslopethal需要想清楚。以cp为例,0-3分别代表不同类型的心绞痛或无症状,它们之间没有严格的大小关系,数值大不代表更严重。如果把0、1、2、3直接喂给逻辑回归,模型会强行学一个线性权重,这在语义上是说不通的。

我的方案是用OneHotEncoder把无序类别展开成哑变量,同时把ca这类取值集中在0-3的计数型变量保留为数值。展开后特征数会从13变成20左右,多了七个哑变量,但对小样本来说仍然可控。

注意一点:树模型其实对类别编码的敏感性不如线性模型那么高,因为树可以做任意切分。但从解释性和模型一致性角度,用one-hot还是更稳妥。如果你用的是XGBoost,也可以开启enable_categorical=True并声明category类型,效果类似,但要注意不同版本的API兼容性,别在最后时刻被报错卡住。

3.3 标准化要放进Pipeline,防止数据泄漏

初学者最经典的操作是把StandardScaler先fit到整个数据集上,然后再做train_test_split,或者干脆先把所有特征标准化,再划分训练集和测试集。表面上看没什么问题,但严格来说,这是数据泄漏的一种。

什么意思?StandardScaler在fit时会计算全量数据的均值和标准差,如果这个全量数据包含了测试集内容,那测试集的分布信息就已经在训练时被“偷看”到了。虽然泄漏一点点均值方差通常不会导致结果爆炸,但正确的姿势应该是:只让scaler在训练集上fit,再对测试集transform。

实际写代码时,不要手动拆两步,而是用Pipeline把预处理和模型打包成一个整体:

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000)) ])

这样在交叉验证中,每一折都是先在该折训练集上fit,再对验证集transform,完全避免泄漏。

3.4 用StratifiedKFold做交叉验证,而不是盲目切一刀

三百多条数据只做一次train_test_split,结果很不稳定,可能这次准确率0.90、下次就掉到0.82。原因在于随机切分可能让某一折的阳性集中度偏高或偏低。

用分层K折可以保证每一折里正负样本比例和全量一致:

from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

StratifiedKFold翻译过来就是“分层抽样+K折”的组合,它把这个问题数据量小的短板从一定程度上弥补了。

4. 模型选型的真实对比:逻辑回归做基线,XGBoost做主力

4.1 逻辑回归:先当“温度计”

不要一上来就上XGBoost。逻辑回归在这个项目里不是最终答案,但它是最重要的基线。它训练快、可解释,输出的概率直接对应风险程度,而且能给你一个判断基准:后面跑的模型如果连逻辑回归都打不过,那大概率是你的数据和特征出了问题,而不是模型不够强。

逻辑回归的假设是特征与对数几率大致线性。这种假设在这个数据集上并不完美,所以不要指望它拿第一,但它能告诉我们哪些特征在“线性层面”有解释力。

4.2 四类模型的优缺点和本次实验配置

我对比了四个模型,选择它们的原因各不相同:

模型优点缺点本次配置
逻辑回归稳定、可解释、适合基线难以处理非线性max_iter=1000
SVM(RBF核)小样本表现好、能捕捉非线性对特征尺度敏感,必须标准化C=1.0, gamma=scale
随机森林抗过拟合相对好、能处理非线性小样本上仍可能过拟合n_estimators=200
XGBoost效果上限高、自带正则参数多、调参成本高learning_rate=0.1, max_depth=3

SVM在这个小数据集上意外能打,因为RBF核可以拟合较复杂的决策边界,而且SVM对高维小样本的泛化通常不错。随机森林则更像一个“稳定输出型选手”,表现中规中矩。XGBoost在充分调参后的上限最高,但也要小心训练集上表现完美、测试集上掉链子的问题。

4.3 调参失控的教训:网格别铺太大

我第一次做调参时犯了一个经典错误:给随机森林同时设置了

param_grid = { "n_estimators": [50, 100, 200, 300], "max_depth": [3, 5, 7, 9], "min_samples_split": [2, 5, 10], "min_samples_leaf": [1, 2, 4] }

这个参数空间组合数是4×4×3×3=144,再乘以5折交叉验证就是720次完整训练。虽然数据量小不至于跑几天,但这个思路在稍大一点的数据集上会直接失控。更麻烦的是,网格搜出来的最优参数很可能是过拟合到验证集上的“虚假最优”。

后来我改用了一个更克制的思路:先固定n_estimators=200,只在max_depthmin_samples_split上各试三四组值,或者直接用optuna跑50次随机搜索。对一个小项目来说,手动理解每个参数的作用,比盲目铺网格更重要。

4.4 第一次跑出来的结果,先别激动

我第一次跑完之后单次测试准确率到了0.92,心里还挺美。但把模型切到StratifiedKFold之后,均分掉到0.87,标准差达到0.04。这0.05的差距就是单次划分的运气成分。以后如果有人只拿一次train_test_split的准确率来汇报结果,你可以多问一句:做了几次交叉验证?

5. 只看准确率会被坑:评估指标的选择与交叉验证

5.1 准确率骗局:当阳性率只有5%时一切都会变

原数据集是平衡的,所以准确率看起来还有参考价值。但我们换个场景:真实体检人群中患病率假设是5%,你只要写一个“永远预测不患病”的模型,准确率就是95%。这样的模型有意义吗?完全没意义,因为它的任务是找出有病的个体,而它把所有人放走了。

这也是为什么医疗类机器学习项目里,几乎不会只用准确率说话。至少要报告混淆矩阵、敏感度(recall)、特异度和AUC。

5.2 从混淆矩阵出发选指标:这个场景更怕漏诊还是误诊?

混淆矩阵四格分别是TP、FP、FN、TN。其中:

  • Recall = TP / (TP + FN),又称敏感度,衡量“真正有病的人里被查出来多少”。医疗筛查场景更关心漏诊,所以recall要尽量高。
  • Precision = TP / (TP + FP),衡量“模型报警的人里真有病的比例”。如果precision太低,会让医生被大量假警报淹没。

在筛查任务里,漏掉一个真病人可能造成严重后果,所以我倾向优先保证recall,同时用AUC观察模型的整体排序能力。AUC不依赖阈值,它衡量的是“随机抽一个阳性样本和随机抽一个阴性样本,模型给阳性样本打分更高的概率”,用来对比不同模型很合适。

5.3 最终结果:5折交叉验证下的横向对比

用清洗后的特征和StratifiedKFold跑完,我得到了一份大致如下的结果(每次随机种子不同会略有波动,但趋势稳定):

模型AccuracyPrecisionRecallF1AUC
逻辑回归0.840.820.860.840.90
SVM(RBF)0.860.850.870.860.92
随机森林0.850.840.860.850.90
XGBoost0.870.860.880.870.93

XGBoost在AUC和F1上都略胜一筹,最终我选了它作为主力模型。但需要说明的是,它们之间的差距并不算天壤之别,很大程度取决于随机种子和特征处理细节。

5.4 小样本下的“高AUC”要打折看

303条数据练出来的0.93的AUC,听起来很漂亮,但要冷静:小样本下模型很容易“记住”数据集的局部模式,而不是学到一个泛化的医学规律。把同样一组代码原封不动迁移到另一家医院、另一种人群,AUC很可能会明显下降。

我在报告里加了一句:当前结果仅能证明“在该数据集范围内,模型具备较强的区分能力”,不能外推到真实人群。这句话写上去之后,整个项目的严谨度立刻不一样。

6. 让模型开口说话:SHAP值告诉你谁是幕后推手

6.1 为什么黑盒模型需要解释

XGBoost好用,但它是一个黑盒。你可以告诉医生“这个模型判断你有87%的概率是高风险”,但医生下一句一定是“为什么?”如果模型给不出理由,就很难被采信。

SHAP(SHapley Additive exPlanations)是目前最常见的特征归因方法。它的核心思想是把每一次预测拆解成“基线预测值+各特征的贡献”。某个特征的SHAP值越大,说明它把预测结果往上推得越多。

6.2 SHAP的实操代码和解读

我的做法是单独保存一份经过预处理的测试集,然后对XGBoost跑TreeExplainer:

import shap explainer = shap.TreeExplainer(xgb_model) shap_values = explainer.shap_values(X_test_preprocessed) shap.summary_plot(shap_values, X_test_preprocessed)

TreeExplainer只能用于树模型。如果你换了逻辑回归,建议用LinearExplainer;如果是SVM,可以用KernelExplainer,但计算速度会慢不少。

summary_plot画出来的图信息量很大:纵轴是特征名称,按重要性从上往下排;横轴是SHAP值,每个点代表一条样本;颜色代表特征值高低。从图上能直观看到哪些特征把风险往上推、哪些往下压。

6.3 最重要的四个特征,医学上说得通吗

在我这版SHAP结果里,排在前几位的特征依次是thalacholdpeakcpsex。这和我前面EDA阶段看到的关系完全一致:

  • thalach越高,SHAP值往往越低,也就是风险更低。
  • oldpeak越高,SHAP值越高,也就是风险更高。
  • cp的不同档位会明显区分风险贡献。
  • sex在数据集中也有明显影响,这和人群中男女心血管疾病基线差异有关。

当机器学习结果和医学常识能够互相印证时,模型的可信度才会真正上一个台阶。如果SHAP告诉你“年龄越长越没风险”,那你要小心是数据或预处理出了问题,而不是相信“模型发现了新规律”。

6.4 怎么给非技术人讲清楚SHAP

给答辩老师或业务方讲的时候,别一上来抛shap_valuesbase_value这些术语。我喜欢用更直白的表达:“当前数据集里平均风险概率大概是0.5,某个病人最大心率明显偏低,这个特征会把他的风险分数往上推0.2;但他的oldpeak不高,这个特征又会往下拉0.15。综合下来,我判定他是高风险。”这样说,哪怕没学过机器学习的人也能听懂大概。

7. 从Notebook到真实场景:部署Demo与医学落地的边界

7.1 模型落地:保存管线,而不是只保存一个模型

很多人在Notebook里跑完就结束了,但如果你想把这个项目展示得更完整,可以做一个简单的网页Demo,让用户输入13项体检指标,页面直接输出风险概率。关键点是:不要只joblib.dump(model),要把“预处理+模型”整个Pipeline一起保存。

import joblib joblib.dump(pipe, "heart_pipe.pkl")

这样加载之后不需要再手动记着“标准化用了哪几个均值、哪些列做了one-hot”,因为所有逻辑都在Pipeline里:

loaded_pipe = joblib.load("heart_pipe.pkl") prob = loaded_pipe.predict_proba(new_data)[0][1]

还有一个容易被忽略的坑:特征列顺序。无论是数据清洗还是手动构造DataFrame,输入新数据时字段顺序必须和训练时完全一致。建议保存一份feature_names列表,放进json或随pkl一起存下来。

7.2 做一个能输入数据、输出风险等级的Demo

用Streamlit写一个简单页面,代码量并不大:

import streamlit as st import pandas as pd import joblib st.set_page_config(page_title="心脏病风险预测", page_icon="heart") st.title("心脏病发作风险预测Demo") age = st.slider("年龄", 20, 90, 50) sex = st.selectbox("性别", [0, 1], format_func=lambda x: "女" if x == 0 else "男") cp = st.selectbox("胸痛类型", [0, 1, 2, 3]) thalach = st.slider("最大心率", 60, 220, 150) oldpeak = st.slider("ST段压低", 0.0, 6.0, 1.0) feature_names = ["age", "sex", "cp", "thalach", "oldpeak"] input_df = pd.DataFrame([[age, sex, cp, thalach, oldpeak]], columns=feature_names) pipe = joblib.load("heart_pipe.pkl") prob = pipe.predict_proba(input_df)[0][1] st.metric("预测高风险概率", f"{prob:.1%}") if prob >= 0.5: st.warning("提示:模型判断风险偏高,建议进一步检查。") else: st.info("提示:模型判断风险偏低,仍请结合临床医生意见。")

这里我只列了五个特征做演示,正式版需要把13个特征全部放进去。运行streamlit run app.py就能在浏览器里看到界面。这个小改动会让项目从“跑个结果”升级成“一个完整的应用演示”。

7.3 医疗AI的边界:模型可以当“助手”,不能当“诊断者”

做完这个项目后,我特意在README里写了一句话:这是一个教学级别的模型,不能作为真实医疗诊断依据。

原因很实在。公开的数据集样本只有几百人,来自上世纪80年代的研究队列,人群构成、医疗条件、检查方式和今天差异很大。真实医院里遇到的是超大规模电子病历、多中心数据、复杂缺失模式、时间跨度效应,这些都不是三百条数据能模拟的。更关键的是,模型评估依赖的是历史记录,而真实世界里病人的结局还受治疗干预影响——一个高风险患者可能因为及时吃了药而没发作,这在数据集里看起来就像是“漏诊”。

医疗AI的正确形态应该是“辅助筛检”,而不是“自动诊断”。它可以告诉医生“这位患者的某些指标组合与既往高风险人群很像”,但最终做判断的必须是人。如果你以后想往医疗AI方向发展,请一定把这条边界放在第一位。

我在纸面上跑这个项目时,最开始的兴奋点是准确率数字,但做到后面,真正让我觉得有价值的是那几张SHAP图和那次交叉验证里的标准差。模型好不好,不能只看它在某个随机划分里的表现,而要看它能不能稳定地解释数据、能不能经得起业务方的追问。如果你也要用这个题目交作业或写简历项目,建议把重心从“刷高准确率”挪到“把每个决策讲清楚”上来——这恰恰是很多机器学习初学者和面试者之间最明显的分水岭。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询