"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
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]")
Global minimum: f = -0.326883
Location: x1 = -0.0553, x2 = 0.0000
Domain: x1 in [-4, 4], x2 in [-4, 4]
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.
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})")
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)
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).
- Compute the improvement gap: \(f^+ - \mu - \xi = 0.50 - 0.30 - 0.01 = 0.19\).
- Standardize: \(z = 0.19 / 0.15 = 1.267\).
- Look up the standard normal CDF and PDF: \(\Phi(1.267) \approx 0.8974\), \(\phi(1.267) \approx 0.1774\).
- 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}")
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
The comparison reveals three advantages of active learning over random search:
- Better final result: Active learning finds a value much closer to the true global minimum.
- Faster convergence: It reaches random search's final quality in roughly one quarter of the budget.
- 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.
Hint
EI 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.
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)}")
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
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."
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})")
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)
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.
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
)
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:
- 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.
- 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.
- 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_acqfhandles 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.
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.
- 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\).
- Initialize with 5 Latin Hypercube samples using
scipy.stats.qmc.LatinHypercube. Fit aGaussianProcessRegressorwith aMatern(nu=2.5)kernel to these points. - 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.
- 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.
- 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
- 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).
- Coding: Extend the
ActiveLearningLoopclass to support the UCB acquisition function as an alternative to EI. Add a parameteracq_typethat 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. - 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
The foundational EGO paper: derived closed-form EI, proposed the sequential surrogate optimization loop, and demonstrated it on engineering design problems.
The comprehensive GP reference, with derivations of posterior inference, kernel design, and approximate methods for large datasets.
The production Bayesian optimization library used throughout this section's "Right Tool" boxes, supporting analytic and Monte Carlo (MC) acquisition functions on GPUs.
Meta's high-level platform for adaptive experimentation, wrapping BoTorch with experiment management, multi-objective support, and a web dashboard.
Coscientist: an LLM-driven system that plans, executes, and interprets chemistry experiments autonomously, integrating active learning with natural language reasoning.
Demonstrated LLM-guided chemistry workflows combining tool use with active experiment selection, bridging statistical and semantic approaches to discovery.
Introduced multi-fidelity GP modeling, enabling the combination of cheap low-fidelity simulations with expensive high-fidelity evaluations.
Early work on multi-objective Bayesian optimization using expected hypervolume improvement, now a standard technique in BoTorch.
Demonstrated GP-based active learning for solar cell optimization, achieving competitive efficiencies in 120 experiments versus an estimated 10,000 for grid search.
The GP implementation used throughout this section's code examples, offering an accessible API for exact GP regression and classification.