The setting
It is election day, 3 November 2026, and the polls have not closed. This notebook makes two forecasts on information that ends on 2 November: who wins the 435 House seats and 35 Senate races, and how much the campaigns will turn out to have spent. Both are written to a file before any result exists, and the notebook ends with the code that will score them.
Information about an election arrives unevenly. A midterm has hundreds of national generic-ballot polls ("would you vote for the Democrat or the Republican for Congress?"), a few hundred Senate polls, and polls in perhaps a quarter of the House districts, many paid for by a party or a campaign. What every district does have is fundamentals: its partisan lean, whether an incumbent runs, how much money each side has raised, and the habit of voters turning against the president's party in a midterm (Abramowitz 2010). Poll aggregators combine polls with fundamentals (Linzer 2013; Heidemanns, Gelman and Morris 2020). The idea that ties it together is partial pooling: each source speaks where it exists, the fundamentals carry the rest, and errors are shared where they really are shared.
E40 built a poll aggregator for the 2016 presidential election: a reverse random walk, pollster house effects and a shared polling error, rotated to be sampleable. This notebook builds on it without repeating its derivations. What is new is the ragged picture, backtests on two midterms, and money.
Money enters in three roles. As a forecast signal, the ratio of the two candidates' receipts predicts the vote beyond lean and incumbency. As a cause, it is mostly confounded: Jacobson (1978, 1990) found that incumbents who spend more do worse, because money follows close races, and Levitt (1994) used rematches between the same two candidates to find a small effect. As a quantity to nowcast, total spending through election day is not known on election day, because candidates report their October spending a month later. This is the problem of nowcasting an epidemic from delayed case reports (E38; Höhle and an der Heiden 2014), with dollars instead of cases.
| part | question | tool |
|---|---|---|
| 1 | What data exist, and what was checked? | FEC, FiveThirtyEight, MEDSL and Wikipedia, cut at each eve |
| 2 | What information exists, and where? | polls per race against partisan lean |
| 3 | The national environment | a midterm prior, a weekly reverse random walk, a shared polling error; a rotation (failure 1) |
| 4 | Do the forecasts hold up? | backtests of 2018 and 2022: the full model against aggregate-only, polls-only and fundamentals-only |
| 5 | Does money buy votes? | Jacobson's paradox (failure 2); Levitt's rematches with hierarchical slopes |
| 6 | How much was spent? | the spending curve, a reporting-lag model for outside money, a late-spending model (failure 3), backtests |
| 7 | 2026 | two frozen forecasts (pre-registered and corrected), an animated map of predictive draws, the forecast if Democrats fall short of their polls, spending nowcasts |
| 8 | Scoring 2026, later | the scorer, exercised on 2018 and 2022; "not yet available" for 2026 |
import os
for name in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(name, "1") # before NumPy: one BLAS thread per chain
os.environ.setdefault("JAX_PLATFORMS", "cpu") # nothing here uses a GPU
import gzip
import hashlib
import json
import logging
import sys
import time
import warnings
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import pytensor.tensor as pt
from IPython.display import HTML
from matplotlib import animation
from matplotlib.collections import LineCollection, PolyCollection
from matplotlib.lines import Line2D
from matplotlib.ticker import MaxNLocator
from scipy.stats import gaussian_kde
from pymc_challenges import data
# ArviZ leaves a lazy stub under "jax", and Matplotlib's first plot tests it, which would import all of
# JAX for nothing (see E89). Nothing here uses JAX, so the stub goes.
sys.modules.pop("jax", None)
RANDOM_SEED = 94
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")
logging.getLogger("pymc").setLevel(logging.WARNING)
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", message=".*sample_stats.*")
pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 20)
T_START = time.time()
D_C, R_C, INK, MUTED, LIGHT, GOLD = "#2166ac", "#c0392b", "#222222", "#8a8a8a", "#d9d9d9", "#e08e0b"
EVE = {2018: "2018-11-05", 2022: "2022-11-07", 2026: "2026-11-02"} # last day of information
ELEC = {2018: "2018-11-06", 2022: "2022-11-08", 2026: "2026-11-03"}
END_30G = {2018: "2018-11-26", 2022: "2022-11-28", 2026: "2026-11-23"} # post-general coverage ends
PRES = {1978: "D", 1982: "R", 1986: "R", 1990: "R", 1994: "D", 1998: "D", 2002: "R", 2006: "R",
2010: "D", 2014: "D", 2018: "R", 2022: "D", 2026: "R"} # president's party at each midterm
TRAIN = {2018: [2016], 2022: [2016, 2018, 2020], 2026: [2016, 2018, 2020, 2022]}
TIMES = {}
1 · The data
Everything comes from public sources and was processed by tools/build_e94_data.py from raw downloads
into small files. Every result in a backtest uses only what was public by the evening before the
election: 5 November 2018, 7 November 2022 and 2 November 2026. Polls are kept only if
FiveThirtyEight had entered them by the eve, FEC reports and independent expenditures by the date the
FEC received them, and the 2026 Wikipedia tables at their last revision on or before 2 November.
Some things could not be had. No openly licensed list of individual 2026 national polls exists, so 2026 uses weekly snapshots of the aggregator averages on Wikipedia (Decision Desk HQ, RealClearPolitics, Silver Bulletin and others): averages, not polls. MEDSL's current House data sit behind a guestbook, so its older GitHub copy is used for 2004-2014 only. The FEC report file keeps only the latest version of each report, so a report amended after the eve drops out of "known by the eve" (1-2% of pre-general reports).
for name in ("e94_races", "e94_polls", "e94_reports", "e94_ie", "e94_rematch", "e94_pollerr"):
data.describe(name)
print()
e94_races: One row per race: cycle (2016-2026), office (H/S), state, race (e.g. PA-07, OH-S, MN-S2 for a special), region (Census), special, seat (counts toward the next Congress), lean (D-minus-R points on the margin scale: 538 lean for 2016-2022, 2 x Cook PVI for 2026; blank where the lines differ), pvi (2026), inc_party (D, R or blank for an open seat), open, d_inc, r_inc, d_id/r_id (FEC candidate IDs) and names of the top D-side and R candidates, d_is_ind (independent on the D side), contested (both sides on the ballot), split (jungle-style ballot), dem2 (D share of the two-party vote; blank for 2026), winner, rcv (ranked-choice final round used), d_votes, r_votes, *_rec_eve / *_disb_eve / *_coh_eve (receipts, disbursements, cash on hand in reports the FEC had received by the eve: 2018-11-05, 2022-11-07, 2026-11-02), *_rec_final / *_disb_final (cycle totals from weball), member and status (2026), multi_cands (2026: several candidates of one side on the November ballot, as in Louisiana), *_id_name_match (2026: FEC ID found by name). source: US House and Senate races 2016-2022 from the FEC's official 'Federal Elections' results workbooks (US government work, public domain), money from FEC bulk files (weball, House/Senate campaign reports, candidate-committee linkage; public domain), partisan lean from FiveThirtyEight's partisan-lean files (github.com/fivethirtyeight/data, CC BY 4.0). The 2026 rows come from Wikipedia's '2026 United States House of Representatives elections' (revision 1383470484) and '2026 United States Senate elections' (revision 1383488048) as of 2026-11-02, CC BY-SA 4.0, whose PVI column reproduces the Cook Political Report's Partisan Voting Index (credit Cook). Changed: parsed, joined and summarised. DERIVED by tools/build_e94_data.py - keep the cached file. No endorsement by any source is implied. url: https://www.fec.gov/introduction-campaign-finance/election-results-and-voting-information/ e94_polls: One row per poll: cycle, kind (generic, senate, house; 2026 also generic_avg = an aggregator's average and state_generic), race (US or a race key from e94_races), pollster, partisan (D/R sponsor or pollster), internal (538's campaign-internal flag), start, end, created (538's entry time; for 2026 averages the snapshot date), n, population (lv/rv/a/v), dem, rep (percent), source, poll_id, admin (aggregate window). source: 2018 and 2022: FiveThirtyEight's poll database (generic_ballot, senate and house _polls_historical.csv, Wayback snapshot of 2025-01-18; CC BY 4.0, github.com/fivethirtyeight/data). 2026: poll tables in 85 Wikipedia race articles and weekly snapshots of the generic-ballot aggregate table, each at its last revision on or before 2026-11-02 (CC BY-SA 4.0; the aggregates are third-party averages). Changed: one question per poll and race, nominees matched by name, polls created (2018/2022) or ending (2026) after the eve dropped. DERIVED by tools/build_e94_data.py - keep the cached file. No endorsement is implied. url: https://github.com/fivethirtyeight/data/tree/master/polls e94_reports: One row per candidate and report: cycle, cand_id, rpt (Q1-Q3, 12P, 12G pre-general, 30G post-general, YE, TER, ...), cvg_start, cvg_end (period covered), receipt (date the FEC received the current version), disb, receipts, coh (cash on hand at the end), race, office, side. The 2026 file was read on 2026-11-03. source: FEC bulk 'House/Senate current campaigns' report files for 2018, 2022 and 2026 (one row per Form 3 report, current version only; public domain), with the candidate-committee linkage file. Changed: rows for the general-election D and R candidates in e94_races, summed over a candidate's committees. DERIVED by tools/build_e94_data.py - keep the cached file. url: https://www.fec.gov/data/browse-data/?tab=bulk-data e94_ie: Dollars by version (final = as filed by 2026-11-03; eve = only filings received by the eve), cycle, race, pro (side helped: support for or opposition to the other), nominee (item names a general-election candidate), date (dissemination, else expenditure date), first_receipt (when the item was first received), amt. source: FEC bulk independent-expenditure files 2018, 2022, 2026 (24/48-hour reports and Schedule E items with receipt and dissemination dates; public domain). Changed: general-election House/Senate items only; prank filers (any single item of $100M or more; 2022: 1 spender, $20B; 2026: 2 spenders, $14B) dropped; filings replaced by an amendment dropped; repeats of the same spender, candidate, amount and date merged keeping the first receipt. DERIVED by tools/build_e94_data.py - keep the cached file. url: https://www.fec.gov/data/browse-data/?tab=bulk-data e94_rematch: One row per rematch: cycle, state, race, d_id, r_id, names, dem2 and dem2_prev (D two-party share now and in the first meeting), d_inc, r_inc (now and _prev), d_disb, r_disb (cycle disbursements, dollars, now and _prev). source: House rematches between the same two FEC candidate IDs in consecutive cycles, 2006-2020. Votes: MEDSL constituency returns 1976-2018 on GitHub (the same data as MEDSL's CC0 Dataverse release; the repository carries no licence file) for 2004-2014, FEC results workbooks for 2016-2020; candidates matched to FEC IDs by name within district and party for 2004-2014; spending from FEC weball. Cycles across a redistricting (2012, 2022) and court redraws (GA, TX 2006; FL, NC, VA 2016; PA 2018; NC 2020) excluded. DERIVED by tools/build_e94_data.py - keep the cached file. url: https://github.com/MEDSL/constituency-returns e94_pollerr: One row per race and cycle (1998-2022): cycle, type_simple, generic (national generic ballot), race_id, location, err (mean signed error of the polls on the D-minus-R margin, points), n (polls). source: FiveThirtyEight pollster-ratings raw_polls.csv (CC BY 4.0). Changed: polls in the last 21 days of House generic (House-G), national generic ballot (House-G-US) and Senate (Sen-G) races, averaged per race. DERIVED by tools/build_e94_data.py - keep the cached file. url: https://github.com/fivethirtyeight/data/tree/master/pollster-ratings
races = data.load("e94_races")
polls = data.load("e94_polls")
reports = data.load("e94_reports")
ie = data.load("e94_ie")
rematch = data.load("e94_rematch")
pollerr = data.load("e94_pollerr")
build_log = json.load(open(data.path("e94_build_log")))
for c in ("cvg_start", "cvg_end", "receipt"):
reports[c] = pd.to_datetime(reports[c])
for c in ("date", "first_receipt"):
ie[c] = pd.to_datetime(ie[c])
polls["end"] = pd.to_datetime(polls["end"])
NAT = {int(k): v for k, v in build_log["national_house_dem2"].items()}
NAT.update({int(k): v for k, v in build_log["national_house_dem2_medsl"].items() if int(k) < 2016})
MARGIN = {y: 100 * (2 * v - 1) for y, v in NAT.items()} # national House margin, D minus R
MARGIN_2024 = 100 * (2 * build_log["national_house_2024_wikipedia"]["dem2"] - 1)
def money_signal(df):
"""log((D receipts + $50k) / (R receipts + $50k)): eve totals where reports exist, else cycle totals.
A candidate with an FEC ID and nothing reported counts as $0. A 2026 nominee with no FEC ID found is
missing (NaN), not $0: the model imputes the signal (see prepare)."""
eve = df.cycle.isin([2018, 2022, 2026]).to_numpy()
d = np.where(eve, df.d_rec_eve, df.d_rec_final).astype(float)
r = np.where(eve, df.r_rec_eve, df.r_rec_final).astype(float)
miss = (df.cycle == 2026).to_numpy() & (np.isnan(d) | np.isnan(r))
out = np.clip(np.log((np.nan_to_num(d) + 5e4) / (np.nan_to_num(r) + 5e4)), -5, 5)
return np.where(miss, np.nan, out)
R = races.copy()
R["inc"] = np.where(R.d_inc, 1.0, np.where(R.r_inc, -1.0, 0.0))
R["money"] = money_signal(R)
R["m"] = 100 * (2 * R.dem2 - 1) # D minus R margin, points
R["leanx"] = R.lean / 10
R["modelled"] = R.seat & R.contested & ~R.split
print(R.groupby(["cycle", "office"]).agg(races=("race", "size"), contested=("contested", "sum"),
modelled=("modelled", "sum")).T.to_string())
cycle 2016 2018 2020 2022 2026 office H S H S H S H S H S races 435 34 435 35 435 35 435 36 435 35 contested 377 33 393 34 407 35 404 35 416 35 modelled 371 31 391 33 403 33 403 33 416 35
A race is modelled when it counts toward the next Congress, has both a Democrat (or an independent on the Democratic side) and a Republican on the ballot, and is not a jungle ballot. That leaves 424 races in 2018, 436 in 2022 and 451 in 2026. The others are settled before any vote: a district with only one party on the ballot (California's top-two system often produces two Democrats) is certain, and the few jungle-style ballots (Louisiana's, Mississippi's 2018 Senate special and a handful of others) are called by their lean, which gets every one of them right in 2018 and 2022.
Lean is the race's partisan lean relative to the nation, on the margin scale: FiveThirtyEight's for 2016-2022, twice Cook's Partisan Voting Index for 2026. The builder's checks of that scale and of the FEC IDs are printed below.
chk = build_log["checks"]
lc = chk["lean538_2022_vs_2xcook_pvi"]
print(f"2022 lean (538) against 2 x Cook PVI on {lc['n']} unchanged districts: slope {lc['slope']:.2f}, "
f"intercept {lc['intercept']:+.1f}, correlation {lc['corr']:.3f}, residual sd {lc['resid_sd']:.1f} points")
sl = chk["state_lean_proxy_vs_538_2018"]
print(f"state lean as the mean of district leans, 2018: correlation {sl['corr']:.3f} with 538's, "
f"mean absolute difference {sl['mean_abs_diff']:.1f}")
print(f"2026 nominees of contested races with an FEC ID found: D {chk['fec_id_2026_name_match_rate']['d']:.1%}, "
f"R {chk['fec_id_2026_name_match_rate']['r']:.1%}; not found: {chk['fec_id_2026_unmatched']['r'] + chk['fec_id_2026_unmatched']['d']}")
for c in (2018, 2022, 2026):
p = chk[f"ie_{c}_prank_spenders"]
print(f"IE {c}: {p['spenders']} prank spender(s) dropped, ${p['dollars'] / 1e9:,.1f}B claimed")
2022 lean (538) against 2 x Cook PVI on 368 unchanged districts: slope 0.97, intercept -0.2, correlation 0.998, residual sd 2.2 points state lean as the mean of district leans, 2018: correlation 0.998 with 538's, mean absolute difference 0.9 2026 nominees of contested races with an FEC ID found: D 100.0%, R 99.6%; not found: ['KY-03: Maria Rodriquez', 'MD-04: George McDermott'] IE 2018: 0 prank spender(s) dropped, $0.0B claimed IE 2022: 1 prank spender(s) dropped, $20.0B claimed IE 2026: 2 prank spender(s) dropped, $14.0B claimed
FEC IDs for the 2026 nominees are matched by state, office, district and name against the FEC's candidate list. A nominee without a match has unknown receipts, not zero: an earlier build set them to \$0, which pinned the money signal of 61 races at its extreme, and section 7 shows what that did to the first frozen forecast. The two nominees still unmatched get a money signal imputed inside the model, from a regression on lean, incumbency and office in the other 2026 races.
2 · What information exists, and where
Before any model, look at what is known about each race on the eve. For every modelled race we count the race polls in FiveThirtyEight's database (2018, 2022) or in the race's Wikipedia article (2026), and mark races whose polls all come from a campaign, a party or a partisan pollster.
def race_poll_table(cycle):
"""Polls per modelled race in the cycle (created or ending by the eve), and whether all are partisan."""
tg = R[(R.cycle == cycle) & R.modelled][["race", "office", "lean"]].copy()
p = polls[(polls.cycle == cycle) & polls.kind.isin(["house", "senate"])]
part = p.partisan.notna() | p.internal
g = pd.DataFrame({"n": p.groupby("race").size(), "n_ind": (~part).groupby(p.race).sum()})
tg = tg.join(g, on="race").fillna({"n": 0, "n_ind": 0})
tg["kind"] = np.where(tg.n == 0, "none", np.where(tg.n_ind == 0, "partisan only", "independent"))
return tg
rag = {c: race_poll_table(c) for c in (2018, 2022, 2026)}
rows = []
for c, t in rag.items():
nat_kind = "generic_avg" if c == 2026 else "generic"
nat = polls[(polls.cycle == c) & (polls.kind == nat_kind)]
for off in ("S", "H"):
s = t[t.office == off]
rows.append({"cycle": c, "office": off, "national polls" if True else "": len(nat),
"races": len(s), "with a poll": int((s.n > 0).sum()),
"partisan only": int((s.kind == "partisan only").sum()),
"polls": int(s.n.sum()),
"close races (|lean| < 10)": int((s.lean.abs() < 10).sum()),
"close and unpolled": int(((s.lean.abs() < 10) & (s.n == 0)).sum())})
rag_tab = pd.DataFrame(rows)
print(rag_tab.to_string(index=False))
print("2026 national rows are weekly snapshots of aggregator averages, not individual polls")
cycle office national polls races with a poll partisan only polls close races (|lean| < 10) close and unpolled 2018 S 978 33 31 1 547 12 0 2018 H 978 391 146 21 560 74 22 2022 S 1221 33 28 0 608 10 0 2022 H 1221 403 102 33 246 83 17 2026 S 308 35 24 3 543 9 1 2026 H 308 416 97 42 281 70 30 2026 national rows are weekly snapshots of aggregator averages, not individual polls
The table is the ragged picture in numbers. Nearly every Senate race has polls, often dozens: 31 of 33 in 2018 and 28 of 33 in 2022. In the House, polls reached 146 of 391 contested districts in 2018, 102 of 403 in 2022 and 97 of 416 in 2026, and many of those had only partisan polls (21, 33 and 42). Even among the close districts, with a lean within 10 points, 22 of 74 had no poll in 2018 and 30 of 70 have none in 2026. The 2026 national "polls" are 308 weekly snapshots of seven aggregators' averages (123 in the last 22 weeks, which the model uses), a different and weaker kind of information, as section 4 measures.
fig, axes = plt.subplots(2, 3, figsize=(13, 6.6), sharex=True, height_ratios=[2.2, 1])
cols = {"independent": INK, "partisan only": GOLD, "none": LIGHT}
for j, (c, t) in enumerate(rag.items()):
ax = axes[0, j]
for off, mk, sz in (("H", "o", 16), ("S", "*", 70)):
for k, col in cols.items():
s = t[(t.office == off) & (t.kind == k)]
y = np.log1p(s.n) + (rng.uniform(-0.12, 0.12, len(s)) if k == "none" else 0)
ax.scatter(s.lean, y, s=sz, marker=mk, color=col, alpha=0.8 if k != "none" else 0.6,
edgecolor="none" if off == "H" else "white", linewidth=0.4)
ax.set_yticks(np.log1p([0, 1, 3, 10, 30, 100]), ["0", "1", "3", "10", "30", "100"])
ax.set_title(f"{c}: {int((t[t.office == 'H'].n > 0).sum())} of {int((t.office == 'H').sum())} "
f"contested districts polled", fontsize=10)
ax.axvspan(-10, 10, color=LIGHT, alpha=0.25, zorder=-1)
ax.set_ylabel("race polls" if j == 0 else "")
ax2 = axes[1, j]
h = t[t.office == "H"]
bins = np.arange(-60, 61, 5)
cut = pd.cut(h.lean.clip(-59.9, 59.9), bins)
share = h.groupby(cut, observed=False).apply(lambda s: (s.n > 0).mean() if len(s) else np.nan)
part = h.groupby(cut, observed=False).apply(lambda s: (s.kind == "partisan only").mean() if len(s) else np.nan)
mids = bins[:-1] + 2.5
ax2.bar(mids, share.to_numpy(), width=4.4, color=INK, alpha=0.8, label="any poll")
ax2.bar(mids, part.to_numpy(), width=4.4, color=GOLD, label="partisan polls only")
ax2.set_ylim(0, 1)
ax2.set_ylabel("share of districts" if j == 0 else "")
axes[1, 0].legend(fontsize=8, loc="upper left")
handles = [Line2D([], [], marker="o", ls="", color=cols[k], label=k) for k in cols]
handles += [Line2D([], [], marker="*", ls="", color=INK, ms=10, label="Senate race")]
axes[0, 2].legend(handles=handles, fontsize=8, loc="upper right")
fig.supxlabel("partisan lean of the race (D minus R, points); grey band: |lean| < 10", fontsize=10)
fig.suptitle("Ragged information: race polls by partisan lean (House: dots, Senate: stars)", fontsize=11);

The figure shows where the polls go. They cluster around a lean of zero, where races are competitive, and thin out quickly beyond 20 points either way: pollsters, like donors, go where the race is close. The bottom row shows the share of districts with any poll, and the share with only partisan polls, by lean. Even in the competitive middle a third to a half of districts are unpolled, and in 2026 the partisan-only share in the middle is large. A forecast must say something about every district, so the fundamentals do most of the work in most places, and the model has to know how far to trust them.
3 · The national environment
3.1 A prior from the midterm penalty
The president's party almost always loses ground in a midterm. Measured as the change in the national House margin (D minus R share of the two-party vote, in points) from the previous election, the swing against the president's party averaged about 7 points over the midterms since 1978, with a standard deviation of about 6. Before any poll, a sensible forecast for the national margin $N$ is the previous margin plus that swing, in the direction against the president:
$$ N \sim \mathcal{N}\big(m_{\text{prev}} + s\,\bar\Delta,\ \sigma_\Delta\big),\qquad s = +1 \text{ under a Republican president}, -1 \text{ under a Democrat}, $$
with $\bar\Delta$ and $\sigma_\Delta$ computed only from midterms before the target, so the 2018 prior uses 1978-2014 and the 2026 prior uses 1978-2022. The national votes come from MEDSL before 2016 and the FEC from 2016 (Wikipedia's results table for 2024).
Two more scales come from history. Polls share an error: in some years nearly all of them miss in the same direction, and that error is not reduced by averaging. FiveThirtyEight's pollster-ratings file records the error of every late poll since 1998; the mean error of the generic and Senate polls in each cycle gives a prior sd for the shared error, and the spread of race-level means around their cycle's mean gives a prior sd for a race's own polling error.
def midterm_prior(target):
"""Previous House margin plus the mean swing against the president's party in earlier midterms."""
sw = {y: (MARGIN[y] - MARGIN[y - 2]) * (1 if PRES[y] == "R" else -1)
for y in PRES if y < target and y in MARGIN and y - 2 in MARGIN}
prev = MARGIN_2024 if target == 2026 else MARGIN[target - 2]
s = np.array(list(sw.values()))
sign = 1 if PRES[target] == "R" else -1
return {"prev": prev, "swings": sw, "mu": prev + sign * s.mean(), "sd": s.std(ddof=1),
"mean_swing": s.mean(), "n": len(s)}
def poll_error_scales(target):
"""Shared and race-level polling-error scales from FiveThirtyEight's raw polls of earlier cycles."""
pe = pollerr[pollerr.cycle < target]
gen = pe[pe.generic].groupby("cycle").err.mean()
sen = pe[~pe.generic & (pe.type_simple == "Sen-G")]
sen_cyc = sen.groupby("cycle").err.mean()
shared = float(np.sqrt(np.mean(np.r_[gen.to_numpy(), sen_cyc.to_numpy()] ** 2)))
race = float((sen.err - sen.cycle.map(sen_cyc)).std())
return {"shared": shared, "race": race, "n_cycles": len(gen) + len(sen_cyc)}
PRIOR = {c: midterm_prior(c) for c in (2018, 2022, 2026)}
PERR = {c: poll_error_scales(c) for c in (2018, 2022, 2026)}
for c in PRIOR:
p, e = PRIOR[c], PERR[c]
print(f"{c}: previous margin {p['prev']:+.1f}, mean swing against the president's party "
f"{p['mean_swing']:.1f} (sd {p['sd']:.1f}, {p['n']} midterms) -> N ~ Normal({p['mu']:+.1f}, {p['sd']:.1f}); "
f"poll error sd: shared {e['shared']:.1f}, race {e['race']:.1f}")
print("actual national margin: 2018 %+.1f, 2022 %+.1f" % (MARGIN[2018], MARGIN[2022]))
v = np.array(list(PRIOR[2026]["swings"].values()))
print(f"swings 1978-2022 range from {v.min():+.1f} to {v.max():+.1f}")
2018: previous margin -1.1, mean swing against the president's party 6.8 (sd 6.5, 10 midterms) -> N ~ Normal(+5.7, 6.5); poll error sd: shared 3.2, race 5.8 2022: previous margin +2.5, mean swing against the president's party 7.1 (sd 6.2, 11 midterms) -> N ~ Normal(-4.6, 6.2); poll error sd: shared 3.4, race 5.6 2026: previous margin -2.6, mean swing against the president's party 7.0 (sd 5.9, 12 midterms) -> N ~ Normal(+4.3, 5.9); poll error sd: shared 3.3, race 5.7 actual national margin: 2018 +8.8, 2022 -2.7 swings 1978-2022 range from -3.8 to +17.8
The three priors differ mostly in their centre. In 2018 a Republican president and a previous margin of R+1.1 give D+5.7; in 2022 a Democratic president and D+2.5 give R+4.6; in 2026 a Republican president and R+2.6 give D+4.3. All three are wide (sd 6-6.5 points), which is honest: the swing has ranged from about -4 to +18. The actual margins were D+8.8 in 2018 and R+2.7 in 2022, both well inside. The polling-error scales are steady across cycles: about 3.3 points shared, about 5.7 points for a race.
sw = PRIOR[2026]["swings"]
fig, ax = plt.subplots(figsize=(10, 3.4))
yrs = list(sw)
ax.bar(yrs, [sw[y] for y in yrs], width=2.6, color=[D_C if PRES[y] == "R" else R_C for y in yrs], alpha=0.85)
ax.axhline(np.mean(list(sw.values())), color=INK, lw=1, ls="--")
for c in (2018, 2022, 2026):
p = PRIOR[c]
sign = 1 if PRES[c] == "R" else -1
ax.errorbar(c + 1.1, sign * (p["mu"] - p["prev"]), yerr=1.64 * p["sd"], fmt="D", color=INK, mfc="white", capsize=3)
ax.axhline(0, color=MUTED, lw=0.6)
ax.set_xticks(yrs + [2026])
ax.set_ylabel("swing to the out-party\n(House margin, points)")
ax.set_title("Swing against the president's party in each midterm (blue bars: Republican president, red: Democrat)\n"
"diamonds: the prior swing for 2018, 2022 and 2026 from the midterms before each, with a 90% range",
fontsize=10);

In the figure, the diamonds sit a year to the right of their bars. The 2018 and 2022 swings (about 10 and 5 points) fell within 3 points of the long-run mean. The fundamentals-only backtest in section 4 is this prior pushed through the race model, which makes it the prior predictive check for seats.
3.2 The model
The national margin is tied to the polls through a weekly walk, as in E40 but in one dimension. Weeks are counted back from the election ($w = 0$ is election week), and the walk is anchored at the target:
$$ \nu_0 = N,\qquad \nu_w = N + \sigma_{\text{rw}} \textstyle\sum_{k=1}^{w} z_k,\qquad z_k \sim \mathcal N(0,1). $$
A generic poll $j$ in week $w_j$ by pollster $p_j$ measures the walk plus the shared error $b$ and the pollster's house effect, with its binomial standard error plus an excess noise $\tau$:
$$ y_j \sim \mathcal N\big(\nu_{w_j} + b + h_{p_j},\ \sqrt{\mathrm{se}_j^2 + \tau^2}\big),\qquad b \sim \mathcal N(0, \sigma_b),\qquad h \sim \text{ZeroSumNormal}(\sigma_h). $$
A race $i$ of office $o$ (House or Senate) in state $s$ has margin
$$ m_i = N + \gamma_o + s_{\text{int}} z_o + \beta_o^{\top} x_i + s_{\text{state}}\, u_{s} + \sigma_o \varepsilon_i, \qquad x_i = (\text{lean}/10,\ \text{incumbent},\ \text{money}), $$
where incumbent is +1 for a Democratic incumbent, -1 for a Republican and 0 for an open seat, and money is $\log\big((D + \$50\text{k})/(R + \$50\text{k})\big)$ for the receipts each side had reported by the eve. The state effect $u_s$ is shared by every House district in a state and by its Senate race: the model's correlated race error, on top of $N$, which moves every race.
The coefficients $\beta_o$, $\gamma_o$, the residual scales $\sigma_o$ and the scales of the cycle and state effects are learned from earlier cycles only: 2016 for the 2018 forecast, 2016-2020 for 2022 and 2016-2022 for 2026 (the target cycle contributes polls, never results). In those cycles $N$ is known, so the same equation is a regression with $N_c$ plugged in, fitted in the same model. A race poll $k$ of race $i$ measures
$$ y_k \sim \mathcal N\big(m_i + (\nu_{w_k} - N) + b + b^{\text{race}}_i + h_{p_k} + \delta\,\text{sponsor}_k,\ \sqrt{\mathrm{se}_k^2 + \tau_r^2}\big), $$
the race's margin, moved by the national walk in the poll's week, plus the shared error, a race-level polling error with the historical scale, the pollster's house effect and a sponsor effect $\delta$ for polls marked D (+1) or R (-1). Only polled races carry a margin in the sampler; every other race is simulated afterwards from the posterior. Seats follow by counting, per draw.
def prepare(target, mode="full"):
"""Training races (earlier cycles), target races, national and race polls for one target cycle."""
train = R[R.cycle.isin(TRAIN[target]) & R.modelled & R.lean.notna()]
tgt = R[(R.cycle == target) & R.seat].copy()
tm = tgt[tgt.modelled].reset_index(drop=True)
P = polls[polls.cycle == target].copy()
P["week"] = ((pd.Timestamp(ELEC[target]) - P.end).dt.days // 7).astype(int)
P = P[(P.week >= 0) & (P.week < 22)]
P["two"] = 100 * (2 * P.dem / (P.dem + P.rep) - 1)
nn = P.n.fillna(600).clip(200, 5000) * (P.dem + P.rep) / 100
q = P.dem / (P.dem + P.rep)
P["se"] = 200 * np.sqrt(q * (1 - q) / nn)
if mode == "fund":
P = P.iloc[0:0]
gp = P[P.kind == ("generic_avg" if target == 2026 else "generic")].copy()
if mode == "agg" and target < 2026: # a stand-in aggregator: a 14-day mean of the polls, once a week
rows = [{"week": w, "two": gp.two[(gp.week >= w) & (gp.week <= w + 1)].mean(), "pollster": "rolling mean"}
for w in range(22) if ((gp.week >= w) & (gp.week <= w + 1)).sum() >= 3]
gp = pd.DataFrame(rows)
if target == 2026 or mode == "agg":
gp["se"] = 1.0 # an assumed sd for an average of several polls
rp = P[P.kind.isin(["house", "senate"]) & P.race.isin(tm.race) & (P.week < 10)].copy()
# A missing money signal gets a prior from the same cycle's other races: a regression on lean,
# incumbency and office (all known on the eve), with its residual sd.
miss = np.flatnonzero(tm.money.isna().to_numpy())
Z = np.c_[np.ones(len(tm)), tm.leanx, tm.inc, (tm.office == "S").astype(float)]
ok = ~tm.money.isna().to_numpy()
coef, *_ = np.linalg.lstsq(Z[ok], tm.money.to_numpy()[ok], rcond=None)
miss_sd = float(np.std(tm.money.to_numpy()[ok] - Z[ok] @ coef, ddof=Z.shape[1]))
return {"target": target, "mode": mode, "train": train, "tgt": tgt, "tm": tm, "gp": gp, "rp": rp,
"miss": miss, "miss_mu": np.clip(Z[miss] @ coef, -5, 5), "miss_sd": miss_sd,
"pollsters": sorted(set(gp.pollster) | set(rp.pollster)), "polled": sorted(set(rp.race)),
"states": sorted(set(R.state)), "prior": PRIOR[target], "perr": PERR[target]}
def build_model(d, rotate=True):
train, tm, gp, rp = d["train"], d["tm"], d["gp"], d["rp"]
cycles, states, pollsters, polled = TRAIN[d["target"]], d["states"], d["pollsters"], d["polled"]
s_idx = {s: i for i, s in enumerate(states)}
isS_tr = (train.office == "S").to_numpy().astype(int)
isS = (tm.office == "S").to_numpy().astype(int)
X_tr = np.c_[train.leanx, train.inc, train.money]
X = np.c_[tm.leanx, tm.inc, np.nan_to_num(tm.money.to_numpy())]
c_tr = train.cycle.map({c: i for i, c in enumerate(cycles)}).to_numpy()
st_tr, st = train.state.map(s_idx).to_numpy(), tm.state.map(s_idx).to_numpy()
natm_tr = np.array([MARGIN[c] for c in cycles])
mu_N, sd_N, sd_b = d["prior"]["mu"], d["prior"]["sd"], d["perr"]["shared"]
mu_b = d.get("bias_mean", 0.0) # the shared error's prior mean: 0 except in section 7.1
coords = {"office": ["H", "S"], "x": ["lean", "inc", "money"], "cycle": cycles, "state": states,
"week": np.arange(22)}
if pollsters:
coords.update(pollster=pollsters)
if polled:
coords.update(polled=polled)
miss = d["miss"]
if len(miss):
coords.update(missing_race=tm.race.to_numpy()[miss])
with pm.Model(coords=coords) as m:
if len(miss): # unknown money signals, as parameters with the regression prior from prepare()
mm = pm.Normal("money_missing", d["miss_mu"], d["miss_sd"], dims="missing_race")
X = pt.set_subtensor(pt.as_tensor_variable(X)[miss, 2], mm)
# fundamentals, learned on earlier cycles where the national margin is known
beta = pm.Normal("beta", [5.0, 3.0, 2.0], [3.0, 3.0, 2.0], dims=("office", "x"))
sig = pm.HalfNormal("sig", 10.0, dims="office")
s_state = pm.HalfNormal("s_state", 4.0)
gamma = pm.Normal("gamma", 0, 3, dims="office")
s_int = pm.HalfNormal("s_int", 2.0)
z_int = pm.Normal("z_int", 0, 1, dims=("cycle", "office"))
z_st = pm.Normal("z_st", 0, 1, dims=("cycle", "state"))
mu_tr = ((natm_tr[:, None] + gamma[None, :] + s_int * z_int)[c_tr, isS_tr]
+ (beta[isS_tr] * X_tr).sum(-1) + s_state * z_st[c_tr, st_tr])
pm.Normal("m_train", mu_tr, sig[isS_tr], observed=train.m.to_numpy())
# the target cycle: national margin N and the shared polling error, seen by the polls as N + bias
if rotate:
zr = pm.Normal("z_rot", 0, 1, shape=2)
h = np.hypot(sd_N, sd_b)
N = pm.Deterministic("N", mu_N + sd_N * (sd_N / h * zr[0] - sd_b / h * zr[1]))
bias = pm.Deterministic("bias", mu_b + sd_b * (sd_b / h * zr[0] + sd_N / h * zr[1]))
else:
N = pm.Deterministic("N", mu_N + sd_N * pm.Normal("z_N", 0, 1))
bias = pm.Deterministic("bias", mu_b + sd_b * pm.Normal("z_bias", 0, 1))
s_rw = pm.HalfNormal("s_rw", 1.0)
z_rw = pm.Normal("z_rw", 0, 1, dims="week")
walk = N + s_rw * pt.concatenate([pt.zeros(1), pt.cumsum(z_rw[1:])]) # week 0 = election week
if pollsters:
house = pm.ZeroSumNormal("house", sigma=pm.HalfNormal("s_house", 2.0), dims="pollster")
pidx = {p: i for i, p in enumerate(pollsters)}
z_int_t = pm.Normal("z_int_t", 0, 1, dims="office")
icpt_t = N + gamma + s_int * z_int_t
z_st_t = pm.Normal("z_st_t", 0, 1, dims="state")
if len(gp):
s_x = pm.HalfNormal("s_x_gen", 2.0)
mu_g = walk[gp.week.to_numpy()] + bias + house[gp.pollster.map(pidx).to_numpy()]
pm.Normal("gen", mu_g, pt.sqrt(gp.se.to_numpy() ** 2 + s_x**2), observed=gp.two.to_numpy())
if polled:
pol_i = np.array([tm.index[tm.race == r][0] for r in polled])
e_pol = pm.Normal("e_pol", 0, 1, dims="polled")
m_pol = pm.Deterministic(
"m_pol", icpt_t[isS[pol_i]] + (beta[isS[pol_i]] * X[pol_i]).sum(-1)
+ s_state * z_st_t[st[pol_i]] + sig[isS[pol_i]] * e_pol, dims="polled")
b_race = pm.Normal("b_race", 0, d["perr"]["race"], dims="polled")
d_part = pm.Normal("d_part", 2.0, 2.0)
k = rp.race.map({r: i for i, r in enumerate(polled)}).to_numpy()
spons = np.where(rp.partisan.fillna("") == "D", 1.0, np.where(rp.partisan.fillna("") == "R", -1.0, 0.0))
s_xr = pm.HalfNormal("s_x_race", 3.0)
mu_r = (m_pol[k] + (walk[rp.week.to_numpy()] - N) + bias + b_race[k] + d_part * spons
+ house[rp.pollster.map(pidx).to_numpy()])
pm.Normal("racepoll", mu_r, pt.sqrt(rp.se.to_numpy() ** 2 + s_xr**2), observed=rp.two.to_numpy())
return m
def diagnostics(idata):
"""Divergences, and the worst r_hat and ESS over variables (one variable at a time: see AUTHORING)."""
rh, es = az.rhat(idata.posterior), az.ess(idata.posterior)
worst = max(rh.data_vars, key=lambda v: float(rh[v].max()))
return {"divergences": int(idata.sample_stats["diverging"].sum()),
"max r_hat": round(max(float(rh[v].max()) for v in rh.data_vars), 3), "worst": worst,
"min ESS": int(min(float(es[v].min()) for v in es.data_vars)),
"tuning": int(idata.posterior.attrs.get("tuning_steps", -1))}
def simulate(d, idata, n=2000, seed=RANDOM_SEED):
"""Draws of every modelled race's margin: polled races from the sampler, the rest simulated."""
tm = d["tm"]
post = az.extract(idata, num_samples=n, random_seed=seed)
g = np.random.default_rng(seed)
isS = (tm.office == "S").to_numpy().astype(int)
X = np.broadcast_to(np.c_[tm.leanx, tm.inc, np.nan_to_num(tm.money.to_numpy())], (n, len(tm), 3)).copy()
st = tm.state.map({s: i for i, s in enumerate(d["states"])}).to_numpy()
T = lambda v, *dims: post[v].transpose("sample", *dims).values # noqa: E731
if len(d["miss"]):
X[:, d["miss"], 2] = T("money_missing", "missing_race")
icpt = post["N"].values[:, None] + T("gamma", "office") + post["s_int"].values[:, None] * T("z_int_t", "office")
mu = (icpt[:, isS] + (T("beta", "office", "x")[:, isS, :] * X).sum(-1)
+ (T("z_st_t", "state") * post["s_state"].values[:, None])[:, st])
sim = mu + T("sig", "office")[:, isS] * g.standard_normal(mu.shape)
if d["polled"]:
pol_i = np.array([tm.index[tm.race == r][0] for r in d["polled"]])
sim[:, pol_i] = T("m_pol", "polled")
walk = post["N"].values[:, None] + post["s_rw"].values[:, None] * np.c_[
np.zeros(n), np.cumsum(T("z_rw", "week")[:, 1:], axis=1)]
return {"sim": sim, "N": post["N"].values, "bias": post["bias"].values, "walk": walk,
"beta": T("beta", "office", "x"), "sig": T("sig", "office")}
def seats(d, sim):
"""D seats per draw: modelled races won plus seats with only one side on the ballot."""
tgt, tm = d["tgt"], d["tm"]
out = {}
for off in ("H", "S"):
sel = (tm.office == off).to_numpy()
other = tgt[(tgt.office == off) & ~tgt.modelled]
if d["target"] < 2026: # one side on the ballot, or a jungle ballot called by lean
fixed = int(np.sum(np.where(other.split, other.lean > 0, other.winner == "D")))
else:
fixed = int((other.d_name.fillna("") != "").sum())
out[off] = (sim[:, sel] > 0).sum(1) + fixed
return out
def fit_cycle(target, mode="full", rotate=True, keep=False, bias_mean=0.0):
d = {**prepare(target, mode), "bias_mean": bias_mean}
t0 = time.time()
with build_model(d, rotate):
idata = pm.sample(random_seed=RANDOM_SEED, progressbar=False, target_accept=0.9)
out = {"d": d, "seconds": time.time() - t0, **diagnostics(idata), **simulate(d, idata)}
out["seats"] = seats(d, out["sim"])
if keep:
out["idata"] = idata
return out
d18 = prepare(2018)
print(f"2018: {len(d18['train'])} training races (2016), {len(d18['tm'])} modelled target races, "
f"{len(d18['gp'])} generic polls, {len(d18['rp'])} race polls in {len(d18['polled'])} races, "
f"{len(d18['pollsters'])} pollsters")
d26 = prepare(2026)
print(f"2026: {d26['gp'].pollster.nunique()} aggregators, {len(d26['gp'])} weekly snapshots in the last 22 weeks; "
f"{len(d26['rp'])} race polls in {len(d26['polled'])} races")
2018: 338 training races (2016), 424 modelled target races, 326 generic polls, 742 race polls in 162 races, 158 pollsters 2026: 7 aggregators, 123 weekly snapshots in the last 22 weeks; 534 race polls in 104 races
3.3 Failure 1: the polls cannot tell the electorate from their own error
Every poll sees $N + b$, never $N$ alone. With $N = \mu_N + \sigma_N z_N$ and $b = \sigma_b z_b$ sampled directly, the polls pin $\sigma_N z_N + \sigma_b z_b$ and leave the orthogonal direction to the priors: a long thin ridge. E40 showed the fix. Rotate the two standard normals by the angle of $(\sigma_N, \sigma_b)$, with $r = \sqrt{\sigma_N^2 + \sigma_b^2}$,
$$ z_N = \frac{\sigma_N u - \sigma_b v}{r},\qquad z_b = \frac{\sigma_b u + \sigma_N v}{r}, \qquad\text{so}\qquad \sigma_N z_N + \sigma_b z_b = r\,u, $$
and the polls see only $u$ while $v$ keeps its standard-normal prior. The model is unchanged; only the coordinates are. We fit the 2026 model both ways.
t0 = time.time()
naive26 = fit_cycle(2026, rotate=False, keep=True)
fit26 = fit_cycle(2026, keep=True)
TIMES["2026 fits"] = time.time() - t0
rows = {}
for k, f in (("N and bias sampled directly", naive26), ("rotated", fit26)):
idt = f["idata"]
es = az.ess(idt.posterior)
rows[k] = {"seconds": round(f["seconds"], 1), "divergences": f["divergences"], "max r_hat": f["max r_hat"],
"ESS of N": int(es["N"]), "ESS of bias": int(es["bias"]),
"r_hat of N": round(float(az.rhat(idt.posterior)["N"]), 3),
"mean tree depth": round(float(idt.sample_stats["depth"].mean()), 1),
"N median": round(float(np.median(f["N"])), 1), "corr(N, bias)": round(float(np.corrcoef(f["N"], f["bias"])[0, 1]), 2)}
print(pd.DataFrame(rows).T.to_string())
del naive26["idata"]
print(az.summary(fit26["idata"], var_names=["N", "bias", "s_rw", "s_x_gen"], round_to=3).to_string())
seconds divergences max r_hat ESS of N ESS of bias r_hat of N mean tree depth N median corr(N, bias)
N and bias sampled directly 22.6 0.0 1.013 270.0 304.0 1.008 6.2 8.1 -0.96
rotated 20.6 0.0 1.011 5444.0 5937.0 1.001 6.1 7.8 -0.97
mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
N 7.976 2.937 3.302 12.688 5444.689 2645.908 1.001 0.040 0.058
bias 1.061 2.907 -3.515 5.733 5937.956 2707.318 1.002 0.038 0.057
s_rw 0.433 0.126 0.261 0.651 1635.990 2074.502 1.002 0.003 0.003
s_x_gen 0.093 0.074 0.007 0.231 2923.269 1955.016 1.001 0.001 0.001Both fits finish without divergences and agree on the answer, but the direct one mixes badly along the ridge: an effective sample size of about 300 for $N$ and for the shared error against about 5,500 after rotating, and an $\hat R$ of 1.008 for $N$ against 1.001. (A pilot run with another seed reached $\hat R$ = 1.05.) Nothing warns about a ridge except the ESS, which is why the diagnostics are read for every variable. The two are almost perfectly confounded in the posterior: their correlation is -0.97, because the polls fix $N + b$ at about 9 points with an sd under a point, while each of $N$ and $b$ keeps an sd near 3. The national margin of 2026 is D+7.8, with a 90% interval of 3 to 13 points; most of that width is the shared polling error the polls cannot see. Every fit below is rotated.
fig, axes = plt.subplots(1, 2, figsize=(10, 4.4))
p26 = fit26["d"]["prior"]
zN = (naive26["N"] - p26["mu"]) / p26["sd"]
zB = naive26["bias"] / fit26["d"]["perr"]["shared"]
s_ = fit26["N"] + fit26["bias"]
axes[0].scatter(zN, zB, s=3, color=D_C, alpha=0.3)
axes[0].set_xlabel("z of the national margin N")
axes[0].set_ylabel("z of the shared polling error")
axes[0].set_title(f"sampled directly: a ridge (the polls fix N + error\nat {np.median(s_):.1f} ± {s_.std():.1f} points)",
fontsize=10)
zr = az.extract(fit26["idata"], var_names=["z_rot"]).values
axes[1].scatter(zr[0], zr[1], s=3, color=GOLD, alpha=0.3)
axes[1].set_xlabel("u: the direction the polls see")
axes[1].set_ylabel("v: left to its prior")
axes[1].set_title("rotated: one tight direction, one standard normal", fontsize=10)
for ax in axes:
ax.set_xlim(-4, 4)
ax.set_ylim(-4, 4)
ax.set_aspect("equal");

3.4 Does the model reproduce the polls?
A posterior predictive check in poll space asks whether the polls look like draws from the fitted model. It cannot see the shared error, because a shift of all polls together is absorbed by $b$: only results can test that (E40's lesson), which is what the backtests are for.
with build_model(fit26["d"]):
ppc26 = pm.sample_posterior_predictive(fit26["idata"].isel(draw=slice(None, None, 4)), var_names=["gen", "racepoll"],
random_seed=RANDOM_SEED, progressbar=False)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.2))
for ax, (v, obs, lab) in zip(axes, (("gen", fit26["d"]["gp"].two, "aggregator snapshots"),
("racepoll", fit26["d"]["rp"].two, "race polls"))):
rep = ppc26.posterior_predictive[v].stack(s=("chain", "draw")).transpose("s", ...).values
pit = (rep < obs.to_numpy()).mean(0)
ax.hist(pit, bins=20, range=(0, 1), color=D_C, alpha=0.7, density=True)
ax.axhline(1, color=INK, lw=0.7, ls=":")
lo, hi = np.quantile(rep, [0.05, 0.95], 0)
cov = np.mean((obs.to_numpy() >= lo) & (obs.to_numpy() <= hi))
ax.set_title(f"2026 {lab}\n{len(obs)} values, {cov:.0%} inside their 90% interval", fontsize=10)
ax.set_xlabel("PIT of each observed value")
print(f"{lab}: 90% predictive interval covers {cov:.1%} of {len(obs)}")
axes[0].set_ylabel("density")
fig.tight_layout()
del ppc26;
aggregator snapshots: 90% predictive interval covers 99.2% of 123 race polls: 90% predictive interval covers 97.0% of 534

Almost all 2026 aggregator snapshots (99%) and 97% of race polls fall inside their 90% predictive intervals, and both PIT histograms are humped: the predictive spread is too wide in poll space. For the snapshots that is by construction: each is given an sd of 1 point, but an average moves slowly from week to week, and seven aggregators averaging largely the same polls count as seven measurements. That matters little next to the shared error, but it is the price of having no individual national polls in 2026. For race polls the noise allowed is generous, which makes the forecast slightly cautious.
4 · The 2018 and 2022 backtests
A forecast's honesty is tested against results it did not see. 2018 and 2022 are the two midterms with full FEC report files here, and in each we fit the model exactly as on the eve: polls entered by then, money reported by then, coefficients from earlier cycles. Three variants of the same model measure what each kind of information is worth:
- full: everything above;
- aggregate only: the national polls replaced by one 14-day rolling mean per week with an assumed sd of 1 point, which is the kind of national information 2026 has;
- fundamentals only: no polls at all, so $N$ comes from the midterm prior alone.
Six fits, each of 4 chains with nutpie's default 400 warm-up steps and 1,000 draws.
t0 = time.time()
FITS = {}
for c in (2018, 2022):
for mode in ("full", "agg", "fund"):
FITS[c, mode] = fit_cycle(c, mode)
TIMES["backtest fits"] = time.time() - t0
print(pd.DataFrame({f"{c} {m}": {k: f[k] for k in ("seconds", "divergences", "max r_hat", "worst", "min ESS")}
for (c, m), f in FITS.items()}).T.to_string())
seconds divergences max r_hat worst min ESS 2018 full 12.205168 0 1.006 b_race 790 2018 agg 12.001064 0 1.008 house 808 2018 fund 3.488309 1 1.009 z_st 409 2022 full 17.440345 0 1.008 s_int 709 2022 agg 13.997633 0 1.007 b_race 769 2022 fund 4.622341 0 1.026 gamma 255
All six fits have no or almost no divergences (one in the 2018 fundamentals-only fit) and an $\hat R$ of at most 1.01, except the 2022 fundamentals-only fit, whose cycle intercept reaches 1.03: acceptable for a baseline, not for a headline. A fourth forecast needs no sampler. Polls only is what a poll-watcher would do: the average of a race's polls from the last three weeks where they exist, the generic-poll average of the last 14 days plus the race's lean elsewhere, with the historical race-level and shared polling errors and, for unpolled races, the residual spread of earlier cycles.
def polls_only(target, n=2000, seed=RANDOM_SEED):
"""A poll average where a race has polls, the generic average plus lean elsewhere; errors from history."""
g = np.random.default_rng(seed)
d = prepare(target)
tm, P = d["tm"], polls[polls.cycle == target]
elec = pd.Timestamp(ELEC[target])
gen = P[(P.kind == "generic") & (P.end >= elec - pd.Timedelta(days=14))]
gen_avg = float((100 * (2 * gen.dem / (gen.dem + gen.rep) - 1)).mean())
rp = d["rp"]
rp = rp[rp.week < 3].groupby("race").two.agg(["mean", "size"]).reindex(tm.race)
resid = d["train"].assign(r=lambda t: t.m - t.cycle.map(MARGIN) - t.lean).groupby("office").r.std()
shared = g.normal(0, d["perr"]["shared"], (n, 1))
has = rp["size"].notna().to_numpy()
base = np.where(has, rp["mean"].to_numpy(), gen_avg + tm.lean.to_numpy())
sd = np.where(has, d["perr"]["race"], tm.office.map(resid).to_numpy())
sim = base + shared + sd * g.standard_normal((n, len(tm)))
return {"d": d, "sim": sim, "seats": seats(d, sim), "N": gen_avg + shared[:, 0], "gen_avg": gen_avg,
"n_polled": int(has.sum())}
for c in (2018, 2022):
FITS[c, "polls"] = polls_only(c)
print(f"{c}: polls-only uses the 14-day generic average {FITS[c, 'polls']['gen_avg']:+.1f} and recent polls "
f"in {FITS[c, 'polls']['n_polled']} races")
2018: polls-only uses the 14-day generic average +7.8 and recent polls in 121 races 2022: polls-only uses the 14-day generic average -0.7 and recent polls in 59 races
The polls-only generic average was D+7.8 on the eve of 2018 (the result was D+8.8) and R+0.7 in 2022 (the result was R+2.7): in 2022 the polls leaned Democratic by 2 points, that year's shared error. Recent race polls exist in 121 races in 2018 and only 59 in 2022.
We score each forecast race by race with predictive draws of the margin: the coverage of its 50%, 80% and 90% intervals, the PIT (where the result falls in its predictive distribution), the Brier score and the log score of the predicted winner, the median absolute error and the number of wrong calls. And we compare seat intervals with the seats won.
def race_scores(sim, y):
"""Scores of predictive draws sim (draws x races) against outcomes y (D margin, points)."""
S = sim.shape[0]
pD = (sim > 0).mean(0)
win = y > 0
p_true = np.where(win, ((sim > 0).sum(0) + 0.5) / (S + 1), ((sim <= 0).sum(0) + 0.5) / (S + 1))
out = {f"cover {int(100 * p)}%": float(np.mean((y >= np.quantile(sim, (1 - p) / 2, 0))
& (y <= np.quantile(sim, (1 + p) / 2, 0))))
for p in (0.5, 0.8, 0.9)}
out.update({"Brier": float(np.mean((pD - win) ** 2)), "log score": float(np.mean(np.log(p_true))),
"MAE": float(np.mean(np.abs(np.median(sim, 0) - y))), "wrong calls": int(np.sum((pD > 0.5) != win))})
return out, (sim < y).mean(0), pD
LABEL = {"full": "full model", "agg": "aggregate only", "fund": "fundamentals only", "polls": "polls only"}
score_rows, PIT, PD = [], {}, {}
for (c, mode), f in FITS.items():
tm = f["d"]["tm"]
y = tm.m.to_numpy()
sc, PIT[c, mode], PD[c, mode] = race_scores(f["sim"], y)
act = {off: int((f["d"]["tgt"][f["d"]["tgt"].office == off].winner == "D").sum()) for off in ("H", "S")}
row = {"cycle": c, "model": LABEL[mode]}
for off in ("H", "S"):
q = np.quantile(f["seats"][off], [0.05, 0.5, 0.95])
row[f"{off} seats"] = f"{q[1]:.0f} [{q[0]:.0f}, {q[2]:.0f}]"
row[f"{off} actual"] = act[off]
row["P(D House)"] = round(float((f["seats"]["H"] >= 218).mean()), 2)
qn = np.quantile(f["N"], [0.05, 0.5, 0.95])
row["national"] = f"{qn[1]:+.1f} [{qn[0]:+.1f}, {qn[2]:+.1f}]"
row["actual N"] = round(MARGIN[c], 1)
row.update({k: round(v, 3) if isinstance(v, float) else v for k, v in sc.items()})
score_rows.append(row)
scores = pd.DataFrame(score_rows).sort_values(["cycle", "model"], key=lambda s: s.map(
{"full model": 0, "aggregate only": 1, "polls only": 2, "fundamentals only": 3}) if s.name == "model" else s)
print(scores.drop(columns=["cover 50%", "cover 80%", "cover 90%", "Brier", "log score", "MAE", "wrong calls"]).to_string(index=False))
print()
print(scores[["cycle", "model", "cover 50%", "cover 80%", "cover 90%", "Brier", "log score", "MAE", "wrong calls"]].to_string(index=False))
cycle model H seats H actual S seats S actual P(D House) national actual N 2018 full model 222 [203, 244] 235 24 [20, 28] 24 0.65 +8.0 [+3.3, +12.6] 8.8 2018 aggregate only 226 [207, 250] 235 25 [21, 28] 24 0.75 +6.4 [+1.5, +11.5] 8.8 2018 polls only 236 [207, 265] 235 24 [20, 28] 24 0.84 +7.9 [+2.6, +12.8] 8.8 2018 fundamentals only 225 [193, 274] 235 23 [17, 29] 24 0.63 +5.7 [-4.6, +16.8] 8.8 2022 full model 204 [184, 222] 213 14 [11, 17] 15 0.12 -2.0 [-6.8, +3.0] -2.7 2022 aggregate only 201 [182, 220] 213 14 [10, 17] 15 0.08 -1.1 [-6.7, +4.2] -2.7 2022 polls only 208 [182, 232] 213 13 [10, 18] 15 0.27 -0.5 [-6.1, +4.6] -2.7 2022 fundamentals only 196 [156, 229] 213 12 [7, 17] 15 0.15 -4.5 [-14.9, +5.5] -2.7 cycle model cover 50% cover 80% cover 90% Brier log score MAE wrong calls 2018 full model 0.571 0.854 0.934 0.042 -0.133 4.641 26 2018 aggregate only 0.547 0.851 0.939 0.040 -0.124 4.605 24 2018 polls only 0.715 0.920 0.950 0.043 -0.153 5.318 22 2018 fundamentals only 0.700 0.941 0.974 0.049 -0.155 5.143 30 2022 full model 0.511 0.833 0.924 0.031 -0.101 5.216 21 2022 aggregate only 0.507 0.817 0.929 0.033 -0.107 5.343 25 2022 polls only 0.656 0.906 0.968 0.045 -0.143 5.329 25 2022 fundamentals only 0.500 0.839 0.959 0.044 -0.137 7.051 29
The full model's 90% seat and national intervals contain the result in both cycles, and its 90% race intervals cover 93% and 92% of races, close to nominal. It gave the Democrats a 65% chance of the House in 2018, which they won, and 12% in 2022, which they lost.
The comparison is the lesson. Without any poll the forecast is right on average but twice as uncertain about seats and makes more wrong calls. Polls only gets the 2018 House almost exactly, but its intervals are too wide and its log score is the worst of the four in 2022: where a district has no poll, lean alone is a blunt instrument. Aggregate only loses nothing on races, but its national median was more than 2 points short in 2018. Two cycles are two draws of the shared polling error, so differences of a few hundredths in Brier score are noise; the gaps to the fundamentals-only forecast are not.
fig, axes = plt.subplots(1, 2, figsize=(13, 5.2), sharey=True)
for ax, c in zip(axes, (2018, 2022)):
f = FITS[c, "full"]
tm = f["d"]["tm"]
y = tm.m.to_numpy()
med = np.median(f["sim"], 0)
lo, hi = np.quantile(f["sim"], [0.05, 0.95], 0)
pol = tm.race.isin(f["d"]["polled"]).to_numpy()
for sel, col, lab in ((~pol, MUTED, "no race poll"), (pol, D_C, "polled race")):
ax.errorbar(med[sel], y[sel], xerr=[med[sel] - lo[sel], hi[sel] - med[sel]], fmt="o", ms=2.5,
color=col, elinewidth=0.5, alpha=0.6, label=lab)
wrong = (med > 0) != (y > 0)
ax.scatter(med[wrong], y[wrong], s=40, facecolor="none", edgecolor=R_C, lw=1, label="wrong call", zorder=5)
print(f"{c}: {int(wrong.sum())} wrong calls, {int((wrong & pol).sum())} in polled races, "
f"{int((wrong & (y > 0)).sum())} won by a Democrat forecast to lose")
ax.plot([-80, 80], [-80, 80], color=INK, lw=0.8)
ax.axhline(0, color=MUTED, lw=0.5)
ax.axvline(0, color=MUTED, lw=0.5)
ax.set_xlim(-60, 60)
ax.set_ylim(-60, 60)
ax.set_xlabel("predicted margin on the eve, median and 90% interval")
ax.set_title(f"{c}: {int(wrong.sum())} wrong calls of {len(y)} contested races", fontsize=10)
axes[0].set_ylabel("actual margin (D minus R, points)")
axes[0].legend(fontsize=8, loc="upper left");
2018: 26 wrong calls, 26 in polled races, 21 won by a Democrat forecast to lose 2022: 21 wrong calls, 15 in polled races, 15 won by a Democrat forecast to lose

Most wrong calls sit near zero on both axes and in polled races, because close races get polled. In both years most of them (21 of 26, and 15 of 21) are Democrats who won races the forecast expected them to lose: a shared error, seen race by race.
fig, axes = plt.subplots(1, 3, figsize=(13, 3.8))
modes = ("full", "agg", "polls", "fund")
mcol = {"full": D_C, "agg": "#5aa0d8", "polls": GOLD, "fund": MUTED}
for ax, c in zip(axes[:2], (2018, 2022)):
for k, mode in enumerate(modes):
h, e = np.histogram(PIT[c, mode], bins=10, range=(0, 1))
ax.step(e, np.r_[h, h[-1]] / h.sum() * 10, where="post", color=mcol[mode], lw=1.6 if mode == "full" else 1,
label=LABEL[mode])
ax.axhline(1, color=INK, lw=0.6, ls=":")
ax.set_title(f"{c}: PIT of the actual margins", fontsize=10)
ax.set_xlabel("PIT")
axes[0].set_ylabel("density")
axes[0].legend(fontsize=7)
ax = axes[2]
bins = np.array([0, 0.05, 0.2, 0.35, 0.5, 0.65, 0.8, 0.95, 1.0001])
for mode in modes:
p = np.r_[PD[2018, mode], PD[2022, mode]]
w = np.r_[FITS[2018, mode]["d"]["tm"].m > 0, FITS[2022, mode]["d"]["tm"].m > 0]
b = np.digitize(p, bins) - 1
xs = [p[b == i].mean() for i in range(len(bins) - 1) if (b == i).sum() >= 5]
ys = [w[b == i].mean() for i in range(len(bins) - 1) if (b == i).sum() >= 5]
ax.plot(xs, ys, "o-", color=mcol[mode], ms=4, lw=1.6 if mode == "full" else 1)
if mode in ("full", "polls"):
print(LABEL[mode], "reliability (mean forecast, share won, races):",
[(round(a_, 2), round(b_, 2), int((b == i).sum())) for i, a_, b_ in
zip([i for i in range(len(bins) - 1) if (b == i).sum() >= 5], xs, ys)])
ax.plot([0, 1], [0, 1], color=INK, lw=0.6, ls=":")
ax.set_xlabel("forecast P(D wins)")
ax.set_ylabel("share won by D")
ax.set_title("reliability, 2018 and 2022 pooled", fontsize=10);
full model reliability (mean forecast, share won, races): [(np.float64(0.0), np.float64(0.01), 364), (np.float64(0.1), np.float64(0.17), 41), (np.float64(0.27), np.float64(0.36), 22), (np.float64(0.44), np.float64(0.63), 27), (np.float64(0.57), np.float64(0.7), 23), (np.float64(0.73), np.float64(0.82), 22), (np.float64(0.89), np.float64(1.0), 28), (np.float64(1.0), np.float64(1.0), 333)] polls only reliability (mean forecast, share won, races): [(np.float64(0.01), np.float64(0.01), 277), (np.float64(0.11), np.float64(0.05), 81), (np.float64(0.26), np.float64(0.16), 43), (np.float64(0.44), np.float64(0.41), 37), (np.float64(0.57), np.float64(0.74), 34), (np.float64(0.73), np.float64(0.83), 41), (np.float64(0.9), np.float64(0.96), 69), (np.float64(0.99), np.float64(1.0), 278)]

In 2018 the full model's PIT is nearly flat; in 2022 it rises to the right, because results came in more Democratic than forecast across many races. The fundamentals-only PIT is humped: its intervals were wider than needed. In the reliability diagram, which pools both cycles, Democrats won 36% of the 22 races the full model gave them about a 27% chance and 63% of the 27 given about 44%: across the middle they won more often than forecast, the same lean as the PITs.
fig, axes = plt.subplots(2, 2, figsize=(13, 5.6))
for j, c in enumerate((2018, 2022)):
for i, off in enumerate(("H", "S")):
ax = axes[i, j]
act = int((FITS[c, "full"]["d"]["tgt"].query("office == @off").winner == "D").sum())
for mode in modes:
s = FITS[c, mode]["seats"][off]
w_ = 3 if off == "H" else 1 # House seats binned in threes for display
e = np.arange(s.min() - 0.5, s.max() + w_, w_)
v, _ = np.histogram(s, e)
ax.stairs(v / len(s) / w_, e, color=mcol[mode], lw=2 if mode == "full" else 1, label=LABEL[mode],
fill=False)
ax.axvline(act, color=R_C, lw=2)
ax.text(act, ax.get_ylim()[1] * 0.92, f" actual {act}", color=R_C, fontsize=9)
if off == "H":
ax.axvline(217.5, color=INK, lw=0.6, ls=":")
ax.set_title(f"{c} {'House' if off == 'H' else 'Senate seats up'}: D seats", fontsize=10)
axes[0, 0].legend(fontsize=7)
for ax in axes[:, 0]:
ax.set_ylabel("probability");

In both years the full model's House distribution sits a few seats below the result, with the result inside its central region; the fundamentals-only distributions are far wider.
def coef_frame(f, label):
b = f["beta"]
rows = []
for i, off in enumerate(("H", "S")):
for j, x in enumerate(("lean", "inc", "money")):
q = np.quantile(b[:, i, j], [0.05, 0.5, 0.95])
rows.append({"train": label, "office": off, "x": x, "lo": q[0], "med": q[1], "hi": q[2]})
return rows
cf = pd.DataFrame(coef_frame(FITS[2018, "full"], "2016") + coef_frame(FITS[2022, "full"], "2016-20")
+ coef_frame(fit26, "2016-22"))
fig, axes = plt.subplots(1, 3, figsize=(12, 3.2))
xlab = {"lean": "per 10 points of lean", "inc": "incumbent (+1 D, -1 R)", "money": "per unit of log receipts ratio"}
for ax, x in zip(axes, ("lean", "inc", "money")):
s = cf[cf.x == x].reset_index(drop=True)
for k, r in s.iterrows():
col = D_C if r.office == "H" else GOLD
ax.errorbar(r.med, k, xerr=[[r.med - r.lo], [r.hi - r.med]], fmt="o", color=col, capsize=2)
ax.set_yticks(range(len(s)), [f"{r.office} {r.train}" for _, r in s.iterrows()], fontsize=8)
ax.set_title(xlab[x], fontsize=10)
ax.axvline(0, color=MUTED, lw=0.5)
axes[0].invert_yaxis()
for ax in axes[1:]:
ax.invert_yaxis()
fig.suptitle("Fundamentals coefficients (points of margin; 90% intervals), by office and training cycles", fontsize=10)
print(cf.assign(est=lambda t: t.med.round(2).astype(str) + " [" + t.lo.round(2).astype(str) + ", "
+ t.hi.round(2).astype(str) + "]").pivot(index=["office", "x"], columns="train", values="est").to_string());
train 2016 2016-20 2016-22
office x
H inc 5.6 [4.56, 6.71] 3.6 [3.04, 4.14] 3.28 [2.84, 3.75]
lean 8.04 [7.73, 8.35] 8.34 [8.17, 8.51] 8.52 [8.37, 8.66]
money 2.92 [2.47, 3.36] 2.72 [2.48, 2.96] 2.41 [2.21, 2.61]
S inc 2.48 [0.21, 4.76] 2.87 [1.45, 4.4] 2.58 [1.18, 3.9]
lean 7.01 [5.79, 8.27] 7.84 [7.1, 8.62] 7.85 [7.16, 8.54]
money 4.56 [3.47, 5.64] 2.8 [2.12, 3.48] 2.58 [2.01, 3.17]
The fundamentals are stable across training sets. Ten points of lean move a House margin by about 8 points; incumbency is worth about 3-4 points in the House and 2.5 in the Senate. The money coefficient is about 2.4-2.9 points of House margin per unit of the log receipts ratio: a Democrat who has raised $e \approx 2.7$ times as much leans 2-3 points further than lean and incumbency predict. That is a forecast signal, not an effect of spending: donors give to candidates they expect to do well.
fig, axes = plt.subplots(1, 3, figsize=(13, 3.8), sharey=True)
for ax, (c, f) in zip(axes, ((2018, FITS[2018, "full"]), (2022, FITS[2022, "full"]), (2026, fit26))):
elec = pd.Timestamp(ELEC[c])
wk = elec - pd.to_timedelta(7 * np.arange(22), "D")
seen = f["walk"] + f["bias"][:, None]
for arr, col, lab in ((seen, MUTED, "what the polls measure (walk + shared error)"), (f["walk"], D_C, "the walk itself")):
lo, mid, hi = np.quantile(arr, [0.05, 0.5, 0.95], 0)
ax.fill_between(wk, lo, hi, color=col, alpha=0.25)
ax.plot(wk, mid, color=col, lw=1.5, label=lab)
gp = f["d"]["gp"]
P = polls[(polls.cycle == c) & (polls.kind == ("generic_avg" if c == 2026 else "generic"))]
P = P[P.end >= elec - pd.Timedelta(days=7 * 22)]
ax.scatter(P.end, 100 * (2 * P.dem / (P.dem + P.rep) - 1), s=6, color=INK, alpha=0.35,
label="aggregator snapshots" if c == 2026 else "generic polls")
if c < 2026:
ax.scatter([elec], [MARGIN[c]], marker="*", s=160, color=R_C, zorder=6, label="result")
ax.set_title(f"{c}", fontsize=10)
ax.tick_params(axis="x", labelsize=8, rotation=30)
axes[0].set_ylabel("national margin, D - R")
h_, l_ = axes[0].get_legend_handles_labels()
fig.legend(h_ + axes[2].get_legend_handles_labels()[0][-1:], l_ + ["aggregator snapshots (2026)"], fontsize=8,
loc="lower center", ncol=5, bbox_to_anchor=(0.5, -0.07))
fig.suptitle("The weekly national walk: 90% bands for the walk and for what the polls measure", fontsize=10);

What the polls measure (walk plus shared error) is a narrow band; the walk itself is wider, because the shared error is unknown. In 2022 the result fell below what the polls measured but inside the walk's band: the model had allowed for the polls leaning Democratic. The 2026 band is smooth because its inputs are averages, and ends near D+8.
5 · Money and votes
Does money win elections? The forecast coefficient above is not the answer. The naive way to ask is to regress an incumbent's vote share on what each candidate spent. Jacobson (1978, 1990) did this and found a paradox: the more an incumbent spends, the worse the incumbent does. We redo it on the 663 contested House races with an incumbent in 2018 and 2022, with spending as cycle totals from the FEC (log10, so a coefficient is per tenfold).
x = R[R.cycle.isin([2018, 2022]) & (R.office == "H") & R.modelled & (R.d_inc | R.r_inc)].copy()
x["inc_share"] = 100 * np.where(x.d_inc, x.dem2, 1 - x.dem2)
dsp, rsp = x.d_disb_final.fillna(0).to_numpy(), x.r_disb_final.fillna(0).to_numpy()
x["inc_sp"] = np.log10(np.where(x.d_inc, dsp, rsp).clip(1e4)) # $10k floor: some challengers spend ~nothing
x["ch_sp"] = np.log10(np.where(x.d_inc, rsp, dsp).clip(1e4))
x["lean_inc"] = np.where(x.d_inc, x.lean, -x.lean)
def ols(cols, y):
A = np.c_[np.ones(len(y)), cols]
b, *_ = np.linalg.lstsq(A, y, rcond=None)
res = y - A @ b
se = np.sqrt(np.diag(np.linalg.inv(A.T @ A)) * res.var(ddof=A.shape[1]))
return b, se
b0, se0 = ols(np.c_[x.inc_sp, x.ch_sp], x.inc_share.to_numpy())
b1, se1 = ols(np.c_[x.inc_sp, x.ch_sp, x.lean_inc, x.cycle == 2022], x.inc_share.to_numpy())
print(f"{len(x)} contested House races with an incumbent, 2018 and 2022 (incumbent's two-party share, points)")
print(f"naive: incumbent spending {b0[1]:+.1f} ± {se0[1]:.1f} per tenfold, challenger {b0[2]:+.1f} ± {se0[2]:.1f}")
print(f"+ lean and cycle: incumbent spending {b1[1]:+.1f} ± {se1[1]:.1f} per tenfold, challenger {b1[2]:+.1f} ± {se1[2]:.1f}, "
f"lean {b1[3]:+.2f} per point")
print(f"correlation of log incumbent spending with |incumbent share - 50|: "
f"{np.corrcoef(x.inc_sp, np.abs(x.inc_share - 50))[0, 1]:+.2f}")
663 contested House races with an incumbent, 2018 and 2022 (incumbent's two-party share, points) naive: incumbent spending -0.7 ± 0.7 per tenfold, challenger -7.6 ± 0.3 + lean and cycle: incumbent spending +1.0 ± 0.4 per tenfold, challenger -3.6 ± 0.2, lean +0.37 per point correlation of log incumbent spending with |incumbent share - 50|: -0.30
Failure 2, a regression that answers the wrong question. Naively, a tenfold increase in the incumbent's spending goes with -0.7 points of share, and in the challenger's with -7.6. Adding the district's lean and a cycle term turns the incumbent's coefficient positive (+1.0) and halves the challenger's (-3.6), and the lean coefficient (+0.37 points of share per point of lean) shows how much of the original pattern was district. The reason is reverse causation: incumbents spend when they are in danger. The correlation of log incumbent spending with the incumbent's distance from 50% is -0.30. Adding lean does not solve the problem, because danger has causes the data do not record (a scandal, a strong challenger, a bad local economy), and those raise both spending and the chance of losing.
fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))
ax = axes[0]
col = np.where(x.d_inc, D_C, R_C)
ax.scatter(10 ** x.inc_sp / 1e6, x.inc_share, s=9, c=col, alpha=0.55)
xs = np.linspace(x.inc_sp.min(), x.inc_sp.max(), 50)
ax.plot(10 ** xs / 1e6, b0[0] + b0[1] * xs + b0[2] * x.ch_sp.mean(), color=INK, lw=1.5)
ax.set_xscale("log")
ax.set_xlabel("incumbent's spending over the cycle ($M, log scale)")
ax.set_ylabel("incumbent's two-party share (%)")
ax.set_title("Jacobson's paradox: the incumbents who spend most do worst", fontsize=10)
ax = axes[1]
sc = ax.scatter(x.lean_inc, 10 ** x.inc_sp / 1e6, s=9, c=x.inc_share, cmap="RdYlGn", vmin=40, vmax=80, alpha=0.8)
ax.set_yscale("log")
ax.set_xlabel("lean toward the incumbent's party (points)")
ax.set_ylabel("incumbent's spending ($M)")
ax.set_title("money follows close races", fontsize=10)
fig.colorbar(sc, ax=ax, label="incumbent's share (%)");

In the left panel, the most expensive incumbents are the ones with the lowest shares; in the right, money piles up where the lean is small, and spending falls as the seat gets safer. Money follows close races.
5.1 Rematches: the same two candidates, twice
Levitt (1994) used a natural design: the same two candidates meeting in the same district in two elections. Whatever is fixed about the pair and the place, the candidates' quality, their name recognition, the district's habits, cancels in the change from one meeting to the next. Write the change in the Democratic share (in points) between meetings $\Delta v_j$ against the changes in log spending of each side, with a national swing per cycle $a_c$ (a wave year moves every rematch) and slopes that vary by cycle around a common mean:
$$ \Delta v_j \sim t_\nu\big(a_{c(j)} + \beta^D_{c(j)} \Delta \log D_j + \beta^R_{c(j)} \Delta \log R_j,\ \sigma\big), \qquad \beta^{D}_{c} = \mu_D + \tau_D z^D_c,\ \ \beta^{R}_{c} = \mu_R + \tau_R z^R_c. $$
The Student-t allows for a rematch where something odd happened between meetings. The builder found 232 usable rematches with spending on both sides in both meetings, in 2006-2020, leaving out cycles across a redistricting (2012, 2022) and court-ordered redraws. A pilot with centred slopes gave 33 divergences; with seven cycles and few rematches per cycle the slopes are weakly identified, which is where the non-centred form above samples cleanly.
rm = rematch[(rematch.d_disb > 0) & (rematch.r_disb > 0) & (rematch.d_disb_prev > 0) & (rematch.r_disb_prev > 0)].copy()
rm["dv"] = 100 * (rm.dem2 - rm.dem2_prev)
rm["dd"] = np.log(rm.d_disb / rm.d_disb_prev)
rm["dr"] = np.log(rm.r_disb / rm.r_disb_prev)
rcyc = sorted(rm.cycle.unique())
ci = rm.cycle.map({c: i for i, c in enumerate(rcyc)}).to_numpy()
print(f"{len(rm)} rematches with spending on both sides in both meetings:", rm.groupby("cycle").size().to_dict())
bf, sef = ols(np.c_[rm.dd, rm.dr], rm.dv.to_numpy())
print(f"first differences, OLS: D spending {bf[1]:+.2f} ± {sef[1]:.2f}, R spending {bf[2]:+.2f} ± {sef[2]:.2f} "
"points of D share per e-fold")
with pm.Model(coords={"cycle": rcyc, "side": ["D", "R"]}) as m_rm:
a = pm.Normal("swing", 0, 5, dims="cycle")
b_mu = pm.Normal("b_mu", 0, 2, dims="side")
tau = pm.HalfNormal("tau", 1.0, dims="side")
z = pm.Normal("z", 0, 1, dims=("cycle", "side"))
b = pm.Deterministic("b", b_mu + tau * z, dims=("cycle", "side"))
sd = pm.HalfNormal("sd", 5)
nu = pm.Gamma("nu", 2, 0.1)
pm.StudentT("dv", nu=nu, mu=a[ci] + b[ci, 0] * rm.dd.to_numpy() + b[ci, 1] * rm.dr.to_numpy(), sigma=sd,
observed=rm.dv.to_numpy())
t0 = time.time()
idata_rm = pm.sample(random_seed=RANDOM_SEED, progressbar=False, target_accept=0.95)
TIMES["rematch fit"] = time.time() - t0
print(diagnostics(idata_rm))
print(az.summary(idata_rm, var_names=["b_mu", "tau", "sd", "nu"], round_to=2).to_string())
bmu = az.extract(idata_rm, var_names=["b_mu"]).values
print(f"P(D slope > 0) = {(bmu[0] > 0).mean():.2f}, P(R slope < 0) = {(bmu[1] < 0).mean():.2f}; "
f"doubling D spending: {np.log(2) * np.median(bmu[0]):+.2f} points "
f"[{np.log(2) * np.quantile(bmu[0], 0.05):+.2f}, {np.log(2) * np.quantile(bmu[0], 0.95):+.2f}]")
232 rematches with spending on both sides in both meetings: {2006: 35, 2008: 38, 2010: 36, 2014: 24, 2016: 36, 2018: 21, 2020: 42}
first differences, OLS: D spending +1.82 ± 0.39, R spending -0.64 ± 0.33 points of D share per e-fold{'divergences': 0, 'max r_hat': 1.006, 'worst': 'tau', 'min ESS': 857, 'tuning': 400}
mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
b_mu[D] 0.95 0.40 0.32 1.57 2039.04 2223.99 1.00 0.01 0.01
b_mu[R] -0.19 0.34 -0.69 0.37 2078.12 2228.90 1.00 0.01 0.01
tau[D] 0.66 0.39 0.11 1.34 857.59 717.81 1.00 0.01 0.01
tau[R] 0.49 0.36 0.05 1.14 889.35 891.81 1.01 0.01 0.01
sd 2.68 0.21 2.32 3.02 2312.43 2288.86 1.00 0.00 0.00
nu 14.76 10.71 4.75 35.01 2183.32 2533.00 1.00 0.22 0.28
P(D slope > 0) = 0.98, P(R slope < 0) = 0.75; doubling D spending: +0.67 points [+0.21, +1.10]The simple first-difference regression, without cycle swings, gives +1.8 points per e-fold of Democratic spending. Some of that is plausibly national tides: in wave years (2006, 2018) the Democrats could both raise more and win more everywhere, and a swing per cycle absorbs that. With it, the hierarchical model gives a common slope of +0.95 points of Democratic share per e-fold of Democratic spending (89% interval 0.32 to 1.57) and -0.19 for Republican spending (-0.69 to 0.37). Doubling the Democrat's spending in a rematch is worth about +0.7 points of share, with a 90% interval of 0.2 to 1.1: real, and small. The sampler had no divergences.
The bounds are worth stating. Candidate quality can still change between meetings, and a challenger who raises more the second time may be one whose prospects improved for other reasons, which would bias the slope up. Rematches are a selected sample, and spending is measured as cycle totals including primaries. Levitt read his estimates as an upper bound on a small effect; ours points the same way. The asymmetry between the parties should not be over-read: in size, the two slopes' intervals overlap between about 0.3 and 0.7.
fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))
ax = axes[0]
ratio = rm.dd - rm.dr
ax.scatter(ratio, rm.dv, s=10, color=D_C, alpha=0.5)
k_ = np.polyfit(ratio, rm.dv, 1)
xs = np.linspace(ratio.min(), ratio.max(), 20)
ax.plot(xs, np.polyval(k_, xs), color=INK, lw=1.2)
ax.axhline(0, color=MUTED, lw=0.5)
ax.axvline(0, color=MUTED, lw=0.5)
ax.set_xlabel("change in log(D spending / R spending) between the two meetings")
ax.set_ylabel("change in D share (points)")
ax.set_title(f"{len(rm)} rematches, 2006-2020", fontsize=10)
ax = axes[1]
bb = idata_rm.posterior["b"]
for s_i, (side, col) in enumerate((("D", D_C), ("R", R_C))):
for k, c in enumerate(rcyc):
v = bb.sel(side=side, cycle=c).values.ravel()
q = np.quantile(v, [0.05, 0.5, 0.95])
ax.errorbar(q[1], k + 0.18 * (s_i * 2 - 1), xerr=[[q[1] - q[0]], [q[2] - q[1]]], fmt="o", color=col, ms=3)
q = np.quantile(bmu[s_i], [0.05, 0.5, 0.95])
ax.errorbar(q[1], len(rcyc) + 0.5 + 0.18 * (s_i * 2 - 1), xerr=[[q[1] - q[0]], [q[2] - q[1]]], fmt="D", color=col,
ms=6, label=f"{side} spending")
ax.set_yticks(list(range(len(rcyc))) + [len(rcyc) + 0.5], [str(c) for c in rcyc] + ["all cycles"])
ax.axvline(0, color=MUTED, lw=0.6)
ax.set_xlabel("points of D share per e-fold of spending (90%)")
ax.legend(fontsize=8)
ax.set_title("the hierarchical rematch slopes", fontsize=10);

In the left panel the rematches spread widely around a gentle slope, and in the right the per-cycle slopes overlap each other and the common mean: no cycle stands out, so the pooling across cycles is doing what it should.
6 · Nowcasting campaign spending
On election day nobody knows how much the campaigns spent, because of the reporting calendar. A candidate's committee files a pre-general report (12G) covering the period to 14 October 2026, and a post-general report (30G) covering from there to 23 November, due on 3 December. The busiest five weeks are reported a month after the election. Outside groups making independent expenditures (IE) file 24- or 48-hour reports, so outside money is visible almost at once. The 48-hour notices of large contributions are receipts, not spending, and are in no bulk file; they are not used.
The target is defined so that it can be checked: the disbursements of each Democratic and Republican nominee's committees through the end of the 30G period, plus general-election IE dated through election day. A backtest truncates the 2018 and 2022 filings at what the FEC had received by each eve, so nothing from after the eve leaks in, and compares the nowcast with what was eventually filed.
def spend_window(cycle):
"""Nominee reports covering the cycle up to the end of the post-general period (post-election runoffs out)."""
r = reports[reports.cycle == cycle]
runoff_after = r.rpt.isin(["12R", "30R"]) & (r.cvg_end > pd.Timestamp(EVE[cycle]))
return r[~runoff_after & (r.cvg_end <= pd.Timestamp(END_30G[cycle]))]
def cumulative(r, elec, days=np.arange(-730, 21)):
"""Cumulative disbursements by day relative to election day, each report spread evenly over its period."""
s = (r.cvg_start - elec).dt.days.clip(days[0]).to_numpy()
e = (r.cvg_end - elec).dt.days.to_numpy()
rate = r.disb.to_numpy() / np.maximum(e - s + 1, 1)
out = np.zeros(len(days))
for si, ei, ra in zip(s, e, rate):
out += ra * np.clip(days - si + 1, 0, ei - si + 1)
return days, out
SPEND = {}
for c in (2018, 2022, 2026):
w = spend_window(c)
eve = pd.Timestamp(EVE[c])
SPEND[c] = {"final": w.disb.sum(), "known": w[w.receipt <= eve].disb.sum(),
"n_cands": w.cand_id.nunique(), "late_30G": w[w.rpt == "30G"].disb.sum()}
f = ie[(ie.cycle == c) & (ie.version == "final")]
SPEND[c]["ie_final"] = f.amt.sum()
SPEND[c]["ie_known"] = f.amt[f.first_receipt <= eve].sum()
sp = pd.DataFrame(SPEND).T
sp_show = (sp[["n_cands", "known", "final", "late_30G", "ie_known", "ie_final"]]
.assign(**{c: lambda t, c=c: (t[c] / 1e6).round(0) for c in ("known", "final", "late_30G", "ie_known", "ie_final")}))
sp_show["share of nominee spending unseen on the eve"] = (1 - sp.known / sp.final).round(3)
sp_show["share of IE unseen"] = (1 - sp.ie_known / sp.ie_final).round(4)
print("$M; 2026 'final' is what has been filed so far (nothing after the eve)")
print(sp_show.to_string())
$M; 2026 'final' is what has been filed so far (nothing after the eve)
n_cands known final late_30G ie_known ie_final share of nominee spending unseen on the eve share of IE unseen
2018 843.0 1679.0 2123.0 443.0 1045.0 1049.0 0.209 0.004
2022 846.0 2316.0 2751.0 435.0 1610.0 1623.0 0.158 0.008
2026 876.0 2408.0 2408.0 0.0 3171.0 3171.0 0.000 0.000On the eve of 2018, the reports already filed held \$1.68B of the nominees' spending; a month later the total through the 30G was \$2.12B. In 2022: \$2.32B on the eve and \$2.75B in the end. About a fifth of nominee spending (21% and 16%) was unseen on the eve, and nearly all of it (\$443M and \$435M) arrived in one report, the 30G. Outside money is the opposite: 99.6% (2018) and 99.2% (2022) of the eventual general-election IE dollars had been received by the eve. 2026 has \$2.41B of nominee spending and \$3.17B of outside spending on file, twice the outside money of 2022.
fig, axes = plt.subplots(1, 2, figsize=(13, 4.3), width_ratios=[1.6, 1])
ax = axes[0]
for c, col in ((2018, MUTED), (2022, D_C), (2026, INK)):
w = spend_window(c)
elec = pd.Timestamp(ELEC[c])
days, fin = cumulative(w, elec)
_, kn = cumulative(w[w.receipt <= pd.Timestamp(EVE[c])], elec)
last = (w[(w.rpt == "12G") & (w.receipt <= pd.Timestamp(EVE[c]))].cvg_end.max() - elec).days # 12G end
ax.plot(days[days <= last], kn[days <= last] / 1e9, color=col, lw=2, label=f"{c}: filed by the eve")
if c < 2026:
ax.plot(days[days >= last], fin[days >= last] / 1e9, color=col, lw=2, ls=":",
label=f"{c}: filed a month later")
SPEND[c]["curve_known_last"] = last
ax.axvline(0, color=INK, lw=0.6)
ax.set_xlim(-730, 25)
ax.set_xlabel("days from election day")
ax.set_ylabel("nominees' cumulative disbursements ($B)")
ax.set_title("the spending curve: the last five weeks are reported after the election", fontsize=10)
ax.legend(fontsize=8)
ax = axes[1]
for c, col in ((2018, MUTED), (2022, D_C)):
f = ie[(ie.cycle == c) & (ie.version == "final")]
eve = pd.Timestamp(EVE[c])
dd = (f.date - pd.Timestamp(ELEC[c])).dt.days
days = np.arange(-60, 2)
tot = np.array([f.amt[dd == k].sum() for k in days]) / 1e6
seen = np.array([f.amt[(dd == k) & (f.first_receipt <= eve)].sum() for k in days]) / 1e6
ax.plot(days, tot, color=col, lw=1, ls=":")
ax.plot(days, seen, color=col, lw=1.6, label=f"{c}: received by the eve (dotted: final)")
ax.set_xlabel("dissemination date, days from election day")
ax.set_ylabel("outside spending per day ($M)")
ax.set_title("independent expenditures are filed within a day or two", fontsize=10)
ax.legend(fontsize=8);

The left panel is the spending curve: cumulative disbursements of all nominees by day, each report spread evenly over its period. On the eve it is known to 20 days before the election, and 2026's curve runs almost on top of 2022's. On the right, outside spending filed by the eve and final are nearly the same line.
6.1 The reporting lag of outside money
Outside money needs only a small correction, but it should be a learned one. Let $L$ be the number of days between an item's dissemination date and the eve. For the dollars disseminated $L$ days before the eve, the share already received by the eve is $f_L$, and we model the dollars received (in units of \$100k) as beta-binomial,
$$ \text{seen}_L \sim \text{BetaBinomial}\big(n_L,\ f_L \kappa,\ (1 - f_L)\kappa\big),\qquad \operatorname{logit} f_L = a + b \log(1 + L), $$
over daily bins up to two weeks and wider bins beyond. The overdispersion $\kappa$ is there because dollars arrive in lumps. Dollars dated on election day are modelled as a share $r$ of the last week's dollars. For a new cycle, the curve is shifted by a cycle effect on the logit scale with an assumed sd of 0.5: the two cycles cannot estimate how much the lag varies from one cycle to the next. Only one backtest is a forward test: fitted on 2018, nowcasting 2022 from what had been filed by its eve. The other, fitted on 2022 and nowcasting 2018, runs backwards in time: the 2018 filings are cut at its eve, but the parameters come from a later cycle, so it is a leave-one-cycle-out check. Report files for other cycles were not downloaded, so there is no second forward test. The 2026 nowcast fits on both.
LAG_BINS = [(L, L) for L in range(15)] + [(15, 30), (31, 60), (61, 2000)]
def ie_lag_table(cycle, version="final"):
f = ie[(ie.cycle == cycle) & (ie.version == version)]
eve = pd.Timestamp(EVE[cycle])
L = (eve - f.date).dt.days
rows = []
for lo, hi in LAG_BINS:
g = f[(L >= lo) & (L <= hi)]
rows.append({"cycle": cycle, "lo": lo, "hi": hi, "Lmid": (lo + min(hi, 120)) / 2,
"final": g.amt.sum(), "seen": g.amt[g.first_receipt <= eve].sum()})
week = f.amt[(L >= 0) & (L < 7)].sum()
return pd.DataFrame(rows), {"week": week, "post": f.amt[L < 0].sum()}
def fit_ie_lag(cycles):
lag = pd.concat([ie_lag_table(c)[0] for c in cycles])
post = [ie_lag_table(c)[1] for c in cycles]
U = 1e5 # dollars counted in units of $100k
with pm.Model() as m:
a = pm.Normal("a", 0, 2)
b = pm.Normal("b", 2, 2)
kappa = pm.Gamma("kappa", 2, 0.05)
f_L = pm.math.invlogit(a + b * np.log1p(lag.Lmid.to_numpy()))
pm.BetaBinomial("seen", n=np.round(lag.final.to_numpy() / U).astype(int), alpha=f_L * kappa,
beta=(1 - f_L) * kappa, observed=np.round(lag.seen.to_numpy() / U).astype(int))
# dollars dated election day or later, as a share of the last week's: a cycle-level share
r_mu = pm.Beta("r_mu", 1, 50)
r_k = pm.Gamma("r_k", 2, 0.02)
pm.BetaBinomial("post", n=np.round([p["week"] / U for p in post]).astype(int), alpha=r_mu * r_k,
beta=(1 - r_mu) * r_k, observed=np.round([p["post"] / U for p in post]).astype(int))
idata = pm.sample(random_seed=RANDOM_SEED, progressbar=False, target_accept=0.95)
return idata
IE_CYCLE_SD = 0.5 # an assumed cycle-wide shift of the lag curve (logit scale): two cycles cannot estimate it
def ie_nowcast(idata, cycle, n=2000, seed=RANDOM_SEED + 1):
"""Draws of a cycle's total general-election IE: seen by the eve, plus lag completion, plus election day.
Its own seed: predict_late() draws its cycle shift first from RANDOM_SEED, and sharing the stream would
make the two shifts identical and the candidate and IE parts perfectly anti-correlated."""
g = np.random.default_rng(seed)
lag, post = ie_lag_table(cycle, "eve" if cycle == 2026 else "final")
seen = lag.seen.to_numpy() if cycle < 2026 else lag.final.to_numpy()
pst = az.extract(idata, num_samples=n, random_seed=seed)
shift = g.normal(0, IE_CYCLE_SD, (n, 1))
fL = 1 / (1 + np.exp(-(pst["a"].values[:, None] + shift + pst["b"].values[:, None] * np.log1p(lag.Lmid.to_numpy()))))
unseen = (seen * (1 / fL - 1)).sum(1)
r = g.beta(pst["r_mu"].values * pst["r_k"].values, (1 - pst["r_mu"].values) * pst["r_k"].values)
week_seen = seen[:7].sum()
return seen.sum() + unseen + r * week_seen, seen.sum()
t0 = time.time()
IE_FIT = {"2018 -> 2022": fit_ie_lag([2018]), "2022 -> 2018": fit_ie_lag([2022]), "both -> 2026": fit_ie_lag([2018, 2022])}
TIMES["IE lag fits"] = time.time() - t0
for k, idt in IE_FIT.items():
print(k, diagnostics(idt))
print(az.summary(IE_FIT["both -> 2026"], var_names=["a", "b", "kappa", "r_mu"], round_to=3).to_string())
ie_bt = {}
for k, target in (("2018 -> 2022", 2022), ("2022 -> 2018", 2018)):
tot, seen = ie_nowcast(IE_FIT[k], target)
q = np.quantile(tot, [0.05, 0.5, 0.95]) / 1e6
ie_bt[target] = tot
print(f"IE {k}: seen by the eve ${seen / 1e6:,.0f}M, nowcast ${q[1]:,.1f}M [{q[0]:,.1f}, {q[2]:,.1f}], "
f"final ${SPEND[target]['ie_final'] / 1e6:,.1f}M")
2018 -> 2022 {'divergences': 0, 'max r_hat': 1.002, 'worst': 'r_k', 'min ESS': 1144, 'tuning': 400}
2022 -> 2018 {'divergences': 0, 'max r_hat': 1.003, 'worst': 'r_k', 'min ESS': 1030, 'tuning': 400}
both -> 2026 {'divergences': 0, 'max r_hat': 1.004, 'worst': 'a', 'min ESS': 980, 'tuning': 400}
mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
a 1.301 0.443 0.614 2.030 1067.854 1567.482 1.004 0.014 0.008
b 1.116 0.263 0.691 1.529 980.576 1356.130 1.002 0.008 0.005
kappa 29.250 13.908 12.292 54.260 1671.620 2005.590 1.000 0.331 0.325
r_mu 0.012 0.007 0.003 0.026 2628.766 2006.082 1.001 0.000 0.000
IE 2018 -> 2022: seen by the eve $1,609M, nowcast $1,622.8M [1,614.3, 1,643.3], final $1,622.5M
IE 2022 -> 2018: seen by the eve $1,045M, nowcast $1,062.6M [1,051.9, 1,091.4], final $1,048.9MIn the forward test, fitted on 2018, the model nowcast 2022's outside total at \$1,622M (90% interval 1,614 to 1,644) against \$1,622.5M filed. Leaving out 2018 and fitting on 2022, it nowcast 2018 at \$1,063M (1,052 to 1,092) against \$1,049M: too high by 1.3% and just outside the interval, because 2022 had more late filers than 2018. The error is tiny next to the candidate side.
fig, ax = plt.subplots(figsize=(7.5, 3.6))
pst = az.extract(IE_FIT["both -> 2026"], num_samples=500, random_seed=RANDOM_SEED)
Ls = np.linspace(0, 90, 200)
fl = 1 / (1 + np.exp(-(pst["a"].values[:, None] + pst["b"].values[:, None] * np.log1p(Ls))))
lo, mid, hi = np.quantile(fl, [0.05, 0.5, 0.95], 0)
ax.fill_between(Ls, lo, hi, color=D_C, alpha=0.25)
ax.plot(Ls, mid, color=D_C, label="fitted, 2018 and 2022")
for c, mk in ((2018, "o"), (2022, "s")):
t, _ = ie_lag_table(c)
ax.scatter(t.Lmid, t.seen / t.final, marker=mk, color=INK, s=18, label=str(c), zorder=5)
ax.set_xscale("symlog", linthresh=1)
ax.set_xlabel("days between dissemination and the eve")
ax.set_ylabel("share of dollars received by the eve")
ax.set_ylim(0.0, 1.02)
ax.legend(fontsize=8)
ax.set_title("IE reporting lag: dollars spent L days before the eve that were already filed", fontsize=10);

The points scatter widely in the first two days and sit close to the curve after a week.
6.2 Candidates' late spending
A hierarchical growth curve for each race over the whole cycle is more than the eve needs: every nominee's curve is observed up to mid-October, and only its tail is unknown. The tail is predicted by how fast the campaign was spending in the 12G period and how much cash it had left. For nominee $j$ in group $g$ (office x competitiveness: |lean| below 8, 8-20, above 20), with 30G disbursements $y_j$,
$$ \log(1 + y_j) \sim \mathcal N\big(a_g + b_{1g}(x^{\text{rate}}_j - 10) + b_2 (x^{\text{cash}}_j - 12) + b_{\text{inc}}\,\text{incumbent}_j,\ \sigma_g\big),\qquad a_g \sim \mathcal N(\mu_a, \sigma_a), $$
where $x^{\text{rate}}$ is the log of 20 days of spending at the 12G daily rate and $x^{\text{cash}}$ the log of cash on hand at the end of the 12G. Groups pool toward each other through $a_g$. A pilot with Student-t errors had fine per-candidate coverage but tails that summed to absurd totals (its 90% interval for the 2022 national total reached \$470B); Normal errors on the log scale fix that. As for outside money, a shared shift of all candidates in a cycle (a late surge of small donations, say) cannot be estimated from two cycles, so the nowcast adds one with an assumed sd of 0.25 on the log scale. Both choices, Normal errors and the sd of 0.25, were made after seeing the pilot's backtests (the pilot, without a cycle shift, missed 2018 with \$606M, 90% interval 480 to 790, against \$443M filed). The backtest totals below are therefore not out of sample for those two choices.
Failure 3, the hierarchy written the other way. Every group has dozens to hundreds of candidates, so
the data pin each $a_g$ tightly, and that is where the centred form is the right one (E25). The
non-centred form, the usual fix for a funnel, is printed first; then the three fits we use, centred,
with target_accept=0.95.
def cand_table(cycle):
"""One row per nominee with reports received by the eve: known spending, the 12G period, and the 30G."""
r = spend_window(cycle)
eve = pd.Timestamp(EVE[cycle])
known = r[r.receipt <= eve]
k = known.groupby("cand_id").agg(disb_known=("disb", "sum"), race=("race", "first"), office=("office", "first"),
side=("side", "first"))
p12 = known[known.rpt == "12G"].groupby("cand_id").agg(d12=("disb", "sum"), s12=("cvg_start", "min"),
e12=("cvg_end", "max"), coh12=("coh", "sum"))
final = r.groupby("cand_id").disb.sum().rename("disb_final")
d30 = r[r.rpt == "30G"].groupby("cand_id").disb.sum().rename("d30")
df = k.join(p12).join(final).join(d30).reset_index()
rr = R[R.cycle == cycle].set_index("race")
df["cycle"] = cycle
df["lean"] = df.race.map(rr.lean)
df["inc"] = [bool(rr.d_inc.get(rc, False) if s == "D" else rr.r_inc.get(rc, False)) for rc, s in zip(df.race, df.side)]
df = df[df.d12.notna()].copy()
df["days12"] = (df.e12 - df.s12).dt.days + 1
df["x_rate"] = np.log1p(df.d12.clip(0) / df.days12 * 20) # 20 days at the pre-general burn rate
df["x_coh"] = np.log1p(df.coh12.clip(0))
df["comp"] = pd.cut(df.lean.abs().fillna(15), [-1, 8, 20, 200], labels=False).astype(int)
df["grp"] = (df.office == "S").astype(int) * 3 + df.comp
return df
GROUPS = [f"{o} {c}" for o in ("House", "Senate") for c in ("|lean|<8", "8-20", ">20")]
def fit_late(df, centred=True):
t = df[df.d30.notna()]
g = t.grp.to_numpy()
with pm.Model(coords={"group": GROUPS}) as m:
a_mu = pm.Normal("a_mu", 0, 2)
a_sd = pm.HalfNormal("a_sd", 1)
if centred:
a = pm.Normal("a", a_mu, a_sd, dims="group")
else:
a = pm.Deterministic("a", a_mu + a_sd * pm.Normal("z_a", 0, 1, dims="group"), dims="group")
b1 = pm.Normal("b1", 0.5, 0.5, dims="group")
b2 = pm.Normal("b2", 0.3, 0.5)
binc = pm.Normal("binc", 0, 0.5)
sd = pm.HalfNormal("sd", 1, dims="group")
mu = a[g] + b1[g] * (t.x_rate.to_numpy() - 10) + b2 * (t.x_coh.to_numpy() - 12) + binc * t.inc.to_numpy()
pm.Normal("y", mu=mu, sigma=sd[g], observed=np.log1p(t.d30.clip(0)).to_numpy())
idata = pm.sample(random_seed=RANDOM_SEED, progressbar=False, target_accept=0.95)
return idata
CYCLE_SD = 0.25 # an assumed cycle-wide shift on the log scale: two cycles cannot estimate it
def predict_late(idata, df, n=2000, seed=RANDOM_SEED):
g_ = np.random.default_rng(seed)
p = az.extract(idata, num_samples=n, random_seed=seed)
g = df.grp.to_numpy()
T = lambda v: p[v].transpose("sample", ...).values # noqa: E731
mu = (T("a")[:, g] + T("b1")[:, g] * (df.x_rate.to_numpy() - 10) + T("b2")[:, None] * (df.x_coh.to_numpy() - 12)
+ T("binc")[:, None] * df.inc.to_numpy())
y = mu + g_.normal(0, CYCLE_SD, (n, 1)) + T("sd")[:, g] * g_.standard_normal(mu.shape)
return np.expm1(y).clip(0)
CT = {c: cand_table(c) for c in (2018, 2022, 2026)}
for c, t in CT.items():
print(f"{c}: {len(t)} nominees with a pre-general report by the eve; with a post-general report: "
f"{int(t.d30.notna().sum())}")
t0 = time.time()
print("non-centred group intercepts, fitted on 2022:", diagnostics(fit_late(CT[2022], centred=False)))
LATE_FIT = {"2018 -> 2022": fit_late(CT[2018]), "2022 -> 2018": fit_late(CT[2022]),
"both -> 2026": fit_late(pd.concat([CT[2018], CT[2022]]))}
TIMES["late-spending fits"] = time.time() - t0
for k, idt in LATE_FIT.items():
print(k, diagnostics(idt))
print(az.summary(LATE_FIT["both -> 2026"], var_names=["a", "b1", "b2", "binc", "sd"], round_to=2).to_string())
2018: 826 nominees with a pre-general report by the eve; with a post-general report: 812 2022: 820 nominees with a pre-general report by the eve; with a post-general report: 800 2026: 863 nominees with a pre-general report by the eve; with a post-general report: 0
non-centred group intercepts, fitted on 2022: {'divergences': 13, 'max r_hat': 1.009, 'worst': 'a_sd', 'min ESS': 399, 'tuning': 400}2018 -> 2022 {'divergences': 0, 'max r_hat': 1.005, 'worst': 'sd', 'min ESS': 1494, 'tuning': 400}
2022 -> 2018 {'divergences': 4, 'max r_hat': 1.007, 'worst': 'b1', 'min ESS': 601, 'tuning': 400}
both -> 2026 {'divergences': 0, 'max r_hat': 1.006, 'worst': 'a_mu', 'min ESS': 1249, 'tuning': 400}mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd a[House |lean|<8] 11.26 0.07 11.14 11.38 1249.33 1659.24 1.0 0.0 0.0 a[House 8-20] 11.13 0.07 11.01 11.25 3472.79 3468.80 1.0 0.0 0.0 a[House >20] 10.90 0.05 10.83 10.98 1973.48 2811.67 1.0 0.0 0.0 a[Senate |lean|<8] 11.10 0.18 10.81 11.38 1454.49 1684.79 1.0 0.0 0.0 a[Senate 8-20] 11.00 0.17 10.72 11.26 1832.93 2226.43 1.0 0.0 0.0 a[Senate >20] 11.15 0.14 10.93 11.38 2524.97 2516.21 1.0 0.0 0.0 b1[House |lean|<8] 0.59 0.02 0.55 0.63 1415.46 2004.32 1.0 0.0 0.0 b1[House 8-20] 0.57 0.03 0.53 0.61 3492.03 3433.83 1.0 0.0 0.0 b1[House >20] 0.54 0.02 0.50 0.57 4037.84 2839.10 1.0 0.0 0.0 b1[Senate |lean|<8] 0.70 0.04 0.64 0.76 1409.32 1636.83 1.0 0.0 0.0 b1[Senate 8-20] 0.67 0.04 0.60 0.73 1788.22 2165.06 1.0 0.0 0.0 b1[Senate >20] 0.56 0.05 0.48 0.64 2688.78 2724.05 1.0 0.0 0.0 b2 0.30 0.02 0.28 0.33 1738.53 2188.34 1.0 0.0 0.0 binc -0.34 0.05 -0.41 -0.26 1923.95 2387.87 1.0 0.0 0.0 sd[House |lean|<8] 0.41 0.02 0.38 0.44 6581.19 2621.06 1.0 0.0 0.0 sd[House 8-20] 1.10 0.04 1.04 1.17 6564.98 3133.94 1.0 0.0 0.0 sd[House >20] 1.09 0.03 1.04 1.13 6823.33 3063.53 1.0 0.0 0.0 sd[Senate |lean|<8] 0.42 0.05 0.34 0.51 5851.06 2765.28 1.0 0.0 0.0 sd[Senate 8-20] 0.64 0.08 0.53 0.77 6445.61 2769.55 1.0 0.0 0.0 sd[Senate >20] 0.74 0.07 0.64 0.87 6712.36 3138.87 1.0 0.0 0.0
The non-centred version had 13 divergences on 2022; the centred one has 0, 4 and 0 in the three fits, with $\hat R$ at most 1.01. The four remaining divergences sit where the six group intercepts pool weakly (small $\sigma_a$) and do not move the nowcast. The coefficients are sensible: late spending rises with the 12G burn rate (elasticity 0.55-0.70) and with cash on hand (0.30), and is about 30% lower for incumbents. The residual sd is about 0.4 on the log scale for close races and 0.6-1.1 for the others, where a campaign may simply stop.
bt_rows, LATE_BT = [], {}
for k, target in (("2018 -> 2022", 2022), ("2022 -> 2018", 2018)):
df = CT[target]
late = predict_late(LATE_FIT[k], df)
truth = df.d30.fillna(0).clip(0).to_numpy()
LATE_BT[target] = (df, late, truth)
tot = late.sum(1)
pit = (late < truth).mean(0) + 0.5 * (late == truth).mean(0)
lo, hi = np.quantile(late, [0.05, 0.95], 0)
bt_rows.append({"fit -> target": k, "late, true $M": truth.sum() / 1e6,
"late, nowcast $M": np.median(tot) / 1e6, "90% lo": np.quantile(tot, 0.05) / 1e6,
"90% hi": np.quantile(tot, 0.95) / 1e6, "PIT of the total": (tot < truth.sum()).mean(),
"per-candidate 90% cover": np.mean((truth >= lo) & (truth <= hi)),
"per-candidate 50% cover": np.mean((truth >= np.quantile(late, 0.25, 0)) & (truth <= np.quantile(late, 0.75, 0)))})
print(pd.DataFrame(bt_rows).round(2).to_string(index=False))
fit -> target late, true $M late, nowcast $M 90% lo 90% hi PIT of the total per-candidate 90% cover per-candidate 50% cover 2018 -> 2022 434.26 481.32 315.40 725.53 0.34 0.92 0.64 2022 -> 2018 442.58 622.91 395.88 991.22 0.12 0.93 0.68
In the forward test, fitted on 2018, the nowcast of 2022's late spending is \$481M (90% interval 315 to 726) against \$434M filed: inside, a little high (PIT 0.34). Leaving out 2018, it nowcasts 2018 at \$623M (396 to 991) against \$443M: inside, but in the lower tail (PIT 0.12). So the evidence on totals is two totals, both over-predicted, one of them a forward test, and the cycle shift that puts both inside was sized after the pilot had seen them. Per candidate, the 90% intervals cover 92-93% and the 50% intervals 64-68%: a little too wide in the middle, as the humped PIT below shows.
def race_totals(cycle, late, df, ie_tot=None):
"""Per-race spending: nominees' known + late draws, plus IE (final, or seen + scaled-up for 2026)."""
races_ = sorted(df.race.unique())
idx = pd.Index(races_).get_indexer(df.race)
S = late.shape[0]
cand = np.zeros((S, len(races_)))
np.add.at(cand.T, idx, (late + df.disb_known.to_numpy()).T)
f = ie[(ie.cycle == cycle) & (ie.version == ("eve" if cycle == 2026 else "final"))]
if cycle < 2026:
f_seen = f[f.first_receipt <= pd.Timestamp(EVE[cycle])]
else:
f_seen = f
ie_seen = f_seen.groupby("race").amt.sum().reindex(races_).fillna(0).to_numpy()
scale = (ie_tot / f_seen.amt.sum())[:, None] if ie_tot is not None else 1.0
return races_, cand + ie_seen[None, :] * scale
rt_rows = []
for target in (2018, 2022):
df, late, truth = LATE_BT[target]
races_, draws = race_totals(target, late, df, ie_bt[target])
f = ie[(ie.cycle == target) & (ie.version == "final")]
true_race = (df.groupby("race").disb_final.sum().reindex(races_).to_numpy()
+ f.groupby("race").amt.sum().reindex(races_).fillna(0).to_numpy())
lo, hi = np.quantile(draws, [0.05, 0.95], 0)
top = np.argsort(-true_race)[:50]
rt_rows.append({"target": target, "races": len(races_), "90% cover, all races": np.mean((true_race >= lo) & (true_race <= hi)),
"90% cover, 50 costliest": np.mean((true_race[top] >= lo[top]) & (true_race[top] <= hi[top])),
"median abs error, 50 costliest (%)": 100 * np.median(np.abs(np.median(draws, 0)[top] / true_race[top] - 1))})
print(pd.DataFrame(rt_rows).round(3).to_string(index=False))
target races 90% cover, all races 90% cover, 50 costliest median abs error, 50 costliest (%) 2018 468 0.964 0.96 2.163 2022 470 0.962 0.98 1.827
By race, adding each race's filed spending and outside money, the nowcast's 90% intervals cover 96% of race totals in both backtests, and the median error for the 50 costliest races is about 2%: by the eve most of a race's money is already on file, and the nowcast only has to supply the tail.
fig, axes = plt.subplots(1, 3, figsize=(13, 3.9), width_ratios=[1.2, 1, 1])
ax = axes[0]
for k, target in enumerate((2018, 2022)):
df, late, truth = LATE_BT[target]
tot = late.sum(1) / 1e6
parts = np.quantile(tot, [0.05, 0.25, 0.5, 0.75, 0.95])
ax.plot([parts[0], parts[4]], [k, k], color=D_C, lw=1.5)
ax.plot([parts[1], parts[3]], [k, k], color=D_C, lw=5)
ax.plot(parts[2], k, "o", color="white", mec=D_C)
ax.plot(truth.sum() / 1e6, k, "*", color=R_C, ms=14)
ax.set_yticks([0, 1], ["2018 (fitted on 2022,\nleave-one-cycle-out)", "2022 (fitted on 2018,\nforward test)"])
ax.set_xlabel("nominees' late spending, $M (50% and 90%)")
ax.set_title("backtest of the late-spending nowcast\n(star: filed in the post-general report)", fontsize=10)
ax = axes[1]
for target, col in ((2018, MUTED), (2022, D_C)):
df, late, truth = LATE_BT[target]
pit = (late < truth).mean(0) + 0.5 * (late == truth).mean(0)
h, e = np.histogram(pit, bins=10, range=(0, 1))
ax.step(e, np.r_[h, h[-1]] / h.sum() * 10, where="post", color=col, label=str(target))
ax.axhline(1, color=INK, lw=0.6, ls=":")
ax.set_xlabel("PIT per candidate")
ax.legend(fontsize=8)
ax.set_title("per-candidate calibration", fontsize=10)
ax = axes[2]
df, late, truth = LATE_BT[2022]
med = np.median(late, 0)
ax.scatter(np.maximum(med, 1e3), np.maximum(truth, 1e3), s=6, alpha=0.5, color=D_C)
ax.plot([1e3, 1e8], [1e3, 1e8], color=INK, lw=0.7)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("nowcast median ($)")
ax.set_ylabel("filed in the 30G ($)")
ax.set_title("2022, per candidate", fontsize=10);

The backtest figure shows both totals inside their intervals, the 2018 total near the lower edge. The per-candidate PITs are humped; the scatter of nowcast against filed spending for 2022 follows the diagonal over four orders of magnitude, with a row of candidates at the bottom who filed nothing more (they count as zero, as the 2026 total will).
7 · 2026: the frozen forecast and nowcast
There are two frozen forecasts, both made on 3 November 2026 before any polls closed, both from information dated up to 2 November, and both will be scored.
- v1, pre-registered (
e94_freeze_2026.json): written by the pilot code intools/e94_pilot/at 18:17 UTC and committed with its inputs and code ine9c37e7at 19:09:40 UTC. - v2, corrected (
e94_freeze_2026_v2.json): written bytools/e94_freeze_v2.py, which runs this notebook's code, at 19:18:45 UTC and committed in77d2c05at 19:18:56 UTC.
v2 exists because of a mistake in v1's data. The builder matched 2026 nominees to FEC IDs by name, only among candidates who had filed a pre-general report, and gave an unmatched nominee \$0 of receipts. That missed 14% of Republican nominees, including Ashley Hinson in Iowa's Senate race (the FEC files her as "ARENHOLZ, ASHLEY HINSON"), and pinned the money signal of 61 races at its clip, worth about 12 points of margin. v2 matches against the FEC's full candidate list and treats the two nominees still unmatched as missing. v1 stays the pre-registered record; scoring both shows what the mistake cost. Each file stores the sha256 of its inputs.
def sha(p, content=False):
b = open(p, "rb").read()
return hashlib.sha256(gzip.decompress(b) if content and str(p).endswith(".gz") else b).hexdigest()
FROZEN1 = json.load(open(data.path("e94_freeze_2026"))) # v1: pre-registered, commit e9c37e7
FROZEN = json.load(open(data.path("e94_freeze_2026_v2"))) # v2: corrected, same information cutoff
V1_INPUTS = json.load(open(data.path("e94_freeze_2026_v1_inputs")))
for lab, fz_ in (("v1", FROZEN1), ("v2", FROZEN)):
print(f"{lab}: frozen at {fz_['frozen_at_utc']} | information cutoff: {fz_['information_cutoff'][:10]} | "
f"sha256 {sha(data.path('e94_freeze_2026' + ('_v2' if lab == 'v2' else '')))[:16]}...")
for fname, h in FROZEN["inputs_sha256"].items():
p = data.DATA_DIR / fname
same_raw = sha(p) == h
same_v2 = sha(p, True) == FROZEN["inputs_sha256_content"][fname]
same_v1 = sha(p, True) == V1_INPUTS["content_sha256"][fname]
print(f" input {fname:20s} v2: {'unchanged' if same_v2 else 'CHANGED'} (content), "
f"{'same bytes' if same_raw else 'other bytes'}; v1 ({V1_INPUTS['commit'][:7]}): "
f"{'unchanged' if same_v1 else 'rebuilt since'}")
v1: frozen at 2026-11-03T18:17:11Z | information cutoff: 2026-11-02 | sha256 0b7712887bdc71a1... v2: frozen at 2026-11-03T19:18:45Z | information cutoff: 2026-11-02 | sha256 de18642ab75047eb... input e94_races.csv v2: unchanged (content), same bytes; v1 (e9c37e7): rebuilt since input e94_polls.csv v2: unchanged (content), same bytes; v1 (e9c37e7): unchanged input e94_reports.csv.gz v2: unchanged (content), same bytes; v1 (e9c37e7): rebuilt since input e94_ie.csv.gz v2: unchanged (content), same bytes; v1 (e9c37e7): rebuilt since input e94_pollerr.csv v2: unchanged (content), same bytes; v1 (e9c37e7): unchanged
The inputs on disk are exactly v2's. Three of v1's inputs were rebuilt for v2, by design; v1's own
inputs are in commit e9c37e7, and the content hashes of all five are kept in a separate file so that
a rebuild that only changes gzip header bytes does not look like a change.
s26 = fit26["seats"]
notup = {k: int(v) for k, v in build_log["senate_2026_not_up"].items()}
sen26 = s26["S"] + notup["Democratic"] + notup["Independent"]
q = lambda v: np.quantile(v, [0.05, 0.5, 0.95]) # noqa: E731
def frozen_col(f):
fs_ = f["seats"]
iv = lambda v, fmt: f"{v[1]:{fmt}} [{v[0]:{fmt}}, {v[2]:{fmt}}]" # noqa: E731
return [iv(fs_["N_q05_q50_q95"], "+.1f"), iv(fs_["H"]["D_seats_q05_q50_q95"], ".0f"),
iv(fs_["S"]["D_seats_q05_q50_q95"], ".0f"), f"{fs_['H']['P_D_control']:.2f}", f"{fs_['S']['P_D_control']:.2f}"]
cmp = pd.DataFrame({
"v1 (pre-registered)": frozen_col(FROZEN1), "v2 (corrected)": frozen_col(FROZEN),
"this run": [f"{q(fit26['N'])[1]:+.1f} [{q(fit26['N'])[0]:+.1f}, {q(fit26['N'])[2]:+.1f}]",
f"{q(s26['H'])[1]:.0f} [{q(s26['H'])[0]:.0f}, {q(s26['H'])[2]:.0f}]",
f"{q(sen26)[1]:.0f} [{q(sen26)[0]:.0f}, {q(sen26)[2]:.0f}]",
f"{(s26['H'] >= 218).mean():.2f}", f"{(sen26 >= 51).mean():.2f}"]},
index=["national margin N", "House D seats", "Senate D-caucus seats", "P(D House majority)", "P(D Senate control)"])
print(cmp.to_string())
fz = pd.DataFrame(FROZEN["races"]).set_index("race")
fz1 = pd.DataFrame(FROZEN1["races"]).set_index("race")
tm26 = fit26["d"]["tm"].assign(pD=(fit26["sim"] > 0).mean(0), med=np.median(fit26["sim"], 0))
j = tm26.join(fz, on="race", rsuffix="_frozen")
print(f"race by race, this run against v2: P(D) differs by {np.abs(j.pD - j.pD_frozen).mean():.3f} on average "
f"(largest {np.abs(j.pD - j.pD_frozen).max():.2f}); median margins by {np.abs(j.med - j.pred).mean():.2f} points")
v12 = fz1[["pD", "pred"]].join(fz[["pD", "pred"]], rsuffix="_v2").join(tm26.set_index("race")[["r_name"]])
v12["shift"] = v12.pred_v2 - v12.pred
print("largest changes from v1 to v2 (median D margin, points):")
print(v12.reindex(v12["shift"].abs().sort_values(ascending=False).index).head(8).round(2).to_string())
v1 (pre-registered) v2 (corrected) this run
national margin N +7.9 [+3.2, +12.8] +7.8 [+3.0, +12.9] +7.8 [+3.1, +12.7]
House D seats 236 [217, 261] 236 [217, 262] 237 [217, 262]
Senate D-caucus seats 52 [49, 56] 52 [48, 56] 52 [48, 56]
P(D House majority) 0.95 0.94 0.94
P(D Senate control) 0.83 0.78 0.79
race by race, this run against v2: P(D) differs by 0.002 on average (largest 0.04); median margins by 0.15 points
largest changes from v1 to v2 (median D margin, points):
pD pred pD_v2 pred_v2 r_name shift
race
CA-45 1.00 28.68 1.00 21.48 Chuong Vo -7.20
FL-28 0.00 -19.09 0.03 -13.96 Carlos Giménez 5.13
MD-04 1.00 85.35 1.00 90.40 George McDermott 5.05
ID-S 0.06 -8.62 0.01 -13.61 Jim Risch -4.99
IA-S 0.87 5.50 0.57 0.83 Ashley Hinson -4.66
KY-03 1.00 39.34 1.00 36.09 Maria Rodriquez -3.26
TX-06 0.00 -22.71 0.00 -19.76 Jake Ellzey 2.95
MS-04 0.00 -38.04 0.00 -36.10 Mike Ezell 1.94The corrected freeze, v2, puts the national House margin at D+7.8 (90% interval 3.0 to 12.9) and the Democrats at 236 House seats (217 to 262) with a 0.94 chance of a majority, and 52 Democratic-caucus Senate seats (48 to 56) with a 0.78 chance of control. The pre-registered v1 said D+7.9 (3.2 to 12.8), 236 (217 to 261), 0.95, and 52 (49 to 56) with 0.83. This run matches v2 within sampling noise. The Senate count includes the 34 seats not up (32 Democrats and 2 independents who caucus with them), and a 50-50 Senate goes to the Republicans through Vice President Vance.
The correction moves the races whose nominee was missed. Iowa's Senate race goes from D+5.5 with P(D) = 0.87 to D+0.8 with 0.57, closer to its polls, and Idaho's moves 5 points toward Jim Risch (whose FEC party is "UNK"); together they lower the chance of Senate control from 0.83 to 0.78. California's 45th moves 7 points toward the Republican. The House totals hardly move, because most of the missed nominees ran in safe seats.
fig, axes = plt.subplots(1, 2, figsize=(13, 3.6))
for ax, v, off, line, lab in ((axes[0], s26["H"], "H", 217.5, "House D seats"),
(axes[1], sen26, "S", 50.5, "Senate D-caucus seats (34 not up included)")):
w_ = 2 if off == "H" else 1
e = np.arange(v.min() - 0.5, v.max() + w_, w_)
cnt, _ = np.histogram(v, e)
cnt = cnt / len(v) / w_
ax.bar(e[:-1], cnt, width=w_, align="edge", color=np.where(e[:-1] + w_ / 2 > line, D_C, R_C), alpha=0.75)
fq = FROZEN["seats"][off]["D_seats_q05_q50_q95"]
ax.errorbar(fq[1], cnt.max() * 1.1, xerr=[[fq[1] - fq[0]], [fq[2] - fq[1]]], fmt="D", color=INK, capsize=3,
label="frozen v2, 90% interval")
ax.axvline(line, color=INK, lw=0.7, ls=":")
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xlabel(lab)
ax.legend(fontsize=8, loc="upper right")
axes[0].set_ylabel("probability")
fig.suptitle("2026 on the eve: seat distributions (this run) and the frozen v2 intervals", fontsize=10);

The House distribution is wide and right-skewed, with most of its mass above the 218 line; the Senate distribution is centred at 52, one seat above the 51 that control needs, with a real chance of 50 or fewer.
fig = plt.figure(figsize=(13, 7.2))
gs = fig.add_gridspec(2, 2, width_ratios=[1.35, 1], height_ratios=[1, 0.22])
ax = fig.add_subplot(gs[:, 1])
pDall = fit26["d"]["tgt"][["race", "office", "state"]].merge(fz[["pD", "pred", "q10", "q90"]], left_on="race",
right_index=True, how="left")
close = fz.assign(unc=np.abs(fz.pD - 0.5)).sort_values("unc").head(30).sort_values("pred")
polled26 = set(fit26["d"]["polled"])
print(f"of the 30 closest frozen races, {sum(r in polled26 for r in close.index)} have race polls; "
f"House seat 218 from the Democratic end has frozen P(D) = "
f"{np.sort(np.r_[np.zeros(int((~fit26['d']['tgt'].modelled & (fit26['d']['tgt'].office == 'H') & (fit26['d']['tgt'].d_name.fillna('') == '')).sum())), fz[fz.office == 'H'].pD.to_numpy(), np.ones(int((~fit26['d']['tgt'].modelled & (fit26['d']['tgt'].office == 'H') & (fit26['d']['tgt'].d_name.fillna('') != '')).sum()))])[435 - 218]:.2f}")
for k, (race, r) in enumerate(close.iterrows()):
col = D_C if r.pred > 0 else R_C
ax.plot([r.q10, r.q90], [k, k], color=col, lw=2.5, alpha=0.7)
ax.plot(r.pred, k, "o", color="white", mec=col, ms=5)
ax.text(31, k, f"{r.pD:.2f}", va="center", fontsize=7, color=INK)
ax.set_yticks(range(len(close)), [f"{r}{' *' if r in polled26 else ''}" for r in close.index], fontsize=7)
ax.axvline(0, color=INK, lw=0.6)
ax.set_xlim(-30, 36)
ax.set_xlabel("frozen D margin, median and 80% interval (right: P(D))")
ax.set_title("the 30 closest races in the frozen forecast (* = race polls)", fontsize=10)
axg = fig.add_subplot(gs[0, 0])
hz = fz[fz.office == "H"].sort_values("pred")
uncont = fit26["d"]["tgt"][(fit26["d"]["tgt"].office == "H") & ~fit26["d"]["tgt"].modelled]
ncol = 25
vals = np.r_[np.ones(int((uncont.d_name.fillna("") == "").sum())) * 0.0, hz.pD.to_numpy(),
np.ones(int((uncont.d_name.fillna("") != "").sum()))]
grid = np.full(int(np.ceil(len(vals) / ncol)) * ncol, np.nan)
grid[: len(vals)] = vals
axg.imshow(grid.reshape(-1, ncol), cmap="RdBu", vmin=0, vmax=1, aspect="equal")
k218 = 435 - 218
axg.add_patch(plt.Rectangle((k218 % ncol - 0.5, k218 // ncol - 0.5), 1, 1, fill=False, ec=GOLD, lw=2))
axg.set_xticks([])
axg.set_yticks([])
axg.set_title("all 435 House seats, ordered from safest R to safest D, coloured by frozen P(D)\n"
"(gold square: the 218th seat counted from the safest D seat)", fontsize=10)
axs = fig.add_subplot(gs[1, 0])
sz = fz[fz.office == "S"].sort_values("pred")
axs.imshow(sz.pD.to_numpy()[None, :], cmap="RdBu", vmin=0, vmax=1, aspect="equal")
axs.set_xticks(range(len(sz)), [r.replace("-S", "").replace("2", "") for r in sz.index], fontsize=6, rotation=90)
axs.set_yticks([])
axs.set_title("the 35 Senate races", fontsize=9);
of the 30 closest frozen races, 22 have race polls; House seat 218 from the Democratic end has frozen P(D) = 0.76

The grid shows every House seat as one tile, ordered from the safest Republican seat to the safest Democratic one, coloured by v2's chance of a Democratic win. The gold tile, the 218th seat counted from the Democratic end, sits in the light blue: the seat that decides the majority leans Democratic. Of the 30 closest races on the right, 22 have race polls (marked *); the other 8 rest on the fundamentals.
A distribution of seat counts is hard to feel. Each predictive draw is one complete election: every race gets a margin, and all of them share one national environment, one set of state effects and one polling error. The animation plays thirty of them. They are taken evenly across the distribution of Democratic House seats (the draws at the 2nd, 5th, 8th and so on up to the 98th percentile), so a Republican House turns up about as often as the forecast says it should, and they run from the worst Democratic outcome to the best. The left panel colours each state with a Senate race by its margin in that draw. The right panel is the House grid from above, coloured by margin, and re-sorted in every frame: first the seats the Republicans win in that draw, then the seats the Democrats win, each group in order of the seat's chance of a Democratic win. The boundary between red and blue is then the seat count, and control changes hands when it crosses the gold tile, the 218th seat from the Democratic end. The states are drawn from the county outlines used in E16 and E89; a state border is any county edge the state shares with another state. There is no district map, because several states redrew their lines for 2026 and the repository has no boundary file for the new districts.
FIPS = {"01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA", "08": "CO", "09": "CT", "10": "DE", "11": "DC",
"12": "FL", "13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN", "19": "IA", "20": "KS", "21": "KY",
"22": "LA", "23": "ME", "24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS", "29": "MO", "30": "MT",
"31": "NE", "32": "NV", "33": "NH", "34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND", "39": "OH",
"40": "OK", "41": "OR", "42": "PA", "44": "RI", "45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT",
"50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI", "56": "WY"}
def place(ring, st):
"""Lower 48 as longitude and latitude; Alaska and Hawaii moved and scaled into the corner."""
x, y = ring[:, 0], ring[:, 1]
if st == "AK":
x, y = -118 + (x + 152) * 0.2, 25 + (y - 63) * 0.35
elif st == "HI":
x, y = -104 + (x + 157), 25 + (y - 20.5)
return np.c_[x, y]
rings, ring_state = [], []
for ft in json.load(open(data.path("us_counties_geojson")))["features"]:
st = FIPS.get(ft["properties"]["STATE"])
geom = ft["geometry"]
polys = [geom["coordinates"]] if geom["type"] == "Polygon" else geom["coordinates"]
for poly in polys:
ring = np.asarray(poly[0], float)
if st and not (st == "AK" and ring[:, 0].max() > 0): # Aleutians past 180 degrees dropped
rings.append(place(ring, st))
ring_state.append(st)
ring_state = np.array(ring_state)
seen_ = {}
for ring, st in zip(rings, ring_state): # state borders, no GIS dissolve (E89)
r_ = np.round(ring, 3)
for a_, b_ in zip(map(tuple, r_[:-1]), map(tuple, r_[1:])):
seen_.setdefault((a_, b_) if a_ < b_ else (b_, a_), set()).add(st)
borders = [e for e, g_ in seen_.items() if len(g_) > 1]
tm_a = fit26["d"]["tm"]
sim26 = fit26["sim"]
sen_i = np.flatnonzero((tm_a.office == "S").to_numpy())
sen_col = {st: i for st, i in zip(tm_a.state.to_numpy()[sen_i], sen_i)}
ring_race = np.array([sen_col.get(st, -1) for st in ring_state])
h_i = np.flatnonzero((tm_a.office == "H").to_numpy())
tgt26 = fit26["d"]["tgt"]
unc = tgt26[(tgt26.office == "H") & ~tgt26.modelled]
n_r, n_d = int((unc.d_name.fillna("") == "").sum()), int((unc.d_name.fillna("") != "").sum())
ncol = 29
n_tiles = n_r + len(h_i) + n_d
p_seat = np.r_[np.zeros(n_r), (sim26[:, h_i] > 0).mean(0), np.ones(n_d)] # each tile's P(D)
gx, gy = np.arange(n_tiles) % ncol, np.arange(n_tiles) // ncol
n_frames = 30
house_draws = seats(fit26["d"], sim26)["H"]
by_seats = np.argsort(house_draws, kind="stable")
pick = by_seats[((np.arange(n_frames) + 0.5) / n_frames * len(sim26)).astype(int)] # evenly spaced in rank
norm = plt.Normalize(-20, 20)
cmap = plt.get_cmap("RdBu").copy()
cmap.set_bad(LIGHT)
fig, (axm, axh) = plt.subplots(1, 2, figsize=(11, 4.2), width_ratios=[1.45, 1], dpi=60)
pc = PolyCollection(rings, cmap=cmap, norm=norm, edgecolor="face", linewidth=0.4) # no seams between counties
axm.add_collection(pc)
axm.add_collection(LineCollection(borders, color="white", lw=0.5))
axm.set_xlim(-125, -66)
axm.set_ylim(17, 50)
axm.set_aspect(1 / np.cos(np.radians(38)))
axm.set_axis_off()
axm.set_title("Senate: D margin by state (grey: no race)", fontsize=9)
tiles = axh.scatter(gx, -gy, c=np.zeros(n_tiles), cmap=cmap, norm=norm, marker="s", s=40, linewidths=0)
k218 = n_tiles - 218
axh.scatter([gx[k218]], [-gy[k218]], marker="s", s=64, facecolor="none", edgecolor=GOLD, lw=1.6)
axh.set_axis_off()
axh.set_aspect("equal")
axh.set_title("House: R wins, then D wins, each sorted by P(D)\n(gold: the 218th seat from the D end)", fontsize=9)
fig.colorbar(plt.cm.ScalarMappable(norm, cmap), ax=[axm, axh], orientation="horizontal", fraction=0.04, pad=0.02,
label="D minus R margin in this draw (points; clipped at 20)")
title = fig.suptitle("", fontsize=10)
def show_draw(k):
s_ = sim26[pick[k]]
pc.set_array(np.ma.masked_array(np.where(ring_race >= 0, s_[ring_race], 0.0), ring_race < 0))
m_ = np.r_[np.full(n_r, -100.0), s_[h_i], np.full(n_d, 100.0)]
tiles.set_array(m_[np.lexsort((p_seat, m_ > 0))]) # re-sorted every frame: who won, then P(D)
hd = n_d + int((s_[h_i] > 0).sum())
sd = int((s_[sen_i] > 0).sum()) + notup["Democratic"] + notup["Independent"]
title.set_text(f"Draw {k + 1} of {n_frames}, from fewest to most D House seats: House D {hd} "
f"({'D' if hd >= 218 else 'R'} majority), Senate D caucus {sd} ({'D' if sd >= 51 else 'R'} control)")
return [pc, tiles, title]
show_draw(0)
anim = animation.FuncAnimation(fig, show_draw, frames=n_frames, interval=600)
plt.close(fig)
hd_all = house_draws[pick]
print(f"{n_frames} draws: the Democrats hold the House in {(hd_all >= 218).sum()} and the Senate in "
f"{int(((sim26[pick][:, sen_i] > 0).sum(1) + notup['Democratic'] + notup['Independent'] >= 51).sum())}; "
f"{len(rings):,} county polygons, {len(borders):,} border edges")
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
anim_html = anim.to_jshtml(default_mode="loop")
print(f"animation: {len(anim_html) / 1e6:.1f} MB")
HTML(anim_html)
30 draws: the Democrats hold the House in 28 and the Senate in 20; 3,326 county polygons, 9,208 border edges
animation: 2.3 MB
The Democrats hold the House in 28 of the 30 frames, close to the forecast's 0.94. They hold the Senate in 20, fewer than the 24 that 0.79 implies, but the frames were chosen to cover the House distribution, and thirty frames are a small sample of the Senate. The point of watching is that the races move together. In the first two frames the red block runs past the gold tile and the House goes Republican; as the frames move on, the boundary slides back about two rows, from 213 Democratic seats to 270. Iowa, Ohio, Alaska and Texas change colour with it, because $N$, the shared error and the state effects shift every race at once. Each race also has its own noise. Inside each block the seats are in order of their chance, so the first tiles of the blue block are the Democrats' least likely wins, and most of them are pale: narrow upsets. Between them, the shared shift and the noise of each race make the seat distribution wide.
7.1 If the Democrats fall short of their polls
Both frozen forecasts centre the shared polling error on zero: the polls are as likely to overstate the Democrats as the Republicans. After 2016, 2020 and 2024 a common worry is that they lean one way, and that Democrats tend to underperform their polls. This section asks what the record says, and how much the 2026 forecast depends on the answer. Both analyses were added on election day, after the freezes, and are not part of either frozen forecast.
gen_err = pollerr[pollerr.generic].set_index("cycle").err
sen_err = pollerr[~pollerr.generic & (pollerr.type_simple == "Sen-G")].groupby("cycle").err.mean()
hist = pd.DataFrame({"generic ballot": gen_err, "Senate, mean of races": sen_err})
hist = hist[hist.index % 2 == 0] # drop the odd-year Senate specials
hist["midterm"] = hist.index.isin(PRES)
print("mean signed error of the last three weeks' polls, D minus R margin (positive: the polls overstated the Democrats)")
print(hist.drop(columns="midterm").round(1).T.to_string())
for lab, sel in (("midterms", hist.midterm), ("presidential years", ~hist.midterm)):
h_ = hist[sel]
print(f"{lab:19s}: generic ballot {h_['generic ballot'].mean():+.1f} on average, overstating the Democrats in "
f"{(h_['generic ballot'] > 0).sum()} of {len(h_)}; Senate {h_['Senate, mean of races'].mean():+.1f}, "
f"{(h_['Senate, mean of races'] > 0).sum()} of {len(h_)}")
MID_GEN = {c: float(hist[(hist.index < c) & hist.midterm]["generic ballot"].mean()) for c in (2018, 2022, 2026)}
print("mean midterm generic-ballot error before each target:", {c: round(v, 2) for c, v in MID_GEN.items()})
b26 = np.quantile(fit26["bias"], [0.05, 0.5, 0.95])
print(f"2026 shared error: prior Normal(0, {PERR[2026]['shared']:.1f}); posterior median {b26[1]:+.1f} "
f"[{b26[0]:+.1f}, {b26[2]:+.1f}]; P(error > {MID_GEN[2026]:.1f}) = {(fit26['bias'] > MID_GEN[2026]).mean():.2f}")
mean signed error of the last three weeks' polls, D minus R margin (positive: the polls overstated the Democrats)
cycle 1998 2000 2002 2004 2006 2008 2010 2012 2014 2016 2018 2020 2022
generic ballot 2.8 2.3 5.3 4.6 6.3 -1.9 -0.3 -0.8 3.2 3.6 -1.2 4.9 2.3
Senate, mean of races -2.8 -2.5 1.2 2.4 -2.6 -1.3 -1.2 -3.3 3.1 5.0 0.5 6.4 1.0
midterms : generic ballot +2.6 on average, overstating the Democrats in 5 of 7; Senate -0.1, 4 of 7
presidential years : generic ballot +2.1 on average, overstating the Democrats in 4 of 6; Senate +1.1, 3 of 6
mean midterm generic-ballot error before each target: {2018: 3.45, 2022: 2.68, 2026: 2.63}
2026 shared error: prior Normal(0, 3.3); posterior median +1.2 [-3.7, +5.8]; P(error > 2.6) = 0.30For national polls, the record supports the worry. In their last three weeks, generic-ballot polls overstated the Democrats in 9 of the 13 cycles since 1998: in 5 of 7 midterms, by 2.6 points on average, and in 4 of 6 presidential years, by 2.1. Senate polls show no such lean in midterms (-0.1 on average, 4 of 7) and a modest one in presidential years (+1.1). The errors are large and change sign from cycle to cycle. 2018 understated the Democrats by 1.2 points and 2006 overstated them by 6.3. With seven midterms, the mean of 2.6 has a standard error of about 1 point.
The model already leans that way a little. Its posterior for the 2026 shared error has a median of +1.2 points (90% interval -3.7 to +5.8), because the midterm prior (D+4.3) is less Democratic than the polls (about D+9). The model splits the gap between them, and the part it gives to the polls is read as polling error. It puts a 30% chance on an error larger than the midterm average of 2.6.
shortfalls = np.arange(-2, 8.01, 0.25)
rows = []
for dl in shortfalls:
s_ = seats(fit26["d"], fit26["sim"] - dl) # every race dl points less Democratic, in every draw
sen_ = s_["S"] + notup["Democratic"] + notup["Independent"]
rows.append({"shortfall": dl, "P(D House)": (s_["H"] >= 218).mean(), "P(D Senate)": (sen_ >= 51).mean(),
**{f"H q{int(100 * p_)}": np.quantile(s_["H"], p_) for p_ in (0.05, 0.5, 0.95)},
**{f"S q{int(100 * p_)}": np.quantile(sen_, p_) for p_ in (0.05, 0.5, 0.95)}})
sweep = pd.DataFrame(rows)
even = {k: float(np.interp(0.5, sweep[k].to_numpy()[::-1], sweep.shortfall.to_numpy()[::-1]))
for k in ("P(D House)", "P(D Senate)")} # P falls as the shortfall grows
print(sweep[sweep.shortfall.isin(np.arange(-1, 7))].round(2).to_string(index=False))
print(f"shortfall at which control is a coin flip: House {even['P(D House)']:.1f} points, "
f"Senate {even['P(D Senate)']:.1f} points")
h0, h3 = (sweep.loc[sweep.shortfall == v, "H q50"].iat[0] for v in (0, 3))
print(f"median House seats: {h0:.0f} with no shortfall, {h3:.0f} with 3 points ({(h0 - h3) / 3:.1f} seats per point)")
fig, axes = plt.subplots(2, 2, figsize=(13, 7.6))
ax = axes[0, 0]
sd26 = PERR[2026]["shared"]
ax.axhspan(-1.645 * sd26, 1.645 * sd26, color=D_C, alpha=0.08, label=f"the model's prior: 90% of Normal(0, {sd26:.1f})")
ax.bar(hist.index, hist["generic ballot"], width=1.5, color=np.where(hist.midterm, GOLD, MUTED), alpha=0.85)
ax.scatter(hist.index, hist["Senate, mean of races"], color=INK, s=14, zorder=5, label="Senate polls, mean of races")
ax.axhline(0, color=INK, lw=0.6)
ax.axhline(MID_GEN[2026], color=GOLD, lw=1, ls="--", label=f"midterm generic average, {MID_GEN[2026]:+.1f}")
ax.set_ylabel("polls minus result, D margin\n(+: overstated the Democrats)")
ax.set_title("polling error by cycle (bars: generic ballot; gold: midterms)", fontsize=10)
ax.legend(fontsize=7, loc="lower left")
ax = axes[0, 1]
for k, col, lab in (("P(D House)", D_C, "House"), ("P(D Senate)", GOLD, "Senate")):
ax.plot(sweep.shortfall, sweep[k], color=col, lw=2, label=f"{lab}: a coin flip at {even[k]:.1f} points")
ax.plot([even[k]], [0.5], "o", color=col, ms=7)
for v, lab in ((MID_GEN[2026], "midterm\naverage"), (hist[hist.midterm]["generic ballot"].max(), "largest\n(2006)")):
ax.axvline(v, color=MUTED, lw=0.8, ls=":")
ax.text(v, 0.03, f" {lab}", fontsize=7, color=MUTED)
ax.axhline(0.5, color=INK, lw=0.6, ls=":")
ax.set_ylim(0, 1)
ax.set_ylabel("P(D control)")
ax.set_title("chance of control against the shortfall", fontsize=10)
ax.legend(fontsize=8, loc="lower left")
for ax, off, line, lab in ((axes[1, 0], "H", 217.5, "House D seats"), (axes[1, 1], "S", 50.5, "Senate D-caucus seats")):
sh = np.c_[[seats(fit26["d"], fit26["sim"] - dl)[off] for dl in shortfalls]].astype(float)
if off == "S":
sh += notup["Democratic"] + notup["Independent"]
q_ = np.quantile(sh, [0.05, 0.25, 0.5, 0.75, 0.95], axis=1)
col = D_C if off == "H" else GOLD
ax.fill_between(shortfalls, q_[0], q_[4], color=col, alpha=0.15, step="mid", label="90% of draws")
ax.fill_between(shortfalls, q_[1], q_[3], color=col, alpha=0.35, step="mid", label="50%")
ax.step(shortfalls, q_[2], where="mid", color=col, lw=2, label="median")
ax.axhline(line, color=INK, lw=0.8, ls=":")
ax.text(shortfalls[-1], line, " control", fontsize=7, va="bottom", ha="right", color=INK)
ax.axvline(MID_GEN[2026], color=MUTED, lw=0.8, ls=":")
ax.set_ylabel(lab)
ax.yaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_title(f"{lab} against the shortfall", fontsize=10)
ax.legend(fontsize=7, loc="upper right")
for ax in axes[1]:
ax.set_xlabel("Democratic shortfall in every race (points)")
axes[0, 1].set_xlabel("Democratic shortfall in every race (points)")
fig.tight_layout();
shortfall P(D House) P(D Senate) H q5 H q50 H q95 S q5 S q50 S q95
-1.0 0.97 0.87 220.00 241.0 267.0 49.0 53.0 57.0
0.0 0.94 0.79 217.00 237.0 262.0 48.0 52.0 56.0
1.0 0.90 0.70 213.95 233.0 257.0 48.0 52.0 56.0
2.0 0.84 0.60 211.00 228.0 252.0 47.0 51.0 55.0
3.0 0.74 0.49 208.00 225.0 247.0 47.0 50.0 54.0
4.0 0.62 0.38 205.00 221.0 242.0 46.0 50.0 54.0
5.0 0.49 0.27 203.00 217.0 237.0 45.0 49.0 53.0
6.0 0.36 0.19 200.00 214.0 233.0 45.0 49.0 52.0
shortfall at which control is a coin flip: House 4.9 points, Senate 2.9 points
median House seats: 237 with no shortfall, 225 with 3 points (4.0 seats per point)
The simplest test subtracts a fixed shortfall from every race in every draw. The result is what the forecast would say if the Democrats did $\delta$ points worse everywhere than the model expects, which is what an extra shared polling error of $\delta$ would do.
The House holds up. Each point of shortfall costs about 4 seats at the median (237 seats becomes 225 at 3 points). The chance of a Democratic majority falls from 0.94 to 0.84 at 2 points and 0.74 at 3, and becomes a coin flip only at 4.9 points, nearly twice the midterm average and close to the misses of 2002 and 2006. The Senate is fragile. Its median sits one seat above control, so a small shift moves the deciding races in Iowa, Ohio and Alaska: 0.79 becomes 0.60 at 2 points, and control is a coin flip at 2.9, about the size of an average midterm miss. If Democrats fall short of their polls by the historical average, the House stays likely Democratic (about 0.8) and the Senate is a toss-up.
The bottom panels show the spread around those medians. With no shortfall, the House's 90% band already reaches down to 217 seats, and at 3 points the bottom of its 50% band reaches the majority line. The Senate's 50% band straddles control from a shortfall of about half a point.
A uniform shift is the hardest case for the forecast, because every race moves in the same direction. A real polling error also varies from race to race, and the model already carries that part, with an sd of about 5.7 points.
t0 = time.time()
SHIFTED = {c: fit_cycle(c, bias_mean=MID_GEN[c]) for c in (2018, 2022, 2026)}
TIMES["shifted-prior fits"] = time.time() - t0
iv3 = lambda v, fmt: "{1:{f}} [{0:{f}}, {2:{f}}]".format(*np.quantile(v, [0.05, 0.5, 0.95]), f=fmt) # noqa: E731
rows = []
for c in (2018, 2022, 2026):
for lab, f in (("mean 0 (as frozen)", FITS[c, "full"] if c < 2026 else fit26),
(f"mean {MID_GEN[c]:+.1f}", SHIFTED[c])):
row = {"cycle": c, "shared-error prior": lab, "div.": f["divergences"], "max r_hat": f["max r_hat"],
"national": iv3(f["N"], "+.1f"), "House D": iv3(f["seats"]["H"], ".0f"),
"P(D House)": round(float((f["seats"]["H"] >= 218).mean()), 2)}
if c < 2026:
sc, _, _ = race_scores(f["sim"], f["d"]["tm"].m.to_numpy())
row.update({"actual N": round(MARGIN[c], 1),
"actual House": int((f["d"]["tgt"].query("office == 'H'").winner == "D").sum()),
"cover 90%": round(sc["cover 90%"], 3), "Brier": round(sc["Brier"], 4),
"log score": round(sc["log score"], 3), "wrong calls": sc["wrong calls"]})
else:
sen_ = f["seats"]["S"] + notup["Democratic"] + notup["Independent"]
row.update({"Senate D": iv3(sen_, ".0f"), "P(D Senate)": round(float((sen_ >= 51).mean()), 2)})
rows.append(row)
print(pd.DataFrame(rows).fillna("").to_string(index=False))
print(f"2026: the prior mean moved by {MID_GEN[2026]:+.1f}, the national median by "
f"{np.median(SHIFTED[2026]['N']) - np.median(fit26['N']):+.1f}")
cycle shared-error prior div. max r_hat national House D P(D House) actual N actual House cover 90% Brier log score wrong calls Senate D P(D Senate) 2018 mean 0 (as frozen) 0 1.006 +8.0 [+3.3, +12.6] 222 [203, 244] 0.65 8.8 235.0 0.934 0.0421 -0.133 26.0 2018 mean +3.5 0 1.006 +5.2 [+0.5, +10.0] 211 [196, 232] 0.29 8.8 235.0 0.882 0.0525 -0.171 34.0 2022 mean 0 (as frozen) 0 1.008 -2.0 [-6.8, +3.0] 204 [184, 222] 0.12 -2.7 213.0 0.924 0.0311 -0.101 21.0 2022 mean +2.7 0 1.007 -4.2 [-9.0, +1.0] 196 [176, 216] 0.04 -2.7 213.0 0.89 0.0383 -0.122 27.0 2026 mean 0 (as frozen) 0 1.011 +7.8 [+3.1, +12.7] 237 [217, 262] 0.94 52 [48, 56] 0.79 2026 mean +2.6 0 1.015 +6.0 [+1.0, +10.7] 229 [211, 251] 0.84 51 [47, 55] 0.61 2026: the prior mean moved by +2.6, the national median by -1.9
Subtracting a shift afterwards assumes the whole shortfall happens. To say "polls tend to overstate the Democrats" in the model itself, move the prior mean of the shared error to the historical midterm average and refit. As always, the average uses only midterms before the target: +3.5 for 2018, +2.7 for 2022 and +2.6 for 2026. The backtests can then judge the idea.
In 2026 the national median moves by 1.9 points, not by 2.6. The polls fix $N + b$, and the midterm prior on $N$ pulls back, so the shift is shared in proportion to the prior variances: $2.6 \times 6.3^2/(6.3^2 + 3.3^2) \approx 2.0$. The refitted forecast gives 229 House seats (211 to 251) with $P = 0.84$, and 51 Senate seats (47 to 55) with $P = 0.61$.
The backtests reject the shifted prior. In 2018 it puts the national margin at D+5.2 against an actual D+8.8 and the House at 211 seats against 235, and gives a Democratic House a 0.29 chance (they won it). Its race scores are worse on every measure: 88% coverage of the 90% intervals against 93%, a Brier score of 0.053 against 0.042, and 34 wrong calls against 26. In 2022 it says R+4.2 against an actual R+2.7 and 196 seats against 213, and again every score is worse. That year the generic polls did overstate the Democrats, by 2.3 points, but the midterm prior had already pulled the model's national estimate below the polls, and the shifted prior pulled it too far. In both backtests the Democrats did better than the unshifted model expected, not worse: the same lean as the PITs and the wrong calls in section 4.
One refit answers the question for one prior mean. A whole curve needs no more sampling. The prior mean of the shared error $b$ enters the model only through the prior density of $b$, so the posterior under a prior $\mathcal N(\mu, \sigma_b)$ is the mean-0 posterior multiplied by the ratio of the two priors:
$$ w(b) \propto \frac{\mathcal N(b;\, \mu, \sigma_b)}{\mathcal N(b;\, 0, \sigma_b)} = \exp\!\Big(\frac{\mu b - \mu^2/2}{\sigma_b^2}\Big). $$
Each draw of the mean-0 fit gets the weight $w$ for its own $b$, and every quantity (the national margin, seats, chances of control, the scores of the backtest races) becomes a weighted average over the same draws. This is exact importance sampling, not an approximation of the model. Its only limit is Monte Carlo: when $\mu$ is far from 0, a few draws carry most of the weight. The effective sample size $1/\sum w^2$ measures that, and the curves stop where it falls below 200 of the 4,000 draws. The refits above are the check.
def prior_weights(f, c, mu):
"""Importance weights that move the shared error's prior mean from 0 to mu (the only prior that changes)."""
lw = (mu * f["bias"] - mu**2 / 2) / PERR[c]["shared"] ** 2
w = np.exp(lw - lw.max())
return w / w.sum()
def wq(x, w, qs):
"""Weighted quantiles of draws x."""
o = np.argsort(x, kind="stable")
cw = np.cumsum(w[o])
return np.interp(qs, (cw - 0.5 * w[o]) / cw[-1], x[o])
def log_score(sim, y, w):
"""Mean log probability of each race's actual winner, from weighted draws."""
pD = w @ (sim > 0)
return float(np.mean(np.log(np.clip(np.where(y > 0, pD, 1 - pD), 0.5 / len(w), 1))))
def reweighted(f, c, mu):
w = prior_weights(f, c, mu)
H = f["seats"]["H"].astype(float)
out = {"ESS": 1 / np.sum(w**2), "N q50": wq(f["N"], w, 0.5),
**dict(zip(("H q5", "H q25", "H q50", "H q75", "H q95"), wq(H, w, [0.05, 0.25, 0.5, 0.75, 0.95]))),
"P(D House)": float(w @ (H >= 218))}
if c < 2026:
out["log score"] = log_score(f["sim"], f["d"]["tm"].m.to_numpy(), w)
else:
sen_ = (f["seats"]["S"] + notup["Democratic"] + notup["Independent"]).astype(float)
out.update({"P(D Senate)": float(w @ (sen_ >= 51)),
**dict(zip(("S q5", "S q25", "S q50", "S q75", "S q95"), wq(sen_, w, [0.05, 0.25, 0.5, 0.75, 0.95])))})
return out
MU = np.arange(-5, 7.01, 0.25)
BASE = {c: FITS[c, "full"] if c < 2026 else fit26 for c in (2018, 2022, 2026)}
RW = {c: pd.DataFrame([{"mu": mu, **reweighted(BASE[c], c, mu)} for mu in MU]) for c in BASE}
RW = {c: t_[t_.ESS >= 200].reset_index(drop=True) for c, t_ in RW.items()} # too few effective draws beyond
print("prior means kept (ESS of the weights >= 200):", {c: (t_.mu.min(), t_.mu.max()) for c, t_ in RW.items()})
rows = []
for c, f in BASE.items():
r_ = reweighted(f, c, MID_GEN[c])
g_ = SHIFTED[c]
rows.append({"cycle": c, "prior mean": round(MID_GEN[c], 2), "ESS of the weights": int(r_["ESS"]),
"N median: reweighted": round(r_["N q50"], 2), "refit": round(float(np.median(g_["N"])), 2),
"House median: reweighted": r_["H q50"], "refit ": float(np.median(g_["seats"]["H"])),
"P(D House): reweighted": round(r_["P(D House)"], 3), "refit ": round(float((g_["seats"]["H"] >= 218).mean()), 3)})
print("check: reweighting the mean-0 fit against refitting with the shifted prior")
print(pd.DataFrame(rows).to_string(index=False))
for c in (2018, 2022):
t_ = RW[c]
best = t_.loc[t_["log score"].idxmax()]
print(f"{c}: race log score {t_.loc[t_.mu == 0, 'log score'].iat[0]:.4f} at prior mean 0, best "
f"{best['log score']:.4f} at {best.mu:+.2f}")
t_ = RW[2026]
for k_ in ("P(D House)", "P(D Senate)"):
print(f"2026 {k_}: " + ", ".join(f"{t_.loc[t_.mu == m, k_].iat[0]:.2f} at {m:+.0f}" for m in (-2, 0, 2, 4, 6)
if (t_.mu == m).any()))
prior means kept (ESS of the weights >= 200): {2018: (np.float64(-5.0), np.float64(7.0)), 2022: (np.float64(-5.0), np.float64(5.0)), 2026: (np.float64(-5.0), np.float64(5.75))}
check: reweighting the mean-0 fit against refitting with the shifted prior
cycle prior mean ESS of the weights N median: reweighted refit House median: reweighted refit P(D House): reweighted refit
2018 3.45 909 5.43 5.22 211.0 211.0 0.315 0.287
2022 2.68 1178 -4.22 -4.23 196.0 196.0 0.033 0.038
2026 2.63 1215 5.97 5.97 229.0 229.0 0.820 0.836
2018: race log score -0.1325 at prior mean 0, best -0.1202 at -3.50
2022: race log score -0.1007 at prior mean 0, best -0.0910 at -3.25
2026 P(D House): 0.98 at -2, 0.94 at +0, 0.86 at +2, 0.72 at +4
2026 P(D Senate): 0.89 at -2, 0.79 at +0, 0.65 at +2, 0.49 at +4fig, axes = plt.subplots(3, 3, figsize=(14, 10.8))
for j, c in enumerate((2018, 2022, 2026)):
f0, f1, t_, mh = BASE[c], SHIFTED[c], RW[c], MID_GEN[c]
ax = axes[0, j]
lo_, hi_ = np.quantile(np.r_[f0["N"], f1["N"]], [0.001, 0.999])
xg = np.linspace(lo_ - 1, hi_ + 1, 300)
d0 = gaussian_kde(f0["N"])(xg)
ax.fill_between(xg, d0, color=D_C, alpha=0.25, label="prior mean 0 (as frozen)")
ax.plot(xg, gaussian_kde(f1["N"])(xg), color=R_C, lw=1.8, label=f"refit, prior mean {mh:+.1f}")
ax.plot(xg, gaussian_kde(f0["N"], weights=prior_weights(f0, c, mh))(xg), color=INK, ls="--", lw=1.2,
label="mean-0 fit, reweighted")
for v_, col in ((f0["N"], D_C), (f1["N"], R_C)):
q5, q95 = np.quantile(v_, [0.05, 0.95])
ax.plot([q5, q95], [-0.012 if col == D_C else -0.024] * 2, color=col, lw=3, solid_capstyle="butt")
ax.set_ylim(-0.035, None)
if c < 2026:
ax.axvline(MARGIN[c], color=INK, lw=2)
ax.text(MARGIN[c], ax.get_ylim()[1] * 0.9, " result", fontsize=8)
ax.set_title(f"{c}: national margin N (bars: 90% intervals)", fontsize=10)
ax.set_xlabel("D minus R, points", fontsize=9)
ax.set_yticks([])
ax = axes[1, j]
ax.fill_between(t_.mu, t_["H q5"], t_["H q95"], color=D_C, alpha=0.15, label="90%")
ax.fill_between(t_.mu, t_["H q25"], t_["H q75"], color=D_C, alpha=0.35, label="50%")
ax.plot(t_.mu, t_["H q50"], color=D_C, lw=2, label="median (reweighted)")
qh = np.quantile(f1["seats"]["H"], [0.05, 0.5, 0.95])
ax.errorbar([mh], [qh[1]], yerr=[[qh[1] - qh[0]], [qh[2] - qh[1]]], fmt="D", color=R_C, capsize=3,
label="refit, median and 90%")
ax.axhline(217.5, color=INK, lw=0.6, ls=":")
if c < 2026:
act = int((f0["d"]["tgt"].query("office == 'H'").winner == "D").sum())
ax.axhline(act, color=INK, lw=2)
ax.text(MU[0], act, f" won: {act}", fontsize=8, va="bottom")
ax.axvline(0, color=MUTED, lw=0.8, ls=":")
ax.set_title(f"{c}: House D seats against the prior mean", fontsize=10)
ax = axes[2, j]
if c < 2026:
ax.plot(t_.mu, t_["log score"], color=INK, lw=2)
b_ = t_.loc[t_["log score"].idxmax()]
ax.plot([b_.mu], [b_["log score"]], "o", color=INK)
ax.text(b_.mu, b_["log score"], f" best at {b_.mu:+.1f}", fontsize=8, va="bottom")
y_ = f0["d"]["tm"].m.to_numpy()
ax.plot([mh], [log_score(f1["sim"], y_, np.full(len(f1["sim"]), 1 / len(f1["sim"])))], "D", color=R_C,
label="refit")
ax.set_title(f"{c}: how well the races were predicted", fontsize=10)
else:
for k_, col, lab in (("P(D House)", D_C, "House"), ("P(D Senate)", GOLD, "Senate")):
ax.plot(t_.mu, t_[k_], color=col, lw=2, label=f"{lab} (reweighted)")
ax.plot([mh], [(f1["seats"]["H"] >= 218).mean()], "D", color=D_C, mec=R_C, label="refits")
ax.plot([mh], [((f1["seats"]["S"] + notup["Democratic"] + notup["Independent"]) >= 51).mean()], "D",
color=GOLD, mec=R_C)
ax.axhline(0.5, color=INK, lw=0.6, ls=":")
ax.set_ylim(0, 1)
ax.set_ylabel("P(D control)", fontsize=9)
ax.set_title("2026: chance of control against the prior mean", fontsize=10)
ax.axvline(0, color=MUTED, lw=0.8, ls=":")
ax.axvline(mh, color=GOLD, lw=1, ls="--")
ax.legend(fontsize=7)
for ax in axes[1:].ravel():
ax.set_xlabel("prior mean of the shared polling error", fontsize=9)
for ax in axes[:, 0]:
ax.yaxis.label.set_size(9)
axes[2, 0].set_ylabel("race log score (higher is better)", fontsize=9)
axes[0, 2].legend(fontsize=7, loc="upper left")
axes[1, 0].legend(fontsize=7, loc="lower left")
fig.suptitle("Sensitivity to the prior mean of the shared polling error (+: the polls overstate the Democrats; "
"gold dashes: the midterm average before each cycle)", fontsize=10)
fig.tight_layout();

The check passes. At the historical prior means the reweighted draws reproduce the refits: the same House medians (211, 196 and 229), national medians within 0.2 points and chances of a Democratic House within 0.03, using 900 to 1,200 effective draws. In the top row the dashed reweighted density lies on the refit's red one. Both sit about 2 points to the left of the frozen forecast, and the 90% bars under them are as wide as before: the prior mean moves the forecast without making it more certain.
The middle row shows the whole response. In every cycle the House median falls by about 3 seats per point of prior mean, a little less than the 4 seats of a uniform shortfall, because part of each point goes to the polling error instead of to $N$. The 90% band stays 40 to 45 seats wide wherever it is drawn. The seats the Democrats won in 2018 fall outside it once the prior mean passes about 3 points, and in 2022 they sit near its top.
The bottom row gives the verdict of the backtests. The race log score is a smooth curve with its best value at a prior mean of about -3.5 in 2018 and -3.2 in 2022, which says the polls understated the Democrats, the opposite of the worry. Two cycles are a small sample, and in 2022 the generic polls did overstate the Democrats. What the curve measures is the whole model: its midterm prior and fundamentals already expected less than the Democrats got in both years, and a negative prior mean is how this experiment pays that back. For 2026 the chance of a Democratic House stays above 0.7 for any prior mean up to +4, while the Senate drops below 0.5 at about +4.
So the tendency is real in the generic-ballot record, but it is small next to the swings from year to year. Part of it is already absorbed by a midterm prior that is less enthusiastic than the polls, and two backtests give no support for building in more. The question that matters on election night is how large a shortfall would change the answer: about 5 points for the House and about 3 for the Senate.
7.2 The spending nowcast
late26 = predict_late(LATE_FIT["both -> 2026"], CT[2026])
ie26, ie26_seen = ie_nowcast(IE_FIT["both -> 2026"], 2026)
known26 = CT[2026].disb_known.sum()
cand26 = known26 + late26.sum(1)
all26 = cand26 + ie26
f3 = lambda v: f"{np.quantile(v, 0.5) / 1e9:.2f} [{np.quantile(v, 0.05) / 1e9:.2f}, {np.quantile(v, 0.95) / 1e9:.2f}]" # noqa: E731
g3 = lambda v: f"{v[1] / 1e9:.2f} [{v[0] / 1e9:.2f}, {v[2] / 1e9:.2f}]" # noqa: E731
fs1, fs = FROZEN1["spending_nowcast"], FROZEN["spending_nowcast"]
nc = pd.DataFrame({
"v1 (pre-registered)": [f"{fs1['candidates_known_to_12G'] / 1e9:.2f}", g3(fs1["candidates_late_q05_q50_q95"]),
g3(fs1["candidates_total_q05_q50_q95"]),
f"{fs1['ie_seen_by_eve'] / 1e9:.2f} + {fs1['ie_unseen_estimate'] / 1e6:.0f}M (point)",
g3(fs1["all_q05_q50_q95"]) + " (no IE spread)", fs1["n_candidates"]],
"v2 (corrected)": [f"{fs['candidates_known_to_12G'] / 1e9:.2f}", g3(fs["candidates_late_q05_q50_q95"]),
g3(fs["candidates_total_q05_q50_q95"]),
f"{fs['ie_seen_by_eve'] / 1e9:.2f} + {fs['ie_unseen_q05_q50_q95'][1] / 1e6:.0f}M "
f"[{fs['ie_unseen_q05_q50_q95'][0] / 1e6:.0f}, {fs['ie_unseen_q05_q50_q95'][2] / 1e6:.0f}]",
g3(fs["all_q05_q50_q95"]), fs["n_candidates"]],
"this run": [f"{known26 / 1e9:.2f}", f3(late26.sum(1)), f3(cand26),
f"{ie26_seen / 1e9:.2f} + {np.median(ie26 - ie26_seen) / 1e6:.0f}M "
f"[{np.quantile(ie26 - ie26_seen, 0.05) / 1e6:.0f}, {np.quantile(ie26 - ie26_seen, 0.95) / 1e6:.0f}]",
f3(all26), len(CT[2026])]},
index=["nominees, filed by the eve ($B)", "nominees, late ($B)", "nominees, total ($B)",
"independent expenditures ($B)", "all ($B)", "nominees in the table"])
print("2026 spending nowcast: median [90%], through the post-general coverage (2026-11-23) for nominees")
print(nc.to_string())
2026 spending nowcast: median [90%], through the post-general coverage (2026-11-23) for nominees
v1 (pre-registered) v2 (corrected) this run
nominees, filed by the eve ($B) 2.39 2.41 2.41
nominees, late ($B) 0.61 [0.40, 0.94] 0.64 [0.42, 0.97] 0.64 [0.42, 0.97]
nominees, total ($B) 3.00 [2.79, 3.33] 3.05 [2.82, 3.38] 3.05 [2.82, 3.38]
independent expenditures ($B) 3.17 + 22M (point) 3.17 + 49M [20, 124] 3.17 + 49M [20, 124]
all ($B) 6.20 [5.99, 6.52] (no IE spread) 6.28 [6.04, 6.62] 6.28 [6.04, 6.62]
nominees in the table 860 863 863In the corrected freeze, nominees had filed \$2.41B by the eve, and their late spending through 23 November is nowcast at \$0.64B (90% interval 0.42 to 0.97), for a nominee total of \$3.05B (2.82 to 3.38). Outside groups had filed \$3.17B, plus an estimated \$49M not yet on file (20 to 124). All together, \$6.28B (6.04 to 6.62), against about \$4.4B in 2022. v1 said \$6.20B (5.99 to 6.52), for three fewer nominees, with a point estimate for the outside part, so its interval leaves out the IE uncertainty. The interval is set almost entirely by the candidates' late spending, and as section 6 says, the backtests over-predicted that part in both cycles.
The 2026 outside total rests partly on a few very large filings, among them a Texas group's media buys of \$20-28M each, split between support for one Senate nominee and opposition to the other in the same filing. The items are distinct in the file and add up to the spender's own total, but whether a contrast ad was counted on both sides cannot be checked from the filings.
races26, draws26 = race_totals(2026, late26, CT[2026], ie26)
ord_ = np.argsort(-np.median(draws26, 0))[:20]
f26 = ie[(ie.cycle == 2026) & (ie.version == "eve")]
fig, axes = plt.subplots(1, 2, figsize=(13, 5), width_ratios=[1.4, 1])
ax = axes[0]
for k, i in enumerate(ord_):
rc = races26[i]
known_c = CT[2026].disb_known[CT[2026].race == rc].sum() / 1e6
ie_c = f26.amt[f26.race == rc].sum() / 1e6
lo, mid, hi = np.quantile(draws26[:, i], [0.05, 0.5, 0.95]) / 1e6
ax.barh(k, known_c, color=INK, alpha=0.8, label="nominees, filed" if k == 0 else None)
ax.barh(k, ie_c, left=known_c, color=GOLD, alpha=0.8, label="outside (IE), filed" if k == 0 else None)
ax.errorbar(mid, k, xerr=[[mid - lo], [hi - mid]], fmt="|", color=D_C, ms=12, capsize=3,
label="nowcast total, 90%" if k == 0 else None)
ax.set_yticks(range(len(ord_)), [races26[i] for i in ord_], fontsize=8)
ax.invert_yaxis()
ax.set_xlabel("$M")
ax.legend(fontsize=8, loc="lower right")
ax.set_title("2026: the 20 costliest races, filed by the eve and nowcast", fontsize=10)
ax = axes[1]
elec = pd.Timestamp(ELEC[2026])
w = spend_window(2026)
days, kn = cumulative(w, elec)
last = SPEND[2026]["curve_known_last"]
ax.plot(days[(days <= last) & (days > -120)], kn[(days <= last) & (days > -120)] / 1e9, color=INK, lw=2, label="filed by the eve")
# the tail drawn as the 2022 shape of the late period, scaled to each draw's late total
w22 = spend_window(2022)
d22, f22 = cumulative(w22, pd.Timestamp(ELEC[2022]))
last22 = SPEND[2022]["curve_known_last"]
tail = (f22 - f22[d22 == last22]) / (f22[-1] - f22[d22 == last22])
sel = d22 >= last22
qs = np.quantile(late26.sum(1), [0.05, 0.5, 0.95])
for qv, ls in zip(qs, (":", "-", ":")):
ax.plot(d22[sel], (kn[days == last] + qv * tail[sel]) / 1e9, color=D_C, ls=ls, lw=1.5)
ax.axvline(0, color=INK, lw=0.6)
ax.set_xlabel("days from election day")
ax.set_ylabel("nominees' cumulative disbursements ($B)")
ax.set_title("2026 nominees: the curve so far and the nowcast tail (median, 90%)", fontsize=10);

The costliest races are Senate races, led by Texas, Ohio and Michigan, where outside money (gold) dwarfs what the nominees spent. The right panel draws the nowcast tail with the shape of 2022's last weeks.
8 · Scoring 2026, later
A frozen forecast is worth something only if it is scored the same way whatever happens. The scorer takes a frozen file and a table of results (race and margin, with the seat totals and the national margin attached). It reports the 80% coverage of race margins, the Brier score of the race chances and the wrong calls; whether the seat totals and the national margin fall in their frozen 90% intervals; the Brier and log scores of the chances of House and Senate control; and whether the nominees' 30G spending falls in its frozen interval. It runs first on the 2018 and 2022 backtests, written in the same format.
def score_frozen(frozen, results, nominee_late=None):
"""Score a frozen forecast dict against results.
results: a DataFrame with race and margin (D minus R, points); its attrs may hold "House seats" (D seats),
"Senate seats" (on the same definition as the frozen file: for 2026 the D caucus including the 34 seats
not up; for the backtests the seats up only) and "N" (national House margin). nominee_late: the
nominees' 30G disbursements in dollars."""
fz_ = pd.DataFrame(frozen["races"]).merge(results, on="race")
y = fz_.margin.to_numpy()
out = {"races scored": len(fz_),
"80% cover": float(np.mean((y >= fz_.q10) & (y <= fz_.q90))),
"Brier": float(np.mean((fz_.pD - (y > 0)) ** 2)),
"wrong calls": int(np.sum((fz_.pD > 0.5) != (y > 0))),
"wrong calls, 0.2 < P(D) < 0.8": int(np.sum(((fz_.pD > 0.5) != (y > 0)) & (np.abs(fz_.pD - 0.5) < 0.3)))}
for off, key, need in (("H", "House", 218), ("S", "Senate", 51)):
if f"{key} seats" in results.attrs:
seats_ = results.attrs[f"{key} seats"]
lo, _, hi = frozen["seats"][off]["D_seats_q05_q50_q95"]
out[f"{key} seats in 90%"] = bool(lo <= seats_ <= hi)
p = frozen["seats"][off].get("P_D_control")
if p is not None and (off == "H" or results.attrs.get("Senate counts all seats")):
won = float(seats_ >= need)
out[f"{key} control: Brier"] = round((p - won) ** 2, 4)
out[f"{key} control: log score"] = round(float(np.log(max(p if won else 1 - p, 1e-6))), 3)
if "N" in results.attrs:
lo, _, hi = frozen["seats"]["N_q05_q50_q95"]
out["national margin in 90%"] = bool(lo <= results.attrs["N"] <= hi)
if nominee_late is not None:
lo, _, hi = frozen["spending_nowcast"]["candidates_late_q05_q50_q95"]
out["late spending in 90%"] = bool(lo <= nominee_late <= hi)
return out
def as_frozen(f, cycle):
"""Write a backtest fit in the frozen file's format, so the same scorer runs on 2018 and 2022."""
tm = f["d"]["tm"]
q10, med, q90 = np.quantile(f["sim"], [0.1, 0.5, 0.9], 0)
return {"races": pd.DataFrame({"race": tm.race, "office": tm.office, "pD": (f["sim"] > 0).mean(0), "pred": med,
"q10": q10, "q90": q90}).to_dict("records"),
"seats": {"H": {"D_seats_q05_q50_q95": list(np.quantile(f["seats"]["H"], [0.05, 0.5, 0.95])),
"P_D_control": float((f["seats"]["H"] >= 218).mean())},
"S": {"D_seats_q05_q50_q95": list(np.quantile(f["seats"]["S"], [0.05, 0.5, 0.95]))},
"N_q05_q50_q95": list(np.quantile(f["N"], [0.05, 0.5, 0.95]))}}
for c in (2018, 2022):
f = FITS[c, "full"]
res = f["d"]["tm"][["race"]].assign(margin=f["d"]["tm"].m.to_numpy())
res.attrs = {"House seats": int((f["d"]["tgt"].query("office == 'H'").winner == "D").sum()),
"Senate seats": int((f["d"]["tgt"].query("office == 'S'").winner == "D").sum()), "N": MARGIN[c]}
print(c, score_frozen(as_frozen(f, c), res))
2018 {'races scored': 424, '80% cover': 0.8537735849056604, 'Brier': 0.042137985849056606, 'wrong calls': 26, 'wrong calls, 0.2 < P(D) < 0.8': 18, 'House seats in 90%': True, 'House control: Brier': 0.1225, 'House control: log score': -0.431, 'Senate seats in 90%': True, 'national margin in 90%': True}
2022 {'races scored': 436, '80% cover': 0.8325688073394495, 'Brier': 0.031116297018348622, 'wrong calls': 21, 'wrong calls, 0.2 < P(D) < 0.8': 18, 'House seats in 90%': True, 'House control: Brier': 0.0146, 'House control: log score': -0.129, 'Senate seats in 90%': True, 'national margin in 90%': True}It reproduces the backtest: 80% intervals covered 85% and 83% of races, 18 wrong calls each year among races given a 20-80% chance, every seat total and national margin inside its 90% interval, and a Brier score for House control of 0.12 in 2018 and 0.01 in 2022. In the backtests the Senate count is the seats up, which says nothing about control, so only the House control is scored there.
For 2026 the cell scores v1 and v2 side by side once two files exist: e94_results_2026.csv (race,
margin) and e94_results_2026_totals.json with "House seats", "Senate seats" (the Democratic caucus
including the 34 seats not up, as in the frozen files) and "N". The late-spending check needs
e94_30g_2026.csv from the 30G reports due on 3 December; it sums v2's nominees, three more than v1's.
RESULTS_2026 = data.DATA_DIR / "e94_results_2026.csv" # race, margin: does not exist on 2026-11-03
TOTALS_2026 = data.DATA_DIR / "e94_results_2026_totals.json" # House seats, Senate seats (D caucus incl. not up), N
LATE_2026 = data.DATA_DIR / "e94_30g_2026.csv" # cand_id, disb: post-general reports due 2026-12-03
if RESULTS_2026.exists() and TOTALS_2026.exists():
res26 = pd.read_csv(RESULTS_2026)
res26.attrs = {**json.load(open(TOTALS_2026)), "Senate counts all seats": True}
late_true = None
if LATE_2026.exists():
g30 = pd.read_csv(LATE_2026)
late_true = float(g30[g30.cand_id.isin(FROZEN["spending_nowcast"]["late_per_candidate_q05_q50_q95"])].disb.sum())
for lab, fz_ in (("v1 (pre-registered)", FROZEN1), ("v2 (corrected)", FROZEN)):
print(f"2026 {lab}:", score_frozen(fz_, res26, late_true))
else:
print("2026 election results: not yet available. Both frozen forecasts wait to be scored.")
if not LATE_2026.exists():
print("2026 post-general (30G) reports: not yet available (due 2026-12-03).")
TIMES["total"] = time.time() - T_START
print({k: round(v) for k, v in TIMES.items()})
2026 election results: not yet available. Both frozen forecasts wait to be scored.
2026 post-general (30G) reports: not yet available (due 2026-12-03).
{'2026 fits': 47, 'backtest fits': 71, 'rematch fit': 3, 'IE lag fits': 7, 'late-spending fits': 12, 'shifted-prior fits': 58, 'total': 221}Summary
- Information is ragged. In 2026, 97 of 416 contested House districts have a race poll, 42 of them only partisan ones, and 30 of 70 close districts have none. Fundamentals carry the rest.
- The national environment came from a midterm prior updated by a weekly walk of generic polls with house effects and a shared polling error. Sampled directly, the margin and the shared error formed a ridge (ESS about 300); rotated as in E40, ESS 5,500.
- The backtests held. Learned on earlier cycles only, the full model's 90% intervals covered 93% and 92% of race margins, and its seat intervals contained the results (222 [203, 244] against 235 House seats in 2018, 204 [184, 222] against 213 in 2022). Both cycles put the Democrats a few seats short.
- Money is a signal, not a lever. The naive regression shows Jacobson's paradox because money follows close races. In 232 rematches, doubling the Democrat's spending is worth about +0.7 points of share: a small effect, and if anything an upper bound.
- Spending can be nowcast, with care. Outside money is 99% on file by the eve; candidates' last five weeks (about a fifth of their spending) arrive a month later. A late-spending model from the 12G burn rate and cash on hand covered 96% of race totals. On national totals the evidence is thin: two totals, both over-predicted (\$481M against \$434M, \$623M against \$443M), only one of them a forward test, with the error family and the cycle shift chosen after the pilot had seen them.
- 2026 is frozen twice and waits. The corrected freeze (v2): D+7.8 nationally (3.0 to 12.9), 236 Democratic House seats (217 to 262, P = 0.94), 52 Democratic-caucus Senate seats (48 to 56, P = 0.78), and \$6.28B of spending (6.04 to 6.62), \$0.64B of it nominees' late spending not yet reported. The pre-registered v1 said D+7.9, 236 seats (P = 0.95), 52 seats (P = 0.83) and \$6.20B; it counted nominees without a matched FEC ID as raising nothing. Both were committed before the polls closed, and the scoring code prints "not yet available" until the results are in.
- The Senate depends more than the House on whether the polls lean Democratic. Generic-ballot polls overstated the Democrats in 5 of 7 midterms since 1998, by 2.6 points on average. With a uniform shortfall, House control becomes a coin flip at 4.9 points and Senate control at 2.9. Building the average into the prior moves 2026 to 229 seats ($P = 0.84$) and Senate $P = 0.61$. Reweighting one fit by the ratio of priors traces every prior mean at once, matches the refits, and shows both backtests preferring a prior mean near -3: the Democrats beat this model in 2018 and 2022.
Try it yourself
- Thin the 2026 snapshots. Keep one snapshot per aggregator every other week, or one average of all aggregators per week, and refit 2026. How much does the national interval widen, and how many House seats does the median move? Then do the same to the aggregate-only backtests and check whether the wider version is better calibrated on 2018 and 2022.
- A district poll model of its own. Partisan polls are few in 2018 but many in 2026. Give polls with a D or R mark their own excess noise as well as a sponsor shift, and let the sponsor shift vary by party. Does the 2022 PIT (which leaned high) flatten, and how much do the chances in the 30 closest 2026 races change?
- The whole spending curve. Replace the late-spending model by a hierarchical growth curve for each race over the cycle (for example a log-logistic cumulative curve per race whose midpoint and final total pool by office and competitiveness), fitted to every report received by the eve. Does it beat the tail model on the 2018 and 2022 backtests, and does its interval for 2026 agree?