Source code for pybnf.noise.laplace

"""Laplace observation noise (ADR-0011, ADR-0021)."""

import numpy as np

from ..printing import PybnfError
from .base import NoiseModel
from .location import MEDIAN
from .scale import LINEAR


[docs] class Laplace(NoiseModel): """Laplace (double-exponential) observation noise -- the heavy-tailed sibling of Gaussian. Its negative log-likelihood penalizes the residual *linearly* (``|prediction - observation| / b``) rather than quadratically, so it is the maximum-likelihood model behind least-absolute-deviation fitting and is robust to outliers; PEtab v2's ``noiseDistribution = laplace``. Configured by the same two axes as ``Gaussian`` -- the scale its noise is additive on and the location interpretation of the prediction. On the linear scale Laplace is symmetric, so mean and median coincide and the location axis is trivial (the default ``LINEAR``, ``MEDIAN``); on a log scale (``log-laplace``) the distribution is asymmetric in the original space, so ``location = mean`` picks up the **Laplace** moment correction (``mean_offset``), which is *not* Gaussian's (#419). ``data_fit`` is ``|mu - forward(obs)| / b`` with the scale ``b`` as the noise parameter; ``log_normalizer`` is ``log(2 b)``. With a fixed ``b`` the caller drops the normalizer; with a free ``b`` (the ``laplace`` objfunc's ``b__FREE``) it keeps the full ``nll`` -- the ``log(2 b)`` term is exactly what prevents the fit from driving ``b -> inf``. Value oracle: ``scipy.stats.laplace.logpdf``. """ noise_params = ('scale',) def __init__(self, additive_on=LINEAR, location=MEDIAN): self.additive_on = additive_on self.location = location
[docs] def with_location(self, location): return type(self)(additive_on=self.additive_on, location=location)
[docs] def mean_offset(self, noise): """The **Laplace** moment correction (distinct from Gaussian's, #419). For ``base**Laplace(mu, b)`` the mean is ``base**mu / (1 - b**2 t**2)`` with ``t = ln(base)`` (the Laplace MGF ``E[e**(tL)] = e**(mu t)/(1 - b**2 t**2)``, which exists only for ``|b t| < 1``), so recovering ``mu`` from a prediction taken to be that mean subtracts ``-ln(1 - b**2 t**2)/t`` in additive space. 0 on the linear scale (symmetric: mean = median).""" t = self.additive_on.ln_base if t == 0.0: return 0.0 bt = noise * t if bt >= 1.0: raise PybnfError( f"log-Laplace mean-centering needs b*ln(base) < 1 for the mean to " f"exist (the tail is too heavy otherwise): got scale b={noise}, " f"ln(base)={t} (b*ln(base)={bt} >= 1). Use location = median, or a " f"smaller Laplace scale b.") return -np.log(1.0 - bt ** 2.) / t
[docs] def d_mean_offset_d_noise(self, noise): """``d(mean_offset)/d b = 2 b t / (1 - b**2 t**2)`` (0 on the linear scale), ``t = ln(base)``. The offset's own dependence on the scale ``b``, folded into the estimated-scale gradient column when a free Laplace scale centers a MEAN on a log scale (#385). Shares ``mean_offset``'s ``b*ln(base) < 1`` domain (the mean exists only there); off the log-mean corner (linear scale or median centering) it is 0, so the column reduces byte-for-byte.""" t = self.additive_on.ln_base if t == 0.0: return 0.0 bt = noise * t if bt >= 1.0: raise PybnfError( f"log-Laplace mean-centering needs b*ln(base) < 1 for the mean to exist: got " f"scale b={noise}, ln(base)={t} (b*ln(base)={bt} >= 1). Use location = median, " f"or a smaller Laplace scale b.") return 2. * noise * t / (1. - bt ** 2.)
def _mu(self, prediction, noise): """The additive-space location parameter for ``prediction``.""" return self.additive_on.forward(prediction) - self.location.offset(self, noise)
[docs] def data_fit(self, prediction, observation, noise, extra=None): residual = self._mu(prediction, noise) - self.additive_on.forward(observation) return abs(residual) / noise
[docs] def d_data_fit_d_prediction(self, prediction, observation, noise, extra=None): """``d(data_fit)/d(prediction) = sign(mu - forward(obs))/b * forward'(pred)`` (#454). The Laplace data fit ``|mu - forward(obs)|/b`` is non-smooth where ``mu == forward(obs)``; PyBNF takes the **subgradient 0** there -- ``np.sign(0) == 0``, the symmetric least- absolute-deviation choice -- documented like layer E's positivity decision. The derivative is undefined at the kink, so the optimizer must not rely on it being exactly the slope of a finite difference straddling the kink; an FD acceptance oracle therefore evaluates away from it (data offset from the prediction). ``forward'(pred) = 1`` on the linear scale, and the offset is prediction-independent, so MEAN and MEDIAN agree.""" residual = self._mu(prediction, noise) - self.additive_on.forward(observation) return np.sign(residual) / noise * self.additive_on.dforward(prediction)
[docs] def residual(self, prediction, observation, noise, extra=None): """**Laplace has no exact least-squares residual -- it stays scalar-only** (#459). The sqrt-loss residual ``sign(z) * sqrt(2 * data_fit) = sign(z) * sqrt(2|z|/b)`` (``z = mu - forward(obs)``) is ``~ sqrt|z|`` near the residual zero: a **cusp with infinite slope at z=0** (and the IRLS weight ``1/|z| -> inf`` there too). L1 / least-absolute-deviation is *inherently* not cleanly least-squares, so -- unlike Student-t, whose sqrt-loss residual is smooth through 0 (#459) -- Laplace carries no residual a trust-region solver could minimize. Its gradient rides the scalar :meth:`d_data_fit_d_prediction` path instead (:meth:`~pybnf.objective.LikelihoodObjective.has_least_squares_residual` returns False for Laplace, so this is never reached on the normal path); a smoothed pseudo-Huber surrogate would be a separate, explicitly opt-in approximation of the loss, not exposed here.""" raise NotImplementedError( 'Laplace has no exact least-squares residual: sqrt(2*data_fit) ~ sqrt|z| is a cusp ' 'with infinite slope at z=0 (L1 / least-absolute-deviation is not cleanly ' 'least-squares), so a Laplace observable rides the scalar data-fit gradient, not the ' 'residual-Jacobian (#459). Use the L-BFGS-B path for a Laplace objective.')
[docs] def d_residual_d_prediction(self, prediction, observation, noise, extra=None): """Laplace has no least-squares residual Jacobian (the residual itself has an infinite-slope cusp at ``z=0``); it stays scalar-only -- see :meth:`residual` (#459).""" raise NotImplementedError( 'Laplace has no least-squares residual Jacobian; it rides the scalar data-fit ' 'gradient (#459). See Laplace.residual.')
[docs] def d_nll_d_noise_params(self, prediction, observation, noise, extra=None): """``{'scale': -sign(R) * d(offset)/d b / b - |R|/b**2 + 1/b}`` with ``R = mu - forward(obs)`` -- the estimated-scale gradient column (#451/#454/#385). The ``-|R|/b**2`` is ``d(data_fit)/d b`` holding the offset fixed and ``1/b`` is ``d(log(2 b))/d b`` (the normalizer that keeps a free Laplace scale from running to infinity); the ``-sign(R) * (d offset/d b)/b`` is the coupling that appears when a MEAN is centered on a log scale, where ``mu = forward(pred) - offset`` and the offset depends on ``b`` (``d offset/d b = 2 b t/(1 - b**2 t**2)``). ``d(offset)/d b`` is 0 for the MEDIAN and on the linear scale, so this reduces to ``-|R|/b**2 + 1/b`` byte-for-byte off that corner.""" residual = self._mu(prediction, noise) - self.additive_on.forward(observation) return {'scale': -np.sign(residual) * self.location.d_offset_d_noise(self, noise) / noise - abs(residual) / noise ** 2. + 1. / noise}
[docs] def log_normalizer(self, noise): return np.log(2. * noise)