Всем привет! Меня зовут Роза и я MLOps-инженер. В этой статье расскажу, как построить CI/CD-пайплайн для ML-приложений с нуля, поэтапно и без боли. Ну почти :)
Я работаю в Купере — сервисе доставки из магазинов и ресторанов, где занимаюсь разработкой ML-платформы. Наши ML-сервисы проникают во все бизнес-процессы работы приложения, начиная от рекомендации бананов в корзине и заканчивая прогнозом того, через сколько приедет ваш курьер.
Немного цифр: у нас 9 ML-команд, 78 сервисов, 41 человек и почти четыре сотни DAG’ов в Airflow, которые ежедневно и даже ежеминутно переобучают ML-модели.
Раньше очень часто работа DS-инженера заканчивалась на подготовке кода модели в Jupyter-ноутбуке, а дальше его подхватывали команды разработки и доводили до продакшена. У такого подхода есть минусы. Например, если произойдёт инцидент, непонятно кто ответственен за сервис — команда разработки или авторы ML-модели?
К счастью, культура разработки меняется: теперь ML-инженер — это специалист, который разрабатывает свой ML-сервис на всем пути от общения с бизнесом до продакшена. Этот подход хорошо описывает принцип «you build it, you run it»: кто построил модель, тот её и запускает. Как раз в этом здорово помогает CI/CD.
С чего начинаем
Все пайплайны и оптимизации ниже описаны для GitLab CI/CD, но их достаточно легко перенести на другие фреймворки типа Jenkins. Для экспериментов были доступны 48 GitLab раннеров (4 CPU, 8Gb RAM каждый). В качестве сборщика зависимостей был выбран poetry из-за его гибкости и функциональности. Также все образы собираются с официального образа Python для репрезентативности, но на практике обычно в зеркалах компании собирают обогащённый образ, где не только Python, но и poetry, и всякие удобные тулкиты для работы.
├── my_project │ ├── cli │ │ ├── ... │ ├── my_module │ │ ├── ... │ ├── my_second_module │ │ ├── __init__.py │ │ └── print_hello.py │ └── __init__.py ├── notebooks │ └── my_report.ipynb ├── tests │ └── __init__.py ├── .dockerignore ├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── Makefile ├── README.md ├── lint.toml ├── poetry.lock └── pyproject.toml
Для примера возьмём типичный ML-проект. В нем есть отдельные директории для исходного кода, тестов и Jupyter-ноутбуков. Из важных «системных» файлов можно отметить .gitlab-ci.yaml — в нем как раз будет описан наш CI/CD, а также Dockerfile для сборки образа и Makefile (к нему вернёмся ниже). В файлах poetry.lock и pyproject.toml описаны зависимости проекта.
Что касается зависимостей, соберём небольшой набор из популярных ML-библиотек.
[tool.poetry.dependencies] python = "^3.11" catboost = "^1.2.5" click = "8.1.7" clickhouse-driver = "^0.2.7" clickhouse-sqlalchemy = "^0.3.1" lightgbm = "^4.3.0" loguru = "0.7.2" numpy = "^1.26.4" pandas = "^2.2.2" polars = "^0.20.25" prophet = "^1.1.5" protobuf = "^5.26.1" pyarrow = "^16.0.0" pymysql = "^1.1.0" requests = "^2.31.0" scikit-learn = "^1.4.2" sqlalchemy = "^2.0.30» # Tests and Linters jupyter = "1.0.0" mypy = "1.7.1" pytest = "7.4.3" pytest-cov = "4.1.0"
Базовый пайплайн, с которого мы начнём, выглядит просто и минималистично. Сначала собирается образ, затем в нем запускаются линтеры и тесты и дальше происходит деплой:

В образе копируем код из раннера, указываем poetry создать окружение в текущей директории. Далее устанавливаем сам poetry и затем уже зависимости проекта. Команда в CMD вызывает напрямую скрипт из проекта.
FROM your/company/hub/python:3.11 ENV POETRY_VIRTUALENVS_CREATE=true \ POETRY_VIRTUALENVS_IN_PROJECT=true WORKDIR /app COPY . . RUN pip install --no-cache-dir poetry==1.8.1 \ && poetry install --no-root CMD [ "poetry", "run", "python", "my_project/my_second_module/print_hello.py" ]
Проблема #1: ML-инженер ждёт вечность, пока соберётся пайплайн

Первая же проблема, с которой сталкивается ML-инженер в описанном выше простеньком пайплайне — это время его работы. Оно составляет 4 минуты 25 секунд.
Выглядит не так уж страшно, но это время легко может вырасти до десятков минут, е��ли усложнить проект сборкой CUDA, например.
Как будем измерять успех?
Чтобы понимать, насколько успешно идет оптимизация, нужны метрики. Для начала это скорость (наши 4,5 минуты) и вес образов (около 3 ГБ), для которого стоит сразу обозначить нижнюю границу.
Если базовый образ Python (не с тегом
slim) весит около 1 ГБ и зависимости в распакованном виде весят около 1,2 ГБ, то предел, до которого можно обезжирить образ — это где-то 2,2 ГБ. На эту нижнюю границу и будем ориентироваться.
Но вернёмся к основной цели — бусту скорости, и попробуем ускорить сборку зависимостей.
Решение
Python-окружение всегда состоит из двух частей: это само приложение и его зависимости:

Код при этом меняется очень часто (его исправляет и коммитит разработчик), а зависимости пересобираются очень редко. Что можно сделать с тем, что переиспользуется очень часто, но меняется очень редко? Конечно, кэшировать!
В GitLab CI/CD есть удобная фича из коробки — оператор cache:, который позволяет заархивировать папку с кэшем в zip в каком-нибудь хранилище типа S3 бакета, после чего просто передавать этот архив другим джобам в пайплайне (подробнее можно прочитать тут). При этом можно задать набор файлов, изменение которых инвалидирует кэш. Давайте попробуем воспользоваться этим оператором и добавим новую джобу кэширования зависимостей перед сборкой образа:
build:deps: image: your/company/hub/python:3.11 stage: build script: - pip install --no-cache-dir poetry==1.8.1 - poetry config virtualenvs.in-project true - poetry install --no-root cache: key: files: - "poetry.lock" - "pyproject.toml" paths: - .venv/ needs: [ ]
Фактически в джобе делается всё то же самое, что раньше мы делали в докер-образе. Мы все так же говорим рoetry сделать окружение внутри папки и установить зависимости. Здесь важно отметить, что инвалидация кэша происходит тогда, когда мы меняем pyproject.toml или poetry.lock, то есть те места, в которых эти зависимости описываются.
FROM your/company/hub/python:3.11 WORKDIR /app COPY . . CMD [ ".venv/bin/python", "my_project/my_second_module/print_hello.py" ]
Как изменится сборка образа? Теперь она станет совсем минималистичной: - в ней останется только базовый образ Python и копирование кода. Однако мы помним, что в папке .venv в самой директории с кодом наход��тся Python-окружение с зависимостями, поэтому COPY . . нам ещё и окружение заодно скопирует в образ. И вместо того, чтобы запускать скрипт через poetry run для CMD, мы будем делать это напрямую через ванильный Python, так как poetry в образе уже не будет.
Что это даст? На логе этой джобы видно, что при попытке установки зависимостей ничего не устанавливается, потому что кэш уже подключён к окружению, то есть все зависимости уже находятся в окружении. Профит!

Вот так будет выглядеть пайплайн с новой джобой кэширования зависимостей:

Здесь необходимо отметить, что от сборки образа теперь зависит только деплой, потому что линтеру и тестам для работы нужно только окружение и исходный код проекта.

За счёт того, что теперь зависимости кэшируются, а линтеры и тесты запускаются параллельно сборке, время сократилось до 3 минут 40 секунд. Причём, несмотря на то, что целью оптимизации была скорость, в процессе получилось также уменьшить вес образов до той самой нижней границы в 2 ГБ. Это произошло, потому что в образе теперь остались только наше окружение и исходный код, когда раньше там хранился кэш poetry, сам poetry и еще кэш pip.
Проблема #2: хотим, чтобы контейнер с кодом был ридонли
Зачем вообще нужны иммутабельные контейнеры? Чтобы код в нем всегда совпадал с кодом в репозитории и чтобы никто не смог зайти и поменять этот код в рантайме через простой exec.
Решение
Для этого мы будем ставить код через whl или, другими словами, ставить проект не копипастой исходного кода, а установкой проекта через пакет в наше окружение наравне с его зависимостями.
Для этого нам нужно перейти к мультистайдийной сборке, где в первой стадии собирается сам whl через poetry build, а во второй — он устанавливается в образ.
FROM your/company/hub/python:3.11 AS builder WORKDIR /app COPY pyproject.toml . COPY my_project my_project RUN pip install --no-cache-dir poetry==1.8.1 \ && poetry build FROM your/company/hub/python:3.11 AS app-image WORKDIR /app ENV PATH="/app/.venv/bin:$PATH" COPY .venv .venv COPY --from=builder /app/dist /app/dist RUN python -m pip install --no-deps -v dist/*.whl \ && rm -rf dist CMD [ "my_project", "--help" ]
poetry build сохранит whl в директории ./dist (по дефолту), который потом копируется через COPY --from в финальный образ. А дальше необходимо просто установить его через старый-добрый pip в уже существующее окружение из прошлого шага. Не забываем удалить ./dist, чтобы в образе не было лишнего.
Важно отметить, что здесь явно копируется .venv (кэш с зависимостями) с раннера — это необходимо, так как мы теперь не копируем всю директорию с проектом (через COPY . .).
И неочевидная плюшка, которая появилась в этой сборке — теперь можно вызывать CLI проекта напрямую.

По метрикам у нас появился новый критерий — это безопасность. Сделали контейнер ридонли. Что интересно — сократилось время пайплайна, до 2,5 минут! Это значительно, если сравнивать его с предыдущей версией и тем более с пятью минутами на старте. Что же так заметно ускорило сборку?
Все дело в том самом явном копировании COPY .venv .venv. Теперь мы копируем только конкретную директорию, а как мы помним, меняется она очень редко. За счёт этого при последующих коммитах Docker закэширует этот слой в образе, и наш гигабайт зависимостей будет реально копироваться только тогда, когда они поменяются. Отсюда такая значительная экономия времени.
Проблема #3: ИБ просит удалить Jupyter в продакшен-образе
Теперь давайте посмотрим на наш CI/CD с другой стороны. В какой-то момент к вам приходит ИБ (информационная безопасность) и требует убрать Jupyter из зависимостей.
Казалось бы, что плохого в Jupyter? Все мы любим и знаем его, но если чуть внимательнее посмотреть, то окажется, что этот метапакет очень давно не обновлялся (аж с 2015 года на момент статьи, хотя уже вышла более свежая версия) и тянет за собой уязвимости. Поэтому Jupyter для сканеров безопасности — как красная тряпка для быка.

Однако «голый» Jupyter (тот самый метапакет jupyter) скорее всего почти никто не использует: есть просто Jupyter Notebook (notebook), Jupyter Lab (jupyterlab) и кластерный Jupyter Hub (jupyterhub). Мы долго пытались понять, откуда в зависимостях протекает именно этот пакет. И оказалось, что у VS Code (IDE, которой у нас в компании пользуются почти все ML-щики) есть плагин, который упрощает работу с Jupyter, и именно он тянет этот метапакет.
Убрать его полностью из зависимостей мы не можем, поскольку это означает, что придётся заставлять разработчиков ставить пакет вручную.
Кто-то может спросить: а зачем вообще нужен Jupyter? Он позволяет запускать интерактивную среду для экспериментов, при этом часто с ним устанавливают какие-то дополнительные плагины, например, библиотеку tqdm. Ещё ML-инженеры очень любят рисовать графики, и, конечно же, логировать всё это в MLflow. А объединяет все эти действия то, что они относятся не к продакшену, а к этапу разработки.
Поэтому на самом деле проблема шире! Она звучит так — в продакшн образе не должно быть ничего лишнего. Не должно быть тех фреймворков и тулкитов, которые нужны именно для работы на стадии экспериментов.
Решение
Что мы можем с этим сделать? Поделить наши зависимости на основные и для разработки, а ещё отдельно собирать тестовый образ.
На самом деле группировать зависимости достаточно просто: в poetry эта фича идёт из коробки.
… … [tool.poetry.dependencies] [tool.poetry.dependencies] python = "^3.11" python = "^3.11" catboost = "^1.2.5" catboost = "^1.2.5" click = "8.1.7" click = "8.1.7" … ==> … # Tests and Linters [tool.poetry.group.dev.dependencies] jupyter = "1.0.0" jupyter = "1.0.0" mypy = "1.7.1" mypy = "1.7.1" pytest = "7.4.3" pytest = "7.4.3" pytest-cov = "4.1.0” pytest-cov = "4.1.0” … …
Есть основные зависимости (main), и мы сделаем ещё одну группу и обозначим её dev для зависимостей на этапе разработки. Теперь можем ставить их обе по отдельности. В соответствии с группами также удваивается и количество кэшей: первый только для основных зависимостей, второй — для всех зависимостей (и основных, и dev).
build:deps:main: image: your/company/hub/python:3.11 stage: build script: - … - poetry install --no-root --only main cache: key: prefix: main files: - "poetry.lock" - "pyproject.toml" paths: - .venv/ build:deps:dev: image: your/company/hub/python:3.11 stage: build script: - … - poetry install --no-root cache: key: prefix: dev files: - "poetry.lock" - "pyproject.toml" paths: - .venv/
Заодно добавим префиксы в название архива (строки 9 и 25), чтобы можно было видеть и различать, какой из них мы подкладываем в следующие джобы.

Снова смотрим на пайплайн: в процесс добавилась одна параллельная джоба со сборкой dev-зависимостей.

В итоге мы избавились от уязвимых пакетов, потому что теперь кэш с dev-зависимостями подкладывается только в линтеры и тесты, где он нужен.
Также появилась гибкость, потому что теперь есть такая сущность, как группа зависимостей. poetry позволяет делать столько групп, сколько нужно.
Например, у нашей команды был кейс, когда мы эту фичу активно использовали при построении платформенного шаблона ML-сервиса. Были созданы четыре группы. Первые две — это как раз те зависимости, которые идут от платформы и которые пользователь не должен трогать. Они всегда должны быть, чтобы сервис работал. И аналогично есть две группы зависимостей для самих пользователей — в них он может прописывать нужные ему библиотеки, которые не покрываются платформой. Очень удобно!
Все ещё проблема #3: что делать, если нужен тестовый образ?
До этого мы сделали только кэш, который существовал исключительно в рамках пайплайнов в CI/CD. Теперь давайте разберёмся, что делать, если нам всё-таки нужен дополнительный образ с dev-зависимостями.
Это может быть необходимо, например, когда пользователь хочет сверху дополнительные слои в образе. У нас был кейс, когда платформой поставляется базовый образ с минимально необходимым сервису тулкитом, и при этом есть возможность пользователям добавить свои слои, например, с какими-то фреймворками для дебага.
Решение
Что мы можем с этим всем сделать? Добавляем просто сборку ещё одного образа, ровно такого же, который у нас уже есть, с тем же Dockerfile, но с одним отличием: мы передаём ему не main-зависимости, а dev и main. Таким образом, мы делаем «тестовый» образ, в котором есть основное окружение и ещё dev-зависимости.
По метрикам у нас ничего не поменяется, так как мы добавляем только параллельную джобу в пайплайн (скорее здесь все упирается в производительность вашего Container Registry):

В итоге, у нас все ещё один Dockerfile, но с него билдится два образа. Теперь попробуем оптимизировать общую часть, а именно сборку whl, чтобы не выполнять её два раза. Вынесем её в отдельную джобу и облегчим немного наш докер: эта сборка будет запускаться только один раз.
Чуть-чуть схитрим и вынесем сборку whl в уже существующую джобу сборки кэша зависимостей. Более правильно сделать отдельную джобу, но таким образом мы сэкономим время на накладные расходы при выполнении.
build:deps:main: image: your/company/hub/python:3.11 stage: build script: - … - poetry build # собирает whl в dist/ cache: key: prefix: main files: - "poetry.lock" - "pyproject.toml" paths: - .venv/ artifacts: paths: - dist/*.whl
Для этого воспользуемся оператором artifact: в Gitlab, который позволит нам выгрузить whl на сервер Gitlab и аналогично кэшу передать его в следующие джобы (подробнее можно почитать тут).
Таким образом наш Dockerfile стал короче:
ARG PYTHON_VERSION="3.11" FROM your/company/hub/python:3.11 WORKDIR /app ENV PATH="/app/.venv/bin:$PATH" ENV VIRTUAL_ENV="/app/.venv" COPY .venv .venv COPY dist dist RUN python -m pip install --no-deps -v dist/*.whl \ && rm -rf dist CMD [ "my_project", "--help" ]
В нем изчезла первая стадия, и вместо нее мы копируем с раннера директорию ./dist, в которой лежит наш артефакт в виде whl пакета.
Внесла ли эта оптимизация изменения?

На самом деле небольшие — всего на 15 секунд уменьшили наше время. Однако мы получили кастомный образ для тестирования, плюс возможность гибко собирать и настраивать больше дополнительных образов. Также мы добились, что наш whl собирается для всех образов всего один раз.
Проблема #4: несколько ML-инженеров обновляют версию в своих MR (или не обновляют)

Очень часто случается так, что несколько ML-инженеров, работая в одном проекте, в своих мердж-реквестах меняют версию в pyproject.toml. И также часто случается так, что они эту версию не меняют. Почему и то, и то – проблема?

Без обновления версии приложение никак не версионируется, в нем всегда одна и та же версия, несмотря на то, что оно изменяется. А если версию команда всё-таки обновляет – это делается руками. Такой подход приводит к тому, что в реквестах происходят конфликты, которые требуют ручного вмешательства. Другими словами: если один инженер сумел свой реквест задеплоить, то второму придётся этот локальный конфликт как-то решать.
А усугубляет проблему то, что у нас есть на самом деле несколько версий:
версия в
pyproject.tomlверсия в самом репозитории (git tag)
версия приложения, с которой оно публикуется в репозиторий (PyPi или внутреннее зеркало типа Nexus)
Нам нужно все эти версии сделать сквозными, к тому же добавить фичу автоматического обновления.
Решение
В этом нам поможет опен-сорс проект gitlab-semantic-versioning. Это достаточно простой Python-скрипт, который позволяет автоматически обновлять git тег проекта согласно semver нотации. Разработчик проставляет в реквесте соответствующий лейбл версии, которую он хочет обновить (major, minor или patch), а скрипт берёт текущий тег, обновляет его и пушит в репозиторий.
Таким образом мы решили две проблемы из трёх: теперь версии обновляются автоматически. Осталось git тег пролить в pyproject.toml. Решение этой проблемы автоматически прольет нам эту же версию и в Nexus, потому что poetry при публикации как раз использует версию из pyproject.toml.
Как это сделать?
Сначала добавим основную джобу с бампом версии: возьмём готовый образ с указанными выше скриптом и сохраним полученный git тег в виде артефакта (оператор dotenv:, подробнее тут). Теперь добавим вторую джобу, которая проливает этот тег в poetry через poetry version и заодно коммитит это изменение в репозиторий.
version:bump: stage: version image: your/company/hub/gitlab-semantic-versioning:1.1.0 script: - printf "GIT_TAG=" > bump.env - python3 /version-update/version-update.py >> bump.env … artifacts: reports: dotenv: bump.env version:publish: stage: version image: your/company/hub/git/image:latest script: - ... - git checkout -b $CI_DEFAULT_BRANCH - poetry version -vv "$GIT_TAG" - git add -A - git commit -m "Version $GIT_TAG" - git push origin needs: - "version:bump" # takes $GIT_TAG from artifact
В целом, такой набор джоб уже обеспечит автоматическую сквозную версию. Но часто в командах есть какой-то уже устоявшийся релизный цикл выпуска версий. Вы можете легко адаптировать эти джобы конкретно под ваши нужды. Выше приведён минимальный сетап.
Проблема #5: ML-инженер не хочет ждать CI/CD, чтобы прогнать линтеры и тесты

Мы настроили супер-пупер CI/CD пайплайн, в котором оптимизировали самые узкие бутылочные горлышки и добавили много фичей. Давайте теперь вернёмся к нашему ML-инженеру.
Стандартный флоу его работы состоит в том, что он пишет какой-то код, пушит его в репозитории и с тревогой смотрит на пайплайн этого CI/CD, не окрасилось ли там что-то красным. Если всё зелёное, значит, всё супер и можно спокойно выкатывать это на ревью и деплоить свой реквест.
Но если что-то окрасилось красным, значит что-то упало и нужно править код. А это опять пушить, опять ждать 2–3 минуты, опять смотреть, снова править, пушить, ждать, и так по кругу, много раз, пока все не будет зелёным.
Две-три минуты — это терпимо, если проблема решается сразу. Но когда ошибка сложная и фидбек на исправления в коде приходится ждать несколько раз, процесс ожидания может здорово утомлять.
Решение
Что можно сделать? Можно и дальше пробовать оптимизировать пайплайны, но хочется, чтобы инженер мог ещё до коммита понимать, проходит ли его код линтеры и тесты. Этого можно добиться, добавив в репозиторий старый-добрый Makefile.
.EXPORT_ALL_VARIABLES: show-version: poetry version tests: echo "Run tests." poetry run pytest tests mypy: echo "Run mypy checks." poetry run mypy --config-file ../lint.toml lint: mypy ## Start all linters

Обычно Makefile — это что-то из мира C/C++. Но и здесь его очень удобно применять, потому что он решает несколько проблем.
Первое — в нём мы можем имитировать весь CI/CD. Если прописать в нем линтеры, тесты, сборку нужного нам окружения, чтобы проверить какую-то фичу, то все эти команды будут абсолютно идентичны нашему CI/CD, то есть они будут запускаться локально.
Вторая проблема, которую решает Makefile — унификация локальной работы с ML-приложением. Если раньше каждая команда могла придумать свой велосипед, а потом в ступоре сказать: «Ой, у меня локально всё работает, а что-то все пайплайны красные, помогите!», то с Makefile такой проблемы нет. Все используют один и тот же Makefile, в нём запускаются какие-то стандартные линтеры (для всех одинаковые), всё становится более унифицированным и приятным для поддержки.
Ну и неочевидная плюшка: если к вам в команду приходит новичок, ему не нужно шерстить Confluence и README проектов, искать команды в попытках запустить приложение. Все это в удобной форме хранится в Makefile.
Ну и финальные результаты наших метрик:

А что насчёт деплоя?
Давайте посмотрим, чем ещё можно помочь ML-инженеру, ведь помимо того, что он пушит код в репозитории, ему приходится делать ещё много разной сложной работы до и после коммита.
Чтобы обучать модель на регулярной основе, ML-инженер должен прогнать свои в даги в Airflow или аналогичном оркестраторе. Также ему часто нужно распределённо посчитать какой-нибудь большой датасет или сделать сэмпл данных, например, через Spark или Trino. Или даже запустить свои dbt-модели для обработки данных. Ну и конечно для воспроизводимости нужно логировать свои эксперименты с моделями в MLflow. А если это онлайн-сервис, то добавляется получение фичей из online фичастора.
Решение
На самом деле здесь сложно дать универсальный рецепт. Ниже будет приведён пример, как именно наша команда справилась с такими требованиями, но в реальности все очень сильно зависит от конкретных ML-команд, которые приходят с такими запросами.

Мы пошли по пути стейджинг-окружения on demand, где под on demand я подразумеваю какую-нибудь зелёную кнопку в GitLab, которую ML-инженер нажимает, и у него в Kubernetes разворачивается неймспейс, в котором есть все эти инструменты.
Таким образом весь флоу его работы от и до поднимается в рамках одного неймспейса: поднимается маленький Airflow, к нему рядом Jupyter, чтобы он мог легко и быстро данные считать, тут же Spark или spark-operator, MLflow для экспериментов. И в этом же неймспейсе и само приложение.
Получается такой изолированный небольшой контур, который работает только для одного разработчика, поднимается из конкретного окружения, например, из мердж-реквеста, и в нем разработчик может протестировать всё, что ему нужно: свои фичи, свои модели.
Такой подход ведет к тому, что когда мы выкатываем сервис на продакшн, ML-инженер уверен, что его код в идентичной среде будет работать ожидаемо. Конечно, не всегда можно построить окружение, один в один повторяющее условия продакшена. Однако по возможности нужно повторить большую часть инфраструктурных элементов, чтобы в будущем было меньше головной боли с тем, как же проверить, почему локально все летает, а в продакшене падает.
Итого
Коротко подведём итог и зафиксируем решения, которые мы исследовали в статье.
собирать зависимости только тогда, когда они меняются;
ставить python-приложение через whl;
делать продакшен-образ чистым и легковесным;
делать сквозное автоматическое версионирование;
дать возможность ML-инженерам работать локально as is в CI/CD;
дать возможность ML-инженерам на всех этапах разработки быстро тестировать свою работу.
Все эти «рецепты» наша команда нашла и сформулировала «в бою», и будем надеяться, что они помогут кому-то еще облегчить жизнь ML-инженерам :)
Псс, подписывайся на tg-канал ML-команды Купера ML Доставляет.
