Spatial Weights and Applications

HANDS-ON EXERCISE 4

Author
Affiliation

Jedidiah Asaf Tallulembang

ISSS626 Geospatial Analytics and Applications

Published

September 18, 2026

R spdep Contiguity Spatial Lag

This exercise follows Spatial Weights and Applications, the first reading in the Spatial Weights block of the course.

1. Overview

Everything in Exercises 2 and 3 treated space as distance between points. This exercise switches to areal data, meaning polygons with values attached, where the question is no longer “how far apart are these events” but “which regions are neighbours, and how much should each neighbour count?”

That is what a spatial weights matrix encodes. It is the object that every subsequent technique in the course depends on: spatial autocorrelation, clustering, and geographically weighted regression all take a weights matrix as input. Getting it wrong quietly invalidates everything downstream, which is why a whole exercise is spent on it.

By the end I should be able to compute contiguity weights, distance-based weights, inverse-distance weights, row-standardise them, and use them to derive spatially lagged variables.

2. The Study Area and Data

Dataset Description Format
Hunan County boundary layer for Hunan province, China, 88 counties ESRI Shapefile (polygon)
Hunan_2012.csv Selected local development indicators for 2012 CSV

A change of scene from Singapore. Hunan gives 88 areal units with a real spread of development levels, which is exactly what is needed to see spatial weighting do something visible. Singapore’s 332 subzones are too small and too uniformly urban to make a good contiguity example.

3. Getting Started

pacman::p_load(sf, spdep, tmap, tidyverse, knitr)

4. Getting the Data Into R

4.1 Importing the Shapefile

hunan <- st_read(dsn = "data/geospatial",
                 layer = "Hunan",
                 quiet = TRUE)
hunan
Simple feature collection with 88 features and 7 fields
Geometry type: POLYGON
Dimension:     XY
Bounding box:  xmin: 108.7831 ymin: 24.6342 xmax: 114.2544 ymax: 30.12812
Geodetic CRS:  WGS 84
First 10 features:
     NAME_2  ID_3    NAME_3   ENGTYPE_3 Shape_Leng Shape_Area    County
1   Changde 21098   Anxiang      County   1.869074 0.10056190   Anxiang
2   Changde 21100   Hanshou      County   2.360691 0.19978745   Hanshou
3   Changde 21101    Jinshi County City   1.425620 0.05302413    Jinshi
4   Changde 21102        Li      County   3.474325 0.18908121        Li
5   Changde 21103     Linli      County   2.289506 0.11450357     Linli
6   Changde 21104    Shimen      County   4.171918 0.37194707    Shimen
7  Changsha 21109   Liuyang County City   4.060579 0.46016789   Liuyang
8  Changsha 21110 Ningxiang      County   3.323754 0.26614198 Ningxiang
9  Changsha 21111 Wangcheng      County   2.292093 0.13049161 Wangcheng
10 Chenzhou 21112     Anren      County   2.240739 0.13343936     Anren
                         geometry
1  POLYGON ((112.0625 29.75523...
2  POLYGON ((112.2288 29.11684...
3  POLYGON ((111.8927 29.6013,...
4  POLYGON ((111.3731 29.94649...
5  POLYGON ((111.6324 29.76288...
6  POLYGON ((110.8825 30.11675...
7  POLYGON ((113.9905 28.5682,...
8  POLYGON ((112.7181 28.38299...
9  POLYGON ((112.7914 28.52688...
10 POLYGON ((113.1757 26.82734...

4.2 Importing the CSV

hunan2012 <- read_csv("data/aspatial/Hunan_2012.csv", show_col_types = FALSE)
glimpse(hunan2012)
Rows: 88
Columns: 29
$ County      <chr> "Anhua", "Anren", "Anxiang", "Baojing", "Chaling", "Changn~
$ City        <chr> "Yiyang", "Chenzhou", "Changde", "Hunan West", "Zhuzhou", ~
$ avg_wage    <dbl> 30544, 28058, 31935, 30843, 31251, 28518, 54540, 28597, 33~
$ deposite    <dbl> 10967.0, 4598.9, 5517.2, 2250.0, 8241.4, 10860.0, 24332.0,~
$ FAI         <dbl> 6831.7, 6386.1, 3541.0, 1005.4, 6508.4, 7920.0, 33624.0, 1~
$ Gov_Rev     <dbl> 456.72, 220.57, 243.64, 192.59, 620.19, 769.86, 5350.00, 1~
$ Gov_Exp     <dbl> 2703.0, 1454.7, 1779.5, 1379.1, 1947.0, 2631.6, 7885.5, 11~
$ GDP         <dbl> 13225.0, 4941.2, 12482.0, 4087.9, 11585.0, 19886.0, 88009.~
$ GDPPC       <dbl> 14567, 12761, 23667, 14563, 20078, 24418, 88656, 10132, 17~
$ GIO         <dbl> 9276.90, 4189.20, 5108.90, 3623.50, 9157.70, 37392.00, 513~
$ Loan        <dbl> 3954.90, 2555.30, 2806.90, 1253.70, 4287.40, 4242.80, 4053~
$ NIPCR       <dbl> 3528.3, 3271.8, 7693.7, 4191.3, 3887.7, 9528.0, 17070.0, 3~
$ Bed         <dbl> 2718, 970, 1931, 927, 1449, 3605, 3310, 582, 2170, 2179, 1~
$ Emp         <dbl> 494.310, 290.820, 336.390, 195.170, 330.290, 548.610, 670.~
$ EmpR        <dbl> 441.4, 255.4, 270.5, 145.6, 299.0, 415.1, 452.0, 127.6, 21~
$ EmpRT       <dbl> 338.0, 99.4, 205.9, 116.4, 154.0, 273.7, 219.4, 94.4, 174.~
$ Pri_Stu     <dbl> 54.175, 33.171, 19.584, 19.249, 33.906, 81.831, 59.151, 18~
$ Sec_Stu     <dbl> 32.830, 17.505, 17.819, 11.831, 20.548, 44.485, 39.685, 7.~
$ Household   <dbl> 290.4, 104.6, 148.1, 73.2, 148.7, 211.2, 300.3, 76.1, 139.~
$ Household_R <dbl> 234.5, 121.9, 135.4, 69.9, 139.4, 211.7, 248.4, 59.6, 110.~
$ NOIP        <dbl> 101, 34, 53, 18, 106, 115, 214, 17, 55, 70, 44, 84, 74, 17~
$ Pop_R       <dbl> 670.3, 243.2, 346.0, 184.1, 301.6, 448.2, 475.1, 189.6, 31~
$ RSCG        <dbl> 5760.60, 2386.40, 3957.90, 768.04, 4009.50, 5220.40, 22604~
$ Pop_T       <dbl> 910.8, 388.7, 528.3, 281.3, 578.4, 816.3, 998.6, 256.7, 45~
$ Agri        <dbl> 4942.253, 2357.764, 4524.410, 1118.561, 3793.550, 6430.782~
$ Service     <dbl> 5414.5, 3814.1, 14100.0, 541.8, 5444.0, 13074.6, 17726.6, ~
$ Disp_Inc    <dbl> 12373, 16072, 16610, 13455, 20461, 20868, 183252, 12379, 1~
$ RORP        <dbl> 0.7359464, 0.6256753, 0.6549309, 0.6544614, 0.5214385, 0.5~
$ ROREmp      <dbl> 0.8929619, 0.8782065, 0.8041262, 0.7460163, 0.9052651, 0.7~

4.3 Performing the Relational Join

The join key is County, which appears in both tables. I name it explicitly rather than letting dplyr guess, so that the code fails loudly if the column ever changes rather than silently joining on something else.

hunan <- left_join(hunan, hunan2012, by = "County") %>%
  select(1:4, 7, 15)

names(hunan)
[1] "NAME_2"    "ID_3"      "NAME_3"    "ENGTYPE_3" "County"    "GDPPC"    
[7] "geometry" 
nrow(hunan)
[1] 88

88 counties in, 88 out, with no rows duplicated or dropped by the join, which is the first thing worth checking after any left_join onto a spatial layer.

5. Visualising the Regional Development Indicator

basemap <- tm_shape(hunan) +
  tm_polygons(fill = "grey95", col = "white", lwd = 0.5) +
  tm_text("NAME_3", size = 0.35)

gdppc <- tm_shape(hunan) +
  tm_polygons(fill = "GDPPC",
              fill.scale = tm_scale_intervals(style = "quantile", values = "viridis"),
              fill.legend = tm_legend(title = "GDP per capita"),
              col = "white", lwd = 0.4)

tmap_arrange(basemap, gdppc, asp = 1, ncol = 2)

NoteWhat the choropleth already suggests

High GDP per capita is not scattered at random. The brighter counties sit together, clustered around Changsha in the north-east, and the lower quantiles form a contiguous band through the west. That visual impression of “like next to like” is precisely what spatial autocorrelation statistics formalise, but none of them can be computed until I have defined what “next to” means, which is this exercise.

6. Computing Contiguity Spatial Weights

Contiguity weights define neighbours by shared boundaries. The two standard rules are named after chess moves:

  • Queen: regions are neighbours if they share any boundary point, edge or corner.
  • Rook: regions are neighbours only if they share an edge.

6.1 Queen Contiguity

wm_q <- poly2nb(hunan, queen = TRUE)
summary(wm_q)
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 448 
Percentage nonzero weights: 5.785124 
Average number of links: 5.090909 
Link number distribution:

 1  2  3  4  5  6  7  8  9 11 
 2  2 12 16 24 14 11  4  2  1 
2 least connected regions:
30 65 with 1 link
1 most connected region:
85 with 11 links

The most connected county:

hunan$County[which.max(card(wm_q))]
[1] "Taoyuan"

Its neighbours:

hunan$NAME_3[wm_q[[which.max(card(wm_q))]]]
 [1] "Anxiang"  "Hanshou"  "Jinshi"   "Linli"    "Shimen"   "Yuanling"
 [7] "Anhua"    "Nan"      "Cili"     "Sangzhi"  "Taojiang"

And their GDP per capita:

hunan$GDPPC[wm_q[[which.max(card(wm_q))]]]
 [1] 23667 20981 34592 25554 27137 24194 14567 21311 18714 14624 19509

The full weight matrix, printed as the first few rows of neighbour lists:

str(wm_q, list.len = 5)
List of 88
 $ : int [1:5] 2 3 4 57 85
 $ : int [1:5] 1 57 58 78 85
 $ : int [1:4] 1 4 5 85
 $ : int [1:4] 1 3 5 6
 $ : int [1:4] 3 4 6 85
  [list output truncated]
 - attr(*, "class")= chr "nb"
 - attr(*, "region.id")= chr [1:88] "1" "2" "3" "4" ...
 - attr(*, "call")= language poly2nb(pl = hunan, queen = TRUE)
 - attr(*, "type")= chr "queen"
 - attr(*, "snap")= num 9e-08
 - attr(*, "sym")= logi TRUE
 - attr(*, "ncomp")=List of 2
  ..$ nc     : int 1
  ..$ comp.id: int [1:88] 1 1 1 1 1 1 1 1 1 1 ...

6.2 Rook Contiguity

wm_r <- poly2nb(hunan, queen = FALSE)
summary(wm_r)
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 440 
Percentage nonzero weights: 5.681818 
Average number of links: 5 
Link number distribution:

 1  2  3  4  5  6  7  8  9 10 
 2  2 12 20 21 14 11  3  2  1 
2 least connected regions:
30 65 with 1 link
1 most connected region:
85 with 10 links
data.frame(
  Rule = c("Queen", "Rook"),
  `Total links` = c(sum(card(wm_q)), sum(card(wm_r))),
  `Average links` = round(c(sum(card(wm_q)), sum(card(wm_r))) / nrow(hunan), 3),
  check.names = FALSE
)
   Rule Total links Average links
1 Queen         448         5.091
2  Rook         440         5.000
NoteQueen vs rook, and why the difference is small here

Queen finds a handful more links than rook. Those extra links are the corner-only contacts, where counties meet at a single point. On a regular grid the difference is large and systematic; on irregular administrative boundaries like these, exact corner-touching is rare, so the two rules very nearly agree.

That does not make the choice unimportant. It makes it cheap to defend: with so few cases in dispute, I can state that the substantive results are insensitive to the choice, which is a stronger position than having to argue for one rule on theory alone.

6.3 Visualising Contiguity Weights

The neighbour lists have to be drawn from county centroids, so those come first. st_centroid on the geometry column returns points; st_coordinates turns them into the plain longitude/latitude matrix that spdep expects.

coords <- st_coordinates(st_centroid(st_geometry(hunan)))
head(coords, 3)
            X        Y
[1,] 112.1531 29.44346
[2,] 112.0371 28.86480
[3,] 111.8918 29.47097
par(mfrow = c(1, 2))
plot(st_geometry(hunan), border = "grey70", main = "Queen contiguity")
plot(wm_q, coords, pch = 19, cex = 0.5, add = TRUE, col = "#c0392b")
plot(st_geometry(hunan), border = "grey70", main = "Rook contiguity")
plot(wm_r, coords, pch = 19, cex = 0.5, add = TRUE, col = "#2980b9")

par(mfrow = c(1, 1))

7. Computing Distance-based Neighbours

Contiguity ignores distance entirely: a shared border counts the same whether the two county centres are 20 km or 200 km apart. Distance-based weights take the opposite view.

7.1 Determining the Cut-off Distance

The cut-off must be large enough that no county is left with zero neighbours. The standard approach is to find the distance to each county’s first nearest neighbour and take the maximum of those.

k1 <- knn2nb(knearneigh(coords))
k1dists <- unlist(nbdists(k1, coords, longlat = TRUE))
summary(k1dists)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  24.79   32.56   38.00   39.07   44.52   61.81 
ImportantThe mistake that cost me this section

My first attempt passed the output of st_centroid() straight into knearneigh(), an sfc object rather than a coordinate matrix, and got a maximum first-neighbour distance of 0.6 instead of 61.8. The longlat = TRUE argument was silently overridden, so spdep measured in degrees of latitude and longitude as though they were planar units. A 62-unit cut-off then made every county a neighbour of every other county, and the fixed-distance matrix came out with 87 links per county instead of about 4.

What makes this dangerous is that it does not error. It produces a perfectly well-formed weights matrix that is completely wrong, and every statistic computed from it afterwards would have been wrong too, with nothing to indicate a problem. The lesson: pass st_coordinates() output, and sanity-check that the distances come back in plausible kilometres before using them.

The maximum first-nearest-neighbour distance is about 62 km, so every county is guaranteed at least one neighbour at that cut-off.

7.2 Fixed Distance Weight Matrix

wm_d62 <- dnearneigh(coords, 0, 62, longlat = TRUE)
wm_d62
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 324 
Percentage nonzero weights: 4.183884 
Average number of links: 3.681818 
str(wm_d62, list.len = 5)
List of 88
 $ : int [1:5] 3 4 5 57 64
 $ : int [1:4] 57 58 78 85
 $ : int [1:4] 1 4 5 57
 $ : int [1:3] 1 3 5
 $ : int [1:4] 1 3 4 85
  [list output truncated]
 - attr(*, "class")= chr "nb"
 - attr(*, "region.id")= chr [1:88] "1" "2" "3" "4" ...
 - attr(*, "call")= language dnearneigh(x = coords, d1 = 0, d2 = 62, longlat = TRUE)
 - attr(*, "dnn")= num [1:2] 0 62
 - attr(*, "bounds")= chr [1:2] "GE" "LE"
 - attr(*, "nbtype")= chr "distance"
 - attr(*, "sym")= logi TRUE
 - attr(*, "ncomp")=List of 2
  ..$ nc     : int 1
  ..$ comp.id: int [1:88] 1 1 1 1 1 1 1 1 1 1 ...
par(mfrow = c(1, 2))
plot(st_geometry(hunan), border = "grey70", main = "1st nearest neighbour")
plot(k1, coords, add = TRUE, col = "#c0392b", length = 0.08)
plot(st_geometry(hunan), border = "grey70", main = "Fixed distance, 62 km")
plot(wm_d62, coords, add = TRUE, pch = 19, cex = 0.5)

par(mfrow = c(1, 1))

7.3 Adaptive Distance Weight Matrix

A fixed distance band gives densely settled areas many neighbours and sparse areas few. Fixing the number of neighbours instead makes the bandwidth adapt to local density.

knn6 <- knn2nb(knearneigh(coords, k = 6))
knn6
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 528 
Percentage nonzero weights: 6.818182 
Average number of links: 6 
Non-symmetric neighbours list
str(knn6, list.len = 5)
List of 88
 $ : int [1:6] 2 3 4 5 57 64
 $ : int [1:6] 1 3 57 58 78 85
 $ : int [1:6] 1 2 4 5 57 85
 $ : int [1:6] 1 3 5 6 69 85
 $ : int [1:6] 1 3 4 6 69 85
  [list output truncated]
 - attr(*, "region.id")= chr [1:88] "1" "2" "3" "4" ...
 - attr(*, "call")= language knearneigh(x = coords, k = 6)
 - attr(*, "sym")= logi FALSE
 - attr(*, "type")= chr "knn"
 - attr(*, "knn-k")= num 6
 - attr(*, "class")= chr "nb"
 - attr(*, "ncomp")=List of 2
  ..$ nc     : int 1
  ..$ comp.id: int [1:88] 1 1 1 1 1 1 1 1 1 1 ...
plot(st_geometry(hunan), border = "grey70", main = "Adaptive: 6 nearest neighbours")
plot(knn6, coords, pch = 19, cex = 0.5, add = TRUE, col = "#27ae60")

NoteFixed vs adaptive, stated as a trade

Every county now has exactly six neighbours. That guarantees no isolates and a balanced weights matrix, but it forces a relationship where none may exist: a remote western county gets six neighbours even if the nearest is far away and arguably irrelevant.

The fixed-distance matrix is the honest one about geography and the dishonest one about balance; the adaptive matrix is the reverse. Neither is correct in the abstract. What matters is stating which was used and why, because the choice propagates into every subsequent result.

8. Weights Based on Inverse Distance

Contiguity says neighbours count equally. Inverse distance weighting (IDW) says nearer neighbours count more, with weight proportional to 1/distance.

dist <- nbdists(wm_q, coords, longlat = TRUE)
ids  <- lapply(dist, function(x) 1 / x)
head(ids, 2)
[[1]]
[1] 0.01535568 0.03916395 0.01821251 0.02808559 0.01144589

[[2]]
[1] 0.01535568 0.01763792 0.01926047 0.02324947 0.01718289

9. Row-standardised Weights Matrix

Raw binary weights mean a county with eight neighbours carries eight times the total influence of a county with one. Row standardisation divides each row by its number of neighbours so every county’s weights sum to 1.

rswm_q <- nb2listw(wm_q, style = "W", zero.policy = TRUE)
rswm_q
Characteristics of weights list object:
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 448 
Percentage nonzero weights: 5.785124 
Average number of links: 5.090909 

Weights style: W 
Weights constants summary:
   n   nn S0       S1       S2
W 88 7744 88 37.86334 365.9147

The weights for the first county:

rswm_q$weights[1]
[[1]]
[1] 0.2 0.2 0.2 0.2 0.2

Five neighbours, each weighted 0.2. The equivalent using the inverse-distance list:

rswm_ids <- nb2listw(wm_q, glist = ids, style = "B", zero.policy = TRUE)
rswm_ids$weights[1]
[[1]]
[1] 0.01535568 0.03916395 0.01821251 0.02808559 0.01144589
summary(unlist(rswm_ids$weights))
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
0.008225 0.015090 0.018745 0.019614 0.022822 0.040338 

10. Application: Spatially Lagged Variables

A spatial lag is the weighted average (or sum) of a variable across a region’s neighbours. It is the basic building block of every spatial autocorrelation statistic.

10.1 Spatial Lag with Row-standardised Weights

GDPPC.lag <- lag.listw(rswm_q, hunan$GDPPC)
head(GDPPC.lag, 5)
[1] 24847.20 22724.80 24143.25 27737.50 27270.25
lag.list <- list(hunan$NAME_3, lag.listw(rswm_q, hunan$GDPPC))
lag.res <- as.data.frame(lag.list)
colnames(lag.res) <- c("NAME_3", "lag GDPPC")
hunan <- left_join(hunan, lag.res, by = "NAME_3")

kable(head(hunan %>% st_drop_geometry() %>% select(NAME_3, GDPPC, `lag GDPPC`), 6))
NAME_3 GDPPC lag GDPPC
Anxiang 23667 24847.20
Hanshou 20981 22724.80
Jinshi 34592 24143.25
Li 24473 27737.50
Linli 25554 27270.25
Shimen 27137 21248.80
gdppc_map <- tm_shape(hunan) +
  tm_polygons(fill = "GDPPC",
              fill.scale = tm_scale_intervals(style = "quantile", values = "viridis"),
              col = "white", lwd = 0.3) +
  tm_title("GDPPC")

lag_map <- tm_shape(hunan) +
  tm_polygons(fill = "lag GDPPC",
              fill.scale = tm_scale_intervals(style = "quantile", values = "viridis"),
              col = "white", lwd = 0.3) +
  tm_title("Spatial lag of GDPPC")

tmap_arrange(gdppc_map, lag_map, asp = 1, ncol = 2)

10.2 Spatial Lag as a Sum of Neighbouring Values

Using binary weights instead, the lag becomes a sum rather than an average.

b_weights  <- lapply(wm_q, function(x) 0 * x + 1)
b_weights2 <- nb2listw(wm_q, glist = b_weights, style = "B")

lag_sum <- list(hunan$NAME_3, lag.listw(b_weights2, hunan$GDPPC))
lag.res <- as.data.frame(lag_sum)
colnames(lag.res) <- c("NAME_3", "lag_sum GDPPC")
hunan <- left_join(hunan, lag.res, by = "NAME_3")

kable(head(hunan %>% st_drop_geometry() %>% select(NAME_3, GDPPC, `lag_sum GDPPC`), 6))
NAME_3 GDPPC lag_sum GDPPC
Anxiang 23667 124236
Hanshou 20981 113624
Jinshi 34592 96573
Li 24473 110950
Linli 25554 109081
Shimen 27137 106244

10.3 Spatial Window Average

The window average includes the region itself alongside its neighbours. include.self() adds the diagonal.

wm_qs <- include.self(wm_q)
wm_qs
Neighbour list object:
Number of regions: 88 
Number of nonzero links: 536 
Percentage nonzero weights: 6.921488 
Average number of links: 6.090909 
wm_qs <- nb2listw(wm_qs)
lag_w_avg_gpdpc <- lag.listw(wm_qs, hunan$GDPPC)

lag.list.wm_qs <- list(hunan$NAME_3, lag.listw(wm_qs, hunan$GDPPC))
lag_wm_qs.res <- as.data.frame(lag.list.wm_qs)
colnames(lag_wm_qs.res) <- c("NAME_3", "lag_window_avg GDPPC")
hunan <- left_join(hunan, lag_wm_qs.res, by = "NAME_3")

kable(head(hunan %>% st_drop_geometry() %>%
             select(NAME_3, GDPPC, `lag GDPPC`, `lag_window_avg GDPPC`), 6))
NAME_3 GDPPC lag GDPPC lag_window_avg GDPPC
Anxiang 23667 24847.20 24650.50
Hanshou 20981 22724.80 22434.17
Jinshi 34592 24143.25 26233.00
Li 24473 27737.50 27084.60
Linli 25554 27270.25 26927.00
Shimen 27137 21248.80 22230.17

10.4 Spatial Window Sum

wm_qs2 <- include.self(wm_q)
b_weights  <- lapply(wm_qs2, function(x) 0 * x + 1)
b_weights2 <- nb2listw(wm_qs2, glist = b_weights, style = "B")

w_sum_gdppc <- list(hunan$NAME_3, lag.listw(b_weights2, hunan$GDPPC))
w_sum_gdppc.res <- as.data.frame(w_sum_gdppc)
colnames(w_sum_gdppc.res) <- c("NAME_3", "w_sum GDPPC")
hunan <- left_join(hunan, w_sum_gdppc.res, by = "NAME_3")

kable(head(hunan %>% st_drop_geometry() %>%
             select(NAME_3, GDPPC, `lag_sum GDPPC`, `w_sum GDPPC`), 6))
NAME_3 GDPPC lag_sum GDPPC w_sum GDPPC
Anxiang 23667 124236 147903
Hanshou 20981 113624 134605
Jinshi 34592 96573 131165
Li 24473 110950 135423
Linli 25554 109081 134635
Shimen 27137 106244 133381

10.5 Comparing the Four Lag Definitions

lag_maps <- function(var, ttl) {
  tm_shape(hunan) +
    tm_polygons(fill = var,
                fill.scale = tm_scale_intervals(style = "quantile", values = "viridis"),
                fill.legend = tm_legend(show = FALSE),
                col = "white", lwd = 0.3) +
    tm_title(ttl)
}

tmap_arrange(lag_maps("lag GDPPC", "Lag (row-std average)"),
             lag_maps("lag_sum GDPPC", "Lag (binary sum)"),
             lag_maps("lag_window_avg GDPPC", "Window average"),
             lag_maps("w_sum GDPPC", "Window sum"),
             ncol = 2, nrow = 2)

11. My Own Exploration: Does the Choice of Weights Matrix Actually Matter?

Everything above follows the reference chapter. This section does not. Writing section 10 left me with a question the exercise never asks: I had just built five different definitions of “neighbour” from one unchanged shapefile, and I had no idea whether that choice changes any answer or is merely bookkeeping. Rather than assert it matters, I decided to measure it.

The test is simple. Compute the spatially lagged GDP per capita under every weighting scheme, then correlate the resulting vectors. If the correlations are near 1, the choice is cosmetic. If they are not, every downstream statistic inherits the choice.

11.1 Building All Five Schemes

wm_d62_e  <- dnearneigh(coords, 0, 62, longlat = TRUE)
knn6_e    <- knn2nb(knearneigh(coords, k = 6))
dist_e    <- nbdists(wm_q, coords, longlat = TRUE)
ids_e     <- lapply(dist_e, function(x) 1 / x)

lagged <- data.frame(
  Queen     = lag.listw(nb2listw(wm_q,    style = "W", zero.policy = TRUE), hunan$GDPPC),
  Rook      = lag.listw(nb2listw(wm_r,    style = "W", zero.policy = TRUE), hunan$GDPPC),
  Fixed62   = lag.listw(nb2listw(wm_d62_e,style = "W", zero.policy = TRUE), hunan$GDPPC,
                        zero.policy = TRUE),
  Adaptive6 = lag.listw(nb2listw(knn6_e,  style = "W", zero.policy = TRUE), hunan$GDPPC),
  IDW       = lag.listw(nb2listw(wm_q, glist = ids_e, style = "W", zero.policy = TRUE),
                        hunan$GDPPC)
)

11.2 How Connected Is Each Scheme?

data.frame(
  Scheme   = c("Queen", "Rook", "Fixed 62 km", "Adaptive k=6"),
  Links    = c(sum(card(wm_q)), sum(card(wm_r)), sum(card(wm_d62_e)), sum(card(knn6_e))),
  Min      = c(min(card(wm_q)), min(card(wm_r)), min(card(wm_d62_e)), min(card(knn6_e))),
  Max      = c(max(card(wm_q)), max(card(wm_r)), max(card(wm_d62_e)), max(card(knn6_e))),
  Isolates = c(sum(card(wm_q) == 0), sum(card(wm_r) == 0),
               sum(card(wm_d62_e) == 0), sum(card(knn6_e) == 0))
)
        Scheme Links Min Max Isolates
1        Queen   448   1  11        0
2         Rook   440   1  10        0
3  Fixed 62 km   324   1   6        0
4 Adaptive k=6   528   6   6        0
par(mfrow = c(1, 4), mar = c(4, 3, 3, 1))
for (nm in c("Queen", "Rook", "Fixed 62 km", "Adaptive k=6")) {
  nb <- switch(nm, "Queen" = wm_q, "Rook" = wm_r,
               "Fixed 62 km" = wm_d62_e, "Adaptive k=6" = knn6_e)
  hist(card(nb), breaks = 0:12, main = nm, xlab = "neighbours",
       col = "#c0392b", border = "white")
}

par(mfrow = c(1, 1))

11.3 Do the Lagged Values Agree?

round(cor(lagged), 4)
           Queen   Rook Fixed62 Adaptive6    IDW
Queen     1.0000 0.9975  0.8754    0.9558 0.9852
Rook      0.9975 1.0000  0.8814    0.9557 0.9850
Fixed62   0.8754 0.8814  1.0000    0.9166 0.9251
Adaptive6 0.9558 0.9557  0.9166    1.0000 0.9576
IDW       0.9852 0.9850  0.9251    0.9576 1.0000
pairs(lagged, pch = 19, cex = 0.4, col = "#2980b9",
      main = "Lagged GDPPC under five weighting schemes")

ImportantThe answer, and it is not uniform

The correlations split the five schemes into two camps.

Queen and rook are effectively the same thing here. They correlate at 0.998. I had already guessed this in section 6.2 from the link counts, but guessing and measuring are different, and now I can say it rather than hedge it.

Contiguity and distance are not the same thing at all. Queen against the fixed 62 km band correlates at only 0.875. That is a real divergence. Roughly a quarter of the variance in one lagged variable is unexplained by the other, from identical input data, purely because of how I defined “neighbour”.

So the honest summary is that the queen/rook decision is safe to make casually, and the contiguity/distance decision is not. Those two choices are presented side by side in the chapter as though they were the same kind of decision. They are not.

11.4 Which Counties Disagree Most?

An average hides where the disagreement lives, so I looked at the individual counties where queen contiguity and adaptive k=6 diverge most.

pct_diff <- abs(lagged$Queen - lagged$Adaptive6) / lagged$Queen * 100
ord <- order(-pct_diff)[1:8]

data.frame(
  County        = hunan$NAME_3[ord],
  GDPPC         = hunan$GDPPC[ord],
  `Queen lag`   = round(lagged$Queen[ord]),
  `Adaptive lag`= round(lagged$Adaptive6[ord]),
  `Difference %`= round(pct_diff[ord], 1),
  `Queen nbrs`  = card(wm_q)[ord],
  check.names = FALSE
)
      County GDPPC Queen lag Adaptive lag Difference % Queen nbrs
1   Longshan  9754     12926        17038         31.8          3
2   Taojiang 19509     35934        44943         25.1          7
3   Jianghua 15801     17003        20681         21.6          4
4   Xiangyin 33983     37058        44816         20.9          5
5 Shuangfeng 17755     24094        28846         19.7          6
6   Xinhuang 15412     20518        16676         18.7          1
7   Yongshun  9590     14943        17709         18.5          5
8    Dongkou 13240     13870        16177         16.6          5
hunan$lag_gap <- pct_diff
tm_shape(hunan) +
  tm_polygons(fill = "lag_gap",
              fill.scale = tm_scale_intervals(style = "quantile", values = "-viridis"),
              fill.legend = tm_legend(title = "Queen vs adaptive\ndisagreement (%)"),
              col = "white", lwd = 0.3) +
  tm_layout(frame = FALSE)

Before asserting a pattern from the top eight, I tested it across all 88 counties.

data.frame(
  `Queen neighbours` = c("1 to 3", "4 to 6", "7 or more"),
  Counties = c(sum(card(wm_q) <= 3),
               sum(card(wm_q) >= 4 & card(wm_q) <= 6),
               sum(card(wm_q) >= 7)),
  `Mean disagreement %` = round(c(mean(pct_diff[card(wm_q) <= 3]),
                                  mean(pct_diff[card(wm_q) >= 4 & card(wm_q) <= 6]),
                                  mean(pct_diff[card(wm_q) >= 7])), 1),
  check.names = FALSE
)
  Queen neighbours Counties Mean disagreement %
1           1 to 3       16                10.4
2           4 to 6       54                 7.3
3        7 or more       18                 7.2
cor(card(wm_q), pct_diff)
[1] -0.1795046
NoteWhere the disagreement concentrates, and how strong that pattern really is

My first reading of the top-eight table was that poorly connected counties disagree most. Longshan is the worst case at about 32% and has only 3 queen neighbours, which fits neatly.

Then I checked it properly, and the neat story is only partly true. Counties with 3 or fewer neighbours do disagree more on average (10.4% against roughly 7.2% for everyone else), but the correlation between neighbour count and disagreement is only about -0.18. That is a weak tendency, not a rule. Taojiang sits second in the table with a 25% gap and has 7 neighbours, which the story does not explain at all.

The mechanism I can defend is narrower than the one I first reached for. If a county has three contiguous neighbours and adaptive weighting forces it to six, three non-touching counties each receive a sixth of the weight, which can move the average a long way. That explains Longshan. It does not explain Taojiang, where something else is going on, most likely a neighbour with an extreme GDPPC entering or leaving the set.

What I take from this is less about Hunan than about my own habit. I had the explanation written before I had the correlation, and the correlation turned out to be 0.18 rather than the 0.6 or 0.7 my confident phrasing implied. Ranking a table by one column and reading a cause off the top rows is a good way to generate a hypothesis and a bad way to confirm one.

12. What I Take From This Exercise

The weights matrix is a modelling assumption, not a data property. Nothing in the Hunan shapefile tells me whether two counties are neighbours. Queen or rook, 62 km or 6 nearest neighbours, binary or inverse-distance, row-standardised or not. Each is a decision I make, and each produces a different matrix from identical data. Every autocorrelation statistic and spatial regression later in the course inherits whichever one I pick. This exercise is not really about spdep syntax; it is about the fact that a large part of a spatial analysis is decided before any statistic is computed.

The silent-failure lesson. The sfc-versus-matrix mistake in section 7.1 is the one I will actually remember. It did not throw an error, it did not produce an obviously absurd map, and it yielded a valid weights object. It was simply measuring degrees and calling them kilometres. The only thing that caught it was noticing that a first-nearest-neighbour distance of 0.6 km between Chinese counties is impossible. Unit plausibility checks are not busywork; here they were the only line of defence.

Averages versus sums matter more than they look. The four lag definitions in section 10 produce visibly different maps from the same variable and the same neighbour list. The binary-sum versions are partly just a map of how many neighbours each county has, which is a property of the geography rather than of GDP. Row standardisation removes that artefact, which is why it is the sensible default for comparing across regions of uneven connectivity.

Connecting forward. The row-standardised queen contiguity matrix built here is the direct input to Moran’s I and Geary’s C in the next exercise. Section 11 gives me a concrete expectation to carry into that: switching between queen and rook should barely move Moran’s I, while switching from contiguity to a distance band plausibly could. I intend to run the autocorrelation statistic under at least two weighting schemes rather than report a single number as though the matrix behind it were a fact about Hunan instead of a decision I made.