Проверка монотонности и стабильности переменных в кредитном скоринге Python | AiManual
AiManual Logo Ai / Manual.
30 Апр 2026 Гайд

Кредитный скоринг: как не попасть в ловушку немонотонных и нестабильных переменных — Python-гайд с кровью и кодом

Пошаговый гайд с кодом: как детектить немонотонность и дрифт популяции в Credit Scoring. Population Stability Index, Python, real cases.

Когда речь заходит о кредитном скоринге, 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, если в данных есть выбросы — получите бакеты с одним-двумя значениями, и монотонность будет ложной.

💡
Для категориальных переменных (с кодировкой WoE) монотонность проверяется по отношению WoE к таргету. Если WoE немонотонен — кодировку надо пересмотреть. В гайде по ошибкам ML на реальных данных мы разбирали кейс, когда WoE давал отличный IV, но спустя месяц перестал работать из-за сдвига распределения категорий.

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-фолдовую кросс-валидацию.

Подписаться на канал