Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
hdbscan.h
Go to the documentation of this file.
1#pragma once
2
3#include <algorithm>
4#include <array>
5#include <cmath>
6#include <cstddef>
7#include <cstdint>
8#include <limits>
9#include <span>
10#include <type_traits>
11#include <utility>
12#include <vector>
13
15#include "clustering/hdbscan/detail/condensed_tree.h"
16#include "clustering/hdbscan/detail/eom_extract.h"
17#include "clustering/hdbscan/detail/glosh.h"
18#include "clustering/hdbscan/detail/leaf_extract.h"
19#include "clustering/hdbscan/detail/single_linkage.h"
24#include "clustering/ndarray.h"
25
27
35enum class ClusterSelectionMethod : std::uint8_t {
38};
39
50enum class MinSamplesConvention : std::uint8_t {
57};
58
59} // namespace clustering::hdbscan
60
61namespace clustering {
62
106template <class T, class MstBackend = hdbscan::AutoMstBackend<T>>
107 requires hdbscan::MstBackendStrategy<MstBackend, T>
108class HDBSCAN {
109 static_assert(std::is_same_v<T, float>,
110 "HDBSCAN<T> supports only float; a double specialization is out of scope.");
111
112public:
122 std::span<const std::int32_t> parent;
124 std::span<const std::int32_t> child;
126 std::span<const T> lambda;
128 std::span<const std::int32_t> childSize;
129
131 [[nodiscard]] bool empty() const noexcept { return parent.empty(); }
133 [[nodiscard]] std::size_t size() const noexcept { return parent.size(); }
134 };
135
151 explicit HDBSCAN(
152 std::size_t minClusterSize, std::size_t minSamples = 0,
154 std::size_t nJobs = 0,
156 : m_minClusterSize(minClusterSize), m_minSamples(minSamples), m_method(method),
157 m_nJobs(math::clampedJobCount(nJobs)), m_convention(convention), m_labels({0}),
158 m_outlierScores({0}) {
159 CLUSTERING_ALWAYS_ASSERT(minClusterSize >= 2);
160 // Pool acquisition is deferred to @ref run: at small shapes every hot phase gates serial, so
161 // a fully-serial fit should not force the shared registry to materialize a worker pool. The
162 // shape-gated borrow from @ref math::sharedPool happens inside @ref run.
163 }
164
165 HDBSCAN(const HDBSCAN &) = delete;
166 HDBSCAN &operator=(const HDBSCAN &) = delete;
167 HDBSCAN(HDBSCAN &&) = delete;
168 HDBSCAN &operator=(HDBSCAN &&) = delete;
169 ~HDBSCAN() = default;
170
182 void run(const NDArray<T, 2> &X) {
183 const std::size_t n = X.dim(0);
184
185 CLUSTERING_ALWAYS_ASSERT(m_minClusterSize >= 2);
186
187 // Translate the user-facing @c minSamples to the non-self neighbour count the MST backends
188 // consume. Under the scikit-learn convention the query point counts as one of the neighbours
189 // and we feed the backend one less; under Campello we pass it through.
190 const std::size_t requestedMinSamples = (m_minSamples == 0) ? m_minClusterSize : m_minSamples;
191 if (m_convention == hdbscan::MinSamplesConvention::kSklearn) {
192 CLUSTERING_ALWAYS_ASSERT(requestedMinSamples >= 2);
193 } else {
194 CLUSTERING_ALWAYS_ASSERT(requestedMinSamples >= 1);
195 }
196 const std::size_t effectiveMinSamples =
197 (m_convention == hdbscan::MinSamplesConvention::kSklearn) ? requestedMinSamples - 1
198 : requestedMinSamples;
199 CLUSTERING_ALWAYS_ASSERT(effectiveMinSamples >= 1);
200 CLUSTERING_ALWAYS_ASSERT(effectiveMinSamples < n);
201 CLUSTERING_ALWAYS_ASSERT(n >= m_minClusterSize);
203 static_cast<std::size_t>(std::numeric_limits<std::int32_t>::max()));
204
205 // Shape-gated pool borrow: the MST backend is the only phase that can parallelise at all;
206 // post-MST is serial by contract. Size the work gate by the backend's dominant shape (N * N)
207 // so small inputs stay fully serial and never touch the shared registry.
208 const math::Pool pool{math::shouldSpawnPool(n * n, m_nJobs, /*minOpsPerWorker=*/1U << 15)
209 ? &math::sharedPool(m_nJobs)
210 : nullptr};
211
212 // Phase 1: MST via the pinned backend. The backend writes edges and core distances into
213 // `m_mstOutput`.
214 m_backend.run(X, effectiveMinSamples, pool, m_mstOutput);
215
216 // Convert squared distances to linear distances before the post-MST pipeline consumes them.
217 // The backends store squared Euclidean internally (avoids an @c sqrt per pair-distance); the
218 // MST structure is invariant under @c d -> `sqrt(d)` (monotone) but the condensed-tree
219 // stability DP compares absolute lambda values whose outcome is not invariant under that
220 // transform. Linearising here aligns the lambda scale with the reference implementation and
221 // with outlier-score bounds users expect.
222 {
223 const std::size_t nCore = m_mstOutput.coreDistances.dim(0);
224 T *coreData = m_mstOutput.coreDistances.data();
225 for (std::size_t i = 0; i < nCore; ++i) {
226 coreData[i] = std::sqrt(coreData[i]);
227 }
228 for (auto &edge : m_mstOutput.edges) {
229 edge.weight = std::sqrt(edge.weight);
230 }
231 }
232
233 // Phase 2: build the single-linkage dendrogram from the MST edges.
234 hdbscan::detail::SingleLinkageTree<T> slt;
235 hdbscan::detail::buildSingleLinkageTree(m_mstOutput, n, slt);
236
237 // Phase 3: condense the dendrogram under `minClusterSize`.
238 hdbscan::detail::CondensedTree<T> condensed;
239 hdbscan::detail::condenseTree(slt, n, m_minClusterSize, condensed);
240
241 // Phase 4: cluster extraction (EOM or leaf).
242 std::vector<std::int32_t> labels;
244 hdbscan::detail::extractEom(condensed, n, labels);
245 } else {
246 hdbscan::detail::extractLeaf(condensed, n, labels);
247 }
248
249 // Phase 5: GLOSH outlier scores. The per-point score loop fans out over the same pool the MST
250 // borrowed; its internal subtree-max precompute stays serial.
251 std::vector<T> scores;
252 hdbscan::detail::computeGlosh(condensed, n, labels, scores, pool);
253
254 // Finalise result accessors. The label array lands in the public NDArray buffer; ditto the
255 // outlier-score array. The condensed tree is retained in its parallel-array form so the
256 // public view can borrow from it without an additional copy.
257 m_labels = NDArray<std::int32_t, 1>(std::array<std::size_t, 1>{n});
258 std::int32_t maxLabel = -1;
259 for (std::size_t i = 0; i < n; ++i) {
260 m_labels(i) = labels[i];
261 maxLabel = std::max(maxLabel, labels[i]);
262 }
263 m_outlierScores = NDArray<T, 1>(std::array<std::size_t, 1>{n});
264 for (std::size_t i = 0; i < n; ++i) {
265 m_outlierScores(i) = scores[i];
266 }
267 m_nClusters = (maxLabel < 0) ? std::size_t{0} : static_cast<std::size_t>(maxLabel) + 1;
268
269 m_ctParent = std::move(condensed.parent);
270 m_ctChild = std::move(condensed.child);
271 m_ctLambda = std::move(condensed.lambdaVal);
272 m_ctChildSize = std::move(condensed.childSize);
273 }
274
277 [[nodiscard]] const NDArray<std::int32_t, 1> &labels() const noexcept { return m_labels; }
278
281 [[nodiscard]] const NDArray<T, 1> &outlierScores() const noexcept { return m_outlierScores; }
282
285 [[nodiscard]] std::size_t nClusters() const noexcept { return m_nClusters; }
286
289 [[nodiscard]] CondensedTreeView condensedTree() const noexcept {
290 return CondensedTreeView{
291 .parent = std::span<const std::int32_t>(m_ctParent.data(), m_ctParent.size()),
292 .child = std::span<const std::int32_t>(m_ctChild.data(), m_ctChild.size()),
293 .lambda = std::span<const T>(m_ctLambda.data(), m_ctLambda.size()),
294 .childSize = std::span<const std::int32_t>(m_ctChildSize.data(), m_ctChildSize.size()),
295 };
296 }
297
299 void reset() {
300 m_labels = NDArray<std::int32_t, 1>({0});
301 m_outlierScores = NDArray<T, 1>({0});
302 m_nClusters = 0;
303 m_ctParent = std::vector<std::int32_t>{};
304 m_ctChild = std::vector<std::int32_t>{};
305 m_ctLambda = std::vector<T>{};
306 m_ctChildSize = std::vector<std::int32_t>{};
307 m_mstOutput = hdbscan::MstOutput<T>{};
308 m_backend = MstBackend{};
309 }
310
311private:
312 std::size_t m_minClusterSize;
313 std::size_t m_minSamples;
315 std::size_t m_nJobs;
318 NDArray<T, 1> m_outlierScores;
319 std::size_t m_nClusters = 0;
320
321 // Condensed-tree parallel arrays, filled by the post-MST pipeline and surfaced through
322 // @ref condensedTree as a read-only view.
323 std::vector<std::int32_t> m_ctParent;
324 std::vector<std::int32_t> m_ctChild;
325 std::vector<T> m_ctLambda;
326 std::vector<std::int32_t> m_ctChildSize;
327
328 MstBackend m_backend{};
329 hdbscan::MstOutput<T> m_mstOutput{};
330};
331
332} // namespace clustering
#define CLUSTERING_ALWAYS_ASSERT(cond)
Release-active assertion: evaluates cond in every build configuration.
CondensedTreeView condensedTree() const noexcept
Borrowed view over the condensed tree from the most recent run, or an empty view if no fit has produc...
Definition hdbscan.h:289
HDBSCAN(std::size_t minClusterSize, std::size_t minSamples=0, hdbscan::ClusterSelectionMethod method=hdbscan::ClusterSelectionMethod::kEom, std::size_t nJobs=0, hdbscan::MinSamplesConvention convention=hdbscan::MinSamplesConvention::kSklearn)
Construct a reusable HDBSCAN fitter.
Definition hdbscan.h:151
HDBSCAN(const HDBSCAN &)=delete
std::size_t nClusters() const noexcept
Total number of clusters discovered by the most recent run, or 0 if no fit has produced a result yet.
Definition hdbscan.h:285
HDBSCAN & operator=(HDBSCAN &&)=delete
HDBSCAN & operator=(const HDBSCAN &)=delete
const NDArray< std::int32_t, 1 > & labels() const noexcept
Length-n assignment; -1 marks noise.
Definition hdbscan.h:277
void run(const NDArray< T, 2 > &X)
Fit to X.
Definition hdbscan.h:182
HDBSCAN(HDBSCAN &&)=delete
const NDArray< T, 1 > & outlierScores() const noexcept
Length-n per-point GLOSH outlier scores in [0, 1].
Definition hdbscan.h:281
void reset()
Release every scratch buffer. The next run call reallocates against its shape.
Definition hdbscan.h:299
Represents a multidimensional array (NDArray) of a fixed number of dimensions N and element type T.
Definition ndarray.h:136
size_t dim(std::size_t index) const noexcept
Returns the size of a specific dimension of the NDArray.
Definition ndarray.h:462
ClusterSelectionMethod
Cluster extraction method on the condensed tree.
Definition hdbscan.h:35
@ kEom
Excess-of-mass selection (the published default).
Definition hdbscan.h:36
@ kLeaf
Leaf-cluster selection; every condensed-tree leaf becomes a cluster.
Definition hdbscan.h:37
MinSamplesConvention
Semantics of the minSamples parameter at core-distance extraction.
Definition hdbscan.h:50
@ kCampello
minSamples counts only non-self neighbours, matching Campello 2015 directly.
Definition hdbscan.h:56
@ kSklearn
minSamples counts the query point itself as one of the k neighbours.
Definition hdbscan.h:54
bool shouldSpawnPool(std::size_t totalOps, std::size_t nJobs, std::size_t minOpsPerWorker=std::size_t{1}<< 15) noexcept
Decide whether spawning a pool with nJobs workers is worth it for totalOps of arithmetic work.
Definition thread.h:82
OwnedPool & sharedPool(std::size_t nJobs)
Process-wide pool registry, keyed by worker count.
Definition thread.h:516
Read-only view over the condensed-tree result.
Definition hdbscan.h:120
std::size_t size() const noexcept
Number of rows in the condensed tree.
Definition hdbscan.h:133
std::span< const std::int32_t > childSize
Size of the child subtree at its birth lambda.
Definition hdbscan.h:128
std::span< const std::int32_t > child
Child cluster or leaf point id for each row.
Definition hdbscan.h:124
bool empty() const noexcept
True when the view holds no rows (no fit or reset called).
Definition hdbscan.h:131
std::span< const T > lambda
Lambda value (= 1 / distance) at which child detaches from parent.
Definition hdbscan.h:126
std::span< const std::int32_t > parent
Parent cluster id for each row of the condensed tree.
Definition hdbscan.h:122
Frozen output contract of every MST backend.
Definition mst_output.h:41
Thin compile-time-templated wrapper around the underlying OwnedPool.
Definition thread.h:109