
简介这套个人贷款违约预测算法项目包聚焦金融风控领域面向准备毕业设计、系统开发或希望上手机器学习建模的学习者提供从数据预处理、特征处理到模型训练、调优与部署的完整闭环。压缩包共11个文件整体约2.17MB其中5份docx覆盖项目背景、程序说明、结果分析、部署指南及阈值取值说明3份csv对应训练集、测试集与提交结果另有1份Python源码、1份README和1份gitignore文件目录结构清晰便于按模块查阅。目前已有69人学习下载。借助该套材料学习者可掌握逻辑回归、决策树等经典算法在个人贷款违约场景中的实际应用理解违约阈值如何设定以及模型效果的评估方法同时参考部署文件将模型封装为可执行程序或API接口收获从理论到落地的完整实战经验。所有数据已经过清洗处理适合作为教学示例或进一步研究的基础可合理用于学术研究和个人能力提升。1. 个人贷款违约预测算法从一份压缩包到能落地的风控方案做信贷业务的都懂一个道理坏账不是一夜出现的但等你从报表里读出苗头时那笔钱大概率已经收不回了。个人贷款违约预测算法要解决的就是在放款之前或贷后早期用客户的历史数据算出“这个人有多大概率还不上”。这份压缩包里给出的是一条完整链路python源码可以直接改说明文档交代了字段含义与建模思路部署文件让模型跑成接口供业务调用数据则用来复现和验证整套流程。适合三类人被监管要求“把模型讲清楚”的风控工程师、想把手头几千条客户数据做成评分卡的数据分析师、以及需要完整课程设计项目的学生。下面按数据、特征、模型、部署、排错的顺序把真正影响结果的部分拆开讲。2. 贷款违约预测的数据准备与特征工程先让数据能进模型2.1 从zip里的data目录说起常见字段与读取方式解压后第一步永远是打开数据目录看原始字段。贷款违约预测的数据一般围绕客户基本属性、负债情况、历史还款表现、近期待征信号四个维度组织。常见字段大致如下字段名类型说明常见脏数据cust_idstr客户编号重复、空值ageint年龄0 或超过 100incomefloat月收入缺失、负数debtfloat当前总负债缺失loan_amountfloat贷款金额0 值loan_termint期限月异常值overdue_count_histint历史逾期次数缺失query_count_90dint近 90 天征信查询次数缺失purposestr贷款用途编码不一致is_defaultint是否违约标签类别不均衡读取时建议固定用utf-8-sig编码而不是默认的utf-8。原因是 Windows 下 Excel 导出的 CSV 通常带 BOM 头默认编码会读出一个隐藏字符间接导致后续列名匹配失败。import pandas as pd train pd.read_csv(data/train.csv, encodingutf-8-sig) print(训练集:, train.shape) print(违约率:, train[is_default].mean()) print(train.head(3))先打印违约率是建模前的必要动作。个人贷款数据里违约样本占比通常在 5% 到 15% 之间少数极端场景会低于 3%。这个数字直接决定后面要不要做样本均衡、怎么设置模型权重。如果打印出来发现违约率 30% 以上先怀疑数据集是不是对坏客户做过采样而不是真实业务分布。2.2 数据清洗与特征衍生的常规做法贷款数据的脏不只是缺值和异常值更多是“看起来合理、实际上不可用”的值。比如收入为 0 但负债很高的客户可能是自由职业者没填收入也可能是数据录入错误。这类记录不能简单删掉要看业务逻辑。常见做法是收入小于 0 直接删除收入等于 0 但标签正常保留、用中位数填充。import numpy as np # 去除重复客户与明显脏数据 train train.drop_duplicates(subsetcust_id) train train[train[income] 0] train.loc[train[age] 18, age] np.nan # 缺失值填充 train[income] train[income].fillna(train[income].median()) train[debt] train[debt].fillna(0) train[overdue_count_hist] train[overdue_count_hist].fillna(0) # 特征衍生 train[debt_ratio] train[debt] / train[income] train[loan_income_ratio] train[loan_amount] / train[income] train[query_ratio] train[query_count_90d] / 90debt_ratio和loan_income_ratio看起来都跟收入相关但业务含义不同前者看存量负债压力后者看本次贷款相对收入的体量两个变量在信用评估里缺一不可。query_ratio把 90 天查询次数折算成日均查询频率相比原始计数更平滑能削弱“查询次数分布右偏”带来的影响。这三个衍生特征几乎是信贷评分卡的标配。注意填充策略的选择。income用中位数而不是均值是因为收入分布天然右偏少数超高收入客户会把均值拉高中位数更能代表普通客户水平。debt填充 0 是业务上的保守处理缺失往往意味着“没有上报负债”当成无负债比当成平均水平更符合实际。树模型其实不要求特征标准化但如果后续要对比逻辑回归或者输出评分卡统一做一次标准化能省掉很多返工。这里对训练集做fit_transform测试集只做transform避免信息泄漏。from sklearn.preprocessing import StandardScaler scaler StandardScaler() feat_cols [income, debt, debt_ratio, loan_income_ratio, query_ratio, age] train[feat_cols] scaler.fit_transform(train[feat_cols])2.3 样本不均衡的三种处理SMOTE、欠采样与权重调整违约率 5% 的数据集直接丢进模型模型会学会“全部预测不违约”因为这样准确率也有 95%。评估指标必须换数据也要处理。常见做法有三种SMOTE 过采样少数类、随机欠采样多数类、直接在模型里设置类别权重。三种都能提升 AUC但适用场景完全不同。SMOTE 适合数据量在几十万以内、正样本绝对数量不多的场景。欠采样会丢掉大量好客户信息适合数据量极大、训练时间有限的情况。类别权重最省事不改变样本量后续部署时也不用额外处理。个人建议优先尝试权重方案数据量小或追求极致效果时再上 SMOTE。from imblearn.over_sampling import SMOTE from sklearn.model_selection import train_test_split X train[feat_cols] y train[is_default] # 先切分再只对训练集过采样 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) smote SMOTE(random_state42) X_train_res, y_train_res smote.fit_resample(X_train, y_train) print(过采样后正负样本量:, y_train_res.value_counts().to_dict())关键点在于“先切分再过采样”这个顺序错一步结果就废了。如果对全量数据先做 SMOTE 再切分合成样本会同时出现在训练集和测试集模型等于提前看到了验证集的一部分答案验证 AUC 虚高到没有参考价值。另一个容易忽略的点过采样后正样本和负样本各占一半此时训练集分布已经不是真实业务分布训练出来的概率值整体偏高后面部署时需要用阈值重新校准不能直接沿用 0.5。3. 违约预测模型怎么选从逻辑回归到LightGBM的取舍3.1 为什么基准模型必须是逻辑回归贷款场景里模型不只是“准不准”的问题还要回答“为什么”。客户被拒的时候业务方要能解释原因监管检查时也要说得清每个变量的系数方向。在所有机器学习算法里只有逻辑回归能直接给出每个特征的权重系数和方向这意味着它可以同时当模型和报表工具用。常见做法是先用逻辑回归跑通全流程清洗、衍生、评估、部署确认链路没毛病之后再换更强的树模型去提升效果。这样做还有个好处逻辑回归的系数本身就是一份特征重要性报告能提前发现“收入越高违约率越高”这类违反直觉的信号这种信号通常意味着数据有脏值或特征泄漏。模型可解释性非线性训练速度适合场景逻辑回归高弱快评分卡、强监管场景随机森林中强中特征较多、调参时间少XGBoost低强中有调参经验的团队LightGBM低强快大样本、特征多实践中 LightGBM 是这一类项目里最常用的主力模型。相比 XGBoost它的直方图算法训练速度快在几十万量级的信贷数据上优势明显并且对缺失值有原生处理能容忍前面清洗环节的少量遗漏。3.2 LightGBM的核心参数与训练流程承接上一步如果数据量在十万以上SMOTE 会让训练时间成倍增加此时可以跳过过采样直接在参数里调scale_pos_weight。这个参数等于负样本数除以正样本数作用是给少数类更高的惩罚权重让模型更关注违约样本。import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: 7, min_child_samples: 50, subsample: 0.8, subsample_freq: 1, colsample_bytree: 0.8, scale_pos_weight: 20, random_state: 42, n_jobs: -1, verbosity: -1, } skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) auc_list [] for train_idx, valid_idx in skf.split(X_train_res, y_train_res): dtr lgb.Dataset(X_train_res.iloc[train_idx], labely_train_res.iloc[train_idx]) dva lgb.Dataset(X_train_res.iloc[valid_idx], labely_train_res.iloc[valid_idx]) clf lgb.train( params, dtr, num_boost_round1000, valid_sets[dva], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] ) pred clf.predict(X_train_res.iloc[valid_idx], num_iterationclf.best_iteration) auc_list.append(roc_auc_score(y_train_res.iloc[valid_idx], pred)) print(5折CV AUC:, np.mean(auc_list))逐项说参数选择的逻辑。num_leaves和max_depth控制树复杂度信贷数据本身非线性程度有限叶子数超过 32 后基本在拟合噪声测试集 AUC 反而下降。min_child_samples50限制叶子节点最少样本数防止某个极端样本单独形成一条规则这在样本不均衡的贷款数据上尤其重要。subsample和colsample_bytree都是 0.8行采样和列采样同时下降可以明显缓解过拟合代价是训练轮数略微增加。scale_pos_weight的值建议按实际负正比设置不要拍脑袋填 20。先跑一句print((y_train 0).sum() / (y_train 1).sum())把得到的比值填进去。填太大会让模型把大量好客户误判成坏客户直接压低通过率。3.3 评估指标要盯住KS与混淆矩阵模型训练完先看 AUC但 AUC 只是起点。AUC 衡量的是模型把坏客户排在好客户前面的能力不关心阈值设在哪。业务上真正关心的是如果我把违约概率大于某个阈值的客户全部拒绝能拦下多少坏客户、误伤多少好客户。from sklearn.metrics import roc_auc_score, confusion_matrix y_prob clf.predict(X_test, num_iterationclf.best_iteration) print(测试集AUC:, roc_auc_score(y_test, y_prob)) y_pred (y_prob 0.5).astype(int) tn, fp, fn, tp confusion_matrix(y_test, y_pred).ravel() print(fTP{tp} FN{fn} FP{fp} TN{tn})如果直接拿 0.5 当阈值大概率会得到一份很难看的混淆矩阵FN 很高说明大部分坏客户被放过去了。这是因为正样本占比本身就低模型输出的概率整体偏低0.5 对贷款违约场景太苛刻。实际操作时一般会画一条阈值-通过率曲线找一个“通过率 80% 时拦截坏账 60%”之类的平衡点。这个找阈值的过程没有标准答案取决于业务能接受多少坏账换多少客源。4. 部署文件怎么用模型持久化、Flask接口与Docker打包4.1 模型持久化pickle与joblib的差异训练完成后clf还在内存里关掉 Jupyter 就没了。部署的第一步是把模型和 scaler 落盘。常见做法是把训练时拟合过的所有对象统一放进deploy/目录与源码区分开。import pickle with open(deploy/model.pkl, wb) as f: pickle.dump(clf, f) with open(deploy/scaler.pkl, wb) as f: pickle.dump(scaler, f)pickle是 Python 标准库通用性最好joblib对 numpy 数组的序列化效率更高大模型几百 MB场景下更快。两者在新旧 Python 版本之间都可能出现不兼容换环境后要重新验证一次能否正常 load。这里有个容易踩的坑lgb.train返回的是 Booster 对象而不是 sklearn 接口的模型保存和加载方式与普通模型没区别但预测时要用num_iterationclf.best_iteration指定迭代次数否则默认用最后一轮的模型效果会差一截。4.2 一个能用的Flask预测接口部署文件里的 app.py 通常就是一个轻量 Flask 服务接收业务系统传来的客户特征 JSON返回违约概率。下面这个接口按照第 2 章的特征处理逻辑原样复刻顺序不能乱。import pickle import pandas as pd from flask import Flask, request, jsonify app Flask(__name__) with open(deploy/model.pkl, rb) as f: clf pickle.load(f) with open(deploy/scaler.pkl, rb) as f: scaler pickle.load(f) FEAT_COLS [income, debt, debt_ratio, loan_income_ratio, query_ratio, age] app.route(/predict, methods[POST]) def predict(): data request.get_json() df pd.DataFrame([data]) # 与训练阶段完全一致的特征衍生 df[debt_ratio] df[debt] / df[income] df[loan_income_ratio] df[loan_amount] / df[income] df[query_ratio] df[query_count_90d] / 90 # 标准化只transform不fit df[[income, debt, debt_ratio, loan_income_ratio, query_ratio]] \ scaler.transform(df[[income, debt, debt_ratio, loan_income_ratio, query_ratio]]) prob clf.predict(df[FEAT_COLS], num_iterationclf.best_iteration)[0] return jsonify({default_probability: prob}) if __name__ __main__: app.run(host0.0.0.0, port8000)接口里最容易出错的是scaler.transform。训练阶段用的fit_transform会计算训练集的均值和标准差部署时单条请求进来只能用保存好的参数做转换一旦对单条数据重新 fit数值分布就完全错位预测概率整体漂移。接口写好后先用 curl 验证一遍确认入参格式和返回结构再交给业务方联调。curl -X POST http://127.0.0.1:8000/predict \ -H Content-Type: application/json \ -d {income:8000,debt:20000,loan_amount:50000,query_count_90d:5,age:32}4.3 Dockerfile与requirements.txt部署文件里一般会包含 Dockerfile 和 requirements.txt。说明文档里的“环境准备”章节通常也会要求先核对 python 版本再装依赖。这里给出一个最小可用的镜像配置路径作用deploy/model.pkl训练好的 LightGBM Boosterdeploy/scaler.pkl训练时拟合的标准化器app.pyFlask 预测接口requirements.txtPython 依赖清单Dockerfile镜像构建配置FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . COPY deploy/ ./deploy/ EXPOSE 8000 CMD [python, app.py]镜像选择python:3.11-slim而不是完整版体积能小三分之一构建和拉取都快。先复制 requirements.txt 再装依赖是为了利用 Docker 缓存层只要依赖没变后续修改代码重建镜像时不需要重新下载安装包。requirements.txt 建议直接pip freeze requirements.txt生成里面每行锁定到具体版本比如lightgbm4.3.0而不是只写包名不写版本。本地环境升级过 numpy 之后模型加载报错这类问题几乎都来自依赖版本漂移。构建完在本地先启动容器确认模型文件加载成功再交付。用 docker desktop 管理容器时可以直接查看容器日志和文件但生产环境不建议在运行中的容器里临时改代码任何改动都要回到镜像重新构建否则容器一删就全没了。5. 个人贷款违约预测项目的5个常见问题与排查路径5.1 特征口径不一致线下AUC很高线上完全失效现象训练集 AUC 0.84测试集也正常但上线第一个月通过率异常拒绝名单里的坏账率反而上升。原因训练和部署时特征处理逻辑不一致。训练时代码里可能用了fillna(train[income].median())填充缺失值这个中位数是训练集全局统计量部署时单条请求进来要么没有填充逻辑要么用当前批次数据重新算了一个中位数。另一种常见问题是标准化时对全量数据做了fit_transform部署时又对单条数据重新做了一次fit_transform等于模型拿到的是另一套分布下的特征。解决把缺失值填充、特征衍生、标准化封装成同一个函数训练和部署共用一份代码scaler 只 fit 一次然后 pickle 保存部署端只调transform。验证方法很直接拿训练集前 100 条数据走一遍部署接口对比本地预测结果偏差应该在1e-6以内超过这个量级说明管线有分歧。5.2 时间泄漏用未来数据训练模型现象模型在验证集上 AUC 0.95看起来完美实际策略上线后完全不 work。原因训练/测试切分用了随机切分而不是时间切分。贷款违约预测的数据天然带时间顺序随机切分会让测试集里混入“晚于训练集某些样本”的数据模型相当于偷看了未来。最常见的一个泄漏源是标签本身如果某条样本的放款时间是 2023 年 1 月但特征里包含了 2023 年 6 月才产生的逾期记录模型学到的是“已经逾期所以预测他会违约”这种循环逻辑。解决按申请时间或放款时间排序取前 80% 做训练、后 20% 做验证。所有特征只能用准入时点之前已经产生的数据。跨年数据还要警惕宏观环境变化早年的模型放到经济下行期会整体失效建议定期重新训练。5.3 SMOTE污染测试集现象AUC 高达 0.97上线后 KS 跌到 0.2 以下。原因对全量数据先过采样再切分合成样本同时存在于训练集和测试集。模型在训练时见过这批合成数据的“兄弟样本”验证时等于做了开卷考试。解决严格先train_test_split再fit_resample测试集永远保持原始分布。补充一点测试集上用混淆矩阵计算业务指标时要用原始测试集而不是过采样后的版本否则 TP、FP 的数量关系完全失真。5.4 requirements.txt依赖版本冲突现象本地能跑通docker build 之后 Flask 正常启动但 predict 接口报 500或者import lightgbm直接 segmentation fault。原因requirements.txt 里写了sklearn而不是scikit-learnpip 装完发现模型加载时类对不上或者没锁版本构建时拉到了新版 numpyLightGBM 编译时绑定的旧版接口已经不存在。解决用pip freeze requirements.txt生成锁定文件把与业务无关的本地开发包手动删掉构建后进容器执行python -c import lightgbm, sklearn, flask验证导入正常。这一步花两分钟能省掉后面联调时一整天的问题排查。5.5 CSV读取乱码与类型推断失败现象pd.read_csv(data/train.csv)直接报 ParserError或者读出来中文全是乱码income列的类型打印出来是 object。原因数据文件是 GBK 编码pandas 默认按 UTF-8 解析另一类问题是 CSV 里某些单元格有空字符串pandas 把整列推断成 object 类型后续数值运算全部失败。解决读取时统一encodingutf-8-sig如果还报错就换gbk用dtype参数指定关键列类型比如{income: float, age: int}已经读进来的 object 列用pd.to_numeric(errorscoerce)强制转换转不了的值变成 NaN 再走填充流程。6. 上线前多花三步KS曲线、PSI监测与拒绝推断6.1 用KS曲线判断模型区分度AUC 适合横向对比模型但上线前必须再看一个指标KS。按模型预测概率从高到低排序计算累计坏客户占比与累计好客户占比的最大差值这个差值就是 KS。风控里 KS 大于 0.3 可以上线0.4 以上效果明显稳定超过 0.6 反而要警惕数据泄漏。def ks_stat(y_true, y_prob): df pd.DataFrame({y_true: y_true, y_prob: y_prob}) df df.sort_values(y_prob, ascendingFalse) total_bad df[y_true].sum() total_good len(df) - total_bad df[cum_bad] df[y_true].cumsum() / total_bad df[cum_good] (1 - df[y_true]).cumsum() / total_good return (df[cum_bad] - df[cum_good]).abs().max()6.2 用PSI监测特征漂移模型上线只是开始特征分布会随客群结构变化慢慢漂移最常见的信号是收入中位数下降、查询次数整体上升。每周用线上真实特征分布和训练集特征分布算一次 PSI群体稳定性指标小于 0.1 说明分布稳定0.1 到 0.25 需要关注超过 0.25 基本可以判断线上数据已经换了市场模型该重训或回滚了。def psi(expected, actual, bins10): exp_counts, edges np.histogram(expected, binsbins) act_counts, _ np.histogram(actual, binsedges) exp_ratio exp_counts / exp_counts.sum() act_ratio act_counts / act_counts.sum() return np.sum((act_ratio - exp_ratio) * np.log(act_ratio / exp_ratio))6.3 没有坏标签的拒绝样本怎么办最后说一个真实业务里绕不开的偏差。模型上线后会拒绝一批高风险客户被拒绝的人永远不会产生真实还款表现于是后续训练数据里缺失了极端风险人群模型会越训越乐观。行业中常见的做法是拒绝推断对模型预测概率最高的拒绝样本按 70% 概率打上“伪坏标签”补进训练集。这个操作有争议但至少应该意识到模型存在这个盲区而不是一直用完美数据自我安慰。我一般会在模型上线时把带时间戳的模型快照单独存一份每周跑一次 PSI指标超过阈值就回滚到上一个快照并重新训练。这个习惯救过我两次一次是客群结构突变一次是上游特征源改了字段口径。希望帮到你。本文还有配套的精品资源点击获取