ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

信用卡违约预测实战:不平衡分类与模型融合的风控全流程指南

信用卡违约预测实战:不平衡分类与模型融合的风控全流程指南 简介面向信用卡违约预测场景的机器学习实战资源聚焦客户违约风险识别与信贷评估优化适合数据科学初学者及金融风控方向学习者参考。RAR压缩包内仅含1个Python脚本大小约4KB代码虽精简却完整覆盖数据加载、缺失值、异常值及重复值清洗、特征工程、多模型训练、交叉验证及模型融合等核心环节便于快速读懂全流程。目前已有591人学习下载适合作为入门项目拆解练习。脚本中可见逻辑回归、随机森林等常见算法的调用与比较并涉及AUC-ROC、精确率、召回率、F1分数等评估指标模型融合部分通过bagging、boosting或stacking策略提升预测稳定性。阅读后可掌握从数据预处理、特征编码到结果评估的完整建模思路积累金融风控场景的实用经验也可作为进一步扩展模型调参的起点。1. 信用卡违约预测为什么刷透这个题比刷十个通用分类项目更值钱信用卡违约预测在国内信贷风控里属于最典型的评分卡场景。它不是简单的用机器学习分个类而是用一个极不平衡的样本预测一个极低概率事件并承担错判代价的问题。很多人在 Kaggle 或者课程设计里跑通一个 XGBoost 就拿 90% 准确率交差但在真实业务里这个 90% 恰恰是坑——因为违约样本只占 2%~5%模型只要全部预测不违约就能拿到这个分数。这个项目真正的难点在于三件事第一样本极度不平衡直接训练出来的模型对少数类几乎没有区分度第二单一模型的泛化能力不够LR 解释性强但拟合弱树模型拟合强但对噪声敏感需要模型融合来互补第三评估指标不能用准确率得看 AUC、KS、PR 曲线甚至业务上的召回率与坏账率换算。这篇文章我会按自己的实战路径走一遍数据清洗、基线模型、调参、模型融合、阈值选择把每一步的代码和参数都说透最后把那些让你翻车的坑挨个点名。2. 信用卡违约预测的数据准备先别急着建模把样本和特征摸清楚2.1 数据长什么样默认率、特征类型和缺失值检查常见做法是使用 UCI 的台湾信用卡客户数据集默认率约 22.1%或者银行脱敏后的内部数据。开跑之前我一般先做一次体检——把行列数、缺失率、每个特征的 dtype、目标变量分布一次性打出来。这一步很土但能避免后面所有关于特征工程的返工。import pandas as pd import numpy as np df pd.read_csv(credit_card.csv) print(shape:, df.shape) print(target_rate:, df[default].mean().round(4)) # 缺失值概览 missing df.isnull().sum() print(missing[missing 0]) # 数值特征的极值检查 print(df.describe().T[[min, max, mean]].round(3))逻辑说明target_rate是违约率0.22 左右意味着这是一个偏斜但不极端的不平衡问题和欺诈检测那类 0.1% 的场景不同这决定了后续采样策略不需要上 SMOTE 重采样用class_weight或阈值调整就够。describe这一步是为了发现异常值——比如某些账单金额字段出现 0 或负数这类数据在信贷字段里可能是本期无账单的编码不能直接当缺失值删掉。参数说明missing[missing 0]只显示有缺失的列避免被全零列干扰。如果发现字段类型是 object说明原始数据里有脏值需要单独处理。另外提醒一句信用卡账单类特征LIMIT_BAL、PAY_0、BILL_AMT1 等的单位和量纲差异大树模型无所谓但后面做 LR 或模型融合时要注意归一化或标准化。2.2 特征工程把 PAY_0 这种类别型特征的坑填平UCI 信用卡数据里最阴间的字段是PAY_0到PAY_6表面上看起来是数值-2 到 8实则是有序类别-2 表示无需还款-1 表示按时还款0 表示延期一个月1 表示延期两个月以此类推。如果直接当连续特征喂给模型模型会把 -2 和 8 当成线性关系这是典型的数据语义错误。# PAY_0 ~ PAY_6 是有序类别转成字符串再编码或做有序映射 pay_cols [PAY_0, PAY_2, PAY_3, PAY_4, PAY_5, PAY_6] for col in pay_cols: df[col] df[col].map({ -2: 0, # 无需还款 -1: 1, # 按时还款 0: 2, # 延期1个月 1: 3, 2: 4, 3: 5, 4: 6, 5: 7, 6: 8, 7: 9, 8: 10, }) # 衍生特征账单金额的环比变化 df[bill_ratio_1] df[BILL_AMT1] / (df[BILL_AMT2] 1) df[pay_ratio_1] df[PAY_AMT1] / (df[BILL_AMT1] 1) # 截断极端值超过 99 分位的按分位截断 for col in [BILL_AMT1, BILL_AMT2, BILL_AMT3]: q99 df[col].quantile(0.99) df[col] df[col].clip(upperq99)逻辑说明把PAY字段从 数值 矫正为 有序类别看起来只是换了个编码实际效果是让模型不再误以为 -2 和 8 之间存在线性可比的梯度。bill_ratio_1这类环比特征是信贷场景里少数能真正提升区分度的衍生变量——它捕捉的是用户这个月是不是突然多刷了的异常行为而不是绝对金额。参数说明除法里加 1 是防止除零clip上限取 99 分位而不是直接删行因为信用卡账单金额遵循长尾分布删掉极值会让验证集的 KS 掉得厉害。这个操作对 LR 类线性模型尤其重要树模型可以不截断但融合模型里通常会带 LR所以提前统一处理。2.3 数据切分时间序列数据的切分不能乱 shuffle信用卡违约数据有个容易被忽略的性质它是按月收集的时序数据。如果直接train_test_split(shuffleTrue)会把未来数据混进训练集导致模型在验证集上虚高上线后立刻崩盘。正确切法要么按时间排序后取尾部做验证集要么至少保证切分前先按 ID 分组避免同一个用户出现在两个集合里。from sklearn.model_selection import train_test_split X df.drop(columns[default, ID]) y df[default] # 按时间顺序切分验证集取最后 20% X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, shuffleFalse, stratifyNone ) print(train default rate:, y_train.mean().round(4)) print(test default rate:, y_test.mean().round(4))逻辑说明shuffleFalse是时序切分的核心。如果原始数据不是按时间排序的需要先用df.sort_values(time_col)排序再切分。stratify在这里必须设为 None因为按时间切分天然会改变验证集的违约率——如果验证集的违约率和训练集差异过大说明原始时间窗口内的客群结构发生了变化这个信号本身就该被观察到而不是被掩盖。参数说明test_size0.2是按时间切分的常用比例但真实项目中我习惯用 0.15因为信贷场景的逾期标签往往有滞后性训练集如果太短近期样本的特征分布会不稳。另一点是切分后要检查y_train.mean()和y_test.mean()的差如果超过 3 个百分点建议缩短训练集窗口或者干脆做滚动验证。3. 基线模型与评估为什么准确率在这个场景里完全不可信3.1 先跑一个 Logistic Regression不是为了分数是为了解释性信用卡违约预测的入门模型首选就是 LR原因不是它精度高而是它给每个特征一个清晰的权重业务方能直接拿去当策略解释材料。实际做的时候先跑一个带class_weightbalanced的 LR 基线记录 AUC 和召回率后续所有模型的提升都以这个为基准。from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score, recall_score, precision_score scaler StandardScaler() X_train_s scaler.fit_transform(X_train) X_test_s scaler.transform(X_test) lr LogisticRegression( class_weightbalanced, C0.1, max_iter1000, solverliblinear, random_state42, ) lr.fit(X_train_s, y_train) pred lr.predict(X_test_s) prob lr.predict_proba(X_test_s)[:, 1] print(AUC:, roc_auc_score(y_test, prob).round(4)) print(Recall:, recall_score(y_test, pred).round(4)) print(Precision:, precision_score(y_test, pred).round(4))逻辑说明class_weightbalanced会按样本比例自动给少数类加权这是处理不平衡最简单、最不会翻车的手段。solverliblinear适合小规模数据而且支持 L1 正则方便后续做特征筛选。注意C0.1——C 是正则强度的倒数越小正则越强LR 在信用卡数据上如果不加正则特征权重会被 LIMIT_BAL 这种大数值字段带偏。参数说明用StandardScaler的原因不只是量纲——LR 的目标函数对特征尺度敏感solverliblinear配合标准化后收敛更稳。这里的 Recall 用的是predict的默认阈值 0.5但class_weight已经改变了预测概率的分布所以这个 Recall 参考价值有限真正要做的是后面第 6 章里的阈值搜索。3.2 上 XGBoost让树模型先跑出上限在信用卡违约预测项目里XGBoost 几乎是必跑的基模型。它速度快、自带正则、能处理缺失值关键是它在表格数据上确实比 LR 强一大截。单独跑 XGBoost 时最需要关注的是scale_pos_weight和树的深度这两个参数决定模型对少数类的响应程度。import xgboost as xgb xgb_model xgb.XGBClassifier( n_estimators300, max_depth5, learning_rate0.05, subsample0.8, colsample_bytree0.8, scale_pos_weightsum(y_train 0) / sum(y_train 1), eval_metricauc, early_stopping_rounds50, random_state42, ) xgb_model.fit( X_train, y_train, eval_set[(X_test, y_test)], verboseFalse, ) print(Best iteration:, xgb_model.best_iteration) print(AUC:, roc_auc_score(y_test, xgb_model.predict_proba(X_test)[:, 1]).round(4))逻辑说明scale_pos_weight用的是负样本数除以正样本数等于 3.5 左右这个值不是拍脑袋而是让模型把少数类的梯度放大到和多数类同量级。early_stopping_rounds50是防止过拟合的常规手段但要注意——如果不设eval_metric而默认用 logloss收敛会慢很多信用卡这种不平衡场景用 AUC 做早停更贴合目标。参数说明max_depth5是保守选择。信用卡数据的特征量不大深度超过 6 之后模型的方差会明显变大在验证集上的波动肉眼可见。learning_rate0.05配 300 棵树是慢工出细活的配置如果你用默认的 0.3 配 100 棵树通常也能跑但 AUC 会低 0.5~1 个百分点。3.3 评估矩阵AUC、KS 和 PR 曲线看哪个很多人跑完模型只看一个 AUC这是不够的。信用卡违约预测里三个指标各有各的用途AUC 看全局排序能力KS 看好坏样本分布的最大分离度PR 曲线看少数类的精确率与召回率均衡。真实风控项目里建模报告至少要贴 AUC 和 KS策略调阈值时看 PR 曲线。from sklearn.metrics import roc_curve, precision_recall_curve fpr, tpr, _ roc_curve(y_test, prob) ks_value max(tpr - fpr) print(KS:, round(ks_value, 4)) precision, recall, _ precision_recall_curve(y_test, prob) # 找 precision 和 recall 的交点作为参考阈值 for p, r in zip(precision, recall): if abs(p - r) 0.02: print(PR approx: p , round(p, 3), r , round(r, 3)) break逻辑说明KS 是风控建模的老传统计算方式是 TPR 减 FPR 的最大值这个值超过 0.4 在信贷场景里算不错的模型。PR 曲线比 ROC 对不平衡更敏感当正样本只有 20% 时ROC 看起来很美但 PR 可能惨不忍睹所以做模型融合调参时我主要看 PR 曲线下的面积而不是只看 AUC。参数说明for循环找abs(p - r) 0.02的交点是为了快速定位一个不偏不倚的初始阈值。实际上阈值应该由业务成本决定比如一笔违约损失是 5000 元催收成本是 100 元那最优阈值就是两者比值的函数——这一点留到第 6 章细说。4. 模型融合Stacking 与加权平均把 LR 和 XGBoost 的短板补上4.1 融合思路为什么模型融合不是简单地多个模型投票在信用卡违约预测里做模型融合核心动机不是三个臭皮匠顶诸葛亮而是不同的算法错误模式不一样LR 对线性边界敏感对特征交叉无能为力XGBoost 擅长抓特征交互但在小样本上容易过拟合LightGBM 速度快但和 XGBoost 的错误高度重合——所以融合时我会刻意选 LR XGBoost 一个随机森林三个模型的算法差异足够大融合才有收益。话虽如此最重要的一条经验是融合的下限由基模型决定上限由元模型决定。如果三个基模型 AUC 都在 0.75 附近再怎么融合也到不了 0.85。所以做融合之前先确保每个基模型单独调过参而不是直接拿默认参数堆叠。4.2 手工实现 Stacking五折交叉生成训练集与测试集Stacking 的常见做法是两层结构第一层用多个基模型在训练集上做五折交叉预测把预测概率作为第二层模型的输入特征第二层用 Logistic Regression 拟合这些概率。这里最关键的细节是防止基模型看到测试集——如果直接用.predict_proba(X_test)来生成第二层的训练特征数据泄漏会让融合的 AUC 虚高 2~3 个百分点。from sklearn.model_selection import StratifiedKFold from sklearn.ensemble import RandomForestClassifier import lightgbm as lgb def stacking_preds(models, X, y, X_test, n_splits5): 返回: (oof_preds, test_preds_list) oof_preds: (len(X), n_models) 每个模型在交叉验证下的预测概率 test_preds_list: 每个模型对测试集的平均预测概率 skf StratifiedKFold(n_splitsn_splits, shuffleTrue, random_state42) oof np.zeros((len(X), len(models))) test_preds np.zeros((len(X_test), len(models))) for i, model in enumerate(models): oof[:, i] 0.0 test_fold np.zeros(len(X_test)) for train_idx, val_idx in skf.split(X, y): model_clone model.__class__(**model.get_params()) model_clone.fit(X.iloc[train_idx], y.iloc[train_idx]) oof[val_idx, i] model_clone.predict_proba(X.iloc[val_idx])[:, 1] test_fold model_clone.predict_proba(X_test)[:, 1] / n_splits test_preds[:, i] test_fold return oof, test_preds models [ LogisticRegression(class_weightbalanced, C0.1, max_iter1000), xgb.XGBClassifier(n_estimators200, max_depth4, scale_pos_weight3.5), RandomForestClassifier(n_estimators200, max_depth8, class_weightbalanced), ]逻辑说明这个函数是 Stacking 最常见的手工实现每个基模型走一遍StratifiedKFold在每一折上用训练部分拟合、验证部分预测最后把验证部分的预测概率拼成完整的 OOF 预测概率。注意model_clone model.__class__(**model.get_params())这一步——直接把原模型对象 reuse 会累积已拟合的状态导致数据泄漏这是新手最常踩的坑。参数说明n_splits5是 Stacking 的默认选择数据量够大可以加到 8数据量小用 5 更稳。基模型的参数特意选得比单独训练时弱一点——比如 XGBoost 的max_depth4而不是单独调参时的 5这是为了降低基模型之间的相关性给元模型留出学习空间。4.3 元模型与加权融合LightGBM 作为元模型还是 LR 更靠谱第一层生成 OOF 特征之后第二层用 LR 拟合oof_preds到y这是一般做法里最稳的组合。原因是元模型的输入维度就是基模型数量通常 3~5 个LR 不容易过拟合而且能给每个基模型一个可解释的权重——如果某个模型的权重变成负数说明它提供的信号和最终结果是负相关的需要回看是不是该模型的验证集预测出了问题。meta_lr LogisticRegression(C1.0, max_iter1000) meta_lr.fit(oof_preds, y_train) final_test_prob meta_lr.predict_proba(test_preds)[:, 1] # 打印各基模型的权重检查是否有负权重 for name, coef in zip([LR, XGB, RF], meta_lr.coef_[0]): print(f{name} weight: {coef:.4f}) print(Stacking AUC:, roc_auc_score(y_test, final_test_prob).round(4)) # 简单加权融合作为对照 weighted_prob (0.2 * test_preds[:, 0] 0.5 * test_preds[:, 1] 0.3 * test_preds[:, 2]) print(Weighted AUC:, roc_auc_score(y_test, weighted_prob).round(4))逻辑说明元模型拟合的对象是 OOF 概率而不是原始特征所以它学到的是每个基模型在哪种样本上更可信这是 Stacking 的精髓。打印权重这一步很有用——如果 XGB 的权重小于 0 或接近 0说明它在交叉验证里表现的区分度大部分被 RF 覆盖了可以考虑去掉这个模型让融合模型更干净。参数说明C1.0是元模型的正则参数一般不需要太强的正则。另一点是加权融合那行代码里的权重0.2/0.5/0.3不是拍脑袋——我先跑了三个模型各自的 OOF AUC然后用 AUC 占整体比例作为初始权重再手动微调。加权融合在绝大多数场景下不如 Stacking但胜在简单线下验证如果两者 AUC 差距在 0.003 以内我会选加权融合因为上线部署更简单。4.4 融合的高级技巧概率校准与排序稳定性融合模型输出的概率往往不是真实概率——XGBoost 输出的概率偏向极端值LR 输出的概率偏向中部。在信用卡策略里如果把概率当作违约可能性去计算预期损失就必须做概率校准Probability Calibration否则计算出的坏账准备金会失真。from sklearn.calibration import CalibratedClassifierCV calibrated_lr CalibratedClassifierCV( estimatormeta_lr, methodisotonic, # 等渗回归适合非单调关系 cv3, ) calibrated_lr.fit(oof_preds, y_train) calibrated_prob calibrated_lr.predict_proba(test_preds)[:, 1] # 检查校准效果预测概率均值是否接近真实违约率 print(mean calibrated prob:, calibrated_prob.mean().round(4)) print(test default rate:, y_test.mean().round(4))逻辑说明methodisotonic是不需要预设曲线形状的校准方法如果训练数据足够超过 1000 条效果一般优于sigmoid。这里有个细节校准用的是 OOF 概率做输入所以cv3是内层再交叉验证避免校准本身也过拟合。参数说明运行完一定要对比calibrated_prob.mean()和y_test.mean()如果两者差超过 2 个百分点说明校准失败通常是因为 OOF 概率里有极端值需要先做 logit 变换。这个概率均值对齐真实违约率的习惯是我做风控策略同学的标配检查项。5. 避坑与排查信用卡违约预测里 5 个让你翻车的隐形坑5.1 准确率虚高90% 的准确率掩盖了模型什么都没学到现象模型在测试集上准确率 92%但看混淆矩阵发现违约样本几乎全被预测成不违约。原因数据不平衡模型只需要把所有人都判为不违约就能拿到 78% 的准确率再用class_weight微调一下就变成 92%。准确率在不平衡场景里严重失真。解决统一改用 AUC / KS / PR-AUC 做模型评估。如果已经用准确率调过参务必回头看混淆矩阵把 Recall 和 Precision 按业务权重重新定目标。5.2 数据泄漏Shuffle 切分导致的未来数据穿越现象交叉验证 AUC 0.85训练集全量训练后上线测试 AUC 只有 0.72。原因信用卡数据本身有时间顺序直接用train_test_split默认的shuffleTrue会把后面月份的样本混进训练集模型偷看了未来信息。真实业务推理时未来数据是不可见的。解决切分前先按时间排序shuffleFalse或用TimeSeriesSplit。更严格的做法是切分时不只按行切还要按用户 ID 去重防止同一个用户在不同集合里出现。5.3 特征里藏了泄露字段ID、还款状态和未来变量现象模型 AUC 0.97 高得离谱细看特征重要性发现排序第一的是ID列或者某些当月已经逾期的时效性字段。原因原始数据里default标签是从某个时间点回看确定的如果特征列里包含本期是否已经还款这类未来才知道的信息模型等于直接看到了答案。尤其要警惕推导列比如PAY_0如果和标签的统计口径重叠就是隐形泄漏。解决建表之前把ID、申请时间、数据截止时间相关字段全部排除。对每一个特征做信息时效审查——这个特征在预测时点是不是真实可得的。拿不准的字段宁可删掉。5.4 特征交叉太狠树模型没崩LR 先炸了现象加了十几个PAY_AMT / BILL_AMT的比值特征后LR 的 AUC 起反作用但 XGBoost 的 AUC 没变化。原因比值特征放大了小账单金额的噪声比如PAY_AMT1 / (BILL_AMT11)在账单金额为 0 或 1 时会产生数百倍的极值。树模型对极值有天然的鲁棒性LR 没有。解决LR 输入的所有衍生特征做一次分位数截断或者改用np.log1p对数变换。更稳的做法是让 LR 只吃原始语义特征衍生特征只喂给树模型在 Stacking 层做特征分流。5.5 阈值 0.5 不是天经地义策略里的损失函数决定阈值现象模型 AUC 0.82 但实际投放时亏损——按 0.5 阈值拒绝了一批本可以盈利的客户同时又漏掉了一批真正违约的客户。原因AUC 只看排序不看绝对概率。0.5 是默认阈值但不等于业务最优阈值。当违约客户带来的坏账损失远大于优质客户的利润贡献时阈值必须下移多召回一些人反之则上移。解决把违约损失和收益量化算一个成本函数在验证集上网格搜索最小化成本的阈值。这一步通常能把模型的实际业务价值提升 20%~40%比重调任何模型参数都快。6. 阈值选择与业务落地网格搜索最优阈值把 AUC 翻译成钱从模型到策略之间还差最后一步选阈值。很多人在这一步用predict_proba后直接 0.5完事但在信用卡场景里0.5 几乎不可能是最优解。假设一笔违约贷款的平均损失是 8000 元一个正常客户的年化收益是 500 元那你应该尽量把阈值压低即便牺牲一些精确率多召回部分违约客户也划算——因为错过一个违约客户的损失远大于误杀几个好客户。# 用损失函数网格搜索最优阈值 default_loss 8000 # 违约客户造成的平均损失 profit_gain 500 # 正常客户带来的平均收益 best_threshold 0.5 best_cost float(inf) for thr in np.arange(0.1, 0.9, 0.02): pred_label (calibrated_prob thr).astype(int) tp ((pred_label 1) (y_test 1)).sum() fn ((pred_label 0) (y_test 1)).sum() tn ((pred_label 0) (y_test 0)).sum() cost fn * default_loss (len(y_test) - tn - tp) * profit_gain if cost best_cost: best_cost cost best_threshold thr print(best threshold:, round(best_threshold, 2)) print(min total cost:, best_cost)逻辑说明这个网格搜索的思路不是找统计最优而是找业务最优。default_loss 8000和profit_gain 500是示例值真实项目里应该让风控同事给出坏账回收率和资金成本后再填。注意这个成本函数里没有直接算 TP 的收益——TP 代表的是该拒绝的客户被正确识别它本身不减成本而是减少损失。参数说明np.arange(0.1, 0.9, 0.02)是搜索范围。步长 0.02 已经足够细再小只会带来过拟合噪声。跑完之后把最优阈值附近的成本曲线画出来看是不是有一个明显的凹点——如果是平滑下降说明模型对阈值不敏感策略上可以放宽如果是尖峰说明验证集样本量不够需要做交叉验证取平均。最后说一个我的习惯选完阈值后用最优阈值回看一遍测试集的混淆矩阵如果召回率提升带来的坏账减少能被精确率下降带来的人工审核成本覆盖我就会把best_threshold写进策略配置而不是留在 notebook 里。这个流程走完信用卡违约预测模型才真正从机器学习作业变成了能上线的风控策略。希望帮到你。本文还有配套的精品资源点击获取
返回列表