简介:这份资源围绕京东借贷需求预测这一真实金融业务场景,系统整理了机器学习从数据预处理到模型上线的完整实践链路,适合具备一定Python与机器学习基础、希望积累金融风控与需求预测项目经验的学习者。压缩包共8个文件,以5个py脚本为主,辅以1个md说明文档、1个numbers特征设计表和1个xlsx特征清单,整体约575KB,体积轻量却覆盖了特征工程、模型训练与集成等关键环节。内容涉及数据清洗与标准化、特征选择、LightGBM与XGBoost等模型训练、stacking集成以及模型评估调优,并配有特征设计说明,便于读者理解每一步的输入输出与设计意图。已有223人学习下载,可作为金融借贷需求预测方向的实战参考,帮助读者掌握从原始数据到预测结果的完整建模思路与脚本组织方式。
1. 借贷需求预测这套源码,到底能跑出什么结果
金融风控圈里有个共识:借贷需求预测比违约预测更难做。违约预测有明确的标签,需求预测的标签往往是连续值,而且受营销节奏、节假日、用户生命周期影响极大。JDD_Loan_Forecasting-master 这个包,就是冲着这个场景来的——用机器学习把用户未来的借贷需求量化出来,帮资金端提前做头寸安排。
包里的文件结构很直白:lightGBM_JDD.py是主模型,genfeature.py负责特征生成,stacking.py做模型融合,lr_xgb_ensamble.py是逻辑回归加 XGBoost 的集成方案,useless.py看名字就知道是废弃实验的回收站。配套的features_list.xlsx和特征设计.numbers是特征字典,README.md交代了运行顺序。这套东西适合谁?如果你手头有用户行为宽表,想快速搭一个需求预测的 baseline,或者想看看工业界怎么把 LightGBM 和 Stacking 串起来,这个包能省你不少搭框架的时间。但别指望开箱即用——数据格式、特征列名、标签定义都得按你自己的业务改。
2. 特征工程流水线:从原始行为到模型输入
2.1 特征设计的底层逻辑
借贷需求预测的特征体系,核心就三类:历史借贷行为、用户活跃度、外部触发信号。features_list.xlsx里列的特征大概率围绕这几个维度展开。历史借贷行为包括过去 N 个月的借款次数、平均借款金额、还款准时率、当前待还笔数;用户活跃度看登录频次、浏览理财页面的时长、点击借款入口的次数;外部触发信号可能是营销活动曝光、额度调整通知、利率变动提醒。
为什么这么设计?因为借贷需求本质上是「意愿 × 能力 × 触发」的乘积。意愿靠活跃度捕捉,能力靠历史还款表现衡量,触发靠营销事件标记。genfeature.py干的就是把原始日志按用户 ID 聚合,再按时间窗口切片,生成这些衍生指标。
2.2 特征生成脚本的实操拆解
genfeature.py通常是一个批处理脚本,输入是用户行为明细表,输出是宽表。我一般会按下面的结构改:
import pandas as pd import numpy as np from datetime import timedelta # 读取原始行为日志,假设字段:user_id, event_time, event_type, amount raw = pd.read_csv('user_behavior.csv', parse_dates=['event_time']) # 定义观察窗口和预测窗口 OBS_DAYS = 90 # 用过去90天行为做特征 PRED_DAYS = 30 # 预测未来30天是否有借贷需求 # 按用户聚合历史借贷行为 loan_events = raw[raw['event_type'] == 'loan_apply'].copy() loan_agg = loan_events.groupby('user_id').agg( loan_cnt_90d=('event_time', 'count'), loan_amt_avg=('amount', 'mean'), loan_amt_sum=('amount', 'sum'), last_loan_days=('event_time', lambda x: (pd.Timestamp.now() - x.max()).days) ).reset_index() # 活跃度特征:登录、浏览、点击 active_events = raw[raw['event_type'].isin(['login', 'view_loan_page', 'click_loan_btn'])] active_pivot = active_events.pivot_table( index='user_id', columns='event_type', values='event_time', aggfunc='count' ).fillna(0).reset_index() active_pivot.columns = ['user_id', 'login_cnt', 'view_cnt', 'click_cnt'] # 合并特征宽表 features = loan_agg.merge(active_pivot, on='user_id', how='left').fillna(0) # 生成标签:未来30天内是否有借款申请 future_loan = loan_events[loan_events['event_time'] > pd.Timestamp.now() + timedelta(days=0)] future_loan['label'] = 1 label_df = future_loan[['user_id', 'label']].drop_duplicates() features = features.merge(label_df, on='user_id', how='left') features['label'] = features['label'].fillna(0) features.to_csv('feature_wide_table.csv', index=False)这段代码的逻辑说明:先按事件类型拆出借贷行为和活跃行为,分别聚合后再 merge。OBS_DAYS和PRED_DAYS是两个关键参数——观察窗口太短,特征噪声大;太长,用户行为漂移严重。我一般会跑 30/60/90 三组对比,看验证集 AUC 选最优。last_loan_days这个特征很关键,它捕捉了用户的「借贷冷却期」,很多用户还完款后一段时间内不会再借。
参数怎么改?如果你的数据量小,把loan_cnt_90d换成loan_cnt_30d,避免稀疏。如果用户行为事件类型多,active_pivot那步可以加更多列,但注意维度爆炸。
2.3 特征筛选与特征字典的用法
features_list.xlsx不是摆设。打开它,你会看到特征名、含义、计算逻辑、预期方向(正相关/负相关)。我一般会拿它做两件事:一是核对genfeature.py生成的列是否齐全,二是做初步的特征筛选——把预期方向与实际 IC 值符号相反的特征标出来,大概率是计算逻辑写反了或者数据泄漏。
常见做法是用 LightGBM 的feature_importance做一轮粗筛,再结合 IV 值(信息价值)做细筛。useless.py里可能就有这类实验代码,虽然名字叫 useless,但里面的特征淘汰逻辑值得翻一翻。
3. LightGBM 主模型训练:参数、调参与验证策略
3.1 为什么选 LightGBM 而不是 XGBoost
lightGBM_JDD.py是主模型脚本,选 LightGBM 而不是 XGBoost,核心原因是直方图算法 + Leaf-wise 生长策略。借贷需求预测的特征宽表通常有几百列,样本量在百万级,LightGBM 的训练速度比 XGBoost 快 3 到 5 倍,内存占用也低。而且 Leaf-wise 策略在拟合非线性关系时更容易收敛到低 loss。
但 Leaf-wise 有个坑:容易过拟合。所以num_leaves不能设太大,一般控制在2^max_depth以内。我通常从num_leaves=31起步,配合min_data_in_leaf=50做正则。
3.2 训练脚本的核心参数与代码
import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score import pandas as pd import numpy as np # 读取特征宽表 df = pd.read_csv('feature_wide_table.csv') X = df.drop(['user_id', 'label'], axis=1) y = df['label'] # 定义参数:二分类任务 params = { 'objective': 'binary', 'metric': 'auc', 'boosting_type': 'gbdt', 'num_leaves': 31, 'max_depth': 6, 'learning_rate': 0.05, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'min_data_in_leaf': 50, 'lambda_l2': 1.0, 'verbose': -1, 'seed': 42 } # 5折交叉验证 skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) auc_scores = [] best_iters = [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] dtrain = lgb.Dataset(X_train, label=y_train) dval = lgb.Dataset(X_val, label=y_val, reference=dtrain) model = lgb.train( params, dtrain, num_boost_round=2000, valid_sets=[dval], callbacks=[lgb.early_stopping(stopping_rounds=100), lgb.log_evaluation(period=200)] ) y_pred = model.predict(X_val, num_iteration=model.best_iteration) auc = roc_auc_score(y_val, y_pred) auc_scores.append(auc) best_iters.append(model.best_iteration) print(f'Fold {fold+1} AUC: {auc:.4f}, Best Iter: {model.best_iteration}') print(f'Mean AUC: {np.mean(auc_scores):.4f} +/- {np.std(auc_scores):.4f}')逻辑说明:StratifiedKFold保证每折的正负样本比例一致,这对借贷需求预测很重要——正样本通常只占 5% 到 15%。early_stopping防止过拟合,stopping_rounds=100意味着验证集 AUC 连续 100 轮不提升就停。best_iteration是最终模型该用的树数量。
参数怎么调?learning_rate和num_leaves是联动关系。学习率降到 0.02,num_leaves可以提到 63,但训练时间翻倍。我一般先用 0.05 跑一轮看 baseline,再决定要不要细调。feature_fraction和bagging_fraction低于 0.7 时,模型方差会明显增大,除非特征维度极高(超过 1000 列),否则不建议压太低。
3.3 验证策略的坑:时间泄漏与样本权重
借贷需求预测最容易翻车的地方是时间泄漏。如果你用随机 K 折,未来信息会通过特征聚合窗口漏进训练集。正确做法是按时间切分:用前 6 个月训练,后 1 个月验证。lightGBM_JDD.py如果用的是随机 K 折,你得改成TimeSeriesSplit。
另一个坑是样本权重。借贷需求有强烈的季节性,节假日前后需求激增。如果不加权重,模型会偏向平稳期样本。常见做法是按月份给样本加权,需求高峰月的权重设为 1.5 到 2.0。
4. Stacking 融合与 LR+XGB 集成:怎么叠才不翻车
4.1 Stacking 的两层结构设计
stacking.py实现的是典型的两层 Stacking:第一层用多个基模型(LightGBM、XGBoost、逻辑回归)分别预测,第二层用一个小模型(通常是逻辑回归或浅层树模型)把第一层的输出作为特征再预测。
为什么这么叠?单一模型容易在某个特征子空间上过拟合,Stacking 通过多模型投票降低方差。但 Stacking 的坑在于:第一层模型的交叉验证预测必须严格用 out-of-fold 方式生成,否则第二层会过拟合。
from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier import xgboost as xgb import lightgbm as lgb import numpy as np # 第一层基模型 base_models = { 'lgb': lgb.LGBMClassifier(n_estimators=500, num_leaves=31, learning_rate=0.05), 'xgb': xgb.XGBClassifier(n_estimators=500, max_depth=6, learning_rate=0.05), 'lr': LogisticRegression(C=1.0, max_iter=1000) } # 生成 out-of-fold 预测 skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof_preds = np.zeros((X.shape[0], len(base_models))) test_preds = np.zeros((X.shape[0], len(base_models))) for i, (name, model) in enumerate(base_models.items()): for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model_clone = model.__class__(**model.get_params()) model_clone.fit(X_train, y_train) oof_preds[val_idx, i] = model_clone.predict_proba(X_val)[:, 1] print(f'{name} OOF AUC: {roc_auc_score(y, oof_preds[:, i]):.4f}') # 第二层:逻辑回归融合 meta_model = LogisticRegression(C=0.5, max_iter=1000) meta_model.fit(oof_preds, y) final_pred = meta_model.predict_proba(oof_preds)[:, 1] print(f'Stacking AUC: {roc_auc_score(y, final_pred):.4f}')逻辑说明:oof_preds存的是每个基模型在 out-of-fold 上的预测概率,第二层用这些概率做输入。C=0.5是第二层逻辑回归的正则强度,比默认的 1.0 更保守,防止第二层过拟合。如果第一层模型 AUC 差异很大(比如 LR 只有 0.65,LGB 有 0.78),第二层可能会给 LR 分配负权重,这时候要么去掉弱模型,要么加非负约束。
4.2 LR+XGB 集成的适用场景
lr_xgb_ensamble.py走的是另一条路:逻辑回归捕捉线性关系,XGBoost 捕捉非线性关系,两者加权平均。这种方案比 Stacking 简单,可解释性也更好。权重怎么定?常见做法是按验证集 AUC 做网格搜索,或者直接用scipy.optimize.minimize优化权重。
我一般会先跑 LR 和 XGB 单独看 AUC,如果差距在 0.03 以内,加权平均能提升 0.005 到 0.01;如果差距超过 0.05,加权平均基本没用,不如直接上 Stacking。
4.3 融合模型的线上部署注意点
融合模型上线时,最大的坑是特征一致性。第一层模型用的特征必须和第二层训练时完全一致,包括缺失值填充方式、归一化参数。我见过有人离线用 StandardScaler 归一化,线上忘了同步,结果 LR 的预测直接崩掉。建议把预处理逻辑封装成sklearn.Pipeline,离线训练和线上推理共用同一套代码。
5. 避坑与排查:那些让我重跑过模型的坑
5.1 特征列顺序不一致导致预测结果全错
现象:离线验证 AUC 0.78,线上部署后预测值全部集中在 0.5 附近。原因:训练时特征列顺序是 A、B、C,线上推理时数据框列顺序变成了 C、A、B。LightGBM 按列索引读取特征,顺序错了等于特征全乱。解决:在genfeature.py输出时固定列顺序,保存一个feature_order.json,线上推理前按这个顺序重排。
5.2 标签定义窗口与特征窗口重叠
现象:验证集 AUC 高得离谱(0.95+),上线后效果暴跌。原因:特征计算窗口是过去 90 天,标签定义是「未来 30 天是否有借款」,但代码里pd.Timestamp.now()取的是当前时间,导致部分未来信息漏进了特征。解决:所有时间窗口必须基于一个固定的「观察点日期」,特征只用观察点之前的数据,标签只用观察点之后的数据。这个日期要作为参数传入,不能硬编码。
5.3 LightGBM 的min_data_in_leaf设太小导致过拟合
现象:训练集 AUC 0.92,验证集 AUC 0.71,差距超过 0.2。原因:min_data_in_leaf默认是 20,对于百万级样本来说太小,叶子节点里只有几十个样本,模型把噪声也学进去了。解决:把min_data_in_leaf提到 100 到 200,同时配合lambda_l2加到 5.0 到 10.0。如果还过拟合,降低num_leaves到 15。
5.4 Stacking 第二层用了原始特征
现象:Stacking 的 AUC 比单模型还低。原因:第二层训练时不小心把原始特征也拼进去了,导致第二层模型直接记住了原始特征与标签的关系,第一层输出被忽略。解决:第二层的输入只能是第一层的 out-of-fold 预测,绝对不能混入原始特征。代码里用oof_preds单独建 DataFrame,不要和X合并。
5.5 样本权重与交叉验证的冲突
现象:加了时间衰减权重后,交叉验证的 AUC 波动极大(±0.05)。原因:StratifiedKFold按标签分层,但权重是按时间衰减的,导致某些折的权重分布极不均匀。解决:改用TimeSeriesSplit,或者手动按时间分桶后再做分层。权重归一化到均值为 1,避免某些折的总权重过大。
6. 从离线到线上:模型监控与迭代的一个实用技巧
模型上线不是终点。借贷需求预测的模型衰减速度比违约预测快得多——用户行为模式可能两三个月就变一轮。我一般会做两件事:PSI 监控和滚动重训。
PSI(Population Stability Index)用来监控特征分布漂移。计算方式很简单:把训练集的特征分箱,统计每个箱的样本占比,再对线上最近 7 天的数据做同样分箱,算 PSI 值。PSI 超过 0.2 就触发告警,超过 0.3 就得重训。
import numpy as np import pandas as pd def calculate_psi(expected, actual, bins=10): """计算单个特征的 PSI""" # 按训练集分位数分箱 breakpoints = np.percentile(expected, np.linspace(0, 100, bins + 1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf expected_counts = np.histogram(expected, bins=breakpoints)[0] / len(expected) actual_counts = np.histogram(actual, bins=breakpoints)[0] / len(actual) # 避免除零 expected_counts = np.where(expected_counts == 0, 0.0001, expected_counts) actual_counts = np.where(actual_counts == 0, 0.0001, actual_counts) psi = np.sum((actual_counts - expected_counts) * np.log(actual_counts / expected_counts)) return psi # 对每个特征计算 PSI train_df = pd.read_csv('feature_wide_table.csv') online_df = pd.read_csv('online_feature_last7d.csv') psi_results = {} for col in train_df.columns: if col in ['user_id', 'label']: continue psi_results[col] = calculate_psi(train_df[col].values, online_df[col].values) # 输出 PSI 最高的 10 个特征 psi_series = pd.Series(psi_results).sort_values(ascending=False) print(psi_series.head(10))这段代码的逻辑:breakpoints用训练集的分位数确定,保证每个箱的样本量大致均衡。0.0001是平滑项,防止 log(0)。PSI 排名前 10 的特征,就是漂移最严重的,优先检查这些特征的计算逻辑是否还适用。
滚动重训的策略:每周用最近 6 个月的数据重训一次,保留旧模型做 A/B 对比。如果新模型在验证集上 AUC 提升超过 0.005,就切流;否则继续观察。我一般会把lightGBM_JDD.py和stacking.py封装成 Airflow DAG,每天跑特征生成,每周跑训练,每月跑一次全量 Stacking。
从那以后我每次上线新模型前,都强制走一遍 PSI 检查——哪怕离线指标再好看,特征漂移没控住,线上就是玄学。希望帮到你。
本文还有配套的精品资源,点击获取