Skip to content

Commit cf0cc4b

Browse files
authored
Merge pull request #1348 from PyAutoLabs/feature/prior-width-safety
fix(priors): width-modifier safety + sigma validation agreement
2 parents c0b6c94 + 754a202 commit cf0cc4b

5 files changed

Lines changed: 205 additions & 28 deletions

File tree

autofit/mapper/prior/width_modifier.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ def name_of_class(cls) -> str:
2222

2323
@classmethod
2424
def from_dict(cls, width_modifier_dict):
25-
return width_modifier_type_dict[width_modifier_dict["type"]](
26-
value=width_modifier_dict["value"]
27-
)
25+
# Forward every key except "type" so optional keys (e.g. the
26+
# RelativeWidthModifier absolute_floor) round-trip from config.
27+
kwargs = {
28+
key: value
29+
for key, value in width_modifier_dict.items()
30+
if key != "type"
31+
}
32+
return width_modifier_type_dict[width_modifier_dict["type"]](**kwargs)
2833

2934
@abstractmethod
3035
def __call__(self, mean):
@@ -72,12 +77,47 @@ def for_class_and_attribute_name(cls: type, attribute_name: str) -> "WidthModifi
7277
return RelativeWidthModifier(0.5)
7378

7479
def __eq__(self, other):
75-
return self.__class__ is other.__class__ and self.value == other.value
80+
return self.__class__ is other.__class__ and self.dict == other.dict
7681

7782

7883
class RelativeWidthModifier(WidthModifier):
84+
def __init__(self, value, absolute_floor=None):
85+
"""
86+
Prior-passing width proportional to the magnitude of the posterior
87+
median: ``sigma = value * abs(mean)`` (#1331 Decision 5).
88+
89+
``abs`` guards the negative-median case, which previously produced a
90+
negative sigma that flowed silently into the passed prior and flipped
91+
its scale. For medians at (or very near) zero the relative width
92+
collapses; set ``absolute_floor`` (here or via the ``width_modifier``
93+
entry in the priors config) to impose a minimum width. Without a floor,
94+
a zero width is rejected loudly at prior-passing time.
95+
96+
Parameters
97+
----------
98+
value
99+
The proportionality constant applied to ``abs(mean)``.
100+
absolute_floor
101+
Optional minimum width. When set, the returned width is
102+
``max(value * abs(mean), absolute_floor)``.
103+
"""
104+
super().__init__(value)
105+
self.absolute_floor = (
106+
float(absolute_floor) if absolute_floor is not None else None
107+
)
108+
79109
def __call__(self, mean):
80-
return self.value * mean
110+
sigma = self.value * abs(mean)
111+
if self.absolute_floor is not None:
112+
sigma = max(sigma, self.absolute_floor)
113+
return sigma
114+
115+
@property
116+
def dict(self):
117+
d = super().dict
118+
if self.absolute_floor is not None:
119+
d["absolute_floor"] = self.absolute_floor
120+
return d
81121

82122

83123
class AbsoluteWidthModifier(WidthModifier):

autofit/mapper/prior_model/abstract.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,20 @@ def mapper_from_prior_means(self, means, a=None, r=None, no_limits=False):
10531053
width = r * mean
10541054
else:
10551055
width = width_modifier(mean)
1056+
if width <= 0:
1057+
path = ".".join(map(str, self.path_for_prior(prior)))
1058+
raise exc.PriorException(
1059+
f"Prior passing produced a non-positive width "
1060+
f"(sigma={width}) for parameter '{path}' (posterior "
1061+
f"median mean={mean}). This happens when the posterior "
1062+
"median is zero and the parameter's width modifier is "
1063+
"relative with no floor. Configure an "
1064+
"AbsoluteWidthModifier or set absolute_floor on the "
1065+
"RelativeWidthModifier for this parameter in the "
1066+
"priors config. (Explicit a=/r= widths are exempt from "
1067+
"this check and keep their historical point-mass "
1068+
"semantics at mean=0.)"
1069+
)
10561070

10571071
if no_limits:
10581072
limits = (float("-inf"), float("inf"))

autofit/messages/normal.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,32 @@ def is_nan(value):
2424
return is_nan_
2525

2626
def assert_sigma_non_negative(sigma, xp=np):
27-
28-
is_negative = sigma < 0
29-
30-
if xp.__name__.startswith("jax"):
31-
import jax
32-
# JAX path: cannot convert to Python bool
33-
# Raise using JAX control flow:
34-
return jax.lax.cond(
35-
is_negative,
36-
lambda _: (_ for _ in ()).throw(
37-
ValueError("Sigma cannot be negative")
38-
),
39-
lambda _: None,
40-
operand=None,
41-
)
42-
else:
43-
# NumPy path: normal boolean works
44-
if bool(is_negative):
45-
raise ValueError("Sigma cannot be negative")
27+
"""
28+
Reject ``sigma < 0`` on the NumPy path (#1331 Decision 2), matching
29+
``TruncatedNormalMessage`` — the two classes previously disagreed, so a
30+
negative sigma (e.g. from a negative posterior median through a relative
31+
width modifier) constructed a ``NormalMessage`` silently and flipped the
32+
scale of ``value_for`` / ``sample``.
33+
34+
``sigma == 0`` is deliberately permitted: it is the established point-mass
35+
idiom — the latent-variables machinery wraps known values as
36+
``GaussianPrior(mean=value, sigma=0.0)`` (``non_linear/samples/util.py``),
37+
``from_mode(..., covariance=0)`` requests a point-mass projection, and
38+
``model_centred_relative`` pins ``sigma == 0`` for zero-centred parameters.
39+
40+
The JAX path is a deliberate no-op: a traced ``sigma`` cannot be converted
41+
to a Python bool, so validity defers to NaN propagation. (The previous
42+
``jax.lax.cond`` branch here never worked — ``lax.cond`` traces both
43+
branches, so the generator-throw lambda was suppressed under ``jit``.)
44+
"""
45+
if xp is np:
46+
if (np.asarray(sigma) < 0).any():
47+
raise exc.MessageException(
48+
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."
52+
)
4653

4754
class NormalMessage(AbstractMessage):
4855

@@ -84,7 +91,7 @@ def __init__(
8491
The mean (μ) of the normal distribution.
8592
8693
sigma
87-
The standard deviation (σ) of the distribution. Must be non-negative.
94+
The standard deviation (σ) of the distribution. Must be strictly positive.
8895
8996
log_norm
9097
An additive constant to the log probability of the message. Used internally for message-passing normalization.
@@ -100,7 +107,7 @@ def __init__(
100107
import jax.numpy as jnp
101108
xp = jnp
102109

103-
# assert_sigma_non_negative(sigma, xp=xp)
110+
assert_sigma_non_negative(sigma, xp=xp)
104111

105112
super().__init__(
106113
mean,

autofit/messages/truncated_normal.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,17 @@ def __init__(
8888
mean
8989
The mean (μ) of the normal distribution.
9090
sigma
91-
The standard deviation (σ) of the distribution. Must be non-negative.
91+
The standard deviation (σ) of the distribution. Must be strictly positive.
9292
log_norm
9393
An additive constant to the log probability of the message. Used internally for message-passing normalization.
9494
Default is 0.0.
9595
id_
9696
An optional unique identifier used to track the message in larger probabilistic graphs or models.
9797
"""
9898
if (np.array(sigma) < 0).any():
99-
raise exc.MessageException("Sigma cannot be negative")
99+
raise exc.MessageException(
100+
f"TruncatedNormalMessage sigma cannot be negative, got sigma={sigma}."
101+
)
100102

101103
super().__init__(
102104
mean,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Regression tests for the width-modifier safety pair (PyAutoFit #1346,
2+
Phase 2 of decision hub #1331 — Decisions 5 + 2). Numpy-only.
3+
"""
4+
import pytest
5+
6+
import autofit as af
7+
from autofit import exc
8+
from autofit.mapper.prior.width_modifier import (
9+
RelativeWidthModifier,
10+
WidthModifier,
11+
)
12+
from autofit.messages.normal import NormalMessage
13+
from autofit.messages.truncated_normal import TruncatedNormalMessage
14+
15+
16+
# --- Decision 5: RelativeWidthModifier uses abs(mean), optional absolute_floor ---
17+
18+
def test__relative_width_modifier_abs_mean():
19+
mod = RelativeWidthModifier(0.5)
20+
# A negative posterior median previously produced a negative sigma that
21+
# flowed silently into the passed prior and flipped its scale.
22+
assert mod(-2.0) == 1.0
23+
assert mod(2.0) == 1.0
24+
# The bare modifier still returns 0.0 at mean=0; prior passing rejects it
25+
# loudly downstream (see the chained tests below).
26+
assert mod(0.0) == 0.0
27+
28+
29+
def test__relative_width_modifier_floor():
30+
mod = RelativeWidthModifier(0.5, absolute_floor=0.1)
31+
assert mod(0.0) == 0.1 # floor engages at zero median
32+
assert mod(0.1) == 0.1 # 0.5 * 0.1 = 0.05 < floor
33+
assert mod(10.0) == 5.0 # a floor, not a cap
34+
35+
36+
def test__relative_width_modifier_dict_round_trip():
37+
mod = RelativeWidthModifier(0.5, absolute_floor=0.1)
38+
assert mod.dict == {"type": "Relative", "value": 0.5, "absolute_floor": 0.1}
39+
assert WidthModifier.from_dict(mod.dict) == mod
40+
41+
bare = RelativeWidthModifier(0.5)
42+
assert bare.dict == {"type": "Relative", "value": 0.5}
43+
assert WidthModifier.from_dict(bare.dict) == bare
44+
assert bare != mod
45+
46+
47+
# --- Decision 2 (evidence-adjusted): both message classes now agree — sigma < 0
48+
# rejected, sigma == 0 permitted as the established point-mass idiom (latent
49+
# variables' simple_model_for_kwargs, from_mode(covariance=0),
50+
# model_centred_relative at mean=0 all depend on it) ---
51+
52+
def test__normal_message_rejects_negative_sigma():
53+
with pytest.raises(exc.MessageException):
54+
NormalMessage(mean=0.0, sigma=-1.0)
55+
56+
57+
def test__truncated_normal_message_rejects_negative_sigma():
58+
with pytest.raises(exc.MessageException):
59+
TruncatedNormalMessage(
60+
mean=0.0, sigma=-1.0, lower_limit=-1.0, upper_limit=1.0
61+
)
62+
63+
64+
def test__sigma_zero_point_mass_still_constructs():
65+
# The point-mass carrier used by the latent-variables machinery
66+
# (non_linear/samples/util.py) and from_mode(covariance=0) must keep working.
67+
m = NormalMessage(mean=3.0, sigma=0.0)
68+
assert m.sigma == 0.0
69+
p = af.GaussianPrior(mean=3.0, sigma=0.0)
70+
assert p.sigma == 0.0
71+
72+
73+
def test__gaussian_prior_rejects_negative_sigma():
74+
# Previously constructed silently with deceptive variance = sigma**2 > 0
75+
# and a sign-flipped value_for.
76+
with pytest.raises(exc.MessageException):
77+
af.GaussianPrior(mean=0.0, sigma=-0.5)
78+
79+
80+
# --- The mean=0 chained-parameter regression (the Phase-2 sequencing gate:
81+
# yesterday's silent delta-freeze must become a clear, parameter-named error,
82+
# and the floor must be the working remedy) ---
83+
84+
def _mapper_with_relative_widths(absolute_floor=None):
85+
mapper = af.ModelMapper(mock_class=af.m.MockClassx2)
86+
for prior in mapper.priors:
87+
prior.width_modifier = RelativeWidthModifier(
88+
0.5, absolute_floor=absolute_floor
89+
)
90+
return mapper
91+
92+
93+
def test__prior_passing_mean_zero_raises_with_guidance():
94+
mapper = _mapper_with_relative_widths()
95+
with pytest.raises(exc.PriorException) as err:
96+
mapper.mapper_from_prior_means([0.0, 5.0])
97+
# The error must name the parameter and point at the remedy.
98+
assert "mock_class" in str(err.value)
99+
assert "absolute_floor" in str(err.value)
100+
101+
102+
def test__prior_passing_mean_zero_with_floor_passes():
103+
mapper = _mapper_with_relative_widths(absolute_floor=0.1)
104+
result = mapper.mapper_from_prior_means([0.0, 5.0])
105+
assert result.mock_class.one.mean == 0.0
106+
assert result.mock_class.one.sigma == 0.1 # floor engaged
107+
assert result.mock_class.two.sigma == 2.5 # 0.5 * 5.0, floor irrelevant
108+
109+
110+
def test__prior_passing_negative_mean_gets_positive_width():
111+
mapper = _mapper_with_relative_widths()
112+
result = mapper.mapper_from_prior_means([-2.0, 5.0])
113+
assert result.mock_class.one.mean == -2.0
114+
assert result.mock_class.one.sigma == 1.0 # 0.5 * abs(-2.0)

0 commit comments

Comments
 (0)