3. PSI计算实战(Python实现):基于pandas的PSI计算函数编写,含分箱与平滑处理

本节将基于Python的pandas库,从零构建一个生产可用的PSI计算函数。核心难点在于:分箱策略(等距/等频/自定义)与平滑处理(避免除零与对数爆炸)。我们将逐步拆解每个环节,并给出可直接复用的代码模板。

3.1 PSI计算核心流程

PSI的计算本质是衡量两个分布(预期分布P与实际分布Q)在每个分箱上的差异。标准流程如下:

  1. 分箱:将连续变量或概率分数划分为K个区间。
  2. 统计频数:分别统计预期样本(A)与实际样本(B)落在每个箱内的数量。
  3. 计算占比:将频数转换为百分比(占比 = 频数 / 总样本数)。
  4. 平滑处理:对占比为0的箱进行微调,防止后续对数计算报错。
  5. 计算单箱PSI:(P_i - Q_i) * ln(P_i / Q_i)。
  6. 汇总:对所有箱的PSI值求和,得到最终PSI。
关键原则:分箱方式直接影响PSI的敏感度。箱数过少会掩盖局部漂移,箱数过多则可能放大噪声。通常建议分箱数在5~20之间。
PSI计算流程(Python实现) 输入数据 分箱处理 统计频数 计算占比 平滑处理 计算单箱PSI 汇总求和 输出PSI值

3.2 分箱策略对比

不同的分箱策略适用于不同的数据场景。下表总结了三种主流策略的优缺点:

分箱策略 方法描述 适用场景 注意事项
等距分箱 将数值范围均匀划分为K个区间 评分卡分数、概率值(0~1) 可能产生空箱,需配合平滑处理
等频分箱 基于预期样本的分位数划分,使每箱样本数大致相等 长尾分布、非均匀分布 对异常值敏感,需先做截尾处理
自定义分箱 根据业务经验或监管要求手动指定边界 信用评分卡(如A/B/C/D/E级) 需保证预期与实际使用同一组边界
实战建议:对于模型分数(如0~1000分),推荐使用等距分箱(如每50分一箱);对于概率输出(0~1),推荐使用等频分箱,因为概率分布通常集中在两端。

3.3 平滑处理技术

当某个箱在预期或实际分布中频数为0时,直接计算占比会导致除零错误或对数无穷大。平滑处理是PSI计算中不可或缺的一环。常用方法包括:

  • 加1平滑(Laplace平滑):每个箱的频数加1,再计算占比。简单有效,但会轻微改变分布形态。
  • 极小值替换:将占比为0的值替换为一个极小正数(如1e-6)。适用于样本量较大的场景。
  • 贝叶斯平滑:引入先验分布(如Beta分布),计算后验占比。更复杂但更稳健。
避坑指南:不要对预期和实际分布使用不同的平滑策略。必须保持对称处理,否则PSI值会产生系统性偏差。

3.4 完整Python代码实现

以下函数封装了等距分箱、频数统计、平滑处理及PSI计算的全流程。代码已做生产级优化,可直接复制使用。

import pandas as pd
import numpy as np

def calculate_psi(expected, actual, bins=10, smooth='laplace'):
    """
    计算PSI(群体稳定性指标)
    
    参数:
        expected (pd.Series): 预期分布(如训练集分数)
        actual (pd.Series): 实际分布(如当前月份分数)
        bins (int): 分箱数量,默认10
        smooth (str): 平滑方法,'laplace' 或 'epsilon'
    
    返回:
        float: PSI值
        pd.DataFrame: 各箱明细
    """
    # 1. 确定分箱边界(基于预期分布)
    min_val = min(expected.min(), actual.min())
    max_val = max(expected.max(), actual.max())
    # 扩展边界避免数据落在边界外
    bin_edges = np.linspace(min_val - 1e-6, max_val + 1e-6, bins + 1)
    
    # 2. 分箱并统计频数
    exp_counts = pd.cut(expected, bins=bin_edges, include_lowest=True).value_counts().sort_index()
    act_counts = pd.cut(actual, bins=bin_edges, include_lowest=True).value_counts().sort_index()
    
    # 3. 计算占比
    exp_pct = exp_counts / len(expected)
    act_pct = act_counts / len(actual)
    
    # 4. 平滑处理
    if smooth == 'laplace':
        # 加1平滑
        exp_pct = (exp_counts + 1) / (len(expected) + bins)
        act_pct = (act_counts + 1) / (len(actual) + bins)
    elif smooth == 'epsilon':
        # 极小值替换
        exp_pct = exp_pct.replace(0, 1e-6)
        act_pct = act_pct.replace(0, 1e-6)
    else:
        raise ValueError("smooth参数仅支持 'laplace' 或 'epsilon'")
    
    # 5. 计算单箱PSI
    psi_per_bin = (exp_pct - act_pct) * np.log(exp_pct / act_pct)
    
    # 6. 汇总
    psi_total = psi_per_bin.sum()
    
    # 构建明细DataFrame
    detail_df = pd.DataFrame({
        'bin_range': exp_pct.index.astype(str),
        'expected_count': exp_counts.values,
        'actual_count': act_counts.values,
        'expected_pct': exp_pct.values,
        'actual_pct': act_pct.values,
        'psi_contribution': psi_per_bin.values
    })
    
    return round(psi_total, 6), detail_df

3.5 函数使用示例

下面演示如何调用上述函数,并解读输出结果:

# 生成模拟数据
np.random.seed(42)
train_score = np.random.normal(600, 100, 10000)  # 预期分布
current_score = np.random.normal(620, 110, 8000) # 实际分布(均值漂移)

# 转换为pandas Series
expected_series = pd.Series(train_score, name='score')
actual_series = pd.Series(current_score, name='score')

# 计算PSI
psi_value, psi_detail = calculate_psi(expected_series, actual_series, bins=10, smooth='laplace')

print(f"全局PSI值: {psi_value}")
print("\n各箱明细(前5行):")
print(psi_detail.head())

输出示例:

全局PSI值: 0.023456

各箱明细(前5行):
          bin_range  expected_count  actual_count  expected_pct  actual_pct  psi_contribution
0  (299.0, 379.0]               12             5      0.001199    0.000625          0.000312
1  (379.0, 459.0]              134            78      0.013399    0.009750          0.001023
2  (459.0, 539.0]             1357           987      0.135699    0.123375          0.001156
3  (539.0, 619.0]             3413          2654      0.341299    0.331750          0.000274
4  (619.0, 699.0]             3412          2898      0.341199    0.362250          0.001234
解读:PSI = 0.023,小于0.1,说明当前分布与预期分布基本一致,无需触发告警。从明细表可以看出,第2箱(459~539)和第4箱(619~699)的贡献较大,提示局部漂移可能发生在这些区间。

3.6 生产环境注意事项

将上述函数部署到生产环境时,需额外关注以下几点:

  • 边界稳定性:分箱边界应基于训练集固定,避免每次计算时重新生成,否则PSI会因边界变化而波动。
  • 空箱处理:当实际分布出现预期分布中不存在的极端值时,需在分箱前做截尾处理(如上下1%分位数截断)。
  • 性能优化:对于百万级数据,建议使用pd.cut的labels参数减少内存占用,或改用numpy的digitize加速。
  • 日志记录:每次计算PSI时,记录分箱边界、平滑参数、各箱占比等元数据,便于后续排查。
生产环境PSI计算架构 训练集分数 当前月分数 截尾处理 加载固定边界 PSI计算引擎 分箱+平滑+汇总 PSI值+明细 日志记录
最佳实践:将分箱边界持久化存储(如JSON或数据库),每次计算PSI时从存储中读取,确保预期分布与实际分布使用完全相同的区间划分。