Advanced Topics in Spatial Data

Summer School for Women in Political Methodology

Anne Stroppe

2026-07-24

“All things are connected”

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


“All things are connected”

A lot of (classic) theories inherently make use of space (e.g., Allport 1954)1

  • It’s where people interact
  • It’s what people collectively shape
  • Space becomes place

Thus, there’s a deep intersection or even embeddedness of space in social science research

  • It’s what geographers call “human-environment-system”
  • But often, these links are even only implicit in our data

Geographic information in social science

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…

Three (Analytical) Perspectives on Space

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.

On materials, data and code

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

Space as a Threat

Spatial Interdependence

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:

  1. Define who is connected (neighbourhood matrix)
  2. Define the (relative) strength of connectivity (weights matrix)
  3. Compute autocorrelation measures

Our case study

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.

Voting districts

voting_districts <-
  sf::st_read("./data/Stimmbezirk.shp") |> 
  dplyr::mutate(
    district_id = as.numeric(nummer)
    ) |> 
  dplyr::select(district_id, Shape_Area, geometry) 

head(voting_districts, 2)
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...

Federal elections 2021

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

Do vote shares spatially cluster?

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")

Pull in German Census data

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")

It’s raster data

ggplot() +
  tidyterra::geom_spatraster(
    data = rent_rast
    ) +
  scale_fill_viridis_c()

Side quest: Aggregating raster by vector

As the voting (vector) data differs from the Census raster data, we cannot use simple ID matching like before.

  • We have to rely on spatial linking techniques
  • We could use terra::extract()
    • But as a default, it only captures raster cells as a whole and not their spatial fraction
    • Which would be honestly okay for most applications
    • But we can also aggregate more precisely with 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

Voilà

ggplot() +
  geom_sf(
    data = election_results,
    aes(fill = rent_avg)
  ) +
  scale_fill_viridis_c()

How to test spatial autocorrelation

We now have to ask

  • Do the spatial units relate to each other?
  • If yes, in which way?
    • Only if they are bordering each other? (i.e., Queens or Rooks)
    • Or also if they are in proximity but not necessarily contiguous?

Let’s try Queens neighborhoods

queens_neighborhoods <-
  spdep::poly2nb(
    election_results,
    queen = TRUE
  )

summary(queens_neighborhoods)
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

And alternative rook neighborhoods

rook_neighborhoods <-
  spdep::poly2nb(
    election_results,
    queen = FALSE
  )

summary(rook_neighborhoods)
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

Connected regions

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()

Can we now start?

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).

Step 2: Normalization

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:

  • Units with many neighbors exert more influence
  • Spatial lag depends on network density

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

Row-normalization

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}} \]

Effect of row-normalization

Show R code
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"
  )

Alternatives to row-normalization

“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

Apply row-normalization

queens_W <- spdep::nb2listw(queens_neighborhoods, style = "W")

summary(queens_W)
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

Tidyverse alternative: sfdep package

The sfdep package provides a more tidyverse-compliant syntax to spatial weights. See:

election_results <-
  election_results |> 
  dplyr::mutate(
    neighbors = sfdep::st_contiguity(election_results), # queen neighborhoods by default
    weights = sfdep::st_weights(neighbors)
  )

head(election_results, 2)
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

Step 3: Tests of spatial autocorrelation

  • Global measures consider the average level of spatial autocorrelation across all observations
  • Bias can be induced through edge effects where important parts of the spatial process fall outside the study area (compare to left- and right-censoring longitudinal data)

Global tests in this session include:

  1. Moran’s I
  2. Geary’s C

Moran’s I

\[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}\]

  • Most and foremost, Moran’s I use the previously created weights between all spatial unit pairs \(w_{ij}\).
  • It weights deviations from an overall mean value of connected pairs according to the strength of the modeled spatial relations.
  • Moran’s I can be interpreted as a correlation coefficient with a range from -1 to +1

Example patterns

Show R code
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"
  )

Moran’s I in spdep

spdep::moran.test(
  election_results$rent_avg, 
  listw = queens_W
)

    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 

Moran scatterplot

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")

Moran’s I in sfdep

library(magrittr)

election_results %$% 
  sfdep::global_moran_test(rent_avg, neighbors, weights)

    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 

Test of spatial autocorrelation: Geary’s C

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:

  • values near 0 = positive spatial autocorrelation;
  • values near 1 = no spatial autocorrelation;
  • values near 2 = negative spatial autocorrelation.

Geary’s C in spdep

spdep::geary.test(
  election_results$rent_avg, 
  listw = queens_W
)

    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 

Geary’s C in sfdep

election_results %$% 
  sfdep::global_c_test(rent_avg, neighbors, weights)

    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 

Exercise: Neighborhood Matrices & Moran’s 💪

🖱 Click here for the exercise

Measures of local spatial autocorrelation: LISA clusters

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})\]

Local Moran’s I in sfdep

lisa <- 
  election_results |> 
  dplyr::mutate(
    lisa = sfdep::local_moran(rent_avg, neighbors, weights)
  ) |>
  tidyr::unnest(cols = lisa)

head(lisa, 2)
Simple 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>

It’s also nice for mapping

ggplot() +
  geom_sf(
    data = lisa,
    aes(fill = ii)
  ) +
  scale_fill_viridis_c()

It’s suitable for hot-spot analysis

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"
  )

When is space a “threat”

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.

Solution: Spatial Error Model

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\]

Linear regression

linear_regression <-
  lm(cdu_share ~ immigrant_share + inhabitants, data = election_results)

summary(linear_regression)

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

Now we need a spatial weight

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.

queen_neighborhoods <- spdep::poly2nb(election_results, queen = TRUE)

queen_W <- spdep::nb2listw(queen_neighborhoods, style = "W")

Spatial Error Model

spatial_error_model <-
  spatialreg::errorsarlm(
    cdu_share ~ immigrant_share + inhabitants,
    data = election_results, listw = queen_W)

summary(spatial_error_model)

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)

 

Spatial Autocorrelation Is Not the Only Threat

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?

Spatial Autocorrelation Is Not the Only Threat

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?

The Original Claim: Homola, Pereira & Tavits (APSR 2020)

“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.

The Problem

The Problem: Camps Are Not Randomly Distributed Across Space

Pepinsky, Goodman & Ziller (APSR 2024) show that the result is an artefact of unobserved regional heterogeneity:

  • Concentration camps cluster heavily in what became East Germany after 1945
  • East German Bundesländer differ from West German states on a wide range of political and cultural dimensions (for reasons mostly unrelated to camp proximity)

Respondents are nested within Bundesländer. Failing to model that hierarchy means state-level confounders load onto the camp-proximity coefficient.

The Fix: State Fixed Effects and Mulilevel Models

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.

::::

The Rebuttal: Post-Treatment Effect

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 as a Process

Is it meaningful or just nuisances?

    Space can be important in our analysis in two ways.

    • It’s meaningful in our theory, and we thus interpret it accordingly after estimation
    • It can distort our empirical estimates, producing bias, inconsistency, and inefficiency

    We can address these different perspectives in our analysis with spatial econometric methods.

What are spatial econometrics?

Classic econometrics:

  • Using statistics to model (complex) theories, esp. causal thinking
  • As default, we think about regression analysis

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?

Spatial Diffusion

  • \(y_i\) affects \(y_j\) through \(w_{ij}\)
  • \(y_j\) affects \(y_i\) through \(w_{ji}\)
  • endogenous by design!
  • Examples:
    • tax competition: if a state cuts corporate tax, neighbours respond by cutting theirs too
    • civil war onset: conflict in one country raises the probability of onset in neighbours

Spatial Spill-Over

  • \(x_i\) affects \(y_j\) through \(w_{ij}\)
  • \(x_j\) affects \(y_i\) through \(w_{ij}\)
  • Examples:
    • trade and export: a neighbour region’s GDP (their X) raises your export volumes or wages (your Y)
    • crime displacement: increased policing in one neighbourhood raises crime in your neighbourhood, as criminals relocate

Formulas…

    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\]

Flavors and extensions

But what if….

  • … you have interdependence and spillovers in covariates?
  • … spillovers and clustering in errors?
  • … interdependence and clustering in errors?
  • … everything is related with everything?

Flavors and extensions

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\]

Which model to choose?

Intermediate summary

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:

  • Misspecifying SAR as OLS → biased β (omitted variable: Wy)
  • Misspecifying SEM as SAR → spurious diffusion, inefficient β
  • Misspecifying SAR as SLX → underestimates spillovers (captures first-order neighbour effect)

I’d explicitly like to recommend the work of Tobias Rüttenauer for us social scientists. Here are some really nice workshop materials.

Another practical application

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:

  1. Do immigrant shares affect CDU voting shares between neighborhoods? (=spillover)
  1. Do CDU voting shares affect CDU voting shares between neighborhoods? (=diffusion)

Again, controlling inhabitant numbers within the voting districts might also be a good idea.

Linear regression

linear_regression <-
  lm(cdu_share ~ immigrant_share + inhabitants, data = election_results)

summary(linear_regression)

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

Spatial Lag X Model

spatial_lag_x_model <-
  spatialreg::lmSLX(
    cdu_share ~ immigrant_share + inhabitants,
    data = election_results, listw = queen_W
  )

summary(spatial_lag_x_model)

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

 

Spatial Lag Y Model

spatial_lag_y_model <-
  spatialreg::lagsarlm(
    cdu_share ~ immigrant_share + inhabitants,
    data = election_results, listw = queen_W)

summary(spatial_lag_y_model)

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

Comparison: What’s ‘better’?

AIC(spatial_error_model, spatial_lag_x_model, spatial_lag_y_model)
                    df      AIC
spatial_error_model  5 3024.353
spatial_lag_x_model  6 3187.867
spatial_lag_y_model  5 3021.218
spdep::lm.LMtests(linear_regression, queen_W, test = c("LMerr", "LMlag"))

    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

Comparison: What’s ‘better’?

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?

Of higher importance: interpretation

Unfortunately, in a Spatial Lag Y Model, the spatial parameter \(\rho\) only tells us whether the effect is (statistically) significant.

  • Remember: these models are endogenous by design
    • We have effects of \(y_j\) on \(y_i\) and vice versa
    • What a mess

Luckily, there’s a method to decompose the spatial effects into direct, indirect, and total effects: estimating impacts

Impact estimation in R

This time, let’s start with the Spatial Lag Y Model:

spatialreg::impacts(spatial_lag_y_model, listw = queen_W)
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:

coef(spatial_lag_y_model)
            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).

Spatial Lag X impacts

spatialreg::impacts(spatial_lag_x_model, listw = queen_W)
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

Compare it to the ‘simple’ regression output:

coef(spatial_lag_x_model)
        (Intercept)     immigrant_share         inhabitants lag.immigrant_share     lag.inhabitants 
        33.42607569         -0.03501709         -0.02992990         -0.08481509         -0.10379397 

Nothing is really gained.

If you need p-values and stuff

spatialreg::impacts(spatial_lag_y_model, listw = queen_W, R = 500) |> 
  summary(zstats = TRUE, short = TRUE)
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

Exercise: Spatial Regression 💪

🖱 Click here for the exercise

Space as Leverage

Intermediate Summary

So far, spatial dependence has appeared in two roles:

  • Threat: autocorrelation in residuals biases OLS inference → we detect (Moran’s I, Geray’s C and LISA) and correct it (SEM)
  • Process: clustering and spillover are the substantive phenomenon → we model it explicitly (SAR, SDM)

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?

A short note on causal designs and the POF

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.

Geography in the POF

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.

  • Difference-in-differences with place-based interventions
  • Spatial regression discontinuity designs
  • Geographic features as instruments
  • Matching on spatial covariates / synthetic controls

Difference-in-Differences Designs

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.

Who Is Treated? Space as Assignment Mechanism

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.

Who Is Treated? Space as Assignment Mechanism

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

What Can Go Wrong? SUTVA and Spatial Spillovers

SUTVA (Stable Unit Treatment Value Assumption) requires:

  1. No interference: Does unit \(i\)’s outcome depends only on \(i\)’s own treatment?
  2. No hidden treatment variation: Only one version of treatment for all units?

Place-based interventions almost always put SUTVA under pressure:

  • A policy in municipality A affects commuters, consumers, and voters in adjacent municipality B: near treated areas are partially treated through spillovers
  • This attenuates DiD estimates: the control group is no longer a clean counterfactual and, in the extreme, spillovers can reverse the estimated sign

Fictional example: Reform in Cologn

SUTVA: Districts Are Contaminated?

Robustness: Donut vs Ring

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 Good or the Bad Wolf?

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 Good or the Bad Wolf?

The Good or the Bad Wolf?

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.

Regression Discontinuity Designs

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

Finding a Threshold in Space

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?

  • Sharp and clean: treatment changes discretely at the boundary, not gradually.
  • No compound treatment: the boundary of interest should not perfectly coincide with other boundaries ( media markets, county borders).
  • No sorting: units should not be able to select which side of the boundary they fall on based on anticipated treatment effects.
  • Historical stability: the longer a border has existed, the more likely it is that units on either side have diverged along unobserved dimensions which undermines comparability while also strengthening the case that the boundary is exogenous to current outcomes.

RDD: The Rhine as a Natural Boundary

RDD: Distance to the Rhine as Running Variable

“Real life example”: EU Funds and Euroscepticism

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.

“Real life example”: EU Funds and Euroscepticism

“Real life example”: EU Funds and Euroscepticism

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.

On the implementation of DiD and Spatial RDD

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

More “advanced” spatial data topics

Modelling:

  • Geographically Weighted Regression
  • Multilevel models with spatial autocorrelation
  • Dynamic spatial models (lagged y in both space and time)
  • Small area estimation/MRP estimating quantities for areas where surveys have too few observations

More “advanced” spatial data topics

GIS techniques

  • Routing & network analysis
  • Cluster analysis
  • Point pattern analysis
  • Areal interpolation and imputaion

and, and, and

The End