Topological mode-seeking clustering for JuliaTDA: find density peaks, then merge peaks whose prominence is too small. A high peak separated by a deep valley can remain a cluster; a small bump on its hillside can disappear. The input neighbourhood graph determines which valleys the algorithm can cross.
The current packages are unregistered. In a new Julia project, resolve both together:
using Pkg
Pkg.activate("tomato-example"; shared=false)
Pkg.develop([
PackageSpec(url="https://github.com/JuliaTDA/MetricSpaces.jl"),
PackageSpec(url="https://github.com/JuliaTDA/ToMATo.jl"),
])Julia 1.9 or later is supported. For sibling checkouts, replace the URLs with path="../MetricSpaces.jl" and path="../ToMATo.jl", relative to your working directory.
Five points form a path with assigned densities 5, 3, 1, 2, 4. The two peaks meet at density 1, so the shorter peak has prominence 4 - 1 = 3.
using ToMATo, MetricSpaces
using Graphs: path_graph
X = EuclideanSpace([[Float64(i), 0.0] for i in 1:5])
g = path_graph(5)
ds = [5.0, 3.0, 1.0, 2.0, 4.0]
labels, modes = tomato(X, g, ds, 2.0)
@assert labels == [1, 1, 1, 2, 2]
merged, full_modes = tomato(X, g, ds) # default τ = Inf
@assert merged == ones(Int, 5)
@assert full_modes[5] == [4.0, 1.0]
@assert full_modes[1] == [5.0, Inf]Larger τ permits more merging. τ=0 keeps all modes for distinct finite densities; τ=Inf permits every eligible merge. The inequality is strict: a mode with prominence exactly 3 survives at τ=3 and merges at τ>3.
modes maps original peak point IDs to [birth_density, death_density]. These keys differ from final cluster labels, which are ranked by peak density. death=Inf is the implementation's sentinel for a mode that did not merge in that run. Do not calculate birth - Inf as its lifetime. A finite death is recorded only when a merge occurs, so use the τ=Inf run to inspect eligible merges before selecting a finite threshold.
using Random, ToMATo, MetricSpaces
using MetricSpaces.Datasets: two_clusters
Random.seed!(42)
X = two_clusters(200; dim=2, separation=10)
ds = ToMATo.knn_density(X; k=6)
g = proximity_graph(X, 1.5; max_k_ball=12, min_k_ball=2, k_nn=5)
_, modes = tomato(X, g, ds, Inf)
prominences = sort([b - d for (b, d) in values(modes) if isfinite(d)])
labels, _ = tomato(X, g, ds, 0.1) # illustrative: inspect prominences firstknn_density is inverse neighbour distance, not a normalized probability density. The query point counts among the k neighbours, so k=6 reaches the fifth other neighbour when points are distinct. The graph uses Euclidean geometry even if you supply another distance to the density estimator. Changing scaling, k, or graph connectivity changes the meaning of τ.
The documentation explains density and graph parameters, disconnected graphs and plateaus, threshold selection, background label 0, and plots with TDAplots. Tutorial examples execute during the documentation build.
The current implementation also has a limitation at some multiway saddles: even a connected graph with distinct densities can retain more than one mode at τ=Inf. The parameters guide gives a reproducible counterexample. Check memberships rather than assuming the unrestricted run equals graph connected components.
With the sibling MetricSpaces.jl checkout beside this repository:
julia --project=docs docs/setup.jl
julia --project=docs docs/make.jlOpen docs/build/index.html. Publishing is opt-in through JULIATDA_DOCS_DEPLOY=true; the default command only builds locally. In a configured development environment, run tests with julia --project=. -e 'using Pkg; Pkg.test()'.
F. Chazal, L. J. Guibas, S. Y. Oudot & P. Skraba (2013), Persistence-based clustering in Riemannian manifolds, Journal of the ACM 60(6), 41. DOI.
MetricSpaces.jl supplies geometry and datasets; TDAmapper.jl supplies Mapper summaries; TDAplots.jl visualizes results; JuliaTDA.jl brings the ecosystem together.
MIT license.