简介:一份基于机器学习的个人信贷违约预测识别项目源码与配套训练测试数据集,面向计算机相关专业学生和从业者,适合作为毕业设计、期末课程设计或大作业的完整参考。项目评审分达到97分,代码经过严格调试可直接运行。压缩包共包含59个文件,主要类型有16个csv数据文件、10个Python源码文件、6个Go文件、5个Markdown说明文档、5张图片(如决策树成绩图),以及docx/pdf报告、pptx演示文稿、pth模型权重和可执行文件等,整体大小约142.33MB,目录结构层次分明,便于按模块查阅。已有178人学习下载。资源内附完整数据处理流程、模型训练与验证脚本、决策树/MLP等多模型实现,以及实验报告和演示文稿,可帮助读者快速复现信贷违约预测实验,理解特征工程、模型调参与结果评估的完整链路。
1. 个人信贷违约预测识别项目:为什么值得拿它当机器学习入手的第一个完整项目
个人信贷违约预测识别,其实是风控场景里最典型的一个二分类问题:根据借款人填写的资料和历史借贷行为,预测他未来会不会逾期。很多人在学机器学习时看过不少教程,但真正下手做项目时却卡在“拿什么数据”“怎么组织代码”“模型怎么评估”上。这个带训练测试数据集的源码包,正好给出一条完整的落地路径。它不像图像分类那样需要高性能GPU,也不像NLP那样要调大模型,一套普通笔记本就能跑,却能覆盖从数据清洗、特征工程、模型训练到评估的完整流程。适合刚要找工作、想做风控方向数据分析的从业者,也适合企业里想把传统规则升级成机器学习评分的一线工程师。我当年就是从这样一个小项目开始,才真正理解了什么叫“数据决定上限,模型逼近上限”。
2. 把违约预测拆成数据问题:样本、特征与标签的定义
2.1 违约预测并不等同于“准没错”:先搞清楚业务口径
机器学习里最容易被忽略的是标签怎么定义。你拿到一份个人信贷数据,里面可能有“是否逾期”“逾期天数”之类的字段,但直接拿“是否逾期”当标签,会发现模型训练出来很怪。原因在于业务上对“违约”有明确口径,比如逾期超过90天才算坏客户,也就是M3+口径;也有用30天或60天的。
我在处理这种数据时,一般会先看字段说明。如果没有说明,就从“逾期天数”或“还款状态”字段去构造标签。代码逻辑大致是:
import pandas as pd # 读取项目中的训练数据集 df = pd.read_csv('train_data.csv') # 假设数据里已有逾期天数 overdue_days # 违约口径:逾期超过90天记为1,否则为0 df['label'] = (df['overdue_days'] > 90).astype(int) # 如果逾期天数是缺失值,说明没有逾期,记为0 df['label'] = df['overdue_days'].fillna(0).apply(lambda x: 1 if x > 90 else 0) print(df['label'].value_counts())这里关键在后面两行,把缺失的逾期天数处理为0,再按阈值切分。为什么是90天而不是30天?因为信贷业务里,逾期90天通常意味着借款人已经没有短期还款意愿或能力,属于实质性坏账。如果你把阈值设在30天,会把一批临时忘记还款但后面正常还掉的客户误伤成“违约”,模型学到的模式也不稳定。
注意:这个口径必须固定下来,后面做模型评估和跨时间验证都用同一个口径,否则“高分项目”只能停留在训练集上,一到真实场景就失效。
2.2 数据清洗常见的坑:缺失值、异常值和重复样本
信贷数据里的缺失值非常常见。有些字段比如“工作单位”“年收入”是借款人填写的,漏填很正常。模型对这些缺失值很敏感,需要先处理。
我处理缺失值的顺序是:先看整体缺失率,再决定是删列、填补还是做“缺失标记”。缺失率超过50%的字段直接删掉,比如有些客户资质审核时根本没填学历,这类字段留着只会增加噪声。缺失率较低的字段按类型填补:数值型用中位数,类别型用众数。但要注意,如果之后要做时间序列切分,这个中位数不能全量计算,只能在训练集上算,否则测试集的分布提前泄露到模型里。
# 先看缺失率 missing_rate = df.isnull().sum() / len(df) print(missing_rate[missing_rate > 0.5]) # 删掉缺失率过高的列 df = df.drop(columns=missing_rate[missing_rate > 0.5].index) # 数值型字段用训练集的中位数填补 num_cols = df.select_dtypes(include=['int64', 'float64']).columns medians = df[num_cols].median() df[num_cols] = df[num_cols].fillna(medians) # 类别型字段用众数填补,同时保留是否缺失的信息 cat_cols = df.select_dtypes(include=['object']).columns for col in cat_cols: df[col + '_missing'] = df[col].isnull().astype(int) df[col] = df[col].fillna(df[col].mode()[0])这里我额外创建了“_missing”列,相当于告诉模型“这个字段原本缺失”。这个技巧在做评分卡时很管用,因为它把缺失本身变成了一个特征。很多新手直接删掉缺失行,结果样本量损失20%以上,而且那些偿债能力弱、填表不认真的客户往往更容易缺失,删除后模型就学不到这部分人的风险特征。
异常值方面,信贷数据里最容易出现的是年龄异常、收入为0、电话号码位数不对。年龄小于18或大于100的基本是录入错误,收入为0的要么是失业要么是填错,我会根据业务逻辑单独处理:年龄异常的直接删除;收入为0的暂时保留,但生成一个“是否无收入”的指示特征。因为无收入人群的违约率往往极高,直接删除会丢掉信息。
2.3 特征工程:从原始字段到能区分好坏的变量
信贷违约预测的特征大致有三类:基础资质(年龄、学历、收入)、历史行为(历史逾期次数、贷款笔数、查询次数)、负债情况(负债收入比)。原始字段往往不够直接,比如“年收入”单独看意义有限,但把它和“贷款金额”放到一起,得到“负债收入比”,就能更清楚反映客户的偿还压力。
特征工程虽然不会让模型发生质变,但至少能把基线往上拉几个点。常见的做法包括:
# 构造负债收入比 df['debt_income_ratio'] = df['loan_amount'] / (df['annual_income'] + 1) # 构造历史逾期率:过去12个月逾期次数 / 过去12个月账单总数 df['overdue_rate_12m'] = df['overdue_times_12m'] / (df['total_bills_12m'] + 1) # 对连续特征做分箱,比如收入分成低中高三档 df['income_bin'] = pd.qcut(df['annual_income'], 3, labels=['low', 'mid', 'high']) # 把类别特征转成数值编码,便于模型输入 df = pd.get_dummies(df, columns=['income_bin'], drop_first=True)这里用加法+1防止除零,是常见的平滑手法。逾期率的分子分母都来自聚合指标,能稳定反映客户的历史行为。分箱操作不是必须的,但它能让逻辑回归这类线性模型更容易捕捉非线性关系。XGBoost自己就能处理非线性,但分箱后的特征同时可以作为评分卡里的哑变量。
整套特征工程的要点是:每构造一个特征都要有业务理由,不要盲目堆特征。像“收入分箱”对应银行存量客户分层,“逾期率”对应收入稳定性,最终模型可解释性也更强。如果只是为了把分数刷高而无脑加几十个无关特征,项目交付时会被风控同事问到无话可说。
2.4 训练测试数据集怎么切:时间序列切分才是信贷场景的正解
很多人在做项目时随机打散数据,然后按7:3切训练集和测试集。这在普通分类问题里没有大问题,但信贷数据本质上是带时间戳的。你拿2019年的样本训练,拿2020年的样本测试,模型表现会明显下降,因为客群资质和经济环境在变。更关键的是,真实业务永远是“拿历史数据训练,去预测未来三个月的新客户”,如果训练集里混入了测试时间段的样本,模型就看到了未来的答案,评估结果虚高。
我一般按下单日期或申请日期排序后切分:
# 假设有申请日期字段 apply_date df = df.sort_values('apply_date').reset_index(drop=True) train_size = int(len(df) * 0.7) train_df = df.iloc[:train_size] test_df = df.iloc[train_size:] # 保存切分后的数据集 train_df.to_csv('train_featurized.csv', index=False) test_df.to_csv('test_featurized.csv', index=False)注意切分前先按日期排序,而不是在随机状态下直接切。训练集在时间上要早于测试集。这样做的目的是模拟真实业务里的前向验证,模型在旧数据上学会规律,然后应用到新客户上。如果评估结果依然稳定,说明模型有时间外推广能力,否则就是过拟合或特征里藏着时间泄漏。
在这一步,训练测试数据集的概念才开始真正变得有意义。一份好的源码包,不是只给你两个csv文件,而是会把切分逻辑、特征构造逻辑都组织成清晰可复现的脚本。用的时候只需要替换路径和相关字段名,就能套到新数据上。
3. 模型选型与首个基线:从逻辑回归到LightGBM
3.1 为什么先上逻辑回归,而不是一上来就堆Transformer
信贷风控场景里,模型并不是越复杂越好。逻辑回归的权重可以直接换算成评分卡分数,业务人员容易理解,监管机构也看得懂。所以哪怕树模型效果更好,许多银行依然用逻辑回归作为主模型。这个项目既然叫“机器学习项目”,合理的路径是先用逻辑回归搭一个基线,再把树模型拉出来对比,证明“复杂的模型确实能提升一定效果”,这才是完整的工作流。
逻辑回归的另一个优势是训练快、稳定性高。在高维稀疏特征下表现很好,不容易因为个别异常点导致整个模型崩溃。项目代码里大部分工程量其实都在数据特征填不上来那一块,模型本身只占很少代码,这点和实际工业项目是一致的。
3.2 用逻辑回归跑通第一个模型的最小代码
把训练集和测试集准备好之后,逻辑回归的代码非常简洁。但要注意,逻辑回归对特征的量纲敏感,数值型特征必须先标准化,否则那些数值很大的收入、金额会压倒其他特征。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, roc_auc_score # 分离特征和标签 X_train = train_df.drop(columns=['label', 'apply_date']) y_train = train_df['label'] X_test = test_df.drop(columns=['label', 'apply_date']) y_test = test_df['label'] # 标准化:只fit训练集,避免数据泄漏 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 训练模型 model = LogisticRegression(C=1.0, max_iter=200, class_weight='balanced') model.fit(X_train_scaled, y_train) # 预测概率 y_pred = model.predict(X_test_scaled) y_pred_proba = model.predict_proba(X_test_scaled)[:, 1] # 评估 print('accuracy:', accuracy_score(y_test, y_pred)) print('auc:', roc_auc_score(y_test, y_pred_proba))代码里最容易被忽略的是StandardScaler必须在训练集上fit,在测试集上只transform。如果你对整个数据一起标准化,测试集的均值和方差就被“偷看”到了,模型评估结果会偏乐观。这个细节也是面试常问的“数据泄漏”话题。
另一个参数class_weight='balanced'可以自动调整样本权重,应对类别不平衡。信贷数据里违约样本通常只占3%到10%,如果不处理,逻辑回归会把所有样本都判成不违约,虽然准确率很高,但一个坏客户也抓不到。设置这个参数后,少数类的损失被放大,模型更愿意把低置信客户识别为高危。
3.3 换成树模型:XGBoost和LightGBM的改动量很小
逻辑回归只是一个基线。在项目交付里,通常会再训练一个树模型作对比。常见的是XGBoost或LightGBM。这两者的原理都基于梯度提升,但方式略有不同,我们也不用过多纠结理论,只要知道它们比逻辑回归更能捕捉非线性关系、对一些异常值和缺失值天然鲁棒。
代码改动量很小:
import lightgbm as lgb lgb_model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, max_depth=5, num_leaves=31, class_weight='balanced', random_state=42 ) lgb_model.fit(X_train, y_train, eval_set=[(X_test, y_test)], eval_metric='auc') y_pred_lgb = lgb_model.predict(X_test) y_pred_proba_lgb = lgb_model.predict_proba(X_test)[:, 1] print('lgb auc:', roc_auc_score(y_test, y_pred_proba_lgb))这里我直接让LightGBM在训练时用测试集做早停监控,但注意这只是演示用途。比较严谨的做法是从训练集再切一部分做验证集,否则测试集不过滤到模型选择过程中,仍会造成轻微泄漏。源码包里如果分为“训练集”、“验证集”、“测试集”三个部分,就要把验证集当作调参依据,测试集只在最后评估一次。
参数说明:n_estimators决定树的数量,越多越容易过拟合,配合learning_rate一起调整,学习率越低就需要越多树,但训练时间也长。max_depth控制单棵树的深度,太深层数容易学习到噪声。num_leaves是LightGBM特有的参数,越大模型复杂度越高。项目调参时,我一般先固定一个中间值,再用交叉验证搜索网格。
3.4 多模型对比:不能只看AUC,还要看分组稳定性
模型训练完成,不能只在黑匣子之后留下一句“LightGBM更好”。需要同时保存逻辑回归的权重和树模型的特征重要性,方便后面解释。这是高分项目源码里应该有的一部分,如果没有,也要在写笔记时补上。
# 输出逻辑回归系数,按绝对值排序 lasso_coefs = pd.Series(model.coef_[0], index=X_train.columns) print(lasso_coefs.abs().sort_values(ascending=False).head(20)) # 输出LightGBM特征重要性 lgb_importances = pd.Series(lgb_model.feature_importances_, index=X_train.columns) print(lgb_importances.sort_values(ascending=False).head(20))对比时我通常会看两个模型在测试集上的AUC和KS值,而不是只报告准确率。逻辑回归的AUC可能在0.72,LightGBM在0.76,看似差距不大,但如果把测试集按模型分数分成10组,观察每一组的坏样本占比,会发现树模型在前两组(高分低危)和最后两组(低分高危)的区分度更明显。对信贷业务来说,抓到极端的高危人群比平均准确率更重要。
4. 样本不平衡与评估指标:别让准确率骗了你
4.1 准确率陷阱:当违约率只有5%时,全猜“不违约”也能有95%准确率
在一个违约率只有5%的数据集上,一个什么都不学、永远输出“不违约”的预测器,能拿到95%的准确率。所以你会发现很多新手项目在报告里写“准确率96%”,但其实模型没有学到任何东西。
正确做法是看召回率、精确率、AUC和KS。对于风控场景,我更关注“在保持一定精确率的前提下,能召回多少违约客户”。精确率低意味着误伤好客户,召回率低意味着放过坏客户。业务上通常会在两个指标之间权衡,比如设定审批通过率不超过某个阈值,然后再最大化召回率。
from sklearn.metrics import confusion_matrix, classification_report # 以0.5为阈值的混淆矩阵 cm = confusion_matrix(y_test, y_pred_lgb) print('混淆矩阵:') print(cm) # 更详细的分类报告 print(classification_report(y_test, ypred_lgb))classification_report里每行对应一个类别,列包含precision、recall、f1-score。你会看到“0”类(好客户)的precision可能很高,“1”类(坏客户)的recall却不忍直视。此时要明白,这是阈值0.5下的表现,而实际上我们可以通过调低阈值来提高对坏客户的召回。
4.2 过采样与SMOTE:合成样本到底要不要用
既然违约样本太少,一个常见的思路是做数据过采样,让训练集里两类样本数量接近。比较流行的方法是SMOTE,在少数类的样本之间做插值生成新样本。
from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42) X_train_res, y_train_res = smote.fit_resample(X_train, y_train) print('原始训练集类分布:', y_train.value_counts().to_dict()) print('SMOTE后:', y_train_res.value_counts().to_dict())SMOTE在项目里能明显提升少数类召回率,但也容易引入噪声:插值生成的样本可能不符合真实逻辑,比如两个坏客户之间生成的样本并不一定是坏客户。正如上面所说,这个操作必须在切分之后执行,而且要注意只能对训练集做,不能对测试集做。如果对全量数据做SMOTE,测试集就会混入合成样本,评估结果就不可信。
我的经验是,如果原始数据有上万条且坏样本占比不低于2%,用类别权重或者直接调整阈值就能应付,不需要做SMOTE。只有在坏样本极少、模型严重欠拟合时才考虑过采样。真实风控里“样本太少”问题经常存在,这时候可以尝试把部分次贷客户也标记为坏样本,扩充分母,但这需要业务方同意。
4.3 阈值选择:用分数做决策而不是模型默认的0.5
逻辑回归和树模型输出的概率值不是真实的概率分数,它是“模型认为客户违约的可能性”。在信贷决策中,我们实际上是把概率排序后取前百分之几审批或拒绝。比如设定“我拒绝概率最高的20%客户”,那么阈值就不是0.5,而是测试集概率的第80个百分点。
import numpy as np # 取测试集违约概率的0.8分位数作为阈值 threshold = np.percentile(y_pred_proba_lgb, 80) print('自动阈值:', threshold) # 用阈值重新做预测 y_pred_new = (y_pred_proba_lgb >= threshold).astype(int) # 输出新的混淆矩阵 print(confusion_matrix(y_test, y_pred_new))你会发现,如果把阈值调到这个点,相当于拒绝了概率最高的20%客户,但真实坏客户里有60%被识别出来,同时误伤了15%的好客户。这就是风控中的命中率和误伤率权衡。不同银行对风险偏好不一样,有的愿意多误伤好客户来减少逾期,有的则更在乎通过率带来的利润。
所以项目里“评估模型”这一步,不是打印一个准确率就结束了。更专业的做法是画出ROC曲线和KS曲线,再结合业务拒绝率和坏样本捕获率来选择阈值。
4.4 KS曲线和AUC:风控建模的两把尺子
AUC是ROC曲线下的面积,范围0.5到1,表示模型能把好客户和坏客户分开的概率。KS(Kolmogorov-Smirnov)统计量则是累计好客户占比与累计坏客户占比的最大差值。在信贷项目里,KS在0.3以上就说明模型可用,0.4以上算很好。
from sklearn.metrics import roc_curve, roc_auc_score fpr, tpr, thresholds = roc_curve(y_test, y_pred_proba_lgb) ks = max(tpr - fpr) print('KS值:', ks) # 也可以看AUC print('AUC:', roc_auc_score(y_test, y_pred_proba_lgb))选模型不能只看AUC,因为AUC描述的是整体排序能力,而KS点关注的是哪个分数段最能分开好坏。通常两者趋势一致,但如果分数集中在中间,KS会很低,即便AUC 0.75也可能鸡肋。K线放进项目报告里,风控同事会更愿意接受你的方案,因为这就是他们日常用的标准。
5. 避坑记录:这几个坑让我重跑了几次模型
5.1 标准化时对整个数据集fit,导致测试集信息提前泄露
现象:模型在测试集上的AUC极高,接近0.99,但拿到新数据上完全崩溃。
原因:我在预处理阶段先把全部数据合在一起,做了scaler.fit(df),这样训练集和测试集共享了同一个均值和方差。测试集的分布信息被模型间接看到,评估结果虚假。
解决:把标准化器从“先fit全量再transform”改成“只fit训练集,transform训练集和测试集”。正确代码如下:
scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)养成习惯:任何需要“基于数据统计”的操作,比如缺失值中位数、标准化、分位数分箱,都要在训练集上计算后应用到其他集。这个原则贯穿所有机器学习项目,尤其是这种带时间序列的信贷项目,一步错就全盘错。
5.2 切分数据前忘了按时间排序,测试集成了“随机抽样”
现象:模型在测试集上效果很好,但提交给业务方做小范围灰度验证时,误判率明显升高。
原因:我直接用了train_test_split,没有按申请时间排序。信贷客群受宏观环境和内部策略影响很大,比如某个阶段拉新活动带来大量低资质客户,随机切分会让训练集和测试集互相污染。
解决:先按申请时间排序,再切分。并且记录切分日期节点,后续跨时间验证时,每个月切一块新数据做模型稳定性监测,而不是只做一次固定切分。
5.3 类别特征用LabelEncoder,把无序类别变成了有序数字
现象:特征重要性分析里,“学历”被排到前几名,但每次数据中心切换后特征重要性排名变动非常大,模型也不稳定。
原因:学历原本是“高中、本科、硕士、其他”这样的无序类别,我直接用LabelEncoder把它编码成0、1、2、3。这样树模型会误以为2>1>0,给类别强加了顺序关系。逻辑回归更惨,相当于给学历加了一个线性权重。
解决:用pd.get_dummies或OneHotEncoder处理无序类别。如果要保留单调性,只有像“教育年限”这种有天然的从低到高关系的字段才能用数值编码。另外,将“学历”分箱后再编码,也能减少分类过多导致的稀疏。
5.4 调参时反复看测试集,测试集被“调”成了训练集
现象:模型在训练集和测试集上都拿到了很好的效果,但在下一季新数据上性能下滑严重。
原因:我在调参时每次对比测试集的AUC,一旦发现某个参数组合测试集AUC涨了0.01就改过去,调了很多轮。测试集的信息被我反复使用,模型已经过拟合到了测试集上。这本质上是把验证集的活让测试集干了。
解决:在训练集和测试集之间再切出验证集,调参只盯着验证集。最后模型固定后,测试集只用来评估一次。没有验证集的情况下,利用交叉验证代替。具体操作:
from sklearn.model_selection import GridSearchCV param_grid = {'learning_rate': [0.01, 0.05, 0.1], 'max_depth': [3, 5, 7]} search = GridSearchCV(lgb.LGBMClassifier(class_weight='balanced'), param_grid, cv=5, scoring='roc_auc') search.fit(X_train, y_train) print(search.best_params_)5.5 潜在数据泄露:把“未来字段”当成特征带进训练
现象:训练时模型AUC逼近0.98,而且“信用卡额度使用率”的特征重要性名列第一。
原因:我发现数据里有一个“当前逾期状态”字段,它是快照当天的最新信息,而训练目标“未来90天是否违约”也依赖这个字段。当样本集里的“当前逾期状态”已经包含了一部分未来信息时,模型是在用未来的答案预测未来。
解决:构造特征时,必须确保时间上没有重叠。比如预测未来三个月违约,特征就要使用申请日之前的数据,而不能用申请日当天最近的额度使用率。源码包里如果给了“字段说明”或“数据字典”,要先检查每个字段的计算时间点。遇到可疑字段时,可以把它从特征中剔除,再观察AUC下降幅度。
6. 进阶:用SHAP给模型装“解释器”,让风控同事愿意采信
模型做到最后,不是“跑出高分”就完事了。信贷风控要解释,比如某个客户为什么被拒,监管或业务方都会问。SHAP是一种能统一解释树模型特征贡献度的工具,对于LightGBM这类模型尤其合适。
import shap explainer = shap.TreeExplainer(lgb_model) shap_values = explainer.shap_values(X_test) # 全局特征重要性 shap.summary_plot(shap_values, X_test, max_display=20)summary_plot里每个点代表一个样本,点的横坐标是该特征对预测结果的SHAP值。正值表示让模型倾向于预测违约,负值表示倾向于不违约。颜色代表特征值的高低。比如“历史逾期次数”那一条,你会看到随着颜色从蓝变红,SHAP值迅速升高,说明历史逾期越多的客户,模型给出的违约概率越高。
除了全局解释,还要看单个样本的解释。对一个被拒的客户,可以输出他各特征的SHAP力场图,定位到是“负债收入比过高”导致被拒,而不是某些黑盒逻辑。这一步能让风控同事更信服模型,也方便后续做人工复核。
我现在的习惯是每迭代一版模型,先跑SHAP,再对照业务常识看特征方向有没有矛盾。比如如果发现“收入越高”反而违约风险越大,那一定是特征里藏着异常,需要回头查数据。敢在模型上线之前做这一步,才是真正把机器学习项目落地,而不是停留在跑通一个demo。希望帮到你。
本文还有配套的精品资源,点击获取