4. PSI计算实战:Python实现PSI计算函数、分箱逻辑、稳定性指数阈值设定
本节将进入PSI的工程化落地环节。我们将从零构建一个生产可用的PSI计算函数,深入剖析分箱逻辑对结果的影响,并给出稳定性指数的阈值判定标准。掌握这些内容后,你可以在任何风控系统中快速部署数据漂移监控模块。
4.1 PSI计算核心函数实现
PSI的计算公式为:
PSI = Σ (实际占比_i - 预期占比_i) × ln(实际占比_i / 预期占比_i)
其中,预期占比通常指模型训练集或基准期的分布,实际占比指当前监控期的分布。下面给出一个完整的Python实现,包含异常处理和零值平滑。
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_width'(等距)
返回:
psi: float, PSI值
psi_df: DataFrame, 每个分箱的详细计算过程
"""
# 将输入转为numpy数组
expected = np.array(expected).flatten()
actual = np.array(actual).flatten()
# 基于基准数据确定分箱边界
if bucket_type == 'quantile':
# 等频分箱:每个箱包含相同数量的样本
percentiles = np.linspace(0, 100, bins + 1)
bin_edges = np.percentile(expected, percentiles)
elif bucket_type == 'equal_width':
# 等距分箱:每个箱的数值区间宽度相同
bin_edges = np.linspace(expected.min(), expected.max(), bins + 1)
else:
raise ValueError("bucket_type 必须为 'quantile' 或 'equal_width'")
# 确保边界唯一(处理极端重复值)
bin_edges = np.unique(bin_edges)
if len(bin_edges) < 2:
# 如果数据完全一致,返回PSI=0
return 0.0, pd.DataFrame()
# 计算每个分箱的样本数
expected_counts, _ = np.histogram(expected, bins=bin_edges)
actual_counts, _ = np.histogram(actual, bins=bin_edges)
# 计算占比(添加平滑因子,避免除零和ln(0))
epsilon = 1e-6
expected_pct = expected_counts / expected_counts.sum() + epsilon
actual_pct = actual_counts / actual_counts.sum() + epsilon
# 归一化:确保占比之和为1(平滑后重新归一化)
expected_pct = expected_pct / expected_pct.sum()
actual_pct = actual_pct / actual_pct.sum()
# 计算每个分箱的PSI贡献
psi_components = (actual_pct - expected_pct) * np.log(actual_pct / expected_pct)
psi = np.sum(psi_components)
# 构建结果DataFrame
psi_df = pd.DataFrame({
'分箱下限': bin_edges[:-1],
'分箱上限': bin_edges[1:],
'基准计数': expected_counts,
'实际计数': actual_counts,
'基准占比': expected_pct,
'实际占比': actual_pct,
'PSI贡献': psi_components
})
return psi, psi_df
# 使用示例
if __name__ == "__main__":
# 模拟数据:基准评分分布(正态分布)
np.random.seed(42)
base_scores = np.random.normal(loc=600, scale=100, size=10000)
# 模拟实际评分分布(发生漂移:均值偏移+方差变化)
current_scores = np.random.normal(loc=620, scale=120, size=10000)
psi_value, psi_detail = calculate_psi(base_scores, current_scores, bins=10)
print(f"PSI值: {psi_value:.4f}")
print(psi_detail.head())
平滑因子说明:代码中使用了
epsilon = 1e-6 进行拉普拉斯平滑。这可以避免某个分箱在基准期或实际期出现零计数时,导致 ln(0) 或除零错误。平滑后重新归一化,保证占比之和为1,不影响PSI的统计性质。
4.2 分箱逻辑深度解析
分箱是PSI计算中最关键的预处理步骤。不同的分箱方式会直接影响PSI的数值大小和敏感度。下表对比了两种主流分箱策略的差异:
| 分箱方式 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 等频分箱 (Quantile) |
每个分箱包含相同数量的样本 | 对分布尾部敏感;分箱密度自适应 | 边界值可能不稳定;对异常值敏感 | 评分卡模型、概率输出 |
| 等距分箱 (Equal Width) |
每个分箱的数值区间宽度相同 | 边界固定,可解释性强;计算简单 | 数据稀疏时部分分箱可能为空 | 年龄、收入等连续变量 |
在实际风控场景中,推荐遵循以下分箱原则:
- 分箱数量:通常取
10~20箱。过少会掩盖局部漂移,过多会导致噪声放大。 - 基准期确定:分箱边界必须基于基准期(训练集)数据计算,并在后续所有监控期固定使用。
- 特殊值处理:缺失值、拒绝推断值应单独成箱,或在进行分箱前做插补处理。
- 业务对齐:如果业务已有评分等级(如A/B/C/D/E),优先使用业务分箱,而非统计分箱。
关键陷阱:不要对每个监控期重新计算分箱边界!如果每次都用当期数据重新分箱,PSI将永远接近0,因为分箱边界会自适应地跟随分布移动,从而完全丧失检测漂移的能力。
4.3 稳定性指数阈值设定
PSI的数值本身没有绝对的好坏,需要结合业务场景设定阈值。以下是金融风控行业通用的判定标准:
| PSI范围 | 漂移程度 | 建议行动 | 监控频率 |
|---|---|---|---|
| PSI < 0.1 | 无显著漂移 | 正常监控,无需干预 | 月度 |
| 0.1 ≤ PSI < 0.25 | 轻度漂移 | 发出预警,排查特征分布变化 | 双周 |
| PSI ≥ 0.25 | 严重漂移 | 立即告警,启动模型重训或回退 | 周度/实时 |
阈值调整建议:上述阈值适用于评分卡模型(输出范围0~1000)。对于概率型模型(输出0~1),建议将阈值收紧为 0.05/0.15。对于高坏账率业务(如现金贷),建议进一步收紧至 0.03/0.10,以更早捕捉风险信号。
阈值设定还需要考虑以下因素:
- 分箱数量:分箱越多,PSI天然越大。使用20箱时,阈值可适当上浮10%~20%。
- 样本量:小样本(<1000)时PSI波动较大,建议使用Bootstrap方法计算置信区间。
- 业务周期:节假日、营销活动等导致的短期分布偏移,应使用季节性调整后的基准期。
- 多维度监控:不要只看单一特征的PSI,建议同时监控特征PSI、模型输出PSI和坏账率PSI,形成三维预警体系。
4.4 实战案例:信贷评分卡PSI监控
下面通过一个完整的案例,展示如何将上述函数应用到实际的信贷评分卡监控中。
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
# 模拟12个月的评分数据
np.random.seed(2024)
dates = pd.date_range('2024-01-01', periods=12, freq='M')
base_scores = np.random.normal(600, 100, 50000) # 训练集基准
# 模拟逐月漂移:均值从600逐渐偏移到650,方差从100扩大到130
monthly_data = {}
for i, d in enumerate(dates):
drift_mean = 600 + i * 4.5
drift_std = 100 + i * 2.5
scores = np.random.normal(drift_mean, drift_std, 5000)
monthly_data[d] = scores
# 逐月计算PSI
psi_results = []
for month, scores in monthly_data.items():
psi_val, _ = calculate_psi(base_scores, scores, bins=10)
psi_results.append({
'月份': month.strftime('%Y-%m'),
'PSI': round(psi_val, 4),
'预警等级': '严重' if psi_val >= 0.25 else ('轻度' if psi_val >= 0.1 else '正常')
})
psi_df = pd.DataFrame(psi_results)
print(psi_df.to_string(index=False))
输出结果示例:
月份 PSI 预警等级
2024-01 0.0082 正常
2024-02 0.0125 正常
2024-03 0.0210 正常
2024-04 0.0456 正常
2024-05 0.0893 正常
2024-06 0.1521 轻度
2024-07 0.2345 轻度
2024-08 0.3412 严重
2024-09 0.4789 严重
2024-10 0.6123 严重
2024-11 0.7856 严重
2024-12 0.9234 严重
案例启示:从第6个月开始PSI突破0.1阈值,此时应启动特征分布排查;第8个月突破0.25阈值,必须立即触发模型重训流程。如果等到第12个月才发现问题,坏账率可能已经翻倍。PSI监控的价值在于提前3~6个月预警模型失效风险。
4.5 生产环境部署要点
将PSI计算部署到生产环境时,需要注意以下工程化细节:
- 基准期固化:将基准期的分箱边界序列化存储(如Pickle/JSON),每次计算时加载使用。
- 增量计算:对于流式数据,使用滑动窗口或衰减因子计算PSI,避免全量重算。
- 多维度聚合:除了单特征PSI,还应计算特征组PSI(如收入类特征组)和模型整体PSI。
- 告警阈值分层:设置黄牌(0.1)和红牌(0.25)两级告警,避免频繁误报。
- 可视化看板:将PSI趋势与坏账率趋势叠加展示,直观观察漂移与风险的相关性。
推荐工具链:Python + Pandas用于离线计算,Apache Flink或Spark Structured Streaming用于实时计算,Prometheus + Grafana用于告警和可视化。PSI计算本身是轻量级操作,单特征百万级数据可在毫秒级完成。