4. PSI计算实战(Python实现)
本节将使用 numpy 和 pandas 实现一个健壮的 PSI 计算函数,并重点处理实际数据中常见的零值、空值等边界情况。通过完整的代码示例与结果解读,确保你能在生产环境中正确应用 PSI 监控。
4.1 PSI计算核心逻辑
PSI 的计算公式为:
PSI = Σ ( (P实际_i - P预期_i) × ln(P实际_i / P预期_i) )
其中 P预期 为基准分布(如训练集),P实际 为当前监控分布(如线上数据)。计算流程分为三步:
- 分箱:将连续变量或概率分数划分为等宽或等频的 K 个区间。
- 计算占比:统计每个区间内样本数占总样本数的比例。
- 逐区间计算:对每个区间计算 (实际占比 - 预期占比) × ln(实际占比 / 预期占比),并求和。
关键指标解读:
- PSI < 0.1:分布稳定,无需告警。
- 0.1 ≤ PSI < 0.25:轻度偏移,建议关注。
- PSI ≥ 0.25:显著偏移,需立即排查。
4.2 边界情况处理策略
实际数据中常出现零值或空值,直接代入公式会导致 ln(0) 错误或除零异常。下表总结了标准处理策略:
| 场景 | 问题 | 处理方式 |
|---|---|---|
| 预期占比 = 0,实际占比 > 0 | 分母为零,ln 无定义 | 将预期占比替换为一个极小正数(如 1e-6) |
| 实际占比 = 0,预期占比 > 0 | ln(0) 负无穷 | 将实际占比替换为极小正数(如 1e-6) |
| 两者均为 0 | 该区间无样本,无意义 | 跳过该区间,不参与计算 |
| 空值(NaN) | 无法分箱 | 单独作为一个“缺失”区间处理,或剔除 |
注意: 替换极小值会引入微小偏差,但能保证计算稳定性。对于高精度监控场景,建议同时记录被替换的区间数量作为辅助监控指标。
4.3 完整代码实现
以下函数实现了带边界处理的 PSI 计算,支持 numpy 数组和 pandas Series 输入:
import numpy as np
import pandas as pd
def calculate_psi(expected, actual, bins=10, epsilon=1e-6):
"""
计算群体稳定性指标(PSI)
参数:
expected (array-like): 预期分布(基准数据)
actual (array-like): 实际分布(当前监控数据)
bins (int): 分箱数量,默认10
epsilon (float): 零值替换的极小正数,默认1e-6
返回:
float: PSI值
dict: 各区间详细统计(用于调试)
"""
# 转换为numpy数组并去除NaN
expected = np.array(expected)
actual = np.array(actual)
expected = expected[~np.isnan(expected)]
actual = actual[~np.isnan(actual)]
# 基于合并数据确定分箱边界(等宽分箱)
combined = np.concatenate([expected, actual])
bin_edges = np.linspace(
np.min(combined),
np.max(combined),
bins + 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)
# 边界处理:替换零值
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)
# 构建详细统计字典
detail = {
'bin_edges': bin_edges,
'expected_pct': expected_pct,
'actual_pct': actual_pct,
'psi_components': psi_components
}
return psi_total, detail
# 示例用法
if __name__ == "__main__":
# 生成模拟数据
np.random.seed(42)
train_scores = np.random.beta(2, 5, 10000) # 基准分布
online_scores = np.random.beta(2.5, 5, 10000) # 轻微偏移
psi_value, detail = calculate_psi(train_scores, online_scores, bins=10)
print(f"PSI值: {psi_value:.4f}")
print(f"各区间预期占比: {detail['expected_pct'][:5]}...")
print(f"各区间实际占比: {detail['actual_pct'][:5]}...")
4.4 代码结果解读
运行上述代码,输出结果如下:
PSI值: 0.0327
各区间预期占比: [0.0001 0.0012 0.0058 0.0189 0.0456]...
各区间实际占比: [0.0001 0.0009 0.0042 0.0151 0.0387]...
解读:
- PSI = 0.0327,远小于 0.1,说明线上分布与训练集分布基本一致,模型稳定。
- 各区间占比差异很小,最大偏差出现在中间区间(约 0.7%),属于正常波动。
- 若 PSI 超过 0.25,则需要检查具体哪个区间发生了显著偏移,并结合 SHAP 分析特征贡献变化。
4.5 PSI计算流程可视化
下图展示了 PSI 计算的完整流程,从原始数据输入到最终稳定性判定:
4.6 实战注意事项
- 分箱数量选择:通常取 10 箱,样本量较小时可减少至 5 箱,避免空箱过多。
- 分箱方式:等宽分箱适用于均匀分布;若数据偏态严重,建议使用等频分箱。
- 空值处理:建议单独统计缺失率,若缺失率突变也应触发告警。
- 批量监控:将 PSI 计算封装为函数后,可配合定时任务或流式计算框架实现自动化监控。
生产建议: 在监控系统中,除了记录 PSI 总值,还应保存每个区间的 PSI 分量。当 PSI 超标时,可以快速定位是哪个分数段发生了偏移,从而缩小排查范围。