Skip to content

Commit e1af7c9

Browse files
Fixing scalar shape handling: treat (1,) as vectors (#4214)
Fixing scalar shape handling: treat (1,) as vectors
1 parent 398eae6 commit e1af7c9

7 files changed

+97
-132
lines changed

RELEASE-NOTES.md

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
# Release Notes
22

33
## PyMC3 vNext (on deck)
4-
This is the first release to support Python3.9 and to drop Python3.6.
4+
This release breaks some APIs w.r.t. `3.10.0`.
5+
It also brings some dreadfully awaited fixes, so be sure to go through the changes below.
6+
(Or latest when you run into problems.)
7+
8+
### Breaking Changes
9+
- Python 3.6 support was dropped (by no longer testing) and Python 3.9 was added (see [#4332](https://github.com/pymc-devs/pymc3/pull/4332)).
10+
- Changed shape behavior: __No longer collapse length 1 vector shape into scalars.__ (see [#4206](https://github.com/pymc-devs/pymc3/issue/4206) and [#4214](https://github.com/pymc-devs/pymc3/pull/4214))
11+
- __Applies to random variables and also the `.random(size=...)` kwarg!__
12+
- To create scalar variables you must now use `shape=None` or `shape=()`.
13+
- __`shape=(1,)` and `shape=1` now become vectors.__ Previously they were collapsed into scalars
14+
- 0-length dimensions are now ruled illegal for random variables and raise a `ValueError`.
15+
- In `sample_prior_predictive` the `vars` kwarg was removed in favor of `var_names` (see [#4327](https://github.com/pymc-devs/pymc3/pull/4327)).
16+
- Removed `theanof.set_theano_config` because it illegally changed Theano's internal state (see [#4329](https://github.com/pymc-devs/pymc3/pull/4329)).
517

618
### New Features
719
- `OrderedProbit` distribution added (see [#4232](https://github.com/pymc-devs/pymc3/pull/4232)).
@@ -10,8 +22,6 @@ This is the first release to support Python3.9 and to drop Python3.6.
1022
### Maintenance
1123
- Fixed bug whereby partial traces returns after keyboard interrupt during parallel sampling had fewer draws than would've been available [#4318](https://github.com/pymc-devs/pymc3/pull/4318)
1224
- Make `sample_shape` same across all contexts in `draw_values` (see [#4305](https://github.com/pymc-devs/pymc3/pull/4305)).
13-
- Removed `theanof.set_theano_config` because it illegally touched Theano's privates (see [#4329](https://github.com/pymc-devs/pymc3/pull/4329)).
14-
- In `sample_posterior_predictive` the `vars` kwarg was removed in favor of `var_names` (see [#4343](https://github.com/pymc-devs/pymc3/pull/4343)).
1525
- The notebook gallery has been moved to https://github.com/pymc-devs/pymc-examples (see [#4348](https://github.com/pymc-devs/pymc3/pull/4348)).
1626
- `math.logsumexp` now matches `scipy.special.logsumexp` when arrays contain infinite values (see [#4360](https://github.com/pymc-devs/pymc3/pull/4360)).
1727
- Fixed mathematical formulation in `MvStudentT` random method. (see [#4359](https://github.com/pymc-devs/pymc3/pull/4359))

pymc3/distributions/discrete.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def __init__(self, alpha, beta, n, *args, **kwargs):
212212
self.mode = tt.cast(tround(alpha / (alpha + beta)), "int8")
213213

214214
def _random(self, alpha, beta, n, size=None):
215-
size = size or 1
215+
size = size or ()
216216
p = stats.beta.rvs(a=alpha, b=beta, size=size).flatten()
217217
# Sometimes scipy.beta returns nan. Ugh.
218218
while np.any(np.isnan(p)):
@@ -1096,7 +1096,7 @@ def random(self, point=None, size=None):
10961096
return generate_samples(
10971097
random_choice,
10981098
p=p,
1099-
broadcast_shape=p.shape[:-1] or (1,),
1099+
broadcast_shape=p.shape[:-1],
11001100
dist_shape=self.shape,
11011101
size=size,
11021102
)

pymc3/distributions/distribution.py

+6-27
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ def __new__(cls, name, *args, **kwargs):
107107
dims = (dims,)
108108
shape = model.shape_from_dims(dims)
109109

110+
# failsafe against 0-shapes
111+
if shape is not None and any(np.atleast_1d(shape) <= 0):
112+
raise ValueError(
113+
f"Distribution initialized with invalid shape {shape}. This is not allowed."
114+
)
115+
110116
# Some distributions do not accept shape=None
111117
if has_shape or shape is not None:
112118
dist = cls.dist(*args, **kwargs, shape=shape)
@@ -1001,16 +1007,6 @@ def _draw_value(param, point=None, givens=None, size=None):
10011007
raise ValueError("Unexpected type in draw_value: %s" % type(param))
10021008

10031009

1004-
def _is_one_d(dist_shape):
1005-
if hasattr(dist_shape, "dshape") and dist_shape.dshape in ((), (0,), (1,)):
1006-
return True
1007-
elif hasattr(dist_shape, "shape") and dist_shape.shape in ((), (0,), (1,)):
1008-
return True
1009-
elif to_tuple(dist_shape) == ():
1010-
return True
1011-
return False
1012-
1013-
10141010
def generate_samples(generator, *args, **kwargs):
10151011
"""Generate samples from the distribution of a random variable.
10161012
@@ -1044,7 +1040,6 @@ def generate_samples(generator, *args, **kwargs):
10441040
Any remaining args and kwargs are passed on to the generator function.
10451041
"""
10461042
dist_shape = kwargs.pop("dist_shape", ())
1047-
one_d = _is_one_d(dist_shape)
10481043
size = kwargs.pop("size", None)
10491044
broadcast_shape = kwargs.pop("broadcast_shape", None)
10501045
not_broadcast_kwargs = kwargs.pop("not_broadcast_kwargs", None)
@@ -1120,21 +1115,5 @@ def generate_samples(generator, *args, **kwargs):
11201115
samples = generator(size=dist_bcast_shape, *args, **kwargs)
11211116
else:
11221117
samples = generator(size=size_tup + dist_bcast_shape, *args, **kwargs)
1123-
samples = np.asarray(samples)
1124-
1125-
# reshape samples here
1126-
if samples.ndim > 0 and samples.shape[0] == 1 and size_tup == (1,):
1127-
if (
1128-
len(samples.shape) > len(dist_shape)
1129-
and samples.shape[-len(dist_shape) :] == dist_shape[-len(dist_shape) :]
1130-
):
1131-
samples = samples.reshape(samples.shape[1:])
11321118

1133-
if (
1134-
one_d
1135-
and samples.ndim > 0
1136-
and samples.shape[-1] == 1
1137-
and (samples.shape != size_tup or size_tup == tuple() or size_tup == (1,))
1138-
):
1139-
samples = samples.reshape(samples.shape[:-1])
11401119
return np.asarray(samples)

pymc3/distributions/mixture.py

-6
Original file line numberDiff line numberDiff line change
@@ -870,12 +870,6 @@ def random(self, point=None, size=None):
870870

871871
# The `samples` array still has the `mixture_axis`, so we must remove it:
872872
output = samples[(..., 0) + (slice(None),) * len(event_shape)]
873-
874-
# Final oddity: if size == 1, pymc3 defaults to reducing the sample_shape dimension
875-
# We do this to stay consistent with the rest of the package even though
876-
# we shouldn't have to do it.
877-
if size == 1:
878-
output = output[0]
879873
return output
880874

881875
def _distr_parameters_for_repr(self):

pymc3/distributions/posterior_predictive.py

-2
Original file line numberDiff line numberDiff line change
@@ -560,8 +560,6 @@ def random_sample(
560560
shape: Tuple[int, ...],
561561
) -> np.ndarray:
562562
val = meth(point=point, size=size)
563-
if size == 1:
564-
val = np.expand_dims(val, axis=0)
565563
try:
566564
assert val.shape == (size,) + shape, (
567565
"Sampling from random of %s yields wrong shape" % param

pymc3/tests/test_distributions_random.py

+76-89
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,19 @@ def test_blocking_context(self):
204204
class BaseTestCases:
205205
class BaseTestCase(SeededTest):
206206
shape = 5
207+
# the following are the default values of the distribution that take effect
208+
# when the parametrized shape/size in the test case is None.
209+
# For every distribution that defaults to non-scalar shapes they must be
210+
# specified by the inheriting Test class. example: TestGaussianRandomWalk
211+
default_shape = ()
212+
default_size = ()
207213

208214
def setup_method(self, *args, **kwargs):
209215
super().setup_method(*args, **kwargs)
210216
self.model = pm.Model()
211217

212218
def get_random_variable(self, shape, with_vector_params=False, name=None):
219+
""" Creates a RandomVariable of the parametrized distribution. """
213220
if with_vector_params:
214221
params = {
215222
key: value * np.ones(self.shape, dtype=np.dtype(type(value)))
@@ -220,100 +227,87 @@ def get_random_variable(self, shape, with_vector_params=False, name=None):
220227
if name is None:
221228
name = self.distribution.__name__
222229
with self.model:
223-
if shape is None:
224-
return self.distribution(name, transform=None, **params)
225-
else:
226-
try:
230+
try:
231+
if shape is None:
232+
# in the test case parametrization "None" means "no specified (default)"
233+
return self.distribution(name, transform=None, **params)
234+
else:
227235
return self.distribution(name, shape=shape, transform=None, **params)
228-
except TypeError:
229-
if np.sum(np.atleast_1d(shape)) == 0:
230-
pytest.skip("Timeseries must have positive shape")
231-
raise
236+
except TypeError:
237+
if np.sum(np.atleast_1d(shape)) == 0:
238+
pytest.skip("Timeseries must have positive shape")
239+
raise
232240

233241
@staticmethod
234242
def sample_random_variable(random_variable, size):
243+
""" Draws samples from a RandomVariable using its .random() method. """
235244
try:
236-
return random_variable.random(size=size)
245+
if size is None:
246+
return random_variable.random()
247+
else:
248+
return random_variable.random(size=size)
237249
except AttributeError:
238-
return random_variable.distribution.random(size=size)
239-
240-
@pytest.mark.parametrize("size", [None, 5, (4, 5)], ids=str)
241-
def test_scalar_parameter_shape(self, size):
242-
rv = self.get_random_variable(None)
243-
if size is None:
244-
expected = (1,)
245-
else:
246-
expected = np.atleast_1d(size).tolist()
247-
actual = np.atleast_1d(self.sample_random_variable(rv, size)).shape
248-
assert tuple(expected) == actual
250+
if size is None:
251+
return random_variable.distribution.random()
252+
else:
253+
return random_variable.distribution.random(size=size)
249254

250-
@pytest.mark.parametrize("size", [None, 5, (4, 5)], ids=str)
251-
def test_scalar_shape(self, size):
252-
shape = 10
255+
@pytest.mark.parametrize("size", [None, (), 1, (1,), 5, (4, 5)], ids=str)
256+
@pytest.mark.parametrize("shape", [None, ()], ids=str)
257+
def test_scalar_distribution_shape(self, shape, size):
258+
""" Draws samples of different [size] from a scalar [shape] RV. """
253259
rv = self.get_random_variable(shape)
260+
exp_shape = self.default_shape if shape is None else tuple(np.atleast_1d(shape))
261+
exp_size = self.default_size if size is None else tuple(np.atleast_1d(size))
262+
expected = exp_size + exp_shape
263+
actual = np.shape(self.sample_random_variable(rv, size))
264+
assert (
265+
expected == actual
266+
), f"Sample size {size} from {shape}-shaped RV had shape {actual}. Expected: {expected}"
267+
# check that negative size raises an error
268+
with pytest.raises(ValueError):
269+
self.sample_random_variable(rv, size=-2)
270+
with pytest.raises(ValueError):
271+
self.sample_random_variable(rv, size=(3, -2))
254272

255-
if size is None:
256-
expected = []
257-
else:
258-
expected = np.atleast_1d(size).tolist()
259-
expected.append(shape)
260-
actual = np.atleast_1d(self.sample_random_variable(rv, size)).shape
261-
assert tuple(expected) == actual
262-
263-
@pytest.mark.parametrize("size", [None, 5, (4, 5)], ids=str)
264-
def test_parameters_1d_shape(self, size):
265-
rv = self.get_random_variable(self.shape, with_vector_params=True)
266-
if size is None:
267-
expected = []
268-
else:
269-
expected = np.atleast_1d(size).tolist()
270-
expected.append(self.shape)
271-
actual = self.sample_random_variable(rv, size).shape
272-
assert tuple(expected) == actual
273-
274-
@pytest.mark.parametrize("size", [None, 5, (4, 5)], ids=str)
275-
def test_broadcast_shape(self, size):
276-
broadcast_shape = (2 * self.shape, self.shape)
277-
rv = self.get_random_variable(broadcast_shape, with_vector_params=True)
278-
if size is None:
279-
expected = []
280-
else:
281-
expected = np.atleast_1d(size).tolist()
282-
expected.extend(broadcast_shape)
283-
actual = np.atleast_1d(self.sample_random_variable(rv, size)).shape
284-
assert tuple(expected) == actual
285-
273+
@pytest.mark.parametrize("size", [None, ()], ids=str)
286274
@pytest.mark.parametrize(
287-
"shape", [(), (1,), (1, 1), (1, 2), (10, 10, 1), (10, 10, 2)], ids=str
275+
"shape", [None, (), (1,), (1, 1), (1, 2), (10, 11, 1), (9, 10, 2)], ids=str
288276
)
289-
def test_different_shapes_and_sample_sizes(self, shape):
290-
prefix = self.distribution.__name__
291-
292-
rv = self.get_random_variable(shape, name=f"{prefix}_{shape}")
293-
for size in (None, 1, 5, (4, 5)):
294-
if size is None:
295-
s = []
296-
else:
297-
try:
298-
s = list(size)
299-
except TypeError:
300-
s = [size]
301-
if s == [1]:
302-
s = []
303-
if shape not in ((), (1,)):
304-
s.extend(shape)
305-
e = tuple(s)
306-
a = self.sample_random_variable(rv, size).shape
307-
assert e == a
277+
def test_scalar_sample_shape(self, shape, size):
278+
""" Draws samples of scalar [size] from a [shape] RV. """
279+
rv = self.get_random_variable(shape)
280+
exp_shape = self.default_shape if shape is None else tuple(np.atleast_1d(shape))
281+
exp_size = self.default_size if size is None else tuple(np.atleast_1d(size))
282+
expected = exp_size + exp_shape
283+
actual = np.shape(self.sample_random_variable(rv, size))
284+
assert (
285+
expected == actual
286+
), f"Sample size {size} from {shape}-shaped RV had shape {actual}. Expected: {expected}"
287+
288+
@pytest.mark.parametrize("size", [None, 3, (4, 5)], ids=str)
289+
@pytest.mark.parametrize("shape", [None, 1, (10, 11, 1)], ids=str)
290+
def test_vector_params(self, shape, size):
291+
shape = self.shape
292+
rv = self.get_random_variable(shape, with_vector_params=True)
293+
exp_shape = self.default_shape if shape is None else tuple(np.atleast_1d(shape))
294+
exp_size = self.default_size if size is None else tuple(np.atleast_1d(size))
295+
expected = exp_size + exp_shape
296+
actual = np.shape(self.sample_random_variable(rv, size))
297+
assert (
298+
expected == actual
299+
), f"Sample size {size} from {shape}-shaped RV had shape {actual}. Expected: {expected}"
300+
301+
@pytest.mark.parametrize("shape", [-2, 0, (0,), (2, 0), (5, 0, 3)])
302+
def test_shape_error_on_zero_shape_rv(self, shape):
303+
with pytest.raises(ValueError, match="not allowed"):
304+
self.get_random_variable(shape)
308305

309306

310307
class TestGaussianRandomWalk(BaseTestCases.BaseTestCase):
311308
distribution = pm.GaussianRandomWalk
312309
params = {"mu": 1.0, "sigma": 1.0}
313-
314-
@pytest.mark.xfail(reason="Supporting this makes a nasty API")
315-
def test_broadcast_shape(self):
316-
super().test_broadcast_shape()
310+
default_shape = (1,)
317311

318312

319313
class TestNormal(BaseTestCases.BaseTestCase):
@@ -1577,7 +1571,7 @@ def test_Triangular(
15771571
assert prior["target"].shape == (prior_samples,) + shape
15781572

15791573

1580-
def generate_shapes(include_params=False, xfail=False):
1574+
def generate_shapes(include_params=False):
15811575
# fmt: off
15821576
mudim_as_event = [
15831577
[None, 1, 3, 10, (10, 3), 100],
@@ -1596,20 +1590,13 @@ def generate_shapes(include_params=False, xfail=False):
15961590
del mudim_as_event[-1]
15971591
del mudim_as_dist[-1]
15981592
data = itertools.chain(itertools.product(*mudim_as_event), itertools.product(*mudim_as_dist))
1599-
if xfail:
1600-
data = list(data)
1601-
for index in range(len(data)):
1602-
if data[index][0] in (None, 1):
1603-
data[index] = pytest.param(
1604-
*data[index], marks=pytest.mark.xfail(reason="wait for PR #4214")
1605-
)
16061593
return data
16071594

16081595

16091596
class TestMvNormal(SeededTest):
16101597
@pytest.mark.parametrize(
16111598
["sample_shape", "dist_shape", "mu_shape", "param"],
1612-
generate_shapes(include_params=True, xfail=False),
1599+
generate_shapes(include_params=True),
16131600
ids=str,
16141601
)
16151602
def test_with_np_arrays(self, sample_shape, dist_shape, mu_shape, param):
@@ -1619,7 +1606,7 @@ def test_with_np_arrays(self, sample_shape, dist_shape, mu_shape, param):
16191606

16201607
@pytest.mark.parametrize(
16211608
["sample_shape", "dist_shape", "mu_shape"],
1622-
generate_shapes(include_params=False, xfail=True),
1609+
generate_shapes(include_params=False),
16231610
ids=str,
16241611
)
16251612
def test_with_chol_rv(self, sample_shape, dist_shape, mu_shape):
@@ -1636,7 +1623,7 @@ def test_with_chol_rv(self, sample_shape, dist_shape, mu_shape):
16361623

16371624
@pytest.mark.parametrize(
16381625
["sample_shape", "dist_shape", "mu_shape"],
1639-
generate_shapes(include_params=False, xfail=True),
1626+
generate_shapes(include_params=False),
16401627
ids=str,
16411628
)
16421629
def test_with_cov_rv(self, sample_shape, dist_shape, mu_shape):

pymc3/tests/test_shape_handling.py

-3
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,6 @@ def test_broadcast_dist_samples_to(self, samples_to_broadcast_to):
214214
def test_sample_generate_values(fixture_model, fixture_sizes):
215215
model, RVs = fixture_model
216216
size = to_tuple(fixture_sizes)
217-
if size == (1,):
218-
# Single draws are interpreted as scalars for backwards compatibility
219-
size = tuple()
220217
with model:
221218
prior = pm.sample_prior_predictive(samples=fixture_sizes)
222219
for rv in RVs:

0 commit comments

Comments
 (0)