3. PSI 计算 Python 实现:分箱、概率分布与对数求和

本节将使用 pandas 和 numpy 实现 PSI 的核心计算函数。我们将遵循“分箱 → 概率分布 → 对数求和”的标准流程,并提供一个可直接复用的函数模板。

3.1 计算流程总览

PSI 的计算可以拆解为三个连续步骤,下面的流程图展示了从原始数据到最终 PSI 值的完整路径:

PSI 计算三步流程 步骤1:分箱 等宽 / 等频 / 自定义分箱 步骤2:概率分布 实际占比 vs 预期占比 步骤3:对数求和 ∑(实际-预期)*ln(实际/预期) 输入:实际分布数组 + 预期分布数组 输出:单个浮点数 PSI 值(无量纲) 分箱方式选择 等宽 | 等频 | 分位数 概率平滑处理 加极小值防除零 结果判定 PSI < 0.1 稳定

3.2 核心函数实现

以下代码实现了完整的 PSI 计算函数,包含三种分箱策略和自动平滑处理:

import pandas as pd
import numpy as np

def calculate_psi(actual, expected, bins=10, bin_type='equal_width'):
    """
    计算 PSI(群体稳定性指数)

    参数:
        actual (array-like): 实际分布数据
        expected (array-like): 预期分布数据
        bins (int): 分箱数量,默认10
        bin_type (str): 分箱方式,'equal_width' | 'equal_freq' | 'quantile'

    返回:
        float: PSI 值
    """
    # 步骤1:分箱
    if bin_type == 'equal_width':
        # 等宽分箱:基于合并数据的分位数
        combined = np.concatenate([actual, expected])
        bin_edges = np.percentile(combined, np.linspace(0, 100, bins + 1))
        # 去重边界
        bin_edges = np.unique(bin_edges)
    elif bin_type == 'equal_freq':
        # 等频分箱:基于实际数据的分位数
        bin_edges = np.percentile(actual, np.linspace(0, 100, bins + 1))
        bin_edges = np.unique(bin_edges)
    elif bin_type == 'quantile':
        # 分位数分箱:使用实际数据的十分位
        bin_edges = np.percentile(actual, np.linspace(0, 100, bins + 1))
        bin_edges = np.unique(bin_edges)
    else:
        raise ValueError("bin_type 必须为 'equal_width', 'equal_freq' 或 'quantile'")

    # 步骤2:计算概率分布
    actual_counts, _ = np.histogram(actual, bins=bin_edges)
    expected_counts, _ = np.histogram(expected, bins=bin_edges)

    # 转换为占比
    actual_pct = actual_counts / len(actual)
    expected_pct = expected_counts / len(expected)

    # 平滑处理:防止除零或取对数时出现无穷大
    epsilon = 1e-6
    actual_pct = np.clip(actual_pct, epsilon, 1 - epsilon)
    expected_pct = np.clip(expected_pct, epsilon, 1 - epsilon)

    # 步骤3:对数求和
    psi_value = np.sum((actual_pct - expected_pct) * np.log(actual_pct / expected_pct))

    return psi_value

3.3 函数使用示例

下面通过一个模拟场景演示函数的使用方法:

# 生成模拟数据
np.random.seed(42)
# 预期分布:正态分布
expected_data = np.random.normal(loc=0, scale=1, size=10000)
# 实际分布1:与预期相同(稳定)
actual_stable = np.random.normal(loc=0, scale=1, size=10000)
# 实际分布2:均值偏移(不稳定)
actual_shifted = np.random.normal(loc=0.5, scale=1.2, size=10000)

# 计算 PSI
psi_stable = calculate_psi(actual_stable, expected_data, bins=10)
psi_shifted = calculate_psi(actual_shifted, expected_data, bins=10)

print(f"稳定场景 PSI: {psi_stable:.4f}")
print(f"偏移场景 PSI: {psi_shifted:.4f}")
输出结果:
稳定场景 PSI: 0.0023
偏移场景 PSI: 0.1247

3.4 分箱策略对比

不同的分箱策略对 PSI 计算结果有显著影响。下表总结了三种策略的适用场景:

分箱策略 原理 适用场景 注意事项
等宽分箱 基于合并数据的分位数划分区间 数据分布未知,需要统一尺度 可能产生空箱,需处理边界重复
等频分箱 基于实际数据的分位数,每箱样本数相近 实际分布稀疏或长尾数据 对异常值敏感,需检查分箱边界
分位数分箱 使用十分位/二十分位等固定分位点 业务有固定分位标准(如信用评分) 分位点需与业务对齐

3.5 平滑处理的重要性

在实际计算中,某些分箱可能出现零计数,导致对数计算失败。我们的函数通过 np.clip 将概率限制在 [1e-6, 1-1e-6] 范围内,确保计算稳定性。

常见陷阱: 如果不对概率进行平滑处理,当某个分箱的实际占比为 0 而预期占比不为 0 时,ln(0/预期) 会得到负无穷,导致 PSI 值异常。建议始终保留平滑逻辑。

3.6 批量计算与监控

在实际监控场景中,通常需要计算多个时间窗口的 PSI。以下代码展示了如何批量计算:

def batch_psi_calculation(reference_data, monitoring_data_list, bins=10):
    """
    批量计算多个监控窗口的 PSI

    参数:
        reference_data: 基准分布(如模型训练集)
        monitoring_data_list: 列表,每个元素为一个时间窗口的数据
        bins: 分箱数量

    返回:
        pd.DataFrame: 包含时间窗口和对应 PSI 值的表格
    """
    results = []
    for i, window_data in enumerate(monitoring_data_list):
        psi = calculate_psi(window_data, reference_data, bins=bins)
        results.append({
            'window_id': f'window_{i+1}',
            'psi_value': round(psi, 4),
            'sample_size': len(window_data)
        })
    return pd.DataFrame(results)

# 使用示例
reference = np.random.normal(0, 1, 50000)
windows = [np.random.normal(0.1 * i, 1, 10000) for i in range(5)]
psi_df = batch_psi_calculation(reference, windows, bins=10)
print(psi_df)
最佳实践: 建议将 PSI 计算函数封装为独立的模块,并在监控系统中定期调用。对于生产环境,可以设置 bins=20 以获得更精细的分布对比,但需确保每个分箱的样本量不少于 50。

3.7 代码验证与调试

为了确保函数正确性,可以使用以下单元测试进行验证:

def test_psi_identical_distributions():
    """测试相同分布的 PSI 应接近 0"""
    data = np.random.normal(0, 1, 10000)
    psi = calculate_psi(data, data, bins=10)
    assert abs(psi) < 0.01, f"相同分布 PSI 应接近 0,实际为 {psi}"

def test_psi_different_distributions():
    """测试不同分布的 PSI 应显著大于 0"""
    data1 = np.random.normal(0, 1, 10000)
    data2 = np.random.normal(2, 1, 10000)
    psi = calculate_psi(data1, data2, bins=10)
    assert psi > 0.5, f"不同分布 PSI 应较大,实际为 {psi}"

def test_psi_smoothing():
    """测试平滑处理不会导致 NaN"""
    data1 = np.array([1, 2, 3, 4, 5])
    data2 = np.array([10, 20, 30, 40, 50])
    psi = calculate_psi(data1, data2, bins=3)
    assert not np.isnan(psi), "平滑处理后不应出现 NaN"
    assert np.isfinite(psi), "PSI 应为有限值"

# 运行测试
test_psi_identical_distributions()
test_psi_different_distributions()
test_psi_smoothing()
print("所有测试通过!")

通过以上实现,我们获得了一个健壮、可复用的 PSI 计算工具,为后续的监控阈值设定和告警策略奠定了数据基础。