Уроки 6-9: autolog, hyperparam sweep, grid search, serving + MLproject, walkthrough
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Урок 6: MLflow Autolog — автоматическое логирование
|
||||
====================================================
|
||||
Вместо ручного log_param/log_metric можно вызвать mlflow.autolog()
|
||||
ОДИН раз — и MLflow сам залогирует всё: параметры, метрики, модель,
|
||||
признаки, даже feature importance.
|
||||
|
||||
Сравните с train_simple.py — там всё логировалось вручную.
|
||||
|
||||
Запуск:
|
||||
python src/autolog_demo.py
|
||||
"""
|
||||
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
|
||||
|
||||
import mlflow
|
||||
import mlflow.sklearn
|
||||
|
||||
|
||||
def main():
|
||||
# ─── КЛЮЧЕВАЯ СТРОКА ──────────────────────────────────────
|
||||
# autolog() включает автоматическое логирование для sklearn.
|
||||
# Доступно для: sklearn, pytorch, tensorflow, xgboost, lightgbm,
|
||||
# keras, fastai, spark, autogluon, statsmodels
|
||||
mlflow.sklearn.autolog(
|
||||
log_input_examples=False, # не логировать примеры входа
|
||||
log_model_signatures=False, # не строить сигнатуру модели
|
||||
log_models=True, # сохранить модель автоматически
|
||||
log_datasets=True, # залогировать датасет
|
||||
max_tuning_runs=10, # лимит для GridSearch
|
||||
log_post_training_metrics=True,
|
||||
)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
mlflow.set_experiment("autolog_wine")
|
||||
|
||||
# Датасет wine — классификация вин по 3 сортам
|
||||
wine = load_wine()
|
||||
X, y = wine.data, wine.target
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=0.3, random_state=42
|
||||
)
|
||||
print(f"📊 Датасет wine: {X.shape[0]} образцов, {X.shape[1]} признаков, 3 класса")
|
||||
|
||||
# autolog начинает работать автоматически внутри start_run
|
||||
with mlflow.start_run(run_name="gb_autolog") as run:
|
||||
print(f"MLflow run ID: {run.info.run_id}")
|
||||
|
||||
# Просто обучаем модель — НИКАКИХ log_param/log_metric!
|
||||
model = GradientBoostingClassifier(
|
||||
n_estimators=100,
|
||||
learning_rate=0.1,
|
||||
max_depth=3,
|
||||
random_state=42,
|
||||
)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# Можно добавить и ручные метрики — они дополнят автолог
|
||||
y_pred = model.predict(X_test)
|
||||
acc = accuracy_score(y_test, y_pred)
|
||||
mlflow.log_metric("manual_accuracy", acc)
|
||||
|
||||
print(f"\n✅ Готово! Откройте MLflow UI:")
|
||||
print(f" Эксперимент: autolog_wine")
|
||||
print(f" Run: {run.info.run_id}")
|
||||
print(f" Accuracy: {acc:.4f}")
|
||||
print(f"\n🔍 Что autolog залогировал автоматически:")
|
||||
print(f" • Параметры: n_estimators, learning_rate, max_depth, ...")
|
||||
print(f" • Метрики: training_accuracy, training_log_loss, ...")
|
||||
print(f" • Модель: сохранена в artifacts/model/")
|
||||
print(f" • Датасет: профиль в artifacts/")
|
||||
print(f" • Ручная метрика: manual_accuracy")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Урок 9: MLflow + GridSearchCV — автолог перебора
|
||||
==================================================
|
||||
sklearn GridSearchCV сам перебирает гиперпараметры.
|
||||
MLflow autolog автоматически залогирует КАЖУЮ попытку как отдельный run.
|
||||
|
||||
Запуск:
|
||||
python src/grid_search_cv.py
|
||||
"""
|
||||
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
|
||||
|
||||
import mlflow
|
||||
import mlflow.sklearn
|
||||
|
||||
|
||||
def main():
|
||||
# autolog для sklearn — залогирует все промежуточные попытки GridSearch
|
||||
mlflow.sklearn.autolog(
|
||||
log_models=False, # не сохранять каждую модель (экономим место)
|
||||
max_tuning_runs=20, # максимум залогированных под-запусков
|
||||
log_datasets=False,
|
||||
)
|
||||
|
||||
mlflow.set_experiment("gridsearch_digits")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# Сетка для перебора
|
||||
param_grid = {
|
||||
"n_estimators": [50, 100, 200],
|
||||
"max_depth": [4, 8, 12],
|
||||
"min_samples_split": [2, 5],
|
||||
}
|
||||
# 3 × 3 × 2 = 18 комбинаций × 5 фолдов = 90 обучений
|
||||
|
||||
print(f"🔬 GridSearchCV")
|
||||
print(f" Комбинаций: {len(param_grid['n_estimators'])} × "
|
||||
f"{len(param_grid['max_depth'])} × "
|
||||
f"{len(param_grid['min_samples_split'])} = "
|
||||
f"{np.prod([len(v) for v in param_grid.values()])}")
|
||||
print(f" CV фолдов: 5")
|
||||
print(f" Всего обучений: {np.prod([len(v) for v in param_grid.values()]) * 5}")
|
||||
print()
|
||||
|
||||
with mlflow.start_run(run_name="gridsearch_rf") as run:
|
||||
print(f"MLflow parent run: {run.info.run_id}")
|
||||
|
||||
grid = GridSearchCV(
|
||||
estimator=RandomForestClassifier(random_state=42, n_jobs=-1),
|
||||
param_grid=param_grid,
|
||||
cv=5,
|
||||
scoring="accuracy",
|
||||
n_jobs=-1,
|
||||
verbose=1,
|
||||
)
|
||||
grid.fit(X_train, y_train)
|
||||
|
||||
# Логируем лучший результат
|
||||
best = grid.best_estimator_
|
||||
y_pred = best.predict(X_test)
|
||||
test_acc = accuracy_score(y_test, y_pred)
|
||||
|
||||
mlflow.log_param("best_params", str(grid.best_params_))
|
||||
mlflow.log_metric("best_cv_score", grid.best_score_)
|
||||
mlflow.log_metric("test_accuracy", test_acc)
|
||||
|
||||
print(f"\n🏆 Лучшие параметры: {grid.best_params_}")
|
||||
print(f" CV score: {grid.best_score_:.4f}")
|
||||
print(f" Test accuracy: {test_acc:.4f}")
|
||||
print(f"\n📊 В MLflow UI:")
|
||||
print(f" Эксперимент: gridsearch_digits")
|
||||
print(f" Parent run: {run.info.run_id}")
|
||||
print(f" Child runs: {len(grid.cv_results_['params'])} под-запусков")
|
||||
print(f" Каждый child — отдельная комбинация гиперпараметров")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import numpy as np
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Урок 7: Hyperparameter Sweep — перебор гиперпараметров
|
||||
========================================================
|
||||
Запускает множество комбинаций гиперпараметров и логирует каждый
|
||||
вариант в MLflow. В UI можно сравнить все варианты и найти лучший.
|
||||
|
||||
Запуск:
|
||||
python src/hyperparam_sweep.py
|
||||
python src/hyperparam_sweep.py --max-combos 50 # больше комбинаций
|
||||
"""
|
||||
import argparse
|
||||
import itertools
|
||||
import random
|
||||
import time
|
||||
|
||||
from sklearn.datasets import load_digits
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.model_selection import cross_val_score
|
||||
from sklearn.metrics import recall_score, precision_score, f1_score, accuracy_score
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
import mlflow
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MLflow hyperparameter sweep")
|
||||
parser.add_argument("--max-combos", type=int, default=20,
|
||||
help="максимум комбинаций для перебора")
|
||||
parser.add_argument("--cv-folds", type=int, default=5,
|
||||
help="фолдов кросс-валидации")
|
||||
parser.add_argument("--experiment-name", type=str, default="sweep_digits")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ─── Сетка гиперпараметров ───────────────────────────────
|
||||
param_grid = {
|
||||
"n_estimators": [50, 100, 150, 200, 300],
|
||||
"max_depth": [4, 6, 8, 12, 16, None],
|
||||
"min_samples_split": [2, 5, 10],
|
||||
"min_samples_leaf": [1, 2, 4],
|
||||
"max_features": ["sqrt", "log2", None],
|
||||
}
|
||||
|
||||
# Генерируем все комбинации, берём случайные max_combos
|
||||
all_combos = list(itertools.product(
|
||||
param_grid["n_estimators"],
|
||||
param_grid["max_depth"],
|
||||
param_grid["min_samples_split"],
|
||||
param_grid["min_samples_leaf"],
|
||||
param_grid["max_features"],
|
||||
))
|
||||
random.seed(42)
|
||||
random.shuffle(all_combos)
|
||||
combos = all_combos[:args.max_combos]
|
||||
|
||||
print(f"🔬 Hyperparameter Sweep")
|
||||
print(f" Всего возможных комбинаций: {len(all_combos)}")
|
||||
print(f" Будет запущено: {len(combos)}")
|
||||
print(f" CV фолдов: {args.cv_folds}")
|
||||
print(f" Эксперимент: {args.experiment_name}")
|
||||
print()
|
||||
|
||||
# ─── Данные ──────────────────────────────────────────────
|
||||
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
|
||||
)
|
||||
|
||||
mlflow.set_experiment(args.experiment_name)
|
||||
|
||||
best_acc = 0.0
|
||||
best_run_id = None
|
||||
best_params = None
|
||||
|
||||
for i, (n_est, depth, min_split, min_leaf, max_feat) in enumerate(combos):
|
||||
params = {
|
||||
"n_estimators": n_est,
|
||||
"max_depth": depth,
|
||||
"min_samples_split": min_split,
|
||||
"min_samples_leaf": min_leaf,
|
||||
"max_features": max_feat,
|
||||
}
|
||||
|
||||
# Описание для имени run
|
||||
depth_str = str(depth) if depth else "None"
|
||||
feat_str = str(max_feat) if max_feat else "None"
|
||||
run_name = f"rf_ne{n_est}_d{depth_str}_ms{min_split}_ml{min_leaf}_mf{feat_str}"
|
||||
|
||||
with mlflow.start_run(run_name=run_name) as run:
|
||||
# Логируем ВСЕ параметры
|
||||
mlflow.log_params(params)
|
||||
|
||||
# Обучаем
|
||||
model = RandomForestClassifier(
|
||||
random_state=42, n_jobs=-1, **params
|
||||
)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# Кросс-валидация (более надёжная оценка)
|
||||
cv_scores = cross_val_score(model, X_train, y_train,
|
||||
cv=args.cv_folds, scoring="accuracy")
|
||||
|
||||
# Метрики на тесте
|
||||
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_precision = precision_score(y_test, y_pred, average="macro")
|
||||
test_f1 = f1_score(y_test, y_pred, average="macro")
|
||||
|
||||
# Логируем метрики
|
||||
mlflow.log_metric("cv_mean_accuracy", cv_scores.mean())
|
||||
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_precision_macro", test_precision)
|
||||
mlflow.log_metric("test_f1_macro", test_f1)
|
||||
|
||||
# Тег — номер комбинации
|
||||
mlflow.set_tag("combo_index", str(i))
|
||||
|
||||
# Отслеживаем лучший
|
||||
if test_acc > best_acc:
|
||||
best_acc = test_acc
|
||||
best_run_id = run.info.run_id
|
||||
best_params = params
|
||||
|
||||
# Прогресс
|
||||
bar_len = 30
|
||||
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="")
|
||||
|
||||
print(f"\n\n{'='*60}")
|
||||
print(f"🏆 Лучший результат:")
|
||||
print(f" Run ID: {best_run_id}")
|
||||
print(f" Accuracy: {best_acc:.4f}")
|
||||
print(f" Параметры: {best_params}")
|
||||
print(f"\n📊 Откройте MLflow UI:")
|
||||
print(f" Эксперимент: {args.experiment_name}")
|
||||
print(f" Отсортируйте по test_accuracy (клик на заголовок колонки)")
|
||||
print(f" Сравните cv_mean_accuracy vs test_accuracy")
|
||||
print(f" если cv_std_accuracy высокий — модель нестабильна")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Урок 8: Model Serving — разворачивание модели как REST API
|
||||
============================================================
|
||||
Загружает модель из Model Registry и запускает HTTP-сервер.
|
||||
Модель принимает JSON-запросы и возвращает предсказания.
|
||||
|
||||
Два способа:
|
||||
1. Встроенный MLflow server (простой):
|
||||
mlflow models serve -m models:/digits_rf_model/Production -p 5001
|
||||
|
||||
2. Этот скрипт — кастомный сервер с дополнительной логикой:
|
||||
|
||||
Запуск:
|
||||
python src/serve_model.py --port 5001
|
||||
|
||||
Запрос:
|
||||
curl -X POST http://localhost:5001/predict \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"instances": [[0,0,0,...,0]]}'
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
from sklearn.datasets import load_digits
|
||||
|
||||
import mlflow
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
|
||||
def create_app(model_name, stage):
|
||||
app = Flask(__name__)
|
||||
|
||||
# Загружаем модель один раз при старте
|
||||
print(f"📥 Загружаем модель '{model_name}' ({stage})...")
|
||||
model = mlflow.sklearn.load_model(f"models:/{model_name}/{stage}")
|
||||
print(f"✅ Модель загружена!")
|
||||
|
||||
# Загружаем датасет для демо-генерации примеров
|
||||
digits = load_digits()
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "model": model_name, "stage": stage})
|
||||
|
||||
@app.route("/info", methods=["GET"])
|
||||
def info():
|
||||
return jsonify({
|
||||
"model": model_name,
|
||||
"stage": stage,
|
||||
"n_features": 64,
|
||||
"classes": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
"endpoints": ["/health", "/info", "/predict", "/predict_random"],
|
||||
})
|
||||
|
||||
@app.route("/predict", methods=["POST"])
|
||||
def predict():
|
||||
"""Предсказание для переданных образцов.
|
||||
Формат запроса:
|
||||
{"instances": [[...64 числа...], [...64 числа...]]}
|
||||
"""
|
||||
data = request.get_json(force=True)
|
||||
|
||||
if "instances" not in data:
|
||||
return jsonify({"error": "поле 'instances' обязательно"}), 400
|
||||
|
||||
instances = np.array(data["instances"])
|
||||
|
||||
if instances.shape[-1] != 64:
|
||||
return jsonify({
|
||||
"error": f"ожидалось 64 признака, получено {instances.shape[-1]}"
|
||||
}), 400
|
||||
|
||||
predictions = model.predict(instances)
|
||||
|
||||
# Если модель поддерживает predict_proba — добавим вероятности
|
||||
try:
|
||||
probabilities = model.predict_proba(instances)
|
||||
return jsonify({
|
||||
"predictions": predictions.tolist(),
|
||||
"probabilities": probabilities.tolist(),
|
||||
"n_samples": len(predictions),
|
||||
})
|
||||
except AttributeError:
|
||||
return jsonify({
|
||||
"predictions": predictions.tolist(),
|
||||
"n_samples": len(predictions),
|
||||
})
|
||||
|
||||
@app.route("/predict_random", methods=["GET"])
|
||||
def predict_random():
|
||||
"""Демо-эндпоинт: берёт случайный образец из digits и предсказывает."""
|
||||
idx = np.random.randint(0, len(digits.data))
|
||||
sample = digits.data[idx:idx+1]
|
||||
true_label = int(digits.target[idx])
|
||||
prediction = int(model.predict(sample)[0])
|
||||
|
||||
try:
|
||||
probs = model.predict_proba(sample)[0]
|
||||
confidence = float(max(probs))
|
||||
except AttributeError:
|
||||
confidence = None
|
||||
|
||||
return jsonify({
|
||||
"true_label": true_label,
|
||||
"prediction": prediction,
|
||||
"correct": true_label == prediction,
|
||||
"confidence": confidence,
|
||||
"sample_image": sample[0].reshape(8, 8).tolist(),
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MLflow model serving")
|
||||
parser.add_argument("--model-name", default="digits_rf_model")
|
||||
parser.add_argument("--stage", default="Production")
|
||||
parser.add_argument("--port", type=int, default=5001)
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(args.model_name, args.stage)
|
||||
|
||||
print(f"\n🚀 Сервер запускается на http://{args.host}:{args.port}")
|
||||
print(f" /health — проверка состояния")
|
||||
print(f" /info — информация о модели")
|
||||
print(f" /predict — предсказание (POST JSON)")
|
||||
print(f" /predict_random — случайный образец (GET)")
|
||||
print(f"\n📋 Примеры запросов:")
|
||||
print(f" curl http://localhost:{args.port}/health")
|
||||
print(f" curl http://localhost:{args.port}/predict_random")
|
||||
print()
|
||||
|
||||
app.run(host=args.host, port=args.port, debug=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,7 +18,6 @@ import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import torch
|
||||
from sklearn.metrics import recall_score, precision_score, f1_score
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
from torch.utils.data import DataLoader
|
||||
@@ -141,27 +140,11 @@ def main():
|
||||
|
||||
acc = correct / total
|
||||
test_accs.append(acc)
|
||||
|
||||
# Recall / Precision / F1 (macro-усреднение по 10 классам)
|
||||
all_preds, all_labels = [], []
|
||||
with torch.no_grad():
|
||||
for data, target in test_loader:
|
||||
data, target = data.to(device), target.to(device)
|
||||
output = model(data)
|
||||
pred = output.argmax(dim=1)
|
||||
all_preds.extend(pred.cpu().numpy())
|
||||
all_labels.extend(target.cpu().numpy())
|
||||
recall = recall_score(all_labels, all_preds, average="macro")
|
||||
precision = precision_score(all_labels, all_preds, average="macro")
|
||||
f1 = f1_score(all_labels, all_preds, average="macro")
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Логируем метрики по эпохам
|
||||
mlflow.log_metric("train_loss", avg_loss, step=epoch)
|
||||
mlflow.log_metric("test_accuracy", acc, step=epoch)
|
||||
mlflow.log_metric("recall_macro", recall, step=epoch)
|
||||
mlflow.log_metric("precision_macro", precision, step=epoch)
|
||||
mlflow.log_metric("f1_macro", f1, step=epoch)
|
||||
mlflow.log_metric("epoch_time_sec", elapsed, step=epoch)
|
||||
|
||||
print(f" → Epoch {epoch+1}: loss={avg_loss:.4f}, "
|
||||
@@ -186,7 +169,6 @@ def main():
|
||||
mlflow.pytorch.log_model(
|
||||
model,
|
||||
artifact_path="model",
|
||||
serialization_format="pickle",
|
||||
registered_model_name=None,
|
||||
input_example=torch.randn(1, 1, 28, 28).to(device),
|
||||
)
|
||||
|
||||
+1
-4
@@ -15,7 +15,7 @@ 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, recall_score, precision_score, f1_score
|
||||
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
import mlflow
|
||||
@@ -66,9 +66,6 @@ def main():
|
||||
|
||||
# Логируем метрики
|
||||
mlflow.log_metric("accuracy", acc)
|
||||
mlflow.log_metric("recall_macro", recall_score(y_test, y_pred, average="macro"))
|
||||
mlflow.log_metric("precision_macro", precision_score(y_test, y_pred, average="macro"))
|
||||
mlflow.log_metric("f1_macro", f1_score(y_test, y_pred, average="macro"))
|
||||
# можно логировать несколько шагов (для графиков в UI)
|
||||
for i, tree in enumerate(model.estimators_):
|
||||
tree_acc = accuracy_score(y_test, tree.predict(X_test))
|
||||
|
||||
Reference in New Issue
Block a user