Causal Models and Interventions › Worked Example: The Confounded Climate Controller
World Models HandbookAll chapters 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:
- with : a latent endogenous root representing the day's hidden heat load (sunlight, occupancy), unobserved by the modeler. This scalar is distinct from the formal exogenous set above.
- : the cooling effort. In the historical logs, operators react to the heat: with .
- : the room temperature deviation from comfort, with a baseline of 2 degrees: with .
For the derivations below, , , and are centered and mutually independent.
Before computing anything, trace the causal graph. points into both and , forming the backdoor path , while the direct causal path carries the true mechanism with slope . The behavior policy makes an increasing function of , so hot days get more cooling; the mechanism makes push up. Both effects of therefore push the observational association between and 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 , which here simplifies to , 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.
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
The true causal effect of cooling is degrees per unit of effort. What will an unconstrained regression on the logs learn? Regressing on , the population slope is
It is worth seeing exactly where each term comes from. The denominator is the total action variance, split between hidden-load response and independent action noise. The confounding term grows only until 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 .
Now consider the planning consequence. The agent's job is to choose a constant cooling effort to hit the comfort target . The naive model's unconstrained target-matching action solves , giving ; the implemented planner clips it to the actuator limit of . The true interventional model gives . 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.
## 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
We can also ask a Rung 3 question about one specific logged episode. The seeded episode has and 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 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 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 . The difference update therefore retains the episode's full realized outcome disturbance, not only the posterior mean of one component.
## 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,
]