In formal spatial models, we need a way of defining relationships between observations. In the spatial analysis and econometrics literature, the data structure used to relate observations to one another is canonically called a spatial weights matrix, or \(W\), and is used to specify the hypothesized interaction structure that may exist between spatial units (Getis, 2009). These relationships between units form a special kind of network graph embedded in geographical space, and using the libpysal.graph module, we can explore these relationships in depth.
Code
%load_ext jupyter_black%load_ext watermark%watermark -v -a "author: eli knaap"-d -u -p libpysal
Author: author: eli knaap
Last updated: 2025-11-23
Python implementation: CPython
Python version : 3.12.12
IPython version : 9.7.0
libpysal: 4.13.0
Note
The Graph class is only available in versions of libpysal >=4.9. With versions prior to that, you need to use the W class, which works a little differently than demonstrated here.
Code
import contextily as ctximport matplotlib.pyplot as pltimport pandas as pdimport geopandas as gpdimport networkx as nximport osmnx as oximport pandarm as pdnafrom geosnap import DataStorefrom geosnap import io as giofrom libpysal.graph import Graphdatasets = DataStore()
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
The graph will use the geodataframe index to identify unique observations, so when we have something meaningful (like a FIPS code) that serves as an “id variable”, then we should set that as the index.
Code
dc = gio.get_acs(datasets, state_fips="11", years=2021)dc = dc.to_crs(dc.estimate_utm_crs())dc = dc.set_index("geoid")dc.plot()plt.show()
/Users/knaaptime/miniforge3/envs/urban_analysis/lib/python3.12/site-packages/geosnap/io/util.py:273: UserWarning: Unable to find local adjustment year for 2021. Attempting from online data
warn(
/Users/knaaptime/miniforge3/envs/urban_analysis/lib/python3.12/site-packages/geosnap/io/constructors.py:218: UserWarning: Currency columns unavailable at this resolution; not adjusting for inflation
warn(
Blockgroups in Washington D.C.
The most common way of creating a spatial interaction graph is by “building” one from an existing geodataframe. The libpysal.graph.Graph class has a several methods available to generate these relationships, all of which use the Graph.build_* convention.
7.1 Contiguity Graphs
A contiguity graph assumes observations have a relationship when they share an edge and/or vertex. These are conceptually simple relationships that make sense for polygon-based data, and they encode “neighborhoods” when observations share sides (edges), corners (nodes), or a combination of the two. When observation data are encoded as points, it’s technically possible to use contiguity relationships if we rely on certain assumptions, however, strictly speaking it’s not possible for points to be be contiguous with one another.
Since many datasets in urban studies are based on polygons (e.g. administrative units), and a great deal of early research in spatial analysis was based on polygon lattice data (think counties in the U.S.), contiguity relationships are one of the most common forms of spatial Graphs used in practice.
Note
To use “contiguity” relationships using point-based observation data, it is common to use a Vornoi tesselation, which results in a set of implied polygons that encode each point (based on creating equidistance boundaries). When point data are used to create contiguity Graphs, libpysal uses this technique under the hood.
7.1.1 Rook
Code
g_rook = Graph.build_contiguity(dc)g_rook.n
571
The number of observations in our graph (the .n attribute) should match the number of rows in the dataframe from which it was constructed.
Code
dc.shape[0]
571
One way to interact with the graph structure directly is to use the adjacency attribute which returns the neighbor relationships as a multi-indexed pandas series.
“Slicing” into a Graph object returns a pandas series where the index is the neighbor’s id/key and the value is the “spatial weight” that encodes the strength of the connection between observations.
The cardinalities attribute stores the number of connections/relationships for each observation. In graph terminology, that would be the number of edges for each node, and in simple terms, this is the number of neighbors for each unit of analysis
count 571.000000
mean 5.162872
std 1.919363
min 2.000000
25% 4.000000
50% 5.000000
75% 6.000000
max 30.000000
Name: cardinalities, dtype: float64
We can get a sense for what the neighbor distribution looks like by plotting a histogram of the cardinalities
Code
g_rook.cardinalities.hist()plt.show()
Histogram of Rook Graph Cardinalities
The attribute pct_nonzero stores the share of entries in the \(n \times n\) connectivity matrix that are non-zero. If every observation were connected to every other observation, this attribute would equal 1
Code
g_rook.pct_nonzero
0.9041807625421343
In this graph, less than 1% of the entries in that matrix are non-zero, showing that this is a very sparse connectivity graph indeed. Note, to compute the pct_nonzero measure ourselves, we could alternatively rely on properties of the graph:
Code
(g_rook.n_edges / g_rook.n_nodes**2) *100
0.9041807625421342
To see the full \(n \times n\) matrix representation of the graph, the sparse (scipy) representation is available under the sparse attribute
Code
g_rook.sparse
<Compressed Sparse Row sparse array of dtype 'float64'
with 2948 stored elements and shape (571, 571)>
To see the dense version, just convert from sparse to dense using scipy conventions. Both rows and columns of the matrix representation are ordered as found in the geodataframe from which the Graph was constructed (or in the order given, when using a _from_* method). Currently this order is stored under the unique_ids attribute.
Classic spatial connectivity graphs are typically very sparse. That is, we generally do not consider every observation to have a relationship with every other observation. Rather, we tend to encode relationships such that observations are influenced directly by other observations that are nearby in physical space. But because nearby observations also interact with their neighbors, it is possible for an influence from one observation to influence another observation far away by traversing the graph.
One useful way to understand how the Graph encodes spatial relationships is to use the plot method, which embeds the graph in planar space showing how observations are connected to one another. In the default plot, each observation (or its centroid, if the observations are polygons) is shown as a dot (or node in graph terminology), and a line (or edge) is drawn between nodes that are connected. Since we are using a rook contiguity rule, the edge, here, indicates that the observations share a common border.