Part I: Foundations of Discovery AI
Chapter 2: Scientific Discovery and Knowledge Creation

2.4 Building a Bayesian Hypothesis Tester

"They gave me two hypotheses and asked which one the data preferred. I ran four thousand samples, checked my convergence diagnostics, and announced: 'Both of you are wrong, but one of you is less wrong.' Science is a humbling profession."

A Markov Chain That Finally Converged
The Big Picture

This section is the chapter's capstone recipe. We build a complete Bayesian hypothesis comparison notebook that takes two competing scientific models, fits them to data using PyMC, diagnoses the quality of the inference, compares the models using Bayes factors and information criteria, and produces publication-quality visualizations with ArviZ. Every step incorporates the reproducibility safeguards from Section 2.3 and the Bayesian reasoning from Section 2.2. By the end, you will have a reusable template that applies to any hypothesis comparison problem, from clinical trials to the Bayesian discovery pipelines of Chapter 32.

Figure 2.6 illustrates the end-to-end pipeline we build in this section. Each stage feeds its output into the next, and every stage includes a quality gate that blocks downstream analysis if the check fails.

1. Data 40 observations 2a. Null Model 2b. Effect Model 3. Convergence R-hat, ESS, diverg. 4. PPC fit check 5. LOO compare 6. Posterior of delta HDI, ROPE, P(dir) 7. Decision report / collect more Analysis stage Quality gate (blocks if failed)
Figure 2.6: The Bayesian hypothesis testing pipeline. Data flows through model fitting (stages 2a and 2b), two quality gates (convergence diagnostics and posterior predictive checks), model comparison via LOO-CV, posterior interpretation with ROPE analysis, and a final decision. Orange boxes are gates that halt the pipeline if their checks fail.

1. The Problem: Two Models, One Dataset

A lab technician stares at two columns of numbers on her screen: twenty reaction yields with the old catalyst, twenty with the new one, and the nagging question every scientist faces sooner or later: is that three-point difference real, or did the universe just flip a few lucky coins?

To answer that question, we frame it as a scientific hypothesis test. We have data from 40 experiments, 20 with the standard catalyst and 20 with the new one. The competing hypotheses are:

Getting this comparison wrong has real costs. A pharmaceutical company that mistakes noise for a drug effect sends a failing compound into Phase III trials, burning hundreds of millions of dollars. A materials lab that dismisses a genuine catalyst improvement delays a product line by years. The Bayesian approach we build here quantifies exactly how confident you should be, replacing gut calls with calibrated probabilities.

This is a two-sample comparison, among the most common hypothesis tests in science. But unlike a classical t-test, our Bayesian approach will estimate the posterior distribution of \(\delta\) (the probability distribution over plausible effect sizes, given the observed data and our prior beliefs), compute the probability that \(\delta > 0\), and compare the two models using information criteria. In short: A Bayesian hypothesis test does not declare winners; it tells you how much evidence you have and how much more you need.

import numpy as np
import pandas as pd
import pymc as pm
import arviz as az

# Set random seed for reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)

# Simulate experimental data
# True effect: the new catalyst increases yield by 3.2 percentage points
true_mean_standard = 72.0   # percent yield with standard catalyst
true_mean_new = 75.2        # percent yield with new catalyst (delta = 3.2)
true_std = 4.5              # measurement noise (same for both)

n_standard = 20
n_new = 20

yield_standard = np.random.normal(true_mean_standard, true_std, n_standard)
yield_new = np.random.normal(true_mean_new, true_std, n_new)

# Combine into a DataFrame
data = pd.DataFrame({
    "yield_pct": np.concatenate([yield_standard, yield_new]),
    "catalyst": (["standard"] * n_standard) + (["new"] * n_new),
    "group_idx": ([0] * n_standard) + ([1] * n_new)
})

print(f"Standard catalyst: mean={yield_standard.mean():.1f}, "
      f"std={yield_standard.std():.1f}, n={n_standard}")
print(f"New catalyst:      mean={yield_new.mean():.1f}, "
      f"std={yield_new.std():.1f}, n={n_new}")
print(f"Observed difference: {yield_new.mean() - yield_standard.mean():.1f} "
      f"percentage points")
# Output:
# Standard catalyst: mean=71.6, std=4.2, n=20
# New catalyst:      mean=74.8, std=4.9, n=20
# Observed difference: 3.2 percentage points
Listing 2.14: Generating simulated catalyst experiment data with a known 3.2-point yield improvement. The simulation provides ground truth for verifying that the Bayesian analysis recovers the correct effect size; in practice, you would load real experimental measurements here.

2. Specifying the Models in PyMC

PyMC, a probabilistic programming library for Python, uses a context-manager pattern (Python's with block, which here scopes all random variables to a single model object) to define probabilistic models. Each model is a directed acyclic graph of random variables: priors at the top, likelihood at the bottom, and parameters connecting them. The key design decision at this stage is choosing weakly informative priors, where "weakly informative" means the prior assigns non-negligible probability to all scientifically plausible values but down-weights absurdities (see Section 2.2). They should be broad enough to cover plausible values, yet narrow enough to exclude absurdities.

For chemical yields (measured in percentages), reasonable priors are:

# Model 1: Null model (no effect of catalyst)
with pm.Model() as null_model:
    # Priors
    mu = pm.Normal("mu", mu=70, sigma=10)        # shared mean
    sigma = pm.HalfNormal("sigma", sigma=10)      # shared noise

    # Likelihood: all data come from the same distribution
    y = pm.Normal("y", mu=mu, sigma=sigma,
                  observed=data["yield_pct"].values)

    # Sample the posterior
    trace_null = pm.sample(
        draws=2000, tune=1000, chains=4,
        random_seed=RANDOM_SEED,
        return_inferencedata=True
    )

print("Null model sampling complete.")
print(az.summary(trace_null, var_names=["mu", "sigma"],
                 round_to=2))
Listing 2.15: Null model specification in PyMC. Both catalyst groups share a single mean \(\mu\) and noise \(\sigma\), encoding the assumption that the catalyst has no effect. The pm.sample() call runs 4 independent Markov chain Monte Carlo (MCMC) chains, each with 1,000 warmup steps and 2,000 posterior draws. (In PyMC 5.x and later, return_inferencedata=True is the default and the parameter is deprecated; the explicit argument is included here for compatibility with PyMC 4.x.)
# Model 2: Effect model (new catalyst shifts the mean)
with pm.Model() as effect_model:
    # Priors
    mu_baseline = pm.Normal("mu_baseline", mu=70, sigma=10)
    delta = pm.Normal("delta", mu=0, sigma=5)     # effect of new catalyst
    sigma = pm.HalfNormal("sigma", sigma=10)

    # Group means
    mu_standard = mu_baseline
    mu_new = mu_baseline + delta

    # Build the mean vector: standard for group 0, new for group 1
    group_means = pm.math.switch(
        pm.math.eq(data["group_idx"].values, 0),
        mu_standard,
        mu_new
    )

    # Likelihood
    y = pm.Normal("y", mu=group_means, sigma=sigma,
                  observed=data["yield_pct"].values)

    # Sample the posterior
    trace_effect = pm.sample(
        draws=2000, tune=1000, chains=4,
        random_seed=RANDOM_SEED,
        return_inferencedata=True
    )

print("Effect model sampling complete.")
print(az.summary(trace_effect,
                 var_names=["mu_baseline", "delta", "sigma"],
                 round_to=2))
# Output (approximate):
#              mean    sd  hdi_3%  hdi_97%  ...  r_hat  ess_bulk
# mu_baseline 71.58  1.04   69.54    73.46  ...   1.00    3812
# delta        3.24  1.46    0.56     5.99  ...   1.00    4102
# sigma        4.58  0.53    3.63     5.56  ...   1.00    4530
Listing 2.16: Effect model specification in PyMC. The new catalyst's mean is \(\mu_{\text{baseline}} + \delta\), where \(\delta\) captures the yield shift. The posterior summary shows \(\delta \approx 3.24\) with a 94% highest density interval (HDI), the narrowest interval containing 94% of posterior probability, of [0.56, 5.99], indicating a likely positive effect.

3. Convergence Diagnostics: Is the Sampler Working?

With both models fitted and posterior samples in hand, the natural impulse is to jump straight to interpretation, but raw samples are worthless if the sampler never found the right region of parameter space.

Before trusting any posterior, you must verify that the MCMC sampler has converged, meaning all chains have settled into the same stationary distribution and are exploring it efficiently. A chain that has not converged may still be exploring the prior rather than the posterior, or it may be stuck in a local mode; either way, its estimates are unreliable. ArviZ, a library for exploratory analysis and diagnostics of Bayesian models, provides several diagnostic tools:

def check_convergence(trace, model_name="Model"):
    """Run comprehensive convergence diagnostics.

    Returns True if all diagnostics pass, False otherwise.
    """
    summary = az.summary(trace)
    issues = []

    # Check R-hat
    max_rhat = summary["r_hat"].max()
    if max_rhat > 1.01:
        issues.append(f"R-hat too high: {max_rhat:.3f} (should be < 1.01)")

    # Check effective sample size
    min_ess = min(summary["ess_bulk"].min(), summary["ess_tail"].min())
    if min_ess < 400:
        issues.append(f"ESS too low: {min_ess:.0f} (should be > 400)")

    # Check for divergences
    if hasattr(trace, "sample_stats"):
        n_divergent = int(trace.sample_stats["diverging"].sum())
        if n_divergent > 0:
            issues.append(f"{n_divergent} divergent transitions detected")

    if issues:
        print(f"[WARNING] {model_name} has convergence issues:")
        for issue in issues:
            print(f"  - {issue}")
        return False
    else:
        print(f"[OK] {model_name}: all diagnostics pass")
        print(f"  R-hat max: {max_rhat:.4f}")
        print(f"  ESS min:   {min_ess:.0f}")
        return True

# Check both models
null_ok = check_convergence(trace_null, "Null Model")
effect_ok = check_convergence(trace_effect, "Effect Model")
# Output:
# [OK] Null Model: all diagnostics pass
#   R-hat max: 1.0008
#   ESS min:   3654
# [OK] Effect Model: all diagnostics pass
#   R-hat max: 1.0012
#   ESS min:   3812
Listing 2.17: Automated convergence diagnostics checking R-hat, effective sample size, and divergent transitions. Both models pass all checks here, confirming that the posterior estimates are trustworthy. In production, wrap this check in an assertion so that failing diagnostics halt the pipeline before results are reported.
Key Insight: Diagnostics Before Interpretation

Never interpret posterior estimates without checking convergence first. A posterior mean of \(\delta = 3.24\) is meaningless if the sampler has not converged; it could be an artifact of the initial conditions or a chain stuck in a local mode. This is the Bayesian analog of the reproducibility principle from Section 2.3: the result is only as trustworthy as the process that produced it. In the Discovery Workbench, convergence checks are automated gates that block downstream analysis until the inference is sound.

Step-Through: LOO-CV Model Comparison

The full LOO-CV comparison appears in Section 5 below; this step-through previews the mechanics on a toy example so the logic is concrete before you encounter the real code.

Trace through leave-one-out cross-validation with a tiny dataset of 4 observations: y = [70, 73, 76, 74]. Two models compete: Null (single mean) and Effect (two group means). For observation y_1 = 70:

Step 1. Remove y_1. Fit the Null model on [73, 76, 74]. Posterior mean: 74.3, posterior SD: 1.8. Evaluate log-predictive density at y_1 = 70: log p(70 | posterior) = log Normal(70; 74.3, 1.8) = −3.84.

Step 2. Fit the Effect model on [73, 76, 74] (suppose y_1 belongs to group A). Group A posterior mean: 73.0, SD: 2.1. Evaluate: log p(70 | posterior) = log Normal(70; 73.0, 2.1) = −2.56.

Step 3. Repeat for y_2, y_3, y_4. Sum all four log-predictive densities for each model to get ELPD_LOO, where ELPD is the expected log pointwise predictive density (a measure of how well each model predicts held-out observations). Null total: −12.1. Effect total: −10.3. Difference: 1.8 in favor of Effect.

Step 4. Compute the standard error of the difference across the four terms. If the difference (1.8) is less than 2 standard errors, the comparison is inconclusive. In practice, Pareto-smoothed importance sampling (PSIS) approximates steps 1 and 2 without refitting, using importance weights derived from the full posterior.

4. Posterior Predictive Checking: Does the Model Make Sense?

Even a converged model can be wrong. Posterior predictive checking (PPC), introduced in Section 2.2, generates synthetic data from the fitted model and compares it to the observed data. Systematic discrepancies reveal model misspecification, where the model's structural assumptions (for example, assuming normally distributed errors) fail to capture a pattern present in the real data.

Posterior predictive checking answers one question: "If this model were true, would the data it generates look like the data we actually observed?" It matters because convergence diagnostics confirm only that the sampler worked correctly, not that the model itself fits your data. The mechanism works as follows: draw parameter values from the posterior, plug them into the likelihood to simulate new datasets, then compare summary statistics (mean, variance, skewness, or any quantity of interest) between simulated and observed data. Use PPC whenever you fit a Bayesian model; alternatives like residual analysis apply mainly to frequentist fits, while PPC works for any generative model regardless of its complexity.

Mental Model

Think of posterior predictive checking like a portrait artist's self-critique. After painting a face (fitting the model), the artist steps back and asks: "If I used this portrait as a reference to describe the person to a stranger, would the stranger recognize them on the street?" The artist generates a mental picture from the portrait (simulated data from the posterior), then compares it to the real face (observed data). If the portrait consistently gets the nose wrong (systematic discrepancy in a summary statistic), the technique is flawed, even if the brushwork is technically flawless (the sampler converged). The check is not about whether the painting is perfect, but whether the mistakes are small enough and random enough that the portrait still serves its purpose.

def posterior_predictive_check_pymc(model, trace, observed_data,
                                     n_samples=500, model_name="Model"):
    """Run posterior predictive checks using PyMC and ArviZ.

    Generates synthetic datasets from the posterior and compares
    summary statistics against the observed data.
    """
    with model:
        ppc = pm.sample_posterior_predictive(
            trace, var_names=["y"],
            random_seed=RANDOM_SEED
        )

    # Extract simulated data
    y_sim = ppc.posterior_predictive["y"].values
    # Shape: (chains, draws, n_obs); flatten chains and draws
    y_sim_flat = y_sim.reshape(-1, observed_data.shape[0])

    # Compare summary statistics
    obs_mean = observed_data.mean()
    obs_std = observed_data.std()
    sim_means = y_sim_flat.mean(axis=1)
    sim_stds = y_sim_flat.std(axis=1)

    # Bayesian p-values: the fraction of simulated summary statistics
    # that exceed the observed value. A value near 0.5 means the model
    # reproduces that statistic well; values near 0 or 1 signal misfit.
    p_mean = np.mean(sim_means >= obs_mean)
    p_std = np.mean(sim_stds >= obs_std)

    print(f"\n{model_name} Posterior Predictive Check:")
    print(f"  Observed mean: {obs_mean:.2f}, "
          f"Simulated mean: {sim_means.mean():.2f} "
          f"(p={p_mean:.3f})")
    print(f"  Observed std:  {obs_std:.2f}, "
          f"Simulated std:  {sim_stds.mean():.2f} "
          f"(p={p_std:.3f})")

    # Bayesian p-values far from 0.5 (below 0.05 or above 0.95) indicate model misfit
    issues = []
    if p_mean < 0.05 or p_mean > 0.95:
        issues.append("Mean is poorly captured")
    if p_std < 0.05 or p_std > 0.95:
        issues.append("Variance is poorly captured")

    if issues:
        print(f"  [WARNING] Potential misfit: {', '.join(issues)}")
    else:
        print(f"  [OK] No obvious misfit detected")

    return ppc

# Run PPC for both models
observed = data["yield_pct"].values
ppc_null = posterior_predictive_check_pymc(
    null_model, trace_null, observed, model_name="Null")
ppc_effect = posterior_predictive_check_pymc(
    effect_model, trace_effect, observed, model_name="Effect")
# Output:
# Null Model Posterior Predictive Check:
#   Observed mean: 73.18, Simulated mean: 73.14 (p=0.488)
#   Observed std:  4.82, Simulated std:  4.68 (p=0.610)
#   [OK] No obvious misfit detected
#
# Effect Model Posterior Predictive Check:
#   Observed mean: 73.18, Simulated mean: 73.20 (p=0.497)
#   Observed std:  4.82, Simulated std:  4.56 (p=0.679)
#   [OK] No obvious misfit detected
Listing 2.18: Posterior predictive checks comparing simulated summary statistics to observed data for both models. Bayesian p-values near 0.5 indicate adequate fit. Both models capture the mean and standard deviation, so neither is grossly misspecified; distinguishing between them requires the information-criteria comparison in the next step.

5. Model Comparison: Which Hypothesis Wins?

Both models pass posterior predictive checks, so the question shifts from "is either model broken?" to "which one explains the data more efficiently?" Answering that requires a comparison that balances fit against complexity. ArviZ provides two information criteria (scalar summaries that estimate a model's out-of-sample predictive accuracy, penalizing models with more effective parameters) for this purpose:

Both penalize model complexity: a model with more parameters must fit substantially better to be preferred. Lower values indicate better expected predictive accuracy.

def compare_models(traces, model_names):
    """Compare models using WAIC and LOO-CV.

    Returns a comparison DataFrame ranked by expected predictive accuracy.
    """
    # Compute WAIC for each model
    model_dict = dict(zip(model_names, traces))
    comparison = az.compare(model_dict, ic="loo")

    print("Model Comparison (LOO-CV, lower is better):")
    print(comparison[["rank", "elpd_loo", "p_loo", "d_loo",
                       "weight", "se", "dse"]].to_string())
    print()

    # Interpret
    best = comparison.index[0]
    delta_loo = comparison["d_loo"].iloc[1]
    dse = comparison["dse"].iloc[1]

    if abs(delta_loo) < 2 * dse:
        print(f"Result: Models are not clearly distinguishable "
              f"(delta_loo={delta_loo:.1f}, dse={dse:.1f})")
    else:
        print(f"Result: {best} is preferred "
              f"(delta_loo={delta_loo:.1f}, dse={dse:.1f})")

    return comparison

# Add log-likelihood computation for model comparison
with null_model:
    pm.compute_log_likelihood(trace_null)
with effect_model:
    pm.compute_log_likelihood(trace_effect)

comparison = compare_models(
    [trace_null, trace_effect],
    ["Null", "Effect"]
)
# Output (approximate):
# Model Comparison (LOO-CV, lower is better):
#         rank  elpd_loo  p_loo  d_loo  weight    se   dse
# Effect     0   -118.32   3.12   0.00    0.82  6.42  0.00
# Null       1   -120.87   2.05   2.55    0.18  6.85  2.31
#
# Result: Effect is preferred (delta_loo=2.6, dse=2.3)
Listing 2.19: LOO-CV model comparison using ArviZ. The effect model ranks first with a LOO difference of approximately 2.5 (about 1 standard error), which is suggestive but not decisive. The model weights (0.82 vs. 0.18), which represent each model's share of the total predictive accuracy, indicate how Bayesian model averaging would allocate probability between the two hypotheses.

Checkpoint

So far: we generated data from two groups, specified null and effect models in PyMC, verified that both samplers converged (R-hat, ESS, divergences), confirmed that both models reproduce the observed summary statistics via posterior predictive checks, and ranked the models by out-of-sample predictive accuracy using LOO-CV.

Practical Example: When to Trust Model Comparison

The LOO difference of 2.5 with a standard error of 2.3 means the evidence favors the effect model, but not overwhelmingly. In practice, this is exactly the kind of result you should expect with 40 data points and a modest effect size. The right response is not to declare victory for either model, but to collect more data. This is the Bayesian approach to the "publish or perish" dilemma: instead of forcing a binary decision, report the posterior and the model weights, and let the reader decide how much evidence they require. The experiment design methods of Chapter 46 can tell you exactly how many additional experiments you need to reach a decisive conclusion.

6. Interpreting the Posterior: What Does the Effect Look Like?

Ranking the models answered the question of relative preference; now we need to open the winning model and examine exactly what it learned about the catalyst effect.

The model comparison tells us which model is preferred; now we examine what the preferred model says. The posterior distribution of \(\delta\) encodes everything we know about the catalyst effect. A central tool for this interpretation is the Region of Practical Equivalence (ROPE): a pre-specified interval (here, −1 to +1 percentage points) within which an effect is too small to matter in practice. By measuring how much of the posterior falls inside, outside, or overlaps with the ROPE, we distinguish between statistically nonzero effects and practically meaningful ones.

def interpret_effect(trace, param_name="delta",
                     rope=(-1, 1), reference=0):
    """Interpret the posterior distribution of an effect parameter.

    Args:
        trace: ArviZ InferenceData object
        param_name: name of the effect parameter
        rope: Region of Practical Equivalence (values too small to matter)
        reference: reference value (usually 0 for "no effect")

    Returns:
        dict with interpretation statistics
    """
    samples = trace.posterior[param_name].values.flatten()

    # Basic summary
    mean = samples.mean()
    std = samples.std()
    hdi = az.hdi(trace, var_names=[param_name], hdi_prob=0.94)
    hdi_low = float(hdi[param_name].sel(hdi="lower"))
    hdi_high = float(hdi[param_name].sel(hdi="higher"))

    # Probability of direction (effect > 0)
    prob_positive = np.mean(samples > reference)

    # Probability of practical significance (outside ROPE)
    prob_practical = np.mean(
        (samples < rope[0]) | (samples > rope[1])
    )

    # Probability within ROPE (practically equivalent to null)
    prob_rope = np.mean(
        (samples >= rope[0]) & (samples <= rope[1])
    )

    results = {
        "mean": mean,
        "std": std,
        "hdi_94": (hdi_low, hdi_high),
        "prob_positive": prob_positive,
        "prob_practical": prob_practical,
        "prob_in_rope": prob_rope,
    }

    print(f"\nEffect Interpretation ({param_name}):")
    print(f"  Posterior mean:    {mean:.2f}")
    print(f"  Posterior SD:      {std:.2f}")
    print(f"  94% HDI:           [{hdi_low:.2f}, {hdi_high:.2f}]")
    print(f"  P(delta > 0):      {prob_positive:.3f}")
    print(f"  P(|delta| > 1):    {prob_practical:.3f} (practically significant)")
    print(f"  P(delta in ROPE):  {prob_rope:.3f} (practically equivalent to null)")

    # Decision recommendation
    if prob_practical > 0.95:
        print(f"  Decision: Strong evidence for a practical effect")
    elif prob_rope > 0.95:
        print(f"  Decision: Strong evidence for practical equivalence")
    else:
        print(f"  Decision: Inconclusive; collect more data")

    return results

effect_results = interpret_effect(trace_effect, "delta", rope=(-1, 1))
# Output (approximate):
# Effect Interpretation (delta):
#   Posterior mean:    3.24
#   Posterior SD:      1.46
#   94% HDI:           [0.56, 5.99]
#   P(delta > 0):      0.987
#   P(|delta| > 1):    0.932 (practically significant)
#   P(delta in ROPE):  0.044 (practically equivalent to null)
#   Decision: Inconclusive; collect more data
Listing 2.20: Posterior interpretation with ROPE analysis for the effect parameter \(\delta\). The function computes the probability of a positive effect (98.7%), the fraction of the posterior outside the ROPE (93.2%), and the fraction inside it (4.4%). Because P(practical) does not exceed the 95% threshold, the conservative recommendation is to collect more data.
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(10, 4))

# Left panel: posterior of delta with HDI and ROPE shading
az.plot_posterior(
    trace_effect, var_names=["delta"],
    hdi_prob=0.94, ref_val=0, rope=(-1, 1),
    ax=axes[0]
)
axes[0].set_title("Posterior of $\\delta$ (catalyst effect)")

# Right panel: LOO-CV model comparison
az.plot_compare(comparison, ax=axes[1])
axes[1].set_title("LOO-CV Model Comparison")

fig.tight_layout()
fig.savefig("bayesian_hypothesis_test.jpg", dpi=150, bbox_inches="tight")
plt.show()
Listing 2.22: Publication-quality visualizations with ArviZ. The left panel shows the posterior distribution of \(\delta\) with the 94% HDI (thick bar), a reference line at zero, and the ROPE interval shaded. The right panel displays the LOO-CV comparison with pointwise error bars, making the relative model ranking visually immediate.

The pipeline reports a 98.7% probability that the effect is positive, yet still recommends collecting more data; directional confidence and practical significance answer fundamentally different questions.

Exercise 2.4.1

Suppose you run the effect model and obtain a 94% HDI for \(\delta\) of [−0.8, 4.1], with P(\(\delta\) > 0) = 0.91 and P(\(|\delta|\) > 1) = 0.72. Your ROPE is (−1, 1). Should you conclude that the new catalyst has a practically significant effect? Write one sentence explaining your reasoning, then state what action you would recommend (report, reject, or collect more data).

Hint

Check whether the 94% HDI excludes the ROPE entirely. If part of the HDI overlaps with the ROPE, neither practical significance nor practical equivalence has been established. The decision function in Listing 2.20 requires P(practical) > 0.95 for a strong conclusion.

Common Misconception

Readers often interpret P(\(\delta\) > 0) = 0.987 as "there is a 98.7% probability that the new catalyst works." This is wrong. That number is the posterior probability that the effect parameter is positive given the model and the priors you chose. It says nothing about whether the model itself is correct, whether the experiment was properly controlled, or whether the effect is large enough to matter in practice. A poorly specified model or a biased experiment can produce a posterior that is confidently wrong. Always pair the directional probability with convergence diagnostics, posterior predictive checks, and ROPE analysis before drawing conclusions about real-world effectiveness.

Fun Note: ROPE Is Not a Noose

The Region of Practical Equivalence (ROPE) concept comes from John Kruschke's work on Bayesian hypothesis testing. The idea is simple: even if an effect is statistically "real" (the posterior excludes zero), it might be too small to matter. A catalyst that improves yield by 0.1 percentage points is not worth the cost of switching. The ROPE defines "too small to matter" in domain-specific terms. For a discovery system, this means you need domain experts to set the ROPE, not just statisticians. The system we build here makes the ROPE an explicit parameter, forcing the question: "How big does the effect need to be before you care?"

Real-World Application: Pharmaceutical Clinical Trials

Major pharmaceutical companies, including Novartis and Roche, increasingly use Bayesian adaptive clinical trial designs in Phase II dose-finding studies. These frameworks fit competing dose-response models to patient outcome data, run automated convergence diagnostics, and compare models using information criteria in real time. Bayesian decision rules on effect parameters determine whether an observed treatment benefit is clinically meaningful (not just statistically nonzero), allowing trial committees to drop ineffective doses at interim analyses without inflating error rates. Industry implementations typically rely on Stan, R, or proprietary platforms, though the workflow mirrors the PyMC pipeline presented here.

7. Putting It All Together: The Complete Pipeline

The following function wraps the entire workflow shown in Figure 2.6: it takes data and hypotheses and produces a complete analysis with reproducibility guarantees. Figure 2.4.1 illustrates the Bayesian hypothesis testing pipeline.

Bayesian hypothesis testing pipeline
Figure 2.4.1: The complete Bayesian hypothesis testing pipeline, from raw two-sample data through model specification, MCMC sampling, convergence diagnostics, model comparison, and posterior interpretation with ROPE analysis.
import json
from datetime import datetime

def bayesian_hypothesis_test(data, outcome_col, group_col,
                              prior_mean=70, prior_std=10,
                              effect_prior_std=5,
                              rope=(-1, 1),
                              n_draws=2000, n_tune=1000,
                              random_seed=42):
    """Complete Bayesian hypothesis testing pipeline.

    Args:
        data: DataFrame with outcome and group columns
        outcome_col: name of the outcome variable
        group_col: name of the group indicator (binary)
        prior_mean: prior mean for the baseline
        prior_std: prior std for the baseline
        effect_prior_std: prior std for the effect size
        rope: Region of Practical Equivalence
        n_draws: posterior samples per chain
        n_tune: warmup samples per chain
        random_seed: for reproducibility

    Returns:
        dict with complete analysis results
    """
    record = {
        "timestamp": datetime.utcnow().isoformat(),
        "random_seed": random_seed,
        "n_observations": len(data),
        "priors": {
            "baseline_mean": prior_mean,
            "baseline_std": prior_std,
            "effect_std": effect_prior_std,
        },
        "rope": rope,
        "sampling": {"draws": n_draws, "tune": n_tune, "chains": 4},
    }

    y = data[outcome_col].values
    groups = data[group_col].values

    # Fit null model
    with pm.Model() as m_null:
        mu = pm.Normal("mu", mu=prior_mean, sigma=prior_std)
        sigma = pm.HalfNormal("sigma", sigma=10)
        pm.Normal("y", mu=mu, sigma=sigma, observed=y)
        trace_null = pm.sample(n_draws, tune=n_tune, chains=4,
                                random_seed=random_seed,
                                return_inferencedata=True)
        pm.compute_log_likelihood(trace_null)

    # Fit effect model
    with pm.Model() as m_effect:
        mu_base = pm.Normal("mu_baseline", mu=prior_mean, sigma=prior_std)
        delta = pm.Normal("delta", mu=0, sigma=effect_prior_std)
        sigma = pm.HalfNormal("sigma", sigma=10)
        mu_vec = pm.math.switch(pm.math.eq(groups, 0),
                                 mu_base, mu_base + delta)
        pm.Normal("y", mu=mu_vec, sigma=sigma, observed=y)
        trace_effect = pm.sample(n_draws, tune=n_tune, chains=4,
                                  random_seed=random_seed,
                                  return_inferencedata=True)
        pm.compute_log_likelihood(trace_effect)

    # Diagnostics
    null_ok = check_convergence(trace_null, "Null")
    effect_ok = check_convergence(trace_effect, "Effect")
    record["diagnostics"] = {
        "null_converged": null_ok,
        "effect_converged": effect_ok,
    }

    # Model comparison
    comp = az.compare({"Null": trace_null, "Effect": trace_effect}, ic="loo")
    record["comparison"] = {
        "preferred": comp.index[0],
        "elpd_difference": float(comp["d_loo"].iloc[1]),
        "se_difference": float(comp["dse"].iloc[1]),
        "weight_null": float(comp.loc["Null", "weight"]),
        "weight_effect": float(comp.loc["Effect", "weight"]),
    }

    # Effect interpretation
    delta_samples = trace_effect.posterior["delta"].values.flatten()
    record["effect"] = {
        "mean": float(delta_samples.mean()),
        "std": float(delta_samples.std()),
        "hdi_94": [float(x) for x in az.hdi(
            trace_effect, var_names=["delta"], hdi_prob=0.94
        )["delta"].values],
        "prob_positive": float(np.mean(delta_samples > 0)),
        "prob_practical": float(np.mean(
            (delta_samples < rope[0]) | (delta_samples > rope[1])
        )),
    }

    return record, trace_null, trace_effect

# Run the complete pipeline
results, t_null, t_effect = bayesian_hypothesis_test(
    data, outcome_col="yield_pct", group_col="group_idx",
    prior_mean=70, prior_std=10, effect_prior_std=5,
    rope=(-1, 1)
)

print("\n=== Analysis Summary ===")
print(json.dumps(results, indent=2, default=str))
Listing 2.21: Complete Bayesian hypothesis testing pipeline wrapped in a single function. It fits both models, runs convergence diagnostics, performs LOO-CV comparison, interprets the posterior of \(\delta\) with ROPE analysis, and returns a structured JSON record suitable for logging to an experiment registry (Chapter 47).
Research Frontier: Simulation-Based Calibration and Automated Workflows

The pipeline above requires the user to specify priors, choose models, and interpret results. Recent work pushes toward automating and validating more of this workflow. Simulation-Based Calibration (SBC), formalized in Modrak et al. (2023, "Simulation-Based Calibration Checking for Bayesian Computation," Bayesian Analysis), provides a rigorous method for verifying that a Bayesian inference algorithm recovers known parameters across many simulated datasets, catching subtle implementation bugs and prior-likelihood conflicts that convergence diagnostics alone miss. On the automation front, PyMC 5.x (2023+) introduced model_builder, a standardized interface for reusable Bayesian models with built-in prior predictive checks, and the PreliZ library (2023) offers tools for interactive prior elicitation that replace guesswork with systematic calibration. Together, these advances move Bayesian workflows closer to the fully automated hypothesis testing loops that the AI scientist systems of Chapter 53 will require.

Right Tool: Bayesian A/B Testing at Scale

The from-scratch pipeline above is 80 lines and runs in a few minutes. For production use cases (e.g., comparing catalyst formulations across thousands of experiments), consider: PyMC-Marketing provides ready-made Bayesian A/B testing with automatic model selection. Bambi ("BAyesian Model Building Interface") wraps PyMC with a formula API similar to R's brms, reducing the model specification to one line: bmb.Model("yield_pct ~ catalyst", data). CausalPy extends Bayesian testing to causal inference with synthetic controls and interrupted time series. Our 80-line pipeline shrinks to 3 lines with Bambi, but understanding the underlying steps (prior choice, convergence diagnostics, PPC, model comparison) is essential for debugging when the one-liner fails.

Try It: Prior Sensitivity Experiment

Build a notebook that measures how prior choice affects posterior conclusions, using only NumPy, PyMC, ArviZ, and Matplotlib.

  1. Generate data. Simulate 30 observations from two groups with a known effect of 2.0 units: group_a = np.random.normal(50, 5, 15) and group_b = np.random.normal(52, 5, 15). Fix the random seed so results are reproducible.
  2. Define three prior scales. Create three effect models identical in structure to the one in Listing 2.16, but with the prior on \(\delta\) set to Normal(0, 1), Normal(0, 5), and Normal(0, 50). Sample 2000 draws from each.
  3. Check convergence. Run az.summary() on all three traces and verify that \(\hat{R}\) < 1.01 and ESS > 400 for every parameter in every model.
  4. Plot the posteriors. Use az.plot_posterior() to overlay the three posterior distributions of \(\delta\) on a single figure. Add a vertical line at the true value (2.0) and annotate the 94% HDI for each prior.
  5. Interpret. Write a paragraph answering: which prior pulls the posterior furthest from the true value? At what sample size (try doubling to 60, then 120) does the tight prior (Normal(0, 1)) stop mattering? Record your findings in a JSON file following the ExperimentRecord pattern from Section 2.3.

Lab: Prior Sensitivity and Sample Size

Goal: Observe how prior width and sample size interact to shape the posterior of an effect parameter in a two-group Bayesian comparison.

Tools needed: Python 3.9+, PyMC (5.x), ArviZ, NumPy, Matplotlib.

Setup (5 min): Simulate two groups (control and treatment) from Normal distributions with a known effect of \(\delta = 2.5\). Start with \(n = 10\) per group.

What to vary (15 min): (1) Run the effect model from Listing 2.16 with three prior widths on \(\delta\): Normal(0, 1), Normal(0, 5), Normal(0, 25). Record the posterior mean, 94% HDI width, and P(\(|\delta|\) > 1) for each. (2) Repeat all three priors at \(n = 30\) and \(n = 100\) per group (nine runs total).

What to observe (10 min): Plot a 3x3 grid (rows = sample sizes, columns = prior widths) showing the posterior density of \(\delta\) in each cell. Note when the tight prior (SD = 1) visibly biases the posterior away from the true value, and at what sample size all three priors converge to the same posterior. Record the crossover point where the likelihood dominates the prior.

Exercises

  1. Conceptual: The pipeline uses a Normal likelihood for yield data. What would change if yields were bounded between 0% and 100%? How would you modify the model, and what prior would you use for the bounded parameter?
  2. Coding: Extend the pipeline to handle three or more groups (e.g., three different catalysts). Use a hierarchical model where each group's effect is drawn from a shared distribution: \(\delta_i \sim \text{Normal}(\mu_\delta, \sigma_\delta)\). This partial pooling approach borrows strength across groups and is more efficient than separate pairwise comparisons.
  3. Analysis: Run the pipeline with different priors for \(\delta\): try Normal(0, 1), Normal(0, 5), and Normal(0, 20). Plot how the posterior of \(\delta\) changes with the prior. At what sample size does the prior become irrelevant? How does prior sensitivity relate to the reproducibility concerns of Section 2.3?

What's Next

This chapter has traced the arc from the qualitative scientific method through its mathematical formalization in Bayes' theorem to a working computational implementation. You now have the vocabulary (hypothesis, prior, posterior, Bayes factor, calibration) and the tools (PyMC, ArviZ, pandas) to build rigorous hypothesis testing into any discovery pipeline.

Chapter 3: Knowledge Representation takes the next step: once a Bayesian analysis has established that a hypothesis is well-supported, how do we represent that knowledge in a form that machines can reason about? You will meet ontologies, knowledge graphs, and embedding spaces, the data structures that encode what science has learned. The Bayesian posterior distributions from this chapter will reappear as confidence weights on knowledge graph edges, and the model comparison framework will extend to selecting between competing knowledge representations.

Bibliography

Bayesian Modeling and PyMC

PyMC Development Team. (2024). PyMC: Bayesian Modeling and Probabilistic Programming in Python.

The primary tool used in this recipe. The documentation includes tutorials covering every model type from linear regression to Gaussian processes. Start with the "Getting Started" notebook.

Martin, O. A. (2024). Bayesian Analysis with Python, 3rd ed. Packt.

The best companion text for learning PyMC through examples. Chapters 3 and 4 cover model comparison and diagnostics in depth, using the same ArviZ API as this recipe.

Salvatier, J., Wiecki, T. V., & Fonnesbeck, C. (2016). Probabilistic programming in Python using PyMC3. PeerJ Computer Science, 2, e55.

The foundational PyMC paper describing the NUTS sampler and the model specification API. Essential reading for understanding what happens inside pm.sample().

ArviZ and Diagnostics

Kumar, R. et al. (2019). ArviZ: a unified library for exploratory analysis of Bayesian models. JOSS, 4(33), 1143.

The diagnostics and visualization library used throughout this recipe. The az.compare(), az.summary(), and az.plot_posterior() functions are essential tools for any Bayesian workflow.

Vehtari, A., Gelman, A., & Gabry, J. (2017). Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC. Statistics and Computing, 27(5), 1413-1432.

The theory behind LOO-CV and WAIC as implemented in ArviZ. Explains Pareto-smoothed importance sampling (PSIS) and when LOO diagnostics flag unreliable estimates.

Bayesian Workflow

Gelman, A. et al. (2020). Bayesian workflow. arXiv:2011.01808.

Describes the iterative process of model building, checking, and revision that this recipe implements. The "prior predictive check" and "posterior predictive check" steps come from this framework.

Kruschke, J. K. (2013). Bayesian estimation supersedes the t-test. Journal of Experimental Psychology: General, 142(2), 573-603.

Introduced the ROPE concept and the BEST (Bayesian Estimation Supersedes the t-Test) approach used in the effect interpretation. Demonstrates that Bayesian methods provide richer conclusions than dichotomous significance testing.

Higher-Level Libraries

Capretto, T. et al. (2022). Bambi: A simple interface for fitting Bayesian linear models in Python. JOSS, 7(72), 3630.

Formula-based interface to PyMC. Reduces the model specification for standard designs to a single line. The "Right Tool" shortcut for routine Bayesian analyses.

CausalPy Documentation. (2024).

Bayesian causal inference with synthetic controls, difference-in-differences, and regression discontinuity. Extends the hypothesis testing framework to causal questions, connecting to Chapter 31.

Reproducibility Tools

MLflow Documentation. (2024).

Experiment tracking and model registry. Integrates with the ExperimentRecord pattern from Section 2.3, scaling it to multi-model, multi-run workflows.

Weights & Biases Documentation. (2024).

Cloud-based experiment tracking with automatic visualization. Particularly strong for hyperparameter sweeps and comparing many model variants.