Skip to content

Commit b1c5e39

Browse files
First linear model (#3)
* bump version to 0.0.1 * Added some tests for the new input system * bump version to 0.0.2 * Updated zenodo badge * towards 100% coverage * Corrected lint * 100% coverage * Added version info to main file * Corrected typo * Thinned chains * Added check to version
1 parent 347a258 commit b1c5e39

File tree

8 files changed

+288
-6
lines changed

8 files changed

+288
-6
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ build
88
*~
99
.pytest_cache/*
1010
.coverage
11+
venv

README.rst

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ lsbi: Linear Simulation Based Inference
33
=======================================
44
:lsbi: Linear Simulation Based Inference
55
:Author: Will Handley
6-
:Version: 0.0.1
6+
:Version: 0.0.2
77
:Homepage: https://github.com/handley-lab/lsbi
88
:Documentation: http://lsbi.readthedocs.io/
99

@@ -19,8 +19,8 @@ lsbi: Linear Simulation Based Inference
1919
.. image:: https://badge.fury.io/py/lsbi.svg
2020
:target: https://badge.fury.io/py/lsbi
2121
:alt: PyPi location
22-
.. image:: https://zenodo.org/badge/XXXXXXXXX.svg
23-
:target: https://zenodo.org/badge/latestdoi/XXXXXXXXX
22+
.. image:: https://zenodo.org/badge/705730277.svg
23+
:target: https://zenodo.org/doi/10.5281/zenodo.10009816
2424
:alt: Permanent DOI for this release
2525
.. image:: https://img.shields.io/badge/license-MIT-blue.svg
2626
:target: https://github.com/handley-lab/lsbi/blob/master/LICENSE

lsbi/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
"""unimpeded: Universal model comparison & parameter estimation."""
2+
from lsbi._version import __version__ # noqa: F401

lsbi/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.0.1'
1+
__version__ = '0.0.2'

lsbi/model.py

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Gaussian models for linear Bayesian inference."""
2+
import numpy as np
3+
from functools import cached_property
4+
from scipy.stats import multivariate_normal
5+
6+
7+
class LinearModel(object):
8+
"""A linear model.
9+
10+
Defined by:
11+
- Parameters: theta (n,)
12+
- Data: D (d,)
13+
- Prior mean: mu (n,)
14+
- Prior covariance: Sigma (n, n)
15+
- Data mean: m (d,)
16+
- Data covariance: C (d, d)
17+
- Model M: D = m + M theta +/- sqrt(C)
18+
19+
Parameters
20+
----------
21+
M : array_like, optional
22+
Model matrix, defaults to identity matrix
23+
m : array_like, optional
24+
Data mean, defaults to zero vector
25+
C : array_like, optional
26+
Data covariance, defaults to identity matrix
27+
mu : array_like, optional
28+
Prior mean, defaults to zero vector
29+
Sigma : array_like, optional
30+
Prior covariance, defaults to identity matrix
31+
32+
the overall shape is attempted to be inferred from the input parameters.
33+
"""
34+
35+
def __init__(self, *args, **kwargs):
36+
37+
self.M = kwargs.pop('M', None)
38+
self.m = kwargs.pop('m', None)
39+
self.C = kwargs.pop('C', None)
40+
self.mu = kwargs.pop('mu', None)
41+
self.Sigma = kwargs.pop('Sigma', None)
42+
43+
n, d = None, None
44+
45+
if self.m is not None:
46+
self.m = np.atleast_1d(self.m)
47+
d, = self.m.shape
48+
if self.C is not None:
49+
self.C = np.atleast_2d(self.C)
50+
d, d = self.C.shape
51+
if self.Sigma is not None:
52+
self.Sigma = np.atleast_2d(self.Sigma)
53+
n, n = self.Sigma.shape
54+
if self.mu is not None:
55+
self.mu = np.atleast_1d(self.mu)
56+
n, = self.mu.shape
57+
if self.M is not None:
58+
self.M = np.atleast_2d(self.M)
59+
d, n = self.M.shape
60+
61+
if n is None:
62+
raise ValueError('Unable to determine number of parameters n')
63+
if d is None:
64+
raise ValueError('Unable to determine data dimensions d')
65+
66+
if self.M is None:
67+
self.M = np.eye(d, n)
68+
if self.m is None:
69+
self.m = np.zeros(d)
70+
if self.C is None:
71+
self.C = np.eye(d)
72+
if self.mu is None:
73+
self.mu = np.zeros(n)
74+
if self.Sigma is None:
75+
self.Sigma = np.eye(n)
76+
77+
@property
78+
def n(self):
79+
"""Dimensionality of parameter space len(theta)."""
80+
return self.M.shape[1]
81+
82+
@property
83+
def d(self):
84+
"""Dimensionality of data space len(D)."""
85+
return self.M.shape[0]
86+
87+
@cached_property
88+
def invSigma(self):
89+
"""Inverse of prior covariance."""
90+
return np.linalg.inv(self.Sigma)
91+
92+
@cached_property
93+
def invC(self):
94+
"""Inverse of data covariance."""
95+
return np.linalg.inv(self.C)
96+
97+
def likelihood(self, theta):
98+
"""P(D|theta) as a scipy distribution object."""
99+
return multivariate_normal(self.D(theta), self.C)
100+
101+
def prior(self):
102+
"""P(theta) as a scipy distribution object."""
103+
return multivariate_normal(self.mu, self.Sigma)
104+
105+
def posterior(self, D):
106+
"""P(theta|D) as a scipy distribution object."""
107+
Sigma = np.linalg.inv(self.invSigma + self.M.T @ self.invC @ self.M)
108+
mu = Sigma @ (self.invSigma @ self.mu
109+
+ self.M.T @ self.invC @ (D-self.m))
110+
return multivariate_normal(mu, Sigma)
111+
112+
def evidence(self):
113+
"""P(D) as a scipy distribution object."""
114+
return multivariate_normal(self.D(self.mu),
115+
self.C + self.M @ self.Sigma @ self.M.T)
116+
117+
def joint(self):
118+
"""P(D, theta) as a scipy distribution object."""
119+
mu = np.concatenate([self.D(self.mu), self.mu])
120+
Sigma = np.block([[self.C+self.M @ self.Sigma @ self.M.T,
121+
self.M @ self.Sigma],
122+
[self.Sigma @ self.M.T, self.Sigma]])
123+
return multivariate_normal(mu, Sigma)
124+
125+
def D(self, theta):
126+
"""D(theta) as the underlying data model."""
127+
return self.m + self.M @ theta
128+
129+
def DKL(self, D):
130+
"""D_KL(P(theta|D)||P(theta)) the Kullback-Leibler divergence."""
131+
cov_p = self.posterior(D).cov
132+
cov_q = self.prior().cov
133+
mu_p = self.posterior(D).mean
134+
mu_q = self.prior().mean
135+
return 0.5 * (- np.linalg.slogdet(cov_p)[1]
136+
+ np.linalg.slogdet(cov_q)[1]
137+
+ np.trace(np.linalg.inv(cov_q) @ cov_p - 1)
138+
+ (mu_q - mu_p) @ np.linalg.inv(cov_q) @ (mu_q - mu_p))

tests/test_example.py

-2
This file was deleted.

tests/test_model.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from lsbi.model import LinearModel
2+
import numpy as np
3+
import scipy.stats
4+
from numpy.testing import assert_allclose
5+
import pytest
6+
7+
8+
def _test_shape(model, d, n):
9+
assert model.n == n
10+
assert model.d == d
11+
assert model.M.shape == (d, n)
12+
assert model.m.shape == (d,)
13+
assert model.C.shape == (d, d)
14+
assert model.mu.shape == (n,)
15+
assert model.Sigma.shape == (n, n)
16+
17+
18+
def test_M():
19+
model = LinearModel(M=np.random.rand())
20+
_test_shape(model, 1, 1)
21+
22+
model = LinearModel(M=np.random.rand(1))
23+
_test_shape(model, 1, 1)
24+
25+
model = LinearModel(M=np.random.rand(1, 5))
26+
_test_shape(model, 1, 5)
27+
28+
model = LinearModel(M=np.random.rand(3, 1))
29+
_test_shape(model, 3, 1)
30+
31+
model = LinearModel(M=np.random.rand(3, 5))
32+
_test_shape(model, 3, 5)
33+
34+
35+
def test_m_mu():
36+
model = LinearModel(m=np.random.rand(), mu=np.random.rand())
37+
_test_shape(model, 1, 1)
38+
39+
model = LinearModel(m=np.random.rand(1), mu=np.random.rand(1))
40+
_test_shape(model, 1, 1)
41+
42+
model = LinearModel(m=np.random.rand(1), mu=np.random.rand(5))
43+
_test_shape(model, 1, 5)
44+
45+
model = LinearModel(m=np.random.rand(3), mu=np.random.rand(1))
46+
_test_shape(model, 3, 1)
47+
48+
model = LinearModel(m=np.random.rand(3), mu=np.random.rand(5))
49+
_test_shape(model, 3, 5)
50+
51+
52+
def test_failure():
53+
with pytest.raises(ValueError) as excinfo:
54+
LinearModel(m=np.random.rand(5))
55+
assert "Unable to determine number of parameters n" in str(excinfo.value)
56+
57+
with pytest.raises(ValueError) as excinfo:
58+
LinearModel(mu=np.random.rand(3))
59+
assert "Unable to determine data dimensions d" in str(excinfo.value)
60+
61+
62+
def random_model(d, n):
63+
M = np.random.rand(d, n)
64+
m = np.random.rand(d)
65+
C = scipy.stats.wishart(scale=np.eye(d)).rvs()
66+
mu = np.random.rand(n)
67+
Sigma = scipy.stats.wishart(scale=np.eye(n)).rvs()
68+
return LinearModel(M=M, m=m, C=C, mu=mu, Sigma=Sigma)
69+
70+
71+
def test_joint():
72+
d = 5
73+
n = 3
74+
N = 100
75+
model = random_model(d, n)
76+
prior = model.prior()
77+
evidence = model.evidence()
78+
joint = model.joint()
79+
80+
samples_1 = prior.rvs(N)
81+
samples_2 = joint.rvs(N)[:, -n:]
82+
83+
for i in range(n):
84+
p = scipy.stats.kstest(samples_1[:, i], samples_2[:, i]).pvalue
85+
assert p > 1e-5
86+
87+
p = scipy.stats.kstest(prior.logpdf(samples_2),
88+
prior.logpdf(samples_1)).pvalue
89+
assert p > 1e-5
90+
91+
samples_1 = evidence.rvs(N)
92+
samples_2 = joint.rvs(N)[:, :d]
93+
94+
for i in range(d):
95+
p = scipy.stats.kstest(samples_1[:, i], samples_2[:, i]).pvalue
96+
assert p > 1e-5
97+
98+
p = scipy.stats.kstest(evidence.logpdf(samples_2),
99+
evidence.logpdf(samples_1)).pvalue
100+
assert p > 1e-5
101+
102+
103+
def test_likelihood_posterior():
104+
d = 5
105+
n = 3
106+
N = 1000
107+
model = random_model(d, n)
108+
joint = model.joint()
109+
110+
samples = []
111+
theta = model.prior().rvs()
112+
for _ in range(N):
113+
data = model.likelihood(theta).rvs()
114+
theta = model.posterior(data).rvs()
115+
samples.append(np.concatenate([data, theta])[:])
116+
samples_1 = np.array(samples)[::100]
117+
samples_2 = joint.rvs(len(samples_1))
118+
119+
for i in range(n+d):
120+
p = scipy.stats.kstest(samples_1[:, i], samples_2[:, i]).pvalue
121+
assert p > 1e-5
122+
123+
p = scipy.stats.kstest(joint.logpdf(samples_2),
124+
joint.logpdf(samples_1)).pvalue
125+
assert p > 1e-5
126+
127+
128+
def test_DKL():
129+
d = 5
130+
n = 3
131+
N = 1000
132+
model = random_model(d, n)
133+
134+
data = model.evidence().rvs()
135+
posterior = model.posterior(data)
136+
prior = model.prior()
137+
138+
samples = posterior.rvs(N)
139+
Info = (posterior.logpdf(samples) - prior.logpdf(samples))
140+
assert_allclose(Info.mean(), model.DKL(data), atol=5*Info.std()/np.sqrt(N))

tests/test_scaffolding.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def test_version():
2+
from lsbi import __version__ as v1
3+
from lsbi._version import __version__ as v2
4+
assert v1 == v2

0 commit comments

Comments
 (0)