Hidden Markov Model Map Matching in Python
When raw GPS coordinates drift, skip, or suffer multipath interference in dense urban canyons, simple nearest-road snapping fails catastrophically — a single outlier ping can snap a vehicle onto a parallel street one block over, corrupting every downstream metric from dwell time to fuel consumption. Hidden Markov Model (HMM) map matching solves this by treating the road network as a set of latent states and GPS observations as noisy emissions from those states. By jointly optimising spatial proximity, topological connectivity, and temporal plausibility, HMM matching delivers production-grade route reconstruction even under severe noise.
This guide provides a complete, step-by-step implementation workflow for mobility engineers, Python GIS developers, and logistics platform builders. For the broader context on spatial trajectory processing, see the parent overview at Trajectory Analysis & Map Matching Techniques.
Prerequisites
Before implementing an HMM matcher, ensure your environment and data pipeline meet these baseline requirements:
- Python 3.10+ with
numpy,pandas,scipy,shapely, andrequests - Routing engine: OSRM, Valhalla, or GraphHopper running locally or via managed API
- Input data: Time-ordered GPS traces in GeoJSON, CSV, or Parquet format containing
lat,lon,timestamp, and optionallyspeed/heading - Network data: OSM-derived road graph with topology (nodes, edges, speed limits, turn restrictions)
- Mathematical background: Markov chains, emission/transition probabilities, and the Viterbi algorithm at a conceptual level
HMM map matching does not require deep learning frameworks. It relies on probabilistic graph traversal, making it deterministic, auditable, and suitable for compliance-heavy fleet operations.
Step-by-Step Workflow
1. Data Ingestion and Temporal Alignment
Raw telemetry arrives asynchronously from telematics control units (TCUs) or mobile SDKs. Sort by timestamp, remove stationary pings (consecutive points within 2 m of each other), and extract kinematic features that later refine probability matrices.
Temporal alignment also demands clock drift handling and timezone normalisation. Always convert timestamps to UTC and compute inter-point deltas (Δt) in seconds. Use timestamp synchronisation across mixed OBD-II and mobile devices as a reference if your fleet mixes hardware types. If Δt exceeds a configurable threshold (e.g. 30 seconds), treat the trace as two independent segments — this prevents the Viterbi decoder from forcing implausible long-range transitions.
import pandas as pd
import numpy as np
def prepare_trace(df: pd.DataFrame, gap_threshold_s: float = 30.0) -> list[pd.DataFrame]:
"""
Sort, deduplicate, and split a GPS trace on temporal gaps.
Parameters
----------
df : DataFrame with columns lat, lon, timestamp (UTC ISO-8601 or epoch)
gap_threshold_s : seconds; gaps larger than this start a new segment
Returns
-------
List of DataFrames, each a contiguous trace segment
"""
df = df.sort_values("timestamp").reset_index(drop=True)
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df["dt"] = df["timestamp"].diff().dt.total_seconds().fillna(0.0)
# Drop near-stationary duplicates (< 2 m apart)
from shapely.geometry import Point
coords = df[["lon", "lat"]].values
mask = np.ones(len(df), dtype=bool)
for i in range(1, len(df)):
if Point(coords[i]).distance(Point(coords[i - 1])) * 111_320 < 2.0:
mask[i] = False
df = df[mask].reset_index(drop=True)
df["dt"] = df["timestamp"].diff().dt.total_seconds().fillna(0.0)
# Split on gaps
split_points = df.index[df["dt"] > gap_threshold_s].tolist()
boundaries = [0] + split_points + [len(df)]
return [df.iloc[boundaries[i]:boundaries[i + 1]] for i in range(len(boundaries) - 1)]
Expected output shape: a list of DataFrames, each with columns lat, lon, timestamp, dt. A trace of 500 pings typically splits into 1–5 segments depending on signal quality.
2. Candidate Road Segment Generation
For each GPS observation, query the routing engine or a spatial index (e.g. scipy.spatial.cKDTree over OSM edge centroids) to retrieve k nearest road segments within radius R (typically 50–150 m). Each candidate records:
- Edge ID and full geometry
- Perpendicular distance from the GPS point to its projected point on the edge
- Road class, speed limit, and one-way status
When projecting points onto linestrings, use Shapely’s nearest_points to compute exact snap locations and bearing vectors. If your fleet includes mixed vehicle types, filter candidates against vehicle-specific constraints (bridge clearance, turn radius) — the multi-modal considerations are covered in Multi-Modal Route Matching for Mixed Fleets. For strategies on aligning sensor-derived heading with road network directionality, see Directionality & Heading Synchronisation.
from scipy.spatial import cKDTree
from shapely.geometry import Point, LineString
from shapely.ops import nearest_points
def build_candidate_index(edges: list[dict]) -> cKDTree:
"""Build a KD-tree over edge midpoints for fast radius queries."""
centroids = [
LineString(e["geometry"]).interpolate(0.5, normalized=True).coords[0]
for e in edges
]
return cKDTree(centroids)
def get_candidates(
point: tuple[float, float],
edges: list[dict],
tree: cKDTree,
radius_m: float = 100.0,
) -> list[dict]:
"""
Return candidate edges within radius_m of the GPS point.
Parameters
----------
point : (lon, lat) in WGS-84 — note longitude-first order
radius_m: search radius in metres (approximate; 1 deg lat ≈ 111 320 m)
"""
radius_deg = radius_m / 111_320
idxs = tree.query_ball_point(point, radius_deg)
candidates = []
for i in idxs:
edge = edges[i]
geom = LineString(edge["geometry"])
snap, _ = nearest_points(geom, Point(point))
dist_m = Point(point).distance(snap) * 111_320
candidates.append({
"edge_id": edge["id"],
"geometry": geom,
"snap_point": snap,
"distance_m": dist_m,
"speed_limit_ms": edge.get("speed_limit_kmh", 50) / 3.6,
})
return sorted(candidates, key=lambda c: c["distance_m"])
3. Probability and Math Model
The HMM framework requires two probability distributions. Working in log-space throughout is non-negotiable — floating-point underflow silently produces all-zero probability columns, which causes the Viterbi decoder to emit arbitrary paths with no warning.
Emission Probability
Models how likely a GPS ping is, given the vehicle occupies a specific road segment. Modelled as a zero-mean Gaussian over the perpendicular snap distance d:
P_emission(d | σ) = (1 / (σ√(2π))) · exp(−0.5 · (d/σ)²)
σ represents GPS measurement error — typically 5–15 m, but calibrate it from the device’s reported HDOP if available (σ ≈ 5 + HDOP × 3 metres).
Transition Probability
Models how likely it is that the vehicle moves from road segment i at time t−1 to segment j at time t. The routing engine returns the network distance D_route(i→j); the expected travel distance is v_avg × Δt. The probability decays exponentially with the absolute mismatch:
P_transition(i→j) = exp(−β · |D_route(i→j) − v_avg · Δt|)
β (default 2.0 m⁻¹) controls sensitivity. Speed profiling from raw GPS coordinates can supply per-segment v_avg estimates, tightening these bounds considerably.
import numpy as np
def compute_log_emission(distances_m: np.ndarray, sigma: float = 10.0) -> np.ndarray:
"""
Log-space Gaussian emission probabilities.
Parameters
----------
distances_m : (N,) perpendicular snap distances in metres
sigma : GPS error standard deviation in metres
Returns
-------
(N,) log-probabilities; higher (less negative) is more likely
"""
return -0.5 * (distances_m / sigma) ** 2 - np.log(sigma * np.sqrt(2 * np.pi))
def compute_log_transition(
route_distances_m: np.ndarray,
delta_t: float,
avg_speed_ms: float,
beta: float = 2.0,
) -> np.ndarray:
"""
Log-space transition probabilities based on routing vs. kinematic distance.
Parameters
----------
route_distances_m : (N_prev, N_curr) shortest-path distances from routing engine
delta_t : elapsed time in seconds between observations
avg_speed_ms : expected average speed in m/s for this edge pair
beta : decay rate in m⁻¹ (higher = stricter distance matching)
Returns
-------
(N_prev, N_curr) log-transition matrix
"""
expected_dist_m = avg_speed_ms * delta_t
diff = np.abs(route_distances_m - expected_dist_m)
return -beta * diff
Routing Engine Integration
Use OSRM’s /table endpoint to batch-request all N_prev × N_curr route distances in a single HTTP call rather than making individual /route requests:
import requests
def osrm_distance_matrix(
sources: list[tuple[float, float]],
destinations: list[tuple[float, float]],
base_url: str = "http://localhost:5000",
) -> np.ndarray:
"""
Fetch a routing distance matrix from a local OSRM instance.
Parameters
----------
sources : list of (lon, lat) snap points — OSRM expects lon/lat order
destinations : list of (lon, lat) snap points
Returns
-------
(len(sources), len(destinations)) distance matrix in metres;
unreachable pairs are set to 1e9 (effectively -inf in log-space)
"""
coords_str = ";".join(
f"{lon},{lat}"
for lon, lat in (sources + destinations)
)
src_idxs = ";".join(str(i) for i in range(len(sources)))
dst_idxs = ";".join(str(i + len(sources)) for i in range(len(destinations)))
resp = requests.get(
f"{base_url}/table/v1/driving/{coords_str}",
params={"sources": src_idxs, "destinations": dst_idxs, "annotations": "distance"},
timeout=5,
)
resp.raise_for_status()
data = resp.json()
matrix = np.array(data["distances"], dtype=float)
matrix[matrix is None] = 1e9 # unreachable
matrix = np.where(matrix == 0.0, 1e9, matrix) # zero-distance self-loops
return matrix
Coordinate order gotcha: OSRM uses (longitude, latitude) throughout its API — the opposite of most GeoJSON tools. Passing (lat, lon) silently produces wrong distances with no error.
4. Viterbi Decoding and Path Reconstruction
With log-probability matrices populated, the Viterbi algorithm finds the most likely sequence of road segments. The algorithm runs in O(T × N²) time, where T is the number of GPS observations and N is the average candidate count per observation. Avoid nested Python loops; use numpy broadcasting throughout.
def viterbi_decode(
log_emissions: np.ndarray,
log_transitions: list[np.ndarray],
) -> tuple[np.ndarray, float]:
"""
Vectorised Viterbi decoder for HMM map matching.
Parameters
----------
log_emissions : (T, N) — log emission probability for each observation/candidate
log_transitions : list of T-1 arrays, each (N_prev, N_curr)
— log transition probability between consecutive observations
Returns
-------
path : (T,) array of candidate indices (the optimal state sequence)
log_prob : scalar log-probability of the optimal path
"""
T = log_emissions.shape[0]
N = log_emissions.shape[1]
delta = np.full((T, N), -np.inf)
psi = np.zeros((T, N), dtype=int)
# Initialise from first observation
delta[0] = log_emissions[0]
# Forward pass
for t in range(1, T):
# log_transitions[t-1] has shape (N_prev, N_curr)
# delta[t-1] has shape (N_prev,) — broadcast over columns
scores = delta[t - 1, :, np.newaxis] + log_transitions[t - 1] # (N_prev, N_curr)
psi[t] = np.argmax(scores, axis=0) # (N_curr,)
delta[t] = np.max(scores, axis=0) + log_emissions[t] # (N_curr,)
# Backtrack
path = np.zeros(T, dtype=int)
path[-1] = int(np.argmax(delta[-1]))
for t in range(T - 2, -1, -1):
path[t] = psi[t + 1, path[t + 1]]
return path, float(delta[-1, path[-1]])
The algorithm’s formal foundation is the Newson & Krumm paper “Hidden Markov Map Matching Through Noise and Sparseness” (2009), summarised on Wikipedia’s map matching overview. The implementation in Building an HMM-based map matcher with OSRM and Python integrates these components into a complete, runnable class.
5. Post-Processing and Production Validation
The decoded path yields a sequence of edge IDs. Convert these into actionable telemetry:
- Geometric smoothing: Interpolate along matched edges using cumulative distance ratios to reconstruct a continuous polyline.
- Timestamp alignment: Reassign timestamps proportionally along the matched geometry to preserve temporal fidelity for downstream stop detection and dwell-time analytics.
- Confidence scoring: Compute the normalised log-probability of the optimal path per observation:
score = log_prob / T. Paths with per-observation scores below−15.0indicate severe GPS degradation or unmapped infrastructure — flag them for manual QA rather than silently propagating bad data. - Outlier removal validation: Cross-check that the matched speed profile is consistent with
speed_limit × 1.3along each edge. Matched segments that imply impossible speeds are a reliable signal that the trace had an uncorrected outlier upstream.
Always validate against ground-truth routes or known depot-to-destination corridors. Implement automated regression tests that feed synthetic traces — with known noise profiles — into your pipeline to verify decoder stability before deploying to production fleets.
Routing Engine Integration Notes
| Engine | Table endpoint | Coord order | Turn restrictions | Rate limit notes |
|---|---|---|---|---|
| OSRM (local) | /table/v1/driving/{coords} |
lon,lat |
Enabled by default | No limit; set max_table_size in config |
| OSRM (managed) | Same | lon,lat |
Enabled | Typically 100 req/s; batch aggressively |
| Valhalla (local) | /sources_to_targets |
lat,lon in JSON body |
Enabled with costing_options |
No limit; chunked JSON |
| GraphHopper (local) | /matrix |
lat,lon in JSON body |
Via ch.disable=true + turn_costs=true |
No limit with self-hosted |
Config flags to verify before deployment:
- OSRM: compile with
--algorithm MLDfor live traffic support;--max-table-size 1000prevents request timeouts with large candidate grids. - Valhalla: set
"use_restrictions": 1in the costing options; default is 0. - GraphHopper: enable
turn_costs=trueinconfig.yml; without it, illegal U-turns are not penalised.
Cache D_route values keyed by (edge_id_prev, edge_id_curr, vehicle_type) tuples. For a fleet of 100 vehicles on an urban grid, the warm-cache hit rate typically exceeds 70 % within a day of operation.
Operational Troubleshooting
GPS signal loss and trace gaps
Cause: GNSS reception drops in tunnels, underground car parks, or dense urban canyons. The trace has a gap where Δt > 60 s.
Symptom: The Viterbi decoder forces a path across hundreds of metres of missing data, producing topologically illegal transitions (e.g. bridge-to-tunnel-to-motorway in 10 seconds).
Fix: Hard-split the trace at any gap exceeding your threshold (30–60 s is typical for urban fleets; 120 s for long-haul motorway routes). Treat each segment independently. For advanced interpolation strategies during known tunnels, maintain a geofenced tunnel registry and apply dead-reckoning from the last known heading and speed.
Routing API rate limits exhausted
Cause: Each candidate-pair combination triggers a routing request. A trace of 200 observations with 10 candidates each yields up to 2 000 × 10 = 20 000 routing lookups per second at realtime processing rates.
Symptom: HTTP 429 responses or timeouts mid-trace; the transition matrix has NaN or 1e9 values for the affected time steps, causing the decoder to fall back to emission-only decisions (effectively nearest-road snapping).
Fix: Use the /table batch endpoint (one call per time step returns all N_prev × N_curr distances). Add an LRU cache keyed on (edge_id_from, edge_id_to). For offline or low-latency fallbacks, substitute straight-line Euclidean distance multiplied by a road-network detour factor (1.3–1.5 for urban grids; 1.1–1.2 for motorways).
Turn restriction violations in decoded path
Cause: The transition probability model does not encode prohibited turns. The Viterbi decoder treats all transitions as equally feasible if routing distances are similar.
Symptom: The matched path contains illegal U-turns, left-into-one-way, or restricted access entries. Fleet managers report impossible routes in compliance audits.
Fix: After computing log_transitions, iterate over (edge_from, edge_to) pairs that the routing engine flags as restricted. Set those cells to −np.inf before passing the matrix to the decoder. OSRM returns null durations for unreachable pairs via /table — map those directly to −np.inf.
Memory overflow on long-haul traces
Cause: Storing the full (T, N, N) transition matrix for a 10-hour long-haul trace with T = 36 000 and N = 15 candidates requires ~6 GB of float64 memory.
Symptom: MemoryError or silent OOM-kill on the worker process; partial traces written to storage with no error logged.
Fix: Implement a sliding-window Viterbi approach that processes chunks of 50–100 observations. Carry the final delta vector forward as the initialisation of the next window. This caps peak memory at O(W × N²) — roughly 1 MB for W = 100, N = 15. The accuracy penalty at chunk boundaries is negligible when window size exceeds 3× the maximum GPS gap.
Numerical underflow despite log-space arithmetic
Cause: Summation of many negative log-probabilities across a long trace accumulates to values below the float64 minimum (~−1 × 10³⁰⁸). This commonly occurs when σ is set too small relative to actual GPS error, making every emission probability nearly zero.
Symptom: delta array fills with −inf after 20–30 time steps; all backtracked paths return index 0 regardless of actual road geometry.
Fix: Rescale delta at every step by subtracting the maximum value: delta[t] -= np.max(delta[t]). This keeps the relative ordering intact while anchoring magnitudes near zero. Also verify that σ reflects the actual device error — use HDOP-derived values from GPS data preprocessing fundamentals rather than a fixed constant.
Incorrect CRS causing systematic snapping errors
Cause: GPS coordinates ingested in a projected CRS (e.g. EPSG:27700 British National Grid) are passed to a routing engine expecting WGS-84 decimal degrees. All distances and snap operations are computed in the wrong coordinate space.
Symptom: Candidates are consistently snapped to roads 1–50 km from the actual position; the emission probability matrix has uniformly tiny values across all candidates; the matched route is geographically nonsensical.
Fix: Normalise all coordinates to WGS-84 (EPSG:4326) before any spatial indexing or routing query. Refer to Coordinate Reference System Mapping for Fleet Data for a systematic CRS normalisation workflow.
Deployment Checklist
By combining Gaussian emission probabilities, routing-derived transition costs, and log-space Viterbi decoding, this workflow delivers deterministic, auditable route reconstruction at sub-10-metre accuracy across thousands of fleet vehicles. The probabilistic foundation gives compliance teams a transparent paper trail — every matched segment has a quantified confidence score — making HMM matching the preferred approach for regulated logistics, insurance telematics, and municipal mobility planning.
Related
- Building an HMM-based map matcher with OSRM and Python — complete, runnable implementation integrating all components above
- Speed Profiling from Raw GPS Coordinates — supplying per-segment speed priors for tighter transition probabilities
- Directionality & Heading Synchronisation — aligning heading vectors with road network directionality before candidate filtering
- Stop Detection & Dwell-Time Analytics — downstream consumer of matched trajectories; dwell accuracy depends directly on matching quality
- GPS Data Preprocessing & Cleaning Fundamentals — upstream CRS normalisation, outlier removal, and timestamp alignment that feed into the matcher