资讯动态

机器学习心脏病预测全流程:特征工程、交叉验证与SHAP解释

发布时间:2026/9/11 2:58:27 来源:尧图企业网站定制
前两天帮人改一个期末项目打开压缩包时里面躺着一个经典的“机器学习心脏病预测”作业三百多条数据一个跑完准确率就交差的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否目标列注意cp、restecg、slope、thal虽然读进来是数字但它们本质是等级或类别。如果不做处理直接当成连续特征喂给线性模型等于默认了数值间距的线性关系这在医学指标里往往站不住脚。1.3 读入数据时最容易翻车的两个点第一点是占位符。很多从公开渠道下载到的版本里ca和thal两列夹杂着问号比如“?”。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)第二点是看漏类别分布。拿到数据后先对ca、thal、cp做一次value_counts()能直观发现缺失情况和取值是否集中在某个档位。比如ca大多数人都是0少数是1、2、3这种情况下如果把缺失直接填0其实隐含了“这条记录没做检查没有血管病变”的假设这个假设未必成立但你心里必须有数。2. 可视化阶段挖出的几个关键信号胸痛和最大心率不是玄学2.1 目标列有没有失衡这个数据集“过分平衡”了先看目标分布。我这版数据里target1大概占51%target0占49%可以说非常平衡。这对建模训练是个好事因为不用一上来就处理严重的类别不平衡问题。但这里要泼一盆冷水真实世界的心血管疾病筛查中患病率远没有这么高。很多体检场景下阳性率可能只有5%甚至更低。这个数据集之所以平衡是因为当初做病例对照研究时专门匹配了阴性样本而不是流行病学调查中的自然发生率。所以你在课程项目里可以忽略类别不平衡但心里要门儿清真实场景里准确率会严重失真后面我细说。2.2 年龄和血压直觉可能骗你我画了年龄与目标关系的箱线图发现50岁左右的患病风险开始往上走但四五十岁这个区间并不像“50岁以上必然高风险”那样一刀切。事实上年轻组里也有不少阳性样本这提醒我们单看年龄无法做判断必须和其他指标组合。血压和胆固醇也有类似问题。许多初学者会默认“血压越高风险越高”但从相关系数上看trestbps和chol与目标的相关性并不强因为高血压和高胆固醇更像是长期风险因素而数据集里的特征采集的是某一时点的静态数值它的解释力被其他更直接的指标压过了。这也解释了为什么EDA阶段一定要做多变量交叉而不是盯着单变量下结论。2.3 三条与标签相关性最高的线索我这次跑出来的相关性排名里最抓眼的三条是thalach最大心率与target正相关大约在0.42左右。直觉上会觉得“心率快不是坏事吗”这里的thalach不是静息心率而是运动时能达到的最大心率。它代表心脏的代偿能力最大心率上不去往往提示心脏功能储备差反而是风险信号。oldpeak与target负相关大约在-0.43。oldpeak是运动恢复期ST段压低的数值压低越明显心肌缺血风险越高。这个指标在临床上本来就是心电负荷试验的核心判据模型学到这一点完全在情理之中。cp胸痛类型与target相关度也很高。不同胸痛类型对应的患病比例差异明显有典型心绞痛症状的人群阳性率显著更高。这三条结论给我最大的启发是机器学习在这个项目里并不是发现什么反常识的黑魔法它只是把医生已经用了很多年的临床经验重新量化了一遍。2.4 相关矩阵不是万能钥匙做热力图没问题但不要只盯着皮尔逊相关系数看。皮尔逊相关假设变量之间是线性关系而cp、slope这类类别变量本质上不该参与线性相关计算。我见过不少人把所有特征扔进df.corr()然后把热力图上颜色浅的特征全部删掉这种做法非常危险。更稳妥的做法是分类讨论连续变量用散点图或箱线图看分布类别变量用堆叠柱状图看阳性率差异。用seaborn画boxplot(xtarget, ythalach)和countplot(xcp, huetarget)比一张大热力图信息量大得多。3. 特征工程里的三个坑缺失值、类别编码和数据泄漏3.1 缺失值都藏在ca和thal里把问号替换成NaN后ca和thal的缺失比例并不高在我这版里一个缺了4行、一个缺了2行。处理方式我尝试了两种第一种是直接众数填充。用SimpleImputer(strategymost_frequent)简单省事对树模型影响不大。第二种是删除缺失行因为只少了6条删掉之后还剩297条对整体影响很小。我的建议是小样本教学项目选众数填充就行但在报告里写清楚你是如何处理缺失的、为什么这样处理。如果以后换成真实医院数据缺失模式会很复杂直接众数填充往往会造成偏倚那时候就要结合随访记录、检查时间等因素综合判断。3.2 类别特征别一股脑丢进模型先分清有序和无序sex、fbs、exang是天然的二分类0/1可以直接用。但cp、restecg、slope、thal需要想清楚。以cp为例0-3分别代表不同类型的心绞痛或无症状它们之间没有严格的大小关系数值大不代表更严重。如果把0、1、2、3直接喂给逻辑回归模型会强行学一个线性权重这在语义上是说不通的。我的方案是用OneHotEncoder把无序类别展开成哑变量同时把ca这类取值集中在0-3的计数型变量保留为数值。展开后特征数会从13变成20左右多了七个哑变量但对小样本来说仍然可控。注意一点树模型其实对类别编码的敏感性不如线性模型那么高因为树可以做任意切分。但从解释性和模型一致性角度用one-hot还是更稳妥。如果你用的是XGBoost也可以开启enable_categoricalTrue并声明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_iter1000)) ])这样在交叉验证中每一折都是先在该折训练集上fit再对验证集transform完全避免泄漏。3.4 用StratifiedKFold做交叉验证而不是盲目切一刀三百多条数据只做一次train_test_split结果很不稳定可能这次准确率0.90、下次就掉到0.82。原因在于随机切分可能让某一折的阳性集中度偏高或偏低。用分层K折可以保证每一折里正负样本比例和全量一致from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42)StratifiedKFold翻译过来就是“分层抽样K折”的组合它把这个问题数据量小的短板从一定程度上弥补了。4. 模型选型的真实对比逻辑回归做基线XGBoost做主力4.1 逻辑回归先当“温度计”不要一上来就上XGBoost。逻辑回归在这个项目里不是最终答案但它是最重要的基线。它训练快、可解释输出的概率直接对应风险程度而且能给你一个判断基准后面跑的模型如果连逻辑回归都打不过那大概率是你的数据和特征出了问题而不是模型不够强。逻辑回归的假设是特征与对数几率大致线性。这种假设在这个数据集上并不完美所以不要指望它拿第一但它能告诉我们哪些特征在“线性层面”有解释力。4.2 四类模型的优缺点和本次实验配置我对比了四个模型选择它们的原因各不相同模型优点缺点本次配置逻辑回归稳定、可解释、适合基线难以处理非线性max_iter1000SVMRBF核小样本表现好、能捕捉非线性对特征尺度敏感必须标准化C1.0, gammascale随机森林抗过拟合相对好、能处理非线性小样本上仍可能过拟合n_estimators200XGBoost效果上限高、自带正则参数多、调参成本高learning_rate0.1, max_depth3SVM在这个小数据集上意外能打因为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×3144再乘以5折交叉验证就是720次完整训练。虽然数据量小不至于跑几天但这个思路在稍大一点的数据集上会直接失控。更麻烦的是网格搜出来的最优参数很可能是过拟合到验证集上的“虚假最优”。后来我改用了一个更克制的思路先固定n_estimators200只在max_depth和min_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.90SVMRBF0.860.850.870.860.92随机森林0.850.840.860.850.90XGBoost0.870.860.880.870.93XGBoost在AUC和F1上都略胜一筹最终我选了它作为主力模型。但需要说明的是它们之间的差距并不算天壤之别很大程度取决于随机种子和特征处理细节。5.4 小样本下的“高AUC”要打折看303条数据练出来的0.93的AUC听起来很漂亮但要冷静小样本下模型很容易“记住”数据集的局部模式而不是学到一个泛化的医学规律。把同样一组代码原封不动迁移到另一家医院、另一种人群AUC很可能会明显下降。我在报告里加了一句当前结果仅能证明“在该数据集范围内模型具备较强的区分能力”不能外推到真实人群。这句话写上去之后整个项目的严谨度立刻不一样。6. 让模型开口说话SHAP值告诉你谁是幕后推手6.1 为什么黑盒模型需要解释XGBoost好用但它是一个黑盒。你可以告诉医生“这个模型判断你有87%的概率是高风险”但医生下一句一定是“为什么”如果模型给不出理由就很难被采信。SHAPSHapley Additive exPlanations是目前最常见的特征归因方法。它的核心思想是把每一次预测拆解成“基线预测值各特征的贡献”。某个特征的SHAP值越大说明它把预测结果往上推得越多。6.2 SHAP的实操代码和解读我的做法是单独保存一份经过预处理的测试集然后对XGBoost跑TreeExplainerimport 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结果里排在前几位的特征依次是thalach、oldpeak、cp、sex。这和我前面EDA阶段看到的关系完全一致thalach越高SHAP值往往越低也就是风险更低。oldpeak越高SHAP值越高也就是风险更高。cp的不同档位会明显区分风险贡献。sex在数据集中也有明显影响这和人群中男女心血管疾病基线差异有关。当机器学习结果和医学常识能够互相印证时模型的可信度才会真正上一个台阶。如果SHAP告诉你“年龄越长越没风险”那你要小心是数据或预处理出了问题而不是相信“模型发现了新规律”。6.4 怎么给非技术人讲清楚SHAP给答辩老师或业务方讲的时候别一上来抛shap_values和base_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_iconheart) st.title(心脏病发作风险预测Demo) age st.slider(年龄, 20, 90, 50) sex st.selectbox(性别, [0, 1], format_funclambda 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]], columnsfeature_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图和那次交叉验证里的标准差。模型好不好不能只看它在某个随机划分里的表现而要看它能不能稳定地解释数据、能不能经得起业务方的追问。如果你也要用这个题目交作业或写简历项目建议把重心从“刷高准确率”挪到“把每个决策讲清楚”上来——这恰恰是很多机器学习初学者和面试者之间最明显的分水岭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价