3. PSI 计算步骤详解:数据准备、分箱方法、计算每个箱子的占比、求和得到 PSI 值
PSI(Population Stability Index,群体稳定性指标)的计算并不依赖复杂的数学模型,其核心在于“分布对比”。只要掌握四个标准步骤——数据准备、分箱、占比计算、求和——即可准确量化模型评分在时间维度上的偏移程度。本节将逐一拆解每个环节的实操要点与数学逻辑。
3.1 数据准备:定义基准样本与测试样本
PSI 计算需要两组数据:基准样本(Expected) 和 测试样本(Actual)。基准样本通常来自模型开发时的训练集或验证集,代表“稳定状态”;测试样本来自模型上线后的某个监控周期(如月度、季度),代表“当前状态”。
| 样本类型 | 数据来源 | 用途 | 常见时间窗口 |
|---|---|---|---|
| 基准样本(E) | 模型训练集 / 验证集 | 定义“稳定分布”参考线 | 模型开发期(静态) |
| 测试样本(A) | 线上评分结果 / 新批次数据 | 检测当前分布是否偏移 | 每月 / 每季度(滚动) |
实操要点:确保两组样本的评分字段(Score)口径一致,包括评分范围、缺失值处理方式、异常值截断规则。若基准样本使用“标准化后评分”,测试样本也必须执行相同的标准化流程。
3.2 分箱方法:等距、等频与分位数
分箱是将连续评分变量离散化为若干个区间(Bins),这是 PSI 计算的前提。不同的分箱策略会直接影响 PSI 值的敏感度与稳定性。三种主流方法对比如下:
| 分箱方法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 等距分箱 | 将评分范围等分为 k 个宽度相同的区间 | 计算简单,区间边界固定 | 样本分布不均时,部分箱子可能为空或样本极少 | 评分范围已知且分布较均匀 |
| 等频分箱 | 使每个箱子包含相同数量的样本 | 每个箱子样本量均衡,避免空箱 | 区间边界随样本分布变化,不固定 | 样本量充足,分布偏态明显 |
| 分位数分箱 | 基于基准样本的分位数(如 10%、20%…)确定边界 | 边界由基准分布决定,可复用到测试样本 | 对基准样本的异常值敏感 | 需要跨时间周期保持边界一致 |
行业推荐:在模型监控实践中,分位数分箱是最常用的方法。通常将基准样本按 10 分位(Decile)划分为 10 个箱子,然后将相同的分位边界应用到测试样本上。这样既能保证每个箱子有足够的样本量,又能保持跨周期的边界一致性。
3.3 计算每个箱子的占比
分箱完成后,分别计算基准样本和测试样本在每个箱子中的样本占比。公式如下:
# 伪代码:计算每个箱子的占比
# bins: 分箱边界列表,如 [0, 100, 200, 300, ...]
# expected_scores: 基准样本评分数组
# actual_scores: 测试样本评分数组
def calculate_proportions(scores, bins):
total = len(scores)
proportions = []
for i in range(len(bins) - 1):
lower = bins[i]
upper = bins[i + 1]
count = sum(1 for s in scores if lower <= s < upper)
proportions.append(count / total)
return proportions
# 计算基准占比 E_i 和测试占比 A_i
E_props = calculate_proportions(expected_scores, bins)
A_props = calculate_proportions(actual_scores, bins)
假设分箱数为 10,则得到两组占比向量:E = [E₁, E₂, ..., E₁₀] 和 A = [A₁, A₂, ..., A₁₀],且每组占比之和均为 1(或 100%)。
避坑提示:若测试样本中某个箱子的占比为 0,则后续计算 ln(Aᵢ / Eᵢ) 时会出现除零错误。建议对占比进行“平滑处理”,例如将 0 替换为一个极小值(如 0.0001),或采用拉普拉斯平滑(Laplace Smoothing)。
3.4 求和得到 PSI 值
PSI 的最终计算公式为:
PSI = Σᵢ (Aᵢ - Eᵢ) × ln(Aᵢ / Eᵢ)
其中:
- Aᵢ:测试样本在第 i 个箱子的占比
- Eᵢ:基准样本在第 i 个箱子的占比
- ln:自然对数
计算过程可拆解为以下步骤:
- 对每个箱子,计算
diff = Aᵢ - Eᵢ(占比差值) - 对每个箱子,计算
ratio = Aᵢ / Eᵢ(占比比值) - 对每个箱子,计算
term = diff × ln(ratio)(单项贡献) - 将所有箱子的
term求和,得到最终 PSI 值
# Python 实现:完整 PSI 计算
import numpy as np
def calculate_psi(expected, actual, bins):
"""
计算群体稳定性指标 (PSI)
:param expected: 基准样本评分数组
:param actual: 测试样本评分数组
:param bins: 分箱边界列表
:return: PSI 值
"""
def get_proportions(scores):
total = len(scores)
counts, _ = np.histogram(scores, bins=bins)
return counts / total
E = get_proportions(expected)
A = get_proportions(actual)
# 平滑处理:避免除零或 ln(0)
epsilon = 1e-6
E = np.clip(E, epsilon, None)
A = np.clip(A, epsilon, None)
psi = np.sum((A - E) * np.log(A / E))
return psi
# 示例用法
bins = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
psi_value = calculate_psi(expected_scores, actual_scores, bins)
print(f"PSI = {psi_value:.4f}")
3.5 PSI 计算全流程可视化
下图完整展示了从数据准备到 PSI 输出的四个核心阶段,帮助理解每一步的输入与输出关系:
3.6 完整计算示例(手工演算)
假设将评分分为 5 个箱子,基准样本和测试样本的占比数据如下,逐步计算 PSI:
| 箱子编号 | 基准占比 Eᵢ | 测试占比 Aᵢ | 差值 (Aᵢ - Eᵢ) | 比值 (Aᵢ / Eᵢ) | ln(比值) | 单项贡献 |
|---|---|---|---|---|---|---|
| 1 | 0.20 | 0.15 | -0.05 | 0.75 | -0.2877 | 0.0144 |
| 2 | 0.20 | 0.25 | 0.05 | 1.25 | 0.2231 | 0.0112 |
| 3 | 0.20 | 0.20 | 0.00 | 1.00 | 0.0000 | 0.0000 |
| 4 | 0.20 | 0.30 | 0.10 | 1.50 | 0.4055 | 0.0405 |
| 5 | 0.20 | 0.10 | -0.10 | 0.50 | -0.6931 | 0.0693 |
| PSI = Σ 单项贡献 = | 0.1354 | |||||
结果解读:本例中 PSI = 0.1354,落在 0.1 ~ 0.25 区间,属于“需关注”级别。虽然整体分布未发生剧烈偏移,但箱子 4 和箱子 5 的占比变化较大,建议进一步分析这两个区间的样本特征是否发生了结构性变化。
3.7 常见陷阱与最佳实践
- 分箱数选择:分箱数过少(如 < 5)会掩盖局部偏移;分箱数过多(如 > 20)会导致每个箱子样本量不足,PSI 对随机波动过于敏感。推荐 10 箱作为默认值。
- 边界一致性:必须使用基准样本的分箱边界来划分测试样本,不能各自独立分箱。否则两个分布的“箱子”不对齐,PSI 失去可比性。
- 零占比处理:测试样本中某个箱子占比为 0 时,ln(0) 无定义。建议在计算前对所有占比加上一个极小平滑项(如 1e-6),或使用拉普拉斯平滑。
- 缺失值处理:评分缺失的样本应单独作为一个箱子处理,或直接剔除并在报告中注明缺失率。不要将缺失值混入正常评分区间。