Transport Mode Inference
Transport mode inference assigns a travel mode — walking, cycling, a road vehicle, rail — to each segment of a movement trace, using the distribution of speed, acceleration and stopping behaviour over the segment rather than any single observation.
It is the step that turns a geometric record of where a device went into a behavioural record of how it travelled, and almost every mobility question that reaches a policy or product decision needs it: mode share, emissions attribution, active-travel measurement, multimodal journey reconstruction. It is also the step where the most confident-looking errors are made, because a classifier that has never seen a congested arterial will label an hour of stationary driving as walking and produce a mode-share figure that is wrong in a direction nobody checks.
Prerequisites
This page assumes the trace has already been through the foundational cleaning stages. Specifically:
- Cleaned positions. Multipath jitter inflates the acceleration features that do most of the classification work, so run GPS drift correction first. An unfiltered urban trace produces phantom accelerations that make every mode look like a car.
- A metric CRS. Every speed and acceleration feature is metric; see coordinate reference system mapping.
- Segmented tracks. Mode is a property of a stretch of travel, so the track must already be cut into candidate single-mode segments by trajectory segmentation.
- Python stack.
pandas >= 2.0,numpy >= 1.25,scikit-learn >= 1.4orlightgbm >= 4.0,geopandas >= 0.14for the network-context features.
Labels are the harder prerequisite. Mode labels do not exist in raw telemetry, and the three practical sources — travel diaries, ticketing records and instrumented volunteers — each cover different modes with different reliability. Budget for label collection before budgeting for modelling.
Why the Modes Overlap
The intuition that speed separates modes survives about ten minutes of contact with real data. The problem is that the speed distributions overlap heavily in exactly the band where most urban travel happens.
What separates the overlapping modes is the shape of the movement. A bicycle accelerates at well under 1 m/s² and rarely exceeds 30 km/h even downhill. A bus reaches higher peak speeds between stops but stops at regular, repeated locations. A car in congestion has the same median speed as both but retains a high 95th-percentile acceleration whenever a gap opens. None of these are visible in the mean.
Feature Groups That Do the Work
| Group | Features | What it separates | Degrades when |
|---|---|---|---|
| Speed distribution | median, 85th and 95th percentile, standard deviation | walk from everything; car from cycle at the top end | congestion compresses the upper percentiles |
| Acceleration | 95th percentile of |a|, fraction of time above 1.5 m/s² | cycle from motorised modes | sampling interval above ~5 s |
| Stop behaviour | stops per km, median dwell, dwell regularity | bus from car | short segments with too few stops |
| Heading | heading-change rate, fraction of near-straight travel | rail from road; walk from cycle | dense grid networks |
| Network context | distance to nearest rail line, matched road class, cycle-path fraction | rail from road; cycle from bus | network data missing or stale |
| Segment shape | length, duration, straightness ratio | short access legs from main legs | very short segments |
The ordering matters. Kinematic features alone reach the high seventies on four modes at 1 Hz; adding stop behaviour takes it to the mid eighties; adding network context takes it to the low nineties. Changing the model family — logistic regression to random forest to gradient boosting — moves accuracy by two or three points at most. Practitioners consistently over-invest in the model and under-invest in the features.
Deterministic Pipeline Overview
The stages in words:
- Segment. Cut at stops longer than the dwell threshold and at gaps beyond the split threshold. Mode changes almost always happen at a stop, so a segmenter tuned for trips is usually already close to what mode inference needs.
- Extract features. Compute the distributional features above per segment. Guard against short segments: anything under about 20 fixes or 200 m has unstable percentiles and should carry a flag.
- Classify. A gradient-boosted tree on 20–40 features is a strong baseline and trains in seconds. Calibrate the output probabilities — uncalibrated tree ensembles are systematically overconfident, and stage five depends on the probability meaning something.
- Smooth the sequence. Consecutive segments are not independent. A one-minute segment classified as walking between two long car segments is far more likely to be a car in a queue than a genuine mode change; a transition matrix estimated from labelled chains encodes that.
- Gate on confidence. Emit
unknownbelow the threshold. Report the unknown rate as a headline metric, because a model whose accuracy improved while its unknown rate collapsed has usually just become more confidently wrong.
Implementation Walkthrough
The function below computes the per-segment feature frame that stages three onward consume. It is deliberately dependency-light: everything except the network-context features is NumPy over projected coordinates.
import numpy as np
import pandas as pd
def segment_mode_features(
df: pd.DataFrame,
segment_col: str = "segment_id",
time_col: str = "t",
x_col: str = "x",
y_col: str = "y",
stop_speed_ms: float = 0.7,
min_fixes: int = 20,
) -> pd.DataFrame:
"""
Build one feature row per trajectory segment for transport-mode inference.
Parameters
----------
df : pd.DataFrame
Fix-level frame with segment_col, tz-aware time_col, and PROJECTED
metric coordinates in x_col / y_col. Never pass degrees.
stop_speed_ms : float
Speed below which a fix counts as stopped (m/s).
min_fixes : int
Segments shorter than this get is_short=True; their percentile
features are unstable and downstream code should treat them as such.
Returns
-------
pd.DataFrame
One row per segment, indexed by segment id.
Raises
------
ValueError
If required columns are missing or the frame is empty.
"""
required = {segment_col, time_col, x_col, y_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
rows = []
for seg_id, g in df.sort_values([segment_col, time_col]).groupby(segment_col, sort=False):
n = len(g)
if n < 3:
continue # cannot derive a velocity, let alone a distribution
t = g[time_col].to_numpy()
dt = np.diff(t).astype("timedelta64[ns]").astype(float) / 1e9
dt = np.where(dt <= 0, np.nan, dt) # guard against clock steps
dx = np.diff(g[x_col].to_numpy())
dy = np.diff(g[y_col].to_numpy())
step = np.hypot(dx, dy)
speed = step / dt # m/s, metric CRS assumed
# Central-difference acceleration; edges use one-sided differences so
# no NaN sentinel is introduced.
accel = np.gradient(speed, np.nanmean(dt))
# Heading change, wrapped into +/-180 degrees before accumulating.
heading = np.degrees(np.arctan2(dy, dx))
dh = np.diff(heading)
dh = (dh + 180.0) % 360.0 - 180.0
duration_s = float(np.nansum(dt))
length_m = float(np.nansum(step))
straight_m = float(np.hypot(
g[x_col].iloc[-1] - g[x_col].iloc[0],
g[y_col].iloc[-1] - g[y_col].iloc[0],
))
stopped = speed < stop_speed_ms
rows.append({
segment_col: seg_id,
"n_fixes": n,
"is_short": n < min_fixes or length_m < 200.0,
"duration_s": duration_s,
"length_m": length_m,
# ── speed distribution ────────────────────────────────────
"v_median": float(np.nanmedian(speed)),
"v_p85": float(np.nanpercentile(speed, 85)),
"v_p95": float(np.nanpercentile(speed, 95)),
"v_std": float(np.nanstd(speed)),
# ── acceleration ──────────────────────────────────────────
"a_p95": float(np.nanpercentile(np.abs(accel), 95)),
"frac_a_gt_1p5": float(np.nanmean(np.abs(accel) > 1.5)),
# ── stop behaviour ────────────────────────────────────────
"frac_stopped": float(np.nanmean(stopped)),
"stops_per_km": float(_run_starts(stopped) / max(length_m / 1000.0, 1e-6)),
# ── heading ───────────────────────────────────────────────
"heading_change_per_km": float(
np.nansum(np.abs(dh)) / max(length_m / 1000.0, 1e-6)
),
# ── shape ─────────────────────────────────────────────────
"straightness": straight_m / length_m if length_m > 0 else np.nan,
})
if not rows:
raise ValueError("No segment had enough fixes to derive features.")
return pd.DataFrame(rows).set_index(segment_col)
def _run_starts(mask: np.ndarray) -> int:
"""Number of runs of True — i.e. distinct stop events, not stopped fixes."""
if mask.size == 0:
return 0
return int(mask[0]) + int(np.sum(mask[1:] & ~mask[:-1]))
Two details are worth dwelling on. _run_starts counts stop events rather than stopped fixes, which is the difference between “this bus stopped eleven times” and “this bus was stationary for 40% of the segment” — the first separates bus from car, the second does not. And dt guards against non-positive intervals, because a clock step inside a segment otherwise produces an infinite speed that poisons every percentile in the row.
The network-context features are a spatial join and belong after this function: distance from the segment centroid to the nearest rail line, and the fraction of the segment that map-matches to each road class via map matching against a road network.
Calibration and Parameter Tuning
| Parameter | Typical value | How to choose |
|---|---|---|
stop_speed_ms |
0.7 m/s | Just below slow walking; raise to 1.0 for noisy 1 Hz urban data |
| Minimum segment length | 200 m / 20 fixes | Below this, percentile features are noise — flag rather than drop |
| Confidence threshold | 0.65 | Set from the precision you need per mode, not a round number |
| Transition prior | from labelled chains | Never uniform: walk→car is common, rail→cycle is not |
| Training cadence | match deployment | Downsample training data if production logs at 30 s |
The confidence threshold is the parameter with the largest effect on downstream numbers and the least attention paid to it. Raising it from 0.5 to 0.7 typically converts 8–12% of segments to unknown and adds three or four points of precision on the retained ones. Whether that trade is right depends entirely on whether the consumer can handle an unknown class — and if it cannot, that is a conversation to have before the model ships, not after.
Integration and Compatibility
Mode labels are consumed almost immediately downstream, which makes their failure modes propagate fast. Origin-destination flow matrices split by mode inherit every misclassification directly into the cell counts. Emissions and active-travel reporting multiply mode by distance, so a systematic bias — congested driving labelled as cycling — moves the headline number in a way no aggregate check catches.
Two integration rules keep that manageable. First, always carry the probability vector, not just the argmax; a consumer that needs a different precision/recall balance can then re-threshold without re-running the model. Second, keep the mode label separate from the segment definition: if a later change to trajectory segmentation changes the segment boundaries, every mode label attached to the old boundaries is stale, and a schema that makes that obvious saves a quarter of confused analysis.
In This Section
- Classifying walk, bike and car from speed features — a runnable baseline classifier and what its confusion matrix says about where the features fail.
- Feature engineering for transport mode classifiers — the network-context and stop-regularity features that lift accuracy past the kinematic ceiling.
FAQ
Can speed alone separate the transport modes?
Only at the extremes. Median speed separates walking from everything else cleanly, but cycling, buses in traffic and cars on congested streets all occupy the same 10–20 km/h band, and a car in a jam is indistinguishable from a bicycle by average speed. What separates them is the 95th-percentile speed, the acceleration distribution and the stop pattern: a bus stops regularly at fixed points, a car stops irregularly at junctions, and a bicycle accelerates far more slowly than either.
Should I classify each GPS fix or each segment?
Segments. A single fix carries almost no mode information — an instantaneous 4 km/h is consistent with walking, with a cyclist at a junction and with a car in a queue. Segment first at stops and gaps, compute distributional features over each segment, and classify those. Per-fix classification followed by smoothing reaches the same answer far more expensively and with less consistent labels.
How much does sampling rate affect accuracy?
Substantially, because the acceleration and stop features degrade first. At 1 Hz a good feature set reaches around 90% segment accuracy on four modes. At 30-second intervals accelerations are averaged away and short stops vanish, and accuracy typically falls to the low 70s, with most of the loss in bus-versus-car and cycle-versus-bus. Train on the cadence you will deploy on, or downsample your training data to match it.
Do I need a road or rail network to get good accuracy?
Not to start, but network context is the most valuable feature group after kinematics. Distance to the nearest rail line separates rail from road modes almost perfectly, and the fraction of a segment matching a cycle path or bus route resolves much of the remaining confusion. Adding these typically buys 6–10 points, more than any change of model family.
What accuracy is realistic in production?
For four coarse modes, 88–94% segment accuracy at 1 Hz with kinematic plus network features. Separating bus from car reliably needs route context or ticketing data, and mode chains with very short legs — a two-minute walk to a parked car — remain the dominant residual error. Report accuracy per mode: a model that is 92% accurate overall may be 60% on the rarest mode you care about most.
Related
- Trajectory Segmentation — the upstream stage that defines what a single-mode segment is.
- Speed & Acceleration Profiling — the kinematic derivations that produce most of this page’s features.
- Stay-Point Detection Algorithms — stop events, which carry the bus-versus-car signal.
- Map-Matching to Road Networks — the source of the road-class and cycle-path context features.
- Movement Anomaly Detection — the sibling technique that flags segments no mode explains.