脑卒中预测模型实战:从数据清洗到类别不平衡与阈值选择 简介面向机器学习初学者与医疗数据分析人员这份“中风预测模型”项目基于Kaggle公开数据集使用JupyterNotebook完成从数据探索、特征过滤到模型评估的完整流程通过年龄、性别、高血压、心脏病及吸烟状况等字段预测中风风险。项目中包含数据清洗与可视化环节并对模型性能进行调优与验证可作为二分类预测任务的典型参考案例。压缩包共11个文件涵盖3个ipynb分析笔记、Python脚本、原始CSV数据、训练好的pkl模型及若干部署文件Procfile、requirements.txt、setup.sh等整体约4.36MB结构紧凑便于按需查阅。配套的Readme与细节笔记解释了每一步建模思路Heroku部署脚本支持直接上线体验形成“数据-模型-部署”的完整链路。资源已有739人学习非常适合希望快速上手医疗数据实践的学习者。读者既能获得可运行的代码和模型也能参考项目组织方式与部署方法在较短时间内完成从数据处理到应用发布的全流程演练。1. 脑卒中预测模型数据不缺缺的是对类别不平衡的处理方式某健康管理平台拿公开统计资料做脑卒中预测模型stroke-prediction-model时第一次跑通逻辑回归准确率 96%团队差点直接上生产。结果看了混淆矩阵才发现真正发病的人一个都没抓住。这就是脑卒中预测模型最反直觉的地方——样本里中风患者通常只占 1% 到 3%准头全被预测阴性贡献了。这篇文章想解决的不是怎么训练一个模型而是怎么做才敢拿去用从数据清洗、特征构造、类别不平衡处理到阈值选取全程给出可复现代码和踩坑记录。适合正在做健康风险筛查、慢病管理或体检辅助评估的开发者参考。2. 脑卒中预测模型的数据准备从原始字段到可训练样本2.1 原始字段的类型与可用性判断先快速过一遍这类开源健康统计里最常见的字段构成因为后续所有清洗和特征工程都建立在对字段的理解上。通常包含id患者标识、gender性别、age年龄、hypertension高血压病史、heart_disease心脏病史、ever_married是否已婚、work_type工作类型、Residence_type居住类型、avg_glucose_level平均血糖、bmi身体质量指数、smoking_status吸烟状态以及目标列 stroke。从建模角度这些字段可以分成三类数值型、二分类、多分类。我建议在做任何统计之前先按下面的思路过一遍。字段类型预处理建议需要特别注意的点age数值保留原始值或分箱和中风发病率强相关但不要过度分箱丢掉线性信息avg_glucose_level数值观察分布处理极端值有些统计里血糖值明显偏高可能是测量环境不同bmi数值缺失值必须处理缺失率通常不低处理方式直接决定模型表现hypertension二分类保留 0/1 编码语义清晰直接使用heart_disease二分类保留 0/1 编码同样直接使用gender类别观察取值再编码有的统计里会出现 Other 类别占比极低要提前合并ever_married类别转 0/1二分类语义适合直接转work_type多元类别OneHot 或数值编码类别间没有大小关系树模型用 LabelEncoder 也可以Residence_type类别转 0/1Urban/Rural二分类smoking_status多元类别小心 Unknown 取值很多统计里 Unknown 占比不小直接删会浪费样本一个重要提醒id 字段绝对不能作为特征。有些初学者在对数据做整体统计时把 id 也喂进模型结果验证集 AUC 高到离谱。id 在训练集里是唯一的模型学到的是记住每个样本而不是学会判断风险。2.2 缺失值处理与类别编码的最小代码拿到数据后第一步不是建模而是把缺失和编码处理好。下面是这套流程里我会反复用到的处理方式你可以直接照着跑。import pandas as pd import numpy as np df pd.read_csv(stroke_data.csv) print(df.info()) print(df.isnull().sum()) # bmi 缺失是脑卒中统计里的常态直接用全局中位数填充会压缩年龄段的差异 # 我一般会按年龄分组后取组内中位数填充避免年轻人被填成老年人水平 # 先构造年龄段分箱再对 bmi 做 transform 填充 df[age_group] pd.cut(df[age], bins[0, 30, 50, 70, 100], labels[0, 1, 2, 3]) df[bmi] df[bmi].fillna( df.groupby(age_group, observedFalse)[bmi].transform(median) ) df df.drop(columns[age_group]) # smoking_status 里的 Unknown 不要和有效类别混在一起 # 在不确定真实状态的情况下常见做法是单独建一列 unknown 标记再把 Unknown 归为 never_smoked df[smoking_unknown] (df[smoking_status] Unknown).astype(int) df[smoking_status] df[smoking_status].replace(Unknown, never_smoked)逻辑说明先通过 info 和 isnull 确认缺失分布。bmi 的缺失在公开健康资料里很常见简单用全局中位数填充会让模型以为所有年龄段的体型水平一致这不符合医学常识所以按年龄段分组填充更稳妥。smoking_status 的 Unknown 建议单独标记因为数据没记录本身可能带有信息直接归并到某个真实类别会掩盖这种模式。接下来是类别编码。需要注意LabelEncoder 在树模型上够用但逻辑回归最好用 OneHot因为线性模型对类别编码的数字大小是敏感的。from sklearn.preprocessing import LabelEncoder # 多分类类别字段用 LabelEncoder 转为数值 # 注意如果后续测试数据里出现训练时没见过的类别LabelEncoder 会直接报错 # 解决方式见第 5 章的避坑部分这里先用最直接的写法 categorical_cols [gender, work_type, Residence_type, smoking_status] le_dict {} for col in categorical_cols: le LabelEncoder() df[col] le.fit_transform(df[col]) le_dict[col] le # gender 里的 Other 类别占比极低合并到占比最高的类别 if Other in df[gender].unique(): df[gender] df[gender].replace(Other, df[gender].mode()[0]) # ever_married 是二分类语义直接映射为 0/1 df[ever_married] df[ever_married].map({Yes: 1, No: 0}).astype(int) print(df.head())参数说明le_dict 保存了每个字段的编码器后续对测试集做同样转换时要复用不能重新 fitgender 里的 Other 如果存在且占比极低直接替换成众数类别让模型少学一个噪声分组house 等相关字段如果存在但业务含义不明确建议先做单变量分析相关性极低的直接丢弃。2.3 训练集与验证集切分分层抽样是必须的脑卒中数据里正样本比例通常只有 2% 左右随机切分很容易把正样本全分到训练集或验证集。我的做法是必须用 stratify 参数按目标列分层切分。from sklearn.model_selection import train_test_split # 数据里如果有重复患者记录先按 id 去重再切分 # 否则同一个患者的多次就诊记录会同时出现在训练集和验证集里造成隐性数据泄漏 df df.drop_duplicates(subsetid, keepfirst) X df.drop(columns[id, stroke]) y df[stroke] X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print(y_train.value_counts()) print(y_val.value_counts())这里的逻辑很直接stratifyy 让切分后的正负样本比例和原始数据一致避免运气成分。去重的目的是防止同一患者的多条记录被切开后模型在训练时见过这个患者的部分特征到验证时又遇到同一患者的不同记录导致验证 AUC 虚高。数据切分之后任何基于全量统计量的操作都不能再做。比如标准化、归一化、重采样都必须基于训练集统计或拟合验证集只能做转换。这条不做后面验证集再漂亮都是假的。3. 脑卒中预测模型选型与训练从逻辑回归到集成模型3.1 三种常见模型的适用边界在脑卒中预测这个场景里我需要的是稳定、可解释、概率输出靠得住的模型而不是花哨的深度网络。原因有三点健康风险场景对误诊和漏诊都敏感需要模型输出概率而不是硬分类样本量通常在几千到几万之间深度模型容易过拟合监管审查时需要向业务方解释每个特征的贡献方向黑匣子模型很难通过评审。模型优点缺点适用场景逻辑回归可解释强、训练快、概率校准好无法学习复杂非线性关系基线模型、需要向业务解释特征贡献方向时随机森林对类别特征和非线性关系友好概率输出偏离散外推能力弱特征维度高、类别字段多的场景XGBoost / LightGBM精度上限高、能处理缺失和类别需要调参防过拟合概率校准偏差追求最高精度、样本量足够时我的日常做法是先用逻辑回归跑通整个流程看基线指标然后随机森林验证有没有提升最后上 XGBoost 看能不能进一步压榨。这样三步走每一步的收益和代价都清楚。如果你一上来就上 XGBoost很容易陷入调参泥潭最后也不知道提升来自模型还是数据。3.2 用交叉验证训练并对比基线下面这段代码把训练流程串起来并对比逻辑回归和随机森林在完全相同的验证集上的表现。注意先做标准化再训练标准化必须只 fit 训练集。import warnings warnings.filterwarnings(ignore) from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.metrics import roc_auc_score, classification_report # 标准化只 fit 训练集验证集用同一个 scaler 转换 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_val_scaled scaler.transform(X_val) # 逻辑回归class_weightbalanced 是给少数类加权重 # max_iter 默认 100 容易不收敛特别是特征没有标准化时这里显式提到 1000 lr LogisticRegression( class_weightbalanced, max_iter1000, random_state42 ) lr.fit(X_train_scaled, y_train) # 随机森林同样用 class_weight 处理类别不平衡 # max_depth 限制防止过拟合min_samples_leaf 确保叶子节点有足够样本 rf RandomForestClassifier( n_estimators300, max_depth8, min_samples_leaf10, class_weightbalanced_subsample, random_state42, n_jobs-1 ) rf.fit(X_train_scaled, y_train) for name, model in [(LogisticRegression, lr), (RandomForest, rf)]: y_pred_proba model.predict_proba(X_val_scaled)[:, 1] auc roc_auc_score(y_val, y_pred_proba) print(f{name} Val AUC: {auc:.4f})参数说明class_weightbalanced 会让少数类样本在损失函数里获得更高权重这是处理脑卒中数据不平衡的第一道防线逻辑回归的 max_iter 调高是因为默认 100 次迭代在特征标准化不当时经常报收敛警告随机森林里 class_weightbalanced_subsample 和balanced的区别在于前者是对每个自助采样子样本重新计算权重对数据不平衡的适应能力更强。3.3 必调的参数清单如果你没有时间做完整调参至少把下面这几个参数设对效果会比默认参数好很多。逻辑回归必调参数class_weight必须设为 balanced默认状态下模型会把所有样本倾向于预测为多数类max_iter设到 1000 以上避免收敛警告影响结果的稳定性C默认 1.0 先跑如果过拟合再减小到 0.1 或 0.01随机森林必调参数class_weightbalanced_subsample 比 balanced 对少数类更友好max_depth8 到 12 之间不限制深度很容易把个别样本记死min_samples_leaf10 以上强制每个叶子有足够样本支撑XGBoost 必调参数scale_pos_weight负样本数除以正样本数这是 XGBoost 处理类别不平衡最直接的手段max_depth3 到 6 足够太深必过拟合early_stopping_rounds配合验证集用防止训练轮次过多这里有个经验之谈在脑卒中预测这种正样本占比极低的情况下网格搜索不如先把类别权重设对来得有效。很多人一上来就 GridSearchCV 疯狂调参却忽略了 class_weight 根本没设置模型在数据层面就是歪的后面怎么调都白搭。4. 脑卒中预测模型的类别不平衡重采样、权重与评估指标选型4.1 准确率在脑卒中数据上是骗人的先说一个必须刻在脑子里的结论在这类数据上准确率没有任何参考价值。假设你的数据里中风患者占 2%一个把所有样本都预测为未中风的模型准确率是 98%看起来非常强但它实际上什么都没做。我在第一次跑脑卒中预测模型的时候就是被这个 98% 骗了。直到我打印出混淆矩阵发现真正的中风患者预测正确数为 0才意识到问题有多严重。对于健康风险筛查场景漏掉一个真正会发病的人代价远大于把一个健康人误判为高风险。所以评估时必须盯着少数类的表现而不是整体准确率。正确做法是把四个指标一起看敏感性召回率、特异性、ROC-AUC、PR-AUC。其中 PR-AUC 对正样本占比极低的数据尤其敏感因为它在计算时不受负样本数量主导。from sklearn.metrics import confusion_matrix, recall_score, precision_score, f1_score # 使用默认阈值 0.5 预测 y_pred lr.predict(X_val_scaled) tn, fp, fn, tp confusion_matrix(y_val, y_pred).ravel() sensitivity tp / (tp fn) specificity tn / (tn fp) print(fSensitivity: {sensitivity:.4f}) print(fSpecificity: {specificity:.4f}) print(fPR-AUC: {average_precision_score(y_val, lr.predict_proba(X_val_scaled)[:, 1]):.4f})这段代码会直接告诉你模型在抓住中风险患者和不误伤健康人之间做得怎么样。如果 Sensitivity 极低而 Specificity 接近 1说明模型把所有样本都推给了阴性侧这时候最需要做的不是换模型而是调整类别权重或重采样。4.2 重采样与 class_weight 的取舍处理类别不平衡主流思路有两种在数据层面做重采样在算法层面调权重。我通常先试 class_weight因为简单且不引入额外噪声效果不理想再用重采样。SMOTESynthetic Minority Over-sampling Technique是表格数据里最常用的少数类合成方法。它的原理是在两个邻近的少数类样本之间插值生成新样本。对脑卒中预测来说合成数据本身不会增加真实信号但它能让模型有更多少数类样本来学习边界。from imblearn.over_sampling import SMOTE from imblearn.pipeline import Pipeline as ImbPipeline # SMOTE 只能用在训练集上绝不能对全量数据做 # 否则合成的样本会同时污染训练集和验证集验证结果完全失真 smote SMOTE(random_state42, k_neighbors3) # 用 imblearn 的 Pipeline 把 SMOTE 和模型串起来 # 好处是交叉验证时每个 fold 只在训练部分做重采样 smote_pipeline ImbPipeline([ (smote, SMOTE(random_state42, k_neighbors3)), (lr, LogisticRegression(class_weightbalanced, max_iter1000)) ]) smote_pipeline.fit(X_train_scaled, y_train) y_pred_proba_smote smote_pipeline.predict_proba(X_val_scaled)[:, 1] print(fSMOTE LR Val AUC: {roc_auc_score(y_val, y_pred_proba_smote):.4f})参数说明k_neighbors 是 SMOTE 生成样本时参考的近邻数正样本太少时建议调小到 3 左右太大了会在稀疏区域生成大量不靠谱的合成样本。必须遵守的铁律是重采样只能在训练集内部做用 imblearn 的 Pipeline 能强制保证这一点不会因为手写流程出错导致数据泄漏。从实际对比看class_weight 和 SMOTE 并不互斥。我的经验是逻辑回归配 balanced 通常就能把敏感性拉到可接受水平但树模型特别是随机森林配合 SMOTE 有时会有进一步提升因为合成样本让树有更多少数类结构可学。可以两个都跑一遍对比 AUC不必想当然。4.3 该看什么指标ROC-AUC、PR-AUC、混淆矩阵与校准曲线先说一个常见误区ROC-AUC 对类别不平衡不敏感因为它分别计算真正率和假正率不受样本比例影响。但 ROC-AUC 高不代表模型在少数类上表现好——想象一个模型随机乱猜ROC-AUC 也能到 0.5 以上。PR-AUC 更依赖少数类表现正样本极少时PR-AUC 的变动比 ROC-AUC 明显得多能更好地区分真有用和假有用的模型。校准曲线也值得看。很多集成模型输出的概率并不是真实概率——XGBoost 输出的概率整体偏高或偏低是常态。如果后续你的业务要按概率阈值分群比如 0.7 以上进高危队列概率不准会导致实际分群比例和预期完全对不上。from sklearn.calibration import calibration_curve, CalibratedClassifierCV # 画校准曲线看模型预测概率和真实频率是否一致 prob_true, prob_pred calibration_curve( y_val, lr.predict_proba(X_val_scaled)[:, 1], n_bins10 ) print(预测概率均值:, prob_pred) print(实际频率:, prob_true) # 如果校准偏差大用 CalibratedClassifierCV 做概率校正 # 常见做法是用 sigmoid 方法样本量少时比 isotonic 更稳定 calibrated_lr CalibratedClassifierCV(lr, methodsigmoid, cv5) calibrated_lr.fit(X_train_scaled, y_train) y_proba_calibrated calibrated_lr.predict_proba(X_val_scaled)[:, 1]这段代码能直接告诉你模型输出概率的可信度。概率校正建议在确定最终模型后再做因为校正本身需要额外的数据通过交叉验证避免过拟合如果还没定模型就校正后面换模型还得重新校正一遍效率太低。5. 脑卒中预测模型常见问题排查现象、原因与解决这一章是实战里踩坑最多的部分每一类问题都是我或者身边搞健康数据建模的同事真实遇到过的按现象 → 原因 → 解决列出希望能省掉你逐条排查的时间。5.1 验证 AUC 高到离谱部署后完全失效现象验证集 ROC-AUC 能到 0.95 以上模型表现好得不真实。交付到业务侧后真实识别率远低于实验数字甚至不如简单规则。原因绝大多数情况下是数据泄漏。最常见的一种在切分前用全量数据算均值或中位数做了缺失值填充验证集的信息在训练之前就被模型偷看到了。第二种常见泄漏是剔除了中风后随访时测得的干预后特征比如某些版本数据集里血糖可能是发病后测量的相当于用结果反推结果。解决切分后再做填充和标准化这是一条铁律。把原始字段里所有可能受发病影响的特征列出来和业务方确认测量时间点是否都在发病前。我现在的习惯是把 id 和所有时间相关字段都先 drop再考虑建模。测试集单独留一份从始至终不碰最后才跑一次防止自己欺骗自己。5.2 训练 AUC 0.92验证 AUC 却只有 0.68过拟合现象训练集表现特别好验证集明显缩水说明模型把训练集里的噪声也背下来了。原因正样本太少是根源。假设训练集只有 200 个正样本模型很容易把它们学成特殊模式。XGBoost 和随机森林如果 max_depth 不限制或者 min_child_weight 太小单个叶子节点可能只覆盖一两个样本导致过拟合。特征也同理特征数量超过样本量的十分之一时过拟合风险急剧上升。解决先做特征筛选用单变量统计检验卡方或互信息筛掉和 target 无关的字段。然后调模型复杂度随机森林限制 max_depth 和 min_samples_leafXGBoost 用早停。交叉验证必须用 StratifiedKFold且每个 fold 里正样本数量都很少多看几次验证结果再下结论不要单靠一次切分的验证集就定模型。5.3 模型几乎把所有样本预测为未中风现象混淆矩阵里第一行预测为阴性数量巨大第二行几乎为零。召回率不到 5%。原因默认阈值 0.5 在类别严重不平衡时是失效的。模型对正样本的平均预测概率可能只有 0.2离 0.5 阈值太远。更深层的原因是训练时模型没有收到足够的少数类信号即使不加 class_weight损失函数也会被多数类主导。解决先设置 class_weightbalanced 或者 scale_pos_weight让少数类信号在损失函数里放大。这样模型输出概率会整体向高风险方向偏移再用验证集重选阈值。选择阈值的常见做法是遍历 0.1 到 0.9找到使 F2-score敏感性权重更高最大的点或者业务指定特异性不低于某个值后让敏感性最大化。我上次调的最终阈值是 0.32谁说不能用 0.5 以外的阈值业务上完全合理。5.4 测试集新类别导致编码报错现象训练好的模型在验证或上线时报错提示某个类别的值无法转换。检查后发现数据版本更新smoking_status 多了一个训练时没见过的取值。原因单独用 LabelEncoder 处理类别字段后只保存了模型权重没有保存编码器测试阶段重新 fit 编码器导致编码空间错位或直接报错。解决把预处理和模型全部装进 sklearn Pipeline用 joblib 保存整个 pipeline 而不是只保存模型。Pipeline 里调用 predict 时会自动执行训练时设定的编码、标准化、模型推理全流程测试数据进来不会跑偏。如果有类别字段的稀有取值可以在训练时把占比低于 1% 的类别合并成other这样就算测试数据里出现新类别也会落到这个兜底类里。6. 让脑卒中预测模型从概率变成决策阈值选择与简单规则化模型训练完只是第一步业务方不关心 AUC只关心你说谁有风险凭什么是这个人。所以我的习惯是最后做一步阈值选择 规则化把模型输出从一个概率转成一个可执行的分组决策。先说明为什么不能直接用概率排序列表。健康筛查场景里模型输出的是 0 到 1 之间的概率但没人会根据一个裸概率决定是否给患者做进一步检查。最常见的做法是设定两个阈值把人群分成三档低风险、中风险、高风险。低风险人群不做额外干预中风险进入随访观察高风险直接进入人工复核或转诊。这样做的好处是即便模型判断有误差业务规则的兜底也能控制风险。一个简单的实现方式如下用验证集找到合适的高阈值和低阈值然后对模型输出做业务分组。import numpy as np # 假设已经拿到校准后的验证集预测概率 prob_val calibrated_lr.predict_proba(X_val_scaled)[:, 1] # 业务定义高风险比例不超过 10%中风险比例 30% 左右 # 用分位数找阈值简单直接 high_threshold np.percentile(prob_val, 90) # 前 10% 为高风险 low_threshold np.percentile(prob_val, 70) # 前 30% 含高风险中风险 def assign_risk(prob): if prob high_threshold: return high elif prob low_threshold: return medium else: return low risk_group np.array([assign_risk(p) for p in prob_val]) # 看分组人数比例 unique, counts np.unique(risk_group, return_countsTrue) print(dict(zip(unique, counts)))这里的操作逻辑是不要直接设定 0.7、0.5 这样的固定阈值而是用验证集的分位数确定阈值这样能保证分组的比例符合业务资源分配。比如你的团队每月只能处理 10% 的高风险复核量那高阈值就取 90 分位。等到新数据进来分位阈值会让分组比例自然适应新的概率分布变化。另一个我习惯做的验证专门留出一个业务方指定的时间窗口之外的样本人工标注这批人的后续健康状态和模型预测对比。这基本相当于做一次外部验证比在同一个数据集上反复切分更有说服力。我踩过最深的坑就是只在内部验证集上调阈值没有做时间外验证结果业务反馈完全不是那么回事。最后说一个实践经验我现在看到高 AUC 的第一反应不是开心而是先去查数据泄漏。健康数据建模领域有句话叫验证集提升靠运气业务效果靠数据脑卒中预测模型尤其如此。你花在特征工程和类别不平衡上的时间永远比调参的时间值钱。希望帮到你。本文还有配套的精品资源点击获取