简介:基于机器学习的糖尿病预测系统源代码,出自大三学期课程设计,经导师指导评审获高分通过。项目面向计算机科学、人工智能、大数据等相关专业的在校学生与教师,可直接作为课程设计、期末大作业或毕设起点,也适合机器学习初学者实战演练。压缩包共四十六个文件,包含Java与Scala源代码、JSP页面、XML和Properties配置、CSS样式及说明文档等,整体仅五十五KB,模块划分清晰,便于快速定位数据加载、模型训练与预测输出的关键实现。目前已有三百六十人浏览学习,代码经稳定运行验证,附带的说明文本可帮助理解项目结构及糖尿病预测流程。若在此基础上继续拓展,可更换训练数据或调整模型参数以实现功能扩展,适合作为项目演示与二次开发的蓝本。
1. 糖尿病预测系统的课程设计:为什么大家都在做这个题目
想用机器学习拿一个课程设计高分,糖尿病预测几乎是标准答案:数据公开、特征明确、二分类任务容易上手,但又能把数据预处理、模型对比、参数调优、结果可视化全部串进去。这套基于机器学习的糖尿病预测系统,通常以 Pima Indians Diabetes Dataset 为输入,用 Python 和 scikit-learn 搭建完整训练管道,最终得到一个输入“血糖、BMI、年龄”就能输出风险概率的模型。我接手过不少类似的源代码项目,发现真正拉开分数差距的,不是代码写得花哨,而是数据切分是否规范、评估指标选没选对、答辩时能不能说清模型为什么预测错。这篇笔记就把我从“能跑通”到“敢答辩”走过的完整路径整理出来,新手照步骤能复现,熟手可以重点看第 5 章的坑和最后一章的可解释性。
2. 数据与评估先行:Pima 数据集和“什么才算预测对”
2.1 Pima 数据集里的 0 值不是普通缺失,而是“未测得”
课程设计里最常见的版本是 Pima Indians Diabetes Dataset,一共 768 条样本,8 个数值特征:怀孕次数、血糖、血压、皮肤厚度、胰岛素、BMI、糖尿病家族函数、年龄,标签是是否患病。很多同学拿到 CSV 直接丢进模型,结果逻辑回归输出一个看似合理的准确率,答辩时老师一问“Glucose 为 0 怎么解释”,当场答不上来。这里的 0 值并不是真实测量为 0,而是当年数据采集时没测或无法测得,是典型的机器学习中的数据处理问题:缺失值被零值掩盖了。
处理时要区分列。血压、血糖、BMI、皮肤厚度、胰岛素这五列,在医学上不可能为 0,出现 0 就要当作缺失值;而怀孕次数为 0 属正常,年龄、家族函数也不会出现无意义零值。所以第一步不是打标准化,而是先把这些列里的 0 转成 NaN,再由填充器接管。这件事必须在切分训练集和测试集之前做,因为它只依赖领域常识,不读取标签,不会造成数据泄漏。
2.2 为什么准确率不适合当唯一指标:类别不平衡会骗人
Pima 数据里患病样本约占 35%,不患病占 65%。如果只盯着准确率,一个“永远预测不患病”的模型也能拿到 65%,看起来没差太多,但漏掉了真正需要干预的糖尿病人。课程设计的源码包里如果只打印 accuracy,通常被扣分,因为老师会用混淆矩阵追问敏感度。所以建模之前就要定好评估指标,我一般同时看 roc_auc、recall 和 F1,而不是只看准确率。roc_auc 对类别比例不敏感,适合用来做交叉验证和网格搜索;recall 则直接回答“有病的人里我抓住了多少”。
这个选型理由要先讲清楚,后边的所有代码才站得住。如果你的课程设计题目是“糖尿病预测系统”,建议在文档中明确写:本系统以召回率和 AUC 为主要优化目标,因为在医疗场景里漏诊的成本远高于误诊。这句话一写,评分的档次就上去了。
2.3 用 StratifiedKFold 做分层交叉验证:最小可行的数据划分代码
交叉验证和固定随机种子是复现结果的前提。下面这段代码完成数据加载、零值转缺失、分层划分,是后续所有实验的地基。
import pandas as pd import numpy as np from sklearn.model_selection import StratifiedKFold, train_test_split df = pd.read_csv("diabetes.csv") # 医学上不可能为0的列,先转成缺失值再统一处理 zero_not_valid = ["Glucose", "BloodPressure", "SkinThickness", "Insulin", "BMI"] for col in zero_not_valid: df[col] = df[col].replace(0, np.nan) X = df.drop("Outcome", axis=1) y = df["Outcome"] # 分层切分:保证训练集和测试集的正例比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42) # 5折分层交叉验证,供后面所有模型统一使用 cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) print("训练集患病比例: {:.3f}".format(y_train.mean())) print("测试集患病比例: {:.3f}".format(y_test.mean()))这段代码里有三个参数值得在报告里解释。test_size=0.2表示留出 20% 样本做最终测试,剩下 80% 参与交叉验证,避免测试集被反复“看”到。stratify=y按标签比例分层抽样,否则随机切分可能让训练集和测试集正例比例差异很大,评估结果不稳定。shuffle=True配合random_state=42让每一次运行得到同样划分,这样和同学对结果时有据可查。交叉验证折数 5 在这个数据量下是稳妥选择,768 条样本用 10 折反而会让每折只有 70 条左右,方差变大。
3. 建模管道搭建:缩放、缺失值和不平衡处理
3.1 特征缩放为什么是逻辑回归和 SVM 的必选项
很多课程设计源码里,逻辑回归结果不如随机森林,就随手把逻辑回归删了。实际上问题往往出在没做标准化。血糖的数值范围是 50 到 200,BMI 是 18 到 60,而怀孕次数只有 0 到 17,逻辑回归用梯度下降优化时,量纲大的特征天然获得更大权重,模型训练就会偏向血糖和胰岛素,不是因为它真的更重要,只是梯度更新被量纲放大了。随机森林这类树模型不受影响,因为树分裂只看阈值排序,所以如果你没做预处理,拿逻辑回归和随机森林对比是欺负逻辑回归。
解决方式也很直接:用 StandardScaler 把每个特征变成均值 0、方差 1,训练集上 fit,测试集和验证集上只 transform。更稳的做法是把它和缺失值填补一起塞进 Pipeline,这样每一折交叉验证都会在训练折内重新计算均值方差,测试折自然不会被污染。这也是机器学习模型评估里最容易翻车的一环,后面第 5 章会单独说。
3.2 用 Pipeline 把填补、缩放和模型串起来:一份可直接复制的基础管道
以下代码把缺失值填补、标准化、逻辑回归整合到一个 Pipeline 里,并做 5 折交叉验证。零值转 NaN 在切分前全局做好,随后 Pipeline 内部的 SimpleImputer 会在每一折训练集上学习中位数,再应用到验证折。
from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score # 全部8列都是数值特征,统一走同一套预处理 num_cols = X_train.columns.tolist() preprocessor = ColumnTransformer([ ("num", Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ]), num_cols) ]) lr_pipe = Pipeline([ ("prep", preprocessor), ("clf", LogisticRegression(class_weight="balanced", max_iter=1000)) ]) # scoring用roc_auc,不用accuracy lr_scores = cross_val_score(lr_pipe, X_train, y_train, cv=cv, scoring="roc_auc") print("LR AUC: {:.3f} ± {:.3f}".format(lr_scores.mean(), lr_scores.std()))这里几个参数值得细说。SimpleImputer(strategy="median")用中位数填补缺失值,比平均值更抗离群值,尤其胰岛素列存在极端分布时差距明显。class_weight="balanced"让模型按类别比例自动调整权重,患病样本少就给它更高误分类代价,这是最简单的不平衡处理手段,不需要额外数据。max_iter=1000是因为标准化后逻辑回归通常几十次迭代就收敛,但数据缺失和离群值存在时偶尔会到迭代上限,设大一点避免警告,代价只是训练时间增加不到一秒。输出 AUC 能看到逻辑回归在这个问题上完全能到 0.83 上下,并不比随机森林差太多。
3.3 类别不平衡的进阶打法:SMOTE什么时候才值得用
class_weight="balanced"是调整损失函数的权重,SMOTE 则是直接生成少数类的新样本,两者思路不同。Pima 数据患病比例约 35%,其实不算严重不平衡,我一般先用 class_weight,不动 SMOTE。但如果你的课程设计换成了别的数据集,比如患病率只有 5%,那 SMOTE 值得试。SMOTE 的原理是在少数类样本的 k 近邻之间插值生成合成样本,使用时必须放在交叉验证内部,否则合成样本会泄漏到验证集里,造成虚高的分数。
from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE smote_pipe = ImbPipeline([ ("prep", preprocessor), ("smote", SMOTE(random_state=42, k_neighbors=5)), ("clf", LogisticRegression(class_weight="balanced", max_iter=1000)) ]) smote_scores = cross_val_score(smote_pipe, X_train, y_train, cv=cv, scoring="roc_auc") print("LR+SMOTE AUC: {:.3f} ± {:.3f}".format(smote_scores.mean(), smote_scores.std()))k_neighbors=5是默认值,样本量小的时候可以降到 3,否则近邻距离太大,合成样本可能插到类别交叠区域。这里注意imblearn.pipeline和sklearn.pipeline不能混用,如果 Pipeline 里出现 SMOTE,就要用前者,否则交叉验证不会正确处理过采样步骤。我通常的做法是两种管道都跑一遍,把 AUC 对比放进报告表格,说明为什么最终选择某一个,这比直接写“SMOTE 提升准确率”更有说服力。
4. 多模型对比与参数调优:从基线跑到高分
4.1 先立基线:逻辑回归、决策树、随机森林的默认参数对比
不要一上来就调参,先把三个模型用默认参数跑一遍,建立基线。这样你能知道调参的收益到底有多大,答辩时也能说“我在默认模型基础上通过调参让 AUC 提升了多少”。下面这段代码统一复用上一章的 preprocessor,保证特征处理完全一致,只有模型不同。
from sklearn.tree import DecisionTreeClassifier from sklearn.ensemble import RandomForestClassifier models = { "lr": LogisticRegression(class_weight="balanced", max_iter=1000), "dt": DecisionTreeClassifier(class_weight="balanced", random_state=42), "rf": RandomForestClassifier(class_weight="balanced", n_jobs=-1, random_state=42), } for name, clf in models.items(): pipe = Pipeline([("prep", preprocessor), ("clf", clf)]) auc_scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring="roc_auc") print(f"{name}: AUC={auc_scores.mean():.3f} ± {auc_scores.std():.3f}")n_jobs=-1让随机森林并行使用所有 CPU 核心,768 条训练数据其实用不上,但保留这个参数能让模型扩展到更大数据时不卡住。random_state=42固定随机森林的抽样过程,否则每次跑出来的 AUC 都不一样,报告里数字对不上会很尴尬。跑完之后你大概率看到随机森林 AUC 略高于逻辑回归,决策树垫底。决策树垫底是正常的,因为它不加约束时深度很深,会把训练集的噪声也学进去,这个现象正好是下一节调参的切入点。
4.2 用 GridSearchCV 调参并锁定最优参数
随机森林可调参数多,但课程设计不需要追求极致,重点调三个:n_estimators、max_depth、min_samples_leaf。n_estimators决定树的数量,太多提升边际收益下降;max_depth限制树的深度,防止过拟合;min_samples_leaf要求叶节点至少包含多少样本,同样是正则化手段。网格搜索的 scoring 继续用roc_auc,不换指标。
from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier rf_pipe = Pipeline([ ("prep", preprocessor), ("clf", RandomForestClassifier(class_weight="balanced", random_state=42, n_jobs=-1)) ]) param_grid = { "clf__n_estimators": [100, 200, 300], "clf__max_depth": [3, 5, None], "clf__min_samples_leaf": [1, 3, 5], } grid = GridSearchCV(rf_pipe, param_grid, cv=cv, scoring="roc_auc", n_jobs=-1) grid.fit(X_train, y_train) print("最优参数:", grid.best_params_) print("最优AUC:", grid.best_score_)参数名里的clf__前缀是 Pipeline 的关键格式,表示这个参数属于名为clf的步骤。如果漏写前缀,GridSearchCV 会报错说参数不存在。param_grid一共 3×3×3=27 种组合,5 折交叉验证就是 135 次训练,用n_jobs=-1并行后通常几十秒内跑完;如果换更大网格,建议用RandomizedSearchCV先随机搜索一轮再缩小范围。需要提醒的是,调参结果只代表当前数据划分下的最优,最终模型要用grid.best_estimator_在完整训练集上重新拟合,然后再在 X_test 上做一次最终评估,不能在训练集上反复试探后还把训练分当最终成绩。
4.3 用 ROC 曲线和特征重要性解释“高分来自哪里”
答辩时老师很少只看最终 AUC,他更可能问“为什么随机森林比逻辑回归好”或“哪些特征影响最大”。ROC 曲线一画,模型在不同阈值下的敏感度和特异度关系就直观了,而树模型的特征重要性可以给出可解释的证据。以下代码在测试集上评估最优随机森林,并画出 ROC 曲线。
import matplotlib.pyplot as plt from sklearn.metrics import roc_curve, auc best_model = grid.best_estimator_ best_model.fit(X_train, y_train) y_proba = best_model.predict_proba(X_test)[:, 1] fpr, tpr, _ = roc_curve(y_test, y_proba) test_auc = auc(fpr, tpr) print("测试集AUC: {:.3f}".format(test_auc)) plt.figure(figsize=(6, 5)) plt.plot(fpr, tpr, label=f"RandomForest (AUC={test_auc:.3f})") plt.plot([0, 1], [0, 1], "k--", label="Random Guess") plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.legend() plt.show()predict_proba返回两个列,第一列是 0 类概率,第二列是 1 类概率,所以用[:, 1]取患病概率。ROC 曲线越靠近左上角越好,对角线代表随机猜测。特征重要性可以用best_model.named_steps["clf"].feature_importances_取出来,和列名拼成表格放进报告。Pima 数据上通常血糖、BMI、年龄排在前三,这符合医学常识,写文档时一定要加上这句:模型识别出的高风险因素与临床研究基本一致,说明预测系统具备可解释性基础。
5. 常见问题与排查:糖尿病预测系统翻车的五个现场
5.1 现象:测试集 AUC 高到离谱,答辩一问就露馅
有些源码在 train_test_split 之前对整个 X 做 StandardScaler,或者先用 SMOTE 生成样本再切分训练测试集。这样测试集的信息在训练阶段就已经被模型见过,AUC 能到 0.95 甚至更高,但换一批新数据就崩。原因就是数据泄漏:预处理和过采样的参数吸收了测试集的分布。解决方法是把 StandardScaler、SimpleImputer、SMOTE 全部放进 Pipeline,并让交叉验证在每个训练折内重新拟合,测试折只经历过 transform。最简单的检查办法是看交叉验证得分和测试集得分是否接近,如果交叉验证明显低于测试集,八成是划分顺序错了。
5.2 现象:把 0 值当有效数值,逻辑回归权重变得不可解释
Pima 数据里 Glucose 取值为 0 的样本,模型会把它当成“极低血糖”来学习,逻辑回归会对血糖给出一个奇怪的正权重,这和临床逻辑完全矛盾。原因前面说过:0 值实际上是缺失值。解决方法是把 Glucose、BloodPressure、SkinThickness、Insulin、BMI 这五列先替换为 NaN,再交给 SimpleImputer 用中位数填补。这一步要在切分之前做,因为它不依赖标签;但填补中位数的数值必须在训练折内学,否则就是泄漏。文档里最好留一行注释写清楚哪些列做了 0 值转缺失,这能体现你对机器学习中的数据预处理有真实理解。
5.3 现象:决策树训练准确率 99%,验证集只有 80%
决策树默认不限制深度,会把每一个训练样本都分到独立叶节点,训练准确率自然接近满分。原因就是模型容量过大、正则化缺失,过拟合把噪声也学进去了。解决方法是给max_depth设一个较小的值,比如 3 或 5,并给min_samples_leaf设 3 或 5,让叶节点不再为单个样本服务。调参后决策树的 AUC 通常会明显提升,和随机森林的差距也会缩小。这个现象在报告里写“决策树过拟合,通过限制深度改善泛化”后,评分老师几乎不会再刁难。
5.4 现象:每次运行脚本结果都不一样,同学之间对不上数字
随机森林、SMOTE、StratifiedKFold 都依赖随机数生成器。如果代码里没有设置random_state,每次运行结果都会有波动,轻则误差在小数点后两位,重则最优参数组合都不同。原因很简单:随机种子没有固定。解决方法是统一给所有模型、过采样器、交叉验证拆分器设置random_state=42,并在文档开头的全局配置里说明。需要留意的是,StratifiedKFold设置random_state的同时必须打开shuffle=True,否则随机种子不会生效。固定种子不等于作弊,它是可复现实验的前提,课程设计源码里注明这一点反而是加分项。
5.5 现象:只报准确率,不报召回率,模型对糖尿病人漏报严重
65% 的不患病样本会让模型偏向预测“不患病”,准确率依然不低,但真正患病的人被漏掉。原因就是类别不平衡没有处理,且评估指标选错。解决方法有两个层面:训练层面加class_weight="balanced"或在 Pipeline 里引入 SMOTE;评估层面把评分指标从 accuracy 换成 roc_auc,并在报告里打印混淆矩阵,计算敏感度(召回率)和特异度。如果老师问“阈值为什么是 0.5”,你可以进一步说明,可以在测试集上滑动阈值找召回率更高的点,比如把阈值降到 0.4,敏感度和特异度会有一个新的平衡。这类讨论在课程设计答辩中属于加分回答。
6. 收尾:模型解释和“可答辩”的系统演示
调参完成后,一个容易忽略的步骤是保存完整 Pipeline,而不是只保存训练好的模型。因为部署预测时新数据进来,同样要经过缺失值填补和标准化,只保存分类器对象会导致预测结果完全错误。下面这段代码演示如何保存 Pipeline 并写一个简单的风险预测函数。
import joblib # 保存完整pipeline,包含预处理和模型 joblib.dump(grid.best_estimator_, "diabetes_pipeline.pkl") def risk_score(row): model = joblib.load("diabetes_pipeline.pkl") sample = pd.DataFrame([row]) # 与训练时一致,把医学无效0值转成NaN for col in ["Glucose", "BloodPressure", "SkinThickness", "Insulin", "BMI"]: sample[col] = sample[col].replace(0, np.nan) prob = model.predict_proba(sample)[0, 1] return round(prob, 3) risk_score({"Pregnancies": 2, "Glucose": 148, "BloodPressure": 72, "SkinThickness": 35, "Insulin": 0, "BMI": 33.6, "DiabetesPedigreeFunction": 0.627, "Age": 50})这里joblib.dump保存的是完整 Pipeline,加载后predict_proba会自动完成填补、标准化和模型预测,新数据只需要保证列名一致。风险概率是连续值,如果你想输出“高风险/低风险”,可以自己定一个阈值,比如 0.5 以上输出高风险。我个人的习惯是在风险概率旁边再打印一列“样本中主要危险因素”,比如 Glucose 和 BMI 的原始值超过医学参考区间就单独标注,这样演示给同学看时不是黑匣子,而是一个能解释结论的预测系统。如果课程设计要交界面,常见做法是拿这个函数套一层 Flask 或 Streamlit,把返回值渲染成一个简单的 Web 页面,但底层核心逻辑还是这一套。
这套源码题目的套路,我在不同数据上重复过很多次,最大的教训是:一个项目拿高分,靠的不是准确率从 0.83 调到 0.85,而是能不能把数据泄漏、类别不平衡、阈值选择这些坑讲明白。诚实报告模型的边界,比假装它完美更让人信服。希望帮到你。
本文还有配套的精品资源,点击获取