Make the SFNO local filter configurable and add its two-branch dhconv form - #1397
Make the SFNO local filter configurable and add its two-branch dhconv form#1397mcgibbon wants to merge 9 commits into
Conversation
`isotropic morlet` (radial Hann window times radial Fourier modes) exists in the filter basis but was not selectable from config, because `BasisType` did not list it. It is the only Morlet family that is genuinely isotropic: plain morlet keeps a Cartesian y-harmonic even with a single azimuthal bin, so it depends on the azimuthal angle. Consumers that require isotropic branches need to know which (basis_type, kernel_shape) pairs qualify, and for piecewise-linear bases it depends on the kernel shape rather than the family. Declare it per family as `FilterBasis.is_isotropic` so callers check a property instead of pattern-matching on strings, and add `kernel_shape_for_basis_count` for sizing a radial family to a target number of degrees of freedom. `_compute_cutoff_radius` was defined in sfnonet and imported by localnet and ankur; move it to `fme.core.disco` as `compute_cutoff_radius`, next to the basis vocabulary it is keyed to. `BasisType` moves there for the same reason and is re-exported from localnet for existing importers.
`FourierNeuralOperatorBlock` added its post-filter activation only for
`filter_type` in {linear, real linear}, so a block listed in `local_blocks`
silently ran with no nonlinearity between the filter and the second norm.
A local block therefore differed from a linear block by a missing activation
as well as by its filter, which makes the two uncomparable.
This changes the computation of any model using `local_blocks`. No training
config does, and the affected path has no released checkpoints.
… form The local (DISCO) filter hardcoded a 3x3 Morlet basis with a support radius from the mode-count heuristic, so its receptive field could not be set. Add `local_filter` on `SFNONetConfig` and `NoiseConditionedSFNOBuilder` exposing `kernel_shape`, `basis_type`, `theta_cutoff`, and `basis_norm_mode`, with `kernel_shape: lmax` and `theta_cutoff: global` as resolution-independent spellings of "one degree of freedom per total wavenumber" and "pi". The defaults reproduce the previous hardcoded filter exactly. Also add the two-branch form that makes the DISCO filter the analog of the dhconv spectral filter. Under the real-SHT storage convention a complex per-l weight acts as `a_l + i b_l sign(m)`: the real part is isotropic, the imaginary part is an axis-tied zonal phase shift that no isotropic kernel can express. By Funk-Hecke the filter therefore decomposes exactly as `K_a * f + H[K_b * f]` with two real radius-only kernels and one fixed, parameter-free `H` (multiplication by `-i sign(m)`), implemented as a per-latitude-row FFT phase flip. `two_branch` selects it; `theta_cutoff` then localizes the K branches while H stays zonally global, making locality and chirality independent knobs. `match_spectral_init` rescales the branch weights to `SpectralConvS2`'s output magnitude, which is a unit-gain initialization. DISCO's own `1 / sqrt(channels * kernel_size)` is ~50x weaker at `kernel_size = lmax`, which would otherwise confound a comparison of training dynamics with an initialization difference. Tests cover H's exact properties (quarter-cycle phase advance; annihilation of the zonal mean and Nyquist wavenumbers; anticommuting with longitude reflection where an isotropic branch commutes), that the radial basis spans arbitrary per-l profiles with a bounded condition number and solution norm, and that the fitted two-branch filter reproduces dhconv on resolved scales while an isotropic branch alone does not. The reproduction tolerance is loose by necessity: DISCO's quadrature is only approximately isotropic on a lat-lon grid, so its measured l-transfer drifts with zonal wavenumber (about 1% at m=1, growing toward the Nyquist). That ceiling belongs to the discretization, not to this decomposition or to the choice of radial family.
Sizing a radius-only DISCO basis to lmax spans every real per-l profile exactly, which makes it look like the right rule. It is not. Each basis function's l-transfer drifts with zonal wavenumber, because the quadrature is only approximately rotation-equivariant on a lat-lon grid, and cancelling that drift costs degrees of freedom beyond the ones spent spanning the profile. Measured against a random dhconv operator by least squares over the whole (l, m) plane, the best achievable relative error is 18% at lmax, 4.0% at 2 lmax, 1.7% at 3 lmax and 0.9% at 4 lmax, with the fitted coefficient norm falling as the basis grows -- a better-conditioned fit, not a finer cancellation. The figures are the same at 16x32 and at 45x90, so the rule is resolution-independent. kernel_shape therefore accepts "<n>lmax" as well as "lmax". The parity tests fit over the full (l, m) plane rather than to the l-transfer at m = 0; the m = 0 fit spends every degree of freedom on one m slice and lands a factor of ~5 worse, which is what previously made the ceiling look irreducible.
test_latest_module_backwards_compatibility fails on any new key in the builder's config until the cached copy is regenerated. The cached builder sets local_blocks: null, so it exercises no local filter and its state dict is unchanged.
All three cached step configurations (sm_with_atmos_corr, sm_noise_conditioned, multi_call) set local_blocks: [0], so this branch changes them twice: the local filter's weight moves from blocks.0.filter.filter.conv.weight to blocks.0.filter.filter.branches.0.weight, and the block now applies its post-filter activation, which changes the outputs. Generated with a single-rank `pytest -m parallel` run and verified under torchrun with FME_DISTRIBUTED_BACKEND=model at H=2,W=1 and H=1,W=2.
- resolved_theta_cutoff: order the branches so mypy narrows the union, dropping a type: ignore. - zonal_quarter_cycle_shift: the precision comment claimed the filter runs with autocast disabled; only the spectral path does that, so state the cast on its own terms. - Reuse the one Distributed instance in LocalFilter.__init__. - test_isotropic_branch_commutes_with_longitude_reflection: the symmetry is exact to round-off (2e-7), not approximate, so a 1e-2 bound would pass with the branch substantially anisotropic. Tighten to 1e-5. - test_match_spectral_init...: the unscaled filter is 2.2x weaker at this grid, so a < 0.5 * ratio bound sat 10% from failing on a scale it does not measure. Loosen to 0.7 and report both numbers on failure. - The oversampling test runs in 2s, so mark it medium_duration rather than slow: it guards a design choice against being simplified away and should run in the --fast loop. - LocalNetConfig: note that the radius-only families ignore kernel_shape[1], newly reachable now that isotropic morlet is config-selectable.
|
Claude (pre-review agent): independent pre-review, read cold against Audit passes
DescriptionUpdated: the state-dict key rename is now stated as a checkpoint break on the Open question
Does it do what it says, and is it reviewable?Yes. The claim I would have most expected to be overstated — that the two-branch form is the exact continuum decomposition of dhconv — holds under direct measurement, including the two edge wavenumbers, and the init algebra is right for a reason (weight independence) that is easy to get wrong. The DOF-counting-is-not-enough findings are the substance and they are carried by tests rather than only by prose. Review load is concentrated in one new module plus its tests; the Ready for review, after the fixes above. Still marked draft. |
The guard is what stops a spatially-parallel run from computing the zonal phase shift per tile and silently returning a wrong filter, so it needs a test. Branches on whether spatial ranks exist so it is meaningful across the parallel CI matrix.
4213e7a to
2be8361
Compare
bool is an int subclass, so `theta_cutoff: true` passed the (0, pi] range check and configured a 1 radian support radius rather than failing.
|
Claude (pre-review agent): one more fix after the comment above, plus the verification record.
Also loaded the new config through the registry from YAML end to end, which was otherwise only exercised by direct dataclass construction: Local verification of the final branch: |
The local (DISCO) filter used by
local_blockshardcoded a 3x3 Morlet basis and derived its support radius from a mode-count heuristic, so its receptive field could not be set at all. This makes those choices configurable and adds the two-branch form that turns the DISCO filter into the analog of the dhconv spectral filter, so the receptive field becomes a continuous knob where the spectral filter has none.Under the real-SHT storage convention (only
m >= 0stored,m < 0implied by conjugate symmetry),SpectralConvS2's complex per-lweightw_l = a_l + i b_lacts over the fullmrange asa_l + i b_l sign(m). The real part is genuinely isotropic; the imaginary part is an axis-tied zonal phase shift that no isotropic kernel can express, since any real span of radius-only kernels is mirror-symmetric. By Funk-Hecke, convolving with a real radius-only kernel scalesf_lmby a real number identically inm, so the filter decomposes exactly aswith two learned real radius-only kernels and one fixed, parameter-free
H(multiplication by-i sign(m); the sign of the profileK_blearns is absorbed into it).Htouches only longitude structure, so it is a per-latitude-row FFT phase flip and needs no spherical harmonic transform. BecauseHstays zonally global whiletheta_cutoffshrinks theKbranches, locality and chirality become independent knobs.Defaults reproduce the previous hardcoded filter bit-for-bit, down to the order in which it draws its weights, so an existing
local_blocksconfig builds the same numbers apart from the activation fix below. Its weight moves fromblocks.<i>.filter.filter.conv.weighttoblocks.<i>.filter.filter.branches.0.weight, so an existinglocal_blockscheckpoint no longer loads; no released checkpoint uses that path.Changes:
fme.core.models.conditional_sfno.local_filter: new module holdingLocalFilterConfig(kernel_shape,basis_type,theta_cutoff,basis_norm_mode,two_branch,match_spectral_init), theLocalFiltermodule, andzonal_quarter_cycle_shift(theHoperator).kernel_shape: lmaxandtheta_cutoff: globalare resolution-independent spellings of "one degree of freedom per total wavenumber" and "pi";kernel_shape: 2lmaxand friends ask for a multiple of that count, which the second bullet below shows is what a global filter actually needs.match_spectral_initrescales the branch weights toSpectralConvS2's output magnitude.SpectralConvS2's1/sqrt(channels)init is unit-gain (measured output RMS 1.07 for unit-RMS input); DISCO's1/sqrt(channels * kernel_size)is ~50x weaker atkernel_size = lmax, which would otherwise confound a comparison of training dynamics with an initialization difference. The scale is computed analytically from the precomputed convolution tensor, so it is deterministic.fme.core.disco: exposeisotropic morletinBasisType(it existed but was not config-selectable, and is now reachable fromLocalNetConfigtoo); declare isotropy per family asFilterBasis.is_isotropic, since for piecewise-linear bases it depends onkernel_shaperather than the family; addkernel_shape_for_basis_count; move_compute_cutoff_radiushere ascompute_cutoff_radius, next to the basis vocabulary it is keyed to, andBasisTypewith it.fme.core.models.conditional_sfno.sfnonet.FourierNeuralOperatorBlock: local blocks now get the same post-filter activation as linear blocks. They previously got none, so a local block differed from a linear block by a missing nonlinearity as well as by its filter. This changes the computation of any model usinglocal_blocks; no training config does, and the path has no released checkpoints.SFNONetConfig.local_filterandNoiseConditionedSFNOBuilder.local_filterplumb the config through.local_blocks: the three spatial-parallel step regression baselines (sm_with_atmos_corr,sm_noise_conditioned,multi_call) are regenerated for the renamed weight and the new activation, andNoiseConditionedSFNO_module_config.yamlrecords the newlocal_filterkey.two_branchrequires branches that are purely radial at the givenkernel_shapeand rejects spatial model parallelism, sinceHis an FFT over the full longitude circle.match_spectral_initlikewise needs the fork's precomputed convolution tensor, which the model-parallel torch-harmonics path does not expose.Two properties of the radial basis worth flagging for review. Both are measured by least-squares fitting the two-branch filter to a random dhconv operator over the whole
(l, m)plane, at 4 degrees (45x90,lmax = 45) and reproduced at the 16x32 test grid:isotropic morletatkernel_shape: lmaxhas anl-transfer condition number of 2e11 with only 25 of 45 usable directions, so it cannot realize arbitrary per-lprofiles in practice despite nominally spanning them. Piecewise-linear radial bumps at(2 * lmax - 1, 1)give the same 45 degrees of freedom at condition number 81 with exact profile representability.lmaxradial modes span every per-lprofile but do not behave isotropically. Each basis function'sl-transfer drifts withm(the quadrature is only approximately rotation-equivariant on a lat-lon grid), and cancelling that drift costs degrees of freedom beyond the ones spent spanning the profile. Best achievable relative error: 18% atlmax, 4.0% at2lmax, 1.7% at3lmax, 0.9% at4lmax, with the residual concentrated at highland the fitted coefficient norm falling as the basis grows -- so oversampling is a better-conditioned fit, not a finer cancellation. Cost is linear in the count, since the DISCO contraction saves a(batch, channels, kernel_size, nlat, nlon)activation.The fits are done over the full
(l, m)plane rather than against thel-transfer measured atm = 0. Fitting atm = 0looks natural, since the target ism-independent, but it spends every degree of freedom on onemslice and lands about 5x worse; an earlier revision of this branch did that and concluded the ~30% ceiling was a property of the discretization.