Python, dalle fondamenta Lezione 51 / 60

Modelli ad albero: random forest, XGBoost, LightGBM

Perché gli alberi dominano il ML tabulare, le differenze tra le tre grandi librerie, e gli iperparametri che contano.

Se da questo modulo ti porti a casa una sola cosa pratica, sia questa: sui dati tabulari, nel 2026, i modelli ad albero vincono ancora. Non “vincono qualche volta, dipende dal dataset”. Vincono come default. Il paper del 2022 di Grinsztajn, Oyallon e Varoquaux, “Why do tree-based models still outperform deep learning on tabular data”, lo ha stabilito con rigore, e quattro anni dopo nulla lo ha sostituito. Le ragioni sono strutturali, ci arriveremo alla fine, ma la conseguenza è che quando vedi un CSV con righe e colonne e un target, la prima mossa giusta è un gradient-boosted tree.

Questa lezione copre le tre librerie che contano, XGBoost, LightGBM, CatBoost, più la random forest di scikit-learn come baseline solida. Faremo il fit dello stesso problema con tutte loro, parleremo degli iperparametri che davvero spostano l’ago della bilancia, e discuteremo quando ricorrere a quale.

Cos’è un albero, brevemente

Un decision tree pone una sequenza di domande sì/no sulle feature:

income > 50000?
├── sì: age > 30?
│   ├── sì: predici "approva"
│   └── no:  predici "nega"
└── no:  employment_years > 5?
    ├── sì: predici "approva"
    └── no:  predici "nega"

Tutto qui. Ogni nodo interno splitta su una feature e una soglia; ogni foglia dà una predizione. L’algoritmo di training sceglie ogni split per massimizzare un qualche criterio di purezza (Gini, entropia, riduzione del mean squared error), greedy, localmente ottimale, veloce.

Un singolo albero fa overfit. Allenalo abbastanza in profondità e memorizza il training set. Il rimedio sono gli ensemble: combinare molti alberi così che i loro errori individuali si annullino. Due sapori di ensemble dominano.

Bagging (random forest): cresci molti alberi, ognuno su un campione bootstrap dei dati e su un sottoinsieme casuale di feature a ogni split, poi media le loro predizioni. La casualità decorrela gli alberi, così la media riduce la varianza.

Boosting (gradient boosting): cresci alberi in sequenza, ognuno allenato per correggere gli errori dell’ensemble fino a quel momento. Più lento, più cauto, di solito più accurato.

In pratica, il gradient boosting batte la random forest sulla maggior parte dei problemi tabulari, ma la random forest è un ottimo default: è robusta, ha pochi iperparametri, e produce una baseline solida in tre righe di codice.

Random forest

Cominciamo dalla baseline. In scikit-learn:

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

rf = RandomForestClassifier(
    n_estimators=500,       # numero di alberi - di più è meglio, fino a un plateau
    max_depth=None,          # lascia gli alberi crescere completamente; il bagging gestisce l'overfit
    min_samples_leaf=2,      # regolarizzazione lieve
    n_jobs=-1,               # usa tutti i core CPU
    random_state=42,
)

scores = cross_val_score(rf, X, y, cv=5, scoring="roc_auc")
print(scores.mean())

La random forest perdona. Gli iperparametri di default ti danno l’80% dell’accuracy raggiungibile sulla maggior parte dei problemi. Aumenta n_estimators finché il tuo punteggio non plateaua, imposta min_samples_leaf a forse 2-5, e hai finito.

Gestisce anche i valori mancanti elegantemente se lasci che lo faccia HistGradientBoostingClassifier di scikit-learn, ma il RandomForestClassifier vanilla no: imputa prima.

Le librerie di boosting: XGBoost, LightGBM, CatBoost

Ora ai veri cavalli da tiro. Tutte e tre sono librerie di gradient boosting, tutte e tre seguono l’API .fit / .predict di scikit-learn, e sono grossomodo intercambiabili. Ognuna ha i propri punti di forza.

XGBoost (dal 2014) è stata la libreria che ha messo il gradient boosting sulla mappa. Ha dominato Kaggle per anni. Matura, GPU-friendly, tante manopole. Nel 2026 è alla versione 2.x, con un’API Python stabile e supporto categorico migliorato.

uv add xgboost
from xgboost import XGBClassifier

xgb = XGBClassifier(
    n_estimators=2000,
    learning_rate=0.05,
    max_depth=6,
    subsample=0.8,
    colsample_bytree=0.8,
    reg_lambda=1.0,
    eval_metric="auc",
    early_stopping_rounds=50,
    n_jobs=-1,
    random_state=42,
)

xgb.fit(
    X_train, y_train,
    eval_set=[(X_val, y_val)],
    verbose=False,
)

LightGBM (Microsoft, 2017) è più veloce di XGBoost sulla maggior parte dei problemi, in particolare con dataset grandi. Il suo punto di forza è la crescita leaf-wise invece che depth-wise: cresce l’albero espandendo ripetutamente la foglia con la maggior riduzione di loss, il che converge più velocemente ma è più incline all’overfitting su dataset piccoli a meno che tu non regolarizzi. Attualmente alla versione 4.x.

uv add lightgbm
from lightgbm import LGBMClassifier

lgbm = LGBMClassifier(
    n_estimators=2000,
    learning_rate=0.05,
    num_leaves=63,           # leaf-wise - questo è l'analogo di max_depth
    min_child_samples=20,
    subsample=0.8,
    colsample_bytree=0.8,
    reg_lambda=1.0,
    n_jobs=-1,
    random_state=42,
)

lgbm.fit(
    X_train, y_train,
    eval_set=[(X_val, y_val)],
    callbacks=[lgb.early_stopping(50)],
)

CatBoost (Yandex, 2017) gestisce le feature categoriche nativamente: le passi come stringhe e lui capisce un target encoding ordinato internamente che non leaka. Se il tuo dataset è pesante sulle categoriche, CatBoost è spesso la vittoria più facile perché salti un intero passo di encoding. È anche il più user-friendly per i principianti: default sensati, meno tuning necessario.

uv add catboost
from catboost import CatBoostClassifier

cat = CatBoostClassifier(
    iterations=2000,
    learning_rate=0.05,
    depth=6,
    l2_leaf_reg=3,
    cat_features=["country", "plan", "device"],   # digli quali colonne sono categoriche
    early_stopping_rounds=50,
    random_seed=42,
    verbose=False,
)

cat.fit(X_train, y_train, eval_set=(X_val, y_val))

Quale scegliere? In pratica, le tre sono entro un paio di punti percentuali l’una dall’altra sulla maggior parte dei problemi. I miei default: LightGBM per iterare velocemente, CatBoost quando i dati hanno molte categoriche, XGBoost quando il team la conosce già. Provane almeno due su qualsiasi problema serio e scegli quella che ottiene il punteggio migliore su un set tenuto da parte.

Gli iperparametri che contano

Il gradient boosting ha tante manopole. La maggior parte le puoi lasciare al default. Quelle che davvero spostano il punteggio:

n_estimators (o iterations in CatBoost). Numero di alberi nell’ensemble. Di più è meglio fino a un certo punto: oltre, inizi a memorizzare i dati di training. La risposta giusta è “il maggior numero che puoi usare prima che la validation loss smetta di migliorare”, che è quello di cui si occupa l’early stopping. Imposta questo a un numero grande come 2000-5000 e lascia che early_stopping_rounds ti tagli fuori.

learning_rate (anche eta). Quanto ogni nuovo albero corregge l’errore precedente. Un learning rate più piccolo più alberi di solito vince, ma richiede più tempo per il fit. 0,05 è un default sensato. 0,01 se hai tempo e vuoi l’ultimo 0,5% di accuracy. 0,1-0,3 per prototipazione veloce.

max_depth (XGBoost, CatBoost) o num_leaves (LightGBM). Quanto può essere complesso ogni singolo albero. Alberi più profondi adattano pattern più sfumati e fanno overfit più velocemente. 4-10 copre la maggior parte dei casi. num_leaves di LightGBM è grossomodo 2^max_depth, quindi num_leaves=63 è comparabile a max_depth=6.

min_child_weight (XGBoost) / min_samples_leaf (random forest) / min_child_samples (LightGBM). Numero minimo di campioni (o campioni pesati) richiesti per formare una foglia. Valori più alti significano più regolarizzazione. 20 è un default ragionevole per dataset di dimensioni medie.

subsample e colsample_bytree. Frazione di righe e colonne da campionare per ogni albero. Impostare questi sotto 1,0 (tipicamente 0,7-0,9) introduce stocasticità che agisce come un bagging in cima al boosting. Quasi sempre aiuta un po’.

reg_lambda / l2_leaf_reg. Regolarizzazione L2 sui pesi delle foglie. Il default va bene nella maggior parte dei casi; tunalo se vedi overfit.

La verità onesta: con default ragionevoli più early stopping, il gradient boosting ti porta al 90% dell’accuracy raggiungibile. Il restante 10% richiede tuning serio, idealmente con uno strumento come Optuna che fa ricerca bayesiana sullo spazio dei parametri.

import optuna
from lightgbm import LGBMClassifier
from sklearn.model_selection import cross_val_score

def objective(trial):
    params = {
        "n_estimators": 2000,
        "learning_rate": trial.suggest_float("lr", 0.01, 0.1, log=True),
        "num_leaves": trial.suggest_int("num_leaves", 16, 256),
        "min_child_samples": trial.suggest_int("min_child_samples", 5, 100),
        "subsample": trial.suggest_float("subsample", 0.6, 1.0),
        "colsample_bytree": trial.suggest_float("colsample_bytree", 0.6, 1.0),
        "reg_lambda": trial.suggest_float("reg_lambda", 1e-3, 10.0, log=True),
    }
    model = LGBMClassifier(**params, n_jobs=-1, random_state=42)
    return cross_val_score(model, X, y, cv=5, scoring="roc_auc").mean()

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=100)
print(study.best_params, study.best_value)

Quello è il pattern di produzione: pipeline più Optuna più early stopping più un test set tenuto da parte che guardi solo una volta.

Early stopping

Questo è il trucco che trasforma “scegli n_estimators con cura” in “scegli un numero grande e lascia che la libreria capisca da sola”. Passi un set di validation; la libreria traccia la validation loss a ogni round; se non migliora per early_stopping_rounds round consecutivi, l’allenamento si ferma e la migliore iterazione viene ripristinata.

xgb.fit(
    X_train, y_train,
    eval_set=[(X_val, y_val)],
    verbose=False,
)
# Dopo il fit, xgb.best_iteration ti dice dove si è fermato.

Il set di validation deve essere separato dal tuo test set. Il test set è per la valutazione finale; il set di validation è per scegliere gli iperparametri e fermarsi. Confonderli è un sapore di leakage.

Stesso problema, tre librerie, fianco a fianco

from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier

# Un dataset tabulare realistico
data = fetch_openml("adult", version=2, as_frame=True)
X, y = data.data, (data.target == ">50K").astype(int)
X = pd.get_dummies(X, drop_first=True)  # gestione categoriche veloce

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42,
)
X_tr, X_val, y_tr, y_val = train_test_split(
    X_train, y_train, test_size=0.2, stratify=y_train, random_state=42,
)

models = {
    "RandomForest": RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42),
    "XGBoost":      XGBClassifier(n_estimators=1000, learning_rate=0.05, max_depth=6,
                                  early_stopping_rounds=50, eval_metric="auc",
                                  n_jobs=-1, random_state=42),
    "LightGBM":     LGBMClassifier(n_estimators=1000, learning_rate=0.05, num_leaves=63,
                                   n_jobs=-1, random_state=42),
}

for name, m in models.items():
    if name == "RandomForest":
        m.fit(X_tr, y_tr)
    else:
        m.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)
    auc = roc_auc_score(y_test, m.predict_proba(X_test)[:, 1])
    print(f"{name:12s}  AUC = {auc:.4f}")

In una rapida esecuzione sul dataset adult, aspettati numeri nell’intervallo 0,91-0,93 di ROC AUC, con i booster che superano la forest di un punto percentuale o giù di lì. Su un problema reale di produzione, questo è il tuo punto di partenza: scegli il vincitore, fai feature engineering (lezione 50), tuna con Optuna, e sei ben dentro al territorio delle predizioni utili.

Perché gli alberi battono ancora il deep learning sui dati tabulari

Il paper di Grinsztajn et al. del 2022 ha formalizzato quello che i professionisti dicevano da anni. Tre ragioni strutturali:

  1. I dati tabulari hanno feature eterogenee. Una riga potrebbe avere età, paese, importo di transazione e tenure del cliente, feature misurate in unità completamente diverse, con distribuzioni diverse, e nessuna struttura spaziale che le colleghi. Le reti neurali sono state costruite per input dove i valori vicini significano qualcosa (pixel, parole). Gli alberi trattano ogni feature indipendentemente, il che corrisponde a come i dati tabulari sono effettivamente strutturati.

  2. Gli alberi gestiscono bene funzioni target non lisce. Un piccolo cambiamento nel reddito potrebbe ribaltare una decisione di affidabilità creditizia; le deep net, con il loro flusso di gradiente liscio, faticano a rappresentare confini netti. Gli alberi li rappresentano nativamente.

  3. Gli alberi gestiscono valori mancanti e categoriche senza preprocessing. Le librerie di boosting moderne splittano direttamente sulla mancanza e (nel caso di CatBoost) su valori categorici senza one-hot. Le reti neurali hanno bisogno di tutto in forma numerica densa, che è più fragile e perde informazioni.

C’è stato progresso sul deep learning tabulare, TabNet, FT-Transformer, SAINT, TabPFN, e su alcuni problemi specifici eguagliano o battono il boosting. Ma “eguagliare o battere il boosting” è l’asticella da superare, e sul problema tabulare medio nel 2026, parti con LightGBM e finisci con LightGBM.

Chiusura

Tre lezioni dentro. Conosci l’API di scikit-learn, sai cosa rende buone delle feature e cosa leaka, e sai come fare il fit di un modello ad albero che è competitivo con qualsiasi altra cosa sui dati tabulari. È abbastanza per gestire la maggior parte dei problemi reali di ML che la gente viene pagata per risolvere. Le prossime lezioni di questo modulo vanno oltre il tabulare, valutazione fatta per bene, reti neurali, NLP e la storia della messa in produzione, ma se ti fermassi qui e facessi solo pipeline più alberi boosted, saresti già avanti rispetto alla maggior parte dei team.

Cerca