Usage guide¶
Runnable snippets for every interface. For executed, plotted walk-throughs see the
example notebooks; for the full capability list see FEATURES.md.
One-shot — fit_predict¶
import numpy as np
import betula_cluster
X = np.random.default_rng(0).normal(size=(100_000, 10))
labels = betula_cluster.fit_predict(X, n_clusters=10, feature="diagonal", method="gmm")
labels = betula_cluster.fit_predict(X, n_clusters=10, feature="full", method="gmm-full")
labels = betula_cluster.fit_predict(X, n_clusters=0, method="gmm") # auto-select k via BIC
labels = betula_cluster.fit_predict(X, n_clusters=10, method="kmeans")
labels = betula_cluster.fit_predict(X, method="hdbscan", min_samples=10, min_cluster_size=25)
# hdbscan: label -1 == noise
Keyword args: feature ∈ {spherical, diagonal, full, fd}, method ∈ {kmeans, xmeans, kmedoids, fuzzy-cmeans, gmm, gmm-full, mppca, mfa, ward, average, weighted, centroid, median, spectral, leiden, leiden-cpm, spherical-kmeans, vmf, watson, hyperbolic, gmm-toeplitz, gmm-toeplitz-full, gmm-toeplitz-gs, hdbscan, dc-center, dc-median, scale-space},
distance ∈ {euclidean, manhattan, ward, average} (routing measure),
absorb ∈ {euclidean, manhattan, average, diameter, ward, radius, chi2, subspace} (see Absorption criteria
below; chi2 = mass-invariant Mahalanobis gate at level chi2_p with chi2_scale = within-cluster
variance; fixes the BIRCH size-imbalance bug), decay (EWMA factor
for streaming concept drift), normalize (L2-normalize rows → cluster by direction; on the unit
sphere squared-Euclidean is monotone in cosine, so the tree clusters by angle. It earns its keep on
digits-64 (k-means 0.467 → 0.569, ward 0.643 → 0.699, median of three seeds); on MNIST-784
it is now a wash — 0.307 → 0.346, inside the seed spread and sign-flipping between seeds, since the
tree-rebuild fix removed most of the Euclidean collapse it used to compensate for. Leave it off for
tabular data where magnitude is signal: it takes covtype ward to −0.049, worse than random),
n_shards (shard+merge tree build — >1 gives ~4–5× on large
N, and changes the labels, since the shards are the partition; canonical_order=True derives
the shard count from n instead and rejects a caller-set one), threshold, branching (≥ 2), leaf_cap (≥ 1), max_leaves (an integer is an absolute leaf cap; a
float in (0, 1) is a fraction of the row count, resolved as ceil(frac·N) at fit time —
ELKI's -cftree.maxleaves convention, whose own default is 0.05. A fraction is undefined for
partial_fit, which never sees a final N, and raises there rather than guessing a batch size;
memory_budget_mb overrides either form, being the harder constraint. The fraction is the
estimator's: resolving it lives in the Python wrapper, and the free fit_predict /
fit_predict_sparse are the engine functions re-exported verbatim, so those two take the integer
and raise TypeError on a float), max_iter (≥ 1; a zero-iteration fit
returns its own initialisation, which for the mixture heads is a responsibility matrix of zeros and
every point in cluster 0, so 0 raises rather than answering), n_init (k-means++ restarts, kept by lowest inertia;
None leaves each head on its own count of 4. Only the heads whose labels come from an
inertia-selected k-means take it — "kmeans", "spherical-kmeans", "spectral", and the
COP-KMeans of fit_constrained — and passing it to any other head raises rather than being ignored;
see How many restarts a k-means head needs below), min_samples
(None/"auto" by default — the mass of ten average leaves, see min_samples on a summary
below; an integer is a point count. For method="hdbscan" it is the core-distance neighbourhood
counting the point itself —
the convention of Campello's Def. 3.1, sklearn.cluster.HDBSCAN and ELKI, so min_samples=1
leaves every core distance at 0 and HDBSCAN* degenerates to single linkage;
scikit-learn-contrib/hdbscan excludes it, where the same number means one neighbour more),
min_cluster_size, graph_degree (for method="hdbscan", the out-degree of the proximity graph the
density head runs on; 0 = the exact complete graph, a positive value is a floor the head raises
to whatever min_samples needs — see Graph-indexing the density head below), resolution (Leiden γ — granularity for method="leiden" / "leiden-cpm", higher
⇒ more communities), covariance_weight (Leiden β — a log-Euclidean covariance/shape term in the
affinity, feature="full"; 0 = off, the centroid-only default), tangent_weight / tangent_rank
(Leiden γ — a Grassmann tangent-subspace term of rank tangent_rank for manifold-aware communities,
feature="full"; 0 = off), n_iterations (Leiden passes: each pass after the first starts from
the previous partition, as igraph's n_iterations does, and the passes stop early once one changes
nothing; a negative value repeats until that happens, capped at 100 passes. 1, the default, is the
single pass of every earlier release and keeps its labels. One pass stops at a local optimum: on a
three-community planted partition at four times the detection threshold, two passes move ARI from
0.44–0.70 to 0.85–0.93 — see What the heads can and cannot guarantee in
MATH.md), rank (subspace rank q for method="mppca" and method="mfa", clamped to at
most dim - 1; 0 makes every mppca component spherical and every mfa component diagonal), fuzzifier (the exponent m > 1 of
method="fuzzy-cmeans", default 2.0; m → 1⁺ is k-means, m → ∞ sends every membership to
1/k), projection / projection_dim / projection_max_iter (reduce the leaf centroids to
projection_dim codes before the head; "none" = off. "weighted-nmf", or
"weighted-nmf-kl" for count data, gives nonnegative CF-weighted NMF codes — for nonnegative
data only: TF-IDF / counts / spectrograms, dense or CSR. "svd" gives a CF-weighted PCA, accepts
signed data, and is the one-call text pipeline — see Text: reduce and cluster in one call below.
After a fit, components_ gives the (projection_dim, dim) parts and reconstruction_err_ the
relative fit error), refine (BIRCH Phase 4 — see below), leaf_refit (Lloyd on the leaves — see below), canonical_order (order-independent build — see below), route_beam (widen the routing descent — see below), seed. n_clusters=0 ⇒ automatic k for every parametric head (BIC for
k-means/GMM, dendrogram cut for Ward). threshold="auto" (dense only) drops the one knob users most
often have to guess: a subsample pilot estimates a warm-start absorption radius, so the full fit
starts near-converged instead of growing the threshold from zero.
A head-specific keyword handed to a head that does not read it raises. Ten of the keywords above
belong to one family of heads and configure nothing anywhere else, so rank=8 with method="ward"
is a request the fit was never going to honour:
| keyword | read by |
|---|---|
min_samples, graph_degree |
hdbscan, dc-center, dc-median |
min_cluster_size |
hdbscan |
resolution, covariance_weight, tangent_weight, tangent_rank, n_iterations |
leiden, leiden-cpm |
rank |
mppca, mfa |
fuzzifier |
fuzzy-cmeans |
Leaving one at its default is not a request — rank=2 is what every caller who never thought about
rank passes — so only a value you changed is an error. n_init follows the same rule against its
own three heads. Everything else on the list is either tree-level (it configures the summary, which
every head then reads) or general to the parametric heads (max_iter, auto_k_max), and those are
not checked against the head: their applicability depends on n_clusters too, not on the head
alone.
Absorption criteria¶
absorb decides when a point joins an existing leaf rather than starting a new one — the single
choice that shapes the whole summary. The full BIRCH grid is available, plus this crate's own gate:
absorb |
BIRCH name | what it measures | threshold units |
|---|---|---|---|
euclidean (default) |
D0 | squared distance to the centroid | squared |
manhattan |
D1 | L1 distance to the centroid | L1, not squared |
average |
D2 | mean squared distance between the two clusters' points | squared |
diameter |
D3 | mean squared distance within the merged cell | squared |
ward |
D4 | variance increase from the merge | squared |
radius |
R | mean squared radius of the merged cell | squared |
chi2 |
— | Mahalanobis-χ² with a variance prior | χ²dim quantile via chi2_p |
subspace |
— | the same gate read on the leaf's own low-rank basis | χ²dim quantile via chi2_p |
threshold is read in the chosen criterion's own units, so a value tuned for one does not
transfer to another — retune when you switch. D2, D3 and R read the leaves' second moments, so
they grow with a cell's scatter; D0, D1 and D4 are centroid-only and therefore the most
numerically stable.
On wildly unequal cluster sizes the criterion used to decide the outcome; since 2026-09-10 it
does not. BIRCH's size-imbalance bug (scikit-learn
#22854 — a large cluster swallows a
distant point because one global absorption radius is generous relative to a dense core) used to be
a property of the criterion here: on the
bench/results_imbalance.csv
fixture only ward and chi2 scored ARI 1.0000 at every leaf budget, manhattan / diameter /
radius recovered at 4 000 leaves and not below, and euclidean (the default) and average never
recovered at any budget. All eight now read 1.0000 at every budget, because the rebuild ranks
its merges by what they cost rather than by how close the pair is, and the dense core is the most
expensive thing it can merge. The criterion is again a choice about absorption rather than a repair
for compaction. Lang's thesis tunes absorption for minimum variance and finds D4 × D2 best on
Gaussian data; D4 is ward, and it remains the one to reach for if you want the tree's routing and
its compaction to agree. chi2 still gets there with a fraction of the leaves (129 against 3 746 at
a 4 000 budget on that fixture), which is an operating point rather than a quality result — see the
next paragraph. Keep the default unless you have a reason: it is the cheapest and most stable gate,
and it no longer costs anything on unequal masses.
chi2 sets its own resolution, so it is a different operating point rather than a better
criterion. The gate stops subdividing once a cell is described, which on real data binds long
before max_leaves does: on covtype-20k it realises 46 leaves whether you ask for 500 or 4 000,
on digits 57, on MNIST-10k 920. What that buys and costs, medians of seeds 0/1/2 against the
default euclidean at the same budget (the fixtures here are the sub rule — subsample first,
standardize the subsample; bench/RESULTS.md § "Which covtype is this?" says why that has to be
stated):
| dataset | head | euclidean (leaves) |
chi2 (leaves) |
|---|---|---|---|
| covtype-20k @4000 | ward | 0.0861 (3 602) | 0.1199 (46) |
| covtype-20k @4000 | gmm | 0.0545 (3 602) | 0.0342 (46) |
| digits @2000 | ward | 0.6428 (1 797) | 0.4456 (57) |
| digits @2000 | kmeans | 0.4670 (1 797) | 0.5125 (57) |
| MNIST-10k @500 | ward | 0.1601 (487) | 0.2834 (476) |
| MNIST-10k @4000 | ward | 0.4275 (3 866), 7.38 s | 0.3209 (920), 0.42 s |
So it is not a general win and covtype is not representative of it: the gate wins where the budget is tight or the columns are a mix of continuous and rare binary, and loses where a generous budget lets a radius resolve the data. Where it wins it is also much cheaper — the MNIST row is 17× the wall clock for +0.107 ARI, which is the trade to weigh, not a free choice.
subspace reads the same χ² gate on the leaf's own basis, and only feature="fd" has one. Every
other feature model falls back to chi2, so the option changes nothing unless you asked for the
Frequent-Directions sketch. It takes the same chi2_p and chi2_scale, in the same units.
Use it when your clusters differ in orientation more than in location, and when the summary is
compressed — that second condition is the one that was missing. On a fixture where eight rank-3
subspaces share a single centre in 60 dimensions, summarised into 50 leaves for 2000 rows, leaf
purity reads 0.713 against chi2's 0.505 and plain euclidean's 0.626: seven of seven seeds,
mean +0.207 and +0.093, worst case +0.123 and +0.064, all three seed ranges disjoint. Widen the
budget and the gap closes on its own — at 300 leaves for 3000 rows every criterion lands in one
0.961–0.978 band, because ten points per leaf resolve the subspaces whatever decides the absorption.
The gate earns its keep where leaves have to hold a crowd.
At a large budget it still leads, by much less: six rank-5 subspaces in 100 dimensions, 20 000 rows
into 2000 leaves, read 0.9793 against chi2's 0.9601 (3/3 seeds, ranges disjoint). On
well-separated blobs it reaches the same ARI 1.0 with 6 leaves instead of 99.
On MNIST-20k it is a loss, and that is the case to weigh it against: ARI 0.250–0.260 against
chi2's 0.274–0.291 at every scale tried, with more leaves and ~20 % more time (the gate costs
O(ℓ²d) per decision against O(d), which shows at d=784 and not at d=100). Real image data at
leaf scale did not have the structure the gate is built to find.
One caveat worth stating because it bounds what the option can currently buy: on that concentric
fixture both gates score ARI ≈ 0.05 while purity is 0.82–0.96. Every head here assigns by centroid,
so a better-oriented summary has nothing to consume it — subspace improves the tree, not yet the
answer.
Choosing a head¶
| your data / goal | method |
needs k? |
|---|---|---|
| compact/spherical groups, fastest | kmeans |
yes (or 0 = BIC sweep, capped at 20) |
| the same, but the count may exceed 20, or the sweep is too slow | xmeans |
no — n_clusters is an upper bound; see Where xmeans refuses to split |
| the same, but the centre must be a real observation (an exemplar you can show) | kmedoids |
yes — 0 switches objective to the medoid silhouette |
| a graded membership with no density behind it (each point partly in several clusters) | fuzzy-cmeans |
yes (or 0 = Xie–Beni) — read A soft head that fits no density first: the hard partition is a loss against kmeans |
| elliptical / correlated / anisotropic, soft assignment | gmm (diag) or gmm-full |
yes (or 0 = BIC) |
clusters on low-dimensional subspaces, d too large for gmm-full |
mppca + feature="fd", rank = the intrinsic dimension — read rank, and where mppca loses first |
yes (or 0 = BIC) |
| the same, but the columns are in different units and cannot be standardised | mfa — per-axis noise instead of one σ² — read Per-axis noise, and the narrow case for it first: on a common scale mppca wins every table measured |
yes (or 0 = BIC) |
| L2-normalized embeddings (CLIP / face / sentence / speaker), cosine geometry | spherical-kmeans (hard) or vmf (soft) — read A concentration per cluster, and where it costs before vmf |
yes (or 0 = BIC, vmf) |
the same, but the sign is arbitrary — eigenvectors, SVD/PCA axes, line orientations, any feature where x and −x mean the same thing |
watson — read Directions without a sign |
yes (or 0 = BIC) |
| a hyperbolic embedding of a hierarchy (Poincaré / Lorentz coordinates of a tree, taxonomy, scale-free graph) | hyperbolic — read Clustering in the hyperbolic plane first: the win is invariance, not ARI, and a Poincaré-ball chart plus gmm-full scores higher on a centred embedding |
yes — no auto-k |
| a cluster hierarchy / merge structure | ward |
yes (or 0 = dendrogram cut) |
| non-convex / manifold shapes (moons, rings, spirals) | spectral |
yes (pair with a small threshold) |
| community / graph structure, unknown count | leiden (or leiden-cpm) |
no — count is discovered; tune resolution |
| variable-density clusters + noise, unknown count | hdbscan |
no |
the same density structure, but you know k and want a partition rather than noise |
dc-median |
yes — read Naming k on the density hierarchy; dc-center is the other objective and is mass-blind |
density peaks, arbitrary count, no k or bandwidth to pick |
scale-space |
no — scale chosen by mode persistence |
| ordered / stationary signals (time-series windows, trajectories, sensor waveforms), covariance shape | gmm-toeplitz |
yes (or 0 = BIC) |
| ordered signals with structure beyond a low-order AR (long-lag echo, narrowband) | gmm-toeplitz-full (any lag) or gmm-toeplitz-gs (likelihood-optimal precision, ≤ order 16) |
yes (or 0 = BIC) |
| topological skeleton / #components / loops | mapper() |
no |
n_clusters=0 auto-selects k for the parametric heads; leiden / hdbscan always discover it
(leiden reads the count off the graph — tune granularity with resolution γ, higher ⇒ more).
For a robustness score per point, wrap any partitional head in consensus (see below).
The leaf feature a covariance head can read¶
feature="spherical" keeps one scalar of within-leaf scatter per leaf. gmm, gmm-full and mfa
read a per-component covariance off that summary, and a scalar cannot supply one: Spherical's
cov_dense hands back ssd/(w·dim) · I, so every component gets the same isotropic term added,
ln|Σ_c| moves with it, and the maximum-posterior argmax follows. On digits at ×2.0 compression
that is ARI 0.0097 against 0.4343 for feature="diagonal" — a collapse, not a degradation. The
library raises on that pair rather than warning, and tune()'s default space does not propose it.
Pass feature="diagonal" (or "full", or "fd" in high dimension).
The exemptions are measured, not assumed. mppca is unaffected because its own model is a
low-rank subspace plus isotropic noise — its σ² absorbs the addition — and the gmm-toeplitz
family reads covariance off the between-leaf structure and scores identically on both features. The
centroid, linkage, graph and density heads never touch the leaf covariance at all. Numbers in
bench/RESULTS.md.
auto_k_max — the ceiling n_clusters=0 searches under¶
Two families of selector sit behind n_clusters=0, and they pay for a wide search differently.
A sweep — kmeans, gmm, gmm-full, mppca, mfa, vmf, watson and the three gmm-toeplitz rungs — refits
the whole head at every candidate k and keeps the best BIC. Its work is Σ_{k≤K} k = O(K²), so the
ceiling is the only thing bounding it, and it defaults to 20. A cut selector — ward,
average, weighted, centroid, median — builds one dendrogram and scores its cuts, and xmeans
stops on its own split test; a wider ceiling costs those a linear pass and nothing more, so they are
bounded only by the leaf count.
Measured on 480 leaves in 64 dimensions holding 120 true groups:
| head | ceiling 20 | ceiling 120 | ceiling = leaf count (480) |
|---|---|---|---|
ward |
5.7 ms, k = 2, ARI 0.009 |
7.8 ms, k = 120, ARI 1.000 |
23.8 ms, k = 120, 1.000 |
kmeans |
45.5 ms, k = 20, ARI 0.109 |
1449 ms, k = 120, 1.000 |
— |
gmm |
502 ms, k = 20, ARI 0.109 |
4100 ms, k = 120, 1.000 |
— |
xmeans |
— | — | 12.1 ms, k = 120, 1.000 |
(the_cost_of_the_auto_k_ceiling in src/model.rs, cargo test --release --all-features --lib --
--ignored --nocapture the_cost_of_the_auto_k. The leaf-count column is left unmeasured for the two
sweeps rather than run for minutes to restate what the 120 column already shows.)
Read three things off it. The ceiling is not a mild preference — at 20 the answer is wrong, not
merely coarse. Lifting it for a sweep costs 8–32×, which is why the default stays at 20 and
auto_k_max is an explicit opt-in (0 = the default). And on k-means-shaped data xmeans gets the
same partition as the fully-swept kmeans 120× faster, which is the head to reach for before
auto_k_max.
A selection that lands exactly on its ceiling raises a UserWarning: an argmax on the last candidate
is evidence the search stopped early, not evidence about the data.
How many restarts a k-means head needs — n_init¶
Lloyd converges to a local optimum of the inertia, so the k-means heads run n_init k-means++
draws and keep the lowest-inertia one. None (the default) leaves each on its own count of 4.
MNIST-20k, feature="spherical", method="kmeans", max_leaves=4000, threshold=0, ARI and time
the median of seeds 0/1/2 (the same harness the published tables use — the sub fixture rule, so
the covtype-20k figures below are not the budget study's covtype-20k):
n_init |
mnist ARI | mnist time | digits ARI | covtype ARI |
|---|---|---|---|---|
| 1 | 0.2873 | 1.37 s | 0.4513 | 0.0739 |
| 4 (default) | 0.3069 | 2.22 s | 0.4670 | 0.0739 |
| 10 | 0.3069 | 3.26 s | 0.5698 | 0.0562 |
| 25 | 0.3303 | 6.64 s | 0.4670 | 0.0912 |
| 50 | 0.3303 | 11.65 s | 0.4670 | 0.0486 |
sklearn.cluster.KMeans(n_init=10) on the raw rows |
0.3244 | 13.45 s | — | 0.0539 |
So on MNIST 25 restarts pass scikit-learn's score at half its wall clock, and cost 3× the default
fit; on digits the same 25 score below 10, and on covtype the column has no direction. The
restart is chosen by inertia, which is not ARI: more draws reliably buy a lower objective and only
sometimes buy better labels. Raise it when the head is the bottleneck and you can afford the linear
cost, and check the result rather than assuming it.
On "spectral" it is close to free and buys nothing, which is why the default does not move.
Its k-means runs on a k-dimensional embedding of at most max_leaves rows rather than on the leaf
features, so a restart is cheap: measured at n_init ∈ {4, 10, 25, 100}, medians of seeds 0/1/2, the
whole default allotment of four restarts is 0.5–2 % of the fit (digits 0.20 s → 0.28 s and
covtype-20k 0.39 s → 0.44 s at a hundred restarts, i.e. ~0.8 ms and ~0.5 ms each). The quality curve
is flat: moons 0.9999 and circles 1.0000 identical to four decimals at every count, digits
0.6688 → 0.6697 → 0.6697 → 0.6695, and covtype-20k moves 0.0798 → 0.0696 inside a seed spread of
0.0657–0.0992 at the default. The eigensolve is deterministic and its embedded clusters are
well-separated, so every restart lands in the same optimum — the restart count is not the lever on
this head.
Only the heads whose labels come from an inertia-selected k-means take it — "kmeans",
"spherical-kmeans", "spectral" (its embedding k-means) and the COP-KMeans behind
fit_constrained. The EM heads restart too, but they select by likelihood, and moving their count
1 → 25 was measured to be non-monotone on all three datasets (bench/RESULTS.md), so n_init on a
head that would ignore it raises a ValueError rather than passing silently.
Where xmeans refuses to split — method="xmeans"¶
kmeans with n_clusters=0 fits a full k-means at every k and keeps the best BIC. That costs
O(k_max²) passes over the leaves, which is why its cap is 20. xmeans (Pelleg & Moore, ICML 2000)
asks a different question: it tests each centre separately for a 2-way split and stops when none
wants one, so the cost is O(k) two-centre problems on shrinking subsets and there is nothing to
cap. n_clusters is an upper bound here, not a target; 0 bounds it only by the leaf count.
The split test has a threshold, and it is a function of d. A balanced binary split costs
n·ln 2 of mixture-weight likelihood and buys ½·n·d·ln(S₁/S₂), so it is accepted only when the cut
captures more than 1 − 2^(−2/d) of the region's sum of squares:
d |
2 | 5 | 10 | 32 | 64 |
|---|---|---|---|---|---|
| fraction of the scatter one cut must capture | 0.50 | 0.24 | 0.13 | 0.043 | 0.022 |
A cut through a cloud that is round at every scale captures about 0.64/d, always less than the
1.39/d the rule asks for. The recursion therefore starts at k = 2, not at k = 1 — a greedy
splitter has no way back from a refused split, and at k = 1 the entire answer rides on the one
comparison the threshold answers worst, since a layout of many well-separated groups is itself
close to round. Measured: from k = 1 the head is exact at 10, 20 and 30 blobs and then collapses
to k = 1 in five of twenty (k*, seed) cells, in every seed at k* = 60, and on a 3×3 grid of
nine equal 2-D blobs. From k = 2 all of those return the true count. Pelleg & Moore, ELKI and
pyclustering all start at 2 for this reason.
What survives the fix is the threshold itself, at d = 2 and a large k. Measured on random blob
layouts, 4 leaves per blob, 40 points per blob, median of seeds 0/1/2, with kmeans/n_clusters=0
at its shipped cap of 20 and xmeans bounded only by the leaf count:
d |
true k |
kmeans |Δk| |
kmeans s |
xmeans |Δk| |
xmeans s |
|---|---|---|---|---|---|
| 2 | 10 | 0.3 | 0.0012 | 0.7 | 0.0001 |
| 2 | 30 | 10.0 (capped) | 0.0030 | 6.7 | 0.0005 |
| 5 | 10 | 0.0 | 0.0012 | 0.0 | 0.0001 |
| 5 | 30 | 10.0 (capped) | 0.0034 | 0.0 | 0.0005 |
| 10 | 10 | 0.0 | 0.0016 | 0.0 | 0.0001 |
| 10 | 30 | 10.0 (capped) | 0.0048 | 0.0 | 0.0008 |
| 32 | 30 | 10.0 (capped) | 0.0067 | 0.0 | 0.0010 |
| 64 | 30 | 10.0 (capped) | 0.0090 | 0.0 | 0.0014 |
Read the rule off the table: at every d ≥ 5 xmeans lands on the true count, including where the
sweep is stuck at its cap, for 6–16× less time. At d = 2 it still under-splits once the count is
large — 6.7 short of 30 — because that is where one cut has to capture half the scatter; use
kmeans with n_clusters=0 there, since a sweep compares k = 1 against k = 30 directly and
never has to pass through the cuts the split test refuses.
A centre that exists in the data — method="kmedoids"¶
kmedoids runs eager FasterPAM (Schubert & Rousseeuw 2021) over the leaf centroids, weighting each
leaf by its mass. The centre of a cluster is one of the summary's own micro-clusters rather than an
average, which is the point: it is an exemplar you can show, and it stays on the data manifold where
a mean need not.
It is exact on the summary under the whole-leaf restriction every shipped head accepts. With the medoid drawn from the leaf-centroid set,
so the leaf-level total the swap search minimises is the point-level sum of squares, up to the constant \(\sum_i S_i\) no medoid choice can move. Note the square: classical PAM minimises the sum of absolute distances, and \(\sum_{x \in \text{leaf}} \lVert x - \mu_j \rVert\) has no closed form in a cluster feature. The squared objective is the one a summary can answer exactly, and it is what this head reports as its loss.
n_clusters=0 is not a free auto-k here: total deviation falls monotonically as k grows, so
this head's own objective cannot choose. The automatic arm runs dyn_msc — the medoid silhouette of
Lenssen & Schubert 2024, a different objective — and says so rather than pretending the sweep is free.
refine is a no-op. A Lloyd sweep is the k-means update; it would move each medoid to the mean of
its cluster, off the data and out of the head's own objective.
Measured on digits (1797 × 64, feature="spherical", threshold=0.0), ARI as median [min–max]
over seeds 0–4:
max_leaves |
leaves | kmeans |
kmedoids |
kmeans s |
kmedoids s |
|---|---|---|---|---|---|
| 2000 (one leaf per point) | 1797 | 0.467 [0.443–0.571] | 0.554 [0.554–0.570] | 0.025 | 0.157 |
| 300 | 296 | 0.487 [0.381–0.523] | 0.520 [0.468–0.520] | 0.007 | 0.008 |
| 120 | 115 | 0.240 [0.202–0.252] | 0.219 [0.219–0.219] | 0.005 | 0.004 |
Two things to read off it. The medoid restriction is a regulariser: at a fine summary it wins on the
median and on the spread, because the centre cannot drift into the gap between two digit classes.
And it inverts at a coarse summary — 115 candidate centres is not enough to place ten of them well,
and there kmeans is free to put a centre where no leaf sits. The O(m²) swap pass is what costs:
invisible at 296 leaves, 6× kmeans at 1797.
On the synthetic fixtures at max_leaves=4000 the two are level (blobs 0.864/0.864, aniso
0.545/0.548, varied 0.540/0.548, highdim 1.000/1.000, median of seeds 0/1/2), which is the
expected result: where the mean is a good centre, so is the nearest micro-cluster to it.
fit_predict_sparse does not accept this head — see Sparse input.
A soft head that fits no density — method="fuzzy-cmeans"¶
fuzzy-cmeans is weighted fuzzy c-means (Bezdek 1981) over the leaf centroids. It minimises
with \(\sum_j u_{ij} = 1\). Tying the membership within a leaf makes the leaf-level objective equal
to the point-level one — the same König–Huygens identity every centroid head here rests on — so
d_ij is exact and the head pays leaf cost for a point-level answer. The alternating minimiser is
\(u_{ij} \propto d_{ij}^{-1/(m-1)}\) and \(c_j = \sum_i u_{ij}^m n_i \mu_i / \sum_i u_{ij}^m n_i\); the
mass \(n_i\) cancels out of the membership normalisation and survives only in the centre update.
It is the only soft head in the crate that fits no density. predict_proba returns the memberships
themselves — a partition of unity over the centres, not a posterior, and not comparable across
fits with different m. n_clusters=0 selects by Xie–Beni, \(J_m / (W \cdot \min_{i \ne j} \lVert c_i - c_j \rVert^2)\),
which is this family's own validity index rather than a borrowed likelihood criterion.
Read this before choosing it: the hard partition is a loss. ARI on the bench fixtures at
max_leaves=4000, feature="spherical", threshold=0.0, median of seeds 0/1/2:
| fixture | kmeans |
fuzzy-cmeans m=1.3 |
fuzzy-cmeans m=2.0 |
|---|---|---|---|
blobs |
0.864 | 0.847 | 0.843 |
aniso |
0.545 | 0.535 | 0.531 |
varied |
0.540 | 0.531 | 0.537 |
highdim |
1.000 | 1.000 | 1.000 |
digits |
0.467 | 0.483 | 0.356 |
The intuition that a soft head should win where clusters overlap is the wrong way round, and the
overlap sweep says so directly — 6 000 points, 8 dimensions, 5 centres, max_leaves=2000, median of
seeds 0/1/2. AUC is how well assignment_confidence separates correctly- from wrongly-labelled
points, so it scores the membership rather than the partition:
cluster_std |
kmeans ARI / AUC |
m=1.3 |
m=2.0 |
|---|---|---|---|
| 3.0 | 0.990 / 0.985 | 0.990 / 0.975 | 0.990 / 0.955 |
| 4.0 | 0.922 / 0.931 | 0.913 / 0.916 | 0.889 / 0.830 |
| 5.0 | 0.804 / 0.881 | 0.623 / 0.836 | 0.580 / 0.792 |
The gap widens with overlap, and it widens with m, because both push the same mechanism: no
membership is ever zero, so every point contributes to every centre and each centre is pulled
toward the grand mean. Measured on the cluster_std=5.0 fixture (seed 0), the mean distance from a
cluster centre to their common centroid runs 13.75 for kmeans and 13.19 / 12.98 / 11.88 for
m = 1.3 / 2.0 / 3.0, against a true 13.67. m is a contraction knob as much as a softness knob.
So take this head for the membership, not for the labels: a per-point degree of belonging that comes
out of the objective the fit minimised, where every other centre-based head can only offer the
softmax(−d²/2τ²) proxy with a τ nobody chose. If you want the labels, kmeans is faster and
better; if you want a calibrated posterior, gmm fits one.
Xie–Beni on the same fixtures (median over seeds 0/1/2, showing all three): exact on highdim
(8/8/8 for a true 8) and on the separated overlap fixtures (5/5/5 at cluster_std ≤ 3.0); it
under-counts on blobs (2/4/4 for 6) and aniso (2/2/2 for 3), over-counts on varied (4/4/4 for
3), and on digits runs into the sweep ceiling (18/20/19 for 10, which raises the saturation
warning). Like every sweep selector here it costs a refit per candidate k, so auto_k_max bounds
it at 20 by default.
refine is a no-op, for the same reason it is under kmedoids: a Lloyd sweep replaces each
membership-weighted centre with the hard mean of its argmax cluster, which is a different objective
under the same name.
rank, and where mppca loses — method="mppca"¶
mppca constrains each component covariance to W_c W_cᵀ + σ_c² I with W_c of rank rank: a
rank-dimensional principal subspace plus isotropic noise. It buys gmm-full's orientation at
O(d·rank) per component instead of O(d²), which is what makes it usable at d = 784 where the
full head's per-leaf dense scatters need ~38 GB and simply do not run. Pair it with feature="fd",
whose leaf scatter is already low-rank, and the E-step never forms a d×d matrix either.
rank is the intrinsic dimension of a cluster, and the fit finds it. Six 5-dimensional
subspaces sharing one centre in 100-D — where every centroid coincides and orientation is the only
signal — max_leaves=2000, median of seeds 0/1/2:
rank |
2 | 3 | 5 | 10 | 20 | gmm (diag) |
|---|---|---|---|---|---|---|
| ARI | 0.385 | 0.654 | 0.998 | 0.823 | 0.727 | 0.166 |
The peak is exactly at the true rank, and the band at rank=5 is [0.9976, 0.9984] — this is not a
lucky seed. Overshooting costs less than undershooting here; on a compressed summary it costs much
more, which is the next paragraph. Where the centroids are far enough apart to separate the clusters
on their own, the extra parameters cost nothing: on the same six subspaces pulled apart, gmm and
mppca both score 1.0000 at every rank from 2 to 20.
The trade is against compression, not against dimension. The expected-log E-step folds each
leaf's own scatter into the component covariance as −½ tr(Σ_c⁻¹ Σ_i). That within-leaf scatter is
locally oriented and adds up to a term that carries almost none of the between-cluster
orientation — so the more orientation a head models, the more the summary costs it. Measured on
digits (1797×64, feature="fd", median of seeds 0/1/2), where max_leaves=2000 gives one leaf per
point and the correction is exactly zero:
max_leaves |
leaves | gmm (diag) |
mppca rank=5 |
mppca rank=10 |
gmm-full |
|---|---|---|---|---|---|
| 2000 | 1797 (= n) | 0.461 | 0.600 | 0.555 | 0.575 |
| 300 | 296 | 0.493 | 0.406 | 0.348 | 0.273 |
| 120 | 115 | 0.235 | 0.168 | 0.121 | 0.099 |
At full resolution mppca beats both the diagonal head and the full head at a fraction of the
parameters. At 6:1 compression the ordering inverts, and it inverts in exact order of how much
orientation each head carries. On MNIST-20k (784-D, max_leaves=2000 ⇒ 1880 leaves, 10.6:1) that
puts mppca behind the diagonal head at every rank tried — ARI 0.159 / 0.069 / 0.024 for
rank 2 / 5 / 10 against gmm's 0.274 — and the loss grows with rank, as the mechanism
predicts. Use mppca when the summary is fine relative to the clusters; use gmm when it is coarse.
Per-axis noise, and the narrow case for it — method="mfa"¶
mfa is mppca with one constraint removed: the residual a component cannot explain with its
rank-dimensional subspace is per dimension, Σ_c = W_c W_cᵀ + diag(ψ_c), rather than a single
σ_c² I. That is the classical mixture of factor analysers (Ghahramani & Hinton 1996). It costs
d − 1 extra parameters per component — against d²/2 for gmm-full — and it removes the head's
rotation equivariance, exactly as gmm (diagonal) does: diag(ψ) is a statement about the
coordinate axes, so the answer depends on them. rank=0 is a diagonal Gaussian mixture, bit for bit.
The two heads dissociate in both directions. Which one is right is a question about where the signal sits relative to the axis scales, and neither answer is general:
| fixture (6-D, ARI over 5 seeds) | mfa |
mppca |
gmm |
|---|---|---|---|
| separation on a quiet axis, two loud nuisance axes | 1.00 | 0.04–0.34 | 1.00 |
| three lines differing only in orientation | ≈ 0.00 | 1.00 | ≈ 0.00 |
The first row is mppca paying for its isotropic residual: one σ² has to cover an axis of sd 1.6
and an axis of sd 0.12 at once, and the loud pair wins. The second is the mirror image — ψ can
absorb an elongation that belonged in W, so the factor never has to find the orientation, and the
head lands on the diagonal answer.
On real tables it is behind mppca at full leaf resolution everywhere it was tried. ARI /
seconds, rank=2, threshold=0, median of seeds 0/1/2:
| dataset | feature |
max_leaves |
leaves | gmm |
gmm-full |
mppca |
mfa |
|---|---|---|---|---|---|---|---|
digits (1797×64) |
full |
2000 | 1797 | 0.507 | 0.754 | 0.738 | 0.562 |
| 300 | 270 | 0.576 | 0.623 | 0.657 | 0.523 | ||
| 120 | 114 | 0.623 | 0.676 | 0.675 | 0.660 | ||
digits |
fd |
300 | 270 | 0.562 | 0.467 | 0.652 | 0.502 |
| 120 | 114 | 0.611 | 0.278 | 0.671 | 0.631 | ||
covtype-20k, raw units (54-D) |
full |
2000 | 1815 | 0.045 | 0.080 | 0.087 | 0.062 |
| 300 | 299 | 0.063 | 0.047 | 0.046 | 0.063 | ||
covtype-20k, standardised |
full |
2000 | 1899 | 0.077 | 0.077 | 0.030 | 0.077 |
| 300 | 271 | 0.089 | 0.084 | 0.045 | 0.089 |
MNIST-20k (784-D, feature="fd", max_leaves=2000) says the same by rank — mppca
0.365 / 0.309 / 0.205 at rank 2 / 5 / 10 against mfa's 0.277 / 0.299 / 0.130 — at
indistinguishable cost, 30.9 / 76.7 / 185.8 s against 37.6 / 80.6 / 197.5 s. The extra d − 1
parameters are not what is expensive; they are what is hard to estimate.
What the head is actually for, then. Not a higher ceiling — a floor. It contains the diagonal
Gaussian mixture as its rank=0 limit and, where the factor finds nothing, converges onto it: on
standardised covtype it reproduces gmm's partition exactly at both budgets, while mppca — whose
model cannot fall back on a per-axis noise — scores less than half the diagonal head there
(0.030 against 0.077). Reach for mfa when the columns are in different units and you cannot
standardise them (a mixed feature table, a physical measurement whose scales are meaningful), and for
mppca when they are already on a common scale, which is every image, embedding and whitened
design matrix.
One reading to avoid: the covtype-standardised row is not evidence that W collapsed to zero.
At max_leaves=2000 in 54 dimensions the posterior is saturated — all 20 000 points sit at
max_c P(c|x) = 1 to within 1e-12, for mfa, gmm and gmm-full alike — so agreeing on
labels is what any mean-driven partition looks like there, and identical predict_proba output
restates the labels rather than the covariance. At max_leaves=300, where the posterior is no longer
saturated, the two partitions come apart — ARI 0.909 between them, not 1.
The fd rows start at 300 because at max_leaves=2000 every leaf holds one point, where the sketch
has nothing to truncate and reproduces full to the last digit.
A concentration per cluster, and where it costs — method="vmf"¶
vmf fits every cluster a mean direction and its own concentration κ. spherical-kmeans is the
same model with one κ shared by all clusters and taken to infinity, so the per-cluster κ is the
whole of what vmf adds — and on diffuse data it is the whole of its weakness. A row far from every
mean direction is more probable under a broad, low-κ component than under any tight one, so a broad
component out-bids the others for every ambiguous row and keeps growing.
Measured on 20-newsgroups, through the bench's own text pipeline (TF-IDF over 2 000 terms, English stopwords removed), reduced to 50 dimensions and L2-normalised: 18 200 non-empty posts, every row its own leaf, medians of seeds 0/1/2.
| head | ARI | largest cluster, share of rows |
|---|---|---|
spherical-kmeans |
0.175 | 0.103 |
vmf |
0.073 | 0.379 |
an independent movMF, one κ per cluster |
0.074 | 0.375 |
the same movMF, one κ shared by all clusters |
0.159 | 0.142 |
The true topics are near-equal, the largest 0.053 of the rows. The independent EM (Banerjee et al.,
JMLR 2005, started from a k-means partition) collapses the same way, and tying κ removes the
collapse, so this is the model and not the implementation. It is not the summary either: at 500, 2 000
and 8 000 leaves vmf reads 0.073–0.100 and spherical-kmeans 0.146–0.174. On data like this —
many rows about equally far from every cluster — start with spherical-kmeans, and read vmf's
cluster sizes before its labels. The case the extra parameter is for is tight clusters of unequal
spread, such as identities in a face or speaker embedding; that case is not measured here. On
digits-PCA20 (below) the two heads tie.
Directions without a sign — method="watson"¶
vmf models a direction: −μ is as far from μ as its density goes. A great deal of directional
data is not like that. An eigenvector, an SVD or PCA axis, a line's orientation, a fibre direction,
the output of any routine that fixes a sign arbitrarily — for all of them x and −x are the same
observation. Handed that, vmf spends half its components on the antipodes of the other half, and
where the two poles are equally populated its resultant \(\sum_i n_i \mu_i\) cancels to nothing.
The Watson distribution (Watson 1965; Mardia & Jupp 2000 §9.4.1) is the axial answer:
\((\mu^\top x)^2\) does not change when x flips sign, which is the whole point. \(M\) is Kummer's
confluent hypergeometric \(_1F_1\). κ > 0 is bipolar — mass at both ends of the axis; κ < 0 is
girdle — mass on the equator orthogonal to μ, which is how a co-planar structure is
described. Both signs are fitted, and which one a component takes is decided by likelihood, not by a
flag.
Input is auto-L2-normalized, exactly as for vmf / spherical-kmeans. n_clusters=0 selects k
by BIC.
Why the leaf summary is enough. The sufficient statistic is the second moment about the origin, and a cluster feature carries it exactly: \(E[x x^\top] = \Sigma_i + \mu_i \mu_i^\top\). So the E-step term \(E_{x \in \text{leaf } i}[(\mu_c^\top x)^2] = \mu_c^\top (\Sigma_i + \mu_i \mu_i^\top) \mu_c\) is closed-form and exact, and the within-leaf spread is integrated rather than dropped — the same status as the Gaussian heads' E-step. The M-step needs \(T_c = \sum_i r_{ic} n_i (\Sigma_i + \mu_i \mu_i^\top)\), also exact, and takes an extreme eigenvector of it.
Where it wins. N = 6 000, max_leaves=300, feature="full", k given, median of seeds 0/1/2 —
ARI / seconds. Each axial fixture puts half of each cluster's points at each pole of its axis:
| fixture | watson |
vmf |
spherical-kmeans |
gmm-full |
kmeans |
|---|---|---|---|---|---|
| axial, 8-D, 2 poles/axis | 0.870 / 0.114 | 0.208 / 0.009 | 0.269 / 0.007 | 0.309 / 0.013 | 0.233 / 0.008 |
| axial, 32-D | 0.953 / 0.176 | 0.218 / 0.041 | 0.202 / 0.039 | 0.316 / 0.093 | 0.244 / 0.038 |
| axial, 64-D | 0.961 / 0.653 | 0.221 / 0.131 | 0.234 / 0.129 | 0.200 / 0.384 | 0.232 / 0.130 |
| one pole per axis, 32-D | 0.952 / 0.186 | 0.976 / 0.041 | 0.976 / 0.034 | 0.966 / 0.082 | 0.976 / 0.034 |
| girdles, 8-D | 0.100 / 0.476 | 0.000 / 0.011 | −0.000 / 0.009 | 0.000 / 0.015 | −0.000 / 0.008 |
Three readings, in order of how much they should change what you do:
- Where the sign is genuinely arbitrary, the gap is not close — 0.87–0.96 against 0.20–0.32 for every other head, which is the difference between recovering the structure and not.
- Where it is not arbitrary,
watsoncosts you. Row four is the same generator with one pole per axis:vmfreads 0.976 andwatson0.952, because identifyingμwith−μthrows away information that was there. This is a head for a property of your data, not a strictly bettervmf. - A mixture of girdles is not recovered on this fixture.
κ < 0fits and every other head scores exactly zero, but 0.100 is not a result — three great circles through a common origin intersect, and near the intersections no axial model can separate them. Readκ < 0as a component that can describe a flat cluster, not as a girdle-mixture head.
Real data, where the sign ambiguity is the whole story. digits, PCA-20, L2-normalized;
flipped multiplies a random half of the rows by −1 and leaves the labels alone — the transformation
a sign-arbitrary pipeline applies for free:
| fixture | watson |
vmf |
spherical-kmeans |
gmm-full |
kmeans |
|---|---|---|---|---|---|
digits-PCA20 |
0.498 / 1.407 | 0.631 / 0.036 | 0.625 / 0.010 | 0.685 / 0.120 | 0.665 / 0.009 |
digits-PCA20, signs flipped |
0.503 / 1.282 | 0.173 / 0.020 | 0.204 / 0.009 | 0.284 / 0.341 | 0.221 / 0.010 |
watson moves by 0.005 across the flip and every other head loses 60–70 % of its score. That
invariance is the deliverable. It is also the whole trade: on the un-flipped data watson is the
worst of the five, because digits has no axial structure to find and the head has given up the sign
for nothing.
Which leaf model. The theory says the axial signal lives in the off-diagonal scatter that
spherical and diagonal leaves discard. The measurement says that is only true at a coarse budget.
Axial 32-D, k = 4, median of seeds 0/1/2; leaves is the realised count:
max_leaves |
leaves | spherical |
diagonal |
full |
fd |
median leaf-mean norm |
|---|---|---|---|---|---|---|
| 4 | 4 | 0.477 | 0.496 | 0.573 | 0.181 | 0.454 |
| 6 | 6 | 0.655 | 0.654 | 0.668 | 0.322 | 0.577 |
| 8 | 8 | 0.623 | 0.618 | 0.678 | 0.570 | 0.584 |
| 12 | 11 | 0.782 | 0.680 | 0.935 | 0.384 | 0.582 |
| 20 | 18 | 0.609 | 0.604 | 0.641 | 0.396 | 1.000 |
| 40 | 39 | 0.950 | 0.950 | 0.949 | 0.938 | 1.000 |
| 100 | 100 | 0.914 | 0.915 | 0.936 | 0.763 | 1.000 |
| 300 | 272 | 0.953 | 0.953 | 0.953 | 0.951 | 1.000 |
The last column is the mechanism. A leaf holding one pole has a mean of unit norm; a leaf straddling
both poles of its axis has a mean that cancels toward zero, and there the off-diagonal block is the
only thing left that names the axis. Above ~20 leaves every leaf sits on one pole, the mean alone is
enough, and the three dense leaf models tie. Below that full leads by 0.10–0.15 — on a budget whose
absolute quality is poor either way, and where the column is not monotone in the budget at all.
Take full; do not expect it to buy much once the summary is fine. fd is the one to avoid at a
coarse budget: the sketch's rank truncation costs it 0.2–0.55 there.
Cost. Axial fixture, k = 4, seed 0, seconds:
d |
max_leaves |
watson |
vmf |
gmm-full |
|---|---|---|---|---|
| 8 | 120 / 300 / 1000 | 0.062 / 0.086 / 0.121 | 0.007 / 0.008 / 0.018 | 0.010 / 0.012 / 0.036 |
| 64 | 120 / 300 / 1000 | 0.751 / 0.656 / 1.954 | 0.119 / 0.132 / 0.243 | 0.193 / 0.380 / 1.491 |
| 256 | 120 / 300 / 1000 | 16.218 / 12.660 / 29.536 | 2.336 / 2.396 / 4.873 | 3.105 / 4.357 / 14.957 |
It is the most expensive of the directional heads: 5–8× vmf, and 2–6× gmm-full. The cost is the
special function, not the linear algebra — every E-step normalizer and every κ solve runs an
ascending \(_1F_1\) series whose length grows with κ, which is why the head is capped at κ = 10⁴.
Auto-k. n_clusters=0 runs the BIC sweep. On the axial fixture it is the only head that
finds the axis count: vmf answers 8 for 4 true axes, one component per pole, which is exactly the
failure the head exists to fix.
| fixture | true k |
watson |
vmf |
|---|---|---|---|
| axial, 32-D | 4 | 4 | 8 |
| one pole per axis, 32-D | 4 | 4 | 4 |
| girdles, 8-D | 3 | 1 | 1 |
Numerics. log M(1/2, d/2, κ) and the concentration equation g(κ) = r̄ rest on two identities
verified symbolically in Maxima before any of it was written: M'(a,b,z) = (a/b) M(a+1,b+1,z), and
Kummer's transformation M(a,b,z) = e^z M(b−a,b,−z), which is what makes κ < 0 computable at all —
the ascending series alternates for a negative argument and loses every digit to cancellation, while
for a positive one every term is positive. The leading asymptotic is not used: measured against
Maxima it is still 42 % low at d = 50, κ = 50. g is strictly increasing with g(0) = 1/d, so
r̄ ≷ 1/d fixes the sign of κ before any solving and the solver cannot pick the wrong branch.
Clustering in the hyperbolic plane — method="hyperbolic"¶
Hierarchies embed into hyperbolic space with vanishing distortion where Euclidean space needs
exponentially many dimensions (Sarkar 2011; Nickel & Kiela 2017), so taxonomies, ontologies and
scale-free graphs increasingly arrive as points of H^d. This head clusters them there.
Rows are (d+1)-dimensional Lorentz coordinates, coordinate 0 time-like, and the boundary
recomputes it as x₀ = √(1 + ‖s‖²) before insertion — so a row that is slightly off the sheet is
projected rather than believed. A Poincaré-ball embedding p converts with
x = (1 + ‖p‖², 2p) / (1 − ‖p‖²).
Why a cluster feature can carry this at all. The head does not use the geodesic distance
d_H = arccosh(−⟨x,y⟩_L), which has no closed-form Fréchet mean. It uses the squared Lorentzian
distance of Law, Liao, Snavely & Dhillon (ICML 2019), d_L²(x,y) = −2 − 2⟨x,y⟩_L, which is a
strictly increasing function of d_H — so it orders pairs identically — and whose centroid is the
normalised sum μ = R/|R|_L with R = Σ_i n_i x_i.
d_L² is affine in each argument. Assigning a whole leaf costs −2n_i − 2⟨R_i, c⟩_L, which
reads the leaf only through (n_i, R_i). There is no scatter term at all — a linear function has
no second order — so unlike every other head here the leaf's covariance is not merely cheap to use,
it is not used. Measured, tree layout below, k = 16, seed 0: feature="spherical" /
"diagonal" / "full" all read ARI 0.6911, to four digits, because they are the same
computation. Take spherical; the others buy nothing.
The fixture. A 4-ary tree of depth 4 laid out in geodesic polar coordinates — depth sets the
hyperbolic radius (τ = 1.6 per level), each node owns an angular interval its children subdivide —
with 60 points per tree leaf, 15 360 points, and the depth-2 subtree (16 groups) as the truth.
threshold=0, max_leaves=2000, median of seeds 0/1/2.
Same points, three charts. A hyperbolic embedding is handed over in one of three coordinate systems, and all three are things people feed a clustering routine:
| chart | hyperbolic |
kmeans |
gmm |
gmm-full |
ward |
|---|---|---|---|---|---|
| Lorentz | 0.731 | 0.407 | 0.223 | 0.663 | 0.389 |
| Poincaré ball | — | 0.575 | 0.584 | 0.817 | 0.598 |
| tangent at the origin | — | 0.767 | 0.581 | 0.530 | 0.632 |
Read that honestly: on a centred embedding, converting to the Poincaré ball and running gmm-full
scores higher than this head does. The ball is a bounded chart that preserves angle and compresses
radius by tanh(r/2), which is exactly what a Euclidean head needs, and on this fixture the class
structure is angular. What the head wins is the Lorentz-coordinate column, where a Euclidean head is
ranking sinh r above angle and loses half its score.
Where that reading breaks. A Lorentz boost is an isometry of H^d — the same points, the same
hyperbolic distances, a different origin. Nothing about the data changed; a clustering that moves
under it was answering about the chart. Boosting the fixture by rapidity φ along one axis:
φ |
hyperbolic |
Poincaré + kmeans |
Poincaré + gmm-full |
tangent + kmeans |
|---|---|---|---|---|
| 0.0 | 0.731 | 0.575 | 0.817 | 0.767 |
| 0.5 | 0.653 | 0.618 | 0.637 | 0.647 |
| 1.0 | 0.675 | 0.577 | 0.584 | 0.590 |
| 2.0 | 0.614 | 0.478 | 0.513 | 0.523 |
| 3.0 | 0.596 | 0.348 | 0.311 | 0.375 |
The ball route loses 0.51 of its ARI and the head loses 0.14. The head is the one whose answer is a
property of the data rather than of where the embedding put its origin — and if you do not know
that your embedding is centred, which for a learned embedding you do not, the φ = 0 column is the
one you cannot count on.
And the residual 0.14 is not the head's. Both Lloyd steps read only (n_i, R_i) and Λ is
linear with |ΛR|_L = |R|_L, so the partition over a fixed leaf set is exactly boost-invariant
(asserted in tests/equivariance.rs). What moves is the CF-tree, which routes and absorbs in the
ambient Euclidean coordinates. Raise the budget until every point is its own leaf and the drift
disappears entirely:
max_leaves |
φ=0 |
φ=1 |
φ=3 |
|---|---|---|---|
| 500 | 0.634 | 0.569 | 0.576 |
| 2000 | 0.731 | 0.675 | 0.596 |
| 8000 | 0.809 | 0.583 | 0.696 |
| 20000 (one leaf per point) | 0.772 | 0.772 | 0.772 |
That is the honest boundary of the feature: the head is hyperbolic, the tree is not. A Lorentz-aware routing distance would close it and is not built.
No automatic k. Σ_c 2(|R_c|_L − W_c) falls monotonically in k, exactly as total deviation
does for kmedoids, so this head's own objective cannot select the count. n_clusters=0 yields a
single cluster; name the k you want.
Constraints. No predict_proba — there is no density, and the Euclidean softmax fallback the
other centroid heads use would disagree with this head's own labels, so it raises instead. No
projection= — a code vector has no time-like coordinate. No sparse (CSR) input — the sheet
projection writes a nonzero into column 0 of every row. No refine — a Lloyd sweep is the Euclidean
update.
The working radius is finite and is not a detail. ⟨x,y⟩_L is a difference of two nearly equal
Θ(cosh² r) terms against an Θ(1) answer, so f64 has a hard ceiling at r ≈ ½ ln(2/ε) ≈ 18.4.
Measured against 60-digit mpmath, relative error of d_L² on two points a unit apart: 1e−16 at
r = 0, 1e−8 at 10, 2e−3 at 15, and the sign flips at 18. r ≲ 10 is exact for any purpose;
past 18 nothing is. This is a property of the representation — a Poincaré-ball implementation
saturates earlier — which is why the Rust API's HyperbolicKMeans reports the largest radius it
saw.
How many leaves the spectral head can use — method="spectral"¶
Every leaf is a node of the graph. It did not used to be: above 256 microclusters the head reduced
them to 256 weighted k-means landmarks and let each leaf inherit its landmark's label, which is a
second lossy summarisation on top of the tree's. What forced that was the O(M³) dense eigensolver,
and it has been replaced by a Chebyshev-filtered subspace iteration that only ever multiplies by the
sparse graph. Two internal boundaries remain, both about cost rather than correctness: the exact
Jacobi solver runs to 256 nodes, the exact O(M²) affinity to 2048, and past each the approximate
route takes over.
What that buys, A/B against the landmark path on identical trees, median of seeds 0/1/2:
max_leaves |
landmark | Chebyshev | |
|---|---|---|---|
| two-moons / two-circles, N=20 000 | 500 → 5000 | ARI 1.000, 0.20–0.43 s | ARI 1.000, 0.02–0.36 s |
digits-PCA20 |
500 | 0.660, 0.36 s | 0.779, 0.03 s |
| 1000 | 0.786, 0.40 s | 0.801, 0.06 s | |
| 1797 (= n) | 0.766, 0.50 s | 0.735, 0.19 s |
The head has a leaf ceiling anyway, and it is the graph's, not the solver's. At 10 000 leaves the
non-convex fixtures still score ARI 1.000 in 0.7 s. At 20 000 — one leaf per point — they fall to
≈ 0.6, and forcing the exact affinity there gives the same answer for ten times the wall clock. The
cause is the fixed neighbour count: 10 neighbours is 1.0 · log n at 20 000 nodes, at the
connectivity threshold below which a k-NN Laplacian's spectrum stops describing the manifold.
But where that ceiling sits scales with the dimension, and on high-dimensional data it is far
higher than the fixtures above suggest. MNIST, 20 000 rows standardized, k = 10, median of seeds
0/1/2 — the same head, only max_leaves moving:
max_leaves |
256 | 512 | 1024 | 2048 | 4000 | 8000 | 12 000 | 16 000 | 20 000 (= n) |
|---|---|---|---|---|---|---|---|---|---|
| ARI | 0.000 | 0.007 | 0.025 | 0.050 | 0.082 | 0.203 | 0.235 | 0.328 | 0.299 |
The curve is monotone until it turns at one leaf per point, and the peak is at 16 000 — where
this page's own hires table already puts it. At 4 000, the bounded-memory budget the comparison
table uses, the head reads 0.082 against the landmark path's 0.203; at 8 000 it matches that answer
in 6.21 s against 43.87 s, and at 16 000 it passes it outright. What the landmark reduction was
doing beyond saving time was smoothing: 256 weighted k-means landmarks condition the affinity graph
better than 4 000 raw 784-dimensional centroids do, so the Chebyshev head needs the resolution back
before it can beat them — and buys it cheaply.
So do not read "a few hundred to a few thousand leaves" as the rule. Give the head the resolution the
data's dimension asks for, stop short of one leaf per point, and expect the useful budget to be a
large fraction of n in high dimension and a small one in two.
min_samples on a summary — hdbscan¶
min_samples and min_cluster_size are counted in points, not in leaves: a leaf contributes its
whole weight, so both arguments mean the same thing whether the head sees one feature per point or a
summary of a million of them. Transfer them from sklearn.cluster.HDBSCAN unchanged.
What does not transfer is a small min_samples. HDBSCAN* separates overlapping densities
through the core distance — the radius enclosing min_samples points. A single leaf already holds
N / max_leaves points at one coordinate, so any min_samples below that leaf mass is enclosed at
radius zero, every core distance collapses, and mutual reachability degenerates to plain distance,
i.e. single linkage, which chains through overlaps. Measured on six overlapping 2-D Gaussians,
N = 100 000, min_cluster_size = 250 (ARI, clusters found):
min_samples |
10 | 100 | 1 000 |
|---|---|---|---|
max_leaves = 2 000 (leaf mass 50) |
0.480 (24) | 0.477 (24) | 0.784 (5) |
max_leaves = 8 000 (leaf mass 12) |
0.569 (4) | 0.800 (5) | 0.849 (6) |
So set min_samples comfortably above N / max_leaves, or raise max_leaves until the leaf mass
falls below the min_samples you want. On well-separated clusters neither matters; on overlapping
ones it is the difference between single linkage — which here sheds two dozen fragments off the
overlap — and finding six.
That is a rule the library can apply itself, and by default it does. min_samples=None — the
default, spelled "auto" if you prefer to say it — asks for the mass of ten average leaves,
which is the conventional ten-point default translated into the currency the head is counting in.
On the same fixture it reads 0.821 at max_leaves = 2 000 and 0.822 at 8 000 (medians of seeds
0/1/2, where the fixed ten reads 0.612 and 0.717), against fast_hdbscan's 0.910 over the raw
points. The plateau is wide — five to forty leaves are all inside the seed spread there — so the
constant is the shape of the rule rather than a fit to it.
On the six published quality fixtures at N = 30 000 (median of seeds 0/1/2) the same rule costs
nothing anywhere and gains on varied:
| fixture | min_samples=10 |
automatic |
|---|---|---|
| blobs | 0.423 | 0.429 |
| varied | 0.568 | 0.839 |
| aniso | 0.568 | 0.565 |
| moons | 0.9999 | 0.9996 |
| circles | 1.0000 | 0.9999 |
| highdim | 1.0000 | 1.0000 |
It is not a win everywhere. On two overlapping blobs at N = 100 000 the fixed ten beats it at 200
and 2 000 leaves — ARI 0.484 and 0.452 against 0.000 and 0.337 — and ties it at 8 000; the grid is
in bench/RESULTS.md.
Pass an integer whenever you want the scikit-learn number instead; it is used exactly as given. The
same automatic value serves dc-center / dc-median, which read the same core distances.
Graph-indexing the density head — graph_degree¶
The exact head is quadratic in the leaf count twice over: a full sort per leaf for the core
distances, then Prim over the complete mutual-reachability graph. That is what makes a large
max_leaves unaffordable exactly where the section above says a density head needs one.
graph_degree > 0 replaces both with the two-pass construction of Okkels et al. (Inf. Syst. 142
(2026) 102768, Alg. 4): build a bounded-degree approximate k-NN graph over the leaf means, read the
core distances off that graph, take an exact MST of it. The graph is flat — no HNSW layer stack,
following Thordsen & Schubert (SISAP 2025), who find the hierarchy buys little in high dimension and
that a capped beam search is the part worth keeping — with three uniformly random out-edges per
vertex standing in for the long edges the upper layers would have contributed.
The number is a floor, not a ceiling. Core distances read off a graph saturate at the farthest
neighbour, so a degree below what min_samples needs underestimates every core distance with no
bound on the error. The head therefore raises the requested degree to min_samples / mean leaf mass
whenever that is larger; graph_degree=1 is a request for the cheapest legal graph, not a broken one.
Median of seeds 0/1/2, one BLAS thread, min_cluster_size = N/100, min_samples = 4N/max_leaves
(the rule the section above argues for). "head" is the time after subtracting the identical tree
build, which is what the parameter changes:
| dataset | max_leaves |
exact head | ARI | graph_degree=16 |
ARI | graph_degree=32 |
ARI |
|---|---|---|---|---|---|---|---|
| blobs 100 k, 2-D | 2 000 | 0.11 s | 0.5645 | 0.02 s | 0.5596 | 0.04 s | 0.5634 |
| 8 000 | 2.04 s | 0.5668 | 0.11 s | 0.5530 | 0.21 s | 0.5640 | |
| 32 000 | 36.6 s | 0.5674 | 0.52 s | 0.5454 | 0.98 s | 0.5608 | |
| covtype 581 k, 54-D | 2 000 | 0.41 s | 0.0531 | 0.20 s | 0.0496 | 0.24 s | 0.0519 |
| 8 000 | 4.61 s | 0.0490 | 0.28 s | 0.0374 | 0.45 s | 0.0457 | |
| MNIST 70 k, 784-D | 2 000 | 3.00 s | 0.0298 | 0.13 s | 0.0298 | 0.28 s | 0.0298 |
| 8 000 | 52.0 s | 0.0523 | < 0.5 s | 0.0523 | 0.45 s | 0.0523 |
At 32 000 leaves on blobs the head goes from 36.6 s to 1.0 s — 37× — for 1.2% of the ARI. On MNIST at 8 000 leaves it goes from 52.0 s to 0.45 s — 116× — for no ARI at all: the graph reproduces the exact partition to four decimals with the same ten clusters. The trade is monotone in the degree and it is the degree, not the graph, that costs the quality: doubling 16 to 32 recovers most of the loss at half the saving. Below ~2 000 leaves the exact path is already cheap and there is nothing to buy.
Degree 8 is not enough in high dimension, whatever the leaf budget: on MNIST it gives ARI 0.0190
with 4 clusters against the exact 0.0523 with 10, and swings across [0.0068, 0.0600] between seeds.
16 is the smallest degree measured to be lossless at d = 784.
What it does not fix. The approximation is in which edges the MST may choose from, never in the
criterion; where the head is weak on the exact graph (covtype: ARI 0.05 at any budget) it stays
weak on the approximate one. graph_degree buys leaves, not quality.
Refining on the raw points — refine¶
refine=n runs BIRCH's Phase 4: n Lloyd sweeps over the raw rows, warm-started from the Phase-3
centres. It is off by default, applies only to the centroid heads (kmeans, spherical-kmeans),
and only to the in-memory fit / fit_predict — partial_fit keeps a tree, not the data, and the
sparse path would have to densify the matrix it exists to avoid.
It moves the objective where the summary is coarse relative to the data. MNIST (first 20 000 rows,
784-D, StandardScaler, k=10, spherical CF, threshold=0, max_leaves=4000; median of seeds
0/1/2 — local/scratch/refine_claims.py):
| ARI | k-means objective | time | |
|---|---|---|---|
refine=0 |
0.315 | 11 750 563 | 4.2 s |
refine=5 |
0.311 | 11 720 402 | 4.6 s |
refine=20 |
0.309 | 11 710 630 | 6.4 s |
sklearn KMeans(n_init=10) |
0.324 | 11 671 351 | 19.3 s |
Read that table before enabling the parameter: the objective falls monotonically and the ARI falls with it. Twenty sweeps buy 0.34 % of objective for a 52 % time premium and cost 0.006 ARI. Phase 4 does exactly what it says — Lloyd is monotone in the objective — but on this dataset the objective and the ground truth point in opposite directions, which is the caveat two paragraphs down and not a bug in the sweep.
Two regimes get nothing, for structural reasons rather than weak refinement. When
max_leaves ≥ N the tree holds one leaf per point — digits at max_leaves=4000 realises 1 797
leaves for 1 797 rows — so Phase 3 is exact k-means on the raw data and Phase 4 starts at its fixed
point; the labels are bit-identical at every refine. Raising the budget does the same thing more
gradually: MNIST at max_leaves=16000 reaches ARI 0.3237 unrefined at objective 11 671 813 —
scikit-learn's own answer, in 15.8 s against its 19.3 s — and twenty sweeps then move the ARI by
0.0001. covtype at max_leaves=4000 is the same story from the other end: 0.1993 → 0.1998, and the
centres move by 7e-5 relative.
A lower objective is not a better partition, and on this benchmark it is reliably worse. On
covtype (same probe), sklearn KMeans(n_init=10) reaches the better objective (827 314 against
n_init=1's 832 081) and 0.174 ARI against 0.277. digits shows the same inversion — n_init=1
scores 0.559 at objective 69 749, n_init=10 scores 0.468 at 69 405. Refinement optimizes the
objective faithfully; whether that is what you want is a property of your data, so measure it rather
than assuming.
Refitting the leaves on the raw points — leaf_refit¶
refine is Lloyd on the macro clusters, after the head. leaf_refit=n is Lloyd on the micro
clusters, before it — and it exists because a leaf CF is not a summary of a region, it is an
absorption history. It holds the rows that happened to arrive while that entry was the nearest
one, in the order they arrived; the tree those rows finished in routes a different partition
entirely. Each pass routes every row through the finished tree — at the estimator's route_beam
width (see Widening the descent below), so the leaves are fitted to the partition the labelling
step reproduces — rebuilds each leaf CF from exactly the rows it wins, and drops the entries that
win none. Same restrictions as refine: in-memory fit / fit_predict only, since
partial_fit keeps a tree and not the data. It is off by default because it relabels. Every
number in this section is at the default width of 1.
Since 2026-09-10 it is close to a coin flip, and it used to pay 11 of 12. ARI against the labels,
medians of seeds 0/1/2, on raw features (sklearn.datasets.load_digits().data and the first
10 000 rows of fetch_openml("mnist_784")) with every other parameter left at its default — the
setup is stated because an earlier version of this table did not state one, and turned out to have
been measured under two different ones. This mnist-10k is unscaled and is not the standardized
mnist-10k of the budget and order studies; bench/RESULTS.md § "Which covtype is this?" lists
all three fixture rules and which table uses which. The harness is
bench/leaf_refit.py and
its output is committed as
bench/results_refit.csv,
so every cell below can be checked without re-running anything:
arrival, leaf_refit=0 |
arrival, =1 |
canonical, =0 |
canonical, =1 |
|
|---|---|---|---|---|
digits, max_leaves=90, kmeans |
0.625 | 0.650 | 0.617 | 0.636 |
digits, max_leaves=90, ward |
0.678 | 0.642 | 0.725 | 0.725 |
digits, max_leaves=90, gmm |
0.520 | 0.599 | 0.597 | 0.575 |
MNIST, max_leaves=200, kmeans |
0.359 | 0.343 | 0.355 | 0.348 |
MNIST, max_leaves=200, ward |
0.409 | 0.367 | 0.349 | 0.406 |
MNIST, max_leaves=200, gmm |
0.347 | 0.375 | 0.324 | 0.330 |
The pass pays in 6 of those 12 column-pairs, loses in 5 and ties in 1, and its mean effect is
+0.006 on the arrival order and +0.009 under canonical_order. The 2026-09-06 edition of this table
read 11 of 12 with means +0.032 and +0.082. Nothing about the pass changed there; the tree it runs on
did. Ranking rebuild merges by what they cost leaves each leaf CF already close to the cell the
finished tree routes, so one more Lloyd step over it has little to move — every leaf_refit=0 cell in
the table above rose (MNIST ward 0.216 → 0.409, digits ward 0.533 → 0.678), and the pass is what
absorbed the loss.
The old reading — that leaf_refit and canonical_order compose, because the canonical order
cost digits quality (0.583 → 0.443 on kmeans) and the pass bought it back — is gone with its
premise: the canonical order no longer costs digits anything (0.625 → 0.617 on kmeans, and 0.678 →
0.725 on ward, a gain). The two knobs now overlap the way the original hypothesis expected.
Turn it on when you have measured it on your own data, and read the arrival/canonical columns separately — the sign differs between them in three of the six rows.
It does not make the result order-independent, and it was built hoping it would. Feed the same
rows in two different orders and the pairwise ARI between the two answers stays at 0.4–0.55, with the
pass and without it, while two seeds on a fixed order agree at ~1.0. The pass fixes what each
prototype summarises; it cannot fix where the prototypes are, because it re-routes against the tree
insertion order already built. That is what canonical_order below does instead, by choosing the
order rather than by routing more cleverly against it.
Three more properties before turning it on. Passes are not monotone — each one re-routes against
the tree the previous one produced, so the worst leaf's distance from the centroid of its rows runs
1.26 → 1.85 → 1.79 → 1.80 → 1.12 over n = 0..4 at max_leaves=40 on a 4000 × 8 blob fixture, and
1.44 → 1.30 → 1.25 → 1.26 → 1.26 at 320. And a leaf budget above N is not a free no-op, unlike
refine, which is genuinely idempotent there: the partition is the one the tree's greedy descent
produces, descent is not the inverse of absorption, and 600 rows at one row per leaf come back as 354
leaves carrying the same total weight.
It also costs more than one route. A pass that drops an entry re-routes once more, because the
rebalance it triggers replaces the tree the leaf CFs were just accumulated against; the ceiling is
two routes and the measured price of leaf_refit=1 is 1.8–2.5× a plain fit on digits,
MNIST-10k and 200 000 × 8 blobs, against 1.4–1.8× for the single-route version that shipped in 0.8.0.
What that bought (2026-09-10). Until this change the pass could leave the tree routing badly at a
large budget: it dropped the entries that won no row, rebuilt the tree from the survivors, and handed
back leaf CFs that were the exact statistics of a partition the rebuilt tree no longer produced. Over
150 cells (5 budgets × 10 blob fixtures × leaf_refit 1/2/3, 4000 × 8) the routed codebook error —
what assign_microclusters returns — rose by more than 5 % in 8 of them, once by 5.25× with
15 % of rows misrouted and a p99 19× the median. It now rises in 2, worst 1.33×, and the
worst exact nearest-centre ratio falls from 1.32 to 1.13. On the cell that blew up, the routed error
reads 4.66 against 5.06 without the pass, the misroute rate 2.0 % against 7.2 %, and every one of the
107 surviving prototypes wins at least one row where the un-refit tree leaves 19 of 144 winning none.
Making the answer independent of the row order — canonical_order¶
Every BIRCH-family tree has the same defect, and it is structural rather than a bug. The prototype
set is a greedy threshold-net built in arrival order: a row is absorbed when it falls within the
threshold of an entry that earlier rows created, and starts a new entry otherwise, so which regions
get a prototype is a function of the sequence. Two permutations of the same rows give two different
trees, and at real compression they disagree more than two random seeds do. leaf_refit above cannot
fix it — it moves each prototype onto the centroid of its cell but never changes which cells exist.
canonical_order=True sorts the rows by a key computed from the data before the first insert, so the
build is a function of the multiset. The guarantee is exact: the label arrays are equal element
for element, not merely similar.
| pairwise ARI between two row orders | |
|---|---|
canonical_order=False |
0.22 – 0.68, depending on data and budget |
canonical_order=True |
1.0000, labels bit-identical |
The key is a Morton (Z-order) code over eight random projections drawn from a fixed constant — not
from seed, so that varying seed re-runs the head over one fixed summary rather than reshaping the
tree — with ties broken on the full row. That tie-break is not a detail: a key alone is not a total
order, and rows that collide would otherwise keep whatever order the caller passed, which quietly
destroys the guarantee. Sorting by squared norm fails exactly this way on integer-valued data.
Read this before enabling it: it buys reproducibility, not accuracy. The published sweep
(bench/RESULTS.md, 27 cells,
eight permutations each) puts the change against the arrival order's median draw at mean
−0.0064, median −0.0052, non-negative in 9 of 27 — and the canonical value lands inside
the order arm's own [min, max] in 21 of 27. It is usually indistinguishable from one of the draws
you would have got anyway: it fixes which draw you get, it does not move the distribution. The
worst losses are −0.062, −0.061 and −0.053, and at mnist-10k with 200 leaves all three heads read
lower. Until 2026-09-09 this paragraph reported a positive mean of +0.0136, coming from two cells
where the canonical order rescued a gmm head the arrival order was collapsing; those two cells
were the spherical+gmm mismatch the library now refuses, and on the diagonal feature the
arrival order is ahead in both.
It is capped at u32::MAX rows. A rank is four bytes rather than eight, which is 17 GB of
index at that length and 34 GB if it were a usize. Past 2³² − 1 rows the flag raises a
ValueError instead of wrapping the ranks, which would have produced a valid-looking order over the
wrong rows with the tail of the matrix never visited. The arrival-order build has no such ceiling.
It reaches threshold="auto" too. That estimate is piloted on a bounded subsample, and drawing
that subsample by row position would put the order dependence back before the tree was ever built —
on a 12-dimensional 4-blob probe one permutation moved the pilot threshold from 17.184 to 17.256 and
the realised tree from 284 leaves to 279, which is a different summary, not a different rounding.
Under canonical_order=True the pilot rows are taken evenly spaced along the canonical order
instead, so the subsample is a function of the row multiset like everything else on this path. With
the flag off the uniform draw is kept, since that build is order-dependent by construction.
A second, separate effect worth knowing: the realised leaf count stops varying too. It moved in 18 of those 27 cells under reordering (327–358, 909–1000, 180–195) and is constant under the canonical order, so two runs now summarise at the same resolution and not merely to similar labels.
Nor is it free. Measured on the engine, A-B-A-B on one build, medians of three:
200k × 20 1.19×, 200k × 128 0.83×, 100k × 784 1.06×, 20k × 784 1.33× — a wash to modestly
slower. A Python prototype of this measured 0.66–0.96× and was misleading: it materialised the
reordered rows, buying back sequential access at the cost of a second copy of the input — which is
exactly the 29 GB duplicate the zero-copy ingest exists to avoid, so the engine indexes instead.
cargo bench --bench canonical_order splits that number into the three things it pays for, and the
mechanism is two effects pulling opposite ways rather than the "coherent inserts split less" this
page claimed before it was measured:
| shape | max_leaves |
key | insert | total | rebuilds |
|---|---|---|---|---|---|
| 200k × 20 | 2000 | 20.5 % | 1.11× | 1.32× | 67 → 60 |
| 200k × 20 | 8000 | 9.6 % | 0.95× | 1.05× | 43 → 42 |
| 200k × 128 | 2000 | 11.4 % | 0.77× | 0.88× | 4 → 16 |
| 200k × 128 | 8000 | 5.3 % | 0.58× | 0.63× | 6 → 10 |
| 50k × 784 | 2000 | 6.9 % | 0.83× | 0.90× | 3 → 14 |
| 50k × 784 | 8000 | 3.3 % | 1.56× | 1.59× | 3 → 30 |
A coherent stream re-descends the same subtree, which is cache-friendly and is where the 0.58× comes
from. But it also fills the leaf budget with fine leaves in one region before the next region
arrives, so the tree has to rebuild more often — 3 → 30 at the worst shape, and that is what makes
it 1.56× there. The key itself is 3–20 % and runs through the same SIMD dot the distance path uses;
as an unvectorised rank-1 accumulate it was roughly twice that.
Read that 3 → 30 as a tail, not as the cost of high dimension. It is a property of the pairing
between one projection draw and one dataset: over 24 draws at that shape the rebuild count reads
min 3, median 4, max 31, and the shipped constant sits at 4. Re-rolling the constant only moves
which shapes land in the tail — a seven-candidate screen over six shapes found one 25 % better, and a
held-out re-measure on shapes it had not been chosen on erased the margin and inverted it on one
cell. No cheap statistic of the codes predicts the tail either (Spearman |ρ| ≤ 0.48 over those draws),
so it cannot be selected away at fit time. The constant is therefore fixed and not a tuning target;
the evidence is in docs/adr/005.
Low-discrepancy walks over the sorted order (van der Corput, round-robin stride) were measured and rejected — 5/16 and 7/16 cells non-negative against this scheme's 12/16, for an extra constant.
n_shards is a model parameter, not a worker count — which is why it is not called n_jobs.
The shard+merge build splits the insertion sequence, so the shard count is the partition: two
counts hold different point sets and build different sub-summaries, which no merge order can repair.
Measured over the 27 cells above at 1 / 2 / 4 / 8 shards, the labels at 8 agree with those at 1 at
pairwise ARI 0.46 on average and 0.098 at worst wherever compression is real (exactly 1.0000
where max_leaves ≥ n, which is the control) — as far apart as two row orders were. scikit-learn's
own documentation defines n_jobs only as a worker count and stops short of promising the output
does not move with it; what made the old name a defect anyway is that no scikit-learn estimator
does move, so it carried a convention every caller reads it by. Threads come from
RAYON_NUM_THREADS, as they already do for the kernels and the Phase-3 heads, and do not enter the
answer.
With canonical_order=True the shard count is derived from n instead (n / 25000, capped at 64)
and passing n_shards as well raises ValueError — a guarantee that survives a reshuffle but
not a re-tuned shard count is not a guarantee, and silently discarding the argument is what let the
old name go unexamined. That constant is the measured knee, not a guess: at n = 200k,
max_leaves = 2000 a kmeans fit reads 1.00 / 1.90 / 2.66 / 3.42 / 3.71 / 3.91× for 1 / 2 / 4 /
8 / 16 / 32 shards, so eight shards buy 87 % of the available speed-up. The quality cost of sharding
is a wash and was measured before the constant was chosen: 8 shards minus 1 is mean +0.004 ARI
over those 27 cells. Inputs under 25 000 rows return one shard and keep the plain sequential build,
so nothing in the table above is affected.
The remaining scoping rule: it applies to a fit / fit_predict that sees the whole dataset — a
partial_fit stream never does, and ordering a chunk would be canonical for the wrong set, so the
stream ignores the flag rather than raising, exactly as it does for leaf_refit.
Sparse CSR input honours it too, on the same terms: the key is computed over the CSR rows
directly, without densifying, and a scipy.sparse matrix gives the same labels as its dense
equivalent. That requires the column indices to be sorted and unique within each row — which is the
scipy canonical form, but not an invariant of the format — so a matrix that is not in canonical form
raises ValueError naming X.sort_indices() rather than being silently mis-ordered. The sparse=
leader path (fit_predict_sparse) is a different algorithm and takes no canonical_order.
Widening the descent — route_beam¶
Routing a row to its microcluster is a greedy descent: one child per level, no backtracking. What it
returns is therefore the nearest entry in the leaf it reached, which is not the nearest entry in
the tree — and the gap is not small. Against an exact scan at max_leaves = 4000, between 24.6 %
(2-D blobs) and 44.4 % (covtype) of rows get a different answer, and a microcluster's own centre
fed back through the tree finds its own entry only 48–71 % of the time. The full measurement is in
bench/RESULTS.md.
route_beam=b keeps the b nearest nodes at each level instead of one, then scans the entries of
every leaf the frontier ends on. b = 1 is the plain descent and the default; large enough b is
the exact scan, reached in practice well before b gets near the leaf count.
ward, max_leaves = 4000, ARI the median of seeds 0/1/2, moved the share of rows that route to a
different microcluster than at b = 1, route the cost of assign_microclusters relative to b = 1:
| dataset | d | ARI b=1 |
2 | 4 | 8 | 16 | moved @16 | exact misroute | route ×16 |
|---|---|---|---|---|---|---|---|---|---|
| blobs | 2 | 0.9795 | 0.9795 | 0.9795 | 0.9795 | 0.9795 | 24.6 % | 24.6 % | 14.9× |
| highdim | 20 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 39.8 % | 39.9 % | 11.3× |
| digits | 64 | 0.6428 | 0.6677 | 0.6643 | 0.6643 | 0.6643 | 29.9 % | 30.0 % | 11.6× |
| mnist | 784 | 0.3452 | 0.3653 | 0.3766 | 0.3801 | 0.3797 | 26.2 % | 26.8 % | 3.0× |
| covtype | 54 | 0.0911 | 0.0906 | 0.0905 | 0.0905 | 0.0905 | 43.6 % | 44.4 % | 11.0× |
Three things that table is doing. The moved column converges to the independently measured
exact-scan misroute rate on every dataset (within 0.1–0.8 points), which is what confirms the beam
reaches the exact answer rather than merely a different one. The ARI column shows the width buying
something on exactly the two datasets where the microclusters of different classes interleave —
+0.035 on mnist and +0.025 on digits — and nothing anywhere else; digits peaking at b = 2 and
settling 0.003 lower is the reminder that ARI is a proxy, not the objective, so routing more exactly
is not monotone in it. And the cost is lowest where the gain is: mnist pays 3.0× on the route where
the others pay 11–15×, because at d = 784 the node features are far larger than the cache and the
descent is already stalled on memory, so the extra nodes ride along on traffic the narrow descent was
paying for anyway. End to end it is small either way: routing is 0.53 s of a 5.82 s mnist fit at
b = 1, so b = 8 adds roughly 0.6 s.
The default stays at 1, and not for compatibility alone: there is no width that is right for
every dataset. Above, b = 8 costs 6–10× the routing time for zero ARI on three of five sets and
buys a tenth of the achievable score on one. Raise it when the data is high-dimensional and the
classes are known to interleave; leave it alone otherwise, and measure rather than assume.
Two scoping rules. Only the heads that assign by microcluster — ward, spectral, leiden,
hdbscan — can see the parameter at all: kmeans and every mixture head install a rule that labels
from the k cluster centres (or the mixture posterior) and never consults the tree, so their labels
are bit-identical at any width. And it applies to routing, not to building: insertion and the leaf
clustering are untouched, so the tree and the head are identical across widths and only the final
row → microcluster map moves. predict, predict_proba, assign_microclusters and
outlier_scores all honour it, including the sparse CSR path.
leaf_refit is the one exception to the second rule, and since 1.1 it is deliberate. That pass
is itself a route — it reassigns every row and rebuilds the leaf statistics from what each entry
won — so it runs at route_beam like every other route on the estimator. A Lloyd step is only a
Lloyd step against the rule it is read back with; before 1.1 the pass routed greedily whatever the
width, which handed back leaves fitted to a partition none of the estimator's own queries reproduce.
That is not a cheaper answer, it is a mismatched one. The consequence is that with both knobs set
the tree itself moves, so every head sees it — including kmeans and the mixtures, which are
otherwise width-blind — and the pass costs a linear factor in the width on its route. At the default
route_beam=1 nothing changes: beam <= 1 is the plain descent and the pass is what it always was.
What it is worth, measured against a build with the change reverted (six cells, ARI the median of
seeds 0/1/2, full table in
bench/RESULTS.md): three
cells up, three down, mean +0.013 — and the gain sits where the argument says it should. ward,
the only head in the table that assigns by microcluster, gains +0.061 on digits and +0.052
on mnist-10k; kmeans and gmm, which never consult the tree for a label, move in both directions
by about as much as a seed does. The case for the change is the worst cell rather than the mean: on
mnist-10k with ward and route_beam=8, the greedy pass read 0.359 against 0.424 for no refit at
all — it was destroying 0.065 ARI, more than the width had bought — where the consistent pass reads
0.411.
It is an estimator parameter. The free fit_predict / fit_predict_sparse are the engine
functions re-exported verbatim and route with the plain descent; use Betula(route_beam=b) to widen
it. Same split as the fractional max_leaves above, and for the same reason.
The engine functions therefore also refit greedily: CFTree::refit_leaves is unchanged and is the
beam = 1 case of the additive CFTree::refit_leaves_beam, which is what the estimator calls.
Streaming / out-of-core — the Betula estimator¶
Feed chunks with partial_fit, finalize with a no-arg partial_fit(), then predict. Memory stays
bounded by max_leaves no matter how much data streams through (the CF-tree rebuilds, it never grows
without limit) — or set memory_budget_mb and let it size max_leaves for you (a target for
the tree's resident size; most meaningful for streaming, where the data is transient and the tree is
what grows). The per-leaf cost it divides by is measured, as the slope of process RSS against
the realised leaf count for each feature × dim, and the constants sit a few per cent above every
measured cell — so the budget is met or beaten rather than silently overrun. Before 1.1 the formula
counted only the declared arrays and under-predicted by 1.15× to 19.5×, worst on feature="fd",
whose sketch rows it ignored entirely. Set huber_k (e.g. 2.0) to winsorize each incoming
point to \(\pm k\sigma\) of its target
microcluster before folding it in, so outliers in the stream cannot drag a centroid or inflate a radius.
est = betula_cluster.Betula(method="gmm", memory_budget_mb=512) # don't think about max_leaves
for chunk in stream_of_arrays: # each chunk is a 2-D float64 array
est.partial_fit(chunk)
est.partial_fit() # finalize the global clustering over everything seen
labels = est.predict(X_query) # est.n_clusters_ / est.n_leaves_ / est.effective_max_leaves_
Sizing max_leaves against n_clusters¶
The summary has to be finer than the partition you ask of it. Below two leaves per cluster the
head has essentially no freedom — every cluster is one leaf and the answer is the tree's, not the
head's — and quality collapses: over three seeds on the ward head, well-separated synthetic data
loses 29 % (k=50) and 55 % (k=200) of its achievable ARI at ≈1 leaf per cluster, while digits
and covtype score 0.000 and 0.003 there. betula raises a UserWarning naming the realised leaf
count, n_clusters and the current max_leaves whenever it lands under that floor.
Two is a floor, not a target. More resolution is not monotonically better: on covtype the same
sweep peaks at ≈8 leaves per cluster and declines after, while digits keeps improving to ≈60. If the
warning fires, raise max_leaves (or lower threshold, or lower n_clusters) — then tune the ratio
on your own data rather than assuming higher is better. The warning reads the realised leaf count,
not the cap: the tree routinely settles below max_leaves, and when N < max_leaves the cap never
binds at all.
When more leaves buy nothing — check where the budget went¶
A budget can be fully spent and still spent badly. The absorption radius is one global number, so a
region that is dense relative to it collapses into a single leaf while sparse regions keep splitting:
the tree fills 90–98 % of max_leaves and puts most of the mass in a handful of them. The symptom
is that raising max_leaves does not move the score at all.
The diagnostic costs one line, and the heaviest leaf's share of the mass is the number to read:
w = np.asarray(est.microcluster_weights_)
print(w.max() / w.sum()) # ≈ 1/n_leaves is healthy; 0.5+ means one leaf holds half your data
If it is large, set balance — a per-leaf cap of that many times the n / max_leaves ideal:
max_leaves stays a hard bound; the cap is best-effort and yields to it. On a fixture with 80 % of
the mass in one tight core this used to move kmeans from ARI 0.4174 to 1.0000 at every budget
from 250 to 4000. Since 2026-09-10 the uncapped tree reads 1.0000 there too — the rebuild stopped
concentrating mass in one leaf on its own — so the cap has much less left to repair, and it was never
a free win. Read the share on your own data first, and measure the cap against balance=None.
Where a fixed cap still pays is not where the share is high. Re-measured 2026-09-10 over
digits / mnist-10k / covtype-20k × kmeans/ward × max_leaves ∈ {8, 16, 32, 64}, medians of
seeds 0/1/2: balance=4.0 is worth +0.0900 on digits at 64 leaves and +0.0757 on mnist-10k
at 32, where the heaviest leaf holds only 0.056 and 0.212 of the mass — and it costs 0.024 to 0.042
on the same two datasets one budget further out. There is no share at which it is safe and none at
which it is useless; it is a different budget-allocation policy, not a repair, and the only way to
know is the A/B.
balance="auto" reads the share for you — and on today's tree the share almost never passes.
"auto" builds the tree once, reads the heaviest leaf's share off it, and — only if it passes 0.5 —
summarises the same rows again with the cap on from the first point. Over the 27 cells Q1 shipped it
on (digits / covtype-20k / mnist-10k × kmeans/ward/gmm × budgets 225–4000) it fired on six and
gained +0.08 to +0.25. Re-measured on the 2026-09-10 tree it fires on none of the 27: the share
now reads 0.005–0.046 there, an order of magnitude below the trigger, and auto is bit-identical
to balance=None in every one.
It has not stopped working — it has moved two decades of budget tighter. Over the same three datasets
at max_leaves ∈ {8, 16, 32, 64} it fires on three configurations, and the decision is exact in all
24 cells (auto reproduces balance=4.0 where it fires and balance=None where it does not, to the
last bit):
| cell | share | balance=None |
"auto" |
|---|---|---|---|
| mnist-10k, 16 leaves, kmeans | 0.998 | −0.0000 | 0.2280 |
| mnist-10k, 16 leaves, ward | 0.998 | −0.0000 | 0.2282 |
| mnist-10k, 8 leaves, either | 0.859 | 0.0296 | 0.1030 |
| covtype-20k, 8 leaves, either | 0.889 | 0.1489 | 0.0859 |
Two wins, one loss, and the loss is the honest half: the predictor selects on concentration, and concentration is not the same question as whether capping it helps. Read it as insurance against the degenerate case — a budget so tight, or a core so dense, that one leaf swallows the dataset — and not as a tuning knob.
Two costs, both stated rather than hidden. Where the share is low the result is bit-identical to
balance=None and nothing extra is paid; where it fires you pay one extra pass over the data, and
the labels are the balance=4.0 labels. And it needs every row at once, so fit and fit_predict
get the two-pass decision while partial_fit cannot: on a stream the tree instead watches its own
mass distribution as it builds and arms the cap when a leaf passes half the mass. That is strictly
weaker, because a cluster feature cannot be split back into points — a leaf that has already
absorbed the core keeps it. Measured on mnist-10k at 250 leaves, the streaming arming recovers
+0.006 to +0.037 where the two-pass recovers +0.167 to +0.251. If a stream is your case and
the diagnostic is bad, pass a number.
How much a float32 tree can count¶
float32 input builds an f32 tree, which halves the resident memory of the f64 one. It counts
every row you give it, and until 2026-09-10 it did not: a leaf stopped counting at
2²⁴ = 16 777 216 points, because binary32 spaces its values 2 apart from there upward, so w + 1 == w
and a unit-weight row left the leaf's weight exactly where it was. The rest of the summary did not
stop with it — the mean kept moving at 2⁻²⁴ per row, and the scatter kept accumulating against a
weight that no longer grew, so the reported variance drifted by roughly the fraction of rows the
weight had dropped (1.0058 on unit-variance data 200 000 rows past the ceiling, against 1.0000 in
float64).
The fix is that the weight is a f64 running total whatever the tree's element type, because its
precision is set by how many rows a leaf absorbs rather than by how precisely a coordinate is stored.
The mean stays in the tree's type: it is bounded by the data, not by the row count. So
microcluster_weights_, cluster_sizes_, every mass fraction and every mass-weighted head are exact
at any stream length. What a caller sees is rounded to the tree's type on the way out — one part in
16 777 216 of a total that is itself exact — which is the precision float32 was chosen for.
One residual, measured and left in. feature="spherical" (the default) also keeps its scatter in
f64: it is one number per leaf, so it costs nothing. The per-axis and full-matrix scatters of
"diagonal", "full" and "fd" are d or d² numbers per leaf, and they stay in the tree's type
— so past 2²⁴ rows in one leaf the scatter is now the term that saturates, and the variance comes
back about 0.89 % low (where the original defect had it 0.58 % high). Widening them was
implemented and measured before being rejected: +11 % on the insert path and an f32 tree three
quarters the size of the f64 one instead of half, charged to every f32 user, to correct a leaf
that has to be past 16.8 M rows before it is wrong at all. If that leaf is your case, use
float64, or lower threshold / raise max_leaves / set balance so the mass spreads over more
leaves — the same three levers as before, now for a 0.9 % variance rather than for lost rows.
How large a row can be¶
A row whose squared norm is past dtype.max / 2⁵⁶ is refused with a ValueError that names it
— about 4.7e21 for float32 (a norm of 6.9e10) and 2.5e291 for float64 (5.0e145). The
dtype is the one the fit runs in: an estimator that computes in float64 widens a float32 row
first, and a float32 model casts a float64 chunk down before checking it, so 1e39 — finite in
float64, inf in float32 — is caught.
Every sum a fit takes over squared distances — a leaf's scatter, a merge's cost, a head's objective —
is at most 5·W·M for W rows of squared norm at most M, because whatever it squares lies in the
rows' convex hull. The bound keeps that inside the type for every W up to 2⁵³, so it needs no row
count and a stream's domain does not narrow as the stream grows. Until 2026-09-27 such rows were
taken in, and what followed depended on the head: on a float64 row at 1e200, kmeans indexed
out of bounds and normalize=True scaled the row to zero; 3000 float32 rows spread over
±4.5e18 left the radii of every non-directional head non-finite and made kmedoids panic.
What is checked is what gets squared: the row as given, except under method="hyperbolic", which
lifts the row onto the sheet and squares the lifted one. Needing no count has a price — a small
float32 fit past the bound that happened to stay in range is refused too. Rescale the data, or pass
float64.
Saving and loading a model¶
The file is a gzip-framed, version-tagged CBOR document — a plain gzip member, so
gzip -dc model.betula yields the CBOR and the model stays inspectable without this library.
Pickle goes through the same bytes, so pickle.dumps(est) and joblib.dump are compressed too.
How much the framing saves is a property of your data, not of the codec. What compresses in a model is repetition down the leaves — dead dimensions, shared zeros, near-duplicate centroids — and not float entropy, so:
| model | file | gzipped | ratio |
|---|---|---|---|
digits (1797 × 64), max_leaves=300, spherical |
0.18 MB | 0.09 MB | 2.0× |
covtype (581 012 × 54), max_leaves=2000, spherical |
1.06 MB | 0.22 MB | 4.8× |
mnist (70 000 × 784), max_leaves=2000, spherical |
14.35 MB | 5.29 MB | 2.7× |
mnist (70 000 × 784), max_leaves=2000, diagonal |
22.28 MB | 9.44 MB | 2.4× |
| Gaussian blobs (20 000 × 64), no dead dimension | 1.17 MB | 1.03 MB | 1.1× |
The last row is the honest floor: a full-entropy synthetic fixture has nothing to repeat. Writing
the 14 MB mnist model costs 0.6 s. zstd -19, brotli -q9 and xz -6 were measured on the same
files and beat gzip by 12–22 %, for 4–13 s per save — the wrong trade for a call a user makes
interactively, and flate2's pure-Rust backend also keeps every wheel target cross-compiling without
a C toolchain.
A column-major leaf layout was implemented as a prototype and rejected: on the saved file it is worth only 1.06–1.17×, because CBOR tags every float with its width and the tag bytes dilute the column similarity that makes the layout pay on a raw array.
Compatibility. A saved model is not portable across major versions. load accepts one schema
version and refuses every other, naming the version it found:
Re-save with the version that wrote the file before upgrading, or keep the training code and re-fit. The alternative — a reader for every past layout — would pin the internals of the CF-tree for the whole 1.x line, and the tree is where the research happens. Within a major version the format is stable, and a file written before the gzip framing existed still loads.
Soft assignment, coresets, diagnostics, drift¶
All over the microclusters the tree already holds (no extra data passes):
proba = est.predict_proba(X_query) # (n, k): the point's own mixture posterior (argmax == predict); centroid-softmax heuristic for the non-generative heads
conf = est.assignment_confidence(X_query) # (n,) in [0, 1] — low flags boundary / ambiguous points
coreset = est.export_coreset() # coreset.centers / .weights / .radii — fit any weighted model on these
coreset = est.export_coreset(size=500, k=8) # …or a (k, eps)-coreset of 500 leaves; see below
report = est.diagnostics() # compression_ratio, radius p50/p90/p99, cluster mass spread, n_rebuilds
reps = est.representatives(X_query, cluster_id=0, method="medoid") # or "boundary" / "outlier" / "diverse"
profile = est.cluster_profile(0) # JSON-able geometry + nearest clusters (e.g. to LLM-name a cluster)
batch = est.active_learning_batch(X_query, n=100, strategy="uncertain") # rows to review/label
snap = est.snapshot() # cluster geometry now; later, detect drift:
drift = betula_cluster.Betula.compare_snapshots(snap, est_next.snapshot()) # matched clusters: centroid shifts / mass ratios
Internal validity — validity()¶
est.validity()
# {'calinski_harabasz': 8143.2, 'davies_bouldin': 0.41,
# 'medoid_silhouette': 0.93, 'simplified_silhouette': 0.95}
Four indices off the leaf summary, all in \(O(\ell k d)\) — there is no second pass over the data
and no \(O(N^2)\) term, because the sum of squared distances inside a leaf is
\(S_i + n_i\lVert\mu_i - c\rVert^2\) exactly. On a fine tree (threshold=0 with a leaf budget above
\(N\)) calinski_harabasz reproduces scikit-learn's point-level calinski_harabasz_score to
floating-point noise; the test suite asserts it.
Read the caveats before selecting k with any of them:
| index | direction | status on cluster features |
|---|---|---|
calinski_harabasz |
higher is better | exact; undefined at k = 1 |
davies_bouldin |
lower is better | the RMS-dispersion variant, \(\sigma_j=\sqrt{E\lVert x-c_j\rVert^2}\) — the classical mean-distance form is not a function of a cluster feature at all |
medoid_silhouette |
higher is better, ≤ 1 | the index of the summary: a per-leaf ratio weighted by leaf mass, which converges to the point-level value only as the leaves shrink |
simplified_silhouette |
higher is better, ≤ 1 | the same construction with the cluster centroid as the representative (Hruschka's simplified silhouette) — a declared surrogate for the classical one, not an approximation that tightens |
The exact silhouette needs \(\sum\lVert x-y\rVert\) over all pairs, which is degree 1 in the norm and
so not a function of a cluster feature at all: {−1, −7/12, 0, 1} and {−4/3, 0, 0, 3/4} share
n, Σx and Σx² exactly and differ in \(\sum\lvert x_i-x_j\rvert\) by more than 0.1. Both
silhouettes here replace it with a distance to one representative, which is summarisable. What
that buys and what it costs, measured on three datasets, is in
bench/RESULTS.md: the
summary itself costs almost nothing (0.0004 on covtype-20k at 5.6 points per leaf, 0.013 worst-case
on 784-dimensional MNIST, and nothing on a tree with one leaf per point), while the surrogate
class shifts the level a long way — 0.44 against scikit-learn's 0.16 on digits. Compare it across
configurations of the same data; do not compare it to a published silhouette_score.
None of the four can say "there is no structure here." Schubert, Stop using the elbow
criterion for k-means (SIGKDD Explorations 25(1), 2023), Table 1 shows the distance-based indices
reporting 3–22 clusters in pure noise where BIC correctly reports one. Calinski–Harabasz is
undefined at k = 1, which is the same limitation stated honestly. For the "is there anything here
at all" question, fit with n_clusters=0 on a mixture head and let BIC answer — that path is
unchanged and is the authority.
tune(objective="simplified_silhouette") (or "medoid_silhouette") scores each trial on the leaf
summary instead of the points, which is 39 ms per trial against 1456 ms for a 10 000-point sampled
silhouette_score on MNIST-20k — and has no sampling seed in it.
What "exact" and "of the summary" mean, precisely. A cluster feature is a sum-decomposition in
the sense of Deep Sets — \(f(X)=\varphi(\sum_u \psi(x_u))\) with \(\psi(x)=(1,x,xx^{\mathsf T})\) — so it
carries exactly the permutation-invariant polynomials of degree \(\le 2\) and nothing else. Everything
built from \(\sum w\lVert x-c\rVert^2\) therefore comes back exactly: WCSS, the Ward merge cost,
Calinski–Harabasz, the RMS Davies–Bouldin, the Bregman \(D4_\varphi\) and the GMM sufficient
statistics. Anything built from a min or max over pairs does not. The two sets
have the same weight, mean and scatter exactly, so their spherical, diagonal, full and FD features
are the same object — yet their single-linkage merge heights are \(0,2,3\) against \(0,3,3\), and DBSCAN
at \(\varepsilon=2\), minPts \(=2\) gives two clusters and no noise on \(A\) against one cluster and two
noise points on \(B\). A density or single-linkage head reading the summary is answering a question
the summary does not contain, and the counterexample is four points rather than an asymptotic
argument.
For the exact indices the summary's error is not a bound but an identity: when every leaf lies inside one cluster,
so the total leaf SSD — the quantity threshold and max_leaves control — is the cost of
summarising, exactly. For any index Lipschitz in the Wasserstein-2 metric the same quantity bounds
the error, since sending each point to its own leaf centroid is a feasible transport plan:
\(W_2(\text{data},\text{summary})^2 \le \sum_i S_i / W\).
k with a k = 1 answer available — gap_statistic¶
curve = betula_cluster.gap_statistic(X, k_max=8, n_refs=10, max_leaves=200)
curve.k # the chosen k -- 1 is a possible answer
curve.ks, curve.gaps, curve.standard_errors # the whole sweep, for plotting
The null is a uniform sample over the data's bounding box re-summarized at the same leaf budget,
so both sides of the gap pay the same quantization error and the statistic measures structure rather
than compression; the same reference draws are reused across k, which pairs the comparison. Cost is
O(ℓ k d) per fit off the leaf summary, with no second pass over the points.
Measured on the two null fixtures from that table (1000×5, medians of seeds 0/1/2): a single
Gaussian answers k = 1 on every seed, uniform noise on two seeds of three — the third is a
near-tie, gap(1) − gap(2) = −0.007 against −2.83 for a genuine two-cluster fixture, so it is an
undecided answer rather than a confident wrong one. That matches the paper's "partly". Two-blob and
four-blob controls come back at 2 and 4 on every seed.
| selector | trust it when | cannot |
|---|---|---|
BIC (n_clusters=0, mixture head) |
you want the authority on "is there anything here" | — it is parametric, so it is answering about Gaussians |
calinski_harabasz |
you want a cheap k and already believe k > 1 |
say k = 1; it is undefined there |
gap_statistic |
you need k = 1 on the table but not a distributional assumption |
be decisive on pure noise — expect the near-tie |
Hartigan's dip test is the obvious fourth row, and it is deliberately absent. The statistic was
reproduced exactly here — the definition solved as a linear program agrees with the reference
implementation to 5.5e-12 over 648 samples spanning seven shapes, ties included — but that form
costs one linear program per candidate mode (7.97 s at n = 200), which cannot back the
Monte-Carlo null a p-value needs. The cheap closed form, one convex hull each side of the mode, is
exact on tied data and under-estimates continuous data by up to 9.97e-3 on a statistic of order
0.06; an under-estimated dip calls multimodal data unimodal, which is the one error the test
exists to prevent. Closing that gap needs the published iterative algorithm, whose available sources
are GPL-2 or non-commercial and so cannot enter an MIT crate.
method="ward" with n_clusters=0 now cuts the dendrogram at the best Calinski–Harabasz score
rather than at the largest relative jump in merge height. The old rule was the elbow criterion in a
dendrogram's clothing, and it fails exactly where the paper says it does: on two far groups of two
nearby subclusters each, the tallest relative jump is the one that joins the far groups, so it
reported k = 2 on every seed where the variance ratio reports the true 4.
Why is my tree collapsing? — tree_report()¶
est.tree_report() # MNIST-10k at max_leaves=16 — a budget tight enough to collapse
# {'n_leaves': 15, 'max_leaves': 16, 'fill': 0.938, 'threshold': 61049.187,
# 'heaviest_leaf_mass_fraction': 0.998, 'heaviest_leaf_width': 0.474,
# 'leaf_mass_quantiles': {50: 1.0, 90: 5.0, 99: 8582.06, 100: 9978.0},
# 'diagnosis': ['the leaf budget is 94% spent and one leaf holds 100% of the mass: …']}
The budget in that example is deliberately absurd, and since 2026-09-10 it has to be: the tree
concentrates mass far less than it used to, so the same call on the 100 000-row size-imbalance
fixture at 250 leaves now returns heaviest_leaf_mass_fraction 0.064 and an empty diagnosis
where it used to return 0.800 and fire.
fill and heaviest_leaf_mass_fraction locate the size-imbalance pathology of scikit-learn's Birch
issue #22854 — a spent budget with the
mass in one leaf means the tree resolved the sparse part of the data and merged the dense part.
heaviest_leaf_width (that leaf's RMS radius over the median leaf's) is what says whether it cost
anything: a dense region that really is point-like is summarized faithfully by one tight leaf,
while a heavy leaf as wide as a typical one is a merged region and whatever was inside it is gone.
Pass the data for an A-BIRCH threshold estimate beside the threshold in use:
est.tree_report(X)["suggested_threshold"] # gap-statistic estimate from a sample
betula_cluster.estimate_threshold(X) # …or on its own, without a fitted tree
# ThresholdEstimate(threshold=1.74, n_clusters=4, radius_ratio=1.08, separation=11.8, assumptions=[])
Advisory only. max_leaves is the knob that binds — the threshold is what the rebuild derives
from it — and the estimate assumes well-separated, near-spherical clusters of comparable size.
assumptions names each of those the data breaks rather than leaving you to guess; a non-empty list
does not make the number useless, it makes it a hint. What it is genuinely good for is the
comparison: a tree that settled above twice the sampled estimate is absorbing points from more
than one cluster, and tree_report(X) says so in diagnosis.
A coreset with a guarantee — export_coreset(size=…)¶
export_coreset() with no arguments is the streaming summary it always was: every leaf, at its own
mass, in one O(n_leaves) pass. Passing a size subsamples it by sensitivity sampling
(Feldman & Langberg, STOC 2011) and turns the word coreset into a claim: every candidate solution
scores within (1 ± ε) of its score on the full summary, not just the one this estimator fitted.
The error is two independent halves, and the API keeps them apart because they fail differently.
Summarization — present in both modes. With Δ = coreset.offset = Σᵢ Sᵢ, the summary's cost
ĉost(C) = Σᵢ (Sᵢ + nᵢ‖μᵢ − C‖²) is exactly the cost of sending every point of a leaf to the
centre nearest that leaf's centroid, so it can only over-charge, and by a bounded amount:
That is a relative error of 4√ρ + 4ρ at ρ = Δ / c(C), and c(C) ≥ OPT_k bounds it uniformly.
Δ is known exactly; OPT_k is not, so coreset.summary_epsilon(alpha) makes you name the
approximation factor you assume rather than picking one for you — reference_cost upper-bounds
OPT_k, so summary_epsilon(1.0) is optimistic, not a certificate.
Sampling — only when size is given. Since ĉost(C) = Δ + Σᵢ nᵢ‖μᵢ − C‖² and Δ does not
depend on C, the sample only has to be a coreset of the weighted set {(μᵢ, nᵢ)}; offset
carries the constant instead of losing it, and coreset.cost(centers) adds it back so you cannot
forget. Sensitivity sampling attains the optimal worst-case size Õ(k·ε⁻²·min(√k, ε⁻²)) — matching
the STOC 2022 lower bound — and Õ(k/ε²) on stable instances (arXiv 2405.01339).
cs = est.export_coreset(size=500, k=8)
cs.centers.shape # (<= 500, d)
cs.cost(candidate_centers) # weighted cost + offset
cs.summary_epsilon(1.0) # optimistic; pass the alpha you can defend
cs.total_sensitivity # 10 + 4k when the reference solution left no cluster empty
A size at or above the leaf count returns every leaf exactly, with no sampling error — not a
noisy redraw of something already held exactly.
The other four linkages — average / weighted / centroid / median¶
method="ward" is the nearest-neighbour chain, which is only valid for a reducible linkage.
The other four run on Anderberg's algorithm and take the same n_clusters (and n_clusters=0
for a Calinski–Harabasz-scored cut) as every other partitional head. Names follow SciPy's
scipy.cluster.hierarchy.linkage(method=…):
method |
classical name | what it measures between two clusters | children weighted by |
|---|---|---|---|
average |
UPGMA | mean squared distance over all cross-cluster point pairs | mass |
weighted |
WPGMA (McQuitty) | the same, with the two children counted equally | 1 each |
centroid |
UPGMC | squared distance between mass-weighted centroids | mass |
median |
WPGMC | squared distance between dyadic midpoints | 1 each |
ward |
Ward | 2·n_a n_b/(n_a+n_b)·‖Δμ‖² |
mass |
All five are on squared distances, and on single-point leaves all five reduce to the plain squared distance between the two points — that is what the factor two on Ward is for.
Three of them are exactly the CF distances the tree already routes by: average is D2²,
centroid is D0², ward is 2·D4². weighted and median are not, and cannot be: a cluster
feature merge is mass-weighted by construction, so nothing built out of cluster features can
represent a cluster whose children were combined equally regardless of size. They are driven by a
per-cluster (mean, mean squared radius) pair instead, updated by the König–Huygens recurrence in
its all-positive form — no Σα‖μ‖² − ‖m‖², so no cancellation far from the origin.
centroid and median invert. They can merge at a height below one of their children's. This
is a property of the linkage, not a bug, and it is why cuts here are taken as a prefix of the
agglomeration order rather than by sorting on height. If you need a monotone dendrogram, use
average, weighted or ward.
Topological structure — mapper()¶
A TDA-Mapper skeleton over the microclusters: non-convex shape, branch points, and bridges (thin links that flag topic leakage / merges in embeddings). It runs over the \(M \ll N\) microclusters, so it is cheap — an exploration tool, not a partition.
est = betula_cluster.Betula(n_clusters=8).fit(X)
g = est.mapper(lens="density", resolution=10, gain=0.3) # lens: density|radius|l2norm|coordinate|eccentricity
g.n_nodes, g.n_edges # skeleton size
g.branch_points # nodes where the shape splits (degree >= 3)
g.bridges # indices into g.edges whose removal disconnects the graph
g.edge_overlap # (n_edges,) Bhattacharyya overlap in (0, 1]: a bridge across a sparse
# neck reads LOWER than an edge inside one dense blob — distributional,
# not just a shared-microcluster count
nxg = g.to_networkx() # optional (needs networkx); edges carry weight / overlap / bridge
# sweep resolution to find the topologically stable scale (β0 / branch / bridge counts vs resolution)
curve = est.mapper_stability(resolutions=[8, 12, 16])
Naming k on the density hierarchy — dc-center / dc-median¶
hdbscan reads its cluster count off the persistence of the mutual-reachability spanning tree. These
two cut the same tree for a k you name instead, and both cuts are provably optimal — the
density-connectivity distance (Beer et al., KDD 2023) is an ultrametric, and k-center and
k-median are exactly solvable in one.
# k-median: minimise the mass-weighted total dc-dist. The one to reach for.
labels = betula_cluster.fit_predict(X, n_clusters=6, method="dc-median", min_samples=5)
# k-center: minimise the largest dc-dist. Exact, and mass-blind — see below.
labels = betula_cluster.fit_predict(X, n_clusters=6, method="dc-center", min_samples=5)
min_samples and graph_degree are hdbscan's and mean the same thing. Neither head ever
answers -1: they partition. If you want noise, the question is DBSCAN*'s and hdbscan is the
head for it, over this very tree.
Why they are exact. dc(a, b) is the heaviest edge on the path between a and b in that tree,
so it is the height of their lowest common ancestor in the single-linkage dendrogram. k-center then
falls out of deleting the k − 1 heaviest edges. k-median falls out of a second observation: a
leaf's service cost depends only on which subtree its nearest centre sits in, never on which centre
it is — which turns choosing k centres into an O(m·k) knapsack over the dendrogram. Draganov et
al. (NeurIPS 2025) run the same recursion for every k at once. Both are checked against brute force
over every C(m, k) centre set for m ≤ 12.
What they score. N = 6 000, max_leaves=600, min_samples=5, k given (except hdbscan,
which picks its own), median of seeds 0/1/2 — ARI / seconds:
| fixture | dc-center |
dc-median |
hdbscan |
ward |
spectral |
kmeans |
|---|---|---|---|---|---|---|
| moons, noise 0.06 | 1.000 / 0.020 | 1.000 / 0.021 | 1.000 / 0.020 | 0.434 / 0.011 | 1.000 / 0.203 | 0.255 / 0.004 |
| moons, noise 0.10 | 0.000 / 0.014 | 0.889 / 0.014 | 0.015 / 0.016 | 0.420 / 0.007 | 0.691 / 0.383 | 0.256 / 0.003 |
| circles | 1.000 / 0.015 | 1.000 / 0.015 | 1.000 / 0.015 | 0.000 / 0.006 | 1.000 / 0.221 | −0.000 / 0.004 |
| blobs, 8-D | 1.000 / 0.019 | 1.000 / 0.019 | 1.000 / 0.020 | 1.000 / 0.015 | 1.000 / 0.095 | 1.000 / 0.008 |
| blobs + 5 % noise | 0.000 / 0.015 | 0.725 / 0.015 | 0.322 / 0.016 | 0.841 / 0.007 | 0.864 / 0.381 | 0.878 / 0.005 |
| digits, PCA-20 | −0.000 / 0.018 | 0.725 / 0.020 | 0.458 / 0.019 | 0.721 / 0.010 | 0.703 / 0.408 | 0.669 / 0.007 |
dc-median is the head to take from this: it matches spectral wherever spectral wins and beats
it by 0.198 on the harder moons, at 20× less wall clock, and it ties ward on digits while
beating hdbscan there by 0.267. It loses the noise fixture to the three centroid heads, which is
the expected shape — an ultrametric has no notion of a point being between clusters.
dc-center is mass-blind, and that is not a bug to fix. A maximum cannot see a weight, so on a
summary — where an outlier is a low-mass leaf — it spends the whole budget isolating strays. On the
noise fixture at k = 6 its clusters hold [6297, 2, 1, 0] rows against dc-median's
[1670, 1240, 1039, 1022]. This is the mass-based answer the CURE probe went looking for and did not
find in shrinkage: not a repair of the geometric objective but a different objective.
Seen from the other side, the same property makes dc-center insensitive to over-specifying k —
on moons-0.06 it holds 1.000 / 1.000 / 0.999 / 0.999 / 0.997 at k = 2/3/4/6/10, because its extra
clusters are singletons, while dc-median falls 1.000 / 0.754 / 0.524 / 0.465 / 0.285 because it
genuinely splits mass. Read that as one fact stated twice, not as two findings.
Cost is the spanning tree's, which both heads share with hdbscan: at N = 6 000, k = 8,
0.014 / 0.047 / 0.350 / 1.528 s at max_leaves 300 / 1000 / 3000 / 6000, within 2 % of hdbscan's
own 0.015 / 0.044 / 0.347 / 1.526 and about 3× ward's. graph_degree > 0 bounds it, with the same
caveat as everywhere else on this tree: it changes the tree, so the answer stays optimal for a
different problem.
Neither head takes n_clusters=0. Both costs fall monotonically in k to zero at k = m, so the
objective cannot select it — the same reason kmedoids has no automatic mode.
Density structure — reachability()¶
An OPTICS reachability plot (Ankerst et al. 1999) over the microclusters — a diagnostic, not a head. It answers "what does the density structure look like, and at what scale does it change" rather than "which cluster is this row in".
est = betula_cluster.Betula(method="hdbscan", min_samples=5, max_leaves=600).fit(X)
p = est.reachability(min_samples=5) # pass the same min_samples / graph_degree the fit used
p.order # leaf indices in sweep order
p.reachability # aligned with p.order; p.reachability[0] is inf — nothing reached the first leaf
p.core_distances # per leaf, in leaf indexing (not sweep order)
p.weights # per leaf mass, in leaf indexing
import matplotlib.pyplot as plt
plt.bar(range(len(p.order)), p.reachability) # valleys are clusters, peaks are the walks between
leaf_labels = p.labels_at(eps) # DBSCAN* at eps, per leaf; -1 is noise
row_labels = leaf_labels[est.assign_microclusters(X)] # ...lifted back to rows
It is the density head's own hierarchy, not a second opinion about it. OPTICS with no ε cutoff is
Prim's algorithm on the mutual-reachability graph, so the sweep walks the same spanning tree
method="hdbscan" cuts. Every peak is a merge height in that hierarchy and labels_at(ε) reproduces
its cut at ε exactly — asserted by test, not measured as an approximation. That is also why the
reachability here is the mutual max(core(p), core(q), d(p, q)) rather than Ankerst's asymmetric
max(core(q), d(q, p)): the asymmetric form draws a similar-looking picture of a different tree.
One position per leaf. A valley's width is a leaf count, not a point count — three leaves can
hold a hundred thousand rows. Read weights before reading a width. Core distances follow the head's
convention too: they are mass-weighted, so a leaf that already carries min_samples points has core
distance 0 and can never be noise.
Cost. The sweep runs over the leaves, so it does not see N at all. blobs, 6 centres,
max_leaves=300, median of seeds 0/1/2:
| N | fit s | reachability() s |
plot / fit | leaves |
|---|---|---|---|---|
| 5 000 | 0.005 | 0.0024 | 0.52 | 280 |
| 20 000 | 0.007 | 0.0027 | 0.39 | 291 |
| 80 000 | 0.017 | 0.0027 | 0.16 | 290 |
| 320 000 | 0.054 | 0.0028 | 0.05 | 297 |
What it does see is the leaf budget, quadratically — the default neighbour pass is exact. Same
fixture at N = 80 000: 99 leaves 0.0003 s, 290 leaves 0.0028 s, 926 leaves 0.0284 s, 2883 leaves
0.2970 s. graph_degree > 0 bounds it with the same approximate proximity graph the density head
uses, and pays for it in the same currency: at 2883 leaves, graph_degree=16 runs in 0.0364 s but
its cut agrees with the exact plot's at only ARI 0.518, and 32 / 64 / 128 all sit at 0.923 —
so the approximate graph gives you a plot of a slightly different tree, not a cheaper plot of the
same one. Leave it at 0 unless the leaf count makes that impossible.
Against sklearn.cluster.OPTICS on the raw points. N = 6 000, max_leaves=600,
min_samples=5, best ε on each side's own reachability grid, median of seeds 0/1/2:
| fixture | labels_at ARI |
sklearn ARI | betula s (fit + plot) | sklearn s |
|---|---|---|---|---|
| blobs, 6 centres | 0.452 | 0.448 | 0.024 | 5.38 |
| moons | 0.997 | 0.978 | 0.028 | 4.92 |
| circles | 0.997 | 0.978 | 0.026 | 5.86 |
| blobs + 5 % uniform noise | 0.687 | 0.753 | 0.041 | 6.26 |
Two hundred times faster on all four, and it loses the one that is about noise. The compression is not why. Pushing the leaf budget up on that fixture drives the mass per leaf from 23.0 to 1.0 — one leaf per point, no compression left — and the best ARI only moves 0.518 → 0.687 → 0.687 → 0.686 → 0.707 at 274 / 572 / 1461 / 3899 / 6300 leaves. The first step is real and is the mass-weighted core distance: at 274 leaves, 41 % of the true noise points sit in a leaf whose core distance is 0, which by definition can never be labelled noise; by 3899 leaves that is 2 %. The rest of the gap to 0.753 survives at zero compression, so it is the convention, not the summary — DBSCAN* has no border points to hand back to a cluster, and the mutual reachability is a stricter link than Ankerst's asymmetric one.
Semi-supervised — COP-KMeans constraints¶
Constraints are (row_i, row_j) index pairs into X:
est = betula_cluster.Betula(n_clusters=4, method="kmeans")
labels = est.fit_predict(
X,
must_link=[(0, 5), (0, 9)], # rows 0, 5, 9 end up in the same cluster
cannot_link=[(0, 42)], # rows 0 and 42 end up in different clusters
)
# Infeasible (e.g. a cannot-link inside one microcluster, or more mutually-cannot-linked
# groups than n_clusters) raises ValueError — constraints are never silently violated.
Mixed numeric + categorical + directional — KPrototypes¶
Name the categorical column indices; their values are integer codes:
from betula_cluster import KPrototypes
# X columns: [age, income, city_code, plan_code]; columns 2 and 3 are categorical
kp = KPrototypes(n_clusters=5, categorical=[2, 3]) # gamma auto = ½·mean numeric σ
labels = kp.fit_predict(X)
kp.cluster_centroids_ # numeric centroids (n_clusters × n_numeric)
kp.cluster_modes_ # categorical modes (n_clusters × n_categorical)
A third block: directional=¶
Some columns are neither a quantity nor a category — they are a direction: a cyclic hour encoded
as (cos, sin), a heading, a normalised embedding. Naming them in directional= puts them in their
own block, L2-normalised per row, priced by its own weight:
# X columns: [spend, recency, region_code, cos_hour, sin_hour]
kp = KPrototypes(n_clusters=5, categorical=[2], directional=[3, 4])
labels = kp.fit_predict(X)
kp.cluster_directions_ # unit prototypes (n_clusters × n_directional)
The full dissimilarity is
A cluster's direction is the normalised resultant \(R/\|R\|\) — averaging unit vectors leaves the sphere, and two rows a half-turn apart average to zero rather than to a point between them. Because \(\|u\| = 1\), the block's cost over a whole micro-cluster is \(2n_i - 2\langle R_i, c\rangle\): exact from \((n_i, R_i)\), with no scatter term, so a leaf carries the block in one vector.
Rules and defaults:
- At least one numeric column is always required, plus at least one categorical or directional
column. A one-column directional block is rejected — a direction in one dimension is a sign, which
belongs in
categorical=. - A row whose directional part is all zeros has no direction. It keeps the zero vector, which makes
its term equal for every prototype: it stops voting rather than voting arbitrarily. Same for a
cluster whose members' directions cancel — its
cluster_directions_row is all zeros. gamma_dirdefaults to the mean numeric variance, so one unit of \(\|u-c\|^2\) costs one numeric variance. There is no published heuristic for this weight; that is a scale-matching convention, not a result. Sweep it.
When this beats writing the direction as two numeric columns — and when it does not — is measured
in bench/RESULTS.md.
Evolving streams — DenStream & DbStream¶
For an evolving stream where stale data should fade, use the separate DenStream head:
from betula_cluster import DenStream
ds = DenStream(eps=1.5, decay=0.05, beta=0.5, mu=4) # eps = micro-cluster radius (tune to scale)
for chunk in stream_of_arrays:
ds.partial_fit(chunk) # old micro-clusters fade as new data arrives
labels = ds.predict(X_query) # -1 = noise; finalizes the offline step once
For arbitrarily-shaped clusters on a stream (or to avoid bridging close-but-disconnected regions),
use DbStream, which connects micro-clusters by shared density rather than distance:
from betula_cluster import DbStream
ds = DbStream(r=1.5, decay=0.05, alpha=0.1) # r = micro radius; alpha = shared-density bridge
for chunk in stream_of_arrays:
ds.partial_fit(chunk)
labels = ds.predict(X_query) # -1 = noise; finalizes the shared-density graph once
Has the stream changed? — drift_¶
decay is a schedule: it forgets at a fixed rate whether or not anything changed, and a wrong λ is
silent in both directions — too small and the model never forgets, too large and it forgets structure
that was never stale. Both streaming heads therefore also carry an ADWIN change detector (Bifet &
Gavaldà, SDM 2007), which answers the other question — did the stream change — from the data:
ds.partial_fit(chunk)
ds.drift_ # {'alarms': 3, 'last_alarm': 2016.0, 'distance': 1.12, 'window': 650}
alarms— change reports since construction,last_alarmthe stream time (points seen) of the most recent.distance— the statistic being watched: the distance from an incoming point to the nearest micro-cluster, in units of the micro-cluster radius (eps/r), averaged over the adaptive window. Stationary data sits near 1 by construction; a distribution moving into space the model does not cover sends it far higher. Reported in radii so the reading does not depend on the scale of your features.window— points the adaptive window holds. It collapses on a change and regrows while the stream is stationary, so it is itself a read on how settled the model is.
The false-positive rate is a stated δ = 0.002 per point, not a tuned threshold; measured at 0.00050
on stationary streams (bench/drift.py).
Two things to expect:
- Early alarms are the model warming up, not drift. Until the first micro-clusters exist, points really are landing far from everything, and the falling routing distance is a real change in the statistic.
- A
decayfast enough to prune micro-clusters as fast as they form has no baseline to depart from, and the detector then reports nothing at all rather than reporting noise. Usedecayto choose how fast the model follows; usedrift_to find out when it had to.
It reports; it does not act. An alarm prunes nothing, promotes nothing and relabels nothing — what to do about a change is your policy, and a detector that silently rebuilt the model would turn its own false-positive rate into a correctness problem.
Windowed stream queries — WindowStream¶
DenStream has only a present: decay makes the past fade, so it cannot answer "what did the data
look like between t₀ and t₁". WindowStream keeps a summary per frame and answers a window
by summing the frames it covers:
from betula_cluster import WindowStream
ws = WindowStream(frame_width=3600.0, capacity=48, max_micros=256) # 48 hourly frames retained
for chunk, times in stream_of_arrays_with_timestamps:
ws.partial_fit(chunk, times) # timestamps must be one per row, non-decreasing per call
ws.close_frame() # seal the frame still filling, so it can be queried
ws.window_moments(t0, t1) # {'weight', 'mean', 'ssd'} summed over that window
centers, weights, cost = ws.cluster_window(t0, t1, 5) # k-means over just that window's summary
Two properties are worth stating because they are the whole design:
- The window is never computed by subtraction. CluStream (Aggarwal et al., VLDB 2003) stores
cumulative snapshots and gets
[t₀, t₁]asCF(t₁) − CF(t₀). That inverse merge loseslog₁₀(S_AB/S_B)digits of the scatter, and under driftS_ABis dominated by the displacement between the windows, so the ratio runs away while the point counts stay small — a mass-based guard sees nothing. On a two-half fixture measured here it costs a factor of 6155 in the recovered variance at a mass ratio of 2.0. Summation has no such term. - The price is resolution, and it is bounded. A window resolves only to a frame boundary: a query
ending 0.1 s into a frame gets that whole frame. The error is bounded by
frame_width, where the subtraction's error is bounded by nothing. Pickframe_widthas the coarsest resolution you will ever query at, andcapacityas how far back you want to be able to look.
Moments::checked_subtract in the Rust core does implement the inverse merge, and refuses rather
than returning digits it does not have — it is there to be measured against, not to be relied on.
Streaming quantiles — KllSketch & DdSketch¶
Bounded-memory, mergeable across shards:
from betula_cluster import KllSketch, DdSketch
kll = KllSketch(k=256) # rank-error (uniform); DdSketch(alpha=0.01) for relative-error
for chunk in stream_of_values:
kll.update_many(chunk) # 1-D float64 array
p50, p99 = kll.quantile(0.5), kll.quantile(0.99)
kll.merge(other_shard_sketch) # combine sketches computed in parallel
Sparse input¶
Transparent — pass a scipy.sparse matrix to any of fit / fit_predict / partial_fit / predict:
import scipy.sparse as sp
X = sp.csr_matrix(one_hot_features) # never densified to N × d
labels = betula_cluster.Betula(method="kmeans", feature="diagonal").fit_predict(X)
For very high-dimensional sparse data (text TF-IDF, large one-hot), the \(O(\mathrm{nnz})\) sparse-native one-shot touches only the non-zeros:
from betula_cluster import fit_predict_sparse
labels = fit_predict_sparse(X, n_clusters=20, threshold=0.5) # kmeans by default; O(nnz) per row
kmedoids is rejected on this entry point, and the reason is a property of the flat summary rather
than of the head. A medoid centre is one micro-cluster, and a row's squared distance to one is
dominated by that micro-cluster's own norm — which varies with how many terms its handful of rows
happened to carry — while the overlap term that knows the topic is a fraction of it. On the
four-block corpus at max_leaves=300 the head reads ARI 0.017 by its own medoid rule and
0.002 by the micro-cluster route, against 1.000 for kmeans, whose pooled cluster centroids
average the norms out. Betula(method="kmedoids").fit(X) on the same CSR builds a real CF-tree and
scores 1.000; use that.
fuzzy-cmeans is absent for a duller reason: this entry point takes no fuzzifier keyword, and a
head with a knob nobody can set is worse than no head. Its centre is a weighted mean of many
micro-clusters, so the norm-domination argument above does not apply to it —
Betula(method="fuzzy-cmeans").fit(X) on a CSR is the supported route.
Text: reduce and cluster in one call — projection="svd"¶
Clustering TF-IDF in its own geometry does not work, and the size of the failure is worth stating:
on 20-newsgroups the unprojected sparse path scores ARI 0.053, against 0.056 for scikit-learn's
k-means on the same rows. The standard fix is to reduce first, and projection="svd" does it inside
the same call — a CF-weighted PCA of the leaf summary, so the factorization runs over M ≈ 10³
micro-clusters rather than N documents.
labels = fit_predict_sparse(
X, n_clusters=20, method="spherical-kmeans", # cosine geometry on the codes -- see below
max_leaves=256, projection="svd", projection_dim=50,
)
20-newsgroups TF-IDF (18 846 × 2 000, k=20, rank 50, median of seeds 0/1/2, single-threaded,
re-measured 2026-08-30):
| ARI | time | |
|---|---|---|
| sparse path, no projection | 0.053 | 3.2 s |
projection="svd", max_leaves=256 |
0.146 | 0.61 s |
projection="svd", max_leaves=512 |
0.136 | 1.2 s |
projection="svd", max_leaves=2048 |
0.195 | 4.7 s |
TruncatedSVD(50) + KMeans on the raw rows |
0.143 | 0.78 s |
Two things decide whether this works for you.
Use a cosine head on the codes. method="kmeans" on the same codes scores 0.026 against
spherical-kmeans's 0.195 — a seven-fold difference, because the leading principal direction of a
TF-IDF corpus is document length, and only an angular objective ignores it.
The leaf budget is the cost, not the projection. Sweeping the rank from 1 to 100 moves the total
by 1.2 s; sweeping max_leaves from 256 to 2048 moves it from 0.61 s to 4.7 s, because the sparse
summarizer compares each row against every micro-cluster it has so far. Buy resolution deliberately.
The basis is not a compromise for being built from a summary: labelling raw rows in it scored 0.159
against 0.143 for TruncatedSVD's own basis on the same rows (2026-08-24, not re-measured since the
leader pass changed). Under the spherical cluster feature the discarded within-leaf scatter is
isotropic, so it shifts eigenvalues and leaves the directions alone.
Unlike weighted-nmf, a PCA is a linear map, so each row is labelled by its own code
((x − x̄)Vᵀ, computed from its non-zeros) rather than by its micro-cluster's. That distinction was
worth 0.062 ARI here when it was measured (2026-08-24), and it is why the NMF projection cannot be
given the same treatment: its code is the solution of a per-row nonnegative least squares, not a
matrix product.
Hyperparameter tuning — memory-aware, dependency-free¶
betula_cluster.tune searches the CF-representation knobs (compression resolution, covariance model,
normalize) for the best clustering — with an internal metric, or ARI when you have labels. It is
NumPy-only; its multi-objective mode returns the quality / memory / speed Pareto front, so you
pick the point that fits your accuracy, footprint and latency budget.
import numpy as np
import betula_cluster
X = np.random.default_rng(0).normal(size=(20_000, 16))
# single-objective: maximize the internal Calinski-Harabasz score, then refit with the winner
best = betula_cluster.tune(X, n_clusters=8, n_trials=40)
labels = betula_cluster.fit_predict(X, n_clusters=8, **best.best_params)
# multi-objective: the accuracy / memory / speed Pareto front
result = betula_cluster.tune(X, n_clusters=8, multi_objective=True)
for t in result.pareto:
print(t.params, f"score={t.score:.1f} leaves={t.n_leaves} time={t.time_s:.3f}s")
The Optuna backend drops in for random search at the same trial budget — usually better trials
for the same cost. It is an optional extra (pip install 'betula-cluster[tune]'); the default path
above needs only NumPy.
# needs: pip install 'betula-cluster[tune]'
best = betula_cluster.tune(
X,
n_clusters=8,
sampler="optuna", # TPE (single-objective) / NSGA-II (multi_objective Pareto)
n_trials=60,
space={ # optional: override the default search space
"max_leaves": ("int_log", 256, 8192), # log-uniform integer
"feature": ("cat", ["spherical", "diagonal", "full"]),
"normalize": ("cat", [False, True]),
},
)
labels = betula_cluster.fit_predict(X, n_clusters=8, **best.best_params)
Objectives: "calinski_harabasz" (default, higher better), "davies_bouldin" (lower better),
"dbcv" (density-based), "ari" (needs y=), or — scored on the fitted leaf summary rather
than the points — "simplified_silhouette" / "medoid_silhouette" (higher better; see Internal
validity for what a summary silhouette is and is not). Because betula fits are cheap, hundreds of
trials stay fast — and every trial is scored for memory (n_leaves) and time, not just quality.
Consensus & stability — consensus¶
The CF-tree depends on insertion order. consensus clusters several random permutations of the input
and votes, so you get a robust labelling and a per-point stability score — low where a point sits
on an unstable boundary, high where every insertion order groups it the same way.
res = betula_cluster.consensus(X, n_clusters=8, n_runs=5, method="kmeans", n_jobs=-1) # -1 = all cores
res.labels # (n,) consensus label per point
res.confidence # (n,) in [0, 1] — per-point agreement across runs
res.mean_confidence # scalar robustness summary
stable = X[res.confidence == 1.0] # points every insertion order agrees on
For the partitional heads (kmeans / gmm / ward / spectral) at a fixed n_clusters; extra
kwargs are forwarded to fit_predict.
vary names the nuisance the score is about. "order" (the default) permutes the rows and holds
the head's seed fixed, which is the CF-tree's own sensitivity and what the paragraph above describes;
"seed" holds one insertion order and moves the head's seed, which is the initialisation
sensitivity (k-means++ draws, EM restarts) and returns confidence 1 for a deterministic head like
ward; "both" moves both at once and cannot say which one a low score came from. Before 1.0.0
there was only "both", under the documentation of "order".
The vote aligns one cluster to one cluster. Runs are matched to the first run by a
maximum-overlap bijection. Sending each cluster to whichever reference cluster it overlaps most
is cheaper and biased in one direction: a run that splits a reference cluster maps both halves back
onto it, so the split scores as agreement while the merge it forces at fixed k is counted once. On
a split/merge fixture that reads 0.750 where the bijection reads 0.625, and +0.03…+0.13 over random
disagreements. Scores from 0.8.0 and earlier are therefore not comparable with these; they were
too high.
Rust¶
use betula_cluster::distance::CentroidEuclidean;
use betula_cluster::feature::Spherical;
use betula_cluster::model::{Method, Model};
use betula_cluster::tree::CFTree;
let mut tree: CFTree<f64, Spherical<f64>, _, _> =
CFTree::new(2, 32, 32, 0.0, 2000, CentroidEuclidean, CentroidEuclidean);
for p in &points {
tree.try_insert(p)?; // Err(ShapeError) if p is not `dim` wide
}
// (tree, k, method, max_iter, n_init, seed, auto_k_max); `0` takes the default for the last three.
let model = Model::fit(tree, 4, Method::Gmm, 100, 0, 0, 0);
let label = model.try_predict(&points[0])?;
Command line¶
A dependency-free betula binary (behind the cli feature) clusters a delimited numeric file (or
stdin) and writes one label per row to stdout:
cargo install --path . --features cli # or: cargo build --release --features cli
betula --clusters 4 --method gmm data.csv # reads a comma-delimited matrix
cat data.csv | betula -k 0 --method kmeans # k=0 → auto-select k; reads stdin
betula --help # all options
Flags mirror the library: --feature, --threshold, --branching, --leaf-cap, --max-leaves,
--max-iter, --n-init, --seed, --delimiter, --header. --n-init is the k-means restart
count and is refused on the other heads, exactly as the Python boundary refuses it: they select
their restarts by likelihood, not by inertia.
Build from source¶
Prebuilt abi3 wheels (Python 3.11+) ship for Linux, macOS, and Windows, so normally no Rust
toolchain is needed. To build from source instead:
# Python wheel (needs a Rust toolchain)
maturin build --release --features python
pip install target/wheels/betula_cluster-*.whl
# Rust library: add betula-cluster as a path / git dependency in Cargo.toml
For a build pinned to your own CPU, add target-cpu=native for ~8 % off the CF-tree build from
AVX2 / AVX-512 vectorization of the distance kernels (this is what brings the build to parity with
betulars, whose wheels ship with it):
The published wheels deliberately stay portable (a target-cpu=native wheel raises SIGILL on any
CPU older than the build host), so this is a local/private build only — see
.cargo/config.toml.