bamojax 0.4.2__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.
bamojax/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from . import _version
2
+ __version__ = _version.get_versions()['version']
bamojax/_version.py ADDED
@@ -0,0 +1,21 @@
1
+
2
+ # This file was generated by 'versioneer.py' (0.29) from
3
+ # revision-control system data, or from the parent directory name of an
4
+ # unpacked source archive. Distribution tarballs contain a pre-generated copy
5
+ # of this file.
6
+
7
+ import json
8
+
9
+ version_json = '''
10
+ {
11
+ "date": "2026-10-26T21:35:56+0100",
12
+ "dirty": false,
13
+ "error": null,
14
+ "full-revisionid": "7b6576685f711fc94c568bb32813d45e67e2a861",
15
+ "version": "0.4.2"
16
+ }
17
+ ''' # END VERSION_JSON
18
+
19
+
20
+ def get_versions():
21
+ return json.loads(version_json)
bamojax/base.py ADDED
@@ -0,0 +1,632 @@
1
+ from jaxtyping import Array, Union
2
+ import jax
3
+ import jax.numpy as jnp
4
+
5
+ import jax.random as jrnd
6
+ from typing import Tuple, Callable
7
+
8
+ from numpyro.distributions import Distribution, TransformedDistribution
9
+ from numpyro.distributions.transforms import Transform as Bijector
10
+
11
+
12
+
13
+ class Node:
14
+ r""" The essential element of any Bayesian model is the variable, represented by a node in a DAG.
15
+
16
+ Nodes can consist of stochastic or deterministic variables, and can be observed or latent.
17
+
18
+ Hyperparameters of a model are implicitly observed, deterministic nodes.
19
+
20
+ """
21
+
22
+ def __init__(self, name: str = 'root',
23
+ observations: Array = None,
24
+ distribution: Union[Distribution, Bijector] = None,
25
+ parents = None,
26
+ link_fn: Callable = None,
27
+ shape: Union[Tuple, int] = None,
28
+ bijector: Bijector = None):
29
+ self.name = name
30
+
31
+ if shape is None:
32
+ shape = ( )
33
+ self.shape = shape
34
+ if bijector is not None:
35
+ self.bijector = bijector
36
+ if observations is not None:
37
+ observations = jnp.asarray(observations) if jnp.isscalar(observations) else observations
38
+ self.observations = observations
39
+ if distribution is not None:
40
+ self.distribution = distribution
41
+ self.parents = {}
42
+ if parents is not None:
43
+ for param, parent in parents.items():
44
+ self.add_parent(param, parent)
45
+ if link_fn is None:
46
+ def identity(**kwargs):
47
+ return kwargs
48
+ link_fn = identity
49
+ self.link_fn = link_fn
50
+
51
+ #
52
+ def is_observed(self) -> bool:
53
+ """ Check if a node is an observed variable.
54
+
55
+ """
56
+ return hasattr(self, 'observations')
57
+
58
+ #
59
+ def is_stochastic(self) -> bool:
60
+ """ Check whether a node is stochastic or deterministic.
61
+
62
+ """
63
+ return hasattr(self, 'distribution')
64
+
65
+ #
66
+ def is_root(self) -> bool:
67
+ """ Check whether a node is a root node.
68
+
69
+ """
70
+ return not hasattr(self, 'parents') or len(self.parents) == 0
71
+
72
+ #
73
+ def add_parent(self, param, node):
74
+ """ Add a parent node.
75
+
76
+ """
77
+ assert isinstance(node, Node)
78
+ self.parents[param] = node
79
+
80
+ #
81
+ def set_step_fn(self, step_fn):
82
+ self.step_fn = step_fn
83
+
84
+ #
85
+ def set_step_fn_parameter(self, step_fn_params):
86
+ self.step_fn_params = step_fn_params
87
+
88
+ #
89
+ def is_leaf(self):
90
+ """ Check whether a node is observed and has parents.
91
+
92
+ """
93
+ return hasattr(self, 'parents') and self.is_observed()
94
+
95
+ #
96
+ def get_distribution(self, state: dict = None, minibatch: dict = None) -> Distribution:
97
+ r""" Derives the parametrized distribution p(node | Parents=x), where x is derived from the state object.
98
+
99
+ Args:
100
+ state: Current assignment of (parent) values.
101
+ minibatch: A additional set of assigned variables, useful for out-of-sample predictions.
102
+ Returns:
103
+ An instantiated distrax distribution object.
104
+
105
+ """
106
+
107
+ # Root-level nodes can be defined as instantiated numpyro distributions.
108
+ if isinstance(self.distribution, Distribution):
109
+ return self.distribution
110
+
111
+ if minibatch is None:
112
+ minibatch = {}
113
+
114
+ # Otherwise the distribution is instantiated from the state.
115
+ parent_values = {}
116
+ for parent_name, parent_node in self.parents.items():
117
+ if parent_node in state:
118
+ parent_values[parent_name] = state[parent_node]
119
+ else:
120
+ if parent_name in minibatch:
121
+ parent_values[parent_name] = minibatch[parent_name]
122
+ else:
123
+ parent_values[parent_name] = self.parents[parent_name].observations
124
+
125
+ transformed_parents = self.link_fn(**parent_values)
126
+ if hasattr(self, 'bijector'):
127
+ return TransformedDistribution(self.distribution(**transformed_parents), self.bijector)
128
+ else:
129
+ return self.distribution(**transformed_parents)
130
+
131
+ #
132
+ def __repr__(self) -> str:
133
+ return f'{self.name}'
134
+
135
+ #
136
+ def __hash__(self):
137
+ return hash((self.name))
138
+
139
+ #
140
+ def __eq__(self, other):
141
+ # Nodes compare equal to other nodes, and to their own name. Because __hash__ is the
142
+ # hash of the name, this lets a Node look up entries in dicts keyed by variable name.
143
+ if isinstance(other, Node):
144
+ return self.name == other.name
145
+ if isinstance(other, str):
146
+ return self.name == other
147
+ raise NotImplementedError
148
+
149
+ #
150
+
151
+ #
152
+ class Model:
153
+ r""" A Bayesian model is represented as a directed acyclic graph, in which nodes are associated with random variables.
154
+
155
+ Typical use:
156
+
157
+ model = Model('model name')
158
+ _ = model.add_node('x', observations=...)
159
+
160
+ """
161
+
162
+ def __init__(self, name='Bayesian model', verbose=False):
163
+ self.name = name
164
+ self.nodes = {}
165
+ self.root_nodes = list()
166
+ self.leaf_nodes = list()
167
+ self.children = dict()
168
+ self.parents = dict()
169
+ self.verbose = verbose
170
+
171
+ #
172
+ def add_node(self,
173
+ name: str = 'root',
174
+ distribution: Union[Distribution, Bijector] = None,
175
+ observations: Array = None,
176
+ parents: dict = None,
177
+ link_fn: Callable = None,
178
+ shape: Union[Tuple, int] = None,
179
+ bijector: Bijector = None) -> Node:
180
+ r""" Adds a node to the Bayesian model DAG
181
+
182
+ Args:
183
+ name: The name of the variable.
184
+ distribution: The distrax distribution of the variable given its (transformed) parents.
185
+ observations: If the node is observed; the actual observations.
186
+ parents: The nodes that this node depends on.
187
+ link_fn: A link function combining the inputs to form the input to the corresponding distrax distribution.
188
+ shape: The dimensions of the variable.
189
+ bijector: A bijector can be passed to transform variables.
190
+ Returns:
191
+ New node
192
+
193
+ """
194
+ if parents is not None:
195
+ new_parents = {}
196
+ for parent_name, parent in parents.items():
197
+ if not isinstance(parent, Node):
198
+ # Parent is numeric
199
+ parent_node = self.add_node(name=f'{parent_name}_{name}', observations=parent)
200
+ new_parents[parent_name] = parent_node
201
+ else:
202
+ new_parents[parent_name] = parent
203
+ parents = new_parents
204
+ new_node = Node(name=name, distribution=distribution, observations=observations, parents=parents, link_fn=link_fn, shape=shape, bijector=bijector)
205
+ self.nodes[name] = new_node
206
+ if self.verbose: print(f'Adding node ({name})')
207
+ if parents is not None:
208
+ for parent in parents.values():
209
+ self.add_edge(parent, new_node)
210
+ if new_node.is_root():
211
+ self.root_nodes.append(new_node)
212
+ if new_node.is_leaf():
213
+ self.leaf_nodes.append(new_node)
214
+ return new_node
215
+
216
+ #
217
+ def add_edge(self, from_node, to_node):
218
+ r""" Store the dependency between two nodes.
219
+
220
+ Args:
221
+ from_node: source node
222
+ to_node: target node
223
+
224
+ """
225
+ if self.verbose: print(f'Add edge ({from_node}) -> ({to_node})')
226
+ if not from_node in self.children:
227
+ self.children[from_node] = set()
228
+ self.children[from_node].add(to_node)
229
+ if not to_node in self.parents:
230
+ self.parents[to_node] = set()
231
+ self.parents[to_node].add(from_node)
232
+
233
+ #
234
+ def get_children(self, node):
235
+ """ Returns the children of a node.
236
+
237
+ """
238
+ if node in self.children:
239
+ return self.children[node]
240
+ return []
241
+
242
+ #
243
+ def get_parents(self, node):
244
+ """ Returns the parents of a node.
245
+
246
+ """
247
+ if node in self.parents:
248
+ return self.parents[node]
249
+ return []
250
+
251
+ #
252
+ def get_root_nodes(self):
253
+ """ Return all nodes that are roots.
254
+
255
+ """
256
+ return self.root_nodes
257
+
258
+ #
259
+ def get_leaf_nodes(self):
260
+ """ Returns all nodes that are leaves.
261
+
262
+ """
263
+ return {self.nodes[k]: self.nodes[k] for k in self.nodes.keys() - self.children.keys()}
264
+
265
+ #
266
+ def get_stochastic_nodes(self):
267
+ """ Returns all stochastic nodes.
268
+
269
+ """
270
+ return {k: v for k, v in self.nodes.items() if v.is_stochastic()}
271
+
272
+ #
273
+ def get_latent_nodes(self):
274
+ """ Returns all latent nodes.
275
+
276
+ """
277
+ return {k: v for k, v in self.nodes.items() if v.is_stochastic() and not v.is_observed()}
278
+
279
+ #
280
+ def logprior_fn(self) -> Callable:
281
+ r""" Returns a callable function that provides the log prior of the model given the current state of assigned variables.
282
+
283
+ """
284
+
285
+ def logprior_fn_(state) -> float:
286
+ sorted_free_variables = [node for node in self.get_node_order() if node.is_stochastic() and not node.is_observed()]
287
+ logprob = 0.0
288
+ for node in sorted_free_variables:
289
+ if node.is_root():
290
+ logprob += jnp.sum(node.get_distribution().log_prob(state[node.name]))
291
+ else:
292
+ logprob += jnp.sum(node.get_distribution(state).log_prob(state[node.name]))
293
+ return logprob
294
+
295
+ #
296
+ return logprior_fn_
297
+
298
+ #
299
+ def loglikelihood_fn(self) -> Callable:
300
+ r""" Returns a callable function that provides the log likelihood of the model given the current state of assigned variables.
301
+
302
+ """
303
+
304
+ def loglikelihood_fn_(state) -> float:
305
+ logprob = 0.0
306
+ for node in self.get_leaf_nodes():
307
+ if '__mask' in state:
308
+ element_wise_logp = node.get_distribution(state).log_prob(value=node.observations)
309
+ logprob += jnp.sum(jnp.nan_to_num(element_wise_logp, nan=0.0)*state['__mask'])
310
+ else:
311
+ logprob += jnp.sum(node.get_distribution(state).log_prob(value=node.observations))
312
+ return logprob
313
+
314
+ #
315
+ return loglikelihood_fn_
316
+
317
+ #
318
+ def batched_loglikelihood_fn(self) -> Callable:
319
+ r""" Batched loglikelihood function for stochastic-gradient methods.
320
+
321
+ Assumes `minibatch` is a dictionary containing a subset of observations for each observed leaf node.
322
+
323
+ """
324
+
325
+ def loglikelihood_fn_(state, minibatch) -> float:
326
+ logprob = 0.0
327
+ for node in self.get_leaf_nodes():
328
+ logprob += jnp.sum(node.get_distribution(state, minibatch=minibatch).log_prob(value=minibatch[node]))
329
+ return logprob
330
+
331
+ #
332
+ return loglikelihood_fn_
333
+
334
+ #
335
+ def get_model_size(self) -> int:
336
+ r""" Returns the total dimensions of the model.
337
+
338
+ As node.distribution can be abstract, we create a concrete instantiation by drawing a sample from the prior and deriving the shape from this sample.
339
+
340
+ """
341
+
342
+ draw = self.sample_prior(key=jrnd.PRNGKey(0))
343
+ size = jnp.sum(jnp.array([jnp.size(v) for v in draw.values()]))
344
+ return size
345
+
346
+ #
347
+ def get_node_order(self):
348
+ r""" Returns the latent variables in topological order; child nodes are always listed after their parents.
349
+
350
+ """
351
+ if not hasattr(self, 'node_order'):
352
+ self.node_order = self.__get_topological_order()
353
+ return self.node_order
354
+
355
+ #
356
+ def __get_topological_order(self) -> list:
357
+ r""" Traverses the directed acyclic graph that defines the Bayesian model and returns its nodes in topological order
358
+
359
+ Returns:
360
+ A list of sorted nodes.
361
+
362
+ """
363
+ def traverse_dag_backwards(node: Node, visited_: dict, order: list):
364
+ if node in visited_:
365
+ return
366
+ visited_[node] = 1
367
+
368
+ for parent_node in node.parents.values():
369
+ if parent_node.is_stochastic():
370
+ traverse_dag_backwards(parent_node, visited_, order)
371
+
372
+ order.append(node)
373
+
374
+ #
375
+ order = []
376
+ visited = {}
377
+ leaves = self.get_leaf_nodes()
378
+ for leaf in leaves:
379
+ traverse_dag_backwards(leaf, visited, order)
380
+ return order
381
+
382
+ #
383
+ def sample_prior(self, key) -> dict:
384
+ r""" Samples from the (hierarchical) prior distribution of the model.
385
+
386
+ Args:
387
+ key: Random seed
388
+ Returns:
389
+ A state dictionary with one random value for each node.
390
+
391
+ """
392
+ state = dict()
393
+ sorted_free_variables = [node for node in self.get_node_order() if node.is_stochastic() and not node.is_observed()]
394
+ for node in sorted_free_variables:
395
+ key, subkey = jrnd.split(key)
396
+ if node.is_root():
397
+ dist = node.get_distribution()
398
+ else:
399
+ dist = node.get_distribution(state)
400
+ state[node.name] = dist.sample(key=subkey, sample_shape=node.shape)
401
+ return state
402
+
403
+ #
404
+ def sample_predictive(self, key, state: dict, input_variables: dict = None) -> dict:
405
+ r""" Sample stochastic observed nodes
406
+
407
+ Args:
408
+ key: PRNGKey
409
+ state: a draw from either $p(x)$ or $p(x \mid \cdot)$
410
+ input_variables: a dictionary with values for observed non-stochastic nodes
411
+
412
+ Returns:
413
+ A dictionary which is the same as 'state' but appended with sampled values.
414
+
415
+ """
416
+ state = dict(state) # do not modify the caller's dictionary in place
417
+ for node in self.get_leaf_nodes():
418
+ key, key_obs = jrnd.split(key)
419
+ distribution = node.get_distribution(state, minibatch=input_variables)
420
+ sample_shape = self._predictive_sample_shape(node, distribution, input_variables)
421
+ state[node.name] = distribution.sample(key=key_obs, sample_shape=sample_shape)
422
+ return state
423
+
424
+ #
425
+ @staticmethod
426
+ def _predictive_sample_shape(node, distribution, input_variables=None) -> tuple:
427
+ r""" Determine the sample_shape that makes a predictive draw look like the observed data.
428
+
429
+ If the user gave the node an explicit `shape`, that is used. If new input variables are
430
+ supplied, the shape follows from those inputs (via the distribution's batch shape). Otherwise,
431
+ i.i.d. observations from a distribution with scalar parameters need extra leading sample
432
+ dimensions so that the draw has the same shape as `node.observations`.
433
+
434
+ """
435
+ if node.shape != ():
436
+ return node.shape
437
+ if input_variables or not node.is_observed():
438
+ return ()
439
+ try:
440
+ dist_shape = tuple(distribution.batch_shape) + tuple(distribution.event_shape)
441
+ except (AttributeError, NotImplementedError, ValueError):
442
+ return ()
443
+ obs_shape = tuple(jnp.shape(node.observations))
444
+ num_extra = len(obs_shape) - len(dist_shape)
445
+ return obs_shape[:num_extra] if num_extra > 0 else ()
446
+
447
+ #
448
+
449
+
450
+
451
+ def sample_prior_predictive(self, key, prediction_options: dict = None) -> dict:
452
+ r""" Sample from the (hierarchical) prior predictive distribution of the model.
453
+
454
+ Args:
455
+ key: Random seed
456
+ prediction_options: A dictionary of options which can include minibatched input variables
457
+
458
+ Returns:
459
+ A dictionary with a random value for all stochastic observed nodes.
460
+
461
+ """
462
+ key, key_latent = jrnd.split(key)
463
+ state = self.sample_prior(key_latent)
464
+ return self.sample_predictive(key, state, prediction_options)
465
+
466
+ #
467
+ def sample_posterior_predictive(self, key, state: dict, input_variables: dict = None) -> dict:
468
+ r""" Sample from the posterior predictive
469
+
470
+ Args:
471
+ key: Random key
472
+ state: A draw from the posterior
473
+ input_variables: Potential predictors and other non-stochastic observations
474
+
475
+ Returns:
476
+ A dictionary containing values for all stochastic observed nodes, conditioned on the observations.
477
+
478
+ """
479
+
480
+ return self.sample_predictive(key, state, input_variables)
481
+
482
+ #
483
+ def print_gibbs(self):
484
+ r""" Print the structure of conditional distributions.
485
+
486
+
487
+ """
488
+
489
+ print('Gibbs structure:')
490
+ sorted_free_variables = [node for node in self.get_node_order() if node.is_stochastic() and not node.is_observed()]
491
+ for node in sorted_free_variables:
492
+ # get prior density
493
+ if node.is_root():
494
+ prior = f'p({node})'
495
+ else:
496
+ parents = {p for p in self.get_parents(node)}
497
+ prior = f'p({node} | {", ".join([p.name for p in parents])})'
498
+
499
+ # get conditional density
500
+ conditionals = []
501
+ children = [c for c in self.get_children(node)]
502
+ for child in children:
503
+ co_parents = set()
504
+ for parent in self.get_parents(child):
505
+ if not parent == node or parent in co_parents:
506
+ co_parents.add(parent)
507
+ co_parents.add(node)
508
+ conditional = f'p({child} | {", ".join([str(p) for p in co_parents])})'
509
+ conditionals.append(conditional)
510
+
511
+ print(f'{str(node):20s}: {" ".join(conditionals)} {prior}')
512
+
513
+ #
514
+
515
+ #
516
+ class MetaModel():
517
+ r""" A meta-model is a collection of Bayesian models, which can be used for reversible jump MCMC.
518
+ """
519
+
520
+ def __init__(self,
521
+ model_list):
522
+ self.model_list = model_list
523
+ self.M = len(model_list)
524
+ self.model_sizes = [model.get_model_size() for model in model_list]
525
+ self.indiv_latent_nodes = [set(model.get_latent_nodes().keys()) for model in model_list]
526
+
527
+ # find the number of auxiliary variables needed, as the largest difference in latent node sets between any two models
528
+ self.num_auxiliary = 0
529
+ for i in range(self.M):
530
+ for j in range(i+1, self.M):
531
+ diff_left = set.difference(self.indiv_latent_nodes[i], self.indiv_latent_nodes[j])
532
+ diff_right = set.difference(self.indiv_latent_nodes[j], self.indiv_latent_nodes[i])
533
+ max_diff = jnp.max(jnp.array([len(diff_left), len(diff_right)]))
534
+ if max_diff > self.num_auxiliary:
535
+ self.num_auxiliary = max_diff
536
+
537
+ self.latent_variables = set.union(*self.indiv_latent_nodes)
538
+ self.auxiliary_variables = [f'u_{i}' for i in range(self.num_auxiliary)]
539
+ self.meta_state = self.latent_variables.union(set(self.auxiliary_variables))
540
+
541
+ def make_model_sample_prior_fn(model_index):
542
+ """Creates a function that samples from the prior of the specified model index."""
543
+ def fn(key):
544
+ sample = self.model_list[model_index].sample_prior(key)
545
+ all_latents = {k: jnp.nan for k in self.latent_variables}
546
+ return {**all_latents, **sample}
547
+
548
+ #
549
+ return fn
550
+ #
551
+ self.model_sample_prior_fns = [make_model_sample_prior_fn(i) for i in range(self.M)]
552
+
553
+ def make_model_sample_predictive_fn(model_index):
554
+ """Creates a function that samples from the predictive distribution of the specified model index."""
555
+ def fn(input):
556
+ key, state, input_variables = input
557
+ return self.model_list[model_index].sample_predictive(key, state, input_variables)
558
+
559
+ #
560
+ return fn
561
+
562
+ #
563
+ self.model_sample_predictive_fns = [make_model_sample_predictive_fn(i) for i in range(self.M)]
564
+
565
+ def make_logprior_fn(model_index):
566
+ """Creates a function that computes the log prior of the specified model index."""
567
+ def fn(state):
568
+ return self.model_list[model_index].logprior_fn()(state)
569
+
570
+ #
571
+ return fn
572
+
573
+ #
574
+ self.model_logprior_fns = [make_logprior_fn(i) for i in range(self.M)]
575
+
576
+ def make_loglikelihood_fn(model_index):
577
+ """Creates a function that computes the log likelihood of the specified model index."""
578
+ def fn(state):
579
+ return self.model_list[model_index].loglikelihood_fn()(state)
580
+
581
+ #
582
+ return fn
583
+
584
+ #
585
+ self.model_loglikelihood_fns = [make_loglikelihood_fn(i) for i in range(self.M)]
586
+
587
+ #
588
+ def sample_prior(self, key) -> dict:
589
+ key_model, key_sample = jrnd.split(key)
590
+ model_index = jrnd.randint(key_model, shape=(), minval=0, maxval=self.M)
591
+ sample = jax.lax.switch(model_index, self.model_sample_prior_fns, operand=key_sample)
592
+ auxiliary_values = {f'u_{i}': jnp.nan for i in range(self.num_auxiliary)} # to keep the same pytree structure across models in reversible jump MCMC
593
+ return {'model_index': model_index, **sample, **auxiliary_values}
594
+
595
+ #
596
+ def sample_predictive(self, key, state: dict, input_variables: dict = None) -> dict:
597
+ return jax.lax.switch(state['model_index'], self.model_sample_predictive_fns,
598
+ operand=[key, state, input_variables])
599
+
600
+ #
601
+ def sample_prior_predictive(self, key, **input_variables) -> dict:
602
+ key_prior, key_predictive = jrnd.split(key)
603
+ prior_sample = self.sample_prior(key_prior)
604
+ return self.sample_predictive(key_predictive, prior_sample, input_variables=input_variables)
605
+
606
+ #
607
+ def sample_posterior_predictive(self, key, state: dict, input_variables: dict = None) -> dict:
608
+ return self.sample_predictive(key, state, input_variables=input_variables)
609
+
610
+ #
611
+ def logprior_fn(self) -> Callable:
612
+
613
+ def fn(state: dict) -> float:
614
+ """Computes the log prior of the state."""
615
+ model_index = state['model_index']
616
+ return jax.lax.switch(model_index, self.model_logprior_fns, operand=state)
617
+
618
+ #
619
+ return fn
620
+
621
+ #
622
+ def loglikelihood_fn(self) -> Callable:
623
+
624
+ def fn(state: dict) -> float:
625
+ """Computes the log likelihood of the state."""
626
+ model_index = state['model_index']
627
+ return jax.lax.switch(model_index, self.model_loglikelihood_fns, operand=state)
628
+ #
629
+ return fn
630
+
631
+ #
632
+ #
File without changes
@@ -0,0 +1,6 @@
1
+ from .gwishart import gwishart_drj, fixed_by_drj
2
+
3
+ __all__ = [
4
+ "gwishart_drj",
5
+ "fixed_by_drj",
6
+ ]