信贷违约预测实战:从特征工程到模型调参与评分卡构建

发布时间:2026/9/26 11:58:17
信贷违约预测实战:从特征工程到模型调参与评分卡构建 简介面向计算机相关专业学生与从业者的个人信贷违约预测识别项目源码包基于机器学习方法实现包含完整源码与训练测试数据集评审分达九十七分经过严格调试可正常运行适合期末课程设计、课程大作业或毕业设计参考。资源包含五十九个文件压缩包约一百四十二MB以十六个csv数据文件与十个Python源码文件为核心另有数个Go辅助脚本、Markdown说明文档、模型权重文件、项目报告、答辩幻灯片及成绩截图等覆盖数据预处理、模型训练、违约预测与结果评估全过程。已有178人次学习浏览。下载后可直接运行主程序参考决策树与多层感知机等多种模型实现结合成绩验证截图、项目报告与答辩PPT系统理解高分项目结构亦便于二次开发与复现实验。整体结构清晰注释完整便于按需提取相关模块。1. 信贷违约预测到底在预测什么一个让银行风控部门天天调参的二分类问题个人信贷违约预测识别是机器学习最主要也最现实的落地场景之一审批一笔贷款之前系统根据申请人的历史行为与申请信息预估他未来几个月内违约的概率。这个 zip 里大概率装着完整的 python 项目源码、训练测试数据集通常是 CSV和模型评估脚本属于典型的「高分毕设项目」结构——但它的业务逻辑完全可以当作真实信贷风控的入门沙盘。我接触这类项目时最深的感受是模型训练代码反而是最简单的一环真正的门槛在数据处理和标签定义上这两个环节直接决定最终模型是能上线还是只能停在答辩 PPT 里。这篇文章想帮你看懂包里每一份文件的用途、跑通一条建模链路并把最容易翻车的地方讲透。2. 拿到「训练测试数据集」先别急着跑模型目标变量定义、数据清洗与特征工程的三个硬动作2.1 先定义谁是坏人观察期加表现期才是标签的完整逻辑信贷数据里的 target 不是简单地从某一天开始看「逾期了没有」。真实风控里每个样本必须对应一个明确的时间窗口观察期用来提取特征表现期用来确定标签。常见做法是取申请人提交贷款申请前的 6 到 12 个月作为观察期再用之后的 3 到 6 个月作为表现期如果申请人在表现期内发生过至少一笔逾期超过 30 天的记录就定义成违约样本即 target 1否则是 0。我遇到过不少同学拿到这类项目后直接调用pd.read_csv就开始训练完全没看标签是怎么生成的。这在答辩或面试时很容易被追问你的正负样本比例是多少违约窗口是多长如果数据集包里已经给出了 target 字段你要做的第一件事仍然是验标签——用 groupby 统计每个申请月份的违约率如果发现违约率随时间明显波动说明标签窗口可能存在泄漏或者样本采样不均匀。验证标签定义比训练模型本身更值得花时间。顺带说一个规律真实信贷数据集里坏样本占比通常不超过 10%你这个项目如果显示正样本 30% 以上大概率是做过下采样平衡的。做下采样是可以的但报告结果时必须说明否则模型预测概率会被整体抬高后面做阈值调整时会被误导。import pandas as pd df pd.read_csv(credit_data.csv, encodinggbk) print(df.shape) print(df[target].value_counts()) print(df[target].value_counts(normalizeTrue)) # 查看每个月份的申请量和违约率判断标签口径是否一致 df[apply_month] pd.to_datetime(df[apply_date]).dt.to_period(M) monthly_stats df.groupby(apply_month)[target].agg([count, mean]) print(monthly_stats)这段代码的核心用途是「建模前体检」。value_counts(normalizeTrue)看正负样本比例groupby看不同申请月份下的目标均值。如果 apply_month 的违约率从 0.05 跳到 0.3 再跳回 0.06不要怀着侥幸心理往下做——最可能的解释是标签窗口切错了需要回头确认数据生成脚本里的时间偏移逻辑。特征可以后期再加工标签口径错了整个项目就是空中楼阁。2.2 数据清洗的三个硬动作缺失率、重复样本、异常值截断这个 zip 里的训练测试数据集往往是从原始信贷记录里抽出来的字段命名可能不规范还带着大量缺失和高频异常值。清洗这一步我一般固定在三个动作上统计缺失率、删除完全重复的行、对极端连续值做分位数截断。缺失率处理要看字段的业务含义比如「年收入」缺失 20% 可以选择填补而「逾期次数」缺失 5% 以下可以直接用 -1 填充分箱代表「没有历史记录」这个独立含义。重复行的问题在信贷数据里比想象中常见因为同一申请人可能在短时间内提交多次申请特征完全相同而 target 相同或相反。如果 target 相反的重复行存在说明标签生成逻辑有严重缺陷建议直接删除所有重复行而不是保留第一条。import numpy as np print(缺失率) print(df.isnull().mean().sort_values(ascendingFalse).head(10)) df df.drop_duplicates().reset_index(dropTrue) # 对连续特征做 1% 分位数截断防止极端值干扰分箱 num_cols [loan_amount, annual_income, debt_ratio, credit_score] for col in num_cols: lo, hi df[col].quantile([0.01, 0.99]) df[col] df[col].clip(lo, hi) print(df.shape)参数说明quantile([0.01, 0.99])取的是 1% 和 99% 分位数clip把所有超出这个范围的值压到边界上。这种方法不是删除样本而是限制极端值对后续分箱的影响——信贷数据里年收入填几千万的异常值往往就是录入错误截断比删除更稳健。drop_duplicates()默认保留第一条记录如果数据量不大建议在删除前输出重复行的 target 分布辅助判断标签逻辑问题。2.3 特征工程WOE 分箱与缺失值单独成箱信贷评分领域最经典的连续特征处理方式不是直接标准化而是 WOEWeight of Evidence分箱。逻辑很简单把连续变量切分成若干段计算每段好样本占比与坏样本占比的比值的对数这个值就是 WOE。WOE 的好处是让特征与 target 的关系呈单调趋势逻辑回归的拟合效果会明显更稳定。def woe_discrete(df, feature, target): df df[[feature, target]].copy() df[bad] df[target] df[good] 1 - df[target] grouped df.groupby(feature).agg( total(bad, count), bad_sum(bad, sum) ) grouped[good_sum] grouped[total] - grouped[bad_sum] grouped[bad_rate] grouped[bad_sum] / grouped[total] total_bad grouped[bad_sum].sum() total_good grouped[good_sum].sum() grouped[woe] np.log( (grouped[good_sum] / total_good) / (grouped[bad_sum] / total_bad) ) return grouped[woe]这段代码的核心逻辑是每一箱的 WOE 等于该箱内好样本占比除以坏样本占比再取对数。正数表示该箱风险低于整体平均水平负数表示风险更高。分箱粒度需要自己控制通常每箱样本量不少于总样本的 5%否则 WOE 波动太大。实际操作时先用pd.qcut做等频分箱然后观察每一箱的坏样本率是否单调不单调就手动合并相邻箱。缺失值单独成一箱不参与分箱合并这是在信贷模型里最容易忽略的一个动作因为缺失本身往往是有业务含义的——比如申请人没有信用卡记录它的违约概率分布和「有卡但低额度」的人完全不同。3. 模型训练与对比逻辑回归、XGBoost、LightGBM 在同一个数据集上跑出可解释结果3.1 划分训练/验证/测试集与逻辑回归基线拿到处理完的特征第一件事是切分数据不是直接上树模型。切分时注意两点一是用stratify参数保持训练集和测试集的正样本比例一致二是如果数据带时间字段不能用随机切分要用按时间切分——以某个月为界之前做训练集之后做测试集。随机切分会高估模型表现因为同期的数据分布更接近。from sklearn.model_selection import train_test_split X df.drop(columns[target, apply_date, uid]) y df[target] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) print(X_train.shape, X_test.shape) print(y_train.mean(), y_test.mean())test_size0.2表示测试集占 20%stratifyy保证切分前后正样本比例不变。random_state42固定随机种子便于复现和对比不同模型结果。如果你有验证集的需求可以再切一次先从 X_train 中切出 20% 做验证集专门用于早停和调参测试集只在最后评估时碰一次这样能避免调参过程中对测试集的信息泄漏。这一步做得好不好直接影响后续 XGBoost 的 early stopping 是否可信。3.2 逻辑回归效果不是最好但是最容易被业务接受逻辑回归在信贷领域的地位很难被替代原因只有一个可解释性。银行风控要求每个拒绝决策都能说清楚理由逻辑回归的系数可以直接转成评分卡而树模型做不到这一点。用逻辑回归做基线模型参数上最需要注意的是类别不平衡处理。from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline lr_pipeline Pipeline([ (scaler, StandardScaler()), (lr, LogisticRegression(class_weightbalanced, C0.5, max_iter1000)) ]) lr_pipeline.fit(X_train, y_train) print(LR train auc:, roc_auc_score(y_train, lr_pipeline.predict_proba(X_train)[:, 1])) print(LR test auc:, roc_auc_score(y_test, lr_pipeline.predict_proba(X_test)[:, 1]))class_weightbalanced是 sklearn 对类别不平衡的自动处理方式它按样本量的反比给少数类加权效果等同手动设置scale_pos_weight。C0.5是正则化强度的倒数越小正则化越强在信贷特征数不多时可以设为默认 1.0特征多了再下调。使用管道Pipeline的意义是确保标准化算子只在训练集上拟合测试集只做 transform这是新手最容易踩的坑先标准化再切分会导致数据泄漏。但逻辑回归也有明显局限它假定特征是线性关系对强非线性交互的捕捉能力有限。所以它适合当基线不适合当最终模型。如果逻辑回归在测试集上 AUC 达到 0.75 以上说明特征工程做得不错后面换树模型才能继续涨点。3.3 XGBoost 与 LightGBM早停参数和权重参数怎么设树模型是这类项目的主流最终选择。XGBoost 和 LightGBM 在信贷数据上的表现通常优于逻辑回归因为能自动捕捉特征交互和阈值效应。但它们的参数敏感性也更高训练时建议把 early stopping 用起来而不是手动硬选迭代次数。import xgboost as xgb from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score X_train, X_val, y_train, y_val train_test_split( X_train, y_train, test_size0.2, stratifyy_train, random_state42 ) xgb_model xgb.XGBClassifier( n_estimators1000, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, scale_pos_weight(y_train 0).sum() / (y_train 1).sum(), eval_metricauc, early_stopping_rounds50, random_state42 ) xgb_model.fit( X_train, y_train, eval_set[(X_val, y_val)], verboseFalse ) y_pred_xgb xgb_model.predict_proba(X_test)[:, 1] print(XGB test auc:, roc_auc_score(y_test, y_pred_xgb))参数说明集中在两个地方scale_pos_weight用于处理正负样本不平衡计算方式是负样本数除以正样本数early_stopping_rounds50表示连续 50 轮验证集 AUC 不再提升就停止训练最终模型会保留历史最优迭代轮次。max_depth4是信贷数据的常用起点树太深容易记住噪声learning_rate0.05配合 1000 棵树能保证模型有足够容量同时不容易跳过最优解。LightGBM 的训练参数和 XGBoost 大部分一致区别在它默认用叶子生长策略from lightgbm import LGBMClassifier lgb_model LGBMClassifier( n_estimators1000, num_leaves31, max_depth5, learning_rate0.05, subsample0.8, colsample_bytree0.8, scale_pos_weight(y_train 0).sum() / (y_train 1).sum(), random_state42 ) lgb_model.fit( X_train, y_train, eval_set[(X_val, y_val)], eval_metricauc, callbacks[lgb.early_stopping(50)] )num_leaves31是 LightGBM 的核心参数它控制每棵树的最大叶子数比max_depth更能直接影响模型复杂度。调参时先从 31 开始如果验证集 AUC 还在涨可以往上提到 63 试试一味的增大叶子数会带来严重的过拟合。两个模型跑完之后建议把预测结果并排对比一下画 ROC 曲线或用散点图看它们预测不一致的样本集中在什么区间这比单看 AUC 更有洞察价值。3.4 模型效果对比AUC、KS、混淆矩阵一起看只报 AUC 是走不到项目验收的。信贷领域更看重 KS 值它衡量模型对好坏样本的区分度。KS 的计算方式是把预测概率排序分箱每一箱的累计好样本比例减去累计坏样本比例取最大值。AUC 是全局平均区分度KS 是最大区分度两者都是 0 到 1 的数值信贷模型通常要求 KS 大于 0.3 才认为有可用性。from sklearn.metrics import roc_curve, roc_auc_score fpr, tpr, thresholds roc_curve(y_test, y_pred_xgb) ks max(tpr - fpr) print(KS:, ks)注意 KS 对应的阈值不是最佳业务阈值它只代表模型在最优点上的区分能力。实际业务阈值要结合成本和收益单独定这在后面第 5 章再展开。到这里你已经跑完一条完整的建模链路但离「上线」还差两步把坑踩平把概率转化成业务动作。4. 违约预测最容易翻车的 4 类坑从标签泄漏到线上离线不一致全是血泪经验4.1 标签泄漏训练 AUC 0.98测试集直接崩到 0.55**现象**模型在训练集上表现极好AUC 0.98一换到测试集就变成 0.55 左右接近随机猜测。更隐蔽的情况是训练集和测试集都在 0.9 以上但模型上线后完全不work。**原因**标签泄漏指训练时用到了未来信息或目标信息本身。最常见的有三种一是特征里包含了「当前是否逾期」这类和目标强相关的字段二是做数据清洗时用了全量数据的统计值填充缺失比如用整个数据集的平均收入去填训练集的空值三是在切分前做了特征选择使用了测试集的信息。**解决**排查方法很直接——把每个特征的 IV 值和模型 feature_importance 打印出来逐个检查排名靠前的特征是否在业务上「长得太像标签」。比如一个字段叫「overdue_days」建模时它可能是最强特征但业务上这个字段在申请时点根本拿不到。处理方式是先做特征血缘梳理每个特征标注它的来源时间点是否早于申请时点对填充类统计量只用训练集部分计算然后transform到测试集。4.2 类别不平衡坏样本只有 3%模型学成了一个只会说「好」的复读机**现象**准确率 95% 以上但混淆矩阵里坏客户全部预测成好客户AUC 依然可以做到 0.7 以上因为 AUC 只看排序不看绝对概率。**原因**绝大多数分类算法以降低整体损失为目标少数类样本占比过低时模型从「全部预测为好」的错误中学不到足够惩罚决策边界容易偏向多数类。这是机器学习在信贷领域的经典翻车场景不是算法坏了是目标函数设定的问题。**解决**三个可选手段。第一是在逻辑回归里设class_weightbalanced在 XGBoost/LightGBM 里设scale_pos_weight这是最省事的做法第二是做 SMOTE 过采样但只建议在训练集上做并且要在过采样之前完成训练测试切分第三是训练完不调模型调阈值——把预测概率的判定阈值从 0.5 往下压比如压到 0.2坏客户召回率会明显上升代价是误伤部分好客户。真实风控里第三种方法最常用因为坏客户的漏损成本远高于好客户的误拒成本。4.3 过拟合高方差参数组合看着很美换了数据就现原形**现象**XGBoost 训练到 500 轮时训练集 AUC 0.96验证集 0.78再继续训练验证集 AUC 反而下降到了 0.75。**原因**模型开始记住训练样本里特有的噪声模式。信贷数据本身信噪比低特征维度多、样本量不够时深树或小学习率加大量迭代的组合最容易触发这个问题。**解决**代码里已经写了 early stopping这是第一道防线。第二道防线是限制单棵树复杂度max_depth控制在 4 到 6min_child_weight设为 5 到 20让每个叶子节点至少积累足够的样本量再分裂。第三道防线是增加随机性subsample0.7colsample_bytree0.7让每棵树看到的数据和特征不完全相同依赖多棵树的平均来抵消方差。调参血泪经验是先固定一个较小的max_depth让 early stopping 自己找最优迭代数再去动其他参数。一次性同时调一堆参数你根本分不清是哪个设置导致了涨跌。4.3 特征口径不一致线下 AUC 0.82线上效果打六折原因不在模型而在特征管线**现象**离线验证集上表现良好的模型在线上小流量测试时预测分数的分布整体偏移——均值从 0.3 漂到 0.5高风险客户的命中率远低于预期。模型文件没有任何改动逻辑回归系数也是同一套。**原因**特征口径不一致。最常见的是线上实时抽取的特征和离线训练集的特征计算方式不同缺失值填充的口径不同、分箱边界不一致、部分字段在线上申请时点还没有值而离线数据里是后期补录的。这类问题数据处理层的锅不完全是模型的锅。**解决**做法是给每个特征写一个标准化的计算函数包括缺失值填充规则和分箱边界训练和预测共用同一个函数。上线前再用一批最近的真实申请数据跑一遍预测分布和训练集的预测分布做对比如果两者有明显偏移先回头排查特征管线而不是重新调模型。把特征加工脚本和模型参数一起打进zip包管理是我见过最有效的工程习惯。5. 模型值不值得上线的最后一步分数卡转化、阈值选择与业务可解释性验证逻辑回归的朴素输出是违约概率但业务方不习惯用概率做决策。常见做法是把概率映射成分数评分卡形式的分值范围一般是 300 到 800越高代表风险越低。转换公式不复杂设定基准分值和基准违约概率再让分数每升高固定分值违约概率的 odds 翻倍。import numpy as np base_score 600 # 基准分数 base_odds 50 # 基准 odds好客户/坏客户 50 pdo 50 # 每升高 50 分odds 翻倍 A pdo / np.log(2) B base_score - A * np.log(base_odds) score A * np.log((1 - y_pred) / y_pred) B print(score range:, score.min(), score.max())参数说明pdo 50是评分卡的常见行业设置表示每增加 50 分好客户与坏客户的比值翻一倍。A和B是线性变换系数计算后每个预测概率都能映射成一个整数分数。注意这里y_pred是模型输出的违约概率转换时用的是 odds (1 - p) / p。分数越低风险越高在业务上看起来更直观。做完分数转换下一步是定拒绝阈值。在真实信贷业务里这一步不需要追求 AUC 最大化而是比较误拒好客户和漏放坏客户两类错误的成本。如果坏账损失是 10000 元好客户贡献的利息收入是 500 元那么阈值应该往召回率方向压。画一条精确率-召回率曲线找到业务可接受的精确率对应的召回率点再反查阈值分数。这个决策过程需要业务方参与不完全是算法工程师能单方面定的。最后还要做一件事模型在上线后需要持续监控监控的不是 AUC而是预测分布的稳定性。每个月统计一次分数分布用 PSIPopulation Stability Index量化当前月分布和训练月分布的差异PSI 超过 0.25 就必须告警然后排查是客群变化还是特征源出问题。这也是我做了几个信贷项目后被反复教育出来的习惯——模型不是一次训练完就结束的产出物它是一个需要长期运维的系统组件。每次把这类项目交付出去我都会反复核对特征口径和标签定义这两个最容易埋雷的环节在代码注释里写清每个字段的业务含义和时间窗口这样的项目源码才有被复用和扩展的价值希望帮到你。本文还有配套的精品资源点击获取