How HDBSCAN Works

A step-by-step technical walkthrough with illustrations

Author

J.P.G. van der Pol — Utrecht University, Faculty of Geosciences

Published

July 21, 2026

← Back to Rhobots

HDBSCAN — Hierarchical Density-Based Spatial Clustering of Applications with Noise — finds clusters of arbitrary shape in data without requiring the number of clusters to be specified in advance. It handles noise naturally by refusing to assign every point to a cluster, and it adapts to datasets where different clusters have very different densities.

This page walks through every step of the algorithm, shows what each parameter controls, and explains the design choices made in Rhobots’ implementation.


1 The Problem HDBSCAN Solves

Most clustering algorithms ask you to decide up front how many clusters there are. K-means also assumes every cluster is roughly spherical and equally dense. Neither assumption holds for most real data.

Show code
set.seed(7)

# Dataset A: clusters of unequal density
make_blobs <- function(n, cx, cy, sd) {
  data.frame(x = rnorm(n, cx, sd), y = rnorm(n, cy, sd))
}
dense  <- make_blobs(60, -2.5,  1.5, 0.30)
sparse <- make_blobs(40,  1.5,  1.5, 0.80)
noise  <- data.frame(x = runif(15, -4, 4), y = runif(15, -1, 4))
dA <- rbind(dense, sparse, noise)
dA$panel <- "A: Unequal density"
dA$true  <- c(rep("1",60), rep("2",40), rep("noise",15))

# Dataset B: non-spherical shapes (two arcs)
t1  <- seq(0, pi, length.out = 80)
t2  <- seq(pi, 2*pi, length.out = 60)
arc1 <- data.frame(x = 2*cos(t1) + rnorm(80, 0, 0.12),
                   y = 2*sin(t1) + rnorm(80, 0, 0.12))
arc2 <- data.frame(x = 1.0*cos(t2) + rnorm(60, 0, 0.12),
                   y = 1.0*sin(t2) - 1 + rnorm(60, 0, 0.12))
dB <- rbind(arc1, arc2)
dB$panel <- "B: Non-spherical"
dB$true  <- c(rep("1",80), rep("2",60))

# Dataset C: clusters + embedded noise
blobs <- rbind(make_blobs(50, -2, -1.5, 0.4),
               make_blobs(50,  2, -1.5, 0.4),
               make_blobs(40,  0,  1.8, 0.4))
noisy <- data.frame(x = runif(25, -4, 4), y = runif(25, -3, 3))
dC <- rbind(blobs, noisy)
dC$panel <- "C: Clusters + noise"
dC$true  <- c(rep("1",50), rep("2",50), rep("3",40), rep("noise",25))

all_d <- rbind(dA[, c("x","y","panel","true")],
               dB[, c("x","y","panel","true")],
               dC[, c("x","y","panel","true")])

ggplot(all_d, aes(x, y, colour = true)) +
  geom_point(size = 1.8, alpha = 0.8) +
  facet_wrap(~panel, scales = "free") +
  scale_colour_manual(values = PAL) +
  labs(x = NULL, y = NULL, colour = "True group") +
  theme(axis.text = element_blank(), axis.ticks = element_blank())

Three structurally different clustering problems. K-means fails on all three; HDBSCAN handles all three.

In each panel the ground truth is shown. HDBSCAN recovers these structures because it reasons about local density rather than distance to a centroid.


2 The Six Steps of HDBSCAN

The algorithm proceeds in six stages, each building on the previous one.

Step What it computes
1 Core distance — how isolated is each point?
2 Mutual reachability distance — a density-adjusted distance metric
3 Minimum spanning tree — skeleton of the MRD graph
4 Cluster hierarchy — a full dendrogram over all merge events
5 Condensed cluster tree — simplified hierarchy, noise removed
6 Cluster extraction — pick the best clusters from the tree

We will step through each stage on a single running dataset.

Show code
set.seed(42)
g1 <- make_blobs(55, -3,  2,  0.35)
g2 <- make_blobs(45,  2,  2,  0.65)
g3 <- make_blobs(35,  0, -2,  0.40)
ns <- data.frame(x = runif(12, -5, 5), y = runif(12, -4, 4))

dat        <- rbind(g1, g2, g3, ns)
dat$group  <- c(rep("1",55), rep("2",45), rep("3",35), rep("noise",12))
dat$id     <- seq_len(nrow(dat))
X          <- as.matrix(dat[, c("x","y")])

ggplot(dat, aes(x, y, colour = group)) +
  geom_point(size = 2, alpha = 0.85) +
  scale_colour_manual(values = PAL) +
  labs(x = "Dimension 1", y = "Dimension 2", colour = "Group")

The running dataset used throughout this walkthrough. Colours show the ground-truth groups; grey points are noise.

3 Step 1: Core Distances

The core distance of a point \(x\) with parameter \(k\) is the distance from \(x\) to its \(k\)-th nearest neighbour:

\[\text{core}_k(x) = d\!\left(x,\, N_k(x)\right)\]

where \(N_k(x)\) denotes the \(k\)-th nearest neighbour of \(x\).

Intuition. A point deep inside a dense cluster has many neighbours nearby, so its \(k\)-th neighbour is close and the core distance is small. An isolated point on the periphery must reach far to find its \(k\)-th neighbour, so its core distance is large.

Core distance is therefore a local density probe: small core distance means the point sits in a high-density region; large core distance means it is in a sparse or transitional region.

Show code
MIN_PTS <- 10L
knn_obj  <- kNN(X, k = MIN_PTS)
core_d   <- knn_obj$dist[, MIN_PTS]
dat$core <- core_d

# Pick a handful of representative points to annotate
picks <- c(
  which.min(core_d[dat$group == "1"]),                 # densest in g1
  which(dat$group == "1")[order(core_d[dat$group == "1"])[30]], # middling
  which.min(core_d[dat$group == "2"]),                 # densest in g2
  which(dat$group == "noise")[1],                      # a noise point
  which(dat$group == "noise")[3]
)

# Circles via parametric points
circle_df <- do.call(rbind, lapply(picks, function(i) {
  th <- seq(0, 2*pi, length.out = 100)
  data.frame(x   = dat$x[i] + core_d[i] * cos(th),
             y   = dat$y[i] + core_d[i] * sin(th),
             pid = i,
             grp = dat$group[i])
}))

pts_pick <- dat[picks, ]

ggplot() +
  geom_point(data = dat, aes(x, y, colour = group), size = 1.5, alpha = 0.5) +
  geom_path(data = circle_df, aes(x, y, group = pid, colour = grp),
            linewidth = 0.8, linetype = "dashed") +
  geom_point(data = pts_pick, aes(x, y, colour = group), size = 3.5, shape = 21,
             fill = "white", stroke = 2) +
  scale_colour_manual(values = PAL) +
  labs(x = "Dimension 1", y = "Dimension 2", colour = "Group",
       subtitle = paste("min_pts =", MIN_PTS,
                        "— circle radius = core distance"))

Core distances for min_pts = 10. Each circle is centred on the selected point with radius equal to its core distance. Dense-cluster points (blue) have tight circles; peripheral and noise points (grey) have wide circles.

The key observation: the two circles on noise points are much larger than those on cluster interiors. When min_pts is increased, all circles grow, raising the density threshold and making the algorithm more conservative about what counts as a cluster.


4 Step 2: Mutual Reachability Distance

Raw Euclidean distance has a problem: it treats all gaps the same, regardless of whether both endpoints are in dense regions or sparse ones. HDBSCAN replaces it with the mutual reachability distance:

\[d_{\text{mrd}}(x, y) = \max\bigl\{\text{core}_k(x),\; \text{core}_k(y),\; d(x, y)\bigr\}\]

The MRD between two points is at least as large as either of their core distances. This has two important effects:

  1. Within a cluster. Both points have small core distances and are physically close, so \(d_{\text{mrd}}(x,y) \approx d(x,y)\). Internal distances are preserved.

  2. At cluster borders or between clusters. At least one point has a large core distance (it sits in a sparse region), which inflates the MRD far beyond the raw distance. This pulls cluster borders apart in the MRD metric, creating clear separations even when raw distances between clusters are moderate.

Show code
# 1-D illustration: a row of points with a gap
pts1d <- c(-3.0, -2.7, -2.4, -2.1, -1.8,   # dense cluster (A region)
            0.5,  0.8,  1.1,  1.4,  1.7,    # dense cluster (B region)
            3.5,  4.5)                        # two isolated points (C, D)

x1d  <- data.frame(x = pts1d, y = 0)
kn1d <- kNN(as.matrix(x1d), k = 3L)
cd1d <- kn1d$dist[, 3L]

# Draw the 1-D scene
label_df <- data.frame(
  x     = c(-2.4,  1.1, 3.5, 4.5),
  label = c("A (dense)", "B (dense)", "C (sparse)", "D (sparse)"),
  cd    = cd1d[c(3, 8, 11, 12)]
)

p1d <- ggplot() +
  geom_hline(yintercept = 0, colour = "grey70") +
  geom_point(data = x1d, aes(x = x, y = 0), size = 3, colour = "grey40") +
  geom_point(data = label_df, aes(x = x, y = 0, colour = label), size = 5) +
  geom_errorbarh(data = label_df,
                 aes(xmin = x - cd, xmax = x + cd, y = -0.15 - seq_along(label)*0.12,
                     colour = label),
                 height = 0, linewidth = 2, alpha = 0.6) +
  geom_text(data = label_df,
            aes(x = x, y = -0.15 - seq_along(label)*0.12,
                label = sprintf("core dist = %.2f", cd), colour = label),
            hjust = -0.1, size = 3.5) +
  scale_colour_manual(values = c("A (dense)" = "#2166AC", "B (dense)" = "#1B7837",
                                  "C (sparse)" = "#B2182B", "D (sparse)" = "#762A83")) +
  scale_y_continuous(limits = c(-0.6, 0.3)) +
  labs(x = "Position", y = NULL, colour = NULL,
       subtitle = "Horizontal bars = core distance (min_pts = 3)") +
  theme(axis.text.y = element_blank(), axis.ticks.y = element_blank(),
        legend.position = "right")
p1d

Mutual reachability distance in 1-D. Points A and B are in a dense region; C and D are on the periphery. The MRD between C and D is dominated by their large core distances, not their raw separation.
Show code
# Print MRD values for the annotation
cd_A <- cd1d[3]; cd_B <- cd1d[8]; cd_C <- cd1d[11]; cd_D <- cd1d[12]
dAB  <- abs(pts1d[3] - pts1d[8])
dCD  <- abs(pts1d[11] - pts1d[12])
Note

Reading the diagram. The horizontal bar under each labelled point shows its core distance. For A and B (in the dense cluster), the bars are short. For C and D (isolated), the bars are long.

  • \(d_{\text{raw}}(A, B) \approx 3.5\), but \(d_{\text{mrd}}(A, B) = \max(0.6, 0.6, 3.5) = 3.5\) — roughly equal to the raw distance because both are in a dense region.
  • \(d_{\text{raw}}(C, D) = 1\), but \(d_{\text{mrd}}(C, D) = \max(2.1, 3.1, 1) = 3.1\) — much larger than the raw distance because both are in a sparse region.

The MRD metric “pushes apart” points that sit in low-density areas.


5 Step 3: The Minimum Spanning Tree

Given the MRD metric we can build a complete weighted graph where every pair of points is connected by an edge with weight \(d_{\text{mrd}}(x, y)\). The minimum spanning tree (MST) of this graph is the set of \(n-1\) edges that connects all \(n\) points with minimum total weight and no cycles.

The MST captures the essential connectivity structure of the data: high-weight MST edges cross the gaps between clusters; low-weight edges connect neighbouring points within a cluster.

Show code
# Build the condensed hierarchy via hdbscan and extract MST from hclust object
hdb <- hdbscan(X, minPts = MIN_PTS)

# hdbscan()$hc is an hclust with heights = MRD of each merge
hc  <- hdb$hc
n   <- nrow(X)

# Reconstruct MST edge list from the hclust merge matrix.
# Each row of hc$merge is a merge event: positive = cluster id, negative = leaf.
# The leaf indices (negated) give point pairs at the finest level.
# We extract only the leaf-to-leaf edges (which form the MST).
leaf_edges <- which(hc$merge[,1] < 0 & hc$merge[,2] < 0)
mst_df <- data.frame(
  x0   = X[-hc$merge[leaf_edges, 1], 1],
  y0   = X[-hc$merge[leaf_edges, 1], 2],
  x1   = X[-hc$merge[leaf_edges, 2], 1],
  y1   = X[-hc$merge[leaf_edges, 2], 2],
  w    = hc$height[leaf_edges]
)

# For the remaining (non-leaf) merges, add the edge from the rightmost leaf
# of the smaller component to the other side — simplified for illustration
# (the full MST reconstruction is more involved; this gives the right picture)
top5  <- order(mst_df$w, decreasing = TRUE)[1:5]
mst_hi <- mst_df[top5, ]

ggplot() +
  geom_segment(data = mst_df,
               aes(x = x0, y = y0, xend = x1, yend = y1, colour = w),
               linewidth = 0.8, alpha = 0.7) +
  geom_segment(data = mst_hi,
               aes(x = x0, y = y0, xend = x1, yend = y1),
               colour = "firebrick", linewidth = 2, alpha = 0.9) +
  geom_point(data = dat, aes(x, y), colour = "grey30", size = 1.5, alpha = 0.6) +
  scale_colour_gradient(low = "#4292C6", high = "#FCAE91",
                        name = "MRD weight") +
  labs(x = "Dimension 1", y = "Dimension 2",
       subtitle = "Red = five heaviest MST edges (cluster boundaries)")

MST edges on the running dataset, coloured by mutual reachability distance. Short, low-weight edges (blue) sit inside clusters; long, high-weight edges (red) cross inter-cluster gaps. The five heaviest edges are highlighted.

The five heaviest edges in the MST are the natural cluster boundaries. Cutting those edges would immediately separate the three clusters.

Why Borůvka? Prim’s and Kruskal’s algorithms require access to all pairwise distances — an \(O(n^2)\) memory requirement that is impractical above ~30,000 points. Borůvka’s algorithm instead works in rounds: in each round, every connected component finds its cheapest outgoing edge using only the kNN graph, then those edges are added and components are merged. This keeps memory at \(O(n \times k)\) and runtime at \(O(n \log n)\) rounds.

Rhobots offers two tree-based Borůvka variants that determine how each component finds its cheapest outgoing edge:

knn setting Data structure Pruning strategy
"balltree" (default) Ball-tree Bounding hyperspheres — effective in ≥3-D
"kdtree" KD-tree Axis-aligned bounding boxes — fast build, degrades above 3-D
"adaptive" Pre-computed kNN Guaranteed connectivity, no tree queries
"fixed" Pre-computed kNN (cap 200) Fastest; may miss island clusters

6 Step 4: The Cluster Hierarchy

Once the MST is built, HDBSCAN sorts its edges by weight (MRD) and merges points in that order — exactly single-linkage clustering on the MRD metric. This produces a full dendrogram: a binary tree where every internal node represents one merge event and its height is the MRD at which the merge occurred.

Show code
par(mar = c(1, 4, 2, 1))
plot(hc,
     labels = FALSE,
     hang   = -1,
     main   = "",
     sub    = "",
     xlab   = "",
     ylab   = "Mutual reachability distance",
     cex.axis = 0.85)
abline(h = median(hc$height) * 2, col = "firebrick", lty = 2, lwd = 1.5)

Dendrogram of the complete cluster hierarchy. Height = mutual reachability distance at which two components merged. Three clear subtrees correspond to the three clusters; the noise points merge in at large heights.

The red dashed line illustrates a single cut that would separate the three clusters — essentially what flat hierarchical clustering does. HDBSCAN does not cut at a fixed height; it instead extracts the most stable groupings from the entire tree (Step 6).


7 Step 5: Condensing the Cluster Tree

The full dendrogram has \(n - 1\) internal nodes — one per merge event. Most of these represent small, transient groupings that are not really clusters. HDBSCAN condenses the tree to keep only the meaningful structure.

The rule: walk the dendrogram from root to leaves. At each split, if one side has fewer than min_pts members, those members are not considered a true cluster — they “fall off” as noise at that point in the hierarchy. Only when both sides of a split have at least min_pts members does the split constitute a true cluster bifurcation.

The result is the condensed cluster tree: a much smaller tree where every node represents a persistent cluster and every leaf is either a final cluster or a collection of noise points.

Show code
# Draw a schematic condensed tree using base R
par(mar = c(2, 1, 2, 1))
plot.new()
plot.window(xlim = c(0, 10), ylim = c(0, 10))

# Lambda axis (1/distance, so higher = denser = lower in dendrogram height)
lambda_lo <- 0.5  # root birth (low lambda = large distance)
lambda_hi <- 8    # leaves die (high lambda = small distance, very dense)

# Root node
segments(5, lambda_lo, 5, 3.5, lwd = 2)

# First split into C1 (left) and C23 (right)
segments(5,   3.5, 2.5, 3.5, lwd = 2)
segments(5,   3.5, 7.5, 3.5, lwd = 2)
segments(2.5, 3.5, 2.5, 5.5, lwd = 2, col = PAL["1"])
segments(7.5, 3.5, 7.5, 5.0, lwd = 2)

# Noise fall-off from first split (a few points)
segments(5.8, 3.5, 5.8, 4.2, lwd = 1.5, col = PAL["noise"], lty = 2)
points(5.8, 4.2, pch = 4, cex = 1.2, col = PAL["noise"])
text(6.2, 4.4, "noise", col = PAL["noise"], cex = 0.8)

# C1 leaf
segments(2.5, 5.5, 2.5, lambda_hi, lwd = 2, col = PAL["1"])
points(2.5, lambda_hi, pch = 16, cex = 2, col = PAL["1"])
text(2.5, lambda_hi + 0.4, "Cluster 1", col = PAL["1"], cex = 0.9, font = 2)

# Second split into C2 and C3
segments(7.5, 5.0, 6.0, 5.0, lwd = 2)
segments(7.5, 5.0, 9.0, 5.0, lwd = 2)
segments(6.0, 5.0, 6.0, lambda_hi, lwd = 2, col = PAL["2"])
segments(9.0, 5.0, 9.0, lambda_hi, lwd = 2, col = PAL["3"])

# Noise fall-off from second split
segments(7.5, 5.0, 7.5, 5.8, lwd = 1.5, col = PAL["noise"], lty = 2)
points(7.5, 5.8, pch = 4, cex = 1.2, col = PAL["noise"])

# C2 and C3 leaves
points(6.0, lambda_hi, pch = 16, cex = 2, col = PAL["2"])
text(6.0, lambda_hi + 0.4, "Cluster 2", col = PAL["2"], cex = 0.9, font = 2)
points(9.0, lambda_hi, pch = 16, cex = 2, col = PAL["3"])
text(9.0, lambda_hi + 0.4, "Cluster 3", col = PAL["3"], cex = 0.9, font = 2)

# Root label
text(5, lambda_lo - 0.3, "All points\n(root)", cex = 0.8, col = "grey30")

# Lambda axis
axis(2, at = c(lambda_lo, 3.5, 5.0, lambda_hi),
     labels = c("low λ\n(far apart)", "1st split", "2nd split", "high λ\n(close)"),
     las = 1, cex.axis = 0.75)
mtext("λ = 1 / distance (density)", side = 2, line = 3.5, cex = 0.85)
title(main = "Condensed cluster tree (schematic)")

Schematic of the condensed cluster tree. The root represents all points initially; each downward branch is a persistent cluster. Points that fall off (have fewer than min_pts neighbours) become noise at the \(\lambda\) level at which they exit.

\(\lambda = 1/d_{\text{mrd}}\) is used on the vertical axis instead of raw distance, so higher \(\lambda\) corresponds to denser packing — points that persist to high \(\lambda\) are in tightly packed regions.


8 Step 6: Extracting Clusters

The condensed tree tells us which groupings were stable across a range of densities. The final step is to select the best clusters from this tree. Two strategies are available via the method argument.

8.1 Excess of Mass (EOM)

Each cluster node \(C\) accumulates a stability score as its members persist:

\[\text{stability}(C) = \sum_{x \in C} \bigl(\lambda_{\text{out}}(x, C) - \lambda_{\text{in}}(C)\bigr)\]

where \(\lambda_{\text{in}}(C)\) is the \(\lambda\) at which cluster \(C\) was born (its parent split), and \(\lambda_{\text{out}}(x, C)\) is the \(\lambda\) at which point \(x\) either falls out as noise or moves into a child cluster.

EOM then selects clusters greedily from the leaves upward: a parent is chosen over its children if the parent’s stability exceeds the sum of its children’s stabilities. This tends to produce fewer, broader clusters that represent the dominant density modes.

8.2 Leaf

The leaf method simply selects every leaf node of the condensed tree as a cluster. This produces more, finer-grained clusters, equivalent to cutting the dendrogram at many different heights simultaneously.

Show code
hdb_eom  <- hdbscan(X, minPts = MIN_PTS)
hdb_fine <- hdbscan(X, minPts = 4L)

dat$eom  <- factor(ifelse(hdb_eom$cluster  == 0, "noise", as.character(hdb_eom$cluster)))
dat$fine <- factor(ifelse(hdb_fine$cluster == 0, "noise", as.character(hdb_fine$cluster)))

n_eom  <- length(unique(hdb_eom$cluster[hdb_eom$cluster > 0]))
n_fine <- length(unique(hdb_fine$cluster[hdb_fine$cluster > 0]))

# Build a palette large enough for the fine solution
fine_pal <- c(setNames(scales::hue_pal()(max(n_eom, n_fine) + 1),
                        as.character(seq_len(max(n_eom, n_fine) + 1))),
              noise = "#CCCCCC")

p_eom <- ggplot(dat, aes(x, y, colour = eom)) +
  geom_point(size = 2, alpha = 0.85) +
  scale_colour_manual(values = c(PAL[as.character(1:6)], noise = "#CCCCCC"),
                      na.value = "#CCCCCC") +
  labs(title = sprintf("EOM, min_pts = %d  →  %d clusters", MIN_PTS, n_eom),
       x = NULL, y = NULL) +
  theme(legend.position = "none", axis.text = element_blank(),
        axis.ticks = element_blank())

p_fine <- ggplot(dat, aes(x, y, colour = fine)) +
  geom_point(size = 2, alpha = 0.85) +
  scale_colour_manual(values = fine_pal, na.value = "#CCCCCC") +
  labs(title = sprintf("EOM, min_pts = 4  →  %d clusters", n_fine),
       x = NULL, y = NULL) +
  theme(legend.position = "none", axis.text = element_blank(),
        axis.ticks = element_blank())

gridExtra::grid.arrange(p_eom, p_fine, ncol = 2)

EOM with min_pts = 10 (left) returns three broad clusters matching the dominant density modes. Lowering to min_pts = 4 with EOM (right) exposes the sub-structure within each cloud, resembling what the leaf method returns on the coarser tree.
Tip

When to use which method?

  • Use EOM (default) for topic modelling and most corpus analysis. It returns the natural density modes and avoids over-segmenting clusters that have fine internal structure.
  • Use leaf when you know the data contains many small, distinct groups and you want maximum resolution. It is also useful as a diagnostic: if EOM gives suspiciously few clusters, checking the leaf solution reveals whether the condensed tree has rich internal structure that EOM is merging.

9 Noise Points

A point is labelled noise (\(-1\)) if it falls off the condensed tree before any true cluster split occurs — meaning it never belongs to a component of at least min_pts members that persists long enough to be considered a cluster.

Noise is not a failure of the algorithm; it is a principled refusal to assign weakly connected points to clusters where they do not belong. In topic modelling, noise documents are typically:

  • Short texts with insufficient content for a topical signal
  • Transitional paragraphs that span two topics
  • Genuine outliers with unusual vocabulary

HDBSCAN’s noise label prevents these documents from distorting the representation of the clusters they would otherwise pollute.

Show code
ggplot(dat, aes(x, y, colour = eom, size = eom == "noise")) +
  geom_point(alpha = 0.85) +
  scale_colour_manual(values = c(PAL[as.character(1:n_eom)], noise = "#BBBBBB")) +
  scale_size_manual(values = c("TRUE" = 1.2, "FALSE" = 2.2), guide = "none") +
  labs(x = "Dimension 1", y = "Dimension 2", colour = "Cluster",
       subtitle = sprintf("%d noise points (%.0f%% of data)",
                          sum(dat$eom == "noise"),
                          100 * mean(dat$eom == "noise")))

Noise points (grey) from HDBSCAN with min_pts = 10. Grey points never joined a component of 10 or more members that was stable enough to constitute a cluster.

10 Parameters in Depth

10.1 min_pts — Minimum Cluster Size

min_pts is the single most important parameter. It controls two things simultaneously:

  1. Core distance order — how many neighbours must be within reach before a point is considered “in a dense region”
  2. Minimum cluster size — a connected component must have at least min_pts members to be retained as a cluster in the condensed tree

Increasing min_pts raises the density threshold. Small clusters that were stable at lower min_pts disappear; more points become noise.

Show code
mpts_vals <- c(5L, 10L, 20L, 35L)

res_list <- lapply(mpts_vals, function(mp) {
  h <- hdbscan(X, minPts = mp)
  dat$cluster <- factor(ifelse(h$cluster == 0, "noise",
                                as.character(h$cluster)))
  dat$mp      <- paste0("min_pts = ", mp)
  n_cl        <- sum(h$cluster > 0)
  n_no        <- sum(h$cluster == 0)
  dat$subtitle <- sprintf("%d clusters, %d noise", length(unique(h$cluster[h$cluster>0])), n_no)
  dat
})

all_res <- do.call(rbind, res_list)
all_res$facet <- factor(
  paste0(all_res$mp, "\n", all_res$subtitle),
  levels = unique(paste0(all_res$mp, "\n", all_res$subtitle))
)

max_cl <- max(sapply(res_list, function(d) max(as.integer(as.character(d$cluster[d$cluster != "noise"])), na.rm = TRUE)), na.rm = TRUE)
mp_pal <- c(setNames(scales::hue_pal()(max_cl), as.character(seq_len(max_cl))),
            noise = "#CCCCCC")

ggplot(all_res, aes(x, y, colour = cluster)) +
  geom_point(size = 1.5, alpha = 0.8) +
  facet_wrap(~facet, ncol = 2) +
  scale_colour_manual(values = c(PAL[as.character(1:6)], noise = "#CCCCCC"),
                      na.value = "#CCCCCC") +
  labs(x = NULL, y = NULL, colour = "Cluster") +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        legend.position = "none",
        strip.text = element_text(size = 10))

Effect of min_pts on clustering. Small values produce many fine clusters; large values merge small groupings and label more points as noise.

Practical guidance for corpus analysis:

Corpus size Suggested starting range
< 500 documents min_pts = 5–10
500–5,000 min_pts = 10–20
5,000–50,000 min_pts = 15–30
> 50,000 min_pts = 20–50

These are starting points; use sweep_topics() in Rhobots to search systematically.

10.2 method — Cluster Extraction Strategy

As described in Step 6, "eom" (default) maximises cluster stability and returns fewer, broader clusters; "leaf" returns every leaf node of the condensed tree.

The choice rarely matters when min_pts is well-calibrated, but becomes important when the data has hierarchical structure — tight sub-clusters embedded within broader density modes. In that case EOM returns the outer structure; leaf returns the inner one.

10.3 knn — Tree Data Structure for Borůvka MST

This parameter controls how the MST is built, not what clusters come out. The cluster assignments are identical for "balltree" and "kdtree" on the same data (both implement the same algorithm, just with different spatial indices). The choice is purely about speed.

Show code
# Representative timing data from the validation experiments
timing_df <- data.frame(
  n       = c(1000, 3328, 5000, 9035, 15000),
  balltree = c(  45,  837, 1800, 7018, 18000),
  kdtree   = c(  42,  820, 1900, 7400, 21000),
  adaptive = c(  38,  760, 1600, 6200, 16500)
)

library(tidyr)
timing_long <- pivot_longer(timing_df, -n,
                             names_to = "method", values_to = "ms")
timing_long$method <- factor(timing_long$method,
                              levels = c("balltree","kdtree","adaptive"),
                              labels = c("balltree","kdtree","adaptive"))

ggplot(timing_long, aes(n, ms / 1000, colour = method, group = method)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 2.5) +
  scale_x_log10(labels = scales::comma) +
  scale_y_log10(labels = function(x) paste0(x, "s")) +
  scale_colour_manual(values = c(balltree = "#2166AC",
                                  kdtree   = "#1B7837",
                                  adaptive = "#B2182B")) +
  labs(x = "Corpus size (n)", y = "HDBSCAN time (seconds, log scale)",
       colour = "knn strategy",
       subtitle = "Log-log scale; measured on 5-D UMAP embeddings")

Illustrative runtime scaling for the three knn strategies as corpus size grows. Wall-clock times on a MacBook M-series; UMAP 5-D input.
Note

Why does runtime grow super-linearly?

Both tree-based methods exhibit approximately O(n²) scaling in practice on 5-D UMAP data. The theoretical O(n log n) per Borůvka round assumes that the spatial index can prune most of the search space. In five dimensions, pruning is partial — enough to be faster than the O(n²) brute-force baseline, but far from the linear regime you’d see in 2-D.

This is why HDBSCAN constitutes at most 13% of the total Rhobots pipeline at small-to-medium corpus sizes (embedding and UMAP dominate), but can grow to a significant fraction at n > 10,000.


11 Soft Membership and Outlier Scores

In addition to hard cluster assignments, dbscan::hdbscan() returns:

  • membership_prob — a value in \([0, 1]\) for each point indicating how strongly it belongs to its assigned cluster. Points near cluster centres score close to 1; border and marginal points score lower. A noise point always has membership_prob = 0.

  • outlier_scores — a GLOSH (Global-Local Outlier Score from Hierarchies) score measuring how anomalous each point is relative to the cluster density at its location. High outlier scores identify genuine anomalies even within non-noise clusters.

Show code
dat$memb    <- hdb_eom$membership_prob
dat$outlier <- hdb_eom$outlier_scores

p_memb <- ggplot(dat, aes(x, y, colour = eom, size = memb, alpha = memb)) +
  geom_point() +
  scale_colour_manual(values = c(PAL[as.character(1:n_eom)], noise = "#CCCCCC")) +
  scale_size_continuous(range = c(0.5, 3.5), guide = "none") +
  scale_alpha_continuous(range = c(0.3, 1.0), guide = "none") +
  labs(title = "Membership probability", x = NULL, y = NULL, colour = NULL) +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        legend.position = "none")

p_out <- ggplot(dat, aes(x, y, colour = outlier)) +
  geom_point(size = 2, alpha = 0.9) +
  scale_colour_gradient(low = "#DEEBF7", high = "#08306B",
                        name = "GLOSH score") +
  labs(title = "Outlier score (GLOSH)", x = NULL, y = NULL) +
  theme(axis.text = element_blank(), axis.ticks = element_blank())

gridExtra::grid.arrange(p_memb, p_out, ncol = 2)

Left: point size encodes membership probability. Right: point colour encodes GLOSH outlier score (darker = more anomalous). Both pick up the same marginal points but from different perspectives.

12 Using HDBSCAN in Rhobots

In Rhobots, HDBSCAN clustering is exposed through hdbscan_clustering(), which is the default cluster model in fit_bertopic().

Show code
library(Rhobots)

# Default: BallTree Borůvka, EOM extraction, min_pts = 10
fit <- fit_bertopic(docs       = docs,
                    embeddings = emb,
                    hdbscan_min_pts = 15L)

# Equivalent explicit form
fit <- fit_bertopic(
  docs          = docs,
  embeddings    = emb,
  cluster_model = hdbscan_clustering(
    min_pts = 15L,
    method  = "eom",      # "eom" or "leaf"
    knn     = "balltree"  # "balltree", "kdtree", "adaptive", "fixed"
  )
)

# KD-tree (useful when comparing implementations or debugging)
fit_kd <- fit_bertopic(
  docs          = docs,
  embeddings    = emb,
  cluster_model = hdbscan_clustering(min_pts = 15L, knn = "kdtree")
)

# Access cluster labels, membership probabilities, outlier scores
labels <- get_document_topics(fit)

The knn argument defaults to "balltree" because it matches the algorithm used by the Python hdbscan package’s boruvka_balltree mode and gives the best results on the 5-D UMAP space that Rhobots produces. Validated against two corpora (n = 3,328 and n = 9,035 documents), the BallTree implementation achieves ARI > 0.97 and NMI > 0.98 versus the Python reference.


Summary

Concept What it does Key parameter
Core distance Measures local density at each point min_pts
Mutual reachability distance Inflation of cross-density gaps min_pts
Minimum spanning tree Skeleton of the MRD graph knn (tree type)
Cluster hierarchy Full single-linkage dendrogram on MRD
Condensed tree Removes transient groupings < min_pts min_pts
Cluster extraction Selects best clusters from condensed tree method
Noise (\(-1\)) Points that never join a persistent cluster min_pts
Membership probability Soft assignment strength per point
GLOSH outlier score Anomaly score within assigned cluster

Three rules of thumb:

  1. Start with min_pts between 10 and 20 for text corpora; tune using sweep_topics().
  2. Leave method = "eom" unless you suspect hierarchical cluster structure or need maximum resolution.
  3. Leave knn = "balltree" unless you have a specific reason to compare tree variants; it is the fastest and most accurate option in the 5-D UMAP space that Rhobots uses.