Immagina uno studente che prepara un esame con le tracce degli anni passati. Se all'esame vero escono esattamente quelle tracce, prenderà trenta e lode senza aver capito nulla. Un modello di machine learning fa la stessa cosa: se lo valuti sugli stessi dati con cui lo hai addestrato, ti dirà sempre che è bravissimo. Tutto il metodo di un progetto di ML, dalla divisione dei dati alla scelta delle metriche, serve a una sola cosa: impedire che tu ti racconti una bugia sulla qualità del modello.
Questa lezione apre il modulo sul machine learning classico. Non c'è ancora nessun algoritmo sofisticato: c'è il processo che userai con tutti gli algoritmi delle lezioni successive, e le tre trappole che rovinano più progetti reali: il data leakage, le metriche che mentono sulle classi sbilanciate e l'overfitting nascosto da una buona accuratezza.
Cos'è davvero il machine learning
Un programma tradizionale è una funzione scritta da te: regole in ingresso, risposte in uscita. Nel machine learning rovesci il rapporto: fornisci esempi (dati e risposte attese) e un algoritmo ricava la funzione. Il risultato dell'addestramento è un oggetto che prende un input nuovo e restituisce una previsione. Due famiglie ti bastano per ora:
- Apprendimento supervisionato: ogni esempio ha un'etichetta (la risposta corretta). Se l'etichetta è una categoria («spam» / «non spam») si parla di classificazione; se è un numero («prezzo di una casa») di regressione.
- Apprendimento non supervisionato: non ci sono etichette, cerchi struttura nei dati (gruppi, direzioni di massima variazione). Lo vedrai con clustering e riduzione di dimensionalità.
Formalmente, nel caso supervisionato hai coppie (x, y) estratte da una distribuzione sconosciuta e cerchi una funzione f tale che f(x) ≈ y anche su coppie mai viste. L'ultima parte della frase è il cuore di tutto: l'obiettivo non è ricordare i dati di addestramento, è generalizzare.
Il vocabolario di base, definito una volta per tutte
- Esempio (o campione, o riga): una singola osservazione. In un dataset di clienti, un cliente.
- Feature: un valore in ingresso che descrive l'esempio (età, importo dell'ultimo acquisto). L'insieme delle feature di tutti gli esempi è una tabella con una riga per esempio e una colonna per feature, la matrice X.
- Etichetta (o target): la risposta che vogliamo prevedere, il vettore y.
- Modello: la funzione, con i suoi parametri, che trasforma le feature in una previsione. Addestrare (fit) significa scegliere i parametri guardando i dati; inferenza (predict) significa applicare il modello addestrato a dati nuovi.
- Iperparametro: una scelta che fai tu prima dell'addestramento e che l'algoritmo non impara (per esempio la profondità massima di un albero). I parametri si imparano, gli iperparametri si scelgono con la validazione.
Le cinque voci si dividono in due gruppi: i dati (esempio, feature, etichetta) e ciò che li trasforma (modello, iperparametro). La distinzione che conta davvero è l'ultima, fra parametri che il modello impara e iperparametri che scegli tu guardando la validazione: se scegli un iperparametro guardando il test, il test ha già smesso di essere un esame.
Il processo, in sei passi
- Formulare il problema: che cosa prevedo, con quale costo di errore? Un falso allarme e una diagnosi mancata non costano lo stesso.
- Raccogliere e ispezionare i dati: distribuzione delle classi, valori mancanti, duplicati.
- Dividere i dati in addestramento, validazione e test, prima di qualunque trasformazione.
- Preparare le feature (scalatura, codifica delle categorie) imparando i parametri solo dal training set.
- Addestrare e confrontare modelli usando la validazione.
- Valutare una sola volta sul test e solo alla fine, con la metrica coerente col costo dell'errore.
L'ordine dei sei passi non è una preferenza: ciascuno dipende dal precedente, e scambiarne due ha una conseguenza precisa. La divisione prima della preparazione evita il leakage, la valutazione sul test una volta sola evita l'ottimismo, la formulazione iniziale decide quale metrica avrà senso. Il passo da non saltare mai è il primo, perché fissa il costo dell'errore.
Il disegno mostra la divisione tipica. Le percentuali non sono sacre: con un milione di esempi bastano pochi punti percentuali per validazione e test, con mille esempi conviene la validazione incrociata (più avanti).
Il passo 3 precede il 4 e questo ordine non è negoziabile: è la sorgente del data leakage, che vedremo tra poco.
Train, validation e test: tre ruoli diversi
L'analogia dello studente regge bene. Il training set è il libro di testo con gli esercizi svolti: ci studi sopra. Il validation set è la simulazione d'esame: la usi per capire cosa sistemare (quale algoritmo, quali iperparametri). Il test set è l'esame vero: lo apri una volta sola. Se dopo aver visto il voto della simulazione ti rimetti a studiare «mirando» a quelle domande, la simulazione non è più indipendente. Lo stesso vale per il test: ogni volta che lo guardi e poi cambi il modello, ne consumi un po' di onestà.
Con classi sbilanciate la divisione deve essere stratificata, cioè mantenere in ogni parte la stessa proporzione di classi. Generiamo un problema dove i positivi sono circa il 5%:
Nota sul codice: i blocchi Python di questo articolo vanno eseguiti in ordine, in un unico script o notebook. Ciascuno riusa gli import e le variabili definiti nei blocchi precedenti.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
X, y = make_classification(
n_samples=4000, n_features=10, n_informative=4,
weights=[0.95, 0.05], flip_y=0.02, class_sep=1.0, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=0)
print("forma train:", X_tr.shape, " forma test:", X_te.shape)
print("quota positivi totale/train/test: %.3f %.3f %.3f" % (y.mean(), y_tr.mean(), y_te.mean()))
forma train: (3000, 10) forma test: (1000, 10)
quota positivi totale/train/test: 0.059 0.059 0.059
Cosa fa il codice. make_classification genera un dataset sintetico: 4000 esempi, 10 feature di cui 4 informative, con il 95% circa di esempi negativi (weights) e il 2% delle etichette scambiate a caso (flip_y, per simulare errori di annotazione). train_test_split mette da parte il 25% dei dati per il test; stratify=y garantisce la stessa quota di positivi nelle due parti, e random_state rende la divisione riproducibile. Le tre stampe confermano le forme (3000 righe di training, 1000 di test) e che la quota dei positivi è 0,059 ovunque: circa un esempio su 17 è positivo.
Senza stratify=y con pochi positivi potresti ritrovarti con un test set quasi privo della classe che ti interessa, e metriche instabili da un'esecuzione all'altra.
Le metriche che mentono: l'accuratezza sulle classi sbilanciate
L'accuratezza è la frazione di previsioni corrette: (previsioni giuste) / (tutte le previsioni). Sembra innocente. Ora costruisci il «modello più pigro possibile»: dice sempre «negativo». Con i positivi al 5,9% dei dati come qui, quel modello ha il 94,1% di accuratezza e non trova nessun positivo. Verifichiamolo.
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score
pigro = DummyClassifier(strategy="most_frequent").fit(X_tr, y_tr)
pred = pigro.predict(X_te)
print("accuratezza del modello pigro: %.3f" % accuracy_score(y_te, pred))
print("recall sui positivi: %.3f" % recall_score(y_te, pred))
accuratezza del modello pigro: 0.941
recall sui positivi: 0.000
Il codice usa un DummyClassifier con strategia «most_frequent»: ignora completamente le feature e predice sempre la classe più comune nel training. È la baseline più economica, e dovrebbe essere il primo confronto di ogni progetto: un modello che non batte la baseline pigra non ha imparato niente. Qui l'accuratezza è 0.941 e la recall è 0.000: nessun positivo trovato.
Per vedere cosa succede serve la matrice di confusione, che incrocia realtà e previsione. Chiamiamo «positiva» la classe rara che vogliamo trovare:
previsto positivo previsto negativo
reale positivo VP FN
reale negativo FP VN
VP sono i vero-positivi, FN i falsi-negativi (positivi che ti sono sfuggiti), FP i falsi-positivi (falsi allarmi), VN i vero-negativi. Da qui nascono le metriche che contano:
accuratezza = (VP + VN) / (VP + VN + FP + FN)
precision = VP / (VP + FP) "quando dico positivo, quante volte ho ragione?"
recall = VP / (VP + FN) "quanti dei positivi veri ho trovato?"
F1 = 2 · precision · recall / (precision + recall)
La precision è la serietà dei tuoi allarmi, la recall è la copertura. F1 è la media armonica: la scelta della media armonica non è un vezzo, serve a punire gli squilibri. Con precision 1.0 e recall 0.01 la media aritmetica darebbe 0.505, la armonica circa 0.02, che descrive meglio un modello inutile. Calcoliamo tutto a mano dalla matrice e confrontiamo con scikit-learn, usando un modello vero (regressione logistica, la vedrai nel dettaglio nel modulo successivo):
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, precision_score, f1_score
modello = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
pred = modello.predict(X_te)
vn, fp, fn, vp = confusion_matrix(y_te, pred).ravel()
print("VP=%d FP=%d FN=%d VN=%d" % (vp, fp, fn, vn))
acc = (vp + vn) / (vp + vn + fp + fn)
prec = vp / (vp + fp) if (vp + fp) else 0.0
rec = vp / (vp + fn)
f1 = 2 * prec * rec / (prec + rec)
print("a mano -> acc %.3f prec %.3f rec %.3f f1 %.3f" % (acc, prec, rec, f1))
print("sklearn -> acc %.3f prec %.3f rec %.3f f1 %.3f" % (
accuracy_score(y_te, pred), precision_score(y_te, pred),
recall_score(y_te, pred), f1_score(y_te, pred)))
VP=8 FP=1 FN=51 VN=940
a mano -> acc 0.948 prec 0.889 rec 0.136 f1 0.235
sklearn -> acc 0.948 prec 0.889 rec 0.136 f1 0.235
Cosa fa il codice: addestra una regressione logistica (un classificatore lineare che produce una probabilità; la studierai per intero più avanti nel modulo), poi ricava le quattro caselle della matrice con confusion_matrix(...).ravel() e calcola le metriche con le formule, riga per riga. La condizione if (vp + fp) else 0.0 evita una divisione per zero quando il modello non prevede mai «positivo». Le due stampe coincidono: le formule a mano e quelle di scikit-learn sono la stessa cosa.
Guarda il risultato: il modello «vero» ha 94,8% di accuratezza contro il 94,1% di quello pigro. Sembra un miglioramento minimo, ma la recall dice la verità: trova 8 positivi su 59 (13,6%). La precision è alta (8 allarmi su 9 sono giusti) perché il modello si espone solo nei casi ovvi. Un'accuratezza di 94,8% nasconde che 51 positivi su 59 sono passati inosservati.
Nota l'ordine di confusion_matrix(...).ravel(): scikit-learn restituisce [[VN, FP], [FN, VP]], cioè con la classe 0 per prima. Scambiare quell'ordine è un errore comunissimo, e produce precision e recall «invertite» senza alcun messaggio d'errore.
Soglia di decisione: il cursore tra precision e recall
Un classificatore probabilistico produce una probabilità; «positivo» significa probabilità ≥ soglia, e la soglia predefinita 0.5 è una convenzione, non una legge. Abbassarla trova più positivi (recall su) ma genera più falsi allarmi (precision giù). Quale compromesso scegliere dipende dal costo degli errori, non dalla statistica: in uno screening medico si accetta qualche falso allarme pur di non perdere un malato; in un filtro che blocca account si fa il contrario.
Nell'esperimento qui sotto, scendere dalla soglia 0.5 a 0.1 porta la recall da 0.136 a 0.390 ma fa crollare la precision da 0.889 a 0.261. Non esiste un valore «giusto» in assoluto: esiste quello giusto per il tuo costo degli errori.
p = modello.predict_proba(X_te)[:, 1]
for soglia in (0.5, 0.3, 0.2, 0.1):
pr = (p >= soglia).astype(int)
print("soglia %.1f precision %.3f recall %.3f" % (
soglia, precision_score(y_te, pr, zero_division=0), recall_score(y_te, pr)))
soglia 0.5 precision 0.889 recall 0.136
soglia 0.3 precision 0.800 recall 0.271
soglia 0.2 precision 0.667 recall 0.305
soglia 0.1 precision 0.261 recall 0.390
Esercizio 1
Le quattro caselle, senza libreria
Un collaudo automatico esamina 1200 saldature, di cui 60 sono davvero difettose. Il sistema ne segnala 24 come difettose e, di quelle, ne azzecca 18. Carta e penna, niente codice.
- Ricostruisci le quattro caselle della matrice di confusione e calcola accuratezza, precision, recall e F1.
- Quanto vale l'accuratezza del collaudatore che timbra "buono" ogni pezzo senza guardarlo?
- Un secondo modello ha precision 1,00 e recall 0,01. Calcola media aritmetica e media armonica delle due. Quale delle due descrive meglio quel modello?
Le tre domande portano dalle quattro caselle alle metriche e poi mostrano perché una sola metrica non basta: la prima è il calcolo, la seconda confronta il modello con il collaudatore che non guarda, la terza spiega perché la media armonica punisce lo squilibrio fra precision e recall. Rispondi alla seconda prima di fidarti di qualunque accuratezza.
Mostra la soluzione
1. Dai tre numeri si ricava tutto: VP = 18 (segnalati e azzeccati), FP = 24 − 18 = 6 (segnalati a torto), FN = 60 − 18 = 42 (difetti sfuggiti), VN = 1200 − 60 − 6 = 1134. Quindi accuratezza = (18 + 1134)/1200 = 1152/1200 = 0,96; precision = 18/24 = 0,75; recall = 18/60 = 0,30; F1 = 2 · 0,75 · 0,30 / 1,05 = 0,45/1,05 = 0,4286.
2. Indovina tutti i 1140 pezzi buoni e sbaglia tutti i 60 difettosi: 1140/1200 = 0,95. Il modello "al 96%" vale un punto percentuale più di un timbro, e intanto lascia passare 42 difetti su 60. L'accuratezza non ha mai nominato la classe che ci interessa, perché è dominata dai 1134 pezzi buoni.
3. Media aritmetica: (1,00 + 0,01)/2 = 0,505. Media armonica: 2 · 1,00 · 0,01 / 1,01 = 0,0198. La prima dice che il modello è a metà strada, la seconda che è quasi inutile — e la seconda ha ragione, perché quel modello trova un difetto su cento. La media armonica è schiacciata dal termine piccolo, ed è esattamente per questo che F1 la usa: punire gli squilibri invece di mediarli.
Data leakage: la trappola che fa sembrare geniale un modello inutile
Il data leakage (perdita di informazione) accade quando nel processo di addestramento entra, direttamente o indirettamente, informazione che nel mondo reale non avresti al momento della previsione. Le forme tipiche:
- Preprocessing sul dataset intero: calcoli media e deviazione standard (o selezioni le feature) su tutti i dati, poi dividi. Il test set ha già influenzato il training.
- Feature che contengono la risposta: una colonna «giorni dalla cancellazione» per prevedere la cancellazione.
- Duplicati o quasi-duplicati finiti sia nel train sia nel test.
- Dati temporali mescolati: prevedere il futuro usando dati che vengono dopo.
Prima, due termini. Selezione delle feature: scegliere, tra molte colonne, le poche che sembrano più utili (qui, quelle con la più alta statistica F rispetto all'etichetta). Validazione incrociata (cross-validation): ripetere addestramento e valutazione su porzioni diverse dei dati; la spieghiamo meglio più sotto, per ora basta sapere che restituisce una stima di accuratezza su dati «non visti».
Per rendere la cosa evidente costruiamo un caso estremo ma istruttivo: 100 campioni, 5000 feature, tutte rumore puro, e un'etichetta casuale. Nessun modello onesto può fare meglio del caso (50%). Ora commettiamo l'errore classico: scegliere le 20 feature «più correlate con l'etichetta» guardando tutti i dati, e solo dopo fare la validazione incrociata.
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
rng = np.random.default_rng(0)
Xr = rng.normal(size=(100, 5000))
yr = rng.integers(0, 2, size=100)
# SBAGLIATO: la selezione ha già visto le etichette di tutti i fold
Xsel = SelectKBest(f_classif, k=20).fit_transform(Xr, yr)
sbagliato = cross_val_score(LogisticRegression(max_iter=1000), Xsel, yr, cv=5).mean()
# GIUSTO: la selezione sta DENTRO la pipeline, rifatta in ogni fold
pipe = make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression(max_iter=1000))
giusto = cross_val_score(pipe, Xr, yr, cv=5).mean()
print("accuratezza CV con leakage: %.2f" % sbagliato)
print("accuratezza CV senza leakage: %.2f" % giusto)
accuratezza CV con leakage: 0.85
accuratezza CV senza leakage: 0.48
Cosa fa il codice. Generiamo 100 righe e 5000 colonne di numeri casuali e un'etichetta casuale: per costruzione nessuna colonna ha relazione con l'etichetta. Nel caso SBAGLIATO SelectKBest è addestrata una volta su tutti i 100 campioni e restituisce le 20 colonne «migliori»; fra 5000 colonne di rumore, per puro caso, venti correlano con l'etichetta, e la validazione incrociata successiva le trova «predittive», perché le etichette dei campioni di test sono già entrate nella scelta. Nel caso GIUSTO make_pipeline unisce selezione e classificatore in un solo oggetto: a ogni fold la selezione è rifatta guardando solo i campioni di training di quel fold.
Il primo numero è un miraggio (0,85 su dati che sono rumore): su dati che sono letteralmente rumore, il modello «scopre» una struttura inesistente perché la selezione delle feature ha usato le etichette dei campioni che poi sarebbero stati di test. Il secondo numero (0,48, cioè il caso) è la verità. La regola pratica che ne discende è una sola: qualunque passo che «impara» qualcosa dai dati (scaler, imputazione, selezione, PCA, codifica target) va dentro una Pipeline, in modo che venga rifatto su ogni fold usando solo il training di quel fold.
Cross-validation: valutare senza sprecare dati
Con pochi dati, tenere da parte il 25% per un'unica valutazione è costoso e rumoroso. La validazione incrociata k-fold divide il training in k parti; per k volte ne usa k−1 per addestrare e una per validare, poi media i risultati. Ottieni una stima più stabile e anche una misura della sua variabilità (la deviazione standard tra i fold). Il prezzo è il tempo di calcolo: k addestramenti invece di uno. Per le classi sbilanciate usa StratifiedKFold, che cross_val_score applica già di default nei problemi di classificazione.
Overfitting mascherato da buona accuratezza
L'overfitting è quando il modello memorizza il rumore del training invece del segnale. Il sintomo è un divario tra prestazioni sul training e sul dato non visto. Un albero decisionale senza limiti di profondità lo mostra benissimo:
from sklearn.tree import DecisionTreeClassifier
albero = DecisionTreeClassifier(random_state=0).fit(X_tr, y_tr)
print("train: acc %.3f recall %.3f" % (albero.score(X_tr, y_tr), recall_score(y_tr, albero.predict(X_tr))))
print("test: acc %.3f recall %.3f" % (albero.score(X_te, y_te), recall_score(y_te, albero.predict(X_te))))
train: acc 1.000 recall 1.000
test: acc 0.945 recall 0.492
Sul training l'albero è perfetto (accuratezza e recall a 1.000); sul test la recall sui positivi crolla a 0.492, cioè trova solo la metà circa dei positivi. Ma l'accuratezza globale sul test resta 0.945, un numero alto e rassicurante perché dominato dai negativi: è l'overfitting mascherato. Due difese: confrontare sempre la metrica giusta tra train e validazione, e preferire modelli più semplici finché la validazione non dimostra il contrario (il rasoio di Occam in versione pratica). Il rimedio sistematico, la regolarizzazione, è il tema della lezione su overfitting e regolarizzazione.
Trade-off da tenere a mente
- Più dati di test = stima più affidabile, ma meno dati per addestrare.
- Precision contro recall: non si massimizzano insieme, si sceglie in base al costo degli errori.
- Cross-validation contro hold-out: la prima è più stabile e più lenta; con dataset enormi l'hold-out basta.
- Modello semplice contro complesso: il complesso vince sul training quasi sempre, e perde spesso fuori.
I quattro compromessi hanno la stessa struttura: ogni guadagno si paga con una perdita su un altro asse (dati di training, recall, tempo di calcolo, capacità di generalizzare). Non ce n'è uno corretto in assoluto; vanno scelti in funzione del costo dell'errore e della quantità di dati, e dichiarati nel risultato.
Checklist delle trappole
- Hai diviso i dati prima di scalare, imputare o selezionare feature?
- La divisione è stratificata (classi sbilanciate) o temporale (serie storiche)?
- Hai guardato precision e recall, non solo l'accuratezza? Hai un confronto con il modello pigro?
- Il test set è stato usato una sola volta, alla fine?
- Ci sono duplicati tra train e test? Una feature «sa» già la risposta?
- L'ordine di
confusion_matrixè quello che pensi?
La checklist è ordinata dal punto in cui si sbaglia più spesso al meno frequente: divisione prima della trasformazione, tipo di divisione, metrica, uso del test, duplicati e feature che conoscono la risposta, ordine delle caselle. Se un controllo ti sembra ovvio, è quello che stai per violare; conviene scorrerla prima di ogni valutazione, non dopo.
Oltre la soglia: curva ROC, curva precision-recall e AUC
Finora hai valutato un modello a una soglia fissa. Ma un classificatore probabilistico non ha «una» soglia: ne ha infinite, e ciascuna produce una coppia diversa di precision e recall. Due curve riassumono l'intero compromesso. La curva ROC (Receiver Operating Characteristic) mette in grafico, al variare della soglia, il tasso di veri positivi TPR = VP/(VP + FN) (che è la recall) contro il tasso di falsi positivi FPR = FP/(FP + VN) (la frazione di negativi che scambi per positivi). Un modello inutile sta sulla diagonale; uno perfetto passa per l'angolo in alto a sinistra. L'AUC (area sotto la curva ROC) è un solo numero tra 0,5 (caso) e 1 (perfetto) e ha un'interpretazione elegante: è la probabilità che un positivo scelto a caso riceva un punteggio più alto di un negativo scelto a caso. Lo verifichiamo calcolando l'AUC in due modi indipendenti, a mano.
from sklearn.metrics import roc_auc_score, average_precision_score, precision_recall_curve
scores = LogisticRegression(max_iter=1000).fit(X_tr, y_tr).predict_proba(X_te)[:, 1]
# 1) curva ROC a mano: una soglia per ogni punteggio distinto, dal più alto al più basso
order = np.argsort(-scores)
y_sorted = y_te[order]
tpr = np.concatenate([[0], np.cumsum(y_sorted) / y_sorted.sum()])
fpr = np.concatenate([[0], np.cumsum(1 - y_sorted) / (1 - y_sorted).sum()])
auc_trapezoids = np.trapezoid(tpr, fpr) # area con la regola dei trapezi
# 2) AUC come probabilità: confronta ogni positivo con ogni negativo (pareggi = 1/2)
pos, neg = scores[y_te == 1], scores[y_te == 0]
auc_pairs = ((pos[:, None] > neg[None, :]).sum() + 0.5 * (pos[:, None] == neg[None, :]).sum()) / (len(pos) * len(neg))
print("AUC con i trapezi: %.4f AUC come probabilità di coppie: %.4f scikit-learn: %.4f" % (auc_trapezoids, auc_pairs, roc_auc_score(y_te, scores)))
print("coppie positivo-negativo confrontate: %d × %d = %d" % (len(pos), len(neg), len(pos) * len(neg)))
# confronto con la curva precision-recall
ap = average_precision_score(y_te, scores)
print("AUC ROC = %.3f ma precisione media (area sotto la curva precision-recall) = %.3f (modello casuale: %.3f)" % (roc_auc_score(y_te, scores), ap, y_te.mean()))
prec, rec, thr = precision_recall_curve(y_te, scores)
for target in (0.2, 0.4, 0.6):
print("con recall ≥ %.1f la precision massima raggiungibile è %.3f" % (target, prec[:len(rec)][rec >= target].max()))
AUC con i trapezi: 0.5886 AUC come probabilità di coppie: 0.5886 scikit-learn: 0.5886
coppie positivo-negativo confrontate: 59 × 941 = 55519
AUC ROC = 0.589 ma precisione media (area sotto la curva precision-recall) = 0.363 (modello casuale: 0.059)
con recall ≥ 0.2 la precision massima raggiungibile è 0.933
con recall ≥ 0.4 la precision massima raggiungibile è 0.180
con recall ≥ 0.6 la precision massima raggiungibile è 0.060
Cosa fa il codice. Prima i punteggi di una regressione logistica sul test. Il metodo 1 ordina i campioni dal punteggio più alto: abbassare la soglia progressivamente equivale a scorrere questa lista e dichiarare positivi i primi k; a ogni passo TPR e FPR sono le frazioni cumulate di positivi e negativi incontrati. L'area sotto la curva si calcola con la regola dei trapezi (np.trapezoid). Il metodo 2 non disegna nulla: confronta tutte le 59 × 941 = 55 519 coppie (positivo, negativo) e conta in quante il positivo ha un punteggio maggiore, con i pareggi contati metà. I tre numeri coincidono (0,5886): l'AUC è quella probabilità.
Guarda il resto dell'output, perché racconta due storie diverse sullo stesso modello. L'AUC ROC è 0,589, appena sopra il caso: se prendi un positivo e un negativo a caso, il modello li ordina bene solo nel 59% delle coppie. La precisione media (area sotto la curva precision-recall) è 0,363, più di sei volte il riferimento di un modello casuale (0,059, la quota dei positivi). E la tabella dice perché: con recall almeno 0,2 la precision massima raggiungibile è 0,93, con recall 0,4 scende a 0,18 e con recall 0,6 è 0,06, cioè quella del caso. Il modello è molto sicuro su circa il 20% dei positivi (punteggi altissimi, quasi tutti veri positivi) e per il resto non li distingue dai negativi. L'AUC pesa allo stesso modo tutte le coppie, comprese le moltissime in cui il positivo è uno di quelli «difficili»; la precisione media guarda soprattutto la testa della classifica.
Quale metrica è giusta dipende dall'uso: se un analista può rivedere solo i primi 100 allarmi, conta la testa della classifica e la precisione media è molto più informativa; se serve ordinare bene l'intera popolazione, conta l'AUC. Un secondo motivo di cautela: con classi molto sbilanciate la FPR ha al denominatore tutti i negativi (941), quindi anche decine di falsi allarmi producono una FPR minuscola e la curva ROC appare più ottimista di quanto un operatore, che vede solo gli allarmi, sperimenti.
La validazione incrociata k-fold, scritta a mano
Hai usato cross_val_score come una scatola nera. Vale la pena scriverla una volta, perché i bug di validazione sono tra i più subdoli. L'idea: dividi gli esempi in k gruppi (fold) di dimensione simile; per ciascun gruppo, addestra sugli altri k − 1 e valuta su quello escluso; poi riporta media e deviazione standard dei k punteggi. Nel caso stratificato, ciascun gruppo mantiene la proporzione delle classi. Il punto cruciale, ripetuto: tutto ciò che impara dai dati (lo scaler, la selezione, il modello) va addestrato dentro il ciclo, sul solo training del fold.
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
def stratified_folds(y, k, seed=0):
rng = np.random.default_rng(seed)
fold_of = np.empty(len(y), dtype=int)
for c in np.unique(y):
idx = rng.permutation(np.where(y == c)[0]) # mescola gli esempi di ogni classe
fold_of[idx] = np.arange(len(idx)) % k # e distribuiscili a turno nei k gruppi
return fold_of
folds = stratified_folds(y_tr, k=5)
print("esempi per gruppo: %s positivi per gruppo: %s" % (np.bincount(folds), [int(y_tr[folds == f].sum()) for f in range(5)]))
results = []
for f in range(5):
train_idx, val_idx = folds != f, folds == f
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X_tr[train_idx], y_tr[train_idx]) # lo scaler vede solo il training del fold
p_val = pipe.predict_proba(X_tr[val_idx])[:, 1]
results.append(average_precision_score(y_tr[val_idx], p_val))
print("a mano: precisione media per fold %s media %.3f ± %.3f" % (np.round(results, 3), np.mean(results), np.std(results)))
reference = cross_val_score(make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)), X_tr, y_tr,
cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring="average_precision")
print("scikit-learn: precisione media per fold %s media %.3f ± %.3f" % (np.round(reference, 3), reference.mean(), reference.std()))
esempi per gruppo: [601 600 600 600 599] positivi per gruppo: [36, 35, 35, 35, 35]
a mano: precisione media per fold [0.497 0.572 0.389 0.438 0.453] media 0.470 ± 0.062
scikit-learn: precisione media per fold [0.502 0.51 0.414 0.557 0.363] media 0.469 ± 0.070
Cosa fa il codice. stratified_folds assegna a ciascun esempio un numero di gruppo da 0 a 4: per ogni classe mescola gli indici e li distribuisce «a turno», così ogni gruppo riceve 35 o 36 positivi (177 in tutto) e 599-601 esempi. Il ciclo addestra una Pipeline (scaler e modello) sui quattro gruppi di training e valuta la precisione media sul gruppo escluso. Il risultato a mano (0,470 ± 0,062) e quello di scikit-learn (0,469 ± 0,070) non sono identici fold per fold, perché i due metodi mescolano in modo diverso e quindi i gruppi non coincidono, ma sono equivalenti statisticamente: stessa media entro l'incertezza. La deviazione tra gruppi è il dato prezioso: i cinque valori vanno da 0,36 a 0,56, un intervallo ampio, perché i positivi per gruppo sono solo 35 e basta che alcuni siano «difficili» per spostare il punteggio. Se il miglioramento tra due modelli è più piccolo di quella deviazione, non hai prove che sia reale: la validazione incrociata serve a misurare non solo la media ma anche quanto fidarti.
Dalla metrica al costo: scegliere la soglia con i soldi, non con il 0,5
Nella vita reale gli errori hanno costi diversi, e la soglia giusta è quella che minimizza il costo atteso, non quella che massimizza una metrica astratta. Immagina un sistema che segnala transazioni sospette: un falso negativo (una frode non rilevata) costa in media 50 unità, un falso positivo (un blocco inutile di un cliente onesto, con la chiamata dell'assistenza) ne costa 1. I numeri sono inventati per l'esercizio: quello che conta è il metodo. La procedura corretta è scegliere la soglia sul training, usando previsioni fuori-campione (out-of-fold, cioè ottenute con la validazione incrociata), e solo poi misurare il costo sul test una volta.
from sklearn.model_selection import cross_val_predict
COST_FN, COST_FP = 50.0, 1.0 # costi illustrativi, in unità arbitrarie
cv_scores = cross_val_predict(make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)), X_tr, y_tr,
cv=StratifiedKFold(5, shuffle=True, random_state=0), method="predict_proba")[:, 1]
def total_cost(y, p, threshold):
pred = p >= threshold
return COST_FN * ((~pred) & (y == 1)).sum() + COST_FP * (pred & (y == 0)).sum()
thresholds = np.logspace(-3, np.log10(0.99), 150) # da 0,001 a 0,99, su scala logaritmica
costs_cv = np.array([total_cost(y_tr, cv_scores, t) for t in thresholds])
best_t = thresholds[costs_cv.argmin()]
final = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X_tr, y_tr)
p_test = final.predict_proba(X_te)[:, 1]
print("soglia scelta sul training con la validazione incrociata: %.3f (costo medio per esempio: %.3f)" % (best_t, costs_cv.min() / len(y_tr)))
print("%-30s %-12s %-12s %-12s" % ("", "soglia", "recall", "costo/esempio"))
theory_t = COST_FP / (COST_FP + COST_FN) # soglia ottima teorica per probabilità calibrate
for name, t in (("soglia 0,5 (default)", 0.5), ("soglia scelta dal costo (CV)", best_t), ("soglia teorica C_FP/(C_FP+C_FN)", theory_t), ("segnala sempre (soglia 0)", 0.0)):
pred = p_test >= t
print("%-30s %-12.3f %-12.3f %-12.3f" % (name, t, recall_score(y_te, pred), total_cost(y_te, p_test, t) / len(y_te)))
print("costo di un modello che non segnala mai nulla: %.3f per esempio" % (COST_FN * y_te.sum() / len(y_te)))
soglia scelta sul training con la validazione incrociata: 0.003 (costo medio per esempio: 0.938)
soglia recall costo/esempio
soglia 0,5 (default) 0.500 0.119 2.601
soglia scelta dal costo (CV) 0.003 0.966 1.038
soglia teorica C_FP/(C_FP+C_FN) 0.020 0.746 1.504
segnala sempre (soglia 0) 0.000 1.000 0.941
costo di un modello che non segnala mai nulla: 2.950 per esempio
Cosa fa il codice. cross_val_predict restituisce, per ogni esempio del training, la probabilità prevista da un modello che non ha visto quell'esempio: previsioni oneste su cui si può scegliere. La funzione total_cost somma i costi dei due tipi di errore. Si scansionano 150 soglie su scala logaritmica (le probabilità interessanti sono vicine a zero) e si sceglie quella a costo minimo sul training: 0,003. Solo allora si apre il test, e il risultato è istruttivo su tre piani. Primo, la soglia di default 0,5 trova solo il 12% dei positivi e costa 2,60 per esempio, quasi quanto non fare nulla (2,95). Secondo, la soglia scelta dalla validazione incrociata (0,003) trova il 97% dei positivi e costa 1,04: un grande miglioramento, ma il modello sta di fatto segnalando quasi tutto.
Terzo, e più importante: la strategia banale «segnala sempre» costa 0,941 per esempio, meno di qualsiasi soglia del modello. Il modello, con AUC 0,59, non aggiunge valore rispetto a quella baseline, perché con un rapporto di costi di 50 a 1 e il 6% di positivi chiamare tutto positivo è già conveniente. Il risultato non è un difetto dell'esercizio: è esattamente ciò che una valutazione basata sul costo deve scoprire.
C'è anche una formula che predice la soglia. Per probabilità calibrate, segnalare conviene se (1 − p)·C_FP < p·C_FN, cioè se p > C_FP/(C_FP + C_FN) = 1/51 ≈ 0,020. La soglia teorica 0,020 dà un costo di 1,50: peggio di 0,003 perché le probabilità del modello non sono ben calibrate (lezione sulla regressione logistica) e la validazione le ha corrette empiricamente. Morale: prima di valutare un modello, confrontalo con le strategie banali (non fare nulla, fare sempre), e scegli la soglia con i costi del tuo problema.
Esercizio 2
La soglia che costa meno
Lasciare passare una saldatura difettosa costa 40 unità; fermare un pannello buono ne costa 2. Il modello assegna a dieci pezzi questi punteggi, in ordine decrescente: 0,95 · 0,80 · 0,62 · 0,55 · 0,40 · 0,33 · 0,21 · 0,12 · 0,07 · 0,02. I pezzi davvero difettosi sono quelli con punteggio 0,95 · 0,80 · 0,55 · 0,33.
- Ricava la soglia teorica partendo dal confronto tra il costo atteso di segnalare e quello di non segnalare un pezzo con probabilità p. Quanto vale con questi costi?
- Calcola il costo totale alle soglie 0,50 — quella di default — 0,33 e alla soglia teorica del punto 1.
- La soglia teorica è la più economica delle tre? Se no, quale ipotesi della derivazione non regge?
Le tre domande riproducono in piccolo l'esperimento della sezione sul costo: la prima ricava la soglia dal rapporto dei costi, la seconda la confronta con la soglia di default e con un'altra, la terza chiede quando la teoria non basta. Se la soglia teorica non è la più economica, l'ipotesi che cade è la calibrazione delle probabilità.
Mostra la soluzione
1. Se segnali un pezzo che ha probabilità p di essere difettoso, sbagli con probabilità (1 − p) e paghi il falso allarme: costo atteso (1 − p) · C_FP. Se non lo segnali, sbagli con probabilità p e paghi il difetto sfuggito: p · C_FN. Segnalare conviene quando (1 − p) · C_FP < p · C_FN, cioè C_FP < p · (C_FN + C_FP), cioè p > C_FP / (C_FP + C_FN). Con i numeri: 2 / 42 = 0,0476.
2. Il costo è 40 · (difettosi sotto soglia) + 2 · (buoni sopra soglia). Alla soglia 0,50 segnali i primi quattro punteggi: trovi tre difettosi, ne perdi uno (quello a 0,33) e fai un falso allarme (0,62), quindi 40 + 2 = 42. Alla soglia 0,33 segnali i primi sei: li trovi tutti e quattro, con due falsi allarmi (0,62 e 0,40), quindi 0 + 4 = 4. Alla soglia 0,0476 segnali tutto tranne l'ultimo pezzo: nessun difetto sfuggito, cinque falsi allarmi, quindi 0 + 10 = 10.
3. No: la soglia teorica costa 10, più del doppio del minimo che è 4. La formula non è sbagliata, è sbagliata l'ipotesi su cui poggia: vale solo se i punteggi del modello sono probabilità calibrate, cioè se fra tutti i pezzi a cui il modello assegna 0,30 ce ne sono davvero il 30% di difettosi. Quasi nessun modello lo è senza una calibrazione esplicita. La formula dice dove starebbe la soglia se i punteggi fossero probabilità; la ricerca empirica, fatta su dati di validazione mai usati per addestrare, dice dov'è davvero.
Tre esercizi
Esercizio 1. Cambia i costi (FN = 10, poi FN = 200) e osserva come si sposta la soglia ottima. Che cosa succede alla recall? Esercizio 2. Ripeti il confronto ROC contro precision-recall con un dataset non sbilanciato (weights=[0.5, 0.5] in make_classification): la differenza tra AUC ROC e precisione media si riduce? Esercizio 3. Inserisci di proposito del leakage nel ciclo k-fold (standardizza prima di dividere) e misura quanto cambia la precisione media: è una buona stima dell'«ottimismo» del leakage per questo problema.
Gli esercizi di codice di questa lezione sono in un solo file, esercizi.py (circa 16 KB). Tre esercizi di codice su un dominio nuovo, il collaudo di saldature: le metriche ricavate dalle quattro caselle, la soglia a costo minimo cercata per scansione, e uno split per gruppo che smaschera il leakage da righe duplicate. Richiede Python 3.12+ e NumPy 2.3 — nessun scikit-learn. Eseguilo con python3 esercizi.py: gira anche con le parti da completare lasciate vuote e ti dice quali mancano.
Lezione precedente del percorso: Catene di Markov: la proprietà che dimentica il passato, e la distribuzione che non cambia più. Nella stessa serie rimandano a questa lezione anche: Algebra lineare 2: autovalori, SVD e PCA, Support Vector Machine: il margine massimo e il trucco del kernel, KNN: classificare guardando i vicini (e la maledizione della dimensionalità), Random Forest: perché cento alberi sbagliano meno di uno, Reinforcement learning: l'agente che impara per tentativi (e il dilemma esplorazione-sfruttamento).
Fonti
- scikit-learn, Common pitfalls and recommended practices - la definizione di data leakage e la raccomandazione di mettere ogni passo che impara dai dati dentro una pipeline, consultata il 2026-11-12
- scikit-learn, cross_val_score - per un classificatore binario o multiclasse con
cvintero viene usatoStratifiedKFold, consultata il 2026-11-12 - scikit-learn, Metrics and scoring - precision, recall, F1, curva ROC e precisione media, consultata il 2026-11-12
- scikit-learn, DummyClassifier - la strategia
most_frequentusata come modello pigro di riferimento, consultata il 2026-11-12
Riferimento non raggiunto online: T. Hastie, R. Tibshirani e J. Friedman, The Elements of Statistical Learning, 2ª edizione, Springer, 2009, capitolo sulla valutazione e la scelta dei modelli, dove è discusso il caso del rumore puro con selezione delle feature prima della validazione incrociata (il PDF dell'autore non era scaricabile). Tutti i listati sono stati eseguiti due volte con Python 3.14.4, NumPy 2.3.5 e scikit-learn 1.8.0: le uscite riportate coincidono con quelle reali.
Nelle prossime lezioni applicherai questo stesso processo a ogni algoritmo, partendo dal più semplice da capire: Naive Bayes.
Commenti
Caricamento commenti...
Accedi per commentare