3. PSI计算实战(Python实现):基于pandas的PSI计算函数编写,含分箱与平滑处理
本节将基于Python的pandas库,从零构建一个生产可用的PSI计算函数。核心难点在于:分箱策略(等距/等频/自定义)与平滑处理(避免除零与对数爆炸)。我们将逐步拆解每个环节,并给出可直接复用的代码模板。
3.1 PSI计算核心流程
PSI的计算本质是衡量两个分布(预期分布P与实际分布Q)在每个分箱上的差异。标准流程如下:
- 分箱:将连续变量或概率分数划分为K个区间。
- 统计频数:分别统计预期样本(A)与实际样本(B)落在每个箱内的数量。
- 计算占比:将频数转换为百分比(占比 = 频数 / 总样本数)。
- 平滑处理:对占比为0的箱进行微调,防止后续对数计算报错。
- 计算单箱PSI:(P_i - Q_i) * ln(P_i / Q_i)。
- 汇总:对所有箱的PSI值求和,得到最终PSI。
关键原则:分箱方式直接影响PSI的敏感度。箱数过少会掩盖局部漂移,箱数过多则可能放大噪声。通常建议分箱数在5~20之间。
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时,记录分箱边界、平滑参数、各箱占比等元数据,便于后续排查。
最佳实践:将分箱边界持久化存储(如JSON或数据库),每次计算PSI时从存储中读取,确保预期分布与实际分布使用完全相同的区间划分。