资讯详情

基于Python的银行个贷违约预测:数据治理、WOE编码与XGBoost实战

📅 2026/10/27 20:17:44 | 华诺云谱 👁 阅读
基于Python的银行个贷违约预测:数据治理、WOE编码与XGBoost实战
简介面向金融风控、数据挖掘方向学习者与毕业设计人员这份基于Python实现的中原银行个人贷款违约预测资源围绕如何利用银行存量信贷数据服务新场景、新客群的赛题展开以迁移学习为主要思路适合课程设计、项目演示或入门进阶。资源共九个文件压缩包仅三百一十一KB包含六个Python脚本、两个CSV数据集和一个Markdown说明文档py脚本负责数据清洗、特征构建与预测建模csv提供测试样本与提交格式md阐述项目背景和使用方式源码覆盖清洗、特征、建模等完整流程特征构建细分为类别特征、时间特征、数值编码等多个子模块并配有公开测试集样例和提交示例便于直接运行复现。作者提供远程教学支持运行中遇到问题可私信沟通代码经测试通过答辩评审平均分达九十六分整体方案可作个人贷款违约预测全流程参考。目前已有九十八人学习下载结合附带的说明文档可快速理解项目结构适合借鉴完整建模流程的读者参考学习。1. 基于python实现的中原银行个贷违约预测模型不是难点数据治理才是基于python实现的中原银行个贷违约预测是我见过最适合当信贷风控入门样例的项目类型——它自带源代码、文档说明和数据集你不需要从零搭框架而是把整条链路亲手走一遍读数据、补缺失、分箱、建模、调阈值。很多人拿到项目先急着跑模型跑完发现AUC只有0.6就把锅甩给代码。实际上个贷违约预测最花时间的是进模型之前的数据治理以及最后一步怎么把违约概率翻译成业务决策。这个项目适合三类人想入行银行风控的算法工程师、做金融信贷场景毕设的学生、以及想了解评分卡落地逻辑的业务分析师。本文按我自己的实操顺序展开每一步都有可复现代码和参数说明。2. 拿到数据集先别跑模型字段识别、缺失值与数据泄漏2.1 先读文档说明再做字段来源审计任何个贷项目的数据集到手第一步不是df.head()而是打开配套的文档说明把每个字段的来源时点标清楚。我在处理类似项目时发现数据集里通常会混着两种变量一类是授信前变量申请时点就能拿到的年龄、收入、职业、贷款金额、历史征信记录另一类是贷中或贷后变量还款逾期天数、催收次数、已还款比例。后者在建模阶段是绝对禁区因为线上部署时你根本拿不到未来才发生的信息一旦混进去训练集AUC能到0.95真实场景直接崩盘。import pandas as pd df pd.read_csv(loan_data.csv, encodinggbk) print(df.shape) print(df.columns.tolist()) # 按文档说明给字段打上来源标签后续建模只保留授信前变量 pre_loan_vars [age, income, loan_amt, loan_term, credit_score, debt_ratio, purpose, region] label default_flag # 先验证这些字段都在数据里避免后续KeyError missing_cols [c for c in pre_loan_vars [label] if c not in df.columns] if missing_cols: print(字段缺失:, missing_cols) else: print(授信前变量核对通过共, len(pre_loan_vars), 个特征)这段代码的核心价值是特征白名单机制。我在多个风控项目里吃过亏数据集字段名不规范时模型训练完才发现用了未来变量这种错误在代码层面极难发现只能靠字段清单硬约束。encodinggbk是读取银行导出数据的常见编码如果报错就换成utf-8或gb18030。拿到数据后建议先打印字段列表人工过一遍任何一个名字里带overdue、collection、return的变量都要警惕。2.2 缺失率统计与填补顺序先补还是先删顺序错了全白搭个贷数据的缺失处理有个默认优先级先看缺失率再决定策略。我一般把缺失率分成三档大于50%的字段直接删除20%到50%的看业务含义决定是否保留低于20%的做填补。这个规则不绝对但作为起点能避免两个极端——把高缺失字段硬填进去制造噪声或者把有强解释力的字段因为一处空值就整列删掉。import numpy as np miss_rate df.isnull().mean().sort_values(ascendingFalse) print(缺失率Top10:\n, miss_rate[miss_rate 0]) # 处理策略缺失率50%直接删列其余字段统一标记后填补 drop_cols miss_rate[miss_rate 0.5].index.tolist() df df.drop(columnsdrop_cols) print(删除高缺失字段:, drop_cols) # 数值型字段用中位数填补类别型字段用众数填补 num_cols df.select_dtypes(include[np.number]).columns cat_cols df.select_dtypes(include[object]).columns for col in num_cols: df[col] df[col].fillna(df[col].median()) for col in cat_cols: df[col] df[col].fillna(df[col].mode()[0]) print(填补完成剩余缺失率:, df.isnull().sum().sum())这里的关键参数是中位数而不是均值。收入、贷款金额这类字段天然右偏个别高净值客户会把均值拉得很高用均值填补等于给所有缺失样本注入一个虚高的收入值模型学到的收入阈值全是偏的。中位数对极值不敏感是信贷字段填补的默认选项。类别字段用众数填补但要注意如果某个类别占据80%以上填补后这个字段几乎失去区分能力后续IV筛选会把它过滤掉所以也不用太担心。异常值处理放在缺失值之后。年龄大于100、收入为负数、贷款金额小于0这类脏数据直接用布尔索引剔除。我不建议用3σ原则自动删因为信贷数据天生厚尾3σ会误伤真实的高收入群体逻辑判断比统计判断更安全。3. 特征工程链分箱、WOE编码与IV值筛选3.1 连续变量分箱等宽、等频与决策树辅助分箱是整个特征工程里最体现功力的环节目的是把连续变量离散化降低异常值和噪声的影响同时为后面的WOE编码打基础。三种做法我全试过等宽分箱实现最简单但遇到年龄这种分布不均的字段20到40岁可能挤了60%的样本箱内违约率没有明显梯度等频分箱保证每箱样本量接近但边界值可能出现在很怪的位置比如月收入分箱边界是13542.7元业务解释很尴尬决策树辅助分箱效果最好用单变量决策树拟合违约标签直接把树的分裂点当作箱边界再用业务常识微调。from sklearn.tree import DecisionTreeClassifier def tree_bins(series, y, max_bins5): 用单变量决策树找连续变量的最优分箱边界 model DecisionTreeClassifier(max_leaf_nodesmax_bins, random_state42) model.fit(series.values.reshape(-1, 1), y) thresholds model.tree_.threshold[model.tree_.feature ! -2] thresholds sorted(set([round(t, 2) for t in thresholds if t ! -2])) return [-np.inf] thresholds [np.inf] # 以income字段为例生成分箱边界 bins tree_bins(df[income], df[default_flag], max_bins5) df[income_bin] pd.cut(df[income], binsbins, labelsFalse) print(income分箱边界:, bins) print(各箱样本量:\n, df[income_bin].value_counts().sort_index())max_leaf_nodes控制分箱数量我建议设置在4到6之间太细会导致某些箱的违约样本量太少WOE值不稳定太粗会丢失变量的非线性信息。决策树分箱的好处是边界直接从数据里长出来比如模型发现收入低于8000元违约率显著上升这个8000就是业务认可的真实阈值而不是等宽分箱拍脑袋定的10000。分箱完成后建议逐箱看违约率正常情况下违约率应该随收入上升单调下降如果出现U型曲线说明分箱边界有问题或者变量本身有交互项没处理。3.2 WOE编码替代one-hot为什么风控项目都这么干WOEWeight of Evidence编码是信贷风控的标配原因很简单one-hot在类别多的时候会产生大量稀疏列逻辑回归的系数会变得非常难解释而WOE把每个类别替换成该类别违约分布与正常分布的对比值直接把类别特征变成有序的数值特征而且数值大小本身就带着业务含义。def calc_woe(df, feature, labeldefault_flag): 计算单特征每个分箱的WOE值 stats df.groupby(feature)[label].agg([sum, count]) stats.columns [bad, total] stats[good] stats[total] - stats[bad] total_bad stats[bad].sum() total_good stats[good].sum() stats[bad_pct] stats[bad] / total_bad stats[good_pct] stats[good] / total_good # 加0.5平滑防止某箱bad或good为0时取对数无穷 stats[woe] np.log((stats[bad_pct] 0.5) / (stats[good_pct] 0.5)) return stats[woe] # 对收入分箱字段计算WOE并替换原值 woe_map calc_woe(df, income_bin) df[income_woe] df[income_bin].map(woe_map) print(各箱WOE值:\n, woe_map)WOE公式里我加了0.5平滑因子这是血泪经验极端的类别分箱下如果某一箱只有3个样本且全部违约bad_pct会是1good_pct接近0直接取对数会出现无穷大。平滑因子降低了这种小样本噪声对模型的影响。WOE计算完成后原始字段可以删除因为分箱信息和违约分布信息已经被WOE值完全吸收。注意WOE的一个隐含假设每个箱的违约率与正常率之比是单调的如果某特征WOE值来回跳动说明分箱不合理或者变量本身与违约是非线性关系需要考虑换特征或做交互项。3.3 用IV值过滤弱特征0.02、0.1、0.3三档阈值IVInformation Value是WOE的加权汇总衡量一个特征对违约状态的区分能力。它是风控项目里筛特征的硬标准比直接看模型feature_importance更前置、更稳定。严格意义上传入WOE计算结果用求和形式就可以了。def calc_iv(df, feature, labeldefault_flag): 基于WOE计算IV值 stats df.groupby(feature)[label].agg([sum, count]) stats.columns [bad, total] stats[good] stats[total] - stats[bad] total_bad stats[bad].sum() total_good stats[good].sum() stats[bad_pct] stats[bad] / total_bad stats[good_pct] stats[good] / total_good # 这里直接用原始占比差乘WOE不需要额外加平滑 stats[iv] (stats[bad_pct] - stats[good_pct]) * \ np.log((stats[bad_pct] 1e-10) / (stats[good_pct] 1e-10)) return stats[iv].sum() iv_values {} for col in [age_bin, income_bin, credit_score_bin, debt_ratio_bin, purpose_woe]: # 有些已经做了WOE编码直接用编码值分组 iv_values[col] calc_iv(df, col) iv_series pd.Series(iv_values).sort_values(ascendingFalse) print(各特征IV值:\n, iv_series)IV筛选的通常标准小于0.02的变量基本没有区分能力直接丢弃0.02到0.1之间属于弱变量可以留着让模型自己去组合0.1到0.3属于中等偏强变量是模型的主力大于0.3的变量要特别小心它可能是真实的高区分度变量但更可能是前文说的数据泄漏来源需要回头核对字段来源。我在跑这类数据集时credit_score通常是IV最高的字段因为征信评分本身就是模型输出的概率用它预测违约本质上是拿结果预测结果不算完全错误但要看清楚数据集里这个字段的评分时点是否早于贷款发放。4. 模型选型与效果评估从LR到XGBoost的参数调优4.1 逻辑回归和XGBoost的选型逻辑个贷违约预测里逻辑回归LR和XGBoost是两个极端。LR的优势是系数可以翻译成业务语言——年龄每增加一岁违约对数几率降低多少部署也轻一个线性表达式就能上线连模型文件都不需要。XGBoost的优势是能自动捕捉特征交互比如“收入低于阈值且负债率高”这种组合风险特征LR要手动构造交互项才能学到XGBoost的树结构天然就能分出来。我的做法是两套都训先用LR出基线再上XGBoost追AUC上限。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split # 特征矩阵注意这里全部用WOE和分箱后的字段 feature_cols [income_woe, age_woe, credit_score_woe, debt_ratio_woe, purpose_woe, loan_amt_woe] X df[feature_cols] y df[default_flag] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42 ) lr LogisticRegression(C0.1, max_iter1000) lr.fit(X_train, y_train) print(LR训练完成特征系数:) for col, coef in zip(feature_cols, lr.coef_[0]): print(f {col}: {coef:.4f})C0.1是逻辑回归的正则化强度值越小正则越强。在风控场景里我倾向把C调低因为信贷特征之间相关性高正则太小系数会爆炸线上部署时微小输入波动就会被放大。stratifyy保证训练集和验证集的违约比例一致这是分类任务的默认操作。逻辑回归对特征尺度敏感这也是前面坚持做WOE编码的原因——WOE值天然在0附近波动尺度统一不需要额外做StandardScaler。XGBoost那边参数就要复杂一些。我常用的起始参数组是n_estimators300、learning_rate0.05、max_depth3、subsample0.8、colsample_bytree0.8、reg_alpha0.1。这个组合的核心思路是低学习率配多轮迭代避免过拟合max_depth3在大多数表格型信贷数据上够用了深度再大容易学到噪声和极端样本。reg_alpha和reg_lambda是XGBoost的正则化参数前者做L1稀疏化会自动裁掉部分弱特征后者做L2防抖动两者同时开是风控调参的常见做法。4.2 样本不均衡class_weight和scale_pos_weight怎么设个贷数据集的违约率通常在2%到5%之间意味着负样本是正样本的20倍以上。如果不管这个不均衡模型会把所有样本都预测为正常准确率依然高达95%但这毫无用处。Python生态里处理这个问题有几条路逻辑回归可以直接设class_weightbalanced让sklearn自动调整类别权重XGBoost对应的是scale_pos_weight参数最简单好记。通常设成负样本数除以正样本数就能得到一个合理起点。from xgboost import XGBClassifier # 计算类别比例scale_pos_weight通常取负样本/正样本 neg_count (y_train 0).sum() pos_count (y_train 1).sum() scale_pos_weight neg_count / pos_count print(正负样本比:, f正{pos_count} 负{neg_count}, scale_pos_weight:, round(scale_pos_weight, 2)) xgb XGBClassifier( n_estimators300, learning_rate0.05, max_depth3, scale_pos_weightscale_pos_weight, subsample0.8, colsample_bytree0.8, reg_alpha0.1, random_state42 ) xgb.fit(X_train, y_train) print(XGB训练完成)scale_pos_weight的取值逻辑并不精确有人在2%违约率的数据集上把值调到10以上反而更好原因是这个参数不只是平衡样本还隐含了对召回率的偏好。我的习惯是先按比例设一个值跑出基线然后网格搜索一个范围——通常是比例值的0.5倍、1倍、2倍三档看验证集KS和召回率哪个组合更稳。另外提一句做XGB之前不用先做SMOTEscale_pos_weight本身就能处理不均衡两者叠加容易过拟合我实际项目中很少同时用。4.3 用AUC、KS和PR-AUC评估准确率在这里是陷阱个贷场景下准确率是最没参考价值的指标。1000个样本里20个违约模型把所有人判正常准确率98%但这个模型毫无用处。风控行业看三样东西AUC衡量模型对正负样本排序能力数值越高说明违约客户的风险分数越靠后KS衡量模型区分度风控模型KS一般要求大于0.3PR-AUC精确率-召回率曲线下面积在极端不均衡数据里比ROC更敏感因为它直接关注少数类的表现。from sklearn.metrics import roc_auc_score, precision_recall_curve, auc # 先看预测分数分布确认模型输出的不是纯0/1 y_prob xgb.predict_proba(X_test)[:, 1] print(预测分数分布: min , round(y_prob.min(), 4), max , round(y_prob.max(), 4), mean , round(y_prob.mean(), 4)) # AUC auc_score roc_auc_score(y_test, y_prob) print(AUC:, round(auc_score, 4)) # KS按分数排序找累计正负样本比例差的最大值 fpr_list, tpr_list [], [] thresholds np.linspace(0, 1, 100) for t in thresholds: pred (y_prob t).astype(int) tpr np.sum((pred 1) (y_test 1)) / np.sum(y_test 1) fpr np.sum((pred 1) (y_test 0)) / np.sum(y_test 0) tpr_list.append(tpr) fpr_list.append(fpr) ks max(np.array(tpr_list) - np.array(fpr_list)) print(KS:, round(ks, 4))这段代码里阈值遍历用的是np.linspace(0, 1, 100)步长0.01KS的计算精度足够。KS超过0.4说明模型在排序上表现优秀但也要警惕推测可能是过拟合或发生了未来信息泄漏。AUC和KS的观感不一样AUC对整体排序敏感KS关注的是最大分离点的能力。如果AUC有0.85但KS只有0.2说明模型在中间分数段区分度不够可能特征剂量不足如果KS很高但AUC一般说明模型只在某个分数区间有区分度。5. 避坑记录个贷违约预测最容易翻车的5个细节5.1 未来信息泄漏AUC高到离谱就是报警信号现象训练集AUC达到0.97测试集也有0.92但把模型用到新数据时一塌糊涂审批通过率和坏账率严重偏离预期。原因数据集的字段里混进了贷后变量。最常见的是直接用“累计逾期天数”“催收次数”当特征这些字段只有在贷款发放之后才会产生属于上帝视角。解决回到字段列表凡是文档说明里标注为“贷后”“催收”“还款表现”的字段一律删除。更隐蔽的是“历史征信逾期次数”——这个要看统计时间窗口如果窗口覆盖了贷款发放后的几个月同样是未来信息。经验做法是逐字段过一遍生成逻辑拿不准就删AUC从0.97掉到0.75是正常的不要慌。5.2 随机划分数据导致样本泄漏同一个人出现在训练集和验证集现象模型在验证集上AUC很高但上线后表现断崖式下跌。原因数据集里可能同一个客户有多条贷款记录直接train_test_split随机划分时同一客户的记录可能一部分进了训练集、一部分进了验证集。模型等于“见过”这个客户的部分信息再去预测他的其他记录成绩虚高。解决如果数据里有客户ID或身份证脱敏字段先按客户ID做去重或者用GroupShuffleSplit保证同一客户的所有记录只在同一侧。5.3 对全量数据计算WOE再切分验证集形同虚设现象WOE编码后训练集和验证集的AUC都比预期高很多但KS惨不忍睹。原因WOE映射表是在全量数据上计算的验证集样本的信息已经被统计进去了。验证集不再独立评估结果天然乐观。这是很多教程没讲清楚的地方他们直接对整个DataFrame做WOE的map操作严格来说流程不严谨。解决先切分训练集和测试集在训练集上fit出WOE映射字典测试集用map方法套用同一份映射绝不重新计算。5.4 SMOTE过采样放在切分之前重复样本污染验证集现象模型AUC逼近0.99几乎完美。原因SMOTE生成的都是训练集样本的插值组合如果过采样发生在数据切分之前验证集里会混入和训练集样本极其相似甚至相同的合成样本模型验证毫无意义。解决先train_test_split然后在训练集上用SMOTE验证集保持真实分布。imblearn的SMOTE只fit训练集sampling_strategy参数设置为0.3到0.5之间的一个比例让正样本达到负样本的三分之一或一半而不是强行1比1过高的采样比例反而会让模型学到重复模式。5.5 把阈值默认定在0.5业务成本完全被忽略现象模型预测违约概率在0.1到0.3之间的客户数量庞大用0.5当阈值审批通过率极低业务部门抱怨客户流失。原因0.5是模型输出的天然分界但个贷场景里违约率本来就低大量真实违约客户的预测分数集中在0.1到0.3之间。固定0.5等于默认“犯错的代价”对错判违约和错判正常完全一样这在信贷业务里显然不成立。解决把阈值当成一个业务参数用成本矩阵寻找最优切分点。这个思路在下一章展开写这里先记住一个原则阈值永远是根据业务定出来的不是模型默认的。6. 把概率变成决策违约阈值与可解释性落地个贷违约预测项目的最后一步是把模型输出的违约概率翻译成“批不批、按什么额度批”的业务决策。常见的做法是最小化期望成本假设审批一个正常客户能赚到息差收益拒绝错了好客户损失的是这笔收益审批通过一个违约客户损失的是本金。把这两个成本量化然后在验证集上扫阈值找总成本最低的点。cost_fn 10000 # 把违约客户错判为正常的成本本金损失 cost_fp 500 # 把正常客户错判为违约的成本损失息差利润 thresholds np.linspace(0.05, 0.6, 56) total_cost_list [] for t in thresholds: pred (y_prob t).astype(int) fn np.sum((pred 0) (y_test 1)) fp np.sum((pred 1) (y_test 0)) total_cost fn * cost_fn fp * cost_fp total_cost_list.append(total_cost) best_idx np.argmin(total_cost_list) best_threshold thresholds[best_idx] print(最优阈值:, round(best_threshold, 3), 对应总成本:, total_cost_list[best_idx])这个扫描逻辑的理解成本很低但它是整个项目从“模型跑通”走向“业务可用”的关键一步。实际业务中cost_fn和cost_fp不是拍脑袋定的需要和审批、信贷产品经理对齐。如果审批拒绝的客户会流向竞争对手cost_fp就要调高如果单笔贷款金额大、欺诈风险高cost_fn就要上调。阈值确定后模型输出的分数分段也可以继续细分——比如0.4分以上直接拒绝0.2到0.4之间转人工审批0.2以下自动通过。XGBoost模型的可解释性可以用SHAP做落地分析。对每个预测样本输出它的特征贡献分解业务人员可以直接看到“这个客户被拒是因为收入分箱的WOE偏低、加上征信评分分箱的风险权重过高”。这比给业务看AUC数字更有说服力也让模型上线后的监管沟通顺畅得多。我最初做这类项目时也栽在AUC偏高但审批通过率过低这个坑里后来发现是阈值没校准业务完全用不上从那以后我把阈值决策当成模型的一部分来对待同样的模型配上合理的阈值落地效果完全不一样。希望这个思路对你有帮助至少让你在跑通代码之后知道下一步该往哪走。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑