资讯动态

脑卒中风险预测实战:表格建模、SMOTE与阈值调优

发布时间:2026/10/26 1:41:00 来源:尧图企业网站定制
简介这是一份围绕中风风险预测的 Jupyter Notebook 实战项目面向希望练习医疗分类建模、数据可视化与模型部署的机器学习学习者。项目使用 Kaggle 中风数据集以性别、年龄、疾病史和吸烟状况等字段为输入预测患者是否可能发生中风并借助数据过滤方法抽取原始训练数据子集覆盖探索分析、特征处理、模型训练、评估与简易部署等环节项目描述中还提出以较高 F1 分数和 AUC 为目标的评估设计。压缩包共 11 个文件约 4.36MB包含 3 个 ipynb 笔记、csv 数据集、pkl 模型、py 脚本、sh 与 Procfile 部署配置以及 md 说明等目录结构便于按实验流程检索。已有 741 人学习下载。读者可参考其中的分类建模与可视化思路结合部署文件尝试搭建预测服务适合作为医疗数据项目练习、复现与复盘材料。1. 从一行体检记录到风险概率stroke-prediction-model 到底在算什么假设你手上有一张表每行是一次体检记录年龄、是否高血压、是否有心脏病、平均血糖、BMI、吸烟状态、婚否、职业类型。stroke-prediction-model 干的事就一句话——根据这十来个字段算出这个人未来发生脑卒中的概率并把它压成一个二分类标签。整个工程用 Jupyter Notebook 写成从读数据到出图、出指标、出结论都摆在同一个.ipynb里适合三类人想把表格数据建模全流程走一遍的初学者、要给业务方演示风险分层原型的算法同学、需要一个可复现基线做对照的熟手。它不追 SOTA追的是每一步都能讲清为什么。全篇主线只有四段清洗与缺失值处理、类别不平衡处理、多模型对比、阈值调优与可解释性。这份资源真正值钱的地方不是最后那个模型文件而是中间那条能直接搬到别的表格数据上的流水线。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆。2. 项目结构与技术选型一个 Notebook 如何切成六层流水线打开这个包第一眼看到的是一个主 Notebook几百个 cell 从头排到尾。新手容易顺手就在末尾继续加 cell跑三天之后自己都不知道哪个变量覆盖了哪个。我一般会先在脑子里把它切成六层配置层路径、随机种子、阈值、数据层读表、清洗、字段校验、特征层编码、标准化、缺失填补、采样层SMOTE、模型层训练、交叉验证、评估层指标、混淆矩阵、特征重要性。Notebook 可以是单文件但这六层的边界要在代码里显式体现出来否则后面的二次开发基本没法做。2.1 目录骨架Notebook 只是入口不是全部一个能长期维护的版本目录大致是下面这个样子。主 Notebook 只负责流程编排和出图具体逻辑尽量落在src/里这样单元测试和推理服务都能复用同一份代码。路径职责notebooks/01_eda.ipynb分布、相关性、缺失率摸底只出图不建模notebooks/02_modeling.ipynb主流程预处理 → 采样 → 训练 → 评估src/preprocess.py构建ColumnTransformer定义列分组src/train.py交叉验证、模型对比、选阈值src/predict.py加载 pipeline对外提供单条推理data/raw/*.csv原始数据只读不改artifacts/*.pkl落盘的 pipeline、阈值配置requirements.txt锁版本的依赖清单这里的核心约定是data/raw永远不动手所有清洗结果在内存里产生或写到data/interim。血泪经验是——一旦你在原始 CSV 上手工改过几行后面所有实验都不可复现别人拿到你的包跑出来的数字和你完全对不上排查成本极高。2.2 依赖环境三个库版本对不上就跑不出同样结果表格建模的依赖不多但版本敏感度比想象中高。scikit-learn和imbalanced-learn的版本必须匹配否则Pipeline的接口对不上fit_resample会直接报参数错误pandas2.x 之后groupby().transform()的行为也有细微变化缺失值填补结果可能差几个样本。# 建议单独建虚拟环境Python 3.10 附近兼容性最好 python -m venv .venv source .venv/bin/activate # 锁大版本别用 latestNotebook 里很多写法依赖旧行为 pip install pandas2.1.* numpy1.26.* \ scikit-learn1.3.* imbalanced-learn0.11.* \ matplotlib3.8.* seaborn0.13.* \ joblib1.3.* jupyterlab4.0.* # 落一份锁定清单别人复现时直接装 pip freeze requirements.txt参数说明imbalanced-learn的 0.11 系列和scikit-learn1.3 是对得上的组合跨大版本乱配是新手最常见的翻车点。启动 Notebook 用jupyter lab就行别去折腾全局环境。2.3 数据字典十一个字段里藏着三个陷阱字段本身不复杂age、avg_glucose_level、bmi是连续值hypertension、heart_disease是 0/1 二值gender、ever_married、work_type、Residence_type、smoking_status是类别stroke是标签。三个陷阱得提前说清。第一标签极度不平衡正例通常只占 5% 左右也就是二十个样本里一个卒中这决定了后面所有指标都不能看 accuracy。第二bmi有缺失占比约 4%而且缺失不是完全随机跟年龄和吸烟状态有关。第三gender里会出现样本量极小的Other类别个位数样本喂给 One-Hot 编码只会制造噪声常见做法是直接剔除或并入多数类。这三点在 EDA 阶段就要落到结论上别等到模型跑完发现召回率怎么都上不去再回头查。3. 数据预处理链路缺失值、类别编码与不平衡三板斧预处理决定了这个项目 80% 的效果上限。很多人把精力全放在换模型上从逻辑回归换到 XGBoostAUC 涨了 0.01但回头把bmi的填补策略从全局中位数改成按年龄分组的中位数召回率直接涨了 5 个点。这一章按执行顺序把三段预处理拆开讲。3.1 BMI 的缺失与离谱值分组中位数比全局中位数稳原始表里bmi大约有 4% 的空值另有少量明显不合理的极值。直接fillna(df.bmi.median())能跑但会把高年龄段人群的 BMI 系统性拉低因为 BMI 本身和年龄强相关。我一般先按年龄段和吸烟状态分组取中位数再用全局中位数兜底最后用分位数截断处理极值而不是删样本——正例本来就只有两百多个删一个都心疼。import pandas as pd import numpy as np df pd.read_csv(data/raw/stroke_data.csv) # 主键列对预测没有任何信息量先丢掉 df df.drop(columns[id]) # 类别列统一大小写和空格Male 和 male 必须归一否则编码后是两列 cat_cols [gender, ever_married, work_type, Residence_type, smoking_status] for c in cat_cols: df[c] df[c].astype(str).str.strip().str.lower() # Other 类别只有个位数样本保留只会让编码矩阵多一列噪声 df df[df[gender] ! other].reset_index(dropTrue) # 按年龄段分组比全局中位数更贴近真实分布 df[age_bin] pd.cut(df[age], bins[0, 18, 35, 50, 65, 120], labelsFalse) df[bmi] df.groupby([age_bin, smoking_status])[bmi].transform(median) df[bmi] df[bmi].fillna(df[bmi].median()) # 整组都缺时兜底 # 1% / 99% 分位截断保留样本量又能压住长尾 lo, hi df[bmi].quantile([0.01, 0.99]) df[bmi] df[bmi].clip(lo, hi)逻辑说明pd.cut把连续年龄切成五段是为了让分组后的中位数有统计意义直接用原始age分组会得到几百个只有一两条记录的组中位数基本等于原值等于没填。transform(median)返回与原表等长的序列索引对齐不会出现赋值错位。clip之后age_bin这列要记得删掉它只是中间产物留着会被后面的编码器当成特征。3.2 用 ColumnTransformer 把编码和标准化一次串起来数值列要标准化二值列保持原样类别列做 One-Hot。手动pd.get_dummies也能做但训练和推理时列顺序、列数量容易对不上线上第一次请求就报维度错误。ColumnTransformer的好处是把这套规则固化成一个对象fit一次之后跟着模型一起落盘推理时直接复用。from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder num_cols [age, avg_glucose_level, bmi] bin_cols [hypertension, heart_disease] cat_cols [gender, ever_married, work_type, Residence_type, smoking_status] preprocess ColumnTransformer( transformers[ # 连续列兜底填补 标准化逻辑回归和树模型都能吃 (num, Pipeline([ (imp, SimpleImputer(strategymedian)), (sc, StandardScaler()), ]), num_cols), # 0/1 列已经是数值标准化反而破坏可解释性透传 (bin, passthrough, bin_cols), # handle_unknown 必须给线上会冒出新类别 (cat, OneHotEncoder(handle_unknownignore, dropif_binary), cat_cols), ], remainderdrop, # 中间产物列如 age_bin在这里被自动丢弃 )参数说明handle_unknownignore是关键训练时没见过的类别在推理时会全部编码成 0不会抛异常dropif_binary对只有两个取值的列去掉一列避免共线性对逻辑回归的系数解释更友好remainderdrop顺手解决了上一步age_bin忘删的问题但别依赖它该删还是得删。3.3 SMOTE 必须在切分之后一次数据泄漏的教训正例只有 5%任何模型都会倾向于全判负accuracy 照样 0.95。SMOTE 通过在少数类近邻之间插值造合成样本来缓解这个问题。但这里有个几乎所有教程都写错的地方过采样必须放在训练折内部做绝不能对全量数据做完 SMOTE 再切训练测试集。后者会让同一个原始样本的近邻同时出现在训练和测试里指标虚高线上直接崩。from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE from sklearn.linear_model import LogisticRegression # 用 imblearn 的 Pipeline不是 sklearn 的那个 # sklearn 的 Pipeline 不允许中间步骤改变样本数量 clf ImbPipeline(steps[ (prep, preprocess), # sampling_strategy0.3合成后少数类达到多数类的 30% # 别用 1.0 拉平过度采样会让精确率掉得很难看 (smote, SMOTE(random_state42, sampling_strategy0.3, k_neighbors5)), (model, LogisticRegression(max_iter1000, random_state42)), ])逻辑说明imblearn.Pipeline在fit时只对传入的这一折训练数据做重采样predict阶段自动跳过 SMOTE 步骤这是它和 sklearn 原生 Pipeline 的本质区别。sampling_strategy0.3是个经验值拉到 1.0 会让决策边界严重偏向少数类精确率跌破 0.1业务上没法用。k_neighbors5是默认值正例少于 6 个时得往下调否则 SMOTE 直接报错。4. 建模与评估从逻辑回归基线到树模型的对比实验模型这块最容易陷入的误区是一上来就上集成模型调半天参数最后发现不如把阈值从 0.5 改到 0.2。合理的顺序是先把线性模型跑通当基线再上树模型看提升空间最后统一在同一套交叉验证和同一组指标下比较。4.1 三个基线模型与统一的交叉验证基线选逻辑回归因为它系数可解释能直接告诉业务方“年龄每增加一岁风险的对数几率涨多少”。第二个选随机森林抗噪声、不用太调参。第三个选梯度提升树看非线性交互能带来多少增益。三者共用同一份StratifiedKFold保证每折的正负比例一致。from sklearn.model_selection import StratifiedKFold, cross_validate from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scoring {auc: roc_auc, recall: recall, precision: precision, f1: f1} models { lr: LogisticRegression(max_iter1000, random_state42), rf: RandomForestClassifier(n_estimators300, min_samples_leaf5, class_weightbalanced, random_state42), gbdt: GradientBoostingClassifier(n_estimators200, learning_rate0.05, max_depth3, random_state42), } results {} for name, est in models.items(): # 换掉 pipeline 最后一步预处理和采样逻辑完全复用 pipe clf.set_params(modelest) results[name] cross_validate(pipe, X, y, cvcv, scoringscoring, n_jobs-1)逻辑说明set_params(modelest)复用同一个 pipeline 骨架保证三个模型看到的预处理完全一致否则对比没有意义。class_weightbalanced和 SMOTE 同时用等于把少数类加权了两次实践中通常二选一我一般先用 SMOTE 跑一轮再单独试class_weight看哪条路上限高。n_jobs-1并行跑五折数据量几千行的情况下几十秒出结果。4.2 评估指标accuracy 0.95 的模型为什么不能用一个把所有样本都判成“无卒中”的模型在这份数据上 accuracy 能到 0.95召回率是 0临床上毫无价值。这类项目的核心指标是召回率——漏掉一个高风险患者的代价远大于多叫几个人来做进一步检查。所以看指标的顺序应该是先看召回再看精确率最后看 AUC 和 PR 曲线。指标含义这个项目里怎么看Recall真实正例里被找出来的比例主指标目标 0.75 以上Precision判为正的样本里真正正例的比例次要指标别低于 0.1ROC-AUC排序能力不受阈值影响0.80 上下算正常水平PR-AUC不平衡数据下比 ROC-AUC 更敏感更适合做模型间对比ROC-AUC 在极度不平衡时会显得过于乐观因为它把大量真负例算进了 FPR 的分母。PR 曲线只关心正例更能反映实际使用体验。对比模型时我一般两个都打出来如果 ROC-AUC 接近但 PR-AUC 差一截选 PR-AUC 高的那个。4.3 阈值扫描与混淆矩阵把召回调到业务能接受的位置模型输出的是概率0.5 只是默认切点没有任何理由认为它是最优的。正确做法是在验证集上扫一遍阈值找出满足召回要求的最低切点再用测试集验证。import numpy as np from sklearn.metrics import precision_recall_curve, confusion_matrix # proba 是正类概率取第 1 列 proba best_model.predict_proba(X_test)[:, 1] prec, rec, thr precision_recall_curve(y_test, proba) # 在召回 0.80 的候选点里挑精确率最高的那个阈值 mask rec[:-1] 0.80 best_thr float(thr[mask][np.argmax(prec[:-1][mask])]) print(选定阈值:, round(best_thr, 3)) pred (proba best_thr).astype(int) tn, fp, fn, tp confusion_matrix(y_test, pred).ravel() print(f召回 {tp/(tpfn):.3f} 精确 {tp/(tpfp):.3f} 漏诊 {fn} 例)逻辑说明precision_recall_curve返回的三个数组长度是n1 / n1 / n所以阈值索引要写rec[:-1]和prec[:-1]对齐这是最常见的下标越界来源。best_thr一般会落在 0.15 到 0.3 之间比 0.5 低不少这意味着更多人会被标记为高风险需要业务方接受这个代价。混淆矩阵里的fn是漏诊数这个数字比任何百分比都更能说服人——对着“我们漏掉了 12 个真实病例”这句话没人会再纠结精确率掉了几个点。5. 常见问题排查跑这份 Notebook 最容易踩的五个坑同一份代码别人跑出来 AUC 0.84你跑出来 0.99先别高兴多半是漏了某一步。下面五条是我在类似项目里反复遇到的按“现象 → 原因 → 解决”记。现象一验证集召回 0.9换个数据集掉到 0.2。原因是对全量数据做了 SMOTE 再切分合成样本的近邻同时落在训练集和测试集指标是假的。解决办法是把 SMOTE 塞进imblearn.Pipeline让它只在每一折的训练部分执行测试折永远是原始分布。现象二每次重启内核重跑结果都不一样差得还挺多。原因是train_test_split、SMOTE、随机森林三处都有随机性只要有一处没固定种子五折交叉验证的方差就会放大。解决办法是在 Notebook 开头的配置 cell 里统一定义SEED 42所有带random_state参数的地方全部引用它包括切分、采样、模型和StratifiedKFold的shuffle。现象三推理时报Found unknown categories。原因是线上来了训练集里没出现过的work_type或smoking_status取值OneHotEncoder默认遇到未知类别直接抛异常。解决办法是构造编码器时显式传handle_unknownignore同时在预处理阶段就把类别值统一小写去空格把能提前合并的别名合并掉。现象四同时开了class_weightbalanced和 SMOTE效果反而变差。原因是少数类被加权了两次模型过度偏向正例精确率崩到个位数。解决办法是二选一先单独跑 SMOTE 记一组指标再单独跑class_weight记一组用 PR-AUC 决定留哪个不要凭感觉叠加。现象五joblib.load回来的模型预测结果和 Notebook 里的对不上。原因通常有两种只存了model没存preprocess推理时原始字段直接喂进去或者推理时传的是numpy array而不是DataFrameColumnTransformer按列名匹配失败静默走了错误的列顺序。解决办法是整体joblib.dump那个完整的 pipeline 对象推理入参强制构造成DataFrame并保持列名与训练时一致。注意这五条里前两条属于“指标虚高”后三条属于“线上静默出错”。虚高还能靠对比发现静默出错往往要等业务方反馈才知道所以回归测试比调参重要得多。6. 二次开发把 Notebook 改成能对外服务的推理函数Notebook 跑通只是第一步。真要给别人用改造点集中在三处把预处理和模型固化成一份 pipeline 文件、把阈值从代码里挪到配置、把预测包成一个带列校验的函数。import joblib import pandas as pd # 落盘的是完整 pipelineprep model不是单独的 estimator bundle joblib.load(artifacts/stroke_pipeline.pkl) FEATURES [gender, age, hypertension, heart_disease, ever_married, work_type, Residence_type, avg_glucose_level, bmi, smoking_status] def predict_risk(record: dict, threshold: float 0.18) - dict: # 列顺序必须和训练时一致ColumnTransformer 按列名匹配 row pd.DataFrame([record])[FEATURES] proba float(bundle.predict_proba(row)[:, 1][0]) return {probability: round(proba, 4), label: int(proba threshold)}三个细节值得盯。threshold不要写死不同人群、不同筛查场景对召回的要求不一样放到配置里最省事。FEATURES的顺序必须和训练时完全一致少一列或者换个顺序ColumnTransformer可能静默出错而不报异常。返回值给概率而不是只给 0/1业务方才能自己分层做分级干预。改造优先级我大致按这个顺序排pipeline 落盘保证训练推理一致→ 阈值配置化 → 输入列校验能挡住大部分线上报错→ 特征重要性输出让结论可解释。前三项加起来一天之内能做完收益却是数量级的。我踩过最疼的一次是只存了model没存preprocess线上拿原始字段直接喂进去模型照样吐出一个 0 到 1 之间的数看着挺正常其实全错。静默失败比报错难查十倍。从那以后我每次保存模型都强制走一遍“加载回来、用同一批样本对比概率”的回归检查差超过 1e-6 就不许上线。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑