☰
信贷风控中的申请评分卡建模:基于Python的WOE分箱与逻辑回归完整流程
2026/10/13 9:05:19 网站建设 项目流程

简介:信贷风控模型建设中,如何兼顾预测能力与业务可解释性一直是核心挑战。逻辑回归作为一种成熟稳健的算法,配合证据权重(WOE)与信息价值(IV)的特征工程方法,能够将原始申请数据转化为业务人员易于理解的分数表。这种信用评分卡不仅保留了统计模型的严谨性,还满足监管审计对决策过程透明化的要求。在实际应用中,银行与消费金融公司常借助Python生态完成从数据清洗、变量分箱、WOE编码到逻辑回归拟合、分数刻度映射的整套流程,最终输出可落地的审批策略。本文以申请评分卡为对象,系统拆解了基于公开信贷数据集构建标准评分卡的每一步工程细节,覆盖数据准备、标签定义、分箱合并、系数推导及上线校验等关键环节,为信贷策略与风控从业者提供了一份可直接复现的Python实践指南。

1. 申请评分卡是这么一种东西:它把审批逻辑变成一张能解释的分数表

申请评分卡(Application Scorecard)是信贷审批场景里最不性感但最稳的方案:用 Python 从申请数据里抽特征、分箱、算 WOE、跑逻辑回归,最后生成一张每个变量每档都有分数的表。审批人员拿起表就能解释为什么拒绝,监管要审计也能逐项追溯。它不像 XGBoost 那样能榨干数据里每一点信号,却换来可解释、可监控、可快速修正。做这个方向的人大多不是建模科学家,而是信贷策略、数据分析转岗风控的从业者——大家卡住的往往不是模型本身,而是数据:标签怎么定义、表现期取多长、拒绝样本怎么处理、字段从哪来。本文按“数据集 → 分箱 → 建模 → 刻度映射 → 上线校验”的顺序,把每一步写成可以直接复现的 Python 流程。和量化交易里的因子打分一样,评分卡本质也是把一堆指标映射成一个分数再排序,只是这里的分数量纲是业务方看得懂的“600 分 / 650 分”。

2. 数据集怎么选:公开信贷数据、字段映射与坏客户定义

2.1 申请评分卡的数据长什么样:字段要对应“申请时点”

申请评分卡和催收评分卡、行为评分卡最大的区别在于:建模用的特征必须是在客户申请那一刻能拿到的信息。用 Python 做这个模型时,最常被问到的一个问题是“数据集去哪找”。真实业务里,申请数据来自进件系统,包含征信查询、收入负债、职业学历这些字段;但教学和面试场景下,从业者普遍落到两个公开数据上:UCI 的 Statlog German Credit 数据集,以及 Kaggle 上的 Give Me Some Credit(GMSC)竞赛数据。前者 1000 条样本、20 个字段,后者 15 万条样本、10 个字段,两者都非常适合作为 Python 建模练习的数据集。

不过选型时要特别注意:German Credit 的特征更偏“申请侧”,比如现有账户状态、贷款用途、储蓄账户、就业年限,这些在进件时就能拿到,天然适合申请评分卡练习;GMSC 里像“过去 30-59 天逾期次数”“90 天以上逾期次数”这类字段,更接近行为评分卡的数据形态——如果硬套到申请场景,必须问自己:申请时点这些历史逾期记录是否已经在征信报告中体现。把行为字段放回申请评分卡里,是数据选型阶段最常见的翻车点。

German Credit 的关键字段映射大致如下,初学者可以照着这个表去理解申请评分卡到底在用哪些信息:

原始字段字段含义类型建模价值
checking_account现有支票账户状态分类型负债与流动性侧写
duration贷款期限(月)连续型申请条件本身
credit_history历史信用记录分类型历史履约能力
purpose贷款用途分类型资金用途风险
credit_amount贷款金额连续型敞口规模
savings_account储蓄账户/债券分类型偿债缓冲
employment就业年限分类型收入稳定性
age年龄连续型基础人口学

2.2 数据准备代码:模拟数据和公开数据双轨跑

我的经验是准备两套数据:先用一段模拟数据把管道跑通,再换公开数据集。原因是公开数据在下载、字段解析上可能出幺蛾子,先把代码逻辑验证好,后面排错范围就小很多。

import numpy as np import pandas as pd np.random.seed(42) n = 5000 def make_sim_data(n=5000): """生成一份模拟的申请进件数据,好/坏标签按逻辑回归假设生成""" df = pd.DataFrame({ 'age': np.random.randint(22, 60, n), 'monthly_income': np.random.lognormal(mean=8.5, sigma=0.4, size=n).round(0), 'debt_ratio': np.random.uniform(0.05, 0.95, n), 'history_30d': np.random.poisson(0.3, n), # 历史30天逾期次数 'num_queries_3m': np.random.poisson(1.5, n), # 近3个月征信查询次数 'purpose': np.random.choice(['car', 'home', 'education', 'other'], n, p=[0.3, 0.3, 0.2, 0.2]), 'marital': np.random.choice(['married', 'single', 'divorced'], n) }) # 人为设置风险方向,保证数据里有真实信号可学习 log_odds_bad = ( -0.05 * (df['age'] - 35) - 0.4 * np.log(df['monthly_income'] / 10000) + 1.5 * df['debt_ratio'] + 0.5 * df['history_30d'] + 0.3 * df['num_queries_3m'] + 0.2 * (df['purpose'] == 'education').astype(int) - 0.3 * (df['marital'] == 'married').astype(int) + np.random.normal(0, 0.3, n) ) p_bad = 1 / (1 + np.exp(-log_odds_bad)) df['bad'] = np.random.binomial(1, p_bad).astype(float) return df df = make_sim_data() print(df.shape) print(df['bad'].mean()) # 坏客户占比,期望落在 0.2~0.3 区间

这段代码把“坏客户”生成为 1,好客户为 0。debt_ratio系数为正表示负债越高坏的概率越大,monthly_income系数为负表示收入越高风险越低。这样生成的数据虽然是人造的,但变量方向符合业务直觉,跑出来的评分卡也更容易解读。坏样本占比在 0.2~0.3 之间,接近真实信贷申请客群的违约分布。

公开数据的读取和清洗写成独立脚本,和模拟数据共用同一个下游管道:

# 从 UCI Statlog 下载 German Credit 数据,存为 data/german_credit.csv # 原始文件里最后一列标签是 1=good, 2=bad,需要映射成 0/1 df_gc = pd.read_csv('data/german_credit.csv') # 如果文件里带双引号,加一行 quotechar='"' 参数 # df_gc = pd.read_csv('data/german_credit.csv', quotechar='"') # 假设列名和 UCI 文档一致,最后一列名为 target df_gc['bad'] = (df_gc['target'] == 2).astype(int) df_gc = df_gc.drop(columns=['target'])

读取参数里quotechar='"'是处理 UCI 数据常见的带引号 CSV 格式。特征列名如果和文档不一致,先print(df_gc.columns.tolist())把列打印出来人工核对。这一步看起来琐碎,实际却是整个评分卡项目里最容易翻车的地方——列名错一位,后面所有分箱逻辑全错。

2.3 好/坏样本定义与切分方式:表现期和时序是硬约束

标签定义直接决定模型学什么。“坏客户”在信贷业务里一般定义为:开户后 12 个月(表现期)内,出现逾期 90 天及以上的客户;好客户则是表现期内从未逾期或逾期不超过 30 天。观察点(observation point)是申请审批通过日,从这一天往后数 12 个月看表现。Python 里落地这个概念,需要在进件表里加两列:observe_date(申请通过日)和perf_end_date(通过日 + 12 个月),标签取的是表现期内的最差逾期状态。

训练集和测试集不能用train_test_split(random_state=42)随机切。申请数据天然带时间顺序,随机切分会把早期客户和晚期客户混在一起——如果客群策略在中间某个时间点调整过,模型学到的就是“两个策略的混合体”,上线后必然偏移。常规做法是按申请月份排序,前 80% 做训练集,后 20% 做验证集:

df = df.sort_values('observe_date').reset_index(drop=True) cut_idx = int(len(df) * 0.8) train_df = df.iloc[:cut_idx].copy() test_df = df.iloc[cut_idx:].copy() print(f"train: {len(train_df)}, test: {len(test_df)}")

提示:评分卡建模里的“数据集”不止是原始 CSV,而是“原始字段 + 观察点 + 表现期标签”的完整结构。只拿一张宽表就跑模型,后面业务复核时会发现标签定义说不清楚,这是血泪教训。

3. 特征分箱与 WOE/IV 计算:评分卡和机器学习模型的第一个分岔口

3.1 为什么要分箱而不是直接标准化进回归

评分卡不用原始连续值直接进逻辑回归,核心原因有三个。第一,信贷风险和特征的关系不是线性的:年龄对违约率的影响是 U 型,年轻人收入低违约高、中年人稳定、退休后收入骤降违约又抬头;收入的对数效应也是递减的。分箱后每一档单独给一个 WOE 值,相当于让模型自己拟合分段非线性。第二,分箱天然处理了异常值和缺失值,比如收入为 0、负债率大于 1 的异常个体可以单独成箱,避免极端值拉动整个回归系数。第三,也是业务上最关键的一点,分箱后的模型可以直接转成审批规则表:“年龄 22-30 岁扣 15 分,负债率大于 0.7 扣 30 分”——这种表达审批人员听得懂,监管也愿意接受。机器学习模型在这点上就是黑匣子,这也是为什么评分卡到今天依然是信贷审批的主力。

WOE(Weight of Evidence,证据权重)是分箱后每一档的“风险强度”度量,定义为:

WOE = ln(好客户占比 / 坏客户占比)

注意这里的好/坏方向要和你后面逻辑回归的目标变量编码一致。我在整个流程里统一用“好客户=1,坏客户=0”,所以 WOE 越大代表这一档客户越好。IV(Information Value,信息值)是 WOE 的加权和,衡量整个变量的预测能力,业界通行判断标准是:

IV 范围预测能力处理建议
< 0.02无预测力剔除
0.02 ~ 0.1弱可保留,结合业务判断
0.1 ~ 0.3中等优先保留
> 0.3强保留但警惕过拟合

3.2 等频分箱与自动合并:qcut 的坑和补救

分箱的方法很多:等距、等频、卡方、决策树。评分卡实战里用得最多的是等频分箱加手工合并,Python 里对应pd.qcut。它按分位数切箱,保证每箱样本量接近,但不保证分箱结果业务上合理——某个箱子可能全是坏客户,或者某个区间恰好落在完全没意义的点上。

def auto_bin(df, col, target, max_bins=5, min_bin_pct=0.05): """等频分箱 + 自动合并。返回分箱边界和每箱统计。 参数说明: max_bins: 最大分箱数,评分卡一般不超过 5~6 箱 min_bin_pct: 单箱最小样本占比,低于该值触发合并 """ df = df.copy() # duplicates='drop' 处理分位数重复的情况,比如年龄大量集中在 30 岁 df['bin'] = pd.qcut(df[col], q=max_bins, duplicates='drop') grouped = df.groupby('bin', as_index=False).agg( total=('bad', 'count'), bad_sum=('bad', 'sum') ) grouped['good_sum'] = grouped['total'] - grouped['bad_sum'] grouped['bad_rate'] = grouped['bad_sum'] / grouped['total'] # 合并样本占比过小的箱子:从第一箱开始,把当前箱合并到下一箱 while True: small = grouped[grouped['total'] / df.shape[0] < min_bin_pct] if small.empty: break idx = small.index[0] if idx == len(grouped) - 1: idx = idx - 1 # 最后一箱和前一箱合并 new_total = grouped.loc[idx, 'total'] + grouped.loc[idx + 1, 'total'] new_bad = grouped.loc[idx, 'bad_sum'] + grouped.loc[idx + 1, 'bad_sum'] grouped.loc[idx, 'total'] = new_total grouped.loc[idx, 'bad_sum'] = new_bad grouped.loc[idx, 'good_sum'] = new_total - new_bad grouped.loc[idx, 'bad_rate'] = new_bad / new_total # 合并后区间标记取前一箱的下边界到后一箱的上边界 grouped.loc[idx, 'bin'] = pd.Interval(left=grouped.loc[idx, 'bin'].left, right=grouped.loc[idx + 1, 'bin'].right) grouped = grouped.drop(index=idx + 1).reset_index(drop=True) return grouped

这段代码里duplicates='drop'是最容易被忽略的参数。遇到年龄、期数这类离散化严重的变量,如果没有这个参数,qcut会直接抛异常“Bin edges must be unique”。合并逻辑简单粗暴:从头扫到尾,样本占比低于 5% 的箱就和下一箱合并。实际业务里箱占比低于 5% 的档位解释能力太弱,统计上也不稳定,合并后模型更稳健。注意每次合并且要重新计算 bad_rate,因为评分卡每箱的得分依据就是这箱的坏账浓度。

3.3 WOE/IV 计算代码与变量筛选

分箱完成后就可以算 WOE 和 IV。这个环节是整个评分卡建模的承重墙,代码不难,但方向、公式、边界条件一步都不能错。

def calc_woe_iv(grouped): """输入 auto_bin 的输出,计算每箱 WOE 和变量总 IV""" df = grouped.copy() total_good = df['good_sum'].sum() total_bad = df['bad_sum'].sum() df['dist_good'] = df['good_sum'] / total_good df['dist_bad'] = df['bad_sum'] / total_bad # 防 0 保护:WOE 公式里有 ln,箱内 0 好/0 坏会导致 ±inf df['woe'] = np.log((df['dist_good'] + 1e-6) / (df['dist_bad'] + 1e-6)) df['iv'] = (df['dist_good'] - df['dist_bad']) * df['woe'] var_iv = df['iv'].sum() return df, var_iv # 对每个特征循环计算 IV,输出排序结果 iv_dict = {} for col in ['age', 'debt_ratio', 'monthly_income', 'history_30d', 'num_queries_3m']: grouped = auto_bin(train_df, col, 'bad') woe_df, var_iv = calc_woe_iv(grouped) iv_dict[col] = var_iv iv_series = pd.Series(iv_dict).sort_values(ascending=False) print(iv_series)

计算时我只在公式里加了1e-6做平滑,替代直接删除零样本箱子——完全删箱会改变分箱结构,平滑则保留箱子只做数值保护,这个选择要记录到建模文档里。IV 排序出来后,低于 0.02 的变量直接剔除;IV 在 0.02 到 0.1 之间的看业务价值,比如婚姻状况这类字段业务上必须要,IV 低也保留;IV 超过 0.3 的字段要再确认一下是不是泄露了未来信息。

注意:分箱不是一次性工作,每个变量的分箱结果要人工看一眼单调性。如果 WOE 序列在中间箱反复横跳,比如 0.8、-1.2、0.5,说明这个变量和风险的关系不稳定,优先检查分箱边界是否选错,其次考虑删变量。评分卡分箱确实带一点玄学,但边界必须服从业务解释,不能为了追求单调性硬切。

4. 逻辑回归拟合与分数刻度映射:从 log-odds 到 600 分

4.1 用 WOE 值替换原始特征训练 Logit

分箱和 WOE 计算完毕,原始特征全部替换成对应箱的 WOE 值。这一步的本质是:把非线性特征转成线性且等尺度的值,使得逻辑回归的系数可以直接解释为“该变量的整体风险方向和强度”。

def woe_transform(df, woe_dict): """把原始特征替换成 WOE 值。 woe_dict 格式: {col: {区间: woe_value}},由训练集生成 """ df = df.copy() for col, mapping in woe_dict.items(): # 每行落在哪个区间,取该区间的 WOE df[col + '_woe'] = df[col].apply( lambda x: next(v for k, v in mapping.items() if x in k) ) df = df.drop(columns=[col]) return df

模拟数据中连续变量的区间判断可以用pd.IntervalIndex优化,next()遍历在小数据集上没问题,数据量大时不建议。训练 WOE 映射字典时要注意:测试集变换时必须复用训练集的分箱边界和 WOE 值,绝不能重新分箱重算——否则测试集的信息会渗进模型验证结果。

import statsmodels.api as sm # 构建训练集 WOE 矩阵 X_train = train_df[['age', 'debt_ratio', 'monthly_income', 'history_30d']].copy() X_train_woe = woe_transform(X_train, woe_dict) y_train = train_df['bad'] # 1=好客户, 0=坏客户 # statsmodels 需要手动加常数项(截距) X_train_woe_const = sm.add_constant(X_train_woe) logit_model = sm.Logit(y_train, X_train_woe_const).fit() print(logit_model.summary())

这里用 statsmodels 而不是 sklearn 的 LogisticRegression,核心原因是想要 P 值和置信区间。评分卡建模不只看系数,还要看变量是否统计显著,P 值大于 0.1 的变量说明它在 WOE 变换后预测力不稳定,一般要剔除或重新分箱。sm.add_constant这一步很容易漏,漏了之后截距会被强制为 0,评分表的基准分会整体错位,后面刻度算出来怎么调都不对。

4.2 offset 与 factor 推导:把逻辑回归换算成标准分数

评分卡标准形式是:

score = offset - factor * ln(odds)

这里 odds 是好客户概率与坏客户概率之比(因为 y_train 中 1=好客户)。业务上需要给两个约束:设定某个 odds 值对应多少分,以及 odds 翻倍时分数变化多少。业界最常用的设定是:odds = 30:1 时分数为 600,odds 每翻一倍分数减少 50。由此可以推导出:

factor = 50 / np.log(2) # ≈ 72.13 offset = 600 + factor * np.log(30) # ≈ 845.21 print(f"factor={factor:.4f}, offset={offset:.4f}")

注意这是“好客户为 1”的坐标方向。如果建模时把坏客户设为 1,公式要变成score = offset + factor * ln(odds_bad),分数越高风险越高,业务上还得再反转一次。这是最容易搞混的地方,建议统一“好=1,坏=0”。评分表的美观性来自把 offset 按变量拆解:

def build_scorecard(model, woe_dict, offset, factor): """把逻辑回归系数和 WOE 映射成评分卡表 返回每个变量每个分箱区间的加减分数 """ params = model.params intercept = params['const'] scorecard_list = [] for col, mapping in woe_dict.items(): coef = params[col + '_woe'] # 该变量在所有样本上的平均 WOE,作为基准点 mean_woe = sum(mapping.values()) / len(mapping) for interval, woe_val in mapping.items(): # 基准分到每箱:相对平均 WOE 的偏移 bin_score = -factor * coef * (woe_val - mean_woe) scorecard_list.append({ 'variable': col, 'interval': str(interval), 'woe': round(woe_val, 4), 'coef': round(coef, 4), 'score': round(bin_score, 2) }) return pd.DataFrame(scorecard_list) scorecard_df = build_scorecard(logit_model, woe_dict, offset, factor) print(scorecard_df.head(10))

这里的关键参数是基准点选择:每个变量的基准分不直接取 0,而是取平均 WOE。这样做的效果是:某个变量内部各档的分数围绕 0 上下波动,一个中风险的申请人得到的总分等于基础分加上所有变量的偏移量。变量之间的分数可以互相抵消,业务方解释起来更直观。总分数计算:

# 基础分 = offset - factor * (截距 + Σ(β_i * 平均WOE_i)) base_score = offset - factor * ( logit_model.params['const'] + sum(logit_model.params[c + '_woe'] * mean_woe_lookup[c] for c in woe_dict.keys()) ) def score_samples(df_woe, model, offset, factor, base_score): log_odds = model.params['const'] for col in woe_dict.keys(): log_odds += model.params[col + '_woe'] * df_woe[col + '_woe'] score = offset - factor * log_odds return score train_df['score'] = score_samples(X_train_woe, logit_model, offset, factor, base_score)

4.3 AUC 与 KS:评分卡能用但不只看这两个数

评分卡验证通常报两个指标:AUC 和 KS。AUC 反映模型排序能力,KS 反映好/坏客户累计分布的分离程度。

from sklearn.metrics import roc_auc_score # 测试集同样用训练集的 WOE 映射做变换 X_test = test_df[['age', 'debt_ratio', 'monthly_income', 'history_30d']].copy() X_test_woe = woe_transform(X_test, woe_dict) y_test = test_df['bad'] X_test_woe_const = sm.add_constant(X_test_woe) y_pred_proba = logit_model.predict(X_test_woe_const) auc = roc_auc_score(y_test, y_pred_proba) print(f"AUC = {auc:.3f}") # KS:好/坏累计占比差的最大值 def ks_score(y_true, y_pred): df_ks = pd.DataFrame({'true': y_true, 'pred': y_pred}) df_ks = df_ks.sort_values('pred', ascending=False).reset_index(drop=True) df_ks['cum_bad'] = (df_ks['true'] == 0).cumsum() / (df_ks['true'] == 0).sum() df_ks['cum_good'] = (df_ks['true'] == 1).cumsum() / (df_ks['true'] == 1).sum() return max(df_ks['cum_good'] - df_ks['cum_bad']) ks = ks_score(y_test, y_pred_proba) print(f"KS = {ks:.3f}")

申请评分卡的 AUC 通常落在 0.75 到 0.85 之间,KS 在 0.3 到 0.5 之间。超过 0.85 要怀疑是否有标签泄露或特征穿越。另外注意:由于 y_train 里 1=好客户,roc_auc_score计算时正类是“好”,AUC 含义是“随机抽一个好客户和一个坏客户,好客户的预测分数更高的概率”,这符合业务直觉。如果你建模方向是坏=1,AUC 数值不变但方向要反转,再次提醒整个流程的方向一致性。

5. 申请评分卡常见问题与避坑清单

5.1 分数方向和业务直觉相反

现象:模型上线后,审批团队发现分数越高的人坏账率越高,业务方完全无法接受。

原因:建模时把坏客户设为 1,WOE 方向也跟着反转,最终分数越高代表风险越大。如果业务方要求“高分=低风险”,整个评分表就得全部取负,姿势非常难看。

解决:建模开始时统一约定好=1、坏=0,WOE 定义为ln(好占比/坏占比),评分公式用score = offset - factor * ln(odds_good)。每做一个环节都检查一次方向,别等到评分卡生成再回头改。这是评分卡项目里最贵的一个踩坑点。

5.2 qcut 报错或 WOE 出现正负无穷

现象:pd.qcut报 “Bin edges must be unique”;或者 WOE 计算结果是-inf/inf,评分卡表里对应档位分数是 ±23.03 这种夸张数字。

原因:连续变量分布过于集中(比如年龄大量等于 30 岁),分位数出现重复导致箱边界重合;或者某一箱里全是好客户/坏客户,ln(0)没有数学意义。

解决:qcut加duplicates='drop'参数;WOE 计算加平滑项避免除零。如果合并后某个箱子样本占比依然过低,人工把这个箱子和相邻箱合并。这个环节没有银弹,只能靠数据探索阶段把每个变量的分布直方图先打出来看一遍。

5.3 随机切分训练/测试集导致时序穿越

现象:离线验证 AUC 高达 0.9,上线后 KS 掉到 0.1,模型完全失效。

原因:train_test_split(random_state=42)把同一个客群在不同策略期内的样本混在了一起。信贷策略在观察期内调整过进件标准,随机切分等于让模型同时学习两套策略的混合规律,上线后面对的是“新策略下的新客群”,规律立刻漂移。

解决:按申请日期排序,前 80% 做训练,后 20% 做验证。更严格的做法是:申请评分卡在验证时还要按时间窗滚动验证,训练集用 T 到 T-12 月,验证集用 T-1 月,模拟真实上线环境。

5.4 缺失值直接丢行

现象:建模时训练集干净,上线评分时发现线上数据里大量字段缺失,和woe_transform的区间匹配不上,程序直接报错。

原因:训练时用dropna()把缺失行删了,模型没见过“缺失”这种取值状态。

解决:缺失值本身是一种信息,单独成箱并在建模阶段就保留。分箱时把缺失值标记为特殊一档,比如“未知”,WOE 按这档样本的好/坏分布单独计算。评分卡表里这一档可以分配一个中性分数或略偏负的分数,而不是上线时一剑封喉。

5.5 拒绝推断没做,模型只在“通过的人”里学规则

现象:模型训练样本全部来自“审批通过”的客户,通过率越高,模型的区分能力越差——因为被拒绝的那批高风险客户根本没有表现标签。

原因:申请评分卡的训练数据天然是选择性样本。只用通过客户的标签建模,等于默认拒绝客户和通过客户风险相同,这个假设在进件量大的客群上严重失真。

解决:常见做法是两阶段拒绝推断(reject inference):先用通过客户拟合模型,对拒绝客户做软标签推断,再把拒绝样本按一定权重加回训练集。实操中权重怎么定没有统一标准,我一般保守做法是给推断样本 0.3 到 0.5 的权重,避免模型过度自信。

提示:申请评分卡的数据集问题里,最隐蔽的就是拒绝推断。公开数据集(German Credit、GMSC)自带完整标签,所以很多人学完建模流程后忽略了这一步,实际业务里几乎每个客群都要面对。

6. 上线前必做的分数校准:校准曲线与 cut-off 设定

评分卡上线前最后一步,是把分数切成十档,逐档对比实际坏账率和模型预期坏账率。这一步能抓出刻度映射阶段的系统性偏差。校准代码如下:

# 训练集/测试集分数已经算好,用 qcut 切成 10 档 df_calib = test_df[['score', 'bad']].copy() df_calib['decile'] = pd.qcut(df_calib['score'], 10, duplicates='drop') calib = df_calib.groupby('decile', observed=True).agg( n=('bad', 'size'), actual_bad=('bad', 'mean') ) # 模型预期坏账率:从分数反推概率 # 好客户为1时,ln(odds_good) = (offset - score) / factor factor = 50 / np.log(2) offset = 600 + factor * np.log(30) calib['pred_bad'] = 1 / (1 + np.exp((offset - calib['decile'].apply(lambda x: x.mid)) / factor)) print(calib.round(4))

每组分数取区间中位数作为代表分数,反推出预期坏账率。实际坏账率和预期坏账率如果整体成比例偏移,说明因子和基准分设定有偏差,只需调整 offset 平移即可;如果高低分段偏差方向不一致,说明分箱或者变量选择有问题,要回到前面步骤排查。校准完成后,再根据通过率和坏账率的平衡点设定 cut-off:

分数切点通过率预期坏账率建议
720 分以上45%0.8%自动通过
650~720 分68%2.3%自动通过+抽样复核
600~650 分82%3.7%人工审批
600 分以下12%8.5%拒绝

我的个人习惯是:cut-off 从不只设一个阈值,而是设“硬拒绝线”和“人工复核线”两档,中间档留给审批人员结合其他软信息做最后判断。评分卡不是自动化杀戮工具,它给业务提供的是“可解释的排序依据”。建模文档里始终保留每次校准的表和调整记录,这比模型本身更值钱。这套流程我做了很多次,翻过车也补过洞,最大的教训就是:评分卡的功夫全在模型之外——标签定义、方向一致性、缺失值策略、切分方式,任何一环马虎,上线后都得加倍还。希望帮到你,愿你的模型一次校准通过。

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

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

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

立即咨询