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 kk 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 kk off a silhouette curve.

Why This Matters

Choosing kk 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 kk, 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 kk 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 kk 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 kk propagates straight into the modifiable areal unit problem: you have not escaped arbitrary boundaries, you have merely chosen them yourself.

Why the silhouette score cannot choose k here On the left, thirty-six units are drawn in attribute space as five tight, well-separated groups; an unconstrained Ward partition at k equals five recovers them and scores a silhouette of 0.447. On the right, the same thirty-six units are drawn on a six by six map, tinted by the same five groups, which are only mostly contiguous: several units of one colour sit inside another colour's territory. The heavy lines show the five contiguous regions a solver is allowed to produce, which cut across the colours and score a silhouette of only 0.231. Why the silhouette score cannot choose k here the same units, scored in attribute space either way attribute space (first two standardised axes) impose contiguity unconstrained Ward, k = 5 silhouette 0.447 – but these groups are not regions SKATER, k = 5 (contiguity enforced) silhouette 0.231 – the same data, a legal partition Contiguity forbids the partition the score rewards, so the constrained silhouette falls at every k and its argmax is always k = 2

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

bash
pip install "geopandas>=1.0" "libpysal>=4.9" "spopt>=0.6" \
            "scikit-learn>=1.3" "numpy>=1.24" "pandas>=2.0" "shapely>=2.0"
python
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.

python
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}")
text
n = 144  attributes = 3  total SS = 432.0

Standardising to unit variance makes the total sum of squares exactly n×p=144×3=432n \times p = 144 \times 3 = 432, 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

python
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"
text
components = 1, mean neighbours = 7.03

An island forces the solver to spend a region on it whatever kk 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

python
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 R1,,RkR_1, \dots, R_k,

W(k)  =  r=1kiRrxixˉr2,ρ(k)  =  Wc(k)Wu(k)    1,W(k) \;=\; \sum_{r=1}^{k} \sum_{i \in R_r} \lVert \mathbf{x}_i - \bar{\mathbf{x}}_r \rVert^2 , \qquad \rho(k) \;=\; \frac{W_c(k)}{W_u(k)} \;\geq\; 1 ,

where WcW_c is computed on the contiguity-constrained partition and WuW_u on the unconstrained one. The ratio ρ(k)\rho(k) 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

python
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}))
text
  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 k=5k = 5 and k=6k = 6. 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.

Constrained and unconstrained within-region sum of squares against k Two falling curves plot the within-region sum of squares from k equals 2 to k equals 10. The constrained SKATER curve runs from 288.4 down to 127.9; the unconstrained Ward curve runs from 271.6 down to 87.6 and lies below it throughout. The shaded gap between them widens steadily. A dashed vertical line marks the chosen value k equals 5, where the marginal drop is 28.6 and the ratio is 1.154. The elbow survives the constraint; the gap is what the constraint costs within-region sum of squares number of regions k 100150200 250300 234 567 8910 chosen k = 5 ΔW = 28.6, ratio 1.154 next step: ΔW = 14.3, ratio 1.212 SKATER – contiguity enforced Ward – unconstrained baseline the price of contiguity Both curves fall forever; only the gap and the marginal drop tell you where to stop

Step 5 — Confirm the silhouette failure rather than assuming it

python
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"]))
text
  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
text
argmax constrained silhouette: 2
argmax unconstrained silhouette: 5

The unconstrained silhouette has a clean interior maximum at k=5k = 5, 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 kk 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.

python
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}"))
text
  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 B=40B = 40 replicates there are (402)=780\binom{40}{2} = 780 pairwise comparisons, cheap relative to the 40 refits. The adjusted Rand index between two labellings is

ARI=ij(nij2)[i(ai2)j(bj2)]/(n2)12[i(ai2)+j(bj2)][i(ai2)j(bj2)]/(n2),\mathrm{ARI} = \frac{\sum_{ij} \binom{n_{ij}}{2} - \left[\sum_i \binom{a_i}{2}\sum_j \binom{b_j}{2}\right] \big/ \binom{n}{2}} {\tfrac{1}{2}\left[\sum_i \binom{a_i}{2} + \sum_j \binom{b_j}{2}\right] - \left[\sum_i \binom{a_i}{2}\sum_j \binom{b_j}{2}\right] \big/ \binom{n}{2}},

with nijn_{ij} the count of units in region ii of the first labelling and region jj of the second, and ai,bja_i, b_j 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 k=5k = 5, 0.63 at k=6k = 6. 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

python
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))
text
marginal-drop threshold: 21.6
chosen k = 5

The rule is deliberately conjunctive and deliberately takes the largest passing kk: 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 k=5k = 5 the answer is 28.6 against a threshold of 21.6; at k=6k = 6 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 k=5k = 5, then 5.8 points to k=6k = 6 and 6.7 to k=7k = 7. 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 k=10k = 10 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 n/kn/k as kk 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 k=2k = 2 is not evidence for k=2k = 2 — 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 kk, 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 kk is right), a ratio already above 1.3 at the smallest kk tested, or an ari that never reaches 0.75 anywhere on the sweep.

The four gates applied to five candidate values of k A matrix with one row per candidate number of regions from three to seven, and one column per gate: marginal drop at least 21.6, ratio at most 1.20, mean adjusted Rand index at least 0.75, and smallest region at least 15 units. Values of k equal to three, four and five clear all four gates; six and seven fail all four. The rule takes the largest passing value, so k equals five is chosen. One decision rule, four independent gates take the largest k that clears every gate – coarser regions can be merged later, finer ones cannot be recovered k regions ΔW ≥ 21.6 5% of total SS ratio ≤ 1.20 price of contiguity ARI ≥ 0.75 bootstrap stability min size ≥ 15 reporting floor verdict 345 67 52.31.1000.8834 units 36.31.1290.8521 units 28.61.1540.8117 units 14.31.2120.638 units 9.61.2790.555 units passes passes chosen rejected rejected Four gates, four different kinds of evidence, one k – when they disagree, the attributes have no coherent spatial structure

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 kk for structural reasons and their argmax is degenerate. If you want a single scalar to plot, plot the ratio ρ(k)\rho(k) 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

ρ(k)\rho(k) is only interpretable if Wu(k)W_u(k) 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 WuW_u and makes contiguity look free. Note also that SKATER is a heuristic, so ρ(k)\rho(k) 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 kk 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 kk 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 kk Attribute groups happen to be spatially blocky, so the constraint is nearly inactive Check ρ(k)\rho(k); if it stays under about 1.05 the constraint is not binding and ordinary clustering diagnostics apply
ratio below 1.0 at some kk 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 kk 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 kk changes on every run Solver tie-breaking on a near-degenerate spanning tree Seed the run, report the stability column alongside kk, and prefer the smaller kk when two are within one gate of each other
Sweep takes hours on tens of thousands of units Refitting SKATER 40 times per kk for the bootstrap Bootstrap only the two or three values of kk still in contention after the other three gates

Next Steps

With kk 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 kk. The penalty therefore accumulates, the curve declines from k=2k = 2 onward, and taking its maximum always returns the smallest kk 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 kk.

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 kk 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 kk 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

← Back to Spatial Clustering & Regionalization