☰
Python申请评分卡全链路:WOE分箱、逻辑回归刻度与PSI监控
2026/10/28 8:53:35 网站建设 项目流程

简介:围绕Python申请评分卡模型构建的完整数据集与配套代码,面向金融风控、信贷评分及机器学习学习者,帮助梳理从申请数据到信用评分卡落地的全流程。包内共14个文件,以7个pkl模型与字典、5个py脚本、1个csv数据集及1个lr_classweight文件为主,压缩包约9.22MB;pkl保存训练测试数据、WOE与分箱字典及逻辑回归模型,py脚本覆盖数据处理、评分卡函数和多模型实验,csv可直接用于训练测试。已有1704人学习下载,适合对照复现特征选择、模型训练评估与打分规则创建。读者可借助现成数据与代码理解缺失值处理、One-Hot编码、WOE分箱、逻辑回归系数转评分等环节,也能参考GBDT、DNN脚本拓展模型对比,快速搭建可解释的申请评分卡实验环境。

1. 一份能跑的申请评分卡,难点从来不在建模那几行

拿到一份 python 评分卡 申请评分 的数据集,大多数人的第一反应是把LogisticRegression拉起来,跑出一个 AUC,然后截图发群里。真正让人返工三次的从来不是建模那几行代码:坏样本怎么定义、变量窗口有没有穿到表现期、WOE 分箱的边界取的是谁的分位点、分数刻度的 A/B 到底谁做分子。这几件事里任何一件错了,模型在训练集上照样能跑出 0.8 的 KS,上线一个季度后分数分布整体左移,通过率掉了七八个点,你才发现自己拿到的是一张「看上去很准」的卡。

这份数据集的价值不在于它有多大,而在于它把申请级宽表、时间外样本、字段口径这几件事凑齐了,能让你把从分箱到刻度、从 KS 到 PSI 的完整链路走一遍。适合两类人:一类是刚接触风控建模、需要一份能从头跑到尾的练手素材;另一类是手上有生产任务、想拿一份结构干净的数据验证自己那套脚本有没有写错。后面几章按「拆数据 → 分箱筛选 → 建模刻度 → 排查坑 → 时间外验证」推下去,每一步都给能直接抄的代码和参数说明。

2. 先拆数据集:申请级宽表的字段口径与坏样本定义

2.1 一条样本等于一次申请,不是等于一个人

打开表第一眼要确认的是粒度。申请评分卡的样本粒度必须是「一次申请」,主键是申请 ID,不是客户 ID。同一个客户在半年内申请三次,就是三条样本,标签各自独立。这一点如果搞错,后面所有的分箱统计和 KS 都是错的。

表里通常有四类列,看的时候按这个顺序过一遍:

类别典型列用途注意点
主键与时间申请 ID、申请日期切训练/OOT、做时间趋势日期必须能解析,别是字符串
标签好坏标识建模目标定义要和业务对齐
申请信息渠道、产品类型、申请金额分群、稳定性监控类别型,注意取值漂移
行为与征信变量查询次数、额度使用率、历史逾期次数入模特征每个变量都要有时间窗

行为类变量是最容易出事的地方。一个叫「近 6 个月查询次数」的字段,如果它的统计截止日是审批当天,那没问题;如果它在数据集里是「截至数据抽取日」的累计值,那就已经把表现期的信息带进来了,这种变量 IV 会高得离谱,进模型后训练集 KS 能到 0.5 以上,上线直接崩。看数据字典时逐列确认窗口终点,是这份数据集最值得花时间的一步。

2.2 坏样本的定义决定了后面所有数字

坏的定义通常有两种口径:一是「放款后前 6 个期内出现 M1+ 逾期」,二是「观察期内发生 30 天以上逾期」。前者严、坏样本率低,后者松、坏样本率高。同一份数据换个定义,坏样本率可能从 4% 变到 12%,后面所有 WOE 和 IV 都会跟着变。练手的时候建议两种都跑一遍,看看系数的符号稳不稳,这是判断变量健壮性最快的方法。

时间维度上要切三段:观察期(变量取值的时间窗)、表现期(盯着看会不会坏的时间窗)、中间最好留一个缓冲月。比如变量窗口是放款前 12 个月,表现期是放款后 6 个月,那么 2022 年 1 月到 2023 年 6 月的申请可以进训练集,2023 年 7 月到 12 月的进 OOT。缓冲月的意义是防止临近截止日的样本表现期没走完、被误标成好客户,这种「假好样本」会系统性地拉低坏账率估计。

2.3 第一轮体检:缺失率、单值率与时间跨度

在动任何模型之前,先跑一遍字段体检。这一步的目标是找出三类列:缺失率超过 50% 的、只有一个取值的、以及取值随时间明显漂移的。前两类大概率要直接扔掉或者单独处理,第三类要重点标记,它们是后面 PSI 报警的主要来源。

import pandas as pd import numpy as np # 读入时就把日期列解析掉,避免后面反复 astype app = pd.read_csv("app_train.csv", parse_dates=["apply_date"]) oot = pd.read_csv("app_oot.csv", parse_dates=["apply_date"]) def profile(df, y="flag"): rows = [] for col in df.columns: s = df[col] rows.append({ "col": col, "dtype": str(s.dtype), # 类型,object 列要单独看取值 "miss_rate": round(s.isna().mean(), 4), # 缺失率,>0.5 先标记 "n_unique": s.nunique(dropna=True), # 单值列这里会是 1 }) return pd.DataFrame(rows).sort_values("miss_rate", ascending=False) print(profile(app).head(20)) # 坏样本率必须打印,它是后面所有阈值的锚 print("train bad rate:", round(app["flag"].mean(), 4)) print("oot bad rate:", round(oot["flag"].mean(), 4)) print("train 时间范围:", app["apply_date"].min(), app["apply_date"].max()) print("oot 时间范围:", oot["apply_date"].min(), oot["apply_date"].max())

profile这个函数没有技巧,就是把三件事一次打出来。nunique(dropna=True)注意一定要带dropna,否则整列缺失会被算成 1 个取值,你会误以为它是个常量列。两段数据集的坏样本率要对比着看,如果 OOT 的坏样本率比训练集高出一倍,那要么是客群变了,要么是标签口径在切分点前后不一致,这两种情况都不能直接建模。

时间范围的打印是给自己留证据。很多翻车案例最后复盘时发现,训练集和 OOT 的申请日期根本没有前后关系,是随机切的,那样所谓的「时间外验证」就是自欺欺人。

3. WOE 分箱与 IV 筛选:把连续变量压成能进逻辑回归的一列

3.1 为什么申请评分卡偏爱 WOE,而不是 one-hot

逻辑回归本身能吃连续变量,为什么还要费劲分箱转 WOE?四个理由,每一个都是实战里被逼出来的。

第一是缺失值。评分卡变量缺失太常见了,用均值填充会把缺失样本塞进中间那箱,人为抹掉一个可能很强的信号;WOE 分箱直接把缺失单独做一箱,缺失本身就成了一个可解释的类别。第二是非线性。收入这个变量和坏账率的关系往往不是单调的,中间某段反而最稳,分箱能把这种形状表达出来,而原始值进线性模型只能拟合一条直线。第三是异常值。征信查询次数出现一个 200 的极值,标准化也救不回来,分箱之后它自动被归到最后一箱。第四是稳定性。分数卡上线后要监控每个变量的分布,有箱边界才有「这一箱的人变多了」这种可执行的报警。

代价也要说清楚:分箱会损失信息,箱数太少拟合不足,箱数太多又过拟合。bin_num_limit一般卡在 5 到 8 之间,样本量小的时候往 5 靠,几十万条以上可以放到 8。

3.2 手写一遍分箱与 IV,知道每步在算什么

工具库能省事,但第一次做建议手写一遍,否则出了分箱单调性问题你都不知道从哪查。下面这个函数用等频分箱加缺失单独成箱,把 WOE 和 IV 一次算出来。

def woe_iv(df, col, y="flag", bins=5): d = df[[col, y]].copy() # 缺失单独成箱,不能混进分位点计算,否则边界被拉偏 bin_col = pd.Series("MISSING", index=d.index) mask = d[col].notna() if d.loc[mask, col].nunique() > bins: bin_col[mask] = pd.qcut(d.loc[mask, col], q=bins, duplicates="drop").astype(str) else: bin_col[mask] = d.loc[mask, col].astype(str) d["bin"] = bin_col g = d.groupby("bin", observed=True)[y].agg(cnt="count", bad="sum") g["good"] = g["cnt"] - g["bad"] g["dist_bad"] = g["bad"] / g["bad"].sum() # 该箱占总坏样本比 g["dist_good"] = g["good"] / g["good"].sum() # 该箱占总好样本比 eps = 1e-6 # 防止 log(0) g["woe"] = np.log((g["dist_bad"] + eps) / (g["dist_good"] + eps)) g["iv"] = (g["dist_bad"] - g["dist_good"]) * g["woe"] return g, g["iv"].sum() tbl, iv = woe_iv(app, "query_cnt_6m", bins=5) print(tbl) print("IV =", round(iv, 4))

duplicates="drop"是必须的,某个变量有大量重复取值时,qcut会报边界重复的错。eps的作用是防止某箱坏样本数为零导致log(0),加了之后该箱 WOE 会是一个很大的负数,视觉上很显眼,正好提醒你这箱样本太少、需要合并。

WOE 的方向要说清楚:这里用的是ln(dist_bad / dist_good),意思是这一箱的坏样本占比相对于整体越高,WOE 越大。所以入模后如果系数是正的,说明这个变量越大越坏,符合直觉。如果换成ln(good/bad)的定义,所有系数符号会整体翻过来,两种写法都能用,但一份代码里必须只出现一种,混用是系数符号反了的头号原因。

IV 的判读经验值:小于 0.02 基本没信息,0.02 到 0.1 弱,0.1 到 0.3 中等,0.3 到 0.5 强,超过 0.5 要警惕,八成是变量穿越或者标签泄漏。见过 IV 到 1.2 的变量,查下来是「历史最大逾期天数」,这玩意儿本身就是坏结果的一部分。

3.3 从 IV 到入模变量:相关性、VIF 与符号预判

IV 筛完通常会从几十个变量剩十几个,这时候还不能直接进模型。先看两两相关性,两个相关性 0.7 以上的变量同时入模,系数会互相抢解释权,出现一个正一个负的怪现象。常用的处理是先按 IV 从高到低排,保留 IV 高的那个,把相关性超阈值的删掉。

from statsmodels.stats.outliers_influence import variance_inflation_factor import statsmodels.api as sm cand = [c for c in app.columns if c not in ("cust_id", "apply_date", "flag")][:15] # 先用 IV 筛出的候选 # 相关系数矩阵,阈值 0.7,超过的按 IV 高低留一个 corr = app[cand].corr().abs() upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)) drop_by_corr = [c for c in upper.columns if (upper[c] > 0.7).any()] print("相关性过高:", drop_by_corr) # VIF:大于 10 说明共线性严重,一般控制在 5 以内更稳 X = sm.add_constant(app[[c for c in cand if c not in drop_by_corr]].fillna(-9999)) vif = pd.Series( [variance_inflation_factor(X.values, i) for i in range(X.shape[1])], index=X.columns ).sort_values(ascending=False) print(vif.head(10))

VIF 这里用fillna(-9999)只是为了让函数能跑通,做正式计算前更稳妥的做法是先按箱把变量转成 WOE,再对 WOE 值算 VIF,因为入模的本来就是 WOE 而不是原始值。这个小差别很多人忽略,导致 VIF 算出来和实际入模的共线性对不上。

符号预判是筛变量的最后一道关。在入模之前,对每个变量先写下你预期的系数方向:查询次数越多越坏,额度使用率越高越坏,收入越高越好。模型跑出来之后逐条对,符号反了就必须查,不能靠「模型自己学」糊弄过去。评分卡是要给业务方看的,一个反直觉的系数会让整张卡失去信任。

4. 逻辑回归到分数刻度:从概率到 300–850 的换算

4.1 逻辑回归不是保守,是要那张能解释的刻度表

现在梯度提升树满地跑,为什么申请评分卡还在用逻辑回归?因为监管、业务和运维三方面都要「可解释」。逻辑回归加上 WOE,每个变量每个箱对应一个固定分值,业务方问「为什么这个人被拒」,你能直接翻出「因为他近 6 个月查询次数落在第 5 箱,扣了 38 分」。树模型给不出这种粒度的答案,至少给不了能写进拒绝原因代码的答案。

另一个理由是稳定性。逻辑回归的假设强,意味着它对数据分布变化更敏感,但也更可预测。分数分布的漂移能被 PSI 及时抓到,而树模型在分布漂移时往往是「静悄悄地性能下降」,等你发现时已经放了三个月款。

选型确定之后,建模阶段要盯的不是 AUC 有多高,而是系数是否显著、符号是否正确、分数分布是否合理。AUC 0.75 的卡配上稳定的分数分布,比 AUC 0.82 但分布诡异的卡更有生产价值。

4.2 statsmodels 建模与系数体检

建模用statsmodels而不是sklearn,核心原因是它会给每个系数的标准误、z 值和 p 值。p 值大于 0.05 的变量基本可以请出去了,它在这个模型里没贡献,还占了一个入模名额。

import statsmodels.api as sm # 先把所有候选变量转成 WOE 值,再拼成建模宽表 def to_woe(df, col, tbl): # tbl 来自 3.2 的 woe_iv,索引是箱标签 edges = [eval(b) for b in tbl.index if b != "MISSING"] return df[col].pipe(lambda s: s.map(lambda v: np.nan if pd.isna(v) else v)) final_vars = ["query_cnt_6m", "util_rate", "income", "age"] # 假设已通过 IV 与 VIF 筛选 X = sm.add_constant(train_woe[final_vars]) y = train_woe["flag"] model = sm.Logit(y, X).fit(disp=0) print(model.summary()) # 系数体检:p 值、符号、量级 coef = pd.DataFrame({ "coef": model.params, "pvalue": model.pvalues, "odds_ratio": np.exp(model.params), }).sort_values("coef") print(coef)

sm.add_constant不能漏,漏了就没有截距项,后面的刻度映射会整体偏掉。disp=0只是关掉迭代日志,不影响结果。odds_ratio这一列是给业务看的:系数 0.35 对应 OR 1.42,意思是这个变量的 WOE 每增加 1,坏账的几率变成 1.42 倍。

系数体检有三条硬规则。p 值超过 0.05 的删掉;符号和 3.3 里的预判不一致的,回去查共线性和 WOE 定义;量级特别大的(比如系数超过 3),多半是某个箱样本太少导致 WOE 极端,回去合并小箱。这三条走完,入模变量通常能稳定在 8 到 12 个,再多就要怀疑是不是塞了冗余变量。

4.3 概率到分数的换算:offset、factor 与 base_odds

模型输出的是坏概率 p,业务要的是 300 到 850 的分数,中间要做一次线性变换。标准做法是设定三个参数:基准分、基准好坏比、以及每翻一倍 odds 需要加多少分(PDO)。

base_score = 600 # 基准分 base_odds = 30 # 基准分对应的 好:坏 = 30:1 pdo = 20 # odds 翻倍,分数加 20 factor = pdo / np.log(2) # 每单位 ln(odds) 对应的分数 offset = base_score - factor * np.log(base_odds) def score_from_proba(p, offset=offset, factor=factor): # ln(好/坏),p 越小分数越高 return offset + factor * np.log((1 - p) / p) # 把系数拆到每个变量每个箱,生成可交付的评分卡表 def build_card(woe_tables, params, factor, offset): rows = [] for var, tbl in woe_tables.items(): beta = params[var] for bin_label, r in tbl.iterrows(): rows.append({ "var": var, "bin": str(bin_label), "woe": round(r["woe"], 4), "points": round(factor * beta * r["woe"], 1), # 该箱贡献的分数 }) base_points = offset + factor * params["const"] # 基础分 return pd.DataFrame(rows), round(base_points, 1) card, base_points = build_card(woe_tables, model.params, factor, offset) print("base:", base_points) print(card.head(10))

三个参数的含义必须搞清:base_odds=30表示一个好坏比 30:1 的人拿 600 分;pdo=20表示好坏比变成 60:1 时拿 620 分。改pdo会改变分数的「分辨率」,pdo 小则分数对风险变化更敏感,但波动也大;改base_score只是整体平移,不影响排序。

拆出来的points列可以做一致性校验:把所有变量的箱分值加上基础分,应该能还原出score_from_proba的结果。差得超过 1 分,说明某处符号或者const用错了。这个校验我每次出卡都跑,两行代码能省掉一整轮返工。

分数算完还要看分布。正常的一张卡,训练集分数应该接近正态,均值落在基准分附近,低于 500 分的占比和实际坏账率大致同量级。如果分布出现双峰或者长尾拖到 300 分以下,多半是某个变量的 WOE 极端值在作祟,回 3.2 把那箱合并掉。

5. 上线后翻车的五个点:分箱边界、缺失箱与加权截距的排查清单

5.1 分箱边界钉在训练集上,OOT 里冒出只有三个样本的箱

现象:训练集 KS 0.42,OOT 掉到 0.28,一看 OOT 的分数分布,某个变量有将近四成样本挤在最高一箱里,而训练集里这箱只占 8%。

原因:分箱边界取的是训练集的分位点,OOT 客群整体上移之后,大量样本被切到了边界外的最后一箱,这一箱的 WOE 是用训练集那 8% 的样本估出来的,代表性极差。

解决:边界不要用浮点分位点,取整到业务上说得通的整数,比如查询次数就用 0/1/2/4/6,别用 3.47。另外对每个变量记录训练集和 OOT 的箱占比,占比偏差超过 15 个百分点的箱强制合并。OOT 只做映射,绝不允许在 OOT 上重新分箱,否则验证结果就成了自己骗自己。

5.2 缺失值单独成箱,然后成了模型里最强的变量

现象:某个征信变量缺失率 35%,缺失箱的 IV 贡献占了整列的 70%,模型给这一箱的分值极低,等于「没查到征信就直接判死刑」。

原因:缺失不是随机的。查不到往往是因为这个人在征信体系里太「干净」(没有借贷记录),也可能是因为渠道数据回传失败。这两种缺失的业务含义完全相反,混在一个箱里,模型学到的是混合信号。

解决:把缺失拆成「无记录」和「查询失败」两类,前者单独一箱,后者直接按流程拒绝、不进模型。同时给缺失率本身建一个监控指标,上线后缺失率从 35% 涨到 50%,说明数据源出了问题,这时候模型分数再怎么正常也不可信。

5.3 系数符号反了:三种常见成因

现象:收入越高,模型给的分越低,业务方一眼看出来,整张卡信任度归零。

原因有三种:一是 WOE 定义方向在某个变量上写反了,全局用ln(bad/good),个别变量用了ln(good/bad);二是共线性,收入和额度两个变量相关性 0.85,同时入模后系数互相拉扯;三是样本里有表现期信息泄漏,某个「看起来该是好客户」的群体被大量标成坏。

解决:先查 WOE 定义,统一在一个函数里生成,不手写第二份;再算 VIF,超过 5 的按 IV 高低删;最后逐变量做单变量 KS,如果单变量方向正常、多变量反了,那基本就是共线性。这三步按顺序走,九成的符号问题能定位到。

5.4 加权之后分数整体漂移

现象:为了修正好坏样本不平衡,给坏样本加了权重,模型跑完发现按公式换算出来的分数,均值比预期低了 40 分,通过率算出来对不上。

原因:加权会改变逻辑回归的截距项。原本的截距对应的是真实的好坏比,加权之后截距反映的是加权后样本的好坏比,概率不再校准。这时候拿score_from_proba去算,分数自然整体偏移。

解决:加权只用于训练,出卡之前把截距还原。简单做法是不加权的模型出分、加权的模型只用来选变量和看系数稳定性;或者保留加权模型,但在刻度换算时用训练集的真实坏账率反推一个修正项加到offset上。这个修正是必须的,否则你交付的分数和实际风险水平是两套语言。

5.5 变量窗口穿越表现期,AUC 虚高到 0.95

现象:训练集 AUC 0.95,交叉验证也稳,一上 OOT 直接掉到 0.6,比随机好不了多少。

原因:某个变量的统计窗口包含了表现期。典型的是「历史最大逾期天数」「近 12 个月最大逾期金额」这类字段,在数据抽取时已经包含了放款后的行为。模型学到的不是预测能力,是结果本身。

解决:拉一份变量清单,逐个字段标注统计窗口的起止时间,终点晚于申请日的全部剔除。对拿不准的变量做一次「时间穿越测试」:只用申请日之前就能拿到的数据重算一遍,如果 IV 从 0.8 掉到 0.05,那它之前的高 IV 全是假的。这一步做完通常会砍掉三到五个变量,AUC 会掉,但掉下来的才是真实水平。

6. 时间外验证与 PSI 监控:让评分卡活过第一个季度

模型跑完不算完,能不能用要看时间外样本,以及上线后第一个季度的分布稳不稳。这一节把验证和监控两块串起来,都是能直接落到脚本里的东西。

6.1 时间外验证:KS 和分数分布一起看

from sklearn.metrics import roc_curve, roc_auc_score def ks_score(y_true, y_score): fpr, tpr, _ = roc_curve(y_true, y_score) return float(np.max(tpr - fpr)) for name, df in [("train", train), ("oot", oot_df)]: s = score_from_proba(df["pred_prob"].values) print(f"{name}: AUC={roc_auc_score(df['flag'], s):.4f} " f"KS={ks_score(df['flag'], s):.4f} " f"mean_score={s.mean():.1f} bad_rate={df['flag'].mean():.3f}")

跑完看三个数:AUC、KS、均值分。KS 从训练集的 0.42 掉到 OOT 的 0.30 以内是可以接受的衰减,掉一半以上就要回去查 5.5。均值分如果 OOT 比训练集低 20 分以上,说明客群整体变差或者分数映射有问题,这时候要通过率必须重新测算,不能沿用训练集的阈值。

6.2 PSI:分数和变量两个层面都要监控

def psi(expected, actual, bins=10): # 用训练集的分位点切箱,OOT 只做映射 edges = np.quantile(expected, np.linspace(0, 1, bins + 1)) edges[0], edges[-1] = -np.inf, np.inf e = np.histogram(expected, bins=edges)[0] / len(expected) a = np.histogram(actual, bins=edges)[0] / len(actual) e, a = np.clip(e, 1e-6, None), np.clip(a, 1e-6, None) return float(np.sum((a - e) * np.log(a / e)))

监控要分两层:分数层的 PSI 告诉你整体客群有没有变,变量层的 PSI 告诉你具体是哪个变量在动。只监控分数层,报警了也不知道从哪查;只监控变量层,变量多了看不过来。常用阈值:

PSI 区间判读动作
< 0.1分布稳定继续观察
0.1 – 0.25轻微漂移按周加密监控,查变量层
> 0.25显著漂移触发重训评估,必要时下调额度

6.3 阈值不是拍脑袋,是拿通过率和坏账率换出来的

分数出来之后,业务方最关心的就一句:卡在多少分。这个不能凭感觉。做法是把 OOT 按分数从高到低排序,每隔 5 分算一次「通过率」和「通过人群的实际坏账率」,画成一张表,让业务方在坏账率和通过率之间选一个点。选完之后再把这个阈值对应的预期坏账率写进监控看板,实际坏账率连续两个月超出预期 20% 以上,就说明卡该重训了。

我踩过最深的一个坑,是第一次出卡时把base_odds和 pdo 的顺序记反了,分数算出来整张卡是反的——坏客户拿高分。当时训练集 KS 一切正常,因为排序没变,只有落到具体分数才暴露。从那以后我每次出卡都强制走一遍「随机抽 100 个样本,手工按 WOE 表加一遍分,和脚本结果对齐」的校验,两分钟的事,能挡住最蠢也最致命的那类错误。数据集和配套脚本放在同一个包里,按第 2 章的字段清单对一遍就能从头跑通,建议第一遍别改参数,先让整条链路跑出结果,再回头调分箱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询