Salta al contenuto principale

113 - ML-Ops: model registry, versioni, alias e promozione di un modello

Come registrare un modello con MLflow, caricarlo per alias, promuovere un candidato con una regola scritta e tornare indietro spostando un'etichetta.

Cosa ne pensi di questo articolo?

113 - ML-Ops: model registry, versioni, alias e promozione di un modello
Machine Learning Engineerfedericocalo.dev

In Breve

Come registrare un modello con MLflow, caricarlo per alias, promuovere un candidato con una regola scritta e tornare indietro spostando un'etichetta.

Un esperimento tracciato (vedi MLflow, tracciare gli esperimenti) ti dice come è andata una prova. Un model registry risponde a un'altra domanda: quale modello è quello in uso, e come lo cambio senza fare danni? È un catalogo di modelli con un nome, numeri di versione, metadati e alias, cioè etichette spostabili come champion. Con MLflow 3.18 registri il miglior modello di un esperimento, lo fai caricare a un servizio per nome logico e non per percorso di file, ne promuovi uno nuovo con una regola scritta e torni indietro spostando un'etichetta.

Tutti i listati Python di questo articolo sono stati eseguiti due volte, in una cartella temporanea fuori dal repository, con MLflow 3.18.0, scikit-learn 1.9.1 e Python 3.12, e le due esecuzioni hanno dato la stessa uscita (cambiano soltanto gli identificatori m-... che MLflow assegna ai modelli). Partono dagli stessi quattro esperimenti e dagli stessi dati sintetici dell'articolo precedente, quindi i numeri sono coerenti con quelli. Nessun server MLflow è stato avviato.

Le parole del registry di modelli

Servono due termini dell'articolo sul tracking: la run, una esecuzione di addestramento registrata, e l'artefatto, un file prodotto da una run, come il modello salvato. La documentazione del Model Registry aggiunge un piccolo vocabolario, che ho riassunto qui:

  • Modello registrato: un nome logico (churn-classifier) sotto cui il registry raccoglie tutte le versioni. Come il titolo di un libro che ha più edizioni.
  • Versione: un'edizione numerata (1, 2, 3...). Ogni nuova registrazione con lo stesso nome incrementa il numero; non sovrascrive la precedente. È l'identificatore stabile, il corrispettivo per i modelli del digest di cui parla l'articolo su registry, tag e digest.
  • Alias: un riferimento con nome, modificabile, che punta a una sola versione. Come «l'edizione consigliata» in libreria: oggi è la terza, domani la quarta, senza che le edizioni cambino.
  • Champion e challenger: i due ruoli usuali. Il campione è la versione in servizio; lo sfidante è la candidata che si sta valutando per sostituirla.
  • Promozione e rollback: la prima sposta l'alias champion su una versione nuova, il secondo lo rimette sulla precedente.
  • Model URI: l'indirizzo di un modello, nella forma models:/nome@alias oppure models:/nome/versione.
  • Lineage (genealogia): la catena che lega una versione alla run che l'ha prodotta, e questa ai dati e al codice.

Il filo che tiene insieme le voci è la distinzione fra ciò che non cambia e ciò che si sposta: nome e numero di versione identificano, l'alias decide. Le ultime due voci servono a rispondere in seguito a «da dove viene questo modello?» e a caricarlo senza ambiguità. Una voce manca di proposito: gli stage (Staging, Production, Archived), il vecchio sistema di MLflow con stati fissi. La documentazione sul flusso di lavoro dichiara che dalla versione 2.9.0 sono deprecati e saranno rimossi in una futura versione maggiore, quindi per codice nuovo si usano gli alias.

Versioni fisse e alias mobili in un disegno. Le AUC sono quelle dei due modelli che registreremo più avanti:

Model registry: versioni numerate e alias champion e challenger che puntano a una versione e si possono spostareversione 1auc 0.7295versione 2auc 0.7282championchallengerchurn-classifierspostare l'alias = promozione o rollback

L'analogia: la tabella dei turni

In un reparto d'ospedale esiste l'elenco dei medici (le persone, ognuna con le sue qualifiche) e la tabella dei turni («di guardia stanotte: dott. Bianchi»). Se Bianchi si ammala, nessuno riscrive i curriculum: si cambia il nome nella tabella dei turni. Il registry funziona così. Le versioni sono le persone: numerate, con le loro credenziali (metriche, dataset, dipendenze). L'alias è il turno: champion indica quale versione serve il traffico adesso.

Perché non basta un file

La soluzione ingenua è un file model.joblib in una cartella, che ogni addestramento sovrascrive. Un servizio che salva il modello sempre nello stesso percorso e lo riaddestra a scadenza fissa (per esempio ogni lunedì) lavora così, e funziona finché va tutto bene. Quando qualcosa va storto emergono quattro limiti: non puoi tornare alla settimana scorsa perché il file vecchio non esiste più; non sai quale metrica avesse il file in uso; non c'è un punto in cui qualcuno decide «questo va in produzione»; e un nome come model_v2 indica la generazione del modello, non il singolo addestramento. Il registry nasce per togliere questi quattro limiti con poche chiamate.

Registrare un modello

Si parte dai quattro esperimenti dell'articolo precedente (churn-baseline, con C uguale a 0.01, 0.1, 1.0 e 10.0). Lo script prende la run con l'AUC migliore, ne registra il modello sotto il nome churn-classifier, annota l'AUC come tag della versione e assegna l'alias champion:

# register.py
import mlflow
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()

best = mlflow.search_runs(
    experiment_names=["churn-baseline"], order_by=["metrics.auc DESC"], max_results=1
).iloc[0]
mv = mlflow.register_model(best["tags.model_uri"], "churn-classifier")
client.set_model_version_tag("churn-classifier", mv.version, "auc", f'{best["metrics.auc"]:.4f}')
client.set_registered_model_alias("churn-classifier", "champion", mv.version)
print("versione", mv.version, "-> alias champion", "| C =", best["params.C"])

# Created version '1' of model 'churn-classifier'.
# versione 1 -> alias champion | C = 0.01

Tre passi. register_model crea la versione 1 del modello registrato; il tag auc annota la qualità sulla versione, così chi consulta il registry non deve risalire alle run; set_registered_model_alias designa la versione come quella in uso. Noti che la versione 1 non è quella con C più alto ma quella con C = 0.01: era la migliore per AUC, anche se, come mostra l'articolo precedente, la differenza con le altre è dentro il rumore. Il registry cataloga ciò che gli dici di catalogare, non giudica se la scelta è sensata.

Perché best["tags.model_uri"] e non runs:/<id>/model? Nell'articolo precedente ho annotato come tag l'URI del modello salvato (models:/m-...). Ho provato anche la forma runs:/<id>/model: MLflow 3.18 registra comunque il modello, ma stampa un avviso, «Run ... has no artifacts at artifact path 'model', registering model based on models:/m-... instead». Funziona, ma è un percorso di compatibilità: in MLflow 3 i modelli sono entità a sé (logged model) con un proprio identificativo, e conviene riferirsi a quello.

Alias, non stage

Gli alias sono etichette arbitrarie che scegli tu, ognuna puntata a una sola versione. Puoi avere champion e challenger contemporaneamente, o un alias per ogni ambiente. Registro come candidata la run con C = 10.0 (terza per AUC, a pari merito con C = 1.0) e le do l'alias challenger:

import mlflow
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()
NAME = "churn-classifier"

run = mlflow.search_runs(experiment_names=["churn-baseline"], filter_string="params.C = '10.0'").iloc[0]
mv = mlflow.register_model(run["tags.model_uri"], NAME)
client.set_model_version_tag(NAME, mv.version, "auc", f'{run["metrics.auc"]:.4f}')
client.set_registered_model_alias(NAME, "challenger", mv.version)
for alias in ("champion", "challenger"):
    print(alias, "->", client.get_model_version_by_alias(NAME, alias).version)

# champion -> 1
# challenger -> 2

Lo script cerca la run con un filtro (params.C = '10.0', i parametri sono stringhe), registra il suo modello, che diventa la versione 2, e poi stampa dove puntano i due alias. Un alias può puntare a una sola versione, ma una versione può avere più alias: nessuna delle due cose è un errore.

Caricare per alias e promuovere: il servizio non conosce il numero

Il codice del servizio dice «dammi il campione», non «dammi la versione 1». Per cambiare il modello in servizio non tocchi il codice né ricostruisci l'immagine (vedi l'articolo sul Dockerfile): sposti l'alias e riavvii il servizio, che lo ricarica all'avvio. Aggiornare il modello e aggiornare il software diventano due operazioni con due ritmi diversi. Lo script che segue carica per alias un cliente fisso (3 mesi di anzianità, 4 reclami), poi promuove la versione 2 e infine torna indietro:

import mlflow
import pandas as pd
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()
NAME = "churn-classifier"
cliente = pd.DataFrame({"tenure": [3], "complaints": [4]})

def risposta():
    model = mlflow.sklearn.load_model(f"models:/{NAME}@champion")
    return round(float(model.predict_proba(cliente)[0, 1]), 4)

print("prima          :", risposta())
client.set_registered_model_alias(NAME, "champion", "2")   # promozione
print("dopo promozione:", risposta(), "| champion ->", client.get_model_version_by_alias(NAME, "champion").version)
client.set_registered_model_alias(NAME, "champion", "1")   # rollback
print("dopo rollback  :", risposta(), "| champion ->", client.get_model_version_by_alias(NAME, "champion").version)

# prima          : 0.7993
# dopo promozione: 0.8051 | champion -> 2
# dopo rollback  : 0.7993 | champion -> 1

La funzione risposta risolve l'alias a ogni chiamata, ed è per questo che dopo la promozione la stessa richiesta dà una probabilità diversa (0.8051 contro 0.7993): il modello in servizio è un altro, e il codice non è cambiato. Il rollback è la stessa riga con il numero precedente: nessuna ricostruzione, nessun file da recuperare. Un processo già avviato, però, non vede lo spostamento da solo: nello script il modello viene ricaricato apposta a ogni chiamata, un servizio reale lo fa all'avvio e va quindi riavviato. È lo stesso principio dell'identificatore immutabile (la versione) più un'etichetta che si sposta (l'alias).

Una regola di promozione scritta in codice

Promuovere «perché sembra meglio» è il modo di far entrare in produzione un modello peggiore. La decisione va scritta. La funzione confronta il tag auc del candidato con quello del campione e sposta l'alias solo se il candidato lo supera di almeno un margine:

import mlflow
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()

NAME = "churn-classifier"
MARGINE = 0.01   # da scegliere con i dati osservati, come per la variabilità fra split

def auc_of(client, version):
    return float(client.get_model_version(NAME, version).tags["auc"])

def promote_if_better(client, candidate):
    champion = client.get_model_version_by_alias(NAME, "champion").version
    if auc_of(client, candidate) >= auc_of(client, champion) + MARGINE:
        client.set_registered_model_alias(NAME, "champion", candidate)
        return True
    return False

print("promossa la 2:", promote_if_better(client, "2"))
print("champion ->", client.get_model_version_by_alias(NAME, "champion").version)

# promossa la 2: False
# champion -> 1

Provando a promuovere la versione 2 (AUC 0.7282) contro la 1 (0.7295) il risultato è False e champion resta alla versione 1. Il margine non è un dettaglio. L'articolo precedente ha misurato, con la validazione incrociata sugli stessi dati, una deviazione standard dell'AUC fra fold di circa 0.019: un candidato che batte il campione di 0.002 su una sola divisione non è migliore, è fortunato. Il margine di 0.01 qui è un valore didattico, e a rigore sta ancora dentro quel rumore: è il motivo per cui più avanti il confronto si sposta su un insieme di valutazione fisso e più grande.

Cosa conviene salvare su ogni versione

Su ogni versione registra la metrica principale e il dataset di valutazione su cui è stata misurata, l'identità dei dati di addestramento e l'hash del commit (vedi Git, versionare il lavoro), chi l'ha promossa, quando e perché, in un tag di testo libero. Le dipendenze non servono a mano: MLflow le archivia da solo, lockfile compreso, se il progetto usa UV (uv e la gestione dei pacchetti). Il criterio per ogni valore è chiedersi se tra sei mesi, davanti a un modello che si comporta male, ti servirà per capire da dove viene.

Una trappola vista in prova: dove sono finiti gli alias?

Chiedendo l'elenco delle versioni con search_model_versions, nella mia esecuzione il campo aliases di ogni versione risultava vuoto, mentre gli alias esistevano. Il listato mette a confronto tre modi di fare la stessa domanda e, nell'ultima riga, mostra i campi che una versione registrata porta con sé:

import mlflow
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()
NAME = "churn-classifier"
print("search_model_versions:", [(v.version, list(v.aliases)) for v in client.search_model_versions(f"name='{NAME}'")])
print("get_registered_model :", dict(client.get_registered_model(NAME).aliases))
mv = client.get_model_version_by_alias(NAME, "champion")
print(mv.name, mv.version, mv.status, mv.source[:9])

# search_model_versions: [(2, []), (1, [])]
# get_registered_model : {'challenger': 2, 'champion': 1}
# churn-classifier 1 READY models:/m

L'elenco di search_model_versions restituisce (2, []) e (1, []): nessun alias. get_registered_model(...).aliases restituisce invece il dizionario corretto, {'challenger': 2, 'champion': 1}, e get_model_version_by_alias trova il campione. Se costruisci un controllo «chi è il campione?» prendendolo dall'elenco, puoi concludere che non ce n'è nessuno. Quando serve la risposta, chiedila con la funzione fatta per quella domanda, e verifica il controllo con un caso noto. Il comportamento l'ho osservato con la 3.18.0 e può cambiare con le versioni.

L'ultima riga stampa nome logico, numero di versione, stato (READY: il modello è pronto all'uso) e l'inizio della sorgente, cioè l'indirizzo dell'artefatto (models:/m-..., il modello come entità di MLflow 3). Il campo run_id della stessa versione è la genealogia: da lì risali ai parametri, alla metrica, al dataset annotato come tag. Il registry non copia il modello: lo cataloga e ne gestisce le etichette.

Due modi di caricare lo stesso modello

Lo stesso modello si può caricare nel formato nativo, per versione, o nel formato generico pyfunc, l'interfaccia universale di MLflow, per alias. Sembrano equivalenti e non lo sono:

import mlflow
import pandas as pd

mlflow.set_tracking_uri("sqlite:///mlflow.db")
X = pd.DataFrame({"tenure": [3, 60], "complaints": [4, 0]})
m1 = mlflow.sklearn.load_model("models:/churn-classifier/1")          # per versione
m2 = mlflow.pyfunc.load_model("models:/churn-classifier@champion")    # per alias, formato generico
print(m1.predict_proba(X)[:, 1].round(4))
print(m2.predict(X))

# [0.7993 0.1862]
# [1 0]

Il primo carica una versione precisa come oggetto scikit-learn, con tutti i suoi metodi, tra cui predict_proba. Il secondo espone per ogni libreria lo stesso predict. La trappola è nel risultato: pyfunc.predict ha restituito [1 0], cioè le classi previste, non le probabilità. Se il servizio si aspetta una probabilità e carica il modello in formato generico, riceve 0 e 1 e le soglie di business sbagliano senza alcun errore. Il rimedio: caricare nel formato nativo (mlflow.sklearn.load_model) quando servono metodi specifici, e provarlo con un test che controlla tipo e intervallo dell'output (vedi testare i modelli con pytest).

Un servizio che dichiara quale versione serve

Il principio «registra sempre quale versione ha caricato il servizio» si realizza con poche righe nel codice di avvio, al posto del caricamento da file. L'indirizzo del registry arriva da una variabile d'ambiente (variabili d'ambiente e segreti), qui impostata a sqlite:///mlflow.db:

from mlflow import MlflowClient
import mlflow, os

state = {}
NAME = "churn-classifier"
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])      # da variabile d'ambiente

version = MlflowClient().get_model_version_by_alias(NAME, "champion").version
model = mlflow.sklearn.load_model(f"models:/{NAME}/{version}")   # carica per NUMERO: nessuna corsa tra le due righe
state["model"], state["model_version"] = model, version
print({"status": "ok", "model_version": state["model_version"]})

# con MLFLOW_TRACKING_URI=sqlite:///mlflow.db
# {'status': 'ok', 'model_version': 1}

Il dettaglio sottile è nella penultima riga: si risolve l'alias in un numero e si carica per numero. Se si caricasse due volte per alias e tra le due chiamate qualcuno spostasse l'alias, il numero dichiarato e il modello caricato potrebbero non coincidere. La versione finisce poi nel controllo di salute del servizio, descritto nell'articolo sul deploy. Con questo, «quale modello risponde?» ha una risposta che si legge con un curl.

Confrontare campione e candidato nel modo giusto

La regola di promozione di prima confrontava il tag auc delle due versioni, e nasconde un errore sottile: quei due valori furono misurati su split diversi (ogni addestramento ha il suo), e una differenza di pochi millesimi di AUC tra split diversi è dentro il rumore. Il confronto onesto è tra i due modelli sullo stesso insieme di valutazione, fisso e mai usato per addestrare, detto holdout. Lo script riaddestra un candidato su dati nuovi (1.500 clienti con un altro seme: immagina l'arrivo di dati più recenti), valuta campione e candidato sullo stesso holdout di 3.000 clienti, registra il candidato e decide con il margine:

import mlflow
import numpy as np
from mlflow import MlflowClient
from sklearn.metrics import roc_auc_score

from churn.data import make_dataset
from churn.data import FEATURES
from churn.model import train

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()
NAME = "churn-classifier"
MARGINE = 0.01

holdout = make_dataset(n=3000, seed=99)       # insieme FISSO, mai usato per addestrare

def auc_su_holdout(model) -> float:
    return roc_auc_score(holdout["churn"], model.predict_proba(holdout[FEATURES])[:, 1])

champion_v = client.get_model_version_by_alias(NAME, "champion").version
champion = mlflow.sklearn.load_model(f"models:/{NAME}/{champion_v}")

with mlflow.start_run(run_name="riaddestramento"):
    candidate, _ = train(make_dataset(n=1500, seed=7), seed=0, C=1.0)
    info = mlflow.sklearn.log_model(candidate, name="model")
    a_champ, a_cand = auc_su_holdout(champion), auc_su_holdout(candidate)
    mlflow.log_metrics({"holdout_auc_campione": a_champ, "holdout_auc_candidato": a_cand})
    mv = mlflow.register_model(info.model_uri, NAME)
    client.set_model_version_tag(NAME, mv.version, "holdout_auc", f"{a_cand:.4f}")
    if a_cand >= a_champ + MARGINE:
        client.set_registered_model_alias(NAME, "champion", mv.version)
        esito = "promosso"
    else:
        client.set_registered_model_alias(NAME, "challenger", mv.version)
        esito = f"NON promosso (margine richiesto {MARGINE}): v{mv.version} resta challenger"
print(f"campione v{champion_v}: {a_champ:.4f} | candidato: {a_cand:.4f} | differenza {a_cand - a_champ:+.4f}")
print(esito)

# campione v1: 0.7208 | candidato: 0.7213 | differenza +0.0005
# NON promosso (margine richiesto 0.01): v3 resta challenger

Il campione è caricato per numero, risolto dall'alias. Le due metriche finiscono nella run, il candidato nel registry con il tag holdout_auc, e diventa campione solo se supera il campione di almeno il margine; altrimenti resta challenger. Qui l'AUC è praticamente identica (+0.0005) e il candidato non è stato promosso. È un risultato utile: il sistema ha riaddestrato, registrato, misurato e lasciato in servizio il modello che c'era, senza intervento umano e senza rischio. Il candidato è la versione 3 e ha preso l'alias challenger, spostandolo dalla versione 2.

Il modo ombra (shadow): stesse richieste, nessun rischio

Anche quando due modelli hanno la stessa AUC possono decidere in modo diverso sui singoli clienti. Prima di promuovere, un'abitudine prudente è far girare il candidato «in ombra»: riceve le stesse richieste del campione, ma la sua risposta non viene usata, solo registrata per confronto. Una versione in miniatura, su 1.000 clienti nuovi, è la coda dello stesso script:

X = make_dataset(1000, seed=123)[FEATURES]
p1, p2 = champion.predict_proba(X)[:, 1], candidate.predict_proba(X)[:, 1]
print(round(float(np.abs(p1 - p2).mean()), 4))
print(int(((p1 >= .5) != (p2 >= .5)).sum()), "su 1000")

# 0.0094
# 13 su 1000

La differenza media tra le probabilità dei due modelli è di 0.0094, e 13 clienti su 1.000 (l'1,3%) ricevono una decisione diversa alla soglia 0,5, pur con AUC praticamente identica. L'AUC misura la capacità di ordinare i clienti, non dice se due modelli prenderebbero le stesse decisioni. Per un servizio le cui previsioni attivano un'azione (una telefonata di fidelizzazione, un blocco), quell'1,3% è un cambio reale di comportamento anche senza un cambio di metrica. In produzione si registrano le risposte del candidato accanto a quelle del campione e si analizzano dopo ore o giorni di traffico reale.

Esistono due strategie vicine, spesso confuse. Il canary manda una piccola quota di richieste vere (per esempio il 5%) al candidato, le cui risposte vengono usate: più informativo, ma con un rischio limitato. L'A/B test è un confronto sperimentale con gruppi casuali di utenti, per misurare l'effetto sul risultato di business; ne tratta l'articolo su A/B testing dei modelli ML. L'ombra è la più sicura: nessun utente ne subisce la risposta.

Metadati, descrizioni e alias da togliere

Ogni versione può avere una descrizione di testo e tag liberi, e un alias non più necessario si elimina senza toccare la versione. Lo script scrive descrizione e un tag di validazione sulla versione 1, poi mostra gli alias prima e dopo la cancellazione di challenger:

import mlflow
from mlflow import MlflowClient
mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()
client.update_model_version("churn-classifier", "1",
    description="Regressione logistica, dati sintetici seed 0")
client.set_model_version_tag("churn-classifier", "1", "validato_da", "nome-cognome")
print(client.get_model_version("churn-classifier", "1").description)
print(sorted(client.get_registered_model("churn-classifier").aliases))
client.delete_registered_model_alias("churn-classifier", "challenger")
print(sorted(client.get_registered_model("churn-classifier").aliases))

# Regressione logistica, dati sintetici seed 0
# ['challenger', 'champion']
# ['champion']

La descrizione resta leggibile nell'interfaccia e dall'API (get_model_version(...).description restituisce la stringa scritta). Un set minimo di tag che ti toglie domande future: holdout_auc (la metrica e su quale insieme), dataset, commit, validato_da e la data. Le versioni si possono cancellare con delete_model_version, ma farlo toglie la possibilità di tornarci e può rompere un servizio che le carica per numero: è un'operazione da fare solo con una politica chiara, per esempio «si conservano la versione in produzione, la precedente e le ultime N».

La prova di rollback: da fare prima di averne bisogno

Un rollback non provato è una speranza. Un esercizio di dieci minuti, da fare su un ambiente di prova:

  1. Parti con champion sulla versione 1; avvia il servizio e annota la risposta a una richiesta fissa (tenure=3, complaints=4: nel mio caso 0.7993).
  2. Sposta l'alias sulla versione 2 (set_registered_model_alias(... "champion", "2")) e riavvia il servizio. Verifica che il controllo di salute dichiari la versione 2 e che la risposta alla stessa richiesta sia cambiata (nel mio caso 0.8051).
  3. Rimetti l'alias sulla 1, riavvia, e verifica che la risposta sia tornata identica alla prima.
  4. Cronometra l'intera operazione. Se è più di qualche minuto, scrivi in un runbook (il documento con i comandi del rilascio, di cui parla l'articolo su deploy manuale e CI/CD) i comandi esatti, così la prossima volta, magari alle due di notte, sono già pronti.

Il passo 3 è quello che dà fiducia: la risposta «torna identica» prova che il rollback riporta davvero lo stesso comportamento, e che il modello non dipende da qualcos'altro che nel frattempo è cambiato. Un file di preprocessing, per esempio, è un buon motivo per includere la preparazione dei dati dentro il modello salvato, come una Pipeline di scikit-learn.

Un caso tipico: il modello sempre nello stesso file

Immagina un servizio che ogni lunedì ricostruisce un modello (con un timer di systemd, per esempio OnCalendar=Mon *-*-* 03:00:00 UTC) e lo scrive sempre nello stesso percorso, model.joblib, che il servizio legge da una variabile d'ambiente. Rileggendolo con le categorie di questo articolo: nessuna versione numerata, nessun alias, nessun posto dove annotare la metrica di quel giorno, e un rollback possibile solo se esiste una copia del file. È una scelta di semplicità sostenibile con una persona e un modello. I segnali che dicono «ora serve un registry» sono precisi: un riaddestramento peggiora un risultato e non sai di quanto; vuoi confrontare due modelli in parallelo; qualcun altro deve poter decidere la promozione. Il passaggio, quando verrà, è piccolo: salvare il modello con log_model, registrarlo, e far caricare al servizio models:/nome-del-modello@champion al posto del percorso fisso.

Quattro domande a cui un registry sa rispondere

Un registry non è un posto dove «mettere i modelli»: è ciò che ti permette di rispondere, in modo verificabile e in pochi secondi, a quattro domande che in produzione arrivano prima o poi, spesso nel momento peggiore.

  1. Cosa sta girando adesso? Un numero di versione, non «quello che c'è nella cartella».
  2. Da dove viene? La genealogia: quale run, con quali parametri e dati, da quale commit.
  3. Cosa c'era prima, e come ci torno? Le versioni precedenti restano, e tornare a una è spostare un'etichetta.
  4. Chi ha deciso, e perché? Tag e descrizioni sulla versione lasciano una traccia che sopravvive alle persone.

Se una di queste domande, nel tuo progetto, ha come risposta «dipende da chi chiedi», è il segnale che il registry, o almeno la sua disciplina, ti serve. Chi risponde a un utente che segnala un'anomalia deve sapere quale modello l'ha prodotta; senza registry, tornare indietro vuol dire sperare che qualcuno abbia conservato il file giusto.

Accessi e responsabilità

Spostare l'alias champion cambia il modello in produzione: dovrebbe essere un'azione con la serietà di un rilascio di codice. Tre punti da decidere, la cui risposta dipende dalla tua installazione e va verificata nella sua documentazione, non presunta. Chi può promuovere? Se tutti i membri del team hanno scrittura piena sul registry, la regola di promozione è solo una convenzione: un registry condiviso richiede un sistema di permessi o una pipeline unica con il diritto esclusivo di cambiare gli alias. Esiste una traccia delle modifiche? Se il tuo registry non conserva chi ha spostato l'alias e quando, conservala tu, con un tag di data e autore o con una riga nel registro dei rilasci. I servizi ricaricano da soli? Se sì, uno spostamento dell'alias cambia la produzione senza un rilascio: comodo e pericoloso. Se no, serve un riavvio controllato, la scelta più semplice e più sicura.

Trade-off e limiti

  • Registry contro cartella versionata: per un modello solo e un'unica persona, salvare i file con la data nel nome e tenere gli ultimi N può bastare. Il registry ripaga quando devi tornare indietro, confrontare, far decidere un'altra persona, o servire più modelli.
  • Caricare per alias contro versione fissata: l'alias è comodo ma cambia il comportamento senza un rilascio del servizio; la versione fissata nel codice è più rigida ma più tracciabile. Con l'alias, registra sempre quale versione il servizio ha caricato.
  • Un registry da gestire: serve un database vero, copie di sicurezza e permessi di scrittura limitati a chi può promuovere. Gli artefatti devono stare in un posto che il server vede: il database può dire «versione 3» mentre il file è in una cartella invisibile.

Il criterio comune è lo stesso di ogni strumento di processo: il registry si adotta quando il costo di non averlo (modelli non recuperabili, promozioni senza traccia) supera il costo di gestirlo. Tre limiti non dipendono dalla scelta: il registry non decide al posto tuo (la regola di promozione va scritta), non ricarica i servizi già avviati, e non versiona i dati (per quelli vedi dataset e modelli con DVC e lakeFS). Non monitora nemmeno il modello in produzione: se la qualità degrada nel tempo serve la drift detection.

Un ciclo di riaddestramento completo

  1. Trigger: un orario (un riaddestramento settimanale, per esempio) o un segnale di degrado.
  2. Addestra un candidato, tracciato come run, con ambiente riproducibile.
  3. Valuta candidato e campione sullo stesso holdout fisso, e controlla anche i test di comportamento.
  4. Registra il candidato; promuovi solo con la regola scritta, altrimenti resta challenger.
  5. Rilascia con un riavvio controllato e verifica la versione servita.

Questo è, in piccolo, ciò che la serie MLOps: da esperimento a produzione chiama ciclo di addestramento continuo. Il modulo non lo automatizza: ti dà i pezzi con cui decidere, caso per caso, quanto automatizzare, e di quanta automazione ha senso parlare lo dice l'articolo su deploy manuale e CI/CD.

Prova tu: quattro esercizi guidati

Esercizio 1: registrare e caricare per alias

Dopo aver tracciato i quattro esperimenti, registra il migliore con register_model, assegnagli l'alias champion e caricalo con mlflow.sklearn.load_model("models:/churn-classifier@champion"). Dovresti vedere Created version '1' of model 'churn-classifier' e una previsione per un cliente di prova. Prova anche mlflow.pyfunc.load_model sullo stesso indirizzo e confronta l'uscita di predict con quella di predict_proba. Cosa impari: che il formato generico restituisce le classi (nella mia prova [1 0]) e non le probabilità.

Esercizio 2: la prova di rollback

Registra una seconda versione (con C = 10.0), assegnale l'alias challenger, poi sposta champion sulla versione 2 con set_registered_model_alias. Ricarica per alias e annota la risposta per un cliente fisso. Rimetti champion sulla versione 1 e verifica che la risposta torni identica. Dovresti vedere due probabilità diverse e poi la prima di nuovo (nella mia prova, 0.7993, 0.8051 e di nuovo 0.7993 per tenure=3, complaints=4). Cosa impari: che il rollback è un cambio di etichetta e che la sua prova richiede un minuto.

Esercizio 3: il confronto sullo stesso holdout

Scrivi lo script che riaddestra un candidato, lo valuta insieme al campione su un holdout fisso, lo registra e decide la promozione con un margine. Dovresti vedere le due AUC e la decisione (nella mia prova: 0.7208 contro 0.7213, non promosso con margine 0.01). Cambia i dati del candidato (un altro seme, più righe) e osserva se la decisione cambia. Cosa impari: che il confronto va fatto sullo stesso insieme di valutazione, e che «non promuovere» è un risultato valido.

Esercizio 4: due modelli, decisioni diverse

Con campione e candidato, calcola su 1.000 clienti nuovi la differenza media assoluta tra le probabilità e quanti clienti ricevono una decisione diversa a soglia 0,5. Dovresti vedere numeri non nulli anche con AUC quasi identica (nella mia prova, 0.0094 e 13 clienti su 1.000). Scrivi in una frase che cosa significherebbe per il servizio. Cosa impari: che la stessa metrica non implica gli stessi comportamenti, e il valore del confronto in ombra.

Per chi vuole chiudere il cerchio: fai caricare al servizio dell'articolo sul Dockerfile il modello per alias all'avvio, esponi la versione nel controllo di salute e ripeti la prova di rollback attraverso il servizio, riavviandolo. È l'esercizio che unisce quasi tutto il modulo.

Che cosa sai fare adesso

Sai registrare un modello con MLflow, assegnargli un alias e farlo caricare a un servizio per nome logico. Sai promuovere un candidato e tornare indietro spostando un'etichetta, con una regola di promozione scritta e un margine confrontato col rumore, e sai confrontare campione e candidato sullo stesso holdout e in ombra. Sai anche due trappole concrete: l'elenco delle versioni può mostrare gli alias vuoti, e il formato generico restituisce le classi e non le probabilità. Il passo successivo è portare il modello dove gira: deploy, dove gira il modello e come scegliere.

Nella stessa serie trovi anche ML-Ops: dal notebook al servizio.

Fonti

Come sono stati verificati i listati: tutti i listati Python sono stati eseguiti due volte da una cartella nuova con MLflow 3.18.0, scikit-learn 1.9.1 e Python 3.12, con la stessa uscita (salvo gli identificatori m-...). L'avviso di runs:/ è stato riprodotto con una registrazione di prova. Nessun server MLflow è stato avviato; il controllo di salute dello script del servizio è il dizionario stampato, non una risposta HTTP. Clienti, anzianità, reclami e probabilità sono dati sintetici inventati.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti