☰
申请评分卡建模全流程:数据集切分、WOE编码与OOT验收
2026/10/28 18:07:56 网站建设 项目流程

简介:面向金融信贷风控场景的Python申请评分卡模型配套数据集与代码包,适合具备Python和机器学习基础的风控从业者、金融数据分析学习者,用于理解申请评分卡的建模流程并完成信用风险预测实践。压缩包共14个文件,以7个pkl、5个py为主,另有1个csv申请样本和1个lr_classweight权重配置;pkl保存训练测试数据、模型及分箱映射结果,py对应建模脚本,csv提供原始申请特征,整体约9.22MB。已有1704人学习下载。内容覆盖数据清洗、特征筛选、WOE分箱、逻辑回归与树模型训练、评分卡规则生成及模型评估等环节,并涉及样本不平衡处理和模型解释思路;代码与数据按建模环节分层组织,可帮助读者复现从样本到评分规则的完整链路,也便于替换数据开展二次实验、指标对比与结果对照,适合作为申请评分卡项目的学习底稿和代码参考。

1. 为什么先定数据集再谈模型:申请评分卡最容易搞反的顺序

见过太多这样的开头:拿到一份信贷样本表,五行代码LogisticRegression().fit(X, y),训练集 AUC 0.93、KS 0.52,图表一画就准备写进答辩 PPT,结果被一句"你的样本是随机切的还是按时间切的"问住。python 评分卡 申请评分这条链路里,模型结构从来不是难点,逻辑回归就那么几行;真正的分水岭是一份口径干净、时间边界清晰、好坏标签明确的建模数据集。这份申请评分卡数据集面向两类人:做毕设或课程设计、需要一套能跑通且讲得清完整流程的同学,以及要快速交付一版可解释评分卡的从业者。它解决的不是"调不出高 AUC",而是"调出来了也不敢用"的问题。

2. 数据集设计:观察期、表现期与好坏标签的三段切分

2.1 时间窗怎么切:观察期、表现期与时间外验证集

申请评分卡的时间轴由三段组成,缺一段标签就是错的。观察期(observation window)从申请日往前推,通常取 6 到 12 个月,用来统计这个人在申请之前发生过什么——查了几次征信、有几笔在贷、额度用了多少。表现期(performance window)从申请日往后推,通常取 6 到 12 个月,用来判定这个人拿到额度之后有没有变坏。中间那个点,也就是申请日apply_date,是整份数据集的唯一锚点。锚点选错,比如拿放款日或者首次还款日去切,后面所有标签都会整体错位一个月以上。

真正让方案站得住的是切分方式。风控场景几乎不用随机切分,因为随机切分会让同一个客户的多条记录同时落进训练集和验证集,模型本质上在背答案,指标虚高是必然的。行业里通用的做法是按申请月份做时间外切分,也就是 OOT(out-of-time)验证:前面一段时间的样本训练,后面一段时间的样本验证,模拟"用历史预测未来"的真实使用姿势。

集合申请日期区间用途建议样本占比
训练集 train2023-01 至 2023-06分箱、WOE 编码、拟合模型约 55%
验证集 valid2023-07 至 2023-12调参、系数符号检查、阈值选择约 25%
时间外测试集 oot2024-01 至 2024-06最终验收,只跑一次约 20%

落到代码上,切分逻辑和标签定义最好写在同一段里,避免两边口径打架:

import pandas as pd # application_sample.csv:一行一个申请件,apply_date 是申请日期 df = pd.read_csv("application_sample.csv", parse_dates=["apply_date"]) # 表现期截止日 = 申请日 + 180 天;超过截止日仍无结果的属于未成熟样本,直接剔除 df["mature_date"] = df["apply_date"] + pd.Timedelta(days=180) CUTOFF = pd.Timestamp("2024-07-01") df = df[df["mature_date"] < CUTOFF].copy() # 好坏标签:表现期内最大逾期天数 dpd_max df["label"] = None df.loc[df["dpd_max"] < 30, "label"] = 0 # 好样本:最多逾期 1~29 天 df.loc[df["dpd_max"] >= 30, "label"] = 1 # 坏样本:逾期 30 天及以上 df = df.dropna(subset=["label"]).astype({"label": int}) # 按申请日期做时间外切分,不随机 train = df[df["apply_date"] < "2023-07-01"] valid = df[(df["apply_date"] >= "2023-07-01") & (df["apply_date"] < "2024-01-01")] oot = df[df["apply_date"] >= "2024-01-01"] print(train.shape, valid.shape, oot.shape) print(train["label"].mean(), valid["label"].mean(), oot["label"].mean())

CUTOFF这个变量是防"样本未成熟"的闸门:假设现在是 2024 年 7 月,一笔 2024 年 3 月的申请还没走完 180 天表现期,它的dpd_max必然偏小,如果混进训练集会人为压低坏账率。代码最后一行打印三个集合的坏样本占比,是给方案做体检——三个值应该接近,如果 OOT 的坏账率是训练集的 1.5 倍以上,说明客群在这一年里发生了明显变化,后面解释 KS 下降时要把这个原因写进去,而不是简单归咎于"模型过拟合"。

2.2 好坏标签定义与账龄分析

好坏定义看起来是一句话的事,实际上决定了整个方案的噪音水平。常见做法是把表现期内最大逾期天数(dpd_max)达到 30 天及以上的定义为坏样本,也就是常说的 M1;1 到 29 天的归为好样本。为什么不用 M0(逾期 1 天就算坏)?因为首期逾期里有大量代扣失败、忘记还款、跨行到账延迟的情况,这些客户后来大多正常还款,把它们标成坏会引入大量噪声,模型的区分度会被稀释。为什么不用 M3(90 天以上)?坏样本数量会骤降到几百条甚至更少,且走到 M3 的客户往往已经历过多轮催收,样本本身带有强烈的选择偏差。

介于好与坏之间还有一类灰色样本,比如表现期内出现过展期、重组、减免,或者表现期不足 180 天的。这类样本我一般直接剔除,不做任何插补。它们的标签在业务上本身就说不清,硬塞进训练集只会让系数变得难以解释,答辩时被追问"这个客户到底算好算坏"会很难看。

标签定完还要做一次账龄分析(vintage),确认表现期长度够用。做法是按申请月份分组统计坏账率,看这条曲线在几个月后趋于平稳:

# 按月统计样本量与坏账率,两个用途:确认表现期足够、确认客群没有剧烈漂移 monthly = df.groupby(df["apply_date"].dt.to_period("M"))["label"].agg(["count", "mean"]) print(monthly.tail(12))

如果某个申请月份的坏账率在 6 个月后还在明显爬坡,说明 180 天的表现期不够长,这批样本的标签被系统性低估了,需要把表现期拉长到 270 天甚至 360 天重做。这一步很多方案直接跳过,等到 OOT 阶段 KS 断崖式下跌才回头找原因,代价是整条流水线重跑一遍。

2.3 数据集字段清单与表结构

这份数据集按申请评分卡的典型入模需求组织,字段分三组,全部是申请时刻就能拿到的信息,不含任何贷后行为,这是申请评分卡和 Behavior Score 最本质的区别。

字段类型含义是否入模备注
cust_idstring脱敏客户编号否仅用于去重
apply_datedate申请日期否只用于时间切分
ageint申请人年龄是截断到 18~70
maritalstring婚姻状态是类别变量
educationstring学历层级是有序类别
occupation_levelint职业等级 1~5是由职业大类映射
monthly_income_binstring月收入区间码是已做分箱,避免原始金额
city_tierint城市层级 1~5是类别变量
qry_3m / qry_6mint近 3 / 6 个月征信查询次数是强风险信号
loan_cntint当前在贷笔数是反映多头
card_utilfloat信用卡额度使用率是0~1
max_dpd_12mint近 12 个月最大逾期天数是观察期内取值
outstanding_amtfloat当前未结清金额是取对数后入模
platform_cnt_3mint近 3 个月申请平台数是多头核心指标
dpd_maxint表现期内最大逾期天数否仅用于生成 label
labelint0 好 / 1 坏目标不可入模

这张表里最需要强调的两条规则:dpd_max和label绝不能进特征,apply_date之后的任何字段都不能进特征。前者是标签泄漏最典型的形式,后者属于时间穿越,两者都会让训练指标好得不真实。另外,monthly_income_bin给的是区间码而不是原始金额,是为了降低极端值和隐私风险,建模时按有序变量处理即可。

2.4 采样与样本量:坏样本数决定方案上限

样本量这件事,很多方案里写的是"共 N 万条",但真正决定模型能不能训稳的是坏样本条数,不是总条数。经验值是坏样本至少 500 条,最好 1000 条以上,好坏比控制在 1:5 到 1:15 之间。如果坏样本只有 200 条,即使总样本有 10 万条,分箱时每个箱子里的坏样本可能只有个位数,WOE 会剧烈波动,IV 也算不准,最后得到的分数在真实数据上会抖得厉害。

另一个常见误区是把好坏样本过采样到 1:1。逻辑回归输出的概率是参与刻度换算的,过采样会把截距整体抬高,导致换算出来的分数系统性偏移——本来 620 分的客户被算成 580 分,整个分数分布全错位。这份数据集本身的好坏比控制在合理区间,直接原样训练即可;如果确实需要用class_weight="balanced"做对比实验,要在验收表里注明"该版本的分数不可直接用于刻度换算",否则后面做分数监控时会对不上账。

3. 特征工程流水线:缺失、分箱、WOE 与 IV 筛选

3.1 缺失值和异常值:先判断缺失是不是信息

缺失处理的第一步不是填补,而是判断这个缺失有没有业务含义。征信查询次数缺失,可能意味着这个客户从来没有过信贷记录,属于"白户",本身就是一个强风险信号;而月收入缺失更可能是客户懒得填或者渠道采集失败,属于纯噪声。这两种缺失的处理方式完全不同。

通常的做法是按缺失率分层:缺失率超过 50% 的字段直接剔除,除非业务上能明确解释;缺失率在 5% 到 50% 之间的,不填补,而是单独设一个"缺失"箱,让 WOE 编码自己去判断这个缺失箱的风险高低;缺失率低于 5% 的,用众数或者中位数填上,影响可以忽略。这个策略比无脑fillna(0)稳妥得多,因为fillna(0)在"查询次数"这种字段上等价于告诉模型"这个人从没查过征信",实际上是把缺失伪装成了一个极值。

异常值同理。年龄填 200 岁、月收入填 9999999、额度使用率超过 3(超额透支),这类值不能直接删,也不能直接截断到均值。我的习惯是用 1% 和 99% 分位数做缩尾(winsorize),把极端值压到边界上,既保留了"这个人是极端值"的信息,又不会让逻辑回归的系数被一两个点拉爆。

3.2 分箱:等频起步、卡方合并、单调性收尾

评分卡不用原始连续值入模,而是把每个特征切成 3 到 5 个箱,再对箱做 WOE 编码。原因有三:一是逻辑回归对非线性关系无能为力,分箱等于手工构造了非线性;二是分箱后每个箱对应一个分数段,业务方拿到的是一张能直接对照的打分表;三是分箱能把异常值和缺失值统一收进某个箱里,鲁棒性更好。

分箱的流程一般是三步:先用等频分箱切出 20 个初始箱,保证每个箱样本量接近;再按卡方值把相邻箱两两合并,直到箱数降到 5 个以内;最后检查坏账率随箱序号是否单调,如果不单调,就把违反单调的相邻箱强行合并。第三步是很多人漏掉的,单调性不满足的分箱在业务上没法解释——你没办法跟评审说"收入越高风险越高,但最高那档风险又掉下来了"。

几个硬约束要写进代码:每个箱的样本占比不低于 5%,坏样本数不低于 30 条,箱数控制在 3 到 5 个。占比过低的箱会让 WOE 的方差变得极大,一条样本的变动就能让整箱 WOE 跳一个数量级。

3.3 WOE 与 IV:公式、代码和方向一致性

WOE(Weight of Evidence)衡量的是某个箱里坏样本占比相对于整体坏样本占比的偏离程度,公式是:

WOE_i = ln( (Bad_i / Bad_total) / (Good_i / Good_total) )

IV(Information Value)是这个特征所有箱的 WOE 加权求和,用来衡量整体区分能力:

IV = Σ (Bad_i/Bad_total - Good_i/Good_total) × WOE_i

代码实现要注意两件事:除零保护和方向统一。前者靠加一个极小值 eps 解决,后者靠固定 WOE 的分子分母顺序——所有特征都必须用"坏 / 好"的顺序,不能有的用"好 / 坏"。

import numpy as np import pandas as pd def woe_iv(bin_series, target): """bin_series: 分箱后的类别列;target: 0/1 标签。 统一用 ln(bad_dist / good_dist),保证 WOE 越大风险越高。""" tmp = pd.DataFrame({"bin": bin_series, "y": target}) stat = tmp.groupby("bin")["y"].agg(total="count", bad="sum") stat["good"] = stat["total"] - stat["bad"] stat["bad_rate"] = stat["bad"] / stat["total"] stat["pct"] = stat["total"] / stat["total"].sum() # 箱占比,用于检查 5% 下限 eps = 1e-6 # 防止某个箱坏样本为 0 时 log 报错 bad_dist = stat["bad"] / stat["bad"].sum() + eps good_dist = stat["good"] / stat["good"].sum() + eps stat["woe"] = np.log(bad_dist / good_dist) stat["iv"] = (bad_dist - good_dist) * stat["woe"] return stat, float(stat["iv"].sum()) # 用法:对每个候选特征循环,把 IV 汇总成一张表排序 iv_table = {c: woe_iv(train[c + "_bin"], train["label"])[1] for c in candidate_feats} print(pd.Series(iv_table).sort_values(ascending=False).head(20))

这段代码返回的stat是一张分箱明细表,业务上可以直接当打分卡底稿用:每一行是一个箱,woe列就是这个箱对应的风险刻度。eps取 1e-6 而不是 0,是因为某些箱可能一个坏样本都没有,直接取对数会得到inf,整个 IV 就废了。方向统一这一点必须在函数内部固定死,如果不同特征混用两种方向,训练出来的系数符号会一半正一半负,后面做系数自检时根本分不清是特征真的反向还是编码反了。

3.4 入模清单:IV、相关性、VIF 三道闸

分箱算完 WOE 之后,候选特征可能有几十个,不能全塞进模型。筛选一般过三道闸。

第一道是 IV 阈值。IV 低于 0.02 的特征基本没有区分力,直接剔除;0.02 到 0.1 是弱区分,0.1 到 0.3 是中等,0.3 以上算强。但 IV 高于 0.5 要特别警惕,尤其是单个特征 IV 就超过 0.8 的,八成是标签泄漏或者时间穿越——比如把"当前逾期天数"当成特征,那模型等于在抄答案。

第二道是相关性。两个特征相关系数超过 0.7 时保留 IV 更高的那个,另一个剔除。相关性高不只是共线性的问题,更麻烦的是系数符号会变得不稳定,换一批样本系数就翻个方向,答辩时解释不清。

第三道是 VIF(方差膨胀因子)。VIF 超过 5 说明该特征能被其他特征线性解释,系数估计的方差被放大了,通常继续剔除。三道闸过完,入模特征控制在 8 到 15 个比较合适,太少了区分度不够,太多了打分表做出来没人看得懂。

4. 模型训练与评分刻度转换:从逻辑回归系数到 300–850 分

4.1 为什么这套方案里逻辑回归比 XGBoost 更合适

树模型在区分度上通常能比逻辑回归高几个点,但申请评分卡这个场景里,几个点的 AUC 换不来可解释性。评分卡要交付的不只是一个模型文件,而是一张能被业务、风控、评审三方同时看懂的分数表:哪个特征、落在哪个箱、扣多少分、加多少分,每一分都要能追溯。逻辑回归加 WOE 编码天然满足这个要求,系数乘以 WOE 就是该箱的分值,累加就是总分。XGBoost 做出来的东西只有 SHAP 图,客户经理问"为什么这个客户被拒了",你没法把 300 棵树的路径讲给他听。

比较务实的做法是把树模型当对照基线:训练一个 XGBoost,看它的 KS 比逻辑回归高多少。如果只高 0.02 以内,直接用逻辑回归,方案干净;如果高出 0.08 以上,说明特征和标签之间存在明显的非线性,这时候应该回头检查分箱是不是太粗,而不是直接换成树模型。这条对照实验写进方案里,答辩时是加分项。

4.2 训练配置与系数符号自检

训练配置上,L2 正则、solver="lbfgs"、max_iter调到 1000 以上,class_weight保持默认None。原因在 2.4 节说过——一旦做类别加权,输出的概率就不再是真实概率,后面的刻度换算会整体偏移。

from sklearn.linear_model import LogisticRegression import pandas as pd feats = ["woe_age", "woe_income", "woe_qry3m", "woe_loan_cnt", "woe_card_util", "woe_max_dpd12m", "woe_platform3m"] # class_weight 用默认值,保证输出概率可用于刻度换算 model = LogisticRegression(penalty="l2", C=1.0, solver="lbfgs", max_iter=1000, class_weight=None) model.fit(train[feats], train["label"]) coef = pd.Series(model.coef_[0], index=feats).sort_values() print(coef) # WOE 方向统一为 ln(bad/good) 后,所有系数应为正;出现负值说明该特征编码方向反了 assert (coef > 0).all(), "存在负系数,检查 WOE 方向或该特征是否与标签反向"

倒数第二行的断言是这套方案里最省事的一道防线。因为 WOE 已经把所有特征都转换成"值越大风险越高"的单调形式,逻辑回归拟合出来预测坏样本,系数理应全部为正。一旦出现负系数,几乎可以断定三种情况之一:该特征的 WOE 方向算反了、该特征存在标签泄漏、该特征与其他特征高度共线导致系数翻转。这三种问题在训练阶段发现,改一行代码就能修;等上了 OOT 才发现,就得推倒重来。

4.3 评分刻度换算:PDO、基准分与基准 Odds

逻辑回归输出的是概率,业务要的是分数。两者之间的桥梁是三个参数:基准分(BASE_SCORE)、基准好坏比(BASE_ODDS)、以及 PDO(Points to Double the Odds,好坏比每翻一倍增加的分数)。

设 odds = p_good / p_bad,分数与 odds 的关系是线性对数关系:

Score = A + B × ln(odds) B = PDO / ln(2) A = BASE_SCORE - B × ln(BASE_ODDS)

取 BASE_SCORE = 600、BASE_ODDS = 30(即坏账率约 3.2%)、PDO = 20,则 B ≈ 28.85,A ≈ 501.87。逻辑回归拟合的是 ln(p_bad/p_good),也就是 -ln(odds),所以最终算分要取负号:

import numpy as np PDO = 20 # 好坏比翻一倍,分数 +20 BASE_SCORE = 600 # 基准分 BASE_ODDS = 30 # 基准分对应 good:bad = 30:1 B = PDO / np.log(2) # ≈ 28.85 A = BASE_SCORE - B * np.log(BASE_ODDS) # ≈ 501.87 def to_score(intercept, coef, x): """x 为一条样本的 WOE 向量,顺序与 coef 一致""" logit_bad = intercept + np.dot(coef, x) # 模型预测的是 ln(bad/good) return A - B * logit_bad # Score = A + B*ln(good/bad) row = train[feats].iloc[0].values print(to_score(model.intercept_[0], model.coef_[0], row))

这段代码最容易翻车的地方就是最后的正负号。写成A + B * logit_bad的话,坏客户的分数反而更高,整个模型方向完全颠倒,而且因为分数看起来还在 300 到 850 之间,肉眼很难发现。验证方法很简单:拿训练集里坏账率最高的一批样本算分,分数应该明显低于好样本,如果反了就说明符号错了。另外,BASE_ODDS一旦定下来就不能随便改,改一次全量客户的分数都要重算,历史分数没法对比,监控也就断了。

4.4 评估指标:KS、AUC、PSI 的计算与阈值

申请评分卡看三个指标:KS 看区分度,AUC 看整体排序能力,PSI 看分数分布稳不稳。

KS 是累计好样本分布和累计坏样本分布的最大差值,取值 0 到 1。训练集 KS 在 0.3 以上算合格,0.4 以上算不错。但真正要看的是 OOT 上的 KS 相对训练集掉了多少——掉 0.05 以内属于正常波动,掉 0.1 以上就要在方案里专门写一段原因分析。AUC 通常和 KS 同向变动,0.7 以上可以接受。

PSI 衡量的是两次分数分布的差异,用来做上线后的稳定性监控,也用来做 OOT 验收。

from sklearn.metrics import roc_curve import numpy as np def ks(y_true, y_prob): fpr, tpr, _ = roc_curve(y_true, y_prob) return float(np.max(tpr - fpr)) def psi(expected, actual, bins=10): """expected: 训练集分数;actual: OOT 或线上分数。 以训练集的分位点切箱,两边的箱边界必须一致,否则没有可比性。""" edges = np.quantile(expected, np.linspace(0, 1, bins + 1)) edges[0], edges[-1] = -np.inf, np.inf e = np.histogram(expected, edges)[0] / len(expected) a = np.histogram(actual, 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))) train_score = -model.decision_function(train[feats]) # 越大越坏,仅用于算 PSI oot_score = -model.decision_function(oot[feats]) print("KS(train) =", ks(train["label"], -train_score)) print("KS(oot) =", ks(oot["label"], -oot_score)) print("PSI =", psi(train_score, oot_score))

PSI 的判定惯例是:小于 0.1 认为分布稳定,0.1 到 0.25 需要关注并排查原因,大于 0.25 则认为分布发生显著偏移,模型需要重训。psi函数里用训练集的分位点切箱这一点很关键,如果两边各切各的箱,算出来的 PSI 没有意义——箱边界不一样,比较的就不是同一个东西了。

5. 避坑与排查:评分卡项目里最容易翻车的五件事

5.1 训练 AUC 0.95,OOT 掉到 0.62

现象:训练集和验证集指标漂亮得不像话,一跑 OOT 直接崩盘,KS 从 0.55 掉到 0.18。

原因:随机切分加上 WOE 在全量数据上拟合。随机切分让同一客户的多条记录横跨训练集和测试集;WOE 如果在全量数据上算完再切分,等于把测试集的标签信息提前泄露进了特征。两个问题叠加,指标必然虚高。

解决:切分动作放在所有特征工程之前,先切 train/valid/oot,再只用 train 拟合分箱边界和 WOE 映射表,然后用map把同一套映射应用到 valid 和 oot 上。分箱边界一旦确定就冻结,后续不允许再动。这一步做完,训练集 KS 通常会掉到 0.35 到 0.45 之间,这才是真实水平。

5.2 某个特征 IV 高到 1.2

现象:IV 排序表里有一个特征遥遥领先,单特征 IV 就有 1.2,其他特征都在 0.1 上下。

原因:标签泄漏或者时间穿越。常见的是把"当前逾期天数""近 30 天催收次数""最近一次还款状态"这类贷后字段混进了特征池,这些字段在申请时刻根本拿不到,但数据表里存在,一不留神就被select_dtypes(include="number")全选进去了。

解决:维护一份显式的特征白名单,只允许申请时刻可获取的字段入模,不要用自动全选。同时设一条硬规则:IV 超过 0.5 的特征一律人工复核,逐个确认数据可得时间点。

5.3 分数分布出现双峰或者大量重复分

现象:画出分数的直方图,要么是两个明显的峰,要么有一大段客户分数完全一样,区分度在尾部塌陷。

原因:分箱太粗,或者缺失值全部集中在一个箱里。3 个箱的特征有 12 个,总分组合数就那么多,客户一多必然撞分。缺失率高的特征如果没做单独分箱,缺失客户会全部落到同一个 WOE 上,形成一个人为的堆积峰。

解决:把入模特征的箱数控制在 4 到 5 个,缺失单独成箱,同时对分数做一次分布检查——如果最高频的分数占比超过 5%,说明分箱粒度不够,需要回头细化。另外可以引入第二个小数位,但那是治标,根子还在分箱。

5.4 上线后分数整体上移,通过率莫名变高

现象:模型没动,代码没改,某个月开始客户平均分涨了 20 分,审批通过率跟着往上走。

原因:客群漂移或者刻度未校准。前者是业务侧投放渠道变了,进来的客户本身质量不同;后者是训练时用了class_weight或者样本做过采样,导致截距偏移,分数基准线本身就不对。

解决:上线前用训练集分数做一次锚定,确认基准分对应的坏账率和业务实际坏账率在同一个量级。上线后按月跑 PSI,同时对分数做分箱监控,一旦某个月 PSI 超过 0.1,先查渠道和客群结构,再查模型。

5.5 好客户被打低分,坏客户被打高分

现象:抽查几十个样本,发现明显资质很好的客户分数只有 480,而一个多头借贷的客户拿到了 660。

原因:to_score函数里的符号写反了,或者 WOE 方向在不同特征之间不统一。前者在 4.3 节讲过,A - B * logit_bad写成A + B * logit_bad就会整体反转;后者是部分特征按 ln(bad/good) 编码、部分按 ln(good/bad) 编码,系数符号一正一负,混在一起后方向就乱了。

解决:写一个符号自检脚本,每次训练完自动跑——先确认所有系数为正,再抽 100 条样本,比较坏样本均分和好样本均分,前者必须显著低于后者。这个脚本放进流水线,能挡住绝大多数低级错误。

6. 验收与答辩:一份能自证的检查清单

方案做到最后,拼的不是模型多花哨,而是能不能拿出一套让人信服的证据链。我一般会在交付前把下面这张表从头到尾跑一遍,每一项都要有具体数字,不能写"良好""符合预期"这种模糊表述。

检查项合格线怎么算不达标怎么办
坏样本数量≥ 500train["label"].sum()拉长取数区间或放宽到 M0
好坏比1:5 到 1:15value_counts()检查样本口径是否一致
训练集 KS≥ 0.30ks()函数检查分箱粒度和 IV 筛选
OOT KS 衰减≤ 0.05训练集 KS − OOT KS排查客群漂移,写进方案说明
OOT AUC≥ 0.70roc_auc_score检查是否漏掉强特征
系数符号全部为正coef > 0断言复核 WOE 方向与标签泄漏
PSI(训练 vs OOT)< 0.10psi()函数0.1~0.25 需关注,>0.25 重训
分数区间落在 300~850to_score批量算调整 BASE_SCORE 或 PDO
坏样本均分低于好样本 80 分以上分组mean()检查符号与刻度参数

答辩环节的叙述线,我建议按"样本怎么来、特征怎么造、模型怎么选、分数怎么定、上线怎么盯"这五步走,每步配一张图。样本那步重点讲清楚为什么按时间切分而不是随机切分,这是评委最容易追问的点,也是最能体现专业度的地方;特征那步讲 WOE 单调性和 IV 阈值,展示一张分箱明细表比讲十分钟原理管用;模型那步主动交代逻辑回归的局限,并给出与树模型的对照结果,反而显得诚实;刻度那步把 A、B 两个参数和 PDO 的含义讲透,顺带说明为什么选 600 分和 30:1;最后一步给出 PSI 的监控阈值和触发重训的条件。整条线讲完,评委基本问不出"这个模型能不能用"这种问题,因为你自己已经把边界划清楚了。

有一次我把 KS 0.52 的方案交上去,被追问了一句"OOT 是多少",才发现根本没做时间外验证,那 0.52 是全量随机切分下的数字。回头重跑,OOT 只有 0.29,差点整个方案推倒重来。从那以后我每次做评分卡,不管时间多紧,都强制先跑一遍这张验收表,尤其是坏样本数、系数符号、OOT KS 这三项,一项不过就不往下走。希望帮到你。

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

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

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

立即咨询