Python贷款违约预测实战:从数据清洗到模型部署全流程
2026/9/23 4:21:21 网站建设 项目流程

简介:这是一套面向金融科技与机器学习入门者的贷款违约预测实践源码,围绕信用评分与风险控制场景,帮助读者用Python完成从数据分析到模型部署的完整链路。资源包共23个文件,约13.77MB,其中7个Python源码分别承担数据探索、随机森林、决策树、梯度提升与逻辑回归训练及预测任务,2个CSV文件提供训练与测试数据,另有11张PNG图像展示学习曲线、特征重要性与树结构,并附Excel数据字典、readme说明与LICENSE许可。已有560人学习下载。读者可据此掌握数据清洗、特征工程、模型对比与调优、过拟合识别等关键环节,并借助可视化结果直观评估各算法表现,快速搭建可复用的违约风险预测方案。

1. 贷款违约预测为什么值得用 Python 从头做一遍

信贷业务里最怕的不是坏账本身,而是坏账来得毫无预兆。一个客户在放款时看着各项指标都正常,六个月后却连续三期不还款,这种事后复盘往往只能看到一堆「当时其实有信号」的字段。贷款违约预测要解决的就是把这种事后懊悔变成事前概率:给定一笔申请或一个存量账户的特征,输出一个 0 到 1 之间的违约概率,再配合阈值和额度策略决定放不放、放多少。

用 Python 做这件事的价值在于链路完整且可复现。从数据清洗、特征工程、类别不平衡处理,到逻辑回归、梯度提升树的训练与评估,再到把模型固化成能批量打分的脚本,整套流程都能用 pandas、scikit-learn、lightgbm 这类常用包串起来。这也是「python 机器学习常用包」被反复搜索的原因——大家真正想要的不是算法推导,而是一条能跑通、能改参数、能解释结果的工程路径。

这篇面向两类人:一类是刚学完机器学习入门课程、想找一个真实结构化数据集练手的同学;另一类是手里有信贷数据、需要快速搭出基线模型的从业者。下面按「数据怎么准备 → 特征怎么造 → 模型怎么选和调 → 怎么评估才不骗自己 → 怎么排错」的顺序推进,每一步都给可抄的代码和参数说明。

2. 数据准备与特征工程:把原始字段变成模型能吃的输入

2.1 先搞清楚违约标签怎么定义

做贷款违约预测,第一个翻车点往往不是模型,而是标签。同一个数据集里,「违约」可能指逾期 30 天、60 天、90 天,也可能指核销。定义不同,正样本比例能差好几倍,模型学出来的东西完全不是一回事。常见做法是明确一个观察窗口和表现窗口:比如用放款后前 6 个月的还款行为,判断第 7 到第 12 个月是否出现 90 天以上逾期。

假设数据里有一个loan_status字段,取值包括Fully PaidCurrentCharged OffLate (31-120 days)等。我一般会把Charged Off和严重逾期归为违约(1),Fully Paid归为正常(0),Current这种还没走完周期的样本直接剔除,避免标签污染。

import pandas as pd import numpy as np df = pd.read_csv("loan_data.csv") # 只保留标签明确的样本,剔除周期未走完的 df = df[df["loan_status"].isin(["Fully Paid", "Charged Off", "Late (31-120 days)"])].copy() # 定义二分类标签:严重逾期与核销记为违约 df["target"] = df["loan_status"].isin(["Charged Off", "Late (31-120 days)"]).astype(int) print(df["target"].value_counts(normalize=True))

这段代码做了两件事:过滤掉标签模糊的样本,再把多分类状态压成 0/1。isin里的取值要根据你实际数据的枚举来改,别照抄。打印出的正样本占比很关键,如果低于 5%,后面必须处理类别不平衡。

2.2 缺失值和异常值:别急着用均值填

信贷数据里缺失本身可能就是信息。比如「最近一次查询征信距今月数」缺失,可能意味着这个人很少借贷,反而风险低。所以我不建议无脑用均值填充,而是先看缺失比例:超过 60% 的字段直接删,10% 到 60% 的单独加一个「是否缺失」的指示列,再用中位数填数值、用众数填类别。

# 统计缺失比例 missing_ratio = df.isnull().mean().sort_values(ascending=False) print(missing_ratio[missing_ratio > 0]) # 高缺失字段直接删除 drop_cols = missing_ratio[missing_ratio > 0.6].index.tolist() df = df.drop(columns=drop_cols) # 中等缺失字段加缺失指示列,再填中位数 for col in missing_ratio[(missing_ratio > 0.1) & (missing_ratio <= 0.6)].index: if col in df.columns: df[col + "_is_missing"] = df[col].isnull().astype(int) if df[col].dtype != object: df[col] = df[col].fillna(df[col].median())

missing_ratioisnull().mean()一次算出每列缺失占比。阈值 0.6 和 0.1 是我常用的经验值,数据质量差可以放宽到 0.7。加_is_missing列是为了让模型自己决定「缺失」这件事有没有预测力,这一步在树模型上收益比较明显。

异常值方面,像年收入出现 9999999 这种,多半是占位符。我一般对数值字段做分位数截断,把超过 99.5 分位和低于 0.5 分位的值拉回边界,而不是直接删样本,因为删样本会连带丢掉标签。

2.3 类别特征编码与衍生特征

类别字段比如贷款用途、房屋所有权、职业,直接用 one-hot 会让维度爆炸,尤其是职业这种高基数特征。常见做法是:低基数(取值少于 10 个)用 one-hot,高基数用目标编码或频率编码。目标编码要注意在训练集上算、验证集上用,否则会泄漏标签。

from sklearn.preprocessing import OneHotEncoder low_card_cols = [c for c in df.select_dtypes(include="object").columns if df[c].nunique() < 10] encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False) encoded = encoder.fit_transform(df[low_card_cols]) encoded_df = pd.DataFrame(encoded, columns=encoder.get_feature_names_out(low_card_cols), index=df.index) df = pd.concat([df.drop(columns=low_card_cols), encoded_df], axis=1)

handle_unknown="ignore"保证线上遇到训练时没见过的类别不会报错,直接编码成全 0。sparse_output=False是为了方便拼回 DataFrame,数据量大时改成 True 省内存。

衍生特征里最值钱的是比率类:贷款金额除以年收入(负债收入比)、已用额度除以总额度(额度使用率)、当前负债笔数除以账龄。这些比值比原始金额更能反映还款压力,也是信贷风控里公认有效的方向。

3. 模型选型与训练:从逻辑回归基线到梯度提升树

3.1 为什么先跑逻辑回归再上树模型

很多人一上来就调 LightGBM,结果连基线都没有,调完也不知道提升来自哪里。逻辑回归在这个场景有两个不可替代的作用:一是它的系数可以直接解释成「这个特征每变化一个单位,违约几率变化多少」,业务方认这个;二是它训练快,能快速验证特征和标签有没有问题。

from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline feature_cols = [c for c in df.columns if c not in ["target", "loan_status"]] X = df[feature_cols].select_dtypes(include=[np.number]) y = df["target"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) lr_pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced", C=1.0)) ]) lr_pipe.fit(X_train, y_train)

stratify=y保证训练集和测试集的正样本比例一致,类别不平衡时必加。class_weight="balanced"让少数类权重自动调高,等价于按类别频率反比加权。C是正则强度倒数,越小正则越强,先用 1.0 跑基线。

3.2 LightGBM 的关键参数怎么设

树模型在结构化信贷数据上通常比逻辑回归强,因为它能自动捕捉特征交互和非线性。LightGBM 参数多,但真正影响结果的就那几个。下面这套是我在中小规模数据(几万到几十万行)上常用的起点。

import lightgbm as lgb lgb_clf = lgb.LGBMClassifier( n_estimators=500, # 树的数量,配合早停用 learning_rate=0.05, # 学习率,越小越稳但越慢 num_leaves=31, # 叶子数,控制模型复杂度 max_depth=-1, # 不限制深度,靠 num_leaves 约束 min_child_samples=50, # 叶子最小样本数,防过拟合 subsample=0.8, # 行采样比例 colsample_bytree=0.8, # 列采样比例 reg_alpha=0.1, # L1 正则 reg_lambda=0.1, # L2 正则 scale_pos_weight=10, # 正负样本权重比,按实际比例调 random_state=42 ) lgb_clf.fit( X_train, y_train, eval_set=[(X_test, y_test)], eval_metric="auc", callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] )

num_leaves是 LightGBM 最该调的参数,它直接决定模型容量,31 是保守起点,数据量大可以到 63 或 127。min_child_samples设 50 是为了防止每个叶子只装几个样本导致过拟合,正样本少的时候可以降到 20。scale_pos_weight建议设成负样本数除以正样本数,比如正样本占 8%,就设 11 左右,别凭感觉写。

early_stopping(50)表示验证集 AUC 连续 50 轮不提升就停,能省掉手动定n_estimators的麻烦。eval_metric="auc"是因为 AUC 对阈值不敏感,适合不平衡场景。

3.3 类别不平衡的三种处理方式对比

信贷违约预测正样本通常只占 5% 到 20%,不平衡处理直接影响召回。常见三种做法:调整类别权重、过采样少数类、欠采样多数类。我一般优先用权重,因为它不改数据分布,训练也快。

方法做法优点风险
类别权重class_weight/scale_pos_weight不改数据,实现简单权重过大易过拟合少数类
过采样SMOTE 生成少数类样本提升召回明显合成样本可能不真实
欠采样随机丢弃多数类训练快丢失大量信息

用 SMOTE 时务必只在训练集上做,测试集保持原始分布,否则评估结果虚高。我见过有人在划分数据集之前就做了过采样,导致训练集和测试集里出现同源合成样本,AUC 冲到 0.99,上线直接崩,这是典型的血泪经验。

4. 评估与阈值:AUC 高不代表模型能用

4.1 别只看准确率,先看 AUC 和 KS

不平衡数据里准确率是骗人的:全预测为正常也能有 90% 以上。真正该看的是 AUC 和 KS。AUC 衡量排序能力,KS 衡量好坏样本累计分布的最大差距,风控里更常用 KS,一般要求 0.3 以上才算可用。

from sklearn.metrics import roc_auc_score, roc_curve y_prob = lgb_clf.predict_proba(X_test)[:, 1] auc = roc_auc_score(y_test, y_prob) fpr, tpr, thresholds = roc_curve(y_test, y_prob) ks = max(tpr - fpr) print(f"AUC: {auc:.4f}, KS: {ks:.4f}")

predict_proba取第 1 列才是违约概率,第 0 列是正常概率,取错列是新手高频错误。KS 用tpr - fpr的最大值算,和 AUC 一起看:AUC 高但 KS 低,说明模型在中间段排序乱,两端还行。

4.2 阈值不是 0.5,要按业务成本定

模型输出概率后,得选一个阈值把概率转成「通过/拒绝」。0.5 只是数学默认值,业务上完全不合理。假设通过一笔好客户赚 1000,放过一个坏客户亏 5000,那阈值应该偏向拒绝。可以用成本矩阵算期望损失最小的点。

import numpy as np cost_fn = 5000 # 放过一个坏客户的损失 profit_tn = 1000 # 通过一个好客户的收益 best_thr, best_cost = 0.5, np.inf for thr in np.arange(0.05, 0.95, 0.01): pred = (y_prob >= thr).astype(int) fn = ((pred == 0) & (y_test == 1)).sum() # 漏掉的坏人 tn = ((pred == 0) & (y_test == 0)).sum() # 正确通过的好人 cost = fn * cost_fn - tn * profit_tn if cost < best_cost: best_cost, best_thr = cost, thr print(f"最优阈值: {best_thr:.2f}, 期望成本: {best_cost:.0f}")

这段循环遍历阈值,算每个阈值下的总成本,取最小。cost_fnprofit_tn必须换成你业务里的真实数字,否则算出来的阈值没有意义。实际落地时还会按分数分箱做策略,而不是单点阈值。

4.3 特征重要性怎么看才不误导

LightGBM 的feature_importances_默认按分裂次数算,会偏向高基数特征。更稳的是看 gain(增益)或者用 SHAP 值。我一般先看 gain 排序,再挑前几个特征做 SHAP 依赖图,确认方向和业务常识一致。

importance = pd.DataFrame({ "feature": X_train.columns, "gain": lgb_clf.booster_.feature_importance(importance_type="gain") }).sort_values("gain", ascending=False) print(importance.head(15))

如果某个特征 gain 特别高但业务上说不通,比如「客户 ID 后四位」,那多半是数据泄漏,得查这个字段是不是在放款后才产生的。放款后才知道的字段(比如实际还款记录)绝对不能进模型,这是信贷建模的红线。

5. 避坑与排查:那些让模型上线就废的细节

5.1 现象:线下 AUC 0.9,线上效果断崖

原因:训练时用了放款后产生的字段,或者做了全局标准化导致测试集信息泄漏。解决:逐个核对特征的产生时间点,所有「未来信息」字段一律剔除;标准化、编码、填充这些操作全部放进 Pipeline,只在训练集上 fit。

5.2 现象:模型对某类客户全部预测为低风险

原因:这类客户在训练集里样本极少,模型没学到。解决:检查这类客户的正样本数量,少于 50 个就别指望模型能区分,要么合并类别,要么单独建规则。

5.3 现象:KS 在验证集 0.35,换一版数据掉到 0.15

原因:特征分布漂移,比如收入字段的口径变了。解决:上线前做特征稳定性监控,用 PSI(群体稳定性指标)对比训练集和线上样本,PSI 超过 0.25 的特征要重新审视。

5.4 现象:概率输出全是 0.02 到 0.08 之间,区分不开

原因:scale_pos_weight设得过大,或者正样本太少导致模型被压平。解决:先不加权重跑一版看概率分布,再逐步调权重;必要时用 isotonic 或 sigmoid 做概率校准。

5.5 现象:训练报错Input contains NaN

原因:衍生特征做除法时出现除零,产生 inf 或 NaN。解决:除法前给分母加一个极小值,或者用np.where判断分母是否为 0,训练前统一df.replace([np.inf, -np.inf], np.nan)再处理。

6. 把模型固化成可复用的打分脚本

模型训练完只是半成品,真正能用是把它变成一个输入原始特征、输出违约概率的脚本。我习惯把 Pipeline 和编码器一起用joblib存下来,线上只调一次predict_proba

import joblib # 保存训练好的模型和特征列顺序 joblib.dump({"model": lgb_clf, "features": list(X_train.columns)}, "loan_model.pkl") def score(new_df): bundle = joblib.load("loan_model.pkl") model, features = bundle["model"], bundle["features"] # 对齐特征列,缺失的补 0,多余的丢掉 new_df = new_df.reindex(columns=features, fill_value=0) return model.predict_proba(new_df)[:, 1] # 模拟批量打分 batch = X_test.head(5).copy() print(score(batch))

reindex(columns=features, fill_value=0)是保证线上线下特征顺序一致的关键,顺序错了模型不会报错但结果全乱,这种玄学问题排查起来很痛苦。存模型时把特征列名一起存,比只存模型对象靠谱得多。

验证脚本是否可靠,我一般做两件事:一是拿训练时留出的测试集重新打分,确认和训练时算的 AUC 一致;二是构造几条边界样本,比如全 0、极大值,看输出是否在 0 到 1 之间且不报错。这两步能挡掉大部分上线事故。

最后说个习惯:每次调完参数,把配置、AUC、KS、阈值和日期记一行到实验日志里。我早期不做记录,回头想复现某个 0.02 的提升,怎么都想不起来改了哪个参数,只能重跑一遍。模型迭代是长期活,后悔药就是那本日志。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询