Spatial Weight Matrices: Construction & Validation in Python

A spatial weight matrix WW formalises geographic proximity as a computable structure, translating the question “which observations are neighbours?” into numerical relationships that power every spatial statistics workflow. From computing spatial autocorrelation metrics to fitting spatial regression models, WW is the shared dependency that must be constructed carefully before any downstream analysis is trustworthy. This page covers the full construction and validation workflow in Python, with annotated code, diagnostic routines, and production scaling patterns — all within the broader context of Core Concepts of Spatial Statistics & Geostatistics.

Prerequisites

  • Python 3.9+
  • numpy>=1.22, scipy>=1.9, geopandas>=0.13, libpysal>=4.8, esda>=2.4
  • A GeoDataFrame with valid, non-overlapping geometries and a projected (metric) CRS. Distance-based weights require metres — UTM or a regional projection (State Plane, OSGB, etc.).
  • Unique, sequential integer index on the GeoDataFrame. libpysal maps matrix rows to positional indices.
  • No self-intersections or unclosed rings. Run gdf.geometry.is_valid.all() and repair before weight generation.

Mathematical Core

A spatial weight matrix WW is an n×nn \times n real-valued matrix where element wijw_{ij} encodes the spatial relationship between observation ii and observation jj. By convention, wii=0w_{ii} = 0 (no self-influence). The raw (binary) form assigns:

wij={1if i and j are neighbours0otherwisew_{ij} = \begin{cases} 1 & \text{if } i \text{ and } j \text{ are neighbours} \\ 0 & \text{otherwise} \end{cases}

For row-standardised weights, each element is scaled by the row sum:

w~ij=wijk=1nwik\tilde{w}_{ij} = \frac{w_{ij}}{\sum_{k=1}^{n} w_{ik}}

ensuring jw~ij=1\sum_{j} \tilde{w}_{ij} = 1 for every observation ii that has at least one neighbour. The spatially lagged value of attribute yy at location ii is then:

(Wy)i=j=1nw~ijyj(Wy)_i = \sum_{j=1}^{n} \tilde{w}_{ij} \, y_j

which is the neighbourhood-weighted average of yy across all locations that WW designates as ii’s neighbours. In practice WW is sparse — typically fewer than 5% of entries are non-zero — so libpysal stores it in CSR (Compressed Sparse Row) format internally.

Topology Options and Decision Criteria

Choosing a spatial weight topology Decision diagram: polygon areal data leads to Queen or Rook contiguity; point data with a known interaction radius leads to distance-band weights; point data with sparse or irregular distribution leads to KNN weights. What is your data type? observation geometry Polygon areal Points, known range Points, sparse/uneven Contiguity Queen.from_dataframe(gdf) Queen shares edge or vertex Rook shares full edge only Distance Band DistanceBand(gdf, threshold) K-Nearest Neighbours KNN.from_dataframe(gdf, k=4) All topologies require a metric (projected) CRS and a reset integer index

Queen vs Rook. Queen contiguity is the standard default for polygon data — it treats shared vertices as sufficient for neighbourhood, producing denser connectivity. Rook is preferable when diagonal contact is not meaningful (regular grids, census tracts where corner-only touch is administrative artefact rather than real adjacency).

Distance Band vs KNN. A fixed distance threshold works well when you have domain knowledge about the interaction range (e.g., 5 km for air-quality monitoring stations). KNN produces a symmetric-like structure of constant neighbourhood size and avoids the isolated-node problem in unevenly distributed data, at the cost of slightly unequal distances between neighbours.

Annotated Implementation

1. Geometry Ingestion and Topological Repair

python
import geopandas as gpd
import libpysal

# Load and reproject to a metric CRS (UTM Zone 18N)
gdf = gpd.read_file("study_area.shp").to_crs("EPSG:26918")

# Repair invalid geometries before topology detection
if not gdf.geometry.is_valid.all():
    gdf["geometry"] = gdf.geometry.make_valid()

# libpysal expects a 0-based sequential integer index
gdf = gdf.reset_index(drop=True)

Always verify the index after reset: assert list(gdf.index) == list(range(len(gdf))). A non-sequential or string index is the primary cause of silent row-column misalignment in the resulting weight object.

2. Building the Neighbourhood Topology

python
# Queen contiguity — standard for polygon areal data
w_queen = libpysal.weights.Queen.from_dataframe(gdf)

# Rook contiguity — edges only, no vertex neighbours
w_rook = libpysal.weights.Rook.from_dataframe(gdf)

# Fixed distance band (5 000 m) — requires metric CRS
w_dist = libpysal.weights.DistanceBand.from_dataframe(gdf, threshold=5000.0)

# K-nearest neighbours (k=4) — adaptive, avoids islands
w_knn = libpysal.weights.KNN.from_dataframe(gdf, k=4)

3. Row-Standardisation

python
import numpy as np

# In-place transformation to row-standardised weights
w_queen.transform = "r"

# Verify: every row sum must be 1.0 (islands excepted)
row_sums = np.array(w_queen.sparse.sum(axis=1)).flatten()
non_island_mask = ~np.isin(np.arange(w_queen.n), w_queen.islands)
assert np.allclose(row_sums[non_island_mask], 1.0, atol=1e-10), \
    "Row-standardisation failed for non-island observations"

For custom inverse-distance weighting or boundary-length-proportional schemes, see Building Custom Spatial Weights Matrices.

4. Full Diagnostic Routine

python
def diagnose_weights(W, label="W"):
    """
    Structural QA for a libpysal weights object.
    Raises ValueError for critical failures; prints warnings for borderline cases.
    """
    n = W.n
    islands = W.islands
    n_components = W.n_components
    density = W.sparse.nnz / (n * n)
    min_neighbours = min(len(v) for v in W.neighbors.values())
    max_neighbours = max(len(v) for v in W.neighbors.values())

    print(f"[{label}] n observations:      {n}")
    print(f"[{label}] Isolated units:       {len(islands)}")
    print(f"[{label}] Connected components: {n_components}")
    print(f"[{label}] Matrix density:       {density:.4%}")
    print(f"[{label}] Neighbour range:      {min_neighbours}{max_neighbours}")

    if len(islands) > 0:
        print(f"  WARNING — island indices (first 10): {islands[:10]}")
    if n_components > 1:
        raise ValueError(
            f"[{label}] Disconnected graph ({n_components} components). "
            "Spatial autoregressive models require a single connected component."
        )
    if density > 0.15:
        print(f"  WARNING — density {density:.1%} exceeds 15%; "
              "consider tighter threshold or KNN to maintain sparse structure.")

    return W

diagnose_weights(w_queen, label="Queen")

Key diagnostic thresholds to watch:

Metric Healthy Warning
W.islands count 0 > 0 — topological gaps or threshold too small
W.n_components 1 > 1 — spatial models will fail eigenvalue decomposition
Matrix density < 5% for large nn > 15% — may saturate sparse solvers
Min neighbours ≥ 1 0 = island; resolve before modelling

Output Interpretation

A healthy weight matrix for polygon data looks like this in practice:

  • Density 1–8%: typical for administrative polygon datasets (counties, census tracts). Each observation has an average of 4–8 neighbours.
  • n_components = 1: the neighbourhood graph is fully connected. Every observation can reach every other through a chain of neighbours — a structural requirement for spatial regression estimators.
  • Row sums = 1.0 (after standardisation): confirms that Wy produces true weighted averages rather than neighbour-count-inflated sums.
  • Asymmetry flag: contiguity weights from real polygon data are typically symmetric (if ii neighbours jj, then jj neighbours ii). Check W.asymmetry() — unexpected asymmetry often signals geometry overlap or sliver artefacts.

When you visualise the connectivity graph (libpysal.weights.util.WSP2W or networkx export), look for isolated subgraphs at the dataset boundary — these are boundary-effect artefacts where the study region terminates. Understanding how to detect and mitigate these is closely tied to the concepts discussed in stationarity and trend analysis, since boundary artefacts can mimic non-stationary behaviour in residuals.

Integration with Downstream Spatial Models

Once validated, WW feeds directly into autocorrelation statistics, regression estimators, and clustering workflows.

python
from scipy.sparse import csr_matrix
import esda

# ── Spatial autocorrelation (Moran's I) ──────────────────────────────────────
y = gdf["target_variable"].values
moran = esda.Moran(y, w_queen, permutations=999)
print(f"Moran's I = {moran.I:.4f}  (p = {moran.p_sim:.3f})")

# ── Spatial lag (neighbourhood-weighted average of y) ────────────────────────
W_sparse = csr_matrix(w_queen.sparse)   # explicit scipy CSR for external libs
Wy = W_sparse @ y                       # shape (n,), compatible with spreg/statsmodels

# ── Spatial regression (Spatial Lag Model via spreg) ────────────────────────
# import spreg
# X = gdf[["covariate_1", "covariate_2"]].values
# slm = spreg.ML_Lag(y.reshape(-1, 1), X, w=w_queen, name_y="target",
#                    name_x=["covariate_1", "covariate_2"])

The weight matrix you construct here is consumed directly by esda.Moran, esda.Moran_Local, and spreg regression estimators. Changing the topology (Queen → KNN) changes the neighbourhood definition and therefore the Moran’s I value and regression coefficients — topology choice is a modelling decision, not just an implementation detail.

For detecting local concentration patterns and high-high / low-low clusters once the matrix is built, refer to the spatial autocorrelation metrics page. When working with point processes before defining a weight matrix, point pattern analysis methods can inform appropriate distance thresholds. If your data suffers from uneven sampling coverage, correcting it before matrix construction is covered in sampling bias mitigation.

Production Considerations

Memory and sparsity. For n>10,000n > 10{,}000 observations, never call W.full() — the dense n×nn \times n float64 array is n2×8n^2 \times 8 bytes. Always use W.sparse (CSR format). At n=50,000n = 50{,}000 with average 6 neighbours, the sparse matrix requires roughly 3.6 MB; its dense equivalent would require 20 GB.

Parallelising weight construction. libpysal >= 4.8 supports parallel geometry comparisons via the silence_warnings context and multiprocessing. For very large datasets (> 500 k polygons), tile the geometry into spatial chunks, build per-tile weight objects, and merge adjacency lists before instantiating the global WW:

python
# Chunked construction pattern for large n
from libpysal.weights import W as WSP

neighbor_dict = {}
for tile_gdf in spatial_tiles:
    w_tile = libpysal.weights.Queen.from_dataframe(tile_gdf)
    # merge tile neighbor lists into global neighbor_dict
    # (requires remapping local indices to global indices)

w_global = WSP(neighbor_dict)

Serialisation. Persist validated weight objects with w_queen.to_file("queen_weights.gal", format="gal") (GAL for contiguity) or w_dist.to_file("dist_weights.gwt", format="gwt") (GWT for distance). Avoid rebuilding from geometry on every pipeline run — weight construction over large polygon datasets can take minutes.

Reproducibility. Pin libpysal and geopandas versions in your requirements.txt. Topology algorithms have changed between minor releases. Tag stored weight files with the library version and the CRS EPSG code used during construction.

Troubleshooting

Symptom Likely Cause Fix
ValueError: shape mismatch during Moran() GeoDataFrame index not aligned with weight matrix rows gdf.reset_index(drop=True) before calling from_dataframe
W.n_components > 1 Islands, water bodies, or administrative gaps break graph connectivity Increase distance threshold; switch to KNN; manually add bridge neighbours
Row sums ≠ 1.0 after transform = "r" Islands (zero-weight rows) included in check Mask islands before assertion: row_sums[non_island_mask]
MemoryError on large nn W.full() called on a large matrix Always use W.sparse; never materialise the dense form
libpysal raises topology errors Invalid geometries or mixed CRS (degrees vs metres) gdf.geometry.make_valid() and gdf.to_crs(epsg) before weight construction
Unexpected asymmetry in contiguity weights Sliver polygons or overlapping geometries gdf["geometry"] = gdf.buffer(0) to dissolve slivers; re-validate
Distance-band weights produce many islands Threshold too small relative to point spacing Inspect gdf.geometry.distance(gdf.geometry.iloc[0]) distribution; set threshold above the median nearest-neighbour distance
KNN weights are non-symmetric Expected for KNN (asymmetry is standard) Use libpysal.weights.util.fill_diagonal(w_knn) or symmetrise manually if the downstream model requires symmetric WW

FAQ

What is the difference between Queen and Rook contiguity? Rook contiguity requires a shared edge of positive length between two polygons. Queen contiguity additionally treats shared vertices (corner-touching polygons) as neighbours. Queen produces slightly denser matrices and is the default; Rook is appropriate when you want to exclude diagonal adjacency, such as with grid cells or regular lattices.

Why must I row-standardise spatial weights? Row-standardisation ensures that the spatially lagged variable WyWy is a neighbourhood-weighted average rather than a neighbourhood-count-weighted sum. Observations with many neighbours would otherwise exert disproportionate influence on spatial lag calculations, and eigenvalue decomposition required by spatial autoregressive estimators (SAR, SEM) assumes a properly bounded ρW\rho W product.

What causes spatial islands and how do I fix them? Islands arise from polygon gaps, distance thresholds set below the nearest-neighbour separation, or invalid geometries. Fix options in order of preference: repair geometries (make_valid), increase the distance threshold, switch to KNN (which guarantees every observation has kk neighbours), or manually assign neighbours to isolated units.

How do spatial weight matrices relate to Moran’s I? Moran’s I uses WW to weight cross-products of mean-centred attribute values. The matrix defines which pairs of observations contribute to the spatial covariance estimate. The same attribute data will produce different Moran’s I values under Queen vs KNN topology, because the neighbourhood structure changes which pairs are compared.

Next Steps

For advanced weighting schemes — shared-boundary-proportion weights, gravity-model decay, or time-lagged matrices — see Building Custom Spatial Weights Matrices. Once your matrix is validated, the natural next step is measuring spatial dependence: the global and local statistics available through the spatial autocorrelation metrics workflow build directly on the WW object constructed here.


Related

← Back to Core Concepts of Spatial Statistics & Geostatistics