95%准确率却抓不到病例:卒中预测的类别不平衡与Pipeline落地

发布时间:2026/10/27 10:55:15
95%准确率却抓不到病例:卒中预测的类别不平衡与Pipeline落地

简介:围绕Kaggle中风预测数据集构建的机器学习实战项目,面向希望掌握二分类建模、数据可视化与模型部署的Jupyter使用者,也适合作为医疗健康数据课程案例。项目以性别、年龄、疾病史和吸烟状况等特征预测患者是否可能中风,目标是在严格评估下提升正负类别区分能力,并呈现从数据探索到应用落地的完整链路。压缩包共11个文件、约4.36MB,含多个ipynb笔记本、csv原始数据、pkl训练模型、py部署脚本以及依赖清单和运行配置,便于复现实验、比较模型与继续调参。已有741人学习,读者可据此梳理数据清洗、缺失值处理、特征编码、模型训练和指标分析思路,并结合模型持久化与应用部署文件搭建预测演示;目录按数据、建模与部署拆分,适合按模块学习并替换特征或尝试其他分类器。

1. 95% 准确率却抓不到一个病例,这份 Notebook 在补哪个洞

先给一个反直觉的结论:一份在测试集上准确率 95% 的卒中预测模型,很可能一个真实病例都没抓到。stroke-prediction-model 这份 Jupyter Notebook 处理的正是这类问题——把年龄、高血压史、心脏病史、平均血糖、BMI、吸烟状态这十几列结构化字段,走完「清洗—编码—不平衡处理—模型对比—阈值选取—推理落盘」的完整链路。它不是调包演示,重点落在类别不平衡、缺失值填补位置、评估口径这三个最容易糊弄过去的地方。适合两类人:想完整跑一遍表格建模流程的初中级算法同学,以及要做慢病风险评分原型的工程同学。看完你大概能判断它值不值得下、能不能直接改成自己业务里的字段。

2. 数据管线拆解:把十几列临床字段拼成能进模型的矩阵

拿到这类 Notebook,我第一件事不是Run All,而是把数据准备那段单独拎出来读。原因很实际:模型分数不好,八成问题出在这一段,而不是出在换了哪个分类器上。卒中预测这份数据的字段结构很典型——几列连续值、几列二值病史标记、几列名义变量,还有一个带缺失的 BMI。每一类字段的处理方式都不一样,混在一起处理就会出事。

2.1 字段语义与类型判定

判类型不能只看dtypes。hypertension存成 int64,它是二值标记不是连续变量;smoking_status里可能有个Unknown类别,它不是缺失,是真实取值;bmi的缺失被写成N/A的时候,read_csv会把它变成 object 列,后面的StandardScaler直接报错。所以第一步是先把字段语义列清楚,再决定处理方式。

字段语义类型判定建议处理
age年龄连续保留原值,线性模型可另加分段
hypertension高血压史二值 0/1原样进模型,不要再 one-hot
heart_disease心脏病史二值 0/1同上
avg_glucose_level平均血糖连续、右偏保留原值,线性模型可加 log1p
bmi体质指数连续、有缺失分组中位数 + 缺失指示列
smoking_status吸烟状态名义,含 Unknownone-hot,Unknown 单独成一类
ever_married / work_type / Residence_type社会属性名义one-hot,drop 一列防共线
gender性别名义,可能有小类one-hot,注意小类样本量

这张表我一般会写进 Notebook 第一个 Markdown cell,后面每加一个特征就回来对一眼。血泪经验是:二值列被 one-hot 成两列之后,和截距项共线,逻辑回归的系数会变得没法解释,而你还以为模型学到了东西。

2.2 缺失值与异常值的处理顺序

先摸清缺失分布,再动手填。dtype、缺失率、唯一值个数三个指标放在一起看,能区分「真缺失」和「伪缺失」——比如 BMI 被写成 0,缺失率统计不出来,但它其实是个占位符。

import pandas as pd df = pd.read_csv("data/stroke.csv", na_values=["N/A", "Unknown", ""]) report = pd.DataFrame({ "dtype": df.dtypes.astype(str), "missing": df.isna().sum(), "missing_pct": (df.isna().mean() * 100).round(2), "n_unique": df.nunique(), }) print(report.sort_values("missing_pct", ascending=False))

na_values那行是关键:把常见的占位符提前声明成缺失,比事后用字符串替换干净得多。n_unique用来识别名义变量,一般唯一值个数在 2 到 10 之间、又不是 0/1 的列,基本可以判定为类别列。跑完先看缺失率最高的那几列,如果某一列缺失超过 40%,我更倾向于直接丢掉而不是硬填。

BMI 的填补方式值得单独说。它的缺失不是完全随机的,往往和年龄、吸烟状态相关,全局中位数会把分布压平。常见做法是按smoking_status和gender分组取中位数,再补一列缺失指示:

df["bmi_missing"] = df["bmi"].isna().astype(int) df["bmi"] = df.groupby(["smoking_status", "gender"])["bmi"].transform( lambda s: s.fillna(s.median()) ) df["bmi"] = df["bmi"].fillna(df["bmi"].median())

transform而不是apply,是因为前者返回与原表同索引的 Series,不会把分组键带回来造成列错位。最后那行兜底不能省——某些小组可能整组都是空值,median()返回 NaN,后面标准化就会炸。至于缺失指示列,它本身是有信息量的(缺失可能和就诊记录完整度相关),但要注意别让它变成泄漏源,这一点在避坑那章会展开。

异常值方面,血糖这一列长尾很明显,个位数到两三百都可能出现。我不建议直接按 3σ 截断——高血糖本身就是卒中风险信号,砍掉尾部等于把最有用的样本删了。线性模型里加个log1p变换就够,树模型可以完全不动。

2.3 编码与缩放的落点

到了这一步,最容易犯的错是把预处理写在切分之前,或者散落在 Notebook 各处,最后训练和推理用了两套逻辑。我的习惯是用ColumnTransformer一次性写死,让预处理成为模型的一部分。

from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.impute import SimpleImputer num_cols = ["age", "avg_glucose_level", "bmi"] bin_cols = ["hypertension", "heart_disease", "bmi_missing"] cat_cols = ["gender", "ever_married", "work_type", "Residence_type", "smoking_status"] pre = ColumnTransformer( transformers=[ ("num", Pipeline([ ("imp", SimpleImputer(strategy="median")), ("sc", StandardScaler()), ]), num_cols), ("cat", Pipeline([ ("imp", SimpleImputer(strategy="most_frequent")), ("oh", OneHotEncoder(handle_unknown="ignore", drop="first")), ]), cat_cols), ("bin", "passthrough", bin_cols), ], remainder="drop", )

三组分别处理,是因为它们的统计性质不同:连续列要标准化,类别列要 one-hot,二值列什么都不用做。handle_unknown="ignore"是给线上准备的——推理时遇到训练集没见过的类别值,它会输出全零而不是抛异常,这比在服务里包一层 try/except 干净。drop="first"去掉每个类别组的第一列,避免和截距共线;如果你用的是树模型,这个参数设成None也无所谓,但统一成first能让逻辑回归的系数可读。

remainder="drop"是个保险丝:任何没被显式列进三组的字段会被丢掉,包括 id 列。如果你希望遇到未声明字段时直接报错,把它改成"raise",在开发阶段更安全。

还有一点,标准化必须放在Pipeline里而不是提前对整份数据做。理由很直白:StandardScaler拟合时用了均值和方差,如果在切分前拟合,测试集的统计量就渗进了训练过程,交叉验证的分数会偏乐观。

3. 类别不平衡才是主线:SMOTE、class_weight 与阈值三选一

卒中数据的正例占比通常只有几个百分点,这个比例决定了整篇文章的技术重心。很多人跑到这里,看到准确率 0.95 就收工了,然后拿去给业务方演示,一上线发现召回率接近零。这一章讲清楚为什么,以及三种主流手段该怎么选、参数怎么调。

3.1 为什么 accuracy 会骗人

先跑一个只会输出多数类的假模型,把评估下限钉死:

from sklearn.dummy import DummyClassifier from sklearn.metrics import accuracy_score, recall_score, average_precision_score dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train) pred = dummy.predict(X_test) print("accuracy:", round(accuracy_score(y_test, pred), 4)) print("recall :", round(recall_score(y_test, pred, zero_division=0), 4)) print("pr_auc :", round(average_precision_score(y_test, pred), 4))

DummyClassifier永远预测负类,准确率就等于负例占比,召回率恒为 0。这段代码的价值不在于它多强,而在于它给出了一个下限——任何模型的准确率如果只比这个数高一点点,那它就是没学到东西。zero_division=0是为了避免正例全没预测到时 sklearn 打警告刷屏。

从这里开始,评估指标就应该换成召回率、PR-AUC(average_precision_score)和混淆矩阵。ROC-AUC 在极度不平衡下会偏乐观,因为它把真负例率也算了进去,而真负例本来就多。我一般两个都记,但决策看 PR-AUC。

3.2 三种重采样策略的实现

三种手段的定位不一样:

  • class_weight="balanced":改损失函数里各类的权重,不动样本。实现最简单,不引入合成样本,概率输出会有偏移但排序基本不变。适合样本量不大、想快速拿到基线的场景。
  • SMOTE 过采样:在少数类的近邻之间插值造新样本。能提升召回,但样本量小的时候容易放大噪声,而且合成样本会让预测概率失去校准意义。
  • 阈值移动:什么都不改,训练完在 PR 曲线上挑一个 cutoff。最省事,也最容易被忽略。

我一般先跑 class_weight 版本当基线,再上 SMOTE 对比,最后统一用阈值扫描收尾。SMOTE 的落地有个硬性要求——必须放在imblearn的 Pipeline 里,不能提前对整个训练集做完再交叉验证:

from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE from sklearn.linear_model import LogisticRegression clf = ImbPipeline(steps=[ ("pre", pre), ("smote", SMOTE(random_state=42, k_neighbors=5, sampling_strategy=0.3)), ("lr", LogisticRegression(max_iter=2000)), ])

这里三个参数值得说。sampling_strategy=0.3表示把正例补到负例数量的 30%,而不是常见的 1:1。补到 1:1 会让正例膨胀十几倍,合成样本的噪声被同比放大,模型在真实分布上的表现反而下滑;0.2 到 0.4 这个区间我试下来比较稳,具体看正例绝对数量,正例少于 200 条时还要再降。k_neighbors=5是默认值,正例很少时降到 3,否则近邻里会混进噪声点。random_state固定是为了可复现,SMOTE 内部有随机选点的步骤。

用imblearn.pipeline.Pipeline而不是 sklearn 的那个,是因为只有前者认识fit_resample方法。这个类的调用方式和 sklearn 完全一致,fit/predict_proba都能直接用,pre也照常在里面拟合。交叉验证时,每一折只在训练部分做重采样,验证部分保持原始分布——这正是评估结果可信的前提。

3.3 阈值扫描与 PR 曲线

模型输出的是概率,业务要的是判定。这两者之间隔着一个阈值,而默认的 0.5 在 imbalance 场景下几乎总是错的。

import numpy as np from sklearn.metrics import precision_recall_curve proba = clf.predict_proba(X_test)[:, 1] prec, rec, thr = precision_recall_curve(y_test, proba) f1 = 2 * prec * rec / (prec + rec + 1e-9) best = np.argmax(f1[:-1]) print("best_thr:", round(float(thr[best]), 3), "precision:", round(float(prec[best]), 3), "recall:", round(float(rec[best]), 3))

注意thr的长度比prec和rec少一位,所以索引用f1[:-1]对齐,这是precision_recall_curve的固定行为,写错就会 index 越界。1e-9是防止除零。

选阈值的准则不该盲目用 F1。筛查类场景更看重召回,可以把目标定成「召回不低于 0.8 的前提下,取精确率最高的那个点」:

mask = rec[:-1] >= 0.80 candidates = np.where(mask)[0] best = candidates[np.argmax(prec[:-1][candidates])]

这个阈值后面要跟着模型一起存下来,不能等到推理时再写死 0.5,第六章会把这件事固化。还有一点:如果用了 SMOTE,输出的概率分布已经和真实先验不一致,predict_proba的绝对值不可信,但排序仍然有效,所以阈值扫描这个做法本身不受影响;如果业务需要「给出一个风险百分比」,那得另做概率校准。

4. 模型对比与评估:从 LogisticRegression 到树模型的选择依据

这一章解决一个很实际的问题:Notebook 里往往塞了五六个模型,到底该留哪个。我的判断顺序是基线优先、方差优先、可解释性优先,性能排在它们后面。

4.1 基线模型不要跳

逻辑回归必须跑,而且必须是第一个跑的。原因不是它准,而是它的系数方向可以直接拿去和常识对照:年龄的系数应该是正的,高血压史、心脏病史应该是正的,如果某一列的方向反了,基本可以断定编码写错了或者数据泄漏了。这是一次免费的正确性检查,跳过它直接上梯度提升,出了问题你连从哪查都不知道。

标准化之后,coef_的大小还能当作粗略的重要性排序,配合pre.get_feature_names_out()把列名对回去:

clf.fit(X_train, y_train) names = clf.named_steps["pre"].get_feature_names_out() coef = clf.named_steps["lr"].coef_[0] for n, c in sorted(zip(names, coef), key=lambda x: -abs(x[1]))[:10]: print(f"{n:40s}{c:+.3f}")

get_feature_names_out()输出的列名带前缀,形如num__age、cat__work_type_Private,前缀对应ColumnTransformer里那三组的名字,对不上号的时候按前缀查就行。

4.2 交叉验证与分层抽样

单次切分的方差在不平衡数据上大得离谱,换个种子分数能掉一大截。所以评估必须走分层交叉验证,并且同时记录多个指标:

from sklearn.model_selection import StratifiedKFold, cross_validate cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scoring = { "roc_auc": "roc_auc", "pr_auc": "average_precision", "recall": "recall", "precision": "precision", } scores = cross_validate(clf, X_train, y_train, cv=cv, scoring=scoring, n_jobs=-1) for k in scoring: v = scores[f"test_{k}"] print(f"{k:10s} {v.mean():.3f} ± {v.std():.3f}")

StratifiedKFold保证每折的正例比例和整体一致,这是不平衡数据的前提。shuffle=True必须配random_state,否则每次跑出来的折不一样,分数就不可比。n_jobs=-1开满核,五个模型乘五折的网格搜索不开并行会等到怀疑人生。

如果正例特别少(比如不到 300 条),5 折里每折验证集只有几十个正例,标准差依然很大。这时换成RepeatedStratifiedKFold(n_splits=5, n_repeats=3),代价是三倍时间,换来的是均值可信。

4.3 解释性输出与模型选择

树模型的重要度有个坑:基于不纯度下降的重要度会偏向取值多的连续特征,age这种高基数连续列经常莫名其妙排第一。换成排列重要度更靠谱,它衡量的是「把这列打乱之后指标掉多少」,口径统一:

from sklearn.inspection import permutation_importance clf.fit(X_train, y_train) r = permutation_importance( clf, X_test, y_test, scoring="average_precision", n_repeats=10, random_state=42, n_jobs=-1, ) for i in r.importances_mean.argsort()[::-1][:8]: print(f"{names[i]:40s}{r.importances_mean[i]:.4f}")

scoring要和你最终决策用的指标一致,用准确率算出来的重要度对不平衡数据没有参考价值。n_repeats=10是打乱十次取平均,少于 5 次结果抖得厉害。

几个模型的定位我用一张表来定,具体数值要看你自己跑出来的交叉验证结果,但相对关系和适用边界是稳定的:

模型排序能力训练成本可解释性什么时候留它
DummyClassifier下限基线极低无只用来看评估下限
逻辑回归中低强,系数方向可对常识需要向非技术方解释、要做评分卡
随机森林中高中中,重要度有偏特征间交互明显、样本量中等
梯度提升高中高弱,需 SHAP 辅助追求排序质量、能接受调参成本

我的默认组合是:逻辑回归做基线并保留作解释入口,梯度提升做主力预测,两者在验证集上的 PR-AUC 差距如果在 0.03 以内,就选逻辑回归——线上维护成本和沟通成本都低得多。

5. 避坑与排查:这份 Notebook 上最容易翻车的五处

这一章是按「现象 → 原因 → 解决」整理的实战记录,每条我都真实遇到过至少一次。跑之前对着过一遍,能省掉大半天。

5.1 重采样与特征泄漏

现象:验证集 PR-AUC 跑到 0.8 以上,拿去给某导师看,换成新一批数据直接掉到 0.2 附近。

原因:SMOTE 在切分之前对全量数据执行了。合成样本是用「未来」的测试集样本插值出来的,验证集里混进了训练信息的影子,分数虚高。这是这类 Notebook 里最高频的错误,很多示例代码图省事就这么写。

解决:把重采样塞进imblearn的Pipeline,只在fit时对训练折生效,验证折保持原始分布。判断方法很直接——cross_validate的分数比单次train_test_split低一截,通常说明单次那个是有泄漏的。

现象:树模型的特征重要度里,id或者某个索引列排在前三。

原因:ColumnTransformer用了remainder="passthrough",把没声明的列原样带进了特征矩阵。id 列虽然是递增整数,树模型照样能在上面切出与标签偶然相关的分裂点。

解决:remainder设成"drop",或者在读数据之后第一行就df = df.drop(columns=["id"])。做完之后重新看一遍get_feature_names_out(),确认列数和预期一致。

5.2 评估口径与阈值

现象:两次实验只改了random_state,PR-AUC 从 0.42 跳到 0.28。

原因:正例太少,单次train_test_split的验证集里正例只有几十个,一个样本的归属变化就能让指标大幅波动。

解决:改用StratifiedKFold报告均值 ± 标准差,正例不足 300 条时上RepeatedStratifiedKFold。我现在的习惯是任何单次切分的分数都不写进结论,只作为调试时的快速反馈。

现象:模型上线后,被判为高风险的人寥寥无几,业务方反馈「这个模型什么都不预警」。

原因:推理代码里写死了proba > 0.5。在不平衡数据上,0.5 这个默认阈值对应的是极低的召回率,模型其实排序正常,只是切点选错了。

解决:训练阶段就用 PR 曲线扫出阈值,和模型一起落盘(第六章会写具体做法)。另外要意识到,用了class_weight或 SMOTE 之后,概率的绝对值已经偏移,predict_proba不能直接当风险百分比解释,需要校准才有物理意义。

5.3 填补位置与复现

现象:填补完 BMI 之后,bmi_missing这一列的重要度异常高,甚至超过年龄。

原因:缺失本身可能与标签相关(比如重症患者的检查记录更完整,反而缺失少),也可能是在切分前用全量中位数填补,让缺失模式带上了测试集信息。两种情况都会让这一列变成捷径特征。

解决:把SimpleImputer放进Pipeline内部,用训练折的统计量拟合;同时单独验证一次缺失指示列与标签的相关性,如果相关性过强,考虑去掉这一列只保留填补后的值。跑完对比一次有/无该列的 PR-AUC,差距超过 0.05 就要警惕。

现象:Notebook 在自己机器上跑得好好的,交给 A 同学复现,OneHotEncoder直接报参数错误。

原因:sparse和sparse_output这两个参数名在不同版本之间改过,imblearn的Pipeline和 sklearn 的版本也有兼容区间。Notebook 里没有锁版本,谁装谁崩。

解决:在仓库根目录放一个requirements.txt,把scikit-learn、imbalanced-learn、pandas固定到具体小版本;Notebook 第一个 cell 打印sklearn.__version__。这事看起来琐碎,但它是别人愿不愿意继续用这份代码的分水岭。

注意:imblearn的 Pipeline 和 sklearn 的 Pipeline 不能混用。你在cross_validate里传的必须是最外层那个ImbPipeline对象,中间任何一步换成 sklearn 的版本,重采样都会被静默跳过,而且不报错。

6. 从 Notebook 到能用的推理脚本:把预处理和阈值一起焊死

Notebook 跑通只是半成品,真正交付要解决的是「换一批数据能不能给出同样的结论」。我踩过最贵的一次坑,是训练时用了一套编码、推理时手写了另一套,特征顺序错位,模型没报错但输出全乱。从那以后,我每次交付前都强制走一遍「落盘 + 冒烟测试」的流程。

6.1 用 Pipeline 把预处理焊死

预处理必须和模型一起序列化成一个对象,推理端只负责构造 DataFrame。这样训练和推理用的是同一份ColumnTransformer,顺序、编码、缩放全部一致:

import joblib joblib.dump(clf, "artifacts/model.joblib") # 推理端:只传原始字段,不要自己编码 raw = pd.DataFrame([{ "gender": "Male", "age": 67, "hypertension": 1, "heart_disease": 0, "ever_married": "Yes", "work_type": "Private", "Residence_type": "Urban", "avg_glucose_level": 228.69, "bmi": 36.6, "smoking_status": "formerly smoked", }]) model = joblib.load("artifacts/model.joblib") print(model.predict_proba(raw)[:, 1])

这里raw里故意没有bmi_missing——那一列是在数据准备阶段派生出来的,推理端不该知道它的存在。稳妥的做法是把派生逻辑也塞进 Pipeline 的第一个步骤(用FunctionTransformer包一个函数),让raw只需要最小字段集。这份 Notebook 里如果没这么写,我一般会补上,否则线上每次加字段都要改两处代码。

6.2 阈值和元数据一起落盘

阈值不能硬编码在推理脚本里,模型换一版阈值就该跟着换。我习惯存一个 JSON:

import json meta = { "threshold": float(thr[best]), "target_recall": 0.80, "features": list(raw.columns), "trained_at": "见训练日志", "metrics": {"pr_auc": 0.0, "recall": 0.0}, } with open("artifacts/meta.json", "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2)

features这一项是防呆用的:推理时先校验传入的列名集合是否等于它,不等就直接拒绝,比让模型静默输出一个错误概率强得多。metrics里的数字从训练阶段填进去,别留 0.0,否则上线三个月后没人记得这版模型的评估结果。

6.3 一条命令做冒烟测试

最后留一个infer.py,用几条写死的样本跑通全流程,退出码非零就算失败:

python infer.py --model artifacts/model.joblib --meta artifacts/meta.json

脚本里做三件事:校验列名、输出概率和判定结果、断言概率落在 [0, 1] 且判定结果与手算阈值一致。把这条命令接进 CI,每次改代码自动跑一遍,能挡住绝大多数「改了预处理忘了重训」的低级事故。

还有一个容易被忽略的点:joblib的版本兼容性。跨大版本加载模型可能报属性错误,所以我在meta.json里额外记一行训练时的 sklearn 版本,加载失败时先比对版本再排查其他原因,比盲目 debug 快得多。至于这份 Notebook 本身的产物,模型、元数据和那个冒烟脚本都在资源包里,按目录结构对号入座就能跑起来;artifacts/下的文件是我自己重训后替换的,你拿到手建议也用本地数据重跑一遍再上线。希望帮到你。

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