ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

4.9%正例率下的卒中风险预测:不平衡表格二分类全流程

4.9%正例率下的卒中风险预测:不平衡表格二分类全流程

简介:围绕Kaggle中风预测数据集构建的机器学习实战项目,面向希望掌握二分类建模全流程的Jupyter Notebook学习者与数据科学初学者。项目以性别、年龄、疾病史、吸烟状况等特征预测个体是否可能发生中风,覆盖数据清洗、可视化探索、特征筛选、模型训练与评估等环节,并以较高F1分数与AUC为目标讨论阳性与阴性类别的区分能力。压缩包共11个文件,包含3个ipynb分析笔记、csv数据、pkl模型、py与sh脚本、依赖说明及部署配置等,整体约4.36MB,目录紧凑,配合说明文档可快速梳理字段含义与运行方式,便于复现实验并理解从建模、模型保存到应用部署的衔接。已有741人学习下载,可参考完整分析思路、模型持久化方式、部署配置与项目说明,适合课程设计、竞赛练手、医疗数据二分类入门复盘,也可作为同类表格数据项目的参考模板。

1. 一个 4.9% 正例率的表,为什么值得单独做一套预测模型

卒中风险预测模型这个题目,很多人第一版 Jupyter Notebook 跑完会得到一个 0.95 的准确率,然后误以为收工——直到发现把所有样本一律判成“无卒中”,准确率同样是 0.95。正例占比不到 5% 的表格二分类,难点从来不在调模型,而在需求口径、数据清洗、不平衡处理和评估方式这四件事。

这套方案面向两类人:一类是要交毕设或课设,需要一份结构完整、有指标支撑、能讲清楚取舍的卒中风险预测项目;另一类是手里真有一张患者登记表,想做出可交付的分层筛查工具的从业者。

后面按「需求定义 → 数据底座 → 特征流水线 → 模型训练 → 踩坑排查 → 验收答辩」推进,每一章都能对着自己那份数据直接改。

2. 需求与数据底座:先钉死口径,再谈 12 个字段怎么处理

2.1 预测目标:是筛查排序,不是临床确诊

动手写第一行read_csv之前,必须先回答一个问题:模型输出的概率,交给谁、用来干什么。这个答案直接决定后面所有指标的选择。

通常的做法是把场景定成“门诊或体检场景下的风险分层筛查”:模型读入年龄、血压相关病史、血糖、BMI、吸烟状态这类低成本可得信息,输出一个 0~1 的风险分,用于把人群粗排成高、中、低三层,高分的人被提示去做进一步检查。它不承担确诊职责,也没有影像和实验室金标准,所以对外表述时不能写成“诊断”。

这个口径带来三个硬约束:

第一,指标以召回优先。在 5100 人里只有两百多个正例的情况下,漏掉一个高风险个体的代价,远大于把几十个健康人标成中风险。所以离线和线上都先看 Recall 和 PR-AUC,再看精确率,准确率只作为一个参照系放在最后。

第二,特征必须是事件前可采集的。如果原始表里有出院结局、随访天数、住院次数、用药记录这类字段,一律不能入模——它们和标签同源,放进去 AUC 会漂亮得离谱,换一批数据立刻崩。我在审查别人的 Notebook 时,第一个翻的就是特征列表里有没有这类“结果类”字段。

第三,时间切片要对齐。这份数据是横断面登记,不是纵向随访队列,意味着它回答的是“这批人里哪些特征与卒中史相关”,而不是“这些人未来三年会不会卒中”。答辩时把这一点说清楚,比多刷 0.02 的 AUC 更能体现你懂业务。

提示:如果导师或评审问“这个模型能不能直接上临床”,正确回答是“不能,它是筛查辅助脚本,需要前瞻性队列验证和伦理审查才能谈临床使用”。硬说能,反而暴露对方案边界的理解不足。

2.2 数据字典:12 个字段分四类处理

字段不多,但每一类的处理逻辑完全不同。先把它们归到四组,后面写ColumnTransformer时就能一一对应。

字段类型取值/范围处理策略
id整数唯一编号建模时丢弃,仅用于回溯
gender类别Male / Female / Other独热编码,Other 样本极少,可并入 Female 并加注释
age数值0.08 ~ 82保留原始值,注意婴儿期记录
hypertension二值0 / 1直接入模,不缩放
heart_disease二值0 / 1直接入模
ever_married类别Yes / No独热编码
work_type类别5 类独热编码,Private 占比接近六成,注意分布偏移
Residence_type类别Urban / Rural独热编码
avg_glucose_level数值55 ~ 272长尾,标准化后可考虑分箱对照
bmi数值10 ~ 98,含缺失中位数或分组中位数填补,缺失加指示变量
smoking_status类别4 类,含 UnknownUnknown 单独成类,不要轻易删
stroke标签0 / 1,正例约 4.9%目标变量,不做任何预处理

几点说明。id必须删,但在删之前要把它留在一份单独的索引里,后面分析误判样本时靠它回查。hypertension和heart_disease本身就是 0/1,标准化只会增加解释成本,直接放过去更合适。

smoking_status的 Unknown 占三成左右,很多人第一反应是删掉这批样本。但删之前要做一次交叉检查:如果 Unknown 组的正例率明显高于已知组,那这批人不是随机缺失,删掉就是主动丢弃信号。我的习惯是保留 Unknown 作为一个独立类别,同时把“是否为 Unknown”做成一个指示特征,让模型自己决定权重。

age里能看到 0.08 这种婴儿记录,这在登记数据里很常见,通常是单位或录入格式问题。处理方式不是删,而是加一条校验规则,把小于 1 岁的记录单独打标,再在 EDA 里看这批样本的标签分布。

2.3 数据质量排查:缺失、异常与口径不一致

数据质量检查不要指望“看一眼 describe 就够了”,describe()只会告诉你分布,不会告诉你缺失和标签之间的关系。真正要查的是三件事:缺多少、缺得有没有规律、异常值是错误值还是真实极值。

import pandas as pd df = pd.read_csv("stroke_data.csv") # 1) 缺失率、唯一值数一起看 report = pd.DataFrame({ "missing": df.isna().sum(), "missing_pct": (df.isna().mean() * 100).round(2), "n_unique": df.nunique(), }) print(report.sort_values("missing_pct", ascending=False)) # 2) 缺失是否与标签相关——这一步决定能不能直接删 df["bmi_missing"] = df["bmi"].isna().astype(int) print(df.groupby("bmi_missing")["stroke"].agg(["mean", "count"])) # 3) 数值列的极值检查,用分位数而不是最大最小值 print(df[["age", "avg_glucose_level", "bmi"]].describe(percentiles=[.01, .5, .99]))

第一段统计缺失率和唯一值数,唯一值数能帮你快速发现“看起来是数值、实际是编码”的字段。第二段是关键:把 BMI 缺失与否变成一个 0/1 变量,再按它分组看正例率。如果缺失组的正例率和完整组差得远,说明缺失不是随机的,直接dropna()会引入偏倚,这时候只能填补并保留缺失指示变量。第三段用 1% 和 99% 分位数看极值,比看最大最小值靠谱,因为最大最小值往往只是个录入错误。

口径不一致还体现在类别字段的书写上,比如Work_type和work_type、Male后面带空格、Yes/yes/Y混用。这类问题不处理,独热编码会凭空多出好几列全零的废特征。通常的做法是先统一strip().lower(),再对照一份白名单校验,不在名单里的值打印出来人工确认。

3. 特征工程与预处理流水线:把手工操作固化成可复现步骤

3.1 缺失值的三条路线:删除、单变量填补、模型填补

BMI 缺失两百条左右,占总量的 4%,这个比例处在“删了可惜、不删又不知道怎么填”的尴尬区间。三条路线各有适用条件。

删除:只有当缺失完全随机、且删除后正例数量没有明显减少时才可用。4% 看似不多,但如果正例里缺失比例更高,删完之后你的正例可能只剩两百出头,交叉验证每一折的验证集里只有四五个正例,AUC 的方差会大到没法比较模型。

单变量填补:中位数最稳,均值会被 272 这种极值带偏。缺点是它忽略了 BMI 和年龄、性别之间的关系。

分组填补:按性别和年龄段分组取中位数,比全局中位数更贴近真实。实现上用groupby().transform()一行就能做,成本很低,收益通常比想象中大。

# 按性别与年龄段分组,用组内中位数填补 BMI df["age_band"] = pd.cut(df["age"], bins=[0, 18, 40, 60, 100], labels=["<18", "18-40", "40-60", "60+"]) df["bmi_filled"] = df["bmi"].fillna( df.groupby(["gender", "age_band"])["bmi"].transform("median") ) # 组内全缺失时兜底 df["bmi_filled"] = df["bmi_filled"].fillna(df["bmi"].median())

这里age_band是临时列,只用于分组,不要直接留在最终特征里(否则会和age共线)。transform("median")返回的是与原始行对齐的 Series,所以可以直接fillna。最后一行兜底不能省——某些小组可能整组都没有 BMI 记录,不兜底会留下新的缺失。

注意:如果分组填补用到了验证集的数据分布,本质上也是一种泄漏。严谨做法是把这段逻辑包进自定义 Transformer,让它只在训练折上fit。数据量小时这种泄漏影响不大,但答辩被问到要有话说。

3.2 编码与缩放:用 ColumnTransformer 一次写对

手工在 DataFrame 上做get_dummies再拼接,是 Notebook 里最常见的写法,也是最容易在预测阶段翻车的写法——训练时有 18 列,线上来一条新样本只有 17 列,因为某个类别值没出现过。正确姿势是用ColumnTransformer把预处理封装成可fit/transform的对象,跟着模型一起序列化。

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"] cat_cols = ["gender", "ever_married", "work_type", "Residence_type", "smoking_status"] bin_cols = ["hypertension", "heart_disease"] preprocess = 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="if_binary")), ]), cat_cols), ("bin", "passthrough", bin_cols), ], remainder="drop", # 显式丢弃 id 等无关列,避免误入模 )

几个参数值得单独说。handle_unknown="ignore"让线上出现训练时没见过的类别值时输出全零列而不是报错,这是生产环境的必备开关。drop="if_binary"对二类别字段只保留一列,避免独热后两列完全共线,逻辑回归里会引起系数不稳定。remainder="drop"是显式的自我约束——默认就是 drop,但写出来是为了提醒自己和评审:id这类字段绝不能被悄悄带进模型。

数值列标准化放在SimpleImputer之后,顺序不能反,先缩放再填补会把中位数缩放成一个奇怪的值。二值列既不填补也不缩放,直接透传。

3.3 不平衡处理:SMOTE 放在流水线的哪一格决定成败

正例不到 5%,绝大多数人的处理是“先 SMOTE 把数据扩到平衡,再划分训练测试集”。这个顺序是错的,而且错得很隐蔽——过采样之后同一批合成样本可能同时落进训练集和测试集,AUC 会虚高十几个点,换一份数据立刻打回原形。

正确的做法是把采样器放进imblearn的 Pipeline 里,让它只在交叉验证的训练折内部执行。

from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE from sklearn.linear_model import LogisticRegression from sklearn.model_selection import StratifiedKFold, cross_validate clf = ImbPipeline(steps=[ ("prep", preprocess), ("smote", SMOTE(random_state=42, k_neighbors=5)), ("model", LogisticRegression(max_iter=1000)), ]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_validate( clf, X, y, cv=cv, scoring=["roc_auc", "average_precision", "recall", "precision"], return_estimator=False, )

imblearn.pipeline.Pipeline和 sklearn 的 Pipeline 接口几乎一致,区别在于它会在fit时对训练数据做重采样、在predict时跳过采样步骤。这一点很多人不知道,误以为两者可以随便换。

k_neighbors=5是默认值,正例只有两百多个的时候可以降到 3,否则合成样本容易在少数类内部“近亲繁殖”,制造出一堆几乎相同的点。random_state一定要固定,不然每次跑出来的指标都在抖,没法判断改动到底有没有效果。

另外提醒一句:SMOTE 不是唯一解,也不是首选解。class_weight="balanced"在逻辑回归上往往效果相当而且更简单,代价敏感学习在业务上更好解释。我的习惯是先跑class_weight做基线,再试 SMOTE,两者都留结果,答辩时能讲出取舍。

3.4 特征衍生:三类值得试的分箱

原始数值列直接入模通常够用,但卒中这个场景里有几个分箱是有临床依据的,值得作为对照实验做一轮。

血糖按 100 和 126 两个阈值切成正常、偏高、高三段;BMI 按 25 和 30 切;年龄按 18、40、60 切。分箱的好处是能把非线性的阈值效应显式表达出来,逻辑回归这种线性模型尤其受益。坏处是丢掉了箱内的连续信息,树模型上收益通常不明显。

通常的做法是两套都跑,看 PR-AUC 有没有稳定提升。如果提升在交叉验证的波动范围内,就别加分箱——特征越少,答辩时越好解释。

4. 模型选型与训练:从逻辑回归基线到集成模型

4.1 基线先行:逻辑回归给的是可解释的锚点

很多人上来就上 XGBoost,跑出一个 0.85 的 AUC,然后被问“为什么是这个值”就答不上来。我一般的顺序是:逻辑回归先跑通,拿到一个可解释的基线,再往上叠复杂模型。

逻辑回归在这份数据上的 AUC 通常在 0.83 上下,不算惊艳但很稳。更重要的是它能给出系数方向:年龄的系数为正、高血压和心脏病史为正、血糖为正,这些都符合医学常识。如果某个特征系数方向和常识相反,往往说明编码或缺失处理出了问题——这比看任何评估指标都更早暴露 bug。

代价敏感版的写法很简单:

from sklearn.linear_model import LogisticRegression # class_weight="balanced" 会按类别频率自动反比加权 lr_balanced = ImbPipeline(steps=[ ("prep", preprocess), ("model", LogisticRegression(max_iter=1000, class_weight="balanced")), ])

max_iter=1000在你加了标准化之后其实很少需要,但留着不亏——不收敛的警告一旦出现,说明特征尺度或共线性有问题,早发现早处理。

4.2 集成模型对比:随机森林与梯度提升的取舍

模型关键参数优势代价
逻辑回归C、class_weight可解释、训练快、概率相对可靠无法自动捕捉交互
随机森林n_estimators、max_depth、min_samples_leaf抗过拟合、对异常值不敏感概率偏保守、模型体积大
梯度提升(XGBoost / LightGBM)learning_rate、n_estimators、max_depth表格数据上通常最优小样本易过拟合、调参成本高
KNNn_neighbors、weights实现简单、无需训练高维下失效、需要缩放

小样本(五千条、两百多个正例)场景下,梯度提升的优势并不像在竞赛数据上那么明显,反而更容易过拟合。我一般把max_depth压到 3~4,learning_rate设 0.05,n_estimators通过早停决定,min_child_weight调大一些防止在少数正例上分裂出过细的叶子。

随机森林这边,min_samples_leaf是控制过拟合最有效的旋钮,正例稀少时不要让它小于 5。n_estimators设 300 以上基本稳定,再多对指标没帮助,只是拖慢训练。

四条曲线放在同一张 PR 曲线图上对比,比看四个 AUC 数字直观得多。曲线交叉的时候,取哪个模型取决于你锁定的工作点——高召回区间谁更强,就选谁。

4.3 交叉验证与超参搜索:StratifiedKFold 不能省

五折交叉验证如果不分层,某一折的验证集里可能只有一两个正例,AUC 会算出 0.5 甚至报错。StratifiedKFold保证每折的正例比例与整体一致,这是这类不平衡数据的最低要求。

from sklearn.model_selection import GridSearchCV param_grid = { "model__C": [0.01, 0.1, 1.0, 10.0], "model__class_weight": [None, "balanced"], } gs = GridSearchCV( estimator=lr_balanced, param_grid=param_grid, scoring="average_precision", # 不平衡数据用 PR-AUC 选参 cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42), n_jobs=-1, refit=True, ) gs.fit(X_train, y_train) print(gs.best_params_, gs.best_score_)

scoring选average_precision(即 PR-AUC)而不是roc_auc,是因为 ROC 曲线在极端不平衡时会被大量真负例“稀释”,看起来很美但反映不了正例的排序质量。这个细节在答辩时是加分项,因为它说明你知道两个指标的适用边界。

param_grid里的键名必须带model__前缀,因为要穿透两层 Pipeline 定位到估计器。这是新手最容易写错的地方,报错信息还不直观。

4.4 阈值与概率校准:0.5 从来不是业务阈值

predict()默认用 0.5 切分,这个值在平衡数据上勉强合理,在 4.9% 正例率的数据上几乎必然导致召回极低。真正的阈值应该由业务约束反推:假设筛查场景能承受“每找到 1 个高风险、误报 5 个”的成本,那就去找精确率约 0.17 时对应的阈值。

import numpy as np from sklearn.metrics import precision_recall_curve proba = gs.predict_proba(X_test)[:, 1] prec, rec, thr = precision_recall_curve(y_test, proba) # 在召回不低于 0.75 的前提下,取精确率最高的阈值 mask = rec[:-1] >= 0.75 best_idx = np.argmax(prec[:-1][mask]) best_thr = thr[mask][best_idx] print(f"阈值={best_thr:.3f}, 精确率={prec[best_idx]:.3f}, 召回率={rec[best_idx]:.3f}")

precision_recall_curve返回的thr长度比prec、rec少一个,所以索引要写[:-1],不这么写会触发形状不匹配。mask用来加业务约束,没有约束的话直接取 F1 最大点也可以,但要在报告里写清楚这是按 F1 选的。

如果要把概率直接当风险分展示给使用者,还得做校准。逻辑回归本身校准尚可,随机森林和提升树输出的概率通常偏保守。CalibratedClassifierCV包一层,用method="isotonic"或"sigmoid",样本量小的时候选 sigmoid 更稳。

5. 避坑与排查:五个让 AUC 虚高的陷阱

5.1 数据与流水线层面的三个坑

坑一:先过采样再划分数据集。

现象:交叉验证 AUC 轻松到 0.95 以上,换一份同分布数据掉到 0.80。

原因:SMOTE 在划分之前执行,合成样本由原始正例插值生成,同一批“血统”的样本同时出现在训练和验证集里,模型等于提前看过答案。

解决:把采样器放进imblearn的 Pipeline,确保它只在训练折内部 fit;划分数据集这一步永远排在所有重采样之前。改完之后 AUC 会掉几个点,那才是真实水平。

坑二:在全集上算中位数再填补。

现象:指标看着不错,但同一个流程在验证集上重跑,结果对不上。

原因:df["bmi"].fillna(df["bmi"].median())里的中位数是用包含验证集在内的全部数据算出来的,把测试集的信息提前泄露进了训练过程。

解决:所有统计量(中位数、众数、均值、分箱边界)都必须由训练折单独计算,这也是要用 Pipeline 而不是手写fillna的根本原因。手写代码不是不行,是太容易忘。

坑三:把 ID 类或近 ID 类特征放进模型。

现象:AUC 高得反常,特征重要性排名第一的是一个看起来毫无意义的字段。

原因:登记数据里的编号有时是按录入顺序或机构分批生成的,隐含了时间或来源信息,和标签存在虚假相关。

解决:id一律remainder="drop";对任何唯一值数接近样本量的字段保持警惕,先算一遍它与标签的相关性再决定去留。遇到可疑字段,删掉重跑一遍对比,是最快的验证方式。

5.2 评估与交付层面的两个坑

坑四:用准确率选模型。

现象:某模型的准确率 0.95,被选为最终模型,上线后高风险人群几乎一个都没排到前面。

原因:把所有人判成负类就有 0.95 的准确率,这个指标在 4.9% 正例率下几乎不携带信息。

解决:主指标锁定 PR-AUC 和高召回区间的精确率,准确率只在报告里作为参照列出,并且必须和“全负基线”并排展示。答辩 PPT 上把全负基线的数字写上,比任何解释都有说服力。

坑五:只报一个 AUC,不报工作点和混淆矩阵。

现象:评审问“这个模型实际用起来会误报多少人”,答不上来。

原因:AUC 衡量的是全阈值范围内的排序能力,不对应任何一个具体工作点。

解决:报告里固定给出一张表——指定阈值下的 TP、FP、FN、TN,换算成“每筛查 1000 人,命中多少、误报多少、漏掉多少”。这三个数字是业务方唯一真正关心的东西,也是答辩现场最容易被追问的地方。

6. 验收与答辩:用一份清单把模型交付出去

到了这一步,Notebook 里躺着几条曲线和一堆数字,但能不能算“交付完成”,取决于别人能不能照着你写的东西复现出来。我的验收习惯是拿一张清单逐条打勾,缺哪条补哪条。

验收项合格标准检查方式
数据可复现从原始表到特征矩阵一条命令跑通清空输出后重启内核全量执行
无信息泄漏划分、填补、采样顺序正确审查 Pipeline 结构,确认采样器在 Pipeline 内
指标完整PR-AUC、Recall、Precision、混淆矩阵齐全对照报告模板逐项核对
工作点明确给出阈值及其业务含义能说出每 1000 人的命中与误报数
可解释至少给出系数或特征重要性前五名方向与医学常识一致
可部署模型 + 预处理可整体序列化joblib.dump(pipeline)后重新加载预测一致

序列化这一步别等到最后才做。预处理和模型分开保存,是上线阶段最常见的翻车点——训练时用StandardScaler缩放过,线上忘了缩放,预测结果全错但程序不报错,属于典型的静默失败。整体存一个 Pipeline 对象就没这个隐患:

import joblib # 整个 Pipeline(预处理 + 模型)一起存,杜绝线上线下不一致 joblib.dump(gs.best_estimator_, "stroke_risk_pipeline.joblib") # 加载后必须做一次一致性校验 loaded = joblib.load("stroke_risk_pipeline.joblib") assert abs(loaded.predict_proba(X_test)[:, 1] - gs.predict_proba(X_test)[:, 1]).max() < 1e-9

assert那一行看着多余,但它救过我一次——某次因为环境里 sklearn 版本不同,OneHotEncoder的列顺序变了,重载后预测悄悄偏掉,靠这行断言才发现。

答辩环节,我的建议是只讲三件事,顺序别乱:问题边界(这是筛查排序,不是诊断)、关键取舍(为什么用 PR-AUC、为什么 SMOTE 放进 Pipeline、阈值怎么定的)、代价说明(在选定阈值下每 1000 人误报多少、漏掉多少)。评委追问最多的一定是第三点,前两点讲扎实了,第三点才有底气接。

现场演示留一条最朴素的路径:打开 Notebook,从读数据开始,一路执行到输出混淆矩阵,中间不停。别在演示时现场调参,也别放一堆没跑通的实验分支在同一个文件里。把 EDA、特征、训练、评估拆成四个 Notebook,或者至少四个清晰的段落,别人接手时才不会迷路。

从那以后我每次做这类表格二分类项目,都会在提交前强制走一遍那张验收清单,尤其是“清空输出全量执行”和“加载后一致性校验”这两条——它们拦下的问题,比任何调参技巧都多。希望帮到你。

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

返回列表