Debate nights produce some of the laziest causal claims in politics.
The snapshots are irresistible:
- clip goes viral
- coverage explodes
- polls move
Then the sentence arrives: the debate changed the race.
Sometimes it did. But in many cycles, what people call “the debate effect” is partly a momentum effect that was already building and partly a coverage effect that is downstream of the debate itself.
If you want to know what the debate did, you cannot treat momentum as if it were invisible or media coverage as if it were just another harmless control.
That is why this is a good py-bbn plus py-scm problem.
Start with the familiar version
The ordinary political argument says:
- after the debate, Candidate A gained in the polls
- therefore the debate performance caused the gain
But there are at least two other moving pieces:
- the campaign may already have had favorable momentum
- the media coverage surge may be part of the mechanism, not an outside nuisance
So the practical question becomes:
- what moved because of the debate hit itself?
- what was already moving?
- what part of the movement traveled through the coverage channel?
Put that into a graph
Here is a compact version:
Momentum: preexisting campaign movementAdSpend: campaign spending pressureDebateHit: how strong the debate moment isCoverage: how much media amplification followsPoll: the poll outcome
The graph says:
- momentum affects both the debate narrative and the polls
- the debate hit affects coverage
- coverage affects polls
- ad spend moves polls too, even if it is not the confounder of the debate effect in this toy story
Drawn explicitly, that election-week story looks like this:

With py-bbn, the confounding question is easy to state.
import networkx as nx from pybbn.graphical import get_graph_tuple, get_minimal_confounders, get_paths g = nx.DiGraph() g.add_edges_from( [ ("Momentum", "DebateHit"), ("Momentum", "Coverage"), ("Momentum", "Poll"), ("AdSpend", "Poll"), ("DebateHit", "Coverage"), ("Coverage", "Poll"), ("DebateHit", "Poll"), ] ) gt = get_graph_tuple(g) get_minimal_confounders(gt, "DebateHit", "Poll")# ['Momentum']get_paths(gt, "DebateHit", "Poll")
That is already a useful editorial correction. The debate may matter, but momentum is doing confounding work, and coverage is part of the pathway rather than something you casually subtract away.
Use py-scm for the poll movement
import numpy as np
from pyscm.reasoning import create_reasoning_model
def build_linear_model(nodes, weighted_edges):
idx = {node: i for i, node in enumerate(nodes)}
B = np.zeros((len(nodes), len(nodes)))
D = np.eye(len(nodes))
for parent, child, weight in weighted_edges:
B[idx[child], idx[parent]] = weight
A = np.eye(len(nodes)) - B
cov = np.linalg.inv(A) @ D @ np.linalg.inv(A).T
return create_reasoning_model(
{"nodes": nodes, "edges": [(p, c) for p, c, _ in weighted_edges]},
{"v": nodes, "m": [0.0] * len(nodes), "S": cov.tolist()},
)
nodes = ["Momentum", "AdSpend", "DebateHit", "Coverage", "Poll"]
weighted_edges = [
("Momentum", "DebateHit", 0.9),
("Momentum", "Coverage", 0.6),
("Momentum", "Poll", 1.4),
("AdSpend", "Poll", 0.9),
("DebateHit", "Coverage", 1.1),
("Coverage", "Poll", 0.8),
("DebateHit", "Poll", 0.4),
]
model = build_linear_model(nodes, weighted_edges)
The raw post-debate slice looks bigger than the debate effect
If you just condition on a strong debate hit:
mean, _ = model.pquery({"DebateHit": 1.0}) float(mean["Poll"])# 2.2148
That is the kind of number that encourages sweeping commentary. The polls look about 2.21 points better in the strong-hit state.
But the graph already warned us: some of that is momentum.
The intervention is smaller and more defensible
model.iquery("Poll", {"DebateHit": 1.0})# mean 1.28model.iquery("Poll", {"DebateHit": 0.0})# mean 0.00model.equery("Poll", {"DebateHit": 1.0}, {"DebateHit": 0.0})# mean 1.28
So in this toy model, the debate hit still moves the polls. It just moves them by about 1.28 points, not 2.21.
That is the difference between “the entire swing belonged to the debate” and “the debate mattered inside a race that was already moving.”
Counterfactuals let you revisit one polling moment
Suppose a post-debate poll comes in with a favorable move of 2.1 points.
You can ask:
model.cquery(
"Poll",
factual={
"Momentum": 0.7,
"AdSpend": 0.2,
"DebateHit": 1.0,
"Coverage": 1.6,
"Poll": 2.1,
},
counterfactual=[{"DebateHit": 0.0}],
)
In this toy counterfactual, the same setting drops to about 0.82 without the debate hit.
The same-week counterfactual makes the argument narrower and better:

It holds the campaign week fixed and asks what the polls would have looked like without the debate hit itself.
That is a much sharper claim than “the debate won the week.” It says the debate likely mattered, but part of what people saw in the polling average was momentum and amplification moving through the same window.
What this buys you
Political commentary often argues with causal language while refusing causal structure.
The graph makes the structure explicit:
- momentum is the confounder
- coverage is part of the mechanism
- the intervention is not the same thing as the post-debate snapshot
That is exactly the kind of distinction a causal API should make easy to inspect.
Next in the series: broad geopolitical counterfactuals are usually too big to answer cleanly, but sanction-sized interventions are not.

Leave a Reply