DynamicSVD: fit_on_signal + >2D inputs, and actually use the mu scaffold in example 05 - #119
Conversation
…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 Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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>
|
Pushed two more commits implementing the on-the-fly noise estimate — you were right that requiring a whitener was the wrong shape for this.
|
| 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>
|
Pushed 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. Reasoning, for the record:
I did not split it into separate 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 No behaviour change in this commit — pure rename plus docs. |
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_regressiondeclaresscaffolds: [mu]and passes_input_: [x, mu]toDynamicSVD. Its forward model is deliberately split intoSignalSimulator→NoiseSimulatorfor the sole purpose of exposing the noiselessmu.DynamicSVDthen throws it away. Onmain,signalis touched in exactly one place:No whitener is attached in that config, so
muis plumbed through the entire graph and silently discarded. The example pays for a scaffold node it never uses.What changes
fit_on_signalfits the eigenbasis on the cleansignalstream while projections inforward()stay onx. Two reasons that matters:xlets noise eigenvalues — spread up to the Marchenko–Pastur edge(1+√(D/N))²σ²— masquerade as structure and displace real components from the top-k.xgivesλ ≈ λ_s + 1and 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λ = λ_sand the textbookλ_s/(λ_s+1).Net effect on output scale, in whitened units:
>2Dinputs are flattened to(batch_size, D)internally, so image-shaped data can be fed in directly.Commits
fit_on_signalflag — cherry-pick of21cee8a. DefaultFalse, and with the flag off the expression reduces to the previous line exactly, so it is a no-op for existing callers.79b8b53, coveringupdate()andforward().reconstruct()too — the one entry point commit 2 missed, which would still reject the inputs the other two now accept.signalas 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+ 1sits in raw data units and the threshold is arbitrary. No behaviour change.Example 05: the whitener is not optional here
fit_on_signalalone 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:The whitener trains on
noise = x - mu, which is zero-mean withσ = 0.1, so it drivesstd → σand the eigenvalues become genuine signal-to-noise ratios.The nested
_target_resolves correctly —builder.py:336runs every embedding kwarg through_instantiate_sub_targets, whose docstring calls out this exact case (a raw dict would makeDynamicSVDcalldict.update()instead of the whitener's.update()).Sigma_post/mu_postbefore merging.Default left at False
fit_on_signaldefaults toFalseand 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 evenmain's existingsignalhandling was dead code. Commit 5 makes the first and third live. The>2Dflatten path still has no in-repo caller.Testing
Not run —
import torchfails in my environment (libcudnn.so.9: cannot open shared object file), which breakspytest testscollection onmaintoo. Verification here is static review,py_compile, YAML parse of the edited config, and a check thatdim: 1000matches then_bins: 1000on themusimulator.The re-run of example 05 is the real test and I cannot do it.
🤖 Generated with Claude Code