Skip to content

Commit c0b6c94

Browse files
authored
Merge pull request #1345 from PyAutoLabs/feature/priors-messages-fixes
fix(priors): correctness batch — crashes, log-partition, projections
2 parents 7ab525b + 1af2b5b commit c0b6c94

12 files changed

Lines changed: 294 additions & 61 deletions

File tree

autofit/mapper/prior/abstract.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ class attribute.
3737

3838
self.width_modifier = None
3939

40+
def log_normalisation(self, xp=np) -> float:
41+
"""
42+
The additive constant dropped from :meth:`log_prior_from_value`.
43+
44+
By contract ``log_prior_from_value`` returns the log prior density *up to
45+
an additive constant*: the value-independent normaliser is dropped because
46+
it is irrelevant to posterior shape (it cancels in the Metropolis ratio,
47+
and nested samplers use the unit-cube transform rather than the prior
48+
density). This is harmless for sampling but means absolute ``log_prior``
49+
values are not comparable across prior types.
50+
51+
For code that needs the *fully normalised* log density — e.g. evidence or
52+
Bayes-factor arithmetic — the normalised value is::
53+
54+
log_prior_from_value(value) + log_normalisation()
55+
56+
The default is ``0.0`` (constant unknown / already normalised). Priors with
57+
a known closed-form normaliser override this.
58+
"""
59+
return 0.0
60+
4061
@classmethod
4162
def tree_unflatten(cls, aux_data, children):
4263
"""

autofit/mapper/prior/gaussian.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ def parameter_string(self) -> str:
114114
"""
115115
return f"mean = {self.mean}, sigma = {self.sigma}"
116116

117+
def log_normalisation(self, xp=np) -> float:
118+
"""The constant ``-log(sigma) - 0.5*log(2*pi)`` dropped from the density-form
119+
quadratic returned by ``NormalMessage.log_prior_from_value``. See
120+
``Prior.log_normalisation``."""
121+
return -xp.log(self.sigma) - 0.5 * xp.log(2.0 * np.pi)
122+
117123
def value_for(self, unit, xp=np):
118124
"""
119125
Map a unit value in [0, 1] to a physical value drawn from this Gaussian prior.

autofit/mapper/prior/log_gaussian.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,6 @@ def with_limits(cls, lower_limit: float, upper_limit: float) -> "LogGaussianPrio
9595
return cls(
9696
mean=(lower_limit + upper_limit) / 2,
9797
sigma=upper_limit - lower_limit,
98-
lower_limit=lower_limit,
99-
upper_limit=upper_limit,
10098
)
10199

102100
def _new_for_base_message(self, message):
@@ -108,9 +106,7 @@ def _new_for_base_message(self, message):
108106
"""
109107
return LogGaussianPrior(
110108
*message.parameters,
111-
lower_limit=self.lower_limit,
112-
upper_limit=self.upper_limit,
113-
id_=self.instance().id,
109+
id_=self.id,
114110
)
115111

116112
def value_for(self, unit, xp=np):
@@ -141,6 +137,12 @@ def value_for(self, unit, xp=np):
141137
def parameter_string(self) -> str:
142138
return f"mean = {self.mean}, sigma = {self.sigma}"
143139

140+
def log_normalisation(self, xp=np) -> float:
141+
"""The constant ``-log(sigma) - 0.5*log(2*pi)`` dropped from the Gaussian-in-log
142+
density in ``log_prior_from_value`` (the value-dependent ``-log(value)``
143+
change-of-variables Jacobian is kept). See ``Prior.log_normalisation``."""
144+
return -xp.log(self.sigma) - 0.5 * xp.log(2.0 * np.pi)
145+
144146
def log_prior_from_value(self, value, xp=np):
145147
"""
146148
Compute the log prior density of a given physical value under this log-Gaussian prior.

autofit/mapper/prior/log_uniform.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ def log_prior_from_value(self, value, xp=np):
153153
in_bounds = (value >= self.lower_limit) & (value <= self.upper_limit)
154154
return xp.where(in_bounds, -xp.log(value), -xp.inf)
155155

156+
def log_normalisation(self, xp=np) -> float:
157+
"""The constant ``-log(log(upper / lower))`` dropped from
158+
``log_prior_from_value`` (which returns ``-log(value)``). See
159+
``Prior.log_normalisation``."""
160+
return -xp.log(xp.log(self.upper_limit / self.lower_limit))
161+
156162
def value_for(self, unit, xp=np):
157163
"""
158164
Returns a physical value from an input unit value according to the limits of the log10 uniform prior.

autofit/mapper/prior/truncated_gaussian.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ def parameter_string(self) -> str:
133133
f"upper_limit = {self.upper_limit}"
134134
)
135135

136+
def log_normalisation(self, xp=np) -> float:
137+
"""The constant ``-log(sigma) - 0.5*log(2*pi) - log(Z)`` dropped from
138+
``TruncatedNormalMessage.log_prior_from_value``, where
139+
``Z = Phi((upper - mean)/sigma) - Phi((lower - mean)/sigma)`` is the
140+
truncation mass. See ``Prior.log_normalisation``."""
141+
if xp.__name__.startswith("jax"):
142+
import jax.scipy.stats as jstats
143+
norm = jstats.norm
144+
else:
145+
from scipy.stats import norm
146+
a = (self.lower_limit - self.mean) / self.sigma
147+
b = (self.upper_limit - self.mean) / self.sigma
148+
Z = norm.cdf(b) - norm.cdf(a)
149+
return -xp.log(self.sigma) - 0.5 * xp.log(2.0 * np.pi) - xp.log(Z)
150+
136151
def value_for(self, unit, xp=np):
137152
"""
138153
Map a unit value in [0, 1] to a physical value drawn from this truncated Gaussian prior.

autofit/mapper/prior/uniform.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,21 @@ def logpdf(self, x):
106106
Parameters
107107
----------
108108
x
109-
The value at which to evaluate the log PDF.
109+
The value(s) at which to evaluate the log PDF. May be a scalar or a
110+
numpy array.
110111
"""
111-
# TODO: handle x as a numpy array
112-
if x == self.lower_limit:
113-
x += epsilon
114-
elif x == self.upper_limit:
115-
x -= epsilon
112+
# Nudge values sitting exactly on a boundary inwards by epsilon, where the
113+
# PDF is otherwise undefined. The scalar path is kept bit-identical to the
114+
# historical behaviour; the array path vectorises the same snap with
115+
# ``np.where`` (the previous scalar-only ``==`` comparison raised on arrays).
116+
if np.ndim(x) == 0:
117+
if x == self.lower_limit:
118+
x += epsilon
119+
elif x == self.upper_limit:
120+
x -= epsilon
121+
else:
122+
x = np.where(x == self.lower_limit, x + epsilon, x)
123+
x = np.where(x == self.upper_limit, x - epsilon, x)
116124
return self.message.logpdf(x)
117125

118126
def dict(self) -> dict:
@@ -178,6 +186,11 @@ def log_prior_from_value(self, value, xp=np):
178186
in_bounds = (value >= self.lower_limit) & (value <= self.upper_limit)
179187
return xp.where(in_bounds, xp.zeros_like(value), -xp.inf)
180188

189+
def log_normalisation(self, xp=np) -> float:
190+
"""The constant ``-log(upper - lower)`` dropped from ``log_prior_from_value``
191+
(which returns ``0.0``). See ``Prior.log_normalisation``."""
192+
return -xp.log(self.upper_limit - self.lower_limit)
193+
181194
@property
182195
def limits(self) -> Tuple[float, float]:
183196
"""The (lower_limit, upper_limit) bounds of this uniform prior."""

autofit/messages/beta.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ def inv_beta_suffstats(
7474
b
7575
Estimated beta parameter(s) of the Beta distribution.
7676
77-
Warnings
78-
--------
79-
Emits a RuntimeWarning if negative parameters are found, and clamps them to 0.5.
77+
Raises
78+
------
79+
ValueError
80+
If the Newton-Raphson projection produces a negative alpha or beta, which
81+
is not a valid Beta distribution. Previously this was silently warned and
82+
clamped into a local that was immediately overwritten (a no-op).
8083
"""
8184

8285
_lnX, _ln1X = np.ravel(lnX), np.ravel(ln1X)
@@ -94,12 +97,14 @@ def inv_beta_suffstats(
9497
ab += np.linalg.solve(jac, - f)
9598

9699
if np.any(ab < 0):
97-
warnings.warn(
98-
"invalid negative parameters found for inv_beta_suffstats, "
99-
"clampling value to 0.5",
100-
RuntimeWarning
100+
raise ValueError(
101+
"inv_beta_suffstats produced negative Beta parameters "
102+
f"(alpha, beta):\n\n{ab}\n\n"
103+
"A negative alpha or beta is not a valid Beta distribution, so the "
104+
"moment-matching projection has failed. The previous behaviour "
105+
"silently warned and clamped into a local that was immediately "
106+
"overwritten (a no-op), letting the invalid parameters escape."
101107
)
102-
b = np.clip(ab, 0.5, None)
103108

104109
shape = np.shape(lnX)
105110
if shape:

autofit/messages/fixed.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ def sample(self, n_samples: Optional[int] = None) -> np.ndarray:
5454
return self.value
5555
return np.array([self.value])
5656

57-
logpdf_cache = {}
58-
5957
def logpdf(self, x: np.ndarray) -> np.ndarray:
60-
if x.shape not in FixedMessage.logpdf_cache:
61-
FixedMessage.logpdf_cache[x.shape] = np.zeros_like(x)
62-
return FixedMessage.logpdf_cache[x.shape]
58+
# A fixed message contributes zero log-density everywhere. Return a
59+
# fresh zero array each call: the previous class-level ``logpdf_cache``
60+
# was an unbounded dict keyed on shape that also handed back an aliased
61+
# mutable array, so mutating one result silently corrupted later calls.
62+
return np.zeros_like(x)
6363

6464
@cached_property
6565
def mean(self) -> np.ndarray:

autofit/messages/gamma.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,13 @@ def sample(self, n_samples=None):
7676
def from_mode(cls, mode, covariance, **kwargs):
7777
m, V = cls._get_mean_variance(mode, covariance)
7878

79-
alpha = 1 + m ** 2 * V # match variance
80-
beta = alpha / m # match mean
79+
# Match the requested mean m and variance V, consistent with the Normal
80+
# family. For a Gamma, mean = alpha / beta and variance = alpha / beta**2,
81+
# which inverts to alpha = m**2 / V, beta = m / V. The previous
82+
# ``alpha = 1 + m**2 * V`` had the variance the wrong way up (requesting
83+
# variance 0.25 produced 2.0, and 4.0 produced 0.235).
84+
alpha = m ** 2 / V
85+
beta = m / V
8186
return cls(alpha, beta, **kwargs)
8287

8388
def kl(self, dist):

autofit/messages/truncated_normal.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,20 @@ def log_partition(self, xp=np) -> float:
3030
"""
3131
Compute the log-partition function (normalizer) of the truncated Gaussian.
3232
33-
This is the log of the normalization constant Z of the truncated normal:
33+
For the exponential-family interface this is the full cumulant, made of
34+
two parts:
3435
35-
Z = Φ((b - μ)/σ) - Φ((a - μ)/σ)
36+
1. the untruncated Gaussian log-partition ``A(η) = μ²/(2σ²) + log σ``
37+
(identical to ``NormalMessage.log_partition``), and
38+
2. the truncation-mass correction ``log Z`` with
39+
``Z = Φ((b - μ)/σ) - Φ((a - μ)/σ)``, where Φ is the standard normal
40+
CDF and ``[a, b]`` are the truncation bounds.
3641
37-
where Φ is the standard normal CDF and [a, b] are the truncation bounds.
42+
Previously only ``log Z`` was returned, dropping the Gaussian term. That
43+
made the generic exponential-family pdf integrate to
44+
``σ·exp(μ²/2σ²)`` (e.g. 2.27 for the unit case) instead of 1.0 — the path
45+
the EP machinery consumes. Sampling and ``log_prior_from_value`` use a
46+
separate, correct path and were unaffected.
3847
3948
Returns
4049
-------
@@ -43,10 +52,14 @@ def log_partition(self, xp=np) -> float:
4352
"""
4453
from scipy.stats import norm
4554

55+
# Untruncated Gaussian log-partition — see NormalMessage.log_partition.
56+
gaussian = (self.mean ** 2) / (2 * self.sigma ** 2) + xp.log(self.sigma)
57+
4658
a = (self.lower_limit - self.mean) / self.sigma
4759
b = (self.upper_limit - self.mean) / self.sigma
4860
Z = norm.cdf(b) - norm.cdf(a)
49-
return xp.log(Z) if Z > 0 else -xp.inf
61+
log_Z = xp.log(Z) if Z > 0 else -xp.inf
62+
return gaussian + log_Z
5063

5164
log_base_measure = -0.5 * np.log(2 * np.pi)
5265

@@ -472,9 +485,19 @@ def log_prior_from_value(self, value: float, xp=np) -> float:
472485
"""
473486
Compute the log prior probability of a given physical value under this truncated Gaussian prior.
474487
475-
This accounts for truncation by normalizing the Gaussian density over the
476-
interval [lower_limit, upper_limit], returning -inf if the value lies outside
477-
these limits.
488+
Returns ``log p(value)`` in density form, up to an additive constant, and
489+
``-inf`` for values outside ``[lower_limit, upper_limit]``.
490+
491+
The value-independent constants ``-log(sigma) - 0.5*log(2*pi)`` (the
492+
Gaussian normaliser) and ``-log(Z)`` (the truncation mass) are dropped, so
493+
this matches the constant-dropping convention already used by
494+
``NormalMessage.log_prior_from_value`` (and Uniform / LogUniform / LogGaussian).
495+
Previously this method returned the *fully normalised* truncated density,
496+
making it the odd one out — harmless to posterior shape (constants cancel
497+
in the Metropolis ratio and nested samplers use the unit-cube transform),
498+
but inconsistent for anyone reading absolute ``log_prior`` values or doing
499+
evidence arithmetic. The dropped constant is recoverable via
500+
``TruncatedGaussianPrior.log_normalisation``.
478501
479502
Parameters
480503
----------
@@ -483,33 +506,18 @@ def log_prior_from_value(self, value: float, xp=np) -> float:
483506
484507
Returns
485508
-------
486-
The log prior probability of the given value, or -inf if outside truncation bounds.
509+
The log prior density at the given value up to an additive constant, or
510+
-inf if outside the truncation bounds.
487511
"""
488512

489-
if xp.__name__.startswith("jax"):
490-
import jax.scipy.stats as jstats
491-
norm = jstats.norm
492-
else:
493-
from scipy.stats import norm
494-
495-
# Normalization term (truncation)
496-
a = (self.lower_limit - self.mean) / self.sigma
497-
b = (self.upper_limit - self.mean) / self.sigma
498-
Z = norm.cdf(b) - norm.cdf(a)
499-
500-
# Log pdf
513+
# Density-form quadratic, constants dropped (see docstring / NormalMessage).
501514
z = (value - self.mean) / self.sigma
502-
log_pdf = (
503-
-0.5 * z ** 2
504-
- xp.log(self.sigma)
505-
- 0.5 * xp.log(2.0 * xp.pi)
506-
)
507-
log_trunc_pdf = log_pdf - xp.log(Z)
515+
log_pdf = -0.5 * z ** 2
508516

509-
# Truncation mask (must be xp.where for JAX)
517+
# Truncation mask (must be xp.where for JAX).
510518
in_bounds = (self.lower_limit <= value) & (value <= self.upper_limit)
511519

512-
return xp.where(in_bounds, log_trunc_pdf, -xp.inf)
520+
return xp.where(in_bounds, log_pdf, -xp.inf)
513521

514522
def __str__(self):
515523
"""

0 commit comments

Comments
 (0)