Summer School for Women in Political Methodology
2026-07-24
Catchphrase #1: Tobler’s Law: “I invoke the first law of geography: everything is related to everything else, but near things are more related than distant things.” (Tobler 1970)1
Catchphrase #2: Tobler’s Addendum: “near can take on many meanings in different situations.” (Tobler 2004)2
\(\rightarrow\) “Space is more than geography” (Beck et al. 2006)3
A lot of (classic) theories inherently make use of space (e.g., Allport 1954)1
Thus, there’s a deep intersection or even embeddedness of space in social science research
Exploiting geographic information is not new.
For example, Siegfried (1913)1 used soil composition information to explain election results in France.
The book is often seen as foundational for electoral geography because it demonstrates that political behavior is embedded in place.
Luckily, we moved on from comparing print out maps…
The same spatial dependence can be a problem, a finding, or a design feature:
Space as Threat: detect and correct for interdependence
Burnett & Lacombe (2012): five predictors of the 2004 US presidential vote change or disappear once spatial dependence is modelled.
Space as Process: model diffusion and spillover as substance
Shipan & Volden (2008): cities adopt smoking bans because neighbouring cities did.
Space as Leverage: exploit spatial structure for causal identification
Dell (2010): Peru’s historical forced-labour boundary identifies persistent effects 200 years later.
\(\rightarrow\) Today we work through all three in that order.
You find all materials on github: https://annestroppe.github.io/wpm-2026
They are adopted and extended from our Introduction to Geospatial Techniques for Social Scientists in R (shout out to Stefan and Dennis)
Data in this course
Please always cite original data sources
Tobler’s law is the fundamental principle of detecting spatial autocorrelation.
Spatial autocorrelation is the correlation of a variable with itself across space, conditional on a specified neighborhood or weights matrix.
Three crucial steps:
Say we are interested in clusters of citizens’ party preferences and associations with covariates such as rent prices or immigrant shares.
We will focus on Cologne, utilizing available voting districts.
Reading layer `Stimmbezirk' from data source `C:\Users\stroppan\Documents\wpm-2026\data\Stimmbezirk.shp' using driver `ESRI Shapefile'
Simple feature collection with 543 features and 14 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 343914.7 ymin: 5632759 xmax: 370674.3 ymax: 5661475
Projected CRS: ETRS89 / UTM zone 32N
Simple feature collection with 2 features and 2 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 354181.4 ymin: 5642934 xmax: 356401.7 ymax: 5644951
Projected CRS: ETRS89 / UTM zone 32N
district_id Shape_Area geometry
1 10205 363417.6 MULTIPOLYGON (((354878.4 56...
2 10213 161963.0 MULTIPOLYGON (((356057.1 56...
btw21_votes <-
glue::glue(
"https://www.stadt-koeln.de/wahlen/bundestagswahl/09-2021/praesentation/\\
Open-Data-Bundestagswahl476.csv"
) |>
readr::read_csv2() |>
dplyr::mutate(
district_id = as.numeric(`gebiet-nr`),
valid_votes = `F`,
cdu_share = (F1 / valid_votes) * 100,
spd_share = (F2 / valid_votes) * 100,
fdp_share = (F3 / valid_votes) * 100,
afd_share = (F4 / valid_votes) * 100,
greens_share = (F5 / valid_votes) * 100,
linke_share = (F6 / valid_votes) * 100,
.keep = "none"
)
head(btw21_votes, 2)# A tibble: 2 × 8
district_id valid_votes cdu_share spd_share fdp_share afd_share greens_share linke_share
<dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 10101 543 13.1 21.9 8.10 4.24 36.6 9.21
2 10102 551 11.4 22.0 11.3 2.90 36.5 9.26
Simple feature collection with 2 features and 9 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 354181.4 ymin: 5642934 xmax: 356401.7 ymax: 5644951
Projected CRS: ETRS89 / UTM zone 32N
district_id Shape_Area valid_votes cdu_share spd_share fdp_share afd_share greens_share linke_share
1 10205 363417.6 462 9.74026 17.31602 11.255411 2.597403 41.99134 10.173160
2 10213 161963.0 561 11.76471 23.70766 8.912656 1.247772 40.81996 9.447415
geometry
1 MULTIPOLYGON (((354878.4 56...
2 MULTIPOLYGON (((356057.1 56...
plot_data <- election_results |>
tidyr::pivot_longer(
cols = ends_with("_share"),
names_to = "party",
values_to = "vote_share"
) |>
mutate(
party = sub("_share$", "", party),
party = factor(party)
)
ggplot(plot_data) +
geom_sf(aes(fill = vote_share), color = NA) +
facet_wrap(~ party, nrow = 2, ncol = 3) +
scale_fill_viridis_c() +
theme_void() +
labs(fill = "Vote share")immigrants_cologne <- terra::rast("./data/immigrants_cologne.tif")
inhabitants_cologne <- terra::rast("./data/inhabitants_cologne.tif")
immigrants_cologne <- terra::subst(immigrants_cologne, from = -9, to = NA)
inhabitants_cologne <- terra::subst(inhabitants_cologne, from = -9, to = NA)
immigrant_share_cologne <- (immigrants_cologne / inhabitants_cologne)*100
age_rast <- terra::rast("./data/census22_age_avg.tif")
rent_rast <- terra::rast("./data/census22_rent_avg.tif")As the voting (vector) data differs from the Census raster data, we cannot use simple ID matching like before.
terra::extract()
exactextractr::exact_extract()exactextractr::exact_extract()!election_results <-
election_results |>
dplyr::mutate(
immigrant_share =
exactextractr::exact_extract(
immigrant_share_cologne, election_results, 'mean', progress = FALSE
),
inhabitants =
exactextractr::exact_extract(
inhabitants_cologne, election_results, 'mean', progress = FALSE
),
age_avg =
exactextractr::exact_extract(
age_rast, election_results, 'mean', progress = FALSE
),
rent_avg =
exactextractr::exact_extract(
rent_rast, election_results, 'mean', progress = FALSE
)
)
head(election_results, 2)Simple feature collection with 2 features and 13 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 354181.4 ymin: 5642934 xmax: 356401.7 ymax: 5644951
Projected CRS: ETRS89 / UTM zone 32N
district_id Shape_Area valid_votes cdu_share spd_share fdp_share afd_share greens_share linke_share
1 10205 363417.6 462 9.74026 17.31602 11.255411 2.597403 41.99134 10.173160
2 10213 161963.0 561 11.76471 23.70766 8.912656 1.247772 40.81996 9.447415
geometry immigrant_share inhabitants age_avg rent_avg
1 MULTIPOLYGON (((354878.4 56... 25.80862 168.2629 38.59269 11.63169
2 MULTIPOLYGON (((356057.1 56... 23.67849 138.0017 40.87474 10.81500
We now have to ask
Neighbour list object:
Number of regions: 543
Number of nonzero links: 3120
Percentage nonzero weights: 1.058169
Average number of links: 5.745856
Link number distribution:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
1 9 48 89 137 97 70 43 19 17 4 5 2 1 1
1 least connected region:
69 with 1 link
1 most connected region:
387 with 15 links
Neighbour list object:
Number of regions: 543
Number of nonzero links: 2922
Percentage nonzero weights: 0.9910157
Average number of links: 5.381215
Link number distribution:
1 2 3 4 5 6 7 8 9 10 11 12 13 15
3 14 51 111 145 97 55 32 20 7 4 2 1 1
3 least connected regions:
69 121 196 with 1 link
1 most connected region:
387 with 15 links
rook_lines <- rook_neighborhoods |>
spdep::nb2lines(
coords = sf::st_as_sfc(election_results),
as_sf = TRUE
)
queen_lines <- queens_neighborhoods |>
spdep::nb2lines(
coords = sf::st_as_sfc(election_results),
as_sf = TRUE
)
nb_points <- sf::st_centroid(queen_lines)
ggplot() +
geom_sf(data = queen_lines, color = "#1b9e77",
linewidth = 1, alpha = 0.6) +
geom_sf(data = rook_lines, color = "#d95f02",
linewidth = 1, alpha = 0.9,
linetype = "dashed") +
geom_sf(data = nb_points, size = 2) +
theme_void()Unfortunately, we are not yet done with creating the links between neighborhoods. What we receive is, in principle, a huge matrix with connected observations.
1 2 3 4 5 6 7 8 9 10
1 0 0 0 0 0 0 0 0 0 0
2 0 0 0 0 0 0 0 1 1 0
3 0 0 0 1 1 1 1 0 1 0
4 0 0 1 0 1 1 1 0 1 0
5 0 0 1 1 0 1 0 0 0 0
6 0 0 1 1 1 0 1 1 1 0
7 0 0 1 1 0 1 0 1 1 0
8 0 1 0 0 0 1 1 0 1 0
9 0 1 1 1 0 1 1 1 0 0
10 0 0 0 0 0 0 0 0 0 0
That’s nothing we could plug into a statistical model, such as a regression or the like (see next session).
Normalization is the process of creating actual spatial weights. There is a debate on how to do it (Neumayer & Plümper, 2016)1. But nobody questions whether it should be done in the first place since, among others, it restricts the parameter space of the weights. Without normalization:
Goal: make influence comparable across units
1 2 3 4 5
1 0 0 0 0 0
2 0 0 0 0 0
3 0 0 0 1 1
4 0 0 1 0 1
5 0 0 1 1 0
[1] 0 0 2 2 2
1 2 3 4 5
1 0 0 0.0 0.00000000 0.00000000
2 0 0 0.0 0.00000000 0.00000000
3 0 0 0.0 0.08333333 0.08333333
4 0 0 0.2 0.00000000 0.20000000
5 0 0 0.2 0.20000000 0.00000000
[1] 0.0000000 0.0000000 0.1666667 0.4000000 0.4000000
One of the standard procedures is row-normalization. It divides all individual weights (=connections between spatial units) \(w_{ij}\) by the row-wise sum of of all other weights:
Each weight is normalized by the sum of its row:
\[ w_{ij}^* = \frac{w_{ij}}{\sum_j w_{ij}} \]
library(dplyr)
library(ggplot2)
library(ggpattern)
make_case <- function(focal_x, focal_y, title_text) {
grid <- expand.grid(x = 1:5, y = 1:5) |>
as_tibble()
grid <- grid |>
mutate(
dist = abs(x - focal_x) + abs(y - focal_y),
role = case_when(
x == focal_x & y == focal_y ~ "focal",
dist == 1 ~ "neighbor",
TRUE ~ "other"
)
)
n_nb <- sum(grid$role == "neighbor")
grid |>
mutate(
weight = ifelse(role == "neighbor", 1 / n_nb, NA),
label = case_when(
role == "focal" ~ "i",
role == "neighbor" ~ paste0("1/", n_nb),
TRUE ~ ""
),
example = title_text
)
}
df <- bind_rows(
make_case(3, 3, "Central cell\n4 neighbors → 1/4 each"),
make_case(3, 5, "Edge cell\n3 neighbors → 1/3 each")
)
ggplot(df, aes(x, y)) +
geom_tile(fill = "grey95", color = "white", linewidth = 1) +
ggpattern::geom_tile_pattern(
data = subset(df, role == "neighbor"),
fill = "grey95",
pattern = "stripe",
pattern_fill = "lightgreen",
pattern_colour = "lightgreen",
pattern_density = 0.35,
pattern_spacing = 0.03,
color = "white",
linewidth = 1
) +
geom_tile(
data = subset(df, role == "focal"),
fill = "gold",
color = "white",
linewidth = 1
) +
geom_text(aes(label = label), size = 5) +
facet_wrap(~example) +
coord_equal() +
scale_y_reverse() +
theme_void() +
theme(
strip.text = element_text(size = 11, face = "bold"),
legend.position = "none"
)“B” (Binary) - Keeps original neighbor structure
“W” (Row-standardized) - Row-normalized weights / Each unit’s weights sum to 1 / Interpretable as average of neighbors
“C” (Globally standardized) - Weights scaled so the total sum across all units equals n (number of observations) / Preserves global comparability
“U” (Equal to C but unscaled) - Similar structure to “C”, but total sum equals 1 / Less commonly used
“S” (Variance-stabilizing) - Adjusts weights to stabilize variance across units / Reduces influence of highly connected units
“minmax” - Scales weights by the minimum of the maximum row and column sums / Keeps weights bounded and comparable
Characteristics of weights list object:
Neighbour list object:
Number of regions: 543
Number of nonzero links: 3120
Percentage nonzero weights: 1.058169
Average number of links: 5.745856
Link number distribution:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
1 9 48 89 137 97 70 43 19 17 4 5 2 1 1
1 least connected region:
69 with 1 link
1 most connected region:
387 with 15 links
Weights style: W
Weights constants summary:
n nn S0 S1 S2
W 543 294849 543 201.1676 2261.458
sfdep packageThe sfdep package provides a more tidyverse-compliant syntax to spatial weights. See:
Simple feature collection with 2 features and 15 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 354181.4 ymin: 5642934 xmax: 356401.7 ymax: 5644951
Projected CRS: ETRS89 / UTM zone 32N
district_id Shape_Area valid_votes cdu_share spd_share fdp_share afd_share greens_share linke_share
1 10205 363417.6 462 9.74026 17.31602 11.255411 2.597403 41.99134 10.173160
2 10213 161963.0 561 11.76471 23.70766 8.912656 1.247772 40.81996 9.447415
geometry immigrant_share inhabitants age_avg rent_avg neighbors
1 MULTIPOLYGON (((354878.4 56... 25.80862 168.2629 38.59269 11.63169 16, 18, 22, 25, 458, 459, 460, 462
2 MULTIPOLYGON (((356057.1 56... 23.67849 138.0017 40.87474 10.81500 8, 9, 11, 48, 49
weights
1 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125
2 0.2, 0.2, 0.2, 0.2, 0.2
Global tests in this session include:
\[I=\frac{N}{\sum_{i=1}^N\sum_{j=1}^Nw_{ij}}\frac{\sum_{i=1}^{N}\sum_{j=1}^Nw_{ij}(x_i-\bar{x})(x_j-\bar{x})}{\sum_{i=1}^N(x_i-\bar{x})^2}\]
library(dplyr)
library(ggplot2)
library(spdep)
library(tibble)
# 5x5 grid and rook neighbours
n <- 5
grid <- expand.grid(x = 1:n, y = 1:n) |>
as_tibble()
nb <- spdep::cell2nb(nrow = n, ncol = n, type = "rook")
lw <- spdep::nb2listw(nb, style = "W", zero.policy = TRUE)
# helper to compute Moran's I
moran_I <- function(v) {
spdep::moran(
x = v,
listw = lw,
n = length(v),
S0 = spdep::Szero(lw),
zero.policy = TRUE
)$I
}
# pattern close to +1: iteratively smooth a random field
make_positive_pattern <- function(target = 0.95, max_iter = 200) {
set.seed(123)
v <- rnorm(nrow(grid))
for (i in seq_len(max_iter)) {
v <- 0.35 * v + 0.65 * spdep::lag.listw(lw, v, zero.policy = TRUE)
if (moran_I(v) > target) break
}
as.numeric(scale(v))
}
# pattern close to 0: search for a near-random arrangement
make_zero_pattern <- function(tol = 0.05, max_iter = 10000) {
best_v <- NULL
best_I <- Inf
for (i in seq_len(max_iter)) {
v <- sample.int(nrow(grid))
I <- moran_I(v)
if (abs(I) < abs(best_I)) {
best_v <- v
best_I <- I
}
if (abs(I) <= tol) break
}
as.numeric(scale(best_v))
}
# pattern close to -1: checkerboard
v_neg <- ifelse((grid$x + grid$y) %% 2 == 0, 1, -1)
v_zero <- make_zero_pattern()
v_pos <- make_positive_pattern()
# combine for plotting
plot_df <- bind_rows(
grid |>
mutate(
value = v_neg,
panel = sprintf("Moran's I close to -1\nDispersion pattern", moran_I(v_neg))
),
grid |>
mutate(
value = v_zero,
panel = sprintf("Moran's I close to 0\nRandom pattern", moran_I(v_zero))
),
grid |>
mutate(
value = v_pos,
panel = sprintf("Moran's I close to 1\nClustering pattern", moran_I(v_pos))
)
)
ggplot(plot_df, aes(x, y, fill = value)) +
geom_tile(color = "white", linewidth = 1) +
facet_wrap(~panel, nrow = 1) +
coord_equal() +
scale_y_reverse() +
scale_fill_gradient2(
low = "steelblue",
mid = "white",
high = "firebrick",
midpoint = 0
) +
theme_void() +
theme(
strip.text = element_text(size = 12, face = "bold"),
legend.position = "none"
)spdep
Moran I test under randomisation
data: election_results$rent_avg
weights: queens_W
Moran I statistic standard deviate = 33.387, p-value < 2.2e-16
alternative hypothesis: greater
sample estimates:
Moran I statistic Expectation Variance
0.8661742414 -0.0018450185 0.0006759199
mp <- spdep::moran.plot(
x = election_results$rent_avg,
listw = queens_W,
labels = election_results$district_id,
xlab = "Rent average",
ylab = "Spatial lag of rent average"
)
x_mean <- mean(election_results$rent_avg, na.rm = TRUE)
lag_rent <- spdep::lag.listw(queens_W, election_results$rent_avg)
y_mean <- mean(lag_rent, na.rm = TRUE)
x_range <- range(mp$x, na.rm = TRUE)
y_range <- range(mp$wx, na.rm = TRUE)
x_left <- mean(c(x_range[1], x_mean))
x_right <- mean(c(x_mean, x_range[2]))
text(x_right, y_range[2], "High–High", pos = 1, col = "red")
text(x_left, y_range[2], "Low–High", pos = 1, col = "red")
text(x_left, y_range[1], "Low–Low", pos = 3, col = "red")
text(x_right, y_range[1], "High–Low", pos = 3, col = "red")sfdep
Moran I test under randomisation
data: x
weights: listw
Moran I statistic standard deviate = 33.387, p-value < 2.2e-16
alternative hypothesis: greater
sample estimates:
Moran I statistic Expectation Variance
0.8661742414 -0.0018450185 0.0006759199
Moran’s I is based on covariance between values and their spatial lag and captures overall similarity patterns. It can produce issues when there are only local clusters of spatial interdependence in the data. An alternative is the use of Geary's C:
\[C=\frac{(N-1)\sum_i\sum_jw_{ij}(x_i-x_j)^2}{2\sum_{i=1}^N\sum_{j=1}^Nw_{ij}\sum_i(x_i-\bar{x})^2}\]
It is based on squared differences between neighbors and emphasizes local dissimilarities.
Geary’s C only produces values between 0 and 2:
spdep
Geary C test under randomisation
data: election_results$rent_avg
weights: queens_W
Geary C statistic standard deviate = 31.5, p-value < 2.2e-16
alternative hypothesis: Expectation greater than statistic
sample estimates:
Geary C statistic Expectation Variance
0.1291293820 1.0000000000 0.0007643565
sfdep
Geary C test under randomisation
data: x
weights: listw
Geary C statistic standard deviate = 31.5, p-value < 2.2e-16
alternative hypothesis: Expectation greater than statistic
sample estimates:
Geary C statistic Expectation Variance
0.1291293820 1.0000000000 0.0007643565
Also referred to the sfdep package because it provides nice functions to calculate local measures of spatial autocorrelation. One popular choice is the estimation of Local Indicators of Spatial Autocorrelation (i.e., LISA clusters). Most straightforwardly, they can be interpreted as case-specific indicators of spatial autocorrelation:
\[I_i=\frac{x_i-\bar{x}}{\frac{\sum_{i-1}^N(x_i-\bar{x})^2}{N}}\sum_{j=1}^Nw_{ij}(x_j-\bar{x})\]
sfdepSimple feature collection with 2 features and 27 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 354181.4 ymin: 5642934 xmax: 356401.7 ymax: 5644951
Projected CRS: ETRS89 / UTM zone 32N
# A tibble: 2 × 28
district_id Shape_Area valid_votes cdu_share spd_share fdp_share afd_share greens_share linke_share
<dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 10205 363418. 462 9.74 17.3 11.3 2.60 42.0 10.2
2 10213 161963. 561 11.8 23.7 8.91 1.25 40.8 9.45
# ℹ 19 more variables: geometry <MULTIPOLYGON [m]>, immigrant_share <dbl>, inhabitants <dbl>, age_avg <dbl>,
# rent_avg <dbl>, neighbors <nb>, weights <list>, ii <dbl>, eii <dbl>, var_ii <dbl>, z_ii <dbl>, p_ii <dbl>,
# p_ii_sim <dbl>, p_folded_sim <dbl>, skewness <dbl>, kurtosis <dbl>, mean <fct>, median <fct>, pysal <fct>
lisa <- lisa |>
dplyr::mutate(
lisa_cluster_rent = dplyr::if_else(
p_folded_sim < 0.05,
as.character(mean),
"Not significant"
),
lisa_cluster_rent = factor(
lisa_cluster_rent,
levels = c(
"High-High",
"Low-Low",
"High-Low",
"Low-High",
"Not significant"
)
)
)
ggplot(lisa) +
geom_sf(aes(fill = lisa_cluster_rent), color = NA) +
scale_fill_manual(
values = c(
"High-High" = "red",
"Low-Low" = "blue",
"High-Low" = "orange",
"Low-High" = "lightgreen",
"Not significant" = "grey90"
),
drop = FALSE
) +
theme_void() +
labs(
fill = "LISA cluster",
title = "Local Moran clusters for average rent",
subtitle = "Significance based on p_folded_sim < 0.05"
)So far: we’ve described spatial structure, i.e. clusters in vote shares or rents and find that nearby units are more equal. What does that mean when we want to run a normal regresison, f.e. to answer the question
Do immigrant shares affect CDU voting shares within voting districts?
One core assumption of OLS: Observations are independent of each other. This is not the case any more and if we do not model the spatial interdependence the residuals are correlated and bias OLS results.
We can include the spatial interdependence in the error term of our model to correct for deflated standard errors.
Linear Regression: \[\small Y = X\beta + \epsilon\]
Spatial Error Model (SEM): \[\small Y = X\beta + u\] \[\small u = \lambda Wu + \epsilon\]
Call:
lm(formula = cdu_share ~ immigrant_share + inhabitants, data = election_results)
Residuals:
Min 1Q Median 3Q Max
-14.7358 -3.1172 -0.1621 2.8060 22.3594
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 28.290554 0.797624 35.469 <2e-16 ***
immigrant_share -0.051758 0.024987 -2.071 0.0388 *
inhabitants -0.085988 0.003849 -22.343 <2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 4.754 on 540 degrees of freedom
Multiple R-squared: 0.5011, Adjusted R-squared: 0.4992
F-statistic: 271.1 on 2 and 540 DF, p-value: < 2.2e-16
Once again, we have to construct a spatial weight as in the analysis of spatial autocorrelation to estimate a spatial regression. In fact, we’ll use the same approach as before.
Call:spatialreg::errorsarlm(formula = cdu_share ~ immigrant_share +
inhabitants, data = election_results, listw = queen_W)
Residuals:
Min 1Q Median 3Q Max
-8.54327 -2.24926 -0.31318 1.87871 23.47900
Type: error
Coefficients: (asymptotic standard errors)
Estimate Std. Error z value Pr(>|z|)
(Intercept) 25.2771710 1.1671855 21.6565 < 2.2e-16
immigrant_share -0.1301300 0.0310444 -4.1917 2.768e-05
inhabitants -0.0320731 0.0049987 -6.4163 1.397e-10
Lambda: 0.78004, LR test value: 216.59, p-value: < 2.22e-16
Asymptotic standard error: 0.03193
z-value: 24.429, p-value: < 2.22e-16
Wald statistic: 596.8, p-value: < 2.22e-16
Log likelihood: -1507.176 for error model
ML residual variance (sigma squared): 12.967, (sigma: 3.6009)
Number of observations: 543
Number of parameters estimated: 5
AIC: 3024.4, (AIC for lm: 3238.9)
Beyond autocorrelation in residuals: spatial data is often hierarchically structured.
Respondents nest within municipalities. Municipalities nest within regions. Regions differ systematically in history, culture, and politics. Ignoring this hierarchy can produce findings that are artefacts of unmodelled spatial heterogeneity — not substantive effects.
A vivid example: Does living near a former Nazi concentration camp make Germans more intolerant today?
Beyond autocorrelation in residuals: spatial data is often hierarchically structured.
Respondents nest within municipalities. Municipalities nest within regions. Regions differ systematically in history, culture, and politics. Ignoring this hierarchy can produce findings that are artefacts of unmodelled spatial heterogeneity — not substantive effects.
A vivid example: Does living near a former Nazi concentration camp make Germans more intolerant today?
“Legacies of the Third Reich: Concentration Camps and Out-group Intolerance”
Design: distance from a survey respondent’s municipality to the nearest former Nazi concentration camp as a continuous treatment; outcome: out-group intolerance, anti-immigrant attitudes, AfD vote share.
Finding: Germans living closer to former camps display significantly higher levels of out-group intolerance and are more likely to vote for the AfD → mechanisms of historical persistence: local exposure to Nazi terror left a lasting imprint on regional political culture.
Pepinsky, Goodman & Ziller (APSR 2024) show that the result is an artefact of unobserved regional heterogeneity:
Respondents are nested within Bundesländer. Failing to model that hierarchy means state-level confounders load onto the camp-proximity coefficient.
PGZ replicate HPT’s results using European Values Survey + 2017 election returns, then extend with:
State fixed effects to absorb all between-Bundesland variation, isolating within-state variation in camp proximity
Multilevel models to model the nested structure explicitly, allowing intercepts (and optionally slopes) to vary across Bundesländer
Result: once state-level heterogeneity is accounted for, no consistent evidence that distance to camps predicts contemporary intolerance or AfD support.
::::
HPT’s counter-argument (Homola, Pereira & Tavits 2021, 2024): post-war Bundesland borders were partly shaped by Allied occupation decisions that were themselves correlated with Nazi-era geography → state fixed effects introduce post-treatment bias.
Better: pre-treatment spatial fixed effects (e.g., historical Holy Roman Empire districts) are available and do not carry the same risk (?)
Practical implication for us: Whenever your geographic treatment clusters at a spatial level above the individual (region, state, labour market area), ask whether that cluster-level unit is also a confounder. And if so, whether controlling for it is pre- or post-treatment.
Space can be important in our analysis in two ways.
We can address these different perspectives in our analysis with spatial econometric methods.
Classic econometrics:
One core assumption: Observations are independent of each other. However, we just learnt that is often not the case.
Where does spatial dependence and spatial processes enter our models and affect our outcome of interest?
Linear Regression: \[\small Y = X\beta + \epsilon\]
Spatial Error Model (SEM): \[\small Y = X\beta + u\] \[\small u = \lambda Wu + \epsilon\]
Spatial Lag Y / Spatial Autoregressive Model (SAR, Diffusion): \[\small Y = \rho WY + X\beta + \epsilon\]
Spatial Lag X Model (SLX, Spillover): \[\small Y = X\beta + WX\theta + \epsilon\]
But what if….
Spatial Durbin Model
\[Y = \rho WY + X\beta + WX\theta + \epsilon\]
Spatial Durbin Error Model
\[Y = X\beta + WX\theta + u\] \[u = \lambda Wu + \epsilon\]
Combined Spatial Autocorrelation Model
\[Y = \rho WY + X\beta + u\] \[u = \lambda Wu + \epsilon\]
Manski Model
\[Y = \rho WY + WX\theta + X\beta + u\] \[u = \lambda Wu + \epsilon\]
There are a lot of models you could estimate to explain spatial autocorrelation. And there’s a vast body of literature on the best choice for which application. Important for us: theory-grounded reasoning for the underlying data generating process.
Getting this wrong has real consequences:
I’d explicitly like to recommend the work of Tobias Rüttenauer for us social scientists. Here are some really nice workshop materials.
We will use the same example and test if one of our spatial regression models helps further investigate the data generation process. We may ask:
Again, controlling inhabitant numbers within the voting districts might also be a good idea.
Call:
lm(formula = cdu_share ~ immigrant_share + inhabitants, data = election_results)
Residuals:
Min 1Q Median 3Q Max
-15.0379 -3.3415 -0.3242 3.2834 24.7445
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 28.066680 1.050168 26.726 < 2e-16 ***
immigrant_share -0.077070 0.014311 -5.385 1.08e-07 ***
inhabitants -0.083491 0.004932 -16.928 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 5.174 on 540 degrees of freedom
Multiple R-squared: 0.409, Adjusted R-squared: 0.4068
F-statistic: 186.8 on 2 and 540 DF, p-value: < 2.2e-16
Call:
lm(formula = formula(paste("y ~ ", paste(colnames(x)[-1], collapse = "+"))),
data = as.data.frame(x), weights = weights)
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 3.343e+01 1.626e+00 2.055e+01 1.044e-69
immigrant_share -3.502e-02 1.448e-02 -2.419e+00 1.590e-02
inhabitants -2.993e-02 6.086e-03 -4.918e+00 1.164e-06
lag.immigrant_share -8.482e-02 2.561e-02 -3.312e+00 9.879e-04
lag.inhabitants -1.038e-01 9.502e-03 -1.092e+01 3.165e-25
Call:spatialreg::lagsarlm(formula = cdu_share ~ immigrant_share +
inhabitants, data = election_results, listw = queen_W)
Residuals:
Min 1Q Median 3Q Max
-10.48876 -2.36875 -0.21506 1.93747 23.78484
Type: lag
Coefficients: (asymptotic standard errors)
Estimate Std. Error z value Pr(>|z|)
(Intercept) 9.0224178 1.0909437 8.2703 2.22e-16
immigrant_share -0.0295856 0.0101910 -2.9031 0.003695
inhabitants -0.0323508 0.0038451 -8.4135 < 2.2e-16
Rho: 0.71173, LR test value: 311.67, p-value: < 2.22e-16
Asymptotic standard error: 0.033348
z-value: 21.342, p-value: < 2.22e-16
Wald statistic: 455.49, p-value: < 2.22e-16
Log likelihood: -1505.609 for lag model
ML residual variance (sigma squared): 13.319, (sigma: 3.6495)
Number of observations: 543
Number of parameters estimated: 5
AIC: 3021.2, (AIC for lm: 3330.9)
LM test for residual autocorrelation
test value: 29.403, p-value: 5.8781e-08
df AIC
spatial_error_model 5 3024.353
spatial_lag_x_model 6 3187.867
spatial_lag_y_model 5 3021.218
Rao's score (a.k.a Lagrange multiplier) diagnostics for spatial dependence
data:
model: lm(formula = cdu_share ~ immigrant_share + inhabitants, data = election_results)
test weights: listw
RSerr = 245.52, df = 1, p-value < 2.2e-16
Rao's score (a.k.a Lagrange multiplier) diagnostics for spatial dependence
data:
model: lm(formula = cdu_share ~ immigrant_share + inhabitants, data = election_results)
test weights: listw
RSlag = 372.45, df = 1, p-value < 2.2e-16
| Test | Estimate | Points to |
|---|---|---|
| RSerr | 245.5, p < .001 | SEM (\(\lambda \neq 0\)) |
| RSlag | 372.5, p < .001 | SAR (\(\rho \neq 0\)) |
RSlag > RSerr → SAR preferred … but the SAR residuals still show autocorrelation (LM = 29.4, p < .001).
Let’s stick to our theory, shall we?
Unfortunately, in a Spatial Lag Y Model, the spatial parameter \(\rho\) only tells us whether the effect is (statistically) significant.
Luckily, there’s a method to decompose the spatial effects into direct, indirect, and total effects: estimating impacts
RThis time, let’s start with the Spatial Lag Y Model:
Impact measures (lag, exact):
Direct Indirect Total
immigrant_share dy/dx -0.03408500 -0.06854653 -0.1026315
inhabitants dy/dx -0.03727076 -0.07495324 -0.1122240
Compare it to the ‘simple’ regression output:
rho (Intercept) immigrant_share inhabitants
0.71172970 9.02241777 -0.02958562 -0.03235085
A 1pp increase in immigrant share decreases CDU vote share in this unit by 0.0341pp (direct), and decreases CDU vote share in the whole neighbourhood system by a further 0.0685pp (indirect).
Impact measures (SlX, glht):
Direct Indirect Total
immigrant_share dy/dx -0.03501709 -0.08481509 -0.1198322
inhabitants dy/dx -0.02992990 -0.10379397 -0.1337239
Impact measures (lag, exact):
Direct Indirect Total
immigrant_share dy/dx -0.03408500 -0.06854653 -0.1026315
inhabitants dy/dx -0.03727076 -0.07495324 -0.1122240
========================================================
Simulation results ( variance matrix):
========================================================
Simulated standard errors
Direct Indirect Total
immigrant_share dy/dx 0.011865143 0.02520912 0.03626088
inhabitants dy/dx 0.004095388 0.01199834 0.01406409
Simulated z-values:
Direct Indirect Total
immigrant_share dy/dx -2.887832 -2.751226 -2.857640
inhabitants dy/dx -9.171326 -6.356297 -8.093316
Simulated p-values:
Direct Indirect Total
immigrant_share dy/dx 0.0038791 0.0059373 0.004268
inhabitants dy/dx < 2.22e-16 2.0667e-10 6.6613e-16
So far, spatial dependence has appeared in two roles:
But the same spatial structure ( borders, distances, geographic discontinuities) can also become a design feature. If treatment assignment is plausibly driven by geography, we can exploit that variation to identify causal effects we could not otherwise recover.
Any ideas?
The Potential Outcomes Framework defines the causal effect of treatment \(D_i\) on unit \(i\) as:
\[\tau_i = Y_i(1) - Y_i(0)\]
where \(Y_i(1)\) is the outcome if treated and \(Y_i(0)\) the outcome if untreated. The fundamental problem is that we only ever observe one of the two.
All causal designs solve this by finding a situation in which treatment assignment is as-if random conditional on observed covariates, i.e., treated and control units would have had the same outcome absent treatment.
Why geography helps: administrative rules, historical events, and physical features assign treatment based on where a unit is and this often without regard to the outcome. The border is not drawn because of what will happen to voters. The river did not choose which side would receive EU funds.
DiD compares the change in outcomes for treated units to the change for control units across two periods:
\[\hat{\tau}^{DiD} = \underbrace{(\bar{Y}_{treated,post} - \bar{Y}_{treated,pre})}_{\text{change for treated}} - \underbrace{(\bar{Y}_{control,post} - \bar{Y}_{control,pre})}_{\text{change for controls}}\]
The central assumption is parallel trends: absent treatment, treated and control units would have followed the same trajectory.
In place-based applications: \(\text{Treated}_i\) is a geographic indicator. For example, some municipalities, districts, or regions receive a policy intervention, others serve as counterfactuals.
Place-based treatments assign intervention at the level of geographic units.
The key design question is always the same: why did some places get treated and not others? If assignment correlates with pre-existing trends in the outcome, parallel trends fails.
| Treatment | Geographic unit | Control |
|---|---|---|
| EU structural funds | NUTS 2 regions below GDP threshold | Regions just above threshold |
| Wolf reintroduction | Municipalities with attacks | Neighboring municipalities without |
| Minimum wage increase | States/provinces adopting policy | Adjacent states/provinces |
| Refugee reception centers | Municipalities with facilities | Municipalities without |
SUTVA (Stable Unit Treatment Value Assumption) requires:
Place-based interventions almost always put SUTVA under pressure:
Donut DiD removes the spillover zone from the control group. Ring DiD keeps it and estimates a separate coefficient per ring, tracing spillover decay with distance.
The original claim by Clemm von Hohenberg & Hager (PNAS 2023): Wolf attacks on livestock in German municipalities predict a rise in AfD vote share and a decline in Green votes. DiD design: municipalities experiencing first wolf attack vs. never-attacked neighbors.
The original claim by Clemm von Hohenberg & Hager (PNAS 2023): Wolf attacks on livestock in German municipalities predict a rise in AfD vote share and a decline in Green votes. DiD design: municipalities experiencing first wolf attack vs. never-attacked neighbors.
The replication by Sonntag (Electoral Studies 2024): Wolf attacks cluster overwhelmingly in East Germany which are the same region with structurally different political trajectories since reunification. Pre-treatment voting trends predict the probability of a wolf attack. Splitting by East/West or including region-specific time trends nullifies or reverses the effect.
The methodological lesson: Spatial clustering of treatment might not be random clustering: Pre-trend tests, placebo treatments, and heterogeneity checks by region are essential, not optional.
In a standard RD, units are assigned to treatment based on whether a running variable \(R_i\) exceeds a threshold \(c\):
\[D_i = \mathbf{1}[R_i \geq c]\]
The identifying assumption is local continuity: near the threshold, potential outcomes are continuous in \(R_i\), so units just above and below \(c\) are comparable except for treatment status. Treatment is as-if randomly assigned in a neighbourhood around \(c\).
The estimand is the local average treatment effect at the cutoff
In spatial RD (Keele & Titiunik 2015): the running variable is distance to a geographic boundary, and the boundary itself creates the discontinuity in treatment. What makes a geographic boundary a valid discontinuity?
Crescenzi, Di Cataldo & Giua (Journal of Public Economics 2021) — “It’s not about the money. EU funds, local opportunities, and Euroscepticism”
Design: EU structural funds are allocated to NUTS 2 regions with GDP per capita below 75% of the EU average. In East vs. West Wales that created a sharp geographic discontinuity in funding. Regions just below the cutoff receive substantial transfers; regions just above receive little.
Crescenzi, Di Cataldo & Giua (Journal of Public Economics 2021) — “It’s not about the money. EU funds, local opportunities, and Euroscepticism”
Design: EU structural funds are allocated to NUTS 2 regions with GDP per capita below 75% of the EU average. In East vs. West Wales that created a sharp geographic discontinuity in funding. Regions just below the cutoff receive substantial transfers; regions just above receive little.
Finding: Funds do not reduce Euroscepticism in recipient regions. What matters is whether funds translate into local economic opportunity — employment, wages, firm creation. Where they do, attitudes toward the EU improve; where they do not (i.e., funds are absorbed without local economic multipliers), Euroscepticism persists or worsens.
The main “spatial task” is defining your treatment (and the robustness checks for your treatment). The calculation of the models follows standard modelling:’
You can also check out this Tutorial by Tuğba Bozçağa
Modelling:
GIS techniques
and, and, and