Skip to content

Commit 1acb325

Browse files
committed
Add v4 grw and tests
1 parent c22ea96 commit 1acb325

File tree

4 files changed

+269
-228
lines changed

4 files changed

+269
-228
lines changed

pymc/distributions/timeseries.py

Lines changed: 190 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from typing import Tuple, Union
16+
1517
import aesara.tensor as at
1618
import numpy as np
1719

1820
from aesara import scan
19-
from scipy import stats
21+
from aesara.tensor.random.op import RandomVariable
2022

21-
from pymc.distributions import distribution, multivariate
23+
from pymc.aesaraf import change_rv_size, floatX, intX
24+
from pymc.distributions import distribution, logprob, multivariate
2225
from pymc.distributions.continuous import Flat, Normal, get_tau_sigma
26+
from pymc.distributions.dist_math import check_parameters
2327
from pymc.distributions.shape_utils import to_tuple
28+
from pymc.util import check_dist_not_registered
2429

2530
__all__ = [
2631
"AR1",
@@ -33,6 +38,189 @@
3338
]
3439

3540

41+
class GaussianRandomWalkRV(RandomVariable):
42+
"""
43+
GaussianRandomWalk Random Variable
44+
"""
45+
46+
name = "GaussianRandomWalk"
47+
ndim_supp = 1
48+
ndims_params = [0, 0, 0, 0]
49+
dtype = "floatX"
50+
_print_name = ("GaussianRandomWalk", "\\operatorname{GaussianRandomWalk}")
51+
52+
def _supp_shape_from_params(self, dist_params, reop_param_idx=0, param_shapes=None):
53+
steps = dist_params[3]
54+
55+
return (steps + 1,)
56+
57+
@classmethod
58+
def rng_fn(
59+
cls,
60+
rng: np.random.RandomState,
61+
mu: Union[np.ndarray, float],
62+
sigma: Union[np.ndarray, float],
63+
init: float,
64+
steps: int,
65+
size: Tuple[int],
66+
) -> np.ndarray:
67+
"""Gaussian Random Walk generator.
68+
69+
The init value is drawn from the Normal distribution with the same sigma as the
70+
innovations.
71+
72+
Notes
73+
-----
74+
Currently does not support custom init distribution
75+
76+
Parameters
77+
----------
78+
rng: np.random.RandomState
79+
Numpy random number generator
80+
mu: array_like
81+
Random walk mean
82+
sigma: np.ndarray
83+
Standard deviation of innovation (sigma > 0)
84+
init: float
85+
Initialization value for GaussianRandomWalk
86+
steps: int
87+
Length of random walk, must be greater than 1. Returned array will be of size+1 to
88+
account as first value is initial value
89+
size: int
90+
The number of Random Walk time series generated
91+
92+
Returns
93+
-------
94+
ndarray
95+
"""
96+
97+
if steps is None or steps < 1:
98+
raise ValueError("Steps must be None or greater than 0")
99+
100+
# If size is None then the returned series should be (1+steps,)
101+
if size is None:
102+
init_size = 1
103+
steps_size = steps
104+
105+
# If size is None then the returned series should be (size, 1+steps)
106+
else:
107+
init_size = (*size, 1)
108+
steps_size = (*size, steps)
109+
110+
init = np.reshape(init, init_size)
111+
steps = rng.normal(loc=mu, scale=sigma, size=steps_size)
112+
113+
grw = np.concatenate([init, steps], axis=-1)
114+
115+
return np.cumsum(grw, axis=-1)
116+
117+
118+
gaussianrandomwalk = GaussianRandomWalkRV()
119+
120+
121+
class GaussianRandomWalk(distribution.Continuous):
122+
r"""Random Walk with Normal innovations
123+
124+
125+
Notes
126+
-----
127+
init is currently drawn from a Normal distribution with the same sigma as the innovations
128+
129+
Parameters
130+
----------
131+
mu: tensor_like of float
132+
innovation drift, defaults to 0.0
133+
sigma: tensor_like of float, optional
134+
sigma > 0, innovation standard deviation, defaults to 0.0
135+
init: Scalar PyMC distribution
136+
Scalar distribution of the initial value, created with the `.dist()` API. Defaults to
137+
Normal with same `mu` and `sigma` as the GaussianRandomWalk
138+
steps: int
139+
Number of steps in Gaussian Random Walks
140+
size: int
141+
Number of independent Gaussian Random Walks
142+
"""
143+
144+
rv_op = gaussianrandomwalk
145+
146+
def __new__(cls, name, mu=0.0, sigma=1.0, init=None, steps: int = 1, **kwargs):
147+
check_dist_not_registered(init)
148+
return super().__new__(cls, name, mu, sigma, init, steps, **kwargs)
149+
150+
@classmethod
151+
def dist(
152+
cls, mu=0.0, sigma=1.0, init=None, steps: int = 1, size=None, **kwargs
153+
) -> at.TensorVariable:
154+
155+
mu = at.as_tensor_variable(floatX(mu))
156+
sigma = at.as_tensor_variable(floatX(sigma))
157+
steps = at.as_tensor_variable(intX(steps))
158+
159+
if "shape" in kwargs.keys():
160+
shape = kwargs["shape"]
161+
else:
162+
shape = None
163+
164+
# If no scalar distribution is passed then initialize with a Normal of same sd and mu
165+
if init is None:
166+
init = Normal.dist(mu, sigma, size=size)
167+
else:
168+
if not (
169+
isinstance(init, at.TensorVariable)
170+
and init.owner is not None
171+
and isinstance(init.owner.op, RandomVariable)
172+
and init.owner.op.ndim_supp == 0
173+
):
174+
raise TypeError("init must be a univariate distribution variable")
175+
176+
if size is not None or shape is not None:
177+
init = change_rv_size(init, to_tuple(size or shape))
178+
else:
179+
# If not explicit, size is determined by the shape of mu and sigma
180+
mu_ = at.broadcast_arrays(mu, sigma)[0]
181+
init = change_rv_size(init, mu_.shape)
182+
183+
# Ignores logprob of init var because that's accounted for in the logp method
184+
init.tag.ignore_logprob = True
185+
186+
return super().dist([mu, sigma, init, steps], size=size, **kwargs)
187+
188+
def logp(
189+
value: at.Variable,
190+
mu: at.Variable,
191+
sigma: at.Variable,
192+
init: at.Variable,
193+
steps: at.Variable,
194+
) -> at.TensorVariable:
195+
"""Calculate log-probability of Gaussian Random Walk distribution at specified value.
196+
197+
Parameters
198+
----------
199+
value: at.Variable,
200+
mu: at.Variable,
201+
sigma: at.Variable,
202+
init: at.Variable, Not used
203+
steps: at.Variable, Not used
204+
205+
Returns
206+
-------
207+
TensorVariable
208+
"""
209+
210+
# Calculate initialization logp
211+
init_logp = logprob.logp(init, value[..., 0])
212+
213+
# Make time series stationary around the mean value
214+
stationary_series = value[..., 1:] - value[..., :-1]
215+
series_logp = logprob.logp(Normal.dist(mu, sigma), stationary_series)
216+
217+
return check_parameters(
218+
init_logp + series_logp.sum(axis=-1),
219+
steps > 0,
220+
msg="steps > 0",
221+
)
222+
223+
36224
class AR1(distribution.Continuous):
37225
"""
38226
Autoregressive process with 1 lag.
@@ -171,125 +359,6 @@ def logp(self, value):
171359
return at.sum(innov_like) + at.sum(init_like)
172360

173361

174-
class GaussianRandomWalk(distribution.Continuous):
175-
r"""Random Walk with Normal innovations
176-
177-
Note that this is mainly a user-friendly wrapper to enable an easier specification
178-
of GRW. You are not restricted to use only Normal innovations but can use any
179-
distribution: just use `aesara.tensor.cumsum()` to create the random walk behavior.
180-
181-
Parameters
182-
----------
183-
mu : tensor_like of float, default 0
184-
innovation drift, defaults to 0.0
185-
For vector valued `mu`, first dimension must match shape of the random walk, and
186-
the first element will be discarded (since there is no innovation in the first timestep)
187-
sigma : tensor_like of float, optional
188-
`sigma` > 0, innovation standard deviation (only required if `tau` is not specified)
189-
For vector valued `sigma`, first dimension must match shape of the random walk, and
190-
the first element will be discarded (since there is no innovation in the first timestep)
191-
tau : tensor_like of float, optional
192-
`tau` > 0, innovation precision (only required if `sigma` is not specified)
193-
For vector valued `tau`, first dimension must match shape of the random walk, and
194-
the first element will be discarded (since there is no innovation in the first timestep)
195-
init : pymc.Distribution, optional
196-
distribution for initial value (Defaults to Flat())
197-
"""
198-
199-
def __init__(self, tau=None, init=None, sigma=None, mu=0.0, *args, **kwargs):
200-
kwargs.setdefault("shape", 1)
201-
super().__init__(*args, **kwargs)
202-
if sum(self.shape) == 0:
203-
raise TypeError("GaussianRandomWalk must be supplied a non-zero shape argument!")
204-
tau, sigma = get_tau_sigma(tau=tau, sigma=sigma)
205-
self.tau = at.as_tensor_variable(tau)
206-
sigma = at.as_tensor_variable(sigma)
207-
self.sigma = sigma
208-
self.mu = at.as_tensor_variable(mu)
209-
self.init = init or Flat.dist()
210-
self.mean = at.as_tensor_variable(0.0)
211-
212-
def _mu_and_sigma(self, mu, sigma):
213-
"""Helper to get mu and sigma if they are high dimensional."""
214-
if sigma.ndim > 0:
215-
sigma = sigma[1:]
216-
if mu.ndim > 0:
217-
mu = mu[1:]
218-
return mu, sigma
219-
220-
def logp(self, x):
221-
"""
222-
Calculate log-probability of Gaussian Random Walk distribution at specified value.
223-
224-
Parameters
225-
----------
226-
x : numeric
227-
Value for which log-probability is calculated.
228-
229-
Returns
230-
-------
231-
TensorVariable
232-
"""
233-
if x.ndim > 0:
234-
x_im1 = x[:-1]
235-
x_i = x[1:]
236-
mu, sigma = self._mu_and_sigma(self.mu, self.sigma)
237-
innov_like = Normal.dist(mu=x_im1 + mu, sigma=sigma).logp(x_i)
238-
return self.init.logp(x[0]) + at.sum(innov_like)
239-
return self.init.logp(x)
240-
241-
def random(self, point=None, size=None):
242-
"""Draw random values from GaussianRandomWalk.
243-
244-
Parameters
245-
----------
246-
point : dict or Point, optional
247-
Dict of variable values on which random values are to be
248-
conditioned (uses default point if not specified).
249-
size : int, optional
250-
Desired size of random sample (returns one sample if not
251-
specified).
252-
253-
Returns
254-
-------
255-
array
256-
"""
257-
# sigma, mu = distribution.draw_values([self.sigma, self.mu], point=point, size=size)
258-
# return distribution.generate_samples(
259-
# self._random,
260-
# sigma=sigma,
261-
# mu=mu,
262-
# size=size,
263-
# dist_shape=self.shape,
264-
# not_broadcast_kwargs={"sample_shape": to_tuple(size)},
265-
# )
266-
pass
267-
268-
def _random(self, sigma, mu, size, sample_shape):
269-
"""Implement a Gaussian random walk as a cumulative sum of normals.
270-
axis = len(size) - 1 denotes the axis along which cumulative sum would be calculated.
271-
This might need to be corrected in future when issue #4010 is fixed.
272-
"""
273-
if size[len(sample_shape)] == sample_shape:
274-
axis = len(sample_shape)
275-
else:
276-
axis = len(size) - 1
277-
rv = stats.norm(mu, sigma)
278-
data = rv.rvs(size).cumsum(axis=axis)
279-
data = np.array(data)
280-
281-
# the following lines center the random walk to start at the origin.
282-
if len(data.shape) > 1:
283-
for i in range(data.shape[0]):
284-
data[i] = data[i] - data[i][0]
285-
else:
286-
data = data - data[0]
287-
return data
288-
289-
def _distr_parameters_for_repr(self):
290-
return ["mu", "sigma"]
291-
292-
293362
class GARCH11(distribution.Continuous):
294363
r"""
295364
GARCH(1,1) with Normal innovations. The model is specified by

pymc/tests/test_distributions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2619,6 +2619,22 @@ def test_interpolated_transform(self, transform):
26192619
assert not np.isfinite(m.compile_logp()({"x": -1.0}))
26202620
assert not np.isfinite(m.compile_logp()({"x": 11.0}))
26212621

2622+
def test_gaussianrandomwalk(self):
2623+
def ref_logp(value, mu, sigma, steps):
2624+
# Relying on fact that init will be normal by default
2625+
return (
2626+
scipy.stats.norm.logpdf(value[0], mu, sigma)
2627+
+ scipy.stats.norm.logpdf(np.diff(value), mu, sigma).sum()
2628+
)
2629+
2630+
self.check_logp(
2631+
pm.GaussianRandomWalk,
2632+
Vector(R, 4),
2633+
{"mu": R, "sigma": Rplus, "steps": Nat},
2634+
ref_logp,
2635+
decimal=select_by_precision(float64=6, float32=1),
2636+
)
2637+
26222638

26232639
class TestBound:
26242640
"""Tests for pm.Bound distribution"""

0 commit comments

Comments
 (0)