diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1eaf91bd30..86d9904f7b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,7 +67,6 @@ jobs: tests/distributions/test_multivariate.py - | - tests/distributions/test_bound.py tests/distributions/test_censored.py tests/distributions/test_simulator.py tests/sampling/test_forward.py diff --git a/pymc/distributions/__init__.py b/pymc/distributions/__init__.py index a393257a44..e71e8c3d7d 100644 --- a/pymc/distributions/__init__.py +++ b/pymc/distributions/__init__.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pymc.distributions.bound import Bound from pymc.distributions.censored import Censored from pymc.distributions.continuous import ( AsymmetricLaplace, @@ -129,7 +128,6 @@ "HalfCauchy", "Gamma", "Weibull", - "Bound", "LogNormal", "Lognormal", "HalfStudentT", @@ -192,7 +190,6 @@ "Logistic", "LogitNormal", "Interpolated", - "Bound", "Rice", "Moyal", "Simulator", diff --git a/pymc/distributions/bound.py b/pymc/distributions/bound.py deleted file mode 100644 index ad8ec61f85..0000000000 --- a/pymc/distributions/bound.py +++ /dev/null @@ -1,307 +0,0 @@ -# Copyright 2024 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. -import warnings - -import numpy as np -import pytensor.tensor as pt - -from pytensor.tensor import as_tensor_variable -from pytensor.tensor.random.op import RandomVariable -from pytensor.tensor.variable import TensorVariable - -from pymc.distributions.continuous import BoundedContinuous, bounded_cont_transform -from pymc.distributions.dist_math import check_parameters -from pymc.distributions.distribution import Continuous, Discrete -from pymc.distributions.shape_utils import to_tuple -from pymc.distributions.transforms import _default_transform -from pymc.logprob.basic import logp -from pymc.model import modelcontext -from pymc.util import check_dist_not_registered - -__all__ = ["Bound"] - - -class BoundRV(RandomVariable): - name = "bound" - ndim_supp = 0 - ndims_params = [0, 0, 0] - dtype = "floatX" - _print_name = ("Bound", "\\operatorname{Bound}") - - @classmethod - def rng_fn(cls, rng, distribution, lower, upper, size): - raise NotImplementedError("Cannot sample from a bounded variable") - - -boundrv = BoundRV() - - -class _ContinuousBounded(BoundedContinuous): - rv_op = boundrv - bound_args_indices = [4, 5] - - def logp(value, distribution, lower, upper): - """ - Calculate log-probability of Bounded distribution at specified value. - - Parameters - ---------- - value: numeric - Value for which log-probability is calculated. - distribution: TensorVariable - Distribution which is being bounded - lower: numeric - Lower bound for the distribution being bounded. - upper: numeric - Upper bound for the distribution being bounded. - - Returns - ------- - TensorVariable - """ - res = pt.switch( - pt.or_(pt.lt(value, lower), pt.gt(value, upper)), - -np.inf, - logp(distribution, value), - ) - - return check_parameters( - res, - lower <= upper, - msg="lower <= upper", - ) - - -@_default_transform.register(BoundRV) -def bound_default_transform(op, rv): - return bounded_cont_transform(op, rv, _ContinuousBounded.bound_args_indices) - - -class DiscreteBoundRV(BoundRV): - name = "discrete_bound" - dtype = "int64" - - -discrete_boundrv = DiscreteBoundRV() - - -class _DiscreteBounded(Discrete): - rv_op = discrete_boundrv - - def __new__(cls, *args, **kwargs): - kwargs.setdefault("transform", None) - if kwargs.get("transform") is not None: - raise ValueError("Cannot transform discrete variable.") - return super().__new__(cls, *args, **kwargs) - - def logp(value, distribution, lower, upper): - """ - Calculate log-probability of Bounded distribution at specified value. - - Parameters - ---------- - value: numeric - Value for which log-probability is calculated. - distribution: TensorVariable - Distribution which is being bounded - lower: numeric - Lower bound for the distribution being bounded. - upper: numeric - Upper bound for the distribution being bounded. - - Returns - ------- - TensorVariable - """ - res = pt.switch( - pt.or_(pt.lt(value, lower), pt.gt(value, upper)), - -np.inf, - logp(distribution, value), - ) - - return check_parameters( - res, - lower <= upper, - msg="lower <= upper", - ) - - -class Bound: - r""" - Create a Bound variable object that can be applied to create - a new upper, lower, or upper and lower bounded distribution. - - The resulting distribution is not normalized anymore. This - is usually fine if the bounds are constants. If you need - truncated distributions, use `Bound` in combination with - a :class:`~pymc.model.Potential` with the cumulative probability function. - - The bounds are inclusive for discrete distributions. - - Parameters - ---------- - dist : PyMC unnamed distribution - Distribution to be transformed into a bounded distribution created via the - `.dist()` API. - lower : float or array like, optional - Lower bound of the distribution. - upper : float or array like, optional - Upper bound of the distribution. - - Examples - -------- - .. code-block:: python - - with pm.Model(): - normal_dist = pm.Normal.dist(mu=0.0, sigma=1.0) - negative_normal = pm.Bound("negative_normal", normal_dist, upper=0.0) - - """ - - def __new__( - cls, - name, - dist, - lower=None, - upper=None, - size=None, - shape=None, - initval=None, - dims=None, - **kwargs, - ): - warnings.warn( - "Bound has been deprecated in favor of Truncated, and will be removed in a " - "future release. If Truncated is not an option, Bound can be implemented by" - "adding an IntervalTransform between lower and upper to a continuous " - "variable. A Potential that returns negative infinity for values outside " - "of the bounds can be used for discrete variables.", - FutureWarning, - ) - cls._argument_checks(dist, **kwargs) - - if dims is not None: - model = modelcontext(None) - if dims in model.coords: - dim_obj = np.asarray(model.coords[dims]) - size = dim_obj.shape - else: - raise ValueError("Given dims do not exist in model coordinates.") - - lower, upper, initval = cls._set_values(lower, upper, size, shape, initval) - - if isinstance(dist.owner.op, Continuous): - res = _ContinuousBounded( - name, - [dist, lower, upper], - initval=initval.astype("float"), - size=size, - shape=shape, - **kwargs, - ) - else: - res = _DiscreteBounded( - name, - [dist, lower, upper], - initval=initval.astype("int"), - size=size, - shape=shape, - **kwargs, - ) - return res - - @classmethod - def dist( - cls, - dist, - lower=None, - upper=None, - size=None, - shape=None, - **kwargs, - ): - cls._argument_checks(dist, **kwargs) - lower, upper, initval = cls._set_values(lower, upper, size, shape, initval=None) - if isinstance(dist.owner.op, Continuous): - res = _ContinuousBounded.dist( - [dist, lower, upper], - size=size, - shape=shape, - **kwargs, - ) - res.tag.test_value = initval - else: - res = _DiscreteBounded.dist( - [dist, lower, upper], - size=size, - shape=shape, - **kwargs, - ) - res.tag.test_value = initval.astype("int") - return res - - @classmethod - def _argument_checks(cls, dist, **kwargs): - if "observed" in kwargs: - raise ValueError( - "Observed Bound distributions are not supported. " - "If you want to model truncated data " - "you can use a pm.Potential in combination " - "with the cumulative probability function." - ) - - if not isinstance(dist, TensorVariable): - raise ValueError( - "Passing a distribution class to `Bound` is no longer supported.\n" - "Please pass the output of a distribution instantiated via the " - "`.dist()` API such as:\n" - '`pm.Bound("bound", pm.Normal.dist(0, 1), lower=0)`' - ) - - check_dist_not_registered(dist) - - if dist.owner.op.ndim_supp != 0: - raise NotImplementedError("Bounding of MultiVariate RVs is not yet supported.") - - if not isinstance(dist.owner.op, (Discrete, Continuous)): - raise ValueError( - f"`distribution` {dist} must be a Discrete or Continuous" " distribution subclass" - ) - - @classmethod - def _set_values(cls, lower, upper, size, shape, initval): - if size is None: - size = shape - - lower = np.asarray(lower) - lower = np.where(lower == None, -np.inf, lower) # noqa E711 - upper = np.asarray(upper) - upper = np.where(upper == None, np.inf, upper) # noqa E711 - - if initval is None: - _size = np.broadcast_shapes(to_tuple(size), np.shape(lower), np.shape(upper)) - _lower = np.broadcast_to(lower, _size) - _upper = np.broadcast_to(upper, _size) - initval = np.where( - (_lower == -np.inf) & (_upper == np.inf), - 0, - np.where( - _lower == -np.inf, - _upper - 1, - np.where(_upper == np.inf, _lower + 1, (_lower + _upper) / 2), - ), - ) - lower = as_tensor_variable(lower, dtype="floatX") - upper = as_tensor_variable(upper, dtype="floatX") - return lower, upper, initval diff --git a/tests/distributions/test_bound.py b/tests/distributions/test_bound.py deleted file mode 100644 index 04015f4509..0000000000 --- a/tests/distributions/test_bound.py +++ /dev/null @@ -1,264 +0,0 @@ -# Copyright 2024 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. - -import warnings - -import numpy as np -import pytest -import scipy.stats as st - -from pytensor.tensor.random.op import RandomVariable - -import pymc as pm - - -class TestBound: - """Tests for pm.Bound distribution""" - - def test_continuous(self): - with pm.Model() as model: - dist = pm.Normal.dist(mu=0, sigma=1) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "invalid value encountered in add", RuntimeWarning - ) - UnboundedNormal = pm.Bound("unbound", dist, transform=None) - InfBoundedNormal = pm.Bound( - "infbound", dist, lower=-np.inf, upper=np.inf, transform=None - ) - LowerNormal = pm.Bound("lower", dist, lower=0, transform=None) - UpperNormal = pm.Bound("upper", dist, upper=0, transform=None) - BoundedNormal = pm.Bound("bounded", dist, lower=1, upper=10, transform=None) - LowerNormalTransform = pm.Bound("lowertrans", dist, lower=1) - UpperNormalTransform = pm.Bound("uppertrans", dist, upper=10) - BoundedNormalTransform = pm.Bound("boundedtrans", dist, lower=1, upper=10) - - assert model.compile_fn(model.logp(LowerNormal), point_fn=False)(-1) == -np.inf - assert model.compile_fn(model.logp(UpperNormal), point_fn=False)(1) == -np.inf - assert model.compile_fn(model.logp(BoundedNormal), point_fn=False)(0) == -np.inf - assert model.compile_fn(model.logp(BoundedNormal), point_fn=False)(11) == -np.inf - - assert model.compile_fn(model.logp(UnboundedNormal), point_fn=False)(0) != -np.inf - assert model.compile_fn(model.logp(UnboundedNormal), point_fn=False)(11) != -np.inf - assert model.compile_fn(model.logp(InfBoundedNormal), point_fn=False)(0) != -np.inf - assert model.compile_fn(model.logp(InfBoundedNormal), point_fn=False)(11) != -np.inf - - assert model.compile_fn(model.logp(LowerNormalTransform), point_fn=False)(-1) != -np.inf - assert model.compile_fn(model.logp(UpperNormalTransform), point_fn=False)(1) != -np.inf - assert model.compile_fn(model.logp(BoundedNormalTransform), point_fn=False)(0) != -np.inf - assert model.compile_fn(model.logp(BoundedNormalTransform), point_fn=False)(11) != -np.inf - - ref_dist = pm.Normal.dist(mu=0, sigma=1) - assert np.allclose( - model.compile_fn(model.logp(UnboundedNormal), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(LowerNormal), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(UpperNormal), point_fn=False)(-5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(BoundedNormal), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - - def test_discrete(self): - with pm.Model() as model: - dist = pm.Poisson.dist(mu=4) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "invalid value encountered in add", RuntimeWarning - ) - UnboundedPoisson = pm.Bound("unbound", dist) - LowerPoisson = pm.Bound("lower", dist, lower=1) - UpperPoisson = pm.Bound("upper", dist, upper=10) - BoundedPoisson = pm.Bound("bounded", dist, lower=1, upper=10) - - assert model.compile_fn(model.logp(LowerPoisson), point_fn=False)(0) == -np.inf - assert model.compile_fn(model.logp(UpperPoisson), point_fn=False)(11) == -np.inf - assert model.compile_fn(model.logp(BoundedPoisson), point_fn=False)(0) == -np.inf - assert model.compile_fn(model.logp(BoundedPoisson), point_fn=False)(11) == -np.inf - - assert model.compile_fn(model.logp(UnboundedPoisson), point_fn=False)(0) != -np.inf - assert model.compile_fn(model.logp(UnboundedPoisson), point_fn=False)(11) != -np.inf - - ref_dist = pm.Poisson.dist(mu=4) - assert np.allclose( - model.compile_fn(model.logp(UnboundedPoisson), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(LowerPoisson), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(UpperPoisson), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - assert np.allclose( - model.compile_fn(model.logp(BoundedPoisson), point_fn=False)(5), - pm.logp(ref_dist, 5).eval(), - ) - - def create_invalid_distribution(self): - class MyNormal(RandomVariable): - name = "my_normal" - ndim_supp = 0 - ndims_params = [0, 0] - dtype = "floatX" - - my_normal = MyNormal() - - class InvalidDistribution(pm.Distribution): - rv_op = my_normal - - @classmethod - def dist(cls, mu=0, sigma=1, **kwargs): - return super().dist([mu, sigma], **kwargs) - - return InvalidDistribution - - def test_arguments_checks(self): - msg = "Observed Bound distributions are not supported" - with pm.Model() as m: - x = pm.Normal("x", 0, 1) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", x, observed=5) - - msg = "Cannot transform discrete variable." - with pm.Model() as m: - x = pm.Poisson.dist(0.5) - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "invalid value encountered in add", RuntimeWarning - ) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", x, transform=pm.distributions.transforms.log) - - msg = "Given dims do not exist in model coordinates." - with pm.Model() as m: - x = pm.Poisson.dist(0.5) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", x, dims="random_dims") - - msg = "The dist x was already registered in the current model" - with pm.Model() as m: - x = pm.Normal("x", 0, 1) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", x) - - msg = "Passing a distribution class to `Bound` is no longer supported" - with pm.Model() as m: - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", pm.Normal) - - msg = "Bounding of MultiVariate RVs is not yet supported" - with pm.Model() as m: - x = pm.MvNormal.dist(np.zeros(3), np.eye(3)) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(NotImplementedError, match=msg): - pm.Bound("bound", x) - - msg = "must be a Discrete or Continuous distribution subclass" - with pm.Model() as m: - x = self.create_invalid_distribution().dist() - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with pytest.raises(ValueError, match=msg): - pm.Bound("bound", x) - - def test_invalid_sampling(self): - msg = "Cannot sample from a bounded variable" - with pm.Model() as m: - dist = pm.Normal.dist(mu=0, sigma=1) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - BoundedNormal = pm.Bound("bounded", dist, lower=1, upper=10) - with pytest.raises(NotImplementedError, match=msg): - pm.sample_prior_predictive() - - def test_bound_shapes(self): - with pm.Model(coords={"sample": np.ones((2, 5))}) as m: - dist = pm.Normal.dist(mu=0, sigma=1) - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - bound_sized = pm.Bound("boundedsized", dist, lower=1, upper=10, size=(4, 5)) - bound_shaped = pm.Bound("boundedshaped", dist, lower=1, upper=10, shape=(3, 5)) - bound_dims = pm.Bound("boundeddims", dist, lower=1, upper=10, dims="sample") - - initial_point = m.initial_point() - dist_size = initial_point["boundedsized_interval__"].shape - dist_shape = initial_point["boundedshaped_interval__"].shape - dist_dims = initial_point["boundeddims_interval__"].shape - - assert dist_size == (4, 5) - assert dist_shape == (3, 5) - assert dist_dims == (2, 5) - - def test_bound_dist(self): - # Continuous - bound = pm.Bound.dist(pm.Normal.dist(0, 1), lower=0) - assert pm.logp(bound, -1).eval() == -np.inf - assert np.isclose(pm.logp(bound, 1).eval(), st.norm(0, 1).logpdf(1)) - - # Discrete - bound = pm.Bound.dist(pm.Poisson.dist(1), lower=2) - assert pm.logp(bound, 1).eval() == -np.inf - assert np.isclose(pm.logp(bound, 2).eval(), st.poisson(1).logpmf(2)) - - def test_array_bound(self): - with pm.Model() as model: - dist = pm.Normal.dist() - with pytest.warns(FutureWarning, match="Bound has been deprecated"): - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "invalid value encountered in add", RuntimeWarning - ) - LowerPoisson = pm.Bound("lower", dist, lower=[1, None], transform=None) - UpperPoisson = pm.Bound("upper", dist, upper=[np.inf, 10], transform=None) - BoundedPoisson = pm.Bound( - "bounded", dist, lower=[1, 2], upper=[9, 10], transform=None - ) - - first, second = model.compile_fn(model.logp(LowerPoisson, sum=False)[0], point_fn=False)( - [0, 0] - ) - assert first == -np.inf - assert second != -np.inf - - first, second = model.compile_fn(model.logp(UpperPoisson, sum=False)[0], point_fn=False)( - [11, 11] - ) - assert first != -np.inf - assert second == -np.inf - - first, second = model.compile_fn(model.logp(BoundedPoisson, sum=False)[0], point_fn=False)( - [1, 1] - ) - assert first != -np.inf - assert second == -np.inf - - first, second = model.compile_fn(model.logp(BoundedPoisson, sum=False)[0], point_fn=False)( - [10, 10] - ) - assert first == -np.inf - assert second != -np.inf diff --git a/tests/test_printing.py b/tests/test_printing.py index 53c18c828b..b2577768f1 100644 --- a/tests/test_printing.py +++ b/tests/test_printing.py @@ -102,9 +102,6 @@ def setup_class(self): # Expected value of outcome mu = Deterministic("mu", floatX(alpha + dot(X, b))) - # add a bounded variable as well - # bound_var = Bound(Normal, lower=1.0)("bound_var", mu=0, sigma=10) - # KroneckerNormal n, m = 3, 4 covs = [np.eye(n), np.eye(m)]