简介:基于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_memory=False) print(df.shape) print(df.columns.tolist()[:20])用zipfile.ZipFile按文件名直接读取,省去先解压再pd.read_csv两步操作,也避免压缩包解出来占用双份磁盘空间。low_memory=False这个参数很关键: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(normalize=True))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_bins=10, encode="ordinal", strategy="quantile") df[col + "_bin"] = kb.fit_transform(data).astype(int)strategy="quantile"表示按分位数切分,每个箱子里样本数大致相等,对长尾分布最友好;encode="ordinal"输出 0~9 的整数编码,之后做 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_first=True) y = model_df["default_flag"] X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42 ) lr_pipe = Pipeline([ ("scaler", StandardScaler()), ("lr", LogisticRegression(class_weight="balanced", max_iter=1000)) ]) lr_pipe.fit(X_train, y_train)class_weight="balanced"是给少数类(违约样本)更高的权重,让模型不至于把所有样本都预测成"不违约"。注意逻辑回归对特征尺度敏感,所以先StandardScaler标准化。这里用了train_test_split随机切分,作为基线没问题,但你要知道真实上线时更合理的做法是时间切分,第 4.3 节会专门讲。
4.2 用 LightGBM 提分:默认参数跑通后只调三个参数
逻辑回归的 AUC 通常在 0.65~0.72 之间,LightGBM 能把它推到 0.72~0.78。但树模型是黑匣子,所以我的习惯是逻辑回归保底、LightGBM 提分,两个模型都训练,上线时看验证集表现择优。LightGBM 的核心调参其实只有三个:树的数量(靠early_stopping决定)、叶子节点数、特征采样比例。
import lightgbm as lgb from sklearn.metrics import roc_auc_score train_data = lgb.Dataset(X_train, label=y_train) val_data = lgb.Dataset(X_val, label=y_val, reference=train_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_round=1000, valid_sets=[val_data], callbacks=[lgb.early_stopping(stopping_rounds=100)] ) val_pred = model.predict(X_val, num_iteration=model.best_iteration) print("LightGBM AUC:", roc_auc_score(y_val, val_pred))num_leaves是 LightGBM 里最重要的复杂度参数,31 是默认值,如果你的数据量在 10 万行以下,建议降到 15~20 防止过拟合;feature_fraction类似随机森林的特征子采样,是 GBDT 里最有效的防过拟合手段;learning_rate从 0.05 起步,配合early_stopping让模型自己决定什么时候停,不要在调参阶段把num_boost_round写死。
4.3 时间序列视角的数据切分:模拟上线后的真实表现
这是最容易翻车的一步。随机切分假设样本独立同分布,但信贷数据是按放款时间顺序产生的,宏观环境、客群结构、审批政策都在变。如果你随机切分,验证集里可能包含与训练集同一时期的样本,模型等于"开卷考试",验证集 AUC 虚高 0.03~0.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, interpolation="nearest") 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 虽然对不平衡相对鲁棒,但它衡量的是排序能力,不能直接告诉你"阈值定在多少能平衡通过率与坏账率"。
解决:用 KS(Kolmogorov-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_iteration=model.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 eocd(eocd 是 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 从违约概率到最终分值:一个可以直接复用的映射公式
模型输出的概率不是一个可直接审批的数字,业务部门要的是 300~900 分之间、分数越低风险越高的评分。常见做法是把概率转换成 odds(违约与不违约的比值),再做线性映射:
import numpy as np def prob_to_score(prob, base_score=600, pdo=50, base_odds=50): """ 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_score=600表示"好坏比 = 50"的申请人得 600 分;pdo=50是评分卡行业常用参数,表示分数每高 50 分,好客户与坏客户之比翻倍。阈值不用拍脑袋,看两个数:在预期通过率(比如 60%)下,这批申请人的违约率是多少,能不能被风险偏好接受。一般我会把 600~680 分之间的申请单拉出来人工复核,分数映射之后模型交付才算完整。
6.2 验证建议:回测时把时间轴拉长
上线前我最相信的验证方式是滚动回测:以放款月份为窗口,每 3 个月训练一次,预测后 3 个月放款客户的风险,把各期 KS 画成一条曲线。如果曲线后期明显下滑,说明客群在漂移,光调模型参数没用,需要重新做特征工程。我第一次做 LendingClub 项目时就是随机切分骗了自己,模型上线一个月就露怯,后来时间切分和滚动回测成为固定动作,模型在验证集上的 KS 掉了,但上线后的真实稳定性反而好了。这一节的技巧如果你能直接复用到自己的信贷建模流程里,能省下大量返工时间,希望帮到你。
本文还有配套的精品资源,点击获取