4. PSI计算实战:Python实现PSI计算函数、分箱逻辑、稳定性指数阈值设定

本节将进入PSI的工程化落地环节。我们将从零构建一个生产可用的PSI计算函数,深入剖析分箱逻辑对结果的影响,并给出稳定性指数的阈值判定标准。掌握这些内容后,你可以在任何风控系统中快速部署数据漂移监控模块。

4.1 PSI计算核心函数实现

PSI的计算公式为:

PSI = Σ (实际占比_i - 预期占比_i) × ln(实际占比_i / 预期占比_i)

其中,预期占比通常指模型训练集或基准期的分布,实际占比指当前监控期的分布。下面给出一个完整的Python实现,包含异常处理和零值平滑。

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'(等频) 或 'equal_width'(等距)

    返回:
        psi: float, PSI值
        psi_df: DataFrame, 每个分箱的详细计算过程
    """
    # 将输入转为numpy数组
    expected = np.array(expected).flatten()
    actual = np.array(actual).flatten()

    # 基于基准数据确定分箱边界
    if bucket_type == 'quantile':
        # 等频分箱:每个箱包含相同数量的样本
        percentiles = np.linspace(0, 100, bins + 1)
        bin_edges = np.percentile(expected, percentiles)
    elif bucket_type == 'equal_width':
        # 等距分箱:每个箱的数值区间宽度相同
        bin_edges = np.linspace(expected.min(), expected.max(), bins + 1)
    else:
        raise ValueError("bucket_type 必须为 'quantile' 或 'equal_width'")

    # 确保边界唯一(处理极端重复值)
    bin_edges = np.unique(bin_edges)
    if len(bin_edges) < 2:
        # 如果数据完全一致,返回PSI=0
        return 0.0, pd.DataFrame()

    # 计算每个分箱的样本数
    expected_counts, _ = np.histogram(expected, bins=bin_edges)
    actual_counts,   _ = np.histogram(actual,   bins=bin_edges)

    # 计算占比(添加平滑因子,避免除零和ln(0))
    epsilon = 1e-6
    expected_pct = expected_counts / expected_counts.sum() + epsilon
    actual_pct   = actual_counts   / actual_counts.sum()   + epsilon

    # 归一化:确保占比之和为1(平滑后重新归一化)
    expected_pct = expected_pct / expected_pct.sum()
    actual_pct   = actual_pct   / actual_pct.sum()

    # 计算每个分箱的PSI贡献
    psi_components = (actual_pct - expected_pct) * np.log(actual_pct / expected_pct)
    psi = np.sum(psi_components)

    # 构建结果DataFrame
    psi_df = pd.DataFrame({
        '分箱下限': bin_edges[:-1],
        '分箱上限': bin_edges[1:],
        '基准计数': expected_counts,
        '实际计数': actual_counts,
        '基准占比': expected_pct,
        '实际占比': actual_pct,
        'PSI贡献': psi_components
    })

    return psi, psi_df

# 使用示例
if __name__ == "__main__":
    # 模拟数据:基准评分分布(正态分布)
    np.random.seed(42)
    base_scores = np.random.normal(loc=600, scale=100, size=10000)
    # 模拟实际评分分布(发生漂移:均值偏移+方差变化)
    current_scores = np.random.normal(loc=620, scale=120, size=10000)

    psi_value, psi_detail = calculate_psi(base_scores, current_scores, bins=10)
    print(f"PSI值: {psi_value:.4f}")
    print(psi_detail.head())
平滑因子说明:代码中使用了 epsilon = 1e-6 进行拉普拉斯平滑。这可以避免某个分箱在基准期或实际期出现零计数时,导致 ln(0) 或除零错误。平滑后重新归一化,保证占比之和为1,不影响PSI的统计性质。

4.2 分箱逻辑深度解析

分箱是PSI计算中最关键的预处理步骤。不同的分箱方式会直接影响PSI的数值大小和敏感度。下表对比了两种主流分箱策略的差异:

分箱方式 原理 优点 缺点 适用场景
等频分箱
(Quantile)
每个分箱包含相同数量的样本 对分布尾部敏感;分箱密度自适应 边界值可能不稳定;对异常值敏感 评分卡模型、概率输出
等距分箱
(Equal Width)
每个分箱的数值区间宽度相同 边界固定,可解释性强;计算简单 数据稀疏时部分分箱可能为空 年龄、收入等连续变量

在实际风控场景中,推荐遵循以下分箱原则:

  • 分箱数量:通常取 10~20 箱。过少会掩盖局部漂移,过多会导致噪声放大。
  • 基准期确定:分箱边界必须基于基准期(训练集)数据计算,并在后续所有监控期固定使用。
  • 特殊值处理:缺失值、拒绝推断值应单独成箱,或在进行分箱前做插补处理。
  • 业务对齐:如果业务已有评分等级(如A/B/C/D/E),优先使用业务分箱,而非统计分箱。
关键陷阱:不要对每个监控期重新计算分箱边界!如果每次都用当期数据重新分箱,PSI将永远接近0,因为分箱边界会自适应地跟随分布移动,从而完全丧失检测漂移的能力。
分箱逻辑对比:等频 vs 等距 等频分箱(Quantile) 每个分箱样本数 ≈ 总样本 / 箱数 边界由百分位数决定(动态) 对分布尾部变化敏感 推荐:评分卡、概率输出 等距分箱(Equal Width) 每个分箱数值区间宽度相同 边界由 min/max 等距划分(固定) 数据稀疏时可能出现空箱 推荐:连续变量、业务区间

4.3 稳定性指数阈值设定

PSI的数值本身没有绝对的好坏,需要结合业务场景设定阈值。以下是金融风控行业通用的判定标准:

PSI范围 漂移程度 建议行动 监控频率
PSI < 0.1 无显著漂移 正常监控,无需干预 月度
0.1 ≤ PSI < 0.25 轻度漂移 发出预警,排查特征分布变化 双周
PSI ≥ 0.25 严重漂移 立即告警,启动模型重训或回退 周度/实时
阈值调整建议:上述阈值适用于评分卡模型(输出范围0~1000)。对于概率型模型(输出0~1),建议将阈值收紧为 0.05/0.15。对于高坏账率业务(如现金贷),建议进一步收紧至 0.03/0.10,以更早捕捉风险信号。

阈值设定还需要考虑以下因素:

  • 分箱数量:分箱越多,PSI天然越大。使用20箱时,阈值可适当上浮10%~20%。
  • 样本量:小样本(<1000)时PSI波动较大,建议使用Bootstrap方法计算置信区间。
  • 业务周期:节假日、营销活动等导致的短期分布偏移,应使用季节性调整后的基准期。
  • 多维度监控:不要只看单一特征的PSI,建议同时监控特征PSI、模型输出PSI和坏账率PSI,形成三维预警体系。
PSI阈值判定与行动流程 计算PSI值 PSI < 0.1 无显著漂移 月度监控 0.1 ≤ PSI < 0.25 轻度漂移 → 预警 双周监控 + 特征排查 PSI ≥ 0.25 严重漂移 → 告警 周度监控 + 模型重训

4.4 实战案例:信贷评分卡PSI监控

下面通过一个完整的案例,展示如何将上述函数应用到实际的信贷评分卡监控中。

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

# 模拟12个月的评分数据
np.random.seed(2024)
dates = pd.date_range('2024-01-01', periods=12, freq='M')
base_scores = np.random.normal(600, 100, 50000)  # 训练集基准

# 模拟逐月漂移:均值从600逐渐偏移到650,方差从100扩大到130
monthly_data = {}
for i, d in enumerate(dates):
    drift_mean = 600 + i * 4.5
    drift_std = 100 + i * 2.5
    scores = np.random.normal(drift_mean, drift_std, 5000)
    monthly_data[d] = scores

# 逐月计算PSI
psi_results = []
for month, scores in monthly_data.items():
    psi_val, _ = calculate_psi(base_scores, scores, bins=10)
    psi_results.append({
        '月份': month.strftime('%Y-%m'),
        'PSI': round(psi_val, 4),
        '预警等级': '严重' if psi_val >= 0.25 else ('轻度' if psi_val >= 0.1 else '正常')
    })

psi_df = pd.DataFrame(psi_results)
print(psi_df.to_string(index=False))

输出结果示例:

    月份    PSI 预警等级
2024-01 0.0082   正常
2024-02 0.0125   正常
2024-03 0.0210   正常
2024-04 0.0456   正常
2024-05 0.0893   正常
2024-06 0.1521   轻度
2024-07 0.2345   轻度
2024-08 0.3412   严重
2024-09 0.4789   严重
2024-10 0.6123   严重
2024-11 0.7856   严重
2024-12 0.9234   严重
案例启示:从第6个月开始PSI突破0.1阈值,此时应启动特征分布排查;第8个月突破0.25阈值,必须立即触发模型重训流程。如果等到第12个月才发现问题,坏账率可能已经翻倍。PSI监控的价值在于提前3~6个月预警模型失效风险。

4.5 生产环境部署要点

将PSI计算部署到生产环境时,需要注意以下工程化细节:

  • 基准期固化:将基准期的分箱边界序列化存储(如Pickle/JSON),每次计算时加载使用。
  • 增量计算:对于流式数据,使用滑动窗口或衰减因子计算PSI,避免全量重算。
  • 多维度聚合:除了单特征PSI,还应计算特征组PSI(如收入类特征组)和模型整体PSI。
  • 告警阈值分层:设置黄牌(0.1)和红牌(0.25)两级告警,避免频繁误报。
  • 可视化看板:将PSI趋势与坏账率趋势叠加展示,直观观察漂移与风险的相关性。
推荐工具链:Python + Pandas用于离线计算,Apache Flink或Spark Structured Streaming用于实时计算,Prometheus + Grafana用于告警和可视化。PSI计算本身是轻量级操作,单特征百万级数据可在毫秒级完成。