Skip to content

feat(cadecon): noise-constrained sparsity for low-SNR deconvolution#168

Merged
daharoni merged 4 commits into
mainfrom
feat/noise-constrained-sparsity
Jul 9, 2026
Merged

feat(cadecon): noise-constrained sparsity for low-SNR deconvolution#168
daharoni merged 4 commits into
mainfrom
feat/noise-constrained-sparsity

Conversation

@daharoni

@daharoni daharoni commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an optional, knob-free sparsity to the InDeCa spike inference that suppresses noise being fit as spurious activity. Rather than picking the binarization threshold that maximizes fit (which overfits into the noise floor), it picks the sparsest spike support whose reconstruction residual still reaches the data-derived noise floor — "don't spend spikes explaining anything that's within the noise." The effect self-scales with SNR: strong at low SNR, negligible at high SNR.

Default ON in the CaDecon app (library default is off / MaxPve, so existing callers are byte-identical). There is no tuning parameter — σ is measured from the data.

How it works

  • Threshold-selection stage, not FISTA. The Box[0,1] relaxation is unchanged (an L1 λ there gets laundered out by the peak-normalization that follows). The sparsity acts only when the relaxed solution is binarized: pick the highest (sparsest) threshold whose residual, evaluated at original-rate grid positions, stays within the noise budget σ²·n_grid (the Morozov discrepancy principle). Falls back to the min-residual threshold if none is feasible.
  • LP-robust noise estimate. σ is the raw high-frequency-PSD estimate of the unfiltered trace (immune to the low-pass filter, which would otherwise gut that band) rescaled by the empirically measured noise gain of the actual upsample→HP/LP chain, so the budget tracks the noise that survives into the residual.
  • Consistent outer-loop selection. The scale-iteration loop selects its best iterate by max-PVE under MaxPve (unchanged) but by best prescale calibration under NoiseFloor, so the outer loop doesn't re-select the densest-fitting iteration and undo the sparsity the inner search just imposed.

Teaching / impact overlay (opt-in, default OFF)

To see the sparsity's effect, an opt-in overlay solves each trace with both settings per iteration and stores the opposite-setting counts; the Trace Inspector overlays them and shows both spike counts. It's precomputed during the run (no main-thread solving) so browsing stays smooth, roughly doubles run time, and lives under a Display Options panel — a visual-inspection aid, not an algorithm knob. Documented in-UI and in the "Reading Convergence & Results" tutorial.

Also in this PR

  • Removed the experimental collapse_runs lever — it collapsed genuine dense spike trains and was shown harmful on bursty data; only the noise-constrained path ships.
  • Python binding + docs. calab.solve_trace(..., noise_constrained=False) exposes the option through PyO3; documented in the CaDecon guide.
  • Threads the flag through the WASM + PyO3 bindings.
  • Sidebar layout fix: control panels used the default flex-shrink, so a short viewport (or large text) compressed and overlapped them instead of scrolling. Panels are now fixed-size and the sidebar scrolls.
  • Changelog backfill: reconstructed the missing minor releases (2.1.0–2.4.0), added 2.5.0 (PRs fix(cadecon): correct per-subset kernel attribution + init/variance robustness #153docs(changelog): backfill the #60–155 gap from git history #167) and 2.6.0 (this PR) from git history.

Testing

  • Rust: cargo test -p calab-solver — 135 passed. New tests cover the noise-floor budget-ordering invariant (larger budget → sparser support), the infeasible-fallback path, high_band_sigma recovering an injected white-noise σ, and end-to-end recovery on a noisy trace.
  • Python: pytest — 18 passed (adds a noisy-trace noise_constrained integration test).
  • tsc clean; CI green (rust, python 3.11/3.12/3.13, format, supabase).
  • Event-level A/B validation (simulated cells, event precision/recall + count-bias vs ground truth across SNR ∈ {1,2,4,8,16}): noise-constrained more than doubles precision and pulls count-inflation from ~2.8× toward ~1× at every SNR. By F1 it wins at SNR 4/8, is a wash at SNR 2 (a precision-for-recall rebalance), and is mildly worse at SNR 1 (the documented low-SNR trim — the toggle description flags it). A ±30% kernel-mismatch condition barely changed the results. Note: smoothed-trace correlation is a misleading gauge here (it rises with the feature even where event recall falls), which is why validation is event-level.
  • Manual: verified in-browser (demo data) that noise-constrained sparsity cleans low-SNR activity, the overlay + per-cell spike counts render with no lag, and the sidebar scrolls without squishing at short viewport heights.

🤖 Generated with Claude Code

Add an optional, knob-free sparsity to the InDeCa spike inference that
suppresses noise being fit as spurious activity. Instead of picking the
binarization threshold that maximizes fit (which overfits into the noise),
pick the sparsest spike support whose reconstruction residual still reaches
the data-derived noise floor ("don't spend spikes explaining what's within
the noise"). The effect concentrates at low SNR and is neutral at high SNR.

Core (crates/solver):
- threshold.rs: Selection::{MaxPve,NoiseFloor}; threshold_search_opts +
  select_noise_floor_threshold pick the highest (sparsest) threshold whose
  residual, evaluated at original-rate grid positions, stays within sigma^2*T.
- indeca.rs: SolveOptions{noise_constrained, collapse_runs}; solve_trace_opts.
  Noise sigma is LP-robust: raw high-frequency-PSD estimate (high_band_sigma)
  rescaled by the empirically measured noise gain of the upsample+HP/LP chain
  (estimate_grid_noise_sigma), so it tracks the noise that survives the filters.
- upsample.rs: collapse_runs (event-preserving reduction; not wired to UI).
- js_indeca.rs / py_api.rs: expose the flag through the WASM + PyO3 bindings.

App (apps/cadecon):
- Thread noise_constrained through worker -> pool -> iteration-manager, read
  from a new algorithm-store signal. Default ON.
- Teaching/impact overlay (opt-in, default OFF): when enabled, each trace is
  solved with BOTH sparsity settings per iteration and the opposite-setting
  counts are stored; the Trace Inspector overlays them and shows both spike
  counts. Precomputed in the run (no main-thread solving) so browsing stays
  smooth. Lives under a new "Display Options" panel (it is a visual-inspection
  aid, not an algorithm knob) and doubles run time, documented in-UI and in
  the "Reading Convergence & Results" tutorial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDPugMWynodvqCBPH4p8ab
@daharoni daharoni force-pushed the feat/noise-constrained-sparsity branch from 29f5f67 to 77546cd Compare July 9, 2026 04:37
daharoni and others added 3 commits July 8, 2026 22:00
…nto Python, backfill changelog

Remove the last collapse-runs reference (stale doc comment), expose the
noise_constrained option through the calab.solve_trace Python binding, and
document it in the CaDecon guide.

Restructure docs/CHANGELOG.md: split the consolidated post-2.0.6 blob into the
real minor releases (2.1.0-2.4.0), add 2.5.0 (PRs #153-#167), and scope 2.6.0
to PR #168.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…; review cleanups

Council review caught a correctness bug: the outer scale-iteration loop always
selected the best iterate by max PVE, even under noise-constrained selection —
re-selecting the densest-fitting iteration after the inner threshold search had
deliberately stopped at the noise floor, partially re-laundering the sparsity.
Under NoiseFloor the outer loop now selects the best-calibrated prescale (min
scale-convergence error), the loop's own fixed point; MaxPve is unchanged
(historical output byte-identical).

Also folds in the prior review cleanups:
- unify grid_sse_at_threshold into evaluate_threshold via a stride param
- top-down early-exit noise-floor scan (identical result, fewer evaluations)
- Rust tests: budget-ordering invariant, infeasible fallback, high_band_sigma
  recovery, end-to-end noisy-trace recovery; Python noisy-trace test
- doc rewording (drop "experiment"/"count-debiasing"); corrected viz-store
  overlay comment; consolidated TraceInspector label helpers
- UI: clarify the toggle describes a stopping rule + SNR≈1 trim caveat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daharoni daharoni merged commit fa14dbe into main Jul 9, 2026
6 checks passed
@daharoni daharoni deleted the feat/noise-constrained-sparsity branch July 9, 2026 05:59
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