Recall везде + фиксы окружения

- recall_macro/recall_weighted во всех скриптах с метриками
  (train_simple, train_gpu, autolog_demo, hyperparam_sweep,
   grid_search_cv, load_and_predict, compare_runs)
- grid_search_cv: multi-metric scoring (accuracy + recall_macro)
- register_model: выбор лучшей модели по recall (--metric)
- MLflow warnings: name вместо artifact_path, сигнатура модели,
  numpy input_example для pytorch, подавление env-var INFO
- start_ui.sh: порт 5555 + file-store backend (фикс: UI не показывал
  эксперименты, т.к. скрипты писали в mlruns/, а сервер читал sqlite)
- setup_server.sh: развилка macOS/Linux + проверка MPS
- train_gpu: get_device CUDA→MPS→CPU, num_workers=0 на macOS
- AGENTS.md: гид для агента + саморегламент обновления
- README/CODE_WALKTHROUGH: выровнена нумерация уроков 8/9
- .gitignore: +.DS_Store
- удалён scripts/setup_git.sh
This commit is contained in:
2026-07-20 22:28:23 +03:00
parent d6a4b46db0
commit d5c5bf3767
15 changed files with 547 additions and 257 deletions
+3
View File
@@ -9,3 +9,6 @@ data/MNIST/
# Flask serve
instance/
# macOS
.DS_Store
+206
View File
@@ -0,0 +1,206 @@
# AGENTS.md — гид для агента по проекту mlflow-practice
> Этот файл — ориентир для любого AI-агента (и человека), который будет
> работать с проектом. Читай его **первым** при входе в репозиторий.
---
## 🎯 Назначение проекта
Учебный проект для изучения **MLflow** на практике: от логирования
экспериментов до Model Registry и serving моделей. Девять последовательных
уроков, каждый — отдельный скрипт в `src/`. Поддерживает **GPU (RTX 3090, CUDA)**
и **macOS (Intel CPU / Apple Silicon MPS)**.
Не production-система. Цель — показать полный цикл MLOps на маленьких
встроенных датасетах (digits, wine, MNIST).
---
## 📁 Карта проекта
```
mlflow-practice/
├── AGENTS.md # ← этот файл (поддерживай актуальным!)
├── README.md # пользовательская документация (как запускать)
├── CODE_WALKTHROUGH.md # подробный разбор каждого файла с пояснениями
├── MLproject # MLflow Projects: entry points main/gpu/sweep
├── python_env.yaml # окружение для MLproject (python 3.10)
├── requirements.txt # pip-зависимости
├── setup_server.sh # установка окружения (РАЗВИЛКА macOS/Linux)
├── start_ui.sh # запуск MLflow Tracking Server (UI на :5555, file-store)
├── .gitignore
├── scripts/
│ └── setup_git.sh # настройка Git (HTTPS + токен)
├── src/ # ← весь код уроков здесь
│ ├── train_simple.py # Урок 1: sklearn + Tracking (ручное логирование)
│ ├── train_gpu.py # Урок 2: PyTorch CNN, CUDA→MPS→CPU
│ ├── register_model.py # Урок 3: Model Registry (версии, стадии)
│ ├── load_and_predict.py # Урок 4: загрузка модели + предсказание
│ ├── compare_runs.py # Урок 5: сравнение запусков через API
│ ├── autolog_demo.py # Урок 6: mlflow.autolog()
│ ├── hyperparam_sweep.py # Урок 7: перебор гиперпараметров вручную
│ ├── serve_model.py # Урок 8: REST API сервер модели (Flask)
│ └── grid_search_cv.py # Урок 9: GridSearchCV + autolog
├── data/ # датасеты (MNIST скачается автоматически)
├── artifacts/ # графики, отчёты (логируются в MLflow)
└── mlruns/ # локальный file store MLflow (в .gitignore)
```
---
## 🧩 Роль каждого файла (кратко)
| Файл | Что делает | Датасет | Фреймворк |
|---|---|---|---|
| `train_simple.py` | RandomForest, ручное логирование params/metrics/model/artifacts | digits | sklearn |
| `train_gpu.py` | CNN, логирование по эпохам, выбор устройства `get_device()` | MNIST | PyTorch |
| `register_model.py` | ищет лучший run по метрике, регистрирует модель, стадия Production | — | MLflow API |
| `load_and_predict.py` | грузит `models:/digits_rf_model/Production`, предсказывает | digits | MLflow |
| `compare_runs.py` | `MlflowClient.search_runs`, топ-N по метрике | — | MLflow API |
| `autolog_demo.py` | `mlflow.autolog()` для GradientBoosting | wine | sklearn |
| `hyperparam_sweep.py` | ручной перебор комбинаций, `--max-combos` | digits | sklearn |
| `serve_model.py` | Flask-сервер `/predict`, грузит модель из реестра | digits | Flask + MLflow |
| `grid_search_cv.py` | `GridSearchCV`, autolog залогит каждую попытку | digits | sklearn |
---
## 🖥️ Платформенная специфика
### Выбор устройства в `train_gpu.py`
Функция `get_device()` определяет устройство в порядке:
**CUDA → MPS (Apple Silicon) → CPU**. Тег MLflow `device_type` хранит выбор
(`cuda` / `mps` / `cpu`) — по нему удобно фильтровать запуски в UI.
### `num_workers` в DataLoader
На **macOS с MPS** многопроцессная загрузка нестабильна (fork + MPS),
поэтому `num_workers=0` на Darwin и `4` на Linux. `pin_memory=True`
только для CUDA. Логика — в `train_gpu.py` рядом с `DataLoader`.
### `setup_server.sh` — развивка по ОС
- `Darwin` → macOS: проверка Homebrew, `python@3.12` через brew, PyTorch
из PyPI (без CUDA), проверка `torch.backends.mps.is_available()`.
- `Linux` → GPU-сервер: `nvidia-smi`, PyTorch из
`--index-url https://download.pytorch.org/whl/cu121`, проверка VRAM.
При добавлении новой платформы — расширяй `case "$OS"` в начале скрипта.
---
## 🚀 Стандартные команды
```bash
# Окружение
bash setup_server.sh # установка (macOS или Linux)
source .venv/bin/activate
./start_ui.sh # MLflow UI → http://localhost:5555
# Уроки (в отдельном терминале, с активированным venv)
python src/train_simple.py --n-estimators 100 --max-depth 8
python src/train_gpu.py --epochs 5 --batch-size 128 --lr 0.001 # macOS/MPS
python src/register_model.py --experiment digits_classification
python src/load_and_predict.py
python src/compare_runs.py --experiment digits_classification --top 10
python src/autolog_demo.py
python src/hyperparam_sweep.py --max-combos 20
python src/serve_model.py --port 5001
python src/grid_search_cv.py
# Через MLflow Projects (изолированное окружение по MLproject)
mlflow run . -P n_estimators=100 -P max_depth=8
mlflow run . -P epochs=10 -P batch_size=256 -P lr=0.001 --entry-point gpu
```
---
## 🔧 Конвенции кода
- **Датасеты** — только встроенные (`load_digits`, `load_wine`, MNIST через
`torchvision`). Никаких внешних файлов в `data/`, кроме скачанного MNIST.
- **Логирование** — параметры/метрики/модель/артефакты. Графики сохраняются
в `artifacts/` и логируются через `mlflow.log_artifact`.
- **Имена экспериментов**: `digits_classification`, `mnist_cnn_gpu`,
`wine_autolog` и т.п. Модель в реестре — `digits_rf_model`.
- **`matplotlib.use("Agg")`** обязателен перед импортом pyplot — скрипты
работают без GUI (на сервере).
- **Python ≥ 3.10** (см. `python_env.yaml`). type hints опциональны.
- **CLI через `argparse`** — все скрипты с параметрами запускаются из CLI.
---
## ⚠️ Известные шероховатости / TODO
- ~~Нумерация уроков 8/9 расходилась между докстрингами и README/CODE_WALKTHROUGH~~ —
**исправлено**: канон `serve_model.py`=Урок 8 / `grid_search_cv.py`=Урок 9,
README.md и CODE_WALKTHROUGH.md выровнены под докстринги (порядок секций тоже).
- `requirements.txt` включает `torch`/`torchvision` из PyPI (CPU/MPS).
Для CUDA их нужно ставить отдельно через `--index-url .../whl/cu121`
(см. `setup_server.sh`, ветка Linux).
- **Порт MLflow UI = 5555** (не 5000): на macOS порт 5000 занят системным сервисом AirPlay Receiver (процесс `ControlCenter`), поэтому `start_ui.sh` использует 5555. На Linux можно вернуть 5000, но 5555 работает везде.
- **MLflow 3.x: `artifact_path` устарел** — во всех `log_model` используется `name=...` (был `artifact_path=...`). При обновлении/добавлении log_model не используй `artifact_path`.
- **Сигнатура модели**: `train_simple.py` логирует модель с явной `signature=` и `input_example=` (через `mlflow.models.signature.infer_signature`). Без этого MLflow пишет warning «Model logged without a signature and input example».
- `train_gpu.py` передаёт `input_example` → сигнатура авто-infer'ится.
- `autolog_demo.py` намеренно отключает сигнатуры (`log_model_signatures=False`) как демо опций autolog — там warning ожидаем.
- **INFO про переменные окружения** (`OPENAI_API_KEY` и т.п.) при логировании подавлено через `MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING=false` в скриптах, логирующих модели.
- **`input_example` для `mlflow.pytorch.log_model` — только numpy**, не `torch.Tensor`: MLflow 3.x валидирует пример входа и падает с `MlflowException ... but got '<class 'torch.Tensor'>'`. В `train_gpu.py` передаётся `np.random.rand(1,1,28,28).astype(np.float32)`. Аналогично для других flavor'ов — пример должен быть DataFrame/ndarray/dict/list/скаляр.
- **urllib3 `NotOpenSSLWarning`** на macOS (системный `ssl` собран с LibreSSL 2.8.3, urllib3 v2 просит OpenSSL 1.1.1+) — стороннее шумовое предупреждение, к проекту отношения не имеет; не подавляем (чтобы не прятать реальные предупреждения).
- **«Эксперимент не виден в UI» (главная причина)**: скрипты по умолчанию пишут в локальный file store `./mlruns`, а `start_ui.sh` раньше поднимал сервер на **SQLite** (`mlflow.db`) — два разных хранилища, UI ничего не показывал. Теперь `start_ui.sh` использует `--backend-store-uri file://${PWD}/mlruns`**то же хранилище, что и скрипты**, поэтому всё видно без настройки tracking URI. Альтернатива (SQLite-бэкенд) оставлена закомментированной в `start_ui.sh`; при её включении в скриптах нужно задать `mlflow.set_tracking_uri('http://localhost:5555')` (или `MLFLOW_TRACKING_URI`).
- `mlruns/`, `artifacts/*`, `data/MNIST/`, `mlflow.db` — в `.gitignore`,
в репозиторий не попадают.
---
## 🔄 Когда обновлять этот файл
**AGENTS.md — живой документ. Обновляй его при:**
1. **Добавлении/удалении файла в `src/`** — обнови карту проекта и таблицу ролей.
2. **Изменении платформенной логики** (новое устройство, новая ОС в
`setup_server.sh`, изменение `get_device()` или `num_workers`) — обнови
раздел «Платформенная специфика».
3. **Изменении имён экспериментов/моделей** в реестре — обнови «Конвенции».
4. **Изменении зависимостей или версии Python** (`requirements.txt`,
`python_env.yaml`) — проверь раздел команд и конвенции.
5. **Появлении новых entry points в `MLproject`** — добавь в карту и команды.
6. **Обнаружении gotcha/шероховатости** — занеси в «Известные шероховатости»,
чтобы следующий агент не наступал повторно.
Правило простое: **если ты что-то изменил в структуре/поведении проекта,
отрази это в AGENTS.md в том же коммите.** Если файл устарел относительно
кода — это баг, который нужно исправить.
---
## 📌 Шпаргалка по MLflow API (часто используемое в проекте)
```python
mlflow.set_experiment("name")
with mlflow.start_run(run_name="...") as run:
mlflow.log_param("k", v)
mlflow.log_metric("acc", 0.95, step=epoch)
mlflow.log_artifact("artifacts/curve.png")
mlflow.sklearn.log_model(model, "model") # sklearn flavor
mlflow.pytorch.log_model(model, "model") # pytorch flavor
mlflow.set_tag("device_type", "mps")
mlflow.autolog() # авто-логирование
# Model Registry
client = MlflowClient()
client.search_runs([experiment_id], order_by=["metrics.accuracy DESC"])
mlflow.register_model("runs:/<run_id>/model", "digits_rf_model")
client.transition_model_version_stage("digits_rf_model", version=1, stage="Production")
mlflow.pyfunc.load_model("models:/digits_rf_model/Production")
```
---
## 🧭 С чего начать новому агенту
1. Прочитай этот файл целиком.
2. Прочитай `README.md` (пользовательский взгляд) и `CODE_WALKTHROUGH.md`
(детальный разбор кода).
3. Прогони `bash setup_server.sh` на целевой платформе, чтобы понять
состояние окружения.
4. Перед правками проверь `git status` — в этом окружении `.git` может быть
read-only для агента (тогда коммит делает пользователь вручную).
5. Любое структурное изменение → коммит + правка AGENTS.md.
+19 -19
View File
@@ -23,8 +23,8 @@ mlflow-practice/
├── compare_runs.py # Урок 5: сравнение запусков через API
├── autolog_demo.py # Урок 6: autolog (автоматическое логирование)
├── hyperparam_sweep.py # Урок 7: перебор гиперпараметров (вручную)
├── grid_search_cv.py # Урок 8: GridSearchCV + autolog
└── serve_model.py # Урок 9: REST API сервер модели
├── serve_model.py # Урок 8: REST API сервер модели
└── grid_search_cv.py # Урок 9: GridSearchCV + autolog
```
---
@@ -379,23 +379,6 @@ CV делит train на 5 фолдов, обучает 5 раз, усредня
---
## 📄 grid_search_cv.py — GridSearchCV + autolog
```python
mlflow.sklearn.autolog(max_tuning_runs=20)
...
grid = GridSearchCV(estimator=..., param_grid=..., cv=5)
grid.fit(X_train, y_train)
```
MLflow autolog + GridSearchCV = **дерево запусков**:
- **Parent run** (`gridsearch_rf`) — главный, содержит лучшие параметры
- **Child runs** — каждая комбинация гиперпараметров отдельный run
В UI это видно как иерархия. Parent показывает итог, children — детали.
---
## 📄 serve_model.py — REST API
### Загрузка модели при старте
@@ -448,6 +431,23 @@ MLflow сам поднимает сервер с стандартным API. К
---
## 📄 grid_search_cv.py — GridSearchCV + autolog
```python
mlflow.sklearn.autolog(max_tuning_runs=20)
...
grid = GridSearchCV(estimator=..., param_grid=..., cv=5)
grid.fit(X_train, y_train)
```
MLflow autolog + GridSearchCV = **дерево запусков**:
- **Parent run** (`gridsearch_rf`) — главный, содержит лучшие параметры
- **Child runs** — каждая комбинация гиперпараметров отдельный run
В UI это видно как иерархия. Parent показывает итог, children — детали.
---
## 📄 MLproject — воспроизводимость
```yaml
+7 -7
View File
@@ -28,8 +28,8 @@ mlflow-practice/
├── compare_runs.py # Урок 5: сравнение через API
├── autolog_demo.py # Урок 6: autolog (автоматическое)
├── hyperparam_sweep.py # Урок 7: перебор гиперпараметров
├── grid_search_cv.py # Урок 8: GridSearchCV + autolog
└── serve_model.py # Урок 9: REST API сервер
├── serve_model.py # Урок 8: REST API сервер
└── grid_search_cv.py # Урок 9: GridSearchCV + autolog
```
---
@@ -52,7 +52,7 @@ pip install flask # для serve_model.py
```bash
./start_ui.sh
# → http://10.0.0.7:5000
# → http://10.0.0.7:5555
```
### 3. Уроки по порядку
@@ -81,12 +81,12 @@ python src/autolog_demo.py
# Урок 7 — Hyperparameter sweep (20 комбинаций)
python src/hyperparam_sweep.py --max-combos 20
# Урок 8 — GridSearchCV + autolog (parent/child runs)
python src/grid_search_cv.py
# Урок 9 — Model serving (REST API)
# Урок 8 — Model serving (REST API)
python src/serve_model.py --port 5001
# → curl http://localhost:5001/predict_random
# Урок 9 — GridSearchCV + autolog (parent/child runs)
python src/grid_search_cv.py
```
### 4. MLflow Projects (воспроизводимый запуск)
-148
View File
@@ -1,148 +0,0 @@
#!/bin/bash
# ============================================================
# Глобальная настройка Git (HTTPS + токен, без SSH)
# Запуск: bash scripts/setup_git.sh
# ============================================================
set -e
echo "🔧 Глобальная настройка Git"
echo "============================"
echo ""
if ! command -v git &> /dev/null; then
echo "❌ Git не установлен!"
echo " macOS: brew install git"
echo " Ubuntu: sudo apt install git"
exit 1
fi
echo "✅ Git: $(git --version)"
echo ""
# ─── 1. Имя и email ──────────────────────────────────────────
DEFAULT_NAME="admin"
DEFAULT_EMAIL="admin@e7.by"
GIT_NAME="${GIT_NAME:-$DEFAULT_NAME}"
GIT_EMAIL="${GIT_EMAIL:-$DEFAULT_EMAIL}"
read -p "Имя [$GIT_NAME]: " input_name
read -p "Email [$GIT_EMAIL]: " input_email
git config --global user.name "${input_name:-$GIT_NAME}"
git config --global user.email "${input_email:-$GIT_EMAIL}"
echo "✅ user.name = $(git config --global user.name)"
echo "✅ user.email = $(git config --global user.email)"
echo ""
# ─── 2. Базовые настройки ────────────────────────────────────
git config --global init.defaultBranch main
git config --global core.autocrlf input
git config --global core.safecrlf true
git config --global core.longpaths true
git config --global core.editor "vi"
git config --global core.pager "less -FRX"
git config --global color.ui auto
git config --global pull.rebase true
git config --global rebase.autoStash true
git config --global push.default current
echo "✅ Базовые настройки (main, rebase, colors, pager)"
# ─── 3. Алиасы ───────────────────────────────────────────────
git config --global alias.st "status -sb"
git config --global alias.co "checkout"
git config --global alias.br "branch"
git config --global alias.ci "commit"
git config --global alias.cm "commit -m"
git config --global alias.ca "commit --amend"
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.last "log -1 HEAD --stat"
git config --global alias.unstage "restore --staged"
git config --global alias.discard "checkout --"
git config --global alias.aliases "config --get-regexp ^alias\\."
echo "✅ Алиасы: git st, git lg, git ci, git cm, git co, git br, git ca, git last"
# ─── 4. Credential helper (хранение токенов) ─────────────────
# macOS: токен в Keychain (безопасно, шифруется)
# Linux: токен в ~/.git-credentials (файл, без шифрования)
if [[ "$OSTYPE" == "darwin"* ]]; then
git config --global credential.helper osxkeychain
echo "✅ credential.helper = osxkeychain (macOS Keychain)"
else
git config --global credential.helper store
chmod 600 ~/.git-credentials 2>/dev/null || true
echo "✅ credential.helper = store (~/.git-credentials, chmod 600)"
fi
# ─── 5. Настройка токена для git.e7.by ────────────────────────
echo ""
echo "🔐 Настройка токена для git.e7.by"
echo "--------------------------------------------"
echo " git.e7.by работает только через HTTPS + токен."
echo " Токен можно создать в: Profile → Settings → Access Tokens"
echo ""
read -p "Введите токен (или Enter чтобы пропустить): " TOKEN
if [ -n "$TOKEN" ]; then
# Сохраняем токен через credential helper
# Формат: https://username:token@host
echo "https://core:${TOKEN}@git.e7.by" | git credential-store store 2>/dev/null || \
echo "https://core:${TOKEN}@git.e7.by" >> ~/.git-credentials 2>/dev/null || true
# Для macOS Keychain — через security
if [[ "$OSTYPE" == "darwin"* ]]; then
# Записываем в Keychain напрямую
security add-internet-password -s git.e7.by -a core -w "$TOKEN" -r https 2>/dev/null || true
echo "✅ Токен сохранён в macOS Keychain (git.e7.by / core)"
else
chmod 600 ~/.git-credentials 2>/dev/null || true
echo "✅ Токен сохранён в ~/.git-credentials"
fi
# ВАЖНО: remote URL должен быть БЕЗ токена!
# Токен подхватится автоматически из credential helper
echo ""
echo "📌 Теперь добавляйте remote БЕЗ токена:"
echo " git remote add origin https://git.e7.by/sandbox/repo.git"
echo " git push -u origin main"
echo " (токен подставится автоматически из credential helper)"
else
echo "⏭️ Токен не введён — пропускаю"
echo " Добавьте позже:"
echo " echo 'https://core:ТОКЕН@git.e7.by' >> ~/.git-credentials # Linux"
echo " security add-internet-password -s git.e7.by -a core -w 'ТОКЕН' -r https # macOS"
fi
# ─── 6. Если репо уже есть — чистим токен из remote URL ──────
echo ""
echo "🧹 Проверка текущих remote на наличие токена в URL..."
CLEANED=0
for repo_remote in $(git remote 2>/dev/null); do
url=$(git remote get-url "$repo_remote" 2>/dev/null || true)
if echo "$url" | grep -q "://.*:.*@"; then
clean_url=$(echo "$url" | sed 's|://[^@]*@|://|')
git remote set-url "$repo_remote" "$clean_url"
echo "$repo_remote: токен удалён из URL"
echo " было: $url"
echo " стало: $clean_url"
CLEANED=1
fi
done
if [ "$CLEANED" -eq 0 ]; then
echo " ✅ Токенов в remote URL не найдено"
fi
# ─── 7. Финал ────────────────────────────────────────────────
echo ""
echo "============================================"
echo " ✅ Настройка завершена!"
echo "============================================"
echo ""
echo "📋 Глобальные настройки:"
echo "--------------------------------------------"
git config --global --list
echo "--------------------------------------------"
echo ""
echo "🧪 Проверка токена:"
echo " git ls-remote https://git.e7.by/sandbox/mlflow-practice.git"
echo " (если спросит пароль — токен не подхватился)"
+126 -40
View File
@@ -1,56 +1,134 @@
#!/bin/bash
# ============================================================
# Установка окружения MLflow на GPU-сервере (RTX 3090)
# Запускать НА СЕРВЕРЕ: bash setup_server.sh
# Установка окружения MLflow Practice
# Поддержка: Linux (GPU-сервер, RTX 3090) и macOS (Intel/Apple Silicon)
# Запускать: bash setup_server.sh
# ============================================================
set -e
echo "============================================"
echo " MLflow Practice — установка на сервере"
echo " MLflow Practice — установка окружения"
echo "============================================"
# --- Проверка GPU ---
echo ""
echo "📡 Проверка GPU..."
if command -v nvidia-smi &> /dev/null; then
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader
# --- Определение ОС ---
OS="$(uname -s)"
case "$OS" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) echo "❌ Неподдерживаемая ОС: $OS"; exit 1 ;;
esac
ARCH="$(uname -m)"
echo "📍 Платформа: $PLATFORM ($ARCH)"
# ============================================
# macOS — установка без CUDA (есть MPS на Apple Silicon)
# ============================================
if [ "$PLATFORM" = "macos" ]; then
echo ""
echo "🍎 macOS: подготовка системы..."
# --- Homebrew ---
if ! command -v brew &> /dev/null; then
echo "⚠️ Homebrew не найден. Установите его вручную:"
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
echo " и запустите скрипт снова."
exit 1
fi
echo " Homebrew: $(brew --version | head -1)"
# --- Python через brew (если нет) ---
if ! command -v python3 &> /dev/null; then
echo "📥 Устанавливаю python@3.13 через brew..."
brew install python@3.13
fi
echo "🐍 Python: $(python3.13 --version)"
# --- Создание venv ---
echo ""
echo "📦 Создание виртуального окружения venv..."
if [ ! -d ".venv" ]; then
python3.13 -m venv .venv
fi
source .venv/bin/activate
echo " Активировано: $(which python)"
# --- pip ---
echo ""
echo "⬆️ Обновление pip..."
pip install --upgrade pip wheel setuptools
# --- MLflow + базовые библиотеки ---
echo ""
echo "📥 Установка MLflow + scikit-learn + matplotlib..."
pip install mlflow scikit-learn pandas numpy matplotlib
# --- PyTorch (CPU на Intel, MPS на Apple Silicon) ---
echo ""
echo "🔥 Установка PyTorch (PyPI — без CUDA)..."
pip install torch torchvision
# --- Проверка PyTorch + устройства ---
echo ""
echo "✅ Проверка PyTorch..."
python3 -c "
import torch, platform
print(f'PyTorch: {torch.__version__}')
print(f'MPS available: {torch.backends.mps.is_available()}')
if torch.backends.mps.is_available():
print('Устройство: Apple Silicon GPU (MPS)')
else:
print('Устройство: CPU (Intel Mac)')
print('️ На macOS CUDA недоступна — train_gpu.py будет использовать CPU/MPS')
"
# ============================================
# Linux — GPU-сервер с CUDA (RTX 3090)
# ============================================
else
# --- Проверка GPU ---
echo ""
echo "📡 Проверка GPU..."
if command -v nvidia-smi &> /dev/null; then
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader
else
echo "⚠️ nvidia-smi не найден — CUDA может быть не установлена"
fi
fi
# --- Проверка Python ---
echo ""
echo "🐍 Проверка Python..."
python3 --version
# --- Проверка Python ---
echo ""
echo "🐍 Проверка Python..."
python3 --version
# --- Создание виртуального окружения ---
echo ""
echo "📦 Создание виртуального окружения venv..."
if [ ! -d ".venv" ]; then
# --- Создание venv ---
echo ""
echo "📦 Создание виртуального окружения venv..."
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
echo " Активировано: $(which python)"
fi
source .venv/bin/activate
echo " Активировано: $(which python)"
# --- Обновление pip ---
echo ""
echo "⬆️ Обновление pip..."
pip install --upgrade pip wheel setuptools
# --- pip ---
echo ""
echo "⬆️ Обновление pip..."
pip install --upgrade pip wheel setuptools
# --- Установка MLflow и базовых библиотек ---
echo ""
echo "📥 Установка MLflow + scikit-learn + matplotlib..."
pip install mlflow scikit-learn pandas numpy matplotlib
# --- MLflow + базовые библиотеки ---
echo ""
echo "📥 Установка MLflow + scikit-learn + matplotlib..."
pip install mlflow scikit-learn pandas numpy matplotlib
# --- Установка PyTorch под CUDA ---
echo ""
echo "🔥 Установка PyTorch (CUDA 12.1)..."
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
# --- PyTorch под CUDA 12.1 ---
echo ""
echo "🔥 Установка PyTorch (CUDA 12.1)..."
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
# --- Проверка CUDA ---
echo ""
echo "✅ Проверка PyTorch + CUDA..."
python3 -c "
# --- Проверка PyTorch + CUDA ---
echo ""
echo "✅ Проверка PyTorch + CUDA..."
python3 -c "
import torch
print(f'PyTorch: {torch.__version__}')
print(f'CUDA available: {torch.cuda.is_available()}')
@@ -58,15 +136,18 @@ if torch.cuda.is_available():
print(f'GPU: {torch.cuda.get_device_name(0)}')
print(f'VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB')
"
fi
# --- Проверка MLflow ---
# ============================================
# Общая проверка MLflow (обе платформы)
# ============================================
echo ""
echo "✅ Проверка MLflow..."
python3 -c "import mlflow; print(f'MLflow: {mlflow.__version__}')"
echo ""
echo "============================================"
echo " ✅ Установка завершена!"
echo " ✅ Установка завершена! ($PLATFORM)"
echo "============================================"
echo ""
echo "Следующие шаги:"
@@ -74,6 +155,11 @@ echo " 1. Активируйте окружение: source .venv/bin/activate
echo " 2. Запустите MLflow UI: ./start_ui.sh"
echo " 3. В другом терминале:"
echo " source .venv/bin/activate"
echo " python src/train_simple.py --n-estimators 100 --max-depth 8"
echo " python src/train_gpu.py --epochs 10 --batch-size 256 --lr 0.001"
if [ "$PLATFORM" = "macos" ]; then
echo " python src/train_simple.py --n-estimators 100 --max-depth 8"
echo " python src/train_gpu.py --epochs 3 --batch-size 64 --lr 0.01 # CPU/MPS, меньше эпох"
else
echo " python src/train_simple.py --n-estimators 100 --max-depth 8"
echo " python src/train_gpu.py --epochs 10 --batch-size 256 --lr 0.001"
fi
echo ""
+11 -1
View File
@@ -13,11 +13,15 @@
from sklearn.datasets import load_wine
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.metrics import accuracy_score, recall_score
import mlflow
import mlflow.sklearn
# Подавляем INFO MLflow о переменных окружения (напр. OPENAI_API_KEY) при логировании модели
import os
os.environ.setdefault("MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING", "false")
def main():
# ─── КЛЮЧЕВАЯ СТРОКА ──────────────────────────────────────
@@ -60,12 +64,18 @@ def main():
# Можно добавить и ручные метрики — они дополнят автолог
y_pred = model.predict(X_test)
acc = accuracy_score(y_test, y_pred)
recall_macro = recall_score(y_test, y_pred, average="macro")
recall_weighted = recall_score(y_test, y_pred, average="weighted")
mlflow.log_metric("manual_accuracy", acc)
mlflow.log_metric("manual_recall_macro", recall_macro)
mlflow.log_metric("manual_recall_weighted", recall_weighted)
print(f"\n✅ Готово! Откройте MLflow UI:")
print(f" Эксперимент: autolog_wine")
print(f" Run: {run.info.run_id}")
print(f" Accuracy: {acc:.4f}")
print(f" Recall (macro): {recall_macro:.4f}")
print(f" Recall (weighted):{recall_weighted:.4f}")
print(f"\n🔍 Что autolog залогировал автоматически:")
print(f" • Параметры: n_estimators, learning_rate, max_depth, ...")
print(f" • Метрики: training_accuracy, training_log_loss, ...")
+21 -5
View File
@@ -35,19 +35,35 @@ def main():
print("❌ Нет запусков!")
return
# Какие метрики показывать (показываем recall везде, где он есть)
metric_cols = [args.metric, "recall_macro", "recall_weighted",
"test_recall_macro", "test_recall_weighted",
"manual_recall_macro"]
print(f"📊 Топ-{len(runs)} запусков в '{args.experiment}' по {args.metric}:")
print(f"{'#':>3} | {'Run ID':>36} | {'accuracy':>9} | {'n_est':>6} | {'depth':>5}")
print("-" * 75)
header = f"{'#':>3} | {'Run ID':>20} | {'n_est':>6} | {'depth':>5}"
for m in metric_cols:
header += f" | {m[:14]:>14}"
print(header)
print("-" * len(header))
for i, run in enumerate(runs):
run_id = run.info.run_id
acc = run.data.metrics.get(args.metric, 0)
n_est = run.data.params.get("n_estimators", "")
depth = run.data.params.get("max_depth", "")
print(f"{i+1:>3} | {run_id:>36} | {acc:>9.4f} | {n_est:>6} | {depth:>5}")
row = f"{i+1:>3} | {run_id[:20]:>20} | {str(n_est):>6} | {str(depth):>5}"
for m in metric_cols:
v = run.data.metrics.get(m)
row += f" | {v:>14.4f}" if v is not None else f" | {'':>14}"
print(row)
best = runs[0]
print(f"\n🏆 Лучший: accuracy={best.data.metrics.get(args.metric, 0):.4f}")
print(f"\n🏆 Лучший по {args.metric}: {best.data.metrics.get(args.metric, 0):.4f}")
# Покажем recall лучшего, если он залогирован
for rm in ("recall_macro", "test_recall_macro", "manual_recall_macro"):
rv = best.data.metrics.get(rm)
if rv is not None:
print(f" {rm} = {rv:.4f}")
if __name__ == "__main__":
+18 -4
View File
@@ -10,11 +10,15 @@ MLflow autolog автоматически залогирует КАЖУЮ поп
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.metrics import accuracy_score
from sklearn.metrics import accuracy_score, recall_score
import mlflow
import mlflow.sklearn
# Подавляем INFO MLflow о переменных окружения (напр. OPENAI_API_KEY) при логировании модели
import os
os.environ.setdefault("MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING", "false")
def main():
# autolog для sklearn — залогирует все промежуточные попытки GridSearch
@@ -56,7 +60,8 @@ def main():
estimator=RandomForestClassifier(random_state=42, n_jobs=-1),
param_grid=param_grid,
cv=5,
scoring="accuracy",
scoring=["accuracy", "recall_macro"], # мульти-метрика: accuracy + recall
refit="accuracy", # лучший выбираем по accuracy
n_jobs=-1,
verbose=1,
)
@@ -66,14 +71,23 @@ def main():
best = grid.best_estimator_
y_pred = best.predict(X_test)
test_acc = accuracy_score(y_test, y_pred)
test_recall_macro = recall_score(y_test, y_pred, average="macro")
test_recall_weighted = recall_score(y_test, y_pred, average="weighted")
best_cv_recall = grid.cv_results_["mean_test_recall_macro"][grid.best_index_]
mlflow.log_param("best_params", str(grid.best_params_))
mlflow.log_metric("best_cv_score", grid.best_score_)
mlflow.log_metric("best_cv_score", grid.best_score_) # accuracy (refit)
mlflow.log_metric("best_cv_recall_macro", best_cv_recall)
mlflow.log_metric("test_accuracy", test_acc)
mlflow.log_metric("test_recall_macro", test_recall_macro)
mlflow.log_metric("test_recall_weighted", test_recall_weighted)
print(f"\n🏆 Лучшие параметры: {grid.best_params_}")
print(f" CV score: {grid.best_score_:.4f}")
print(f" CV accuracy: {grid.best_score_:.4f}")
print(f" CV recall_macro: {best_cv_recall:.4f}")
print(f" Test accuracy: {test_acc:.4f}")
print(f" Test recall_macro: {test_recall_macro:.4f}")
print(f" Test recall_weighted: {test_recall_weighted:.4f}")
print(f"\n📊 В MLflow UI:")
print(f" Эксперимент: gridsearch_digits")
print(f" Parent run: {run.info.run_id}")
+3 -1
View File
@@ -104,6 +104,7 @@ def main():
y_pred = model.predict(X_test)
test_acc = accuracy_score(y_test, y_pred)
test_recall = recall_score(y_test, y_pred, average="macro")
test_recall_weighted = recall_score(y_test, y_pred, average="weighted")
test_precision = precision_score(y_test, y_pred, average="macro")
test_f1 = f1_score(y_test, y_pred, average="macro")
@@ -112,6 +113,7 @@ def main():
mlflow.log_metric("cv_std_accuracy", cv_scores.std())
mlflow.log_metric("test_accuracy", test_acc)
mlflow.log_metric("test_recall_macro", test_recall)
mlflow.log_metric("test_recall_weighted", test_recall_weighted)
mlflow.log_metric("test_precision_macro", test_precision)
mlflow.log_metric("test_f1_macro", test_f1)
@@ -129,7 +131,7 @@ def main():
filled = int(bar_len * (i + 1) / len(combos))
bar = "" * filled + "" * (bar_len - filled)
print(f"\r [{bar}] {i+1}/{len(combos)} | "
f"acc={test_acc:.4f} | {run_name[:40]:<40}", end="")
f"acc={test_acc:.4f} rec={test_recall:.4f} | {run_name[:38]:<38}", end="")
print(f"\n\n{'='*60}")
print(f"🏆 Лучший результат:")
+18 -3
View File
@@ -29,10 +29,25 @@ def main():
print(" Сначала запустите train_simple.py и register_model.py")
return
# Делаем предсказание на нескольких образцах
# --- Качество на отложенной тестовой выборке (как в train_simple.py) ---
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, recall_score
digits = load_digits()
X, y = digits.data, digits.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
y_pred = model.predict(X_test)
acc = accuracy_score(y_test, y_pred)
recall_macro = recall_score(y_test, y_pred, average="macro")
recall_weighted = recall_score(y_test, y_pred, average="weighted")
print(f"\n📈 Качество загруженной модели на тесте ({len(y_test)} образов):")
print(f" Accuracy: {acc:.4f}")
print(f" Recall (macro): {recall_macro:.4f}")
print(f" Recall (weighted): {recall_weighted:.4f}")
# --- Демо: предсказание на нескольких случайных образцах ---
n_samples = 5
indices = np.random.choice(len(X), n_samples, replace=False)
samples = X[indices]
@@ -46,8 +61,8 @@ def main():
ok = "" if predictions[i] == true_labels[i] else ""
print(f"{i+1:>6} | {true_labels[i]:>8} | {predictions[i]:>14} | {ok}")
acc = np.mean(predictions == true_labels)
print(f"\nТочность на {n_samples} образцах: {acc:.2%}")
demo_acc = np.mean(predictions == true_labels)
print(f"\nТочность на {n_samples} образцах: {demo_acc:.2%}")
if __name__ == "__main__":
+6 -2
View File
@@ -1,8 +1,12 @@
"""
Урок 3: Model Registry — управление версиями моделей
=====================================================
Ищет лучший run по метрике accuracy, регистрирует модель
в Model Registry и переводит её в стадию Production.
Ищет лучший run по заданной метрике (по умолчанию accuracy),
регистрирует модель в Model Registry и переводит её в стадию Production.
Можно выбирать лучшую модель по recall:
python src/register_model.py --experiment digits_classification --metric recall_macro
python src/register_model.py --experiment sweep_digits --metric test_recall_macro
Запуск:
python src/register_model.py --experiment digits_classification
+59 -10
View File
@@ -2,11 +2,15 @@
Урок 2: MLflow + PyTorch на GPU (RTX 3090)
=============================================
Обучаем CNN на MNIST. Автоматически использует CUDA если доступна,
иначе — CPU. Все параметры, метрики и модель логируются в MLflow.
иначе — CPU. На Apple Silicon используется MPS.
Все параметры, метрики и модель логируются в MLflow.
Запуск на 3090:
Запуск на 3090 (CUDA):
python src/train_gpu.py --epochs 10 --batch-size 256 --lr 0.001
Запуск на Apple Silicon (MPS):
python src/train_gpu.py --epochs 5 --batch-size 128 --lr 0.001
Запуск на CPU (для теста):
python src/train_gpu.py --epochs 2 --batch-size 64 --lr 0.01
"""
@@ -17,15 +21,21 @@ import time
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from sklearn.metrics import recall_score
import mlflow
import mlflow.pytorch
# Подавляем INFO MLflow о переменных окружения (напр. OPENAI_API_KEY) при логировании модели
import os
os.environ.setdefault("MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING", "false")
# ─── Модель: простая CNN ───
class SimpleCNN(nn.Module):
@@ -52,6 +62,15 @@ class SimpleCNN(nn.Module):
return self.classifier(x)
def get_device() -> torch.device:
"""Выбор устройства: CUDA -> MPS (Apple Silicon) -> CPU."""
if torch.cuda.is_available():
return torch.device("cuda")
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
def main():
parser = argparse.ArgumentParser(description="MLflow + PyTorch GPU demo")
parser.add_argument("--epochs", type=int, default=5)
@@ -60,13 +79,16 @@ def main():
parser.add_argument("--experiment-name", type=str, default="mnist_cnn_gpu")
args = parser.parse_args()
# ─── Устройство ───
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# ─── Устройство: CUDA -> MPS -> CPU ───
device = get_device()
print(f"🖥️ Устройство: {device}")
gpu_name = None
if device.type == "cuda":
gpu_name = torch.cuda.get_device_name(0)
gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1e9
print(f" GPU: {gpu_name} ({gpu_mem:.1f} GB)")
elif device.type == "mps":
print(" Apple Silicon GPU (MPS)")
# ─── MLflow эксперимент ───
mlflow.set_experiment(args.experiment_name)
@@ -80,9 +102,22 @@ def main():
os.makedirs(data_dir, exist_ok=True)
train_ds = datasets.MNIST(data_dir, train=True, download=True, transform=transform)
test_ds = datasets.MNIST(data_dir, train=False, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, num_workers=4)
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, num_workers=4)
print(f"📊 Train: {len(train_ds)}, Test: {len(test_ds)}")
# num_workers: на macOS с MPS многопроцессная загрузка нестабильна
# (fork + MPS) → используем 0 воркеров на mac, 4 на Linux/CUDA.
import platform
num_workers = 0 if platform.system() == "Darwin" else 4
pin_memory = device.type == "cuda"
train_loader = DataLoader(
train_ds, batch_size=args.batch_size, shuffle=True,
num_workers=num_workers, pin_memory=pin_memory,
)
test_loader = DataLoader(
test_ds, batch_size=args.batch_size, shuffle=False,
num_workers=num_workers, pin_memory=pin_memory,
)
print(f"📊 Train: {len(train_ds)}, Test: {len(test_ds)} "
f"(num_workers={num_workers}, pin_memory={pin_memory})")
# ─── MLflow run ───
with mlflow.start_run(run_name=f"cnn_e{args.epochs}_bs{args.batch_size}") as run:
@@ -130,6 +165,7 @@ def main():
# ─── Валидация ───
model.eval()
correct, total = 0, 0
all_preds, all_targets = [], []
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
@@ -137,18 +173,28 @@ def main():
pred = output.argmax(dim=1)
correct += (pred == target).sum().item()
total += target.size(0)
all_preds.append(pred.cpu())
all_targets.append(target.cpu())
acc = correct / total
# Recall (macro) — полнота по всем классам MNIST
recall_macro = recall_score(
torch.cat(all_targets).numpy(),
torch.cat(all_preds).numpy(),
average="macro",
zero_division=0,
)
test_accs.append(acc)
elapsed = time.time() - t0
# Логируем метрики по эпохам
mlflow.log_metric("train_loss", avg_loss, step=epoch)
mlflow.log_metric("test_accuracy", acc, step=epoch)
mlflow.log_metric("test_recall_macro", recall_macro, step=epoch)
mlflow.log_metric("epoch_time_sec", elapsed, step=epoch)
print(f" → Epoch {epoch+1}: loss={avg_loss:.4f}, "
f"acc={acc:.4f}, time={elapsed:.1f}s")
f"acc={acc:.4f}, recall={recall_macro:.4f}, time={elapsed:.1f}s")
# ─── График обучения ───
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
@@ -166,17 +212,20 @@ def main():
plt.close(fig)
# ─── Логируем модель ───
# input_example — numpy ndarray (MLflow 3.x не принимает torch.Tensor).
# Форма как у входа модели: (batch, channels, H, W) = (1, 1, 28, 28).
mlflow.pytorch.log_model(
model,
artifact_path="model",
name="model", # name вместо устаревшего artifact_path
registered_model_name=None,
input_example=torch.randn(1, 1, 28, 28).to(device),
input_example=np.random.rand(1, 1, 28, 28).astype(np.float32),
)
# ─── Теги ───
mlflow.set_tag("model_type", "SimpleCNN")
mlflow.set_tag("framework", "PyTorch")
mlflow.set_tag("dataset", "MNIST")
mlflow.set_tag("device_type", device.type)
final_acc = test_accs[-1]
print(f"\n✅ Обучение завершено!")
+17 -3
View File
@@ -15,12 +15,17 @@ import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report, recall_score
from sklearn.model_selection import train_test_split
import mlflow
import mlflow.sklearn
# Подавляем INFO MLflow о переменных окружения (напр. OPENAI_API_KEY) при логировании модели
import os
os.environ.setdefault("MLFLOW_RECORD_ENV_VARS_IN_MODEL_LOGGING", "false")
from mlflow.models.signature import infer_signature
def main():
parser = argparse.ArgumentParser(description="MLflow + scikit-learn demo")
@@ -62,10 +67,16 @@ def main():
# --- Предсказание и метрики ---
y_pred = model.predict(X_test)
acc = accuracy_score(y_test, y_pred)
recall_macro = recall_score(y_test, y_pred, average="macro")
recall_weighted = recall_score(y_test, y_pred, average="weighted")
print(f"Accuracy: {acc:.4f}")
print(f"Recall (macro): {recall_macro:.4f}")
print(f"Recall (weighted): {recall_weighted:.4f}")
# Логируем метрики
mlflow.log_metric("accuracy", acc)
mlflow.log_metric("recall_macro", recall_macro)
mlflow.log_metric("recall_weighted", recall_weighted)
# можно логировать несколько шагов (для графиков в UI)
for i, tree in enumerate(model.estimators_):
tree_acc = accuracy_score(y_test, tree.predict(X_test))
@@ -92,10 +103,13 @@ def main():
f.write(report)
mlflow.log_artifact(report_path)
# --- Логируем саму модель ---
# --- Логируем саму модель (с сигнатурой и примером входа) ---
signature = infer_signature(X_test[:5], model.predict(X_test[:5]))
mlflow.sklearn.log_model(
model,
artifact_path="model",
name="model", # name вместо устаревшего artifact_path
signature=signature, # явная сигнатура модели
input_example=X_test[:5], # пример входа → убирает warning
registered_model_name=None, # регистрация — в отдельном скрипте
)
+27 -8
View File
@@ -1,21 +1,40 @@
#!/bin/bash
# ============================================================
# Запуск MLflow Tracking Server с SQLite backend
# Откройте в браузере: http://localhost:5000
# Запуск MLflow Tracking Server
# Откройте в браузере: http://localhost:5555
#
# Backend store = file://$(pwd)/mlruns (тот же каталог, куда скрипты
# пишут по умолчанию). Поэтому UI видит все эксперименты/запуски
# без доп. настройки tracking URI в скриптах.
#
# Альтернатива — SQLite-бэкенд (нужен, только если хотите SQL-запросы
# к запускам). Тогда раскомментируйте блок SQLITE ниже и закомментируйте
# FILE, а в скриптах задайте mlflow.set_tracking_uri("http://localhost:5555")
# (или export MLFLOW_TRACKING_URI=http://localhost:5555).
# ============================================================
set -e
cd "$(dirname "$0")"
PORT=5555
echo "🚀 Запускаем MLflow Tracking Server..."
echo " UI: http://localhost:5000"
echo " DB: sqlite:///mlflow.db"
echo " Artifacts: ./artifacts"
echo " UI: http://localhost:${PORT}"
echo " Store: file://${PWD}/mlruns"
echo " Artifacts: file://${PWD}/artifacts"
echo ""
echo " Нажмите Ctrl+C для остановки"
echo ""
# ─── FILE backend (по умолчанию — единое хранилище со скриптами) ───
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./artifacts \
--backend-store-uri "file://${PWD}/mlruns" \
--default-artifact-root "file://${PWD}/artifacts" \
--host 0.0.0.0 \
--port 5000
--port "${PORT}"
# ─── SQLITE backend (альтернатива — см. комментарий в шапке) ──────
# mlflow server \
# --backend-store-uri sqlite:///mlflow.db \
# --default-artifact-root ./artifacts \
# --host 0.0.0.0 \
# --port "${PORT}"