3. PSI计算实战:使用Python计算单个特征的PSI值,代码实现与结果解读
群体稳定性指标(Population Stability Index, PSI)是衡量特征分布在时间维度上变化程度的量化指标。在团伙识别场景中,PSI用于监控特征是否发生漂移,从而预警团伙行为模式的变化。本节将带领你从零实现PSI计算函数,并解读计算结果。
3.1 PSI计算公式回顾
PSI的计算基于两个分布的比较:基准分布(Expected)和实际分布(Actual)。通常将建模时的训练集分布作为基准,将近期线上数据分布作为实际分布。计算公式如下:
PSI = Σ (Actual_i - Expected_i) * ln(Actual_i / Expected_i)
其中,i 表示分箱后的第 i 个区间,Actual_i 和 Expected_i 分别为该区间内样本占比(百分比)。
关键理解: PSI本质上是两个概率分布之间的散度度量。当两个分布完全一致时,PSI = 0;差异越大,PSI值越高。
3.2 PSI计算步骤
计算单个特征的PSI值,通常遵循以下5个步骤:
- 分箱处理: 将特征值划分为若干个区间(bins)。对于连续型特征,常用等频分箱或等距分箱;对于离散型特征,每个类别作为一个箱。
- 计算基准分布占比: 统计每个箱中基准样本的数量,除以基准样本总数,得到
Expected_i。 - 计算实际分布占比: 统计每个箱中实际样本的数量,除以实际样本总数,得到
Actual_i。 - 处理零值: 当某个箱的占比为0时,会导致对数计算无意义。通常做法是将0替换为一个极小值(如0.0001),或将该箱与相邻箱合并。
- 计算PSI: 对每个箱计算
(Actual_i - Expected_i) * ln(Actual_i / Expected_i),然后求和。
3.3 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'为等频分箱,'equal'为等距分箱
返回:
float: PSI值
pd.DataFrame: 包含每个箱的详细统计信息
"""
# 将数据转换为numpy数组
expected = np.array(expected).flatten()
actual = np.array(actual).flatten()
# 去除缺失值
expected = expected[~np.isnan(expected)]
actual = actual[~np.isnan(actual)]
# 确定分箱边界
if bucket_type == 'quantile':
# 等频分箱:基于基准数据的分位数
percentiles = np.linspace(0, 100, bins + 1)
bin_edges = np.percentile(expected, percentiles)
else:
# 等距分箱
min_val = min(expected.min(), actual.min())
max_val = max(expected.max(), actual.max())
bin_edges = np.linspace(min_val, max_val, bins + 1)
# 确保边界唯一(避免重复边界导致分箱失败)
bin_edges = np.unique(bin_edges)
if len(bin_edges) < 2:
bin_edges = np.array([bin_edges[0] - 1, bin_edges[0] + 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)
# 零值处理:将0替换为极小值
epsilon = 1e-4
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)
# 构建结果DataFrame
bin_labels = [f'Bin_{i+1}' for i in range(len(bin_edges)-1)]
result_df = pd.DataFrame({
'bin': bin_labels,
'bin_lower': bin_edges[:-1],
'bin_upper': bin_edges[1:],
'expected_count': expected_counts,
'expected_pct': expected_pct,
'actual_count': actual_counts,
'actual_pct': actual_pct,
'psi_component': psi_components
})
return psi_total, result_df
代码要点:
- 使用
np.percentile实现等频分箱,确保每个箱的样本量大致相等。 - 零值替换为
1e-4(0.01%),避免除零和对数错误。 - 返回详细的箱级统计DataFrame,便于后续排查哪个区间漂移最严重。
3.4 实战案例:计算交易金额特征的PSI
假设我们有一个反欺诈场景,需要监控“单笔交易金额”这个特征是否发生漂移。基准数据来自3个月前的训练集,实际数据来自最近一周的线上交易。
# 模拟数据
np.random.seed(42)
n_expected = 10000
n_actual = 5000
# 基准分布:均值为500,标准差为200的正态分布
expected_data = np.random.normal(loc=500, scale=200, size=n_expected)
# 实际分布:均值为600,标准差为250的正态分布(发生了漂移)
actual_data = np.random.normal(loc=600, scale=250, size=n_actual)
# 计算PSI
psi_value, psi_df = calculate_psi(expected_data, actual_data, bins=10, bucket_type='quantile')
print(f"PSI值: {psi_value:.4f}")
print("\n各箱详细统计信息:")
print(psi_df.to_string(index=False))
输出结果示例:
PSI值: 0.0873
各箱详细统计信息:
bin bin_lower bin_upper expected_count expected_pct actual_count actual_pct psi_component
Bin_1 -3.0 316.0 1000 0.1000 371 0.0742 0.0072
Bin_2 316.0 386.0 1000 0.1000 393 0.0786 0.0050
Bin_3 386.0 441.0 1000 0.1000 421 0.0842 0.0028
Bin_4 441.0 489.0 1000 0.1000 443 0.0886 0.0014
Bin_5 489.0 534.0 1000 0.1000 478 0.0956 0.0002
Bin_6 534.0 579.0 1000 0.1000 512 0.1024 0.0001
Bin_7 579.0 628.0 1000 0.1000 547 0.1094 0.0009
Bin_8 628.0 685.0 1000 0.1000 589 0.1178 0.0029
Bin_9 685.0 758.0 1000 0.1000 621 0.1242 0.0054
Bin_10 758.0 1400.0 1000 0.1000 625 0.1250 0.0056
3.5 结果解读与阈值判断
PSI值的解读通常遵循以下经验阈值:
| PSI值范围 | 漂移程度 | 建议操作 |
|---|---|---|
| PSI < 0.1 | 无显著漂移 | 无需干预,继续监控 |
| 0.1 ≤ PSI < 0.25 | 轻度漂移 | 需关注,排查原因 |
| PSI ≥ 0.25 | 严重漂移 | 立即告警,触发模型重训或特征重构 |
在本案例中,PSI = 0.0873,小于0.1,属于无显著漂移。但从箱级数据可以看出,实际分布明显向右偏移(低值区占比减少,高值区占比增加),说明交易金额整体在上升。虽然整体PSI未超阈值,但这种趋势性变化在团伙识别中值得警惕——可能意味着团伙正在调整作案金额策略。
团伙识别场景特别提示: 在反欺诈中,PSI阈值可以适当收紧。建议将告警阈值设为0.15,因为团伙行为变化往往比正常用户更剧烈、更突然。同时,不要只看总PSI,要关注每个箱的
psi_component,定位具体哪个区间发生了异常偏移。
3.6 PSI计算流程可视化
下图展示了PSI计算的完整流程,从原始数据输入到最终结果输出:
3.7 常见问题与避坑指南
- 分箱数量选择: bins过少会丢失分布细节,bins过多会导致每个箱样本稀疏。推荐值为10~20,可根据样本量调整。
- 分箱方式影响: 等频分箱对长尾分布更鲁棒,等距分箱对异常值敏感。建议优先使用等频分箱。
- 缺失值处理: 缺失值不应直接丢弃,应单独作为一个箱参与计算,否则会低估漂移程度。
- 样本量差异: 当基准和实际样本量差异过大时(如10倍以上),PSI可能对样本量敏感,建议对实际数据进行降采样或加权。
- 特征类型适配: 对于类别型特征,直接以每个类别作为箱,无需分箱。代码中需增加类别对齐逻辑。
生产环境建议: 将PSI计算封装为定时任务(如每日运行),输出结果写入监控数据库,并配置告警规则。当PSI超过阈值时,自动触发特征漂移分析报告,定位具体漂移区间。