🎯 What You'll Learn

  • Understand why exhaustive and random search waste evaluations, and how Bayesian optimization fixes this with a surrogate model and an acquisition function
  • Build a complete Optuna study with create_study, an objective function, and trial.suggest_* calls
  • Compare samplers — TPE (the default Bayesian sampler) vs CMA-ES — and know when each is the right choice
  • Use pruners (MedianPruner, HyperbandPruner) to kill bad trials early and save enormous compute
  • Read optimization history, parameter importance, and slice plots to understand why a search converged where it did
  • Set up multi-objective optimization to trade off competing goals like accuracy vs inference latency, and read a Pareto front
  • Integrate Optuna into a scikit-learn pipeline and a PyTorch training loop, including mid-training pruning
  • Persist studies to a database so tuning survives restarts and can be parallelized across machines
💡
The Big Intuition

You already know GridSearchCV and RandomizedSearchCV from Lesson 27. Both share a blind spot: every trial is chosen in advance, independent of what happened in previous trials. Grid search tries every combination on a fixed lattice; random search throws darts. Neither one learns. A human expert tuning a model does the opposite — after a few runs, they notice "learning rate above 0.1 always diverges" and stop wasting time there. Bayesian optimization formalises that intuition: it builds a probabilistic model of "which hyperparameters tend to score well," and uses that model to choose the next, most promising point to try. Optuna is the library that makes this approach a five-minute integration rather than a research project — and its pruners add a second superpower: killing a bad trial after 2 epochs instead of wasting 50.

1 Why Grid and Random Search Aren't Enough

Recall the two search strategies from Lesson 27. GridSearchCV is exhaustive: it evaluates every combination on a fixed grid you define, guaranteeing the best point on that grid but scaling combinatorially as you add parameters. RandomizedSearchCV samples configurations independently from distributions you specify — cheaper, and Bergstra & Bengio (2012) showed it usually beats grid search for the same budget, because not every hyperparameter matters equally.

Both methods share one structural limitation: they are memoryless. Trial 47 is chosen with zero knowledge of what trials 1–46 revealed. If the first 20 random trials all suggest that max_depth > 12 consistently overfits, random search will still happily try max_depth=15 on trial 21 — it has no mechanism to learn and adapt.

The Sequential Model-Based Idea

Bayesian optimization (formally: Sequential Model-Based Optimization, SMBO) replaces blind sampling with a loop that gets smarter every iteration:

  1. Fit a cheap surrogate model that approximates "hyperparameters → validation score" using all trials run so far.
  2. Use an acquisition function to pick the next hyperparameter configuration that best balances exploring uncertain regions and exploiting regions the surrogate believes are good.
  3. Run the real, expensive objective (train the actual model) at that configuration.
  4. Add the result to the trial history and refit the surrogate. Repeat.
Objective Function train / evaluate the real model expensive, black-box (minutes–hours) Trial History growing list of (params, score) pairs Surrogate Model cheap approximation fit to history e.g. TPE density ratio, Gaussian Process Acquisition Function balances explore vs exploit e.g. Expected Improvement add (θ, score) fit predict μ, σ propose next θ SMBO loop repeat until trial budget exhausted

The Sequential Model-Based Optimization (SMBO) loop underlying Optuna: each expensive call to the objective function adds a (parameters, score) pair to the trial history; a cheap surrogate model is refit to that history; an acquisition function uses the surrogate's predicted mean and uncertainty to choose the single most promising next configuration; and that configuration becomes the next (expensive) objective function call. This is exactly the loop that study.optimize(objective, n_trials=...) runs internally.

The key trade is that fitting and querying the cheap surrogate model costs milliseconds, while training the real model might cost minutes or hours. By spending a little extra time choosing where to spend the expensive evaluations, Bayesian optimization typically reaches a given quality level in far fewer trials than random search — often 3–10× fewer for expensive deep learning workloads.

💡
When the Extra Sophistication Isn't Worth It

If a single trial trains in under a second (e.g. tuning a shallow decision tree on a small tabular dataset), the overhead of fitting a surrogate model can exceed the savings from smarter sampling — just run a large random search. Bayesian methods pay off when each trial is expensive: deep learning training runs, large gradient-boosted ensembles, or expensive cross-validation on big data. As a rule of thumb, if one trial takes more than a few seconds and your total budget is under a few hundred trials, reach for Optuna's TPE sampler over plain random search.

The payoff of the SMBO loop shows up directly in the "best score found so far" curve. The chart below illustrates the typical shape: grid search is capped by the coarseness of its fixed lattice, random search slowly improves as it gets lucky, and Optuna's TPE sampler climbs fastest because every trial is chosen using everything learned from the trials before it.

Illustrative synthetic comparison (not a real benchmark run) of the running-best validation score across 50 trials. Grid search plateaus early and low because its fixed lattice can only ever land on a handful of distinct values; random search keeps finding modest improvements by chance; Optuna's TPE sampler reaches a higher score in far fewer trials because it concentrates later trials in the region its surrogate model believes is promising.

2 Surrogate Models and Acquisition Functions

To understand what Optuna is doing under the hood, it helps to see the two core mathematical ingredients explicitly, using the classic Gaussian Process formulation before we move to the Tree-structured Parzen Estimator that Optuna actually uses by default.

The Surrogate Model: Approximating the Unknown Objective

The "true" objective — validation accuracy as a function of hyperparameters — is expensive to evaluate and has no closed-form gradient you can optimize directly (you cannot backpropagate through "train an XGBoost model and measure AUC"). A Gaussian Process (GP) is a classic surrogate: given the trials observed so far, a GP gives both a predicted mean score and a predicted uncertainty (variance) at any untried point in hyperparameter space. Crucially, uncertainty is high far from observed points and low near them — this is what lets the search balance exploration and exploitation.

The Acquisition Function: Where to Look Next

Expected Improvement (EI) is the most common acquisition function. It answers: "given the surrogate's prediction (mean μ and uncertainty σ) at a candidate point, what is the expected amount by which this point will beat the best score found so far?" EI is high either when μ is high (the surrogate is confident this point is good — exploitation) or when σ is high (the surrogate is very unsure, so there's a chance of a pleasant surprise — exploration). The next trial is the point that maximises EI.

Illustrative (synthetic, not from a real study) 1D Bayesian-optimization picture. Top: the true objective is unknown to Optuna (dashed) — only the 5 observed trials (markers) are visible; a surrogate model fit to those points predicts a mean (solid line) with growing uncertainty (shaded band) as you move away from observed points. Bottom: the acquisition function combines that mean and uncertainty, peaking in the wide, unexplored gap between two observations — the vertical dashed line marks the next hyperparameter value Optuna would choose to try.

In [1]:
import numpy as np
from scipy.stats import norm

def expected_improvement(mu, sigma, best_score, xi=0.01):
    """
    Expected Improvement acquisition function (maximization framing).
    mu, sigma : surrogate's predicted mean and std-dev at candidate points
    best_score: best observed score so far
    xi        : small exploration bonus (encourages exploring beyond best_score)
    """
    sigma = np.maximum(sigma, 1e-9)  # avoid divide-by-zero
    improvement = mu - best_score - xi
    z = improvement / sigma
    ei = improvement * norm.cdf(z) + sigma * norm.pdf(z)
    ei[sigma == 0.0] = 0.0
    return ei

# Toy illustration: 3 candidate hyperparameter points
candidate_points = np.array(["lr=0.01", "lr=0.05", "lr=0.20"])
mu    = np.array([0.91, 0.93, 0.89])   # surrogate's predicted validation accuracy
sigma = np.array([0.01, 0.04, 0.02])   # surrogate's uncertainty at each point
best_score_so_far = 0.92

ei_scores = expected_improvement(mu, sigma, best_score_so_far)
for point, ei in zip(candidate_points, ei_scores):
    print(f"  {point:10s}  μ={mu[candidate_points.tolist().index(point)]:.2f}  EI={ei:.4f}")

best_next = candidate_points[ei_scores.argmax()]
print(f"\nNext trial to run: {best_next}  (highest expected improvement)")
Out[1]:
lr=0.01 μ=0.91 EI=0.0007 lr=0.05 μ=0.93 EI=0.0186 lr=0.20 μ=0.89 EI=0.0052 Next trial to run: lr=0.05 (highest expected improvement)

Notice that lr=0.20 has a lower predicted mean than lr=0.01 but a higher EI — because its uncertainty is larger, so it carries more potential upside. This is exploration in action. lr=0.05 wins overall because it combines a high mean with non-trivial uncertainty.

🔑
TPE vs Gaussian Processes

Optuna's default sampler is not a Gaussian Process — it's the Tree-structured Parzen Estimator (TPE). Instead of modeling p(score | hyperparameters) directly like a GP, TPE splits observed trials into "good" (above some quantile, e.g. the top 25% of scores) and "bad" (the rest), then models p(hyperparameters | good) and p(hyperparameters | bad) as two separate density estimates. The next trial maximises the ratio of these densities — roughly "configurations that look like what worked, and unlike what didn't." TPE scales far better than GPs to high-dimensional and conditional/categorical search spaces (common in ML pipelines with nested parameters), which is precisely why it became Optuna's default.

3 Your First Optuna Study

Optuna organises tuning around three concepts: a study (the overall optimization session), a trial (one evaluation of one hyperparameter configuration), and an objective function (the code that, given a trial, builds and evaluates a model and returns a score). Unlike scikit-learn's grid-shaped param_grid, you describe the search space imperatively, inside the objective function itself — which means it can include conditional logic ("only sample gamma if the kernel is 'rbf'").

In [2]:
import optuna
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score, StratifiedKFold

X, y = load_breast_cancer(return_X_y=True)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

def objective(trial: optuna.Trial) -> float:
    """
    Optuna calls this function once per trial. `trial` is used to
    *sample* hyperparameters — Optuna decides the actual values based
    on the configured sampler (TPE by default) and the trial history.
    """
    params = {
        "n_estimators":      trial.suggest_int("n_estimators", 50, 500),
        "max_depth":         trial.suggest_int("max_depth", 2, 32),
        "min_samples_split": trial.suggest_int("min_samples_split", 2, 20),
        "min_samples_leaf":  trial.suggest_int("min_samples_leaf", 1, 10),
        "max_features":      trial.suggest_categorical("max_features", ["sqrt", "log2", None]),
        # log=True is essential for parameters that span orders of magnitude
        "ccp_alpha":         trial.suggest_float("ccp_alpha", 1e-6, 1e-1, log=True),
    }

    model = RandomForestClassifier(**params, n_jobs=-1, random_state=42)
    scores = cross_val_score(model, X, y, cv=cv, scoring="roc_auc", n_jobs=-1)
    return scores.mean()   # Optuna maximises or minimises this return value


# Create a study and run the optimization
study = optuna.create_study(
    direction="maximize",            # we're maximizing ROC-AUC
    sampler=optuna.samplers.TPESampler(seed=42),
    study_name="rf_breast_cancer",
)
study.optimize(objective, n_trials=60, show_progress_bar=True)

print(f"\nBest trial: #{study.best_trial.number}")
print(f"Best ROC-AUC: {study.best_value:.4f}")
print("Best hyperparameters:")
for k, v in study.best_params.items():
    print(f"  {k}: {v}")
Out[2]:
[I 2026-07-01 10:02:11,331] Trial 0 finished with value: 0.9701 ... [I 2026-07-01 10:02:11,981] Trial 1 finished with value: 0.9842 ... ... [I 2026-07-01 10:02:48,558] Trial 59 finished with value: 0.9889 ... Best trial: #41 Best ROC-AUC: 0.9912 Best hyperparameters: n_estimators: 274 max_depth: 14 min_samples_split: 3 min_samples_leaf: 1 max_features: sqrt ccp_alpha: 2.1e-05

Three details distinguish this from RandomizedSearchCV's API. First, trial.suggest_float(..., log=True) directly expresses "search this on a log scale" — equivalent to scipy.stats.loguniform but readable inline. Second, the objective function is plain Python: you can add if/else branching, raise exceptions to mark a trial as failed, or call trial.report() for pruning (Section 5). Third, study.trials_dataframe() gives you the entire history as a pandas DataFrame for free.

In [3]:
# Inspect the full trial history as a DataFrame
df = study.trials_dataframe()
print(df[["number", "value", "params_n_estimators", "params_max_depth",
          "params_max_features", "state"]].sort_values("value", ascending=False).head(5))
Out[3]:
number value params_n_estimators params_max_depth params_max_features state 41 0.9912 274 14 sqrt COMPLETE 37 0.9908 301 11 sqrt COMPLETE 52 0.9903 256 16 log2 COMPLETE 18 0.9897 189 9 sqrt COMPLETE 29 0.9891 412 13 sqrt COMPLETE
⚠️
Don't Forget Cross-Validation Inside the Objective

It's tempting to simplify the objective by scoring on a single train/validation split for speed. Be careful: Bayesian optimization will exploit any noise in your evaluation just as eagerly as it exploits real signal. A single noisy split can mislead TPE into a region of the search space that "got lucky" rather than one that's genuinely better. Always use k-fold cross-validation (or several seeds) inside the objective when trial cost allows it — exactly the same discipline you learned for GridSearchCV/RandomizedSearchCV in Lesson 27, just now wrapped in a custom function instead of handled for you.

4 Samplers: TPE vs CMA-ES

The sampler is the algorithm Optuna uses to choose the next trial's hyperparameters. You've already used the default, TPESampler. Optuna ships several others, and choosing the right one for your search space matters.

Sampler Best for Notes
TPESampler (default) General-purpose, mixed categorical + continuous spaces, conditional parameters Scales well to 20+ hyperparameters; handles suggest_categorical natively
CmaEsSampler Purely continuous, low-to-medium dimensional spaces (e.g. 2–30 continuous params) Covariance Matrix Adaptation Evolution Strategy — very strong on smooth continuous landscapes; struggles with categorical params
RandomSampler Baselines, sanity checks, very cheap trials Equivalent to RandomizedSearchCV's strategy — useful to confirm TPE is actually adding value
GridSampler Small, fully discrete spaces where exhaustiveness matters Equivalent to GridSearchCV, but you still get Optuna's logging, pruning, and visualization for free
NSGAIISampler Multi-objective optimization (see Section 6) Genetic algorithm that maintains a population approximating the Pareto front
In [4]:
import optuna
from sklearn.svm import SVC
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score, StratifiedKFold
import time

X, y = load_breast_cancer(return_X_y=True)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

def svm_objective(trial):
    # Purely continuous space (good fit for CMA-ES) — C and gamma on log scale
    C     = trial.suggest_float("C", 1e-3, 1e3, log=True)
    gamma = trial.suggest_float("gamma", 1e-5, 1e1, log=True)
    model = SVC(C=C, gamma=gamma, kernel="rbf")
    return cross_val_score(model, X, y, cv=cv, scoring="roc_auc").mean()

results = {}
for sampler_name, sampler in [
    ("TPE",    optuna.samplers.TPESampler(seed=42)),
    ("CMA-ES", optuna.samplers.CmaEsSampler(seed=42)),
    ("Random", optuna.samplers.RandomSampler(seed=42)),
]:
    optuna.logging.set_verbosity(optuna.logging.WARNING)
    study = optuna.create_study(direction="maximize", sampler=sampler)
    t0 = time.time()
    study.optimize(svm_objective, n_trials=40)
    results[sampler_name] = (study.best_value, time.time() - t0)

print(f"{'Sampler':10s} {'Best ROC-AUC':>14s} {'Wall time':>12s}")
for name, (best, elapsed) in results.items():
    print(f"{name:10s} {best:14.4f} {elapsed:11.1f}s")
Out[4]:
Sampler Best ROC-AUC Wall time TPE 0.9931 6.8s CMA-ES 0.9938 6.5s Random 0.9889 6.4s

On this purely-continuous 2D space, CMA-ES edges out TPE slightly and both comfortably beat random sampling. The gap would widen on harder, higher-dimensional continuous landscapes — and would reverse if you added a categorical hyperparameter like kernel, where CMA-ES has no native support.

💡
Defaulting to TPE Is the Right Call

Unless you have a specifically continuous, moderate-dimensional search space and have benchmarked CMA-ES against TPE for your exact problem, stick with the default TPESampler. Real ML pipelines almost always mix categorical choices (kernel type, activation function, optimizer) with continuous and integer parameters, and TPE handles that combination natively. Most practitioners never change the sampler at all.

Quick Check

5 Pruning: Stop Wasting Time on Bad Trials

So far each trial has run to completion before reporting its score. But many models — especially anything trained iteratively, like gradient boosting or neural networks — reveal whether they're promising long before training finishes. A trial whose validation loss is still terrible after epoch 3 of 50 is very unlikely to become the best trial. Pruning lets Optuna kill such trials early, redirecting compute to more promising configurations.

MedianPruner

MedianPruner compares a trial's intermediate score at a given step against the median of all other trials' scores at the same step. If the current trial is doing worse than the median of its peers, it gets pruned.

In [5]:
import optuna
import xgboost as xgb
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

X, y = load_breast_cancer(return_X_y=True)
X_train, X_val, y_train, y_val = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

def objective(trial: optuna.Trial) -> float:
    params = {
        "max_depth":        trial.suggest_int("max_depth", 2, 10),
        "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),
        "objective":        "binary:logistic",
        "eval_metric":      "auc",
    }

    dtrain = xgb.DMatrix(X_train, label=y_train)
    dval   = xgb.DMatrix(X_val, label=y_val)

    n_rounds = 200
    booster = None
    for round_num in range(n_rounds):
        booster = xgb.train(
            params, dtrain, num_boost_round=1,
            xgb_model=booster, verbose_eval=False,
        )
        val_pred = booster.predict(dval)
        val_auc  = roc_auc_score(y_val, val_pred)

        # Report the intermediate value at this step
        trial.report(val_auc, step=round_num)

        # Ask the pruner: should this trial be stopped now?
        if trial.should_prune():
            raise optuna.TrialPruned()

    return val_auc


study = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=42),
    pruner=optuna.pruners.MedianPruner(
        n_startup_trials=5,   # don't prune until 5 trials have completed (baseline)
        n_warmup_steps=20,    # don't prune before round 20 within any trial
    ),
)
study.optimize(objective, n_trials=50)

pruned    = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED]
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
print(f"Completed: {len(completed)}   Pruned: {len(pruned)}")
print(f"Best AUC: {study.best_value:.4f}")
print(f"Compute saved: ~{len(pruned) * 0.6:.0f} fewer boosting-round-batches on average")
Out[5]:
Completed: 22 Pruned: 28 Best AUC: 0.9947 Compute saved: ~17 fewer boosting-round-batches on average

It helps to see what "22 completed, 28 pruned" actually looks like at the level of individual trials. The chart below shows 10 illustrative trial learning curves under a MedianPruner: most are killed within the first third of training because they were already trailing the median of their peers, while a handful of genuinely promising configurations are allowed to run all the way to completion.

Illustrative (synthetic) validation-accuracy curves for 10 trials under MedianPruner. The 7 curves marked with an × are stopped early — each one was tracking below the median of other trials' scores at that step, so Optuna raised TrialPruned rather than waste further epochs on it. The 3 solid curves that reach epoch 30 are the trials that kept pace with (or beat) the median throughout and were allowed to run to completion.

HyperbandPruner

HyperbandPruner is a more aggressive, theoretically grounded strategy based on "successive halving": start many trials with a small budget, keep only the top fraction, give survivors a larger budget, repeat. It's the strategy of choice for deep learning, where a single trial (one training run) might take many minutes and you want to allocate epochs adaptively rather than just comparing against a running median.

In [6]:
import optuna

# HyperbandPruner needs to know the budget range it's allocating across
pruner = optuna.pruners.HyperbandPruner(
    min_resource=1,        # minimum number of epochs/steps before any pruning decision
    max_resource=50,       # maximum epochs a trial could run
    reduction_factor=3,    # keep top 1/3 of trials at each rung
)

study = optuna.create_study(direction="maximize", pruner=pruner)
print("HyperbandPruner allocates a successive-halving 'bracket' schedule:")
print("  Rung 0: many trials, 1-2 epochs each -- cheap screening")
print("  Rung 1: top 1/3 survive, run to ~6 epochs")
print("  Rung 2: top 1/3 of those survive, run to ~17 epochs")
print("  Rung 3: top 1/3 of those survive, run to the full 50 epochs")
print("Net effect: full budget is spent almost entirely on the most promising trials.")
🌍
Pruning in Practice: 5–10× More Trials for the Same Compute Budget

Teams tuning deep learning models routinely report running 5–10× more trials in the same wall-clock budget once they switch on pruning, because the majority of "bad" hyperparameter combinations (too-high learning rate, unstable architecture choice) become visibly bad within the first 10–20% of training. Pruning doesn't just save time — it changes what's feasible: a search that would take a week without pruning might complete overnight with it, which is often the difference between tuning thoroughly and not tuning at all before a deadline.

6 Visualizing the Search

One of Optuna's most practically useful features is its built-in visualization module, which turns the trial history into plots that explain why the search converged where it did — invaluable both for debugging your search space and for explaining tuning decisions to teammates.

In [7]:
import optuna
import optuna.visualization as vis

# (continuing the study from Section 3 / 5)
# study = ... already populated with trials ...

# 1. Optimization history: best score so far at each trial
fig1 = vis.plot_optimization_history(study)
fig1.write_html("optimization_history.html")

# 2. Hyperparameter importance — which params actually drove the score?
#    Computed via fANOVA (functional analysis of variance) over the trial history
fig2 = vis.plot_param_importances(study)
fig2.write_html("param_importances.html")

# 3. Slice plot: score vs each individual hyperparameter
fig3 = vis.plot_slice(study, params=["max_depth", "learning_rate", "subsample"])
fig3.write_html("slice_plot.html")

# 4. Parallel coordinate plot: see how parameters co-vary in good vs bad trials
fig4 = vis.plot_parallel_coordinate(study)
fig4.write_html("parallel_coordinate.html")

# 5. Contour plot: interaction between two hyperparameters
fig5 = vis.plot_contour(study, params=["max_depth", "learning_rate"])
fig5.write_html("contour_plot.html")

# Print importance scores as plain numbers too (useful in notebooks/CI logs)
importances = optuna.importance.get_param_importances(study)
print("Hyperparameter importance (fraction of variance explained):")
for name, score in importances.items():
    bar = "█" * int(score * 40)
    print(f"  {name:18s} {score:.3f}  {bar}")
Out[7]:
Hyperparameter importance (fraction of variance explained): learning_rate 0.512 ████████████████████ max_depth 0.231 █████████ subsample 0.158 ██████ colsample_bytree 0.099 ███

This kind of breakdown often surprises people: in many gradient-boosting searches, learning_rate alone explains over half the variance in validation score, while parameters you might have spent a lot of search budget on (like colsample_bytree) barely matter. Once you see this, you can narrow the search range for low-importance parameters and devote more trials to the ones that matter — exactly the "two-stage coarse-then-fine" discipline from Lesson 27, but now data-driven instead of guessed.

💡
Use the Slice Plot to Catch a Misconfigured Search Range

If plot_slice shows the best trials clustering at the very edge of a parameter's allowed range (e.g. all good trials have max_depth near your upper bound of 10), that's a strong signal your search range is too narrow — widen it and re-run. This single check catches one of the most common and easily-avoidable tuning mistakes: an artificially constrained search space silently capping your final model's quality.

7 Multi-Objective Optimization

Real deployment decisions are rarely about a single metric. A fraud model that is 0.3% more accurate but takes 4× longer to score a transaction at checkout may not be worth shipping. Optuna supports multi-objective optimization natively: return a tuple of objectives from your function, and Optuna searches for the Pareto front — the set of trials where no objective can be improved without worsening another.

In [8]:
import optuna
import xgboost as xgb
import numpy as np
import time
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

X, y = load_breast_cancer(return_X_y=True)
X_train, X_val, y_train, y_val = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

def multi_objective(trial: optuna.Trial):
    params = {
        "n_estimators":  trial.suggest_int("n_estimators", 20, 500),
        "max_depth":     trial.suggest_int("max_depth", 2, 12),
        "learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
        "objective":     "binary:logistic",
        "eval_metric":   "auc",
        "n_jobs":        1,   # single-threaded so latency measurement is meaningful
    }
    model = xgb.XGBClassifier(**params)
    model.fit(X_train, y_train)

    val_pred = model.predict_proba(X_val)[:, 1]
    auc = roc_auc_score(y_val, val_pred)

    # Measure realistic single-row inference latency (what matters at serving time)
    single_row = X_val[:1]
    n_repeats = 200
    t0 = time.perf_counter()
    for _ in range(n_repeats):
        model.predict_proba(single_row)
    latency_ms = (time.perf_counter() - t0) / n_repeats * 1000

    # Return (objective_1, objective_2) -- Optuna tracks both
    return auc, latency_ms


study = optuna.create_study(
    directions=["maximize", "minimize"],     # maximize AUC, minimize latency
    sampler=optuna.samplers.NSGAIISampler(seed=42),
)
study.optimize(multi_objective, n_trials=80)

print(f"Number of trials on the Pareto front: {len(study.best_trials)}")
print(f"\n{'AUC':>8s} {'Latency (ms)':>14s}   n_estimators  max_depth  learning_rate")
for t in sorted(study.best_trials, key=lambda t: -t.values[0])[:8]:
    auc, latency = t.values
    p = t.params
    print(f"{auc:8.4f} {latency:14.3f}   {p['n_estimators']:12d}  {p['max_depth']:9d}  {p['learning_rate']:.4f}")
Out[8]:
Number of trials on the Pareto front: 11 AUC Latency (ms) n_estimators max_depth learning_rate 0.9951 4.812 480 11 0.0186 0.9948 2.207 310 9 0.0241 0.9939 1.105 180 7 0.0512 0.9921 0.563 95 5 0.0894 0.9887 0.301 48 4 0.1340 0.9810 0.184 24 3 0.2100

Every row in this table is a legitimate "best" answer — none is strictly worse than another in both dimensions simultaneously. The choice of which Pareto-optimal point to deploy is a business decision, not an optimization one: a fraud-detection system gating checkout in real time might accept the 0.9921 AUC model at 0.56ms over the 0.9951 AUC model at 4.8ms, because the latency budget is hard-capped.

🔑
The Pareto Front Is the Deliverable, Not a Single Number

With single-objective tuning, study.best_params gives you one answer. With multi-objective tuning, there is no single "best" — study.best_trials returns the entire Pareto front, and you (or a downstream business rule) pick a point on it after the fact based on constraints that may change over time (a new latency SLA, a new accuracy floor). This is also why it's worth keeping the whole Pareto front in your model registry rather than just the single model you initially chose to deploy — next quarter's latency budget might be different.

8 Integrating Optuna with PyTorch Training Loops

You already know how to write a PyTorch training loop. Wrapping one in an Optuna objective mostly means: (1) sample architecture/optimizer hyperparameters at the top of the function, (2) report validation metrics after each epoch so the pruner can act, and (3) raise optuna.TrialPruned() when told to stop.

In [9]:
import optuna
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

device = "cuda" if torch.cuda.is_available() else "cpu"

# Assume train_ds / val_ds are already-prepared TensorDatasets (e.g. MNIST-like)
# train_ds = TensorDataset(X_train, y_train)
# val_ds   = TensorDataset(X_val, y_val)

def build_model(trial, input_dim, n_classes):
    n_layers = trial.suggest_int("n_layers", 1, 3)
    layers, in_dim = [], input_dim
    for i in range(n_layers):
        out_dim = trial.suggest_int(f"n_units_l{i}", 32, 256, step=32)
        layers += [nn.Linear(in_dim, out_dim), nn.ReLU()]
        dropout = trial.suggest_float(f"dropout_l{i}", 0.0, 0.5)
        layers.append(nn.Dropout(dropout))
        in_dim = out_dim
    layers.append(nn.Linear(in_dim, n_classes))
    return nn.Sequential(*layers).to(device)


def objective(trial: optuna.Trial) -> float:
    model = build_model(trial, input_dim=784, n_classes=10)

    optimizer_name = trial.suggest_categorical("optimizer", ["Adam", "RMSprop", "SGD"])
    lr = trial.suggest_float("lr", 1e-5, 1e-1, log=True)
    optimizer = getattr(optim, optimizer_name)(model.parameters(), lr=lr)

    batch_size = trial.suggest_categorical("batch_size", [32, 64, 128, 256])
    train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
    val_loader   = DataLoader(val_ds, batch_size=256)

    criterion = nn.CrossEntropyLoss()
    n_epochs = 20

    for epoch in range(n_epochs):
        model.train()
        for xb, yb in train_loader:
            xb, yb = xb.to(device), yb.to(device)
            optimizer.zero_grad()
            loss = criterion(model(xb), yb)
            loss.backward()
            optimizer.step()

        # Validation pass
        model.eval()
        correct, total = 0, 0
        with torch.no_grad():
            for xb, yb in val_loader:
                xb, yb = xb.to(device), yb.to(device)
                preds = model(xb).argmax(dim=1)
                correct += (preds == yb).sum().item()
                total += yb.size(0)
        val_acc = correct / total

        # Report to Optuna at the end of each epoch, then check for pruning
        trial.report(val_acc, step=epoch)
        if trial.should_prune():
            raise optuna.TrialPruned()

    return val_acc


study = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=42),
    pruner=optuna.pruners.HyperbandPruner(min_resource=1, max_resource=20, reduction_factor=3),
)
study.optimize(objective, n_trials=40, timeout=3600)  # stop after 1 hour regardless

print(f"Best validation accuracy: {study.best_value:.4f}")
print(f"Best config: {study.best_params}")
⚠️
Conditional Search Spaces Change Trial-to-Trial — That's Fine

Notice build_model only samples n_units_l2 and dropout_l2 when n_layers == 3. This means different trials have genuinely different sets of active parameters — something GridSearchCV's flat param_grid cannot express at all. TPE handles this natively because it models each parameter's conditional distribution independently; you don't need to enumerate every architecture shape up front. This is one of the most underrated reasons to move from scikit-learn's search tools to Optuna once your search space has structure like "how many layers, and a per-layer width."

Optuna's Native PyTorch Pruning Helper

For standard training loops, Optuna also ships optuna.integration helpers (e.g. for PyTorch Lightning, Keras, LightGBM, XGBoost) that wire up reporting and pruning automatically so you don't hand-write the trial.report / should_prune boilerplate. For a custom loop like the one above, the manual pattern shown is the most transparent and portable approach, and is what most production codebases actually use.

9 Persistent Studies: Surviving Restarts and Parallelizing

By default, an Optuna study lives only in memory — if your process crashes mid-search, every trial is lost. For anything beyond a quick experiment, back the study with a relational database via storage. This also unlocks running multiple worker processes (or machines) against the same study concurrently, each pulling the next suggested trial from the shared store.

In [10]:
import optuna

# SQLite for local persistence (single machine, survives crashes/restarts)
study = optuna.create_study(
    study_name="xgb_fraud_tuning",
    storage="sqlite:///optuna_studies.db",
    direction="maximize",
    load_if_exists=True,   # resume the study if it already exists, instead of erroring
)
study.optimize(objective, n_trials=20)   # run a further batch of trials
print(f"Study now has {len(study.trials)} total trials")
print(f"Best so far: {study.best_value:.4f}")

# For multi-machine parallel search, use a networked database, e.g. PostgreSQL/MySQL:
#   storage="postgresql://user:password@db-host:5432/optuna_db"
# Then launch the same script on N machines -- they all read/write the same study
# and each worker independently calls study.optimize(objective, n_trials=...).

# Inspect or resume a study later without re-running anything:
loaded_study = optuna.load_study(study_name="xgb_fraud_tuning",
                                  storage="sqlite:///optuna_studies.db")
print(f"\nResumed study '{loaded_study.study_name}' has "
      f"{len(loaded_study.trials)} trials on record.")
Out[10]:
Study now has 20 total trials Best so far: 0.9941 Resumed study 'xgb_fraud_tuning' has 20 trials on record.
🌍
Distributed Tuning Across a GPU Cluster

A common production pattern: a Kubernetes job spins up 8 worker pods, each with one GPU, all pointed at the same PostgreSQL-backed study via storage=. Optuna's storage layer handles trial coordination — workers never duplicate a configuration and the surrogate model is updated globally as results stream in from every worker. This turns "how long does tuning take" from a single-machine wall-clock question into a horizontally-scalable one: doubling worker count roughly halves wall-clock time for a fixed trial budget, with no code changes beyond pointing every worker at the same database URL.

🌍

Real-World Spotlight: Two Production Tuning Campaigns

Case 1 — Fraud Detection Under a Hard Latency Budget

A payments company (the same fraud use case from Lesson 27, now revisited) needs to re-score every transaction at checkout within a 5ms p99 latency budget. A grid/random search optimizing AUC alone — exactly what Lesson 27 demonstrated — previously produced a model that exceeded the latency budget under peak load. This time, the team uses Optuna's multi-objective search directly against both constraints.

In [11]:
import optuna
import xgboost as xgb
import numpy as np
import time
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

np.random.seed(42)
n = 200_000
X_fraud = np.random.randn(n, 20)
fraud_idx = np.random.choice(n, int(n * 0.004), replace=False)
X_fraud[fraud_idx, :6] += 1.1
y_fraud = np.zeros(n); y_fraud[fraud_idx] = 1

X_tr, X_val, y_tr, y_val = train_test_split(
    X_fraud, y_fraud, test_size=0.2, stratify=y_fraud, random_state=42)
scale_w = (y_tr == 0).sum() / (y_tr == 1).sum()

LATENCY_BUDGET_MS = 5.0

def objective(trial: optuna.Trial):
    params = {
        "n_estimators":     trial.suggest_int("n_estimators", 30, 400),
        "max_depth":        trial.suggest_int("max_depth", 2, 10),
        "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),
        "scale_pos_weight": scale_w,
        "n_jobs": 1,
    }
    model = xgb.XGBClassifier(**params)
    model.fit(X_tr, y_tr)

    auc = roc_auc_score(y_val, model.predict_proba(X_val)[:, 1])

    # Realistic p99-style single-transaction latency measurement
    row = X_val[:1]
    timings = []
    for _ in range(300):
        t0 = time.perf_counter()
        model.predict_proba(row)
        timings.append((time.perf_counter() - t0) * 1000)
    p99_latency = float(np.percentile(timings, 99))

    return auc, p99_latency

study = optuna.create_study(directions=["maximize", "minimize"],
                             sampler=optuna.samplers.NSGAIISampler(seed=42))
study.optimize(objective, n_trials=100)

# Filter the Pareto front down to trials that meet the hard latency SLA,
# then pick the highest-AUC survivor
feasible = [t for t in study.best_trials if t.values[1] <= LATENCY_BUDGET_MS]
chosen = max(feasible, key=lambda t: t.values[0])
print(f"Pareto front size: {len(study.best_trials)}  |  Feasible under {LATENCY_BUDGET_MS}ms: {len(feasible)}")
print(f"Chosen model: AUC={chosen.values[0]:.4f}  p99 latency={chosen.values[1]:.2f}ms")
print(f"Params: {chosen.params}")

The result: instead of discovering the latency violation in a post-hoc load test (and re-tuning from scratch), the latency constraint is baked directly into the search, and engineering simply filters the Pareto front for compliant points before picking the most accurate one.

Case 2 — Tuning a PyTorch CNN with Hyperband Pruning

A computer vision team needs to tune a CNN's architecture and training hyperparameters (you know CNNs from earlier in the course). Full training takes 25 minutes per configuration on their hardware, making a 200-trial random search infeasible (over 80 GPU-hours). They switch to Optuna with HyperbandPruner so unpromising trials are killed within the first few epochs.

In [12]:
import optuna
import torch, torch.nn as nn, torch.optim as optim

device = "cuda" if torch.cuda.is_available() else "cpu"

def build_cnn(trial):
    n_conv = trial.suggest_int("n_conv_layers", 2, 4)
    channels, in_ch = [], 3
    layers = []
    for i in range(n_conv):
        out_ch = trial.suggest_categorical(f"channels_l{i}", [16, 32, 64, 128])
        layers += [nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch),
                   nn.ReLU(), nn.MaxPool2d(2)]
        in_ch = out_ch
    layers += [nn.AdaptiveAvgPool2d(1), nn.Flatten(),
               nn.Linear(in_ch, 10)]
    return nn.Sequential(*layers).to(device)

def objective(trial: optuna.Trial) -> float:
    model = build_cnn(trial)
    lr = trial.suggest_float("lr", 1e-4, 5e-2, log=True)
    weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-2, log=True)
    optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
    criterion = nn.CrossEntropyLoss()

    max_epochs = 30
    for epoch in range(max_epochs):
        model.train()
        for xb, yb in train_loader:    # assume defined elsewhere (e.g. CIFAR-10)
            xb, yb = xb.to(device), yb.to(device)
            optimizer.zero_grad()
            criterion(model(xb), yb).backward()
            optimizer.step()

        model.eval()
        correct, total = 0, 0
        with torch.no_grad():
            for xb, yb in val_loader:
                xb, yb = xb.to(device), yb.to(device)
                correct += (model(xb).argmax(1) == yb).sum().item()
                total += yb.size(0)
        val_acc = correct / total

        trial.report(val_acc, step=epoch)
        if trial.should_prune():
            raise optuna.TrialPruned()

    return val_acc

pruner = optuna.pruners.HyperbandPruner(min_resource=2, max_resource=30, reduction_factor=3)
study = optuna.create_study(direction="maximize",
                             sampler=optuna.samplers.TPESampler(seed=42),
                             pruner=pruner)
study.optimize(objective, n_trials=120, timeout=8 * 3600)  # 8-hour budget

pruned_frac = sum(t.state == optuna.trial.TrialState.PRUNED for t in study.trials) / len(study.trials)
print(f"Trials run: {len(study.trials)}   Pruned fraction: {pruned_frac:.0%}")
print(f"Best val accuracy: {study.best_value:.4f}")
print(f"Best architecture: {study.best_params}")
Out[12]:
Trials run: 120 Pruned fraction: 78% Best val accuracy: 0.9183 Best architecture: {'n_conv_layers': 3, 'channels_l0': 32, 'channels_l1': 64, 'channels_l2': 128, 'lr': 0.0041, 'weight_decay': 3.2e-05}

With 78% of trials pruned — most within the first few epochs — the team completed 120 trials in the same 8-hour budget that would otherwise have allowed only around 25–30 full training runs, more than tripling the effective search coverage at no extra hardware cost.

✍️ Practice Exercises

  1. Take a RandomForestClassifier tuning problem you previously solved with RandomizedSearchCV in Lesson 27 (or recreate one on the Wine or Breast Cancer dataset). Re-implement it as an Optuna study with the TPESampler using the same number of trials/iterations. Compare the best cross-validated score and the wall-clock time of both approaches.
  2. Add a MedianPruner to an iterative model (XGBoost or LightGBM, reporting validation AUC after every boosting round). Run the study twice — once with pruning enabled and once disabled — using the same trial budget and random seed. Report how many trials were pruned and the total wall-clock time saved.
  3. Set up a multi-objective study that tunes a gradient-boosted model for both validation accuracy and model size (e.g. n_estimators × max_depth as a cheap proxy for memory footprint, or the actual pickled file size in KB). Plot or print the Pareto front and pick a point you'd recommend for a mobile/edge deployment with a strict memory budget.
  4. Use optuna.visualization.plot_param_importances and plot_slice on one of your studies above. Identify the single most important hyperparameter, then run a second, narrower study that fixes the unimportant hyperparameters to reasonable defaults and spends the entire trial budget refining only the top 1–2 important ones. Does the narrower, focused search reach a better score in fewer trials?
▶ Show Solution (Exercise 1 — Optuna vs RandomizedSearchCV)
In [13]:
import time
import optuna
import numpy as np
from scipy.stats import randint
from sklearn.datasets import load_wine
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import (RandomizedSearchCV, StratifiedKFold,
                                      cross_val_score, train_test_split)

X, y = load_wine(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2,
                                           stratify=y, random_state=42)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
N_TRIALS = 40

# ── Approach 1: RandomizedSearchCV (Lesson 27 baseline) ──
param_dist = {
    "n_estimators":      randint(50, 400),
    "max_depth":         randint(2, 30),
    "min_samples_split": randint(2, 20),
    "min_samples_leaf":  randint(1, 10),
}
t0 = time.time()
rand_search = RandomizedSearchCV(
    RandomForestClassifier(random_state=42, n_jobs=-1),
    param_distributions=param_dist, n_iter=N_TRIALS, cv=cv,
    scoring="accuracy", random_state=42, n_jobs=-1,
)
rand_search.fit(X_tr, y_tr)
rand_time = time.time() - t0
print(f"RandomizedSearchCV — best CV acc: {rand_search.best_score_:.4f}  "
      f"(in {rand_time:.1f}s, {N_TRIALS} fits)")

# ── Approach 2: Optuna with TPESampler ──
def objective(trial):
    params = {
        "n_estimators":      trial.suggest_int("n_estimators", 50, 400),
        "max_depth":         trial.suggest_int("max_depth", 2, 30),
        "min_samples_split": trial.suggest_int("min_samples_split", 2, 20),
        "min_samples_leaf":  trial.suggest_int("min_samples_leaf", 1, 10),
    }
    model = RandomForestClassifier(**params, random_state=42, n_jobs=-1)
    return cross_val_score(model, X_tr, y_tr, cv=cv, scoring="accuracy").mean()

optuna.logging.set_verbosity(optuna.logging.WARNING)
t0 = time.time()
study = optuna.create_study(direction="maximize",
                             sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=N_TRIALS)
optuna_time = time.time() - t0
print(f"Optuna (TPE)        — best CV acc: {study.best_value:.4f}  "
      f"(in {optuna_time:.1f}s, {N_TRIALS} trials)")

# ── Compare on the held-out test set ──
from sklearn.metrics import accuracy_score
rand_test_acc = accuracy_score(y_te, rand_search.best_estimator_.predict(X_te))

best_optuna_model = RandomForestClassifier(**study.best_params, random_state=42, n_jobs=-1)
best_optuna_model.fit(X_tr, y_tr)
optuna_test_acc = accuracy_score(y_te, best_optuna_model.predict(X_te))

print(f"\nHeld-out test accuracy — RandomizedSearchCV: {rand_test_acc:.4f}")
print(f"Held-out test accuracy — Optuna (TPE):       {optuna_test_acc:.4f}")

📚 Primary Source for This Lesson

Optuna: A Next-generation Hyperparameter Optimization Framework
The official Optuna documentation covers samplers, pruners, multi-objective optimization, distributed studies, and integration modules in depth. Also recommended: Akiba et al. (2019) "Optuna: A Next-generation Hyperparameter Optimization Framework" (KDD), the original paper introducing TPE-based define-by-run search and the pruning architecture used throughout this lesson.

💬 Not sure whether to use MedianPruner or HyperbandPruner for your training loop? Or how to design an objective function for a multi-stage pipeline? Describe your model and trial cost — your tutor will help you pick a sampler/pruner combination and sanity-check your search space.