简介:面向信用卡违约预测场景的机器学习实战资源,聚焦客户违约风险识别与信贷评估优化,适合数据科学初学者及金融风控方向学习者参考。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(upper=q99)逻辑说明:把PAY字段从 "数值" 矫正为 "有序类别",看起来只是换了个编码,实际效果是让模型不再误以为 -2 和 8 之间存在线性可比的梯度。bill_ratio_1这类环比特征是信贷场景里少数能真正提升区分度的衍生变量——它捕捉的是"用户这个月是不是突然多刷了"的异常行为,而不是绝对金额。
参数说明:除法里加 1 是防止除零;clip上限取 99 分位而不是直接删行,因为信用卡账单金额遵循长尾分布,删掉极值会让验证集的 KS 掉得厉害。这个操作对 LR 类线性模型尤其重要,树模型可以不截断,但融合模型里通常会带 LR,所以提前统一处理。
2.3 数据切分:时间序列数据的切分不能乱 shuffle
信用卡违约数据有个容易被忽略的性质:它是按月收集的时序数据。如果直接train_test_split(shuffle=True),会把未来数据混进训练集,导致模型在验证集上虚高,上线后立刻崩盘。正确切法要么按时间排序后取尾部做验证集,要么至少保证切分前先按 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_size=0.2, shuffle=False, stratify=None ) print("train default rate:", y_train.mean().round(4)) print("test default rate:", y_test.mean().round(4))逻辑说明:shuffle=False是时序切分的核心。如果原始数据不是按时间排序的,需要先用df.sort_values("time_col")排序再切分。stratify在这里必须设为 None,因为按时间切分天然会改变验证集的违约率——如果验证集的违约率和训练集差异过大,说明原始时间窗口内的客群结构发生了变化,这个信号本身就该被观察到,而不是被掩盖。
参数说明:test_size=0.2是按时间切分的常用比例,但真实项目中我习惯用 0.15,因为信贷场景的逾期标签往往有滞后性,训练集如果太短,近期样本的特征分布会不稳。另一点是切分后要检查y_train.mean()和y_test.mean()的差,如果超过 3 个百分点,建议缩短训练集窗口,或者干脆做滚动验证。
3. 基线模型与评估:为什么准确率在这个场景里完全不可信
3.1 先跑一个 Logistic Regression:不是为了分数,是为了解释性
信用卡违约预测的入门模型首选就是 LR,原因不是它精度高,而是它给每个特征一个清晰的权重,业务方能直接拿去当策略解释材料。实际做的时候,先跑一个带class_weight="balanced"的 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_weight="balanced", C=0.1, max_iter=1000, solver="liblinear", random_state=42, ) 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_weight="balanced"会按样本比例自动给少数类加权,这是处理不平衡最简单、最不会翻车的手段。solver="liblinear"适合小规模数据,而且支持 L1 正则,方便后续做特征筛选。注意C=0.1——C 是正则强度的倒数,越小正则越强,LR 在信用卡数据上如果不加正则,特征权重会被 LIMIT_BAL 这种大数值字段带偏。
参数说明:用StandardScaler的原因不只是量纲——LR 的目标函数对特征尺度敏感,solver="liblinear"配合标准化后收敛更稳。这里的 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_estimators=300, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, scale_pos_weight=sum(y_train == 0) / sum(y_train == 1), eval_metric="auc", early_stopping_rounds=50, random_state=42, ) xgb_model.fit( X_train, y_train, eval_set=[(X_test, y_test)], verbose=False, ) 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_rounds=50是防止过拟合的常规手段,但要注意——如果不设eval_metric而默认用 logloss,收敛会慢很多,信用卡这种不平衡场景用 AUC 做早停更贴合目标。
参数说明:max_depth=5是保守选择。信用卡数据的特征量不大,深度超过 6 之后模型的方差会明显变大,在验证集上的波动肉眼可见。learning_rate=0.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_splits=5): """ 返回: (oof_preds, test_preds_list) oof_preds: (len(X), n_models) 每个模型在交叉验证下的预测概率 test_preds_list: 每个模型对测试集的平均预测概率 """ skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) 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_weight="balanced", C=0.1, max_iter=1000), xgb.XGBClassifier(n_estimators=200, max_depth=4, scale_pos_weight=3.5), RandomForestClassifier(n_estimators=200, max_depth=8, class_weight="balanced"), ]逻辑说明:这个函数是 Stacking 最常见的手工实现,每个基模型走一遍StratifiedKFold,在每一折上用训练部分拟合、验证部分预测,最后把验证部分的预测概率拼成完整的 OOF 预测概率。注意model_clone = model.__class__(**model.get_params())这一步——直接把原模型对象 reuse 会累积已拟合的状态,导致数据泄漏,这是新手最常踩的坑。
参数说明:n_splits=5是 Stacking 的默认选择,数据量够大可以加到 8,数据量小用 5 更稳。基模型的参数特意选得比单独训练时"弱一点"——比如 XGBoost 的max_depth=4而不是单独调参时的 5,这是为了降低基模型之间的相关性,给元模型留出学习空间。
4.3 元模型与加权融合:LightGBM 作为元模型,还是 LR 更靠谱?
第一层生成 OOF 特征之后,第二层用 LR 拟合oof_preds到y,这是一般做法里最稳的组合。原因是元模型的输入维度就是基模型数量(通常 3~5 个),LR 不容易过拟合,而且能给每个基模型一个可解释的权重——如果某个模型的权重变成负数,说明它提供的信号和最终结果是负相关的,需要回看是不是该模型的验证集预测出了问题。
meta_lr = LogisticRegression(C=1.0, max_iter=1000) 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 覆盖了,可以考虑去掉这个模型,让融合模型更干净。
参数说明:C=1.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( estimator=meta_lr, method="isotonic", # 等渗回归,适合非单调关系 cv=3, ) 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))逻辑说明:method="isotonic"是不需要预设曲线形状的校准方法,如果训练数据足够(超过 1000 条),效果一般优于sigmoid。这里有个细节:校准用的是 OOF 概率做输入,所以cv=3是内层再交叉验证,避免校准本身也过拟合。
参数说明:运行完一定要对比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默认的shuffle=True,会把后面月份的样本混进训练集,模型"偷看"了未来信息。真实业务推理时,未来数据是不可见的。
解决:切分前先按时间排序,shuffle=False或用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_AMT1+1)在账单金额为 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 里。这个流程走完,信用卡违约预测模型才真正从"机器学习作业"变成了"能上线的风控策略"。希望帮到你。
本文还有配套的精品资源,点击获取