Generating Various Types of Graphs by Proximity#
This notebook illustrates how to generate and visualize different spatial graph types based on proximity metrics (KNN, Delaunay, Gilbert, Waxman) using City2Graph, OSMnx, and NetworkX.
Overview#
This notebook covers:
Setting up the environment and importing libraries
Retrieving points of interest around a location
Defining helper functions for node extraction and plotting
Generating and visualizing KNN, Delaunay, Gilbert, and Waxman graphs interactively
1. Setup and Environment#
[1]:
import warnings
import geopandas as gpd
import contextily as ctx
import matplotlib.pyplot as plt
import networkx as nx
import osmnx as ox
from IPython.display import HTML
from matplotlib import animation
import city2graph
warnings.filterwarnings("ignore")
2. Retrieve Points of Interest#
Fetch restaurant POIs around Shibuya, Tokyo using OSMnx and filter to nodes only. Also, streets network is obtained for calculating network distances.
[2]:
poi_tags = {
"amenity": [
"restaurant"]}
#poi_gdf = ox.features_from_place("Shibuya, Tokyo, Japan", poi_tags)
poi_gdf = ox.features_from_point(
(35.658514, 139.70133), # Tokyo Tower coordinates
tags=poi_tags,
dist=1000, # Search radius in meters
)
# Filter to include only nodes, not ways and relations
poi_gdf = poi_gdf[poi_gdf.index.get_level_values("element") == "node"]
# Reproject to a projected CRS for accurate centroids
poi_gdf = poi_gdf.to_crs(epsg=6677)
[3]:
segments_G = ox.graph_from_point(
(35.658514, 139.70133), # Tokyo Tower coordinates
dist=1000, # Search radius in meters
)
segments_gdf = ox.graph_to_gdfs(segments_G, nodes=False, edges=True)
[4]:
def get_node_positions(gdf):
"""Extract node positions from GeoDataFrame."""
node_positions = {}
for id, geom in gdf["geometry"].items():
if geom.geom_type == "Point":
node_positions[id] = (geom.x, geom.y)
else:
centroid = geom.centroid
node_positions[id] = (centroid.x, centroid.y)
return node_positions
def plot_graph(graph,
title,
node_positions,
add_basemap=False,
crs=None):
"""Plot a network graph with color-coded nodes based on degree centrality."""
# Compute degree centrality for node coloring
node_degrees = dict(graph.degree())
node_colors = [node_degrees.get(node, 0) for node in graph.nodes()]
# Create the plot
fig, ax = plt.subplots(figsize=(12, 10))
# Set equal aspect ratio to maintain map proportions
ax.set_aspect("equal")
# Plot the edges with better color
nx.draw_networkx_edges(graph, pos=node_positions,
edge_color="grey",
alpha=0.5,
width=0.5,
ax=ax)
# Plot the POIs with beautiful color scheme
nodes = nx.draw_networkx_nodes(graph, pos=node_positions,
node_size=30,
node_color=node_colors,
cmap=plt.cm.plasma,
alpha=0.9,
linewidths=0.5,
ax=ax)
# Add basemap if requested - with no buffer/margin
if add_basemap and crs:
ctx.add_basemap(ax, crs=crs, source=ctx.providers.CartoDB.Positron)
# Set exact limits based on node positions to avoid any buffer
node_xs = [pos[0] for pos in node_positions.values()]
node_ys = [pos[1] for pos in node_positions.values()]
ax.set_xlim(min(node_xs), max(node_xs))
ax.set_ylim(min(node_ys), max(node_ys))
# Add a colorbar with better styling
cbar = plt.colorbar(nodes, ax=ax, label="Degree Centrality", shrink=0.8)
cbar.ax.tick_params(labelsize=10)
plt.title(title, fontsize=14, fontweight="bold", pad=20)
plt.axis("off")
plt.tight_layout()
plt.show()
node_positions = get_node_positions(poi_gdf)
3. K-Nearest Neighbors (KNN) Graph#
Create an interactive slider to plot KNN graphs for varying \(k\) values. You can specify the distance metric from "manhattan"
, "euclidean"
, and "network"
. If you use network distance, you need to set the GeoDataFrame of network. You can save the output as GeoDataFrame or nx.Graph.
[5]:
knn_l1_nodes, knn_l1_edges = city2graph.knn_graph(poi_gdf,
distance_metric="manhattan",
network_gdf=segments_gdf.to_crs(epsg=6677))
knn_l2_nodes, knn_l2_edges = city2graph.knn_graph(poi_gdf,
distance_metric="euclidean",
network_gdf=segments_gdf.to_crs(epsg=6677))
knn_net_nodes, knn_net_edges = city2graph.knn_graph(poi_gdf,
k=10,
distance_metric="network",
network_gdf=segments_gdf.to_crs(epsg=6677))
[6]:
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# Plot Manhattan distance KNN graph
knn_l1_edges.plot(ax=axes[0], color='red', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[0], color='darkred', markersize=20, alpha=0.8)
ctx.add_basemap(axes[0], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[0].set_title('KNN Graph - Manhattan Distance', fontsize=12, fontweight='bold')
axes[0].set_aspect('equal')
axes[0].axis('off')
# Plot Euclidean distance KNN graph
knn_l2_edges.plot(ax=axes[1], color='blue', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[1], color='darkblue', markersize=20, alpha=0.8)
ctx.add_basemap(axes[1], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[1].set_title('KNN Graph - Euclidean Distance', fontsize=12, fontweight='bold')
axes[1].set_aspect('equal')
axes[1].axis('off')
# Plot Network distance KNN graph
knn_net_edges.plot(ax=axes[2], color='green', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[2], color='darkgreen', markersize=20, alpha=0.8)
ctx.add_basemap(axes[2], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[2].set_title('KNN Graph - Network Distance', fontsize=12, fontweight='bold')
axes[2].set_aspect('equal')
axes[2].axis('off')
plt.tight_layout()
plt.show()

You can obtain the output as nx.Graph
object.
[7]:
knn_l2_G = city2graph.knn_graph(poi_gdf,
distance_metric="euclidean",
as_nx=True)
plot_graph(knn_l2_G,
title="KNN Graph - Euclidean Distance",
node_positions=node_positions,
add_basemap=True,
crs=poi_gdf.crs)

[8]:
HTML("""
<video controls style="width: 100%; max-width: 800px; height: auto;">
<source src="../_static/knn_graph.mp4" type="video/mp4">
</video>
""")
[8]:
5. Delaunay Graph#
Generate and plot a Delaunay triangulation graph of the POIs.
[9]:
del_l1_nodes, del_l1_edges = city2graph.delaunay_graph(poi_gdf,
distance_metric="manhattan")
del_l2_nodes, del_l2_edges = city2graph.delaunay_graph(poi_gdf,
distance_metric="euclidean")
del_net_nodes, del_net_edges = city2graph.delaunay_graph(poi_gdf,
distance_metric="network",
network_gdf=segments_gdf.to_crs(epsg=6677))
delaunay_graph supports only 'euclidean' distance for edge identification algorithm; 'manhattan' will be used only for generating edge geometries.
delaunay_graph supports only 'euclidean' distance for edge identification algorithm; 'network' will be used only for generating edge geometries.
[10]:
plt.ion()
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# Plot Manhattan distance Delaunay graph
del_l1_edges.plot(ax=axes[0], color='red', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[0], color='darkred', markersize=20, alpha=0.8)
ctx.add_basemap(axes[0], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[0].set_title('Delaunay Graph - Manhattan Distance', fontsize=12, fontweight='bold')
axes[0].set_aspect('equal')
axes[0].axis('off')
# Plot Euclidean distance Delaunay graph
del_l2_edges.plot(ax=axes[1], color='blue', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[1], color='darkblue', markersize=20, alpha=0.8)
ctx.add_basemap(axes[1], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[1].set_title('Delaunay Graph - Euclidean Distance', fontsize=12, fontweight='bold')
axes[1].set_aspect('equal')
axes[1].axis('off')
# Plot Network distance Delaunay graph
del_net_edges.plot(ax=axes[2], color='green', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[2], color='darkgreen', markersize=20, alpha=0.8)
ctx.add_basemap(axes[2], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[2].set_title('Delaunay Graph - Network Distance', fontsize=12, fontweight='bold')
axes[2].set_aspect('equal')
axes[2].axis('off')
plt.tight_layout()
plt.show()

[11]:
del_l2_G = city2graph.delaunay_graph(poi_gdf,
distance_metric="euclidean",
as_nx=True)
plot_graph(del_l2_G,
title="Delaunay Graph - Euclidean Distance",
node_positions=node_positions,
add_basemap=True,
crs=poi_gdf.crs)

6. Fixed Threshold Graph (Gilbert Graph)#
Fixed Threshold Graph is a deterministic model to generate edges based on the Euclidean distance (Gilbert Graph is a generalised concept assuming points are randomly assigned). Given a parameter \(r\) as radious, neighbours are connected if they are within the radious from a node.
[12]:
fix_l1_nodes, fix_l1_edges = city2graph.fixed_radius_graph(poi_gdf,
distance_metric="manhattan",
radius=100)
fix_l2_nodes, fix_l2_edges = city2graph.fixed_radius_graph(poi_gdf,
distance_metric="euclidean",
radius=100)
fix_net_nodes, fix_net_edges = city2graph.fixed_radius_graph(poi_gdf,
distance_metric="network",
radius=100,
network_gdf=segments_gdf.to_crs(epsg=6677))
[13]:
plt.ion()
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# Plot Manhattan distance Gilbert graph
fix_l1_edges.plot(ax=axes[0], color='red', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[0], color='darkred', markersize=20, alpha=0.8)
ctx.add_basemap(axes[0], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[0].set_title('Fixed Radius Graph - Manhattan Distance', fontsize=12, fontweight='bold')
axes[0].set_aspect('equal')
axes[0].axis('off')
# Plot Euclidean distance Gilbert graph
fix_l2_edges.plot(ax=axes[1], color='blue', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[1], color='darkblue', markersize=20, alpha=0.8)
ctx.add_basemap(axes[1], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[1].set_title('Fixed Radius Graph - Euclidean Distance', fontsize=12, fontweight='bold')
axes[1].set_aspect('equal')
axes[1].axis('off')
# Plot Network distance Gilbert graph
fix_net_edges.plot(ax=axes[2], color='green', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[2], color='darkgreen', markersize=20, alpha=0.8)
ctx.add_basemap(axes[2], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[2].set_title('Fixed Radius Graph - Network Distance', fontsize=12, fontweight='bold')
axes[2].set_aspect('equal')
axes[2].axis('off')
plt.tight_layout()
plt.show()

[14]:
gil_l2_G = city2graph.fixed_radius_graph(poi_gdf,
distance_metric="euclidean",
radius=100,
as_nx=True)
plot_graph(gil_l2_G,
title="Fixed Radius Graph - Euclidean Distance",
node_positions=node_positions,
add_basemap=True,
crs=poi_gdf.crs)

[15]:
HTML("""
<video controls style="width: 100%; max-width: 800px; height: auto;">
<source src="../_static/gilbert_graph.mp4" type="video/mp4">
</video>
""")
[15]:
7. Waxman Graph (Soft Random Geometry Model)#
Waxman graph with adjustable \(r_0\) (r_0
) and \(\beta\) (beta
) as parameters. The probability of connection follows below:
where \(d_{ij}\) is the Euclidean distance between node \(i\) an \(j\); \(r_0\) is the maximum distance between nodes; and \(\beta\) denotes the scaling parameter.
[16]:
wax_l1_nodes, wax_l1_edges = city2graph.waxman_graph(poi_gdf,
distance_metric="manhattan",
r0=100,
beta=0.5)
wax_l2_nodes, wax_l2_edges = city2graph.waxman_graph(poi_gdf,
distance_metric="euclidean",
r0=100,
beta=0.5)
wax_net_nodes, wax_net_edges = city2graph.waxman_graph(poi_gdf,
distance_metric="network",
r0=100,
beta=0.5,
network_gdf=segments_gdf.to_crs(epsg=6677))
[17]:
plt.ion()
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# Plot Manhattan distance Waxman graph
wax_l1_edges.plot(ax=axes[0], color='red', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[0], color='darkred', markersize=20, alpha=0.8)
ctx.add_basemap(axes[0], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[0].set_title('Waxman Graph - Manhattan Distance', fontsize=12, fontweight='bold')
axes[0].set_aspect('equal')
axes[0].axis('off')
# Plot Euclidean distance Waxman graph
wax_l2_edges.plot(ax=axes[1], color='blue', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[1], color='darkblue', markersize=20, alpha=0.8)
ctx.add_basemap(axes[1], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[1].set_title('Waxman Graph - Euclidean Distance', fontsize=12, fontweight='bold')
axes[1].set_aspect('equal')
axes[1].axis('off')
# Plot Network distance Waxman graph
wax_net_edges.plot(ax=axes[2], color='green', alpha=0.6, linewidth=0.8)
poi_gdf.plot(ax=axes[2], color='darkgreen', markersize=20, alpha=0.8)
ctx.add_basemap(axes[2], crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
axes[2].set_title('Waxman Graph - Network Distance', fontsize=12, fontweight='bold')
axes[2].set_aspect('equal')
axes[2].axis('off')
plt.tight_layout()
plt.show()

[18]:
wax_l2_G = city2graph.waxman_graph(poi_gdf,
distance_metric="euclidean",
r0=100,
beta=0.5,
as_nx=True)
plot_graph(wax_l2_G,
title="Waxman Graph - Euclidean Distance",
node_positions=node_positions,
add_basemap=True,
crs=poi_gdf.crs)

[19]:
HTML("""
<video controls style="width: 100%; max-width: 800px; height: auto;">
<source src="../_static/waxman_graph.mp4" type="video/mp4">
</video>
""")
[19]:
Other Available Proximity Graph Types#
The City2Graph library provides several additional proximity-based graph generation methods beyond the ones demonstrated above:
Euclidean Minimum Spanning Tree (EMST)#
Creates a tree that connects all nodes with the minimum total edge weight using Euclidean distances. This ensures all nodes are connected with the fewest possible edges while minimizing total distance.
Gabriel Graph#
Two nodes are connected if no other node lies within the circle having these two nodes as diameter endpoints. This creates a sparser graph than Delaunay triangulation while maintaining good connectivity properties.
Relative Neighborhood Graph (RNG)#
Two nodes are connected if no other node is closer to both nodes than they are to each other. This creates an even sparser graph than Gabriel graph, often used in computational geometry and network analysis.
Key Characteristics#
EMST: Guarantees connectivity with minimum total cost; useful for infrastructure planning
Gabriel Graph: Good balance between sparsity and connectivity; useful for wireless networks
RNG: Sparsest among proximity graphs; useful for identifying strongest local relationships
Fixed Radius: Already demonstrated; creates connections within a specified distance threshold
Comparison of Graph Sparsity#
In terms of edge density (from most to least dense):
Delaunay - Most edges, captures all proximity relationships
Gabriel - Subset of Delaunay, removes longer edges in dense areas
RNG - Subset of Gabriel, keeps only the strongest local connections
EMST - Sparsest connected graph, exactly n-1 edges for n nodes
These different graph types are particularly useful for modeling different types of spatial relationships and constraints in urban networks.
8. Bridge Nodes (Multi-layer Networks)#
The bridge_nodes
function creates directed proximity edges between different layers of nodes, enabling multi-layer network analysis. This is particularly useful for modeling complex urban systems where different types of entities (e.g., schools, hospitals, parks) interact with each other.
The function generates directed edges from every node in one layer to their nearest neighbors in another layer, using either KNN or fixed-radius methods.
In this example, three types of nodes (restaurants, hospitals, and commercials) are extracted from OpenStreetMap in Shibuya, Tokyo.
[20]:
# Create different layers of POIs for multi-layer analysis
# Let's create hospitals and commercial POIs in addition to restaurants
# Hospitals layer
hospital_tags = {
"amenity": [
"hospital"]}
hospital_gdf = ox.features_from_point(
(35.658514, 139.70133), # Tokyo Tower coordinates
tags=hospital_tags,
dist=1000, # Search radius in meters
)
# Filter to include only nodes, not ways and relations
hospital_gdf = hospital_gdf[hospital_gdf.index.get_level_values("element") == "node"]
hospital_gdf = hospital_gdf.to_crs(epsg=6677)
# Commercial layer (shops)
commercial_tags = {
"shop": True}
commercial_gdf = ox.features_from_point(
(35.658514, 139.70133), # Tokyo Tower coordinates
tags=commercial_tags,
dist=1000, # Search radius in meters
)
# Filter to include only nodes and take a subset to avoid too many points
commercial_gdf = commercial_gdf[commercial_gdf.index.get_level_values("element") == "node"]
commercial_gdf = commercial_gdf.to_crs(epsg=6677)
# Take a subset for better visualization
commercial_gdf = commercial_gdf.sample(min(30, len(commercial_gdf)), random_state=42)
print(f"Restaurants: {len(poi_gdf)} nodes")
print(f"Hospitals: {len(hospital_gdf)} nodes")
print(f"Commercial: {len(commercial_gdf)} nodes")
Restaurants: 557 nodes
Hospitals: 5 nodes
Commercial: 30 nodes
A dictionary of nodes with their label names are made as nodes_dict
. It is then passed to bridge_nodes
to generate edges by proximity.
[21]:
# Create a nodes dictionary for multi-layer network
nodes_dict = {
"restaurants": poi_gdf,
"hospitals": hospital_gdf,
"commercial": commercial_gdf
}
# Generate proximity edges between layers using KNN method
proximity_nodes, proximity_edges = city2graph.bridge_nodes(
nodes_dict,
proximity_method="knn",
k=5, # Connect to 5 nearest neighbors in each target layer
distance_metric="euclidean"
)
print("Generated edge types:")
for edge_key in proximity_edges.keys():
print(f" {edge_key[0]} → {edge_key[2]}: {len(proximity_edges[edge_key])} edges")
Generated edge types:
restaurants → hospitals: 2785 edges
restaurants → commercial: 2785 edges
hospitals → restaurants: 25 edges
hospitals → commercial: 25 edges
commercial → restaurants: 150 edges
commercial → hospitals: 150 edges
As shown in the plot, there are six types of proximity in the result.
[22]:
# Visualize the multi-layer network connections
fig, axes = plt.subplots(2, 3, figsize=(18, 12))
axes = axes.flatten()
# Define colors for each layer
layer_colors = {
"restaurants": "red",
"hospitals": "blue",
"commercial": "green"
}
# Define edge colors for visualization
edge_colors = {
("restaurants", "hospitals"): "blue",
("restaurants", "commercial"): "orange",
("hospitals", "restaurants"): "cyan",
("hospitals", "commercial"): "magenta",
("commercial", "restaurants"): "black",
("commercial", "hospitals"): "brown"
}
# Plot each type of connection
plot_idx = 0
for i, (layer1, layer2) in enumerate([
("restaurants", "hospitals"),
("restaurants", "commercial"),
("hospitals", "restaurants"),
("hospitals", "commercial"),
("commercial", "restaurants"),
("commercial", "hospitals")
]):
if plot_idx >= 6:
break
ax = axes[plot_idx]
# Get the edge key for this connection
edge_key = (layer1, "is_nearby", layer2)
if edge_key in proximity_edges:
# Plot edges
proximity_edges[edge_key].plot(
ax=ax,
color=edge_colors.get((layer1, layer2), "gray"),
alpha=0.6,
linewidth=1
)
# Plot source layer nodes
nodes_dict[layer1].plot(
ax=ax,
color=layer_colors[layer1],
markersize=30,
alpha=0.8,
label=layer1.capitalize()
)
# Plot target layer nodes
nodes_dict[layer2].plot(
ax=ax,
color=layer_colors[layer2],
markersize=20,
alpha=0.6,
label=layer2.capitalize()
)
# Add basemap
ctx.add_basemap(ax, crs=poi_gdf.crs, source=ctx.providers.CartoDB.Positron)
ax.set_title(f'{layer1.capitalize()} → {layer2.capitalize()}', fontsize=11, fontweight='bold')
ax.legend(loc='upper right', fontsize=8)
ax.set_aspect('equal')
ax.axis('off')
plot_idx += 1
plt.tight_layout()
plt.show()

[23]:
# Compare with fixed-radius method
radius_nodes, radius_edges = city2graph.bridge_nodes(
nodes_dict,
proximity_method="fixed_radius",
radius=200, # 200 meter radius
distance_metric="euclidean"
)
print("\nFixed-radius method (200m radius):")
print("Generated edge types:")
total_knn_edges = sum(len(gdf) for gdf in proximity_edges.values())
total_radius_edges = sum(len(gdf) for gdf in radius_edges.values())
for edge_key in proximity_edges.keys():
knn_count = len(proximity_edges[edge_key])
radius_count = len(radius_edges[edge_key]) if edge_key in radius_edges else 0
print(f" {edge_key[0]} → {edge_key[2]}: KNN(k=2)={knn_count}, Fixed-radius(200m)={radius_count}")
print(f"\nTotal edges: KNN={total_knn_edges}, Fixed-radius={total_radius_edges}")
Fixed-radius method (200m radius):
Generated edge types:
restaurants → hospitals: KNN(k=2)=2785, Fixed-radius(200m)=143
restaurants → commercial: KNN(k=2)=2785, Fixed-radius(200m)=1263
hospitals → restaurants: KNN(k=2)=25, Fixed-radius(200m)=143
hospitals → commercial: KNN(k=2)=25, Fixed-radius(200m)=12
commercial → restaurants: KNN(k=2)=150, Fixed-radius(200m)=1263
commercial → hospitals: KNN(k=2)=150, Fixed-radius(200m)=12
Total edges: KNN=5920, Fixed-radius=2836
You can stack multiple layers of networks by bridge_nodes
to construct a heterogenous graph. In another example below, streets network and bus transportation network will be stacked. For the behaviour of load_gtfs
and travel_summary_graph
, see transportation.py for detailed documentation.
In this case, OpenStreetMap and GTFS from the Greater London are used as samples.
[24]:
# Load GTFS data for travel summary graph generation
sample_gtfs_path = "./itm_london_gtfs.zip"
gtfs_data = city2graph.load_gtfs(sample_gtfs_path)
# Generate travel summary graph for a specific date
travel_summary_nodes, travel_summary_edges = city2graph.travel_summary_graph(
gtfs_data, calendar_start="20250601", calendar_end="20250601")
# Get the boundary polygon for London from OSMnx
london_boundary = ox.geocode_to_gdf("Greater London, UK").to_crs(epsg=27700)
# Project travel summary data to the same CRS as the bounding box
travel_summary_nodes = travel_summary_nodes.to_crs(epsg=27700)
travel_summary_edges = travel_summary_edges.to_crs(epsg=27700)
# Filter nodes and edges that are within the bounding box
nodes_in_bound = gpd.sjoin(travel_summary_nodes, london_boundary, how="inner").drop(columns=['index_right'])
edges_in_bound = gpd.sjoin(travel_summary_edges, london_boundary, how="inner").drop(columns=['index_right'])
# Update the original variables with the filtered data
travel_summary_nodes = nodes_in_bound
travel_summary_edges = edges_in_bound
travel_summary_edges = travel_summary_edges[
travel_summary_edges.index.get_level_values('from_stop_id').isin(travel_summary_nodes.index) &
travel_summary_edges.index.get_level_values('to_stop_id').isin(travel_summary_nodes.index)
]
print(f"Nodes within boundary: {len(travel_summary_nodes)}")
print(f"Edges within boundary: {len(travel_summary_edges)}")
# Download London's street network as a GeoDataFrame
london_graph = ox.graph_from_place("Greater London, UK", network_type="drive")
street_nodes, street_edges = ox.graph_to_gdfs(london_graph, nodes=True, edges=True)
# Add these lines to project the nodes as well
street_nodes.to_crs(epsg=27700, inplace=True)
travel_summary_nodes.to_crs(epsg=27700, inplace=True)
street_edges.to_crs(epsg=27700, inplace=True)
travel_summary_edges.to_crs(epsg=27700, inplace=True)
street_edges['mean_travel_time'] = street_edges['length'] / (4 * 1000 / 3600)
Nodes within boundary: 20220
Edges within boundary: 25182
Now, bridge_nodes
is applied to the two types of nodes.
[25]:
proximity_nodes, proximity_edges = city2graph.bridge_nodes({"street": street_nodes, "bus": travel_summary_nodes})
The two types of edges ('bus', 'is_nearby', 'street')
and ('street', 'is_nearby', 'bus')
are visualized.
[26]:
# Visualize the street-bus network connections with improved styling and more visible edges
fig, axes = plt.subplots(1, 2, figsize=(20, 8))
# Define colors for each layer
layer_colors = {
"street": "#2E86AB", # Blue
"bus": "#F24236" # Red
}
# Define edge colors for visualization - using brighter, more contrasting colors
edge_colors = {
("street", "bus"): "blue",
("bus", "street"): "blue",
}
# Plot street → bus connections
ax = axes[0]
edge_key = ('street', 'is_nearby', 'bus')
if edge_key in proximity_edges:
# Plot edges with much more visible styling
proximity_edges[edge_key].plot(
ax=ax,
color=edge_colors[("street", "bus")],
alpha=0.9, # Increased opacity
linewidth=3, # Thicker lines
linestyle='-'
)
# Plot street nodes (sample for visibility)
street_sample = street_nodes.sample(min(1000, len(street_nodes)), random_state=42)
street_sample.plot(
ax=ax,
color=layer_colors["street"],
markersize=4, # Smaller to not overwhelm
alpha=0.4,
label="Street Nodes",
edgecolors='none'
)
# Plot bus nodes with better visibility
travel_summary_nodes.plot(
ax=ax,
color=layer_colors["bus"],
markersize=40, # Larger bus stops
alpha=1.0, # Full opacity
label="Bus Stops",
edgecolors='white',
linewidth=2
)
ax.set_title('Street → Bus Stop Connections', fontsize=14, fontweight='bold', pad=20)
ax.legend(loc='upper right', fontsize=10, framealpha=0.9)
ax.set_aspect('equal')
ax.axis('off')
# Plot bus → street connections
ax = axes[1]
edge_key = ('bus', 'is_nearby', 'street')
if edge_key in proximity_edges:
# Plot edges with much more visible styling
proximity_edges[edge_key].plot(
ax=ax,
color=edge_colors[("bus", "street")],
alpha=0.9, # Increased opacity
linewidth=3, # Thicker lines
linestyle='-'
)
# Plot bus nodes first (so they're behind edges)
travel_summary_nodes.plot(
ax=ax,
color=layer_colors["bus"],
markersize=40,
alpha=1.0,
label="Bus Stops",
edgecolors='white',
linewidth=2
)
# Plot street nodes (sample for visibility)
street_sample = street_nodes.sample(min(1000, len(street_nodes)), random_state=42)
street_sample.plot(
ax=ax,
color=layer_colors["street"],
markersize=4,
alpha=0.4,
label="Street Nodes",
edgecolors='none'
)
ax.set_title('Bus Stop → Street Connections', fontsize=14, fontweight='bold', pad=20)
ax.legend(loc='upper right', fontsize=10, framealpha=0.9)
ax.set_aspect('equal')
ax.axis('off')
plt.tight_layout()
plt.show()
# Print connection statistics
print(f"\nConnection Statistics:")
print(f"Street → Bus connections: {len(proximity_edges[('street', 'is_nearby', 'bus')])} edges")
print(f"Bus → Street connections: {len(proximity_edges[('bus', 'is_nearby', 'street')])} edges")
print(f"Total street nodes: {len(street_nodes):,}")
print(f"Total bus stops: {len(travel_summary_nodes):,}")

Connection Statistics:
Street → Bus connections: 130014 edges
Bus → Street connections: 20220 edges
Total street nodes: 130,014
Total bus stops: 20,220
In addition to the obtained edges by proximity, street_edges
and travel_summary_edges
are registered as two meta-paths of the heterogenous graph. proximity_nodes
and proximity_edges
are finally used to constructed a HeteroData
for PyTorch Geometric by gdf_to_pyg
.
[27]:
# Add street_edges and travel_summary_edges to proximity_edges
proximity_edges[("street", "connects", "street")] = street_edges
proximity_edges[("bus", "connects", "bus")] = travel_summary_edges
# Convert proximity_nodes and proximity_edges to HeteroData using gdf_to_pyg
hetero_data = city2graph.gdf_to_pyg(proximity_nodes, proximity_edges)
Removed 3 invalid geometries
[28]:
print("HeteroData structure:")
print(hetero_data)
print("\nNode types and their counts:")
for node_type in hetero_data.node_types:
print(f" {node_type}: {hetero_data[node_type].x.shape[0]} nodes")
print("\nEdge types and their counts:")
for edge_type in hetero_data.edge_types:
print(f" {edge_type}: {hetero_data[edge_type].edge_index.shape[1]} edges")
HeteroData structure:
HeteroData(
crs=EPSG:27700,
graph_metadata=<city2graph.utils.GraphMetadata object at 0x17c360290>,
street={
x=[130014, 0],
pos=[130014, 2],
},
bus={
x=[20220, 0],
pos=[20220, 2],
},
(street, is_nearby, bus)={
edge_index=[2, 130014],
edge_attr=[130014, 0],
},
(bus, is_nearby, street)={
edge_index=[2, 20220],
edge_attr=[20220, 0],
},
(street, connects, street)={
edge_index=[2, 302814],
edge_attr=[302814, 0],
},
(bus, connects, bus)={
edge_index=[2, 25179],
edge_attr=[25179, 0],
}
)
Node types and their counts:
street: 130014 nodes
bus: 20220 nodes
Edge types and their counts:
('street', 'is_nearby', 'bus'): 130014 edges
('bus', 'is_nearby', 'street'): 20220 edges
('street', 'connects', 'street'): 302814 edges
('bus', 'connects', 'bus'): 25179 edges
Bridge Nodes - Practical Applications#
The bridge_nodes
function is particularly useful for:
Urban Planning: Analyzing accessibility between different urban facilities (schools to hospitals, parks to commercial areas)
Multi-modal Transportation: Connecting different transportation networks (bus stops to train stations, parking to bike sharing)
Ecological Networks: Modeling species movement between different habitat types
Supply Chain Analysis: Understanding connections between different types of business facilities
The function supports both KNN and fixed-radius methods:
KNN: Ensures each node has exactly k connections to the target layer (good for guaranteed connectivity)
Fixed-radius: Creates connections based on distance threshold (good for realistic proximity modeling)
All connections are directed, meaning you can model asymmetric relationships between different layer types.