资讯动态

中风预测模型实战:Notebook中的LightGBM与SHAP全流程解析

发布时间:2026/10/25 7:44:51 来源:尧图企业网站定制
简介这是一份面向机器学习初学者与医疗数据分析爱好者的中风预测建模项目基于Kaggle公开数据集构建通过性别、年龄、疾病史、吸烟状况等特征预测患者中风风险适合用于分类模型训练、特征工程与可视化实践。压缩包共11个文件约4.36MB包含3个Jupyter Notebook数据处理与建模、1个CSV数据集、1个模型文件pkl、1个部署脚本py、Procfile与setup.sh等覆盖从数据清洗到模型部署的完整链路。目前已有741人学习下载。文件中含详尽的Notebook步骤、模型持久化结果与Heroku部署配置可帮助读者复现F1分数与AUC的优化过程并快速搭建可交互的预测应用尤其适合希望将算法模型落地为Web服务的开发者参考。1. 中风预测模型一份 Notebook 能端到端讲清从数据到结论的全部细节有一次我在复现某个公开的中风预测任务时跑完对方的 Notebook 后看到了一个接近完美的准确率。第一时间我没有兴奋而是去检查了一遍是否有数据泄漏——因为在类别严重不平衡的任务里“准确率很高”往往不代表模型强而是模型学会了把所有人都判成正常人。这份以 stroke-prediction-model 命名的 Jupyter Notebook 资源走的是另一种路子从探索性数据分析开始用可视化确认字段分布再依次处理特征工程、类别不平衡和 LightGBM 建模最后用 SHAP 解释哪些因素真正推高了中风风险。适合刚学完 pandas 和 scikit-learn、想完整看一个业务型机器学习项目如何落地的读者也适合想把它改造成小型预测服务的开发。2. 项目结构与技术选型在 Jupyter Notebook 里组织机器学习工作流的正确姿势很多人把 Jupyter Notebook 当成草稿纸写一段跑一段能出图就收工。这份资源不是这样组织的它更接近一份“可执行的实验报告”环境依赖、数据路径、模型结果全部固化在文件结构里换台机器也能按顺序复现出同一套结论。2.1 资源里有什么文件清单与 Notebook 内部章节解压后会看到一份典型的建模工程目录文件不多但分工明确。Notebook 内部用 Markdown 单元做了章节切分每个章节对应数据科学流程里的一个阶段单元顺序就是执行顺序不建议跳着跑。文件作用README.md说明运行环境、依赖版本与整体思路requirements.txt锁定 Python 依赖版本data/存放原始数据与清洗后的中间结果stroke_prediction.ipynb主 Notebook按章节组织全部代码Notebook 内部按“数据加载 → 探索性分析 → 特征工程 → 模型训练 → 评估与解释”分成了五个阶段每个阶段开头都有一个 Markdown 单元写清楚“这一步在做什么、为什么做”。这种做法在团队协作里特别有用别人拿到的不是一团散落的代码块而是一条有上下文的工作流。资源的第一个代码单元通常长这样import pandas as pd import numpy as np from pathlib import Path import warnings warnings.filterwarnings(ignore) SEED 42 DATA_DIR Path(data) RESULT_DIR Path(results) RESULT_DIR.mkdir(exist_okTrue) df pd.read_csv(DATA_DIR / stroke_data.csv) print(f原始数据形状: {df.shape})这段代码的逻辑很直白先统一设置随机种子和路径常量保证后续所有单元共享同一个环境和结果目录。随机种子固定为 42 是为了让切分、SMOTE 采样和模型初始化具备可复现性这一点在做医疗类预测时尤其重要——评审方或同事需要能原样复现你的实验。路径用Path而不是字符串拼接是为了跨平台兼容Windows 和 Linux 下都不会因为斜杠方向报错。2.2 技术栈怎么选从 pandas 到 LightGBM 的依赖组合这份资源依赖项不多但每一样都选得比较克制没有为了“炫技”引入深度学习框架。核心是围绕表格数据建模的常用组合模块在项目里承担的角色pandas / numpy数据加载、缺省值处理、特征变换matplotlib / seaborn分布图、相关矩阵、模型评估可视化scikit-learn数据切分、编码、交叉验证、评估指标imbalanced-learnSMOTE-Tomek 处理类别不平衡LightGBM梯度提升树分类器承担主模型训练SHAP模型输出解释分析每个特征的影响方向为什么不用更流行的 PyTorch 或 TensorFlow因为中风预测这类表格数据通常只有几千到几万条样本维度也不高梯度提升树在这种体量下训练快、调参成本低而且对缺失值和异常值有很强的容忍度。我一般会建议先跑树模型拿到基线再考虑更复杂的方案。requirements.txt 里锁定了主要版本比如 LightGBM 和 imbalanced-learn 的版本都是经过验证的安装时直接按这个文件恢复环境最省事。2.3 数据从哪来、流到哪去一份完整的建模工作流资源内置的是某公开平台的中风预测数据集原始数据约 5000 条记录特征涵盖了性别、年龄、高血压史、心脏病史、婚姻状况、工作类型、居住类型、平均血糖、BMI 和吸烟状况。目标列是 stroke记录是否发生过中风。正类占比非常低只有约 2%这也是整个项目最大的建模难点。数据流向是一条很标准的链路原始 CSV 读入后先做质量检查补缺失、清理异常年龄然后进入特征工程把类别字段编码、数值字段做变换接着用分层切分把数据分成训练、验证、测试三份SMOTE 只在训练集上执行避免合成样本污染验证结果最后训练 LightGBM输出概率并做评估与 SHAP 解释。每次运行的全部中间产物都写到 results 目录方便回溯。提示如果你拿到资源后第一件事是打开 Notebook 直接运行请先确认data/目录下确实有 CSV 文件并且当前工作目录是项目根目录否则相对路径会读不到数据。3. 核心模块拆解从数据清洗到不平衡分类的完整链路这一章是整份资源的重点。中风的业务背景决定了很多处理不能照搬通用模板——少数类占比过低、字段含义需要医学常识支撑、缺失值的位置暗示数据采集方式。我们按 Notebook 的实际执行顺序把每个核心模块逐段拆开讲。3.1 数据清洗与探索性分析先看清字段再动手第一件事不是建模而是把字段的分布和缺失情况摸清楚。原始数据集里的类别字段是字符串数值字段里藏着不少陷阱一步不看直接训练很容易被脏数据带偏。def inspect_data(df): 输出数据类型、缺失率与基础分布信息 overview pd.DataFrame({ dtype: df.dtypes, missing_ratio: df.isna().mean().round(4), n_unique: df.nunique() }) print(overview) inspect_data(df)这段输出能直接告诉你哪些字段是数值型、哪些是类别型、哪些缺失率异常。比如 bmi 的缺失率通常接近 3%~4%这个量级需要单独处理而 smoking_status 里会出现大量 unknown这类值不是随机缺失而是“病人没被问到或不想回答”属于有业务含义的缺失。如果直接丢掉模型会失真。接着画目标列分布与年龄直方图这是最便宜的数据质量检查手段。你会发现 age 列出现两个明显的尖峰一个集中在 0 附近一个超过 100。年龄为 0 的样本不是新生儿而是原始记录里把出生年份当成了年龄高龄段的异常值又可能是录入错误。针对这种情况常见的做法是先把 age 为 0 的样本视为缺失再用训练集年龄中位数按性别分组填充而不是整行删除——因为高龄段样本对预测中风非常关键删掉它们会直接削弱模型对高风险人群的判别力。3.2 特征工程类别编码与数值变换的正确处理特征工程这一步直接决定了模型的上限。我们对类别字段做低频合并与编码对数值字段做变换让分布更加适合建模。from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler # 对类别字段做低频合并出现次数极少的类别归为 other def merge_rare_categories(series, threshold10): counts series.value_counts() rare counts[counts threshold].index return series.replace(rare, other) cat_cols [gender, work_type, Residence_type, smoking_status] for col in cat_cols: df[col] merge_rare_categories(df[col].astype(str)) # 数值字段血糖做 log 变换BMI 按高血压分组填充中位数 df[avg_glucose_level] np.log1p(df[avg_glucose_level]) df[bmi] df.groupby(hypertension)[bmi].transform( lambda x: x.fillna(x.median()) )这里的细节都值得展开。低频类别合并是为了避免 one-hot 编码后产生大量只有几个样本的稀疏列比如 gender 里的 Other 出现次数极少单独编码只会增加过拟合风险。血糖做 log 变换是因为平均血糖分布严重左偏、存在高值长尾log1p 能压缩极值影响树模型本身对单调变换不敏感但如果你后续换用逻辑回归这个变换就能省掉不少麻烦。BMI 按“是否患高血压”分组填充中位数是因为高血压与 BMI 高度相关全局均值会压掉组间差异分组填充能保留部分业务结构。最后用 ColumnTransformer 把类别字段做 one-hot 编码、数值字段做标准化。注意这里所有统计量都只能在训练集上计算验证集和测试集要用相同的拟合结果去做变换这是后面单独说的一条重要避坑点。3.3 类别不平衡处理SMOTE 必须放在切分之后中风数据集中正类比例约 2%直接训练树模型也能跑但模型会被多数类主导几乎学不到少数类模式。这份资源用的是 SMOTE-Tomek——先生成少数类合成样本再把边界上的多数类样本清掉兼顾过采样与清洗。from imblearn.combine import SMOTETomek from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_stateSEED ) # 只在训练集上做 SMOTE-Tomek smote_tomek SMOTETomek(random_stateSEED) X_train_res, y_train_res smote_tomek.fit_resample(X_train, y_train) print(SMOTE 前训练集正类数量:, y_train.sum()) print(SMOTE 后训练集正类数量:, y_train_res.sum())关键点是顺序先把数据按分层抽样切成训练集和测试集然后在训练集内部 fit_resample。如果把 SMOTE 放在切分之前合成样本可能同时出现在训练集和测试集里模型在测试时相当于见过部分答案AUC 会虚高这种情况下你拿着这个模型上线的结果完全是假的。SMOTE-Tomek 的参数只有一个 random_state用于保证合成过程可复现。选择它而不是普通 SMOTE是因为它能去掉类边界上的噪声样本减少类别重叠对决策边界的影响。训练完成后测试集始终保持原始分布这样才能真实反映模型在实际场景下的表现。3.4 建模与基线先用 DummyClassifier 打底再上 LightGBM建模之前先建一个基线是很有必要的。DummyClassifier 不学任何模式只会预测多数类它的指标是我们要超过的底线。from sklearn.dummy import DummyClassifier from sklearn.metrics import roc_auc_score from sklearn.model_selection import StratifiedKFold import lightgbm as lgb import numpy as np # 基线总是预测多数类 dummy DummyClassifier(strategymost_frequent) dummy.fit(X_train_res, y_train_res) dummy_pred dummy.predict_proba(X_test)[:, 1] print(fDummy AUROC: {roc_auc_score(y_test, dummy_pred):.4f}) # LightGBM 交叉验证输出 OOF 预测 params { objective: binary, learning_rate: 0.05, num_leaves: 31, max_depth: 6, n_estimators: 500, random_state: SEED, } skf StratifiedKFold(n_splits5, shuffleTrue, random_stateSEED) oof_pred np.zeros(len(X_train)) for fold, (tr_idx, va_idx) in enumerate(skf.split(X_train, y_train)): model lgb.LGBMClassifier(**params) model.fit( X_train.iloc[tr_idx], y_train.iloc[tr_idx], eval_set[(X_train.iloc[va_idx], y_train.iloc[va_idx])], callbacks[lgb.early_stopping(50, verboseFalse)] ) oof_pred[va_idx] model.predict_proba(X_train.iloc[va_idx])[:, 1]参数里最关键的是learning_rate0.05和n_estimators500小学习率配合 early stopping既能让模型慢慢拟合减少过拟合又能自动决定最佳迭代轮数。num_leaves31控制树的复杂度叶子越多模型越容易记住噪声在几千条样本的数据集上 31 是一个稳妥的起点。OOFOut-of-Fold预测的意义在于它不会让同一个样本既参与训练又参与预测交叉验证完成后 OOF 分数比单次切分更可信。这里用分层 K 折保证每一折里正类比例和全量一致避免某些折出现极端分布。基线 AUROC 大约在 0.5 附近LightGBM 的 OOF AUROC 通常能到 0.85 左右达到这个差距说明模型确实学到了数据里的信号而不是瞎猜。4. 模型评估与可解释性ROC、PR 曲线与 SHAP 值怎么配合使用模型训练完之后最容易犯的错误是只看一个指标就下结论。中风的业务特性决定了评估方式必须比普通分类任务更谨慎SHAP 则是把模型从“黑匣子”变成可讨论对象的关键工具。4.1 为什么不只看准确率用 PR-AUC 对齐业务目标在不平衡分类里准确率是一个极具欺骗性的指标。正类只占 2% 时模型把所有样本都判为“未中风”准确率就是 98%但这样的模型毫无价值。我们要同时汇报 ROC-AUC 和 PR-AUC并且以 PR-AUC 为主要参考。from sklearn.metrics import roc_auc_score, average_precision_score roc roc_auc_score(y_test, test_prob) pr average_precision_score(y_test, test_prob) print(fROC-AUC: {roc:.4f}) print(fPR-AUC: {pr:.4f})ROC-AUC 衡量的是模型区分正负样本的排序能力数值高代表正样本的预测概率普遍高于负样本。PR-AUC 则直接衡量“预测为正的样本里有多少真的是正类精确率”与“真实正类被找回来多少召回率”之间的权衡。在正类极少的场景下PR-AUC 的数值变化更敏感也更贴近业务方关心的实际效果。简单说ROC-AUC 看排序PR-AUC 看命中质量两者一起报才能全面反映模型能力。4.2 最佳阈值不是默认 0.5用 PR 曲线找 cut-offLightGBM 默认输出的是概率最终判定是正类还是负类需要一个阈值。大多数样例代码直接拿 0.5 当阈值这在类别严重不平衡时是错的——正类占比只有 2%模型输出的概率天然偏低0.5 会把几乎所有风险样本拒之门外。from sklearn.metrics import precision_recall_curve precisions, recalls, thresholds precision_recall_curve(y_test, test_prob) # 找到满足召回率 0.7 的最高精度阈值 valid recalls[:-1] 0.7 best_idx np.where(valid)[0][-1] best_threshold thresholds[best_idx] print(f推荐阈值: {best_threshold:.3f})这里的逻辑是业务方希望至少召回 70% 的真实中风患者在这个约束下让精度尽量高。你可以按业务需求调整召回率目标换成 0.6 或 0.8 都行。计算得到阈值后用它去给预测概率打标签而不是用模型的默认 0.5。这种做法在医疗场景里很通用——漏掉一个高危病人和误报一个低危病人代价不同阈值本身就是业务决策不应该由模型自动定死。4.3 SHAP把模型的判断依据逐条摊开看特征重要性只能告诉你“年龄重要”但模型到底是认为年龄大的人更容易中风还是更不容易要靠 SHAP 才能看清楚。import shap explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_test) shap.summary_plot(shap_values, X_test, feature_namesX_test.columns)TreeExplainer 是专门给树模型用的解释器计算速度快且结果准确。summary_plot 输出一张散点图横轴是 SHAP 值颜色代表特征值高低。运行后你会看到 age、avg_glucose_level 和 hypertension 的 SHAP 值跨度最大且方向符合预期年龄越大、血糖越高、有高血压史SHAP 值越正中风风险越高。这里有一个需要警惕的细节smoking_status 中的 unknown 类别可能会被模型学成一个“低风险”信号因为数据里未填吸烟状态的人可能整体偏年轻。这不是医学结论而是数据缺失机制造成的相关性。遇到这种情况要回到数据分布里确认原因而不是直接向社会公众解读为“吸烟对中风没有影响”。可解释性分析的价值就在于此——它能帮你抓出模型里隐藏的偏见避免上线后产生违背常识的预测。5. 排查与避坑训练中风预测模型最容易翻车的五个环节这些坑不是这份资源独有的任何做医疗类二分类任务都会遇到。如果你跑完发现指标异常按下面的顺序排查能省下不少查资料的时间。5.1 SMOTE 放错位置等于变相喂题现象验证集上 AUROC 高得惊人一换到新数据立刻掉回 0.6。原因SMOTE 在 train_test_split 之前执行合成样本同时进到了训练集和测试集模型在测试阶段等于见过一部分“答案”。这是不平衡学习最常见的错误之一网上大量教程都踩过这个坑。解决严格遵循“先切分、后合成”的顺序SMOTE 只针对训练集调用 fit_resample。代码里可以用管道包住这一步避免以后手滑改坏顺序。5.2 年龄字段里的“幽灵尖峰”现象画年龄直方图时看到 0 附近和 100 岁以上有明显尖峰直接用布尔条件删掉这些行后模型在高龄区间的表现变差了。原因年龄为 0 不是真实数据可能是原始记录把出生年份填进了年龄列而高龄区间是中风的高危人群不能当作普通异常值处理。解决先把 age0 替换为 NaN再按性别分组用中位数填充年龄大于 110 的样本检查后单独标记不要整行删除。这个处理逻辑和上一章 BMI 缺失值处理保持了一致性。5.3 BMI 缺失值用全局均值填充模型学了个寂寞现象填充后训练集指标正常测试集 PR-AUC 始终上不去。原因缺失的 BMI 不是随机发生的有些患者没有测身高体重直接填全局均值会把 BMI 的方差压小还抹掉了不同人群的差异。解决改成按 hypertension 分组填充中位数或者用 KNN 插补。插补之后重新看一次 BMI 的分布直方图确认它没有被捏造成一根尖刺。5.4 默认 0.5 阈值是默认陷阱现象精确率很高召回率只有个位数模型报告 500 个高风险患者实际真正中风的只抓回来 5 个。原因正类占比只有 2%模型输出的概率普遍低于 0.5默认阈值把绝大多数“中等风险”挡在门外。解决用 PR 曲线结合业务需求选阈值或者干脆不设硬阈值把预测概率作为风险分直接交给业务方。基数越不平衡阈值就越不应该用默认值。5.5 98% 准确率的假象现象测试集准确率 0.98打开混淆矩阵发现所有样本都被预测成了“未中风”。原因模型找到了作弊解法——全预测多数类准确率恰好等于多数类占比。解决汇报指标时强制同时输出混淆矩阵、PR-AUC、F1 分数和 ROC-AUC这四个指标能互相制约单独一个不会骗人。这也是我每次复现 Notebook 时最先检查的项目。提示如果你的数据质量心里没底可以先跑一遍 ydata-profiling 生成快速体检报告把缺失、离群、类别不平衡一次性暴露出来再动手写建模代码。6. 二次开发思路从 Notebook 到可复用的预测服务Notebook 适合探索不适合直接上生产。把这份资源改造成可复用服务核心工作是三件事把处理流程固化成 sklearn Pipeline、导出模型文件、用一个轻量接口暴露预测能力。Pipeline 的意义在于把所有预处理逻辑和模型绑定在一起调用 fit 和 predict 时不会再漏掉某一环节。常见做法是对数值列做标准化、类别列做 one-hot最后接 LightGBMfrom sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder import joblib import lightgbm as lgb preprocessor ColumnTransformer( transformers[ (num, StandardScaler(), num_cols), (cat, OneHotEncoder(handle_unknownignore), cat_cols) ] ) pipe Pipeline(steps[ (preprocessor, preprocessor), (classifier, lgb.LGBMClassifier(**params)) ]) pipe.fit(X_train, y_train) joblib.dump(pipe, stroke_model.joblib)注意 ColumnTransformer 里的handle_unknownignore这个参数保证预测时遇到训练集没见过的类别不会报错而是把它当作一个全新类别处理生产环境里很有用。训练完成后把整个 Pipeline 序列化成 joblib 文件后续加载只需要一行joblib.load(stroke_model.joblib)。在接口服务里接收 JSON 请求把字段组装成 DataFrame 后调用pipe.predict_proba即可返回风险概率。验证改造是否正确有一个简单办法把测试集前 100 条记录用接口请求一遍把返回概率和 Notebook 里直接调模型得到的结果对比误差应该小于 1e-6。从那以后我每次复现类似的 Notebook都会强制先走一遍“数据体检 → 确认切分顺序 → 建基线模型”这三步确认它们没问题再去读后面的模型代码。这样既避免被花哨的参数带偏也能快速定位问题出在数据还是出在模型。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑