← 返回文章列表

评分卡建模实战WOE与IV编码

评分卡建模实战:WOE/IV 编码与特征分箱

信用评分卡是信贷风控的基石模型,从信用卡审批到小微贷款定价,几乎所有金融机构的自动化审批链路里都跑着一套评分卡。这篇文章不讲"评分卡是什么"的百科式介绍,直接讲一个建模工程师上手时最核心的三个问题:怎么分箱、怎么算 WOE/IV、怎么把 WOE 编码后的特征喂进逻辑回归。


一、为什么需要 WOE 编码,不能直接把原始特征扔进逻辑回归?

逻辑回归是线性模型,它对特征有两个隐含假设:

  1. 特征与目标之间存在单调的线性关系(log-odds 尺度上)
  2. 特征的值是数值型的,可比较大小

现实中的风控特征往往不满足这两个条件。举个例子:

年龄区间 好客户数 坏客户数 坏样本率
18-22 1200 800 40.0%
23-30 8500 1500 15.0%
31-45 18000 2000 10.0%
46-55 7000 1000 12.5%
56-65 2000 800 28.6%

坏样本率和年龄不是线性的——两头高、中间低,是个 U 型曲线。直接把年龄数值喂给逻辑回归,模型只能学出一个单调的线性系数,无法捕捉这种非线性。

WOE(Weight of Evidence)编码解决的就是这个问题:把每个分箱的原始值替换为该箱的"证据权重",既保留了该箱对好坏样本的区分能力,又让编码后的值在 log-odds 尺度上与目标呈线性关系。


二、特征分箱:卡方分箱 vs 等频分箱 vs 等距分箱

分箱是 WOE 编码的前置步骤。三种主流分箱方式各有适用场景:

2.1 等距分箱(Equal-Width Binning)

按数值范围等间隔切分,比如年龄 18-28、28-38、38-48...每个箱宽度相同。

优点:简单,可解释性强,生成评分卡时"年龄 18-28 得 5 分"这种规则业务方一看就懂。 缺点:数据分布不均时会出现空箱或极端集中。

2.2 等频分箱(Equal-Frequency Binning)

每个箱包含大致相同数量的样本,比如按分位数切分。

优点:不会出现空箱,对异常值不敏感。 缺点:同一个值的样本可能被切到不同的箱,业务解释性稍差。

2.3 卡方分箱(Chi-Merge Binning)

自底向上合并:初始每个值一个箱,然后反复合并卡方值最小(即好坏分布最相似)的相邻箱,直到达到预设的箱数或卡方阈值。

优点:数据驱动的合并,能自动识别哪些相邻区间可以合并而不损失区分能力。 缺点:计算量稍大,对极稀疏特征需要做预处理。

以下是卡方分箱的 Python 实现骨架:

import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency


def chi_merge_binning(df, feature, target, max_bins=6, min_bin_pct=0.05):
    """
    卡方分箱:自底向上合并
    df: DataFrame
    feature: 特征名
    target: 标签列名(1=坏样本, 0=好样本)
    max_bins: 最大分箱数
    min_bin_pct: 每箱最小样本占比
    """
    # 初始分箱:每个唯一值一个箱
    df_temp = df[[feature, target]].copy()
    df_temp['bin'] = df_temp[feature].rank(method='dense')

    min_samples = int(len(df) * min_bin_pct)

    while df_temp['bin'].nunique() > max_bins:
        bins = sorted(df_temp['bin'].unique())
        chi2_values = {}

        for i in range(len(bins) - 1):
            bin_a = bins[i]
            bin_b = bins[i + 1]
            # 构造 2x2 列联表
            a_good = ((df_temp['bin'] == bin_a) & (df_temp[target] == 0)).sum()
            a_bad  = ((df_temp['bin'] == bin_a) & (df_temp[target] == 1)).sum()
            b_good = ((df_temp['bin'] == bin_b) & (df_temp[target] == 0)).sum()
            b_bad  = ((df_temp['bin'] == bin_b) & (df_temp[target] == 1)).sum()

            observed = np.array([[a_good, a_bad], [b_good, b_bad]])
            if observed.min() == 0:
                chi2_values[bin_a] = 0.0
            else:
                chi2, _, _, _ = chi2_contingency(observed)
                chi2_values[bin_a] = chi2

        # 合并卡方值最小的相邻箱
        min_chi_bin = min(chi2_values, key=chi2_values.get)
        next_bin = bins[bins.index(min_chi_bin) + 1]
        df_temp.loc[df_temp['bin'] == next_bin, 'bin'] = min_chi_bin

        # 检查最小样本量
        bin_counts = df_temp['bin'].value_counts()
        if bin_counts.min() < min_samples:
            break

    # 生成分箱边界
    bin_edges = df_temp.groupby('bin')[feature].agg(['min', 'max'])
    bin_edges = bin_edges.sort_values('min')
    return bin_edges

实际项目中更推荐用 toadscorecardpy 库的分箱函数,它们在边界处理、单调性约束、空箱合并上做了大量工程化打磨:

import scorecardpy as sc

# scorecardpy 的自动分箱,一行搞定
bins = sc.woebin(
    df,
    y='target',
    method='tree',           # 决策树分箱,等价于有监督的卡方合并
    max_num_bin=6,
    min_perc_total=0.05,     # 每箱最小占比
    stop_limit=0.1,          # IV 增量低于此值停止继续分
    positive='bad|1'         # 坏样本标识
)

三、WOE 与 IV 的计算

3.1 WOE(Weight of Evidence)

对每个分箱,WOE 定义为:

WOE_i = ln( (坏样本占比_i) / (好样本占比_i) )
      = ln( (Bad_i / Bad_total) / (Good_i / Good_total) )

WOE 的值越大,说明这个箱里坏样本的"浓度"越高,风险越大。WOE 接近 0 表示该箱好坏分布与整体一致,无区分能力。

3.2 IV(Information Value)

IV 衡量整个特征对好坏样本的区分能力:

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

IV 的解读标准:

IV 范围 预测能力
< 0.02 几乎无预测能力
0.02 - 0.1
0.1 - 0.3 中等
0.3 - 0.5
> 0.5 极强(需警惕过拟合/标签泄漏)

3.3 完整计算代码

def calc_woe_iv(df, feature, target, bins):
    """
    计算单个特征的 WOE 和 IV
    df: DataFrame
    feature: 特征名
    target: 标签列名
    bins: 分箱边界列表或分箱规则
    """
    df_work = df[[feature, target]].copy()
    df_work['bin'] = pd.cut(df_work[feature], bins=bins, include_lowest=True)

    # 统计每个箱的好/坏样本数
    grouped = df_work.groupby('bin')[target].agg(
        good=lambda x: (x == 0).sum(),
        bad=lambda x: (x == 1).sum()
    ).reset_index()

    total_good = grouped['good'].sum()
    total_bad = grouped['bad'].sum()

    # 计算 WOE
    grouped['good_pct'] = grouped['good'] / total_good
    grouped['bad_pct']  = grouped['bad'] / total_bad
    # 拉普拉斯平滑:避免 ln(0)
    grouped['good_pct'] = grouped['good_pct'].replace(0, 1 / total_good)
    grouped['bad_pct']  = grouped['bad_pct'].replace(0, 1 / total_bad)

    grouped['WOE'] = np.log(grouped['bad_pct'] / grouped['good_pct'])
    grouped['IV']  = (grouped['bad_pct'] - grouped['good_pct']) * grouped['WOE']

    iv_total = grouped['IV'].sum()

    return grouped, iv_total

四、WOE 编码后的评分卡转换

做完 WOE 编码后,特征值被替换为 WOE 值,此时逻辑回归的系数 β 直接对应每个特征的"权重"。评分卡的最终分数通过以下公式计算:

Score = A - B × ln(odds)

其中:
  odds  = p/(1-p),即好坏比
  A     = BaseScore + B × ln(BaseOdds)
  B     = PDO / ln(2)
  PDO   = Points to Double the Odds(odds 翻倍时分数的变化量)
  BaseScore = 基准 odds 对应的分数

Java 实现(评分转换核心逻辑):

public class ScorecardTransformer {

    // 评分卡参数
    private static final double BASE_SCORE = 600.0;
    private static final double BASE_ODDS  = 20.0;    // 好:坏 = 20:1
    private static final double PDO        = 50.0;     // odds 翻倍加 50 分

    private static final double B = PDO / Math.log(2);
    private static final double A = BASE_SCORE + B * Math.log(BASE_ODDS);

    /**
     * 将逻辑回归的 log-odds 转换为评分卡分数
     * @param intercept  逻辑回归截距项
     * @param coefficients 特征系数 Map<特征名, 系数>
     * @param woeValues    当前样本各特征的 WOE 值 Map<特征名, WOE>
     * @return 信用评分
     */
    public static double toScore(
            double intercept,
            Map<String, Double> coefficients,
            Map<String, Double> woeValues) {

        double logOdds = intercept;
        for (Map.Entry<String, Double> entry : coefficients.entrySet()) {
            String feature = entry.getKey();
            double coef = entry.getValue();
            double woe = woeValues.getOrDefault(feature, 0.0);
            logOdds += coef * woe;
        }

        return A - B * logOdds;
    }

    /**
     * 单特征的评分贡献(用于解释单条决策)
     */
    public static double featureContribution(
            String featureName,
            double coefficient,
            double woeValue) {
        return -B * coefficient * woeValue;
    }
}

五、三个工程实践要点

5.1 WOE 的单调性检查

好的特征分箱后 WOE 应该是单调的(随特征值增大,WOE 单调递增或递减)。如果 WOE 出现剧烈跳动(比如年龄 30-40 WOE=0.5,40-50 WOE=-0.3),说明:

  • 分箱粒度不合理,需要合并相邻箱
  • 该特征本身与目标的关系不稳定,考虑做二次衍生(如年龄与收入的交叉特征)
def check_woe_monotonicity(woe_df):
    """检查 WOE 序列是否单调"""
    woe_values = woe_df['WOE'].values
    is_increasing = all(x <= y for x, y in zip(woe_values, woe_values[1:]))
    is_decreasing = all(x >= y for x, y in zip(woe_values, woe_values[1:]))
    return is_increasing or is_decreasing

5.2 训练集分箱规则在推理时的应用

WOE 转换最大的坑:训练时的分箱边界必须在推理时原样复用。不能每次请求都重新分箱,否则同一特征值在不同时间点的 WOE 映射不一致,评分就失去了可比性。

标准做法是把分箱规则序列化保存:

import json

# 训练完成后保存分箱规则
bin_rules = {}
for feat, bin_df in bins.items():
    bin_rules[feat] = {
        'breaks': bin_df['breaks'].tolist(),
        'woe': bin_df['woe'].tolist()
    }

with open('model/scorecard_bins.json', 'w') as f:
    json.dump(bin_rules, f, indent=2)

推理时加载分箱规则,用 pd.cut + 预定义的 breaks 做映射,不对数据重新计算边界。

5.3 IV 筛选的误区

IV 高不代表特征一定好。两类常见陷阱:

  1. 标签泄漏:特征本身包含了目标信息。比如用"近 3 个月逾期天数"去预测"是否会逾期",IV 可能 > 1.0,但这是时间穿越,上线后毫无意义。
  2. IV 低但交互价值高:单个特征 IV 只有 0.03,但与其他特征交叉后区分能力暴增。这种情况在反欺诈特征中很常见(设备指纹 × 申请时间 × 地理位置)。

做特征筛选时,IV 是第一道粗筛,后续还要结合 VIF(方差膨胀因子)做共线性检测,以及业务可解释性评估。


六、评分卡建模全流程速览

原始数据 → 数据清洗 → 特征分箱 → WOE编码 → IV筛选
                                              ↓
上线部署 ← 评分转换 ← 系数校准 ← 逻辑回归训练 ← VIF共线性检测
                                              ↓
                                         模型评估(KS/AUC)

每一步都有它的工程坑,但核心逻辑不复杂。关键是把分箱规则和 WOE 映射作为模型资产的一部分管理起来——它们是评分卡模型"可解释性"的来源,不只是建模阶段的中间产物。


写在最后

评分卡看似简单(逻辑回归 + WOE 编码),但要做好需要关注细节:分箱策略的选择、WOE 单调性的校验、分箱规则在训练和推理间的一致性。这些细节决定了模型上线的稳定性——一个"看起来差不多"的评分卡,上线后 KS 从 0.35 掉到 0.22 的例子我见过不止一次,根因通常是推理时 WOE 映射与训练时不一致。

下一轮讲 XGBoost/LightGBM 和评分卡的对比,以及两者在实际风控系统中如何协同。