Source code for pybnf.priors.truncated

"""The ``TruncatedPrior`` decorator: a family confined to a finite box in the
sampling space ``u`` (ADR-0020, issue #411).

PEtab v2 truncates a prior by the parameter's ``lowerBound``/``upperBound``, so a
``normal`` prior with finite bounds is a *truncated* normal. PyBNF's
unbounded-support families (Normal, Laplace, and their log forms) could not carry
bounds; this wraps any such family in a decorator that renormalizes the density
over ``[lo_u, hi_u]`` and samples *inside* the box by inverse-CDF.

Truncation is a hybrid concern (ADR-0010): it changes both the family's *support*
and its *normalization*. This decorator owns both halves of the distribution math
in one family-agnostic place, while the owning :class:`~pybnf.pset.FreeParameter`
owns the matching reflecting box (the bounds in ``theta``). The decorator works
for any inner family backed by a ``scipy.stats`` frozen distribution -- it reads
only ``cdf``/``ppf``/``logpdf`` -- so no per-family code is needed (the M2.3
ethos).

The bounds ``lo_u``/``hi_u`` are in sampling space ``u``: the owning
``FreeParameter`` maps the ``theta`` box through its ``Scale`` before wrapping, so
a log-scale parameter truncates in ``log10`` space (the same space its family and
proposal arithmetic already live in).
"""

import numpy as np

from .base import Prior


[docs] class TruncatedPrior(Prior): """An inner :class:`Prior` family confined to ``[lo_u, hi_u]`` in ``u``. Density is the inner family's, renormalized over the box (``logpdf = inner.logpdf - log Z`` inside, ``-inf`` outside). Sampling and the inverse CDF go through the truncated inverse-CDF, so a draw lands inside the box by construction (not an unbounded draw folded back). ``Z`` is parameter-independent, so it cancels in the MCMC acceptance ratio and in MAP optimization (ADR-0003); it is kept only so reported densities are genuinely those of the truncated distribution. """ has_prior = True has_bounded_support = True def __init__(self, inner, lo_u, hi_u): if inner.frozen is None: raise ValueError( "TruncatedPrior requires an inner family with a scipy frozen " "distribution (cannot truncate a NoPrior).") if not (lo_u < hi_u): raise ValueError( f"TruncatedPrior needs lo_u < hi_u, got [{lo_u}, {hi_u}].") self.inner = inner self.lo_u = lo_u self.hi_u = hi_u # The truncated mass, in u-space. cdf(hi) - cdf(lo) > 0 because the inner # families have support on all of R (or, for the log forms, all of R in u). self._cdf_lo = float(inner.frozen.cdf(lo_u)) self._z = float(inner.frozen.cdf(hi_u)) - self._cdf_lo if not self._z > 0.0: raise ValueError( f"TruncatedPrior box [{lo_u}, {hi_u}] carries no prior mass " f"(Z={self._z}); the bounds exclude the family's bulk.") self._log_z = float(np.log(self._z)) # Back-compat passthrough for FreeParameter._distribution: the underlying # (untruncated) scipy frozen, which exposes logpdf/rvs as callers expect. self.frozen = inner.frozen
[docs] def logpdf(self, u): """Renormalized log density: ``inner.logpdf(u) - log Z`` in the box, else ``-inf``. The ``-log Z`` constant cancels for inference but makes the reported value the true truncated-density log-pdf.""" if u < self.lo_u or u > self.hi_u: return -np.inf return self.inner.logpdf(u) - self._log_z
[docs] def logpdf_jax(self, u): """JAX log-density of the truncated family (ADR-0059): the inner family's ``logpdf_jax`` renormalized by ``-log Z`` inside ``[lo_u, hi_u]``, ``-inf`` outside -- the JAX peer of :meth:`logpdf`. The safe-``u`` double-``where`` evaluates the inner density at the box midpoint outside the box (a point guaranteed in-support, since ``Z > 0`` requires the box to carry inner mass), then masks, so ``jax.grad`` stays finite (``0``) outside the box (the same autodiff guard the positive-support families use). This is the *density* of the truncated prior; like :class:`Uniform`, the ``hmc`` sampler reparameterizes the box ``u = lo_u + (hi_u-lo_u) sigmoid(z)`` (:mod:`pybnf.priors.bijector`), so NUTS samples an unbounded ``z`` and a truncation wall is unreachable -- the retained mass is sampled divergence-free even when it leans against a bound (ADR-0059 item 5).""" import jax.numpy as jnp inside = (u >= self.lo_u) & (u <= self.hi_u) su = jnp.where(inside, u, 0.5 * (self.lo_u + self.hi_u)) val = self.inner.logpdf_jax(su) - self._log_z return jnp.where(inside, val, -jnp.inf)
[docs] def rvs(self, rng): """Draw one sample in the box by inverse-CDF: ``ppf(U)`` with ``U ~ U(0,1)`` drawn from the caller's Generator (so the draw lands inside the box by construction, not by folding an unbounded draw back in).""" return self.ppf(float(rng.random()))
[docs] def ppf(self, q): """Truncated inverse CDF over ``[lo_u, hi_u]``: map ``q in [0, 1]`` onto the retained CDF interval, then invert with the inner family's ``ppf``.""" return float(self.inner.frozen.ppf(self._cdf_lo + q * self._z))
[docs] def support(self): return (self.lo_u, self.hi_u)