ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于LightGBM的糖尿病风险预警:从数据清洗到概率校准

基于LightGBM的糖尿病风险预警:从数据清洗到概率校准

简介:这是一份以《基于机器学习的糖尿病风险预警分析系统的设计与实现》为完整案例的毕业论文写作指南,主要面向计算机、数据科学、人工智能等专业的本科与专科学生。内容从选题与研究计划制定入手,逐步展开糖尿病危险因素分析、医疗数据收集与预处理、特征工程,以及随机森林、决策树、神经网络等模型的选择、训练与评估,并提供了系统架构、数据可视化和性能分析等章节的写作思路。文档还包含论文写作常见问题、解决方案以及评审和答辩准备建议,可帮助读者将机器学习理论落地到实际毕业设计项目中。资源为单个docx文档,大小约30KB,目前已累计695人学习下载,适合正在准备相关方向论文或需要完整项目参考的学生。

1. 糖尿病风险预警不是“预测血糖”:要预测的是三个月的未来

基层门诊最常见的场景:空腹血糖 5.8,医生说“回去少吃甜食”。半年后复查 6.3,再半年 7.2,确诊。数据其实一直在发出信号,只是没人事先把“哪一类人会在未来 12 个月跨过 7.0 这条线”算清楚。这个题目的价值就在这里——它不是做血糖回归,而是做机器学习的二分类预警:给定一批常规体检字段和生活方式问卷,输出“未来 12 个月内高风险”的概率。把预测目标从“当前血糖值”改成“未来是否发病”,整个建模逻辑就完全不一样了。

我拿到这类需求时,先把它当作一个标准的机器学习项目来拆:数据清洗、特征工程、模型训练、阈值调整、上线观察。这套流程能支撑内分泌科的分诊、体检报告的风险分层、慢病管理 App 的随访优先级。适合谁看:想在校招答辩里做健康类项目的同学,以及医疗信息公司里被“预测模型”需求砸中的工程师。先别急着上深度学习,这个场景里数据处理和特征工程比模型结构更值钱。

2. 数据准备:PIMA 数据集为什么撑不起一个“系统”,以及我把管线怎么搭

2.1 PIMA 数据集的三个盲区

聊到糖尿病风险预警,最常被翻出来的是 PIMA 印第安人数据集——768 行、8 个特征、二分类标签。它常被当作教学基线,但拿到真实项目里会有三个问题。第一,样本全部来自特定族裔的女性,患病率约 34%,这个先验概率比普通体检人群高出太多,模型学到的阈值天然偏向“高危”。第二,数据采集于上世纪 90 年代,饮食结构和筛查标准与现在差异很大。第三,字段太薄:只有怀孕次数、血糖、血压、皮褶厚度、胰岛素、BMI、糖尿病家族史、年龄,现代慢病管理里真正重要的生活方式变量(睡眠、运动频次、饮食结构)一个都没有。

我做项目时会先和数据结构提供方确认三件事:能不能拿到近三年的体检记录加随访结局;血糖、糖化血红蛋白、甘油三酯这类关键字段缺失率有多高;标签怎么定的——是医生复诊确诊,还是只靠空腹血糖单次超过 7.0。这三个问题直接决定系统是做一个可用的工具,还是只够写一篇论文。如果拿不到院内真实随访数据,就用公开数据集做原型验证,但心里要清楚:原型和落地之间的差距主要不在模型,在数据分布。

2.2 字段清单与清洗模板

健康预警系统的数据通常长这样:一份体检表加上一份问卷。体检表里有空腹血糖、糖化血红蛋白、收缩压、舒张压、甘油三酯、总胆固醇、HDL-C、LDL-C、BMI、年龄、性别;问卷里有运动频次、家族史、吸烟饮酒史、睡眠时长。清洗时我会先拉一张字段清单,把单位、类型、缺失率、处理方式一次性定下来,避免后面特征工程时反复返工。

字段类型常见缺失率默认处理
空腹血糖数值3%剔除联合离群值后中位数填充
糖化血红蛋白数值20%多重插补(不直接填均值)
BMI数值8%用身高体重重算,重算为空的填分组中位数
收缩压/舒张压数值15%保留两份原始列,同时生成高血压标志列
运动频次类别30%新增“未回答”类别,不填众数
随访标签0/10%以确诊记录为准,不以单次血糖为准

这张表看起来简单,但它决定了模型的天花板。比如糖化血红蛋白缺失 20%,如果直接删行,会丢掉大量有部分信息可用的样本;如果填均值,会把分布压窄,让 LightGBM 很难在糖化血红蛋白上切出有效分裂点。我在这样的场景里更信赖带随机性的多重插补,或者至少按年龄组和血糖分箱去填分组中位数,而不是全局均值。

2.3 缺失值填充:为什么“均值填补”在这里是错的

均值填补最大的问题不是偏差,而是它抹掉了特征与标签之间的关联。举个例子:年轻人空腹血糖 5.5 和老年人空腹血糖 5.5 背后的风险完全不同,全局均值填进去后,模型只能看到 5.5 这个数,看不到“这个人是老年且血糖偏高”的上下文。换句话说,均值填补把连续特征变成了一个“假精确”的常数,树模型的分裂收益会明显下降。

import pandas as pd import numpy as np from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer df = pd.read_csv("health_checkup.csv", encoding="utf-8") # 分组中位数兜底:对血糖缺失按年龄组填充,保留上下文 df["age_group"] = pd.cut(df["age"], bins=[0, 40, 55, 65, 120], labels=["<40", "40-55", "55-65", "65+"]) df["glucose_fill"] = df.groupby("age_group")["glucose"].transform( lambda x: x.fillna(x.median())) # 多重插补:对糖化血红蛋白、甘油三酯这类与多条变量相关的列做迭代估计 imputer = IterativeImputer(max_iter=10, random_state=42, sample_posterior=True) df["hba1c_mice"] = imputer.fit_transform( df[["hba1c", "glucose_fill", "bmi", "age"]].values)[:, 0] # 离群值处理:血糖低于 2.2 或高于 33.3 按生理极限直接剔除 df = df[(df["glucose"] >= 2.2) & (df["glucose"] <= 33.3)]

参数说明:sample_posterior=True表示每次插补都从后验分布中抽样,避免多次插补结果完全一样;max_iter=10是迭代估计轮数,对这个量级的数据 10 轮足够收敛。df[(df["glucose"] >= 2.2) & ...]这段是临床校验——血糖 33.3 通常是仪器上限或样本污染,留着会让模型学到“极高血糖反而安全”的伪规律。清洗完后再做标签对齐:随访结局记录在未来 12 个月内出现确诊或空腹血糖 ≥ 7.0,标为 1,否则为 0。

3. 特征工程:血糖、BMI 与年龄之外,系统真正依赖的变量是哪些

3.1 糖尿病风险预警里值得构造的四个派生特征

原始体检字段可以直接喂给树模型,但风险预警系统的难点是:单看某个指标,绝大多数人都在正常范围,组合起来才能看出趋势。特征工程的核心是把临床上已经验证过的组合关系转成数值特征。我常用四个:HOMA-IR(胰岛素抵抗指数)、年龄×血糖交互项、BMI×血压交互项、家族史加权分。

df["homa_ir"] = (df["glucose_fill"] * df["insulin"]) / 405 df["age_glu"] = df["age"] * df["glucose_fill"] / 100 df["bmi_sbp"] = df["bmi"] * df["sbp"] / 100 df["family_score"] = ( df["family_diabetes"] * 1.5 + df["family_hypertension"] * 0.8 )

HOMA-IR是内分泌科评估胰岛素抵抗的经典公式,对空腹血糖还在正常高值的人群有很强的区分度。age_glu这个交互项解决的是“年龄增大带来的正常血糖上升”和“病理性的血糖上升”之间的混淆:一个 65 岁的人血糖 6.0,和一个 30 岁的人血糖 6.0,含义完全不同。family_score把家族史从“有/没有”变成加权连续量,糖尿病一级亲属权重给到 1.5,高血压亲属给 0.8,这样模型可以在“多种家族史叠加但单项都不严重”的样本上学到规律。

3.2 特征筛选:相关性热力图只是第一步

很多人拿到数据先画相关性热力图,然后手动删掉高相关特征。这在糖尿病数据上经常翻车:BMI、腰围、甘油三酯三者高度相关,但它们分别对应肥胖、内脏脂肪和脂代谢三条不同的病理通路,全部去掉后模型 AUC 只降不升。我一般把筛选分成两步:先用 LightGBM 训练一版,取feature_importance的 gain 值排序;再做 SHAP 依赖图看特征与预测方向是否一致。两个维度都表现差的特征才删。

import lightgbm as lgb model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=31, min_data_in_leaf=20, random_state=42 ) model.fit( X_train, y_train, feature_name=list(feature_cols), categorical_feature=["family_diabetes", "exercise_freq"] ) gain_importance = pd.DataFrame({ "feature": feature_cols, "gain": model.booster_.feature_importance(importance_type="gain") }).sort_values("gain", ascending=False)

参数说明:importance_type="gain"统计的是该特征每次分裂带来的平均增益,比默认的split(分裂次数)更能反映真实贡献。categorical_feature告诉 LightGBM 哪些列是类别变量,它会自己在内部做类别展开,不需要手动 one-hot。类别特征如果只有 2~4 个取值,比如运动频次、家族史,直接传给模型就行,千万别在这种低基数类别上做 target encoding——样本量不足时极容易过拟合。做完这步,特征数量通常从 20 个左右压缩到 12~14 个,模型训练时间减少一半,AUC 基本不动。

3.3 从“血糖正常”里挖出的第三个特征:血糖波动幅度

如果数据里有多次体检记录,一定要构造“前后两次血糖变化量”。这个特征在真实场景里比单次血糖值更有预警价值。连续两年血糖从 5.2 升到 5.9 的人,比稳定在 6.0 的人风险更高,因为前者表现出明显的胰岛功能下行趋势。构造方法是按人分组、按体检日期排序,取当前记录减上一条记录的血糖差值:

df = df.sort_values(["patient_id", "check_date"]) df["glu_diff"] = df.groupby("patient_id")["glucose_fill"].diff() df["glu_std"] = df.groupby("patient_id")["glucose_fill"].transform("std")

glu_diff是前后两次的差值,glu_std是所有历史记录的波动标准差。注意第一行体检记录没有前值,diff 出来是 NaN,直接填 0 或 -999 都会污染树模型,我会保留 NaN 让 LightGBM 自己走缺失分支。这个特征的系数方向如果和直觉相反(血糖下降反而高风险),先检查是不是有吃药干预的样本混进来了——已经开始服药的早期患者血糖会下降,但这些人恰恰是高风险人群。这是特征工程里最隐蔽的泄漏之一。

4. 模型实现:用 LightGBM 把基线跑通,并让概率校准替你兜底

4.1 模型选型:为什么基线首选 LightGBM 而不是深度学习

在糖尿病风险预警这个任务上,我几乎不会第一时间考虑神经网络。原因很简单:样本量通常在几千到几万,特征以表格为主,且很多特征之间有明确但不光滑的交互关系。神经网络在这个规模上很难比树模型更稳;而 LightGBM 对缺失值、类别特征、非线性关系都是原生支持,训练快,调参维度少。之前拿一个 8000 行样本的数据做对比,效果大致是这样的:

模型AUC召回率@0.5训练耗时
逻辑回归0.780.512 秒
KNN0.650.42训练快,预测极慢
随机森林0.840.6140 秒
LightGBM0.880.688 秒

我不建议在这个阶段花太多时间精调逻辑回归的特征工程来追 LightGBM,因为逻辑回归的线性假设对“年龄×血糖”这类交互项需要手工构造,而树模型能自己找分裂点。更深一层的理由是:预警系统的解释对象是医生和患者,SHAP 在树模型上的计算稳定且开源基础好,这是神经网络短期替代不了的。

4.2 LightGBM 在健康预警场景里的六个调参项

网上能搜到一堆 LightGBM 调参清单,但健康预警数据有自己的特点:样本不平衡、特征之间有临床已知强关联、噪声主要来自标签本身。我只盯六个参数,按重要性排序:

参数区间说明
learning_rate0.01~0.05预警数据量小,学习率降下来防止过拟合
num_leaves15~31比默认 31 更保守,控制在 16 附近
min_data_in_leaf20~50防止树在极少数样本上分裂
feature_fraction0.7~0.9每次分裂抽特征比例,增加鲁棒性
bagging_fraction0.8~0.9配合bagging_freq=1使用
lambda_l21.0~5.0对高维交互特征做正则

这里特别提一下num_leaves。LightGBM 是 leaf-wise 生长,叶子数直接决定模型复杂度。在几千样本的医疗数据上,叶子数超过 64 几乎必然过拟合,表现为训练 AUC 0.97、验证 AUC 0.85。我把叶子数压到 16 到 24 之间,配合特征抽样的下限,验证集表现反而更好。学习率我习惯先固定 0.05 做粗调,确定叶子数和最小叶子样本数后再降到 0.02 做最后一轮收敛。

4.3 训练代码骨架:分层采样与早停

from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score, recall_score import lightgbm as lgb import numpy as np skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) auc_list, recall_list = [], [] for train_idx, valid_idx in skf.split(X, y): X_tr, X_va = X.iloc[train_idx], X.iloc[valid_idx] y_tr, y_va = y.iloc[train_idx], y.iloc[valid_idx] model = lgb.LGBMClassifier( n_estimators=1000, learning_rate=0.02, num_leaves=16, min_data_in_leaf=30, feature_fraction=0.8, bagging_fraction=0.85, bagging_freq=1, lambda_l2=2.0, class_weight="balanced", random_state=42 ) model.fit( X_tr, y_tr, eval_set=[(X_va, y_va)], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)] ) y_pred = model.predict_proba(X_va)[:, 1] auc_list.append(roc_auc_score(y_va, y_pred)) pred_label = (y_pred >= 0.3).astype(int) recall_list.append(recall_score(y_va, pred_label)) print(f"AUC: {np.mean(auc_list):.3f}±{np.std(auc_list):.3f}") print(f"Recall@0.3: {np.mean(recall_list):.3f}")

代码里的class_weight="balanced"是处理正例占比 15%~25% 时的最简单手段,不要一上来就上 SMOTE。early_stopping(100)表示验证集指标连续 100 轮不提升就停止,防止n_estimators=1000白跑。阈值我特意没有用默认的 0.5,而是手工放到 0.3——在预警场景里我们宁可多标一些人去复查,也不希望漏掉真正的高危人群。这段代码跑出来的五折均值如果 AUC 在 0.85 以上,就说明特征和标签是干净的,可以进入下一章说的避坑排查。

5. 常见问题与避坑:过采样、时间泄漏、样本偏移,预警系统翻车的高发区

5.1 踩坑记录一:SMOTE 过采样让验证集 AUC 虚高

现象:训练集正例只有 15%,用了 SMOTE 后 AUC 从 0.86 涨到 0.92,信心满满上线,三个月后发现实际召回率远低于验证集。

原因:SMOTE 在少数类样本之间做线性插值生成新样本。如果正例里面本身包含多种异质性亚型,插值点会落在特征空间的空白区域或类别边界上。LightGBM 在这些合成样本上学会的边界是“平滑过的假边界”,真实数据里并不存在。验证集里的正例和合成样本高度相似,所以评估好看,真实现场全是没见过的新组合。

解决:先用class_weight或代价敏感学习,不要默认上过采样。真要做过采样,也必须放到交叉验证的每一折内部,先划分再合成,绝不能在划分前对全量数据做 SMOTE——那个操作会把同一条样本的“人工邻居”同时塞进训练集和验证集,评估结果直接失去意义。这里的血泪经验是:如果模型在验证集上表现异常好,先怀疑数据,而不是先庆祝。

5.2 踩坑记录二:把“就诊日期”放进特征,模型学会了预知未来

现象:某版模型特征重要性第一名是“登记日期”,AUC 0.95。业务方说预警真准,但我总觉得不对劲——日期和糖尿病哪有因果。

原因:这是典型的时间泄漏。具体来说,血糖筛查标准在过去几年调整过,重症患者转诊路径也变了,登记日期的年份和月份隐含着“患者处于哪一个医疗政策时期”。另一个隐蔽形式是把年龄填错:用“数据录入时的年龄”而不是“体检时的年龄”,等于把随访结局的信息反向注入了特征。

解决:删除所有日期字段,只保留与生理状态直接相关的年龄和体检间隔。如果一定要用时间信息做样本划分,按“前三年训练、最后一年验证”的方式做时间序列切分,而不是随机打乱。验证集上的 AUC 会立刻从 0.95 掉到 0.87 左右——这个 0.87 才是可信数。

5.3 踩坑记录三:训练集来自内分泌科,上线到体检中心后样本偏移

现象:模型在内分泌科历史数据上召回率 0.7,放到体检中心以后跌到 0.4,而且模型倾向于把所有人标成高风险。

原因:内分泌科的患者本身已有糖代谢异常,正例比例高;体检中心以健康人为主,血糖分布整体左移。模型在高患病率环境中学到的先验概率被带到了低患病率环境,本质是训练集和上线场景的分布不一致。

解决:上线前先采集至少一版目标场景的样本做分布对比——看血糖、BMI、年龄三个核心特征的均值差。差得大就做迁移策略,最简单的做法是保留特征和模型结构,只对输出概率做场景级校准:采集新场景 300 条带标签样本,用 Platt scaling 把概率重新映射。这个方法 30 分钟就能跑完,能挽回大半精度损失。

5.4 踩坑记录四:把 SHAP 值直接解释给医生听,被一句“不 plausible”打回

现象:SHAP 图上显示某些高血压患者的预测概率主要由“血压正常”驱动,医生觉得模型逻辑错乱。

原因:LightGBM 的特征交互让 SHAP 值表现为“某个特征的贡献取决于另一特征当前取值”。比如在血糖偏高的人群里,血压的 SHAP 贡献方向可能是正向;但在血糖正常人群里,同一个血压值贡献方向可能变成负向。医生看的是边际效应,SHAP 给的是条件贡献,两者口径不同。

解决:给医生的解释图不要只看单一特征的 SHAP 均值,要看 SHAP 依赖图(Dependence Plot),把血糖放在 x 轴、SHAP 值放在 y 轴、颜色表示血压高低。并提前说明“这是模型视角的贡献度,不是临床因果关系”。这不算模型缺陷,是解释口径没对齐。实测中做好这一步,业务方对模型的接受度会提高很多。

6. 落地验证与进阶:阈值移动、概率校准和上线后要盯的三个指标

6.1 阈值移动:别让 0.5 这个默认值毁了预警系统

分类模型的默认阈值 0.5 是“两类错误等代价”的假设,在预警场景里根本不成立。漏掉一个高风险患者,代价是几个月后确诊;错标一个低风险用户,代价只是复查一次。因此阈值应该由“每标出 100 个高风险,有多少个确实出了问题”这个业务容忍度来决定。我一般做阈值扫描,把 0.2 到 0.5 之间每隔 0.05 的精确率和召回率打出来,再选。

import numpy as np from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_va, y_pred) for t in [0.2, 0.25, 0.3, 0.35, 0.4, 0.5]: idx = np.argmin(np.abs(thresholds - t)) print(f"threshold={t:.2f}, precision={precision[idx]:.3f}, recall={recall[idx]:.3f}")

如果业务方说“可以接受 30% 的误报,但要把 80% 的真正高危都捞出来”,就把阈值定在满足 recall ≥ 0.8 的最大值处,再看此时的 precision。这个数字同时决定了后续投入的随访人力——按照历史发病率换算每千人中会多出多少复查量,预算才谈得下来。

6.2 概率校准:预测值必须能直接读成风险等级

LightGBM 输出的概率不是真实概率,普遍存在过度自信的问题——预测 0.8 的样本里真正发病的可能只有 0.6。预警系统要按概率分档(低危/中危/高危),概率不校准的话,分档边界完全不可信。我用calibration_curve看可靠性,用期望校准误差(ECE)量化,并做 Isotonic Regression 回归校准:

from sklearn.calibration import calibration_curve, IsotonicRegression prob_true, prob_pred = calibration_curve( y_va, y_pred, n_bins=10, strategy="quantile") calibrator = IsotonicRegression(out_of_bounds="clip") calibrator.fit(y_pred, y_va) y_pred_cal = calibrator.predict(y_pred) ece_before = np.mean(np.abs(prob_true - prob_pred)) print(f"ECE before calibration: {ece_before:.3f}") prob_true_cal, prob_pred_cal = calibration_curve( y_va, y_pred_cal, n_bins=10, strategy="quantile") ece_after = np.mean(np.abs(prob_true_cal - prob_pred_cal)) print(f"ECE after calibration: {ece_after:.3f}")

校准做完后,可以把人群分成三档:预测概率 < 0.15 低危,0.15~0.4 中危,> 0.4 高危。每个档位对应不同的随访频次和宣教内容。这一步才是系统“可用”的标志——没有校准的概率值没法直接写进报告单。

6.3 上线后盯住三个指标,而不是只看 AUC

第一是“预警-确诊时间提前量”,即模型发出高危预警到患者最终确诊之间隔了多少个月。预警系统存在的意义就是把这个数字拉大,至少半年以上才有干预空间。第二是“高危人群覆盖率”,即在最终确诊的患者里,系统提前预警了多少——如果确诊患者里只有一半被提前捞出来,说明特征或阈值还需要调。第三是“复查转化率”,拿到预警的人里有多少真的去做了糖耐量或复查糖化血红蛋白,这个指标直接决定系统的实际健康收益。

我在这个项目里踩过的最大一个坑是:训练时一切完美,AUC 0.89,上线后前两周 precision 高得吓人,第三周就开始往下掉。后来排查发现是随访数据回流有延迟,前两周“未确诊”的标签其实是还没回填,而不是真的健康。这个问题的排查方法很简单——把预测概率和标签按时间画在一张图上,看标签分布有没有突变。从那以后我养成一个习惯:每一次重新训练前,先检查标签回填的截止日期,确认最近三个月的标签都是完整的才动手训练。希望这个习惯也能帮到你少走这段弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进