---
title: "Checking a spatial regression"
description: "Diagnose an areal spatial model with residual Moran's I and Lagrange multiplier tests, compare SAR, CAR and filtering, and confront spatial confounding."
date: "2026-06-05 13:00"
date-modified: "2026-10-14"
categories: [spatial, regression, model checking, diagnostics, ecology tutorial]
image: thumbnail.png
image-alt: "Two grids of small green cells side by side with an arrow pointing from the left one to the right one: the OLS residual map falls into smooth dark and light patches, while the spatial model map is a fine scatter of light and dark cells with no visible clustering."
---
*Corrected 14 October 2026: the advice on reading the two Lagrange multiplier tests spoke of the statistics disagreeing in direction and did not say that both are significant on both datasets here; it now says that in that case their ordering is only a first reading and the adjusted forms help decide. The confounding section said that with a smoother covariate or more maps the slope estimate starts to drift; it now says that, with errors unrelated to the covariate, adding maps moves a single estimate about but does not shift it on average.*
*Corrected 1 October 2026: the confounding section read the near tripling of the slope's standard error as lost precision; it now explains that the standard error with no maps is too small for the autocorrelated errors and measures the real, smaller cost by refitting to fresh errors. The model comparison no longer says the error model whitens its innovations, which it leaves slightly negatively autocorrelated.*
You have fitted a spatial model, a [spatial lag or error model](../spatial-autoregressive-models-sar/), a [conditional autoregressive model](../conditional-autoregressive-car-models/), or a [Moran eigenvector filter](../moran-eigenvector-spatial-filtering/). Three questions decide whether to trust it. Did the model actually remove the spatial autocorrelation it was meant to? Which specification do the data prefer? And, the question most tutorials duck, is the covariate effect you care about still identified once space is in the model? This closing tutorial answers all three in base R, and the third answer is a genuine caution rather than a reassurance.
## Setting up
```{r setup}
#| code-fold: true
#| code-summary: "Setup: packages, colours and plot theme"
#| results: hide
#| message: false
#| warning: false
#| fig-show: hide
library(ggplot2)
library(grid)
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
dev = "png", dev.args = list(bg = "#f5f4ee"),
dpi = 150, fig.width = 8, fig.height = 3.5)
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 = element_blank(),
plot.title = element_text(face = "bold", size = 11),
plot.subtitle = element_text(size = 9, colour = "grey30"),
axis.ticks = element_blank(),
legend.key.width = unit(0.35, "cm"))
}
two_panel <- function(g1, g2) {
grid.newpage(); pushViewport(viewport(layout = grid.layout(1, 2)))
print(g1, vp = viewport(layout.pos.row = 1, layout.pos.col = 1))
print(g2, vp = viewport(layout.pos.row = 1, layout.pos.col = 2))
}
moran_test <- function(z, W) {
n <- length(z); z <- z - mean(z)
S0 <- sum(W); S1 <- 0.5 * sum((W + t(W))^2)
S2 <- sum((rowSums(W) + colSums(W))^2)
I <- (n / S0) * as.numeric(t(z) %*% W %*% z) / sum(z^2)
EI <- -1 / (n - 1); b2 <- n * sum(z^4) / (sum(z^2)^2)
VI <- (n * ((n^2 - 3 * n + 3) * S1 - n * S2 + 3 * S0^2)
- b2 * ((n^2 - n) * S1 - 2 * n * S2 + 6 * S0^2)) /
((n - 1) * (n - 2) * (n - 3) * S0^2) - EI^2
list(I = I, p = pnorm((I - EI) / sqrt(VI), lower.tail = FALSE))
}
```
```{r lattice}
side <- 20; n <- side * side
coords <- expand.grid(col = 1:side, row = 1:side)
A <- matrix(0, n, n)
idx <- function(r, c) (c - 1) * side + r
for (r in 1:side) for (c in 1:side) {
i <- idx(r, c)
if (r > 1) A[i, idx(r - 1, c)] <- 1
if (r < side) A[i, idx(r + 1, c)] <- 1
if (c > 1) A[i, idx(r, c - 1)] <- 1
if (c < side) A[i, idx(r, c + 1)] <- 1
}
deg <- rowSums(A); D <- diag(deg); W <- A / deg
ev <- eigen(diag(1 / sqrt(deg)) %*% A %*% diag(1 / sqrt(deg)), symmetric = TRUE)$values
rho_lo <- 1 / min(ev); rho_hi <- 1 / max(ev)
set.seed(4234)
x <- as.numeric(scale(0.8 * sin(pi * coords$col / 7) +
0.6 * scale(coords$row)[, 1] + rnorm(n, 0, 0.6)))
X <- cbind(1, x); b0 <- 0.6; b1 <- 0.7; sig <- 0.5
```
## Check one: is the autocorrelation gone?
The workhorse diagnostic is Moran's I on the residuals. Ordinary residuals from a well-specified spatial model should look spatially random. For a lag model the relevant residuals are the filtered innovations `(I - rho W)y - X beta`; for an error or CAR model they are the whitened innovations. In every earlier tutorial the pattern was the same: the naive model left a large residual Moran's I, and the spatial model drove it towards zero. That single number, computed on the innovations rather than the raw response, is the first thing to report and the first thing a reviewer will ask for.
## Check two: lag or error?
Residual Moran's I tells you *that* autocorrelation is present, not *which* model to use. The Lagrange multiplier tests of Anselin et al. (1996) fill that gap. Both start from ordinary least squares residuals and ask whether adding a lag term, or an error term, would significantly improve the fit. We implement both by hand.
```{r lm-tests}
M <- diag(n) - X %*% solve(crossprod(X)) %*% t(X) # OLS annihilator
Tw <- sum(diag(crossprod(W))) + sum(diag(W %*% W)) # a weights constant
lm_tests <- function(y) {
o <- lm(y ~ x); e <- resid(o); s2 <- sum(e^2) / n
Wy <- as.numeric(W %*% y); We <- as.numeric(W %*% e)
fit <- as.numeric(X %*% coef(o)); WXb <- as.numeric(W %*% fit)
LMerr <- (sum(e * We) / s2)^2 / Tw
Draj <- as.numeric(t(WXb) %*% M %*% WXb) / s2 + Tw
LMlag <- (sum(e * Wy) / s2)^2 / Draj
c(LMerr = LMerr, LMlag = LMlag)
}
```
To see the tests discriminate, we simulate one dataset from a lag process and one from an error process, then run both tests on each.
```{r lm-apply}
set.seed(51); y_lag <- as.numeric(solve(diag(n) - 0.6 * W) %*% (X %*% c(b0, b1) + rnorm(n, 0, sig)))
set.seed(52); y_err <- as.numeric(X %*% c(b0, b1) + solve(diag(n) - 0.6 * W) %*% rnorm(n, 0, sig))
hl <- lm_tests(y_lag); he <- lm_tests(y_err)
chk_lmerr_lag <- hl["LMerr"]; chk_lmlag_lag <- hl["LMlag"]
chk_lmerr_err <- he["LMerr"]; chk_lmlag_err <- he["LMlag"]
rbind(`lag data` = round(hl, 1), `error data` = round(he, 1))
```
The tests point the right way each time. On lag-generated data the lag statistic, `r round(chk_lmlag_lag, 0)`, dwarfs the error statistic, `r round(chk_lmerr_lag, 0)`. On error-generated data the ordering reverses, with the error statistic `r round(chk_lmerr_err, 0)` above the lag statistic `r round(chk_lmlag_err, 0)`. Both are referred to a chi-squared distribution on one degree of freedom, and both by-hand values match spdep's `lm.LMtests` to numerical precision. When only one of the two statistics is significant, follow it; when both are, as on both datasets here, the ordering is only a first reading and the adjusted forms in the same reference help decide.
## Check three: which model fits best?
Tests are local; a global comparison rounds them out. We fit all five candidates to the lag-generated data and rank them by AIC alongside the residual autocorrelation each leaves behind. The spatial fits reuse the by-hand likelihoods from the earlier tutorials.
```{r compare}
y <- y_lag
ols <- lm(y ~ x); chk_aic_ols <- AIC(ols); chk_mi_ols <- moran_test(resid(ols), W)$I
# spatial lag
fl <- optim(c(coef(ols), 0.1, log(sd(resid(ols)))), function(p) {
be <- p[1:2]; r <- p[3]; s2 <- exp(2 * p[4]); if (r <= rho_lo || r >= rho_hi) return(1e10)
Ay <- y - r * (W %*% y); e <- Ay - X %*% be
-(-n / 2 * log(2 * pi) - n / 2 * log(s2) + sum(log(1 - r * ev)) - sum(e^2) / (2 * s2))
}, method = "BFGS")
chk_aic_slm <- 2 * 4 + 2 * fl$value
chk_mi_slm <- moran_test(as.numeric((diag(n) - fl$par[3] * W) %*% y - X %*% fl$par[1:2]), W)$I
# spatial error
fe <- optim(c(0.1, log(sd(resid(ols)))), function(p) {
lam <- p[1]; s2 <- exp(2 * p[2]); if (lam <= rho_lo || lam >= rho_hi) return(1e10)
B <- diag(n) - lam * W; BtB <- crossprod(B)
be <- solve(t(X) %*% BtB %*% X, t(X) %*% BtB %*% y); e <- B %*% (y - X %*% be)
-(-n / 2 * log(2 * pi) - n / 2 * log(s2) + sum(log(1 - lam * ev)) - sum(e^2) / (2 * s2))
}, method = "BFGS")
lam <- fe$par[1]; Bh <- diag(n) - lam * W; BtB <- crossprod(Bh)
be_e <- solve(t(X) %*% BtB %*% X, t(X) %*% BtB %*% y)
chk_aic_sem <- 2 * 4 + 2 * fe$value; chk_mi_sem <- moran_test(as.numeric(Bh %*% (y - X %*% be_e)), W)$I
# CAR
fc <- optimise(function(r) {
if (r <= rho_lo || r >= rho_hi) return(1e10); P <- D - r * A
be <- solve(t(X) %*% P %*% X, t(X) %*% P %*% y); e <- y - X %*% be
t2 <- as.numeric(t(e) %*% P %*% e) / n
-(-n / 2 * log(2 * pi) - n / 2 - n / 2 * log(t2) + 0.5 * (sum(log(deg)) + sum(log(1 - r * ev))))
}, c(rho_lo + 1e-4, rho_hi - 1e-4))
Pc <- D - fc$minimum * A; be_c <- solve(t(X) %*% Pc %*% X, t(X) %*% Pc %*% y)
chk_aic_car <- 2 * 4 + 2 * fc$objective
chk_mi_car <- moran_test(as.numeric(chol(Pc) %*% (y - X %*% be_c)), W)$I
# MEM filter
one <- rep(1, n); Cn <- diag(n) - outer(one, one) / n
egm <- eigen(Cn %*% A %*% Cn, symmetric = TRUE); keep <- abs(egm$values) > 1e-8
E <- egm$vectors[, keep]; moE <- (n / sum(A)) * egm$values[keep]
broad <- which(moE > 0)[order(-moE[moE > 0])]
sel <- integer(0); rr <- resid(lm(y ~ x))
for (s in 1:25) { if (moran_test(rr, W)$p > 0.05) break
cc <- abs(cor(E[, setdiff(broad[1:80], sel)], rr))
sel <- c(sel, setdiff(broad[1:80], sel)[which.max(cc)]); rr <- resid(lm(y ~ x + E[, sel])) }
mfit <- lm(y ~ x + E[, sel]); chk_aic_mem <- AIC(mfit); chk_mi_mem <- moran_test(resid(mfit), W)$I
chk_nmem <- length(sel)
data.frame(model = c("OLS", "SLM", "SEM", "CAR", "MEM"),
AIC = round(c(chk_aic_ols, chk_aic_slm, chk_aic_sem, chk_aic_car, chk_aic_mem), 1),
resid_moran = round(c(chk_mi_ols, chk_mi_slm, chk_mi_sem, chk_mi_car, chk_mi_mem), 3))
```
The verdict is unanimous with the LM tests. The spatial lag model wins on AIC, `r round(chk_aic_slm, 0)` against the naive `r round(chk_aic_ols, 0)`, and it drives the residual Moran's I to `r round(chk_mi_slm, 3)`. The CAR model also whitens its innovations and the error model overshoots into a slight negative autocorrelation, both at a worse AIC, because they impose the wrong structure. The eigenvector filter does not manage it: the selection loop runs into its cap of `r chk_nmem` maps with residual Moran's I still at `r round(chk_mi_mem, 3)`, well short of the OLS value `r round(chk_mi_ols, 3)` but still significant, and more so against the residual null of `lm.morantest()`, which with this many maps sits well below the -1/(n - 1) used by `moran_test()` (see [Moran's I on residuals](../morans-i-on-regression-residuals/)). It buys a middling AIC without finishing the job. The lag test told us to expect a lag model; every global measure agrees.
```{r fig1-compare}
#| echo: false
#| fig-cap: "AIC and absolute residual Moran's I for the five models fitted to the lag-generated data. The spatial lag model is lowest on AIC and drives the residual autocorrelation to near zero; the eigenvector filter does neither."
#| fig-alt: "Two bar charts side by side. The left panel shows AIC for five models, with OLS highest, then SEM and CAR and MEM lower, and SLM lowest. The right panel shows the absolute residual Moran's I for the same five models, high for OLS, near zero for SLM and CAR, lower but still visible for SEM, and clearly above zero for MEM."
mods <- factor(c("OLS", "SLM", "SEM", "CAR", "MEM"),
levels = c("OLS", "SLM", "SEM", "CAR", "MEM"))
d1 <- data.frame(model = mods,
AIC = c(chk_aic_ols, chk_aic_slm, chk_aic_sem, chk_aic_car, chk_aic_mem))
g1 <- ggplot(d1, aes(model, AIC, fill = model == "SLM")) +
geom_col(width = 0.65) +
scale_fill_manual(values = c(`FALSE` = "grey70", `TRUE` = "#275139")) +
labs(title = "Model comparison by AIC", subtitle = "lower is better; SLM wins",
x = NULL, y = "AIC") +
theme_te() + theme(legend.position = "none",
panel.grid.major.y = element_line(colour = "grey92"))
d2 <- data.frame(model = mods,
mi = abs(c(chk_mi_ols, chk_mi_slm, chk_mi_sem, chk_mi_car, chk_mi_mem)))
g2 <- ggplot(d2, aes(model, mi, fill = model == "OLS")) +
geom_col(width = 0.65) +
scale_fill_manual(values = c(`FALSE` = "#275139", `TRUE` = "#b40426")) +
labs(title = "Residual autocorrelation", subtitle = "absolute Moran's I of innovations",
x = NULL, y = "|Moran's I|") +
theme_te() + theme(legend.position = "none",
panel.grid.major.y = element_line(colour = "grey92"))
two_panel(g1, g2)
```
## The honest limit: spatial confounding
Now the caution. Suppose the covariate you care about is itself spatially smooth, as environmental drivers usually are. Adding a flexible spatial term, whether an eigenvector basis, an ICAR effect, or an error structure, gives the model a second way to describe smooth spatial variation. The covariate and the spatial term then compete for the same signal. Hodges and Reich (2010) showed that this inflates the variance of the fixed effect, and can shift the estimate itself. We reproduce the effect by adding progressively more eigenvector maps to a regression with a smooth spatial covariate that has a true slope of `0.6`.
```{r confound}
set.seed(4235)
xs <- as.numeric(scale(sin(pi * coords$col / 13) * cos(pi * coords$row / 11) +
0.15 * rnorm(n))) # a very smooth spatial covariate
b1c <- 0.6
ys <- b0 + b1c * xs + as.numeric(solve(diag(n) - 0.6 * W) %*% rnorm(n, 0, 0.5))
Ks <- c(0, 10, 30, 60, 100, 150); bx <- sx <- vif <- numeric(length(Ks))
for (i in seq_along(Ks)) {
K <- Ks[i]
if (K == 0) { m <- lm(ys ~ xs); vif[i] <- 1 } else {
m <- lm(ys ~ xs + E[, broad[1:K]])
vif[i] <- 1 / (1 - summary(lm(xs ~ E[, broad[1:K]]))$r.squared)
}
bx[i] <- coef(m)["xs"]; sx[i] <- summary(m)$coefficients["xs", "Std. Error"]
}
chk_bx <- bx; chk_sx <- sx; chk_vif <- vif
data.frame(maps = Ks, beta_x = round(bx, 3), se = round(sx, 3), VIF = round(vif, 1))
# the real spread of the slope: refit to fresh draws of the same autocorrelated errors
Binv <- solve(diag(n) - 0.6 * W); n_mc <- 300
set.seed(4236)
sd_mc <- apply(replicate(n_mc, {
yy <- b0 + b1c * xs + as.numeric(Binv %*% rnorm(n, 0, 0.5))
vapply(Ks, function(K) coef(if (K == 0) lm(yy ~ xs) else lm(yy ~ xs + E[, broad[1:K]]))[["xs"]], 0)
}), 1, sd)
data.frame(maps = Ks, reported_se = round(sx, 3), real_sd = round(sd_mc, 3))
```
As more maps enter, the covariate becomes increasingly collinear with the spatial basis: its variance inflation factor climbs from `r round(chk_vif[1], 0)` to `r round(chk_vif[length(Ks)], 0)`. The reported standard error of the slope nearly triples, from `r round(chk_sx[1], 3)` to `r round(chk_sx[length(Ks)], 3)`, even though the point estimate stays near the true `r b1c`. Not all of that rise is lost precision. The errors in `ys` are spatially autocorrelated, so the ordinary least squares standard error with no maps is too small, and as the maps absorb the autocorrelation the reported standard error catches up with the real one. Refitted to `r n_mc` fresh draws of the same errors (the last lines of the chunk), the slope varies with a standard deviation of `r round(sd_mc[1], 3)` with no maps and `r round(sd_mc[length(Ks)], 3)` with `r Ks[length(Ks)]`, so the real cost is a factor of about `r sprintf("%.1f", sd_mc[length(Ks)] / sd_mc[1])`. You do not lose the estimate here, and you do lose precision, though less than the reported standard errors suggest; adding maps moves the single estimate about, as the table shows, but because the errors are unrelated to the covariate it does not shift the estimate on average. "Controlling for space" is not free: past a point, the model can no longer tell whether the covariate or the map explains the pattern. This is the same collinearity that [concurvity](../concurvity-in-additive-models/) describes for additive models, now wearing a spatial coat.
```{r fig2-confound}
#| echo: false
#| fig-cap: "The covariate slope and its variance inflation factor against the number of eigenvector maps added. The point estimate stays near the true 0.6 while the reported confidence intervals fan out, partly because the interval with no maps is too narrow for the autocorrelated errors."
#| fig-alt: "Two panels. The left panel shows the covariate slope estimate with 95 per cent confidence intervals against the number of eigenvector maps; the point estimate stays near 0.6 but the intervals fan out sharply as maps are added. The right panel shows the variance inflation factor rising from 1 to about 15 as maps are added."
dc <- data.frame(K = Ks, bx = bx, lo = bx - 1.96 * sx, hi = bx + 1.96 * sx)
g1 <- ggplot(dc, aes(factor(K), bx)) +
geom_hline(yintercept = b1c, linetype = 2, colour = "grey55") +
geom_errorbar(aes(ymin = lo, ymax = hi), width = 0.25, colour = "#275139") +
geom_point(size = 2, colour = "#275139") +
labs(title = "The slope loses precision", subtitle = "true value dashed at 0.6",
x = "eigenvector maps added", y = "slope estimate") +
theme_te() + theme(axis.text = element_text(size = 8),
panel.grid.major.y = element_line(colour = "grey92"))
g2 <- ggplot(data.frame(K = Ks, vif = vif), aes(factor(K), vif)) +
geom_col(width = 0.6, fill = "#b40426") +
labs(title = "Covariate and space overlap", subtitle = "variance inflation factor",
x = "eigenvector maps added", y = "VIF") +
theme_te() + theme(axis.text = element_text(size = 8),
panel.grid.major.y = element_line(colour = "grey92"))
two_panel(g1, g2)
```
## What to carry forward
Checking an areal spatial model is a three-part routine. Read the residual Moran's I on the innovations to confirm the autocorrelation is gone. Use the Lagrange multiplier tests to choose between a lag and an error specification, and back them with an AIC comparison across the model families. Then, before you report the covariate effect, ask whether it is still identified: if the predictor is spatially smooth, the spatial term will compete with it, inflating its standard error and possibly biasing it.
The remedy Hodges and Reich propose is restricted spatial regression, which confines the spatial term to the part of the map orthogonal to the fixed effects, so that space cannot steal the covariate's signal. Whether that is the right choice depends on your question (Paciorek 2010), and there is no universal fix. The honest summary of this whole series is that spatial models repair the inference that autocorrelation breaks, but they introduce a tension of their own between explaining space and estimating effects. Diagnosing that tension, rather than assuming it away, is what separates a spatial analysis you can defend from one you cannot.
## References
Anselin L, Bera AK, Florax R, Yoon MJ 1996 Regional Science and Urban Economics 26(1):77-104 (10.1016/0166-0462(95)02111-6)
Hodges JS, Reich BJ 2010 The American Statistician 64(4):325-334 (10.1198/tast.2010.10052)
Paciorek CJ 2010 Statistical Science 25(1):107-125 (10.1214/10-STS326)
Ver Hoef JM, Peterson EE, Hooten MB, Hanks EM, Fortin M-J 2018 Ecological Monographs 88(1):36-59 (10.1002/ecm.1283)
## Related tutorials
- [Spatial lag and spatial error models (SAR)](../spatial-autoregressive-models-sar/) fit the two models these tests choose between.
- [Conditional autoregressive (CAR) models](../conditional-autoregressive-car-models/) provide the CAR fit compared here.
- [Moran eigenvector spatial filtering](../moran-eigenvector-spatial-filtering/) supplies the eigenvector basis used in the confounding demonstration.
- [Spatial+ and the attenuated slope](../spatial-plus-and-the-attenuated-slope/) is the local coefficient version of the same question, with a permutation test for non-stationarity.