Scientific, Medical, and Social Worlds › A Worked Example: Two Models, One Intervention

Michael BrenndoerferJuly 30, 202618 min read
World Models HandbookAll chapters
Part 10 of 12Application Domains
Chapter 60Scientific, Medical, and Social Worlds › A Worked Example: Two Models, One Intervention
Reading section 7 of 10 · A Worked Example: Two Models, One Intervention · 18 minSection 7 of 10 · 18 min

Book contents

Section 7 of 10 · 18 min read

Scientific, Medical, and Social Worlds › A Worked Example: Two Models, One InterventionOne section · Sections in this chapter7 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We can make the central distinction concrete with a small simulation. Its numerical computation runs quickly once the environment is loaded; cold startup and figure rendering are separate costs. The experiment has four parts:

  1. Simulate a compartmental epidemic from a known mechanistic model, and record only the weekly case counts.
  2. Fit two very different models to those counts: a mechanistic susceptible-infectious-recovered model, and a flexible phenomenological autoregression on log cases.
  3. Introduce an intervention that was absent from the observed history, and see what each model predicts.
  4. Vary the recovery rate while refitting transmission and initial prevalence, and compare nearly unchanged training errors with different post-intervention totals.

Everything is deterministic given a fixed random seed, and everything is synthetic. The numbers below carry no medical or policy meaning. They illustrate an inference problem, not an epidemic.

The compartment model includes an action-responsive transition, but that does not make its fitted parameters correct. We will examine a shallow profile of the fitting loss over recovery rates. The autoregression, as implemented here, has no contact-action input. A different learned model could include and learn such an input; this comparison does not prove that learned transition models require hand-written mechanisms.

We observe 25 weekly reports indexed 0 through 24. All forecasts are prospective: the first unseen week is 25, when a 70 percent contact reduction begins, and the 20-week forecast window ends at week 44. The reduced-contact trajectory is withheld from fitting. The action timing and every model's plotted week must agree.

In[5]:
Code
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import least_squares

N_POP = 400_000  # synthetic population size
OBS_STEPS = 25  # weeks 0 through 24 of reported cases
POST_STEPS = 20  # forecast weeks 25 through 44
INTERVENTION_STEP = OBS_STEPS
TOTAL_STEPS = OBS_STEPS + POST_STEPS
BETA_MULTIPLIER = 0.30  # a 70 percent reduction in effective contacts

The simulator is a discrete-time SIR model. The state st=(St,It,Rt)s_t=(S_t,I_t,R_t) contains susceptible, infectious and recovered counts at the start of week tt. The action at=mta_t=m_t multiplies the transmission rate β\beta. Weekly incidence is βmtStIt/N\beta m_t S_t I_t/N, so the noisy observation model is action-conditioned, p(ot∣st,at)p(o_t\mid s_t,a_t). All three compartments are latent to the fitted models; the simulator retains them internally. The recovered count RtR_t is distinct from the effective reproduction number, which for γ>0\gamma>0 we denote Reff,t=βmtSt/(γN)\mathcal{R}_{\mathrm{eff},t}=\beta m_t S_t/(\gamma N). Zero recovery is still a valid simulation setting, but this finite ratio is then undefined.

In[6]:
Code
def simulate_sir(
    beta,
    gamma,
    i_init,
    steps,
    intervention_step=None,
    beta_multiplier=1.0,
    return_states=False,
):
    """Weekly SIR flows; default return is incidence with shape (steps,)."""
    if not (0.0 <= beta <= 1.0 and 0.0 <= gamma <= 1.0):
        raise ValueError("Weekly beta and gamma must be in [0, 1].")
    if not (0.0 <= i_init <= N_POP and 0.0 <= beta_multiplier <= 1.0):
        raise ValueError("Invalid initial prevalence or contact multiplier.")
    if not isinstance(steps, (int, np.integer)) or steps < 1:
        raise ValueError("steps must be a positive integer.")
    if intervention_step is not None and (
        not isinstance(intervention_step, (int, np.integer))
        or not 0 <= intervention_step <= steps
    ):
        raise ValueError("Invalid intervention step.")
    s, i, r = float(N_POP - i_init), float(i_init), 0.0
    incidence = np.empty(steps)
    states = np.empty((steps + 1, 3))
    states[0] = (s, i, r)
    tolerance = 1e-9 * N_POP
    for t in range(steps):
        multiplier = (
            beta_multiplier
            if intervention_step is not None and t >= intervention_step
            else 1.0
        )
        new_infections = beta * multiplier * s * i / N_POP
        new_recoveries = gamma * i
        # Reject substantive violations before bounding roundoff at endpoints.
        assert np.all(np.isfinite([s, i, r, new_infections, new_recoveries]))
        assert -tolerance <= new_infections <= s + tolerance
        assert -tolerance <= new_recoveries <= i + tolerance
        new_infections = min(s, max(0.0, new_infections))
        new_recoveries = min(i, max(0.0, new_recoveries))
        next_state = np.array(
            [
                s - new_infections,
                i + (new_infections - new_recoveries),
                r + new_recoveries,
            ]
        )
        assert np.all(np.isfinite(next_state))
        assert np.all(next_state >= -tolerance)
        assert np.all(next_state <= N_POP + tolerance)
        np.testing.assert_allclose(
            next_state.sum(), N_POP, rtol=0.0, atol=tolerance
        )
        if np.any(next_state < 0.0) or np.any(next_state > N_POP):
            # Only endpoint roundoff is repaired. Reconcile the largest
            # compartment with the two bounded others to conserve the budget.
            next_state = np.clip(next_state, 0.0, float(N_POP))
            largest = int(np.argmax(next_state))
            next_state[largest] = float(N_POP) - sum(
                next_state[j] for j in range(3) if j != largest
            )
        assert np.all(next_state >= 0.0)
        assert np.all(next_state <= N_POP)
        s, i, r = next_state
        states[t + 1] = (s, i, r)
        incidence[t] = new_infections
    assert np.all(states >= -tolerance)
    np.testing.assert_allclose(
        states.sum(axis=1), N_POP, rtol=0.0, atol=tolerance
    )
    if return_states:
        return incidence, states
    return incidence

The compartment flows use weekly coefficients in [0,1] and a contact multiplier in [0,1]. For valid nonnegative compartments summing to NN, the infection flow cannot exceed SS and the recovery flow cannot exceed II. Updating RR explicitly closes the population budget. In floating-point arithmetic, the implementation checks raw flows and states against the stated tolerance before bounding tiny endpoint errors; it rejects substantive violations rather than masking them. If a state crosses zero or NN only by roundoff, the largest compartment is reconciled with the two bounded others. This is a sufficient safe scope for our selected discrete model, not a general-purpose continuous-time epidemic integrator. The default return is a length-steps incidence array; optional state output has shape (steps+1,3).

We generate an unmodified trajectory for observations and a second trajectory with contact reduction at week 25. Before that week, both coincide. Reports multiply incidence by independent lognormal noise with log standard deviation 0.25. The conditional median of a report equals incidence; its conditional mean is larger by the factor exp⁡(0.252/2)\exp(0.25^2/2). This illustrative observation process is not a surveillance calibration. Forecast totals below compare latent incidence, not noisy future reports.

In[8]:
Code
BETA_TRUE, GAMMA_TRUE, I_INIT_TRUE = 0.45, 0.10, 100.0

truth_natural = simulate_sir(BETA_TRUE, GAMMA_TRUE, I_INIT_TRUE, TOTAL_STEPS)
truth_intervened, truth_states = simulate_sir(
    BETA_TRUE,
    GAMMA_TRUE,
    I_INIT_TRUE,
    TOTAL_STEPS,
    intervention_step=INTERVENTION_STEP,
    beta_multiplier=BETA_MULTIPLIER,
    return_states=True,
)
np.testing.assert_allclose(
    truth_intervened[:OBS_STEPS], truth_natural[:OBS_STEPS]
)

rng = np.random.default_rng(7)
NOISE_SD = 0.25
observed = truth_natural[:OBS_STEPS] * np.exp(
    rng.normal(0.0, NOISE_SD, OBS_STEPS)
)
Out[9]:
Console
True R0 = 4.50
First five reported weeks: [ 45.   65.4  76.5  88.5 133.2]
Last five reported weeks:  [ 9000.7 16743.4 15704.1 27145.7 29868.6]

These reports mostly rise over the observed window, with noise around the underlying trajectory. Log-scale residuals match our selected multiplicative observation process; they are not the uniquely correct loss for every count-forecasting task. We will examine how well this limited record constrains recovery rather than infer identifiability from the apparent trend.

The mechanistic fit minimizes squared log residuals for the selected observation model. Its three free parameters are the transmission rate, the recovery rate, and the initial prevalence. All three have meanings in the simulator: transmission, recovery and initial infectious prevalence. Initial incidence depends jointly on transmission and initial prevalence, so one incidence measurement does not separately identify both.

In[10]:
Code
def sir_log_residuals(
    params, steps, target_log, intervention_step=None, beta_multiplier=1.0
):
    beta, gamma, i_init = params
    incidence = simulate_sir(
        beta,
        gamma,
        i_init,
        steps,
        intervention_step=intervention_step,
        beta_multiplier=beta_multiplier,
    )
    assert np.all(incidence >= 0.0)
    # Positive floor handles numerical zeros only, never negative compartments.
    return np.log(np.maximum(incidence, np.finfo(float).tiny)) - target_log


obs_log = np.log(observed)

fit = least_squares(
    sir_log_residuals,
    x0=np.array([0.35, 0.12, 150.0]),
    args=(OBS_STEPS, obs_log),
    bounds=(np.array([0.05, 0.02, 1.0]), np.array([1.0, 1.0, 5000.0])),
)
beta_hat, gamma_hat, i_init_hat = fit.x
assert fit.success
# A four-rate profile is computed and displayed below; this point estimate is not a posterior.

The competing AR(1) model regresses the next log report on the previous log report. Its two fitted mean coefficients are an intercept and slope. The code does not estimate or use an additive noise-variance parameter. One-step fitted values use observed previous reports; prospective forecasts recurse from the last observed report at week 24, with their first output at week 25. The model has no contact-action input. We also define an explicitly ad hoc output-scaled variant; it is not learned or causally identified.

In[11]:
Code
design = np.column_stack([np.ones(OBS_STEPS - 1), obs_log[:-1]])
target = obs_log[1:]
coef, *_ = np.linalg.lstsq(design, target, rcond=None)
a_hat, b_hat = coef


def ar1_rollout(a, b, log_start, steps):
    logs = np.empty(steps)
    current = float(log_start)
    for step in range(steps):
        current = a + b * current
        logs[step] = current
    return np.exp(logs)


ar_one_step = np.exp(a_hat + b_hat * obs_log[:-1])
ar_rollout_post = ar1_rollout(a_hat, b_hat, obs_log[-1], POST_STEPS)
ar_rollout_post_scaled = BETA_MULTIPLIER * ar_rollout_post

sir_fit_natural = simulate_sir(beta_hat, gamma_hat, i_init_hat, OBS_STEPS)
sir_pred_intervened = simulate_sir(
    beta_hat,
    gamma_hat,
    i_init_hat,
    TOTAL_STEPS,
    intervention_step=INTERVENTION_STEP,
    beta_multiplier=BETA_MULTIPLIER,
)

The SIR forecast applies the contact reduction to the transmission flow. The unchanged AR forecast lacks that input, while the ad hoc variant multiplies each forecast output by 0.30 without changing its recursion. All three predictions now cover exactly the same unseen weeks. Giving the scaled AR an output adjustment does not validate its transition response.

In[12]:
Code
weeks_obs = np.arange(OBS_STEPS)
weeks_post = np.arange(INTERVENTION_STEP, TOTAL_STEPS)

post_truth = truth_intervened[INTERVENTION_STEP:]
post_sir = sir_pred_intervened[INTERVENTION_STEP:]
assert np.array_equal(weeks_post, np.arange(25, 45))
assert (
    post_truth.shape == post_sir.shape == ar_rollout_post.shape == (POST_STEPS,)
)


def log_rmse(prediction, reference):
    prediction = np.asarray(prediction, dtype=float)
    reference = np.asarray(reference, dtype=float)
    assert prediction.shape == reference.shape
    assert np.all(prediction > 0.0) and np.all(reference > 0.0)
    p, r = np.log(prediction), np.log(reference)
    return float(np.sqrt(np.mean((p - r) ** 2)))


sir_fit_rmse = log_rmse(sir_fit_natural, observed)
ar_fit_rmse = log_rmse(ar_one_step, observed[1:])

post_totals = {
    "Truth under the intervention": float(post_truth.sum()),
    "Mechanistic SIR prediction": float(post_sir.sum()),
    "AR(1) prediction, unchanged": float(ar_rollout_post.sum()),
    "AR(1) prediction, ad hoc 30 percent scale": float(
        ar_rollout_post_scaled.sum()
    ),
}
Out[13]:
Console
Fitted SIR: beta=0.353, gamma=0.020, I0=131.4 -> R0=17.66
True  SIR: beta=0.450, gamma=0.100, I0=100.0 -> R0=4.50

AR(1) on log cases: log(y_next) = 0.410 + 0.980 * log(y_now)

In-sample log-scale RMSE (observation noise sd = 0.25):
  mechanistic SIR : 0.184
  AR(1), one step : 0.250

Total predicted cases over the 20 post-intervention weeks:
  Truth under the intervention                    108,047
  Mechanistic SIR prediction                      164,552
  AR(1) prediction, unchanged                   6,279,971
  AR(1) prediction, ad hoc 30 percent scale     1,883,991

The reported SIR training log RMSE is about 0.184, below the selected log-noise standard deviation of 0.25; the AR(1) value of about 0.250 is about that noise scale. They use different residual protocols: SIR is fitted recursively over the whole window, whereas AR(1) uses observed inputs for one-step predictions. This is not an apples-to-apples held-out ranking. The fitted recovery rate lands at its lower bound, and the resulting reproduction ratio is far from the simulator's true ratio. An action-responsive structure can therefore still yield a poor intervention forecast.

The first figure shows the fit window on a log scale. Both fits follow the reports, but a visual comparison on one history is neither a likelihood-equivalence proof nor an intervention test.

Out[14]:
Visualization
Growing noisy reports and fitted curves on a logarithmic incidence axis for weeks zero through twenty-four.
Reports for weeks 0–24, the recursively fitted SIR incidence, and AR(1) one-step fitted values. Both fits follow the growing reports under the chosen noise, but use different residual protocols. Visual agreement on this realized history does not establish equality of observable distributions or intervention forecasts.

Now we apply the intervention. The mechanistic model reduces its transmission parameter, and the autoregression has nothing to reduce, so it continues on its learned trend. The scaled variant gets the intervention handed to it directly, as a multiplicative reduction in predicted cases, without any of the dynamical knock-on effects. The fitted incidence initially rises before a shallow decline; the executed output reports its peak week. In this simulator, the threshold for growth of infectious prevalence is Reff,t=β^mtSt/(γ^N)\mathcal{R}_{\mathrm{eff},t}=\hat{\beta}m_t S_t/(\hat{\gamma}N), including susceptible depletion. It is not just β^mt/γ^\hat{\beta}m_t/\hat{\gamma}, and an instantaneous prevalence threshold does not by itself determine the slope of incidence. We therefore inspect the actual trajectory rather than infer its shape from a ratio.

Out[15]:
Visualization
Four forecasts on a log incidence axis: declining truth, shallow fitted SIR, and two growing autoregressive variants.
Latent weekly incidence forecasts for weeks 25–44 after the simulator reduces contact by 70 percent at week 25. The fitted SIR responds to that action but initially rises while the stipulated truth falls. Neither the unchanged AR(1) nor its ad hoc output-scaled variant reproduces the intervention trajectory. The logarithmic vertical axis keeps all four curves visible.

Three things are worth reading off this figure carefully:

  • The fitted SIR incidence starts in the same broad range as the truth, but initially rises while the truth declines. Its net decline over the window is much shallower. The estimated parameters differ substantially from the simulator's true parameters; action responsiveness alone has not established accurate intervention dynamics.
  • The unscaled autoregression diverges almost immediately and continues to diverge. It has been asked a question it has no machinery to answer, and its answer is implicitly "nothing changes."
  • The ad hoc scaled variant is the most instructive curve of the three. It was given the intervention explicitly, with the same multiplicative factor the mechanistic model used. It still fails, for a deeper reason: in a mechanistic model, reducing transmission does not merely reduce this week's cases; it also prevents those infections from generating further infections next week and depletes the susceptible pool more slowly. The effect compounds. In this simulator the intervention changes the trajectory over time, so a constant output factor does not reproduce its response. That is not a general claim about scaling: proportional trajectories could be related by a constant factor. This result concerns the specified AR baseline, not all learned or statistical simulators.

We can now probe parameter uncertainty directly. At each fixed recovery rate γ\gamma, refit transmission β\beta and initial prevalence I0I_0 to the same observations, then record training log RMSE and post-intervention totals. During approximately exponential early growth, the net growth rate is primarily related to β−γ\beta-\gamma, not the ratio β/γ\beta/\gamma. Here the fitted loss changes little across several recovery rates even though the ratio and future totals change. This is a shallow profile of the loss under noisy, limited observations, not an exact non-identifiability theorem. Every tested refitted trajectory has a net decline in this post-intervention window.

In[16]:
Code
GAMMA_GRID = np.array([0.07, 0.10, 0.14, 0.20])
sweep_rows = []
sweep_predictions = []

for gamma_value in GAMMA_GRID:

    def residuals_two(params, steps, target_log, g=gamma_value):
        return sir_log_residuals(
            np.array([params[0], g, params[1]]), steps, target_log
        )

    sol = least_squares(
        residuals_two,
        x0=np.array([0.4, 120.0]),
        args=(OBS_STEPS, obs_log),
        bounds=(np.array([0.05, 1.0]), np.array([1.0, 5000.0])),
    )
    assert sol.success
    beta_sweep, i0_sweep = sol.x
    inc_sweep = simulate_sir(
        beta_sweep,
        gamma_value,
        i0_sweep,
        TOTAL_STEPS,
        intervention_step=INTERVENTION_STEP,
        beta_multiplier=BETA_MULTIPLIER,
    )
    sweep_predictions.append(inc_sweep[INTERVENTION_STEP:])
    sweep_rows.append(
        {
            "gamma": float(gamma_value),
            "beta": float(beta_sweep),
            "r0": float(beta_sweep / gamma_value),
            "rmse": float(np.sqrt(np.mean(sol.fun**2))),
            "post_cases": float(inc_sweep[INTERVENTION_STEP:].sum()),
        }
    )
Out[17]:
Console
  gamma    beta     R0   log RMSE    post-intervention cases
   0.07    0.40   5.78      0.184                    125,473
   0.10    0.44   4.35      0.185                    105,903
   0.14    0.48   3.40      0.185                     84,591
   0.20    0.54   2.69      0.186                     61,424

Every row of that table describes a model with a similar training error. Their reproduction ratios and intervention totals differ. The executed output below computes the ratio of the largest and smallest total rather than hardcoding it in the prose. This demonstrates a shallow fitting-loss profile relevant to the forecast, not an exactly flat likelihood ridge.

The next two figures share the fixed recovery-rate axis. One shows the four training errors, and the other shows their post-intervention totals. Their separation is visible on a linear total-incidence axis.

In[18]:
Code
# Arrays are extracted from the completed recovery-rate sweep above; no fitting happens here.
ridge_gamma = np.array([row["gamma"] for row in sweep_rows])
ridge_r0 = np.array([row["r0"] for row in sweep_rows])
ridge_post = np.array([row["post_cases"] for row in sweep_rows])
ridge_rmse = np.array([row["rmse"] for row in sweep_rows])

# Use the same four refitted parameter settings for both displayed diagnostics.
ridge_fit_rmse = ridge_rmse.copy()
peak_week = int(weeks_post[np.argmax(post_sir)])
sweep_total_ratio = float(ridge_post.max() / ridge_post.min())
Out[19]:
Console
Fitted SIR peak week: 29
Selected sweep max/min total ratio: 2.043
Out[20]:
Visualization
Four similar training errors against fixed weekly recovery coefficient.
Training log RMSE at four fixed recovery rates, refitting transmission and initial prevalence at each. The errors are similar for these particular observations, not proven equivalent likelihoods.
Four distinct total incidence predictions against the same recovery coefficient.
Twenty-week intervention totals from the same four refits. A linear axis displays their differing magnitudes; the separately printed output gives the max/min ratio.
Out[21]:
Visualization
Four differently colored and styled incidence forecasts, all with net declines over the forecast window.
Weekly intervention incidence for the four fixed recovery coefficients with transmission and initial prevalence refitted. Each curve ends below its starting value over weeks 25–44, while levels and totals differ. These are selected parameter-profile forecasts, not posterior draws or opposing growth-versus-collapse outcomes.

The last figure isolates parameter uncertainty within one transition structure. All four settings decline over this window, but they disagree about how much incidence remains. Independent information about recovery or transmission could constrain this shallow fitting-loss profile. Longer or more informative observations can also help; there is no claim that additional observational data can never identify these parameters.

Three caveats on the experiment, stated plainly:

  • This is a synthetic system with three state variables and a known generative process. Real outbreaks can involve age structure, contact networks, asymptomatic transmission, behavioral feedback, waning immunity, and reporting delays. None is modeled here.
  • The AR(1) baseline is deliberately weak. A stronger learned model could include action inputs and suitable training data; this example does not evaluate such a model.
  • Nothing here is a statement about any real disease or any real policy. The exercise demonstrates forecast ambiguity within its selected models and synthetic data. Drawing a public health conclusion from it would misuse the example.