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

Мониторинг

Модель не падает громко, когда мир сдвигается. Она продолжает уверенно отвечать про данные, которых никогда не видела, а метрика, которая это поймала бы, — accuracy по разметке — в продакшене не существует: разметка и есть то, чего вы ждёте.

Что доступно с первого запроса — это вход. Сдвиг его распределения — самый ранний честный сигнал, что модели задают вопросы, под которые её не обучали.

Две половины

Обучение уже записывает то, на чём оно шло. Каждая зарегистрированная версия несёт профиль своего обучающего сплита:

Sentiment.load()._version.baseline
# {'text': {'kind': 'text', 'mean': 37.5, 'edges': [...], 'counts': [...]},
#  'label': {'kind': 'categorical', 'values': {'neg': 162, 'pos': 155}}}

Вторую половину записывает сервинг — если её включить:

myproject/settings.py
PREDICTION_LOG = {
    "ENABLED": True,
    "SAMPLE": 0.05,        # 5% достаточно, чтобы увидеть сдвиг распределения
    "MAX_ROWS": 100_000,   # старые строки обрезаются за этим порогом
}

По умолчанию выключено намеренно. Лог предсказаний — это копия пользовательского ввода в базе: решение, которое проект принимает сам, а не обнаруживает утром. Сэмплирование идёт построчно, а не по запросу, поэтому батчевый эндпоинт записывает долю каждого батча, а не все целиком одни и ни одного другие.

Измерение расхождения

python manage.py drift reviews.Sentiment
python manage.py drift reviews.Sentiment --stage production --since 24h
python manage.py drift reviews.Sentiment --against reviews.Incoming
reviews.Sentiment@v4 vs 2841 logged predictions over the last 7d

Column             Kind         PSI     Verdict
-----------------  -----------  ------  -----------
text               text         0.4132  significant
label (predicted)  categorical  0.1801  moderate

PSI below 0.1 is stable, 0.1–0.25 moderate, above 0.25 significant.
A column has moved significantly. Retraining is likely overdue.

Сравниваются две вещи, и вторая важнее всего тогда, когда первая мало что может сказать:

  • Дрейф входа — каждый признак против того же признака на обучении.
  • Дрейф предсказаний — то, что модель отвечает, против меток, на которых её учили. Модель, чей выход раньше делился поровну, а теперь на 90% один класс, стоит посмотреть — и для этого вообще не нужна разметка.
Флаг Что делает
--version N / --stage NAME С профилем какой версии сравнивать
--since 24h\|7d\|4w Окно логов предсказаний. По умолчанию 7d
--against LABEL Сравнить датасет вместо лога — для батча, который вы собираетесь скорить
-n N Ограничить число прочитанных строк. По умолчанию 10 000
--json Отдать оценки
--fail-on moderate\|significant Выйти с ненулевым кодом. Для регулярной задачи

Страница модели в админке показывает ту же таблицу за последние семь дней — и не показывает ничего, когда лог выключен: пустая таблица дрейфа на каждой странице учит людей не смотреть на таблицу дрейфа.

Что такое PSI

Population stability index — стандарт в кредитном скоринге ровно для этой задачи:

PSI = Σ (actual_i − expected_i) · ln(actual_i / expected_i)

по корзинам i, в долях от своих сумм. Одно число на колонку, сравнимое между колонками разных типов, с порогами, которые не надо выдумывать: ниже 0.1 — стабильно, 0.1–0.25 — умеренно, выше 0.25 — значимо.

Как колонка разбивается на корзины, зависит от того, что в ней лежит, и решается один раз на обучении:

Вид Разбиение Замечания
Числовая Децили обучающего сплита Квантили, а не равные ширины: равные ширины на скошенной колонке сложат всё в одну корзину и не заметят ничего
Категориальная Корзина на значение Значения, не встречавшиеся на обучении, получают свою
Текстовая Децили длины Грубый прокси — и честный: значения свободного текста не повторяются, поэтому их частоты ничего не говорят

Наблюдаемые данные всегда читаются как тот вид, который определил baseline. Определять вид дважды кажется безобидным, но это не так: продакшн-текст, схлопнувшийся до горстки повторов, был бы прочитан как категориальный, и сравнение вернуло бы пустоту ровно в тот момент, который стоило поймать.

В регулярной задаче

--fail-on превращает дрейф из отчёта в проверку:

- name: Check for drift
  run: python manage.py drift reviews.Sentiment --since 24h --fail-on significant

OpenTelemetry

mlango ведёт собственную запись каждого рана, трейса и спана, и админка читает именно её. Это мост для организаций, у которых уже есть куда смотреть:

pip install "mlango[otel]"
myproject/settings.py
TELEMETRY = {"ENABLED": True, "SERVICE_NAME": "reviews-api"}

После этого раны обучения, циклы агентов и каждый вызов инструмента отдаются спанами, а собственные атрибуты mlango уходят в своё пространство имён (mlango.target, mlango.run, mlango.status, mlango.step) — чтобы их можно было найти среди спанов десятка библиотек. Ран обучения, запущенный HTTP-запросом, оказывается его дочерним спаном; ради этого всё и делается.

mlango не настраивает экспортёр. Ни эндпоинта, ни сэмплера, ни заголовков. Это делает процесс — так, как ожидает любая другая библиотека с инструментацией OpenTelemetry: обычно несколько строк на старте или запуск через opentelemetry-instrument. Взять эту настройку на себя означало бы завести вторую, худшую копию настроек самого SDK.

Сломать вашу работу это не может. Упавший коллектор, кидающий исключение экспортёр, взорвавшийся трейсер — всё это уходит в debug-лог, а ран продолжается. Телеметрия описывает работу, а не распоряжается ею.

С выключенной настройкой не отдаётся ничего, а зависимость необязательна: без opentelemetry-api настройка один раз предупредит, и каждый спан станет пустышкой.

Чем это не является

Это не продукт для мониторинга и не пытается им быть. Здесь нет алертов, нет дашбордов во времени, нет детекции аномалий. Он отвечает на один вопрос — ушёл ли вход от того, на чём обучалась эта версия, — по данным, которые у фреймворка уже были, и без всякой дополнительной инфраструктуры. Когда вы это перерастёте, лог предсказаний останется обычной таблицей, которую можно отгрузить куда угодно.