简介:面向计算机相关专业毕业设计与课程设计场景,一套基于机器学习的银行客户逾期行为预测项目提供了从数据处理到建模评估的完整源码与全部数据,适合正在做毕设或希望实战金融风控建模的学习者参考。压缩包内共10个文件,包含训练集、测试集与提交结果等3个CSV数据文件,3个Jupyter Notebook(分别完成EDA探索性分析、XGBoost+LR与GBDT+LR融合建模),另有字段说明XLSX、使用说明TXT、辅助Python脚本及gitignore配置,整体约127.3MB。项目经过严格调试,可直接运行。已有228人学习浏览。读者可以获得一套可复现的银行逾期预测完整流程,包括数据探索、特征理解、模型对比和结果输出,既能支撑毕业设计答辩,也便于在此基础上扩展风控特征或调整算法。
1. 当逾期预测不再靠经验拍板
银行信贷经理最头疼的事,是明明客户在申请表上看起来一切正常,三个月后却变成了逾期名单上的一个编号。传统风控靠评分卡和人工审核,能拦截住“历史上有逾期记录”的群体,但对那些首次逾期、资质看起来不错的客户几乎无能为力。机器学习进入这个场景后,思路变成了:把每一位客户过去的行为、负债、征信查询等数据拼成一条特征向量,用分类器学习“逾期者”和“正常者”之间的非线性边界。这份源码项目做的事情,就是提供从 CSV 原始数据到概率输出、再到逾期名单导出的完整 Pipeline。对于想进入金融风控领域做特征工程和模型调参的 Python 工程师,这是一个最适合起步的落地数据集。
2. 数据加载与质量勘察:拿到 zip 后的第一件事
2.1 解压并识别数据文件的基本情况
源码包解压后,通常是一个 CSV 文件加若干 Python 脚本,常见做法是data.csv,里面以行表示客户,以列表示字段。不要急着训练模型,第一步是核对数据的行列数、缺失情况和字段类型。前面走偏了,后面所有结论都是空中楼阁。
import pandas as pd df = pd.read_csv('data.csv', encoding='utf-8') print(df.shape) print(df.info()) print(df.describe(include='all').T) # 检查目标列的分布 print(df['overdue'].value_counts(normalize=True))shape给出样本量和字段数,info()会列出每列的非空值和 dtype,describe(include='all')能看均值、分位数以及唯一值数量。目标列通常叫overdue或target,取值 0/1,1 表示逾期。用value_counts(normalize=True)看一眼正负比例,这决定了后续要用什么策略处理不平衡问题。对这份数据,我一般会把缺失率超过 50% 的列先剔除,缺失率在 10% 到 50% 之间的列做中位数填充,低于 10% 的可以用众数。
2.2 数据不一致与类型转换的典型坑位
银行导出的数据里,最常出现的问题是把数值型变量存成了字符串。比如收入列里混入了逗号,年龄列里有-,这时直接df['income'] = df['income'].astype(float)会抛异常。遇到这种情况,用pd.to_numeric配合errors='coerce'把非法值变成 NaN,再做填充,比手工逐个替换要稳定得多。
for col in ['income', 'age', 'debt_ratio']: df[col] = pd.to_numeric(df[col], errors='coerce') # 把非数字内容抽出来看看,确认是脏数据而不是有意义的标记 bad = df[pd.isnull(df['income'])].head(20) print(bad[['income', 'age']])基数型字段(比如学历、职业编码)常被读成了 int,但它们在语义上是类别。如果直接把这类数值列喂给模型,算法会把 1、2、3 理解成有大小关系,这显然不符合事实。对这类列,即使原始值是数字,也必须显式转成字符串再走类别编码。这一步做不对,后续训练出的模型在特征重要性排序上会严重失真。
2.3 训练集和测试集划分:按时间切,不要随机切
逾期预测的公开数据集通常没有给出明确的时间字段,但真实银行场景里,客户在某个月被标记为好坏样本。随机划分的后果是把未来信息混进了训练集,造成评估指标虚高。常见做法是:如果数据里有观察月份或放款月份字段,就按月份排序后取前 80% 做训练,后 20% 做验证。
if 'month' in df.columns: df = df.sort_values('month').reset_index(drop=True) split_idx = int(len(df) * 0.8) train = df.iloc[:split_idx].copy() test = df.iloc[split_idx:].copy() else: train, test = train_test_split(df, test_size=0.2, random_state=42, stratify=df['overdue'])stratify=df['overdue']能让训练集和测试集里逾期客户的比例与全量数据保持一致。如果没有时间字段,这是最不容易让模型评估骗自己的方式。划分完成后,把特征矩阵和标签分开存取,并保持索引对齐,后续做特征工程时不容易错位。
3. 特征工程与预处理:把银行原始字段变成模型能用的特征向量
3.1 数值型特征的分箱与标准化策略
银行数据里,年龄、收入、负债比这类数值变量与逾期风险的关系通常不是线性的。年龄 25 岁和 45 岁的风险差异大,但 35 岁和 36 岁几乎没有差别。线性模型很难自己学到这种分段的敏感性,所以需要人为分箱。
import numpy as np df['age_bin'] = pd.cut(df['age'], bins=[18, 25, 35, 45, 55, 80], labels=[0, 1, 2, 3, 4], right=False) df['income_log'] = np.log1p(df['income'])分箱后,箱号作为有序整数特征输入树模型是合理的,但对逻辑回归,建议再对分箱结果做 one-hot。收入取log1p是为压缩长尾分布,防止个别高净值客户把特征的方差拉得过大。标准化则要看模型选型:树模型不关心量纲差异,逻辑回归和 SVM 需要做 StandardScaler,否则梯度下降的路径会左右摇摆,收敛很慢。
3.2 类别特征编码:one-hot 与 frequency encoding 的选择
银行数据集里的类别字段,比如住房状况(自有、按揭、租房、其他)、职业类型,取值个数一般在 5 到 20 个之间。one-hot 编码最安全,但会产生稀疏矩阵,对逻辑回归还好,对树模型会增加无谓的切分开销。另一个常见做法是频率编码:用每个类别在样本中的出现比例替换原值。
for col in ['housing', 'job']: freq_map = df[col].value_counts(normalize=True) df[col + '_freq'] = df[col].map(freq_map)频率编码的好处是保留了这个类别在业务上是否普遍的信息,坏处是它和目标变量容易产生轻微泄漏,因为频率是在全量数据上统计的。我一般在树模型上会适度使用频率编码,在逻辑回归里坚持 one-hot。如果类别中有一个取值占压到 90% 以上,建议把剩下所有小类合并成一个“其他”类别,避免编码后出现极稀疏列。
3.3 用 Pipeline 把预处理串起来,防止数据泄漏
最容易被忽略的问题是在填缺失值或标准化时,用了测试集的数据来计算参数。正确做法是把各种转换塞进 sklearn Pipeline,让它在fit阶段只在训练数据上学习参数,transform阶段再把参数应用到测试集上。
from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer numeric_features = ['age', 'income_log', 'debt_ratio'] categorical_features = ['housing', 'edu_level'] numeric_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) categorical_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='most_frequent')), ('onehot', OneHotEncoder(handle_unknown='ignore')) ]) preprocessor = ColumnTransformer(transformers=[ ('num', numeric_transformer, numeric_features), ('cat', categorical_transformer, categorical_features) ])SimpleImputer(strategy='median')在数值列上用中位数填充,树模型在面对缺失值时有自己的处理逻辑,但当前 Line 模型还是提前统一填充更稳。handle_unknown='ignore'保证测试集中出现训练集从未见过的类别时,one-hot 不会崩,而是自动全 0。后面接模型时,直接Pipeline(steps=[('pre', preprocessor), ('clf', LogisticRegression())])再调用交叉验证即可。可以看到 Pipeline 的意义不只是代码整洁,它切断了数据泄漏的所有路径。
4. 模型训练与参数调优:从逻辑回归到 XGBoost 的不平衡之战
4.1 先跑一个逻辑回归骨架模型
无论数据最终用什么复杂模型,我通常先用逻辑回归跑通管线,目的是验证特征管道是否正确、数据有没有泄漏,以及给出一个衡量其他模型好坏的基线。逻辑回归的可解释性在银行风控里有不可替代的价值:模型每做一次判断,业务人员需要能说清为什么拒绝这位客户。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score, StratifiedKFold train_x = train.drop('overdue', axis=1) train_y = train['overdue'] lr_pipeline = Pipeline(steps=[ ('pre', preprocessor), ('clf', LogisticRegression(max_iter=1000, C=0.5, solver='liblinear')) ]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(lr_pipeline, train_x, train_y, cv=cv, scoring='roc_auc') print('LR AUC: %.4f (+/- %.4f)' % (scores.mean(), scores.std()))max_iter=1000防止某些特征收敛慢导致迭代次数不足,C=0.5适度加强正则化,抑制噪声特征带来的过拟合。StratifiedKFold确保每折验证集中正样本的比例和全量数据一致——在不平衡数据集上,普通 K 折可能某折里一个逾期样本都没有,评估结果会虚高。评估指标这里不选准确率,因为如果负样本占 95%,一个全部预测为正常的模型也能拿到 95% 准确率,这对风控毫无意义。
4.2 类别不平衡处理:class_weight 与阈值移动,而不是盲目上过采样
银行逾期数据里,逾期比例常在 2% 到 10% 之间,直接训练 XGBoost 会让模型学会把所有样本都判为正常,因为这样损失最小。常见做法是有两条路可选:调模型参数或用重采样技术。class_weight='balanced'是最省事的方法,它的原理是按类别频率的倒数自动放大少数类的损失权重。另一种做法是 SMOTE 过采样,在特征空间中合成新的少数类样本,但会破坏样本之间原本的时间相关性,使用时需要格外小心。
from xgboost import XGBClassifier xgb_pipeline = Pipeline(steps=[ ('pre', preprocessor), ('clf', XGBClassifier( n_estimators=300, max_depth=4, learning_rate=0.08, scale_pos_weight=9, eval_metric='auc', use_label_encoder=False, random_state=42 )) ])scale_pos_weight=9的含义是负样本数量约是正样本的 9 倍,该参数告诉模型把正样本错的代价放大 9 倍,能显著提升对少数类的召回。max_depth=4控制树深度,在特征数不多时 4 到 6 足够,过高容易记住噪声。n_estimators=300配合learning_rate=0.08,学习率越低,每棵树贡献越小,需要更多树,但泛化会更好。use_label_encoder=False是较新版本 xgboost 的必选项,否则会警告甚至报错。
4.3 网格搜索与重点调参方向
XGBoost 的参数量很大,但真正决定模型优劣的只有几个:max_depth、min_child_weight、subsample和正则系数。不要一次性对全部参数做网格搜索,维度爆炸会让时间成本高到不可接受。常见的做法是分两轮,先用粗网格固定树结构参数,再微调正则项。
from sklearn.model_selection import GridSearchCV param_grid = { 'clf__max_depth': [3, 4, 5], 'clf__min_child_weight': [1, 3, 5], 'clf__subsample': [0.7, 0.9], 'clf__colsample_bytree': [0.7, 0.9] } search = GridSearchCV( xgb_pipeline, param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=1 ) search.fit(train_x, train_y) print(search.best_params_)min_child_weight是叶节点所需的最小样本权重和,调大它能抑制过拟合;subsample每次迭代随机抽 70% 到 90% 的样本训练,增加随机性从而降低方差。colsample_bytree对特征做列采样,在特征数超过 50 时这能显著提速且效果更好。网格搜索的scoring仍然用roc_auc,因为这是不平衡分类下最稳健的排序指标。调参完成后再去测试集上评估,而不是在验证集上调完就汇报,后者会带来明显的乐观偏差。
4.4 模型横向对比与选择
| 模型 | 训练速度 | 可解释性 | 对非线性特征的处理 | 对不平衡的处理 | 适用阶段 |
|---|---|---|---|---|---|
| 逻辑回归 | 极快 | 高 | 弱,需要手动分箱 | 需配合 class_weight | 基线、合规要求严格的场景 |
| 随机森林 | 快 | 中 | 强 | 需配合 class_weight | 做特征筛选时的参照模型 |
| XGBoost | 较慢 | 低 | 强 | 直接设 scale_pos_weight | 精度优先的主力模型 |
| LightGBM | 快 | 低 | 强 | 同 XGBoost | 海量数据下的替代选择 |
表格里的结论适用于绝大多数银行客户逾期预测数据集。如果项目方对模型解释有硬性要求,比如必须输出拒绝原因码,逻辑回归和分箱评分卡是首选。如果内部考核只看 AUC 和 KS,XGBoost 基本不会让你失望。还有一种常见做法是拿随机森林做特征重要性排序,剔掉无关特征后再喂给 XGBoost,能微幅提升效果并缩短训练时间。
5. 阈值优化与概率校准:逾期名单是一道得仔细算的门槛
模型输出的是概率,但业务方需要的是“这批客户进催收名单”。把阈值定在 0.5 并不合理,因为负样本占比极高时,模型输出的正样本概率大多集中在 0.1 到 0.3 之间,0.5 会把几乎所有人都判为正常。阈值的选择其实是一个业务问题:高阈值意味着名单更准但覆盖的逾期客户少,低阈值意味着覆盖更多但误杀也更多。解决思路是画出 PR 曲线或 KS 曲线,找到召回率和精确率的平衡点。实际操作中,我常在验证集上遍历阈值,计算每个阈值下的 F1 或业务成本函数。
from sklearn.metrics import precision_recall_curve prob = search.predict_proba(test_x)[:, 1] precision, recall, thresholds = precision_recall_curve(test_y, prob) # 找到 F1 最大时对应的阈值 f1_scores = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1] + 1e-9) best_idx = np.argmax(f1_scores) best_threshold = thresholds[best_idx] print('Best threshold:', best_threshold)precision_recall_curve返回的thresholds比precision和recall少一位,计算 F1 时必须用[:-1]切片对齐。1e-9是防止分母为 0。选阈值时还需要考虑库存成本:如果催收以电话为主,外包坐席每通电话成本固定,那就以“名单内逾期客户的挽回金额 - 名单成本”为目标函数做优化,F1 只是退而求其次的代理指标。
概率校准是另一个容易被忽略的步骤。XGBoost 输出的概率值整体偏高,因为有提升算法的每棵树都在逼近梯度方向,最终值并不能直接当作真实逾期概率。如果下游系统需要用概率做资本计提或计件定价,就要做 Platt Scaling 或 Isotonic Regression。比较常见的做法是拿训练集的预测概率作为输入,真实标签作为输出,再拟合一个逻辑回归。
from sklearn.calibration import CalibratedClassifierCV calibrated = CalibratedClassifierCV(search.best_estimator_, method='isotonic', cv=3) calibrated.fit(test_x, test_y)method='isotonic'适合样本量较大的场景,它的拟合能力更强,但需要足够数据才不会过拟合,一般测试集超过几万条时用它是合适的。如果样本量小,选method='sigmoid'更稳。校准后用 Brier Score 评估,分数越低说明概率越准。对这份源码项目来说,加完校准步骤后输出名单时,可以直接用概率乘以逾期金额排序,优先催收对风险金额最高的客户,这比只看逾期概率更贴合业务。
本文还有配套的精品资源,点击获取