Causality APIs 02: Seeing Is Not Setting

If observing X and setting X collapse to the same operation in your tooling, confounding has already beaten you.

Most teams run into causality at the moment a predictive relationship is about to become a decision. A variable looks strongly associated with the outcome, so the obvious next question is whether pushing that variable would actually help.

That is the point where ordinary observational tooling stops being enough. If the API cannot separate seeing from doing, the user is being invited to make an intervention with the wrong quantity.

A system that cannot distinguish observation from intervention is not a causal system.

We can set this up with py-scm as a small linear-Gaussian backdoor model. The whole structure is only three nodes: X -> Y, plus the backdoor path X <- Z -> Y.

Backdoor DAG

That is enough to make one observational query differ from its interventional counterpart and also show when the two become equal after adjustment.

Observe the same value you will later set

The query surface is intentionally small:

from pyscm.reasoning import create_reasoning_model

d = {
    "nodes": ["X", "Y", "Z"],
    "edges": [["X", "Y"], ["Z", "X"], ["Z", "Y"]],
}

p = {
    "v": ["X", "Y", "Z"],
    "m": [3.69869185, 8.31314088, 0.90820438],
    "S": [
        [10.70339312, 22.43782095, 3.23883358],
        [22.43782095, 48.42224821, 6.98851448],
        [3.23883358, 6.98851448, 1.08029594],
    ],
}

model = create_reasoning_model(d, p)

see_y, see_cov = model.pquery({"X": 4.171986})
do_y = model.iquery("Y", {"X": 4.171986})

see_xz, see_xz_cov = model.pquery({"X": 4.171986, "Z": 1.0})
do_xz = model.iquery("Y", {"X": 4.171986, "Z": 1.0})

Those four calls show both behaviors:

see X:
  E[Y | X=4.171986]        = 9.3053
  std[Y | X=4.171986]      = 1.1770

do(X):
  E[Y | do(X=4.171986)]    = 9.0212
  std[Y | do(X=4.171986)]  = 2.2898

see X,Z:
  E[Y | X=4.171986, Z=1.0]       = 9.2033
  std[Y | X=4.171986, Z=1.0]     = 0.9953

do(X,Z):
  E[Y | do(X=4.171986, Z=1.0)]   = 9.2033
  std[Y | do(X=4.171986, Z=1.0)] = 0.9953

The first gap is the important one.

Conditioning on X=4.171986 gives 9.3053.

Intervening to force X=4.171986 gives 9.0212.

Same visible value. Different query. Different answer.

Why? Because pquery(...) is still letting Z explain part of what you saw. iquery(...) cuts the incoming edges into X and answers the manipulated-world question instead.

The second pair is equal on purpose.

Once you condition on Z=1.0, the backdoor path X <- Z -> Y is blocked. In this graph Z is itself a root node, so do(Z=1.0) and see(Z=1.0) are the same operation. That means:

P(Y | X=x, Z=z) = P(Y | do(X=x), Z=z) = P(Y | do(X=x, Z=z))

for this model. So the equality in the see X,Z and do(X,Z) lines is not a bug. It is exactly the adjustment story you want a backdoor example to show.

Ask for the effect, not just the state

Once the model is in place, the causal effect call is almost embarrassingly direct:

ace = model.equery("Y", {"X": 4.171986}, {"X": 0.9735130000000005})

The result:

E[Y | do(X=4.171986)] - E[Y | do(X=0.973513)] = 4.7848

That is the kind of quantity that belongs in an API when the user is deciding whether to push a lever, not just explain a correlation.

So what

There are really three separate promises here:

  • pquery(...) should answer the observational conditioning question correctly.
  • iquery(...) should answer the manipulated-world question correctly.
  • equery(...) should make effect estimation a first-class query, not an afterthought users have to reconstruct themselves.

If all three are present, the API starts to feel like a decision tool.

If the second one is missing, users end up treating E[Y | X=x] as if it were E[Y | do(X=x)], and the bug is conceptual before it is numerical.

The useful sanity check is that the gap disappears after controlling for the confounder. If it did not, this would not be a clean backdoor-adjustment example.

Leave a Reply

Discover more from Rocket Vector Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading