Part 1 · Clustering

Trying to sort 632 seats into types

Running the full standard toolkit — K-means, HAC, DBSCAN and GMM, across PCA and UMAP, scored on four metrics — on Britain's constituencies, and reporting honestly on where it falls short.

October 2025 · 14 min read

1. Setting up the experiment

The input is 632 GB constituencies (England, Scotland, Wales pooled into one clustering run, not fit separately) described by 34 2021 Census variables per seat — ethnicity, religion, age bands, education, NS-SEC occupation class, housing tenure, plus a non-dom-derived Superwealthy proxy. Deliberately no vote share or swing goes into the features at this stage: the whole point is to ask whether demographics alone separate seats into discrete types, independently of how they happen to vote. Feeding vote data in here would answer a circular question — "do seats that vote alike get clustered together" is trivially yes if votes are one of the clustering inputs.

Four algorithms, two ways of reducing 34 dimensions down to something clustering can work with, and four ways of scoring the result — a full grid, not a single best-guess run:

And two ways of getting 34 census variables down to a workable number of dimensions before any of the above runs: PCA (a linear projection onto the directions of greatest variance) and UMAP (a nonlinear technique that explicitly reshapes distances to preserve which points are each other's near neighbours). Both get carried through the entire grid below, since — as Section 3 shows — the choice between them turns out to matter a lot.

import json
import numpy as np
import pandas as pd
from pathlib import Path

OUTPUT = Path("../output")

selection = pd.read_csv(OUTPUT / "whole_seat_model_selection.csv")
diagnostics = json.load(open(OUTPUT / "whole_seat_clusters_diagnostics.json", encoding="utf-8"))

print(f"{diagnostics['n_seats']} seats, {diagnostics['n_features']} demographic features")
print(f"PCA: {diagnostics['n_pca_components']} components, "
      f"{diagnostics['pca_variance_explained']:.0%} of variance retained")
print(f"k searched from 2 to {diagnostics['k_max_searched']}")
selection.head()
Output
632 seats, 34 demographic features
PCA: 12 components, 91% of variance retained
k searched from 2 to 20
Output
embedding algorithm k distortion silhouette davies_bouldin calinski_harabasz n_noise eps_percentile degenerate
0 PCA KMeans 2 13172.574071 0.453189 1.309598 309.318057 0 NaN NaN
1 PCA HAC 2 13735.558432 0.440431 1.373572 270.817884 0 NaN NaN
2 PCA GMM 2 14052.498072 0.428604 1.483707 250.500863 0 NaN NaN
3 PCA KMeans 3 11325.209306 0.454481 1.483012 230.902574 0 NaN NaN
4 PCA HAC 3 12178.933737 0.433729 1.366030 192.670697 0 NaN NaN

2. The sweep: does anything here agree on how many types there are?

Every (embedding, algorithm) pair gets its own recommended k from each of the four requested metrics — Distortion (elbow), Silhouette (max), Davies-Bouldin (min), Calinski-Harabasz (max) — plus a consensus taken as their median, since the four routinely disagree and picking one silently would hide that disagreement rather than report it.

def find_elbow(xs, ys):
    xs, ys = np.array(xs, dtype=float), np.array(ys, dtype=float)
    xn = (xs - xs.min()) / (xs.max() - xs.min() + 1e-12)
    yn = (ys - ys.min()) / (ys.max() - ys.min() + 1e-12)
    x1, y1, x2, y2 = xn[0], yn[0], xn[-1], yn[-1]
    dist = np.abs((y2 - y1) * xn - (x2 - x1) * yn + x2 * y1 - y2 * x1) / np.hypot(y2 - y1, x2 - x1)
    return int(xs[np.argmax(dist)])

rows = []
for (emb, algo), grp in selection[selection["algorithm"] != "DBSCAN"].groupby(["embedding", "algorithm"]):
    grp = grp.sort_values("k")
    picks = {
        "distortion": find_elbow(grp["k"], grp["distortion"]),
        "silhouette": int(grp.loc[grp["silhouette"].idxmax(), "k"]),
        "davies_bouldin": int(grp.loc[grp["davies_bouldin"].idxmin(), "k"]),
        "calinski_harabasz": int(grp.loc[grp["calinski_harabasz"].idxmax(), "k"]),
    }
    rows.append({"Embedding": emb, "Algorithm": algo, **picks,
                 "Consensus k": int(np.median(list(picks.values())))})

pd.DataFrame(rows).sort_values(["Embedding", "Algorithm"])
Output
Embedding Algorithm distortion silhouette davies_bouldin calinski_harabasz Consensus k
0 PCA GMM 10 2 13 2 6
1 PCA HAC 7 2 15 2 4
2 PCA KMeans 7 3 11 2 5
3 UMAP GMM 7 7 7 10 7
4 UMAP HAC 7 7 8 9 7
5 UMAP KMeans 7 8 7 14 7

Two things jump out immediately. First, within a single (embedding, algorithm) pair, the four metrics themselves often disagree wildly — PCA + GMM gets told k=10 by Distortion, k=2 by Silhouette, k=13 by Davies-Bouldin and k=2 by Calinski-Harabasz, a spread of 11 across four metrics measuring the same partition. Second, PCA and UMAP disagree systematically with each other, not just noisily: PCA's consensus recommendations cluster in the 4–6 range, UMAP's sit at 7 almost everywhere. That's not four algorithms independently converging on a different answer — it's the same four algorithms, run on the same underlying data, reduced to the same number of dimensions, giving a different answer purely because of which dimensionality-reduction method sits upstream of them.

3. Why that split isn't a coin flip: PCA vs. UMAP on the exact same partition

The gap above is worth pinning down precisely rather than shrugging at, because it decides which numbers in the rest of this post can be trusted. Take the identical clustering — GMM, k=6 — and score it once on each embedding:

same_k6 = selection[(selection["algorithm"] == "GMM") & (selection["k"] == 6)]
same_k6[["embedding", "silhouette", "davies_bouldin", "calinski_harabasz", "distortion"]]
Output
embedding silhouette davies_bouldin calinski_harabasz distortion
14 PCA 0.058471 1.988346 90.326804 11408.953499
75 UMAP 0.455465 0.744086 701.301197 695.477092

Same seats, same algorithm, same k — and the Silhouette score jumps from 0.058 on PCA to 0.455 on UMAP, nearly an eightfold difference, on a metric that's supposed to be measuring one fixed property of one fixed partition. That's not GMM finding a better split on UMAP's coordinates; it's UMAP's coordinates lying about how well-separated the points already were. UMAP is explicitly built to warp distances so that points which were near-neighbours in the original 34-dimensional space stay near each other in the low-dimensional output — a design choice that's genuinely useful for visualization, but it means the distances UMAP hands back are no longer a faithful measure of separation in the original data. Silhouette, Davies-Bouldin and Calinski-Harabasz all assume the coordinates they're given are a faithful distance measure. Feed them UMAP's warped ones and they report a rosier picture than the underlying census data actually supports. PCA's projection is linear — it can only rotate and rescale, never selectively stretch some neighbourhoods and compress others — so its distances stay an honest (if lower-dimensional) read of the real thing. Every result from here on uses PCA for exactly that reason.

4. DBSCAN: does GB even have dense, separated clusters at all?

DBSCAN needs no k — it grows clusters from dense regions and calls everything else noise — which makes it a useful honesty check on the other three: if there genuinely isn't dense, separated structure in this data, DBSCAN is the algorithm that will say so instead of forcing an answer.

dbscan = selection[selection["algorithm"] == "DBSCAN"][
    ["embedding", "eps_percentile", "k", "n_noise", "degenerate", "silhouette"]
]
dbscan
Output
embedding eps_percentile k n_noise degenerate silhouette
57 PCA 80.0 1 89 True NaN
58 PCA 85.0 2 53 False 0.503568
59 PCA 90.0 1 37 True NaN
60 PCA 95.0 1 14 True NaN
118 UMAP 80.0 6 27 False 0.456344
119 UMAP 85.0 4 22 False 0.473744
120 UMAP 90.0 3 15 False 0.440765
121 UMAP 95.0 2 3 False 0.311445

On PCA, three of the four density thresholds tried come back degenerate — DBSCAN finds only one real cluster plus a pile of noise points, not a genuine multi-way split — and the one PCA threshold that does produce two clusters splits off a fairly small dense core (53 seats flagged as noise) rather than partitioning the full 632. UMAP's warped geometry, true to Section 3's finding, manufactures a cleaner-looking multi-cluster split at every threshold — which is the same artifact showing up a second way, not a second, independent piece of evidence. Read honestly, DBSCAN's verdict on the real (PCA) geometry is that Great Britain's 632 constituencies mostly form one dense mass with a soft, fuzzy edge, not a small number of crisply separated islands.

5. Picking a working configuration anyway, and checking it against judgement

Every metric disagreeing is itself the finding, but a concrete partition is still worth building and inspecting — averaging out four disagreeing per-metric picks with a median gives PCA + GMM, k=6 as a working consensus (Section 2), so that's what gets fit and profiled.

profiles = json.load(open(OUTPUT / "whole_seat_clusters_profiles.json", encoding="utf-8"))

pd.DataFrame([{
    "Segment": p["segment"], "n seats": p["n_seats"],
    "Distinguishing profile": p["auto_label"],
    "Leading 2024 vote": ", ".join(f"{party} {share:.0f}%" for party, share in p["leading_parties_2024"].items()),
} for p in profiles])
Output
Segment n seats Distinguishing profile Leading 2024 vote
0 2 143 high Age18to24 (z=+0.6); high Chinese (z=+0.6)... LAB 38%, CON 20%
1 1 116 high Routine (z=+1.0); low Mixed (z=-0.9); hig... LAB 41%, CON 17%
2 4 108 high Age0to17 (z=+0.7); low Degrees (z=-0.5); ... LAB 37%, CON 26%
3 5 106 high OwnerOccupation (z=+0.8); high Managerial... CON 32%, LAB 27%
4 6 80 high Age65plus (z=+1.4); high Veterans (z=+1.3... CON 31%, LAB 24%
5 3 79 low White (z=-2.0); low WhiteBritish (z=-2.0);... LAB 44%, CON 17%

That's a genuinely legible six-way split — a young/graduate/urban segment, a white-British working-class segment, a young-families/routine-occupation segment, an owner-occupier professional segment, an older/veteran/intermediate-occupation segment, and a low-White-British, high-Muslim/Asian segment — each with a distinct implied vote. Real structure is in here.

tribe_comparison = json.load(open(OUTPUT / "whole_seat_tribe_comparison.json", encoding="utf-8"))
print(f"Adjusted Rand Index vs. this project's own hand-assessed dominant category per seat: "
      f"{tribe_comparison['adjusted_rand_index']}")
print(f"Seats where the hard cluster's majority category disagrees with the hand assessment: "
      f"{tribe_comparison['n_disagreements']} of {tribe_comparison['n_seats']} "
      f"({tribe_comparison['n_disagreements'] / tribe_comparison['n_seats']:.0%})")
Output
Adjusted Rand Index vs. this project's own hand-assessed dominant category per seat: 0.125
Seats where the hard cluster's majority category disagrees with the hand assessment: 266 of 632 (42%)

An Adjusted Rand Index of 0.125 is weak agreement — far above the 0 you'd get from random labels, so the clustering isn't picking up nothing, but nowhere near the 1.0 that would mean "this hard partition and hand judgement are describing the same categories." 42% of seats get a different dominant category from the two approaches. Combined with Section 2's internal metric disagreement and Section 4's DBSCAN read of one soft, fuzzy mass rather than crisp islands, three independent checks are converging on the same honest answer: there's real, recoverable structure in how British constituencies differ demographically, but a hard partition — one seat, one label — isn't the right shape of answer for it.

6. Why segments, not clusters

Every algorithm tried here, GMM's "soft" probabilities included, still answers the same question underneath: which single cluster does this seat most plausibly belong to, and how confident are we? A seat sitting at 55% Segment 3-ish and 45% Segment 5-ish gets assigned to whichever is larger and its GMM confidence reported alongside it — the 45% doesn't survive the trip into the output. Section 5's 42% disagreement rate is exactly the visible cost of that: a constituency isn't neatly one thing, and forcing it into the label of whichever thing it's most like throws away the part of the picture explaining why judgement and the algorithm often land on different labels for the same seat.

The fix isn't a better k, a better algorithm, or a better embedding — Section 2 through 4 already ran the full available grid of all three. It's dropping the assumption that "seat → single type" is even the right question. What's needed instead is a technique that answers a different one: not which type a seat belongs to, but how much of each recurring type is present in it — a genuine compositional breakdown, in percentages that sum to 100%, the same way a census breakdown of age or ethnicity already does for the underlying population. That's where the next post picks up.