Part V: Discovery Through Simulation & Optimization
Chapter 45: Optimization For Discovery

45.1 Bayesian Optimization

"I placed a Gaussian process over all possible experiments. It placed a Gaussian process over all possible me's. We reached a Nash equilibrium around coffee time."

A Bayesian Prior With Strong Opinions
The Big Picture

When each evaluation of your objective function costs hours of lab time or thousands of GPU-hours, you cannot afford to evaluate it a million times. Bayesian optimization (BO) solves this by building a cheap probabilistic surrogate model of the expensive objective, then using that surrogate to decide intelligently where to evaluate next. The surrogate provides not just a prediction but an uncertainty estimate, enabling a principled trade-off between exploring uncertain regions and exploiting promising ones. This section derives the machinery from scratch, then shows you how to use it through BoTorch and Optuna.

A surrogate model is a fast statistical approximation that stands in for the true objective function, which is too expensive to query freely. It matters because the surrogate lets you reason about the entire search space after only a handful of real evaluations, predicting both the expected outcome and the confidence interval at every candidate point. The mechanism is straightforward: after each real evaluation, the surrogate (typically a Gaussian process) updates its posterior distribution over all possible function shapes, and an acquisition function scores where the next evaluation would yield the most information or improvement. Use a surrogate-based approach like BO when evaluations cost significant time or money (minutes to hours each) and the search space has fewer than roughly 20 continuous dimensions; for cheap evaluations or very high-dimensional spaces, grid search, random search, or evolutionary methods are more practical.

1. The Black-Box Optimization Problem

A single neural architecture search can burn tens of thousands of GPU-hours if every candidate is evaluated from scratch. When each evaluation costs that much, choosing the wrong next experiment is not merely wasteful; it can exhaust the entire project budget before any useful result emerges. Bayesian optimization exists precisely for these high-stakes, low-budget regimes.

We seek to find the input \(\mathbf{x}^* \in \mathcal{X}\) that maximizes an unknown function \(f\):

$$\mathbf{x}^* = \arg\max_{\mathbf{x} \in \mathcal{X}} f(\mathbf{x})$$

where \(\mathcal{X} \subseteq \mathbb{R}^d\) is a bounded search space and \(f\) has no known closed form. Each evaluation \(y = f(\mathbf{x}) + \varepsilon\) is expensive, and noise \(\varepsilon \sim \mathcal{N}(0, \sigma^2_n)\) may corrupt the observation. We have no access to gradients \(\nabla f\). The budget \(N\) is small, typically 20 to a few hundred evaluations. Scientific discovery operates in exactly this regime: a lab can synthesize and test a modest number of compounds, run a limited set of simulations, or conduct a bounded number of physical experiments. In short: Bayesian optimization treats every expensive evaluation as precious, using a probabilistic surrogate to make each one count.

Key Insight

Bayesian optimization inverts the usual machine learning workflow. In supervised learning, data is cheap and the model is the deliverable. In BO, the model (a Gaussian process) is cheap and exists only to guide the selection of the next expensive data point. The Gaussian process is a means, not an end.

2. Gaussian Process Surrogates

A Gaussian process (GP) is a distribution over functions: any finite collection of function values \(\mathbf{f} = [f(\mathbf{x}_1), \ldots, f(\mathbf{x}_n)]^T\) follows a multivariate Gaussian distribution. A GP is fully specified by a mean function \(m(\mathbf{x})\) and a covariance (kernel) function \(k(\mathbf{x}, \mathbf{x}')\):

$$f(\mathbf{x}) \sim \mathcal{GP}\bigl(m(\mathbf{x}),\; k(\mathbf{x}, \mathbf{x}')\bigr)$$

The kernel encodes our prior beliefs about \(f\): smoothness, periodicity, length scale, and amplitude. The most common choice is the Matérn 5/2 kernel:

$$k_{\text{M52}}(\mathbf{x}, \mathbf{x}') = \sigma^2_f \left(1 + \sqrt{5}r + \frac{5}{3}r^2\right) \exp\left(-\sqrt{5}r\right)$$

where \(r = \sqrt{\sum_{i=1}^d \left(\frac{x_i - x'_i}{\ell_i}\right)^2}\) and \(\ell_i\) are per-dimension length scales. The Matérn 5/2 is twice differentiable (smoother than Matérn 3/2, less smooth than the squared-exponential), which tends to match the regularity of many physical and chemical response surfaces.

2.1 Posterior Inference

Given \(n\) observations \(\mathcal{D}_n = \{(\mathbf{x}_i, y_i)\}_{i=1}^n\), the GP posterior at a new point \(\mathbf{x}_*\) is also Gaussian with closed-form mean and variance:

$$\mu(\mathbf{x}_*) = \mathbf{k}_*^T (\mathbf{K} + \sigma^2_n \mathbf{I})^{-1} \mathbf{y}$$ $$\sigma^2(\mathbf{x}_*) = k(\mathbf{x}_*, \mathbf{x}_*) - \mathbf{k}_*^T (\mathbf{K} + \sigma^2_n \mathbf{I})^{-1} \mathbf{k}_*$$

where \(\mathbf{K}\) is the \(n \times n\) kernel matrix with entries \(K_{ij} = k(\mathbf{x}_i, \mathbf{x}_j)\), \(\mathbf{k}_*\) is the \(n\)-vector with entries \(k(\mathbf{x}_i, \mathbf{x}_*)\), and \(\mathbf{y} = [y_1, \ldots, y_n]^T\). The posterior mean \(\mu(\mathbf{x}_*)\) is our best estimate of \(f(\mathbf{x}_*)\); the posterior variance \(\sigma^2(\mathbf{x}_*)\) quantifies how uncertain we are. Points far from any observation have high variance; points near many observations have low variance.

Checkpoint

So far: a Gaussian process defines a distribution over functions via a mean function and a kernel; given observed data, the GP posterior provides a closed-form predicted mean and uncertainty at any new point, which together will drive the acquisition function that selects the next evaluation.

The following class implements this from scratch, fitting a GP with a squared exponential kernel and predicting with calibrated uncertainties.

import numpy as np
from scipy.linalg import cho_solve, cho_factor
from scipy.optimize import minimize

class GaussianProcessRegressor:
    """Minimal GP regressor for Bayesian optimization.

    Uses the squared exponential kernel with per-dimension length scales
    and optimizes hyperparameters via marginal likelihood.
    """

    def __init__(self, noise: float = 1e-6):
        self.noise = noise
        self.length_scales = None
        self.signal_var = 1.0
        self.X_train = None
        self.y_train = None
        self._L = None  # Cholesky factor
        self._alpha = None  # Precomputed weights

    def _kernel(self, X1: np.ndarray, X2: np.ndarray) -> np.ndarray:
        """Squared exponential kernel with ARD length scales."""
        X1_scaled = X1 / self.length_scales
        X2_scaled = X2 / self.length_scales
        sq_dists = (
            np.sum(X1_scaled**2, axis=1, keepdims=True)
            + np.sum(X2_scaled**2, axis=1)
            - 2 * X1_scaled @ X2_scaled.T
        )
        return self.signal_var * np.exp(-0.5 * sq_dists)

    def _neg_log_marginal_likelihood(self, theta: np.ndarray) -> float:
        """Negative log marginal likelihood for hyperparameter optimization."""
        d = self.X_train.shape[1]
        self.length_scales = np.exp(theta[:d])
        self.signal_var = np.exp(theta[d])

        K = self._kernel(self.X_train, self.X_train)
        K += self.noise * np.eye(len(K))

        try:
            L = cho_factor(K)
            alpha = cho_solve(L, self.y_train)
            # log p(y|X,theta) = -0.5 * y^T alpha - sum(log(diag(L))) - n/2 log(2pi)
            nll = (
                0.5 * self.y_train @ alpha
                + np.sum(np.log(np.diag(L[0])))
                + 0.5 * len(self.y_train) * np.log(2 * np.pi)
            )
            return nll
        except np.linalg.LinAlgError:
            return 1e10  # Return large value for non-PD matrices

    def fit(self, X: np.ndarray, y: np.ndarray) -> "GaussianProcessRegressor":
        """Fit the GP by optimizing kernel hyperparameters."""
        self.X_train = np.asarray(X, dtype=np.float64)
        self.y_train = np.asarray(y, dtype=np.float64)
        d = X.shape[1]

        # Initialize length scales to data range, signal variance to data variance
        self.length_scales = np.ones(d)
        theta0 = np.zeros(d + 1)  # log length scales + log signal variance

        result = minimize(
            self._neg_log_marginal_likelihood,
            theta0,
            method="L-BFGS-B",
            bounds=[(-3, 3)] * (d + 1),
        )
        self.length_scales = np.exp(result.x[:d])
        self.signal_var = np.exp(result.x[d])

        # Precompute for prediction
        K = self._kernel(self.X_train, self.X_train)
        K += self.noise * np.eye(len(K))
        self._L = cho_factor(K)
        self._alpha = cho_solve(self._L, self.y_train)
        return self

    def predict(self, X: np.ndarray) -> tuple:
        """Return posterior mean and standard deviation."""
        X = np.asarray(X, dtype=np.float64)
        K_star = self._kernel(X, self.X_train)
        mu = K_star @ self._alpha

        v = cho_solve(self._L, K_star.T)
        var = self.signal_var - np.sum(K_star.T * v, axis=0)
        var = np.maximum(var, 1e-10)  # Numerical floor
        return mu, np.sqrt(var)
Listing 45.1: A minimal Gaussian process regressor with ARD (Automatic Relevance Determination) length scales, where each input dimension gets its own learned length scale. Hyperparameters are optimized via log marginal likelihood, and the Cholesky decomposition (a numerically stable matrix factorization) provides O(n^2) prediction after the O(n^3) fit.

3. Acquisition Functions

The GP posterior gives us a belief about \(f\) everywhere in \(\mathcal{X}\). The acquisition function \(\alpha(\mathbf{x})\) converts this belief into a scalar score that quantifies the "utility" of evaluating \(f\) at \(\mathbf{x}\). We maximize the acquisition function (which is cheap, since it uses only the GP posterior) to select the next evaluation point:

$$\mathbf{x}_{n+1} = \arg\max_{\mathbf{x} \in \mathcal{X}} \alpha(\mathbf{x} \mid \mathcal{D}_n)$$

Three acquisition functions dominate practice:

Mental Model

Think of acquisition functions as a restaurant strategy in an unfamiliar city. You have read some reviews (the GP posterior) and must choose where to eat tonight. Probability of Improvement is like picking any restaurant rated above your current favorite, even if only by a fraction of a star. Expected Improvement is like picking the restaurant that, on average, would raise your best dining experience the most, weighing both the predicted quality and how uncertain the reviews are (a new place with few but glowing reviews might beat a well reviewed safe choice). Upper Confidence Bound is like adding bonus points for restaurants with very few reviews, on the optimistic assumption that unknown places might be spectacular. Each strategy uses the same review data differently: PI asks "will it be better at all?", EI asks "by how much, on average?", and UCB asks "what if I assume the best?"

3.1 Probability of Improvement (PI)

Let \(f^+ = \max_{i=1}^n y_i\) be the best observed value. PI measures the probability that the new point exceeds \(f^+\):

$$\alpha_{\text{PI}}(\mathbf{x}) = \Phi\left(\frac{\mu(\mathbf{x}) - f^+ - \xi}{\sigma(\mathbf{x})}\right)$$

where \(\Phi\) is the standard normal CDF and \(\xi \geq 0\) is a small "jitter" that prevents pure exploitation. PI is simple but greedy: it weights a tiny improvement the same as a massive one.

3.2 Expected Improvement (EI)

Derivation: Expected Improvement

Expected Improvement (EI) measures the expected amount by which the new evaluation improves over \(f^+\). Define the improvement as \(I(\mathbf{x}) = \max(f(\mathbf{x}) - f^+, 0)\). Under the GP posterior, \(f(\mathbf{x}) \sim \mathcal{N}(\mu, \sigma^2)\) (dropping the \(\mathbf{x}\) subscripts for brevity). The expected improvement is:

$$\alpha_{\text{EI}}(\mathbf{x}) = \mathbb{E}[\max(f(\mathbf{x}) - f^+, 0)]$$

Let \(z = \frac{f - \mu}{\sigma}\) so \(f = \mu + \sigma z\) with \(z \sim \mathcal{N}(0,1)\). The improvement is positive when \(f > f^+\), i.e., when \(z > \frac{f^+ - \mu}{\sigma}\). Define \(u = \frac{\mu - f^+}{\sigma}\). Then:

$$\alpha_{\text{EI}} = \int_{-u}^{\infty} (\mu + \sigma z - f^+) \phi(z)\, dz$$ $$= (\mu - f^+) \int_{-u}^{\infty} \phi(z)\, dz + \sigma \int_{-u}^{\infty} z\, \phi(z)\, dz$$ $$= (\mu - f^+)\, \Phi(u) + \sigma\, \phi(u)$$

where \(\phi\) and \(\Phi\) are the standard normal PDF and CDF. This closed-form expression has two intuitive terms: the first rewards points with high predicted mean (exploitation); the second rewards points with high uncertainty (exploration). When \(\sigma(\mathbf{x}) = 0\), we define \(\alpha_{\text{EI}} = 0\).

The EI formula balances exploration and exploitation without a tuning parameter, making it the default choice in most BO implementations. (This is a key reason EI is often preferred over UCB in practice: UCB requires tuning \(\beta_t\) per problem, while EI delivers a principled explore/exploit balance out of the box.) The implementation follows:

from scipy.stats import norm

def expected_improvement(
    X_candidates: np.ndarray,
    gp: GaussianProcessRegressor,
    y_best: float,
    xi: float = 0.01,
) -> np.ndarray:
    """Compute Expected Improvement at candidate points.

    Parameters
    ----------
    X_candidates : array of shape (m, d)
        Points at which to evaluate the acquisition function.
    gp : GaussianProcessRegressor
        Fitted GP surrogate.
    y_best : float
        Best observed value so far.
    xi : float
        Exploration-exploitation trade-off parameter (small positive).

    Returns
    -------
    ei : array of shape (m,)
        Expected improvement values.
    """
    mu, sigma = gp.predict(X_candidates)

    with np.errstate(divide="ignore", invalid="ignore"):
        improvement = mu - y_best - xi
        Z = improvement / sigma
        ei = improvement * norm.cdf(Z) + sigma * norm.pdf(Z)
        ei[sigma < 1e-10] = 0.0  # No improvement where GP is certain

    return ei
Listing 45.2: Expected Improvement acquisition function using the closed-form derivation above. The xi parameter adds a small exploration bonus, preventing the optimizer from getting stuck at a known good point.

3.3 Upper Confidence Bound (UCB)

The GP-UCB acquisition function adds a scaled uncertainty bonus to the posterior mean:

$$\alpha_{\text{UCB}}(\mathbf{x}) = \mu(\mathbf{x}) + \beta_t \sigma(\mathbf{x})$$

where \(\beta_t\) controls the exploration-exploitation balance and can be set to achieve sublinear regret bounds. Srinivas et al. (2010) proved that \(\beta_t = 2 \log(|\mathcal{X}| t^2 \pi^2 / 6\delta)\) yields cumulative regret (the total shortfall between the values chosen and the true optimum) of \(O(\sqrt{T \gamma_T \log T})\), where \(\gamma_T\) is the maximum information gain (a measure of how much the GP can learn about \(f\) from \(T\) observations, determined by the kernel's eigenvalue decay). In practice, \(\beta_t\) between 1 and 3 works well. This connects directly to the UCB strategy from Section 1.2, but now applied to continuous spaces via the GP posterior.

Common Misconception

A frequent misunderstanding is that Bayesian optimization guarantees finding the global optimum if you run enough iterations. In reality, BO is a heuristic: the GP surrogate can misspecify the true function's smoothness, the kernel's length scales can cause the model to "smooth over" sharp optima, and the acquisition function can converge prematurely to a local basin. BO is provably no-regret only under specific assumptions (known kernel, bounded Reproducing Kernel Hilbert Space (RKHS) norm, where RKHS is a function space in which the kernel defines an inner product that measures function smoothness) that rarely hold exactly in practice. What BO does guarantee is principled sample efficiency, finding a good solution with far fewer evaluations than uninformed search, but "good" is not the same as "globally optimal."

4. The Bayesian Optimization Loop

These two components, the GP surrogate and the acquisition function, combine into a single iterative algorithm. The loop is illustrated in Figure 45.1.

Initialize random samples Fit GP surrogate model Maximize Acquisition select next x Evaluate Objective expensive f(x) Budget? done add (x, y) to dataset
Figure 45.1: The Bayesian optimization loop. After random initialization, the algorithm iterates: fit the GP surrogate to all observations, maximize the acquisition function to select the next query point, evaluate the expensive objective, and repeat until the evaluation budget is exhausted.

The complete BO algorithm iterates three steps: (1) fit the GP to all observations, (2) maximize the acquisition function to select the next query point, and (3) evaluate the expensive objective at that point. The loop begins with a Latin hypercube initialization (a space-filling sampling scheme that divides each dimension into equal-probability intervals, ensuring initial points cover the search space more uniformly than purely random sampling). Here is a from-scratch implementation: Figure 45.1.1 illustrates Bayesian optimization loop with GP surrogate and acquisition function.

Bayesian optimization loop with GP surrogate and acquisition function
Figure 45.1.1: The Bayesian optimization loop visualized on a 1-D objective. The GP surrogate (middle) approximates the true function (top) with calibrated uncertainty, and the Expected Improvement acquisition function (bottom) selects the next evaluation point where the potential gain is highest.
from typing import Callable

def bayesian_optimization(
    objective: Callable,
    bounds: np.ndarray,
    n_init: int = 5,
    n_iter: int = 25,
    seed: int = 42,
) -> dict:
    """Run Bayesian optimization on a black-box objective.

    Parameters
    ----------
    objective : callable
        Function f(x) -> float to maximize. x is a 1-D array.
    bounds : array of shape (d, 2)
        Lower and upper bounds for each dimension.
    n_init : int
        Number of initial random evaluations (Latin hypercube).
    n_iter : int
        Number of BO iterations after initialization.
    seed : int
        Random seed.

    Returns
    -------
    dict with keys 'X' (all evaluated points), 'y' (all values),
    'best_x', 'best_y', 'history' (best value at each step).
    """
    rng = np.random.default_rng(seed)
    d = bounds.shape[0]

    # Latin hypercube initialization
    X = rng.uniform(bounds[:, 0], bounds[:, 1], size=(n_init, d))
    y = np.array([objective(x) for x in X])

    history = [np.max(y)]
    gp = GaussianProcessRegressor(noise=1e-4)

    for i in range(n_iter):
        # Step 1: Fit GP to all observations
        gp.fit(X, y)

        # Step 2: Maximize acquisition function via random + local search
        n_candidates = 5000
        X_cand = rng.uniform(bounds[:, 0], bounds[:, 1], size=(n_candidates, d))
        ei = expected_improvement(X_cand, gp, y_best=np.max(y))

        # Refine top candidates with L-BFGS-B
        top_k = X_cand[np.argsort(ei)[-5:]]
        best_ei, best_x = -1, None
        for x0 in top_k:
            result = minimize(
                lambda x: -expected_improvement(x.reshape(1, -1), gp, np.max(y))[0],
                x0,
                bounds=list(zip(bounds[:, 0], bounds[:, 1])),
                method="L-BFGS-B",
            )
            if -result.fun > best_ei:
                best_ei = -result.fun
                best_x = result.x

        # Step 3: Evaluate the expensive objective
        y_new = objective(best_x)
        X = np.vstack([X, best_x.reshape(1, -1)])
        y = np.append(y, y_new)
        history.append(np.max(y))

    best_idx = np.argmax(y)
    return {
        "X": X, "y": y,
        "best_x": X[best_idx], "best_y": y[best_idx],
        "history": history,
    }
Listing 45.3: Complete Bayesian optimization loop with Latin hypercube initialization, where initial points are spread evenly across each dimension rather than sampled purely at random. The acquisition function is maximized via a two-stage strategy: random sampling to find promising regions, followed by L-BFGS-B refinement from the top candidates.
Practical Example: Optimizing a Catalyst Composition

A materials science lab wants to find the ternary alloy composition (fractions of Pt, Pd, and Ru summing to 1) that maximizes oxygen reduction reaction (ORR) activity. Each electrochemical measurement takes 4 hours. With a budget of 30 experiments, random search explores only \(30 / \binom{20}{2} = 0.16\%\) of a coarse 5%-resolution grid. BO with EI typically finds a composition within 5% of the optimum after 15 to 20 evaluations, because the GP posterior learns that ORR activity varies smoothly with composition and concentrates evaluations near the ridge of high activity.

5. Practical BO with BoTorch

The from-scratch implementation above clarifies the mechanics, but production BO uses libraries that handle numerical stability, GPU acceleration, batch acquisition, and multi-output GPs. BoTorch, built on PyTorch and GPyTorch, is the standard for research-grade Bayesian optimization.

import torch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from botorch.acquisition import ExpectedImprovement
from botorch.optim import optimize_acqf
from gpytorch.mlls import ExactMarginalLogLikelihood

def botorch_bo_step(
    train_X: torch.Tensor,
    train_Y: torch.Tensor,
    bounds: torch.Tensor,
) -> torch.Tensor:
    """One step of BO using BoTorch: fit GP, optimize EI, return next point.

    Parameters
    ----------
    train_X : Tensor of shape (n, d)
        Observed inputs, scaled to [0, 1]^d.
    train_Y : Tensor of shape (n, 1)
        Observed outputs, standardized.
    bounds : Tensor of shape (2, d)
        Lower and upper bounds (typically 0 and 1).

    Returns
    -------
    next_x : Tensor of shape (1, d)
        The next point to evaluate.
    """
    # Fit a GP with automatic hyperparameter tuning
    gp = SingleTaskGP(train_X, train_Y)
    mll = ExactMarginalLogLikelihood(gp.likelihood, gp)
    fit_gpytorch_mll(mll)

    # Construct and optimize the EI acquisition function
    best_f = train_Y.max()
    ei = ExpectedImprovement(model=gp, best_f=best_f)

    next_x, acq_value = optimize_acqf(
        acq_function=ei,
        bounds=bounds,
        q=1,            # Single-point acquisition
        num_restarts=10,
        raw_samples=512,
    )
    return next_x
Listing 45.4: One BO iteration with BoTorch. The library handles GP fitting (including kernel hyperparameter optimization via marginal likelihood), acquisition function construction, and multi-start L-BFGS-B optimization of the acquisition surface.
Library Shortcut: Optuna

If you want BO without managing GP internals, Optuna provides a high-level interface. The same optimization problem reduces to about 15 lines:

import optuna

def objective(trial):
    x1 = trial.suggest_float("x1", 0.0, 1.0)
    x2 = trial.suggest_float("x2", 0.0, 1.0)
    return expensive_function(x1, x2)

study = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=42),
)
study.optimize(objective, n_trials=30)

print(f"Best: {study.best_value:.4f} at {study.best_params}")
Listing 45.4b: Optuna's high-level interface for black-box optimization using the TPE sampler. The trial.suggest_float API declaratively defines the search space, and Optuna handles sampling, pruning, and result tracking internally.

Optuna uses Tree-structured Parzen Estimators (TPE), where the search space is modeled by fitting separate density estimates to "good" observations (above a threshold) and "bad" observations, then sampling points that maximize the ratio of the two densities, by default rather than GPs. TPE models \(p(\mathbf{x} \mid y > y^*)\) and \(p(\mathbf{x} \mid y \leq y^*)\) separately, which scales better to high dimensions but provides less calibrated uncertainty. For problems with \(d < 20\) and expensive evaluations, BoTorch's GP-based approach is more sample-efficient. For hyperparameter tuning with \(d > 20\) and cheap evaluations, Optuna's TPE is the practical choice. As of Optuna v3.5 (2024), Optuna also offers a built-in GPSampler that uses a GP surrogate internally, narrowing the gap with BoTorch for low-dimensional problems while retaining Optuna's simple API. This reduces approximately 80 lines of GP fitting and acquisition optimization to 15 lines.

6. Batch Bayesian Optimization

The methods above select one evaluation at a time, but many real laboratories and compute clusters can run several experiments simultaneously.

When you can run experiments in parallel (multiple synthesis stations, a GPU cluster), you want to select a batch of \(q\) points simultaneously. Naive parallelization (selecting the top \(q\) EI points independently) fails because those points will cluster near the same optimum. Batch BO methods account for the correlations between pending evaluations.


The \(q\)-Expected Improvement (\(q\)-EI) acquisition function jointly optimizes over \(q\) candidate points:

$$\alpha_{q\text{-EI}}(\mathbf{x}_1, \ldots, \mathbf{x}_q) = \mathbb{E}\left[\max\left(\max_{j=1}^q f(\mathbf{x}_j) - f^+,\; 0\right)\right]$$

This integral has no closed form for \(q > 1\), but BoTorch computes it efficiently via Monte Carlo sampling from the GP posterior:

from botorch.acquisition import qExpectedImprovement

def batch_bo_step(
    train_X: torch.Tensor,
    train_Y: torch.Tensor,
    bounds: torch.Tensor,
    batch_size: int = 4,
) -> torch.Tensor:
    """Select a batch of points for parallel evaluation."""
    gp = SingleTaskGP(train_X, train_Y)
    mll = ExactMarginalLogLikelihood(gp.likelihood, gp)
    fit_gpytorch_mll(mll)

    qei = qExpectedImprovement(
        model=gp,
        best_f=train_Y.max(),
        sampler=torch.quasirandom.SobolEngine(dimension=1),
    )

    candidates, _ = optimize_acqf(
        acq_function=qei,
        bounds=bounds,
        q=batch_size,
        num_restarts=10,
        raw_samples=512,
    )
    return candidates  # Shape: (batch_size, d)
Listing 45.5: Batch Bayesian optimization with q-EI via BoTorch. The q candidates are optimized jointly so they spread across promising regions rather than clustering at a single peak.

7. When Bayesian Optimization Struggles

Batch acquisition broadens the practical reach of BO, yet even with parallelism the method carries fundamental assumptions about smoothness, dimensionality, and evaluation cost that do not always hold.

BO is not universally optimal. Its limitations define the boundaries of its applicability and motivate the evolutionary and RL methods in the following sections:

Research Frontier

A major barrier to BO adoption has been its poor scaling to high-dimensional search spaces. Eriksson and Jankowiak (2021) introduced SAASBO (Sparse Axis-Aligned Subspace Bayesian Optimization), which places sparsity-inducing half-Cauchy priors on GP length scales to automatically identify the few dimensions that matter. Building on this, Papenmeier et al. (2022) proposed BAxUS (Bayesian Optimization with Adaptively Expanding Subspaces), which dynamically grows the effective search dimensionality during optimization rather than fixing a random embedding up front. BAxUS achieved state-of-the-art results on problems with hundreds of nominal dimensions in the BayesBench benchmark suite, outperforming both TuRBO and SAASBO on several tasks. These methods make BO competitive with evolutionary approaches in spaces that were previously considered too large, provided the objective truly depends on a low-dimensional substructure.

Connection: From Search to Optimization

The bandit algorithms from Section 1.2 are the finite-armed ancestors of BO. UCB for bandits chooses the arm with the highest \(\hat{\mu}_i + c\sqrt{\log t / n_i}\); GP-UCB chooses the point with the highest \(\mu(\mathbf{x}) + \beta_t \sigma(\mathbf{x})\). The GP generalizes the per-arm statistics to a continuous function, and the kernel encodes the assumption that "nearby arms" (points in input space) have similar rewards (function values). The Bayesian perspective from Chapter 32 provides the formal justification for treating function uncertainty as a posterior distribution.

8. Discovery Workbench Integration

The Discovery Workbench (introduced in Chapter 6) gains a BayesianOptimizer component that wraps the BO loop above. The optimizer accepts any objective function registered through the Workbench's experiment runner. It logs all evaluations, GP hyperparameters, and acquisition function values to the experiment registry (Chapter 47). These logs enable retrospective analysis of the optimization trajectory: which regions did the GP explore? Where did EI disagree with the final optimum? Did the length-scale estimates converge?

from discovery_workbench import Optimizer, ExperimentRegistry

class WorkbenchBayesianOptimizer(Optimizer):
    """BO optimizer integrated with the Discovery Workbench."""

    def __init__(self, bounds, registry: ExperimentRegistry, **kwargs):
        self.bounds = torch.tensor(bounds, dtype=torch.float64)
        self.registry = registry
        self.train_X = torch.empty(0, len(bounds))
        self.train_Y = torch.empty(0, 1)

    def suggest(self, n_suggestions: int = 1) -> list:
        """Suggest next experiment(s) to run."""
        if len(self.train_X) < 5:
            # Random initialization phase
            candidates = torch.rand(n_suggestions, self.bounds.shape[0])
            candidates = self.bounds[0] + candidates * (self.bounds[1] - self.bounds[0])
        else:
            candidates = batch_bo_step(
                self.train_X, self.train_Y, self.bounds, batch_size=n_suggestions
            )

        # Log suggestions to the experiment registry
        self.registry.log_suggestions(candidates.numpy(), method="bayesian_optimization")
        return candidates.numpy().tolist()

    def observe(self, X, y):
        """Record experiment results."""
        X_new = torch.tensor(X, dtype=torch.float64).reshape(-1, self.bounds.shape[0])
        y_new = torch.tensor(y, dtype=torch.float64).reshape(-1, 1)
        self.train_X = torch.cat([self.train_X, X_new])
        self.train_Y = torch.cat([self.train_Y, y_new])
        self.registry.log_observations(X_new.numpy(), y_new.numpy())
Listing 45.6: Discovery Workbench integration with a suggest/observe interface. The suggest method delegates to batch_bo_step after the initialization phase, while observe records results and updates the internal dataset for subsequent GP fitting.

Try It: Optimize a Synthetic Benchmark with BO from Scratch

Build a complete Bayesian optimization pipeline using only NumPy and SciPy, then visualize how the GP posterior evolves over iterations. (1) Define the 1-D Forrester function: \(f(x) = (6x - 2)^2 \sin(12x - 4)\) on \([0, 1]\). This classic benchmark has one global minimum and one local minimum, making it ideal for studying exploration vs. exploitation. (2) Copy the GaussianProcessRegressor and expected_improvement code from Listings 45.1 and 45.2 into a script. Initialize with 3 random points sampled uniformly from \([0, 1]\). (3) Run 10 BO iterations: at each step, evaluate EI on a dense grid of 500 candidate points, pick the candidate with the highest EI, evaluate the true function there, and append it to your dataset. (4) After each iteration, plot three panels side by side using matplotlib: the true function, the GP posterior mean with a shaded \(\pm 2\sigma\) confidence band, and the EI acquisition function. Mark evaluated points with red dots and the next query point with a green star. (5) Animate or save all 10 plots as a sequence and observe how the confidence band tightens near evaluated points, how EI shifts from broad exploration to focused exploitation, and how the GP eventually concentrates queries around the global optimum. The entire script requires fewer than 80 lines beyond the GP class and runs in under 5 seconds.

Exercise 45.1.1

Suppose you have a GP posterior with \(\mu(\mathbf{x}_A) = 3.2\), \(\sigma(\mathbf{x}_A) = 0.1\), \(\mu(\mathbf{x}_B) = 2.8\), \(\sigma(\mathbf{x}_B) = 1.5\), and the best observed value so far is \(f^+ = 3.0\). Compute the Expected Improvement at both points by hand using the closed-form formula \(\text{EI}(\mathbf{x}) = (\mu - f^+)\,\Phi(u) + \sigma\,\phi(u)\) where \(u = (\mu - f^+)/\sigma\). Which point does EI prefer, and why does the answer illustrate the exploration/exploitation trade-off?

Hint

For point A, \(u_A = (3.2 - 3.0)/0.1 = 2.0\). Look up \(\Phi(2.0) \approx 0.9772\) and \(\phi(2.0) \approx 0.0540\). For point B, \(u_B = (2.8 - 3.0)/1.5 \approx -0.133\). Look up \(\Phi(-0.133) \approx 0.447\) and \(\phi(-0.133) \approx 0.395\). Plug into the formula and compare. Point B wins despite having a lower predicted mean, because its large uncertainty contributes a substantial exploration term (\(\sigma \cdot \phi(u)\)).

Step-Through: One Iteration of Bayesian Optimization

Trace through a single BO iteration on a 1-D problem with search space \([0, 5]\). Start with three observations: \((1.0, 0.5)\), \((2.5, 1.8)\), \((4.0, 0.9)\), so \(f^+ = 1.8\). (1) Fit the GP: the posterior mean interpolates the data and the posterior standard deviation is near zero at observed points but peaks between them, say \(\sigma(1.75) \approx 0.3\) and \(\sigma(3.25) \approx 0.4\). (2) Evaluate EI on a grid. At \(x = 2.3\): \(\mu = 1.7\), \(\sigma = 0.2\), \(u = (1.7 - 1.8)/0.2 = -0.5\), giving \(\text{EI} = (-0.1)(0.3085) + (0.2)(0.3521) = 0.039\). At \(x = 3.25\): \(\mu = 1.3\), \(\sigma = 0.4\), \(u = (1.3 - 1.8)/0.4 = -1.25\), giving \(\text{EI} = (-0.5)(0.1056) + (0.4)(0.1826) = 0.020\). At \(x = 2.7\): \(\mu = 1.85\), \(\sigma = 0.25\), \(u = (1.85 - 1.8)/0.25 = 0.2\), giving \(\text{EI} = (0.05)(0.5793) + (0.25)(0.3910) = 0.127\). (3) The maximum EI is at \(x \approx 2.7\), near the current best but with enough uncertainty to expect improvement. Evaluate the true function there: \(y = f(2.7) = 2.1\). (4) Update: \(f^+\) rises from 1.8 to 2.1, and the GP posterior tightens around \(x = 2.7\).

Real-World Application: Pharmaceutical Drug Design

Merck's AutoQSAR platform has reportedly used Bayesian optimization to navigate molecular descriptor spaces when searching for drug candidates with desired binding affinity and selectivity profiles. Each candidate compound requires synthesizing and assaying in a wet lab (costing days and thousands of dollars per evaluation), making BO's sample efficiency critical. The GP surrogate learns the structure-activity landscape from as few as 50 to 100 assayed compounds, then guides synthesis toward regions of chemical space most likely to yield potent, selective molecules.

The "Optimizer's Curse" That Haunts Every BO Run

Bayesian optimization systematically overestimates the value of the point it recommends. This is not a bug in the GP; it is a statistical inevitability called the optimizer's curse (Smith and Winkler, 2006). When you select the point with the highest predicted value from a noisy model, you are implicitly conditioning on the noise being favorable. In a 10-dimensional problem with 100 candidates, the expected overestimate can exceed one posterior standard deviation. Experienced practitioners correct for this by reporting the GP posterior mean at the recommended point rather than the acquisition function's optimistic estimate, or by using a "fantasized" observation model that accounts for selection bias.

Lab: Watch a GP Learn a Function in Real Time

Goal: Build an interactive Bayesian optimization loop on a 1-D benchmark and visualize how the GP posterior and acquisition function evolve over 15 iterations. Tools needed: Python 3.10+, NumPy, SciPy, matplotlib (all standard; no GPU required). (Python 3.8 and 3.9 reached end of life in 2024; 3.10 or later is recommended.) Setup (5 min): Define the Forrester function \(f(x) = (6x - 2)^2 \sin(12x - 4)\) on \([0, 1]\). Copy the GaussianProcessRegressor and expected_improvement code from Listings 45.1 and 45.2. Experiment (15 min): Initialize with 2 random points. At each iteration, plot three vertically stacked panels: (a) true function with GP mean and \(\pm 2\sigma\) band, (b) EI acquisition function, (c) convergence curve (best \(y\) vs. iteration). Run 15 iterations and save each frame. What to vary: Change the exploration parameter \(\xi\) from 0.0 to 0.1 to 1.0 and observe how the query pattern shifts between exploitation and exploration. Try starting with 2 vs. 5 vs. 10 initial points and note how the number of "wasted" early iterations changes. What to observe: How quickly does the confidence band collapse near the global optimum? At what iteration does EI shift from a broad multi-peaked shape to a single narrow spike? Does a higher \(\xi\) delay convergence but reduce the chance of missing the global optimum?

What's Next

Bayesian optimization excels when evaluations are expensive and the search space is moderate-dimensional. But many discovery problems involve multiple conflicting objectives, combinatorial design spaces, or populations of candidate solutions. Section 45.2: Evolutionary and Multi-Objective Methods introduces Covariance Matrix Adaptation Evolution Strategy (CMA-ES) for high-dimensional single-objective problems and NSGA-III for navigating Pareto frontiers when no single "best" solution exists.