Source code for pybnf.priors.bijector

"""Support-aware unconstraining bijections for gradient-based sampling (ADR-0059 item 5).

The gradient-free samplers (``am`` / ``dream`` / ``p_dream``) keep a draw inside a
constrained prior support with **reflecting bounds** -- a Metropolis device with no HMC
analogue. NUTS, by contrast, samples an *unbounded* momentum-driven trajectory; at a
``-inf`` support wall (a positive-support prior's ``u <= 0``, ``beta``'s ``[0, 1]``, a
:class:`~pybnf.priors.truncated.TruncatedPrior` box) the leapfrog integrator diverges.

The fix is a **change of variables**: HMC samples an unconstrained ``z in R``, mapped to the
prior's support by a monotone bijection ``u = b(z)``, and the target gains the Jacobian term
``log|b'(z)|``. Because ``b`` lands strictly inside the open support for every finite ``z``,
the ``-inf`` wall (and the divergence it caused) is never reached.

The transform is a property of the support *shape*, not the family, so it lives here once and
keys purely on ``support()`` (the ``(lo, hi)`` finiteness pattern) -- the same family-agnostic
ethos as :mod:`~pybnf.priors.truncated`. Four cases cover the whole catalog:

===================  ===========================  =========================  ===================================
support ``(lo,hi)``  ``u = b(z)``                 ``z = b^{-1}(u)``          ``log|b'(z)|``
===================  ===========================  =========================  ===================================
``(-inf, inf)``      ``z``                        ``u``                      ``0``
``(lo, inf)``        ``lo + exp(z)``              ``log(u - lo)``            ``z``
``(-inf, hi)``       ``hi - exp(z)``              ``log(hi - u)``            ``z``
``(lo, hi)``         ``lo + (hi-lo)*sigmoid(z)``  ``logit((u-lo)/(hi-lo))``  ``log(hi-lo)+logsig(z)+logsig(-z)``
===================  ===========================  =========================  ===================================

Each bijector exposes a numpy ``to_unconstrained`` / ``to_constrained`` / ``logdet`` (host-side
init seeding and draw-writing) and a JAX-traceable ``to_constrained_jax`` / ``logdet_jax`` (the
differentiable target the ``hmc`` sampler composes), mirroring :class:`~pybnf.priors.scale.Scale`'s
``inverse`` / ``inverse_jax`` split. ``jax`` is imported lazily so importing this module (and the
whole ``priors`` package) never requires the optional ``pybnf[jax]`` extra.
"""

import numpy as np
from scipy import special as _sp

#: How far inside an open boundary an init point is nudged before ``to_unconstrained``
#: (``log`` / ``logit`` of an on-the-boundary ``u`` is ``-inf``). The latin-hypercube
#: seed sits strictly inside support in the common case; this only guards the corner where
#: a quantile lands exactly on an edge, so the NUTS start position stays finite.
_EDGE_EPS = 1e-9


[docs] def bijector_for_support(lo, hi): """The unconstraining bijector for a prior whose sampling-space support is ``(lo, hi)``. Keys on the finiteness of each endpoint (the support *shape*), so one factory serves every family: an unbounded support is the identity, one finite endpoint is a log/exp half-line map, two finite endpoints are the logit/sigmoid box map.""" lo_finite, hi_finite = np.isfinite(lo), np.isfinite(hi) if not lo_finite and not hi_finite: return IdentityBijector() if lo_finite and not hi_finite: return LowerBoundedBijector(float(lo)) if hi_finite and not lo_finite: return UpperBoundedBijector(float(hi)) return BoxBijector(float(lo), float(hi))
[docs] class Bijector: """A monotone unconstraining map ``u = b(z)`` from ``R`` onto a prior's support. ``to_constrained`` is ``b`` (``z -> u``); ``to_unconstrained`` is ``b^{-1}`` (``u -> z``, used once to seed NUTS from the latin-hypercube start point); ``logdet`` is the change-of-variables Jacobian ``log|b'(z)|`` added to the HMC target. The ``*_jax`` peers are the JAX-traceable forms the differentiable log-density uses."""
[docs] def to_unconstrained(self, u): """Map a support-space ``u`` to the unconstrained ``z`` (numpy; init seeding).""" raise NotImplementedError
[docs] def to_constrained(self, z): """Map an unconstrained ``z`` back to support-space ``u`` (numpy; draw-writing).""" raise NotImplementedError
[docs] def logdet(self, z): """``log|du/dz|`` at ``z`` (numpy; un-Jacobians the recorded posterior density).""" raise NotImplementedError
[docs] def to_constrained_jax(self, z): """JAX-traceable :meth:`to_constrained` (the differentiable target).""" raise NotImplementedError
[docs] def logdet_jax(self, z): """JAX-traceable :meth:`logdet` (the differentiable Jacobian term).""" raise NotImplementedError
[docs] class IdentityBijector(Bijector): """Unbounded support ``(-inf, inf)``: ``u = z``, no Jacobian. The real-support families (normal/laplace/cauchy/student_t/gumbel/logistic, and any ``lognormal_var`` whose prior is a normal in ``u``) need no reparameterization, so HMC samples them in ``u`` directly."""
[docs] def to_unconstrained(self, u): return u
[docs] def to_constrained(self, z): return z
[docs] def logdet(self, z): return 0.0
[docs] def to_constrained_jax(self, z): return z
[docs] def logdet_jax(self, z): import jax.numpy as jnp return jnp.zeros_like(z)
[docs] class LowerBoundedBijector(Bijector): """Half-line support ``(lo, inf)``: ``u = lo + exp(z)``, ``log|b'(z)| = z``. Covers the positive families (gamma/exponential/chisquare/rayleigh/weibull/inv_gamma/the half-* scale priors), whose ``lo`` is ``0``. ``exp(z) > 0`` for every finite ``z``, so ``u > lo`` strictly -- the ``u <= lo`` wall is unreachable.""" def __init__(self, lo): self.lo = lo
[docs] def to_unconstrained(self, u): return np.log(max(u - self.lo, _EDGE_EPS))
[docs] def to_constrained(self, z): return self.lo + np.exp(z)
[docs] def logdet(self, z): return float(z)
[docs] def to_constrained_jax(self, z): import jax.numpy as jnp return self.lo + jnp.exp(z)
[docs] def logdet_jax(self, z): return z
[docs] class UpperBoundedBijector(Bijector): """Half-line support ``(-inf, hi)``: ``u = hi - exp(z)``, ``log|b'(z)| = z``. The mirror of :class:`LowerBoundedBijector` (no catalog family is upper-only by default, but a one-sided upper :class:`~pybnf.priors.truncated.TruncatedPrior` lands here); ``u < hi`` strictly.""" def __init__(self, hi): self.hi = hi
[docs] def to_unconstrained(self, u): return np.log(max(self.hi - u, _EDGE_EPS))
[docs] def to_constrained(self, z): return self.hi - np.exp(z)
[docs] def logdet(self, z): return float(z)
[docs] def to_constrained_jax(self, z): import jax.numpy as jnp return self.hi - jnp.exp(z)
[docs] def logdet_jax(self, z): return z
[docs] class BoxBijector(Bijector): """Finite box support ``(lo, hi)``: ``u = lo + (hi-lo)*sigmoid(z)``, with ``log|b'(z)| = log(hi-lo) + logsigmoid(z) + logsigmoid(-z)``. Covers ``uniform`` / ``loguniform`` boxes, ``beta``'s ``[0, 1]``, and a two-sided :class:`~pybnf.priors.truncated.TruncatedPrior`. ``sigmoid(z) in (0, 1)`` for every finite ``z``, so ``u`` stays strictly inside the box -- both walls are unreachable.""" def __init__(self, lo, hi): self.lo = lo self.hi = hi self.width = hi - lo
[docs] def to_unconstrained(self, u): # logit of the in-box fraction; clip a hair off the edges so an on-the-boundary # init quantile maps to a finite z rather than +-inf. frac = (u - self.lo) / self.width frac = min(max(frac, _EDGE_EPS), 1.0 - _EDGE_EPS) return float(_sp.logit(frac))
[docs] def to_constrained(self, z): return self.lo + self.width * _sp.expit(z)
[docs] def logdet(self, z): # log(width) + log sigmoid(z) + log sigmoid(-z); log_expit is the overflow-safe # log-sigmoid (scipy >= 1.8). return float(np.log(self.width) + _sp.log_expit(z) + _sp.log_expit(-z))
[docs] def to_constrained_jax(self, z): import jax.nn as jnn return self.lo + self.width * jnn.sigmoid(z)
[docs] def logdet_jax(self, z): import jax.nn as jnn import jax.numpy as jnp return jnp.log(self.width) + jnn.log_sigmoid(z) + jnn.log_sigmoid(-z)