- BrainTools - https://www.braintools.ru -

Как я уместил весь MLOps-пайплайн в 10 строк

Почему пользовать MLOps CI/CD компонентом круто

Почему пользовать MLOps CI/CD компонентом круто

В последнее время я часто работал с разными ML-проектами в GitLab. В каждом был свой .gitlab-ci.yml, своя обвязка вокруг MLFlow, своя регистрация и валидация модели. Со временем я понял, что MLOps-пайплайн во всех проектах очень похож, а при работе с новыми копипаста размножается быстрее кроликов. Ну и тут уже хочешь не хочешь, но идея сделать общий шаблон напрашивается. Однако будем честны, обычный template для CI — это круто, но хочется чего-то гибкого, декларативного и красивого. Для достижения этих целей GitLab уже давно предлагает переходить на CI/CD компоненты [1]. В результате я хотел видеть 10 строк YAML, которые будут выдавать полноценный пайплайн с валидацией данных, обучением [2], quality gates и регистрацией модели.

И спустя месяц я добился желаемого. В этой статье покажу, как устроен компонент, на какие грабли наступал по пути, и как подключить всё это в ваш проект.

Сам компонент. [3]

Проблема: копипаста между ML-проектами

Я думаю, этот сценарий знаком многим. Создаёте свой первый 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). И мне кажется, что конечный результат стоил приложенных усилий.

10 строк, которые заменяют всё

Для начала предлагаю взглянуть на результат — .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] и всё, что с ними связано, теперь живут в компоненте. Вам остаётся только самое интересное — скрипт обучения.

Что происходит на каждом шаге

validate — проверка данных до обучения

Самая недооценённая часть 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

В результате, если что-то пойдёт не так, то пайплайн упадёт сразу, сэкономив Ваши нервы и время.

train — обучение с автотрекингом

Самая интересная часть! Здесь компонент оборачивает Ваш скрипт обучения в 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 метрики отобразятся рядом с тестами. На мой взгляд, очень приятная мелочь, когда хочется понять, что именно поменялось в модели.

evaluate — quality gates

Важный этап, включающий решение, является ли модель достойной прода. Компонент берёт 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.

register — регистрация в Model Registry

Если 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 и потом в артефакты. Всё остальное компонент сделает сам.

Что происходит при пуше

  1. prepare-data создаёт data/train.csv и data/test.csv,

  2. validate проверяет data/train.csv,

  3. train запускает scripts/train.py [14], логирует всё в MLflow,

  4. evaluate берёт accuracy из MLflow, сравнивает с порогом 0.85,

  5. Если accuracy >= 0.85, то register создаёт версию в Model Registry.

После успешного пайплайна метрики можно будет найти в Analyze > Model experiments.

Можете форкнуть этот проект и попробовать сами. Главное, как я уже говорил выше, не забудьте создать access токен с доступом к api и добавить его в CI/CD переменную MLOPS_ACCESS_TOKEN.

DVC: данные под контролем

При прочтении у вас, скорее всего, возник вопрос, а что делать, если данные хранятся в 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 строк мало: отдельные компоненты

«Пайплайн на 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

Docker-образы и фреймворки

Здесь я выделил отдельный образ под каждый фреймворк. Фреймворк выбирается через image_suffix:

Суффикс

Фреймворки

GPU

sklearn

scikit-learn, matplotlib

Нет

boosting

XGBoost, LightGBM, scikit-learn

Нет

pytorch

PyTorch (CPU)

Нет

pytorch-gpu

PyTorch + CUDA 12.4

Да

tensorflow

TensorFlow (CPU)

Нет

tensorflow-gpu

TensorFlow + CUDA 12.4

Да

Все образы включают: Python 3.12, MLflow, pandas и скрипт. Собирал всё слоями: base → фреймворк. Если Вам нужны дополнительные зависимости, то можете либо указать requirements_file: requirements.txt, тогда перед началом работы всё необходимое подтянется, либо просто собрать свой образ и указать его в переменной image_registry_base.

Как попробовать

Тут могу предложить вам 2 пути:

  1. Форкнуть пример mlops-component-example [13], который я разбирал выше, и поиграться с ним.

  2. Добавить в свой проект. Тут вам нужно будет положить в директорию scripts ваш скрипт обучения и, следуя README компонента, настроить его под себя.

Что дальше

Я опубликовал компонент в GitLab CI/CD Catalog, так что подключайте его по тегу latest и не забудьте о реализации MLOps-пайплайна. Если нашли баг или не хватает функциональности, буду ждать ваши issue или MR.

В ближайшем будущем хочу добавить: сборку с BuiltKit, retry для нестабильных MLFlow-запросов и GitLab Environments.

Ссылки

© 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

www.BrainTools.ru

Rambler's Top100