LendingClub信贷违约预测模型:数据清洗、特征工程与评分卡全流程

发布时间:2026/10/10 2:43:27
LendingClub信贷违约预测模型:数据清洗、特征工程与评分卡全流程
简介基于LendingClub公开信贷数据构建的智能违约预测模型项目面向金融风控、数据科学与机器学习初学者及从业者解决从数据清洗、预处理、特征工程到模型训练与评估的完整风控建模问题。资源共5个文件压缩包仅47KB包含可运行的模型脚本、分步演示的Jupyter笔记本、项目说明文档、txt使用说明及Word附赠资料分别对应模型实现、分析过程、项目导读、运行指引和补充学习内容Notebook便于逐段理解与调试脚本可独立执行文档帮助快速掌握整体结构目录清晰方便查阅。目前已有59人学习下载。项目围绕借款人信用评分、贷款金额、利率、就业年限、年收入与负债等核心字段完整演示逻辑回归、随机森林、梯度提升机等算法在违约预测中的实践并通过准确率、精确率、召回率与F1分数等指标评估模型效果同时关注数据隐私与公平性可帮助读者掌握信贷风控建模的完整链路应用于贷款审批、风险定价与违约损失控制具备实际业务参考价值可用于课程设计、毕业项目或业务探索。1. 智能违约预测模型LendingClub 信贷数据能训练出什么这套基于 LendingClub 信贷数据构建的智能违约预测模型项目拆完以后我最大的感受是它不像竞赛题更像一份可以直接对接风控业务的真实场景数据。LendingClub 把 2007—2018 年间每一笔贷款的借款人信用评分、贷款金额、利率、就业年限、年收入、负债水平都公开了目标很明确——用这些字段预测某笔贷款最终会不会违约。这套违约预测模型资源适合两类人一是刚转风控、想在没有真实业务数据时跑通「清洗—建模—评估」全流程的从业者二是想用完整项目证明建模能力的求职者。模型层面的东西其实不复杂真正值钱的是数据口径怎么定、特征怎么处理、阈值怎么选。下面我就按实际拆解的顺序把这套资源从原始表到可落地评分卡完整过一遍。2. 数据清洗与样本设计先从 2007—2018 原始表里拿到干净样本拿到 LendingClub 数据的第一件事不是跑模型而是先回答三个问题哪些记录算「好客户」、哪些算「坏客户」、剩下的那些能不能用。这三个问题没定清楚后面所有 AUC、KS 都是自欺欺人。我见过不少人直接把表读完就喂给模型结果把还在还款中的贷款当成了「正常样本」验证集指标虚高 5 个点不止。这一章先把目标变量和样本区间定死。2.1 目标变量怎么定bad 的口径决定模型上限LendingClub 的loan_status字段有 7 种取值常见的是 Fully Paid、Charged Off、Current、Late (31-120 days)、In Grace Period、Default。这里最容易翻车的点是 Current 和 In Grace Period贷款还在进行中好坏没有定论直接纳入训练集等于拿「未知结果」当标签模型学到的全是噪声。我的常规做法是只保留已经结束的贷款也就是 Fully Paid 和 Charged Off / Default / Late (31-120 days)其中后者归为 bad1。# 只保留贷款周期已结束的记录避免用未定论样本训练 valid_status [Fully Paid, Charged Off, Default, Late (31-120 days)] df df[df[loan_status].isin(valid_status)].copy() # 定义二分类目标违约为1正常结清为0 df[bad] df[loan_status].apply( lambda s: 1 if s in [Charged Off, Default, Late (31-120 days)] else 0 ) print(df[bad].value_counts(normalizeTrue))这段代码的逻辑是先把样本限定在「结果已经确定」的记录上再用isin把违约类状态映射成 1。这里有个细节Late (31-120 days)之所以算违约是因为 LendingClub 的逾期超过 31 天基本不会回款把它当 good 会严重高估模型能力如果你做的是更保守的风控策略也可以只保留 Charged Off 和 Default把 Late 样本剔除代价是坏样本量减少。提示loan_status里还有Does not meet the credit policy这类状态它表示贷款因审核不通过而未发放这类记录应该直接删除因为它不是一笔真实放款。2.2 缺失值处理贷前字段和贷后字段要分开对待LendingClub 表的缺失值不是随机缺失尤其是mths_since_last_delinq距上次逾期月数和mths_since_last_record距上次公共记录月数这两个字段只有发生过逾期或破产的人才有值缺失代表「近 12 个月内没有发生过」语义是 0 而不是「未知」。如果统一填充中位数模型会把「从未逾期」和「逾期很久」混为一谈后面 KS 统计量掉 6 个点都不奇怪。# 贷后行为字段缺失 事件未发生填 0 并保留缺失标志 for col in [mths_since_last_delinq, mths_since_last_record]: df[col _missing] df[col].isna().astype(int) df[col] df[col].fillna(0) # 贷前字段缺失比例低用中位数填充 df[revol_util] df[revol_util].fillna(df[revol_util].median()) # emp_length 是字符串需要先映射成数值 emp_map { 1 year: 0, 1 year: 1, 2 years: 2, 3 years: 3, 4 years: 4, 5 years: 5, 6 years: 6, 7 years: 7, 8 years: 8, 9 years: 9, 10 years: 10} df[emp_length_num] df[emp_length].map(emp_map)emp_length的映射注意10 years要放在字典里字符串匹配是全等匹配漏掉一个取值就会变成 NaN。至于col_missing这种标志位我是建议保留的它告诉模型「这个客户没有逾期历史」本身就是一个强信号。整体原则贷前字段如收入、负债率缺失可以填充贷后行为类字段缺失一律按事件未发生处理。2.3 训练集、验证集、测试集按时间切分不按随机种子信贷数据和图像数据不一样它有时间结构。2015 年和 2018 年的信贷政策、经济环境完全不同随机切分会让模型在训练时「看到未来」测试集指标虚高。我一般按issue_d放款月份来切前 70% 的月份做训练中间 15% 做验证最后 15% 做测试。这样模拟的是真实上线场景——用过去的数据训练去预测尚未发生的贷款。df[issue_d_ts] pd.to_datetime(df[issue_d], format%b-%Y) train df[df[issue_d_ts] 2016-04-01] valid df[(df[issue_d_ts] 2016-04-01) (df[issue_d_ts] 2017-04-01)] test df[df[issue_d_ts] 2017-04-01] print(train[bad].mean(), valid[bad].mean(), test[bad].mean())三个区间的违约率一般会略有差异这是正常的不需要强行对齐。如果差异超过 5 个百分点说明样本区间跨越了比较大的经济波动这时候要看的是模型在每个区间上的稳定性而不是把数据混在一起重新随机切。时间切分的代价是训练样本变少但换来的是评估结果可信。3. 特征工程FICO、利率、dti 变成违约信号的正确打开方式LendingClub 原始表里有 100 多列真正能稳定贡献违约预测能力的其实不超过 20 个。这一章不追求特征数量而是把标题里提到的信用评分、贷款金额、利率、就业年限、年收入、负债这几个核心字段做扎实再加上两个派生特征最后用 WOE 处理强分类变量。特征工程做到位逻辑回归也能跑到 0.81 的 AUC。3.1 原始字段先做三件事取中值、取对数、截断fico_range_low和fico_range_high是两个区间端点直接取均值得到单一 FICO 分int_rate原始是带百分号的字符串要先转 floatannual_inc长尾分布严重取对数后模型更稳定dti负债收入比超过 40 的人基本是极端负债保留原始值会把模型带偏我习惯把超过 40 的值截断到 40。import numpy as np # FICO 取区间中值 df[fico] (df[fico_range_low] df[fico_range_high]) / 2 # 利率字符串 12.99% - 12.99 df[int_rate] df[int_rate].str.replace(%, , regexFalse).astype(float) # 年收入取对数压缩长尾 df[log_income] np.log1p(df[annual_inc]) # 负债收入比截断防止极端值主导模型 df[dti] df[dti].clip(upper40)clip(upper40)比直接删掉极端值更好因为保留样本量又限制了极端值对逻辑回归系数的拉扯。log1p对 0 收入也友好避免 log(0) 报错。贷款金额loan_amnt我通常保留原始值因为它本身和利率、分期数强相关再取对数反而抹掉了这部分信息。3.2 派生特征信用历史长度和循环额度使用率earliest_cr_line是客户第一笔信用记录的时间用放款时间减去它就能算出信用历史长度。这个特征的逻辑很直接信用历史越长风控能观察到的行为越多违约率越低。revol_bal和max_revol_bal可以算循环额度使用率使用率超过 60% 的人通常现金流紧张是违约的强信号。# 信用历史长度年 df[credit_history_years] ( pd.to_datetime(df[issue_d], format%b-%Y) - pd.to_datetime(df[earliest_cr_line], format%b-%Y) ).dt.days / 365 # 循环额度使用率缺失时用 bal/max 估算 df[revol_util_calc] df[revol_bal] / df[max_revol_bal].replace(0, np.nan) df[revol_util] df[revol_util].fillna(df[revol_util_calc]) df df.drop(columns[revol_util_calc])credit_history_years计算出来可能会有负数或极大值负数是原始数据日期解析错误直接删除超过 50 年的基本是脏数据也删掉。max_revol_bal为 0 的情况说明客户没有循环额度这时使用率没有意义保持 NaN 交给填充逻辑即可。3.3 类别变量用 WOE 编码sub_grade 别 one-hotLendingClub 的grade和sub_grade是平台自己的信用评级从 A1 到 G5 共 35 个等级对违约预测的区分度非常高。直接 one-hot 会生成 35 列稀疏特征树模型还能扛逻辑回归就容易过拟合。我习惯把sub_grade做 WOE 编码先按等级分组算坏客户占比再取对数比这样把一个 35 分类变量压缩成一个连续变量。def calc_woe(df, col, targetbad): grouped df.groupby(col)[target].agg([sum, count]) grouped[good] grouped[count] - grouped[sum] grouped[bad_pct] grouped[sum] / grouped[sum].sum() grouped[good_pct] grouped[good] / grouped[good].sum() grouped[woe] np.log(grouped[good_pct] / grouped[bad_pct]) grouped[iv] (grouped[good_pct] - grouped[bad_pct]) * grouped[woe] return grouped sub_grade_woe calc_woe(train, sub_grade, bad) train train.merge(sub_grade_woe[woe].rename(sub_grade_woe), left_onsub_grade, right_indexTrue, howleft)WOE 为正表示该等级坏客户占比低于整体为负表示风险偏高。合并时一定要用train计算映射后应用到valid和test防止验证集信息泄漏进训练过程。iv列信息价值可以用于筛选特征一般 IV 大于 0.1 的特征值得保留sub_grade 的 IV 通常能到 0.5 以上。计算完 WOE 后做一次相关性检查sub_grade_woe和fico相关系数可能超过 0.6这两个特征同时进逻辑回归会出现共线性我的选择是保留 WOE因为它已经包含了平台的综合判断。4. 模型对比与调参逻辑回归、随机森林、XGBoost 谁更稳这一章不追求堆模型而是用三个典型模型把问题看透逻辑回归给出可解释基准随机森林验证非线性关系XGBoost 在精度上做提升。三个模型跑同一份训练集用同一套验证集评估结果才有可比性。先说结论在这份 LendingClub 数据上XGBoost 的 AUC 通常比逻辑回归高 4 到 5 个百分点但提升幅度远没有很多人想象的那么大特征工程做扎实之后逻辑回归已经能拿下大部分收益。4.1 逻辑回归做基准先标准化再调 C逻辑回归对特征尺度敏感annual_inc和dti的量纲差了几十倍不标准化的话系数根本没法解释。标准化要在训练集上 fit再用同一组参数转换验证集和测试集不能三个数据集各自标准化。from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression feature_cols [fico, int_rate, log_income, dti, emp_length_num, loan_amnt, credit_history_years, revol_util, sub_grade_woe] scaler StandardScaler() X_train scaler.fit_transform(train[feature_cols]) X_valid scaler.transform(valid[feature_cols]) lr LogisticRegression(penaltyl2, C0.1, solverliblinear, max_iter500) lr.fit(X_train, train[bad])solverliblinear在小数据集上比lbfgs收敛更快。C0.1是正则化强度的倒数C 越小正则化越强。我一般先用默认 C1 跑一遍再用 0.1、0.01、0.001 网格搜索看验证集 AUC 的峰值出现在哪。如果 0.001 还在涨说明特征里噪声居多要回去砍特征而不是继续加大惩罚。4.2 随机森林min_samples_leaf 是最重要的正则化旋钮随机森林在信贷数据上最容易犯的毛病是过拟合——训练集 AUC 能到 0.99验证集掉到 0.84。罪魁祸首是max_depth不设限、min_samples_leaf太小树把个别样本的噪声也学进去了。信贷数据里min_samples_leaf我一般不会低于 50这相当于要求每个叶子节点至少有 50 个样本支持预测才稳定。from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier( n_estimators300, max_depth6, min_samples_leaf50, max_featuressqrt, class_weightbalanced, n_jobs-1, random_state42 ) rf.fit(X_train, train[bad])max_depth6配合min_samples_leaf50是我在这个数据集上的常用组合既能捕捉特征的交互效应又不会把单棵树的方差拉得过大。max_featuressqrt让每棵树只随机看一部分特征降低树之间的相关性是随机森林泛化能力的关键。class_weightbalanced会在第 5 章详细讲这里先挂上。随机森林的一个优势是可以直接输出feature_importances_用来和逻辑回归的系数做交叉验证——如果两个模型认为的重要特征完全不一致说明特征工程环节可能有问题。4.3 XGBoost早停是必选项不是可选项XGBoost 的参数组合很多我只调最关键的四组max_depth控制树复杂度learning_rate控制学习步长subsample和colsample_bytree控制样本和特征采样比例。信贷数据量级一般不到几十万行n_estimators可以放心给到 2000配合早停让模型自己决定什么时候停。import xgboost as xgb xgb_model xgb.XGBClassifier( max_depth5, learning_rate0.03, subsample0.8, colsample_bytree0.8, n_estimators2000, tree_methodhist, random_state42 ) xgb_model.fit( X_train, train[bad], eval_set[(X_valid, valid[bad])], eval_metricauc, early_stopping_rounds50, verboseFalse )early_stopping_rounds50的含义是验证集 AUC 连续 50 轮没有提升就停止训练返回最优迭代轮次的模型。learning_rate0.03偏小需要更多的树但每棵树对噪声的敏感度更低。subsample0.8表示每棵树随机用 80% 的样本colsample_bytree0.8同理这两个参数是 XGBoost 抗过拟合的主力。tree_methodhist在大数据集上训练速度提升明显对结果几乎没有影响。三个模型的评估指标我习惯看三个AUC排序能力、KS区分度、Top 20% 违约召回率业务价值。在这份数据上一轮典型结果大致如下模型AUCKSTop 20% 风险客户召回率训练耗时逻辑回归0.8120.480.3115 秒随机森林0.8430.550.382 分钟XGBoost0.8610.610.425 分钟这个量级说明三件事逻辑回归在这个数据上已经能用随机森林比逻辑回归提升约 3 个点 AUCXGBoost 又比随机森林提升约 2 个点。后面业务落地时XGBoost 的优势主要体现在高风险客户召回上但如果你要的是可解释性和监管友好逻辑回归依然是最稳妥的选择。5. 类别不平衡与阈值选择违约率 20% 场景下的四个避坑点LendingClub 数据里坏客户占比大约在 20% 左右这个比例不算极端不平衡但足够让很多新手在阈值上翻车。我见过最典型的场景模型 AUC 0.87看着很漂亮一查混淆矩阵违约客户只抓出来 7%。原因是默认阈值 0.5 在 20% 违约率的数据上太高了模型输出概率普遍在 0.2 上下阈值 0.5 等于把所有人都判成好客户。这一章专门讲清楚不平衡怎么处理、阈值怎么定以及我在这个环节踩过的四个坑。5.1 现象AUC 很高但业务上没有多赚到钱先看一个典型现象。模型 AUC 0.86KS 0.58但把预测概率大于 0.5 的样本标为违约最后发现只筛选出全部违约客户的 12%剩下 88% 的坏客户全部漏掉了。原因很简单数据里违约率只有 20%模型输出的概率集中在 0.1 到 0.35 之间0.5 这个阈值在分布里根本不存在多少样本。AUC 衡量的是排序能力和阈值无关而业务关心的是「你拦下来的这批人里有多少坏客户」直接看阈值下的精确率和召回率。5.2 原因默认阈值 0.5 和 20% 违约率错配在医学影像、异常检测这类正样本占比低于 5% 的场景阈值 0.5 还有一定意义但信贷数据的违约率是 20% 左右模型输出的概率被训练目标压向先验分布附近大部分预测值都在 0.15 到 0.3 之间。这时候用 0.5 做判断标准等于把绝大多数样本都推给了「好客户」阵营。正确做法是用验证集上的精确率—召回率曲线来找阈值而不是拍脑袋定 0.5。from sklearn.metrics import precision_recall_curve import numpy as np proba xgb_model.predict_proba(X_valid)[:, 1] precisions, recalls, thresholds precision_recall_curve(valid[bad], proba) # 找到 F1 最大的阈值 f1s 2 * precisions[:-1] * recalls[:-1] / (precisions[:-1] recalls[:-1] 1e-9) best_th thresholds[np.argmax(f1s)] print(fbest threshold: {best_th:.3f}, max F1: {f1s.max():.3f})这段代码先通过precision_recall_curve得到每个阈值下的精确率和召回率再计算 F1 分数选择 F1 最大的阈值。在这份数据上最优阈值通常在 0.25 到 0.35 之间比默认的 0.5 低很多。注意precisions和recalls数组比thresholds多一个元素所以计算 F1 时要去掉最后一个值否则数组长度不匹配。5.3 处理class_weight、采样策略和概率校准一起上处理不平衡有三种常见做法改模型内部权重、改样本分布、改决策阈值。我的习惯是三个一起用。class_weightbalanced或 XGBoost 的scale_pos_weight让模型更关注少数类SMOTE 过采样增加坏样本的多样性最后用 PR 曲线重选阈值。采样一定只作用于训练集验证集和测试集保持原始分布否则评估结果就是假的。参数上scale_pos_weight一般取负样本数除以正样本数LendingClub 数据里大约是 4 左右。设大了模型会过度激进把大量好客户误杀设小了又回到漏掉坏客户的老路。我会在 2 到 8 之间做一轮网格搜索观察验证集的 F1 和 Top 20% 召回率而不是只看 AUC。概率校准用sklearn.calibration.CalibratedClassifierCV在验证集上做 Platt Scaling可以修正模型输出的概率偏移让阈值选择更有把握。5.4 四个踩坑记录现象、原因、解决办法第一条随机森林训练集 AUC 0.99验证集只有 0.83明显过拟合。原因是max_depth不设限、min_samples_leaf设成了 1树直接把个别样本的特征值记住了。解决办法是把min_samples_leaf提到 50限制max_depth6验证集 AUC 回升到 0.84训练集降到 0.87两者的差距收敛到合理范围。第二条SMOTE 过采样后逻辑回归的系数正负号反了fico对违约概率变成正贡献明显违背业务常识。原因是过采样生成样本放大了重叠区域的噪声而逻辑回归没做标准化L2 惩罚对不同量纲特征的约束力度也不一样。解决办法是先标准化再做 SMOTE同时把 C 从 1 降到 0.1系数方向恢复正常验证集表现还略有提升。第三条mths_since_last_delinq用中位数填充后模型 KS 掉了 6 个点。原因是这个字段的缺失代表「近期没有逾期」中位数填充把这个信息抹掉了模型把「从未逾期」和「逾期了很久」混成同一类。解决办法是把缺失值填 0并新增had_delinq标志位模型能明确区分这两类客户。第四条随机切分训练测试集时测试集 AUC 0.87按时间切分后掉到 0.82。原因是随机切分让训练集包含了未来月份的数据模型实际上是拿着 2017 年的信息去预测 2013 年的贷款测试结果虚高。解决办法就是第 2 章写的严格按issue_d做时间切分虽然在指标上「变差了」但这才接近真实上线效果。6. 从概率到业务落地把模型输出映射成 0—100 评分卡模型输出的概率对业务人员不友好风控更习惯看分数。把 XGBoost 的预测概率映射成 0—100 的整数分每十分一档再和利率定价联动这才是完整闭环。这一步不复杂但映射参数直接决定分数含义要固定下来。6.1 标准分映射公式评分卡映射用的是对数几率log odds。设基准分数为 600 分对应好客户与坏客户比值为 20:1PDOodds 翻倍时分数增加量取 50。公式是分数等于基准分加上系数乘以对数几率差值其中系数等于 PDO 除以 2 的自然对数约等于 72.13。违约概率越高odds 越低分数越低。PDO 50 # odds 翻倍时分数增加 50 分 base_score 600 # 基准分数 base_odds 20 # 基准 odds好客户:坏客户 20:1 factor PDO / np.log(2) def proba_to_score(p): odds p / (1 - p) return base_score factor * np.log(odds / base_odds) test[score] test[pred_proba].apply(proba_to_score) test[score] test[score].clip(0, 100).round().astype(int)映射之后分数量纲和原始概率不同了但排序完全一致模型效果不受影响。实际业务里我会把base_score设为 600 是为了兼容常见评分卡口径如果你只要 0—100 的简化版本可以把基准分改成 50、PDO 改为 10公式逻辑不变。分数越低风险越高。6.2 分数分档与利率定价联动整数分出来后按每十分一档做分档统计看每档的真实违约率。LendingClub 原数据里int_rate是平台给定的利率我们可以反过来用模型分数做建议定价调整低分档提高利率或直接拒绝中分档维持基准利率高分档适当降价吸引优质客户。一档典型结果可能长这样分数区间该档违约率定价建议80—100约 4%基准利率下浮 100bp70—80约 9%维持基准利率60—70约 18%利率上浮 150bp0—60约 35%人工复核或拒贷分档阈值不是拍脑袋而是根据验证集里每档的违约率画的核心原则是违约率翻倍的地方要拉开分数档边界。从那以后我每次拿到信贷数据第一件事不是建模而是先看三样东西loan_status的分类是否完整、贷款是否已经走完、缺失值是「没有」还是「未知」。这三样只要错一个后面所有调参都是白费。希望帮到你。本文还有配套的精品资源点击获取