☰
个人贷款违约预测:从特征工程到模型部署的完整风控实战
2026/10/27 17:37:26 网站建设 项目流程

简介:这是一套面向金融科技学习者、毕业设计学生及风控方向数据分析师的个人贷款违约预测完整工程包,覆盖从数据清洗、特征工程到多模型训练、参数调优及部署上线的全流程。压缩包共11个文件,以Python源码、CSV数据集、docx说明文档和Markdown笔记为主,源码实现了逻辑回归、决策树等经典算法并做了性能优化,文档系统讲解项目背景、技术栈、阈值选择与结果分析,部署文件则给出模型封装为API或可执行程序的指南,整体体积约2.17MB,便于快速下载和本地复现。目前已有69人学习下载,适合希望掌握金融风控建模实战的入门到中级使用者。借助清洗后的真实案例数据与完整代码注释,读者可以独立跑通训练-评估-部署链路,并借此理解违约概率预测中的关键决策点与常见坑,是一份兼具教学与工程参考价值的紧凑资料。

1. 个人贷款违约预测:别等坏账爆了才想起风控

做信贷风控的人都有过这种经历:线下审批靠经验,线上放款靠规则,等到逾期率抬头才去翻数据,发现变量早就给出信号了。个人贷款违约预测这个方向,就是把「会不会还钱」这个问题变成一个可量化、可回测、可上线的二分类任务。标题里这套 python 源码 + 说明文档 + 部署文件 + 数据的压缩包,本质是一个完整到能直接跑的最小可用系统,不是某个算法课的残缺作业。它适合三类人:刚入行想做风控模型但没见过完整项目的算法工程师,银行/消金/互金公司里被业务追着要「模型解释性」的数据分析师,以及准备把模型部署成 HTTP 接口但不想从零写服务层的后端开发。下面我把这套东西从数据预处理到模型上线拆开讲,顺便把最容易翻车的几个地方提前说清楚。

2. 违约预测的建模框架:先想清楚预测什么,再碰代码

2.1 标签定义:逾期 30 天还是 90 天,直接决定模型上限

拿到「个人贷款违约预测」这个任务,第一件事不是跑模型,而是跟业务方对齐什么是「违约」。常见做法是用逾期天数划分:M0 是当期未还,M1 是逾期 30 天以内,M2 是 30~60 天,M3 是 60~90 天。如果做贷前风控,通常把逾期 ≥ 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(axis=1) # 历史使用率的波动性:标准差比均值更能反映资金紧张程度 df['util_std_6m'] = df[['util_month1', 'util_month2', 'util_month3', 'util_month4', 'util_month5', 'util_month6']].std(axis=1)

这里income_debt_ratio分母加 1 是怕遇到零负债客户直接除零报错,credit_utilization同理。风控里有个经验:信用卡使用率长期超过 80% 的客户违约概率显著上升,所以history_avg_util和util_std_6m这两个衍生变量往往比原始额度字段重要得多。源码里如果只给你原始字段,务必自己加上这类比率和波动性特征,LightGBM 虽然能自动找非线性切分,但我见过不少场景下人工衍生特征仍然能把 AUC 拉高 2~3 个点。

除了数值特征,类别特征也需要处理。年龄、学历、职业这类字段,直接标签编码会引入虚假的序数关系,常见做法是 WOE 编码或目标编码。WOE 编码的思路是计算每个分箱里坏客户占比相对于整体坏客户占比的对数比值,公式是WOE = ln(坏样本占比 / 好样本占比),它天然适合逻辑回归这种线性模型;树模型则可以直接吃原始类别,设置categorical_feature参数就行。源码里如果统一用LabelEncoder处理了所有类别列,跑 LightGBM 时记得改回原生类别特征,否则类别数量很多时会因为乱序分裂导致性能下降。

2.3 模型选型:为什么风控场景偏爱 LightGBM 和逻辑回归

个人贷款违约预测的模型选择,其实是在准确率、可解释性、稳定性和部署成本之间做权衡。常见做法是先用逻辑回归做基线,再用 LightGBM 做主力模型。逻辑回归的优势是权重可以直接换算成风险评分——score = a + b * log(odds),业务人员听一遍就能理解「收入负债比每下降一个档次,评分扣多少分」;LightGBM 的优势是能捕捉非线性关系和特征交互,在样本量足够时 AUC 通常比逻辑回归高 5~8 个点。但树模型有个隐患:对特征分布的漂移更敏感,上线后如果客户群体变化,分数分布可能整体偏移,需要频繁监控。

# 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_size=0.2, random_state=42, stratify=y ) 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, label=y_train), num_boost_round=500, valid_sets=[lgb.Dataset(X_val, label=y_val)], callbacks=[lgb.early_stopping(stopping_rounds=50)] )

这段配置里有几个参数是风控场景的默认选择:min_child_samples=100比通用调参教程里的 20 大得多,因为信贷数据噪声大,叶子节点样本太少学的全是偶然模式;feature_fraction=0.8和bagging_fraction=0.8是双随机,能明显降低方差;early_stopping用验证集 AUC 决定树的数量,防止在训练集上无限迭代。如果你发现验证集 AUC 很高但上线后效果崩了,大概率是max_depth和num_leaves给太大,模型记住了训练集里的非稳定规律——这时候优先调小num_leaves到 15~31 之间,再考虑加正则。

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.8,pd.concat和np.where的默认行为可能有细微差别。更常见的是 LightGBM 在 Mac 上编译失败,这时候直接pip install lightgbm --no-binary lightgbm大概率还是编译失败,我通常建议 Windows 用户直接用conda install -c conda-forge lightgbm,Mac 用户检查是否装了 libomp。往 requirements.txt 里逐行看,如果它写的明明是scikit-learn==0.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, bins=10): pred = model.predict(X_val) df_eval = pd.DataFrame({'pred': pred, 'y': y_val}) df_eval['bin'] = pd.qcut(df_eval['pred'], q=bins, duplicates='drop') grouped = df_eval.groupby('bin', observed=True).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', observed=True)这个参数在 pandas 2.0 之前不加会报警告,如果源码里没加而你本地 pandas 版本较新,跑出来的结果可能把bin列当成类别变量做笛卡尔展开,输出一堆空组。分组结果里重点看最后两行的cum_bad_ratio:如果最低分组的累计坏账占比已经超过 60%,说明模型在低分段能识别出大头风险,这个模型就算合格了。

3.3 说明文档要读哪几页:数据字典、特征列表、模型参数表

拿到说明文档,别从头到尾通读,先翻目录找三样东西:数据字典、特征列表、模型参数表。数据字典会告诉你每个字段的含义、类型、缺失率和取值范围,这是后面排查特征泄漏的依据——比如如果发现有is_default这样直接等于标签的字段混在特征里,训练时 AUC 可能是 0.99,上线后直接崩成 0.5。特征列表则会告诉你最终进了模型的字段集合,以及是否有衍生字段参与了建模。我习惯把数据字典里缺失率超过 50% 的字段列出来,在训练脚本里确认它们是不是被dropna删掉了——如果没有删除而是填充了均值,一定要在文档里记录填充值,否则部署到生产环境时,新数据走同样的预处理管道才能保持一致的分布。

模型参数表是部署时最关键的参考。源码作者如果贴了完整的params字典,你要逐个核对:objective是不是 binary,metric是不是 auc,learning_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_file='model/lgb_auc_0.7821.txt') # 预测时需要传入特征矩阵,注意列顺序必须和训练时一致 pred = model.predict(df_val[feature_columns].values)

这里有个血泪经验:如果模型是用lgb.train训练得到的Booster对象,用pickle.dump保存后在另一台机器上pickle.load,经常会因为 LightGBM 版本不一致报Unsupported data type或直接段错误。反过来,如果训练时用的是LGBMClassifier(sklearn 接口),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_file='model/lgb_model.txt') with open('model/feature_columns.json', 'r', encoding='utf-8') as f: FEATURE_COLUMNS = json.load(f) # 缺失率高的字段用训练集的中位数填充,避免预测时空值传给模型 FILL_MEDIAN = json.load(open('model/fill_median.json', 'r', encoding='utf-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(host='0.0.0.0', port=8000, threaded=True)

这段代码里有个关键细节:FILL_MEDIAN字典是训练时统计好的,而不是预测时现算——如果你在服务里对每个请求算中位数,单机并发一高就卡死,而且新数据的分布和训练集不一致时,现算中位数会漂移。另一个细节是df_input.values必须和训练时特征顺序一致,所以FEATURE_COLUMNS这个 JSON 文件一定要在训练脚本里保存,不能靠记忆。threaded=True让 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_state=0) offline_preds = model.predict(sample.values) online_preds = [] for _, row in sample.iterrows(): resp = requests.post('http://localhost:8000/predict', json=row.to_dict()) online_preds.append(resp.json()['probability']) mae = mean_absolute_error(offline_preds, online_preds) print(f'MAE 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, key=lambda 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_weight=scale_pos, n_estimators=500, learning_rate=0.05, num_leaves=31 )

scale_pos_weight的作用等价于给正样本的梯度放大scale_pos倍,让模型更关注少数类。但要注意,放大倍数太大会让预测概率整体抬升,导致业务上设定的 0.5 阈值不再适用——上线前必须重新用验证集选阈值。选择阈值的方法是找到「漏报率」和「误报率」的平衡点,通常看 KS 曲线或者 PR 曲线:如果业务更怕坏账漏出去,就把阈值调低,比如 0.3;如果更怕误杀好客户,就把阈值调高。源码里如果自带阈值搜索函数,一般是基于验证集的 F1 分数或约登指数,你可以直接用,但务必确认它选出来的阈值在你业务场景下合理——F1 最大化不等于利润最大化。

5.4 时间序列的验证集切分:随机打散会让 AUC 虚高 0.1

信贷数据是强时序数据,客户进件时间、还款行为都在时间轴上演进。如果源码里直接用了train_test_split(test_size=0.2, random_state=42),没有按时间排序,那么训练集和测试集里会混入同一个月放款的客户,模型能「偷看」到近邻时间段的行为模式。常见的正确姿势是按放款月份切分:例如用 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 告诉你模型什么时候悄悄失效

模型上线不是终点,是起点。个人贷款客群会随宏观环境、渠道政策、产品利率变化而变化,三个月前训练的特征分布可能已经完全走样。业界通用的监控指标是 PSI(Population Stability Index),它衡量特征在训练集和当前生产样本之间的分布偏移程度。PSI 小于 0.1 表示稳定,0.1~0.25 需要关注,大于 0.25 表示显著偏移、模型需要重新训练。每个特征都应该单独算 PSI,模型预测概率本身也算一个 PSI——如果概率的 PSI 飘到 0.25 以上,基本可以确定模型已经失效。

# PSI 计算:评估单个特征在训练集与生产样本间的分布偏移 def calculate_psi(expected, actual, buckets=10): def _get_bucket_ratios(data): # 按训练集的分位数切分,生产数据套用同一分位点 breakdown, _ = pd.qcut(expected, q=buckets, retbins=True, duplicates='drop') exp_ratios = breakdown.value_counts(normalize=True).sort_index() act_buckets = pd.cut(actual, bins=_drop_duplicate_bins(breakdown.cat.categories)) act_ratios = act_buckets.value_counts(normalize=True).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', ascending=False).reset_index(drop=True) 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.11,6 个月逾期频率贡献 +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', ascending=False) 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 当真,这些坑我都替你踩过了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询