简介基于LendingClub公开信贷数据构建的智能违约预测模型项目面向金融风控、数据分析与机器学习初学者及从业人员以借款人信用评分、年收入、就业年限、负债等字段为基础演示从数据清洗、特征工程到模型训练评估的完整建模流程。压缩包共5个文件包含可直接运行的Jupyter Notebook、Python脚本、说明文件、Markdown笔记与Word附赠资源整体仅47KB轻量易用便于快速复现实验。压缩包内配套文档解释了逻辑回归、随机森林、梯度提升机等算法的应用思路及准确率、F1分数等评估指标能帮助读者理解信贷违约预测的关键环节与风险模型落地方式。该资源已有59人学习适合希望上手金融风控建模、完成课堂项目或积累信贷场景实战经验的学习者。1. 基于LendingClub信贷数据构建智能违约预测模型这个项目解决什么问题、适合谁在消费金融的风控场景里违约预测的核心诉求只有一个放款之前能否用借款人已有的信息把未来的逾期概率估出来。这个项目的答案是——用LendingClub平台公开的历史信贷数据把借款人信用评分、贷款金额、利率、就业年限、年收入、负债比这几类字段喂给模型训练一个智能违约预测模型最终交付成一份可复用的工程包zip。它解决的是有数据、没套路的问题数据是公开的且字段足够丰富但从原始CSV到能上线用的违约预测模型中间隔着清洗、特征工程、不平衡处理和评估口径四道坎。这篇文章适合刚入行风控的数据分析师、想拿真实数据集做算法项目的在校生以及准备把传统评分卡升级成机器学习模型但还没动手的从业者。我会按一个完整项目的推进顺序讲先吃透数据再处理特征然后建模调参最后把模型落成可交付的成果。2. 吃透LendingClub数据字段语义、目标变量与数据装载2.1 公开数据从哪来zip包解压与数据装载LendingClub 历年会在官网公开贷款绩效数据按年份打包成 CSV 压缩包Kaggle 上也有整理好的历史副本。项目标题里带.zip说明交付物和原始数据都以 zip 为容器。我一般会在项目根目录建data/和src/两个目录数据包放data/下所有处理脚本放src/避免后期反复改路径。# 解压 LC 数据包并快速读取不把文件解到磁盘 import zipfile import pandas as pd zf zipfile.ZipFile(data/lendingclub_data.zip) print(zf.namelist()) # 常见输出 [LoanStats_2019.csv, LoanStats_2020.csv, README.txt] with zf.open(LoanStats_2019.csv) as f: df pd.read_csv(f, low_memoryFalse) print(df.shape) print(df.columns.tolist()[:20])用zipfile.ZipFile按文件名直接读取省去先解压再pd.read_csv两步操作也避免压缩包解出来占用双份磁盘空间。low_memoryFalse这个参数很关键LendingClub 的单年 CSV 有几十万行、上百列如果默认按块推断类型结果可能会出现某列前半部分是数值、后半部分是字符串的类型断裂。直接关闭内存优化让 pandas 一次性读完再推断类型更稳定。这个环节最常见的坑是 zip 文件本身损坏。解压时报invalid zip archive: could not find eocd的情况我遇到的几乎全是下载被截断或者压缩时用了不规范的打包方式。处理方法放在第 5 章的避坑清单里。2.2 关键字段逐一拆解从原始列到可用特征标题里点名的字段——信用评分、贷款金额、利率、就业年限、年收入、负债——正好是 LendingClub 数据集中最具建模价值的几类。我把它们整理成一张字段表建模时会围绕这几个字段做衍生和分箱。字段名含义建模用途fico_range_low/fico_range_high借款人的 FICO 信用评分区间核心强特征分箱后入模且必须是放款时点已存在的分数loan_amnt申请贷款金额需要结合年收入做归一化绝对值本身意义有限int_rate贷款利率年化利率包含平台对风险的定价与违约概率有非线性关系emp_length就业年限缺失率高不能简单填均值需要单独编码annual_inc借款人年收入右偏严重通常取对数与贷款金额构造收入杠杆比dti负债收入比月负债 / 月收入经典风控特征对违约识别贡献稳定除了表里这几个LendingClub 数据还包括住房所有权、贷款用途、最近两年逾期次数、信用查询次数等字段。后面特征工程会用到一部分但要注意像最近两年逾期次数这种字段必须确认统计窗口是放款之前还是放款之后否则就是在拿未来数据预测过去属于典型的时间泄漏。2.3 目标变量构造把 loan_status 变成 0/1数据读进来之后第一件事不是做特征而是构造标签。LendingClub 的loan_status字段有多个取值Fully Paid、Charged Off、Current、In Grace Period、Late (31-120 days)等。建模只保留两类状态——已结清和已核销即实际违约中间的在贷、宽限期、逾期中都排除因为它们的终态还没定硬编码成 0 或 1 都会污染标签。# 目标变量构造只保留终态样本 status_map {Fully Paid: 0, Charged Off: 1} df df[df[loan_status].isin(status_map)] df[default_flag] df[loan_status].map(status_map) # 违约分布 print(df[default_flag].value_counts(normalizeTrue))Charged Off是 LendingClub 核销坏账的标记代表借款人逾期超 150 天且平台认定无法收回把它视为违约是行业标准做法。运行后你会看到违约占比通常在 15%25% 之间这个比例决定了后续模型评估不能只看准确率——后面第 4 章会细讲。这一章做完你已经拿到了一个行数约 10 万到 50 万取决于你把几个年份的数据拼接在一起、带干净二分类标签的建模表格。接下来进入特征工程。3. 特征工程与数据清洗把信贷字段变成模型能吃的样子3.1 缺失值处理就业年限的高缺失不能乱填emp_length在 LendingClub 原始数据里是字符串比如10 years、5 years、 1 year还有一部分直接是n/a。很多新手习惯把所有缺失值统一fillna(0)这在风控场景里会带来一个隐蔽问题缺失可能意味着没有稳定就业或不愿披露它本身有信息量当成 0 反而把两类人混在一起。import numpy as np import pandas as pd def clean_emp_length(v): 把 10 years 转成数值 10缺失返回 NaN if pd.isna(v): return np.nan s str(v).strip().lower() if s n/a or s unknown: return np.nan if s.startswith(): return 0 return int(s.split()[0].replace(, )) df[emp_year] df[emp_length].apply(clean_emp_length) df[emp_year_missing] df[emp_year].isna().astype(int) df[emp_year] df[emp_year].fillna(-1)这里的关键设计是把是否缺失单独做成一个二值特征emp_year_missing然后把数值本身填成 -1。模型可以同时学到这个人就业年限未知和这个人就业年限是 3 年两类信息比用均值填充更稳。annual_inc的缺失率相对低我一般用中位数填充因为年收入右偏严重均值会被高收入样本拉高。3.2 数值特征想起业务利率和信用评分为什么要分箱信用评分和利率这两个字段直接扔进模型不是不行但效果通常不如分箱后好。原因有两个第一FICO 分数和违约概率的关系不是直线比如 680 分以上每高 10 分风险下降幅度和 620 分以下完全不同线性模型拟合不了这种分段关系第二LendingClub 的利率档位本身就是离散的同一个利率档位内的借款人风险差异有限分箱可以起到平滑作用。from sklearn.preprocessing import KBinsDiscretizer # 对利率和信用评分做分位数分箱让非线性关系更稳 resources { int_rate: df[[int_rate]], credit_score: df[[fico_range_low]] } for col, data in resources.items(): kb KBinsDiscretizer(n_bins10, encodeordinal, strategyquantile) df[col _bin] kb.fit_transform(data).astype(int)strategyquantile表示按分位数切分每个箱子里样本数大致相等对长尾分布最友好encodeordinal输出 09 的整数编码之后做 WOE 编码或直接给树模型都能用。注意我在这里用的是fico_range_low也就是 FICO 分数区间的下限这是放款时点就已经确定的信息不存在泄漏。这个阶段还应构造两个业务衍生特征loan_amnt / annual_inc贷款金额占年收入比反映还款压力、annual_inc_log np.log1p(annual_inc)压缩右偏。dti本身就是比率直接入模即可。3.3 类别特征与高基数问题避免维度爆炸和新类别翻车LendingClub 的类别字段里有home_ownership、purpose、state借款人所在州等。住房所有权有 5 个取值直接 one-hot 没问题但州有 50 多个取值one-hot 会产生大量稀疏列而且新数据的州取值一旦不在训练集里程序会直接报错。我通常的做法是低频合并 固定白名单。# 低频类别合并为 OTHER预测时用同一套映射 cat_cols [home_ownership, purpose, state] for c in cat_cols: df[c] df[c].fillna(MISSING) top_vals df[c].value_counts().index[:20] df[c] df[c].where(df[c].isin(top_vals), OTHER) # 转成 category 类型节省内存 for c in cat_cols: df[c] df[c].astype(category)代码里top_vals取频次前 20 的取值剩下的全合并成OTHER这样训练集和预测集的取值空间被锁死在 21 个档位里不会因为新数据出现新州名而崩。这一步做完把default_flag、所有特征列挑出来组成建模表model_df存成 parquet 或 CSV 备份。到这里数据侧的工作基本完成。4. 违约预测模型的选型与训练从白盒基线到提分4.1 为什么先做逻辑回归风控场景要的是能解释的模型很多教程一上来就 LightGBM但在信贷场景里逻辑回归依然是绕不开的基线。原因不是它精度最高而是监管和审计要求模型可解释审批拒绝客户时要能说清楚因为你的负债比过高、信用评分低于阈值所以被拒绝。逻辑回归的系数可以解释成每个特征对违约概率的贡献方向这是黑盒模型做不到的。from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split feature_cols [ int_rate, loan_amnt, annual_inc, dti, emp_year, emp_year_missing, credit_score_bin, home_ownership, purpose ] X pd.get_dummies(model_df[feature_cols], drop_firstTrue) y model_df[default_flag] X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42 ) lr_pipe Pipeline([ (scaler, StandardScaler()), (lr, LogisticRegression(class_weightbalanced, max_iter1000)) ]) lr_pipe.fit(X_train, y_train)class_weightbalanced是给少数类违约样本更高的权重让模型不至于把所有样本都预测成不违约。注意逻辑回归对特征尺度敏感所以先StandardScaler标准化。这里用了train_test_split随机切分作为基线没问题但你要知道真实上线时更合理的做法是时间切分第 4.3 节会专门讲。4.2 用 LightGBM 提分默认参数跑通后只调三个参数逻辑回归的 AUC 通常在 0.650.72 之间LightGBM 能把它推到 0.720.78。但树模型是黑匣子所以我的习惯是逻辑回归保底、LightGBM 提分两个模型都训练上线时看验证集表现择优。LightGBM 的核心调参其实只有三个树的数量靠early_stopping决定、叶子节点数、特征采样比例。import lightgbm as lgb from sklearn.metrics import roc_auc_score train_data lgb.Dataset(X_train, labely_train) val_data lgb.Dataset(X_val, labely_val, referencetrain_data) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, # 控制模型复杂度过小欠拟合、过大过拟合 feature_fraction: 0.8, # 每棵树随机采 80% 特征防止过拟合 bagging_fraction: 0.8, bagging_freq: 1, verbose: -1 } model lgb.train( params, train_data, num_boost_round1000, valid_sets[val_data], callbacks[lgb.early_stopping(stopping_rounds100)] ) val_pred model.predict(X_val, num_iterationmodel.best_iteration) print(LightGBM AUC:, roc_auc_score(y_val, val_pred))num_leaves是 LightGBM 里最重要的复杂度参数31 是默认值如果你的数据量在 10 万行以下建议降到 1520 防止过拟合feature_fraction类似随机森林的特征子采样是 GBDT 里最有效的防过拟合手段learning_rate从 0.05 起步配合early_stopping让模型自己决定什么时候停不要在调参阶段把num_boost_round写死。4.3 时间序列视角的数据切分模拟上线后的真实表现这是最容易翻车的一步。随机切分假设样本独立同分布但信贷数据是按放款时间顺序产生的宏观环境、客群结构、审批政策都在变。如果你随机切分验证集里可能包含与训练集同一时期的样本模型等于开卷考试验证集 AUC 虚高 0.030.05上线后立刻现原形。# 按放款月份切分训练集在时间上必须早于验证集 model_df[issue_month] pd.to_datetime(model_df[issue_d]).dt.to_period(M) cutoff_month model_df[issue_month].quantile(0.8, interpolationnearest) train_mask model_df[issue_month] cutoff_month test_mask model_df[issue_month] cutoff_month X_train, X_val X[train_mask], X[test_mask] y_train, y_val y[train_mask], y[test_mask]issue_d是放款日期按它排序后取前 80% 的月份做训练、后 20% 做验证。这也解释了为什么第 2 章要保留issue_d字段——它在建模时不吃特征但在切分时是唯一可信的时间依据。做评分卡项目时我还会再留最近 6 个月的数据做最终回测训练集只用到回测期之前。5. 模型评估与常见坑不平衡、时间泄漏与 zip 交付翻车5.1 看准确率就以为自己成功了不平衡样本下的评估陷阱现象违约样本占比只有 20%模型把所有样本都预测为不违约准确率直接到 80%。很多新手看到 80% 准确率觉得模型不错直到画出混淆矩阵才发现违约样本一个都没抓住。原因准确率在不平衡数据里是多数类覆盖率它奖励的是无脑预测多数类。AUC 虽然对不平衡相对鲁棒但它衡量的是排序能力不能直接告诉你阈值定在多少能平衡通过率与坏账率。解决用 KSKolmogorov-Smirnov和 PR-AUC 作为主评估指标KS 衡量好违约样本分布的最大分叉程度风控行业习惯用它衡量模型区分度。from sklearn.metrics import roc_curve def compute_ks(y_true, y_pred): fpr, tpr, _ roc_curve(y_true, y_pred) return max(tpr - fpr) val_pred_prob model.predict(X_val, num_iterationmodel.best_iteration) print(KS:, compute_ks(y_val, val_pred_prob))KS 大于 0.3 在信贷场景里就是可用的模型0.4 以上算优秀。如果 KS 只有 0.1 左右优先回去查特征别急着调参。5.2 用未来数据训练验证集 KS 虚高的元凶现象模型在验证集上的 KS 有 0.45上线两个月后实际表现只有 0.2。原因特征里混入了放款时点之后才产生的信息。典型的是 LendingClub 数据中的delinq_2yrs近两年逾期次数和mths_since_last_delinq最近一次逾期距今月数这类字段如果统计窗口包含了放款后的行为就属于用未来预测过去。随机切分会让这个问题更加隐蔽因为部分验证集样本和训练集重叠在同一时间段泄漏特征被当成真实规律学进模型。解决第一只保留放款前已知的字段拿不准的字段一律查数据字典确认统计窗口第二严格按第 4.3 节的时间切分来评估让验证集全部晚于训练集第三观察特征重要性排名如果mths_since_last_delinq排第一且重要性异常高直接怀疑泄漏并剔除。5.3 zip 交付包在别人机器上解压失败eocd 报错的血泪经验现象训练好的模型和预测结果打包成 zip 发给同事对方解压报错invalid zip archive: could not find eocdeocd 是 zip 格式的中央目录结尾标识。原因zip 包在传输过程中被截断或者打包时目标路径里有文件正被占用导致压缩不完整。还有一种是下载环节的问题——从网盘或邮箱下载 zip 时浏览器中途断点续传失败文件大小比源文件小几百字节。解决打包后用 md5 校验值锁定交付内容传输方式尽量走统一的文件服务器或对象存储避免邮件附件被网关处理时篡改文件头。# 打包并做完整性校验 zip -r lendingcloud_predict_model.zip src/ model/ output/ md5sum lendingcloud_predict_model.zip lendingcloud_predict_model.zip.md5 # 接收方校验推荐在目标机器上重新下载后执行 md5sum -c lendingcloud_predict_model.zip.md5出错时先看文件大小是否与源文件一致再检查是不是用zip -r打包的完整目录。这个经验放之四海皆准任何交付物只要不是纯文本都要有校验和兜底。5.4 类别特征预测时翻车测试集出现新城市现象训练好的模型在预测一批新申请样本时pd.get_dummies之后特征数量对不上或者 LightGBM 直接报未知类别错误。原因one-hot 编码是根据训练集的取值动态生成的测试集出现训练集没见过的取值时编码器无法映射。解决特征工程阶段就锁定取值白名单而不是让 pandas 动态生成。第 3.3 节的top_vals方案就是为了解决这个问题——所有取值在训练时被限制在 21 个档位内预测时先用同一套映射函数转换映射不到的字符统一填OTHER。我还会把特征列表和取值白名单一并存成 pickle放进交付包里保证训练、预测两端的特征空间严格一致。6. 把模型交付成可用的评分卡阈值选择与分数映射6.1 从违约概率到最终分值一个可以直接复用的映射公式模型输出的概率不是一个可直接审批的数字业务部门要的是 300900 分之间、分数越低风险越高的评分。常见做法是把概率转换成 odds违约与不违约的比值再做线性映射import numpy as np def prob_to_score(prob, base_score600, pdo50, base_odds50): pdo point to double odds每增加 50 分好坏比翻倍 base_odds 是基准点对应的好:坏比 评分越大风险越低 odds (1 - prob) / prob factor pdo / np.log(2) return base_score factor * np.log(odds / base_odds) df[score] prob_to_score(val_pred_prob) df[score].describe()base_score600表示好坏比 50的申请人得 600 分pdo50是评分卡行业常用参数表示分数每高 50 分好客户与坏客户之比翻倍。阈值不用拍脑袋看两个数在预期通过率比如 60%下这批申请人的违约率是多少能不能被风险偏好接受。一般我会把 600680 分之间的申请单拉出来人工复核分数映射之后模型交付才算完整。6.2 验证建议回测时把时间轴拉长上线前我最相信的验证方式是滚动回测以放款月份为窗口每 3 个月训练一次预测后 3 个月放款客户的风险把各期 KS 画成一条曲线。如果曲线后期明显下滑说明客群在漂移光调模型参数没用需要重新做特征工程。我第一次做 LendingClub 项目时就是随机切分骗了自己模型上线一个月就露怯后来时间切分和滚动回测成为固定动作模型在验证集上的 KS 掉了但上线后的真实稳定性反而好了。这一节的技巧如果你能直接复用到自己的信贷建模流程里能省下大量返工时间希望帮到你。本文还有配套的精品资源点击获取