Skip to content

Commit f966049

Browse files
authored
Merge pull request #77 from pymc-devs/main
CAR random variables (pymc-devs#4596)
2 parents 9b9f46c + 819f045 commit f966049

File tree

4 files changed

+207
-70
lines changed

4 files changed

+207
-70
lines changed

pymc3/distributions/multivariate.py

Lines changed: 107 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
import numpy as np
2525
import scipy
2626

27+
from aesara.assert_op import Assert
2728
from aesara.graph.basic import Apply
2829
from aesara.graph.op import Op
30+
from aesara.sparse.basic import sp_sum
2931
from aesara.tensor import gammaln, sigmoid
3032
from aesara.tensor.nlinalg import det, eigh, matrix_inverse, trace
3133
from aesara.tensor.random.basic import MultinomialRV, dirichlet, multivariate_normal
@@ -397,7 +399,6 @@ def __new__(cls, name, *args, **kwargs):
397399

398400
@classmethod
399401
def dist(cls, a, **kwargs):
400-
401402
a = at.as_tensor_variable(a)
402403
# mean = a / at.sum(a)
403404
# mode = at.switch(at.all(a > 1), (a - 1) / at.sum(a - 1), np.nan)
@@ -491,7 +492,6 @@ class Multinomial(Discrete):
491492

492493
@classmethod
493494
def dist(cls, n, p, *args, **kwargs):
494-
495495
p = p / at.sum(p, axis=-1, keepdims=True)
496496
n = at.as_tensor_variable(n)
497497
p = at.as_tensor_variable(p)
@@ -1925,6 +1925,81 @@ def _distr_parameters_for_repr(self):
19251925
return ["mu"]
19261926

19271927

1928+
class CARRV(RandomVariable):
1929+
name = "car"
1930+
ndim_supp = 1
1931+
ndims_params = [1, 2, 0, 0]
1932+
dtype = "floatX"
1933+
_print_name = ("CAR", "\\operatorname{CAR}")
1934+
1935+
def make_node(self, rng, size, dtype, mu, W, alpha, tau):
1936+
mu = at.as_tensor_variable(floatX(mu))
1937+
1938+
W = aesara.sparse.as_sparse_or_tensor_variable(floatX(W))
1939+
if not W.ndim == 2:
1940+
raise ValueError("W must be a matrix (ndim=2).")
1941+
1942+
sparse = isinstance(W, aesara.sparse.SparseVariable)
1943+
msg = "W must be a symmetric adjacency matrix."
1944+
if sparse:
1945+
abs_diff = aesara.sparse.basic.mul(aesara.sparse.basic.sgn(W - W.T), W - W.T)
1946+
W = Assert(msg)(W, at.isclose(aesara.sparse.basic.sp_sum(abs_diff), 0))
1947+
else:
1948+
W = Assert(msg)(W, at.allclose(W, W.T))
1949+
1950+
tau = at.as_tensor_variable(floatX(tau))
1951+
alpha = at.as_tensor_variable(floatX(alpha))
1952+
1953+
return super().make_node(rng, size, dtype, mu, W, alpha, tau)
1954+
1955+
def _infer_shape(self, size, dist_params, param_shapes=None):
1956+
shape = tuple(size) + tuple(dist_params[0].shape)
1957+
return shape
1958+
1959+
@classmethod
1960+
def rng_fn(cls, rng: np.random.RandomState, mu, W, alpha, tau, size):
1961+
"""
1962+
Implementation of algorithm from paper
1963+
Havard Rue, 2001. "Fast sampling of Gaussian Markov random fields,"
1964+
Journal of the Royal Statistical Society Series B, Royal Statistical Society,
1965+
vol. 63(2), pages 325-338. DOI: 10.1111/1467-9868.00288
1966+
"""
1967+
if not scipy.sparse.issparse(W):
1968+
W = scipy.sparse.csr_matrix(W)
1969+
s = np.asarray(W.sum(axis=0))[0]
1970+
D = scipy.sparse.diags(s)
1971+
tau = scipy.sparse.csr_matrix(tau)
1972+
alpha = scipy.sparse.csr_matrix(alpha)
1973+
1974+
Q = tau.multiply(D - alpha.multiply(W))
1975+
1976+
perm_array = scipy.sparse.csgraph.reverse_cuthill_mckee(Q, symmetric_mode=True)
1977+
inv_perm = np.argsort(perm_array)
1978+
1979+
Q = Q[perm_array, :][:, perm_array]
1980+
1981+
Qb = Q.diagonal()
1982+
u = 1
1983+
while np.count_nonzero(Q.diagonal(u)) > 0:
1984+
Qb = np.vstack((np.pad(Q.diagonal(u), (u, 0), constant_values=(0, 0)), Qb))
1985+
u += 1
1986+
1987+
L = scipy.linalg.cholesky_banded(Qb, lower=False)
1988+
1989+
size = tuple(size or ())
1990+
if size:
1991+
mu = np.broadcast_to(mu, size + mu.shape)
1992+
z = rng.normal(size=mu.shape)
1993+
samples = np.empty(z.shape)
1994+
for idx in np.ndindex(mu.shape[:-1]):
1995+
samples[idx] = scipy.linalg.cho_solve_banded((L, False), z[idx]) + mu[idx][perm_array]
1996+
samples = samples[..., inv_perm]
1997+
return samples
1998+
1999+
2000+
car = CARRV()
2001+
2002+
19282003
class CAR(Continuous):
19292004
r"""
19302005
Likelihood for a conditional autoregression. This is a special case of the
@@ -1966,45 +2041,13 @@ class CAR(Continuous):
19662041
"Generalized Hierarchical Multivariate CAR Models for Areal Data"
19672042
Biometrics, Vol. 61, No. 4 (Dec., 2005), pp. 950-961
19682043
"""
2044+
rv_op = car
19692045

1970-
def __init__(self, mu, W, alpha, tau, sparse=False, *args, **kwargs):
1971-
super().__init__(*args, **kwargs)
1972-
1973-
D = W.sum(axis=0)
1974-
d, _ = W.shape
1975-
1976-
self.d = d
1977-
self.median = self.mode = self.mean = self.mu = at.as_tensor_variable(mu)
1978-
self.sparse = sparse
1979-
1980-
if not W.ndim == 2 or not np.allclose(W, W.T):
1981-
raise ValueError("W must be a symmetric adjacency matrix.")
1982-
1983-
if sparse:
1984-
W_sparse = scipy.sparse.csr_matrix(W)
1985-
self.W = aesara.sparse.as_sparse_variable(W_sparse)
1986-
else:
1987-
self.W = at.as_tensor_variable(W)
1988-
1989-
# eigenvalues of D^−1/2 * W * D^−1/2
1990-
Dinv_sqrt = np.diag(1 / np.sqrt(D))
1991-
DWD = np.matmul(np.matmul(Dinv_sqrt, W), Dinv_sqrt)
1992-
self.lam = scipy.linalg.eigvalsh(DWD)
1993-
self.D = at.as_tensor_variable(D)
1994-
1995-
tau = at.as_tensor_variable(tau)
1996-
if tau.ndim > 0:
1997-
self.tau = tau[:, None]
1998-
else:
1999-
self.tau = tau
2000-
2001-
alpha = at.as_tensor_variable(alpha)
2002-
if alpha.ndim > 0:
2003-
self.alpha = alpha[:, None]
2004-
else:
2005-
self.alpha = alpha
2046+
@classmethod
2047+
def dist(cls, mu, W, alpha, tau, *args, **kwargs):
2048+
return super().dist([mu, W, alpha, tau], **kwargs)
20062049

2007-
def logp(self, value):
2050+
def logp(value, mu, W, alpha, tau):
20082051
"""
20092052
Calculate log-probability of a CAR-distributed vector
20102053
at specified value. This log probability function differs from
@@ -2021,30 +2064,37 @@ def logp(self, value):
20212064
TensorVariable
20222065
"""
20232066

2067+
sparse = isinstance(W, aesara.sparse.SparseVariable)
2068+
2069+
if sparse:
2070+
D = sp_sum(W, axis=0)
2071+
Dinv_sqrt = at.diag(1 / at.sqrt(D))
2072+
DWD = at.dot(aesara.sparse.dot(Dinv_sqrt, W), Dinv_sqrt)
2073+
else:
2074+
D = W.sum(axis=0)
2075+
Dinv_sqrt = at.diag(1 / at.sqrt(D))
2076+
DWD = at.dot(at.dot(Dinv_sqrt, W), Dinv_sqrt)
2077+
lam = at.slinalg.eigvalsh(DWD, at.eye(DWD.shape[0]))
2078+
2079+
d, _ = W.shape
2080+
20242081
if value.ndim == 1:
20252082
value = value[None, :]
20262083

2027-
logtau = self.d * at.log(self.tau).sum()
2028-
logdet = at.log(1 - self.alpha.T * self.lam[:, None]).sum()
2029-
delta = value - self.mu
2084+
logtau = d * at.log(tau).sum()
2085+
logdet = at.log(1 - alpha.T * lam[:, None]).sum()
2086+
delta = value - mu
20302087

2031-
if self.sparse:
2032-
Wdelta = aesara.sparse.dot(delta, self.W)
2088+
if sparse:
2089+
Wdelta = aesara.sparse.dot(delta, W)
20332090
else:
2034-
Wdelta = at.dot(delta, self.W)
2091+
Wdelta = at.dot(delta, W)
20352092

2036-
tau_dot_delta = self.D[None, :] * delta - self.alpha * Wdelta
2037-
logquad = (self.tau * delta * tau_dot_delta).sum(axis=-1)
2093+
tau_dot_delta = D[None, :] * delta - alpha * Wdelta
2094+
logquad = (tau * delta * tau_dot_delta).sum(axis=-1)
20382095
return bound(
20392096
0.5 * (logtau + logdet - logquad),
2040-
self.alpha >= -1,
2041-
self.alpha <= 1,
2042-
self.tau > 0,
2043-
broadcast_conditions=False,
2097+
at.all(alpha <= 1),
2098+
at.all(alpha >= -1),
2099+
tau > 0,
20442100
)
2045-
2046-
def random(self, point=None, size=None):
2047-
raise NotImplementedError("Sampling from a CAR distribution is not supported.")
2048-
2049-
def _distr_parameters_for_repr(self):
2050-
return ["mu", "W", "alpha", "tau"]

pymc3/tests/conftest.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818

1919
import pymc3 as pm
2020

21-
# @pytest.fixture(scope="function", autouse=True)
22-
# def aesara_config():
23-
# config = aesara.config.change_flags(compute_test_value="raise")
24-
# with config:
25-
# yield
21+
22+
@pytest.fixture(scope="function", autouse=True)
23+
def aesara_config():
24+
config = aesara.config.change_flags(on_opt_error="raise")
25+
with config:
26+
yield
2627

2728

2829
@pytest.fixture(scope="function", autouse=True)

pymc3/tests/test_distributions.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3025,41 +3025,87 @@ def test_ordered_probit_probs():
30253025
assert isinstance(x, TensorVariable)
30263026

30273027

3028-
@pytest.mark.xfail(reason="Distribution not refactored yet")
3029-
@pytest.mark.parametrize("size", [(1,), (4,)], ids=str)
3030-
def test_car_logp(size):
3028+
@pytest.mark.parametrize(
3029+
"sparse, size",
3030+
[(False, ()), (False, (1,)), (False, (4,)), (False, (4, 4, 4)), (True, ()), (True, (4,))],
3031+
ids=str,
3032+
)
3033+
def test_car_logp(sparse, size):
30313034
"""
30323035
Tests the log probability function for the CAR distribution by checking
30333036
against Scipy's multivariate normal logpdf, up to an additive constant.
30343037
The formula used by the CAR logp implementation omits several additive terms.
30353038
"""
30363039
np.random.seed(1)
30373040

3038-
xs = np.random.randn(*size)
3039-
30403041
# d x d adjacency matrix for a square (d=4) of rook-adjacent sites
30413042
W = np.array(
30423043
[[0.0, 1.0, 1.0, 0.0], [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 1.0, 0.0]]
30433044
)
30443045

3045-
tau = 2.0
3046+
tau = 2
30463047
alpha = 0.5
30473048
mu = np.zeros(4)
30483049

3050+
xs = np.random.randn(*(size + mu.shape))
3051+
30493052
# Compute CAR covariance matrix and resulting MVN logp
30503053
D = W.sum(axis=0)
30513054
prec = tau * (np.diag(D) - alpha * W)
30523055
cov = np.linalg.inv(prec)
30533056
scipy_logp = scipy.stats.multivariate_normal.logpdf(xs, mu, cov)
30543057

3055-
car_logp = logp(CAR.dist(mu, W, alpha, tau, size=size), xs).eval()
3058+
W = aesara.tensor.as_tensor_variable(W)
3059+
if sparse:
3060+
W = aesara.sparse.csr_from_dense(W)
3061+
3062+
car_dist = CAR.dist(mu, W, alpha, tau, size=size)
3063+
car_logp = logp(car_dist, xs).eval()
30563064

30573065
# Check to make sure that the CAR and MVN log PDFs are equivalent
30583066
# up to an additive constant which is independent of the CAR parameters
30593067
delta_logp = scipy_logp - car_logp
30603068

30613069
# Check to make sure all the delta values are identical.
3062-
assert np.allclose(delta_logp - delta_logp[0], 0.0)
3070+
tol = 1e-08
3071+
if aesara.config.floatX == "float32":
3072+
tol = 1e-5
3073+
assert np.allclose(delta_logp - delta_logp[0], 0.0, atol=tol)
3074+
3075+
3076+
@pytest.mark.parametrize(
3077+
"sparse",
3078+
[False, True],
3079+
ids=str,
3080+
)
3081+
def test_car_matrix_check(sparse):
3082+
"""
3083+
Tests the check of W matrix symmetry in CARRV.make_node.
3084+
"""
3085+
np.random.seed(1)
3086+
tau = 2
3087+
alpha = 0.5
3088+
mu = np.zeros(4)
3089+
xs = np.random.randn(*mu.shape)
3090+
3091+
# non-symmetric matrix
3092+
W = np.array(
3093+
[[0.0, 1.0, 2.0, 0.0], [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 1.0, 0.0]]
3094+
)
3095+
W = aesara.tensor.as_tensor_variable(W)
3096+
if sparse:
3097+
W = aesara.sparse.csr_from_dense(W)
3098+
3099+
car_dist = CAR.dist(mu, W, alpha, tau)
3100+
with pytest.raises(AssertionError, match="W must be a symmetric adjacency matrix"):
3101+
logp(car_dist, xs).eval()
3102+
3103+
# W.ndim != 2
3104+
if not sparse:
3105+
W = np.array([0.0, 1.0, 2.0, 0.0])
3106+
W = aesara.tensor.as_tensor_variable(W)
3107+
with pytest.raises(ValueError, match="W must be a matrix"):
3108+
car_dist = CAR.dist(mu, W, alpha, tau)
30633109

30643110

30653111
class TestBugfixes:

pymc3/tests/test_distributions_random.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,3 +2380,43 @@ def test_with_cov_rv(self, sample_shape, dist_shape, mu_shape):
23802380
prior = pm.sample_prior_predictive(samples=sample_shape)
23812381

23822382
assert prior["mv"].shape == to_tuple(sample_shape) + dist_shape
2383+
2384+
2385+
@pytest.mark.parametrize("sparse", [True, False])
2386+
def test_car_rng_fn(sparse):
2387+
delta = 0.05 # limit for KS p-value
2388+
n_fails = 20 # Allows the KS fails a certain number of times
2389+
size = (100,)
2390+
2391+
W = np.array(
2392+
[[0.0, 1.0, 1.0, 0.0], [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 1.0, 0.0]]
2393+
)
2394+
2395+
tau = 2
2396+
alpha = 0.5
2397+
mu = np.array([1, 1, 1, 1])
2398+
2399+
D = W.sum(axis=0)
2400+
prec = tau * (np.diag(D) - alpha * W)
2401+
cov = np.linalg.inv(prec)
2402+
W = aesara.tensor.as_tensor_variable(W)
2403+
if sparse:
2404+
W = aesara.sparse.csr_from_dense(W)
2405+
2406+
with pm.Model(rng_seeder=1):
2407+
car = pm.CAR("car", mu, W, alpha, tau, size=size)
2408+
mn = pm.MvNormal("mn", mu, cov, size=size)
2409+
check = pm.sample_prior_predictive(n_fails)
2410+
2411+
p, f = delta, n_fails
2412+
while p <= delta and f > 0:
2413+
car_smp, mn_smp = check["car"][f - 1, :, :], check["mn"][f - 1, :, :]
2414+
p = min(
2415+
st.ks_2samp(
2416+
np.atleast_1d(car_smp[..., idx]).flatten(),
2417+
np.atleast_1d(mn_smp[..., idx]).flatten(),
2418+
)[1]
2419+
for idx in range(car_smp.shape[-1])
2420+
)
2421+
f -= 1
2422+
assert p > delta

0 commit comments

Comments
 (0)