Spatial Clustering & Regionalization
Regionalization is the art of drawing regions that are both internally homogeneous and geographically contiguous. Statistical agencies use it to build reporting zones with a minimum population; ecologists use it to delineate habitat regions; utilities use it to carve service territories from customer data. The defining constraint is contiguity: unlike ordinary clustering, which can scatter members of a group across the map, regionalization requires every region to be a single connected block of adjacent units. That constraint is enforced through the spatial weight matrix, which tells the algorithm which units are allowed to merge. This page develops the objective functions, the leading methods (Max-P-Regions, SKATER, and Ward with connectivity), and a runnable spopt implementation, all within the broader context of Core Concepts of Spatial Statistics & Geostatistics.
Prerequisites
- Python 3.9+
-
numpy>=1.22,geopandas>=0.13,libpysal>=4.8,spopt>=0.5,scikit-learn>=1.2 - A
GeoDataFrameof areal units in a projected (metric) CRS with a reset integer index - One or more numeric clustering variables, standardised to zero mean and unit variance
- A contiguity weight matrix with a single connected component — islands cannot join a region
Mathematical Core
Regionalization partitions areal units into regions that minimise within-region heterogeneity subject to a contiguity constraint. Let be the standardised attribute vector for unit . A general objective is the total within-region sum of squared deviations from each region’s centroid:
subject to the constraint that every region induces a connected subgraph of the contiguity graph defined by the weight matrix . It is this constraint that separates regionalization from ordinary k-means, which optimises the same objective with no spatial term.
Each symbol has a physical reading worth holding onto. is the number of areal units — counties, tracts, grid cells — and is fixed by the geography you were handed rather than chosen by the model. is the number of regions and the number of clustering variables, so is one unit’s position in a -dimensional attribute space after standardisation. The region centroid is not a geographic centre: it is the mean attribute profile of the units assigned to region , a point in attribute space that no unit need occupy. The squared distance therefore measures how atypical unit is of its own region, and the sum over all units is the total within-region sum of squares. For standardised variables that total starts at roughly when every unit sits in a single region and falls towards zero as approaches . Because it decreases monotonically with , the objective can never select on its own; that number has to come from a floor constraint, an external design requirement, or the point at which extra regions stop buying a meaningful reduction.
SKATER: Minimum Spanning Tree Partitioning
SKATER (Spatial 'K’luster Analysis by Tree Edge Removal) first builds a minimum spanning tree (MST) over the contiguity graph, where each edge carries a cost equal to the attribute dissimilarity between adjacent units:
The MST reduces the full connectivity graph to edges while preserving connectivity. SKATER then removes edges to split the tree into connected subtrees, choosing at each step the edge whose removal yields the greatest reduction in within-region variance. Because every subtree is by construction a connected component of the original graph, each region is guaranteed contiguous.
Max-P-Regions: Maximising Regions Under a Floor
Max-P-Regions inverts the question. Rather than fixing , it fixes a minimum-size threshold and finds the maximum number of regions such that each region satisfies a floor constraint on a spatially extensive variable (population, area, count):
where is the threshold variable for unit . Among all partitions satisfying the floor, Max-P selects the one that also minimises within-region attribute heterogeneity. This is the right formulation when the design requirement is “every region must contain at least 50,000 people” rather than “give me exactly eight regions”. Max-P is NP-hard and is solved with a heuristic (construction followed by local search).
Ward with Connectivity
Agglomerative Ward clustering can be made spatial by supplying a connectivity matrix derived from . At each merge step Ward joins the two adjacent clusters whose union produces the smallest increase in within-cluster variance, and the connectivity constraint restricts merges to spatially adjacent clusters. It produces a full hierarchy (a dendrogram) rather than a single partition, which is useful when you want to explore regionalizations at multiple scales.
The constraint changes Ward’s behaviour in a way worth anticipating. Unconstrained Ward merges the globally cheapest pair at every step, so merge heights rise monotonically and the dendrogram reads as a calibrated distance scale. Once connectivity is supplied, the cheapest available merge is often forbidden because the two clusters do not touch, and the algorithm is forced to take a costlier adjacent merge instead. Merge heights can then fall from one step to the next — a dendrogram inversion — and the familiar habit of cutting the tree at a large vertical gap becomes unreliable. Cut by requested cluster count instead, and read the dendrogram as a nesting structure rather than as a measure of how far apart the merged groups really are.
| Method | You specify | Contiguity | Best when |
|---|---|---|---|
| SKATER | Number of regions | MST subtrees | You know how many regions you need |
| Max-P-Regions | Minimum region size (floor) | Graph merge | You have a size/population requirement |
| Ward + connectivity | Cut level of hierarchy | Adjacency-restricted merges | You want a multi-scale hierarchy |
| k-means (no constraint) | Number of clusters | None | Geography is irrelevant to the grouping |
Annotated Implementation
The spopt.region module implements SKATER and Max-P-Regions against a libpysal weights object. The pattern is: standardise attributes, build contiguity weights, fit the solver, extract labels.
1. Attribute Standardisation and Contiguity Weights
import geopandas as gpd
import numpy as np
import libpysal
from sklearn.preprocessing import StandardScaler
# Load areal units in a projected CRS and reset the index
gdf = gpd.read_file("counties.gpkg").to_crs("EPSG:5070")
gdf = gdf.reset_index(drop=True)
# Clustering variables — standardise so no feature dominates the metric
attrs = ["median_income", "pct_college", "unemployment"]
gdf[attrs] = StandardScaler().fit_transform(gdf[attrs].values)
# Queen contiguity defines which units may merge into a shared region
w = libpysal.weights.Queen.from_dataframe(gdf)
assert w.n_components == 1, "Weight matrix must be a single connected component"
The n_components == 1 assertion is critical: regionalization cannot bridge disconnected components, so islands must be resolved (via KNN or manual bridging) before fitting.
2. SKATER for a Fixed Number of Regions
from spopt.region import Skater
skater = Skater(
gdf,
w,
attrs_name=attrs,
n_clusters=6, # target number of contiguous regions
floor=5, # minimum units per region
trace=False,
)
skater.solve()
gdf["skater_region"] = skater.labels_
print(gdf["skater_region"].value_counts())
Skater takes the GeoDataFrame, the weights, the attribute names, and n_clusters. After solve(), labels_ holds the region index for each unit.
3. Max-P-Regions Under a Size Floor
from spopt.region import MaxPHeuristic
# A spatially extensive threshold variable — e.g. population per unit
gdf["pop"] = gdf["population"].astype(float)
maxp = MaxPHeuristic(
gdf,
w,
attrs_name=attrs, # homogeneity variables
threshold_name="pop", # the floor is applied to this column
threshold=50000, # every region must contain >= 50,000 people
top_n=2,
)
maxp.solve()
gdf["maxp_region"] = maxp.labels_
print(f"Max-P found {maxp.p} regions")
MaxPHeuristic discovers the number of regions rather than taking it as input. maxp.p reports how many regions satisfied the floor.
Output Interpretation
Evaluate a regionalization on two axes: contiguity (are regions connected?) and homogeneity (are units within a region similar?).
def within_region_variance(gdf, label_col, attrs):
"""Mean within-region variance across clustering variables (lower = tighter)."""
total = 0.0
for _, grp in gdf.groupby(label_col):
total += grp[attrs].var(ddof=0).sum() * len(grp)
return total / len(gdf)
wrv = within_region_variance(gdf, "skater_region", attrs)
print(f"Mean within-region variance: {wrv:.3f}")
Good signs: each region label maps to a solid connected block, and within-region variance is well below the global variance of the standardised attributes (which is , the number of variables, since each has unit variance). Warning signs: a region consisting of a single unit (the floor was too low, or an outlier could not merge), or within-region variance close to the global value, which means the contiguity constraint has overwhelmed attribute similarity — often a sign that the attributes carry little spatial structure to begin with. Whether that structure exists is exactly what the spatial autocorrelation metrics quantify: variables with near-zero Moran’s I have no coherent regions to find.
Production Considerations
Complexity. Max-P-Regions is NP-hard; the heuristic’s runtime grows with the number of units and the tightness of the floor. For in the tens of thousands, expect minutes and set top_n modestly. SKATER is cheaper because the MST construction is near-linear in the number of edges and edge removal is .
Determinism. Both heuristics involve randomised initialisation. Pass a fixed random seed where the API exposes one, and record it, so a reported partition can be reproduced.
Islands. Resolve disconnected components before fitting. A single island forces the solver either to fail or to emit a singleton region; neither is usually acceptable. Repair geometry, switch to KNN, or bridge the island manually — the same remedies covered in the spatial weight matrices guide.
Scaling variables. Never skip standardisation. Because the objective is a variance across features, an unstandardised high-magnitude variable will silently dominate every region boundary.
Weak spatial structure. Every method here assumes the clustering variables vary smoothly across space. As that assumption weakens the failure is quiet rather than loud: the solver still returns perfectly contiguous regions, because contiguity is a hard constraint and not a fitted quantity, but the boundaries increasingly trace the shape of the adjacency graph instead of anything in the data. The measurable symptom is within-region variance sitting close to the global value , and the pre-flight check is the univariate Moran’s I of each clustering variable. A variable near zero adds noise to the objective without adding signal, and is better dropped than standardised alongside the rest.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Solver raises a connectivity error | Weight matrix has more than one component | assert w.n_components == 1; resolve islands before fitting |
| Regions form along one variable only | Attributes not standardised | Apply StandardScaler to the clustering columns |
| Max-P returns very few regions | Floor set too high for the threshold variable | Lower threshold or check units of threshold_name |
| A region contains a single unit | Floor too low, or an unmergeable outlier | Raise the floor; inspect the outlier’s attributes |
| SKATER ignores my target count | n_clusters incompatible with floor |
Ensure n_clusters * floor <= n |
| Runtime is very slow | Large with a tight Max-P floor | Reduce top_n; pre-aggregate units; prefer SKATER |
| Non-contiguous region in output | Island slipped through, or wrong weights | Rebuild contiguity weights; verify w.n_components |
| Ward merge heights fall between steps | Connectivity forbade the globally cheapest merge | Expected under the constraint; cut by cluster count, not by dendrogram height |
| Within-region variance barely below the global value | Variables carry little spatial structure | Check each variable’s Moran’s I; drop the near-zero ones and refit |
Next Steps
For a focused, copy-pasteable walkthrough of the Max-P heuristic — passing a libpysal weights object, setting the threshold variable and floor, extracting labels, and mapping the regions — follow Spatially Constrained Clustering with Max-P Regions. Because every method here depends on the connectivity graph, revisit spatial weight matrices to get the contiguity structure right, and use the spatial autocorrelation metrics to confirm your variables have the spatial structure worth regionalizing in the first place.
Frequently Asked Questions
What is the difference between clustering and regionalization?
Attribute-space clustering such as k-means groups observations by feature similarity alone and ignores geography, so a resulting cluster can be scattered across the map. Regionalization adds a contiguity constraint through a spatial weight matrix, requiring every region to form a single connected block of adjacent units. Regionalization trades some attribute homogeneity for spatial contiguity, which is what makes the output usable as administrative or planning regions.
When should I use Max-P-Regions instead of SKATER?
Use SKATER when you know how many regions you want; it partitions a minimum spanning tree into a fixed number of contiguous groups. Use Max-P-Regions when you instead have a minimum-size requirement, such as a minimum population per region, and want the algorithm to find the maximum number of regions that all satisfy that floor while remaining internally homogeneous.
Why must clustering variables be standardised?
Regionalization objectives are built on a distance or variance metric across the clustering variables. If one variable is measured in the thousands and another in fractions, the large-magnitude variable dominates the metric and the regions form almost entirely along that single dimension. Standardising each variable to zero mean and unit variance gives every feature comparable influence on the partition.
Does regionalization guarantee contiguous regions?
Yes, provided the spatial weight matrix is a single connected component. SKATER and Max-P-Regions operate on the connectivity graph and can only merge units that are adjacent, so every output region is contiguous. If the weight matrix contains islands or disconnected components, those units cannot join a region and the solver will fail or isolate them.
How do I choose the number of regions for SKATER?
The within-region sum of squares falls monotonically as p rises, so the objective itself cannot pick p for you. Plot mean within-region variance against p across a range of candidate values and look for the point where the curve flattens, then check that candidate against the practical constraint: the number of reporting zones you need, the crews you can staff, or the minimum unit count implied by the floor. If no flattening appears, the variables probably lack the spatial structure that would make any particular p natural, and Max-P-Regions with an explicit size floor is the more defensible formulation.
Related
- Spatially Constrained Clustering with Max-P Regions — the step-by-step spopt MaxPHeuristic workflow
- Spatial Weight Matrices — the contiguity graph that enforces the region constraint
- Spatial Autocorrelation Metrics — confirming variables carry the spatial structure worth regionalizing
- Hot Spot Analysis with Getis-Ord Statistics — locating intense clusters as a complement to partitioning
← Back to Core Concepts of Spatial Statistics & Geostatistics