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.
- binference/__init__.py +85 -0
- binference/_kernels.py +252 -0
- binference/_quadrature.py +202 -0
- binference/_simplex.py +54 -0
- binference/_utils.py +139 -0
- binference/_version.py +1 -0
- binference/basis.py +314 -0
- binference/bins.py +645 -0
- binference/density.py +323 -0
- binference/diagnostics.py +400 -0
- binference/estimator.py +197 -0
- binference/inference.py +368 -0
- binference/io.py +309 -0
- binference/likelihoods.py +1684 -0
- binference/measure.py +246 -0
- binference/model.py +833 -0
- binference/plotting.py +538 -0
- binference/priors/__init__.py +47 -0
- binference/priors/base.py +248 -0
- binference/priors/dirichlet.py +640 -0
- binference/priors/hyper.py +240 -0
- binference/priors/logistic.py +676 -0
- binference/priors/rates.py +139 -0
- binference/response.py +539 -0
- binference/results.py +2271 -0
- binference/samplers/__init__.py +19 -0
- binference/samplers/_curvature.py +67 -0
- binference/samplers/_nuts_core.py +284 -0
- binference/samplers/base.py +118 -0
- binference/samplers/bootstrap.py +136 -0
- binference/samplers/gibbs.py +287 -0
- binference/samplers/laplace.py +200 -0
- binference/samplers/nuts.py +681 -0
- binference/samplers/optimize.py +488 -0
- binference/samplers/vb.py +118 -0
- binference/selection.py +621 -0
- binference/simulate.py +720 -0
- binference/validation.py +477 -0
- binference/warnings.py +48 -0
- binference-0.1.0.dist-info/METADATA +130 -0
- binference-0.1.0.dist-info/RECORD +43 -0
- binference-0.1.0.dist-info/WHEEL +4 -0
- 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)
|