Outbreak arguments often sound simpler than they are.
People see vaccine uptake fall, cases later rise, and then argue past each other:
- one side says the answer is obvious
- the other side points to confirmed-case quirks, testing behavior, or community differences
Both sides are partly noticing real structure. The trouble is that they often compress very different variables into one flat claim.
The causal question is not whether lower vaccination and higher confirmed cases happened in the same place. The causal question is what infections would have looked like under a different vaccination level, holding the rest of the community context fixed.
That is where a DAG becomes useful instead of academic.
Start with the real-world confusion
There are at least three different things people blend together:
- community trust and prevention behavior
- vaccination itself
- who gets tested and therefore shows up in confirmed-case counts
If you ignore that structure, you can get two bad outcomes at once:
- over-credit vaccination for behavior differences that travel with trust
- over-interpret confirmed-case slices that are partly built by testing behavior
Put the outbreak story into a graph
Here is a compact version:
Trust: a community-level trust or prevention factorVaccination: whether the person is vaccinatedInfection: whether infection occursTesting: whether infection gets investigatedConfirmed: whether the case becomes a confirmed case
The graph is:
Trust -> VaccinationTrust -> TestingTrust -> InfectionVaccination -> InfectionInfection -> TestingInfection -> ConfirmedTesting -> Confirmed
Drawn explicitly, the outbreak story looks like this:

The important structural point is that Confirmed is downstream of both infection and testing. That makes it a terrible place to pretend you found a neutral subgroup.
With py-bbn, you can inspect that structure directly.
import networkx as nx from pybbn.graphical import get_graph_tuple, get_minimal_confounders, get_paths g = nx.DiGraph() g.add_edges_from( [ ("Trust", "Vaccination"), ("Trust", "Testing"), ("Trust", "Infection"), ("Vaccination", "Infection"), ("Infection", "Testing"), ("Infection", "Confirmed"), ("Testing", "Confirmed"), ] ) gt = get_graph_tuple(g) get_minimal_confounders(gt, "Vaccination", "Infection")# ['Trust']get_paths(gt, "Vaccination", "Confirmed")
That is already a useful public-health lesson. If you want the effect of vaccination on infection, Trust is the confounding story you need to deal with. If you condition on Confirmed, you are slicing on a downstream reporting node, not a neutral analytic convenience.
Build the toy outbreak model in py-bbn
from pybbn.factory import create_reasoning_model
d = {
"nodes": ["Trust", "Vaccination", "Testing", "Infection", "Confirmed"],
"edges": [
("Trust", "Vaccination"),
("Trust", "Testing"),
("Trust", "Infection"),
("Vaccination", "Infection"),
("Infection", "Testing"),
("Infection", "Confirmed"),
("Testing", "Confirmed"),
],
}
p = {
"Trust": {
"columns": ["Trust", "__p__"],
"data": [["low", 0.35], ["high", 0.65]],
},
"Vaccination": {
"columns": ["Trust", "Vaccination", "__p__"],
"data": [
["low", "no", 0.58],
["low", "yes", 0.42],
["high", "no", 0.12],
["high", "yes", 0.88],
],
},
"Testing": {
"columns": ["Trust", "Infection", "Testing", "__p__"],
"data": [
["low", "no", "no", 0.88],
["low", "no", "yes", 0.12],
["low", "yes", "no", 0.35],
["low", "yes", "yes", 0.65],
["high", "no", "no", 0.82],
["high", "no", "yes", 0.18],
["high", "yes", "no", 0.18],
["high", "yes", "yes", 0.82],
],
},
"Infection": {
"columns": ["Trust", "Vaccination", "Infection", "__p__"],
"data": [
["low", "no", "no", 0.76],
["low", "no", "yes", 0.24],
["low", "yes", "no", 0.88],
["low", "yes", "yes", 0.12],
["high", "no", "no", 0.88],
["high", "no", "yes", 0.12],
["high", "yes", "no", 0.95],
["high", "yes", "yes", 0.05],
],
},
"Confirmed": {
"columns": ["Infection", "Testing", "Confirmed", "__p__"],
"data": [
["no", "no", "no", 1.00],
["no", "no", "yes", 0.00],
["no", "yes", "no", 0.995],
["no", "yes", "yes", 0.005],
["yes", "no", "no", 0.98],
["yes", "no", "yes", 0.02],
["yes", "yes", "no", 0.10],
["yes", "yes", "yes", 0.90],
],
},
}
model = create_reasoning_model(d, p)
The vaccinated group looks safer observationally
If you just compare vaccinated and unvaccinated people:
vax = model.pquery(nodes=["Infection"], evidences=model.e({"Vaccination": "yes"}))["Infection"]
no_vax = model.pquery(nodes=["Infection"], evidences=model.e({"Vaccination": "no"}))["Infection"]
In this toy world:
P(infection = yes | vaccination = yes) = 0.0643P(infection = yes | vaccination = no) = 0.2067
That gap is directionally useful, but it still mixes in the trust channel.
The intervention is smaller and cleaner
do_vax = model.iquery(["Infection"], ["yes"], ["Vaccination"], ["yes"]).iloc[0]
do_no_vax = model.iquery(["Infection"], ["yes"], ["Vaccination"], ["no"]).iloc[0]
Now the interventional difference is:
P(infection = yes | do(vaccination = yes)) = 0.0745P(infection = yes | do(vaccination = no)) = 0.1620
Vaccination still helps. The point is that the causal effect is not identical to the raw difference because community trust was doing some work in the observed comparison.
Side by side, the trust problem stops looking abstract:

The observed gap was directionally right, but it was also borrowing separation from the community context that traveled with vaccination.
Confirmed cases are a dangerous place to condition
If you slice by confirmed cases, you are no longer just looking at infection. You are looking at infection after it passed through testing behavior too.
That is why confirmed-case-only arguments often sound sharper than they deserve.
In this toy model, the more useful case-level question is something like:
model.cquery(
"Infection",
{"Trust": "low", "Vaccination": "no", "Testing": "yes"},
{"Vaccination": "yes"},
)
For a low-trust person who got tested, the counterfactual infection probability under vaccination falls to about 0.3155.
That is much more actionable than a shouting match over confirmed-case counts. It asks: for this kind of person in this kind of community context, how much risk shift is vaccination buying?
What this buys you
This is why graph queries matter in public-health arguments:
- the graph separates trust from vaccination
- the graph warns you that confirmed status is not a neutral slice
- the intervention computes the effect people actually mean
- the counterfactual makes the story case-level and concrete
That is a better public conversation than pretending every confirmed-case table is automatically causal.
Next in the series: debate-night poll swings often borrow their drama from momentum that was already in motion.

Leave a Reply