Source code for binference.simulate

"""Mock catalogues for tests, tutorials and simulation-based calibration.

Each ``mock_*`` function returns a :class:`Mock` bundling the bins, the
correct per-object :class:`~binference.Likelihoods`, the :class:`Truth`, and
(optionally) posteriors under an interim prior and posterior samples, so that
every input convention can be exercised. ``Mock`` unpacks as
``likelihoods, truth = mock``.
"""

from __future__ import annotations

from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from typing import Any

import numpy as np
from scipy import special, stats

from ._utils import RNGLike, as_generator
from .bins import Bins
from .density import Density
from .likelihoods import Likelihoods
from .priors import PopulationPrior
from .response import Response
from .selection import Selection, _bin_average

__all__ = [
    "Mock",
    "Truth",
    "mock_1d",
    "mock_2d",
    "mock_categorical",
    "mock_unfolding",
    "population",
    "prior_predictive",
]


[docs] @dataclass class Truth: """The true population and objects behind a mock catalogue. Attributes ---------- rho : (K,) array Population fractions the objects were drawn from. labels : (N,) int array True bin of every observed object. values : array or None True continuous values of the observed objects (``None`` for categories). counts : (K,) array Realised histogram of the observed objects' true bins. n_drawn : int Objects drawn before selection. """ rho: np.ndarray labels: np.ndarray values: np.ndarray | None counts: np.ndarray n_drawn: int extra: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass class Mock: """A mock catalogue: bins, likelihoods, truth and alternative input forms.""" bins: Bins likelihoods: Likelihoods truth: Truth measurements: dict[str, np.ndarray] = field(default_factory=dict) posteriors: np.ndarray | None = None interim: np.ndarray | None = None samples: np.ndarray | None = None response: Response | None = None def __iter__(self) -> Iterator[Any]: yield self.likelihoods yield self.truth
# ---------------------------------------------------------------------------- # densities and populations # ---------------------------------------------------------------------------- def _density_1d(name: str, lo: float, hi: float) -> Callable[[np.ndarray], np.ndarray]: span = hi - lo def unit(z): return (np.asarray(z, dtype=float) - lo) / span if name == "bimodal": return lambda z: ( 0.6 * stats.beta.pdf(unit(z), 3.0, 9.0) + 0.4 * stats.norm.pdf(unit(z), 0.55, 0.1) ) if name == "gaussian": return lambda z: stats.norm.pdf(unit(z), 0.5, 0.15) if name == "uniform": return lambda z: np.ones_like(np.asarray(z, dtype=float)) if name == "steep": return lambda z: np.exp(-4.0 * unit(z)) raise ValueError(f"Unknown density {name!r}; use 'bimodal', 'gaussian', 'uniform' or 'steep'.") def _density_2d(name: str, bins: Bins) -> Callable[[np.ndarray], np.ndarray]: (lx, hx), (ly, hy) = bins.range def f(p): p = np.asarray(p, dtype=float) u = (p[..., 0] - lx) / (hx - lx) v = (p[..., 1] - ly) / (hy - ly) if name == "banana": ridge = 0.2 + 0.55 * u + 0.12 * np.sin(3.0 * u) return stats.beta.pdf(u, 2.0, 2.5) * np.exp(-0.5 * ((v - ridge) / 0.06) ** 2) if name == "gaussian": return np.exp(-0.5 * (((u - 0.5) / 0.15) ** 2 + ((v - 0.5) / 0.2) ** 2)) raise ValueError(f"Unknown 2-D density {name!r}; use 'banana' or 'gaussian'.") return f def _as_density(density: Any, bins: Bins) -> Density: # a Density from a mock density name, a Density, a callable, a distribution or masses if isinstance(density, str) and density != "flat": if bins.ndim == 1: lo, hi = bins.range[0] return Density.from_function(_density_1d(density, lo, hi)) return Density.from_function(_density_2d(density, bins)) return Density.coerce(density, "mass", name="density")
[docs] def population(bins: Bins, density: Any = "bimodal", tol: float = 1e-6) -> np.ndarray: """Population fractions per bin from a density name, :class:`Density`, callable or masses. Parameters ---------- bins : Bins The bins. density : str, Density, callable or array_like (K,), optional A named density, a :class:`~binference.Density`, a callable or masses. The names are ``"bimodal"``, ``"gaussian"``, ``"uniform"`` and ``"steep"`` on 1-D bins, ``"banana"`` and ``"gaussian"`` on 2-D grids. tol : float, optional Tolerance of the adaptive quadrature over each bin. """ return _as_density(density, bins).masses(bins, tol=tol)
def _draw_values(bins: Bins, density: Any, n: int, rng: np.random.Generator): """True values (continuous) and labels for n objects.""" f = _as_density(density, bins)._log_pointwise() if f is None: rho = population(bins, density) labels = rng.choice(bins.K, size=n, p=rho) return bins.sample_within(labels, rng), labels # inverse-CDF sampling on every bin split into 20 equal steps in u (the mask repeated) fb, coarse = bins.refine(20) dens = np.exp(np.asarray(f(fb.centers), dtype=float).ravel()) * fb.volumes if not dens.sum() > 0: raise ValueError("density has no mass on the active bins.") cell = rng.choice(fb.K, size=n, p=dens / dens.sum()) return fb.sample_within(cell, rng), coarse[cell] def _noise(noise: Any, n: int, rng: np.random.Generator, shape=()) -> np.ndarray: if np.ndim(noise) == 0: return np.full((n, *shape), float(noise)) lo, hi = noise return rng.uniform(lo, hi, size=(n, *shape)) def _measured_pass(z, noise, lo, hi): # P(lo <= z + sigma * e <= hi), averaged over the noise distribution if np.ndim(noise) == 0: sig, wts = np.array([float(noise)]), np.array([1.0]) else: x, w = np.polynomial.legendre.leggauss(16) sig = noise[0] + (noise[1] - noise[0]) * 0.5 * (x + 1.0) wts = 0.5 * w zz = np.asarray(z, dtype=float)[..., None] a, b = (lo - zz) / sig, (hi - zz) / sig # take the difference in the tail where it keeps its precision p = np.where(a > 0, special.ndtr(-a) - special.ndtr(-b), special.ndtr(b) - special.ndtr(a)) return p @ wts # ---------------------------------------------------------------------------- # mock catalogues # ----------------------------------------------------------------------------
[docs] def mock_1d( n_objects: int = 2000, bins: Bins | None = None, *, density: Any = "bimodal", noise: Any = (0.05, 0.2), interim: Any = None, n_samples: int = 0, selection: Any = None, measured_range: tuple[float | None, float | None] | None = None, outlier_fraction: float = 0.0, rng: RNGLike = None, ) -> Mock: """A 1-D mock catalogue with heteroscedastic Gaussian measurement errors. Parameters ---------- n_objects : int Number of *observed* objects (after selection). bins : Bins, optional Bins of the mock; ``None`` gives 30 equal bins on [0, 3]. density : str, callable, or array_like (K,) True population: a named density ("bimodal", "gaussian", "uniform", "steep"), a callable density, or bin fractions (values then uniform within bins). noise : float or (lo, hi) Measurement standard deviation, fixed or drawn uniformly per object. interim : str, callable or array_like, optional If given, also return per-object posteriors on the bins computed under this interim prior (``"gaussian"`` gives a broad Gaussian), as ``mock.posteriors`` with masses ``mock.interim``. n_samples : int If positive, also return ``n_samples`` posterior samples per object (under the interim prior, flat if not given) as ``mock.samples``. selection : array_like (K,), optional Per-bin detection probability depending on the *true* bin (selection on the truth): objects are thinned accordingly. measured_range : (lo, hi), optional Selection on the measurement: only objects whose measured value lies in ``[lo, hi]`` are observed (either end may be ``None``). outlier_fraction : float Fraction of measurements replaced by uniform draws over the range (the likelihoods ignore this: a misspecification test). rng : int, Generator or None Notes ----- The matching :class:`Selection` is attached to ``mock.likelihoods``: ``"on_truth"`` with ``selection``, ``"on_measurement"`` with ``measured_range`` (the population-weighted bin average of the probability that the measured value passes, over the noise distribution), and :meth:`Selection.combined` with both (DECISIONS #48). With a named or callable density the true values follow that continuous density, whereas the histogram model assumes the population is flat within each bin. For coarse bins, measurement errors comparable to the bin width and many objects, this *binning bias* can exceed the statistical errors. Pass bin fractions (e.g. ``density=population(bins, "bimodal")``) to draw values uniformly within bins, for which the model is exactly right. """ gen = as_generator(rng) bins = Bins.linear(0.0, 3.0, 30) if bins is None else bins if bins.ndim != 1: raise ValueError("mock_1d needs one-dimensional bins.") mock = _gaussian_mock( bins, density, n_objects, noise, selection, measured_range, outlier_fraction, gen ) lk, obs, sigma = mock.likelihoods, mock.measurements["mean"], mock.measurements["sigma"] log_pi = None if interim is not None: if isinstance(interim, str) and interim == "gaussian": lo, hi = bins.range[0] interim = stats.norm(0.4 * lo + 0.6 * hi, 0.3 * (hi - lo)) interim = _as_density(interim, bins) log_pi = interim._log_pointwise() Pi = interim.masses(bins, tol=1e-6) P = lk.toarray() * Pi[None, :] # row scales are irrelevant after normalising mock.posteriors = P / P.sum(axis=1, keepdims=True) mock.interim = Pi if n_samples > 0: mock.samples = _posterior_samples_1d( obs, sigma, bins, log_pi, mock.interim, n_samples, gen ) if mock.interim is None: mock.interim = population(bins, "uniform") return mock
def _gaussian_mock( bins, density, n_objects, noise, on_truth, measured_range, outliers, gen, *, measured_keep=None, selection=None, ): # draw objects until n_objects pass the selection, measure them, and build the mock # with the Selection that describes how they were selected (DECISIONS #48); # measured_keep thins on the bin of the measured value (the end bins extended # outward) and `selection` replaces the Selection attached to the likelihoods sel = None if on_truth is None else Selection(on_truth, "on_truth") if measured_range is not None: if len(measured_range) != 2: raise ValueError("measured_range must be (lo, hi); either may be None.") lo = -np.inf if measured_range[0] is None else float(measured_range[0]) hi = np.inf if measured_range[1] is None else float(measured_range[1]) if not lo < hi: raise ValueError(f"measured_range must have lo < hi; got {measured_range}.") # S_k: the population-weighted bin average of the probability of passing the cut log_pop = _as_density(density, bins)._log_pointwise() if log_pop is None: # flat in the measure coordinate within bins log_pop = lambda z: bins.measures[0].log_jacobian(z) # noqa: E731 passes = _bin_average(lambda z: _measured_pass(z, noise, lo, hi), bins, log_weight=log_pop) sel = ( Selection(passes[0], "on_measurement") if on_truth is None else Selection.combined(on_truth, passes[0]) ) S = None if sel is None else sel.row # thinning on the true bin shape = () if bins.ndim == 1 else (bins.ndim,) parts: list[tuple[np.ndarray, ...]] = [] n_drawn, need = 0, n_objects while need > 0: batch = max(2 * need, 1000) v, lab = _draw_values(bins, density, batch, gen) sigma = _noise(noise, batch, gen, shape) obs = v + sigma * gen.standard_normal(v.shape) if outliers > 0: # misspecification the likelihoods and the selection ignore bad = gen.random(batch) < outliers obs[bad] = gen.uniform(*bins.range[0], size=int(bad.sum())) keep = lab >= 0 if S is not None: keep &= gen.random(batch) < S[np.maximum(lab, 0)] if measured_range is not None: keep &= (obs >= lo) & (obs <= hi) if measured_keep is not None: j = np.clip(np.searchsorted(bins.edges[0], obs, side="right") - 1, 0, bins.K - 1) keep &= gen.random(batch) < measured_keep[j] idx = np.flatnonzero(keep)[:need] # count only the draws needed to reach n_objects observed objects n_drawn += batch if idx.size < need else int(idx[-1]) + 1 parts.append((v[idx], lab[idx], sigma[idx], obs[idx])) need -= idx.size values, labels, sigma, obs = (np.concatenate(a) for a in zip(*parts, strict=True)) lk = Likelihoods.from_gaussian( obs, sigma, bins, selection=sel if selection is None else selection, warn_outside=False, # observations near the edges spill past them by construction ) counts = np.bincount(labels, minlength=bins.K).astype(float) rho = population(bins, density) truth = Truth(rho=rho, labels=labels, values=values, counts=counts, n_drawn=n_drawn) return Mock(bins, lk, truth, {"mean": obs, "sigma": sigma}) def _posterior_samples_1d(obs, sigma, bins, log_pi, pi_masses, n_samples, gen): grid = bins.refine(50)[0].edges[0] # 50 steps per bin, so each step lies inside one bin mid = 0.5 * (grid[1:] + grid[:-1]) k = bins.index(mid) with np.errstate(divide="ignore"): if log_pi is not None: log_prior = np.asarray(log_pi(mid), dtype=float) elif pi_masses is not None: log_prior = np.log(pi_masses[k] / bins.volumes[k]) else: log_prior = np.zeros_like(mid) # the prior mass of each step (steps differ in width between bins); none in masked bins log_prior = np.where(k >= 0, log_prior + np.log(np.diff(grid)), -np.inf) out = np.empty((obs.size, n_samples)) for i in range(obs.size): logp = -0.5 * ((mid - obs[i]) / sigma[i]) ** 2 + log_prior p = np.exp(logp - logp.max()) cdf = np.concatenate(([0.0], np.cumsum(p))) cdf /= cdf[-1] out[i] = np.interp(gen.random(n_samples), cdf, grid) return out
[docs] def mock_2d( n_objects: int = 5000, bins: Bins | None = None, *, density: Any = "banana", noise: Any = (0.02, 0.06), selection: Any = None, rng: RNGLike = None, ) -> Mock: """A 2-D mock catalogue (e.g. a colour-magnitude-like ridge) with Gaussian errors. Parameters ---------- n_objects : int, optional Number of *observed* objects (after selection). bins : Bins, optional Grid of the mock; ``None`` gives a 20 x 20 grid on the unit square. density : str, callable or array_like (K,), optional True population: a named density, a callable density or bin fractions, as for :func:`mock_1d`. noise : float or (lo, hi), optional Measurement standard deviation per axis, fixed or drawn uniformly per object and axis. selection : array_like (K,), optional Per-bin detection probability depending on the *true* bin. rng : int, Generator or None Random number generator. """ gen = as_generator(rng) if bins is None: bins = Bins.grid(np.linspace(0, 1, 21), np.linspace(0, 1, 21)) if bins.ndim != 2: raise ValueError("mock_2d needs a two-dimensional grid.") return _gaussian_mock(bins, density, n_objects, noise, selection, None, 0.0, gen)
[docs] def mock_categorical( n_objects: int = 2000, n_classes: int = 4, *, prevalence: Any = None, train_prior: Any = None, separation: float = 1.5, temperature: float = 1.0, n_validation: int = 500, rng: RNGLike = None, ) -> Mock: """A quantification problem: classifier outputs over unordered classes. Each object has a one-dimensional feature drawn from a unit-variance Gaussian whose mean depends on its class (means ``separation`` apart). A classifier trained with class prior ``train_prior`` (uniform by default) reports posteriors ``mock.posteriors``, calibrated when ``temperature`` is one (its log-likelihoods are divided by ``temperature``); ``mock.likelihoods`` divides them by the training prior (route A, :meth:`Likelihoods.from_table`). ``mock.response`` is tallied with :meth:`Response.from_labelled` from a validation set of ``n_validation`` objects drawn with the training prior, scored by their feature; with ``mock.measurements["feature"]`` it gives route C (:meth:`Likelihoods.from_binned`). The test-set prevalence ``prevalence`` (default: a skewed vector) differs from the training prior (label shift). Parameters ---------- n_objects : int, optional Number of objects in the target set. n_classes : int, optional Number of classes. prevalence : array_like (n_classes,), optional Class fractions of the target set; ``None`` gives fractions rising as the square of the class index. train_prior : array_like (n_classes,), optional Class prior the classifier was trained with; ``None`` is uniform. separation : float, optional Distance between the class means of the feature. temperature : float, optional Divisor of the classifier's log-likelihoods; one is calibrated. n_validation : int, optional Objects in the labelled validation set. rng : int, Generator or None Random number generator. """ if not temperature > 0: raise ValueError(f"temperature must be positive; got {temperature}.") if int(n_validation) < 1: raise ValueError(f"n_validation must be at least 1; got {n_validation}.") gen = as_generator(rng) C = int(n_classes) prev = ( (np.arange(1, C + 1, dtype=float) ** 2) if prevalence is None else np.asarray(prevalence, dtype=float) ) prev = prev / prev.sum() pi = np.full(C, 1.0 / C) if train_prior is None else np.asarray(train_prior, dtype=float) pi = pi / pi.sum() means = separation * np.arange(C) labels = gen.choice(C, size=n_objects, p=prev) x = means[labels] + gen.standard_normal(n_objects) val_labels = gen.choice(C, size=int(n_validation), p=pi) val_x = means[val_labels] + gen.standard_normal(val_labels.size) logpost = -0.5 * (x[:, None] - means[None, :]) ** 2 / temperature + np.log(pi)[None, :] post = np.exp(logpost - special.logsumexp(logpost, axis=1, keepdims=True)) bins = Bins.categories([f"class_{c}" for c in range(C)]) lk = Likelihoods.from_table(post, bins, interim=pi) counts = np.bincount(labels, minlength=C).astype(float) truth = Truth(rho=prev, labels=labels, values=None, counts=counts, n_drawn=n_objects) missing = np.setdiff1d(np.arange(C), val_labels) if missing.size: raise ValueError( f"The validation set has no objects of class(es) {missing.tolist()}; raise " "n_validation or the training prior of those classes." ) response = Response.from_labelled( val_x, np.asarray(bins.labels, dtype=object)[val_labels], classes=bins.labels ) return Mock(bins, lk, truth, {"feature": x}, posteriors=post, interim=pi, response=response)
[docs] def mock_unfolding( n_objects: int = 5000, n_true: int = 20, n_reco: int = 25, *, slope: float = 3.0, resolution: float = 0.08, efficiency: tuple[float, float] = (0.6, 0.95), rng: RNGLike = None, ) -> Mock: """An unfolding problem: an exponential spectrum smeared by a detector. Unlike in the other mocks, ``n_objects`` counts the objects (events) *before* detection, so the number observed is random (``mock.truth.counts.sum()``). True values on [0, 1] follow ``exp(-slope * z)``; events are detected with an efficiency rising linearly across the range, smeared with Gaussian ``resolution`` and histogrammed in ``n_reco`` bins on [-0.1, 1.1]. The response matrix is computed for a population uniform within each true bin, ``mock.response`` is a :class:`Response` and ``mock.likelihoods`` is built with :meth:`Likelihoods.from_binned` (its efficiency becomes the selection function). Parameters ---------- n_objects : int, optional Number of events before detection. n_true : int, optional Number of true bins. n_reco : int, optional Number of measured bins. slope : float, optional Decay rate of the exponential spectrum. resolution : float, optional Standard deviation of the Gaussian smearing. efficiency : (float, float), optional Detection efficiency at the two ends of the range. rng : int, Generator or None Random number generator. """ gen = as_generator(rng) tb = Bins.linear(0.0, 1.0, n_true) re = np.linspace(-0.1, 1.1, n_reco + 1) density = lambda z: np.exp(-slope * np.asarray(z)) # noqa: E731 rho = population(tb, density) eff = lambda z: efficiency[0] + (efficiency[1] - efficiency[0]) * np.asarray(z) # noqa: E731 # response R[j, k] = P(reco j, detected | true bin k), population uniform within bin k u, wq = np.polynomial.legendre.leggauss(16) e = tb.edges[0] R = np.zeros((n_reco, n_true)) for k in range(n_true): zk = e[k] + (e[k + 1] - e[k]) * 0.5 * (u + 1.0) wk = 0.5 * wq cdf = stats.norm.cdf((re[None, :] - zk[:, None]) / resolution) R[:, k] = (np.diff(cdf, axis=1) * (wk * eff(zk))[:, None]).sum(axis=0) # simulate events z, _ = _draw_values(tb, density, n_objects, gen) det = gen.random(n_objects) < eff(z) reco = z + resolution * gen.standard_normal(n_objects) observed = det & (reco >= re[0]) & (reco <= re[-1]) response = Response(R, tb, Bins(re)) lk = Likelihoods.from_binned(response, values=reco[observed]) counts = np.bincount(lk.meta["measured_bin"], weights=lk.weights, minlength=n_reco) true_lab = tb.index(z[observed]) truth = Truth( rho=rho, labels=true_lab, values=z[observed], counts=np.bincount(true_lab, minlength=n_true).astype(float), n_drawn=n_objects, ) return Mock(tb, lk, truth, {"reco_counts": counts}, response=response)
# ---------------------------------------------------------------------------- # prior predictive simulator (for SBC) # ----------------------------------------------------------------------------
[docs] def prior_predictive( prior: PopulationPrior, bins: Bins, n_objects: int | None = None, *, noise: Any = (0.05, 0.2), selection: Any = None, selection_mode: str | None = None, max_objects: float = 1e6, ) -> Callable[[np.random.Generator], tuple[Likelihoods, np.ndarray]]: r"""A simulator ``simulate(rng) -> (likelihoods, truth)`` drawing populations from a prior. Each call draws a population from ``prior``, objects uniformly within bins from it, and Gaussian measurements with the given ``noise`` (in units of the bin coordinate), and returns the likelihoods with the selection attached and the true fractions :math:`\rho` (or rates :math:`\lambda` for a rate prior). Intended for :func:`binference.validation.sbc` and :func:`binference.validation.coverage`. One-dimensional bins only. Parameters ---------- prior : PopulationPrior Prior on fractions, or on rates (e.g. :class:`~binference.priors.GammaRates`, which then needs an informative ``rate``). bins : Bins One-dimensional bins. n_objects : int, optional Number of observed objects (after selection), for priors on fractions (default 200). For rates the number is drawn: :math:`N \sim \mathrm{Poisson}(S\cdot\lambda)`, i.e. :math:`\Lambda\,S\cdot\rho`, so ``n_objects`` must not be given. A draw with no object is discarded and the population drawn again, which conditions the joint draw on :math:`N \ge 1` and leaves the ranks of SBC uniform. noise : float or (lo, hi) Measurement standard deviation, fixed or drawn uniformly per object. selection : array_like (K,), optional Selection per bin: detection probabilities in (0, 1] for fractions, exposures (any positive values) for rates. Without it, every object is observed (unit exposure for rates). selection_mode : {"on_truth", "on_measurement"} Required with ``selection``. ``"on_truth"``: an object is kept with a probability proportional to ``selection`` of its *true* bin, and the likelihoods carry ``Selection(selection, "on_truth")``. ``"on_measurement"``: it is kept with a probability proportional to ``selection`` of the bin its *measured* value falls in (values beyond the range count in the end bins), and the likelihoods carry an ``"on_measurement"`` selection whose :math:`S_k` is the bin average of :math:`\sum_j T_j P(d \in j \mid z)` over the noise distribution, with :math:`T` the given values. max_objects : float For rates, the largest expected number of objects :math:`S\cdot\lambda` a draw may have. Every object is simulated, so a draw beyond it raises (as the vague default :class:`~binference.priors.GammaRates` does). Returns ------- callable ``simulate(rng)`` returning ``(likelihoods, truth)``. Notes ----- Objects are kept with probability ``selection / max(selection)`` until enough are observed; only the ratios matter for which objects are kept, while the absolute values set the selection attached to the likelihoods and, for rates, the expected number of objects. """ if not isinstance(bins, Bins) or bins.is_categorical or bins.ndim != 1: raise ValueError("prior_predictive needs one-dimensional Bins.") bound = prior.bind(bins) rates = bound.kind == "rates" if bound.kind not in ("fractions", "rates"): raise ValueError("prior_predictive supports priors on fractions or rates.") if rates and n_objects is not None: raise ValueError( "n_objects must not be given for a rate prior: the number of objects is drawn " "from Poisson(S . lambda)." ) n = 200 if n_objects is None else n_objects if not rates and (int(n) != n or n < 1): raise ValueError(f"n_objects must be a positive integer; got {n_objects}.") n = int(n) if not max_objects > 0: raise ValueError(f"max_objects must be positive; got {max_objects}.") sel = keep_truth = keep_measured = None S = np.ones(bins.K) if selection is None: if selection_mode is not None: raise ValueError("selection_mode was given without a selection.") else: scale = "exposure" if rates else "probability" sel = Selection(selection, selection_mode, scale=scale) # checks the mode T = sel.values if T.size != bins.K: raise ValueError(f"selection needs one value per bin ({bins.K}); got {T.size}.") if selection_mode == "on_truth": keep_truth = T / T.max() else: if bins.is_masked: raise ValueError("selection_mode='on_measurement' needs unmasked bins.") e = np.r_[-np.inf, bins.edges[0][1:-1], np.inf] nz = np.asarray(noise, dtype=float) def passes(z): return sum(T[j] * _measured_pass(z, nz, e[j], e[j + 1]) for j in range(bins.K)) sel = Selection.from_function(passes, bins, selection_mode, scale=scale) keep_measured = T / T.max() S = sel.values def draw(rng: np.random.Generator) -> tuple[np.ndarray, int]: value = bound.sample(1, rng)[0] if not rates: return value, n # N ~ Poisson(S . lambda) mean = float(S @ value) if not mean <= max_objects: raise ValueError( f"A rate drawn from the prior expects {mean:.3g} objects, above max_objects=" f"{max_objects:.3g}; give the rate prior an informative scale (e.g. " "GammaRates(shape, rate) with a larger rate)." ) return value, int(rng.poisson(mean)) def simulate(rng: np.random.Generator) -> tuple[Likelihoods, np.ndarray]: for _ in range(1000): # condition on N >= 1 (rates only) value, count = draw(rng) if count: break else: raise ValueError( "1000 populations drawn from the rate prior in a row had no object; give " "the rate prior an informative scale or the selection larger exposures." ) rho = np.maximum(value, 0.0) rho = rho / rho.sum() mock = _gaussian_mock( bins, density=rho, n_objects=count, noise=noise, on_truth=keep_truth, measured_range=None, outliers=0.0, gen=rng, measured_keep=keep_measured, selection=sel, ) return mock.likelihoods, value if rates else rho return simulate