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,400 @@
1
+ """Convergence and quality diagnostics.
2
+
3
+ * :func:`ess` and :func:`rhat` follow Vehtari, Gelman, Simpson, Carpenter &
4
+ Burkner (2021): rank-normalised, split-chain statistics.
5
+ * :func:`autocorr_time` follows the windowed estimator used by emcee
6
+ (Sokal 1997; Goodman & Weare 2010).
7
+ * :func:`psis` implements Pareto-smoothed importance sampling (Vehtari et al.
8
+ 2024), returning smoothed log-weights and the shape diagnostic ``k_hat``.
9
+ * :func:`kkt_gap` and :func:`missing_information` are model-specific checks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ from scipy import special, stats
18
+
19
+ from ._utils import chunk_slices
20
+
21
+ __all__ = [
22
+ "autocorr_function",
23
+ "autocorr_time",
24
+ "ess",
25
+ "importance_ess",
26
+ "kkt_gap",
27
+ "mcse",
28
+ "missing_information",
29
+ "psis",
30
+ "psis_threshold",
31
+ "rhat",
32
+ ]
33
+
34
+ #: Quantiles whose indicators define the tail ESS.
35
+ TAIL_QUANTILES = (0.05, 0.95)
36
+ #: Pareto k-hat at or above which importance sampling is unreliable, reached from about
37
+ #: 2,000 weights; with n weights the limit is min(1 - 1/log10(n), K_HAT_MAX).
38
+ K_HAT_MAX = 0.7
39
+ #: Pareto smoothing needs at least this many importance weights.
40
+ PSIS_MIN_WEIGHTS = 5
41
+ #: The generalised Pareto is fitted to the ceil(min(a n, b sqrt(n))) largest of n
42
+ #: weights, for (a, b) these.
43
+ PSIS_TAIL = (0.2, 3.0)
44
+
45
+
46
+ # ----------------------------------------------------------------------------
47
+ # autocorrelation
48
+ # ----------------------------------------------------------------------------
49
+ def _next_pow_two(n: int) -> int:
50
+ i = 1
51
+ while i < n:
52
+ i <<= 1
53
+ return i
54
+
55
+
56
+ def _autocov(x: np.ndarray) -> np.ndarray:
57
+ """Biased autocovariance along the last axis (FFT)."""
58
+ x = np.asarray(x, dtype=float)
59
+ n = x.shape[-1]
60
+ m = _next_pow_two(2 * n)
61
+ xc = x - x.mean(axis=-1, keepdims=True)
62
+ f = np.fft.rfft(xc, n=m, axis=-1)
63
+ acov = np.fft.irfft(f * np.conjugate(f), n=m, axis=-1)[..., :n]
64
+ return acov / n
65
+
66
+
67
+ def autocorr_function(x: Any) -> np.ndarray:
68
+ """Normalised autocorrelation function of a 1-D chain (or average over chains).
69
+
70
+ Parameters
71
+ ----------
72
+ x : array_like, shape (n_draws,) or (n_chains, n_draws)
73
+ Draws of a scalar quantity.
74
+ """
75
+ x = np.atleast_2d(np.asarray(x, dtype=float))
76
+ acov = _autocov(x)
77
+ with np.errstate(invalid="ignore", divide="ignore"):
78
+ acf = acov / acov[..., :1]
79
+ acf = np.where(np.isfinite(acf), acf, 0.0)
80
+ return acf.mean(axis=0)
81
+
82
+
83
+ def autocorr_time(x: Any, c: float = 5.0) -> float:
84
+ """Integrated autocorrelation time with Sokal's adaptive window.
85
+
86
+ Parameters
87
+ ----------
88
+ x : array_like (n_draws,) or (n_chains, n_draws)
89
+ Draws; autocorrelation functions are averaged over chains, as in emcee.
90
+ c : float, optional
91
+ Window factor: the window is the smallest lag at least ``c`` times the
92
+ running estimate of the time.
93
+ """
94
+ f = autocorr_function(x)
95
+ taus = 2.0 * np.cumsum(f) - 1.0
96
+ window = np.arange(len(taus)) < c * taus
97
+ m = int(np.argmin(window)) if np.any(~window) else len(taus) - 1
98
+ return float(max(taus[m], 1.0))
99
+
100
+
101
+ # ----------------------------------------------------------------------------
102
+ # ESS and R-hat (Vehtari et al. 2021)
103
+ # ----------------------------------------------------------------------------
104
+ def _as_chains(x: Any) -> np.ndarray:
105
+ a = np.asarray(x, dtype=float)
106
+ if a.ndim == 1:
107
+ a = a[None, :]
108
+ if a.ndim != 2:
109
+ raise ValueError("Expected an array of shape (n_draws,) or (n_chains, n_draws).")
110
+ return a
111
+
112
+
113
+ def _split(a: np.ndarray) -> np.ndarray:
114
+ n = a.shape[1]
115
+ if n < 4:
116
+ return a
117
+ half = n // 2
118
+ return np.concatenate([a[:, :half], a[:, n - half :]], axis=0)
119
+
120
+
121
+ def _z_scale(a: np.ndarray) -> np.ndarray:
122
+ flat = a.ravel()
123
+ ranks = stats.rankdata(flat, method="average")
124
+ z = special.ndtri((ranks - 3.0 / 8.0) / (flat.size - 2.0 * 3.0 / 8.0 + 1.0))
125
+ return z.reshape(a.shape)
126
+
127
+
128
+ def _ess_raw(a: np.ndarray) -> float:
129
+ n_chain, n_draw = a.shape
130
+ if n_draw < 4:
131
+ return float("nan")
132
+ if np.all(a == a.flat[0]):
133
+ return float("nan")
134
+ acov = _autocov(a)
135
+ chain_mean = a.mean(axis=1)
136
+ mean_var = np.mean(acov[:, 0]) * n_draw / (n_draw - 1.0)
137
+ var_plus = mean_var * (n_draw - 1.0) / n_draw
138
+ if n_chain > 1:
139
+ var_plus += np.var(chain_mean, ddof=1)
140
+ if var_plus <= 0:
141
+ return float("nan")
142
+ rho = np.zeros(n_draw)
143
+ rho_even = 1.0
144
+ rho[0] = rho_even
145
+ rho_odd = 1.0 - (mean_var - np.mean(acov[:, 1])) / var_plus
146
+ rho[1] = rho_odd
147
+ t = 1
148
+ while t < n_draw - 3 and (rho_even + rho_odd) > 0.0:
149
+ rho_even = 1.0 - (mean_var - np.mean(acov[:, t + 1])) / var_plus
150
+ rho_odd = 1.0 - (mean_var - np.mean(acov[:, t + 2])) / var_plus
151
+ if (rho_even + rho_odd) >= 0:
152
+ rho[t + 1] = rho_even
153
+ rho[t + 2] = rho_odd
154
+ t += 2
155
+ max_t = t - 2
156
+ if rho_even > 0:
157
+ rho[max_t + 1] = rho_even
158
+ t = 1
159
+ while t <= max_t - 2:
160
+ if (rho[t + 1] + rho[t + 2]) > (rho[t - 1] + rho[t]):
161
+ rho[t + 1] = (rho[t - 1] + rho[t]) / 2.0
162
+ rho[t + 2] = rho[t + 1]
163
+ t += 2
164
+ total = n_chain * n_draw
165
+ tau = -1.0 + 2.0 * np.sum(rho[: max_t + 1]) + np.sum(rho[max_t + 1 : max_t + 2])
166
+ tau = max(tau, 1.0 / np.log10(total))
167
+ return float(total / tau)
168
+
169
+
170
+ def ess(x: Any, kind: str = "bulk") -> float:
171
+ """Effective sample size of a scalar quantity.
172
+
173
+ Parameters
174
+ ----------
175
+ x : array_like, shape (n_draws,) or (n_chains, n_draws)
176
+ kind : {"bulk", "tail", "mean"}
177
+ Bulk, tail or mean ESS. ``"bulk"``: rank-normalised split-chain ESS;
178
+ ``"tail"``: minimum of the ESS of the 5% and 95% quantile indicators;
179
+ ``"mean"``: ESS of the raw (split) draws, appropriate for the Monte Carlo
180
+ error of the mean.
181
+ """
182
+ a = _split(_as_chains(x))
183
+ if kind == "bulk":
184
+ return _ess_raw(_z_scale(a))
185
+ if kind == "mean":
186
+ return _ess_raw(a)
187
+ if kind == "tail":
188
+ q05, q95 = np.quantile(a, TAIL_QUANTILES)
189
+ e1 = _ess_raw((a <= q05).astype(float))
190
+ e2 = _ess_raw((a <= q95).astype(float))
191
+ return float(np.nanmin([e1, e2])) if np.isfinite([e1, e2]).any() else float("nan")
192
+ raise ValueError("kind must be 'bulk', 'tail' or 'mean'.")
193
+
194
+
195
+ def _rhat_raw(a: np.ndarray) -> float:
196
+ n = a.shape[1]
197
+ if a.shape[0] < 2 or n < 2:
198
+ return float("nan")
199
+ chain_mean = a.mean(axis=1)
200
+ between = n * np.var(chain_mean, ddof=1)
201
+ within = np.mean(np.var(a, axis=1, ddof=1))
202
+ if within <= 0:
203
+ return float("nan")
204
+ return float(np.sqrt((between / within + n - 1.0) / n))
205
+
206
+
207
+ def rhat(x: Any) -> float:
208
+ """Rank-normalised split R-hat (maximum of bulk and folded/tail versions).
209
+
210
+ Needs ``x`` of shape ``(n_chains, n_draws)``; a single chain is split in
211
+ two. Values above about 1.01 indicate non-convergence.
212
+
213
+ Parameters
214
+ ----------
215
+ x : array_like, shape (n_draws,) or (n_chains, n_draws)
216
+ Draws of a scalar quantity.
217
+ """
218
+ a = _split(_as_chains(x))
219
+ if a.shape[0] < 2:
220
+ return float("nan")
221
+ bulk = _rhat_raw(_z_scale(a))
222
+ folded = _rhat_raw(_z_scale(np.abs(a - np.median(a))))
223
+ return float(np.nanmax([bulk, folded]))
224
+
225
+
226
+ def mcse(x: Any) -> float:
227
+ """Monte Carlo standard error of the mean of a scalar quantity.
228
+
229
+ Parameters
230
+ ----------
231
+ x : array_like, shape (n_draws,) or (n_chains, n_draws)
232
+ Draws of a scalar quantity.
233
+ """
234
+ a = _as_chains(x)
235
+ n_eff = ess(a, kind="mean")
236
+ if not np.isfinite(n_eff) or n_eff <= 0:
237
+ return float("nan")
238
+ return float(np.std(a, ddof=1) / np.sqrt(n_eff))
239
+
240
+
241
+ # ----------------------------------------------------------------------------
242
+ # Pareto-smoothed importance sampling
243
+ # ----------------------------------------------------------------------------
244
+ def _gpdfit(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
245
+ """Zhang & Stephens (2009) generalised-Pareto fit with the PSIS prior on k, of each
246
+ row of the sorted exceedances ``x`` ``(..., n)``."""
247
+ prior_bs, prior_k = 3.0, 10.0
248
+ n = x.shape[-1]
249
+ m_est = 30 + int(n**0.5)
250
+ b = 1.0 - np.sqrt(m_est / (np.arange(1, m_est + 1, dtype=float) - 0.5))
251
+ b = b / (prior_bs * x[..., int(n / 4 + 0.5) - 1, None]) + 1.0 / x[..., -1, None]
252
+ k = np.log1p(-b[..., None] * x[..., None, :]).mean(axis=-1)
253
+ with np.errstate(divide="ignore", invalid="ignore"):
254
+ len_scale = n * (np.log(-(b / k)) - k - 1.0)
255
+ weights = 1.0 / np.exp(len_scale[..., None, :] - len_scale[..., None]).sum(axis=-1)
256
+ weights = np.where(weights >= 10 * np.finfo(float).eps, weights, 0.0) # NaN dropped too
257
+ b_post = np.sum(b * weights, axis=-1) / weights.sum(axis=-1)
258
+ k_post = np.log1p(-b_post[..., None] * x).mean(axis=-1)
259
+ sigma = -k_post / b_post
260
+ k_post = (n * k_post + prior_k * 0.5) / (n + prior_k)
261
+ return k_post, sigma
262
+
263
+
264
+ def _gpinv(p: np.ndarray, k: np.ndarray, sigma: np.ndarray) -> np.ndarray:
265
+ # quantiles p of generalised Paretos (k, sigma), broadcast; NaN where sigma <= 0
266
+ with np.errstate(divide="ignore", invalid="ignore"):
267
+ q = np.where(
268
+ np.abs(k) < np.finfo(float).eps,
269
+ -sigma * np.log1p(-p),
270
+ sigma * np.expm1(-k * np.log1p(-p)) / k,
271
+ )
272
+ return np.where(sigma > 0, q, np.nan)
273
+
274
+
275
+ def psis_threshold(n: int) -> float:
276
+ """The Pareto ``k_hat`` below which PSIS with ``n`` weights is reliable.
277
+
278
+ ``min(1 - 1 / log10(n), 0.7)`` (Vehtari et al. 2024, §3), for ``n >= 5``.
279
+
280
+ Parameters
281
+ ----------
282
+ n : int
283
+ Number of importance weights.
284
+ """
285
+ if n < PSIS_MIN_WEIGHTS:
286
+ raise ValueError(f"n must be at least {PSIS_MIN_WEIGHTS} importance weights; got {n}.")
287
+ return min(1.0 - 1.0 / float(np.log10(n)), K_HAT_MAX)
288
+
289
+
290
+ def psis(log_weights: Any) -> tuple[np.ndarray, float]:
291
+ """Pareto-smoothed importance sampling.
292
+
293
+ Parameters
294
+ ----------
295
+ log_weights : array_like (n,)
296
+ Unnormalised log importance ratios.
297
+
298
+ Returns
299
+ -------
300
+ log_weights_smoothed : (n,) array
301
+ Normalised (``logsumexp == 0``) smoothed log-weights.
302
+ k_hat : float
303
+ Estimated Pareto shape: the estimate is reliable below
304
+ :func:`psis_threshold` of the number of weights (0.7 from about 2,000);
305
+ ``-inf`` for equal weights, which have no tail.
306
+ """
307
+ lw = np.array(log_weights, dtype=float).ravel()
308
+ if lw.size < PSIS_MIN_WEIGHTS:
309
+ raise ValueError(f"Need at least {PSIS_MIN_WEIGHTS} importance weights.")
310
+ lw, k = _psis_rows(lw[None]) # lw is a copy
311
+ return lw[0], float(k[0])
312
+
313
+
314
+ def _psis_rows(log_weights: np.ndarray, block: int = 2**22) -> tuple[np.ndarray, np.ndarray]:
315
+ """:func:`psis` of each row of the float array ``log_weights`` ``(r, n)``, ``n >= 5``,
316
+ in place: the smoothed log-weights (that array) and ``k_hat`` ``(r,)``. Rows whose
317
+ tails are equally long are fitted together, about ``block`` values at a time."""
318
+ lw = log_weights
319
+ r, n = lw.shape
320
+ lw[~np.isfinite(lw)] = -np.inf
321
+ lw -= lw.max(axis=1, keepdims=True)
322
+ k = np.full(r, np.inf) # no tail of more than 4 weights to fit
323
+ equal = np.all(lw > -1e-10, axis=1) # equal weights (to round-off): exact, no tail
324
+ k[equal] = -np.inf
325
+ share, factor = PSIS_TAIL
326
+ tail_len = int(np.ceil(min(share * n, factor * np.sqrt(n)))) # rounded up, as ArviZ, loo
327
+ # the tail_len + 1 largest weights of each row, sorted: the cut and the tail above it
328
+ top = np.argpartition(lw, n - tail_len - 1, axis=1)[:, n - tail_len - 1 :]
329
+ srt = np.take_along_axis(lw, top, axis=1)
330
+ order = np.argsort(srt, axis=1, kind="stable")
331
+ top, srt = np.take_along_axis(top, order, axis=1), np.take_along_axis(srt, order, axis=1)
332
+ cut = np.maximum(srt[:, 0], np.log(np.finfo(float).tiny)) # in logs, as ArviZ
333
+ exp_cut = np.exp(cut)
334
+ size = np.sum(srt > cut[:, None], axis=1) # weights tied with the cut stay below it
335
+ fit = ~equal & (size > 4)
336
+ for m in np.unique(size[fit]):
337
+ same = np.flatnonzero(fit & (size == m))
338
+ probs = np.arange(0.5, m) / m
339
+ for sl in chunk_slices(same.size, max(1, block // (m * (30 + int(m**0.5))))):
340
+ rows = same[sl]
341
+ x = np.exp(srt[rows, -m:]) - exp_cut[rows, None]
342
+ k[rows], sigma = _gpdfit(x)
343
+ k[rows] = np.where(np.isnan(k[rows]), np.inf, k[rows]) # a degenerate fit: no tail
344
+ smoothed = np.log(_gpinv(probs, k[rows, None], sigma[:, None]) + exp_cut[rows, None])
345
+ ok = np.isfinite(k[rows])
346
+ lw[rows[ok, None], top[rows[ok], -m:]] = np.minimum(smoothed[ok], 0.0)
347
+ mx = lw.max(axis=1, keepdims=True) # normalised: logsumexp of each row zero
348
+ lse = mx[:, 0] + np.log(np.exp(lw - mx).sum(axis=1))
349
+ lw -= np.where(equal, np.log(n), lse)[:, None]
350
+ return lw, k
351
+
352
+
353
+ # ----------------------------------------------------------------------------
354
+ # model-specific diagnostics
355
+ # ----------------------------------------------------------------------------
356
+ def kkt_gap(model: Any, value: Any, include_prior: bool = True) -> float:
357
+ """First-order optimality violation of a point estimate.
358
+
359
+ See :meth:`binference.PopulationModel.kkt_gap`.
360
+
361
+ Parameters
362
+ ----------
363
+ model : PopulationModel
364
+ The model.
365
+ value : array_like (K,)
366
+ Fractions or rates.
367
+ include_prior : bool, optional
368
+ Include the prior's pseudo-counts in the optimality conditions.
369
+ """
370
+ return model.kkt_gap(np.asarray(value, dtype=float), include_prior=include_prior)
371
+
372
+
373
+ def missing_information(model: Any, value: Any, **kwargs: Any) -> float:
374
+ """Largest fraction of missing information.
375
+
376
+ See :meth:`binference.PopulationModel.missing_information`.
377
+
378
+ Parameters
379
+ ----------
380
+ model : PopulationModel
381
+ The model.
382
+ value : array_like (K,)
383
+ Fractions or rates.
384
+ **kwargs
385
+ Passed to :meth:`binference.PopulationModel.missing_information`.
386
+ """
387
+ return model.missing_information(np.asarray(value, dtype=float), **kwargs)
388
+
389
+
390
+ def importance_ess(weights: Any) -> float:
391
+ r"""Kish's effective sample size of importance weights,
392
+ :math:`(\sum_s w_s)^2 / \sum_s w_s^2` (normalised or not).
393
+
394
+ Parameters
395
+ ----------
396
+ weights : array_like (n,)
397
+ Importance weights.
398
+ """
399
+ w = np.asarray(weights, dtype=float)
400
+ return float(np.sum(w) ** 2 / np.sum(w**2))
@@ -0,0 +1,197 @@
1
+ """A scikit-learn style estimator wrapper around :func:`binference.infer`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ._utils import RNGLike, as_generator
11
+ from .bins import Bins
12
+ from .inference import infer
13
+ from .likelihoods import Likelihoods
14
+ from .priors import PopulationPrior
15
+ from .results import PointEstimate, PopulationResult
16
+
17
+ __all__ = ["PopulationEstimator"]
18
+
19
+
20
+ class PopulationEstimator:
21
+ """Population inference with the scikit-learn estimator conventions.
22
+
23
+ ``fit(X)`` infers the population from per-object likelihoods ``X`` (a
24
+ :class:`~binference.Likelihoods` or an ``(N, K)`` array) and stores the result
25
+ in ``result_``. ``predict_proba(X)`` gives per-object posteriors over bins
26
+ for (new) objects under the inferred population, and ``score(X)`` the mean
27
+ log posterior-predictive density per object, which can be used to compare
28
+ priors or binnings by cross-validation. The estimator follows the
29
+ scikit-learn ``get_params``/``set_params`` contract without depending on
30
+ scikit-learn.
31
+
32
+ Parameters
33
+ ----------
34
+ prior : PopulationPrior, optional
35
+ Population prior (default ``Dirichlet(1.0)``).
36
+ method : str
37
+ Inference engine (see :func:`binference.infer`).
38
+ selection : array_like (K,), callable or Selection, optional
39
+ Selection function.
40
+ selection_mode : {"on_measurement", "on_truth"}, optional
41
+ Required with an array or callable ``selection``.
42
+ bins : Bins, optional
43
+ Bins for raw array inputs.
44
+ n_samples : int, optional
45
+ Draws per chain / in total.
46
+ rng : int, Generator, or None
47
+ Random number generator.
48
+ engine_kwargs : dict, optional
49
+ Extra keyword arguments for the engine.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ prior: PopulationPrior | None = None,
55
+ *,
56
+ method: str = "auto",
57
+ selection: Any = None,
58
+ selection_mode: str | None = None,
59
+ bins: Bins | None = None,
60
+ n_samples: int | None = None,
61
+ rng: RNGLike = None,
62
+ engine_kwargs: dict[str, Any] | None = None,
63
+ ) -> None:
64
+ self.prior = prior
65
+ self.method = method
66
+ self.selection = selection
67
+ self.selection_mode = selection_mode
68
+ self.bins = bins
69
+ self.n_samples = n_samples
70
+ self.rng = rng
71
+ self.engine_kwargs = engine_kwargs
72
+
73
+ # -------------------------------------------------- sklearn contract
74
+ @classmethod
75
+ def _get_param_names(cls) -> list[str]:
76
+ sig = inspect.signature(cls.__init__)
77
+ return [p for p in sig.parameters if p != "self"]
78
+
79
+ def __sklearn_tags__(self):
80
+ # scikit-learn >= 1.6 asks every estimator for its tags; X is all it needs
81
+ from sklearn.utils import Tags, TargetTags
82
+
83
+ return Tags(estimator_type=None, target_tags=TargetTags(required=False))
84
+
85
+ def get_params(self, deep: bool = True) -> dict[str, Any]:
86
+ """Constructor parameters (scikit-learn convention).
87
+
88
+ Parameters
89
+ ----------
90
+ deep : bool, optional
91
+ Accepted for scikit-learn compatibility; has no effect.
92
+ """
93
+ return {name: getattr(self, name) for name in self._get_param_names()}
94
+
95
+ def set_params(self, **params: Any) -> PopulationEstimator:
96
+ """Set constructor parameters (scikit-learn convention).
97
+
98
+ Parameters
99
+ ----------
100
+ **params
101
+ Constructor parameters and their new values.
102
+ """
103
+ valid = set(self._get_param_names())
104
+ for k, v in params.items():
105
+ if k not in valid:
106
+ raise ValueError(f"Invalid parameter {k!r} for {type(self).__name__}.")
107
+ setattr(self, k, v)
108
+ return self
109
+
110
+ def __repr__(self) -> str:
111
+ args = ", ".join(f"{k}={v!r}" for k, v in self.get_params().items() if v is not None)
112
+ return f"{type(self).__name__}({args})"
113
+
114
+ # ------------------------------------------------------------- API
115
+ def fit(self, X: Any, y: Any = None) -> PopulationEstimator:
116
+ """Infer the population from likelihoods ``X``.
117
+
118
+ Parameters
119
+ ----------
120
+ X : Likelihoods or array_like (N, K)
121
+ Per-object likelihoods.
122
+ y : None, optional
123
+ Ignored; accepted for scikit-learn compatibility.
124
+ """
125
+ kwargs = dict(self.engine_kwargs or {})
126
+ self.result_: PopulationResult | PointEstimate = infer(
127
+ X,
128
+ self.prior,
129
+ bins=self.bins if not isinstance(X, Likelihoods) else None,
130
+ selection=self.selection,
131
+ selection_mode=self.selection_mode,
132
+ method=self.method,
133
+ n_samples=self.n_samples,
134
+ rng=self.rng,
135
+ **kwargs,
136
+ )
137
+ res = self.result_
138
+ self.population_ = res.rho if isinstance(res, PointEstimate) else res.mean
139
+ self.n_features_in_ = int(self.population_.size)
140
+ return self
141
+
142
+ def _check_fitted(self) -> None:
143
+ if not hasattr(self, "result_"):
144
+ raise RuntimeError(f"This {type(self).__name__} is not fitted yet; call fit().")
145
+
146
+ def predict_proba(self, X: Any) -> np.ndarray:
147
+ """Per-object posterior over bins under the inferred population.
148
+
149
+ Parameters
150
+ ----------
151
+ X : Likelihoods or array_like (N, K)
152
+ Likelihoods of the objects.
153
+ """
154
+ self._check_fitted()
155
+ return self.result_.predict_proba(X)
156
+
157
+ def predict(self, X: Any) -> np.ndarray:
158
+ """Most probable bin of each object under the inferred population.
159
+
160
+ Parameters
161
+ ----------
162
+ X : Likelihoods or array_like (N, K)
163
+ Likelihoods of the objects.
164
+ """
165
+ return np.argmax(self.predict_proba(X), axis=1)
166
+
167
+ def score(self, X: Any, y: Any = None) -> float:
168
+ """Mean log posterior-predictive density per object (higher is better).
169
+
170
+ Parameters
171
+ ----------
172
+ X : Likelihoods or array_like (N, K)
173
+ Per-object likelihoods.
174
+ y : None, optional
175
+ Ignored; accepted for scikit-learn compatibility.
176
+ """
177
+ self._check_fitted()
178
+ lp = self.result_.log_predictive(X)
179
+ w = X.weights if isinstance(X, Likelihoods) else np.ones(lp.size)
180
+ return float(np.dot(w, lp) / w.sum())
181
+
182
+ def sample(self, n: int = 1, rng: RNGLike = None) -> np.ndarray:
183
+ """Draws of the population fractions from the fitted posterior.
184
+
185
+ Parameters
186
+ ----------
187
+ n : int, optional
188
+ Number of population draws returned.
189
+ rng : int, Generator or None
190
+ Random number generator.
191
+ """
192
+ self._check_fitted()
193
+ res = self.result_
194
+ if isinstance(res, PointEstimate):
195
+ return np.tile(res.rho, (n, 1))
196
+ idx = as_generator(rng).integers(0, res.n_draws, size=n)
197
+ return res.samples[idx]