Experiment Tracking con MLflow: Guida Completa
Hai mai perso ore a cercare quale combinazione di iperparametri aveva prodotto quel risultato eccellente di tre settimane fa? O ti sei trovato a chiederti perchè il modello in produzione si comporta diversamente da quello che avevi testato in locale? Questi problemi, comunissimi nel ciclo di vita del machine learning, hanno una radice comune: la mancanza di un sistema strutturato di experiment tracking.
MLflow e la risposta open-source più diffusa a questo problema. Nato in Databricks nel 2018 e diventato un progetto Apache nel 2019, MLflow si e affermato come lo standard de facto per il tracking degli esperimenti ML nell'ecosistema Python. Con il rilascio di MLflow 3 nel giugno 2025 durante il Databricks Data + AI Summit, la piattaforma ha fatto un salto evolutivo significativo: da strumento di tracking a piattaforma unificata per sviluppo, valutazione e deployment di modelli ML e GenAI, con LoggedModel come entità di prima classe e performance di logging migliorate del 25% rispetto alla versione 2.5.
In questa guida vedremo MLflow end-to-end: dall'installazione al tracking avanzato, dall'autologging al Model Registry, fino al serving dei modelli e all'integrazione con Docker. Ogni esempio e testato e pronto all'uso in produzione.
Cosa Imparerai in Questo Articolo
- Architettura MLflow: tracking server, backend store, artifact store e le novità di MLflow 3
- Setup locale e produzione: SQLite, PostgreSQL, S3 come artifact store
- Experiment tracking completo: log params, metrics, artifacts, tags e nested runs
- Autologging: integrazione zero-config con scikit-learn, XGBoost, PyTorch, TensorFlow
- Model Registry: staging, production, archivio e gestione del ciclo di vita dei modelli
- Model Serving con MLflow: REST API, Docker container, integrazione FastAPI
- MLflow con Docker Compose per deployment in produzione
- Confronto con alternative: W&B, Neptune, ClearML - quando scegliere cosa
- Best practices e anti-patterns per team ML di ogni dimensione
La Serie MLOps e Machine Learning in Produzione
| # | Articolo | Focus |
|---|---|---|
| 1 | MLOps: Da Esperimento a Produzione | Fondamenta e lifecycle completo |
| 2 | Pipeline ML con CI/CD | GitHub Actions e Docker per ML |
| 3 | Versioning DVC vs LakeFS | Dataset e model versioning |
| 4 | Sei qui - Experiment Tracking con MLflow | Tracking, registry, serving |
| 5 | Model Drift Detection | Monitoraggio e retraining automatico |
| 6 | Serving con FastAPI + Uvicorn | Deployment modelli in produzione |
| 7 | Scaling ML su Kubernetes | KubeFlow e Seldon Core |
| 8 | A/B Testing di Modelli ML | Metodologia e implementazione |
| 9 | Governance ML | Compliance, AI Act EU, ethics |
| 10 | Case Study: Churn Prediction | Pipeline end-to-end in produzione |
Architettura MLflow: I Quattro Componenti Fondamentali
Prima di scrivere una riga di codice, e importante capire i building block di MLflow. La piattaforma si compone di quattro componenti principali, ciascuno con un ruolo specifico nel ciclo di vita del machine learning:
- MLflow Tracking: l'API e la UI per loggare e interrogare esperimenti. Registra parametri, metriche, tag, artefatti e note per ogni run di training.
- MLflow Projects: formato per impacchettare codice ML in run riproducibili, con gestione delle dipendenze e dell'ambiente tramite Conda o Docker.
- MLflow Models: formato standard per salvare modelli in modo che possano essere serviti da più framework (Python function, REST API, Spark UDF, ecc.).
- MLflow Model Registry: store centralizzato per gestire il ciclo di vita dei modelli: versioning, staging, produzione, archiviazione e audit trail.
Con MLflow 3 (2025) si aggiunge un quinto elemento fondamentale: il concetto di LoggedModel come entità di prima classe. Invece del precedente approccio run-centrico (dove il modello era solo un artefatto di un run), i LoggedModel persistono attraverso multipli run, ambienti e deployment, con lineage completa verso parametri, metriche, tracce e dati di valutazione.
Architettura di Storage MLflow
| Componente | Cosa Contiene | Backend Consigliato |
|---|---|---|
| Backend Store | Parametri, metriche, tag, metadata dei run | PostgreSQL / MySQL (produzione), SQLite (locale) |
| Artifact Store | File del modello, immagini, CSV, dati di valutazione | S3, GCS, Azure Blob (produzione), filesystem locale |
| Tracking Server | API REST per logging e UI web | Container Docker / EC2 / Kubernetes pod |
| Model Registry | Versioni modelli, stage, annotazioni, webhooks | Richiede database (non funziona con file system) |
Setup MLflow: Da Locale a Produzione
Installazione e Setup Locale
L'installazione base di MLflow richiede un singolo comando pip. Per development locale, MLflow usa SQLite come backend store e il filesystem locale come artifact store: nessun server aggiuntivo necessario.
# Installazione MLflow (versione 2.x/3.x)
pip install mlflow
# Con extras per integrazioni specifiche
pip install mlflow[extras] # scikit-learn, XGBoost, LightGBM
pip install mlflow[databricks] # integrazione Databricks
pip install mlflow[genai] # tools per GenAI e LLM (MLflow 3+)
# Verifica installazione
mlflow --version
# mlflow, version 2.x.x o 3.x.x
# Avvia la UI locale (usa ./mlruns come storage)
mlflow ui
# UI disponibile su http://localhost:5000
# Avvia con database SQLite
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./mlruns \
--host 0.0.0.0 \
--port 5000
Setup Produzione: PostgreSQL + S3
Per ambienti di produzione con più utenti e concorrenza elevata, e fondamentale usare un database relazionale come backend e un object storage come artifact store. Il Model Registry di MLflow richiede obbligatoriamente un database (non funziona con file system):
# ==================== docker-compose.yml ====================
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: mlflow
POSTGRES_USER: mlflow
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mlflow"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
mlflow:
image: ghcr.io/mlflow/mlflow:v2.19.0
depends_on:
postgres:
condition: service_healthy
environment:
MLFLOW_BACKEND_STORE_URI: postgresql://mlflow:${POSTGRES_PASSWORD}@postgres:5432/mlflow
MLFLOW_ARTIFACT_ROOT: s3://mlflow-artifacts/
AWS_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY}
MLFLOW_S3_ENDPOINT_URL: http://minio:9000
command: >
mlflow server
--backend-store-uri postgresql://mlflow:${POSTGRES_PASSWORD}@postgres:5432/mlflow
--default-artifact-root s3://mlflow-artifacts/
--host 0.0.0.0
--port 5000
ports:
- "5000:5000"
volumes:
postgres_data:
minio_data:
# .env file (NON commitare in Git!)
POSTGRES_PASSWORD=sicura_password_123
MINIO_ACCESS_KEY=minio_admin
MINIO_SECRET_KEY=minio_password_sicura
# Avvio dello stack completo
docker-compose up -d
# Verifica che MLflow sia in ascolto
curl http://localhost:5000/health
# {"status": "OK"}
# Crea il bucket MinIO per gli artefatti
docker exec -it minio_container mc alias set local http://localhost:9000 admin password
docker exec -it minio_container mc mb local/mlflow-artifacts
Budget <5K EUR/anno per PMI
Per team con budget limitato, questa configurazione con Docker Compose su una singola VM costa circa 180 EUR/anno (EC2 t3.small o equivalente). MinIO sostituisce S3 in locale con API completamente compatibili. Per storage persistente si può usare anche AWS S3 (circa 2-5 EUR/mese per pochi GB di artefatti). Il risparmio rispetto a piattaforme SaaS come W&B Teams (50+ USD/utente/mese) e significativo: un team di 5 persone risparmia oltre 2500 EUR/anno.
Experiment Tracking: Log Params, Metrics e Artifacts
Il cuore di MLflow e l'API di tracking. Ogni chiamata a mlflow.start_run()
crea un nuovo run all'interno di un experiment. Un
experiment raggruppa run correlati (es. tutti i run per il modello di churn prediction).
I run loggano quattro tipi di dati:
- Parameters: valori fissi per il run (iperparametri, configurazione)
- Metrics: valori numerici che possono variare nel tempo (loss, accuracy per epoch)
- Artifacts: file arbitrari (modelli, immagini, dataset, report HTML)
- Tags: metadati chiave-valore per annotare e filtrare i run
import mlflow
import mlflow.sklearn
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import (
accuracy_score, f1_score, roc_auc_score,
confusion_matrix, classification_report, RocCurveDisplay
)
from sklearn.model_selection import train_test_split
import os
# ==================== Configurazione MLflow ====================
# Connessione al tracking server (locale o remoto)
mlflow.set_tracking_uri("http://localhost:5000")
# Crea o usa un experiment esistente
experiment_name = "churn-prediction-gbm"
mlflow.set_experiment(experiment_name)
# Ottieni informazioni sull'experiment
experiment = mlflow.get_experiment_by_name(experiment_name)
print(f"Experiment ID: {experiment.experiment_id}")
# ==================== Training con Tracking Completo ====================
def train_churn_model(X_train, X_val, y_train, y_val, params: dict) -> str:
"""
Allena un GBM per churn prediction con tracking MLflow completo.
Restituisce il run_id del run MLflow.
"""
with mlflow.start_run(run_name=f"gbm-lr{params['learning_rate']}-depth{params['max_depth']}") as run:
# ---- 1. TAG: metadata del run ----
mlflow.set_tags({
"team": "ml-engineering",
"project": "churn-prediction",
"dataset_version": "v2.1",
"git_commit": os.popen("git rev-parse HEAD").read().strip(),
"environment": "dev",
})
# ---- 2. PARAMS: iperparametri e configurazione ----
mlflow.log_params(params)
mlflow.log_params({
"train_size": len(X_train),
"val_size": len(X_val),
"n_features": X_train.shape[1],
"target_positive_rate": float(y_train.mean()),
})
# ---- 3. TRAINING ----
model = GradientBoostingClassifier(**params)
model.fit(X_train, y_train)
# ---- 4. METRICS: step-by-step durante training ----
# Logga la loss per ogni stage del GBM (equivalente all'epoch loss)
train_scores = list(model.staged_predict(X_train))
val_scores = list(model.staged_predict(X_val))
for step, (tr_pred, val_pred) in enumerate(zip(train_scores, val_scores)):
train_acc = accuracy_score(y_train, tr_pred)
val_acc = accuracy_score(y_val, val_pred)
mlflow.log_metrics({
"train_accuracy_step": train_acc,
"val_accuracy_step": val_acc,
}, step=step)
# ---- 5. METRICS FINALI ----
y_pred = model.predict(X_val)
y_prob = model.predict_proba(X_val)[:, 1]
final_metrics = {
"accuracy": accuracy_score(y_val, y_pred),
"f1_score": f1_score(y_val, y_pred),
"auc_roc": roc_auc_score(y_val, y_prob),
"precision": float(np.mean(y_pred[y_pred == 1] == y_val[y_pred == 1])) if sum(y_pred) > 0 else 0.0,
"recall": float(sum((y_pred == 1) & (y_val == 1)) / sum(y_val == 1)),
}
mlflow.log_metrics(final_metrics)
# ---- 6. ARTIFACTS: file del modello e report ----
# Salva e logga confusion matrix come immagine
fig, ax = plt.subplots(figsize=(6, 5))
cm = confusion_matrix(y_val, y_pred)
im = ax.imshow(cm, interpolation='nearest', cmap='Blues')
ax.set_title('Confusion Matrix - Churn Prediction')
ax.set_xlabel('Predicted')
ax.set_ylabel('True')
plt.colorbar(im)
plt.tight_layout()
mlflow.log_figure(fig, "confusion_matrix.png")
plt.close()
# Salva ROC curve
fig2, ax2 = plt.subplots(figsize=(6, 5))
RocCurveDisplay.from_predictions(y_val, y_prob, ax=ax2)
ax2.set_title('ROC Curve - Churn Model')
mlflow.log_figure(fig2, "roc_curve.png")
plt.close()
# Logga classification report come file di testo
report = classification_report(y_val, y_pred, target_names=["No Churn", "Churn"])
mlflow.log_text(report, "classification_report.txt")
# Logga feature importance come CSV
feature_imp = pd.DataFrame({
"feature": X_train.columns.tolist(),
"importance": model.feature_importances_
}).sort_values("importance", ascending=False)
mlflow.log_table(feature_imp.to_dict(orient="list"), "feature_importance.json")
# ---- 7. LOG MODEL: salva il modello con firma e input example ----
input_example = X_val.head(3)
signature = mlflow.models.infer_signature(X_val, y_pred)
mlflow.sklearn.log_model(
sk_model=model,
artifact_path="model",
signature=signature,
input_example=input_example,
registered_model_name="churn-gbm-model", # Registra automaticamente nel Registry
)
print(f"Run ID: {run.info.run_id}")
print(f"Accuracy: {final_metrics['accuracy']:.4f}")
print(f"AUC-ROC: {final_metrics['auc_roc']:.4f}")
print(f"UI: http://localhost:5000/#/experiments/{experiment.experiment_id}/runs/{run.info.run_id}")
return run.info.run_id
# ==================== Esempio di utilizzo ====================
if __name__ == "__main__":
# Dati sintetici per demo
from sklearn.datasets import make_classification
X, y = make_classification(n_samples=10000, n_features=20, n_informative=10, random_state=42)
X_df = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(20)])
X_train, X_val, y_train, y_val = train_test_split(X_df, y, test_size=0.2, random_state=42)
params = {
"n_estimators": 300,
"learning_rate": 0.05,
"max_depth": 4,
"subsample": 0.8,
"min_samples_split": 20,
"random_state": 42,
}
run_id = train_churn_model(X_train, X_val, y_train, y_val, params)
Nested Runs e Hyperparameter Search
MLflow supporta i nested runs: run figli all'interno di un run padre. Questo pattern e ideale per hyperparameter search (Optuna, GridSearchCV) dove si vuole un run padre che rappresenta la ricerca complessiva e tanti run figli, uno per ogni configurazione testata:
import mlflow
import optuna
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import cross_val_score
def hyperparameter_search(X_train, y_train, n_trials: int = 50) -> str:
"""
Hyperparameter search con Optuna + MLflow nested runs.
Run padre: contiene il summary della ricerca
Run figli: ogni trial Optuna e un run MLflow figlio
"""
mlflow.set_tracking_uri("http://localhost:5000")
mlflow.set_experiment("churn-hparam-search")
with mlflow.start_run(run_name="optuna-search-v1") as parent_run:
mlflow.set_tag("search_method", "optuna-tpe")
mlflow.log_param("n_trials", n_trials)
mlflow.log_param("optimization_metric", "auc_roc")
best_auc = 0.0
best_params = {}
def objective(trial) -> float:
"""Funzione obiettivo Optuna - ogni trial e un nested run MLflow."""
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 1000),
"learning_rate": trial.suggest_float("learning_rate", 0.001, 0.3, log=True),
"max_depth": trial.suggest_int("max_depth", 2, 8),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"min_samples_split": trial.suggest_int("min_samples_split", 5, 50),
}
# Ogni trial ottiene il suo run MLflow figlio
with mlflow.start_run(
run_name=f"trial-{trial.number}",
nested=True # <-- indica che e un run figlio
) as child_run:
mlflow.log_params(params)
model = GradientBoostingClassifier(**params, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=3, scoring="roc_auc")
auc_mean = scores.mean()
auc_std = scores.std()
mlflow.log_metrics({
"cv_auc_mean": auc_mean,
"cv_auc_std": auc_std,
"trial_number": trial.number,
})
return auc_mean
# Esegui la ricerca Optuna
study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=42)
)
study.optimize(objective, n_trials=n_trials, n_jobs=1)
# Logga i risultati della ricerca nel run padre
best_trial = study.best_trial
mlflow.log_params({f"best_{k}": v for k, v in best_trial.params.items()})
mlflow.log_metrics({
"best_auc": best_trial.value,
"n_trials_completed": len(study.trials),
})
print(f"Miglior AUC: {best_trial.value:.4f}")
print(f"Migliori parametri: {best_trial.params}")
return parent_run.info.run_id
Autologging: Zero-Config Tracking
L'autologging e una delle funzionalità più comode di MLflow: con una singola riga di codice, MLflow intercetta automaticamente le chiamate ai principali framework ML e logga parametri, metriche e artefatti senza alcuna modifica al codice di training. E supportato da scikit-learn, XGBoost, LightGBM, PyTorch Lightning, TensorFlow/Keras, Spark MLlib e altri.
import mlflow
import mlflow.sklearn
import mlflow.xgboost
# ==================== Autologging scikit-learn ====================
# Abilita autologging per scikit-learn
# Logga automaticamente: hyperparametri, metriche di training, signature del modello
mlflow.sklearn.autolog(
log_input_examples=True, # Logga esempi di input
log_model_signatures=True, # Inferisce e logga la firma del modello
log_models=True, # Salva il modello come artefatto
log_datasets=False, # Non loggare l'intero dataset (troppo grande)
max_tuning_runs=100, # Per GridSearchCV: max run tracciati
exclusive=False, # Permette log manuali in aggiunta
)
mlflow.set_experiment("autolog-demo")
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV
# Con autolog attivo, fit() logga tutto automaticamente
with mlflow.start_run(run_name="rf-gridsearch-autolog"):
param_grid = {
"n_estimators": [100, 300],
"max_depth": [4, 6, 8],
"min_samples_split": [10, 20],
}
model = GridSearchCV(
RandomForestClassifier(random_state=42),
param_grid,
cv=3,
scoring="roc_auc",
n_jobs=-1
)
model.fit(X_train, y_train)
# MLflow ha loggato automaticamente:
# - Tutti i parametri del RandomForest
# - cv=3, scoring, n_jobs
# - Best params dal GridSearchCV
# - Score di validazione incrociata
# - Il modello come artefatto
# ==================== Autologging XGBoost ====================
mlflow.xgboost.autolog(
importance_types=["gain", "weight"], # Logga feature importance
log_model_signatures=True,
log_input_examples=True,
)
import xgboost as xgb
import numpy as np
with mlflow.start_run(run_name="xgb-autolog"):
dtrain = xgb.DMatrix(X_train, label=y_train)
dval = xgb.DMatrix(X_val, label=y_val)
params = {
"objective": "binary:logistic",
"eval_metric": ["logloss", "auc"],
"learning_rate": 0.05,
"max_depth": 6,
"n_estimators": 500,
"subsample": 0.8,
"seed": 42,
}
# Autolog traccia: tutte le metriche per ogni boosting round
# e il modello finale, la feature importance
booster = xgb.train(
params,
dtrain,
num_boost_round=500,
evals=[(dtrain, "train"), (dval, "val")],
early_stopping_rounds=50,
verbose_eval=False,
)
# ==================== Autologging PyTorch Lightning ====================
mlflow.pytorch.autolog(
every_n_iter=10, # Logga ogni 10 iterazioni
log_models=True,
checkpoint_monitor="val_loss",
)
# Con PyTorch Lightning, chiama semplicemente trainer.fit()
# e MLflow cattura automaticamente tutte le loss e le metriche
Limitazioni dell'Autologging
L'autologging e comodo per prototipazione rapida, ma ha alcune limitazioni in produzione:
non logga metriche custom definite dall'utente, non logga informazioni sul dataset
(dimensioni, versione, distribuzione delle classi), non gestisce le dipendenze tra
esperimenti. Per pipeline MLOps mature, e consigliato usare l'autologging come base
e aggiungere chiamate manuali a mlflow.log_param(), mlflow.log_metric()
e mlflow.log_artifact() per le informazioni specifiche del dominio.
MLflow Model Registry: Ciclo di Vita dei Modelli
Il Model Registry di MLflow e il componente che trasforma MLflow da semplice strumento di tracking a vera piattaforma MLOps. Permette di gestire le versioni dei modelli attraverso un ciclo di vita standardizzato: da sviluppo a staging a produzione, con audit trail, annotazioni e notifiche.
Il Registry e accessibile sia tramite UI (drag & drop per cambiare stage) sia tramite l'API Python, essenziale per automazione in CI/CD. Con MLflow 3, il Registry e stato arricchito con webhooks per notifiche automatiche al passaggio di stage.
import mlflow
from mlflow.tracking import MlflowClient
from mlflow.entities.model_registry import ModelVersion
import time
mlflow.set_tracking_uri("http://localhost:5000")
client = MlflowClient()
MODEL_NAME = "churn-gbm-model"
# ==================== 1. REGISTRARE UN MODELLO ====================
# Metodo A: durante il log_model (più comune)
with mlflow.start_run() as run:
# ... training ...
mlflow.sklearn.log_model(
sk_model=model,
artifact_path="model",
registered_model_name=MODEL_NAME,
)
# Il modello viene automaticamente registrato come versione 1
# con stage "None" (development)
# Metodo B: da un run esistente tramite URI
run_id = "abc123def456"
model_uri = f"runs:/{run_id}/model"
version = mlflow.register_model(
model_uri=model_uri,
name=MODEL_NAME,
tags={"team": "ml-eng", "algorithm": "gbm"}
)
print(f"Registrato: {MODEL_NAME} versione {version.version}")
# ==================== 2. GESTIRE LE VERSIONI E GLI STAGE ====================
# Aggiungi una descrizione alla versione
client.update_model_version(
name=MODEL_NAME,
version=version.version,
description=(
"GBM per churn prediction v2.1. "
"Accuracy: 0.9423, AUC-ROC: 0.9567. "
"Trainato su dataset 2024-01 to 2025-01, 45k samples."
)
)
# Promuovi a Staging (dopo validazione interna)
client.transition_model_version_stage(
name=MODEL_NAME,
version=version.version,
stage="Staging",
archive_existing_versions=False, # Mantieni altre versioni staging
)
print(f"Modello v{version.version} promosso a Staging")
# Aggiungi tag per tracciabilita
client.set_model_version_tag(
name=MODEL_NAME,
version=version.version,
key="validated_by",
value="alice.rossi@company.com"
)
client.set_model_version_tag(
name=MODEL_NAME,
version=version.version,
key="validation_date",
value="2025-11-15"
)
# Promuovi a Production (dopo approvazione)
client.transition_model_version_stage(
name=MODEL_NAME,
version=version.version,
stage="Production",
archive_existing_versions=True, # Archivia la versione Production precedente
)
print(f"Modello v{version.version} in produzione!")
# ==================== 3. CARICARE IL MODELLO IN PRODUZIONE ====================
def load_production_model(model_name: str):
"""Carica sempre la versione Production dal registry."""
model_uri = f"models:/{model_name}/Production"
model = mlflow.sklearn.load_model(model_uri)
return model
# In uno script di inference o serving
model = load_production_model(MODEL_NAME)
predictions = model.predict(new_data)
# ==================== 4. INTERROGARE IL REGISTRY ====================
# Lista tutte le versioni di un modello
versions = client.search_model_versions(f"name='{MODEL_NAME}'")
for v in versions:
print(f"v{v.version} | Stage: {v.current_stage} | Run: {v.run_id[:8]}...")
# Cerca solo versioni in Production
prod_versions = client.get_latest_versions(MODEL_NAME, stages=["Production"])
if prod_versions:
latest_prod = prod_versions[0]
print(f"Versione in produzione: v{latest_prod.version}")
print(f"Run ID: {latest_prod.run_id}")
# Ottieni tutte le metriche associate alla versione in produzione
run_data = client.get_run(latest_prod.run_id).data
print(f"AUC-ROC in produzione: {run_data.metrics.get('auc_roc', 'N/A')}")
# ==================== 5. ROLLBACK IN CASO DI PROBLEMA ====================
def rollback_to_previous_production(model_name: str) -> None:
"""
Rollback: archivia la versione Production attuale e
ripristina la versione Archived più recente.
"""
# Trova versione corrente in Production
current_prod = client.get_latest_versions(model_name, stages=["Production"])
if not current_prod:
print("Nessuna versione in produzione trovata")
return
# Trova la versione Archived più recente (precedente Production)
archived = client.search_model_versions(
f"name='{model_name}'",
filter_string="tags.stage_history LIKE '%production%'",
)
if len(archived) < 2:
print("Nessuna versione archiviata disponibile per rollback")
return
# Archivia la versione problematica
client.transition_model_version_stage(
name=model_name,
version=current_prod[0].version,
stage="Archived",
)
# Promuovi la versione precedente
prev_version = archived[1].version
client.transition_model_version_stage(
name=model_name,
version=prev_version,
stage="Production",
)
print(f"Rollback completato: ora in produzione v{prev_version}")
Model Serving con MLflow
MLflow include un server di serving integrato che espone qualsiasi modello registrato come REST API con un singolo comando. E una soluzione eccellente per prototipazione e ambienti di sviluppo. Per produzione a scala, si consiglia di integrare il modello MLflow con FastAPI (vedi il prossimo articolo della serie).
# ==================== Serving via CLI ====================
# Servi l'ultima versione Production del modello
mlflow models serve \
--model-uri "models:/churn-gbm-model/Production" \
--host 0.0.0.0 \
--port 8080 \
--env-manager conda
# Servi un run specifico
mlflow models serve \
--model-uri "runs:/abc123def456/model" \
--port 8080
# Con Docker (raccomandato per produzione)
mlflow models build-docker \
--model-uri "models:/churn-gbm-model/Production" \
--name "churn-model-server" \
--enable-mlserver # Usa MLServer per performance migliori
docker run -p 8080:8080 churn-model-server
# ==================== Test del Serving ====================
# Il server espone l'endpoint /invocations
import requests
import json
import pandas as pd
# Prepara i dati di input nel formato atteso da MLflow
test_data = pd.DataFrame({
"feature_0": [0.5, -1.2],
"feature_1": [1.3, 0.8],
# ... altri 18 features
})
# MLflow accetta JSON in formato "split" o "records"
payload = {
"dataframe_split": {
"columns": test_data.columns.tolist(),
"data": test_data.values.tolist()
}
}
response = requests.post(
"http://localhost:8080/invocations",
headers={"Content-Type": "application/json"},
data=json.dumps(payload)
)
predictions = response.json()
print(f"Predizioni: {predictions}")
# {"predictions": [0, 1]}
Integrazione MLflow + FastAPI per Produzione
Per un serving di produzione robusto, la pratica migliore e caricare il modello dal Model Registry di MLflow all'avvio dell'applicazione FastAPI, e aggiornarlo automaticamente quando viene promossa una nuova versione Production:
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
import mlflow
import pandas as pd
import numpy as np
import threading
import time
import logging
from typing import List
logger = logging.getLogger(__name__)
app = FastAPI(title="Churn Prediction API", version="2.0.0")
# ==================== Model Manager con Auto-Refresh ====================
class MLflowModelManager:
"""
Gestisce il caricamento e il refresh automatico del modello
dal MLflow Model Registry.
"""
def __init__(self, model_name: str, tracking_uri: str, refresh_interval: int = 300):
self.model_name = model_name
self.tracking_uri = tracking_uri
self.refresh_interval = refresh_interval # secondi
self._model = None
self._model_version = None
self._lock = threading.Lock()
mlflow.set_tracking_uri(tracking_uri)
self._load_model()
self._start_refresh_thread()
def _load_model(self) -> None:
"""Carica la versione Production corrente dal registry."""
try:
model_uri = f"models:/{self.model_name}/Production"
new_model = mlflow.sklearn.load_model(model_uri)
# Ottieni il numero di versione
client = mlflow.MlflowClient()
versions = client.get_latest_versions(self.model_name, stages=["Production"])
version = versions[0].version if versions else "unknown"
with self._lock:
self._model = new_model
self._model_version = version
logger.info(f"Modello {self.model_name} v{version} caricato dal registry")
except Exception as e:
logger.error(f"Errore nel caricamento del modello: {e}")
def _start_refresh_thread(self) -> None:
"""Avvia thread background per refresh periodico del modello."""
def refresh_loop():
while True:
time.sleep(self.refresh_interval)
self._load_model()
thread = threading.Thread(target=refresh_loop, daemon=True)
thread.start()
def predict(self, features: pd.DataFrame) -> np.ndarray:
with self._lock:
if self._model is None:
raise RuntimeError("Modello non disponibile")
return self._model.predict(features)
def predict_proba(self, features: pd.DataFrame) -> np.ndarray:
with self._lock:
if self._model is None:
raise RuntimeError("Modello non disponibile")
return self._model.predict_proba(features)[:, 1]
@property
def model_version(self) -> str:
return self._model_version or "unknown"
# Istanza globale del manager
model_manager = MLflowModelManager(
model_name="churn-gbm-model",
tracking_uri="http://mlflow-server:5000",
refresh_interval=300, # Controlla nuove versioni ogni 5 minuti
)
# ==================== API Endpoints ====================
class PredictionRequest(BaseModel):
features: List[List[float]]
feature_names: List[str]
class PredictionResponse(BaseModel):
predictions: List[int]
probabilities: List[float]
model_version: str
@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest) -> PredictionResponse:
"""Endpoint di predizione churn."""
try:
df = pd.DataFrame(
request.features,
columns=request.feature_names
)
predictions = model_manager.predict(df).tolist()
probabilities = model_manager.predict_proba(df).tolist()
return PredictionResponse(
predictions=predictions,
probabilities=probabilities,
model_version=model_manager.model_version,
)
except Exception as e:
logger.error(f"Errore nella predizione: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"model_name": "churn-gbm-model",
"model_version": model_manager.model_version,
}
MLflow vs Alternative: W&B, Neptune, ClearML
MLflow non e l'unica opzione per l'experiment tracking. Il mercato offre diverse alternative valide, ciascuna con punti di forza specifici. La scelta dipende da budget, dimensione del team, infrastruttura esistente e requisiti di governance.
Confronto Completo Strumenti di Experiment Tracking
| Dimensione | MLflow | W&B | Neptune | ClearML |
|---|---|---|---|---|
| Licenza | Open-source (Apache 2.0) | SaaS + self-host Enterprise | SaaS + self-host | Open-source + Enterprise |
| Costo (team 5 persone) | Gratis (self-hosted) | ~250 USD/mese | ~100 USD/mese | Gratis (self-hosted) |
| Setup | Docker in ~30 min | Zero config (SaaS) | Zero config (SaaS) | Complesso (ClearML Server) |
| UI/UX | Funzionale, non bella | Eccellente, ricca di grafici | Buona, molto customizzabile | Completa, curva di apprendimento alta |
| Autologging | Eccellente (20+ framework) | Eccellente (W&B SDK) | Buono | Automatico via monkey-patching |
| Model Registry | Integrato, staging workflow | W&B Model Registry | Model Registry disponibile | Model Repository integrato |
| Hyperparameter Sweep | Integrazione Optuna/Hyperopt | Sweeps nativo (eccellente) | Buono | HPO integrato |
| Governance/Compliance | Audit trail di base | Access control, team features | Team workspace | Avanzato (RBAC, audit) |
| GenAI/LLM Support | MLflow 3: tracing, evaluation | Prompts, LLM monitoring | LLM tracking | LLM Experiment tracking |
| Ideale per | Team con infrastruttura propria, PMI, self-hosted | Team che prioritizza UX e collaboration | Team con budget moderato | Enterprise con esigenze di automation |
Quando Scegliere MLflow
MLflow e la scelta ottimale in questi scenari:
- Budget limitato (<5K EUR/anno): self-hosting su singola VM costa circa 180 EUR/anno
- Requisiti di data residency: dati sensibili che non possono uscire dall'infrastruttura aziendale
- Integrazione con ecosistema Python esistente: MLflow si integra nativamente con scikit-learn, PyTorch, TensorFlow, XGBoost e oltre 20 framework
- Compliance e audit (AI Act EU): accesso completo al database e agli artefatti, nessun lock-in SaaS
- Team DevOps-oriented: MLflow e un container Docker come qualsiasi altro servizio
Interrogare ed Analizzare gli Esperimenti
Una delle funzionalità più preziose di MLflow e la possibilità di interrogare programmaticamente tutti gli esperimenti per trovare il run migliore, confrontare run su più dimensioni, o estrarre dati per report automatici:
import mlflow
from mlflow.tracking import MlflowClient
import pandas as pd
mlflow.set_tracking_uri("http://localhost:5000")
client = MlflowClient()
# ==================== Ricerca di Run con Filtri ====================
# Trova tutti i run con AUC-ROC > 0.92 nel tuo experiment
runs = mlflow.search_runs(
experiment_names=["churn-prediction-gbm"],
filter_string="metrics.auc_roc > 0.92 and tags.environment = 'dev'",
order_by=["metrics.auc_roc DESC"],
max_results=20,
)
# Il risultato e un DataFrame pandas
print(runs[["run_id", "metrics.auc_roc", "metrics.f1_score",
"params.learning_rate", "params.max_depth"]].head())
# ==================== Confronto Run su Multiple Metriche ====================
def compare_top_runs(experiment_name: str, n: int = 5) -> pd.DataFrame:
"""Restituisce un DataFrame con i top N run per AUC-ROC."""
runs = mlflow.search_runs(
experiment_names=[experiment_name],
filter_string="status = 'FINISHED'",
order_by=["metrics.auc_roc DESC"],
max_results=n,
)
# Seleziona le colonne più rilevanti
cols = [
"run_id",
"metrics.accuracy", "metrics.f1_score", "metrics.auc_roc",
"params.n_estimators", "params.learning_rate", "params.max_depth",
"tags.dataset_version", "start_time",
]
# Filtra solo colonne esistenti
existing_cols = [c for c in cols if c in runs.columns]
return runs[existing_cols].copy()
comparison_df = compare_top_runs("churn-prediction-gbm")
print(comparison_df.to_string(index=False))
# ==================== Trovare il Best Run e Caricarlo ====================
def get_best_run(experiment_name: str, metric: str = "metrics.auc_roc") -> dict:
"""Trova il run con la metrica migliore e restituisce run_id e metriche."""
runs = mlflow.search_runs(
experiment_names=[experiment_name],
filter_string="status = 'FINISHED'",
order_by=[f"{metric} DESC"],
max_results=1,
)
if runs.empty:
raise ValueError(f"Nessun run trovato in {experiment_name}")
best = runs.iloc[0]
return {
"run_id": best["run_id"],
"auc_roc": best.get("metrics.auc_roc"),
"accuracy": best.get("metrics.accuracy"),
"f1_score": best.get("metrics.f1_score"),
}
best_run = get_best_run("churn-prediction-gbm")
print(f"Best run: {best_run['run_id']}, AUC-ROC: {best_run['auc_roc']:.4f}")
# Carica il modello dal best run
model = mlflow.sklearn.load_model(f"runs:/{best_run['run_id']}/model")
# ==================== Export Metriche per Report ====================
# Carica la history di una metrica step-by-step (es. validation loss per epoch)
run_id = "abc123def456"
metric_history = client.get_metric_history(run_id, "val_accuracy_step")
steps = [m.step for m in metric_history]
values = [m.value for m in metric_history]
accuracy_over_time = pd.DataFrame({"step": steps, "val_accuracy": values})
print(f"Training steps loggati: {len(accuracy_over_time)}")
Best Practices per MLflow in Produzione
1. Convenzioni di Naming
Una naming convention coerente rende gli esperimenti ricercabili e comprensibili mesi dopo:
# Schema naming raccomandato per experiments e run
# EXPERIMENT: [team]-[progetto]-[tipo]
# Esempi:
"ml-eng-churn-prediction-gbm"
"ml-eng-churn-prediction-neural-net"
"research-recommender-collaborative"
# RUN NAME: [algoritmo]-[key-param]-[data]
# Esempi:
"gbm-lr0.05-depth6-2025-11-15"
"xgb-v2-autofeat-2025-11-20"
"baseline-logistic-regression"
# Usa sempre tag per metadata strutturati
mlflow.set_tags({
"team": "ml-engineering",
"project": "churn-prediction",
"environment": "dev", # dev | staging | prod
"dataset_version": "v2.1",
"git_branch": git_branch,
"git_commit": git_commit[:8],
"triggered_by": "ci-cd", # manual | ci-cd | scheduled
"approved_by": "", # Compilato prima della promozione
})
2. Struttura del Codice MLflow
from contextlib import contextmanager
import mlflow
import functools
# Pattern: decorator per tracking automatico
def mlflow_run(experiment_name: str, run_name: str = None, tags: dict = None):
"""Decorator che wrappa una funzione in un MLflow run."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
mlflow.set_experiment(experiment_name)
with mlflow.start_run(run_name=run_name or func.__name__) as run:
if tags:
mlflow.set_tags(tags)
result = func(*args, **kwargs)
return result
return wrapper
return decorator
# Uso del decorator
@mlflow_run(
experiment_name="churn-prediction-gbm",
run_name="training-v2",
tags={"team": "ml-eng", "version": "v2"}
)
def train_model(X_train, y_train, params: dict):
mlflow.log_params(params)
model = GradientBoostingClassifier(**params)
model.fit(X_train, y_train)
# ... metriche e artefatti
return model
# Pattern: context manager per setup/teardown
@contextmanager
def mlflow_experiment(experiment_name: str, run_name: str):
"""Context manager con gestione errori."""
mlflow.set_experiment(experiment_name)
with mlflow.start_run(run_name=run_name) as run:
try:
mlflow.set_tag("status", "running")
yield run
mlflow.set_tag("status", "success")
except Exception as e:
mlflow.set_tag("status", "failed")
mlflow.set_tag("error", str(e))
raise
# Uso
with mlflow_experiment("churn-prediction", "training-run-v3") as run:
mlflow.log_params({"n_estimators": 300})
# ... training
mlflow.log_metric("accuracy", 0.94)
3. Integrazione con GitHub Actions per CI/CD
# .github/workflows/ml-experiment.yml
name: ML Experiment + Model Promotion
on:
push:
branches: [main]
paths:
- 'src/models/**'
- 'src/features/**'
- 'params.yaml'
jobs:
train-and-evaluate:
runs-on: ubuntu-latest
env:
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run training
id: training
run: |
# Script di training che logga su MLflow e stampa il run_id
RUN_ID=$(python src/models/train.py --output-run-id)
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
- name: Evaluate and check metrics
id: evaluation
run: |
python scripts/ci_evaluate.py \
--run-id ${{ steps.training.outputs.run_id }} \
--min-auc 0.92 \
--min-accuracy 0.90
- name: Promote to Staging
if: success()
run: |
python scripts/promote_model.py \
--run-id ${{ steps.training.outputs.run_id }} \
--model-name churn-gbm-model \
--target-stage Staging
- name: Notify team
if: success()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": "Nuovo modello promosso a Staging: run ${{ steps.training.outputs.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Anti-Patterns da Evitare
-
Usare mlflow.log_metric() con chiavi diverse per la stessa metrica:
accuracyeaccsono due metriche separate per MLflow. Definisci un dizionario di nomi canonici e usalo sempre. -
Non chiudere i run attivi: se il codice crasha dentro un run attivo
senza il context manager
with mlflow.start_run(), il run rimane nello stato "RUNNING" indefinitamente. Usa sempre il context manager. - Loggare artefatti enormi come CSV: MLflow non e un data lake. Per dataset grandi, logga solo i metadata (path DVC, hash, dimensioni) e usa DVC per il versioning dei dati.
- Usare SQLite in produzione con più utenti: SQLite non supporta scritture concorrenti. Con due processi di training paralleli, si verificano lock errors. Usa PostgreSQL o MySQL per qualsiasi setup multi-utente.
- Non loggare la versione del dataset: i parametri del modello senza la versione dei dati non bastano per la riproducibilità. Logga sempre il commit Git, il tag DVC e le dimensioni del dataset.
- Promuovere a Production senza promozione intermedia Staging: il workflow Staging intermediario permette test di integrazione e validazione del team prima del deployment in produzione. Non saltare questo step.
Le Novità di MLflow 3: Verso il GenAI e gli Agent
Con il rilascio di MLflow 3 nel giugno 2025, la piattaforma ha fatto un salto evolutivo significativo orientato al mondo GenAI. Le novità più rilevanti per chi lavora con modelli ML tradizionali e con LLM:
- LoggedModel come entità di prima classe: il modello non e più solo un artefatto di un run. I LoggedModel persistono attraverso run, ambienti e deployment, con lineage completa verso metriche, parametri, tracce e dati di valutazione.
- Performance migliorate del 25%: MLflow 3.x ha ottimizzato le query al database e ridotto l'overhead del logging, con throughput di log superiore del 25% rispetto alla versione 2.5 (benchmark 2026).
- GenAI Tracing: tracing automatico per LLM, chain, tool call e agenti con supporto per LangChain, LlamaIndex, OpenAI SDK, Anthropic e altri.
- Feedback Collection API: raccolta strutturata di feedback umano sui risultati dei modelli, integrata con la UI per revisione e valutazione.
-
Evaluation Framework evoluto:
mlflow.evaluate()ora supporta metriche custom, LLM-as-judge, e comparazione automatica di modelli.
Conclusioni e Prossimi Passi
MLflow si e consolidato come lo strumento di experiment tracking più diffuso nell'ecosistema ML open-source, con una community attiva e un'evoluzione costante. La combinazione di tracking, Model Registry e serving in un'unica piattaforma self-hosted lo rende la scelta naturale per team che vogliono controllo completo sulla propria infrastruttura MLOps senza i costi delle soluzioni SaaS.
Il workflow che abbiamo visto in questo articolo, dalla registrazione degli esperimenti alla promozione in produzione tramite il Model Registry, copre il 90% dei casi d'uso reali di un team ML. Integrandolo con DVC per il versioning dei dati (articolo precedente) e con GitHub Actions per l'automazione CI/CD, si ottiene un sistema MLOps completo e professionale con un budget inferiore a 250 EUR/anno.
Nel prossimo articolo affronteremo uno dei problemi più insidiosi del machine learning in produzione: il Model Drift. Vedremo come rilevare il degrado delle performance nel tempo (data drift, concept drift, prediction drift) e come implementare sistemi di retraining automatico con alert su Grafana e Prometheus.
Risorse e Prossimi Passi
- Documentazione ufficiale MLflow: mlflow.org/docs/latest
- MLflow 3 release notes: mlflow.org/releases/3
- MLflow GitHub: github.com/mlflow/mlflow
- Articolo precedente: Versionare Dataset e Modelli con DVC
- Articolo successivo: Model Drift Detection e Retraining Automatico
- Cross-link: Deep Learning Avanzato - Training Avanzato
- Cross-link: Computer Vision - Object Detection Pipeline
Stack MLOps Completo con MLflow (Budget <5K EUR/anno)
| Componente | Strumento | Costo Annuo Stimato |
|---|---|---|
| Experiment Tracking | MLflow self-hosted | Gratis (open-source) |
| Backend Store | PostgreSQL su Docker | Gratis (stesso server MLflow) |
| Artifact Store | MinIO (S3-compat.) o AWS S3 | Gratis / ~30 EUR/anno |
| VM per MLflow + PostgreSQL | EC2 t3.small (2 vCPU, 2 GB RAM) | ~180 EUR/anno |
| Dataset Versioning | DVC + DagsHub | Gratis |
| CI/CD Pipeline | GitHub Actions | Gratis (2000 min/mese) |
| Totale stimato | <220 EUR/anno |

Commenti
Caricamento commenti...
Accedi per commentare