简介:这是面向金融风控场景的机器学习贷中风险预测项目包,源自“江苏银行杯”金融大数据建模挑战赛初赛方案,适合高校人工智能、金融科技相关专业学生、教师及从业者学习参考。压缩包共49个文件,约10.97MB,包含19个Python脚本、5个Jupyter Notebook、3个Excel结果表、3个Word文档、2个PPT报告及多个CSV/TXT数据文件,覆盖数据清洗、特征工程、模型训练到结果输出的完整流程。其中代码模块按决策树、随机森林、XGBoost、LightGBM、朴素贝叶斯、AdaBoost等算法分别封装,并附赛题说明、字段释义、初赛方案和项目介绍文档,便于将业务理解与代码实现相互对应。目前已有69人学习下载,适合作为毕业设计、课程设计或金融风控建模入门进阶的实用参考。
1. 贷中风险预测模型,和贷前评分卡不是一回事
“贷中风险预测模型”这四个字,做过风控的人都知道,和贷前评分卡完全不是一套打法。贷前判断的是“这个客户能不能放”,贷中判断的是“已经放出去的客户,未来三个月会不会变坏”。很多团队把贷前的逻辑直接套到贷中,标签、样本、特征全部错位,模型训练出来AUC很高,上线之后额度调整策略却一塌糊涂。这个标题里的Python源码加上金融应用文档和PPT报告,属于一套比较完整的工程交付物,核心是解决贷中业务的三个问题:谁是高风险的存量客户、该不该降额、催收优先级怎么排。适合刚接手贷中建模的风控算法工程师、策略分析师,也适合想从贷前评分卡转到贷中方向的从业者。
2. 贷中建模前先定义样本:观察点、表现期和样本漏失
2.1 贷中模型与贷前模型的本质差异:预测目标、样本口径和业务动作
贷中模型和贷前评分卡虽然都叫“风险模型”,但业务问题完全不同。贷前只回答一件事:这笔申请该不该批。贷中要回答的是:这个客户已经在还款了,接下来会不会逾期,我要不要调整他的额度、利率,或者把他放进催收队列。
| 对比维度 | 贷前评分卡 | 贷中风险预测模型 |
|---|---|---|
| 预测目标 | 首次违约概率 | 未来1-3个月内逾期概率 |
| 样本来源 | 申请件+征信报告 | 存量在贷客户的月度快照 |
| 特征来源 | 申请表、征信、外部数据 | 还款行为、交易行为、账户状态 |
| 打分频率 | 审批时一次性 | 每月或每周滚动评分 |
| 业务动作 | 通过、拒绝、人工复核 | 额度调整、利率定价、催收分桶 |
| 表现期 | 6-12个月 | 1-3个月 |
表现期短是贷中模型最鲜明的特点。贷前你有耐心等客户还款六个月再判断他是好是坏,贷中等不起——客户已经在你手里,每天的额度敞口都在消化,风险早一天识别,损失就少一截。所以贷中模型的标签窗口普遍是90天,极端场景甚至看30天内的逾期表现。
样本口径的差异更关键。贷前模型用的是“申请件”,每笔申请是独立样本。贷中模型用“存量在贷客户的月度快照”,同一个客户在不同的观察点会重复出现,形成面板数据。这意味着不能简单做随机切分训练集和验证集——同一客户在不同月份的信息会交叉泄漏,时间切分是必须的。
2.2 做观察点切分与表现期标注:Python源码里最核心的预处理逻辑
没有做过贷中建模的人,第一步就容易栽在“样本怎么切”上。常见做法是:把存量客户在每个自然月月初拍一张快照,这个快照时间点叫观察点。观察点之前的历史行为数据用来生成特征,观察点之后的表现用来打标签。
import pandas as pd import numpy as np # loan_info: 每笔放款的起贷日、结清日 # repay_plan: 每笔贷款的应还日、应还金额、实还日期、实还金额 # 目标: 生成存量客户在指定区间内每个月初的观察点快照 def generate_observation_points(loan_info, start_date, end_date): obs_dates = pd.date_range(start_date, end_date, freq='MS') # 每月月初 records = [] for obs in obs_dates: # 只有存续期内的贷款才纳入: 起贷日 <= 观察点 <= 结清日 mask = (loan_info['loan_start'] <= obs) & (loan_info['loan_end'] >= obs) tmp = loan_info.loc[mask].copy() tmp['obs_date'] = obs records.append(tmp) return pd.concat(records, ignore_index=True) obs_df = generate_observation_points(loan_info, '2023-01-01', '2023-12-01') print(obs_df[['loan_id', 'obs_date', 'loan_start', 'loan_end']].head())这段代码做的是最底层的事:把“在贷客户”这个动态群体,变成一组静态快照。用月初而不是月末,是考虑到月末结账日前后数据源经常有批量调整,月初快照更稳定。观察点间隔一个月,和贷中策略的月度重评节奏对齐。
打标签时,要严格限定表现期窗口和逾期阈值。
# 表现期标签: 观察点之后90天内是否出现逾期(逾期天数 >= 30) def make_label(repay_plan, obs_df, perf_days=90, overdue_th=30): obs_df['label'] = 0 for i, row in obs_df.iterrows(): obs = row['obs_date'] end = obs + pd.Timedelta(days=perf_days) plan = repay_plan[ (repay_plan['loan_id'] == row['loan_id']) & (repay_plan['due_date'] > obs) & (repay_plan['due_date'] <= end) ] # 实还日期为空(未还)或实还日期晚于应还日超过阈值, 判为坏客户 bad = plan[ plan['repay_date'].isna() | (plan['repay_date'] > plan['due_date'] + pd.Timedelta(days=overdue_th - 1)) ] if len(bad) > 0: obs_df.at[i, 'label'] = 1 return obs_df obs_df = make_label(repay_plan, obs_df) print(obs_df['label'].value_counts())这段代码是示意,真实生产环境里我一般会用SQL在数仓里直接join,Python版适合在抽样数据上做原型验证。代码里两个参数值得盯住:overdue_th=30代表“M1逾期”作为坏标准,overdue_th=1则代表“任何一期逾期1天就算坏”,后者噪声大但召回更高;perf_days=90是表现期,拉长到180天坏样本会更“干净”,但样本量会少一大截,而且贷中业务等不了那么久。
还有一类客户必须单独统计:观察点之前已经结清或核销的贷款。它们进不了样本,但它们往往是“已经变坏”或者“已经流失”的客户,把它们漏掉,模型学到的只是“还在我手上这批人的风险”,这就是样本漏失。我见过一个团队用全量放款客户跑贷中模型,AUC做到0.92,上线后坏账率一点没降——因为训练数据里根本没有提前结清客户的“已结清”状态,模型天然偏向稳定还款人群。
3. 贷中行为特征工程:用Python把还款行为变成特征
3.1 滑动窗口特征:3个月还款行为趋势为什么比全量累计更稳
贷中模型的特征来源比贷前丰富得多——你手里有客户每一期的还款记录、额度使用情况、交易行为。但这些原始数据不能直接用,要做成特征。最常见的坑是把全量累计值当特征,比如“累计逾期次数”“历史还款总期数”。这类特征有个问题:它让模型对老客户天然打分偏高(因为老客户历史记录长),但老客户近期的行为变化反而被稀释了。
我一般用滑动窗口特征替代全量累计。核心思路很简单:只看最近3期或最近6期的行为,不看全部历史。
# 还款行为特征: 最近N期的逾期次数、逾期比例、提前还款平均天数 def build_repay_features(repay_plan, obs_df, windows=[3, 6]): feat_list = [] for loan_id, obs_date in zip(obs_df['loan_id'], obs_df['obs_date']): hist = repay_plan[ (repay_plan['loan_id'] == loan_id) & (repay_plan['due_date'] < obs_date) ] row_feat = {'loan_id': loan_id, 'obs_date': obs_date} for w in windows: recent = hist.nlargest(w, 'due_date') delq_cnt = (recent['repay_date'] > recent['due_date']).sum() row_feat[f'recent{w}_delq_cnt'] = delq_cnt row_feat[f'recent{w}_delq_ratio'] = delq_cnt / max(len(recent), 1) row_feat[f'recent{w}_avg_advance_days'] = ( recent['due_date'] - recent['repay_date'] ).dt.days.mean() feat_list.append(row_feat) return pd.DataFrame(feat_list) repay_feat = build_repay_features(repay_plan, obs_df) print(repay_feat.head())注意两个细节。第一,窗口内实际期数不足时,比如客户刚放款3个月,recent可能只有1-2条,分母用max(len(recent), 1)而不是固定窗口长度,避免特征被低估。第二,delq_cnt是计数、delq_ratio是比例、avg_advance_days是均值,三种口径都留,模型自己去学哪个更重要。
为什么要窗口滚动而不是直接取“最近一个月的状态”?因为贷中风险往往是一个渐变过程——连续两个月逾期但第三个月恢复正常,和连续三个月稳定还款,两种客户的未来风险差异很大,单看某一期看不到这个趋势。
3.2 账户维度特征:额度使用率、还款及时性与逾期天数分段编码
除了还款行为,账户状态特征是贷中模型最稳定的信号来源。放款金额、剩余本金、剩余期数、额度使用率,这些字段在贷后管理里每天都有快照,拿来做特征非常顺手。
# 账户画像特征: 观察点时点的账户快照 def build_account_features(account_info, obs_df): df = obs_df.merge( account_info, on='loan_id', how='left' ) # 额度使用率: 剩余本金 / 授信额度 df['credit_util_ratio'] = df['outstanding_principal'] / df['credit_limit'] # 剩余可用额度比例 df['payment_buffer_ratio'] = ( df['credit_limit'] - df['outstanding_principal'] ) / df['credit_limit'] # 剩余期数占比 df['remaining_term_ratio'] = df['remaining_terms'] / df['total_terms'] return df acct_feat = build_account_features(account_info, obs_df) print(acct_feat[['loan_id', 'credit_util_ratio', 'remaining_term_ratio']].head())额度使用率这个变量在贷中模型里几乎永远是Top 5特征。当使用率超过85%时,客户的风险会非线性上升,因为这意味着客户手上几乎没有缓冲额度,任何收入波动都可能直接导致逾期。payment_buffer_ratio和credit_util_ratio在数学上互补,但业务含义有细微差别——前者更接近客户“留有余地”的程度,建议两个都保留,不要嫌冗余。
逾期天数的处理也有讲究。如果直接把当前逾期天数放进模型做连续特征,模型会认为D+29和D+30差别巨大,实际上催收动作一过,这两个状态的风险差异很小。分段编码更符合业务直觉。
# 当前逾期天数分段编码 def encode_overdue_days(days): if days < 0: return 0 # 提前还款 if days == 0: return 1 # 正常 if days <= 3: return 2 # 容时期 if days <= 15: return 3 # 提醒期 if days <= 30: return 4 # M1催收期 return 5 # M2及以上 obs_df['overdue_level'] = obs_df['current_overdue_days'].apply(encode_overdue_days)还有一个容易忽略的边界:特征必须严格使用“观察点当时已知”的信息。比如“观察点当月是否已还款”这个特征,如果观察点是月初,客户当月的还款行为还没发生,这个特征就不能用。我见过有源码包把“截至最新日期的账户快照”和“截至观察点的快照”混在一起用,导致特征穿越——模型在训练时看到了未来,上线后就没那么灵了。账户类特征必须带上as_of_dt(数据日期),取数时强制过滤到观察点当天或之前。
4. 模型训练与验证:LightGBM在贷中场景的参数设置
4.1 为什么选LightGBM而不是逻辑回归:非线性与特征交互
贷中模型的特征以行为特征为主,这些特征和风险之间不是简单的线性关系。额度使用率在85%以下时风险平缓,超过85%后急剧上升;还款及时性和期数之间也存在交互——新客户第一期逾期和老客户第五期逾期的含义差别很大。逻辑回归能解释这些现象,但需要手工做分箱和WOE编码,工作量大而且容易漏交互项。
| 模型 | 优势 | 劣势 | 贷中场景适用性 |
|---|---|---|---|
| 逻辑回归 | 可解释、上线快、稳定 | 需要手动分箱WOE、难捕捉交互 | 适合强监管、简单产品线 |
| LightGBM | 非线性强、自动交互 | 需要防过拟合、需要监控 | 适合行为特征丰富的存量客户 |
| XGBoost | 精度略高 | 训练耗时 | 样本量大时可作为备选 |
| 深度模型 | 自动特征工程 | 数据量要求大、解释性差 | 贷中场景通常收益有限 |
我的做法是双轨并行:第一版用逻辑回归跑通全链路,作为影子模型和上线基准;最终模型用LightGBM。逻辑回归留作冠军挑战者实验里的对照组,LightGBM负责真正调额决策。这样做的好处是,即使LightGBM上线后分数分布漂移导致策略异常,你还有一套稳定的逻辑回归分数可以用来排查是模型问题还是客群变化。
深度模型在贷中场景我一般不碰。存量客户规模通常几十万到几百万,特征已经是人工构造过的强变量,深度学习的增益有限,却要付出解释性和稳定性的代价。风控模型过审时,监管和业务方一定会问“为什么给这个客户降额”,可解释性在贷中比贷前更重要。
4.2 训练、调参与时间外验证:源码级别的参数说明
贷中模型的训练代码不复杂,复杂的是验证方式。随机K折在这个场景是错的——同一客户在不同月份的样本会被分到训练集和验证集,模型“见过”该客户的行为模式,验证集AUC虚高。我强制要求按观察点月份做时间外验证。
import lightgbm as lgb from sklearn.model_selection import train_test_split feature_cols = [c for c in train_df.columns if c not in ['loan_id', 'obs_date', 'label']] # 时间外验证: 最后两个月作为验证集, 模拟上线后的时间差 train_part = train_df[train_df['obs_date'] < '2023-10-01'] valid_part = train_df[train_df['obs_date'] >= '2023-10-01'] # 类别特征列, LightGBM原生支持类别特征 cat_cols = ['channel', 'product_type', 'overdue_level'] model = lgb.LGBMClassifier( objective='binary', learning_rate=0.03, # 行为特征噪声大, 学习率调低防过拟合 num_leaves=31, # 叶子数不用太大, 特征交叉够用即可 min_child_samples=500, # 样本量大, 提高该值避免学到偶发噪声 subsample=0.8, # 行采样 subsample_freq=1, colsample_bytree=0.8, # 列采样, 缓解行为特征高度相关的问题 n_estimators=1000, reg_alpha=0.1, reg_lambda=1.0 ) model.fit( train_part[feature_cols], train_part['label'], eval_set=[(valid_part[feature_cols], valid_part['label'])], eval_metric='auc', categorical_feature=cat_cols, callbacks=[lgb.early_stopping(100), lgb.log_evaluation(50)] )几个参数值得展开说。learning_rate=0.03比常用的0.1更保守,贷中行为特征噪声大,学习率一高,提前停止往往拦不住过拟合。min_child_samples=500是我在贷中场景的默认值,它约束了每个叶节点的最小样本量,节点上样本太少学出的规则基本都是偶发模式,上线后很快失效。
num_leaves=31看起来不大,但贷中特征之间交互并没有那么深,31个叶子已经足够。特征重要度跑出来,如果排名第一的是一个渠道编码而不是额度使用率,要怀疑渠道特征是否泄漏了——比如渠道字段里混入了获客时间或其他后验信息。
评估指标不能只看AUC。贷中场景坏样本占比通常只有3%-7%,AUC被大量“好样本”主导。我一般同时看KS和Lift:KS看排序能力,Lift看Top分位是否真正抓到坏人。更重要的是看“稳定性指标”,这里指的是模型分在不同月份的分布偏移,会在下一章详细展开。
5. 贷中模型落地避坑:样本穿越、特征漂移与线上不一致
5.1 标签穿越:验证集AUC高达0.95,上线后降额策略却无效
现象:离线验证时模型表现惊艳,AUC接近0.95,业务方兴奋地安排上线。上线后跑了一个月,降额名单里的客户逾期率并没有显著高于未降额客户,策略基本无效。
原因:标签时间窗和特征时间窗没有严格边界。最常见的是在特征里混入了观察点之后才发生的信息,比如催收记录——催收动作本身是观察点之后发生的,如果在特征表里用了“截至最新日期的催收次数”,模型等于直接看到了答案。
解决:在特征和标签对齐时,强制加一个断言检查:所有特征字段的业务日期必须小于等于观察点。代码里可以在合并特征后做一个时间戳校验,不合格的直接报错。我习惯把这个检查写进数据管道,而不是靠建模人员自觉。
5.2 表现期没对齐:用12个月表现期评估贷中模型
现象:建模团队从贷前模型复用了一套标签定义——观察点之后12个月内是否M3+逾期。模型做出来排序还不错,但业务方发现他们对“未来一个月就要逾期”的客户完全无感,侧重抓长期风险的评分用不到日常调额上。
原因:贷中业务动作的响应速度要求很高,90天表现期足够,12个月表现期把短期风险信号稀释了。一个客户第9个月才逾期,和一个月内就要逾期,对额度调整的意义完全不同。
解决:把标签统一为未来90天内是否出现M1逾期,并且上线后监控的窗口和训练时保持完全一致。如果业务方确实需要长期风险视图,单独做一套长期模型,不要和短期调额模型混用。
5.3 特征穿越:剩余期数特征在验证集上表现极强,换成观察点快照后增益消失
现象:特征重要度排序里,剩余期数排名前二。换了一个观察点版本的数据重训之后,这个特征的增益几乎消失。
原因:账户快照表取的“剩余期数”是最新值,不是观察点当时的剩余期数。存续时间短的客户被标记为剩余期数多,而实际上这些客户本来就处在风险爬坡期,模型学到的是“放款时间越短越危险”这个假象。
解决:所有账户类特征都必须拉取as_of_dt版本,保证特征字段对应的是观察点当天的账户状态。贷中建模的数据管道里,账户表一定要做成“每日快照表”而不是“最新状态表”。
5.4 训练集和线上特征分布漂移:PSI超过0.25才被发现
现象:模型上线三个月后,降额建议数量突然翻倍,业务方投诉“模型疯了”。排查后发现,模型分数的整体分布已经明显右移,大量客户被推高分数。
原因:存量客户结构变了。比如授信策略从某个月开始收紧,新进客户整体信用更好,而训练集使用的历史快照仍然以旧客群为主。特征分布偏移先发生,分数分布偏移随后跟上。
解决:上线第一天就要跑PSI基线快照,之后每月自动比对。分数PSI超过0.25立即触发重训,特征PSI超过0.25要逐个人工排查是哪些变量在漂移。不要等业务方发现异常再回头看数据,那时已经晚了。
5.5 用一个全局模型覆盖所有产品线
现象:循环贷、分期贷、大额消费贷共用一套贷中评分卡,模型整体AUC合格,但分期贷子群体的坏账率明显高于整体水平,而循环贷被过度降额。
原因:不同产品线的资金用途、还款节奏、额度使用模式差异很大。样本占比如果失衡,模型会偏向样本量大的产品线,小众产品线的风险信号被淹没。
解决:至少按产品大类拆分模型,或者做分层建模。如果样本量不足,可以先训练全局模型,再用产品线ID做微调。最忌讳的是全局模型加一个产品线编码特征了事——LightGBM学到的交互是隐性的,你控制不住它怎么用这个编码。
6. 上线后验证:冠军挑战者实验与PSI监控
模型上线不是终点,贷中模型直接影响额度、利率和催收资源分配,任何一次升级都要先过冠军挑战者实验。我一般的做法是保持线上冠军模型不动,把新训出来的LightGBM模型作为挑战者,随机分流一部分客户给它打分,但不下发业务策略。
| 实验组 | 流量比例 | 业务动作 | 观察指标 |
|---|---|---|---|
| 冠军组 | 85% | 沿用现有评分与策略 | 逾期率、回收率、客诉量 |
| 挑战者组 | 10% | 按新模型分数触发调额 | 逾期率、额度使用率 |
| 黑盒观察组 | 5% | 新模型打分但不做动作 | 模型排序与真实逾期的相关性 |
黑盒观察组价值最高——不下发策略,纯粹用真实还款行为验证新模型的排序能力。这组数据不受到业务策略干扰,能干净地回答“新模型是不是真的比旧模型强”。等观察组积累了三个月表现,再决定是否全量切换。
PSI监控脚本要练成肌肉记忆。我把它挂成每日任务,模型分和特征分布一起看。
import numpy as np def calc_psi(expected, actual, bins=10): # expected: 训练集模型分, actual: 当月线上模型分 # 以训练集分位为基准切分, 统计当月分布偏移 quantiles = np.percentile(expected, np.linspace(0, 100, bins + 1)) exp_counts, _ = np.histogram(expected, bins=quantiles) act_counts, _ = np.histogram(actual, bins=quantiles) exp_ratio = exp_counts / exp_counts.sum() act_ratio = act_counts / act_counts.sum() # 用平滑值防除零 exp_ratio = np.clip(exp_ratio, 1e-6, 1) act_ratio = np.clip(act_ratio, 1e-6, 1) psi = np.sum((act_ratio - exp_ratio) * np.log(act_ratio / exp_ratio)) return psi monthly_psi = calc_psi(train_score, live_score) print(f"当前月份PSI: {monthly_psi:.4f}")PSI小于0.1表示分布基本稳定,0.1到0.25进入观察,超过0.25就要考虑重训。这个阈值不是玄学,是行业里大量项目沉淀出来的共识。我个人的习惯是,只要PSI超过0.15就开始看特征维度的漂移明细,提前定位是客群变化还是某个特征取数出了问题。
贷中建模做到最后,拼的不是模型精度,而是对数据边界和时间线的敬畏。特征和标签的时间对齐、观察点的严格执行、上线后分布漂移的持续追踪,这三件事比调参重要得多。希望这些踩过的坑能帮你少走一段弯路。
本文还有配套的精品资源,点击获取