Adding a distribution#
Marginals inherit from Distribution.
A subclass normally builds a frozen scipy.stats distribution and passes it
as dist_obj to the base constructor. The base class supplies density,
CDF, quantile and marginal normal-space transformations. A custom law can
instead implement these operations directly, including both tails: pdf,
logpdf, cdf, sf, logcdf, logsf, ppf and isf.
The marginal jacobian(u, x) supplies the diagonal derivative du/dx;
the joint transformations expose the directed methods described in
Distributions and dependence.
Continuous transformations require continuous marginals. A reconstruction
contract for ZeroInflated does not make FORM or smooth copula transforms
valid at its atom. Keep the failure convention g <= 0 when evaluating
mixed distributions through a supported simulation route.
Reconstruction and parameter replacement#
Every marginal exposes a read-only
parameters mapping.
It contains the complete keyword arguments needed to rebuild the law, including
its name, start point and any bounds, shift, shape or nested distributions:
import numpy as np
import pystra as ra
original = ra.Beta("R", q=2.3, r=4.1, lower=3, upper=10, start_point=5)
rebuilt = type(original)(**original.parameters)
copied = original.with_parameters()
np.testing.assert_allclose(rebuilt.cdf([4, 5, 6]), original.cdf([4, 5, 6]))
assert copied.start_point == 5
with_parameters() returns
an independent instance with selected parameters replaced. It rejects unknown
keywords. Nested marginals and SciPy frozen objects in built-in parameter
snapshots and copies are detached from the original. The mapping itself is
read-only; its values need not be immutable.
Built-ins with native constructors expose native parameters where fitting
moments again would lose precision. For example, Beta exposes q, r,
lower and upper; GEV exposes loc, scale and shape.
ScipyDist exposes its frozen dist_obj. Maximum, MaxParent and
ZeroInflated expose their nested marginal and exponent or atom probability.
Moment-parameterized marginals also accept mean and std in
with_parameters. Supplying either selects moment construction, preserves
the other moment, and keeps fixed bounds or the GEV shape. A native parameter
update keeps the other native parameters. Mixing the two modes raises
TypeError. The start point is preserved; explicitly passing
start_point=None selects the new mean:
changed = original.with_parameters(mean=6, start_point=None)
assert changed.parameters["lower"] == 3
assert changed.parameters["upper"] == 10
assert changed.start_point == changed.mean
A custom marginal whose constructor differs from (name, mean, std,
start_point=...) must override the public parameters property.
The mapping must reconstruct the current law, and mutable values must be
independent snapshots. For a moment-based subclass with an additional bound:
from types import MappingProxyType
@property
def parameters(self):
return MappingProxyType({
"name": self.name,
"mean": self.mean,
"std": self.std,
"lower": self.lower,
"start_point": self.start_point,
})
The inherited with_parameters passes this mapping, with replacements,
to the constructor. A custom class with alternative parameterizations should
also override with_parameters to define explicitly which coordinates stay
fixed. It must preserve independence, reject unknown or mixed parameter sets,
and reproduce the law when called without replacements. There is no
_ctor_kwargs or _make_copy extension hook in 2.0.
Sensitivity coordinates#
sensitivity_params
is separate metadata: it names the coordinates to perturb for sensitivity
analysis. The default is {"mean": self.mean, "std": self.std}.
GEV adds shape. Beta bounds participate in reconstruction but remain fixed
during moment sensitivity analysis.
Sensitivity analysis passes the complete sensitivity mapping to
with_parameters and changes one coordinate at a time. Thus a GEV shape
sensitivity holds its mean and standard deviation fixed, even though the
reconstruction mapping uses native parameters:
load = ra.GEV("S", 10, 2, shape=0.1)
heavier_tail = load.with_parameters(**{**load.sensitivity_params, "shape": 0.2})
np.testing.assert_allclose(
[heavier_tail.mean, heavier_tail.std], [load.mean, load.std]
)
ScipyDist and the composite marginals have empty sensitivity metadata:
their constructors cannot generically replace a physical mean and standard
deviation. Their copy contract is supported, but cdf_gradient raises a
clear error until a subclass supplies sensitivity coordinates and derivatives.
The base cdf_gradient()
uses central differences. It checks that reconstruction reproduces the law
before perturbing it. Override it with analytic derivatives when available;
the returned mapping must have the same keys as sensitivity_params:
def cdf_gradient(self, x):
z = (x - self.mean) / self.std
density = self.std_normal.pdf(z)
return {
"mean": -density / self.std,
"std": -(x - self.mean) * density / self.std**2,
}
Verification#
Test reconstruction from parameters and through with_parameters
against moments, support, CDFs, tails and quantiles. Include native constructor
inputs, non-default bounds, explicit start points, and independence of mutable
nested objects. Check that changing a parameter preserves its documented fixed
coordinates and leaves the original unchanged.
For continuous marginals, run a small FORM reference case and compare numerical
and closed-form sensitivities. Tolerances should reflect the finite-difference
step and reference problem; shape derivatives may be less stable than moment
derivatives. See tests/test_distribution_copy.py and
tests/test_sensitivity.py for examples.