Part 3 · Prediction

How the model predicts a seat

Here we detail the actual electoral predictor itself: vote flows, simulated uncertainty, and a tactical-voting system, all to produce some hopefully-defensible seat-by-seat forecasts

August 2026 · 18 min read

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_flows
Output
Row sums (should all be 100):
{'Labour': 100, 'Conservative': 100, 'Reform': 100, 'LibDem': 100, 'Green': 100, 'Oth': 100, 'SNP': 100, 'Plaid': 100, 'Restore': 100}
Output
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)
Output
{'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]
Output
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"]
Output
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)
Output
{'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")
Output
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)
Output
Vectorized: all 632 seats x 10,000 sims in 4.49s (loop-based estimate was ~173s -> 39x faster)
Output
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
Output
{'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"]]
Output
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)}")
flipped
Output
Seats where tactical voting changes the projected winner: 33 of 632
Output
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)))
Output
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"]]
Output
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.