Перейти к содержанию

Обучение и валидация скоринговой карты

Обучаем скоринговую карту (SPEC §8) на синтетическом мире (SPEC §14), проверяем её на отложенной выборке и выгружаем в scorecard_v1.json — формат контракта Scorecard (docs/v2/CONTRACTS.md). Это наши собственные открытые веса: скоринговый модуль (T15) читает файл, банк может подставить свою модель в том же формате.

Запуск

python -m apps.ml --seed 42 --n 5000 --as-of 2026-10-01 --version scorecard_v1 --created-at 2026-10-24

Все параметры необязательные; мир — тот же, что python -m apps.world строит по умолчанию, --created-at по умолчанию — сегодня. Обучение и валидация на 5000 компаниях занимают около 10 секунд, numpy и scikit-learn не нужны. Результат — три файла в apps/ml/:

  • scorecard_v1.json — сама карта: интервалы, WoE и баллы каждого признака, базовые баллы, границы грейдов;
  • scorecard_v1.training.json — паспорт обучения: версия набора признаков (feature_set_version), параметры мира, размер выборки, информационная ценность (IV), коэффициент и z-статистика каждого признака, почему признак не вошёл в карту, Gini на обучающей выборке. Версия набора признаков лежит здесь, потому что в контракте Scorecard для неё нет поля;
  • scorecard_v1.validation.json — валидация карты на отложенной выборке для страницы риск-менеджера (T32), см. «Валидация».

Одинаковый мир и одинаковая --created-at дают побайтно одинаковые файлы.

Когда переобучать

При любой правке config/features.json (поднимается его version), определений признаков в apps/api/features/, генератора мира или config/world_outcomes.json. Валидация пересчитывается той же командой. Тест test_committed_scorecard_is_what_training_gives переобучает карту и сравнивает все три файла с файлами в репозитории: если они устарели, он падает и подсказывает команду.

Как обучается

train.py:

  1. Выборка. Компании обучающей выборки мира (sample == 'train'), признаки — extract_features(SourcePayloads.from_company(company), world.meta.as_of): тот же вызов, что у движка на живой заявке. Из скрытой правды мира обучение берёт только исход (default_12m) и выборку (sample).
  2. Разбиение на интервалы и WoE (binning.py). WoE интервала = ln(доля всех «хороших» в интервале / доля всех дефолтов в интервале): положительный WoE — интервал надёжнее среднего.
  3. Числовой признак сначала режется на 20 квантильных классов. Потом соседние классы сливаются, пока в каждом интервале не окажется не меньше 5% компаний с данными и не меньше 10 дефолтов, пока доля дефолтов не станет монотонной по значению признака и пока интервалов не станет не больше пяти. Крайние интервалы открыты, поэтому любое значение попадает в какой-то интервал.
  4. Категориальный признак (группа риска ОКВЭД, правовая форма, флаги «да / нет»): каждая категория — свой интервал, маленькие сливаются с ближайшими по доле дефолтов. Категория, которой не было в обучении, всё равно получает интервал.
  5. У каждого признака есть интервал «нет данных» (SPEC §5.1). Если компаний без данных достаточно (те же 5% и 10 дефолтов), у него свой WoE; если нет — WoE пустой, а баллы нулевые, то есть средний риск: учиться не на чем.
  6. Отбор признаков. Признаки с IV меньше 0,02 не различают заёмщиков и отбрасываются. На остальных — логистическая регрессия ln(odds_good) = b0 + сумма(b_i * WoE_i) методом Ньютона с небольшой L2-регуляризацией (logreg.py). Затем по одному убирается самый слабый признак, пока у каждого коэффициента z-статистика не станет не меньше 2. Отрицательный коэффициент значит, что признак в модели работает против своего WoE: обычно его информацию уже несёт другой признак. Такой признак тоже убирается, иначе баллы стали бы необъяснимыми («лучше значение — меньше баллов»).
  7. Баллы (SPEC §8): 600 баллов = шансы 50:1, +20 баллов = шансы ×2.

factor = 20 / ln 2, offset = 600 − factor · ln 50 баллы интервала = factor · b_i · WoE интервала базовые баллы = offset + factor · b0 PD = 1 / (1 + odds_good), odds_good = 50 · 2^((баллы − 600) / 20)

Баллы интервала показывают, насколько он надёжнее (+) или рискованнее (−) среднего. Сумма базовых баллов и баллов по признакам переводится в PD через Scorecard.pd_from_points. Баллы округляются до десятых. 5. Грейды — границы SPEC §8: A < 1,5%, B < 3%, C < 6%, D < 12%, E — остальные.

score.py — как карта читает признаки (find_bin, total_points). Скоринговый модуль (T15) должен давать те же баллы; проще всего импортировать их из apps.ml: пакет зависит только от контрактов, а обучение (apps.ml.train, ему нужен apps/world/) при этом не загружается.

Что получилось (scorecard_v1, зерно 42, 5000 компаний)

Обучающая выборка — 3492 компании, 234 дефолта (6,7%). В карту вошли 8 признаков: волатильность поступлений, доля снятия наличных, средний остаток в днях расходов, месяцы с выплатой зарплаты, худшая просрочка компании, использование лимитов, возраст бизнеса, маржинальность. Остальные отброшены, причина у каждого — в scorecard_v1.training.json.

Gini карты Gini истинной PD (потолок)
Обучающая выборка 0,56 0,66
Отложенная выборка (1508 компаний, 111 дефолтов) 0,46 0,61

Компании распределяются по грейдам примерно поровну (на отложенной выборке A — 23%, B — 19%, C — 19%, D — 19%, E — 20%), все «технические» компании попадают в E. Полная валидация — в разделе «Валидация».

Что стоит знать о результате:

  • Группа риска ОКВЭД почти ничего не добавляет (IV 0,01), хотя в мире отрасль сдвигает вероятность дефолта. Поправки отраслей (от −0,2 до +0,5 к логарифму шансов) малы по сравнению с влиянием скрытого качества q, а группы объединяют отрасли с разными поправками.
  • Кредитная история слабее поведения по счёту. В карту попала только худшая просрочка компании (62+ дня); просрочка собственника, запросы КИ и долг к выручке отброшены: их информацию уже несут признаки по счёту. Это свойство синтетического мира (apps/world/README.md, «Как q проявляется в кредитной истории»), а не метода.
  • Смена собственника или директора и отрицательный капитал не связаны с исходом (IV ≈ 0): в мире они от q не зависят.

Валидация

validation.py — метрики и формат отчёта ValidationReport; как и score.py, он зависит только от контрактов, поэтому API может читать отчёт без мира. validate.py прогоняет карту по миру: признаки обучающей и отложенной выборки — тот же вызов extract_features, что у обучения и движка. Из скрытой правды мира валидация берёт исход (default_12m), выборку (sample) и, только для потолка Gini, истинную PD (pd_12m) — одним агрегированным числом, не по компаниям.

Отчёт проверяет три свойства, которые кейс (стр. 14) называет для рейтинговых моделей:

  • Способность различать (gini, cap). Gini на отложенной выборке и его 95%-й интервал по стандартной ошибке AUC (Hanley & McNeil). Рядом для сравнения — Gini на обучающей выборке и Gini истинной PD мира, то есть потолок, выше которого не поднимется никакая модель на этих данных. Кривая CAP (21 точка): отказав X% самых рискованных компаний, банк отсекает Y% всех дефолтов.
  • Точность (calibration). По каждому грейду — число компаний, дефолты, средняя прогнозная PD, фактическая доля дефолтов и её 95%-й интервал Уилсона (в отличие от «p ± 1,96·σ» он не выходит за [0, 1] при малом числе дефолтов). predicted_vs_actual: within — прогноз внутри интервала, below — карта недооценивает риск, above — переоценивает.
  • Стабильность (psi_grades, psi_points, psi_features). PSI = Σ (доля сейчас − доля при обучении) · ln(доля сейчас / доля при обучении). Считается по грейдам, по 10 диапазонам баллов, равным по размеру на обучающей выборке, и по интервалам каждого признака карты (индекс стабильности признака, CSI). Пороги общепринятые: меньше 0,1 — stable, 0,1–0,25 — watch, от 0,25 — shift (PSI_WATCH, PSI_SHIFT в validation.py). Пустой интервал считается с долей 0,01%, чтобы PSI не уходил в бесконечность.

В limitations отчёт перечисляет ограничения, которые страница должна показать рядом с метриками (раздел «Упрощения» ниже).

Что получилось (scorecard_v1)

Отложенная выборка — 1508 компаний, 111 дефолтов (7,4%).

Значение
Gini, отложенная выборка 0,46 (95%: 0,35–0,57)
Gini, обучающая выборка 0,56
Gini истинной PD (потолок) 0,61
CAP: отказ 20% самых рискованных отсекает 49% дефолтов
Грейд Доля компаний Прогнозная PD Фактическая доля дефолтов 95%-й интервал Итог
A 23% 0,9% 1,7% (6 из 343) 0,8–3,8% внутри
B 19% 2,2% 3,8% (11 из 289) 2,1–6,7% внутри
C 19% 4,2% 6,6% (19 из 288) 4,3–10,1% прогноз ниже
D 19% 8,6% 7,4% (21 из 285) 4,9–11,0% внутри
E 20% 20,4% 17,8% (54 из 303) 13,9–22,5% внутри

Средняя прогнозная PD — 7,2% при фактических 7,4%. Карта откалибрована на долю дефолтов обучающей выборки (6,7%), а в отложенной дефолтов случайно больше, поэтому в грейдах A–C прогноз ниже факта; в C он на границе интервала. Перекалибровывать карту на отложенную выборку мы не стали: тогда она перестала бы быть отложенной.

PSI по грейдам — 0,004, по баллам — 0,007, по признакам — не больше 0,01: при случайном разбиении одного среза так и должно быть.

Упрощения

В коде помечены # SIMPLIFICATION:.

Упрощение Как было бы в реальности
Обучение на всех компаниях обучающей выборки, как будто банк выдал кредит всем заявителям, включая «технические» компании Исходы известны только по выданным кредитам. По отказанным заявкам их восстанавливают (reject inference: экстраполяция, взвешивание, данные БКИ о кредитах в других банках). Отбирать компании по архетипу мы не можем: это скрытая правда мира
Выборка обучения — один срез мира, отложенная — случайная часть того же среза; Gini на ней поэтому ближе к обучающему, чем было бы в реальности Обучение на нескольких поколениях выдач, проверка на более позднем периоде (out-of-time)
PSI сравнивает отложенную выборку с обучающей, а не поток заявок с обучающей выборкой Ежемесячный мониторинг: PSI грейдов и признаков по новым заявкам, при shift — пересмотр модели
Пороги PSI (0,1 и 0,25) и 95%-е интервалы — общепринятые, в validation.py, а не в config/ Пороги мониторинга и проверки калибровки (светофор по биномиальному тесту) утверждает валидационное подразделение
Модель обучена на синтетических данных: её качество доказывает работу конвейера обучения, а не реальный риск (SPEC §15) Обучение на истории портфеля банка, независимая валидация
Параметры разбиения и отбора (5% и 10 дефолтов на интервал, до 5 интервалов, IV ≥ 0,02, z ≥ 2) взяты из учебной практики; на небольшом переборе (3–8% на интервал, 4–5 интервалов, 5–10 дефолтов) Gini на отложенной выборке менялся в пределах шума Подбор на кросс-валидации, экспертный пересмотр интервалов андеррайтерами
Карта откалибрована на долю дефолтов обучающей выборки (6,7%) Калибровка на долгосрочную среднюю долю дефолтов по циклу (through-the-cycle)

Тесты

tests/test_ml.py:

  • интервалы монотонные, не меньше 5% компаний и 10 дефолтов, покрывают все значения; маленький интервал «нет данных» — средний риск; категория, которой не было в обучении, получает интервал;
  • логистическая регрессия восстанавливает известные коэффициенты; Gini на краевых случаях;
  • карта проходит контракт; средняя PD по баллам равна доле дефолтов обучающей выборки; баллы интервала имеют знак его WoE;
  • на отложенной выборке Gini больше 0,35 и меньше потолка истинной PD;
  • PSI, интервалы Уилсона и Gini, кривая CAP, калибровка по грейдам и диапазоны баллов — на известных примерах;
  • отчёт валидации проходит свою модель, сходится с выборками, PSI при случайном разбиении — stable; модуль метрик не загружает мир;
  • обучение воспроизводимо, а scorecard_v1.json, паспорт и отчёт валидации в репозитории совпадают с переобучением.