binference 0.1.0__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 (43) hide show
  1. binference/__init__.py +85 -0
  2. binference/_kernels.py +252 -0
  3. binference/_quadrature.py +202 -0
  4. binference/_simplex.py +54 -0
  5. binference/_utils.py +139 -0
  6. binference/_version.py +1 -0
  7. binference/basis.py +314 -0
  8. binference/bins.py +645 -0
  9. binference/density.py +323 -0
  10. binference/diagnostics.py +400 -0
  11. binference/estimator.py +197 -0
  12. binference/inference.py +368 -0
  13. binference/io.py +309 -0
  14. binference/likelihoods.py +1684 -0
  15. binference/measure.py +246 -0
  16. binference/model.py +833 -0
  17. binference/plotting.py +538 -0
  18. binference/priors/__init__.py +47 -0
  19. binference/priors/base.py +248 -0
  20. binference/priors/dirichlet.py +640 -0
  21. binference/priors/hyper.py +240 -0
  22. binference/priors/logistic.py +676 -0
  23. binference/priors/rates.py +139 -0
  24. binference/response.py +539 -0
  25. binference/results.py +2271 -0
  26. binference/samplers/__init__.py +19 -0
  27. binference/samplers/_curvature.py +67 -0
  28. binference/samplers/_nuts_core.py +284 -0
  29. binference/samplers/base.py +118 -0
  30. binference/samplers/bootstrap.py +136 -0
  31. binference/samplers/gibbs.py +287 -0
  32. binference/samplers/laplace.py +200 -0
  33. binference/samplers/nuts.py +681 -0
  34. binference/samplers/optimize.py +488 -0
  35. binference/samplers/vb.py +118 -0
  36. binference/selection.py +621 -0
  37. binference/simulate.py +720 -0
  38. binference/validation.py +477 -0
  39. binference/warnings.py +48 -0
  40. binference-0.1.0.dist-info/METADATA +130 -0
  41. binference-0.1.0.dist-info/RECORD +43 -0
  42. binference-0.1.0.dist-info/WHEEL +4 -0
  43. binference-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,640 @@
1
+ """Conjugate priors on the simplex: Dirichlet, Dirichlet-tree, generalised Dirichlet, Polya tree.
2
+
3
+ All of these remain conjugate given latent bin counts, so Gibbs sampling,
4
+ EM/MAP and mean-field VB are closed-form. Gradient-based engines use the
5
+ isometric log-ratio (ILR) parametrisation, in which the log-density of a
6
+ Dirichlet-tree is linear in the log masses of leaves and internal nodes:
7
+
8
+ log p(rho) = const + sum_k (alpha_pa(k),k - 1) log rho_k
9
+ + sum_{c != root} (alpha_pa(c),c - A_c) log rho_c ,
10
+
11
+ with ``A_c`` the total concentration of node ``c``'s children.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ from scipy import special
21
+
22
+ from .._simplex import helmert_basis, log_softmax, softmax
23
+ from .._utils import check_positive
24
+ from ..bins import Bins
25
+ from ..density import Density
26
+ from .base import (
27
+ _TINY,
28
+ BoundPrior,
29
+ PopulationPrior,
30
+ _log_gamma_variates,
31
+ _per_item,
32
+ _stick_break,
33
+ )
34
+
35
+ __all__ = ["Dirichlet", "DirichletTree", "GeneralizedDirichlet", "PolyaTree"]
36
+
37
+
38
+ def _dirichlet_draw(alpha: np.ndarray, rng: np.random.Generator, size: int | None = None):
39
+ """Dirichlet draws without exact zeros, even for tiny concentrations (floored at 1e-300)."""
40
+ alpha = np.asarray(alpha, dtype=float)
41
+ shape = alpha.shape if size is None else (int(size), *alpha.shape)
42
+ return np.maximum(softmax(_log_gamma_variates(alpha, rng, shape)), _TINY)
43
+
44
+
45
+ class _SimplexBound(BoundPrior):
46
+ """Shared ILR machinery for simplex priors."""
47
+
48
+ has_density = True
49
+ has_prior_transform = True
50
+ conjugate = True
51
+
52
+ def __init__(self, prior: PopulationPrior, bins: Bins) -> None:
53
+ super().__init__(prior, bins)
54
+ self.dim = self.K - 1
55
+ self.V = helmert_basis(self.K)
56
+ self._log_jac_const = 0.5 * np.log(self.K)
57
+
58
+ def forward(self, x: np.ndarray) -> tuple[np.ndarray, Any]:
59
+ eta = self.V @ x
60
+ return eta, eta
61
+
62
+ def log_prior_and_grad(self, x, cache, value, d_eta):
63
+ log_rho = log_softmax(cache)
64
+ lp, g_eta = self._lp_eta(value, log_rho)
65
+ return lp + self._log_jac_const, self.V.T @ (d_eta + g_eta)
66
+
67
+ def init_from_value(self, value: np.ndarray) -> np.ndarray:
68
+ r = np.asarray(value, dtype=float)
69
+ r = np.maximum(r, 1e-12 * np.max(r))
70
+ r = r / r.sum()
71
+ return self.V.T @ np.log(r)
72
+
73
+ def _lp_eta(self, rho, log_rho): # pragma: no cover - abstract
74
+ raise NotImplementedError
75
+
76
+
77
+ # ----------------------------------------------------------------------------
78
+ # Dirichlet
79
+ # ----------------------------------------------------------------------------
80
+ class Dirichlet(PopulationPrior):
81
+ r"""Dirichlet prior :math:`\rho \sim \mathrm{Dir}(\alpha)` on bin fractions.
82
+
83
+ The default prior of binference (``alpha=1``, uniform on the simplex).
84
+
85
+ Parameters
86
+ ----------
87
+ alpha : float, array_like (K,), or "reference"
88
+ Concentration parameters (strictly positive). ``"reference"`` uses
89
+ ``alpha = 1/K`` (Berger, Bernardo & Sun 2015), which avoids swamping
90
+ sparse problems with many bins.
91
+
92
+ Notes
93
+ -----
94
+ A symmetric ``alpha`` over bins of unequal volume is *not* consistent under
95
+ merging bins; :meth:`from_base_measure` makes ``alpha`` proportional to a
96
+ base measure (flat in the measure coordinate by default) instead.
97
+ """
98
+
99
+ def __init__(self, alpha: float | Sequence[float] | np.ndarray | str = 1.0) -> None:
100
+ if isinstance(alpha, str):
101
+ if alpha != "reference":
102
+ raise ValueError("alpha must be positive numbers or 'reference'.")
103
+ else:
104
+ check_positive(alpha, "Dirichlet alpha")
105
+ self.alpha = alpha
106
+ self._base: tuple[float, Any] | None = None
107
+
108
+ def _params(self):
109
+ if self._base is not None:
110
+ return {"concentration": self._base[0], "base": self._base[1]}
111
+ return {"alpha": self.alpha}
112
+
113
+ @classmethod
114
+ def reference(cls, K: int | None = None) -> Dirichlet:
115
+ """Reference prior ``alpha = 1/K``.
116
+
117
+ Parameters
118
+ ----------
119
+ K : int, optional
120
+ Number of bins; ``None`` takes it from the bins when the prior is bound.
121
+ """
122
+ return cls("reference") if K is None else cls(1.0 / K)
123
+
124
+ @classmethod
125
+ def from_counts(
126
+ cls, counts: Sequence[float] | np.ndarray, strength: float = 1.0, base: float = 1.0
127
+ ) -> Dirichlet:
128
+ """Prior from a reference sample: ``alpha = base + strength * counts``.
129
+
130
+ ``strength < 1`` down-weights a reference sample that may not be fully
131
+ representative (for a coherent model of an unrepresentative reference
132
+ use a reference-centred :class:`~binference.priors.LogisticNormal` or
133
+ :class:`~binference.priors.LogisticRandomWalk` with ``mean=log(reference)``).
134
+
135
+ Parameters
136
+ ----------
137
+ counts : array_like (K,)
138
+ Counts of the reference sample in each bin.
139
+ strength : float, optional
140
+ Weight of the reference counts in ``alpha``.
141
+ base : float, optional
142
+ Concentration added to every bin.
143
+ """
144
+ c = np.asarray(counts, dtype=float)
145
+ if np.any(c < 0):
146
+ raise ValueError("counts must be non-negative.")
147
+ return cls(base + strength * c)
148
+
149
+ @classmethod
150
+ def from_base_measure(cls, concentration: float = 1.0, base: Any = "flat") -> Dirichlet:
151
+ """``alpha_k = concentration * B_k``, with ``B`` the normalised bin masses of ``base``.
152
+
153
+ ``base`` is a :class:`~binference.Density` (or anything
154
+ :meth:`Density.coerce <binference.Density.coerce>` accepts; an array is
155
+ read as masses per bin). The default is flat in the measure coordinate,
156
+ proportional to bin volumes on linear bins.
157
+
158
+ Parameters
159
+ ----------
160
+ concentration : float, optional
161
+ Total concentration, spread over the bins by the base measure.
162
+ base : Density, str, callable or array_like, optional
163
+ Base measure whose bin masses shape ``alpha``.
164
+ """
165
+ check_positive(concentration, "concentration")
166
+ obj = cls(1.0)
167
+ obj.alpha = None # set at binding, from the base measure
168
+ obj._base = (float(concentration), Density.coerce(base, "mass", name="base"))
169
+ return obj
170
+
171
+ def resolve_alpha(self, bins: Bins) -> np.ndarray:
172
+ K = bins.K
173
+ if self._base is not None:
174
+ conc, base = self._base
175
+ b = base.masses(bins)
176
+ if np.any(b <= 0):
177
+ raise ValueError("The base measure must give every bin positive mass.")
178
+ return conc * b
179
+ if isinstance(self.alpha, str):
180
+ return np.full(K, 1.0 / K)
181
+ return _per_item(self.alpha, K, "alpha").astype(float)
182
+
183
+ def bind(self, bins: Bins) -> _BoundDirichlet:
184
+ return _BoundDirichlet(self, bins, self.resolve_alpha(bins))
185
+
186
+
187
+ class _BoundDirichlet(_SimplexBound):
188
+ def __init__(self, prior: PopulationPrior, bins: Bins, alpha: np.ndarray) -> None:
189
+ super().__init__(prior, bins)
190
+ self.alpha = alpha
191
+ self.pseudocounts = alpha - 1.0
192
+ self.A = float(alpha.sum())
193
+ self._const = float(special.gammaln(self.A) - special.gammaln(alpha).sum())
194
+
195
+ def _lp_eta(self, rho, log_rho):
196
+ lp = float(np.dot(self.alpha, log_rho)) + self._const
197
+ return lp, self.alpha - self.A * rho
198
+
199
+ def sample(self, n, rng):
200
+ return _dirichlet_draw(self.alpha, rng, size=n)
201
+
202
+ def log_density(self, value):
203
+ r = np.asarray(value, dtype=float)
204
+ return np.sum(special.xlogy(self.alpha - 1.0, r), axis=-1) + self._const
205
+
206
+ def grad_log_density(self, value):
207
+ v = np.asarray(value, dtype=float)
208
+ pos = v > 0
209
+ return np.where(pos, (self.alpha - 1.0) / np.where(pos, v, 1.0), 0.0)
210
+
211
+ def prior_transform(self, u):
212
+ return _stick_break(self.alpha, np.asarray(u, dtype=float))
213
+
214
+ def posterior_sample(self, counts, rng, exposure=None):
215
+ return _dirichlet_draw(self.alpha + counts, rng)
216
+
217
+ def unbounded_density(self):
218
+ return bool(np.any(self.alpha < 1.0))
219
+
220
+ def map_from_counts(self, counts, exposure=None):
221
+ x = np.maximum(np.asarray(counts, float) + self.alpha - 1.0, 0.0)
222
+ tot = x.sum()
223
+ return x / tot if tot > 0 else np.full(self.K, 1.0 / self.K)
224
+
225
+ def vb_update(self, counts, exposure=None):
226
+ return self.alpha + counts
227
+
228
+ def vb_expected_log(self, state):
229
+ return special.digamma(state) - special.digamma(state.sum())
230
+
231
+ def vb_sample(self, state, n, rng):
232
+ return _dirichlet_draw(state, rng, size=n)
233
+
234
+ def vb_mean(self, state):
235
+ return state / state.sum()
236
+
237
+
238
+ # ----------------------------------------------------------------------------
239
+ # Dirichlet-tree family
240
+ # ----------------------------------------------------------------------------
241
+ class _TreeStructure:
242
+ """Flattened representation of a Dirichlet-tree over ``K`` leaves.
243
+
244
+ ``nodes[c]`` is a list of children, each ``("leaf", k)`` or ``("node", c')``;
245
+ node 0 is the root and parents precede children.
246
+ """
247
+
248
+ def __init__(self, nodes: list[list[tuple[str, int]]], alphas: list[np.ndarray], K: int):
249
+ # the priors validate the concentrations; the leaves need K
250
+ C = len(nodes)
251
+ seen_leaves: list[int] = []
252
+ parent_slot_of_node = np.full(C, -1, dtype=np.int64)
253
+ leaf_slot = np.full(K, -1, dtype=np.int64)
254
+ slot_child: list[tuple[str, int]] = []
255
+ slot_node: list[int] = []
256
+ slot_alpha: list[float] = []
257
+ ptr = [0]
258
+ for c, (children, a) in enumerate(zip(nodes, alphas, strict=True)):
259
+ for (kind, idx), aj in zip(children, a, strict=True):
260
+ s = len(slot_child)
261
+ slot_child.append((kind, idx))
262
+ slot_node.append(c)
263
+ slot_alpha.append(float(aj))
264
+ if kind == "leaf":
265
+ if not 0 <= idx < K:
266
+ raise ValueError(f"Leaf index {idx} out of range for K={K}.")
267
+ seen_leaves.append(idx)
268
+ leaf_slot[idx] = s
269
+ else:
270
+ if not c < idx < C:
271
+ raise ValueError("Child nodes must come after their parents.")
272
+ if parent_slot_of_node[idx] != -1:
273
+ raise ValueError(f"Node {idx} has more than one parent.")
274
+ parent_slot_of_node[idx] = s
275
+ ptr.append(len(slot_child))
276
+ if sorted(seen_leaves) != list(range(K)):
277
+ raise ValueError("Tree leaves must be exactly 0..K-1, each appearing once.")
278
+ if np.any(parent_slot_of_node[1:] < 0):
279
+ raise ValueError("Every internal node except the root needs a parent.")
280
+ self.K, self.C = K, C
281
+ self.ptr = np.asarray(ptr)
282
+ self.slot_node = np.asarray(slot_node)
283
+ self.slot_alpha = np.asarray(slot_alpha)
284
+ self.slot_child = slot_child
285
+ S = len(slot_child)
286
+ # leaf membership of every node and every child slot (bottom-up)
287
+ node_mem = np.zeros((C, K))
288
+ for c in range(C - 1, -1, -1):
289
+ for kind, idx in nodes[c]:
290
+ if kind == "leaf":
291
+ node_mem[c, idx] = 1.0
292
+ else:
293
+ node_mem[c] += node_mem[idx]
294
+ slot_mem = np.zeros((S, K))
295
+ for s, (kind, idx) in enumerate(slot_child):
296
+ if kind == "leaf":
297
+ slot_mem[s, idx] = 1.0
298
+ else:
299
+ slot_mem[s] = node_mem[idx]
300
+ self.node_mem = node_mem
301
+ self.slot_mem = slot_mem
302
+ A_node = np.add.reduceat(self.slot_alpha, self.ptr[:-1])
303
+ self.A_node = A_node
304
+ self.leaf_coef = self.slot_alpha[leaf_slot] - 1.0 # (K,)
305
+ nr = np.arange(1, C)
306
+ self.node_coef = self.slot_alpha[parent_slot_of_node[nr]] - A_node[nr] # (C-1,)
307
+ self.nonroot_mem = node_mem[1:]
308
+ # slot_nodes[s, c'] = 1 if non-root internal node c' lies under (or is) slot s's child
309
+ sizes = self.nonroot_mem.sum(axis=1)
310
+ self.slot_nodes = ((slot_mem @ self.nonroot_mem.T) == sizes[None, :]).astype(float)
311
+ self.const = float(
312
+ np.sum(special.gammaln(A_node)) - np.sum(special.gammaln(self.slot_alpha))
313
+ )
314
+
315
+ # -- helpers --------------------------------------------------------------
316
+ def compose(self, theta: np.ndarray) -> np.ndarray:
317
+ """Leaf probabilities from branch probabilities (last axis = slots)."""
318
+ lt = np.log(np.maximum(theta, _TINY))
319
+ rho = np.exp(lt @ self.slot_mem)
320
+ zero = (theta <= 0).astype(float) @ self.slot_mem > 0
321
+ if np.any(zero):
322
+ rho = np.where(zero, 0.0, rho)
323
+ return rho
324
+
325
+ def draw_theta(self, alpha_slots: np.ndarray, rng, size: int | None = None) -> np.ndarray:
326
+ shape = (alpha_slots.size,) if size is None else (size, alpha_slots.size)
327
+ theta = np.empty(shape)
328
+ for c in range(self.C):
329
+ sl = slice(self.ptr[c], self.ptr[c + 1])
330
+ theta[..., sl] = _dirichlet_draw(alpha_slots[sl], rng, size=size)
331
+ return theta
332
+
333
+ def slot_counts(self, counts: np.ndarray) -> np.ndarray:
334
+ return self.slot_mem @ counts
335
+
336
+
337
+ class _BoundTree(_SimplexBound):
338
+ def __init__(self, prior: PopulationPrior, bins: Bins, tree: _TreeStructure) -> None:
339
+ super().__init__(prior, bins)
340
+ self.tree = tree
341
+ self._a1 = tree.leaf_coef + 1.0
342
+ self._sum_a1 = float(self._a1.sum())
343
+ self._sum_d = float(tree.node_coef.sum())
344
+
345
+ def _lp_eta(self, rho, log_rho):
346
+ t = self.tree
347
+ rc = np.maximum(t.nonroot_mem @ rho, _TINY)
348
+ lp = float(np.dot(self._a1, log_rho) + np.dot(t.node_coef, np.log(rc)) + t.const)
349
+ g = self._a1 - self._sum_a1 * rho + rho * (t.nonroot_mem.T @ (t.node_coef / rc))
350
+ g = g - self._sum_d * rho
351
+ return lp, g
352
+
353
+ def sample(self, n, rng):
354
+ theta = self.tree.draw_theta(self.tree.slot_alpha, rng, size=n)
355
+ return np.maximum(self.tree.compose(theta), _TINY)
356
+
357
+ def unbounded_density(self):
358
+ # log-density coefficients of the branch probabilities: negative ones (e.g.
359
+ # Polya trees, whose concentrations grow with depth) make it unbounded
360
+ t = self.tree
361
+ coef = t.slot_mem @ t.leaf_coef + t.slot_nodes @ t.node_coef
362
+ return bool(np.any(coef < 0.0))
363
+
364
+ def log_density(self, value):
365
+ t = self.tree
366
+ r = np.asarray(value, dtype=float)
367
+ rc = r @ t.nonroot_mem.T
368
+ leaf = np.sum(special.xlogy(t.leaf_coef, r), axis=-1)
369
+ node = np.sum(special.xlogy(t.node_coef, rc), axis=-1)
370
+ return leaf + node + t.const
371
+
372
+ def grad_log_density(self, value):
373
+ t = self.tree
374
+ v = np.asarray(value, dtype=float)
375
+ pos = v > 0
376
+ g = np.where(pos, t.leaf_coef / np.where(pos, v, 1.0), 0.0)
377
+ rc = t.nonroot_mem @ v
378
+ ok = rc > 0
379
+ g = g + t.nonroot_mem[ok].T @ (t.node_coef[ok] / rc[ok])
380
+ return np.where(pos, g, 0.0)
381
+
382
+ def prior_transform(self, u):
383
+ t = self.tree
384
+ u = np.asarray(u, dtype=float)
385
+ theta = np.empty((*u.shape[:-1], t.slot_alpha.size))
386
+ for c in range(t.C): # one stick-breaking per internal node, of m - 1 uniforms
387
+ lo, hi = t.ptr[c], t.ptr[c + 1]
388
+ theta[..., lo:hi] = _stick_break(t.slot_alpha[lo:hi], u[..., lo - c : hi - c - 1])
389
+ return t.compose(theta)
390
+
391
+ def posterior_sample(self, counts, rng, exposure=None):
392
+ t = self.tree
393
+ return np.maximum(
394
+ t.compose(t.draw_theta(t.slot_alpha + t.slot_counts(counts), rng)), _TINY
395
+ )
396
+
397
+ def map_from_counts(self, counts, exposure=None):
398
+ # Mode of (prior density on the simplex) x prod rho^counts. The log-density is
399
+ # linear in log leaf and node masses, i.e. linear in the log branch
400
+ # probabilities, so each node's branches are proportional to their coefficients.
401
+ t = self.tree
402
+ x = t.slot_mem @ (np.asarray(counts, float) + t.leaf_coef) + t.slot_nodes @ t.node_coef
403
+ x = np.maximum(x, 0.0)
404
+ tot = np.add.reduceat(x, t.ptr[:-1])
405
+ n_ch = np.diff(t.ptr)
406
+ tot_rep = np.repeat(tot, n_ch)
407
+ theta = np.where(
408
+ tot_rep > 0, x / np.where(tot_rep > 0, tot_rep, 1.0), 1.0 / np.repeat(n_ch, n_ch)
409
+ )
410
+ return t.compose(theta)
411
+
412
+ def vb_update(self, counts, exposure=None):
413
+ return self.tree.slot_alpha + self.tree.slot_counts(counts)
414
+
415
+ def vb_expected_log(self, state):
416
+ t = self.tree
417
+ tot = np.repeat(np.add.reduceat(state, t.ptr[:-1]), np.diff(t.ptr))
418
+ return (special.digamma(state) - special.digamma(tot)) @ t.slot_mem
419
+
420
+ def vb_sample(self, state, n, rng):
421
+ return np.maximum(self.tree.compose(self.tree.draw_theta(state, rng, size=n)), _TINY)
422
+
423
+ def vb_mean(self, state):
424
+ t = self.tree
425
+ tot = np.repeat(np.add.reduceat(state, t.ptr[:-1]), np.diff(t.ptr))
426
+ return t.compose(state / tot)
427
+
428
+
429
+ def _parse_nested(tree: Any) -> list[list[tuple[str, int]]]:
430
+ """Nested lists of leaf indices -> flattened node list (pre-order)."""
431
+ nodes: list[list[tuple[str, int]]] = []
432
+
433
+ def visit(sub: Any) -> int:
434
+ if isinstance(sub, int | np.integer):
435
+ raise ValueError("The tree root must be a list of children.")
436
+ idx = len(nodes)
437
+ children: list[tuple[str, int]] = []
438
+ nodes.append(children)
439
+ for ch in sub:
440
+ if isinstance(ch, int | np.integer):
441
+ children.append(("leaf", int(ch)))
442
+ else:
443
+ children.append(("node", visit(ch)))
444
+ if len(children) < 2:
445
+ raise ValueError("Every internal node of a Dirichlet-tree needs >= 2 children.")
446
+ return idx
447
+
448
+ visit(tree)
449
+ return nodes
450
+
451
+
452
+ class _TreePrior(PopulationPrior):
453
+ """A prior built as a Dirichlet-tree over the bins (subclasses give its structure)."""
454
+
455
+ def _structure(self, bins: Bins) -> _TreeStructure: # pragma: no cover - abstract
456
+ raise NotImplementedError
457
+
458
+ def bind(self, bins: Bins) -> _BoundTree:
459
+ return _BoundTree(self, bins, self._structure(bins))
460
+
461
+
462
+ class DirichletTree(_TreePrior):
463
+ """Dirichlet-tree prior: independent Dirichlet splits along a tree of bins.
464
+
465
+ Generalises the Dirichlet (a single node) and the generalised Dirichlet (a
466
+ "caterpillar" tree). Bins grouped under the same internal node can be
467
+ positively correlated, and each split has its own concentration. Conjugate
468
+ given latent counts.
469
+
470
+ Parameters
471
+ ----------
472
+ tree : nested sequence of int
473
+ Internal nodes are lists; leaves are bin indices ``0..K-1``, each
474
+ appearing exactly once. E.g. ``[[0, 1], [2, [3, 4]]]``.
475
+ alpha : float or sequence of arrays
476
+ A single concentration for every branch, or one array per internal node
477
+ (in pre-order, i.e. parents before children, left to right) with one
478
+ value per child.
479
+
480
+ Examples
481
+ --------
482
+ >>> import binference
483
+ >>> prior = binference.priors.DirichletTree([[0, 1], [2, [3, 4]]], alpha=2.0)
484
+ """
485
+
486
+ def __init__(self, tree: Any, alpha: float | Sequence[Any] = 1.0) -> None:
487
+ self.tree = tree
488
+ self.alpha = alpha
489
+ self._nodes = _parse_nested(tree)
490
+ self._alphas = self._resolve_alphas(alpha)
491
+
492
+ def _resolve_alphas(self, alpha) -> list[np.ndarray]:
493
+ # per-node arrays may differ in length, so test the container before np.ndim
494
+ if not isinstance(alpha, list | tuple) and np.ndim(alpha) == 0:
495
+ a = float(check_positive(alpha, "alpha"))
496
+ return [np.full(len(ch), a) for ch in self._nodes]
497
+ alphas = [check_positive(a, "alpha").ravel() for a in alpha]
498
+ if len(alphas) != len(self._nodes):
499
+ raise ValueError(f"Need one alpha array per internal node ({len(self._nodes)}).")
500
+ for c, (a, ch) in enumerate(zip(alphas, self._nodes, strict=True)):
501
+ if a.size != len(ch):
502
+ raise ValueError(f"alpha for node {c} needs one value per child ({len(ch)}).")
503
+ return alphas
504
+
505
+ def _structure(self, bins: Bins) -> _TreeStructure:
506
+ return _TreeStructure(self._nodes, self._alphas, bins.K)
507
+
508
+
509
+ class GeneralizedDirichlet(_TreePrior):
510
+ r"""Generalised Dirichlet (Connor & Mosimann 1969) via stick-breaking Betas.
511
+
512
+ :math:`V_k \sim \mathrm{Beta}(a_k, b_k)` for ``k = 1..K-1`` and
513
+ :math:`\rho_k = V_k \prod_{j<k} (1 - V_j)`, breaking sticks in bin order (or
514
+ ``order``). It reduces to :math:`\mathrm{Dir}(\alpha_1, \dots, \alpha_K)`
515
+ with :math:`a_k = \alpha_k` and :math:`b_k = \sum_{j>k}\alpha_j`, the sum
516
+ including :math:`\alpha_K`; other choices give more flexible (including
517
+ positive) correlations.
518
+
519
+ Parameters
520
+ ----------
521
+ a : float or array_like (K-1,), optional
522
+ First Beta parameter of each stick.
523
+ b : float or array_like (K-1,), optional
524
+ Second Beta parameter of each stick. ``None`` gives the
525
+ Dirichlet-equivalent ``b_k = (K - 1 - k) * a`` for scalar ``a``.
526
+ order : sequence of int, optional
527
+ Permutation of the bins defining the stick-breaking order.
528
+ """
529
+
530
+ def __init__(
531
+ self,
532
+ a: float | Sequence[float] | np.ndarray = 1.0,
533
+ b: float | Sequence[float] | np.ndarray | None = None,
534
+ order: Sequence[int] | None = None,
535
+ ) -> None:
536
+ check_positive(a, "a")
537
+ if b is None:
538
+ if np.ndim(a) != 0:
539
+ raise ValueError("Pass b explicitly when a is an array.")
540
+ else:
541
+ check_positive(b, "b")
542
+ if order is not None:
543
+ o = np.asarray(order)
544
+ if o.ndim != 1 or sorted(o.tolist()) != list(range(o.size)):
545
+ raise ValueError("order must be a permutation of 0..K-1.")
546
+ self.a = a
547
+ self.b = b
548
+ self.order = order
549
+
550
+ def _structure(self, bins: Bins) -> _TreeStructure:
551
+ K = bins.K
552
+ order = np.arange(K) if self.order is None else np.asarray(self.order, dtype=int)
553
+ if order.size != K:
554
+ raise ValueError(f"order must be a permutation of 0..K-1 with K={K}.")
555
+ a = _per_item(self.a, K - 1, "a").astype(float)
556
+ if self.b is None:
557
+ b = (K - 1 - np.arange(K - 1)) * float(self.a)
558
+ else:
559
+ b = _per_item(self.b, K - 1, "b").astype(float)
560
+ nodes: list[list[tuple[str, int]]] = []
561
+ alphas: list[np.ndarray] = []
562
+ for k in range(K - 1):
563
+ right = ("leaf", int(order[K - 1])) if k == K - 2 else ("node", k + 1)
564
+ nodes.append([("leaf", int(order[k])), right])
565
+ alphas.append(np.array([a[k], b[k]]))
566
+ return _TreeStructure(nodes, alphas, K)
567
+
568
+
569
+ class PolyaTree(_TreePrior):
570
+ r"""Finite Polya-tree prior on an ordered 1-D grid (a balanced binary Dirichlet-tree).
571
+
572
+ Bins are split recursively into halves; a split at level :math:`m`
573
+ (root = 1) has Beta concentrations
574
+ :math:`c\,m^{p}\cdot 2 G_0(\text{child})/G_0(\text{parent})`, so the prior
575
+ mean is the base measure :math:`G_0` and fluctuations shrink at finer
576
+ scales (``power=2`` gives absolutely continuous limits; Lavine 1992). A
577
+ conjugate, multi-scale alternative to smoothness priors.
578
+
579
+ Parameters
580
+ ----------
581
+ c : float
582
+ Overall concentration (larger = closer to the base measure).
583
+ power : float
584
+ Growth of concentration with depth.
585
+ base : Density, "flat", callable or array_like (K,)
586
+ Base measure, read by :meth:`Density.coerce <binference.Density.coerce>`
587
+ (an array is masses per bin). The default is flat in the measure
588
+ coordinate.
589
+ """
590
+
591
+ def __init__(
592
+ self,
593
+ c: float = 1.0,
594
+ power: float = 2.0,
595
+ base: Any = "flat",
596
+ ) -> None:
597
+ check_positive(c, "c")
598
+ if not (np.isfinite(power) and power >= 0):
599
+ raise ValueError("power must be finite and non-negative.")
600
+ self.c = float(c)
601
+ self.power = float(power)
602
+ self.base = Density.coerce(base, "mass", name="base")
603
+
604
+ def _structure(self, bins: Bins) -> _TreeStructure:
605
+ if bins.is_categorical or bins.ndim != 1:
606
+ raise ValueError("PolyaTree is defined on ordered one-dimensional bins.")
607
+ K = bins.K
608
+ if K < 2:
609
+ raise ValueError("PolyaTree needs at least two bins.")
610
+ g0 = self.base.masses(bins)
611
+ if np.any(g0 <= 0):
612
+ raise ValueError("base must give a positive mass for every bin.")
613
+ cum = np.concatenate(([0.0], np.cumsum(g0)))
614
+ nodes: list[list[tuple[str, int]]] = []
615
+ alphas: list[np.ndarray] = []
616
+
617
+ def mass(lo: int, hi: int) -> float:
618
+ return cum[hi] - cum[lo]
619
+
620
+ def build(lo: int, hi: int, level: int) -> int:
621
+ idx = len(nodes)
622
+ children: list[tuple[str, int]] = []
623
+ a = np.empty(2)
624
+ nodes.append(children)
625
+ alphas.append(a)
626
+ mid = (lo + hi + 1) // 2
627
+ for j, (clo, chi) in enumerate(((lo, mid), (mid, hi))):
628
+ if chi - clo == 1:
629
+ children.append(("leaf", clo))
630
+ else:
631
+ children.append(("node", build(clo, chi, level + 1)))
632
+ with np.errstate(over="ignore"): # inf is refused below
633
+ depth = np.power(float(level), self.power)
634
+ a[j] = self.c * depth * 2.0 * mass(clo, chi) / mass(lo, hi)
635
+ return idx
636
+
637
+ build(0, K, 1)
638
+ for a in alphas: # a large power can overflow level**power
639
+ check_positive(a, "PolyaTree concentrations (c * level**power)")
640
+ return _TreeStructure(nodes, alphas, K)