Skip to content

DynamicSVD: fit_on_signal + >2D inputs, and actually use the mu scaffold in example 05 - #119

Merged
cweniger merged 8 commits into
mainfrom
feat/svd-fit-on-signal
Aug 8, 2026
Merged

DynamicSVD: fit_on_signal + >2D inputs, and actually use the mu scaffold in example 05#119
cweniger merged 8 commits into
mainfrom
feat/svd-fit-on-signal

Conversation

@cweniger

@cweniger cweniger commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Extracted from plan/gaussianized-flow-matching (21cee8a, 79b8b53), plus the follow-through needed to make the feature real in a shipped example.

The bug this uncovers

examples/05_linear_regression declares scaffolds: [mu] and passes _input_: [x, mu] to DynamicSVD. Its forward model is deliberately split into SignalSimulatorNoiseSimulator for the sole purpose of exposing the noiseless mu.

DynamicSVD then throws it away. On main, signal is touched in exactly one place:

if self.whitener is not None and signal is not None:
    self.whitener.update((x - signal).detach())

No whitener is attached in that config, so mu is plumbed through the entire graph and silently discarded. The example pays for a scaffold node it never uses.

What changes

fit_on_signal fits the eigenbasis on the clean signal stream while projections in forward() stay on x. Two reasons that matters:

  • Basis selection. Fitting on noisy x lets noise eigenvalues — spread up to the Marchenko–Pastur edge (1+√(D/N))²σ² — masquerade as structure and displace real components from the top-k.
  • The Wiener step. The whitener normalizes noise to unit variance, so fitting on x gives λ ≈ λ_s + 1 and the gain λ/(λ+1) becomes (λ_s+1)/(λ_s+2), bounded below by ½. A pure-noise direction reaches the downstream network at half amplitude instead of being suppressed. Fitting on the signal gives λ = λ_s and the textbook λ_s/(λ_s+1).

Net effect on output scale, in whitened units:

λ used output std at λ_s = 0 (pure noise) λ_s → ∞
fit on x λ_s + 1 0.50 → 1
fit on signal λ_s 0 → 1

>2D inputs are flattened to (batch_size, D) internally, so image-shaped data can be fed in directly.

Commits

  1. fit_on_signal flag — cherry-pick of 21cee8a. Default False, and with the flag off the expression reduces to the previous line exactly, so it is a no-op for existing callers.
  2. flatten >2D inputs — cherry-pick of 79b8b53, covering update() and forward().
  3. flatten in reconstruct() too — the one entry point commit 2 missed, which would still reject the inputs the other two now accept.
  4. docstring — it was stale under every setting: it described signal as whitener-only, and advertised step 2 as a "Wiener filter" without noting that this holds only when λ is signal power in noise units. Now states all three configurations, including that with no whitener the + 1 sits in raw data units and the threshold is arbitrary. No behaviour change.
  5. example 05 — see below.

Example 05: the whitener is not optional here

fit_on_signal alone would not have been correct in this example. With no whitener the eigenvalues are in raw data units, so λ/(λ+1) compares signal power against 1.0 when the noise variance is σ² = 0.01. The two settings only make sense together:

embedding:
  _target_: falcon.embeddings.DynamicSVD
  _input_: [x, mu]
  n_components: 32
  momentum: 0.1
  fit_on_signal: true
  whitener:
    _target_: falcon.embeddings.DiagonalWhitener
    dim: 1000   # n_bins

The whitener trains on noise = x - mu, which is zero-mean with σ = 0.1, so it drives std → σ and the eigenvalues become genuine signal-to-noise ratios.

The nested _target_ resolves correctly — builder.py:336 runs every embedding kwarg through _instantiate_sub_targets, whose docstring calls out this exact case (a raw dict would make DynamicSVD call dict.update() instead of the whitener's .update()).

⚠️ This changes the example's behaviour: different basis, differently scaled coefficients. 05 has an analytic posterior, so it is directly checkable — please re-run it and confirm it still recovers Sigma_post / mu_post before merging.

Default left at False

fit_on_signal defaults to False and is set explicitly in example 05. Flipping the constructor default remains open — worth doing once 05 has demonstrated the setting end to end, since at that point every in-repo caller already opts in. Say the word and it is a one-line follow-up.

Coverage note

Before this PR, none of the affected paths were reachable from any committed config: nothing set fit_on_signal, nothing fed >2D input, and nothing passed a whitener and a signal together — so even main's existing signal handling was dead code. Commit 5 makes the first and third live. The >2D flatten path still has no in-repo caller.

Testing

Not run — import torch fails in my environment (libcudnn.so.9: cannot open shared object file), which breaks pytest tests collection on main too. Verification here is static review, py_compile, YAML parse of the edited config, and a check that dim: 1000 matches the n_bins: 1000 on the mu simulator.

The re-run of example 05 is the real test and I cannot do it.

🤖 Generated with Claude Code

cweniger and others added 5 commits August 8, 2026 23:19
…ld stream

With fit_on_signal=true, update() fits the basis from the noise-free
``signal`` argument (a training-only scaffold node) while forward()
keeps projecting the noisy ``x``. Measured motivation (LDC MBHB v67b
endgame, D=17280, N=512): a noisy-fitted 48-dim basis carries ~20
components of pure re-projected buffer noise (Marchenko-Pastur edge
(1+sqrt(D/N))^2 ~ 46 sigma^2) - in-sample leakage and overfitting fuel;
info-weighted contamination of the informative directions is only ~0.5%,
so this is hygiene for the tail components, not a posterior-width fix.
Default off; signal=None at inference is a no-op (eval never updates).

Pairs with the falcon graph `scaffolds:` node role: list the clean twin
under scaffolds (not evidence) and wire `_input_: [x, s]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgzZR6VZFBKwm2aXz5X5Hg
Lets graph nodes with shaped outputs (e.g. (B, C, N) strain) wire
directly via _input_: [x, s] without nn.Flatten pipeline stages -
which would crash on the None a scaffold key becomes at inference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgzZR6VZFBKwm2aXz5X5Hg
update() and forward() gained an internal flatten, but reconstruct() did
not, so it still fails on the >2D inputs the other two now accept.  Same
defect, same fix -- leaving one of the three entry points inconsistent is
just a trap for whoever hits it next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The class docstring was stale under every setting: it described `signal` as
whitener-only (it now also drives the eigenbasis fit), and it advertised step
2 as a "Wiener filter" without saying that this only holds when lambda is
signal power in noise units.

Spell out the three configurations:
  - whitener + fit_on_signal: lambda = lambda_signal, the textbook gain
  - whitener, fit on x:       lambda ~ lambda_signal + 1, so the gain is
                              (l+1)/(l+2) >= 1/2 and a noise-only direction is
                              passed at half amplitude instead of suppressed
  - no whitener:              the `+ 1` is in raw data units, threshold arbitrary

Also note that steps 2-3 are applied jointly and only under shrinkage=True,
and that >2D inputs are flattened internally.  No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config declared scaffolds: [mu] and passed _input_: [x, mu] to
DynamicSVD, but no whitener was attached -- and DynamicSVD only touches
`signal` when a whitener is present.  So mu was plumbed all the way through
the graph and then silently discarded.  The forward model is split into
SignalSimulator -> NoiseSimulator purely to expose mu, so the example was
paying for a scaffold it never used.

Attach a DiagonalWhitener(dim=1000) and set fit_on_signal: true, which is
the combination that makes both mu-dependent paths real:

  - the whitener trains on noise = x - mu, so it normalizes the noise to
    unit variance and the SVD eigenvalues become signal-to-noise ratios;
    lambda/(lambda+1) is then the textbook Wiener gain.  Without a whitener
    the `+ 1` sits in raw data units (sigma^2 = 0.01 here), so the filter
    threshold lands in the wrong place.
  - fit_on_signal fits the basis on clean mu, so noise eigenvalues cannot
    masquerade as structure and crowd real components out of the top-32.

BEHAVIOUR CHANGE for this example: the embedding now learns a different
basis and emits differently scaled coefficients.  Needs a re-run against the
analytic posterior to confirm it still converges (see PR notes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 10.21%. Comparing base (9db772f) to head (950503d).

Files with missing lines Patch % Lines
src/falcon/embeddings/svd.py 0.00% 26 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #119      +/-   ##
==========================================
- Coverage   10.26%   10.21%   -0.05%     
==========================================
  Files          30       30              
  Lines        3955     3973      +18     
==========================================
  Hits          406      406              
- Misses       3549     3567      +18     
Flag Coverage Δ
unit 10.21% <0.00%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

cweniger and others added 2 commits August 8, 2026 23:29
The Wiener step lambda/(lambda+1) is only meaningful if lambda and the
denominator are in the same units.  A whitener guarantees that by
normalizing the noise to unit variance, but without one the eigenvalues are
in raw data units and the `+ 1` is an arbitrary constant -- in example 05 it
compares signal power against 1.0 when the noise variance is 0.01, so the
filter threshold lands in the wrong place entirely.

When a signal is available but no whitener is, estimate a single scalar
noise variance from x - signal under a white-noise assumption (EMA, same
momentum as the SVD blend) and use lambda/(lambda+sigma^2).  Precedence:

  whitener attached            -> sigma^2 = 1  (noise already normalized)
  no whitener, signal given    -> sigma^2 = running estimate
  neither                      -> sigma^2 = 1  (nothing to estimate from)

Only the middle case changes behaviour, and nothing in the repo was in it.

Steps 2-3 of forward() are now applied as the single factor
sqrt(L)/(L+sigma^2) rather than L/(L+1)/sqrt(L).  These are algebraically
identical and agree to ~2e-16 relative across L in [1e-12, 1e6]; the new
form also avoids dividing by sqrt of the 1e-12 clamp.  reconstruct() gets
the same denominator.

_noise_var is persisted through get/set_extra_state, read back with .get()
so checkpoints predating it still load and fall back to the old constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the scalar noise estimate in place, this example's homoscedastic noise
(sigma = 0.1 in every bin) no longer requires a whitener for the Wiener step
to be well-posed.  Keep it -- it demonstrates the per-feature case you need
as soon as the noise varies across bins -- but say so, since the previous
comment claimed it was required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cweniger

cweniger commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Pushed two more commits implementing the on-the-fly noise estimate — you were right that requiring a whitener was the wrong shape for this.

68ce127 — estimate σ² when no whitener is attached

The Wiener step only means something if λ and the denominator share units. A whitener guarantees that by normalizing the noise; without one the + 1 is arbitrary. Now, when a signal is available but a whitener is not, a single scalar noise variance is estimated from x - signal under a white-noise assumption (EMA, reusing the SVD blend momentum), and the gain becomes λ/(λ+σ²).

Precedence:

situation σ²
whitener attached 1 — noise already normalized per feature
no whitener, signal given running scalar estimate
neither 1 — nothing to estimate from

Only the middle row changes behaviour, and no config in the repo was in it.

Steps 2–3 of forward() are now one factor. Substituting λ → λ/σ² throughout and simplifying:

old:  Λ/(Λ+1)/√Λ
new:  √Λ/(Λ+σ²)

These are algebraically identical at σ²=1. Checked numerically across λ ∈ [1e-12, 1e6]: max relative difference 2.2e-16, i.e. float rounding only. The new form is also better conditioned — the old one divides by √Λ after clamping Λ at 1e-12, so by 1e-6. reconstruct() gets the same denominator.

_noise_var persists through get/set_extra_state, read back with .get() so pre-existing checkpoints still load and fall back to the old constant.

7258748 — example 05 comment

The previous comment claimed the whitener was required there. That is no longer true: 05's noise is homoscedastic (σ = 0.1 in every bin), so the scalar estimate covers it exactly and dropping the block would also be correct. I kept the whitener — it demonstrates the per-feature case you need once noise varies across bins — but the comment now says so rather than overstating it.

Consequence for the re-run

The validation ask is unchanged but now covers a bit more. Since 05 keeps its whitener it takes the σ²=1 path, so the new estimator is not exercised by that run. If you want the scalar path covered too, commenting out the whitener: block is a second cheap run against the same analytic posterior — it should land in the same place, and if it doesn't, the estimator is wrong.

"Shrinkage" was wrong on three counts:

  - It gates two operations and names one.  In forward() the flag controls
    the Wiener gain AND the 1/sqrt(lambda) rescaling, so turning it off does
    not give unshrunk-but-still-sane coefficients -- it gives raw eigenbasis
    projections whose scale spans orders of magnitude across components.
    That second effect is the one that matters to a downstream network.
  - The same flag meant different things in different methods: Wiener plus
    rescaling in forward(), Wiener alone in reconstruct() (correctly -- the
    rescaling would be wrong on the way back to data space).
  - In covariance estimation "shrinkage" means Ledoit-Wolf-style eigenvalue
    regularization, which a reader would look for in _svd_update, not in
    output post-processing.  The operation is a Wiener/MMSE filter.

Renaming is free: nothing outside this class ever set the flag -- no config,
no example, no other module -- so there is no compatibility surface and no
shim is needed.

Also corrects a docstring claim that was never true.  Step 3 was described
as "normalize to ~unit variance", but the output standard deviation is
sqrt(lambda/(lambda+sigma^2)) = sqrt(SNR/(SNR+1)).  Unit scale is only the
lambda >> sigma^2 asymptote; noise-dominated directions are deliberately
suppressed toward zero, which is the entire point of the step.

Considered splitting into separate wiener/normalize flags and rejected it:
wiener=False with normalize=True would divide by sqrt(lambda) with no noise
suppression, amplifying exactly the directions the filter exists to kill.
Better not to make that combination expressible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cweniger

cweniger commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Pushed 950503d: shrinkagewiener.

Renaming turned out to be free — nothing outside the class ever set the flag (no config, no example, no other module), so there is no compatibility surface and no shim is needed. grep -rn shrinkage over the repo now returns nothing.

Reasoning, for the record:

  • It gated two operations and named one. In forward() the flag controls the Wiener gain and the 1/√λ rescaling. shrinkage=False therefore doesn't give unshrunk-but-still-sane coefficients — it gives raw eigenbasis projections with scale √(λ+σ²), varying by orders of magnitude across components. That second effect is the one that matters to a downstream network, and the old name said nothing about it.
  • It meant different things in different methods. Wiener + rescaling in forward(), Wiener alone in reconstruct() — the latter correctly, since the rescaling would be wrong heading back to data space. Now documented explicitly.
  • "Shrinkage" is already taken in this neighbourhood: in covariance estimation it means Ledoit–Wolf-style eigenvalue regularization, which a reader would go looking for in _svd_update.

I did not split it into separate wiener / normalize flags. The combination wiener=False, normalize=True would divide by √λ with no noise suppression, amplifying exactly the directions the filter exists to kill — better not to make that expressible.

The commit also fixes a docstring claim that was never accurate. Step 3 was described as "normalize to ~unit variance", but the output standard deviation is √(λ/(λ+σ²)) = √(SNR/(SNR+1)). Unit scale is only the λ ≫ σ² asymptote; suppressing the rest toward zero is the point of the step, not a side effect.

No behaviour change in this commit — pure rename plus docs.

@cweniger
cweniger merged commit dd41311 into main Aug 8, 2026
4 of 6 checks passed
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