Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions autofit/graphical/expectation_propagation/optimiser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions autofit/graphical/laplace/newton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions autofit/graphical/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading