Add per-sample spectral whitening option to the energy-score loss#1303
Add per-sample spectral whitening option to the energy-score loss#1303mcgibbon wants to merge 7 commits into
Conversation
… loss EnergyScoreLoss gains a spectral_whitening='per_sample' mode that reweights the per-(l,m) energy score by the inverse per-degree RMS amplitude of each target sample (computed over valid orders m<=l, detached, magnitude-preserving so energy_score_weight keeps its meaning). This flattens each target sample's spectrum so high-l (small-scale) modes are no longer starved by the red spectrum, raising their gradient SNR. Default 'none' is a no-op (backward compatible). Threaded through EnsembleLoss via energy_score_whitening / energy_score_whitening_eps_frac kwargs. Tests cover the no-op, white-target invariance, small-scale boost, magnitude preservation, and config wiring.
EnergyScoreLoss.spectral_whitening='per_sample' gains a whitening_exponent gamma in (0, 1]: the per-degree reweight becomes (1/amp_l)**gamma, interpolating between no reweighting (gamma->0) and full flattening (gamma=1, the prior behavior and default). Partial whitening (gamma<1) keeps most of the small-scale spectral win while taming the upweighting of noise-dominated low-amplitude degrees, which destabilizes residual-prediction rollouts under full whitening. Magnitude preservation and the white-target no-op hold for any gamma. Threaded through EnsembleLoss as energy_score_whitening_exponent; requires spectral_whitening='per_sample'. Tests cover range validation, the per_sample requirement, and partial-whitening interpolation + magnitude preservation. Also fixes a pre-existing mypy failure on the invalid-whitening test.
|
Full analysis and figures: whitening-γ-selection report
|
| γ | seed 0 | seed 1 |
|---|---|---|
| 0 (baseline) | wandb oshj5u79 |
beaker 01KWW0J0KWPGDXT2X5PNXY2SBG / wandb eyfz92l8 |
| 0.2 | beaker 01KWW0EPJZKKE25P6ZSWHEPS7S / wandb bwbg9r2k | beaker 01KWW0FBKYYNA7R9ET1Z6T1JF4 / wandb jjprdcdt |
| 0.5 (selected) | beaker 01KWHS1DHTR858DC51720J1Q5C / wandb erug2fqs | beaker 01KWW0HBG0509A1C6Q86YQA803 / wandb 4dltgowi |
| 0.8 | beaker 01KWW0G0SN0383BM8FSD3SR940 / wandb 8vgzlp54 | beaker 01KWW0GNXKVWRBKGY3DMAY12VA / wandb xen5ofv9 |
Caveat carried by the verdict: the γ arms stop at epoch 120 while the baseline (extended to 259) drifts past ~ep 150, so γ=0.5 is verified clean through 120 but not yet proven not to merely delay the same intrinsic residual-recipe drift; a decisive hardening run (extend the two γ=0.5 seeds past ep 150) is tracked separately.
Note: this commit also fixes a pre-existing pre-commit (mypy) CI failure — the test_energy_score_invalid_whitening_raises test passes a deliberately-invalid literal, now marked # type: ignore[arg-type].
Address review: group the spectral-whitening options into a config dataclass and trim the verbose factor docstring. - New `SpectralWhitening` operator (holds eps_frac + exponent, computes the per-degree reweight factor) and `SpectralWhiteningConfig` dataclass (kind / eps_frac / exponent, validated in __post_init__; `kind='none'` default -> build() returns None = disabled). Mirrors the global_mean_removal config->operator pattern. - `EnergyScoreLoss` now takes a single `whitening: SpectralWhiteningConfig` and holds the built operator (or None); the floats and the Literal are private to the whitening operation. `EnsembleLoss` takes one `energy_score_whitening` config instead of three loose kwargs. - `LossConfig.build` normalizes a YAML dict into the config (kwargs is opaque `Mapping[str, Any]`, so dacite does not descend into it). - Trimmed the factor docstring; the mechanics stay as inline comments. - Tests updated to the config/operator API; ensemble test parametrized over the dict (YAML) and dataclass construction paths.
| ): | ||
| super().__init__() | ||
| self.sht = sht | ||
| self._whitening = (whitening or SpectralWhiteningConfig()).build() |
There was a problem hiding this comment.
Isn't this always non-None? But you're checking for None below? Choose one.
There was a problem hiding this comment.
Claude: Good catch on the readability — the (whitening or SpectralWhiteningConfig()) config is always non-None, but .build() returns SpectralWhitening | None (None when kind='none'), so self._whitening genuinely can be None and the check in forward is deliberate: it skips the whole reweight when whitening is disabled (rather than building a no-op operator that still runs the SHT/factor math every step). I kept the None path and made the intent explicit — annotated self._whitening: SpectralWhitening | None with a comment saying build() returns None when disabled (801a719).
…cit (review) - Add a per-argument Args block to SpectralWhiteningConfig documenting kind, eps_frac, and exponent. - Annotate self._whitening as SpectralWhitening | None with a comment: build() returns None when whitening is disabled and forward() skips the reweight, so the None check downstream is intentional.
…aner factor Self-review follow-ups on the spectral-whitening feature: - Builder pattern: LossConfig.build now calls SpectralWhiteningConfig.build() and passes the built SpectralWhitening (or None) down; EnergyScoreLoss and EnsembleLoss take the operator instead of the config, so the nn.Modules no longer build their own sub-config. - Default whitening strength is now the validated stable knee gamma=0.5 (was full whitening gamma=1, which the gamma sweep found destabilizes residual rollouts). eps_frac/exponent are None-sentinel defaults resolved only for kind='per_sample'; setting either with kind='none' now raises symmetrically (previously only exponent did). - SpectralWhitening.factor drops the target's singleton ensemble dim up front (y_hat[:, 0]) instead of a trailing conditional squeeze, and computes the magnitude tensor once (was twice). - Tests: cover the eps_frac-with-none guard and the resolved per_sample defaults; update the disabled-matches-default test to the operator signature.
|
Claude: Applied self-review follow-ups (pushed as
New tests cover the |
|
Claude: Empirical motivation and validation, moved out of the description so it reads as a clean commit message (detail kept here): Motivation — gradient probe. On a trained checkpoint, a per- Validation. On 4°/daily residual runs, base whitening delivered a large small-scale spectral-fidelity gain at neutral CRPS. A two-seed γ ∈ {0, 0.2, 0.5, 0.8} sweep selected γ=0.5 as the stable knee — the default this PR ships for |
Small-scale (high-
l) spectral power converges very slowly in SFNO training: the energy score lives in a red spectrum, so high-lmodes carry tiny amplitude, contribute little to the score, and theirdhconvfilter weights see proportionally small, low-SNR gradients that the energy score's uniform per-(l,m)mode_weightsdo not correct.This PR adds an opt-in per-sample spectral whitening mode to
EnergyScoreLoss. It reweights the per-(l,m)energy score by a power of the inverse per-degree RMS amplitude of each target sample, flattening the target's angular power spectrum so high-lmodes are no longer starved and their gradient SNR rises. The reweight is computed from the detached target coefficients (no new gradient path) and is magnitude-preserving (rescaled per(sample, channel), so the overall energy-score magnitude — and the meaning ofenergy_score_weight— is unchanged; only the per-scale balance shifts). A white-spectrum target yields a uniform factor (no-op), and the default (whitening off) is bit-for-bit backward compatible.Whitening strength is the exponent γ ∈ (0, 1] (per-degree factor
(1/amp_l)**γ), interpolating between no reweighting (γ→0) and full flattening (γ=1). Enabling whitening (kind='per_sample') defaults to γ=0.5, the validated stable knee; full flattening (γ=1) over-upweights noise-dominated low-amplitude degrees and destabilizes residual rollouts, so it is opt-in. Options are grouped in aSpectralWhiteningConfig(kind,eps_frac,exponent);kind='none'(default) disables whitening, andeps_frac/exponentleft unset resolve to0.02/0.5when enabled (setting either withkind='none'raises). In a loss config it is a nested block:Changes:
fme.core.loss.SpectralWhitening/SpectralWhiteningConfig— new operator and config for per-sample whitening. The config (validated in__post_init__)build()s the operator, orNonewhenkind='none'; the operator owns the reweight (per-(sample, channel, l), detached, floored ateps_fracof the per-sample mean degree amplitude, raised toexponent, magnitude-preserving).fme.core.loss.EnergyScoreLoss/EnsembleLoss— take the builtwhitening: SpectralWhitening | Noneoperator (not the config) and apply its factor aftermode_weights.fme.core.loss.LossConfig.build— constructsSpectralWhiteningConfigfrom the opaque losskwargsdict, builds the operator, and passes it down.fme.core.test_loss— no-op default, white-target invariance, small-scale boost, magnitude preservation, exponent range, symmetricper_sample-required validation of both knobs, resolvedper_sampledefaults, partial-whitening interpolation, and config wiring via dict and dataclass.Tests added
If dependencies changed, "deps only" image rebuilt and "latest_deps_only_image.txt" file updated