简介:面向毕业设计、课程设计及期末大作业等场景,基于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_format=True ) 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。这个模式避免了预测时因为一个陌生城市名而崩溃。需要注意,训练集本身如果包含'UNKNOWN',set(classes_)里已存在,追加不会重复。使用pd.factorize无法做到这一点,因为它的编号永远从 0 开始动态生成,训练和预测两套编号对不上。
3.2 ClassFeature:target encoding 必须分折计算
ClassFeature 通常用来构造类别字段的目标编码,比如“不同职业的历史违约率”。但如果直接对整个训练集计算groupby(职业).mean(),然后把该值赋回原样本,模型会说“原来这个职业违约率这么高”,但实际上这个统计量包含当前样本自己的标签,属于典型标签泄漏。我一般用 KFold 分折计算,样本所在折不参与自身统计。
from sklearn.model_selection import KFold def oof_target_encoding(df, feature, target, alpha=10.0): df = df.copy() te_col = f'te_{feature}' global_mean = df[target].mean() df[te_col] = np.nan kf = KFold(n_splits=5, shuffle=True, random_state=42) 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越小,越相信该类别自己的历史表现。通常取 5~20。这种特征对树模型很有效,但要注意:target encoding 只能用训练折和目标标签做出来,测试集编码时不能重新计算,而是用训练折统计的映射直接对测试集做map。ClassFeature.py 在实际项目里还会同时保留原职业字段,让树模型自己决定用哪一种。
3.3 TimeFeature:申请日期背后的周期信号
TimeFeature.py 拆的不是日期字符串,而是周期信号。直接把日期转成时间戳喂给树模型,树只会找到一个上升或下降的切分点;拆成 month、weekday、day_of_month 后,模型才能捕捉月底申请、周末申请这类业务模式。
def add_time_features(df, time_col='apply_date'): dt = pd.to_datetime(df[time_col]) res = pd.DataFrame(index=df.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, params=None): 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_splits=5, shuffle=True, random_state=42) 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_metric='auc') oof[va_idx] = model.predict_proba(X_va)[:, 1] models.append(model) return models, oofStratifiedKFold在split时根据 y 的类别比例划分索引,每一折中正负样本比例和整体保持一致。shuffle=True避免原始样本按时间排序带来的前几折全是一个时段。eval_metric='auc'让 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(f'positive: {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, beta=2.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, beta=beta) if score > best_score: best_score, best_th = score, th return best_th, best_scorebeta=2.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(columns=feature_columns, fill_value=0) 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', index=False, float_format='%.6f')feature_columns应该在训练结束后通过list(X_train.columns)导出并保存。reindex(columns=feature_columns, fill_value=0)是最关键的一行:它把测试特征矩阵的列顺序重排成训练时顺序,多余的列直接丢弃,缺失的列填充 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; s=pd.read_csv('submission.csv'); assert s.isnull().sum().sum()==0; print(s.shape, s['score'].min(), s['score'].max())"这条命令断言输出没有空值,并打印预测列的取值区间。如果最大值大于 1 或出现负数,说明模型输出的是决策函数值而不是概率;如果全部为同一个数,检查是不是标签列被直接赋值。概率值必须落在 0 到 1 之间,这是提交前最直接的合法性检查。
本文还有配套的精品资源,点击获取