Skip to content

Commit

Permalink
Implement shape/dims/size API
Browse files Browse the repository at this point in the history
Some tests were refactored because size is now implemented more consistently.

Co-authored-by: Oriol Abril-Pla<oriol.abril.pla@gmail.com>
  • Loading branch information
michaelosthege committed Apr 18, 2021
1 parent 116cdf3 commit 66d5fa7
Show file tree
Hide file tree
Showing 11 changed files with 690 additions and 147 deletions.
1 change: 1 addition & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### New Features
- The `CAR` distribution has been added to allow for use of conditional autoregressions which often are used in spatial and network models.
- The shape handling of random variables was improved dramatically (see [#4625](https://github.com/pymc-devs/pymc3/pull/4625)).
- ...

### Maintenance
Expand Down
12 changes: 9 additions & 3 deletions pymc3/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import urllib.request

from copy import copy
from typing import Any, Dict, List
from typing import Any, Dict, List, Sequence

import aesara
import aesara.tensor as at
Expand All @@ -33,6 +33,7 @@
import pymc3 as pm

from pymc3.aesaraf import pandas_to_array
from pymc3.exceptions import ShapeError

__all__ = [
"get_data",
Expand Down Expand Up @@ -502,7 +503,7 @@ class Data:
>>> for data_vals in observed_data:
... with model:
... # Switch out the observed dataset
... pm.set_data({'data': data_vals})
... model.set_data('data', data_vals)
... traces.append(pm.sample())
To set the value of the data container variable, check out
Expand Down Expand Up @@ -543,6 +544,11 @@ def __new__(self, name, value, *, dims=None, export_index_as_coords=False):

if export_index_as_coords:
model.add_coords(coords)
elif dims:
# Register new dimension lengths
for d, dname in enumerate(dims):
if not dname in model.dim_lengths:
model.add_coord(dname, values=None, length=shared_object.shape[d])

# To draw the node for this variable in the graphviz Digraph we need
# its shape.
Expand All @@ -562,7 +568,7 @@ def __new__(self, name, value, *, dims=None, export_index_as_coords=False):
return shared_object

@staticmethod
def set_coords(model, value, dims=None):
def set_coords(model, value, dims=None) -> Dict[str, Sequence]:
coords = {}

# If value is a df or a series, we interpret the index as coords:
Expand Down
245 changes: 219 additions & 26 deletions pymc3/distributions/distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,27 @@
# limitations under the License.
import contextvars
import inspect
import logging
import multiprocessing
import sys
import types
import warnings

from abc import ABCMeta
from copy import copy
from typing import TYPE_CHECKING
from typing import Any, Optional, Sequence, Tuple, Union

import aesara
import aesara.graph.basic
import aesara.tensor as at
import dill

from aesara.graph.basic import Variable
from aesara.tensor.random.op import RandomVariable
from aesara.tensor.shape import specify_shape

from pymc3.aesaraf import change_rv_size
from pymc3.distributions import _logcdf, _logp

if TYPE_CHECKING:
from typing import Optional, Callable

import aesara
import aesara.graph.basic
import aesara.tensor as at

from pymc3.util import UNSET, get_repr_for_variable
from pymc3.vartypes import string_types

Expand All @@ -46,12 +45,18 @@
"NoDistribution",
]

_log = logging.getLogger(__file__)

vectorized_ppc = contextvars.ContextVar(
"vectorized_ppc", default=None
) # type: contextvars.ContextVar[Optional[Callable]]

PLATFORM = sys.platform

Shape = Union[int, Tuple[Union[str, type(Ellipsis)]], Variable]
Dims = Union[str, Sequence[Union[str, type(Ellipsis)]]]
Size = Union[int, Tuple[int]]


class _Unpickling:
pass
Expand Down Expand Up @@ -122,13 +127,112 @@ def logcdf(op, var, rvs_to_values, *dist_params, **kwargs):
return new_cls


def _valid_ellipsis_position(items: Union[None, Shape, Dims, Size]) -> bool:
if items is not None and not isinstance(items, Variable) and Ellipsis in items:
if any(i == Ellipsis for i in items[:-1]):
return False
return True


def _validate_shape_dims_size(
shape: Any = None, dims: Any = None, size: Any = None
) -> Tuple[Optional[Shape], Optional[Dims], Optional[Size]]:
# Raise on unsupported parametrization
if shape is not None and dims is not None:
raise ValueError("Passing both `shape` ({shape}) and `dims` ({dims}) is not supported!")
if dims is not None and size is not None:
raise ValueError("Passing both `dims` ({dims}) and `size` ({size}) is not supported!")
if shape is not None and size is not None:
raise ValueError("Passing both `shape` ({shape}) and `size` ({size}) is not supported!")

# Raise on invalid types
if not isinstance(shape, (type(None), int, list, tuple, aesara.graph.basic.Variable)):
raise ValueError("The `shape` parameter must be an int, list or tuple.")
if not isinstance(dims, (type(None), str, list, tuple)):
raise ValueError("The `dims` parameter must be a str, list or tuple.")
if not isinstance(size, (type(None), int, list, tuple)):
raise ValueError("The `size` parameter must be an int, list or tuple.")

# Warn about backwards-compatible but discouraged parametrization
if isinstance(shape, int):
shape = (shape,)
if isinstance(dims, str):
dims = (dims,)
if isinstance(size, int):
# size as int is okay, because that's the most common use case
size = (size,)

# Convert to actual tuples
if not isinstance(shape, (type(None), tuple, Variable)):
shape = tuple(shape)
if not isinstance(dims, (type(None), tuple)):
dims = tuple(dims)
if not isinstance(size, (type(None), tuple)):
size = tuple(size)

if not _valid_ellipsis_position(shape):
raise ValueError(
f"Ellipsis in `shape` may only appear in the last position. Actual: {shape}"
)
if not _valid_ellipsis_position(dims):
raise ValueError(f"Ellipsis in `dims` may only appear in the last position. Actual: {dims}")
if size is not None and Ellipsis in size:
raise ValueError("The `size` parameter cannot contain an Ellipsis. Actual: {size}")
return shape, dims, size


class Distribution(metaclass=DistributionMeta):
"""Statistical distribution"""

rv_class = None
rv_op = None

def __new__(cls, name, *args, **kwargs):
def __new__(
cls,
name: str,
*args,
rng=None,
dims: Optional[Dims] = None,
testval=None,
observed=None,
total_size=None,
transform=UNSET,
**kwargs,
) -> RandomVariable:
"""Adds a RandomVariable corresponding to a PyMC3 distribution to the current model.
Note that all remaining kwargs must be compatible with .dist()
Parameters
----------
cls : type
A PyMC3 distribution.
name : str
Name for the new model variable.
rng : optional
Random number generator to use with the RandomVariable.
dims : tuple, optional
A tuple of dimension names known to the model.
testval : optional
Test value to be attached to the output RV.
Must match its shape exactly.
observed : optional
Observed data to be passed when registering the random variable in the model.
See `Model.register_rv`.
total_size : float, optional
See `Model.register_rv`.
transform : optional
See `Model.register_rv`.
**kwargs
Keyword arguments that will be forwarded to .dist().
Most prominently: `shape` and `size`
Returns
-------
rv : RandomVariable
The created RV, registered in the Model.
"""

try:
from pymc3.model import Model

Expand All @@ -141,40 +245,129 @@ def __new__(cls, name, *args, **kwargs):
"for a standalone distribution."
)

rng = kwargs.pop("rng", None)
if not isinstance(name, string_types):
raise TypeError(f"Name needs to be a string but got: {name}")

if rng is None:
rng = model.default_rng

if not isinstance(name, string_types):
raise TypeError(f"Name needs to be a string but got: {name}")
_, dims, _ = _validate_shape_dims_size(dims=dims)

data = kwargs.pop("observed", None)
# Create the RV without specifying testval, because the testval may
# have a shape that only matches after replicating with a size implied
# by dims (see below).
rv_out = cls.dist(*args, rng=rng, testval=None, **kwargs)

total_size = kwargs.pop("total_size", None)
# `dims` are only available with this API, because `.dist()` can be used
# without a modelcontext and dims are not yet tracked at the Aesara level.
if dims is not None:
if Ellipsis in dims:
# Auto-complete the dims tuple to the full length
dims = (*dims[:-1], *[None] * rv_out.ndim)

dims = kwargs.pop("dims", None)
n_implied = rv_out.ndim
n_size = len(dims) - n_implied

if "shape" in kwargs:
raise DeprecationWarning("The `shape` keyword is deprecated; use `size`.")
# All size dims must be known already (numerically or symbolically).
unknown_size_dims = set(dims[:n_size]) - set(model.dim_lengths)
if unknown_size_dims:
raise KeyError(
f"Dimensions {unknown_size_dims} are unknown to the model and cannot be used to specify a `size`."
)

# The numeric/symbolic size tuple can be created using model.RV_dim_lengths
size = tuple(model.dim_lengths[dname] for dname in dims[:n_size])

transform = kwargs.pop("transform", UNSET)
if size:
# A batch size was specified through dims!
rv_out = change_rv_size(rv_var=rv_out, new_size=size, expand=True)

rv_out = cls.dist(*args, rng=rng, **kwargs)
# Now that we have a handle on the output RV, we can register named implied dimensions that
# were not yet known to the model, such that they can be used for size further downstream.
for di, dname in enumerate(dims[n_size:]):
if not dname in model.dim_lengths:
model.add_coord(dname, values=None, length=rv_out.shape[n_size + di])

return model.register_rv(rv_out, name, data, total_size, dims=dims, transform=transform)
if testval is not None:
# Assigning the testval earlier causes trouble because the RV may not be created with the final shape already.
rv_out.tag.test_value = testval

return model.register_rv(rv_out, name, observed, total_size, dims=dims, transform=transform)

@classmethod
def dist(cls, dist_params, **kwargs):
def dist(
cls,
dist_params,
*,
shape: Optional[Shape] = None,
size: Optional[Size] = None,
testval=None,
**kwargs,
) -> RandomVariable:
"""Creates a RandomVariable corresponding to the `cls` distribution.
Parameters
----------
dist_params
shape : tuple, optional
A tuple of sizes for each dimension of the new RV.
Ellipsis (...) may be used in the last position of the tuple,
and automatically expand to the shape implied by RV inputs.
Without Ellipsis, a `SpecifyShape` Op is automatically applied,
constraining this model variable to exactly the specified shape.
size : int, tuple, Variable, optional
A scalar or tuple for replicating the RV in addition
to its implied shape/dimensionality.
testval : optional
Test value to be attached to the output RV.
Must match its shape exactly.
testval = kwargs.pop("testval", None)
Returns
-------
rv : RandomVariable
The created RV.
"""
if "dims" in kwargs:
raise NotImplementedError("The use of a `.dist(dims=...)` API is not yet supported.")

shape, _, size = _validate_shape_dims_size(shape=shape, size=size)
assert_shape = None

# Create the RV without specifying size or testval.
# The size will be expanded later (if necessary) and only then the testval fits.
rv_native = cls.rv_op(*dist_params, size=None, **kwargs)

if shape is None and size is None:
size = ()
elif shape is not None:
# SpecifyShape is automatically applied for symbolic and non-Ellipsis shapes
if isinstance(shape, Variable):
assert_shape = shape
size = ()
else:
if Ellipsis in shape:
size = tuple(shape[:-1])
else:
size = tuple(shape[: len(shape) - rv_native.ndim])
assert_shape = shape
# no-op conditions:
# `elif size is not None` (User already specified how to expand the RV)
# `else` (Unreachable)

if size:
rv_out = change_rv_size(rv_var=rv_native, new_size=size, expand=True)
else:
rv_out = rv_native

rv_var = cls.rv_op(*dist_params, **kwargs)
if assert_shape is not None:
rv_out = specify_shape(rv_out, shape=assert_shape)

if testval is not None:
rv_var.tag.test_value = testval
rv_out.tag.test_value = testval

return rv_var
return rv_out

def _distr_parameters_for_repr(self):
"""Return the names of the parameters for this distribution (e.g. "mu"
Expand Down
Loading

0 comments on commit 66d5fa7

Please sign in to comment.