When the city starts moving: transit, flows and GNNs with City2Graph

Part I ended with three graphs of Liverpool. One represented intersections and streets, another represented places and movement space, and the third connected points of interest by proximity. The last one we did not plot.
They were all static. Nobody caught a bus, moved house or walked from one cafe to another.
This second part adds movement. We will turn a GTFS timetable into a stop-to-stop network, convert flows between zones into weighted edges, and connect amenities through the street network. The final graph will go into PyTorch Geometric and then come back out as geographic data.
The examples target City2Graph 1.0.0 and continue to use British National Grid, EPSG:27700. We are assuming the Overture layers and street graph from Part I are all available.

Use an archived feed when dates matter
GTFS (General Transit Feed Specification) is a collection of linked text tables. Stops have coordinates, trips belong to routes, stop times order the stops within each trip, and calendars decide whether the trip runs on a given day. It is tidy data, but it is not yet a simple network.
The Liverpool City2Graph case-study archive includes the GTFS input used by the authors. This is preferable to a live GTFS feed, as those might expire and make the whole example useless.
After unpacking the archive, point the code at the raw GTFS directory.
The function load_gtfs returns an in-memory DuckDB connection. That choice makes sense for a large feed: This allows inspecting or filtering tables with SQL without first pulling millions of stop-time records into pandas.
The geographic filter is deliberately broad. Its purpose is inspection, not the graph construction itself.
From timetable to weighted network
City2Graph’s travel_summary_graph reads the trip sequences and service calendar, then aggregates consecutive stop pairs. The edges contain average travel time and service frequency. That is much easier to analyse than ten linked GTFS tables.
Pick a date covered by the archived feed, here given by SERVICE_DAY.
If this returns an empty graph, the first thing to check is the date, not the plotting code.
The graph can now answer ordinary network questions. Which stops act as bridges between different parts of the service network? Betweenness centrality is one possible measure, although it can be expensive on a large graph.
After importing NetworkX, we construct the graph here, then pick the largest component. We approximate the measure and then return the top results. The betweenness centrality in this context can be thought of measuring which stops lie on the most shortest travel-time routes between other pairs of stops. So its a good guess to think of these as transfer hubs.
For exact calculations or heavier algorithms, City2Graph can convert NetworkX graphs to rustworkx.
A map can encode two edge attributes at once:
The line colour represents travel time and the width represents service frequency. Double encodings are compact, but they are not telepathic. To get the following graph there is some extra clean up necessary, but this would bloat this post a bit too much.

The red markers are the top stops.
Movement between areas
A transit graph records possible scheduled movement. Origin-destination data record observed or estimated flows between zones. Commuting, migration, bike-share trips and pedestrian counts all fit this pattern.
For a compact, reproducible example, we create a small flow table from six Liverpool output areas. The numbers are synthetic. They exist to demonstrate the conversion and should not be interpreted as observed travel.
The conversion keeps both weights and creates line geometry between the zones:
The edge with 18 journeys disappears because the threshold is 20. Direction is preserved, so a morning flow from A to B is different from one from B to A.
For real data, City2Graph’s OD tutorial uses 2021 Census migration between Middle Layer Super Output Areas in England and Wales. That graph is large enough to deserve its own analysis, so the six-zone example is a better way to learn the mechanics.
A relation that does not exist in any source file
The Overture places layer knows where amenities are. The street layer knows how roads connect. Neither source contains an edge saying that two amenities are reachable through nearby streets. But, we can derive that relation with a metapath.
First, use the primal street nodes and edges created in Part I. Select a manageable set of point amenities and attach each one to its nearest intersection.
So we use as nodes both the old junctions and then add our sample of amenities. Then, the new is_nearby relation links amenities to junctions. A metapath composes those typed edges into a relation between two amenities.
The resulting amenity-to-amenity edges do not mean “close in a straight line.” They mean “connected by this precise sequence of relations.” That distinction is useful when a river, railway or badly connected street pattern makes Euclidean distance optimistic.
There is also a weighted version, add_metapaths_by_weight, which uses a cost threshold rather than a fixed number of hops. With suitable edge weights, it can express “reachable within ten minutes” instead of “reachable within three streets.”
Into PyTorch Geometric
The heterogeneous graph already has the structure expected by many graph neural networks: named node types and relation types. City2Graph can convert it to PyTorch Geometric’s HeteroData without discarding the geographic information needed later.
For this one needs to install the CPU dependencies if they are not present.
For a first conversion, we choose a deliberately small feature set. Throwing every Overture column at a model would create a mixture of nested objects, strings, missing values and numbers with no analytical plan.
Coordinates are included here to make the example concrete, not because raw projected coordinates are always good predictive features. For many models we would have to centre and scale them, replace them with local spatial descriptors, or omit them to avoid learning location shortcuts. In this case distances and degrees are vastly different.
The round trip is the part that is important, we can return to the original graph:
restored_nodes, restored_edges = c2g.pyg_to_gdf(pyg_city)
assert set(restored_nodes) == set(model_nodes)
print(restored_nodes["amenity"].crs)
print(restored_edges.keys())
A model can produce an embedding or class for every amenity, but the result becomes much easier to inspect once it is attached to geometry again. Predictions can return to a map rather than remaining row numbers in a tensor.
Where City2Graph fits
City2Graph does not replace every spatial network package. If we only wanted to download and analyse an OpenStreetMap road network, OSMnx would probably be the shorter route. The City2Graph documentation describes the two libraries as complementary.
The library becomes interesting when the project crosses boundaries:
GIS layers need to become an analysis graph without losing geometry.
Several kinds of urban objects must coexist without being flattened into one node type.
Transit schedules or OD tables need domain-specific preprocessing.
Work begins in GeoPandas, moves through NetworkX or rustworkx, and ends in PyTorch Geometric.
That last point is easy to underestimate. Conversion code tends to grow quietly around a project. First there is an index lookup, then a geometry side table, then a second lookup for labels, and eventually nobody is quite sure whether tensor row 418 still refers to the same bus stop. A library that maintains those mappings is doing useful work even when the graph algorithm itself comes from somewhere else.
One city, several answers
Across the two posts, the same part of Liverpool became several graphs. Intersections connected through streets. Plots touched and faced movement space. Bus stops connected according to a timetable. Statistical areas exchanged journeys. Amenities became related through a short path over street junctions.
Those graphs are not competing descriptions. Each preserves the relationship needed for a particular question.
That is what is really useful in City2Graph. It does not pretend that a city has one natural graph waiting to be discovered. It gives us a practical way to state what the nodes and edges mean, test that choice with familiar Python tools, and carry the result into a graph model when we need one.



Comments