Clustering
C++20 header-only: DBSCAN, HDBSCAN, k-means.
Loading...
Searching...
No Matches
dbscan.h
Go to the documentation of this file.
1#pragma once
2
3#include <cstddef>
4#include <cstdint>
5#include <type_traits>
6#include <utility>
7#include <vector>
8
12#include "clustering/math/dsu.h"
14#include "clustering/ndarray.h"
15
16namespace clustering {
17
37template <class T, class QueryModel = index::AutoRangeIndex<T>>
39class DBSCAN {
40public:
41 static constexpr std::int32_t UNCLASSIFIED = -2;
43 static constexpr std::int32_t NOISY = -1;
44
54 explicit DBSCAN(T eps, std::size_t minPts, std::size_t nJobs = 0)
55 : m_eps(eps), m_minPts(minPts), m_nJobs(math::clampedJobCount(nJobs)), m_labels({0}) {
56 CLUSTERING_ALWAYS_ASSERT(minPts >= 1);
57 // Pool acquisition is deferred to @ref run: at small shapes every hot phase gates serial, so
58 // a fully-serial fit should not force the shared registry to materialize a worker pool. The
59 // shape-gated borrow from @ref math::sharedPool happens inside @ref run.
60 }
61
62 DBSCAN(const DBSCAN &) = delete;
63 DBSCAN &operator=(const DBSCAN &) = delete;
64 DBSCAN(DBSCAN &&) = delete;
65 DBSCAN &operator=(DBSCAN &&) = delete;
66 ~DBSCAN() = default;
67
81 void run(const NDArray<T, 2> &X) {
82 const std::size_t n = X.dim(0);
83 ensureLabelsShape(n);
84 m_clusterId = 0;
85
86 if (n == 0) {
87 return;
88 }
89
90 // One adjacency query per point is the unit of work the range-index backends fan out on;
91 // shouldSpawnPool with minOpsPerWorker=16 matches the KDTree adjacency sweep's own
92 // `shouldParallelize(n, 4, 2)` gate (@c n / 4 >= 2 * workerCount => `n >= 8` * workerCount)
93 // so the pool spawn and the backend fan-out fire at the same shape. @c n * d would
94 // under-estimate DBSCAN work -- the backend does per-query tree walks that are much heavier
95 // than @c d ops -- and caused @c n_jobs=16 at low @c d to fall back to serial here while
96 // every worker-side kernel would have cleared its own gate.
97 const std::size_t poolJobs = effectiveWorkerCount(n, X.dim(1), m_nJobs);
98 const math::Pool pool{math::shouldSpawnPool(n, poolJobs, /*minOpsPerWorker=*/16)
99 ? &math::sharedPool(poolJobs)
100 : nullptr};
101
102 // Pool-aware query models parallelize their own construction (the KDTree build forks
103 // subtrees); models without that constructor keep the plain shape contract.
104 QueryModel queryModel = [&] {
105 if constexpr (std::is_constructible_v<QueryModel, const NDArray<T, 2> &, math::Pool>) {
106 return QueryModel(X, pool);
107 } else {
108 return QueryModel(X);
109 }
110 }();
111 // The backend derives the core flags from full degrees; core rows may carry only their
112 // upper-half neighbours per the @ref clustering::index::CoreAdjacency contract, which is
113 // exactly the half the component build below reads.
114 const auto [adj, isCore, extraEdges] = queryModel.query(m_eps, m_minPts, pool);
115
116 // Connected components over core-core edges: density-reachability is the transitive closure of
117 // "core within eps of core", which a disjoint-set union builds directly.
118 const std::vector<std::uint32_t> componentRoots =
119 buildComponentRoots(adj, isCore, extraEdges, pool);
120
121 // Dense cluster ids in first-core-index order so the lowest-index core of each component names
122 // its cluster. Writing each core's id into the label buffer now freezes it so the border pass
123 // reads it without another root lookup.
124 std::int32_t *labels = m_labels.data();
125 std::vector<std::int32_t> rootCluster(n, UNCLASSIFIED);
126 for (std::size_t i = 0; i < n; ++i) {
127 if (isCore[i] == 0) {
128 continue;
129 }
130 const auto root = componentRoots[i];
131 std::int32_t &slot = rootCluster[root];
132 if (slot == UNCLASSIFIED) {
133 slot = static_cast<std::int32_t>(m_clusterId);
134 ++m_clusterId;
135 }
136 labels[i] = slot;
137 }
138
139 assignBorderAndNoise(adj, isCore);
140 }
141
143 [[nodiscard]] const NDArray<std::int32_t, 1> &labels() const noexcept { return m_labels; }
144
146 [[nodiscard]] std::size_t nClusters() const noexcept { return m_clusterId; }
147
149 void reset() {
150 m_labels = NDArray<std::int32_t, 1>({0});
151 m_clusterId = 0;
152 }
153
154private:
155 void ensureLabelsShape(std::size_t n) {
156 if (m_labels.dim(0) != n) {
157 m_labels = NDArray<std::int32_t, 1>({n});
158 }
159 }
160
161 static constexpr std::size_t kSmall2dMaxParallelN = 25'000;
162 static constexpr std::size_t kSmall2dWorkerCap = 8;
163
164 [[nodiscard]] static std::size_t effectiveWorkerCount(std::size_t n, std::size_t d,
165 std::size_t requested) noexcept {
166 if (d <= 2 && n <= kSmall2dMaxParallelN && requested > kSmall2dWorkerCap) {
167 return kSmall2dWorkerCap;
168 }
169 return requested;
170 }
171
189 static std::vector<std::uint32_t> buildComponentRoots(
190 const std::vector<std::vector<std::int32_t>> &adj, const std::vector<std::uint8_t> &isCore,
191 const std::vector<std::pair<std::int32_t, std::int32_t>> &extraEdges, math::Pool pool) {
192 const std::size_t n = adj.size();
193 std::vector<std::uint32_t> roots(n);
194
195 const auto uniteRange = [&](auto &dsu, std::size_t lo, std::size_t hi) {
196 for (std::size_t i = lo; i < hi; ++i) {
197 if (isCore[i] == 0) {
198 continue;
199 }
200 const auto iu = static_cast<std::uint32_t>(i);
201 // No orientation filter: a backend may carry an edge in either direction only (a
202 // clique shortcut can thin the mirror side), and repeated unions are idempotent.
203 for (const std::int32_t neighbor : adj[i]) {
204 const auto j = static_cast<std::size_t>(neighbor);
205 if (j != i && isCore[j] != 0) {
206 dsu.unite(iu, static_cast<std::uint32_t>(j));
207 }
208 }
209 }
210 };
211 // Representative edges the backend carried outside the rows; both endpoints are cores by
212 // the CoreAdjacency contract, and unions are idempotent, so no filtering is needed.
213 const auto uniteExtraRange = [&](auto &dsu, std::size_t lo, std::size_t hi) {
214 for (std::size_t e = lo; e < hi; ++e) {
215 dsu.unite(static_cast<std::uint32_t>(extraEdges[e].first),
216 static_cast<std::uint32_t>(extraEdges[e].second));
217 }
218 };
219
220 const std::size_t workers = pool.workerCount();
221 if (pool.pool == nullptr || workers <= 1) {
222 UnionFind<std::uint32_t> components(n);
223 uniteRange(components, 0, n);
224 uniteExtraRange(components, 0, extraEdges.size());
225 for (std::size_t i = 0; i < n; ++i) {
226 roots[i] = components.find(static_cast<std::uint32_t>(i));
227 }
228 return roots;
229 }
230
231 // More blocks than workers lets dynamic stealing balance the skewed per-row degree; the
232 // shared structure makes block placement irrelevant to the result.
233 AtomicUnionFind<std::uint32_t> components(n);
234 pool.parallelForBlocks(std::size_t{0}, n, workers * 4,
235 [&](std::size_t lo, std::size_t hi) { uniteRange(components, lo, hi); });
236 pool.parallelForBlocks(std::size_t{0}, n, std::size_t{0}, [&](std::size_t lo, std::size_t hi) {
237 for (std::size_t i = lo; i < hi; ++i) {
238 roots[i] = components.find(static_cast<std::uint32_t>(i));
239 }
240 });
241 return roots;
242 }
243
255 void assignBorderAndNoise(const std::vector<std::vector<std::int32_t>> &adj,
256 const std::vector<std::uint8_t> &isCore) {
257 const std::size_t n = adj.size();
258 std::int32_t *labels = m_labels.data();
259 for (std::size_t p = 0; p < n; ++p) {
260 if (isCore[p] != 0) {
261 continue;
262 }
263 std::int32_t best = NOISY;
264 for (const std::int32_t neighbor : adj[p]) {
265 const auto q = static_cast<std::size_t>(neighbor);
266 if (isCore[q] == 0) {
267 continue;
268 }
269 const std::int32_t cluster = labels[q];
270 if (best == NOISY || cluster < best) {
271 best = cluster;
272 }
273 }
274 labels[p] = best;
275 }
276 }
277
278 T m_eps;
279 std::size_t m_minPts;
280 std::size_t m_nJobs;
281 std::size_t m_clusterId = 0;
285 NDArray<std::int32_t, 1> m_labels;
286};
287
288} // namespace clustering
#define CLUSTERING_ALWAYS_ASSERT(cond)
Release-active assertion: evaluates cond in every build configuration.
void run(const NDArray< T, 2 > &X)
Fit to X.
Definition dbscan.h:81
std::size_t nClusters() const noexcept
Total number of clusters discovered by the most recent run.
Definition dbscan.h:146
DBSCAN(T eps, std::size_t minPts, std::size_t nJobs=0)
Construct a reusable DBSCAN fitter.
Definition dbscan.h:54
DBSCAN(const DBSCAN &)=delete
const NDArray< std::int32_t, 1 > & labels() const noexcept
Per-point cluster labels after run; NOISY marks outliers.
Definition dbscan.h:143
DBSCAN & operator=(const DBSCAN &)=delete
static constexpr std::int32_t NOISY
Label assigned to points that no cluster claimed.
Definition dbscan.h:43
DBSCAN(DBSCAN &&)=delete
DBSCAN & operator=(DBSCAN &&)=delete
void reset()
Release every scratch buffer. The next run call reallocates against its shape.
Definition dbscan.h:149
static constexpr std::int32_t UNCLASSIFIED
Sentinel for a component without a cluster id yet; never an output label.
Definition dbscan.h:41
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
Contract for spatial indexes that can surface the radius-neighborhood adjacency over a borrowed point...
Definition range_query.h:53
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
Thin compile-time-templated wrapper around the underlying OwnedPool.
Definition thread.h:109