简介:面向金融风控建模学习者,这份源码包聚焦大数据风控全流程,适合有志于信用评分、欺诈检测与风险预测的数据分析师或建模工程师。资源共一百零六个文件,包含四十个脚本、十九个模型、十六个样本数据、二十个可视化图表及三个示例,压缩包约十九点六七兆字节,脚本、数据、模型与说明文档层级分明。目前已有一千零一人下载学习。源码覆盖数据清洗与特征工程、逻辑回归与随机森林建模、异常检测与极端梯度提升欺诈识别、均值聚类客户分群以及模型验证与调参等关键环节,并配有可视化与实时风控接口示例,读者可结合数据直接运行,快速掌握从数据预处理到模型落地的完整路径。该资源不仅便于理解各类算法在金融场景中的应用,也为构建可上线风控系统提供了可参考的代码基线。
1. Python金融大数据风控建模实战:这个方向到底要解决什么问题
“Python金融大数据风控建模实战”这个技术方向,切入的是一类很具体的生产问题:信贷业务每天涌入几万条申请,每条申请带几百个原始字段,人工审批和固定规则在这个量级上要么太慢、要么坏账率失控。用Python把数据清洗、特征工程、模型训练、评分卡转换和上线监控串成一条流水线,就是这套实战方法的核心目标。它以数据量大、维度高、坏样本稀疏的信贷场景为对象,产出一个可解释、可迭代的信用评分模型,而不是一个只看AUC的黑匣子。
这类项目适合消费金融、信用卡、供应链金融等场景的算法工程师和策略分析师,也适合想从规则审批转模型审批的业务同学。对照“实战.zip”这个名字,你拿到的往往是数据、代码、文档三件套,但真正值钱的不是代码,而是代码背后那套数据口径和建模纪律。下面我按过去做这类项目的落地顺序,把宽度拼接、好坏定义、分箱编码、模型选型和最容易翻车的细节完整拆一遍。
2. 建模前的三道关:宽度拼接、好坏定义和时间切分
2.1 从原始表到建模宽表:先想清楚“一行代表什么”
拿到这类实战包,我习惯先不急着看代码,而是把数据摸一遍。第一步永远是环境确认,包里的代码大概率依赖pandas、numpy、scikit-learn和lightgbm,先一次性检查,是为了避免跑到半路才发现版本不对。
unzip Python金融大数据风控建模实战.zip -d risk_workspace cd risk_workspace python -c "import pandas, numpy, sklearn, lightgbm; print('deps ok')"这里的逻辑是先解压到独立目录,再验证依赖。lightgbm装不上时可以用sklearn的GradientBoostingClassifier先顶着,数据量不大时效果差距不明显。
接下来是数据形态。信贷原始数据通常分散在几张表里:申请信息表、征信查询表、历史还款表。建模宽表的原则是“一行代表一个申请”,同一个客户不同时间的申请要拆成多行。代码里最常见的翻车点是join时键不唯一,一对多膨胀让行数暴涨,模型训练到一半内存爆掉。所以我每次合并明细表之前,都会先做一次基数检查。
import pandas as pd app = pd.read_csv("application.csv", parse_dates=["apply_date"]) repay = pd.read_csv("repay_history.csv") n_before = len(app) app_repay = app.merge( repay.groupby("apply_id").agg( repay_count=("repay_date", "count"), repay_late_days=("late_days", "max"), ).reset_index(), on="apply_id", how="left" ) print(f"rows before {n_before}, rows after {len(app_repay)}")这段代码的关键是先对明细表做groupby聚合,把多种还款记录收敛到“申请”粒度,再按apply_id左连接。join键用apply_id而不用customer_id,是因为同一个客户历史上可能有多次申请,用客户维度合并会把上一次申请的表现带到本次申请,造成标签泄漏。repay_count是还款次数,repay_late_days是历史最大逾期天数,这两个字段就是最初级的申请行为特征。如果前后行数变化超过可忽略范围,基本可以断定键没选对。
2.2 好坏客户定义:观察期与表现期分离
建模宽表就绪后要做的第一件事不是写特征,而是定义坏客户。坏客户不是数据里天然的字段,需要业务口径换算。申请评分卡里最常见的定义是:申请通过后进入表现期,在表现期内出现任意一笔逾期超过30天的贷款标记为坏客户,其余为好客户。表现期长度直接影响标签质量:太短,坏样本还没暴露完整;太长,样本时效性差,模型服务的是三个月前的客群。常见选择是12个月,业务周期短的场景用6个月。
这里藏着风控建模最核心的一条纪律:观察期和表现期必须严格错开。特征只允许使用表现期开始之前的信息,标签来自表现期内的表现。如果特征里混进了表现期之后才产生的数据,模型在训练集上会非常漂亮,上线后直接崩盘,这一类坑后面专门展开。构造标签的示意代码如下:
app["bad"] = 0 expr_end = app["apply_date"] + pd.DateOffset(months=12) for i, row in app.iterrows(): overdue = repay[ (repay["apply_id"] == row["apply_id"]) & (repay["repay_date"] > row["apply_date"]) & (repay["repay_date"] <= expr_end[i]) & (repay["late_days"] >= 30) ] if not overdue.empty: app.at[i, "bad"] = 1这段循环写法是为了把口径讲清楚,真实工程里要向量化,否则几十万样本能跑很久。逻辑是取申请日到申请日加12个月内的还款记录,只要有一条逾期天数达到30天,该申请就被标记为坏客户。注意判断条件里repay_date大于apply_date,这保证了还款行为发生在申请之后;expr_end限制表现期终点,超过12个月的逾期不参与本期建模。表现期不满12个月的样本要单独标记为floating,不进建模集但进监控集;样本量小时不要直接剔除,保留floating可以在上线后跟踪回填标签,持续扩充训练数据。
2.3 时间切分:不许随机shuffle
风控数据天然带时间属性,训练集、验证集、测试集的划分方式和普通机器学习不一样。默认的train_test_split自带shuffle=True,在风控场景里必须禁用。原因很直接:模型上线后服务的是未来客群,如果用随机切分,等于让模型提前见过未来,评估结果乐观得没有参考价值。我一般按申请日期升序排列后直接切片:
app = app.sort_values("apply_date").reset_index(drop=True) cut1 = int(len(app) * 0.70) cut2 = int(len(app) * 0.85) train = app.iloc[:cut1] val = app.iloc[cut1:cut2] test = app.iloc[cut2:]这个切分的含义是:最早70%的样本训练,中间15%做验证调参,最后15%做最终测试。验证集只用来选参数和早停,测试集在整个建模周期里只允许碰一次,多碰几次测试集就变成了训练集的一部分。切片后还要检查好坏比例在三段里是否接近,如果时间窗口内有客群结构变化,比如最近几个月明显收紧审批导致坏率下降,那三段的比例差异是正常的,不需要强行对齐。
注意:时间切分后重新检查验证集的坏样本占比,如果验证集坏率明显偏低,模型早停会提前,需要整体重排切分点,而不是靠调参硬拉回来。
3. 特征工程与WOE编码:风控建模和普通ML建模的分水岭
3.1 为什么金融风控偏好WOE而不是直接喂原始值
普通机器学习项目拿到数值特征标准化就可以进模型,风控建模不行。原因有三个:第一,监管和业务部门要求模型可解释,评分卡里每一个变量都要能讲清楚“为什么这一箱分数高、那一箱分数低”,原始数值喂进逻辑回归只能告诉你系数正负,解释不了非线性关系;第二,信贷特征里缺失值占比高、长尾重,直接用原始值会把缺失变成一种隐性编码,业务上说不通;第三,风控主模型常用逻辑回归,逻辑回归在线性空间拟合log(odds),而WOE编码天然把特征转换成了对数尺度的“好坏比偏离度”,与逻辑回归的假设契合。
WOE的计算口径是:对每个分箱,计算好客户占比和坏客户占比的比值取对数。某个箱的好客户占比显著高于平均水平,WOE就是正数,反之是负数。IV值则是每个箱的“好客户占比减坏客户占比”乘以WOE再加总,用来衡量变量整体的预测力。风控里对IV值有一套约定俗成的解读:
| IV区间 | 预测力判断 | 处理建议 |
|---|---|---|
| <0.02 | 基本无预测力 | 直接删除 |
| 0.02~0.10 | 弱预测力 | 保留但组合使用 |
| 0.10~0.30 | 中等预测力 | 主力变量区间 |
| 0.30~0.50 | 强预测力 | 注意业务合理性 |
| >0.50 | 可疑 | 优先排查时间穿越和数据错误 |
这条线不是金科玉律,但足够用来做第一轮筛选。我见过不少项目把IV大于0.5的变量当成宝贝,结果一查是时间穿越或者字段口径错误,所以IV值过高反而要先警惕。另外要记住,IV筛选是单变量层面的初筛,不替代多变量模型,有些单变量IV低的特征组合起来对模型帮助很大,0.02只是参考线,不是硬阈值。
3.2 特征分箱:等频只是起点,单调性和箱内占比才是目标
WOE编码的前置步骤是分箱。最省事的做法是等频分箱,按分位数把连续变量切成样本量相等的若干箱。等频分箱在数值分布均匀的字段上表现还可以,但信贷特征普遍长尾,收入、负债率这类变量大部分客户挤在低位,少数客户拖出长尾,等频切出来会看到某几个箱的坏率忽高忽低,没有单调性。而评分卡要求连续变量的坏率尽量单调,至少要在业务逻辑上说得通。
我常用的替代方案是决策树分箱:用单个变量和坏样本标签训练一棵浅层决策树,叶子节点作为分箱边界。这样分箱会主动去拟合坏率变化,比等频更贴合目标,同时比卡方分箱更容易复现。示意代码:
from sklearn.tree import DecisionTreeClassifier def tree_bin(x, y, max_leaf=5): x2 = x.fillna(-999).values.reshape(-1, 1) clf = DecisionTreeClassifier( max_leaf_nodes=max_leaf, min_samples_leaf=0.02, criterion="entropy" ).fit(x2, y) return clf.apply(x2) train["age_bin"] = tree_bin(train["age"], train["bad"]) tmp = train.groupby("age_bin").agg( cnt=("age", "count"), bad_cnt=("bad", "sum") ) tmp["bad_rate"] = tmp["bad_cnt"] / tmp["cnt"] print(tmp)分箱边界由决策树自动决定,但使用前必须做两件事:检查箱内样本占比和坏率单调性。max_leaf_nodes限制节点数,防止树枝太细;min_samples_leaf=0.02要求每个叶子至少覆盖2%样本,保证箱子统计稳定。fillna(-999)只是让缺失值能进树,因为sklearn的决策树不接受NaN,如果原始数据没有-999这个合法值,缺失样本会聚到同一个节点,分箱完成后缺失自然独立成箱。分箱结束后检查bad_rate,如果不单调,先尝试合并相邻箱;合并后仍不单调,这个变量基本可以放弃,不要为了凑变量数硬留。
3.3 WOE计算与IV筛选:公式、平滑、剔除高相关
分箱完成后进入WOE计算。这里有个细节:每个箱的好客户数或坏客户数可能为0,直接取对数会产生inf,所以要加一个很小的平滑项。计算IV的公式是每个箱的(好客户占比减坏客户占比)乘以WOE后再求和。代码里要注意好和坏的定义跟业务约定一致:
import numpy as np def calc_woe_iv(df, feature, target="bad"): total_good = (df[target] == 0).sum() total_bad = (df[target] == 1).sum() tmp = df.groupby(feature, dropna=False).agg( cnt=(target, "count"), bad_cnt=(target, "sum") ) tmp["good_cnt"] = tmp["cnt"] - tmp["bad_cnt"] tmp["good_dist"] = tmp["good_cnt"] / total_good tmp["bad_dist"] = tmp["bad_cnt"] / total_bad eps = 0.0001 tmp["woe"] = np.log((tmp["good_dist"] + eps) / (tmp["bad_dist"] + eps)) tmp["iv"] = (tmp["good_dist"] - tmp["bad_dist"]) * tmp["woe"] return tmp["woe"], tmp["iv"].sum()groupby(dropna=False)可以把缺失值单独分成一组,这一箱的WOE如果绝对值很大,说明“是否缺失”本身有预测力,业务上通常解释为渠道来源差异或信息完整度差异。计算完所有变量的IV后,按IV降序保留大于0.02的变量,同时计算相关系数矩阵,把相关系数超过0.7的高相关特征成对删掉。逻辑回归对共线性很敏感,两个强相关变量同时进模型,系数符号可能互相抵消,评分卡就没法解释。这一步做完,特征集合一般能从几百个压到三四十个。
4. 模型训练与评估:从逻辑回归到LightGBM的选型路
4.1 逻辑回归基线:合规性优先
特征工程完成后,我先跑逻辑回归而不是一上来就上LightGBM。理由很简单:风控模型要过审计,逻辑回归的权重就是变量贡献度,评分卡转换也依赖log(odds)的线性展开。用WOE化后的特征训练逻辑回归,特征本身就是有序分箱,系数方向能直接解释业务方向。训练代码:
from sklearn.linear_model import LogisticRegression X_train = train_woe[feature_cols] y_train = train_woe["bad"] lr = LogisticRegression( penalty="l2", C=1.0, class_weight="balanced", max_iter=200 ) lr.fit(X_train, y_train) coef_df = pd.DataFrame({ "feature": feature_cols, "coef": lr.coef_[0] }).sort_values("coef", ascending=False) print(coef_df)C是正则化强度的倒数,C越小正则越强,默认1.0,后续可以用验证集上的KS做网格搜索。class_weight="balanced"自动按样本比例放大少数类权重,信贷场景坏样本占比通常在3%到8%,这个参数比手工下采样省事也更安全。系数方向检查是这步最容易忽略的:把每个变量按业务常识判断预测方向,比如收入水平越高、坏率越低,在WOE编码下系数通常为负;逾期次数越多、坏率越高,系数通常为正。如果整体方向和业务常识相反,别怀疑常识,回去查分箱方向和WOE里好坏占比是不是写反了。
4.2 LightGBM:大数据量下的性能补充
逻辑回归的缺点是只能捕捉线性关系。信贷数据里交叉特征很多,年龄和负债率、收入和工作稳定性等组合往往比单变量更有效,LightGBM这类梯度提升树天然擅长找交叉。在几十万样本、几百维特征上,LightGBM的训练速度比XGBoost快一个数量级,内存占用也低,所以大数据风控里更常见。训练参数的设置有风控特点:
import lightgbm as lgb model = lgb.LGBMClassifier( num_leaves=31, learning_rate=0.05, min_data_in_leaf=200, feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1, scale_pos_weight=20, n_estimators=500, verbose=-1 ) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(0)] )min_data_in_leaf=200是这里最重要的防过拟合参数。信贷数据噪声大,叶子太小会把个别异常客群记住,线上表现差的往往就是这批叶子。scale_pos_weight=20表示把坏样本权重放大20倍,近似平衡类别,这个值通常取“好客户数除以坏客户数”的比值。feature_fraction和bagging_fraction分别是每棵树的列采样和行采样比例,0.8是常见起点。早停基于验证集,回调里的50表示连续50轮验证集指标不提升就停止训练。
LightGBM模型一般不会直接上线当评分卡,而是用来做两件事:一是跟LR做特征交叉,常见做法是让LightGBM输出叶子编码向量拼进LR特征,既保留树的交叉能力,又维持LR的可解释主结构;二是通过特征重要性帮助筛选和验证LR的特征集合,如果LightGBM的重要性排名和LR的系数方向明显冲突,优先查特征口径而不是模型问题。
4.3 评估模型:KS、AUC和Gini,别只看一个数
风控模型有自己偏好的评估指标。AUC是全局区分能力,KS刻画好坏客户累计分布的最大距离,Gini是AUC的线性变换。三个指标从不同角度看同一个模型,评估时都要看。常规参考线:
| 指标 | 常见参考值 | 说明 |
|---|---|---|
| KS | 0.3以上 | 好坏分布分离度,上线门槛常用0.3 |
| AUC | 0.75以上 | 随机好坏样本排序正确的概率 |
| Gini | 2*AUC-1 | 业务沟通时使用较多 |
KS的计算和绘图是风控日常最高频的操作。一个计算KS的稳定实现如下:
from sklearn.metrics import roc_auc_score def calc_ks(y_true, y_score): df = pd.DataFrame({"y": y_true, "s": y_score}).sort_values("s").reset_index(drop=True) total_bad = df["y"].sum() total_good = len(df) - total_bad df["cum_bad"] = df["y"].cumsum() / total_bad df["cum_good"] = (1 - df["y"]).cumsum() / total_good return (df["cum_bad"] - df["cum_good"]).abs().max() train_woe["score"] = lr.decision_function(X_train) test_woe["score"] = lr.decision_function(X_test) print("train ks:", calc_ks(train_woe["bad"], train_woe["score"])) print("test ks:", calc_ks(test_woe["bad"], test_woe["score"]))实现思路:把样本按模型分数排序,然后逐行累加坏客户占比和好客户占比,两者差值的最大绝对值就是KS。这个函数比封装好的方法更可控,方便你排序后在某个分数区间截取看分布。使用时有两点提醒:第一,验证集和测试集相差不要太大,测试KS比训练高0.15以上基本可以认定有泄漏;第二,同一个模型在不同客群切片上的KS差异很大,比如有房和无房客群区分度不一样是常态,不用强迫完全一致。
5. 风控建模避坑指南:5个容易翻车的环节
5.1 风控模型的失败模式:大多数坑不在算法在数据
风控建模跟普通ML项目最大的区别是,数据口径几乎决定模型生死。第2章铺垫的观察期、表现期、时间切分,没有哪一条是复杂算法,但每一条都足以让模型上线后崩盘。下面这5条是这类项目里反复出现的血泪经验,每一条都至少对应过一次返工,建议动手前把对照清单过一遍。
5.2 时间穿越:特征里混入了未来信息
现象是训练集AUC冲到0.95以上,LightGBM的特征重要性榜首出现“当前逾期天数”这类字段,模型上线后对新申请客群的区分度直接断崖式下跌。原因不是模型玄学,而是特征表里有一列叫“截至提取日期的逾期状态”,提取日期晚于表现期结束,等于在用未来回答过去的问题。解决方法是构造特征时强制设置特征截止日,只取申请日之前的数据;上线前用历史截断数据做一次回测,新客审批场景尤其要把“是否用到审批时点之后才产生的字段”作为代码评审的必查项。
5.3 样本不平衡被过度矫正,分数分布跟着偏
现象是下采样之后模型在验证集上KS从0.42涨到0.48,但回到原始样本分布里看分数,整体分数偏高,低分段几乎没有客户。原因是下采样把坏样本比例从5%强行提到50%,模型学到的先验概率完全失真,分数整体被推高,切分阈值自然不准。解决方法是优先用class_weight或scale_pos_weight做类别平衡,不要物理删样本;如果出于性能必须下采样,训练完成后要按原始好坏比例重新校准截距或分数映射参数,再做KS和分数分布验证。
5.4 缺失值一填了之,“缺失”本身就是信号
现象是一个IV排名前五的变量,人工经验却觉得这个字段不该有这么大区分度,查下来发现40%的客户在该字段上是空值。原因是建模时直接fillna(0)或fillna(均值),把“空值”和“真实值0”混为一谈,缺失本身携带的渠道差异和信息完整度信号全部消失。解决办法是WOE分箱时把缺失单独成箱,不做填充;同时看IV高的变量要盯每一箱的样本占比,缺失箱占比过高的变量要小心,它反映的更可能是数据质量差异,而不是客户信用差异,上线后一旦数据收集策略变化,这个变量会最先失效。
5.5 随机切分训练验证集,动态回溯就翻车
现象是模型在测试集上AUC和KS都达标,按月份滚动回测时最近三个月的KS连续下滑。原因很直接:train_test_split默认随机抽样,把未来月份的样本混进了训练集,模型见过未来,评估指标自然乐观。解决方法是回到第2章的时间切分方案,按申请日期升序切片;同时上线前的动态回溯必须纳入发布检查清单:用最近三个月样本单独测KS,如果比训练集KS衰减超过20%,先排查是客群漂移还是模型失效。
5.6 分数当概率用,风险汇报差两个数量级
现象是业务团队把评分卡分数除以某个常数直接当成逾期概率汇报,结果汇报数字比实际坏账率低两个数量级。原因是评分卡分数是Odds的对数线性变换,只保序不保数值,分数高低能反映相对风险,但不是概率本身。解决办法是交给业务前必须先通过第6章的公式把分数换算成Odds,再换算成概率;对外统一口径为分数段和对应坏账率,文档里不要出现“概率”两个字以免误导。
6. 把模型变成业务能用的评分卡:分数映射与上线验证
6.1 评分卡分数映射:PDO和Offset怎么定
评分卡的本质是把逻辑回归输出的log(odds)做一个线性变换,变换到业务习惯的分数区间。两个自由参数是PDO,即每个Odds翻倍对应的分数增量,和基准分,即某个指定Odds对应的分数。业界常见设定是:Odds=1:20时分数600,Odds翻倍时分数加20。
factor = 20 / np.log(2) offset = 600 - factor * np.log(1 / 20) # score = offset + factor * log(odds) # odds = exp(b0 + sum(bi * woe_i)) X_woe = test_woe[feature_cols] score = offset + factor * (lr.intercept_[0] + X_woe.dot(lr.coef_[0]))这里的factor对应PDO=20除以ln2,offset由基准分600和基准Odds=1/20反推。两个参数可由风险策略自由设定,修改它们只改变分数刻度,不改变排序,但会影响后续审批阈值。转换完成后,每个WOE箱可以按系数拆出分数贡献,形成一份业务能读懂的评分卡明细表。
6.2 上线前两个验证:PSI与滚动回测
分数映射完不能直接上线,至少要过两道验证。第一道是PSI(群体稳定性指数),衡量模型分数在训练样本和当前新样本上的分布偏移。第二道是滚动回测,把最近三个月的数据按审批通过后的表现重新打标签,用KS衰减判断模型是否还适用。PSI的计算很简单:
def calc_psi(expected, actual, bins=10): exp, _ = np.histogram(expected, bins=bins) act, _ = np.histogram(actual, bins=bins) exp_pct = exp / exp.sum() act_pct = act / act.sum() eps = 1e-6 return ((act_pct - exp_pct) * np.log((act_pct + eps) / (exp_pct + eps))).sum()| PSI区间 | 稳定性判断 |
|---|---|
| <0.1 | 稳定,可正常使用 |
| 0.1~0.25 | 有漂移,需关注变量变化 |
| >0.25 | 显著恶化,建议排查重训 |
我自己现在的习惯是,不管项目多急,评分卡上线前必跑一遍PSI和滚动回测。早期在模拟项目X上吃过一次亏:训练时KS稳定在0.43,上线后第二个月PSI直接到0.31,查下来是某第三方数据源缺失率突变,缺失箱样本占比从5%跳到35%,评分卡在低分段挤了一大堆本不该拒绝的客户。后来我把所有高IV字段的缺失率也纳入每周监控,评分卡加一个简单规则:缺失率周环比超过50%触发告警。这条经验帮我少返了好几次工,希望帮到你。
本文还有配套的精品资源,点击获取