简介:以逻辑回归为核心的评分卡模型构建项目,面向希望在机器学习与风控建模方向入门或进阶的学习者。项目完整覆盖特征工程、WOE编码、IV值计算与特征筛选、特征WOE化、评分卡建模等关键环节,输入筛选后特征的属性值即可自动得出评分,适合作为毕设、课程设计、工程实训或初期项目立项。压缩包共6个文件,包括3个csv原始数据、1个xls数据字典、1个py模型脚本及1个md说明文档,整体约5.07MB,结构清晰便于直接运行与逐步调试。已有152人学习下载。通过该项目可掌握评分卡从数据预处理到分数输出的完整流程,理解WOE/IV在风控特征筛选中的应用,并基于Python独立搭建可扩展的评分卡框架。
1. 评分卡不是黑匣子:逻辑回归撑起信贷审批的底气
信贷审批里真正跑量的模型,不是深度学习,而是基于逻辑回归的评分卡模型。逻辑回归输出的是违约概率,可业务要的是一个整数分:多少分通过、多少分拒绝、多少分转人工。这中间隔着一道分数刻度转换,而这一步恰好是很多教程讲完回归就停笔的地方。评分卡模型的完整链路是:特征分箱、WOE编码、IV筛选、逻辑回归训练,最后把系数映射成信用分数。它能解决“这个人该不该批、批多少额度”的问题,也能解释每个特征扣了多少分。适合风控数据分析师、信贷策略岗,以及想转数据挖掘的 Python 工程师。
2. 数据清洗与特征分箱:先把连续变量切成业务能解释的段
2.1 为什么要先分箱再建模
逻辑回归的假设是 logit(p) 与特征线性相关,但年龄、收入、负债比这些原始变量和违约概率的关系从来不是一条直线。征信报告上的“近 6 个月查询次数”,前 5 次和违约率几乎没关系,冲到 15 次以后风险才陡增。这种阶梯状关系,直接把连续数值喂给模型,系数会被中间大段“平缓区”稀释,等于让模型用一把不准的尺子量风险。
分箱就是把特征切成几段,每段独立计算风险浓度,让非线性关系变成一个个台阶。这么做有三个实际好处:缺失值可以单独成箱,不用做均值插补;异常值被锁在箱内,不会单点拉偏系数;上线之后每一档分数都对应一段业务上看得懂的解释,比如“近 3 个月查询次数 10 次以上,扣 30 分”。这三条加起来,就是评分卡和普通黑匣子模型最本质的差别。
2.2 三种分箱方式的选型
等频分箱用pd.qcut按分位数切,保证每箱样本量接近,适合偏态分布的变量,比如收入、授信额度;等距分箱按数值区间等宽切,适合年龄这种跨度小、分布均匀的变量。但等频不关心业务边界,可能把“逾期 30 天”和“逾期 90 天”的客户切进同一箱;等距则容易在长尾处产生空箱,比如收入 200 万以上的箱子里只有两三个人。
更可靠的是卡方分箱或决策树分箱。它们以目标变量为监督,自动找坏样本分布差异最大的切点。常见做法是先用等频切 20 个粗箱,再按卡方值合并相邻箱,直到箱数降到 5 到 8 个。卡方值小表示两个箱的坏样本结构接近,最没必要分开,优先合并;卡方值大的相邻箱说明好坏分布差异显著,必须保留边界。
2.3 用 Python 做等频分箱与卡方合并
import pandas as pd import numpy as np def freq_binning(df, col, target, bins=10): """等频分箱,返回每个箱的好坏样本统计。""" tmp = df[[col, target]].copy() # qcut 按分位数切箱;duplicates='drop' 处理重复切点 tmp['bin'] = pd.qcut(tmp[col], q=bins, duplicates='drop') grouped = tmp.groupby('bin', as_index=False)[target].agg( total='count', bad='sum' ) grouped['good'] = grouped['total'] - grouped['bad'] return grouped def chi2_merge(df, col, target, max_bins=6): """先切 20 个粗箱,再按相邻箱卡方值最小优先合并。""" work = df[[col, target]].copy() work['bin'] = pd.qcut(work[col], q=20, duplicates='drop') while work['bin'].nunique() > max_bins: grouped = work.groupby('bin', as_index=False)[target].agg( total='count', bad='sum' ) grouped['good'] = grouped['total'] - grouped['bad'] min_chi2 = None merge_idx = None for i in range(len(grouped) - 1): tbl = grouped.iloc[[i, i + 1]][['bad', 'good']].to_numpy() row_sum = tbl.sum(axis=1, keepdims=True) col_sum = tbl.sum(axis=0, keepdims=True) exp = row_sum @ col_sum / tbl.sum() # 期望频数矩阵 chi2 = ((tbl - exp) ** 2 / exp).sum() # 卡方值 if min_chi2 is None or chi2 < min_chi2: min_chi2, merge_idx = chi2, i labels = list(work['bin'].cat.categories) work.loc[work['bin'] == labels[merge_idx + 1], 'bin'] = labels[merge_idx] work['bin'] = work['bin'].cat.remove_unused_categories() return work['bin']这段代码里最关键的是卡方值的计算:row_sum @ col_sum / tbl.sum()得到的是期望频数矩阵,再用(实际 - 期望)^2 / 期望累加出卡方值。每一轮循环只合并卡方值最小的一对相邻箱,然后重算边界,直到箱数降到max_bins。remove_unused_categories()在合并后把已经不存在的空类别清掉,避免后面groupby出现空箱。
等频分箱的bins=10是一个起点,样本量低于 1 万时建议降到 8,否则某些箱可能只有几十个样本。卡方合并的max_bins=6是评分卡里比较常用的收尾目标,箱数太少会损失区分度,太多又会让 WOE 来回振荡,6 到 8 通常比较平衡。
2.4 分箱的三个硬性要求
第一,每个箱的好样本和坏样本都别少于 5 个。样本太少的箱,WOE 会被一个极端客户带飞,后面 IV 值也会虚高。第二,分箱后 WOE 尽量单调或保持单一低谷,如果出现“先升后降再升”这种反复横跳,说明箱切得太细,需要继续合并。第三,缺失值要么单独成箱,要么并回占比最大的箱,不要用均值填充。填充等于告诉模型“缺失值和其他值一样”,这在信贷数据里通常是错的,缺失本身往往就带信息。
提示:分箱切点只能在训练集上确定,测试集和未来的上线样本必须沿用同一套切点。否则相当于把验证集的信息提前泄漏给了模型,后面评估出来的 AUC 全是虚的。这一点在 5.2 里会展开讲。
3. WOE编码与IV筛选:把好客户和坏客户“换算”成信息量
3.1 WOE公式与业务直觉
WOE 全称 Weight of Evidence,证据权重。第 i 个箱的计算公式是:
WOE_i = ln( bad_i / B_total / ( good_i / G_total ) )
其中 bad_i 是第 i 箱的坏样本数,B_total 是全量坏样本数,good_i 和 G_total 同理。这个式子算的是“该箱坏客户浓度相对整体浓度的高低”。WOE 大于 0,说明这个箱里坏客户比整体更浓,分数该扣;WOE 小于 0,说明这个箱相对安全。
用 WOE 替换原始值之后,特征和 logit(p) 之间近似线性,这正是逻辑回归需要的输入。注意方向:我习惯用 bad/good,另一套教程用 good/bad,两种定义都能跑通,但必须全程保持一致。最怕中间混用,最后逻辑回归系数正负全反,分数越高风险越高,这种翻车不仔细查根本发现不了。
3.2 IV筛选:到底留哪些特征
IV 的计算公式是 IV = Σ( bad_i/B_total - good_i/G_total ) × WOE_i,它把每个箱的好坏占比差和 WOE 乘起来累加,度量这个特征区分好坏客户的总信息量。从业者通行阈值大概是:IV 小于 0.02 基本没有区分力,直接删;0.02 到 0.1 信息量弱,可以留也可以结合业务定;0.1 到 0.3 区分力较好,是评分卡的主力特征;0.3 到 0.5 很强;超过 0.5 要警惕,常见做法是怀疑这个特征和目标已经有泄漏,或者未来客群高度不稳定。
提示:IV 只衡量单变量区分度,不代表进模后一定好。两个 IV 都是 0.4 的特征可能高度相关,一起进模型不仅不增加信息量,还会让系数互相打架。
3.3 计算 WOE 与 IV 的 Python 实现
import numpy as np import pandas as pd def woe_iv_by_col(df, col, target): """对单个特征计算 WOE 和 IV,返回 (woe_dict, iv)。""" grouped = df.groupby(col, observed=False)[target].agg( total='count', bad='sum' ) grouped['good'] = grouped['total'] - grouped['bad'] B_total = grouped['bad'].sum() G_total = grouped['good'].sum() if B_total == 0 or G_total == 0: return None, 0 bad_dist = grouped['bad'] / B_total good_dist = grouped['good'] / G_total # 加极小值避免 ln(0),坏样本占比为 0 的箱也能平滑计算 eps = 1e-6 woe = np.log((bad_dist + eps) / (good_dist + eps)) iv = ((bad_dist - good_dist) * woe).sum() return woe.to_dict(), iv这个函数里eps是核心细节。分箱后某些箱可能一个坏样本都没有,占比为 0,直接取对数就是负无穷,整个 IV 计算会崩。加1e-6属于常见的平滑手段,它只会影响极端箱的数值,不会改变正常箱的排序方向。
feature_cols = ['age_bin', 'income_bin', 'query_cnt_bin'] # 分箱后的列 selected_features = {} for col in feature_cols: woe_map, iv = woe_iv_by_col(df, col, 'target') if woe_map is not None and 0.02 < iv < 0.5: selected_features[col] = {'woe': woe_map, 'iv': iv} print(f"筛选后保留 {len(selected_features)} 个特征") for col, info in sorted(selected_features.items(), key=lambda x: x[1]['iv'], reverse=True): print(f"{col}: IV = {info['iv']:.4f}")筛选条件0.02 < iv < 0.5是经验区间,业务需要更多特征时可以放宽下界到 0.01,但上界不建议放。IV 超过 0.5 的特征经常在训练集表现惊艳,上线后客群一变就崩盘,属于典型的“看历史分高分、看未来没底”。筛选完把每个特征的woe_map存成字典,下一步逻辑回归训练和分数映射都要用它。
4. 逻辑回归训练与分数刻度:从概率到整数分的完整链路
4.1 样本划分与样本不均衡
信贷数据里坏样本占比通常只有 5% 到 10%,直接训练逻辑回归,模型会把所有人都倾向判成好客户。常见的处理有三种:过采样、欠采样、设置class_weight='balanced'。评分卡场景我一般只用class_weight='balanced',因为逻辑回归输出的概率后续还要刻度化成分数,保持原始客群分布比强行平衡更重要。过采样会把好客户重复复制,导致概率失去原来的基数意义。
划分时用train_test_split配合stratify=y做分层抽样,保证训练集和测试集坏样本比例一致。更严格的评分卡流程应该按时间切:前半段的样本训练、后半段做时间外验证,这能检验客群漂移,比随机切分可靠太多。
4.2 逻辑回归的三个关键参数与调参
| 参数 | 常见取值 | 说明 |
|---|---|---|
| penalty | l2 | 评分卡优先选 l2,系数更稳定;l1 会把部分系数压成 0,特征解释性反而变差 |
| C | 0.01 ~ 10 | 正则化强度的倒数,C 越小惩罚越重,系数越保守 |
| class_weight | balanced | 按坏样本占比自动放大少数类权重 |
| solver | liblinear | 二分类小数据量场景最稳定,对 l1/l2 都支持 |
| max_iter | 500 | 特征多或数据量大时默认 100 可能不收敛 |
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold X = woe_df[selected_features.keys()] # WOE 编码后的特征矩阵 y = df['target'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) param_grid = { 'C': [0.01, 0.1, 1, 10], 'penalty': ['l2'] } lr = LogisticRegression( class_weight='balanced', solver='liblinear', max_iter=500, random_state=42 ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV(lr, param_grid, scoring='roc_auc', cv=cv) grid.fit(X_train, y_train) best_lr = grid.best_estimator_ print(f"最优 C: {grid.best_params_['C']}, 训练集 CV AUC: {grid.best_score_:.4f}")scoring='roc_auc'的选择是有意的。评分卡上线决策主要看排序能力,也就是好客户分数是否普遍高于坏客户,而不是概率值是否精确。AUC 直接度量这种排序能力。另外一个可选的评分是neg_log_loss,也就是逻辑回归损失函数的负对数似然,它反映概率校准质量,但信贷场景里概率绝对值对阈值的影响远不如排序重要,所以默认先看 AUC。
penalty只放l2也是故意为之。l1 正则化在特征多时会强制很多系数归零,看起来模型更简洁,但评分卡业务方会追问“为什么高收入客户系数是 0”,解释成本很高,l2 让所有特征都保留平滑的小系数,反而更贴合业务叙事。
4.3 从系数到分数:刻度公式与映射代码
这一步是评分卡区别于普通逻辑回归的核心。定义 odds = p / (1 - p),分数公式为:
Score = offset - factor × ln(odds)
而 ln(odds) = β0 + Σ βi × WOE_i,所以分数可以拆成基础分加上每个特征每个箱的贡献。需要先设定两个业务参数:基准 odds 为 1:10(违约比正常等于 1 比 10)时,基准分 P0 = 600;odds 每翻一倍,分数扣 20 分,也就是 PD = 20。
由此算出 factor = PD / ln2 = 20 / 0.693 = 28.85,offset = P0 + factor × ln(0.1) = 600 + 28.85 × (-2.303) = 533.6。这里容易算反:因为公式里 ln(odds) 是负数,offset 反而比基准分小,不要看到 533 小于 600 就以为错了,代回基准 odds 验证一遍:533.6 - 28.85 × ln(0.1) = 600,正好对上。
def build_scorecard(model, woe_dict, factor=28.85, offset=533.6): """把逻辑回归系数映射成每个特征每个箱的分数。""" coef = model.coef_[0] intercept = model.intercept_[0] # 基础分:不含任何特征贡献时的起始分数 base_score = offset - factor * intercept score_map = {} for i, col in enumerate(woe_dict): col_scores = {} for bin_label, woe_val in woe_dict[col].items(): # 负号是因为风险越高分越低 col_scores[bin_label] = round(-factor * coef[i] * woe_val, 2) score_map[col] = col_scores return round(base_score, 2), score_map base_score, score_map = build_scorecard(best_lr, selected_features)代码里的-factor * coef[i] * woe_val是整个评分卡的灵魂。woe_val 越大代表该箱坏客户浓度越高,如果系数为正说明模型确实认为这个方向有风险,乘积为负就是扣分;如果某个特征算出高 WOE 反而加分,说明系数符号出了问题,大概率是特征共线性或 WOE 方向混用,要回到第 5 章的排查思路。
最终单笔客户总分 = base_score + 每个特征对应箱的分数累加。实际项目里还会对总分做四舍五入到整数,再落到一个 300 到 900 的可解释区间。比如基础分 500,某客户年龄箱 +10,收入箱 +40,查询次数扣 30,总分 520。业务看这种分表达比看一个 0.83 的概率直观得多。
5. 评分卡常见的 5 个坑:从WOE振荡到系数符号反转
5.1 WOE 反复横跳,单调性救不回来
现象:分箱 10 档之后,WOE 从 -0.3 升到 0.2,又掉回 -0.1,再冲到 0.4,完全没有单调趋势。
原因:最常见的是箱切得太细,某个中间箱只有几十个样本,一个坏客户的进出就让 WOE 剧烈波动;另一种可能是这个特征本身对违约率就是 U 型关系,比如信用卡使用率,太高和太低都危险。
解决:先用卡方合并把箱数压到 5 到 7 箱,再要求每箱好坏样本至少 5 个。U 型关系本身不必须强修成单调,只要方向业务上讲得通,保留合理的双峰也可以。真正要修的是那种反复乱跳、业务说不出理由的振荡。
5.2 训练集 AUC 0.85、验证集 AUC 0.62
现象:训练集效果漂亮,一到验证集就崩,AUC 掉 0.2 以上。
原因:先查分箱是不是在全部样本上做的。如果在全量数据上确定了切点,再随机分训练测试集,切点已经带了验证集信息,这就是典型的数据泄漏。另外一个原因是特征太多,模型把训练集的噪音也记下来了。
解决:分箱切点只在训练集上计算并冻结,测试集直接复用pd.cut的bins=切点列表。同时降低 C 值加强正则化,把分箱数控制到 8 以内,筛选特征时删掉高相关的冗余变量。做完这一步,测试集 AUC 通常能拉回 0.7 以上。
5.3 系数符号和业务常识相反
现象:收入这个特征,理论上是收入越高风险越低,但逻辑回归里系数为正,打分时高收入客户反而被扣分。
原因:最常见是特征间多重共线性。收入、职业等级、学历三个特征高度相关,三个正负系数互相拉扯,整体预测能力没问题,但单看符号完全解释不通。另一个可能是前面 WOE 方向混用,某一个特征在编码时用了 good/bad,其他特征用 bad/good。
解决:先统一检查所有特征的 WOE 定义方向和系数符号是否一致。再用 VIF 检查共线性,VIF 大于 10 的特征逐个剔除,保留业务解释最顺的那个。评分卡宁可损失一点单变量区分度,也要让每个系数的符号业务上说得通。
5.4 上线一个月,分数分布整体漂移
现象:上线后第一个月分数均值还稳定,第二个月开始掉,第三个月坏账率明显抬升,模型打分越来越不可信。
原因:客群结构变了,或者说宏观环境变了。模型开发时用的是存量客群,上线后进件渠道变了、市场利率变了,新客群特征分布和开发样本对不上。
解决:上线前就部署 PSI 监控,月度计算分数和每个特征的 PSI。PSI 小于 0.1 视为稳定,0.1 到 0.25 需要关注,超过 0.25 要触发重训。这个机制不是可选项,是所有评分卡上线后的必选项。
5.5 cutoff 拍脑袋,审批通过率和预期不符
现象:定了 660 分以上通过,结果通过率只有 12%,业务预期是 30%,审批量完全跟不上业绩目标。
原因:cutoff 是拍脑袋定的,没有把分数映射到累计通过率和坏账率。模型分数分布如果整体偏高,660 分以上本来就只有少数客户。
解决:用开发样本把分数从低到高分段,计算每段的累计通过率和累计坏账率,再结合单笔亏损和单笔盈利找到盈亏平衡点,用这个平衡点定 cutoff。比如分数 580 对应累计通过率 30%、坏账率 4%,如果 4% 的坏账率在盈亏平衡线内,580 才是实际业务上该用的阈值。
6. 上线前只做三件事:OOT验证、cutoff设定与PSI监控
逻辑回归训练完不等于评分卡做完,上线前我固定只做三件事。第一件是 OOT 验证,时间外验证。不用随机切分,而是按时间顺序把前半段样本做训练,后半段样本做验证,比如 2023 年 1 月到 10 月训练、11 月到 12 月验证。随机切分等于拿同一条街的左右邻居互相验证,检验不出客群漂移;OOT 直接用未来客群验当前模型,训练集 AUC、测试集 AUC 和 OOT AUC 三者放到一起比较,OOT 若掉得特别多,说明模型对时间轴上的变化很敏感。
第二件事是 cutoff 设定。把分数从低到高等距切成 20 档,计算每档的累计通过率、累计坏账率,再代入单笔审批成本和单笔违约损失,画出利润曲线。利润最高点的分数就是 cutoff,而不是拍一个 660。实际项目里这个 cutoff 经常会落在 560 到 620 之间,和大多数人的直觉相差很大。
第三件事是 PSI 监控部署。用基准时的分数分箱作为锚点,上线后每月计算当期分数的 PSI:
def psi_calc(actual, expected, bins=10): """actual: 本期分数序列, expected: 基准期分数序列""" breaks = np.percentile(expected, np.linspace(0, 100, bins + 1)) breaks[0], breaks[-1] = -np.inf, np.inf exp_pct = np.histogram(expected, bins=breaks)[0] / len(expected) act_pct = np.histogram(actual, bins=breaks)[0] / len(actual) # 占比为 0 时平滑,避免除零 exp_pct = np.where(exp_pct == 0, 1e-6, exp_pct) act_pct = np.where(act_pct == 0, 1e-6, act_pct) psi = ((act_pct - exp_pct) * np.log(act_pct / exp_pct)).sum() return psiPSI 超过 0.25 就说明客群结构已经明显漂移,分数分布在向低分区域迁移,这时候再靠模型做自动审批就很危险了。我早年上线第一张评分卡时只盯 AUC,上线两个月后分数分布整体下移,坏账率抬升了快一倍,那之后才把 OOT 和 PSI 当成硬性流程。评分卡的核心不是模型有多高级,而是每一步都经得起回看。希望帮到你。
本文还有配套的精品资源,点击获取