- BrainTools - https://www.braintools.ru -
В последнее время я часто работал с разными ML-проектами в GitLab. В каждом был свой .gitlab-ci.yml, своя обвязка вокруг MLFlow, своя регистрация и валидация модели. Со временем я понял, что MLOps-пайплайн во всех проектах очень похож, а при работе с новыми копипаста размножается быстрее кроликов. Ну и тут уже хочешь не хочешь, но идея сделать общий шаблон напрашивается. Однако будем честны, обычный template для CI — это круто, но хочется чего-то гибкого, декларативного и красивого. Для достижения этих целей GitLab уже давно предлагает переходить на CI/CD компоненты [1]. В результате я хотел видеть 10 строк YAML, которые будут выдавать полноценный пайплайн с валидацией данных, обучением [2], quality gates и регистрацией модели.
И спустя месяц я добился желаемого. В этой статье покажу, как устроен компонент, на какие грабли наступал по пути, и как подключить всё это в ваш проект.
Сам компонент. [3]
Я думаю, этот сценарий знаком многим. Создаёте свой первый ML-проект, пишете CI, который включает:
подготовку данных,
обучение модели с логированием в MLflow,
проверку метрик,
регистрацию в Model Registry.
Выглядит отлично, всё работает, и проект дальше развивается, но спустя время появляется новый. Вы копируете конфиг, меняете пути, подправляете пороги. Потом ещё один, Вы повторяете то же самое, и так пока клавиша ctrl не сотрётся в ноль. Где-то на пятом проекте Вы уже не помните, в каком именно конфиге до этого чинили баг с передачей MLFLOW_RUN_ID между джобами.
Чтобы понять масштаб боли [4], вот как выглядел типичный .gitlab-ci.yml до компонента (сокращённо, но идею передаёт):
stages: [validate, train, evaluate, register]
validate-data:
stage: validate
image: python:3.12
script:
- pip install pandas great_expectations
- python scripts/validate.py --data data/train.csv --check-nulls --threshold 0.05
artifacts:
paths: [validation_report.json]
train-model:
stage: train
image: python:3.12
variables:
MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow"
script:
- pip install mlflow scikit-learn pandas
- python scripts/train.py --data data/train.csv
- echo "MLFLOW_RUN_ID=$(cat run_id.txt)" >> train.env
artifacts:
reports:
dotenv: train.env
paths: [model/, metrics.json]
evaluate-model:
stage: evaluate
image: python:3.12
needs: [{job: train-model, artifacts: true}]
variables:
MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow"
script:
- pip install mlflow
- python scripts/evaluate.py --run-id $MLFLOW_RUN_ID --threshold 0.85
- echo "EVAL_PASSED=$(cat eval_result.txt)" >> evaluate.env
artifacts:
reports:
dotenv: evaluate.env
register-model:
stage: register
image: python:3.12
needs: [{job: train-model, artifacts: true}, {job: evaluate-model, artifacts: true}]
rules:
- if: $EVAL_PASSED == "true"
variables:
MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow"
script:
- pip install mlflow
- python scripts/register.py --run-id $MLFLOW_RUN_ID --model-name my-model
Выглядит как отличный инкубатор для багов, переходящих из проекта в проект. А ведь здесь ещё не включены DVC, retry, кэширование pip, обработка ошибок и т. п., которые сделают наш конфиг ещё более громоздким. В реальности каждый проект наращивал свои костыли, и к каждому .gitlab-ci.yml нужно было дописывать парочку вспомогательных скриптов, каждый из которых имел собственную реализацию auto_configure_mlflow, свой парсинг аргументов, свои баги.
Я хотел сделать всё красиво, поэтому очевидным выбором стали CI/CD Components [5], добавленные в GitLab с версии 17.0. Компоненты — это переиспользуемые шаблоны, но с версионированием и гибким управлением через входящие аргументы (inputs). И мне кажется, что конечный результат стоил приложенных усилий.
Для начала предлагаю взглянуть на результат — .gitlab-ci.yml проекта, который использует MLOps компонент:
stages: [validate, train, evaluate, register]
include:
- component: gitlab.com/netOpyr/gitlab-mlops-component/full-pipeline@1.0.0
inputs:
model_name: wine-classifier
training_script: scripts/train.py
training_args: '--data data/train.csv --test-data data/test.csv'
data_path: data/train.csv
framework: sklearn
metric_name: accuracy
min_threshold: '0.85'
И из этих 10 строк мы получаем 4 джобы:
validate ──> train ──> evaluate ──> register
│ │ │ │
schema MLflow accuracy Model Registry
nulls autolog >= 0.85 (если eval прошёл)
drift метрики vs prod
Магия? Нет, только набор шаблонов и Python скрипт, аккуратно упакованный в Docker. GitLab сам при парсинге .gitlab-ci.yml разворачивает шаблоны, а скрипт делает всю грязную работу.
Насущные scripts/validate.py [6], scripts/evaluate.py [7], scripts/register.py [8] и всё, что с ними связано, теперь живут в компоненте. Вам остаётся только самое интересное — скрипт обучения.
Самая недооценённая часть ML-пайплайна. Я неоднократно запускал обучение на битых данных и после 20 минут ожидания получал NaN в метриках. Validate же отловит все проблемы до начала обучения.
В первую очередь проверяется схема, нас интересует, все ли колонки на месте. Для работы достаточно указать expected_columns: 'feature1,feature2,target' при добавлении компонента. И теперь пайплайн упадёт на этапе проверки с понятным сообщением, если кто-то поменял название столбца.
Далее смотрим на пустые значения. Здесь скрипт считает долю null в каждом столбце и сравнивает с порогом, который по умолчанию задан как 5%. Если условие не выполнено, то, как и для остальных ошибок, будет чёткое сообщение в логах.
Для проектов, в которых нужно следить за дрейфом, есть интеграция с Evidently. Для работы включаем фичу enable_drift: true и указываем путь к референсным данным. После сравнения validate подготовит HTML-отчёт и зафейлит джобу, если дрейф выше заданного порога.
Также компонент поддерживает работу с Great Expectations. Достаточно указать expectation_suite: path/to/suite.json для кастомных правил. Если же нужно ещё более гибкое описание правил проверок, то Python-скрипт с функцией run_checks(df) отлично подтянется компонентом custom_expectations_script: scripts/custom_checks.py [9]. Скрипт загрузит модуль, вызовет run_checks() с DataFrame и включит результаты в общий отчёт. Пример:
def run_checks(df):
results = []
# Проверяем, что целевая переменная содержит ровно 3 класса
unique_targets = set(df["target"].unique())
results.append({
"name": "target_classes",
"passed": unique_targets == {0, 1, 2},
"message": f"Expected {{0,1,2}}, got {unique_targets}"
})
# Проверяем диапазон значений
out_of_range = ((df["alcohol"] < 5) | (df["alcohol"] > 20)).sum()
results.append({
"name": "alcohol_range",
"passed": out_of_range == 0,
"message": f"{out_of_range} values out of range"
})
return results
В результате, если что-то пойдёт не так, то пайплайн упадёт сразу, сэкономив Ваши нервы и время.
Самая интересная часть! Здесь компонент оборачивает Ваш скрипт обучения в MLFlow-сессию.
Предлагаю чуть подробнее рассмотреть логику [10] работы этого этапа. Компонент поддерживает интеграцию с MLFlow, интегрированным в GitLab, поэтому компонент начинает с вытягивания MLFLOW_TRACKING_URI из переменных CI, далее создаёт эксперимент и run для него, также включает автологинг для Вашего фреймворка (sklearn, PyTorch, TensorFlow, XGBoost, LightGBM), чтобы уменьшить возможное взаимодействие с MLFlow API.
Следующий этап — запуск вашего скрипта обучения. Компонент передаёт переменную MLFLOW_RUN_ID через переменную окружения, по ней Вы можете логировать, что пожелаете, в результате всё попадёт в один MLFlow run. После отработки переменные MLFLOW_RUN_ID и MLFLOW_EXPERIMENT_ID запишутся в dotenv-артефакт для downstream-джоб, метрики сохранятся в metrics.txt, потом GitLab отобразит их прямо в Merge Request.
Для того чтобы при работе скрипт не сыпал шумными MLFlow-предупреждениями и получил MLFLOW_RUN_ID как подпроцесс, запуск происходит через обёртку на runpy.run [11]_path(). Ваш скрипт при этом видит себя как main.
При этом никакого особого API от Вашего скрипта не требуется, это обычный Python-файл, который подхватывает MLFLOW_RUN_ID из окружения и логирует в run. Пример взаимодействия с компонентом Вашего скрипта:
run_id = os.getenv("MLFLOW_RUN_ID")
if run_id:
with mlflow.start_run(run_id=run_id):
mlflow.log_param("n_estimators", 200)
mlflow.log_metric("accuracy", acc)
mlflow.sklearn.log_model(pipeline, "model")
Но если Вам достаточно метрик, которые автолог подхватит сам, то явное логирование можно не указывать. Например, для sklearn autolog залогирует все гиперпараметры, метрики кросс-валидации и даже модель.
Также из приятного, выше упоминал, что компонент на этом этапе генерирует metrics.txt в формате GitLab Metrics Reports [12]. И при открытии Merge Request метрики отобразятся рядом с тестами. На мой взгляд, очень приятная мелочь, когда хочется понять, что именно поменялось в модели.
Важный этап, включающий решение, является ли модель достойной прода. Компонент берёт MLFLOW_RUN_ID из артефактов train, подтягивает метрики из MLFlow и прогоняет их через абсолютный и относительный пороги.
Ворота 1: абсолютный порог. Сравниваем метрики с заданными пороговыми значениями. Например, если условие accuracy >= 0.85 не выполнилось, то модель не будет зарегистрирована. Также для loss метрик в inputs можно найти параметр higher_is_better: false, который позволяет инвертировать проверку и заменить >= на <=.
Ворота 2: сравнение с продом (опционально). Для подключения необходимо указать compare_with_production: true. Компонент ищет модель с алиасом champion или production в Model Registry, тянет её метрики из MLFlow и считает в процентах, насколько новая модель лучше. И если улучшение меньше заданного порога improvement_threshold, то модель до прода не дойдёт.
В результате работы evaluate этапа мы получаем:
MLOPS_EVALUATION_PASSED=true/false в dotenv,
MLOPS_CURRENT_METRIC — текущее значение метрики,
evaluation_report.json — полный отчёт с деталями по каждому gate,
metrics.txt.
Если evaluate прошёл успешно, то модель регистрируется в GitLab Model Registry. При регистрации подтягиваются метаданные: алиас (staging по умолчанию), commit SHA, pipeline ID, метрики из MLflow. Также если MLFlow API недоступен, вы всё равно увидите accuracy прямо в Model Registry, так как метрики дублируются в теги версии.
Алиасы в данном случае выступают в качестве указателей на одну из версий staging, champion, production. Компонент поддерживает все планы GitLab от Free до Ultimate. На Premium/Ultimate компонент стучится в set_registered_model_alias(), а если это Free версия, то вызов просто тихо зафейлится, сохранив информацию в тегах.
Отлично, основной воркфлоу понятен. Но как оно работает изнутри? Три апостола компонента:
CI/CD Components:
Как уже говорилось выше, компоненты — это шаблоны на стероидах. Компонент состоит из spec: (входные параметры) и тела (определение джоб). Работа с входными параметрами идёт через $[[ inputs.x ]]. Важно, что inputs подставляют значения на этапе парсинга пайплайна, они не являются shell-переменными.
Пример spec: для train компонента:
spec:
inputs:
training_script:
type: string
description: 'Path to the Python training script.'
framework:
default: 'auto'
options: ['auto', 'sklearn', 'pytorch', 'tensorflow', 'xgboost', 'lightgbm', 'none']
image_suffix:
default: 'sklearn'
options: ['sklearn', 'pytorch', 'tensorflow', 'boosting', 'pytorch-gpu', 'tensorflow-gpu']
Здесь options: — механизм валидации на уровне GitLab.
dotenv-артефакты:
Механизм GitLab, который позволяет передавать переменные между джобами. Например, train создаёт файл mlops_train.env:
MLFLOW_RUN_ID=abc123def456
MLFLOW_EXPERIMENT_ID=42
MLOPS_MODEL_NAME=wine-classifier
Далее GitLab тянет этот файл и добавляет переменные в downstream-джобы. В результате evaluate видит MLFLOW_RUN_ID без лишних усилий с нашей стороны.
Итоговый датафлоу:
train → mlops_train.env (MLFLOW_RUN_ID) → evaluate → mlops_evaluate.env (MLOPS_EVALUATION_PASSED) → register
Python CLI (gitlab-mlops):
Общий скрипт, включающий в себя все 4 этапа, как отдельные подкоманды: validate, train, evaluate, register. Как уже упоминалось выше, большим плюс является самостоятельность скрипта, он сам настраивает MLflow tracking URI из CI-переменных GitLab:
def auto_configure_mlflow():
if not os.getenv("MLFLOW_TRACKING_URI"):
api_url = os.getenv("CI_API_V4_URL", "")
project_id = os.getenv("CI_PROJECT_ID", "")
if api_url and project_id:
uri = f"{api_url}/projects/{project_id}/ml/mlflow"
os.environ["MLFLOW_TRACKING_URI"] = uri
Если же MLFLOW_TRACKING_URI уже определён в переменных окружения, то скрипт оставит всё как есть. Важно отметить, что логика работы с токеном отличается: для начала необходимо создать access токен в проекте и выдать ему права на работу с api, далее занести в CI/CD-переменные MLOPS_ACCESS_TOKEN, во время работы скрипт его подтянет.
Пришло время перейти от теории к практике. Соберём простой проект-пример и настроим для него компонент. Обучать будем бессмертной классике — классификация вин на три сорта. В качестве фреймворка выступает sklearn.
Полный проект: gitlab.com/netOpyr/mlops-component-example [13]
mlops-component-example/
├── .gitlab-ci.yml # пайплайн (компонент + prepare job)
├── scripts/
│ ├── prepare_data.py # генерация train/test CSV из sklearn
│ └── train.py # RandomForest + MLflow логирование
└── data/
Не красота ли? Всего три файла, ничего лишнего. Всё необходимое теперь живёт внутри компонента.
# Строим пайплайн
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", RandomForestClassifier(
n_estimators=args.n_estimators,
max_depth=args.max_depth,
random_state=42,
)),
])
# Кросс-валидация
cv_scores = cross_val_score(pipeline, X_train, y_train, cv=5)
print(f"CV Accuracy: {cv_scores.mean():.4f} +/- {cv_scores.std():.4f}")
pipeline.fit(X_train, y_train)
# Логируем в MLflow (run_id пришёл от компонента)
run_id = os.getenv("MLFLOW_RUN_ID")
if run_id:
with mlflow.start_run(run_id=run_id):
mlflow.log_param("n_estimators", args.n_estimators)
mlflow.log_param("max_depth", args.max_depth)
mlflow.log_metric("accuracy", cv_scores.mean())
mlflow.sklearn.log_model(pipeline, "model")
Важный момент: здесь нет ничего специфичного, что заставит Вас пользоваться компонентом, и не позволит слезть с него. Это обычный Python скрипт, переменная MLFLOW_RUN_ID — единственное, что связывает его с компонентом. Этот скрипт также отлично отработает хоть локально, хоть в Jupyter.
stages: [prepare, validate, train, evaluate, register]
# Генерация данных
prepare-data:
stage: prepare
image: python:3.12-slim
script:
- pip install pandas scikit-learn --quiet
- python scripts/prepare_data.py
artifacts:
paths: [data/]
# Остальные этапы выполняет компонент
include:
- component: $CI_SERVER_FQDN/netOpyr/gitlab-mlops-component/full-pipeline@1.0.0
inputs:
model_name: wine-classifier
training_script: scripts/train.py
training_args: '--data data/train.csv --test-data data/test.csv'
data_path: data/train.csv
framework: sklearn
metric_name: accuracy
min_threshold: '0.85'
Здесь от нас требуется только подготовить данные для обучения, закинуть их в csv и потом в артефакты. Всё остальное компонент сделает сам.
prepare-data создаёт data/train.csv и data/test.csv,
validate проверяет data/train.csv,
train запускает scripts/train.py [14], логирует всё в MLflow,
evaluate берёт accuracy из MLflow, сравнивает с порогом 0.85,
Если accuracy >= 0.85, то register создаёт версию в Model Registry.
После успешного пайплайна метрики можно будет найти в Analyze > Model experiments.
Можете форкнуть этот проект и попробовать сами. Главное, как я уже говорил выше, не забудьте создать access токен с доступом к api и добавить его в CI/CD переменную MLOPS_ACCESS_TOKEN.
При прочтении у вас, скорее всего, возник вопрос, а что делать, если данные хранятся в S3. На этот случай я добавил интеграцию с dvc:
include:
- component: .../train@1.0.0
inputs:
training_script: scripts/train.py
model_name: my-model
dvc_enabled: true
dvc_remote: minio
dvc_files: 'data/train.csv.dvc data/test.csv.dvc'
dvc_push: true
dvc_push_paths: 'model/'
С подключённым dvc компонент перед обучением ставил dvc через pip и тянет данные. Далее валидация, обучение, а после dvc add model/ и dvc push , в результате обученная модель отправляется обратно.
Креды настраиваются через CI/CD переменные: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL.
Единственный нюанс — dvc каждый раз ставится заново, так как не всем он нужен, а весит прилично. Поэтому при необходимости вы можете собрать свой образ и указать его при включении компонента.
«Пайплайн на 10 строк» звучит круто и выглядит красиво, но когда проект становится больше и более требовательным к MLOps, вы можете перейти на раздельное подключение джоб:
stages: [validate, train, evaluate, register]
include:
- component: .../validate@1.0.0
inputs:
data_path: data/train.csv
check_nulls: true
null_threshold: '0.03'
enable_drift: true
reference_data_path: data/reference.csv
drift_threshold: '0.1'
- component: .../train@1.0.0
inputs:
training_script: scripts/train.py
model_name: my-model
image_suffix: pytorch-gpu
framework: pytorch
tags: ["gpu"]
- component: .../evaluate@1.0.0
inputs:
model_name: my-model
metric_name: val_loss
min_threshold: '0.1'
higher_is_better: false
image_suffix: pytorch-gpu
- component: .../register@1.0.0
inputs:
model_name: my-model
alias: staging
Здесь уже поинтереснее. Используется GPU-раннер, есть проверка на дрейф и loss метрики. В итоге получаем отличный конструктор, с которым каждый может играть, как пожелает.
А зачем такие усложнения? Возможно, Вы захотите добавить условий на выполнение конкретных этапов или указать отдельный GPU-раннер для train, где-то, может, нужны свои образы. Гибкая настройка в данном случае позволит вам выжать всё по максимуму из компонента.
Также нельзя пройти мимо возможности параллельного обучения на основе компонента. Используя параметр as, вы можете дать уникальные имена джобам и прогонять сразу несколько моделей:
include:
# RandomForest
- component: .../train@1.0.0
inputs:
as: train-rf
training_script: train_rf.py
model_name: model-rf
- component: .../evaluate@1.0.0
inputs:
as: eval-rf
model_name: model-rf
min_threshold: '0.85'
needs_job: train-rf
# XGBoost
- component: .../train@1.0.0
inputs:
as: train-xgb
training_script: train_xgb.py
model_name: model-xgb
image_suffix: boosting
framework: xgboost
- component: .../evaluate@1.0.0
inputs:
as: eval-xgb
model_name: model-xgb
min_threshold: '0.85'
image_suffix: boosting
needs_job: train-xgb
Здесь я выделил отдельный образ под каждый фреймворк. Фреймворк выбирается через image_suffix:
|
Суффикс |
Фреймворки |
GPU |
|---|---|---|
|
|
scikit-learn, matplotlib |
Нет |
|
|
XGBoost, LightGBM, scikit-learn |
Нет |
|
|
PyTorch (CPU) |
Нет |
|
|
PyTorch + CUDA 12.4 |
Да |
|
|
TensorFlow (CPU) |
Нет |
|
|
TensorFlow + CUDA 12.4 |
Да |
Все образы включают: Python 3.12, MLflow, pandas и скрипт. Собирал всё слоями: base → фреймворк. Если Вам нужны дополнительные зависимости, то можете либо указать requirements_file: requirements.txt, тогда перед началом работы всё необходимое подтянется, либо просто собрать свой образ и указать его в переменной image_registry_base.
Тут могу предложить вам 2 пути:
Форкнуть пример mlops-component-example [13], который я разбирал выше, и поиграться с ним.
Добавить в свой проект. Тут вам нужно будет положить в директорию scripts ваш скрипт обучения и, следуя README компонента, настроить его под себя.
Я опубликовал компонент в GitLab CI/CD Catalog, так что подключайте его по тегу latest и не забудьте о реализации MLOps-пайплайна. Если нашли баг или не хватает функциональности, буду ждать ваши issue или MR.
В ближайшем будущем хочу добавить: сборку с BuiltKit, retry для нестабильных MLFlow-запросов и GitLab Environments.
Компонент: gitlab.com/netOpyr/gitlab-mlops-component [3]
Пример: gitlab.com/netOpyr/mlops-component-example [13]
GitLab CI/CD Components: docs.gitlab.com/ci/components [5]
GitLab Model Registry: docs.gitlab.com/ee/user/project/ml/model_registry [15]
© 2026 ООО «МТ ФИНАНС»
Автор: net0pyr
Источник [16]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/28467
URLs in this post:
[1] CI/CD компоненты: https://habr.com/ru/companies/ruvds/articles/928360/
[2] обучением: http://www.braintools.ru/article/5125
[3] Сам компонент.: https://gitlab.com/netOpyr/gitlab-mlops-component
[4] боли: http://www.braintools.ru/article/9901
[5] CI/CD Components: https://docs.gitlab.com/ci/components/
[6] validate.py: http://validate.py
[7] evaluate.py: http://evaluate.py
[8] register.py: http://register.py
[9] checks.py: http://checks.py
[10] логику: http://www.braintools.ru/article/7640
[11] runpy.run: http://runpy.run
[12] GitLab Metrics Reports: https://docs.gitlab.com/ee/ci/testing/metrics_reports.html
[13] gitlab.com/netOpyr/mlops-component-example: https://gitlab.com/netOpyr/mlops-component-example
[14] train.py: http://train.py
[15] docs.gitlab.com/ee/user/project/ml/model_registry: https://docs.gitlab.com/ee/user/project/ml/model_registry/
[16] Источник: https://habr.com/ru/companies/ruvds/articles/1013854/?utm_campaign=1013854&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.