MLflow è uno strumento open source (licenza Apache 2.0) che registra ciò che accade quando addestri un modello: con quali parametri, con quale risultato, producendo quali file. È la risposta a una domanda che un notebook non ricorda: "quale esperimento ha dato quel numero?". Questo articolo ti porta da zero a quattro esperimenti registrati e confrontati con una query, e poi a una ricerca di parametri con validazione incrociata. Per la panoramica completa dello strumento c'è già la guida all'experiment tracking con MLflow; qui costruiamo il caso partendo dal progetto del modulo.
Tutti i listati Python sono stati eseguiti due volte, in una cartella temporanea fuori dal repository, con MLflow 3.18.0, scikit-learn 1.9.1, pandas 3.1.1 e Python 3.12, e le due esecuzioni hanno dato la stessa uscita. Il server web di MLflow e il server condiviso con PostgreSQL non li ho avviati (richiedono una porta in ascolto o servizi esterni): nel testo sono dichiarati schema da adattare. I dati sono sintetici e inventati.
Le parole del tracciamento
L'experiment tracking è la pratica di registrare, per ogni addestramento, con quali ingressi è stato fatto e cosa ha prodotto: come tenere il quaderno di laboratorio. Un esperimento è un contenitore di prove sullo stesso problema (qui churn-baseline), una run è una singola esecuzione registrata, cioè una riga del quaderno, con un identificatore univoco. I parametri sono gli ingressi che scegli prima dell'addestramento (la regolarizzazione C, il seed, il numero di righe) e, una volta registrati in una run, non si cambiano; le metriche sono le uscite che misuri (l'AUC, la perdita) e possono essere registrate più volte con un numero di passo (step). Gli artefatti sono i file prodotti dalla run: il modello, un grafico, una configurazione. Un tag è un'etichetta di testo libero, per esempio il nome del dataset.
Resta da capire dove MLflow mette tutto questo. Il backend store contiene le righe del quaderno (run, parametri, metriche) in un database; l'artifact store contiene gli allegati in una cartella o in un archivio di oggetti: come l'indice e gli scaffali di una biblioteca. Il tracking URI è l'indirizzo che dice a MLflow dove stanno i dati (sqlite:///mlflow.db). La documentazione di MLflow Tracking descrive appunto il backend store come il luogo che conserva i metadati di ogni run, e distingue le run (con parametri e metriche) dagli artefatti (i file in uscita).
Il ciclo di vita di una run, e il confronto finale:
Nel disegno l'ordine dei passi riflette quello dello script che vedremo: i parametri si annotano prima o durante l'addestramento, le metriche e il modello dopo, e la run si chiude da sola alla fine del blocco with. Il confronto con search_runs avviene fuori dalla run, su tutte quelle già chiuse.
L'analogia: il quaderno di laboratorio
Un chimico serio tiene un quaderno: data, quantità dei reagenti, temperatura, cosa ha osservato. Non per burocrazia: perché tra tre settimane, davanti a un risultato inatteso, deve poter dire "ho fatto proprio questo". Il quaderno è anche ciò che consente a un collega di ripetere l'esperimento. MLflow è il quaderno di laboratorio dell'ML, ma compilato dal codice, quindi non si dimentica mai di annotare.
Perché non un foglio di calcolo: tre dolori che il tracciamento cura
Per cinque esperimenti un foglio di calcolo va benissimo, e spesso è ciò con cui si comincia. Il tracciamento strutturato risolve tre dolori che il foglio risolve male, e che si presentano tutti dopo la decima prova.
Il dolore del confronto. Hai venti righe, ciascuna con parametri e risultato. Quale combina il miglior risultato con un costo accettabile? Quali parametri hanno effetto e quali no? Ordinare e filtrare un foglio funziona finché le colonne sono sempre le stesse; appena un esperimento aggiunge un parametro nuovo, il foglio si deforma. Un sistema che trattiene ogni run come un record con parametri e metriche arbitrari rende l'interrogazione uniforme (search_runs).
Il dolore della riproduzione. La riga del foglio dice "AUC 0.74, C=1". Ma con quale dataset? Quale seed? Quale versione del codice? Quali librerie? Se la riga non lo dice, il numero non è riproducibile, anche se l'ha scritto un'altra persona onesta. Il tracciamento ti spinge a registrare il contesto nel momento in cui lo conosci, cioè durante la run, non settimane dopo a memoria.
Il dolore della comunicazione. Il collega, o il te di tra sei mesi, deve capire cosa è stato fatto. Un foglio con venti righe e nomi come "prova3_ok" non si capisce; un esperimento con run nominate, tag e descrizioni, sì. Per chi deve decidere se il modello può andare in produzione, l'interfaccia di MLflow è un cruscotto che mostra cosa si è provato e cosa si è scelto.
Preparare l'ambiente
Con UV (vedi uv e la gestione dei pacchetti) si aggiunge MLflow al progetto con uv add mlflow, e si verifica con uv run python -c "import mlflow; print(mlflow.__version__)" (comandi di schema, da adattare al tuo progetto). MLflow ha bisogno di sapere dove mettere i dati. Per iniziare basta SQLite, un file locale (sqlite:///mlflow.db): niente server da installare.
Un'avvertenza che ho verificato con MLflow 3.18.0: il vecchio backend a cartella (./mlruns, quello che molti tutorial usano ancora) solleva un'eccezione per impostazione predefinita. Se un tutorial vecchio non funziona, probabilmente è questo.
import mlflow
mlflow.set_tracking_uri("./mlruns")
try:
with mlflow.start_run():
pass
except Exception as e:
print(type(e).__name__, "|", str(e))
# MlflowException | The filesystem tracking backend (e.g., './mlruns') is in maintenance mode
# and will not receive further updates. Please migrate to a database backend
# (e.g., 'sqlite:///mlflow.db') to access the latest MLflow features. [...]
# If the filesystem backend is required for your workflow, set `MLFLOW_ALLOW_FILE_STORE=true`
# to opt out of this exception.
Il messaggio (che ho accorciato) dice due cose: il backend a cartella è in manutenzione, e c'è una variabile, MLFLOW_ALLOW_FILE_STORE=true, per rimandare la scelta. Conviene partire direttamente da SQLite.
Il progetto di prova: dati e modello
Gli esempi usano due moduli del progetto churn. Li riporto in forma minima, così che tutto sia eseguibile: make_dataset genera clienti sintetici con anzianità e reclami, train separa un insieme di test, addestra una regressione logistica e restituisce modello e AUC.
# churn/data.py
import numpy as np
import pandas as pd
FEATURES = ["tenure", "complaints"]
def make_dataset(n: int = 800, seed: int = 0) -> pd.DataFrame:
rng = np.random.default_rng(seed)
tenure = rng.integers(0, 72, n)
complaints = rng.integers(0, 8, n)
logit = 0.3 - 0.03 * tenure + 0.25 * complaints
churn = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
return pd.DataFrame({"tenure": tenure, "complaints": complaints, "churn": churn})
# churn/model.py
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from churn.data import FEATURES
def train(df, seed: int = 0, C: float = 1.0):
X_tr, X_te, y_tr, y_te = train_test_split(
df[FEATURES], df["churn"], test_size=0.25, random_state=seed, stratify=df["churn"]
)
model = LogisticRegression(C=C, max_iter=500).fit(X_tr, y_tr)
auc = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])
return model, float(auc)
Il seed fissa sia la generazione dei dati (default_rng(seed)) sia la divisione fra addestramento e test (random_state=seed): senza, lo stesso codice darebbe numeri diversi a ogni esecuzione, e confrontare run diventerebbe impossibile. stratify mantiene la proporzione di clienti che se ne vanno in entrambe le parti. C è l'inverso della forza di regolarizzazione di scikit-learn: valori piccoli vincolano di più i coefficienti.
Il primo script di tracciamento
# track.py
import mlflow
from churn.data import make_dataset
from churn.model import train
mlflow.set_tracking_uri("sqlite:///mlflow.db")
mlflow.set_experiment("churn-baseline")
df = make_dataset(n=800, seed=0)
for C in (0.01, 0.1, 1.0, 10.0):
with mlflow.start_run(run_name=f"logreg-C{C}"):
model, auc = train(df, seed=0, C=C)
mlflow.log_params({"C": C, "seed": 0, "data_seed": 0, "n_rows": len(df)})
mlflow.log_metric("auc", auc)
mlflow.set_tag("dataset", "sintetico-v1")
info = mlflow.sklearn.log_model(model, name="model")
mlflow.set_tag("model_uri", info.model_uri)
Che cosa fa, riga per riga. set_experiment crea (o riusa) l'esperimento. with mlflow.start_run(...) apre una run e la chiude alla fine del blocco, anche se c'è un errore. log_params e log_metric annotano ingressi e uscite: nota che registro anche data_seed, il seed dei dati, perché è un ingresso come gli altri (ne vedremo il motivo nella prova di riproduzione). set_tag aggiunge etichette libere: io annoto il nome del dataset, perché tra un mese "su quali dati?" sarà la prima domanda. mlflow.sklearn.log_model salva il modello come artefatto con le informazioni per ricaricarlo; in MLflow 3 il parametro si chiama name (nelle versioni precedenti era artifact_path). Ogni log_model ha richiesto una decina di secondi sulla mia macchina: i tempi cambiano da una macchina all'altra.
Confrontare gli esperimenti
import mlflow
mlflow.set_tracking_uri("sqlite:///mlflow.db")
runs = mlflow.search_runs(experiment_names=["churn-baseline"], order_by=["metrics.auc DESC"])
print(runs[["params.C", "metrics.auc"]].to_string())
# params.C metrics.auc
# 0 0.01 0.729467
# 1 0.1 0.728466
# 2 10.0 0.728165
# 3 1.0 0.728165
Il search_runs restituisce una tabella pandas con una riga per run, ordinata per AUC decrescente. Eseguito due volte, l'esito è identico. La tabella racconta una cosa utile e un'avvertenza: C = 1.0 e C = 10.0 danno lo stesso identico AUC (0.728165), mentre C = 0.01 è, in questo esempio, leggermente meglio degli altri (0.729467). La differenza fra il primo e l'ultimo è di circa un millesimo: con 200 clienti nell'insieme di test una distanza così piccola non distingue un modello migliore dal caso. Lo verificheremo più sotto con la validazione incrociata. Senza la tabella avresti dovuto ricordarti i numeri, o rilanciare tutto.
L'interfaccia web
uv run mlflow server --backend-store-uri sqlite:///mlflow.db --port 5000
# schema da adattare, non eseguito: apre una porta in ascolto sul tuo computer
Poi apri http://localhost:5000. Nell'interfaccia puoi ordinare le run per metrica, selezionarne due o più e confrontarle con grafici, aprire gli artefatti. È utile per esplorare; per l'analisi sistematica lo script con search_runs resta più riproducibile, perché lo puoi rieseguire e versionare.
Una cosa che fa MLflow da solo e vale oro
Se il progetto ha un pyproject.toml e un uv.lock, MLflow se ne accorge e registra le dipendenze esatte insieme al modello. L'ho provato in una cartella con i due file (dipendenze: mlflow, scikit-learn, pandas): durante log_model sono comparse nel log le righe Detected uv project: found uv.lock and pyproject.toml e Exported 92 dependencies via uv, e il file requirements.txt dell'artefatto aveva 91 righe. Il numero dipende dal tuo lockfile e dalla data. Il collegamento con l'articolo su UV è diretto: il lockfile non serve solo a ricostruire l'ambiente di sviluppo, viene archiviato accanto al modello, così chi lo carica un anno dopo sa con quali versioni è stato prodotto (la trappola delle versioni del modello è descritta nell'articolo sul Dockerfile).
Cosa tracciare, e cosa no
| Sì, sempre | Perché |
|---|---|
| Parametri del modello e dell'addestramento | Sono gli ingressi dell'esperimento |
| Seed (e quelli dello split e dei dati) | Senza, il numero non si riproduce (vedi testare i modelli con pytest) |
| Versione e identità dei dati | Il parametro più dimenticato: "quale dataset?" |
| Metriche di valutazione e il loro insieme di test | Un AUC senza il set su cui è stato misurato non dice nulla |
| Hash del commit del codice | Lega la run al codice esatto (vedi Git: versionare il lavoro) |
| Il modello e le sue dipendenze | Senza, non lo puoi rilasciare |
Evita invece di registrare dati sensibili o personali come parametri o artefatti, e non registrare segreti (token, password): il tracking server è un archivio condiviso, spesso con permessi più larghi del codice. Il criterio per decidere è chiedersi, per ogni valore, se serve a riprodurre o a capire la run: se sì, va registrato; se contiene qualcosa che non vorresti vedere in un log pubblico, no.
Cosa è stato salvato davvero: guardare dentro
Uno strumento di cui non sai cosa produce resta una scatola nera. Dopo i quattro esperimenti ho guardato cosa c'era sul disco. Il file mlflow.db è un normale database SQLite: lo si interroga con il modulo sqlite3 di Python. Le tabelle sono decine, perché MLflow copre anche usi che qui non servono; le tre che contano sono runs, params e metrics. Con il backend SQLite, i file del modello finiscono per impostazione predefinita nella cartella mlruns/ accanto allo script.
import sqlite3, pathlib
con = sqlite3.connect("mlflow.db")
for t in ("runs", "params", "metrics"):
print(t, con.execute(f"select count(*) from {t}").fetchone()[0])
print("tabelle:", con.execute("select count(*) from sqlite_master where type='table'").fetchone()[0])
m = next(pathlib.Path("mlruns").rglob("MLmodel"))
print(sorted(p.name for p in m.parent.iterdir()))
print("righe requirements:", len((m.parent / "requirements.txt").read_text().splitlines()))
# runs 4
# params 16
# metrics 4
# tabelle: 71
# ['MLmodel', 'conda.yaml', 'model.skops', 'python_env.yaml', 'requirements.txt']
# righe requirements: 6
I numeri confermano il modello mentale: 4 run e 4 metriche (una AUC per run), e 16 parametri, cioè 4 parametri (C, seed, data_seed, n_rows) per ciascuna delle 4 run. Puoi interrogare il database direttamente, ed è lo strumento giusto per un'analisi che l'interfaccia non offre, ma meglio usare le funzioni di MLflow, che non dipendono dallo schema interno. Nella cartella del modello ci sono cinque file. MLmodel è un piccolo file di testo con i "gusti" (flavor) in cui il modello può essere caricato: sklearn (come oggetto scikit-learn) e python_function (come funzione generica predict, il formato universale di MLflow). conda.yaml e python_env.yaml descrivono l'ambiente in due formati. requirements.txt elenca le librerie esatte al momento del salvataggio: 6 righe senza progetto UV (mlflow, numpy, pandas, scikit-learn, scipy, skops), contro le 91 della prova con uv.lock vista sopra.
Un dettaglio notevole è che il modello è salvato come model.skops, un formato del progetto skops, e non come un pickle (il file MLmodel riporta serialization_format: skops). La pagina di scikit-learn sulla persistenza spiega perché: il pickle di Python può eseguire codice arbitrario quando viene caricato, mentre skops, a differenza dei formati basati su pickle, non consente l'esecuzione automatica di codice arbitrario al caricamento. Per un ML engineer è una buona abitudine ricordarsi che un file di modello è codice, non solo dati, e va caricato solo da fonti di cui ti fidi.
I parametri non si cambiano; le metriche possono avere una storia
Due comportamenti del tracciamento che conviene conoscere, provati con MLflow 3.18.0. Il primo: registrare due volte lo stesso parametro con valori diversi in una run è un errore. Il secondo: le metriche possono essere registrate più volte con un numero di step, e MLflow ne conserva la serie. Nello stesso listato registro anche due allegati non numerici, un dizionario e un testo.
import mlflow
mlflow.set_tracking_uri("sqlite:///mlflow.db")
mlflow.set_experiment("prove")
with mlflow.start_run():
mlflow.log_param("C", 1.0)
try:
mlflow.log_param("C", 2.0)
except Exception as e:
print(type(e).__name__, "|", str(e)[:100])
for step, loss in enumerate([0.9, 0.6, 0.45]):
mlflow.log_metric("loss", loss, step=step)
mlflow.log_dict({"features": ["tenure", "complaints"]}, "config/features.json")
mlflow.log_text("nota libera", "note.txt")
# MlflowException | Changing param values is not allowed. Param with key='C' was already logged with value='1.0' [...]
# (la serie "loss" salvata ha tre punti: 0.9, 0.6, 0.45)
La seconda log_param solleva MlflowException con il messaggio "Changing param values is not allowed": un parametro descrive come è stata fatta la run, e riscriverlo falsificherebbe il quaderno. Se un valore cambia, è un'altra run. Le metriche con step servono per modelli che si addestrano in più iterazioni (la "perdita" che scende a ogni passo): l'interfaccia ne disegna la curva, mentre per un modello che si addestra in un colpo solo, come la nostra regressione logistica, una metrica finale basta. log_dict salva un dizionario come file JSON nel percorso indicato e log_text una stringa come testo: sono il modo semplice per allegare ciò che non è un numero, come l'elenco delle feature, il report, o un mlflow.log_text(commit_hash, "git_commit.txt") per legare la run al codice esatto.
Autolog: comodo, con un costo
MLflow può registrare da solo parametri, metriche e modello per le librerie più diffuse, con una riga, mlflow.sklearn.autolog(), prima dell'addestramento (non l'ho eseguita per questo articolo). Il vantaggio è la velocità; i limiti sono due. Registra ciò che lui decide e può includere più rumore di quello che vuoi, con molti parametri interni. E non conosce il tuo dominio: il nome del dataset, la versione dei dati o il commit non li inventa. Un buon compromesso è usare l'autolog per partire in fretta, più poche chiamate esplicite (set_tag("dataset", ...)) per ciò che solo tu sai.
Esperimenti che si ripetono davvero: la prova
La promessa del tracciamento è la riproducibilità. Va verificata, non creduta. Il listato legge da MLflow i parametri della run con C = 1.0, riaddestra con quei valori e confronta la metrica:
import mlflow
from churn.data import make_dataset
from churn.model import train
mlflow.set_tracking_uri("sqlite:///mlflow.db")
r = mlflow.search_runs(experiment_names=["churn-baseline"],
filter_string="metrics.auc > 0.7 and params.C = '1.0'")
run = r.iloc[0]
C = float(run["params.C"]); seed = int(run["params.seed"])
n = int(run["params.n_rows"]); data_seed = int(run["params.data_seed"])
_, auc2 = train(make_dataset(n, seed=data_seed), seed=seed, C=C)
print("loggato", run["metrics.auc"], "| rifatto", auc2, "| uguali:", auc2 == run["metrics.auc"])
# loggato 0.7281650641025641 | rifatto 0.7281650641025641 | uguali: True
Il numero ricostruito è identico fino all'ultima cifra, perché ho registrato tutto ciò che serviva (parametri, seed, dimensione dei dati) e perché il codice è deterministico. Due dettagli. Il primo: filter_string è un piccolo linguaggio di interrogazione, con metrics.auc > 0.7 and params.C = '1.0'; i parametri sono stringhe (da qui le virgolette attorno a '1.0') e vanno riconvertiti (float(...)) per riusarli. Il secondo è la lacuna da non lasciare: se lo script non registrasse il seed dei dati (data_seed), bisognerebbe ricordarlo a memoria, e la riproduzione fallirebbe senza dire perché; per questo è tra i parametri. Tutto ciò che influenza il risultato va tra i parametri, compresa l'origine dei dati.
Una sola misura non basta: registrare media e deviazione
Un unico valore di AUC dipende dalla divisione fra addestramento e test: con split diversi cambia, e il confronto fra C = 1.0 e C = 10.0 rischia di essere un confronto di rumore (il tema è trattato nell'articolo sul testare i modelli). Una pratica migliore è la validazione incrociata (cross-validation): si divide il dataset in 5 parti, si addestra 5 volte usando ogni volta una parte diversa per la valutazione e si ottengono 5 valori di AUC, di cui si registrano la media e la deviazione standard. Il listato ripete l'esperimento dei quattro valori di C con run annidate: una run "padre" per la ricerca e quattro run "figlie", una per ogni C.
import mlflow
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from churn.data import FEATURES, make_dataset
mlflow.set_tracking_uri("sqlite:///mlflow.db")
mlflow.set_experiment("churn-cv")
df = make_dataset(n=800, seed=0)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
with mlflow.start_run(run_name="ricerca-C") as parent:
for C in (0.01, 0.1, 1.0, 10.0):
with mlflow.start_run(run_name=f"C={C}", nested=True):
scores = cross_val_score(LogisticRegression(C=C, max_iter=500),
df[FEATURES], df["churn"], cv=cv, scoring="roc_auc")
mlflow.log_param("C", C)
mlflow.log_metrics({"auc_mean": scores.mean(), "auc_std": scores.std()})
figlie = mlflow.search_runs(experiment_names=["churn-cv"],
filter_string=f"tags.mlflow.parentRunId = '{parent.info.run_id}'",
order_by=["metrics.auc_mean DESC"])
print(figlie[["params.C", "metrics.auc_mean", "metrics.auc_std"]].round(4).to_string())
spread = figlie["metrics.auc_mean"].max() - figlie["metrics.auc_mean"].min()
print("differenza tra miglior e peggior C:", round(spread, 4), "| auc_std tipica:", round(figlie["metrics.auc_std"].mean(), 4))
# params.C metrics.auc_mean metrics.auc_std
# 0 10.0 0.7364 0.0193
# 1 1.0 0.7364 0.0193
# 2 0.1 0.7358 0.0194
# 3 0.01 0.7356 0.0196
# differenza tra miglior e peggior C: 0.0008 | auc_std tipica: 0.0194
Cosa fa: nested=True apre una run dentro un'altra, e MLflow registra il legame (tag mlflow.parentRunId), così nell'interfaccia la ricerca appare come un'unica voce espandibile e nello script si ritrovano le figlie con un filtro, che qui ne restituisce quattro. cross_val_score restituisce i 5 punteggi; log_metrics ne registra più di uno con una sola chiamata.
Leggere questa tabella è l'abilità che si vuole allenare. La differenza tra il miglior valore di C e il peggiore è 0.0008; la deviazione standard dei singoli fold è circa 0.019, più di venti volte più grande. In altre parole: tra questi quattro valori di C non c'è una differenza che i dati riescano a distinguere dal rumore. Scegliere C = 10 perché "ha la media più alta" sarebbe una scelta arbitraria presentata come un risultato; e il confronto dello script precedente, dove C = 0.01 sembrava il migliore, è l'esempio di ciò che una sola divisione può far credere. La conclusione onesta è che i quattro modelli sono equivalenti su questo problema, e conviene scegliere il più semplice o il più stabile (C più piccolo, più regolarizzato). Senza avere registrato la deviazione standard, avresti avuto una classifica e una falsa certezza. I numeri dipendono dai dati sintetici e dal seme: non sono una proprietà della regressione logistica.
Cercare nelle run: il linguaggio dei filtri
search_runs accetta un filtro con una sintassi simile a SQL. Ho provato quelli della tabella sull'esperimento churn-baseline (quattro run), e la colonna di destra riporta quante run restituiscono:
| Domanda | Filtro | Run trovate |
|---|---|---|
| Le run con AUC sopra una soglia | metrics.auc > 0.7 | 4 |
| Una configurazione precisa | params.C = '1.0' (le stringhe tra apici) | 1 |
| Combinare condizioni | metrics.auc > 0.7 and params.C = '1.0' | 1 |
| Per tag | tags.dataset = 'sintetico-v1' | 4 |
| Solo le run finite correttamente | attributes.status = 'FINISHED' | 4 |
| Solo le figlie di una run | tags.mlflow.parentRunId = '<id>' | 4 (esperimento churn-cv) |
L'ordinamento si dà con order_by=["metrics.auc_mean DESC"], e max_results limita il numero. Il risultato è una tabella pandas (DataFrame) con colonne params.*, metrics.* e tags.*: ci puoi calcolare sopra quello che vuoi. La pagina di MLflow sulla ricerca delle run descrive la sintassi completa dei filtri.
Più persone, un solo tracking server
Con SQLite in un file locale il quaderno è tuo. Per un team servono tre cose: un server che mostri a tutti la stessa interfaccia, un database vero come backend (di solito PostgreSQL) e un archivio condiviso per gli artefatti (una cartella di rete o un archivio di oggetti). La riga di avvio ha la stessa forma di quella locale, con indirizzi diversi:
mlflow server --backend-store-uri postgresql://utente@host/mlflow \
--artifacts-destination s3://bucket-degli-artefatti \
--host 0.0.0.0 --port 5000
# schema da adattare, non eseguito: richiede un database e un archivio a cui collegarsi
Gli indirizzi sono d'esempio. Con un server condiviso tornano le regole dell'articolo sulle variabili d'ambiente e i segreti: l'indirizzo del server (MLFLOW_TRACKING_URI) arriva da una variabile d'ambiente, e le credenziali non finiscono nel codice. E tornano le domande di sicurezza: chi può leggere i modelli registrati? Chi può promuovere un alias (ne parla l'articolo sul model registry)? L'opzione --host 0.0.0.0 apre il server a tutta la rete: va fatta solo dietro un'autenticazione e un proxy.
Il caso reale: addestramento senza tracciamento
Il servizio ML di questo sito riaddestra un modello (un ensemble per un sistema di previsioni sportive) ogni lunedì alle 03:00 UTC con un timer systemd (OnCalendar=Mon *-*-* 03:00:00 UTC in infra/systemd/sport-intelligence-rolling-train.timer). L'unità lancia python -m app.sport_intelligence.train_advanced --catboost --output /var/lib/portfolio/sport_intelligence/model_v2.joblib. Non usa MLflow, e per un progetto con una persona e un modello unico è una scelta difendibile. Ma mostra dove sta il costo: dall'unità si legge che l'output va sempre allo stesso percorso e che non c'è un passaggio che conservi la versione precedente o le sue metriche (non ho verificato altri meccanismi sul server). Se una settimana il modello peggiora, la domanda "com'era quello della settimana scorsa?" non ha una risposta strutturata. È esattamente il dolore che un tracking risolve, e il momento giusto per introdurlo è quando quel dolore si presenta.
Trade-off
- MLflow contro un foglio di calcolo: per cinque esperimenti, il foglio basta. MLflow vince dai primi dieci-venti, quando contano ricerca, confronto e artefatti collegati.
- SQLite contro un server condiviso: SQLite è perfetto da soli; per un team serve un tracking server con database vero (per esempio PostgreSQL) e un archivio per gli artefatti.
- Tracciare tutto contro il rumore: troppe metriche per run rendono il confronto illeggibile. Poche, scelte prima.
Le tre scelte hanno lo stesso criterio sottostante: adotta lo strumento quando il costo di non averlo (confronti a memoria, risultati non riproducibili) supera il costo di gestirlo. Le soglie "cinque" e "dieci-venti esperimenti" sono una regola pratica, non una misura: dipendono da quanto è costoso rifare una prova e da quante persone leggono i risultati.
Come organizzare gli esperimenti perché restino leggibili
Un esperimento per domanda: churn-baseline, churn-cv, churn-feature-nuove; mescolare domande diverse nello stesso esperimento rende inutile il confronto. Cambia una cosa alla volta, perché se in una run cambi parametro, dati e feature insieme non sai a cosa attribuire la differenza. Dai nomi parlanti alle run (C=0.1, senza-reclami, non prova7) e usa un piccolo vocabolario di tag coerenti (dataset, commit, scopo): i tag servono a filtrare, e un tag scritto in tre modi non filtra. Usa le run annidate per le ricerche di parametri, registra anche le run fallite con il loro stato, perché sapere che una configurazione non funziona è informazione, e ogni tanto pulisci le run di prova: un archivio pieno di rumore scoraggia chi lo consulta.
Il rischio opposto alla mancanza di tracciamento è l'eccesso: registrare duecento metriche per run "perché si può", e poi non guardarne nessuna. Il criterio è scegliere prima poche metriche, quelle che userai per decidere. Tentazioni tipiche: cambiare la metrica principale dopo aver visto i risultati (p-hacking: scegliere a posteriori il numero che conferma ciò che speravi), confrontare run fatte su dataset diversi come se fossero equivalenti, registrare dati personali tra gli artefatti, e usare il tracciamento come sostituto della riflessione invece che come suo supporto. Dimenticare di chiudere una run è un altro errore comune: usa sempre il blocco with. E ricorda che le cartelle mlruns/ e il file mlflow.db sono output, non codice: vanno nel .gitignore e nel .dockerignore.
Una routine di lavoro in quattro mosse
- Prima di addestrare: scegli il nome dell'esperimento e cosa varierai (un parametro per volta).
- Durante:
with start_run, registra parametri, seed, dataset, commit. - Dopo:
search_runsordinata per metrica, e guarda i numeri insieme alla variabilità fra fold. - Decidi e registra la decisione: un tag sulla run scelta (
mlflow.set_tag("scelto", "si")) o, meglio, passa al registry.
L'ordine è quello di un ragionamento: la prima mossa fissa la domanda, la seconda garantisce che i dati siano registrati quando li conosci, la terza ti impedisce di decidere sul primo numero che vedi, e solo la quarta produce una decisione tracciata. Saltare la prima porta a esperimenti non confrontabili; saltare la terza a scelte basate sul rumore.
Dal tracciamento al processo
Il tracciamento risponde alla domanda "cosa ho provato e cosa è venuto fuori". Non risponde ancora a "quale modello va in produzione e come cambio idea". Per questo l'articolo successivo aggiunge il registry: la run che hai scelto (dopo aver letto la tabella con la deviazione standard) diventa una versione registrata del modello, con la sua etichetta e la sua regola di promozione. Il filo conduttore è che a ogni passo si conserva l'identità: l'esperimento ha un identificatore, il modello una versione, il rilascio un tag o un digest (vedi registry, tag e digest), il codice un hash di commit. Se in un momento qualsiasi non sai rispondere a "quale?", la catena si è spezzata lì. Per versionare dati e modelli grandi c'è anche l'articolo su versionare dataset e modelli.
Prova tu: quattro esercizi guidati
Esercizio 1: quattro run e una query
Con il progetto churn di questo articolo (o un tuo modello), esegui lo script di tracciamento con quattro valori di C e poi interroga i risultati con mlflow.search_runs(..., order_by=["metrics.auc DESC"]). Dovresti vedere una tabella ordinata (nella mia prova: 0.7295 per C=0.01, 0.7285 per 0.1, 0.7282 per 1 e 10). Se vuoi, avvia anche l'interfaccia con uv run mlflow server --backend-store-uri sqlite:///mlflow.db --port 5000 e trova le stesse run a mano. Cosa impari: che cosa viene registrato e il vantaggio della query.
Esercizio 2: guardare dentro
Apri mlflow.db con il modulo sqlite3 di Python e conta le righe delle tabelle runs, params e metrics. Poi elenca la cartella mlruns/ e apri i file MLmodel e requirements.txt di un modello salvato. Dovresti vedere 4 run, 16 parametri, 4 metriche e un requirements.txt con le versioni esatte. Cosa impari: che il tracciamento è una base di dati e dei file, nulla di magico.
Esercizio 3: la prova di riproduzione
Leggi da MLflow i parametri di una run, riaddestra con quei valori e confronta la metrica con ==. Dovresti vedere uguali: True. Poi togli data_seed dai parametri registrati e prova a riprodurre: dovrai ricordarlo a memoria, ed è esattamente il guaio da evitare. Cosa impari: che la riproducibilità si verifica e che la lacuna tipica è un parametro dimenticato.
Esercizio 4: media e deviazione standard
Rifai la ricerca su C con la validazione incrociata a 5 fold e run annidate (padre e figlie). Registra auc_mean e auc_std. Dovresti vedere che la differenza tra il miglior e il peggior C è molto minore della deviazione standard (nella mia prova 0.0008 contro circa 0.019). Scrivi in una riga la conclusione onesta che ne trai. Cosa impari: a non scegliere un parametro per una differenza che i dati non distinguono dal rumore.
Esercizio extra: prova a registrare due volte lo stesso parametro nella stessa run e leggi l'errore di MLflow. Poi cambia valore in una run nuova. Il messaggio ti dice una regola del sistema: un parametro descrive come è stata fatta la run e non si riscrive.
Che cosa sai fare adesso
Sai registrare un addestramento con MLflow su un database SQLite (esperimento, run, parametri, metriche, tag, modello), confrontare le run con search_runs e filtrarle con la sintassi dei filtri. Sai che cosa tracciare perché un numero sia riproducibile (parametri, seed dei dati e dello split, dataset, commit, dipendenze) e come verificarlo ricostruendo una run da quanto è registrato. Sai leggere un confronto tra parametri tenendo conto della deviazione standard, e non scegliere un vincitore per una differenza che il rumore spiega. Il passo successivo è dare una versione e un nome al modello scelto: model registry, versioni e alias.
Nella stessa serie trovi anche ML-Ops: dal notebook al servizio.
Fonti
- Documentazione di MLflow Tracking: run, esperimenti, backend store e artefatti, consultata il 2026-11-12.
- MLflow, ricerca delle run: sintassi di
filter_string, consultata il 2026-11-12. - scikit-learn, persistenza dei modelli: pickle e esecuzione di codice, skops, consultata il 2026-11-12.
- Licenza di MLflow su GitHub: testo della licenza Apache 2.0, consultata il 2026-11-12.
Come sono stati verificati i listati: tutti i listati Python sono stati eseguiti due volte (dall'inizio, in una cartella nuova) con MLflow 3.18.0, scikit-learn 1.9.1, pandas 3.1.1 e Python 3.12, con la stessa uscita, in un ambiente creato fuori dal repository. Il rilevamento del progetto UV è stato provato con un pyproject.toml e un uv.lock di prova (UV 0.12.21). Non eseguiti, dichiarati nel testo: mlflow server (porta in ascolto), il server con PostgreSQL e archivio S3, autolog. Il timer del servizio ML è stato letto in infra/systemd/sport-intelligence-rolling-train.timer e nel file .service corrispondente. I dati sono sintetici e inventati.
Commenti
Caricamento commenti...
Accedi per commentare