---
title: "Spatial early warning signals"
description: "Spatial variance, Moran's I and spatial skewness measured on a coupled grazing lattice approaching a fold in R, with base stats only, and their caveats."
date: "2026-06-06 13:00"
categories: [early warning signals, spatial statistics, R, resilience, ecology tutorial]
image: thumbnail.png
image-alt: "Three square heatmaps of a 25 by 25 lattice side by side, labelled c = 1, c = 2 and c = 2.58 and filled on a gold to dark green scale: the left panel is nearly uniform, faint patches appear in the middle one, and the right is mottled into correlated light and dark patches. Titled The field grows patchier as the fold nears."
---
Critical slowing down has a spatial face. In a system where neighbouring patches interact, a slow recovery rate means a local perturbation spreads and lingers, so the correlation length grows. Snapshots of such a system, taken as a driver approaches a fold, should show rising spatial variance and rising spatial correlation even without any time series (Dakos et al. 2010; Kefi et al. 2014). A single well-sampled map can, in principle, carry the warning. This post builds a coupled grazing lattice, drives it towards collapse, and measures three proposed spatial indicators with base `stats` only.
## A coupled grazing lattice
Each cell follows the same grazing dynamics as the [temporal tutorial](../early-warning-signals-critical-slowing/), with diffusive coupling to its four rook neighbours on a periodic grid, plus local noise. The grazing pressure `c` is held fixed within each snapshot and stepped up between snapshots.
```{r}
#| label: model
r <- 1; K <- 10; h <- 1
f <- function(x, c) r*x*(1 - x/K) - c*x^2/(x^2 + h^2)
sim_spatial <- function(seed, L = 25, D = 0.30, sigma = 0.20, dt = 0.01,
c_snaps = c(1.0, 1.6, 2.0, 2.3, 2.5, 2.58), burn = 40, hold = 25){
set.seed(seed)
X <- matrix(8.9, L, L)
neigh_mean <- function(M) # rook mean, periodic
(M[c(L, 1:(L-1)), ] + M[c(2:L, 1), ] + M[, c(L, 1:(L-1))] + M[, c(2:L, 1)]) / 4
step <- function(M, c){
lap <- neigh_mean(M) - M
M2 <- M + (f(M, c) + D*lap)*dt + sigma*sqrt(dt)*matrix(rnorm(L*L), L, L)
M2[M2 < 0.001] <- 0.001; M2
}
snaps <- vector("list", length(c_snaps))
for(s in seq_along(c_snaps)){
cc <- c_snaps[s]
for(i in 1:round(burn/dt)) X <- step(X, cc) # relax towards a stationary field
for(i in 1:round(hold/dt)) X <- step(X, cc)
snaps[[s]] <- X
}
list(c = c_snaps, snaps = snaps, L = L)
}
sp <- sim_spatial(seed = 4240)
```
## Three spatial indicators
Spatial variance is the variance across all cells. Spatial skewness is their third standardised moment. Spatial correlation we measure with Moran's I, coded by hand with row-standardised rook weights on the periodic grid, the same construction used in the [Moran's I tutorial](../spatial-autocorrelation-morans-i/).
```{r}
#| label: indicators
moran_grid <- function(M){
L <- nrow(M); z <- as.vector(M) - mean(M); n <- L*L
idx <- function(i, j) ((i-1) %% L)*L + ((j-1) %% L) + 1
num <- 0
for(i in 1:L) for(j in 1:L){
nb <- c(z[idx(i-1, j)], z[idx(i+1, j)], z[idx(i, j-1)], z[idx(i, j+1)])
num <- num + z[idx(i, j)] * sum(nb)/4 # row-standardised weights (1/4 each)
}
num / sum(z^2) # (n / sum of weights) = 1 here
}
skew <- function(v){ v <- v - mean(v); mean(v^3) / (mean(v^2))^1.5 }
tab <- data.frame(
c = sp$c,
mean = sapply(sp$snaps, mean),
s_var = sapply(sp$snaps, function(M) var(as.vector(M))),
s_skew = sapply(sp$snaps, function(M) skew(as.vector(M))),
moran = sapply(sp$snaps, moran_grid))
tau_var <- cor(tab$c, tab$s_var, method = "kendall")
tau_skew <- cor(tab$c, tab$s_skew, method = "kendall")
tau_moran <- cor(tab$c, tab$moran, method = "kendall")
var_fold <- tab$s_var[nrow(tab)] / tab$s_var[1]
```
As the driver climbs from 1.0 to 2.58, the mean biomass falls from `r sprintf("%.2f", tab$mean[1])` to `r sprintf("%.2f", tab$mean[nrow(tab)])`, tracking the sinking upper equilibrium. Spatial variance rises from `r sprintf("%.3f", tab$s_var[1])` to `r sprintf("%.3f", tab$s_var[nrow(tab)])`, a `r sprintf("%.1f", var_fold)`-fold increase (Kendall tau = `r sprintf("%.2f", tau_var)`). Moran's I moves from `r sprintf("%.3f", tab$moran[1])` to `r sprintf("%.3f", tab$moran[nrow(tab)])` (Kendall tau = `r sprintf("%.2f", tau_moran)`). These are the spatial analogue of rising variance and rising lag-1 autocorrelation in time.
```{r}
#| label: setup-theme
#| echo: false
#| message: false
#| warning: false
library(ggplot2)
ink <- "#16241d"; body <- "#2c3a31"; forest <- "#275139"; label <- "#46604a"
paper <- "#f5f4ee"; line <- "#dad9ca"; faint <- "#5d6b61"; amber <- "#cda23f"; brick <- "#b5534e"
grad_lo <- "#c9b458"; grad_hi <- "#1d5b4e"
theme_te <- function(){ theme_minimal(base_size = 11) +
theme(plot.background = element_rect(fill = "#f5f4ee", colour = NA),
panel.background = element_rect(fill = "#f5f4ee", colour = NA),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = line, linewidth = 0.3),
axis.title = element_text(colour = body), axis.text = element_text(colour = faint),
plot.title = element_text(colour = ink, face = "bold", size = 12),
plot.subtitle = element_text(colour = label, size = 10),
legend.position = "none") }
mat_long <- function(M, cc){ L <- nrow(M)
data.frame(row = rep(1:L, times = L), col = rep(1:L, each = L),
value = as.vector(M), c = paste0("c = ", cc)) }
```
```{r}
#| label: fig-fields
#| echo: false
#| fig-width: 8
#| fig-height: 3
#| fig-cap: "Biomass fields at three driver values. Far from the fold the field is smooth; approaching it, correlated patches of high and low biomass emerge as the mean sinks."
#| fig-alt: "Three square heatmaps of a 25 by 25 lattice. At c equals 1 the field is nearly uniform green. At c equals 2 faint patches appear. At c equals 2.58 the field is clearly mottled into correlated light and dark patches."
picks <- c(1, 3, 6)
long <- do.call(rbind, Map(function(i) mat_long(sp$snaps[[i]], sp$c[i]), picks))
long$c <- factor(long$c, levels = paste0("c = ", sp$c[picks]))
p_fields <- ggplot(long, aes(col, row, fill = value)) +
geom_raster() + facet_wrap(~ c, nrow = 1) +
scale_fill_gradient(low = grad_lo, high = grad_hi) +
coord_equal() +
labs(title = "The field grows patchier as the fold nears", x = NULL, y = NULL) +
theme_te() +
theme(axis.text = element_blank(), strip.text = element_text(colour = body))
p_fields
```
```{r}
#| label: thumb
#| include: false
ggsave("thumbnail.png", p_fields, width = 7.5, height = 5.4, dpi = 100)
```
```{r}
#| label: fig-indicators
#| echo: false
#| fig-width: 8
#| fig-height: 3.4
#| fig-cap: "The three spatial indicators against the driver. Spatial skewness wanders with no reliable trend."
#| fig-alt: "Three panels against grazing pressure, each a line with round points and a Kendall tau printed in the panel subtitle. Spatial variance rises. Moran's I rises. Spatial skewness scatters between about minus 0.13 and 0.01 with no clear direction."
p_v <- ggplot(tab, aes(c, s_var)) + geom_line(colour = forest, linewidth = 0.6) +
geom_point(colour = forest, size = 1.8) +
labs(title = "Spatial variance", subtitle = paste0("tau = ", sprintf("%.2f", tau_var)),
x = "grazing pressure c", y = "variance") + theme_te()
p_m <- ggplot(tab, aes(c, moran)) + geom_line(colour = amber, linewidth = 0.6) +
geom_point(colour = amber, size = 1.8) +
labs(title = "Moran's I", subtitle = paste0("tau = ", sprintf("%.2f", tau_moran)),
x = "grazing pressure c", y = "Moran's I") + theme_te()
p_s <- ggplot(tab, aes(c, s_skew)) + geom_line(colour = brick, linewidth = 0.6) +
geom_point(colour = brick, size = 1.8) +
labs(title = "Spatial skewness", subtitle = paste0("tau = ", sprintf("%.2f", tau_skew)),
x = "grazing pressure c", y = "skewness") + theme_te()
grid::grid.newpage()
grid::pushViewport(grid::viewport(layout = grid::grid.layout(1, 3)))
print(p_v, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 1))
print(p_m, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
print(p_s, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 3))
```
## The skewness caveat, and a deeper one
Spatial skewness is supposed to rise in magnitude near a fold, as the field starts to feel the lower state. Here it does not cooperate: its Kendall tau is `r sprintf("%.2f", tau_skew)`, and it wanders between snapshots with no clear direction. A single snapshot is a noisy estimator of a third moment, and one realisation is not enough to pin it down. This is worth stating plainly: not every indicator that has been proposed works on a given system, and reporting only the ones that rose would be the same selection error the [checking tutorial](../checking-early-warning-signals/) warns about.
The deeper caveat applies to spatial variance and Moran's I as well. Spatial variance and correlation rise for reasons other than an approaching fold. Self-organised vegetation patterns, environmental gradients, dispersal limitation, and measurement grain all raise spatial correlation without any loss of resilience. A patchy map is consistent with an approaching transition, but it is equally consistent with a landscape that is simply patchy. Spatial early warning signals narrow the hypotheses; they do not confirm one. The tools that turn a rising indicator into a tested claim are the subject of the final tutorial in this series.
## References
- May 1977 Nature 269(5628):471-477 (10.1038/269471a0)
- Dakos et al. 2010 Theoretical Ecology 3:163-174 (10.1007/s12080-009-0060-6)
- Dai, Korolev and Gore 2013 Nature 496(7445):355-358 (10.1038/nature12071)
- Kefi et al. 2014 PLoS ONE 9(3):e92097 (10.1371/journal.pone.0092097)
## Related tutorials
- [Early warning signals and critical slowing](../early-warning-signals-critical-slowing/)
- [Detrending and bandwidth in early warning signals](../detrending-and-bandwidth-choice-ews/)
- [Checking early warning signals](../checking-early-warning-signals/)
- [Spatial autocorrelation and Moran's I](../spatial-autocorrelation-morans-i/)