Source code for pybnf.noise.scale

"""The additive-noise-scale axis (ADR-0011, ADR-0022): the scale a noise model's
noise is additive on.

This is **distinct from** a free parameter's ``priors.Scale`` (the *Parameter
Scale* -- the space a parameter is sampled in). They are different domain concepts
(see the CONTEXT.md glossary), so they are deliberately separate code -- but they
share one log-base convention (ADR-0022): **a bare "log" means log10 everywhere in
PyBNF**, matching ``logvar`` / ``loguniform_var`` / ``lognormal_var`` and the
proposal arithmetic. Natural log is never implied; it exists only as the explicit
``LN``. So this axis has three named members: ``LINEAR``, ``LOG10``, and ``LN`` --
there is no ambiguous bare ``LOG``. ``Gaussian`` noise additive on ``LINEAR`` is
ordinary additive error; additive on ``LOG10`` is (log10) lognormal error.

A scale exposes ``ln_base`` -- the natural log of its base (the ``t`` in
``X = base**L = e**(t*L)``): 0 on the linear scale, ``ln 10`` on log10, 1 on
natural log. That is all a family's moment-generating function needs to convert an
additive-space location into the original-space mean. The moment correction itself
is **family-specific** -- Gaussian's ``t*sigma**2/2`` differs from Laplace's
``-ln(1 - b**2 t**2)/t`` (#419) -- so it lives on each ``NoiseModel`` family (their
``mean_offset``), not here. The scale owns only the transform (``forward``) and its
base (``ln_base``).
"""

import numpy as np

_LN10 = np.log(10.0)


[docs] class AdditiveNoiseScale: """Maps a value into the space a noise model's noise is additive on. ``ln_base`` is the natural log of the scale's base -- the only thing a family's moment correction needs from the scale (the family owns the correction itself). """ ln_base = 0.0
[docs] def forward(self, x): """Transform an original-space value into the additive space.""" raise NotImplementedError
[docs] def dforward(self, x): """``d forward(x)/dx`` -- the plain first derivative of the scale transform, the per-point seam a gradient needs (#452): the standardized residual on a log scale is ``rho = (forward(pred) - forward(obs))/sigma``, so ``d rho/d pred = forward'(pred)/sigma``. ``1`` on the linear scale (identity), ``1/(x ln 10)`` on log10, ``1/x`` on natural log -- so the linear short-circuits to the historical ``1/sigma`` byte-for-byte. ``forward``'s sibling for the optimizer, as :meth:`log_abs_dforward` is for a normalized density: this is the *signed* derivative the chain rule multiplies, that one the *log-absolute* change-of- variables term a density carries; deliberately the Additive Noise Scale axis, not a parameter's ``priors.Scale`` (the CONTEXT.md glossary keeps them apart).""" raise NotImplementedError
[docs] def log_abs_dforward(self, x): """``log|d forward(x)/dx|`` -- the change-of-variables Jacobian term that turns an additive-space log-density into the original-space (data-space) log-density, ``log p_X(x) = log p_L(forward(x)) + log|d forward/dx|`` (ADR-0056). It is 0 on the linear scale (identity transform) and the only thing besides a family's own normalizer that a *normalized* per-point likelihood (LOO/WAIC) needs that the optimizer/sampler never did -- the Jacobian is constant in the parameters, so it cancels in every accept ratio and PyBNF's ``nll`` omits it. ``forward``'s sibling: ``forward`` moves the value, this accounts for the density's stretch under that move.""" raise NotImplementedError
class _Linear(AdditiveNoiseScale): ln_base = 0.0 def forward(self, x): return x def dforward(self, x): # Identity transform: d x/d x = 1. return 1.0 def log_abs_dforward(self, x): # Identity transform: |d x/d x| = 1, log 1 = 0. return 0.0 class _Log10(AdditiveNoiseScale): ln_base = _LN10 def forward(self, x): # A non-positive value has no place on the log scale: log10(x<=0) is # mathematically -inf/undefined. A model *prediction* can land there from an # ODE solver's tiny sub-tolerance undershoot in a deep decay tail (a value # like -1e-10 that is physically zero). Map any x<=0 to -inf rather than let # numpy return NaN: the residual then blows up and the lognormal NLL is +inf # -- a hard penalty that steers the optimizer away -- instead of a NaN score # that silently corrupts the fit (observations are validated positive # upstream, so this guards the prediction). `[()]` keeps a scalar in, scalar # out (0-d array -> numpy scalar), preserving the plain-log10 return type. with np.errstate(divide='ignore', invalid='ignore'): return np.where(x > 0, np.log10(x), -np.inf)[()] def dforward(self, x): # d log10(x)/dx = 1/(x ln 10). return 1.0 / (x * _LN10) def log_abs_dforward(self, x): # d log10(x)/dx = 1/(x ln 10); log|.| = -log(x) - log(ln 10). return -np.log(x) - np.log(_LN10) class _Ln(AdditiveNoiseScale): ln_base = 1.0 def forward(self, x): # Same non-positive-prediction guard as _Log10.forward (natural-log twin): # x<=0 -> -inf, so the NLL is +inf rather than NaN. with np.errstate(divide='ignore', invalid='ignore'): return np.where(x > 0, np.log(x), -np.inf)[()] def dforward(self, x): # d ln(x)/dx = 1/x. return 1.0 / x def log_abs_dforward(self, x): # d ln(x)/dx = 1/x; log|.| = -log(x). return -np.log(x) LINEAR = _Linear() LOG10 = _Log10() LN = _Ln()