Causal Models and Interventions › Worked Example: The Confounded Climate Controller

Michael BrenndoerferJune 21, 20268 min read
World Models HandbookAll chapters
Part 4 of 12Space, Physics, and Causality
Chapter 21Causal Models and Interventions › Worked Example: The Confounded Climate Controller
Reading section 9 of 14 · Worked Example: The Confounded Climate Controller · 8 minSection 9 of 14 · 8 min

Book contents

Section 9 of 14 · 8 min read

Causal Models and Interventions › Worked Example: The Confounded Climate ControllerOne section · Sections in this chapter9 / 14 ↓

Let us now make the opening story precise and small enough to compute by hand. The system has three variables:

  • U=ηUU = \eta_U with ηU∼N(0,1)\eta_U \sim \mathcal{N}(0, 1): a latent endogenous root representing the day's hidden heat load (sunlight, occupancy), unobserved by the modeler. This scalar UU is distinct from the formal exogenous set U\mathcal{U} above.
  • AA: the cooling effort. In the historical logs, operators react to the heat: A=0.8 U+ηAA = 0.8\,U + \eta_A with ηA∼N(0,0.52)\eta_A \sim \mathcal{N}(0, 0.5^2).
  • YY: the room temperature deviation from comfort, with a baseline of 2 degrees: Y=−1.5 A+U+2+ηYY = -1.5\,A + U + 2 + \eta_Y with ηY∼N(0,1)\eta_Y \sim \mathcal{N}(0, 1).

For the derivations below, UU, ηA\eta_A, and ηY\eta_Y are centered and mutually independent.

Before computing anything, trace the causal graph. UU points into both AA and YY, forming the backdoor path A←U→YA \leftarrow U \to Y, while the direct causal path A→YA \to Y carries the true mechanism with slope −1.5-1.5. The behavior policy makes AA an increasing function of UU, so hot days get more cooling; the mechanism makes UU push YY up. Both effects of UU therefore push the observational association between AA and YY in the same weakening direction, which is why the bias below is so large.

The bias formula rewards one more look before we simulate. Because the bias term α⋅U_VAR/(α2⋅U_VAR+SIGMA_A2)\alpha \cdot \text{U\_VAR} / (\alpha^2 \cdot \text{U\_VAR} + \text{SIGMA\_A}^2), which here simplifies to α/(α2+0.25)\alpha / (\alpha^2 + 0.25), depends on how strongly operators react to the heat load and how much independent action noise they inject, we can sweep the reaction strength and watch the fitted slope travel between the causal value and a badly flattened one at intermediate reaction strengths.

In[5]:
Code
import numpy as np

## Sweep the behavior policy's reaction strength. Each point is the
## population OLS slope under the linear-Gaussian SCM of the worked example,
## computed in closed form (no sampling), so no seed or tie-breaking is needed.
conf_grid = np.linspace(0.0, 2.0, 41)
slope_grid = CAUSAL_SLOPE + conf_grid / (conf_grid**2 + SIGMA_A**2)
bias_here = CONFOUNDING / (CONFOUNDING**2 + SIGMA_A**2)
slope_chapter = CAUSAL_SLOPE + bias_here
Out[6]:
Visualization
Population OLS cooling slope rises from the causal value to a peak bias at alpha 0.5, then declines toward the causal value; the chapter parameter is marked.
Population OLS slope as a function of the behavior policy's reaction to the hidden heat load. The fitted slope starts at the true causal value when the backdoor is closed, reaches its most biased value of -0.5 near alpha = 0.5, and returns toward the causal value as reactive behavior grows very strong; the chapter's parameter choice is marked.

The true causal effect of cooling is −1.5-1.5 degrees per unit of effort. What will an unconstrained regression on the logs learn? Regressing YY on AA, the population slope is

β=Cov(A,Y)Var(A)=−1.5+0.80.64+0.25≈−1.5+0.899=−0.601.\beta = \frac{\mathrm{Cov}(A, Y)}{\mathrm{Var}(A)} = -1.5 + \frac{0.8}{0.64 + 0.25} \approx -1.5 + 0.899 = -0.601.

It is worth seeing exactly where each term comes from. The denominator 0.64+0.250.64 + 0.25 is the total action variance, split between hidden-load response and independent action noise. The confounding term α/(α2+0.25)\alpha/(\alpha^2+0.25) grows only until α=0.5\alpha=0.5 and then decreases; increasing independent action noise reduces it. This expression captures the nonmonotonic omitted-variable bias of the stated linear additive SCM.

The operators' reactive behavior makes cooling look roughly 2.5 times weaker than it is. The bias is entirely due to the backdoor path A←U→YA \leftarrow U \to Y.

Now consider the planning consequence. The agent's job is to choose a constant cooling effort aa to hit the comfort target Y=0Y = 0. The naive model's unconstrained target-matching action solves −0.601 a+2=0-0.601\,a + 2 = 0, giving a≈3.33a \approx 3.33; the implemented planner clips it to the actuator limit of 3.03.0. The true interventional model gives a≈1.33a \approx 1.33. In this linear additive SCM, a positive operator response to a positive heat-load effect biases the apparent negative cooling effect toward zero, so the naive planner overshoots. That direction is specific to these structural signs rather than a rule for arbitrary behavior policies.

Before running the planners, it is worth seeing the cost function they search. The true expected cost is a parabola in the cooling effort, and the two models effectively place their plan at different points on that same parabola.

In[7]:
Code
## True expected cost under do(A = a), from the closed form used later:
## mean temperature CAUSAL_SLOPE * a + BASELINE (E[U] = 0), variance
## U_VAR + SIGMA_Y**2. Computed analytically, so no sampling or seeds apply.
a_curve = np.linspace(0.0, 3.0, 200)
cost_curve = (CAUSAL_SLOPE * a_curve + BASELINE) ** 2 + U_VAR + SIGMA_Y**2
a_opt_curve = -BASELINE / CAUSAL_SLOPE  # analytic minimizer, ~1.33
Out[8]:
Visualization
Parabola of expected cost versus cooling effort, causal plan near minimum, naive plan far right.
True expected comfort cost as a function of a constant cooling effort. The causal model's plan lands at the minimum of this curve, while the naive confounded model chooses far more cooling and pays a much higher cost.

We can also ask a Rung 3 question about one specific logged episode. The seeded episode has A=1.02A = 1.02 and Y=2.94Y = 2.94 after rounding. Abduction updates the day's heat-load estimate from this pair; prediction then asks what the temperature would have been had the operators applied A=1.5A = 1.5 in that same room, on that same day. This differs from a fitted population prediction at cooling 1.5, which does not retain the realized disturbance from this episode.

The distinction between the counterfactual and the population answer is easiest to see side by side, using the same episode computed above.

The posterior mean u^\hat u below is a useful diagnostic summary of the hidden heat load. It is not inserted directly into the counterfactual equation because, in this additive constant-slope SCM, subtracting the factual equation from the counterfactual equation cancels the combined term U+ηYU+\eta_Y. The difference update therefore retains the episode's full realized outcome disturbance, not only the posterior mean of one component.

In[9]:
Code
## Self-contained computation so this block runs in chapter order: the naive
## fit, the episode, and the abduction are all recomputed here from the same
## linear-Gaussian SCM used in the Code Implementation section.
rng_cf = np.random.default_rng(11)
u_fig = rng_cf.normal(size=4000)
a_fig = CONFOUNDING * u_fig + SIGMA_A * rng_cf.normal(size=4000)
y_fig = (
    CAUSAL_SLOPE * a_fig + u_fig + BASELINE + SIGMA_Y * rng_cf.normal(size=4000)
)
slope_obs, intercept_obs = fit_ols(a_fig, y_fig)

i = 1694
a0, y0 = a_fig[i], y_fig[i]
prec_A, prec_Y, prec_U = 1 / SIGMA_A**2, 1 / SIGMA_Y**2, 1 / U_VAR
H = CONFOUNDING**2 * prec_A + prec_Y + prec_U
b = CONFOUNDING * a0 * prec_A + (y0 - CAUSAL_SLOPE * a0 - BASELINE) * prec_Y
u_hat = b / H
a_cf = 1.5
## A same-episode counterfactual retains the realized outcome disturbance as
## well as the hidden heat load. In this additive SCM, subtracting the factual
## and counterfactual equations gives the exact update below.
y_cf = y0 + CAUSAL_SLOPE * (a_cf - a0)

y_population = slope_obs * a_cf + intercept_obs
cf_bars = [y0, y_cf, y_population]
cf_labels = [
    "Observed\n(A = %.2f)" % a0,
    "Counterfactual\n(do(A = %.2f))" % a_cf,
    "Naive fitted\nprediction at A = %.2f" % a_cf,
]
Out[10]:
Visualization
Bars comparing the observed temperature, same-episode counterfactual temperature, and finite-sample naive fitted prediction.
One logged episode viewed three ways: the observed temperature, the same-episode counterfactual under stronger cooling, and the finite-sample naive fitted prediction. The counterfactual retains the episode's full realized disturbance, producing a visibly different answer.