简介:面向银行客户逾期行为预测这一典型风控场景,提供一套可直接运行的Python机器学习毕业设计源码与完整数据。资源聚焦信用风险评估中的二分类问题,通过客户历史交易与属性数据构建预测模型,适合计算机、数据科学等相关专业学生用于毕业设计、课程设计、期末大作业,也适合希望掌握机器学习项目完整流程的实战学习者。压缩包内含10个文件,主要由CSV格式的训练集、测试集与提交样例,xlsx字段说明文档,以及3个可执行的Jupyter Notebook组成——分别完成用户逾期数据EDA探索、XGBoost+LR与GBDT+LR模型构建,此外还提供Python辅助脚本和txt使用说明,帮助用户快速了解数据结构、运行顺序与输出格式。整体压缩包约127.3MB,项目已经导师指导并通过严格调试,解压后按说明即可运行。当前已有229人学习,对准备答辩或沉淀建模经验者而言,是一份涵盖数据探索、特征工程、模型对比与结果提交的完整参考。
1. 银行客户逾期行为预测:不是堆模型,而是先解决“标签和特征”两件事
银行客户逾期行为预测,是风控领域出现频率最高的二分类问题之一。很多人拿到一份“python实现基于机器学习的银行客户逾期行为预测源码+全部数据.zip”时,第一反应往往是赶紧跑通模型、看AUC,但真正决定这套方案能不能落地价值的,是标签怎么定义、特征怎么对齐时间窗口、样本不均衡怎么处理。我在做信贷风控项目时,见过太多空有高AUC的模型上线后效果崩盘,也见过用简单逻辑回归配合好特征打平复杂模型的案例。下面的内容面向两类人:想用Python上手机器学习的初学者,以及需要把预测结果解释给业务方的分析师。我会按数据、特征、建模、评估、避坑的顺序,把实现路径和踩过的坑讲透。
2. 数据清洗与特征工程:逾期预测的胜负手在数据不在算法
2.1 拿到原始数据先做什么:缺失值、时间戳和标签构造
一份典型的银行客户逾期数据,通常包含客户ID、申请时间、年龄、收入、负债、历史还款记录、逾期次数、征信查询次数,以及一个“是否逾期”的标记。但直接把原始表丢给模型是跑不出好效果的。我一般先查三件事:缺失值分布、日期字段格式、标签的统计口径。
先看一段最小数据清洗代码:
import pandas as pd import numpy as np # 读取原始数据,gbk 是银行导出数据的常见编码 df = pd.read_csv('bank_customer_data.csv', encoding='gbk', engine='c') # 检查缺失率,超过 80% 的列先移出特征候选 missing_ratio = df.isnull().mean() drop_cols = missing_ratio[missing_ratio > 0.8].index.tolist() df.drop(columns=drop_cols, inplace=True) # 日期字段统一转成 datetime,后续特征都基于真实时间偏移 df['apply_time'] = pd.to_datetime(df['apply_time']) df['last_overdue_time'] = pd.to_datetime(df['last_overdue_time']) df['overdue_flag'] = df['overdue_flag'].astype(int) print(f"数据量 {df.shape[0]},剩余特征数 {df.shape[1]},逾期率 {df['overdue_flag'].mean():.4f}")这段代码解决三件事:把缺失率过高的列先剔掉,避免后续填充制造噪声;把日期字段转成标准格式,因为计算“申请时间距上次逾期的时间差”依赖它;把标签统一成整数。参数上,encoding='gbk'是银行导出的常见编码,报错就换成utf-8或检查分隔符;engine='c'能加速大文件解析,遇到混合编码时需要换回python引擎。
提示:如果标签列直接用“历史上有没有逾期”,模型学到的就是过去而不是未来,务必先和业务方确认逾期观察窗口。
这里要强调一个常见误区:逾期行为预测里的“标签”,不能简单等于“历史是否逾期”。更常见的做法是设定某个观察点,看该点未来30天或90天是否发生逾期。如果原始数据的标签字段已经固化,你需要先重算。
# 原始数据里包含 obs_date(观察点)和 overdue_date(逾期发生日期) df['overdue_in_30d'] = ((df['overdue_date'] - df['obs_date']).dt.days <= 30).astype(int) # 观察点后超过30天的逾期不算当前窗口 df.loc[df['overdue_date'] > df['obs_date'] + pd.Timedelta(days=30), 'overdue_in_30d'] = 0这段逻辑看着简单,却是整个逾期预测里最容易泄漏的地方:如果你用“已发生的逾期”去做特征,模型学到的其实是结果而不是预测。我一般动手前会先问业务方,逾期是首期逾期、滚动逾期还是自然人逾期,口径不一样,标签和特征就完全不一样。
2.2 特征工程:把信贷行为转成机器学习能吃的数值
特征工程是银行客户逾期预测收益最高的部分。常见做法是把特征分成客户基本属性、收入负债、历史行为、外部数据几类。对风控来说,比单列数值更有效的是“比率”和“差值”,因为额度、收入这些原始数字在不同客户间量纲差异太大。
# 基础特征:额度使用率、收入负债比 df['credit_utilization'] = df['loan_balance'] / df['credit_limit'] df['debt_to_income'] = df['total_debt'] / (df['annual_income'] + 1e-6) # 历史行为特征:距上次逾期天数、近6个月逾期次数 df['days_since_last_overdue'] = (df['obs_date'] - df['last_overdue_time']).dt.days df['overdue_count_6m'] = df[['overdue_m1', 'overdue_m2', 'overdue_m3', 'overdue_m4', 'overdue_m5', 'overdue_m6']].sum(axis=1) # 离散特征:学历、地区、申请渠道直接转 category cat_cols = ['education', 'region', 'channel'] df[cat_cols] = df[cat_cols].astype('category')这些特征有效,是因为它们反映真实的偿债压力和行为稳定性:额度使用率高的人,往往现金流已经绷紧;距上次逾期时间越短,说明不良行为越近;近6个月多次逾期的人,大概率是“习惯性逾期”。参数上,1e-6用来防除零,也可以用np.finfo(float).eps。sum(axis=1)在列名匹配上要小心,一旦原始表里某个月份的列缺失,结果会静默偏小。
特征构造完,我会做一次“未来信息审查”:同一客户的多条申请记录里,如果obs_date不同,特征中不能混入后续发生的信息。否则就会制造机器学习的噪声数据,不是随机噪声,而是带有未来信息的伪特征,训练集指标好得出奇,上线立刻失灵。
2.3 样本不均衡处理:逾期样本太少时不要急着上SMOTE
银行客户逾期率通常只有2%到10%,这意味着训练集里正样本非常少。很多人第一反应是上SMOTE过采样,但实际做下来,SMOTE不是默认首选。过采样会让模型看到更多“人为凑出来的正样本”,在真实分布上未必有效,尤其在特征空间复杂、样本量少时,合成样本会放大噪声。
from sklearn.model_selection import StratifiedKFold from imblearn.over_sampling import SMOTE # 先在原始数据上建立分层交叉验证窗口 skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 只在训练折内做SMOTE,验证折保持真实分布 for train_idx, valid_idx in skf.split(X, y): X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx] smote = SMOTE(random_state=42, k_neighbors=5) X_train_res, y_train_res = smote.fit_resample(X_train, y_train)这段代码的关键点有两个:用StratifiedKFold保证每一折正负样本比例与总体一致;SMOTE只作用于训练折,验证折绝对不能重采样。如果你先对全量数据做SMOTE再切分,验证集里混入合成样本,指标虚高,部署时立刻现原形。参数上,k_neighbors=5是默认值,正样本很少时调到3,避免跨类别噪声样本被强行连接。
那么什么情况下才适合SMOTE?我自己的标准是:正样本数量低于1000,或者模型在原始数据上几乎学不到正例特征时,先试过采样;但一定要同时观察精确率和召回率的平衡,而不是只看AUC。这个坑我在第5章还会展开。
2.4 用时间序列切分代替随机切分:避免未来信息泄漏
银行逾期数据本质上带时间戳,随机切分会把“同一时间段的人”同时分到训练和测试里,模型看到的是同一时期的客户画像,而上线后面对的是未来客户。如果宏观经济或政策变化,模型就会水土不服。所以处理数据阶段就要决定切分方式。
# 按 obs_date 排序,取前70%作训练、后30%作测试 df = df.sort_values('obs_date').reset_index(drop=True) train_size = int(len(df) * 0.7) train = df.iloc[:train_size] test = df.iloc[train_size:]切分后,特征、标签的提取要严格分开:
feature_cols = [c for c in df.columns if c not in ['overdue_in_30d', 'overdue_date', 'obs_date']] X_train = train[feature_cols] y_train = train['overdue_in_30d'] X_test = test[feature_cols] y_test = test['overdue_in_30d']这里最容易被忽略的是填充缺失值。如果你在切分前用全量mean()填充,测试集信息就泄漏给了训练集。正确做法是:
# 先用训练集计算填充值,再分别应用到训练集和测试集 fill_values = X_train.median() X_train = X_train.fillna(fill_values) X_test = X_test.fillna(fill_values)按时间切分后,前面70%客户训练,后面30%客户模拟未来。训练集上计算的median()本质上来自历史,测试集只是拿到历史中位数去填充新样本,这才是合规的。
3. 建模与训练:从逻辑回归到提升树,跑通逾期预测的主流程
3.1 先建逻辑回归基线:理解系数比追求精度更重要
在搞复杂模型之前,先跑逻辑回归,有两个好处:一是能快速发现特征泄漏,逻辑回归对极端特征很敏感;二是业务方需要“可解释的分数”,逻辑回归的系数能直接讲故事。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler # 数值列单独做标准化,类别列交给后续 one-hot num_cols = ['credit_utilization', 'debt_to_income', 'days_since_last_overdue', 'age', 'annual_income'] scaler = StandardScaler() X_train_num = scaler.fit_transform(X_train[num_cols]) X_test_num = scaler.transform(X_test[num_cols]) # 用逻辑回归训练基线 lr = LogisticRegression(max_iter=1000, C=0.1, solver='liblinear') lr.fit(X_train_num, y_train)这里C=0.1对应更强的正则化,对高维稀疏特征和噪声特征有抑制作用。solver='liblinear'在样本量大、特征维度不高时更快,而且对L1/L2都支持。训练完,我会把系数和业务逻辑对照一下。如果某个特征系数是负数但业务上正相关,那就要考虑是不是存在特征泄漏或特征间互相矛盾。
当然,逻辑回归的问题也很明显:特征之间的非线性交互能力弱,比如“高额度使用率”和“近6个月逾期多次”同时出现时,风险不是简单相加。这时候需要更灵活的树模型。
3.2 用LightGBM提升效果:核心参数与早停逻辑
LightGBM是银行风控里最常见的机器学习模型之一,速度快,且能直接处理类别特征。下面是典型训练流程:
import lightgbm as lgb # 构造 Dataset,指定类别特征 lgb_train = lgb.Dataset(X_train, y_train, categorical_feature=cat_cols) lgb_valid = lgb.Dataset(X_test, y_test, categorical_feature=cat_cols, reference=lgb_train) params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.03, 'num_leaves': 31, 'max_depth': -1, 'min_child_samples': 20, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 1.0, 'lambda_l2': 5.0, 'verbose': -1 } model = lgb.train( params, lgb_train, num_boost_round=1000, valid_sets=[lgb_valid], callbacks=[lgb.early_stopping(stopping_rounds=100), lgb.log_evaluation(100)] )参数说明:learning_rate=0.03配合num_boost_round=1000是常规搭配,让模型每步走小步,靠早停避免过拟合。num_leaves=31是单棵树的复杂度,对中小样本够用;min_child_samples=20防止叶子节点样本过少;feature_fraction和bagging_fraction是列采样和行采样,能显著减少过拟合;lambda_l1/lambda_l2是正则化,在银行小样本场景下建议都设上,否则特征重要性会被高维离散特征带偏。
训练完要看特征重要性,但不要只看默认的split次数。我一般把gain重要性排序后,和业务经验对照。比如“距最近一次逾期天数”排第一很合理,但如果obs_date本身排进前五,那说明时间泄漏了——观察点字段不应该成为预测因子。
3.3 用五折交叉验证替代直接训练测试:OOF分数更可信
直接使用默认参数训练LightGBM可以跑出不错效果,但要让模型稳定,我会用5折交叉验证来评估参数组合。原因是:单次训练测试切分的结果方差很大,正样本少时,某一折的坏样本分布偏移会误导你。
from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros(len(X_train)) for fold_idx, (tr_idx, va_idx) in enumerate(skf.split(X_train, y_train)): X_tr, X_va = X_train.iloc[tr_idx], X_train.iloc[va_idx] y_tr, y_va = y_train.iloc[tr_idx], y_train.iloc[va_idx] d_tr = lgb.Dataset(X_tr, y_tr, categorical_feature=cat_cols) d_va = lgb.Dataset(X_va, y_va, categorical_feature=cat_cols, reference=d_tr) fold_model = lgb.train( params, d_tr, num_boost_round=1000, valid_sets=[d_va], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)] ) oof[va_idx] = fold_model.predict(X_va, num_iteration=fold_model.best_iteration) print(f"OOF AUC: {roc_auc_score(y_train, oof):.4f}")这段代码输出的是 out-of-fold 预测结果oof:把每一折验证集的预测拼在一起,模拟“模型在没见过的数据上”的表现。后面第6章你会发现,OOF分数还能用来做阈值选择、计算业务指标。调参时,不要每次只看测试集AUC,而要比较OOF AUC,否则你在多次调参中等于在偷偷用测试集做选择,最终评估会偏乐观。
注意这里的np.zeros(len(X_train))需要提前保证X_train.index是连续的,如果前面有过筛选,建议先reset_index(drop=True)再开始,否则oof[va_idx]会错位。
3.4 调参顺序:先定学习率,再看叶子数和正则化
如果不追求极致精度,上面的参数已经够用。但如果你还想压榨一下模型,我建议按这个顺序调,而不是直接网格搜索全部参数。第一步固定learning_rate=0.05,跑num_boost_round=1000,看最优迭代数落在哪里;第二步调num_leaves,好的范围是 16~63,和max_depth联动,num_leaves增大的时候正则化也要跟着加大;第三步调feature_fraction和bagging_fraction,它们对过拟合影响最直接;最后才动min_child_samples和lambda_l2。每个参数单独调,不要一上来就并行网格搜,否则你根本不知道是谁在起作用。
4. 模型评估与可解释性:不要只看AUC,要算业务账
模型训练完,很多人习惯只看AUC,哪边高就选哪边。但银行风控模型是要算钱的,一个线上决策错误可能意味着几十万坏账。所以这一章我会逐项拆开:混淆矩阵、KS、SHAP、阈值和PSI。每个都对应着一个上线前必须回答的问题。
4.1 混淆矩阵与KS:风控场景更该看哪些指标
在风控界,AUC不是业务最关心的。一个逾期率3%的产品,如果模型把几乎所有客户都预测为“不逾期”,AUC也可能有0.7以上,但真正能过滤坏客户的阈值完全没有。所以我会先看混淆矩阵,再看KS、精确率、召回率。
from sklearn.metrics import confusion_matrix # 拿到LightGBM在测试集上的预测概率 prob = model.predict(X_test, num_iteration=model.best_iteration) # 先看默认阈值0.5下的表现 pred_label = (prob >= 0.5).astype(int) cm = confusion_matrix(y_test, pred_label) tn, fp, fn, tp = cm.ravel() precision = tp / (tp + fp) recall = tp / (tp + fn) print(f"精确率: {precision:.3f},召回率: {recall:.3f}")这里有一个非常关键的认知:在逾期率只有5%的数据上,0.5阈值几乎没有意义。你需要根据前5%、前10%的坏样本占比去选阈值。KS统计量是累计正例比例和累计负例比例的差,能直观反映模型区分好坏客户的能力。一般风控认为KS在0.3以上可用,0.4以上算优秀,但要结合逾期率看,逾期率极低时KS很难冲高。
计算KS的代码很短,但要注意排序逻辑:
def ks_statistic(y_true, y_prob): df = pd.DataFrame({'y': y_true, 'prob': y_prob}).sort_values('prob') df['cum_bad'] = (df['y'] == 1).cumsum() / (df['y'] == 1).sum() df['cum_good'] = (df['y'] == 0).cumsum() / (df['y'] == 0).sum() return (df['cum_bad'] - df['cum_good']).max() print(f"KS: {ks_statistic(y_test, prob):.3f}")sort_values('prob')默认升序,累积差最大值出现的点就是模型区分能力最强的分数切点。如果 KS 很低,先不要调参,回去检查特征和标签口径,十有八九是特征没有对齐观察点时点。
4.2 用SHAP解释模型:为什么这个客户被判逾期
银行风控模型上线后,一定会被问:“为什么拒绝这个客户?”答案不能是“模型分数低”。SHAP是一种解释树模型的方法,能给出每个特征对单个样本预测的贡献。处理“黑匣子”问题,关键是先让业务方看到特征方向是否合理。
import shap # LightGBM模型用TreeExplainer explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test[:1000]) # 取一个被预测为高风险的样本做解释 idx = 5 shap.force_plot(explainer.expected_value, shap_values[idx], X_test.iloc[idx])TreeExplainer速度快,但对超大规模数据要小心内存。如果你只需要解释单个样本,可以直接传入一行数据,不必对所有测试集计算。SHAP值加起来等于模型的输出概率(经过logistic转换),因此能说人话:“该客户年收入5万,贡献了+0.12的逾期概率;近6个月逾期3次,贡献了+0.25;总评分从基准0.08上升到0.45。”
注意shap_values在某些LightGBM版本里返回的是一个 list(类别数=2),如果你取shap_values[1]才是正类方向的贡献。新版 shap 更推荐直接用shap_values = explainer(X_test)对象接口。
4.3 阈值选择:从模型分到决策规则的落地方法
模型输出概率,不是最终决策。你要和业务方一起定一条线:线之上拒绝客户,线之下通过客户。此时要算两笔账:漏过的坏账损失,误拒的好客户流失收益。下面是一个简化版本的成本计算。
from sklearn.metrics import confusion_matrix # 每个逾期客户的平均坏账损失 cost_bad # 每个好客户的平均利润 profit_good # 在网格上搜索最优阈值,让净收益最大 thresholds = np.arange(0.05, 0.5, 0.05) best_t, best_profit = 0, -np.inf for t in thresholds: pred = (prob >= t).astype(int) tn_, fp_, fn_, tp_ = confusion_matrix(y_test, pred).ravel() net_profit = tp_ * profit_good - fp_ * profit_good - fn_ * cost_bad if net_profit > best_profit: best_t, best_profit = t, net_profit print(f"最优阈值: {best_t:.2f},净收益: {best_profit:.2f}")这里我故意把公式简化:通过的好客户产生的收益去抵拒绝错杀的损失,逾期客户直接按坏账扣除。实际项目里,你应该把产品利率、催收成本、资金成本都放进去。关键是,别直接拿0.5当决策线,而是在概率上搜索业务收益最大的线。搜索步长0.05够用,样本量大时可以加密到0.01。
4.4 上线前最后一道检查:PSI群体稳定性指标
模型上线后不是一劳永逸。银行客群会随宏观环境变化,特征分布也会漂移。PSI(人群稳定性指数)衡量训练集和上线后实际客群的特征分布差异。当PSI超过0.25时,就该重训模型。
# 简化版PSI计算,按十分位数分组 def psi(expected, actual, bins=10): expected_percent = np.histogram(expected, bins=bins, range=(0, 1))[0] / len(expected) actual_percent = np.histogram(actual, bins=bins, range=(0, 1))[0] / len(actual) psi_value = np.sum((actual_percent - expected_percent) * np.log(actual_percent / expected_percent + 1e-6)) return psi_value # 用训练集OOF概率作为expected,最新一批客群预测结果作为actual psi_score = psi(oof, prob_new_data) print(f"PSI: {psi_score:.3f}")注意,log里加1e-6避免某一区间概率为0时爆掉。实际项目中我会对每个关键特征单独算PSI,而不是只算分数PSI。如果一个特征分箱跨月份剧烈变化,即使模型总AUC没跌,也要排查是不是数据采集口径变了。这一步能提前发现模型悄悄失效的苗头。
5. 逾期预测最容易交学费的5个坑:现象、原因与解决办法
下面这5个坑,每一个都有人在这类项目里真金白银交过学费。我按现象、原因、解决三步写,方便你直接对照排查。
5.1 训练集AUC很高,上线后却失灵
现象:训练集AUC 0.95,OOF AUC也有0.9,但上线后每天的实际逾期命中率只有原来的三分之一。
原因:最常见的是时间泄漏。比如原始数据同时存在“申请时刻的负债”和“申请之后才发生的还款记录”,特征工程时把后者也当成已知值;或者标签定义里直接把未来30天逾期结果当成了特征筛选依据。
解决:回到特征构造阶段,逐列问一句话“这个字段在观察点obs_date当天能不能查到”。不能查到的列全部删掉。然后用按时间排序的切分重新训练,并用最新一批客群做回测。排查时可以先打印特征重要性和相关系数:
# 检查关键泄漏嫌疑字段和标签的相关性 leak_cols = ['repay_amount_latest', 'account_balance', 'total_overdue'] for col in leak_cols: if col in df.columns: corr = df[col].corr(df['overdue_in_30d']) print(f"{col}: {corr:.3f}") # 如果相关性超过0.3,大概率是泄漏,需要回到特征表确认时点一个正常行为特征和标签的相关性很少能到0.3,除非它本身就是未来结果。比如“已还款金额”和“未来是否逾期”高度负相关,这在逻辑上就不对,因为观察点当天你不可能知道客户后面还了多少钱。如果时间回溯困难,至少用obs_date做分组切分,避免同一时期客户同时落在训练和验证里。
5.2 SHAP解释里出现了“未来余额”这种变量
现象:业务方看SHAP图时突然说:“不对啊,这个客户的余额是上个月才变的,模型怎么用到了?”
原因:特征工程把多个时间点的记录横向展开了,但没对齐观察点。比如客户最新余额取的是整张表的最新一条记录,而不是obs_date当天及之前的最后一条,于是未来信息混进特征。
解决:在构造“最新值”类特征时,要按obs_date做截断。用 pandas 的merge_asof可以处理:
# 使用 merge_asof 找到每个客户在 obs_date 当天的最新余额 merged = pd.merge_asof( df.sort_values('obs_date'), balance_df.sort_values('date'), left_on='obs_date', right_on='date', by='customer_id', direction='backward' )merge_asof的direction='backward'表示取观察点之前最近的一条记录,天然排除了未来数据。这个逻辑最好写成一个通用的“特征时点对齐函数”,把每一张流水表都在obs_date处截断,而不是简单取最新。过程虽然麻烦,但能避免绝大多数黑匣子翻车。上线前再抽查10个被拒绝客户的SHAP图,让业务方一起确认特征方向,比事后解释诚实得多。
5.3 SMOTE后在真实场景反而变差
现象:用了SMOTE,训练集精确率漂亮,但在测试集上精确率直接掉一半,而且是莫名其妙地掉。
原因:先在全量数据上做SMOTE再切分,或者对验证集也做了过采样。SMOTE合成样本在特征空间里连接了两个相邻样本,如果验证集里也有合成样本,模型等于“见过答案”。
解决:只对训练折做SMOTE,验证折保持真实分布。更简单的是先不用SMOTE,改用scale_pos_weight。在LightGBM里这样设置:
params['scale_pos_weight'] = (y_train == 0).sum() / (y_train == 1).sum()这个参数的含义是让模型在计算损失时,把少数类的权重放大到与多数类接近,不需要人为生成样本。实际项目里,scale_pos_weight的效果通常比SMOTE稳定,因为它不会改变特征空间的真实分布。如果用了SMOTE,也要用原始分布的数据做OOF验证,否则你看到的所有指标都不可信。
5.4 LightGBM训练速度极慢,而且每次结果波动大
现象:同一个params,换一次随机种子,特征重要性前几名就变了,训练时间也从10分钟跳到40分钟。
原因:num_leaves调得太大,min_child_samples太小,导致树又深又碎;或者类别特征没有声明成category,LightGBM还在按数值处理,白白增加计算负担。
解决:先用小数据集跑通,固定随机种子。给一组保守参数:
params = { 'learning_rate': 0.05, 'num_leaves': 31, 'min_child_samples': 50, 'feature_fraction': 0.7, 'bagging_fraction': 0.7, 'bagging_freq': 1, 'random_state': 42, }类别特征在lgb.Dataset里用categorical_feature显式声明,并且确保类别列是全数值编码,比如0、1、2,而不是字符串。字符串类别会让LightGBM报错,或者在自动转换时变成无意义的数值。调参时,一次只动一个参数,每轮都看OOF AUC和训练时间,免得两个参数互相影响,最后分不清是谁在起作用。
5.5 模型分数和业务通过率对不上
现象:模型预测的逾期概率平均是0.08,但实际业务里逾期率只有0.03,模型分数明显抬高。
原因:训练集的标签口径和业务口径不一致,比如训练集把“宽限期后还款”也标成逾期,而业务方的逾期定义是“逾期90天以上”;或者训练集来自一个高风险的测试客群,和目标客群分布不同。
解决:和业务方重对齐标签口径,重新筛选训练样本。然后计算分数PSI和关键特征PSI,定位哪一个特征分布偏移最严重。下面是一个快速PSI核对方法:
def psi(expected, actual, bins=10, min_value=1e-6): expected_percent = np.histogram(expected, bins=bins, range=(0, 1))[0] / len(expected) actual_percent = np.histogram(actual, bins=bins, range=(0, 1))[0] / len(actual) return np.sum((actual_percent - expected_percent) * np.log((actual_percent + min_value) / (expected_percent + min_value))) # 对比训练集OOF分数和10月份上线客群分数 month_score_psi = psi(oof, october_scores) print(f"10月客群PSI: {month_score_psi:.3f}")如果分数PSI大于0.25,基本可以确认客群变了,模型必须重训。如果只有某个特征PSI高,比如收入分布变了,可以只对这个特征做分箱校准,不一定全量重训。这里的分箱要和训练时保持一样,否则算出来的PSI没有可比性。
6. 用OOF分数做最后的验证:三个动作和一个调参技巧
训练阶段的oof分数不要扔,它是你最后一个能诚实的评估依据。我先做三件小事:用OOF选阈值而不是测试集;做特征稳定性检查;最后用全量数据加早停迭代轮数再训练几轮。
# 用OOF分数选阈值,避免反复使用测试集导致评估失真 best_t = select_threshold_by_profit(oof, y_train, thresholds=thresholds) print(f"OOF最优阈值:{best_t:.2f}") # 全量数据上继续训练,轮数在早停最优迭代基础上增加20 final_model = lgb.train( params, lgb.Dataset(X_train, y_train, categorical_feature=cat_cols), num_boost_round=model.best_iteration + 20 )这段代码里,select_threshold_by_profit是你在4.3节里写的那个函数,直接用OOF结果调用。为什么不用测试集选阈值?因为你一旦在多次调参中用测试集选阈值,测试集就不再代表“没见过的数据”,最终上线评估会偏乐观。OOF是从5折交叉验证拼出来的,每个样本的预测都来自没有见过该样本的模型,用它选阈值更稳。
第二个动作是特征稳定性检查。上线前把训练集按月份拆成两半,分别计算每个特征的均值和方差,再和最近一批线上数据对比。如果某个特征在线上缺失率超过训练时的5%,多半是上游数据源出了问题,要在规则里先拦截。第三个动作是固定随机种子:LightGBM的bagging_fraction和feature_fraction依赖随机数,不固定random_state,你每次训练得到的特征重要性排序都不同,业务方会怀疑模型不稳定。
最后一个技巧是在全量训练时,用交叉验证得到的最佳迭代数加20到50轮。全量数据比验证折更多,模型还能再收敛一点;但加太多就会过拟合。我一般加30轮,然后在测试集上验证一次,如果没有明显掉点就保留。
我个人的血泪教训是:在逾期预测项目里,真正拉开差距的往往不是模型选了LightGBM还是XGBoost,而是标签口径和特征时点。源码和数据包只能让你跑通流程,能不能落到真实场景,取决于你有没有花足够时间做时间序列切分和PSI校验。希望帮到你。
本文还有配套的精品资源,点击获取