La pull request sembrava innocua: qualche riga in più nel preprocessing, una feature aggiunta. I test verdi, il codice pulito, il collega approva, si fa il merge. Due settimane dopo qualcuno nota che le predizioni del modello in produzione sono peggiorate: quella feature aveva introdotto una fuga di informazione che gonfiava le metriche in fase di sviluppo e le affossava sui dati reali. Nessun test aveva protestato, perché nessun test guardava la cosa giusta. Nel software tradizionale il continuous integration chiede: «il codice compila e i test passano?». Con un modello di machine learning quella domanda non basta. Serve chiederne un’altra: «questa versione predice meglio o peggio di quella che abbiamo già in produzione?». In questo tutorial costruiamo una pipeline che pone entrambe le domande e blocca il merge se la risposta è sbagliata.
Cosa cambia rispetto al software normale
Una pipeline CI/CD classica ha una proprietà comoda: è deterministica. Lo stesso codice, dati gli stessi input, produce lo stesso output, e un test o passa o fallisce in modo netto. Con il machine learning questa certezza si sgretola. Il comportamento del sistema non dipende solo dal codice, ma anche dai dati di addestramento e dal modello che ne risulta. E la qualità non è un booleano: è un numero su una scala continua — un’accuratezza, un F1, un errore medio — che oscilla.
L’analogia utile è la differenza tra correggere un problema di matematica e valutare un tema. Nel primo caso la risposta è giusta o sbagliata, punto. Nel secondo dai un voto su una scala, e la domanda diventa «questo tema è migliore o peggiore del precedente?». Il CI per ML deve saper dare voti, non solo crocette. Concretamente, aggiunge due controlli che il software normale non ha: una soglia assoluta («il modello non deve mai scendere sotto un livello minimo di qualità») e un gate di confronto («il candidato non deve peggiorare rispetto a quello che sta già girando»).
Il test che conta: addestrare e misurare
Il primo mattone è uno script che addestra il modello e ne misura la qualità in modo onesto. Onesto significa non valutare sul training set — sarebbe come dare a uno studente lo stesso compito su cui si è esercitato — ma con una cross-validation, che stima la performance su dati mai visti. Usiamo scikit-learn e un dataset di esempio incluso nella libreria, così il codice gira anche sul tuo portatile senza scaricare nulla.
# train.py — addestra il modello, salva l'artefatto e le metriche
import json
from pathlib import Path
import joblib
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
RANDOM_STATE = 42
def build_model():
# Pipeline: normalizzazione + classificatore in un unico oggetto.
# make_pipeline evita di dimenticare il preprocessing al momento
# della predizione, l'errore che genera piu' bug silenziosi in ML.
return make_pipeline(
StandardScaler(),
RandomForestClassifier(n_estimators=200, random_state=RANDOM_STATE),
)
def main():
X, y = load_breast_cancer(return_X_y=True)
model = build_model()
# Cross-validation a 5 fold: una stima realistica su dati non visti,
# non l'accuratezza sul training set (che sarebbe ottimistica).
scores = cross_val_score(model, X, y, cv=5, scoring="f1")
f1 = float(scores.mean())
model.fit(X, y) # riaddestra su tutti i dati per l'artefatto finale
out = Path("artifacts")
out.mkdir(exist_ok=True)
joblib.dump(model, out / "model.joblib")
(out / "metrics.json").write_text(
json.dumps({"f1": round(f1, 4)}, indent=2)
)
print(f"F1 medio (5-fold): {f1:.4f}")
if __name__ == "__main__":
main()
Lo script produce due file: l’artefatto del modello (model.joblib) e un piccolo metrics.json con la metrica misurata. Separare la metrica in un file è la mossa chiave: da qui in poi i controlli non riaddestrano nulla, leggono solo quel numero. Addestrare una volta, valutare più volte.
Il primo gate: la soglia minima
Ora scriviamo il controllo più semplice, quello che pytest eseguirà automaticamente in CI: la qualità del modello non deve mai scendere sotto una soglia decisa a tavolino. È il «non peggiorare mai sotto X» scritto in codice, la rete di sicurezza contro le regressioni grossolane.
# test_model.py — eseguito da pytest nella pipeline
import json
from pathlib import Path
# Soglia minima concordata dal team. Sotto questo valore il test
# fallisce, pytest esce con codice diverso da zero e la CI si ferma.
SOGLIA_F1 = 0.95
def test_f1_sopra_soglia():
metrics = json.loads(Path("artifacts/metrics.json").read_text())
assert metrics["f1"] >= SOGLIA_F1, (
f"F1 {metrics['f1']:.4f} sotto la soglia minima {SOGLIA_F1}"
)
È un test come tanti, ma cambia la natura di ciò che stai testando: non stai più verificando che una funzione restituisca il valore atteso, stai verificando che un modello sia abbastanza buono. La soglia va scelta con giudizio — troppo alta e blocchi PR legittime per il rumore statistico, troppo bassa e non protegge da nulla. Un buon punto di partenza è appena sotto la qualità attuale, con un margine che assorba l’oscillazione naturale tra un addestramento e l’altro.
Un test che chiede «la funzione restituisce il valore giusto?» non serve a niente su un modello. La domanda è «il modello è ancora abbastanza buono?».
Il secondo gate: non peggiorare la baseline
La soglia assoluta ha un punto cieco. Immagina che il modello in produzione abbia un F1 di 0,97 e la soglia sia fissata a 0,95. Una pull request che porta il modello a 0,955 passa il test — è sopra soglia — ma ha silenziosamente peggiorato il modello di oltre un punto. È il modo più subdolo di degradare un sistema: un piccolo passo indietro alla volta, ognuno dei quali supera il controllo minimo.
Serve un secondo gate che confronti il candidato con la baseline, cioè con le metriche del modello attualmente promosso. È la logica champion/challenger: il nuovo modello (challenger) deve battere, o almeno non peggiorare oltre una tolleranza, quello in carica (champion).
# gate.py — confronta il candidato con la baseline in produzione
import json
import sys
from pathlib import Path
# Tolleranza: quanto puo' peggiorare il candidato prima di essere
# bloccato. Un piccolo margine assorbe l'oscillazione tra due run;
# metterlo a zero renderebbe la pipeline ostaggio del rumore.
TOLLERANZA = 0.005
def leggi_f1(path):
return json.loads(Path(path).read_text())["f1"]
def main():
candidato = leggi_f1("artifacts/metrics.json")
baseline_path = Path("baseline/metrics.json")
# Prima release: non c'e' ancora un champion da battere.
if not baseline_path.exists():
print(f"Nessuna baseline: promuovo il candidato (F1 {candidato:.4f}).")
return 0
baseline = leggi_f1(baseline_path)
if candidato < baseline - TOLLERANZA:
print(f"BLOCCATO: {candidato:.4f} peggiora la baseline {baseline:.4f}")
return 1
print(f"OK: {candidato:.4f} regge la baseline {baseline:.4f} "
f"(tolleranza {TOLLERANZA})")
return 0
if __name__ == "__main__":
sys.exit(main()) # exit code != 0 => la CI fallisce e blocca il merge
La baseline è semplicemente il metrics.json dell’ultimo modello promosso, salvato nel repository (o in un registro esterno) sotto baseline/. Quando un candidato viene approvato e va in produzione, il suo file metriche diventa la nuova baseline. Il gate, così, alza l’asticella da solo a ogni release: il modello di oggi deve battere quello di ieri, non un valore fisso deciso mesi fa.
La pipeline: mettere insieme i pezzi
Restano da orchestrare i tre passaggi — addestra, verifica la soglia, confronta con la baseline — in modo che partano da soli a ogni pull request. Con GitHub Actions bastano poche righe. La logica sarebbe identica su GitLab CI o su qualunque altro runner: cambia la sintassi, non la sequenza.
# .github/workflows/ml-ci.yml
name: ml-ci
on: [pull_request]
jobs:
train-and-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install scikit-learn joblib pytest
- run: python train.py # addestra e scrive artifacts/metrics.json
- run: pytest -q # gate 1: soglia minima assoluta
- run: python gate.py # gate 2: non peggiorare la baseline
I passi girano in ordine e la pipeline si ferma al primo che fallisce. Se pytest trova l’F1 sotto soglia, o se gate.py esce con codice diverso da zero, il job diventa rosso e — configurando il branch come protetto su GitHub — il pulsante di merge resta bloccato finché il problema non è risolto. Il diagramma qui sotto mostra il flusso e il punto in cui il gate decide.
La pipeline CI/CD con il gate di qualità sul modello. I passi neri e teal sono la spina dorsale — dalla pull request all’addestramento; il rombo giallo è la decisione. Solo se il modello supera la soglia e regge la baseline si arriva al merge e il candidato diventa la nuova baseline; altrimenti il merge resta bloccato.
Cosa portarsi a casa
Se metti un modello in produzione, prima o poi qualcuno — magari tu stesso tra sei mesi — proverà a modificarlo, e la buona fede non basta a garantire che il cambiamento sia un miglioramento. Un test che chiede solo «il codice gira?» ti lascia scoperto proprio sulla cosa che conta, la qualità delle predizioni. I due gate che abbiamo costruito — una soglia minima assoluta e un confronto con la baseline — sono il minimo indispensabile perché nessuna pull request possa degradare il modello senza che qualcuno se ne accorga prima del merge, non due settimane dopo. Da qui si può crescere: aggiungere controlli sui dati in ingresso, valutare la qualità su sottogruppi critici per intercettare i bias, tracciare le metriche nel tempo con un registro dei modelli. Ma il principio resta quello di oggi, e vale la pena scriverlo sul muro: nel machine learning, la domanda che protegge davvero il tuo sistema non è «il test passa?», è «questa versione è migliore di quella che ho già?».
I commenti sono riservati agli iscritti.
Accedi per commentare