3. PSI 计算步骤详解:分箱、计算占比、计算对数比、求和
群体稳定性指标(PSI)的计算过程严格遵循四个步骤:分箱 → 计算占比 → 计算对数比 → 加权求和。每一步都直接影响最终 PSI 值的准确性与可解释性。下面我们以“模型预测分数”为例,逐步拆解每个环节的数学操作与工程实现。
3.1 步骤一:分箱(Binning)
分箱是将连续型预测分数或概率值划分为若干个离散区间(箱)。分箱方式决定了 PSI 对分布偏移的敏感度。
| 分箱方式 | 说明 | 适用场景 |
|---|---|---|
| 等距分箱 | 将分数范围均匀切分为 N 段(如 0–100 分,每 10 分一箱) | 分数分布均匀、无极端值 |
| 等频分箱 | 使每个箱内样本数量大致相等(基于训练集分位数) | 长尾分布、分数集中在局部区间 |
| 自定义分箱 | 根据业务含义或监管要求设定断点(如信用评分卡 0–300, 300–600, 600–850) | 业务规则明确、需要对齐评级标准 |
工程建议: 推荐使用训练集的等频分箱边界作为固定分箱方案。这样既能保证每个箱有足够的统计样本,又能避免因分箱边界变化导致 PSI 计算不稳定。
分箱完成后,我们得到 K 个箱(通常 K=10 或 K=20)。每个箱对应一个区间 [b_{i-1}, b_i),其中 i = 1, 2, …, K。
3.2 步骤二:计算占比(Proportion)
分别计算期望分布(训练集)和实际分布(当前监控集)在每个箱中的样本占比。
设训练集总样本数为 Ntrain,当前监控集总样本数为 Ncurrent。对于第 i 个箱:
- 期望占比:Pi = counttrain,i / Ntrain
- 实际占比:Qi = countcurrent,i / Ncurrent
避坑提醒: 如果某个箱的占比为 0,后续计算对数比时会遇到数学错误(log(0) 无定义)。工程上通常对占比做平滑处理,例如加一个极小值 ε = 1e-6,或使用拉普拉斯平滑。
3.3 步骤三:计算对数比(Log Ratio)
对每个箱,计算实际占比与期望占比的自然对数比:
LogRatio_i = ln(Q_i / P_i)
这个对数比衡量了每个箱中分布偏移的方向与幅度:
- 当 Qi > Pi 时,对数比 > 0,表示该箱中当前样本占比增加(偏移正向)
- 当 Qi < Pi 时,对数比 < 0,表示该箱中当前样本占比减少(偏移负向)
- 当 Qi = Pi 时,对数比 = 0,表示该箱分布无变化
3.4 步骤四:加权求和(Weighted Sum)
将每个箱的占比差与对数比相乘,然后对所有箱求和,得到最终的 PSI 值:
PSI = Σ_{i=1}^{K} (Q_i - P_i) * ln(Q_i / P_i)
这个公式可以理解为:每个箱的贡献 = 占比变化幅度 × 变化方向的对数度量。所有箱的贡献累加后,得到一个非负的标量值。
关键理解: PSI 本质上是两个概率分布之间的散度度量。当两个分布完全一致时,每个箱的 (Qi - Pi) = 0,PSI = 0。分布差异越大,PSI 值越大。
3.5 完整计算示例(Python 代码)
下面给出一个完整的 PSI 计算函数,包含平滑处理与分箱逻辑:
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 或 list, 分箱数量或自定义分箱边界
epsilon: float, 平滑常数,防止 log(0)
返回:
psi: float, PSI 值
bin_edges: array, 分箱边界
contributions: array, 每个箱的 PSI 贡献
"""
# 1. 分箱:基于期望分布的分位数确定边界
if isinstance(bins, int):
bin_edges = np.percentile(expected, np.linspace(0, 100, bins + 1))
else:
bin_edges = np.array(bins)
# 2. 计算占比(加平滑)
expected_counts, _ = np.histogram(expected, bins=bin_edges)
actual_counts, _ = np.histogram(actual, bins=bin_edges)
expected_pct = (expected_counts + epsilon) / (expected.sum() + epsilon * len(expected_counts))
actual_pct = (actual_counts + epsilon) / (actual.sum() + epsilon * len(actual_counts))
# 3. 计算对数比
log_ratio = np.log(actual_pct / expected_pct)
# 4. 加权求和
contributions = (actual_pct - expected_pct) * log_ratio
psi = np.sum(contributions)
return psi, bin_edges, contributions
# 使用示例
train_scores = np.random.normal(0.5, 0.15, 10000)
current_scores = np.random.normal(0.55, 0.18, 10000)
psi_value, edges, contribs = calculate_psi(train_scores, current_scores, bins=10)
print(f"PSI = {psi_value:.4f}")
3.6 各步骤常见问题与排查
| 步骤 | 常见问题 | 排查方向 |
|---|---|---|
| 分箱 | 箱数过多导致稀疏箱,箱数过少掩盖局部偏移 | 使用等频分箱,K=10 作为默认值;检查每个箱的样本量是否 ≥ 50 |
| 计算占比 | 监控集样本量远小于训练集,占比波动大 | 确保监控集样本量 ≥ 1000;使用平滑常数 ε |
| 计算对数比 | 占比为 0 导致 log(0) 错误 | 始终使用平滑处理;检查分箱边界是否覆盖了所有分数范围 |
| 求和 | PSI 值异常大(>1.0)或异常小(≈0) | 检查分箱边界是否合理;确认训练集与监控集分数范围是否一致 |
生产环境最佳实践: 将 PSI 计算封装为独立的微服务或 UDF(用户自定义函数),输入为两个分布数组,输出为 PSI 值及每个箱的贡献明细。这样便于后续告警时快速定位是哪个分数区间发生了偏移。