From bcaf6f77ef1b3fffbeb11a2bb19c7321d0c87029 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 15:24:07 +0100 Subject: [PATCH] Report zero-iteration Laplace convergence as an update, not a skip (#1711) optimise_quasi_newton met its stop condition at the start point (iter=0) and returned updated=False, the same status as a failed line search, so a factor already at its mode looked like a stale one to anything reading the Newton status. The projection of that point is accepted and its message updated, and EP's own stale tracking already read the post-projection status, so only the Newton status and the NO_CHANGE docstrings change; no update maths, projection or Laplace numerics are touched. Evidence: analytic_gaussian wave-1 leg A seed 140 (the mu prior; posterior mean within 2e-3 of the prior mean; closed-form match a=5e-5, b=2e-5). The BAD_PROJECTION-only HierarchicalFactor shape on leg B is the #1405 scatter state cured by projection="moments" (#1656), verified on seeds 29 and 71. Adds two tests in test_factor_failure_recovery.py. Co-Authored-By: Claude Fable 5.1 --- .../expectation_propagation/optimiser.py | 12 +++ autofit/graphical/laplace/newton.py | 9 ++ autofit/graphical/utils.py | 10 +++ .../test_factor_failure_recovery.py | 82 +++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/autofit/graphical/expectation_propagation/optimiser.py b/autofit/graphical/expectation_propagation/optimiser.py index af6e5a1c8..2294e7d31 100644 --- a/autofit/graphical/expectation_propagation/optimiser.py +++ b/autofit/graphical/expectation_propagation/optimiser.py @@ -438,6 +438,9 @@ def _check_consecutive_failures( return True else: if status.updated: + # Includes a `NO_CHANGE` update — an optimiser that converged + # at its start point — whose projection was accepted: that is + # a converged update, not a skip (PyAutoFit#1711). self._factors_updated.add(factor) else: # A skipped update (failed line search, bad projection): the @@ -468,6 +471,15 @@ def _stale_factor_warnings(self) -> List[str]: and *never once* updated. A factor that failed intermittently but landed at least one update has a real message and is not reported. + "Updated" is not "flagged SUCCESS". A factor whose optimiser meets its + stop condition at the start point is flagged `NO_CHANGE` in + `ep_history.csv`, but that is a converged update — the start point is + the mode of its tilted density, and the projection is accepted like any + other — so a factor whose every update is `NO_CHANGE` is not stale and + is not named here (PyAutoFit#1711). On a graph where a factor's cavity + already puts the mode where the factor wants it (a prior whose mean the + posterior happens to share), that is the expected outcome. + The factor-level test cannot see a *partial* revert. On a hierarchical graph every projection may be `BAD_PROJECTION` with one variable — the parent scatter — reverted in each one, while the factor's mean and its diff --git a/autofit/graphical/laplace/newton.py b/autofit/graphical/laplace/newton.py index 8b40996b5..8ac47efd0 100644 --- a/autofit/graphical/laplace/newton.py +++ b/autofit/graphical/laplace/newton.py @@ -369,6 +369,15 @@ def optimise_quasi_newton( if callback: callback(state, old_state) + if success and i == 0: + # A stop condition met before the first step (e.g. the gradient + # tolerance at the start point) is convergence, not a skip: the start + # point is already the mode of the tilted density, and its projection + # is as valid as one reached after a step. Report it as an update; the + # flag stays NO_CHANGE so `ep_history.csv` still records that the mode + # did not move (PyAutoFit#1711). + updated = True + message += f", iter={i}" messages += (message,) status = Status( diff --git a/autofit/graphical/utils.py b/autofit/graphical/utils.py index 8db3ea3ad..bbae6dfe7 100644 --- a/autofit/graphical/utils.py +++ b/autofit/graphical/utils.py @@ -257,6 +257,16 @@ def to_variabledata(variables, raw_values) -> VariableData: class StatusFlag(Enum): FAILURE = 0 SUCCESS = 1 + # The factor's optimiser converged without taking a step: a stop condition + # (typically the gradient tolerance) already held at the start point, so + # the start point is the mode of the tilted density. This is a converged, + # successful update (`Status.updated` is true and EP projects it like any + # other), not a skip — it is common once EP has settled, and on the first + # visit whenever a factor's cavity already puts the mode where the factor + # wants it (e.g. a prior whose mean the posterior happens to share). The + # projected message can still move, because the curvature is recomputed at + # the mode. Only an optimiser that did not succeed (FAILURE, EXCEPTION) or + # a projection that was rejected (BAD_PROJECTION) leaves a message stale. NO_CHANGE = 2 BAD_PROJECTION = 3 # The factor's optimiser *raised* rather than returning a failed status. diff --git a/test_autofit/graphical/functionality/test_factor_failure_recovery.py b/test_autofit/graphical/functionality/test_factor_failure_recovery.py index 58f758051..1c90b5cf2 100644 --- a/test_autofit/graphical/functionality/test_factor_failure_recovery.py +++ b/test_autofit/graphical/functionality/test_factor_failure_recovery.py @@ -25,6 +25,7 @@ ExactFactorFit, ) from autofit.graphical.expectation_propagation.history import EPHistory +from autofit.graphical.laplace import LaplaceOptimiser from autofit.graphical.utils import StatusFlag from autofit.mapper.variable import Variable from autofit.messages.normal import NormalMessage @@ -593,6 +594,87 @@ def run(approx, name, steps): assert all(row["reverted_variables"] == "" for row in rows) +def make_at_mode_approx(): + """ + A prior and a likelihood on one variable `x`, both centred on 5, started + from a mean field also centred on 5: every tilted density this graph forms + has its mode at the start point, so the prior factor's Laplace optimiser + meets its gradient tolerance before taking a step (PyAutoFit#1711; the + analytic_gaussian leg-A seed-140 `PriorFactor5 {NO_CHANGE: 2}` shape). + """ + x = Variable("x") + prior = NormalMessage(5.0, 2.0).as_factor(x, name="prior_x") + likelihood = NormalMessage(5.0, 1.0).as_factor(x, name="like_x") + factor_graph = graph.FactorGraph([prior, likelihood]) + model_approx = graph.EPMeanField.from_approx_dists( + factor_graph, {x: NormalMessage(5.0, 10.0)} + ) + return model_approx, factor_graph, prior, likelihood, x + + +def test_zero_iteration_convergence_is_reported_as_an_update(): + """ + A Laplace optimisation whose start point already satisfies a stop condition + has converged: it is a successful update with nothing left to move, not a + skip. The flag stays `NO_CHANGE` (the mode did not move) but `updated` must + be true, so nothing downstream can read it as a failed line search. + """ + model_approx, _, prior, _, x = make_at_mode_approx() + + new_model_dist, status = LaplaceOptimiser().optimise( + model_approx.factor_approximation(prior) + ) + + assert status.success + assert status.flag is StatusFlag.NO_CHANGE + assert status.messages[-1].endswith("iter=0") + assert status.updated, "a zero-iteration convergence was reported as a skip" + # the mode did not move, but the projection is the tilted density's, not + # the start point's: the curvature carries the prior's precision + assert new_model_dist[x].mean == pytest.approx(5.0) + assert new_model_dist[x].sigma == pytest.approx( + (1 / 2.0**2 + 1 / 10.0**2) ** -0.5, rel=1e-4 + ) + + +def test_factor_at_its_mode_on_first_visit_is_not_stale(tmp_path, caplog): + """ + EP over a factor that is at its mode on every visit: its history rows keep + the `NO_CHANGE` flag (so `ep_history.csv` still shows the zero-iteration + convergence) but count as updates, the STALE FACTORS warning is not raised + for it, and the posterior is the closed form, width included. + """ + model_approx, factor_graph, prior, likelihood, x = make_at_mode_approx() + + optimiser = graph.EPOptimiser( + factor_graph, + factor_optimisers={prior: LaplaceOptimiser(), likelihood: ExactFactorFit()}, + ep_history=EPHistory(kl_tol=None), + paths=DirectoryPaths(name="at_mode_first_visit", path_prefix=str(tmp_path)), + ) + with caplog.at_level(logging.WARNING): + result = optimiser.run(model_approx, max_steps=3, max_consecutive_failures=100) + + with open(optimiser.output_path / "ep_history.csv", newline="") as f: + rows = [row for row in csv.DictReader(f) if row["factor"] == prior.name] + + assert rows + assert all(row["flag"] == StatusFlag.NO_CHANGE.name for row in rows) + assert all(row["updated"] == "True" for row in rows) + assert all(row["reverted_variables"] == "" for row in rows) + + assert prior in optimiser._factors_updated + assert prior not in optimiser._factors_skipped + assert optimiser._stale_factor_warnings() == [] + assert "STALE FACTORS" not in caplog.text + written = (optimiser.output_path / "ep_diagnostics.results").read_text() + assert "STALE FACTORS" not in written + + posterior = result.mean_field[x] + assert posterior.mean == pytest.approx(5.0) + assert posterior.sigma == pytest.approx((1 / 2.0**2 + 1 / 1.0**2) ** -0.5, rel=1e-6) + + def test_factor_step_preserves_the_changed_mask_on_the_status(): """ `factor_step` rebuilds the `Status` it returns (to fold in caught