Когда речь заходит о кредитном скоринге, Data Scientist'ы часто бросаются в объятия градиентного бустинга или нейросетей, забывая, что регуляторы (да и бизнес) требуют понятных и стабильных решений. Проблема вот в чем: модель может показывать крутой ROC AUC на исторических данных, но если ее переменные немонотонны или популяция заемщиков дрейфует — через квартал вы получите модель, которая одобряет тех, кому завтра надо бить тревогу. Я сам обжегся на этом, когда моя модель с красивым WoE перестала работать после релиза нового кредитного продукта. С тех пор я усвоил: перед обучением нужно проверить каждую переменную на монотонность и стабильность. Ниже — жесткий гайд, как это делать по-взрослому на Python.
Почему монотонность — это не прихоть, а закон (и где тут подвох)
В кредитном скоринге каждая переменная должна иметь логичную, предсказуемую зависимость с целевой переменной (дефолт/погашение). Чем больше просрочек в прошлом — тем выше риск. Чем больше доход — тем ниже риск. Это монотонность. Если у вас переменная сначала уменьшает риск, потом увеличивает, а потом снова уменьшает — вы не сможете объяснить бизнесу, почему вдруг клиенты с доходом 100-120 тыс. руб. надежнее, чем с доходом 80-100 тыс. руб. Регулятор (например, ЦБ РФ) может завернуть модель.
Типичная ошибка: смотреть только на корреляцию с таргетом. Корреляция может быть высокой, но нелинейной. Пример: возраст заемщика — сначала риск падает (молодые без опыта), потом растет (35-50 лет — пик кредитной нагрузки), потом снова падает (пенсионеры с накоплениями). Корреляция будет почти нулевой, но если вы разобьете на бакеты и посмотрите на средний уровень дефолтов внутри каждого — увидите U-образную форму. Это не монотонно, и такая переменная в линейной модели (логистической регрессии) даст плохой WoE и нестабильность.
В статье про робастный отбор переменных мы уже обсуждали, как важно отбирать признаки не на глаз, а с кратно-валидированными метриками. Монотонность — один из критериев фильтрации. Давайте перейдем к коду.
Стабильность: Population Stability Index (PSI) и его злые братья
Монотонность — про логику. Стабильность — про время. Популяция заемщиков меняется: экономический кризис, новый маркетинговый канал, изменение кредитной политики. Если распределение переменной в последнем периоде отличается от того, на чем учили модель, — предсказания будут валиться. PSI (Population Stability Index) — классический способ измерить дрифт. Формула простая: сумма по бакетам (доля_новой - доля_базовой) * ln(доля_новой / доля_базовой). Пороги: PSI < 0.1 — хорошо, 0.1-0.25 — надо смотреть, >0.25 — плохо.
На практике PSI часто считают не для сырых значений, а для предсказанных вероятностей модели. Но я рекомендую считать и для каждой переменной отдельно — это помогает локализовать проблему. В материале по MLOps диагностике мы разбирали, как R² может маскировать дрифт — здесь то же самое: PSI на уровне скора может быть в норме, а переменная уже уехала.
Пошаговый план: как это готовить
1 Подготовка данных и бакетирование
Для оценки монотонности нам нужно разбить непрерывную переменную на бакеты (обычно 10-20 квантилей) и посчитать внутри каждого бакета долю дефолтов (event rate) или среднее значение таргета. Если тренд event rate по бакетам строго возрастает или убывает — переменная монотонна. Если есть перегибы — скорее всего немонотонна.
import pandas as pd
import numpy as np
from scipy.stats import chi2_contingency
def monotonicity_check(df, var, target, bins=10):
df = df[[var, target]].dropna()
df['bucket'] = pd.qcut(df[var], q=bins, duplicates='drop')
grouped = df.groupby('bucket')[target].agg(['count', 'mean']).reset_index()
grouped.columns = ['bucket', 'count', 'event_rate']
# Проверка монотонности: смотрим знак разности соседних значений
diffs = np.diff(grouped['event_rate'])
if np.all(diffs > 0) or np.all(diffs < 0):
is_monotonic = True
else:
# Дополнительно можно проверить статистический тест (Mantel-Haenszel)
is_monotonic = False
return grouped, is_monotonic
Функция возвращает таблицу с бакетами и флаг монотонности. Но тут есть нюанс: если в каких-то бакетах мало наблюдений (менее 30), event rate может быть шумным. Лучше объединять мелкие бакеты или использовать метод pd.cut с фиксированными границами. Не советую полагаться на автоматическое qcut, если в данных есть выбросы — получите бакеты с одним-двумя значениями, и монотонность будет ложной.
2 Визуальная проверка: черт побери, я хочу это увидеть!
Одно дело — цифры, другое — глаза. Постройте график event rate по бакетам. Если видите «горбы» или «впадины» — это немонотонность. Добавьте на тот же график histogram распределения переменной, чтобы понимать, где густо а где пусто.
import matplotlib.pyplot as plt
import seaborn as sns
def plot_monotonicity(grouped, var_name):
fig, ax1 = plt.subplots(figsize=(10,5))
ax1.bar(range(len(grouped)), grouped['count'], alpha=0.3, label='Count')
ax1.set_ylabel('Count', color='blue')
ax2 = ax1.twinx()
ax2.plot(range(len(grouped)), grouped['event_rate'], 'ro-', linewidth=2, label='Event Rate')
ax2.set_ylabel('Event Rate', color='red')
plt.title(f'Monotonicity check for {var_name}')
plt.xticks(range(len(grouped)), [f'{b:.2f}' if isinstance(b, float) else str(b) for b in grouped['bucket']], rotation=45)
plt.show()
Это simplest approach. В реальном проекте я делаю дашборд в Plotly, где можно выбирать переменную и период. Если у вас исторические данные по месяцам — стройте такие графики для каждого месяца последовательно, и вы увидите, как монотонность меняется во времени. Это уже диагностика стабильности.
3 Population Stability Index: код, который не врет
PSI рассчитывается по одинаковым бакетам (обычно по 10 перцентилям базового периода). Сравниваем распределения двух выборок.
def calculate_psi(expected, actual, buckets=10):
# expected - массив значений базового периода
# actual - массив значений текущего периода
# Определяем границы бакетов по expected
percentiles = np.linspace(0, 100, buckets + 1)
bins = np.percentile(expected, percentiles)
bins[-1] = np.inf # чтобы хвосты не терялись
# Разбиваем оба массива
expected_binned = np.digitize(expected, bins)
actual_binned = np.digitize(actual, bins)
# Считаем доли
expected_counts = np.bincount(expected_binned, minlength=buckets+1)[1:buckets+1]
actual_counts = np.bincount(actual_binned, minlength=buckets+1)[1:buckets+1]
expected_pct = expected_counts / len(expected)
actual_pct = actual_counts / len(actual)
# Защита от нулевых долей (добавляем epsilon)
eps = 1e-6
expected_pct = np.clip(expected_pct, eps, 1)
actual_pct = np.clip(actual_pct, eps, 1)
psi = np.sum((actual_pct - expected_pct) * np.log(actual_pct / expected_pct))
return psi
Используйте эту функцию для каждой переменной. Я обычно запускаю её в цикле по всем признакам и вывожу в DataFrame с колонками: variable, psi, flag (green/yellow/red). PSI > 0.25 — красный флаг, надо разбираться. PSI > 0.1 — желтый, стоит посмотреть на причины. Если PSI стабильно низкий, но модель всё равно дрейфует — проблема может быть в кросс-взаимодействиях. В таком случае рекомендую метод с 4-фолдовой стратифицированной кросс-валидацией, который отсекает нестабильные признаки даже без явного PSI.
Нюансы и грабли, на которые я наступил лично
- Не используйте PSI для бинарных переменных с редким событием. Если в базовом периоде было 5% единиц, а в новом 0% — логарифм улетает в бесконечность. Лучше используйте Adjusted PSI или просто процентное изменение. Еще вариант — тест хи-квадрат.
- Бакеты должны быть осмысленными. Если вы применяете квантили по базе, а в актуальной выборке оказались значения за пределами — они попадут в последний бакет, и PSI может показать дрифт, которого нет. Решение: расширить границы на ±10% от наблюдаемого диапазона.
- PSI чувствителен к количеству бакетов. Для переменных с длинными хвостами лучше брать 5-7 бакетов, иначе мелкие изменения размазываются. Используйте метод
np.histogram_bin_edgesс адаптивными бинами. - Монотонность ≠ линейность. Логистическая регрессия предполагает линейность в логарифме шансов, но это допущение часто нарушается. Если монотонность есть, но нелинейная — используйте сплайны или WoE бакетирование. Для деревьев (бустинг, случайный лес) монотонность не обязательна, но регуляторы всё равно могут потребовать интерпретируемости, и тут монотонность становится юридическим требованием.
- Не доверяйте автоматическому детекту монотонности. Я однажды искал баг, а оказалось, что в данных была опечатка — у одного бакета event rate был 0.5 вместо реального 0.05 из-за смешения ID. Всегда проверяйте распределение количества наблюдений.
| Проблема | Симптом | Что делать |
|---|---|---|
| Немонотонность из-за шума | График event rate скачет туда-сюда | Увеличить количество наблюдений, сгладить или объединить смежные бакеты |
| Дрифт популяции (PSI > 0.25) | Переменная изменила распределение | Дообучить модель на новой выборке или добавить калибровку |
| PSI в норме, а скор дрейфует | Средний скор растет/падает | Проверить скрытые дрифты взаимодействий, использовать стабильный отбор признаков |
Под капотом: можно ли это автоматизировать?
Да. Упакуйте проверки в класс, который принимает DataFrame с признаками, период (месяц) и таргет, и возвращает отчёт с флагами монотонности и PSI. Я в своём проекте сделал такой CreditVariableValidator — он генерирует Excel-отчёт с графиками и таблицами. Если будете делать так же — не забудьте про мультиколлинеарность и взаимодействия, потому что даже монотонные и стабильные переменные могут вместе давать нестабильный результат. Тут вам поможет робастный отбор.
А ещё — не забывайте про ансамбли. Если одна переменная немонотонна, её можно трансформировать (например, взять логарифм) или разбить на два признака (условные эффекты). В логистической регрессии для этого добавляют сплайны или квадратичные члены. Но тогда интерпретация усложняется. Выбор за вами.
В итоге вы должны получить pipeline: загрузили данные → проверили каждую переменную на монотонность (с визуализацией) → посчитали PSI для каждой переменной относительно последнего стабильного периода → отсекли проблемные → обучили модель. Этот подход спас не одну мою модель от провала в продакшне. Советую внедрить в свой стандартный процесс валидации. Если хотите глубже — читайте следующую статью про 4-фолдовую кросс-валидацию.