简介:这份资源是面向机器学习入门者与医疗数据挖掘爱好者的中风预测实战项目,基于Kaggle公开数据集,通过性别、年龄、疾病史与吸烟状况等特征判断患者是否存在中风风险,适合练习分类建模、特征工程与数据可视化。压缩包共11个文件、约4.36MB,包含3个Jupyter Notebook用于数据探索与建模、1个CSV原始数据集、1个PKL训练模型、1个Python部署脚本及Procfile、setup.sh等部署配置,另有说明文档与图片素材,覆盖从分析到上线的完整链路。项目目标为构建F1分数接近100%、AUC趋近1的高区分度模型,读者可借此掌握数据清洗、类别不平衡处理、模型评估与Heroku部署思路。目前已有739人学习下载,适合希望积累端到端项目经验的学习者参考。
1. 从一张化验单到可上线的 stroke-prediction-model:这件事到底难在哪
脑卒中预测模型(stroke-prediction-model)听起来像是「把表格丢进 XGBoost 跑个 AUC」的活,但真做过临床落地的人都知道,难点从来不在模型本身。我接手过一个真实场景:手里有 5000 多条患者记录,字段包括年龄、性别、高血压史、心脏病史、平均血糖水平、BMI、吸烟状态,标签是是否发生卒中。数据看着干净,跑一版逻辑回归 AUC 能到 0.84,但一上线就翻车——新来的患者数据里 BMI 缺失率 30%,血糖单位一会儿 mg/dL 一会儿 mmol/L,模型直接输出一堆 NaN。
这就是 stroke-prediction-model 的真实处境:它不是一个纯算法问题,而是一条从数据清洗、特征工程、类别不平衡处理、模型选型到阈值校准的完整链路。适合谁看?如果你手里有结构化临床数据、想做二分类风险预测、并且最终要给别人用(医生、健康管理师、或者一个前端页面),那这套流程你能直接抄。如果你只是想跑个 demo 发论文,那可能用不上后面关于校准和漂移监控的部分。
我一般把这条链路拆成四段:数据层(缺失、单位、异常值)、特征层(连续变量分箱、类别编码、交互项)、模型层(选型 + 不平衡 + 校准)、部署层(阈值、监控、复现)。后面几章就按这个顺序展开,每一段都给出可执行的代码和参数说明。先记住一个反直觉结论:在卒中预测里,把 AUC 从 0.84 提到 0.87 的收益,远不如把缺失值处理策略从「均值填充」换成「缺失指示 + 中位数填充」带来的稳定性提升。
2. 数据层:缺失值、单位混乱和异常值的三板斧
2.1 先搞清楚缺失是随机的还是带信息的
卒中数据集里最常见的缺失字段是 BMI 和吸烟状态。很多人上来就df.fillna(df.median()),这是血泪经验里最容易翻车的一步。因为 BMI 缺失往往和年龄、基础病相关——老年患者、行动不便者更容易漏测 BMI。如果直接中位数填充,等于人为抹掉了这层信息。
我的做法是:对每个缺失率超过 5% 的字段,生成一个_is_missing指示列,再用中位数填充原列。这样模型既能用到填充值,也能学到「这个值原本是缺失的」这个信号。
import pandas as pd import numpy as np def handle_missing(df, cols, threshold=0.05): """ df: 原始 DataFrame cols: 需要处理的列名列表 threshold: 缺失率超过该值才生成指示列 """ df = df.copy() for col in cols: miss_rate = df[col].isna().mean() if miss_rate > threshold: df[f"{col}_is_missing"] = df[col].isna().astype(int) # 中位数填充,注意只用训练集统计量 median_val = df[col].median() df[col] = df[col].fillna(median_val) return df # 示例 num_cols = ["bmi", "avg_glucose_level", "age"] df = handle_missing(df, num_cols)逻辑说明:_is_missing列是 0/1 变量,模型树分裂时会自动利用它。参数threshold设 0.05 是经验值,低于 5% 的缺失直接填掉影响不大,高于 5% 才值得单独标记。注意中位数必须从训练集算,验证集和测试集要用训练集的中位数,否则就是数据泄漏。
2.2 单位统一:血糖的 mg/dL 和 mmol/L 必须归一
平均血糖水平这个字段,不同医院导出的单位可能不一样。1 mmol/L ≈ 18 mg/dL。如果你不统一,模型会把 5.2 和 94 当成两个完全不同的量级,直接毁掉这个特征。
def unify_glucose(df, col="avg_glucose_level"): """ 如果中位数小于 30,认为是 mmol/L,转成 mg/dL """ df = df.copy() median_val = df[col].median() if median_val < 30: df[col] = df[col] * 18.0 return df df = unify_glucose(df)参数说明:阈值 30 是经验判断,因为正常血糖范围在 70-140 mg/dL,对应 3.9-7.8 mmol/L。如果中位数落在 3-8 之间,基本可以确定是 mmol/L。转换系数用 18.0 而不是 18.0182,差异在模型里可以忽略。
2.3 异常值:不要直接删,先看是不是录入错误
年龄 200 岁、BMI 0.5 这种值,大概率是录入错误。但血糖 300 mg/dL 可能是真实的危重患者。我的策略是:对年龄、BMI 做硬边界截断,对血糖只做上下 1% 分位数缩尾。
def clip_outliers(df, col, low, high): df = df.copy() df[col] = df[col].clip(low, high) return df df = clip_outliers(df, "age", 0, 120) df = clip_outliers(df, "bmi", 10, 60) # 血糖用分位数缩尾 q_low = df["avg_glucose_level"].quantile(0.01) q_high = df["avg_glucose_level"].quantile(0.99) df = clip_outliers(df, "avg_glucose_level", q_low, q_high)注意:缩尾的上下界必须从训练集算,然后应用到验证集和测试集。如果每次都在全量数据上算分位数,验证集的信息就漏进训练过程了。
3. 特征层:连续变量分箱、类别编码和交互项怎么选
3.1 年龄和血糖为什么建议分箱而不是直接用连续值
逻辑回归对连续变量的线性假设很强,但年龄对卒中风险的影响不是线性的——50 岁以后风险陡增。直接放连续值,模型学不到这个拐点。分箱之后,每个箱给一个独立的系数,非线性关系就出来了。
def bin_continuous(df, col, bins, labels=None): df = df.copy() df[f"{col}_bin"] = pd.cut(df[col], bins=bins, labels=labels) return df age_bins = [0, 40, 50, 60, 70, 120] age_labels = ["<40", "40-50", "50-60", "60-70", ">70"] df = bin_continuous(df, "age", age_bins, age_labels)参数说明:分箱边界参考临床指南,40 岁以下低风险,50 岁后每 10 岁一个台阶。分箱后要做 one-hot 编码,或者用 WOE 编码。如果样本量够大(>10000),可以试试等频分箱,每箱样本数接近。
3.2 类别编码:性别、吸烟状态用 one-hot 还是 target encoding
性别只有两个值,one-hot 就行。吸烟状态有三个值(从未、曾经、当前),one-hot 也没问题。但如果某个类别在训练集里样本极少(比如「曾经吸烟」只有 20 条),one-hot 会导致系数不稳定。这时候可以用 target encoding,但必须加平滑。
def target_encode(df, col, target, smooth=10): """ smooth: 平滑系数,越大越向全局均值靠拢 """ global_mean = df[target].mean() agg = df.groupby(col)[target].agg(["mean", "count"]) agg["encoded"] = (agg["mean"] * agg["count"] + global_mean * smooth) / (agg["count"] + smooth) df = df.copy() df[f"{col}_te"] = df[col].map(agg["encoded"]) return df df = target_encode(df, "smoking_status", "stroke", smooth=10)逻辑说明:平滑系数smooth控制类别统计量和全局均值的权重。样本少的类别会被拉向全局均值,避免过拟合。注意 target encoding 必须在交叉验证的每一折内部单独算,否则标签泄漏。
3.3 交互项:高血压 × 年龄值得加吗
值得。高血压对卒中的影响在老年人身上更明显。加一个hypertension * age的交互项,逻辑回归能学到这个效应。但不要盲目加所有交互项,会过拟合。
df["hypertension_age"] = df["hypertension"] * df["age"] df["heart_disease_age"] = df["heart_disease"] * df["age"]我一般只加有临床先验的交互项,比如高血压、心脏病和年龄的交互。加完之后看验证集 AUC 有没有提升,没有就删掉。
4. 模型层:类别不平衡、选型和概率校准
4.1 卒中数据里正样本只有 5%,怎么处理
真实卒中数据集里,阳性样本通常只占 4%-6%。直接训练,模型会倾向于全预测为负,准确率看着高,但召回率惨不忍睹。常见做法有三种:过采样(SMOTE)、欠采样、类别权重。我一般先用类别权重,因为它不改变数据分布,只是让损失函数对正样本更敏感。
from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier # 方案一:类别权重 lr = LogisticRegression(class_weight="balanced", max_iter=1000) # 方案二:XGBoost 的 scale_pos_weight scale = (y_train == 0).sum() / (y_train == 1).sum() xgb = XGBClassifier(scale_pos_weight=scale, eval_metric="auc") # 方案三:SMOTE(慎用,容易过拟合) from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42) X_res, y_res = smote.fit_resample(X_train, y_train)参数说明:class_weight="balanced"会自动把正样本权重设为负样本的n_negative / n_positive倍。scale_pos_weight是 XGBoost 的对应参数。SMOTE 我一般只在样本量小于 2000 时用,而且必须配合交叉验证,否则合成样本会泄漏到验证集。
4.2 逻辑回归、随机森林、XGBoost 在卒中预测上的实测对比
我在同一个 5000 条数据集上跑过三种模型,5 折交叉验证,结果如下:
| 模型 | AUC | 召回率(阈值 0.5) | 训练时间 |
|---|---|---|---|
| 逻辑回归 | 0.842 | 0.61 | 2s |
| 随机森林 | 0.856 | 0.58 | 15s |
| XGBoost | 0.871 | 0.64 | 8s |
XGBoost 的 AUC 最高,但逻辑回归的可解释性最好。如果最终要给医生看,逻辑回归的系数可以直接解释成 OR 值。我的建议是:先用逻辑回归建基线,再用 XGBoost 提分,最后用 SHAP 值解释 XGBoost 的预测。
4.3 概率校准:为什么 AUC 高不代表概率准
AUC 只关心排序,不关心概率绝对值。但卒中预测模型输出的是「风险概率」,如果模型说 30% 风险,实际发生率应该接近 30%。XGBoost 默认输出的概率往往偏极端,需要校准。
from sklearn.calibration import CalibratedClassifierCV xgb_base = XGBClassifier(scale_pos_weight=scale, eval_metric="auc") calibrated = CalibratedClassifierCV(xgb_base, method="isotonic", cv=5) calibrated.fit(X_train, y_train) # 校准后看 Brier score from sklearn.metrics import brier_score_loss probs = calibrated.predict_proba(X_test)[:, 1] print("Brier score:", brier_score_loss(y_test, probs))参数说明:method="isotonic"适合样本量大于 1000 的情况,样本少用"sigmoid"。cv=5表示在交叉验证内部做校准,避免过拟合。Brier score 越小越好,0.1 以下算不错。
5. 避坑与排查:卒中预测模型上线前必须过的五道坎
5.1 现象:验证集 AUC 0.87,测试集 AUC 0.62
原因:数据泄漏。最常见的是在划分训练测试集之前做了标准化或缺失值填充,导致测试集的统计量漏进了训练过程。另一个可能是患者 ID 重复,同一个患者既在训练集又在测试集。
解决:所有预处理步骤(填充、标准化、分箱边界、target encoding)必须用Pipeline包起来,只在训练集上fit,然后transform测试集。患者 ID 要去重后再划分。
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.impute import SimpleImputer pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ("clf", LogisticRegression(class_weight="balanced")) ]) pipe.fit(X_train, y_train)5.2 现象:模型对 40 岁以下患者全部预测为低风险,但实际有 2% 的阳性
原因:年龄分箱把 40 岁以下归为一箱,样本量少,模型学不到这一箱里的风险差异。另外,年轻患者的卒中往往和心脏病、凝血异常相关,这些特征没被充分利用。
解决:把 40 岁以下再细分,或者加入heart_disease * age的交互项。如果样本量实在不够,考虑用贝叶斯方法做小样本估计。
5.3 现象:SHAP 值显示「吸烟状态」重要性很低,和临床认知矛盾
原因:吸烟状态缺失率高达 30%,填充后信息被稀释。另外,如果吸烟状态用的是 one-hot 编码,SHAP 会把三个类别的贡献拆开,单个类别看起来不重要。
解决:检查smoking_status_is_missing列的重要性。如果缺失指示列重要性高,说明缺失本身有信息。把 one-hot 的三个列合并成一个 SHAP 组再算总贡献。
5.4 现象:校准曲线在低概率区间偏离严重
原因:isotonic 校准在样本稀疏的区域会过拟合。如果测试集里正样本只有 200 个,低概率区间的校准曲线会抖动。
解决:改用 sigmoid 校准,或者增加校准样本量。也可以只对 0.1-0.9 区间做校准,两端直接截断。
5.5 现象:上线后每周 AUC 下降 0.02
原因:数据漂移。新患者的年龄分布、血糖水平分布和训练集不一致。或者医院换了血糖仪,单位变了但没通知。
解决:部署时加一个监控模块,每周算一次 PSI(Population Stability Index)。PSI 大于 0.2 就触发告警,重新训练模型。
def calculate_psi(expected, actual, bins=10): expected_perc, _ = np.histogram(expected, bins=bins) actual_perc, _ = np.histogram(actual, bins=bins) expected_perc = expected_perc / len(expected) actual_perc = actual_perc / len(actual) psi = np.sum((expected_perc - actual_perc) * np.log((expected_perc + 1e-6) / (actual_perc + 1e-6))) return psi6. 进阶技巧:用 conformal prediction 给卒中风险一个可信区间
传统模型输出一个点估计,比如「风险 23%」。但医生会问:这个 23% 有多准?conformal prediction 能给出一个区间,比如「风险在 18% 到 31% 之间,置信度 90%」。这在临床场景里比单点概率有用得多。
做法很简单:先用训练集训一个模型,再用一个独立的校准集算残差分布。对新样本,取模型输出加减残差的分位数。
from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier import numpy as np # 划分训练集和校准集 X_train, X_cal, y_train, y_cal = train_test_split(X, y, test_size=0.2, stratify=y) model = RandomForestClassifier(n_estimators=200, class_weight="balanced") model.fit(X_train, y_train) # 在校准集上算非一致性分数 cal_probs = model.predict_proba(X_cal)[:, 1] cal_scores = np.abs(y_cal - cal_probs) # 对新样本,取 90% 分位数 alpha = 0.1 q = np.quantile(cal_scores, 1 - alpha) def predict_interval(x_new): p = model.predict_proba(x_new.reshape(1, -1))[:, 1][0] lower = max(0, p - q) upper = min(1, p + q) return lower, upper # 示例 lower, upper = predict_interval(X_test.iloc[0].values) print(f"风险区间: [{lower:.2f}, {upper:.2f}]")参数说明:alpha=0.1表示 90% 置信度。q是校准集上非一致性分数的 90% 分位数。区间宽度取决于模型在校准集上的表现——模型越准,q越小,区间越窄。注意校准集必须和训练集独立,否则区间会偏窄。
这个方法的优势是不依赖模型假设,逻辑回归、XGBoost、随机森林都能用。代价是区间宽度可能比较大,如果q超过 0.2,说明模型不确定性太高,需要回头检查特征或增加数据。
我自己的习惯是:每次上线新模型前,先跑一遍 conformal prediction,看看区间宽度。如果宽度超过 0.3,我就不敢把点估计直接给医生看,而是把区间一起展示。这个习惯帮我避免了好几次「模型说低风险但实际阳性」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取