feat: LaplaceOptimiser(projection="moments") — moment-matching projection for the hierarchical scatter (#1654) - #1656
Merged
Merged
Conversation
autofit/graphical/laplace/moments.py: outer Gauss-Legendre over the scale / bounded-support variables in base coordinates, windowed to the support and the cavity (re-windowed while under-resolved, <= 4 passes); inner conditional Laplace at each node (warm-started quasi-Newton, Newton polish on value central differences); log w = log w_GL + log|J| + l(m,s) + d/2 log 2pi - 1/2 log det(-H); moments via an order-3 Gauss-Hermite expansion of each conditional. Deterministic, numpy only. Tests: known two-variable moments vs scipy quad (1e-6), Gaussian mode/cov, narrow re-window, RNG / Variable-id determinism, mode fallbacks, BAD_PROJECTION on an empty window, hierarchical toy vs the exact scatter posterior, near-zero scatter mode-vs-moments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up1fQmjtGAh4RhLrWHfcfg
New kwargs projection ("mode" default | "moments"), n_quadrature=64,
quadrature_half_width=8.0, moment_max_size=4, moment_max_outer=2;
optimise_approx tries the moment projection first and falls back to the
unchanged mode path when it does not apply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Up1fQmjtGAh4RhLrWHfcfg
…iables (#1654) from_weighted_nodes projects weighted nodes through each message's own project (log_norm 0 per message, projection log_norm = log Z). scale_variables maps _SCALE_ARGUMENT_NAMES (kept in the import-free diagnostics module, now shared) to the parent distribution's priors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up1fQmjtGAh4RhLrWHfcfg
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up1fQmjtGAh4RhLrWHfcfg
3 of 5 tasks
Collaborator
Author
|
Workspace PR: PyAutoLabs/autofit_workspace_test#105 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR addresses the Laplace-on-scatter caveat (
autofit/graphical/README.md§3.5). EP'sLaplaceOptimiserprojects every factor's tilted distribution onto a Gaussian at its mode and curvature. For a hierarchical factor, the tilted density of the scatter σ often has no interior mode: it piles up against σ = 0, or is strongly skewed. The mode path then returnsBAD_PROJECTIONor a biased σ, and the autofit-EP column ofanalytic_gaussian/analytic_gaussian_priorsmisses the closed form. Those scripts are parked inno_run.yamlfor this reason.This PR adds an opt-in moment-matching projection,
LaplaceOptimiser(projection="moments"). It is deterministic nested quadrature, ported from theanalytic_ep_minimalreferee in autofit_workspace_test:mean ± quadrature_half_width · std. Up to 4 re-windowing passes run until the tilted density is resolved.The default
projection="mode"is unchanged. Any factor the moments path does not apply to falls back to the mode path bit-for-bit.Design departure from the prompt. The prompt proposed Gauss–Hermite quadrature centred on the Hessian (mode) approximation. That centring is exactly what breaks here: when the tilted σ density has no interior mode, a Hessian-centred rule is placed at the wrong location and scale. A support-aware outer Gauss–Legendre rule with re-windowing does not depend on a mode existing, and it is the scheme the referee already validates.
API Changes
af.LaplaceOptimiser(..., projection="mode" | "moments", n_quadrature=64, quadrature_half_width=8.0, moment_max_size=4, moment_max_outer=2): new keyword arguments. The default"mode"keeps today's behaviour exactly. Any otherprojectionstring raisesValueError.MeanField.from_weighted_nodes(nodes, log_weights, log_norm=0.0): a moment-matching projection of a weighted node set onto the mean field's message families._HierarchicalFactor.scale_variables(property): the priors that parameterise the parent distribution's scale.autofit.graphical.laplace.moments(numpy-only).moments: n_nodes=…, passes=…, f_count=…, g_count=….LaplaceOptimiser.See full details below.
Test Plan
test_autofit/graphical/functionality/test_moment_projection.py, 13 tests. The initial 9 failed red (TypeError: unexpected kwarg 'projection') before the change.N(1,0.5) and sTN(0.8,0.6;0,100), againstscipy.integrate.quad: dE[x] 3.3e-9, dVar[x] 9.0e-10, dE[s] 7.7e-13, dVar[s] 2.9e-13, dlogZ 1.5e-13 (asserted at 1e-6).moment_max_size=1ormoment_max_outer=0gives the same bits and messages as"mode"and logs why.projection="median"raisesValueError.BAD_PROJECTION,updated=False, and the model dist is returned unchanged.SUCCESS, and the scatter parent mean is 9.297 against exact 9.654±3.468. On the tight toy (50,50.5,49.5) the mode path has 0SUCCESS(allBAD_PROJECTION) while moments gives 3/3SUCCESS.scale_variables == {hf.sigma}.origin/main:python -m pytest test_autofit -x, 2947 passed, 2 skipped. Existing Laplace tests are unchanged, and so is the mode-path code.analytic_ep_minimal.ep_leg_b, moments, theta=sigma): toy (45,52,58) parent 9.2880±3.6524, identical to this PR at 4 dp.analytic_autofit.pyin a scratch copy of autofit_workspace_test withLaplaceOptimiser(projection="moments"), 3 runs of 92–108 s:The single moments miss is the "autofit graphical" DynestyStatic joint-fit sigma row (a 0.120–0.129 vs tol 0.10). That sampler is unseeded, the row passed in 1 of 3 runs, and it misses the same way under the mode path. It is not an EP column.
analytic_autofit.pygets theprojectionkwarg and labels,analytic_gaussian/analytic_gaussian_priorscome out ofno_run.yaml, andsmoke_tests.txtis curated.Full API Changes (for automation & release notes)
Added
autofit.graphical.laplace.optimiser.LaplaceOptimiser.__init__(..., projection: str = "mode", n_quadrature: int = 64, quadrature_half_width: float = 8.0, moment_max_size: int = 4, moment_max_outer: int = 2)autofit.graphical.laplace.optimiser.LaplaceOptimiser._moment_projection(factor_approx, mean_field, params=None, **kwargs) -> Optional[Tuple[MeanField, Status]](private). ReturnsNonewhen the mode path should run.autofit.graphical.laplace.moments(new module). Public helpers aremoment_projection(optimiser, factor_approx, mean_field, params=None, **kwargs),MomentProjection,split_variables,fallback_reasonandgauss_legendre(n, lo, hi). Module constants areSCALE_SUPPORT = (0, inf),MAX_PASSES = 4,NARROW_FRACTION = 0.25,EDGE_MASS = 1e-6,WEIGHTED_NODE_MASS = 1e-10,NEWTON_DECREMENT_TOL = 1e-12andMAX_NEWTON_POLISH = 5.autofit.graphical.mean_field.MeanField.from_weighted_nodes(nodes: Dict[Variable, np.ndarray], log_weights: np.ndarray, log_norm: float = 0.0) -> MeanFieldautofit.graphical.declarative.factor.hierarchical._HierarchicalFactor.scale_variables -> frozenset(property)moments: n_nodes=…, passes=…, f_count=…, g_count=…Changed
LaplaceOptimiser.__init__accepts the five kwargs above (defaults preserve behaviour) and validatesprojection.LaplaceOptimiser.optimisetries the moments path first whenprojection="moments", then falls back to the unchanged mode path.autofit/graphical/expectation_propagation/diagnostics.py: a comment marks_SCALE_ARGUMENT_NAMESas shared with_HierarchicalFactor.scale_variables.autofit/graphical/README.md§3.2, §3.3, §3.5 document the moments path and the caveat it cures.Migration
af.LaplaceOptimiser(projection="moments").Deviations from the approved plan
newton.finite_difference_hessianon gradients. At σ≈0.017 the forward-difference gradient carried a 0.065 error: catastrophic cancellation of η₁x, η₂x² and A(η), which are around 1e6–1e7. That error made the Hessian indefinite (min eig -0.47) and every hierarchical update ended BAD_PROJECTION or FAILURE. The value differences are exact to about 4e-9. Quasi-Newton still runs first, warm-started._SCALE_ARGUMENT_NAMESstays inexpectation_propagation/diagnostics.py, which imports nothing from autofit so no cycle arises, rather than moving.(0, inf), applied by argument name rather than read per distribution class.moments.pyis numpy-only, with its own logsumexp and Cholesky solve. A module-levelscipy.specialimport broketest_lazy_transformed_support.Generated by the PyAutoLabs agent workflow.
Heart RED override (development only)
--json, ts 2026-09-30T09:51:49Z, re-read ~11:45 BST unchanged): "PyAutoArray: 2 commit(s) behind origin"; "PyAutoLens: 2 commit(s) behind origin"; "release validation FAILED (stage integrate)". Yellow: "workspace validation not passing (0 failed, 1 timeout, cloud#36404726969: autolens_test scripts/multi_dataset/rectangular.py)"; "manifest drift: public front-door organ tables (generated) — 1 mismatch(es) vs PyAutoMind/repos.yaml".origin/mainclean;pytest test_autofit -x2947 passed, 2 skipped; the new tests were confirmed red first; witness 40/41 with the one miss outside EP./prm) and green required checks.🤖 Generated with Claude Code
https://claude.ai/code/session_01Up1fQmjtGAh4RhLrWHfcfg