Why tune with a study instead of guessing
Hand-tuning is tempting. But it’s hard to reason about the interactions among parameters like max_depth, min_child_weight, subsample, and eta (learning rate). Instead of inventing sequences by gut feel, Optuna orchestrates an intelligent sequence of model trainings—guided by past results and uncertainty—so you converge faster and with more confidence.
- Intelligent series of models: A sampler (e.g., TPE) proposes the next hyperparameters using the history of trials, not arbitrary grids.
- Query the best model: You can ask the study for the best score, the best trial, or filter by constraints (e.g., training time) rather than eyeballing a handful of runs.
- Reproducibility: Studies are persisted; experiments are resumable and auditable.
Definitions (short and crisp)
- Study space: The hyperparameter search space you define.
- Trial: One end-to-end training + evaluation with a particular hyperparameter set.
- Experiment: A collection of trials over a study space (often persisted in a storage backend).
- Sampler: Strategy for proposing the next hyperparameters (e.g., TPE, random, CMA-ES).
- Pruner: Strategy to stop unpromising trials early (e.g., median/pruning, successive halving, Hyperband).
Cost-aware search space design
The larger the dimensionality (and the wider each dimension), the more trials you need to adequately sample. If the model is expensive (e.g., very large datasets or complex objectives), keep the space smaller at first.
- High dimensions ⇒ more samples: Otherwise the sampler doesn’t see enough outcomes to separate good from bad regions.
- Start simple: Lock some parameters to sensible defaults and open them later. For XGBoost, a common first pass varies:
max_depth,min_child_weight,eta,subsample,colsample_bytree,lambda,alpha. - Log scales: Use log-uniform for parameters spanning orders of magnitude (e.g.,
eta,lambda,alpha).
Random warm‑up and non-greedy exploration
Most samplers begin with a number of random trials to build a dataset (a non-greedy phase). This is good: it prevents early over-commitment to local optima and gives the learner diverse evidence about the search space. You can control the number of random starts with sampler settings, or just let TPE’s defaults handle it.
Pruning: stop bad runs early
If the loss curve looks similar to weaker trials—or just clearly lags—cut it. Pruners compare intermediate metrics (per boosting round) to historical benchmarks and stop unpromising trials early. This saves time and shifts budget to better candidates.
Early stopping inside the model
XGBoost supports early stopping via an evaluation set and early_stopping_rounds. When the validation metric plateaus, training halts and the model rolls back to the best iteration. Combined with Optuna’s pruning, you’re protected both within a trial and across trials.
Restarts and parallelism
It’s easy to resume or parallelize studies. Persist to SQLite or PostgreSQL, launch multiple workers, and Optuna will coordinate trials safely.
End-to-end example: Optuna + XGBoost (classification)
Below is a compact but realistic setup. Adjust the search space and budget to your data. This example uses scikit-learn API for familiarity.
#| label: setup
#| echo: true
import os
import optuna
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import StratifiedKFold, train_test_split
from sklearn.metrics import roc_auc_score
from xgboost import XGBClassifier
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
X, y = load_breast_cancer(return_X_y=True)
X_train, X_valid, y_train, y_valid = train_test_split(
X, y, test_size=0.2, random_state=RANDOM_SEED, stratify=y
)
eval_set = [(X_valid, y_valid)]Define the objective and search space
#| label: objective
#| echo: true
def objective(trial: optuna.Trial) -> float:
params = {
"n_estimators": trial.suggest_int("n_estimators", 200, 1500),
"max_depth": trial.suggest_int("max_depth", 2, 10),
"min_child_weight": trial.suggest_float("min_child_weight", 1.0, 10.0),
"learning_rate": trial.suggest_float("eta", 1e-3, 3e-1, log=True),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
"reg_lambda": trial.suggest_float("lambda", 1e-3, 10.0, log=True),
"reg_alpha": trial.suggest_float("alpha", 1e-4, 1.0, log=True),
"random_state": RANDOM_SEED,
"tree_method": "hist",
# Speed/consistency tweaks; adjust per hardware
"n_jobs": 0,
}
model = XGBClassifier(**params)
model.fit(
X_train, y_train,
eval_set=eval_set,
eval_metric="auc",
verbose=False,
early_stopping_rounds=50,
callbacks=[optuna.integration.XGBoostPruningCallback(trial, "validation_0-auc")],
)
preds = model.predict_proba(X_valid)[:, 1]
auc = roc_auc_score(y_valid, preds)
return aucCreate a study with TPE sampler and median pruner
#| label: study
#| echo: true
storage_url = "sqlite:///optuna_xgb.db" # persisted study (easy resume)
study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=RANDOM_SEED, n_startup_trials=15),
pruner=optuna.pruners.MedianPruner(n_startup_trials=15, n_warmup_steps=50),
study_name="xgb_classif_breast_cancer",
storage=storage_url,
load_if_exists=True,
)
study.optimize(objective, n_trials=60, timeout=None, n_jobs=1, show_progress_bar=False)
print({"best_value": study.best_value, "best_trial": study.best_trial.number})Querying the study (choose a model by query, not vibes)
#| label: query
#| echo: true
best_trial = study.best_trial
print("Best AUC:", best_trial.value)
print("Best params:")
for k, v in best_trial.params.items():
print(f" {k}: {v}")
# Inspect top-5 trials
top5 = sorted(study.trials, key=lambda t: t.value or -np.inf, reverse=True)[:5]
for t in top5:
print({"trial": t.number, "value": t.value})Refit the best model on train+valid and save
#| label: refit
#| echo: true
best_params = best_trial.params.copy()
best_params.update({
"n_estimators": max(300, best_trial.params.get("n_estimators", 500)),
"random_state": RANDOM_SEED,
"tree_method": "hist",
"n_jobs": 0,
})
final_model = XGBClassifier(**best_params)
final_model.fit(
np.vstack([X_train, X_valid]),
np.concatenate([y_train, y_valid]),
eval_set=[(X_valid, y_valid)],
eval_metric="auc",
verbose=False,
early_stopping_rounds=50,
)
import joblib
joblib.dump(final_model, "xgb_final.joblib")Ask–Tell API: total transparency and manual pruning
The Ask–Tell API exposes each step explicitly: you ask for a trial, sample parameters, run training, report intermediate metrics, optionally prune, and finally tell the study the result. This makes control flow transparent and easy to integrate with custom training loops, distributed systems, or non- sklearn code paths.
Below is a compact loop using Ask–Tell with pruning based on the validation AUC trajectory. We use MedianPruner logic via should_prune, but you can implement any rule (e.g., compare to the best-so-far curve or a time budget).
#| label: ask-tell
#| echo: true
import time
max_trials = 40
storage_url = "sqlite:///optuna_xgb_asktell.db"
study_at = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=RANDOM_SEED, n_startup_trials=10),
pruner=optuna.pruners.MedianPruner(n_startup_trials=10, n_warmup_steps=30),
study_name="xgb_ask_tell_breast_cancer",
storage=storage_url,
load_if_exists=True,
)
for _ in range(max_trials):
trial = study_at.ask()
params = {
"n_estimators": trial.suggest_int("n_estimators", 200, 1200),
"max_depth": trial.suggest_int("max_depth", 2, 10),
"min_child_weight": trial.suggest_float("min_child_weight", 1.0, 10.0),
"learning_rate": trial.suggest_float("eta", 1e-3, 3e-1, log=True),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
"reg_lambda": trial.suggest_float("lambda", 1e-3, 10.0, log=True),
"reg_alpha": trial.suggest_float("alpha", 1e-4, 1.0, log=True),
"random_state": RANDOM_SEED,
"tree_method": "hist",
"n_jobs": 0,
}
# Manual training loop with incremental reporting and pruning.
# We'll grow in chunks and evaluate after each chunk to allow pruning.
chunk = 100
best_auc = -np.inf
trained_estimators = 0
model = None
while trained_estimators < params["n_estimators"]:
next_estimators = min(params["n_estimators"] - trained_estimators, chunk)
if model is None:
model = XGBClassifier(**{**params, "n_estimators": next_estimators})
model.fit(
X_train,
y_train,
eval_set=eval_set,
eval_metric="auc",
verbose=False,
)
else:
# continue training by increasing n_estimators
model.set_params(n_estimators=trained_estimators + next_estimators)
model.fit(
X_train,
y_train,
eval_set=eval_set,
eval_metric="auc",
verbose=False,
xgb_model=model.get_booster(),
)
trained_estimators += next_estimators
# Evaluate and report intermediate value
preds = model.predict_proba(X_valid)[:, 1]
auc = roc_auc_score(y_valid, preds)
best_auc = max(best_auc, auc)
trial.report(auc, step=trained_estimators)
# Prune if median rule says it's unpromising (or if your rule triggers)
if trial.should_prune():
study_at.tell(trial, state=optuna.trial.TrialState.PRUNED)
break
else:
# Completed without pruning
study_at.tell(trial, value=float(best_auc))
print({"best_value": study_at.best_value, "best_trial": study_at.best_trial.number})What’s nice about this pattern:
- You decide when to evaluate and how to aggregate metrics (per chunk, per epoch, per time window).
- You can add custom telemetry, constraints, or timeouts and still let Optuna coordinate sampling and bookkeeping.
- Pruning decisions are explicit and auditable via
trial.reportandtrial.should_prune()calls.
Practical guidance and trade-offs
- Keep the study space sane first: Start with 6–8 parameters. Add more only when you can afford more trials.
- Normalize budgets: Use
n_trialsortimeoutconsistently so results are comparable across experiments. - Use early stopping and pruning together: Early stopping guards within a trial; pruning reallocates budget across trials.
- Cross-validation: For small or noisy datasets, prefer CV over a single split. Use
StratifiedKFoldand average validation metrics per trial. - Parallel workers: Set a shared storage and run multiple Python processes calling
study.optimize(..., n_jobs=1)each. Optuna coordinates safely. - Reproducibility: Fix seeds for the sampler and model. Persist to a DB.
Example: cross-validation objective (sketch)
#| label: cv-objective
#| echo: true
from sklearn.model_selection import StratifiedKFold
def cv_objective(trial: optuna.Trial) -> float:
params = {
"n_estimators": trial.suggest_int("n_estimators", 200, 1200),
"max_depth": trial.suggest_int("max_depth", 2, 8),
"min_child_weight": trial.suggest_float("min_child_weight", 1.0, 8.0),
"learning_rate": trial.suggest_float("eta", 1e-3, 2e-1, log=True),
"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("lambda", 1e-3, 5.0, log=True),
"reg_alpha": trial.suggest_float("alpha", 1e-4, 1.0, log=True),
"random_state": RANDOM_SEED,
"tree_method": "hist",
"n_jobs": 0,
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_SEED)
aucs = []
for train_idx, valid_idx in cv.split(X, y):
model = XGBClassifier(**params)
model.fit(
X[train_idx], y[train_idx],
eval_set=[(X[valid_idx], y[valid_idx])],
eval_metric="auc",
verbose=False,
early_stopping_rounds=50,
)
preds = model.predict_proba(X[valid_idx])[:, 1]
aucs.append(roc_auc_score(y[valid_idx], preds))
return float(np.mean(aucs))CV is slower but more stable. If you enable pruning with CV, report intermediate values per fold or boosting round.
Frequently asked
- How many trials do I need? Depends on dimensionality and noise. For 6–8 parameters on tabular data, 50–200 trials is a common starting point.
- What if training is very slow? Shrink the space, reduce
n_estimators, increaseearly_stopping_rounds, and rely more on pruning. - Can I resume after interruption? Yes—use persistent storage; re-run the same study name with
load_if_exists=True. - How do I avoid overfitting to the validation set? Use CV or keep a final untouched test set for last.
Takeaways
- Train a series of models intelligently with Optuna instead of manual guessing.
- Keep the hyperparameter space scoped to your budget; expand gradually.
- Embrace random warm‑up, pruning, and early stopping—they accelerate learning without sacrificing quality.
- Persist studies for easy restarts and parallelism.
- Choose models by querying study results, not by gut.