Origin–Destination Matrices to Graphs¶
od_matrix_to_graph converts origin–destination (OD) data into a spatial graph. It accepts an edge list (a DataFrame with source, target, and weight columns) or an adjacency matrix (a square DataFrame or NumPy array), together with a zones_gdf holding the zone geometries. This notebook works through toy examples of both formats, then applies the function to 2021 census migration flows between the MSOAs of England and Wales.
1. Overview¶
The core inputs are the OD table, the zone geometries, and the choice of directed or undirected treatment. The examples start with a small grid of toy zones to show the mechanics, then scale up to a real migration network.
2. Imports and setup¶
# Imports
import numpy as np
import pandas as pd
import geopandas as gpd
import networkx as nx
import matplotlib.pyplot as plt
import city2graph as c2g
plt.rcParams["figure.figsize"] = (10, 8)
3. Edge list¶
The first example uses a grid of toy zones and an edge list with a single weight column.
Creating sample zones¶
A 4×4 grid of 1×1 degree square zones, each with an identifier such as G00 or G10, serves as the toy geography.
# Sixteen zones as a 4x4 grid of square cells (polygons) with explicit ids
from shapely.geometry import box
cells = []
ids = []
# Iterate rows (y) and columns (x) to build 1x1 degree squares
for i in range(4): # rows (y = 0..3)
for j in range(4): # cols (x = 0..3)
cells.append(box(j, i, j + 1, i + 1))
ids.append(f"G{j}{i}") # e.g., G00, G10, ..., G33
zones_gdf = gpd.GeoDataFrame({"zone_id": ids}, geometry=cells, crs="EPSG:4326")
zones_gdf.head()
| zone_id | geometry | |
|---|---|---|
| 0 | G00 | POLYGON ((1 0, 1 1, 0 1, 0 0, 1 0)) |
| 1 | G10 | POLYGON ((2 0, 2 1, 1 1, 1 0, 2 0)) |
| 2 | G20 | POLYGON ((3 0, 3 1, 2 1, 2 0, 3 0)) |
| 3 | G30 | POLYGON ((4 0, 4 1, 3 1, 3 0, 4 0)) |
| 4 | G01 | POLYGON ((1 1, 1 2, 0 2, 0 1, 1 1)) |
Creating the edge list and converting to graph¶
The edge list is a DataFrame of source–target pairs with a flow value — for example, 5 units from G00 to G10. od_matrix_to_graph() turns it into a pair of node and edge GeoDataFrames.
# Edge list with single weight column using grid zone ids (subset for clarity)
E = pd.DataFrame({
"source": ["G00", "G00", "G10", "G01", "G22", "G23", "G12"],
"target": ["G10", "G01", "G11", "G11", "G32", "G33", "G22"],
"flow": [5, 2, 3, 7, 4, 6, 5],
})
nodes_gdf1, edges_gdf1 = c2g.od_matrix_to_graph(
E, zones_gdf, zone_id_col="zone_id",
matrix_type="edgelist",
source_col="source", target_col="target",
weight_cols=["flow"],
threshold=None, # drop zeros only
include_self_loops=False,
compute_edge_geometry=True,
directed=True,
as_nx=False,
)
/Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:135: UserWarning: Geographic CRS detected; distance/length measures may be inaccurate (requirement 3.5) _validate_crs(zones_gdf) /Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:1101: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. centroids = zones_gdf.geometry.centroid
The function returns two GeoDataFrames: nodes_gdf1 contains the zone geometries, and edges_gdf1 contains the flow edges with geometries connecting zone centroids.
edges_gdf1.head()
| weight | flow | geometry | ||
|---|---|---|---|---|
| source | target | |||
| G00 | G01 | 2 | 2 | LINESTRING (0.5 0.5, 0.5 1.5) |
| G10 | 5 | 5 | LINESTRING (0.5 0.5, 1.5 0.5) | |
| G01 | G11 | 7 | 7 | LINESTRING (0.5 1.5, 1.5 1.5) |
| G10 | G11 | 3 | 3 | LINESTRING (1.5 0.5, 1.5 1.5) |
| G12 | G22 | 5 | 5 | LINESTRING (1.5 2.5, 2.5 2.5) |
Visualising the spatial graph¶
Zone boundaries and flow edges plotted together, with edge thickness proportional to the flow weight.
# Quick plot of nodes (grid cells) and edges
ax = zones_gdf.boundary.plot(color="#2E86AB", alpha=0.8, linewidth=0.8)
c2g.plot_graph(
nodes=nodes_gdf1.set_geometry(nodes_gdf1.centroid),
edges=edges_gdf1,
ax=ax,
bgcolor="#f8f9fa",
node_color="#2E86AB",
node_alpha=0.8,
markersize=20,
edge_color="#E67E22",
edge_linewidth=np.log1p(edges_gdf1["weight"]) * 1.2,
)
plt.title("Mock edgelist graph on 4x4 grid (directed)")
plt.axis("equal"); plt.axis("off")
plt.show()
/var/folders/_n/l2f9tkgn3g17dj7hnsjprssc0000gn/T/ipykernel_20184/2848274737.py:4: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. nodes=nodes_gdf1.set_geometry(nodes_gdf1.centroid),
4. Undirected with multiple weights¶
With directed=False, reciprocal flows are merged by summing, and several weight columns can be carried at once; threshold_col selects the primary weight (the canonical weight).
Creating an undirected graph with multiple weights¶
The edge data below contains reciprocal pairs (G00→G10 and G10→G00) and two weight columns.
# Multi-weight undirected example on 4x4 grid (subset of pairs with reciprocals)
E2 = pd.DataFrame({
"source": ["G00", "G10", "G01", "G11", "G22", "G32", "G23", "G33"],
"target": ["G10", "G00", "G11", "G01", "G32", "G22", "G33", "G23"],
"trips": [5, 1, 7, 2, 4, 3, 8, 2],
"cost": [10, 2, 12, 3, 7, 4, 15, 3],
})
nodes_gdf2, edges_gdf2 = c2g.od_matrix_to_graph(
E2, zones_gdf, zone_id_col="zone_id",
matrix_type="edgelist",
source_col="source", target_col="target",
weight_cols=["trips", "cost"],
threshold=3, threshold_col="trips",
include_self_loops=False,
compute_edge_geometry=True,
directed=False, # undirected: sum reciprocals
as_nx=False,
)
/Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:135: UserWarning: Geographic CRS detected; distance/length measures may be inaccurate (requirement 3.5) _validate_crs(zones_gdf) /Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:1101: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. centroids = zones_gdf.geometry.centroid
Plotting the undirected graph¶
Reciprocal flows now appear as single undirected edges with their combined weights.
ax = zones_gdf.boundary.plot(color="#2E86AB", alpha=0.8, linewidth=0.8)
c2g.plot_graph(
nodes=nodes_gdf2.set_geometry(nodes_gdf2.centroid),
edges=edges_gdf2,
ax=ax,
bgcolor="#f8f9fa",
node_color="#2E86AB",
node_alpha=0.8,
markersize=20,
edge_color="#9B59B6",
edge_linewidth=np.log1p(edges_gdf2["weight"]) * 1.2,
)
plt.title("Mock edgelist graph on 4x4 grid (undirected, primary=trips)")
plt.axis("equal"); plt.axis("off")
plt.show()
/var/folders/_n/l2f9tkgn3g17dj7hnsjprssc0000gn/T/ipykernel_20184/1635354523.py:3: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. nodes=nodes_gdf2.set_geometry(nodes_gdf2.centroid),
5. Adjacency matrix¶
The same conversion accepts a square pandas DataFrame (index and columns must match) or a NumPy array.
Building an adjacency matrix¶
A 16×16 DataFrame whose rows and columns are zones and whose cell values are flows.
# Build an adjacency DataFrame matching the 4x4 grid zone ids
ids = zones_gdf["zone_id"]
# Create a sparse 16x16 matrix with a few flows
A = pd.DataFrame(0, index=ids, columns=ids, dtype=float)
# add some directed flows
A.loc["G00", "G10"] = 5
A.loc["G00", "G01"] = 2
A.loc["G10", "G11"] = 3
A.loc["G01", "G11"] = 7
A.loc["G22", "G32"] = 4
A.loc["G23", "G33"] = 6
A.loc["G12", "G22"] = 5
nodes_gdf3, edges_gdf3 = c2g.od_matrix_to_graph(
A, zones_gdf, zone_id_col="zone_id",
matrix_type="adjacency",
include_self_loops=False,
threshold=None,
directed=True,
)
/Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:135: UserWarning: Geographic CRS detected; distance/length measures may be inaccurate (requirement 3.5) _validate_crs(zones_gdf) /Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:1101: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. centroids = zones_gdf.geometry.centroid
Visualising the adjacency matrix graph¶
The flows are the same as in the first example, so the resulting graph is identical; only the input format differs.
ax = zones_gdf.boundary.plot(color="#2E86AB", alpha=0.8, linewidth=0.8)
c2g.plot_graph(
nodes=nodes_gdf3.set_geometry(nodes_gdf3.centroid),
edges=edges_gdf3,
ax=ax,
bgcolor="#f8f9fa",
node_color="#2E86AB",
node_alpha=0.8,
markersize=20,
edge_color="#27AE60",
edge_linewidth=np.log1p(edges_gdf3["weight"]) * 1.2,
)
plt.title("Mock adjacency graph on 4x4 grid (directed)")
plt.axis("equal"); plt.axis("off")
plt.show()
/var/folders/_n/l2f9tkgn3g17dj7hnsjprssc0000gn/T/ipykernel_20184/1234772101.py:3: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. nodes=nodes_gdf3.set_geometry(nodes_gdf3.centroid),
6. England & Wales MSOA example¶
This section applies the same workflow to real data, read from local files; adjust the paths as needed.
Loading real-world data¶
The OD table records migration between England and Wales from the UK Census 2021, with MSOA (Middle layer Super Output Area) boundaries as the zone unit.
# Paths (adjust to your environment if needed)
# Relative to repository root
ZONE_GPKG = "../examples/data/Middle_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V3_-1334546435986816930.gpkg"
OD_CSV = "../examples/data/odmg/odmg01ew/ODMG01EW_MSOA.csv"
zones_london = gpd.read_file(ZONE_GPKG)
od_london = pd.read_csv(OD_CSV)
print(f"zones_london: {len(zones_london)} rows, CRS={zones_london.crs}")
od_london.head(3)
zones_london: 7264 rows, CRS=EPSG:27700
| Migrant MSOA one year ago code | Migrant MSOA one year ago label | Middle layer Super Output Areas code | Middle layer Super Output Areas label | Count | |
|---|---|---|---|---|---|
| 0 | -8 | Does not apply | E02000001 | City of London 001 | 6237 |
| 1 | -8 | Does not apply | E02000002 | Barking and Dagenham 001 | 7622 |
| 2 | -8 | Does not apply | E02000003 | Barking and Dagenham 002 | 10285 |
Converting the migration data to a graph¶
The census table has its own column names, so these are mapped to the function's parameters. The graph is built undirected, treating migration as a bidirectional flow between each pair of MSOAs.
# Column mapping for ODMG dataset
source_col = "Migrant MSOA one year ago code"
target_col = "Middle layer Super Output Areas code"
weight_col = "Count"
zone_id_col = "MSOA21CD"
od_nodes, od_edges = c2g.od_matrix_to_graph(
od_london, zones_london, zone_id_col=zone_id_col,
matrix_type="edgelist",
source_col=source_col, target_col=target_col,
weight_cols=[weight_col],
threshold=None,
include_self_loops=False,
compute_edge_geometry=True,
directed=False,
as_nx=False,
)
len(od_nodes), len(od_edges)
/Users/yutasato/Projects/Liverpool/city2graph/city2graph/mobility.py:173: UserWarning: Dropped 36661 edges referencing unknown zone IDs (requirement 3.6) aligned = _align_edgelist_zones(
(7264, 1228547)
Inspecting the graph structure¶
A quick look at the node and edge tables of the migration network.
# Inspect a few rows
od_nodes.head()
| MSOA21CD | MSOA21NM | MSOA21NMW | BNG_E | BNG_N | LAT | LONG | GlobalID | geometry | |
|---|---|---|---|---|---|---|---|---|---|
| MSOA21CD | |||||||||
| E02000001 | E02000001 | City of London 001 | 532384 | 181355 | 51.515621 | -0.093490 | {71249043-B176-4306-BA6C-D1A993B1B741} | MULTIPOLYGON (((532135.138 182198.131, 532071.... | |
| E02000002 | E02000002 | Barking and Dagenham 001 | 548267 | 189685 | 51.586521 | 0.138756 | {997A80A8-0EBE-461C-91EB-3E4122571A6E} | MULTIPOLYGON (((548881.563 190845.265, 548845.... | |
| E02000003 | E02000003 | Barking and Dagenham 002 | 548259 | 188520 | 51.576061 | 0.138149 | {62DED9D9-F53A-454D-AF35-04404D9DBE9B} | MULTIPOLYGON (((549102.438 189324.625, 549120.... | |
| E02000004 | E02000004 | Barking and Dagenham 003 | 551004 | 186412 | 51.556389 | 0.176828 | {511181CD-E71F-4C63-81EE-E8E76744A627} | MULTIPOLYGON (((551550.056 187364.705, 551551.... | |
| E02000005 | E02000005 | Barking and Dagenham 004 | 548733 | 186824 | 51.560692 | 0.144267 | {B0C823EB-69E0-4AE7-9E1C-37715CF3FE87} | MULTIPOLYGON (((549099.634 187656.076, 549057.... |
od_edges.head()
| weight | Count | geometry | ||
|---|---|---|---|---|
| source | target | |||
| E02000001 | E02000012 | 1 | 1 | LINESTRING (532485.482 181271.782, 545635.991 ... |
| E02000024 | 1 | 1 | LINESTRING (532485.482 181271.782, 524413.978 ... | |
| E02000029 | 1 | 1 | LINESTRING (532485.482 181271.782, 527246.245 ... | |
| E02000030 | 1 | 1 | LINESTRING (532485.482 181271.782, 522736.368 ... | |
| E02000035 | 3 | 3 | LINESTRING (532485.482 181271.782, 525825.887 ... |
Calculating network centrality¶
Converting the graph to NetworkX gives access to its centrality measures.
G = c2g.gdf_to_nx(od_nodes, od_edges)
# Calculate centralities
degree_centrality = nx.degree_centrality(G)
# Set as node attributes
nx.set_node_attributes(G, degree_centrality, 'degree_centrality')
# Convert back to GeoDataFrames
od_nodes, od_edges = c2g.nx_to_gdf(G)
od_nodes.head()
| MSOA21CD | MSOA21NM | MSOA21NMW | BNG_E | BNG_N | LAT | LONG | GlobalID | geometry | degree_centrality | |
|---|---|---|---|---|---|---|---|---|---|---|
| MSOA21CD | ||||||||||
| E02000001 | E02000001 | City of London 001 | 532384 | 181355 | 51.515621 | -0.093490 | {71249043-B176-4306-BA6C-D1A993B1B741} | MULTIPOLYGON (((532135.138 182198.131, 532071.... | 0.116205 | |
| E02000002 | E02000002 | Barking and Dagenham 001 | 548267 | 189685 | 51.586521 | 0.138756 | {997A80A8-0EBE-461C-91EB-3E4122571A6E} | MULTIPOLYGON (((548881.563 190845.265, 548845.... | 0.039928 | |
| E02000003 | E02000003 | Barking and Dagenham 002 | 548259 | 188520 | 51.576061 | 0.138149 | {62DED9D9-F53A-454D-AF35-04404D9DBE9B} | MULTIPOLYGON (((549102.438 189324.625, 549120.... | 0.061683 | |
| E02000004 | E02000004 | Barking and Dagenham 003 | 551004 | 186412 | 51.556389 | 0.176828 | {511181CD-E71F-4C63-81EE-E8E76744A627} | MULTIPOLYGON (((551550.056 187364.705, 551551.... | 0.042269 | |
| E02000005 | E02000005 | Barking and Dagenham 004 | 548733 | 186824 | 51.560692 | 0.144267 | {B0C823EB-69E0-4AE7-9E1C-37715CF3FE87} | MULTIPOLYGON (((549099.634 187656.076, 549057.... | 0.055349 |
The network drawn with plot_graph, keeping only flows of 10 or more.
# Filter for flows >= 10
top_edges = od_edges[od_edges[weight_col] >= 10]
c2g.plot_graph(
nodes=zones_london.centroid,
edges=top_edges,
)
<Axes: >
Setting the edge alpha by the percentile rank of the weight emphasises the strongest flows.
# Compute percentile ranks for weights
percentile_ranks = top_edges[weight_col].rank(pct=True)
c2g.plot_graph(
nodes=zones_london.centroid,
edges=top_edges,
edge_alpha=percentile_ranks,
)
<Axes: >
Finally, degree centrality is mapped as a choropleth beside the flow network.
# Create subplots: left for network, right for degree centrality
fig, axs = plt.subplots(1, 2, figsize=(24, 12))
axs[0].set_title("England & Wales MSOA Migration Network: Flows ≥10\n(Alpha by Percentile Rank)", fontsize=22, color='white')
c2g.plot_graph(
nodes=zones_london.centroid,
edges=top_edges,
ax=axs[0],
edge_alpha=percentile_ranks,
)
# Right subplot: Degree centrality
axs[1].set_title("England & Wales MSOA Migration Network: Degree Centrality\n(Migration Flows 2021 - Quantile Classification)", fontsize=22, color='white')
od_nodes.plot(
column='degree_centrality',
ax=axs[1],
cmap='viridis',
edgecolor='white',
linewidth=0.1,
legend=True,
scheme='quantiles',
k=4,
legend_kwds={'title': 'Degree Centrality (Quantiles)', 'loc': 'upper left', 'bbox_to_anchor': (1, 1), 'fontsize': 14}
)
axs[1].set_axis_off()
axs[1].set_facecolor('#f8f9fa')
plt.tight_layout()
plt.show()