资讯动态

信贷风控数据分析实战:从数据清洗到风险打分模型

发布时间:2026/10/17 19:32:04 来源:尧图企业网站定制
去年年底我手里压着一批陈年信贷记录每天光处理逾期清单就要花掉大半天。数据躺在十几个 Excel 表里口径对不上字段名混乱审批结论基本靠老同事的直觉加一张模糊评分卡。我实在受不了这种状态干脆花了几周时间把它重构成一个完整的 financial-services 数据分析项目从数据清洗、特征工程到风险打分模型全部重做了一遍。这篇文章不是讲什么宏大平台就是把我这套个人项目从 0 到 1 的完整思路、踩坑过程、选型逻辑和真实数据复盘拆开给你看。如果你正在做信贷风控、客户分层、还款预测这类分析或者想摆脱纯手工 Excel 决策每天跟几百行 SQL 打交道的内容这篇很适合你。1. 我为什么要在个人场景里搞一套“金融服务”项目1.1 起点人工信审到底哪里不靠谱最开始这个项目并不是为了追什么热点纯粹是被业务逼出来的。老流程里一位审批员处理一笔借款申请平均要 10 到 15 分钟先翻流水、再打电话核实、最后凭经验填一张评分表。问题在于每个审批员的评分权重都不一样同一个申请人换个人审结论可能完全相反。更麻烦的是贷后表现从没被真正回看过谁批对了、谁批错了没人说得清。这种场景在小型消费金融团队里特别常见。大家嘴上都说“数据驱动”实际决策链路里数据的作用却非常小。我想要的不是做一个自动放贷机器人而是把所有历史申请、还款表现、客户资料拉到同一套标准下让每个决策都有迹可循。通过一个偏数据分析向的 financial-services 项目我可以把最耗时的信审环节变成“模型初筛人工复核”把主观因素降到最低。1.2 项目范围与交付形态动手之前我先定了项目边界避免做成一锅粥。这套项目最终交付四样东西一份可反复跑的清洗脚本输入原始导出表输出干净宽表一套统一的指标计算逻辑收入、负债率、历史逾期这些口径全部锁定一个可解释的风险打分模型能输出每个客户的违约概率一组阈值策略建议告诉业务侧“概率超过多少该拒绝、多少该人工复核”。技术栈非常简单Python 加 pandas、scikit-learn 就够用完全本地可跑。我刻意没有引入大数据框架原因后面会细说。这个项目最大的价值不在于用了多高级的算法而在于把从前散落在个人脑子里的审批经验全部转译成可验证、可迭代的量化规则。2. 数据集选型与那些必须踩一遍的格式化坑2.1 公开数据集的选型原则我没有银行生产库权限所以我用的是一份公开的信贷申请与还款记录样本数据量大概在几万条量级字段覆盖申请信息、财务状况、贷款金额、期限、利率以及最终是否发生严重逾期或核销。选它的核心原因只有一个这套数据从“申请时刻”到“贷后结果”是一个完整闭环正好对得上业务决策链条。选数据集时有几个坑很值得说。第一个坑是实现细节与网上教程对不上有些公开数据为了脱敏会把收入字段搅成指数形式有些干脆去掉你得自己重新算。第二个坑是时间跨度不够长如果样本只覆盖了放款后 6 个月那很多逾期行为根本没来得及暴露模型会严重低估坏账。我的建议是至少覆盖 24 个月以上的贷后表现期否则标签本身就有偏。2.2 清洗流程中的几个灾难现场金融数据的脏跟我以前处理过的其他领域数据完全不是一个量级。首先是金额字段年收入里混着逗号、美元符号、空字符串和科学计数法pd.to_numeric 一跑直接报错。我最后写了一段兼容性很强的正则清洗逻辑。import pandas as pd import numpy as np def clean_currency(series): return ( series.astype(str) .str.replace(r[\$,], , regexTrue) .str.replace(r\s, , regexTrue) .replace({: np.nan, nan: np.nan, NA: np.nan}) .pipe(pd.to_numeric, errorscoerce) ) df[annual_inc] clean_currency(df[annual_inc])这种看起来平平无奇的活恰恰是整个项目里单位时间内价值最高的部分。另一个灾难现场是目标变量的定义。同一个数据集里有人把“错过还款日 30 天”就算坏客户有人把“核销”才算坏。不同定义下坏账率能差出三倍模型完全是两个方向。2.3 目标变量的边界定义我最终把坏客户定义为“贷款存续期内出现 90 天以上逾期或者最终核销”。选 90 天而不是 30 天是因为 30 天窗口里混着大量临时性资金安排比如忘记还款、跨行转账延迟这些客户的真实信用水平远好于标签呈现出来的样子。90 天以上逾期说明债务人已经实质性丧失还款能力或还款意愿这个信号稳定得多。清洗阶段我踩过的第二个隐藏坑是特征穿越训练集里混进了“放款之后才产生的变量”。比如有些表会记录每个账户的当前余额这个数值已经包含了贷款发放后的资金流动拿它去预测放款决策就等于拿了考试答案复盘。所有进入模型的变量必须严格限制在申请时间点可获得的信息范围内这一点我强烈建议你在做任何金融风控项目时都拉一条硬红线。3. 特征工程不要迷信大数据三成有效特征就够用3.1 特征池搭建的底层逻辑很多新手拿到数据第一反应是把所有列都塞进模型然后堆一个 LightGBM 期待奇迹。这种做法在金融数据上尤其危险因为很多字段之间高度共线模型看似 AUC 不错实际上学到的是重复信号一旦某个源头字段下发延迟整个系统就崩了。我把特征整理成三大类每一类都对应一个明确的业务解释。偿还能力类月收入、负债收入比DTI、住房状态、工作年限偿还意愿类过去 30 天、60 天、90 天以上逾期次数、信用账户总数、近期硬查询次数贷款结构类贷款金额、期限、利率、分期数。偿还能力和偿还意愿的逻辑很直接一个是没有钱还一个是不想还这两个维度的特征要分开建。真正需要特别小心的是贷款结构类特征。贷款金额大、期限长、利率高这些既是风险信号又是机构定价的结果。借款人在申请时就面临不同报价接受高利率的人本身风险分布就更厚尾所以这类变量不能直接当风险因子用通常我会在模型里同时放入原始值和经过风险分位调整后的值避免双重计算。3.2 特征筛选IV 值加相关系数双层过滤我初版建了 40 多个特征最后只保留下 14 个。筛选方法不花哨先用 IV 值信息价值做一轮单变量排序IV 低于 0.02 的直接丢掉这种特征解释了不到 2% 的信息留着只会增加噪声。剩下的特征再算两两相关系数如果某组相关性超过 0.7只保留 IV 更高、业务含义更明确的那一个。还清未清比例和信用账户总数经常高度相关因为账户总数多的人通常有更多已结清账户也会有一些逾期小尾巴。单独看两者都有解释力放进同一个模型后反而互相干扰标准差被拉得很大。我保留了信用账户总数因为它的业务口径更稳定而“未清比例”在不同年龄段差异巨大容易引入年龄段偏差。3.3 连续变量的分段与某个容易忽略的细节连续变量直接喂给逻辑回归也不是不行但效果很不稳定。我对收入、DTI、贷款金额这些变量做了分箱处理然后计算每个箱子的 WOE 值。WOE 编码的好处在于把非线性关系和缺失值一并处理了而且在逻辑回归里每个箱子的贡献系数可以直接换算成风险倍数。def woe_bin(series, target, binsqcut): df_bin pd.DataFrame({x: series, y: target}) df_bin[bin] pd.qcut(series, q10, duplicatesdrop) grouped df_bin.groupby(bin, observedTrue).agg( bad(“y”, “sum”), total(“y”, “count”) ) grouped[good] grouped[total] - grouped[bad] grouped[woe] np.log( (grouped[good] / grouped[good].sum()) / (grouped[bad] / grouped[bad].sum()) ) return grouped分箱后有一个细节很多人会忽略分箱的切点必须用训练集确定再用同样的切点去切验证集和测试集。否则验证集分布看起来比真实情况平滑线上表现会大打折扣。我见过有人因为这个小细节在回测里多报了两个百分点的 KS后来一查全是在全局数据上做的分箱属于典型的泄漏。4. 模型训练与效果评测线下指标再漂亮也别急着上线4.1 基线模型为什么选逻辑回归这个项目我一开始就把 XGBoost 往后放先跑逻辑回归。金融风控的核心诉求不完全是精度而是可解释性。审批拒绝客户时需要能说清楚“为什么拒绝”监管和客诉场景下尤其如此。逻辑回归配合 WOE 编码每个特征的贡献可以直接增量拆解。XGBoost 不是说不能用而是解释成本太高SHAP 值虽然能说明重要度排序但到了业务侧很难转化成一条清晰的审批话术。4.2 训练集、验证集、测试集切分布局贷款数据切分必须按时间顺序绝不能随机切。随机切分等于给模型开了上帝视角。我按申请月份排序较早的 70% 做训练集中间的 15% 做验证集最近 15% 做测试集。这样做的意义是训练集模拟“我拿过去的数据建模”测试集模拟“我拿这个模型预测未来新客”和实际上线节奏完全一致。from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, classification_report train df[df[loan_issue_month] 截止2019-06-30] valid df[(df[loan_issue_month] 介于2019-07-01和2019-12-31之间)] test df[df[loan_issue_month] 晚于2020-01-01] model LogisticRegression(C0.1, max_iter500) model.fit(train[features], train[bad_flag]) valid_pred model.predict_proba(valid[features])[:, 1] test_pred model.predict_proba(test[features])[:, 1] print(“valid AUC:”, roc_auc_score(valid[bad_flag], valid_pred)) print(“test AUC:”, roc_auc_score(test[bad_flag], test_pred))4.3 实测踩坑几个模型的效果差距没那么大我在一份几万条样本上做过一组快速横向对比逻辑回归、随机森林、XGBoost 的 AUC 分别约为 0.73、0.75、0.76。表面看 XGBoost 赢了但拆开看测试集里的误判结构就发现问题了。增益主要来源于一些采样噪声较大的稀疏特征这些特征在训练集和测试集里表现很一致可一旦换到真正的新客批次里分布一变就完蛋。模型 | AUC | 稳定度(PSI) | 可解释性 | 结果 逻辑回归 | 0.73 | 0.02 | 强 | 基线选择 随机森林 | 0.75 | 0.04 | 中 | 备选 XGBoost | 0.76 | 0.06 | 弱 | 需谨慎这组数据不代表所有项目但它给了我一记提醒在小样本金融场景里复杂模型和线性模型的差距常常没有想象中大稳定性反而才是生命线。我保留了逻辑回归作为主模型把 XGBoost 留作集成对比而不是直接交付。5. 策略层阈值不是模型自动决定的5.1 坏客户只有 2%模型凭什么会偏向“全过”公开的信贷样本里坏客户比例通常低于 5%我这份数据只有 2% 左右。如果直接拿原始分布训练模型的预测概率会被严重扭曲它会倾向于把所有客户都判定为“好”。这不是逻辑回归独有的问题所有算法在极端不平衡下都会退化。解决办法并不复杂给少数类样本提高权重或者用 SMOTE 做合成过采样我用的是 class_weightbalanced简单好用不产生额外数据偏差。model LogisticRegression(C0.1, max_iter500, class_weightbalanced)调整权重之后模型的整体 AUC 变化不大但预测概率的分布明显分开了而且不同分位上的坏客户占比不再是躺平的 2%高分段能拉到 8% 到 10%。这才是风险模型真正该有的样子。5.2 拒绝推断被拒绝的人永远没有标签这里藏着金融风控里最隐蔽的坑。训练样本里的所有客户都是“已经放款的人”被拒绝的人根本不会出现在数据集里没有后续还款标签。如果直接用这批人训练模型只能学会区分“通过审核的人里面谁更坏”却完全不了解“被拒人群是什么样子”。最直接的后果是新模型对拒绝边缘客户的判断极不稳定因为这些客户的特征分布和训练集覆盖的分布完全不同轻微偏移就会引发极端预测。处理拒绝推断的方法有很多常见的是给被拒客户补一个“如果当年放了款大概会怎样”的代理标签或者做两阶段 Heckman 修正。这两类方法实现复杂度都不低。我在这份项目里只做了简单合规版把近三年被拒客户的分布特征记录下来在模型上线时作为监控基线之一。至少要知道模型打出的高分人群跟历史被拒人群重叠度是否在上涨如果持续上涨说明模型正在把一个不该放款的群体放进白名单这是需要立即回滚模型的红灯。5.3 把 0.5 当成拒绝线是最大的浪费金融策略里0.5 这个阈值几乎从来不是最优解。审批本质上是一个成本收益决策。放过一个坏客户损失的是本金加上资金占用成本错杀一个好客户损失的是这笔贷款产生的利息收入。两者之间差距很大正确的做法是根据当前资金成本、坏账回收率、平均单笔毛收益把所有可能阈值下的总收益算出来再选。我的样本里逻辑回归输出的风险概率分布长尾明显如果严格按概率大于 0.5 拒绝只能拒绝掉 0.6% 的客户风控形同虚设。如果把阈值放到 0.3拒绝率提升到 4.2%坏账率显著下降但也会误杀部分优质客户。真正的平衡点通常落在 0.08 到 0.25 之间具体取决于资金成本。这个决定不能拍脑袋要用历史资金成本率和单笔合同毛利反推。6. 从一次性分析到可持续运行的策略系统6.1 把脚本包装成日更批处理光做出一个离线模型价值只兑现了一半另一半在于让它持续运转。我的做法是把清洗、特征计算、模型预测全部封装成几个模块化函数然后用一个 scheduler 按天执行。每天拉取前一天的增量申请数据重新跑一遍特征生成和打分输出待审批名单和风险等级。调度工具没有刻意用 Airflow简单场景下 cron 加日志就够用复杂度越高稳定性反而越难保证。# run_daily_score.py from data_pipeline import load_new_applications, build_features from model import load_model raw load_new_applications() features build_features(raw) prob load_model().predict_proba(features)[:, 1] raw[risk_score] prob raw.to_csv(/output/daily_risk_scores.csv, indexFalse)这套批处理上线后审批时长从人均 12 分钟降到了 4 分钟业务员只需要把精力放在模型判断为“灰区”的客户上。这比任何调参都更直观地体现项目价值。6.2 模型监控里的 PSI 和 KS模型上线只是开始后面最常被忽略的是监控。我每个月计算一次风险分数的 PSI群体稳定性指数这个指标衡量的是“模型打分的分布”和“建模时训练集分布”的偏离程度。如果 PSI 超过 0.1说明近期客户构成发生了明显变化超过 0.25基本上训练集已经代表不了当前客群必须触发器去重训。另一个指标是 KS区分好客户和坏客户的能力有多少如果 KS 连续三个月下滑超过 20%就说明模型区分度在衰减不能等坏账率已经抬头了再补救。模型监控这件事非常枯燥但没有它前面的建模功夫会逐月贬值。我在工程上还做了一个记录模型输入特征日志的环节每个月导出一次各特征的分位数快照这样就算某天模型失效也能快速定位是哪一个特征分布先开始漂移的。最后的个人体会这套 financial-services 项目做下来我最深的感受是算法模型只是最后 20% 的工程前面数据口径、特征定义、标签边界才是真正的护城河。我踩过最大的一次坑是在特征清洗阶段不小心把“放款后账户状态”混进特征集导致回测 AUC 一度飙到 0.9后来定位到泄漏源之后真实水平回到 0.74。认清这个现实比任何模型调参都更刺痛也更清醒。另一个体会是做金融风控风格的数据分析宁可模型保守一点也要保证每个输出结论都能被回溯和解释。如果你正在做同类项目我建议你把大部分时间放在“定义坏客户”和“切分纪律”这两件事上做完这两步后面的一切都会顺很多。

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

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

免费获取报价 →
↑