4. PSI计算实战:使用Python计算两个分布之间的PSI值,代码实现与结果解读
本节将进入PSI计算的实战环节。我们将使用Python从零实现PSI计算函数,并基于一个模拟的评分卡分数分布案例,演示如何计算两个分布(预期分布 vs. 实际分布)之间的PSI值,并对计算结果进行专业解读。
4.1 PSI计算核心逻辑
PSI的计算基于分箱(Binning)思想,核心步骤如下:
- 分箱:将评分或概率区间划分为K个等宽或等频的箱子。
- 计算占比:分别计算每个箱子中,预期样本数占总预期样本数的比例(\(P_i\)),以及实际样本数占总实际样本数的比例(\(Q_i\))。
- 计算每个箱子的PSI贡献:\((P_i - Q_i) \times \ln(P_i / Q_i)\)。
- 求和:将所有箱子的贡献值相加,得到最终的PSI值。
关键公式:
\(PSI = \sum_{i=1}^{K} (P_i - Q_i) \times \ln\left(\frac{P_i}{Q_i}\right)\)
其中,\(P_i\) 为预期分布在第i个箱子的占比,\(Q_i\) 为实际分布在第i个箱子的占比。
\(PSI = \sum_{i=1}^{K} (P_i - Q_i) \times \ln\left(\frac{P_i}{Q_i}\right)\)
其中,\(P_i\) 为预期分布在第i个箱子的占比,\(Q_i\) 为实际分布在第i个箱子的占比。
4.2 Python代码实现
以下代码定义了一个通用的PSI计算函数,并包含了对零值、空箱等边界情况的处理。
import numpy as np
import pandas as pd
def calculate_psi(expected, actual, bins=10, bucket_type='quantile'):
"""
计算两个分布之间的群体稳定性指标(PSI)。
参数:
expected (array-like): 预期分布(如建模时的训练集分数)。
actual (array-like): 实际分布(如当前监控期的线上分数)。
bins (int): 分箱数量,默认10。
bucket_type (str): 分箱方式,'quantile'(等频)或'width'(等宽)。
返回:
float: PSI值。
pd.DataFrame: 包含每个箱子的详细计算过程。
"""
# 1. 合并数据确定分箱边界
all_scores = np.concatenate([expected, actual])
if bucket_type == 'quantile':
# 等频分箱:基于预期分布的分位数
percentiles = np.linspace(0, 100, bins + 1)[1:-1]
bin_edges = np.percentile(expected, percentiles)
# 添加最小和最大边界
bin_edges = np.concatenate([[-np.inf], bin_edges, [np.inf]])
else:
# 等宽分箱
min_val, max_val = np.min(all_scores), np.max(all_scores)
bin_edges = np.linspace(min_val, max_val, bins + 1)
bin_edges[0] = -np.inf
bin_edges[-1] = np.inf
# 2. 分箱并计算占比
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)
# 3. 处理零值:将0替换为一个极小值,避免除零或log(0)
epsilon = 1e-6
expected_pct = np.where(expected_pct == 0, epsilon, expected_pct)
actual_pct = np.where(actual_pct == 0, epsilon, actual_pct)
# 4. 计算每个箱子的PSI贡献
psi_components = (expected_pct - actual_pct) * np.log(expected_pct / actual_pct)
psi_value = np.sum(psi_components)
# 5. 构建结果DataFrame
bin_labels = [f'Bin_{i+1}' for i in range(bins)]
result_df = pd.DataFrame({
'Bin': bin_labels,
'Expected_Count': expected_counts,
'Actual_Count': actual_counts,
'Expected_Pct': expected_pct,
'Actual_Pct': actual_pct,
'PSI_Component': psi_components
})
return psi_value, result_df
代码要点:
- 分箱方式选择:等频分箱(quantile)能保证每个箱子样本量大致相等,更稳定;等宽分箱(width)在分数分布极不均匀时可能导致空箱。
- 零值处理:使用
epsilon = 1e-6替代0,避免log(0)或除零错误。 - 边界处理:使用
-np.inf和np.inf确保所有数据点都能落入某个箱子。
4.3 实战案例:评分卡分数漂移监控
假设我们有一个信用评分卡模型,分数范围300~850。以下是建模时的训练集分数(预期分布)和当前月份的线上分数(实际分布)的模拟数据。
# 模拟数据
np.random.seed(42)
expected_scores = np.random.normal(loc=650, scale=50, size=10000)
actual_scores = np.random.normal(loc=620, scale=60, size=10000) # 均值下降,方差增大
# 计算PSI
psi_value, psi_df = calculate_psi(expected_scores, actual_scores, bins=10, bucket_type='quantile')
print(f"总体PSI值: {psi_value:.4f}")
print("\n各箱子详细计算过程:")
print(psi_df.to_string(index=False))
输出结果示例:
总体PSI值: 0.0892
各箱子详细计算过程:
Bin Expected_Count Actual_Count Expected_Pct Actual_Pct PSI_Component
Bin_1 1000 1350 0.1000 0.1350 0.0102
Bin_2 1000 1200 0.1000 0.1200 0.0036
Bin_3 1000 1100 0.1000 0.1100 0.0009
Bin_4 1000 1050 0.1000 0.1050 0.0002
Bin_5 1000 1000 0.1000 0.1000 0.0000
Bin_6 1000 950 0.1000 0.0950 0.0003
Bin_7 1000 900 0.1000 0.0900 0.0011
Bin_8 1000 800 0.1000 0.0800 0.0045
Bin_9 1000 750 0.1000 0.0750 0.0072
Bin_10 1000 900 0.1000 0.0900 0.0011
4.4 结果解读与漂移判定
根据行业通用标准,PSI值的解读如下:
| PSI值范围 | 漂移程度 | 建议行动 |
|---|---|---|
| PSI < 0.1 | 无显著漂移 | 无需干预,持续监控 |
| 0.1 ≤ PSI < 0.25 | 轻度漂移 | 需关注,排查特征或客群变化 |
| PSI ≥ 0.25 | 严重漂移 | 立即告警,启动模型重训或策略调整 |
在本案例中,PSI值为0.0892,小于0.1,属于无显著漂移。但从各箱子的PSI贡献来看,Bin_1(低分段)和Bin_9(高分段)的贡献值相对较高,说明实际分布的低分客群占比增加,高分客群占比减少,模型评分整体有下移趋势,需要持续关注。
实战避坑:
- 分箱数量:bins=10是行业默认值,但若样本量较小(如<1000),建议减少至5~6箱,避免空箱导致PSI失真。
- 分箱方式:等频分箱比等宽分箱更稳健,尤其在分数分布呈偏态时。
- 零值处理:务必处理空箱,否则PSI计算会报错或产生无穷大值。
- 阈值调整:不同业务场景可微调PSI阈值,例如高风险信贷场景可将告警阈值设为0.15。
4.5 PSI计算流程可视化
下图展示了从原始分数分布到最终PSI值的完整计算流程,帮助理解数据流转与核心步骤。
4.6 批量监控与自动化脚本
在实际生产环境中,通常需要按天/周/月批量计算PSI,并自动触发告警。以下是一个简化的批量监控脚本框架:
import pandas as pd
from datetime import datetime, timedelta
def batch_psi_monitor(historical_data, current_data, date_col='score_date', score_col='score', bins=10):
"""
批量计算每日PSI,并与基准分布对比。
参数:
historical_data (pd.DataFrame): 历史基准数据(含日期和分数列)。
current_data (pd.DataFrame): 当前监控期数据。
date_col (str): 日期列名。
score_col (str): 分数列名。
bins (int): 分箱数。
返回:
pd.DataFrame: 每日PSI监控结果。
"""
# 以历史数据为基准分布
expected = historical_data[score_col].values
results = []
for date, group in current_data.groupby(date_col):
actual = group[score_col].values
psi, _ = calculate_psi(expected, actual, bins=bins)
results.append({'date': date, 'psi': psi})
result_df = pd.DataFrame(results)
result_df['alert_level'] = result_df['psi'].apply(
lambda x: '正常' if x < 0.1 else ('关注' if x < 0.25 else '告警')
)
return result_df
# 使用示例
# daily_psi = batch_psi_monitor(train_data, online_data)
# print(daily_psi[daily_psi['alert_level'] == '告警'])