--- title: "Evaluating density-based clusterings with discoCVI" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Evaluating density-based clusterings with discoCVI} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4.2, eval = requireNamespace("dbscan", quietly = TRUE)) ``` This vignette shows a complete workflow with **discoCVI**: cluster a dataset with a density-based algorithm, score the result with DISCO, use the score to choose a hyperparameter, and inspect individual points, in particular points labelled as noise. DISCO (Beer et al., 2026) is an *internal* cluster validity index: it judges a clustering using only the data and the labels, without ground truth. Unlike the silhouette coefficient, it measures distances along dense regions (density-connectivity distance), so it suits clusters of arbitrary shape, and it explicitly scores points labelled as noise. Scores lie in $[-1, 1]$; higher is better. ## Data We create two interleaving half-moons and add uniformly scattered noise points. Base R is enough; no data package is needed. ```{r data} library(discoCVI) library(dbscan) set.seed(42) make_moons <- function(n, sd = 0.08) { n1 <- n %/% 2 t1 <- runif(n1, 0, pi) t2 <- runif(n - n1, 0, pi) moons <- rbind(cbind(cos(t1), sin(t1)), cbind(1 - cos(t2), 0.5 - sin(t2))) moons + matrix(rnorm(2 * n, sd = sd), ncol = 2) } X <- rbind(make_moons(600), cbind(runif(40, -1.5, 2.5), runif(40, -1, 1.5))) dim(X) plot(X, pch = 19, cex = 0.5, asp = 1, xlab = "x1", ylab = "x2") ``` ## Scoring a clustering We cluster the data with HDBSCAN from the **dbscan** package. **Noise labels.** DISCO expects noise to be labelled `-1`. The **dbscan** package labels noise `0`, so the labels have to be converted. Passing them unchanged would silently treat all noise points as one extra cluster. ```{r hdbscan} hdb <- hdbscan(X, minPts = 10) labels_hdb <- hdb$cluster labels_hdb[labels_hdb == 0] <- -1L table(labels_hdb) disco_score(X, labels_hdb) ``` For comparison, k-means with two clusters cuts across the moons and has no notion of noise: ```{r kmeans} labels_km <- kmeans(X, centers = 2, nstart = 10)$cluster disco_score(X, labels_km) ``` DISCO prefers the density-based result, which follows the shape of the moons and separates the scattered points as noise. ## Choosing a hyperparameter Because DISCO needs no ground truth, it can be used to choose the parameters of a clustering algorithm. Here we score HDBSCAN for several values of `minPts` and keep the best one. ```{r tuning} score_minpts <- function(m) { lab <- hdbscan(X, minPts = m)$cluster lab[lab == 0] <- -1L disco_score(X, lab) } grid <- c(3, 5, 10, 15, 20, 30, 50) scores <- sapply(grid, score_minpts) data.frame(minPts = grid, DISCO = round(scores, 3)) plot(grid, scores, type = "b", pch = 19, xlab = "minPts", ylab = "DISCO score") ``` The `min_points` argument of `disco_score()` is a separate parameter: it is the neighbourhood size used *inside* DISCO to measure density (default 5, as in the original paper), and it does not need to equal the `minPts` of the clustering. ## Pointwise scores `disco_samples()` returns one score per point; `disco_score()` is their mean. Pointwise scores show where a clustering is good or bad. ```{r pointwise} s <- disco_samples(X, labels_hdb) summary(s) pal <- hcl.colors(100, "RdYlBu") col <- pal[cut(s, breaks = seq(-1, 1, length.out = 101), include.lowest = TRUE)] plot(X, col = col, pch = ifelse(labels_hdb == -1, 4, 19), cex = 0.7, asp = 1, xlab = "x1", ylab = "x2", main = "Pointwise DISCO (blue = good, red = poor; x = noise)") ``` For points labelled as noise, a high score means the point really lies in a sparse region far from every cluster; a low or negative score means it is dense or density-connected to a cluster, so it should probably have been assigned to that cluster. `p_noise()` returns the two parts of the noise score separately: ```{r noise} nz <- p_noise(X, labels_hdb) noise_scores <- data.frame(point = which(labels_hdb == -1), p_sparse = nz$p_sparse, p_far = nz$p_far) head(noise_scores[order(pmin(noise_scores$p_sparse, noise_scores$p_far)), ]) ``` The rows at the top are the noise labels DISCO considers least convincing. ## Several labelings of the same data `disco_score()` and `disco_samples()` never build an $n \times n$ matrix, so they scale to large datasets (100,000 points take well under a minute on a laptop). If you want to compare many labelings of a *small* dataset, you can also compute the density-connectivity distance matrix once and reuse it with `p_cluster()`. Keep in mind that this matrix has $n^2$ entries. ```{r reuse} D <- compute_dc_distances(X, min_points = 5) dim(D) nn <- labels_hdb != -1 summary(p_cluster(D[nn, nn], labels_hdb[nn], precomputed_dc_dists = TRUE)) ``` ## Summary A typical workflow is: 1. cluster the data, for example with `dbscan::hdbscan()`; 2. convert noise labels to `-1`; 3. compare candidate clusterings with `disco_score()`; 4. inspect `disco_samples()` and `p_noise()` to understand the chosen one. ## Reference Beer A., Krieger L., Weber P., Ritzert M., Assent I., Plant C. (2026). Internal Evaluation of Density-Based Clusterings with Noise. *International Conference on Learning Representations (ICLR)*. doi:10.48550/arXiv.2503.00127