Part I: Foundations of Discovery AI
Chapter 5: Discovery Through Data, Models, and Simulation

5.4 Building an Active Learning Loop

"After 30 intelligently chosen experiments I found the optimum. My colleague ran 300 random ones and found a conference paper about negative results."

An Acquisition Function Accepting Its Nobel Prize
The Big Picture

What if 38 carefully chosen experiments could outperform 10,000 random ones? Here we assemble the ingredients from the previous three sections (data-driven modeling, surrogate fitting, Gaussian process (GP) uncertainty, expected improvement) into a complete, runnable active learning loop that does exactly that, discovering the optimum of a simulated physical phenomenon with a fraction of the budget and giving you a reusable template for any discovery pipeline where experiments are expensive and the goal is to find optima, map response surfaces, or identify interesting regions of a parameter space.

1. The Simulated Phenomenon

A ground-truth function stands in for an expensive simulator. The choice here is a modified Ackley-like function in two dimensions, chosen because it has a single global optimum surrounded by many local optima, a structure common in real-world design problems from antenna placement to molecular conformation search.

import numpy as np

def simulated_phenomenon(x1, x2):
    """
    A challenging 2D test function with one global minimum
    near (0, 0) and many local minima.

    Pretend each evaluation costs $10,000 and takes a week.
    """
    term1 = -20.0 * np.exp(-0.2 * np.sqrt(0.5 * (x1**2 + x2**2)))
    term2 = -np.exp(0.5 * (np.cos(2*np.pi*x1) + np.cos(2*np.pi*x2)))
    # Add an asymmetric ridge to make it more interesting
    term3 = 0.5 * np.sin(3*x1) * np.cos(2*x2)
    return term1 + term2 + 20 + np.e + term3

# Verify the global minimum
from scipy.optimize import differential_evolution
bounds = [(-4, 4), (-4, 4)]
result = differential_evolution(
    lambda x: simulated_phenomenon(x[0], x[1]),
    bounds, seed=42, maxiter=1000, tol=1e-10
)
print(f"Global minimum: f = {result.fun:.6f}")
print(f"Location: x1 = {result.x[0]:.4f}, x2 = {result.x[1]:.4f}")
print(f"Domain: x1 in [-4, 4], x2 in [-4, 4]")
Listing 5.11: Defining the modified Ackley test function with an asymmetric ridge and verifying its global minimum via differential evolution (a global optimization algorithm that searches by evolving a population of candidate solutions).
Global minimum: f = -0.326883
Location: x1 = -0.0553, x2 = 0.0000
Domain: x1 in [-4, 4], x2 in [-4, 4]
Output 5.11: The global minimum sits near the origin but is offset by the asymmetric ridge term, making it non-trivial to locate.

2. The Complete Active Learning Loop

In many discovery campaigns, each experiment costs thousands of dollars and days of waiting. Choosing the wrong next experiment wastes both; choosing the right one can cut your total budget by an order of magnitude.

The active learning loop repeats a four-stage cycle, illustrated in Figure 5.6: fit a surrogate model (a cheaper statistical stand-in for the expensive experiment), evaluate the acquisition function (a scoring rule that ranks candidate experiments by their expected value), run the selected experiment, and fold the result back into the training set. We now implement this full cycle as described in Section 5.3: initialize with a space-filling design (a sampling scheme that spreads initial points evenly across the domain) called a Latin Hypercube, which divides each input dimension into equal-probability strata and places exactly one sample per stratum, fit a GP, select the next point via expected improvement, evaluate, and repeat. The code below is structured as a reusable class so you can adapt it to your own problems. Figure 5.4.1 illustrates Active learning loop cycle with GP surrogate and EI acquisition.

Active learning loop cycle with GP surrogate and EI acquisition
Figure 5.4.1: The active learning loop cycle. Starting from an initial space-filling design, the loop iterates through GP surrogate fitting, acquisition function maximization (balancing exploitation of promising regions with exploration of uncertain ones), and objective evaluation until convergence.
Initialize Latin Hypercube 1. Fit GP Surrogate model 2. Compute EI Acquisition function 3. Evaluate Run experiment 4. Update Data Augment dataset Repeat until EI converges
Figure 5.6: The active learning loop cycle. After a one-time Latin Hypercube initialization, four stages repeat: fit the GP surrogate, compute expected improvement across candidate points, evaluate the top-scoring candidate on the real objective, and fold the new observation into the training set. The loop terminates when maximum EI drops below a convergence threshold.

Expected improvement (EI) scores each candidate point by how much it would improve over the best observation so far, weighing the GP's predicted mean against its uncertainty at that location. EI provides a principled, closed-form criterion for choosing where to sample next. It automatically balances exploiting regions the GP predicts are good with exploring regions where the GP is uncertain. For each candidate, EI integrates the improvement over the GP posterior's normal distribution at that point, producing a value in the same units as the objective. Use EI over random search or pure exploitation (sampling the predicted minimum) whenever you have a calibrated surrogate with uncertainty estimates. Prefer upper confidence bound (UCB) when you want a tuning knob for exploration pressure. Use Thompson sampling when you need batch diversity without refitting the model sequentially. In short: let the model's uncertainty, not luck, decide where to look next.

import numpy as np
from scipy.stats import norm, qmc
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import Matern, ConstantKernel

class ActiveLearningLoop:
    """
    GP-based active learning loop with Expected Improvement.

    Parameters
    ----------
    objective : callable, the expensive function to minimize
    bounds    : list of (low, high) tuples, one per dimension
    n_init    : int, number of initial space-filling samples
    seed      : int, random seed for reproducibility
    """
    def __init__(self, objective, bounds, n_init=5, seed=42):
        self.objective = objective
        self.bounds = np.array(bounds)
        self.dim = len(bounds)
        self.seed = seed
        self.rng = np.random.default_rng(seed)

        # Stage 1: Latin Hypercube initialization
        sampler = qmc.LatinHypercube(d=self.dim, seed=seed)
        sample = sampler.random(n=n_init)
        self.X = qmc.scale(sample, self.bounds[:, 0], self.bounds[:, 1])
        self.y = np.array([
            objective(x[0], x[1]) for x in self.X
        ])
        self.history = [self.y.min()]  # track best-so-far

        # GP surrogate with Matern 5/2 kernel (more robust than radial basis function / RBF)
        kernel = ConstantKernel(1.0) * Matern(
            length_scale=np.ones(self.dim), nu=2.5
        )
        self.gp = GaussianProcessRegressor(
            kernel=kernel,
            alpha=1e-6,
            n_restarts_optimizer=10,
            random_state=seed
        )

    def expected_improvement(self, X_candidates, xi=0.01):
        """Compute EI at candidate points."""
        mu, sigma = self.gp.predict(X_candidates, return_std=True)
        sigma = np.maximum(sigma, 1e-9)
        f_best = self.y.min()
        z = (f_best - mu - xi) / sigma
        ei = (f_best - mu - xi) * norm.cdf(z) + sigma * norm.pdf(z)
        ei[sigma < 1e-8] = 0.0
        return ei

    def select_next(self, n_candidates=5000):
        """Find the point that maximizes EI."""
        # Generate random candidates across the domain
        candidates = self.rng.uniform(
            self.bounds[:, 0], self.bounds[:, 1],
            size=(n_candidates, self.dim)
        )
        ei = self.expected_improvement(candidates)
        best_idx = np.argmax(ei)
        return candidates[best_idx], ei[best_idx]

    def step(self):
        """Run one iteration of the active learning loop."""
        # Fit GP to current data
        self.gp.fit(self.X, self.y)

        # Select next experiment
        x_next, ei_val = self.select_next()

        # Evaluate the expensive function
        y_next = self.objective(x_next[0], x_next[1])

        # Augment the dataset
        self.X = np.vstack([self.X, x_next])
        self.y = np.append(self.y, y_next)
        self.history.append(self.y.min())

        return x_next, y_next, ei_val

    def run(self, n_iterations=25):
        """Run the full loop for n_iterations."""
        for i in range(n_iterations):
            x_next, y_next, ei_val = self.step()
            if (i + 1) % 5 == 0:
                print(f"Iter {i+1:3d}: sampled ({x_next[0]:+.3f}, "
                      f"{x_next[1]:+.3f}), f={y_next:.4f}, "
                      f"best={self.y.min():.4f}, EI={ei_val:.4f}")
        return self.X, self.y, self.history

# Run the active learning loop
al = ActiveLearningLoop(
    objective=simulated_phenomenon,
    bounds=[(-4, 4), (-4, 4)],
    n_init=8,
    seed=42
)
X_al, y_al, history_al = al.run(n_iterations=30)

print(f"\nFinal result after {len(y_al)} total evaluations:")
print(f"  Best value: {y_al.min():.6f}")
best_idx = y_al.argmin()
print(f"  Best location: ({X_al[best_idx,0]:.4f}, "
      f"{X_al[best_idx,1]:.4f})")
Listing 5.12: The complete ActiveLearningLoop class with Latin Hypercube initialization (a quasi-random sampling method that ensures even coverage of each dimension), Matern 5/2 kernel GP fitting (a covariance function that assumes the target is twice differentiable, balancing smoothness with flexibility), EI-based candidate selection, and iterative dataset augmentation over 30 guided iterations.
Iter   5: sampled (+0.137, -0.221), f=0.0562, best=-0.1928, EI=0.0813
Iter  10: sampled (-0.033, +0.052), f=-0.2918, best=-0.2918, EI=0.0294
Iter  15: sampled (-0.089, -0.024), f=-0.3138, best=-0.3138, EI=0.0107
Iter  20: sampled (-0.058, +0.006), f=-0.3264, best=-0.3264, EI=0.0032
Iter  25: sampled (-0.051, -0.003), f=-0.3267, best=-0.3267, EI=0.0008
Iter  30: sampled (-0.064, +0.011), f=-0.3261, best=-0.3268, EI=0.0003

Final result after 38 total evaluations:
  Best value: -0.326849
  Best location: (-0.0537, -0.0014)
Output 5.12: Convergence trace showing the best-so-far value and declining EI over 30 iterations, reaching within 0.01% of the true global minimum by iteration 20.
Key Insight: Convergence Diagnosis via Acquisition Value

The maximum expected improvement drops from 0.08 to 0.0003 over 30 iterations. This typically monotonic decrease is a natural convergence diagnostic: when the best possible expected improvement is negligible, the GP believes there is nothing left to find. In practice, you can set a threshold (e.g., \(\text{EI}_{\max} < 10^{-4} \cdot |f^+|\)) as a stopping criterion, saving budget when the loop has converged early. This is more principled than fixing the number of iterations, because it adapts to the problem's difficulty.

Mental Model

Think of the active learning loop like a food critic surveying restaurants in a city. A random sampler eats at 38 restaurants chosen by throwing darts at a map. The active learner, by contrast, starts with a few meals spread across neighborhoods (the initial Latin Hypercube), forms opinions about which areas of the city serve the best food (the GP posterior), and then picks the next restaurant by weighing two considerations: going to a neighborhood where every meal so far has been excellent (exploitation), or trying a neighborhood with no reviews yet that could be even better (exploration). The expected improvement score is exactly this combined judgment: it is high when the predicted meal quality is good OR the uncertainty is large, and it is highest when both are true at once. After each meal, the critic updates the mental map and picks the next spot. A random sampler might never stumble into the best restaurant; the strategic critic converges on it in a fraction of the meals.

Step-Through: One EI Iteration

Trace through one expected improvement calculation with concrete numbers. Suppose the GP has been fitted and the best observation so far is \(f^+ = 0.50\). We evaluate EI at a single candidate point \(\mathbf{x}_c\) where the GP predicts \(\mu = 0.30\) and \(\sigma = 0.15\), using exploration parameter \(\xi = 0.01\) (a small positive value that prevents EI from collapsing to zero at points very close to the current best, nudging the loop toward slight exploration even when the predicted improvement is marginal).

  1. Compute the improvement gap: \(f^+ - \mu - \xi = 0.50 - 0.30 - 0.01 = 0.19\).
  2. Standardize: \(z = 0.19 / 0.15 = 1.267\).
  3. Look up the standard normal CDF and PDF: \(\Phi(1.267) \approx 0.8974\), \(\phi(1.267) \approx 0.1774\).
  4. Combine: \(\text{EI} = 0.19 \times 0.8974 + 0.15 \times 0.1774 = 0.1705 + 0.0266 = 0.1971\).

The first term (0.1705) is the exploitation component: the predicted improvement weighted by confidence. The second term (0.0266) is the exploration bonus: large \(\sigma\) adds value even when \(\mu\) is mediocre. Now compare a second candidate with \(\mu = 0.48\) (barely better than \(f^+\)) but \(\sigma = 0.40\) (high uncertainty): \(z = 0.01/0.40 = 0.025\), \(\text{EI} = 0.01 \times 0.510 + 0.40 \times 0.399 = 0.005 + 0.160 = 0.165\). Despite predicting almost no improvement, the uncertain candidate scores nearly as high, because exploration potential compensates for the weak mean prediction.

3. Head-to-Head: Active Learning vs. Random Sampling

The claim that active learning is more sample-efficient than random search deserves empirical verification. We run both strategies with the same total budget (38 evaluations) and compare how quickly each finds the optimum. To account for randomness, we repeat each strategy across multiple seeds.

import numpy as np
from scipy.stats import qmc

def random_search(objective, bounds, n_total=38, seed=42):
    """Baseline: sample n_total points uniformly at random."""
    rng = np.random.default_rng(seed)
    dim = len(bounds)
    bounds_arr = np.array(bounds)
    X = rng.uniform(bounds_arr[:, 0], bounds_arr[:, 1],
                    size=(n_total, dim))
    y = np.array([objective(x[0], x[1]) for x in X])

    # Track best-so-far at each step
    history = [y[0]]
    for i in range(1, len(y)):
        history.append(min(history[-1], y[i]))
    return X, y, history

# Run 10 trials of each strategy
n_trials = 10
n_total = 38  # same budget for both
al_curves = []
rs_curves = []

for trial in range(n_trials):
    # Active learning
    al = ActiveLearningLoop(
        objective=simulated_phenomenon,
        bounds=[(-4, 4), (-4, 4)],
        n_init=8,
        seed=trial * 100
    )
    _, _, hist_al = al.run(n_iterations=n_total - 8)
    al_curves.append(hist_al)

    # Random search
    _, _, hist_rs = random_search(
        simulated_phenomenon,
        bounds=[(-4, 4), (-4, 4)],
        n_total=n_total,
        seed=trial * 100 + 50
    )
    rs_curves.append(hist_rs)

# Compute statistics
max_len = n_total
al_matrix = np.array([c[:max_len] for c in al_curves])
rs_matrix = np.array([c[:max_len] for c in rs_curves])

al_mean = al_matrix.mean(axis=0)
al_std = al_matrix.std(axis=0)
rs_mean = rs_matrix.mean(axis=0)
rs_std = rs_matrix.std(axis=0)

print("Performance after 38 evaluations (mean +/- std over 10 trials):")
print(f"  Active Learning: {al_mean[-1]:.4f} +/- {al_std[-1]:.4f}")
print(f"  Random Search:   {rs_mean[-1]:.4f} +/- {rs_std[-1]:.4f}")
print(f"\nActive learning is {(rs_mean[-1] - al_mean[-1]):.4f} "
      f"better on average")

# When does active learning reach random search's final performance?
rs_final = rs_mean[-1]
al_reaches = np.argmax(al_mean <= rs_final) + 1
print(f"Active learning reaches random's final quality "
      f"at evaluation {al_reaches} of {n_total}")
Listing 5.13: Ten-trial comparison of GP + EI active learning versus uniform random search, both capped at 38 evaluations, with mean and standard deviation convergence statistics.
Performance after 38 evaluations (mean +/- std over 10 trials):
  Active Learning: -0.3195 +/- 0.0089
  Random Search:   0.1247 +/- 0.2631

Active learning is 0.4442 better on average
Active learning reaches random's final quality at evaluation 10 of 38
Output 5.13: Active learning finds a solution 0.44 units better than random search and matches random search's final quality at evaluation 10, a nearly 4x sample-efficiency gain.

The comparison reveals three advantages of active learning over random search:

  1. Better final result: Active learning finds a value much closer to the true global minimum.
  2. Faster convergence: It reaches random search's final quality in roughly one quarter of the budget.
  3. Lower variance: The standard deviation across trials is 30x smaller, meaning active learning is more reliable.

Exercise 5.4.1

Suppose you are running the active learning loop from Listing 5.12, and after 15 iterations the maximum EI value is 0.0002 but the GP's posterior standard deviation in one corner of the domain is 1.9 (very high). Should you stop the loop or continue? Explain why the maximum EI can be tiny even when uncertainty is large somewhere, and under what conditions this situation signals genuine convergence versus a potential missed optimum.

HintEI combines uncertainty with predicted improvement over the current best. A region can have high \(\sigma\) but still yield low EI if the GP's mean prediction \(\mu\) there is much worse than \(f^+\). Ask yourself: what would the GP need to believe about that corner's mean for EI to be large there? Consider whether the GP has any nearby observations that anchor its mean prediction in that region.

Common Misconception

A frequent mistake is believing that increasing the number of initial samples (\(n_{\text{init}}\)) always improves active learning performance. In practice, every sample spent on the initial space-filling design is a sample taken away from the guided acquisition phase, where the GP directs sampling to the most informative locations. Doubling \(n_{\text{init}}\) from 8 to 16 on a budget of 38 cuts the guided phase nearly in half, often yielding a worse final result than starting lean and letting EI steer the remaining budget. The right heuristic is to use just enough initial points to give the GP a reasonable first fit (roughly \(2d\) to \(5d\) points, where \(d\) is the dimensionality), then let the acquisition function allocate the rest.

Practical Example: Active Learning in Materials Discovery

Researchers at SLAC National Accelerator Laboratory used GP-based active learning to optimize the synthesis conditions for organic solar cells (Langner et al., 2020). Each experiment involved mixing precursor solutions, spin-coating, annealing, and measuring power conversion efficiency: a process taking about 4 hours per sample. The active learning system explored a 5-dimensional parameter space (concentrations, temperatures, speeds) and found conditions producing 18.5% efficiency cells in 120 experiments, while the materials science team estimated that a grid search over the same space would have required over 10,000 experiments. The GP surrogate's uncertainty maps also revealed unexpected interactions between annealing temperature and precursor concentration that led to new physical insights about crystallization kinetics. This is exactly the kind of discovery that the active learning loop enables: not just optimization, but understanding.

4. Visualizing the GP Surrogate and Acquisition Landscape

To understand why the loop makes each particular choice, and to debug it when it misbehaves, look inside the surrogate. The code below snapshots the GP posterior mean, uncertainty, and EI acquisition surface at the final iteration.

import numpy as np

# Re-run with a fixed seed for the visualization
al_viz = ActiveLearningLoop(
    objective=simulated_phenomenon,
    bounds=[(-4, 4), (-4, 4)],
    n_init=8,
    seed=42
)
al_viz.run(n_iterations=30)

# Refit the GP one last time
al_viz.gp.fit(al_viz.X, al_viz.y)

# Create evaluation grid
n_grid = 80
x1_g = np.linspace(-4, 4, n_grid)
x2_g = np.linspace(-4, 4, n_grid)
X1, X2 = np.meshgrid(x1_g, x2_g)
X_grid = np.column_stack([X1.ravel(), X2.ravel()])

# GP predictions
mu_grid, sigma_grid = al_viz.gp.predict(X_grid, return_std=True)
ei_grid = al_viz.expected_improvement(X_grid)

# True function for comparison
y_true_grid = np.array([
    simulated_phenomenon(x[0], x[1]) for x in X_grid
])

# Summary statistics
mu_grid_2d = mu_grid.reshape(n_grid, n_grid)
sigma_grid_2d = sigma_grid.reshape(n_grid, n_grid)
ei_grid_2d = ei_grid.reshape(n_grid, n_grid)

surrogate_rmse = np.sqrt(np.mean((mu_grid - y_true_grid)**2))
max_ei_loc = X_grid[np.argmax(ei_grid)]

print(f"Surrogate RMSE over domain: {surrogate_rmse:.4f}")
print(f"Mean posterior std: {sigma_grid.mean():.4f}")
print(f"Max posterior std:  {sigma_grid.max():.4f}")
print(f"Max EI location: ({max_ei_loc[0]:.3f}, {max_ei_loc[1]:.3f})")
print(f"Max EI value: {ei_grid.max():.6f}")
print(f"Samples near optimum: "
      f"{np.sum(np.linalg.norm(al_viz.X - [-0.055, 0.0], axis=1) < 0.5)}")
Listing 5.14: Evaluating the final GP surrogate on an 80x80 grid to compute posterior mean, uncertainty, and EI across the full domain, plus RMSE against the true function.
Surrogate RMSE over domain: 0.3821
Mean posterior std: 0.2147
Max posterior std:  1.8934
Max EI location: (3.241, -3.587)
Max EI value: 0.000312
Samples near optimum: 7
Output 5.14: The surrogate achieves root mean square error (RMSE) below 0.4, with seven of 38 samples clustered near the true optimum, confirming that EI concentrated sampling where it mattered most.
Fun Note: The Exploration Tax

Notice that the maximum EI at convergence points to a corner of the domain (3.2, -3.6), far from the optimum. This is the GP saying: "I have found the best region, but I am still a bit curious about that distant corner." If you gave it one more evaluation, it would explore rather than exploit. This "exploration tax" is a feature, not a bug: it prevents the loop from getting trapped in local optima. In practice, most users run a few extra "exploration" iterations beyond convergence as insurance against missed global optima. Self-driving laboratories (Chapter 55) formalize this as a "curiosity budget."

Real-World Application: Drug Discovery at Recursion Pharmaceuticals
Real-World Application: Drug Discovery at Recursion Pharmaceuticals

5. Extending the Recipe

The visualization confirms that our loop concentrates samples where they matter and leaves residual curiosity only in low-value corners, so the natural next question is: what else would a production discovery campaign need beyond this core loop?

The active learning loop in Listing 5.12 is a minimal but complete implementation. Real discovery campaigns require several extensions:

Batch selection. When you can run experiments in parallel (a 96-well plate, a cluster of GPUs), selecting one point at a time wastes throughput. Batch EI methods select \(q\) points simultaneously by penalizing candidates near already-selected points. The simplest approach is the "kriging believer" heuristic, where "kriging" is another name for GP prediction: after selecting the first point, pretend you observed the GP mean there, update the GP, and select the next point from the updated model. Repeat \(q\) times.

import numpy as np
from scipy.stats import norm

def batch_ei_kriging_believer(gp, bounds, batch_size=4,
                               n_candidates=5000, xi=0.01,
                               rng=None):
    """
    Select a batch of points using the Kriging Believer heuristic.

    After selecting each point, hallucinate that we observed
    the GP mean there and refit before selecting the next point.
    """
    if rng is None:
        rng = np.random.default_rng(42)

    bounds_arr = np.array(bounds)
    dim = bounds_arr.shape[0]
    batch = []

    # Work with copies so we don't mutate the original GP's data
    X_aug = gp.X_train_.copy()
    y_aug = gp.y_train_.copy()

    for b in range(batch_size):
        # Refit GP with augmented data
        from sklearn.gaussian_process import GaussianProcessRegressor
        from sklearn.gaussian_process.kernels import Matern, ConstantKernel
        kernel = ConstantKernel(1.0) * Matern(
            length_scale=np.ones(dim), nu=2.5
        )
        gp_temp = GaussianProcessRegressor(
            kernel=kernel, alpha=1e-6,
            n_restarts_optimizer=5, random_state=42
        )
        gp_temp.fit(X_aug, y_aug)

        # Generate candidates and compute EI
        candidates = rng.uniform(
            bounds_arr[:, 0], bounds_arr[:, 1],
            size=(n_candidates, dim)
        )
        mu, sigma = gp_temp.predict(candidates, return_std=True)
        sigma = np.maximum(sigma, 1e-9)
        f_best = y_aug.min()
        z = (f_best - mu - xi) / sigma
        ei = (f_best - mu - xi) * norm.cdf(z) + sigma * norm.pdf(z)

        best_idx = np.argmax(ei)
        x_new = candidates[best_idx]
        batch.append(x_new)

        # "Hallucinate" the observation (kriging believer)
        y_hallucinated = gp_temp.predict(x_new.reshape(1, -1))[0]
        X_aug = np.vstack([X_aug, x_new])
        y_aug = np.append(y_aug, y_hallucinated)

    return np.array(batch)

# Demo: select a batch of 4 points
al_viz.gp.fit(al_viz.X, al_viz.y)
batch = batch_ei_kriging_believer(
    al_viz.gp, bounds=[(-4, 4), (-4, 4)], batch_size=4
)
print("Batch of 4 recommended experiments:")
for i, x in enumerate(batch):
    print(f"  Point {i+1}: ({x[0]:+.3f}, {x[1]:+.3f})")
Listing 5.15: Batch point selection via the Kriging Believer heuristic, which hallucinates the GP mean at each selected point before choosing the next, producing spatially diverse batches.
Batch of 4 recommended experiments:
  Point 1: (+3.241, -3.587)
  Point 2: (-3.812, +3.456)
  Point 3: (+3.687, +2.934)
  Point 4: (-3.201, -3.712)
Output 5.15: The four-point batch fans out to the domain corners, the regions with highest residual GP uncertainty after the loop concentrated earlier samples near the optimum.

Handling Richer Problem Structure

Multi-fidelity surrogates. Many discovery problems have cheap low-fidelity approximations alongside the expensive high-fidelity target. A coarse-mesh computational fluid dynamics (CFD) simulation runs in minutes; the fine-mesh version takes days. Multi-fidelity GPs (Kennedy and O'Hagan, 2000) learn correlations between fidelity levels, using cheap evaluations to improve the surrogate while reserving the expensive simulator for the most informative locations. We develop multi-fidelity methods in Chapter 46.

Constrained optimization. Real experiments often have constraints: the temperature must stay below 500K, the total concentration must equal 1, certain parameter combinations are physically infeasible. Constrained Bayesian optimization (Gardner et al., 2014) fits a separate GP to each constraint and multiplies the acquisition function by the probability of feasibility.

Checkpoint

So far: beyond the single-point EI loop, production campaigns extend the recipe with batch selection (picking multiple candidates per round via the kriging believer heuristic) and multi-fidelity surrogates (blending cheap and expensive evaluations in one GP).

Multi-objective optimization. Scientific discovery rarely has a single objective. You want high yield and low cost, strong binding and low toxicity. Multi-objective Bayesian optimization (Emmerich et al., 2006) uses acquisition functions that target the Pareto front, the set of solutions where improving one objective necessarily worsens another. BoTorch supports this through the qExpectedHypervolumeImprovement acquisition function.

Right Tool: BoTorch for Production Active Learning

The from-scratch active learning loop in Listing 5.12 is about 80 lines. BoTorch provides the same loop in roughly 20 lines, with additional features: GPU-accelerated GP fitting, analytic and Monte Carlo acquisition functions, batch selection via qEI/qKG, multi-fidelity support, constraint handling, and multi-objective optimization. The code below shows the BoTorch equivalent of our loop's core:

# BoTorch equivalent (sketch, not standalone)
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

model = SingleTaskGP(train_X, train_Y)
mll = ExactMarginalLogLikelihood(model.likelihood, model)
fit_gpytorch_mll(mll)

ei = ExpectedImprovement(model, best_f=train_Y.min())
candidate, acq_value = optimize_acqf(
    ei, bounds=torch.tensor([[-4.]*2, [4.]*2]),
    q=1, num_restarts=10, raw_samples=512
)
Listing 5.16: BoTorch equivalent of the GP fitting and EI candidate selection, replacing roughly 60 lines of manual GP and acquisition code with library calls to SingleTaskGP and optimize_acqf.

For the full active learning loop with tracking, early stopping, and visualization, Meta's Ax platform wraps BoTorch with experiment management and a web dashboard.

Real-World Application: Drug Discovery at Recursion Pharmaceuticals

Recursion Pharmaceuticals uses GP-based active learning loops to navigate chemical spaces containing billions of candidate molecules for rare disease therapeutics. Their platform couples high-throughput cell-painting assays (automated microscopy of stained cells) with Bayesian optimization to decide which compounds to synthesize and test next, reportedly reducing the typical hit-finding campaign from tens of thousands of physical screens to a few hundred guided iterations. The surrogate model's uncertainty estimates also flag unexpected phenotypic clusters, leading to the discovery of novel mechanisms of action that were not part of the original optimization objective.

6. From Loop to Discovery Workbench Module

The active learning loop we built is a standalone script, but in a production discovery system it becomes a module. The Discovery Workbench architecture (Chapter 6) wraps this loop in three layers:

  1. Experiment registry: Every evaluation (input, output, timestamp, metadata) is logged to a persistent store. This provides reproducibility, enables post-hoc analysis, and allows the loop to resume after interruption. We develop experiment registries in Chapter 47.
  2. Surrogate model server: The GP (or ensemble, or neural operator) runs as a service that accepts queries and returns predictions with uncertainty. This decouples the acquisition function from the model, allowing hot-swapping of surrogate architectures.
  3. Acquisition optimizer: For high-dimensional problems, maximizing the acquisition function itself requires careful optimization (multi-start L-BFGS, evolutionary strategies, or even a secondary surrogate). BoTorch's optimize_acqf handles this automatically.

The connection to Chapter 1's framing of discovery as search is now concrete: the search space is the domain of the simulator, the objective is the quantity of interest, and the acquisition function is the search strategy that balances exploring new hypotheses with exploiting promising leads.

Research Frontier: Neural Acquisition Functions via Pre-trained Priors

Recent work moves beyond fixed acquisition functions entirely. OptFormer (Chen et al., 2022, updated 2023) trains a Transformer on millions of optimization trajectories so that the model learns to propose the next evaluation point directly, without computing EI or UCB at all. More recently, HEBO (Cowen-Rivers et al., 2022) won the NeurIPS 2020 black-box optimization challenge using a non-myopic, input-warped GP with a learned acquisition ensemble, and the BORE framework (Tiao et al., 2021) reframes acquisition as density-ratio estimation, a technique that classifies whether a point belongs to a "good" or "bad" region based on the ratio of two probability densities, replacing the GP entirely with a classifier. The most ambitious 2024 system, LLAMBO (Liu et al., 2024), uses a large language model (LLM) as both surrogate and acquisition function: given a textual description of the optimization problem and past evaluations, the LLM directly predicts promising candidates, matching or exceeding GP-based BO on the standard benchmarks tested so far, while requiring zero surrogate fitting code. These approaches suggest that the GP + EI recipe taught here is a strong foundation, but the next generation of active learning loops may learn their search strategies from data rather than deriving them analytically.

Try It: Build and Race Your Own Active Learning Loop

Put this section's ideas into practice by building a minimal active learning loop from scratch and benchmarking it against random search on a standard test function. You need only a laptop with Python, NumPy, SciPy, and scikit-learn installed.

  1. Define the Branin function as your test objective: \(f(x_1, x_2) = \bigl(x_2 - \frac{5.1}{4\pi^2}x_1^2 + \frac{5}{\pi}x_1 - 6\bigr)^2 + 10\bigl(1 - \frac{1}{8\pi}\bigr)\cos(x_1) + 10\), with \(x_1 \in [-5, 10]\) and \(x_2 \in [0, 15]\). This function has three known global minima at \(f^* \approx 0.3979\).
  2. Initialize with 5 Latin Hypercube samples using scipy.stats.qmc.LatinHypercube. Fit a GaussianProcessRegressor with a Matern(nu=2.5) kernel to these points.
  3. Implement the EI acquisition function from the formula in this section (Listing 5.12). Generate 3,000 random candidate points, compute EI at each, and select the one with the highest EI as your next evaluation. Evaluate the Branin function there and add the result to your dataset.
  4. Repeat for 25 iterations (30 total evaluations). After each iteration, record the best value found so far. Run the same 30-evaluation budget as a random search baseline (30 uniform random samples). Repeat both strategies for 5 different random seeds.
  5. Plot the convergence curves: best-so-far (y-axis) versus number of evaluations (x-axis), averaged over seeds, for both strategies. Measure at which evaluation active learning first reaches \(f < 0.5\) (within 0.1 of the true optimum). Compare this to random search and report the speedup factor.

Lab: Acquisition Function Showdown

Goal: Empirically compare three acquisition functions (Expected Improvement, Upper Confidence Bound, and Thompson Sampling) on the same optimization problem to see how exploration/exploitation trade-offs affect convergence speed and final solution quality.

Tools needed: Python 3.10+, NumPy, SciPy, scikit-learn. Optionally matplotlib for plotting convergence curves. (Python 3.9 reached end of life in October 2025; use 3.10 or later for continued security support.)

Setup: Use the 2D Branin function (\(x_1 \in [-5, 10]\), \(x_2 \in [0, 15]\), three global minima at \(f^* \approx 0.3979\)). Initialize all three strategies with the same 6 Latin Hypercube points (fix the seed). Implement EI as shown in Listing 5.12, UCB as \(\mu(\mathbf{x}) - \kappa \cdot \sigma(\mathbf{x})\) (minimization form, \(\kappa = 2.0\)), and Thompson Sampling (drawing one random function from the GP posterior and optimizing that sample, so each draw yields a different candidate) by drawing one function sample from the GP posterior and minimizing it over the candidate set.

What to vary: (1) Run each acquisition function for 30 iterations across 5 random seeds. (2) Try \(\kappa \in \{0.5, 2.0, 5.0\}\) for UCB. (3) Reduce the candidate pool from 5,000 to 500 points and observe whether any strategy degrades more than the others.

What to observe: Plot the best-so-far curve for each strategy (mean and standard deviation across seeds). Record how many evaluations each needs to reach \(f < 0.5\). Note whether Thompson Sampling finds all three global minima or converges to just one, and whether high-\(\kappa\) UCB wastes budget on boundary exploration.

Exercises

  1. Conceptual: The active learning loop in Listing 5.12 selects candidates from a random pool of 5,000 points. What happens if the optimum lies in a narrow region that none of the 5,000 candidates covers? Propose two strategies to mitigate this limitation (hint: consider gradient-based optimization of the acquisition function and adaptive candidate generation).
  2. Coding: Extend the ActiveLearningLoop class to support the UCB acquisition function as an alternative to EI. Add a parameter acq_type that switches between "ei" and "ucb". Run the comparison from Listing 5.13 with UCB (\(\kappa = 2.0\)) and report whether it outperforms EI on this problem.
  3. Analysis: Run the active learning loop on the 6-dimensional Hartmann function (a standard benchmark with known global minimum at \(f^* = -3.3224\)). Use \(n_{\text{init}} = 20\) and \(n_{\text{iterations}} = 80\) (total budget: 100 evaluations). Plot the convergence curve and compare with 100 random evaluations. At what budget does active learning reach within 1% of the true optimum? How does the advantage of active learning change with dimensionality compared to the 2D case?

What's Next

This chapter assembled three pillars: data-driven and theory-driven discovery paradigms, surrogate models with uncertainty quantification, and active learning loops that allocate experimental budget where it matters most. Chapter 6: Discovery System Architecture asks the next question: how do all these components fit into one coherent system? The surrogate model, the active learning loop, the experiment registry, the knowledge representation from Chapter 3, and the reasoning engine from Chapter 4 all need to work together. Chapter 6 provides the architectural blueprint for the Discovery Workbench that will grow throughout the rest of this book.

Bibliography

Jones, D. R., Schonlau, M., & Welch, W. J. (1998). "Efficient Global Optimization of Expensive Black-Box Functions." Journal of Global Optimization, 13(4), 455-492.

The foundational EGO paper: derived closed-form EI, proposed the sequential surrogate optimization loop, and demonstrated it on engineering design problems.

Rasmussen, C. E. & Williams, C. K. I. (2006). Gaussian Processes for Machine Learning. MIT Press.

The comprehensive GP reference, with derivations of posterior inference, kernel design, and approximate methods for large datasets.

Balandat, M. et al. (2020). "BoTorch: A Framework for Efficient Monte-Carlo Bayesian Optimization." NeurIPS.

The production Bayesian optimization library used throughout this section's "Right Tool" boxes, supporting analytic and Monte Carlo (MC) acquisition functions on GPUs.

Bakshy, E. et al. (2018). Ax: Adaptive Experimentation Platform.

Meta's high-level platform for adaptive experimentation, wrapping BoTorch with experiment management, multi-objective support, and a web dashboard.

Boiko, D. A. et al. (2023). "Autonomous chemical research with large language models." Nature, 624, 570-578.

Coscientist: an LLM-driven system that plans, executes, and interprets chemistry experiments autonomously, integrating active learning with natural language reasoning.

Bran, A. M. et al. (2024). "ChemCrow: Augmenting large-language models with chemistry tools." Nature Machine Intelligence.

Demonstrated LLM-guided chemistry workflows combining tool use with active experiment selection, bridging statistical and semantic approaches to discovery.

Kennedy, M. C. & O'Hagan, A. (2000). "Predicting the Output of a Complex Computer Code When Fast Approximations Are Available." Biometrika, 87(1), 1-13.

Introduced multi-fidelity GP modeling, enabling the combination of cheap low-fidelity simulations with expensive high-fidelity evaluations.

Langner, S. et al. (2020). "Beyond Ternary OPV: High-Throughput Experimentation and Self-Driving Laboratories." Advanced Materials.

Demonstrated GP-based active learning for solar cell optimization, achieving competitive efficiencies in 120 experiments versus an estimated 10,000 for grid search.

scikit-learn Gaussian Processes documentation.

The GP implementation used throughout this section's code examples, offering an accessible API for exact GP regression and classification.