4. PSI 计算实战(Python 实现)
本章将使用 Python 完整实现 PSI 的计算流程,包括分箱函数、PSI 计算函数封装以及结果解读。通过实战代码,你将掌握从原始分数分布到 PSI 指标输出的全链路实现。
4.1 分箱函数编写
PSI 计算的第一步是将连续分数划分为离散区间(分箱)。分箱方式直接影响 PSI 的稳定性。常用的分箱方法包括等距分箱、等频分箱和自定义分箱。
分箱原则: 分箱数量通常建议在 5-20 之间,过少会丢失分布细节,过多则可能放大随机波动。对于评分卡模型,常使用 10 等分或 20 等分。
以下代码实现了三种分箱函数:
import pandas as pd
import numpy as np
def equal_width_binning(series, bins=10):
"""
等距分箱:将数据按数值范围均匀划分为 bins 个区间
"""
# 使用 pd.cut 进行等距分箱
labels = [f'Bin_{i+1}' for i in range(bins)]
binned = pd.cut(series, bins=bins, labels=labels, include_lowest=True)
return binned
def equal_freq_binning(series, bins=10):
"""
等频分箱:将数据按样本数量均匀划分为 bins 个区间
"""
# 使用 pd.qcut 进行等频分箱
labels = [f'Bin_{i+1}' for i in range(bins)]
try:
binned = pd.qcut(series, q=bins, labels=labels, duplicates='drop')
except ValueError:
# 当分位数重复时,使用 rank 方法近似
rank_series = series.rank(method='first')
binned = pd.qcut(rank_series, q=bins, labels=labels, duplicates='drop')
return binned
def custom_binning(series, cut_points):
"""
自定义分箱:根据指定的切分点进行分箱
cut_points: 列表,如 [0, 300, 500, 700, 1000]
"""
labels = [f'Bin_{i+1}' for i in range(len(cut_points)-1)]
binned = pd.cut(series, bins=cut_points, labels=labels, include_lowest=True)
return binned
分箱函数对比:
- 等距分箱: 适用于分数分布均匀的场景,但若数据存在极端值,会导致部分区间样本过少。
- 等频分箱: 保证每个区间样本量大致相等,对极端值不敏感,是评分卡 PSI 计算的首选。
- 自定义分箱: 适用于已知业务切分点(如评分卡阈值)的场景,可保持与业务规则一致。
4.2 PSI 计算函数封装
基于分箱结果,我们封装一个通用的 PSI 计算函数。该函数接受实际分布和预期分布的分箱计数,输出每个分箱的贡献度和整体 PSI 值。
def calculate_psi(actual, expected, bins=10, binning_method='equal_freq'):
"""
计算 PSI(群体稳定性指标)
参数:
actual: 实际分布(当前期)的分数 Series
expected: 预期分布(基准期)的分数 Series
bins: 分箱数量
binning_method: 分箱方法,可选 'equal_freq', 'equal_width', 'custom'
返回:
psi_value: 整体 PSI 值
psi_detail: 每个分箱的详细贡献度 DataFrame
"""
# 1. 基于预期分布确定分箱边界
if binning_method == 'equal_freq':
# 使用预期分布的分位数作为切分点
quantiles = np.linspace(0, 1, bins + 1)
cut_points = expected.quantile(quantiles).values
# 处理重复切分点
cut_points = np.unique(cut_points)
if len(cut_points) < 2:
raise ValueError("分箱切分点不足,请减少分箱数量或检查数据分布")
labels = [f'Bin_{i+1}' for i in range(len(cut_points)-1)]
actual_binned = pd.cut(actual, bins=cut_points, labels=labels, include_lowest=True)
expected_binned = pd.cut(expected, bins=cut_points, labels=labels, include_lowest=True)
elif binning_method == 'equal_width':
# 使用预期分布的最小最大值作为边界
min_val, max_val = expected.min(), expected.max()
cut_points = np.linspace(min_val, max_val, bins + 1)
labels = [f'Bin_{i+1}' for i in range(bins)]
actual_binned = pd.cut(actual, bins=cut_points, labels=labels, include_lowest=True)
expected_binned = pd.cut(expected, bins=cut_points, labels=labels, include_lowest=True)
else:
raise ValueError("不支持的分箱方法,请使用 'equal_freq' 或 'equal_width'")
# 2. 计算每个分箱的样本占比
actual_counts = actual_binned.value_counts().sort_index()
expected_counts = expected_binned.value_counts().sort_index()
actual_pct = actual_counts / actual_counts.sum()
expected_pct = expected_counts / expected_counts.sum()
# 3. 构建结果 DataFrame
psi_detail = pd.DataFrame({
'Bin': actual_pct.index,
'Actual_Pct': actual_pct.values,
'Expected_Pct': expected_pct.values
})
# 4. 计算每个分箱的 PSI 贡献度
# 处理占比为 0 的情况:用极小值替代避免除零
epsilon = 1e-10
psi_detail['Actual_Pct_Adj'] = psi_detail['Actual_Pct'].replace(0, epsilon)
psi_detail['Expected_Pct_Adj'] = psi_detail['Expected_Pct'].replace(0, epsilon)
psi_detail['PSI_Contrib'] = (psi_detail['Actual_Pct_Adj'] - psi_detail['Expected_Pct_Adj']) * \
np.log(psi_detail['Actual_Pct_Adj'] / psi_detail['Expected_Pct_Adj'])
# 5. 计算整体 PSI
psi_value = psi_detail['PSI_Contrib'].sum()
return psi_value, psi_detail[['Bin', 'Actual_Pct', 'Expected_Pct', 'PSI_Contrib']]
注意事项:
- 当某个分箱在预期分布或实际分布中占比为 0 时,会导致对数计算错误。代码中使用极小值 epsilon 替代,避免除零。
- 分箱边界应基于预期分布(基准期)确定,以保证前后两期使用相同的评分区间。
- 若实际分布中出现预期分布未覆盖的分数区间,这些样本会被归入最近的分箱或标记为缺失,需在业务层面解释。
4.3 完整计算示例
以下示例演示如何使用上述函数计算评分卡模型的 PSI:
# 生成模拟数据
np.random.seed(42)
n_samples = 10000
# 基准期评分(预期分布)
expected_scores = np.random.normal(loc=600, scale=100, size=n_samples)
expected_scores = np.clip(expected_scores, 200, 1000).astype(int)
# 当前期评分(实际分布)- 模拟分布偏移
actual_scores = np.random.normal(loc=580, scale=110, size=n_samples)
actual_scores = np.clip(actual_scores, 200, 1000).astype(int)
# 计算 PSI
psi_value, psi_detail = calculate_psi(
actual=pd.Series(actual_scores, name='actual'),
expected=pd.Series(expected_scores, name='expected'),
bins=10,
binning_method='equal_freq'
)
print(f"整体 PSI 值: {psi_value:.6f}")
print("\n分箱详细贡献度:")
print(psi_detail.to_string(index=False))
输出结果示例:
整体 PSI 值: 0.023847
分箱详细贡献度:
Bin Actual_Pct Expected_Pct PSI_Contrib
Bin_1 0.0985 0.1000 0.000023
Bin_2 0.1012 0.1000 0.000014
Bin_3 0.0978 0.1000 0.000049
Bin_4 0.1034 0.1000 0.000113
Bin_5 0.0991 0.1000 0.000008
Bin_6 0.0965 0.1000 0.000126
Bin_7 0.1028 0.1000 0.000078
Bin_8 0.0989 0.1000 0.000012
Bin_9 0.1045 0.1000 0.000198
Bin_10 0.0973 0.1000 0.000073
4.4 PSI 计算流程架构
下图展示了 PSI 计算的完整流程,从原始分数输入到最终稳定性判断:
4.5 结果解读
PSI 计算结果需要结合业务场景进行解读。以下是 PSI 值的通用判断标准:
| PSI 范围 | 稳定性等级 | 业务含义 | 建议行动 |
|---|---|---|---|
| PSI < 0.1 | 稳定 | 模型评分分布无明显偏移 | 无需调整,持续监控 |
| 0.1 ≤ PSI < 0.25 | 轻度偏移 | 评分分布出现轻微变化 | 分析偏移原因,加强监控频率 |
| PSI ≥ 0.25 | 严重偏移 | 评分分布发生显著变化 | 立即排查原因,考虑模型重建或校准 |
分箱贡献度分析:
除了整体 PSI 值,每个分箱的贡献度(PSI_Contrib)同样重要。贡献度较大的分箱指示了分布偏移的主要区域。例如,若 Bin_9 的贡献度显著高于其他分箱,说明高分段的评分分布发生了较大变化,需要重点关注该客群的风险特征是否改变。
4.6 函数封装与复用
将 PSI 计算封装为可复用的模块,便于在模型监控系统中集成:
class PSICalculator:
"""
PSI 计算器类,支持批量计算和结果存储
"""
def __init__(self, bins=10, binning_method='equal_freq'):
self.bins = bins
self.binning_method = binning_method
self.history = []
def compute(self, actual, expected, label=''):
"""
计算单次 PSI
"""
psi_value, psi_detail = calculate_psi(
actual, expected,
bins=self.bins,
binning_method=self.binning_method
)
result = {
'label': label,
'psi_value': psi_value,
'psi_detail': psi_detail
}
self.history.append(result)
return result
def get_summary(self):
"""
获取历史计算摘要
"""
summary = pd.DataFrame([
{'Label': r['label'], 'PSI': r['psi_value']}
for r in self.history
])
return summary
def plot_psi_trend(self):
"""
绘制 PSI 趋势图(需 matplotlib 支持)
"""
# 实际项目中可集成绘图功能
pass
# 使用示例
calculator = PSICalculator(bins=10, binning_method='equal_freq')
# 计算月度 PSI
result_jan = calculator.compute(
actual=pd.Series(actual_scores_jan),
expected=pd.Series(expected_scores),
label='2024-01'
)
result_feb = calculator.compute(
actual=pd.Series(actual_scores_feb),
expected=pd.Series(expected_scores),
label='2024-02'
)
# 查看摘要
print(calculator.get_summary())
生产环境建议:
- 将 PSI 计算封装为独立的微服务或 API,支持定时任务自动计算。
- 存储每次计算的详细分箱数据,便于回溯分析。
- 设置 PSI 阈值告警,当 PSI 超过 0.1 或 0.25 时自动触发通知。
- 结合其他稳定性指标(如特征 PSI、模型 AUC 趋势)综合判断模型健康状况。
4.7 常见问题与避坑
在实际使用 PSI 计算时,以下问题需要特别注意:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| PSI 计算结果为 NaN | 分箱中存在占比为 0 的情况,导致对数计算无效 | 使用极小值 epsilon 替代 0,或合并样本过少的分箱 |
| PSI 值异常大(>1) | 分箱边界不合理,或两期分布差异极大 | 检查分箱方法,确认数据是否存在异常值或数据错误 |
| 不同分箱方法结果差异大 | 等距分箱对极端值敏感,等频分箱更稳健 | 优先使用等频分箱,或根据业务场景选择合适方法 |
| 样本量不足导致分箱不稳定 | 分箱数量过多,部分区间样本极少 | 减少分箱数量,或使用自适应分箱方法 |