Стресс-тест, который отвечает на вопрос бизнеса «а что, если»


Ровно 18 лет назад, 15 сентября 2008 года, обанкротился Lehman Brothers — крупнейшее банкротство в истории США. Банк не использовал стресс-тесты для коммерческой недвижимости, поэтому ответа на вопрос «а что, если офисы и торговые центры обесценятся» у него не было.
Сегодня стресс-тест доступен любой компании, а не только банку с отделом риска. «А что, если доставка вырастет на 3 дня», «конкурент снизит цену на 15%», «ставка по ипотеке поднимется на 3 пункта» — на каждый вопрос можно ответить числом, а не словами.
Далее — шаги, как посчитать на своих данных. Код движка и инструкция в опенсорсе.
Привет, Хабр!
Стресс-тест отвечает на вопросы «а что, если» цифрами. В первой статье мой риск-движок MAMIR прогнал ипотечный кризис 2008 года на 26.5 млн реальных записей — на его стресс-тестах и покажу, как это работает.
Для примера возьму сценарий «ставка вырастет на 3 пункта» — скажем, с 7% до 10%. Прибавить эти 3 пункта можно двумя разными механиками, и ответы получатся разные.
Данные из примера
Freddie Mac Single-Family Loan-Level — открытая помесячная история ипотечных займов: просрочки, дефолты, продажи залога.
Данные официальные и доступны любому. На странице датасета регистрируешься и качаешь sample-файлы, по паре на каждый год выдачи: выдача и помесячное обслуживание. Структура полей описана в официальном User Guide.
Сдвиг — прибавить три к среднему за год
Модель не читает всю историю займа. Она видит фичи — числа, посчитанные из истории. Среднюю ставку за год, сколько раз человек просрочил платеж. Сдвиг правит эти фичи напрямую.
Надо проверить рост ставки на три пункта — беру среднюю за год, прибавляю три, смотрю ответ. Сами данные не трогаю. Так делают, потому что дешево. Пара строк кода, ответ сразу.
// «ставка выросла на 3 пункта»
[{ feature: 'loan_rate_mean_365d', op: 'add', value: 3 }]
Правишь среднее — переписываешь прошлое
Модель видит не ставку по займу, а среднюю за последний год. Сейчас 7%. Чтобы спросить про рост до 10%, прибавляю 3 пункта. Но средняя за год будет 10%, только если ставка была такой все двенадцать месяцев.
Хотел спросить, что будет после повышения, а вместо этого сказал модели, что повышение случилось год назад и заемщик уже год платит по новой ставке.
Одно поправил, остальные фичи сломались
Из остатка долга у меня выходят три числа — среднее за год, минимальное за год и среднее по штату. Поправил среднее, а минимальное и штатное остались от старых данных. Три числа больше не сходятся между собой, и модель получает заемщика, которого не бывает.
Тот же капкан в карточном антифроде виден вообще без модели. Сдвигаю счетчик транзакций за сутки в 20 раз вверх, средний чек впятеро вниз, сумму за сутки оставляю прежней. При таком количестве транзакций и таком чеке сумма выросла бы вчетверо, а в сценарии записано, что она прежняя.
Получается, тестировал несуществующий мир. Сдвиги выбросил и стал искать другую механику.
Шок — пересчитать все заново
Стресс-тест на шоках меняет сами данные, а все числа для модели пересчитываются заново. Ставка выросла на три пункта — значит, меняю ставку в каждой записи по займу. Дальше тот же код, что готовил данные для обучения, заново считает фичи, которые из этого поля выходят. Руками эти числа не трогаю, они выходят сами и сходятся между собой.
// «ставка выросла на 3 пункта»
[{ field: 'CurrentInterestRate', op: 'add', value: 3 }]
Разница со сдвигом — одно слово в конфиге, feature стало field. Правится не вход модели, а само поле в данных.
Цена
Сдвиг отвечал мгновенно, шоку надо пройти по 5.3 млн событий, ответ занимает около трех минут на M3 Max.
Ограничение — не все можно шокировать
Шокировать можно не любое поле. Ставку — да, ее задает банк или рынок, она приходит извне. А просрочку — нет. Платит человек или перестал — это и надо узнать. Скажу в сценарии «пусть у всех будет по три просрочки» — модель покажет большие потери. Только это не прогноз. Я вписал ответ в вопрос и получил его же обратно.
Сценарий «ставка выросла на 3 пункта»
Вопрос со ставкой записывается в движке так — это реальная декларация из plugins/credit-risk, шок из примера выше, только с адресом и именем.
{
id: 'high_ltv_rate_shock',
title: 'Rate rise for the high-LTV-at-origination cohort',
select: [{ field: 'OriginalLTV', op: 'gte', value: 90 }],
shock: [{ field: 'CurrentInterestRate', op: 'add', value: 3 }],
}
select выбирает, кого касается вопрос. Здесь это займы с LTV при выдаче от 90 — первый взнос меньше десятой части цены жилья, и рост ставки первым делом бьет по такому плечу. Пустой select задал бы тот же вопрос всему портфелю. shock двигает наблюдаемое поле события, операций две — add и mul. Кода под сценарий не пишут, движок про ипотеку ничего не знает.
Ставка в этих данных фиксированная, у выданного займа она от рынка не растет. Так что сценарий условный — вроде «всем пересчитали ставку на три пункта вверх». Механике все равно, какое поле двигать.
Прогон на реальных данных
Данные — Freddie Mac Single-Family Loan-Level Dataset, те же 26.5 млн записей, что в первой статье. Скачиваются бесплатно под собственный аккаунт, структура полей описана в официальном User Guide. Лицензия запрещает перераспространение, поэтому в репозитории их нет. Нужны пары файлов когорт 1999–2007 в data/freddie-mac/ — sample_orig_*.txt с выдачей и sample_svcg_*.txt с помесячным обслуживанием.
./scripts/up.sh # postgres в докере
./scripts/ingest.sh # загрузка 26.5 млн событий, ~5 минут
npm run windows -w @mamir/backend -- --plugin credit_risk --what labels
npm run windows -w @mamir/backend -- --plugin credit_risk --what features
npm run dev -w @mamir/scoring & # python-сайдкар с моделью
npm run backtest -w @mamir/backend -- --plugin credit_risk --from 2005-01-01 --steps 6
npm run dev -w @mamir/backend & # ядро на :3001
curl -X POST localhost:3001/scenarios/high_ltv_rate_shock/run \
-H 'content-type: application/json' \
-d '{"plugin":"credit_risk","at":"2009-01-01T00:00:00.000Z","lookback":"400d"}'
at — момент, на который собирается портфель. lookback — сколько истории входит в позицию. Вся цепочка до первого сценария занимает около часа на M3 Max, самые дорогие шаги — расчет фич (638 секунд на 8 × 26.5 млн значений) и обучение с бэктестом (770 секунд).
Итог в деньгах
Кроме прибавки потерь в ответе два служебных блока, coverage и extrapolation. Прогон выше на портфеле 2009 года — 207 867 займов на 29.83 млрд долларов — дает такую картину.
deltaEL: ожидаемые потери выросли со 161.40 до 176.85 млн долларов — плюс 15.45 млн, рост на 9.6%.
coverage: пройдено 5 303 019 событий, отобрано и перезаписано 528 614. Шок дошел до данных. Ноль в перезаписанных при непустом отборе означал бы, что нужного поля в данных нет и нулевой эффект сценария ничего не доказывает — такой прогон движок помечает
untouchedвместоcompleted.extrapolation: после шока средняя ставка за год у 7.9% займов уходит за 99-й перцентиль обучающей выборки, до шока таких было 0.077%. Обучающих примеров за этой границей — один из ста, ответ модели там ближе к догадке, чем к оценке.
Если ставка для займов с маленьким первым взносом вырастет на три пункта, к ожидаемым потерям портфеля добавится 15.45 млн долларов. Рядом видно, сколько записей шок реально изменил и где модель вышла за границу обучения.
Сам сценарий не проверишь — его будущее не наступает. Проверяется модель. Исторический прогон сравнивает предсказанные потери с реальными, на кризисе 2009 года разрыв был ×3.39, про это первая статья. Стресс-тест не точнее модели, которую использует.
Свой вопрос
Чтобы прогнать свое «а что, если», нужно положить данные в движок и объявить сценарий из двух частей — select с условием, кто попадает под вопрос, и shock с полем и величиной. Вопрос «доставка вырастет на 3 дня» из начала статьи записался бы как shock: [{ field: 'delivery_days', op: 'add', value: 3 }]. Путь от пустой базы расписан в гайде репозитория mamir, синтетический домен можно поднять за 15 минут, ипотека Freddie Mac — около часа.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.