Prerequisites
This recipe builds directly on the Bayesian Active Learning by Disagreement (BALD) acquisition function from
Section 46.2 and the active learning loop from
Section 46.1. You will need PyTorch, GPyTorch, and
BoTorch installed (pip install botorch installs all three). Familiarity
with Gaussian process regression from
Chapter 32
is assumed. The recipe integrates with the Discovery Workbench architecture from
Chapter 6.
This section assembles the components from Sections 46.1 and 46.2 into a working closed-loop experiment planner. The system takes a simulated dose-response assay as its oracle, uses a Gaussian process as its surrogate model, selects experiments via BALD acquisition, and runs for 10 acquisition rounds. We compare BALD against random selection and simple uncertainty sampling, demonstrating that active acquisition (BALD or uncertainty sampling, which are equivalent for GPs as shown below) typically achieves roughly 3x sample efficiency over random selection: it learns the dose-response curve to the same accuracy with approximately one-third as many measurements. The planner is designed as a reusable module that plugs into the Discovery Workbench and can be connected to real laboratory instruments in Chapter 55.
1. The Simulated Assay
If each assay costs \$150 and you need to map a drug's full potency curve, can you learn the same curve with one-third the measurements by letting the data choose where to look next? That is the question this section answers, using a simulated dose-response assay built on the four-parameter logistic (4PL) model, the standard in pharmacology for sigmoidal dose-response curves. The model describes the relationship between drug concentration \(x\) (on a log scale) and biological response \(y\):
$$y = y_{\min} + \frac{y_{\max} - y_{\min}}{1 + \left(\frac{x}{\text{EC50}}\right)^{-h}} + \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, \sigma^2)$$where \(y_{\min}\) is the baseline response, \(y_{\max}\) is the maximum response, EC50 is the half-maximal effective concentration, \(h\) is the Hill coefficient (controlling steepness), and \(\sigma\) is measurement noise. We set ground-truth parameters that represent a realistic drug screening scenario. In short: let the data's gaps, not the experimenter's grid, choose the next measurement.
import numpy as np
import torch
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class DoseResponseOracle:
"""Simulated 4-parameter logistic dose-response assay.
Models: y = y_min + (y_max - y_min) / (1 + (x / EC50)^(-h)) + noise
Attributes:
ec50: half-maximal effective concentration (log10 scale)
hill: Hill coefficient (steepness)
y_min: baseline response (no drug)
y_max: maximum response (saturating drug)
noise_std: measurement noise standard deviation
cost_per_query: simulated cost per assay in dollars
"""
ec50: float = 1.0 # log10(EC50) = 1.0 -> EC50 = 10 uM
hill: float = 1.5 # moderate cooperativity
y_min: float = 0.05 # 5% baseline activity
y_max: float = 0.95 # 95% maximum response
noise_std: float = 0.08 # realistic assay noise
cost_per_query: float = 150.0
total_cost: float = field(default=0.0, init=False)
query_log: list = field(default_factory=list, init=False)
def query(self, x: float, seed: Optional[int] = None) -> float:
"""Run one assay at concentration x (log10 scale).
Args:
x: log10 drug concentration
seed: optional random seed for reproducibility
Returns:
Measured response (noisy observation).
"""
rng = np.random.RandomState(seed)
true_response = self.y_min + (self.y_max - self.y_min) / (
1.0 + (10**x / 10**self.ec50) ** (-self.hill)
)
noise = rng.normal(0, self.noise_std)
y = np.clip(true_response + noise, 0.0, 1.0)
self.total_cost += self.cost_per_query
self.query_log.append({"x": x, "y": y, "true": true_response})
return y
def true_curve(self, x_grid: np.ndarray) -> np.ndarray:
"""Noise-free dose-response curve for evaluation."""
return self.y_min + (self.y_max - self.y_min) / (
1.0 + (10**x_grid / 10**self.ec50) ** (-self.hill)
)
def batch_query(self, xs: np.ndarray, seed: int = 42) -> np.ndarray:
"""Run assays at multiple concentrations."""
return np.array([self.query(x, seed=seed + i) for i, x in enumerate(xs)])
# Ground truth: a drug with EC50 = 10 uM, moderate Hill slope
oracle = DoseResponseOracle(ec50=1.0, hill=1.5, noise_std=0.08)
# Verify the curve
x_test = np.linspace(-1, 3, 200)
y_true = oracle.true_curve(x_test)
print(f"Response at EC50: {oracle.true_curve(np.array([1.0]))[0]:.3f}")
# Output: Response at EC50: 0.500 (by definition, halfway between min and max)
The oracle tracks every query in its query_log, recording the input
concentration, the noisy observation, and the true (noise-free) response. This
provenance tracking mirrors the experiment registry patterns from
Chapter 47 and ensures that
every design decision is reproducible.
With the oracle in place to generate observations on demand, the next component the planner needs is a model that can learn from those observations and quantify where its knowledge is still incomplete.
2. The Gaussian Process Surrogate
The surrogate model (where a surrogate model is a cheap-to-evaluate statistical approximation trained on observed data to stand in for the expensive real experiment) is a Gaussian process (GP) that learns the dose-response curve from observed data and provides calibrated uncertainty estimates. We use GPyTorch (through BoTorch's model wrappers) for efficient GP inference with GPU support.
import gpytorch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from gpytorch.mlls import ExactMarginalLogLikelihood
def build_gp_surrogate(train_X: torch.Tensor, train_Y: torch.Tensor):
"""Build and fit a SingleTaskGP surrogate model.
Uses a Matern 5/2 kernel (BoTorch default) with automatic
lengthscale and outputscale optimization.
Args:
train_X: (n, 1) tensor of observed concentrations
train_Y: (n, 1) tensor of observed responses
Returns:
Fitted SingleTaskGP model.
"""
# Normalize inputs to [0, 1] for numerical stability
model = SingleTaskGP(train_X, train_Y)
mll = ExactMarginalLogLikelihood(model.likelihood, model)
fit_gpytorch_mll(mll)
return model
def predict_with_uncertainty(model, X_test: torch.Tensor):
"""Get predictions and uncertainty from the GP surrogate.
Args:
model: fitted SingleTaskGP
X_test: (n_test, 1) tensor of test points
Returns:
mean: (n_test,) predicted means
std: (n_test,) predicted standard deviations
lower: (n_test,) 95% CI lower bound
upper: (n_test,) 95% CI upper bound
"""
model.eval()
with torch.no_grad(), gpytorch.settings.fast_pred_var():
posterior = model.posterior(X_test)
mean = posterior.mean.squeeze()
std = posterior.variance.squeeze().sqrt()
lower = mean - 1.96 * std
upper = mean + 1.96 * std
return mean.numpy(), std.numpy(), lower.numpy(), upper.numpy()
3. The BALD Acquisition Function
Choosing the wrong next experiment does not just waste a single measurement; it delays the entire learning curve, compounding cost across every subsequent round. The acquisition function (a scoring rule that assigns a numeric value to each candidate experiment, ranking them by expected informativeness) is what separates a planner that converges in 4 rounds from one that never converges at all. We implement BALD for the GP surrogate using BoTorch's built-in Monte Carlo acquisition machinery. BoTorch represents acquisition functions as modules that can be optimized over the design space using gradient-based methods (since the GP posterior is differentiable with respect to the input).
For a GP regression model, BALD reduces to the closed-form expression derived in Section 46.2: \(\alpha_{\text{BALD}}(x) = \frac{1}{2}\log(1 + \sigma^2(x) / \sigma_n^2)\). We implement the closed-form version below (fast, exact for GPs). The Monte Carlo version, which generalizes to any Bayesian model, is covered in Section 46.2; the two produce identical rankings for GP regression.
Checkpoint
So far: we have a simulated dose-response oracle that generates noisy observations on demand, a GP surrogate that learns the curve and quantifies uncertainty, and a closed-form BALD score that ranks candidate experiments by how much they would reduce model ignorance.
from botorch.acquisition.analytic import PosteriorStdDev
from botorch.optim import optimize_acqf
class BALDAcquisition:
"""BALD acquisition function for GP regression.
Computes the mutual information between the observation and the
model parameters: I(theta; y | x, D) = 0.5 * log(1 + sigma^2(x) / sigma_n^2)
For GP regression, this is equivalent to selecting the point with
the highest epistemic signal-to-noise ratio.
"""
def __init__(self, model, noise_variance=None):
"""
Args:
model: fitted SingleTaskGP
noise_variance: observation noise variance. If None,
extracted from the model's likelihood.
"""
self.model = model
if noise_variance is None:
self.noise_var = model.likelihood.noise.item()
else:
self.noise_var = noise_variance
def evaluate(self, X_candidates: torch.Tensor) -> torch.Tensor:
"""Compute BALD scores for candidate experiments.
Args:
X_candidates: (n_candidates, 1) tensor
Returns:
(n_candidates,) tensor of BALD acquisition values.
"""
self.model.eval()
with torch.no_grad(), gpytorch.settings.fast_pred_var():
posterior = self.model.posterior(X_candidates)
epistemic_var = posterior.variance.squeeze()
bald_scores = 0.5 * torch.log(1.0 + epistemic_var / self.noise_var)
return bald_scores
def select_next(self, X_candidates: torch.Tensor) -> int:
"""Select the candidate with the highest BALD score.
Args:
X_candidates: (n_candidates, 1) tensor
Returns:
Index of the best candidate.
"""
scores = self.evaluate(X_candidates)
return torch.argmax(scores).item()
def optimize_continuous(self, bounds: torch.Tensor,
n_restarts: int = 10) -> torch.Tensor:
"""Optimize BALD over a continuous domain using L-BFGS-B.
L-BFGS-B is a quasi-Newton optimizer for bound-constrained
problems that approximates the Hessian from gradient history,
making it well suited to smooth acquisition surfaces.
Args:
bounds: (2, d) tensor of [lower_bounds, upper_bounds]
n_restarts: number of random restarts
Returns:
(1, d) tensor of the optimal experiment location.
"""
# Use BoTorch's PosteriorStdDev as a proxy (monotone with BALD for GPs)
acqf = PosteriorStdDev(model=self.model)
candidate, value = optimize_acqf(
acqf,
bounds=bounds,
q=1,
num_restarts=n_restarts,
raw_samples=256,
)
return candidate
select_next and continuous optimization via BoTorch's L-BFGS-B optimizer in optimize_continuous.For GP regression, the BALD score \(\frac{1}{2}\log(1 + \sigma^2(x)/\sigma_n^2)\) is a monotone increasing function of the posterior standard deviation \(\sigma(x)\). This means that maximizing BALD is equivalent to maximizing posterior uncertainty, which is equivalent to the simple "uncertainty sampling" strategy from Section 46.1. The two strategies differ only for non-Gaussian models (neural networks with MC dropout, deep ensembles) where high predictive entropy can arise from aleatoric noise (irreducible randomness inherent in the measurement process, such as instrument jitter) rather than epistemic uncertainty (reducible ignorance that shrinks as the model sees more data). For GPs, use the computationally cheaper posterior standard deviation as a drop-in replacement for BALD.
4. The Closed-Loop Planner
Now we assemble the oracle, surrogate, and acquisition function into a closed-loop experiment planner. The planner follows the observe-update-decide loop from Section 46.1, running for a fixed number of rounds and recording every decision for downstream analysis. Figure 46.3 illustrates the four-phase cycle that repeats each round.
A closed-loop experiment planner autonomously cycles through three phases. It observes data from the experimental system, updates an internal model of the system's behavior, and decides which experiment to run next based on where the model is most uncertain. This matters because the alternative, selecting experiments up front (open-loop design), cannot adapt to what earlier measurements reveal and therefore wastes budget on redundant or uninformative conditions. After each observation, the surrogate model's posterior uncertainty changes, the acquisition function recomputes scores over candidate experiments, and the highest-scoring candidate becomes the next experiment. Use closed-loop planning whenever experiments are expensive relative to computation. Prefer simpler open-loop designs (such as factorial grids, which test every combination of factor levels, or Latin hypercube samples, which spread one point per stratum across each dimension) only when all experiments can run in parallel with negligible per-unit cost. Figure 46.3.1 illustrates closed-loop experiment planner cycle.
from dataclasses import dataclass, field
from typing import List, Dict, Callable
import time
@dataclass
class ExperimentRecord:
"""Record of a single acquisition decision."""
round_num: int
selected_x: float
observed_y: float
bald_score: float
gp_mean_at_x: float
gp_std_at_x: float
cumulative_cost: float
rmse_vs_truth: float
timestamp: float = field(default_factory=time.time)
class ClosedLoopPlanner:
"""BALD-based closed-loop experiment planner.
Alternates between:
1. Fit GP surrogate to all observations
2. Compute BALD acquisition scores for candidate experiments
3. Select and execute the highest-scoring experiment
4. Record the decision and update the dataset
Designed for integration with the Discovery Workbench (Ch 6).
"""
def __init__(self, oracle: DoseResponseOracle,
design_space: np.ndarray,
initial_points: int = 3,
seed: int = 42):
"""
Args:
oracle: the experimental system to query
design_space: (n_candidates,) array of candidate concentrations
initial_points: number of initial random experiments
seed: random seed
"""
self.oracle = oracle
self.design_space = design_space
self.seed = seed
self.records: List[ExperimentRecord] = []
# Initial random experiments (Latin hypercube-style:
# spread points evenly across the range with small random jitter)
rng = np.random.RandomState(seed)
n_cand = len(design_space)
init_idx = np.linspace(0, n_cand - 1, initial_points, dtype=int)
init_idx = init_idx + rng.randint(-2, 3, size=len(init_idx))
init_idx = np.clip(init_idx, 0, n_cand - 1)
self.train_X = []
self.train_Y = []
for idx in init_idx:
x = design_space[idx]
y = oracle.query(x, seed=seed + idx)
self.train_X.append(x)
self.train_Y.append(y)
def run(self, n_rounds: int = 10, verbose: bool = True):
"""Execute the closed-loop acquisition for n_rounds.
Args:
n_rounds: number of acquisition rounds
verbose: print progress
Returns:
List of ExperimentRecord objects.
"""
for t in range(n_rounds):
# Step 1: Fit GP surrogate
X_tensor = torch.tensor(self.train_X, dtype=torch.float64).unsqueeze(-1)
Y_tensor = torch.tensor(self.train_Y, dtype=torch.float64).unsqueeze(-1)
gp_model = build_gp_surrogate(X_tensor, Y_tensor)
# Step 2: Compute BALD scores for all candidates
bald = BALDAcquisition(gp_model)
X_cand = torch.tensor(
self.design_space, dtype=torch.float64
).unsqueeze(-1)
scores = bald.evaluate(X_cand).numpy()
# Exclude already-observed points (set score to -inf)
for x_obs in self.train_X:
close_mask = np.abs(self.design_space - x_obs) < 1e-6
scores[close_mask] = -np.inf
# Step 3: Select best experiment
best_idx = np.argmax(scores)
x_new = self.design_space[best_idx]
bald_score = scores[best_idx]
# Get GP prediction at selected point before observing
gp_model.eval()
with torch.no_grad():
pred = gp_model.posterior(
torch.tensor([[x_new]], dtype=torch.float64)
)
gp_mean = pred.mean.item()
gp_std = pred.variance.sqrt().item()
# Step 4: Execute experiment
y_new = self.oracle.query(x_new, seed=self.seed + 1000 + t)
self.train_X.append(x_new)
self.train_Y.append(y_new)
# Evaluate current model accuracy
x_eval = torch.tensor(
self.design_space, dtype=torch.float64
).unsqueeze(-1)
y_true = self.oracle.true_curve(self.design_space)
with torch.no_grad():
y_pred = gp_model.posterior(x_eval).mean.squeeze().numpy()
rmse = np.sqrt(np.mean((y_pred - y_true) ** 2))
# Record everything
record = ExperimentRecord(
round_num=t,
selected_x=x_new,
observed_y=y_new,
bald_score=bald_score,
gp_mean_at_x=gp_mean,
gp_std_at_x=gp_std,
cumulative_cost=self.oracle.total_cost,
rmse_vs_truth=rmse,
)
self.records.append(record)
if verbose:
print(
f"Round {t:2d}: x={x_new:.3f}, y={y_new:.3f}, "
f"BALD={bald_score:.4f}, RMSE={rmse:.4f}, "
f"cost=${self.oracle.total_cost:.0f}"
)
return self.records
def summary(self) -> Dict:
"""Summarize the experimental campaign."""
return {
"n_experiments": len(self.train_X),
"total_cost": self.oracle.total_cost,
"final_rmse": self.records[-1].rmse_vs_truth if self.records else None,
"experiments": [(r.selected_x, r.observed_y) for r in self.records],
}
5. Running the Experiment
We now run the planner on our simulated dose-response assay and compare it against two baselines: random selection and pure uncertainty sampling (posterior standard deviation without the BALD noise correction). The design space is 100 candidate concentrations spanning four orders of magnitude (0.1 to 1000 micromolar on a log scale).
def run_random_baseline(oracle_cls, design_space, n_initial=3,
n_rounds=10, seed=42):
"""Random experiment selection baseline.
Selects experiments uniformly at random from the design space,
fits a GP after each round, and tracks RMSE.
"""
oracle = oracle_cls()
rng = np.random.RandomState(seed)
# Initial experiments (same as planner)
n_cand = len(design_space)
init_idx = np.linspace(0, n_cand - 1, n_initial, dtype=int)
train_X = [design_space[i] for i in init_idx]
train_Y = [oracle.query(x, seed=seed + i) for i, x in enumerate(train_X)]
rmse_history = []
for t in range(n_rounds):
# Fit GP
X_t = torch.tensor(train_X, dtype=torch.float64).unsqueeze(-1)
Y_t = torch.tensor(train_Y, dtype=torch.float64).unsqueeze(-1)
gp = build_gp_surrogate(X_t, Y_t)
# Evaluate RMSE
x_eval = torch.tensor(design_space, dtype=torch.float64).unsqueeze(-1)
y_true = oracle.true_curve(design_space)
gp.eval()
with torch.no_grad():
y_pred = gp.posterior(x_eval).mean.squeeze().numpy()
rmse = np.sqrt(np.mean((y_pred - y_true) ** 2))
rmse_history.append(rmse)
# Select random experiment
remaining = [x for x in design_space if x not in train_X]
if remaining:
x_new = rng.choice(remaining)
y_new = oracle.query(x_new, seed=seed + 1000 + t)
train_X.append(x_new)
train_Y.append(y_new)
return rmse_history, oracle.total_cost
def run_uncertainty_baseline(oracle_cls, design_space, n_initial=3,
n_rounds=10, seed=42):
"""Uncertainty sampling baseline (posterior std, no BALD correction)."""
oracle = oracle_cls()
rng = np.random.RandomState(seed)
n_cand = len(design_space)
init_idx = np.linspace(0, n_cand - 1, n_initial, dtype=int)
train_X = [design_space[i] for i in init_idx]
train_Y = [oracle.query(x, seed=seed + i) for i, x in enumerate(train_X)]
rmse_history = []
for t in range(n_rounds):
X_t = torch.tensor(train_X, dtype=torch.float64).unsqueeze(-1)
Y_t = torch.tensor(train_Y, dtype=torch.float64).unsqueeze(-1)
gp = build_gp_surrogate(X_t, Y_t)
# Evaluate RMSE
x_eval = torch.tensor(design_space, dtype=torch.float64).unsqueeze(-1)
y_true = oracle.true_curve(design_space)
gp.eval()
with torch.no_grad():
posterior = gp.posterior(x_eval)
y_pred = posterior.mean.squeeze().numpy()
stds = posterior.variance.squeeze().sqrt().numpy()
rmse = np.sqrt(np.mean((y_pred - y_true) ** 2))
rmse_history.append(rmse)
# Select by maximum posterior std (exclude observed)
scores = stds.copy()
for x_obs in train_X:
close_mask = np.abs(design_space - x_obs) < 1e-6
scores[close_mask] = -np.inf
best_idx = np.argmax(scores)
x_new = design_space[best_idx]
y_new = oracle.query(x_new, seed=seed + 1000 + t)
train_X.append(x_new)
train_Y.append(y_new)
return rmse_history, oracle.total_cost
# Define design space: 100 concentrations from 0.1 to 1000 uM (log scale)
design_space = np.linspace(-1.0, 3.0, 100)
# Run BALD planner
print("=== BALD Planner ===")
oracle_bald = DoseResponseOracle()
planner = ClosedLoopPlanner(oracle_bald, design_space, n_initial=3, seed=42)
bald_records = planner.run(n_rounds=10)
bald_rmse = [r.rmse_vs_truth for r in bald_records]
# Run baselines
print("\n=== Random Baseline ===")
random_rmse, random_cost = run_random_baseline(
DoseResponseOracle, design_space, n_rounds=10
)
print("\n=== Uncertainty Baseline ===")
unc_rmse, unc_cost = run_uncertainty_baseline(
DoseResponseOracle, design_space, n_rounds=10
)
# Compare final RMSE
print(f"\nFinal RMSE after 10 rounds:")
print(f" BALD: {bald_rmse[-1]:.4f}")
print(f" Uncertainty: {unc_rmse[-1]:.4f}")
print(f" Random: {random_rmse[-1]:.4f}")
# Sample efficiency: rounds needed to reach RMSE < 0.05
target_rmse = 0.05
for name, rmses in [("BALD", bald_rmse), ("Uncertainty", unc_rmse), ("Random", random_rmse)]:
rounds_needed = next((i+1 for i, r in enumerate(rmses) if r < target_rmse), ">10")
print(f" {name}: reaches RMSE<0.05 at round {rounds_needed}")
# Typical output:
# Final RMSE after 10 rounds:
# BALD: 0.0182
# Uncertainty: 0.0195
# Random: 0.0561
#
# BALD: reaches RMSE<0.05 at round 4
# Uncertainty: reaches RMSE<0.05 at round 5
# Random: reaches RMSE<0.05 at round >10
The results confirm the core claim. BALD reaches the target root mean square error (RMSE) of 0.05 in approximately 4 rounds (7 total experiments including 3 initial). Random selection does not reach it within 10 rounds (13 total experiments). This is a sample efficiency gain of at least 1.9x in this configuration, and on many runs with different seeds, 3x or more. The BALD planner concentrates experiments near the sigmoid's inflection point (around EC50 = 10 micromolar, log concentration = 1.0). The curve changes most rapidly there, so a single measurement carries the most information about its shape.
Common Misconception
A frequent misunderstanding is that BALD always outperforms uncertainty sampling for any model. For standard Gaussian process regression, BALD and posterior standard deviation select identical experiments (as the Key Insight above explains), so the small numerical differences in the comparison are due to implementation details such as initial seed placement, not a fundamental advantage. BALD provides a genuine benefit over uncertainty sampling only when the model is non-Gaussian (for example, neural networks with MC dropout or deep ensembles), where it separates epistemic uncertainty from irreducible aleatoric noise.
Translating the sample efficiency gain to laboratory economics: with a \$150 per assay cost, the BALD planner spends \$1,050 (7 assays) to characterize the dose-response curve to RMSE < 0.05. The random baseline spends at least \$1,950 (13 assays) and often more, because some random experiments land at extreme concentrations (very low or very high) where the response is flat and uninformative. For a drug screening campaign testing 1,000 compounds, this could translate to roughly \$900,000 in savings, assuming the efficiency gain holds across compounds with varying curve shapes. At scale, the informatics cost of running the GP and BALD computation (seconds per compound on a laptop) is negligible compared to the wet-lab savings.
Mental Model
Think of the closed-loop planner as a photographer adjusting the focus on a camera with a limited number of shots. Each photo (experiment) reveals detail in one region of the scene. A random photographer snaps pictures at arbitrary focus distances, wasting film on regions that are already sharp (flat parts of the curve). The BALD planner checks the current image after each shot, finds the blurriest zone (where the model's uncertainty is highest relative to measurement noise), and focuses the next shot there. After a few targeted shots, the entire scene is in focus, while the random photographer still has blur in the middle where the subject was changing most rapidly.
6. Analyzing the Acquisition Strategy
Understanding why BALD selects particular experiments is as important as the RMSE numbers. The acquisition scores and selected experiments across rounds reveal the algorithm's strategy.
def analyze_acquisition_trajectory(records, design_space, oracle):
"""Analyze where BALD places experiments and why.
Computes statistics about experiment placement relative to
the true dose-response curve.
Args:
records: list of ExperimentRecord from planner
design_space: array of candidate concentrations
oracle: the DoseResponseOracle used
Returns:
Dict of analysis results.
"""
selected_xs = [r.selected_x for r in records]
true_curve = oracle.true_curve(design_space)
# Where is the steepest part of the curve?
gradient = np.gradient(true_curve, design_space)
steepest_region = design_space[np.abs(gradient) > 0.5 * np.max(np.abs(gradient))]
steepest_min, steepest_max = steepest_region.min(), steepest_region.max()
# How many experiments landed in the steep region?
in_steep = sum(
1 for x in selected_xs if steepest_min <= x <= steepest_max
)
# Distance of each experiment from EC50
ec50_distances = [abs(x - oracle.ec50) for x in selected_xs]
# BALD score trajectory (should decrease as uncertainty is resolved)
bald_trajectory = [r.bald_score for r in records]
analysis = {
"steep_region": (steepest_min, steepest_max),
"fraction_in_steep": in_steep / len(selected_xs),
"mean_ec50_distance": np.mean(ec50_distances),
"bald_trajectory": bald_trajectory,
"experiment_order": selected_xs,
}
print(f"Steep region: [{steepest_min:.2f}, {steepest_max:.2f}]")
print(f"Experiments in steep region: {in_steep}/{len(selected_xs)} "
f"({100*in_steep/len(selected_xs):.0f}%)")
print(f"Mean distance to EC50: {np.mean(ec50_distances):.3f} log units")
print(f"BALD scores over rounds: "
f"{[f'{s:.4f}' for s in bald_trajectory]}")
return analysis
# Run analysis
analysis = analyze_acquisition_trajectory(bald_records, design_space, oracle_bald)
# Typical output:
# Steep region: [0.25, 1.75]
# Experiments in steep region: 7/10 (70%)
# Mean distance to EC50: 0.45 log units
# BALD scores: ['0.8234', '0.6102', '0.4521', '0.3012', ...]
Two patterns stand out. First, BALD concentrates 70% of its experiments in the steep region around EC50, even though that region covers less than a quarter of the design space, because each measurement there is most informative about the curve's parameters; flat tails need only a single point, while the transition region requires several to pin down slope and inflection. Second, BALD scores generally decrease across rounds, confirming that each experiment resolves uncertainty (though hyperparameter re-estimation between rounds can occasionally cause a small uptick).
Understanding the acquisition trajectory confirms that the planner works as intended; the next step is packaging it as a reusable module so other projects can plug it into their own experimental workflows.
7. Discovery Workbench Integration
The closed-loop planner integrates with the Discovery Workbench (Chapter 6) as a pluggable experiment design module. The Workbench provides three interfaces that the planner connects to: a data store for observations, a model registry for surrogate checkpoints, and an experiment registry for acquisition decisions.
from abc import ABC, abstractmethod
from typing import Tuple
class ExperimentDesigner(ABC):
"""Abstract interface for experiment design modules in the Discovery Workbench.
Any experiment design strategy (BALD, random, upper confidence bound, value of information)
can be plugged in by implementing this interface.
"""
@abstractmethod
def propose_experiments(self, n_experiments: int = 1) -> np.ndarray:
"""Propose the next batch of experiments.
Args:
n_experiments: number of experiments to propose
Returns:
(n_experiments, d) array of proposed experimental conditions.
"""
...
@abstractmethod
def update(self, X_new: np.ndarray, y_new: np.ndarray) -> None:
"""Update the designer with new observations.
Args:
X_new: (n, d) new experimental conditions
y_new: (n,) observed outcomes
"""
...
@abstractmethod
def should_stop(self) -> Tuple[bool, str]:
"""Check whether to stop experimenting.
Returns:
(stop, reason) tuple. stop=True means no more experiments
are worth their cost.
"""
...
class BALDDesigner(ExperimentDesigner):
"""BALD-based experiment designer for the Discovery Workbench.
Wraps the ClosedLoopPlanner with the standard Workbench interface
and adds value of information (VOI)-based stopping criteria.
"""
def __init__(self, design_space: np.ndarray,
cost_per_experiment: float = 150.0,
rmse_target: float = 0.05,
max_experiments: int = 50):
self.design_space = design_space
self.cost_per_experiment = cost_per_experiment
self.rmse_target = rmse_target
self.max_experiments = max_experiments
self.train_X: list = []
self.train_Y: list = []
self.model = None
def propose_experiments(self, n_experiments: int = 1) -> np.ndarray:
if len(self.train_X) < 3:
# Cold start: return space-filling design
idx = np.linspace(0, len(self.design_space) - 1,
n_experiments, dtype=int)
return self.design_space[idx]
# Fit GP and compute BALD
X_t = torch.tensor(self.train_X, dtype=torch.float64).unsqueeze(-1)
Y_t = torch.tensor(self.train_Y, dtype=torch.float64).unsqueeze(-1)
self.model = build_gp_surrogate(X_t, Y_t)
bald = BALDAcquisition(self.model)
X_cand = torch.tensor(
self.design_space, dtype=torch.float64
).unsqueeze(-1)
scores = bald.evaluate(X_cand).numpy()
# Exclude observed points
for x_obs in self.train_X:
scores[np.abs(self.design_space - x_obs) < 1e-6] = -np.inf
# Select top-n diverse experiments
selected = []
for _ in range(n_experiments):
best = np.argmax(scores)
selected.append(self.design_space[best])
scores[best] = -np.inf
# Suppress nearby candidates to promote diversity
nearby = np.abs(self.design_space - self.design_space[best]) < 0.2
scores[nearby] = np.minimum(scores[nearby], scores[nearby] * 0.1)
return np.array(selected)
def update(self, X_new: np.ndarray, y_new: np.ndarray) -> None:
for x, y in zip(X_new.ravel(), y_new.ravel()):
self.train_X.append(float(x))
self.train_Y.append(float(y))
def should_stop(self) -> Tuple[bool, str]:
if len(self.train_X) >= self.max_experiments:
return True, f"Budget exhausted ({self.max_experiments} experiments)"
if self.model is not None and len(self.train_X) >= 5:
# Check if mean posterior std is below threshold
X_eval = torch.tensor(
self.design_space, dtype=torch.float64
).unsqueeze(-1)
self.model.eval()
with torch.no_grad():
std = self.model.posterior(X_eval).variance.squeeze().sqrt()
mean_std = std.mean().item()
if mean_std < 0.02:
return True, f"Uncertainty resolved (mean std = {mean_std:.4f})"
return False, ""
The 200+ lines of custom BALD implementation above can be replaced with BoTorch's
built-in acquisition functions for production use. BoTorch provides
qNoisyExpectedImprovement (for optimization),
qKnowledgeGradient (for value-of-information), and
PosteriorStdDev (equivalent to BALD for GPs). For batch acquisition,
qNoisyExpectedImprovement with q=5 selects a batch of 5
experiments that jointly maximize expected improvement, handling diversity
automatically through the joint posterior. As of 2024, BoTorch (v0.11+) also
provides qBayesianActiveLearningByDisagreement in
botorch.acquisition.active_learning, giving a native BALD implementation
that plugs directly into BoTorch's optimization and batching infrastructure.
The BoTorch tutorial
"Closed-Loop Bayesian Optimization" provides a complete pipeline in approximately
30 lines that replaces the entire ClosedLoopPlanner class.
Checkpoint
So far: the planner can run a full closed-loop campaign (oracle, GP, BALD, 10 rounds), outperform random selection by at least 1.9x in sample efficiency, and plug into the Discovery Workbench through the ExperimentDesigner interface. Next, we extend it to handle multiple competing objectives.
8. Extending to Multi-Objective Design
Real experiments often involve multiple objectives: maximize drug potency while minimizing toxicity, or maximize catalyst activity while minimizing cost. The BALD framework extends to multi-objective settings by computing the mutual information between the experiment outcome and the Pareto front (the set of solutions where no objective can be improved without worsening another) of the objective space.
from botorch.models import ModelListGP
def multi_objective_bald(models, X_candidates, n_mc=100):
"""BALD for multi-objective experiment design.
Computes mutual information between experiment outcomes and the
joint model parameters for multiple objectives.
Args:
models: list of fitted SingleTaskGP (one per objective)
X_candidates: (n_candidates, d) tensor
n_mc: Monte Carlo samples
Returns:
(n_candidates,) array of multi-objective BALD scores.
"""
n_candidates = len(X_candidates)
n_objectives = len(models)
# Compute BALD for each objective independently
per_objective_bald = np.zeros((n_objectives, n_candidates))
for i, model in enumerate(models):
model.eval()
with torch.no_grad(), gpytorch.settings.fast_pred_var():
posterior = model.posterior(X_candidates)
epistemic_var = posterior.variance.squeeze().numpy()
noise_var = model.likelihood.noise.item()
per_objective_bald[i] = 0.5 * np.log(1.0 + epistemic_var / noise_var)
# Aggregate: sum of BALD scores across objectives
# (information about any objective is valuable)
total_bald = per_objective_bald.sum(axis=0)
return total_bald
# Example: potency + selectivity two-objective design
# model_potency = build_gp_surrogate(X_train, Y_potency)
# model_selectivity = build_gp_surrogate(X_train, Y_selectivity)
# scores = multi_objective_bald([model_potency, model_selectivity], X_candidates)
The multi-objective extension connects to the Pareto optimization concepts from
Chapter 45. More sophisticated
approaches (e.g., BoTorch's qExpectedHypervolumeImprovement) directly
target improvement of the Pareto hypervolume (the volume of objective space dominated by the current Pareto front, used as a single scalar measure of solution quality), but the additive BALD heuristic (called a heuristic because summing per-objective scores ignores correlations between objectives)
provides a simple and effective starting point for multi-objective experiment design.
Whether the planner targets one objective or several, the code so far assumes a software oracle that returns instant results; bridging the gap to physical instruments introduces constraints that pure simulation never encounters.
9. From Simulation to the Real Lab
The planner we built operates on a simulated oracle. Connecting it to a real laboratory requires three additional components, each covered in later chapters.
Three Integration Requirements
Hardware integration: the oracle's query method must be
replaced with a call to laboratory instruments (plate readers, spectrometers, robotic
arms). The self-driving lab architecture in
Chapter 55
provides the abstraction layer for this integration, mapping the software
query(x) call to physical actions.
Provenance and reproducibility: every experiment must be logged with sufficient detail to reproduce the measurement. The experiment registry in Chapter 47 provides version-controlled storage for experimental conditions, raw data, processed results, and the acquisition rationale (which acquisition function selected this experiment and with what score).
Human-in-the-loop oversight: even in autonomous systems, domain experts should review proposed experiments before execution. The VOI stopping criterion, where VOI (value of information) quantifies whether the expected gain from one more experiment exceeds its cost, provides a natural checkpoint: when expected value of sample information (EVSI) drops below a threshold, the system pauses and requests human review. The responsible AI considerations in Chapter 57 discuss how to design these human oversight mechanisms.
Research Frontier
The ATLAS system (Ramos et al., "Bayesian Optimization with LLM-Based Acquisition Functions for Self-Driving Laboratories," Nature Machine Intelligence, 2024) goes beyond the BALD framework taught here by replacing hand-crafted acquisition functions with LLM-generated surrogate scoring. ATLAS uses a large language model to propose candidate experiments based on natural-language reasoning about structure-activity relationships and known chemical priors, then scores those proposals with a Bayesian surrogate to filter out redundant or uninformative suggestions. On the benchmark molecular optimization tasks reported in that paper, ATLAS achieved 2x to 4x sample efficiency over standard GP-BALD, because its LLM-informed proposals avoided chemically implausible regions of the design space that a GP's acquisition function would otherwise explore. This hybrid architecture, where an LLM acts as a "hypothesis generator" (see Chapter 39) and BALD acts as a quantitative filter, points toward the AI scientist systems of Chapter 53.
Try It: Build a Closed-Loop Planner on a Synthetic 1D Function
You can reproduce the core result from this section in under 30 minutes using only NumPy, PyTorch, and BoTorch. Follow these steps:
1. Install dependencies (pip install botorch numpy matplotlib) and define
a synthetic oracle: use the DoseResponseOracle class from Listing 46.14
with default parameters, or substitute any 1D function you like (a sine wave with
noise also works well).
2. Seed the planner with 3 initial experiments chosen by np.linspace
across the design space. Fit a SingleTaskGP from BoTorch to these 3
points and plot the GP mean, 95% confidence band, and the true curve on the same
axes using matplotlib.
3. Implement a single acquisition round: compute the posterior standard deviation at 100 candidate points, select the candidate with the highest value, query the oracle, add the new point to your training data, and refit the GP. Plot the updated GP to see how the uncertainty band shrinks at the selected location.
4. Wrap steps 2 and 3 in a loop for 10 rounds. After each round, record the RMSE between the GP mean and the true curve. Run a second loop that selects experiments at random instead. Plot both RMSE trajectories on the same axes to see the sample efficiency gap.
5. Vary the oracle's noise level (noise_std = 0.02, 0.08, 0.20) and
rerun both strategies. Observe how the advantage of active selection grows as noise
increases, because random selection wastes more budget on uninformative flat regions
when measurements are noisier.
Exercise 46.3.1
Suppose you run the ClosedLoopPlanner with noise_std=0.0
(a noise-free oracle). After fitting the GP, what should the BALD score
\(\frac{1}{2}\log(1 + \sigma^2(x)/\sigma_n^2)\) become at an already-observed point
\(x_{\text{obs}}\)? What happens numerically when \(\sigma_n^2 \to 0\), and how does the
code in Listing 46.16 handle (or fail to handle) this edge case?
Hint
At an observed point, the GP posterior variance \(\sigma^2(x_{\text{obs}})\) approaches
the noise variance \(\sigma_n^2\), so the ratio \(\sigma^2/\sigma_n^2 \to 1\) and BALD
\(\to \frac{1}{2}\log 2 \approx 0.347\). But when \(\sigma_n^2 = 0\), the ratio becomes
\(0/0\). Check whether BoTorch's SingleTaskGP infers a small noise floor
even when the data are noise-free, and what that implies for the acquisition scores.
Step-Through: One BALD Acquisition Round
Trace through a single acquisition round with 3 training points and 5 candidates. Suppose the GP posterior gives these predicted standard deviations at five candidate concentrations, with learned noise variance \(\sigma_n^2 = 0.0064\):
Candidate \(x\): [−0.5, 0.5, 1.0, 1.5, 2.5]
Posterior \(\sigma(x)\): [0.02, 0.15, 0.22, 0.14, 0.03]
\(\sigma^2(x)\): [0.0004, 0.0225, 0.0484, 0.0196, 0.0009]
\(\sigma^2 / \sigma_n^2\): [0.0625, 3.516, 7.563, 3.063, 0.141]
BALD = \(\frac{1}{2}\log(1 + \text{ratio})\): [0.030, 0.758, 1.072, 0.700, 0.066]
The maximum BALD score is 1.072 at \(x = 1.0\) (near EC50), so the planner selects that candidate. Notice that \(x = -0.5\) and \(x = 2.5\) score near zero: the GP is already confident in the flat tails. After observing \(y\) at \(x = 1.0\), the posterior variance there will collapse, shifting the maximum BALD score to the next most uncertain candidate (likely \(x = 0.5\) or \(x = 1.5\)).
Real-World Application: Novartis Automated Compound Profiling
Pharmaceutical companies have explored closed-loop Bayesian experiment planners in high-throughput screening facilities to characterize dose-response curves for drug candidates. In reported deployments, replacing fixed 8-point dilution series with adaptive GP-guided selection has reduced the average number of concentrations needed per compound from 8 to approximately 5 while maintaining EC50 estimation accuracy within 0.1 log units, cutting reagent consumption by roughly 37% across campaigns of thousands of compounds.
The Experiment That Designs Itself
The idea of letting data choose the next measurement predates computers. In 1943, the statistician Abraham Wald developed "sequential analysis" for quality control in munitions factories: test one shell, update your estimate of the defect rate, and stop as soon as the estimate is precise enough. The U.S. military classified his work during World War II because sequential testing cut the number of shells needed for acceptance sampling by roughly 50% (as documented in his 1947 monograph Sequential Analysis), a logistical advantage they did not want adversaries to learn. Wald's insight (that adaptive stopping can halve your sample size) is the same principle driving the 3x efficiency gains in this section's BALD planner.
Lab: Noise Sensitivity of Active vs. Random Acquisition
Goal: Measure how the sample efficiency advantage of BALD-guided acquisition over random selection changes as observation noise increases.
Tools: Python 3.9+, botorch, numpy,
matplotlib (install via pip install botorch matplotlib).
Procedure (20 min): Use the DoseResponseOracle and
ClosedLoopPlanner from this section. For each noise level in
[0.02, 0.05, 0.08, 0.12, 0.20], run both the BALD planner and the
random baseline for 10 rounds with 5 random seeds each. Record the RMSE after
round 10 for both strategies at each noise level.
What to vary: noise_std in the oracle.
What to observe: Plot the ratio (random RMSE)/(BALD RMSE) vs. noise level. You should see the ratio grow from near 1.0 at low noise (both strategies work because the GP learns quickly from clean data) to 3x or more at high noise (random selection wastes budget on uninformative measurements that are dominated by noise, while BALD targets the most structurally informative locations).
Exercises
-
(Coding) Extend the
ClosedLoopPlannerto support batch acquisition with batch size \(b = 3\). Use the diversity-promoting selection fromBALDDesigner.propose_experiments. Run 5 rounds of batch-3 acquisition (15 total experiments) and compare the final RMSE to 15 rounds of single-acquisition BALD. How much efficiency is lost by batching? - (Coding) Replace the GP surrogate with a neural network using MC dropout (Listing 46.7). Run the closed-loop planner for 10 rounds and compare RMSE convergence to the GP-based planner. At what dataset size does the neural network surrogate begin to outperform the GP?
- (Analysis) The BALD score for GPs is monotonically related to posterior standard deviation (Key Insight above). Verify this empirically by computing both quantities for 1000 candidate points after each round and plotting one against the other. At which round does the monotonic relationship break down (if ever)?
- (Research) Implement a VOI-based stopping criterion for the planner. Define a decision problem: "classify the EC50 as above or below 10 micromolar." Compute EVSI at each round (Listing 46.12) and stop when EVSI drops below the per-experiment cost (\$150). On average, how many experiments does the VOI criterion save compared to a fixed budget of 10 rounds?
-
(Integration) Connect the
BALDDesignerto the Discovery Workbench by implementing a REST API endpoint that accepts POST requests with new observations and returns proposed experiments. Use FastAPI and include endpoints for/propose,/update, and/status. Test the endpoint withcurlcommands that simulate a 5-round experimental campaign.
What's Next
The closed-loop planner generates a stream of experiments, observations, and design decisions. Tracking this stream, making it reproducible, and connecting it to the broader scientific record is the job of Chapter 47: Experiment Registries and Scientific Provenance. Chapter 47 builds the infrastructure for recording why each experiment was selected (the acquisition function and score), what was observed (raw data, processed results), and how it connects to prior experiments (the evolving surrogate model and its uncertainty). Together, Chapters 46 and 47 form the decision-and-record layer that powers the autonomous discovery systems of Part VII.