简介:本资源为基于Kaggle中风数据集构建的机器学习预测项目,面向希望掌握医疗数据分类建模全流程的学习者与数据科学从业者。项目以Jupyter Notebook为主要载体,围绕性别、年龄、疾病史与吸烟状况等特征,预测患者是否可能发生中风,并尝试构建高F1分数与AUC指标的判别模型,适合作为分类算法练习与特征工程实战案例。压缩包共11个文件,约4.36MB,包含3个ipynb笔记本、1个csv原始数据、1个pkl模型文件、1个py部署脚本、1个sh启动脚本、1个Procfile及说明文档等,覆盖从数据探索、模型训练到Heroku部署的完整链路。目前已有740人学习下载。读者可从中获取数据清洗与可视化思路、模型调参与评估方法、可复用的预测脚本以及部署配置参考,便于快速复现并迁移到类似医疗分类场景。
1. 从一份体检表到一次预警:stroke-prediction-model 到底在算什么
stroke-prediction-model 这个标题,落到工程里通常指一套用结构化临床特征预测个体未来发生脑卒中风险的二分类模型。它要解决的不是“确诊”,而是“分层”:在只有年龄、血压、血糖、BMI、吸烟状态、既往病史这类字段的前提下,输出一个 0 到 1 之间的风险概率,供后续人工复核或随访排期使用。适合谁?做医疗数据平台的后端、做健康管理类产品的算法同学、以及手里有一张几千到几万行 CSV 却不知道怎么把 AUC 做上去的从业者。反直觉的结论先放这里:这类任务里,特征工程和阈值选择对业务指标的影响,往往比换模型结构大得多;很多人一上来就上深度网络,最后发现逻辑回归加几个交互项反而更稳。下面按“先立住、再复现、再避坑”的顺序拆开讲。
2. 数据与标签先立住:字段口径、缺失处理和类别不平衡
2.1 先确认标签定义,别急着跑模型
拿到一份脑卒中风险数据,第一件事不是df.describe(),而是问清楚标签是怎么来的。常见做法是:stroke=1表示随访期内发生过缺血性或出血性脑卒中,stroke=0表示随访期内未发生。问题在于,很多公开数据集里的 0 是“没有记录到”,而不是“确认没有发生”。这两者在统计上差别很大,前者会引入标签噪声,让模型学到“没来复诊=低风险”这种错误关联。
我一般会先做三件事:看正样本占比、看每个字段的缺失率、看缺失是否与标签相关。如果某个字段在正样本里缺失率显著更高,那它本身可能就是信号,不能简单用均值填掉。下面这段代码是加载和初筛的最小骨架,字段名按常见口径写,实际替换成你自己的列名即可。
import pandas as pd import numpy as np df = pd.read_csv("stroke_data.csv") # 1. 标签分布:正样本通常很少,先看清比例 print(df["stroke"].value_counts(normalize=True)) # 2. 缺失率排序,重点关注 bmi、smoking_status 这类常缺字段 missing = df.isnull().mean().sort_values(ascending=False) print(missing[missing > 0]) # 3. 缺失与标签的关联:分组看缺失率差异 for col in ["bmi", "smoking_status"]: tmp = df.assign(is_missing=df[col].isnull()) print(col, tmp.groupby("is_missing")["stroke"].mean())逻辑说明:第一步确认不平衡程度,决定后面要不要用重采样或类别权重;第二步找出高缺失字段,避免直接dropna()把样本砍掉一半;第三步是关键,如果缺失组的正样本率明显不同,说明缺失不是随机的,填充策略要谨慎。参数上,normalize=True让输出直接是比例,方便判断;分组均值那一步如果差异超过 2 个百分点,就值得单独建一个“是否缺失”的指示列。
2.2 缺失填充:中位数、众数和“缺失即类别”怎么选
数值字段(age、avg_glucose_level、bmi)常用中位数填充,因为分布往往右偏,均值会被极端值拉走。类别字段(gender、ever_married、work_type、smoking_status)常用众数填充,但smoking_status里本来就有一个Unknown类别,这时候再填众数反而会掩盖信息。我的习惯是:如果某类别字段的缺失率低于 5%,填众数;高于 5%,就把缺失单独设成一个Missing类别,让模型自己决定它的权重。
from sklearn.impute import SimpleImputer num_cols = ["age", "avg_glucose_level", "bmi"] cat_cols = ["gender", "ever_married", "work_type", "smoking_status"] # 数值:中位数填充 num_imputer = SimpleImputer(strategy="median") df[num_cols] = num_imputer.fit_transform(df[num_cols]) # 类别:缺失率高的单独成类,低的填众数 for col in cat_cols: if df[col].isnull().mean() > 0.05: df[col] = df[col].fillna("Missing") else: df[col] = df[col].fillna(df[col].mode()[0])逻辑说明:SimpleImputer的strategy="median"对偏态数值更稳;类别字段用条件分支处理,是为了避免把“未知”和“最常见类别”混为一谈。参数上,5% 这个阈值不是铁律,样本量小的时候可以放宽到 10%,但要在验证集上对比填充前后 AUC 的变化,别凭感觉定。
2.3 类别不平衡:别一上来就 SMOTE
脑卒中正样本占比常见在 2% 到 5% 之间。很多人第一反应是 SMOTE 过采样,但我在实际项目里更倾向先用class_weight="balanced",因为它不改变数据分布,只是调整损失权重,训练更快也更少引入合成噪声。如果非要用 SMOTE,一定只在训练集上做,验证集和测试集保持原始分布,否则评估指标会虚高。
from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, average_precision_score X = pd.get_dummies(df.drop(columns=["stroke"]), drop_first=True) y = df["stroke"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf = LogisticRegression(class_weight="balanced", max_iter=1000) clf.fit(X_train, y_train) proba = clf.predict_proba(X_test)[:, 1] print("AUC:", roc_auc_score(y_test, proba)) print("AP:", average_precision_score(y_test, proba))逻辑说明:stratify=y保证训练和测试集里正样本比例一致;class_weight="balanced"自动按类别频率反比加权;评估时除了 AUC,一定要看 AP(平均精度),因为不平衡数据里 AUC 可能看起来不错但实际查准率很低。参数上,max_iter=1000是为了避免逻辑回归不收敛,random_state固定住方便复现。
3. 特征工程与模型选型:把 AUC 从 0.78 推到 0.85 的几刀
3.1 年龄分箱和血压交互项,比换模型更管用
结构化临床特征里,年龄和血压几乎总是最重要的两个变量。但直接把年龄当连续值丢进去,模型很难学到“某个年龄段风险陡增”这种非线性。我的做法是:年龄做分箱(比如 0-18、19-40、41-60、60+),同时保留原始年龄;血压用hypertension二值字段和avg_glucose_level做交互,因为高血压加高血糖的联合风险通常大于两者单独之和。
# 年龄分箱,保留原始年龄 df["age_bin"] = pd.cut(df["age"], bins=[0, 18, 40, 60, 120], labels=["child", "young", "mid", "senior"]) # 交互项:高血压 * 高血糖(先对血糖做中位数二值化) glucose_median = df["avg_glucose_level"].median() df["high_glucose"] = (df["avg_glucose_level"] > glucose_median).astype(int) df["hypertension_glucose"] = df["hypertension"] * df["high_glucose"]逻辑说明:分箱把非线性关系显式化,交互项让线性模型也能捕捉联合效应。参数上,分箱边界要结合业务常识,不要用等频分箱把 60 岁和 61 岁切到两个箱里;血糖二值化的阈值用中位数是保守做法,如果领域内已有临床切点(比如空腹血糖 7.0),优先用临床切点。
3.2 树模型和线性模型的取舍:看样本量和可解释性
样本量低于 5000 且正样本少于 200 时,我一般先用逻辑回归加交互项,因为树模型在这种规模下容易过拟合,而且逻辑回归的系数可以直接解释成优势比,方便和临床同事沟通。样本量上万、特征维度高、且允许黑盒时,再上 XGBoost 或 LightGBM。下面是一个 LightGBM 的最小配置,重点看scale_pos_weight和min_child_samples这两个参数。
import lightgbm as lgb model = lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, num_leaves=31, min_child_samples=30, scale_pos_weight=(y_train == 0).sum() / (y_train == 1).sum(), random_state=42 ) model.fit(X_train, y_train)逻辑说明:scale_pos_weight是不平衡数据里比 SMOTE 更轻量的手段;min_child_samples=30防止叶子节点过小导致过拟合;num_leaves=31是中小样本的常用起点。参数上,如果验证集 AUC 波动大,先把num_leaves降到 15 再试;如果欠拟合,再提高n_estimators而不是调大学习率。
3.3 用交叉验证选阈值,别用默认的 0.5
默认 0.5 阈值在 unbalanced 场景下几乎一定不是最优。业务上如果漏判代价高,阈值要往低调;如果误报代价高,阈值往高调。我一般用 5 折交叉验证画出 precision-recall 曲线,选 F1 最高或者满足某个查全率下限的点。
from sklearn.model_selection import cross_val_predict from sklearn.metrics import precision_recall_curve proba_cv = cross_val_predict(clf, X_train, y_train, cv=5, method="predict_proba")[:, 1] precision, recall, thresholds = precision_recall_curve(y_train, proba_cv) f1 = 2 * precision * recall / (precision + recall + 1e-9) best_idx = f1.argmax() best_threshold = thresholds[best_idx] print("best threshold:", best_threshold, "F1:", f1[best_idx])逻辑说明:cross_val_predict给出的是每个样本在未参与训练时的预测概率,比单次划分更稳;precision_recall_curve返回的 thresholds 长度比 precision 少 1,索引时注意对齐。参数上,如果业务要求查全率不低于 0.8,就在 recall>=0.8 的区间里选 precision 最高的阈值,而不是直接取 F1 最大。
4. 避坑与排查:五个让模型“看起来很好”的陷阱
4.1 现象:AUC 0.9 但上线后没人用。原因:数据泄漏。解决:检查填充和分箱是否在划分前做了
这是最常见的翻车点。如果在train_test_split之前就对全量数据做了中位数填充或分箱,测试集的信息就漏进了训练集。正确顺序永远是:先划分,再在训练集上fit填充器和分箱边界,然后transform测试集。血泪经验:任何fit_transform出现在划分之前,都要怀疑。
4.2 现象:验证集 AUC 高,测试集掉 0.1。原因:阈值是在验证集上选的,测试集分布不同。解决:用交叉验证选阈值,并留一份从未看过的 holdout
阈值选择本身就是一个拟合过程。如果只在一次划分的验证集上选阈值,很容易过拟合到那批数据。我的习惯是:训练集内部做 5 折选阈值,测试集只在最后评估一次,不再回头调参。
4.3 现象:smoking_status的Unknown被填成never smoked。原因:把缺失当成了真实类别。解决:缺失单独成类或做指示列
Unknown在原始数据里可能代表“没问”也可能代表“不愿说”,直接填众数会把这两种情况都归到“从不吸烟”,人为降低这部分人的风险评分。正确做法是保留Unknown或加一个smoking_missing指示列。
4.4 现象:模型对 60 岁以上人群预测概率普遍偏低。原因:年龄分箱边界不合理或样本偏差。解决:检查各年龄段的正样本率,必要时对年龄做单调约束
如果训练数据里 60 岁以上样本本来就少,模型会倾向于给这个群体一个接近全局均值的概率。解决方式:检查各年龄段的正样本率是否单调,如果不是,说明分箱或采样有问题;树模型可以加单调约束,逻辑回归可以显式加年龄的样条项。
4.5 现象:avg_glucose_level有极端值,模型系数方向反了。原因:未做缩尾或对数变换。解决:对偏态数值做 winsorize 或 log1p
血糖这类字段常有极端高值,线性模型会被拉偏。我一般先做 1% 和 99% 分位缩尾,再考虑log1p变换。注意:缩尾边界要在训练集上算,再应用到测试集,否则又是泄漏。
5. 进阶技巧:用校准曲线和决策曲线把概率变成可用的风险分
模型输出 0.73 到底意味着什么?如果模型没校准,这个 0.73 可能对应真实风险 0.4,也可能对应 0.9。校准曲线(reliability curve)就是看预测概率和实际发生率的对应关系。我一般用CalibratedClassifierCV做 isotonic 或 sigmoid 校准,然后在测试集上画校准曲线。下面这段代码同时输出校准前后的 Brier 分数,Brier 越低说明概率越准。
from sklearn.calibration import CalibratedClassifierCV, calibration_curve from sklearn.metrics import brier_score_loss # 校准:在训练集内部再分一份校准集 calibrated = CalibratedClassifierCV(clf, method="isotonic", cv=5) calibrated.fit(X_train, y_train) proba_raw = clf.predict_proba(X_test)[:, 1] proba_cal = calibrated.predict_proba(X_test)[:, 1] print("Brier raw:", brier_score_loss(y_test, proba_raw)) print("Brier calibrated:", brier_score_loss(y_test, proba_cal)) # 校准曲线:分箱看预测均值 vs 实际发生率 frac_pos, mean_pred = calibration_curve(y_test, proba_cal, n_bins=10) for mp, fp in zip(mean_pred, frac_pos): print(f"pred={mp:.2f}, actual={fp:.2f}")逻辑说明:CalibratedClassifierCV的cv=5表示在训练集内部交叉验证训练校准器,不会碰到测试集;isotonic适合样本量较大的情况,样本少时用sigmoid更稳。参数上,n_bins=10是校准曲线的常用分箱数,样本少时可以降到 5。如果校准后 Brier 反而变高,说明校准集太小或模型本身已经比较准,不必强行校准。
更进一步,如果要做临床决策,可以画决策曲线(DCA),看在不同阈值下模型的净收益是否高于“全部干预”和“全部不干预”两条基线。这一步能把 AUC 这种排序指标翻译成“用这个模型到底能多找出多少人、少误报多少人”。我自己的习惯是:任何风险预测模型,上线前至少要看一眼校准曲线和决策曲线,否则那个概率就只是个黑匣子分数,没法跟业务方解释。希望帮到你。
本文还有配套的精品资源,点击获取