Skip to content

Commit b5e1b41

Browse files
committed
Implement Symmetric and Asymmetric Multivariate Laplace distributions
1 parent 0f5a818 commit b5e1b41

File tree

4 files changed

+488
-0
lines changed

4 files changed

+488
-0
lines changed

docs/api_reference.rst

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Distributions
3939
GeneralizedPoisson
4040
BetaNegativeBinomial
4141
GenExtreme
42+
MvAsymmetricLaplace
43+
MvLaplace
4244
R2D2M2CP
4345
Skellam
4446
histogram_approximation

pymc_experimental/distributions/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Skellam,
2525
)
2626
from pymc_experimental.distributions.histogram_utils import histogram_approximation
27+
from pymc_experimental.distributions.multivariate.laplace import MvAsymmetricLaplace, MvLaplace
2728
from pymc_experimental.distributions.multivariate.r2d2m2cp import R2D2M2CP
2829
from pymc_experimental.distributions.timeseries import DiscreteMarkovChain
2930

@@ -32,6 +33,8 @@
3233
"DiscreteMarkovChain",
3334
"GeneralizedPoisson",
3435
"GenExtreme",
36+
"MvAsymmetricLaplace",
37+
"MvLaplace",
3538
"R2D2M2CP",
3639
"Skellam",
3740
"histogram_approximation",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import numpy as np
2+
import pytensor.tensor as pt
3+
import scipy
4+
5+
from pymc.distributions.dist_math import check_parameters
6+
from pymc.distributions.distribution import Continuous, SymbolicRandomVariable, _support_point
7+
from pymc.distributions.moments.means import _mean
8+
from pymc.distributions.multivariate import (
9+
_logdet_from_cholesky,
10+
nan_lower_cholesky,
11+
quaddist_chol,
12+
quaddist_matrix,
13+
solve_lower,
14+
)
15+
from pymc.distributions.shape_utils import implicit_size_from_params, rv_size_is_none
16+
from pymc.logprob.basic import _logprob
17+
from pymc.pytensorf import normalize_rng_param
18+
from pytensor.gradient import grad_not_implemented
19+
from pytensor.scalar import BinaryScalarOp, upgrade_to_float
20+
from pytensor.tensor.elemwise import Elemwise
21+
from pytensor.tensor.random.utils import normalize_size_param
22+
23+
24+
class Kv(BinaryScalarOp):
25+
"""
26+
Modified Bessel function of the second kind of real order v.
27+
"""
28+
29+
nfunc_spec = ("scipy.special.kv", 2, 1)
30+
31+
@staticmethod
32+
def st_impl(v, x):
33+
return scipy.special.kv(v, x)
34+
35+
def impl(self, v, x):
36+
return self.st_impl(v, x)
37+
38+
def L_op(self, inputs, outputs, output_grads):
39+
v, x = inputs
40+
[out] = outputs
41+
[g_out] = output_grads
42+
dx = -(v / x) * out - self.kv(v - 1, x)
43+
return [grad_not_implemented(self, 0, v), g_out * dx]
44+
45+
def c_code(self, *args, **kwargs):
46+
raise NotImplementedError()
47+
48+
49+
kv = Elemwise(Kv(upgrade_to_float, name="kv"))
50+
51+
52+
class MvLaplaceRV(SymbolicRandomVariable):
53+
name = "multivariate_laplace"
54+
extended_signature = "[rng],[size],(m),(m,m)->[rng],(m)"
55+
_print_name = ("MultivariateLaplace", "\\operatorname{MultivariateLaplace}")
56+
57+
@classmethod
58+
def rv_op(cls, mu, cov, *, size=None, rng=None):
59+
mu = pt.as_tensor(mu)
60+
cov = pt.as_tensor(cov)
61+
rng = normalize_rng_param(rng)
62+
size = normalize_size_param(size)
63+
64+
assert mu.type.ndim >= 1
65+
assert cov.type.ndim >= 2
66+
67+
if rv_size_is_none(size):
68+
size = implicit_size_from_params(mu, cov, ndims_params=(1, 2))
69+
70+
next_rng, e = pt.random.exponential(size=size, rng=rng).owner.outputs
71+
next_rng, z = pt.random.multivariate_normal(
72+
mean=pt.zeros(mu.shape[-1]), cov=cov, size=size, rng=next_rng
73+
).owner.outputs
74+
rv = mu + pt.sqrt(e)[..., None] * z
75+
76+
return cls(
77+
inputs=[rng, size, mu, cov],
78+
outputs=[next_rng, rv],
79+
)(rng, size, mu, cov)
80+
81+
82+
class MvLaplace(Continuous):
83+
r"""Multivariate (Symmetric) Laplace distribution.
84+
85+
The pdf of this distribution is
86+
87+
.. math::
88+
89+
pdf(x \mid \mu, \Sigma) =
90+
\frac{2}{(2\pi)^{k/2} |\Sigma|^{1/2}}
91+
( \frac{(x-\mu)'\Sigma^{-1}(x-mu)}{2} )^{v/2}
92+
\K_v (\sqrt{2(x-\mu)' \Sigma^{-1} (x - \mu)}})
93+
94+
where :math:`v = 1 - k/2` and :math:`\K_v` is the modified Bessel function of the second kind.
95+
96+
======== ==========================
97+
Support :math:`x \in \mathbb{R}^k`
98+
Mean :math:`\mu`
99+
Variance :math:`\Sigma`
100+
======== ==========================
101+
102+
Parameters
103+
----------
104+
mu : tensor_like of float
105+
Location.
106+
cov : tensor_like of float, optional
107+
Covariance matrix. Exactly one of cov, tau, or chol is needed.
108+
tau : tensor_like of float, optional
109+
Precision matrix. Exactly one of cov, tau, or chol is needed.
110+
chol : tensor_like of float, optional
111+
Cholesky decomposition of covariance matrix. Exactly one of cov,
112+
tau, or chol is needed.
113+
lower: bool, default=True
114+
Whether chol is the lower tridiagonal cholesky factor.
115+
"""
116+
117+
rv_type = MvLaplaceRV
118+
rv_op = MvLaplaceRV.rv_op
119+
120+
@classmethod
121+
def dist(cls, mu=0, cov=None, *, tau=None, chol=None, lower=True, **kwargs):
122+
cov = quaddist_matrix(cov, chol, tau, lower)
123+
124+
mu = pt.atleast_1d(pt.as_tensor_variable(mu))
125+
if mu.type.broadcastable[-1] and not cov.type.broadcastable[-1]:
126+
mu, _ = pt.broadcast_arrays(mu, cov[..., -1])
127+
return super().dist([mu, cov], **kwargs)
128+
129+
130+
class MvAsymmetricLaplaceRV(SymbolicRandomVariable):
131+
name = "multivariate_asymmetric_laplace"
132+
extended_signature = "[rng],[size],(m),(m,m)->[rng],(m)"
133+
_print_name = ("MultivariateAsymmetricLaplace", "\\operatorname{MultivariateAsymmetricLaplace}")
134+
135+
@classmethod
136+
def rv_op(cls, mu, cov, *, size=None, rng=None):
137+
mu = pt.as_tensor(mu)
138+
cov = pt.as_tensor(cov)
139+
rng = normalize_rng_param(rng)
140+
size = normalize_size_param(size)
141+
142+
assert mu.type.ndim >= 1
143+
assert cov.type.ndim >= 2
144+
145+
if rv_size_is_none(size):
146+
size = implicit_size_from_params(mu, cov, ndims_params=(1, 2))
147+
148+
next_rng, e = pt.random.exponential(size=size, rng=rng).owner.outputs
149+
next_rng, z = pt.random.multivariate_normal(
150+
mean=pt.zeros(mu.shape[-1]), cov=cov, size=size, rng=next_rng
151+
).owner.outputs
152+
e = e[..., None]
153+
rv = e * mu + pt.sqrt(e) * z
154+
155+
return cls(
156+
inputs=[rng, size, mu, cov],
157+
outputs=[next_rng, rv],
158+
)(rng, size, mu, cov)
159+
160+
161+
class MvAsymmetricLaplace(Continuous):
162+
r"""Multivariate Asymmetric Laplace distribution.
163+
164+
The pdf of this distribution is
165+
166+
.. math::
167+
168+
pdf(x \mid \mu, \Sigma) =
169+
\frac{2}{(2\pi)^{k/2} |\Sigma|^{1/2}}
170+
( \frac{(x-\mu)'\Sigma^{-1}(x-mu)}{2} )^{v/2}
171+
\K_v (\sqrt{2(x-\mu)' \Sigma^{-1} (x - \mu)}})
172+
173+
where :math:`v = 1 - k/2` and :math:`\K_v` is the modified Bessel function of the second kind.
174+
175+
======== ==========================
176+
Support :math:`x \in \mathbb{R}^k`
177+
Mean :math:`\mu`
178+
Variance :math:`\Sigma + \mu' \mu`
179+
======== ==========================
180+
181+
Parameters
182+
----------
183+
mu : tensor_like of float
184+
Location.
185+
cov : tensor_like of float, optional
186+
Covariance matrix. Exactly one of cov, tau, or chol is needed.
187+
tau : tensor_like of float, optional
188+
Precision matrix. Exactly one of cov, tau, or chol is needed.
189+
chol : tensor_like of float, optional
190+
Cholesky decomposition of covariance matrix. Exactly one of cov,
191+
tau, or chol is needed.
192+
lower: bool, default=True
193+
Whether chol is the lower tridiagonal cholesky factor.
194+
"""
195+
196+
rv_type = MvAsymmetricLaplaceRV
197+
rv_op = MvAsymmetricLaplaceRV.rv_op
198+
199+
@classmethod
200+
def dist(cls, mu=0, cov=None, *, tau=None, chol=None, lower=True, **kwargs):
201+
cov = quaddist_matrix(cov, chol, tau, lower)
202+
203+
mu = pt.atleast_1d(pt.as_tensor_variable(mu))
204+
if mu.type.broadcastable[-1] and not cov.type.broadcastable[-1]:
205+
mu, _ = pt.broadcast_arrays(mu, cov[..., -1])
206+
return super().dist([mu, cov], **kwargs)
207+
208+
209+
@_logprob.register(MvLaplaceRV)
210+
def mv_laplace_logp(op, values, rng, size, mu, cov, **kwargs):
211+
[value] = values
212+
quaddist, logdet, posdef = quaddist_chol(value, mu, cov)
213+
214+
k = value.shape[-1].astype("floatX")
215+
norm = np.log(2) - 0.5 * k * np.log(2 * np.pi) - logdet
216+
217+
v = 1 - (k / 2)
218+
kernel = ((v / 2) * pt.log(quaddist / 2)) + pt.log(kv(v, pt.sqrt(2 * quaddist)))
219+
220+
logp_val = norm + kernel
221+
return check_parameters(logp_val, posdef, msg="posdef scale")
222+
223+
224+
@_logprob.register(MvAsymmetricLaplaceRV)
225+
def mv_asymmetric_laplace_logp(op, values, rng, size, mu, cov, **kwargs):
226+
[value] = values
227+
228+
chol_cov = nan_lower_cholesky(cov)
229+
logdet, posdef = _logdet_from_cholesky(chol_cov)
230+
231+
# solve_triangular will raise if there are nans
232+
# (which happens if the cholesky fails)
233+
chol_cov = pt.switch(posdef[..., None, None], chol_cov, 1)
234+
235+
solve_x = solve_lower(chol_cov, value, b_ndim=1)
236+
solve_mu = solve_lower(chol_cov, mu, b_ndim=1)
237+
238+
x_quaddist = (solve_x**2).sum(-1)
239+
mu_quaddist = (solve_mu**2).sum(-1)
240+
x_mu_quaddist = (value * solve_mu).sum(-1)
241+
242+
k = value.shape[-1].astype("floatX")
243+
norm = np.log(2) - 0.5 * k * np.log(2 * np.pi) - logdet
244+
245+
v = 1 - (k / 2)
246+
kernel = (
247+
x_mu_quaddist
248+
+ ((v / 2) * (pt.log(x_quaddist) - pt.log(2 + mu_quaddist)))
249+
+ pt.log(kv(v, pt.sqrt((2 + mu_quaddist) * x_quaddist)))
250+
)
251+
252+
logp_val = norm + kernel
253+
return check_parameters(logp_val, posdef, msg="posdef scale")
254+
255+
256+
@_mean.register(MvLaplaceRV)
257+
@_mean.register(MvAsymmetricLaplaceRV)
258+
def mv_laplace_mean(op, rv, rng, size, mu, cov):
259+
if rv_size_is_none(size):
260+
bcast_mu, _ = pt.random.utils.broadcast_params([mu, cov], ndims_params=[1, 2])
261+
else:
262+
bcast_mu = pt.broadcast_to(mu, pt.concatenate([size, [mu.shape[-1]]]))
263+
return bcast_mu
264+
265+
266+
@_support_point.register(MvLaplaceRV)
267+
@_support_point.register(MvAsymmetricLaplaceRV)
268+
def mv_laplace_support_point(op, rv, rng, size, mu, cov):
269+
# We have a 0 * inf when value = mu. I assume density is infinite, which isn't a good starting point.
270+
point = mu + 1
271+
if rv_size_is_none(size):
272+
bcast_point, _ = pt.random.utils.broadcast_params([point, cov], ndims_params=[1, 2])
273+
else:
274+
bcast_shape = pt.concatenate([size, [point.shape[-1]]])
275+
bcast_point = pt.broadcast_to(point, bcast_shape)
276+
return bcast_point

0 commit comments

Comments
 (0)