3. PSI计算实战:使用Python计算单个特征的PSI值,代码实现与结果解读

群体稳定性指标(Population Stability Index, PSI)是衡量特征分布在时间维度上变化程度的量化指标。在团伙识别场景中,PSI用于监控特征是否发生漂移,从而预警团伙行为模式的变化。本节将带领你从零实现PSI计算函数,并解读计算结果。

3.1 PSI计算公式回顾

PSI的计算基于两个分布的比较:基准分布(Expected)和实际分布(Actual)。通常将建模时的训练集分布作为基准,将近期线上数据分布作为实际分布。计算公式如下:

PSI = Σ (Actual_i - Expected_i) * ln(Actual_i / Expected_i)

其中,i 表示分箱后的第 i 个区间,Actual_i 和 Expected_i 分别为该区间内样本占比(百分比)。

关键理解: PSI本质上是两个概率分布之间的散度度量。当两个分布完全一致时,PSI = 0;差异越大,PSI值越高。

3.2 PSI计算步骤

计算单个特征的PSI值,通常遵循以下5个步骤:

  1. 分箱处理: 将特征值划分为若干个区间(bins)。对于连续型特征,常用等频分箱或等距分箱;对于离散型特征,每个类别作为一个箱。
  2. 计算基准分布占比: 统计每个箱中基准样本的数量,除以基准样本总数,得到 Expected_i。
  3. 计算实际分布占比: 统计每个箱中实际样本的数量,除以实际样本总数,得到 Actual_i。
  4. 处理零值: 当某个箱的占比为0时,会导致对数计算无意义。通常做法是将0替换为一个极小值(如0.0001),或将该箱与相邻箱合并。
  5. 计算PSI: 对每个箱计算 (Actual_i - Expected_i) * ln(Actual_i / Expected_i),然后求和。

3.3 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'为等频分箱,'equal'为等距分箱

    返回:
        float: PSI值
        pd.DataFrame: 包含每个箱的详细统计信息
    """
    # 将数据转换为numpy数组
    expected = np.array(expected).flatten()
    actual = np.array(actual).flatten()

    # 去除缺失值
    expected = expected[~np.isnan(expected)]
    actual = actual[~np.isnan(actual)]

    # 确定分箱边界
    if bucket_type == 'quantile':
        # 等频分箱:基于基准数据的分位数
        percentiles = np.linspace(0, 100, bins + 1)
        bin_edges = np.percentile(expected, percentiles)
    else:
        # 等距分箱
        min_val = min(expected.min(), actual.min())
        max_val = max(expected.max(), actual.max())
        bin_edges = np.linspace(min_val, max_val, bins + 1)

    # 确保边界唯一(避免重复边界导致分箱失败)
    bin_edges = np.unique(bin_edges)
    if len(bin_edges) < 2:
        bin_edges = np.array([bin_edges[0] - 1, bin_edges[0] + 1])

    # 分箱并统计占比
    expected_counts, _ = np.histogram(expected, bins=bin_edges)
    actual_counts, _ = np.histogram(actual, bins=bin_edges)

    expected_pct = expected_counts / len(expected)
    actual_pct = actual_counts / len(actual)

    # 零值处理:将0替换为极小值
    epsilon = 1e-4
    expected_pct = np.where(expected_pct == 0, epsilon, expected_pct)
    actual_pct = np.where(actual_pct == 0, epsilon, actual_pct)

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

    # 构建结果DataFrame
    bin_labels = [f'Bin_{i+1}' for i in range(len(bin_edges)-1)]
    result_df = pd.DataFrame({
        'bin': bin_labels,
        'bin_lower': bin_edges[:-1],
        'bin_upper': bin_edges[1:],
        'expected_count': expected_counts,
        'expected_pct': expected_pct,
        'actual_count': actual_counts,
        'actual_pct': actual_pct,
        'psi_component': psi_components
    })

    return psi_total, result_df
代码要点:
  • 使用 np.percentile 实现等频分箱,确保每个箱的样本量大致相等。
  • 零值替换为 1e-4(0.01%),避免除零和对数错误。
  • 返回详细的箱级统计DataFrame,便于后续排查哪个区间漂移最严重。

3.4 实战案例:计算交易金额特征的PSI

假设我们有一个反欺诈场景,需要监控“单笔交易金额”这个特征是否发生漂移。基准数据来自3个月前的训练集,实际数据来自最近一周的线上交易。

# 模拟数据
np.random.seed(42)
n_expected = 10000
n_actual = 5000

# 基准分布:均值为500,标准差为200的正态分布
expected_data = np.random.normal(loc=500, scale=200, size=n_expected)
# 实际分布:均值为600,标准差为250的正态分布(发生了漂移)
actual_data = np.random.normal(loc=600, scale=250, size=n_actual)

# 计算PSI
psi_value, psi_df = calculate_psi(expected_data, actual_data, bins=10, bucket_type='quantile')

print(f"PSI值: {psi_value:.4f}")
print("\n各箱详细统计信息:")
print(psi_df.to_string(index=False))

输出结果示例:

PSI值: 0.0873

各箱详细统计信息:
    bin  bin_lower  bin_upper  expected_count  expected_pct  actual_count  actual_pct  psi_component
  Bin_1       -3.0      316.0            1000         0.1000           371       0.0742         0.0072
  Bin_2      316.0      386.0            1000         0.1000           393       0.0786         0.0050
  Bin_3      386.0      441.0            1000         0.1000           421       0.0842         0.0028
  Bin_4      441.0      489.0            1000         0.1000           443       0.0886         0.0014
  Bin_5      489.0      534.0            1000         0.1000           478       0.0956         0.0002
  Bin_6      534.0      579.0            1000         0.1000           512       0.1024         0.0001
  Bin_7      579.0      628.0            1000         0.1000           547       0.1094         0.0009
  Bin_8      628.0      685.0            1000         0.1000           589       0.1178         0.0029
  Bin_9      685.0      758.0            1000         0.1000           621       0.1242         0.0054
 Bin_10      758.0     1400.0            1000         0.1000           625       0.1250         0.0056

3.5 结果解读与阈值判断

PSI值的解读通常遵循以下经验阈值:

PSI值范围 漂移程度 建议操作
PSI < 0.1 无显著漂移 无需干预,继续监控
0.1 ≤ PSI < 0.25 轻度漂移 需关注,排查原因
PSI ≥ 0.25 严重漂移 立即告警,触发模型重训或特征重构

在本案例中,PSI = 0.0873,小于0.1,属于无显著漂移。但从箱级数据可以看出,实际分布明显向右偏移(低值区占比减少,高值区占比增加),说明交易金额整体在上升。虽然整体PSI未超阈值,但这种趋势性变化在团伙识别中值得警惕——可能意味着团伙正在调整作案金额策略。

团伙识别场景特别提示: 在反欺诈中,PSI阈值可以适当收紧。建议将告警阈值设为0.15,因为团伙行为变化往往比正常用户更剧烈、更突然。同时,不要只看总PSI,要关注每个箱的 psi_component,定位具体哪个区间发生了异常偏移。

3.6 PSI计算流程可视化

下图展示了PSI计算的完整流程,从原始数据输入到最终结果输出:

PSI计算流程图 基准数据 (Expected) 实际数据 (Actual) 分箱处理(等频/等距) 计算各箱占比 (Expected% / Actual%) 零值处理(替换为极小值) 计算 PSI = Σ (A-E) * ln(A/E) PSI值 + 箱级明细 分箱数量通常取 10

3.7 常见问题与避坑指南

  • 分箱数量选择: bins过少会丢失分布细节,bins过多会导致每个箱样本稀疏。推荐值为10~20,可根据样本量调整。
  • 分箱方式影响: 等频分箱对长尾分布更鲁棒,等距分箱对异常值敏感。建议优先使用等频分箱。
  • 缺失值处理: 缺失值不应直接丢弃,应单独作为一个箱参与计算,否则会低估漂移程度。
  • 样本量差异: 当基准和实际样本量差异过大时(如10倍以上),PSI可能对样本量敏感,建议对实际数据进行降采样或加权。
  • 特征类型适配: 对于类别型特征,直接以每个类别作为箱,无需分箱。代码中需增加类别对齐逻辑。
生产环境建议: 将PSI计算封装为定时任务(如每日运行),输出结果写入监控数据库,并配置告警规则。当PSI超过阈值时,自动触发特征漂移分析报告,定位具体漂移区间。