简介:一份基于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_col=0,把这个列丢给 DataFrame 的索引:
import pandas as pd import numpy as np train = pd.read_csv('data/cs-training.csv', index_col=0) test = pd.read_csv('data/cs-test.csv', index_col=0) print('train shape:', train.shape) print('test shape:', test.shape) print('train missing rate:') print(train.isnull().mean().sort_values(ascending=False))这段代码的逻辑很简单:isnull().mean()按列计算缺失比例并降序排列,一张表就能看出哪些字段缺得厉害。train.shape则用来核对训练集和测试集的特征列是否一致,列数对不上,后面建模一定会报错。这里的列名以你下载的数据字典为准,如果读到的是Unnamed: 0,说明index_col=0生效了,不需要额外处理。
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, bins=5): df_tmp = pd.DataFrame({'x': df[col], 'y': df[target_col]}).dropna() try: # 优先等频分箱;重复值太多时自动退化为等宽 bin_edges = pd.qcut(df_tmp['x'], bins, retbins=True, duplicates='drop')[1] df_tmp['bin'] = pd.cut(df_tmp['x'], bins=bin_edges, include_lowest=True) except ValueError: df_tmp['bin'] = pd.cut(df_tmp['x'], bins=bins) grouped = df_tmp.groupby('bin', observed=True)['y'].agg(['mean', 'count']) grouped['bad_rate'] = grouped['mean'] return groupedbins=5是对大多数评分卡特征比较稳的起点,超过 10 箱会让单箱样本变薄,WOE 波动变大。pd.qcut按样本量等频切分,每个箱里样本数接近;返回的bin_edges是真实切分点,后续要把它固定下来,用于测试集分箱。bad_rate是每箱坏样本占比,如果分箱合理,它应该呈单调或接近单调的趋势,这也是后面做 WOE 之前最重要的体检项。
3. WOE 编码与 IV 值计算:先给特征排个信息量名次
分箱完成后,下一个动作是计算 WOE(Weight of Evidence)和 IV(Information 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,则相反。
计算函数我一般写成下面这样,直接返回分箱明细,同时算出单箱 IV:
def cal_woe_iv(df, col, target=target_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, observed=True)[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为 0,np.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(dtype=float) for col in binned_cols: grp = cal_woe_iv(train_binned, col) iv_series[col] = grp['iv'].sum() iv_series = iv_series.sort_values(ascending=False) 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(penalty='l2', C=1.0, solver='liblinear') 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]solver='liblinear'适合中小规模的二分类场景,C=1.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 对应 odds=1”是非常常见的初始设定。
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_bad=1 时等于 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, axis=1) test_binned['score'] = test_binned.apply(score_one_row, axis=1)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或nan,score.py在训练中途抛出 ValueError;或者总 IV 大到一个不科学的数值,比如单个特征超过 1。
原因:某个分箱里好样本数或坏样本数为 0,分母为 0。比如分箱切得太细,样本分布不均匀,某一箱里全是坏客户,dist_bad占比不等于 0,但dist_good等于 0,np.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_weight='balanced',或者手动给坏样本提高权重。评分卡测试期我更推荐加class_weight,因为它不破坏线性结构,系数和 WOE 的逻辑关系不变,后续算分公式照常使用。加了权重之后,分数分布会重新回到一个相对合理的区间,再重新看 KS 和 PDO 是否需要调整。
6. 用 KS 和 PSI 做上线前验证:区分度与稳定性两个硬指标
评分卡上线前的验证动作,我基本收敛到两个指标:KS 看区分度,PSI 看稳定性。KS 的全称是 Kolmogorov-Smirnov,用在评分卡里就是按分数降序排列后,累计坏样本占比与累计好样本占比的最大差值。它直接回答一个问题:把分数从低到高排序,能不能有效把坏客户往前排?
def ks_value(y_true, score, bins=20): df = pd.DataFrame({'y': y_true, 's': score}).sort_values('s') df['bin'] = pd.qcut(df['s'], bins, duplicates='drop') agg = df.groupby('bin', observed=True)['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 看稳定性,最后输出一张训练集和测试集的分数分布表,给业务看一眼再决定是否上线。评分卡的优势本来就不是炫技,而是让风控决策经得起追问。这套习惯建议你也直接复用到自己的项目里,希望帮到你。
本文还有配套的精品资源,点击获取