ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

逻辑回归评分卡实战:从WOE编码到KS验证的完整风控建模流程

逻辑回归评分卡实战:从WOE编码到KS验证的完整风控建模流程 简介一份基于Python的逻辑回归评分卡模型构建资源面向金融风控、信用评分及数据建模初学者适合毕业设计、课程设计、大作业或工程实训。资源围绕特征工程、证据权重编码、信息价值计算与特征筛选、特征证据权重化、评分卡建模五个核心环节展开输入筛选后特征即可自动输出评分结果帮助理解信用评分卡从数据准备到模型落地的完整流程。压缩包体积5.07MB共6个文件包括3个CSV格式数据集、1个Excel格式数据字典、1个Python建模脚本和1个Markdown说明文档结构与注释清晰便于按模块对照学习。目前已有152人学习下载。通过该资源可掌握逻辑回归评分卡的实现代码、配套数据与字段说明并可在替换数据、调整特征后迁移到自身项目是完成课设或入门评分卡建模的实用参考。1. 逻辑回归评分卡一张能上评审会的算分表而不是调参黑匣子用逻辑回归做评分卡是信贷审批、消费分期和信用卡额度管理里用得最久、也被监管和业务接受度最高的一套方案。它不像树模型那样把特征重要性藏在节点分裂里它的每一分都能解释客户最终得分 基础分 每个特征档位的得分而档位得分由该特征的 WOE 和逻辑回归系数共同决定。基于 Python 构建逻辑回归评分卡模型资源里已经准备好了训练集、测试集、数据字典和 score.py 主脚本从特征工程、WOE 编码、IV 特征筛选到评分输出是一条完整链路。适合正在做金融风控课程设计、毕业设计或者想自己动手复现一遍标准评分卡流程的从业者。整个过程最需要盯紧的不是 AUC而是分箱合理性、WOE 方向一致性和分数刻度换算。2. 从 cs-training.csv 到 WOE 输入特征工程先于模型我拆这类资源有个固定习惯先看文件清单再打开 README然后才是数据。score_logistic-master 的 data 目录下放着三个 csv 和一本 Excel 字典cs-training.csv 是训练集cs-test.csv 是测试集sampleEntry.csv 是最终评分结果输出模板Data-Dictionary.xls 是字段说明。结构上没毛病但真正决定评分卡能不能用的是数据字典里每一列的业务含义是否和建模设计对上。2.1 读文件cs-training.csv 与 Data-Dictionary.xls 一起看拿到压缩包以后我建议你别急着pip install pandas先把 Data-Dictionary.xls 翻开对一遍字段。评分卡有一个铁律目标变量必须是“未来发生的行为”特征变量必须是“申请时点已经知道的信息”。比如目标列是“过去两年是否出现 90 天以上逾期”那它的取值只允许是 0 或 1而年龄、收入、负债比例、历史逾期次数这些字段必须在客户进件时就能拿到。字段时序一旦错位后面 IV 算出来再高都是假的因为你在用未来预测未来。读 CSV 的时候有个细节容易踩坑很多评分卡数据集导出后第一列是行号pd.read_csv默认会把它读成一个叫Unnamed: 0的列。这个多余列如果不处理后面训练集和测试集做特征对齐时它会混进去导致特征数量、列名全部错乱。我的习惯是直接index_col0把这个列丢给 DataFrame 的索引import pandas as pd import numpy as np train pd.read_csv(data/cs-training.csv, index_col0) test pd.read_csv(data/cs-test.csv, index_col0) print(train shape:, train.shape) print(test shape:, test.shape) print(train missing rate:) print(train.isnull().mean().sort_values(ascendingFalse))这段代码的逻辑很简单isnull().mean()按列计算缺失比例并降序排列一张表就能看出哪些字段缺得厉害。train.shape则用来核对训练集和测试集的特征列是否一致列数对不上后面建模一定会报错。这里的列名以你下载的数据字典为准如果读到的是Unnamed: 0说明index_col0生效了不需要额外处理。2.2 缺失值填充与异常值截断先洗出干净样本评分卡数据集不是干净的实验数据缺失和极端值几乎必现。常见做法是分三档处理缺失率低于 5% 的列直接删除对应样本缺失率在 5% 到 20% 之间的列用中位数填充少数类别字段用众数缺失率超过 50% 的列直接放弃因为缺失本身已经能说明该字段上报质量太差填充出来的分布也是扭曲的。填充值我一般用中位数而不是均值。原因很直接月收入这类字段长尾严重均值会被少数极高收入样本拉上去用均值填充会让大部分普通客户缺失值落在偏高位置分箱时这一档的 WOE 就会失真。中位数对极端值的抗性更好也更容易跟业务解释“用中间水平收入代替未知收入”。missing_cols [monthly_income, num_of_dependents] for col in missing_cols: train[col] train[col].fillna(train[col].median()) test[col] test[col].fillna(test[col].median()) # 异常值截断年龄和逾期次数按业务常识圈定合理范围 train train[(train[age] 18) (train[age] 100)] train train[train[overdue_count] 80]fillna 那两行在训练集和测试集上必须用同一个值具体做法是先从训练集算好median()再同步填充到两边。如果训练集和测试集各自算中位数同一个特征在两个数据集里的填充口径就不一致了。后面的异常值截断overdue_count指历史逾期次数80 这个上界不是统计学算出来的是业务上“两年内逾期接近 80 次”本身就不合理我一般直接截掉。2.3 分箱连续变量离散化成有业务含义的档位为什么不把年龄、收入这些连续变量直接扔进逻辑回归一是逻辑回归默认特征是线性的年龄对风险的影响往往不是一条直线年轻人缺稳定收入、中间年龄段最稳、高龄客群健康风险上升这种 U 型关系在连续特征上拟合不好二是评分卡要给每个特征拆成“档位得分”比如“年龄 25 到 35 岁加 5 分、35 到 45 岁加 12 分”连续变量没法直接这么算。分箱的常见方案有等宽、等频、卡方分箱和决策树分箱。等宽对长尾分布不友好收入分布里绝大多数人集中在低区间等宽分箱会让高区间箱里空无一人卡方分箱要额外维护依赖库我实际项目里最常用的是等频分箱打底再用手动调整修正边界def cut_into_bins(df, col, bins5): df_tmp pd.DataFrame({x: df[col], y: df[target_col]}).dropna() try: # 优先等频分箱重复值太多时自动退化为等宽 bin_edges pd.qcut(df_tmp[x], bins, retbinsTrue, duplicatesdrop)[1] df_tmp[bin] pd.cut(df_tmp[x], binsbin_edges, include_lowestTrue) except ValueError: df_tmp[bin] pd.cut(df_tmp[x], binsbins) grouped df_tmp.groupby(bin, observedTrue)[y].agg([mean, count]) grouped[bad_rate] grouped[mean] return groupedbins5是对大多数评分卡特征比较稳的起点超过 10 箱会让单箱样本变薄WOE 波动变大。pd.qcut按样本量等频切分每个箱里样本数接近返回的bin_edges是真实切分点后续要把它固定下来用于测试集分箱。bad_rate是每箱坏样本占比如果分箱合理它应该呈单调或接近单调的趋势这也是后面做 WOE 之前最重要的体检项。3. WOE 编码与 IV 值计算先给特征排个信息量名次分箱完成后下一个动作是计算 WOEWeight of Evidence和 IVInformation Value。这两个指标是评分卡和普通逻辑回归建模之间最明显的分水岭普通逻辑回归直接吃原始特征评分卡则要求先把每个分箱映射成一个 WOE 值再让模型吃 WOE 化之后的特征。3.1 WOE 计算公式与方向约定WOE 衡量的是“某一个分箱里好样本相对集中程度还是坏样本相对集中程度”。我用一个一致性比较强的定义WOE_i ln( Good_i / Good_total ) − ln( Bad_i / Bad_total )在这个定义下 Good_i / Good_total 是该箱好样本占全体好样本的比例Bad_i / Bad_total 是坏样本占全体坏样本的比例。WOE 大于 0说明这个箱里好样本比坏样本更集中风险偏低WOE 小于 0则相反。计算函数我一般写成下面这样直接返回分箱明细同时算出单箱 IVdef cal_woe_iv(df, col, targettarget_col): df_tmp df[[col, target]].dropna() total_good (df_tmp[target] 0).sum() total_bad (df_tmp[target] 1).sum() grouped df_tmp.groupby(col, observedTrue)[target].agg([sum, count]) grouped[bad] grouped[sum] grouped[good] grouped[count] - grouped[bad] grouped[dist_good] grouped[good] / total_good grouped[dist_bad] grouped[bad] / total_bad grouped[woe] np.log(grouped[dist_good] / grouped[dist_bad]) grouped[iv] (grouped[dist_good] - grouped[dist_bad]) * grouped[woe] return grouped.sort_index()这里target默认取 1 为坏样本、0 为好样本。dist_good和dist_bad分别对应公式里的两个占比woe列是每一箱的证据权重iv列是单箱信息量把每箱的 IV 加起来就是整列特征的 IV 值。如果某个箱里dist_bad为 0np.log会得到inf这种情况不能简单加一个小数掩盖正确做法是先合并相邻箱具体见避坑章节。3.2 IV 值计算与筛选阈值是怎么来的IV 的计算公式是各箱(dist_good - dist_bad) * woe的累加值。可以把它理解成“每个分箱里区分能力的加权和”它解决了 WOE 只看相对比例、不看样本权重的问题。比如一个箱 WOE 很高但里面只有 2 个样本它对整体区分能力贡献就应该被降权。评分卡实践里对 IV 的参考阈值大体如下IV 范围预测能力 0.02几乎没有区分能力0.02 ~ 0.1弱0.1 ~ 0.3中等0.3 ~ 0.5强 0.5异常先检查变量泄漏这个阈值不是数学推导出来的是多年项目经验沉淀下来的刻度各家机构会微调。我做初筛时一般取 0.02 作为入选线特征数量特别多就抬到 0.05避免大量弱变量进模型把分箱结构变得很碎。批量计算时建议写一个循环iv_series pd.Series(dtypefloat) for col in binned_cols: grp cal_woe_iv(train_binned, col) iv_series[col] grp[iv].sum() iv_series iv_series.sort_values(ascendingFalse) print(iv_series) selected_cols iv_series[iv_series 0.02].index.tolist()iv_series是每个特征的总 IV 值按降序排列后能看到明显的头部效应。selected_cols是通过初筛进入建模的特征名列表后面所有环节都只围绕这些列展开。打印这一步值得保留它会暴露一种典型翻车现场某个特征的 IV 突然大于 0.5并不是因为它强而是因为数据里有泄漏。3.3 特征 WOE 化训练集测试集必须用同一张映射表特征 WOE 化就是把每个分箱对应的 WOE 值映射到样本上。举个例子年龄字段分箱后分成“18-25”“26-35”“36-45”三箱它们各自有一个 WOE样本年龄落在哪一箱就用那一箱的 WOE 作为该样本在这个特征上的取值。模型看到的不再是“年龄 23”而是“年龄 23 所在的档位其证据权重是 -0.35”。这一步最常见的错误是在训练集和测试集上分别做分箱、分别算 WOE。测试集一旦重新切分箱箱边界就变了同一个客户在训练集落在“26-35”这一箱到了测试集可能变成“36-45”分数当然对不上。正确的做法是先固定训练集上分出的bin_edges和woe_map测试集只用它们做映射woe_map_dict {} for col in selected_cols: grp cal_woe_iv(train_binned, col) woe_map_dict[col] grp[woe].to_dict() for col in selected_cols: train_binned[woe_ col] train_binned[col].map(woe_map_dict[col]) test_binned[woe_ col] test_binned[col].map(woe_map_dict[col]) assert train_binned[woe_ col].notna().all(), f{col} 训练集存在未映射分箱 assert test_binned[woe_ col].notna().all(), f{col} 测试集出现训练集未见过的分箱woe_map_dict保存的是“分箱编号 - WOE 值”的字典map按这个字典替换到新列。两个assert是硬校验只要map返回 NaN说明样本所在的分箱在训练集分箱结构里不存在此时要回去查是不是分箱边界没有正确落到区间上。现实中测试集出现新箱通常发生在样本量太小或者时间窗口漂移时越早暴露越好。4. 逻辑回归训练与评分卡刻度offset、factor 和最终算分表特征完成 WOE 化之后理论上就可以训练逻辑回归了。但这里有个容易误解的地方评分卡项目的重点不在训练环节调参而在于把逻辑回归的 logit 输出精准地映射成一个整数分数同时保证这个分数在业务上具备稳定的刻度含义。4.1 先用 sklearn 把系数方向跑出来建模这一步我一般直接拿sklearn.linear_model.LogisticRegression跑初始版本不急着调C和penalty。倒不是因为 sklearn 比其他工具优越而是它的默认接口稳定、文档全适合做渠道基准。真正要盯的是系数符号而不是训练精度。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split feature_names [woe_ col for col in selected_cols] X train_binned[feature_names].fillna(0) y train_binned[target_col] clf LogisticRegression(penaltyl2, C1.0, solverliblinear) clf.fit(X, y) coef_df pd.DataFrame({feature: feature_names, coef: clf.coef_[0]}) print(coef_df.sort_values(coef)) base_intercept clf.intercept_[0]solverliblinear适合中小规模的二分类场景C1.0是正则强度的默认值先不做网格搜索。coef_数组对应每个特征 WOE 的系数把系数按正负排序后和 3.1 节的 WOE 方向约定对照一遍在 WOE 采用的是 good/bad 占比定义时逻辑回归里的系数应该是负的因为高 WOE 的箱意味着坏样本占比更小直接作用于坏概率时方向为负。如果某列系数符号单调地反了不要继续往下做先回 5.1 查目标变量定义。4.2 评分卡刻度PDO、Offset 和 factor 怎么定评分卡最终输出不是概率而是整数分。从 logit 到分数需要两个刻度参数factor是斜率offset是截距。行业里常用的设定方式是 PDO也就是“odds 翻倍时分数变化的点数”。假设基准逻辑违约比值 odds_bad p / (1 - p)。我们希望 odds_bad 翻倍时分数降低 PDO 分因为违约概率越高分数越低。再设违约概率 p 0.5也就是 odds_bad 1 时基础分是 600 分。这个“600 对应 odds1”是非常常见的初始设定。import math PDO 50.0 base_score 600.0 factor PDO / math.log(2) offset base_score factor * math.log(1.0)PDO 50表示违约比值每翻一倍分数降 50 分factor 50 / ln(2)约等于 72.13它是分数对 logit 的缩放比例offset在 odds_bad1 时等于 600和基础分一致。实际项目中 PDO 常见取值是 20、50、100取值越大幅度变化越剧烈需要结合审批通过率和分数分布调试。4.3 把模型输出变成基础分加各变量得分有了offset和factor评分公式就可以拆开了Score offset − factor × (β0 Σβ_i × WOE_i)拆成“常量部分”和“每个变量贡献部分”之后就得到一张评分卡算分表intercept_score offset - factor * clf.intercept_[0] part_score_dict {} for i, col in enumerate(feature_names): part_score_dict[col] -factor * clf.coef_[0][i] def score_one_row(row): score intercept_score for col in feature_names: score part_score_dict[col] * row[col] return round(score) train_binned[score] train_binned.apply(score_one_row, axis1) test_binned[score] test_binned.apply(score_one_row, axis1)intercept_score是基础分它包含 offset 和截距两项对每个客户一样。part_score_dict是每个woe_*列对应的分数权重乘以该客户这一列的 WOE 值就是该特征对最终分数的贡献。从分数构成可以看出评分卡本质上没有离开逻辑回归的线性框架只是把 lr 的系数和 WOE 组合重新包装成了业务能读的加分项。资源里的 score.py 一般会把这张算分表固化下来实际线上使用时不再重复训练只做字典映射和累加。提示如果某个特征得分出现超大正值或负值先检查这一箱的 WOE 是不是在某个异常箱上比如好样本或坏样本占比极端。分数异常通常不是算分公式的问题而是上游分箱问题。5. 评分卡常见问题排查五条翻车现场复盘评分卡开发流程每一步都可能埋雷下面这五条是我见过最高频、也最值得提前加防护的翻车类型。每条我都按“现象 - 原因 - 解决”的顺序写照着这个思路排查能省掉大量重复试错时间。5.1 好/坏样本定义错位WOE 和系数符号全反过来现象score.py 跑完分数低的人群里反而好客户比例更高KS 曲线方向是倒的clf.coef_的符号和业务认知全部相反。原因目标列 1/0 的定义和建模设计相反。比如业务文档里写“1 表示正常还款”你却把 1 当成坏客户输入模型所有 WOE 的分子分母、逻辑回归系数的方向都会整体反转。这个错误在代码层面不报任何异常只有对照业务时才会暴露。解决先翻开 Data-Dictionary.xls和 README 里对目标变量的描述对齐。我习惯在分箱函数后面加一个方向校验保证 WOE 随风险等级变化的方向符合预期def check_woe_direction(grp): first_woe grp[woe].iloc[0] last_woe grp[woe].iloc[-1] return first_woe last_woe如果返回 False说明整体趋势反了直接停止后续步骤回去核对目标列。这个断言在每次更换数据集或重评分时都应该重跑一遍。5.2 空箱导致 WOE 无穷大IV 直接爆掉现象计算 WOE 时出现inf或nanscore.py在训练中途抛出 ValueError或者总 IV 大到一个不科学的数值比如单个特征超过 1。原因某个分箱里好样本数或坏样本数为 0分母为 0。比如分箱切得太细样本分布不均匀某一箱里全是坏客户dist_bad占比不等于 0但dist_good等于 0np.log(0)就变成负无穷。解决不要靠加0.000001这种微小值来兜底那只是把问题藏起来。正确做法是合并相邻空箱或者把样本数量低于某个下限的箱并入相邻区间。实际项目里我会设一个最小样本量门槛比如单个箱至少 50 个样本否则自动合并保证 WOE 计算稳定。5.3 IV 高得离谱的特征先确认没有变量泄漏现象某个特征一眼扫过去业务含义很普通但 IV 高达 1.8单独用它建模 AUC 接近 0.98总感觉哪里不对劲。原因这个特征很可能间接包含了目标结果信息。常见泄漏场景包括用“当期是否逾期”去预测“未来是否逾期”用贷后表现反推的衍生变量外部数据源里的某个标志位其实就是本行违约名单。解决回溯特征生成逻辑按时间窗口画一条线特征生成必须早于目标观察期。对合法数据集里的高标准差特征也要做一次人工抽数核对确认它不是从结果列拷贝来的。IV 大于 0.5 的特征在风控评审会上一定会被追问来源提前准备好解释口径。5.4 训练测试时间窗口不一致测试集分数整体漂移现象训练集 KS 0.40测试集重算只有 0.33分数分布整体向高端偏移业务同事反馈通过率比预想高。原因cs-training.csv 和 cs-test.csv 如果来自不同时间窗口客群构成、宏观环境、审批策略都可能不同。训练集里学到的最佳分箱在测试集上未必复现同样的分布。解决先确认两个数据集的时间范围再做窗口对齐。分箱边界固定用训练集的测试集只做映射不能重新算 WOE。上线前跑一次 PSI如果 PSI 超过 0.25 就要重新审视离线验证的结论不能直接拿一个来自漂移分布的数据集作为测试基准。5.5 类别不平衡直接硬跑分数分布整体偏高现象坏样本占比很低比如只有 4%逻辑回归训练完分数集中在 650 到 720 区间审批 cut-off 附近几乎没有区分度。原因模型在严重不平衡数据上会偏向多数类“好客户”输出概率整体偏低对应分数整体偏高。逻辑回归本身依然保序但如果不做任何处理分数刻度会整体偏移。解决在LogisticRegression里加class_weightbalanced或者手动给坏样本提高权重。评分卡测试期我更推荐加class_weight因为它不破坏线性结构系数和 WOE 的逻辑关系不变后续算分公式照常使用。加了权重之后分数分布会重新回到一个相对合理的区间再重新看 KS 和 PDO 是否需要调整。6. 用 KS 和 PSI 做上线前验证区分度与稳定性两个硬指标评分卡上线前的验证动作我基本收敛到两个指标KS 看区分度PSI 看稳定性。KS 的全称是 Kolmogorov-Smirnov用在评分卡里就是按分数降序排列后累计坏样本占比与累计好样本占比的最大差值。它直接回答一个问题把分数从低到高排序能不能有效把坏客户往前排def ks_value(y_true, score, bins20): df pd.DataFrame({y: y_true, s: score}).sort_values(s) df[bin] pd.qcut(df[s], bins, duplicatesdrop) agg df.groupby(bin, observedTrue)[y].agg([sum, count]) agg[cum_bad] agg[sum].cumsum() / agg[sum].sum() agg[cum_good] (agg[count] - agg[sum]).cumsum() / (agg[count] - agg[sum]).sum() return (agg[cum_bad] - agg[cum_good]).abs().max()这段代码把样本按评分从低到高排成 20 箱分别计算累计坏样本占比和累计好样本占比取差值的最大值。KS 在 0.2 以下基本不可用0.3 到 0.5 是评分卡比较理想的区间超过 0.5 反而要警惕是不是特征泄漏了。PSI 衡量的是训练集和上线后实际样本的分数分布偏移程度。PSI 小于 0.1 表示稳定0.1 到 0.25 属于中度漂移需要跟踪超过 0.25 就必须回到特征端排查。计算方法也不复杂把期望样本和实际样本按同一个分数区间分箱逐箱计算占比差异的对数加权和。我早年做评分卡只看 KS模型在验证集上表现不错就直接提上线结果客群迁移之后 PSI 飙到 0.31审批通过率一夜之间变了。从那以后每次动完分箱或者换数据源我都会强制走一遍同一套流程先跑 KS 看区分度再跑 PSI 看稳定性最后输出一张训练集和测试集的分数分布表给业务看一眼再决定是否上线。评分卡的优势本来就不是炫技而是让风控决策经得起追问。这套习惯建议你也直接复用到自己的项目里希望帮到你。本文还有配套的精品资源点击获取
返回列表