BIRCH clustering - #347
Conversation
|
Thank you @paulsullivanjr! Unfortunately this pull request does a lot of work outside of Nx, which may:
@RicardoSantos-99 can you take a look if this could mostly written as
I am submitting a fix for that, thanks for filing! |
|
Nice work on the sklearn parity, @paulsullivanjr.
@josevalim, Not with the CF-tree in place, I think. defn wants static shapes, and the tree is dynamic in node count, subcluster count and depth. Preallocating for the worst case means roughly one node per sample, which gives up the memory advantage that makes BIRCH worth using. It also isn't buying much here. The tree exists to avoid scanning every subcluster, but scanning them all is one matvec for us. Without it, the first phase is a single while over the samples with preallocated buffers, same shape as The cost is the sklearn parity, and |
@paulsullivanjr You mention partial_fit as out of scope, which is fair for a first PR, though it's the piece that usually justifies BIRCH over KMeans, and PCA already has incremental_fit/2 as a precedent. Worth noting the flat version makes it easier rather than harder: the state is just the CF buffers and a counter, which is close to resumable already. |
|
@paulsullivanjr here's what I had in mind, concretely, in case it's useful. What comes out is everything from In its place, phase 1 becomes one while {ls, ss, counts, centers, m = 0, i = 0, x}, i < n do
p = x[i]
active = Nx.iota({max_m}) < m
dist = Nx.select(active, Nx.sum((centers - p) ** 2, axes: [1]), inf)
j = Nx.argmin(dist)
n_j = counts[j] + 1
ls_j = ls[j] + p
ss_j = ss[j] + Nx.sum(p * p)
radius2 = ss_j / n_j - Nx.sum((ls_j / n_j) ** 2)
fits? = m > 0 and radius2 <= threshold ** 2
idx = Nx.select(fits?, j, m)
ls = Nx.put_slice(ls, [idx, 0], Nx.reshape(Nx.select(fits?, ls_j, p), {1, d}))
# same for ss, counts and centers
{..., m + Nx.select(fits?, 0, 1), i + 1, x}
end
Worth being clear that this is still sequential over the samples, and nothing makes it otherwise, since each one depends on the ones before it. The gain is that the loop compiles into a single program instead of reading scalars back per sample. The awkward part is the tests. The reference values won't survive, since a flat scan picks a different subcluster than the descent does. I'd rewrite them around properties instead: the radius bound holds for every subcluster, every point lands in its nearest one, results stay order dependent. Happy to help with that. Two unrelated things I noticed while reading. The moduledoc says agglomerative clustering on line 9 and KMeans further down. And |
|
@krstopro so I am thinking this one may not be a good fit for Scholar after all. :( |
@josevalim Quite possible, lemme have a look... |
@josevalim I agree, there are a lot of operations being performed outside of @paulsullivanjr I don't see a convenient way of doing this within |
|
@RicardoSantos-99 Good point, and PCA.incremental_fit/2 is a precedent I'd missed. Agreed
the flat form makes it much easier.
… On Aug 6, 2026, at 11:48 AM, Ricardo Carvalho Santos ***@***.***> wrote:
RicardoSantos-99
left a comment
(elixir-nx/scholar#347)
<#347 (comment)>
Not included: n_clusters=None, transform/2, partial_fit.
@paulsullivanjr <https://github.com/paulsullivanjr> You mention partial_fit as out of scope, which is fair for a first PR, though it's the piece that usually justifies BIRCH over KMeans, and PCA already has incremental_fit/2 as a precedent. Worth noting the flat version makes it easier rather than harder: the state is just the CF buffers and a counter, which is close to resumable already.
—
Reply to this email directly, view it on GitHub <#347?email_source=notifications&email_token=AABCA3CB2MBZSHSYXT7P7CL5ISSFFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQG4YTGNRUHA32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-5207136487>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AABCA3HQZVIUDUDHBBWZ77L5ISSFFAVCNFSNUABFKJSXA33TNF2G64TZHM2DMMJSGQ4DEOJXHNEXG43VMU5TKMBXGY2DAMRXHAYKC5QC>.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS <https://github.com/notifications/mobile/ios/AABCA3FOKDOXFQLQXD4TO3T5ISSFFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQG4YTGNRUHA32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJKTGN5XXIZLSL5UW64Y> and Android <https://github.com/notifications/mobile/android/AABCA3EUU4V3Q2WQSZS3NXL5ISSFFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQG4YTGNRUHA32M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>. Download it today!
You are receiving this because you were mentioned.
|
|
@krstopro I don’t see an easy way either. I’m happy to close it.
… On Aug 6, 2026, at 2:26 PM, Krsto Proroković ***@***.***> wrote:
krstopro
left a comment
(elixir-nx/scholar#347)
<#347 (comment)>
@krstopro <https://github.com/krstopro> so I am thinking this one may not be a good fit for Scholar after all. :(
@josevalim <https://github.com/josevalim> I agree, there are a lot of operations being performed outside of Nx which makes it not suitable for Scholar.
@paulsullivanjr <https://github.com/paulsullivanjr> I don't see a convenient way of doing this within defn. Do you maybe see one? If not, I would suggest closing this one.
—
Reply to this email directly, view it on GitHub <#347?email_source=notifications&email_token=AABCA3A5AVSO3LLLL4VNNE35ITEULA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQHAYTQNRVGY4KM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-5208186568>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AABCA3ABFRBUW6VCOFW4N7D5ITEULAVCNFSNUABFKJSXA33TNF2G64TZHM2DMMJSGQ4DEOJXHNEXG43VMU5TKMBXGY2DAMRXHAYKC5QC>.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS <https://github.com/notifications/mobile/ios/AABCA3BYILV2AFXP3OA3SWD5ITEULA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQHAYTQNRVGY4KM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJKTGN5XXIZLSL5UW64Y> and Android <https://github.com/notifications/mobile/android/AABCA3A2FEAIW3BQRXTAJWL5ITEULA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMRQHAYTQNRVGY4KM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>. Download it today!
You are receiving this because you were mentioned.
|
@RicardoSantos-99 Makes sense, better fit for Scholar than what I did, and agreed on moving the tests to properties. Good catches on both, line 9 is a leftover from when the global step moved to KMeans and squared is right since the argmin is unchanged. |
Adds
Scholar.Cluster.BIRCH.BIRCH makes a single pass over the data, compressing it into a
height-balanced tree of Clustering Features — additive
(n, linear_sum, squared_sum)triples that summarize a group of points without storingthem. The leaf centroids are then reduced to
:num_clusters, and eachinput point is labelled by its nearest subcluster. 2000 points compress
to ~34 subclusters before the global step runs.
The CF-tree is built host-side in Elixir since insertion is sequential;
the labelling and global steps are tensor work.
Options mirror scikit-learn's defaults:
:threshold(0.5),:branching_factor(50),:num_clusters(3), plus:key,:num_runs,:max_iterationsfor the global step.Validation. The CF-tree is independent of the global step, so it was
differential-tested against scikit-learn 1.9.0 across 650 random cases
(varying n, dimensionality, branching factor, threshold; half on an
integer grid to force ties): 248/250 and 398/400 exact agreement
on leaf centroids at 1e-9. Residuals are last-ULP boundary decisions in
deep trees. 18 tests plus doctests, including three sklearn reference
tests and a regression test pinning subcluster ordering on splits.
One deviation: the global step uses
Scholar.Cluster.KMeansratherthan agglomerative clustering, following
SpectralClustering'sprecedent.
Scholar.Cluster.Hierarchicalcan't be used — it reordersdendrogram rows without remapping the clade ids stored inside them, so
labels_listcrashes on 25/25 random 50-point datasets. Pre-existingand unrelated to BIRCH; left alone here, happy to file separately.
Not included:
n_clusters=None,transform/2,partial_fit.