From a6aae649fe4f97ff56b53bf065aed4b707b00977 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 13 Jul 2026 15:45:12 +0100 Subject: [PATCH] fix(graphical): return -inf for out-of-support hierarchical factor scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EP factor optimiser can propose a transiently negative scale for a GaussianPrior distribution drawn via a HierarchicalFactor (just below a truncated scale hyper-prior's lower_limit=0). Factor.__call__ then built GaussianPrior(sigma<0), tripping the strict NormalMessage sigma guard and crashing the fit — the nightly workspace-validation RED on the EP guide (scripts/guides/modeling/advanced/expectation_propagation.py). A distribution parameterised outside its support has zero probability density, so the factor's log-value is -inf rather than a hard error. The strict NormalMessage sigma guard stays intact for genuine prior-passing misuse; its stale RelativeWidthModifier-only hint is corrected. Adds a regression test asserting negative scales return -inf and the guard still raises. Fixes #1363 Co-Authored-By: Claude Opus 4.8 --- .../declarative/factor/hierarchical.py | 19 +++++- autofit/messages/normal.py | 11 +++- .../hierarchical/test_negative_scale.py | 60 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 test_autofit/graphical/hierarchical/test_negative_scale.py diff --git a/autofit/graphical/declarative/factor/hierarchical.py b/autofit/graphical/declarative/factor/hierarchical.py index f61f46d9c..e3b95c1fd 100644 --- a/autofit/graphical/declarative/factor/hierarchical.py +++ b/autofit/graphical/declarative/factor/hierarchical.py @@ -1,5 +1,8 @@ from typing import Set, Optional, Type, List, Tuple, Dict +import numpy as np + +from autofit import exc from autofit.mapper.model import ModelInstance from autofit.mapper.prior.abstract import Prior from autofit.mapper.prior_model.collection import Collection @@ -140,9 +143,19 @@ def __call__(self, **kwargs): prior_id = int(name_.split("_")[1]) prior = self.distribution_model.prior_with_id(prior_id) arguments[prior] = array - return self.distribution_model.instance_for_arguments(arguments).message( - argument - ) + try: + return self.distribution_model.instance_for_arguments(arguments).message( + argument + ) + except exc.MessageException: + # The distribution was parameterised outside its support — e.g. an EP + # factor optimiser proposing a (transiently) negative scale for a + # ``GaussianPrior`` distribution, just below a truncated hyper-prior's + # ``lower_limit=0``. Such a point has zero probability density, so the + # factor's log-value is ``-inf`` rather than a hard error. The strict + # ``NormalMessage`` sigma guard stays intact for genuine prior-passing + # misuse; here we translate it into the correct EP semantics. + return -np.inf class _HierarchicalFactor(AbstractModelFactor): diff --git a/autofit/messages/normal.py b/autofit/messages/normal.py index ecc72346b..de0226f29 100644 --- a/autofit/messages/normal.py +++ b/autofit/messages/normal.py @@ -46,9 +46,14 @@ def assert_sigma_non_negative(sigma, xp=np): if (np.asarray(sigma) < 0).any(): raise exc.MessageException( f"NormalMessage sigma cannot be negative, got sigma={sigma}. " - "Negative widths typically come from prior passing with a " - "parameter whose posterior median is negative — see " - "RelativeWidthModifier in the priors config." + "A negative width means a Gaussian was parameterised outside its " + "support. Two common sources: (1) prior passing with a parameter " + "whose posterior median is negative (though RelativeWidthModifier " + "now uses abs(mean), so this is rare); (2) a hierarchical / EP " + "factor whose optimiser proposes a negative scale for a " + "GaussianPrior distribution. Callers that can legitimately reach " + "such points (e.g. HierarchicalFactor) must treat them as " + "zero-density (-inf) rather than constructing the message." ) class NormalMessage(AbstractMessage): diff --git a/test_autofit/graphical/hierarchical/test_negative_scale.py b/test_autofit/graphical/hierarchical/test_negative_scale.py new file mode 100644 index 000000000..59065d143 --- /dev/null +++ b/test_autofit/graphical/hierarchical/test_negative_scale.py @@ -0,0 +1,60 @@ +import numpy as np +import pytest + +import autofit as af +from autofit import exc +from autofit import graphical as g +from autofit.graphical.declarative.factor.hierarchical import Factor +from autofit.messages.normal import NormalMessage + + +def _factor(): + """A HierarchicalFactor whose scale hyper-prior is truncated at zero, matching + the EP guide (`GaussianPrior` distribution drawn from truncated mean/sigma).""" + hf = g.HierarchicalFactor( + af.GaussianPrior, + mean=af.TruncatedGaussianPrior( + mean=2.0, sigma=1.0, lower_limit=0.0, upper_limit=100.0 + ), + sigma=af.TruncatedGaussianPrior( + mean=0.5, sigma=0.5, lower_limit=0.0, upper_limit=100.0 + ), + ) + hf.add_drawn_variable(af.GaussianPrior(mean=2.0, sigma=1.0)) + return hf + + +def test_negative_scale_returns_neg_inf_not_raise(): + """ + Regression for the EP nightly crash: an EP factor optimiser proposing a + (transiently) negative scale for the `GaussianPrior` distribution — just below + the truncated scale hyper-prior's `lower_limit=0` — must yield a zero-density + (`-inf`) factor value rather than raising `MessageException` from the strict + `NormalMessage` sigma guard. + """ + hf = _factor() + mean_p, scale_p = hf.mean, hf.sigma + factor = Factor(hf) + + def call(scale): + return factor( + **{ + f"prior_{mean_p.id}": 2.0, + f"prior_{scale_p.id}": scale, + "argument": 2.0, + } + ) + + # a valid scale gives a finite log-density + assert np.isfinite(call(0.5)) + + # negative scales are outside the distribution's support → zero density + assert call(-0.016) == -np.inf + assert call(-0.17) == -np.inf + + +def test_normal_message_negative_sigma_guard_still_raises(): + """The fix relies on the strict guard staying loud for genuine misuse (prior + passing); constructing a `NormalMessage` with a negative sigma must still raise.""" + with pytest.raises(exc.MessageException): + NormalMessage(mean=1.0, sigma=-0.1)