Incorporating feedback on niche refactor - #1284
Conversation
…m and store the niche embedding in adata.obsm now. Not sure how much computational benefit this offers, as now, instead of many small embeddings (for each library-subsetted adata), one big embedding is being computed (for the whole adata). But irrespective of computational advantage, I think it can be useful to store the embedding in adata.obsm to inspect for end user.
for more information, see https://pre-commit.ci
…t layer of adata and ii) no longer creating temporary adata object in _UtagEmbedder
for more information, see https://pre-commit.ci
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1284 +/- ##
==========================================
+ Coverage 78.44% 78.57% +0.13%
==========================================
Files 63 64 +1
Lines 9532 9598 +66
Branches 1594 1612 +18
==========================================
+ Hits 7477 7542 +65
- Misses 1489 1498 +9
+ Partials 566 558 -8
🚀 New features to boost your workflow:
|
for more information, see https://pre-commit.ci
…cells are not re-visited
…her cleaning of _NhoodProfileEmbedder
…ation of hop adj matrices (_compute_hop_adjacency_matrices function) and removing unnecessary adata creation
for more information, see https://pre-commit.ci
…ces from _CellcharterEmbedder
…n _NhoodProfileEmbedder
… the result columns back to categoricals in _postprocess_niche_results, iii) explained resolutions arg better in calculate_niche_neighborhood
for more information, see https://pre-commit.ci
… Also renamed the CellcharterEmbedder to a more generic name
…te_niche_cellcharter
for more information, see https://pre-commit.ci
|
Hi, @grst and @selmanozleyen, this is ready for review. I think the suggestions were all quite useful and some of them I also thought about during the refactor, but didn't implement at that time. Here are some questions/points-of-discussion from me about the same:
|
|
I think it'd be nice to add a "share_niches: bool" parameter to the entire function that, if library_key is preset, decides whether things are jointly modeled. But that'd go beyond the scope of this PR. That's iirc also what official Cellcharter does |
## mask
`mask` came from monkeybread's `cellular_niches`, where rows are dropped before
the clustering graph is built. `6638075b` ported that; `b8dff5de` replaced it
with a post-hoc relabel, so everything is clustered now and masked rows are
renamed afterwards. Labels on the kept cells, against an unmasked run:
v1.8.3 35% masked cells absent from the fit
main 100% the mask has no effect on the model at all
here 20%
100% is the tell. The docstring has promised the exclusion since 2024 with no
flavor qualifier, and on v1.8.3 it held for neighborhood (0/20 masked cells got
a niche) and was false for the other three (20/20) -- silently, because `mask`
is in no flavor's `unused` list.
Renamed `cluster_mask`, because the name should say which stage it restricts:
the profile is built from every observation, so a masked one still reaches its
neighbors and is absent only from the clustering. Applied by the three flavors
that build an embedding. spatialleiden raises rather than accepting one it
cannot honour: it clusters the graphs, so an observation either takes part or
loses its edges, and `is_membership_fixed` is never passed to
`optimise_partition_multiplex`. v1.8.3 did nothing there and did not warn, so
no working call breaks.
`_fitted_on` aligns the mask in one place and fixes two things: a mask that
omits observations now keeps them, which is how the documented three-entry
example reads and which used to raise `IndexingError` on main and `IndexError`
on v1.8.3, so the documented usage has never worked; and a mask sharing no
index value, or excluding everything, raises.
## Follow-ups
One library loop instead of two. `_stratify` is the loop both pipelines had
their own copy of, called with a `run_one` callback; 104 records byte-identical
across the refactor. spatialleiden keeps its own `run_one` rather than becoming
a Clusterer, because it takes two graphs and no feature matrix.
`key_added` on all four flavors, defaulting to the existing column names. This
retires the private `prefix`, which existed only to plumb the per-library label
through the recursion `_stratify` replaced.
A warning when a graph has edges across libraries under `library_key`. The loop
slices `obsp`, which only preserves a block-diagonal graph, and nothing checked.
spatialleiden checks both its graphs, and its `latent_connectivities_key`
defaults to whatever `scanpy.pp.neighbors` left in `connectivities`, which knows
nothing of libraries. `min_niche_size` was also listed as unused by every flavor
while all four apply it.
`use_rep` on utag, which could emit an embedding but not take one back, so its
PCA sat inside the per-library loop and each library got its own basis -- widths
need not even match (50 columns in one library, 7 in another). `'X'` now spells
`adata.X` on both `use_rep` arguments.
`_aggregate_over` scales the rows after the sum rather than normalizing the
adjacency first, which rounded 1/k to float32 and left a representable mean
inexact. A non-floating product is promoted first, since the CellCharter rings
are bool and an integer X summed to an integer that cannot hold the quotient.
Dividing by the signed row sum also fixes the weighted mean for negative edge
weights.
153 tests.
8e59c35 to
9f01a12
Compare
Stratifying gives every library its own embedding and its own clustering, so labels carry a lib=<id>_ prefix and a niche in one library is unrelated to the same-numbered niche in another. Say so where users read it, and point at the alternative: no library_key, a batch-corrected use_rep and a graph built with spatial_neighbors(library_key=...). The Returns sections promised the embedding in .obsm even when stratifying, where nothing is written because the per-library embeddings share no axes.
|
IMO the neighborhood graph should respect |
|
I think with the Just to reiterate, even though |
Where does cellcharter provide something like this?
Is this in general? If so I don't see a reason to require library_key then (for other flavors except spatialleiden)? because all the embedding + clustering is already across samples by default |
It doesn't provide the option, it just jointly models it by default.
Yeah, I agree. I also wouldn't require it, the baseline assumption should be the thing that makes the most sense (joint niches here) |
|
Ok any reason not to remove it then for utag, cellcharter, neighbours? Because slice and run is all we do there and we can just document that if someone really wants it. Because |
It cannot build a graph. The flavors take spatial_connectivities_key, never spatial_key, and call no builder, so all library_key can do is subset a graph somebody else built. On a graph already built with spatial_neighbors (library_key=...) that subsetting is a no-op, measured to 0.00e+00; on one built pooled it drops the crossing edges without replacing them, leaving 198/200 cells short at least one neighbour where a genuine per-library run gives every cell its full degree. So it either does nothing or degrades, and never does the thing its name suggests. What is left is fitting a separate model per library, which makes the labels of one library unrelated to another's: three identical sections at n_clusters=4 give 12 labels with none shared, against 4 shared without it. The point of running across samples is a shared vocabulary, so that is now the only behaviour for neighborhood, utag and cellcharter. spatialleiden keeps it. It has no embedding step and both its graphs are inputs, so none of the above applies, and one multiplex run over pooled sections under-clusters through the global null model. calculate_niche is released with the parameter, so it raises for the three flavors that no longer take one rather than silently pooling, and still forwards it for spatialleiden. The loop leaves the pipeline with it. _stratify was unwrapping the SpatialData, looping over libraries and putting the table back; the first and last are what every path needs, so they are _on_table now and _stratify is only the loop on top. calculate_niche_custom runs once over everything and takes neither library_key nor graph_keys. spatialleiden calls the loop when library_key is given and the plain pipeline when it is not, so the stratified shape is opt-in rather than the frame every flavor is built inside. Two aggregation bugs surfaced on the way, both older than this change: Integer features lost their ring means. The block the rings are written into took the features' dtype, so an integer X through use_rep truncated every mean back to a count -- 0 of 240 ring entries kept a fractional part. Bool features aggregated to 1/k in every column, max error 0.75 against a float64 reference, because `bool @ bool` saturates to True instead of summing and the widening happened to the product rather than the operand.
2c83ebe to
6676965
Compare
shashkat
left a comment
There was a problem hiding this comment.
Other than these things, it looks good to me!
Just one more thing: fast-array-utils.types has HasArrayNamespace only from v1.5 onwards. So we may need to add the corresponding pin in the pyproject.yaml file.
grst
left a comment
There was a problem hiding this comment.
First round of comments, for now focussing on _nhood.py. Still need to take a look at _niche.py.
| return group_offsets, group_indices | ||
|
|
||
|
|
||
| @njit(inline="always", cache=True) |
There was a problem hiding this comment.
Have you benchmarked if the numba approach is actually faster than the matrix multiplication approach?
There was a problem hiding this comment.
yes I did, I will do a before after once the pr is more ready to be merged
| """Disjoint adjacency rings, one per hop up to *max_hop*. | ||
|
|
||
| Ring ``k`` holds the pairs first reached at hop ``k + 1``, so the rings never restate | ||
| each other. Ring 0 is the input as booleans, self-loops included. |
There was a problem hiding this comment.
@marcovarrone, reaching out to you as one of the original cellcharter authors here.
Context: we are working on native implementation of common niche-calling algorithms in squidpy, including cell-charter. The plan is to make the implementation modular by decomposing the neighborhood embedding and the clustering step, as these are often shared between different approaches.
I understand cellcharter builds an embedding by aggregating the spatial neighborhood at different levels. What I'm unsure: did you intend to aggregate growing circles, or disjoint rings? I.e. does the layer 2 include all cells from layer1 and so on?
| def _power_adjacencies(adj: CSBase, max_hop: int) -> list[CSBase]: | ||
| if max_hop < 1: | ||
| raise ValueError(f"max_hop must be >= 1, got {max_hop}.") | ||
|
|
||
| adjacencies, power = [adj], adj | ||
| for _ in range(1, max_hop): | ||
| power = power @ adj | ||
| adjacencies.append(power) | ||
| return adjacencies |
There was a problem hiding this comment.
Just one thought, but that's more likely for a future iteration: The n-hop adjacency matrices can become quite large as the sparsity reduces... Maybe we can come up with a smarter solution (maybe numba-based) that doesn't require to keep the entire adjaceny matrix in memory at the same time.
| if aggregation == "variance": | ||
| mean = to_dense(mean_over(features)) | ||
| dense = to_dense(features) | ||
| return to_dense(mean_over(dense * dense)) - mean * mean |
There was a problem hiding this comment.
... and possibly fast_array_utils.stats.mean_var?
|
Thanks for the reviews guys! After talking to @timtreis yesterday I realized spatiallieden doesn't also need |
No flavor takes library_key now. spatialleiden builds neither of its graphs, so per-library graphs were already the caller's job, and what library_key added on top was one Leiden run per library, a separate label space for each: what the other three flavors already dropped. The deprecated calculate_niche keeps the parameter and raises for every flavor, naming the alternative. _stratify, the cross-library warning and the lib=<id>_ prefix go with it. The neighborhood profile counts each reached cell once again. Past hop 1 it read walk counts off the matrix power, so a cell reached by two paths counted twice and a cell took a flat 1/d share of its own 2-hop profile. Hop 1 keeps the edge weights; past it only reachability is defined. Both flavors warn on a weighted graph. Pins that a lower-degree cell gets its own composition, which v1.8.3's padding got wrong. From the review: - fast-array-utils>=1.5, for types.HasArrayNamespace - LeidenClusterer.labels_ is .cat.codes; the niche labels are unchanged - _bfs_shells reads get_num_threads() instead of taking n_threads - unlabelled observations come from groups.notna(), not the one-hot sums Also: - the neighbor count takes that mask instead of every hop matrix being copied with it, including under aggregation="sum" where it changed nothing; bit-identical, and the 3-hop profile is ~25% faster - the BFS no longer writes ring 0 only for it to be replaced by the input - min_niche_size applies before the categorical is built; spatialleiden reads its labels back inside its own loop, and they are strings now as on every other flavor, instead of integers unless min_niche_size was set - niche categories sort numerically, 0, 1, 2 ... 10, as sc.tl.leiden has them, instead of as text
for more information, see https://pre-commit.ci
calculate_niche is released with library_key, and raising broke every v1.8.x script that passed it. It now warns and keeps v1.8.3's behaviour until the function itself goes in v1.9.0: each library fitted on its own subset, labels prefixed lib=<id>_, observations with no library left as not_a_niche. The loop is its own function, _niches_per_library, marked for deletion with calculate_niche, which is its only caller. The flavor functions do not take library_key and do not go through it; the umbrella runs the flavor on each library's subset and merges the labels back. The columns it merges come from each flavor's key_added and _resolution_values, so a rerun overwrites them rather than keeping the previous run's labels.
timtreis
left a comment
There was a problem hiding this comment.
we had already discussed most details, in particular the library_key situation. lgtm
The library_key warning and docstring said to call the flavor on each library's subset without showing how, and did not say which suggestion answers which need. They now separate the two: graphs built per library either way, library_key unset for niches shared across libraries, and a slice per library for niches per library. The docstring example copies the labels back, which a loop over slices otherwise discards; run on two libraries it gives the same 300/300 labels as the deprecated path.
|
I will merge this now and assess the open items in another pr, thanks guys! |
After #1284: calculate_niche_custom takes only data positionally. The deprecated calculate_niche and its validator keep main's signatures and are exempted, since they are removed in v1.9.0; decorating the umbrella also moved its deprecation warning off the caller. The two numba kernels #1284 added are exempted like the others.
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
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
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
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
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
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
Fixes: #1277
Description
Big thanks to @grst for invaluable feedback (#1277) on the niche refactor in #1245. Incorporating those suggestions in this PR.