The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.
Implementation of the DISCO Metric for Internal Clustering Evaluation
R implementation of the DISCO (Density-based Internal Score for Clustering Outcomes) metric — a Cluster Validity Index (CVI) for evaluating density-based clustering results, including explicit evaluation of noise point quality. Translated from the original Python reference implementation by Beer, Krieger, Weber et al. (2025).
tests/testthat);
differences are at the level of floating-point rounding (<
1e-14).See NEWS.md for details.
discoCVI evaluates clustering quality without ground truth labels by using density-connectivity distance (dc-distance) instead of Euclidean distance. It is the first CVI that:
Traditional CVIs like Silhouette and Davies-Bouldin assume convex clusters and ignore noise. On non-convex data, they rank the wrong algorithm higher:
| Algorithm | Silhouette | Davies-Bouldin | DISCO |
|---|---|---|---|
| DBSCAN (correct) | −0.16 ❌ | 2.8 (bad) ❌ | 0.68 ✅ |
| K-Means (wrong) | 0.37 ✅ | 0.6 (good) ✅ | 0.22 ❌ |
DISCO correctly identifies DBSCAN as better on non-convex data.
# Released version from CRAN
install.packages("discoCVI")
# Development version from GitHub
# install.packages("remotes")
remotes::install_github("aminentezari/discoCVI")Dependencies (installed automatically): -
FNN — fast k-nearest neighbour search - Rcpp —
compiled minimum spanning tree (from 0.2.0)
Installing from GitHub compiles C++ code; on macOS this needs the
Xcode command line tools (xcode-select --install), on
Windows Rtools.
library(discoCVI)
library(dbscan)
# 1. Two concentric circles with a little noise
set.seed(42)
n <- 300
theta <- runif(n, 0, 2 * pi)
r <- ifelse(seq_len(n) <= n / 2, 1, 0.4)
X <- cbind(r * cos(theta), r * sin(theta)) + matrix(rnorm(2 * n, sd = 0.05), ncol = 2)
# 2. Cluster with DBSCAN and with k-means
db_labels <- dbscan(X, eps = 0.2, minPts = 5)$cluster - 1L # noise 0 -> -1
km_labels <- kmeans(X, centers = 2, nstart = 10)$cluster - 1L
# 3. Evaluate: DBSCAN should score clearly higher
disco_score(X, db_labels)
disco_score(X, km_labels)
# 4. Per-point scores: which points are badly placed?
scores <- disco_samples(X, db_labels)
summary(scores)
which(scores < 0)Noise labels. DISCO expects noise to be labelled
-1. Thedbscanpackage labels noise0(bothdbscan()andhdbscan()), so convert first:labels <- res$cluster - 1L, orlabels[labels == 0] <- -1L.
disco_score(X, labels, min_points = 5)Returns a single number summarising overall clustering quality.
| Argument | Type | Description |
|---|---|---|
X |
matrix / data.frame | n x p feature matrix |
labels |
integer vector | Cluster labels; use -1 for noise |
min_points |
integer | k for dc-distance. Default: 5 |
Returns: Single numeric in [-1, 1].
disco_samples(X, labels, min_points = 5)Returns per-point scores — useful for identifying problematic points.
scores <- disco_samples(X, labels)
bad_points <- which(scores < 0)p_cluster(X, labels, min_points, precomputed_dc_dists)Silhouette-style scores using dc-distances. -1 is
treated as a valid cluster here.
p_noise(X, labels, min_points, dc_dists)Returns list(p_sparse, p_far) for noise points only.
compute_dc_distances(X, min_points = 5)Returns the n x n DC-distance matrix directly. Useful for comparing
several labelings of the same data, but note that the matrix itself has
n² entries; disco_score() and disco_samples()
do not need it.
D <- compute_dc_distances(X)
s1 <- p_cluster(D, labels1, precomputed_dc_dists = TRUE)
s2 <- p_cluster(D, labels2, precomputed_dc_dists = TRUE)k(x) = d_euclidean(x, x_{min_points-1})
dm(x, y) = max( k(x), k(y), d_euclidean(x, y) )
dc(x, y) = max edge weight on minimax path x -> y in MST
Cluster points:
a = mean dc-distance to own cluster (excluding self)
b = min over other clusters of mean dc-distance
p(x) = (b - a) / max(a, b)
Noise points:
p_sparse = (k(n) - k_max(C)) / max(k(n), k_max(C))
p_far = (dc_min(n,C) - k_max(C)) / max(dc_min(n,C), k_max(C))
p(n) = min(p_sparse, p_far)
Overall:
DISCO = (1/n) * sum(p(x_i))
From version 0.2.0, disco_samples() and
disco_score() never store an n × n matrix:
Memory therefore grows linearly with the number of points (times the number of clusters) instead of quadratically. Time is still dominated by the O(n²) spanning tree, but without R loops or large temporary matrices.
Benchmark on synthetic data (3 Gaussian blobs + 5% noise, 2 dimensions; MacBook Pro, Apple M2 Pro, 16 GB, single thread; median of 5 runs, each in a fresh R process; peak memory = maximum resident set size of the whole R process, of which about 67 MB is R itself; energy measured with CodeCarbon, minus an R start-up baseline). Scores of the two versions are identical.
| n | time 0.1.1 | time 0.2.0 | speed-up | peak memory 0.1.1 | peak memory 0.2.0 |
|---|---|---|---|---|---|
| 500 | 0.25 s | 0.011 s | 23× | 118 MB | 91 MB |
| 1,000 | 1.0 s | 0.022 s | 46× | 200 MB | 92 MB |
| 2,000 | 4.3 s | 0.053 s | 81× | 377 MB | 89 MB |
| 4,000 | 18.6 s | 0.134 s | 138× | 1,222 MB | 93 MB |
| 16,000 | — | 1.4 s | — | 109 MB | |
| 32,000 | — | 5.3 s | — | 125 MB | |
| 100,000 | — | 50 s | — | 198 MB |
At n = 4,000, version 0.2.0 uses about 0.26 mWh instead of 45 mWh of
energy per evaluation. Full results and scripts are in
benchmarks/.
The original R version (0.1.1) is a line-by-line
translation of the Python reference (disco.py +
dctree.py). Three issues were found and fixed during the
translation:
Python includes self (dist=0) in k-NN, so effective neighbor is k-1.
FNN::get.knn excludes self:
k <- min_points - 1
knn_result <- FNN::get.knn(X, k = k)
core_dists <- knn_result$nn.dist[, k]igraph::mst breaks equal-weight edges differently than
Python’s Prim. Fixed by porting Python’s exact
_get_mst_edges() (dctree.py lines 401-438). (The
dc-distances themselves do not depend on how ties are broken, because
the largest edge on a path between two points is the same in every
minimum spanning tree.)
R and Python RNGs produce different K-Means partitions from the same seed. Fixed by exporting Python labels as CSV and loading in R:
# Python
pd.DataFrame({"km_label": km.labels_}).to_csv("python_km_labels.csv", index=False)# R
km_labels <- as.integer(read.csv("python_km_labels.csv")$km_label)After all fixes, R and Python produce identical scores to 10 decimal places:
DISCO - DBSCAN : 0.6900921813 (R == Python)
DISCO - K-Means : 0.0270091908 (R == Python)
The DISCO paper sets the score of a point that forms a cluster on its own to 0 (Section 3, “Edge cases”). Version 0.1.1 returned 1 for such points, because the mean distance to the “rest of its cluster” was taken as 0. Version 0.2.0 follows the paper. Labelings without singleton clusters are not affected.
Version 0.2.0 replaces the matrix-based computation with the merge-tree algorithm described under Performance and is tested to give the same results as 0.1.1 (apart from the singleton fix).
Eight benchmark datasets validated against the Python reference. All differences are within machine epsilon (< 1e-14).
| Dataset | n | Clusters | DBSCAN | K-Means | Match |
|---|---|---|---|---|---|
| Concentric Circles | 300 | 2 | 0.6900921813 | 0.0270091908 | R == Python |
| Two Moons | 1000 | 2 | 0.8179598377 | 0.2832395719 | R == Python |
| 3-Spiral | 312 | 3 | 0.5836514046 | -0.0014176359 | R == Python |
| Complex9 | 3031 | 9 | 0.4209464514 | 0.0551096775 | R == Python |
| Complex8 | 2551 | 8 | 0.0718687652 | 0.0122574749 | R == Python |
| Dartboard1 | 1000 | 4 | 0.8743426861 | -0.0033636711 | R == Python |
| Blobs | 450 | 3 | 0.8548953163 | 0.8575691834 | R == Python |
| Diagonal Blobs | 600 | 3 | 0.7215536253 | 0.7043253397 | R == Python |
Note: Negative K-Means scores on Dartboard1 and 3-Spiral are correct and expected — DISCO penalises algorithms that cut through ring or spiral structures.
discoCVI/
├── R/
│ ├── disco.R <- Exported functions and documentation
│ ├── disco_tree.R <- Fast merge-tree engine (0.2.0)
│ └── RcppExports.R <- Generated by Rcpp
├── src/
│ ├── prim_mrd.cpp <- Minimum spanning tree in C++ (0.2.0)
│ └── RcppExports.cpp <- Generated by Rcpp
├── tests/testthat/ <- Equivalence tests against 0.1.1
├── man/ <- Generated by devtools::document()
├── benchmarks/ <- Timing, memory and energy benchmarks
├── DESCRIPTION
├── NAMESPACE <- Generated by devtools::document()
├── NEWS.md
├── LICENSE
└── README.md
| Score | Meaning |
|---|---|
| 0.7 - 1.0 | Excellent - well-separated, compact clusters |
| 0.4 - 0.7 | Good - clear cluster structure |
| 0.0 - 0.4 | Moderate - overlapping clusters |
| -0.3 - 0.0 | Poor - misassigned points |
| -1.0 - -0.3 | Very poor - clustering worse than random |
# WRONG: dbscan() and hdbscan() use 0 for noise
labels <- dbscan(X, eps = 0.2)$cluster
# CORRECT: DISCO uses -1 for noise
labels <- dbscan(X, eps = 0.2)$cluster - 1L@inproceedings{beer2026disco,
title = {Internal Evaluation of Density-Based Clusterings with Noise},
author = {Beer, Anna and Krieger, Lena and Weber, Pascal and
Ritzert, Martin and Assent, Ira and Plant, Claudia},
booktitle = {International Conference on Learning Representations (ICLR)},
year = {2026},
note = {arXiv:2503.00127}
}R Package:
Entezari A (2026). discoCVI: Implementation of the DISCO Metric for
Internal Clustering Evaluation. R package version 0.2.0.
https://CRAN.R-project.org/package=discoCVI
MIT © Amin Entezari
Original Python implementation by Beer, Krieger, Weber, Ritzert, Assent, Plant (2025).
For questions: amin_entezari@outlook.com
These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.