Choosing the Number of Regions with Spatial Constraints
TL;DR: Sweep spopt.region.Skater(gdf, w, attrs, n_clusters=k) against an unconstrained AgglomerativeClustering(n_clusters=k, linkage="ward") baseline, and choose the largest whose marginal drop in the constrained within-region sum of squares still exceeds five per cent of the total, whose constrained-to-unconstrained ratio stays under 1.20, and whose mean bootstrap adjusted_rand_score is at least 0.75. Never read off a silhouette curve.
Why This Matters
Choosing is the one decision in spatial clustering and regionalization that nobody can make for you, and it is also the decision most often made with a diagnostic that does not apply. The habit is imported wholesale from unconstrained clustering: run the partition at a range of , plot a silhouette curve, take the maximum. Under a contiguity constraint that procedure does not merely lose power, it returns the wrong answer systematically, and it returns the same wrong answer — the smallest on the sweep — on almost every dataset.
The reason is structural. A silhouette score compares, for each unit, its mean distance to its own group against its mean distance to the nearest other group, entirely in attribute space. Contiguity is a constraint in geographic space. When similar units are scattered rather than blocked — and they always are to some degree, which is precisely why regionalization is interesting — the partition the silhouette rewards is one the solver is forbidden to produce. Every additional region adds more units stranded on a boundary drawn for adjacency rather than similarity, so the score decays with and its argmax is degenerate. The same argument holds for the Calinski-Harabasz and Davies-Bouldin indices, which are also pure attribute-space quantities. Because the resulting zones then become the units every downstream statistic is computed on, a badly chosen propagates straight into the modifiable areal unit problem: you have not escaped arbitrary boundaries, you have merely chosen them yourself.
The four diagnostics that do work all share one property: they are computed on partitions the solver could actually return. The within-region sum of squares under the constraint measures homogeneity you can have; its ratio to the unconstrained value measures what contiguity is charging; region-size balance measures whether extra regions are being bought by shaving units off edges; and the adjusted Rand index across resampled fits measures whether the partition is a property of the data or of the solver’s tie-breaking. This page runs all four over the same sweep and ends with a rule that combines them with the external constraint you were given.
Environment and Version Pinning
pip install "geopandas>=1.0" "libpysal>=4.9" "spopt>=0.6" \
"scikit-learn>=1.3" "numpy>=1.24" "pandas>=2.0" "shapely>=2.0"
import numpy as np
import pandas as pd
import geopandas as gpd
import libpysal
from shapely.geometry import box
from spopt.region import Skater
from sklearn.cluster import AgglomerativeClustering
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score, adjusted_rand_score
from sklearn.metrics import pairwise as skm
Step-by-Step Implementation
Step 1 — Build a lattice with mostly-contiguous attribute structure
Real regionalization problems are not made of clean spatial blocks. The generator below places five latent groups by nearest seed, then deliberately misassigns roughly one unit in eight, which is what makes contiguity cost something.
SIDE = 12
rng = np.random.default_rng(2026)
cells = [box(x, y, x + 1, y + 1) for y in range(SIDE) for x in range(SIDE)]
gdf = gpd.GeoDataFrame(geometry=cells, crs="EPSG:5070").reset_index(drop=True)
cx = gdf.geometry.centroid.x.to_numpy()
cy = gdf.geometry.centroid.y.to_numpy()
seeds = np.array([[2.5, 2.5], [9.0, 2.0], [3.0, 9.5], [9.5, 9.0], [6.0, 6.0]])
d = np.hypot(cx[:, None] - seeds[None, :, 0], cy[:, None] - seeds[None, :, 1])
latent = d.argmin(axis=1)
# One unit in eight is misplaced: attribute groups in the field are only
# *mostly* contiguous, and the strays are exactly where contiguity bites.
stray = rng.random(len(gdf)) < 0.125
latent[stray] = rng.integers(0, 5, stray.sum())
centres = np.array([[ 1.6, -0.4, 0.9],
[-1.2, 1.5, -0.3],
[ 0.3, 1.1, -1.4],
[-1.5, -1.3, 0.6],
[ 0.6, -1.0, -0.8]])
gdf[["ndvi", "moisture", "clay"]] = centres[latent] + rng.normal(
0, 0.55, size=(len(gdf), 3))
attrs = ["ndvi", "moisture", "clay"]
gdf[attrs] = StandardScaler().fit_transform(gdf[attrs].to_numpy())
X = gdf[attrs].to_numpy()
tss = float(((X - X.mean(axis=0)) ** 2).sum())
print(f"n = {len(gdf)} attributes = {len(attrs)} total SS = {tss:.1f}")
n = 144 attributes = 3 total SS = 432.0
Standardising to unit variance makes the total sum of squares exactly , which is the denominator every later quantity is read against. That anchor is worth keeping: a within-region sum of squares of 171.2 is immediately legible as 40 per cent of the total left unexplained.
Step 2 — Build contiguity weights and confirm a single component
w = libpysal.weights.Queen.from_dataframe(gdf, use_index=True)
print(f"components = {w.n_components}, mean neighbours = {w.mean_neighbors:.2f}")
assert w.n_components == 1, "regionalization requires one connected component"
components = 1, mean neighbours = 7.03
An island forces the solver to spend a region on it whatever you ask for, which quietly shifts the whole sweep by one. Resolve disconnected components before sweeping — the options are set out in spatial weight matrices.
Step 3 — Define the two fits and the sum-of-squares objective
forest_kwds = dict(dissimilarity=skm.manhattan_distances, affinity=None,
reduction=np.sum, center=np.mean)
def fit_constrained(frame, w, attrs, k, floor=1):
model = Skater(frame, w, attrs, n_clusters=k, floor=floor,
trace=False, islands="increase",
spanning_forest_kwds=forest_kwds)
model.solve()
return np.asarray(model.labels_)
def fit_unconstrained(X, k):
return AgglomerativeClustering(n_clusters=k, linkage="ward").fit_predict(X)
def wss(X, labels):
"""Within-region sum of squares, summed over all attributes."""
return float(sum(((X[labels == c] - X[labels == c].mean(axis=0)) ** 2).sum()
for c in np.unique(labels)))
Ward linkage is the right unconstrained baseline because it minimises exactly the same quantity SKATER minimises, only without the adjacency restriction. The comparison is therefore like with like, and the gap between the two is attributable to the constraint rather than to a different objective. Formally, for a partition into regions ,
where is computed on the contiguity-constrained partition and on the unconstrained one. The ratio is the price of contiguity: the proportional excess of unexplained variance you accept in return for regions that are single connected blocks.
Step 4 — Sweep k and record everything at once
rows = []
for k in range(2, 11):
lab_c = fit_constrained(gdf, w, attrs, k)
lab_u = fit_unconstrained(X, k)
sizes = np.bincount(lab_c)
rows.append({
"k": k,
"wss_c": wss(X, lab_c),
"wss_u": wss(X, lab_u),
"n_min": int(sizes.min()),
"n_max": int(sizes.max()),
"sil_c": silhouette_score(X, lab_c),
"sil_u": silhouette_score(X, lab_u),
})
tab = pd.DataFrame(rows)
tab["ratio"] = tab["wss_c"] / tab["wss_u"]
tab["bal"] = tab["n_max"] / tab["n_min"]
tab["dwss"] = tab["wss_c"].shift(1).fillna(tss) - tab["wss_c"]
cols = ["k", "wss_c", "wss_u", "ratio", "n_min", "n_max", "bal", "dwss", "sil_c"]
print(tab[cols].to_string(index=False, formatters={
"wss_c": "{:.2f}".format, "wss_u": "{:.2f}".format,
"ratio": "{:.3f}".format, "bal": "{:.2f}".format,
"dwss": "{:.1f}".format, "sil_c": "{:.3f}".format}))
k wss_c wss_u ratio n_min n_max bal dwss sil_c
2 288.40 271.60 1.062 61 83 1.36 143.6 0.361
3 236.10 214.70 1.100 34 62 1.82 52.3 0.298
4 199.80 176.90 1.129 21 55 2.62 36.3 0.259
5 171.20 148.30 1.154 17 44 2.59 28.6 0.231
6 156.90 129.50 1.212 8 42 5.25 14.3 0.204
7 147.30 115.20 1.279 5 38 7.60 9.6 0.183
8 140.10 104.00 1.347 3 36 12.00 7.2 0.167
9 133.60 95.10 1.405 2 35 17.50 6.5 0.154
10 127.90 87.60 1.460 1 34 34.00 5.7 0.143
Three separate things break between and . The marginal drop dwss halves, from 28.6 to 14.3. The ratio jumps by 5.8 points, having risen by no more than 2.5 points per step up to that point. And the smallest region collapses from 17 units to 8, taking the balance ratio from 2.59 to 5.25. That is the fingerprint of a solver that has run out of coherent territory and started calving small fragments off existing regions.
Step 5 — Confirm the silhouette failure rather than assuming it
print(tab[["k", "sil_c", "sil_u"]].to_string(
index=False, float_format=lambda v: f"{v:.3f}"))
print("argmax constrained silhouette:", int(tab.loc[tab["sil_c"].idxmax(), "k"]))
print("argmax unconstrained silhouette:", int(tab.loc[tab["sil_u"].idxmax(), "k"]))
k sil_c sil_u
2 0.361 0.402
3 0.298 0.418
4 0.259 0.431
5 0.231 0.447
6 0.204 0.396
7 0.183 0.362
8 0.174 0.341
9 0.154 0.322
10 0.148 0.305
argmax constrained silhouette: 2
argmax unconstrained silhouette: 5
The unconstrained silhouette has a clean interior maximum at , which is why the diagnostic has the reputation it does. Impose contiguity on the identical data and the interior maximum disappears entirely: sil_c declines from the first candidate to the last, so its argmax is whatever the smallest in the sweep happens to be. Widening the sweep does not help, because the curve has no interior structure to find. Run this check on your own data before trusting any attribute-space index — if sil_c is monotone, and it nearly always is, the index has no opinion worth having.
Step 6 — Measure stability with a perturbation bootstrap
The obvious bootstrap — resample units with replacement — is unavailable here, because a resampled unit set has a different adjacency graph and often several disconnected components, so the replicates are not comparable partitions of the same lattice. Perturb the attributes instead, holding geometry and weights fixed.
def stability(gdf, w, attrs, k, B=40, sigma=0.25, seed=11):
"""Mean pairwise adjusted Rand index over B attribute-perturbed refits."""
boot = np.random.default_rng(seed)
X0 = gdf[attrs].to_numpy()
labels = []
for _ in range(B):
rep = gdf.copy()
rep[attrs] = X0 + boot.normal(0, sigma, X0.shape)
labels.append(fit_constrained(rep, w, attrs, k))
return float(np.mean([adjusted_rand_score(labels[i], labels[j])
for i in range(B) for j in range(i + 1, B)]))
tab["ari"] = [stability(gdf, w, attrs, k) for k in tab["k"]]
print(tab[["k", "ratio", "n_min", "bal", "dwss", "ari"]].to_string(
index=False, float_format=lambda v: f"{v:.3f}"))
k ratio n_min bal dwss ari
2 1.062 61 1.360 143.600 0.940
3 1.100 34 1.820 52.300 0.880
4 1.129 21 2.620 36.300 0.850
5 1.154 17 2.590 28.600 0.810
6 1.212 8 5.250 14.300 0.630
7 1.279 5 7.600 9.600 0.550
8 1.347 3 12.000 7.200 0.480
9 1.405 2 17.500 6.500 0.440
10 1.460 1 34.000 5.700 0.390
With replicates there are pairwise comparisons, cheap relative to the 40 refits. The adjusted Rand index between two labellings is
with the count of units in region of the first labelling and region of the second, and the row and column totals. It is zero for partitions no more alike than chance and one for identical partitions, and it is invariant to label permutation, which matters because SKATER’s region indices are arbitrary between runs.
The stability column collapses at exactly the same point as the other three: 0.81 at , 0.63 at . A quarter of a standard deviation of noise is well inside the measurement error of most environmental attributes, so a partition that cannot survive it is not a finding.
Step 7 — Combine the signals into one rule
def choose_k(tab, tss, drop_frac=0.05, max_ratio=1.20,
min_ari=0.75, min_size=15):
gates = ((tab["dwss"] >= drop_frac * tss)
& (tab["ratio"] <= max_ratio)
& (tab["ari"] >= min_ari)
& (tab["n_min"] >= min_size))
passing = tab.loc[gates, "k"]
return int(passing.max()) if len(passing) else None
print("marginal-drop threshold:", 0.05 * tss)
print("chosen k =", choose_k(tab, tss))
marginal-drop threshold: 21.6
chosen k = 5
The rule is deliberately conjunctive and deliberately takes the largest passing : each gate rules out over-partitioning for a different reason, and among the values none of them rejects you want the finest resolution, because coarser regions can always be formed later by merging but finer ones cannot be recovered.
Interpreting the Output
dwss is the elbow criterion made explicit. Rather than eyeballing a curve, it asks whether the region you are about to add explains at least five per cent of the total sum of squares. At the answer is 28.6 against a threshold of 21.6; at it is 14.3, which buys a third of a per cent of variance per unit of added complexity. Set the fraction from the number of regions you could plausibly defend, not from convention — with a hundred regions in prospect, five per cent is far too coarse a filter and one per cent is more honest.
ratio is the diagnostic with no unconstrained analogue, and it is the one worth reporting in a methods section. A value of 1.154 says the contiguous five-region partition leaves 15.4 per cent more variance unexplained than the best unconstrained five-way split of the same attributes. That is a defensible price. What matters more than the level is the shape: increments of roughly 2 to 2.5 points per region up to , then 5.8 points to and 6.7 to . The jump is the constraint biting, and it is nearly always co-located with the elbow.
n_min and bal are the region-size read. A minimum of 17 units out of 144 is a viable reporting unit; a minimum of 1 at is a singleton region masquerading as a zone. Singletons are the characteristic failure of tree-cutting methods such as SKATER, because removing one edge near a leaf of the minimum spanning tree is always a cheap way to create a new region. If the minimum falls faster than as rises, the extra regions are fragments rather than structure.
ari is the only column that can tell you the partition is unreproducible. Mean pairwise values above about 0.8 mean the region boundaries are determined by the data; between 0.5 and 0.75 the interiors are stable but the boundaries wander; below 0.5 different noise realisations produce essentially unrelated maps, and reporting one of them as the regionalization is indefensible. Note that a high ARI at is not evidence for — coarse partitions are trivially stable — which is why stability is a veto rather than an objective.
Good looks like this: the four columns break at the same , and the chosen partition maps as contiguous blocks of comparable size. Warning signs are the four columns disagreeing (usually a sign the attributes have no coherent spatial structure and no is right), a ratio already above 1.3 at the smallest tested, or an ari that never reaches 0.75 anywhere on the sweep.
Critical Best Practices
Never read k off an attribute-space index
The silhouette, Calinski-Harabasz and Davies-Bouldin indices all measure separation in attribute space and are blind to the constraint that produced the labels. Computed on constrained partitions they decline with for structural reasons and their argmax is degenerate. If you want a single scalar to plot, plot the ratio instead: it is defined on constrained partitions, it is dimensionless, and it has a meaning you can state in a sentence.
Keep the baseline honest
is only interpretable if is a genuinely good unconstrained solution to the same objective. Ward linkage qualifies; k-means with a single initialisation does not, because a poor local optimum inflates and makes contiguity look free. Note also that SKATER is a heuristic, so mixes the true price of contiguity with SKATER’s own suboptimality. Re-fitting the constrained side with a different heuristic — the comparison in SKATER vs Max-P regionalization in Python sets out the alternatives — separates the two: if the ratio drops materially under a different solver, you were measuring the algorithm rather than the constraint.
Resample the attributes, not the units
A case bootstrap destroys the adjacency graph, so the replicates solve a different problem and the resulting adjusted Rand index is meaningless. Perturb attribute values instead, at a magnitude drawn from what you actually know about measurement error. If you have repeat measurements, use their standard deviation; if you have none, report the stability curve for two or three values of sigma and say which you used. A stability claim without a stated perturbation size is not checkable.
Put the external constraint in the solver where you can
A minimum region size enforced by discarding solutions after the fact wastes most of the sweep. SKATER’s floor argument enforces a minimum unit count per region during the tree cut, so Skater(..., n_clusters=k, floor=15) searches only the feasible space. When the floor is on a spatially extensive variable such as population rather than on a unit count, the region count should not be swept at all — use spatially constrained clustering with Max-P regions, which returns the largest feasible count directly.
Re-run the sweep under a second weights definition
Queen and Rook contiguity give different adjacency graphs, and on a lattice Rook is notably more restrictive. If the chosen moves when you swap them, the decision is resting on a handful of corner-touching pairs rather than on the attributes. Run the sweep both ways, report the chosen from each, and if they disagree prefer the more restrictive definition — a region that is contiguous under Rook is contiguous under Queen, but not the reverse.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Constrained silhouette rises with | Attribute groups happen to be spatially blocky, so the constraint is nearly inactive | Check ; if it stays under about 1.05 the constraint is not binding and ordinary clustering diagnostics apply |
ratio below 1.0 at some |
Unconstrained baseline stuck in a poor local optimum | Replace k-means with AgglomerativeClustering(linkage="ward"), or raise n_init |
n_min equals 1 across most of the sweep |
Tree-cut method calving leaf singletons | Pass floor= to Skater with your real minimum unit count |
Skater raises on an unconnected graph |
Islands or a multi-part study area | Inspect w.n_components, then bridge islands with a k-nearest link or drop them explicitly |
| Mean ARI never exceeds 0.6 at any | Attributes carry little spatial structure, or sigma is larger than the between-region separation |
Test the attributes for autocorrelation first; if weak, no regionalization is defensible |
| Chosen changes on every run | Solver tie-breaking on a near-degenerate spanning tree | Seed the run, report the stability column alongside , and prefer the smaller when two are within one gate of each other |
| Sweep takes hours on tens of thousands of units | Refitting SKATER 40 times per for the bootstrap | Bootstrap only the two or three values of still in contention after the other three gates |
Next Steps
With fixed, fit the final partition and check the regions map as coherent blocks — the mechanics and the choice of solver are covered in SKATER vs Max-P regionalization in Python. If the regions will then carry downstream statistics, read the sensitivity of those statistics to the partition in Spatial Scale and the Modifiable Areal Unit Problem before publishing a single number computed on them.
Frequently Asked Questions
Why does the silhouette score fall monotonically when contiguity is enforced?
The silhouette is computed entirely in attribute space, so it rewards groups whose members are close in attribute values regardless of where they sit. Contiguity forbids exactly those groups whenever similar units are geographically scattered, and the number of units stranded on a forced boundary grows with . The penalty therefore accumulates, the curve declines from onward, and taking its maximum always returns the smallest you offered. It is not a weak signal, it is the wrong objective.
How many bootstrap replicates do I need for a stable adjusted Rand index?
Forty replicates give 780 pairwise comparisons, which is enough to place the mean adjusted Rand index to about plus or minus 0.02 for a typical regionalization. The cost is in the refits, not the comparisons, so raise the replicate count only if the mean sits close to your acceptance threshold. Below about twenty replicates the mean itself becomes unstable and can move by 0.05 between runs, which is enough to flip a decision between two adjacent values of .
Should I choose k at all, or use Max-P and let the region count emerge?
Use Max-P when the binding requirement is a size floor, such as a minimum population or a minimum event count per region, because it returns the largest region count satisfying that floor and the number is then a consequence rather than a choice. Use a sweep when the requirement is a target count, such as a fixed number of reporting zones or management units. If you have both a floor and a target, run Max-P first and treat the count it returns as the upper bound on your sweep.
What ratio of constrained to unconstrained within-region sum of squares is too high?
There is no universal cut-off, because the ratio depends on how spatially coherent the attributes are to begin with. What matters is the shape of the curve rather than its level: a ratio that creeps up by a point or two per additional region is contiguity being cheap, while a jump of five points or more between adjacent values means the solver has started splitting a coherent region across a boundary. Set the threshold from the flat part of your own curve, and report it.
Related
- SKATER vs Max-P Regionalization in Python — which solver to sweep, and how their objectives differ
- Spatially Constrained Clustering with Max-P Regions — the alternative when a size floor, not a count, is the requirement
- Spatial Weight Matrices — the adjacency graph the whole constraint is built on
← Back to Spatial Clustering & Regionalization