Если вы меня читаете, то знаете, что где-то полгода назад я проектировала, обучала и, в общем, создавала своего агента для проверки ТЗ. А сейчас у меня появилась новая идея по захвату мира: протестировать ML. И да, я не Data Scientist, но когда это меня останавливало? Начала я с вполне ожидаемого вопроса: а что там вообще можно проверить? Под рукой был мой собственный агент, датасет тоже был, так что далеко ходить за подопытным не пришлось.

И тут мой же проект сыграл со мной злую шутку. Нормальной документации у него нет. Я его сама собирала, сама обучала, сама знаю, какие данные туда идут, что означают признаки и какого поведения от него жду. Вся документация прекрасно существует. В моей голове. Для автора проекта это типичное место хранения. Для тестирования уже не очень.

Поэтому я смотрела на датасет и пыталась понять, что из него можно вытащить для проверок. Вот значения признака. Можно посмотреть минимум и максимум. Вот категории. Можно разбить данные на группы. Можно проверить какие-то комбинации. Можно посмотреть метрики на отдельных сегментах. Проверок набиралось много, с этим как раз проблем не было.

Проблема обнаружилась в критериях: почему результат конкретной проверки должен считаться ошибкой? Допустим, в датасете минимальное значение признака 3. Значит ли это, что 2 для модели недопустимо? Нет. Если AUC на одном из сегментов ниже, чем на остальных, это FAIL? Может быть. А какой AUC там должен быть? Если в данных не встретилась какая-то комбинация значений, должна ли модель её поддерживать? Кто сказал? Короче, датасет охотно рассказывал, что в нём есть, и категорически отказывался сообщать, как должно быть.

Умная мысля, как известно, приходит опосля. В моём случае она пришла вместе с Olist. Я наткнулась на готовую модель оценки риска поздней доставки, а рядом с ней лежало всё то, чего мне не хватало в собственном эксперименте: описание модели, датасет, список признаков, сохранённые метрики, информация о версиях библиотек. Вот её я и буду проверять. Документацию.

При слове «документация» сейчас, вероятно, вздрогнули процентов девяносто тестировщиков. Потому что в реальной жизни она обычно находится в одном из трёх состояний: её нет, она устарела или в ней написано ровно столько, чтобы остальные подробности пришлось доставать вопросами практически из всей команды. Но для ML документация оказалась особенно важна. Если нигде не записано, какие признаки ожидает модель, framework не должен придумывать их по датасету. Если нигде не указан допустимый диапазон значения, минимум и максимум тестовой выборки этим диапазоном не становятся. А вот если сохранён ROC AUC 0.8101, его уже можно воспроизвести и сравнить. Здесь есть конкретное ожидаемое значение.

Однако теперь возникла другая проблема: моё нежелание тестировать всё это руками. Да и потом, один проект у меня уже есть, но что-то мне подсказывает, что это далеко не последняя ML модель, с которой мне придётся иметь дело. Так родилась идея создать framework для автоматизированного тестирования готовых ML моделей.

Первым делом я просмотрела документацию Olist и стала разбираться, что из неё вообще можно отдать framework. И тут обнаружилась ещё одна засада. Человек спокойно прочитает, что модель использует 34 признака, найдёт в таблице ROC AUC 0.8101, ниже увидит версию scikit-learn и поймёт, о чём идёт речь. Конечно, можно научить framework разбирать README, искать таблицы, заголовки и нужные слова, но я-то хочу тестировать модель, а не проверять, насколько удачно сегодня распарсился Markdown.

Поэтому рядом с моделью появился model_metadata.json. В него я вынесла то, что framework должен знать точно: список числовых и категориальных признаков, target, сохранённые метрики, версии библиотек и так далее.

Например, для Olist часть metadata выглядит так:

{
  "target": "is_late",
  "test_metrics_shipped": {
    "roc_auc": 0.8101,
    "pr_auc": 0.3354,
    "brier": 0.0633
  },
  "library_versions": {
    "scikit-learn": "1.9.0",
    "pandas": "3.0.5",
    "numpy": "2.4.6"
  }
}

README при этом никуда не делся. Его по-прежнему можно открыть и прочитать. Просто framework не использует человеческий текст как основание для PASS или FAIL.

Но metadata тоже знает не всё. Загруженная модель сама хранит часть информации о себе. У sklearn это, например, feature_names_in_, classes_, а ещё наличие predict и predict_proba. Поэтому я решила собирать требования из двух мест: из metadata и из формального API самой модели. Так появился ModelContract.

Например, список входных признаков framework получает сразу из двух источников. В metadata они перечислены как numeric_features и categorical_features, а у модели есть feature_names_in_. Мне это удобно потому, что одно можно проверить другим. Если в metadata написано 34 признака, а модель ожидает 33, у меня уже есть вполне конкретное расхождение, которое можно показать. С классами похожая ситуация. Для классификатора их не надо искать в датасете. Модель сама сообщает их через classes_. Наличие predict и predict_proba тоже можно определить у загруженного объекта.

В итоге ModelContract у меня собирает признаки, target, классы, сохранённые метрики, версии библиотек и доступные методы модели. И обязательно запоминает, откуда взялось каждое требование.

Если framework пишет FAIL, мне хочется видеть, что именно он проверял, где было записано требование и чем подтверждается нарушение.

В следующей версии framework я хочу зайти ещё дальше со всей этой историей: проверять не только само требование и его источник, но и кто внёс изменения в документацию. Ну, чтобы сразу понимать, к кому идти с дубиной.

Например:

FAIL: В тестовом датасете нет признака distance_km.

Требование: схема данных, обязательные колонки.
Откуда взялось: спецификация v2, пункт 4.1.
Кто изменил или добавил: @ivanov_dev, PR #142 от 15.09.2026.

Но вернёмся к тому, что уже работает. На этом принципе я собрала пять проверок.

И тут важный момент: сами проверки не написаны под Olist. Olist здесь только пример, на котором я разрабатывала и проверяла framework. В коде нет его 34 признаков, distance_km, ROC AUC 0.8101 или других требований этой конкретной модели. Всё это framework получает из контракта того проекта, который в него загрузили.

Поэтому предметная область для этих проверок значения не имеет. Это может быть модель риска поздней доставки, модель вероятности реакции на препарат или другая табличная sklearn совместимая модель. У неё будут свои признаки, свои классы, свои метрики и свои версии библиотек. Framework возьмёт именно их и прогонит те проверки, которые применимы к этой модели. Ограничение первой версии в том, какие типы моделей и требований framework уже умеет проверять. Сейчас это готовые обученные табличные sklearn совместимые модели: бинарная и многоклассовая классификация, а для регрессии выполняются только те проверки, которые к ней применимы.

А модель вообще получила то, что ждала?

Первая проверка работает со схемой входных данных. Framework получает список обязательных признаков из metadata и проверяет, что все они присутствуют в загруженном тестовом датасете. Затем проверяет, нет ли дублирующихся колонок. Если модель предоставляет feature_names_in_, список признаков из metadata сравнивается со списком, который хранит сама модель.

На Olist это 34 признака. Чтобы проверить отрицательный сценарий, я сделала отдельный Xtest_invalid.parquet и удалила из него distance_km. В metadata этот признак остался в списке обязательных, feature_names_in_ модели его тоже содержит, а в тестовом датасете его уже нет. Framework фиксирует отсутствующий обязательный признак и возвращает FAIL.

При этом я специально не стала проверять типы данных только потому, что в metadata признаки разделены на numeric_features и categorical_features. Этого недостаточно, чтобы утверждать, что конкретный признак обязан иметь float64, int64 или какой-то другой dtype. Если такая проверка понадобится, допустимые типы нужно будет явно описать в контракте и научить framework их проверять.

То же самое с пропусками. В тестовом датасете Olist есть 835 NaN, но в текущей версии контракта правил для пропущенных значений нет. Поэтому framework их просто не оценивает. В дальнейшем в metadata можно добавить формальное требование к NaN и реализовать его проверку.

Может ли модель обработать эти данные?

После проверки входных данных framework запускает модель на настоящем тестовом датасете.

Здесь я не стала придумывать ничего сложного. Вызывается model.predict(X). Если модель отработала без исключения, проверка пройдена. Если preprocessing или сама модель падает на этих данных, получаем FAIL и сохраняем ошибку. Эта проверка нужна отдельно от предыдущей. Наличие всех 34 колонок ещё не гарантирует, что весь Pipeline действительно сможет их обработать. И здесь же я поставила ограничение на следующие проверки. Если predict упал, проверять форму его результата уже незачем. Framework не должен выдавать ещё несколько FAIL по одной и той же причине. Следующая проверка в таком случае получает SKIP.

Что модель вернула?

Успешный вызов predict ещё не означает, что на выходе получилось то, что допускает API модели.

Для классификатора framework сравнивает количество предсказаний с количеством строк тестового датасета. Если модель сообщает свои классы через classes_, проверяется, что среди предсказаний нет какого-нибудь внезапного «нежданчика» третьего класса. Если доступен predict_proba, проверок становится больше. Количество строк должно совпадать с количеством объектов, количество столбцов с количеством классов, все значения должны быть конечными и находиться от 0 до 1. Сумма вероятностей в каждой строке должна быть равна единице с небольшой технической погрешностью. Для Olist классы [0, 1], поэтому predict_proba должен вернуть по две вероятности на каждый заказ.

Получатся ли сохранённые метрики ещё раз?

Вот здесь понадобился optional target. В документации Olist сохранены результаты на тестовой выборке: ROC AUC 0.8101, PR AUC 0.3354 и Brier score 0.0633. Framework запускает эту же модель на test set, берёт ytest и считает метрики заново.

У меня получилось:

ROC AUC = 0.810134692045325

PR AUC = 0.3353506524884806

Brier = 0.06332224485406013

В документации значения сохранены с четырьмя знаками после запятой, поэтому framework сравнивает их с той же точностью. Получаются 0.8101, 0.3354 и 0.0633. Проверка проходит. Если target не загрузить, здесь будет SKIP. Framework просто не из чего посчитать метрики. В документации есть и Accuracy, Precision, Recall, F1, но их я сознательно не включила в автоматическую проверку. Для них используется threshold 0.155, а в контракте сейчас не зафиксировано правило применения этого порога. Додумывать, > там должно быть или >=, framework не будет.

В каком окружении всё это запускается?

Olist сохранялся с scikit-learn 1.9.0, pandas 3.0.5 и numpy 2.4.6. Framework смотрит установленные версии этих же библиотек и сравнивает их с metadata. У меня во время запуска были scikit-learn 1.9.1, pandas 3.0.6 и numpy 2.5.3. Совпадения нет. Но здесь я не ставлю FAIL. Другая версия библиотеки сама по себе ещё не доказывает, что модель работает неправильно. Поэтому результат такой проверки WARN. Framework показывает, какая версия была записана при сохранении модели и какая установлена сейчас.

Заодно пришлось учесть маленькую бытовую радость Python: пакет импортируется как sklearn, а называется scikit-learn. Framework приводит эти названия к одному виду, иначе можно было бы получить предупреждение просто из-за двух имён одной библиотеки.

Собираем всё вместе

Для запуска framework нужны три обязательных артефакта: обученная модель, тестовый датасет и model_metadata.json. Target тестового датасета я оставила опциональным. Без него framework всё равно сможет выполнить остальные проверки, а проверку воспроизводимости метрик пропустит. В интерфейсе для этого четыре поля загрузки: модель, тестовый датасет, metadata и отдельно target. После загрузки framework собирает ModelContract, определяет, какие проверки можно выполнить с этим комплектом данных, и запускает их.

Стартовый экран framework
Стартовый экран framework

На полном комплекте Olist я получаю пять результатов. Первые четыре проверки проходят, а проверка окружения возвращает WARN из-за отличий версий библиотек.

Общий результат запуска на полном комплекте Olist
Общий результат запуска на полном комплекте Olist

Карточку любой проверки можно раскрыть и посмотреть технические детали. Там видно само требование, его источник, фактический результат и причина

Для отрицательного сценария я загружаю Xtest_invalid.parquet, из которого удалён distance_km. Первая проверка возвращает FAIL, а в деталях видно, какой обязательный признак отсутствует и откуда framework получил требование о его наличии.

Тестирование не пройдено
Тестирование не пройдено

Собственно, всё проще, чем могло показаться на первый взгляд.

А кто проверяет сам framework?

С моделью разобрались, но оставлять framework без собственных тестов опрометчиво, надо же убедиться, что я собрала из говна и палок что-то работающее. Поэтому отдельно я написала тесты уже для самого framework. Сейчас их 41. Они проверяют загрузку контракта, каждую из пяти проверок, формирование результатов и разные сценарии выполнения.

Например, для проверки входных данных есть тесты на корректный набор признаков, отсутствующую колонку и дубликаты. Для запуска модели проверяется успешный predict и ситуация, когда модель выбрасывает исключение. Для predict_proba отдельно проверяются размер результата, количество классов, диапазон вероятностей, конечность значений и сумма вероятностей. С метриками я проверяю не только успешное воспроизведение. Есть сценарии, когда target отсутствует или нужную метрику невозможно посчитать. Отдельные тесты есть и для окружения, включая историю с sklearn и scikit-learn, чтобы два названия одного пакета не превращались в ложное предупреждение.

Ещё мне хотелось проверить поведение нескольких проверок вместе. Если модель падает на predict, следующая проверка не должна создавать ещё один FAIL из-за отсутствующих предсказаний. Она должна получить SKIP, потому что выполнить её в этой ситуации невозможно. Такие зависимости тоже покрыты тестами.

Результат запуска python -m pytest tests -v
Результат запуска python -m pytest tests -v

Что дальше

В первую очередь я хочу добавить другие типы моделей. Сейчас я работаю с готовыми табличными sklearn совместимыми моделями. В дальнейшем хочу разделить проверки по типам моделей. У классификации будут свои проверки, у регрессии свои, у следующих поддерживаемых типов свои. Framework должен определять тип загруженной модели и собирать подходящий для неё набор. Самих проверок тоже станет больше. Здесь уже можно будет расширять контракт и добавлять новые формальные требования по мере того, как framework научится с ними работать.

К README я тоже ещё вернусь. Возможно, в одной из следующих версий попробую научить framework разбирать документацию и переводить найденную информацию в структурированный вид. Как отделять формальное требование от обычного описания модели, пока оставлю себе как отдельную задачу.

И ещё хочется развить происхождение требований. Сейчас framework знает источник требования. В дальнейшем к этому можно добавить историю изменений: кто изменил требование, когда и в каком PR. Тогда при падении проверки можно будет проследить путь требования до конкретного изменения в проекте. И моя идея с дубиной наконец получит техническую реализацию.

Вместо итога:

— Эй, Брейн, чем мы будем заниматься сегодня вечером?

— Тем же, чем и всегда, Пинки... Попробуем завоевать мир!(с)

GitHub (ссылка на проект): https://github.com/AnnaGamgiya/ML-QA-Framework-Anna-Gamgiya

Hugging Face с моделью Olist: https://huggingface.co/samuelalex37/olist-delivery-risk-model