Eventually the user stops asking about groups and asks about a case they can point to. This patient. This lead. This customer. This failure. Average effects are useful, but they do not answer the postmortem question people actually ask in real decisions: what would likely have happened here if we had acted differently?
That is where counterfactuals stop being academic vocabulary and become the product.
Counterfactuals are not bigger conditionals. They are queries about another world for the same case.
That distinction matters because users often think they want a subgroup average when they really want a counterfactual.
We can set this up with py-bbn using a small gender -> drug, gender -> recovery, drug -> recovery graph. The exact numbers are not the point. The point is the shape of the question.
This is the whole structural story for the example: Gender affects both treatment assignment and recovery, and drug also affects recovery. That is enough to make the subgroup and intervention questions easy to state, and it also makes clear why the counterfactual question has to do more than just re-slice the observed data.

Take one factual case:
gender = femaledrug = yesrecovery = yes
Now ask a harder question:
What would likely have happened for this same woman if the action had been drug = no instead?
The comparison graphic below is the right way to orient the reader before looking at the numbers. Gender is a common cause of both treatment and recovery, so conditioning on it is enough to make the observational and interventional subgroup queries line up. But the counterfactual panel is answering a different question entirely: it keeps the same woman and asks what her outcome would have been in the other world.

Association and intervention are not enough
Here are the three queries side by side:
from pybbn.factory import create_reasoning_model
d = {
"nodes": ["drug", "gender", "recovery"],
"edges": [("gender", "drug"), ("gender", "recovery"), ("drug", "recovery")],
}
p = {
"gender": {"columns": ["gender", "__p__"], "data": [["male", 0.51], ["female", 0.49]]},
"drug": {
"columns": ["gender", "drug", "__p__"],
"data": [
["female", "no", 0.24],
["female", "yes", 0.76],
["male", "no", 0.76],
["male", "yes", 0.24],
],
},
"recovery": {
"columns": ["gender", "drug", "recovery", "__p__"],
"data": [
["female", "no", "no", 0.90],
["female", "no", "yes", 0.10],
["female", "yes", "no", 0.27],
["female", "yes", "yes", 0.73],
["male", "no", "no", 0.99],
["male", "no", "yes", 0.01],
["male", "yes", "no", 0.07],
["male", "yes", "yes", 0.93],
],
},
}
model = create_reasoning_model(d, p)
observed = model.pquery(
nodes=["recovery"],
evidences=model.e({"gender": "female", "drug": "no"}),
)["recovery"]
intervened = model.intervene({"drug": "no"}).pquery(
nodes=["recovery"],
evidences=model.e({"gender": "female"}),
)["recovery"]
counterfactual = model.cquery(
"recovery",
{"gender": "female", "drug": "yes", "recovery": "yes"},
{"drug": "no"},
)
Those are three different objects:
P(recovery=yes | gender=female, drug=no) = 0.1000
P(recovery=yes | gender=female, do(drug=no)) = 0.1000
P(recovery=yes if do(drug=no) | actual female, drug=yes, recovery=yes) = 0.1370
The first two agree here because once gender is fixed, there is no remaining confounding path into the treatment. That is the left-to-middle move in the figure.
The third one does not agree with them, and that is the whole point. The right panel is not another subgroup calculation. It is the same factual case, carried into a different action.
Why the counterfactual is different
The factual success on treatment is evidence about the latent response profile of this exact case. The model is effectively saying:
This woman is not just a random untreated female drawn from the baseline subgroup.
She is a woman who, in the factual world, recovered under treatment.
That extra information changes the posterior over the hidden structural pieces that govern response, which is exactly why the counterfactual untreated recovery probability moves from 10.0% to 13.7%.
That is a small enough example to see the semantics clearly:
- observational: what happens in the observed untreated subgroup?
- interventional: what happens if we apply the untreated action to the subgroup?
- counterfactual: what happens if we change the action for this case, while keeping the same exogenous draw?
So what
This is where a lot of “causal” products stop too early. They expose observational and interventional queries, then leave users to mentally abuse one of them into a counterfactual.
That is not good enough.
A serious causal interface should let the user say:
model.cquery(
"recovery",
{"gender": "female", "drug": "yes", "recovery": "yes"},
{"drug": "no"},
)
That call is the product, because it is the first one that directly answers the question the user actually cares about.

Leave a Reply