资讯详情

资讯详情

个人贷款违约预测:从特征工程到模型部署的完整风控实战

简介这是一套面向金融科技学习者、毕业设计学生及风控方向数据分析师的个人贷款违约预测完整工程包覆盖从数据清洗、特征工程到多模型训练、参数调优及部署上线的全流程。压缩包共11个文件以Python源码、CSV数据集、docx说明文档和Markdown笔记为主源码实现了逻辑回归、决策树等经典算法并做了性能优化文档系统讲解项目背景、技术栈、阈值选择与结果分析部署文件则给出模型封装为API或可执行程序的指南整体体积约2.17MB便于快速下载和本地复现。目前已有69人学习下载适合希望掌握金融风控建模实战的入门到中级使用者。借助清洗后的真实案例数据与完整代码注释读者可以独立跑通训练-评估-部署链路并借此理解违约概率预测中的关键决策点与常见坑是一份兼具教学与工程参考价值的紧凑资料。1. 个人贷款违约预测别等坏账爆了才想起风控做信贷风控的人都有过这种经历线下审批靠经验线上放款靠规则等到逾期率抬头才去翻数据发现变量早就给出信号了。个人贷款违约预测这个方向就是把「会不会还钱」这个问题变成一个可量化、可回测、可上线的二分类任务。标题里这套 python 源码 说明文档 部署文件 数据的压缩包本质是一个完整到能直接跑的最小可用系统不是某个算法课的残缺作业。它适合三类人刚入行想做风控模型但没见过完整项目的算法工程师银行/消金/互金公司里被业务追着要「模型解释性」的数据分析师以及准备把模型部署成 HTTP 接口但不想从零写服务层的后端开发。下面我把这套东西从数据预处理到模型上线拆开讲顺便把最容易翻车的几个地方提前说清楚。2. 违约预测的建模框架先想清楚预测什么再碰代码2.1 标签定义逾期 30 天还是 90 天直接决定模型上限拿到「个人贷款违约预测」这个任务第一件事不是跑模型而是跟业务方对齐什么是「违约」。常见做法是用逾期天数划分M0 是当期未还M1 是逾期 30 天以内M2 是 3060 天M3 是 6090 天。如果做贷前风控通常把逾期 ≥ 30 天或 ≥ 90 天定义为坏客户如果做贷中预警M0 到 M1 的迁徙率才是关注点。你在源码里会看到y (df[overdue_days] 30).astype(int)这类标签构造逻辑它意味着模型学习的是「30 天内会不会进入逾期状态」而不是「最终会不会坏账」。这个选择会直接影响正样本占比和模型阈值——30 天逾期率 5% 和 90 天坏账率 1.5%完全是两个难度级别。我一般会先看数据里逾期天数的分布直方图确认 30 天窗口下正样本量够不够建模不够就降到 15 天或者用滚动窗口多构造几期样本。标签定义还有一个隐蔽的坑观察期和表现期必须错开。比如你用 2023 年 1 月放款的客户做样本用他们未来 6 个月是否逾期做标签那么训练集里只能出现 2023 年 7 月之前放款的客户否则未来信息会泄漏进来。源码里如果用了train_test_split直接随机切分请务必检查切分前有没有按放款日期排序——随机切分在时序数据上一定会泄漏。正确做法是按时间切分前 80% 时间窗口做训练后 20% 做验证或者至少保证同一个放款月份不会被同时分到训练集和测试集。2.2 特征工程收入、负债、历史行为怎么变成模型输入特征工程是这套源码里最值得读的部分。原始数据通常是三张表客户基本信息表年龄、性别、婚姻状况、学历、授信与负债表信用卡额度、已用额度、其他贷款余额、还款行为表历史逾期次数、提前还款次数、最近 6 个月平均使用额度。源码里常见做法是拼成一张宽表然后用以下套路加工# 特征衍生示例基于原始字段构造的常用风控变量 import pandas as pd import numpy as np df[credit_utilization] df[credit_card_used] / (df[credit_card_limit] 1) # 信用卡使用率分母加 1 防除零 df[income_debt_ratio] df[monthly_income] / (df[total_loan_balance] 1) # 收入负债比 df[overdue_ratio_6m] df[overdue_count_6m] / 6 # 近 6 月逾期频率 df[history_avg_util] df[[util_month1, util_month2, util_month3, util_month4, util_month5, util_month6]].mean(axis1) # 历史使用率的波动性标准差比均值更能反映资金紧张程度 df[util_std_6m] df[[util_month1, util_month2, util_month3, util_month4, util_month5, util_month6]].std(axis1)这里income_debt_ratio分母加 1 是怕遇到零负债客户直接除零报错credit_utilization同理。风控里有个经验信用卡使用率长期超过 80% 的客户违约概率显著上升所以history_avg_util和util_std_6m这两个衍生变量往往比原始额度字段重要得多。源码里如果只给你原始字段务必自己加上这类比率和波动性特征LightGBM 虽然能自动找非线性切分但我见过不少场景下人工衍生特征仍然能把 AUC 拉高 23 个点。除了数值特征类别特征也需要处理。年龄、学历、职业这类字段直接标签编码会引入虚假的序数关系常见做法是 WOE 编码或目标编码。WOE 编码的思路是计算每个分箱里坏客户占比相对于整体坏客户占比的对数比值公式是WOE ln(坏样本占比 / 好样本占比)它天然适合逻辑回归这种线性模型树模型则可以直接吃原始类别设置categorical_feature参数就行。源码里如果统一用LabelEncoder处理了所有类别列跑 LightGBM 时记得改回原生类别特征否则类别数量很多时会因为乱序分裂导致性能下降。2.3 模型选型为什么风控场景偏爱 LightGBM 和逻辑回归个人贷款违约预测的模型选择其实是在准确率、可解释性、稳定性和部署成本之间做权衡。常见做法是先用逻辑回归做基线再用 LightGBM 做主力模型。逻辑回归的优势是权重可以直接换算成风险评分——score a b * log(odds)业务人员听一遍就能理解「收入负债比每下降一个档次评分扣多少分」LightGBM 的优势是能捕捉非线性关系和特征交互在样本量足够时 AUC 通常比逻辑回归高 58 个点。但树模型有个隐患对特征分布的漂移更敏感上线后如果客户群体变化分数分布可能整体偏移需要频繁监控。# LightGBM 训练核心配置风控场景常用的参数组合 import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score X_train, X_val, y_train, y_val train_test_split( X_encoded, y, test_size0.2, random_state42, stratifyy ) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: 6, min_child_samples: 100, # 叶子节点最小样本数防过拟合 feature_fraction: 0.8, # 每棵树随机采样 80% 特征 bagging_fraction: 0.8, # 每棵树随机采样 80% 样本 bagging_freq: 1, lambda_l1: 1.0, # L1 正则让权重稀疏 lambda_l2: 1.0, # L2 正则 verbose: -1 } model lgb.train( params, lgb.Dataset(X_train, labely_train), num_boost_round500, valid_sets[lgb.Dataset(X_val, labely_val)], callbacks[lgb.early_stopping(stopping_rounds50)] )这段配置里有几个参数是风控场景的默认选择min_child_samples100比通用调参教程里的 20 大得多因为信贷数据噪声大叶子节点样本太少学的全是偶然模式feature_fraction0.8和bagging_fraction0.8是双随机能明显降低方差early_stopping用验证集 AUC 决定树的数量防止在训练集上无限迭代。如果你发现验证集 AUC 很高但上线后效果崩了大概率是max_depth和num_leaves给太大模型记住了训练集里的非稳定规律——这时候优先调小num_leaves到 1531 之间再考虑加正则。3. 源码目录拆解每个文件是干什么的部署时改哪里3.1 从压缩包到跑通目录结构、依赖安装和第一行输出拿到压缩包后先别急着pip install全部依赖先看目录结构。一套规范的贷前风控源码至少应该有data/原始数据、feature/或feature_engineering.py特征加工、model/训练脚本、deploy/服务代码、docs/说明文档。如果压缩包里只有一个 Jupyter Notebook 加一堆 CSV说明项目作者把流程都揉在一起了跑通容易改造成服务麻烦。我一般会先建虚拟环境再做三件事确认 Python 版本、安装 requirements.txt、用最小数据子集试跑训练脚本因为完整数据可能上百万行直接全量跑光预处理就要半小时。# 在项目根目录执行创建虚拟环境并安装依赖 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install -r requirements.txt # 检查关键依赖版本避免 pandas/numpy 版本冲突 python -c import pandas, numpy, sklearn, lightgbm; print(pandas.__version__, numpy.__version__, sklearn.__version__, lightgbm.__version__)这里最容易翻车的坑是 pandas 和 numpy 版本不匹配——如果你的 Python 是 3.11 而源码作者用的是 3.8pd.concat和np.where的默认行为可能有细微差别。更常见的是 LightGBM 在 Mac 上编译失败这时候直接pip install lightgbm --no-binary lightgbm大概率还是编译失败我通常建议 Windows 用户直接用conda install -c conda-forge lightgbmMac 用户检查是否装了 libomp。往 requirements.txt 里逐行看如果它写的明明是scikit-learn0.24而你 pip 默认装了 1.2 的版本某些 API 会直接报错——遇到check_input参数被移除这类错误去官方 changelog 查替代名别花时间硬调试。跑通训练脚本后你会看到终端输出一批指标train auc、valid auc、best_iteration等。如果valid auc只有 0.5 左右先别怀疑模型去检查标签和特征是否对齐——最常见的原因是合并客户表时用了 inner join把没有行为数据的客户全丢掉了或者sort_values之后索引没有 reset导致train_test_split切分时索引错位、标签和特征对不上。这些都是源码里「看似没问题但一跑就崩」的高频事故点。3.2 训练脚本的核心流程数据读取、分箱、训练、指标回放训练脚本通常长这样main()里先读数据再调preprocess()处理缺失值和异常值然后feature_engineering()生成衍生变量接着train_model()做交叉验证和调参最后evaluate_model()输出混淆矩阵、AUC、KS 曲线。这套流程里最值得细读的是分箱逻辑因为风控模型不只是预测概率还要把概率映射成风险等级。常见做法是把模型输出的违约概率分成 10 箱每箱统计坏账率然后绘制双曲线图横轴是分数段左轴是样本占比柱状图右轴是坏账率折线。如果某个分数段的坏账率是另一个分数段的 10 倍以上说明模型有区分度如果曲线是平的或者倒挂说明特征和标签的关系没学对。# 分箱与指标回放评估模型在业务上的区分度 def evaluate_by_group(model, X_val, y_val, bins10): pred model.predict(X_val) df_eval pd.DataFrame({pred: pred, y: y_val}) df_eval[bin] pd.qcut(df_eval[pred], qbins, duplicatesdrop) grouped df_eval.groupby(bin, observedTrue).agg( sample_count(y, size), bad_rate(y, mean), avg_score(pred, mean) ) grouped[bad_rate] grouped[bad_rate].round(4) grouped[cum_bad_ratio] grouped[bad_rate].cumsum() / grouped[bad_rate].sum() return grouped grouped_res evaluate_by_group(model, X_val, y_val) print(grouped_res)pd.qcut按分位数等频分箱比直接按 0.1 步长等距分箱更合理因为违约概率分布往往右偏等距分箱会导致高分箱样本极少、坏账率波动剧烈。这里有个新手常踩的坑groupby(bin, observedTrue)这个参数在 pandas 2.0 之前不加会报警告如果源码里没加而你本地 pandas 版本较新跑出来的结果可能把bin列当成类别变量做笛卡尔展开输出一堆空组。分组结果里重点看最后两行的cum_bad_ratio如果最低分组的累计坏账占比已经超过 60%说明模型在低分段能识别出大头风险这个模型就算合格了。3.3 说明文档要读哪几页数据字典、特征列表、模型参数表拿到说明文档别从头到尾通读先翻目录找三样东西数据字典、特征列表、模型参数表。数据字典会告诉你每个字段的含义、类型、缺失率和取值范围这是后面排查特征泄漏的依据——比如如果发现有is_default这样直接等于标签的字段混在特征里训练时 AUC 可能是 0.99上线后直接崩成 0.5。特征列表则会告诉你最终进了模型的字段集合以及是否有衍生字段参与了建模。我习惯把数据字典里缺失率超过 50% 的字段列出来在训练脚本里确认它们是不是被dropna删掉了——如果没有删除而是填充了均值一定要在文档里记录填充值否则部署到生产环境时新数据走同样的预处理管道才能保持一致的分布。模型参数表是部署时最关键的参考。源码作者如果贴了完整的params字典你要逐个核对objective是不是 binarymetric是不是 auclearning_rate用的是 0.05 还是 0.1。部署时的特征顺序和训练时不一致是模型上线后跑出乱码概率的最常见原因。解决办法是把训练阶段用到的特征列名存成feature_columns.json部署服务启动时读取这个文件对输入数据做df df[feature_columns]重排。如果说明文档里没提特征顺序你在改部署代码时务必自己补上这一步。4. 部署文件落地从 .pkl 模型到可调用的 HTTP 接口4.1 模型导出格式pickle、joblib 还是 booster 文件训练完成后模型必须导出成部署环境能加载的格式。常见做法有三种pickle.dump(model, f)、joblib.dump(model, f)、LightGBM 的model.save_model(lgb_model.txt)。这三者的区别是pickle 会把整个 Python 对象序列化包括模型结构、超参数、训练数据引用优点是简单缺点是跨版本恢复容易报错joblib 针对 numpy 数组做了优化加载速度更快LightGBM 原生模型文件是纯文本格式跨平台跨版本最稳定而且能把特征名一起存进去部署时几乎不会出现特征顺序错乱的问题。我一般只用.txt格式做生产模型pickle 只在快速实验时用。# LightGBM 模型导出生产环境推荐原生模型文件 model.save_model(model/lgb_auc_{}.txt.format(best_auc)) # 加载方式 import lightgbm as lgb model lgb.Booster(model_filemodel/lgb_auc_0.7821.txt) # 预测时需要传入特征矩阵注意列顺序必须和训练时一致 pred model.predict(df_val[feature_columns].values)这里有个血泪经验如果模型是用lgb.train训练得到的Booster对象用pickle.dump保存后在另一台机器上pickle.load经常会因为 LightGBM 版本不一致报Unsupported data type或直接段错误。反过来如果训练时用的是LGBMClassifiersklearn 接口model.booster_.save_model()导出的是 booster 文件加载后要调用booster.predict(data)而不是model.predict。部署脚本里这两者不能混用我在生产环境里至少遇到三次因为加载接口不匹配导致预测结果全为NaN的案例排查方向最后都指向了导入的类不对。4.2 用 Flask 包一层服务请求格式、批处理与超时控制部署文件里最常见的服务框架是 Flask因为轻量、生态成熟、和机器学习模型搭配顺手。一个最小可用的预测服务通常只有三部分读模型、接收请求、预处理并预测。但这里有两个业务相关的设计点需要额外注意一是输入特征缺失怎么办二是并发请求时模型线程安全吗。LightGBM 的 Booster 对象的predict方法是线程安全的可以直接在 Flask 的多线程模式下复用同一个实例但如果你用的是某个封装类里面有共享的 DataFrame 或者缓存就可能出现并发错乱。解决方法是每次请求进来时从原始 JSON 重新构造特征行不做任何全局可变状态缓存。# Flask 预测服务接收 JSON 返回违约概率与风险等级 from flask import Flask, request, jsonify import lightgbm as lgb import pandas as pd import json app Flask(__name__) model lgb.Booster(model_filemodel/lgb_model.txt) with open(model/feature_columns.json, r, encodingutf-8) as f: FEATURE_COLUMNS json.load(f) # 缺失率高的字段用训练集的中位数填充避免预测时空值传给模型 FILL_MEDIAN json.load(open(model/fill_median.json, r, encodingutf-8)) app.route(/predict, methods[POST]) def predict(): data request.get_json() # 把请求字段对齐到训练特征名缺失字段用中位数填充 row {} for col in FEATURE_COLUMNS: val data.get(col, FILL_MEDIAN.get(col, 0)) row[col] val df_input pd.DataFrame([row])[FEATURE_COLUMNS] prob model.predict(df_input.values)[0] # 根据业务阈值划分风险等级0.3 以下低风险0.7 以上高风险 if prob 0.3: level low elif prob 0.7: level medium else: level high return jsonify({probability: float(prob), risk_level: level, decision: reject if level high else review}) if __name__ __main__: app.run(host0.0.0.0, port8000, threadedTrue)这段代码里有个关键细节FILL_MEDIAN字典是训练时统计好的而不是预测时现算——如果你在服务里对每个请求算中位数单机并发一高就卡死而且新数据的分布和训练集不一致时现算中位数会漂移。另一个细节是df_input.values必须和训练时特征顺序一致所以FEATURE_COLUMNS这个 JSON 文件一定要在训练脚本里保存不能靠记忆。threadedTrue让 Flask 能处理并发请求但如果你把模型加载写成模块级变量每个 worker 都会有自己的副本内存占用是训练时的数倍——我的习惯是用--preload加载一次模型再 fork worker能省一半内存。4.3 上线前的模型一致性检查离线预测 vs 在线预测差多少模型部署到生产环境后最吓人的问题不是服务挂了而是线上的预测结果和线下实验时对不上。常见原因有三个特征名和顺序不一致、缺失值填充方式不一致、类型转换导致的分桶偏移。我每次上线前都做一次「对拍测试」从测试集里抽 1000 条样本离线跑一遍pred_offline model.predict(X_test)再通过 HTTP 接口逐条请求得到pred_online然后比对两者的差值。理论上应该完全一致但实践中经常发现pred_online里面出现NaN——这时候去查请求 JSON 里的字段有没有传错类型比如年龄字段是字符串35而离线是int(35)LightGBM 会把字符串当作类别特征处理分桶直接错位。# 离线与在线预测一致性检查 import requests import numpy as np from sklearn.metrics import mean_absolute_error sample X_test.sample(100, random_state0) offline_preds model.predict(sample.values) online_preds [] for _, row in sample.iterrows(): resp requests.post(http://localhost:8000/predict, jsonrow.to_dict()) online_preds.append(resp.json()[probability]) mae mean_absolute_error(offline_preds, online_preds) print(fMAE between offline and online: {mae:.6f}) # 如果 MAE 1e-6说明部署管道存在问题需要逐条比对特征值如果 MAE 不是 0下一步是取第一条样本把离线特征矩阵的值和在线请求时拼接出来的特征值逐列打印对比。我最常遇到的问题是 DataFrame 在to_dict()时把NaN转成了None然后 Flask 端拿到None后走了FILL_MEDIAN.get(col, 0)分支——离线时填充的是中位数在线时填充的是 0自然对不上。解决方法是统一用同一个预处理函数离线和在线都强制先转成 numpy 数组再做预测把填充逻辑固化在同一个模块里不要一个地方写中位数、另一个地方写临时兜底值。5. 数据质量与特征泄漏让模型分数「看着漂亮」的 5 个隐形坑5.1 标签泄漏还款行为字段进了特征导致 AUC 虚高特征泄漏是个人贷款违约预测里最隐蔽的坑也是最容易让新手误以为自己模型效果极好的坑。典型场景是原始数据里有一列current_overdue记录的是客户当前是否逾期如果直接把它当作特征模型当然能「精准」预测违约因为当前逾期的人大概率马上就会逾期。源码数据里这种字段经常叫overdue_status、delinquency_flag、甚至在联名表里叫repay_status——你以为它是历史状态实际上它是未来结果的一部分。判断方法很简单看这个字段的统计时间和放款时间的关系如果是放款之后产生的它就是泄漏。# 特征泄漏自查计算每个特征与标签的单变量 AUC from sklearn.metrics import roc_auc_score highly_predictive_cols [] for col in X_train.columns: try: auc_val roc_auc_score(y_train, X_train[col].fillna(0)) except ValueError: continue if auc_val 0.8: highly_predictive_cols.append((col, auc_val)) print(单变量 AUC 超过 0.8 的字段, sorted(highly_predictive_cols, keylambda x: -x[1]))这段代码能快速列出异常高区分度的字段但只适用于数值特征。类别特征比如employment_status单变量 AUC 往往不高但和某些字段交叉后会产生泄漏信号。我遇到过最典型的一次是channel_code这个字段单变量 AUC 只有 0.55但按渠道分组后某个渠道的客户全是没有完整收入证明的线下进件坏账率是其他渠道的 3 倍——这不叫特征泄漏这叫「渠道差异」是业务规律可以保留真正要删的是那些「在放款后统计出来的结果字段」。拿到数据后第一时间写脚本扫一遍字段创建时间把晚于放款日的字段全部移出特征列表这是成本最低的降泄漏手段。5.2 缺失值和异常值处理风控数据的「脏」不是随机缺失信贷数据的缺失模式本身就是信息。比如月收入字段缺失可能是因为客户不配合填写、自由职业者没有稳定收入或者进件渠道压根没采到这个字段——前两种情况下缺失本身就暗示更高的违约风险。所以常见做法不是简单地用均值填充而是生成一个is_income_missing指示变量让模型自己学缺失的含义。LightGBM 本身能处理缺失值它会在训练时自动学习缺失值的分裂方向所以如果你用树模型可以不填充直接传入但如果你用逻辑回归缺失值必须显式填充否则 sklearn 直接报错。# 缺失值处理方案区分随机缺失与信息性缺失 for col in [monthly_income, total_loan_balance, credit_card_limit]: missing_rate df[col].isna().mean() print(f{col} missing rate: {missing_rate:.2%}) if missing_rate 0.1: df[col _is_missing] df[col].isna().astype(int) df[col] df[col].fillna(df[col].median())异常值在信贷数据里比缺失值更危险。比如月收入字段如果出现 9999999 这种占位符或者年龄出现 200直接进模型会把分箱逻辑带偏。我的处理顺序是先看每个数值列的describe()把最小值、最大值里明显不合理的数据过滤掉再决定是删除还是盖帽。风控场景里更稳妥的做法是盖帽而不是删除把超过 99.9 分位数的值截断到 99.9 分位数避免把真实的高净值客户误删。这个clip的上下限必须在训练集上算好存成 JSON部署时用同一组上下限对在线输入做同样处理——和前面填充值一样这是离线在线一致性的关键。5.3 样本不均衡违约率 3% 时要不要做过采样个人贷款数据里违约率普遍不到 5%甚至 1%3% 都很常见。如果不做任何处理直接训练LightGBM 会偏向预测多数类最终预测概率普遍偏低把违约客户都压在 0.2 以下。常见做法有四种scale_pos_weight参数、SMOTE 过采样、下采样多数类、改用 AUC 等阈值无关指标评估。这里我的经验是不要一上来就 SMOTE因为风控特征里很多是离散的高基数类别SMOTE 造出来的插值样本可能是「不存在的人」反而引入噪声。# 处理样本不均衡优先用 scale_pos_weight而非 SMOTE neg_count (y_train 0).sum() pos_count (y_train 1).sum() scale_pos neg_count / pos_count params[scale_pos_weight] scale_pos # 或直接调 LGBMClassifier 的 class_weight 参数 from lightgbm import LGBMClassifier model LGBMClassifier( scale_pos_weightscale_pos, n_estimators500, learning_rate0.05, num_leaves31 )scale_pos_weight的作用等价于给正样本的梯度放大scale_pos倍让模型更关注少数类。但要注意放大倍数太大会让预测概率整体抬升导致业务上设定的 0.5 阈值不再适用——上线前必须重新用验证集选阈值。选择阈值的方法是找到「漏报率」和「误报率」的平衡点通常看 KS 曲线或者 PR 曲线如果业务更怕坏账漏出去就把阈值调低比如 0.3如果更怕误杀好客户就把阈值调高。源码里如果自带阈值搜索函数一般是基于验证集的 F1 分数或约登指数你可以直接用但务必确认它选出来的阈值在你业务场景下合理——F1 最大化不等于利润最大化。5.4 时间序列的验证集切分随机打散会让 AUC 虚高 0.1信贷数据是强时序数据客户进件时间、还款行为都在时间轴上演进。如果源码里直接用了train_test_split(test_size0.2, random_state42)没有按时间排序那么训练集和测试集里会混入同一个月放款的客户模型能「偷看」到近邻时间段的行为模式。常见的正确姿势是按放款月份切分例如用 2023 年 1 月到 6 月做训练7 月到 8 月做验证9 月做测试。因为信贷客群的收入结构、政策环境、渠道策略都在随时间变化只有时间外验证才能反映模型真实上线后的表现。# 按时间切分数据集避免随机切分的时序泄漏 df[loan_month] pd.to_datetime(df[loan_date]).dt.to_period(M) train df[df[loan_month] 2023-06] val df[(df[loan_month] 2023-06) (df[loan_month] 2023-08)] test df[df[loan_month] 2023-08] X_train, y_train train[feature_cols], train[label] X_val, y_val val[feature_cols], val[label] X_test, y_test test[feature_cols], test[label]我见过一个案例随机切分时 AUC 0.86改成时间切分后 AUC 掉到 0.78掉得不算剧烈但足以说明随机切分高估了模型效果。更隐蔽的是如果训练期间做特征工程时你用了全量数据统计中位数、分位数、目标编码的均值那即使按时间切分统计量本身也包含了未来信息——正确做法是先只对训练集计算统计量再用训练集的统计量去转换验证集和测试集。这个「只从训练集学习变换参数」的原则是源码里无标注但最关键的建模纪律。5.5 特征稳定性监控PSI 告诉你模型什么时候悄悄失效模型上线不是终点是起点。个人贷款客群会随宏观环境、渠道政策、产品利率变化而变化三个月前训练的特征分布可能已经完全走样。业界通用的监控指标是 PSIPopulation Stability Index它衡量特征在训练集和当前生产样本之间的分布偏移程度。PSI 小于 0.1 表示稳定0.10.25 需要关注大于 0.25 表示显著偏移、模型需要重新训练。每个特征都应该单独算 PSI模型预测概率本身也算一个 PSI——如果概率的 PSI 飘到 0.25 以上基本可以确定模型已经失效。# PSI 计算评估单个特征在训练集与生产样本间的分布偏移 def calculate_psi(expected, actual, buckets10): def _get_bucket_ratios(data): # 按训练集的分位数切分生产数据套用同一分位点 breakdown, _ pd.qcut(expected, qbuckets, retbinsTrue, duplicatesdrop) exp_ratios breakdown.value_counts(normalizeTrue).sort_index() act_buckets pd.cut(actual, bins_drop_duplicate_bins(breakdown.cat.categories)) act_ratios act_buckets.value_counts(normalizeTrue).sort_index() return exp_ratios, act_ratios exp_ratios, act_ratios _get_bucket_ratios(expected) psi 0.0 for idx in exp_ratios.index: exp_ratio exp_ratios.get(idx, 0) 1e-10 act_ratio act_ratios.get(idx, 0) 1e-10 psi (act_ratio - exp_ratio) * np.log(act_ratio / exp_ratio) return psi feature_psi {col: calculate_psi(X_train[col], X_prod[col]) for col in feature_cols} print(PSI 超过 0.25 的特征, {k: v for k, v in feature_psi.items() if v 0.25})PSI 计算有个容易搞错的地方分桶的边界必须固定用训练集的分位数不能每个月重新分桶否则你比较的是「两套桶的分布」数值没有可比性。生产环境里我一般每天跑一次 PSI 任务把超过阈值的特征名推送到告警群连续三天超阈值就触发重训流程。这个监控脚本通常不在压缩包里但它是模型上线后最值得补的一块。6. 从源码到生产把模型效果守住的三件关键事6.1 阈值选择别迷信 0.5用利润曲线找最佳切点很多人拿到违约概率后默认以 0.5 为阈值概率高于 0.5 拒绝低于 0.5 通过。但在信贷场景里0.5 几乎从来不是最佳切点。因为违约造成的损失和正常客户带来的收益通常不对等假设一笔贷款发放后正常还款赚 100 元利息违约损失 1000 元本金那么漏放一个坏客户的代价是误杀一个好客户的 10 倍。此时最优阈值应当远低于 0.5比如 0.3让更多低分段客户被拦截。具体计算方法是把验证集按预测概率从低到高排序依次假设以每个分位数为阈值计算放贷收益(正常客户数 × 利息) - (违约客户数 × 损失)取收益最大的阈值。# 基于收益的阈值搜索 profit_good 100 # 单笔好客户收益 loss_bad 1000 # 单笔坏客户损失 preds model.predict(X_val) df_threshold pd.DataFrame({pred: preds, label: y_val}) df_threshold df_threshold.sort_values(pred, ascendingFalse).reset_index(dropTrue) best_threshold 0.5 best_profit -np.inf for t in np.arange(0.05, 0.95, 0.05): pred_label (df_threshold[pred] t).astype(int) profit ((pred_label 0) (df_threshold[label] 0)).sum() * profit_good \ - ((pred_label 0) (df_threshold[label] 1)).sum() * loss_bad if profit best_profit: best_profit profit best_threshold t print(f最优阈值: {best_threshold:.2f}, 对应利润: {best_profit:.0f})这里有一点要特别注意上面这段代码的利润计算非常简化没有考虑利率期限、复利、回收率、资金成本等因素。真实业务中应该用每个客户的额度、期限、利率算个体化收益而不是统一常量。但即使简化方向是正确的——用业务成本结构去选阈值而不是靠统计指标的默认值。我在实际项目里就是这样做的第一次算出来最优阈值是 0.28比之前拍脑袋定的 0.5 低了整整 22 个百分点但整体利润提升了 17%。6.2 模型解释性SHAP 值输出多少业务就能相信多少信贷模型上线后一定会被业务部门问「你能不能解释为什么拒绝了这位客户」LightGBM 这种梯度提升树模型特征重要度只能告诉你哪些特征整体重要但说不清某个具体客户是因为什么被拒。这时候要用 SHAP 做局部解释。SHAP 的核心思想是把预测概率分解成「基线概率」加「每个特征的贡献值」贡献值加起来正好等于预测输出。这个性质让 SHAP 可以输出一个漂亮的解释表客户 A 违约概率 0.45基线 0.12其中信用卡使用率贡献 0.18收入负债比贡献 0.116 个月逾期频率贡献 0.08。# SHAP 值计算输出每个客户每个特征的贡献 import shap explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_val) # 单个客户的解释 idx 0 shap_summary pd.DataFrame({ feature: X_val.columns, shap_value: shap_values[idx], feature_value: X_val.iloc[idx].values }).sort_values(shap_value, ascendingFalse) print(shap_summary.head(10)) # 展示时转换为自然语言特征 X 的值为 Y使违约概率上升/下降了 Z跑 SHAP 之前要先确认shap_values的形状——LightGBM 的 booster 模型常常返回一个二维数组而 sklearn 包装器可能返回一个三维数组多分类第一个维度是类别。如果shap_values的形状是(n_samples, n_features)直接索引单行没问题如果是(n_classes, n_samples, n_features)你要取shap_values[1]正类别而不是shap_values[0]。我见过一次因为取错维度导致 SHAP 全算反的例子——所有贡献值都是负数解释给业务听差点闹出笑话。还有一个性能坑TreeExplainer对几百万行特征矩阵算 SHAP 会非常慢内存直接爆炸生产环境里一般只对命中拒绝名单的样本做解释批量任务控制在几千行以内。6.3 我的习惯改模型前先做一轮坏样本抽检这是我做风控这几年的一个习惯也是踩过坑之后长出来的规矩无论模型指标多漂亮上线前都动手抽 50 条预测概率最高的坏样本一条条人工看原始记录。为什么这么做因为模型指标衡量的是整体统计规律而业务方真正关心的是单个客户的故事。有一次我抽检时发现模型判为高风险的客户里有一大批是「在校大学生无收入但信用卡已授信」——模型的判断逻辑其实没错但拆开看发现这批客户是通过校园渠道进件的授信额度只有几百块违约损失极低属于「统计上是高风险、业务上可接受」的客群。如果不抽检直接用模型一刀切等于把一批几乎零损失的客户全拒了。这个习惯也帮我抓住过不少数据质量问题。比如有一次抽检发现大量高概率违约样本的monthly_income字段恰好都是 0查了好几天才发现是上游系统在客户失业时把收入字段清零而不是置为缺失——模型学到的是「收入为 0 就违约」但这批人里很多是刚辞职换工作的白领三个月内就会恢复收入。这个发现直接推动我们把收入「为 0」和「缺失」在特征上拆成两个变量模型效果不降反而更稳健了。以后你拿到任何一套贷款违约预测源码也建议先抽检再上。数据会撒谎模型会撒谎只有人工翻样本这件事能帮你把谎话揪出来。希望这篇拆解能帮你在跑通代码之外少走几个我走过的弯路——无论是把特征泄漏当成绩还是把随机切分的 AUC 当真这些坑我都替你踩过了。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →