Source code: Geodemographic classification in Python: k-means on Census 2021 Output Areas, and SKATER and max-p regionalisation
Runnable Python scripts, requirements and console output for the tutorial “Geodemographic classification in Python: k-means on Census 2021 Output Areas…
Last updated
These are the exact scripts used to produce every figure and number in Geodemographic classification in Python: k-means on Census 2021 Output Areas, and SKATER and max-p regionalisation. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- geodemographic_classification.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython geodemographic_classification.pygeodemographic_classification.py
"""Geodemographic classification in Python: a small Output Area Classification for Cardiff fromCensus 2021 tables, with k-means, cluster profiles, and contiguous regionalisation (SKATER and max-p) in spopt.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/geodemographic-classification-python/Data: ONS Census 2021 via Nomis bulk downloads (OGL v3), ONS Open Geography Output Area boundaries (OGL v3),2021/2 Output Area Classification from the Geographic Data Service (OGL v3)."""import jsonimport randomimport sysimport timeimport warningsimport zipfilefrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport libpysalimport matplotlibimport networkx as nximport numpy as npimport pandas as pdimport requests
matplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormap, TwoSlopeNormfrom matplotlib.patches import Patchfrom scipy.sparse import coo_matrixfrom scipy.sparse.csgraph import connected_componentsfrom sklearn.cluster import KMeansfrom sklearn.metrics import adjusted_rand_score, silhouette_samples, silhouette_scorefrom spopt.region import MaxPHeuristic, Skater
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)SEED = 42CRS = "EPSG:27700"LAD = "Cardiff"NOMIS = "https://www.nomisweb.co.uk/output/census/2021/census2021-{}.zip"ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"OA_URL = f"{ONS}/Output_Areas_2021_EW_BGC_V2/FeatureServer/0/query"GEODS = "https://data.geods.ac.uk/dataset/73a16168-f2b0-4732-b36a-3ec0253e34fe/resource"OAC_URL = f"{GEODS}/4924ae88-d384-45f0-8b82-0a4a66e49e01/download/uk_oac_final.csv"OAC_NAMES = f"{GEODS}/5ee35968-582e-4709-8433-90d9fd69d2a0/download/classification_codes_and_names_ukoac_lsoac_msoac.csv"# Paul Tol's colour-blind-safe "muted" qualitative paletteTOL = ["#332288", "#88CCEE", "#44AA99", "#117733", "#999933", "#DDCC77", "#CC6677", "#882255", "#AA4499", "#DDDDDD"]
def section(title): print(f"\n=== {title} ===")
def download(url, path, **params): if not path.exists(): r = requests.get(url, params=params or None, timeout=600, headers={"User-Agent": "TechnicalGIS-tutorial"}) r.raise_for_status() path.write_bytes(r.content) return path
section("versions")print(f"python {sys.version.split()[0]} | scikit-learn {version('scikit-learn')} | spopt {version('spopt')} | " f"libpysal {version('libpysal')} | geopandas {version('geopandas')} | numpy {version('numpy')} | " f"pandas {version('pandas')}")
# ---------------------------------------------------------------- 1. Output Area boundaries for Cardiffsection("Output Areas")oa_file = DATA / "cardiff_oa21.geojson"if not oa_file.exists(): feats, offset = [], 0 while True: page = requests.get(OA_URL, params={"where": f"LSOA21NM LIKE '{LAD} %'", "outFields": "OA21CD,LSOA21CD,LSOA21NM", "outSR": 27700, "f": "geojson", "resultOffset": offset, "resultRecordCount": 1000}, timeout=300).json() feats += page["features"]; offset += len(page["features"]) if len(page["features"]) < 1000: break oa_file.write_text(json.dumps({"type": "FeatureCollection", "features": feats}))oa = gpd.read_file(oa_file).set_crs(CRS, allow_override=True).sort_values("OA21CD").reset_index(drop=True)print(f"{len(oa):,} Output Areas in {oa.LSOA21CD.nunique()} LSOAs, {oa.area.sum() / 1e6:.1f} km2")# map extent: the mainland only (Cardiff also includes the island of Flat Holm, 5 km offshore)MAIN = max(oa.dissolve().explode().geometry, key=lambda p: p.area).bounds
# ---------------------------------------------------------------- 2. Census 2021 tables from Nomissection("Census 2021 variables")
def table(code): z = zipfile.ZipFile(download(NOMIS.format(code), DATA / f"census2021-{code}.zip")) df = pd.read_csv(z.open(f"census2021-{code}-oa.csv")).set_index("geography code") df.columns = [c.split(": ", 1)[1].replace("; measures: Value", "") if ": " in c else c for c in df.columns] return df.loc[oa.OA21CD]
def pct(df, num, den): num = [num] if isinstance(num, str) else num return 100 * df[num].sum(axis=1) / df[den]
age, hh, ten = table("ts007a"), table("ts003"), table("ts054")acc, car, sec = table("ts044"), table("ts045"), table("ts062")qual, eth, hlth = table("ts067"), table("ts021"), table("ts037")occ, welsh, cob = table("ts052"), table("ts032"), table("ts004")eco, dens, pop = table("ts066"), table("ts006"), table("ts001")
V = pd.DataFrame(index=oa.OA21CD)V["Aged 0-14"] = pct(age, ["Aged 4 years and under", "Aged 5 to 9 years", "Aged 10 to 14 years"], "Total")V["Aged 20-24"] = pct(age, "Aged 20 to 24 years", "Total")V["Aged 25-44"] = pct(age, [f"Aged {a} to {a + 4} years" for a in (25, 30, 35, 40)], "Total")V["Aged 65+"] = pct(age, ["Aged 65 to 69 years", "Aged 70 to 74 years", "Aged 75 to 79 years", "Aged 80 to 84 years", "Aged 85 years and over"], "Total")V["Population density"] = dens.iloc[:, -1]V["One-person households"] = pct(hh, "One person household", "Total")V["Lone parents"] = pct(hh, "Single family household: Lone parent family: With dependent children", "Total")V["Owns outright"] = pct(ten, "Owned: Owns outright", "Total: All households")V["Owns with mortgage"] = pct(ten, "Owned: Owns with a mortgage or loan", "Total: All households")V["Social rented"] = pct(ten, "Social rented", "Total: All households")V["Private rented"] = pct(ten, "Private rented", "Total: All households")V["Detached"] = pct(acc, "Detached", "Total: All households")V["Terraced"] = pct(acc, "Terraced", "Total: All households")V["Flats"] = pct(acc, [c for c in acc.columns if c.startswith(("In a purpose-built", "Part of", "In a commercial"))], "Total: All households")V["Overcrowded"] = pct(occ, ["Occupancy rating of bedrooms: -1", "Occupancy rating of bedrooms: -2 or less"], "Total: All households")V["No car"] = pct(car, "No cars or vans in household", "Total: All households")sec_tot = "Total: All usual residents aged 16 years and over"V["Higher professional"] = pct(sec, "L1, L2 and L3 Higher managerial, administrative and professional occupations", sec_tot)V["Routine occupations"] = pct(sec, ["L12 Semi-routine occupations", "L13 Routine occupations"], sec_tot)V["Full-time students"] = pct(sec, "L15 Full-time students", sec_tot)V["Degree level (L4+)"] = pct(qual, "Level 4 qualifications and above", sec_tot)V["No qualifications"] = pct(qual, "No qualifications", sec_tot)V["Unemployed"] = pct(eco, "Economically active (excluding full-time students): Unemployed", sec_tot)V["Bad health"] = pct(hlth, ["Bad health", "Very bad health"], "Total: All usual residents")V["Asian"] = pct(eth, "Asian, Asian British or Asian Welsh", "Total: All usual residents")V["Black"] = pct(eth, "Black, Black British, Black Welsh, Caribbean or African", "Total: All usual residents")V["Born outside UK"] = 100 - pct(cob, "Europe: United Kingdom", "Total")V["Welsh speakers"] = pct(welsh, [c for c in welsh.columns if c.startswith("Can speak")], "Total: All usual residents aged 3 years and over")residents = pop["Total"].reindex(oa.OA21CD).to_numpy()print(f"{V.shape[1]} candidate variables for {len(V):,} OAs; {V.isna().sum().sum()} missing values")print(f"Cardiff: {residents.sum():,} usual residents; OA population median {int(np.median(residents))}, " f"range {residents.min()}-{residents.max()}")print(V.describe().T[["mean", "50%", "min", "max"]].round(1).to_string())
# ---------------------------------------------------------------- 3. transform, standardise, check correlationssection("transformation, standardisation and correlation")skew_raw = V.skew()T = np.arcsinh(V) # inverse hyperbolic sine: log-like, but defined at zeroZ = (T - T.mean()) / T.std(ddof=0)print(f"median |skewness|: raw {skew_raw.abs().median():.2f} -> IHS {T.skew().abs().median():.2f}; " f"most skewed raw: " + ", ".join(f"{k} {v:.1f}" for k, v in skew_raw.sort_values(ascending=False).head(3).items()))C = Z.corr()pairs = [(a, b, C.loc[a, b]) for i, a in enumerate(C) for b in C.columns[i + 1:] if abs(C.loc[a, b]) >= 0.8]for a, b, r in sorted(pairs, key=lambda p: -abs(p[2])): print(f"|r| >= 0.8: {a} ~ {b}: r = {r:.2f}")DROP = ["Degree level (L4+)", "Owns with mortgage"]print(f"dropping {DROP}: degree level duplicates higher professional (r = {C.loc[DROP[0], 'Higher professional']:.2f}); " "the four tenure shares sum to ~100%, so one is redundant")Zk = Z.drop(columns=DROP)X = Zk.to_numpy()print(f"{X.shape[1]} variables go into the clustering")
order = C.abs().mean().sort_values().indexfig, ax = plt.subplots(figsize=(8.6, 7.2))im = ax.imshow(C.loc[order, order], cmap="RdBu_r", norm=TwoSlopeNorm(0, -1, 1))ax.set_xticks(range(len(order)), order, rotation=90, fontsize=7); ax.set_yticks(range(len(order)), order, fontsize=7)for a, b, r in pairs: for (p, q) in ((a, b), (b, a)): ax.add_patch(plt.Rectangle((list(order).index(q) - 0.5, list(order).index(p) - 0.5), 1, 1, fill=False, ec="black", lw=1.2))fig.colorbar(im, ax=ax, shrink=0.75, label="Pearson r (IHS-transformed, standardised)")ax.set_title(f"Correlation between {len(order)} Census 2021 variables across Cardiff's {len(V):,} Output Areas\n" "(boxes: |r| ≥ 0.8)")fig.savefig(OUT / "01-correlation-matrix.png"); plt.close(fig)
# ---------------------------------------------------------------- 4. k-means: elbow, silhouette, stabilitysection("k-means: choosing k")KS = range(2, 13)inertia, sil = [], []for k in KS: km = KMeans(k, n_init=50, random_state=SEED).fit(X) inertia.append(km.inertia_); sil.append(silhouette_score(X, km.labels_)) print(f"k={k:>2}: within-cluster sum of squares {km.inertia_:8.0f} | silhouette {sil[-1]:.3f}")K = 7stab = {}for k in (4, 5, 6, 7, 8, 9): runs = [KMeans(k, n_init=1, random_state=s).fit(X).labels_ for s in range(30)] best = KMeans(k, n_init=100, random_state=SEED).fit(X).labels_ stab[k] = (np.median([adjusted_rand_score(best, r) for r in runs]), np.mean([adjusted_rand_score(runs[i], runs[j]) for i in range(30) for j in range(i + 1, 30)])) print(f"k={k}: 30 single-start runs vs the 100-start solution, median ARI {stab[k][0]:.3f}; " f"mean pairwise ARI {stab[k][1]:.3f}")print(f"chosen k = {K}")
fig, axes = plt.subplots(1, 3, figsize=(12, 3.5), layout="constrained")axes[0].plot(list(KS), inertia, "o-", color="#332288"); axes[0].set_title("Elbow: within-cluster sum of squares")axes[1].plot(list(KS), sil, "o-", color="#117733"); axes[1].set_title("Mean silhouette width")ks = list(stab)axes[2].plot(ks, [stab[k][0] for k in ks], "o-", color="#CC6677", label="single start vs best of 100")axes[2].plot(ks, [stab[k][1] for k in ks], "s--", color="#882255", label="mean pairwise, 30 seeds")axes[2].set_title("Stability across random seeds (ARI)"); axes[2].legend(fontsize=7); axes[2].set_ylim(0, 1.02)for ax in axes: ax.axvline(K, color="#999999", ls=":", lw=1.2); ax.set_xlabel("number of clusters k"); ax.grid(alpha=0.3)axes[0].set_ylabel("sum of squared distances (z units²)"); axes[1].set_ylabel("silhouette"); axes[2].set_ylabel("ARI")fig.savefig(OUT / "02-choosing-k.png"); plt.close(fig)
# ---------------------------------------------------------------- 5. final k-means and profilessection("final k-means classification")km = KMeans(K, n_init=100, random_state=SEED).fit(X)sizes = pd.Series(km.labels_).value_counts()relabel = {old: new for new, old in enumerate(sizes.index)} # cluster 0 = largestoa["kmeans"] = pd.Series(km.labels_).map(relabel).to_numpy()s = silhouette_samples(X, oa.kmeans)print(f"k = {K}: mean silhouette {s.mean():.3f}; OAs with negative silhouette {np.mean(s < 0):.1%}")
# index scores: cluster rate / Cardiff rate x 100, from raw percentages weighted by residentscity = (V.mul(residents, axis=0).sum() / residents.sum())city["Population density"] = residents.sum() / (oa.area.sum() / 1e6)idx = pd.DataFrame({c: V[oa.kmeans.to_numpy() == c].mul(residents[oa.kmeans == c], axis=0).sum() / residents[oa.kmeans == c].sum() for c in range(K)}).Tidx["Population density"] = [residents[oa.kmeans == c].sum() / (oa[oa.kmeans == c].area.sum() / 1e6) for c in range(K)]index = (100 * idx / city).round(0)for c in range(K): hi = index.loc[c].sort_values(ascending=False) lo = index.loc[c].sort_values() print(f"cluster {c} ({(oa.kmeans == c).sum()} OAs, {residents[oa.kmeans == c].sum():,} people): " f"high {', '.join(f'{k} {int(v)}' for k, v in hi.head(4).items())} | " f"low {', '.join(f'{k} {int(v)}' for k, v in lo.head(3).items())}")
# names chosen after reading the index scores printed above; treat them as labels for discussion, not factsNAMES = {0: "Social-rented family estates", 1: "Detached owner-occupier suburbs", 2: "Older settled homeowners", 3: "Dense multicultural inner city", 4: "Professional terraces", 5: "City-centre renters in flats", 6: "Student quarter"}label = lambda c: f"{c + 1}. {NAMES[c]}"for c in range(K): print(f" {label(c)}")oa["kmeans_name"] = oa.kmeans.map(label)
fig, ax = plt.subplots(figsize=(10, 6))cmap = ListedColormap(TOL[:K])oa.plot(column="kmeans", cmap=cmap, ax=ax, edgecolor="white", linewidth=0.1, vmin=-0.5, vmax=K - 0.5)oa.dissolve().boundary.plot(ax=ax, color="#333333", lw=0.6)ax.legend(handles=[Patch(fc=TOL[c], label=f"{label(c)} ({(oa.kmeans == c).sum()} OAs)") for c in range(K)], loc="center left", bbox_to_anchor=(1.0, 0.5), fontsize=7.5, title="k-means cluster", title_fontsize=8)ax.set_axis_off(); ax.set_title(f"A {K}-cluster geodemographic classification of Cardiff's {len(oa):,} Output Areas " "(Census 2021)")mx0, my0, mx1, my1 = MAINax.set_xlim(mx0 - 300, mx1 + 300); ax.set_ylim(my0 - 900, my1 + 300)ax.plot([mx0 + 300, mx0 + 2300], [my0 - 500] * 2, color="black", lw=2)ax.text(mx0 + 1300, my0 - 380, "2 km", ha="center", fontsize=7)fig.savefig(OUT / "03-kmeans-map.png"); plt.close(fig)
vars_order = list(V.columns)fig, ax = plt.subplots(figsize=(10, 4.6))L = np.log2(index[vars_order].clip(lower=12.5, upper=800) / 100)im = ax.imshow(L, cmap="PuOr_r", norm=TwoSlopeNorm(0, -3, 3), aspect="auto")for i in range(K): for j, v in enumerate(vars_order): val = int(index.loc[i, v]) ax.text(j, i, f"{val}", ha="center", va="center", fontsize=6.3, color="white" if abs(L.iloc[i, j]) > 1.8 else "black")ax.set_xticks(range(len(vars_order)), vars_order, rotation=60, ha="right", fontsize=7)ax.set_yticks(range(K), [label(c) for c in range(K)], fontsize=7.5)cb = fig.colorbar(im, ax=ax, shrink=0.9, ticks=[-3, -2, -1, 0, 1, 2, 3])cb.ax.set_yticklabels(["≤12.5", "25", "50", "100", "200", "400", "≥800"]); cb.set_label("index score (Cardiff = 100)")ax.set_title("Cluster profiles: index scores against the Cardiff average (100 = same as the city; 200 = twice)")fig.savefig(OUT / "04-index-scores.png"); plt.close(fig)
RADAR = ["Aged 0-14", "Aged 20-24", "Aged 65+", "Owns outright", "Social rented", "Private rented", "Flats", "No car", "Higher professional", "Routine occupations", "Full-time students", "Born outside UK", "Welsh speakers"]means = Z.groupby(oa.kmeans.to_numpy())[RADAR].mean().clip(-2.5, 2.5)ang = np.linspace(0, 2 * np.pi, len(RADAR), endpoint=False)fig, axes = plt.subplots(2, 4, figsize=(13, 7), subplot_kw={"polar": True}, layout="constrained")for c, ax in enumerate(axes.flat): if c >= K: ax.set_visible(False); continue vals = np.r_[means.loc[c].to_numpy(), means.loc[c].iloc[0]] ax.plot(np.r_[ang, ang[0]], vals, color=TOL[c] if c != 1 else "#1f78b4", lw=1.8) ax.fill(np.r_[ang, ang[0]], vals, color=TOL[c], alpha=0.35) ax.plot(np.linspace(0, 2 * np.pi, 100), np.zeros(100), color="#555555", lw=0.8, ls="--") ax.set_ylim(-2.5, 2.5); ax.set_yticks([-2, 0, 2], ["-2", "0", "+2"], fontsize=6) ax.set_rlabel_position(180 / len(RADAR)) ax.set_xticks(ang, RADAR, fontsize=6.3) ax.set_title(label(c), fontsize=8.5, pad=14)fig.suptitle("Cluster means in standard deviations from the Cardiff OA mean (dashed ring = 0, clipped at ±2.5)")fig.savefig(OUT / "05-radar-profiles.png"); plt.close(fig)
# ---------------------------------------------------------------- 6. compare with the official 2021/2 OACsection("comparison with the official 2021/2 OAC")oac = pd.read_csv(download(OAC_URL, DATA / "uk_oac_final.csv"), dtype=str).set_index("GeographyCode")nm = pd.read_csv(download(OAC_NAMES, DATA / "oac_names.csv"), dtype=str)sg_names = nm[nm.Level == "Supergroup"].set_index("Classification Code")["Classification Name"]oa["oac_sg"] = oac.Supergroup.reindex(oa.OA21CD).to_numpy()oa["oac_group"] = oac.Group.reindex(oa.OA21CD).to_numpy()print(f"matched {oa.oac_sg.notna().sum()} of {len(oa)} OAs to the official classification")print("official supergroups in Cardiff: " + "; ".join(f"{k} {sg_names[k]} {v}" for k, v in oa.oac_sg.value_counts().sort_index().items()))print(f"ARI between our k-means and the official supergroups (8 classes, UK-wide): " f"{adjusted_rand_score(oa.oac_sg, oa.kmeans):.3f}")print(f"ARI against the official groups ({oa.oac_group.nunique()} present): " f"{adjusted_rand_score(oa.oac_group, oa.kmeans):.3f}")ct = pd.crosstab(oa.kmeans, oa.oac_sg)print(ct.to_string())
fig, ax = plt.subplots(figsize=(9, 4.2))share = ct.div(ct.sum(axis=1), axis=0)im = ax.imshow(share, cmap="Blues", vmin=0, vmax=1, aspect="auto")for i in range(share.shape[0]): for j in range(share.shape[1]): if ct.iloc[i, j]: ax.text(j, i, ct.iloc[i, j], ha="center", va="center", fontsize=7, color="white" if share.iloc[i, j] > 0.55 else "black")ax.set_xticks(range(ct.shape[1]), [f"{k}. {sg_names[k]}" for k in ct.columns], rotation=30, ha="right", fontsize=7)ax.set_yticks(range(K), [label(c) for c in range(K)], fontsize=7.5)fig.colorbar(im, ax=ax, label="share of the cluster's OAs")ax.set_title("Our Cardiff clusters (rows) against the official 2021/2 OAC supergroups (columns); numbers are OAs")fig.savefig(OUT / "07-official-oac-crosstab.png"); plt.close(fig)
# ---------------------------------------------------------------- 7. MAUP: the same recipe on LSOAssection("MAUP: repeat at LSOA level")# population-weighted means of the OA percentages approximate the LSOA rates (denominators differ slightly)lsoa_key = oa.LSOA21CD.to_numpy()VL = V.drop(columns="Population density").mul(residents, axis=0).groupby(lsoa_key).sum().div( pd.Series(residents).groupby(lsoa_key).sum().to_numpy(), axis=0)lsoa_geom = oa.dissolve("LSOA21CD")VL["Population density"] = pd.Series(residents).groupby(lsoa_key).sum().to_numpy() / (lsoa_geom.area.to_numpy() / 1e6)TL = np.arcsinh(VL[V.columns].drop(columns=DROP))ZL = ((TL - TL.mean()) / TL.std(ddof=0)).to_numpy()lab_l = KMeans(K, n_init=100, random_state=SEED).fit(ZL).labels_oa_from_lsoa = pd.Series(lab_l, index=VL.index).reindex(lsoa_key).to_numpy()mixed = oa.groupby("LSOA21CD").kmeans.nunique()print(f"{len(VL)} LSOAs, k = {K}: silhouette {silhouette_score(ZL, lab_l):.3f}; " f"ARI with the OA classification (each OA takes its LSOA's cluster) {adjusted_rand_score(oa.kmeans, oa_from_lsoa):.3f}")print(f"LSOAs whose OAs fall in more than one OA-level cluster: {np.mean(mixed > 1):.0%}; " f"max-min range of 'Full-time students' within LSOAs, median {V.groupby(lsoa_key)['Full-time students'].agg(np.ptp).median():.1f} points")
# ---------------------------------------------------------------- 8. contiguous regionalisation with spoptsection("contiguity: spatial weights")w = libpysal.weights.Queen.from_dataframe(oa, use_index=False, silence_warnings=True)print(f"queen contiguity: mean {w.mean_neighbors:.1f} neighbours, islands {len(w.islands)}, " f"connected components {w.n_components}")if w.n_components > 1: w = libpysal.weights.fuse_islands(w, oa) if hasattr(libpysal.weights, "fuse_islands") else w print(f"after joining islands: components {w.n_components}")attrs = list(Zk.columns)reg = oa[["geometry"]].copy()reg[attrs] = Xreg["residents"] = residents
within_ss = lambda lab: sum(((X[lab == c] - X[lab == c].mean(0)) ** 2).sum() for c in np.unique(lab))total_ss = ((X - X.mean(0)) ** 2).sum()
section("SKATER")t0 = time.perf_counter()sk = Skater(reg, w, attrs, n_clusters=K, floor=20, trace=False, islands="increase")sk.solve()oa["skater"] = sk.labels_print(f"SKATER with {K} regions, floor 20 OAs: {time.perf_counter() - t0:.1f} s; region sizes (OAs) " f"{sorted(pd.Series(sk.labels_).value_counts().tolist(), reverse=True)}")
section("max-p")THRESH = 20_000t0 = time.perf_counter()np.random.seed(SEED); random.seed(SEED) # max-p's heuristic is randomisedmp = MaxPHeuristic(reg, w, attrs, "residents", THRESH, top_n=2, max_iterations_construction=50)mp.solve()oa["maxp"] = mp.labels_mp_pop = pd.Series(residents).groupby(mp.labels_).sum()print(f"max-p, every region >= {THRESH:,} residents: p = {mp.p} regions in {time.perf_counter() - t0:.1f} s; " f"region populations {mp_pop.min():,}-{mp_pop.max():,}")
section("comparison: how much homogeneity does contiguity cost?")res = {}for name, lab in [("k-means (no contiguity)", oa.kmeans.to_numpy()), ("SKATER", oa.skater.to_numpy()), ("max-p", oa.maxp.to_numpy())]: lab = np.asarray(lab) r2 = 1 - within_ss(lab) / total_ss # contiguous pieces = connected components of the queen graph once links between groups are cut A = w.sparse.tocoo() keep = lab[A.row] == lab[A.col] parts = connected_components(coo_matrix((A.data[keep], (A.row[keep], A.col[keep])), shape=A.shape))[0] res[name] = {"groups": int(len(np.unique(lab))), "r2": round(float(r2), 3), "pieces": int(parts)} print(f"{name:24s}: {len(np.unique(lab)):>2} groups | share of variance explained {r2:.3f} | " f"contiguous pieces on the map {parts}")print(f"ARI k-means vs SKATER {adjusted_rand_score(oa.kmeans, oa.skater):.3f}; " f"k-means vs max-p {adjusted_rand_score(oa.kmeans, oa.maxp):.3f}")lab16 = KMeans(mp.p, n_init=20, random_state=SEED).fit(X).labels_print(f"for reference, aspatial k-means with k = {mp.p} explains {1 - within_ss(lab16) / total_ss:.3f}")
PAL = ["#88CCEE", "#DDCC77", "#CC6677", "#44AA99", "#AA4499", "#999933", "#332288"]
def region_colours(lab): """Greedy graph colouring so that neighbouring regions never share a colour.""" A = w.sparse.tocoo() G = nx.Graph(); G.add_nodes_from(np.unique(lab)) G.add_edges_from({(lab[i], lab[j]) for i, j in zip(A.row, A.col) if lab[i] != lab[j]}) col = nx.greedy_color(G, strategy="largest_first") return [PAL[col[l] % len(PAL)] for l in lab]
fig, axes = plt.subplots(1, 3, figsize=(14, 4.6), layout="constrained")for ax, col, title in [(axes[0], "kmeans", f"k-means, k = {K}: {res['k-means (no contiguity)']['pieces']} separate " f"pieces, R² {res['k-means (no contiguity)']['r2']:.2f}"), (axes[1], "skater", f"SKATER, {K} regions: R² {res['SKATER']['r2']:.2f}"), (axes[2], "maxp", f"max-p, every region ≥ {THRESH:,} residents: p = {mp.p}, " f"R² {res['max-p']['r2']:.2f}")]: if col == "kmeans": oa.plot(color=[TOL[c] for c in oa.kmeans], ax=ax, edgecolor="white", linewidth=0.05) else: oa.plot(color=region_colours(oa[col].to_numpy()), ax=ax, edgecolor="white", linewidth=0.05) oa.dissolve(col).boundary.plot(ax=ax, color="black", lw=0.7) ax.set_xlim(MAIN[0] - 300, MAIN[2] + 300); ax.set_ylim(MAIN[1] - 300, MAIN[3] + 300) ax.set_axis_off(); ax.set_title(title, fontsize=9)fig.legend(handles=[Patch(fc=TOL[c], label=label(c)) for c in range(K)], loc="outside lower left", ncol=4, fontsize=7, title="left map: k-means clusters", title_fontsize=7.5)fig.suptitle(f"Classification vs regionalisation: the same {len(attrs)} standardised variables, with and without a " "contiguity constraint\n(black lines: region boundaries; colours in the middle and right maps only " "separate neighbouring regions)")fig.savefig(OUT / "06-skater-maxp.png"); plt.close(fig)
oa.to_file(OUT / "cardiff_oa_classification.gpkg", layer="oa", driver="GPKG")json.dump({"k": K, "silhouette": round(float(s.mean()), 3), "regionalisation": res, "maxp_p": int(mp.p)}, open(OUT / "metrics.json", "w"), indent=2)print("\nwrote output/cardiff_oa_classification.gpkg, output/metrics.json and 7 figures")