Hausdorff vs LCSS for partial trajectory matching
When two trips share a corridor but not their endpoints, Hausdorff and LCSS give opposite answers, and both are correct for different questions. Hausdorff measures the worst mismatch anywhere, so a trip that continues past the shared section scores badly. LCSS counts matched points within a tolerance and ignores everything unmatched, so the same pair scores well. Choose from what “similar” has to mean downstream, then set the tolerance from positional error rather than from taste.
Why this happens
Partial overlap is the normal case in fleet data. Two delivery vans share an arterial for four kilometres and then diverge to different districts; a bus and a car share three stops’ worth of road. Whether those trips are “similar” depends entirely on the use: for corridor-load analysis they are, for route-conformance checking they are not.
Hausdorff answers the second question. It is the greatest distance from any point on one path to the nearest point on the other, so the non-shared tail sets the score. LCSS answers the first. It finds the longest subsequence of points that can be matched within a spatial tolerance and a temporal window, and unmatched points cost nothing — they are simply not part of the common subsequence. The measures that assume full overlap are covered in measuring trajectory similarity with Fréchet and DTW.
Core pipeline
- State the question — conformance to a reference, or shared corridor usage — because it determines the measure.
- Set the tolerance from positional error, typically two to three times the 95th-percentile fix error.
- Compute the measure on projected coordinates, using the discrete forms for tractability.
- Normalise before comparing scores across pairs of different lengths.
Production-ready Python implementation
import numpy as np
from scipy.spatial.distance import cdist
def discrete_hausdorff(a: np.ndarray, b: np.ndarray) -> float:
"""
Symmetric discrete Hausdorff distance between two projected paths.
Parameters
----------
a, b : np.ndarray
(n, 2) and (m, 2) arrays of METRIC coordinates. Degrees give a
latitude-dependent answer that cannot be compared between cities.
Returns
-------
float
The greatest distance from any vertex of either path to the nearest
vertex of the other. Dominated by the single worst point, by design.
Raises
------
ValueError
On empty or wrongly shaped input.
"""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
if a.ndim != 2 or a.shape[1] != 2 or b.ndim != 2 or b.shape[1] != 2:
raise ValueError("Both paths must be (n, 2) coordinate arrays.")
if a.size == 0 or b.size == 0:
raise ValueError("Cannot compare an empty path.")
d = cdist(a, b) # O(n·m) memory — block long trips
return float(max(d.min(axis=1).max(), d.min(axis=0).max()))
def lcss(
a: np.ndarray,
b: np.ndarray,
eps_m: float = 40.0,
delta: int = 20,
) -> float:
"""
Longest Common SubSequence similarity in [0, 1].
Parameters
----------
eps_m : float
Spatial tolerance in metres. Two points match if they are within it.
Set from positional error — roughly 2-3x the p95 fix error — not by
eye, or the score measures your tolerance rather than the routes.
delta : int
Maximum index offset allowed when matching, which stops the algorithm
pairing the start of one trip with the end of the other. Also caps
the cost at O(n·delta) rather than O(n·m).
Returns
-------
float
Matched length divided by the shorter path's length. 1.0 means the
shorter path is entirely contained in the longer one within eps.
"""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
if a.size == 0 or b.size == 0:
raise ValueError("Cannot compare an empty path.")
if eps_m <= 0:
raise ValueError("eps_m must be positive.")
n, m = len(a), len(b)
prev = np.zeros(m + 1, dtype=np.int32)
cur = np.zeros(m + 1, dtype=np.int32)
for i in range(1, n + 1):
lo, hi = max(1, i - delta), min(m, i + delta)
cur[:] = 0
for j in range(lo, hi + 1):
if np.hypot(a[i - 1, 0] - b[j - 1, 0], a[i - 1, 1] - b[j - 1, 1]) <= eps_m:
cur[j] = prev[j - 1] + 1
else:
cur[j] = max(prev[j], cur[j - 1])
prev, cur = cur, prev
# Normalise by the SHORTER path: a 2 km trip fully contained in a 20 km
# one should score 1.0, not 0.1. Normalising by the longer path instead
# makes every short trip look dissimilar to everything.
return float(prev[m] / max(min(n, m), 1))
Validation block
def validate_measures(a: np.ndarray, b: np.ndarray, eps_m: float = 40.0) -> dict:
"""Property tests that catch the usual implementation errors."""
# 1. Identity. A path against itself is Hausdorff 0 and LCSS 1.
assert discrete_hausdorff(a, a) == 0.0, "Hausdorff of a path with itself is not 0"
assert abs(lcss(a, a, eps_m) - 1.0) < 1e-9, "LCSS of a path with itself is not 1"
# 2. Symmetry. Both measures are symmetric; an asymmetric result means
# the one-sided Hausdorff was returned, or delta is applied unevenly.
assert abs(discrete_hausdorff(a, b) - discrete_hausdorff(b, a)) < 1e-6
assert abs(lcss(a, b, eps_m) - lcss(b, a, eps_m)) < 1e-9
# 3. Tolerance monotonicity. LCSS must not fall as eps rises.
lo, hi = lcss(a, b, eps_m), lcss(a, b, eps_m * 2)
assert hi >= lo - 1e-9, "LCSS decreased with a looser tolerance"
# 4. Containment. A strict prefix of a path must score LCSS 1.0.
assert abs(lcss(a[: len(a) // 2], a, eps_m) - 1.0) < 1e-9, (
"a prefix does not score 1.0 — the normalisation uses the longer "
"path instead of the shorter one"
)
return {"hausdorff_m": discrete_hausdorff(a, b), "lcss": lcss(a, b, eps_m)}
Common mistakes and gotchas
-
Returning the one-sided Hausdorff. The distance from A to B is not the distance from B to A. The symmetric maximum is what “Hausdorff distance” means.
-
Normalising LCSS by the longer path. Every short trip then looks dissimilar to every long one, including trips entirely contained within them.
-
Setting
epsby eye. Above the spacing between parallel streets the measure matches adjacent roads, which is usually the opposite of the intent. Derive it from positional error and check against a known parallel pair. -
Omitting the
deltawindow. Without it LCSS can match the start of one trip against the end of another, and the cost goes quadratic. -
Comparing raw Hausdorff values across cities. It is an absolute distance in metres, so a 3 km score means something different in a dense grid and a rural network. Normalise by trip length before pooling.
-
Materialising
cdistfor very long paths. An n × m float matrix at 4 000 points each is 128 MB. Simplify the geometry first, as in downsampling high-frequency GPS tracks.
FAQ
Which should I use for route clustering?
LCSS, usually, because partial overlap is normal and Hausdorff will scatter genuinely related trips. The exception is conformance checking against a fixed reference route, where the worst deviation is exactly what you want to measure and Hausdorff is the correct choice.
Does LCSS handle different sampling rates?
Better than Hausdorff does, because it matches on proximity rather than on correspondence — a 1 Hz trip and a 5-second trip on the same road still share matched points. It is not immune, though: at very different rates the shorter sequence limits the achievable matched length, so resample both to a common cadence when the rates differ by more than about a factor of three.
Is there a version that respects direction?
Neither measure does by default: a trip and the same road driven the other way score identically. If direction matters, either add a bearing term to the match predicate in LCSS, or filter pairs by the circular difference of their mean headings before comparing — the circular statistics in computing bearing and heading change give you that cheaply.
Related
- Movement Similarity & Clustering — the parent reference and the blocking strategy these measures need.
- Measuring Trajectory Similarity with Fréchet and DTW — the full-overlap measures.
- Clustering Trajectories with HDBSCAN on a Distance Matrix — the consumer of the matrix these build.
- Detecting Route Deviation Against a Planned Path — the conformance case where Hausdorff is the right tool.
- Downsampling High-Frequency GPS Tracks Without Losing Path Integrity — reducing the cost of the pairwise computation.