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 在跌 → 最高优先级,启动重训流程
七、总结
模型监控不是"上线就完事"的附属品,是模型生命周期里跟训练同等重要的一环。记住五条:
- KS 看区分力,PSI 看分布稳定性——两个指标回答两个不同的问题,不能互相替代
- PSI 不需要标签,T+1 就能算——这是它比 KS 更适合做"第一道防线"的原因
- 分数 PSI 高了先钻到特征 PSI——定位到具体哪个特征偏移了,才算排查完成
- 不要一看到 KS 跌就重训——先看是短期波动还是趋势性下降,连续 4 周 + PSI > 0.2 才是明确的重训信号
- 生产环境必须自动化——手工跑一次 PSI 不叫监控,定时任务 + 持久化记录 + 告警推送才是
模型上线第一天就应该把监控跑起来。等业务方来告诉你"模型不准了"的时候,损失已经发生了。