Python, de la zero Lecția 53 / 60

Tuning de hiperparametri: grid, random, bayesian, optuna

Cele patru strategii de cautare, cand are sens fiecare si de ce optuna e default-ul in 2026.

Fiecare model are două tipuri de numere. Parametrii sunt cei pe care modelul îi învață din date, ponderile dintr-o logistic regression, split-urile dintr-un decision tree, miile de valori de frunză din XGBoost. Hiperparametrii sunt cei pe care îi setezi înainte de antrenare: learning_rate, max_depth, tăria regularizării, numărul de arbori, dropout rate. Greșește-i și același model pe aceleași date poate să meargă de la „inutil” la „state of the art”.

Tuning-ul e procesul de căutare sistematică a unor valori bune de hiperparametri. Există patru strategii comune, fiecare cu un regim în care are sens, și o bibliotecă - Optuna - care a mâncat prânzul majorității celorlalte în ultimii ani.

Ce optimizezi de fapt

Setup-ul e mereu același. Ai o funcție care primește hiperparametri și returnează un scor:

def objective(hyperparams) -> float:
    model = Model(**hyperparams)
    score = cross_val_score(model, X_train, y_train, cv=5).mean()
    return score

Vrei să găsești hiperparametrii care maximizează acel scor. Funcția e scumpă (fiecare apel necesită antrenarea unui model, posibil de mai multe ori pentru cross-validation), zgomotoasă (cross-validation îți dă o estimare, nu scorul adevărat) și black-box (nu ai un gradient, o poți evalua doar în puncte pe care le alegi).

Ultima constrângere e cea interesantă. Majoritatea optimizărilor presupun că poți lua derivate. Optimizarea de hiperparametri nu poate. Cele patru strategii de mai jos sunt răspunsuri diferite la „dat fiind că pot evalua funcția doar în puncte pe care le aleg, cum ar trebui să le aleg?”

Abordarea brute-force: definești un grid discret pentru fiecare hiperparametru și încerci fiecare combinație.

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_)

Acel grid e 3 x 3 x 3 = 27 de combinații, înmulțit cu 5 fold-uri CV = 135 de antrenări de model. Gestionabil.

Catastrofa e că explodează combinatorial. Adaugă un al patrulea hiperparametru cu 5 valori și ești la 135 x 5 x 5 = 3.375 antrenări. Adaugă un al cincilea și treci de 16.000. Grid search e o potrivire bună când:

  • Ai 2-3 hiperparametri,
  • cu 3-5 valori sensibile fiecare,
  • și o singură antrenare e rapidă (logistic regression, random forest mic).

Pentru orice mai greu - XGBoost pe un set de date real, o rețea neuronală - grid search e mort înainte să începi.

În loc să încerci fiecare combinație, eșantionează combinații aleator dintr-o distribuție:

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)

Lucrarea lui Bergstra și Bengio din 2012, „Random Search for Hyper-Parameter Optimization”, a argumentat că, cu același buget de calcul, random search găsește de obicei hiperparametri la fel de buni sau mai buni decât grid search. Intuiția: doar câțiva hiperparametri contează cu adevărat, iar grid search risipește calcul explorându-i pe cei neimportanți la rezoluție mare. Random search, eșantionând continuu, dă fiecărui hiperparametru un număr decent de valori distincte, indiferent care se dovedesc a conta.

Random search e alegerea corectă când:

  • Ai 4+ hiperparametri,
  • unii sunt continui (tării de regularizare, learning rate-uri),
  • și ai un buget de calcul fix - să zicem 50 sau 100 de antrenări.

Tot risipește calcul, totuși. După ce ai eșantionat 30 de puncte și ai văzut că learning_rate mare e rău, random search continuă să tragă mai multe eșantioane cu learning_rate mare. Acolo își câștigă cina următoarea strategie.

Strategia 3: bayesian optimization

Ideea: construiește un model probabilistic al funcției de scor pe măsură ce mergi și folosește acel model pentru a alege punctul următor cu cap.

Concret, după fiecare evaluare antrenezi un Gaussian process (sau un model bazat pe arbori, în implementările moderne) pe perechile tale (hyperparams, score). Modelul îți dă, pentru orice setare candidată de hiperparametri, atât un scor prezis, cât și o estimare a incertitudinii. Apoi alegi punctul următor de evaluat maximizând o acquisition function care echilibrează:

  • Exploitation: încearcă puncte unde modelul crede că scorul va fi mare.
  • Exploration: încearcă puncte unde modelul e incert.

Acquisition function-ul standard e expected improvement: cu cât mai bun decât cel mai bun curent ne așteptăm să fie acest candidat? Fiecare evaluare rafinează modelul, deci alegerile succesive devin mai inteligente.

Asta e dramatic mai eficient ca eșantionare decât random search. Acolo unde random ar putea avea nevoie de 200 de evaluări pentru a găsi o regiune bună, bayesian o nimerește adesea în 30-50. Costul e overhead-ul pe evaluare: antrenarea modelului surogat și optimizarea acquisition function-ului ia secunde. Pentru obiective scumpe - XGBoost pe un milion de rânduri, o rulare de antrenare a unei rețele neuronale - acel overhead e invizibil. Pentru obiective ieftine, nu merită.

Strategia 4: population-based și evoluționară

Pentru bugete de calcul foarte mari - gândește-te la tuning-ul de hiperparametri al unui foundation model cu mii de GPU-uri - există o familie de strategii care mențin o populație de configurații, le evaluează în paralel și folosesc reguli de mutație/crossover sau „exploit-and-explore” pentru a evolua populația. Population-Based Training (DeepMind, 2017) și variantele HyperBand trăiesc aici. Probabil nu vei avea nevoie de astea decât dacă faci deep learning serios la scară. Merită să știi că există.

Optuna: default-ul în 2026

Majoritatea strategiilor de mai sus sunt acum împachetate sub o singură bibliotecă. Optuna a devenit biblioteca dominantă de hiperparametri în Python din mai multe motive bune:

  • Un singur API acoperă grid, random și bayesian (TPE - Tree-structured Parzen Estimator).
  • Funcția obiectiv e Python pur, fără dicționare incomode de parametri.
  • Trial pruning oprește devreme rulările proaste.
  • Modul distribuit rulează trial-uri pe mai multe mașini cu o bază de date partajată.
  • Uneltele de vizualizare sunt de top.

Forma de bază:

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)

Funcția objective e doar Python. Poți pune orice logică în ea: hiperparametri condiționali („dacă model_type e X, tunează și Y”), pași de preprocesare, orice. Apelurile trial.suggest_* înregistrează implicit spațiul de căutare pe măsură ce le apelezi, Optuna învață din trial-uri ce să sugereze în continuare.

Observă log=True pe learning rate și pe tăriile de regularizare. Tunează-le mereu pe scală logaritmică. Diferența între learning_rate=0.001 și learning_rate=0.01 e un factor de 10, ceea ce contează. Diferența între learning_rate=0.291 și learning_rate=0.301 e o eroare de rotunjire.

Implicit, Optuna folosește TPE, un tree-structured Parzen estimator, un sampler cu aromă bayesiană care gestionează bine spații continue/discrete/categorice mixte. Îl poți schimba cu RandomSampler, GridSampler sau CmaEsSampler pasând un argument sampler=.

Pruning: oprește trial-urile proaste devreme

Dacă tunezi un model cu iterații (rounds în XGBoost, epoci de rețea neuronală), un trial lent care e clar în urmă pe leaderboard risipește calcul. Pruning-ul din Optuna lasă trial-ul să raporteze scoruri intermediare și să se oprească devreme:

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 taie trial-urile al căror scor intermediar e sub mediana trial-urilor finalizate la același pas. HyperbandPruner face ceva mai agresiv bazat pe successive halving. Pruning-ul dă de obicei o accelerație de 2-5x pe modelele iterative tunabile.

Cross-validation, nested sau nu

Întrebarea de selecție de model care îi mușcă pe toți: dacă tunezi hiperparametri pe cross-validation și raportezi cel mai bun scor CV, faci overfitting pe fold-urile CV. Estimarea „adevărată” a generalizării a fost contaminată chiar de căutare.

Setup-ul corect: nested cross-validation. O buclă CV exterioară măsoară generalizarea. În interiorul fiecărui fold exterior, o buclă CV interioară tunează hiperparametrii. Scorul exterior e ce raportezi.

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}")

Nested CV e scump: 5 fold-uri exterioare x 50 de trial-uri interioare x 5 fold-uri interioare = 1.250 de antrenări de model. Alternativa ieftină-și-onestă: holdout o fracțiune din date înainte de orice tuning, tunează pe rest cu CV obișnuit și folosește holdout-ul o singură dată la final. Pierzi eficiență statistică, dar răspunsul rămâne de încredere.

Spațiul de căutare contează mai mult decât algoritmul

Dacă intervalele tale de hiperparametri nu includ valori bune, niciun algoritm nu le va găsi. Random search peste learning_rate in [0.5, 1.0] nu va găsi optimul la 0.05, indiferent câte trial-uri rulezi. Bayesian peste același interval e doar o risipă mai inteligentă de calcul.

O regulă practică: pentru orice clasă nouă de model, caută intervalele „tipice” documentate și lărgește-le cu un ordin de mărime la fiecare capăt. Apoi uită-te unde se aglomerează cele mai bune trial-uri ale Optuna. Dacă se îngrămădesc lângă o limită a spațiului tău de căutare, limita ta e greșită, lărgește-o și retunează.

Optuna distribuit

Pentru studii mari, Optuna poate coordona trial-uri pe mai multe mașini printr-o bază de date partajată (PostgreSQL, MySQL, SQLite local). Fiecare worker trage trial-uri din studiu, le rulează și scrie rezultatele înapoi:

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)

Același cod pe fiecare worker. Optuna se ocupă de locking. Așa se întâmplă rulările serioase de tuning în 2026, pornești câțiva workeri în cloud, îi orientezi spre aceeași bază de date și îi lași să mestece trial-uri în paralel.

Asistență AI pentru spații de căutare

Un sfat de productivitate mic, dar real, în 2026: asistenții AI de coding sunt extrem de buni la sugerarea de spații de căutare Optuna pentru un tip de model. Prompt-uri precum „dă-mi un spațiu de căutare Optuna pentru XGBoost regression pe date tabulare” sau „sugerează intervale rezonabile pentru tuning-ul unui clasificator LightGBM cu dezechilibru de clase” îți dau cod care funcționează cu valori sensibile în câteva secunde. Nu e magie, acele intervale sunt documentate în multe locuri, dar îți scutește căutarea. Sanity check pe rezultat față de docs-ul modelului și ajustează în funcție de problema ta specifică.

Lecția următoare: proiect ML end-to-end, punând tot Modulul 9 într-un singur workflow.


Referințe: documentația Optuna (https://optuna.org/), ghidul scikit-learn pentru selecție de model (scikit-learn.org/stable/modules/grid_search.html). Consultat 2026-05-01.

Caută