How to Build a High-Injury Network from Crash Data in Python

On this page

Problem statement

Cities with Vision Zero programmes publish a high-injury network: the small share of streets where most people are killed or seriously injured in crashes. It turns thousands of crash points into a list of corridors to redesign. Building one means choosing a severity threshold, snapping crashes to street segments, ranking segments by injuries per mile so that long streets do not win by length alone, and checking that the concentration is more than a few unlucky blocks in a sparse dataset.

This page builds one for Chicago from three years of crashes, 2022–2024, in which someone was killed or incapacitated, using the city's crash records and street segments from the Census Bureau's TIGER/Line edges, and compares the result with what the same number of injuries spread over the streets in proportion to length would give.

Quick answer

Snap killed-or-seriously-injured crashes to segments, rank segments by KSI per mile, and find the share of KSI on the top few percent of street miles:

j = gpd.sjoin_nearest(KSI, SEG[["geometry"]], max_distance=200, distance_col="moved_ft")
j = j[~j.index.duplicated()]
SEG["ksi"] = j.groupby("index_right").ksi.sum().reindex(SEG.index, fill_value=0)
SEG["miles"] = SEG.length / 5280
order = SEG.assign(rate=SEG.ksi / SEG.miles).sort_values("rate", ascending=False)
miles_share, ksi_share = order.miles.cumsum() / order.miles.sum(), order.ksi.cumsum() / order.ksi.sum()
for q in (0.02, 0.05, 0.10):
    print(f"top {q:.0%} of street miles by KSI per mile: {ksi_share[miles_share <= q].iloc[-1]:.1%} of killed and seriously injured")
top 2% of street miles by KSI per mile: 41.0% of killed and seriously injured
top 5% of street miles by KSI per mile: 69.6% of killed and seriously injured
top 10% of street miles by KSI per mile: 95.5% of killed and seriously injured

Ranked by KSI per mile, the worst 2% of Chicago's 4,247 street miles carried 41.0% of the people killed or seriously injured in 2022–2024, the worst 5% carried 69.6%, and the worst 10% almost all of them.

Map of Chicago's street segments with the top 5 percent of street miles by killed-or-seriously-injured per mile in red, 212 miles holding 4,735 KSI from 2022 to 2024: short red stretches are spread across the grid, densest downtown, on the West Side and along South Side arterials, with longer red runs on Lake Shore Drive and at O'Hare. Map of Chicago's street segments with the top 5 percent of street miles by killed-or-seriously-injured per mile in red, 212 miles holding 4,735 KSI from 2022 to 2024: short red stretches are spread across the grid, densest downtown, on the West Side and along South Side arterials, with longer red runs on Lake Shore Drive and at O'Hare.
A high-injury network for Chicago.

A severity threshold, checked snapping, length-based comparison

1. Choose the severity threshold

Chicago's crash records count injuries by severity on each crash. Killed or seriously injured (KSI) here means at least one fatal or incapacitating injury:

print(f"KSI crashes 2022-2024: {len(RAW):,}; with coordinates: {RAW.latitude.notna().sum():,}")
print(f"people killed: {KSI.injuries_fatal.sum()}; incapacitated: {KSI.injuries_incapacitating.sum():,}; KSI total {KSI.ksi.sum():,}")
print(KSI.first_crash_type.value_counts().head(4).to_string())
KSI crashes 2022-2024: 5,867; with coordinates: 5,831
people killed: 421; incapacitated: 6,398; KSI total 6,819
first_crash_type
PEDESTRIAN    1385
ANGLE          918
TURNING        822
REAR END       544

5,867 crashes killed 421 people and incapacitated 6,398. Pedestrian crashes are the largest single type, 1,385, followed by angle and turning crashes at intersections.

2. Check the snapping

Crash locations come from police reports and are not placed on street centrelines, unlike the city's crime points. Check how far they move and how many fail:

print(f"snapped: {len(j):,} of {len(KSI):,}; distance moved median {j.moved_ft.median():.0f} ft, 90th percentile {j.moved_ft.quantile(0.9):.0f} ft")
print(f"segments with any KSI: {(SEG.ksi > 0).sum():,} of {len(SEG):,} ({(SEG.ksi > 0).mean():.1%})")
snapped: 5,820 of 5,831; distance moved median 34 ft, 90th percentile 40 ft
segments with any KSI: 4,664 of 47,057 (9.9%)

Crash points sit about 34 feet from the centreline, nine in ten within 40 feet: they are placed consistently off the street's centre, not scattered, so a 200-foot tolerance loses only 11 crashes. Fewer than one segment in ten had a KSI crash in three years.

Close-up of a divided road with two carriageways and side streets, around the segment with the most KSI, 19: nine KSI crash points sit tens of feet to one side of the carriageway centrelines rather than on them; a 200-foot scale bar is shown. Close-up of a divided road with two carriageways and side streets, around the segment with the most KSI, 19: nine KSI crash points sit tens of feet to one side of the carriageway centrelines rather than on them; a 200-foot scale bar is shown.
Crash points sit beside the centrelines.

3. Compare with injuries spread by length

A sparse count concentrates by chance: with about 6,000 injuries and 47,000 segments, most segments must be empty. Scatter the same total over the segments in proportion to their length and run the same ranking:

rng = np.random.default_rng(0)
sim = rng.poisson(SEG.ksi.sum() * SEG.miles / SEG.miles.sum())
o2 = SEG.assign(k=sim, rate=sim / SEG.miles).sort_values("rate", ascending=False)
m2, k2 = o2.miles.cumsum() / o2.miles.sum(), o2.k.cumsum() / o2.k.sum()
for q in (0.02, 0.05, 0.10):
    print(f"top {q:.0%} of miles: observed {ksi_share[miles_share <= q].iloc[-1]:.1%}, spread by length {k2[m2 <= q].iloc[-1]:.1%}")
top 2% of miles: observed 41.0%, spread by length 24.5%
top 5% of miles: observed 69.6%, spread by length 50.1%
top 10% of miles: observed 95.5%, spread by length 75.6%

Even spread in proportion to length, the same number of injuries would put half of them on the top 5% of miles, because nine segments in ten must be empty. The observed 69.6% is the excess that says something about the streets: about twenty points at 5%, and similar margins at 2% and 10%.

Naming Chicago's high-injury corridors

The data used on this page

The City of Chicago's Traffic Crashes - Crashes dataset (85ca-t3if), crashes from 2022 to 2024 with at least one fatal or incapacitating injury, selected on the server; street segments from the Census Bureau's TIGER/Line 2023 edges for Cook County, merged at false nodes with momepy; and the community area boundaries.

from pathlib import Path

import geopandas as gpd
import momepy
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
F = Path("data/chicago_ksi_crashes_2022_2024.parquet")
if not F.exists():
    rows, offset = [], 0
    while True:
        part = requests.get("https://data.cityofchicago.org/resource/85ca-t3if.json", timeout=300, params={
            "$where": "crash_date >= '2022-01-01' AND crash_date < '2025-01-01' AND (injuries_fatal > 0 OR injuries_incapacitating > 0)",
            "$limit": 50_000, "$offset": offset, "$order": "crash_record_id",
            "$select": "crash_record_id,crash_date,posted_speed_limit,first_crash_type,injuries_fatal,injuries_incapacitating,latitude,longitude,street_name"}).json()
        rows += part; offset += len(part)
        if len(part) < 50_000:
            break
    pd.DataFrame(rows).to_parquet(F)
SRC = {"chicago_community_areas.geojson": "https://data.cityofchicago.org/resource/igwz-8jzy.geojson?$limit=100",
       "tl_2023_17031_edges.zip": "https://www2.census.gov/geo/tiger/TIGER2023/EDGES/tl_2023_17031_edges.zip"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)

RAW = pd.read_parquet(F)
k = RAW.dropna(subset=["latitude", "longitude"]).astype({"latitude": float, "longitude": float, "injuries_fatal": int, "injuries_incapacitating": int})
k = k[(k.latitude > 41) & (k.longitude < -87)]                             # a few records carry 0,0
k["ksi"] = k.injuries_fatal + k.injuries_incapacitating
KSI = gpd.GeoDataFrame(k, geometry=gpd.points_from_xy(k.longitude, k.latitude), crs=4326).to_crs(3435)
CITY = gpd.read_file("data/chicago_community_areas.geojson").to_crs(3435).union_all()
edges = gpd.read_file("data/tl_2023_17031_edges.zip", columns=["FULLNAME", "MTFCC"])
roads = edges[edges.MTFCC.isin(["S1100", "S1200", "S1400"])].to_crs(3435)
roads = roads[roads.intersects(CITY.buffer(500))].reset_index(drop=True)
SEG = momepy.remove_false_nodes(roads[["FULLNAME", "geometry"]])
SEG = SEG[SEG.intersects(CITY)].reset_index(drop=True)
print(f"{len(KSI):,} KSI crashes with usable coordinates; {len(SEG):,} street segments, {SEG.length.sum() / 5280:,.0f} miles")
5,831 KSI crashes with usable coordinates; 47,057 street segments, 4,247 miles

Example 1: name the corridors

A network is easier to act on as named corridors. Sum KSI by the street name on the crash reports and divide by the length of that street's segments:

by_street = j.groupby("street_name").ksi.sum().sort_values(ascending=False)
print(f"street names on KSI crash reports: {by_street.size:,}; top 10 hold {by_street.head(10).sum() / by_street.sum():.1%} of KSI")
print(by_street.head(6).to_string())
street names on KSI crash reports: 620; top 10 hold 20.8% of KSI
street_name
WESTERN AVE    200
CICERO AVE     192
PULASKI RD     188
HALSTED ST     167
ASHLAND AVE    163
KEDZIE AVE     123

620 street names appear on the reports, and ten of them carry a fifth of all KSI. Western Avenue, Cicero Avenue, Pulaski Road, Halsted Street and Ashland Avenue, long north-south arterials crossing the city, top the list.

Killed and seriously injured by street name on Chicago crash reports, 2022 to 2024. killed or seriously injured by street, 2022-2024Western Ave200Cicero Ave192Pulaski Rd188Halsted St167Ashland Ave163Kedzie Ave1230100200KSITen street names carry a fifth of all KSI; 620 names appear spatialworkflow.io
The streets with the most KSI.

A prioritisation tool for rare, clustered harm

Building a high-injury network from crash records. from crash records to a high-injury networkKSI crashes5,867 in 2022-2024snap tosegmentsmedian 34 ft, 200ft limitKSI per mile4,664 segments withanyrank by ratetop 5% of miles:69.6%check againstchanceby length alone:50.1%Then name the corridors and check stability over years spatialworkflow.io
From crash records to a network.

A high-injury network is a prioritisation tool. Severe injuries are rare at any one place but cluster on a limited set of streets, typically wide, fast arterials with many conflicts between cars, pedestrians and turning traffic. Ranking segments by KSI per mile, rather than by count, puts short dangerous blocks and long dangerous corridors on the same scale; summing along named streets turns the result into corridors that can be redesigned as projects.

Two checks keep the network honest. The first is the comparison with chance: with far more segments than injuries, any ranking of sparse counts looks concentrated, and only the excess over a length-proportional scattering is evidence about the streets. The second is stability: a network drawn from three years should overlap substantially with one drawn from the next three, and pooling years, as here, is the usual way to get there. Crash data also carries its own limits: police reports locate crashes approximately, injury severity is judged at the scene, and crashes on expressways, handled by the state police, may be missing.

Thresholds, short segments and exposure

Building a high-injury network: problems and fixes. high-injury network trapsTrapInsteadstreets ranked by countrank by KSI per mileconcentration taken at face valuecompare with spreading by length:50.1%crash points treated as on thestreetsnap with a tolerance: median 34ftone year of crashespool years: 2022-2024 hereChicago traffic crashes, 2022 to 2024 spatialworkflow.io
High-injury network traps.
  • Severity threshold: including all injury crashes gives a denser, more stable network but one weighted towards minor crashes.
  • Short segments: a 30-foot segment with one KSI has an enormous rate; the miles-share ranking keeps them from dominating, and a minimum length is common.
  • Intersections: many severe crashes happen at intersections; assigning them to one approach splits their effect.
  • Exposure: KSI per mile ignores traffic volume; per vehicle-mile rates need traffic counts.
  • Expressways: interstates (S1100) are in the segments; their crashes may be under-recorded in city data.

FAQ

What is a high-injury network?

The small share of a city's streets where most people are killed or seriously injured in traffic crashes, used by Vision Zero programmes to prioritise street redesigns.

How do I build a high-injury network in Python?

Snap killed-or-seriously-injured crashes to street segments, rank segments by KSI per mile, and select the top segments until they cover a chosen share of injuries or street miles.

Why rank streets by injuries per mile?

Because long streets collect more crashes by length alone; a rate per mile compares streets of different lengths fairly.

How concentrated are severe crashes on Chicago streets?

In 2022–2024, the top 5% of street miles by KSI per mile held about seven in ten killed and seriously injured.