Skip to content

feat: automatic K selection (CellCharter's ClusterAutoK) - #1276

Open
selmanozleyen wants to merge 22 commits into
mainfrom
feat/cluster-auto-k-seed
Open

selmanozleyen wants to merge 22 commits into
mainfrom
feat/cluster-auto-k-seed

Conversation

@selmanozleyen

@selmanozleyen selmanozleyen commented Aug 22, 2026 •

Copy link
Copy Markdown
Member

fixes: #1244

will add more description but I want to start somewhere @sarajimenez, @FrancescaDr these are all the things I could find in https://github.com/Jieran-S/hitchhikers_guide_to_spatial_project/blob/spatial_clustering/notebooks/Spatially_aware_clustering/sac.ipynb.

Anything else needed? If everything is there, how does this state look like for you?

@selmanozleyen selmanozleyen changed the title Rename random_state to seed and improve niche function clarity feat: automatic K selection (CellCharter's ClusterAutoK) Aug 22, 2026
@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.52%. Comparing base (dc82c73) to head (67bac38).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1276      +/-   ##
==========================================
+ Coverage   78.95%   79.52%   +0.56%     
==========================================
  Files          63       65       +2     
  Lines        9439     9617     +178     
  Branches     1574     1599      +25     
==========================================
+ Hits         7453     7648     +195     
+ Misses       1469     1458      -11     
+ Partials      517      511       -6     
Files with missing lines Coverage Δ
src/squidpy/gr/_autok.py 100.00% <100.00%> (ø)
src/squidpy/gr/_cluster_auto_k.py 100.00% <100.00%> (ø)
src/squidpy/gr/_clusterers.py 89.74% <100.00%> (+1.86%) ⬆️
src/squidpy/gr/_nhood.py 62.64% <100.00%> (+1.08%) ⬆️
src/squidpy/gr/_niche.py 88.88% <100.00%> (+0.33%) ⬆️
src/squidpy/types.py 100.00% <100.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@grst

grst commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

I think a Bayesian GMM could also be very interesting for the same purpose: https://scikit-learn.org/stable/modules/generated/sklearn.mixture.BayesianGaussianMixture.html

This class allows to infer an approximate posterior distribution over the parameters of a Gaussian mixture distribution. The effective number of components can be inferred from the data.

The downside is that it's quite slow - not sure how hard it would be to make a fast GPU implementation for that @Intron7.

Comment thread src/squidpy/gr/_cluster_auto_k.py Outdated
stable K is the one whose partition is least sensitive to adding a cluster.

Reimplements the K selection of CellCharter's ``ClusterAutoK``, using
:class:`~sklearn.mixture.GaussianMixture` for the fits.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be tied to the GaussianMixture? We deliberately split the niche calling procedure such that arbitrary embeddings and clustering algos can be combined... In principle, why not use the same approach for Leiden?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to define a clusterer protocol publicly. And to have a hard-coded list of clusterers I wanted some opinions first. Bayesian GMM is a good hint

Comment thread src/squidpy/gr/_autok.py
previous_curve: np.ndarray | None = None
converged = False

for run in range(max_runs):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is embarrassingly parallel. Probably the clustering algorithm has some sort of threading, too, but did you evaluate how well it scales to many cores? Maybe parallelizing at this level could make sense.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we rely on the clustering algorithm to be utilizing the cores as much as possible so that any other threading would oversubscribe only. Also we can only parallelize runs if tolerance is 0, ie we don't early stop.

But from my small benches I didn't see much improvement than 1.5x tbh. And BLAS was already enough.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was planning to open a separate issue the next days to discuss a public "extensibility" interface for the niche algos. We can discuss it again in that context.

Comment thread src/squidpy/gr/_autok.py Outdated
# keyed by (run, K) rather than drawn in sequence, so appending a candidate K
# does not re-seed every other K's fits
random_state = int(np.random.SeedSequence([seed, run, k]).generate_state(1)[0])
labels, nll = _fit_once(X, k, random_state, model_params)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runs only differ at the level of random seeds. Would it make sense to combine this with e.g. bootstrapping?

@selmanozleyen selmanozleyen Aug 25, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure, it sounds interesting but also sounds out of scope for this PR. wdyt @sarajimenez @timtreis ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think it's out of the scope of this PR, we're interested in the changes between Ks not across runs. Bootstrapping would be interesting in case we want to add significance. Am I reading this correct, @grst ?

@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from ca085f2 to afc5110 Compare September 3, 2026 12:03
@selmanozleyen
selmanozleyen changed the base branch from main to feat/experimental-params-typeddicts September 3, 2026 12:04
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from 6dcb8f6 to c522628 Compare September 15, 2026 16:50
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 9b765d2 to 1f3f110 Compare September 25, 2026 16:05
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from b9df9e0 to ae5d9d7 Compare September 25, 2026 19:42
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 1f3f110 to 743c3bc Compare September 25, 2026 19:56
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from ae5d9d7 to 694c55e Compare September 25, 2026 20:05
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch 4 times, most recently from 1ef005e to e57e3bd Compare September 25, 2026 22:04
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from fee85a0 to de01832 Compare September 25, 2026 22:25
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch 2 times, most recently from 49e32ae to e9b8b9a Compare September 29, 2026 14:58
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from de01832 to 145bfee Compare September 29, 2026 14:58
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from e9b8b9a to 07c64ef Compare September 29, 2026 16:05
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from 145bfee to 08c7369 Compare September 29, 2026 16:05
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 07c64ef to 6a7f8e8 Compare October 1, 2026 12:15
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch 2 times, most recently from 06b7fa8 to 0c3243f Compare October 1, 2026 12:22
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 6a7f8e8 to 2308673 Compare October 1, 2026 12:22
@selmanozleyen
selmanozleyen force-pushed the feat/experimental-params-typeddicts branch from 0c3243f to 16d7cc2 Compare October 5, 2026 12:26
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 4d49338 to 8fc996f Compare October 5, 2026 12:26
Base automatically changed from feat/experimental-params-typeddicts to main October 9, 2026 14:34
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 8c571ab to 572bc93 Compare October 27, 2026 17:25
Rebuilt on #1284's niche refactor, reusing its pieces instead of keeping
parallel ones: main's Clusterer and LeidenClusterer, calculate_niche_custom,
the hop shells and nhood_aggregate.

New on top of them:
- sweep_auto_k and AutoKClusterer (gr/_autok.py), with ClusterAutoKResult
  next to them; SweepableClusterer extends main's Clusterer with the
  number-of-clusters parameter the sweep sets per fit
- gr.cluster_auto_k and gr.cluster_stability
- calculate_niche_cellcharter takes a (min, max) tuple or a sequence for
  n_clusters and then sweeps, with max_runs, convergence_tol and
  store_labels; _fit_clusterers writes an estimator's extra labelings and
  diagnostics when it leaves them
- gr.nhood_entropy, the entropy of nhood_aggregate's neighborhood composition
@selmanozleyen
selmanozleyen force-pushed the feat/cluster-auto-k-seed branch from 0089ea7 to 4275371 Compare October 27, 2026 23:58
Rename SweepableClusterer to KClusterer and export it with Clusterer. calculate_niche_cellcharter takes any KClusterer; a fixed K sets whichever of n_clusters/n_components the clusterer has.
@selmanozleyen
selmanozleyen marked this pull request as ready for review October 29, 2026 19:01
selmanozleyen and others added 5 commits October 29, 2026 20:01
A clusterer without a number of clusters is fitted as it is; n_clusters defaults to None (10 for a KClusterer) and must stay None for one without K.
calculate_niche_cellcharter keeps an int n_clusters plus clusterer; select K with cluster_auto_k on its embedding. Removes AutoKClusterer and the niche hooks, renames nll to fit_loss, keeps empty components as categories.
cluster_auto_k needs use_rep ('X' for adata.X); stability rows indexed by K; labels outside 0..K-1 rejected; TypeError for non-clusterers. calculate_niche_cellcharter keeps a given clusterer's K when n_clusters is None. Docs get their own sections.
calculate_niche_cellcharter: n_clusters (default 10) always sets a clusterer's K; K detection shared via k_param. cluster_auto_k validates before copying, guards BayesianGaussianMixture on sparse input, rejects non-integer labels, documents adata[mask].copy(). Tests merged and parametrized, always-true asserts tightened, missing cases added.
@selmanozleyen selmanozleyen self-assigned this Oct 30, 2026
@selmanozleyen
selmanozleyen requested review from Intron7 and grst and removed request for sarajimenez October 30, 2026 09:52
Clusterer and KClusterer are re-exported from squidpy.types and documented on its page, like the result tuples; squidpy.gr still exports them.
One fit path via fit_predict, failed fits get an add_note instead of message matching, no estimator-specific sparse guard, validation in one place, k_param next to the protocols, n_clusters defaults to a visible 10, tests no longer monkeypatch internals.
One _auto_k helper for the repeated sweep arguments, one relabeling KMeans instead of four classes, table layout asserted once, copy and store_labels in one sweep, obsm and SpatialData inputs in one parametrized test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add automatic K selection (CellCharter's ClusterAutoK) for the cellcharter niche flavor

3 participants