Spatial weights s3
- PostgreSQL with PostGIS
- Python
- 2 hours
Prerequisites: Point patterns (s2)
Who counts as a neighbour? Every spatial statistic compares each unit with its neighbours, so the rule is part of the result. Three rules (a distance band, k-nearest and contiguity), row standardization, and where they break: ties, one-way links and islands, on s4's hexagons over Tasmania and on the countries' outlines. The SQL runs in your own PostgreSQL with PostGIS and the PySAL block in the Python environment, on the lab tables (the setup page).
Concept
Who counts as a unit's neighbour? Every spatial statistic compares each unit with its neighbours, so the answer is part of the result.
- A spatial weights matrix W holds, for each pair of units i and j, a weight
w_ijsaying how much j counts as i's neighbour;w_iiis 0. Most weights are binary: 1 for a neighbour, 0 otherwise. The units here are s4's hexagons and the countries' outlines. - A distance band makes j a neighbour of i when their centroids lie within a threshold. Every unit reaches equally far, so the number of neighbours varies: a cell on the coast has fewer. s4's
s4_neighboursis a band of twice the hexagons' edge. - k-nearest makes each unit's k closest units its neighbours. Every unit gets exactly k, so how far it reaches varies instead. The weights aren't symmetric: j can be among i's nearest without i being among j's.
- Ties: when several units sit at the same distance, the k-th neighbour is a choice, and each program breaks the tie its own way. Grids are full of ties, so two programs can give different k-nearest weights from the same data.
- Contiguity makes units neighbours when their shapes share a boundary: an edge for rook contiguity, an edge or a corner for queen. It needs the shapes, not just centroids. Hexagons that touch always share an edge, so on a hexagon grid rook, queen and s4's band give the same neighbours; on a square grid queen adds the four diagonal cells.
- Row standardization divides each row of W by its sum, so the weights of a unit's neighbours add to 1:
w_ij = 1/k_i, wherek_iis i's number of neighbours. The weighted sumΣ_j w_ij y_jis then the mean of the neighbours' values, the spatial lag. A unit with one neighbour gives it all the weight. - An island is a unit with no neighbour. Its row is all zeros and can't be standardized (its sum is 0), and a statistic over it either drops the unit or is undefined. A distance band too short, or contiguity between real islands, makes them.
Worked example
The distance band from Global Moran's I, s4, read as weights. s4_cells holds the hexagons over Tasmania's main island at edges of 5 and 10 km and s4_neighbours their pairs, one row per ordered pair (i, j); s4's first block, s4-cells, makes both. Every download of this module runs that block first, right after connecting, so s3.sql and s3.py work on their own; to run the blocks one at a time, run s4-cells once first. How many neighbours the band gives each cell:
Checked 2026-11-02 on PostgreSQL 16.15, PostGIS 3.4.2, GEOS 3.12.1, GDAL 3.8.4; Python 3.12.3 with requirements.txt sha256 d5bbe28024cc.
s3-contiguity · PostgreSQL with PostGIS
SELECT c.edge_m, k.k AS neighbours, count(*) AS cells
FROM s4_cells AS c
CROSS JOIN LATERAL (SELECT count(*) AS k FROM s4_neighbours AS nb WHERE nb.i = c.id) AS k
GROUP BY c.edge_m, k.k
ORDER BY c.edge_m, k.k;
-- result: s3-contiguity · 11 rows · sha256 13c0a4f1ea54 edge_m | neighbours | cells 5000 | 1 | 1 5000 | 2 | 7 5000 | 3 | 36 5000 | 4 | 66 5000 | 5 | 45 … 6 more rows
In s3.sql, with the blocks it runs after.
On the 5 km grid, 939 of the 1,094 cells have all six neighbours; the coast leaves the rest with fewer, down to one cell with a single neighbour (cell 1051). On the 10 km grid, 221 of the 296 cells have six and the fewest is two (6 cells). No cell has none, so the band leaves no island. A cell with no neighbour would show as a row with 0, since count(*) over no rows gives 0, and the result has no such row.
Lab
k-nearest neighbours on the 5 km cells, ranked by distance rounded to the centimetre and then by id, so ties are broken the same way every run. For k = 4 and k = 6: the links, the links whose reverse is missing, and the cells whose k-th and (k + 1)-th neighbours are tied.
LIMIT 7keeps one more neighbour than the largest k, to see the tie:s3-knn · PostgreSQL with PostGIS
WITH near AS ( SELECT a.id AS i, b.id AS j, b.d_m FROM s4_cells AS a CROSS JOIN LATERAL ( SELECT c.id, round(ST_Distance(ST_Point(a.cx, a.cy), ST_Point(c.cx, c.cy))::numeric, 2) AS d_m FROM s4_cells AS c WHERE c.edge_m = a.edge_m AND c.id <> a.id ORDER BY d_m, c.id LIMIT 7 ) AS b WHERE a.edge_m = 5000 ), ranked AS ( SELECT i, j, d_m, row_number() OVER (PARTITION BY i ORDER BY d_m, j) AS rank FROM near ) SELECT k.k, count(*) FILTER (WHERE r.rank <= k.k) AS links, count(*) FILTER (WHERE r.rank <= k.k AND NOT EXISTS ( SELECT 1 FROM ranked AS back WHERE back.i = r.j AND back.j = r.i AND back.rank <= k.k)) AS one_way, count(*) FILTER (WHERE r.rank = k.k AND r.d_m = ( SELECT nx.d_m FROM ranked AS nx WHERE nx.i = r.i AND nx.rank = k.k + 1)) AS tied_cells FROM ranked AS r, (VALUES (4), (6)) AS k (k) GROUP BY k.k ORDER BY k.k;-- result: s3-knn · 2 rows · sha256 160008ed6781 k | links | one_way | tied_cells 4 | 4376 | 1968 | 1025 6 | 6564 | 254 | 129
In s3.sql, with the blocks it runs after.
With k = 4, 1,025 of the 1,094 cells have their 4th and 5th neighbours at the same distance: an interior cell has six touching cells, all 8.66 km away, and must keep four of them. The rule here (lowest id first) gives the same answer every run, but it isn't neutral.
s4-cellsnumbers the cells column by column from the west, so each cell keeps the cells directly above and below it and its two western neighbours; those western cells keep their own western neighbours, never it. 1,968 of the 4,376 links are one-way. With k = 6 an interior cell keeps all six touching cells, so only 129 cells, near the coast, are tied, and 254 links are one-way.Contiguity between countries, where the units are real outlines. Two tests can call outlines neighbours:
ST_Touches(the boundaries meet and the interiors don't) andST_Intersects(they share any point). For each country, the outlines it intersects and the ones it touches; then the countries with none (islands), those with more intersecting than touching outlines (overlapping), and the most neighbours any country has:s3-countries · PostgreSQL with PostGIS
WITH pairs AS ( SELECT a.country_code, b.country_code AS other, ST_Touches(a.geom, b.geom) AS touches FROM country_shapes AS a LEFT JOIN country_shapes AS b ON b.country_code <> a.country_code AND ST_Intersects(a.geom, b.geom) ), per_country AS ( SELECT country_code, count(other) AS intersecting, count(other) FILTER (WHERE touches) AS touching FROM pairs GROUP BY country_code ) SELECT count(*) AS countries, count(*) FILTER (WHERE intersecting = 0) AS islands, count(*) FILTER (WHERE touching = 0) AS islands_by_touches, count(*) FILTER (WHERE intersecting > touching) AS overlapping, max(intersecting) AS most_neighbours FROM per_country;-- result: s3-countries · 1 row · sha256 5c0dcf803a58 countries | islands | islands_by_touches | overlapping | most_neighbours 247 | 76 | 84 | 12 | 16
In s3.sql, with the blocks it runs after.
76 of the 247 outlines intersect no other: as units, they are islands.
ST_Touchesfinds 84. The 12 overlapping countries are the eight pairs of nested outlines from Loading and validating shapes, g2: France's outline includes French Guiana, Réunion, Guadeloupe, Martinique and Mayotte, Norway's Svalbard and Jan Mayen, the Netherlands' the Caribbean Netherlands and New Zealand's Tokelau, and each of those places also has its own outline. A nested pair's interiors overlap, soST_Touchessays no, and eight of the twelve (New Zealand and seven of the places) have no other neighbour: 8 more islands. Neither test is right on its own: the layer isn't a coverage (shapes that tile the area without overlapping), so decide what a nested pair should be before building contiguity on it. The most neighbours any outline intersects is 16 (China).The islands that hold records:
s3-islands · PostgreSQL with PostGIS
SELECT a.country_code, c.name, count(o.id) AS records FROM country_shapes AS a JOIN countries AS c ON c.code = a.country_code JOIN occurrences AS o ON o.country_code = a.country_code WHERE NOT EXISTS (SELECT 1 FROM country_shapes AS b WHERE b.country_code <> a.country_code AND ST_Intersects(a.geom, b.geom)) GROUP BY a.country_code, c.name ORDER BY records DESC, a.country_code;-- result: s3-islands · 10 rows · sha256 0039efb8454f country_code | name | records AU | Australia | 4636 MG | Madagascar | 2012 KM | Comoros | 109 LK | Sri Lanka | 26 PH | Philippines | 12 … 5 more rows
In s3.sql, with the blocks it runs after.
Ten islands hold records, among them Australia (4,636) and Madagascar (2,012). Contiguity weights over countries would give both no neighbour, so a statistic over countries would drop them or fail. The other five are Singapore (11), Japan (2), Trinidad and Tobago (2), Mauritius (1) and Niue (1); Singapore is here because its outline doesn't reach Malaysia's across the strait between them.
The same weights in PySAL: queen contiguity from the hexagons' shapes against the distance band from their centroids, row standardization, and k = 4 nearest neighbours.
Queen.from_dataframereads the shapes from a GeoDataFrame (GeoPandas' table with a geometry column);DistanceBandwithbinary=Truegives weights of 1 within the threshold, here 10 km, twice the edge.queen.s0is the sum of all weights, so the number of links while they are binary;histogramcounts the cells by number of neighbours.cKDTree.query(xy, k=6)returns each cell's six nearest centroids, itself first at distance 0, so columns 4 and 5 are its 4th and 5th neighbours:s3-pysal · Python
Runs after: py-connect
import geopandas as gpd import numpy as np import shapely from libpysal.weights import KNN, DistanceBand, Queen from scipy.spatial import cKDTree with connect() as conn: rows = conn.execute( "SELECT id, cx, cy, ST_AsBinary(geom) FROM s4_cells WHERE edge_m = 5000 ORDER BY id" ).fetchall() ids = [r[0] for r in rows] xy = np.array([(r[1], r[2]) for r in rows]) cells = gpd.GeoDataFrame({"id": ids}, geometry=shapely.from_wkb([bytes(r[3]) for r in rows]), crs=3577) queen = Queen.from_dataframe(cells, ids="id") band = DistanceBand(xy, threshold=10000, binary=True, ids=ids) same = all(set(queen.neighbors[i]) == set(band.neighbors[i]) for i in ids) print(f"queen = distance band: {same} links: {queen.s0:.0f} islands: {queen.islands}") print("neighbours: cells | " + " ".join(f"{k}: {c}" for k, c in queen.histogram)) queen.transform = "r" rows_sum_to_one = np.allclose(np.asarray(queen.sparse.sum(axis=1)).ravel(), 1) lone = next(i for i in ids if queen.cardinalities[i] == 1) inner = next(i for i in ids if queen.cardinalities[i] == 6) print(f"rows sum to 1: {rows_sum_to_one} cell {lone} (1 neighbour): {queen.weights[lone]} " f"cell {inner} (6): {[round(v, 4) for v in queen.weights[inner]]}") knn = KNN(xy, k=4, ids=ids, silence_warnings=True) one_way = sum(1 for i in ids for j in knn.neighbors[i] if i not in knn.neighbors[j]) d, _ = cKDTree(xy).query(xy, k=6) tied = int(np.sum(np.round(d[:, 4], 2) == np.round(d[:, 5], 2))) print(f"k = 4: links: {knn.s0:.0f} one-way: {one_way} cells with the 4th and 5th tied: {tied}")-- result: s3-pysal · python · sha256 b8f324551172 queen = distance band: True links: 6246 islands: [] neighbours: cells | 1: 1 2: 7 3: 36 4: 66 5: 45 6: 939 rows sum to 1: True cell 1051 (1 neighbour): [1.0] cell 8 (6): [0.1667, 0.1667, 0.1667, 0.1667, 0.1667, 0.1667] k = 4: links: 4376 one-way: 600 cells with the 4th and 5th tied: 1025
Queen contiguity from the shapes gives exactly the band's 6,246 links, the same histogram as
s3-contiguityand no island. Afterqueen.transform = "r"every row sums to 1: cell 1051's one neighbour has weight 1.0, and an interior cell's six have 0.1667 each. PySAL's k = 4 has the same 4,376 links and the same 1,025 tied cells as the SQL, but leaves 600 links one-way, not 1,968: its k-d tree (a structure for finding nearest points fast) breaks the ties in its own order. Same data, same distances, different weights.
Result
On the 5 km grid, 939 of the 1,094 cells have all six neighbours and the coast leaves the rest with fewer, down to one cell with a single neighbour (cell 1051); on the 10 km grid 221 of 296 have six and the fewest is two. No cell has none. PySAL's queen contiguity from the hexagons' shapes gives exactly the distance band's 6,246 links, the same histogram and no island. After row standardization every row sums to 1: cell 1051's one neighbour has weight 1.0, an interior cell's six have 0.1667 each. k-nearest is lopsided: with k = 4, 1,025 cells have their 4th and 5th neighbours at the same distance, so the choice is a tie-break; SQL's rule (lowest id) leaves 1,968 of 4,376 links one-way, PySAL's leaves 600, with the same distances. With k = 6 only 129 cells are tied and 254 links one-way. Between countries, 76 of the 247 outlines intersect no other: they are islands, and 10 of them hold records (Australia 4,636, Madagascar 2,012, Comoros 109, …), so contiguity weights over countries would give Australia and Madagascar no neighbour at all. ST_Touches alone finds 84 islands, because 12 countries belong to g2's nested pairs (France and its overseas regions, among others), whose interiors overlap; the most neighbours any outline intersects is 16.
Exercise
k-nearest weights never leave a cell without neighbours: with k = 6, every 5 km cell gets six. How far does each cell have to reach for its 6th neighbour? Group the 5 km cells by their number of neighbours in s4's distance band (s4_neighbours), and show for each group the cells, their records, and the shortest and longest reach in km. What does k = 6 do to a coastal cell, and which of the two would you use for s4's Moran's I (s4)?
Show answer
s3-ex · PostgreSQL with PostGIS
WITH band AS (
SELECT i, count(*) AS k FROM s4_neighbours WHERE edge_m = 5000 GROUP BY i
), reach AS (
SELECT band.k, a.n,
(SELECT ST_Distance(ST_Point(a.cx, a.cy), ST_Point(c.cx, c.cy))
FROM s4_cells AS c
WHERE c.edge_m = a.edge_m AND c.id <> a.id
ORDER BY 1, c.id
OFFSET 5 LIMIT 1) AS d6_m
FROM s4_cells AS a JOIN band ON band.i = a.id
WHERE a.edge_m = 5000
)
SELECT k AS band_neighbours, count(*) AS cells, sum(n) AS records,
round((min(d6_m) / 1000)::numeric, 2) AS shortest_km,
round((max(d6_m) / 1000)::numeric, 2) AS longest_km
FROM reach
GROUP BY k
ORDER BY k;
-- result: s3-ex · 6 rows · sha256 3f25bd602944 band_neighbours | cells | records | shortest_km | longest_km 1 | 1 | 0 | 22.91 | 22.91 2 | 7 | 57 | 17.32 | 17.32 3 | 36 | 16 | 15.00 | 17.32 4 | 66 | 243 | 15.00 | 15.00 5 | 45 | 133 | 15.00 | 15.00 … 1 more row
In s3.sql, with the blocks it runs after.
Check
The answer: the band's neighbour count per cell, then each cell's 6th-nearest centroid with the same ordering as s3-knn (distance, then id); OFFSET 5 LIMIT 1 skips the first five.
The 939 cells with six neighbours in the band reach 8.66 km (√3 times the edge), the touching cells, so for them k = 6 and the band agree. Every cell on the coast reaches farther: 15.00 km (3 times the edge, the second ring) for the cells with four or five band neighbours, 15.00 to 17.32 km for those with three, 17.32 km for the cells with two, and 22.91 km for cell 1051, more than two and a half times an interior cell's reach. Those 155 coastal cells hold 449 of the 1,972 records in cells. k = 6 keeps the number of neighbours equal and lets the distance grow; the band keeps the distance equal and lets the number shrink. For s4 the band is the better choice: the cells are equal, they cover one island with no gap, and the band leaves no island, so a coastal cell is compared with the cells it touches, not with cells two rings inland. k-nearest earns its place where units differ in size and spacing, like countries, or where a band would leave islands; then state k and the tie rule with the result.
Dialects
- PostgreSQL:
ST_DWithin(a, b, d)builds a band and uses a GiST index on either column; k-nearest is aCROSS JOIN LATERAL (… ORDER BY a.geom <-> b.geom LIMIT k), which an index serves whengeomis a column with one (Spatial indexes, g5), not a point built on the fly as here.ST_Touchesis queen contiguity; rook needs the shared boundary to be a line,ST_Relate(a.geom, b.geom, 'F***1****')(interiors apart, boundaries sharing a line; g2's DE-9IM). PostgreSQL has no matrix type: weights live as rows(i, j, w), as ins4_neighbours. - DuckDB: the
spatialextension hasST_DWithin,ST_Touches,ST_IntersectsandST_Distance, but no<->. k-nearest ranks every pair:QUALIFY row_number() OVER (PARTITION BY a.id ORDER BY ST_Distance(a.geom, b.geom), b.id) <= 4, which 1,094 cells (1.2 million pairs) allow. - SQL Server:
STDistance,STTouches,STIntersectsandSTRelate(other, 'F***1****')for rook; k-nearest isCROSS APPLY (SELECT TOP (4) … WHERE b.geom.STDistance(a.geom) IS NOT NULL ORDER BY b.geom.STDistance(a.geom), b.id), which a spatial index can serve (g5). - PySAL:
libpysal.weightshasDistanceBand,KNN,QueenandRook(from_dataframefor shapes);w.transform = "r"row-standardizes and"b"goes back to binary;w.islands,w.cardinalities,w.histogramandw.s0describe the weights, and building them warns about islands unlesssilence_warnings=True.DistanceBand(…, binary=False, alpha=-1)gives inverse-distance weights. The newerlibpysal.graph.Graph(build_distance_band,build_knn,build_contiguity) builds the same rules.KNNhas no tie rule to choose: its k-d tree decides. - ArcGIS Pro: Generate Spatial Weights Matrix (
arcpy.stats.GenerateSpatialWeightsMatrix) writes a.swmfile the statistics tools read. Conceptualization of Spatial Relationships:Fixed distance(a band, with Threshold Distance),K nearest neighbors(Number of Neighbors),Contiguity edges only(rook) orContiguity edges corners(queen); Row Standardization is on by default. WithFixed distance, Number of Neighbors is a minimum: a unit with fewer neighbours within the threshold gets its nearest ones added, so no unit is an island, and its reach grows like k-nearest's.