1. The problem with giving a constituency one label
Every UK election model has to solve the same problem eventually: you know how a constituency voted last time, you know roughly how the national mood has shifted, and you need to turn that into a vote share for this seat. The classic tool for that is uniform national swing (UNS): take the national swing from Party A to Party B and apply it evenly to every seat, on the reasonable-sounding assumption that the whole country is basically one seat wearing 650 different hats.
A more sophisticated real-world approach exists too, and it's the one this post's own method has the most in common with: MRP (multilevel regression and post-stratification), the technique behind YouGov's constituency-by-constituency projections since 2017, the year it correctly called a hung parliament while nearly every UNS-based model didn't. MRP fits a model of individual vote intention from a national survey, broken down by respondents' demographic and geographic traits (the "multilevel regression" half), then re-weights those modelled preferences for every seat by that seat's actual population count in each demographic cell (the "post-stratification" half) to produce a constituency estimate. Strip away the survey machinery and that second step is the same move this post is about to make: take a small set of reusable group-level preferences and blend them per seat according to how much of each group is actually present locally, rather than assuming the seat is one thing. The difference lies in where the groups and their preferences come from. MRP estimates both from a national survey's individual responses, while this project estimates the groups themselves from unsupervised structure in constituency-level census data, with no survey at all. Even so, the underlying logic, a local blend of shared types rather than one label per seat, is the same idea this post arrives at from a different direction.
MRP's real limitation isn't that it collapses to one number per seat: it's two things bundled together. First, a published MRP result is almost always just the final post-stratified estimate; the demographic-cell-level preferences the model fits on the way there, which group leans which way and by how much, get used internally and then thrown away, never reported as a first-class output in their own right the way this post's archetypes and groups are about to be. Second, and this is the one that actually bites: those cell-level preferences come from fitting a multilevel model against one specific, expensively-fielded national survey. That's fine for a single snapshot, but there's no cheap way to re-estimate a group's preferences every time the national mood shifts, because doing that properly means commissioning a fresh national survey and refitting the whole model each time. A method that can only be run economically once isn't well-suited to tracking how national opinion moves between one snapshot and the next, which is exactly the thing a live election model needs to keep doing. This project's structure sidesteps that by splitting the same two ingredients apart on purpose. A seat's group composition comes from the census (stable, survey-free, and barely changing between elections, which is what Section 4 onward is entirely about), while each group's own political preferences are a separate, far cheaper input to update: an ordinary poll or a fresh flow assumption, not a fresh multilevel model, that can move with the national mood without ever re-deriving what's actually inside each seat.
There's a second inversion on top of that split, and it's arguably the more fundamental one: which direction the fit actually runs. MRP fits inward, from the survey out: it regresses individual survey respondents' stated preferences against their demographic and geographic traits, and whatever national and seat-level totals fall out of that fitted model are simply accepted as the answer. The survey is ground truth, and the group-level breakdown is a byproduct of fitting it. This project's pipeline (covered in full in the next post) runs the fit the other way around: it starts from an evidenced hypothesis of how each latent group is actually moving (the Muslim-to-independent swing, the stable Jewish/diaspora Conservative floor, and so on, argued case-by-case in Sections 5–9), encodes each one as a group-specific vote-flow assumption, and then adjusts those flows until the seats they aggregate up to reproduce the national polling topline that's actually being observed. The group-level flows are fit to the national numbers; the national numbers aren't read off a fit to individual-level polling. MRP treats the survey as the fixed point and the groups as whatever falls out of fitting it; this project treats the groups as the thing under direct scrutiny, and treats matching the current national polling as a constraint those group-level flows have to satisfy, not the object being estimated.
Back to UNS, assuming the national mood shifting meant that it shifted somewhat evenly was a reasonable thing to do in a stable two-party system, where one national vote number told you almost everything anyway. 2024, memorably, was not that system. Part one's own clustering experiment found the same thing from a completely different angle: even the best available hard partition of Britain's 632 seats couldn't agree with itself, let alone with reality, on where the lines between types should fall.
import json
import pandas as pd
from pathlib import Path
DATA = Path("../../web/data/elections")
PARTIES = ["LAB", "CON", "REF", "LD", "GRN", "OTHER", "SNP", "PLAID"]
def national_shares(fname):
records = json.load(open(DATA / fname, encoding="utf-8"))
totals = {p: 0 for p in PARTIES}
grand_total = 0
for r in records:
t = pd.to_numeric(r.get("Total"), errors="coerce")
if pd.isna(t):
continue
grand_total += t
for p in PARTIES:
v = pd.to_numeric(r.get(p), errors="coerce")
if pd.notna(v):
totals[p] += v
return {p: round(v / grand_total * 100, 1) for p, v in totals.items()}
shares_2019 = national_shares("au2021.json") # notional 2019 result on 2024 boundaries
shares_2024 = national_shares("2024.json")
summary = pd.DataFrame({"2019 (notional)": shares_2019, "2024": shares_2024})
summary.loc["Lab+Con (two-party)"] = summary.loc[["LAB", "CON"]].sum()
summary| 2019 (notional) | 2024 | |
|---|---|---|
| LAB | 32.9 | 34.6 |
| CON | 44.7 | 24.4 |
| REF | 2.1 | 14.7 |
| LD | 11.8 | 12.6 |
| GRN | 2.8 | 6.9 |
| OTHER | 1.2 | 3.2 |
| SNP | 4.0 | 2.6 |
| PLAID | 0.5 | 0.7 |
| Lab+Con (two-party) | 77.6 | 59.0 |
That's computed directly from this project's own 632-seat GB dataset (au2021.json /
2024.json), so it will differ slightly from the official UK-wide totals (Great Britain
only, no Northern Ireland), but the shape is unmistakable: the combined Labour+Conservative
vote share fell from 77.6% to 59.0% in one election. Officially, across the whole UK, the
two-party share hit 57.4% (Full Fact;
House of Commons Library, CBP-10009),
the lowest on record. Reform UK took 14.3% of the national vote for 5 seats. Five
pro-Gaza independents, including a former Labour leader, won seats that "should" have been
safe Labour by any category-based model. A seat that used to be well-approximated by "safe
Labour, urban, working-class" might now be some blend of long-time Labour loyalists, a lingering white working class,
Reform-curious minority, a small but decisive bloc of voters motivated primarily by Gaza, and
a handful of Green-curious graduates who moved in during the last decade. None of those groups
is the constituency's "type"; they're all in it, simultaneously, in different proportions
to the seat next door.
That's the empirical case for the rest of this post: if a constituency's electorate is a mixture of a smallish number of recurring voter types, rather than a single type, the right tool isn't a sharper category label. It's something that can express how much of each type is present.
2. From "which bucket" to "how much of which bucket"
Part one's glossary covered what K-means, HAC, DBSCAN and GMM each assume, and if you skipped it, the short version is: they all sort. Worth restating the one thing all four have in common, because it's exactly what needs to change: every one of them, including GMM's "soft" probabilities, answers the question which single cluster does this whole row most plausibly belong to, and how sure are we? A GMM saying "Bethnal Green and Stepney is 60% likely to be in cluster 3, 40% likely to be in cluster 5" is expressing uncertainty about a single assignment, not "60% of this seat's population is one thing and 40% is another." That distinction sounds pedantic until you try to build a vote model on top of it, at which point it's suddenly extremely important, because what we actually want is the second one: a real compositional breakdown that sums to exactly 100% per seat, the same way a census breakdown of ethnicity or age already does for the underlying population. Getting there needs a different family of technique entirely, not a better-tuned member of the family part one already exhausted.
The matrix approach: unmixing instead of sorting
Non-negative Matrix Factorization (NMF) starts from a different question: instead of
"which single group does this row belong to," it asks "can every row be reconstructed as a
non-negative weighted sum of a small number of shared 'parts'?" Concretely, if X is our
632-seats-by-34-census-variables matrix, NMF finds two smaller matrices W (632 × k) and
H (k × 34) such that
X ≈ W @ H
Each row of H is one latent archetype's demographic profile (e.g. "high Rural%, high
Pakistani%, high Graduate%..."). Each row of W is one seat's weights across those archetypes.
Everything in W and H is constrained to be non-negative: archetypes can be added together
but never subtracted, exactly the physical constraint a real population mixture has, since
you can't have -12% of a seat be veterans.
A helpful intuition is that it's like reconstructing every shade in a paint chart from a handful of primary pigments, mixed in different proportions. NMF doesn't need to be told what the primaries are; it finds a set of a few reusable pigments that reconstruct every observed shade about as well as possible, then reports how much of each pigment went into each shade.
One extra step is needed to get all the way to a proper percentage breakdown: NMF's own W
isn't constrained to sum to 1 per row (there's a scale ambiguity between W and H: you can
multiply a column of W by 2 and divide the matching row of H by 2 and get the identical
product). So after NMF finds the archetype profiles (H), each seat's exact mixture weight
is re-solved as a small constrained least-squares problem: find the non-negative weights that
best reconstruct that seat's actual census numbers from the fixed archetype profiles, subject
to the weights summing to 100%. This is a standard trick from hyperspectral image analysis
(unmixing a pixel's spectrum into a blend of known minerals) called Fully Constrained
Least Squares Unmixing (FCLSU), and it's what actually produces "this seat is 62% Segment 3,
25% Segment 1, 13% Segment 5" as a genuine, literal percentage breakdown, not a clustering
confidence score.
Categories are only useful if their members actually vote similarly. If a "category" is secretly two different electorates with two different loyalties bundled together by an oversimplified label, no single description of how it votes can represent it, since the two electorates will just pull in opposite directions in whichever seats mix them in different proportions. That's the test the rest of this post applies to every category it proposes: not just "does this describe the census data well," but "do the seats dominated by this category actually behave like one electorate."
How each category then translates into an actual vote prediction (turnout, swing, tactical voting) is a large enough question to get its own write-up. This one stays focused on the categories themselves: what they are, and what evidence supports each one.
3. The data
Every demographic variable feeding the archetype model comes from the 2021 Census (Office for National Statistics, for England & Wales; National Records of Scotland for the equivalent Scottish figures), at constituency level, with the exception of Public Sector and Rural/Urban classification, which come from other ONS sources:
| Category | Variables |
|---|---|
| Ethnicity | White, White British, White Other, Asian, Indian, Pakistani, Bangladeshi, Chinese, Black, Arab, Mixed, Other |
| Religion | Christian, No Religion, Muslim, Hindu, Jewish, Sikh, Buddhist |
| Age | 0–17, 18–24, 65+ |
| Education | No qualifications, Degree-educated |
| Occupation (NS-SEC) | Public sector, Managerial/Professional, Intermediate, Routine |
| Housing tenure | Social rent, Private rent, Owner-occupied |
| Other | Veterans, LGBT population, Rural/urban classification |
One variable doesn't come from government data at all: Superwealthy, a proxy for the
super-rich/non-domiciled population, isn't something the census asks about. It's sourced from
research using de-identified HMRC tax records: Advani, Burgherr, Savage and Summers (2022),
"Who are the non-doms?", CAGE Policy Briefing No. 36 (University of Warwick / LSE
International Inequalities Institute), which analysed everyone who claimed non-dom tax status
between 1997 and 2018. Its headline constituency-level finding is exactly what this variable
is trying to capture: more than one in ten residents of Kensington, and of Cities of London
and Westminster, had claimed non-dom status at some point, concentrated overwhelmingly in
a small number of very specific, very expensive postcodes, not spread evenly across "affluent"
Britain generally. That concentration, not just "rich" but this specific kind of
international-money rich clustered in a handful of London streets, turns out to matter a
lot in Section 6.
As a supporting proxy, the calculation also incorporates expensive housing sales from the land registry, helping capture the geographic concentration of this wealth where direct population-level data isn't available.
import openpyxl
wb = openpyxl.load_workbook("../../demographics.xlsx", read_only=True, data_only=True)
ws = wb["Demographics"]
rows = list(ws.iter_rows(values_only=True))
header = rows[0]
seat_i, sw_i = header.index("Seat"), header.index("Superwealthy")
superwealthy = sorted(
((r[seat_i], r[sw_i]) for r in rows[1:] if r[seat_i] and isinstance(r[sw_i], (int, float))),
key=lambda t: -t[1],
)
pd.DataFrame(superwealthy[:8], columns=["Seat", "Superwealthy score"])| Seat | Superwealthy score | |
|---|---|---|
| 0 | Cities of London and Westminster | 28.0 |
| 1 | Kensington and Bayswater | 25.6 |
| 2 | Chelsea and Fulham | 20.4 |
| 3 | Hampstead and Highgate | 16.4 |
| 4 | Finchley and Golders Green | 10.4 |
| 5 | Richmond Park | 8.4 |
| 6 | Wimbledon | 8.0 |
| 7 | Hammersmith and Chiswick | 6.8 |
Exactly the two seats the non-dom study flags by name come out on top of this project's own data, a good sign the variable is measuring what it claims to.
4. Running the matrix: five archetypes
NMF was fit once, across all 632 GB constituencies pooled together (England, Scotland and Wales at once, since this step uses no electoral data, there's no reason to fit each nation separately the way an earlier, vote-informed clustering pass might). The number of archetypes, k, was chosen by sweeping NMF's reconstruction R² (how much of the 632×34 census matrix's variance k archetypes can rebuild) across a range of k, then taking the elbow: the point where adding another archetype stops buying much more reconstruction accuracy.
import json
archetypes = json.load(open("../../clustering/output/archetypes.json", encoding="utf-8"))
curve = archetypes["k_search_curve"]
curve_df = pd.DataFrame({
"k": [int(k) for k in curve],
"reconstruction_R2": list(curve.values()),
})
curve_df["gain_over_k-1"] = curve_df["reconstruction_R2"].diff()
curve_df| k | reconstruction_R2 | gain_over_k-1 | |
|---|---|---|---|
| 0 | 2 | 0.4083 | NaN |
| 1 | 3 | 0.5579 | 0.1496 |
| 2 | 4 | 0.6493 | 0.0914 |
| 3 | 5 | 0.6962 | 0.0469 |
| 4 | 6 | 0.7245 | 0.0283 |
| 5 | 7 | 0.7664 | 0.0419 |
| 6 | 8 | 0.7794 | 0.0130 |
| 7 | 9 | 0.8031 | 0.0237 |
| 8 | 10 | 0.8396 | 0.0365 |
| 9 | 11 | 0.8607 | 0.0211 |
| 10 | 12 | 0.8803 | 0.0196 |
| 11 | 13 | 0.8916 | 0.0113 |
Reconstruction R² rises steeply through the first few archetypes (0.41 at k=2, 0.56 at
k=3, 0.65 at k=4), then visibly flattens from k=5 onward (0.70, then 0.72, 0.77, ...):
each additional archetype after the fifth buys a few extra points of reconstruction rather
than the double-digit jumps seen earlier. Locating the point of maximum curvature on that
exact curve (a standard kneedle-style perpendicular-distance-from-the-chord calculation, see
find_elbow in 06_archetype_unmixing.py) picks out k=5 automatically, which is
reassuring, because I would have gone with five anyway and then had to pretend it was
principled.
import json
archetypes = json.load(open("../../clustering/output/archetypes.json", encoding="utf-8"))
print(f"k = {archetypes['k']} | ecological R² (2024 vote, all parties pooled) = {archetypes['ecological_r2_2024']}")
rows = []
for a in archetypes["archetypes"]:
rows.append({
"Archetype": a["archetype"],
"% of GB": a["avg_share_of_gb"],
"Dominant in n seats": a["n_seats_dominant"],
"Distinguishing profile": a["label"],
"Implied 2024 vote": ", ".join(f"{p} {v:.0f}%" for p, v in
sorted(a["implied_vote_2024"].items(), key=lambda kv: -kv[1]) if v > 1),
})
pd.DataFrame(rows)k = 5 | ecological R² (2024 vote, all parties pooled) = 0.295
| Archetype | % of GB | Dominant in n seats | Distinguishing profile | Implied 2024 vote | |
|---|---|---|---|---|---|
| 0 | 1 | 5.59 | 18 | high Asian (z=+9.1); high Age0to17 (z=+8.8); h... | OTHER 37%, LAB 22%, CON 21%, REF 10% |
| 1 | 2 | 37.67 | 310 | high Routine (z=+3.4); low ManagerialProfessio... | LAB 52%, REF 29%, CON 11%, SNP 4%, GRN 4%, OTH... |
| 2 | 3 | 8.52 | 41 | high WhiteOther (z=+5.6); high Superwealthy (z... | LAB 54%, CON 22%, LD 13%, GRN 10%, REF 6% |
| 3 | 4 | 32.74 | 224 | high ManagerialProfessional (z=+4.4); high Own... | CON 51%, LD 35%, REF 8% |
| 4 | 5 | 15.48 | 39 | low Age0to17 (z=-6.2); low Intermediate (z=-6.... | LAB 68%, GRN 30%, SNP 9%, OTHER 3% |
That ecological R² of 0.295 deserves a direct read, not a fly-by: these five archetypes, built from census demographics with zero information about how any seat actually voted, account for a bit under a third of the cross-party variance in 2024 vote shares on their own. That's a real number worth sitting with, and it isn't the R² of the finished model, it's the score for archetype composition alone, before Sections 5–9 do anything with it. Two things keep it from being disqualifying. First, this is a harder test than it looks: a model that gets to see a seat's own electoral history (as UNS does, by starting from the seat's actual last result and applying a swing) starts with far more information than one working from demographics alone, so 0.295 from census data with no electoral history at all is a different, lower bar than a "know what happened last time" model needs to clear. Second, and more to the point: nothing here claims 0.295 is the finished product. It's a diagnostic showing the compositional idea already carries real signal before Sections 5–9 spend the rest of the post deliberately correcting the two archetypes (1 and 3) where that signal is weakest, precisely because a single archetype-level number like 0.295 hides real internal splits (Harrow vs. Bethnal Green; Kensington vs. the rest of Archetype 3) that the following sections dig out by hand. The archetypes are raw material for the group-level scoring formula in Section 9, not a standalone forecasting model being presented as one, however much I'd like to stop here and call it a day.
A brief overview of the archetypes identified:
- Archetype 1 (5.6% of GB): high Asian, Muslim, Pakistani, Indian population, skewed young. Implied vote leans heavily to "Other" (independents), then Labour.
- Archetype 2 (37.7% of GB, the single largest group): high routine occupations, no qualifications, social renting: the classic post-industrial working-class "Red Wall" profile. Implied vote: Labour first, but Reform second at 29%.
- Archetype 3 (8.5% of GB): high "White Other," high
Superwealthy, high ethnic diversity generally: the "international metropolitan" profile. Implied vote: Labour-led, but with significant Conservative, Lib Dem and Green minorities. - Archetype 4 (32.7% of GB): high managerial/professional, owner-occupation, degrees: affluent, professional, and a typical "Blue Wall" profile. Implied vote: Conservative first, Lib Dem a strong second.
- Archetype 5 (15.5% of GB): defined mostly by low scores across the board (low under-18s, low intermediate occupations, low Christian, low owner-occupation): a young, student, rented, graduate profile. Implied vote: Labour first, Green a strong second.
That's a clean, plausible five-way split of Britain's electorate, and also, on inspection, not quite the end of the story. The "implied vote" column above is a single number per archetype; it only tells the truth if every seat dominated by that archetype behaves similarly. As the implied vote shares above already hint, some don't.
5. What's actually inside Archetype 1
Archetype 1's headline profile ("high Asian, high Muslim, high Pakistani, high Indian") looks cohesive as a category at first, for representing Britain's ethnic minorities. NMF found them co-occurring often enough across 632 seats to treat them as one recurring "part," but that's a statement about the census data's correlation structure, not a claim that these communities vote the same way. Let's go and look at the seats where Archetype 1 actually dominates, and see how long the tidy story survives contact with them.
mem = pd.read_csv("../../clustering/output/archetype_membership.csv")
feat = pd.read_csv("../../clustering/output/features_england.csv")
watch_seats = ["Harrow West", "Harrow East", "Brent West","Leicester East", "Leicester South",
"Bethnal Green and Stepney", "Dewsbury and Batley", "Blackburn"]
cols = ["Indian", "Pakistani", "Muslim", "Hindu", "Sikh",
"y2019_CON_share", "y2024_CON_share", "swing_CON", "y2024_OTHER_share", "swing_OTHER"]
merged = mem.merge(feat[["Seat"] + cols], on="Seat", how="left")
view = merged[merged["Seat"].isin(watch_seats)][
["Seat", "Archetype_1", "Archetype_2", "Archetype_5"] + cols
].round(1)
view| Seat | Archetype_1 | Archetype_2 | Archetype_5 | Indian | Pakistani | Muslim | Hindu | Sikh | y2019_CON_share | y2024_CON_share | swing_CON | y2024_OTHER_share | swing_OTHER | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 23 | Bethnal Green and Stepney | 32.8 | 0.0 | 56.4 | 2.2 | 1.1 | 46.6 | 1.3 | 0.3 | 10.1 | 4.1 | -6.0 | 33.7 | 32.8 |
| 39 | Blackburn | 38.7 | 38.3 | 16.0 | 21.4 | 23.5 | 46.8 | 0.4 | 0.1 | 23.3 | 8.9 | -14.4 | 19.4 | 18.7 |
| 60 | Brent West | 40.2 | 3.5 | 22.4 | 35.2 | 4.9 | 17.9 | 28.3 | 0.8 | 33.8 | 32.5 | -1.3 | 6.7 | 6.1 |
| 133 | Dewsbury and Batley | 35.2 | 39.3 | 15.8 | 16.7 | 25.4 | 43.7 | 0.3 | 0.2 | 28.7 | 11.0 | -17.7 | 0.0 | -5.2 |
| 207 | Harrow East | 35.9 | 0.0 | 10.0 | 32.9 | 3.6 | 15.1 | 28.0 | 0.6 | 54.6 | 53.3 | -1.3 | 5.9 | 5.9 |
| 208 | Harrow West | 32.1 | 0.0 | 14.7 | 24.4 | 4.5 | 17.8 | 24.3 | 1.2 | 34.8 | 29.2 | -5.7 | 10.5 | 10.5 |
| 263 | Leicester East | 63.0 | 18.4 | 18.6 | 59.2 | 4.2 | 25.6 | 37.6 | 6.8 | 38.5 | 31.1 | -7.4 | 23.6 | 22.9 |
| 264 | Leicester South | 38.8 | 14.8 | 42.1 | 30.7 | 4.7 | 34.8 | 8.3 | 3.7 | 21.8 | 11.5 | -10.3 | 1.9 | 1.9 |
Two very different stories are both hiding inside "Archetype 1," and the seats above draw the line cleanly:
- Brent West and Harrow East are ~32–40% Archetype 1, with a large Hindu and Indian population (~28%) and comparatively modest Muslim population (15–18%). Their Conservative vote barely moved: Both saw their conservative vote fall only 1.3 points in a year the party collapsed 20 points nationally. No surge to independents either (~6%). Incidentally, Harrow East ended up being the only seat in the country where the Conservatives won a majority of votes.
- Bethnal Green and Stepney, Dewsbury and Batley, and Blackburn are also high on Archetype 1 (or its close sibling Archetype 2), but with the Muslim population share driving most of the loading, and their 2024 story is the opposite of stable: Bethnal Green and Stepney swung nearly 33 points to "Other" (an independent almost won the seat); Blackburn swung 19 points the same way. These are the seats behind the well-documented independent surge in Muslim-plurality areas in 2024, several of them explicitly tied to Gaza as a single-issue driver.
Leicester East is the genuinely awkward middle case: it has both a large Hindu population (37.6%) and a large Muslim population (25.6%), and its result (Conservatives down a below-average 7 points, Labour down heavily with 'Other' candidates surging, the Conservatives' only seat gain of the election) doesn't sit cleanly in either bucket, in a seat that also saw real Hindu-Muslim communal tension after 2022. It's worth pausing on: both the Conservatives holding up relatively well in highly Hindu areas, and Labour's significant losses amongst Muslim communities, played out in the same seat, and between them produced a genuinely unusual result.
The evidence points to (at least) two distinct categories along the Hindu/Indian vs. Muslim axis: a South Asian, Conservative-diaspora-leaning group (Harrow-shaped) and a Muslim, Labour-to-independent group (Bethnal Green/Dewsbury/Blackburn-shaped), rather than one archetype with an implied vote that's a meaningless average of two opposed electorates.
A candidate for the same Conservative-diaspora group: British Jewish voters
If we're building a group defined partly by "minority community with a real, well-documented, above-average Conservative lean," British Jewish voters are the best-studied case in the country, and they point the same direction. The Institute for Jewish Policy Research's polling around the 2019 general election, run with Survation, found that 61% of British Jews voted Conservative and just 11% voted Labour, "almost certainly the lowest level of Jewish support for the Labour Party in history" (JPR: How British Jews vote and why). A pre-election Survation poll had it even more stark: 64% Conservative to 6% Labour. That collapse tracks the Corbyn-era antisemitism crisis specifically, and 2024 polling shows some reversion toward the national picture (JPR, 2024), but the underlying lean is well-established enough, over enough elections, to be a real electoral signature rather than a one-off reaction. This project's own seat-level data agrees:
top_jewish = feat.sort_values("Jewish", ascending=False)[
["Seat", "Jewish", "y2019_CON_share", "y2024_CON_share", "swing_CON"]
].head(6)
england_avg_swing = feat["swing_CON"].mean()
print(f"England-wide average Conservative swing, 2019→2024: {england_avg_swing:.1f} points\n")
top_jewish.round(1)England-wide average Conservative swing, 2019→2024: -21.6 points
| Seat | Jewish | y2019_CON_share | y2024_CON_share | swing_CON | |
|---|---|---|---|---|---|
| 175 | Finchley and Golders Green | 20.8 | 43.8 | 35.1 | -8.7 |
| 221 | Hertsmere | 16.9 | 64.9 | 44.7 | -20.2 |
| 82 | Bury South | 15.8 | 43.5 | 23.4 | -20.0 |
| 216 | Hendon | 14.2 | 48.9 | 38.4 | -10.5 |
| 196 | Hackney North and Stoke Newington | 12.6 | 13.0 | 8.4 | -4.6 |
| 106 | Chipping Barnet | 9.7 | 47.5 | 36.7 | -10.8 |
In the six highest-Jewish-population seats in England, the Conservative vote held up meaningfully better than the national collapse in two of the four (Finchley and Golders Green: -8.7 points; Hendon: -10.5 points, Chipping Barnet -10.8~ against an England-wide average of roughly -21.6), smaller falls than almost anywhere else in the country, in a year the party lost seats it had held for generations. That's consistent with British Jewish voters being a real, distinct, Conservative-leaning constituency within whatever broader "Archetype 1"-adjacent group ends up housing them, a strong candidate to fold into the same Conservative-diaspora group as the Harrow-shaped seats above, on electoral behaviour rather than ethnicity or religion as such, which is about the least controversial sentence I'll manage to write in this section.
6. What's actually inside Archetype 3
Archetype 3 ("international metropolitan," high Superwealthy, high ethnic diversity,
implied vote Labour 54% / Conservative 22%) has the same kind of problem as Archetype 1, for
a related reason: Superwealthy, as the non-dom research above already told us, is
extremely spatially concentrated. A handful of seats drive almost all of the archetype's
Superwealthy loading, and if their Conservative vote behaves differently from the rest of
the archetype, averaging them in dilutes a real, distinct signal.
Worth a wry footnote before the numbers: Superwealthy is, at bottom, a non-dom proxy, and
plenty of non-doms are foreign nationals who aren't eligible to vote in a UK general election
at all (not to mention that non-dom status no longer exists as a tax arrangement). Whatever
Conservative lean turns up here isn't literally non-doms turning out to vote non-dom; it's
that a genuine non-dom enclave doesn't arrive alone. It comes with a great many
very-domestic, very-enfranchised, very Conservative-voting neighbours, because that's who else
can afford to live next door to that kind of money. The seat votes Tory even where the specific
population the variable is naming, mostly, can't.
merged3 = mem.merge(
feat[["Seat", "Superwealthy", "y2019_CON_share", "y2024_CON_share", "swing_CON"]],
on="Seat", how="left",
)
archetype3_seats = merged3[merged3["Archetype_3"] > 25]
print(f"Seats with Archetype 3 > 25% membership: {len(archetype3_seats)}")
print(f"Correlation(Superwealthy, 2024 Conservative share) within this group: "
f"{archetype3_seats['Superwealthy'].corr(archetype3_seats['y2024_CON_share']):.3f}\n")
hi = archetype3_seats[archetype3_seats["Superwealthy"] > 10]
lo = archetype3_seats[archetype3_seats["Superwealthy"] <= 10]
print(f"High-Superwealthy subgroup (n={len(hi)}): mean 2024 CON = {hi['y2024_CON_share'].mean():.1f}%, "
f"mean swing = {hi['swing_CON'].mean():.1f}")
print(f"Rest of Archetype 3 (n={len(lo)}): mean 2024 CON = {lo['y2024_CON_share'].mean():.1f}%, "
f"mean swing = {lo['swing_CON'].mean():.1f}")
archetype3_seats.sort_values("Archetype_3", ascending=False).head(6)[
["Seat", "Archetype_3", "Superwealthy", "y2024_CON_share", "swing_CON"]
].round(1)Seats with Archetype 3 > 25% membership: 56 Correlation(Superwealthy, 2024 Conservative share) within this group: 0.364 High-Superwealthy subgroup (n=5): mean 2024 CON = 31.5%, mean swing = -6.7 Rest of Archetype 3 (n=51): mean 2024 CON = 17.8%, mean swing = -10.3
| Seat | Archetype_3 | Superwealthy | y2024_CON_share | swing_CON | |
|---|---|---|---|---|---|
| 247 | Kensington and Bayswater | 77.2 | 25.6 | 33.7 | -4.6 |
| 109 | Cities of London and Westminster | 75.5 | 28.0 | 32.1 | -8.2 |
| 175 | Finchley and Golders Green | 71.3 | 10.4 | 35.1 | -8.7 |
| 202 | Hampstead and Highgate | 60.8 | 16.4 | 17.4 | -5.7 |
| 97 | Chelsea and Fulham | 59.5 | 20.4 | 39.1 | -6.4 |
| 216 | Hendon | 58.9 | 3.5 | 38.4 | -10.5 |
A real, if moderate, correlation (r ≈ 0.36) between Superwealthy and 2024 Conservative vote
share within Archetype 3, and it's driven by exactly the seats you'd expect from the non-dom
study: Kensington and Bayswater, Cities of London and Westminster, Chelsea and Fulham. Splitting
Archetype 3 at Superwealthy > 10 isolates a 5-seat pocket averaging 31.5% Conservative with a
6.7-point swing away, against 17.8% and a 10.3-point swing for the other 51 seats in the same
archetype: the Conservative-leaning pocket didn't just poll better, it also fell less.
Constituency-level results understate how solid this pocket actually is, because a
parliamentary seat blends the enclave with everything around it (in the case of Kensington and Westminster, a much more Labour friendly mix of renters, graduates, and ethnic minorities). Local election results don't:
in the 2022 Westminster City Council election, the Knightsbridge and Belgravia ward,
sitting inside Cities of London and Westminster, returned all three of its councillors as
Conservatives, with the top candidate taking 62.7% of the vote outright
(results via Wikipedia).
That's not a marginal lean; it's a landslide, at exactly the geographic resolution the
Superwealthy variable is trying to target.
On this evidence (three separate methods all pointing at the same three postcodes, which is
more agreement than I get from my own data most days), the small number of
Superwealthy-heavy seats within Archetype 3 are safe to peel off into the same Blue
(Conservative-leaning) group as the diaspora and Jewish-vote seats from Section 5, a genuinely
different electorate from the rest of Archetype 3's Labour-leaning metropolitan mass, hiding
inside the same broad ethnicity-and-tenure profile that NMF (reasonably) treated as one thing.
What about rural areas?
Rural areas are the traditional shorthand for "safe Conservative," so it's worth checking whether that shorthand still earns a place in the Blue category, and how much weight it deserves, rather than nodding along because everyone else does.
print(f"Correlation(Rural, 2024 Conservative share), England: {feat['Rural'].corr(feat['y2024_CON_share']):.3f}")
print(f"Correlation(Rural, Conservative swing), England: {feat['Rural'].corr(feat['swing_CON']):.3f}\n")
hi_rural = feat[feat["Rural"] > 50]
lo_rural = feat[feat["Rural"] <= 10]
print(f"High-rural seats (Rural>50, n={len(hi_rural)}): mean CON 2019 = {hi_rural['y2019_CON_share'].mean():.1f}%, "
f"mean CON 2024 = {hi_rural['y2024_CON_share'].mean():.1f}%")
print(f"Low-rural seats (Rural<=10, n={len(lo_rural)}): mean CON 2019 = {lo_rural['y2019_CON_share'].mean():.1f}%, "
f"mean CON 2024 = {lo_rural['y2024_CON_share'].mean():.1f}%")Correlation(Rural, 2024 Conservative share), England: 0.495 Correlation(Rural, Conservative swing), England: -0.408 High-rural seats (Rural>50, n=67): mean CON 2019 = 59.5%, mean CON 2024 = 32.7% Low-rural seats (Rural<=10, n=302): mean CON 2019 = 38.7%, mean CON 2024 = 20.4%
Rural seats really are meaningfully more Conservative (r ≈ 0.50 with 2024 vote share, a
stronger relationship than either Jewish or Hindu population share managed on their own), so
routing some of the rural population toward the Blue category is the right call. But the
size of that lean has moved a lot in a short time: the average high-rural seat here went
from 59.5% Conservative in 2019 to 32.7% in 2024, a 26.8-point collapse, and that's not a
quirk of this dataset. An independent CLA/Survation poll of the 100 most rural constituencies
in Britain found Conservative support at 34% going into the 2024 election, "down 25 points
from 2019" (CLA: General election 2024: the rural reaction),
essentially the same number, from a completely different source. Rural seats are also swinging
away from the Conservatives somewhat faster than urban ones (r ≈ -0.41 between Rural and
swing), though this may be more so due to the fact that rural areas had much higher
Conservative votes in 2019 compared to urban areas, with the latter having fewer initial
votes for the Conservatives to lose. All told, the model routes a flat 20% of a seat's
Rural population into Blue: enough to register the relationship that's still there
(r ≈ 0.50), deliberately conservative given that the historic rural Tory lean has roughly
halved in one election (59.5% → 32.7% above).
7. Average: politically disengaged, follows the national mood
It's tempting, having just spent two sections finding real signal inside supposedly generic archetypes, to expect that enough digging turns every seat into a clean, named, explicable electorate: Muslim, Young progressives, White-Working-class, and so on. It doesn't; A large share of Britain's constituencies simply don't lean on any one distinguishing trait: no unusual ethnic or religious composition, no notably young or old population, no striking tenure or class skew. If none of the specific categories above describes a seat well, what does?
The most direct test: does a seat's demographic distinctiveness predict how unusual its voting is? If "Average" seats are a real category, not just leftover statistical noise, they should be the ones whose vote share and swing sit closest to the national numbers, while the seats with a strong, distinctive demographic signature should be the ones that deviate furthest from the national trend.
demo_cols = [c for c in feat.columns if c not in ("Seat", "County", "Region")
and not c.startswith(("y2019_", "y2024_", "swing_"))]
z = (feat[demo_cols] - feat[demo_cols].mean()) / feat[demo_cols].std()
feat["distinctiveness"] = z.abs().mean(axis=1) # average |z-score| across every demographic variable
national_con, national_swing = feat["y2024_CON_share"].mean(), feat["swing_CON"].mean()
feat["dev_swing"] = (feat["swing_CON"] - national_swing).abs()
print(f"Correlation(demographic distinctiveness, |deviation from national swing|): "
f"{feat['distinctiveness'].corr(feat['dev_swing']):.3f}\n")
least = feat.sort_values("distinctiveness").head(50)
most = feat.sort_values("distinctiveness").tail(50)
print(f"50 LEAST distinctive seats: mean 2024 CON = {least['y2024_CON_share'].mean():.1f}% "
f"(national {national_con:.1f}%), mean |swing deviation| = {least['dev_swing'].mean():.1f}pp")
print(f"50 MOST distinctive seats: mean 2024 CON = {most['y2024_CON_share'].mean():.1f}% "
f"(national {national_con:.1f}%), mean |swing deviation| = {most['dev_swing'].mean():.1f}pp")
least.nsmallest(6, "distinctiveness")[["Seat"]]Correlation(demographic distinctiveness, |deviation from national swing|): 0.491 50 LEAST distinctive seats: mean 2024 CON = 28.2% (national 25.1%), mean |swing deviation| = 5.4pp 50 MOST distinctive seats: mean 2024 CON = 15.8% (national 25.1%), mean |swing deviation| = 14.0pp
| Seat | |
|---|---|
| 78 | Buckingham and Bletchley |
| 344 | Northampton South |
| 307 | Milton Keynes North |
| 96 | Chelmsford |
| 9 | Banbury |
| 384 | Rochester and Strood |
The relationship holds (r ≈ 0.49 between distinctiveness and swing deviation), and the gap between the two groups is stark: the 50 least distinctive seats swing within about 5 points of the national average; the 50 most distinctive seats swing nearly three times as far off it. The least-distinctive seats themselves include some well known "bellwether" seats: Northampton South, Milton Keynes North, Rochester and Strood, medium English towns with no strong ethnic, religious, generational or class skew in any direction, and votes that track the national mood almost by definition.
That's what Average actually captures: not "we couldn't characterise them," but a real, predictable behavioural pattern: a voter, or a seat, without a strong independent pull of its own, who or which follows wherever the national conversation goes that year. It's the largest single category almost everywhere, for the same reason a "typical" reading is more common than an extreme one: most places aren't unusual on any single axis at once. The specific categories in Sections 5 and 6 are where the distinctive stories live; Average is what's left when none of those stories apply, and empirically it behaves exactly the way that description predicts.
8. Where this leaves the group structure
Putting Sections 4 to 7 together, here's where all that digging actually lands:
| Group | Where it comes from | Core evidence |
|---|---|---|
| Muslim / independent-leaning | Archetype 1, Muslim-plurality seats | Bethnal Green, Dewsbury, Blackburn: 20–33pt swings to independents |
| South Asian Conservative-diaspora | Archetype 1, Hindu/Indian-plurality seats | Harrow West/East: Conservative vote barely moved against a 20pt national collapse |
| Jewish / Conservative | evidence-led addition | JPR/Survation: 61% Conservative, 11% Labour (2019); Finchley & Hendon held up in 2024 |
| Superwealthy / Conservative | Archetype 3, high-Superwealthy seats |
Kensington, Cities of London & Westminster, Chelsea & Fulham; Sticky conservative vote in inner London, Belgravia ward 62.7% Conservative locally |
| Metropolitan / cosmopolitan | Archetype 3, the rest | Labour-leaning, ethnically diverse, not superwealthy |
| Working-class / Reform-curious | Archetype 2 | Labour 52%, Reform 29% implied vote |
| Affluent professional | Archetype 4 | Conservative 51%, Lib Dem 35% implied vote |
| Student / graduate / renter | Archetype 5 | Labour 68%, Green 30% implied vote |
| Average | no strong distinguishing trait | r≈0.49 between demographic distinctiveness and swing deviation from the national trend |
That's the group structure conceptually. Section 9 makes it arithmetic: the actual per-seat scoring formula settled on for each group, and why each piece of each formula is there.
9. Turning the groups into a formula, seat by seat
Sections 5–8 established which groups belong in the model and why, from evidence. This section is the last mile, and the one where the vague talk of "groups" has to turn into arithmetic I can actually be held to: an explicit, per-seat scoring formula for each group, built directly from the census variables already in the dataset, with every design choice justified against either this project's own correlations or external research.
Muslim: used directly as the Muslim religion percentage, unmodified. Unlike every other
group below, this one needs no construction: religious self-identification in the census
already is the exact population this group is trying to represent, at exactly the right
resolution.
Left: Age18to24, used alone (Archetype 5's defining feature). YouGov's post-election
analysis found the Green vote share among 18–24s roughly doubled in 2024 versus 2019 to 14%,
while Labour led every age group except 65+, "the oldest crossover point since 1997"
(YouGov: How Britain voted in the 2024 general election).
This project's own correlations agree and go further: Age18to24 correlates at r=+0.477 with
2024 Green share (England), a stronger relationship than Labour's own +0.345, and at
r=-0.517 with Conservative share, the single strongest predictor of the Conservative
collapse found anywhere in this dataset.
Progressives: Black + Sikh + ManagerialProfessional × (PrivateRent / (PrivateRent +
OwnerOccupation)). Black British voters have polled at roughly 70–80% Labour for decades,
remaining Labour's most popular choice among Black voters even in 2024
(The Conversation).
British Sikhs lean the same way, if less overwhelmingly: a 2024 survey found 43% Labour
against 20% Conservative among Sikh respondents, more than two-to-one
(Religion Media Centre: General Election 2024: the Sikh vote),
a genuinely different profile from the Hindu-leaning Conservative-diaspora signal folded into
Blue above. The model is deliberately not treating "South Asian" as one bloc. The renting
professional term is Archetype 3's remaining, non-superwealthy urban-cosmopolitan population,
captured through tenure: this project's own data shows PrivateRent correlates at r=+0.457
with 2024 Green share and +0.351 with Labour, against -0.478 with Conservative: renters lean
markedly left of owners in the same occupational class.
Average: a group for the "leftover" section of voters who don't neatly fit anywhere else; it's specifically the category for seats and voters with no strong pull of their own. The final renormalisation step (rescaling all seven raw scores to sum to 100%, described next) automatically hands it a larger share in seats where none of the six specific signals above are strong, and a smaller share where they are, exactly the "follows whichever way the seat otherwise leans" behaviour Section 7 found in the data.
Liberals ("Blue Wall", Archetype 4): ManagerialProfessional × (OwnerOccupation /
(OwnerOccupation + PrivateRent)) × (1 - PublicSector). This is Progressives' formula mirrored:
the same professional population, split the other way by tenure, isolating the
owner-occupying half instead of the renting half. OwnerOccupation is this project's single
strongest Conservative correlate of any variable tested, at r=+0.669 (England, 2024), far
stronger than any ethnicity or religion variable. The × (1 - PublicSector) term then removes
public sector professionals specifically: this project's data shows PublicSector correlates
at -0.369 with Conservative share and +0.319 with Labour, so a professional-class population
that happens to be teachers, doctors and civil servants behaves measurably less like "Blue
Wall" (See, Tooting, Cardiff North) than one drawn from private-sector management and finance. The formula reflects a
real, distinct sub-lean rather than assuming all professionals are interchangeable.
Blue (Conservative-leaning): Hindu + Jewish + Superwealthy + 0.2 × Rural. Each term is
one of the specific, evidenced sub-populations from Sections 5 and 6, added together rather
than derived from a single archetype: the Hindu population (Harrow-shaped diaspora-Conservative
seats: near-zero swing against a seat's own religion/ethnicity baseline), the Jewish population
(JPR/Survation: 61% Conservative in 2019, and the smallest Conservative collapses in the country
in 2024 running through the highest-Jewish seats), the Superwealthy population (r ≈ 0.36 with
2024 Conservative share within Archetype 3, backed independently by the Belgravia ward result), and a flat 20% of the Rural population
(Section 6). This is the one group built from "wherever we found
a specific, well-evidenced Conservative-leaning population," rather than from a single
archetype.
Reform (Archetype 2): ((3 × Routine + NoQualifications) / 4) × WhiteBritish. The
routine-occupation/no-qualifications blend (weighted 3:1 toward occupation, since it's the
stronger and more stable of the two working-class markers) captures the classic post-industrial
profile; multiplying by WhiteBritish restricts it to the specific electorate Reform actually
draws from. This project's data makes the case for that restriction directly: WhiteBritish
correlates at r=+0.508 with 2024 Reform share, the single strongest correlate of Reform's vote
found for any variable in this dataset, stronger even than Routine occupation alone. An
ethnically-diverse working-class seat and a white-British working-class seat can have identical
NS-SEC profiles and very different Reform ceilings; this term is what keeps the model from
conflating them.
On the double-counting question
None of the raw scores above are literal, mutually-exclusive headcounts, and they're not meant
to be, since the census only publishes marginal percentages (what fraction of a seat is Black;
what fraction is a homeowner) rather than the full joint cross-tabulation (what fraction is
both) at constituency level. A real person who is a Black professional homeowner is entirely
possible, and is genuinely counted toward more than one of these raw terms: the Black term
directly, and a share of the ManagerialProfessional term depending on this seat's tenure mix.
That's unavoidable with marginal data, and it's the reason this is a two-stage design rather
than a one-shot formula:
-
The tenure-split terms are a real partition, not a double-count. Every unit of
ManagerialProfessionalis allocated to either Progressives (via the renting fraction) or Liberals (via the owning fraction), andrenting fraction + owning fraction = 1by construction, so the professional population is fully and exactly split between the two groups, never counted twice between them. The one real assumption baked in here is that professionals rent and own in roughly the same proportion as the seat's population overall, i.e. that occupation class and tenure are independent within a seat. That's a simplification (professionals plausibly own at a higher rate than routine workers even within the same seat), and a genuine target for refinement once household-level or small-area tenure-by-occupation data can be brought in. But it's a considerably better approximation than assuming no relationship between tenure and lean at all, and it costs nothing extra to compute from data already on hand. -
The additive terms (
Black,Sikh,Age18to24) are deliberately allowed to overlap with the professional terms, because that overlap is signal, not noise. A seat with a large population of Black professional renters gets two reinforcing contributions toward Progressives (one fromBlackdirectly, one from the renting-professional term), and that's the correct behaviour if the goal is a propensity score, not a headcount: such a seat really does carry two independent pieces of evidence pointing the same way, and a model that let them reinforce each other should score it more confidently Progressive than a seat with only one. What these raw scores are not, at this stage, is a percentage of anything: Black% + Sikh% + a fraction of Professional% has no reason to sum to a sensible number on its own, and isn't supposed to.
That's what the final step is for. Exactly like the NMF archetypes in Section 4, these seven raw propensity scores get rescaled so they sum to 100% per seat, the same "renormalise a set of non-negative scores into a genuine percentage breakdown" move as the FCLSU step in Section 4, just with evidence-derived weights standing in for NMF's data-discovered ones instead of a constrained least-squares solve. The double-counting inside a single raw score before that rescaling isn't a bug to be fixed; it's exactly how the score is allowed to accumulate confidence from more than one piece of overlapping evidence before being turned into a proportion.
That's the group structure and the arithmetic behind it. Turning each group into an actual per-party preference order or flow table, and using that to allocate 2024 votes and project 2029 seats, is the next post: the one where all this careful category-building finally has to go and predict something, in public, where I can be proven wrong.