Skip to content

smooth-all-once: derive any signature from one full-panel smooth (+ groupby cache fix, tutorial sparse-gene rescue) - #3

Merged
katosh merged 1 commit into
mainfrom
dominik/smooth-all-once
Jul 21, 2026
Merged

smooth-all-once: derive any signature from one full-panel smooth (+ groupby cache fix, tutorial sparse-gene rescue)#3
katosh merged 1 commit into
mainfrom
dominik/smooth-all-once

Conversation

@settylab-dotto-bot

Copy link
Copy Markdown

What & why

Computing signature A (genes 1–5) then signature B (genes 3–8) through the same pipeline used to
run two smooths — the #2 cache keys on the requested subset, so partial overlap was never
reused. This PR adds a smooth-all-genes-once path so the expensive smoothing is done a single
time per view and every signature, single gene, and blend is derived from those pre-smoothed
layers with no recompute. It also folds in a cache-correctness fix surfaced by the #2 skeptic, and
reworks the tutorial around the new capability (plus a sparse-gene rescue).

1. The rethink — all_genes=True and smooth_all()

Mechanism (deliberately minimal — it extends the #2 cache, doesn't fight it). smooth(..., all_genes=True) runs the pipeline over the full (n_obs, n_vars) matrix instead of the requested
subset, then gathers the signature's columns out of the full smoothed result for scoring. Because
each step now consumes the whole matrix, the per-step cache key (_cache_key) stops depending
on which genes were asked for — so a second signature, a single gene, or blend through the same
pipeline collides on the same key and re-smooths nothing. smooth_all(adata, steps=...) is a
convenience pre-pass that warms those layers without scoring a throwaway signature.

ss.smooth_all(adata, steps="spatial")                                  # every gene, once
ss.smooth_all(adata, steps="dm")                                       # the ONE GP solve
ss.smooth(adata, sig,     "hippocampus", steps="blend", all_genes=True)  # both layers reused
ss.smooth(adata, ["Ascl1"], "ascl1",     steps="dm",    all_genes=True)  # reused again

How it composes with blend and the cache. blend's two branches (spatial, dm) run on the
raw matrix independently; with all_genes=True each branch is exactly the full-panel spatial /
dm smooth, so a prior smooth_all for those two views serves both branches — the
diffusion-map GP runs exactly once
across spatial, dm, dm+spatial, blend, and any single
gene. Verified end-to-end by call-counting (test_all_genes_gp_runs_once_across_dm_gene_and_blend).

Fidelity — scoped precisely (this is the one place I did not claim "byte-identical" blanket).
The neighbour smoothers (KnnGaussian, Kde) are linear and column-independent, so a signature
gathered from the all-genes layer is bit-for-bit the per-subset smooth
(test_all_genes_linear_is_exact, assert_array_equal). The GP (KompotGP) matches the per-subset
result to floating-point precision (~1e-13 in float64; exact once stored as float32)not
bit-identical, because mellon's Nyström solve batches the columns and a 248-wide solve rounds a
shade differently from a 3-wide one. Every signature derived from one full layer is mutually
exact; the only non-exact comparison is against the counterfactual per-subset path we're replacing.
Tests assert allclose(atol=1e-6) for the GP and are explicit about why.

Scoring is unchanged: mean_z still standardises against the signature's raw subset stats;
all_genes changes only the source of the smoothed values. Storage footprint is unchanged too —
the #2 cache already allocates full-width (n_obs, n_vars) layers; all_genes fills every column
instead of some, and stores fewer distinct entries (one per pipeline, not one per signature).

2. Cache-correctness fix (#2 skeptic): groupby contents in the key

A condition-aware KompotGP(groupby=..., condition=...) fits only on obs[groupby] == condition.
_cache_key hashed the groupby column name and the condition label but not the column's
contents — so mutating that grouping in place (same name, different assignment) between two
otherwise-identical calls served a stale result fitted on the old grouping. The key now folds
the column's values (NUL-joined bytes) in, gated on groupby being set (keys of non-grouped steps
are unaffected). Tests: test_cache_key_folds_groupby_column_contents,
test_cache_key_ignores_unrelated_obs_for_non_groupby_steps, and an end-to-end
test_mutated_grouping_misses_and_recomputes reproducing the old false-hit → now a miss.

3. Tutorial

  • Sparse hippocampal member + rescue. The signature is now ranked by detection rate (the
    fraction of cells with any counts); the sparsest present marker is spotlighted and shown
    raw (speckle) → smoothed (the hippocampal domain re-emerges). The candidate list includes Ascl1
    (the colab's sparse marker, ~19% there) but is robust: it keeps whichever markers the panel
    carries and picks the sparsest, computing and printing the real detection rate at runtime.
  • blend is the combined method (replaces the old compose/dm+spatial framing for "both").
  • Smooth all genes once from the start, then derive every mode with all_genes=True, with a
    cheap check that counts distinct cached smooths — kompot_gp appears exactly once — and a
    before/after check that blend reuses it.

Notebook execution — follow-up. notebooks/tutorial.ipynb is regenerated from source but
unexecuted (I had no data host to run the public Xenium fetch + Palantir diffusion map). The
previously-committed executed outputs referenced the old signature/sections and would be stale, so
shipping a fresh unexecuted notebook is the honest state. The executed artifact (figures + the
real sparse-gene detection rate) needs a one-time re-run on a machine with data access — same path
the blend PR took. The test_tutorial_artifact executed-only checks skip (not fail) until then.

Tests

Full suite green: 161 passed, 6 skipped (3 squidpy env-only + 3 executed-notebook checks that
skip on the unexecuted artifact) via PYTHONPATH=src <venv>/bin/python -m pytest. New:
tests/test_all_genes.py (linear exactness, cross-signature reuse, single-gene rescue,
smooth_all warm/no-score, GP-runs-once, GP precision, blend parity) and the four groupby-key
tests in tests/test_cache.py.

Do not merge — for architecture review.

Add all_genes=True (and smooth_all()) so the pipeline smooths every var once
and any signature/single-gene/blend is gathered from those full layers -- the
cache key stops depending on the requested subset, so the diffusion-map GP runs
exactly once across spatial/dm/composed/blend. Exact for the linear neighbour
smoothers; float-precision for the GP (mellon's Nystrom solve rounds with matrix
width).

Fold obs[groupby] contents into _cache_key: a condition-aware KompotGP fitted on
obs[groupby]==condition was served a stale hit when that column was mutated in
place (same name, different assignment). Now a changed grouping misses.

Tutorial: add a sparse hippocampal member (ranked by detection rate) and show
raw speckle -> smoothed rescue; use blend for the combined view; smooth all
genes once up front and derive every mode cheaply, with a check that the GP ran
once. Notebook regenerated unexecuted (executed re-run needs the data host).
@katosh
katosh merged commit 9667cb2 into main Jul 21, 2026
5 checks passed
katosh added a commit that referenced this pull request Jul 21, 2026
Since v0.1.0 the package gained three user-facing features -- `blend`
composition mode (#1), the smoothing reuse cache (#2), and smooth-all-once
`all_genes=True` (#3) -- so a MINOR bump to 0.2.0 is the correct SemVer step.

- pyproject.toml + src/spatial_smooth/__init__.py: version 0.1.0 -> 0.2.0
- docs/source/installation.rst: the illustrative check_dependencies() output
  shows v0.2.0 (it prints v{__version__})
- CHANGELOG.md: new; 0.2.0 (the three features + the tutorial-now-executed and
  PyPI-image fixes) and a 0.1.0 baseline entry

conf.py already tracks __version__, so the docs title/version follow
automatically. twine check passes on the 0.2.0 sdist+wheel; the full test
suite is green.
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.

1 participant