3. PSI 计算步骤详解:从原始分数到分箱、计算实际占比与预期占比、逐项求和的全流程拆解

群体稳定性指标(PSI)的计算并非一个黑盒公式,而是一套标准化的数据分箱与对比流程。本章将拆解从原始模型分数到最终 PSI 值的每一个步骤,确保你不仅能调用函数,更能理解每一步的数学含义与业务逻辑。

3.1 计算全流程概览

PSI 的计算可以归纳为四个核心阶段:数据准备 → 分箱 → 占比计算 → 求和。下图展示了完整的计算流水线:

PSI 计算四阶段流程图 阶段 1 数据准备 预期分布 (E) & 实际分布 (A) 阶段 2 等频 / 等距分箱 确定箱边界 阶段 3 计算占比 %E_i 与 %A_i 阶段 4 逐项求和 PSI = Σ (%A_i - %E_i) * ln(%A_i / %E_i) 关键原则:预期分布与实际分布必须使用完全相同的分箱边界 分箱数量通常为 10 箱(等频)或根据分数范围等距划分

3.2 步骤一:数据准备与对齐

在开始计算之前,必须明确两个核心数据集:

  • 预期分布(Expected, E):模型开发时使用的训练集或验证集分数,作为稳定性的基准。
  • 实际分布(Actual, A):模型上线后,在监控周期内(如月度、季度)产生的实时评分结果。
数据对齐要点: 确保两个数据集的分数范围一致(例如都是 300-850 分),且剔除异常值(如分数为 NULL 或超出预设边界的记录)。如果实际分布样本量远小于预期分布,需考虑使用小样本校正方法。

3.3 步骤二:分箱(Binning)

分箱是 PSI 计算中最关键的环节。分箱方式直接影响 PSI 的敏感度和稳定性。常用的分箱策略有两种:

分箱方式 方法描述 适用场景 注意事项
等频分箱 按预期分布的百分位数划分,使每箱样本量大致相等(通常 10 箱) 评分卡模型、信用评分等分数分布不均匀的场景 实际分布可能出现空箱(某箱样本量为 0),需做平滑处理
等距分箱 按分数范围等间隔划分(如每 50 分一箱) 分数分布均匀、业务解释性要求高的场景 可能出现某箱样本过少或过多,影响统计稳定性

分箱边界确定规则: 分箱边界必须完全基于预期分布的数据来确定。实际分布的数据只是被“放入”这些预先定义好的箱子中,不能根据实际分布重新调整边界。

实战建议: 信贷风控中默认使用 10 箱等频分箱。如果预期分布样本量小于 10,000,可适当减少箱数(如 5 箱)以避免空箱。对于分数范围较窄的场景(如 600-700 分),等距分箱可能更直观。

3.4 步骤三:计算实际占比与预期占比

分箱完成后,分别计算每个箱子在预期分布和实际分布中的占比:

  • 预期占比(%E_i) = 第 i 箱中预期样本数 / 预期总样本数
  • 实际占比(%A_i) = 第 i 箱中实际样本数 / 实际总样本数

以下是一个 5 箱等频分箱的示例计算表:

箱号 分数区间 预期样本数 预期占比 (%E_i) 实际样本数 实际占比 (%A_i)
1 300 - 450 2,000 20.00% 1,500 15.00%
2 451 - 550 2,000 20.00% 2,200 22.00%
3 551 - 650 2,000 20.00% 2,500 25.00%
4 651 - 750 2,000 20.00% 2,800 28.00%
5 751 - 850 2,000 20.00% 1,000 10.00%
合计 - 10,000 100% 10,000 100%
空箱处理: 如果实际分布中某箱占比为 0%(%A_i = 0),则 ln(%A_i / %E_i) 会趋向负无穷。此时需要对该箱进行平滑处理,常用方法是将 0 替换为一个极小值(如 0.0001),或将该箱与相邻箱合并。

3.5 步骤四:逐项求和计算 PSI

得到每个箱子的 %E_i 和 %A_i 后,按照 PSI 公式逐箱计算贡献值并求和:

PSI = Σ [ (%A_i - %E_i) * ln(%A_i / %E_i) ]   (i = 1, 2, ..., n)

以上述 5 箱数据为例,逐项计算过程如下:

箱号 %E_i %A_i %A_i - %E_i %A_i / %E_i ln(%A_i / %E_i) 单项贡献
1 0.2000 0.1500 -0.0500 0.7500 -0.2877 0.0144
2 0.2000 0.2200 0.0200 1.1000 0.0953 0.0019
3 0.2000 0.2500 0.0500 1.2500 0.2231 0.0112
4 0.2000 0.2800 0.0800 1.4000 0.3365 0.0269
5 0.2000 0.1000 -0.1000 0.5000 -0.6931 0.0693
合计 1.0000 1.0000 0.0000 - - PSI = 0.1237

计算得到 PSI = 0.1237。根据行业经验阈值,该值介于 0.1 和 0.25 之间,属于中度漂移,需要进一步排查原因。

3.6 步骤五:Python 代码实现

以下是一个完整的 PSI 计算函数,包含等频分箱、空箱平滑和结果输出:

import numpy as np
import pandas as pd

def calculate_psi(expected, actual, bins=10, bucket_type='quantile'):
    """
    计算群体稳定性指标 (PSI)
    
    参数:
        expected: array-like, 预期分布分数
        actual: array-like, 实际分布分数
        bins: int, 分箱数量 (默认10)
        bucket_type: str, 'quantile'(等频) 或 'uniform'(等距)
    
    返回:
        psi_value: float, PSI值
        psi_df: DataFrame, 逐箱明细
    """
    # 1. 基于预期分布确定分箱边界
    if bucket_type == 'quantile':
        # 等频分箱:使用百分位数
        percentiles = np.linspace(0, 100, bins + 1)
        bin_edges = np.percentile(expected, percentiles)
    else:
        # 等距分箱:在分数范围内均匀划分
        bin_edges = np.linspace(min(expected), max(expected), bins + 1)
    
    # 确保边界唯一(处理重复百分位数)
    bin_edges = np.unique(bin_edges)
    
    # 2. 对预期和实际分布进行分箱
    expected_bins = np.digitize(expected, bin_edges, right=False)
    actual_bins = np.digitize(actual, bin_edges, right=False)
    
    # 3. 计算每箱样本数
    expected_counts = np.bincount(expected_bins, minlength=len(bin_edges)+1)[1:len(bin_edges)+1]
    actual_counts = np.bincount(actual_bins, minlength=len(bin_edges)+1)[1:len(bin_edges)+1]
    
    # 4. 计算占比(加入平滑处理,防止除零)
    epsilon = 1e-6
    expected_pct = expected_counts / len(expected)
    actual_pct = actual_counts / len(actual)
    expected_pct = np.clip(expected_pct, epsilon, 1 - epsilon)
    actual_pct = np.clip(actual_pct, epsilon, 1 - epsilon)
    
    # 5. 逐项计算PSI贡献
    psi_components = (actual_pct - expected_pct) * np.log(actual_pct / expected_pct)
    psi_value = np.sum(psi_components)
    
    # 6. 构建明细DataFrame
    psi_df = pd.DataFrame({
        'bin_start': bin_edges[:-1],
        'bin_end': bin_edges[1:],
        'expected_count': expected_counts,
        'actual_count': actual_counts,
        'expected_pct': expected_pct,
        'actual_pct': actual_pct,
        'psi_component': psi_components
    })
    
    return psi_value, psi_df

# 使用示例
expected_scores = np.random.normal(600, 100, 10000)
actual_scores = np.random.normal(620, 110, 10000)
psi, details = calculate_psi(expected_scores, actual_scores, bins=10)
print(f"PSI 值: {psi:.4f}")
print(details.head())

3.7 常见计算陷阱与避坑指南

在实际计算中,以下几个问题最容易导致 PSI 结果失真:

  • 分箱边界不一致:预期和实际分布使用了不同的分箱规则,导致对比失去意义。
  • 空箱未处理:实际分布中某箱样本为 0,直接计算 ln(0) 导致无穷大。
  • 样本量差异过大:预期分布 100 万样本,实际分布仅 1000 样本,占比波动剧烈。
  • 分数范围漂移:实际分布出现了预期分布中从未出现过的高分或低分,导致边界外样本被丢弃或归入首尾箱。
最佳实践: 每次计算 PSI 时,务必输出逐箱明细表,检查是否有空箱、边界外样本或占比突变箱。不要只看最终的 PSI 数值,逐箱分析才能定位漂移的具体来源。