MLOps

Testing ML Code

Тестирование ML-кода

актуальноТекущий рабочий стандарт

Направления: Инженерия ИИ · MLOps

Обычные тесты для необычного кода: форма тензора, поведение на краевых входах, воспроизводимость. Проверяют не качество модели, а то, что делает то, что написано.

Ключевые тезисы

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

Какую задачу решает

ML-код тестируют реже обычного, и обычно по одной причине: непонятно, что проверять. Ассерта «метрика равна 0,847» быть не может — она меняется от запуска к запуску. Отсюда вывод, что тестировать нечего, и он неверен.

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

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

Тест, который экономит три часа

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

Подробный разбор

5 подтем — объяснения, формулы, примеры и интерактивные графики.

import torch

def test_forward_shape():
    model = ResNet(num_classes=10)
    x = torch.randn(4, 3, 224, 224)   # батч из четырёх картинок
    y = model(x)
    assert y.shape == (4, 10)
    assert torch.isfinite(y).all()    # ни NaN, ни бесконечностей
Прямой проход на фиктивных данных

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

  • Форма выхода при типичном входе.
  • из одного элемента: частый источник ошибок в .
  • доходит до всех параметров: assert p.grad is not None после backward().

import pytest

@pytest.mark.parametrize("name", ["resnet18", "resnet50", "vit_base"])
@pytest.mark.parametrize("size", [224, 384])
def test_forward(name, size):
    model = build_model(name, num_classes=10)
    y = model(torch.randn(2, 3, size, size))
    assert y.shape == (2, 10)
Все архитектуры разом

Два декоратора дают шесть тестов. В отчёте они видны по отдельности: если сломался только vit_base на 384, это видно сразу, а не как «упал тест моделей».

На практике

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

import pytest

@pytest.fixture(scope="session")
def sample_batch():
    return torch.randn(8, 3, 224, 224), torch.randint(0, 10, (8,))

@pytest.fixture
def tmp_checkpoint(tmp_path):
    path = tmp_path / "model.pt"
    yield path                      # тест работает
    # после теста tmp_path удалится сам
conftest.py
  • scope="session" — объект создаётся один раз на весь прогон. Годится для неизменяемых данных.
  • scope="function" (по умолчанию) — заново на каждый тест. Обязательно для всего, что тест меняет.
  • tmp_path — встроенная временной папки: убирается автоматически.
На практике

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

@pytest.mark.slow
def test_training_converges():
    # обучение на подвыборке, минуты
    ...

@pytest.mark.skipif(not torch.cuda.is_available(), reason="нужен GPU")
def test_amp_forward():
    ...
pytest -m "not slow"        # быстрые — на каждый коммит
pytest -m slow             # медленные — ночью в CI
Запуск
На практике

xfail отличается от skip тем, что тест всё-таки выполняется и, если внезапно проходит, отчёт это показывает. Для известной, но ещё не исправленной ошибки это правильнее: как только починили, вы об этом узнаете.

pytest --cov=src --cov-report=term-missing

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

На практике

Покрытие как цель («не ниже 80%») порождает тесты, которые вызывают функцию и ничего не проверяют: строки выполнились, процент вырос, смысла ноль. Полезнее смотреть на непокрытые места глазами и решать, какие из них важны.

Где применяется

  • Проверка архитектурыПрямой проход всех вариантов модели на фиктивных данных.
  • Загрузчик данныхПустой , один элемент, неполный последний батч — краевые случаи.
  • ПрепроцессингПропуски, , неизвестная категория: что делает трансформация.
  • Регрессия качестваМодель на фиксированном сиде не должна ухудшаться после рефакторинга.

Плюсы, минусы и альтернативы

Плюсы

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

Минусы

  • Не проверяют главное — качество модели на реальных данных.
  • Тесты на обучение медленные, и их приходится отделять от быстрых.
  • Недетерминизм иногда ломает точные сравнения — нужны допуски.

Брать, если

  • Код живёт дольше одного эксперимента.
  • Над пайплайном работает больше одного человека.

Не брать, если

  • Одноразовый ноутбук для проверки гипотезы.
  • Вместо валидации модели: тесты кода её не заменяют.

Чем заменяют

Видеолекции

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

Кому эта тема нужна

Профильная компетенция для роли: Продуктовый инженер.

Усиливает профиль: ML Engineer, MLOps-инженер.

Проверить себя

Тема встречается в тесте по направлениям — ошибки приведут обратно на эту страницу.

Следующий шагПроверить себя: MLOps и инженерияТема встречается в этом тесте 1 раз. Ошибка приведёт обратно на эту страницу — с объяснением, что именно не сошлось.Перейти →

Глава «MLOps» последний раз правилась . Нашли ошибку — напишите.