# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
# Contributors:
# INITIAL AUTHORS - initial API and implementation and/or initial
# documentation
# :author: Matthias De Lozzo
# OTHER AUTHORS - MACROSCOPIC CHANGES
"""The interface to SciPy-based probability distributions.
The :class:`.SPDistribution` class is a concrete class
inheriting from :class:`.Distribution` which is an abstract one.
SP stands for `scipy <https://docs.scipy.org/doc/scipy/tutorial/stats.html>`_
which is the library it relies on.
The :class:`.SPDistribution` of a given uncertain variable is built
from mandatory arguments:
- a variable name,
- a distribution name recognized by SciPy,
- a set of parameters provided as a dictionary
of keyword arguments named as the arguments of the scipy constructor
of this distribution.
.. warning::
The distribution parameters must be provided according to the signature
of the scipy classes. `Access the scipy documentation
<https://docs.scipy.org/doc/scipy/reference/stats.html>`_.
The constructor has also optional arguments:
- a variable dimension (default: 1),
- a standard representation of these parameters
(default: use ``parameters``).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import ClassVar
import scipy
import scipy.stats as sp_stats
from numpy import array
from numpy import ndarray
from numpy import vstack
from gemseo.uncertainty.distributions.distribution import Distribution
from gemseo.uncertainty.distributions.distribution import StandardParametersType
from gemseo.uncertainty.distributions.scipy.composed import SPComposedDistribution
from gemseo.utils.constants import READ_ONLY_EMPTY_DICT
from gemseo.utils.string_tools import MultiLineString
from gemseo.utils.string_tools import pretty_str
if TYPE_CHECKING:
from collections.abc import Generator
from collections.abc import Iterable
from collections.abc import Mapping
from numpy.random import RandomState
from gemseo.uncertainty.distributions.composed import ComposedDistribution
LOGGER = logging.getLogger(__name__)
SP_WEBSITE = "https://docs.scipy.org/doc/scipy/reference/stats.html"
[docs]
class SPDistribution(Distribution):
"""A SciPy-based probability distribution.
Create a probability distribution for an uncertain variable
from its dimension and distribution names and properties.
.. seealso::
:class:`.SPExponentialDistribution`
:class:`.SPNormalDistribution`
:class:`.SPTriangularDistribution`
:class:`.SPUniformDistribution`
Examples:
>>> from gemseo.uncertainty.distributions.scipy.distribution import (
... SPDistribution,
... )
>>> distribution = SPDistribution("x", "expon", {"loc": 3, "scale": 1 / 2.0})
>>> print(distribution)
expon(loc=3, scale=0.5)
"""
COMPOSED_DISTRIBUTION_CLASS: ClassVar[type[ComposedDistribution] | None] = (
SPComposedDistribution
)
def __init__( # noqa: D107
self,
variable: str = Distribution.DEFAULT_VARIABLE_NAME,
interfaced_distribution: str = "uniform",
parameters: Mapping[str, Any] = READ_ONLY_EMPTY_DICT,
dimension: int = 1,
standard_parameters: StandardParametersType | None = None,
) -> None:
"""
Args:
standard_parameters: The parameters of the probability distribution
used for string representation only
(use ``parameters`` for computation).
If ``None``, use ``parameters`` instead.
For instance,
let us consider the interfaced SciPy distribution ``"uniform"``.
Then,
the string representation of
``SPDistribution("x", "uniform", parameters, 1, {"min": 1, "max": 3})``
with ``parameters={"loc": 1, "scale": 2}``
is ``"uniform(max=3, min=1)"``
while the string representation of
``SPDistribution("x", "uniform", parameters)``
is ``"uniform(loc=1, scale=2)"``.
""" # noqa: D205 D212 D415
super().__init__(
variable,
interfaced_distribution,
parameters,
dimension,
standard_parameters,
)
self.marginals = self.__create_distributions(
self.distribution_name, self.parameters
)
msg = MultiLineString()
msg.indent()
msg.add("Mathematical support: {}", self.support)
msg.add("Numerical range: {}", self.range)
LOGGER.debug("%s", msg)
[docs]
def compute_samples( # noqa: D102
self,
n_samples: int = 1,
random_state: None | int | Generator | RandomState = None,
) -> ndarray:
"""
Args:
random_state: The SciPy random state.
""" # noqa: D205, D212
return vstack([m.rvs(n_samples, random_state) for m in self.marginals]).T
[docs]
def compute_cdf( # noqa: D102
self,
vector: Iterable[float],
) -> ndarray:
return array([
self.marginals[index].cdf(value) for index, value in enumerate(vector)
])
[docs]
def compute_inverse_cdf( # noqa: D102
self,
vector: Iterable[float],
) -> ndarray:
return array([
self.marginals[index].ppf(value) for index, value in enumerate(vector)
])
@property
def mean(self) -> ndarray: # noqa: D102
return array([marginal.mean() for marginal in self.marginals])
@property
def standard_deviation(self) -> ndarray: # noqa: D102
return array([marginal.std() for marginal in self.marginals])
def __create_distributions(
self,
distribution: str,
parameters: Mapping[str, int | float],
) -> list[scipy.stats.rv_continuous]:
"""Instantiate a SciPy distribution for each random variable component.
Args:
distribution: The name of the distribution.
parameters: The parameters of the distribution.
Returns:
The marginal SciPy distributions.
"""
try:
create_distribution = getattr(sp_stats, distribution)
except BaseException:
msg = f"{distribution} is an unknown scipy distribution."
raise ValueError(msg) from None
try:
parameters = parameters or {}
create_distribution(**parameters)
distributions = [create_distribution(**parameters)] * self.dimension
except BaseException:
msg = (
f"Arguments are wrong in {distribution}({pretty_str(parameters)}); "
f"more details on: {SP_WEBSITE}."
)
raise ValueError(msg) from None
self.__set_bounds(distributions)
return distributions
def __set_bounds(
self,
distributions: Iterable[scipy.stats.rv_continuous],
extrema_level: float = 1e-12,
) -> None:
"""Set mathematical and numerical bounds (= support and range).
Args:
distributions: The distributions.
extrema_level: A quantile level corresponding to the lower
bound of the numerical random variable range.
"""
self.math_lower_bound = []
self.math_upper_bound = []
self.num_lower_bound = []
self.num_upper_bound = []
for distribution in distributions:
dist_range = distribution.interval(1.0)
self.math_lower_bound.append(dist_range[0])
self.math_upper_bound.append(dist_range[1])
l_b = distribution.ppf(extrema_level)
u_b = distribution.ppf(1 - extrema_level)
self.num_lower_bound.append(l_b)
self.num_upper_bound.append(u_b)
self.math_lower_bound = array(self.math_lower_bound)
self.math_upper_bound = array(self.math_upper_bound)
self.num_lower_bound = array(self.num_lower_bound)
self.num_upper_bound = array(self.num_upper_bound)
def _pdf( # noqa: D102
self,
index: int,
) -> Callable:
def pdf(
point: float,
) -> float:
"""Probability Density Function (PDF).
Args:
point: An evaluation point.
Returns:
The PDF value at the evaluation point.
"""
return self.marginals[index].pdf(point)
return pdf
def _cdf( # noqa: D102
self,
index: int,
) -> Callable:
def cdf(
level: float,
) -> float:
"""Cumulative Density Function (CDF).
Args:
level: A probability level.
Returns:
The CDF value for the probability level.
"""
return self.marginals[index].cdf(level)
return cdf