"I calibrated my model, then asked it what would happen if I doubled the degradation rate. It told me. Then I checked in the lab. It was right. That was the moment I stopped calling it a toy."
A Posterior Distribution That Earned Its Keep
This section assembles every component from the chapter into a single, end-to-end pipeline. We build a stochastic simulator of a gene regulatory toggle switch, calibrate it against synthetic experimental data using Approximate Bayesian Computation with Sequential Monte Carlo (ABC-SMC), validate the calibration through posterior predictive checks, and then use the calibrated model to run counterfactual experiments that test mechanistic hypotheses. The toggle switch is a two-gene system that exhibits bistability: cells commit to one of two stable expression states, and stochastic noise can flip them between states. Understanding the parameters that control this switching behavior is a core question in synthetic biology. By the end of this section, you will have a complete, reusable template for simulation-based discovery that integrates with the Discovery Workbench.
1. The Biological System: A Genetic Toggle Switch
Imagine a cell carrying just a few dozen molecules of two rival proteins, each trying to silence the other; in the next hour, random chance will tip the balance and lock the cell into one of two fates, yet no one can predict which fate by watching averages alone.
The genetic toggle switch, first constructed synthetically by Gardner et al. (2000), consists of two genes that mutually repress each other. Gene A produces protein A, which represses gene B. Gene B produces protein B, which represses gene A. This mutual inhibition creates two stable steady states: high A / low B, or low A / high B. Stochastic fluctuations in molecule counts can cause the system to switch between states, a phenomenon with implications for cell fate decisions, antibiotic resistance, and developmental biology.
The reaction network has six reactions:
$$ \begin{align} R_1: &\quad \emptyset \xrightarrow{\alpha_A / (1 + B^n)} A \quad &\text{(production of A, repressed by B)} \\ R_2: &\quad A \xrightarrow{\delta_A} \emptyset \quad &\text{(degradation of A)} \\ R_3: &\quad \emptyset \xrightarrow{\alpha_B / (1 + A^n)} B \quad &\text{(production of B, repressed by A)} \\ R_4: &\quad B \xrightarrow{\delta_B} \emptyset \quad &\text{(degradation of B)} \\ \end{align} $$The Hill coefficient \(n\) controls the cooperativity of repression (the degree to which multiple repressor molecules must bind together to shut down a gene, producing an all-or-nothing response rather than a gradual one): higher \(n\) produces sharper switches and more stable bistability. The production rates \(\alpha_A, \alpha_B\) and degradation rates \(\delta_A, \delta_B\) are the parameters to calibrate. In short: a calibrated simulator turns "I think this drug will work" into "the posterior probability that this drug breaks the switch is 0.83," replacing intuition with quantified confidence.
The full calibration pipeline, illustrated in Figure 43.4.1, connects these components into a four-stage workflow: simulate, compare, refine, and interrogate.
2. Implementing the Toggle Switch Simulator
import numpy as np
from dataclasses import dataclass
@dataclass
class ToggleSwitchParams:
"""Parameters for the genetic toggle switch model."""
alpha_a: float = 50.0 # max production rate of A
alpha_b: float = 50.0 # max production rate of B
delta_a: float = 1.0 # degradation rate of A
delta_b: float = 1.0 # degradation rate of B
n: float = 2.0 # Hill coefficient (cooperativity)
def toggle_switch_gillespie(params, t_max=100.0, seed=42):
"""Simulate the genetic toggle switch using the Gillespie algorithm.
State: [A, B] (molecule counts of the two proteins)
Args:
params: ToggleSwitchParams or dict with parameter values
t_max: simulation duration
seed: random seed
Returns:
dict with 'times' and 'states' arrays
"""
if isinstance(params, dict):
p = ToggleSwitchParams(**params)
else:
p = params
rng = np.random.default_rng(seed)
# Initial state: start near one steady state
x = np.array([0.0, 0.0]) # [A, B]
t = 0.0
times = [t]
states = [x.copy()]
while t < t_max:
A, B = x[0], x[1]
# Propensities with Hill-function repression
a1 = p.alpha_a / (1.0 + B**p.n) # production of A
a2 = p.delta_a * A # degradation of A
a3 = p.alpha_b / (1.0 + A**p.n) # production of B
a4 = p.delta_b * B # degradation of B
propensities = np.array([a1, a2, a3, a4])
a0 = propensities.sum()
if a0 == 0:
break
# Time to next event
tau = rng.exponential(1.0 / a0)
t += tau
if t > t_max:
break
# Which reaction fires?
j = rng.choice(4, p=propensities / a0)
# Stoichiometry: [dA, dB]
stoich = np.array([
[1, 0], # R1: produce A
[-1, 0], # R2: degrade A
[0, 1], # R3: produce B
[0, -1], # R4: degrade B
])
x += stoich[j]
x = np.maximum(x, 0)
times.append(t)
states.append(x.copy())
return {"times": np.array(times), "states": np.array(states)}
# Run a single trajectory
params = ToggleSwitchParams(alpha_a=50, alpha_b=50, delta_a=1, delta_b=1, n=2)
result = toggle_switch_gillespie(params, t_max=200.0, seed=42)
print(f"Toggle switch: {len(result['times'])} events over "
f"{result['times'][-1]:.1f} time units")
print(f"Final state: A={result['states'][-1, 0]:.0f}, "
f"B={result['states'][-1, 1]:.0f}")
Step-Through: One Gillespie Iteration of the Toggle Switch
Trace through a single step of the Gillespie algorithm (where each iteration samples the time to the next reaction event and selects which reaction fires, producing an exact stochastic trajectory) with concrete numbers. Suppose the current state is A = 30, B = 5, and parameters are \(\alpha_A = 50\), \(\alpha_B = 50\), \(\delta_A = 1\), \(\delta_B = 1\), \(n = 2\).
Step 1: Compute propensities. Propensities are the instantaneous rates at which each reaction fires, computed from the current molecule counts and rate constants. \(a_1 = 50 / (1 + 5^2) = 50 / 26 \approx 1.923\) (production of A). \(a_2 = 1 \times 30 = 30\) (degradation of A). \(a_3 = 50 / (1 + 30^2) = 50 / 901 \approx 0.0555\) (production of B). \(a_4 = 1 \times 5 = 5\) (degradation of B). Total propensity \(a_0 = 1.923 + 30 + 0.0555 + 5 = 36.979\).
Step 2: Sample waiting time. Draw \(\tau \sim \mathrm{Exp}(1/36.979)\). The expected wait is about 0.027 time units, reflecting the fast dynamics when molecule counts are high.
Step 3: Choose which reaction fires. Probabilities: \(R_1\): 5.2%, \(R_2\): 81.1%, \(R_3\): 0.15%, \(R_4\): 13.5%. Degradation of A dominates because A = 30 makes \(a_2\) large, while B = 5 keeps \(a_4\) moderate. Production of B is nearly shut off because \(A^2 = 900\) in the denominator of \(a_3\). This is the mutual repression at work: the system strongly favors staying in the high-A / low-B state.
Step 4: Update state. If \(R_2\) fires (most likely), the new state is A = 29, B = 5. One molecule of A degrades; everything else stays the same. The next iteration recomputes all four propensities with the updated counts.
3. Generating Synthetic Experimental Data
Real discovery workflows use experimental measurements such as single-cell fluorescence time series. Here, synthetic data generated from known ground-truth parameters stands in for real observations, allowing verification that calibration recovers the true values before the pipeline meets real data.
def generate_observed_data(
true_params, n_cells=20, t_max=200.0,
measurement_times=None, noise_std=5.0, seed=42,
):
"""Generate synthetic experimental data from the toggle switch model.
Simulates n_cells independent cells, samples at measurement_times,
and adds Gaussian measurement noise.
Args:
true_params: ToggleSwitchParams with ground-truth values
n_cells: number of independent cell trajectories
t_max: simulation duration
measurement_times: array of time points to sample
noise_std: standard deviation of measurement noise
seed: random seed
Returns:
observed: dict with measurement_times, cell_data, and summaries
"""
rng = np.random.default_rng(seed)
if measurement_times is None:
measurement_times = np.linspace(10, t_max, 20)
all_measurements = []
for i in range(n_cells):
cell_seed = rng.integers(0, 2**31)
result = toggle_switch_gillespie(true_params, t_max=t_max, seed=cell_seed)
# Interpolate to measurement times
A_measured = np.interp(
measurement_times, result["times"], result["states"][:, 0]
)
B_measured = np.interp(
measurement_times, result["times"], result["states"][:, 1]
)
# Add measurement noise
A_noisy = A_measured + rng.normal(0, noise_std, len(measurement_times))
B_noisy = B_measured + rng.normal(0, noise_std, len(measurement_times))
all_measurements.append(np.column_stack([A_noisy, B_noisy]))
cell_data = np.array(all_measurements) # (n_cells, n_times, 2)
return {
"measurement_times": measurement_times,
"cell_data": cell_data,
"n_cells": n_cells,
}
# Ground truth parameters
true_params = ToggleSwitchParams(
alpha_a=40.0, alpha_b=45.0, delta_a=0.8, delta_b=1.2, n=2.5
)
observed = generate_observed_data(true_params, n_cells=20, seed=42)
print(f"Generated data: {observed['n_cells']} cells, "
f"{len(observed['measurement_times'])} time points each")
Synthetic data gives us a target to calibrate against, but comparing raw time series between simulator output and observations is impractical when trajectories are stochastic and high-dimensional; we need compact numerical summaries that capture the system's essential behavior.
4. Designing Summary Statistics for the Toggle Switch
The toggle switch has distinctive statistical signatures that good summary statistics must capture: bimodal distributions (where the histogram of protein levels shows two distinct peaks, one for each stable state), anti-correlation between A and B, and specific steady-state levels.
def toggle_switch_summaries(sim_result, measurement_times=None):
"""Compute summary statistics for toggle switch calibration.
Extracts features that capture the bistable dynamics:
- Mean and variance of each protein across cells/time
- Bimodality coefficient (distinguishes unimodal from bimodal)
- Cross-correlation between A and B
- Fraction of time spent in each state
Args:
sim_result: dict with 'times' and 'states' from the simulator
measurement_times: optional array of time points to evaluate
Returns:
1D numpy array of summary statistics
"""
times = sim_result["times"]
states = sim_result["states"]
A = states[:, 0]
B = states[:, 1]
# Use the second half of the trajectory (after transient)
midpoint = len(times) // 2
A_ss = A[midpoint:]
B_ss = B[midpoint:]
summaries = []
# Moments for each protein
for series, name in [(A_ss, "A"), (B_ss, "B")]:
summaries.append(np.mean(series))
summaries.append(np.std(series))
summaries.append(np.median(series))
# Bimodality coefficient: (skewness^2 + 1) / kurtosis
# Values > 5/9 suggest bimodality
if np.std(series) > 0:
from scipy.stats import skew, kurtosis
s = skew(series)
k = kurtosis(series, fisher=False) # excess=False -> Pearson
bimod = (s**2 + 1) / k if k > 0 else 0
summaries.append(bimod)
else:
summaries.append(0.0)
# Cross-correlation
if np.std(A_ss) > 0 and np.std(B_ss) > 0:
min_len = min(len(A_ss), len(B_ss))
corr = np.corrcoef(A_ss[:min_len], B_ss[:min_len])[0, 1]
summaries.append(corr if np.isfinite(corr) else 0.0)
else:
summaries.append(0.0)
# Fraction of time in high-A state (A > B)
min_len = min(len(A_ss), len(B_ss))
frac_high_a = np.mean(A_ss[:min_len] > B_ss[:min_len])
summaries.append(frac_high_a)
# Mean switching frequency (state changes per unit time)
if min_len > 1:
state_labels = (A_ss[:min_len] > B_ss[:min_len]).astype(int)
switches = np.sum(np.abs(np.diff(state_labels)))
dt = times[-1] - times[midpoint] if times[-1] > times[midpoint] else 1.0
summaries.append(switches / dt)
else:
summaries.append(0.0)
return np.array(summaries)
def observed_data_summaries(observed):
"""Compute summary statistics from the observed multi-cell data.
Aggregates across cells to produce population-level summaries
that match the single-trajectory summaries from the simulator.
"""
cell_data = observed["cell_data"] # (n_cells, n_times, 2)
# Average across cells, then compute summaries
mean_trajectory = cell_data.mean(axis=0) # (n_times, 2)
# Use second half
mid = len(mean_trajectory) // 2
A_ss = mean_trajectory[mid:, 0]
B_ss = mean_trajectory[mid:, 1]
summaries = []
for series in [A_ss, B_ss]:
summaries.append(np.mean(series))
summaries.append(np.std(series))
summaries.append(np.median(series))
# Bimodality from cell-level distribution at the last time point
if np.std(series) > 0:
from scipy.stats import skew, kurtosis
s = skew(series)
k = kurtosis(series, fisher=False)
summaries.append((s**2 + 1) / k if k > 0 else 0)
else:
summaries.append(0.0)
if np.std(A_ss) > 0 and np.std(B_ss) > 0:
summaries.append(np.corrcoef(A_ss, B_ss)[0, 1])
else:
summaries.append(0.0)
summaries.append(np.mean(A_ss > B_ss))
# Switching frequency from individual cells
switch_rates = []
for cell in cell_data:
mid_c = len(cell) // 2
labels = (cell[mid_c:, 0] > cell[mid_c:, 1]).astype(int)
switch_rates.append(np.sum(np.abs(np.diff(labels))))
summaries.append(np.mean(switch_rates))
return np.array(summaries)
obs_summaries = observed_data_summaries(observed)
print(f"Observed summary statistics ({len(obs_summaries)} dimensions):")
print(obs_summaries.round(3))
Checkpoint
So far: the toggle switch simulator produces stochastic trajectories via the Gillespie algorithm, synthetic observed data adds realistic measurement noise to ground-truth simulations, and 11 summary statistics compress high-dimensional trajectories into a compact vector that captures bistability, correlation, and switching dynamics.
5. Running ABC-SMC Calibration
The simulator, observed data, and summary statistics are now in place for ABC-SMC calibration. We use pyABC for its adaptive epsilon scheduling and efficient implementation.
Without principled calibration, a simulator's predictions are little more than guesswork dressed in code. Teams that skip this step routinely discover, after months of wet-lab follow-up, that their in silico drug screen was driven by a plausible but wrong parameter regime, wasting reagents and time on interventions the model never truly endorsed.
ABC-SMC is a likelihood-free inference algorithm that estimates the posterior distribution over model parameters when the likelihood function is too complex to evaluate directly, as is the case for stochastic simulators. It matters because most interesting simulators (including Gillespie models) produce stochastic outputs whose probability cannot be written in closed form, making standard Bayesian methods like Markov chain Monte Carlo (MCMC) inapplicable. The algorithm proposes candidate parameters from a prior, simulates data from each candidate, and compares the simulated summary statistics to the observed ones via a distance threshold (epsilon). It then tightens that threshold across successive populations so that surviving particles (where each particle is a candidate parameter vector that has been accepted by the distance criterion) converge toward the true posterior. The convergence relies on importance weighting: at each new population, particles from the previous round are perturbed by a kernel (typically a Gaussian centered on the old particle), and weights correct for the mismatch between this proposal distribution and the prior, ensuring the final sample approximates the true posterior rather than the proposal. Use ABC-SMC when your simulator is a black box with no tractable likelihood; if your model does have a closed-form likelihood, standard MCMC (e.g., Hamiltonian Monte Carlo via Stan or PyMC; as of 2024, PyMC v5 and NumPyro are the most actively maintained options) will be more sample-efficient.
import pyabc
from pyabc import ABCSMC, RV, Distribution, MedianEpsilon
def run_toggle_switch_calibration(
observed, n_populations=8, population_size=300, seed=42,
):
"""Calibrate the toggle switch model using pyABC's ABC-SMC.
Args:
observed: dict from generate_observed_data
n_populations: number of SMC generations
population_size: particles per generation
seed: random seed
Returns:
history: pyABC History object
posterior_samples: DataFrame of accepted parameters
"""
obs_summ = observed_data_summaries(observed)
# Prior distributions (broad, covering plausible biological ranges)
prior = Distribution(
alpha_a=RV("uniform", 10, 90), # 10 to 100
alpha_b=RV("uniform", 10, 90), # 10 to 100
delta_a=RV("uniform", 0.1, 2.9), # 0.1 to 3.0
delta_b=RV("uniform", 0.1, 2.9), # 0.1 to 3.0
n=RV("uniform", 1.0, 4.0), # 1.0 to 5.0
)
def model(params_dict):
"""Run the simulator and return summary statistics as a dict."""
p = ToggleSwitchParams(**params_dict)
result = toggle_switch_gillespie(p, t_max=200.0, seed=seed)
summaries = toggle_switch_summaries(result)
return {f"s{i}": float(v) for i, v in enumerate(summaries)}
# Convert observed summaries to matching dict format
obs_dict = {f"s{i}": float(v) for i, v in enumerate(obs_summ)}
# Distance and epsilon
distance = pyabc.PNormDistance(p=2)
epsilon = MedianEpsilon(initial_epsilon=100.0)
abc = ABCSMC(
models=model,
parameter_priors=prior,
distance_function=distance,
population_size=population_size,
eps=epsilon,
)
import tempfile, os
db_path = os.path.join(tempfile.gettempdir(), "toggle_calibration.db")
abc.new(f"sqlite:///{db_path}", obs_dict)
history = abc.run(
minimum_epsilon=1.0,
max_nr_populations=n_populations,
)
# Extract posterior samples from the final generation
df, w = history.get_distribution()
print("\nPosterior summary:")
print(df.describe().round(3))
print(f"\nTrue values: alpha_a=40, alpha_b=45, delta_a=0.8, "
f"delta_b=1.2, n=2.5")
return history, df
# Run calibration (this takes several minutes)
# history, posterior = run_toggle_switch_calibration(observed)
If the posterior is tight for some parameters but wide for others, the wide parameters are poorly identifiable from the available data. This is scientifically informative: it tells you which aspects of the mechanism the data constrain and which remain uncertain. For the toggle switch, the Hill coefficient \(n\) is typically well-identified (it controls the sharpness of switching, which is directly observable), while \(\alpha_A\) and \(\delta_A\) may be confounded (only their ratio \(\alpha_A / \delta_A\) determines the steady-state level). Discovering such confounding through calibration guides the design of follow-up experiments, connecting to the automated experiment design of Chapter 46.
Common Misconception
A common misconception is that a well-calibrated simulator has accurately recovered every individual parameter. In reality, calibration constrains the joint posterior, not each parameter independently. Two parameters may be individually uncertain yet tightly constrained as a ratio or product (as with \(\alpha_A\) and \(\delta_A\) above, where only the ratio \(\alpha_A / \delta_A\) is identifiable from steady-state data). A wide marginal posterior for a single parameter does not mean the calibration failed; it means the data do not contain enough information to pin down that parameter in isolation, which is itself a valuable scientific finding.
6. Validating the Calibration
Before using a calibrated simulator for counterfactual predictions, we must verify that the inferred posterior actually produces simulations consistent with the observed data. This step, called a posterior predictive check (a validation procedure that simulates new data from the inferred parameter posterior and compares it to the original observations, testing whether the calibrated model can reproduce the data it was trained on), closes a gap that calibration alone leaves open: ABC-SMC finds parameters whose summary statistics fall within a distance threshold of the observations, but it does not guarantee that the full simulated trajectories look realistic. A posterior predictive check evaluates that broader consistency. Section 7 implements this check as the validate() method of the pipeline class.
7. Counterfactual Interventions
A calibrated simulator is not just a fitted model; it is an experimental platform. You can ask "What if?" questions that are impossible, expensive, or unethical to answer in the lab. These counterfactual interventions (experiments that modify one or more model parameters to simulate a hypothetical scenario that did not actually occur) are the primary scientific payoff of calibration.
Mental Model
Think of a counterfactual experiment like adjusting a recipe after you have already calibrated your oven. You bake a cake at 350 degrees and it comes out right, confirming your oven's temperature is accurate (calibration). Now you ask: "What would happen if I raised it to 400 degrees?" You do not need to bake again to predict the outcome, because you trust the calibrated oven's behavior across temperatures. Similarly, once you have calibrated the simulator's parameters against real data, you can change one parameter (the "oven dial") and trust the prediction, because the rest of the model's machinery (the "oven physics") has been validated. The key requirement is the same: your calibration must cover the regime you are extrapolating into, just as an oven calibrated only at 350 degrees tells you little about its behavior at 600.
For the toggle switch, mechanistically interesting counterfactuals include:
- What if we doubled the degradation rate of protein A? (Mimics adding a protease.)
- What if we reduced the Hill coefficient? (Mimics weakening cooperative binding.)
- What if we broke the symmetry by increasing production of B? (Mimics an environmental signal.)
def counterfactual_experiment(
posterior_samples, intervention_fn, n_posterior_draws=50,
n_replicates=10, t_max=200.0, seed=42,
):
"""Run a counterfactual experiment using the calibrated posterior.
For each posterior sample, applies the intervention and simulates,
then compares to the unperturbed simulation.
Args:
posterior_samples: DataFrame with columns for each parameter
intervention_fn: callable(params_dict) -> modified params_dict
n_posterior_draws: number of posterior samples to use
n_replicates: simulations per posterior sample
t_max: simulation duration
seed: random seed
Returns:
baseline_summaries: summary stats without intervention
intervention_summaries: summary stats with intervention
"""
rng = np.random.default_rng(seed)
# Sample from the posterior
if len(posterior_samples) > n_posterior_draws:
indices = rng.choice(
len(posterior_samples), n_posterior_draws, replace=False
)
samples = posterior_samples.iloc[indices]
else:
samples = posterior_samples
baseline_results = []
intervention_results = []
for _, row in samples.iterrows():
params_dict = row.to_dict()
for rep in range(n_replicates):
run_seed = rng.integers(0, 2**31)
# Baseline (no intervention)
p_base = ToggleSwitchParams(**params_dict)
result_base = toggle_switch_gillespie(
p_base, t_max=t_max, seed=run_seed
)
baseline_results.append(
toggle_switch_summaries(result_base)
)
# Intervention
modified_dict = intervention_fn(params_dict.copy())
p_mod = ToggleSwitchParams(**modified_dict)
result_mod = toggle_switch_gillespie(
p_mod, t_max=t_max, seed=run_seed
)
intervention_results.append(
toggle_switch_summaries(result_mod)
)
return np.array(baseline_results), np.array(intervention_results)
def analyze_counterfactual(baseline, intervention, stat_names=None):
"""Compare baseline and intervention summary statistics.
Computes effect sizes and credible intervals for each statistic.
"""
if stat_names is None:
stat_names = [f"stat_{i}" for i in range(baseline.shape[1])]
print(f"{'Statistic':<25} {'Baseline':>10} {'Intervention':>12} "
f"{'Effect':>10} {'95% CI':>20}")
print("-" * 80)
for j, name in enumerate(stat_names):
base_mean = np.mean(baseline[:, j])
int_mean = np.mean(intervention[:, j])
effect = int_mean - base_mean
# Bootstrap 95% CI for the effect
effects = intervention[:, j] - baseline[:, j]
ci_lo = np.percentile(effects, 2.5)
ci_hi = np.percentile(effects, 97.5)
print(f"{name:<25} {base_mean:>10.2f} {int_mean:>12.2f} "
f"{effect:>+10.2f} [{ci_lo:>+8.2f}, {ci_hi:>+8.2f}]")
# Example: What happens if we double the degradation of protein A?
def double_delta_a(params):
params["delta_a"] *= 2.0
return params
# Example: What happens if we reduce the Hill coefficient?
def reduce_cooperativity(params):
params["n"] = max(1.0, params["n"] - 1.0)
return params
stat_names = [
"mean_A", "std_A", "median_A", "bimod_A",
"mean_B", "std_B", "median_B", "bimod_B",
"corr_AB", "frac_high_A", "switch_freq",
]
# In practice, after calibration:
# baseline, intervened = counterfactual_experiment(
# posterior, double_delta_a, n_posterior_draws=50, n_replicates=10
# )
# analyze_counterfactual(baseline, intervened, stat_names)
Real-World Application: Weather Forecasting at ECMWF
The European Centre for Medium-Range Weather Forecasts (ECMWF) uses a closely related calibrate-then-counterfactual pattern for its Integrated Forecasting System (IFS) (see Haiden et al., ECMWF Technical Memoranda, for ongoing verification reports). The IFS is a stochastic atmospheric simulator whose parameters (cloud microphysics coefficients, convection triggers, boundary layer mixing lengths) are calibrated against decades of radiosonde and satellite observations using ensemble methods analogous to ABC-SMC. Once calibrated, forecasters run counterfactual experiments ("What if sea surface temperatures in the tropical Pacific were 1 degree C warmer?") to attribute extreme weather events to climate drivers, producing the attribution statements that appear in Intergovernmental Panel on Climate Change (IPCC) reports.
The Bistable Switch That Runs Your Gut
The genetic toggle switch is not just a synthetic biology curiosity. In 2016, Norman et al. showed that Bacillus subtilis uses a natural toggle switch to stochastically commit individual cells to either motile (swimming) or sessile (biofilm-forming) states, and that the switching rate appears to be tuned by evolutionary selection to approximately match the frequency of environmental changes. The bacterium has effectively "calibrated" its own simulator over millions of generations: the Hill coefficient and degradation rates have evolved so that the population hedges its bets at a ratio that favors survival in fluctuating environments. Your intestinal microbiome relies on this molecular coin flip working correctly.
A pharmaceutical company wants to know whether a candidate drug that increases the degradation rate of protein A will break the bistable switch and force cells into the high-B state. Running the counterfactual experiment above with the drug's estimated effect on \(\delta_A\) answers this question without synthesizing a single molecule. If the posterior-predictive analysis shows that the bimodality coefficient drops below the bistability threshold for most posterior samples, the drug is predicted to work. If the confidence interval spans the threshold, more data (or a better-calibrated model) is needed before proceeding to synthesis. This in silico screening can save months of lab work and focuses experimental resources on the most promising candidates.
With calibration validated and counterfactual experiments demonstrating the model's predictive power, the remaining step is to package these components into a single reusable pipeline that other researchers (and automated systems) can invoke without reassembling the pieces by hand.
8. The Complete Pipeline
The entire workflow, following the stages shown in Figure 43.4.1, assembles into a single, reusable pipeline class that integrates with the Discovery Workbench architecture from Chapter 6.
from dataclasses import dataclass, field
from typing import Callable, Optional
import json
@dataclass
class CalibratedSimulatorPipeline:
"""End-to-end pipeline for simulation-based discovery.
Stages:
1. Define the simulator and summary statistics
2. Calibrate against observed data using ABC-SMC
3. Validate the calibration (posterior predictive check)
4. Run counterfactual experiments
5. Report results with full provenance
"""
simulator_fn: Callable
summary_fn: Callable
param_names: list
prior_ranges: dict
observed_data: Optional[dict] = None
posterior_samples: Optional[np.ndarray] = None
calibration_history: Optional[object] = None
provenance: dict = field(default_factory=dict)
def set_observed_data(self, data):
"""Register the observed experimental data."""
self.observed_data = data
self.provenance["data_registered"] = True
self.provenance["n_observations"] = (
data.get("n_cells", "unknown")
)
def calibrate(self, n_populations=8, population_size=300, seed=42):
"""Run ABC-SMC calibration."""
if self.observed_data is None:
raise ValueError("Call set_observed_data() first")
obs_summaries = self.summary_fn(self.observed_data)
# ABC-SMC calibration (using the manual implementation for clarity)
from scipy.stats import uniform
def prior_sampler(rng):
params = []
for name in self.param_names:
lo, hi = self.prior_ranges[name]
params.append(rng.uniform(lo, hi))
return np.array(params)
def prior_pdf(theta):
for j, name in enumerate(self.param_names):
lo, hi = self.prior_ranges[name]
if theta[j] < lo or theta[j] > hi:
return 0.0
pdf = 1.0
for j, name in enumerate(self.param_names):
lo, hi = self.prior_ranges[name]
pdf *= 1.0 / (hi - lo)
return pdf
def sim_wrapper(theta, seed=42):
params_dict = dict(zip(self.param_names, theta))
return self.simulator_fn(params_dict, seed=seed)
def summary_wrapper(sim_data):
return self.summary_fn(sim_data)
# Use decreasing epsilon schedule
epsilons = np.geomspace(100, 2, n_populations)
from scipy.spatial.distance import euclidean
particles, weights, populations = abc_smc(
simulator_fn=sim_wrapper,
summary_fn=summary_wrapper,
observed_summaries=obs_summaries,
prior_sampler=prior_sampler,
prior_pdf=prior_pdf,
epsilons=epsilons,
n_particles=population_size,
seed=seed,
)
self.posterior_samples = particles
self.provenance["calibration"] = {
"method": "ABC-SMC",
"n_populations": n_populations,
"population_size": population_size,
"final_epsilon": float(epsilons[-1]),
"seed": seed,
}
return particles, weights
def validate(self, n_checks=100, seed=42):
"""Run posterior predictive check."""
if self.posterior_samples is None:
raise ValueError("Call calibrate() first")
obs_summaries = self.summary_fn(self.observed_data)
rng = np.random.default_rng(seed)
distances = []
indices = rng.choice(
len(self.posterior_samples), min(n_checks, len(self.posterior_samples)),
replace=False,
)
for idx in indices:
theta = self.posterior_samples[idx]
params_dict = dict(zip(self.param_names, theta))
sim_data = self.simulator_fn(
params_dict, seed=rng.integers(0, 2**31)
)
sim_summ = self.summary_fn(sim_data)
from scipy.spatial.distance import euclidean
distances.append(euclidean(sim_summ, obs_summaries))
distances = np.array(distances)
self.provenance["validation"] = {
"median_distance": float(np.median(distances)),
"p95_distance": float(np.percentile(distances, 95)),
}
print(f"Posterior predictive check: "
f"median distance = {np.median(distances):.3f}, "
f"p95 = {np.percentile(distances, 95):.3f}")
return distances
def counterfactual(self, intervention_fn, intervention_name="",
n_draws=50, n_replicates=5, seed=42):
"""Run a counterfactual experiment."""
if self.posterior_samples is None:
raise ValueError("Call calibrate() first")
rng = np.random.default_rng(seed)
indices = rng.choice(
len(self.posterior_samples), min(n_draws, len(self.posterior_samples)),
replace=False,
)
effects = []
for idx in indices:
theta = self.posterior_samples[idx]
params_dict = dict(zip(self.param_names, theta))
for _ in range(n_replicates):
run_seed = rng.integers(0, 2**31)
# Baseline
base_data = self.simulator_fn(params_dict, seed=run_seed)
base_summ = self.summary_fn(base_data)
# Intervention
mod_dict = intervention_fn(params_dict.copy())
mod_data = self.simulator_fn(mod_dict, seed=run_seed)
mod_summ = self.summary_fn(mod_data)
effects.append(mod_summ - base_summ)
effects = np.array(effects)
self.provenance.setdefault("counterfactuals", []).append({
"name": intervention_name,
"n_draws": n_draws,
"n_replicates": n_replicates,
"mean_effect": effects.mean(axis=0).tolist(),
})
return effects
def save_provenance(self, path):
"""Save full provenance record to JSON."""
with open(path, "w") as f:
json.dump(self.provenance, f, indent=2, default=str)
print(f"Provenance saved to {path}")
# Assemble the pipeline
pipeline = CalibratedSimulatorPipeline(
simulator_fn=lambda params, seed=42: toggle_switch_gillespie(
ToggleSwitchParams(**params), t_max=200.0, seed=seed
),
summary_fn=toggle_switch_summaries,
param_names=["alpha_a", "alpha_b", "delta_a", "delta_b", "n"],
prior_ranges={
"alpha_a": (10, 100),
"alpha_b": (10, 100),
"delta_a": (0.1, 3.0),
"delta_b": (0.1, 3.0),
"n": (1.0, 5.0),
},
)
# The full workflow:
# pipeline.set_observed_data(observed)
# particles, weights = pipeline.calibrate(n_populations=8)
# distances = pipeline.validate()
# effects = pipeline.counterfactual(double_delta_a, "double_delta_a")
# pipeline.save_provenance("toggle_switch_provenance.json")
The manual pipeline above illustrates the architecture, but for production use, replace the calibrate() method's internals with pyABC (as shown earlier in this section). pyABC provides adaptive epsilon scheduling, parallel execution across cores or machines via Redis, built-in model selection for comparing alternative mechanisms, and SQLite-backed history objects for storing and querying calibration results. The pipeline class becomes a thin wrapper around pyABC, adding provenance tracking and counterfactual experiment support. This typically reduces the calibration code from roughly 100 lines to about 30, while gaining robustness, parallelism, and diagnostics.
Research Frontier
Neural simulation-based inference (SBI) methods are increasingly replacing traditional ABC for calibrating complex simulators. The sbi Python library (Tejero-Cantero et al., JMLR 2020, with major updates through 2024) implements neural posterior estimation (NPE), neural likelihood estimation (NLE), and neural ratio estimation (NRE), which train neural density estimators on simulated data to amortize inference. Once trained, these networks produce posterior samples in milliseconds rather than requiring thousands of new simulations per inference query. Dax et al. (2023, "Neural Importance Sampling for Rapid and Reliable Gravitational-Wave Inference," Physical Review Letters) reported that neural SBI achieved calibrated posteriors for gravitational wave parameter estimation over 1000 times faster than traditional samplers, with comparable accuracy on their benchmark problems. For the toggle switch pipeline in this section, replacing ABC-SMC with NPE from the sbi package would enable real-time posterior updates as new experimental data arrives, making interactive calibration-and-counterfactual loops feasible during a single lab session.
The pipeline class encapsulates the science, but a standalone script is not yet a component that other tools can query; connecting it to the broader Discovery Workbench turns the calibrated simulator into an on-demand service for automated hypothesis testing.
9. Integrating with the Discovery Workbench
The calibrated simulator pipeline plugs into the Discovery Workbench as a component that accepts hypotheses (as parameter values or intervention specifications) and returns predictions with uncertainty estimates. This integration enables automated hypothesis testing loops where the Workbench generates candidate mechanisms, the simulator tests them, and the results guide the next round of hypothesis generation.
from discovery_workbench import Component, register
@register("calibrated_simulator")
class CalibratedSimulatorComponent(Component):
"""Discovery Workbench component for calibrated simulation.
Exposes three actions:
- predict: simulate from the posterior and return predictions
- intervene: run a counterfactual experiment
- compare_models: use ABC model selection to rank mechanisms
"""
def __init__(self, pipeline):
self.pipeline = pipeline
def predict(self, n_samples=100, seed=42):
"""Generate posterior predictive samples."""
rng = np.random.default_rng(seed)
predictions = []
for _ in range(n_samples):
idx = rng.choice(len(self.pipeline.posterior_samples))
theta = self.pipeline.posterior_samples[idx]
params = dict(zip(self.pipeline.param_names, theta))
result = self.pipeline.simulator_fn(
params, seed=rng.integers(0, 2**31)
)
predictions.append(result)
return predictions
def intervene(self, intervention_fn, name=""):
"""Run a counterfactual and return effect with uncertainty."""
effects = self.pipeline.counterfactual(
intervention_fn, intervention_name=name
)
return {
"mean_effect": effects.mean(axis=0).tolist(),
"std_effect": effects.std(axis=0).tolist(),
"ci_lower": np.percentile(effects, 2.5, axis=0).tolist(),
"ci_upper": np.percentile(effects, 97.5, axis=0).tolist(),
"intervention_name": name,
}
def provenance(self):
return self.pipeline.provenance
A synthetic biology team wants to understand why their engineered toggle switch fails to maintain bistability in certain growth conditions. They integrate the calibrated simulator into a Discovery Workbench pipeline that: (1) generates candidate hypotheses about which parameter changes could explain the loss of bistability (using the hypothesis generation tools from Chapter 39); (2) tests each hypothesis by running the corresponding counterfactual experiment; (3) ranks hypotheses by their posterior predictive fit to the failure-condition data; and (4) suggests the most informative follow-up experiment to distinguish between surviving hypotheses (using the experiment design tools from Chapter 46). The entire loop runs autonomously, producing a ranked list of mechanistic explanations with quantified uncertainty.
Try It: Calibrate and Interrogate a Toggle Switch
Build a minimal calibrated simulator on your laptop using only NumPy and SciPy (no pyABC required). (1) Copy the ToggleSwitchParams, toggle_switch_gillespie, toggle_switch_summaries, and generate_observed_data functions from this section into a Python script. Generate synthetic observed data with the true parameters shown above. (2) Implement a simple rejection ABC: sample 10,000 parameter vectors uniformly from the prior ranges, simulate each, compute summary statistics, and keep the 100 samples whose Euclidean distance to the observed summaries is smallest. (3) Plot histograms of the accepted values for each of the five parameters and overlay the true values as vertical lines. Verify that the posterior concentrates near the ground truth for \(n\) but remains broad for \(\alpha_A\) individually. (4) Pick one counterfactual intervention (e.g., doubling \(\delta_A\)) and re-simulate from each of your 100 accepted parameter sets. Compare the distribution of the switching frequency statistic before and after the intervention. (5) Compute the fraction of posterior samples for which the intervention eliminates bistability (bimodality coefficient drops below 5/9). This fraction is your posterior probability that the intervention breaks the switch, a directly actionable prediction.
Exercise 43.4.1
The toggle switch simulator uses a Hill coefficient of \(n = 2.5\) in the ground-truth parameters. Suppose you observe that the posterior for \(n\) after ABC-SMC calibration is concentrated between 2.3 and 2.7, while the marginal posterior for \(\alpha_A\) spans the entire prior range of 10 to 100. (a) Explain why \(n\) is well-identified but \(\alpha_A\) is not, in terms of what each parameter controls in the observable summary statistics. (b) Propose one additional summary statistic (not already in the toggle_switch_summaries function) that would help constrain \(\alpha_A\) independently of \(\delta_A\). Justify your choice by explaining which observable feature of the toggle switch dynamics depends on \(\alpha_A\) alone rather than on the ratio \(\alpha_A / \delta_A\).
Hint
For part (a), consider that \(n\) controls the sharpness of switching (directly visible in the bimodality coefficient and switching frequency), while \(\alpha_A\) and \(\delta_A\) enter the steady-state level only as their ratio. For part (b), think about transient dynamics rather than steady-state behavior: after a perturbation, the relaxation time back to steady state depends on \(\delta_A\) alone (the degradation rate sets the timescale), so a statistic measuring the autocorrelation decay time of protein A would break the \(\alpha_A / \delta_A\) degeneracy.
Lab: Sensitivity of Bistability to the Hill Coefficient
Goal: Empirically map the boundary between monostable and bistable behavior in the toggle switch as a function of the Hill coefficient \(n\).
Tools needed: Python with NumPy and Matplotlib (no additional packages required; use the Gillespie simulator from this section).
Procedure (20 minutes): (1) For each value of \(n\) in np.linspace(1.0, 4.0, 30), simulate 50 independent trajectories of the toggle switch (each for \(t_\mathrm{max} = 500\) time units, with \(\alpha_A = \alpha_B = 50\), \(\delta_A = \delta_B = 1\)). (2) For each trajectory, compute the bimodality coefficient of protein A over the second half of the trajectory. (3) Plot the mean bimodality coefficient (with a shaded band for the interquartile range) against \(n\). (4) Identify the critical \(n^*\) where the bimodality coefficient crosses the 5/9 threshold.
What to vary: Repeat the sweep with asymmetric production rates (\(\alpha_A = 40\), \(\alpha_B = 60\)) and observe how asymmetry shifts \(n^*\). Try reducing the production rates to \(\alpha_A = \alpha_B = 20\) and note how the transition sharpness changes.
What to observe: Below \(n^*\), the system is monostable (one protein dominates permanently). Above \(n^*\), bistability emerges and the switching frequency typically increases with \(n\) before saturating. The transition is not perfectly sharp because finite molecule counts blur the boundary, a signature of stochastic effects that deterministic ordinary differential equation (ODE) models miss entirely.
10. Summary
This section demonstrated the complete arc of simulation-based scientific discovery. The pipeline built a stochastic simulator of a genetic toggle switch, generated synthetic experimental data, and designed summary statistics that capture bistable dynamics. ABC-SMC calibrated the simulator against the data. Posterior predictive checks validated the calibration, and counterfactual experiments tested mechanistic hypotheses. The pipeline class encapsulates this workflow with full provenance tracking, and the Discovery Workbench integration makes it available as a component in automated discovery loops. The same template applies to any stochastic simulator: swap the reaction network, the summary statistics, and the prior, and the calibration machinery transfers with minimal modification (though the choice of summary statistics and prior ranges requires domain-specific tuning for each new system).
What's Next
The simulators in this chapter encode explicit mechanistic knowledge: every reaction, every rate constant is specified by the modeler. Chapter 44: World Models for Discovery takes the complementary approach: learning the simulator itself from data. Neural world models ingest observations and learn to predict future states, bypassing the need for explicit mechanism specification. The calibration techniques from this chapter (especially ABC-SMC and posterior predictive checks) serve as evaluation baselines for learned world models, and the counterfactual intervention framework transfers directly to neural simulators that support interventional queries.