Skip to content

Add LRRSettings.init_default (identity|CAR) for cold-start rereferencing - #19

Merged
cboulay merged 3 commits into
devfrom
feat/lrr-init-default-car
Jul 24, 2026
Merged

Add LRRSettings.init_default (identity|CAR) for cold-start rereferencing#19
cboulay merged 3 commits into
devfrom
feat/lrr-init-default-car

Conversation

@kylmcgr

@kylmcgr kylmcgr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Make LRR's behavior configurable for when it has no weights yet (weights=None
and nothing fit). Today it initializes to identity and waits for an update; this
adds the option to instead apply per-cluster common-average reference (CAR) as a
cold start, so a stream gets common-mode rejection before any fitted weights exist.
The default stays IDENTITY, so existing behavior is unchanged.

Motivating use case: an offline-only LRR workflow where run 1 has no fitted
checkpoint. Rather than run open-loop with identity referencing (or pre-generate a
CAR matrix out-of-band), the transformer can seed CAR directly from the channel
clusters it already resolves at reset.

What changed

  • RereferenceInit enum: IDENTITY (default) and CAR.
  • LRRSettings.init_default (default IDENTITY) — the effective transform used
    only when no weights are provided or fit.
  • LRRTransformer._car_effective_matrix builds the CAR effective (I − W) matrix
    per cluster: within a cluster of k channels the sub-block is
    (k/(k−1))·I_k − (1/(k−1))·J_k, i.e. y_i = x_i − mean_{j≠i} x_j (leave-one-out).
    Clusters smaller than MIN_REREF_CLUSTER_SIZE — and all cross-cluster terms — stay
    identity, matching the fit's passthrough for tiny/sliced clusters. Built in the
    message's array namespace via a selection-matrix scatter, so GPU-backed arrays stay
    on device (mirrors _solve_weights).
  • _process uses this as the no-weights fallback when init_default == CAR.

Behavior & backward compatibility

  • Default IDENTITYno behavior change; existing users are unaffected.
  • Precedence: a provided or fitted W always wins over init_default. CAR is
    only the cold start and is replaced the moment weights are loaded or fit.
  • init_default affects only the output path — it does not contaminate the fit
    (partial_fit accumulates covariance on the raw input, not the CAR output).
  • CAR uses the same cluster resolution as the fit (channel_clusters /
    cluster_by_field / block_size).

Usage

LRRSettings(axis="ch", cluster_by_field="bank", init_default=RereferenceInit.CAR)
# no weights, no fit  -> per-bank leave-one-out CAR at reset
# provide weights= or partial_fit()  -> those take over

Testing

tests/unit/test_ssr.py — 34 passing. New coverage: per-cluster and
cluster_by_field="bank" leave-one-out CAR against a reference implementation,
small-cluster passthrough, identity-default backward-compat, and both
provided-weights and fitted-weights overriding CAR (test_fit_overrides_car).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a configurable cold-start behavior for LRR when no weights are provided or fitted yet, allowing per-cluster leave-one-out CAR to be applied immediately (while keeping legacy identity passthrough as the default).

Changes:

  • Introduces RereferenceInit (IDENTITY, CAR) and LRRSettings.init_default (default IDENTITY).
  • Implements _car_effective_matrix and uses it as the no-weights fallback in _process when init_default == CAR.
  • Adds unit tests covering CAR cold-start behavior, small-cluster passthrough, default identity behavior, and precedence of provided/fitted weights over CAR.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/ezmsg/learn/process/ssr.py Adds the RereferenceInit enum, init_default setting, CAR effective-matrix construction, and CAR fallback path when no weights exist.
tests/unit/test_ssr.py Adds TestCARInit coverage validating CAR cold-start math, clustering behavior, and precedence rules.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

ezmsg-sigproc 2.34.0 (ezmsg-org/ezmsg-sigproc#186) hoists deterministic
cluster-aware rereference matrix construction into
ezmsg.sigproc.util.rereference. Use it here:

- Replace the local RereferenceInit enum with the imported RereferenceKind
  (re-exported from this module; same "identity"/"car" config values).
- Delete _car_effective_matrix: the cold-start branch in _process is now a
  single rereference_matrix(...) call. The matrix is built as host-side
  numpy; AffineTransformTransformer converts it to the message's
  namespace/dtype/device on first use, so the on-device selection-matrix
  scatter is no longer needed.
- Use the shared validate_channel_clusters() from util.channels for
  cluster bounds checking (empty-list policy stays here).
- Bump ezmsg-sigproc floor to 2.34.0 (and test dep ezmsg-simbiophys to
  1.8.0).

Behavior is unchanged for float streams; integer-dtype streams now get
float64 output from the cold-start transform (weights stay float64
instead of being cast to the message's integer dtype).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cboulay

cboulay commented Jul 23, 2026

Copy link
Copy Markdown
Member

Pushed a simplification commit (a8d4279) that delegates the CAR cold-start matrix construction to the newly released ezmsg-sigproc 2.34.0 (ezmsg-org/ezmsg-sigproc#186), which hoists deterministic cluster-aware rereference matrices into ezmsg.sigproc.util.rereference so they're reusable by any AffineTransform user.

Changes relative to the original PR:

  • RereferenceInit is replaced by the imported RereferenceKind (same "identity"/"car" values, re-exported from ezmsg.learn.process.ssr). LRRSettings.init_default semantics are unchanged.
  • _car_effective_matrix (~30 lines incl. the on-device selection-matrix scatter) is deleted; the cold-start branch in _process is now a single rereference_matrix(kind, n, clusters=..., include_current=False, min_reref_size=MIN_REREF_CLUSTER_SIZE) call. The matrix is plain numpy — AffineTransformTransformer already converts weights to the message's namespace/dtype/device on first use, so GPU streams are still served without device-side construction here.
  • Cluster bounds validation now uses the shared validate_channel_clusters() from ezmsg.sigproc.util.channels (the empty-cluster-list fail-fast policy stays local).
  • Dependency floor bumped to ezmsg-sigproc>=2.34.0.

All tests from this PR pass unchanged apart from the enum rename (34 in test_ssr.py; full unit suite 308 passed). One minor behavior note: integer-dtype streams now get float64 output from the cold-start transform (weights are no longer cast to the message's integer dtype); float streams are bit-identical to before.

🤖 Generated with Claude Code

New TestBackendPreservation (parametrized over mlx and torch, with
importorskip guards for platforms without mlx) asserts that:
- the output stays in the input's array namespace,
- fitted state (cxx, weights) and the internal affine's weight arrays
  live in that namespace, and
- numpy matrices (cold-start CAR/identity, user-provided settings.weights)
  are converted to the message's backend on first use,
with values matching the numpy reference path in all three cases.

The tests exposed a pre-existing bug: MLX's linalg.inv/pinv only run on
the CPU stream, so partial_fit crashed for mlx-backed messages.
_solve_weights now passes stream=mx.cpu for the mlx namespace -- with
unified memory this is a scheduling hint, not a host copy, and results
remain mlx arrays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cboulay

cboulay commented Jul 23, 2026

Copy link
Copy Markdown
Member

Pushed 373d987 adding backend-preservation tests for LRRTransformer (TestBackendPreservation, parametrized over mlx and torch with importorskip guards, so CI without mlx skips rather than fails). They assert, for each backend:

  1. Cold start (init_default=CAR): output stays in the input's namespace, and the numpy-built cold-start matrix is converted to that backend by the internal AffineTransformTransformer on first use — pinning the conversion contract this PR now relies on.
  2. Fit path: state.cxx, state.weights, the affine's weight arrays, and the output all live in the input's namespace, with values matching the numpy-fitted reference.
  3. Provided numpy settings.weights + backend messages: output in the message's backend, values matching numpy.

Writing these exposed a pre-existing bug (present on dev, unrelated to this PR's feature): partial_fit crashed for MLX-backed messages because MLX's linalg.inv/pinv only run on the CPU stream (ValueError: [linalg::inv] This op is not yet supported on the GPU). _solve_weights now passes stream=mx.cpu when the namespace is mlx.core — with Apple unified memory this is a scheduling hint rather than a host copy, so results remain mlx arrays and everything else stays on-device. Happy to split that one-liner into its own PR if preferred, but the tests here are what cover it.

Full unit suite: 314 passed, 2 skipped (macOS/arm64, mlx 0.32.0, torch 2.13.0).

🤖 Generated with Claude Code

@cboulay
cboulay merged commit c82e659 into dev Jul 24, 2026
8 checks passed
@cboulay
cboulay deleted the feat/lrr-init-default-car branch July 24, 2026 01:01
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.

3 participants