1. The pipeline, end to end
Four scripts run in sequence (pipeline/scripts/run_all.py), each dutifully reading the
previous stage's output:
| Stage | Script | Turns... | ...into |
|---|---|---|---|
| 1 | 01_allocate_tribes.py |
a seat's raw party votes | 7 group-by-party allocations (the groups themselves covered in Part 2) |
| 2 | 02_project_flows.py |
group allocations + flow matrices | a projected next-election vote share per seat |
| 3 | 04_tactical_voting.py |
projected vote shares | tactically-adjusted vote shares |
| 4 | 05_export_svg_output.py |
tactical vote shares | raw vote counts + winner, ready for the map |
One stage is conspicuously missing from that list: 03_monte_carlo.py. It reads projected_results.csv and writes seat_probabilities.csv, but it isn't one of
run_all.py's four STAGES, and nothing downstream reads what it produces. This is a
deliberate cut, not an oversight, on the entirely defensible grounds that it's 10,000
simulations run as a Python-level loop for every one of 632 seats, and Section 3 below
measures exactly how long that takes: long enough that it's impracticle to run inside a web
app's request cycle. So it survives as a standalone, manually-run probabilistic intresting alternative instead. Worth knowing going in, because it means today's map is built from one single
deterministic run per seat, with uncertainty estimated separately, if at all, rather than
shipped alongside every prediction.
Two more scripts, 07_export_alloc.py and 08_export_brexit.py, feed the web app's
interactive "Custom Predictor" tab and a historical Brexit-referendum overlay respectively.
Both are useful - and outside the prediction pipeline itself and so will recieve no further mention.
import numpy as np
import pandas as pd
from pathlib import Path
PIPELINE = Path("../../pipeline")2. Stage 2: projecting flows
This is the "flow table" idea promised earlier in the series, now that we've got around to implenting it. {england,scotland,wales}Flows.xlsx each hold 7 sheets, one per group
(Muslim, Left, Progressives, Average, Liberal, Blues, Reforms), and each sheet is a 9×9
matrix, rows and columns both Labour, Conservative, Reform, LibDem, Green, Oth, SNP, Plaid,
Restore, where every row sums to 100. Row i, column j answers one very specific question:
of this group's 2019 voters who backed party i, what percent now back party j?
average_flows = pd.read_excel(PIPELINE / "data/raw/englandFlows.xlsx", sheet_name="Average", index_col=0)
parties = ["Labour", "Conservative", "Reform", "LibDem", "Green", "Oth", "SNP", "Plaid", "Restore"]
average_flows = average_flows.loc[parties, parties]
print("Row sums (should all be 100):")
print(average_flows.sum(axis=1).to_dict())
average_flowsRow sums (should all be 100):
{'Labour': 100, 'Conservative': 100, 'Reform': 100, 'LibDem': 100, 'Green': 100, 'Oth': 100, 'SNP': 100, 'Plaid': 100, 'Restore': 100}
| Labour | Conservative | Reform | LibDem | Green | Oth | SNP | Plaid | Restore | |
|---|---|---|---|---|---|---|---|---|---|
| Labour | 68 | 2 | 28 | 0 | 2 | 0 | 0 | 0 | 0 |
| Conservative | 3 | 50 | 47 | 0 | 0 | 0 | 0 | 0 | 0 |
| Reform | 0 | 0 | 100 | 0 | 0 | 0 | 0 | 0 | 0 |
| LibDem | 0 | 10 | 13 | 67 | 10 | 0 | 0 | 0 | 0 |
| Green | 0 | 0 | 0 | 0 | 100 | 0 | 0 | 0 | 0 |
| Oth | 20 | 0 | 20 | 0 | 10 | 50 | 0 | 0 | 0 |
| SNP | 0 | 0 | 0 | 0 | 0 | 0 | 100 | 0 | 0 |
| Plaid | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 100 | 0 |
| Restore | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 100 |
Reading a couple of rows: within the 'Average' group specifically, Labour voters stay 68% Labour, with 2% leaking off to the Conservatives, 28% to Reform, and 2% to the Greens, which is a fairly on-the-nose summary of the last few years of British politics in one table row.
The projection formula, for one (seat, group) row from the group-allocation table, looks like this:
def project_row(voters_by_party: dict, flow_matrix: pd.DataFrame) -> dict:
"""voters_by_party: this group's current vote total per party, in one seat.
Returns the projected vote total per party after applying the group's flow matrix."""
projected = {p: 0.0 for p in flow_matrix.columns}
for old_party, voters in voters_by_party.items():
if voters == 0 or old_party not in flow_matrix.index:
continue
for new_party in flow_matrix.columns:
projected[new_party] += voters * flow_matrix.loc[old_party, new_party] / 100
return projected
# worked example: a hypothetical Average-group allocation of 1,000 voters in one seat
example_alloc = {"Labour": 600, "Conservative": 0, "Reform": 0, "LibDem": 200,
"Green": 100, "Oth": 100, "SNP": 0, "Plaid": 0, "Restore": 0}
project_row(example_alloc, average_flows){'Labour': np.float64(428.0),
'Conservative': np.float64(32.0),
'Reform': np.float64(214.0),
'LibDem': np.float64(134.0),
'Green': np.float64(142.0),
'Oth': np.float64(50.0),
'SNP': np.float64(0.0),
'Plaid': np.float64(0.0),
'Restore': np.float64(0.0)}Every group's projected row gets computed this way, independently, then summed back across
all 7 groups within a seat (groupby("Seat").sum()) to arrive at that seat's single
projected vote share per party. The seven group-specific stories are only ever visible before
that summing happens; the output file ({nation}Projection.csv, then concatenated into
projected_results.csv, 632 rows) only ever shows you the combined result, which is a shame,
because the group-level detail is genuinely the more interesting bit.
How the actual percentages get set: fitting flows to the national polling average, not the other way around
I should confess up front: none of the numbers in any of these matrices come from a regression. They're set, and re-set, by hand, against a target that's the whole point of Part 2's core methodological claim, which is that this pipeline runs the fit in the opposite direction from MRP. MRP fits a model to individual survey respondents first and accepts whatever national and seat-level numbers happen to fall out of that fit as the answer; this pipeline starts from the current national polling average per party as the fixed target, and adjusts each group's flow-matrix cells until the seven groups' combined, seat-weighted projection reproduces it. The national number is the constraint being satisfied; the flow-matrix cells are simply what gets moved to satisfy it.
Average's Labour row above (68% stay, 28% to Reform, 2% to Conservative, 2% to Green) is one instance of that in action: 28% is the figure that, combined with the other six groups' own Labour rows, makes the model's total projected Reform vote share land on the actual national polling average for Reform, chosen because a Reform surge built mostly from demographically unremarkable Labour switchers is also, independently, the most plausible read of who's actually driving Reform's rise nationally. The two justifications, "this is what the topline needs" and "this is who's plausibly moving," aren't fighting each other; the whole calibration exercise is just finding the cells where they happen to agree.
That still leaves an alarming number of degrees of freedom per group open: a 9×9 matrix has 72 off-diagonal cells before you've even looked at one specific group's electorate, and nowhere near enough real transition data exists to pin all of them down independently for seven separate electorates, so fitting to the topline alone would under-determine them badly. What actually narrows it down is the same discipline Part 2 spent nine sections building: only flows that are plausible for that specific group's actual electorate get populated at all, and "plausible" is deliberately different group to group. Compare Average's Labour→Green cell above (2%) against the Left group's own Labour row:
left_flows = pd.read_excel(PIPELINE / "data/raw/englandFlows.xlsx", sheet_name="Left", index_col=0)
left_flows.loc[["Labour"], parties]| Labour | Conservative | Reform | LibDem | Green | Oth | SNP | Plaid | Restore | |
|---|---|---|---|---|---|---|---|---|---|
| Labour | 30 | 0 | 0 | 0 | 70 | 0 | 0 | 0 | 0 |
The Left group, Archetype 5's young, secular, graduate electorate, sends 70% of its own Labour vote straight to Green, against Average's measly 2%. Same nominal "Labour voter," two entirely different next moves, because a young secular graduate and a demographically average swing voter simply aren't fishing in the same pond of plausible defections. A Reform voter defecting to Green, or vice versa, isn't a flow this pipeline models for any group, whatever the topline needs, because there's no real electoral mechanism putting a meaningful fraction of either group on that path in three or four years, and the matrices are structurally zero there rather than fit-to-zero.
Average: not a category with its own flows, but the one that closes the gap
One group's matrix gets set differently from the rest, on purpose, because of what Part 2, Section 7 already established about it: Average has no strong distinguishing pull of its own, it's specifically the group of seats and voters that follow wherever the national mood happens to be going. That turns out to be exactly the property this pipeline needs somewhere in the system: once the other six groups' flows are locked in against their own specific, evidenced behaviour (Left's Labour-to-Green defection above, and the equivalent evidenced story for each of Part 2's other groups), something has to mop up whatever's left over so the seven groups' combined total lands exactly on the national polling figure being targeted. Average is the natural place for that to happen, not an arbitrary dumping ground: "the group that follows the national mood" and "the group whose flows get set last, to make the topline balance" are the same description of the same thing, not two separate jobs forced onto one unlucky table. Its flow matrix is consequently the one that moves whenever the national polling snapshot moves, while the other six groups' matrices, set from stable, evidenced behavioural stories rather than from whatever's needed to hit a number, stay comparatively put between one polling update and the next.
projected = pd.read_csv(PIPELINE / "data/intermediate/projected_results.csv")
projected[projected["Seat"] == "Aldershot"]| Seat | Labour | Conservative | Reform | LibDem | Green | Oth | SNP | Plaid | Restore | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Aldershot | 29.277097 | 27.348099 | 24.494846 | 6.909929 | 7.702929 | 0.51 | 0.0 | 0.0 | 3.6571 |
This is exactly where Part 2's evidence-based group structure slots in, unchanged: one sheet per data-derived group (Muslim, Progressives, Liberals etc), each estimated from the actual 2019→2024 swing observed in the seats that group dominates.
3. Stage 3 (unused in the automated run): Monte Carlo uncertainty
03_monte_carlo.py is the stage I mentioned earlier that was cut due to running time concerns. It turns one seat's projected vote shares into win probabilities by simulation: 10,000
draws per seat, each party's draw independently Normal with standard deviation set to 30% of
that party's own projected mean (so a party projected at 40% gets ±12pts of simulated noise,
a party at 2% gets ±0.6pts), floored at zero, renormalised back to sum to 100, with the
largest draw declared the winner of that particular imaginary election.
def simulate_seat(means: dict, n_sims: int = 10_000, std_frac: float = 0.30, seed: int | None = None) -> dict:
rng = np.random.default_rng(seed)
parties = list(means.keys())
mean_arr = np.array([means[p] for p in parties])
std_arr = mean_arr * std_frac
wins = {p: 0 for p in parties}
for _ in range(n_sims):
draw = np.clip(rng.normal(mean_arr, std_arr), 0, None)
total = draw.sum()
if total == 0:
continue
draw = draw / total * 100
wins[parties[int(np.argmax(draw))]] += 1
return {p: v / n_sims for p, v in wins.items()}
aldershot = projected[projected["Seat"] == "Aldershot"].iloc[0]
seat_parties = ["Labour", "Conservative", "Reform", "LibDem", "Green", "Oth", "SNP", "Plaid", "Restore"]
means = {p: aldershot[p] for p in seat_parties}
simulate_seat(means, seed=0){'Labour': 0.4545,
'Conservative': 0.3432,
'Reform': 0.2023,
'LibDem': 0.0,
'Green': 0.0,
'Oth': 0.0,
'SNP': 0.0,
'Plaid': 0.0,
'Restore': 0.0}That's the intended mechanism, and it's a genuinely sensible one: proportional noise means a close race stays properly uncertain while a 50-point landslide essentially never flips.
Why it's cut: the loop, not the concept
The 10,000-simulations-per-seat idea was never the problem; the Python-level loop
implementing it was. Timing the script's actual structure (a for loop over seats, a nested
for loop over simulations, one np.random.normal call per simulation) against all 632 seats
confirms exactly how much of a problem:
import time
def time_current_implementation(df, party_list, n_seats=None, n_sims=10_000):
subset = df.head(n_seats) if n_seats else df
t0 = time.time()
for _, row in subset.iterrows():
means = np.array([row[p] for p in party_list], dtype=float)
stds = means * 0.30
wins = {p: 0 for p in party_list}
for _ in range(n_sims):
draw = np.clip(np.random.normal(means, stds), 0, None)
total = draw.sum()
if total == 0:
continue
draw = draw / total * 100
wins[party_list[int(np.argmax(draw))]] += 1
return time.time() - t0
sample_seats = 20
elapsed = time_current_implementation(projected, seat_parties, n_seats=sample_seats)
per_seat = elapsed / sample_seats
print(f"{sample_seats} seats: {elapsed:.2f}s -> {per_seat*1000:.0f}ms/seat -> "
f"~{per_seat*len(projected):.0f}s projected for all {len(projected)} seats")20 seats: 5.47s -> 273ms/seat -> ~173s projected for all 632 seats
Roughly a minute and a half for a full run, needless to say an entirely unacceptable one to wait for a website, hence why this stage
stays a manually-triggered side script rather than a proper run_all.py stage. The good news
is that the slowness is an artifact of writing the simulation as nested Python loops, not of
the underlying statistics being expensive: every seat's simulations are independent of every
other seat's, and every simulation within a seat is independent of every other simulation,
which is exactly the shape NumPy is built to batch in one go rather than loop through by
hand.
def simulate_all_seats_vectorized(df: pd.DataFrame, party_list: list, n_sims: int = 10_000,
std_frac: float = 0.30, seed: int | None = None) -> pd.DataFrame:
rng = np.random.default_rng(seed)
means = df[party_list].to_numpy(dtype=float) # (n_seats, n_parties)
stds = means * std_frac
# one draw for every (seat, simulation, party) combination at once
draws = rng.normal(means[:, None, :], stds[:, None, :], size=(len(df), n_sims, len(party_list)))
draws = np.clip(draws, 0, None)
totals = draws.sum(axis=2, keepdims=True)
totals[totals == 0] = 1.0
draws = draws / totals * 100
winner_idx = draws.argmax(axis=2) # (n_seats, n_sims)
win_probs = np.stack([(winner_idx == k).mean(axis=1) for k in range(len(party_list))], axis=1)
out = pd.DataFrame(win_probs, columns=[f"{p}_Prob" for p in party_list])
out.insert(0, "Seat", df["Seat"].values)
out["PredictedWinner"] = out[[f"{p}_Prob" for p in party_list]].idxmax(axis=1).str.replace("_Prob", "", regex=False)
return out
t0 = time.time()
vectorized_result = simulate_all_seats_vectorized(projected, seat_parties, seed=0)
vector_elapsed = time.time() - t0
elapsed_est = per_seat * len(projected)
print(f"Vectorized: all {len(projected)} seats x 10,000 sims in {vector_elapsed:.2f}s "
f"(loop-based estimate was ~{elapsed_est:.0f}s -> {elapsed_est/vector_elapsed:.0f}x faster)")
vectorized_result.head(3)Vectorized: all 632 seats x 10,000 sims in 4.49s (loop-based estimate was ~173s -> 39x faster)
| Seat | Labour_Prob | Conservative_Prob | Reform_Prob | LibDem_Prob | Green_Prob | Oth_Prob | SNP_Prob | Plaid_Prob | Restore_Prob | PredictedWinner | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Aldershot | 0.4545 | 0.3432 | 0.2023 | 0.0 | 0.0000 | 0.0 | 0.0 | 0.0 | 0.0 | Labour |
| 1 | Aldridge-Brownhills | 0.0551 | 0.5120 | 0.4329 | 0.0 | 0.0000 | 0.0 | 0.0 | 0.0 | 0.0 | Conservative |
| 2 | Altrincham and Sale West | 0.3890 | 0.5982 | 0.0127 | 0.0 | 0.0001 | 0.0 | 0.0 | 0.0 | 0.0 | Conservative |
Same statistics, roughly 40x faster, purely from replacing two Python loops with one batched
array operation, which comfortably fits inside a web request's budget at the cost of holding
one (632, 10000, 9) array in memory at a time (a few hundred megabytes, trivially choppable
into seat-batches if that ever becomes a problem). This is the concrete version of "wire it
in": not "run the existing script more often," but "rewrite the inner loop as a NumPy batch op
first, then wire it in." The cut was the right call against the code as it stood at the time.
4. Stage 3.5: tactical voting, and the winnability problem
National tactical-voting polling, the kind YouGov's tracker of the tactical voting landscape publishes regularly, measures a genuinely useful thing, but a simplified one: it asks hypothetical two-party questions ("if only the Conservatives or Reform UK stood a chance of winning in their seat, voters would favour the Tories by 31% to 24%"). That's a tidy way to isolate one behavioural number, but a real constituency is essentially never actually a clean two-horse race in the way the survey question politely pretends: Eastleigh has four parties within twenty points of each other, not two. Applying a single national two-party number uniformly to all 632 seats would misfire everywhere a third or fourth party is genuinely alive and kicking.
04_tactical_voting.py solves this with what's effectively a winnability system: instead
of asking survey respondents to imagine a two-horse race that may not exist, it works out, per
seat, from that seat's own projected vote shares, which parties are actually plausible
contenders there, and only lets tactical votes flow toward those.
Tiering: turning a vote-share gap into a viability tier
For each seat, every party's tier is set by its gap to the projected leader:
def tier_seat(votes: dict, incumbent: str | None) -> dict:
leader = max(votes, key=votes.get)
leader_share = votes[leader]
tier = {}
for party, share in votes.items():
gap = leader_share - share
if gap <= 5:
tier[party] = 1
elif gap <= 10:
tier[party] = 2
elif gap <= 15:
tier[party] = 3
# gap > 15: no tier at all - not a viable destination, though it can still donate
if incumbent in votes:
tier[incumbent] = 1 # the sitting party is always treated as viable, whatever the swing implies
return tier
tier_seat(means, incumbent=None) # Aldershot, from Section 2{'Labour': 1, 'Conservative': 1, 'Reform': 1}Tier 1 means within 5 points of the leader, or the incumbent regardless of projected gap (a deliberate hedge: a sitting MP's personal vote and local machine routinely outperform what a pure demographic/flow model would predict, so the model refuses to write them off on projected swing alone). Tier 2 is 5–10 points back. Tier 3 is 10–15 points back. Beyond 15 points, a party isn't a valid tactical destination at all, though it can still be a donor: its own supporters can still be talked into defecting elsewhere, they just can't receive defectors of their own.
Two damping tables scale how much actually moves. TIER_STRENGTH discounts votes arriving
at a longer-shot destination (100% / 50% / 25% for tier 1/2/3, on the sensible grounds that a
vote nominally willing to go tactical is still less likely to actually convert into a paper
vote for a distant third place). DONOR_TVPCT_MULT discounts how much a semi-viable donor
is willing to send away at all (40% / 80% for a tier-2 / tier-3 donor, full rate for a fully
non-viable one), on the intuition that a donor still in realistic contention has less reason
to lend its vote elsewhere than a donor with nothing left to lose.
The numbers behind it: calibrated against YouGov's tracker
The actual per-party numbers come from Tactical.xlsx's TVPCT sheet: each party's overall
willingness to consider a tactical vote at all (TVPct), and its personal appeal to each
possible destination (independent percentages, not a forced 100%-split), set to track
YouGov's Feb 2026 tactical voting tracker
cited above, and it tracks closely: Labour voters' 70% appeal toward Lib Dem/Green as the
anti-Reform option (YouGov: 76–77%), and Reform voters' 40% appeal toward Conservative
(YouGov: 43–45%):
tvpct = pd.read_excel(PIPELINE / "data/raw/Tactical.xlsx", sheet_name="TVPCT")
tvpct = tvpct.rename(columns={tvpct.columns[0]: "Donor"}).set_index("Donor")
tvpct[["TVPct", "Labour", "Conservative", "Reform", "LibDem", "Green"]]| TVPct | Labour | Conservative | Reform | LibDem | Green | |
|---|---|---|---|---|---|---|
| Donor | ||||||
| Labour | 41 | NaN | 30.0 | 10.0 | 70.0 | 70.0 |
| Conservative | 45 | 10.0 | NaN | 39.0 | 30.0 | 10.0 |
| Reform | 38 | NaN | 40.0 | NaN | 14.0 | NaN |
| Lib Dem | 42 | 45.0 | 35.0 | NaN | NaN | 50.0 |
| Green | 24 | 24.0 | NaN | NaN | 60.0 | NaN |
| SNP | 0 | NaN | NaN | NaN | NaN | NaN |
| Plaid | 10 | 20.0 | 5.0 | 1.0 | 20.0 | 20.0 |
| Oth | 40 | 60.0 | 20.0 | 20.0 | 20.0 | 60.0 |
| Restore | 0 | NaN | NaN | NaN | NaN | NaN |
Right, but does it actually change any outcomes?
tactical = pd.read_csv(PIPELINE / "data/intermediate/projected_results_tactical.csv")
pre_leader = projected.set_index("Seat")[seat_parties].idxmax(axis=1).rename("pre-tactical leader")
post_leader = tactical.set_index("Seat")[seat_parties].idxmax(axis=1).rename("post-tactical leader")
leaders = pd.concat([pre_leader, post_leader], axis=1)
flipped = leaders[leaders["pre-tactical leader"] != leaders["post-tactical leader"]]
print(f"Seats where tactical voting changes the projected winner: {len(flipped)} of {len(leaders)}")
flippedSeats where tactical voting changes the projected winner: 33 of 632
| pre-tactical leader | post-tactical leader | |
|---|---|---|
| Seat | ||
| Bradford West | Oth | Labour |
| Brent West | Conservative | Labour |
| Congleton | Conservative | Labour |
| Doncaster Central | Labour | Reform |
| Droitwich and Evesham | Reform | Conservative |
| Earley and Woodley | Labour | Conservative |
| East Hampshire | Conservative | LibDem |
| Eastleigh | Reform | LibDem |
| Ely and East Cambridgeshire | Conservative | LibDem |
| Farnham and Bordon | Conservative | LibDem |
| Faversham and Mid Kent | Reform | Conservative |
| Frome and East Somerset | Reform | LibDem |
| Gravesham | Conservative | Labour |
| Halesowen | Reform | Labour |
| Hamble Valley | Reform | Conservative |
| Hertford and Stortford | Conservative | Labour |
| High Peak | Labour | Reform |
| Keighley and Ilkley | Conservative | Labour |
| Mid Derbyshire | Reform | Conservative |
| North East Somerset and Hanham | Reform | Labour |
| North West Hampshire | Reform | Conservative |
| Rochester and Strood | Reform | Conservative |
| St Helens South and Whiston | Reform | Labour |
| Stafford | Reform | Labour |
| Sutton and Cheam | Conservative | LibDem |
| Swindon North | Reform | Labour |
| Tipton and Wednesbury | Reform | Labour |
| Wigan | Reform | Labour |
| Dumfriesshire, Clydesdale and Tweeddale | SNP | Conservative |
| Edinburgh North and Leith | SNP | Labour |
| Glasgow West | SNP | Labour |
| Gordon and Buchan | SNP | Conservative |
| Gower | Plaid | Labour |
33 of 632 seats. Not a rounding error: a genuinely material adjustment, concentrated, as you'd expect, in seats that were already close multi-way races before tactical voting got anywhere near them.
A worked example, and a real limitation the winnability framing runs into
Eastleigh is a good illustration precisely because it's an intresting seat - a very close Lib Dem - Conservative marginal, with a significant Labour vote and strong Reform potetial.
for label, df in [("Pre-tactical", projected), ("Post-tactical", tactical)]:
row = df[df["Seat"] == "Eastleigh"][seat_parties].iloc[0]
print(label, "-", dict(row.round(1)))Pre-tactical - {'Labour': np.float64(11.9), 'Conservative': np.float64(20.4), 'Reform': np.float64(28.9), 'LibDem': np.float64(26.0), 'Green': np.float64(8.6), 'Oth': np.float64(0.8), 'SNP': np.float64(0.0), 'Plaid': np.float64(0.0), 'Restore': np.float64(3.4)}
Post-tactical - {'Labour': np.float64(8.1), 'Conservative': np.float64(18.5), 'Reform': np.float64(30.6), 'LibDem': np.float64(31.3), 'Green': np.float64(7.4), 'Oth': np.float64(0.7), 'SNP': np.float64(0.0), 'Plaid': np.float64(0.0), 'Restore': np.float64(3.4)}
Lib Dem overtakes Reform for the lead here (26.0% → 31.3%, against Reform's 28.9% → 30.6%): the model correctly identifies Lib Dem as the natural tactical home for anti-Reform sentiment in a seat where Labour (11.9%, an untiered donor) never had much of a chance itself.
5. Stage 4: turning percentages back into a map
The final step (05_export_svg_output.py) does two things. First, four specific seats,
Great Yarmouth, Makerfield, Aberdeen South, Gorton and Denton, get a bespoke flow matrix
from LocalFlows.xlsx instead of the generic group-based projection, applied with exactly the
same row-stochastic mechanism as Section 2. Every other seat passes through unchanged from the
tactical-adjusted file.
These four aren't a guess dressed up as a matrix, which feels worth stating plainly, since it's precisely the sort of thing I'd otherwise be accused of. Each of them (bar Great Yarnmouth, where the Reform MP created a new party Restore, local elections results suggest would handily win Great Yarnmouth) had a by-election during the current parliament, which is about as good as evidence gets for a single seat: a real, recent vote, not a demographic inference about how a seat should behave. Each seat's override is calibrated from the gap between what actually happened in that by-election and what the general model, built from that seat's group composition and the polling at the time, would have projected for it. That gap becomes the seat's own flow matrix: a direct, local correction where direct, local evidence exists, rather than an extrapolation from national or regional patterns. It's a small number of seats for the entirely mundane reason that by-elections are rare; every other seat still runs on the general model because no seat-specific evidence like this exists for it yet.
Second, projected percentages become projected raw vote counts, by multiplying against
prev_TOTAL, that seat's total valid votes at the previous election:
vote_totals = pd.read_excel(PIPELINE / "data/raw/Tactical.xlsx", sheet_name="VoteTotals")
vote_totals[vote_totals["Seat"] == "Eastleigh"][["Seat", "prev_Electorate", "prev_TOTAL"]]| Seat | prev_Electorate | prev_TOTAL | |
|---|---|---|---|
| 195 | Eastleigh | 70015 | 46420 |
There's no separate turnout model here at all. Turnout is implicitly assumed identical to last time, seat by seat. That's a defensible simplification (turnout is genuinely hard to project, and errors here are usually smaller in seat-share terms than errors in who those voters actually pick), but it's a real, load-bearing assumption all the same: a seat with an unusually mobilised or demobilised electorate next time around, plausible for exactly the kind of Muslim-vote and Reform-curious seats Part 2 spent most of its time on, would have its vote counts (though not its shares) systematically off by however much turnout actually moved.
6. Where this leaves things
It's hard to test the accuracy of the model given that it would require a general election, the event of which would make the model redundant. But since the electoral segments used to model each constituency can be calculated using census data, there is a way to test all this via council by elections (which happen quite frequently). We can calculate the segment breakdown of any given ward, run it through the model, and test its validity against the council by election result. Though it should be said not all of these wards are having a by election with a baseline of the 2024 national mood, and of course, as local by elections, national politics may not be the definining feature of them. Nonetheless it's something to consider, and the topic of a future blog.