Skip to content

cache: reuse smoothing results via an input+params+basis hash (the DM GP runs once, not twice) - #2

Merged
katosh merged 1 commit into
mainfrom
dominik/smooth-cache
Jul 20, 2026
Merged

cache: reuse smoothing results via an input+params+basis hash (the DM GP runs once, not twice)#2
katosh merged 1 commit into
mainfrom
dominik/smooth-cache

Conversation

@settylab-dotto-bot

Copy link
Copy Markdown

The redundancy this fixes

A four-mode figure that renders spatial, dm and blend computes each smoothing twice. blend runs a spatial branch and a cell-state branch independently on the raw expression, and those branches reproduce the exact same computation the standalone spatial and dm modes already did. So the expensive diffusion-map Gaussian process (and the spatial smooth) each ran once for their own mode and once again inside blend.

This PR memoizes each smoother so the identical computation is served from a cache instead of recomputed — the DM GP now runs once total across all three modes.

The design — a hash-keyed, per-step cache

A pipeline step is a pure function of three things, so the cache key (sha256) is over exactly those:

  • the input matrix it consumes — np.ascontiguousarray(matrix).tobytes() + shape + dtype (the exact stored floats, never a re-derived or rounded copy);
  • the step's parameters — a canonical signature (type(step).__name__ + its dataclass fields, sorted); and
  • the basis bytes it smooths over — adata.obsm[step.basis] (spatial coords / diffusion map).

The cache lives at the per-step dispatch (_run_pipeline), through which both the dm/spatial modes and blend's two branches route — so caching there fixes all of them at once. Because spatial and blend's left branch produce the identical (raw genes, step, basis) key (same for dm and blend's right branch), blend's branches hit the cache.

Invalidation is by construction: change the input, a parameter, or the embedding and the hash changes → miss → recompute. A stale result can never be served.

Storage — matrices in layers, index in uns

  • Each step's output is stored as adata.layers["_sscache_<hash>"]. A layer must be (n_obs, n_vars), so the signature columns are scattered into a full-width float64 layer (rest NaN) and a hit gathers exactly those columns back — bit for bit, float64 kept, so a replay is byte-identical to a fresh compute.
  • adata.uns["spatial_smooth_cache"] holds only the index (a JSON blob: hash → layer-key, the gene columns, the param signature, and the resolved params), never a matrix. JSON mirrors the existing steps_json precedent, so it round-trips through .h5ad cleanly.

The namespaced _sscache_ prefix makes cache artifacts obvious and trivially clearable.

The API — on by default, fully guarded

The operator asked for on-adata caching, so it is on by default, with the serialization/bloat tradeoff guarded three ways:

  • smooth(..., cache=False) — neither read nor write the cache.
  • smooth(..., cache_max_entries=N) — LRU cap (default SMOOTH_CACHE_MAX_ENTRIES = 64) bounding the .h5ad growth caching introduces.
  • ss.clear_smooth_cache(adata) — drops every _sscache_ layer and the uns index, leaving your stored results (obs/obsm/uns["spatial_smooth"]) untouched.

A cache hit short-cuts the smoother only — the score, raw, and provenance are still written normally, and are byte-identical to a cache-off run.

Tests (tests/test_cache.py)

  1. A repeated identical smooth(...) is a genuine hit — the smoother is not re-run (asserted via a call-counter on .apply) and the result is identical.
  2. blend after spatial+dm reuses both branches — the DM GP runs exactly once across all three modes, and blend's output is byte-identical to the same three modes run with cache=False.
  3. A changed input, parameter, or basis misses and recomputes.

Plus: the cached artifact is a layer (not obs) of full (n_obs, n_vars) shape with the index a JSON string in uns; normal outputs and provenance are byte-identical cache-on vs off; the opt-out, clear_smooth_cache, and LRU-eviction guards; and an .h5ad round-trip after which a reloaded object still hits.

The full existing suite stays green (no regressions), including the doctest pass and the kompot-backed GP job.

Notes

  • Public, visualization-only package — no behavior change with the cache off; results identical with it on.
  • The #284 notebook that motivated this needs ~zero change: each smoothing now runs once automatically.

A four-mode figure that runs spatial, dm and blend recomputed the
expensive diffusion-map GP (and the spatial smooth) twice -- once for
their own mode, once inside blend's branches. Memoize each pipeline
step's output on the AnnData, keyed by a sha256 of its input matrix
bytes, canonical parameters, and basis bytes, and reuse it on a hit.
blend's two branches reproduce the spatial and dm steps verbatim, so
the GP now runs once total.

Matrices are stored as full-width layers (_sscache_<hash>), the tiny
hash->layer index as a JSON blob in uns[spatial_smooth_cache]; both
serialize with the object. Guarded: an LRU cap (cache_max_entries),
a cache=False opt-out, and clear_smooth_cache(adata). A hit replays
the exact float64 matrix and its resolved params, so every downstream
output -- score, raw, provenance -- is byte-identical to a cache-off
run. Invalidation is by construction: any input/param/basis change
yields a new hash and a miss.

Adds tests/test_cache.py (hit, blend-GP-runs-once + byte-identical,
miss on change, layer-not-obs storage, opt-out/clear/LRU guards,
h5ad round-trip).
@katosh
katosh merged commit 938f772 into main Jul 20, 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