Salta al contenuto principale

107 - ML-Ops: testare un modello con invarianti, soglie e dati

Come si testa un modello senza risposta esatta: test sui dati, invarianti, soglie scelte dopo aver misurato la variabilità e test del servizio.

Cosa ne pensi di questo articolo?

107 - ML-Ops: testare un modello con invarianti, soglie e dati
Machine Learning Engineerfedericocalo.dev

In Breve

Come si testa un modello senza risposta esatta: test sui dati, invarianti, soglie scelte dopo aver misurato la variabilità e test del servizio.

Testare codice normale è facile perché c'è una risposta esatta: 2 + 2 fa 4. Un modello di machine learning non ha una risposta esatta: ha una qualità, che si misura con un numero, e quel numero cambia con i dati, con lo split e con il seed. La domanda del test diventa un'altra: "il modello è ancora abbastanza buono, e si comporta in modo sensato?". In questa lezione scrivi tre famiglie di test (sui dati, sulle invarianti, sulle soglie) su un classificatore di abbandono clienti, e vedi come reagiscono quando rompi il modello di proposito. Parte dai fondamenti di pytest.

Le parole della valutazione

L'etichetta (label) è la risposta giusta nei dati di addestramento, qui "il cliente ha abbandonato, sì o no", come la soluzione in fondo al libro di esercizi. Lo split separa i dati in una parte per addestrare il modello e una per valutarlo: si valuta su dati che il modello non ha mai visto, come un esame con domande nuove, perché sugli stessi esercizi studiati il voto direbbe solo quanto bene li ha memorizzati. Il seed (seme) è il numero che rende ripetibile ciò che è casuale: dato lo stesso seed il "sorteggio" dello split esce identico ogni volta. La metrica è un numero che riassume la qualità del modello; la soglia (threshold) è il valore minimo di una metrica sotto il quale il test fallisce.

L'AUC (area sotto la curva ROC) risponde a "se scelgo a caso un cliente che ha abbandonato e uno che è rimasto, con quale probabilità il modello dà un punteggio più alto al primo?": 1,0 è l'ordinamento perfetto, 0,5 è come tirare a indovinare, sotto 0,5 è peggio che a caso. Un'invariante è una proprietà che deve valere sempre, qualunque siano i valori esatti. Un test instabile (flaky) a volte passa e a volte fallisce senza che il codice sia cambiato, ed è peggio di un test rosso perché insegna a ignorare il rosso. L'overfitting (sovradattamento) è quando il modello ha imparato a memoria i dati di addestramento e va male su quelli nuovi, ed è il motivo per cui si valuta su dati separati.

I tre tipi di test di questa lezione guardano il modello da tre lati. Se ne rompi il funzionamento di proposito, devono diventare rossi:

Le tre famiglie di test per un modello: dati in ingresso, invarianti di comportamento, soglie di metrica1. Daticosa entra2. Invarianticomportamenti sensati3. Sogliequalità minimaModellorotto di proposito = i test devono diventare rossi

L'analogia: il collaudo di un ponte

Nessuno collauda un ponte verificando che sia identico a un altro: lo si collauda controllando che regga carichi minimi (una soglia), che non si deformi in modi assurdi (invarianti), e che i materiali siano quelli dichiarati (dati in ingresso). Tre domande diverse, tre tipi di controllo. Per un modello è lo stesso.

Il progetto di prova

Dati sintetici di clienti con tre colonne: tenure (mesi di anzianità), complaints (numero di reclami), churn (1 se il cliente ha abbandonato). Un modello di regressione logistica. Gli esempi sono stati eseguiti il 2026-11-12 con Python 3.13.13, scikit-learn 1.9.1, pandas 3.1.1, NumPy 2.5.4, pytest 9.2.0, FastAPI 0.162.5, httpx 0.28.1, gestiti da UV (UV, ambienti e dipendenze), due volte con esiti identici. Il generatore dei dati è parte del progetto e conviene averlo sotto gli occhi, perché da lui dipendono tutti i numeri dei test:

# src/churn/data.py
import numpy as np
import pandas as pd

def make_dataset(n: int = 800, seed: int = 0) -> pd.DataFrame:
    """Clienti sintetici: più reclami alzano il rischio, più anzianità lo abbassa."""
    rng = np.random.default_rng(seed)
    tenure = rng.integers(1, 61, size=n)            # mesi di anzianità, da 1 a 60
    complaints = rng.poisson(1.2, size=n)           # numero di reclami
    logit = -0.2 - 0.04 * tenure + 0.8 * complaints
    p = 1 / (1 + np.exp(-logit))
    churn = (rng.random(n) < p).astype(int)
    return pd.DataFrame({"tenure": tenure, "complaints": complaints, "churn": churn})

Il rischio di abbandono segue una formula logistica nota (ogni reclamo pesa 0,8, ogni mese di anzianità toglie 0,04), quindi sappiamo in anticipo quali invarianti devono valere; la presenza di rng.random dà il rumore che rende il problema non banale. Il modello è questo:

# src/churn/model.py
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

FEATURES = ["tenure", "complaints"]

def train(df: pd.DataFrame, 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, auc

train divide i dati (25% per la valutazione, con stratify perché le due classi mantengano la stessa proporzione nelle due parti), addestra e restituisce il modello con l'AUC sulla parte di valutazione. Il seed controlla lo split; C è la regolarizzazione. Con seed 0 l'AUC è 0,7483 e i coefficienti stimati sono −0,042 per tenure e +0,824 per complaints, vicini a quelli veri della formula.

Prima famiglia: i test sui dati

La maggior parte dei guasti ML nasce nei dati prima che nel modello: una colonna che cambia formato, valori mancanti, un'etichetta quasi sempre uguale. Questi test sono economici e fermano il problema alla fonte:

import pytest
from churn.data import make_dataset

@pytest.fixture(scope="module")
def df():
    return make_dataset(n=800, seed=0)

def test_nessun_valore_mancante(df):
    assert not df.isna().any().any()

def test_intervalli_plausibili(df):
    assert df["tenure"].between(1, 60).all()
    assert (df["complaints"] >= 0).all()

def test_etichetta_binaria_e_non_degenere(df):
    assert set(df["churn"].unique()) == {0, 1}
    quota = df["churn"].mean()
    assert 0.1 < quota < 0.9, f"quota di abbandono sospetta: {quota:.2f}"

Il messaggio nell'ultimo assert è voluto: quando il test fallisce in una pipeline, a mezzanotte, il messaggio è l'unica spiegazione che hai. Qui la quota di abbandono è 0,39, dentro l'intervallo. La fixture ha scope="module", quindi il dataset si genera una volta per file.

Seconda famiglia: le invarianti

Un'invariante è una proprietà che deve valere per qualunque modello ragionevole, indipendentemente dai valori esatti. Non dice "l'output è 0.81"; dice "l'output è una probabilità" o "più reclami non abbassano il rischio".

import pandas as pd
from churn.model import FEATURES, train

@pytest.fixture(scope="module")
def trained():
    return train(make_dataset(n=800, seed=0), seed=0)

def test_probabilita_valide(trained):
    model, _ = trained
    df = make_dataset(n=50, seed=3)
    proba = model.predict_proba(df[FEATURES])[:, 1]
    assert ((proba >= 0) & (proba <= 1)).all()

def test_piu_reclami_non_abbassano_il_rischio(trained):
    model, _ = trained
    poco = pd.DataFrame({"tenure": [12], "complaints": [0]})
    molto = pd.DataFrame({"tenure": [12], "complaints": [5]})
    assert model.predict_proba(molto)[0, 1] > model.predict_proba(poco)[0, 1]

La fixture usa scope="module" (vedi la lezione su pytest): il modello si addestra una volta per file, non una per test. Il compromesso è che i test condividono lo stesso oggetto, quindi nessuno deve modificarlo. Altre invarianti utili sono più sotto: l'ordine delle righe in ingresso non cambia le predizioni, il modello salvato e ricaricato produce le stesse predizioni dell'originale, la stessa richiesta con lo stesso modello è deterministica.

Terza famiglia: le soglie di metrica

Qui sta il punto delicato. Una soglia troppo bassa non rileva nulla; troppo alta fa fallire il test per puro rumore. Per scegliere in modo non arbitrario ho misurato prima: stesso dataset, split diversi con 10 seed.

import numpy as np
from churn.data import make_dataset
from churn.model import train

df = make_dataset(800, seed=0)
auc = [train(df, seed=s)[1] for s in range(10)]
print(f"min {min(auc):.3f}  max {max(auc):.3f}  media {np.mean(auc):.3f}")
# Output: min 0.737  max 0.835  media 0.772

train(df, seed=s) ripete l'addestramento 10 volte cambiando solo il seed dello split; si raccolgono le AUC e se ne stampano estremi e media. La variabilità solo dovuta allo split è di circa dieci punti di AUC (da 0,737 a 0,835), un'informazione che nessun notebook ti mostra se guardi un solo numero. La conseguenza pratica: un solo numero non è una misura, è un campione. Per decidere se un modello è migliore di un altro serve guardare la distribuzione, usare più split (validazione incrociata) o un insieme di valutazione molto più grande.

Ho poi misurato tre modelli volutamente guasti, con lo stesso split: con le etichette mescolate l'AUC è 0,578; con la sola feature tenure è 0,643; con la sola feature complaints è 0,711. Una soglia a 0,70 sta sotto il minimo osservato per il modello sano (0,737) e sopra i primi due guasti, ma non sopra il terzo:

def test_auc_sopra_la_soglia(trained):
    _, auc = trained
    assert auc > 0.70

Con il seed fisso (0) il valore è deterministico (0,7483), quindi il test non è instabile. La soglia non misura "il modello è eccellente", misura "il modello non si è rotto", e solo rispetto a rotture più grandi della sua variabilità. Per migliorare un modello servono gli esperimenti (MLflow: tracciare gli esperimenti); per accorgerti che l'hai rotto serve questo. Una soglia scelta più alta, 0,72 per esempio, catturerebbe anche il terzo guasto ma lascerebbe un margine di 0,017 dal minimo osservato (0,737): è il compromesso tra sensibilità e falsi allarmi, e la sua scelta sta nelle mani di chi conosce il costo di un falso allarme.

Controllare il controllo: la mutazione

Un test che non fallisce mai quando dovrebbe è peggio di nessun test, perché dà fiducia. Dopo averli scritti, rompi il modello di proposito. Con i sei test scritti finora (6 passed) ho tolto la feature complaints da FEATURES e rilanciato:

FAILED tests/test_model.py::test_piu_reclami_non_abbassano_il_rischio - ValueError: The feature names should match those that were passed during fit.
FAILED tests/test_model.py::test_auc_sopra_la_soglia - assert 0.6434426229508...
2 failed, 4 passed

Due test su sei hanno reagito, ciascuno da un angolo diverso (la qualità è scesa a 0,643; l'input atteso non esiste più). Ripristinato il codice, tornano tutti verdi. Poi ho tolto tenure invece di complaints: ha reagito un solo test, quello sui nomi delle feature, mentre il test di soglia è rimasto verde perché l'AUC con la sola complaints è 0,711, sopra 0,70. È una scoperta utile: la mutazione non solo conferma che i test funzionano, mostra anche quali danni i test non vedono. Questa verifica richiede un minuto e ti dice che la rete sotto di te è vera, con i suoi buchi.

L'AUC con numeri che puoi controllare a mano

Una metrica di cui non capisci il significato non la puoi usare come soglia. Tre casi costruiti a mano, con quattro clienti (due che sono rimasti, 0, e due che hanno abbandonato, 1) e i punteggi che un modello ha dato loro:

from sklearn.metrics import roc_auc_score

roc_auc_score([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9])   # 1.0  ordinamento perfetto
roc_auc_score([0, 0, 1, 1], [0.9, 0.8, 0.2, 0.1])   # 0.0  ordinamento invertito
roc_auc_score([0, 1, 0, 1], [0.5, 0.5, 0.5, 0.5])   # 0.5  il modello non distingue nulla

Nel primo caso i due che abbandonano hanno punteggi (0.8, 0.9) più alti di quelli che restano (0.1, 0.2): ogni coppia "abbandona/resta" è ordinata bene, quindi 1.0. Nel secondo l'ordinamento è l'opposto: 0.0. Nel terzo il modello dà lo stesso punteggio a tutti: nessuna coppia è distinguibile, 0.5. Le uscite riportate sono quelle dell'esecuzione. Se un aggiornamento di libreria cambiasse il significato della funzione, lo sapresti subito scrivendo questi tre casi come test. Un modello vero con AUC 0.75 sta nel mezzo: nel 75% circa delle coppie "abbandona/resta" dà il punteggio più alto a chi abbandona.

Altre invarianti che conviene avere

Oltre a "le probabilità sono valide" e "più reclami non abbassano il rischio", ci sono controlli utilissimi su qualsiasi modello salvato e servito. Ho scritto e provato i test seguenti (la suite del progetto è arrivata a 16 test, tutti verdi, compresi i 5 del servizio più sotto).

import joblib

def test_modello_salvato_e_ricaricato_predice_uguale(trained, tmp_path):
    model, _ = trained
    percorso = tmp_path / "modello.joblib"
    joblib.dump(model, percorso)
    ricaricato = joblib.load(percorso)
    X = make_dataset(n=30, seed=5)[FEATURES]
    assert np.allclose(model.predict_proba(X), ricaricato.predict_proba(X))

Il modello salvato e ricaricato fa le stesse predizioni dell'originale: è il test che dimostra che l'artefatto che rilasci (Dockerfile e servizio ML) si carica e risponde come prima. tmp_path è la cartella temporanea della lezione su pytest; np.allclose è il confronto tra array di decimali con tolleranza (l'equivalente di approx). Se questo test fallisce dopo un aggiornamento di scikit-learn, hai scoperto in anticipo il problema delle versioni diverse tra training e serving.

def test_l_ordine_delle_righe_non_conta(trained):
    model, _ = trained
    X = make_dataset(n=30, seed=5)[FEATURES]
    mescolato = X.sample(frac=1, random_state=1)
    p1 = pd.Series(model.predict_proba(X)[:, 1], index=X.index)
    p2 = pd.Series(model.predict_proba(mescolato)[:, 1], index=mescolato.index)
    assert np.allclose(p1.sort_index(), p2.sort_index())

L'ordine delle righe non cambia le predizioni: la previsione per un cliente non può dipendere da chi lo precede nella lista. X.sample(frac=1) rimescola tutte le righe; si confrontano le predizioni per cliente, non per posizione, ricollegandole con l'indice. Un errore di questo tipo (una normalizzazione calcolata sul lotto invece che sul singolo cliente, per esempio) è sottile e molto reale.

def test_stesso_seed_stessa_auc():
    df = make_dataset(n=800, seed=0)
    assert train(df, seed=0)[1] == pytest.approx(train(df, seed=0)[1])

Determinismo: lo stesso seed produce lo stesso risultato. Se questo test diventa instabile, significa che c'è una fonte di casualità non controllata nel codice (un secondo generatore senza seed, per esempio), e tutti gli altri test con soglia diventano inaffidabili. È il test che garantisce la base della riproducibilità.

Più invarianti: le proprietà che il dominio ti regala

Le migliori invarianti vengono dalla conoscenza del problema. Nel nostro esempio, il coefficiente di complaints è +0,824 e quello di tenure è −0,042: più reclami alzano il rischio, più anzianità lo abbassa. Due proprietà che un esperto del dominio firmerebbe subito, e che possiamo trasformare in test (la prima è già nella lezione):

def test_piu_anzianita_non_alza_il_rischio(trained):
    model, _ = trained
    nuovo = pd.DataFrame({"tenure": [3], "complaints": [1]})
    vecchio = pd.DataFrame({"tenure": [48], "complaints": [1]})
    assert model.predict_proba(vecchio)[0, 1] <= model.predict_proba(nuovo)[0, 1]

Queste proprietà si chiamano monotonie, e sono preziose perché non dipendono dai valori esatti: sopravvivono a ogni riaddestramento sano e falliscono quando qualcosa è andato davvero storto (per esempio, un'etichetta invertita nei dati di addestramento). Per modelli più complessi di una regressione logistica (alberi, reti) la monotonia non è garantita dalla struttura del modello e verificarla con un test è ancora più utile.

Il test "d'oro": un'istantanea del comportamento

A volte non sai che proprietà esatta proteggere, ma vuoi sapere se qualcosa è cambiato dopo un refactoring. Il test golden (o snapshot) salva le predizioni su pochi input fissi e le confronta con tolleranza:

CASI = [(3, 4), (48, 0), (12, 2)]       # (tenure, complaints)
ATTESE = [0.9524, 0.1008, 0.725]        # salvate da un'esecuzione verificata

def test_predizioni_golden(trained):
    model, _ = trained
    X = pd.DataFrame(CASI, columns=FEATURES)
    assert model.predict_proba(X)[:, 1] == pytest.approx(ATTESE, abs=1e-3)

I valori sono quelli stampati da una prima esecuzione del modello della fixture (addestrato con C=1.0 e seed 0) sui tre clienti; con la tolleranza di 0,001 il test passa. Il test è utile finché il modello non deve cambiare: se riaddestri e i numeri cambiano, il test diventa rosso, e la domanda è "è un cambiamento voluto?". Se sì, aggiorni i valori attesi consapevolmente, con un commit che dice perché; se no, hai scoperto una regressione. Il suo limite è la fragilità: ogni riaddestramento lo sposta, e anche un aggiornamento di libreria che cambia le ultime cifre. Si usa quindi per il codice di contorno (preparazione dei dati, formato dell'uscita) con un modello congelato, non per il processo di addestramento.

Testare per sottogruppi: la media nasconde

Un AUC globale di 0,748 può nascondere differenze importanti tra gruppi di clienti. Valutare per fette (slice) è una pratica elementare e spesso decisiva. Sul nostro modello (200 clienti di valutazione), ho calcolato l'AUC per quattro fette, con un intervallo al 95% ottenuto ricampionando i clienti di quella fetta 1.000 volte (bootstrap, seme fisso):

FettaClientiAbbandoniAUCIntervallo 95%
Globale200780.7480.68 - 0.82
Anzianità < 24 mesi68400.7420.61 - 0.86
Anzianità >= 24 mesi132380.6700.57 - 0.78
Zero reclami59120.5910.39 - 0.76
Due o più reclami64410.6940.56 - 0.83

Come si legge. Le differenze tra fette (da 0,59 a 0,74) sembrano grandi, ma guarda gli intervalli a fianco: si sovrappongono tutti, e quello della fetta "zero reclami" (12 soli abbandoni) va da 0,39 a 0,76, cioè dal peggio che a caso a un buon modello. Questa è la trappola della valutazione per fette: dividere i dati riduce il numero di casi in ciascun gruppo e quindi la precisione di ogni stima. La regola prudente è non trarre conclusioni da una fetta con poche decine di casi, e usare le fette per cercare problemi grossi (una fetta con AUC 0,55 con un intervallo stretto, mentre le altre stanno a 0,75 sarebbe un segnale), non per sentenziare piccole differenze. Per i modelli che decidono su persone, la valutazione per gruppi è anche una verifica di equità: quel tema richiede più cura di quanto un test automatico possa dare, ma il punto di partenza è sempre questo, guardare la metrica per gruppo e non solo in media.

Il test instabile: perché è un problema, come stanarlo

Un test che a volte passa e a volte no insegna al team a rilanciare finché diventa verde e a ignorare i rossi: dopo poche settimane, un rosso vero non lo guarda nessuno. Cause tipiche nei progetti ML: seed non fissati; soglia troppo vicina al valore tipico (per questo si sceglie sotto il minimo osservato, non vicino alla media); ordine dei dati non deterministico (insiemi, dict di origine diversa); dipendenze dall'orario o dalla rete. Per stanarlo: ripetere il test molte volte e vedere se compare un rosso.

for i in $(seq 20); do uv run pytest -q tests/test_model.py || break; done
# nella prova: 20 esecuzioni su 20 verdi (conteggio dei verdi con una variante del ciclo)

Il ciclo si ferma al primo fallimento (|| break); venti verdi su venti non dimostrano che il test sia stabile ma, con un seed fisso, sono coerenti con l'assenza di casualità non controllata. Un test instabile va corretto o rimosso: non va lasciato lì "perché a volte passa".

Perché non si testa l'overfitting con i test di unità

Un'intuizione frequente è "se il modello va bene sul test set è a posto". Ma il test set della lezione è lo stesso che usi per decidere la soglia, e dopo mesi di ritocchi finisci per adattare il modello a lui (un overfitting indiretto). La difesa è metodologica: tenere un terzo insieme, di validazione finale, che si guarda solo alla fine e non per guidare le scelte. I test automatici di questa lezione proteggono da regressioni (qualcosa si rompe), non dall'illusione di un modello migliore di quello che è. Sono due problemi diversi e servono due strumenti.

Il perché: un modello può rompersi in tre posti diversi

Quando un modello "va male", la prima domanda utile non è quanto ma dove. I guasti di un sistema di machine learning cadono in tre famiglie, e ciascuna ha il proprio tipo di test:

  1. Un guasto nei dati: una colonna è cambiata di formato, una fonte ha smesso di aggiornarsi, un valore speciale (come -999) è finito tra i numeri veri. Il codice è corretto e il modello è corretto: ciò che entra è sbagliato. Test: sui dati.
  2. Un guasto nel codice: un refactoring ha spostato un'operazione, un ordinamento è cambiato, una normalizzazione usa i dati sbagliati. Il modello non è peggiorato "in sé": il programma che lo circonda fa una cosa diversa. Test: sul comportamento (invarianti) e di regressione.
  3. Un guasto di concetto: il codice e i dati sono quelli di sempre, ma il mondo è cambiato e il modello non descrive più la realtà. È la deriva (di cui parla l'introduzione al modulo), che i test di unità non vedono perché non c'è nulla di rotto nel software. Test: monitoraggio in produzione, non test di unità.

Le prime due famiglie si coprono con la suite di questa lezione. La terza richiede un altro strumento, ed è giusto saperlo: un test verde non dice che il modello sia buono per il mondo di oggi, dice che non si è rotto rispetto a ieri.

I test sul servizio: dall'oggetto alla richiesta HTTP

Finora abbiamo testato il modello come oggetto Python. Ma ciò che l'utente usa è il servizio (Dockerfile e servizio ML): se il modello funziona e l'endpoint no, il prodotto non funziona. FastAPI offre un client di test che simula le richieste HTTP senza avviare un server vero (richiede la libreria httpx). Il servizio è una versione ridotta di quello della lezione sul Dockerfile: carica il modello da MODEL_PATH nel lifespan e valida l'input con Pydantic.

# src/churn/servizio.py (ridotto)
import os
from contextlib import asynccontextmanager
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

state = {}

@asynccontextmanager
async def lifespan(app):
    state["model"] = joblib.load(os.environ.get("MODEL_PATH", "modello.joblib"))
    yield
    state.clear()

app = FastAPI(lifespan=lifespan)

class Customer(BaseModel):
    tenure: int = Field(ge=0, le=600)
    complaints: int = Field(ge=0, le=100)

@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": "model" in state}

@app.post("/predict")
def predict(c: Customer):
    p = state["model"].predict_proba(pd.DataFrame([c.model_dump()]))[0, 1]
    return {"churn_probability": round(float(p), 4)}

I test non avviano un server: la fixture salva un modello addestrato in una cartella temporanea, imposta MODEL_PATH e apre il client.

import joblib
import pytest
from fastapi.testclient import TestClient
from churn.data import make_dataset
from churn.model import train
from churn import servizio

@pytest.fixture(scope="module")
def client(tmp_path_factory):
    percorso = tmp_path_factory.mktemp("modello") / "modello.joblib"
    joblib.dump(train(make_dataset(n=800, seed=0), seed=0)[0], percorso)
    mp = pytest.MonkeyPatch()
    mp.setenv("MODEL_PATH", str(percorso))
    with TestClient(servizio.app) as c:     # il "with" esegue il lifespan: carica il modello
        yield c
    mp.undo()

def test_health_dice_che_il_modello_e_caricato(client):
    r = client.get("/health")
    assert r.status_code == 200 and r.json()["model_loaded"] is True

def test_predict_restituisce_una_probabilita(client):
    r = client.post("/predict", json={"tenure": 3, "complaints": 4})
    assert r.status_code == 200
    assert 0.0 <= r.json()["churn_probability"] <= 1.0

@pytest.mark.parametrize("corpo", [{"tenure": -1, "complaints": 0}, {"tenure": 3}, {"tenure": "x", "complaints": 1}])
def test_input_invalido_e_respinto_con_422(client, corpo):
    assert client.post("/predict", json=corpo).status_code == 422

Risultato: i cinque test passano (16 in tutta la suite). La fixture avvia l'applicazione con with TestClient(...), e il with è essenziale perché esegue la fase di avvio (lifespan) che carica il modello: senza, /health risponde {'status': 'ok', 'model_loaded': False} (verificato). Il test su /health verifica il contratto che lega il servizio al suo controllo di salute; quello su /predict controlla che l'uscita sia una probabilità; il test parametrizzato verifica tre input non validi (valore negativo, campo mancante, tipo sbagliato) e che tutti producano 422, il codice HTTP di "richiesta non elaborabile". Per vedere che il test serva, ho tolto ge=0 dal campo tenure: è diventato rosso il caso con tenure: -1 (1 failed, 15 passed). È un test di integrazione leggero e prezioso: è la rete che ti avverte se un cambiamento nello schema di input rompe i chiamanti. pytest ha mostrato anche un avviso di deprecazione di Starlette sull'uso di httpx con il client di test: non è un errore, ma indica che questo meccanismo cambierà nelle versioni future.

Il caso reale: i "gate" di valutazione di un servizio ML

Il servizio ML di questo sito ha la stessa struttura, in grande. Nel suo pyproject.toml i marker di pytest dichiarano famiglie di test di valutazione con ruoli diversi: eval_gate per le dimensioni di valutazione basate su codice che bloccano il build; judge per quelle in cui un modello linguistico fa da giudice, che restano solo report finché non sono calibrate; rpg_eval per quelle da lanciare a mano prima del deploy (verificato leggendo il file). È una distinzione sensata: un controllo non ancora affidabile non deve poter bloccare un rilascio, ma deve essere misurato per diventarlo.

Un secondo dettaglio, dallo stesso file: un commento riporta che la copertura di base, misurata in locale senza i servizi esterni, è del 57% con 33 test di infrastruttura che falliscono perché i servizi non ci sono (misura storica del commento, non rimisurata qui). Il lato istruttivo è che alcuni test dipendono dall'ambiente, e un buon progetto dice quali, invece di far credere che la suite sia sempre verde. Il criterio non è lo zero assoluto di test rossi, è non peggiorare rispetto a prima della modifica.

Quanta copertura serve

La percentuale di righe eseguite dai test (uv run pytest --cov, con il plugin pytest-cov) dice cosa non è mai stato eseguito, non se ciò che è eseguito è verificato bene. Un test senza assert alza la copertura e non protegge nulla. Usala come mappa delle zone cieche, non come obiettivo da gonfiare.

La latenza è un requisito: un test di velocità

Un servizio può essere corretto e inutilizzabile perché lento. Ho misurato il modello della lezione sulla mia macchina in quattro esecuzioni, ciascuna con 2.000 chiamate di predict_proba mediate: tra circa 0,6 e 1,3 ms per una predizione singola e tra circa 0,7 e 1,5 ms per un lotto di 1.000 righe. I valori dipendono dal computer e dal momento, e non sono una promessa. Due letture. Per questo modello leggero il costo è dominato dall'overhead fisso della chiamata, non dal numero di righe: mille righe costano come una, quindi servire un lotto è molto più efficiente che servire mille richieste singole. E un test di latenza ha senso solo con un margine largo (per esempio "sotto 50 ms" per un modello che impiega circa 1 ms), altrimenti diventa un test instabile che fallisce quando la macchina è carica. Si usa per catturare le regressioni grossolane (qualcuno ha sostituito un modello leggero con uno 100 volte più pesante), non per misurare al decimo di millisecondo.

Trappole

La soglia scelta a occhio è la prima: misura prima la variabilità tra seed e split, poi decidi. Il test sul dataset di produzione è la seconda: è lento, non ripetibile e dipende da dati che cambiano; usa dati piccoli, sintetici o fissati. Fissare solo il seed del modello e non quello dello split o dei dati lascia l'esito instabile. I test sui valori esatti di predizione (== 0.8123) si rompono a ogni aggiornamento di libreria senza che nulla sia sbagliato, e conviene preferire disuguaglianze e proprietà. Infine i test lenti nel ciclo veloce vanno marcati e separati, come visto nella lezione su pytest.

Cosa non testare con pytest

Alcune domande non si prestano a un test automatico, ed è sano riconoscerlo. "Il modello è utile per il business?" dipende dal costo di un errore, dal beneficio di una previsione corretta e dall'alternativa, e si valuta con le persone che usano il risultato e si misura in produzione. "Le spiegazioni sono sensate?" è un giudizio umano che non si sostituisce con una soglia: un modello linguistico che fa da giudice può affiancare la revisione ma, come dice la configurazione del servizio ML di questo sito, resta solo report finché non è calibrato (marker judge). "Il modello è equo?" ha nei test per fette un inizio, non una risposta. "Il modello sta peggiorando in produzione?" richiede monitoraggio continuo, non una suite eseguita al rilascio; ne parla la serie su drift detection e retraining automatico.

Saper dire cosa un test non copre è parte della sua qualità. Una scheda del modello (model card) che elenca i test eseguiti e i limiti noti fa più per la fiducia di mille asserzioni non spiegate.

Prova tu: quattro esercizi guidati

Esercizio 1: misurare la variabilità prima di scegliere la soglia

Con il progetto churn (o un tuo modello), addestra lo stesso modello su dieci seed di split diversi e stampa minimo, massimo e media della tua metrica (lo script è nella lezione). Dovresti vedere un intervallo ampio (nella mia prova, AUC da 0,737 a 0,835, media 0,772). Scegli una soglia sotto il minimo osservato e sopra il valore di un modello deliberatamente guasto. Cosa impari: che la soglia di un test si misura, non si indovina.

Esercizio 2: rompere il modello di proposito

Con i test della lezione verdi (6 passed nella suite base), togli una feature da FEATURES e riesegui. Dovresti vedere almeno due test rossi che reagiscono da angoli diversi se togli complaints (nella mia prova: la soglia di AUC, con 0,643, e l'invariante sui reclami, per nomi di colonne non corrispondenti), e uno solo se togli tenure. Ripristina il codice e verifica che tutto torni verde. Poi mescola le etichette dei dati e osserva l'AUC scendere (nella mia prova 0,578, su una sola permutazione: l'AUC con etichette casuali oscilla intorno a 0,5). Cosa impari: a diffidare di un test mai visto fallire, e a scoprire quali danni il tuo test non vede.

Esercizio 3: una valutazione per fette

Calcola l'AUC sul set di valutazione separatamente per due fasce di una tua feature (per esempio anzianità sotto e sopra 24 mesi), annotando per ciascuna quanti casi ci sono e quanti positivi. Dovresti vedere numeri diversi dal globale, e fette con poche decine di positivi (nella mia prova, da 12 a 41). Prima di concludere che una fetta va "peggio", ripeti il calcolo con altri seed di split o ricampiona la fetta: la differenza si mantiene? Cosa impari: che dividere i dati riduce la precisione di ogni stima, e che le fette servono a cercare problemi grossi.

Esercizio 4: il test sul servizio

Dato un servizio FastAPI con /health e /predict (quello della lezione sul Dockerfile), scrivi con TestClient tre test: salute con modello caricato, previsione valida in [0, 1], input non valido con codice 422. Ricordati del with TestClient(app) as c: senza, il modello non viene caricato. Eseguili con uv run pytest dopo uv add --dev httpx. Dovresti vedere tutti verdi (nella mia prova, 5 passed con tre input non validi parametrizzati). Poi rompi qualcosa (togli il ge=0 dal modello di input) e osserva quale test diventa rosso. Cosa impari: che il contratto del servizio si testa come qualunque altro comportamento.

Se vuoi un esercizio extra: scrivi il test golden con le predizioni di tre clienti e prova a riaddestrare con un parametro diverso. Dovrebbe diventare rosso, e quel rosso è il test che fa il suo lavoro: ti obbliga a decidere se il cambiamento è voluto.

Cosa sai fare adesso

Sai distinguere le tre famiglie di test di un modello: dati (cosa entra), invarianti (comportamenti sensati) e soglie (qualità minima). Sai scegliere una soglia dopo aver misurato la variabilità tra split, sapendo che serve a rilevare un modello rotto e non a certificarne l'eccellenza. Sai rompere il modello di proposito per verificare che i test reagiscano, e leggere cosa la mutazione ti dice sui danni che non vedono. Sai che nei progetti veri esistono test che dipendono dall'ambiente, e che vanno distinti e dichiarati. Prossima lezione: variabili d'ambiente e segreti.

Nella stessa serie trovi anche ML-Ops: Git, la macchina del tempo del tuo progetto, ML-Ops: GitHub, remoto, branch protetti e pull request, ML-Ops: model registry, versioni, alias e promozione di un modello e ML-Ops: il deploy, dove gira il modello e come scegliere.

Fonti

Tutti i blocchi eseguibili (Python 3.13.13, versioni elencate sopra, 2026-11-12) sono stati eseguiti due volte con esiti identici, tempi esclusi, che sono riportati come intervalli. Il generatore dei dati e i valori che ne derivano (AUC, coefficienti, quota di abbandono) sono sintetici e riguardano solo questo esempio. I numeri di questa lezione non coincidono con quelli di altre lezioni del modulo scritte con un generatore diverso. Non eseguito: il confronto con pytest-cov della sezione sulla copertura, che è descritta e non mostrata con un'uscita.

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