
简介这是一套基于Python的机器学习个人信用评估项目资源围绕贷款违约预测任务展开适用于课程设计、金融风控入门及天池赛事复现。资源包含完整项目源码、天池脱敏数据集47列变量超120万条记录、可视化分析与建模调参脚本以及Word版设计报告可清晰还原从数据探索、特征工程到模型训练评估的全流程。压缩包共82个文件以Python脚本、PNG图表、CSV数据和Word文档为主大小5.16MB目录组织清晰、便于按需取用。目前已有741人学习下载对需要快速搭建信用评分方案的读者很有价值。通过资源可获取EDA可视化、相关性热力图、PCA降维、缺失值填充、类别编码、网格搜索调参及训练预测等配套代码有助于系统复现赛题思路并迁移到实际项目中。1. 个人信用评估的机器学习落地从原始数据到可用信用分在信贷审批和消费金融场景里判断一个人会不会逾期过去靠人工审核和固定规则现在主流做法是用机器学习从历史还款数据里学出违约规律。这个标题对应的就是一套完整的个人信用评估建模流程数据清洗、特征分箱、模型训练、评分卡转换最后输出业务可直接用的分数。解决的问题非常具体——审批效率、规则覆盖、决策解释。适合正在做风控建模、信贷量化的人也适合想用 Python 切入金融数据岗位的工程师。这里说的机器学习不是深度学习那套黑匣子而是逻辑回归为主、树模型做对照的可解释路线。为什么这么选是后面第 3 章的重点。先把整套流程打通换数据、换客群都只是参数调整骨架不变。2. 信贷数据的清洗与 WOE 分箱特征工程决定模型上限2.1 信贷字段初筛先删掉没用的再填缺失个人信用评估的输入数据常见来源是申请资料、征信报告和历史借贷记录。能进模型的字段大致分三类身份与稳定性年龄、婚姻、学历、居住年限、收入与负债月收入、负债率、贷款笔数、历史行为逾期次数、信用卡使用率、查询次数。原始数据不会直接进模型先要过一遍初筛。我的初筛习惯是看两个指标缺失率和唯一值占比。缺失率超过 70% 的字段补全是造数据不如不用唯一值占比超过 90% 的字段几乎每个样本都不一样模型学不到稳定规律还会增加过拟合风险。下面的代码就是这套逻辑import pandas as pd df pd.read_csv(credit_data.csv) # 字段初筛缺失率 70% 或 单一值占比 90% 直接剔除 miss_rate df.isnull().mean() single_rate df.nunique() / len(df) drop_cols miss_rate[miss_rate 0.7].index.tolist() \ single_rate[single_rate 0.9].index.tolist() df df.drop(columnslist(set(drop_cols))) print(剩余字段数:, df.shape[1])第一行df.isnull().mean()返回每个字段的缺失比例single_rate用nunique()除以样本总数表示唯一值占比。drop_cols里两个条件用加法拼接再通过set去重避免同一个字段重复出现在删除名单里。处理完初筛剩下的缺失字段按三段处理缺失率 30% 以下用中位数填充30% 到 70% 之间加一个该字段是否缺失的 0/1 标记再用 WOE 编码70% 以上直接删除。为什么用中位数不用均值信用数据里收入、负债率都是右偏分布少数高收入样本会把均值拉得很高中位数更接近大多数人的水平。这是信贷场景里比较稳的做法均值填充在右偏数据上容易让模型系数漂移。2.2 连续变量分箱为什么不能直接喂原始数值信用评估场景里连续特征不能直接进逻辑回归。原因有两个一是线性模型对异常值敏感月收入 10 万和 200 万的两个样本数值差异会直接拉偏系数二是信贷字段和违约率的关系不是线性的30 岁和 40 岁的差距和 50 岁到 60 岁的差距完全不一样线性模型表达不了这种形状。分箱就是把连续值切成区间每个区间变成一个类别。这样做的额外好处是让模型对极端值不敏感——年收入 1000 万和 2000 万分箱后都在同一个箱里WOE 值可能只差 0.1。信用评估根本不关心具体数字只关心你落在哪个风险区间。分箱的数量取决于样本量。我的标准是每个箱至少 5% 的样本坏客户数量不少于 30 个否则 WOE 值不稳定。实现上先用pd.cut切分import numpy as np df[age_bin] pd.cut(df[age], bins[17, 25, 30, 40, 50, 60, 80], rightFalse) print(df[age_bin].value_counts())bins用列表显式指定区间终点最后一个值必须大于年龄最大值否则最大样本会变成 NaN。rightFalse表示左闭右开[17,25)包含 17 不含 25避免样本落在边界两边。分箱之后是 WOE 编码。WOEWeight of Evidence衡量每个箱内坏客户和好客户的分布差异公式是WOE ln(坏客户占比 / 好客户占比)WOE 接近 0 的箱没有区分度偏离越大区分越强。这个值的计算不复杂但要注意坏客户占比为 0 的情况log(0) 会直接报错。下面的函数加了平滑项def calc_woe(group_df, feature, target): dist group_df.groupby(feature)[target].agg( badsum, countcount ) dist[good] dist[count] - dist[bad] bad_total dist[bad].sum() good_total dist[good].sum() dist[bad_dist] dist[bad] / bad_total dist[good_dist] dist[good] / good_total dist[woe] np.log((dist[bad_dist] 1e-6) / (dist[good_dist] 1e-6)) return dist woe_age calc_woe(df, age_bin, default) print(woe_age[[bad_dist, good_dist, woe]])agg函数里badsum表示 target1 的样本数坏客户countcount是总样本数两者相减得到好客户数。bad_dist和good_dist是归一化后的占比最后取对数就是 WOE。平滑项加在分子分母上双保险。打印时把bad_dist和good_dist一起打出来方便肉眼检查哪个箱的区分度异常。2.3 IV 值筛选哪些特征值得进模型分箱和 WOE 做完每个特征都变成了一组 WOE 值。下一步是判断这个特征值不值得入模用的指标是 IVInformation ValueIV Σ (坏客户占比 - 好客户占比) × WOEIV 值小于 0.02 基本没用0.02 到 0.1 之间较弱0.1 到 0.3 中等大于 0.3 算强。但 IV 太高超过 0.5也要小心可能这个字段本身包含了目标信息上线后极不稳定。比如当前是否有逾期未还这类字段业务上几乎等于违约的定义IV 会爆炸高但把它放进模型就是变相预测答案。IV 区间预测能力处理建议 0.02几乎无用不入模0.02 - 0.1较弱结合业务经验判断0.1 - 0.3中等建议入模0.3 - 0.5较强入模并关注稳定性 0.5过强警惕数据泄漏谨慎入模计算 IV 的代码很短直接用calc_woe的结果def calc_iv(woe_df): iv ((woe_df[bad_dist] - woe_df[good_dist]) * woe_df[woe]).sum() return iv for feat in [age, income, debt_ratio]: df[feat _bin] pd.cut(df[feat], bins5, rightFalse) woe calc_woe(df, feat _bin, default) print(feat, IV , round(calc_iv(woe), 4))代码里bins5是临时方便实际建模建议按分位数分箱保证每箱样本量均衡。IV 只是筛选工具它决定字段能不能进模型决定不了字段在模型里的权重。有业务经验的人都知道有些 IV 不高但业务意义强的字段比如负债率一定要保留这类字段在模型里不一定贡献最大的 AUC却能防止模型学出违背常识的规律。3. 模型选型与训练逻辑回归为什么还是风控首选3.1 逻辑回归的不可替代性可解释性与稳定性信用评估场景选模型有两个硬约束可解释性和稳定性。监管和业务都要求你对客户解释为什么分低比如因为你最近 6 个月有 3 次逾期。逻辑回归的系数正好能给出这种解释——系数为正的特征拉高违约概率系数为负的特征降低违约概率。XGBoost 这类树模型精度通常更高但它是黑匣子特征重要性只能告诉你哪些字段在起作用说不清每个客户的分数为什么扣、扣在哪。第二是稳定性。信贷人群的分布在变树模型容易过度学习训练集里的交互关系逻辑回归的线性假设在这里反而成了优点——它在新客群上更稳。另外信用评估的数据量一般不像互联网推荐场景那么大几万到几十万样本逻辑回归完全够用。树模型的优势在大规模和复杂交互下才明显在信贷这种强结构化数据上优势有限。我一般用逻辑回归做主线XGBoost 做对照实验如果 AUC 撑死只能领先 0.02就果断用逻辑回归可解释性带来的业务价值远比这一点精度值钱。3.2 用 Sklearn 训练逻辑回归最小可运行代码特征已经分箱并映射成 WOE 后逻辑回归的训练代码很短。核心工作是构造训练矩阵 X每个特征占一列列的值是 WOE。woe_dict来自上一节calc_woe的结果。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score features [age, income, debt_ratio, loan_num] # 用 WOE 映射构造训练特征矩阵 X pd.DataFrame() for feat in features: bin_col feat _bin woe_map woe_dict[feat][woe].astype(str).to_dict() X[feat] df[bin_col].astype(str).map(woe_map) X[feat] X[feat].fillna(X[feat].mean()) y df[default] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy) lr LogisticRegression(penaltyl1, C0.1, solverliblinear) lr.fit(X_train, y_train) print(训练AUC:, round(roc_auc_score(y_train, lr.predict_proba(X_train)[:, 1]), 4))这里把 WOE 字典的键转成字符串同时把分箱列也转成字符串再 map是因为pd.Interval对象在 pandas 版本间哈希行为有差异字符串最稳不容易出现 KeyError。stratifyy保证切分后的训练集和测试集里坏客户比例与全量一致这在样本不均衡时格外重要。penaltyl1让部分特征系数变成 0起到特征压缩的作用C0.1是正则强度C 越小惩罚越重。信贷特征之间相关性高默认的 C1.0 容易让系数发散我一般从 0.1 开始调。3.3 对照实验与调参XGBoost 只做上限参考跑一个默认参数的 XGBoost看看逻辑回归离精度上限差多少。真正上线前不会直接换树模型但你需要知道差距在什么量级from xgboost import XGBClassifier xgb XGBClassifier(n_estimators300, max_depth3, learning_rate0.1, subsample0.8, colsample_bytree0.8, eval_metricauc, random_state42) xgb.fit(X_train, y_train) print(XGB AUC:, round(roc_auc_score(y_test, xgb.predict_proba(X_test)[:, 1]), 4)) print(LR AUC:, round(roc_auc_score(y_test, lr.predict_proba(X_test)[:, 1]), 4))max_depth3是刻意压浅信用数据的有效特征量级不大太深的树很快开始记住噪声。colsample_bytree0.8是随机列采样缓解特征共线性。subsample0.8是行采样让每棵树看到的样本不完全一样防止过拟合。调参时我的习惯是先固定max_depth和learning_rate只调C和n_estimators不要一上来就网格搜索——风控数据的验证集很小网格搜出来的参数经常是玄学换个随机种子就变了。把逻辑回归的C从 0.01 到 0.5 跑一遍选测试集 AUC 最稳的那个值比盲目调 XGBoost 更有效。4. 从模型概率到信用评分卡分数映射与阈值划定4.1 为什么模型概率不能直接当信用分模型输出的违约概率比如 0.72业务人员没法直接理解。信用评分要的是统一刻度600 分和 700 分差多少对应什么风险水平说清楚。逻辑回归输出的概率本身是非线性的0.4 到 0.5 的差距和 0.1 到 0.2 的差距业务上不能等价处理。评分卡把概率空间映射到线性分数空间后每一分的风险含义是均匀的业务才能用同一个标准比较不同客群。标准评分卡公式基于逻辑回归的线性输出模型的线性部分可以写成ln(odds) β₀ β₁·WOE₁ β₂·WOE₂ ...其中 odds 是坏客户概率除以好客户概率。评分卡把 ln(odds) 线性映射到分数区间需要两个参数基准分和 PDO。常用设定是odds 为 50即坏好比 1:50时分数为 600 分分数每增加 20 分 odds 翻倍。也就是说650 分对应的 odds 是 200客户质量翻了一倍。这个映射里隐含了一个业务判断风险翻倍需要的分数差是固定值。20 分翻一倍还是 30 分翻一倍取决于你对风险定价的敏感度PDO 越小分数变动越敏感审批线附近的分差会被放大。4.2 映射公式与 Python 实现公式B PDO / ln(2) A 基准分 B × ln(基准odds) Score A - B × ln(odds)带入基准分 600、基准 odds50、PDO20B28.85A712.87。每个客户的分数等于把客户的 WOE 代入逻辑回归的线性部分再套这个公式。import math PDO 20 base_odds 50 base_score 600 factor PDO / math.log(2) offset base_score factor * math.log(base_odds) print(factor:, round(factor, 4), offset:, round(offset, 4))然后写一个打分函数对每个样本计算信用分def compute_score(row, lr_model, woe_dict, features, factor, offset): coef lr_model.coef_[0] intercept lr_model.intercept_[0] linear intercept for feat in features: bin_col feat _bin woe_map woe_dict[feat][woe].astype(str).to_dict() woe_val str(row[bin_col]) linear coef[features.index(feat)] * woe_val score offset - factor * linear return score sample df.iloc[0] score compute_score(sample, lr, woe_dict, features, factor, offset) print(该客户信用分:, round(score, 2))注意公式里的负号linear越大表示违约概率越高分数越低这是评分卡的语义。offset - factor * linear里的offset包含了基础分intercept也会被乘上 factor 扣掉所以不用单独处理截距。如果某个客户的某特征在训练分箱里没见过woe_map取不到值会返回 NaN整个分数直接变 NaN。这个问题我一般用总体 WOE 均值做回退也就是map之后fillna(woe均值)防止线上打分崩掉。4.3 每个特征的得分贡献与审批线实际业务里不仅要总分还要能解释扣分最多的是哪几个特征。把每个特征单独拿出来看贡献base_score_part offset - factor * lr.intercept_[0] feature_scores {} for feat in features: woe_val str(df.iloc[0][feat _bin]) woe_map woe_dict[feat][woe].astype(str).to_dict() coef_val lr.coef_[0][features.index(feat)] feature_scores[feat] round(-factor * coef_val * woe_val, 2)这里feature_scores每个特征的值就是该客户在这个特征上的得分贡献加总和基础分就是总分。打印出来一眼能看出负债率这个特征扣了 35 分这在业务解释里非常有用。审批线怎么定我的做法是先按总分分 10 档看每档的实际违约率df[score] df.apply(compute_score, axis1, args(lr, woe_dict, features, factor, offset)) score_band pd.cut(df[score], 10) band_badrate df.groupby(score_band)[default].mean() print(band_badrate)找到违约率开始快速抬高的分数段比如 650 分以下违约率从 2% 跳到 8%那审批线就定在 650 附近。这个线不是固定的客群质量变化后要复查一般按月看整体分位分布。评分卡的基础分和得分贡献是相对概念同一个模型用不同的基准分设定总分会整体平移但不影响排序。换句话说600 分对应的违约概率是多少取决于你设定的基准 odds不要把总分绝对化关键是看审批线附近的客群质量。5. 个人信用评估的常见问题与避坑手册五条踩坑记录5.1 正负样本严重失衡模型全预测正常现象训练集里坏客户只占 2%模型 AUC 虚高到 0.9但业务上捞不回坏客户审批通过率暴增。 原因逻辑回归的损失函数被多数类主导学到的最优边界就是把所有样本判成好客户。 解决用 SMOTE 过采样把训练集正负比例拉到 1:4 左右测试集不做任何处理保持真实分布。from imblearn.over_sampling import SMOTE sm SMOTE(sampling_strategy0.3, random_state42) X_res, y_res sm.fit_resample(X_train, y_train)sampling_strategy0.3是让坏样本变成好样本的 30%不要做 1:1过度平衡会让模型高估违约率、审批过严。random_state固定后复现结果稳定。忘记做样本均衡的话模型 Auc 再好看也不敢上线。5.2 时间穿越随机切分让模型偷看答案现象验证 AUC 很好上线之后 AUC 直接腰斩。 原因数据切分用了随机划分训练集和测试集来自同一时间窗。某些特征比如当月查询次数本质上包含未来信息模型学到的是数据泄漏。 解决按时间切分前 80% 的时间做训练后 20% 做测试。df_sorted df.sort_values(apply_date) cut_idx int(len(df_sorted) * 0.8) train_df df_sorted.iloc[:cut_idx] test_df df_sorted.iloc[cut_idx:]信贷建模里这个坑最致命因为随机切分下所有指标都好看一上线就现原形。判断标准很简单如果特征里任何字段是截至本月/本季的统计就必须按时间切分。我在第一个信用项目里就翻过车当时用随机切分 AUC 0.85上线后实际区分度不到 0.6查了三天才定位到是这个原因。5.3 One-Hot 编码在新客上崩了现象线上打分时某客户学历取值研究生在训练数据里没见过编码器报KeyError评分服务直接挂了。 原因训练时用OneHotEncoder转稀疏矩阵新类别的维度对不上。 解决类别字段全部用 WOE 编码未知类别落到一个默认值总体均值不会崩也不影响其他特征。这个经验就是信用评分场景里尽量别用 One-Hot它会把特征维度拉高还会让模型在旧类别上过拟合。5.4 分数堆积在中间审批线失去区分能力现象评分卡上线后发现 80% 的客户集中在 620 到 650 分审批线上下没有明显差异业务方不敢用。 原因分箱太细导致 WOE 波动大或者逻辑回归的正则参数 C 调得过小所有系数都被压扁特征得分贡献普遍低于 10 分。 解决检查每个特征的得分贡献跨度正常情况下至少在 20 分以上。如果不够把箱数减少到 4-5 个同时把 C 往 0.05 方向调一调让重要特征在评分卡里真正拉出分差。这里有个检查技巧直接打印feature_scores的绝对最大值小于 15 分的基本就是分箱太碎或者正则过强。5.5 上线一个月分数漂移业务不敢用现象客户平均分从 650 涨到 680审批通过率上升但逾期没降模型好像失效了。 原因客群结构变了比如新渠道带来了一批资质更好的客户模型本身没坏是输入分布和训练集不一致。 解决按月计算特征 WOE 分布和 PSI。PSI 低于 0.1 视为稳定0.1 到 0.25 需要关注超过 0.25 考虑重训。def calc_psi(expected, actual, bins10): expected_hist, edges np.histogram(expected, binsbins) actual_hist, _ np.histogram(actual, binsedges) exp_rate expected_hist / expected_hist.sum() 1e-6 act_rate actual_hist / actual_hist.sum() 1e-6 psi np.sum((act_rate - exp_rate) * np.log(act_rate / exp_rate)) return psinp.histogram的edges复用保证预期分布和实际分布的箱边界一致。分母加平滑项防止某个箱样本为 0 导致log(0)。PSI 不是模型指标是上线后的监控指标跑批脚本里应该常驻。6. 用 KS 和 AUC 做最终验收信贷模型上线前的最后一关模型训练完、评分卡也建好后最后要做的是验收。信贷场景我看两个指标AUC 和 KS。AUC 衡量整体排序能力KS 衡量排序靠前的样本能不能覆盖更多坏客户两者各有侧重。KS 的计算思路是按预测的违约概率降序排序逐行累计已捕获坏客户占比和误伤好客户占比两者差值的最大值就是 KS。信贷模型 KS 一般要求 0.3 以上0.4 以上就是很能分的模型。pred_proba lr.predict_proba(X_test)[:, 1] df_eval pd.DataFrame({prob: pred_proba, y: y_test}) df_eval df_eval.sort_values(prob, ascendingFalse).reset_index(dropTrue) df_eval[cum_bad] df_eval[y].cumsum() / df_eval[y].sum() df_eval[cum_good] (1 - df_eval[y]).cumsum() / (1 - df_eval[y]).sum() ks abs(df_eval[cum_bad] - df_eval[cum_good]).max() print(AUC:, round(roc_auc_score(y_test, pred_proba), 4)) print(KS:, round(ks, 4))注意排序方向必须按概率降序cum_bad和cum_good的差值才有意义。y 为 1 表示坏客户cumsum()除以总和就是累计占比。除了 KS 和 AUC我每次上线前还会做一次分数校准把样本按分数段分成 10 组计算每组的预测违约率和实际违约率画一张折线对比。如果实际违约率高于预测违约率说明模型概率偏低审批线要相应收紧如果普遍偏低说明模型把风险估大了可以适当放宽。我做个人信用评估的从业经验是建模时间只占整个项目一半另一半全花在特征质量验证和数据切分上。每次换数据集、换客群我都会把第 2 章的字段初筛和第 5 章的时间切分重新跑一遍这些基础工作永远不会白费。线下 AUC 跑得再漂亮都不如确认数据没有泄漏、PSI 稳定来得踏实。希望这篇笔记能帮你把这个方向真正跑通少走我当年走过的弯路。本文还有配套的精品资源点击获取