pangolin 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pangolin/THOUGHTS.md +41 -0
- pangolin/__init__.py +37 -0
- pangolin/blackjax.py +404 -0
- pangolin/dag.py +219 -0
- pangolin/interface/__init__.py +220 -0
- pangolin/interface/base.py +1673 -0
- pangolin/interface/compositing.py +251 -0
- pangolin/interface/indexing.py +264 -0
- pangolin/interface/scanning.py +385 -0
- pangolin/interface/transforming.py +752 -0
- pangolin/interface/util.py +84 -0
- pangolin/interface/vfor.py +734 -0
- pangolin/interface/vmapping.py +595 -0
- pangolin/ir.py +2163 -0
- pangolin/jax_backend/__init__.py +1016 -0
- pangolin/jax_backend/bijectors.py +863 -0
- pangolin/testing/README.md +1 -0
- pangolin/testing/backends/README.md +1 -0
- pangolin/testing/backends/__init__.py +48 -0
- pangolin/testing/backends/base.py +23 -0
- pangolin/testing/backends/composite_tests.py +202 -0
- pangolin/testing/backends/deterministic_tests.py +86 -0
- pangolin/testing/backends/distribution_tests.py +176 -0
- pangolin/testing/backends/transformation_tests.py +100 -0
- pangolin/testing/inference/README.md +1 -0
- pangolin/testing/inference/__init__.py +33 -0
- pangolin/testing/inference/base.py +17 -0
- pangolin/testing/inference/complex_tests.py +188 -0
- pangolin/testing/inference/composite_tests.py +98 -0
- pangolin/testing/inference/deterministic_tests.py +85 -0
- pangolin/testing/inference/distribution_tests.py +151 -0
- pangolin/testing/inference/scan_tests.py +282 -0
- pangolin/testing/inference/simple_posterior_tests.py +103 -0
- pangolin/testing/inference/transformation_tests.py +46 -0
- pangolin/testing/inference/vmap_tests.py +529 -0
- pangolin/testing/test_util.py +46 -0
- pangolin/torch_backend.py +1090 -0
- pangolin/util.py +899 -0
- pangolin-0.0.1.dist-info/METADATA +15 -0
- pangolin-0.0.1.dist-info/RECORD +42 -0
- pangolin-0.0.1.dist-info/WHEEL +4 -0
- pangolin-0.0.1.dist-info/licenses/LICENSE.txt +661 -0
pangolin/THOUGHTS.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
How should indexing work?
|
|
2
|
+
|
|
3
|
+
I think the best conceptual rule in terms of INTERFACE would be that:
|
|
4
|
+
|
|
5
|
+
1. Individual indices can be declared "orthogonal" or "vectorized"
|
|
6
|
+
2. All vectorized indices are broadcast against each other
|
|
7
|
+
3. The vectorized dimensions (if any) go at the beginning
|
|
8
|
+
|
|
9
|
+
The best notation I can think for this is "orthogonal by default"
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
x = [5,7]
|
|
13
|
+
y = [2,3]
|
|
14
|
+
z = [6,7]
|
|
15
|
+
|
|
16
|
+
x = [5,7]; y = [2,3]; z = [6,7]
|
|
17
|
+
|
|
18
|
+
# 1. All Orthogonal (The Grid) -> 3D Result
|
|
19
|
+
B = A[x,y,z] # B[i,j,k] = A[x[i], y[j], z[k]]
|
|
20
|
+
|
|
21
|
+
# 2. All Coupled (The Path) -> 1D Result
|
|
22
|
+
B = A[v[x], v[y], v[z]] # B[i] = A[x[i], y[i], z[i]]
|
|
23
|
+
|
|
24
|
+
# 3. Mixed (Path + Grid) -> 2D Result
|
|
25
|
+
# x and z move together (dim 0, 2); y is independent (dim 1)
|
|
26
|
+
B = A[v[x], y, v[z]] # B[i,j] = A[x[i], y[j], z[i]]
|
|
27
|
+
|
|
28
|
+
# 4. Mixed (Grid + Path) -> 2D Result
|
|
29
|
+
# x and y move together (dim 0, 1); z is independent (dim 2)
|
|
30
|
+
B = A[v[x], v[y], z] # B[i,j] = A[x[i], y[i], z[j]]
|
|
31
|
+
|
|
32
|
+
# 5. Standard Slicing -> 3D Result
|
|
33
|
+
B = A[:, ::2, 3::] # B[i,j,k] = A[i, 2*j, 3+k]
|
|
34
|
+
|
|
35
|
+
# 6. Coupled Slices (Main Diagonal) -> 1D Result
|
|
36
|
+
B = A[v[:], v[:], v[:]] # B[i] = A[i, i, i]
|
|
37
|
+
|
|
38
|
+
# 7. Partial Diagonal (Trace) -> 2D Result
|
|
39
|
+
# Dims 0 and 2 are diagonal; Dim 1 is full/independent
|
|
40
|
+
B = A[v[:], :, v[:]] # B[i,j] = A[i, j, i]
|
|
41
|
+
```
|
pangolin/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pangolin's goal is to be **the world's friendliest probabilistic programming language**.
|
|
3
|
+
|
|
4
|
+
Quickstart:
|
|
5
|
+
|
|
6
|
+
- Use :mod:`pangolin.interface` to define probabilistic models in a cheerful interface.
|
|
7
|
+
- Use :mod:`pangolin.jax_backend` to compile probabilistic models into plain JAX functions (to be used independently of the rest of Pangolin).
|
|
8
|
+
- Use :mod:`pangolin.torch_backend` to compile probabilistic models into plain pytorch functions (to be used independently of the rest of Pangolin).
|
|
9
|
+
- Use :mod:`pangolin.blackjax` to easily call `Blackjax <https://blackjax-devs.github.io/blackjax/>`_ to do inference on probabilistic models.
|
|
10
|
+
|
|
11
|
+
In addition, there are three "private" submodules that end-users would not typically interact with:
|
|
12
|
+
|
|
13
|
+
- :mod:`pangolin.util` - Various internal utility functions.
|
|
14
|
+
- :mod:`pangolin.dag` - Utilities for interacting with directed acyclic graphs (DAGs).
|
|
15
|
+
- :mod:`pangolin.ir` - The internal representation (IR) for probabilistic models in Pangolin.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
with warnings.catch_warnings():
|
|
21
|
+
# Only ignore DeprecationWarning
|
|
22
|
+
warnings.simplefilter("ignore", category=DeprecationWarning)
|
|
23
|
+
import numpyro
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
from pangolin import dag, util, ir, interface, jax_backend, blackjax
|
|
27
|
+
|
|
28
|
+
__all__ = ["dag", "util", "ir", "interface", "jax_backend"]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from pangolin import torch_backend
|
|
32
|
+
|
|
33
|
+
__all__.append("torch_backend")
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
__all__.append("blackjax")
|
pangolin/blackjax.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines a convenient interface to call
|
|
3
|
+
`Blackjax <https://blackjax-devs.github.io/blackjax/>`_
|
|
4
|
+
to do inference. You could of course just call `pangolin.jax_backend.ancestor_log_prob`
|
|
5
|
+
to get a plain jax function and then call Blackjax yourself. But this module abstracts
|
|
6
|
+
away all the details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from jax import numpy as jnp
|
|
11
|
+
import jax.tree_util
|
|
12
|
+
import numpy as np
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
Callable,
|
|
16
|
+
TypeAlias,
|
|
17
|
+
TYPE_CHECKING,
|
|
18
|
+
Type,
|
|
19
|
+
Sequence,
|
|
20
|
+
List,
|
|
21
|
+
Optional,
|
|
22
|
+
)
|
|
23
|
+
from pangolin import ir
|
|
24
|
+
from pangolin.ir import Op, RV, ArrayLike
|
|
25
|
+
from numpy.typing import ArrayLike
|
|
26
|
+
from numpyro import distributions as dist
|
|
27
|
+
from jax.scipy import special as jspecial
|
|
28
|
+
from jax import nn as jnn
|
|
29
|
+
from pangolin import dag, util
|
|
30
|
+
from pangolin import jax_backend
|
|
31
|
+
import blackjax
|
|
32
|
+
from jaxtyping import PyTree
|
|
33
|
+
|
|
34
|
+
__all__ = ["sample", "E", "var", "std", "Calculate", "inf_until_match"]
|
|
35
|
+
|
|
36
|
+
from pangolin.jax_backend.bijectors import default_bijector_dict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def inference_loop(rng_key, kernel, initial_states, num_samples):
|
|
40
|
+
@jax.jit
|
|
41
|
+
def one_step(states, rng_key):
|
|
42
|
+
states, infos = kernel(rng_key, states)
|
|
43
|
+
return states, (states, infos)
|
|
44
|
+
|
|
45
|
+
keys = jax.random.split(rng_key, num_samples)
|
|
46
|
+
_, (states, infos) = jax.lax.scan(one_step, initial_states, keys)
|
|
47
|
+
|
|
48
|
+
return (states, infos)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_nuts(log_prob, key, initial_state, num_samples):
|
|
52
|
+
# to do hmc instead:
|
|
53
|
+
# adapt = blackjax.window_adaptation(blackjax.hmc, log_prob, num_integration_steps=60)
|
|
54
|
+
# kernel = blackjax.hmc(log_prob, **parameters).step
|
|
55
|
+
|
|
56
|
+
sample_key, warmup_key = jax.random.split(key)
|
|
57
|
+
adapt = blackjax.window_adaptation(blackjax.nuts, log_prob)
|
|
58
|
+
|
|
59
|
+
(last_state, parameters), _ = adapt.run(warmup_key, initial_state, num_samples) # type: ignore
|
|
60
|
+
|
|
61
|
+
kernel = blackjax.nuts(log_prob, **parameters).step
|
|
62
|
+
states, infos = inference_loop(sample_key, kernel, last_state, num_samples)
|
|
63
|
+
return states.position
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# TODO: Raise error if given discrete latent variable
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sample_flat(
|
|
70
|
+
vars: list[RV],
|
|
71
|
+
given_vars: list[RV],
|
|
72
|
+
given_vals: list,
|
|
73
|
+
*,
|
|
74
|
+
niter: int,
|
|
75
|
+
bijector_dict: Optional[dict] = default_bijector_dict,
|
|
76
|
+
) -> list[jnp.ndarray]:
|
|
77
|
+
"""
|
|
78
|
+
Given a "flat" specification of an inference problem, do inference using Numpyro. The basic algorithm is:
|
|
79
|
+
|
|
80
|
+
1. Use `get_model_flat` to create a Numpyro model and a mapping from RVs to Numpyro variable names.
|
|
81
|
+
2. Use standard Numpyro routines to do inference using MCMC.
|
|
82
|
+
3. Use the name mapping to extract samples for all variables listed in `vars`.
|
|
83
|
+
|
|
84
|
+
If given is empty, then example samples will (automatically) be drawn using `ancestor_sample_flat`.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
vars
|
|
89
|
+
The RVs you want to sample from
|
|
90
|
+
given
|
|
91
|
+
The RVs you want to condition on
|
|
92
|
+
vals
|
|
93
|
+
The values for the conditioned RVs
|
|
94
|
+
niter: int, optional
|
|
95
|
+
The number of iterations / samples to draw
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
samples: list[jnp.ndarray]
|
|
100
|
+
Samples for each variable in `vars`.
|
|
101
|
+
|
|
102
|
+
Examples
|
|
103
|
+
--------
|
|
104
|
+
>>> x = ir.RV(ir.Constant(0.5))
|
|
105
|
+
>>> y = ir.RV(ir.Normal(), x, x)
|
|
106
|
+
>>> z = ir.RV(ir.Normal(), y, x)
|
|
107
|
+
>>> [samps_x, samps_y] = sample_flat([x, y], [z], [3.0], niter=30)
|
|
108
|
+
>>> samps_x.shape
|
|
109
|
+
(30,)
|
|
110
|
+
>>> samps_y.shape
|
|
111
|
+
(30,)
|
|
112
|
+
>>> np.allclose(samps_x, 0.5)
|
|
113
|
+
True
|
|
114
|
+
>>> np.allclose(samps_y, 0.5)
|
|
115
|
+
False
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
if len(given_vars) != len(given_vals):
|
|
120
|
+
raise ValueError("length of given_vars not equal to length of given_vals")
|
|
121
|
+
|
|
122
|
+
if any(not v.op.random for v in given_vars):
|
|
123
|
+
nonrandom_ops = [v.op for v in given_vars if not v.op.random]
|
|
124
|
+
raise ValueError(f"Cannot condition on RV with non-random op(s) {nonrandom_ops}")
|
|
125
|
+
|
|
126
|
+
# if no given variables, just do ancestor sampling (works but disabled for simplicity)
|
|
127
|
+
# if len(given_vars) == 0:
|
|
128
|
+
# key = jax.random.PRNGKey(0)
|
|
129
|
+
# keys = jax.random.split(key, niter)
|
|
130
|
+
# mysample = lambda key: backend.ancestor_sample_flat(vars, key)
|
|
131
|
+
# return jax.vmap(mysample)(keys)
|
|
132
|
+
|
|
133
|
+
given_vals = [jnp.array(val) for val in given_vals]
|
|
134
|
+
all_vars = dag.upstream_nodes(tuple(vars) + tuple(given_vars))
|
|
135
|
+
latent_vars = [var for var in all_vars if var.op.random and var not in given_vars]
|
|
136
|
+
|
|
137
|
+
for v in latent_vars:
|
|
138
|
+
if v.op.discrete:
|
|
139
|
+
raise ValueError(f"Blackjax backend does not support discrete latent/unobserved RV (saw op {v.op})")
|
|
140
|
+
|
|
141
|
+
@jax.jit
|
|
142
|
+
def log_prob(latent_vals):
|
|
143
|
+
return jax_backend.ancestor_log_prob_flat(latent_vars + given_vars, latent_vals + given_vals, bijector_dict)
|
|
144
|
+
|
|
145
|
+
# key = jax.random.PRNGKey(0)
|
|
146
|
+
seed = np.random.randint(0, 2**32 - 1)
|
|
147
|
+
key = jax.random.PRNGKey(seed)
|
|
148
|
+
|
|
149
|
+
latent_vals = jax_backend.ancestor_sample_flat(latent_vars, key, bijector_dict=bijector_dict)
|
|
150
|
+
latent_samps = run_nuts(log_prob, key, latent_vals, niter)
|
|
151
|
+
|
|
152
|
+
if bijector_dict is not None and len(latent_samps) > 0:
|
|
153
|
+
|
|
154
|
+
def constrain(latent_vals):
|
|
155
|
+
return jax_backend.ancestor_constrain(latent_vars, latent_vals, bijector_dict)
|
|
156
|
+
|
|
157
|
+
latent_samps = jax.vmap(constrain)(latent_samps)
|
|
158
|
+
|
|
159
|
+
def fill(latent_vals):
|
|
160
|
+
return jax_backend.fill_in(latent_vars + given_vars, latent_vals + given_vals, vars)
|
|
161
|
+
|
|
162
|
+
# include niter in case latent_samps is empty
|
|
163
|
+
return jax.vmap(fill, axis_size=niter)(latent_samps)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Calculate:
|
|
167
|
+
"""
|
|
168
|
+
A `Calculate` object just remembers a set of options and then offers inference
|
|
169
|
+
methods.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
default
|
|
174
|
+
represents a set of options for the inference engine that can be overriden later
|
|
175
|
+
frozen
|
|
176
|
+
represents a set of options for the inference engine that cannot be overridden
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, default: Optional[dict] = None, frozen: Optional[dict] = None):
|
|
181
|
+
|
|
182
|
+
if default is None:
|
|
183
|
+
default = {}
|
|
184
|
+
if frozen is None:
|
|
185
|
+
frozen = {}
|
|
186
|
+
|
|
187
|
+
if util.intersects(default, frozen):
|
|
188
|
+
raise ValueError(f"default {default} intersects with frozen {frozen}")
|
|
189
|
+
|
|
190
|
+
self.default = default
|
|
191
|
+
self.frozen = frozen # options for that engine
|
|
192
|
+
|
|
193
|
+
def sample(
|
|
194
|
+
self,
|
|
195
|
+
vars: PyTree[RV],
|
|
196
|
+
given_vars: PyTree[RV] = None,
|
|
197
|
+
given_vals: PyTree[ArrayLike] = None,
|
|
198
|
+
reduce_fn: Optional[Callable] = None,
|
|
199
|
+
**options,
|
|
200
|
+
):
|
|
201
|
+
"""
|
|
202
|
+
Draw samples!
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
vars: A `RV` or list/tuple of `RV` or pytree of `RV` to sample.
|
|
206
|
+
given_vars: A `RV` or list/tuple of `RV` or pytree of `RV` to condition on.
|
|
207
|
+
``None`` indicates no conditioning variables.
|
|
208
|
+
given_vals: An ``ArrayLike`` or list/tuple of ``ArrayLike`` or pytree of
|
|
209
|
+
``ArrayLike`` representing observed values. Must match the structure and
|
|
210
|
+
shape of ``given_vars``.
|
|
211
|
+
reduce_fn: Function to apply to each leaf node in samples before returning.
|
|
212
|
+
This is used to create `E`, `var`, etc. (If ``None``, does nothing.)
|
|
213
|
+
options: extra options to pass to sampler
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Pytree of JAX arrays matching structure and shape of ``vars`` but with one
|
|
217
|
+
extra dimension at the start, containing the samples.
|
|
218
|
+
|
|
219
|
+
Examples
|
|
220
|
+
--------
|
|
221
|
+
>>> zero = ir.RV(ir.Constant(0))
|
|
222
|
+
>>> one = ir.RV(ir.Constant(1))
|
|
223
|
+
>>> x = ir.RV(ir.Normal(), zero, one)
|
|
224
|
+
>>> y = ir.RV(ir.Normal(), x, one)
|
|
225
|
+
>>> calc = Calculate({'niter': 529})
|
|
226
|
+
>>> x_samps = calc.sample(x,y,2)
|
|
227
|
+
>>> x_samps.shape
|
|
228
|
+
(529,)
|
|
229
|
+
>>> np.mean(x_samps) # something close to 1.0
|
|
230
|
+
Array(...)
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
if util.intersects(options, self.frozen):
|
|
234
|
+
raise ValueError(f"options intersects frozen")
|
|
235
|
+
|
|
236
|
+
options = self.default | options # overrides defaults
|
|
237
|
+
|
|
238
|
+
(
|
|
239
|
+
flat_vars,
|
|
240
|
+
flat_given_vars,
|
|
241
|
+
flat_given_vals,
|
|
242
|
+
unflatten,
|
|
243
|
+
unflatten_given,
|
|
244
|
+
) = util.flatten_args(vars, given_vars, given_vals)
|
|
245
|
+
|
|
246
|
+
flat_samps = sample_flat(
|
|
247
|
+
flat_vars,
|
|
248
|
+
flat_given_vars,
|
|
249
|
+
flat_given_vals,
|
|
250
|
+
**options,
|
|
251
|
+
**self.frozen,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if reduce_fn is not None:
|
|
255
|
+
flat_samps = map(reduce_fn, flat_samps)
|
|
256
|
+
|
|
257
|
+
return unflatten(flat_samps)
|
|
258
|
+
|
|
259
|
+
def E(
|
|
260
|
+
self,
|
|
261
|
+
vars: PyTree[RV],
|
|
262
|
+
given_vars: PyTree[RV] = None,
|
|
263
|
+
given_vals: PyTree[ArrayLike] = None,
|
|
264
|
+
**options,
|
|
265
|
+
):
|
|
266
|
+
"""
|
|
267
|
+
Compute (conditional) expected values. This is just a thin wrapper that calls
|
|
268
|
+
`sample` and then reduces by taking the mean.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
vars: A `RV` or list/tuple of `RV` or pytree of `RV` to sample.
|
|
272
|
+
given_vars: A `RV` or list/tuple of `RV` or pytree of `RV` to condition on.
|
|
273
|
+
``None`` indicates no conditioning variables.
|
|
274
|
+
given_vals: An ``ArrayLike`` or list/tuple of ``ArrayLike`` or pytree of
|
|
275
|
+
``ArrayLike`` representing observed values. Must match the structure and
|
|
276
|
+
shape of ``given_vars``.
|
|
277
|
+
reduce_fn: Function to apply to each leaf node in samples before returning.
|
|
278
|
+
This is used to create `E`, `var`, etc. (If ``None``, does nothing.)
|
|
279
|
+
options: extra options to pass to sampler
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Pytree of JAX arrays matching structure and shape of ``vars``, containing
|
|
283
|
+
the expectations.
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
Examples
|
|
287
|
+
--------
|
|
288
|
+
>>> zero = ir.RV(ir.Constant(0))
|
|
289
|
+
>>> one = ir.RV(ir.Constant(1))
|
|
290
|
+
>>> x = ir.RV(ir.Normal(), zero, one)
|
|
291
|
+
>>> y = ir.RV(ir.Normal(), x, one)
|
|
292
|
+
>>> calc = Calculate({'niter': 529})
|
|
293
|
+
>>> calc.E(x,y,2) # something close to 1.0
|
|
294
|
+
Array(...)
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
return self.sample(vars, given_vars, given_vals, lambda x: np.mean(x, axis=0), **options)
|
|
298
|
+
|
|
299
|
+
def var(
|
|
300
|
+
self,
|
|
301
|
+
vars: PyTree[RV],
|
|
302
|
+
given_vars: PyTree[RV] = None,
|
|
303
|
+
given_vals: PyTree[ArrayLike] = None,
|
|
304
|
+
**options,
|
|
305
|
+
):
|
|
306
|
+
return self.sample(vars, given_vars, given_vals, lambda x: np.var(x, axis=0), **options)
|
|
307
|
+
|
|
308
|
+
def std(
|
|
309
|
+
self,
|
|
310
|
+
vars: PyTree[RV],
|
|
311
|
+
given_vars: PyTree[RV] = None,
|
|
312
|
+
given_vals: PyTree[ArrayLike] = None,
|
|
313
|
+
**options,
|
|
314
|
+
):
|
|
315
|
+
return self.sample(vars, given_vars, given_vals, lambda x: np.std(x, axis=0), **options)
|
|
316
|
+
|
|
317
|
+
def sample_arviz(
|
|
318
|
+
self,
|
|
319
|
+
vars: dict[str, RV],
|
|
320
|
+
given_vars: PyTree[RV] = None,
|
|
321
|
+
given_vals: PyTree[ArrayLike] = None,
|
|
322
|
+
**options,
|
|
323
|
+
):
|
|
324
|
+
"""This is an **experimental** function to draw samples in
|
|
325
|
+
`ArviZ <https://www.arviz.org/en/latest/>`__ format.
|
|
326
|
+
|
|
327
|
+
Note: ArviZ is not installed with pangolin by default: You must install it
|
|
328
|
+
manually.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
vars: dictionary mapping names to individual random variables
|
|
332
|
+
given_vars: A `RV` or list/tuple of `RV` or pytree of `RV` to condition on.
|
|
333
|
+
``None`` indicates no conditioning variables.
|
|
334
|
+
given_vars: A `RV` or list/tuple of `RV` or pytree of `RV` to condition on.
|
|
335
|
+
given_vals: An ``ArrayLike`` or list/tuple of ``ArrayLike`` or pytree of
|
|
336
|
+
``ArrayLike`` representing observed values. Must match the structure and
|
|
337
|
+
shape of ``given_vars``.
|
|
338
|
+
reduce_fn: Function to apply to each leaf node in samples before returning.
|
|
339
|
+
This is used to create `E`, `var`, etc. (If ``None``, does nothing.)
|
|
340
|
+
options: extra options to pass to sampler
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
from arviz import convert_to_inference_data
|
|
345
|
+
except ImportError:
|
|
346
|
+
raise ImportError("To use this method you must install arviz manually")
|
|
347
|
+
|
|
348
|
+
samps = self.sample(vars, given_vars, given_vals, **options)
|
|
349
|
+
samps_with_none = {key: samps[key][None, ...] for key in samps}
|
|
350
|
+
dataset = convert_to_inference_data(samps_with_none)
|
|
351
|
+
return dataset
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
default = {"niter": 1000}
|
|
355
|
+
|
|
356
|
+
calc = Calculate(default)
|
|
357
|
+
sample = calc.sample
|
|
358
|
+
"""
|
|
359
|
+
Default version of `Calculate.sample` that draws 1000 samples.
|
|
360
|
+
"""
|
|
361
|
+
E = calc.E
|
|
362
|
+
"""
|
|
363
|
+
Default version of `Calculate.E` that uses 1000 samples.
|
|
364
|
+
"""
|
|
365
|
+
var = calc.var
|
|
366
|
+
"""
|
|
367
|
+
Default version of `Calculate.var` that uses 1000 samples.
|
|
368
|
+
"""
|
|
369
|
+
std = calc.std
|
|
370
|
+
"""
|
|
371
|
+
Default version of `Calculate.std` that uses 1000 samples.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
sample_arviz = calc.sample_arviz
|
|
375
|
+
"""
|
|
376
|
+
Default version of `Calculate.sample_arviz` that uses 1000 samples.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def inf_until_match(inf, vars, given, vals, testfun, niter_start=1000, niter_max=100000):
|
|
381
|
+
from time import time
|
|
382
|
+
|
|
383
|
+
niter = niter_start
|
|
384
|
+
while niter <= niter_max:
|
|
385
|
+
t0 = time()
|
|
386
|
+
out = inf(vars, given, vals, niter=niter)
|
|
387
|
+
t1 = time()
|
|
388
|
+
print(f"{niter=} {t1 - t0}")
|
|
389
|
+
if testfun(out):
|
|
390
|
+
assert True
|
|
391
|
+
return
|
|
392
|
+
else:
|
|
393
|
+
niter *= 2
|
|
394
|
+
assert False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
import functools
|
|
398
|
+
|
|
399
|
+
sample_until_match = functools.partial(inf_until_match, sample)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def sample_flat_until_match(vars, given, vals, testfun, niter_start=1000, niter_max=100000):
|
|
403
|
+
new_testfun = lambda stuff: testfun(stuff[0])
|
|
404
|
+
return inf_until_match(sample_flat, vars, given, vals, new_testfun, niter_start, niter_max)
|
pangolin/dag.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic code for operating on directed acyclic graphs (DAGs).
|
|
3
|
+
None of these functions import or use any other parts of Pangolin.
|
|
4
|
+
**End-users of Pangolin are not expected to use these functions directly**.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import jax.tree_util
|
|
9
|
+
from typing import Sequence, Callable, Any
|
|
10
|
+
from jaxtyping import PyTree
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from typing import Self
|
|
14
|
+
except ImportError:
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Node:
|
|
19
|
+
"""
|
|
20
|
+
The basic `Node` class. This is just an object that remembers a set of parents.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
parents: parents of this node
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *parents: Self):
|
|
27
|
+
self._parents: tuple[Self, ...] = parents
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def parents(self) -> tuple[Self, ...]:
|
|
31
|
+
"The parents of this node"
|
|
32
|
+
return self._parents
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def upstream_nodes_flat[N: Node](
|
|
36
|
+
nodes_flat: Sequence[N],
|
|
37
|
+
node_block: Callable[[N], bool] | None,
|
|
38
|
+
edge_block: Callable[[N, N], bool] | None,
|
|
39
|
+
upstream: list[N],
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Do a DFS starting at all the nodes in ``nodes_flat``. But never visit nodes if
|
|
43
|
+
``node_block(n)`` and never follow an edge from ``n`` to ``p`` if
|
|
44
|
+
``link_block(n,p)``.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
nodes_flat: starting nodes
|
|
48
|
+
node_block: should DFS be blocked from visting a node?
|
|
49
|
+
edge_block: should DFS be blocked from following a link?
|
|
50
|
+
upstream: list of nodes, destructively updated
|
|
51
|
+
"""
|
|
52
|
+
# someday should have dual set / list for faster checking
|
|
53
|
+
for node in nodes_flat:
|
|
54
|
+
if node in upstream or (node_block and node_block(node)):
|
|
55
|
+
continue
|
|
56
|
+
for p in node.parents:
|
|
57
|
+
if not (edge_block and edge_block(node, p)) and not (node_block and node_block(p)):
|
|
58
|
+
upstream_nodes_flat([p], node_block, edge_block, upstream)
|
|
59
|
+
upstream.append(node)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def upstream_nodes[N: Node](
|
|
63
|
+
nodes: PyTree[N],
|
|
64
|
+
node_block: Callable[[N], bool] | None = None,
|
|
65
|
+
edge_block: Callable[[N, N], bool] | None = None,
|
|
66
|
+
) -> list[N]:
|
|
67
|
+
"""
|
|
68
|
+
Do a DFS of all ancestors starting at all the nodes in ``nodes``.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
nodes
|
|
72
|
+
Single node or list/pytree of node
|
|
73
|
+
node_block
|
|
74
|
+
Function defining which nodes to block from inclusion. If
|
|
75
|
+
``node_block(node)`` is True, then ``node`` is not visited. (Defalt: Don't block.)
|
|
76
|
+
edge_block
|
|
77
|
+
Function defining which edges should not be followed. If
|
|
78
|
+
``edge_block(node, parent)`` is true, then don't follow edge from ``node`` to
|
|
79
|
+
``parent``. (Defalt: Don't block)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of upstream nodes in topological order
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Args:
|
|
86
|
+
# nodes (`PyTree[Node]`): single node or list of nodes or pytree of starting nodes
|
|
87
|
+
# node_block: should DFS be blocked from visting a node? If `None`, then all nodes
|
|
88
|
+
# edge_block: should DFS be blocked from following an edge? If `None`, then all
|
|
89
|
+
|
|
90
|
+
# Returns:
|
|
91
|
+
# List of all nodes found, with a partial order so that parents always come
|
|
92
|
+
# before children
|
|
93
|
+
|
|
94
|
+
nodes_flat, _ = jax.tree_util.tree_flatten(nodes)
|
|
95
|
+
upstream = []
|
|
96
|
+
upstream_nodes_flat(nodes_flat, node_block, edge_block, upstream)
|
|
97
|
+
return upstream
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def upstream_with_descendent_old[N: Node](requested_nodes: list[N], given_nodes: list[N]) -> list[N]:
|
|
101
|
+
"""
|
|
102
|
+
First, find all nodes that are upstream (inclusive) of ``requested_nodes``
|
|
103
|
+
Then, find all the nodes that are *downstream* (inclusive) of that set
|
|
104
|
+
that have a descendant in ``given_nodes``
|
|
105
|
+
|
|
106
|
+
args:
|
|
107
|
+
requested_nodes: nodes to search above
|
|
108
|
+
given_nodes: must have a descendant in this set
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
has_descendent = upstream_nodes(given_nodes)
|
|
112
|
+
children = get_children(requested_nodes + given_nodes)
|
|
113
|
+
|
|
114
|
+
nodes = []
|
|
115
|
+
processed_nodes = []
|
|
116
|
+
queue = requested_nodes.copy()
|
|
117
|
+
|
|
118
|
+
def unseen(node):
|
|
119
|
+
return node not in queue and node not in processed_nodes
|
|
120
|
+
|
|
121
|
+
while queue:
|
|
122
|
+
# print(f"{queue=}")
|
|
123
|
+
node = queue.pop()
|
|
124
|
+
# if node.cond_dist.is_random and node in has_descendent:
|
|
125
|
+
if node in has_descendent:
|
|
126
|
+
nodes.append(node)
|
|
127
|
+
processed_nodes.append(node)
|
|
128
|
+
|
|
129
|
+
for p in node.parents:
|
|
130
|
+
if unseen(p) and p not in given_nodes:
|
|
131
|
+
queue.append(p)
|
|
132
|
+
for c in children[node]:
|
|
133
|
+
if unseen(c) and c in has_descendent:
|
|
134
|
+
queue.append(c)
|
|
135
|
+
return nodes
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def upstream_with_descendent[N: Node](requested_nodes: list[N], given_nodes: list[N]) -> list[N]:
|
|
139
|
+
"""
|
|
140
|
+
First, find all nodes that are upstream (inclusive) of ``requested_nodes``
|
|
141
|
+
Then, find all the nodes that are *downstream* (inclusive) of that set
|
|
142
|
+
that have a descendant in ``given_nodes``
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
all_nodes = upstream_nodes(requested_nodes + given_nodes)
|
|
146
|
+
has_descendent = upstream_nodes(given_nodes)
|
|
147
|
+
return [n for n in all_nodes if n in has_descendent]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_children[N: Node](nodes: PyTree[N], node_block: Callable[[N], bool] | None = None) -> dict[N, list[N]]:
|
|
151
|
+
"""
|
|
152
|
+
Get all children for all upstream nodes
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
nodes: Starting nodes (as in ``get_upstream``)
|
|
156
|
+
node_block: Block condition (as in ``get_upstream``)
|
|
157
|
+
|
|
158
|
+
Return:
|
|
159
|
+
Dictionary mapping each upstream node to a list of children of that node
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
all_nodes = upstream_nodes(nodes, node_block)
|
|
164
|
+
children = {}
|
|
165
|
+
for n in all_nodes:
|
|
166
|
+
children[n] = []
|
|
167
|
+
for n in all_nodes:
|
|
168
|
+
for p in n.parents:
|
|
169
|
+
# print(f"{hash(n)=} {hash(p)=}")
|
|
170
|
+
if n not in children[p]:
|
|
171
|
+
children[p].append(n)
|
|
172
|
+
return children
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_in[N: Node](node: N, nodes: Sequence[N]) -> bool:
|
|
176
|
+
for p in nodes:
|
|
177
|
+
# if id(node) == id(p) or node.id == p.id:
|
|
178
|
+
if id(node) == id(p):
|
|
179
|
+
return True
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def has_second_path(node: Node, par_i: int) -> bool:
|
|
184
|
+
assert par_i < len(node.parents)
|
|
185
|
+
other_parents = node.parents[:par_i] + node.parents[par_i + 1 :]
|
|
186
|
+
return node.parents[par_i] in upstream_nodes(other_parents)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# def downstream_nodes(roots,nodes,always_include=[]):
|
|
190
|
+
# downstream = []
|
|
191
|
+
# for node in nodes:
|
|
192
|
+
# if is_in(node,roots):
|
|
193
|
+
# continue
|
|
194
|
+
# if is_in(node,always_include):
|
|
195
|
+
# downstream.append(node)
|
|
196
|
+
# continue
|
|
197
|
+
# for p in node.parents:
|
|
198
|
+
# if is_in(p,downstream+roots):
|
|
199
|
+
# downstream.append(node)
|
|
200
|
+
# break
|
|
201
|
+
# return downstream
|
|
202
|
+
#
|
|
203
|
+
# def middle_nodes(roots,leafs):
|
|
204
|
+
# "all nodes that are both upstream of a leaf and downstream of a root"
|
|
205
|
+
# # TODO: this goes through the whole graph and then filters, could be inefficient but whatever
|
|
206
|
+
# nodes = upstream_nodes(leafs)
|
|
207
|
+
# middle = {}
|
|
208
|
+
# for node in nodes:
|
|
209
|
+
# if is_in(node,leafs):
|
|
210
|
+
# continue
|
|
211
|
+
# for p in node.parents:
|
|
212
|
+
# if p in middle or is_in(p,roots):
|
|
213
|
+
# middle[node] = None # unordered set
|
|
214
|
+
# return list(middle.keys())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_graph[N: Node](nodes: PyTree[N], get_content, block_condition=None) -> dict[N, Any]:
|
|
218
|
+
upstream = upstream_nodes(nodes, block_condition)
|
|
219
|
+
return dict(zip(upstream, [get_content(n) for n in upstream]))
|