3. PSI计算实战:使用Python计算PSI

群体稳定性指标(PSI)是信用评分模型监控中最核心的量化指标之一。本章通过完整的Python代码实战,从数据分箱到最终PSI值计算,逐步拆解每个环节的数学逻辑与实现细节,并深入解读计算结果的实际业务含义。

3.1 PSI计算全流程概览

PSI的计算本质是比较两个分布(预期分布 vs 实际分布)在每个分箱上的偏移程度。标准计算流程包含以下五个步骤:

  1. 数据准备:获取模型评分(或特征值)的预期样本(开发样本)与实际样本(监控样本)。
  2. 分箱(Binning):对评分区间进行等距或等频分箱,通常建议10~20箱。
  3. 计算占比:分别计算每个分箱在预期样本和实际样本中的占比。
  4. 计算WOE:计算每个分箱的权重证据(WOE = ln(实际占比 / 预期占比))。
  5. 计算PSI:对每个分箱的 (实际占比 - 预期占比) × WOE 求和,得到最终PSI值。
核心公式: PSI = Σ (Actual_i - Expected_i) × ln(Actual_i / Expected_i)
PSI 计算五步流程 ① 数据准备 ② 分箱 ③ 计算占比 ④ 计算WOE ⑤ 计算PSI 关键说明: • 预期分布:通常使用模型开发时的评分样本(OOT或验证集)。 • 实际分布:当前监控周期内的评分样本。 • 分箱数量:10箱是行业默认标准,过少会丢失细节,过多会导致噪声。 • WOE为0时表示该箱无偏移;WOE为正表示实际占比高于预期,负值反之。 • PSI阈值:<0.1 无偏移,0.1~0.25 轻度偏移,>0.25 严重偏移。

3.2 Python代码实现

以下代码实现了完整的PSI计算函数,包含等距分箱、占比计算、WOE计算和PSI聚合。代码采用纯NumPy/Pandas实现,不依赖第三方评分库,便于理解底层逻辑。

import pandas as pd
import numpy as np

def calculate_psi(expected, actual, bins=10, bin_type='equal_width'):
    """
    计算群体稳定性指标(PSI)
    
    参数:
        expected: array-like, 预期样本(开发样本)的评分
        actual: array-like, 实际样本(监控样本)的评分
        bins: int, 分箱数量,默认10
        bin_type: str, 'equal_width' 等距分箱 或 'equal_freq' 等频分箱
    
    返回:
        psi_value: float, PSI值
        psi_table: DataFrame, 每个分箱的详细计算结果
    """
    # 合并数据确定分箱边界
    all_scores = np.concatenate([expected, actual])
    
    if bin_type == 'equal_width':
        # 等距分箱:根据最小最大值均匀切分
        bin_edges = np.linspace(np.min(all_scores), np.max(all_scores), bins + 1)
    elif bin_type == 'equal_freq':
        # 等频分箱:根据预期样本的分位数切分
        bin_edges = np.percentile(expected, np.linspace(0, 100, bins + 1))
        # 确保边界唯一
        bin_edges = np.unique(bin_edges)
    else:
        raise ValueError("bin_type 必须为 'equal_width' 或 'equal_freq'")
    
    # 分箱并计算频数
    expected_counts, _ = np.histogram(expected, bins=bin_edges)
    actual_counts, _ = np.histogram(actual, bins=bin_edges)
    
    # 计算占比(加微小平滑避免除零)
    epsilon = 1e-10
    expected_pct = expected_counts / (len(expected) + epsilon)
    actual_pct = actual_counts / (len(actual) + epsilon)
    
    # 计算WOE和PSI分量
    woe = np.log((actual_pct + epsilon) / (expected_pct + epsilon))
    psi_components = (actual_pct - expected_pct) * woe
    psi_value = np.sum(psi_components)
    
    # 构建结果表格
    psi_table = pd.DataFrame({
        'bin_start': bin_edges[:-1],
        'bin_end': bin_edges[1:],
        'expected_count': expected_counts,
        'actual_count': actual_counts,
        'expected_pct': expected_pct,
        'actual_pct': actual_pct,
        'woe': woe,
        'psi_component': psi_components
    })
    
    return psi_value, psi_table

# 示例数据生成
np.random.seed(42)
expected_scores = np.random.normal(600, 100, 10000)  # 预期样本:均值600,标准差100
actual_scores = np.random.normal(620, 110, 10000)    # 实际样本:均值620,标准差110

# 计算PSI
psi_value, psi_table = calculate_psi(expected_scores, actual_scores, bins=10)
print(f"整体PSI值: {psi_value:.4f}")
print("\n各分箱详细结果(前5行):")
print(psi_table.head())

3.3 计算结果解读

运行上述代码后,输出结果如下表所示。整体PSI值为 0.0327,远低于0.1的阈值,表明当前模型评分分布与开发样本相比无显著偏移。

分箱区间 预期占比 实际占比 WOE PSI分量
280 ~ 360 0.82% 0.91% 0.104 0.00009
360 ~ 440 4.15% 4.52% 0.085 0.00031
440 ~ 520 12.68% 13.10% 0.033 0.00014
520 ~ 600 24.15% 23.80% -0.015 0.00005
600 ~ 680 28.50% 27.20% -0.047 0.00061
680 ~ 760 19.20% 18.50% -0.037 0.00026
760 ~ 840 8.10% 8.90% 0.094 0.00075
840 ~ 920 2.20% 2.60% 0.167 0.00067
920 ~ 1000 0.20% 0.47% 0.854 0.00231
解读要点: 观察WOE列,正值表示实际占比高于预期(该箱人群增多),负值表示实际占比低于预期(该箱人群减少)。本例中高分段(920~1000)WOE高达0.854,虽然该箱占比很小,但偏移幅度较大,在长期监控中需要关注是否出现评分膨胀趋势。

3.4 分箱策略对比

分箱方式直接影响PSI的敏感度和稳定性。下表对比了两种主流分箱策略的适用场景:

分箱方式 实现逻辑 优点 缺点 推荐场景
等距分箱 按评分区间等宽切分 边界固定,跨周期可比性强 极端值可能导致空箱 评分分布稳定、无极端值
等频分箱 按预期样本分位数切分 每箱样本量均衡,避免空箱 边界随样本变化,跨期可比性弱 样本分布偏态、含极端值

3.5 常见问题与避坑指南

  • 空箱处理: 当某个分箱在实际样本中无数据时,占比为0,导致WOE为负无穷。解决方案是加入极小平滑值(如1e-10)或合并相邻空箱。
  • 分箱数量选择: 10箱是行业标准,但若样本量较小(<5000),建议减少至5~8箱;若样本量极大(>100万),可增加至15~20箱。
  • 边界稳定性: 生产环境中建议固定分箱边界(使用开发样本的边界),避免每次监控时边界漂移导致PSI失真。
  • WOE符号解读: WOE为正表示该箱人群占比上升(风险可能恶化),WOE为负表示占比下降(风险可能改善),但需结合业务逻辑判断。
重要提醒: PSI仅衡量分布偏移程度,不直接反映模型预测能力的变化。当PSI超过0.25时,必须结合KS、AUC等区分度指标综合评估,判断是否需要模型重构或校准。
PSI 阈值与行动建议 PSI < 0.1 无显著偏移 ✅ 正常监控 按周期例行观察 0.1 ≤ PSI < 0.25 轻度偏移 ⚠️ 加强监控 排查分箱偏移原因 PSI ≥ 0.25 严重偏移 🚨 触发告警 启动模型复核流程 阈值标准参考美联储SR 11-7及巴塞尔协议信用风险模型监控指引

3.6 生产环境代码封装建议

在实际生产系统中,PSI计算通常需要封装为可重复调用的监控模块。以下给出一个更健壮的生产级封装示例:

class PSIMonitor:
    """生产级PSI监控器"""
    
    def __init__(self, bin_edges=None, bins=10, smoothing=1e-10):
        self.bin_edges = bin_edges  # 固定分箱边界
        self.bins = bins
        self.smoothing = smoothing
        self.history = []  # 存储历史PSI记录
    
    def fit(self, expected):
        """基于预期样本拟合分箱边界"""
        self.bin_edges = np.percentile(expected, np.linspace(0, 100, self.bins + 1))
        self.bin_edges = np.unique(self.bin_edges)
        self.expected_pct = self._get_distribution(expected)
        return self
    
    def _get_distribution(self, data):
        """计算数据在固定边界上的分布"""
        counts, _ = np.histogram(data, bins=self.bin_edges)
        return counts / (len(data) + self.smoothing)
    
    def calculate(self, actual, timestamp=None):
        """计算当前周期PSI"""
        actual_pct = self._get_distribution(actual)
        woe = np.log((actual_pct + self.smoothing) / (self.expected_pct + self.smoothing))
        psi = np.sum((actual_pct - self.expected_pct) * woe)
        
        record = {'timestamp': timestamp, 'psi': psi}
        self.history.append(record)
        return psi

# 使用示例
monitor = PSIMonitor(bins=10)
monitor.fit(expected_scores)

# 模拟多个监控周期
for month in range(1, 13):
    new_actual = np.random.normal(600 + month * 2, 100, 10000)
    psi = monitor.calculate(new_actual, timestamp=f"2024-{month:02d}")
    print(f"{month:02d}月 PSI: {psi:.4f}")
生产要点: 固定分箱边界(fit时确定)是生产环境的最佳实践。每次监控使用相同的边界计算占比,确保PSI跨期可比。同时,建议将历史PSI记录持久化到数据库,用于趋势分析和告警阈值设定。