资讯动态

Python贷款违约预测实战:从特征工程到Stacking集成与阈值调优

发布时间:2026/10/14 12:58:40 来源:尧图企业网站定制
简介贷款违约预测是金融风控中的核心任务。一份基于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(ascendingFalse) 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(columnsdrop_cols, inplaceTrue) if id in df.columns: df.drop(columns[id], inplaceTrue) # 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, lower0.01, upper0.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, labelsFalse) 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_size0.2, stratifyy, random_state42 ) 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, labely_train) d_val lgb.Dataset(X_val, labely_val, referenced_train) model lgb.train( params, d_train, num_boost_round2000, valid_sets[d_val], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)], ) pred model.predict(X_val, num_iterationmodel.best_iteration) print(验证集 AUC, roc_auc_score(y_val, pred))stratifyy这个参数值得专门解释。违约样本在真实信贷数据里占比通常只有 5% 到 10%如果不做分层抽样随机划分时很容易把少量违约样本全部分到某个子集里导致训练集和验证集的类别分布差异巨大模型的评估结果也就失真。learning_rate设为 0.05 是比较稳的起点既能保证训练充分又不会像 0.3 那样震荡。num_leaves31对应深度为 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, labely_train) d_val_xgb xgb.DMatrix(X_val, labely_val) xgb_model xgb.train( xgb_params, d_train_xgb, num_boost_round2000, evals[(d_val_xgb, val)], early_stopping_rounds100, verbose_eval100, ) 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_featurescat_cols_ids) val_pool Pool(X_val, y_val, cat_featurescat_cols_ids) cat_model CatBoostClassifier( iterations2000, learning_rate0.05, depth6, l2_leaf_reg5, eval_metricAUC, random_seed42, verbose100, ) cat_model.fit(train_pool, eval_setval_pool, early_stopping_rounds100) 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(), indexX.columns) importance.sort_values(ascendingTrue).tail(20).plot.barh(figsize(10, 8)) plt.tight_layout() plt.savefig(features_importance.png, dpi150)如果在重要度 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_splitsn_fold, shuffleTrue, random_state42) # 存放三个基模型的 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(fStacking 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(fStacking 最优阈值: {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或f1LightGBM 参数里对应的就是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(fF1 最优阈值: {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 交卷。哪怕最后只改动了一点阈值也能让报告里多出一个“业务可解释”的决策依据导师一眼就能看出你不只是会调包。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑