Дрейф данных: KS-тест и PSI
«Кажется, данные изменились» нужно превратить в число — для этого есть KS-тест и PSI.
Дрейф данных (data drift) — изменение распределения входных признаков относительно обучающего; обнаруживается статистически сравнением текущего распределения с эталонным.
Зачем мерить дрейф числом
Глаз на гистограмме субъективен и не масштабируется на сотни признаков. Нужны метрики, дающие число и порог: сработал — алерт. Две самые ходовые — KS-тест и PSI.
KS-тест (Колмогорова — Смирнова)
Сравнивает два распределения по максимальному расстоянию между их функциями распределения (CDF). Статистика KS — это наибольший вертикальный зазор между эталонной и текущей CDF. Чем больше зазор, тем сильнее распределения разошлись. Удобен для непрерывных признаков, не требует предположений о форме распределения.
# Упрощённый KS: максимум |CDF_ref(x) - CDF_cur(x)| по объединённым точкам
def ks_statistic(ref, cur):
points = sorted(set(ref) | set(cur))
nr, nc = len(ref), len(cur)
d = 0.0
for x in points:
cdf_r = sum(1 for v in ref if v <= x) / nr
cdf_c = sum(1 for v in cur if v <= x) / nc
d = max(d, abs(cdf_r - cdf_c))
return d
reference = [10, 11, 12, 12, 13, 13, 14, 15, 11, 12] # обучение
current_ok = [11, 11, 12, 12, 13, 13, 14, 15, 11, 12] # похоже
current_drift = [20, 22, 21, 23, 24, 22, 25, 21, 23, 24] # сдвиг вверх
print(f"KS (без дрейфа): {ks_statistic(reference, current_ok):.2f}")
print(f"KS (с дрейфом): {ks_statistic(reference, current_drift):.2f}")
Вывод:
KS (без дрейфа): 0.10 KS (с дрейфом): 1.00
Без дрейфа зазор мал (0.10), при явном сдвиге вверх — максимален (1.00: распределения не пересекаются). В реальности KS-статистику сопровождают p-value, но величина зазора уже наглядна.
PSI (Population Stability Index)
Любимая метрика в кредитном скоринге. Разбивает диапазон на корзины (bins) и суммирует, насколько доли наблюдений в корзинах сместились между эталоном и текущими данными. Формула по каждой корзине: (p_cur - p_ref) * ln(p_cur / p_ref), и всё суммируется.
import math
def psi(ref_counts, cur_counts):
nr, nc = sum(ref_counts), sum(cur_counts)
total = 0.0
for r, c in zip(ref_counts, cur_counts):
p_ref = max(r / nr, 1e-6) # защита от деления на ноль
p_cur = max(c / nc, 1e-6)
total += (p_cur - p_ref) * math.log(p_cur / p_ref)
return total
# доли по 4 корзинам
ref = [25, 25, 25, 25] # эталон: равномерно
cur_stable = [24, 26, 25, 25] # почти то же
cur_shifted = [10, 15, 30, 45] # масса уехала вправо
print(f"PSI (стабильно): {psi(ref, cur_stable):.3f}")
print(f"PSI (сдвиг): {psi(ref, cur_shifted):.3f}")
Вывод:
PSI (стабильно): 0.001 PSI (сдвиг): 0.315
Как читать PSI
| PSI | Интерпретация |
| < 0.1 | дрейфа практически нет |
| 0.1 – 0.25 | умеренный дрейф, присмотреться |
| > 0.25 | сильный дрейф, нужны меры |
В примере 0.315 > 0.25 — сигнал к действию: проверить причину и, возможно, переобучить.
Как работает под капотом
Оба метода сравнивают текущее окно данных с зафиксированным эталонным (обычно обучающая выборка или стабильный прошлый период). KS работает на сырых значениях через CDF, PSI — на бинированных долях. Детектор дрейфа в проде хранит эталонную статистику признаков, периодически собирает свежее окно из логов, считает метрику по каждому признаку и алертит при превышении порога. Важно: дрейф входов не всегда означает падение качества — это сигнал «присмотреться», а не автоматический приговор.
Частые ошибки
- Считать любой дрейф катастрофой. Дрейф входов может не вредить качеству; это повод проверить, а не паниковать.
- Брать слишком маленькое окно. На шумной выборке метрика дрейфа сама прыгает.
- Один эталон навсегда. После переобучения эталон надо обновлять под новую норму.
- Деление на ноль в PSI. Пустые корзины нужно сглаживать (как
1e-6выше).
Итог
- Дрейф данных измеряют числом: KS-тест (макс. зазор между CDF) и PSI (сдвиг долей по корзинам).
- PSI < 0.1 — норма, 0.1–0.25 — присмотреться, > 0.25 — действовать.
- Детектор сравнивает текущее окно с эталоном по каждому признаку; дрейф — сигнал проверить, а не приговор.