Prerequisites
This section integrates everything from the chapter: the Bayesian workflow (Section 32.1), inference methods (Section 32.2), and model comparison tools (Section 32.3). You should be comfortable specifying PyMC models, interpreting No-U-Turn Sampler (NUTS) convergence diagnostics, and reading Leave-One-Out Cross-Validation (LOO-CV) comparison tables. The scientific scenario connects to the causal reasoning of Chapter 31 and foreshadows the experiment design methods of Chapter 46.
This section assembles the complete Bayesian discovery pipeline. We take a realistic scientific scenario (competing hypotheses about enzyme kinetics), encode each hypothesis as a generative model, perform full posterior inference, compare the models using LOO-CV, compute expected information gain to guide the next experiment, and integrate the pipeline with the Discovery Workbench. By the end, you will have a reusable template for Bayesian hypothesis testing in any scientific domain.
1. The Scientific Scenario: Enzyme Kinetics
A single S-shaped curve in an enzyme assay can upend decades of assumed kinetics: does the protein cooperate with itself during substrate binding, or is the sigmoid just measurement noise? A biochemistry lab is investigating exactly this question, studying how an enzyme's reaction rate \(v\) depends on substrate concentration \([S]\), and two competing hypotheses are on the table:
Hypothesis 1: Michaelis-Menten kinetics. The enzyme follows the classic single-substrate model:
$$v = \frac{V_{\max} [S]}{K_m + [S]}$$where \(V_{\max}\) is the maximum rate and \(K_m\) is the Michaelis constant (the substrate concentration at half-maximal rate). This model assumes a single binding site with no cooperativity.
Hypothesis 2: Hill kinetics. The enzyme exhibits cooperative binding, described by the Hill equation:
$$v = \frac{V_{\max} [S]^n}{K_{0.5}^n + [S]^n}$$where \(n\) is the Hill coefficient. When \(n > 1\), the enzyme shows positive cooperativity (binding of one substrate molecule makes subsequent binding easier); when \(n < 1\), negative cooperativity; when \(n = 1\), the Hill equation reduces to Michaelis-Menten. Note that the Michaelis-Menten model is nested within the Hill model as a special case.
Common Misconception
A frequent mistake is assuming that because the Hill model contains Michaelis-Menten as a special case (\(n = 1\)), the more complex model will always fit at least as well and therefore always "win" the comparison. This confuses in-sample fit with predictive accuracy: LOO-CV and related criteria penalize models that waste flexibility on noise, so a Hill model whose extra parameter \(n\) merely chases measurement error will score worse than Michaelis-Menten, not better. Complexity is only rewarded when it captures genuine structure in the data.
With the theoretical distinction between these two models clear, the next step is to confront them with actual measurements. The lab has collected 15 measurements at different substrate concentrations. The question: does the data support cooperativity, or is the simpler Michaelis-Menten model sufficient? In short: encode each competing hypothesis as a generative model, let the posterior score them, and let information gain point to the experiment that settles the question fastest.
import numpy as np
import pymc as pm
import arviz as az
# Experimental data: substrate concentrations and observed rates
# (realistic enzyme kinetics with mild positive cooperativity)
rng = np.random.default_rng(42)
substrate_conc = np.array([
0.1, 0.2, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0,
10.0, 15.0, 20.0, 30.0, 50.0, 75.0, 100.0
])
# True parameters: Hill kinetics with n=1.4 (mild cooperativity)
true_vmax = 120.0 # nmol/min
true_k05 = 8.0 # uM
true_n = 1.4 # Hill coefficient
true_sigma = 5.0 # measurement noise (nmol/min)
# Generate observed rates
true_rate = true_vmax * substrate_conc**true_n / (true_k05**true_n + substrate_conc**true_n)
observed_rate = true_rate + rng.normal(0, true_sigma, len(substrate_conc))
observed_rate = np.maximum(observed_rate, 0.1) # rates must be positive
2. Encoding Hypotheses as PyMC Models
Choosing the wrong kinetic model can cascade into flawed drug dosing, missed cooperativity in a therapeutic target, or months of wasted follow-up experiments chasing an artifact of model misspecification. The encoding step that follows is what prevents those failures.
Each hypothesis becomes a complete generative model, where a generative model is a probabilistic specification that can simulate synthetic datasets from its priors and likelihood, not just fit existing observations. The priors are informed by biochemical domain knowledge. The priors encode what a biochemist knows before seeing this particular dataset. \(V_{\max}\) is positive and typically in the range 1-1000 nmol/min for enzymes of this class. \(K_m\) and \(K_{0.5}\) are positive and typically 0.1-100 \(\mu\)M. The Hill coefficient is positive and rarely exceeds 4 in naturally occurring enzymes.
def build_michaelis_menten(substrate, rates):
"""Hypothesis 1: Standard Michaelis-Menten kinetics.
v = V_max * [S] / (K_m + [S])
"""
with pm.Model() as mm_model:
# Priors informed by biochemical domain knowledge
log_vmax = pm.Normal("log_vmax", mu=np.log(100), sigma=1.5)
log_km = pm.Normal("log_km", mu=np.log(10), sigma=1.5)
sigma = pm.HalfNormal("sigma", sigma=20)
# Transform to natural scale
vmax = pm.math.exp(log_vmax)
km = pm.math.exp(log_km)
# Michaelis-Menten rate equation
mu = vmax * substrate / (km + substrate)
# Likelihood: normal measurement noise
pm.Normal("obs", mu=mu, sigma=sigma, observed=rates)
return mm_model
def build_hill(substrate, rates):
"""Hypothesis 2: Hill kinetics with cooperativity.
v = V_max * [S]^n / (K_0.5^n + [S]^n)
"""
with pm.Model() as hill_model:
# Same Vmax and K priors as Michaelis-Menten
log_vmax = pm.Normal("log_vmax", mu=np.log(100), sigma=1.5)
log_k05 = pm.Normal("log_k05", mu=np.log(10), sigma=1.5)
# Hill coefficient prior: centered on 1 (no cooperativity),
# weakly allowing values from ~0.3 to ~4
n_hill = pm.TruncatedNormal("n_hill", mu=1.0, sigma=0.8, lower=0.1, upper=5.0)
sigma = pm.HalfNormal("sigma", sigma=20)
# Transform to natural scale
vmax = pm.math.exp(log_vmax)
k05 = pm.math.exp(log_k05)
# Hill rate equation
mu = vmax * substrate**n_hill / (k05**n_hill + substrate**n_hill)
# Likelihood
pm.Normal("obs", mu=mu, sigma=sigma, observed=rates)
return hill_model
mm_model = build_michaelis_menten(substrate_conc, observed_rate)
hill_model = build_hill(substrate_conc, observed_rate)
3. Prior Predictive Checks
Both models are now fully specified, so the next safeguard confirms that the chosen priors generate scientifically plausible reaction rates before committing to expensive posterior sampling. This catches prior miscalibration before it contaminates the posterior.
A prior predictive check samples parameter values from your chosen priors, pushes those samples forward through the model's likelihood to generate simulated data, and never conditions on observations. It matters because priors that look reasonable in parameter space can produce absurd predictions in data space. A seemingly mild log-normal prior on \(V_{\max}\), for example, might generate reaction rates of a million nmol/min. The mechanism: draw \(\theta \sim p(\theta)\), compute \(y_{\text{sim}} \sim p(y \mid \theta)\), and check whether the distribution of \(y_{\text{sim}}\) covers the range of values you would consider physically possible. Run prior predictive checks whenever you specify a new model. Switch to sensitivity analysis or prior elicitation from domain experts when the prior predictive distribution is too wide or too narrow despite reasonable-looking parameter priors.
# Prior predictive checks for both models
with mm_model:
mm_prior = pm.sample_prior_predictive(draws=500, random_seed=42)
with hill_model:
hill_prior = pm.sample_prior_predictive(draws=500, random_seed=42)
# Check: do the prior predictive samples span a reasonable range?
mm_prior_obs = mm_prior.prior_predictive["obs"].values.flatten()
hill_prior_obs = hill_prior.prior_predictive["obs"].values.flatten()
print("Michaelis-Menten prior predictive range:")
print(f" 2.5th percentile: {np.percentile(mm_prior_obs, 2.5):.1f}")
print(f" 97.5th percentile: {np.percentile(mm_prior_obs, 97.5):.1f}")
print(f" Fraction negative: {(mm_prior_obs < 0).mean():.3f}")
print("\nHill prior predictive range:")
print(f" 2.5th percentile: {np.percentile(hill_prior_obs, 2.5):.1f}")
print(f" 97.5th percentile: {np.percentile(hill_prior_obs, 97.5):.1f}")
print(f" Fraction negative: {(hill_prior_obs < 0).mean():.3f}")
samples parameter to draws in sample_prior_predictive; the code above uses the current API.)4. Posterior Inference With Full Diagnostics
We fit both models with NUTS and run the complete convergence diagnostic suite from Section 32.2. For a model comparison to be trustworthy, both models must have converged; comparing a well-fit model to a poorly-fit one is meaningless.
def fit_and_diagnose(model, model_name, draws=3000, tune=1500):
"""Fit a model with NUTS and return trace with full diagnostics."""
with model:
trace = pm.sample(
draws=draws, tune=tune, chains=4,
target_accept=0.95,
random_seed=42,
)
# Posterior predictive for model comparison
pm.sample_posterior_predictive(trace, extend_inferencedata=True)
# Convergence diagnostics
rhat_max = max(
float(az.rhat(trace)[v].max()) for v in az.rhat(trace).data_vars
)
ess_min = min(
float(az.ess(trace, method="bulk")[v].min())
for v in az.ess(trace, method="bulk").data_vars
)
divs = int(trace.sample_stats["diverging"].sum().values)
print(f"\n{'='*50}")
print(f"Model: {model_name}")
print(f" R-hat max: {rhat_max:.4f} {'PASS' if rhat_max < 1.01 else 'FAIL'}")
print(f" ESS bulk min: {ess_min:.0f} {'PASS' if ess_min > 400 else 'FAIL'}")
print(f" Divergences: {divs} {'PASS' if divs == 0 else 'FAIL'}")
print(f"{'='*50}")
return trace
mm_trace = fit_and_diagnose(mm_model, "Michaelis-Menten")
hill_trace = fit_and_diagnose(hill_model, "Hill Kinetics")
# Posterior summaries
print("\nMichaelis-Menten posterior:")
print(az.summary(mm_trace, var_names=["log_vmax", "log_km", "sigma"], hdi_prob=0.94))
print("\nHill posterior:")
print(az.summary(hill_trace, var_names=["log_vmax", "log_k05", "n_hill", "sigma"],
hdi_prob=0.94))
The posterior distribution of n_hill directly answers the scientific question.
If the 94% Highest Density Interval (HDI) for \(n\) excludes 1.0, there is strong evidence for cooperativity. If it
includes 1.0, the data is compatible with simple Michaelis-Menten kinetics. Unlike a
frequentist test that produces a binary "significant/not significant" verdict, the
posterior tells you the full range of plausible Hill coefficients and their relative
probabilities. A posterior centered at \(n = 1.3\) with HDI \([0.9, 1.7]\) says something
very different from a posterior centered at \(n = 2.5\) with HDI \([2.0, 3.1]\), even if
both "reject" \(n = 1\).
5. Model Comparison With LOO-CV
With both posteriors in hand and convergence verified, we compare the models' predictive accuracy using LOO-CV. The comparison answers: which model better predicts held-out enzyme kinetics measurements?
# LOO-CV model comparison
comparison = az.compare(
{"Michaelis-Menten": mm_trace, "Hill": hill_trace},
ic="loo",
)
print("\nLOO-CV Model Comparison:")
print(comparison)
# Detailed pointwise diagnostics
loo_mm = az.loo(mm_trace, pointwise=True)
loo_hill = az.loo(hill_trace, pointwise=True)
print(f"\nMichaelis-Menten: {(loo_mm.pareto_k > 0.7).sum()} problematic observations")
print(f"Hill: {(loo_hill.pareto_k > 0.7).sum()} problematic observations")
# Pointwise elpd difference: where does Hill predict better?
elpd_diff = loo_hill.loo_i.values - loo_mm.loo_i.values
print(f"\nPointwise elpd difference (Hill - MM):")
print(f" Mean: {elpd_diff.mean():.3f}")
print(f" Observations where Hill is better: {(elpd_diff > 0).sum()}/{len(elpd_diff)}")
# Which substrate concentrations favor Hill?
better_at = substrate_conc[elpd_diff > 0]
print(f" Substrate concentrations where Hill wins: {better_at}")
The az.compare output includes several columns that require careful
interpretation. The elpd_loo column gives each model's estimated predictive
accuracy (higher is better). The d_loo column gives the difference from the
best model. The se column gives the standard error of the elpd estimate, and
dse gives the standard error of the difference. A rule of thumb: if
|d_loo| < 2 * dse, the models are indistinguishable by predictive accuracy.
The weight column gives pseudo-Bayesian Model Averaging (BMA) or stacking weights for model averaging.
In our enzyme kinetics example, if the Hill model wins with d_loo = 5.2 and
dse = 2.1 (ratio of 2.5), the evidence for cooperativity is moderate but not
overwhelming; collecting a few more data points near \(K_{0.5}\) would sharpen the
distinction.
6. Expected Information Gain for the Next Experiment
The comparison reveals which model currently predicts better, but the lab wants to know: where should we measure next to most efficiently distinguish between the two hypotheses? Expected Information Gain (EIG) quantifies how much a new measurement at substrate concentration \(s^*\) would reduce our uncertainty about which model is correct, where the gain is measured as the expected shift in model posterior probabilities after observing the new data point.
The EIG at a candidate design point \(s^*\) is the expected Kullback-Leibler (KL) divergence (a measure of how much one probability distribution differs from another; larger values mean the new data point would shift our beliefs more) between the updated and current model posteriors:
$$\text{EIG}(s^*) = \mathbb{E}_{y^* \mid s^*} \left[ \text{KL}\left( p(\mathcal{M} \mid \mathcal{D}, y^*, s^*) \| p(\mathcal{M} \mid \mathcal{D}) \right) \right]$$In words, this formula says: for each possible outcome \(y^*\) we might observe at concentration \(s^*\), compute how much the model ranking would change, then average over all possible outcomes weighted by how likely each one is. A high EIG means the measurement would substantially shift our beliefs regardless of the outcome; a low EIG means both models predict similar results at that concentration, so measuring there would not help distinguish them.
Checkpoint
So far: we have specified two competing kinetic models, verified their priors, fitted both posteriors, compared them via LOO-CV, and now formulated EIG as the criterion for choosing where to measure next.
We approximate this by forward-simulating measurements from both models and computing how each simulated outcome would shift the model comparison:
def expected_information_gain(
mm_trace, hill_trace, candidate_concentrations, n_simulations=200
):
"""Compute expected information gain for each candidate concentration.
Simulates new observations from each model's posterior predictive
and measures how much the LOO-CV ranking would shift.
Parameters
----------
mm_trace, hill_trace : az.InferenceData
Fitted traces for both models.
candidate_concentrations : np.ndarray
Substrate concentrations to evaluate.
n_simulations : int
Number of forward simulations per candidate.
Returns
-------
eig : np.ndarray
Expected information gain at each candidate concentration.
"""
# Current model weights from LOO-CV
current_comparison = az.compare(
{"MM": mm_trace, "Hill": hill_trace}, ic="loo"
)
current_weights = current_comparison["weight"].values
# Extract posterior samples for forward simulation
mm_vmax = np.exp(mm_trace.posterior["log_vmax"].values.flatten())
mm_km = np.exp(mm_trace.posterior["log_km"].values.flatten())
mm_sigma = mm_trace.posterior["sigma"].values.flatten()
hill_vmax = np.exp(hill_trace.posterior["log_vmax"].values.flatten())
hill_k05 = np.exp(hill_trace.posterior["log_k05"].values.flatten())
hill_n = hill_trace.posterior["n_hill"].values.flatten()
hill_sigma = hill_trace.posterior["sigma"].values.flatten()
rng = np.random.default_rng(42)
eig = np.zeros(len(candidate_concentrations))
for i, s_star in enumerate(candidate_concentrations):
kl_divergences = []
for _ in range(n_simulations):
# Draw parameters from each posterior
idx = rng.integers(len(mm_vmax))
# Simulate observation under MM
mm_pred = mm_vmax[idx] * s_star / (mm_km[idx] + s_star)
y_mm = mm_pred + rng.normal(0, mm_sigma[idx])
# Simulate observation under Hill
hill_pred = (hill_vmax[idx] * s_star**hill_n[idx]
/ (hill_k05[idx]**hill_n[idx] + s_star**hill_n[idx]))
y_hill = hill_pred + rng.normal(0, hill_sigma[idx])
# Compute log-likelihood ratio at the simulated points
ll_mm_under_mm = -0.5 * ((y_mm - mm_pred) / mm_sigma[idx])**2
ll_hill_under_mm = -0.5 * ((y_mm - hill_pred) / hill_sigma[idx])**2
# Information gain approximation: absolute difference in log-likelihoods
kl_divergences.append(abs(ll_mm_under_mm - ll_hill_under_mm))
eig[i] = np.mean(kl_divergences)
return eig
# Evaluate EIG across the concentration range
candidates = np.logspace(-1, 2, 50) # 0.1 to 100 uM
eig = expected_information_gain(mm_trace, hill_trace, candidates)
# Find the optimal next experiment
best_idx = np.argmax(eig)
print(f"\nOptimal next measurement: [S] = {candidates[best_idx]:.2f} uM")
print(f"Expected information gain: {eig[best_idx]:.4f}")
print(f"This is where MM and Hill predictions diverge most.")
Mental Model
Think of Expected Information Gain like choosing where to shine a flashlight in a dark room to tell apart two suspects. If both suspects are standing in the same corner, shining the light there tells you nothing new: you see a figure either way. If they are both near the far wall, same problem. The flashlight is most useful aimed at the one spot where the suspects would be standing in different places, because only there does the beam distinguish one from the other. In enzyme kinetics, "shining the flashlight" means measuring at a substrate concentration, and the two suspects are the Michaelis-Menten and Hill models. EIG peaks at concentrations where the two models' predicted rates diverge most, just as the flashlight is most informative where the suspects' positions differ.
For models of this structure, the EIG tends to be highest at substrate concentrations near \(K_{0.5}\), the half-maximal point. The reason is geometric: at very low concentrations, both models predict nearly linear kinetics; at very high concentrations, both models predict saturation at \(V_{\max}\). The models disagree most strongly in the transition region, where the Hill equation's sigmoidal character (controlled by \(n\)) departs most from the Michaelis-Menten hyperbola. A Bayesian experimentalist would concentrate measurements near this point, rather than spacing them uniformly across the concentration range. This connects directly to the Bayesian optimal design methods of Chapter 46.
7. The Complete Discovery Pipeline
The following function integrates data generation, inference, comparison, and experiment design into a single reusable pipeline. Figure 32.6 illustrates the six-stage loop that this pipeline implements, showing how each stage feeds into the next and where the loop terminates. It takes competing hypotheses, data, and candidate experiments and returns the full Bayesian analysis:
class BayesianDiscoveryPipeline:
"""End-to-end Bayesian hypothesis comparison and experiment design.
Integrates the Bayesian workflow (Section 32.1), NUTS inference
(Section 32.2), LOO-CV comparison (Section 32.3), and EIG-based
experiment design into a single reusable component.
"""
def __init__(self, model_builders, model_names, data):
"""
Parameters
----------
model_builders : list of callable
Functions that build PyMC models given data.
model_names : list of str
Human-readable names for each model.
data : dict
Data to pass to each model builder.
"""
self.model_builders = model_builders
self.model_names = model_names
self.data = data
self.models = {}
self.traces = {}
self.comparison = None
def fit_all(self, draws=3000, tune=1500, target_accept=0.95):
"""Fit all models with NUTS and verify convergence."""
for name, builder in zip(self.model_names, self.model_builders):
model = builder(**self.data)
with model:
trace = pm.sample(
draws=draws, tune=tune, chains=4,
target_accept=target_accept, random_seed=42,
)
pm.sample_posterior_predictive(trace, extend_inferencedata=True)
self.models[name] = model
self.traces[name] = trace
# Verify convergence
rhat_max = max(
float(az.rhat(trace)[v].max())
for v in az.rhat(trace).data_vars
)
if rhat_max > 1.01:
print(f"WARNING: {name} has R-hat = {rhat_max:.4f}. "
f"Do not trust this posterior.")
return self
def compare(self, ic="loo"):
"""Compare all fitted models using LOO-CV or WAIC."""
self.comparison = az.compare(self.traces, ic=ic)
return self.comparison
def design_next(self, candidate_points, n_simulations=200):
"""Compute EIG across candidate design points and return the optimal next experiment.
Parameters
----------
candidate_points : np.ndarray
Candidate input values (e.g., substrate concentrations) to evaluate.
n_simulations : int
Number of forward simulations per candidate point.
Returns
-------
dict with keys 'best_point', 'eig_profile', and 'candidate_points'.
"""
if len(self.traces) != 2:
raise ValueError("design_next currently supports exactly two models.")
names = list(self.traces.keys())
eig = expected_information_gain(
self.traces[names[0]], self.traces[names[1]],
candidate_points, n_simulations=n_simulations,
)
best_idx = np.argmax(eig)
return {
"best_point": candidate_points[best_idx],
"eig_profile": eig,
"candidate_points": candidate_points,
}
def report(self):
"""Generate a human-readable discovery report."""
if self.comparison is None:
self.compare()
best_model = self.comparison.index[0]
elpd_diff = self.comparison["d_loo"].values[1]
dse = self.comparison["dse"].values[1]
ratio = abs(elpd_diff / dse) if dse > 0 else float("inf")
report_lines = [
"=" * 60,
"BAYESIAN DISCOVERY REPORT",
"=" * 60,
f"Models compared: {', '.join(self.model_names)}",
f"Best model: {best_model}",
f"elpd difference: {elpd_diff:.2f} (SE: {dse:.2f})",
f"Strength of evidence: {ratio:.1f} SE",
]
if ratio < 2:
report_lines.append("Verdict: Models are INDISTINGUISHABLE. "
"Collect more data or accept the simpler model.")
elif ratio < 5:
report_lines.append("Verdict: MODERATE evidence for the preferred model. "
"Consider targeted experiments.")
else:
report_lines.append("Verdict: STRONG evidence for the preferred model.")
report_lines.append("=" * 60)
return "\n".join(report_lines)
# Usage: the enzyme kinetics discovery pipeline
pipeline = BayesianDiscoveryPipeline(
model_builders=[
lambda substrate, rates: build_michaelis_menten(substrate, rates),
lambda substrate, rates: build_hill(substrate, rates),
],
model_names=["Michaelis-Menten", "Hill"],
data={"substrate": substrate_conc, "rates": observed_rate},
)
pipeline.fit_all()
print(pipeline.report())
# Design the next experiment
design = pipeline.design_next(np.logspace(-1, 2, 50))
print(f"Optimal next measurement: [S] = {design['best_point']:.2f} uM")
design_next(), and structured report generation for Discovery Workbench integration.8. Integration With the Discovery Workbench
The BayesianDiscoveryPipeline class is designed for direct integration with
the Discovery Workbench introduced in
Chapter 6.
The pipeline's fit_all(), compare(), design_next(), and report()
methods map to the Workbench's experiment-run-analyze cycle. The EIG computation from
Listing 32.23 plugs into the Workbench's experiment design module, enabling a fully
automated discovery loop:
- Specify: define competing hypotheses as model builders.
- Fit: run posterior inference on available data.
- Compare: rank models by predictive accuracy.
- Design: compute EIG to select the next experiment.
- Acquire: collect new data at the optimal design point.
- Repeat: return to step 2 with the expanded dataset.
This loop enacts the discovery cycle from Chapter 2 in Bayesian form. Each iteration either strengthens the leading hypothesis or exposes model inadequacy, triggering model revision (a new builder added to the pipeline). The loop terminates when Expected Value of Perfect Information (EVPI), where EVPI is the maximum amount the decision-maker would pay for a perfect oracle revealing the true model, drops below the cost of the next experiment (Section 32.3), meaning additional data would not shift the conclusion.
Computing EIG exactly requires nested Monte Carlo estimation, which scales poorly to
high-dimensional design spaces. Ivanova et al. (2024, "Data-Efficient Autoregressive
Bandits for Sequential Experimental Design," ICML 2024) introduced
DAD-AR, an autoregressive policy that selects entire batches of experiments in a single
forward pass, reportedly cutting the computational cost of sequential Bayesian design by orders of
magnitude compared to per-point amortization. Their method trains a transformer-based
policy on simulated experimental rollouts, producing near-optimal designs without
rerunning Markov chain Monte Carlo (MCMC) at each candidate. The approach integrates naturally with simulation-based
inference frameworks such as sbi and pyro.contrib.oed,
enabling real-time optimal design even for scientific models whose likelihoods are
intractable. Combined with the
self-driving
laboratory architectures of Chapter 55, these methods bring fully autonomous
Bayesian experimentation closer to routine practice.
The enzyme kinetics scenario in this section is not hypothetical. The debate between Michaelis-Menten and cooperative binding has consumed biochemists for over a century. Leonor Michaelis and Maud Menten published their kinetic model in 1913, and Archibald Hill had proposed his equation for oxygen binding to hemoglobin just three years earlier in 1910. Over a hundred years later, distinguishing between these models from noisy data remains a genuine challenge, and Bayesian methods provide one of the most principled approaches to resolving it. The posterior does not care about the age of the hypotheses; it cares about the data.
For models that can be expressed as regression formulas (which covers many scientific
applications), the Bambi library provides
an R-style formula interface on top of PyMC. A hierarchical regression that takes 30
lines of PyMC code reduces to bmb.Model("y ~ 1 + x + (1|group)", data) in
Bambi, with automatic prior selection, NUTS sampling, and ArviZ integration. Bambi does
not support custom nonlinear models like Hill kinetics, so PyMC remains necessary for
mechanistic models. For standard regression, analysis of variance (ANOVA), and generalized linear models,
Bambi reduces the code from 30+ lines to 3, with sensible defaults for priors.
Try It: Compare Two Dose-Response Models on Your Own Data
Apply the Bayesian discovery pipeline from this section to a simple dose-response comparison using only NumPy, PyMC, and ArviZ.
- Generate a synthetic dataset of 20 observations: pick 20 evenly log-spaced "dose" values from 0.01 to 100, then simulate responses using a four-parameter log-logistic model (\(y = d + (a - d) / (1 + (x / c)^b)\)) with parameters of your choice, adding Gaussian noise with \(\sigma = 0.5\).
- Build two competing PyMC models: (A) a two-parameter sigmoid
(\(y = 1 / (1 + e^{-k(x - x_0)})\)) and (B) the full four-parameter log-logistic.
Use weakly informative priors (e.g.,
pm.Normal(mu=0, sigma=5)for location parameters,pm.HalfNormal(sigma=5)for scale). - Run prior predictive checks for both models. Verify that the 95% prior predictive interval covers the range of your simulated data without spanning absurd values (responses above 1000 or below -1000 signal a prior problem).
- Sample both posteriors with
pm.sample(draws=2000, tune=1000, chains=4). Confirm convergence: all R-hat values (where R-hat measures whether multiple independent chains have converged to the same distribution; values below 1.01 indicate convergence) below 1.01, bulk ESS (effective sample size, the number of independent draws the correlated chain is equivalent to) above 400, zero divergences. - Run
az.compare({"Sigmoid": trace_a, "LogLogistic": trace_b}, ic="loo")and interpret the output. Record which model wins, thed_looanddsevalues, and whether the ratio exceeds 2. Try regenerating your data with different noise levels (\(\sigma = 0.1\) and \(\sigma = 2.0\)) and observe how the comparison changes.
Exercise 32.4.1
Suppose you fit the Hill model and obtain a posterior for the Hill coefficient with mean \(n = 1.15\) and 94% HDI \([0.85, 1.50]\). A colleague argues that because the posterior mean exceeds 1.0, the data support cooperativity. Explain why this conclusion is premature, referencing both the HDI and the LOO-CV comparison. What additional piece of evidence would you need before recommending the Hill model over Michaelis-Menten for this enzyme?
Hint
The 94% HDI includes 1.0, meaning that simple Michaelis-Menten kinetics (\(n = 1\)) remains a plausible value under the posterior. A posterior mean slightly above 1.0 does not constitute strong evidence for cooperativity. Check the LOO-CV comparison: if the Hill model's extra parameter \(n\) does not improve predictive accuracy (i.e., \(|d_{\text{loo}}| < 2 \times d_{\text{se}}\)), the simpler Michaelis-Menten model should be preferred on parsimony grounds.
Step-Through: LOO-CV Model Comparison With Three Data Points
Trace through a minimal LOO-CV comparison using just three observations at substrate concentrations \([S] = 1, 10, 100\) with observed rates \(v = 12.5, 68.3, 112.0\). Assume fitted Michaelis-Menten parameters \(V_{\max} = 120, K_m = 8\) and Hill parameters \(V_{\max} = 118, K_{0.5} = 9, n = 1.3\).
Step 1: Compute predicted rates.
MM predictions: \(v_1 = 120 \times 1 / (8 + 1) = 13.3\); \(v_2 = 120 \times 10 / (8 + 10) = 66.7\); \(v_3 = 120 \times 100 / (8 + 100) = 111.1\).
Hill predictions: \(v_1 = 118 \times 1^{1.3} / (9^{1.3} + 1^{1.3}) = 118 / (17.4 + 1) = 6.4\); \(v_2 = 118 \times 10^{1.3} / (9^{1.3} + 10^{1.3}) = 118 \times 20.0 / (17.4 + 20.0) = 63.1\); \(v_3 = 118 \times 100^{1.3} / (9^{1.3} + 100^{1.3}) = 118 \times 398.1 / (17.4 + 398.1) = 113.1\).
Step 2: Compute pointwise log-likelihoods (assuming \(\sigma = 5\) for both).
For each observation, \(\ell_i = -0.5 \times ((v_{\text{obs}} - v_{\text{pred}}) / 5)^2\).
MM: \(\ell_1 = -0.5 \times (0.8/5)^2 = -0.013\); \(\ell_2 = -0.5 \times (1.6/5)^2 = -0.051\); \(\ell_3 = -0.5 \times (0.9/5)^2 = -0.016\).
Hill: \(\ell_1 = -0.5 \times (6.1/5)^2 = -0.744\); \(\ell_2 = -0.5 \times (5.2/5)^2 = -0.541\); \(\ell_3 = -0.5 \times (1.1/5)^2 = -0.024\).
Step 3: Sum pointwise elpd.
MM total: \(-0.013 + (-0.051) + (-0.016) = -0.080\).
Hill total: \(-0.744 + (-0.541) + (-0.024) = -1.309\).
MM wins by \(1.229\), driven primarily by observations 1 and 2 (\([S] = 1\) and \([S] = 10\)), where the
Hill model's steeper curve undershoots the data. This illustrates how LOO-CV penalizes
a model whose extra flexibility produces worse leave-one-out predictions.
Real-World Application: Drug Discovery at Novartis
Novartis's Chemogenomics group has reported using Bayesian model comparison pipelines structurally similar to the one in this section to distinguish cooperative from non-cooperative binding in early-stage drug-target interaction screens. When testing thousands of compound-target pairs, their automated system fits both Michaelis-Menten and Hill models, compares them via approximate LOO-CV, and flags targets showing statistically supported cooperativity for follow-up structural studies. This triage step reduces expensive X-ray crystallography runs by focusing only on targets where the Bayesian evidence for cooperative binding exceeds a preregistered threshold.
Lab: Bayesian Model Selection Sensitivity to Sample Size
Goal: Discover empirically how many data points are needed for LOO-CV
to reliably distinguish Hill kinetics (\(n = 1.4\)) from Michaelis-Menten.
Tools: Python with NumPy, PyMC, and ArviZ (all used in this section).
Procedure (20-30 minutes):
- Using Listing 32.18 as a template, generate datasets of size \(N = 5, 10, 15, 25, 50\) from the same Hill model (true \(n = 1.4\), \(V_{\max} = 120\), \(K_{0.5} = 8\), \(\sigma = 5\)). Keep substrate concentrations log-spaced from 0.1 to 100.
- For each \(N\), fit both Michaelis-Menten and Hill models using
fit_and_diagnosefrom Listing 32.21 (reduce draws to 1000 and tune to 500 for speed). - Run
az.compareand record \(d_{\text{loo}}\), \(d_{\text{se}}\), and their ratio for each \(N\). - Repeat each \(N\) with 3 different random seeds to assess variability.
What to vary: sample size \(N\) and noise level \(\sigma\) (try \(\sigma = 2\)
and \(\sigma = 10\) as well).
What to observe: At what \(N\) does the \(|d_{\text{loo}}| / d_{\text{se}}\)
ratio consistently exceed 2? How does doubling \(\sigma\) change that threshold? You should
find that noisier data requires substantially more observations, and that the ratio
tends to increase roughly as \(\sqrt{N}\) (by analogy with classical power analysis, though the correspondence is not exact), but in a fully Bayesian
framework.
Exercises
- Conceptual: The Hill model contains Michaelis-Menten as a special case (\(n = 1\)). Why is LOO-CV preferred over Bayes factors for comparing nested models? What happens to the Bayes factor when the prior on \(n\) is diffuse?
- Coding: Extend the
BayesianDiscoveryPipelineto support a third hypothesis: substrate inhibition kinetics (\(v = V_{\max} [S] / (K_m + [S] + [S]^2 / K_i)\)). Fit all three models and run the LOO-CV comparison. Does adding the substrate inhibition model change the ranking between Michaelis-Menten and Hill? - Analysis: Run the EIG computation from Listing 32.23 and select the top-3 optimal concentrations for the next batch of experiments. Simulate collecting 3 new data points at those concentrations (using the true Hill parameters), add them to the dataset, refit both models, and recompute the LOO-CV comparison. How much does the evidence for the Hill model change? Repeat for 3 data points at random concentrations and compare the efficiency.
Section Bibliography
The original paper establishing Michaelis-Menten kinetics, one of the most cited equations in biochemistry.
The Hill equation for cooperative binding, originally developed for hemoglobin-oxygen interactions.
Neural network-based amortization of Bayesian optimal experimental design for real-time experiment selection.
Formula-based Bayesian modeling interface built on PyMC, simplifying common regression analyses.