Skip to content

Commit a6aae64

Browse files
Jammy2211claude
authored andcommitted
fix(graphical): return -inf for out-of-support hierarchical factor scale
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 <noreply@anthropic.com>
1 parent 358dee6 commit a6aae64

3 files changed

Lines changed: 84 additions & 6 deletions

File tree

autofit/graphical/declarative/factor/hierarchical.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from typing import Set, Optional, Type, List, Tuple, Dict
22

3+
import numpy as np
4+
5+
from autofit import exc
36
from autofit.mapper.model import ModelInstance
47
from autofit.mapper.prior.abstract import Prior
58
from autofit.mapper.prior_model.collection import Collection
@@ -140,9 +143,19 @@ def __call__(self, **kwargs):
140143
prior_id = int(name_.split("_")[1])
141144
prior = self.distribution_model.prior_with_id(prior_id)
142145
arguments[prior] = array
143-
return self.distribution_model.instance_for_arguments(arguments).message(
144-
argument
145-
)
146+
try:
147+
return self.distribution_model.instance_for_arguments(arguments).message(
148+
argument
149+
)
150+
except exc.MessageException:
151+
# The distribution was parameterised outside its support — e.g. an EP
152+
# factor optimiser proposing a (transiently) negative scale for a
153+
# ``GaussianPrior`` distribution, just below a truncated hyper-prior's
154+
# ``lower_limit=0``. Such a point has zero probability density, so the
155+
# factor's log-value is ``-inf`` rather than a hard error. The strict
156+
# ``NormalMessage`` sigma guard stays intact for genuine prior-passing
157+
# misuse; here we translate it into the correct EP semantics.
158+
return -np.inf
146159

147160

148161
class _HierarchicalFactor(AbstractModelFactor):

autofit/messages/normal.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,14 @@ def assert_sigma_non_negative(sigma, xp=np):
4646
if (np.asarray(sigma) < 0).any():
4747
raise exc.MessageException(
4848
f"NormalMessage sigma cannot be negative, got sigma={sigma}. "
49-
"Negative widths typically come from prior passing with a "
50-
"parameter whose posterior median is negative — see "
51-
"RelativeWidthModifier in the priors config."
49+
"A negative width means a Gaussian was parameterised outside its "
50+
"support. Two common sources: (1) prior passing with a parameter "
51+
"whose posterior median is negative (though RelativeWidthModifier "
52+
"now uses abs(mean), so this is rare); (2) a hierarchical / EP "
53+
"factor whose optimiser proposes a negative scale for a "
54+
"GaussianPrior distribution. Callers that can legitimately reach "
55+
"such points (e.g. HierarchicalFactor) must treat them as "
56+
"zero-density (-inf) rather than constructing the message."
5257
)
5358

5459
class NormalMessage(AbstractMessage):
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import numpy as np
2+
import pytest
3+
4+
import autofit as af
5+
from autofit import exc
6+
from autofit import graphical as g
7+
from autofit.graphical.declarative.factor.hierarchical import Factor
8+
from autofit.messages.normal import NormalMessage
9+
10+
11+
def _factor():
12+
"""A HierarchicalFactor whose scale hyper-prior is truncated at zero, matching
13+
the EP guide (`GaussianPrior` distribution drawn from truncated mean/sigma)."""
14+
hf = g.HierarchicalFactor(
15+
af.GaussianPrior,
16+
mean=af.TruncatedGaussianPrior(
17+
mean=2.0, sigma=1.0, lower_limit=0.0, upper_limit=100.0
18+
),
19+
sigma=af.TruncatedGaussianPrior(
20+
mean=0.5, sigma=0.5, lower_limit=0.0, upper_limit=100.0
21+
),
22+
)
23+
hf.add_drawn_variable(af.GaussianPrior(mean=2.0, sigma=1.0))
24+
return hf
25+
26+
27+
def test_negative_scale_returns_neg_inf_not_raise():
28+
"""
29+
Regression for the EP nightly crash: an EP factor optimiser proposing a
30+
(transiently) negative scale for the `GaussianPrior` distribution — just below
31+
the truncated scale hyper-prior's `lower_limit=0` — must yield a zero-density
32+
(`-inf`) factor value rather than raising `MessageException` from the strict
33+
`NormalMessage` sigma guard.
34+
"""
35+
hf = _factor()
36+
mean_p, scale_p = hf.mean, hf.sigma
37+
factor = Factor(hf)
38+
39+
def call(scale):
40+
return factor(
41+
**{
42+
f"prior_{mean_p.id}": 2.0,
43+
f"prior_{scale_p.id}": scale,
44+
"argument": 2.0,
45+
}
46+
)
47+
48+
# a valid scale gives a finite log-density
49+
assert np.isfinite(call(0.5))
50+
51+
# negative scales are outside the distribution's support → zero density
52+
assert call(-0.016) == -np.inf
53+
assert call(-0.17) == -np.inf
54+
55+
56+
def test_normal_message_negative_sigma_guard_still_raises():
57+
"""The fix relies on the strict guard staying loud for genuine misuse (prior
58+
passing); constructing a `NormalMessage` with a negative sigma must still raise."""
59+
with pytest.raises(exc.MessageException):
60+
NormalMessage(mean=1.0, sigma=-0.1)

0 commit comments

Comments
 (0)