Combining Walk and Transit Legs for Realistic Access Times
A forty-minute transit journey made of ten minutes walking, twelve minutes waiting, one transfer and eighteen minutes riding is not experienced as equivalent to forty minutes in a seat, and travel-behaviour research has measured the difference for decades. This guide, part of Public Transit Accessibility Modeling, covers weighting the legs and reporting the consequence.
Problem Context & Constraints
Riders consistently value out-of-vehicle time more heavily than in-vehicle time. Waiting is typically valued at 1.5 to 2.5 times riding, walking at 1.5 to 2.0, and each transfer carries an additional fixed penalty of roughly four to ten minutes beyond the wait it causes. These are behavioural findings, not modelling conveniences, and ignoring them means treating a journey with three transfers as equivalent to a direct one of the same duration.
For public health access work the effect is not neutral across the map. High-transfer, long-wait itineraries are concentrated in peripheral and low-frequency areas — the areas an equity analysis is about — so unweighted minutes systematically flatter access exactly where it is worst.
Two constraints limit how far to take this. The weights are population-dependent: an elderly or mobility-limited population walks more slowly and tolerates less waiting, and a single national parameter set applied to a specific clinic’s catchment may be badly wrong. And weighted time is not a duration, so it must never be reported in minutes without saying it is weighted — a “58-minute” weighted journey that takes 40 clock minutes will be checked against a timetable and disbelieved.
Prerequisites
- A router that reports leg composition, not only total duration — most modern GTFS routers do
- Leg weights chosen for the population being modelled, with a source
python3.11,pandas2.2.2,numpy1.26.4- The unweighted matrix from Building GTFS Travel-Time Matrices for Clinic Access
Leg composition varies systematically with where a journey starts, which is why weighting is not a uniform inflation:
Step-by-Step Solution
# Weight journey legs and measure how the access threshold set changes.
# Pinned: pandas==2.2.2, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("transit.legs")
# Defaults from general-population travel-behaviour literature. They are a
# starting point for a working-age urban population and are NOT appropriate for
# an elderly or mobility-limited cohort without adjustment.
DEFAULT_WEIGHTS = {"walk": 1.7, "wait": 2.0, "ride": 1.0, "transfer_min": 5.0}
def weighted_cost(legs: pd.DataFrame, weights: dict = None) -> pd.Series:
"""Generalised journey cost in weighted minutes.
`legs` has columns walk_min, wait_min, ride_min, n_transfers per itinerary.
The result is a COST, not a duration; the column name must say so."""
w = {**DEFAULT_WEIGHTS, **(weights or {})}
required = {"walk_min", "wait_min", "ride_min", "n_transfers"}
missing = required - set(legs.columns)
if missing:
raise ValueError(f"leg composition missing {sorted(missing)}; a router that "
"reports only total duration cannot support leg weighting")
cost = (w["walk"] * legs["walk_min"]
+ w["wait"] * legs["wait_min"]
+ w["ride"] * legs["ride_min"]
+ w["transfer_min"] * legs["n_transfers"])
log.info("weighted cost: median %.1f vs clock median %.1f (inflation %.2fx)",
float(cost.median()),
float((legs["walk_min"] + legs["wait_min"] + legs["ride_min"]).median()),
float(cost.median() / max(
(legs["walk_min"] + legs["wait_min"] + legs["ride_min"]).median(), 1e-9)))
return cost.rename("weighted_cost_min")
def threshold_shift(legs: pd.DataFrame, threshold: float = 45.0,
weights: dict = None) -> dict:
"""Which origins fall inside the threshold before and after weighting."""
clock = legs["walk_min"] + legs["wait_min"] + legs["ride_min"]
cost = weighted_cost(legs, weights)
inside_clock = set(legs.loc[clock <= threshold, "from_id"])
inside_cost = set(legs.loc[cost <= threshold, "from_id"])
lost = inside_clock - inside_cost
log.info("threshold %.0f: %d origins inside on clock time, %d on weighted cost, "
"%d lost", threshold, len(inside_clock), len(inside_cost), len(lost))
return {"inside_clock": inside_clock, "inside_cost": inside_cost, "lost": lost}
def sensitivity_grid(legs: pd.DataFrame, threshold: float = 45.0,
wait_range=(1.5, 2.0, 2.5), walk_range=(1.5, 1.7, 2.0)) -> pd.DataFrame:
"""How many origins qualify across plausible weight choices.
Publishing this grid is what prevents a reader treating one weight set as
fact; the range across it is the honest uncertainty in the access figure."""
rows = []
for wait in wait_range:
for walk in walk_range:
res = threshold_shift(legs, threshold, {"wait": wait, "walk": walk})
rows.append({"wait": wait, "walk": walk, "n_inside": len(res["inside_cost"])})
out = pd.DataFrame(rows).pivot(index="wait", columns="walk", values="n_inside")
log.info("origins inside the threshold across the weight grid:\n%s", out.to_string())
return out
The sensitivity grid is the deliverable, not the single weighted number. Leg weights are estimates with real uncertainty, and a map produced from one parameter set presents that uncertainty as precision.
Validation & Edge Cases
1. Confirm the legs sum to the reported duration. A router that reports walk, wait and ride separately should have them sum to the total; a discrepancy means an unmodelled leg — often an initial wait or a fare-gate allowance — is being dropped:
INFO weighted cost: median 63.4 vs clock median 41.2 (inflation 1.54x)
INFO threshold 45: 612 origins inside on clock time, 388 on weighted cost, 224 lost
INFO origins inside the threshold across the weight grid:
walk 1.5 1.7 2.0
wait
1.5 441 424 402
2.0 404 388 366
2.5 371 356 334
2. Check whether the lost origins are systematically located. If the 224 origins that drop out under weighting are concentrated in one part of the county, weighting has revealed a structural difference in journey quality rather than adding uniform inflation. Map the lost set.
3. Adjust the walk weight for the modelled population. A clinic serving predominantly elderly patients should use a slower walk speed and a higher walk weight, and the difference is large enough to change the conclusion. State the population assumption alongside the weights.
4. Do not compare weighted transit cost against unweighted drive time. The comparison is meaningless: either weight both or neither. Where a comparison is required, use unweighted clock time for both and report the leg composition separately.
5. Keep the unweighted matrix. Weighting is a transformation of a stored quantity, and keeping the unweighted values means a later analysis with different weights costs nothing.
6. Treat the first wait differently from a transfer wait. A rider departing from home can time their departure to the timetable, so the initial wait is partly discretionary; a transfer wait is imposed. Many implementations apply the same weight to both, which overstates the burden of the first leg on high-frequency services and understates the difference between a direct route and a connecting one. Where the router exposes them separately, weight the initial wait at roughly half the transfer wait and say so.
7. Sanity-check the inflation factor. The ratio of weighted cost to clock time should typically fall between about 1.3 and 1.8 for an urban network. A ratio below 1.2 usually means walk and wait legs are being under-reported by the router; above 2.0 usually means a transfer penalty has been applied twice, once as a fixed cost and again inside the wait.
8. Keep the weights out of the matrix. Store legs, apply weights at analysis time. Baking a weighting into a stored matrix makes it unusable for any other population.
9. Check the weighting against any local travel survey. Where a metropolitan planning organisation has estimated local values of time, those are better evidence than published national ranges and using them is easy to justify to a reviewer.
Different populations experience the same journey differently, and a single weight set applied to all of them understates the burden on the group most likely to need the service:
Compliance Notes
- Name the column so it cannot be misread.
weighted_cost_minrather thantravel_time_min, with the weights recorded in the metadata. - Publish the weight set and its source with any figure derived from it, and publish the sensitivity grid alongside.
- State the population the weights were chosen for, since a general-population parameter set applied to an elderly cohort understates the burden.
- Never present weighted minutes as a journey duration in public-facing material, where it will be checked against a timetable.
Related Topics
- Public Transit Accessibility Modeling — the parent guide, covering departure windows and reachability.
- Building GTFS Travel-Time Matrices for Clinic Access — where the leg composition this guide weights is produced.
- Choosing Distance Decay Functions for 2SFCA — the analogous parameter choice one step downstream.