Choosing Tolerance Thresholds for Moran’s I Gates

A Moran’s I fidelity gate that compares synthetic output against the source with a hand-picked absolute tolerance such as abs(I_syn - I_src) < 0.05 will reject statistically faithful output at large sample sizes and wave through structurally broken output at small ones, because the sampling variability of Moran’s I is a function of sample size and the weights matrix, not a constant.

Part of Realism Metrics & Evaluation: that stage scores how closely a synthetic layer reproduces the source’s spatial structure, and this page fixes the single most common miscalibration in that stage — treating the acceptance width of an autocorrelation statistic as a fixed number instead of deriving it from the source distribution itself.

Root Cause: A Fixed Tolerance Ignores the Sampling Distribution of I

Moran’s I measures global spatial autocorrelation — the degree to which nearby features carry similar attribute values — against a spatial weights matrix W\mathbf{W}:

I=NWijwij(xixˉ)(xjxˉ)i(xixˉ)2,E[I]=1N1I = \frac{N}{W} \cdot \frac{\sum_{i}\sum_{j} w_{ij}\,(x_i - \bar{x})(x_j - \bar{x})}{\sum_{i}(x_i - \bar{x})^2}, \qquad \mathbb{E}[I] = -\frac{1}{N-1}

Permutation null distribution of Moran's I on a fixed lattice, with the observed value marked A histogram fills the plot, roughly symmetric and centred just below zero. It is built by shuffling the same observed values across the same twenty-four by twenty-four lattice two thousand times and recomputing Moran's I each time, which is the only defensible way to know what values the statistic can take when there is no spatial structure at all. A dashed line marks the theoretical expectation under the null, minus one over n minus one, which sits just left of zero rather than at zero. Two further lines mark the 2.5th and 97.5th percentiles of the permuted values; the band between them is what a gate tolerance should be derived from. Far to the right, well outside the histogram, a marker shows the observed Moran's I of the actual lattice. The distance between the observed value and the edge of the null band is the effect the gate is being asked to protect. The caption underneath makes the operational point: the band is a property of the lattice and the weights matrix, not of the data, so it must be recomputed whenever either changes, and the weights matrix has to be version-pinned alongside the contract for the threshold to mean anything across releases. You cannot pick a Moran's I threshold — you have to permute for it -0.13 -0.073 -0.017 0.039 0.096 0 50 100 150 200 Moran's I under 2,000 random permutations of the same lattice permutations E[I] = −1/(n−1) = -0.0017 2.5% = -0.056 97.5% = +0.059 observed I = +0.712 off the scale, to the right 2,000 permutations on a 24×24 rook-contiguity lattice, fixed seed. The null band is a property of the lattice and the weights matrix, not of the data: change either and it moves. Pin the weights matrix with the contract, or a threshold copied between releases is measuring different things.
2,000 permutations of the same lattice: the null band is a property of the lattice and the weights matrix, so a threshold copied between releases is measuring different things.

where NN is the number of features, wijw_{ij} the weight between features ii and jj, and W=ijwijW = \sum_{i}\sum_{j} w_{ij} the sum of all weights. Two properties of this statistic break any fixed tolerance.

First, the expected value under the null hypothesis of no autocorrelation is not zero — it is 1/(N1)-1/(N-1). For a small synthetic sample of 200 features that offset is about 0.005-0.005; for a source of 50,000 features it is 0.00002-0.00002. A tolerance centred on zero already carries a size-dependent bias before any real difference is measured.

Second, and decisively, the variance of II under the null shrinks as NN grows and depends on the connectivity structure of W\mathbf{W} — the number of neighbours each feature has, and how skewed the attribute distribution is (the kurtosis term). Under randomization the null variance scales roughly as Var[I]1/N\mathrm{Var}[I] \sim 1/N, so the standard deviation falls as 1/N1/\sqrt{N}. A tolerance of 0.050.05 that is comfortably inside one standard deviation at N=300N = 300 can be twenty standard deviations wide at N=100,000N = 100{,}000. The same number is simultaneously too loose to catch real distortion in a large layer and too tight to permit honest sampling noise in a small one.

The consequence in a generation pipeline is a gate that fails for the wrong reason. It fires on runs whose only “defect” is that they were sampled at a different cardinality than the reference, and it stays green on runs whose autocorrelation has genuinely collapsed but happens to land within a wide fixed band. The fix is to stop guessing the width and instead bootstrap the null band from the source, so the acceptance region is calibrated to the sample size and weights the synthetic layer will actually be evaluated at. This is the same discipline the sibling metric evaluating spatial realism with Wasserstein distance applies to distributional distance: the threshold is derived, never assumed.

Calibrated bootstrap null band for a Moran's I gate versus a fixed tolerance A density curve for the distribution of Moran's I estimated by resampling the source at the synthetic sample size. A shaded region between the 2.5th and 97.5th percentiles marks the calibrated acceptance band. The source value sits at the centre of the curve near the expected value minus one over N minus one. A narrow dashed rectangle centred on the source marks a hand-picked fixed tolerance that is far tighter than the true sampling spread. Synthetic candidate A falls inside the calibrated band and passes; synthetic candidate B falls in the right tail outside the band and is rejected. Because the fixed tolerance is narrower than the calibrated band, it would wrongly reject candidate A. source I ≈ E[I] = −1/(N−1) 2.5% 97.5% synthetic A in band → pass synthetic B tail → reject calibrated band (bootstrapped) fixed tolerance (too narrow) The fixed band would wrongly reject synthetic A; the calibrated band accepts it and still rejects B.
A data-driven acceptance band comes from resampling the source at the synthetic's sample size; a fixed tolerance is blind to that spread.

Prerequisite Check: Confirm the Fixed Tolerance Is Miscalibrated

Before rewriting the gate, demonstrate that the sampling spread of I depends on N. Pin the toolchain so the weights construction and permutation streams are identical across machines.

python
# requirements.txt — pin majors, let patch releases float
numpy==1.26.*
scipy==1.11.*
geopandas==0.14.*     # source/synthetic GeoDataFrames
libpysal==4.*         # spatial weights (KNN, Queen, distance band)
esda==2.*             # Moran's I estimator
python
import numpy as np
from libpysal.weights import KNN
from esda.moran import Moran

def moran_i(gdf, value_col: str, k: int = 8) -> float:
    """Global Moran's I under a fixed KNN weights scheme, row-standardized."""
    w = KNN.from_dataframe(gdf, k=k)
    w.transform = "r"                         # row-standardize: W sums to N
    return Moran(gdf[value_col].to_numpy(), w, permutations=0).I

# Resample the SAME source at two cardinalities and watch the spread of I collapse.
rng = np.random.default_rng(20260610)
for n in (300, 30_000):
    vals = [moran_i(source.sample(n, random_state=int(rng.integers(1e9))), "attr")
            for _ in range(200)]
    print(n, f"mean={np.mean(vals):.4f}  sd={np.std(vals):.4f}")
# 300    mean=0.4123  sd=0.0361     <- a 0.05 tolerance is ~1.4 sd: reasonable here
# 30000  mean=0.4488  sd=0.0037     <- the SAME 0.05 tolerance is ~13 sd: far too loose

The standard deviation falls by roughly 100=10×\sqrt{100}=10\times when N grows 100×100\times, exactly as 1/N1/\sqrt{N} predicts. A single fixed number cannot serve both runs.

Fix: Bootstrap a Null Band From the Source, Then Gate Against It

Calibrate the acceptance region in three parts: fix the weights scheme so it is identical for source and synthetic, bootstrap the sampling distribution of I from the source at the synthetic’s cardinality, then accept only if the synthetic I lands inside a percentile band of that distribution. The weights scheme must be frozen in a contract exactly as the scoping rules and data contracts stage freezes the CRS — a KNN gate and a Queen-contiguity gate produce different I values on the same data, so the scheme is part of the metric definition.

False-fail and false-pass rates against a swept Moran's I gate threshold The horizontal axis sweeps a candidate lower-bound threshold for Moran's I. Two curves are plotted against a shared percentage axis. The first, rising, is the proportion of genuinely clean releases the gate would reject: it is near zero for a permissive threshold and climbs to complete rejection as the threshold is raised. The second, falling, is the proportion of genuinely degraded releases the gate would let through: complete at a permissive threshold, near zero at a strict one. The two curves cross, and a marker at the crossing shows the threshold that minimises total error along with the residual error rate there. A shaded band either side of the crossing marks the region where both error rates stay in single figures, which is the range a team can actually negotiate within. The note beneath records that the two populations overlap, so no threshold drives both errors to zero: the choice is which error you would rather make, and that is a product decision written into the manifest rather than a statistical one. Both error rates are real — the threshold chooses which one you prefer 0.435 0.511 0.587 0.663 0.74 0.816 0% 25% 50% 75% 100% candidate gate threshold on Moran's I rate (%) both errors under 10% min total error at I ≥ 0.644 (1.2% residual) clean releases wrongly failed degraded releases wrongly passed 240 clean and 240 degraded releases simulated on a 16×16 lattice, fixed seed. The populations overlap, so no threshold drives both errors to zero — which error you would rather make is a product decision, and it belongs in the manifest next to the number.
The clean and degraded populations overlap, so no threshold drives both error rates to zero — which one you would rather make is a product decision.

Step 1 — Freeze the weights scheme

python
from dataclasses import dataclass

@dataclass(frozen=True)
class MoranGateSpec:
    value_col: str
    k: int = 8                 # KNN neighbours; part of the metric definition
    transform: str = "r"       # row-standardization
    lo_pct: float = 2.5        # calibrated band edges
    hi_pct: float = 97.5
    n_boot: int = 500          # bootstrap replicates
    base_seed: int = 20260610  # versioned, reproducible

Step 2 — Bootstrap the null band at the synthetic’s sample size

Draw n_boot subsamples of the source, each the size of the synthetic layer, and compute I on each. This is the distribution of I you would expect from data statistically indistinguishable from the source, observed at the exact cardinality the synthetic will be judged at — so it absorbs both the 1/(N1)-1/(N-1) offset and the 1/N1/\sqrt{N} spread automatically.

python
import numpy as np

def bootstrap_null_band(source, spec: MoranGateSpec, n_synth: int) -> tuple[float, float]:
    """Percentile band of Moran's I from source subsamples sized like the synthetic."""
    rng = np.random.default_rng(spec.base_seed)
    n = min(n_synth, len(source))                      # cannot draw more than we have
    boot = np.empty(spec.n_boot)
    for b in range(spec.n_boot):
        sub = source.sample(n, replace=(n_synth > len(source)),
                            random_state=int(rng.integers(1 << 32)))
        boot[b] = moran_i(sub, spec.value_col, spec.k)
    lo, hi = np.percentile(boot, [spec.lo_pct, spec.hi_pct])
    return float(lo), float(hi)

Step 3 — Gate the synthetic against the calibrated band

python
from dataclasses import dataclass

@dataclass(frozen=True)
class GateResult:
    passed: bool
    i_synth: float
    band: tuple[float, float]
    reason: str

def morans_i_gate(source, synth, spec: MoranGateSpec) -> GateResult:
    """Accept only if synthetic I lies inside the source-calibrated null band."""
    lo, hi = bootstrap_null_band(source, spec, n_synth=len(synth))
    i_syn = moran_i(synth, spec.value_col, spec.k)
    ok = lo <= i_syn <= hi
    reason = "within source null band" if ok else (
        f"I={i_syn:.4f} outside [{lo:.4f}, {hi:.4f}] "
        f"({'over-clustered' if i_syn > hi else 'under-clustered'})")
    return GateResult(ok, i_syn, (lo, hi), reason)

A synthetic layer whose autocorrelation is higher than the band (i_syn > hi) is over-clustered — often a sign that a point process simulation model has collapsed onto too few attraction centres. One below the band is over-dispersed, the attribute smeared too uniformly across space. The reason string names which failure occurred so the pipeline can route the run to the right diagnostic.

Verification Step: Assert Calibration and Directionality in CI

Wire the gate as a pytest so a regression cannot promote a miscalibrated threshold. These assertions belong in the automated evaluation the CI/CD integration for spatial data stage runs on every generation commit.

python
import numpy as np

def test_morans_i_gate_is_calibrated(source):
    spec = MoranGateSpec(value_col="attr")

    # 1. The band tracks sample size: larger N -> narrower band.
    lo_s, hi_s = bootstrap_null_band(source, spec, n_synth=300)
    lo_l, hi_l = bootstrap_null_band(source, spec, n_synth=30_000)
    assert (hi_l - lo_l) < (hi_s - lo_s), "band must narrow as N grows"

    # 2. A faithful resample of the source PASSES at both sizes.
    for n in (300, 30_000):
        faithful = source.sample(n, replace=True, random_state=7)
        assert morans_i_gate(source, faithful, spec).passed

    # 3. A structurally broken layer is REJECTED with the right direction.
    broken = source.copy()
    broken["attr"] = np.sort(broken["attr"].to_numpy())   # force over-clustering
    res = morans_i_gate(source, broken, spec)
    assert not res.passed and "over-clustered" in res.reason

    # 4. Reproducibility: the band is byte-stable for a fixed seed.
    b1 = bootstrap_null_band(source, spec, n_synth=1000)
    b2 = bootstrap_null_band(source, spec, n_synth=1000)
    assert b1 == b2, "bootstrap band not reproducible under fixed seed"

The size-monotonicity check (assertion 1) is the load-bearing one: it is the property a fixed tolerance can never satisfy, and it is what proves the gate is calibrated rather than guessed.

Edge Cases & Gotchas

Antimeridian-spanning weights. KNN and distance-band weights computed on raw longitudes treat features at +179+179^\circ and 179-179^\circ as thousands of kilometres apart, so a layer straddling the ±180\pm180^\circ seam gets a fragmented weights graph and an artificially low I. Build the weights in a projected or equal-area CRS whose central meridian sits inside the study area (or on great-circle distances), and freeze that projection in the same contract as k, so the source band and the synthetic are measured on identical geometry.

Synthetic larger than the source. When the synthetic layer has more features than the reference, you cannot draw a same-size subsample without replacement. Bootstrapping with replacement (as the replace= flag above handles) is correct but slightly inflates the band’s tails; if the size ratio exceeds roughly 3:1, prefer resampling the synthetic down to the source size for the comparison, and note the reduced power in the gate report rather than silently widening the band.

Near-constant attributes and the zero-variance trap. If a generator produces an attribute column with (near-)zero variance, the denominator i(xixˉ)2\sum_i (x_i-\bar{x})^2 collapses and I is numerically unstable or undefined — yet a naive band can still return finite percentiles from degenerate replicates. Guard with an explicit variance floor before computing I, and fail the gate with a distinct “degenerate attribute” reason so it is never confused with an autocorrelation mismatch.

Frequently Asked Questions

Why bootstrap a band instead of using Moran's I analytic z-score and p-value?
The analytic z-score tests one layer against the null of no autocorrelation; a fidelity gate needs the opposite — whether the synthetic matches the source's specific, non-zero autocorrelation. Bootstrapping the source at the synthetic's cardinality gives the sampling distribution of I around the source value directly, absorbing the sample-size offset and variance without assuming normality or homogeneous weights. It answers "is this synthetic indistinguishable from a fresh draw of the source?", which is the question the gate actually asks.
How many bootstrap replicates do I need for a stable band?
For a 2.5-97.5 percentile band, 500 replicates is a reasonable default and 1000 is comfortable; below about 200 the tail percentiles are themselves noisy and the band edges wobble run to run. Confirm stability by computing the band twice with different seeds and checking the edges agree to your reporting precision. If they do not, raise the replicate count rather than widening the percentile range.
Should the acceptance band be symmetric around the source value?
No — take the empirical percentiles and let them be asymmetric. The sampling distribution of Moran's I is skewed for skewed attributes and for irregular weights graphs, so forcing a symmetric band reintroduces exactly the bias you removed by bootstrapping. Report both edges explicitly and let the gate reason string name whether a rejection was over- or under-clustered.
Does the weights scheme really need to be pinned in a contract?
Yes. Moran's I is only defined relative to a weights matrix, so a KNN-8 gate and a Queen-contiguity gate yield different numbers on identical data. If the scheme drifts between the source band computation and the synthetic evaluation, the comparison is meaningless. Freeze k, the transform, and the projection used to build neighbours alongside the CRS contract, and version them with the metric.