news 2026/10/22 10:22:30

金融风控入门实战:贷款违约预测模型从数据到评分卡全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
金融风控入门实战:贷款违约预测模型从数据到评分卡全流程

1. 项目缘起与整体思路拆解

1.1 为什么选贷款违约预测作为风控入门项目

金融风控这个领域,说大很大,说小也很小。大到一个银行几千人的风控团队,小到一个助贷平台三五个算法工程师就能撑起核心决策链路。但不管规模怎么变,贷款违约预测始终是这条链路上最基础、也最核心的一环。原因很简单:信贷业务赚的就是利差,而利差能不能覆盖住坏账,直接决定了这门生意是盈利还是亏损。

我最早接触这个方向的时候,踩过一个很典型的坑:拿到一份标注好的历史放款数据,二话不说先上XGBoost,AUC跑到0.78就以为大功告成。结果上线之后发现,模型在训练集上表现很好,一到真实审批流里就“失灵”——后来复盘才明白,问题出在样本定义和时间窗口上。训练集里用的是“放款后12个月是否逾期90天以上”作为标签,但实际审批时,很多客户连第一期的还款行为都还没发生,模型学到的很多特征其实是“未来信息”,也就是典型的数据穿越。

所以这个项目我打算从头讲清楚:一份贷款违约预测模型,从拿到原始数据到最终能上线跑分,中间到底要经历哪些环节,每个环节的关键决策点在哪里,以及那些只有真正做过的人才会知道的坑。

这个内容适合谁看?如果你是有一定Python基础、想往风控算法方向转的数据分析同学,或者已经在做风控策略、想了解模型侧是怎么一回事的策略分析师,再或者你是刚入行一两年、想系统梳理一遍完整流程的算法工程师,那这篇内容应该能给你不少参考。我会尽量把代码和业务逻辑揉在一起讲,而不是单纯贴一段sklearn的调用。

1.2 整体方案设计:从原始数据到可解释的评分卡

这个项目的整体思路,我把它拆成六个阶段,每个阶段都有明确的输入和输出:

阶段核心任务关键产出
数据理解与探索搞清楚字段含义、分布、缺失情况数据字典、EDA报告
样本定义与标签构建明确好坏样本定义、观察期与表现期带标签的建模宽表
特征工程衍生变量、分箱、WOE编码特征库、IV值排序
模型训练与调优逻辑回归/XGBoost/LightGBM对比最优模型、超参数
模型评估与验证KS、AUC、PSI、跨时间验证评估报告、稳定性分析
评分卡转换与部署分数映射、 cutoff 确定评分卡、决策阈值

为什么这么设计?因为风控模型和推荐模型、图像模型有一个本质区别:可解释性往往比精度更重要。监管要能说清楚“为什么拒绝这个客户”,业务要能理解“哪些因素在驱动风险”,所以逻辑回归+WOE评分卡这套“老古董”至今仍然是很多银行的主力,不是没有道理的。当然,现在很多互联网平台也会用集成树模型做排序,但最终往往还是要输出一个分数,方便策略同学做cutoff。

我个人的做法是:先用逻辑回归+WOE搭一个baseline,把特征工程和样本定义跑通,然后再上LightGBM看能提升多少。如果提升不明显,那就老老实实用评分卡,因为可解释性和稳定性更好;如果提升显著,再考虑用树模型做线上打分,但一定要做好模型监控和解释性工具(比如SHAP)。

1.3 数据集说明与字段理解

这个项目用的数据集是一份公开的信贷违约数据,包含约3万条放款记录,字段大致分为几类:

  • 客户基本信息:年龄、职业、学历、婚姻状况等
  • 信贷历史:历史贷款笔数、历史逾期次数、信用额度使用率等
  • 本次申请信息:贷款金额、期限、利率、还款方式等
  • 行为数据:近6个月查询次数、近12个月还款表现等
  • 标签字段:是否违约(0/1)

拿到数据第一步,我习惯先做三件事:

  1. 看字段类型和缺失率:缺失率超过60%的字段,除非业务上特别重要,否则直接砍掉。
  2. 看标签分布:违约率大概在5%-8%左右,属于典型的不平衡样本。
  3. 看时间跨度:如果数据跨越了不同年份,一定要按时间切分训练集和测试集,不能随机切。

注意:很多教程喜欢用train_test_split随机切分,这在风控里是大忌。因为信贷数据有时间效应,随机切分会导致未来信息泄露,模型评估结果会虚高。

2. 核心细节解析与实操要点

2.1 样本定义:观察期与表现期怎么切

这是整个项目里最容易被忽视、但影响最大的环节。所谓样本定义,说白了就是:用哪个时间点的信息,去预测哪个时间段的行为。

标准做法是:

  • 观察期:取申请日前一段时间的数据,比如近6个月、近12个月的行为表现。
  • 表现期:放款后一段时间内是否发生违约,比如12个月内是否逾期90天以上。
  • 成熟期:表现期结束后的缓冲,确保所有样本都有足够时间“表现”。

举个例子:假设你在2023年6月做模型,那么:

  • 取2022年1月到2022年6月放款的客户作为样本
  • 观察期取放款前6个月(2021年7月-2021年12月)
  • 表现期取放款后12个月(2022年1月-2022年12月)
  • 2023年1月之后才能确认这些客户是否违约

这样切出来的样本,才是真正“当时能拿到什么信息,就预测什么结果”。

我见过不少团队为了赶进度,直接用“当前逾期状态”作为标签,结果模型学到的全是“已经逾期的人有什么特征”,而不是“哪些人未来会逾期”。这两者天差地别。

2.2 特征工程:从原始字段到WOE编码

特征工程这块,我分三步走:

第一步:衍生变量

原始字段往往不够用,需要根据业务逻辑衍生。比如:

  • 额度使用率 = 已用额度 / 总额度
  • 负债收入比 = 月还款额 / 月收入
  • 查询密度 = 近3个月查询次数 / 3
  • 逾期占比 = 历史逾期次数 / 历史贷款笔数

这些衍生变量往往比原始字段更有区分度。我实测下来,额度使用率和查询密度这两个指标,IV值通常能排进前五。

第二步:分箱

连续变量直接放进逻辑回归,解释性很差,而且容易受异常值影响。所以要做分箱,常用的方法有:

  • 等频分箱:每个箱样本量差不多
  • 等距分箱:按数值区间均匀切
  • 卡方分箱:基于统计检验自动找切点
  • 决策树分箱:用树模型找最优切分点

我一般用卡方分箱,因为它能自动处理单调性问题。分箱之后,每个箱的WOE值应该呈现单调趋势,否则说明分箱不合理。

第三步:WOE编码与IV值筛选

WOE(Weight of Evidence)的计算公式:

WOE = ln(坏样本占比 / 好样本占比) IV = Σ (坏样本占比 - 好样本占比) × WOE

IV值用来衡量变量的预测能力,一般标准是:

IV值范围预测能力
< 0.02几乎无预测力
0.02-0.1弱
0.1-0.3中等
0.3-0.5强
> 0.5极强,但需警惕过拟合

我一般保留IV值在0.02以上的变量,但也不会盲目追求高IV,因为IV太高往往意味着这个变量和标签有某种“巧合”关系,跨时间验证时容易失效。

2.3 模型选择:逻辑回归 vs LightGBM

逻辑回归和LightGBM,我一般都会跑一遍,对比KS和AUC。

逻辑回归的优势:

  • 可解释性强,系数直接反映变量影响方向
  • 配合WOE编码,天然适合评分卡
  • 训练快,稳定性好

LightGBM的优势:

  • 自动捕捉非线性关系
  • 对缺失值和异常值不敏感
  • 通常KS能比逻辑回归高2-5个点

但LightGBM的缺点也很明显:黑盒。你很难跟业务解释“为什么这个客户被拒了”。所以我的做法是:

  • 如果LightGBM比逻辑回归KS高不到3个点,直接用逻辑回归
  • 如果高3个点以上,用LightGBM做线上打分,但同时用SHAP值做解释性分析
  • 无论用哪个模型,都要做跨时间验证

2.4 评估指标:KS、AUC、PSI

风控模型不看准确率,因为样本不平衡,准确率没有意义。核心指标是:

KS(Kolmogorov-Smirnov)

KS衡量的是模型区分好坏样本的能力,计算公式是:

KS = max(累计坏样本占比 - 累计好样本占比)

一般标准:

KS值模型效果
< 0.2差
0.2-0.3一般
0.3-0.4良好
0.4-0.5优秀
> 0.5极好,但需警惕

AUC

AUC是ROC曲线下的面积,衡量的是模型排序能力。风控里一般要求AUC在0.7以上。

PSI(Population Stability Index)

PSI衡量的是模型分数在不同时间段的分布稳定性:

PSI = Σ (实际占比 - 预期占比) × ln(实际占比 / 预期占比)
PSI值稳定性
< 0.1稳定
0.1-0.25需关注
> 0.25不稳定,需重新训练

我一般会按月计算PSI,如果连续两个月PSI超过0.1,就会触发模型预警。

3. 实操过程与核心环节实现

3.1 数据加载与初步探索

先上代码。数据加载这块没什么好说的,pandas直接读:

import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns # 加载数据 df = pd.read_csv('loan_data.csv') print(df.shape) print(df['target'].value_counts()) print(df.isnull().sum().sort_values(ascending=False).head(20))

跑完这几行,你大概能知道:

  • 数据量多大
  • 违约率多少
  • 哪些字段缺失严重

我一般会再画几个图:

# 标签分布 df['target'].value_counts().plot(kind='bar') plt.title('Target Distribution') plt.show() # 数值变量分布 num_cols = df.select_dtypes(include=[np.number]).columns df[num_cols].hist(figsize=(20, 15), bins=30) plt.show()

这一步的目的是建立直觉。你得知道每个字段大概长什么样,有没有极端值,分布是不是偏态。

3.2 样本切分与标签构建

前面说了,不能随机切分。我的做法是按时间切:

# 假设数据里有放款时间字段 df['loan_date'] = pd.to_datetime(df['loan_date']) # 按时间排序 df = df.sort_values('loan_date') # 前70%做训练,后30%做测试 split_point = int(len(df) * 0.7) train = df.iloc[:split_point] test = df.iloc[split_point:] print(f"Train: {train.shape}, Test: {test.shape}") print(f"Train bad rate: {train['target'].mean():.4f}") print(f"Test bad rate: {test['target'].mean():.4f}")

如果训练集和测试集的违约率差异很大,说明时间切分点选得不好,需要调整。

3.3 特征分箱与WOE编码实现

分箱这块,我一般用optbinning这个库,比手写卡方分箱方便很多:

from optbinning import OptimalBinning # 选择需要分箱的变量 bin_vars = ['age', 'income', 'loan_amount', 'credit_score', 'debt_ratio', 'query_3m', 'query_6m'] # 对每个变量做分箱 binning_dict = {} for var in bin_vars: optb = OptimalBinning(name=var, dtype="numerical", solver="cp", max_n_bins=5) optb.fit(train[var], train['target']) binning_dict[var] = optb # 打印分箱结果 binning_table = optb.binning_table.build() print(f"\n{var} 分箱结果:") print(binning_table[['Bin', 'Count', 'Bad Rate', 'WoE', 'IV']])

跑完这段,你会得到每个变量的分箱表和WOE值。重点看两个东西:

  1. WOE是否单调:如果不单调,说明分箱有问题,需要调整。
  2. IV值大小:IV太低的变量可以直接砍掉。

分箱完成后,把WOE值映射回原始数据:

# WOE编码 for var in bin_vars: optb = binning_dict[var] train[var + '_woe'] = optb.transform(train[var], metric="woe") test[var + '_woe'] = optb.transform(test[var], metric="woe")

3.4 逻辑回归模型训练与评分卡转换

WOE编码之后,就可以上逻辑回归了:

from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, roc_curve # 选择WOE变量 woe_cols = [col for col in train.columns if col.endswith('_woe')] # 训练逻辑回归 lr = LogisticRegression(C=0.1, penalty='l2', solver='lbfgs', max_iter=1000) lr.fit(train[woe_cols], train['target']) # 预测 train_pred = lr.predict_proba(train[woe_cols])[:, 1] test_pred = lr.predict_proba(test[woe_cols])[:, 1] # 评估 print(f"Train AUC: {roc_auc_score(train['target'], train_pred):.4f}") print(f"Test AUC: {roc_auc_score(test['target'], test_pred):.4f}")

评分卡转换的公式:

Score = A - B × ln(odds)

其中:

  • odds = p / (1-p),p是违约概率
  • A和B是常数,通常设定为:当odds=1时,Score=600;当odds每翻一倍,Score减少50分
# 评分卡转换 def scorecard(pred_prob, A=600, B=50): odds = pred_prob / (1 - pred_prob) score = A - B * np.log(odds) return score train_score = scorecard(train_pred) test_score = scorecard(test_pred) print(f"Train Score Range: {train_score.min():.0f} - {train_score.max():.0f}") print(f"Test Score Range: {test_score.min():.0f} - {test_score.max():.0f}")

3.5 LightGBM模型对比与SHAP解释

LightGBM这块,我一般用默认参数先跑一版,然后再调:

import lightgbm as lgb # 准备数据 feature_cols = [col for col in train.columns if col not in ['target', 'loan_date']] X_train = train[feature_cols] y_train = train['target'] X_test = test[feature_cols] y_test = test['target'] # 训练LightGBM lgb_model = lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, max_depth=5, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) lgb_model.fit(X_train, y_train) # 预测 lgb_train_pred = lgb_model.predict_proba(X_train)[:, 1] lgb_test_pred = lgb_model.predict_proba(X_test)[:, 1] print(f"LightGBM Train AUC: {roc_auc_score(y_train, lgb_train_pred):.4f}") print(f"LightGBM Test AUC: {roc_auc_score(y_test, lgb_test_pred):.4f}")

SHAP解释性分析:

import shap explainer = shap.TreeExplainer(lgb_model) shap_values = explainer.shap_values(X_test) # 特征重要性 shap.summary_plot(shap_values, X_test, plot_type="bar") shap.summary_plot(shap_values, X_test)

SHAP图能告诉你:哪些特征最重要,以及每个特征对预测结果的影响方向。这个对于跟业务解释模型非常有帮助。

3.6 KS与PSI计算实现

KS计算:

def calculate_ks(y_true, y_pred): fpr, tpr, thresholds = roc_curve(y_true, y_pred) ks = max(tpr - fpr) return ks print(f"LR Test KS: {calculate_ks(y_test, test_pred):.4f}") print(f"LGB Test KS: {calculate_ks(y_test, lgb_test_pred):.4f}")

PSI计算:

def calculate_psi(expected, actual, buckets=10): # 等频分箱 breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf expected_percents = np.histogram(expected, breakpoints)[0] / len(expected) actual_percents = np.histogram(actual, breakpoints)[0] / len(actual) # 避免除零 expected_percents = np.where(expected_percents == 0, 0.0001, expected_percents) actual_percents = np.where(actual_percents == 0, 0.0001, actual_percents) psi = np.sum((actual_percents - expected_percents) * np.log(actual_percents / expected_percents)) return psi # 按月计算PSI test['month'] = pd.to_datetime(test['loan_date']).dt.to_period('M') for month in test['month'].unique(): month_data = test[test['month'] == month] psi = calculate_psi(train_pred, month_data['pred_prob']) print(f"{month} PSI: {psi:.4f}")

4. 常见问题与排查技巧实录

4.1 模型AUC高但KS低是怎么回事

这种情况我遇到过好几次。AUC高说明模型整体排序能力不错,但KS低说明在某个关键阈值附近,好坏样本的区分度不够。常见原因有两个:

一是样本不平衡太严重。如果违约率只有1%,AUC容易虚高,但KS上不去。解决办法是做重采样,或者调整样本权重。

二是分数分布太集中。如果大部分样本的预测概率都在0.05-0.15之间,那KS自然高不了。这时候需要检查特征工程,看看是不是WOE编码把信息压缩得太厉害了。

4.2 跨时间验证KS下降超过20%怎么办

这是风控模型最头疼的问题。我一般按这个顺序排查:

  1. 检查特征PSI:如果某个特征的PSI超过0.25,说明这个特征的分布发生了显著变化,需要考虑剔除或重新分箱。
  2. 检查样本定义:是不是表现期不够长,导致部分样本标签还不成熟?
  3. 检查模型过拟合:训练集KS和测试集KS差距多大?如果超过15%,说明过拟合严重,需要正则化或减少特征。
  4. 检查业务变化:是不是这段时间风控策略调整了,导致通过人群发生了变化?

4.3 WOE单调性不满足怎么调整

WOE不单调,说明分箱切点没选好。我一般这么调:

  • 合并相邻的箱,直到WOE单调
  • 如果合并后箱数太少(少于3箱),考虑换一种分箱方法
  • 如果怎么调都不单调,说明这个变量和标签的关系本身就很弱,直接砍掉

4.4 常见问题速查表

问题可能原因排查方法解决方案
AUC高但KS低样本不平衡、分数集中看分数分布直方图重采样、调整权重
跨时间KS下降大特征漂移、过拟合计算特征PSI剔除高PSI特征、正则化
WOE不单调分箱切点不合理看分箱表合并相邻箱、换分箱方法
模型上线后效果差数据穿越、样本定义错检查特征时间窗口重新定义观察期和表现期
PSI持续升高客群变化、策略调整按月计算PSI重新训练模型

4.5 实操心得与避坑技巧

最后分享几个我踩过的坑:

第一,不要迷信高IV。有些变量IV高达0.8,看起来很强,但跨时间验证时直接失效。我一般会限制单变量IV不超过0.5,超过的要么是标签泄露,要么是过拟合。

第二,WOE编码要用训练集的映射。测试集和线上打分时,必须用训练集算出来的WOE映射表,不能重新计算。否则会导致分数分布不一致。

第三,评分卡分数要定期校准。随着客群变化,同样的分数对应的违约概率会漂移。我一般每季度做一次分数校准,确保分数和风险的对齐关系稳定。

第四,模型监控比模型训练更重要。上线只是开始,后续的PSI监控、KS监控、特征漂移监控才是保证模型持续有效的关键。我见过太多团队模型上线就不管了,结果半年后坏账率飙升才发现模型早就失效了。

第五,业务解释性不能丢。哪怕你用LightGBM,也要准备一套SHAP解释工具,能随时跟业务说清楚“为什么这个客户被拒了”。否则策略同学不敢用你的模型,再高的KS也是白搭。

这个项目后续还可以扩展的方向包括:引入图神经网络挖掘团伙欺诈、用生存分析做动态违约预测、结合文本数据做非结构化特征提取。但不管怎么扩展,样本定义、特征工程、跨时间验证这三板斧,永远是风控模型的根基。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 10:22:29

多Agent系统面试必问:4种经典架构选型与话术指南

面试这事最怕的不是不会&#xff0c;是会了但说不出来。多Agent系统面试就是典型代表&#xff0c;面试官一句“你们的多Agent系统用的什么架构”&#xff0c;不少人脑子里翻涌着论文、框架、实践案例&#xff0c;开口却只剩“多个Agent一起配合干活”这种一句话答案。这个问题在…

作者头像 李华
网站建设 2026/10/22 10:22:27

从电源键到桌面:系统化拆解并解决电脑开机慢问题

前阵子帮人处理一台老电脑开机慢的问题&#xff0c;配置不算太差&#xff0c;但按下电源键到能顺利打开浏览器要一分半钟。我的第一反应和大多数人一样&#xff0c;进任务管理器关掉一堆启动项&#xff0c;结果重启一试&#xff0c;只快了几秒&#xff0c;问题依旧。后来换了个…

作者头像 李华
网站建设 2026/10/22 10:22:12

微服务还是模块化单体?架构选型的真实逻辑与团队适配指南

我见过太多团队在微服务这件事上栽跟头&#xff0c;不是因为他们技术不行&#xff0c;而是因为他们把架构选型当成了一种信仰。前几年微服务架构几乎成了“先进技术”的代名词&#xff0c;谁不在搞微服务谁就落后&#xff0c;谁还在写单体谁就是老古董。结果呢&#xff1f;我在…

作者头像 李华
网站建设 2026/10/22 10:21:57

HTTP协议从原理到实战:请求响应、缓存与性能优化全解析

老实说&#xff0c;我刚开始接触编程那阵子&#xff0c;虽然每天在浏览器里开网页开得飞起&#xff0c;但对 HTTP 超文本传输协议一直停留在“会用就行”的层面。直到有一回&#xff0c;某项目的首页加载慢得离谱&#xff0c;我在浏览器开发者工具里翻了一遍 Network 面板&…

作者头像 李华
网站建设 2026/10/22 10:20:02

TMP_SDF字体渲染数据来源解析:从字体文件到屏幕的完整链路

这是TMP_SDF分析系列里最容易被人跳过、又最值得先解决的一块&#xff1a;数据来源。很多朋友拿到一个文字发虚或者描边发毛的问题&#xff0c;第一反应是去改材质里的Spread、调Face属性&#xff0c;试了半天还是不对。我后来帮人排查才发现&#xff0c;真正的原因往往在烘焙阶…

作者头像 李华
网站建设 2026/10/22 10:19:20

Agent自动迭代的核心:反馈回路与系统约束实战

上周我帮某团队调试一个智能客服分流系统里的Agent模块&#xff0c;前后迭代了十几个版本&#xff0c;最后发现大家卡住的不是大模型本身&#xff0c;而是同一个问题&#xff1a;怎么让Agent自动迭代。写一个会对话的Agent很容易&#xff0c;让它按提示词一次性输出结果也不难&…

作者头像 李华