Spatial Autocorrelation Metrics: Global & Local Statistics in Python

Spatial autocorrelation metrics quantify how strongly the values of a geographic variable resemble — or diverge from — those at neighbouring locations. They are the primary diagnostic for detecting non-random spatial structure in areal and point-referenced data, rooted in Tobler’s First Law of Geography. When ignored, spatial dependence violates the independence assumption of classical statistics, inflating Type I error rates and degrading predictive models. This page covers the full workflow: theory, construction of spatial weight matrices, global and local statistics, and production-grade validation — all within the context of the broader Core Concepts of Spatial Statistics & Geostatistics framework.


Prerequisites

  • Python 3.10+
  • geopandas>=1.0, libpysal>=4.8, esda>=2.5
  • numpy>=1.24, scipy>=1.10, matplotlib>=3.7, statsmodels>=0.14
  • A GeoDataFrame with a single numeric attribute column and a projected CRS (e.g., UTM or LAEA — not EPSG:4326)
  • Familiarity with contiguity-based and distance-based weight definitions (see spatial weight matrices)

Mathematical Core

Global Moran’s I

I=nijwijijwij(xixˉ)(xjxˉ)i(xixˉ)2I = \frac{n}{\sum_i \sum_j w_{ij}} \cdot \frac{\sum_i \sum_j w_{ij}(x_i - \bar{x})(x_j - \bar{x})}{\sum_i (x_i - \bar{x})^2}

Where nn is the number of spatial units, wijw_{ij} is the weight between units ii and jj (from the row-standardised matrix W\mathbf{W}), xix_i is the attribute value at location ii, and xˉ\bar{x} is the global mean. The expected value under spatial randomness is:

E[I]=1n1E[I] = -\frac{1}{n-1}

Values significantly above E[I]E[I] indicate positive autocorrelation (clustering); values below indicate negative autocorrelation (dispersion).

Geary’s C

C=(n1)2ijwijijwij(xixj)2i(xixˉ)2C = \frac{(n-1)}{2\sum_i \sum_j w_{ij}} \cdot \frac{\sum_i \sum_j w_{ij}(x_i - x_j)^2}{\sum_i (x_i - \bar{x})^2}

Geary’s C operates on pairwise squared differences rather than deviations from the mean, making it more sensitive to local divergences. It ranges from 0 to 2: values below 1 indicate positive autocorrelation; values above 1 indicate negative autocorrelation.

Local Moran’s I (LISA)

Ii=(xixˉ)m2jwij(xjxˉ),m2=i(xixˉ)2nI_i = \frac{(x_i - \bar{x})}{m_2} \sum_j w_{ij}(x_j - \bar{x}), \quad m_2 = \frac{\sum_i (x_i - \bar{x})^2}{n}

Each feature receives its own statistic IiI_i, enabling classification into high-high, low-low, high-low, and low-high spatial association quadrants.


Workflow Diagram

Spatial Autocorrelation Workflow Five-step pipeline: Load & project GeoDataFrame, Build spatial weights W, Compute global Moran's I and Geary's C, Compute local LISA and G*, then Validate and map significant clusters. Load & project GeoDataFrame geopandas Build spatial weights W libpysal Global stats Moran's I Geary's C esda Local stats LISA (Moran_Local) Getis-Ord G* esda Validate & map FDR correction Moran scatterplot statsmodels Permutation inference (999–9999 shuffles) at every statistical step

Step 1: Data Preparation & Weight Construction

Spatial autocorrelation metrics are entirely dependent on the structure of the underlying weight matrix. Poorly constructed weights produce misleading coefficients regardless of which test follows.

python
import geopandas as gpd
import libpysal

# 1. Load and validate CRS — weights fail on unprojected lat/lon
gdf = gpd.read_file("data/urban_indicators.shp")
if not gdf.crs.is_projected:
    gdf = gdf.to_crs(epsg=32633)  # UTM Zone 33N; choose zone matching your study area

# 2. Repair topology and remove features with null attributes
gdf["geometry"] = gdf.geometry.make_valid()
gdf = gdf.dropna(subset=["geometry", "income_index"]).reset_index(drop=True)

# 3. Queen contiguity — shares edges or vertices; robust for irregular polygons
w = libpysal.weights.Queen.from_dataframe(gdf)

# 4. Warn about disconnected components (islands artificially inflate I)
if w.n_components > 1:
    print(f"Warning: {w.n_components} disconnected components detected. "
          "Consider KNN fallback for isolated features.")
    # w = libpysal.weights.KNN.from_dataframe(gdf, k=4)

# 5. Always row-standardise: spatial lag becomes a weighted average,
#    keeping coefficients comparable across regions with varying neighbour counts
w.transform = "R"
print(f"Features: {w.n}, Max neighbours: {max(w.cardinalities.values())}")

Always call make_valid() before weight construction when your data originates from administrative boundary files — clipped or dissolved geometries frequently contain self-intersections that cause silent failures in Queen.from_dataframe.


Step 2: Global Spatial Autocorrelation

Global statistics return a single coefficient describing structure across the entire study area. Use them to answer: “Is there any evidence of non-random spatial organisation?”

python
import esda
import numpy as np

y = gdf["income_index"].values

# --- Moran's I ---
moran = esda.Moran(y, w, permutations=999)
print(f"Moran's I:           {moran.I:.4f}")
print(f"Expected I:          {moran.EI:.4f}")        # ≈ -1/(n-1)
print(f"Permutation p-value: {moran.p_sim:.4f}")
print(f"z-score:             {moran.z_norm:.4f}")

# --- Geary's C ---
geary = esda.Geary(y, w, permutations=999)
print(f"Geary's C:           {geary.C:.4f}")         # <1 = positive autocorrelation
print(f"Geary's C p-value:   {geary.p_sim:.4f}")

Interpretation quick-reference:

Statistic Positive autocorrelation No structure Negative autocorrelation
Moran’s I IE[I]I \gg E[I] IE[I]I \approx E[I] IE[I]I \ll E[I]
Geary’s C C<1C < 1 C1C \approx 1 C>1C > 1

Always report the permutation count alongside p-values. For publication, raise permutations to 9999 to stabilise pseudo p-values near the significance boundary.


Step 3: Moran Scatterplot

The Moran scatterplot visualises the relationship between each feature’s standardised value (ziz_i) and its spatial lag (jwijzj\sum_j w_{ij} z_j). The slope of the regression line equals Moran’s I. Quadrant position classifies each observation: High-High (upper-right), Low-Low (lower-left), High-Low (lower-right, spatial outlier), Low-High (upper-left, spatial outlier).

python
import matplotlib.pyplot as plt

z = (y - y.mean()) / y.std()
spatial_lag = libpysal.weights.lag_spatial(w, z)

fig, ax = plt.subplots(figsize=(6, 5))
ax.scatter(z, spatial_lag, alpha=0.5, s=18, color="steelblue", label="Features")
ax.axhline(0, color="gray", lw=0.8, ls="--")
ax.axvline(0, color="gray", lw=0.8, ls="--")

# Regression line (slope = Moran's I)
m = np.polyfit(z, spatial_lag, 1)
xs = np.linspace(z.min(), z.max(), 200)
ax.plot(xs, np.polyval(m, xs), color="crimson", lw=1.5,
        label=f"Moran's I = {moran.I:.3f}")

ax.set_xlabel("Standardised value (z)")
ax.set_ylabel("Spatial lag")
ax.set_title("Moran Scatterplot")
ax.legend(fontsize=9)
plt.tight_layout()
plt.savefig("moran_scatterplot.png", dpi=150)

Step 4: Local Indicators of Spatial Association (LISA)

Global coefficients mask local heterogeneity. LISA decomposes the global signal into per-feature statistics, enabling hotspot detection and spatial outlier identification. For a step-by-step walkthrough of the Moran’s I calculation itself, see How to Calculate Moran’s I in PySAL.

python
from statsmodels.stats.multitest import fdrcorrection

# --- Local Moran's I ---
lisa = esda.Moran_Local(y, w, permutations=999, seed=42)

gdf["lisa_I"]     = lisa.Is        # local statistic per feature
gdf["lisa_p"]     = lisa.p_sim     # raw permutation p-value
gdf["lisa_q"]     = lisa.q         # quadrant: 1=HH, 2=LH, 3=LL, 4=HL

# Benjamini–Hochberg FDR correction — mandatory when testing hundreds of features
_, gdf["lisa_p_fdr"] = fdrcorrection(gdf["lisa_p"], alpha=0.05)
gdf["lisa_sig"]   = gdf["lisa_p_fdr"] < 0.05

# --- Getis-Ord G* ---
# Requires binary (not row-standardised) weights with self-inclusion
w_binary = libpysal.weights.Queen.from_dataframe(gdf)   # untransformed
g_star = esda.G_Local(y, w_binary, permutations=999, star=True, seed=42)

gdf["gstar_z"] = g_star.Zs        # positive z: intensity hotspot; negative: coldspot
gdf["gstar_p"] = g_star.p_sim

# Significant clusters only (FDR-adjusted)
_, gdf["gstar_p_fdr"] = fdrcorrection(gdf["gstar_p"], alpha=0.05)
gdf["gstar_sig"] = gdf["gstar_p_fdr"] < 0.05

LISA quadrant classification:

Code Label Meaning
1 High-High Hotspot — high value surrounded by high neighbours
2 Low-High Spatial outlier — low value inside a high-value neighbourhood
3 Low-Low Coldspot — low value surrounded by low neighbours
4 High-Low Spatial outlier — high value inside a low-value neighbourhood

Never map raw LISA values without FDR or Bonferroni correction. Plotting significance at the raw p<0.05p < 0.05 level across 500 features will produce roughly 25 false positives by chance alone.

LISA vs. Getis-Ord G:* LISA identifies the quadrant (direction of association), while Getis-Ord G* measures intensity (magnitude of concentration). Use them together: LISA pinpoints outliers and both-direction clusters; G* ranks the strength of hotspots and coldspots independently.


Step 5: Diagnostic Configuration

Several validation steps must precede final interpretation.

Trend vs. True Clustering

Before running local statistics, test whether your variable exhibits a large-scale spatial trend. Persistent trends inflate global Moran’s I and create artefact hotspots near the gradient boundaries. Apply stationarity and trend analysis — or at minimum, regress out first- and second-order polynomial surfaces — before computing LISA.

python
from sklearn.linear_model import LinearRegression

# Detrend using centroid coordinates
coords = np.column_stack([gdf.geometry.centroid.x, gdf.geometry.centroid.y])
X_poly = np.column_stack([coords, coords**2, coords[:,0]*coords[:,1]])
trend = LinearRegression().fit(X_poly, y)
y_detrended = y - trend.predict(X_poly)

moran_detrended = esda.Moran(y_detrended, w, permutations=999)
print(f"Moran's I (detrended): {moran_detrended.I:.4f}")

Residual Diagnostics

If you are fitting spatial regression models (SAR, SEM, GWR), always run Moran’s I on residuals. Significant autocorrelation in residuals indicates model misspecification.

python
# After fitting a spatial model, test residuals
residuals = y - model.predict(X)
moran_resid = esda.Moran(residuals, w, permutations=999)
if moran_resid.p_sim < 0.05:
    print(f"Residual autocorrelation detected (I={moran_resid.I:.4f}). "
          "Consider spatial lag or spatial error specification.")

Scale Sensitivity (MAUP)

Autocorrelation coefficients are scale-dependent. Aggregating to coarser zones artificially inflates Moran’s I. Always document the spatial resolution and, where feasible, repeat the analysis at multiple zoning schemes to assess sensitivity.


Output Interpretation: What Good Looks Like vs. Warning Signs

Moran scatterplot — healthy signals:

  • Most points in the HH (upper-right) and LL (lower-left) quadrants with a clearly positive slope confirms meaningful clustering.
  • A few HL/LH outliers are expected and theoretically informative.

LISA map — healthy signals:

  • Geographically coherent patches of HH or LL significance (not scattered individual pixels).
  • After FDR correction, significant count drops by less than 50% — if more than half your features lose significance, the raw pattern was largely noise.

Warning signs:

  • Moran’s I > 0.8 on administrative boundary data almost always reflects MAUP artefacts, not genuine clustering.
  • LISA significance concentrated exclusively at the study area perimeter indicates edge effects rather than real hotspots.
  • Geary’s C near 0 with non-significant Moran’s I suggests a local pattern that cancels out globally — investigate LISA quadrants rather than reporting a null global result.

Production Considerations

Memory and Performance

esda.Moran_Local with 9999 permutations on 50,000 features requires holding the attribute array and the full permutation matrix in memory simultaneously. On a 16 GB machine:

python
# Chunked permutation for large datasets — run global first, local per chunk
import psutil
n_features = len(gdf)
# Approximate RAM requirement: n * permutations * 8 bytes (float64)
ram_gb = (n_features * 9999 * 8) / 1e9
print(f"Estimated RAM for LISA: {ram_gb:.1f} GB")

# For n > 20,000: reduce permutations or use seed-fixed reproducibility
perms = 9999 if n_features < 20_000 else 999
lisa = esda.Moran_Local(y, w, permutations=perms, seed=42)

Sparse Matrices

libpysal returns sparse scipy.sparse weight matrices internally. For datasets above ~10,000 features, avoid converting to dense arrays:

python
# Efficient spatial lag computation using sparse representation
from scipy.sparse import issparse
W_sparse = w.sparse   # scipy CSR matrix
spatial_lag_fast = W_sparse.dot(y)  # O(nnz) instead of O(n²)

Parallelisation

For repeated analysis (bootstrap, scenario comparison), parallelise across independent permutation seeds rather than within a single LISA call:

python
from joblib import Parallel, delayed

def run_lisa_seed(y, w, seed):
    return esda.Moran_Local(y, w, permutations=999, seed=seed).Is

results = Parallel(n_jobs=-1)(
    delayed(run_lisa_seed)(y, w, s) for s in range(50)
)

Troubleshooting

Symptom Likely Cause Fix
ValueError: weights matrix must be binary in G_Local Passed row-standardised weights to Getis-Ord Create a separate untransformed w_binary object for G*
IslandWarning from Queen.from_dataframe Geometries with no shared boundaries (enclaves, islands) Use KNN.from_dataframe(gdf, k=4) or manually connect island features
Moran’s I unchanged after detrending Polynomial surface did not capture the dominant gradient Try a higher-degree polynomial (3rd order) or GAM-based detrending
FDR correction removes all significant LISA features Underlying pattern is noise, or raw p-values are poorly calibrated Increase permutations to 9999; verify weight matrix construction
p_sim always 0.001 regardless of data Too few permutations for the observed test statistic Raise permutations to at least 999; use 9999 for publication
Memory error during Moran_Local Permutation matrix exceeds available RAM Reduce permutations, chunk data, or use seed=42 without storing all permutations
Identical Moran’s I across multiple variables Weight matrix cached incorrectly or variable not updated Verify y array slice matches gdf after dropna/reset_index
Inflated Moran’s I > 0.9 on Census tract data MAUP: administrative boundaries artificially align the variable Re-aggregate to a finer/alternative tessellation and re-run

Frequently Asked Questions

What does a Moran’s I of 0.6 mean? A value of 0.6, significantly above the expected value (E[I]1/(n1)0E[I] \approx -1/(n-1) \approx 0), indicates strong positive spatial autocorrelation: similar attribute values tend to be located near one another rather than distributed randomly across the study area.

When should I use Geary’s C instead of Moran’s I? Geary’s C operates on pairwise squared differences between neighbours, making it more sensitive to local heterogeneity and spatial outliers than Moran’s I, which is based on deviations from the global mean. Use them together: Moran’s I for a dataset-level summary, Geary’s C as a complementary check for local departures.

How many permutations should I use? At least 999 for exploratory analysis; 9999 for publication. Fewer permutations produce unstable pseudo p-values, particularly near the significance boundary (e.g., p0.04p \approx 0.04 can fluctuate to p>0.05p > 0.05 with only 99 permutations).

Do I always need FDR correction for LISA? Yes. LISA runs one significance test per feature. At α=0.05\alpha = 0.05 across 400 features, you expect 20 false positives by chance. Benjamini-Hochberg FDR correction (via statsmodels.stats.multitest.fdrcorrection) is the standard adjustment and takes one line of code.


Next Steps

For a focused walkthrough of computing Moran’s I step-by-step with version-pinned PySAL imports, see How to Calculate Moran’s I in PySAL. If your data consists of discrete event coordinates rather than contiguous areal units, the statistics described here will misrepresent spatial structure; Point Pattern Analysis provides the appropriate framework for that case. To understand how autocorrelation evidence feeds weight construction decisions, review spatial weight matrices and the building custom spatial weight matrices guide.



← Back to Core Concepts of Spatial Statistics & Geostatistics