简介:Diabetes-Predictor 是一份面向数据科学初学者与医疗健康分析爱好者的机器学习实战资源,围绕 Pima Indians 糖尿病数据集,完整演示从数据预处理、探索性分析到模型训练与评估的预测流程,帮助读者掌握疾病风险建模的基本方法。压缩包共 4 个文件,约 322KB,包含 1 个 Jupyter Notebook 用于分步讲解分析与建模过程,1 个 Python 脚本便于直接运行复现,1 个 joblib 格式的已训练模型文件可加载验证,以及 1 个 Markdown 说明文档交代项目背景与使用方式。目前已有 235 人学习下载。通过这份资源,读者可以对照 Notebook 理解特征工程、模型选择与交叉验证等关键环节,借助脚本快速跑通预测流程,并利用现成模型文件直接体验糖尿病风险预测的推理效果,适合作为课程作业、自学练手或教学演示的参考案例。
1. 从 Pima 数据集说起:Diabetes-Predictor 到底在预测什么
很多团队第一次接触糖尿病风险预测,都是从一个叫 Pima Indians Diabetes 的公开数据集开始的。Diabetes-Predictor 这个标题背后,指的是一类用结构化体检指标做二分类的机器学习项目:输入怀孕次数、血糖、血压、皮脂厚度、胰岛素、BMI、糖尿病家族史、年龄这 8 个字段,输出一个人是否属于糖尿病高风险人群。它解决的不是临床确诊问题,而是筛查和分诊——在资源有限的门诊或体检场景里,先把高风险的人挑出来,再安排进一步检查。
这件事适合谁做?如果你手上有体检机构的脱敏表格、想验证一个特征工程流程、或者要给基层卫生站做一个轻量级风险打分工具,Diabetes-Predictor 是个很好的起点。它数据量小、特征含义明确、训练成本低,一台普通笔记本就能跑完全流程。但正因为简单,很多人栽在数据清洗和阈值选择上,模型 AUC 看着不错,上线后却没人敢用。下面我把从数据到可解释输出的完整路径拆开讲。
2. 数据清洗与特征工程:Pima 数据集里那些不能直接喂给模型的坑
2.1 零值不是缺失值,但比缺失值更麻烦
Pima 数据集最出名的血泪经验是:Glucose、BloodPressure、SkinThickness、Insulin、BMI 这 5 列里,0 是无效值,不是真实测量结果。比如胰岛素为 0 在生理上几乎不可能,它代表的是“未检测”。如果你直接把这些 0 当数值喂进去,模型会学到一个荒谬的规律:胰岛素越低越健康。
常见做法是把 0 替换成 NaN,再按类别或整体中位数填充。我一般会先看每列 0 的占比,超过 15% 的列要谨慎,填充方式会显著影响后续模型校准。
import pandas as pd import numpy as np # 列名按 Pima 数据集标准顺序 cols = ['Pregnancies', 'Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI', 'DiabetesPedigreeFunction', 'Age', 'Outcome'] df = pd.read_csv('diabetes.csv', names=cols, header=0) # 这些列里的 0 视为缺失 zero_as_missing = ['Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI'] df[zero_as_missing] = df[zero_as_missing].replace(0, np.nan) # 查看缺失比例,决定填充策略 print(df[zero_as_missing].isna().mean().sort_values(ascending=False))逻辑说明:先替换再统计,避免把 0 混进均值里。参数上,replace(0, np.nan)只作用于指定列,Pregnancies 的 0 是合理的(未怀孕),不能动。填充时我倾向用中位数而不是均值,因为胰岛素和皮脂厚度分布明显右偏,均值会被极端值拉高。
2.2 用分组中位数填充,比全局中位数更稳
直接对整列取中位数填充,会忽略 Outcome 分组带来的分布差异。糖尿病组的血糖中位数天然高于对照组,如果混在一起填,等于人为削弱了特征区分度。我一般按 Outcome 分组后分别填充,再检查填充前后分布是否合理。
# 按 Outcome 分组填充中位数 for col in zero_as_missing: df[col] = df.groupby('Outcome')[col].transform( lambda x: x.fillna(x.median()) ) # 填充后确认没有残留缺失 assert df[zero_as_missing].isna().sum().sum() == 0 print(df.groupby('Outcome')[['Glucose', 'Insulin', 'BMI']].median())逻辑说明:groupby('Outcome').transform保证填充值来自同一组,不会跨组污染。参数上,中位数对偏态分布更稳健;如果某组某列缺失过多,中位数本身不可靠,这时要考虑直接删列或改用模型插补。填充完打印分组中位数,能快速判断两组差异是否还保留着——如果填充后两组中位数几乎一样,说明填充策略把信号抹平了。
2.3 特征缩放和派生特征:别急着上复杂模型
逻辑回归和 SVM 对尺度敏感,树模型不敏感。我通常先做一个标准化版本用于线性模型,同时保留原始尺度给树模型。派生特征方面,BMI 和 Age 的交互、Glucose 与 Insulin 的比值(HOMA-IR 的简化代理)在业务解释上比原始字段更有说服力。
from sklearn.preprocessing import StandardScaler # 派生特征:血糖胰岛素比,加一个小常数避免除零 df['Glucose_Insulin_Ratio'] = df['Glucose'] / (df['Insulin'] + 1) # 标准化版本,供逻辑回归使用 feature_cols = ['Pregnancies', 'Glucose', 'BloodPressure', 'SkinThickness', 'Insulin', 'BMI', 'DiabetesPedigreeFunction', 'Age', 'Glucose_Insulin_Ratio'] scaler = StandardScaler() X_scaled = scaler.fit_transform(df[feature_cols])逻辑说明:比值特征在医学文献里常见,但要注意它和原始 Glucose、Insulin 存在共线性,线性模型里建议做 VIF 检查。参数上,+1是防止胰岛素为 0 时除零,虽然前面已经填充过,但派生计算前保留这个保护更安全。标准化只在训练集上 fit,验证集和测试集用同一个 scaler transform,这是铁律。
3. 模型训练与评估:从逻辑回归到梯度提升的选型对比
3.1 先跑一个逻辑回归基线,别一上来就 XGBoost
逻辑回归在 Pima 这种小数据集上往往表现不差,而且系数可以直接解释成风险因素的方向和强度。我习惯先跑基线,记录 AUC、召回率和混淆矩阵,再决定要不要上复杂模型。如果基线召回率已经满足筛查需求,复杂模型带来的边际收益可能不值得维护成本。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, recall_score, confusion_matrix X_train, X_test, y_train, y_test = train_test_split( X_scaled, df['Outcome'], test_size=0.2, random_state=42, stratify=df['Outcome'] ) lr = LogisticRegression(max_iter=1000, class_weight='balanced') lr.fit(X_train, y_train) y_prob = lr.predict_proba(X_test)[:, 1] y_pred = (y_prob >= 0.5).astype(int) print('AUC:', roc_auc_score(y_test, y_prob)) print('Recall:', recall_score(y_test, y_pred)) print(confusion_matrix(y_test, y_pred))逻辑说明:class_weight='balanced'在阳性样本偏少时能提升召回,筛查场景更看重召回而不是精确率。参数上,max_iter=1000是防止默认迭代次数不够导致不收敛;stratify保证训练测试集阳性比例一致。跑完看混淆矩阵,如果漏诊(假阴性)多,就要降阈值或换模型。
3.2 梯度提升树的参数怎么调:三个必调项
如果基线召回不够,我会试 Gradient Boosting 或 XGBoost。小数据集上不要猛调参,重点看三个:树的数量、学习率、最大深度。树太多容易过拟合,学习率太低训练慢,深度太大直接记住噪声。
from sklearn.ensemble import GradientBoostingClassifier from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [100, 200], 'learning_rate': [0.05, 0.1], 'max_depth': [2, 3] } gb = GradientBoostingClassifier(random_state=42) grid = GridSearchCV(gb, param_grid, cv=5, scoring='roc_auc', n_jobs=-1) grid.fit(X_train, y_train) print('Best params:', grid.best_params_) print('Best AUC:', grid.best_score_)逻辑说明:max_depth限制在 2 到 3,是因为 Pima 只有几百条样本,深树必然过拟合。scoring='roc_auc'比准确率更适合不平衡数据。参数上,cv=5在样本量小时够用,再大意义不大。跑完用最佳参数在测试集上验证,如果测试 AUC 比交叉验证低超过 0.05,说明过拟合了,要减树或降深度。
3.3 评估指标别只看准确率,筛查场景盯住召回和校准
糖尿病筛查里,漏掉一个高风险患者的代价远大于误报。所以召回率是核心指标,同时要看概率校准——模型输出的 0.7 是不是真的代表 70% 风险。如果校准差,阈值就没法按业务需求灵活调整。
from sklearn.calibration import calibration_curve import matplotlib.pyplot as plt prob_true, prob_pred = calibration_curve(y_test, y_prob, n_bins=10) plt.plot(prob_pred, prob_true, marker='o', label='Model') plt.plot([0, 1], [0, 1], linestyle='--', label='Perfectly calibrated') plt.xlabel('Predicted probability') plt.ylabel('True probability') plt.legend() plt.show()逻辑说明:校准曲线偏离对角线越远,概率越不可信。参数上,n_bins=10是常用分箱数,样本少时可以降到 5。如果校准差,可以用 Platt scaling 或 isotonic regression 做后处理,但要在独立校准集上做,不能拿测试集调。
4. 避坑与排查:Diabetes-Predictor 落地时最容易翻车的 4 个地方
4.1 现象:交叉验证 AUC 很高,上线后一塌糊涂
原因:数据泄漏。最常见的是在划分训练测试集之前就做了标准化或填充,导致测试集信息渗入训练过程。另一个隐蔽来源是用了 Outcome 分组填充,如果填充时用了全量数据的中位数,测试集分布就被提前知道了。
解决:所有预处理必须放进 Pipeline,在交叉验证内部 fit。用sklearn.pipeline.Pipeline把填充、缩放、模型串起来,cross_val_score会自动在每个 fold 内独立处理。
from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer pipe = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('clf', LogisticRegression(max_iter=1000, class_weight='balanced')) ])4.2 现象:模型把 Insulin 当成最强特征,但业务上说不通
原因:Insulin 缺失率最高,填充后分布失真,模型可能学到了填充模式的伪信号。如果填充值恰好和 Outcome 有相关性,就会放大这个特征的重要性。
解决:对比填充前后特征重要性排序,如果 Insulin 排名剧烈变化,考虑直接删掉这列或改用缺失指示变量。我一般会跑一版不含 Insulin 的模型,看 AUC 掉多少,掉得少就删。
4.3 现象:阈值设 0.5,召回率只有 0.6,漏掉太多高风险
原因:默认阈值不适合筛查场景。阳性样本少时,模型概率普遍偏低,0.5 会偏向预测阴性。
解决:在验证集上画 PR 曲线,找召回优先的阈值。比如要求召回不低于 0.85,然后看对应的精确率能不能接受。阈值确定后固化到配置文件,不要每次手动改。
from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_test, y_prob) # 找召回 >= 0.85 时对应的最大阈值 idx = np.where(recall >= 0.85)[0][-1] print('Threshold:', thresholds[idx], 'Precision:', precision[idx])4.4 现象:换一批新数据,模型输出全是 0 或全是 1
原因:新数据的特征尺度或分布和训练集差异大,标准化参数不匹配。比如新数据血糖单位是 mmol/L,训练集是 mg/dL,直接 transform 后数值全乱。
解决:上线前做输入校验,检查每个特征的均值和方差是否在训练集合理范围内。超出范围就报警,不要硬跑。另外把 scaler 的 mean_ 和 scale_ 保存下来,推理时用同一套参数。
5. 把模型变成可解释的风险打分:SHAP 值分析与阈值固化技巧
模型跑通只是第一步,真正让医生或体检机构愿意用,得让他们知道“为什么这个人被标成高风险”。SHAP 是目前最实用的解释工具,它对每个样本给出每个特征的贡献值,正负方向明确。我一般会挑几个典型样本,画出 SHAP 力图,再汇总全局重要性。
import shap explainer = shap.TreeExplainer(grid.best_estimator_) shap_values = explainer.shap_values(X_test) # 全局重要性 shap.summary_plot(shap_values, X_test, feature_names=feature_cols) # 单个样本解释 shap.force_plot(explainer.expected_value, shap_values[0], X_test[0], feature_names=feature_cols)逻辑说明:TreeExplainer对树模型快且准,线性模型可以用LinearExplainer。参数上,X_test要是原始尺度还是标准化尺度,取决于模型训练时用的数据——这里模型是在标准化数据上训练的,所以传入标准化后的数组。SHAP 值正负代表推高还是拉低风险,绝对值代表影响大小。
阈值固化方面,我习惯把验证集上选定的阈值、对应的召回和精确率写进一个 JSON 配置文件,推理服务启动时加载。这样业务方改阈值不用动代码,也避免不同环境阈值不一致。
import json config = { 'threshold': float(thresholds[idx]), 'expected_recall': 0.85, 'expected_precision': float(precision[idx]), 'model_version': 'lr_balanced_v1' } with open('predict_config.json', 'w') as f: json.dump(config, f, indent=2)最后说一个我自己的习惯:每次重新训练模型,我都会把新模型的 SHAP 全局重要性图和上一版并排看。如果排名前三的特征换了,哪怕 AUC 涨了,我也会先怀疑数据出了问题,而不是急着上线。这个习惯帮我拦下过好几次因为上游数据表字段错位导致的“假提升”。希望帮到你。
本文还有配套的精品资源,点击获取