Un modello che risponde sempre «nessuna frode» ha un'accuratezza del 97,4% su un insieme di transazioni in cui il 2,6% sono frodi, e non scopre mai una frode. Il numero che hai guardato era giusto e non ti diceva niente. Valutare un modello vuol dire scegliere quale domanda gli fai: quanti errori fa in tutto, quante volte ha ragione quando dice «positivo», quanti positivi riesce a trovare, quanto sono affidabili le probabilità che dichiara, di quanto sbaglia in euro o in gradi.
Ogni domanda ha la sua metrica e ciascuna può ingannare in un modo suo. Questo articolo, l'undicesimo della serie ML Fundamentals, calcola le metriche principali a mano e con scikit-learn, mostra dove ciascuna mente con un esperimento e ti dice quanto fidarti di un singolo numero.
Le metriche si usano su un test set che il modello non ha visto, come hai fatto con la cross-validation e con i modelli della serie, dalla regressione logistica in poi. I dati sono sintetici con seme fisso, dichiarati caso per caso, e le cifre di costo sono ipotetiche e dette tali: non ci sono set di dati reali scaricati. I principi sul processo (split, leakage) sono nell'articolo della serie Machine Learning Engineer su split, data leakage e metriche che mentono.
La matrice di confusione e perché l'accuratezza inganna
La matrice di confusione conta i quattro esiti di un classificatore binario: i veri positivi (TP, hai detto sì ed era sì), i falsi positivi (FP, hai detto sì ed era no), i falsi negativi (FN, hai detto no ed era sì) e i veri negativi (TN). Tutte le metriche di classificazione sono rapporti fra questi quattro numeri.
L'accuratezza è $(TP + TN)/N$; la precision è $TP/(TP + FP)$, cioè la frazione di allarmi veri; la recall è $TP/(TP + FN)$, la frazione di positivi trovati; la F1 è la loro media armonica $2PR/(P + R)$, che è bassa se una delle due è bassa. L'accuratezza bilanciata è la media della recall su ciascuna classe.
Il listato genera 20.000 esempi con 10 feature (circa il 2,6% positivi), tiene metà per il test e confronta due modelli: un classificatore che risponde sempre «no» (DummyClassifier con strategia most_frequent) e una regressione logistica. Poi ricalcola le quattro metriche dalla matrice di confusione della logistica, con le formule appena scritte, per controllare che i conti tornino.
# Classi sbilanciate (circa 2,6% di positivi): chi risponde sempre "no" ha accuratezza 97,4%
import numpy as np
from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, balanced_accuracy_score, confusion_matrix,
f1_score, precision_score, recall_score)
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=20000, n_features=10, n_informative=5, weights=[0.98, 0.02],
flip_y=0.01, class_sep=0.8, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=0, stratify=y)
print("positivi nel test:", int(yte.sum()), "su", len(yte))
modelli = {"sempre 'no'": DummyClassifier(strategy="most_frequent"),
"regressione logistica": LogisticRegression(max_iter=1000)}
for nome, m in modelli.items():
p = m.fit(Xtr, ytr).predict(Xte)
tn, fp, fn, tp = confusion_matrix(yte, p).ravel()
print(f"\n{nome}: TN={tn} FP={fp} FN={fn} TP={tp}")
print(f" accuratezza {accuracy_score(yte, p):.4f} accuratezza bilanciata {balanced_accuracy_score(yte, p):.4f}")
print(f" precision {precision_score(yte, p, zero_division=0):.4f} recall {recall_score(yte, p):.4f} F1 {f1_score(yte, p):.4f}")
# Le stesse metriche ricalcolate a mano dalla matrice di confusione della regressione logistica
precisione = tp / (tp + fp); richiamo = tp / (tp + fn)
f1 = 2 * precisione * richiamo / (precisione + richiamo)
acc = (tp + tn) / (tp + tn + fp + fn)
print("\na mano: accuratezza %.4f precision %.4f recall %.4f F1 %.4f" % (acc, precisione, richiamo, f1))
print("scarto massimo dai valori di sklearn:", max(abs(acc - accuracy_score(yte, p)), abs(precisione - precision_score(yte, p)),
abs(richiamo - recall_score(yte, p)), abs(f1 - f1_score(yte, p))))
# Output:
# positivi nel test: 256 su 10000
#
# sempre 'no': TN=9744 FP=0 FN=256 TP=0
# accuratezza 0.9744 accuratezza bilanciata 0.5000
# precision 0.0000 recall 0.0000 F1 0.0000
#
# regressione logistica: TN=9742 FP=2 FN=179 TP=77
# accuratezza 0.9819 accuratezza bilanciata 0.6503
# precision 0.9747 recall 0.3008 F1 0.4597
#
# a mano: accuratezza 0.9819 precision 0.9747 recall 0.3008 F1 0.4597
# scarto massimo dai valori di sklearn: 5.551115123125783e-17
Su 10.000 esempi di test i positivi sono 256. Chi risponde sempre «no» ha accuratezza 0,9744, ma recall zero, F1 zero e accuratezza bilanciata 0,5, il valore del caso. La regressione logistica ha accuratezza 0,9819 e sembra poco migliore: solo 0,75 punti più alta.
La precision, però, è 0,9747 (su 79 allarmi, 77 veri) e la recall 0,3008: trova 77 positivi su 256 e ne perde 179. Questa è la domanda vera, e l'accuratezza non la vedeva. Le formule a mano coincidono con scikit-learn (scarto di 6e-17), quindi puoi calcolare tutto da una matrice di confusione stampata su un foglio.
ROC, AUC e precision-recall: che cosa cambia con la prevalenza
Un classificatore con probabilità produce un punteggio, e la decisione dipende da una soglia. La curva ROC mostra, al variare della soglia, la recall (frazione di positivi trovati) contro il tasso di falsi positivi (frazione di negativi scambiati per positivi); l'AUC è l'area sotto la curva, e vale la probabilità che un positivo scelto a caso abbia punteggio più alto di un negativo scelto a caso.
La curva precision-recall mostra la precision contro la recall, e la sua sintesi è l'average precision (AP). Saito e Rehmsmeier (PLOS ONE, 2015) sostengono che la ROC può ingannare sui dati sbilanciati per un'interpretazione sbagliata della specificità, mentre la precision-recall valuta la frazione di veri positivi fra le predizioni positive.
Il listato lo verifica senza modelli: genera punteggi per i positivi da una normale con media 1,5 e per i negativi da una normale con media 0 (varianza 1), e cambia solo la proporzione di positivi, da 50% a 0,1%, con 400.000 esempi in tutto. Il «classificatore» è lo stesso a ogni riga: la separazione fra le due popolazioni non cambia.
# Stesso classificatore, prevalenza diversa: l'AUC non si muove, la precisione si'
import numpy as np
from sklearn.metrics import average_precision_score, precision_recall_curve, roc_auc_score
rng = np.random.default_rng(0)
N = 400_000
print(f"{'prevalenza':>11s} {'positivi':>9s} {'ROC-AUC':>8s} {'AP':>7s} {'AP casuale':>11s} {'precision con recall>=0.8':>26s}")
for prev in (0.5, 0.1, 0.01, 0.001):
n_pos = int(N * prev)
s = np.r_[rng.normal(1.5, 1.0, n_pos), rng.normal(0.0, 1.0, N - n_pos)] # punteggi: positivi e negativi
y = np.r_[np.ones(n_pos), np.zeros(N - n_pos)]
P, R, _ = precision_recall_curve(y, s)
prec_80 = P[R >= 0.8].max()
print(f"{prev:11.3f} {n_pos:9d} {roc_auc_score(y, s):8.3f} {average_precision_score(y, s):7.3f} {prev:11.3f} {prec_80:26.3f}")
# Output:
# prevalenza positivi ROC-AUC AP AP casuale precision con recall>=0.8
# 0.500 200000 0.855 0.854 0.500 0.757
# 0.100 40000 0.856 0.480 0.100 0.259
# 0.010 4000 0.853 0.111 0.010 0.031
# 0.001 400 0.863 0.017 0.001 0.003
L'AUC resta a 0,85-0,86 in tutte le righe: dipende dai punteggi dei positivi contro quelli dei negativi, non da quanti sono. La average precision crolla da 0,854 a 0,017, e la precision che ottieni se vuoi trovare l'80% dei positivi passa da 0,757 a 0,003: con una frode ogni mille transazioni, quasi tutti gli allarmi di un sistema che trovasse l'80% delle frodi sarebbero falsi.
Il baseline di una classificazione a caso è la prevalenza (la colonna «AP casuale»): 0,017 è 17 volte il caso, e va letto in quel modo. Quindi l'AUC dice quanto il modello ordina bene; la AP, o la precision a una recall fissata, dice quanto è utile quando i positivi sono rari. Riporta entrambe, e riporta la prevalenza.
La soglia, il costo e l'F-beta
La soglia 0,5 è una convenzione, e va bene solo se i due errori costano uguale e le classi sono equilibrate. Una soglia più bassa trova più positivi e produce più falsi allarmi. Se mancare un positivo costa molto più di un falso allarme, la soglia giusta è più bassa, e la sceglie il costo, non il modello.
La F-beta generalizza la F1 con un peso: $F_\beta = (1+\beta^2)PR/(\beta^2 P + R)$; con $\beta = 2$ conta la recall quattro volte più della precision nel denominatore, con $\beta = 0{,}5$ la precision.
Il listato usa gli stessi dati di prima e divide il training in due parti: una per addestrare la regressione logistica e una di validazione per scegliere la soglia, che poi viene valutata sul test mai toccato. Il costo è ipotetico, dichiarato nel codice: mancare un positivo costa 50 unità, un falso allarme 1. Confronta quattro soglie: 0,5, quella che massimizza la F1 sulla validazione, quella che massimizza la F2, e quella che minimizza il costo.
# La soglia 0.5 non e' sacra: scelta della soglia con una funzione di costo, e F-beta calcolato a mano
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, f1_score, fbeta_score
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=20000, n_features=10, n_informative=5, weights=[0.98, 0.02],
flip_y=0.01, class_sep=0.8, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=0, stratify=y)
Xfit, Xval, yfit, yval = train_test_split(Xtr, ytr, test_size=0.5, random_state=1, stratify=ytr) # validazione per la soglia
m = LogisticRegression(max_iter=1000).fit(Xfit, yfit)
p_val, p_te = m.predict_proba(Xval)[:, 1], m.predict_proba(Xte)[:, 1]
C_FN, C_FP = 50.0, 1.0 # costi IPOTETICI: mancare un positivo costa 50 volte un falso allarme
def costo(y, p, t):
tn, fp, fn, tp = confusion_matrix(y, p >= t).ravel()
return C_FN * fn + C_FP * fp
soglie = np.linspace(0.005, 0.95, 190)
t_costo = soglie[np.argmin([costo(yval, p_val, t) for t in soglie])]
t_f1 = soglie[np.argmax([f1_score(yval, p_val >= t) for t in soglie])]
t_f2 = soglie[np.argmax([fbeta_score(yval, p_val >= t, beta=2) for t in soglie])]
print(f"soglie scelte sulla validazione: costo minimo -> {t_costo:.3f}, F1 massimo -> {t_f1:.3f}, F2 massimo -> {t_f2:.3f}")
print(f"\n{'soglia':>18s} {'TP':>4s} {'FP':>5s} {'FN':>4s} {'costo test':>11s} {'F1':>6s} {'F2':>6s}")
for nome, t in [("0.5 (default)", 0.5), ("F1 massimo", t_f1), ("F2 massimo", t_f2), ("costo minimo", t_costo)]:
tn, fp, fn, tp = confusion_matrix(yte, p_te >= t).ravel()
print(f"{nome:>18s} {tp:4d} {fp:5d} {fn:4d} {costo(yte, p_te, t):11.0f} {f1_score(yte, p_te >= t):6.3f} {fbeta_score(yte, p_te >= t, beta=2):6.3f}")
# F-beta a mano: (1 + b^2) P R / (b^2 P + R)
tn, fp, fn, tp = confusion_matrix(yte, p_te >= t_costo).ravel()
P, R = tp / (tp + fp), tp / (tp + fn)
for b in (0.5, 1, 2):
print(f"F{b}: a mano {(1 + b * b) * P * R / (b * b * P + R):.4f} sklearn {fbeta_score(yte, p_te >= t_costo, beta=b):.4f}")
# Output:
# soglie scelte sulla validazione: costo minimo -> 0.055, F1 massimo -> 0.225, F2 massimo -> 0.135
#
# soglia TP FP FN costo test F1 F2
# 0.5 (default) 83 2 173 8652 0.487 0.374
# F1 massimo 123 47 133 6697 0.577 0.515
# F2 massimo 136 142 120 6142 0.509 0.522
# costo minimo 167 631 89 5081 0.317 0.458
# F0.5: a mano 0.2422 sklearn 0.2422
# F1: a mano 0.3169 sklearn 0.3169
# F2: a mano 0.4583 sklearn 0.4583
La soglia che minimizza il costo sulla validazione è 0,055, quella della F1 0,225 e quella della F2 0,135. Sul test, con la soglia 0,5 il costo è 8.652 (2 falsi allarmi e 173 positivi persi); con la soglia di costo minimo scende a 5.081, il 41% in meno, con 167 positivi trovati ma 631 falsi allarmi.
In quella riga la F1 è la più bassa della tabella (0,317) e la F2 non è la più alta (0,458 contro 0,522): ogni soglia è ottima per la sua metrica e peggiore per le altre. Le tre F-beta calcolate a mano coincidono con scikit-learn (0,2422, 0,3169, 0,4583). La domanda che decide non è «quale metrica è migliore» ma «quanto costa ciascun errore». Se non lo sai, le metriche non lo sanno al posto tuo.
Probabilità calibrate
Un modello è calibrato se, fra le volte in cui dichiara 0,7, il positivo accade circa il 70% delle volte. Conta quando usi la probabilità e non solo la classe (rischio, prezzo, priorità). Il Brier score è l'errore quadratico medio fra la probabilità e l'esito 0/1; la documentazione di scikit-learn avverte che mescola calibrazione e capacità di discriminare, quindi un Brier più basso non significa per forza un modello meglio calibrato.
L'ECE (Expected Calibration Error) divide le predizioni in intervalli di uguale ampiezza (Guo e colleghi usano 15) e calcola la media, pesata dal numero di predizioni, della differenza in valore assoluto fra frazione di positivi e probabilità media di ogni intervallo.
Il listato confronta tre modelli sullo stesso test: regressione logistica, foresta casuale da 100 alberi, e la stessa foresta con calibrazione isotonica (CalibratedClassifierCV, method="isotonic", 3 fold). Poi stampa una tabella di affidabilità a cinque intervalli per la foresta non calibrata, per vedere dove sbaglia.
# Calibrazione: se il modello dice 0.8, e' vero l'80% delle volte? Brier score, ECE (15 intervalli) e tabella di affidabilita'
import numpy as np
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, log_loss
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=12000, n_features=10, n_informative=5, flip_y=0.1, class_sep=1.0, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.4, random_state=0, stratify=y)
def ece(y, p, bins=15):
"""Expected Calibration Error: media pesata di |frazione di positivi - probabilita' media| per intervallo."""
idx = np.minimum((p * bins).astype(int), bins - 1)
tot = 0.0
for b in range(bins):
m = idx == b
if m.any():
tot += m.mean() * abs(y[m].mean() - p[m].mean())
return tot
modelli = {
"regressione logistica": LogisticRegression(max_iter=1000),
"foresta casuale (100 alberi)": RandomForestClassifier(n_estimators=100, random_state=0, n_jobs=1),
"foresta + calibrazione isotonica": CalibratedClassifierCV(RandomForestClassifier(n_estimators=100, random_state=0, n_jobs=1),
method="isotonic", cv=3),
}
print(f"{'modello':34s} {'Brier':>7s} {'log-loss':>9s} {'ECE':>7s} {'accuratezza':>12s}")
prob = {}
for nome, m in modelli.items():
p = m.fit(Xtr, ytr).predict_proba(Xte)[:, 1]
prob[nome] = p
print(f"{nome:34s} {brier_score_loss(yte, p):7.4f} {log_loss(yte, p):9.4f} {ece(yte, p):7.4f} {((p > 0.5) == yte).mean():12.4f}")
print("\ntabella di affidabilita' della foresta (non calibrata): probabilita' media dichiarata contro frazione reale di positivi")
frac, media = calibration_curve(yte, prob["foresta casuale (100 alberi)"], n_bins=5)
for f, m_ in zip(frac, media):
print(f" dichiarata {m_:.3f} -> reale {f:.3f}")
# Output:
# modello Brier log-loss ECE accuratezza
# regressione logistica 0.1349 0.4361 0.0389 0.8187
# foresta casuale (100 alberi) 0.0967 0.3577 0.0384 0.8806
# foresta + calibrazione isotonica 0.0944 0.3265 0.0182 0.8792
#
# tabella di affidabilita' della foresta (non calibrata): probabilita' media dichiarata contro frazione reale di positivi
# dichiarata 0.086 -> reale 0.078
# dichiarata 0.289 -> reale 0.192
# dichiarata 0.501 -> reale 0.530
# dichiarata 0.716 -> reale 0.779
# dichiarata 0.930 -> reale 0.933
La foresta ha un Brier migliore della logistica (0,0967 contro 0,1349) e una accuratezza maggiore (0,881 contro 0,819), ma la tabella di affidabilità mostra dove le sue probabilità non tornano: quando dichiara 0,289 il positivo accade il 19,2% delle volte. L'ECE a 15 intervalli però è quasi identico per i due modelli (0,0384 e 0,0389): il numero di sintesi non dice dove sbaglia ciascuno né in quale verso, quindi serve anche la tabella.
La calibrazione isotonica dimezza l'ECE (0,0182) e migliora il log-loss da 0,3577 a 0,3265, e il Brier da 0,0967 a 0,0944 (2,4% in meno), senza peggiorare l'accuratezza in modo apprezzabile (0,879 contro 0,881). Il miglioramento sul Brier è piccolo: la calibrazione non cambia l'ordinamento molto, corregge la scala.
Più classi: micro, macro e weighted
Con più di due classi la F1 si calcola per ciascuna classe (la classe contro tutte le altre) e poi si media in tre modi. La media macro è la media semplice delle classi, quindi ogni classe pesa uguale; la weighted pesa ogni classe per il numero di esempi; la micro somma TP, FP e FN di tutte le classi e calcola la F1 una volta sola.
Il listato genera 3 classi con proporzioni 80%, 15% e 5%, addestra una regressione logistica e stampa precision, recall e F1 per classe e le tre medie, ricalcolando macro e weighted a mano.
# Tre classi sbilanciate (80% / 15% / 5%): micro, macro e weighted raccontano storie diverse
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, precision_recall_fscore_support
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=20000, n_features=10, n_informative=4, n_redundant=0, n_classes=3,
n_clusters_per_class=1, weights=[0.8, 0.15, 0.05], class_sep=0.9, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=0, stratify=y)
p = LogisticRegression(max_iter=2000).fit(Xtr, ytr).predict(Xte)
P, R, F, S = precision_recall_fscore_support(yte, p)
print("classe precision recall F1 supporto")
for k in range(3):
print(f"{k:6d} {P[k]:9.3f} {R[k]:7.3f} {F[k]:6.3f} {S[k]:9d}")
print(f"\nF1 macro (media semplice delle 3 classi): {f1_score(yte, p, average='macro'):.4f} a mano {F.mean():.4f}")
print(f"F1 weighted (media pesata dal supporto): {f1_score(yte, p, average='weighted'):.4f} a mano {(F * S).sum() / S.sum():.4f}")
print(f"F1 micro (conta TP, FP, FN di tutte le classi): {f1_score(yte, p, average='micro'):.4f}")
print(f"accuratezza: {accuracy_score(yte, p):.4f} -> micro-F1 = accuratezza in classificazione a una etichetta: {np.isclose(f1_score(yte, p, average='micro'), accuracy_score(yte, p))}")
# Output:
# classe precision recall F1 supporto
# 0 0.976 0.997 0.986 7953
# 1 0.977 0.974 0.976 1517
# 2 0.939 0.636 0.758 530
#
# F1 macro (media semplice delle 3 classi): 0.9067 a mano 0.9067
# F1 weighted (media pesata dal supporto): 0.9725 a mano 0.9725
# F1 micro (conta TP, FP, FN di tutte le classi): 0.9745
# accuratezza: 0.9745 -> micro-F1 = accuratezza in classificazione a una etichetta: True
La classe rara, la 2, ha precision 0,939 ma recall 0,636 e F1 0,758: il modello la perde una volta su tre. Le tre medie raccontano storie diverse: la weighted dà 0,9725, dominata dalle due classi grandi; la micro 0,9745, che coincide con l'accuratezza (è sempre così quando ogni esempio ha una sola etichetta); la macro 0,9067, la più bassa, perché la classe piccola pesa un terzo.
Se ti importa delle classi rare, la macro e la recall per classe sono le metriche che la vedono; se ti importa della frazione di casi giusti, l'accuratezza.
Regressione: RMSE, MAE e R²
Per un valore numerico l'errore di ogni predizione è il residuo $e_i = y_i - \hat{y}_i$. L'MAE è la media di $|e_i|$: nell'unità del bersaglio, ogni errore pesa in proporzione. L'RMSE è $\sqrt{\text{media}(e_i^2)}$, anche nell'unità del bersaglio ma con i quadrati sotto radice: gli errori grandi pesano più dei piccoli.
L'R² è $1 - \sum e_i^2 / \sum (y_i - \overline{y})^2$, cioè la frazione di varianza spiegata rispetto a un modello che predice la media. Il listato usa il dataset del diabete (442 pazienti, bersaglio: indice di progressione della malattia), confronta una regressione lineare con il modello che predice sempre la media del training, ricalcola R² a mano e poi cambia un solo valore del bersaglio di test aggiungendo 400.
# Regressione: RMSE, MAE e R^2 sul dataset diabete, e che cosa fa un solo errore enorme
import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
X, y = load_diabetes(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=0)
p = LinearRegression().fit(Xtr, ytr).predict(Xte)
base = np.full_like(yte, ytr.mean()) # modello che predice sempre la media del training
def riga(nome, y_vero, y_pred):
rmse = np.sqrt(mean_squared_error(y_vero, y_pred)); mae = mean_absolute_error(y_vero, y_pred)
print(f"{nome:34s} RMSE {rmse:7.2f} MAE {mae:7.2f} R2 {r2_score(y_vero, y_pred):7.3f}")
print("deviazione standard del bersaglio sul test: %.2f" % yte.std())
riga("predice sempre la media", yte, base)
riga("regressione lineare", yte, p)
# R^2 a mano: 1 - somma dei quadrati dei residui / somma dei quadrati attorno alla media del test
r2 = 1 - ((yte - p) ** 2).sum() / ((yte - yte.mean()) ** 2).sum()
print("R2 a mano: %.3f" % r2)
# Un solo errore grande: aggiungo 400 al bersaglio di un solo campione del test
y_sporco = yte.copy(); y_sporco[0] += 400
riga("lineare, un bersaglio sbagliato +400", y_sporco, p)
# Output:
# deviazione standard del bersaglio sul test: 71.42
# predice sempre la media RMSE 71.42 MAE 59.53 R2 -0.000
# regressione lineare RMSE 55.65 MAE 44.62 R2 0.393
# R2 a mano: 0.393
# lineare, un bersaglio sbagliato +400 RMSE 69.20 MAE 47.63 R2 0.345
Il modello che predice la media ha RMSE 71,42 (la deviazione standard del bersaglio) e R² praticamente nullo; la regressione lineare scende a RMSE 55,65, MAE 44,62 e R² 0,393. L'R² a mano coincide. Il campione sporco cambia le due metriche in modo diverso: l'RMSE passa da 55,65 a 69,20 (+24%), l'MAE da 44,62 a 47,63 (+7%).
Un errore enorme su 133 campioni sposta l'RMSE del 24% e l'MAE del 7%. Se gli errori grandi sono davvero più gravi, l'RMSE è la metrica giusta; se sono rumore nei dati, l'MAE è più stabile. R² non ha un valore «buono» in assoluto: 0,393 qui può essere ottimo o scarso, secondo il problema.
Quanto fidarsi di un numero: l'intervallo bootstrap
Ogni metrica calcolata su un test set è una stima, e cambierebbe se il test set fosse un altro. Il bootstrap la riproduce al computer: ricampiona con reinserimento il test set (stessa dimensione), ricalcola la metrica, ripete 500 volte, e prende i percentili 2,5 e 97,5 come intervallo al 95%.
Il listato estrae test set di 200, 1.000, 5.000 e 20.000 righe da un pool, addestra una regressione logistica e una foresta casuale da 60 alberi, e per ciascun test set stampa l'AUC dei due modelli con il suo intervallo e l'intervallo bootstrap della differenza fra i due, ricampionando le stesse righe per entrambi.
# Quanto e' affidabile un AUC? Intervallo bootstrap al 95% al crescere del test set, e confronto fra due modelli
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=30000, n_features=10, n_informative=5, flip_y=0.1, class_sep=1.0, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=20000, random_state=0, stratify=y)
lr = LogisticRegression(max_iter=1000).fit(Xtr, ytr)
rf = RandomForestClassifier(n_estimators=60, random_state=0, n_jobs=1).fit(Xtr, ytr)
p_lr, p_rf = lr.predict_proba(Xte)[:, 1], rf.predict_proba(Xte)[:, 1]
rng = np.random.default_rng(0)
def bootstrap(n, B=500):
"""Estrae un test set di n righe e ricampiona con reinserimento B volte."""
idx0 = rng.choice(len(yte), n, replace=False)
a, b, t = p_lr[idx0], p_rf[idx0], yte[idx0]
auc_lr, auc_rf = [], []
for _ in range(B):
i = rng.integers(0, n, n)
if t[i].min() == t[i].max():
continue
auc_lr.append(roc_auc_score(t[i], a[i])); auc_rf.append(roc_auc_score(t[i], b[i]))
auc_lr, auc_rf = np.array(auc_lr), np.array(auc_rf)
return roc_auc_score(t, a), np.percentile(auc_lr, [2.5, 97.5]), roc_auc_score(t, b), np.percentile(auc_rf, [2.5, 97.5]), np.percentile(auc_rf - auc_lr, [2.5, 97.5])
print(f"{'n test':>7s} {'AUC logistica [95% bootstrap]':>32s} {'AUC foresta [95% bootstrap]':>30s} {'differenza foresta - logistica [95%]':>38s}")
for n in (200, 1000, 5000, 20000):
a, ca, b, cb, cd = bootstrap(n)
print(f"{n:7d} {a:.3f} [{ca[0]:.3f}, {ca[1]:.3f}] (largh. {ca[1]-ca[0]:.3f}) {b:.3f} [{cb[0]:.3f}, {cb[1]:.3f}] [{cd[0]:+.3f}, {cd[1]:+.3f}]")
# Output:
# n test AUC logistica [95% bootstrap] AUC foresta [95% bootstrap] differenza foresta - logistica [95%]
# 200 0.893 [0.841, 0.942] (largh. 0.101) 0.922 [0.877, 0.962] [+0.004, +0.058]
# 1000 0.933 [0.914, 0.947] (largh. 0.033) 0.955 [0.940, 0.967] [+0.011, +0.034]
# 5000 0.915 [0.906, 0.923] (largh. 0.017) 0.938 [0.930, 0.945] [+0.018, +0.029]
# 20000 0.915 [0.910, 0.919] (largh. 0.009) 0.939 [0.935, 0.942] [+0.021, +0.027]
L'intervallo dell'AUC della logistica si restringe da 0,101 di ampiezza con 200 righe a 0,009 con 20.000, circa con la radice del numero di campioni (4 volte i campioni, circa la metà dell'ampiezza). Con 200 righe gli intervalli dei due AUC (0,893 e 0,922) si sovrappongono, ma l'intervallo della differenza calcolata sulle stesse righe, da +0,004 a +0,058, esclude lo zero di poco: guardare i due intervalli separatamente direbbe «non distinguibili», il confronto appaiato dice che la foresta è meglio, di poco.
Con 5.000 righe l'intervallo della differenza si stringe a [+0,018; +0,029]. I test set di dimensione diversa sono estratti separatamente, perciò gli AUC puntuali non sono monotoni (0,893, 0,933, 0,915): è il motivo per cui un numero da solo non si legge. Una frase come «il modello B è meglio di A» ha bisogno almeno di un intervallo sulla differenza, calcolato sugli stessi esempi.
Esercizio svolto
Un classificatore, su 1.000 esempi, produce 40 veri positivi, 10 falsi positivi, 20 falsi negativi e 930 veri negativi. Calcola a mano accuratezza, precision, recall, specificità, F1 e F2, e l'accuratezza di chi risponde sempre «no». Che cosa ti dice il confronto?
# Esercizio: dalla matrice di confusione alle metriche
tp, fp, fn, tn = 40, 10, 20, 930
n = tp + fp + fn + tn
print("campioni:", n, " positivi veri:", tp + fn)
print("accuratezza:", (tp + tn) / n)
P, R = tp / (tp + fp), tp / (tp + fn)
print("precision:", round(P, 4), " recall:", round(R, 4), " specificita':", round(tn / (tn + fp), 4))
print("F1:", round(2 * P * R / (P + R), 4), " F2:", round(5 * P * R / (4 * P + R), 4))
print("accuratezza di chi risponde sempre 'no':", (tn + fp) / n)
# Output:
# campioni: 1000 positivi veri: 60
# accuratezza: 0.97
# precision: 0.8 recall: 0.6667 specificita': 0.9894
# F1: 0.7273 F2: 0.6897
# accuratezza di chi risponde sempre 'no': 0.94
Soluzione dell'esercizio
I positivi veri sono 40 + 20 = 60. L'accuratezza è (40 + 930)/1000 = 0,97; la precision è 40/50 = 0,8; la recall 40/60 = 0,667; la specificità 930/940 = 0,989. La F1 è 2 · 0,8 · 0,667/(0,8 + 0,667) = 0,727; la F2 è 5 · 0,8 · 0,667/(4 · 0,8 + 0,667) = 0,690.
Chi risponde sempre «no» ha accuratezza 940/1000 = 0,94: il modello guadagna tre punti di accuratezza, ma trova solo due positivi su tre e ne sbaglia uno ogni cinque che segnala. Il confronto giusto è con la recall e la precision, non con la sola accuratezza.
Che cosa sai fare adesso
Dopo questo articolo sai fare cinque cose, e puoi controllarle con un conto:
- leggere una matrice di confusione e ricavarne accuratezza, precision, recall, F1 e F-beta a mano;
- spiegare perché l'AUC non cambia con la prevalenza e la average precision sì, e quale riportare;
- scegliere una soglia con una funzione di costo dichiarata, su un insieme di validazione;
- misurare la calibrazione con Brier, ECE e tabella di affidabilità, e correggerla se serve;
- attaccare un intervallo bootstrap a ogni metrica che usi per confrontare due modelli.
Quello che le cinque voci hanno in comune è che nessuna metrica vale da sola: ciascuna risponde a una domanda e la scelta della domanda viene prima del modello. Il passo successivo è portare il modello fuori dal notebook e continuare a misurarlo dopo il rilascio: il deployment in produzione è l'ultimo articolo della serie. Se vuoi rivedere il modello che qui abbiamo valutato di più, la regressione logistica è l'articolo 3; per i modelli ad albero, alberi e foreste. Le stesse metriche servono altrove nella serie: alle SVM servono probabilità calibrate, il clustering si valuta con ARI e silhouette, la PCA si giudica con l'errore di ricostruzione e le reti neurali con la curva di apprendimento.
Domande frequenti
Con classi sbilanciate devo usare l'AUC o la precision-recall?
Riporta entrambe e la prevalenza. Nell'esperimento l'AUC è rimasta a 0,85-0,86 mentre la average precision è scesa da 0,854 a 0,017 al diminuire della prevalenza da 50% a 0,1%: l'AUC dice quanto il modello ordina bene, la precision-recall quanto sono utili gli allarmi quando i positivi sono rari.
Come scelgo la soglia di decisione?
A partire dal costo degli errori, non dalla metrica che preferisci: sceglila su un insieme di validazione, con costi dichiarati, e valutala sul test. Nell'esempio la soglia di costo minimo (0,055) ha ridotto il costo del 41% rispetto a 0,5, ma ha la F1 più bassa fra le quattro soglie provate.
Quanto deve essere grande un test set per fidarmi di un AUC?
Dipende dalla differenza che vuoi distinguere: nell'esperimento l'intervallo bootstrap al 95% dell'AUC è largo circa 0,10 con 200 righe, 0,03 con 1.000 e 0,009 con 20.000. Non esiste una soglia magica: calcola l'intervallo sul tuo test set e confrontalo con la differenza fra i modelli.
Nella stessa serie trovi anche: 01 - Introduzione al Machine Learning: tassonomia e workflow.
Fonti
- scikit-learn, User Guide: Metrics and scoring - metriche di classificazione, regressione e medie macro, micro e weighted, consultata il 2026-11-13 (versione 1.9)
- scikit-learn, User Guide: Probability calibration - curve di calibrazione, metodi sigmoid e isotonic, limiti del Brier score, consultata il 2026-11-13
- Saito, Rehmsmeier, The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets (PLOS ONE, 2015) - ROC e precision-recall su dati sbilanciati, letto l'abstract il 2026-11-13
- Guo, Pleiss, Sun, Weinberger, On Calibration of Modern Neural Networks (ICML 2017) - definizione dell'Expected Calibration Error con intervalli uguali e M = 15, letta la sezione 2 il 2026-11-13
I listati usano Python 3.12, scikit-learn 1.9.0 e numpy 2.4.6; ciascuno è stato eseguito due volte con uscita identica. I dati sono sintetici con seme fisso, tranne il dataset del diabete incluso in scikit-learn. I costi dell'esempio sulla soglia (50 e 1) sono ipotetici.
Commenti
Caricamento commenti...
Accedi per commentare