← 返回文章列表

KS漂移检测与PSI稳定性实战

KS 漂移检测与 PSI 稳定性:你的风控模型正在悄悄变老

模型上线第一周 KS 0.42,三个月后 KS 0.31。业务方问"是不是模型坏了",你说"等等看"——再等三个月 KS 跌到 0.19,已经跑了半年的坏账才浮出水面。


一、模型为什么会"变老"

风控模型不是静态的。你的训练数据来自 2024 年的用户,但 2025 年的用户行为、欺诈手法、市场环境全都变了。这种变化反映在数据上,就是特征分布和模型分数的偏移

两个核心概念:

  • KS(Kolmogorov-Smirnov):衡量模型区分好坏样本的能力。0 分完全没区分力,1 分完美区分。信用评分场景 0.35+ 算可用。
  • PSI(Population Stability Index):衡量两个分布的差异程度。PSI < 0.1 稳定,0.1~0.25 轻微偏移需关注,> 0.25 显著偏移必须排查。

说白了就是:KS 告诉你"刀还利不利",PSI 告诉你"战场变了没有"。刀不利可以磨(重训模型),战场变了得换地图(重新定义好坏标签、调整特征口径)。


二、KS 怎么算——从原理到代码

KS 值本质上是好坏样本累计分布曲线之间的最大垂直距离。

步骤:
1. 把模型输出的预测概率从低到高排序
2. 在每个分位点计算:好样本累计占比 - 坏样本累计占比
3. 取这个差值的绝对值的最大值 → 就是 KS
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp

def calculate_ks(y_true, y_pred_proba):
    """
    y_true: 0=好样本, 1=坏样本
    y_pred_proba: 模型预测为坏样本的概率
    """
    # 按预测概率降序排列
    df = pd.DataFrame({'true': y_true, 'proba': y_pred_proba})
    df = df.sort_values('proba', ascending=False).reset_index(drop=True)
    
    n_bad = df['true'].sum()        # 坏样本总数
    n_good = len(df) - n_bad         # 好样本总数
    
    if n_bad == 0 or n_good == 0:
        return 0.0  # 只有一类样本,KS 无意义
    
    bad_cumsum = np.cumsum(df['true']) / n_bad
    good_cumsum = np.cumsum(1 - df['true']) / n_good
    
    ks = np.max(np.abs(bad_cumsum - good_cumsum))
    return ks

# 示例
y_true = np.array([0, 0, 1, 0, 1, 0, 1, 1, 0, 1])
y_proba = np.array([0.1, 0.2, 0.8, 0.15, 0.9, 0.3, 0.7, 0.85, 0.05, 0.6])
print(f"KS = {calculate_ks(y_true, y_proba):.4f}")  # KS = 0.6667

KS 的行业经验值(信贷场景):

  • < 0.20:几乎没有区分力,别上线
  • 0.20 ~ 0.30:勉强可用,需要更多特征
  • 0.30 ~ 0.40:正常水平
  • 0.40 ~ 0.50:优秀
  • > 0.50:检查是否过拟合或标签泄露

三、PSI 怎么算——模型稳定性的定量指标

PSI 的公式本质上是在比较两个分布在每个分箱里的差异,再按权重求和:

[ PSI = \sum_{i=1}^{n} (Actual_i% - Expected_i%) \times \ln\left(\frac{Actual_i%}{Expected_i%}\right) ]

其中 Expected 是基准分布(训练集或上线首月),Actual 是当前分布。

def calculate_psi(expected_scores, actual_scores, bins=10):
    """
    expected_scores: 基准期的模型分数(训练集/上线首月)
    actual_scores:   当前期的模型分数
    bins:            分箱数,默认10等分
    """
    # 等频分箱——用 expected 的分位点做边界
    breaks = np.percentile(expected_scores, np.linspace(0, 100, bins + 1))
    
    # 统计每个箱的占比
    expected_cnt = np.histogram(expected_scores, bins=breaks)[0]
    actual_cnt = np.histogram(actual_scores, bins=breaks)[0]
    
    expected_pct = expected_cnt / len(expected_scores)
    actual_pct = actual_cnt / len(actual_scores)
    
    # PSI 计算,对空箱做平滑处理
    epsilon = 1e-10
    psi_values = (actual_pct - expected_pct) * np.log(
        (actual_pct + epsilon) / (expected_pct + epsilon)
    )
    
    return np.sum(psi_values)

# 模拟:训练集 vs 三个月后的分数分布
np.random.seed(42)
train_scores = np.random.beta(5, 3, size=10000)   # 训练集分数
drift_scores = np.random.beta(4, 4, size=10000)   # 三个月后,分布右移了

psi = calculate_psi(train_scores, drift_scores)
print(f"PSI = {psi:.4f}")
# PSI ≈ 0.08~0.15 之间(模拟数据每次略有波动)

四、生产环境的自动化监控——不是手工跑一次就完事

线上模型监控的核心架构:定时任务(日/周)拉取线上打分数据 → 对比基准 → 产出报告 + 告警

/**
 * 模型稳定性监控定时任务——每天凌晨 2 点执行
 * 对比基准:上线首月(前30天)的分数分布
 */
@Component
public class ModelStabilityMonitor {

    @Resource
    private JedisPool jedisPool;           // 线上打分日志缓存
    @Resource
    private ModelStabilityMapper mapper;    // MySQL 持久化存储

    private static final double PSI_ALERT_THRESHOLD = 0.25;
    private static final double KS_ALERT_THRESHOLD = 0.20;

    @Scheduled(cron = "0 0 2 * * ?")
    public void dailyMonitor() {
        String modelVersion = "credit_score_v3";

        // 1. 拉取昨日线上分数
        List<Double> yesterdayScores = fetchScoresFromRedis(
            modelVersion, LocalDate.now().minusDays(1)
        );

        // 2. 拉取基准分数(上线首月,存在 MySQL)
        List<Double> baselineScores = mapper.selectBaselineScores(modelVersion);

        if (yesterdayScores.isEmpty() || baselineScores.isEmpty()) {
            log.warn("分数数据不足,跳过当日监控");
            return;
        }

        // 3. 计算 PSI
        double psi = computePsi(baselineScores, yesterdayScores, 10);

        // 4. 计算 KS(需要标签,T+30 才能拿到真实逾期标签)
        //    这里用"即时 KS"——当日分数对当日已知标签的区分力
        List<Integer> labels = fetchLabelsFromRedis(modelVersion, LocalDate.now().minusDays(1));
        double ks = computeKs(yesterdayScores, labels);

        // 5. 持久化 + 告警
        mapper.insertMonitorRecord(modelVersion, LocalDate.now(), psi, ks);

        if (psi > PSI_ALERT_THRESHOLD) {
            alert(modelVersion, "PSI", psi, "分布显著偏移,建议排查特征数据源");
        }
        if (ks < KS_ALERT_THRESHOLD) {
            alert(modelVersion, "KS", ks, "区分力跌破阈值,建议启动重训");
        }

        log.info("模型监控完成: version={}, psi={:.4f}, ks={:.4f}", modelVersion, psi, ks);
    }

    private double computePsi(List<Double> expected, List<Double> actual, int bins) {
        // 等频分箱 + PSI 公式,逻辑同 Python 版
        double[] expArr = expected.stream().mapToDouble(Double::doubleValue).sorted().toArray();
        int n = expArr.length;
        double[] breaks = new double[bins + 1];
        for (int i = 0; i <= bins; i++) {
            breaks[i] = expArr[Math.min(i * n / bins, n - 1)];
        }

        int[] expCnt = new int[bins];
        int[] actCnt = new int[bins];
        for (double v : expected) {
            expCnt[findBin(v, breaks)]++;
        }
        for (double v : actual) {
            actCnt[findBin(v, breaks)]++;
        }

        double psi = 0.0;
        double epsilon = 1e-10;
        int expTotal = expected.size(), actTotal = actual.size();
        for (int i = 0; i < bins; i++) {
            double expPct = (double) expCnt[i] / expTotal;
            double actPct = (double) actCnt[i] / actTotal;
            if (expPct > 0 && actPct > 0) {
                psi += (actPct - expPct) * Math.log(actPct / expPct);
            }
        }
        return psi;
    }

    private int findBin(double value, double[] breaks) {
        for (int i = breaks.length - 1; i >= 0; i--) {
            if (value >= breaks[i]) return Math.min(i, breaks.length - 2);
        }
        return 0;
    }
}

关键设计点:

  • PSI 不需要标签,T+1 就能算——因为只看分数分布变化,不看好坏
  • KS 需要真实逾期标签,通常要等 T+30(账单日 + 宽限期)——可以同时算一个"即时 KS"做参考
  • 告警阈值不是死的——PSI 0.25 和 KS 0.20 是行业经验值,要根据自己的业务容忍度调整

五、PSI 偏高了怎么办——排查三件套

PSI > 0.25 不代表模型一定坏了,但它说明"有什么东西变了"。排查顺序:

1. 先看特征 PSI(不是只看分数 PSI)

模型分数 PSI 是汇总指标,要定位到具体特征才能找到根因。

def feature_psi_drilldown(baseline_df, current_df, feature_cols, bins=10):
    """逐特征计算 PSI,定位哪些特征发生了偏移"""
    psi_report = {}
    for col in feature_cols:
        psi = calculate_psi(
            baseline_df[col].dropna().values,
            current_df[col].dropna().values,
            bins=bins
        )
        psi_report[col] = psi
    
    # 按 PSI 降序排列,前几个就是元凶
    return pd.Series(psi_report).sort_values(ascending=False)

# 示例输出
# user_age_months    0.32  ← 新用户占比突然变了
# txn_amt_avg_30d    0.28  ← 交易金额分布变了
# device_nunique_7d  0.05
# credit_score_ext   0.03

2. 再看数据源头

user_age_months PSI 0.32?可能是:

  • 上游数据仓库迁移,用户注册时间字段部分为 NULL
  • 产品改了注册流程,新用户占比突然升高
  • 数据管道丢了一个分区

3. 最后看业务变化

如果特征 PSI 普遍偏高、但没有明显的数据管道问题,大概率是业务环境真的变了——新的获客渠道带来了不同的客群、经济周期变化改变了用户还款行为。这时候不是"修模型"的问题,是"模型需要重训以适应新环境"的问题。


六、KS 和 PSI 的关系——容易混淆的三个误区

误区 真相
PSI 高了说明模型坏了 不一定。PSI 高只说明分布变了,如果模型在新分布上 KS 仍然好,说明模型泛化能力强
KS 跌了就马上重训 KS 短期波动正常(节假日、营销活动都会影响)。连续 4 周下降 + PSI > 0.2 才触发重训
PSI 低就万事大吉 PSI 只检查分数分布,不检查标签分布。PSI 低但 KS 也在跌 → 可能是好坏样本比例变了(先息后本→等额本息导致逾期定义偏移)

正确姿势是 KS + PSI 双指标联动:

PSI 低 + KS 稳定 → 放心睡觉
PSI 高 + KS 稳定 → 模型泛化不错,但关注特征偏移趋势
PSI 低 + KS 在跌 → 查标签定义是否变了(逾期口径、宽限期调整)
PSI 高 + KS 在跌 → 最高优先级,启动重训流程

七、总结

模型监控不是"上线就完事"的附属品,是模型生命周期里跟训练同等重要的一环。记住五条:

  1. KS 看区分力,PSI 看分布稳定性——两个指标回答两个不同的问题,不能互相替代
  2. PSI 不需要标签,T+1 就能算——这是它比 KS 更适合做"第一道防线"的原因
  3. 分数 PSI 高了先钻到特征 PSI——定位到具体哪个特征偏移了,才算排查完成
  4. 不要一看到 KS 跌就重训——先看是短期波动还是趋势性下降,连续 4 周 + PSI > 0.2 才是明确的重训信号
  5. 生产环境必须自动化——手工跑一次 PSI 不叫监控,定时任务 + 持久化记录 + 告警推送才是

模型上线第一天就应该把监控跑起来。等业务方来告诉你"模型不准了"的时候,损失已经发生了。