HDBSCAN for Variable-Density Fleet Stops

DBSCAN for fleet stop clustering works well when stop density is roughly uniform across the dataset, but mixed fleets rarely cooperate. A single delivery network can have tight curbside drop points 15-20 meters apart in a dense urban core alongside sprawling distribution-center yards where a truck’s GPS trace wanders 100-200 meters across a loading apron while still representing one operational visit. A single eps value cannot satisfy both: tune it tight enough to separate adjacent urban stops and the depot yard fragments into a dozen spurious micro-clusters; tune it loose enough to hold the depot together and adjacent city-block stops silently merge.

HDBSCAN (Hierarchical DBSCAN) removes the single-eps constraint by building a hierarchy of clusters across a range of density thresholds and then selecting the most stable clusters from that hierarchy automatically. This page gives a self-contained Python class built on the hdbscan library with a haversine metric, extracting both hard cluster labels and the soft membership probabilities and cluster persistence scores that DBSCAN does not provide.

DBSCAN single-eps failure versus HDBSCAN adaptive clustering Two side-by-side panels. Left panel shows DBSCAN with one fixed eps either fragmenting a sparse depot region or merging a dense urban region. Right panel shows HDBSCAN correctly separating both regions using per-region adaptive density. DBSCAN — single eps urban: merges fine depot yard: fragments HDBSCAN — adaptive urban: one cluster depot yard: one cluster

Compatibility and Configuration

Requirement Minimum version / value Notes
Python 3.10
hdbscan 0.8.33 haversine metric support and cluster_selection_epsilon parameter; earlier 0.8.x releases lack the epsilon merge option
scikit-learn 1.3 hdbscan depends on scikit-learn’s BallTree internals for supported metrics
numpy 1.24 np.radians for the mandatory degree-to-radian conversion
Coordinate format [latitude, longitude] in radians same convention as scikit-learn’s metric="haversine"; passing degrees silently produces a nonsensical global-scale hierarchy
Distance metric metric="haversine" do not use metric="euclidean" on raw lat/lon — see the DBSCAN stop-clustering guide for why

The Clusterer

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd
import hdbscan


EARTH_RADIUS_M = 6_371_000


@dataclass
class HDBSCANStopClusterer:
    """
    Cluster fleet stop candidates of varying spatial density using
    HDBSCAN with a haversine metric.

    Parameters
    ----------
    min_cluster_size : int
        The smallest grouping of points HDBSCAN will consider a
        genuine cluster rather than noise. This is the primary density
        knob — unlike DBSCAN's eps, it does not encode a fixed
        distance, so it transfers reasonably well across regions of
        different density. Default 5; raise to 8-10 for high-frequency
        sampling (1 ping/5-10 s) where a real stop naturally
        accumulates many points, lower to 3 for sparse 60 s+ sampling.
    min_samples : int | None
        Controls how conservative the clustering is about calling a
        point noise. Defaults to min_cluster_size when None, which is
        the library default and a reasonable starting point. Raise
        above min_cluster_size to be more conservative (more points
        classified as noise); this smooths out sparse bridging points
        between two otherwise-distinct stop regions.
    cluster_selection_epsilon : float
        A radian distance floor merged into the hierarchy after
        fitting: clusters closer together than this value are merged
        even if HDBSCAN's stability-based selection would otherwise
        keep them separate. Use this to prevent over-fragmentation of
        naturally sparse depot yards without affecting dense urban
        regions. Default 0.0 (disabled); a typical non-zero value is
        metres_to_radians(30) for merging micro-fragments up to 30 m
        apart.
    cluster_selection_method : str
        'eom' (excess of mass, the default) favours fewer, larger,
        more stable clusters and is the safer choice for operational
        stop detection. 'leaf' produces more, smaller, finer-grained
        clusters — useful for exploratory analysis of a new region's
        stop structure but prone to over-segmenting a single depot
        into loading-bay-level sub-clusters in production use.
    """

    min_cluster_size: int = 5
    min_samples: int | None = None
    cluster_selection_epsilon: float = 0.0
    cluster_selection_method: str = "eom"

    @staticmethod
    def metres_to_radians(metres: float) -> float:
        """Convert a metric distance to the radian units HDBSCAN expects."""
        return metres / EARTH_RADIUS_M

    def _build_clusterer(self) -> hdbscan.HDBSCAN:
        return hdbscan.HDBSCAN(
            min_cluster_size=self.min_cluster_size,
            min_samples=self.min_samples,
            metric="haversine",
            cluster_selection_epsilon=self.cluster_selection_epsilon,
            cluster_selection_method=self.cluster_selection_method,
        )

    def fit(
        self,
        df: pd.DataFrame,
        lat_col: str = "latitude",
        lon_col: str = "longitude",
    ) -> pd.DataFrame:
        """
        Fit HDBSCAN over a candidate-stop point DataFrame and return it
        with cluster_id, cluster_probability, and cluster_persistence
        columns attached.

        Parameters
        ----------
        df : pd.DataFrame
            Must contain lat_col and lon_col in decimal degrees
            (WGS84). Should already be pre-filtered to stationary
            candidate points — HDBSCAN clusters spatial density, not
            temporal or velocity signals.

        Returns
        -------
        pd.DataFrame with three new columns:
            cluster_id          : int, -1 for noise
            cluster_probability : float in [0, 1], soft membership
                                   strength within the assigned cluster
            cluster_persistence : float, stability of the assigned
                                   cluster across the density hierarchy
                                   (NaN for noise points)
        """
        coords_rad = np.radians(df[[lat_col, lon_col]].to_numpy())

        clusterer = self._build_clusterer()
        labels = clusterer.fit_predict(coords_rad)

        out = df.copy()
        out["cluster_id"] = labels
        out["cluster_probability"] = clusterer.probabilities_

        persistence_by_cluster = {
            cid: float(p)
            for cid, p in enumerate(clusterer.cluster_persistence_)
        }
        out["cluster_persistence"] = out["cluster_id"].map(
            lambda cid: persistence_by_cluster.get(cid, float("nan"))
        )

        return out

    def summarize_stops(
        self,
        clustered: pd.DataFrame,
        vehicle_id_col: str = "vehicle_id",
        timestamp_col: str = "timestamp_utc",
        lat_col: str = "latitude",
        lon_col: str = "longitude",
        min_probability: float = 0.6,
    ) -> pd.DataFrame:
        """
        Aggregate clustered points into one row per discovered stop.

        min_probability filters out low-confidence cluster members
        before computing the centroid, which keeps borderline points
        (soft cluster edges) from pulling the centroid away from the
        true dense core.
        """
        confident = clustered[
            (clustered["cluster_id"] != -1)
            & (clustered["cluster_probability"] >= min_probability)
        ]

        stops = (
            confident
            .groupby([vehicle_id_col, "cluster_id"])
            .agg(
                centroid_lat=(lat_col, "median"),
                centroid_lon=(lon_col, "median"),
                arrival_time=(timestamp_col, "min"),
                departure_time=(timestamp_col, "max"),
                point_count=(lat_col, "count"),
                mean_persistence=("cluster_persistence", "mean"),
            )
            .reset_index()
        )
        stops["dwell_seconds"] = (
            stops["departure_time"] - stops["arrival_time"]
        ).dt.total_seconds()
        return stops

Usage:

clusterer = HDBSCANStopClusterer(
    min_cluster_size=5,
    min_samples=5,
    cluster_selection_epsilon=HDBSCANStopClusterer.metres_to_radians(30),
    cluster_selection_method="eom",
)

clustered = clusterer.fit(candidate_points, lat_col="latitude", lon_col="longitude")
stops = clusterer.summarize_stops(clustered, min_probability=0.6)
print(stops.sort_values("mean_persistence", ascending=False).head())

Expected output shape: fit() returns the input DataFrame with cluster_id, cluster_probability, and cluster_persistence appended, one row per input point. summarize_stops() collapses that to one row per (vehicle_id, cluster_id) pair with centroid, arrival/departure, point count, and mean persistence.

Execution and Tuning Guidelines

  • min_cluster_size — the primary density-independence knob. Because HDBSCAN evaluates stability across a range of thresholds rather than committing to one fixed radius, this parameter transfers across urban and depot-yard regions in the same fit far better than DBSCAN’s eps does. Start at 5 and inspect the resulting noise fraction; raise it if traffic-signal-length halts are surviving as spurious tiny clusters, lower it if genuine low-traffic rural stops are being discarded as noise.
  • min_samples — leave at the library default (equal to min_cluster_size) initially. Raising it independently makes the algorithm more conservative about calling anything a dense core, which is the right move if bridging points between two adjacent but distinct depot zones are causing them to merge.
  • cluster_selection_epsilon — the targeted fix for over-fragmented sparse regions. A depot yard where GPS scatter naturally spans 40-80 meters can still split into two or three micro-clusters under eom selection; setting this to metres_to_radians(30) or similar merges anything closer than that floor without touching already-well-formed dense urban clusters, which sit far below the epsilon distance already.
  • cluster_selection_method'eom' is the production default; only switch to 'leaf' for one-off exploratory analysis of a new operational region where you want to see the full fine-grained cluster structure before deciding on downstream aggregation rules.

Use cluster_persistence_ as a quality gate before auto-promoting a discovered grouping into a named location: a low-persistence grouping (roughly below 0.1-0.15 in typical fleet data) is a candidate for manual review rather than automatic promotion, since it only barely qualified as a dense region across the density hierarchy.

Common Pitfalls

Forgetting the radian conversion produces a meaningless global hierarchy

Cause: passing raw decimal-degree coordinates directly into hdbscan.HDBSCAN(metric="haversine") without first calling np.radians. The haversine metric implementation expects angular input; degrees are roughly 57 times larger than the equivalent radian value.

Symptom: nearly every point collapses into one enormous cluster spanning the entire dataset, or the fit takes drastically longer than expected because the internal distance hierarchy is operating at the wrong scale.

Fix: always convert with np.radians(df[[lat_col, lon_col]].to_numpy()) immediately before fitting, exactly as done in HDBSCANStopClusterer.fit() above. Sanity-check by confirming the haversine distance between two points known to be 100 m apart returns approximately 1.57e-05 radians.

Confusing noise (-1) with low-persistence clusters

Cause: treating every point with cluster_id == -1 as equivalent, and every non-noise point as equally trustworthy, when HDBSCAN actually provides a continuum: hard noise labels from fit_predict, soft membership strength from probabilities_, and per-cluster stability from cluster_persistence_.

Symptom: downstream reporting either discards too much legitimate data (treating every noise point as garbage, including points that were simply on the soft edge of a real but small stop) or promotes unstable, low-confidence clusters to the same operational status as well-established ones.

Fix: use cluster_probability to filter borderline members before computing centroids (as summarize_stops does with min_probability), and use cluster_persistence_ as a separate confidence gate on the whole grouping before auto-promoting a discovered location into a permanent facility record. Route low-persistence groupings to manual review rather than silently dropping or silently trusting them.

Memory and runtime blow up on large single-fit datasets

Cause: hdbscan builds a minimum spanning tree and a full condensed cluster hierarchy in memory, which scales worse than DBSCAN’s BallTree-only approach as point count grows into the millions on a single fit call.

Symptom: fit time grows non-linearly past roughly 1-2 million points on typical fleet-analytics hardware, and memory usage climbs steadily until the process is killed by the OS or orchestrator.

Fix: partition by vehicle, by day, or by a coarse spatial cell (H3 or S2, following the same pattern used for DBSCAN partitioning at scale) before fitting, running one HDBSCANStopClusterer instance per partition and merging boundary results with union-find logic afterward. For very large single-region fits, evaluate the hdbscan library’s core_dist_n_jobs parallelism setting and consider the prediction_data=True approximate-membership path instead of a full re-fit when only scoring new points against an already-fitted hierarchy.


Up: DBSCAN for Fleet Stop Clustering | Stop Detection & Dwell Time Analytics