| Title: | Parallel and Deterministic Leiden Community Detection |
|---|---|
| Description: | A parallel and deterministic implementation of the Leiden community detection algorithm (Traag, Waltman and van Eck 2019, <doi:10.1038/s41598-019-41695-z>) for large sparse graphs such as single-cell kNN and SNN graphs, implemented in Rust. The same seed gives the same labels at any number of threads. The parallel design follows GVE-Leiden (Sahu et al. 2024, <doi:10.1145/3673038.3673146>). Works on sparse matrices, Seurat graphs and igraph graphs, and as a bluster clustering function. |
| Authors: | Ben Carrillo [aut, cre] (ORCID: <https://orcid.org/0009-0003-5704-4151>) |
| Maintainer: | Ben Carrillo <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.99.0 |
| Built: | 2026-10-21 00:56:13 UTC |
| Source: | https://github.com/btraven00/clust-libreville-rs |
How well do these labels describe this graph, with the labels taken as fixed (an annotation, a clustering from another modality)? Every quantity is a closed form of the block edge-count matrix, one pass over the edges.
assess(graph, membership, check_symmetry = TRUE)assess(graph, membership, check_symmetry = TRUE)
graph |
An undirected graph: a symmetric sparse matrix (e.g. an SNN graph's adjacency) or an igraph graph. |
membership |
Labels, one per vertex (any atomic type). |
check_symmetry |
Validate symmetry ( |
loglik: the degree-corrected SBM profile log-likelihood with the labels
as blocks (Karrer & Newman 2011), the statistic of BESTest (Peel,
Larremore & Clauset 2017), whose p-value is a permutation of the labels
the caller runs.
description_length: Peixoto's (2017) microcanonical DC-SBM description
length with uniform priors, in nats, as graph-tool's
BlockState.entropy(degree_dl_kind = "uniform"). It charges for the
block matrix and the partition, so it ranks nested annotations: the
shorter, the better the graph supports that level.
significance: Traag, Krings & Van Dooren (2013), nats; leidenalg's
SignificanceVertexPartition.quality().
surprise: per label, -ln P(X >= inner edges) with X Poisson at the
configuration-model expectation: the label's cohesion beyond chance
(Lancichinetti, Radicchi & Ramasco 2010, edge-count form).
Exact on unweighted graphs; weights count as multiplicities.
A list: loglik, description_length, significance (numbers);
labels (the distinct labels, in order of first appearance) and, per
label, surprise, size, degree_sum, inner_edges.
Peel, Larremore & Clauset (2017) Sci. Adv. 3:e1602548; Peixoto (2017) Phys. Rev. E 95:012317; Traag, Krings & Van Dooren (2013) Sci. Rep. 3:2930; Karrer & Newman (2011) Phys. Rev. E 83:016107; Lancichinetti, Radicchi & Ramasco (2010) Phys. Rev. E 81:046110.
f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) a <- assess(g, leiden(g)) a$description_lengthf <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) a <- assess(g, leiden(g)) a$description_length
Experimental: the interface and the scores may change.
champ( graph, resolutions = 10^seq(-1, 1, length.out = 20), seeds = 0:4, gamma_range = NULL, n_iterations = -1L, theta = 0.01, min_gain = 0, threads = NULL, check_symmetry = TRUE, metric = c("nvi", "ami") )champ( graph, resolutions = 10^seq(-1, 1, length.out = 20), seeds = 0:4, gamma_range = NULL, n_iterations = -1L, theta = 0.01, min_gain = 0, threads = NULL, check_symmetry = TRUE, metric = c("nvi", "ami") )
graph |
Undirected weighted graph: a symmetric sparse matrix or an
igraph graph, as for |
resolutions |
Grid of resolutions (gamma), all > 0. |
seeds |
Seeds, non-negative whole numbers below 2^53. |
gamma_range |
The range the intervals cover; |
n_iterations, theta, min_gain, threads, check_symmetry
|
Options of
|
metric |
|
Runs leiden() at every pair of resolutions x seeds, then keeps the
partitions that have the highest modularity among that pool somewhere in
gamma_range, each with its interval of resolutions (CHAMP: Weir et al.
2017, https://doi.org/10.3390/a10030093). Modularity is linear in the resolution, so
the intervals are exact; they are optimal relative to the pool, not
globally. Each partition is also scored by its agreement with its
neighbouring domains and with the other seeds, under metric.
Deterministic, at any number of threads.
A list: domains, a data frame with one row per partition on the
envelope in increasing resolution (gamma_lo, gamma_hi, log_width,
n_communities, and the grid point that produced it: resolution,
seed; and the agreement under metric: to_prev with the previous
row's partition, to_neighbours the mean with both neighbours' (close:
a plateau; far: a boundary), to_seeds the mean with the other seeds'
partitions at the same grid resolution (algorithmic noise); NA where
there is nothing to compare with); labels, an integer matrix (vertices x domains, labels 1..k,
community 1 the largest); and best, the row of domains with the
widest interval in log resolution, e.g. labels[, best].
f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) ch <- champ(g, 10^seq(-1, 1, length.out = 10), seeds = 0:2) ch$domains table(ch$labels[, ch$best])f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) ch <- champ(g, 10^seq(-1, 1, length.out = 10), seeds = 0:2) ch$domains table(ch$labels[, ch$best])
leiden() with igraph's cluster_*() interface: it returns an igraph
communities object, so it plugs into igraph and into bluster's graph
clustering, e.g.
bluster::clusterRows(pcs, SNNGraphParam(cluster.fun = cluster_libreville)).
cluster_libreville(graph, ...)cluster_libreville(graph, ...)
graph |
An undirected igraph graph (edge attribute |
... |
Options of |
An igraph communities object (igraph::membership(),
igraph::modularity() and igraph::sizes() work on it).
g <- igraph::make_graph("Zachary") comm <- cluster_libreville(g, seed = 1) igraph::sizes(comm)g <- igraph::make_graph("Zachary") comm <- cluster_libreville(g, seed = 1) igraph::sizes(comm)
A drop-in for Seurat::FindClusters on a graph, for tools built on it
that accept another clustering function (e.g. a patched CHOIR:
options(CHOIR.cluster_fun = clusterLibreville::find_clusters)); for
those that do not, see with_libreville(). Takes the graph (a Seurat
Graph, as obj@graphs$RNA_snn, or any symmetric sparse matrix),
resolution (a vector gives one column each) and random.seed (the
leiden() seed), and returns what FindClusters returns for a graph: a
data frame with one factor column per resolution (res.<r>, labels
1..k, 1 the largest community), rows named by cell. As in Seurat,
group.singletons (default TRUE) merges each one-cell community into
the community it is best connected to (highest mean edge weight); Seurat
breaks ties at random, here the larger community wins. Seurat's other
options (algorithm, method, n.start, n.iter, ...) are accepted
and ignored: the clustering is always this Leiden.
find_clusters( object, resolution = 0.8, random.seed = 0, group.singletons = TRUE, ..., threads = NULL )find_clusters( object, resolution = 0.8, random.seed = 0, group.singletons = TRUE, ..., threads = NULL )
object |
A graph, as for |
resolution |
Resolution(s) of the modularity (gamma). |
random.seed |
Seed, a non-negative whole number below 2^53. |
group.singletons |
Merge one-cell communities into their best-connected community. |
... |
Ignored (Seurat's options). |
threads |
Worker threads, as for |
A data frame, one factor column per resolution.
g <- Matrix::readMM(system.file("extdata", "hao3k.mtx", package = "clusterLibreville")) cl <- find_clusters(g, resolution = c(0.5, 1), random.seed = 1) sapply(cl, nlevels)g <- Matrix::readMM(system.file("extdata", "hao3k.mtx", package = "clusterLibreville")) cl <- find_clusters(g, resolution = c(0.5, 1), random.seed = 1) sapply(cl, nlevels)
Leiden (Traag, Waltman & van Eck 2019) maximising modularity at resolution
resolution, seeded and deterministic: the same seed gives the same
labels at any number of threads.
leiden(x, ...) ## S4 method for signature 'ANY' leiden( x, resolution = 1, seed = 0, n_iterations = -1L, theta = 0.01, min_gain = 0, threads = NULL, check_symmetry = TRUE, details = FALSE ) ## S4 method for signature 'SingleCellExperiment' leiden(x, use.dimred = "PCA", k = 10L, type = "rank", details = FALSE, ...)leiden(x, ...) ## S4 method for signature 'ANY' leiden( x, resolution = 1, seed = 0, n_iterations = -1L, theta = 0.01, min_gain = 0, threads = NULL, check_symmetry = TRUE, details = FALSE ) ## S4 method for signature 'SingleCellExperiment' leiden(x, use.dimred = "PCA", k = 10L, type = "rank", details = FALSE, ...)
x |
A graph or a
|
... |
Arguments for the graph method ( |
resolution |
Resolution of the modularity (gamma). |
seed |
Seed, a non-negative whole number below 2^53. |
n_iterations |
Leiden iterations; -1: until an iteration changes nothing. |
theta |
Randomness of the refinement. |
min_gain |
With |
threads |
Worker threads; |
check_symmetry |
Verify that the matrix is symmetric. |
details |
Also return how the run ended (see Value). |
use.dimred |
Name of the reduced dimension to build the graph from. |
k |
Number of nearest neighbours for the SNN graph. |
type |
Edge weighting of the SNN graph, as in
|
For a graph: integer community labels 1..k, community 1 the
largest (ties: by smallest vertex), named by the graph's row names if it
has them. For a SingleCellExperiment: the same labels as a factor named
by cell, e.g. for colLabels(sce) <- leiden(sce). With details = TRUE,
a list: labels (as above), iterations (Leiden iterations run),
converged (whether the last iteration changed nothing: a fixed point),
stop (why it stopped: "converged", "min_gain" or "n_iterations")
and modularity.
cluster_libreville() for igraph and bluster.
f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) labels <- leiden(g, resolution = 1, seed = 0) table(labels) sce <- SingleCellExperiment::SingleCellExperiment( assays = list(counts = matrix(0, 2, 200)), reducedDims = list(PCA = rbind(matrix(rnorm(400), ncol = 4), matrix(rnorm(400, 5), ncol = 4)))) SingleCellExperiment::colLabels(sce) <- leiden(sce, seed = 1) table(SingleCellExperiment::colLabels(sce))f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) labels <- leiden(g, resolution = 1, seed = 0) table(labels) sce <- SingleCellExperiment::SingleCellExperiment( assays = list(counts = matrix(0, 2, 200)), reducedDims = list(PCA = rbind(matrix(rnorm(400), ncol = 4), matrix(rnorm(400, 5), ncol = 4)))) SingleCellExperiment::colLabels(sce) <- leiden(sce, seed = 1) table(SingleCellExperiment::colLabels(sce))
Newman's modularity at resolution resolution, summed in a fixed order
(the quantity leiden() maximises).
modularity(graph, membership, resolution = 1)modularity(graph, membership, resolution = 1)
graph |
Undirected weighted graph: a symmetric sparse matrix or an
igraph graph, as for |
membership |
Labels, one per vertex (any atomic type). |
resolution |
Resolution of the modularity (gamma). |
A number.
f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) modularity(g, leiden(g))f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) modularity(g, leiden(g))
Peel, Larremore & Clauset's (2017) neoSBM: every vertex keeps its label's
block or is free to sit in any block, at a prior cost of
log((1 - theta) / theta) nats of degree-corrected SBM likelihood.
Sweeping theta towards 1/2 traces the fraction of vertices that defect
from the annotation; at theta = 1/2 the prior is neutral and that
fraction is "vertices whose label is not locally optimal for this graph".
Greedy single-vertex moves with exact likelihood deltas, warm-started along
thetas; a maximiser of the published objective, not Peel's reference
code.
neosbm( graph, membership, thetas = c(1e-06, 1e-05, 1e-04, 0.001, 0.003, 0.01, 0.03, 0.1, 0.2, 0.3, 0.4, 0.5), max_sweeps = 20L, seed = 0L, check_symmetry = TRUE )neosbm( graph, membership, thetas = c(1e-06, 1e-05, 1e-04, 0.001, 0.003, 0.01, 0.03, 0.1, 0.2, 0.3, 0.4, 0.5), max_sweeps = 20L, seed = 0L, check_symmetry = TRUE )
graph |
An undirected graph: a symmetric sparse matrix (e.g. an SNN graph's adjacency) or an igraph graph. |
membership |
Labels, one per vertex (any atomic type). |
thetas |
The grid, in (0, 1), ascending. |
max_sweeps |
Sweeps per theta before giving up on convergence. |
seed |
Fixes the vertex order of the sweeps; |
check_symmetry |
Validate symmetry ( |
A list: thetas; per theta defect (fraction off their label),
loglik_gain (nats recovered) and sweeps; blocks (the block of every
vertex at the last theta, as indices into labels); labels.
f <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) r <- neosbm(g, leiden(g)) r$defectf <- system.file("extdata", "hao3k.mtx", package = "clusterLibreville") g <- Matrix::readMM(f) r <- neosbm(g, leiden(g)) r$defect
Evaluates expr with Seurat::FindClusters swapped for find_clusters()
and restores it afterwards (also on error). For tools that cluster
through Seurat::FindClusters and have no option to choose the clustering
function, such as CHOIR: with_libreville(CHOIR::CHOIR(obj)) makes every
clustering in its tree deterministic Leiden, seeded with CHOIR's
random_seed.
with_libreville(expr, threads = NULL)with_libreville(expr, threads = NULL)
expr |
Code to run. |
threads |
Worker threads for every clustering, as for |
The function is replaced in Seurat's namespace, for the whole R session,
while expr runs.
The value of expr.
g <- Matrix::readMM(system.file("extdata", "hao3k.mtx", package = "clusterLibreville")) cl <- with_libreville(Seurat::FindClusters(g, resolution = 1, random.seed = 1)) table(cl$res.1)g <- Matrix::readMM(system.file("extdata", "hao3k.mtx", package = "clusterLibreville")) cl <- with_libreville(Seurat::FindClusters(g, resolution = 1, random.seed = 1)) table(cl$res.1)