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.
Files changed (42) hide show
  1. pangolin/THOUGHTS.md +41 -0
  2. pangolin/__init__.py +37 -0
  3. pangolin/blackjax.py +404 -0
  4. pangolin/dag.py +219 -0
  5. pangolin/interface/__init__.py +220 -0
  6. pangolin/interface/base.py +1673 -0
  7. pangolin/interface/compositing.py +251 -0
  8. pangolin/interface/indexing.py +264 -0
  9. pangolin/interface/scanning.py +385 -0
  10. pangolin/interface/transforming.py +752 -0
  11. pangolin/interface/util.py +84 -0
  12. pangolin/interface/vfor.py +734 -0
  13. pangolin/interface/vmapping.py +595 -0
  14. pangolin/ir.py +2163 -0
  15. pangolin/jax_backend/__init__.py +1016 -0
  16. pangolin/jax_backend/bijectors.py +863 -0
  17. pangolin/testing/README.md +1 -0
  18. pangolin/testing/backends/README.md +1 -0
  19. pangolin/testing/backends/__init__.py +48 -0
  20. pangolin/testing/backends/base.py +23 -0
  21. pangolin/testing/backends/composite_tests.py +202 -0
  22. pangolin/testing/backends/deterministic_tests.py +86 -0
  23. pangolin/testing/backends/distribution_tests.py +176 -0
  24. pangolin/testing/backends/transformation_tests.py +100 -0
  25. pangolin/testing/inference/README.md +1 -0
  26. pangolin/testing/inference/__init__.py +33 -0
  27. pangolin/testing/inference/base.py +17 -0
  28. pangolin/testing/inference/complex_tests.py +188 -0
  29. pangolin/testing/inference/composite_tests.py +98 -0
  30. pangolin/testing/inference/deterministic_tests.py +85 -0
  31. pangolin/testing/inference/distribution_tests.py +151 -0
  32. pangolin/testing/inference/scan_tests.py +282 -0
  33. pangolin/testing/inference/simple_posterior_tests.py +103 -0
  34. pangolin/testing/inference/transformation_tests.py +46 -0
  35. pangolin/testing/inference/vmap_tests.py +529 -0
  36. pangolin/testing/test_util.py +46 -0
  37. pangolin/torch_backend.py +1090 -0
  38. pangolin/util.py +899 -0
  39. pangolin-0.0.1.dist-info/METADATA +15 -0
  40. pangolin-0.0.1.dist-info/RECORD +42 -0
  41. pangolin-0.0.1.dist-info/WHEEL +4 -0
  42. 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]))