Skip to content

Commit 26470db

Browse files
authored
Merge pull request #1465 from PyAutoLabs/feature/ep-hierarchical-scale-collapse-guard
fix: flag hierarchical parent-scale collapse in EP diagnostics (#1464)
2 parents 18aae0f + 0d304fd commit 26470db

3 files changed

Lines changed: 275 additions & 13 deletions

File tree

autofit/graphical/expectation_propagation/diagnostics.py

Lines changed: 147 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@
1010
writes them as machine-readable CSVs and a matplotlib evolution plot.
1111
- ``mean_field_summary`` — a human-readable table of a mean field,
1212
suitable for printing at the end of any example or script.
13-
- ``check_sigma_collapse`` — guards against the known pathology where
14-
repeated undamped EP updates over-count shared information and every
15-
sigma collapses towards zero around the starting point (rather than
16-
the data); see PyAutoFit issue #1332 (F10).
13+
- ``check_sigma_collapse`` — guards against two collapse pathologies.
14+
First, the one from PyAutoFit issue #1332 (F10): repeated undamped EP
15+
updates over-count shared information and every sigma collapses
16+
towards zero around the starting point (rather than the data).
17+
Second, the *hierarchical parent-scale* collapse of PyAutoFit issue
18+
#1405: the scale hyperparameter of a ``HierarchicalFactor``'s parent
19+
distribution settles near zero with an error bar that is
20+
over-confident only *relative to that mean* — reporting "no scatter"
21+
as a confident answer. The two need different tests; see
22+
``check_sigma_collapse``.
1723
1824
Outputs written to the EP output folder by ``EPOptimiser`` when paths
1925
are enabled:
@@ -27,12 +33,18 @@
2733
import csv
2834
import logging
2935
from pathlib import Path
30-
from typing import Dict, List, Optional, Tuple
36+
from typing import Dict, List, Optional, Set, Tuple
3137

3238
import numpy as np
3339

3440
logger = logging.getLogger(__name__)
3541

42+
#: Argument names by which a parent distribution's *scale* parameter is
43+
#: recognised on a ``HierarchicalFactor``. ``GaussianPrior`` and
44+
#: ``LogGaussianPrior`` call it ``sigma``; the others are accepted so a
45+
#: distribution that names it differently is still covered.
46+
_SCALE_ARGUMENT_NAMES = frozenset({"sigma", "scale", "std", "stddev"})
47+
3648

3749
def _scalar_mean_std(message) -> Tuple[float, float]:
3850
"""
@@ -65,9 +77,37 @@ def __init__(self):
6577
"""
6678
self.factor_rows: List[dict] = []
6779
self.variable_rows: List[dict] = []
80+
self.scale_variables: Set[str] = set()
6881
self._previous_mean_field = None
6982
self._step = 0
7083

84+
def register_hierarchical_scales(self, factor_graph) -> None:
85+
"""
86+
Record which variables are hierarchical parent *scale*
87+
hyperparameters, so ``check_sigma_collapse`` can apply the
88+
scale-specific test to them (PyAutoFit #1405).
89+
90+
A ``HierarchicalFactor`` parameterises a parent distribution
91+
with priors named after that distribution's arguments — e.g.
92+
``af.HierarchicalFactor(af.GaussianPrior, mean=..., sigma=...)``.
93+
The scale argument is the one that collapses, so it is picked
94+
out by name (``_SCALE_ARGUMENT_NAMES``).
95+
96+
Silently records nothing for a graph with no hierarchical
97+
factors, or one that does not expose them (a plain
98+
``FactorGraph`` rather than a ``DeclarativeFactorGraph``) —
99+
diagnostics must never kill the fit.
100+
"""
101+
try:
102+
distribution_models = factor_graph.hierarchical_factors
103+
except AttributeError:
104+
return
105+
106+
for distribution_model in distribution_models:
107+
for name, prior in distribution_model.prior_tuples:
108+
if name in _SCALE_ARGUMENT_NAMES:
109+
self.scale_variables.add(prior.name)
110+
71111
def snapshot(self, factor, model_approx, status) -> None:
72112
"""
73113
Record the state of the approximation after one factor update.
@@ -210,23 +250,94 @@ def mean_field_summary(mean_field) -> str:
210250
return "\n".join(lines)
211251

212252

253+
def _drop_consecutive_repeats(values: np.ndarray) -> np.ndarray:
254+
"""
255+
Collapse runs of identical consecutive values to a single entry.
256+
257+
``EPDiagnostics.snapshot`` records *every* variable on *every*
258+
factor update, but a factor update only moves the marginals of the
259+
variables adjacent to that factor. A variable's history is
260+
therefore dominated by steps at which it did not move at all, and a
261+
strict ``diff < 0`` monotonicity test can essentially never be
262+
satisfied in a multi-factor graph. Dropping the repeats restores
263+
the test to what it was written to mean: consecutive *updates of
264+
this variable* that shrank it.
265+
266+
The first and last values are always preserved, so magnitude
267+
comparisons against them are unaffected.
268+
"""
269+
if len(values) < 2:
270+
return values
271+
return values[np.insert(np.diff(values) != 0, 0, True)]
272+
273+
274+
def _flag_scale_collapse(
275+
means: np.ndarray,
276+
stds: np.ndarray,
277+
mean_fraction: float,
278+
relative_error: float,
279+
) -> bool:
280+
"""
281+
Whether a hierarchical parent scale has collapsed (PyAutoFit #1405).
282+
283+
True when the scale's mean has fallen below ``mean_fraction`` of
284+
its initial value *and* its error is small relative to that mean —
285+
i.e. the fit is confidently reporting near-zero parent scatter.
286+
287+
A non-positive initial mean gives no baseline to collapse from, so
288+
no judgement is made. A latest mean at or below zero is outside the
289+
scale's support and is always flagged.
290+
"""
291+
initial, latest = means[0], means[-1]
292+
293+
if not initial > 0:
294+
return False
295+
if latest <= 0:
296+
return True
297+
if latest >= mean_fraction * initial:
298+
return False
299+
300+
return stds[-1] / latest < relative_error
301+
302+
213303
def check_sigma_collapse(
214304
diagnostics: EPDiagnostics,
215305
std_floor: float = 1e-8,
216306
monotone_steps: int = 5,
217307
shrink_factor: float = 1e-3,
308+
scale_mean_fraction: float = 0.2,
309+
scale_relative_error: float = 0.5,
218310
) -> List[str]:
219311
"""
220-
Detect the EP sigma-collapse pathology (PyAutoFit #1332, F10).
312+
Detect the two EP collapse pathologies.
221313
222-
Repeated undamped EP updates can over-count shared-variable
223-
information: every std shrinks monotonically towards zero around
224-
the *starting* means, while the KL convergence criterion never
225-
triggers. This check flags a variable when either:
314+
**Sigma collapse (PyAutoFit #1332, F10).** Repeated undamped EP
315+
updates can over-count shared-variable information: every std
316+
shrinks monotonically towards zero around the *starting* means,
317+
while the KL convergence criterion never triggers. This check flags
318+
a variable when either:
226319
227320
- its latest std is below ``std_floor``, or
228-
- its std has shrunk monotonically for the last ``monotone_steps``
229-
updates *and* by more than a factor ``1 / shrink_factor`` overall.
321+
- its std has shrunk monotonically over its last ``monotone_steps``
322+
*updates* (steps at which it did not move are not counted — see
323+
``_drop_consecutive_repeats``) *and* by more than a factor
324+
``1 / shrink_factor`` overall.
325+
326+
**Hierarchical parent-scale collapse (PyAutoFit #1405).** Both
327+
tests above are *absolute* and variable-agnostic, because #1332 is
328+
a pathology in which every std goes to zero. The parent scale of a
329+
``HierarchicalFactor`` collapses in a different shape: its *mean*
330+
goes to ~0 while its std stays moderate in absolute terms and is
331+
over-confident only *relative to that mean* — a confident claim of
332+
"no scatter". Measured on the #1405 toy, the collapsed runs sit at
333+
mean 0.80 (std 0.11) and mean 0.0030 against a parent scale
334+
hyper-prior of mean 10, where healthy runs recover 9.1-12.8; an
335+
absolute std test cannot separate those, and does not fire on
336+
either. So for variables registered by
337+
``EPDiagnostics.register_hierarchical_scales`` a variable is
338+
additionally flagged when its mean has fallen below
339+
``scale_mean_fraction`` of its initial value *and* its relative
340+
error ``std / |mean|`` is below ``scale_relative_error``.
230341
231342
Returns
232343
-------
@@ -235,12 +346,35 @@ def check_sigma_collapse(
235346
results text at the end of a run.
236347
"""
237348
warnings_list = []
349+
scale_variables = getattr(diagnostics, "scale_variables", set())
238350

239351
for name, rows in diagnostics.variable_history.items():
240352
stds = np.array([std for _, _, std in rows], dtype=float)
353+
means = np.array([mean for _, mean, _ in rows], dtype=float)
241354
if len(stds) == 0:
242355
continue
243356

357+
if name in scale_variables and _flag_scale_collapse(
358+
means, stds, scale_mean_fraction, scale_relative_error
359+
):
360+
relative_error = (
361+
stds[-1] / abs(means[-1]) if means[-1] != 0 else float("inf")
362+
)
363+
warnings_list.append(
364+
f"scale-collapse: hierarchical parent scale '{name}' has "
365+
f"collapsed to {means[-1]:.3g} "
366+
f"({means[-1] / means[0]:.1%} of its initial {means[0]:.3g}) "
367+
f"with a relative error of {relative_error:.2g} — the fit is "
368+
f"reporting near-zero parent scatter as a confident answer "
369+
f"(see PyAutoFit #1405). This is a known EP instability for "
370+
f"scale hyperparameters and the value should NOT be trusted; "
371+
f"cross-check the parent scatter against a joint sampler fit "
372+
f"of the same graph."
373+
)
374+
continue
375+
376+
stds = _drop_consecutive_repeats(stds)
377+
244378
if stds[-1] < std_floor:
245379
warnings_list.append(
246380
f"sigma-collapse: variable '{name}' has std {stds[-1]:.3g} "
@@ -258,7 +392,7 @@ def check_sigma_collapse(
258392
if np.all(np.diff(tail) < 0) and stds[-1] < shrink_factor * stds[0]:
259393
warnings_list.append(
260394
f"sigma-collapse: variable '{name}' std has shrunk "
261-
f"monotonically over the last {monotone_steps} updates to "
395+
f"monotonically over its last {monotone_steps} updates to "
262396
f"{stds[-1]:.3g} ({stds[-1] / stds[0]:.1e} of its initial "
263397
f"value) — possible information over-counting (PyAutoFit "
264398
f"#1332 F10)."

autofit/graphical/expectation_propagation/optimiser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def __init__(
238238

239239
self.ep_history = ep_history or EPHistory()
240240
self.diagnostics = EPDiagnostics()
241+
self.diagnostics.register_hierarchical_scales(factor_graph)
241242

242243
# Per-factor count of consecutive failed updates; see
243244
# `_check_consecutive_failures`. Reset at the start of every `run`.

test_autofit/graphical/functionality/test_diagnostics.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,130 @@ def test_parallel_end_of_run_guards():
202202
opt._output_diagnostics()
203203
opt._output_diagnostics(final=True, model_approx=model_approx)
204204
opt._warn_sigma_collapse()
205+
206+
207+
def _scale_diagnostics(means, stds, variable="parent_sigma"):
208+
"""
209+
An `EPDiagnostics` carrying one registered parent-scale variable
210+
with the given mean/std trajectory.
211+
"""
212+
diagnostics = EPDiagnostics()
213+
diagnostics.scale_variables = {variable}
214+
diagnostics.variable_rows = [
215+
{"step": step, "factor": "hierarchical", "variable": variable,
216+
"mean": float(mean), "std": float(std)}
217+
for step, (mean, std) in enumerate(zip(means, stds))
218+
]
219+
return diagnostics
220+
221+
222+
# The three states below are the measured outcomes of the PyAutoFit #1405 toy
223+
# (parent scale hyper-prior mean 10, truth 10): two COLLAPSE runs and the
224+
# RECOVER band. See PyAutoMind complete/2026/07/ep_scale_collapse_assets/.
225+
@pytest.mark.parametrize(
226+
"final_mean, final_std",
227+
[
228+
(0.80, 0.11), # shallow collapse — the std alone looks unremarkable
229+
(0.0030, 1e-5), # deep collapse
230+
],
231+
)
232+
def test_scale_collapse_flags_measured_collapses(final_mean, final_std):
233+
diagnostics = _scale_diagnostics(
234+
means=[10.0, 8.0, 4.0, final_mean],
235+
stds=[5.0, 3.0, 1.0, final_std],
236+
)
237+
238+
warnings_list = graph.check_sigma_collapse(diagnostics)
239+
240+
assert len(warnings_list) == 1
241+
assert "scale-collapse" in warnings_list[0]
242+
assert "parent_sigma" in warnings_list[0]
243+
assert "#1405" in warnings_list[0]
244+
245+
246+
@pytest.mark.parametrize("final_mean, final_std", [(9.1, 0.9), (12.8, 2.4)])
247+
def test_scale_collapse_silent_on_measured_recoveries(final_mean, final_std):
248+
diagnostics = _scale_diagnostics(
249+
means=[10.0, 8.0, 11.0, final_mean],
250+
stds=[5.0, 3.0, 2.0, final_std],
251+
)
252+
253+
assert graph.check_sigma_collapse(diagnostics) == []
254+
255+
256+
def test_scale_collapse_needs_confidence_not_just_a_small_mean():
257+
"""
258+
A small parent scale that is honestly uncertain is not a collapse —
259+
the pathology is a small scale reported *confidently*.
260+
"""
261+
diagnostics = _scale_diagnostics(
262+
means=[10.0, 8.0, 4.0, 0.80],
263+
stds=[5.0, 3.0, 1.0, 2.0],
264+
)
265+
266+
assert graph.check_sigma_collapse(diagnostics) == []
267+
268+
269+
def test_scale_check_applies_only_to_registered_scale_variables():
270+
"""
271+
The same trajectory on an unregistered variable must not be flagged:
272+
the relative test is meaningful for a scale hyperparameter, not for
273+
an arbitrary variable that happens to approach zero.
274+
"""
275+
diagnostics = _scale_diagnostics(means=[10.0, 4.0, 0.0030], stds=[5.0, 1.0, 1e-5])
276+
diagnostics.scale_variables = set()
277+
278+
assert graph.check_sigma_collapse(diagnostics) == []
279+
280+
281+
def test_monotone_limb_survives_unchanged_steps():
282+
"""
283+
A variable only moves when a factor adjacent to it is updated, but a
284+
snapshot is recorded for every variable on every factor update. The
285+
monotone test must not be defeated by the resulting repeated rows.
286+
"""
287+
shrinking = np.geomspace(1.0, 1e-5, num=8)
288+
# Interleave each real update with two steps at which this variable
289+
# did not move — the shape a real multi-factor graph produces.
290+
with_repeats = [std for std in shrinking for _ in range(3)]
291+
292+
diagnostics = EPDiagnostics()
293+
diagnostics.variable_rows = [
294+
{"step": step, "factor": "f", "variable": "shrinking", "mean": 1.0,
295+
"std": float(std)}
296+
for step, std in enumerate(with_repeats)
297+
]
298+
299+
warnings_list = graph.check_sigma_collapse(diagnostics)
300+
301+
assert len(warnings_list) == 1
302+
assert "monotonically" in warnings_list[0]
303+
304+
305+
def test_register_hierarchical_scales_finds_the_parent_sigma():
306+
import autofit as af
307+
308+
hierarchical_factor = af.HierarchicalFactor(
309+
af.GaussianPrior,
310+
mean=af.GaussianPrior(mean=50.0, sigma=10.0),
311+
sigma=af.GaussianPrior(mean=10.0, sigma=5.0),
312+
)
313+
for _ in range(3):
314+
hierarchical_factor.add_drawn_variable(af.GaussianPrior(mean=50.0, sigma=10.0))
315+
316+
factor_graph = af.FactorGraphModel(hierarchical_factor)
317+
318+
diagnostics = EPDiagnostics()
319+
diagnostics.register_hierarchical_scales(factor_graph.graph)
320+
321+
sigma_prior = dict(hierarchical_factor.prior_tuples)["sigma"]
322+
assert diagnostics.scale_variables == {sigma_prior.name}
323+
324+
325+
def test_register_hierarchical_scales_tolerates_a_plain_factor_graph():
326+
model_approx, _ = make_model_approx()
327+
328+
diagnostics = EPDiagnostics()
329+
diagnostics.register_hierarchical_scales(model_approx.factor_graph)
330+
331+
assert diagnostics.scale_variables == set()

0 commit comments

Comments
 (0)