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
19 changes: 16 additions & 3 deletions autofit/graphical/declarative/factor/hierarchical.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
11 changes: 8 additions & 3 deletions autofit/messages/normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
60 changes: 60 additions & 0 deletions test_autofit/graphical/hierarchical/test_negative_scale.py
Original file line number Diff line number Diff line change
@@ -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)
Loading