机器学习预测银行客户逾期:从数据处理到风控模型实战
简介一套完整的银行客户逾期行为预测项目源码与数据集面向机器学习初学者、计算机相关专业毕业设计学生以及需要项目实战练习的学习者可直接用于课程设计或期末大作业。项目基于银行业真实业务背景围绕客户是否逾期还款这一核心问题展开建模覆盖数据探索、特征工程、模型训练与评估等关键环节帮助读者掌握从数据到模型的机器学习项目流程。压缩包内共包含十个文件包括三个CSV数据文件、一个字段说明Excel表、三个Jupyter Notebook代码分别对应逾期行为EDA分析、XGBoost与逻辑回归融合、GBDT与逻辑回归融合以及Python脚本和使用说明整体大小约127.3MB目录结构清晰便于按阶段复现与二次开发。目前该资源已有229人学习项目获导师指导并以高分通过严格调试下载后即可直接运行省去环境配置与代码排错。这份现成方案既能提供可复现的建模思路又能帮助深入理解梯度提升树与线性模型融合的实战细节。1. 这项目解决什么问题银行怎么用机器学习圈出“下个月会逾期的人”银行零售信贷和信用卡业务里风控人员每天面对的问题其实很具体手里有一批客户的申请信息、历史借贷记录和还款行为要在未来 30 天或 90 天里判断哪些人会出现逾期。“python实现基于机器学习的银行客户逾期行为预测源码全部数据.zip”这个项目就是把这件事做成了一个可以直接跑起来的完整流程——从 python 环境里读入原始数据表做清洗和特征加工再用机器学习模型输出每个客户的风险概率和名单排序。它不是那种只讲概念的 demo而是带源码和数据的可复现工程。反直觉的结论先说在前面这种项目里真正决定预测效果的往往不是模型选了什么算法而是你如何处理缺失值、怎么定义“逾期”这个标签、以及有没有在切分数据时把未来信息漏进去。模型代码只有几十行数据处理和特征工程却占了整个项目 80% 的工作量。这套流程适合三类人正在做风控或信贷相关课程设计的学生、刚入门机器学习想跑通完整分类项目的开发者、以及需要给业务方交付一个可解释风险名单的从业者。2. 把 zip 里的原始数据变成能训练的表数据加载、标签定义与预处理2.1 先看数据长什么样三类字段决定了你能做什么特征拿到 zip 并解压之后注意别解压到带中文和空格的路径下后面会专门说这个坑通常会发现里面是几份 CSV 或 Excel 表加一组 python 源码文件。常见的组织方式是一份记录客户静态属性的主表一份记录历史借贷行为的行为表外加一份记录外部征信查询的流水表。静态属性包括年龄、性别、收入、职业、婚姻状态、居住城市等级行为表包含贷款金额、贷款期限、历史逾期次数、近 6 个月还款状态、信用卡使用率征信查询流水则记录了客户在观察期内被查询征信的次数和最近一次查询距今月数。这三类字段对应着不同的特征工程手法。客户静态属性大多是分类变量适合做分箱和 WOE 编码行为表里是连续值和计数需要做截尾、分箱、衍生比率特征征信查询次数这类强金融信号往往单独构造“查询次数过高”的风险标志。我在处理这类项目时第一件事不是写模型而是先拿 pandas 把三张表的字段逐一过一遍统计缺失率和取值分布搞清楚哪些列是真正的数值型、哪些其实是离散等级。import pandas as pd import numpy as np # 加载客户主表注意 dtype 统一为字符串避免身份证号/客户ID被读成浮点 df pd.read_csv(customer_info.csv, dtype{customer_id: str}, parse_dates[apply_date]) print(df.shape) print(df.dtypes) # 输出每个字段的缺失率按降序排列 missing_ratio df.isnull().mean().sort_values(ascendingFalse) print(missing_ratio[missing_ratio 0])这段代码里有两个容易被忽略的点。dtype参数是必须的客户 ID 这类纯数字字符串一旦被 pandas 自动推断为 int64后面的 join 操作就会因为精度问题对不上。isnull().mean()是算缺失率最简洁的写法它把每个字段的空值占比算出来排在前面的字段就是接下来要优先决策的字段。2.2 标签怎么定逾期不是“曾经逾期”而是“未来一段时间内会发生逾期”这个项目叫“逾期行为预测”最容易犯的错是把标签定义成“这个客户历史上有没有逾期记录”。那是分类不是预测。正确的做法是把时间轴切成两段观察期和表现期。观察期用过去 6 个月的客户行为数据做特征表现期看未来 30 天或 90 天是否发生了逾期逾期通常定义为逾期天数 30 天业内叫 M1。观察期里面的历史逾期次数可以做特征但不能直接当标签。标签切分有一个硬性要求同一批次客户的观察期和表现期起始点必须对齐否则模型学到的是时间漂移而不是风险规律。我一般会在原始表里加一列label_cut_date表示每个样本的观察期截止日。代码上就是把业务上定义的“放款日/申请日”作为基准日标签列取表现期内最大逾期天数是否超过阈值。# 假设 df 里已有 apply_date 字段以申请日为基准日 # 表现期长度定为 30 天逾期阈值 30 天M1 df[observe_end] df[apply_date] pd.Timedelta(days180) # 观察期 6 个月 df[perform_end] df[apply_date] pd.Timedelta(days210) # 表现期 30 天 # 合并还款流水表计算表现期内最大逾期天数 repay pd.read_csv(repay_flow.csv, dtype{customer_id: str}) repay repay[(repay[repay_date] df[observe_end]) (repay[repay_date] df[perform_end])] max_overdue repay.groupby(customer_id)[overdue_days].max().rename(max_dpd) df df.merge(max_overdue, oncustomer_id, howleft) df[label] (df[max_dpd].fillna(0) 30).astype(int) print(df[label].value_counts(normalizeTrue))注意howleft和fillna(0)。表现期内没有还款流水记录的客户说明他们没有任何逾期逾期天数填 0而不是直接丢掉。这一步如果写成howinner会把所有好客户全过滤掉训练集里只剩逾期客户模型直接崩。标签分布打印出来能看到正样本占比银行真实场景里通常在 3% 到 10% 之间。2.3 缺失值和异常值无脑填充是模型翻车的头号原因缺失值处理没有统一公式但有可复用的经验法则。从 2.1 的缺失率统计出发缺失率低于 5% 的字段直接删除缺失行或众数填充影响都不大缺失率在 5% 到 30% 之间用中位数或分组中位数填充分组变量选和该字段相关性最高的列比如收入按职业分组填充缺失率超过 30% 的字段不要简单填充而是保留缺失并生成一个 “是否缺失” 的标志列因为缺失本身在信贷数据里就是风险信号——一个客户不填收入往往比填低收入更有风险识别价值。异常值处理主要针对收入、贷款金额、信用卡使用率这类右偏严重的连续变量。常见做法是用分位数缩尾winsorize把 1% 和 99% 分位以外的值拉回边界而不是直接删除这样能保留样本数量又避免极端值主导模型。from scipy.stats.mstats import winsorize # 收入字段缺失率约 12%按职业分组填充中位数 df[income] df.groupby(occupation)[income].transform(lambda x: x.fillna(x.median())) # 生成缺失标志列 df[income_missing] df[income].isnull().astype(int) # 缩尾把收入上下 1% 的极端值拉到边界 df[income_wins] winsorize(df[income].fillna(df[income].median()), limits[0.01, 0.01]) print(df[[income, income_wins]].describe())groupby transform是分组填充的标准写法它会把分组统计值广播回原来的行。winsorize的limits参数注意是左右各 1%不是总共 1%。缩尾之后再跑一个describe()看最大值如果还是出现异常的大数说明数据里有脏值而不是真极端值需要回到原始表排查。2.4 训练集/验证集/测试集切分银行数据必须按时间切不能随机打散通用机器学习教程里常见的train_test_split(random_state42)在信贷逾期预测里不能直接用。原因是客户的信贷行为受宏观环境和政策影响用 2022 年 1 月到 6 月的客户训练、用 2022 年 7 月的客户验证模型学到的是跨时间的泛化能力如果用随机打散同一个月的好客户和坏客户会同时出现在训练集和测试集里模型就偷看了未来信息上线后成绩会明显缩水。正确做法是把时间排好序用前 70% 的时间窗口做训练中间 15% 做验证最后 15% 做测试。并且要保证三个集合之间没有同一客户的重叠同一个客户在不同月份申请了多笔贷款如果他的样本同时落在训练集和测试集里也会造成泄漏。# 按基准日排序 df df.sort_values(apply_date).reset_index(dropTrue) # 前70%时间做训练后15%做测试 cut_1 int(len(df) * 0.70) cut_2 int(len(df) * 0.85) train df.iloc[:cut_1] valid df.iloc[cut_1:cut_2] test df.iloc[cut_2:] # 去掉训练集里客户ID重复出现在验证/测试集中的样本 test test[~test[customer_id].isin(train[customer_id])] valid valid[~valid[customer_id].isin(train[customer_id])] print(train.shape, valid.shape, test.shape)这段代码最后两行是容易被忽略的细节。真实信贷数据里同一个客户多次借贷的情况很常见如果不按 customer_id 去重验证集的评估分数会偏乐观。切分比例不是死的如果数据时间跨度长、样本量大可以调成 0.8 / 0.1 / 0.1如果样本只有几千条时间切分会造成测试集太小那就需要改用 K-Fold 时间序列交叉验证但无论如何都不要用随机打散。3. 特征工程与样本不均衡处理从原始字段到能喂给模型的向量3.1 WOE 编码与分箱为什么不让模型直接吃原始数值很多新手把特征工程理解成“把字符串变成数字”做几个LabelEncoder就完事了。但信贷风控里最经典的做法是对连续变量做分箱然后计算每个箱子的 WOEWeight of Evidence证据权重用 WOE 值替代原始数值。这么做有三个收益消除连续变量的非线性影响、让缺失值天然成为独立的分箱、减少异常值和过拟合的影响。WOE 的定义是每个箱子内坏客户占比与好客户占比比值的对数实际效果是把一个连续变量转换成分箱后的风险编码。IVInformation Value信息量则是 WOE 的加权求和用来衡量一个特征对逾期预测的区分能力。IV 低于 0.02 的特征基本没有预测价值0.02 到 0.1 是弱特征0.1 到 0.3 是中等强度特征超过 0.5 要警惕是否包含了未来信息。def woe_iv(df, feature, label, bins10): df_temp df[[feature, label]].copy() # 等频分箱按分位数切分缺失值单独成箱 df_temp[bin] pd.qcut(df_temp[feature], qbins, duplicatesdrop) df_temp[bin] df_temp[bin].cat.add_categories(MISSING) df_temp.loc[df_temp[feature].isnull(), bin] MISSING grouped df_temp.groupby(bin, observedTrue)[label].agg([sum, count]) grouped[bad] grouped[sum] grouped[good] grouped[count] - grouped[sum] # 平滑处理避免除零 grouped[bad_rate] (grouped[bad] 0.5) / (grouped[bad].sum() 0.5) grouped[good_rate] (grouped[good] 0.5) / (grouped[good].sum() 0.5) grouped[woe] np.log(grouped[bad_rate] / grouped[good_rate]) grouped[iv] (grouped[bad_rate] - grouped[good_rate]) * grouped[woe] return grouped, grouped[iv].sum() # 对收入字段做分箱并计算IV bins_df, iv_value woe_iv(train, income_wins, label, bins10) print(fincome_wins IV {iv_value:.4f}) print(bins_df[[woe, iv]])pd.qcut做等频分箱时如果数据有大量重复值会出现某些箱的边界相同导致分箱失败代码里加了duplicatesdrop来规避。平滑处理里的 0.5 是经验值防止某个箱内好客户或坏客户数量为 0 时取对数报错。分箱数bins10不是固定值样本量大可以分 20 箱量少分 5 到 6 箱即可分太细会让 WOE 波动剧烈。分箱完成后真正喂给模型的不是原始收入数值而是每个样本所在箱子对应的 WOE 值。这一步是否做得好直接决定逻辑回归这种线性模型的效果。XGBoost 这类树模型不吃 WOE 也能跑但做分箱仍然能减少过拟合所以实践中特征统一按 WOE 编码是更稳的路线。3.2 分类变量处理与 IV 筛选One-Hot 之外的风控惯用做法分类变量是另一个容易翻车的地方。学历、职业、婚姻状态这些字段直接用LabelEncoder编码成 1、2、3会让模型误以为 3 比 1 风险更高用 One-Hot 编码则可以避免这种次序关系但当分类取值很多比如职业有几十种时One-Hot 会产生大量稀疏列逻辑回归的系数也容易不稳定。风控实战中更稳的做法是把分类变量也做分箱合并再计算 WOE。具体做法是先按类别计算坏账率把坏账率相近的类别合并成一个箱然后对这个合并后的分类变量计算 WOE 和 IV。IV 筛选可以同时对数值型分箱特征和分类特征进行筛选阈值一般设在 0.02低于这个阈值的特征不进入模型。# 对分类字段按坏账率分组合并后计算WOE def category_woe(df, feature, label): grouped df.groupby(feature)[label].agg([sum, count]) grouped[bad_rate] grouped[sum] / grouped[count] # 按坏账率排序将相近的类别合并这里简化为按区间分箱 grouped[bin] pd.cut(grouped[bad_rate], bins4, labelsFalse) df_temp df.merge(grouped[bin].rename(temp_bin), left_onfeature, right_indexTrue) return woe_iv(df_temp, temp_bin, label, bins4) # 筛选IV 0.02的特征 iv_results {} for col in [income_wins, occupation, marital_status, credit_usage]: if col income_wins: _, iv woe_iv(train, col, label, bins10) else: _, iv category_woe(train, col, label) iv_results[col] iv selected_features [k for k, v in iv_results.items() if v 0.02] print(selected_features)category_woe里的分组合并做了简化处理实际项目里会先画每个类别的坏账率柱状图人工判断哪些类别可以合并而不是机械地按坏账率数值切。IV 筛选的作用一是减少特征数量、加快训练速度二是防止太多弱特征干扰模型。但这个阈值可以根据特征总数调整如果总共只有二十几个特征0.02 的阈值就够用如果做了上百个衍生特征可以提高到 0.05。3.3 样本不均衡用 class_weight 还是 SMOTE逾期数据天然不均衡正样本比例通常只有 3% 到 10%。处理方式有两条路线一是在模型层面用class_weightbalanced让模型对少数类样本的误判付出更高代价二是在数据层面做 SMOTE 过采样生成新的少数类样本。两条路线不冲突但 SMOTE 有一个非常关键的坑只能在切分训练集之后、并且只在训练集上执行否则生成的合成样本会和验证集/测试集中的真实样本重叠造成数据泄漏评估结果虚高。我的一般做法是逻辑回归这类线性模型优先用class_weight因为它不改变样本分布训练稳定XGBoost 可以同时用scale_pos_weight参数SMOTE 则用在特征工程确实区分度不够、模型召回率上不去的场景。from imblearn.over_sampling import SMOTE # 先切分再在训练集上做SMOTE —— 顺序不能反 X_train train_scaled[selected_features] y_train train_scaled[label] X_valid valid_scaled[selected_features] y_valid valid_scaled[label] # 只在训练集上过采样验证集和测试集保持原始分布 sm SMOTE(random_state42, sampling_strategy0.3) X_train_res, y_train_res sm.fit_resample(X_train, y_train) print(过采样前:, X_train.shape, 过采样后:, X_train_res.shape)sampling_strategy0.3表示过采样后少数类样本数量达到多数类的 30%不会强行做到 1:1。过采样比例太高会让模型学到大量合成样本的分布反而伤害泛化能力。另外注意imblearn和sklearn的 API 风格一致但它有自己的版本对齐要求装的时候pip install imbalanced-learn即可不要手动改sklearn版本。4. 建模与评估不只跑出准确率要跑出风控能用的排序能力4.1 逻辑回归和 XGBoost两个模型的不同定位逾期预测项目里最常用的是两个模型逻辑回归和 XGBoost两者定位完全不同。逻辑回归的预测结果是一组权重系数每个特征对应的系数正负和大小直接反映了风险方向业务方和监管喜欢这种可解释性但它的前提是特征经过 WOE 编码后与逾期概率呈线性关系。XGBoost 能自动捕捉特征之间的交互和非线性关系预测精度通常更高但解释性差需要用 SHAP 这类工具辅助解释。实际项目里两个模型都会跑逻辑回归作为基线模型如果 XGBoost 相比逻辑回归的 KS 提升不超过 0.03那出于稳定性和可解释性的考虑我更愿意把逻辑回归部署上线。代码上两个模型的训练都很简单重点放在训练前的特征矩阵对齐和训练后的评估口径统一上。from sklearn.linear_model import LogisticRegression from xgboost import XGBClassifier # 逻辑回归C正则系数用较小值防止过拟合 lr LogisticRegression(C0.1, max_iter1000, class_weightbalanced) lr.fit(X_train_res, y_train_res) # XGBoostscale_pos_weight用于处理样本不均衡 xgb XGBClassifier( n_estimators200, max_depth4, learning_rate0.05, scale_pos_weightsum(y_train 0) / sum(y_train 1), eval_metricauc, early_stopping_rounds20, ) xgb.fit(X_train, y_train, eval_set[(X_valid, y_valid)], verboseFalse)逻辑回归的C0.1是经验起点C 越小正则越强特征多的时候从 0.01 起步特征少可以放宽到 1.0。XGBoost 这里用了early_stopping_rounds20它在验证集上连续 20 轮 AUC 不提升就停止训练防止过拟合。scale_pos_weight手动算了一次效果等同于class_weight但 XGBoost 的参数名不同别和 sklearn 的写法搞混。4.2 交叉验证与超参数粗调几组参数就够了风控项目里超参数调优不需要 GridSearch 全网格扫荡那样耗时且提升有限。XGBoost 一般只调三个参数max_depth3 到 6、learning_rate0.01 到 0.1、n_estimators。调参顺序是先固定learning_rate0.05调max_depth再调min_child_weight最后把learning_rate调小并相应增大n_estimators。如果时间紧直接按经验值跑也能拿到不错的效果。交叉验证不要用cross_val_score默认的 KFold因为它也是随机打散和 2.4 节同样的原因。这里用一个简单的自定义时序交叉验证把训练集按时间切成 4 段每次用前 3 段训练、第 4 段验证滚动 3 次。from sklearn.metrics import roc_auc_score, roc_curve # 时序交叉验证按时间顺序做3次滚动验证 aucs [] for i in range(1, 4): split_point int(len(X_train) * i / 4) X_tr, X_va X_train.iloc[:split_point], X_train.iloc[split_point:] y_tr, y_va y_train.iloc[:split_point], y_train.iloc[split_point:] xgb_clone XGBClassifier( n_estimators100, max_depth4, learning_rate0.05, scale_pos_weightsum(y_tr 0) / sum(y_tr 1), eval_metricauc, early_stopping_rounds20, ) xgb_clone.fit(X_tr, y_tr, eval_set[(X_va, y_va)], verboseFalse) aucs.append(roc_auc_score(y_va, xgb_clone.predict_proba(X_va)[:, 1])) print(3轮时序交叉验证AUC:, [round(a, 4) for a in aucs])时序交叉验证的价值在于它模拟了模型上线后的真实场景训练集永远在过去验证集永远在未来。如果 3 轮 AUC 波动超过 0.05说明模型不稳定优先回去检查特征是否存在时序漂移而不是继续调参。4.3 评估指标准确率在逾期预测里是陷阱逾期预测里最典型的错误是用 accuracy 当核心指标。正样本占比只有 5% 时模型全预测为“不逾期”就有 95% 的准确率但这个模型毫无业务价值。风控场景里要看三个更实际的指标AUC衡量排序能力、KS衡量好坏样本分布的最大分叉、以及 top 5% 召回率衡量风险名单里真的圈中了多少逾期客户。# 逻辑回归预测概率 y_prob lr.predict_proba(X_valid)[:, 1] y_pred (y_prob 0.5).astype(int) # 混淆矩阵 from sklearn.metrics import confusion_matrix, precision_recall_curve tn, fp, fn, tp confusion_matrix(y_valid, y_pred).ravel() print(fTP{tp} FP{fp} FN{fn} TN{tn}) # AUC 与 KS auc roc_auc_score(y_valid, y_prob) fpr, tpr, _ roc_curve(y_valid, y_prob) ks max(tpr - fpr) print(fAUC {auc:.4f}, KS {ks:.4f}) # top 5% 召回率取概率最高5%的客户看覆盖了多少逾期客户 top_5pct int(len(y_valid) * 0.05) top_idx np.argsort(y_prob)[::-1][:top_5pct] top_recall y_valid.iloc[top_idx].mean() / y_valid.mean() print(ftop 5% 召回率 {top_recall:.4f})KS的计算就是从 ROC 曲线中取 TPR 和 FPR 的最大差值行业经验值是 KS 大于 0.3 模型就有可用性大于 0.4 效果较好。top 5% 召回率是业务最关心的指标如果全部客户中有 5% 会在未来逾期理论上前 5% 高风险名单应该圈中接近 100% 的逾期客户但实际能做到 30% 以上已经算不错的模型。这个指标比 AUC 更接地气因为它直接对应了“如果我只催收 5% 的客户能挽回多少损失”。5. 常见坑点排查样本泄漏、缺失填充、不均衡处理这些坑我全踩过5.1 坑一对全量数据做中位数填充导致验证集信息泄漏现象训练集上 AUC 高达 0.85验证集上却掉到 0.65差异大得不正常。 原因填充缺失值时用了全量数据的统计量来做填充包括验证集和测试集的数据。填充用的中位数本身包含了“未来信息”验证集里的客户在填充时就见过自己了。 解决把填充操作放进一个Pipeline里或者手动先对训练集fit统计量再分别transform验证集和测试集。sklearn的SimpleImputer就是这样设计的但 pandas 的fillna操作没有这个天然隔离最容易踩坑。我自己后来统一封装了一个fit_transform函数杜绝手写fillna。5.2 坑二在切分数据集之前就做了 SMOTE 过采样现象验证集和测试集上召回率特别高但部署到真实业务里效果差一大截。 原因SMOTE 是在全体数据上跑的合成的样本会跨越训练集和验证集的边界验证集里混入了由训练集样本“生成”的相似样本相当于模型提前见过了考试题目。 解决先切分再只在训练集上执行fit_resample。验证集和测试集必须保持原始分布用真实数据去检验模型。这一步我在 3.3 节代码里标注了注释“顺序不能反”因为这是我合作过的项目里最高频的翻车点。5.3 坑三拿准确率当评估指标模型“看起来”很强实际不能用现象准确率 95%但业务方拉出预测的风险名单一看里面一半以上不是逾期客户。 原因正样本占比太低全预测为负样本就有 95% 准确率。你看到的 95% 是模型的“懒惰正确”而不是真正的识别能力。 解决评估指标改成 AUC、KS、top 5% 召回率。准确率可以保留在报表里给业务看但做模型选型和调参决策时不要看它。如果业务方只能用懂准确率就把混淆矩阵的两行两列讲清楚把好客户误判成坏客户的代价远低于把坏客户放过的代价。5.4 坑四One-Hot 之后直接把逻辑回归系数当风险方向解读现象逻辑回归某个虚拟变量的系数是负的业务方据此判断该组客户低风险结果和实际坏账率相反。 原因One-Hot 编码时默认生成了一个参照组所有虚拟变量的系数都是相对于参照组的相对值。如果参照组本身的坏账率最高那其他组看起来全是“负系数”但这不代表它们低风险。 解决用 WOE 编码替代 One-Hot或者在解读系数时先看每个类别的实际坏账率。更简单的办法对分箱后的特征计算每个箱子的坏账率把坏账率和模型系数放在同一张表里对应看系数方向与坏账率方向一致再下结论。5.5 坑五zip 解压后路径带中文或空格pandas 读取直接报错现象pd.read_csv(C:\Users\xx\银行客户数据\客户表.csv)报 FileNotFoundError但资源管理器里文件明明存在。 原因Windows 控制台默认编码和中文字符串前缀冲突有时加上open()的编码参数能解决但最省事的是源头规避——项目目录全用英文和数字命名。 解决解压 zip 后先移动到D:\project\loan_risk\这种纯英文路径再操作。读文件时用绝对路径和反斜杠转义或Path对象不要直接手写一串带中文的字符串。如果非要用中文路径pd.read_csv前先import os; os.chdir()到对应目录再用相对路径读。这个问题不影响模型逻辑但会让新手在数据加载阶段卡半小时纯粹是环境问题。6. 往前走一步把阈值从 0.5 挪到业务线上再用 SHAP 解释单个客户6.1 用 KS 曲线找阈值0.5 是通用默认值不是风控最优值所有分类模型默认的判定阈值是 0.5但这个值在逾期预测里几乎肯定不是最优的。业务上的实际诉求是我的人力只能催收 5% 的客户我要让这 5% 里尽可能多圈中逾期客户——这是一个成本和收益的权衡不该由算法默认值决定。遍历 0.1 到 0.9 的阈值画出每个阈值下的 TPR 和 FPR或者直接看 top 5% 召回率曲线选业务可接受的那个点。thresholds np.arange(0.1, 0.9, 0.05) for th in thresholds: y_pred_th (y_prob th).astype(int) # top 5% 召回率随阈值变化 top_recall_th y_valid.iloc[np.argsort(y_prob)[::-1][:int(len(y_valid) * 0.05)]].mean() / y_valid.mean() print(fthreshold{th:.2f}, top5% recall{top_recall_th:.4f})我的一般做法是先用无阈值指标AUC、KS选模型再针对选定模型单独调阈值。这一步做完模型才真正从“机器学习跑通了”变成“业务能用了”。6.2 用 SHAP 解释单个客户为什么被拒信贷场景绕不开解释性客户经理需要知道这个客户为什么进风险名单。SHAP 库可以输出每个特征的贡献值直接画出一张图告诉业务方“这个客户因为收入分箱 WOE 值过高即该收入段历史坏账率偏高、近 3 个月征信查询次数超过 6 次被模型判定为逾期高风险”。它不改变模型本身只做解释。import shap explainer shap.TreeExplainer(xgb) shap_values explainer.shap_values(X_valid.iloc[:100]) # 每个客户的瀑布图看单个客户的特征贡献 shap.force_plot(explainer.expected_value, shap_values[0], X_valid.iloc[0])SHAP 的TreeExplainer专门针对树模型几行代码就能跑通但要注意它在数据量大时计算很慢先在小批量数据上做解释够用就行。我做这类预测项目攒下的教训是调参的时间最多占两成剩下八成全在数据理解、标签定义和特征处理上。这个项目的价值不在那几十行模型代码而在你拿到一份带全部数据的完整流程后能亲手把每个环节跑通并改造成自己的方案。先把基线和坑位摸清楚再谈优化模型。希望帮到你。本文还有配套的精品资源点击获取