Skip to content

Genextreme #84

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Sep 28, 2022
28 changes: 26 additions & 2 deletions pymc_experimental/distributions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
from pymc_experimental.distributions import histogram_utils
from pymc_experimental.distributions.histogram_utils import histogram_approximation
# Copyright 2020 The PyMC Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# coding: utf-8
"""
Experimental probability distributions for stochastic nodes in PyMC.
"""

from pymc_experimental.distributions.continuous import (
GenExtreme,
)

__all__ = [
"GenExtreme",
]
218 changes: 218 additions & 0 deletions pymc_experimental/distributions/continuous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Copyright 2020 The PyMC Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# coding: utf-8
"""
Experimental probability distributions for stochastic nodes in PyMC.

The imports from pymc are not fully replicated here: add imports as necessary.
"""

from typing import List, Tuple
import aesara
import aesara.tensor as at
import numpy as np
from scipy import stats

from aesara.tensor.random.op import RandomVariable
from pymc.distributions.distribution import Continuous
from aesara.tensor.var import TensorVariable
from pymc.aesaraf import floatX
from pymc.distributions.dist_math import check_parameters


class GenExtremeRV(RandomVariable):
name: str = "Generalized Extreme Value"
ndim_supp: int = 0
ndims_params: List[int] = [0, 0, 0]
dtype: str = "floatX"
_print_name: Tuple[str, str] = ("Generalized Extreme Value", "\\operatorname{GEV}")

def __call__(
self, mu=0.0, sigma=1.0, xi=0.0, size=None, **kwargs
) -> TensorVariable:
return super().__call__(mu, sigma, xi, size=size, **kwargs)

@classmethod
def rng_fn(
cls,
rng: np.random.RandomState,
mu: np.ndarray,
sigma: np.ndarray,
xi: np.ndarray,
size: Tuple[int, ...],
) -> np.ndarray:
# Notice negative here, since remainder of GenExtreme is based on Coles parametrization
return stats.genextreme.rvs(
c=-xi, loc=mu, scale=sigma, random_state=rng, size=size
)


gev = GenExtremeRV()


class GenExtreme(Continuous):
r"""
Univariate Generalized Extreme Value log-likelihood

The cdf of this distribution is

.. math::

G(x \mid \mu, \sigma, \xi) = \exp\left[ -\left(1 + \xi z\right)^{-\frac{1}{\xi}} \right]

where

.. math::

z = \frac{x - \mu}{\sigma}

and is defined on the set:

.. math::

\left\{x: 1 + \xi\left(\frac{x-\mu}{\sigma}\right) > 0 \right\}.

Note that this parametrization is per Coles (2001), and differs from that of
Scipy in the sign of the shape parameter, :math:`\xi`.

.. plot::

import matplotlib.pyplot as plt
import numpy as np
import scipy.stats as st
import arviz as az
plt.style.use('arviz-darkgrid')
x = np.linspace(-10, 20, 200)
mus = [0., 4., -1.]
sigmas = [2., 2., 4.]
xis = [-0.3, 0.0, 0.3]
for mu, sigma, xi in zip(mus, sigmas, xis):
pdf = st.genextreme.pdf(x, c=-xi, loc=mu, scale=sigma)
plt.plot(x, pdf, label=rf'$\mu$ = {mu}, $\sigma$ = {sigma}, $\xi$={xi}')
plt.xlabel('x', fontsize=12)
plt.ylabel('f(x)', fontsize=12)
plt.legend(loc=1)
plt.show()


======== =========================================================================
Support * :math:`x \in [\mu - \sigma/\xi, +\infty]`, when :math:`\xi > 0`
* :math:`x \in \mathbb{R}` when :math:`\xi = 0`
* :math:`x \in [-\infty, \mu - \sigma/\xi]`, when :math:`\xi < 0`
Mean * :math:`\mu + \sigma(g_1 - 1)/\xi`, when :math:`\xi \neq 0, \xi < 1`
* :math:`\mu + \sigma \gamma`, when :math:`\xi = 0`
* :math:`\infty`, when :math:`\xi \geq 1`
where :math:`\gamma` is the Euler-Mascheroni constant, and
:math:`g_k = \Gamma (1-k\xi)`
Variance * :math:`\sigma^2 (g_2 - g_1^2)/\xi^2`, when :math:`\xi \neq 0, \xi < 0.5`
* :math:`\frac{\pi^2}{6} \sigma^2`, when :math:`\xi = 0`
* :math:`\infty`, when :math:`\xi \geq 0.5`
======== =========================================================================

Parameters
----------
mu: float
Location parameter.
sigma: float
Scale parameter (sigma > 0).
xi: float
Shape parameter
scipy: bool
Whether or not to use the Scipy interpretation of the shape parameter
(defaults to `False`).

References
----------
.. [Coles2001] Coles, S.G. (2001).
An Introduction to the Statistical Modeling of Extreme Values
Springer-Verlag, London

"""

rv_op = gev

@classmethod
def dist(cls, mu=0, sigma=1, xi=0, scipy=False, **kwargs):
# If SciPy, use its parametrization, otherwise convert to standard
if scipy:
xi = -xi
mu = at.as_tensor_variable(floatX(mu))
sigma = at.as_tensor_variable(floatX(sigma))
xi = at.as_tensor_variable(floatX(xi))

return super().dist([mu, sigma, xi], **kwargs)

def logp(value, mu, sigma, xi):
"""
Calculate log-probability of Generalized Extreme Value distribution
at specified value.

Parameters
----------
value: numeric
Value(s) for which log-probability is calculated. If the log probabilities for multiple
values are desired the values must be provided in a numpy array or Aesara tensor

Returns
-------
TensorVariable
"""
scaled = (value - mu) / sigma

logp_expression = at.switch(
at.isclose(xi, 0),
-at.log(sigma) - scaled - at.exp(-scaled),
-at.log(sigma)
- ((xi + 1) / xi) * at.log1p(xi * scaled)
- at.pow(1 + xi * scaled, -1 / xi),
)
# bnd = mu - sigma/xi
return check_parameters(
logp_expression,
1 + xi * (value - mu) / sigma > 0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constraint depends on mu so it should probably be in a switch(constraint, logp, -inf). Usually value-dependent constraints are treated elemwise while parameter based constraints are treated blockwise with check_parameters (if one fails, everything fails), and we raise an error outside of sampling.

Suggested change
1 + xi * (value - mu) / sigma > 0,
1 + xi * (value - mu) / sigma > 0,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've implemented this now, but not precisely sure the purpose: check_parameters is value-based, while switch(constraint... is tensor-based, right? Anyway, please have a look.

Copy link
Member

@ricardoV94 ricardoV94 Sep 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that the logp is usually defined for every possible value (although it can be zero for some combinations of value parameters, such as a halfnormal evaluated at -1), so it should never "error" out. Logps can however be undefined for some parameter values, such as a halfnormal with negative sigma in which case we error out. We do that distinction in PyMC by using a switch for 0 probability values and check_parameters for parameter constraints

import pymc as pm

print(pm.logp(pm.HalfNormal.dist(), -1.0).eval())  # -np.inf
pm.logp(pm.HalfNormal.dist(sigma=-5), 1).eval()  # ValueError: sigma must be positive

Scipy is similar, but yields nan

import scipy.stats as st

print(st.halfnorm().logpdf(-1.0))  # -inf
print(st.halfnorm(scale=-5).logpdf(1.0))  # nan

Copy link
Contributor Author

@ccaprani ccaprani Sep 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ricardoV94 . I can see this alright. Nevertheless, the tests are failing only in the pymc test environment. Here are two cases:

Allowing xi to be in the R domain (mathematically true, but not in practice). The npt.assert_almost_equal is an absolute test of equality defaulting to 6 decimal places, and so we end up with silly outcomes like below, where the relative error is at the 61 - 47 = 15th order of magnitude down! This surely should be accepted?

AssertionError:
Arrays are not almost equal to 6 decimals
{'mu': array(1.), 'sigma': array(0.01), 'xi': array(-0.01), 'value': array(-2.1)}
Mismatched elements: 1 / 1 (100%)
Max absolute difference: 2.39777612e+47
Max relative difference: 1.26305703e-14
 x: array(-1.898391e+61)
 y: array(-1.898391e+61)

You can see the check from numpy here. It's worth noting that the numpy docs don't recommend using assert_almost_equal . For example, assert_allclose would allow a relative tolerance to be specified, rather than an absolute one (in fact both or either).

Restricting the domain of xi to the practical range of [-1,+1]. In this case running check_logp gives:

File ~/anaconda3/envs/pymc4-dev/lib/python3.10/site-packages/_pytest/outcomes.py:
196, in fail(reason, pytrace, msg)
    194 __tracebackhide__ = True
    195 reason = _resolve_msg_to_reason("fail", reason, msg)
--> 196 raise Failed(msg=reason, pytrace=pytrace)

Failed: test_params={'mu': array(-2.1), 'sigma': array(0.01), 'xi': TensorConstan
t{-2.0}}, valid_value=-2.1

where it is checking for an invalid value for xi (-2). But note that it passes when called directly:

In [15]: μ,σ,ξ,x = -2.1,0.01,-2.0,-2.1  # fails check_parameters with xi = R Doma
    ...: in
    ...: logp = pm.logp(GenExtreme.dist(mu=μ,sigma=σ,xi=ξ), x).eval()
    ...: print(logp)
3.605170185988091

and calling check_parameters passes

In [16]: mu = at.as_tensor_variable(floatX(μ))
    ...: sigma = at.as_tensor_variable(floatX(σ))
    ...: xi = at.as_tensor_variable(floatX(ξ))
In [17]: sigma > 0
Out[17]: Elemwise{gt,no_inplace}.0

In [18]: 1 + xi * (x-mu)/sigma > 0,
Out[18]: (Elemwise{gt,no_inplace}.0,)

In [19]: check_parameters(logp,
    ...:     sigma > 0,
    ...:     1 + xi * (x-mu)/sigma > 0,
    ...:     msg="sigma <= 0 or 1+xi*(x-mu)/sigma <= 0")
Out[19]: Check{sigma <= 0 or 1+xi*(x-mu)/sigma <= 0}.0

I did have a look at adding a -1 < xi < 1 check in check-parameters but I couldn't get it tow work with either

  • xi > -1 & xi < 1 as a single check due to the tensors (is there an elementwise and? I couldn't find it in aesara docs), or
  • two checks, ...,x > -1, x<1,...

It would be great to have your input on this, since everything seems to be working fine until it enters the pymc test environment. Hopefully it is just a tweak to one of the conditions in the logp/logc functions perhaps?

Copy link
Member

@ricardoV94 ricardoV94 Sep 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can increase the test tolerange in check_logp/ check_locdf, with the kwarg decimal. Do note that you may only be checking a subset of parameter combinations when testing locally (the default is a random subset of up to 100 combinations), so you can temporarily set n_samples=-1 to make sure all parameter combinations are tested

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ricardoV94 good news: Using at.and_ in check_parameters did the trick for the restricted (but practical) domain of $\xi \in [-1,1]$. All checks now pass!

I can expand to the full domain of $\xi \in \mathbb{R}$ if the logp check is made relative instead of absolute as mentioned above.

In any case, thank you so much for your help on this. I think it's been 11 months in the pipeline, so hopefully we can get it merged?!

Copy link
Member

@ricardoV94 ricardoV94 Sep 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A relative check like the one I mentioned would compare the ratios of the two numbers to a relative accuracy of 1e-6 instead, which makes more sense.

I have thought about this sometime ago, but then faced issues. logps vary widely (and non-linearly) between 1e-700xs and 1e+x. More annoying, it's not uncommon to have logps around 0, in which cases relative comparisons or, on the other hand, more loose absolute comparisons become pretty meaningless.

If you have a more flexible solution that works in general that would be great. Certainly there are more principled ways of doing these accuracy checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ricardoV94 npt.assert_allcose here allows for both a relative, rtol and absolute atol tolerance to be set. Example:

>>> x = np.pi*np.array([1e2,1e-1,1e-6,1e-10,1e-100,1e-700])
>>> y = (1 + 1e-6)*x
>>> npt.assert_allclose(x,y)  # will assert
AssertionError: 
Not equal to tolerance rtol=1e-07, atol=0

Mismatched elements: 5 / 6 (83.3%)
Max absolute difference: 0.00031416
Max relative difference: 9.99999e-07
 x: array([3.141593e+002, 3.141593e-001, 3.141593e-006, 3.141593e-010,
       3.141593e-100, 0.000000e+000])
 y: array([3.141596e+002, 3.141596e-001, 3.141596e-006, 3.141596e-010,
       3.141596e-100, 0.000000e+000])

but this does not assert:

>>> npt.assert_allclose(x,y,rtol=1e-6,atol=1e-10)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do want to try to open a PR on the main repo to change the logic then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# at.switch(xi > 0, value > bnd, value < bnd),
sigma > 0,
)

def logcdf(value, mu, sigma, xi):
"""
Compute the log of the cumulative distribution function for Generalized Extreme Value
distribution at the specified value.

Parameters
----------
value: numeric or np.ndarray or `TensorVariable`
Value(s) for which log CDF is calculated. If the log CDF for
multiple values are desired the values must be provided in a numpy
array or `TensorVariable`.

Returns
-------
TensorVariable
"""
scaled = (value - mu) / sigma
logc_expression = at.switch(
at.isclose(xi, 0), -at.exp(-scaled), -at.pow(1 + xi * scaled, -1 / xi)
)
return check_parameters(logc_expression, 1 + xi * scaled > 0, sigma > 0)

def get_moment(value_var, size, mu, sigma, xi):
r"""
Using the mode, as the mean can be infinite when :math:`\xi > 1`
"""
mode = at.switch(
at.isclose(xi, 0), mu, mu + sigma * (at.pow(1 + xi, -xi) - 1) / xi
)
return at.full(size, mode, dtype=aesara.config.floatX)
50 changes: 50 additions & 0 deletions pymc_experimental/tests/test_distributions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright 2020 The PyMC Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# general imports
import scipy.stats.distributions as ssd

# test support imports from pymc
from pymc.tests.test_distributions import (
R,
Rplus,
Domain,
TestMatchesScipy,
)

# the distributions to be tested
from pymc_experimental.distributions import (
GenExtreme,
)


class TestMatchesScipyX(TestMatchesScipy):
"""
Wrapper class so that tests of experimental additions can be dropped into
PyMC directly on adoption.
"""

def test_genextreme(self):
self.check_logp(
GenExtreme,
R,
{"mu": R, "sigma": Rplus, "xi": Domain([-1, -1, -0.5, 0, 0.5, 1, 1])},
lambda value, mu, sigma, xi: ssd.genextreme.logpdf(value, c=-xi, loc=mu, scale=sigma),
)
self.check_logcdf(
GenExtreme,
R,
{"mu": R, "sigma": Rplus, "xi": Domain([-1, -1, -0.5, 0, 0.5, 1, 1])},
lambda value, mu, sigma, xi: ssd.genextreme.logcdf(value, c=-xi, loc=mu, scale=sigma),
)
51 changes: 51 additions & 0 deletions pymc_experimental/tests/test_distributions_moments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# import aesara
import numpy as np
import pytest
import scipy.stats as st

# from aesara import tensor as at
# from scipy import special

from pymc_experimental.distributions import (
GenExtreme,
)

from pymc.model import Model

from pymc.tests.test_distributions_moments import assert_moment_is_expected


@pytest.mark.parametrize(
"mu, sigma, xi, size, expected",
[
(0, 1, 0, None, 0),
(1, np.arange(1, 4), 0.1, None, np.arange(1, 4) * (1.1 ** -0.1 - 1) / 0.1),
(np.arange(5), 1, 0.1, None, np.arange(5) + (1.1 ** -0.1 - 1) / 0.1),
(
0,
1,
np.linspace(-0.2, 0.2, 6),
None,
((1 + np.linspace(-0.2, 0.2, 6)) ** -np.linspace(-0.2, 0.2, 6) - 1)
/ np.linspace(-0.2, 0.2, 6),
),
(1, 2, 0.1, 5, np.full(5, 1 + 2 * (1.1 ** -0.1 - 1) / 0.1)),
(
np.arange(6),
np.arange(1, 7),
np.linspace(-0.2, 0.2, 6),
(3, 6),
np.full(
(3, 6),
np.arange(6)
+ np.arange(1, 7)
* ((1 + np.linspace(-0.2, 0.2, 6)) ** -np.linspace(-0.2, 0.2, 6) - 1)
/ np.linspace(-0.2, 0.2, 6),
),
),
],
)
def test_genextreme_moment(mu, sigma, xi, size, expected):
with Model() as model:
GenExtreme("x", mu=mu, sigma=sigma, xi=xi, size=size)
assert_moment_is_expected(model, expected)
38 changes: 38 additions & 0 deletions pymc_experimental/tests/test_distributions_random.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright 2020 The PyMC Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# general imports

# test support imports from pymc
from pymc.tests.test_distributions_random import (
BaseTestDistribution,
seeded_scipy_distribution_builder,
)

# the distributions to be tested
import pymc_experimental as pmx


class TestGenExtreme(BaseTestDistribution):
pymc_dist = pmx.GenExtreme
pymc_dist_params = {"mu": 0, "sigma": 1, "xi": -0.1}
expected_rv_op_params = {"mu": 0, "sigma": 1, "xi": -0.1}
# Notice, using different parametrization of xi sign to scipy
reference_dist_params = {"loc": 0, "scale": 1, "c": 0.1}
reference_dist = seeded_scipy_distribution_builder("genextreme")
tests_to_run = [
"check_pymc_params_match_rv_op",
"check_pymc_draws_match_reference",
"check_rv_size",
]