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.

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