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
1824Outputs written to the EP output folder by ``EPOptimiser`` when paths
1925are enabled:
2733import csv
2834import logging
2935from pathlib import Path
30- from typing import Dict , List , Optional , Tuple
36+ from typing import Dict , List , Optional , Set , Tuple
3137
3238import numpy as np
3339
3440logger = 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
3749def _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+
213303def 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)."
0 commit comments