Un modello di classificazione che indovina il 92% dei casi sembra un successo. Se i dati su cui lo hai misurato sono rumore puro, in cui per costruzione nessun modello può superare il 50%, quel 92% è invece un errore di metodo, e lo vedrai prodotto da codice che gira senza un solo messaggio d'errore. Questa lezione ti dà gli strumenti per non cadere nella trappola: l'interfaccia uniforme di scikit-learn (fit, predict, transform), la separazione fra dati di addestramento e di test, la validazione incrociata e la Pipeline, che rende strutturalmente impossibile la fuga di dati (data leakage). La fuga non la descrivo soltanto: la misuro con un esperimento in cui la risposta vera è nota in anticipo.
scikit-learn è la libreria di riferimento per il machine learning «classico» su dati tabulari: modelli lineari, alberi, vicini più prossimi, clustering, riduzione della dimensionalità, e tutto ciò che serve per preparare i dati e valutare i risultati. La sua forza non è un singolo algoritmo ma un'interfaccia uniforme, che impari una volta e vale per centinaia di oggetti. Servono Python 3, NumPy, pandas e scikit-learn (pip install scikit-learn pandas in un ambiente virtuale); i listati sono stati eseguiti con scikit-learn 1.8.0, mentre le pagine di documentazione citate qui sotto sono quelle della versione «stable» al momento della consultazione (1.9.1). I listati della lezione formano un solo programma, da eseguire nell'ordine in cui compaiono: ciascuno riusa gli import e le variabili del precedente, tranne l'ultimo (la soluzione del mini-progetto), che è autonomo. Se arrivi dalle lezioni su NumPy e Pandas hai già tutto quello che serve, e se vuoi rivedere come si guardano i dati prima di modellarli c'è la lezione su Matplotlib.
L'API: tre verbi
Un stimatore (estimator) è un apprendista con un quaderno vuoto. Il verbo fit(X, y) gli fa studiare gli esempi: alla fine il quaderno contiene ciò che ha imparato (coefficienti, soglie, medie). Il verbo predict(X) gli fa rispondere su esempi nuovi, consultando il quaderno senza modificarlo. Il verbo transform(X), proprio dei trasformatori come lo scalatore, applica quanto imparato per modificare i dati invece di predire un'etichetta. Quattro convenzioni aiutano a leggere il codice altrui. Gli iperparametri, le manopole che scegli tu come la forza della regolarizzazione C, si passano al costruttore: LogisticRegression(C=1.0). Tutto ciò che viene appreso dai dati ha un nome che finisce con un underscore (coef_, mean_, classes_), e la guida per sviluppatori di scikit-learn lo dichiara come regola: se un attributo con l'underscore esiste, l'oggetto è già stato addestrato. X ha forma (n_esempi, n_caratteristiche) e y ha forma (n_esempi,): righe uguali esempi, colonne uguali caratteristiche, la stessa convenzione di NumPy. Infine fit restituisce l'oggetto stesso, quindi si può concatenare: modello.fit(X, y).predict(X_nuovo).
Questa uniformità è una scelta di progetto deliberata, descritta dagli autori stessi nel lavoro sull'API design di scikit-learn (Buitinck e coll., 2013). Il vantaggio pratico è che puoi sostituire un algoritmo con un altro cambiando una riga, e che gli strumenti di contorno (Pipeline, validazione incrociata, ricerca degli iperparametri) funzionano con qualsiasi oggetto che rispetti le tre convenzioni.
Addestramento e test: l'esame con domande mai viste
Lavoriamo sul dataset di diagnostica del tumore al seno incluso nella libreria: 569 casi, 30 misure numeriche per caso, 212 maligni e 357 benigni. È un dataset didattico, usato qui solo per mostrare il metodo. L'idea della separazione è quella dell'esame: se ti esamino con gli stessi esercizi che hai già visto misuro la memoria; per misurare la capacità di generalizzare servono domande nuove. Dividiamo dunque i dati in due insiemi, uno per addestrare e uno tenuto da parte per il solo giudizio finale. Nel listato stratify=y mantiene la stessa proporzione fra le classi nei due insiemi, e random_state=0 rende la divisione ripetibile (se lo ometti, ogni esecuzione dà un risultato diverso). Prima del modello vero costruisco la baseline, un DummyClassifier con la strategia most_frequent che risponde sempre con la classe più frequente (la documentazione indica prior come predefinita; qui scelgo la strategia esplicitamente).
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
X, y = load_breast_cancer(return_X_y=True)
print("forma di X:", X.shape, "| classi (0=maligno, 1=benigno):", np.bincount(y))
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
print("addestramento:", X_tr.shape[0], "esempi | test:", X_te.shape[0], "esempi")
base = DummyClassifier(strategy="most_frequent").fit(X_tr, y_tr)
print("baseline (sempre la classe più frequente):", round(base.score(X_te, y_te), 3))
modello = LogisticRegression(max_iter=5000).fit(X_tr, y_tr)
print("regressione logistica, accuratezza sul test:", round(modello.score(X_te, y_te), 3))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
forma di X: (569, 30) | classi (0=maligno, 1=benigno): [212 357]
addestramento: 426 esempi | test: 143 esempi
baseline (sempre la classe più frequente): 0.629
regressione logistica, accuratezza sul test: 0.937
La baseline vale 0.629: rispondere sempre «benigno» basta da solo per il 63% dei casi, e questo è il pavimento. La regressione logistica arriva a 0.937, e solo letto contro quel 63% il risultato è una vittoria vera; un 94% su un problema dove il 63% è gratis impressiona meno. La divisione casuale non è sempre quella giusta. Se i dati hanno un ordine temporale (previsioni su serie storiche), dividere a caso fa vedere al modello il futuro: la documentazione descrive per questo TimeSeriesSplit, in cui i training set successivi contengono quelli precedenti e il test viene sempre dopo. Se più righe appartengono alla stessa persona o alla stessa macchina, un esempio nel training e un suo «fratello» nel test falsano il giudizio, e la guida alla validazione incrociata indica GroupKFold, che garantisce che lo stesso gruppo non compaia mai in entrambi gli insiemi.
Oltre l'accuratezza
L'accuratezza è una sola cifra. La matrice di confusione mostra dove si sbaglia: le righe sono la classe vera, le colonne la classe prevista, e la diagonale contiene le risposte giuste.
pred = modello.predict(X_te)
print(confusion_matrix(y_te, pred))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
[[49 4]
[ 5 85]]
Su 143 casi di test la riga 0 è la classe «maligno»: 49 riconosciuti e 4 dichiarati benigni, che è l'errore più costoso. La riga 1 è la classe «benigno»: 5 dichiarati maligni e 85 riconosciuti. Gli errori hanno costi diversi: dichiarare benigno un tumore maligno non costa quanto il contrario, e la soglia di decisione si sceglie in base a quei costi, non all'accuratezza media. Con la matrice puoi calcolare precisione, richiamo e F1, che l'esercizio seguente ti fa derivare a mano.
Esercizio 1
Quattro numeri da una matrice di confusione
Una linea produce cuscinetti a sfera. Su 200 pezzi collaudati, 40 sono davvero difettosi. Un modello ne segnala 50 come difettosi, e di questi 30 lo sono davvero. Carta e penna.
- Scrivi la matrice di confusione nella convenzione di scikit-learn (righe uguale verità, colonne uguale previsione, classe positiva uguale «difettoso»).
- Calcola accuratezza, precisione, richiamo e F1.
- Un secondo modello dichiara «buono» ogni pezzo senza guardarlo. Quali dei quattro numeri lo premiano e quali lo smascherano?
Parti dalla matrice: tutti gli altri numeri sono rapporti fra le sue quattro celle, quindi un errore nel primo punto si propaga a tutti i successivi. La terza domanda è la ragione per cui esistono quattro numeri e non uno.
Mostra la soluzione
1. Veri positivi 30; falsi positivi 50 - 30 = 20; falsi negativi 40 - 30 = 10; veri negativi 200 - 40 - 20 = 140. Quindi [[140, 20], [10, 30]].
2. Accuratezza (30 + 140) / 200 = 0.85. Precisione 30 / 50 = 0.60: sei allarmi su dieci sono veri. Richiamo 30 / 40 = 0.75: tre difetti su quattro vengono presi. F1 = 2 · 0.6 · 0.75 / 1.35 = 0.9 / 1.35 = 2/3 ≈ 0.667, cioè la media armonica delle due.
3. Il modello pigro ha accuratezza 160 / 200 = 0.80, appena cinque punti sotto il primo: l'accuratezza lo premia. Precisione, richiamo e F1 valgono tutti e tre zero, perché non segnala nemmeno un difetto: lo smascherano. Si capisce anche perché F1 usa la media armonica e non quella aritmetica: con precisione 1 e richiamo 0 la media aritmetica darebbe 0.5, un numero che sembra accettabile, mentre l'armonica dà 0, che è la verità. Quale delle due metriche conti davvero dipende dal costo: se un difetto che arriva al cliente provoca un richiamo di prodotto conta il richiamo; se ogni allarme ferma la linea per un'ora conta la precisione. È una scelta economica, da fare prima di addestrare.
Perché si scala (e quando non serve)
Molti modelli sono sensibili alla scala delle caratteristiche. I vicini più prossimi calcolano distanze: se una caratteristica vale migliaia e un'altra decimi, la prima decide da sola. Il listato confronta il modello dei k vicini senza e con standardizzazione (si sottrae la media e si divide per la deviazione standard) sul dataset dei vini, anch'esso incluso nella libreria: 178 campioni, 13 misure chimiche, tre varietà. La valutazione usa una validazione a 5 gruppi, che spiego più avanti; per ora basta sapere che dà la media di cinque accuratezze. Lo scalatore sta dentro una make_pipeline, e il motivo è il tema della prossima sezione.
from sklearn.datasets import load_wine
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
Xw, yw = load_wine(return_X_y=True)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
grezzo = cross_val_score(KNeighborsClassifier(), Xw, yw, cv=cv)
scalato = cross_val_score(make_pipeline(StandardScaler(), KNeighborsClassifier()), Xw, yw, cv=cv)
print("intervalli di valori (min, max) per colonna:",
[(round(float(Xw[:, j].min()), 1), round(float(Xw[:, j].max()), 1)) for j in (0, 1, 12)])
print("k vicini senza scalare:", round(grezzo.mean(), 3))
print("k vicini con scalatore:", round(scalato.mean(), 3))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
intervalli di valori (min, max) per colonna: [(11.0, 14.8), (0.7, 5.8), (278.0, 1680.0)]
k vicini senza scalare: 0.663
k vicini con scalatore: 0.961
La colonna 12 (la prolina) va da 278 a 1680; la colonna 1 da 0.7 a 5.8, la 0 da 11.0 a 14.8. Senza scalare, la distanza fra due vini è decisa quasi per intero dalla prolina e le altre dodici misure contano pochissimo: l'accuratezza è 0.663. Con lo scalatore sale a 0.961, senza cambiare né i dati né il modello. Gli alberi di decisione e le foreste non usano distanze né combinazioni lineari: per loro la scala è irrilevante, e scalare non serve (non nuoce, ma non aiuta). I modelli lineari regolarizzati, i vicini, le macchine a vettori di supporto e le reti neurali vogliono invece dati sulla stessa scala. Non esiste una regola «scala sempre»: dipende dall'algoritmo, e il confronto del mini-progetto alla fine ne mostra tre.
La fuga di dati (data leakage)
Lo studente dell'esame ha, nascosto, un foglietto con le risposte: prende 30 e lode, e il voto non misura nulla. Nel machine learning il foglietto è qualunque informazione sul test che finisce, direttamente o indirettamente, nell'addestramento. La pagina «Common pitfalls» della documentazione la definisce come l'uso, nella costruzione del modello, di informazioni che non sarebbero disponibili al momento della previsione, e ne elenca l'effetto: stime troppo ottimistiche, anche nella validazione incrociata, e prestazioni peggiori in produzione. La regola generale della stessa pagina è semplice: mai chiamare fit sui dati di test. È la fonte di errore più frequente e più subdola della pratica, perché produce risultati bellissimi in laboratorio e disastri dopo il rilascio.
Un esperimento con la risposta nota
Costruiamo un problema in cui l'unica accuratezza possibile è il 50%: 50 esempi, 1000 caratteristiche di puro rumore gaussiano, etichette binarie estratte a caso e indipendenti da tutto. Nessun algoritmo può fare meglio del caso, perché non c'è nulla da imparare. (La pagina di documentazione citata sopra costruisce un esperimento analogo con 200 esempi e 10 000 caratteristiche; qui i parametri sono miei.) Confronto due procedure. Quella sbagliata ma diffusissima sceglie le 20 caratteristiche «più correlate con l'etichetta» usando tutti i dati e poi fa la validazione incrociata sul sottoinsieme scelto. Quella giusta mette la selezione dentro una Pipeline, così viene rifatta dentro ogni gruppo usando solo i dati di addestramento di quel gruppo. L'esperimento si ripete su 20 campioni di rumore indipendenti (il ciclo for seme in range(20)), per non fidarsi di un singolo caso fortunato.
from sklearn.feature_selection import SelectKBest, f_classif
n, p = 50, 1000
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=1)
sbagliato, giusto = [], []
for seme in range(20): # 20 esperimenti indipendenti
r = np.random.default_rng(seme)
Xn = r.normal(size=(n, p)) # puro rumore
yn = r.integers(0, 2, n) # etichette a caso
# SBAGLIATO: la selezione vede TUTTI i dati, poi si valida
Xsel = SelectKBest(f_classif, k=20).fit_transform(Xn, yn)
sbagliato.append(cross_val_score(LogisticRegression(), Xsel, yn, cv=cv).mean())
# GIUSTO: la selezione è un passo della pipeline, rifatto dentro ogni gruppo
pipe = make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression())
giusto.append(cross_val_score(pipe, Xn, yn, cv=cv).mean())
print("accuratezza attesa dal caso : 0.500")
print("procedura sbagliata (media su 20):", round(float(np.mean(sbagliato)), 3))
print("procedura giusta (media su 20):", round(float(np.mean(giusto)), 3))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
accuratezza attesa dal caso : 0.500
procedura sbagliata (media su 20): 0.927
procedura giusta (media su 20): 0.507
La procedura sbagliata «scopre» un'accuratezza media di 0.927 in dati che sono rumore puro; quella giusta torna a 0.507, cioè alla verità. Il perché è questo: con 1000 caratteristiche indipendenti dall'etichetta e solo 50 esempi, per puro caso alcune caratteristiche sembrano correlate con l'etichetta in quel campione. Se scegli le 20 più correlate guardando tutti i dati, hai scelto proprio quelle in cui il caso ha lavorato a tuo favore anche sul gruppo che userai per validare, e la validazione non è più indipendente. Nella procedura giusta la selezione vede solo il training di ciascun gruppo; sul gruppo di validazione le caratteristiche scelte per caso non hanno alcun potere e l'accuratezza torna al 50%. La figura riassume i due ordini.
Hastie, Tibshirani e Friedman chiamano questa situazione, in The Elements of Statistical Learning, «il modo sbagliato e il modo giusto di fare la validazione incrociata» (sezione 7.10; il libro non l'ho riaperto per questa lezione). La regola d'oro che ne discende: ogni passo che impara qualcosa dai dati (scalatore, selezione di caratteristiche, imputazione dei mancanti, riduzione di dimensione, codifica) deve essere addestrato solo sul training e solo applicato al test. In scikit-learn questo si ottiene mettendo tutti i passi dentro una Pipeline. Il costo è nullo e il rischio evitato è alto; un caso in cui la regola non basta è quello delle righe «gemelle», trattato nell'esercizio.
Esercizio 2
Il gemello che passa il confine
Il collaudo produce 600 righe, su cui si fa una validazione incrociata a 5 gruppi. Attenzione a un dettaglio del processo: ogni cuscinetto viene misurato da due stazioni, quindi le 600 righe descrivono 300 pezzi, a due righe quasi identiche per pezzo.
- Quante righe ha ciascun gruppo di validazione, e su quante righe viene addestrato ciascuno dei cinque modelli?
- Quante volte ciascuna riga finisce in validazione, e quante in addestramento?
- Mescolando le righe, qual è la probabilità che il gemello di una riga di validazione si trovi nel training? Che cosa cambia usando
GroupKFoldsul numero di pezzo?
Le prime due domande sono pura aritmetica sulla struttura della validazione incrociata, e servono a fissare i numeri che usi nella terza; è la terza a contenere l'idea, perché collega il conteggio al problema delle righe quasi duplicate.
Mostra la soluzione
1. 600 / 5 = 120 righe di validazione per gruppo, e 600 · 4 / 5 = 480 righe di addestramento per ciascun modello.
2. Ogni riga compare esattamente una volta in validazione e k - 1 = 4 volte in addestramento. È la proprietà che rende la validazione incrociata più stabile di un singolo test: ogni esempio viene giudicato una volta, da un modello che non lo ha visto.
3. Fissata una riga di validazione, restano 599 righe, di cui 480 nel training: la probabilità che il gemello sia fra queste è 480 / 599 ≈ 0.80. Quattro volte su cinque il modello ha già visto una copia quasi identica della riga che deve giudicare, etichetta compresa. Con GroupKFold raggruppato per numero di pezzo quella probabilità scende a zero: le due misure dello stesso cuscinetto finiscono sempre nello stesso gruppo, e il punteggio torna a misurare la capacità di generalizzare invece della memoria. La regola generale: l'unità da dividere è l'unità statistica della domanda (il pezzo, il paziente, il cliente), non la riga del file.
Pipeline e ColumnTransformer
Una Pipeline è una catena di trasformatori seguiti da un modello finale, che si comporta come un unico stimatore: fit addestra ogni passo in sequenza, ciascun trasformatore imparando dal risultato del precedente, e predict applica tutto in ordine. Con la validazione incrociata, ogni gruppo ri-addestra l'intera catena da capo, e per questo la fuga è strutturalmente impossibile. I dati reali però sono misti: numeri, categorie, buchi. Il ColumnTransformer applica una pipeline diversa a ciascun gruppo di colonne. Il listato costruisce una tabella sintetica di 1200 clienti di un servizio in abbonamento (dati generati dal codice, non reali) con l'obiettivo di prevedere chi disdice. Le tre colonne numeriche passano per l'imputazione con la mediana e per la standardizzazione; le due categoriche per la codifica one-hot. Per il ColumnTransformer le colonne non nominate vengono scartate (il parametro remainder vale 'drop' se non lo scrivi, come indica la documentazione): qui le elenco tutte, quindi non ne perdo nessuna.
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder
rng = np.random.default_rng(11)
m = 1200
clienti = pd.DataFrame({
"eta": rng.integers(18, 80, m).astype(float),
"canone": np.round(rng.normal(30, 10, m).clip(5, None), 2),
"mesi_attivi": rng.integers(1, 60, m),
"piano": rng.choice(["base", "plus", "pro"], m, p=[0.5, 0.3, 0.2]),
"canale": rng.choice(["web", "negozio", "telefono"], m),
})
clienti.loc[rng.random(m) < 0.08, "eta"] = np.nan # 8 % di età mancanti
# l'abbandono dipende da canone alto, pochi mesi attivi, piano base (relazione inventata da noi)
punteggio = 0.15 * (clienti["canone"] - 30) - 0.10 * (clienti["mesi_attivi"] - 30) \
+ 1.5 * (clienti["piano"] == "base") - 1.0
prob = 1 / (1 + np.exp(-punteggio.to_numpy())) # to_numpy: da qui in poi lavoriamo con array senza indice
disdetta = (rng.random(m) < prob).astype(int)
print("tasso di disdette:", round(float(disdetta.mean()), 3))
numeriche = ["eta", "canone", "mesi_attivi"]
categoriche = ["piano", "canale"]
prepara = ColumnTransformer([
("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()), numeriche),
("cat", OneHotEncoder(handle_unknown="ignore"), categoriche),
])
pipeline = make_pipeline(prepara, LogisticRegression(max_iter=1000))
X_tr, X_te, y_tr, y_te = train_test_split(clienti, disdetta, test_size=0.25, stratify=disdetta, random_state=0)
pipeline.fit(X_tr, y_tr)
print("accuratezza sul test:", round(pipeline.score(X_te, y_te), 3))
print("baseline (classe più frequente):", round(max(y_te.mean(), 1 - y_te.mean()), 3))
print("passi della pipeline:", [nome for nome, _ in pipeline.steps])
print("colonne dopo la trasformazione:", pipeline[:-1].transform(X_te).shape[1])
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
tasso di disdette: 0.452
accuratezza sul test: 0.817
baseline (classe più frequente): 0.55
passi della pipeline: ['columntransformer', 'logisticregression']
colonne dopo la trasformazione: 9
Il modello supera nettamente la baseline (0.817 contro 0.55): sta riscoprendo dai dati la relazione che ho inventato nel codice (disdetta più probabile con canone alto, pochi mesi attivi, piano base). Il numero di colonne dopo la trasformazione, 9, conferma la codifica: 3 numeriche, 3 per il piano e 3 per il canale. Perché one-hot invece di scrivere base uguale 0, plus uguale 1, pro uguale 2? Perché i numeri imporrebbero un ordine e una distanza («pro è il doppio di plus») che non esistono; con colonne separate il modello assegna a ciascuna categoria il suo peso.
handle_unknown="ignore" evita un errore in produzione se compare una categoria mai vista nell'addestramento. L'imputazione calcola la mediana sul training, lo scalatore calcola media e deviazione standard sul training, e sul test si applicano quei numeri senza ricalcolarli: fatto a mano è facilissimo sbagliare (fit_transform sul test al posto di transform), con la pipeline l'errore non si può commettere. Nota anche l'uso di pandas per la tabella dei clienti, già visto nella lezione su Pandas, e di .to_numpy() per passare a un array senza indice.
Validazione incrociata e scelta degli iperparametri
Un solo test set ha un difetto: il risultato dipende da quali esempi sono capitati lì, e con pochi dati la stima oscilla molto. La validazione incrociata a k gruppi (k-fold) divide i dati in k gruppi di uguale dimensione. Per k volte ne usa uno come validazione e i restanti k−1 come training. Alla fine ci sono k punteggi, e la stima è la loro media, con la deviazione standard come indicazione di quanto è stabile. Con n esempi ogni gruppo ne ha n/k; ogni esempio compare esattamente una volta in validazione e k−1 volte in addestramento; ogni modello è addestrato su n·(k−1)/k esempi. Per k = 5 l'80% dei dati addestra e il 20% valuta, a rotazione.
Il listato calcola i cinque punteggi con cross_val_score sul solo training e poi li riproduce a mano, con un ciclo che chiama fit e score gruppo per gruppo, per mostrare che non c'è magia. Il confronto np.allclose stampa True quando i due modi coincidono. StratifiedKFold con shuffle=True e un random_state fisso è la forma più comune: mantiene le proporzioni delle classi in ogni gruppo e rende il risultato ripetibile.
punteggi = cross_val_score(pipeline, X_tr, y_tr, cv=StratifiedKFold(5, shuffle=True, random_state=0))
print("punteggi dei 5 gruppi:", np.round(punteggi, 3))
print("media:", round(float(punteggi.mean()), 3), "| deviazione standard:", round(float(punteggi.std()), 3))
# la stessa cosa a mano: dimostra che non c'è magia
manuale = []
for idx_tr, idx_val in StratifiedKFold(5, shuffle=True, random_state=0).split(X_tr, y_tr):
pipeline.fit(X_tr.iloc[idx_tr], y_tr[idx_tr])
manuale.append(pipeline.score(X_tr.iloc[idx_val], y_tr[idx_val]))
print("a mano, identico:", np.allclose(manuale, punteggi))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
punteggi dei 5 gruppi: [0.817 0.833 0.872 0.772 0.861]
media: 0.831 | deviazione standard: 0.035
a mano, identico: True
I cinque punteggi vanno da 0.772 a 0.872: la media è 0.831 e la deviazione standard 0.035. Quest'ultima è la ragione per cui conviene riportare media e variabilità, non solo un numero: due modelli che differiscono di un punto percentuale, con una deviazione standard di tre punti, non sono distinguibili. Per sapere quanto fidarsi di un punteggio servono gli strumenti della lezione di statistica, che mostra come costruire un intervallo di confidenza.
Scegliere un iperparametro senza toccare il test
Vogliamo scegliere la forza della regolarizzazione C della regressione logistica: più è piccolo, più il modello è «frenato». Se la scegliessimo guardando il test, il test cesserebbe di essere tale (saremmo tornati allo studente col foglietto). La scelta avviene perciò dentro il training, con la validazione incrociata, e il test si guarda una volta sola, alla fine. GridSearchCV prova ogni combinazione di una griglia; la griglia è un dizionario che associa il nome di un parametro a una lista di valori, la struttura descritta nella lezione su liste, tuple, dizionari e insiemi. Il nome logisticregression__C usa il doppio underscore per dire «il parametro C del passo logisticregression»: in una pipeline i parametri si indirizzano per nome del passo.
from sklearn.model_selection import GridSearchCV
griglia = {"logisticregression__C": [0.001, 0.01, 0.1, 1, 10, 100]}
ricerca = GridSearchCV(pipeline, griglia, cv=StratifiedKFold(5, shuffle=True, random_state=0))
ricerca.fit(X_tr, y_tr)
for C, media in zip(griglia["logisticregression__C"], ricerca.cv_results_["mean_test_score"]):
print(f"C = {C:>7}: accuratezza di validazione {media:.3f}")
print("scelto:", ricerca.best_params_)
print("test, guardato una volta sola:", round(ricerca.score(X_te, y_te), 3))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
C = 0.001: accuratezza di validazione 0.719
C = 0.01: accuratezza di validazione 0.814
C = 0.1: accuratezza di validazione 0.823
C = 1: accuratezza di validazione 0.831
C = 10: accuratezza di validazione 0.830
C = 100: accuratezza di validazione 0.829
scelto: {'logisticregression__C': 1}
test, guardato una volta sola: 0.817
Leggi la curva: con C = 0.001 il modello è frenato al punto da non riuscire a imparare nemmeno la struttura vera (sotto-adattamento, 0.719); da C = 1 in su la validazione si appiattisce intorno a 0.83, segno che ulteriori gradi di libertà non aiutano. Il valore sul test (0.817) è vicino alla stima di validazione (0.831): è quanto ci si aspetta quando il processo è pulito. Il modello finale viene riaddestrato su tutto il training (refit=True è il valore predefinito di GridSearchCV), e solo allora si valuta sul test.
Più valori nella griglia e più iperparametri significano più combinazioni: con 5 iperparametri e 10 valori ciascuno sono 10⁵ configurazioni, ciascuna moltiplicata per k addestramenti, un conto che trovi spiegato nella lezione sul calcolo combinatorio. C'è anche un rischio sottile: se confronti centinaia di configurazioni, la migliore sarà in parte fortunata, e la sua stima di validazione sarà ottimistica. Per una stima onesta serve un test mai usato oppure una validazione annidata (la documentazione di scikit-learn rimanda a un esempio che confronta la validazione annidata con quella semplice). Se infine ti serve salvare il modello su disco con joblib o pickle, ricorda l'avvertenza della pagina sulla persistenza dei modelli: caricare un file di questo tipo può eseguire codice arbitrario, quindi carica solo file che hai prodotto tu o di cui ti fidi; la lezione su funzioni, file, eccezioni e oggetti spiega perché.
Errori tipici
| Sintomo | Causa | Rimedio |
|---|---|---|
| Risultati spettacolari in laboratorio, deludenti in produzione | fuga di dati | una pipeline per ogni passo che impara; separazione fatta prima di guardare i dati; per i dati temporali, divisione per tempo |
| Lo scalatore «sa» la media del test | fit_transform sul test |
fit solo sul training, transform sul test, oppure la pipeline |
| Accuratezza alta su classi sbilanciate | l'accuratezza premia chi risponde sempre la classe più frequente | baseline, matrice di confusione, precisione e richiamo |
| Il punteggio sul test non è più credibile | il modello è stato scelto guardando il test | validazione incrociata sul training; test aperto una volta |
| Il modello attribuisce un ordine alle categorie | categorie codificate come interi progressivi | OneHotEncoder; codifiche ordinali solo per variabili davvero ordinate |
KeyError o righe sbagliate dopo una divisione |
una Series indicizzata con posizioni cerca invece le etichette |
.iloc[...] oppure .to_numpy() all'ingresso |
| Risultati diversi a ogni esecuzione | random_state dimenticato |
fissarlo e dichiarare la versione della libreria |
| Punteggio troppo bello con più righe per soggetto | righe dello stesso soggetto in training e test | GroupKFold |
La maggior parte di queste righe ha una sola radice, la fuga di dati in una delle sue forme: il test (o una sua parte, o un suo gemello) ha influenzato una scelta fatta in addestramento. Il rimedio è sempre strutturale, non di attenzione: costruire il processo in modo che l'errore non si possa commettere, con una pipeline e con una divisione fatta per unità statistica.
Quattro regole da portare via
- Baseline prima del modello: un
DummyClassifier(oDummyRegressor) come confronto. - Tutto dentro la
Pipeline: imputazione, scalatura, codifica, selezione, modello. - Il test set si apre una volta: se lo guardi due volte, hai un nuovo training.
- Riporta media e variabilità della validazione, non solo un numero.
Sono indipendenti e puoi applicarle in qualsiasi ordine, ma la prima è la più sottovalutata: senza un pavimento non sai se un 94% sia un risultato o una coincidenza, come è successo con la baseline di 0.629 sul dataset del tumore. Controlla anche le forme (X.shape, y.shape) e i tipi in ingresso, perché la maggior parte degli errori di principianti è lì.
Mini-progetto: i vini, dall'inizio alla fine
Con il dataset dei vini, lavora in quattro passi.
- Dividi in training (75%) e test (25%) con stratificazione e
random_state=0. - Confronta, con validazione incrociata a 5 gruppi sul training, tre modelli dentro una pipeline con
StandardScaler: regressione logistica,KNeighborsClassifier,DecisionTreeClassifier(max_depth=3). - Scegli il migliore e valutalo una volta sul test, riportando la matrice di confusione.
- Ripeti il passo 2 senza lo scalatore e scrivi due righe su quale modello cambia e quale no, e perché.
Il secondo passo è quello in cui si sbaglia più spesso, perché è facile calcolare la validazione sull'intero dataset invece che sul solo training: ricorda che il test è fuori dal gioco fino al terzo passo. Prova a farlo da solo prima di leggere la soluzione commentata qui sotto.
La soluzione è un solo programma, autonomo: separazione, confronto fra modelli con validazione incrociata dentro il training, scelta e un'unica valutazione finale sul test. Per ciascun modello cross_val_score calcola l'accuratezza media su 5 gruppi del solo training, due volte: dentro una pipeline con lo scalatore e senza. Il dizionario modelli contiene tre stimatori non ancora addestrati, che cross_val_score clona a ogni gruppo.
import warnings
import numpy as np
from sklearn.datasets import load_wine
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
warnings.filterwarnings("ignore")
X, y = load_wine(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
modelli = {
"regressione logistica": LogisticRegression(max_iter=5000),
"k vicini": KNeighborsClassifier(),
"albero (profondità 3)": DecisionTreeClassifier(max_depth=3, random_state=0),
}
print(f"{'modello':<24} {'con scalatore':>14} {'senza scalatore':>16}")
risultati = {}
for nome, modello in modelli.items():
con = cross_val_score(make_pipeline(StandardScaler(), modello), X_tr, y_tr, cv=cv).mean()
senza = cross_val_score(modello, X_tr, y_tr, cv=cv).mean()
risultati[nome] = (con, senza)
print(f"{nome:<24} {con:>14.3f} {senza:>16.3f}")
migliore = max(risultati, key=lambda k: risultati[k][0])
finale = make_pipeline(StandardScaler(), modelli[migliore]).fit(X_tr, y_tr)
print("\nmodello scelto sulla validazione:", migliore)
print("accuratezza sul test (aperto una volta):", round(finale.score(X_te, y_te), 3))
print("matrice di confusione (righe = classe vera):\n", confusion_matrix(y_te, finale.predict(X_te)))
Uscita ottenuta (Python 3.14.4, NumPy 2.3.5, pandas 3.0.3, scikit-learn 1.8.0; eseguito due volte con uscita identica):
modello con scalatore senza scalatore
regressione logistica 0.963 0.962
k vicini 0.955 0.713
albero (profondità 3) 0.910 0.917
modello scelto sulla validazione: regressione logistica
accuratezza sul test (aperto una volta): 1.0
matrice di confusione (righe = classe vera):
[[15 0 0]
[ 0 18 0]
[ 0 0 12]]
Il confronto mostra l'effetto della scala sui tre algoritmi. I k vicini ne traggono un vantaggio enorme (da 0.713 a 0.955), perché usano distanze. La regressione logistica e l'albero restano praticamente uguali (0.963 contro 0.962 e 0.910 contro 0.917). Per l'albero è atteso: le soglie sui valori non dipendono dalla scala. Per la regressione logistica non ho verificato la causa; una spiegazione plausibile è che, con molte iterazioni consentite, il modello compensi la scala con coefficienti di ampiezza diversa, ma la regolarizzazione li penalizza, quindi la differenza non è garantita su altri dati. Si sceglie poi il modello con la migliore validazione con scalatore (la regressione logistica), lo si riaddestra su tutto il training e si guarda il test una sola volta. Il risultato è 1.0: tutti i 45 esempi del test classificati correttamente, e la matrice di confusione è diagonale.
Attenzione a come si legge quel 100%: con soli 45 esempi significa che questi 45 casi sono andati bene, non che il modello sia perfetto, e l'incertezza su un'accuratezza stimata da 45 esempi è ampia. La stima di validazione (circa 0.96) è più informativa di un singolo test così piccolo. Se, guardando il test, ti venisse la tentazione di cambiare modello, ricorda la regola: quel test è già stato usato, e il numero che otterresti dopo il secondo tentativo non sarebbe più una stima onesta.
Sfide
- Easy. Calcola la baseline
DummyClassifier(strategy="stratified")e spiega perché il suo valore atteso, con classi 63%/37%, è circa0.63² + 0.37² ≈ 0.53e non 0.63. - Medium. Nell'esperimento della fuga, varia
k(numero di caratteristiche scelte) in {5, 20, 100} en(esempi) in {50, 200, 1000}. Con quale combinazione l'effetto della fuga sparisce? Perché? - Hard. Implementa a mano uno scalatore compatibile con l'API (
fit,transform, attributi con_) ereditando daBaseEstimatoreTransformerMixin, e verifica che dentro unaPipelinedia gli stessi risultati diStandardScaler. - Expert. Realizza una validazione annidata (un
GridSearchCVdentro uncross_val_score) e confronta il punteggio con quello del miglior punteggio interno. Quanto è ottimistico il secondo? - Enterprise. Descrivi per un caso reale della tua organizzazione qual è l'unità statistica (cliente, ordine, macchina), quali esempi potrebbero «contaminarsi» fra training e test, e come costruiresti la divisione per evitarlo.
Le prime tre sfide si risolvono con quanto visto nella lezione; la quarta richiede di leggere la documentazione di cross_val_score e GridSearchCV. Nella sfida Medium ti conviene tenere fisso il seme del generatore e cambiare un parametro per volta, altrimenti non sai a che cosa attribuire la differenza.
Nella prossima lezione, Insiemi e logica, inizia il modulo di matematica: il vocabolario con cui si scrive la probabilità.
Fonti
- scikit-learn, Common pitfalls and recommended practices - definizione di data leakage, regola «mai fit sui dati di test», esempio con selezione di caratteristiche su dati casuali; consultata il 2026-11-12
- scikit-learn, Developing scikit-learn estimators - attributi appresi con underscore finale,
fitche restituisceself; consultata il 2026-11-12 - scikit-learn, Cross-validation: evaluating estimator performance - GroupKFold, TimeSeriesSplit, rimando alla validazione annidata; consultata il 2026-11-12
- scikit-learn, GridSearchCV -
refit=Truepredefinito; consultata il 2026-11-12 - scikit-learn, ColumnTransformer -
remainder='drop'predefinito; consultata il 2026-11-12 - scikit-learn, DummyClassifier - strategie, predefinita
prior; consultata il 2026-11-12 - scikit-learn, Model persistence - pickle e joblib possono eseguire codice arbitrario al caricamento; consultata il 2026-11-12
- L. Buitinck e coll., API design for machine learning software: experiences from the scikit-learn project - pagina del lavoro (arXiv:1309.0238), letto titolo e autori; consultata il 2026-11-12
Riferimenti bibliografici citati ma non aperti per questa revisione: T. Hastie, R. Tibshirani, J. Friedman, The Elements of Statistical Learning, 2ª ed., Springer, 2009, sez. 7.10 (il «modo sbagliato e giusto» di fare la validazione incrociata); S. Kaufman e coll., Leakage in Data Mining: Formulation, Detection, and Avoidance, ACM TKDD 6(4), 2012; M. Stone, Cross-Validatory Choice and Assessment of Statistical Predictions, JRSS B 36(2), 1974; F. Pedregosa e coll., Scikit-learn: Machine Learning in Python, JMLR 12, 2011.
Commenti
Caricamento commenti...
Accedi per commentare