Clustering trajectories with HDBSCAN on a distance matrix
Route clusters have wildly different densities — a trunk corridor carries thousands of near-identical trips while a suburban variant carries eight — and a single eps cannot find both. HDBSCAN chooses a different density threshold per cluster, which is exactly the property trajectory data needs. Give it a precomputed distance matrix from Fréchet or DTW, set min_cluster_size from what you would call a route, and treat the noise label as a finding rather than a failure.
Why this happens
DBSCAN asks a single question at a single scale: are there min_samples neighbours within eps? On trajectory data that question has no good answer. Set eps tight enough to keep the trunk corridor from swallowing its variants and every minor route becomes noise; set it loose enough to find the minor routes and the trunk merges with everything adjacent to it.
HDBSCAN builds a hierarchy over all density levels and then extracts the clusters that persist longest as the threshold varies. A dense trunk cluster survives at tight thresholds; a sparse variant survives at loose ones; both appear in the same output. The distance measures that feed it — and their very different opinions about what “similar” means — are covered in movement similarity and clustering.
Core pipeline
- Block before comparing — restrict candidate pairs by day and spatial cell, because the distance matrix is quadratic.
- Compute a symmetric distance matrix with a measure whose failure mode you have chosen deliberately.
- Fit HDBSCAN with
metric="precomputed", settingmin_cluster_sizeto the smallest group you would call a route. - Inspect the noise label, because on trajectory data it usually contains the findings.
Production-ready Python implementation
import numpy as np
import pandas as pd
import hdbscan
def cluster_trajectories(
dist: np.ndarray,
trip_ids: pd.Index,
min_cluster_size: int = 5,
min_samples: int | None = None,
selection: str = "eom",
) -> pd.DataFrame:
"""
Cluster trajectories from a precomputed distance matrix.
Parameters
----------
dist : np.ndarray
Square, symmetric, zero-diagonal distance matrix in METRES (or
whatever unit the measure returns — it must be consistent).
min_cluster_size : int
The smallest group you would be willing to call a route. This is a
domain decision, not a tuning knob: 5 means "five trips make a route".
min_samples : int | None
Conservativeness. Lower values assign more points to clusters;
defaults to min_cluster_size, which is usually too conservative for
trajectory data — try min_cluster_size // 2.
selection : str
'eom' (excess of mass) prefers a few large stable clusters; 'leaf'
prefers many fine ones. Route networks usually want 'leaf'.
Returns
-------
pd.DataFrame
Per trip: label, membership probability, and outlier score.
Raises
------
ValueError
If the matrix is not square, symmetric or non-negative.
"""
if dist.ndim != 2 or dist.shape[0] != dist.shape[1]:
raise ValueError(f"Distance matrix must be square; got {dist.shape}.")
if len(trip_ids) != dist.shape[0]:
raise ValueError("trip_ids length does not match the matrix.")
if not np.allclose(dist, dist.T, atol=1e-6, equal_nan=True):
raise ValueError(
"Matrix is not symmetric. DTW and Fréchet are symmetric by "
"definition, so an asymmetric matrix means the fill loop only "
"wrote the upper triangle."
)
if np.nanmin(dist) < 0:
raise ValueError("Negative distances — a similarity was passed instead.")
if not np.allclose(np.diag(dist), 0.0, atol=1e-9):
raise ValueError("Non-zero diagonal — a trip is not distance 0 from itself.")
# HDBSCAN cannot handle NaN. Blocked-out pairs must be a large finite
# value, not NaN: infinity poisons the mutual-reachability computation.
finite = np.nan_to_num(dist, nan=np.nanmax(dist) * 10.0, posinf=np.nanmax(dist) * 10.0)
clusterer = hdbscan.HDBSCAN(
metric="precomputed",
min_cluster_size=int(min_cluster_size),
min_samples=int(min_samples) if min_samples else None,
cluster_selection_method=selection,
allow_single_cluster=False,
)
labels = clusterer.fit_predict(finite.astype(np.float64))
return pd.DataFrame(
{
"label": labels,
"probability": clusterer.probabilities_,
"outlier_score": clusterer.outlier_scores_,
},
index=trip_ids,
)
Validation block
def validate_clustering(res: pd.DataFrame, dist: np.ndarray,
max_noise_frac: float = 0.4) -> dict:
"""Three checks that separate a real clustering from a degenerate one."""
noise = (res["label"] == -1)
n_clusters = res.loc[~noise, "label"].nunique()
# 1. Not everything is noise, and not everything is one cluster.
assert n_clusters >= 2, (
f"{n_clusters} cluster(s) — try cluster_selection_method='leaf' or "
"a lower min_samples before concluding the routes are homogeneous"
)
assert noise.mean() < max_noise_frac, (
f"{noise.mean():.0%} noise — the distance measure may be separating "
"trips that a human would call the same route"
)
# 2. Clusters should be tighter internally than the overall spread,
# otherwise the labels carry no information.
overall = np.nanmedian(dist[np.triu_indices_from(dist, k=1)])
for lab, idx in res.loc[~noise].groupby("label").groups.items():
pos = res.index.get_indexer(idx)
sub = dist[np.ix_(pos, pos)]
within = np.nanmedian(sub[np.triu_indices_from(sub, k=1)]) if len(pos) > 1 else 0.0
assert within < 0.6 * overall, (
f"cluster {lab} is as spread out as the whole dataset"
)
# 3. Membership probabilities should not be uniformly near zero.
assert res.loc[~noise, "probability"].median() > 0.4, (
"median membership probability is very low — clusters are marginal"
)
return {"clusters": int(n_clusters), "noise_frac": float(noise.mean())}
eps, min_cluster_size has a meaning you can state in words. That is what makes it defensible in a review, and the curve is only there to confirm the choice is not on a cliff.Common mistakes and gotchas
-
NaN in the distance matrix. Blocked pairs must be a large finite number. NaN raises; infinity silently distorts the mutual-reachability graph.
-
An asymmetric matrix. Filling only the upper triangle is the usual cause. The validator above catches it before HDBSCAN produces confidently wrong clusters.
-
Passing a similarity instead of a distance. DTW returns a cost, which is a distance; a cosine or overlap score is a similarity and must be converted, or every cluster is inside out.
-
Leaving
cluster_selection_methodat the default.eomfavours a few large clusters and is right for finding “the main corridors”. Route-variant discovery usually wantsleaf. -
Discarding the noise label. On route data, noise means “no other trip resembles this one”, which is either a one-off diversion or a data problem. Both are worth looking at — see movement anomaly detection.
-
Computing the full matrix. Quadratic cost dominates everything else here. Block by day and spatial cell first; trips in different cities were never going to cluster.
FAQ
How large a matrix is practical?
Memory is the binding constraint: a dense float64 matrix of n trips is 8n² bytes, so 20 000 trips is 3.2 GB and 50 000 is 20 GB. Blocking keeps n per block in the low thousands, which is comfortable. If you genuinely need to cluster 50 000 trips together, cluster the blocks first and then merge cluster representatives.
Should I use DTW or Fréchet for the matrix?
Whichever failure mode you can live with. Fréchet is decided by the single worst point of disagreement, so it separates a route from the same route with one detour — useful for conformance, unhelpful for route discovery. DTW averages along the alignment and tolerates the detour. For route clustering DTW is usually the better default.
What does the membership probability mean?
How firmly a trip belongs to its cluster given the density structure — near 1 for a trip in the core of a corridor, near 0.3 for one on its edge. It is useful as a filter when the clusters feed a downstream product: taking only members above 0.7 gives a clean set of exemplar routes.
Related
- Movement Similarity & Clustering — the parent reference, including the blocking strategy.
- Measuring Trajectory Similarity with Fréchet and DTW — where the matrix comes from.
- Hausdorff vs LCSS for Partial Trajectory Matching — measures for trips that only partly overlap.
- Implementing DBSCAN for Stay-Point Clustering in Python — the single-density sibling, on points rather than trips.
- Movement Anomaly Detection — what to do with the trips HDBSCAN labels as noise.