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.4 or lightgbm >= 4.0, geopandas >= 0.14 for 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Gate on confidence. Emit unknown below 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.

PYTHON
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

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.

Back to Movement Pattern Extraction & Trajectory Analysis