PCI on a dynamical SIR model with policies

This notebook runs probabilistic causal impacts (PCI) on a Bayesian SIR model with two non-pharmaceutical policies whose effects interact: lockdown and mask-wearing. The model and the query come from the chirho tutorial on explainable reasoning in dynamical systems, which scores candidate causes with chirho’s SearchForExplanation handler. We keep both and swap in the thin-search sampler from pci.explanation, which returns a continuous score carrying a necessity and a sufficiency term.

We build the epidemic model, watch a but-for analysis fail to separate the two policies, and then ask what PCI adds. PCI ranks lockdown above mask on the factual world used throughout, keeps that ranking in \(18\) of \(20\) worlds drawn at other operating points.

Setup and notebook conventions

The cell below imports the SIR machinery from chirho (the dynamical handlers that integrate the ODE and log trajectories) together with the thin-search components from pci.explanation: SearchableModel and ThinSearchSampler drive the search over interventional regimes, while abs_diff_score and condition_on_interventional_regime turn sampled regimes into necessity and sufficiency scores.

[1]:
import contextlib
import numbers
import os
import pickle
import sys
from typing import TypeVar, cast

import matplotlib.pyplot as plt
import pandas as pd
import pyro
import pyro.distributions as dist
import seaborn as sns
import torch
from chirho.dynamical.handlers.interruption import StaticEvent
from chirho.dynamical.handlers.solver import TorchDiffEq
from chirho.dynamical.handlers.trajectory import LogTrajectory
from chirho.dynamical.ops import Dynamics, State, on, simulate
from chirho.interventional.ops import Intervention, intervene
from chirho.observational.handlers import condition
from loguru import logger
from pyro.infer import Predictive

from pci.explanation.regime import condition_on_interventional_regime
from pci.explanation.scores import abs_diff_score
from pci.explanation.searchable import SearchableModel
from pci.explanation.thin_search import ThinSearchSampler
from pci.tools.find_root import find_repo_root

R = numbers.Real | torch.Tensor
T = TypeVar("T")

smoke_test = "CI" in os.environ
root = find_repo_root()
results_dir = os.path.join(root, "docs/source/dynamical_benchmark")
fig_dir = os.path.join(root, "docs/source/sir_benchmark")
print(f"Results will be saved to {results_dir}")

# The sampler warns through loguru when a sampled alternative lands too close
# to the factual value; the tqdm bars carry the rest of the progress reporting.
logger.remove()
logger.add(sys.stderr, level="INFO")

num_worlds_at_site = 2 if smoke_test else 20  # factual worlds in the robustness run

# flip to True to recompute the searches and overwrite the cached results
fresh_run = False

seed = 123
pyro.clear_param_store()
pyro.set_rng_seed(seed)

sns.set_style("white")

# Paper-grade matplotlib defaults
plt.rcParams.update(
    {
        "font.size": 11,
        "axes.titlesize": 12,
        "axes.labelsize": 11,
        "legend.fontsize": 9,
        "xtick.labelsize": 9,
        "ytick.labelsize": 9,
        "savefig.dpi": 160,
        "savefig.bbox": "tight",
    }
)
Results will be saved to /home/rafal/s76projects/explainable_paper/docs/source/dynamical_benchmark

SIR model with policies

We use the standard SIR dynamics

\[\dot S = -\beta S I, \qquad \dot I = \beta S I - \gamma I, \qquad \dot R = \gamma I,\]

with a parameterised variant SIRDynamicsPolicies that adds an intervention strength \(l \in [0, 1]\) scaling the transmission rate to \((1 - l)\beta_0\). At \(l = 0\) the model reduces to the unparameterised dynamics. The dynamics are deterministic, so stochasticity enters only through the priors over \(\beta\) and \(\gamma\); we take the structural model as correctly specified and assume no unobserved confounding between the parameters.

The outcome of interest is the overshoot: how far the susceptible count falls between the infectious peak and the end of the run, that is, how many people catch the disease after the peak has passed.The next cell reports units as a fraction of the population (\(0.151\)), but the model’s overshoot_query reports the same quantity in people, which is the unit the threshold uses. The unintervened epidemic therefore overshoots by about \(15\) people out of \(100\), below the \(24\) we treat as the undesirable outcome.

[2]:
class SIRDynamics(pyro.nn.PyroModule):
    def __init__(self, beta, gamma):
        super().__init__()
        self.beta = beta
        self.gamma = gamma

    def forward(self, X: State[torch.Tensor]):
        dX: State[torch.Tensor] = dict()
        dX["S"] = -self.beta * X["S"] * X["I"]
        dX["I"] = self.beta * X["S"] * X["I"] - self.gamma * X["I"]
        dX["R"] = self.gamma * X["I"]

        return dX


class SIRDynamicsPolicies(SIRDynamics):
    def __init__(self, beta0, gamma):
        super().__init__(beta0, gamma)
        self.beta0 = beta0

    def forward(self, X: State[torch.Tensor]):
        self.beta = (1 - X["l"]) * self.beta0
        dX = super().forward(X)
        dX["l"] = torch.zeros_like(X["l"])
        return dX
[3]:
total_population = 100
init_state = dict(S=torch.tensor(99.0), I=torch.tensor(1.0), R=torch.tensor(0.0))
assert init_state["S"] + init_state["I"] + init_state["R"] == total_population

start_time = torch.tensor(0.0)
end_time = torch.tensor(12.0)
step_size = torch.tensor(0.1)
logging_times = torch.arange(start_time, end_time, step_size)  # type: ignore[call-overload]  # torch.arange accepts 0-d tensors at runtime
init_state_lockdown = dict(**init_state, l=torch.tensor(0.0))

beta_true = torch.tensor([0.03])
gamma_true = torch.tensor([0.5])
sir_true = SIRDynamics(beta_true, gamma_true)
with TorchDiffEq(), LogTrajectory(logging_times) as lt:
    simulate(sir_true, init_state, start_time, end_time)

sir_true_traj = lt.trajectory


def get_overshoot(trajectory):
    t_max = torch.argmax(trajectory["I"].squeeze())
    S_peak = trajectory["S"].squeeze()[t_max] / total_population
    S_final = trajectory["S"].squeeze()[-1] / total_population
    return (S_peak - S_final).item()


print(get_overshoot(sir_true_traj))
0.15116800367832184

Bayesian SIR with priors

We put Beta priors on the rates: \(\beta \sim \mathrm{Beta}(18, 600)\) and \(\gamma \sim \mathrm{Beta}(1600, 1600)\), centred near \(0.03\) and \(0.5\).

Policies and asymmetric efficiencies

Two policies, each with prior probability \(1/2\), can be enacted: lockdown at \(t=1\) and masking at \(t=1.5\). Their efficiencies interact asymmetrically. Lockdown alone has efficiency \(0.6\). Masking is worth \(0.45\) on its own but only \(0.1\) under lockdown, since a lockdown has already removed most of the contacts a mask would block. The joint efficiency is the sum of the two, clamped at \(0.95\); with both policies on that sum is \(0.6 + 0.1 = 0.7\).

Interventions go through MaskedStaticIntervention to avoid trace conflicts. overshoot_query reads the overshoot off the logged trajectory and flags \(\mathrm{os\_too\_high} = \mathbb{1}[\mathrm{overshoot} > 24]\).

[4]:
def bayesian_sir(
    base_model=SIRDynamics, plate: pyro.plate | None = None
) -> Dynamics[torch.Tensor]:
    # Beta(18, 600) and Beta(1600, 1600) have scalar parameters, so outside a
    # plate they yield one draw shared by the whole batch. Inside one they are
    # drawn per batch element, which is what lets a batch of factual worlds
    # differ in its operating point and not only in its policy decisions.
    with plate if plate is not None else contextlib.nullcontext():
        beta = pyro.sample("beta", dist.Beta(18, 600))
        gamma = pyro.sample("gamma", dist.Beta(1600, 1600))
    sir = base_model(beta, gamma)
    return sir
[5]:
# Intervene on a dynamical system nested inside another model. The block()
# keeps the intervention off the trace, where the site name already exists.


def MaskedStaticIntervention[T](time: R, intervention: Intervention[State[T]]):

    @on(StaticEvent(time))
    def callback(
        dynamics: Dynamics[T], state: State[T]
    ) -> tuple[Dynamics[T], State[T]]:

        with pyro.poutine.block():
            return dynamics, intervene(state, intervention)

    return callback
[6]:
overshoot_threshold = 24
lockdown_time = torch.tensor(1.0)
mask_time = torch.tensor(1.5)


def policy_model() -> State[torch.Tensor]:

    lockdown = pyro.sample("lockdown", dist.Bernoulli(torch.tensor(0.5)))
    mask = pyro.sample("mask", dist.Bernoulli(torch.tensor(0.5)))

    lockdown_efficiency = pyro.deterministic(
        "lockdown_efficiency", torch.tensor(0.6) * lockdown, event_dim=0
    )

    mask_efficiency = pyro.deterministic(
        "mask_efficiency", (0.1 * lockdown + 0.45 * (1 - lockdown)) * mask, event_dim=0
    )

    joint_efficiency = pyro.deterministic(
        "joint_efficiency",
        torch.clamp(lockdown_efficiency + mask_efficiency, 0, 0.95),
        event_dim=0,
    )

    lockdown_sir = bayesian_sir(SIRDynamicsPolicies)
    with LogTrajectory(logging_times, is_traced=True) as lt:
        with TorchDiffEq():
            with MaskedStaticIntervention(lockdown_time, dict(l=lockdown_efficiency)):
                with MaskedStaticIntervention(mask_time, dict(l=joint_efficiency)):
                    simulate(
                        lockdown_sir, init_state_lockdown, start_time, logging_times[-1]
                    )

    return lt.trajectory


def overshoot_query(
    trajectory: State[torch.Tensor],
) -> tuple[torch.Tensor, torch.Tensor]:

    t_max = torch.max(trajectory["I"], dim=-1).indices
    S_peaks = pyro.ops.indexing.Vindex(trajectory["S"])[..., t_max]
    overshoot = pyro.deterministic(
        "overshoot", S_peaks - trajectory["S"][..., -1], event_dim=0
    )
    os_too_high = pyro.deterministic(
        "os_too_high",
        (overshoot > overshoot_threshold).clone().detach().float(),
        event_dim=0,
    )

    return overshoot, os_too_high


def overshoot_model():
    trajectory = policy_model()
    return overshoot_query(trajectory)

But-for analysis

The classical but-for query comes first: condition on each pair of policy decisions ((0,0), (1,1), (0,1), (1,0)) and draw \(100\) predictive samples from each. Each scenario gives a marginal distribution over the overshoot, which the figure summarises by \(\Pr(\mathrm{overshoot} > 24)\). At \(100\) draws the Monte Carlo standard error on each of those probabilities is about \(0.04\), which matters for reading the four panels.

[7]:
num_samples = 100

# The policy decisions are upstream of the dynamics, so conditioning them
# propagates the change through the trajectory.

overshoot_model_none = condition(
    overshoot_model, {"lockdown": torch.tensor(0.0), "mask": torch.tensor(0.0)}
)
unintervened_predictive = Predictive(
    overshoot_model_none, num_samples=num_samples, parallel=True
)
unintervened_samples = unintervened_predictive()

overshoot_model_all = condition(
    overshoot_model, {"lockdown": torch.tensor(1.0), "mask": torch.tensor(1.0)}
)
intervened_predictive = Predictive(
    overshoot_model_all, num_samples=num_samples, parallel=True
)
intervened_samples = intervened_predictive()

overshoot_model_mask = condition(
    overshoot_model, {"lockdown": torch.tensor(0.0), "mask": torch.tensor(1.0)}
)
mask_predictive = Predictive(
    overshoot_model_mask, num_samples=num_samples, parallel=True
)
mask_samples = mask_predictive()

overshoot_model_lockdown = condition(
    overshoot_model, {"lockdown": torch.tensor(1.0), "mask": torch.tensor(0.0)}
)
lockdown_predictive = Predictive(
    overshoot_model_lockdown, num_samples=num_samples, parallel=True
)
lockdown_samples = lockdown_predictive()

predictive = Predictive(overshoot_model, num_samples=num_samples, parallel=True)
samples = predictive()

print("Variables in the model:", samples.keys())
Variables in the model: dict_keys(['lockdown', 'mask', 'beta', 'gamma', 'lockdown_efficiency', 'mask_efficiency', 'joint_efficiency', 'S', 'I', 'R', 'l', 'overshoot', 'os_too_high'])
[8]:
# Figure 1: but-for analysis, 4 scenarios x 2 panels
# (trajectory on the left, overshoot histogram on the right).
# The "stochastic" prior-mixture case is dropped from this figure for clarity.

# SIR palette (color-blind safe).
sir_colors = {"S": "#377eb8", "I": "#e41a1c", "R": "#4daf4a"}


def _add_traj(preds, ax, color, label):
    sns.lineplot(
        x=logging_times,
        y=preds.mean(dim=0).squeeze().tolist(),
        ax=ax,
        label=label,
        color=color,
        linewidth=1.6,
    )
    ax.fill_between(
        logging_times,
        torch.quantile(preds, 0.025, dim=0).squeeze(),
        torch.quantile(preds, 0.975, dim=0).squeeze(),
        alpha=0.18,
        color=color,
    )


scenarios = [
    ("no interventions", unintervened_samples),
    ("both interventions", intervened_samples),
    ("lockdown only", lockdown_samples),
    ("mask only", mask_samples),
]

n_rows = len(scenarios)
fig, axs = plt.subplots(n_rows, 2, figsize=(11, 2.3 * n_rows), sharex="col")

# common overshoot x-range across the histogram column
all_overshoot = (
    torch.cat([s["overshoot"].squeeze().flatten() for _, s in scenarios])
    .detach()
    .cpu()
    .numpy()
)
hist_lo, hist_hi = float(all_overshoot.min()), float(all_overshoot.max())
hist_pad = 0.05 * (hist_hi - hist_lo + 1e-9)
hist_lo -= hist_pad
hist_hi += hist_pad

butfor_rows: list[tuple[str, float]] = []
for i, (label, samples_i) in enumerate(scenarios):
    ax_traj = axs[i, 0]
    _add_traj(samples_i["S"], ax_traj, sir_colors["S"], "Susceptible")
    _add_traj(samples_i["I"], ax_traj, sir_colors["I"], "Infected")
    _add_traj(samples_i["R"], ax_traj, sir_colors["R"], "Recovered")
    ax_traj.set_title(label, loc="left", fontweight="bold")
    ax_traj.set_ylabel("count")
    if i < n_rows - 1 and ax_traj.get_legend() is not None:
        ax_traj.get_legend().remove()
    else:
        ax_traj.legend(loc="upper right", ncol=3, frameon=False)
    if i == n_rows - 1:
        ax_traj.set_xlabel("time")

    ax_hist = axs[i, 1]
    o = samples_i["overshoot"].squeeze().detach().cpu().numpy()
    pr_too_high = float(samples_i["os_too_high"].squeeze().float().mean().item())
    butfor_rows.append((label, pr_too_high))
    ax_hist.hist(
        o,
        bins=20,
        range=(hist_lo, hist_hi),
        color="#888888",
        edgecolor="black",
        linewidth=0.5,
    )
    ax_hist.axvline(
        overshoot_threshold,
        color="black",
        linestyle="--",
        linewidth=1.0,
        label=f"threshold ({overshoot_threshold})",
    )
    ax_hist.set_title(
        f"Pr(overshoot > {overshoot_threshold}) = {pr_too_high:.2f}",
        loc="right",
    )
    ax_hist.set_xlim(hist_lo, hist_hi)
    ax_hist.set_ylabel("samples")
    if i == n_rows - 1:
        ax_hist.set_xlabel("overshoot")
        ax_hist.legend(loc="upper right", frameon=False)

fig.suptitle(
    "Enacting a Policy Raises the Overshoot, and the Three Intervention Regimes Look Alike",
    fontsize=13,
    y=1.005,
)
plt.tight_layout()
sns.despine()

if not smoke_test:
    os.makedirs(fig_dir, exist_ok=True)
    fig.savefig(os.path.join(fig_dir, "sir_butfor.png"))
    print(f"Saved {os.path.join(fig_dir, 'sir_butfor.png')}")

print(f"but-for: Pr(overshoot > {overshoot_threshold}) by regime")
for label, p in butfor_rows:
    print(f"  {label:<28s} {p:.2f}")

Saved /home/rafal/s76projects/explainable_paper/docs/source/sir_benchmark/sir_butfor.png
but-for: Pr(overshoot > 24) by regime
  no interventions             0.07
  both interventions           0.76
  lockdown only                0.84
  mask only                    0.81
_images/sir_benchmark_12_1.png

Enacting a policy makes the bad outcome more likely. Without intervention \(\Pr(\mathrm{overshoot} > 24) \approx 0.07\), with both policies it is \(0.76\), with lockdown only \(0.84\), and with mask only \(0.81\). The three intervention regimes fall within about two Monte Carlo standard errors of one another and their histograms overlap heavily, so at \(100\) draws this experiment separates intervention from no intervention and nothing finer. A but-for verdict built on these numbers cannot rank lockdown against mask.

Why does suppressing transmission raise the overshoot at all? The cell below answers by holding \(\beta\) and \(\gamma\) at their prior means and simulating each policy configuration deterministically, which strips out the prior noise and leaves the mechanism.

[9]:
def deterministic_overshoot(lockdown_eff: float, joint_eff: float) -> float:
    """Overshoot at the prior-mean rates, for one (lockdown, joint) efficiency pair."""
    sir = SIRDynamicsPolicies(torch.tensor([0.03]), torch.tensor([0.5]))
    with LogTrajectory(logging_times) as lt_det:
        with TorchDiffEq():
            with MaskedStaticIntervention(
                lockdown_time, dict(l=torch.tensor(lockdown_eff))
            ):
                with MaskedStaticIntervention(
                    mask_time, dict(l=torch.tensor(joint_eff))
                ):
                    simulate(sir, init_state_lockdown, start_time, logging_times[-1])
    traj = lt_det.trajectory
    t_peak = int(torch.argmax(traj["I"].squeeze()))
    return float(traj["S"].squeeze()[t_peak] - traj["S"].squeeze()[-1])


efficiency_rows = [
    ("no policies", 0.0, 0.0),
    ("mask only", 0.0, 0.45),
    ("lockdown only", 0.6, 0.6),
    ("both policies", 0.6, 0.7),
    ("(unreachable) at the 0.95 cap", 0.6, 0.95),
]

print(f"{'configuration':32s} {'joint efficiency':>16s} {'overshoot':>10s}")
for label, l_eff, j_eff in efficiency_rows:
    print(f"{label:32s} {j_eff:16.2f} {deterministic_overshoot(l_eff, j_eff):10.2f}")
configuration                    joint efficiency  overshoot
no policies                                  0.00      15.12
mask only                                    0.45      26.28
lockdown only                                0.60      30.19
both policies                                0.70      31.13
(unreachable) at the 0.95 cap                0.95       4.16

Over the whole range this model can reach, the overshoot grows with the strength of suppression: \(15.1\) people with no policy, \(26.3\) under masking alone, \(30.2\) under lockdown alone, \(31.1\) under both. Slowing transmission delays the infectious peak and leaves more susceptibles standing when it arrives, and those susceptibles catch the disease on the way down. Reversing the pattern takes suppression strong enough to truncate the epidemic at the moment the policy lands, which the last row shows at the unreachable \(0.95\): the peak falls back to \(t=1.5\) and the overshoot collapses to \(4.2\). Since the joint efficiency tops out at \(0.7\), both policies operate on the rising part of that curve.

As long as we use flat but-for tests, lockdown alone (\(0.6\)) and both policies (\(0.7\)) differ by a tenth of an efficiency unit, because masking adds only \(0.1\) once lockdown is in force, and once \(\beta\) and \(\gamma\) are drawn from their priors the resulting overshoot distributions overlap. But-for analysis treats each policy as a binary cause and reports an aggregate effect, with no way to say that masking’s contribution depends on whether lockdown is in force.

Conclusions

On the single factual world PCI ranks lockdown above mask, \(0.965\) against \(0.491\), a gap of about four Monte Carlo standard errors. Across \(20\) factual worlds spanning overshoots from \(9.9\) to \(33.8\) people, lockdown leads in \(18\), with a mean gap of \(0.70\) and a standard error of \(0.11\). The two exceptions are worlds whose epidemics never overshot, and there both suspects score negative.

The contexts experiment says more about where the asymmetry comes from. Each context is a different choice of the regime distribution, so each is scored with its own \(q\). With the partner efficiency pinned as a witness, lockdown still scores positive (\(0.49\)) while mask turns negative (\(-0.16\)); with the partner free, lockdown rises to \(1.68\) and mask to \(1.50\).