简介:基于Python构建逻辑回归评分卡模型的完整工程资料,面向机器学习零基础与进阶学习者,适合作为毕设项目、课程设计、大作业或工程实训。项目覆盖评分卡建模全流程,包含特征工程、WOE编码、IV值计算与特征筛选、特征WOE化,并实现输入筛选后特征的属性值即可自动得出评分结果。压缩包内共6个文件,含3个csv格式的训练与测试数据、1个xls数据字典、1个py建模脚本及1个md说明文档,整体大小5.07MB,数据、字典、代码、说明分层存放,便于按模块研读。已有152人浏览学习。借助这份资料,可以完整掌握逻辑回归在金融信用评分中的落地方法,学会从原始数据清洗、特征分箱到评分卡输出的标准流程,并可直接复用于类似项目或作为毕设改写基础,也能加深对评分卡业务逻辑的理解。
1. 评分卡模型不等于跑通一次逻辑回归:要的是整数分数,不是概率
同一个数据集,直接调用 sklearn 的逻辑回归,三行代码就能拿到每个客户的违约概率,但风控业务很少直接用这个概率。原因在于概率值(比如 0.032)没法对客群直接下策略,监管和业务方要求的是“整数、单调、可解释”的分数,比如 580 分以下拒绝、580~650 分人工审批、650 分以上自动通过。评分卡模型做的事情,就是基于逻辑回归的系数,把概率映射成一个整数评分,同时让每个评分区间都能说清楚“为什么扣了这么多分”。这条路在 Python 生态里已经非常成熟,pandas 做分箱和 WOE 转换,sklearn 做逻辑回归训练,再用一个映射公式算出基准分和每项扣分。本文按这条常见的从业方案,从分箱到上线监控完整讲一遍参数设置和踩坑点,适合正在学 python 评分卡、或准备把模型交付给业务侧的读者。
2. WOE 分箱与编码:评分卡的第一步是把变量改成对数优势比
评分卡一切可解释性的根源都来自 WOE(Weight of Evidence),而不是特征的原始值。逻辑回归输入的是数值,但风控变量很少是干净的连续值:年龄 25 岁和 26 岁的违约率差异微乎其微,负债率 0.3 和 0.35 的差异也远不如 0.5 到 0.8 的跳变显著。把连续变量切成若干箱,每一箱用该箱的好客户占比和坏客户占比计算一个 WOE,等于把变量的非线性关系和缺失值一起编码进特征。这一步做不好,后续训练出的评分卡系数符号都可能是反的。
2.1 用等频分箱 + 手动合并构造初始分箱
常见做法是先按分位数切等频箱,再根据每一箱的坏样本率手动合并小样本箱。任何分箱逻辑都是先分组、再统计好坏占比,最后计算 WOE。下面这段代码用模拟数据说明完整过程,数据量 20000 条,包含四个常见风控变量。
import pandas as pd import numpy as np # 构造模拟数据:20000 个样本,四个变量 + 好坏标签 np.random.seed(42) n = 20000 df = pd.DataFrame({ 'age': np.random.randint(22, 65, n), 'income': np.random.lognormal(mean=4.5, sigma=0.6, size=n), 'overdue_count': np.random.poisson(lam=0.8, size=n), 'due_ratio': np.random.beta(2, 8, size=n), }) # 好坏标签:坏客户占比约 12%,且与变量存在相关关系 logit = (35 - df['age']) * 0.03 + (df['overdue_count'] * 1.2) + (df['due_ratio'] * 2.5) - 3 prob = 1 / (1 + np.exp(-logit)) df['bad'] = np.where(np.random.rand(n) < prob, 1, 0) print(df['bad'].mean())代码先构造了四个原始变量,再用手写的 logit 公式生成坏客户标签。逻辑回归作为广义线性模型,和数据的真实生成过程无关,这里只是让“坏客户和年龄、逾期次数、负债率相关”这一个业务直觉成立,方便后面观察系数符号。坏样本占比约 12%,接近实际信贷业务的违约分布。
接下来做等频分箱并计算 WOE。等频分箱的关键参数是qcut的q值,一般取 4~10。取值太小会丢失变量细节,取值太大则每个箱子样本量不足,WOE 波动会很厉害。我一般先取 5 箱,再对有零坏样本的箱子做合并。
def woe_iv_calc(df, feature, target='bad', n_bins=5): # 先做等频分箱,duplicates='drop' 避免数值边界重复导致报错 df['bin'] = pd.qcut(df[feature], q=n_bins, duplicates='drop') grouped = df.groupby('bin', observed=False).agg( total=(target, 'count'), bad=(target, 'sum') ) grouped['good'] = grouped['total'] - grouped['bad'] good_total = grouped['good'].sum() bad_total = grouped['bad'].sum() grouped['good_pct'] = grouped['good'] / good_total grouped['bad_pct'] = grouped['bad'] / bad_total # WOE 定义:ln(好占比 / 坏占比),注意方向不要写反 grouped['woe'] = np.log(grouped['good_pct'] / grouped['bad_pct']) # IV 是每一箱的好坏占比差异乘以 WOE 的累加 grouped['iv'] = (grouped['good_pct'] - grouped['bad_pct']) * grouped['woe'] return grouped这段代码里最关键的是woe那一行,good_pct / bad_pct的比值大于 1 时 WOE 为正,说明该箱好客户比坏客户多、风险低。后面逻辑回归的系数方向全部依赖这个约定,如果写成bad_pct / good_pct,训练出来的系数符号会整体反转。duplicates='drop'参数处理的是特征值大量重复的情况——比如 overdue_count 这种计数变量,大半人都是 0,单纯按分位数切箱会报错。
2.2 WOE 映射表与缺失值处理的落地约定
分箱完成后需要生成一张 WOE 映射表,训练集做转换、验证集和上线打分都依赖这张表,而不是重新分箱。常见做法是把映射表存成 CSV 或者数据库表,上线时直接查表替换。
# 对 age 做分箱并生成映射表 woe_table = woe_iv_calc(df, 'age', n_bins=5) woe_map = woe_table['woe'].to_dict() # 把原始值替换成 WOE 编码后的值 df['age_woe'] = df['age'].map(lambda x: next(v for k, v in woe_map.items() if x in k)) print(woe_table[['bad', 'good_pct', 'bad_pct', 'woe', 'iv']])替换逻辑里用了next()去匹配区间,实际生产中更高效的做法是先用pd.cut把原始值划到同一区间再 map。这里写next()是为了直白表达“每个箱替换成对应 WOE”。注意缺失值在评分卡中的处理方式:缺失单独作为一个箱参与 WOE 计算,而不建议直接填均值或中位数,因为缺失本身在风控数据里往往意味着风险信息。操作上把isnull单独标记,再和其他箱一起计算好坏占比即可。
WOE 变换还有一个隐含约束需要重视:单调性不是强制要求,但业务上希望变量对风险的影响方向明确。如果一个变量分箱后 WOE 出现“低—高—低”的反复波动,通常说明分箱过细或变量本身不稳定,这类变量即使 IV 值合格,也建议谨慎入模,因为系数符号和业务解读会变得很别扭。
3. IV 筛选与样本设计:留下真正有区分度的变量,别让时间泄漏进训练集
WOE 算完,每个变量都得到一张分箱统计表,下一步是变量筛选。这一步直接决定最终入模变量集合,也决定了评分卡能否通过业务评审。变量筛选通常分两层:量化指标(IV 值、相关系数)和业务规则(变量是否稳定采集、是否有明确业务含义)。只靠 IV 值排名不靠谱,我见过 IV 值很高但业务完全无法解释的变量,比如客户在某渠道的点击次数,这类变量上线后很容易受渠道策略调整影响,分数波动剧烈。
3.1 IV 值的判断标准与 Python 实现
IV(Information Value)衡量的是变量对好坏的区分能力,业界常用判断区间大致是:IV 小于 0.02 几乎没有区分能力,0.02~0.1 较弱,0.1~0.3 中等,0.3 以上较强。实际建模中我一般把 0.02 作为下限,0.5 以上反而要警惕,因为异常高的 IV 往往意味着变量泄漏——比如用“是否已经逾期”去预测坏客户,区分度当然高,但这种变量在申请场景下根本拿不到。
def calc_iv_for_variable(df, feature, target='bad'): # 复用前述分箱逻辑,输出该变量总 IV grouped = woe_iv_calc(df, feature, target) return grouped['iv'].sum() features = ['age', 'income', 'overdue_count', 'due_ratio'] iv_dict = {f: calc_iv_for_variable(df, f) for f in features} print(iv_dict)这段代码把第二章定义的woe_iv_calc复用了,总 IV 等于所有分箱 IV 的累加。这里有一个细节:分箱数变化,IV 值也会变化,所以做变量筛选之前应该先统一分箱策略,比如所有连续变量一律先切 5 箱。不要对某个变量反复调分箱数来刷高 IV,这属于典型的自欺欺人。IV 筛选完成后还需要做变量相关性检查,相关系数高于 0.7 的两个变量建议只保留业务含义更清晰的那个,避免逻辑回归的多重共线性把系数符号搞乱。
3.2 样本时间窗口设计:评分卡最容易翻车的地方
评分卡建模很少用全部历史数据直接随机切分训练集和验证集,常见做法是按时间切分:用过去 12 个月的样本做训练,最近 3 个月做验证。原因很简单,信贷业务的数据存在明显的周期性和政策漂移,随机切分会让模型“偷看”未来信息,上线后 KS 值通常会掉一半以上。
坏样本的定义也要固定时间窗口。常见定义是“开户后 3 个月或 6 个月内有 M1(逾期 30 天以上)记录”,这个观察期(performance window)必须写死在建模代码里。同样一批客户,观察期从 3 个月改成 6 个月,坏样本率可能从 5% 涨到 12%,整个模型的分箱和系数都会变。这里有个容易忽略的坑:数据集中“坏标签”往往已经由上游数仓算好,建模前要主动确认标签的时间口径,而不是直接拿bad字段就用。
样本不均衡也是评分卡绕不开的问题。如果坏样本占比只有 2%,逻辑回归会倾向于把所有客户都预测成好客户。常见做法是按下采样处理:把好样本随机抽到坏样本的 1~2 倍,训练完成后在截距项上做偏移校准(后续章节展开)。不建议直接用 SMOTE 这类过采样方法做风控模型,因为生成的人工样本在业务上无法解释,监管评审很难通过。
4. 逻辑回归训练:fit 只是开始,系数符号与稳定性才是关键
变量定稿后进入建模阶段。这里有一个常见的认知偏差:认为调好 sklearn 的LogisticRegression参数,模型就完成了。实际上评分卡建模中,训练只占三分之一的时间,剩下三分之二是系数体检和稳定性验证。用 Python 做逻辑回归训练本身很简单,但训练之前必须想清楚:要让业务解释得通,每个变量的系数方向必须符合业务直觉,这比模型的 AUC 高低更重要。
4.1 训练集构造与关键参数说明
先用 WOE 映射表把训练集的特征全部替换成 WOE 编码值,再调用LogisticRegression。这里必须设置penalty和C,否则默认参数下,如果特征之间相关性较高,系数符号很容易乱掉。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split # 用 WOE 编码后的特征构造训练集 X = df[['age_woe']] # 实际建模会叠加多个变量的 woe 列,此处简化 y = df['bad'] # 下采样:按坏样本 1:1 抽样好样本,缓解样本不均衡 bad_df = df[df['bad'] == 1] good_df = df[df['bad'] == 0].sample(n=len(bad_df), random_state=42) sample_df = pd.concat([bad_df, good_df], axis=0).sample(frac=1, random_state=42) X = sample_df[[f'{f}_woe' for f in features if f'{f}_woe' in sample_df.columns]] y = sample_df['bad'] # 训练逻辑回归模型 model = LogisticRegression(C=0.1, penalty='l2', solver='liblinear', max_iter=200) model.fit(X, y) print('intercept:', model.intercept_[0]) for name, coef in zip(X.columns, model.coef_[0]): print(f'{name}: {coef:.4f}')关键参数逐个说清楚。C是正则化强度的倒数,值越小正则化越强,系数越往零收缩。评分卡场景我一般取 0.01~0.1 之间,避免系数绝对值过大导致分数波动太敏感。solver选liblinear是因为它在小样本线性分类上收敛稳定,lbfgs在特征维度低时也可以,但如果特征中存在完全分离的变量,liblinear的稳定性会更好。max_iter默认 100 可能不够,逻辑回归的迭代次数和特征维度、样本量有关,训练报ConvergenceWarning时优先调大max_iter,不要动学习率——sklearn 里没有学习率这个概念可调。
4.2 系数体检:方向、显著性与截距校准
模型训练完,第一件事不是看准确率或 AUC,而是看系数方向。经验法则:WOE 编码采用“好占比/坏占比”时,好客户多的箱 WOE 为正,那么风险变量(如逾期次数)的系数应该为正,保护变量的系数应该为负。如果overdue_count_woe的系数为负,说明分箱方向或编码方向出了问题,而不是模型帮你发现了“逾期次数多的人更不容易违约”这种反直觉规律。
系数稳定性检查推荐看每个变量的系数在训练集和验证集上的变化幅度。常见做法是用训练集内部做 5 折交叉验证观察系数波动,如果某个变量在不同折里的系数正负方向来回横跳,这个变量的质量就有问题。这类不稳定性通常来自分箱过细导致某个箱的样本量太小,处理方法是合并低样本箱或直接剔除该变量。
样本不均衡还有一个重要修正:下采样会改变截距项。因为训练集中好样本的比例被人为改成了 50%,模型输出的违约概率会系统性偏高。修正方法是在评分映射阶段用实际坏样本占比重置基准概率,具体公式放入第五章统一说明,逻辑回归的系数不需要动,只调整截距即可。
4.3 训练集与验证集的时间一致性
训练完还必须检查训练集和验证集的数据分布是否一致。实际操作中,我习惯先算一遍训练集和验证集每个变量的 WOE 分布,再算 PSI(Population Stability Index)。PSI 小于 0.1 表示分布稳定,0.1~0.25 需要关注,大于 0.25 说明两个时间段的客群已经发生了明显变化,这时候训练集建出来的模型在真实环境里大概率翻车。这个检查应该在建模早期做,而不是等模型训练完才发现上线后分数整体漂移。
PSI 计算依赖分箱后的好坏占比,可以直接复用 WOE 分箱的映射表,用验证集落在每个箱的样本比例和训练集的比例做比较。后面章节会给出具体代码。
5. 评分卡避坑:至少 5 条实战中的常见问题记录
这部分记录评分卡建模中经常遇到的 5 个问题点,按现象 → 原因 → 解决一条一条列清楚。
5.1 训练分数高、验证分数崩塌:时间窗口没用对
- 现象:训练集上 AUC 0.78,验证集上 CV 出来只有 0.55,差距过大。
- 原因:建模样本和验证样本存在时间重叠或时间错位,比如把 2023 年到 2024 年的数据混在一起随机切分,模型“记住”了某段时间的客户特征,而不是真正学会了预测风险。
- 解决:统一按时间窗口切分,训练集用早期数据、验证集用最近数据,并确保好坏标签的表现期已经完整。实际操作中,训练集和验证集的时间间隔一般保持 3 个月以上,避免相邻月份的客群结构太相似而高估模型稳定性。
5.2 特征分箱后 WOE 全是 NaN:某个箱里没有坏样本
- 现象:
np.log(good_pct / bad_pct)报RuntimeWarning: divide by zero encountered in log,对应箱的 WOE 变成inf或NaN。 - 原因:分箱太细,某个箱内没有坏客户,
bad_pct为 0,对数运算直接溢出。 - 解决:先检查该箱的样本量,样本量过小就执行手工合并,把相邻的箱合并成一个;如果所有箱都没有坏样本,说明这个变量本身区分能力极弱,直接剔除比修补分箱更有效。遇到零坏样本时也可以给分子分母加平滑项,比如
(good_count + 0.5) / (bad_count + 0.5),但加平滑后 WOE 的单调性会受轻微影响,能手动合并就不要依赖平滑。
5.3 逻辑回归系数符号与业务常识相反:WOE 方向写反了
- 现象:逾期次数越多,模型给出的分数反而越高,逻辑回归系数符号明显不合理。
- 原因:WOE 计算公式写成了
bad_pct / good_pct,或者业务变量的风险方向本身设定反了。 - 解决:检查代码里 WOE 的除法方向,确认好坏样本的标签定义。正确方向是:坏样本占比高的箱,WOE 为负;坏样本占比低的箱,WOE 为正。把所有变量的系数逐一对照业务含义排查,逾期次数、负债率这类风险变量,系数应为正。
5.4 模型上线后分数整体漂移:客群结构已经变了
- 现象:模型刚上线时分数分布还行,2 个月以后发现高分段客户比例明显下降,平均分数掉了 20 分。
- 原因:业务方调整了获客渠道,新进件客群的年龄、收入结构整体变化,模型在训练集上学习的分布已经不能代表当前客群。
- 解决:上线前先按周或天维度计算 PSI,监控每个月的 PSI 变化。PSI 超过 0.25 时启动模型迭代流程,而不是继续用旧模型硬扛。评分卡模型必须配套一个监控看板,这是上线的基本配置,不是可选项。
5.5 单变量分箱的“隐形泄漏”:用了未来信息
- 现象:某个变量 IV 高达 0.8,模型分数特别好看,但上线后完全失效。
- 原因:特征本身包含了未来信息,比如用“当前是否已经逾期”预测“未来是否会违约”,标签和特征存在天然重叠。
- 解决:按业务逻辑逐变量检查特征的计算时间点。申请评分卡里的特征必须是在申请当时就能拿到的数据,任何涉及申请日之后的信息都不能入模。遇到高 IV 变量先问一句:这个变量在授信决策那一刻真的存在吗?
以上 5 条是评分卡建模中反复出现的真实问题。第一条和第四条属于整体设计问题,通常建模前没有想清楚时间窗口和上线监控就动手,后面再补成本极高。其余三条在写第一版代码时就会撞上,尤其是 WOE 计算方向,可以先把代码跑通再回头调整。
6. 分数映射与上线监控:从概率到整数分数的最后一跳
评分卡的最终产物是一张肉眼可读的分数表,比如总分 650,其中年龄贡献 28 分,负债率贡献 -15 分。分数映射的核心是两个参数:基准分(base score)和翻倍分数(PDO,Points to Double the Odds)。常见做法是设定基准 Odds 为 1:19(即违约概率 5%)时对应 500 分,每翻一倍 Odds 分数增加 50 分。
分数与 Odds 的关系定义如下:
factor = PDO / ln(2) offset = base_score - factor * ln(base_odds) score = offset - factor * ln(odds)注意最后一个公式里的减号,评分越低风险越高,所以用负号把方向对调。落地到 Python,逻辑回归给出的概率需要先转成 Odds,再代入公式。这个映射过程在训练时就写好函数,不要在模型定稿后再补,否则容易在参数传递上出错。
import math # 定义评分刻度参数 base_score = 500 base_odds = 1 / 19 # 基准违约概率 5% pdo = 50 # 计算因子和偏移量 factor = pdo / math.log(2) offset = base_score - factor * math.log(base_odds) def prob_to_score(prob): # 概率转 Odds,再转分数 odds = prob / (1 - prob) score = offset - factor * math.log(odds) return round(score) sample_df['pred_score'] = model.predict_proba(X)[:, 1].map(prob_to_score) print(sample_df[['pred_score']].describe())这个函数写完,还需要一个得分单调性校验。评分卡业务上要求分数越低、坏样本率越高,所以验证的时候按分数分箱,看每个箱体的坏样本占比是否严格递减。常见做法是砍掉分数最高和最低的 5% 样本,因为边界箱样本太少、统计波动大,严格递减在边界上往往难以保证。
上线之前的最后一步,是用一份完全没参与训练的新数据做效果复盘,而不是用训练集。我一般会要求数仓按“最近 1 个月新进件客群”单独出数据,然后计算 KS 值并对比训练集的 KS,如果下降幅度超过 20%,说明模型泛化能力有问题。PSI 的代码实现也不复杂,按训练集的分箱边界,统计验证集各箱样本占比和训练集各箱样本占比,计算对数比值加权和即可。
评分卡建模做到最后,拼的不是模型复杂度,而是对业务口径和数据质量的敏感度。很多人卡在 WOE 分箱或系数调优上,但真实落地中最大的风险往往是时间窗口、坏样本定义这类最基础的问题。把分数映射函数和 PSI 监控脚本固化到建模流程里,后续迭代只需要跑一遍自动化流程就能完成,希望这套思路对你有直接的参考价值。
本文还有配套的精品资源,点击获取