☰
脑卒中风险预测实战:表格建模、SMOTE与阈值调优
2026/10/26 1:40:40 网站建设 项目流程

简介:这是一份围绕中风风险预测的 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 # 锁大版本,别用 latest,Notebook 里很多写法依赖旧行为 pip install "pandas==2.1.*" "numpy==1.26.*" \ "scikit-learn==1.3.*" "imbalanced-learn==0.11.*" \ "matplotlib==3.8.*" "seaborn==0.13.*" \ "joblib==1.3.*" "jupyterlab==4.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% 的效果上限。很多人把精力全放在换模型上,从逻辑回归换到 XGBoost,AUC 涨了 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(drop=True) # 按年龄段分组,比全局中位数更贴近真实分布 df["age_bin"] = pd.cut(df["age"], bins=[0, 18, 35, 50, 65, 120], labels=False) 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(strategy="median")), ("sc", StandardScaler()), ]), num_cols), # 0/1 列已经是数值,标准化反而破坏可解释性,透传 ("bin", "passthrough", bin_cols), # handle_unknown 必须给,线上会冒出新类别 ("cat", OneHotEncoder(handle_unknown="ignore", drop="if_binary"), cat_cols), ], remainder="drop", # 中间产物列(如 age_bin)在这里被自动丢弃 )

参数说明:handle_unknown="ignore"是关键,训练时没见过的类别在推理时会全部编码成 0,不会抛异常;drop="if_binary"对只有两个取值的列去掉一列,避免共线性,对逻辑回归的系数解释更友好;remainder="drop"顺手解决了上一步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_strategy=0.3:合成后少数类达到多数类的 30%, # 别用 1.0 拉平,过度采样会让精确率掉得很难看 ("smote", SMOTE(random_state=42, sampling_strategy=0.3, k_neighbors=5)), ("model", LogisticRegression(max_iter=1000, random_state=42)), ])

逻辑说明:imblearn.Pipeline在fit时只对传入的这一折训练数据做重采样,predict阶段自动跳过 SMOTE 步骤,这是它和 sklearn 原生 Pipeline 的本质区别。sampling_strategy=0.3是个经验值,拉到 1.0 会让决策边界严重偏向少数类,精确率跌破 0.1,业务上没法用。k_neighbors=5是默认值,正例少于 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_splits=5, shuffle=True, random_state=42) scoring = {"auc": "roc_auc", "recall": "recall", "precision": "precision", "f1": "f1"} models = { "lr": LogisticRegression(max_iter=1000, random_state=42), "rf": RandomForestClassifier(n_estimators=300, min_samples_leaf=5, class_weight="balanced", random_state=42), "gbdt": GradientBoostingClassifier(n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42), } results = {} for name, est in models.items(): # 换掉 pipeline 最后一步,预处理和采样逻辑完全复用 pipe = clf.set_params(model=est) results[name] = cross_validate(pipe, X, y, cv=cv, scoring=scoring, n_jobs=-1)

逻辑说明:set_params(model=est)复用同一个 pipeline 骨架,保证三个模型看到的预处理完全一致,否则对比没有意义。class_weight="balanced"和 SMOTE 同时用等于把少数类加权了两次,实践中通常二选一,我一般先用 SMOTE 跑一轮,再单独试class_weight,看哪条路上限高。n_jobs=-1并行跑五折,数据量几千行的情况下几十秒出结果。

4.2 评估指标:accuracy 0.95 的模型为什么不能用

一个把所有样本都判成“无卒中”的模型,在这份数据上 accuracy 能到 0.95,召回率是 0,临床上毫无价值。这类项目的核心指标是召回率——漏掉一个高风险患者的代价远大于多叫几个人来做进一步检查。所以看指标的顺序应该是:先看召回,再看精确率,最后看 AUC 和 PR 曲线。

指标含义这个项目里怎么看
Recall真实正例里被找出来的比例主指标,目标 0.75 以上
Precision判为正的样本里真正正例的比例次要指标,别低于 0.1
ROC-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/(tp+fn):.3f} 精确 {tp/(tp+fp):.3f} 漏诊 {fn} 例")

逻辑说明:precision_recall_curve返回的三个数组长度是n+1 / n+1 / 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_unknown="ignore",同时在预处理阶段就把类别值统一小写去空格,把能提前合并的别名合并掉。

现象四:同时开了class_weight="balanced"和 SMOTE,效果反而变差。原因是少数类被加权了两次,模型过度偏向正例,精确率崩到个位数。解决办法是二选一,先单独跑 SMOTE 记一组指标,再单独跑class_weight记一组,用 PR-AUC 决定留哪个,不要凭感觉叠加。

现象五:joblib.load回来的模型,预测结果和 Notebook 里的对不上。原因通常有两种:只存了model没存preprocess,推理时原始字段直接喂进去;或者推理时传的是numpy array而不是DataFrame,ColumnTransformer按列名匹配失败,静默走了错误的列顺序。解决办法是整体joblib.dump那个完整的 pipeline 对象,推理入参强制构造成DataFrame并保持列名与训练时一致。

注意:这五条里前两条属于“指标虚高”,后三条属于“线上静默出错”。虚高还能靠对比发现,静默出错往往要等业务方反馈才知道,所以回归测试比调参重要得多。

6. 二次开发:把 Notebook 改成能对外服务的推理函数

Notebook 跑通只是第一步。真要给别人用,改造点集中在三处:把预处理和模型固化成一份 pipeline 文件、把阈值从代码里挪到配置、把预测包成一个带列校验的函数。

import joblib import pandas as pd # 落盘的是完整 pipeline(prep + 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 就不许上线。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询