When a map needs edges: getting started with City2Graph

There is a point in many spatial projects where the map stops being enough.
Suppose I have a layer of cafes in Liverpool (Why Liverpool? ...we will come to that). The map tells me where they are. I can colour them by rating, count them per neighbourhood or measure the distance between them. But the question I really want to ask might be: which cafes compete for people walking along the same streets?
That question is about relationships. A cafe is related to a street, streets are connected to other streets, and another cafe may be reached through that network. The coordinates are still important, but they are no longer the whole model.
This is the gap City2Graph pushes in. It turns ordinary geospatial data into graphs that can be inspected with GeoPandas, analysed with NetworkX or rustworkx, and converted to PyTorch Geometric when a graph neural network enters the picture.
I will use Liverpool throughout this two-part series, because it has a nice dataset available. Part I starts with street data, builds two rather different graphs from it and finishes with points of interest. Part II adds buses, movement between zones and some machine learning.

What counts as a graph?
Before touching the code, there is a fundamental question to answer: what should an edge mean?
Take two buildings, I could connect them because they share a boundary, because their front doors face the same street, or because one is among the five closest buildings to the other. All three graphs are valid, but they answer different questions.
City2Graph contains graph builders for several common urban interpretations:
Data | A possible node | A possible edge |
Street segments | An intersection | A street connecting two intersections |
Urban morphology | A plot or street segment | Adjacency, connectivity or frontage |
Points of interest | An amenity | Proximity to another amenity |
Public transport | A stop | A scheduled connection to the next stop |
Origin-destination data | A geographic zone | A flow of people or trips |
This sounds like a small distinction, but it prevents a surprisingly common mistake: treating graph construction as a neutral file conversion. The graph is already part of the analysis.
A Liverpool dataset that can grow with the project
I chose Liverpool because the City2Graph authors use it in the library’s morphology example and in a separate research case study. More importantly, the pieces fit together:
Overture Maps supplies buildings, road segments, connectors, places and land use.
The UK Department for Transport supplies GTFS bus timetables.
The Office for National Statistics supplies output-area boundaries and centroids.
The case-study authors archived their complete data bundle on Zenodo, including processed data and outputs. That is the sensible option if exact reproducibility matters. Live Overture data are convenient for experimenting, but a future release may not contain exactly the same features.
The full Liverpool download is larger than we need. We will fetch it once and then work inside a small circle around the city centre.
Setting up
The core package does not require PyTorch, so we stick to installing City2Graph, GeoPandas, and Matplotlib.
These posts target City2Graph 1.0.0. The project is moving quickly, so I recommend pinning that version if you want the code and article to age together.
This is one of the pleasant parts of City2Graph. We do not need to find four separate downloads or manually crop global Overture files. It all comes from the same source. The setup is rather simple, we define our area of Liverpool. Then we go through all the features we want, in this case buildings, segments, connectors, and places. Define our output file, check whether it exists and then download the layer. We also save it to a file for later use, if you try you will see that the download does take quite a bit, so better cache it.
Now we load the layers and put them in British National Grid. EPSG:27700 uses metres, which saves us from accidentally asking for a 700-degree buffer later.
The extra 250 metres around the street layers reduce ugly edge effects. A road just outside the visible study area can still explain how two roads inside it connect.
First attempt: streets as a conventional network
Overture’s transportation layer contains more than the roads we want. We will keep road segments, let City2Graph deal with connectors and grade separation, and then create a conventional primal graph. In a primal street graph, intersections are nodes and street pieces are edges.
Keeping the result as two GeoDataFrames is useful while we are checking geometry and attributes. When we want network algorithms, we can convert it.
The conversion back to GeoDataFrames is not cosmetic. NetworkX knows the degree of every node, but GeoPandas gives us the map. City2Graph carries the spatial information through the trip. The output should look like something along the following

A simple graph with the intersection coloured by their degree. Note the outliers in the image. They mess up the whole scale, and are due to the way we filtered the data. Any road segment that touches an intersection in our area is added, thus these long roads go out of the area. By using cropped versions we can make this a lot nicer and at a much less distorted scale

A second graph made from the same streets
The primal graph is a good model for routing. It is less helpful when the question concerns urban form. A long street and a short alley are edges in that model, not things that can receive their own rich set of relationships.
City2Graph’s morphological graph uses two node types:
movement nodes represent street segments.
place nodes represent tessellated urban space associated with buildings.
It then records three relations. Places touch other places, movement segments connect to other movement segments, and places face movement segments. The morphology API calls these touched_to, connected_to and faced_to.
The variable name place_nodes is slightly misleading because the dictionary also contains movement nodes. But it makes the next inspection easy to read:
The keys are part of the model. ("place", "faced_to", "movement") tells more than a pair of integer IDs ever could.
For a quick visual check:
The graph that comes out looks as follows

This graph is the reason City2Graph is especially interesting. Many spatial packages can produce a road network. Fewer can keep plots and movement space as separate types while saying exactly how the two are related. This one has as nodes both places as well as roads and shows the three types of connectivity.
“Nearby” needs a definition
Points of interest give us a smaller example of the same modelling issue. I will select places that have a point geometry and compare two proximity graphs.
The output is something along these lines
KNN edges: 630
Radius edges: 2610The KNN graph guarantees neighbours even for an isolated point. The radius graph does not. Conversely, a dense cluster can contain many radius-based edges while KNN still chooses four neighbours per point.
Neither graph should be considered "the truth" here. It depends very much on the usage. If we are looking for local competitors, 175 metres might be a defensible definition. If we need every point connected to a fixed number of candidates for a later algorithm, KNN may be more convenient. City2Graph also offers Delaunay, Gilbert, Waxman, contiguity and network-distance constructions in its proximity module.
What did we gain?
We began with four GIS layers. We now have:
a routable street network;
a heterogeneous description of places, movement and frontage;
two competing definitions of POI proximity;
a route between GeoPandas and NetworkX that preserves geometry.
The useful bit is not that City2Graph draws lines between coordinates. The useful bit is that the lines have explicit meanings, and those meanings survive when the graph moves to another library.
In Part II, we will add a GTFS bus feed and flows between statistical zones. Then we will connect amenities through the street network with a metapath and convert the result to PyTorch Geometric. That is where the various pieces start behaving like one urban model rather than a folder full of shapefiles.



Comments