Tuning DBSCAN eps and min_samples for stay points

eps and min_samples are usually chosen by trial and error and then quietly overfitted to one week of data. Both have principled derivations: eps from the knee of the k-distance curve, checked against the positional error floor; min_samples from the sampling rate multiplied by the minimum dwell you intend to detect. Neither derivation is exact, and both beat guessing by enough that the parameters survive a change of season.

Why this happens

DBSCAN’s two parameters interact, so tuning one at a time converges slowly and to different places depending on which was tuned first. They also mean different things at different sampling rates: min_samples=5 is five seconds of dwell at 1 Hz and two and a half minutes at one fix per thirty seconds, so the same configuration detects completely different phenomena on two fleets.

eps has a hard floor set by measurement. If positional error is 8 m, an eps below about 16 m fragments a single stationary vehicle into several clusters as its fixes scatter. It also has a soft ceiling set by geography: above the typical separation between adjacent parking areas, two stops merge. The whole range between those bounds is what the k-distance curve helps to navigate. The algorithm itself is covered in implementing DBSCAN for stay-point clustering in Python.

Core pipeline

  1. Compute the k-distance curve for k equal to your intended min_samples, sorted ascending.
  2. Locate the knee as the point of maximum curvature, then check it against the error floor and merge ceiling.
  3. Set min_samples from cadence × minimum dwell, not from a default.
  4. Sweep a small grid and choose a plateau, because a parameter that only works at one value will not survive new data.

Production-ready Python implementation

PYTHON
import numpy as np
import pandas as pd
from sklearn.neighbors import NearestNeighbors


def suggest_eps(
    xy: np.ndarray,
    min_samples: int = 5,
    position_error_m: float = 8.0,
    merge_ceiling_m: float = 70.0,
) -> dict:
    """
    Derive an eps candidate from the k-distance knee and bound-check it.

    Parameters
    ----------
    xy : np.ndarray
        (n, 2) PROJECTED metric coordinates of the candidate stationary fixes.
        Run this on slow fixes only — including motorway travel drowns the
        knee in a uniform distance distribution.
    position_error_m : float
        1-sigma horizontal error. eps below about 2x this fragments a single
        stationary vehicle, so the floor is a property of the receiver rather
        than of the tuning.

    Returns
    -------
    dict
        Suggested eps, the raw knee, and whether it violates either bound.

    Raises
    ------
    ValueError
        On too few points or wrongly shaped input.
    """
    xy = np.asarray(xy, dtype=float)
    if xy.ndim != 2 or xy.shape[1] != 2:
        raise ValueError("xy must be an (n, 2) array of projected coordinates.")
    if len(xy) <= min_samples:
        raise ValueError(f"Need more than {min_samples} points to compute a k-distance.")

    nn = NearestNeighbors(n_neighbors=min_samples).fit(xy)
    d, _ = nn.kneighbors(xy)
    kdist = np.sort(d[:, -1])                    # distance to the k-th neighbour

    # Knee by maximum distance from the chord joining the curve's endpoints —
    # the standard Kneedle construction, and robust enough here.
    i = np.arange(len(kdist), dtype=float)
    i_n = (i - i[0]) / max(i[-1] - i[0], 1e-9)
    k_n = (kdist - kdist[0]) / max(kdist[-1] - kdist[0], 1e-9)
    knee_idx = int(np.argmax(k_n - i_n))
    knee = float(kdist[knee_idx])

    floor = 2.0 * position_error_m
    suggested = float(np.clip(knee, floor, merge_ceiling_m))
    return {
        "knee_m": knee,
        "suggested_eps_m": suggested,
        "below_floor": knee < floor,
        "above_ceiling": knee > merge_ceiling_m,
        "floor_m": floor,
        "ceiling_m": merge_ceiling_m,
    }


def suggest_min_samples(median_interval_s: float, min_dwell_s: float = 180.0,
                        safety: float = 0.6) -> int:
    """
    min_samples from the sampling rate and the dwell you intend to detect.

    A stop of min_dwell_s at this cadence contains min_dwell_s / interval
    fixes. Requiring all of them is brittle — a couple will be rejected by
    quality filters — so take a fraction of that count.
    """
    if median_interval_s <= 0:
        raise ValueError("median_interval_s must be positive.")
    expected = min_dwell_s / median_interval_s
    return max(3, int(round(expected * safety)))

Validation block

PYTHON
def validate_parameters(sweep: pd.DataFrame, truth_stops: int | None = None) -> dict:
    """
    Prefer a plateau over a best cell.

    sweep : DataFrame with columns eps, min_samples, n_clusters.
    A parameter set that only works at one value has been fitted to the
    sweep rather than to the behaviour, and will drift with the next month.
    """
    piv = sweep.pivot(index="eps", columns="min_samples", values="n_clusters")

    # 1. Find the cell whose 3x3 neighbourhood varies least — that is the plateau.
    var = piv.rolling(3, center=True, min_periods=3).std().T.rolling(
        3, center=True, min_periods=3).std().T
    if var.notna().sum().sum() == 0:
        raise ValueError("Sweep grid too small to identify a plateau; widen it.")
    eps_star, ms_star = var.stack().idxmin()

    out = {"eps": float(eps_star), "min_samples": int(ms_star),
           "n_clusters": int(piv.loc[eps_star, ms_star])}

    # 2. The plateau should be within reach of the labelled count, if known.
    if truth_stops:
        err = abs(out["n_clusters"] - truth_stops) / truth_stops
        assert err < 0.25, (
            f"plateau gives {out['n_clusters']} stops against {truth_stops} "
            "labelled — the stop definition, not the parameters, is the problem"
        )
        out["count_error"] = err

    # 3. A sharp optimum is a warning sign, not a success.
    neighbourhood = piv.loc[
        piv.index[max(piv.index.get_loc(eps_star) - 1, 0):piv.index.get_loc(eps_star) + 2]
    ]
    spread = neighbourhood.std().std()
    assert spread < 0.25 * out["n_clusters"], (
        "cluster count varies sharply around the chosen cell — no plateau exists"
    )
    return out

Common mistakes and gotchas

  • Running the k-distance curve on all fixes. Motorway travel produces a uniform distance distribution that flattens the knee. Restrict to slow fixes before computing it.

  • eps below twice the positional error. A single stationary vehicle then splits into several clusters, and the symptom — too many stops — looks like eps is too large.

  • A fixed min_samples across fleets. Five samples is five seconds at 1 Hz and two and a half minutes at 30 s. Derive it from the cadence.

  • Tuning one parameter at a time. They interact; a small grid sweep costs minutes and finds the plateau that sequential tuning misses.

  • Choosing the cell that matches ground truth exactly. That is fitting the sweep. Choose the flat region, then report the residual error honestly.

  • Forgetting the radian conversion with the haversine metric. The tuned values are then meaningless — see implementing DBSCAN for stay-point clustering.

FAQ

Should min_samples change between vehicle types?

Only through the cadence. The derivation is dwell duration times sampling rate, so a fleet logging at the same rate uses the same value regardless of vehicle. What does change by type is the dwell you care about: a delivery van’s two minutes and a taxi’s forty seconds are different products.

What if the k-distance curve has no clear knee?

That is informative: it means the fixes are not separable into “clustered” and “travelling” at any radius, which usually means the input has not been filtered to slow fixes, or the positional error is comparable to the stop separation. Fix the input before continuing to tune.

Does HDBSCAN remove the need for this?

It removes eps and replaces it with min_cluster_size, which is easier to state in words. It does not remove the measurement floor — a positional error comparable to the stop radius defeats any density algorithm — and it costs more compute. For stop detection on cleaned data, tuned DBSCAN remains the pragmatic default; for route clustering the variable density genuinely needs HDBSCAN.

Back to Stay-Point Detection Algorithms