贷中风险预测模型避坑指南:从时间窗口到上线监控的完整链路
简介面向高校计算机相关专业毕业设计与大作业场景这份基于机器学习的贷中风险预测项目包含完整建模流程、源码、数据文件、配套文档与答辩演示适合需要完成同类课题或进行实战练习的学生。资源共50个文件压缩包约11MB核心包括19个Python脚本、5个Notebook分析笔记、11个CSV数据文件、4份Word文档、3个Excel结果表及2套PPT覆盖数据预处理、特征工程、模型训练与评估、结果导出等完整环节。已有62人学习使用。项目经导师指导并获高分参考金融大数据建模挑战赛赛题整理源码经本地编译调试可运行内容涵盖数据清洗、特征衍生、主流模型比对与结果分析并提供答辩展示材料便于快速理解贷中风险预测的实现路径和项目组织方法作为课程设计或毕业设计参考可显著节省从零搭建的时间。1. 贷中风险预测模型到底在预测什么为什么回测很好看的模型一上线就衰减贷中风险预测是信贷场景里比贷前评分卡更吃数据功底的建模任务贷前你只能拿到申请资料和外部征信贷中手里却握着客户过去半年到一年的真实还款行为。信息多了陷阱也多了。我见过不少同学用 python 基于机器学习的贷中风险预测模型回测 KS 做到 0.4 以上上线三个月 KS 直接跌到 0.2问题往往不是模型选得不好而是标签定义穿越、样本切分不干净。这里把这类项目从样本窗口、标签、特征、训练、评估到上线的完整链路捋清楚适合正在做贷中行为评分卡建模的从业者也适合拿这个题目做毕业设计或答辩项目的同学。希望在动手之前你能先避开那些最贵的坑。2. 先定样本窗口和标签观察点、表现期与坏客户定义2.1 三个时间窗口先对齐观察点、观察期、表现期贷中风险预测最常犯的错是还没把时间窗口画清楚就急着跑模型。所谓贷中是客户已经在贷、额度已经放出去之后每个月跑批一次判断这个客户在未来一段时间内会不会变坏。我们不是拿历史所有流水直接建模而是把账务历史切成三块观察点、观察期、表现期。观察点T是每个样本的评价时刻通常取每个自然月的月底观察期是 T 之前的一段时间比如 12 个月这里取特征表现期是 T 之后的 3 到 6 个月这里打标签。为了增加样本量常见做法是把每个账户的每个月度快照都当成一个样本一个用款两年的客户最多贡献二十多个样本。核心原则只有一条特征只能来自观察点之前标签只能来自观察点之后中间不允许交叉。先确认 python 环境里 pandas、numpy 这些基础包是齐的没装好的先把 python 开发环境搭出来再往下走。下面这个函数生成最基础的样本表注意 obs_months 传入你选定的那一批观察点月份。import pandas as pd def build_sample_base(acct_month, obs_months, perf_months3): acct_month: 账户月度快照字段至少包含 account_id, month obs_months: 观察点列表例如 2022-01 ~ 2022-10 各月 perf_months: 表现期月数贷中一般取 3 df acct_month[acct_month[month].isin(obs_months)].copy() df[obs_month] df[month] # 观察期起点观察点往前推 12 个月 df[feat_start] pd.to_datetime(df[obs_month]) - pd.DateOffset(months11) # 表现期终点观察点往后推 perf_months 个月 df[perf_end] pd.to_datetime(df[obs_month]) pd.DateOffset(monthsperf_months) return dfobs_month、feat_start、perf_end 三个字段就是后面所有特征和标签的时间围栏。观察期取 12 个月是贷中建模的经验值太短特征不稳定太长早期行为对当前风险的解释力已经衰减还会混入大量政策变化前的旧行为。如果手上数据只有半年退而求其次用 6 个月也行但特征含义要相应调整近12月最大逾期天数这类特征就别硬造了。2.2 好坏样本的判定规则别用是否逾期过当标签很多项目直接把客户历史上有没有逾期当 y这是非常危险的标签定义。一个账户用了三年第三年才逾期如果观察点在第一年这个样本其实是个好样本。所以贷中标签必须绑定表现期观察点之后、表现期内客户是否发生过指定程度的逾期。逾期程度用 M 几来描述M0 是未逾期M1 是逾期 1 到 30 天M2 是逾期 31 到 60 天M3 是逾期 61 到 90 天。贷中行为评分卡一般把表现期内最大逾期天数 30 天即 M2 作为坏样本。原因有两个M1 噪声太大很多人因为忘还、扣款失败晚了一两天不等于真实风险M2 已经能反映还款意愿或还款能力的实质性恶化。如果样本量足够可以再尝试 M3 做稳健性测试对比标签口径对模型排序能力的影响。还需要做样本排除观察点当天已经处于 M2 的账户不进样本表现期内已经结清、核销、转人工催收的账户要么排除要么单独打标。这些已经在坏状态的账户混进来等于把答案写进了训练集。下面这段代码用逾期流水表生成标签。def assign_label(sample_base, overdue_flow, perf_months3): overdue_flow: 账户-字段至少包含 account_id, month, max_overdue_days 返回在表现期内是否发生 M230天 start pd.to_datetime(sample_base[obs_month]) end start pd.DateOffset(monthsperf_months) # 只保留 (obs_month, perf_end] 区间的逾期记录 flow overdue_flow[(overdue_flow[month] start) (overdue_flow[month] end)] worst flow.groupby(account_id)[max_overdue_days].max() sample_base[y] sample_base[account_id].map(worst).fillna(0) sample_base[y] (sample_base[y] 30).astype(int) return sample_base两个容易忽略的点。第一month 要统一存成日期类型不要用 int 类型的 202201否则区间比较会出乱子。第二worst 取最大值而不是平均值贷中风险是非对称的客户只要坏一次就该被识别。标签定完之后务必看坏样本率整体坏样本率低于 2%后面要处理样本不均衡高于 15%大概率是观察点之前已经坏掉的客户混进来了回去查排除逻辑。2.3 特征工程先复现这几组被验证过的行为特征特征工程最容易写长也最容易骗自己。我的做法是先做一组被验证过有效的行为特征再根据业务理解增量而不是一上来堆几百个特征。贷中特征一般分四类特征组特征示例业务含义稳定性注意还款行为近3月M1次数、近6月最大逾期天数、近6月还款率直接刻画还款意愿和能力排序最强M1次数容易受扣款日影响分箱时单独处理0箱额度使用当前额度使用率、近6月平均使用率、使用率波动系数使用率突然飙升往往是资金链紧张信号节假日前后波动大注意看PSI负债与多头近1月贷款审批查询次数、近3月新增大额负债反映外部负债压力依赖征信数据源缺失率要排查行为稳定性还款金额变异系数、还款日偏移天数均值行为紊乱度辅助信号新客户数据短缺失用中位数填充下面计算两个最基础也最常用的特征后续所有特征都可以按同样套路叠加。关键点所有计算严格约束在 feat_start 到 obs_month 之间。def gen_repay_feats(sample_base, repay_flow): repay_flow: 账户-月还款流水需要 account_id, month, due_amt, repay_amt, overdue_days 生成近3月M1次数、近6月还款率 res sample_base.copy() s pd.to_datetime(res[obs_month]) - pd.DateOffset(months2) m3_m1 (repay_flow[(repay_flow[month] s) (repay_flow[overdue_days].between(1, 30))] .groupby(account_id).size()) res[m1_cnt_3m] res[account_id].map(m3_m1).fillna(0) s6 pd.to_datetime(res[obs_month]) - pd.DateOffset(months5) f6 repay_flow[(repay_flow[month] s6)] f6 f6.groupby(account_id).agg( due_sum(due_amt, sum), repay_sum(repay_amt, sum) ) res[repay_rate_6m] res[account_id].map( (f6[repay_sum] / f6[due_sum].replace(0, np.nan)) ).fillna(0) return res参数说明近 3 月 M1 次数在实操里通常非常稀疏大量客户取 0分箱时要让第 0 箱单独存在还款率分母为 0 用 NaN 填充再补 0避免出现无穷值。这类特征可解释性强贷中策略端非常看重这一点。特征做完后先跑一遍缺失率和取值分布缺失率高于 80% 的直接丢掉后续建模会省事很多。3. 从逻辑回归到 LightGBM用 python 机器学习常用包跑通建模流程3.1 选型逻辑为什么贷中主线用逻辑回归LightGBM 做交叉验证贷中模型的主流选择是逻辑回归不是因为它效果最好而是因为稳定、可解释、好上线。监管和内部审计要求你能说清楚每个特征的系数方向逻辑回归天然满足这个要求贷中数据量通常几十万样本起步特征经过 WOE 编码后线性可分性也不错逻辑回归的 KS 并不比树模型差太多。用 python 机器学习常用包跑这套流程核心是 sklearn 加 LightGBM 两类sklearn 提供逻辑回归、KS 计算和网格搜索LightGBM 负责验证非线性关系还能不能再挖一点。我一般把 LightGBM 当影子模型如果它的验证集 AUC 比逻辑回归高很多说明还有重要的非线性特征没挖出来如果只高 0.01那逻辑回归已经够用直接上线更省心。3.2 数据切分按客户和时间切不是随机切贷中样本里同一个客户会出现很多次如果随机切分同一客户的不同月份样本会同时出现在训练集和验证集里验证集 AUC 会虚高 0.05 到 0.1。正确做法是按客户分组切分并尽量让验证集在时间上晚于训练集模拟用上个月学到的规律预测下个月的真实场景。实操上我推荐两段式切分先按时间切出最后 20% 月份作为测试集剩余样本按客户分组做 5 折交叉验证调参。测试集最后只评估一次所有调参决策都基于验证集。from sklearn.model_selection import GroupKFold from sklearn.linear_model import LogisticRegression # 先按时间切测试集 train_df df[df[obs_month] 2022-08-31] test_df df[df[obs_month] 2022-08-31] X_cols [c for c in train_df.columns if c.startswith(feat_)] y_col y # 按客户分组做交叉验证避免同一客户泄露 gkf GroupKFold(n_splits5) for fold, (train_idx, val_idx) in enumerate( gkf.split(train_df, train_df[y_col], groupstrain_df[account_id]) ): Xtr, Xval train_df.iloc[train_idx][X_cols], train_df.iloc[val_idx][X_cols] ytr, yval train_df.iloc[train_idx][y_col], train_df.iloc[val_idx][y_col] model LogisticRegression(max_iter500) model.fit(Xtr, ytr) # 这里记录 val_auc后续用均值对比不同特征组合注意两个细节。第一GroupKFold 要求每个组里样本数不要差异过大某几个客户样本太少的话可以先按客户-表现期聚合后再切。第二切分后要检查训练集和验证集的坏样本率是否接近如果验证集坏样本率只剩 1%说明时间切点选得不对或者该时段贷后政策发生了大变化。3.3 逻辑回归的 WOE 分箱与训练让回归结果可解释直接拿原始特征喂逻辑回归也可以但效果和稳定性都不如分箱后做 WOE 编码。分箱让特征与坏样本率的关系变得单调、平滑减小极端值影响WOE 编码把每一箱映射成该箱客户相对总体是更优还是更坏的对数优势比。分箱常用两种方式等频分箱和决策树分箱。等频分箱简单但遇到取值高度集中的特征会分出很多空箱决策树分箱更贴近风险切点但容易过拟合。我一般先用等频分箱看分布再对业务关键特征比如额度使用率手工调整切点保证每箱坏样本率单调。import numpy as np def woe_encode(df, feature, targety, bins10): tmp df[[feature, target]].copy() tmp[bin] pd.qcut(tmp[feature], qbins, duplicatesdrop) g tmp.groupby(bin, observedFalse)[target].agg([sum, count]) g[bad] g[sum] g[good] g[count] - g[sum] g[bad_pct] g[bad] / g[bad].sum() g[good_pct] g[good] / g[good].sum() # 避免除零 g[bad_pct] g[bad_pct].replace(0, 1e-6) g[good_pct] g[good_pct].replace(0, 1e-6) g[woe] np.log(g[good_pct] / g[bad_pct]) g[iv] (g[good_pct] - g[bad_pct]) * g[woe] return g, g[iv].sum()WOE 的符号要重点检查额度使用率越高WOE 应该越低也就是越坏。如果某个特征的 WOE 方向和业务直觉相反且不是特征定义写反了这个特征不能直接进模型否则每次评审都要解释一遍为什么使用率更高的客户反而更安全。IV 值参考线小于 0.02 区分度很弱0.02 到 0.1 中等0.1 以上算强。贷中场景有还款行为类强特征IV 超过 0.3 并不罕见。WOE 编码做完后训练逻辑回归要注意多重共线性。我习惯先算 WOE 特征相关性矩阵绝对值超过 0.7 的两个特征只保留 IV 更大的那个训练完再打印每个特征的系数和标准误系数正负号异常的优先排查。3.4 LightGBM 参数设置与早停别一上来就 1000 棵树LightGBM 在贷中场景里通常用来做基准对比或最终提效。参数设置给一组常用起点值不是最优但是能稳定出结果的范围。import lightgbm as lgb params { objective: binary, metric: auc, learning_rate: 0.03, num_leaves: 63, max_depth: 6, min_child_samples: 200, subsample: 0.8, subsample_freq: 1, colsample_bytree: 0.8, reg_alpha: 0.1, reg_lambda: 1.0, verbose: -1, seed: 42 } dtrain lgb.Dataset(Xtr, labelytr) dvalid lgb.Dataset(Xval, labelyval, referencedtrain) model lgb.train( params, dtrain, num_boost_round2000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(50)] )参数常用范围作用与注意点learning_rate0.01~0.05调低通常涨点但训练轮数要增加数倍num_leaves31~127叶子数太大容易过拟合用 max_depth 配合限制max_depth4~8不是越大越好小样本特别容易崩min_child_samples100~500贷中样本量大设 100 以下容易学到极少数客户的噪声subsample / colsample_bytree0.7~0.9行采样和列采样双 0.8 起步reg_alpha / reg_lambda0.1~10特征多时加大正则特征少时作用不明显early_stopping 轮数我习惯设 100别设 500否则浪费训练时间还容易过拟合。LightGBM 结果还要和逻辑回归对比只高一点点的话上线成本高、可解释性差不值得换AUC 高出 0.03 以上就可以考虑双模型并行线上用逻辑回归做基础决策LightGBM 做辅助排序。4. 模型评估与上线前检查KS、PSI、过拟合三个关口4.1 KS 和 AUC 怎么算贷中阈值怎么定贷中模型评估不看准确率看排序和区分度。最常用的两个指标是 KS 和 AUC。KS 是累计坏样本率与累计好样本率差值的最大值刻画按分数排序后好坏客户被分开的最大程度AUC 本质是随机抽一个好客户和一个坏客户模型把坏客户排在好客户前面的概率。贷中的经验线是验证集 KS 大于 0.3、AUC 大于 0.75模型有可用价值KS 低于 0.25要么特征没做透要么好坏标签定义有问题。如果训练集 KS 0.45、验证集 KS 0.3过拟合已经很明显了。from sklearn.metrics import roc_auc_score def ks_score(y_true, y_prob): df pd.DataFrame({y: y_true, p: y_prob}) df df.sort_values(p, ascendingFalse).reset_index(dropTrue) df[cum_bad] df[y].cumsum() / df[y].sum() df[cum_good] (1 - df[y]).cumsum() / (1 - df[y]).sum() return (df[cum_bad] - df[cum_good]).max() ks ks_score(y_val, y_prob) auc roc_auc_score(y_val, y_prob) print(fKS: {ks:.4f}, AUC: {auc:.4f})阈值怎么定贷中输出预测概率后把验证集按概率从高到低排序画出通过率-坏账率曲线。业务方会给出目标坏账率比如月度贷中坏账率控制在 1.5%你反查对应的通过率和分数切点。一般决策不是概率大于 0.5 就拒绝而是拒绝预测风险最高的一批客户比如取分数最低的 5% 或 10% 客户。这样做业务量可控模型影响容易被观察。4.2 PSI模型上线前的特征稳定性测试KS 好不等于能上线。上线后面对的客户分布和训练时不一样贷中客户会随准入政策、额度策略变化。PSI 用来度量两个分布偏移程度。常用线小于 0.1 稳定0.1 到 0.25 有偏移需要关注大于 0.25 分布发生重大变化基本需要重建或重训。def psi_score(expected, actual, bins10): expected pd.Series(expected).dropna() actual pd.Series(actual).dropna() # 以 expected 的分位数为箱边界固定切分规则 edges np.quantile(expected, np.linspace(0, 1, bins 1)) edges[0], edges[-1] -np.inf, np.inf e_cut pd.cut(expected, binsedges, include_lowestTrue) a_cut pd.cut(actual, binsedges, include_lowestTrue) e_cnt e_cut.value_counts().sort_index() a_cnt a_cut.value_counts().sort_index() e_pct e_cnt / e_cnt.sum() a_pct a_cnt / a_cnt.sum() a_pct a_pct.reindex(e_pct.index, fill_value1e-6) psi ((e_pct - a_pct) * np.log(e_pct / a_pct)).sum() return psi这里最容易出错的是分箱方式一定先用训练集分布定箱边界再用同一批边界去切线上分布否则两边的箱边界不一致PSI 会被高估。如果报 Bin edges must be unique说明变量大量重复值先对 expected 做轻微扰动或改用 rank 分位。在特征工程阶段就可以用 PSI 做特征筛选剔除掉近1天还款次数这种预测能力尚可但天然不稳定的特征否则模型上线后每周都在报警。4.3 过拟合诊断训练集和验证集差距多大要警惕过拟合的常规判断是训练集和验证集指标差距。AUC 或 KS 差距在 0.05 以内属于正常泛化损耗0.05 到 0.1 要警惕超过 0.1 基本就是模型记住了训练数据里的噪声。出现这种情况优先调低模型复杂度而不是继续加特征或加大训练轮数。实操上我会把训练集和验证集的 KS 画在同一张图上一眼就能看出差距。LightGBM 的 feature_importance 也要检查如果排名最靠前的特征里有明显的时间属性比如最近5分钟还款次数这种快到分钟级的变量赶紧拿掉。贷中模型的特征必须经得起跨时间的稳定性考验时间粒度太细的特征天然不稳。# 输出 LightGBM 特征重要性按 gain 排序 imp pd.Series( model.feature_importance(importance_typegain), indexX_cols ).sort_values(ascendingFalse) print(imp.head(10))如果逻辑回归模型则直接看系数绝对值排名并核对前 5 个特征的方向是否符合业务直觉。贷中模型不需要上百个特征20 到 40 个经过筛选的、方向明确的特征往往比 300 个特征堆出来的模型更耐打。5. 避坑贷中建模最容易被坑的 5 个位置5.1 特征穿越回测 KS 很高上线直接失效现象回测 KS 做到 0.5 以上上线一个多月 KS 直接跌到 0.2 以下。原因特征或标签里混入了观察点之后的信息也就是特征穿越。最常见的是用逾期流水生成特征时过滤条件写错月份把表现期的数据也统计进去了还有一种是把客户当前是否 M2当特征这在某个时间点是已知状态但在观察点当天其实是未来信息。解决把观察期和表现期的日期围栏写成全局变量任何特征代码都从 sample_base 的 feat_start、obs_month、perf_end 三个字段取时间不手写月份。建模前做一次答案注入检测把表现期内的标签信息比如表现期内最大逾期天数临时伪装成特征放进模型如果这个伪特征重要性排第一说明时间围栏有漏洞回去查数据链路。5.2 同客户多月份样本串组验证集虚高现象随机切分后验证集 AUC 比按时间切高出 0.08看起来模型很棒上线后打五折。原因同一个客户在训练集和验证集里都有样本模型实际上在用该客户前几个月的行为预测后几个月的标签客户个体信息泄漏到验证集里。解决所有交叉验证都用 GroupKFold 按 account_id 分组测试集必须晚于训练集且测试集客户不得出现在训练集里。代码上只需要把 random split 全部替换成 group-aware split方案在 3.2 节已经给了。很多开源源码包默认用的是随机切分拿到别人代码第一步就是改这个。5.3 已逾期客户进样本等于把答案写进卷子现象标签坏样本率高达 25%模型 KS 很高但策略人员一看就说模型不能用。原因没有剔除观察点当天已经在逾期的账户。这些账户在表现期内大概率继续坏而且坏的状态在观察点就已知预测它们没有意义只是放大了模型的区分度。解决观察点当天逾期天数大于等于 30 的样本直接剔除只留当时还是好的、后来变坏的样本。贷中模型的目标永远是识别从好变坏的趋势而不是识别已经变坏的客户。识别后者是催收分群要解决的事不是风险预测。5.4 准确率陷阱99% 的准确率照样没用现象项目汇报时展示准确率 98%评审问 KS 和 AUC答不上来。原因贷中正样本率通常只有 3% 到 5%全预测不会逾期准确率也有 95% 以上准确率在这个场景里没有区分能力。解决汇报指标统一用 KS、AUC、Recall、PrecisionTop5% 这类。Top5% 查全率的含义是按预测风险从高到低拒绝最危险的 5% 客户能覆盖多少真实坏客户这个指标比准确率直观得多业务和评审都能听懂。源码包里如果只算了 accuracy记得自己补上这批指标。5.5 上线后不监控模型是被策略用废的现象模型上线半年后回测 PSI 0.35重新训练收益也不明显有人开始怀疑模型本身不行。原因线上策略在模型分数之外还叠加了人工规则和额度调整实际流入模型的客户分布早就不是训练时那个分布长期不重训排序能力自然会退化。解决上线前把分数分布存档上线后每周跑一次分数 PSI 和特征 PSI大于 0.25 就发警告同时记录模型命中率和策略实际拒绝率两条指标。分数分布漂移往往先于坏账率恶化出现监控脚本应当和模型部署一起交付不能等出了事再写。6. 从影子运行到答辩 PPT把模型讲清楚比调参数更值钱6.1 影子运行先双跑一个月再说新模型上线前我习惯让它跟老模型并行跑三十天只记录分数和决策结果不实际干预业务。这段时间用来观察新模型的分数分布是否稳定以及它挑出的高风险客户是不是业务想拒的那批。影子运行期不需要复杂平台一份脚本每天把新老模型分数拉出来做 diff 就够。def shadow_check(old_score, new_score, top0.05): old_top set(old_score.nsmallest(int(len(old_score) * top)).index) new_top set(new_score.nsmallest(int(len(new_score) * top)).index) print(top5%变化率:, len(old_top ^ new_top) / len(old_top))影子运行不只是技术验证也是给业务方的信心证据。连续 30 天风险人群重叠率稳定才敢切实际策略。6.2 答辩 PPT 和高分项目文档怎么讲拿现成源码和文档做答辩的同学最容易把 PPT 写成 API 文档。评审更想听的是判断过程为什么坏样本定义为 M2为什么观察期取 12 个月为什么不用随机切分每一个选择看一眼源码都知道但为什么这么选才是项目含金量。我的建议是 PPT 按四页讲主线第一页样本定义和时间窗口图第二页坏样本率、样本量、特征 IV 分布表第三页模型评估训练集验证集测试集的 KS/AUC 对比和阈值切分依据第四页上线后的稳定性监控计划。文档里把第 5 章那类踩坑记录写进去比漂亮的正样本率更让评审信服——真正的风控项目都是在坑里爬出来的。这套贷中风险预测模型的完整链路从时间窗口到上线监控每一步都比调参更值得花时间。做完之后最大的收获是你会对哪些信息在观察点当天真的已知产生肌肉记忆这个判断能力比任何模型里的超参数都值钱。希望帮到你。本文还有配套的精品资源点击获取