Salta al contenuto principale

11 - Metriche e valutazione modelli ML

Matrice di confusione, AUC e precision-recall, soglia scelta per costo, calibrazione e bootstrap: ogni metrica calcolata a mano e misurata su dati sintetici.

Cosa ne pensi di questo articolo?

11 - Metriche e valutazione modelli ML
ML Fundamentalsfedericocalo.dev

In Breve

Matrice di confusione, AUC e precision-recall, soglia scelta per costo, calibrazione e bootstrap: ogni metrica calcolata a mano e misurata su dati sintetici.

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

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.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti