4. PSI计算实战:使用Python计算单个因子的PSI值(分箱法、密度法)

PSI(Population Stability Index,群体稳定性指标)是量化风控中衡量因子分布随时间漂移程度的核心指标。本节将深入讲解PSI的两种主流计算方法——分箱法与密度法,并提供可直接复用的Python代码实现。

4.1 PSI计算原理回顾

PSI通过比较因子在基准期(如建模样本)与监测期(如最新线上数据)的分布差异,量化漂移程度。其核心公式为:

PSI = Σ (P_i_actual - P_i_expected) * ln(P_i_actual / P_i_expected)

其中:

  • P_i_expected:基准期第 i 个分箱的样本占比
  • P_i_actual:监测期第 i 个分箱的样本占比
PSI判定标准:PSI < 0.1 表示分布稳定;0.1 ≤ PSI < 0.25 表示轻度漂移,需关注;PSI ≥ 0.25 表示显著漂移,需立即排查。

4.2 方法一:分箱法(等频分箱)

分箱法是最常用的PSI计算方式,适用于连续型因子。其核心步骤为:基于基准期数据确定分箱边界,然后将监测期数据映射到相同的分箱中,最后计算各箱占比并代入PSI公式。

4.2.1 分箱法计算流程

  1. 确定分箱数:通常选择 10 箱(等频分箱),也可根据样本量调整。
  2. 计算基准期分箱边界:使用 pd.qcut() 对基准期因子值进行等频分箱。
  3. 映射监测期数据:将监测期因子值按基准期边界进行分箱。
  4. 计算各箱占比:分别计算基准期和监测期每个分箱的样本占比。
  5. 计算PSI:逐箱计算 (实际占比 - 期望占比) * ln(实际占比 / 期望占比),并求和。

4.2.2 分箱法Python实现

import pandas as pd
import numpy as np

def psi_binning(expected, actual, bins=10):
    """
    分箱法计算PSI
    :param expected: 基准期因子值 (Series)
    :param actual: 监测期因子值 (Series)
    :param bins: 分箱数,默认10
    :return: PSI值
    """
    # 1. 基于基准期数据确定分箱边界(等频分箱)
    expected_bins = pd.qcut(expected, q=bins, duplicates='drop', retbins=True)[1]
    
    # 2. 将基准期和监测期数据按相同边界分箱
    expected_cut = pd.cut(expected, bins=expected_bins, include_lowest=True)
    actual_cut = pd.cut(actual, bins=expected_bins, include_lowest=True)
    
    # 3. 计算各箱占比
    expected_pct = expected_cut.value_counts(normalize=True).sort_index()
    actual_pct = actual_cut.value_counts(normalize=True).sort_index()
    
    # 4. 合并并处理空箱(占比为0时,用极小值替代避免除零)
    df = pd.DataFrame({'expected': expected_pct, 'actual': actual_pct})
    df = df.fillna(0.0001)  # 避免ln(0)
    df['actual'] = df['actual'].replace(0, 0.0001)
    
    # 5. 计算PSI
    df['psi_component'] = (df['actual'] - df['expected']) * np.log(df['actual'] / df['expected'])
    psi = df['psi_component'].sum()
    
    return psi

# 示例用法
# psi_value = psi_binning(train_df['feature_x'], online_df['feature_x'], bins=10)
# print(f"分箱法PSI: {psi_value:.4f}")
注意事项:当监测期数据出现基准期未覆盖的极端值时,pd.cut() 会将其标记为 NaN。建议在计算前对异常值进行截断处理,或使用 include_lowest=True 确保左边界闭合。

4.3 方法二:密度法(核密度估计)

密度法适用于样本量较大或分箱边界难以确定的场景。它通过核密度估计(KDE)拟合因子分布,然后对密度曲线进行离散化采样,再计算PSI。

4.3.1 密度法计算流程

  1. 拟合密度曲线:分别对基准期和监测期因子值进行KDE拟合。
  2. 定义采样点:在因子值范围内均匀取 N 个点(通常 N=1000)。
  3. 计算密度值:在每个采样点上计算两个分布的密度值。
  4. 归一化:将密度值归一化为概率(占比)。
  5. 计算PSI:使用与分箱法相同的公式,逐点计算并求和。

4.3.2 密度法Python实现

from scipy.stats import gaussian_kde
import numpy as np

def psi_density(expected, actual, n_points=1000):
    """
    密度法计算PSI
    :param expected: 基准期因子值 (array-like)
    :param actual: 监测期因子值 (array-like)
    :param n_points: 采样点数,默认1000
    :return: PSI值
    """
    # 1. 拟合KDE
    kde_expected = gaussian_kde(expected)
    kde_actual = gaussian_kde(actual)
    
    # 2. 定义采样范围(取两个分布并集的1%和99%分位数)
    combined = np.concatenate([expected, actual])
    x_min = np.percentile(combined, 1)
    x_max = np.percentile(combined, 99)
    x = np.linspace(x_min, x_max, n_points)
    
    # 3. 计算密度值
    density_expected = kde_expected.evaluate(x)
    density_actual = kde_actual.evaluate(x)
    
    # 4. 归一化为概率
    prob_expected = density_expected / density_expected.sum()
    prob_actual = density_actual / density_actual.sum()
    
    # 5. 处理零值并计算PSI
    prob_expected = np.clip(prob_expected, 1e-10, None)
    prob_actual = np.clip(prob_actual, 1e-10, None)
    
    psi = np.sum((prob_actual - prob_expected) * np.log(prob_actual / prob_expected))
    
    return psi

# 示例用法
# psi_value = psi_density(train_df['feature_x'].values, online_df['feature_x'].values)
# print(f"密度法PSI: {psi_value:.4f}")

4.4 两种方法对比

对比维度 分箱法 密度法
适用场景 连续型因子,样本量适中 连续型因子,样本量大或分布复杂
计算复杂度 低,O(n) 中,O(n log n) + KDE拟合
对异常值敏感度 中(需截断处理) 低(KDE自动平滑)
结果稳定性 受分箱数影响较大 受带宽参数影响较大
可解释性 高(分箱边界直观) 中(依赖密度曲线)

4.5 PSI计算流程总览

下图展示了从因子数据输入到PSI输出的完整计算链路,涵盖分箱法与密度法两条路径:

PSI计算流程总览 因子数据输入 分箱法:等频分箱 计算各箱占比 密度法:KDE拟合 均匀采样归一化 PSI = Σ(P_i_actual - P_i_expected) * ln(P_i_actual / P_i_expected) 输出PSI值

4.6 实战建议与避坑指南

生产环境推荐:建议将分箱法作为默认方案,因其计算稳定、可解释性强。当因子分布呈现多峰或严重偏态时,可改用密度法作为补充验证。
  • 分箱数选择:样本量大于10万时,可尝试20箱;样本量小于5000时,建议降至5箱,避免空箱过多。
  • 空箱处理:分箱法中若监测期某箱占比为0,建议用 0.0001 替代,而非直接丢弃该箱,否则会低估PSI。
  • 密度法带宽:scipy 默认使用 Scott 规则,若分布波动剧烈,可尝试 gaussian_kde(expected, bw_method='silverman') 获得更平滑的曲线。
  • 性能优化:对全量因子计算PSI时,建议使用 apply 或向量化操作,避免逐因子循环。

4.7 完整代码封装

以下代码将两种方法封装为统一接口,便于批量计算:

def calculate_psi(expected, actual, method='binning', bins=10, n_points=1000):
    """
    统一PSI计算接口
    :param method: 'binning' 或 'density'
    """
    if method == 'binning':
        return psi_binning(expected, actual, bins)
    elif method == 'density':
        return psi_density(expected, actual, n_points)
    else:
        raise ValueError("method must be 'binning' or 'density'")

# 批量计算示例
# feature_list = ['age', 'income', 'credit_limit']
# psi_results = {}
# for feat in feature_list:
#     psi_results[feat] = calculate_psi(
#         train_df[feat], online_df[feat], method='binning', bins=10
#     )
# print(psi_results)
扩展阅读:对于离散型因子(如省份、职业),可直接使用类别占比计算PSI,无需分箱。此时每个类别即为一个天然分箱,计算逻辑与分箱法一致。