简介:面向机器学习课程大作业场景,这份个贷违约预测项目源码提供了完整的建模与评测实现,特别适合高校学生、竞赛入门者参考。项目围绕经典二分类任务展开,以ROC曲线下面积(AUC)作为核心评价指标,并引入描述性聚类中的软聚类思路进行数据探索。核心代码包含多层感知机、决策树(概率树)以及自定义的“距离-概率转换”三种模型,方便对比不同方法的预测效果。压缩包共59个文件,约占142.31MB,其中csv数据文件16个、py/Go源码16个,另有项目报告(docx/pdf)、演示幻灯片(pptx)、说明文档(md/txt)和模型权重文件(pth)等,目录结构清晰。目前已有2870人学习下载,资源附带的成绩截图与实验记录能直观展示模型表现,是一份可动手复现、二次扩展的课程项目资料。
1. 从“个贷违约预测”到能跑通全流程的机器学习源码
见过太多个贷违约预测作业了:数据量几千条,违约率不到 5%,有人用逻辑回归拿 0.62 的 AUC,有人用同样数据把 LightGBM 调到 0.82,差距不在模型深度,而在三个常识性动作——时间字段拆没拆、标准化在切分前还是切分后、阈值是不是默认 0.5。机器学习课程大作业个贷违约预测项目源码.zip 这个标题,基本概括了多数机器学习期末项目最标准的交付物形态:一个二分类器、一套可复现代码、外加一个能对未知数据做预测的入口。这篇按完整项目源码该有的顺序梳理一遍,赶机器学习课程大作业的能照着跑,想看看二分类项目怎么组织的也能拿来当参照。
2. 数据清洗与特征工程:课程作业里分差最大的环节
个贷违约预测的数据集通常来自放贷机构的脱敏记录,常见字段包括年龄、收入、负债率、征信查询次数、贷款金额、利率、历史逾期次数、放款日期等。拿到手直接丢进模型的作业,全部都会在第一步翻车。原因很简单:这份数据的噪声远大于普通公开数据集,缺失率高的列动辄占到一半以上,类别字段和数值字段混在一起,时间字段如果不拆解就变成一列字符串,模型只能把它当类别编码处理。所有分差几乎都从这里开始。
2.1 拿到数据先做的三个动作:字段盘点、缺失率排序、时间字段拆解
我一般先把数据读进来,逐列统计缺失率,并确认每个字段在放款决策时点的真实含义,再决定保留、填充还是删除。这里的关键不是“缺失率超过某个阈值就删”,而是先搞清楚缺失模式。如果是纯随机缺失,填充不会带来明显偏差;如果缺失本身和违约率相关,高缺失列直接删除反而比填充更安全。
import pandas as pd raw = pd.read_csv("loan_data.csv", parse_dates=["issue_date"]) # 逐列缺失率,排序后决定处理策略 missing_rate = raw.isnull().mean().sort_values(ascending=False) print(missing_rate[missing_rate > 0.3]) num_cols = raw.select_dtypes(include=["float64", "int64"]).columns cat_cols = raw.select_dtypes(include=["object"]).columns # 数值列用中位数填充,类别列补成 "unknown" raw[num_cols] = raw[num_cols].fillna(raw[num_cols].median()) raw[cat_cols] = raw[cat_cols].fillna("unknown") # 从放款日期拆出业务可用特征 raw["issue_year"] = raw["issue_date"].dt.year raw["issue_month"] = raw["issue_date"].dt.month raw.drop(columns=["issue_date"], inplace=True)parse_dates让 pandas 在读入阶段直接完成时间类型转换,后面对.dt.year的调用才不会报错。中位数填充比均值稳,尤其在收入这类右偏长尾变量上,均值会被少数高收入样本拉高,模型拿到的填充值会系统性偏大。类别列填充"unknown"是刻意为之,缺失本身应该作为独立类别保留,而不是用众数去盖掉。
| 缺失率 | 特征类型 | 常见处理 | 适用理由 |
|---|---|---|---|
| <5% | 数值型 | 中位数填充 | 对异常值不敏感,保留数据整体分布 |
| 5% ~ 30% | 数值型 | 按目标分组取分位数填充 | 保留该特征与违约率的条件关联 |
| >30% | 任意类型 | 删除列或衍生缺失指示位 | 缺失模式的信息价值高于列本身取值 |
| 任意比例 | 类别型 | 填充为"unknown" | 缺失本身是一种取值,树模型天然支持 |
提示:字段盘点阶段务必人工核对一遍特征说明。如果数据里出现“放款后至今逾期次数”这类和预测目标同源的字段,直接删除,否则后续所有评估指标都会虚高。
2.2 标准化必须发生在数据切分之后,顺序反了就是数据泄露
机器学习模型训练里最隐蔽也最常见的错误,是对全量数据做了StandardScaler().fit_transform()再做训练测试切分。表面看代码没报错、结果还挺高,实际测试集已经见过训练集的均值和方差,交叉验证的每一折都被污染,评估分数不具备任何泛化意义。个贷这种样本量不大、特征量纲差异大的场景,标准化又是必然动作,顺序问题会被放大。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = raw.drop(columns=["default"]) y = raw["default"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) scaler = StandardScaler() X_train_s = scaler.fit_transform(X_train) # 只在训练集上拟合 X_test_s = scaler.transform(X_test) # 测试集只用 transformfit_transform和transform拆开写不是形式主义。fit会计算训练集的均值和标准差,transform只做数值平移缩放。测试集一旦参与fit,它的分布信息就反向进入了训练过程,后期报告里的 AUC 就不可信了。逻辑回归和 SVM 这类基于梯度下降的模型必须做这一步,树模型不做也不影响训练,但如果后续要用同一个数据源跑多套模型,统一走这个流程更省事。
2.3 类别特征编码:树模型直接声明,线性模型才真正需要 one-hot
课程作业里最常见的编码误区,是拿到所有字符串列就无脑pd.get_dummies。对 LightGBM 和 XGBoost 这类树模型,类别特征完全可以保留原始字符串直接传入,模型内部会按类别分裂。提前 one-hot 不仅让特征矩阵膨胀几十列,还让树模型学不到高基数类别内部的层级关系,纯属帮倒忙。逻辑回归没有这个能力,必须显式编码。
import lightgbm as lgb categorical_features = ["purpose", "grade", "employment_status"] # 树模型:保留字符串,声明为类别特征 lgb_train = lgb.Dataset(X_train, label=y_train, categorical_feature=categorical_features)线性模型侧的做法是按特征基数分档处理:类别数小于 10 的用OneHotEncoder,类别几十个以上的用 target encoding 或直接并入“其他”类。个贷数据里像purpose这种十来个取值的列用 one-hot 问题不大,遇到 ZIP 码这类高基数列就必须先做频次合并。这条规则在机器学习应用流程里属于最容易被忽略但又最能压分的点。
3. 模型训练与参数取舍:逻辑回归兜底,LightGBM 拉上限
个贷违约预测本质是一个二分类问题,课程作业的评分通常看两点:报告里能不能把机器学习算法讲清楚,最后的预测指标有没有说服力。这两个诉求正好对应两套模型。逻辑回归负责可解释,LightGBM 负责分数上限。只交一个模型的同学,大概率会在答辩环节被追问“为什么选这个模型以及另一个会怎样”。
3.1 逻辑回归为什么是课程作业里的兜底模型
逻辑回归的权重向量天然带有“特征方向”语义:系数为正表示该特征值越大违约概率越高,负号则相反。这在风控报告里可以直接翻译成业务结论,树模型的特征重要性做不到这一点。而且逻辑回归对特征共线性敏感,这反而成了倒逼学生做特征筛选的理由。梯度下降收敛需要标准化,正则项对量纲不一致的特征惩罚也不同,所以前面标准化那一步对逻辑回归不是可选项。
from sklearn.linear_model import LogisticRegression lr = LogisticRegression( C=1.0, class_weight="balanced", max_iter=1000, random_state=42, ) lr.fit(X_train_s, y_train)C=1.0是默认正则强度,样本量在几千条级别时不用太激进;class_weight="balanced"让少数类样本的损失按频率反比放大,这一步直接对标违约样本占比不足 10% 的类别不均衡问题;max_iter=1000是必须给的,liblinear外的默认求解器在特征未标准化或迭代不足时容易报收敛警告。逻辑回归的 AUC 通常在 0.65 到 0.75 之间,这个分数作为报告里的“基线模型”正当合理,没人会质疑。
3.2 LightGBM 训练的最小完整代码与参数边界
LightGBM 在当前机器学习期末项目里基本是默认主力。训练快、能直接吃类别特征和缺失值、AUC 上限比逻辑回归高 5 到 15 个百分点。但要小心一件事:它太好用了,学生很容易把num_boost_round拉到几千轮不设早停,然后在测试集上拿到一个过拟合的漂亮分数。
import numpy as np import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": 7, "min_child_samples": 50, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "verbose": -1, } skf = StratifiedKFold(n_splits=10, shuffle=True, random_state=42) oof = np.zeros(len(X_train)) for fold, (tr_idx, va_idx) in enumerate(skf.split(X_train, y_train)): dtr = lgb.Dataset(X_train.iloc[tr_idx], label=y_train.iloc[tr_idx], categorical_feature=categorical_features) dva = lgb.Dataset(X_train.iloc[va_idx], label=y_train.iloc[va_idx], categorical_feature=categorical_features) model = lgb.train(params, dtr, num_boost_round=2000, valid_sets=[dva], callbacks=[lgb.early_stopping(100, verbose=False)]) oof[va_idx] = model.predict(X_train.iloc[va_idx], num_iteration=model.best_iteration) print(f"10-fold OOF AUC: {roc_auc_score(y_train, oof):.4f}")num_leaves=31对应树深 4 到 5 的复杂度,在几千条样本上已经够用,继续加大只提升训练集分数,验证集 AUC 反而波动变大;min_child_samples=50是风控小样本场景的关键参数,强制叶子节点最少含 50 个样本,防止树在个位数的异常样本上反复分裂;learning_rate=0.05配合num_boost_round=2000和早停,让模型自己决定在哪一轮停,而不是拍脑袋定迭代次数。10 折分层交叉验证在这里不只是评估手段,oof概率还会在下一章用于阈值选择。
3.3 随机种子与可复现性的边界
固定random_state=42是课程作业的标配,但要清楚它的能力边界:它只保证同一环境、同一依赖版本下的结果可复现。换一台机器、升级一次 LightGBM 或 sklearn 版本,相同种子得到的结果不会完全一致。写报告时把numpy、pandas、scikit-learn、lightgbm的版本号留在 README 里,比在代码里反复强调种子更重要。交叉验证本身已经包含随机性,所以 10 折的shuffle种子和模型内部种子要分开设置,否则折与折之间的随机性会被不必要地绑定。
4. 样本不均衡与决策阈值:个贷场景的评估套路
个贷违约预测最典型的场景特征是违约率极低,常见数据里正样本占 3% 到 8%。这种分布下,无脑全部预测为“不违约”也能拿到超过 90% 的 accuracy,看起来分数漂亮,但这份机器学习项目源码就失去了意义。先认清评估目标,再谈调参,是这一章要解决的核心问题。
4.1 违约率 5% 时,accuracy 不能作为机器学习分类器的评判指标
accuracy 在类别均衡时直观好用,在违约率只有 5% 的数据上就是垃圾指标。假设模型把所有人预测为不违约,accuracy 是 95%,这比任何认真训练的模型都高,但它一个违约用户都抓不住。贷款风控里漏掉一个违约用户造成的损失,远大于误伤一个正常用户的利息收入,所以业务语义天然偏向“少漏”。课程作业报告如果只写 accuracy,答辩时基本必被追问。
正确做法是同时给出 AUC、KS 和 PR 曲线。AUC 衡量排序能力,Kolmogorov-Smirnov 统计量衡量违约与非违约样本的累积分布最大差异,PR 曲线则在正样本稀缺时比 ROC 更敏感,因为它的分母用的是预测为正的样本数,不会被大量真负样本稀释。我在报告里一般写三个数:AUC、KS、以及阈值调到最优后的 recall,这样既有整体排序指标,又有业务可操作的决策点。
4.2 类别不均衡处理手段对比:加权重、过采样还是移动阈值
| 处理手段 | 原理 | 课程作业里的实际坑 |
|---|---|---|
class_weight="balanced" | 按类别频率反比放大少数类损失 | 概率分布发生偏移,阈值不能继续用 0.5 |
| SMOTE 过采样 | 在特征空间合成少数类样本 | 对树模型收益有限,答辩时难解释“合成样本的真实性” |
| 阈值移动 | 保持模型不变,只调整决策分界点 | 最稳妥,但阈值必须在验证集上重新选取 |
| 随机欠采样 | 丢弃多数类样本 | 样本量小于 2 万时慎用,方差显著变大 |
我一般只在实际操作中用两类手段:逻辑回归加class_weight="balanced",LightGBM 不做过采样而是直接把scale_pos_weight或者不设权重、靠阈值移动来修正。SMOTE 在树模型上几乎不会带来提升,因为它合成的样本并不是真实分布中的点,树模型又天然擅长处理分布边缘,合成样本只会让边界更混乱。放在报告里作为对比实验是可以的,直接作为主方案则容易在答辩时被问住。
4.3 用交叉验证的 OOF 概率自动寻找最佳决策阈值
阈值调整的对象不是测试集,而是训练过程中产出的 OOF 概率。把每一折的验证集概率收集起来,等于拿到了一个“模型从未直接见过”的预测分布,在这个分布上选阈值,才接近真实泛化表现。
from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_train, oof) f1_scores = 2 * precision * recall / (precision + recall) best_idx = np.argmax(f1_scores[:-1]) # 最后一个阈值位置无意义 best_threshold = thresholds[best_idx] print(f"best threshold: {best_threshold:.4f}, F1: {f1_scores[best_idx]:.4f}") print(f"违约率: {y_train.mean():.2%}")当违约率只有 3% 时,F1 最大化对应的阈值通常在 0.2 到 0.4 之间,很少会待在 0.5。f1_scores[:-1]去掉最后一个无意义位置是因为precision_recall_curve会在末尾补一个阈值 1,对应召回率为 0。选好阈值之后,把它和模型一起保存,预测阶段直接使用,这比在测试集上反复试阈值再挑好看的分数要严谨得多。
5. 让 zip 压缩包里的 predict.py 交付后能直接跑起来
课程作业源码包的交付标准只有一个:换一台没有训练环境的机器,按 README 装完依赖,跑predict.py能出结果。很多项目源码.zip 解压后,代码只能在作者自己电脑上运行,问题几乎都出在预处理逻辑与模型文件分离上。标准化器的均值方差、中位数填充值、特征列顺序,这些训练阶段产生的“状态”全部需要随模型一起持久化。
5.1 预处理状态和模型一起存盘,而不是让测试脚本重新计算
第一版写预测脚本的人通常会犯一个错误:在predict.py里直接StandardScaler().fit_transform(单条新数据)。单条数据拟合出来的均值和标准差就是它自己,缩放后全部变成 0,预测结果完全失真。正确的做法是在训练脚本结束时,把所有预处理产物统一保存。
import joblib model_dir = "./model/" joblib.dump(scaler, model_dir + "scaler.joblib") joblib.dump(model, model_dir + "lgb_model.joblib") joblib.dump(X_train.columns.tolist(), model_dir + "feature_list.joblib") joblib.dump(best_threshold, model_dir + "threshold.joblib")joblib对 numpy 数组的序列化效率高于 pickle,是 sklearn 官方推荐做法。feature_list一定不能省略,预测时新数据列顺序稍有变化,reindex会自动对齐,没有这个列表就只能靠肉眼核对列名。跨机器加载失败时先检查 sklearn 和 joblib 版本,次贷危机式的兼容问题在机器学习项目里最常见原因就是版本不一致。
5.2 predict.py 的最小完整结构
预测入口的逻辑必须和训练流程严格保持一致:先填充缺失值,再做列对齐,再标准化,最后进模型。
import joblib import pandas as pd model_dir = "./model/" model = joblib.load(model_dir + "lgb_model.joblib") scaler = joblib.load(model_dir + "scaler.joblib") feature_list = joblib.load(model_dir + "feature_list.joblib") threshold = joblib.load(model_dir + "threshold.joblib") def predict(raw_df: pd.DataFrame) -> pd.DataFrame: df = raw_df.copy() df = df.fillna({"income": df["income"].median()}) df = df.reindex(columns=feature_list, fill_value=0) prob = model.predict_proba(scaler.transform(df))[:, 1] return pd.DataFrame({"pred_prob": prob, "pred_label": (prob >= threshold).astype(int)})reindex做了三件事:去掉训练集不存在的列、补齐训练集有但输入数据缺失的列、保证列顺序与训练时一致。fill_value=0只适合极少数无关列缺失的情况,真正重要的特征如果缺失,这里应该直接抛异常而不是静默补 0。加了assert df.isnull().sum().sum() == 0在标准化之前,能在模型前拦截到未处理干净的输入。
5.3 用特征重要性反向排查数据泄露
zip 里交付的模型如果分数高得离谱,比如 AUC 超过 0.95,先不要高兴,大概率是数据泄露。排查手段很便宜:打印 LightGBM 的 gain 特征重要性,看排名靠前的特征是否在放款决策时点真实可见。
import pandas as pd importance = pd.Series(model.feature_importance("gain"), index=feature_list) print(importance.sort_values(ascending=False).head(20))gain表示该特征在所有分裂点带来的累计增益,比split更能反映信息贡献量。课程作业数据泄露的高频特征是用户 ID、放款批次号、以及任何包含“后”“至今”字样的时间衍生字段。把重要性 top-1 的特征抓出来和特征说明逐条比对,凡是看起来在贷款发生时还未知的字段,一律回到数据清洗阶段重新处理。
本文还有配套的精品资源,点击获取