5.3. Network Metrics and Centrality#

5.3.1. Learning objectives#

By the end of this section, you should be able to:

  • project a graph before measuring and choose an area rule for densities;

  • define the basic network statistics and say what each one depends on;

  • explain how simplification, intersection cleaning, and the area rule change a statistic;

  • check connectivity before computing centrality;

  • distinguish degree, closeness, and betweenness centrality and their computational cost;

  • use a documented sample when exact betweenness is too expensive; and

  • interpret centrality as a diagnostic and not as observed traffic.

Section goal

Describe the structure of a street network with measures that can be compared and checked. Small synthetic networks with known structure show what each statistic and centrality measure reports, and the same code then runs on the Columbia graph.

5.3.2. Project before measuring#

OSMnx graphs downloaded from OpenStreetMap commonly use geographic coordinates, so node positions and edge geometries are in degrees. A density needs an area in square metres, and a geometric length needs metres, so the graph must be projected before it is measured, as in Chapter 4. ox.project_graph chooses a UTM zone [Snyder, 1987] from the graph itself. It leaves the length attribute, already in metres, unchanged.

Densities also need an area, and the choice of area is a decision, not a detail. Table 5.11 lists the common rules and the bias of each.

Table 5.11 Area rules for network densities and the bias of each.#

Area rule

Strength

Bias

Convex hull of the nodes

Needs no extra data

Includes empty space inside a non-convex network

Bounding box of the nodes

Simplest to compute

Overstates the area of any irregular network

Place boundary

Matches an administrative area

Includes water and unbuilt land, so densities fall

Buffered streets

Follows the built footprint

Depends on the buffer distance

The cell below loads the drivable network of Columbia, Missouri, from S01 so that this section runs on its own. The first download is cached, and later runs read the cache. The projected graph and a convex-hull area follow.

Hide code cell source

import osmnx as ox

ox.settings.use_cache = True

G = ox.graph_from_place("Columbia, Missouri, USA", network_type="drive", simplify=True, retain_all=False)
print(G.number_of_nodes(), G.number_of_edges(), G.graph.get("crs"))
5766 13888 epsg:4326

This download has 5,766 nodes and 13,888 edges, slightly more than the 5,749 and 13,854 of S01. The same place and settings can give a slightly different graph on a later day, most likely because OpenStreetMap is edited continuously, so the retrieval date belongs in the record.

Hide code cell source

import osmnx as ox

G_projected = ox.project_graph(G)
nodes_projected, edges_projected = ox.graph_to_gdfs(G_projected)
network_area = nodes_projected.geometry.union_all().convex_hull.area

The convex hull is only an approximation of the study area. For a city comparison, use the same boundary definition and area rule for every graph, and record the choice [Boeing, 2020].

5.3.3. Basic network statistics#

ox.basic_stats summarizes a projected graph in one call. Table 5.12 defines the main entries and what each one is sensitive to.

Table 5.12 Basic statistics returned by OSMnx and what each depends on.#

Statistic

Definition

Depends on

n, m

Number of nodes and of directed edges

Simplification and network type

k_avg

Average degree, equal to 2m / n

A two-way street adds two edges, so it is about twice the streets per node

streets_per_node_avg

Average number of physical streets meeting at a node

Simplification

edge_length_avg

Mean edge length, in metres

Simplification

intersection_density_km

Intersections per square kilometre

The area rule and intersection cleaning

street_density_km

Street length, in metres, per square kilometre

The area rule

circuity_avg

Total edge length divided by the total straight-line distance between edge ends

Simplification, because bends hidden inside edges raise it

The toy network below is an L-shaped grid of two-way streets 100 m apart, built directly in a projected CRS so that its statistics can be checked by hand.

Hide code cell source

import math

import networkx as nx
import osmnx as ox
from shapely.geometry import box


def finish(G):
    # Add the street_count attribute that downloaded OSMnx graphs already carry.
    nx.set_node_attributes(G, ox.stats.count_streets_per_node(G), "street_count")
    return G


def grid(n=9, spacing=100, x0=0, y0=0, first=0, G=None, skip=lambda i, j: False):
    # An n x n grid of two-way streets in a projected CRS, in metres.
    G = G if G is not None else nx.MultiDiGraph(crs="EPSG:32615")
    ids = {}
    for i in range(n):
        for j in range(n):
            if not skip(i, j):
                ids[(i, j)] = first + i * n + j
                G.add_node(ids[(i, j)], x=x0 + i * spacing, y=y0 + j * spacing)
    for (i, j), a in ids.items():
        for di, dj in ((1, 0), (0, 1)):
            b = ids.get((i + di, j + dj))
            if b is not None:
                for u, v in ((a, b), (b, a)):
                    G.add_edge(u, v, length=float(spacing), highway="residential", oneway=False)
    return G


L_shape = finish(grid(9, skip=lambda i, j: i >= 5 and j >= 5))     # a 4 x 4 corner removed
nodes, edges = ox.graph_to_gdfs(L_shape)

stats = ox.stats.basic_stats(L_shape, area=nodes.geometry.union_all().convex_hull.area)
for key in ["n", "m", "k_avg", "streets_per_node_avg", "edge_length_avg", "circuity_avg"]:
    print(f"{key:22} {stats[key]:.3f}" if isinstance(stats[key], float) else f"{key:22} {stats[key]}")

area_rules = {
    "convex hull of nodes": nodes.geometry.union_all().convex_hull.area,
    "bounding box of nodes": box(*nodes.total_bounds).area,
    "streets buffered 50 m": edges.geometry.union_all().buffer(50).area,
}
print()
for name, area in area_rules.items():
    s = ox.stats.basic_stats(L_shape, area=area)
    print(f"{name:22} {area / 1e6:5.3f} km²   {s['intersection_density_km']:6.1f} intersections/km²   "
          f"{s['street_density_km'] / 1000:5.1f} km of street/km²")
n                      65
m                      224
k_avg                  6.892
streets_per_node_avg   3.446
edge_length_avg        100.000
circuity_avg           1.000

convex hull of nodes   0.560 km²    116.1 intersections/km²    20.0 km of street/km²
bounding box of nodes  0.640 km²    101.6 intersections/km²    17.5 km of street/km²
streets buffered 50 m  0.647 km²    100.4 intersections/km²    17.3 km of street/km²

k_avg is 6.9 although a typical node meets only 3.4 streets, because every two-way street counts as two directed edges, so streets_per_node_avg is the number to quote for intersections. The circuity of 1 is exact here, since every edge is straight. The three area rules give three densities, 116, 102, and 100 intersections per square kilometre, a 16% spread from the choice of area alone, which is why the rule belongs in the record.

Intersection counts have a second subtlety. A crossing mapped as two close junctions is counted twice, and clean_int_tol merges junctions closer than a tolerance, in metres. The toy crossing below is an east-west street met by a north street and a south street 8 m apart.

Hide code cell source

offset = nx.MultiDiGraph(crs="EPSG:32615")
positions = {"W": (-100, 0), "X1": (-4, 0), "X2": (4, 0), "E": (100, 0), "N": (-4, 100), "S": (4, -100)}
for name, (x, y) in positions.items():
    offset.add_node(name, x=1000 + x, y=1000 + y)
for a, b in [("W", "X1"), ("X1", "X2"), ("X2", "E"), ("X1", "N"), ("X2", "S")]:
    length = math.dist((offset.nodes[a]["x"], offset.nodes[a]["y"]), (offset.nodes[b]["x"], offset.nodes[b]["y"]))
    for u, v in ((a, b), (b, a)):
        offset.add_edge(u, v, length=length, highway="residential", oneway=False)
finish(offset)

cleaned = ox.stats.basic_stats(offset, area=1e6, clean_int_tol=15)
print("intersections as mapped:        ", cleaned["intersection_count"])
print("intersections after 15 m merge: ", cleaned["clean_intersection_count"])
intersections as mapped:         2
intersections after 15 m merge:  1

The staggered crossing is one intersection in the street, but two in the data, and the 15 m tolerance restores it. The tolerance is itself a parameter, so the value used belongs in the methods note. On the Columbia graph the same call, with the cleaned density added to the printed keys, is:

Hide code cell source

stats = ox.basic_stats(
    G_projected,
    area=network_area,
    clean_int_tol=15,
)

for key in [
    "n",
    "m",
    "k_avg",
    "edge_length_total",
    "edge_length_avg",
    "intersection_density_km",
    "clean_intersection_density_km",
    "street_density_km",
    "circuity_avg",
]:
    if key in stats:
        print(key, stats[key])
n 5766
m 13888
k_avg 4.817204301075269
edge_length_total 1972706.3536797334
edge_length_avg 142.04394827763056
intersection_density_km 23.165820450761945
clean_intersection_density_km 19.10990303413674
street_density_km 5455.958939404555
circuity_avg 1.0776298025819988

Merging junctions within 15 m lowers the intersection density from 23.2 to 19.1 per km², a drop of 17.5%, which is as large as the whole spread between area rules in the toy network. The mean edge is 142 m, and a circuity of 1.078 means that edges are about 8% longer than the straight line between their end nodes. Every one of these values depends on the convex-hull area and the 15 m tolerance used here.

These are descriptive measures, not universal walkability scores. OSM coverage, boundary choice, simplification, and network type all affect them.

5.3.4. Connectivity and centrality#

Connectivity describes whether movement can pass through the graph. S02 separated weak from strong connectivity, and the cell below counts the weak components of the Columbia graph and the size of the largest. The largest component’s share of all nodes is the number to report, because a graph that is 99% one component is a very different object from one that is split in half:

Hide code cell source

import networkx as nx

if G.number_of_nodes() == 0:
    raise ValueError("The query returned no network nodes")

components = list(nx.weakly_connected_components(G))
print("weak components:", len(components))
print("largest component:", max(map(len, components)))
weak components: 1
largest component: 5766

Centrality identifies structurally important nodes or edges, and each measure asks a different question. Table 5.13 compares the common ones.

Table 5.13 Centrality measures, the question each answers, and its cost.#

Measure

Question it answers

Cost

Caution

Degree centrality

How many direct connections does the node have?

Cheap

Nearly the same as street_count, and blind to position

Closeness centrality

How near is the node, on average, to all others?

One shortest-path search per node

Defined within a component, and inflated near the centre of any study area

Betweenness centrality

What share of shortest paths pass through the node or edge?

Highest: shortest paths between all pairs

Depends on the cost weight, the boundary, and which pairs matter

Betweenness [Brandes, 2001, Freeman, 1977] is the usual choice for routing questions, because it counts the shortest paths that actually run through a place. NetworkX chooses the minimum-weight edge when parallel edges exist, so a multigraph can be used directly. The toy network below joins two small grids with a single bridge. Degree should miss the bridge, and betweenness should find it.

Hide code cell source

two_districts = grid(4, first=0)
grid(4, x0=800, first=100, G=two_districts)          # a second grid, 800 m to the east
west_end, east_end = 3 * 4 + 2, 100 + 0 * 4 + 1      # a node on each grid's facing side
length = math.dist(
    (two_districts.nodes[west_end]["x"], two_districts.nodes[west_end]["y"]),
    (two_districts.nodes[east_end]["x"], two_districts.nodes[east_end]["y"]),
)
for u, v in ((west_end, east_end), (east_end, west_end)):
    two_districts.add_edge(u, v, length=length, highway="primary", oneway=False)
finish(two_districts)

betweenness = nx.betweenness_centrality(two_districts, weight="length")
degree = dict(two_districts.degree())
ranked = sorted(betweenness.items(), key=lambda item: -item[1])[:4]

print(f"{'node':>5} {'betweenness':>12} {'degree':>7}")
for node, value in ranked:
    print(f"{node:>5} {value:>12.3f} {degree[node]:>7}")
print("nodes sharing the top nodes' degree:", sum(d == degree[ranked[0][0]] for d in degree.values()))

edge_betweenness = nx.edge_betweenness_centrality(two_districts, weight="length")
top_edge = max(edge_betweenness, key=edge_betweenness.get)
print("most central edge:", top_edge[:2], f"({edge_betweenness[top_edge]:.3f})")
 node  betweenness  degree
   14        0.539       8
  101        0.539       8
  105        0.307       8
   10        0.307       8
nodes sharing the top nodes' degree: 10
most central edge: (14, 101) (0.258)

The two bridge ends, nodes 14 and 101, have a betweenness of 0.54, well above the next nodes at 0.31, yet ten nodes share their degree of 8, so degree cannot single them out. Edge betweenness points at the bridge itself. Exact betweenness runs a shortest-path search from every node, which is too slow for a city, so NetworkX can estimate it from a random sample of k source nodes [Brandes and Pich, 2007]. A fixed seed makes the estimate repeatable:

Hide code cell source

sampled = nx.betweenness_centrality(two_districts, k=10, weight="length", seed=42)
print("exact top four:  ", [node for node, _ in ranked])
print("sampled top four:", sorted(sampled, key=sampled.get, reverse=True)[:4], "(k = 10 of", two_districts.number_of_nodes(), "nodes)")

plain = finish(grid(9))                                  # a plain grid with nothing special in it
plain_betweenness = nx.betweenness_centrality(plain, weight="length")
centre = max(plain_betweenness, key=plain_betweenness.get)
print()
print("most central node in a plain 9 x 9 grid:", centre, "(the geometric centre is node 40)")
print(f"betweenness at the centre {plain_betweenness[40]:.3f}, at a corner {plain_betweenness[0]:.4f}")
exact top four:   [14, 101, 105, 10]
sampled top four: [101, 14, 105, 10] (k = 10 of 32 nodes)

most central node in a plain 9 x 9 grid: 40 (the geometric centre is node 40)
betweenness at the centre 0.142, at a corner 0.0013

A sample of 10 of the 32 sources recovers the same four nodes, and the sample size and seed are what make such an approximation a documented method, not a silent shortcut. The plain grid shows the boundary effect from S02 [Gil, 2017] in centrality form: with no special feature anywhere, the middle of the grid is the most central place, simply because more shortest paths cross it than cross the edge. Centrality near a study-area boundary and at its centre is therefore partly an artefact of where the boundary was drawn.

On the Columbia graph the full computation is impractical, so the cell below uses a documented sample, with edge length as the cost:

Hide code cell source

node_betweenness = nx.betweenness_centrality(
    G_projected,
    k=500,
    weight="length",
    seed=42,
)
nx.set_node_attributes(G_projected, node_betweenness, "betweenness")

most_central = sorted(
    node_betweenness.items(),
    key=lambda item: item[1],
    reverse=True,
)[:10]
print(most_central)
[(12446135329, 0.20668806384455238), (12446135289, 0.20664989590562108), (5195228113, 0.2052529493407356), (5195228111, 0.20523837612768908), (1485863319, 0.2051037473976405), (5195228112, 0.20394621790423317), (10196678666, 0.20354233171408742), (11880766133, 0.20302810548230393), (12446135458, 0.20285426786953503), (2825423374, 0.194030187369882)]

The ten highest scores lie between 0.193 and 0.204, within about 5% of each other, so their ranking has no real meaning: the map below suggests that they lie along one corridor, and a different seed or sample size could reorder them. What the sample does support is that these nodes are far more central than the typical node.

Betweenness can be expensive on a large graph. Use a documented sample or an appropriate approximation rather than silently dropping the calculation, and report the sample size and seed with the result.

5.3.5. Visualize the measure#

Colour the nodes by the measure with a colour map that has a visible scale. The toy network shows the pattern: the bridge ends stand out, and the centrality fades toward the corners of each grid.

../_images/network_betweenness_toy.png

Fig. 5.1 Node betweenness centrality in the toy network of two grids joined by one bridge (dark is low, bright is high).#

In Fig. 5.1, the two bridge ends are the brightest nodes, with a betweenness of 0.54, and the nodes next to them are the next brightest, with 0.31. The colours then fade toward the corners of each grid, the same pattern as the plain grid above, so even without the bridge the centre of a grid would outrank its edge.

The same plot on Columbia colours each node of the projected graph by its sampled betweenness:

../_images/network_betweenness_columbia.png

Fig. 5.2 Sampled node betweenness centrality (k = 500, seed 42, edge length as cost) on the drivable network of Columbia, Missouri. Data © OpenStreetMap contributors.#

Only a few nodes stand out in Fig. 5.2: a short bright segment just south-west of the centre and a corridor of high scores running north-east from the core. Most of the network, especially the residential cul-de-sacs, is dark and close to zero. No colour bar is drawn, so read the colours as relative order, not as values, and remember that the sample uses only 500 source nodes and that the city boundary cuts off through traffic from outside.

A highly central street or intersection is a candidate for closer inspection, not proof that it carries the most observed traffic. Combine centrality with counts, traffic data, land use, or other evidence.

5.3.6. Summary#

Network metrics describe structure only in the context of a declared graph, boundary, projection, and source. Project before measuring, state the area rule and the intersection tolerance, normalize comparisons, check connectivity, and use a documented sample when betweenness is too costly. Interpret centrality as a diagnostic, aware that the study-area boundary shapes it, and not as a standalone outcome.