Guides / Selection and rates

Selection and rates

Real catalogues are incomplete. If the chance of entering the catalogue depends on the quantity you bin, the observed objects are a biased sample of the population. Ignoring that biases the fractions \(\rho\). binference corrects for it, given a selection function: \(S_k\), the probability that an object whose true value lies in bin \(k\) is observed. The same correction gives the total number of objects, including those the catalogue missed.

import numpy as np
import binference

bins = binference.Bins([0.0, 1.0, 2.0, 3.0])
S = np.array([0.9, 0.5, 0.25])                 # detection probability per true bin
mock = binference.simulate.mock_1d(
    3000, bins, density=np.array([0.4, 0.35, 0.25]), noise=(0.3, 0.8), selection=S, rng=7
)
lk = mock.likelihoods.with_selection(None)     # the bare likelihoods
print(mock.truth.rho, mock.truth.n_drawn, "objects drawn,", lk.N, "selected")

Say how the selection acts

The likelihood depends on what the selection acts on, so binference requires you to say which: the selection mode.

selection_mode="on_measurement"

Objects are selected on their measured data: a flux limit on measured fluxes, or a signal-to-noise cut. Here \(S_k\) is the probability that an object in bin \(k\) passes the cut, averaged over its possible data. This mode also covers likelihoods that already include the detection probability, such as a response with its efficiencies.

selection_mode="on_truth"

Objects are selected with a probability that depends on their true value, whatever the measurement noise. Examples are a completeness that depends on true planet radius, or a targeting rate per true class.

Selection.combined(on_truth=..., on_measurement=...)

Both at once: \(P(\text{observed}\mid z,d)=S^{\rm truth}(z)\,S^{\rm meas}(d)\), for example a completeness in redshift together with a magnitude limit.

How each mode enters the likelihood is in the theory.

The mock above selects on the true bin:

right = binference.infer(lk, method="map", selection=S, selection_mode="on_truth").rho
wrong = binference.infer(lk, method="map", selection=S, selection_mode="on_measurement").rho
ignored = binference.infer(lk, method="map").rho
print("truth         ", mock.truth.rho)
print("on_truth      ", right.round(3))
print("on_measurement", wrong.round(3))
print("ignored       ", ignored.round(3))

Ignoring the selection over-weights the well-detected bins. Choosing the wrong mode also biases the fractions. That is why an array passed as selection must come with selection_mode: there is no safe default.

Where to attach it

These three are equivalent:

sel = binference.Selection(S, "on_truth")
a = binference.infer(lk, method="map", selection=sel).rho
b = binference.infer(lk.with_selection(S, "on_truth"), method="map").rho     # on the data
c = binference.PopulationModel(lk, selection=S, selection_mode="on_truth")    # on the model
print(np.allclose(a, b), c)

The simulators and from_binned attach the right selection themselves. simulate.mock_1d(..., selection=S) selects on the truth, and measured_range=(lo, hi) cuts on the measured value. Likelihoods.from_binned attaches the efficiency on the measurement.

Selection values are detection probabilities, between zero and one, by default. For the fractions only ratios between the \(S_k\) matter. If you know \(S\) only up to a constant, say so with Selection(S, mode, scale="relative"); totals and rates are then not available. scale="exposure" declares absolute values that may exceed one, such as an observing time. A bin that can never be observed (\(S_k=0\)) cannot be inferred: remove it from the bins.

Selection that varies within bins

The model needs \(S_k\) averaged over each bin. An array evaluated at bin centres is wrong where \(S\) changes quickly, and the error goes into the fractions of the bins near the edge. binference warns when an array changes steeply between neighbouring bins (warnings).

If that happens, pass the selection as a function of the true value. It is then averaged over each bin in the measure coordinate: use Selection.from_function(f, bins, mode), or pass the function as selection= with selection_mode. Only a raw array is checked. Values that are already bin averages, for example from a simulation, can be passed as Selection(S, mode).

def completeness(z):
    return 1.0 / (1.0 + np.exp((np.asarray(z) - 2.0) / 0.1))   # a sharp edge


smooth = binference.infer(lk, method="map", selection=completeness, selection_mode="on_truth")
print(smooth.rho.round(3))

Selection measured by injections

To measure completeness by injections, add objects of known true value to the data and count how many the pipeline recovers. For each bin, Selection.from_injections(z, detected, bins, "on_truth") counts the injections \(n^{\rm inj}_k\) and the recoveries \(n^{\rm rec}_k\). Each \(S_k\) then gets a \(\mathrm{Beta}(a+n^{\rm rec}_k,\ b+n^{\rm inj}_k-n^{\rm rec}_k)\) posterior, with prior=(a, b) of (1, 1) by default.

Two panels sharing the axis of true values from 0 to 3. Top: the number of injections per bin as an outline, and the number recovered as filled steps. The recovered number is close to the injected one at low values and falls to almost none at high values. Bottom: the selection inferred in each bin, as a median step with 68% and 95% bands. It follows the dashed true completeness curve, which falls from 0.95 to near zero.

Injections give the selection with its uncertainty. Top: 450 injections, uniform in the true value, and the ones the pipeline recovered. Bottom: the posterior of \(S_k\) in each bin from those counts (Selection.from_injections), against the true completeness (dashed). Bins with few injections get wide bands.

binference then infers the selection together with the population. The catalogue’s objects count as detections in their bins, and the objects it missed as non-detections. The catalogue informs only the ratios between the \(S_k\): its likelihood does not change when all of them are scaled together. Their level, and with it the total, comes from the injections. The draws of \(S\) are in result.info["selection_draws"], and the total and the per-object methods use them draw by draw.

rng = np.random.default_rng(3)
z_inj = rng.uniform(0.0, 3.0, 600)
recovered = rng.random(600) < S[bins.index(z_inj)]     # the mock's S
measured = binference.Selection.from_injections(z_inj, recovered, bins, "on_truth")
joint = binference.infer(lk, selection=measured, method="nuts", n_samples=300, n_warmup=300, rng=1)
print(joint.mean.round(3), joint.info["selection_draws"].mean(axis=(0, 1)).round(2))

Gibbs, NUTS, Laplace and MAP(space="unconstrained") infer it; VB and the bootstrap do not (theory). For those, or to ignore the uncertainty, measured.at_mean() gives a fixed selection at its mean values. For a selection on the measurement, pass Selection.from_injections(...).at_mean(), a fixed selection; binference infers only a selection on the truth.

Few injections per bin

With few injections per bin, smooth=\(\tau_S\) lets neighbouring bins share information. It puts a second-order random walk on \(\mathrm{logit}\,S\), with step scale \(\tau_S\), and works on 1-D bins with every engine but Gibbs. Its values are the mode of \(\mathrm{logit}\,S\) given the injections. \(\tau_S\) is the typical change, from one bin to the next, of the slope of \(\mathrm{logit}\,S\) per bin.

Smoothing helps smooth completeness curves with few injections per bin, not sharp edges. A sharp edge needs independent bins or a larger \(\tau_S\).

smooth = binference.Selection.from_injections(z_inj, recovered, bins, "on_truth", smooth=0.3)
fit = binference.infer(lk, selection=smooth, method="laplace", n_samples=300, rng=1)
print(fit.mean.round(3), fit.info["selection_draws"].mean(axis=(0, 1)).round(2))

Totals and rates

Every fit of fractions also gives the total and the rates. Suppose \(W\) objects were detected, and \(S\) is absolute: detection probabilities, or exposures with scale="exposure". The number of detections is then Poisson with mean \(\Lambda\,S\cdot\rho\), where \(\Lambda\) is the total expected number of objects.

  • The total. result.total holds one draw of \(\Lambda\) per draw of \(\rho\), under a scale-free prior on \(\Lambda\) (theory).

  • The rates. result.rates holds \(\lambda_k=\Lambda\rho_k\): the expected number of objects per bin, per unit exposure.

  • A relative selection identifies no total, and both are None.

  • Non-integer weights give a pseudo-posterior for the total too (Advanced input options).

fit = binference.infer(
    lk, method="nuts", n_samples=300, n_warmup=300, rng=1,
    selection=binference.Selection(S, "on_truth", scale="exposure"),
)
print("total:", fit.total.mean().round(0), "+/-", fit.total.std().round(0),
      " actually drawn:", mock.truth.n_drawn)

The total inherits the prior on the fractions, which biases the total low when it is poorly determined, for example when some bins hold few detections (theory). Check a total’s calibration on simulations (Check a result) before you quote it from such data.

The objects the catalogue missed

result.realized_counts(full=True) draws the histogram of the full sample: the detected objects in their bins, plus the missed ones. For each draw, the number missed is negative binomial, and they fall in bins in proportion to \(\rho_k(1-S_k)\).

Objects per bin against the true value. Filled steps show the detected objects, which fall to almost none at the highest values. An outline step with a band shows all objects, detected and missed. It stays well above the detected counts in the bins where detection is unlikely. A dashed step shows the true number of all objects, inside the band.

From detected counts to the full sample. 3000 objects were drawn and each was detected with a probability that falls from 0.95 to 0.15 across the bins. The fit sees the detected ones (filled) and the selection. The median with 68% and 95% bands of realized_counts(full=True) add the missed objects; the dashed line is the true histogram of all 3000.

In this example the fit saw 2013 detected objects, and its mean total is 3027 of the 3000 drawn.

Priors on the rates

To put a prior on the rates themselves, use GammaRates (rates instead of fractions). The selection then acts as an exposure in the same way.

est = binference.infer(
    lk, binference.priors.GammaRates(1.0, 1e-4), method="map",
    selection=binference.Selection(S, "on_truth", scale="exposure"),
)
print("expected total:", est.rates.sum().round(0), " actually drawn:", mock.truth.n_drawn)

Next

Two mistakes are common: correcting for the selection twice, and not at all (pitfall).

The selection tutorial and the populations-with-selection tutorial work through both modes and injections.