Обычные тесты для необычного кода: форма тензора, поведение на краевых входах, воспроизводимость. Проверяют не качество модели, а то, что делает то, что написано.
Ключевые тезисы
- Тест на прямой проход ловит несовпадение размерностей до того, как это сделает трёхчасовое обучение.
- Параметризация прогоняет один тест по набору конфигураций — то, что нужно для архитектур.
- создают модель и данные один раз на весь модуль, а не на каждый тест.
Какую задачу решает
ML-код тестируют реже обычного, и обычно по одной причине: непонятно, что проверять. Ассерта «метрика равна 0,847» быть не может — она меняется от запуска к запуску. Отсюда вывод, что тестировать нечего, и он неверен.
Проверять надо не качество модели, а корректность . Форма тензора на выходе слоя, поведение загрузчика на пустом батче, детерминированность при фиксированном зерне, отсутствие утечки между обучением и валидацией — всё это обычные детерминированные утверждения.
Выигрыш измеряется временем. Ошибка размерности, найденная тестом за две секунды, иначе обнаруживается через три часа обучения — и это в лучшем случае, потому что часть таких ошибок не падает, а тихо портит результат.
Поменяли число каналов в свёрточном блоке и запустили обучение. Через два часа сорок минут — ошибка размерности в полносвязном слое, потому что размер входа в него зависел от числа каналов. Обучение с нуля, ещё три часа. Тест, который это ловит, занимает четыре строки: создать модель, пропустить через неё тензор нужной формы, проверить форму выхода. Прогон — доли секунды. Параметризовав его по всем вариантам архитектуры, вы получаете гарантию, что ни одна конфигурация не собирается неправильно, — и это до того, как вообще включился.
Подробный разбор
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 удалится сам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 # медленные — ночью в CIxfail отличается от skip тем, что тест всё-таки выполняется и, если внезапно проходит, отчёт это показывает. Для известной, но ещё не исправленной ошибки это правильнее: как только починили, вы об этом узнаете.
pytest --cov=src --cov-report=term-missingОтчёт показывает, какие строки не выполнялись ни одним тестом. Смотреть на него стоит не ради процента, а ради списка: часто там обнаруживается ветка обработки ошибок, которую никто никогда не проверял.
Покрытие как цель («не ниже 80%») порождает тесты, которые вызывают функцию и ничего не проверяют: строки выполнились, процент вырос, смысла ноль. Полезнее смотреть на непокрытые места глазами и решать, какие из них важны.
Где применяется
- Проверка архитектурыПрямой проход всех вариантов модели на фиктивных данных.
- Загрузчик данныхПустой , один элемент, неполный последний батч — краевые случаи.
- ПрепроцессингПропуски, , неизвестная категория: что делает трансформация.
- Регрессия качестваМодель на фиксированном сиде не должна ухудшаться после рефакторинга.
Плюсы, минусы и альтернативы
Плюсы
- Ошибка находится за секунды, а не за три часа обучения.
- Рефакторинг перестаёт быть страшным: тесты держат контракт.
- Тесты документируют ожидаемое поведение лучше комментариев.
Минусы
- Не проверяют главное — качество модели на реальных данных.
- Тесты на обучение медленные, и их приходится отделять от быстрых.
- Недетерминизм иногда ломает точные сравнения — нужны допуски.
Брать, если
- Код живёт дольше одного эксперимента.
- Над пайплайном работает больше одного человека.
Не брать, если
- Одноразовый ноутбук для проверки гипотезы.
- Вместо валидации модели: тесты кода её не заменяют.
Чем заменяют
Видеолекции
Записи университетских курсов, где эта тема звучит. Ссылка открывает запись с той секунды, где о ней говорят, — искать по трёхчасовой лекции не нужно.
Всё, что названо в описании, разобрано здесь же или в соседней теме — переходы под каждым определением.
Параметризация
Один тест прогоняется по набору конфигураций — все архитектуры разом.
Разбор ниже: ПараметризацияМаркировка
Медленные тесты помечаются и не запускаются на каждый коммит.
Разбор ниже: Маркировка и разделение по скоростиПокрытие
Доля строк, выполнившихся под тестами. Полезно как индикатор, вредно как цель.
Разбор ниже: ПокрытиеПрофильная компетенция для роли: Продуктовый инженер.
Усиливает профиль: ML Engineer, MLOps-инженер.
Тема встречается в тесте по направлениям — ошибки приведут обратно на эту страницу.
Глава «MLOps» последний раз правилась . Нашли ошибку — напишите.