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

  1. Block before comparing — restrict candidate pairs by day and spatial cell, because the distance matrix is quadratic.
  2. Compute a symmetric distance matrix with a measure whose failure mode you have chosen deliberately.
  3. Fit HDBSCAN with metric="precomputed", setting min_cluster_size to the smallest group you would call a route.
  4. Inspect the noise label, because on trajectory data it usually contains the findings.

Production-ready Python implementation

PYTHON
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

PYTHON
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())}

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_method at the default. eom favours a few large clusters and is right for finding “the main corridors”. Route-variant discovery usually wants leaf.

  • 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.

Back to Movement Similarity & Clustering