27  Interventional Reasoning: Forecasting Under Interventions

Status: Draft

v0.4

27.1 Introduction

This chapter focuses on Doing in the Observable stratum, how can we forecast what will happen under interventions? A critical distinction in causal-dynamical systems: conditional forecasting (what happens if I observe X?) vs interventional forecasting (what happens if I do X?) (Robins 1986; Robins et al. 2000; Pearl 2009). This chapter demonstrates the operational difference and provides templates for interventional queries.

27.2 Conditional vs Interventional Forecasting

27.2.1 Conditional Forecasting

Conditional forecasting asks: “What will happen if I observe X?”

\[ P(Y_{t+1} \mid Y_{1:t}, A_t = a) \]

Interpretation: Distribution of future outcomes given past observations and observed treatment value.

When to use:

  • Prediction: Forecast future observations
  • No intervention: We’re not changing anything, just observing
  • Association: Level 1 of causal hierarchy

27.2.2 Interventional Forecasting

Interventional forecasting asks: “What will happen if I do X?”

\[ P^{do(A_t = a)}(Y_{t+1} \mid Y_{1:t}) \]

Interpretation: Distribution of future outcomes under intervention \(do(A_t = a)\).

When to use:

  • Policy evaluation: What happens if we implement a policy?
  • Treatment effects: What happens if we give treatment?
  • Intervention: Level 2 of causal hierarchy

27.2.3 When Do They Differ?

If treatment assignment is confounded, conditional and interventional forecasts differ (Pearl 2009; Robins et al. 2000):

  • Conditional: \(P(Y \mid A = a)\) adjusts for confounders via conditioning
  • Interventional: \(P^{do(A = a)}(Y)\) sets treatment structurally, breaking confounder links

Key insight: Conditional forecasting observes what happens naturally; interventional forecasting predicts what happens when we change the system.

27.2.4 Implementation: Conditional vs Interventional Forecasting

Here’s an example demonstrating the difference when treatment is confounded:

# Bootstrap book utilities
include(joinpath(dirname(Base.active_project()), "scripts", "book_bootstrap.jl"))

@auto_using Random Distributions GLM DataFrames CairoMakie

Random.seed!(42)

# Simulate data with confounding
# L: Disease severity (confounder)
# A: Treatment (depends on L - confounding!)
# Y: Outcome (depends on L and A)

n = 1000
L = rand(Normal(0, 1), n)  # Confounder
# Treatment assignment: more severe (higher L) → more likely to treat
p_treat = 1 ./ (1 .+ exp.(-(L .- 0.5)))
A = rand.(Bernoulli.(p_treat))
# Outcome: treatment helps, but severity also affects outcome
Y = 0.5 .* A .- 0.8 .* L .+ rand(Normal(0, 0.2), n)

df = DataFrame(L = L, A = A, Y = Y)

# Conditional forecast: P(Y | A = 1)
# This adjusts for confounders via conditioning
conditional_model = lm(@formula(Y ~ A + L), df)
Y_cond_A1 = predict(conditional_model, DataFrame(A = ones(n), L = L))
conditional_forecast = mean(Y_cond_A1)

# Interventional forecast: P^{do(A = 1)}(Y)
# This sets A = 1 structurally, breaking the L → A link
# We need to marginalise over L: E[Y | do(A=1)] = E_L[E[Y | A=1, L]]
# Use marginal distribution of L (not conditional on A=1)
# Sample L from its marginal distribution
L_marginal = rand(Normal(mean(L), std(L)), n)  # Marginal distribution of L
Y_interv_A1 = predict(conditional_model, DataFrame(A = ones(n), L = L_marginal))
interventional_forecast = mean(Y_interv_A1)

# The forecasts differ because:
# - Conditional: P(Y | A=1) conditions on observed A=1, which is correlated with L
# - Interventional: P^{do(A=1)}(Y) sets A=1, breaking the correlation

println("Forecast comparison:")
println("  Conditional: E[Y | A=1] = ", round(conditional_forecast, digits=3))
println("  Interventional: E[Y | do(A=1)] = ", round(interventional_forecast, digits=3))
println("\nNote: When treatment is confounded, these differ!")
println("  Conditional adjusts for confounders via conditioning")
println("  Interventional breaks the confounder link structurally")
Forecast comparison:
  Conditional: E[Y | A=1] = 0.539
  Interventional: E[Y | do(A=1)] = 0.547

Note: When treatment is confounded, these differ!
  Conditional adjusts for confounders via conditioning
  Interventional breaks the confounder link structurally
Figure 27.1: Conditional vs interventional forecasting: the difference when treatment is confounded

27.3 Interventional Forecasting in Dynamical Systems

27.3.1 Structural Changes Propagate Through Time

When we intervene \(do(A_t = a)\), we modify the structural equation for \(A_t\):

\[ A_t \coloneqq a \quad \text{(instead of } \pi(H_t, U^A_t) \text{)} \]

This structural change propagates forward through time:

  1. Immediate effect: \(A_t\) is set to \(a\)
  2. Direct effect: \(A_t\) affects \(Y_t\) and future states
  3. Indirect effects: Changes propagate through feedback loops
  4. Long-term effects: System evolves toward new attractor

27.3.2 Computing Interventional Forecasts

Steps:

  1. Set intervention: \(do(A_t = a)\) for all \(t\) in forecast period
  2. Simulate forward: Use state transition \(f\) to propagate effects
  3. Account for feedback: Past outcomes affect future states
  4. Aggregate: Compute expected outcomes or distributions

27.3.3 Example: Treatment Intervention

Forecast patient outcome under treatment intervention:

\[ P^{do(\text{Treatment}_t = \text{drug A})}(\text{Recovery}_{t+1} \mid \text{History}_{1:t}) \]

This requires:

  • Understanding how treatment affects recovery (causal mechanism)
  • Accounting for time-varying confounding
  • Propagating effects through time

27.3.4 Implementation: Interventional Forecasting in Dynamical Systems

We can forecast under interventions by simulating the system forward with the intervention applied:

# Bootstrap book utilities
include(joinpath(dirname(Base.active_project()), "scripts", "book_bootstrap.jl"))

@auto_using OrdinaryDiffEq CairoMakie

# Example: Disease model with treatment intervention
# Forecast: What happens if we intervene with treatment at t=5?

β = 0.2  # Disease progression
α = 0.3  # Treatment effectiveness

# Original system (no intervention)
function disease_original!(du, u, p, t)
    """Disease model: original dynamics with confounding (treatment depends on severity)."""
    X = u[1]
    # Treatment assignment depends on severity (confounding)
    A = X > 0.5 ? 1.0 : 0.0
    du[1] = β * X - α * A * X
end

# System under intervention do(A = 1) for t >= 5
function disease_intervened!(du, u, p, t)
    """Disease model: intervened dynamics with do(A = 1) starting at t=5."""
    X = u[1]
    # Intervention: do(A = 1) starting at t=5
    A = t >= 5.0 ? 1.0 : (X > 0.5 ? 1.0 : 0.0)
    du[1] = β * X - α * A * X
end

u0 = [0.3]  # Initial severity
tspan = (0.0, 20.0)

# Solve both systems
prob_original = ODEProblem(disease_original!, u0, tspan)
sol_original = solve(prob_original, Tsit5())

prob_intervened = ODEProblem(disease_intervened!, u0, tspan)
sol_intervened = solve(prob_intervened, Tsit5())

# Extract final-time forecasts for reuse (for both plotting and summary text)
final_original = sol_original.u[end][1]
final_intervened = sol_intervened.u[end][1]

println("Interventional forecast:")
println("  Severity at t=20 (original): ", round(final_original, digits=3))
println("  Severity at t=20 (intervened): ", round(final_intervened, digits=3))
println("  Improvement: ", round((final_original - final_intervened) / final_original * 100, digits=1), "%")
Interventional forecast:
  Severity at t=20 (original): 0.502
  Severity at t=20 (intervened): 0.106
  Improvement: 78.9%
Figure 27.2: Interventional forecasting: propagating intervention effects through time

27.4 Uncertainty in Interventional Forecasts

When making interventional forecasts, we must account for uncertainty (Rackauckas 2026). This uncertainty comes from:

  1. Parameter uncertainty: Uncertainty about model parameters
  2. Model uncertainty: Uncertainty about model structure
  3. Measurement uncertainty: Error in observations used to fit the model
  4. Numerical uncertainty: ODE solver errors

Why it matters: Intervention effect estimates without uncertainty bounds are incomplete. We need to know not just the expected effect, but also how uncertain we are about it.

27.4.1 Prediction Intervals for Interventions

Prediction intervals quantify uncertainty in intervention effects:

\[ P^{do(A=a)}(Y_{t+1} \in [L, U] \mid Y_{1:t}) = 1 - \alpha \]

where \([L, U]\) is the \((1-\alpha)\) prediction interval.

Methods:

  • Monte Carlo: Sample parameters from posterior, run model, compute quantiles
  • Linearisation: Approximate using first-order Taylor expansion
  • Bootstrap: Resample data, refit model, compute intervals

Push-forward from a fitted ATE posterior (RxInfer) is the cheapest Bayesian interval when the forecast is a linear functional of \(\tau\):

include(joinpath(dirname(Base.active_project()), "scripts", "book_bootstrap.jl"))
@auto_using CausalDynamics Graphs DataFrames Random Statistics
using RxInfer

Random.seed!(9)
n = 600
Z = randn(n); X = Z .+ randn(n); Y = 1.2 .* X .+ 0.5 .* Z .+ 0.3 .* randn(n)
g = DiGraph(3); add_edge!(g, 1, 2); add_edge!(g, 1, 3); add_edge!(g, 2, 3)
fit = infer_backdoor_effect(g, DataFrame(Z = Z, X = X, Y = Y), 2, 3;
    node_names = Dict(1 => :Z, 2 => :X, 3 => :Y), iterations = 30)
# Gaussian VI marginal → predictive shift for ΔX = 1
τμ, τσ = fit.τ_mean, sqrt(1 / fit.τ_posterior.w)  # NormalWeightedMeanPrecision
Δ = 1.0
lo, hi = τμ * Δ - 1.96 * τσ * Δ, τμ * Δ + 1.96 * τσ * Δ
println("Forecast shift for ΔX=1: ", round(τμ; digits = 3),
    "  95% ≈ [", round(lo; digits = 3), ", ", round(hi; digits = 3), "]")
Forecast shift for ΔX=1: 1.208  95% ≈ [1.131, 1.285]

Implementation:

# Find project root and include ensure_packages.jl
project_root = let
    current = pwd()
    while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
        parent = dirname(current)
        parent == current && break
        current = parent
    end
    current
end
include(joinpath(dirname(Base.active_project()), "scripts", "book_bootstrap.jl"))

@auto_using OrdinaryDiffEq Distributions Random Statistics CairoMakie

# SIR model
function sir!(du, u, p, t)
    S, I, R = u
    β, γ = p
    du[1] = -β * S * I
    du[2] = β * S * I - γ * I
    du[3] = γ * I
end

# Parameter uncertainty: β ~ N(0.3, 0.05²), γ ~ N(0.1, 0.02²)
Random.seed!(42)
n_samples = 1000
β_samples = rand(Normal(0.3, 0.05), n_samples)
γ_samples = rand(Normal(0.1, 0.02), n_samples)

u0 = [0.99, 0.01, 0.0]
tspan = (0.0, 50.0)

# Intervention: reduce transmission rate (vaccination)
β_intervened = 0.15  # 50% reduction

# Monte Carlo: sample parameters, run model, collect outcomes
# Store pairs to ensure matching samples
outcome_pairs = Tuple{Float64, Float64}[]

for i in 1:n_samples
    # Original (no intervention)
    p_orig = (β_samples[i], γ_samples[i])
    prob_orig = ODEProblem(sir!, u0, tspan, p_orig)
    sol_orig = solve(prob_orig, Tsit5())
    
    # Intervened (vaccination)
    p_int = (β_intervened, γ_samples[i])  # β reduced, γ unchanged
    prob_int = ODEProblem(sir!, u0, tspan, p_int)
    sol_int = solve(prob_int, Tsit5())
    
    # Only keep pairs where both succeeded
    if SciMLBase.successful_retcode(sol_orig.retcode) && SciMLBase.successful_retcode(sol_int.retcode) && 
       length(sol_orig.u) > 0 && length(sol_int.u) > 0
        peak_I_orig = maximum([u[2] for u in sol_orig.u])
        peak_I_int = maximum([u[2] for u in sol_int.u])
        if isfinite(peak_I_orig) && isfinite(peak_I_int)
            push!(outcome_pairs, (peak_I_orig, peak_I_int))
        end
    end
end

# Extract separate arrays
outcomes_original = [p[1] for p in outcome_pairs]
outcomes_intervened = [p[2] for p in outcome_pairs]

# Check if we have enough samples
if length(outcomes_original) == 0
    error("No successful ODE solves. Check model parameters and solver settings.")
end

# Compute prediction intervals (95%)
quantiles_orig = quantile(outcomes_original, [0.025, 0.5, 0.975])
quantiles_int = quantile(outcomes_intervened, [0.025, 0.5, 0.975])

# Intervention effect with uncertainty (now guaranteed to have matching pairs)
effect_samples = outcomes_original .- outcomes_intervened
effect_quantiles = quantile(effect_samples, [0.025, 0.5, 0.975])

println("Uncertainty in interventional forecasts:")
println("  Original peak I: ", round(quantiles_orig[2], digits=3), 
        " (95% CI: [", round(quantiles_orig[1], digits=3), ", ", round(quantiles_orig[3], digits=3), "])")
println("  Intervened peak I: ", round(quantiles_int[2], digits=3), 
        " (95% CI: [", round(quantiles_int[1], digits=3), ", ", round(quantiles_int[3], digits=3), "])")
println("  Intervention effect: ", round(effect_quantiles[2], digits=3), 
        " (95% CI: [", round(effect_quantiles[1], digits=3), ", ", round(effect_quantiles[3], digits=3), "])")
println("  Relative reduction: ", round(effect_quantiles[2] / quantiles_orig[2] * 100, digits=1), "%")
Uncertainty in interventional forecasts:
  Original peak I: 0.298 (95% CI: [0.121, 0.496])
  Intervened peak I: 0.061 (95% CI: [0.011, 0.222])
  Intervention effect: 0.226 (95% CI: [0.087, 0.328])
  Relative reduction: 75.9%
Figure 27.3: Uncertainty quantification for interventional forecasts

27.4.2 Decision-Making Under Uncertainty

When making decisions based on interventional forecasts, we need to account for uncertainty:

  • Robust decisions: Choose interventions that work well across parameter uncertainty
  • Risk assessment: Consider worst-case scenarios
  • Value of information: How much would better parameter estimates improve decisions?

Key Points:

  • Uncertainty quantification is essential for reliable interventional forecasts
  • Prediction intervals provide decision-relevant information
  • Parameter uncertainty propagates through models to create forecast uncertainty
  • Connects to counterfactual reasoning (see Counterfactual Reasoning: Unit-Level Alternatives)

27.5 Stratum context

This chapter addresses Doing in the Observable stratum: how can we forecast under interventions? Interventional forecasting requires understanding how inner strata (Structural/Dynamical) become outer strata (Observable), and how we can modify that process through interventions.

27.6 Key Takeaways

  1. Conditional vs interventional: Observing vs doing, fundamentally different
  2. Interventional forecasting: Predict what happens under interventions
  3. Structural changes propagate: Interventions affect system evolution through time
  4. Confounding matters: Conditional and interventional forecasts differ when confounded

27.7 Further Reading