Tuning HMM Emission and Transition Probabilities

The Hidden Markov Model map matching cluster introduces sigma_z and beta as the two parameters governing emission and transition probability, and the OSRM-backed matcher implementation wires them into a runnable class — but neither page covers how to actually derive good values for a specific fleet, or why the right value shifts as GPS sampling rate changes. This page fills that gap: an estimator for sigma_z grounded in real position noise rather than a guessed constant, a transition model built explicitly around the route-length-vs-great-circle-distance gap, and a small grid-search harness for choosing beta against labelled data. For the broader question of when an HMM approach is worth this tuning effort at all versus a simpler geometric matcher, see choosing a map-matching algorithm.


sigma_z / beta tuning outcome grid A grid with sigma_z bands on the vertical axis and beta bands on the horizontal axis. Cells are colour-coded: green for recommended combinations, orange for caution, and red for combinations to avoid, illustrating how tight sigma_z with strict beta overfits noise while loose sigma_z with permissive beta accepts wrong candidates. random snaps RTK only unstable overfits ambiguous recommended recommended kills detours matches all caution sparse OK rejects all 0.005 0.02 0.05 0.15 beta (m⁻¹) <5 (tight) 5–15 (recommended) >15 (loose) sigma_z (m) recommended caution avoid

Compatibility & Configuration Requirements

Requirement Minimum version / value Notes
Python 3.10
numpy 1.24 log-space arithmetic and vectorised grid search
Input perpendicular distances (m), route distances (m), great-circle distances (m) produced by the candidate-generation and routing-engine steps of the HMM cluster
Validation data labelled (d_network, d_gc, is_correct_candidate) tuples from manually verified ground-truth traces; 200+ samples per road-network type recommended
Math space log-space throughout required — see the first pitfall below

Production-Ready Implementation

This class estimates sigma_z from position data, exposes log-space emission and transition scoring, and includes a grid-search harness that scores candidate beta values against a labelled validation set without requiring live routing-engine calls during tuning.

import numpy as np
from dataclasses import dataclass


def _haversine_m(lat1: np.ndarray, lon1: np.ndarray, lat2: np.ndarray, lon2: np.ndarray) -> np.ndarray:
    """Vectorised great-circle distance in metres."""
    R = 6_371_000.0
    phi1, phi2 = np.radians(lat1), np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlambda = np.radians(lon2 - lon1)
    a = np.sin(dphi / 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2) ** 2
    return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1.0 - a))


class HMMProbabilityTuner:
    """
    Estimate and apply emission (sigma_z) and transition (beta) parameters
    for an HMM map matcher, entirely in log-space.

    Parameters
    ----------
    sigma_z : float | None
        GPS positional noise, metres. Pass None to require an explicit
        call to estimate_sigma_z_from_stationary before scoring.
    beta : float
        Transition decay rate, units m^-1. Larger beta penalises the gap
        between network routing distance and great-circle distance more
        harshly per metre of gap — see the tuning guidance below.
    max_candidates : int
        Candidate edges retained per GPS observation after sorting by
        perpendicular distance. Bounds the O(N^2) cost of building the
        transition matrix at each time step.
    min_sigma_z : float
        Floor applied to sigma_z to avoid a near-zero emission scale
        collapsing every candidate's log-probability toward -inf.
    """

    def __init__(
        self,
        sigma_z: float | None = None,
        beta: float = 0.02,
        max_candidates: int = 5,
        min_sigma_z: float = 3.0,
    ):
        self.sigma_z = sigma_z
        self.beta = beta
        self.max_candidates = max_candidates
        self.min_sigma_z = min_sigma_z

    @staticmethod
    def estimate_sigma_z_from_stationary(
        lat: np.ndarray, lon: np.ndarray
    ) -> float:
        """
        Estimate sigma_z as the RMS haversine deviation of GPS fixes from
        their centroid during a known-stationary period.

        Parameters
        ----------
        lat, lon : arrays of fixes recorded while the vehicle was parked
            or otherwise confirmed stationary (e.g. a depot dwell window
            identified by stop detection).

        Returns
        -------
        Estimated sigma_z in metres. Run this over several stationary
        windows across your fleet's device population and take the
        median, since GPS chipset quality varies by hardware model.
        """
        centroid_lat = np.mean(lat)
        centroid_lon = np.mean(lon)
        deviations_m = _haversine_m(
            lat, lon, np.full_like(lat, centroid_lat), np.full_like(lon, centroid_lon)
        )
        return float(np.sqrt(np.mean(deviations_m ** 2)))

    def log_emission(self, distances_m: np.ndarray) -> np.ndarray:
        """
        Log-space zero-mean Gaussian emission probability.

        log P(obs | edge) = -0.5 * (d / sigma_z)^2 - log(sigma_z * sqrt(2*pi))

        The normalising term matters when comparing scores across
        candidates scored with different sigma_z values (e.g. mixed
        device fleets); it can be dropped only if sigma_z is constant
        for every comparison in a single decode.
        """
        sigma = max(self.sigma_z or self.min_sigma_z, self.min_sigma_z)
        d = np.clip(np.asarray(distances_m, dtype=float), 0.0, None)
        return -0.5 * (d / sigma) ** 2 - np.log(sigma * np.sqrt(2 * np.pi))

    def log_transition(
        self, d_network_m: np.ndarray, d_gc_m: np.ndarray
    ) -> np.ndarray:
        """
        Log-space transition probability from the route-length vs
        great-circle distance gap, modelled as an exponential
        distribution with rate beta.

        log P(edge_j | edge_i) = log(beta) - beta * |d_network - d_gc|

        A larger beta concentrates probability mass near diff=0,
        strongly penalising any candidate whose real road-network path
        is much longer than the straight-line distance between fixes —
        exactly the shape of a genuine detour.
        """
        diff = np.abs(np.asarray(d_network_m, dtype=float) - np.asarray(d_gc_m, dtype=float))
        diff = np.clip(diff, 1e-6, None)
        return np.log(self.beta) - self.beta * diff

    def truncate_candidates(
        self, distances_m: np.ndarray, ids: np.ndarray
    ) -> tuple[np.ndarray, np.ndarray]:
        """Keep only the max_candidates closest candidates by distance."""
        order = np.argsort(distances_m)[: self.max_candidates]
        return distances_m[order], ids[order]

    def grid_search_beta(
        self,
        validation_samples: list[dict],
        beta_grid: np.ndarray,
    ) -> dict:
        """
        Score a range of beta values against labelled validation data
        and return the beta that best separates correct from incorrect
        candidate transitions.

        Parameters
        ----------
        validation_samples : list of dicts, each with keys
            'd_network_m', 'd_gc_m', 'is_correct_candidate' (bool),
            drawn from manually verified ground-truth traces.
        beta_grid : array of candidate beta values to evaluate, e.g.
            np.geomspace(0.002, 0.2, 20).

        Returns
        -------
        dict with 'scores' (beta -> margin) and 'best_beta'. The margin
        is the mean log-transition assigned to correct candidates minus
        the mean assigned to incorrect ones; a larger margin means beta
        discriminates better between right and wrong topology.
        """
        correct = [s for s in validation_samples if s["is_correct_candidate"]]
        incorrect = [s for s in validation_samples if not s["is_correct_candidate"]]

        correct_net = np.array([s["d_network_m"] for s in correct])
        correct_gc = np.array([s["d_gc_m"] for s in correct])
        incorrect_net = np.array([s["d_network_m"] for s in incorrect])
        incorrect_gc = np.array([s["d_gc_m"] for s in incorrect])

        scores: dict = {}
        for beta in beta_grid:
            self.beta = float(beta)

            correct_lp = (
                self.log_transition(correct_net, correct_gc)
                if len(correct)
                else np.array([-np.inf])
            )
            incorrect_lp = (
                self.log_transition(incorrect_net, incorrect_gc)
                if len(incorrect)
                else np.array([0.0])
            )
            margin = float(correct_lp.mean() - incorrect_lp.mean())
            scores[float(beta)] = margin

        best_beta = max(scores, key=scores.get)
        self.beta = best_beta
        return {"scores": scores, "best_beta": best_beta}

Execution & Tuning Guidelines

import numpy as np

tuner = HMMProbabilityTuner(max_candidates=5)

# Step 1 — estimate sigma_z from a known-stationary window per device
tuner.sigma_z = tuner.estimate_sigma_z_from_stationary(
    lat=stationary_df["lat"].to_numpy(),
    lon=stationary_df["lon"].to_numpy(),
)

# Step 2 — grid search beta against a labelled validation set
result = tuner.grid_search_beta(
    validation_samples=labelled_transitions,   # list[dict]
    beta_grid=np.geomspace(0.002, 0.2, 20),
)
print(f"best beta: {result['best_beta']:.4f}")

# Step 3 — score a live candidate pair in log-space
log_p = tuner.log_emission(np.array([8.5])) + tuner.log_transition(
    np.array([420.0]), np.array([310.0])
)
Parameter Default Effect of raising Effect of lowering
sigma_z estimated from data Emission term discriminates less between near and far candidates; ambiguous at parallel roads Emission dominates, snapping to the geometrically nearest candidate regardless of route plausibility
beta 0.02 m⁻¹ Stricter continuity assumption; punishes real detours — see pitfall below More tolerant of the network-vs-great-circle gap; risks accepting implausible jumps between unrelated roads
max_candidates 5 Captures more topological alternatives at complex junctions; O(N²) transition-matrix cost grows Cheaper per-step decoding; risks pruning the correct candidate before scoring
log-space always on Never disable; see the underflow pitfall below

Sampling-rate adjustment. beta should not be a single fixed constant across a fleet with mixed sampling rates. As the interval between fixes grows, the physical distance between consecutive true positions grows too, and with it the normal amount by which network distance can exceed great-circle distance even for a correctly matched path. A practical heuristic: scale beta inversely with the expected inter-fix distance, beta ≈ c / (v_avg * Δt) with c around 3–8, so 1 Hz urban delivery traces use a stricter beta than 0.05 Hz long-haul traces with 20-second gaps between fixes.

Common Pitfalls

Linear-space underflow

Multiplying raw (non-log) probabilities across a trace of more than a few dozen observations collapses to exactly 0.0 in float64, and every subsequent decision the Viterbi decoder makes becomes arbitrary — ties on zero are broken by array order, not evidence. This risk is worse the smaller sigma_z is set, since a tight emission distribution already assigns very small linear-space probabilities to any candidate more than a few metres from the GPS fix. log_emission and log_transition above never leave log-space; if you extend this tuner, never call np.exp() on an intermediate score before the final decode step.

Beta too large kills valid detours

A road network rarely tracks the great-circle line between two points — one-way systems, pedestrianized zones, and motorway interchanges routinely produce a network distance well above the straight-line distance for the correct candidate. Setting beta too high pushes log(beta) - beta * diff sharply negative for exactly this legitimate pattern, so the decoder systematically prefers a shorter, topologically wrong candidate over the correct detour. If ground-truth traces show the matcher consistently cutting through pedestrian zones or ignoring one-way restrictions the routing engine itself enforces, beta is very likely set too high — re-run grid_search_beta with a lower range.

Sigma_z estimated from unfiltered GPS

Running estimate_sigma_z_from_stationary over a window that includes moving pings — for example a window boundary drawn a few seconds too wide around a stop event — inflates the estimate with genuine displacement rather than positional noise, producing a sigma_z far larger than the device’s true error. Source stationary windows from a validated stop-detection pipeline with a conservative dwell-time margin trimmed from both ends, and sanity-check the result: consumer smartphone GPS should estimate in the 3–15 m range, and a result above 25 m usually means the window still contains motion.


Up: Hidden Markov Model Map Matching in Python | Trajectory Analysis & Map Matching Techniques