银行贷款违约预测实战:数据清洗、特征工程与模型训练全流程
简介面向毕业设计、课程设计及期末大作业等场景基于Python的银行个人贷款违约预测项目提供了完整源代码和配套数据集帮助学习者掌握从数据清洗、特征工程到违约分类预测的典型流程。包内共20个文件以12个.py脚本和4个.csv数据集为主辅以2个.md说明文档与1个txt说明整体约935KB结构紧凑、便于快速部署与本地复现。当前已有219人学习/下载适合具备一定Python基础、希望深入理解风控建模或进行二次开发的学生与从业者。代码已经过本地运行验证覆盖数据预处理、基于标签编码与类别/时间属性的特征构建、主程序调用及提交结果生成等环节目录划分清晰便于按模块阅读和扩展也可作为毕设答辩展示的实用参考。1. 银行贷款违约预测先跑通 DataClean 和 FeatureBuilder再谈调参同样面对 data/ 下的 test_public.csv有人把 XGBoost 装好就直接 fit有人会先打开 preprocess/DataClean.py 和 FeatureBuilder 目录下的四个特征文件后者的模型即使保持默认参数也往往比前者好一截。银行个人贷款违约预测不是一个模型竞赛而是一个数据处理竞赛标签只有 0/1特征却包含申请日期、职业、收入、历史逾期记录等乱七八糟的字段真正决定预测上限的是这些字段能不能变成树模型能直接使用的特征。这套基于 Python 的源代码自带数据集和 submit_example.csv 提交样例目录里拆出了数据清洗、特征构建、模型训练三个阶段覆盖毕业设计、课程设计、期末大作业最常见的技术点。适合正在做信贷风控 baseline 的学生也适合想快速看一套完整项目结构的工程师。下面按照从清洗到提交的顺序把每一步的关键代码和容易踩的坑过一次。2. DataClean.py先解决缺失率、类型错位和提交样式的对齐很多人在拿到数据集后第一件事就是查看缺失值然后 fillna(0)。在银行贷款场景里这会掩盖业务含义比如“月收入”字段缺失往往意味着申请人没有稳定收入来源填 0 或中位数都等于替模型做了一个错误假设。preprocess/DataClean.py 的作用是先看每个字段的缺失率、类型和取值分布再决定填充策略最后检查测试集 id 和提交样例是否一致。2.1 读入数据先确认字段类型和日期格式test_public.csv 是公开测试集submit_example.csv 只用于确认提交的列名和行数。读取数据时不要直接 read_csv 完事尤其要注意日期列。下面是一个通用开头import pandas as pd raw pd.read_csv( data/test_public.csv, parse_dates[apply_date], infer_datetime_formatTrue ) print(raw.shape) print(raw.dtypes.value_counts())parse_dates[apply_date]会把申请日期解析成 datetime 类型如果项目里字段名叫application_time替换成对应列即可。dtypes.value_counts()输出数值列、对象列和时间列的数量方便决定后续按哪种 pipeline 处理。很多同学在这一步会忽略日期列导致 TimeFeature.py 运行时报“object has no attribute dt”大部分原因就是日期没有被正确 parse。2.2 缺失值处理策略先打标记再填充对数值列常见做法是先记录缺失指示位再填充中位数对类别列填充UNKNOWN而不是删除。这个策略背后的逻辑是在银行信贷数据里缺失本身可能携带信息。一个客户没有填写单位电话可能说明他工作稳定性差这种信号不能被单纯填充覆盖。字段类型处理方式说明连续数值字段记录缺失指示位后填充中位数避免极端值影响同时保留缺失信息类别字段填充 UNKNOWN 再做编码保持类别域稳定防止编码报错时间字段不填充保留 NaN 由 TimeFeature 处理时间缺失无法用均值估计缺失率 0.8 的字段直接丢弃信息量过低填充只会加噪声对应代码可以封装成两个函数后续 DataClean.py 里也是类似结构def clean_numeric(df, num_cols): for col in num_cols: # 先记录缺失标志再填充中位数 df[col _is_missing] df[col].isnull().astype(int) df[col] df[col].fillna(df[col].median()) return df def clean_category(df, cat_cols): for col in cat_cols: # 先转字符串再统一填充 UNKNOWN df[col] df[col].astype(str).fillna(UNKNOWN) return dfclean_numeric先建_is_missing列再填中位数这样模型既能使用原始分布又知道样本是否缺失。clean_category里先astype(str)再填充是为了避免浮点型类别字段把 NaN 变成nan字符串如果不做这一步后续 LabelEncoder 会把nan当成一个正常类别线上预测时若测试集没有该值又会报错。2.3 检查 id 是否与提交样例对齐数据清洗的最后一步是检查清洗后的数据行数是否仍然与submit_example.csv对齐。我见过有人把清洗阶段去重、删缺失行之后测试集从 20000 行变成 19000 行预测结果完全没法提交。所以在进入特征工程前先做断言sub pd.read_csv(data/submit_example.csv) assert set(raw[id]) set(sub[id]), id set mismatch assert raw[id].duplicated().sum() 0, duplicated id found第一行assert检查 id 集合完全一致第二行检查没有重复 id。如果项目本身是拆好训练集和测试集的这一步可以提前暴露数据切分时的脏数据。如果有额外的覆盖需求可以把raw[id]保存到 CSV后续和预测结果做 outer join 核对确保任何一方都没有多出记录。在命令行里运行项目时数据清洗脚本通常是这样调用的python preprocess/DataClean.py --input data/test_public.csv --output data/clean.csv。--input指定原始文件--output指定清洗后文件后面特征工程和训练脚本都从clean.csv读取避免每次重复执行清理逻辑。3. FeatureBuilder 实战LabelEncoder、ClassFeature、TimeFeature 怎么组合不翻车FeatureBuilder 目录下按功能拆了四个文件AddFeature.py、LabelEncoderFeature.py、ClassFeature.py、TimeFeature.py。我按照实际建模顺序使用它们先做 LabelEncoder 稳定类别编码再做 TimeFeature 拆时间周期再做 ClassFeature 做群体统计最后用 AddFeature 放业务规则。顺序不同结果可能差异很大。模块文件核心产出典型使用场景LabelEncoderFeature.py稳定类别编码含未登录类别处理城市、职业、学历ClassFeature.py分组均值、平滑 target encoding用小类别聚合提高区分度TimeFeature.py月份、星期、月初月末等周期特征申请日期、审批日期AddFeature.py手工业务规则和比例特征收入负债比、历史逾期标记3.1 LabelEncoderFeature类别特征要留一个“未知”通道直接用sklearn.preprocessing.LabelEncoder的常见坑是训练时 fit 了训练集线上预测时测试集出现新的类别transform 直接抛 ValueError。SafeLabelEncoder 的思路是先拟合再把UNKNOWN作为显式类别追加进classes_把所有未登录类别统一指到它。import numpy as np from sklearn.preprocessing import LabelEncoder class SafeLabelEncoder: def fit(self, series): self.encoder LabelEncoder() self.encoder.fit(series.astype(str).values) self.classes_ set(self.encoder.classes_) # 将 UNKNOWN 追加进类型集合避免 transform 时未知类别报错 self.encoder.classes_ np.append(self.encoder.classes_, UNKNOWN) return self def transform(self, series): s series.astype(str).copy() s[~s.isin(self.classes_)] UNKNOWN return self.encoder.transform(s)fit阶段把UNKNOWN追加进encoder.classes_transform阶段先用isin判断是否在已知类别集合里不在的替换为UNKNOWN再交给encoder.transform。这个模式避免了预测时因为一个陌生城市名而崩溃。需要注意训练集本身如果包含UNKNOWNset(classes_)里已存在追加不会重复。使用pd.factorize无法做到这一点因为它的编号永远从 0 开始动态生成训练和预测两套编号对不上。3.2 ClassFeaturetarget encoding 必须分折计算ClassFeature 通常用来构造类别字段的目标编码比如“不同职业的历史违约率”。但如果直接对整个训练集计算groupby(职业).mean()然后把该值赋回原样本模型会说“原来这个职业违约率这么高”但实际上这个统计量包含当前样本自己的标签属于典型标签泄漏。我一般用 KFold 分折计算样本所在折不参与自身统计。from sklearn.model_selection import KFold def oof_target_encoding(df, feature, target, alpha10.0): df df.copy() te_col fte_{feature} global_mean df[target].mean() df[te_col] np.nan kf KFold(n_splits5, shuffleTrue, random_state42) for tr_idx, va_idx in kf.split(df): # 只使用训练折的数据计算均值避免标签泄漏 stats df.iloc[tr_idx].groupby(feature)[target].agg([mean, count]) stats[te] (stats[mean] * stats[count] global_mean * alpha) / (stats[count] alpha) df.loc[va_idx, te_col] df.iloc[va_idx][feature].map(stats[te]) df[te_col] df[te_col].fillna(global_mean) return df平滑公式是(count * mean alpha * global_mean) / (count alpha)alpha越大结果越靠近全局平均alpha越小越相信该类别自己的历史表现。通常取 520。这种特征对树模型很有效但要注意target encoding 只能用训练折和目标标签做出来测试集编码时不能重新计算而是用训练折统计的映射直接对测试集做map。ClassFeature.py 在实际项目里还会同时保留原职业字段让树模型自己决定用哪一种。3.3 TimeFeature申请日期背后的周期信号TimeFeature.py 拆的不是日期字符串而是周期信号。直接把日期转成时间戳喂给树模型树只会找到一个上升或下降的切分点拆成 month、weekday、day_of_month 后模型才能捕捉月底申请、周末申请这类业务模式。def add_time_features(df, time_colapply_date): dt pd.to_datetime(df[time_col]) res pd.DataFrame(indexdf.index) res[month] dt.dt.month res[weekday] dt.dt.weekday res[is_weekend] (dt.dt.weekday 5).astype(int) res[day_of_month] dt.dt.day res[days_to_month_end] dt.dt.days_in_month - dt.dt.day return resmonth和weekday是基础周期值is_weekend是一个 0/1 标志days_to_month_end表示申请日距离月末还有几天可以捕捉发薪日附近的资金需求。dt.days_in_month是 pandas 提供的每月天数属性直接用dt.dt.days_in_month - dt.dt.day做差不需要额外查日历。时间特征在我的经验里对信贷预测的增益不一定特别大但往往能提升模型的稳定性因为树模型可以据此区分“工作日白天申请”和“半夜申请”这两种截然不同的用户状态。3.4 AddFeature业务规则特征要可解释AddFeature.py 适合放基于业务直觉构造的规则特征。比如个人贷款里收入与贷款额的比例、月供占收入比例一眼就能看出还款压力。代码逻辑本身不复杂但答辩时这类特征最好讲特征为什么有效预期方向是什么。def add_business_rules(df): df df.copy() # 贷款总额对年收入比例越高压力越大 df[loan_to_income] df[loan_amount] / (df[annual_income] 1e-6) # 月供占月收入比例 df[repayment_ratio] df[monthly_payment] / (df[annual_income] / 12 1e-6) # 历史是否有逾期 df[had_overdue] (df[overdue_count] 0).astype(int) return dfloan_to_income是贷款总额对年收入的比例数值越高还款压力越大预期违约率越高repayment_ratio是月供对月收入的比例had_overdue把逾期次数压成 0/1避免极端次数值影响模型。分母加1e-6只是防止除零。实际操作时需要先统计这两个比例值的分布如果存在超大极端值可以考虑把分母换成年收入1 或者做 log 压缩。4. main.py 中的模型训练与样本不平衡处理特征表构造完成后main.py 负责把这套逻辑串成可复现的流程。这里最值得讲的是切分方式和不平衡处理。银行个人贷款违约数据里正样本比例通常很低某类产品甚至只有 3% 左右。如果直接train_test_split且用 accuracy 评估模型全预测 0 也能拿 97% 准确率。所以从切分到评估每一步都要围绕不平衡数据设计。4.1 用 StratifiedKFold 切分保留比例结构import numpy as np import lightgbm as lgb from sklearn.model_selection import StratifiedKFold def train_lgb_cv(X, y, paramsNone): if params is None: params { objective: binary, learning_rate: 0.05, num_leaves: 31, max_depth: 5, feature_fraction: 0.8, bagging_fraction: 0.8, verbose: -1 } skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(X)) models [] for tr_idx, va_idx in skf.split(X, y): X_tr, X_va X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va y.iloc[tr_idx], y.iloc[va_idx] model lgb.LGBMClassifier(**params) model.fit(X_tr, y_tr, eval_set[(X_va, y_va)], eval_metricauc) oof[va_idx] model.predict_proba(X_va)[:, 1] models.append(model) return models, oofStratifiedKFold在split时根据 y 的类别比例划分索引每一折中正负样本比例和整体保持一致。shuffleTrue避免原始样本按时间排序带来的前几折全是一个时段。eval_metricauc让 LightGBM 在每一折训练时输出 AUC 变化而不是 accuracy。oof数组保存每个样本在验证折上的预测概率这个概率可以用来搜索阈值、绘制 KS 曲线也可以作为后续 Stacking 的输入。4.2 不平衡处理参数表除了切分还需要在模型层面处理不平衡。LightGBM 最常用的参数是scale_pos_weight也可以用负采样或 SMOTE。下面表格列出各自的适用情况。方法实现方式适用场景注意点scale_pos_weight负样本数 / 正样本数树模型代码改动最小比例过大时可能过拟合正样本负采样随机丢弃部分负样本数据量大、内存受限验证集要保留原始分布SMOTE对正样本做插值正样本极少且特征维度不高高维稀疏特征下容易产生无意义样本用 scale_pos_weight 时先算比例再写入参数pos (y 1).sum() neg (y 0).sum() print(fpositive: {pos}, negative: {neg}) if pos 0: raise ValueError(no positive sample found, check label) params { objective: binary, scale_pos_weight: neg / pos, learning_rate: 0.05, max_depth: 5, }scale_pos_weight设置为负样本数除以正样本数。比如正样本 500负样本 9500就传 19。它的效果等价于把正样本的损失权重放大 19 倍迫使模型更关注少数类。这里加了一个pos 0的防御性判断如果标签全是 0后面的训练没有意义应该先查数据构造。4.3 阈值搜索模型输出的是概率不是类别训练完成后很多代码直接(proba 0.5)转成 0/1。在违约率只有 5% 的数据上这个阈值几乎不会把任何人判为正样本。正确做法是在验证集上搜索一个业务最优阈值。from sklearn.metrics import fbeta_score def search_best_threshold(y_true, proba, beta2.0): best_th, best_score 0.5, -1 for th in np.arange(0.05, 0.95, 0.025): pred (proba th).astype(int) score fbeta_score(y_true, pred, betabeta) if score best_score: best_score, best_th score, th return best_th, best_scorebeta2.0表示 recall 的权重是 precision 的两倍信贷场景中少漏掉一个违约客户通常比多打扰一个正常客户更重要。搜索步长 0.025也可以改成 0.01但验证集样本有限过细的步长容易让阈值落在噪声点上。搜索出来的阈值只是一个 baseline业务上如果要求拒绝率不超过某个比例应当根据拒绝率重新选阈值。5. submit_example.csv 对齐与特征顺序锁死模型训练完提交阶段最常见的问题是训练和测试特征列顺序不一致。pd.get_dummies在训练集和测试集分别执行时会因为类别取值不同产生不同的列数pd.factorize在线预测时会重新编号LabelEncoder会因为未知类别直接报错。解决思路是把训练好的特征列名单保存下来预测时强制对齐。import json import joblib import pandas as pd model joblib.load(artifacts/lgb.pkl) with open(artifacts/feature_columns.json, r) as f: feature_columns json.load(f) test pd.read_csv(data/test_public.csv) test build_features(test) # 测试集的特征构建 test test.reindex(columnsfeature_columns, fill_value0) prob model.predict_proba(test)[:, 1] sub pd.read_csv(data/submit_example.csv) assert len(sub) len(prob) sub[score] prob sub.to_csv(submission.csv, indexFalse, float_format%.6f)feature_columns应该在训练结束后通过list(X_train.columns)导出并保存。reindex(columnsfeature_columns, fill_value0)是最关键的一行它把测试特征矩阵的列顺序重排成训练时顺序多余的列直接丢弃缺失的列填充 0。树模型本身能处理 NaN但这里填充 0 更符合“该 one-hot 维度在测试集中未出现”的业务含义。float_format%.6f控制输出精度为 6 位小数避免提交文件过大。类别编码也要做同样的对齐保护。如果使用了 SafeLabelEncoder可以在 transform 前先做一次未知值替换def safe_encode_from_file(series, encoder): known set(encoder.classes_) mapped series.astype(str).apply(lambda x: x if x in known else UNKNOWN) return mapped.map(lambda x: encoder.transform([x])[0])known集合来自训练时保存的 encoder线上出现的新类别会落到UNKNOWN上再用训练时学到的编号映射。map(lambda x: encoder.transform([x])[0])每次只编码一个值效率不高但胜在稳定生产环境可以用np.vectorize或预构建字典优化。最后用命令行检查输出文件python -c import pandas as pd; spd.read_csv(submission.csv); assert s.isnull().sum().sum()0; print(s.shape, s[score].min(), s[score].max())这条命令断言输出没有空值并打印预测列的取值区间。如果最大值大于 1 或出现负数说明模型输出的是决策函数值而不是概率如果全部为同一个数检查是不是标签列被直接赋值。概率值必须落在 0 到 1 之间这是提交前最直接的合法性检查。本文还有配套的精品资源点击获取