
简介这是面向金融风控场景的机器学习贷中风险预测项目包源自“江苏银行杯”金融大数据建模挑战赛初赛方案适合高校人工智能、金融科技相关专业学生、教师及从业者学习参考。压缩包共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, freqMS) # 每月月初 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_indexTrue) 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_days90, overdue_th30): obs_df[label] 0 for i, row in obs_df.iterrows(): obs row[obs_date] end obs pd.Timedelta(daysperf_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(daysoverdue_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在数仓里直接joinPython版适合在抽样数据上做原型验证。代码里两个参数值得盯住overdue_th30代表“M1逾期”作为坏标准overdue_th1则代表“任何一期逾期1天就算坏”后者噪声大但召回更高perf_days90是表现期拉长到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[frecent{w}_delq_cnt] delq_cnt row_feat[frecent{w}_delq_ratio] delq_cnt / max(len(recent), 1) row_feat[frecent{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, onloan_id, howleft ) # 额度使用率: 剩余本金 / 授信额度 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在数学上互补但业务含义有细微差别——前者更接近客户“留有余地”的程度建议两个都保留不要嫌冗余。逾期天数的处理也有讲究。如果直接把当前逾期天数放进模型做连续特征模型会认为D29和D30差别巨大实际上催收动作一过这两个状态的风险差异很小。分段编码更符合业务直觉。# 当前逾期天数分段编码 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( objectivebinary, learning_rate0.03, # 行为特征噪声大, 学习率调低防过拟合 num_leaves31, # 叶子数不用太大, 特征交叉够用即可 min_child_samples500, # 样本量大, 提高该值避免学到偶发噪声 subsample0.8, # 行采样 subsample_freq1, colsample_bytree0.8, # 列采样, 缓解行为特征高度相关的问题 n_estimators1000, reg_alpha0.1, reg_lambda1.0 ) model.fit( train_part[feature_cols], train_part[label], eval_set[(valid_part[feature_cols], valid_part[label])], eval_metricauc, categorical_featurecat_cols, callbacks[lgb.early_stopping(100), lgb.log_evaluation(50)] )几个参数值得展开说。learning_rate0.03比常用的0.1更保守贷中行为特征噪声大学习率一高提前停止往往拦不住过拟合。min_child_samples500是我在贷中场景的默认值它约束了每个叶节点的最小样本量节点上样本太少学出的规则基本都是偶发模式上线后很快失效。num_leaves31看起来不大但贷中特征之间交互并没有那么深31个叶子已经足够。特征重要度跑出来如果排名第一的是一个渠道编码而不是额度使用率要怀疑渠道特征是否泄漏了——比如渠道字段里混入了获客时间或其他后验信息。评估指标不能只看AUC。贷中场景坏样本占比通常只有3%-7%AUC被大量“好样本”主导。我一般同时看KS和LiftKS看排序能力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, bins10): # expected: 训练集模型分, actual: 当月线上模型分 # 以训练集分位为基准切分, 统计当月分布偏移 quantiles np.percentile(expected, np.linspace(0, 100, bins 1)) exp_counts, _ np.histogram(expected, binsquantiles) act_counts, _ np.histogram(actual, binsquantiles) 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就开始看特征维度的漂移明细提前定位是客群变化还是某个特征取数出了问题。贷中建模做到最后拼的不是模型精度而是对数据边界和时间线的敬畏。特征和标签的时间对齐、观察点的严格执行、上线后分布漂移的持续追踪这三件事比调参重要得多。希望这些踩过的坑能帮你少走一段弯路。本文还有配套的精品资源点击获取