Ogni modello ha due tipi di numeri. I parameter sono quelli che il modello impara dai dati: i pesi in una logistic regression, gli split in un decision tree, le migliaia di valori nelle foglie di XGBoost. Gli hyperparameter sono quelli che imposti prima del training: learning_rate, max_depth, forza di regularization, numero di alberi, dropout rate. Sbagliali e lo stesso modello sugli stessi dati può oscillare da “inutile” a “stato dell’arte”.
Il tuning è il processo di cercare sistematicamente buoni valori per gli iperparametri. Ci sono quattro strategie comuni, ognuna con un regime in cui ha senso, e una libreria, Optuna, che si è mangiata gran parte del pranzo delle altre negli ultimi anni.
Cosa stai davvero ottimizzando
Lo scenario è sempre lo stesso. Hai una funzione che prende iperparametri e restituisce uno score:
def objective(hyperparams) -> float:
model = Model(**hyperparams)
score = cross_val_score(model, X_train, y_train, cv=5).mean()
return score
Vuoi trovare gli iperparametri che massimizzano quello score. La funzione è costosa (ogni chiamata richiede di fare il fit di un modello, possibilmente più volte per la cross-validation), rumorosa (la cross-validation ti dà una stima, non lo score vero), e black-box (non hai un gradiente: puoi solo valutarla nei punti che scegli).
Quell’ultimo vincolo è quello interessante. Gran parte dell’ottimizzazione presuppone che tu possa prendere derivate. L’hyperparameter optimization no. Le quattro strategie qui sotto sono risposte diverse a “dato che posso solo valutare la funzione nei punti che scelgo, come dovrei sceglierli?”.
Strategia 1: grid search
L’approccio brute-force: definisci una griglia discreta per ogni iperparametro e prova ogni combinazione.
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
param_grid = {
"n_estimators": [100, 300, 500],
"max_depth": [None, 10, 20],
"min_samples_split": [2, 5, 10],
}
search = GridSearchCV(
RandomForestClassifier(random_state=42),
param_grid,
cv=5,
scoring="roc_auc",
n_jobs=-1,
)
search.fit(X_train, y_train)
print(search.best_params_, search.best_score_)
Quella griglia è 3 x 3 x 3 = 27 combinazioni, per 5 fold di CV = 135 fit del modello. Gestibile.
La catastrofe è che esplode in modo combinatorio. Aggiungi un quarto iperparametro con 5 valori e sei a 135 x 5 x 5 = 3.375 fit. Aggiungine un quinto e sei oltre i 16.000. La grid search è un buon abbinamento quando:
- Hai 2-3 iperparametri,
- con 3-5 valori sensati ciascuno,
- e un singolo fit è veloce (logistic regression, random forest piccola).
Per qualsiasi cosa più pesante, XGBoost su un dataset reale, una rete neurale, la grid search è morta prima ancora di iniziare.
Strategia 2: random search
Invece di provare ogni combinazione, campiona combinazioni a caso da una distribuzione:
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint
param_dist = {
"n_estimators": randint(100, 1000),
"max_depth": randint(3, 30),
"min_samples_split": randint(2, 20),
"max_features": loguniform(0.1, 1.0),
}
search = RandomizedSearchCV(
RandomForestClassifier(random_state=42),
param_dist,
n_iter=50,
cv=5,
scoring="roc_auc",
n_jobs=-1,
random_state=42,
)
search.fit(X_train, y_train)
Il paper di Bergstra e Bengio del 2012 “Random Search for Hyper-Parameter Optimization” ha sostenuto che con lo stesso budget di compute, la random search di solito trova iperparametri altrettanto buoni o migliori della grid search. L’intuizione: solo pochi iperparametri contano davvero, e la grid search spreca compute esplorando ad alta risoluzione quelli non importanti. La random search, campionando in modo continuo, dà a ogni iperparametro un buon numero di valori distinti, indipendentemente da quali finiscano per contare.
La random search è la scelta giusta quando:
- Hai 4+ iperparametri,
- alcuni sono continui (forze di regularization, learning rate),
- e hai un budget di compute fisso, diciamo 50 o 100 fit.
Però spreca comunque compute. Una volta che hai campionato 30 punti e visto che learning_rate alto è cattivo, la random search continua a estrarre altri campioni con learning_rate alto. Lì è dove la prossima strategia si guadagna lo stipendio.
Strategia 3: bayesian optimization
L’idea: costruisci un modello probabilistico della funzione di score man mano che vai avanti, e usa quel modello per scegliere il prossimo punto in modo intelligente.
Concretamente, dopo ogni valutazione fai il fit di un Gaussian process (o un modello tree-based, nelle implementazioni moderne) sulle tue coppie (hyperparams, score). Il modello ti dà, per qualsiasi candidato di iperparametri, sia uno score predetto sia una stima di incertezza. Poi scegli il prossimo punto da valutare massimizzando una acquisition function che bilancia:
- Exploitation: prova punti dove il modello pensa che lo score sarà alto.
- Exploration: prova punti dove il modello è incerto.
L’acquisition function standard è l’expected improvement: di quanto meglio del current best ci aspettiamo che sia questo candidato? Ogni valutazione raffina il modello, quindi le scelte successive diventano più intelligenti.
Questo è drammaticamente più sample-efficient della random search. Dove la random potrebbe avere bisogno di 200 valutazioni per trovare una buona regione, la bayesian spesso la inchioda in 30-50. Il costo è l’overhead per valutazione: il fit del modello surrogato e l’ottimizzazione dell’acquisition function richiedono qualche secondo. Per obiettivi costosi, XGBoost su un milione di righe, un training di rete neurale, quell’overhead è invisibile. Per obiettivi economici, non ne vale la pena.
Strategia 4: population-based ed evolutionary
Per budget di compute molto grandi, pensa al tuning di un foundation model con migliaia di GPU, c’è una famiglia di strategie che mantiene una popolazione di configurazioni, le valuta in parallelo, e usa regole di mutation/crossover o “exploit-and-explore” per far evolvere la popolazione. Il Population-Based Training (DeepMind, 2017) e le varianti HyperBand vivono qui. Probabilmente non ti servirà, a meno che tu non stia facendo deep learning serio su larga scala. Vale la pena sapere che esistono.
Optuna: il default del 2026
Gran parte delle strategie qui sopra sono ora avvolte sotto un’unica libreria. Optuna è diventata la libreria Python dominante per gli iperparametri per diverse buone ragioni:
- Una singola API copre grid, random e bayesian (TPE, Tree-structured Parzen Estimator).
- La objective function è puro Python, niente goffi dizionari di parametri.
- Il trial pruning uccide presto i run cattivi.
- La modalità distribuita esegue trial su più macchine con un database condiviso.
- Gli strumenti di visualizzazione sono di prima classe.
La forma di base:
import optuna
from sklearn.model_selection import cross_val_score
from xgboost import XGBClassifier
def objective(trial):
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 1000),
"max_depth": trial.suggest_int("max_depth", 3, 12),
"learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 10.0, log=True),
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
}
model = XGBClassifier(**params, random_state=42, eval_metric="logloss")
score = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc").mean()
return score
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=100)
print(study.best_params)
print(study.best_value)
La funzione objective è semplicemente Python. Puoi metterci dentro qualsiasi logica: iperparametri condizionali (“se model_type è X, tuna anche Y”), step di preprocessing, qualunque cosa. Le chiamate trial.suggest_* registrano implicitamente lo spazio di ricerca man mano che le chiami: Optuna impara dai trial cosa suggerire dopo.
Nota log=True sul learning rate e sulle forze di regularization. Tunali sempre su scala logaritmica. La differenza tra learning_rate=0.001 e learning_rate=0.01 è un fattore 10, che conta. La differenza tra learning_rate=0.291 e learning_rate=0.301 è un errore di arrotondamento.
Di default, Optuna usa TPE, un tree-structured Parzen estimator, un sampler con sapore bayesian che gestisce bene spazi misti continui/discreti/categorici. Puoi sostituirlo con RandomSampler, GridSampler o CmaEsSampler passando un argomento sampler=.
Pruning: uccidi presto i trial cattivi
Se stai tunando un modello con iterazioni (round di XGBoost, epoch di rete neurale), un trial lento che è chiaramente indietro nella classifica sta sprecando compute. Il pruning di Optuna permette al trial di riportare score intermedi e abortire prima:
def objective(trial):
params = {
"max_depth": trial.suggest_int("max_depth", 3, 12),
"learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
}
model = XGBClassifier(**params, random_state=42)
for epoch in range(100):
model.set_params(n_estimators=epoch + 1)
model.fit(X_train, y_train)
score = roc_auc_score(y_val, model.predict_proba(X_val)[:, 1])
trial.report(score, epoch)
if trial.should_prune():
raise optuna.TrialPruned()
return score
study = optuna.create_study(
direction="maximize",
pruner=optuna.pruners.MedianPruner(n_warmup_steps=10),
)
MedianPruner taglia i trial il cui score intermedio è sotto la mediana dei trial completati allo stesso step. HyperbandPruner fa qualcosa di più aggressivo basato su successive halving. Il pruning tipicamente dà uno speedup di 2-5x su modelli iterativi tunabili.
Cross-validation, nested o no
La domanda di model selection che morde tutti: se tuni gli iperparametri sulla cross-validation e riporti il miglior score CV, stai facendo overfit ai fold di CV. La stima “vera” di generalizzazione è stata contaminata dalla ricerca stessa.
Lo schema corretto: nested cross-validation. Un loop esterno di CV misura la generalizzazione. Dentro ogni fold esterno, un loop interno di CV tuna gli iperparametri. Lo score esterno è ciò che riporti.
from sklearn.model_selection import KFold
outer = KFold(n_splits=5, shuffle=True, random_state=42)
outer_scores = []
for train_idx, test_idx in outer.split(X):
X_tr, X_te = X.iloc[train_idx], X.iloc[test_idx]
y_tr, y_te = y.iloc[train_idx], y.iloc[test_idx]
study = optuna.create_study(direction="maximize")
study.optimize(lambda trial: tune_inner(trial, X_tr, y_tr), n_trials=50)
final_model = XGBClassifier(**study.best_params).fit(X_tr, y_tr)
outer_scores.append(roc_auc_score(y_te, final_model.predict_proba(X_te)[:, 1]))
print(f"Generalization AUC: {np.mean(outer_scores):.3f} +/- {np.std(outer_scores):.3f}")
La nested CV è costosa: 5 fold esterni x 50 trial interni x 5 fold interni = 1.250 fit del modello. L’alternativa economica e onesta: holdout di una frazione dei dati prima di qualsiasi tuning, tuna sul resto con CV regolare, e usa l’holdout una sola volta alla fine. Perdi efficienza statistica ma la risposta è ancora affidabile.
Lo spazio di ricerca conta più dell’algoritmo
Se i tuoi range di iperparametri non includono buoni valori, nessun algoritmo li troverà. Una random search su learning_rate in [0.5, 1.0] non troverà l’ottimo a 0.05, non importa quanti trial esegui. La bayesian sullo stesso range è solo uno spreco più intelligente di compute.
Una regola pratica: per qualsiasi nuova classe di modelli, cerca i range “tipici” documentati e allargali di un ordine di grandezza per lato. Poi guarda dove si raggruppano i migliori trial di Optuna. Se si ammucchiano contro un confine del tuo spazio di ricerca, il tuo confine è sbagliato: allargalo e fai di nuovo il tuning.
Optuna distribuita
Per studi grandi, Optuna può coordinare trial fra macchine tramite un database condiviso (PostgreSQL, MySQL, SQLite per locale). Ogni worker tira fuori trial dallo studio, li esegue, e scrive di nuovo i risultati:
study = optuna.create_study(
storage="postgresql://user:pass@host/optuna_db",
study_name="xgb-tuning-2026-05",
direction="maximize",
load_if_exists=True,
)
study.optimize(objective, n_trials=100)
Stesso codice su ogni worker. Optuna gestisce i lock. È così che avvengono le run di tuning serie nel 2026: avvii qualche worker in cloud, li punti allo stesso database, e li lasci masticare i trial in parallelo.
Assistenza AI per gli spazi di ricerca
Un piccolo ma reale consiglio di produttività nel 2026: gli AI coding assistant sono estremamente bravi a suggerire spazi di ricerca Optuna dato un tipo di modello. Prompt come “give me an Optuna search space for XGBoost regression on tabular data” o “suggest reasonable ranges for tuning a LightGBM classifier with class imbalance” ti danno codice funzionante con default sensati in pochi secondi. Non è magia: quei range sono documentati in molti posti, ma ti risparmia la ricerca. Verifica il risultato contro la doc del modello e aggiusta in base al tuo problema specifico.
Prossima lezione: ML project end-to-end, mettendo tutto il Modulo 9 in un unico workflow.
References: Optuna documentation (https://optuna.org/), scikit-learn model selection guide (scikit-learn.org/stable/modules/grid_search.html). Retrieval 2026-05-01.