"""Marginal distribution interfaces and numerically stable tail mappings."""
import warnings
from collections.abc import Mapping
from copy import deepcopy
from types import MappingProxyType
from typing import Any, Self
from numpy.typing import ArrayLike
from matplotlib.axes import Axes
import numpy as np
from scipy import special as sp
import matplotlib.pyplot as plt
from scipy.stats._distn_infrastructure import rv_frozen
from ..errors import ModelError
__all__ = ["StdNormal", "Constant", "Distribution"]
_LOG_HALF = np.log(0.5)
_TINY = np.finfo(float).tiny
_LOG_TINY = np.log(_TINY)
# Above u = 3 the complement 1 - Phi(u) would lose more than 1e-13 of its
# relative precision, so the survival function is used from there on.
_U_SWITCH = 3.0
_P_SWITCH = float(sp.ndtr(_U_SWITCH))
def _log1mexp(a):
"""Return ``log(1 - exp(a))`` for ``a <= 0`` without cancellation.
Uses ``log(-expm1(a))`` for ``a > -log 2`` and ``log1p(-exp(a))``
otherwise (Mächler, 2012, "Accurately computing log(1 - exp(-|a|))").
"""
a = np.asarray(a, dtype=float)
with np.errstate(divide="ignore", invalid="ignore", under="ignore"):
return np.where(a > -np.log(2), np.log(-np.expm1(a)), np.log1p(-np.exp(a)))[()]
def _call(function, values):
"""Call a distribution method on an array of points.
Arrays go to the method at once. A single point is passed as a float,
and a method written only for scalars (it raises ``TypeError`` for an
array) is called point by point, so subclass overrides from 1.x keep
working. Returns a 1-D array.
"""
values = np.asarray(values, dtype=float)
if values.size == 1:
return np.array(function(float(values.ravel()[0])), dtype=float, ndmin=1)
try:
return np.array(function(values), dtype=float, ndmin=1)
except TypeError:
return np.array([function(float(v)) for v in values.ravel()], dtype=float)
def _piecewise(values, upper, lower_fn, upper_fn):
"""Apply ``lower_fn`` where ``upper`` is false and ``upper_fn`` where true.
Each function receives a 1-D array of the selected values, so only one
tail formula is evaluated per element. Scalars give NumPy scalars.
"""
values = np.asarray(values, dtype=float)
flat = values.ravel()
upper = np.broadcast_to(upper, values.shape).ravel()
out = np.empty(flat.shape)
if not upper.all():
out[~upper] = _call(lower_fn, flat[~upper])
if upper.any():
out[upper] = _call(upper_fn, flat[upper])
return out.reshape(values.shape)[()]
def _solve_log_tail(log_tail, targets, start, step):
"""Solve ``log_tail(x) = target`` where the tail probability underflows.
``log_tail`` is a log-CDF (``step < 0``) or a log-survival function
(``step > 0``). It exceeds every target at ``start`` and decreases in
the direction of ``step``. The bracket is widened by doubling, then
bisected until its ends are adjacent doubles. Points past the support,
where the logarithm is ``-inf`` or undefined, count as past the
solution; a solution beyond the double range gives ``+-inf``.
"""
targets = np.asarray(targets, dtype=float)
direction = np.sign(step)
inner = np.full(targets.shape, float(start))
outer = np.full(targets.shape, direction * np.inf)
if not np.isfinite(start):
return outer
width = abs(float(step))
todo = np.ones(targets.shape, dtype=bool)
while todo.any() and np.isfinite(width):
index = np.flatnonzero(todo)
trial = inner[index] + direction * width
with np.errstate(all="ignore"):
short = log_tail(trial) > targets[index]
inner[index[short]] = trial[short]
outer[index[~short]] = trial[~short]
todo[index[~short]] = False
width *= 2
found = np.isfinite(outer)
lower, upper = inner[found], outer[found]
for _ in range(1100):
middle = 0.5 * (lower + upper)
if not np.any((middle != lower) & (middle != upper)):
break
with np.errstate(all="ignore"):
short = log_tail(middle) > targets[found]
lower = np.where(short, middle, lower)
upper = np.where(short, upper, middle)
outer[found] = 0.5 * (lower + upper)
return outer
[docs]
class StdNormal:
"""Standard normal distribution (mean 0, standard deviation 1).
A lightweight implementation using ``scipy.special`` error functions,
avoiding the overhead of a full ``scipy.stats`` distribution object.
This class is used internally by the Nataf transformation and by the
marginal distribution mappings (``x_to_u`` / ``u_to_x``).
"""
[docs]
@staticmethod
def pdf(u: float | np.ndarray) -> float | np.ndarray:
"""Probability density function of the standard normal.
Parameters
----------
u : float or array_like
Quantile(s) in standard normal space.
Returns
-------
float or ndarray
Density value(s).
"""
p = np.exp(-0.5 * u**2) / np.sqrt(2 * np.pi)
return p
[docs]
@staticmethod
def cdf(u: ArrayLike) -> float | np.ndarray:
"""Cumulative distribution function of the standard normal.
Parameters
----------
u : float or array_like
Quantile(s) in standard normal space.
Returns
-------
float or ndarray
Probability value(s) in [0, 1].
"""
p = sp.ndtr(u)
return p
[docs]
@staticmethod
def ppf(p: ArrayLike) -> float | np.ndarray:
"""Percent-point (inverse CDF) of the standard normal.
Parameters
----------
p : float or array_like
Probability value(s) in (0, 1).
Returns
-------
float or ndarray
Quantile(s) in standard normal space.
"""
u = sp.ndtri(p)
return u
def _uses_native_parameters(distribution, mean, std, **native):
"""Whether a constructor received native parameters instead of moments.
A distribution takes either its mean and standard deviation or every one
of its native parameters, never a mixture of the two.
"""
kind = type(distribution).__name__
names = " and ".join(native)
given = [name for name, value in native.items() if value is not None]
if not given:
if mean is None or std is None:
raise TypeError(f"{kind} needs mean and std, or {names}")
return False
if mean is not None or std is not None:
raise TypeError(f"{kind} takes mean and std, or {names}, not both")
if len(given) < len(native):
raise TypeError(f"{kind} needs mean and std, or {names}")
return True
[docs]
class Constant:
"""A deterministic (non-random) variable in the limit-state function.
Constants are included in the stochastic model but are not treated as
random variables — they carry a fixed value through every evaluation of
the limit-state function.
Parameters
----------
name : str
Name of the constant (must match a keyword argument of the limit
state function).
value : float
The fixed value.
"""
def __init__(self, name: str, value: float) -> None:
self.name = name
self._value = value
[docs]
def get_name(self) -> str:
"""Return the constant name."""
return self.name
@property
def value(self) -> float:
"""The fixed value."""
return self._value
def __repr__(self) -> str:
return f"Constant({self.name!r}, value={self._value!r})"
[docs]
class Distribution:
r"""Base class for all probability distributions used in reliability analysis.
Subclasses typically construct a ``scipy.stats`` frozen distribution
object (``dist_obj``) and pass it to this base class, which then
delegates ``pdf``, ``cdf``, ``ppf``, and the Nataf-space
transformations (``x_to_u``, ``u_to_x``, ``jacobian``) to it.
Subclasses that do not wrap a SciPy distribution must override the
transformation and Jacobian methods directly, or provide the tail
functions below (see, e.g., :class:`Maximum`).
**Tail accuracy.** Every distribution provides ``cdf``, ``sf``,
``logcdf``, ``logsf``, ``pdf``, ``logpdf``, ``ppf`` and ``isf``, each
accurate in its own tail. ``u_to_x`` and ``x_to_u`` evaluate the tail
that the point lies in, so a probability near one is never formed:
``x = F^{-1}(Phi(u))`` for ``u <= 0`` and ``x = Fbar^{-1}(Phi(-u))``
for ``u > 0``. Beyond the smallest normal double (``|u|`` above about
37.5) the log-probability is inverted instead, in closed form where
one exists and otherwise by solving the log-CDF or log-survival
function. Jacobians switch to log densities when the densities
underflow. Subclasses that override ``cdf`` or ``ppf`` should override
``sf`` or ``isf`` as well; the fallbacks ``1 - cdf`` and ``ppf(1 - q)``
lose the upper tail.
Parameters
----------
name : str
Name of the random variable. Must match a keyword argument of
the limit-state function.
dist_obj : scipy.stats.rv_frozen, optional
A frozen SciPy distribution. When provided, ``mean`` and
``std`` are computed from the distribution automatically.
mean : float, optional
Mean of the distribution (required if *dist_obj* is ``None``).
std : float, optional
Standard deviation (required if *dist_obj* is ``None``).
start_point : float, optional
Starting point for iterative search algorithms (defaults to
the mean).
Attributes
----------
name : str
Name of the random variable.
dist_type : str
Human-readable label set by each subclass (e.g. ``"Normal"``).
"""
std_normal = StdNormal()
def __init__(
self,
name: str = "",
dist_obj: rv_frozen | None = None,
mean: float | None = None,
std: float | None = None,
start_point: float | None = None,
) -> None:
self.name = name
self.dist_type = "BaseCls"
# This is the key object that is to be defined in derived classes that
# are using the base class functionality
self.dist_obj = dist_obj
self._update_moments(mean, std)
self._set_start_point(start_point)
@property
def mean(self) -> float:
"""Mean of the distribution."""
return self._mean
@property
def std(self) -> float:
"""Standard deviation of the distribution."""
return self._std
@property
def start_point(self) -> float:
"""Starting point of the design-point search; the mean by default."""
return self._start_point
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r}, mean={self.mean:.6g}, std={self.std:.6g})"
def _update_moments(self, mean=None, std=None):
if self.dist_obj is not None:
self._mean = self.dist_obj.mean()
self._std = self.dist_obj.std()
elif mean is None or std is None:
raise ModelError("Mean and std dev must be defined in derived classes")
else:
self._mean = mean
self._std = std
if not np.isfinite(self.std) or self.std <= 0:
raise ModelError("Std. deviation must be a positive noninfinite number.")
[docs]
def get_name(self) -> str:
"""Return the random-variable name."""
return self.name
def _set_start_point(self, start_point=None):
if start_point is None:
self._start_point = self.mean
else:
self._start_point = start_point
# The following can be overridden by derived classes to implement more
# efficient calculations where desirable
[docs]
def pdf(self, x: ArrayLike) -> float | np.ndarray:
"""Probability density function.
Parameters
----------
x : float or array_like
Value(s) in physical space.
Returns
-------
float or ndarray
Density value(s).
"""
return self.dist_obj.pdf(x)
[docs]
def logpdf(self, x: ArrayLike) -> float | np.ndarray:
"""Log density, ``-inf`` where the density is zero."""
if self.dist_obj is not None and type(self).pdf is Distribution.pdf:
return self.dist_obj.logpdf(x)
with np.errstate(divide="ignore"):
return np.log(self.pdf(x))
[docs]
def cdf(self, x: ArrayLike) -> float | np.ndarray:
"""Cumulative distribution function.
Parameters
----------
x : float or array_like
Value(s) in physical space.
Returns
-------
float or ndarray
Probability value(s) in [0, 1].
"""
return self.dist_obj.cdf(x)
[docs]
def sf(self, x: ArrayLike) -> float | np.ndarray:
"""Survival function ``1 - F(x)``, accurate in the upper tail."""
if self._scipy_cdf():
return self.dist_obj.sf(x)
return 1 - self.cdf(x)
[docs]
def logcdf(self, x: ArrayLike) -> float | np.ndarray:
"""Logarithm of the CDF, accurate in both tails.
Below the median the logarithm of the CDF itself (or SciPy's
``logcdf``) is used; above it, ``log1p(-sf(x))`` keeps the
difference from zero.
"""
c = np.asarray(self.cdf(x), dtype=float)
return _piecewise(
x, c > 0.5, self._lower_logcdf, lambda v: np.log1p(-self.sf(v))
)
[docs]
def logsf(self, x: ArrayLike) -> float | np.ndarray:
"""Logarithm of the survival function, accurate in both tails."""
c = np.asarray(self.cdf(x), dtype=float)
return _piecewise(
x, c > 0.5, lambda v: np.log1p(-self.cdf(v)), self._upper_logsf
)
def _lower_logcdf(self, x):
"""Log-CDF below the median."""
if self._scipy_cdf():
return self.dist_obj.logcdf(x)
with np.errstate(divide="ignore"):
return np.log(self.cdf(x))
def _upper_logsf(self, x):
"""Log-survival function above the median."""
if self._scipy_cdf():
return self.dist_obj.logsf(x)
with np.errstate(divide="ignore"):
return np.log(self.sf(x))
def _scipy_cdf(self):
"""Whether the CDF family can be delegated to ``dist_obj``."""
return self.dist_obj is not None and type(self).cdf is Distribution.cdf
[docs]
def ppf(self, u: ArrayLike) -> float | np.ndarray:
"""Percent-point function (inverse CDF).
Parameters
----------
u : float or array_like
Probability value(s) in (0, 1).
Returns
-------
float or ndarray
Quantile(s) in physical space.
"""
return self.dist_obj.ppf(u)
[docs]
def isf(self, q: ArrayLike) -> float | np.ndarray:
"""Inverse survival function, accurate for small upper-tail ``q``."""
if self.dist_obj is not None and type(self).ppf is Distribution.ppf:
return self.dist_obj.isf(q)
return self.ppf(1 - np.asarray(q, dtype=float))
def _ppf_log(self, logp):
"""Quantile at which ``logcdf`` equals ``logp``, for any ``logp <= 0``."""
logp = np.asarray(logp, dtype=float)
return _piecewise(
logp,
logp > _LOG_HALF,
self._lower_quantile_log,
lambda v: self._upper_quantile_log(_log1mexp(v)),
)
def _isf_log(self, logq):
"""Quantile at which ``logsf`` equals ``logq``, for any ``logq <= 0``."""
logq = np.asarray(logq, dtype=float)
return _piecewise(
logq,
logq > _LOG_HALF,
self._upper_quantile_log,
lambda v: self._lower_quantile_log(_log1mexp(v)),
)
def _lower_quantile_log(self, logp):
"""Lower-tail quantile from a log-probability of at most log(1/2).
Below the smallest normal double the log-CDF is solved directly.
Subclasses with a closed form override this.
"""
logp = np.atleast_1d(np.asarray(logp, dtype=float))
with np.errstate(under="ignore"):
x = _call(self.ppf, np.exp(logp))
deep = (logp < _LOG_TINY) & np.isfinite(logp)
if deep.any():
x[deep] = _solve_log_tail(
self.logcdf, logp[deep], self.ppf(_TINY), -self.std
)
return x
def _upper_quantile_log(self, logq):
"""Upper-tail quantile from a log-probability of at most log(1/2)."""
logq = np.atleast_1d(np.asarray(logq, dtype=float))
with np.errstate(under="ignore"):
x = _call(self.isf, np.exp(logq))
deep = (logq < _LOG_TINY) & np.isfinite(logq)
if deep.any():
x[deep] = _solve_log_tail(self.logsf, logq[deep], self.isf(_TINY), self.std)
return x
[docs]
def u_to_x(self, u: ArrayLike) -> float | np.ndarray:
"""Transform from standard normal space to physical space.
Applies the marginal Nataf mapping ``x = F^{-1}(Phi(u))``. Above
``u = 3`` it is evaluated as ``Fbar^{-1}(Phi(-u))``, so the
probability passed on is never rounded towards one; far into either
tail the log-probability is used (see :meth:`_tail_quantiles`).
Parameters
----------
u : float or array_like
Value(s) in standard normal (u) space.
Returns
-------
float or ndarray
Corresponding value(s) in physical (x) space.
"""
u = np.asarray(u, dtype=float)
if u.ndim == 0:
value = float(u)
upper = value > _U_SWITCH
probability = float(sp.ndtr(-value if upper else value))
if probability >= 1e-8:
quantile = self.isf if upper else self.ppf
return np.asarray(quantile(probability), dtype=float).reshape(())[()]
shape = u.shape
u = u.ravel()
p = sp.ndtr(u)
upper = np.flatnonzero(u > _U_SWITCH)
lower = np.flatnonzero(u < -5.0)
central = ~((u > _U_SWITCH) | (u < -5.0))
x = np.empty(u.shape)
if central.any():
x[central] = _call(self.ppf, p[central])
for index, sign, quantile, log_tail, log_quantile in (
(upper, -1, self.isf, self.logsf, self._upper_quantile_log),
(lower, 1, self.ppf, self.logcdf, self._lower_quantile_log),
):
if index.size:
z = sign * u[index]
prob = sp.ndtr(z)
with warnings.catch_warnings():
# SciPy warns when its numerical inverse gives up; that
# result is checked and solved again
warnings.simplefilter("ignore", RuntimeWarning)
xt = _call(quantile, prob)
x[index] = self._tail_quantiles(
xt, prob, z, log_quantile, log_tail, -sign
)
return x.reshape(shape)[()]
def _tail_quantiles(self, x, prob, z, quantile_log, log_tail, direction):
"""Refine quantiles whose tail probability is below 1e-8.
``prob = Phi(z)`` is the tail probability of each point. Below the
smallest normal double the quantile comes from ``log Phi(z)``.
Numerical inverse CDFs (SciPy's beta, for example) can also stall
or return NaN far into a tail, so each tail quantile is checked
against the log-CDF or log-survival function and solved again if
it misses by more than 1e-8 in relative log-probability.
"""
index = np.flatnonzero(prob < 1e-8)
if index.size == 0:
return x
target = sp.log_ndtr(z[index])
deep = prob[index] < _TINY
if deep.any():
x[index[deep]] = quantile_log(target[deep])
with np.errstate(all="ignore"):
got = _call(log_tail, x[index])
bad = ~(np.abs(got - target) <= 1e-8 * np.abs(target))
if bad.any():
x[index[bad]] = _solve_log_tail(
log_tail, target[bad], self.ppf(0.5), direction * self.std
)
return x
[docs]
def x_to_u(self, x: ArrayLike) -> float | np.ndarray:
"""Transform from physical space to standard normal space.
Applies the marginal Nataf mapping ``u = Phi^{-1}(F(x))``, as
``u = -Phi^{-1}(Fbar(x))`` above ``u = 3``, with log probabilities
where these underflow.
Parameters
----------
x : float or array_like
Value(s) in physical (x) space.
Returns
-------
float or ndarray
Corresponding value(s) in standard normal (u) space.
"""
x = np.asarray(x, dtype=float)
if x.ndim == 0:
value = float(x)
probability = float(np.asarray(self.cdf(value)).item())
if probability > _P_SWITCH:
survival = float(np.asarray(self.sf(value)).item())
if survival < _TINY:
return -sp.ndtri_exp(float(np.asarray(self.logsf(value)).item()))
return -sp.ndtri(survival)
if probability < _TINY:
return sp.ndtri_exp(float(np.asarray(self.logcdf(value)).item()))
return sp.ndtri(probability)
shape = x.shape
x = x.ravel()
c = _call(self.cdf, x)
u = sp.ndtri(c)
deep = c < _TINY
if deep.any():
u[deep] = sp.ndtri_exp(_call(self.logcdf, x[deep]))
upper = c > _P_SWITCH
if upper.any():
s = _call(self.sf, x[upper])
v = -sp.ndtri(s)
tail = s < _TINY
if tail.any():
v[tail] = -sp.ndtri_exp(_call(self.logsf, x[upper][tail]))
u[upper] = v
return u.reshape(shape)[()]
[docs]
def jacobian(self, u: ArrayLike, x: ArrayLike) -> np.ndarray:
"""Diagonal Jacobian of the marginal x-to-u transformation.
Returns a diagonal matrix ``J`` where the diagonal entry is
``f_X(x) / phi(u)`` (Lemaire, eq. 4.9). This is assembled
into the full Jacobian by the :class:`Transformation` class.
The ratio is formed from log densities where either density
underflows.
Parameters
----------
u : float or array_like
Value(s) in standard normal space.
x : float or array_like
Corresponding value(s) in physical space.
Returns
-------
ndarray
Diagonal Jacobian matrix of shape ``(n, n)`` where *n* is
the length of the input arrays.
"""
u = np.atleast_1d(np.asarray(u, dtype=float))
x = np.atleast_1d(np.asarray(x, dtype=float))
pdf1 = np.array(self.pdf(x), dtype=float, ndmin=1)
pdf2 = self.std_normal.pdf(u)
with np.errstate(divide="ignore", invalid="ignore"):
ratio = pdf1 / pdf2
tail = (pdf1 < 1e-300) | (pdf2 < 1e-300)
if tail.any():
log_phi = -0.5 * u[tail] ** 2 - 0.5 * np.log(2 * np.pi)
ratio[tail] = np.exp(self.logpdf(x[tail]) - log_phi)
J = np.diag(ratio)
return J
[docs]
def sample(self, n: int = 1000) -> np.ndarray:
"""Draw random samples from the distribution.
Uses inverse-transform sampling via ``ppf``.
Parameters
----------
n : int, optional
Number of samples (default 1000).
Returns
-------
ndarray
Array of shape ``(n,)`` with sampled values.
"""
u = np.random.rand(n)
samples = self.ppf(u)
return samples
[docs]
def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes:
"""Plot the probability density function.
Parameters
----------
ax : matplotlib.axes.Axes, optional
Axes to plot on. A new figure is created if ``None``.
**kwargs
Additional keyword arguments forwarded to
``ax.plot()``.
Returns
-------
matplotlib.axes.Axes
The axes containing the plot.
"""
# auto-range
samples = self.sample()
x = np.linspace(np.min(samples), np.max(samples), 100)
show = False
if ax is None:
show = True
_, ax = plt.subplots()
ax.plot(x, self.pdf(x), label=self.name, **kwargs)
ax.legend()
if show:
plt.show()
return ax
# ------------------------------------------------------------------
# Sensitivity-analysis support
# ------------------------------------------------------------------
@property
def sensitivity_params(self) -> dict[str, float]:
r"""Distribution parameters for which sensitivities are computed.
Returns a dict ``{param_name: current_value}`` listing every
parameter with respect to which :math:`\partial\beta/\partial\theta`
should be evaluated.
The default implementation returns ``{"mean": μ, "std": σ}``,
which is appropriate for most distributions. Distributions with
additional parameters of interest (e.g. the GEV shape parameter)
should override this property to include them.
Fixed constructor settings such as bounds are separate from these
sensitivity coordinates. Native reconstruction parameters can be
another representation of the same law: sensitivity analysis perturbs
the declared coordinates together, keeping other moments fixed.
Returns
-------
dict
``{param_name: current_value}``
"""
return {"mean": self.mean, "std": self.std}
# Native coordinates replaced when the caller supplies physical moments.
_native_parameters = ()
def _parameter_values(self):
return {"mean": self.mean, "std": self.std}
@property
def parameters(self) -> Mapping[str, object]:
"""Read-only snapshot of keyword arguments that rebuild this marginal.
``type(dist)(**dist.parameters)`` reproduces its law, name and start
point. Built-ins use native parameters where moment fitting could
lose precision. Nested marginals and SciPy objects are independent
copies, so mutating a snapshot never changes the source distribution.
Custom marginals may override this property with their complete
constructor mapping. Sensitivity coordinates are specified separately
by :attr:`sensitivity_params`.
"""
return MappingProxyType(
deepcopy(
{
"name": self.name,
**self._parameter_values(),
"start_point": self.start_point,
}
)
)
[docs]
def with_parameters(self, **changes: object) -> Self:
"""Rebuild an independent marginal, replacing constructor parameters.
Parameters
----------
**changes
Replacements for keys of :attr:`parameters`. Built-ins supporting
moment sensitivities also accept ``mean`` and ``std``; supplying
either switches to moment construction, holding the other moment
and fixed bounds/shape constant. Native coordinates and moments
cannot be supplied together. The start point remains unchanged
unless explicitly replaced (``None`` selects the new mean).
Returns
-------
Distribution
Independent instance of the same type, including nested marginals.
An empty replacement reproduces the distribution.
Raises
------
TypeError
A replacement is unknown, or mixes moments and native coordinates.
ModelError, ValueError
Constructor parameters do not define a valid distribution.
"""
parameters = dict(self.parameters)
allowed = parameters.keys() | self.sensitivity_params.keys()
unknown = changes.keys() - allowed
if unknown:
raise TypeError(
f"Unknown distribution parameters: {', '.join(sorted(unknown))}"
)
if changes.keys() & {"mean", "std"} and self._native_parameters:
if changes.keys() & set(self._native_parameters):
raise TypeError("Specify moments or native parameters, not both")
for key in self._native_parameters:
parameters.pop(key, None)
parameters.update(mean=self.mean, std=self.std)
parameters.update(changes)
return type(self)(**deepcopy(parameters))
def _dmoments_dtheta(self, param):
r"""Derivatives of mean and standard deviation w.r.t. a parameter.
Returns ``(∂μ/∂θ, ∂σ/∂θ)`` for the parameter named *param*.
This is needed by :func:`~pystra._numerics.integration.drho0_dtheta` to
evaluate the general form of :math:`\partial h/\partial\theta`
(Eq. 24 of Bourinet 2017).
For ``"mean"`` and ``"std"`` the derivatives are exact:
``(1, 0)`` and ``(0, 1)`` respectively. For any other parameter
(e.g. a shape parameter) central finite differences via
:meth:`with_parameters` are used.
Parameters
----------
param : str
Parameter name (a key of :attr:`sensitivity_params`).
Returns
-------
tuple of float
``(∂μ/∂θ, ∂σ/∂θ)``
"""
if param == "mean":
return (1.0, 0.0)
elif param == "std":
return (0.0, 1.0)
val = self.sensitivity_params[param]
h = max(abs(val) * 1e-6, 1e-10)
d_plus = self.with_parameters(**{**self.sensitivity_params, param: val + h})
d_minus = self.with_parameters(**{**self.sensitivity_params, param: val - h})
return (
(d_plus.mean - d_minus.mean) / (2 * h),
(d_plus.std - d_minus.std) / (2 * h),
)
[docs]
def cdf_gradient(self, x: ArrayLike) -> dict[str, float | np.ndarray]:
r"""Derivatives of the CDF w.r.t. each sensitivity parameter.
Returns ``∂F_X(x)/∂θ`` for every parameter listed by
:attr:`sensitivity_params`. The base-class implementation uses
central finite differences on the CDF via :meth:`with_parameters`.
Before computing derivatives, a reconstruction sanity check
verifies that :meth:`with_parameters` (with no overrides) reproduces
the current distribution. This catches both constructor
failures (e.g. composite distributions) and silent mismatches
(e.g. distributions whose extra constructor arguments are not
stored in :attr:`parameters`).
Subclasses may override this with analytical expressions for
better accuracy and performance (see :class:`Normal` and
:class:`Lognormal`).
Parameters
----------
x : float
Evaluation point in physical space.
Returns
-------
dict
``{param_name: ∂F/∂θ}`` for each parameter in
:attr:`sensitivity_params`.
Raises
------
ValueError
If the distribution cannot be faithfully reconstructed by
:meth:`with_parameters`.
"""
if not self.sensitivity_params:
raise ValueError(
f"{type(self).__name__} does not support sensitivity analysis"
)
# Validate that reconstruction reproduces this distribution.
try:
test = self.with_parameters()
except Exception as e:
raise ValueError(
f"{type(self).__name__} does not support sensitivity "
f"analysis. Define parameters on the subclass "
f"or override with_parameters()."
) from e
# Use a scalar test point for validation (x may be an array
# when called from drho0_dtheta with quadrature grids)
x_test = float(self.mean + 0.5 * self.std)
ref_cdf = float(self.cdf(x_test))
test_cdf = float(test.cdf(x_test))
if abs(test_cdf - ref_cdf) > 1e-6 * (1 + abs(ref_cdf)):
raise ValueError(
f"{type(self).__name__}.with_parameters() does not faithfully "
f"reconstruct the distribution (CDF mismatch at "
f"x={x_test}: original={ref_cdf:.8g}, "
f"copy={test_cdf:.8g}). "
f"Define a complete parameters mapping on the subclass."
)
result = {}
for param, val in self.sensitivity_params.items():
h = max(abs(val) * 1e-6, self.std * 1e-8)
d_plus = self.with_parameters(**{**self.sensitivity_params, param: val + h})
d_minus = self.with_parameters(
**{**self.sensitivity_params, param: val - h}
)
result[param] = (d_plus.cdf(x) - d_minus.cdf(x)) / (2 * h)
return result
[docs]
def set_location(self, loc: float = 0) -> None:
"""Update the location parameter of the underlying SciPy distribution.
After updating, ``mean`` and ``std`` are recomputed. This is
available for callers that need an in-place parameter update.
Parameters
----------
loc : float, optional
New location parameter (default 0).
Raises
------
ModelError
If the distribution does not wrap a SciPy frozen distribution.
"""
if isinstance(self.dist_obj, rv_frozen):
pdict = self.dist_obj.kwds
pdict["loc"] = loc
self._update_moments()
else:
raise ModelError("Distribution is not a SciPy object")
[docs]
def set_scale(self, scale: float = 1) -> None:
"""Update the scale parameter of the underlying SciPy distribution.
After updating, ``mean`` and ``std`` are recomputed. This is
available for callers that need an in-place parameter update.
Parameters
----------
scale : float, optional
New scale parameter (default 1).
Raises
------
ModelError
If the distribution does not wrap a SciPy frozen distribution.
"""
if isinstance(self.dist_obj, rv_frozen):
pdict = self.dist_obj.kwds
pdict["scale"] = scale
self._update_moments()
else:
raise ModelError("Distribution is not a SciPy object")