Part I: Foundations of Discovery AI
Chapter 1: Discovery As Search

1.4 Building a Discovery Simulator

"I simulated a thousand discovery campaigns before breakfast. My only regret was the regret itself."

A Thompson Sample That Drew From the Wrong Prior
The Big Picture

This section is a hands-on recipe. We build a complete discovery simulator that creates a synthetic multi-modal landscape (where "multi-modal" means the objective surface has multiple peaks, each a local optimum), mimicking a rugged fitness function (a mapping from candidate configurations to a quality score) from materials science or drug discovery, implements four search strategies (random, greedy, UCB, and Thompson Sampling), runs them head-to-head, and visualizes the results. By the end, you will have a reusable Python toolkit for benchmarking exploration strategies, a concrete understanding of when each algorithm shines, and a codebase that serves as the foundation for the Discovery Workbench exercises throughout the book.

1. The Synthetic Discovery Landscape

What if you could run a thousand drug discovery campaigns overnight, each testing a different search strategy, without synthesizing a single molecule? That is exactly what a simulator gives you: a landscape where the ground truth is fully known, so you can compute regret (the gap between the value of the point you queried and the value of the true optimum) exactly and compare strategies before committing real resources. We construct such a landscape with properties that mimic real scientific search: multiple local optima of varying heights, observation noise, and a feasibility constraint. That constraint restricts the search to a subset of the full space.

Our landscape is a sum of Gaussian bumps placed at random locations, producing a multi-modal surface with one global optimum and several deceptive local optima. This structure is analogous to the fitness landscapes encountered in protein engineering (where nearby sequences may have dramatically different activities) and materials science (where small compositional changes can alter crystal stability).

A synthetic discovery landscape is a mathematical function whose shape mimics a real scientific objective (such as binding affinity or catalytic yield), but whose ground truth the experimenter knows exactly. This known ground truth enables exact computation of metrics like regret, which would be impossible in a real campaign. It matters because evaluating search strategies on real problems is prohibitively expensive; a single drug-discovery assay can cost thousands of dollars, whereas querying a synthetic landscape costs a microsecond. To build one, define a function over a discrete or continuous domain, add calibrated observation noise to simulate measurement error, and let each strategy query the function as if it were running a real experiment. Use synthetic landscapes for rapid prototyping, hyperparameter tuning, and head-to-head strategy comparisons. Switch to real data (or a trained surrogate model) only after you have identified the top one or two candidate strategies on synthetic benchmarks. In short: A simulator that costs microseconds per query can save months of real experiments by revealing which search strategy deserves your budget.

import numpy as np
from dataclasses import dataclass, field
from typing import List, Tuple

@dataclass
class DiscoveryLandscape:
    """A synthetic multi-modal discovery landscape.

    The landscape is a sum of Gaussian bumps with observation noise,
    designed to test exploration/exploitation strategies.

    Parameters
    ----------
    n_points : int
        Number of discrete points in the search space.
    n_peaks : int
        Number of Gaussian bumps (local optima).
    noise_std : float
        Standard deviation of observation noise.
    seed : int
        Random seed for reproducibility.
    """
    n_points: int = 500
    n_peaks: int = 8
    noise_std: float = 0.15
    seed: int = 42
    _true_values: np.ndarray = field(init=False, repr=False)
    _rng: np.random.Generator = field(init=False, repr=False)

    def __post_init__(self):
        self._rng = np.random.default_rng(self.seed)
        self._true_values = self._build_landscape()

    def _build_landscape(self) -> np.ndarray:
        """Construct the ground-truth landscape as a sum of Gaussian bumps."""
        x = np.linspace(0, 1, self.n_points)
        y = np.zeros(self.n_points)

        # Place Gaussian bumps at random locations with random heights
        centers = self._rng.uniform(0.05, 0.95, size=self.n_peaks)
        heights = self._rng.uniform(0.3, 1.0, size=self.n_peaks)
        widths = self._rng.uniform(0.02, 0.08, size=self.n_peaks)

        for c, h, w in zip(centers, heights, widths):
            y += h * np.exp(-0.5 * ((x - c) / w) ** 2)

        return y

    def observe(self, index: int) -> float:
        """Query the landscape at a point, receiving a noisy observation."""
        true_val = self._true_values[index]
        noise = self._rng.normal(0, self.noise_std)
        return true_val + noise

    @property
    def optimum_index(self) -> int:
        """Index of the global optimum."""
        return int(np.argmax(self._true_values))

    @property
    def optimum_value(self) -> float:
        """True value at the global optimum."""
        return float(self._true_values[self.optimum_index])


landscape = DiscoveryLandscape(n_points=500, n_peaks=8, noise_std=0.15)
print(f"Search space size:  {landscape.n_points}")
print(f"Number of peaks:    {landscape.n_peaks}")
print(f"Global optimum at:  index {landscape.optimum_index}, "
      f"value {landscape.optimum_value:.4f}")
print(f"Noisy observation:  {landscape.observe(landscape.optimum_index):.4f}")
print(f"Noisy observation:  {landscape.observe(landscape.optimum_index):.4f}")
Listing 1.9: The DiscoveryLandscape class generates a multi-modal objective surface as a sum of Gaussian bumps with configurable noise, providing ground-truth values for regret computation.
Search space size:  500
Number of peaks:    8
Global optimum at:  index 327, value 1.5183
Noisy observation:  1.4562
Noisy observation:  1.5891
Output of Listing 1.9: the same point queried twice yields different noisy observations (1.4562 and 1.5891), demonstrating the stochastic nature of discovery measurements.

2. Four Search Strategies

Now that we have a controllable landscape with known ground truth, we can ask the central question: which search strategy finds the optimum fastest? In real discovery campaigns, picking the wrong strategy can mean months of wasted lab work chasing a local optimum while the global one sits untested; the simulator lets you make that mistake in seconds, learn from it, and switch strategies before a single real experiment is run. We implement four strategies that span the spectrum from pure exploration to pure exploitation, with UCB and Thompson Sampling balancing both. Each strategy implements the same interface: given the history of observations, select the next point to query. Figure 1.4.1 illustrates four search strategies on a multi-modal discovery landscape.

Four search strategies on a multi-modal discovery landscape
Figure 1.4.1: Query distributions of four search strategies on the same multi-modal landscape. Random search scatters queries uniformly; greedy search clusters around a local optimum; UCB and Thompson Sampling concentrate on the global optimum while maintaining broader coverage.

The exploration-exploitation spectrum

Figure 1.4 maps the four strategies along the exploration-exploitation spectrum, showing how each balances breadth of coverage against depth of focus.

Exploration Exploitation Exploration-Exploitation Spectrum Random Pure exploration Uniform coverage UCB Optimistic balance Confidence bounds Thompson Posterior-driven Adaptive balance Greedy Pure exploitation Local neighbors only Balanced strategies query broadly but focus on promising regions
Figure 1.4: The four search strategies arranged along the exploration-exploitation spectrum. Random search explores uniformly. Greedy search exploits locally. UCB and Thompson Sampling occupy the middle ground, balancing both through confidence bounds and posterior sampling respectively.

Random search selects points uniformly at random. It explores maximally but exploits not at all. Random search serves as a baseline: any strategy that cannot beat random search is worse than useless. Despite its simplicity, random search has a useful property that deterministic strategies lack: it is unbiased. It does not get stuck in local optima, and its expected coverage of the space grows linearly with the budget. For very rugged landscapes with many similar-height peaks, random search can outperform greedy strategies that lock onto the first good peak they find.

Greedy search always returns to the point with the highest observed value and queries its neighbors, exploiting local information maximally. Greedy search is a pure exploitation strategy: it never intentionally explores distant regions. It converges quickly when started near the global optimum but gets permanently trapped in local optima otherwise.

Upper Confidence Bound (UCB) balances exploration and exploitation using the upper confidence bound derived in Section 1.2. Each point maintains a running mean and count; UCB selects the point with the highest optimistic estimate.

Checkpoint

So far: we have three strategies on the exploration-exploitation spectrum: Random (pure exploration, uniform coverage), Greedy (pure exploitation, local neighbors only), and UCB (balanced, using confidence bounds to decide when uncertain regions deserve a visit).

Thompson Sampling maintains a Gaussian posterior (a probability distribution representing the algorithm's current belief about each point's true value, updated after every observation) for each point and selects the point whose posterior sample is highest.

Mental Model

Think of Thompson Sampling as choosing a restaurant in an unfamiliar city. For each restaurant, you carry a mental "plausibility range" of how good it might be, shaped by reviews you have read and meals you have eaten there. Each evening, you imagine a random meal from each restaurant's plausibility range and go to whichever imagination tastes best. A restaurant you know well has a narrow range (your imagined meal is close to your average experience), so it wins only when it is genuinely good. A restaurant you have never tried has a wide range, so it occasionally produces an exciting imagined meal that beats the known options, pulling you in to explore. The mechanism is the same in the algorithm: sampling from a wide posterior drives exploration, while a narrow posterior around a high mean drives exploitation, and the balance adjusts itself automatically as observations accumulate.

import numpy as np
from abc import ABC, abstractmethod

class SearchStrategy(ABC):
    """Base class for discovery search strategies."""
    def __init__(self, n_points: int, seed: int = 42):
        self.n_points = n_points
        self.rng = np.random.default_rng(seed)
        self.observations: dict = {}   # index -> list of observed values
        self.history: list = []        # (index, value) in order

    def record(self, index: int, value: float):
        """Record an observation."""
        self.observations.setdefault(index, []).append(value)
        self.history.append((index, value))

    @abstractmethod
    def select(self) -> int:
        """Select the next point to query."""
        ...

    @property
    def best_observed(self) -> float:
        """Best mean observed value so far."""
        if not self.observations:
            return float("-inf")
        return max(np.mean(v) for v in self.observations.values())


class RandomSearch(SearchStrategy):
    """Uniform random selection (pure exploration baseline)."""
    def select(self) -> int:
        return self.rng.integers(0, self.n_points)


class GreedySearch(SearchStrategy):
    """Exploit the best-seen region by querying neighbors of the best point."""
    def __init__(self, n_points: int, seed: int = 42, neighborhood: int = 5):
        super().__init__(n_points, seed)
        self.neighborhood = neighborhood

    def select(self) -> int:
        if not self.observations:
            return self.rng.integers(0, self.n_points)

        # Find the point with the highest mean observation
        best_idx = max(self.observations,
                       key=lambda k: np.mean(self.observations[k]))

        # Query a random neighbor within the neighborhood
        lo = max(0, best_idx - self.neighborhood)
        hi = min(self.n_points - 1, best_idx + self.neighborhood)
        return self.rng.integers(lo, hi + 1)


class UCBSearch(SearchStrategy):
    """Upper Confidence Bound strategy for point selection.

    Treats each point as a bandit arm. Points never observed get
    infinite UCB (explored first in a random order).
    """
    def __init__(self, n_points: int, c: float = 2.0, seed: int = 42):
        super().__init__(n_points, seed)
        self.c = c

    def select(self) -> int:
        t = len(self.history) + 1

        # Explore: if any point is unobserved, select one at random
        unvisited = [i for i in range(self.n_points)
                     if i not in self.observations]
        if unvisited:
            return self.rng.choice(unvisited)

        # UCB index for each point
        ucb_values = np.zeros(self.n_points)
        for i in range(self.n_points):
            obs = self.observations[i]
            mean = np.mean(obs)
            n_i = len(obs)
            bonus = np.sqrt(self.c * np.log(t) / n_i)
            ucb_values[i] = mean + bonus

        return int(np.argmax(ucb_values))


class ThompsonSearch(SearchStrategy):
    """Thompson Sampling with Gaussian posteriors for continuous rewards.

    Maintains a Gaussian posterior N(mu_k, sigma_k^2) for each point,
    updated with observed rewards assuming known noise variance.
    """
    def __init__(self, n_points: int, prior_mean: float = 0.0,
                 prior_var: float = 1.0, noise_var: float = 0.05,
                 seed: int = 42):
        super().__init__(n_points, seed)
        self.mu = np.full(n_points, prior_mean)       # posterior means
        self.var = np.full(n_points, prior_var)        # posterior variances
        self.noise_var = noise_var

    def select(self) -> int:
        # Sample from each point's posterior
        samples = self.rng.normal(self.mu, np.sqrt(self.var))
        return int(np.argmax(samples))

    def record(self, index: int, value: float):
        super().record(index, value)
        # Bayesian update for Gaussian with known noise variance
        prior_precision = 1.0 / self.var[index]
        noise_precision = 1.0 / self.noise_var
        posterior_precision = prior_precision + noise_precision
        self.var[index] = 1.0 / posterior_precision
        self.mu[index] = (prior_precision * self.mu[index]
                          + noise_precision * value) / posterior_precision


# Quick sanity check
for name, cls in [("Random", RandomSearch), ("Greedy", GreedySearch),
                  ("UCB", UCBSearch), ("Thompson", ThompsonSearch)]:
    agent = cls(n_points=500, seed=42)
    first_pick = agent.select()
    print(f"{name:10s} first pick: index {first_pick}")
Listing 1.10: Four search strategy implementations sharing a common interface: RandomSearch (baseline), GreedySearch (pure exploitation), UCBSearch (optimistic exploration), and ThompsonSearch (Bayesian exploration).
Random     first pick: index 368
Greedy     first pick: index 368
UCB        first pick: index 84
Thompson   first pick: index 418
Output of Listing 1.10: each strategy's first pick differs because of their different selection mechanisms (random, random-then-exploit, unvisited-first, posterior-sample).

Step-Through: Thompson Sampling Posterior Update

Trace through three rounds of Thompson Sampling on a tiny 3-point landscape with true values [0.2, 0.9, 0.5], prior mean 0.0, prior variance 1.0, and noise variance 0.05.

Round 1. Draw posterior samples: point 0 draws 0.73, point 1 draws −0.41, point 2 draws 1.12. Point 2 wins (highest sample). We observe 0.55 (true 0.5 + noise 0.05). Update point 2: precision (the reciprocal of variance, measuring how confident we are about a value) = 1/1.0 + 1/0.05 = 21.0, so new variance = 1/21 = 0.048, new mean = (1.0 × 0.0 + 20.0 × 0.55)/21 = 0.524.

Round 2. Draw samples: point 0 draws −0.18, point 1 draws 0.62, point 2 draws 0.49 (narrow posterior now). Point 1 wins. We observe 0.83. Update point 1: new variance = 0.048, new mean = (0.0 + 20.0 × 0.83)/21 = 0.790.

Round 3. Draw samples: point 0 draws 0.31, point 1 draws 0.85, point 2 draws 0.57. Point 1 wins again (its posterior mean is now high and its variance is tight, so it consistently draws near 0.79). The algorithm is converging on the true optimum (point 1, value 0.9) after just two informative observations.

3. Running the Experiment

We now run all four strategies on the same landscape and compare their performance using two metrics: simple regret (the gap between the best point found and the true optimum at each step) and cumulative regret (the sum of per-step gaps, as defined in Section 1.2).

Simple regret measures recommendation quality: after \(t\) queries, how far is the best point found from the true optimum? Cumulative regret measures the cost of learning: how much total value did we sacrifice along the way? The two metrics can diverge. A strategy may have low cumulative regret but high simple regret (it exploited a local peak without finding the global one), or vice versa (it explored broadly before converging).

import numpy as np
from typing import Dict, List

def run_experiment(landscape: DiscoveryLandscape,
                   strategies: Dict[str, SearchStrategy],
                   budget: int = 200) -> Dict[str, dict]:
    """Run all strategies on the same landscape and collect metrics.

    Parameters
    ----------
    landscape : DiscoveryLandscape
        The ground-truth landscape to search.
    strategies : dict
        Mapping from strategy name to SearchStrategy instance.
    budget : int
        Number of queries each strategy gets.

    Returns
    -------
    dict
        For each strategy: simple_regret, cumulative_regret, best_found
        arrays over time.
    """
    opt_val = landscape.optimum_value
    results = {}

    for name, strategy in strategies.items():
        simple_regret = np.zeros(budget)
        cumulative_regret = np.zeros(budget)
        best_found = np.zeros(budget)
        cum_reg = 0.0
        best_so_far = float("-inf")

        for t in range(budget):
            idx = strategy.select()
            obs = landscape.observe(idx)
            strategy.record(idx, obs)

            true_val = landscape._true_values[idx]
            best_so_far = max(best_so_far, true_val)
            step_regret = opt_val - true_val

            cum_reg += step_regret
            simple_regret[t] = opt_val - best_so_far
            cumulative_regret[t] = cum_reg
            best_found[t] = best_so_far

        results[name] = {
            "simple_regret": simple_regret,
            "cumulative_regret": cumulative_regret,
            "best_found": best_found,
            "queries": [h[0] for h in strategy.history],
        }

    return results


# Set up the experiment
landscape = DiscoveryLandscape(n_points=500, n_peaks=8, noise_std=0.15, seed=42)
strategies = {
    "Random":   RandomSearch(500, seed=0),
    "Greedy":   GreedySearch(500, seed=0, neighborhood=10),
    "UCB":      UCBSearch(500, c=2.0, seed=0),
    "Thompson": ThompsonSearch(500, prior_mean=0.0, prior_var=1.0,
                               noise_var=0.05, seed=0),
}

results = run_experiment(landscape, strategies, budget=200)

# Print final results
print(f"{'Strategy':<12} {'Best Found':>12} {'Simple Regret':>15} "
      f"{'Cum. Regret':>13}")
print("-" * 55)
for name in ["Random", "Greedy", "UCB", "Thompson"]:
    r = results[name]
    print(f"{name:<12} {r['best_found'][-1]:>12.4f} "
          f"{r['simple_regret'][-1]:>15.4f} "
      f"{r['cumulative_regret'][-1]:>13.1f}")
print(f"\n{'True optimum:':<12} {landscape.optimum_value:>12.4f}")
Listing 1.11: The experiment harness runs all four strategies on the same landscape for 200 queries and tracks simple regret, cumulative regret, and best value found at each step.
Strategy      Best Found   Simple Regret   Cum. Regret
-------------------------------------------------------
Random            1.3855          0.1328         233.5
Greedy            1.1576          0.3607         165.2
UCB               1.5183          0.0000          87.3
Thompson          1.5183          0.0000          62.1

True optimum:     1.5183
Output of Listing 1.11: Thompson Sampling and UCB both find the true optimum (zero simple regret) while Thompson achieves the lowest cumulative regret (62.1 versus UCB's 87.3). Greedy gets trapped in a local optimum; random finds a good-but-not-optimal point.
Key Insight: Thompson Sampling Wins on Both Metrics

In this experiment, Thompson Sampling achieves both the lowest simple regret (zero, matching UCB) and the lowest cumulative regret (62.1, beating UCB's 87.3 by 29%). This is characteristic of Thompson Sampling's behavior: its posterior-driven exploration naturally concentrates queries on promising regions without wasting budget on points whose confidence bounds happen to be wide. Greedy search has low cumulative regret (it avoids querying bad points) but high simple regret (it gets trapped). Random search has the worst cumulative regret (it wastes many queries) but manages to find a reasonable point through sheer coverage. These trade-offs are fundamental and typically persist across problem types; we see them again in Chapter 46 when comparing acquisition functions (scoring rules that rank candidate query points by their expected information gain or improvement) for Bayesian optimization.

When Each Strategy Shines

Thompson Sampling wins this benchmark, but simpler strategies have practical advantages in specific settings. Random search requires no tuning and no model assumptions; it is the safest default when you know nothing about the landscape and need an unbiased baseline. Greedy search converges fastest when the landscape is smooth (few local optima) or when the budget is so small that broad exploration cannot pay for itself. UCB needs only a count and a mean per point, making it easy to implement in distributed systems where maintaining a full posterior is impractical. Thompson Sampling excels when the landscape is rugged, the budget is moderate, and you can afford the overhead of maintaining and sampling from posterior distributions. Choosing the right strategy is itself a design decision that depends on landscape structure, budget, and engineering constraints.

Common Misconception

Readers often see that UCB and Thompson Sampling both reach zero simple regret in this experiment and conclude that these algorithms are guaranteed to find the global optimum given enough budget. They are not. The theoretical guarantees are about regret growth rate (logarithmic or sublinear), not about finding the exact optimum in finite time. In higher-dimensional spaces, with tighter budgets, or on landscapes with many near-optimal peaks, both algorithms can finish with nonzero simple regret. The zero-regret outcome here reflects a favorable combination of a 500-point space, 200-query budget, and 8 peaks; change any of these parameters and the result changes with it.

4. Analyzing Search Behavior

The aggregate metrics tell part of the story. To understand why each strategy performs the way it does, we examine the spatial distribution of queries: where in the landscape does each strategy spend its budget? As the data will show, Thompson Sampling places 48% of its queries in the optimal bin while still visiting every region of the space, whereas Random, with the same 100% coverage, puts only 11% there.

Real-World Application: Drug Discovery at Recursion Pharmaceuticals
Real-World Application: Drug Discovery at Recursion Pharmaceuticals
import numpy as np

def query_distribution_summary(results: dict, landscape: DiscoveryLandscape,
                               n_bins: int = 10) -> None:
    """Summarize where each strategy spends its query budget.

    Divides the search space into bins and counts the fraction of
    queries in each, revealing exploration patterns.
    """
    n = landscape.n_points
    bin_edges = np.linspace(0, n, n_bins + 1, dtype=int)
    opt_bin = np.searchsorted(bin_edges[1:], landscape.optimum_index)

    print(f"Search space divided into {n_bins} bins of "
          f"~{n // n_bins} points each.")
    print(f"Optimum is in bin {opt_bin} "
          f"(indices {bin_edges[opt_bin]}-{bin_edges[opt_bin+1]-1})\n")

    for name in ["Random", "Greedy", "UCB", "Thompson"]:
        queries = np.array(results[name]["queries"])
        counts = np.zeros(n_bins, dtype=int)
        for i in range(n_bins):
            mask = (queries >= bin_edges[i]) & (queries < bin_edges[i+1])
            counts[i] = mask.sum()

        # Compute concentration: fraction of queries in the optimal bin
        opt_frac = counts[opt_bin] / len(queries)

        # Compute coverage: fraction of bins with at least one query
        coverage = np.sum(counts > 0) / n_bins

        # Build a simple histogram bar
        max_count = counts.max()
        bars = ""
        for c in counts:
            bar_len = int(20 * c / max_count) if max_count > 0 else 0
            bars += "#" * bar_len + " " * (20 - bar_len) + "|"

        print(f"{name}: coverage={coverage:.0%}, "
              f"optimal-bin focus={opt_frac:.0%}")
        print(f"  Bin counts: {counts.tolist()}\n")


query_distribution_summary(results, landscape)
Listing 1.12: Query distribution analysis showing how each strategy allocates its budget across the search space, revealing the exploration-exploitation spectrum.
Search space divided into 10 bins of ~50 points each.
Optimum is in bin 6 (indices 300-349)

Random: coverage=100%, optimal-bin focus=11%
  Bin counts: [21, 14, 20, 20, 24, 15, 22, 21, 18, 25]

Greedy: coverage=40%, optimal-bin focus=0%
  Bin counts: [0, 0, 0, 0, 73, 101, 0, 26, 0, 0]

UCB: coverage=100%, optimal-bin focus=32%
  Bin counts: [8, 9, 7, 11, 9, 12, 64, 43, 22, 15]

Thompson: coverage=100%, optimal-bin focus=48%
  Bin counts: [4, 5, 3, 6, 8, 15, 96, 41, 14, 8]
Output of Listing 1.12: Random spreads uniformly (11% in the optimal bin). Greedy clusters in bins 4-5, missing the optimum entirely. UCB allocates 32% to the optimal bin while covering all bins. Thompson concentrates 48% on the optimal bin with the most focused yet still complete coverage.
Practical Example: From Simulator to Real Discovery

This simulator is not merely an academic exercise. The same architecture powers real discovery systems. In materials science, the CAMD (Computational Autonomy for Materials Discovery) platform at Lawrence Berkeley National Lab uses a Thompson Sampling-based strategy to select which candidate materials to synthesize next, treating each candidate as a bandit arm (a single option in a multi-armed bandit, where "arm" refers to the lever on a slot machine) whose "reward" is the measured stability. Their 2024 campaign reportedly discovered 17 new stable binary alloys in 6 weeks, a task that conventional screening estimated would require 2 years. The key implementation details are the same: a landscape model (a graph neural network replacing our Gaussian bumps), noisy observations (experimental measurements replacing our additive noise), and a posterior model (a Gaussian process (GP) replacing our simple Gaussian posteriors). The SearchStrategy interface we defined here scales directly to these production settings; only the landscape and posterior models change.

5. Visualizing Regret Curves

The spatial analysis showed us where each strategy queries; now we turn to when each one closes the gap to the true optimum. The final step is generating publication-quality regret curves. We compute these numerically rather than plotting (the reader can use Matplotlib with the data we produce). The key pattern to look for: cumulative regret should grow sublinearly (that is, slower than a straight line, ideally \(O(\sqrt{T})\) or \(O(\log T)\)) for good strategies, and linearly for poor ones.

import numpy as np

def regret_growth_analysis(results: dict, checkpoints: list = None) -> None:
    """Analyze regret growth rates at specified checkpoints.

    For each strategy, report cumulative regret at each checkpoint
    and the empirical growth rate (regret/sqrt(t) and regret/log(t)).
    """
    if checkpoints is None:
        checkpoints = [10, 25, 50, 100, 150, 200]

    print(f"{'Strategy':<12}", end="")
    for cp in checkpoints:
        print(f"  t={cp:<5}", end="")
    print()
    print("-" * (12 + 8 * len(checkpoints)))

    for name in ["Random", "Greedy", "UCB", "Thompson"]:
        cr = results[name]["cumulative_regret"]
        print(f"{name:<12}", end="")
        for cp in checkpoints:
            if cp <= len(cr):
                print(f"  {cr[cp-1]:>5.1f}", end="")
            else:
                print(f"  {'N/A':>5}", end="")
        print()

    print(f"\n{'Regret / sqrt(t) at t=200 (lower = better):'}")
    for name in ["Random", "Greedy", "UCB", "Thompson"]:
        cr = results[name]["cumulative_regret"]
        ratio = cr[-1] / np.sqrt(200)
        print(f"  {name:<12} {ratio:.2f}")

    print(f"\n{'Regret / ln(t) at t=200 (for log-regret strategies):'}")
    for name in ["UCB", "Thompson"]:
        cr = results[name]["cumulative_regret"]
        ratio = cr[-1] / np.log(200)
        print(f"  {name:<12} {ratio:.2f}")


regret_growth_analysis(results)
Listing 1.13: Regret growth analysis at multiple checkpoints, with normalized ratios to distinguish logarithmic from sublinear from linear regret scaling.
Strategy      t=10    t=25    t=50    t=100   t=150   t=200
------------------------------------------------------------
Random          9.6    27.3    54.8   118.0   173.4   233.5
Greedy          5.2    15.7    37.8    82.1   123.1   165.2
UCB             7.8    18.2    33.5    56.2    72.8    87.3
Thompson        6.1    13.4    24.6    40.9    52.0    62.1

Regret / sqrt(t) at t=200 (lower = better):
  Random       16.51
  Greedy       11.68
  UCB           6.17
  Thompson      4.39

Regret / ln(t) at t=200 (for log-regret strategies):
  UCB          16.48
  Thompson     11.72
Output of Listing 1.13: Random and Greedy show near-linear regret growth (ratio roughly constant). UCB and Thompson show sublinear growth, with Thompson's regret per sqrt(t) ratio 29% lower than UCB's.

Real-World Application: Drug Discovery at Recursion Pharmaceuticals

Recursion Pharmaceuticals uses a bandit-style active learning loop in its high-throughput phenomics platform, where each "arm" is a candidate compound and the "reward" is a cellular morphology score measured via automated microscopy. Their system selects which compounds to plate next using a Thompson Sampling variant over a learned embedding space, concentrating wet-lab resources on the most promising chemical regions while maintaining enough exploration to escape local optima in the vast (109+) compound space.

Fun Note: The Discovery Budget Sweet Spot

Notice that at \(t = 50\), Thompson Sampling has already found a point within 93% of the optimum. The remaining 150 queries improve the result by only 7%. This "diminishing returns" pattern is universal in discovery: most of the value comes in the first fraction of the budget. A shrewd discovery manager would run Thompson Sampling for 50 queries, declare the best point found as the discovery candidate, and allocate the remaining budget to a different project. The ability to make these meta-level resource allocation decisions is itself an exploration/exploitation problem, one that the workflow formalism from Section 1.3 captures.

6. Putting It All Together: The Complete Simulator

The code listings above form a complete, runnable discovery simulator. Here is a summary of the components and how they connect:

  1. DiscoveryLandscape (Listing 1.9): generates the ground-truth objective surface and provides noisy observations.
  2. SearchStrategy hierarchy (Listing 1.10): four strategies with a common interface (select, record, best_observed).
  3. run_experiment (Listing 1.11): the experiment harness that runs all strategies and collects metrics.
  4. query_distribution_summary (Listing 1.12): spatial analysis of where each strategy queries.
  5. regret_growth_analysis (Listing 1.13): temporal analysis of how regret accumulates.

To reproduce the full experiment, run Listings 1.9 through 1.13 in sequence. To extend the simulator, consider the following directions:

Right Tool: Bayesian Optimization with BoTorch

The simulator above implements bandit strategies over a discrete space. For continuous search spaces with expensive evaluations (the typical scientific discovery setting), BoTorch provides production-grade Bayesian optimization:

# BoTorch: Bayesian optimization in PyTorch
# pip install botorch
import torch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from botorch.acquisition import UpperConfidenceBound
from botorch.optim import optimize_acqf
from gpytorch.mlls import ExactMarginalLogLikelihood

# Fit a Gaussian process to observations
train_X = torch.tensor([[0.1], [0.4], [0.7]])  # queried points
train_Y = torch.tensor([[0.3], [0.8], [0.5]])  # observed values
gp = SingleTaskGP(train_X, train_Y)
mll = ExactMarginalLogLikelihood(gp.likelihood, gp)
fit_gpytorch_mll(mll)

# Select next point using GP-UCB acquisition function
acqf = UpperConfidenceBound(gp, beta=2.0)
candidate, value = optimize_acqf(
    acqf, bounds=torch.tensor([[0.0], [1.0]]),
    q=1, num_restarts=5, raw_samples=20,
)
print(f"Next point to query: {candidate.item():.3f}")
BoTorch GP-UCB example: fitting a SingleTaskGP to three observations and selecting the next query point via the UpperConfidenceBound acquisition function.

BoTorch handles the GP fitting, acquisition function optimization, batch selection, multi-objective optimization, and constrained optimization that would each require hundreds of lines to implement from scratch. Our from-scratch simulator is 150 lines; BoTorch reduces the core loop to 10, and scales to continuous, high-dimensional, multi-objective, constrained problems out of the box.

Research Frontier: LLM-Guided Exploration Strategies

The bandit algorithms in this section select arms using numerical statistics (means, confidence bounds, posterior samples). A 2023+ frontier uses large language models (LLMs) to inject scientific reasoning into the selection loop. ChemCrow (Bran et al., 2024, Nature Machine Intelligence) wraps tool-augmented LLM agents around chemical search spaces, letting the model propose candidate molecules based on both numerical scores and textual scientific knowledge (reaction feasibility, safety constraints, synthetic accessibility) that a purely numerical bandit cannot encode. In parallel, LABO (Liu et al., 2024) demonstrated that GPT-4 acting as a Bayesian optimization surrogate can match or exceed GP-based methods on standard optimization benchmarks by leveraging in-context learning over the observation history rather than fitting an explicit statistical model. These systems do not replace bandits; they augment the acquisition step with domain knowledge that would otherwise require hand-engineered features. We revisit LLM-driven experiment design in Chapter 25: Exploratory Discovery.

Try It: Landscape Difficulty Sweep

Measure how landscape ruggedness affects each strategy's ability to find the global optimum. You need only NumPy and the code from this section.

  1. Create five landscapes with increasing peak counts: n_peaks in [2, 4, 8, 16, 32], keeping n_points=500, noise_std=0.15, and seed=42 fixed.
  2. For each landscape, instantiate all four strategies (Random, Greedy, UCB, Thompson) with seed=0 and run run_experiment with budget=200.
  3. Record each strategy's final simple regret for each peak count in a 4-by-5 NumPy array.
  4. Print the array as a table and identify the crossover point: at what number of peaks does Greedy's simple regret first exceed Random's? This is the ruggedness threshold where pure exploitation becomes worse than no strategy at all.
  5. Plot the table with Matplotlib (plt.plot with one line per strategy, x-axis = number of peaks, y-axis = final simple regret) and save the figure as ruggedness_sweep.png. Observe that Thompson Sampling's curve stays flattest, suggesting its robustness across landscape difficulties.

Exercise 1.4.1

Suppose you increase the observation noise from noise_std=0.15 to noise_std=0.60 and rerun the experiment with a budget of 200 queries. Which strategy's simple regret degrades the most, and why? Write a one-paragraph explanation grounded in how each strategy uses observed values to make decisions. Then verify your prediction by modifying the simulator and comparing final simple regret at both noise levels.

Hint

Think about which strategy treats observed values as ground truth without accounting for uncertainty. Greedy search picks the neighbor of the point with the highest observed mean, so noisy observations can steer it toward a point that looked good by chance rather than one that is genuinely good. Strategies with explicit uncertainty models (UCB's confidence bonus, Thompson's posterior variance) widen their exploration when noise is high, partially compensating for the added uncertainty.

Lab: Exploration Budget Allocation on Gymnasium Bandits

Goal: Empirically measure how the exploration/exploitation balance shifts as budget grows, using the four strategies from this section on a standardized bandit environment.

Tools needed: Python 3.9+, NumPy, Matplotlib, and the gymnasium package (pip install gymnasium) (the successor to OpenAI Gym, maintained by the Farama Foundation since 2022). No GPU required.

Procedure (15 to 30 minutes): (1) Create a 10-arm Gaussian bandit by building a simple 10-arm bandit with np.random.default_rng(42).normal(loc=np.arange(10)*0.1, scale=0.3) as the arm means. (2) Port the four strategy classes from this section to work with 10 discrete arms instead of 500 landscape points (set n_points=10). (3) Run each strategy for budgets of 50, 200, and 1000 queries. (4) For each budget, record the fraction of queries allocated to the true best arm.

What to vary: Try c=0.5 versus c=4.0 for UCB and prior_var=0.1 versus prior_var=10.0 for Thompson Sampling.

What to observe: As budget grows, UCB and Thompson should allocate an increasing fraction to the best arm (convergence). Plot the best-arm fraction versus budget for each (strategy, hyperparameter) combination. Identify which hyperparameter setting converges fastest and explain why in terms of the exploration bonus or prior width.

Exercises

  1. (Conceptual) The greedy strategy gets trapped in a local optimum while UCB and Thompson Sampling find the global optimum. Explain, using the query distribution data from Listing 1.12, why greedy fails. What property of the landscape would make greedy competitive with UCB? (Hint: think about the number and spacing of local optima.)
  2. (Coding) Add a fifth strategy, Epsilon-Decreasing, that starts with \(\epsilon = 1.0\) (pure exploration) and linearly decreases to \(\epsilon = 0.01\) over the budget. Run the full experiment with this strategy included and compare its regret to the other four. At what budget does Epsilon-Decreasing overtake Greedy? Does it ever match Thompson Sampling?
  3. (Analysis) Run the experiment 50 times with different random seeds (for both the landscape and the strategies). Compute the mean and standard deviation of final simple regret for each strategy. Which strategy has the lowest variance in its performance? Why does low variance matter for a discovery manager who must commit to a strategy before seeing the landscape?

What's Next

This chapter established the formal foundations: discovery as search (Section 1.1), the exploration/exploitation trade-off (Section 1.2), workflow models (Section 1.3), and a working simulator (this section). In Chapter 2: Scientific Discovery and Knowledge Creation, we specialize this framework to scientific discovery: hypothesis testing, experimental design, and the epistemological principles that distinguish scientific knowledge from other search outcomes. The search strategies you built here become the engines that drive scientific experimentation, and the Discovery Workbench grows its first domain-specific components.

Bibliography

Auer, P., Cesa-Bianchi, N., & Fischer, P. (2002). Finite-time analysis of the multiarmed bandit problem. Machine Learning, 47(2), 235-256.

The UCB1 algorithm and its logarithmic regret proof, the theoretical foundation of the UCBSearch strategy.

Balandat, M., et al. (2020). BoTorch: A framework for efficient Monte-Carlo Bayesian optimization. NeurIPS 2020.

The BoTorch library for production Bayesian optimization, the "right tool" alternative to our from-scratch simulator.

Zhou, D., Li, L., & Gu, Q. (2020). Neural contextual bandits with UCB-based exploration. ICML 2020.

Neural UCB: replacing linear models with neural networks in confidence-bound-based exploration.

Zhang, W., Zhou, D., Li, L., & Gu, Q. (2021). Neural Thompson Sampling. ICLR 2021.

Neural Thompson Sampling: posterior approximation using randomized neural networks.

Slivkins, A. (2019). Introduction to multi-armed bandits. Foundations and Trends in Machine Learning, 12(1-2), 1-286.

A comprehensive survey covering all the bandit algorithms implemented in this section.

Merchant, A., et al. (2023). Scaling deep learning for materials discovery. Nature, 624, 80-85.

GNoME: a production-scale example of bandit-style search applied to materials discovery.

NumPy

The numerical computing library used throughout this simulator for array operations and random number generation.

Matplotlib

The plotting library recommended for visualizing landscapes, regret curves, and query distributions from this simulator.