简介:贷款违约预测是金融风控中的核心任务。一份基于Python的完整项目专为零基础入门者设计,既可巩固机器学习知识,也可直接用于课程设计或期末大作业。项目已经过导师指导,最终获得97分的高分评价,内容完整、结构规范,下载后无需任何修改即可运行。压缩包共包含10个文件,其中6个Python脚本分别实现数据处理、特征生成、模型训练与集成融合;另附1份Word项目说明、1个JupyterNotebook探索性数据分析文件、1张特征重要性图以及辅助配置文件,整体大小约380KB,目录清晰易查找。读者可以结合源码和手册,系统掌握从数据清洗、特征工程、模型调参到结果分析的完整流程,同时借助特征重要性图和多个基学习器的对比,理解特征筛选与模型集成的基本思路,快速迁移到其他金融风控场景。当前已有131人学习下载,适合需要快速完成高质量课设或竞赛项目的学生参考。
1. 贷款违约预测项目:为什么拿它入门金融风控最省事
刚接触金融风控方向时,最怕的不是算法复杂,而是拿到数据不知道从哪下手。这份基于 Python 的贷款违约预测源码,是某开发者做过的一个完整课程设计项目,数据来自某公开贷款平台历史借贷记录,目标是根据借款人的基本信息预测其是否会违约。它不是那种只教概念的空壳 Demo,而是把特征工程、单模型训练、模型集成和调优完整串起来的可运行工程,压缩包里装的是能直接跑的脚本和说明文档。零基础的人可以顺着代码一步步复现,想交课程设计或期末大作业的可以直接把它作为参考骨架,做风控实习和求职作品集的人也适合拿它练手。
2. 特征工程:从 30 多个原始字段到能建模的特征矩阵
2.1 数据摸底:EDA 阶段该盯哪些指标
拿到原始 CSV 后,第一步别急着写模型。直接跑模型会翻车,因为你不知道这份数据里有多少缺失、多少异常、多少类别值。项目里的EDA.ipynb做的就是这件事,它的核心逻辑是先用 pandas 做一次全面体检。
import pandas as pd import numpy as np df = pd.read_csv("loan_data.csv") print(df.shape) print(df.dtypes.value_counts()) # 统计每个字段的缺失率和唯一值个数 missing_ratio = df.isnull().mean().sort_values(ascending=False) print("缺失率最高的10个字段:") print(missing_ratio.head(10)) # 数值字段的描述性统计,重点看有没有极端值 num_cols = df.select_dtypes(include=[np.number]).columns.tolist() print(df[num_cols].describe().T)这段代码首先打印出数据集的形状和字段类型分布,目的是判断原始数据大概有多大、哪些列属于数值型、哪些属于类别型。缺失率统计是整个特征工程最关键的起点,因为金融信贷数据的缺失往往不是随机的,比如工资收入字段缺失的人,违约概率可能是另一套分布。描述性统计主要用于看最大最小值是否异常,比如某个贷款金额字段出现了负数或者超大的离群值,这种情况在信贷数据里常见,要么是录入问题,要么是不同币种混在一起。
这里的唯一值数量也很值得关注。唯一值个数接近样本量的连续字段通常是像“借款ID”这样的流水号,这类字段建模时一般直接丢弃;而唯一值较少的字段,比如住房状况、贷款用途,则要做类别编码或频次编码。EDA 做完后,你手头应该有一张“字段处理决策表”,每个字段是保留、删除、补缺失还是转换,都要有依据,而不是拍脑袋。
2.2 肮脏数据的标准化清洗与缺失值策略
信贷数据里最磨人的永远是缺失值和异常值。不同字段的缺失率差异极大,有的字段缺失 5%,有的字段可能缺失 30% 以上。处理策略不能一刀切,gen_feas.py里给出的常见做法是按字段性质分别处理,并把结果合并成一份新特征表。
import pandas as pd import numpy as np df = pd.read_csv("loan_data.csv") # 1. 直接删除不可用的流水号字段和缺失率超过80%的字段 drop_cols = [c for c in df.columns if df[c].isnull().mean() > 0.8] df.drop(columns=drop_cols, inplace=True) if "id" in df.columns: df.drop(columns=["id"], inplace=True) # 2. 连续字段的缺失值:用中位数填充,避免被极端值带偏 num_cols = df.select_dtypes(include=[np.number]).columns.tolist() for c in num_cols: df[c] = df[c].fillna(df[c].median()) # 3. 类别字段的缺失值:单独填充为 "UNKNOWN" cat_cols = df.select_dtypes(include=[object]).columns.tolist() for c in cat_cols: df[c] = df[c].fillna("UNKNOWN") print("处理后数据维度:", df.shape)填充策略的选择上有两个细节值得展开。第一,连续字段用中位数而不是均值,原因是信贷数据的收入、负债等字段呈长尾分布,少数高收入群体会把均值拉得很高,用均值填充相当于给大部分缺失样本人为灌入了一个偏高的值。第二,类别字段填“UNKNOWN”而不是填众数,这样模型在训练时能单独学到“该特征未知”这一模式。很多真实场景下,借款人没有填写的信息本身就携带着违约信号。
异常值的处理在gen_feas.py里通常是做截断,也就是把超过某个分位数阈值的值压到边界上,防止个别极端值主导梯度。
def clip_outliers(s, lower=0.01, upper=0.99): lo, hi = s.quantile([lower, upper]) return s.clip(lo, hi) for c in ["loan_amount", "annual_income", "debt_ratio"]: df[c] = clip_outliers(df[c])这里要对有经验的人说一句:截断分位数不要选得太激进,比如 0.01/0.99 这个组合是相对保守的,只处理真正离谱的极端值。如果数据本身噪音大,可以放宽到 0.02/0.98,但过度截断会把模型学到的判别信号削掉,尤其是收入字段,顶部 1% 的高收入者往往是低违约群体,全压到同一水平线上反而丢失区分度。
2.3 衍生特征:把领域经验转成模型能吃的数字
金融风控里,决定模型上限的往往不是算法,而是特征。gen_feas.py里最有价值的部分是衍生特征构造,它把几个原始字段组合成有业务含义的新变量。常见的衍生方向有三类:还款压力类、信用行为类、历史表现类。
# 还款压力:每月应还金额占月收入的比例 df["monthly_payment"] = df["loan_amount"] / df["term"] df["monthly_income"] = df["annual_income"] / 12 df["payment_to_income"] = df["monthly_payment"] / (df["monthly_income"] + 1e-6) # 信用利用程度:贷款金额占年收入的倍数 df["loan_to_income"] = df["loan_amount"] / (df["annual_income"] + 1e-6) # 历史信用:按收入区间分组,统计组内平均信用分,避免直接使用原始分造成过拟合 df["income_bucket"] = pd.qcut(df["annual_income"], 5, labels=False) df["credit_score_group_mean"] = df.groupby("income_bucket")["credit_score"].transform("mean") # 类别字段频次编码:出现次数少的类别合并成 "rare" freq = df["purpose"].value_counts() df["purpose_freq"] = df["purpose"].map(freq) df["purpose"] = np.where(df["purpose_freq"] < 50, "rare", df["purpose"])payment_to_income这类比例特征的意义在于平衡了收入和贷款金额两个绝对值字段。单独看贷款金额 10 万,你无法判断借款人压力大不大,但如果每月还款占月收入的 60%,违约风险就明显上升。分母加一个极小值1e-6是防止年收入为 0 或缺失被填成 0 时出现除零错误。
信用分那个衍生特征的做法值得说明一下,直接使用原始信用分字段建模,模型很容易抓住这个强特频繁分裂,造成过拟合。先按收入分桶,再计算桶内信用分均值,相当于把绝对数值转换成相对位置的信号,实际效果往往比直接用原始分要稳定。频次编码则是处理高基数类别字段的常用手段,把低频类别统一归到 “rare” 既能降维度,又能减少模型对少量样本类别的过拟合风险。
3. 三个模型分别上阵:LightGBM、XGBoost、CatBoost 的落地对比
3.1 LightGBM 快速出基线:参数初始化与早停策略
特征矩阵准备好之后,模型部分的第一步永远是跑一个基线。lgb.py里的流程是标准的三段式:读特征表、初始化参数、训练并输出指标。先用默认参数跑通流程,再根据结果调参,这是避免“一上来就调参数调到天荒地老”的正确姿势。
import lightgbm as lgb from sklearn.model_selection import train_test_split X = pd.read_csv("feas.csv") y = X.pop("default_flag") X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": 6, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "min_data_in_leaf": 50, "verbose": -1, } d_train = lgb.Dataset(X_train, label=y_train) d_val = lgb.Dataset(X_val, label=y_val, reference=d_train) model = lgb.train( params, d_train, num_boost_round=2000, valid_sets=[d_val], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], ) pred = model.predict(X_val, num_iteration=model.best_iteration) print("验证集 AUC:", roc_auc_score(y_val, pred))stratify=y这个参数值得专门解释。违约样本在真实信贷数据里占比通常只有 5% 到 10%,如果不做分层抽样,随机划分时很容易把少量违约样本全部分到某个子集里,导致训练集和验证集的类别分布差异巨大,模型的评估结果也就失真。learning_rate设为 0.05 是比较稳的起点,既能保证训练充分,又不会像 0.3 那样震荡。num_leaves=31对应深度为 5 左右的树复杂度,LightGBM 的叶子生长策略允许层内分裂不对称,通常深度不设太死,靠叶子数约束模型复杂度。
early_stopping(100)的机制是如果在连续 100 轮内验证集指标没有提升,就停止训练并回滚到最优轮次。这在金融风控里非常实用,因为你的目的是拿到验证集泛化能力最强的模型,而不是让训练集的损失一直往下掉。训练结束后打印 AUC,作为后续模型对比的基准线。
3.2 XGBoost 与 CatBoost:参数差异和适用场景
xgb.py和catboot.py在项目里承担的是对照和互补的角色。XGBoost 的结构化数据表现依然可靠,CatBoost 的优势则在自动处理类别特征和有序提升。三个模型在同一份特征数据上跑出来的 AUC 通常差异不大,但排名和最优参数区间会有区别。
import xgboost as xgb xgb_params = { "objective": "binary:logistic", "eval_metric": "auc", "eta": 0.05, "max_depth": 6, "subsample": 0.8, "colsample_bytree": 0.8, "min_child_weight": 5, } d_train_xgb = xgb.DMatrix(X_train, label=y_train) d_val_xgb = xgb.DMatrix(X_val, label=y_val) xgb_model = xgb.train( xgb_params, d_train_xgb, num_boost_round=2000, evals=[(d_val_xgb, "val")], early_stopping_rounds=100, verbose_eval=100, ) pred_xgb = xgb_model.predict(d_val_xgb, iteration_range=(0, xgb_model.best_iteration + 1)) print("XGBoost 验证集 AUC:", roc_auc_score(y_val, pred_xgb))XGBoost 的min_child_weight和 LightGBM 的min_data_in_leaf是同类角色,即约束叶子节点最小样本量。它俩的参数名不一样,但作用方向一致:阻止树学得太碎。subsample和colsample_bytree分别控制行采样和列采样,都设为 0.8 意味着每棵树只用 80% 的样本和 80% 的特征训练,这能显著降低方差。
CatBoost 那侧则要特别注意cat_features参数的传入方式。catboot.py里如果原始特征还有未做编码的类别变量,可以交给 CatBoost 原生处理,不需要提前频率编码。
from catboost import CatBoostClassifier, Pool cat_cols_ids = [X.columns.get_loc(c) for c in ["purpose", "home_ownership"]] train_pool = Pool(X_train, y_train, cat_features=cat_cols_ids) val_pool = Pool(X_val, y_val, cat_features=cat_cols_ids) cat_model = CatBoostClassifier( iterations=2000, learning_rate=0.05, depth=6, l2_leaf_reg=5, eval_metric="AUC", random_seed=42, verbose=100, ) cat_model.fit(train_pool, eval_set=val_pool, early_stopping_rounds=100) print("CatBoost 验证集 AUC:", roc_auc_score(y_val, cat_model.predict_proba(X_val)[:, 1]))使用 CatBoost 的最大好处是省略了手工类别编码环节。它的 Ordered Target Statistics 在提升过程中会对类别特征做带惩罚的目标编码,避免传统 Target Encoding 带来的目标泄漏问题。但前提是你确实把类别列位置传给了cat_features,否则这些字段会被当作纯数值特征使用,分裂效果会很差。
3.3 特征重要度:读完有什么实际用处
项目里附带了一张features_importance.png,这是三个模型特征重要度的对比图。看重要度不是为了发朋友圈,而是用于两个决策:判断特征工程方向对不对,以及决定是否做特征筛选。
import matplotlib.pyplot as plt importance = pd.Series(model.feature_importance(), index=X.columns) importance.sort_values(ascending=True).tail(20).plot.barh(figsize=(10, 8)) plt.tight_layout() plt.savefig("features_importance.png", dpi=150)如果在重要度 Top 10 里看到你在特征工程阶段构造的衍生特征,比如payment_to_income、loan_to_income,说明领域经验的转化方向是对的。反之,如果 Top 10 全是原始字段,就要反思衍生特征是否构造得太绕,或者编码方式出了问题。重要度还可以和业务常识对照验证,比如信用分、负债率排在前面符合预期,而贷款描述这种文本类字段排到最前面则需要警惕,可能是数据里混入了某种间接标签。
4. Stacking 集成:把三个模型合体成一个更强的预测器
4.1 为什么单模型不错了还要做集成
当三个模型在验证集上的 AUC 都稳定在 0.82 到 0.85 区间时,继续调单个模型参数带来的收益会越来越小。这时候 Stacking 的意义在于利用模型之间的差异性,基学习器对同一批样本的错误分布不完全相同,甲模型分错的样本可能是乙模型分对的,集成器能学到该信任哪个模型。
这份项目里的ensemble.py走的是经典双层 Stacking 结构:第一层三个基模型 LightGBM、XGBoost、CatBoost,第二层用一个逻辑回归元学习器。选择逻辑回归而不是树模型做元学习器,因为第二层特征维度低、共线性强,逻辑回归足够稳定,不容易把第一层的预测结果继续分叉得过于复杂。
4.2 五折交叉验证生成 Stacking 特征:防泄漏是关键
Stacking 最容易犯的错误是拿训练集的预测输出直接喂给第二层模型,这会造成严重的过拟合。正确做法是用 K 折交叉验证生成 Out-of-Fold 预测,保证元模型看到的每个样本都是基模型在没见过的数据上生成的输出。
import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression X = pd.read_csv("feas.csv") y = X.pop("default_flag") n_fold = 5 skf = StratifiedKFold(n_splits=n_fold, shuffle=True, random_state=42) # 存放三个基模型的 out-of-fold 预测和测试集预测 oof = np.zeros((len(X), 3)) test_pred = np.zeros((len(test_df), 3)) models = [ ("lgb", lgb_train_predict), ("xgb", xgb_train_predict), ("cat", cat_train_predict), ] for i, (name, train_func) in enumerate(models): oof_ = np.zeros(len(X)) test_ = np.zeros(len(test_df)) for tr_idx, va_idx in skf.split(X, y): X_tr, X_va = X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx] model = train_func(X_tr, y_tr, X_va, y_va) oof_[va_idx] = model.predict_proba(X_va)[:, 1] test_ += model.predict_proba(test_df)[:, 1] / n_fold oof[:, i] = oof_ test_pred[:, i] = test_ # 第二层:直接在 out-of-fold 特征上训练逻辑回归 meta_model = LogisticRegression() meta_model.fit(oof, y) final_pred = meta_model.predict_proba(test_pred)[:, 1]这里每个基模型都对应一个包装函数,在五折循环里返回一个训练好的专用模型。所有基模型必须在各自验证折上独立完成训练和预测,然后用验证折上的预测结果填充oof矩阵。测试集预测则是五折预测均值,这样能减少模型随机性带来的方差。
元模型训练时有一个细节:第二层的训练数据和第一层的数据需要严格对应。oof矩阵里的每一行对应原始训练集的一行,y也是同一顺序的标签,千万不能打乱顺序再训练,否则标签和特征就错位了。另外,逻辑回归输入的三个预测值之间相关性很高,这是正常现象,不需要做标准化,概率类特征的尺度天然一致。
4.3 集成效果的验证:比单一模型强多少才算值得
做 Stacking 之前和之后必须用同一套评估标准来对比。项目的做法是把基模型结果和 Stacking 结果放在同一个表格里,对比验证集 AUC、KS 系数和在最优阈值下的 F1 分数。
from sklearn.metrics import roc_auc_score, f1_score # base_pred 是单个最优基模型的验证集预测 base_auc = roc_auc_score(y_val, base_pred) stack_auc = roc_auc_score(y_val, final_pred) print(f"基模型最佳 AUC: {base_auc:.4f}") print(f"Stacking AUC: {stack_auc:.4f}") # 计算最优阈值下的 F1 from sklearn.metrics import precision_recall_curve prec, rec, thr = precision_recall_curve(y_val, final_pred) f1 = 2 * prec * rec / (prec + rec + 1e-6) best_idx = np.argmax(f1) print(f"Stacking 最优阈值: {thr[best_idx]:.3f}, F1: {f1[best_idx]:.3f}")Stacking 提升多少算有效?根据项目里的结果,通常能从 0.83 提到 0.85 到 0.86 左右。如果提升不到 0.005 甚至持平,那就不值得在生产环境里增加一层复杂度。AUC 提升超过 0.01 属于明显增强,说明基模型之间的互补性发挥出来了。版本对比时注意:训练集二分类 Stacking 的第一层如果三个模型同质化太严重,比如都是同参数不同随机种子跑出来的 LightGBM,集成的提升空间很小,所以基模型的差异化设计更重要。
5. 避坑指南:金融违约预测的五个典型翻车点
5.1 骗人的准确率:97% 准确率模型几乎不可用
某次训练时,A同学看到控制台打印的准确率是 0.972,以为模型已经完美。但看 AUC 只有 0.64,等于瞎猜水平偏上一点。原因是贷款违约数据里正负样本极度不平衡,违约样本可能只占 3% 左右,模型只需把所有样本预测成“不违约”就能拿到 97% 准确率。解决方法是放弃准确率指标,改用 AUC、KS、F1 或 Precision-Recall 曲线来评估。在训练代码里,把评估指标从accuracy改成roc_auc或f1,LightGBM 参数里对应的就是metric: "auc"。
5.2 时间穿越:用未来数据预测过去
使用随机划分把同一批时间跨度内的样本拆成训练集和验证集,导致验证集里的预测结果虚高。后来发现原始数据里某个月份之后的部分其实包含了已还款样本的未来信息,这样切分的模型到新数据上全部翻车。解决方法是数据划分时必须按时间切片,比如用前 70% 的时间段做训练,后 30% 做验证。信贷场景里还要警惕“特征穿越”,比如字段里包含了贷款发放之后的逾期次数,这种特征在前瞻性测试里就会失效。检查gen_feas.py里每个特征构造的时间点,确保只用贷款审批当时能拿到的信息。
5.3 缺失值直接填 0 引发分布断裂
部分字段的缺失值被粗暴地填充成 0,模型训练和验证期间一切正常,但上线后发现所有缺失样本的分数异常集中。原因是 0 在这个字段的业务场景里根本不是一个合理取值,模型把“缺失被填充的 0”当成了一种特殊模式,一旦真实数据里缺失样本是空字符串或者 NULL,填充逻辑不生效,模型就失去了该模式。解决方法是缺失值单独填充或单独标记,连续的用中位数,类别用 “UNKNOWN”,如果样本量足够,还可以构造一个is_missing二元特征辅助模型识别缺失模式。
5.4 LightGBM 叶子数过大导致过拟合
把num_leaves从 31 改到 128,想着提升模型表达能力,结果验证集 AUC 从 0.84 跌到了 0.80。原因是叶子数变大后,模型对训练集的局部模式拟合过度,LightGBM 的生长策略是从当前所有叶子中选增益最大的分裂,叶子数越多越能记住训练集噪音。解决方法是一般把num_leaves控制在2^max_depth附近,比如深度 6 时叶子不超过 64,同时用更大的min_data_in_leaf去抑制过拟合。跑参时先固定一个维度,不要同时改多个参数。
5.5 类别特征标签编码排序泄漏
对“住房状况”这类无序类别直接做LabelEncoder,分别为 0、1、2,模型把这些数字当成了有大小关系的连续值,进而错误学到“编码越大违约率越高”的伪规律。解决方案有两种,一是对无序类别做 one-hot 或频率编码,二是直接用 CatBoost 并在cat_features里声明类别列,它会用有目标统计的机制处理。频率编码比序列编码更安全,它把“出现次数”这个统计量换成数值,模型学到的规律与业务语义更贴近。
6. 阈值调优:把概率分数变成最终决策的临门一脚
模型输出的是违约概率,但业务上需要的是一个“过”还是“拒”的二值决策。很多人在这一步直接套 0.5 阈值,这在类别严重不平衡的信贷场景下是错误的。违约样本只占 3% 时,0.5 阈值会把所有概率低于 0.5 的样本都判成不违约,等于模型只输出了单个类别。这章节用最直接的代码演示怎么找到业务上最优的阈值,让模型真正落地。
from sklearn.metrics import precision_recall_curve, f1_score # 以 Stacking 的验证集预测结果为例 prec, rec, thr = precision_recall_curve(y_val, stack_pred) f1 = 2 * prec * rec / (prec + rec + 1e-6) best_idx = np.argmax(f1) best_thr = thr[best_idx] # 业务代价函数:漏放一个违约客户损失 5000,误拒一个好人损失 500 cost_matrix = {"fn_cost": 5000, "fp_cost": 500} costs = [] for t in thr[:-1]: pred_label = (stack_pred >= t).astype(int) fn = ((pred_label == 0) & (y_val == 1)).sum() fp = ((pred_label == 1) & (y_val == 0)).sum() total_cost = fn * cost_matrix["fn_cost"] + fp * cost_matrix["fp_cost"] costs.append(total_cost) best_cost_idx = np.argmin(costs) best_cost_thr = thr[best_cost_idx] print(f"F1 最优阈值: {best_thr:.3f}, F1={f1[best_idx]:.3f}") print(f"业务代价最优阈值: {best_cost_thr:.3f}, 最小总代价={costs[best_cost_idx]}")F1 阈值和业务代价阈值选哪个,取决于具体场景。做课程设计时,F1 最优阈值最直观,能在报告里清楚说明“精度与召回率的均衡点”。做真实风控策略时,则采用业务代价阈值,因为漏放违约客户带来的坏账损失通常远大于误拒一个好客户的收益损失。可以试着把fn_cost和fp_cost调成 1:1,再看最优阈值的变化,会发现当漏放代价变高时,最佳阈值会降低,也就是模型更倾向于把边缘样本判为违约。
从那以后,我每次跑完金融风控模型都会强制走一遍阈值调优流程,而不是直接拿默认的 0.5 交卷。哪怕最后只改动了一点阈值,也能让报告里多出一个“业务可解释”的决策依据,导师一眼就能看出你不只是会调包。希望帮到你。
本文还有配套的精品资源,点击获取