Causality APIs 12: The Vaccine Rate Fell Before The Outbreak

When vaccine uptake slips and cases later rise, the causal story still has to pass through trust, exposure, and testing behavior. Confirmed cases are not a harmless slice of the world.

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 factor
  • Vaccination: whether the person is vaccinated
  • Infection: whether infection occurs
  • Testing: whether infection gets investigated
  • Confirmed: whether the case becomes a confirmed case

The graph is:

  • Trust -> Vaccination
  • Trust -> Testing
  • Trust -> Infection
  • Vaccination -> Infection
  • Infection -> Testing
  • Infection -> Confirmed
  • Testing -> Confirmed

Drawn explicitly, the outbreak story looks like this:

Vaccination and outbreak DAG

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.0643
  • P(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.0745
  • P(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:

Observed infection gap versus interventional vaccination gap

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

Discover more from Rocket Vector Blog

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

Continue reading