3. PSI计算实战:使用Python实现PSI计算函数,处理缺失值与异常值
在完成PSI的理论推导与阈值设定后,本节将进入工程落地环节。我们将使用Python实现一个健壮的PSI计算函数,重点解决实际数据中常见的缺失值、零分母和极端分布问题。一个未经异常处理的PSI函数在生产环境中极易产生NaN或无穷大,导致误告警或漏告警。
3.1 标准PSI计算函数(基础版)
以下函数实现了PSI的核心公式:PSI = Σ (实际占比 - 预期占比) * ln(实际占比 / 预期占比)。该版本假设输入数据已经完成分箱且无缺失值。
import numpy as np
import pandas as pd
def psi_base(expected, actual, bins=10):
"""
基础PSI计算函数(无异常处理)
Parameters:
expected: array-like, 预期分布(训练集)
actual: array-like, 实际分布(生产集)
bins: int, 分箱数量
Returns:
float, PSI值
"""
# 等宽分箱
_, bin_edges = np.histogram(expected, bins=bins)
# 计算各箱占比
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)
# 计算PSI
psi_value = np.sum((actual_pct - expected_pct) * np.log(actual_pct / expected_pct))
return psi_value
⚠ 生产风险:上述函数在以下场景会直接崩溃或返回NaN:
1. 某分箱内预期占比为0 → 分母为零,ln(0)无定义。
2. 某分箱内实际占比为0 → ln(0)无定义。
3. 输入数据包含NaN → 分箱结果异常。
4. 所有数据落入同一分箱 → 其他分箱占比为0。
1. 某分箱内预期占比为0 → 分母为零,ln(0)无定义。
2. 某分箱内实际占比为0 → ln(0)无定义。
3. 输入数据包含NaN → 分箱结果异常。
4. 所有数据落入同一分箱 → 其他分箱占比为0。
3.2 健壮版PSI函数(含缺失值与异常值处理)
生产级PSI函数需要处理以下三类问题:
| 问题类型 | 影响 | 处理策略 |
|---|---|---|
| 缺失值(NaN) | 分箱计数错误,PSI计算失真 | 单独成箱或剔除后记录缺失率变化 |
| 零占比分箱 | ln(0)导致NaN或无穷大 | 添加极小平滑值(如1e-6) |
| 极端离群值 | 分箱边界被拉偏,PSI虚高 | 基于百分位数截断(如1%-99%) |
def psi_robust(expected, actual, bins=10, smooth=1e-6,
clip_range=(0.01, 0.99), handle_missing='separate'):
"""
健壮PSI计算函数(生产级)
Parameters:
expected: array-like, 预期分布
actual: array-like, 实际分布
bins: int, 分箱数量
smooth: float, 平滑值,防止零占比
clip_range: tuple, 截断百分位范围
handle_missing: str, 'separate' | 'drop' | 'raise'
Returns:
dict: {'psi': float, 'missing_rate_expected': float,
'missing_rate_actual': float, 'bins_used': int}
"""
# 1. 缺失值处理
exp_missing = np.isnan(expected).sum() / len(expected)
act_missing = np.isnan(actual).sum() / len(actual)
if handle_missing == 'raise' and (exp_missing > 0 or act_missing > 0):
raise ValueError(f"数据包含缺失值: 预期{exp_missing:.2%}, 实际{act_missing:.2%}")
if handle_missing == 'drop':
expected = expected[~np.isnan(expected)]
actual = actual[~np.isnan(actual)]
elif handle_missing == 'separate':
# 将缺失值作为独立分箱处理
exp_non_missing = expected[~np.isnan(expected)]
act_non_missing = actual[~np.isnan(actual)]
else:
exp_non_missing = expected
act_non_missing = actual
# 2. 极端值截断(基于百分位数)
lower = np.percentile(exp_non_missing, clip_range[0] * 100)
upper = np.percentile(exp_non_missing, clip_range[1] * 100)
exp_clipped = np.clip(exp_non_missing, lower, upper)
act_clipped = np.clip(act_non_missing, lower, upper)
# 3. 分箱(基于预期分布的边界)
_, bin_edges = np.histogram(exp_clipped, bins=bins)
exp_counts, _ = np.histogram(exp_clipped, bins=bin_edges)
act_counts, _ = np.histogram(act_clipped, bins=bin_edges)
# 4. 计算占比 + 平滑处理
exp_pct = (exp_counts + smooth) / (len(exp_clipped) + smooth * bins)
act_pct = (act_counts + smooth) / (len(act_clipped) + smooth * bins)
# 5. 计算PSI
psi_value = np.sum((act_pct - exp_pct) * np.log(act_pct / exp_pct))
# 6. 如果采用separate模式,加入缺失率漂移惩罚项
if handle_missing == 'separate':
missing_psi = (act_missing - exp_missing) * np.log(
(act_missing + smooth) / (exp_missing + smooth)
)
psi_value += missing_psi
return {
'psi': round(psi_value, 6),
'missing_rate_expected': round(exp_missing, 4),
'missing_rate_actual': round(act_missing, 4),
'bins_used': bins
}
🔧 平滑值选择建议:
- 1e-6:适用于样本量 > 10万的大规模场景,对PSI影响可忽略。
- 1e-4:适用于样本量 1万~10万的中等规模场景。
- 1e-3:适用于样本量 < 1万的小样本场景,防止过度平滑导致PSI被压低。
3.3 PSI计算流程与异常处理决策树
下图展示了PSI计算过程中各异常处理分支的决策逻辑,帮助你在实际工程中快速定位问题环节。
3.4 实战案例:信贷评分卡PSI监控
以下案例模拟了信贷评分卡模型在生产环境中的PSI计算,包含缺失值注入和极端值场景。
# 生成模拟数据
np.random.seed(42)
n_train = 50000
n_prod = 50000
# 训练集:正态分布评分
train_scores = np.random.normal(loc=600, scale=100, size=n_train)
train_scores = np.clip(train_scores, 300, 900)
# 生产集:分布偏移 + 缺失值 + 极端值
prod_scores = np.random.normal(loc=580, scale=120, size=n_prod)
prod_scores = np.clip(prod_scores, 300, 900)
# 注入5%缺失值
missing_idx = np.random.choice(n_prod, size=int(n_prod * 0.05), replace=False)
prod_scores[missing_idx] = np.nan
# 注入1%极端值
extreme_idx = np.random.choice(n_prod, size=int(n_prod * 0.01), replace=False)
prod_scores[extreme_idx] = np.random.uniform(0, 300, size=len(extreme_idx))
# 计算PSI
result = psi_robust(
expected=train_scores,
actual=prod_scores,
bins=10,
smooth=1e-6,
clip_range=(0.01, 0.99),
handle_missing='separate'
)
print(f"PSI值: {result['psi']}")
print(f"预期缺失率: {result['missing_rate_expected']:.4f}")
print(f"实际缺失率: {result['missing_rate_actual']:.4f}")
print(f"使用分箱数: {result['bins_used']}")
💡 输出解读:
若PSI > 0.25,说明生产分布发生显著漂移,需要排查特征工程或数据采集环节。
若缺失率从0%上升到5%以上,即使PSI未超标,也应触发数据质量告警。
若PSI > 0.25,说明生产分布发生显著漂移,需要排查特征工程或数据采集环节。
若缺失率从0%上升到5%以上,即使PSI未超标,也应触发数据质量告警。
3.5 常见踩坑与避坑指南
- 坑1:分箱边界不一致——生产集和训练集使用各自的分箱边界,导致PSI虚高。应始终使用训练集的分箱边界固定生产集的计算。
- 坑2:平滑值过大——当样本量较小时,过大的平滑值会压低真实漂移信号,建议根据样本量动态调整。
- 坑3:忽略缺失率变化——缺失率从1%上升到10%本身就是重大漂移信号,应在PSI之外单独监控缺失率。
- 坑4:对类别特征直接使用等宽分箱——类别特征应使用频次分箱或WOE分箱,等宽分箱会导致分箱边界无意义。
📊 生产环境最佳实践:
- 将PSI计算封装为微服务,支持批量与实时两种调用模式。
- 每次计算时记录分箱边界、缺失率、平滑值等元数据,便于回溯。
- 设置双重告警:PSI > 0.25 触发模型漂移告警;缺失率变化 > 3% 触发数据质量告警。
- 定期(如每周)重新计算训练集的分箱边界,适应特征分布的长期缓慢变化。