ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

逻辑回归信用评分卡建模全流程:从WOE编码到分数刻度转换

逻辑回归信用评分卡建模全流程:从WOE编码到分数刻度转换 简介这是一份基于逻辑回归算法的信用评分模型构建完整流程资料包面向希望学习评分卡模型的编程学习者也可用于毕业设计、课程实践或实训项目。项目以典型的个人信用数据为样例覆盖数据预处理与特征处理、变量证据权重转换、信息价值计算与特征筛选并基于选定特征自动生成评分结果便于读者理解从原始数据到评分输出的全链路。资源共10个文件以代码脚本、数据集、说明文档和字段字典为主要构成另含三个备份文件整体压缩包约7.68MB其中说明文档提供使用指引字段字典帮助理解变量含义代码脚本完整呈现数据预处理、证据权重变换与评分模型训练过程。目前已有41人学习浏览这份资料可帮助读者快速搭建信用评分模型实验环境通过阅读说明与运行主程序即可复现建模流程也适合作为金融风控入门或课程作业的改造基础。1. 逻辑回归信用评分二十年前的算法为什么还在风控主力位置信贷审批这个场景里深度学习喊了好多年XGBoost、LightGBM 也能轻松跑出更高的 AUC但你去翻银行和消费金融公司的核心审批模型逻辑回归依然是绝对主力。这不是保守而是被监管要求和业务可解释性逼出来的选择。监管要求你解释清楚“为什么拒绝这个客户”逻辑回归的每个变量系数都能摊开给审计看换成黑匣子模型解释成本高到业务无法接受。基于逻辑回归算法的信用评分模型构建方法核心思路是把客户违约概率映射成一张 300 到 900 分的评分卡分数越高风险越低审批、定价、额度都从这个分数出发。本文适合信贷风控建模、数据分析岗的从业者也适合刚转行想做评分卡的读者跟着走完一套从数据到刻度转换的完整流程。2. 数据准备与观察期设计标签定义错了后面模型做得再漂亮也白搭2.1 观察期、表现期与好坏客户定义评分卡建模的第一步不是选算法而是定标签。大部分翻车项目都栽在这里好坏客户的定义不统一直接导致模型学到的规律是错的。行业里的通用做法是把客户的时间线切成两段——观察期和表现期。观察期用来提取特征一般取 12 到 24 个月表现期用来定义“好”和“坏”一般取 12 个月。具体窗口得看产品期限。我做过一个 12 期分期产品放款后表现期取 12 个月基本能覆盖整个贷后周期如果是 36 期大额产品最好取 18 到 24 个月。观察期取太长早期数据格式和现在不一致特征覆盖率和质量会变差取太短时序特征拉不出来。坏客户的定义一般取“表现期内出现过 M3”也就是逾期超过 90 天。好客户是表现期内没有任何逾期记录。中间档——逾期 1 到 2 期但未到 90 天的叫灰区客户建模时通常剔除因为这类人行为不稳定放进去会干扰模型。另外表现期不足 12 个月的客户也剔除不然相当于考试没考完就打分。实际操作用 SQL 就能实现慢的是梳理数据源。要排除几类客户放款前已经逾期的、在观察期内额度被冻结的、已经销户的、循环授信但从未用款的。这些样本的特征要么失真要么客群属性特殊留在训练集里会把模型往歪了带。我做第一张评分卡的时候就吃过亏没排除销户客群结果模型对“额度使用率”这个变量的系数符号和业务直觉完全相反。2.2 特征粗筛用 pandas 快速过滤垃圾变量数据源一般有人行征信、内部还款行为、第三方数据。拿到的原始特征表可能有五六百列不能全部进模型。先做一轮粗筛过滤掉覆盖率太低、单一值占比太高的变量这个步骤处理几千个变量也就是几秒钟的事。import pandas as pd import numpy as np df pd.read_csv(raw_features.csv) # 假设原始特征表长这样 # 覆盖率每个特征非空的比例 coverage df.notnull().mean() # 单一值占比取值唯一的列没有区分能力 nunique_ratio df.nunique() / len(df) # 经验阈值覆盖率60%单一值占比95% keep_cols coverage[(coverage 0.6) (nunique_ratio 0.95)].index.tolist() # 对固定值列做二次确认比如某列全为0直接剔除 constant_cols [c for c in df.columns if df[c].nunique() 1] print(剔除固定值列:, constant_cols) df_filtered df[keep_cols] print(f原始特征: {df.shape[1]} 列 - 粗筛后: {df_filtered.shape[1]} 列)这段代码里notnull().mean()计算出每列的覆盖率nunique() / len(df)计算出唯一值占比。覆盖率 60% 这个阈值不是死的征信类变量可以放宽到 40%因为征信字段本身缺失就代表客户没有信贷记录可以单独编码为“无记录”这一档不一定要删除。单一值占比 95% 也是经验值如果一个变量 95% 以上的样本都取同一个值这个变量对细分客群几乎没有贡献。粗筛后一般剩下 200 到 300 个变量进入 WOE 编码阶段。2.3 样本切分按时间切不要随机切训练集、验证集、测试集的划分是评分卡建模里最容易犯低级错误的一步。很多工程师习惯直接train_test_split(random_state42)随机切分但金融数据是强时序的随机切会把同一时期的样本同时分进训练集和测试集导致模型“偷看”到未来的分布信息。我一般按放款月份切比如观察期从 2022 年 1 月开始表现期 12 个月那么训练集取 2022 年 1 月到 2023 年 6 月放款的客群验证集取 2023 年 7 月到 12 月放款的客群留一段 OOT跨时间验证集取 2024 年上半年。这样模拟的是“用过去的数据预测未来”的真实场景。如果随机切分测试集的成绩会虚高 3 到 5 个百分点的 KS上线后马上现原形。3. WOE 编码与 IV 筛选把非线性关系变成逻辑回归能消化的输入3.1 为什么不能直接喂原始特征逻辑回归本质是一个线性模型log(p/(1-p)) β0 β1x1 β2x2 ...。它假设特征和违约对数几率之间存在线性关系。但真实业务里风险变量几乎都是非线性甚至 U 型的。举一个最典型的例子——年龄二十出头的年轻人收入低不稳定违约率高三十到四十岁事业上升期违约率低五十岁以上随着年龄增长收入下降违约率又重新抬上去。直接把年龄原始值丢进逻辑回归模型只能拟合一条直线完全抓不住 U 型规律。解决办法是对连续变量分箱然后用 WOEWeight of Evidence证据权重替换原始值。分箱后的每一箱计算一个 WOE 值它的本质是该箱内坏客户占比和好客户占比的对数比。这样做有两个好处一是把变量和目标之间的关系强制转换成单调或接近线性的形式逻辑回归拟合更稳二是分箱本身能平抑异常值——极端年龄的样本被归到边界箱里不会因为单个极端值拉偏整个拟合。3.2 分箱方法怎么选分箱方法常见的有三种等频分箱、卡方分箱、决策树分箱。等频分箱按分位数把样本切成 n 份最简单但容易把风险分布相似的样本硬拆开卡方分箱自底向上合并相邻箱让箱间坏账率差异最大化是评分卡的主流做法决策树分箱直接对特征和目标跑一棵小树用树的切分点当分箱边界效果也不错但要注意防止过拟合。不管用哪种方法分箱后必须检查每个箱内同时包含好客户和坏客户。如果某一箱全是坏客户WOE 算出来是无穷大模型直接崩。一般建议最终箱数控制在 3 到 5 箱每箱样本占比不低于 5%。分箱调整是个反复尝试的过程我一般先用等频分 5 箱看坏账率趋势如果趋势杂乱再换卡方分箱最后手动合并业务含义相近的箱——比如学历字段的“初中”和“小学”合并成“初中及以下”。3.3 WOE 与 IV 的计算一段可以直接抄的代码WOE 和 IV 的计算逻辑不复杂但手工写容易错在分母边界的处理上。下面这段代码可以直接跑输入一个特征和标签输出分箱明细、WOE 和 IV 值。import pandas as pd import numpy as np def calc_woe_iv(df, feature, target): 计算单个变量的WOE和IV df: 数据框, feature: 特征名, target: 0/1标签 返回分箱明细表和总IV值 data df[[feature, target]].copy() data data.dropna(subset[feature]) # 先用等频分5箱, 变量类型为数值型 try: data[bin] pd.qcut(data[feature], q5, duplicatesdrop) except ValueError: # 如果数值离散程度太低, 退化为按取值分箱 data[bin] data[feature].astype(str) # 统计每箱好坏客户数 grouped data.groupby(bin, observedTrue)[target].agg( badsum, cntcount ).reset_index() grouped[good] grouped[cnt] - grouped[bad] # 全局好坏人总数 total_bad grouped[bad].sum() total_good grouped[good].sum() # 坏占比、好占比、WOE、IV grouped[bad_dist] grouped[bad] / total_bad grouped[good_dist] grouped[good] / total_good # 分母加极小值防止除零 grouped[woe] np.log(grouped[bad_dist] / (grouped[good_dist] 1e-10)) grouped[iv] (grouped[bad_dist] - grouped[good_dist]) * grouped[woe] total_iv grouped[iv].sum() return grouped, total_iv # 使用示例: 计算 age 变量的 WOE 和 IV bin_table, iv_value calc_woe_iv(df, age, is_bad) print(fage 变量的 IV {iv_value:.4f}) print(bin_table[[bin, cnt, bad, good, woe, iv]].round(4))代码里重点看两个地方一是bad_dist / good_dist的比值计算分子分母都除以全局总数得到的是分布占比而不是绝对值这样不同区间的客群量差异不会主导 WOE 值二是除零保护1e-10当某箱内好客户数为 0 时 WOE 会变成无穷大加了这个保护后 WOE 会是一个很大的负数而不是 NaN。qcut遇到取值重复过多会报错所以包了一层try-except退化为按分类值分箱。3.4 IV 分级标准与变量筛选IVInformation Value信息量是 WOE 的加权求和衡量一个变量对好坏客户的区分能力。经验分级标准如下IV 范围预测能力处理建议 0.02几乎无剔除0.02 ~ 0.1较弱可选视模型需要0.1 ~ 0.3中等保留0.3 ~ 0.5强保留但做泄漏检查 0.5可疑地强优先怀疑变量泄漏最后一行是血泪经验。IV 超过 0.5 的变量先别高兴大概率是特征里混进了表现期的信息。比如“最近三个月是否逾期”这个特征如果取数窗口覆盖到表现期内IV 通常会超过 0.8因为它直接剧透了结局。后面避坑章节会展开讲。变量经过 IV 初筛后还要做相关性筛查。逻辑回归对强相关变量很敏感两个 IV 都是 0.2 的变量如果相关性超过 0.6模型系数会被拉扯得不成样子。常见做法是计算皮尔逊相关系数矩阵对相关性高的变量对保留 IV 更高的一方或者用 VIF方差膨胀因子做多变量共线性诊断VIF 大于 10 的变量逐个剔除。4. 训练逻辑回归与调参三个真正需要人盯的参数4.1 样本不平衡优先用 class_weight不要急着上采样信贷场景里坏客户占比通常在 1% 到 5%正负样本天然不平衡。XGBoost 对这种不平衡有较好的鲁棒性但逻辑回归的概率估计会偏需要显式处理。最省事的方式是设置class_weightbalancedsklearn 会自动按样本量反比调整类别权重。少数类样本的权重变大损失函数会更关注坏客户模型会更愿意学习违约特征。有人喜欢用 SMOTE 过采样把坏样本复制到和好样本一样多我不建议用在评分卡场景。原因是 SMOTE 在特征空间中插值生成“虚拟客户”这些客户在真实数据里根本不存在模型在高维特征空间里的决策边界会被伪造样本带偏评分分布会变形。尤其当特征里有类别变量时插值可能生成“研究生学历且月收入 2000 元”这种矛盾样本。逻辑回归本身对类别不平衡不算特别敏感class_weight加分层采样基本够用。4.2 solver、C 和 penalty先跑起来再调优逻辑回归在 sklearn 里需要设置的参数不多但选错 solver 会导致收敛慢甚至模型训练直接不收敛。我一般用liblinear原因就一个它在中小数据规模上是坐标下降法稳定性好不依赖数据标准化评分卡场景下大多数时候是几百个变量几万个样本liblinear最合适。lbfgs和newton-cg在大数据上更快但遇到特征量纲差异大的情况容易收敛到局部次优解还得先初始化参数比较麻烦。C是正则化强度的倒数数值越小表示正则越强、模型越简单。逻辑回归的调参 80% 的精力都花在这个参数上。网格搜索范围一般从 1e-3 到 1e2 按对数等距取值结合 5 折交叉验证选最优。一段可以直接跑通的训练代码from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.metrics import roc_auc_score import pandas as pd # 假设 X 是 WOE 编码后的特征矩阵, y 是 0/1 标签 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 基础模型: liblinear L2, C 从 0.1 起步 model LogisticRegression( solverliblinear, # 坐标下降法, 适合中小样本 penaltyl2, # 默认L2正则, 防止系数爆炸 C0.1, # 正则强度倒数, 越小模型越简单 class_weightbalanced, # 自动处理样本不平衡 max_iter200, # 迭代次数, 不收敛时加大 random_state42 ) model.fit(X_train, y_train) # 先用测试集看一版基线 y_prob model.predict_proba(X_test)[:, 1] print(f基线模型 AUC: {roc_auc_score(y_test, y_prob):.4f}) # 网格搜索调 C param_grid {C: [0.001, 0.01, 0.1, 1, 10]} grid GridSearchCV( LogisticRegression(solverliblinear, penaltyl2, class_weightbalanced), param_grid, cv5, # 5折交叉验证 scoringroc_auc, n_jobs-1 ) grid.fit(X_train, y_train) print(f最优C值: {grid.best_params_}) print(f交叉验证AUC: {grid.best_score_:.4f})网格搜索跑完后用最优参数重新拟合然后就在测试集上报 AUC 和 KS。这里注意一个容易犯的毛病——网格搜索直接在里面加了class_weightbalanced逻辑回归的输出概率在加权后并不代表真实违约概率它是“加权世界”里的概率。如果评分卡刻度转换时需要用到概率对应的 odds后面就要做校准。这个坑在第 5.5 节专门说。4.3 系数符号一致性检查模型训练完第一件事不是看 AUC而是看系数符号。把每个变量的系数打印出来逐个人工核查收入越高违约率应该越低所以 IO月收入的系数应该为正负债收入比越高风险越高系数应该为负如果某个变量的系数符号与业务常识相反这个变量要么和别的变量存在强共线性要么分箱方向搞反了。一个经典例子如果“信用卡额度使用率”这个变量分箱做反了——把使用率高风险高的箱算出来的 WOE 是负值而使用率低风险低的箱算出来是正值那么模型学到的关系一开始就是反的。这不是模型的错是数据处理的错。每次训练完打印系数对着业务含义逐条核对这一项不能省。4.4 评分卡模型的专业评估指标评分卡模型很少报道准确率因为样本不平衡下全预测好客户都能拿到 95% 以上的准确率没有意义。我们看的是排序能力AUC 越高好坏客户的区分能力越强KS 是累计好客户比例减累计坏客户比例的最大差值行业里评分卡 KS 在 0.3 到 0.45 为合格超过 0.5 的模型先别高兴先检查是不是泄漏了。另一个指标是提升度Lift衡量模型筛出的最差 10% 客群的坏样本密度是整体的多少倍这个指标业务方最爱看因为它直接对应“拒绝掉这批人能躲开多少坏账”。5. 常见踩坑与排查五个让评分模型翻车的真实场景5.1 变量泄漏训练 AUC 高得离谱的第一嫌疑人现象训练集 AUC 跑到 0.92测试集也有 0.88看起来是个完美模型。但上线后评分卡的区分度断崖式下跌几个核心变量的贡献方向也和业务直觉对不上。原因特征里混进了表现期的数据。这是评分卡建模事故里最典型的一类。我从一家机构接过一张复盘报告建模特征表里有一个“最近 3 个月催收次数”的变量取数时间范围覆盖到了表现期催收次数越多代表客户已经逾期甚至失联了用它去预测违约等于把答案写进了考卷。所有 AUC 虚高到 0.85 以上的模型第一嫌疑人永远是泄漏。解决做特征清单时每个特征必须标注数据截止时间点确保全部早于观察点。排查方法很粗暴但有效——打印出 IV 排名前 30 的变量逐个问“这个变量的数据是从哪天开始的覆盖到哪天”。凡是不确定来源的特征直接下线重取。也可以做“时间切分验证”只在 2022 年放款样本上训练用 2023 年样本测试如果 AUC 大幅跌落 15% 以上基本可以断定存在时间维度的泄漏。5.2 样本分布偏移客群变了模型没跟上现象模型上线时 KS 有 0.35半年后降到 0.25再往后坏账率明显抬升模型的分数分布整体向“偏好”偏移。原因审批策略调整了。比如引入了一个新线上渠道来的客群比历史训练集年轻、收入结构不一样。模型在旧样本上学习到的评分逻辑应用到新客群时出现了系统性偏差。这是逻辑回归评分卡的老问题——模型本身是静态的业务策略却在不断变化。解决上线前就建立 PSIPopulation Stability Index监控看板按月计算每个关键变量的 PSI。PSI 小于 0.1 表示漂移可忽略0.1 到 0.25 要预警并回溯特征分布超过 0.25 就要准备重新训练模型了。另一个技巧是训练集里混合多个渠道的客群而不是只取单一渠道样本让模型的适用面更宽。5.3 共线性导致系数符号反转业务常识和专业模型打架现象模型输出显示“月收入”变量的系数是负的意思是收入越高违约风险越大。业务方看到这个结果直接炸毛评分卡没法用了。原因月收入与负债率、授信额度、消费金额等多个变量强正相关。逻辑回归的多变量拟合会把总风险分摊到相关变量上如果月收入与其他变量的相关性超过了合理范围回归算法分配系数时可能给它硬塞一个负号用来补偿其他变量的估计。这在统计学上叫“符号反转”系数的方向不再代表真实的边际效应。解决建模前一定要做相关性筛查保留相关系数绝对值小于 0.6 的变量组合训练后立即检查系数符号一致性。如果发现符号反转先看这个变量和相关变量的 VIFVIF 大于 10 就剔除其中一个变量或者把这两个变量做一个合成变量——比如把“月收入”和“月消费”合成“结余率”既保留业务含义又规避共线性。5.4 拒绝推断缺失模型只见过好学生现象评分卡上线审批时发现被拒绝的客户里有一部分人如果真的放款了其实不会违约但模型死活给不出通过分。原因训练样本来自过去已经审批通过的客户被拒绝的客户没有真实标签模型从未见过“差客群”的真实分布。换句话说模型是在幸存者数据上训练的天然学不到被拒绝样本的特征规律。这个问题叫拒绝推断Rejection Inference在行业里存在了很多年标准的朴素做法是给拒绝样本打一个推测标签重新加权后加入训练集。解决如果拒绝样本覆盖比例不高可以先用简单推断法用当前模型给历史拒绝客户打分将分数低于某个阈值的客户标记为“虚拟坏客户”以较小权重比如 0.5放进训练集防止模型过度学习推测标签。如果机构数据积累充足更高级的做法是两阶段模型或者用外部征信分对比推断。这个问题的根源还是数据收集策略——建议机构在小流量产品上跑一段“无审批策略”的随机授信实验积累真正的全客群数据。5.5 概率校准偏差加权训练后的概率不能直接用现象模型给客户的违约概率是 5%但事后回看同分数段客群的实际坏账率是 12%模型整体低估了风险风险定价和额度授信都偏激进。原因class_weightbalanced在训练时人为改变了类别分布模型输出的概率并非真实世界的违约概率而是“加权样本空间里的概率”。把分布还原回真实比例后概率需要重新校准。解决两种处理方式一是直接在训练后重新拟合截距固定所有回归系数在真实好坏比样本上重新训练一个只含截距项的逻辑回归二是在评分卡刻度转换时用一个基准 odds 把概率映射到分数基准 odds 由业务实际的坏账率倒推而不是训练集的坏账率。如果对概率的绝对值要求不高只用分数排序不做校准也影响不大但风险定价场景必须校。提示以上五条基本覆盖了评分卡建模 80% 的线上事故。每一条都在真实项目里发生过且都不是靠更复杂的算法能解决的靠的是流程纪律。6. 把系数转成分数PDO 刻度公式与手工落地模型训练完成、验证通过交付给业务方的不是“这个客户逾期概率是 12%”而是一张 300 到 900 分的分数卡。这步叫刻度转换核心公式是Score offset - factor * ln(odds)其中odds是好坏比p / (1-p)可以直接从逻辑回归的输出概率换算。factor由 PDOPoints to Double Odds决定factor PDO / ln(2)如果设定基准分 600 分对应好坏比 1:50PDO 为 20 分即分数每增加 20 分好坏比翻一倍那么factor 20 / ln(2) 28.85再反推offset 600 28.85 * ln(50) 712.9。有了这两个常数任何客户的违约概率 p 都能直接换算成分数。变量侧的落地方式更直白每个分箱的 WOE 乘以该变量的逻辑回归系数再乘以factor就是该变量在这一档位的分数贡献。把所有变量的贡献加总再补上基准常数就是总分。我通常在 Excel 里维护一张映射表——每一行是一个变量的一个分箱包含 WOE 值、模型系数、分数贡献业务方可以随时审计。我现在的习惯是评分卡上线前不做完最后一轮“分数分布回测”就不签字。具体做法是取最近三个月的放款样本按分卡算分数看每个分数段的实际坏账率和分数是否单调、有没有异常断崖。单调性是底线断崖往往意味着某个变量的分箱边界有问题。最后再回过头用一段时间的 OOT 数据验证 KS 和 PSI两边数据都对上了才敢交付投产。评分卡建模这个活七分在数据和流程纪律三分在模型本身——把这个想明白了模型的坑基本都能绕开。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进