Causality APIs 13: The Debate Moved The Polls. Or Did The News Cycle?

After a debate, the easiest story is that one night moved the polls. The harder question is how much of the shift belonged to the debate itself and how much belonged to momentum and coverage that were already aligned.

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 movement
  • AdSpend: campaign spending pressure
  • DebateHit: how strong the debate moment is
  • Coverage: how much media amplification follows
  • Poll: 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:

Debate and poll DAG

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.28

model.iquery("Poll", {"DebateHit": 0.0})
# mean 0.00

model.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:

Same week counterfactual without the debate hit

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

Discover more from Rocket Vector Blog

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

Continue reading