Tutorials / Binned counts and a response: unfolding a spectrum

Binned counts and a response: unfolding a spectrum

A detector records each event in a measured bin \(j\), such as a bin of reconstructed energy. An event with a true value in bin \(k\) lands in measured bin \(j\) with probability \(R_{jk}\), the response matrix. Some events are lost entirely, so the efficiency \(\epsilon_k=\sum_jR_{jk}\) is less than one. Recovering the true spectrum from the counts is unfolding. This tutorial unfolds a smeared spectrum with binference, under a Dirichlet prior and under a smoothness prior.

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LinearSegmentedColormap, to_rgb

import binference
from binference.plotting import SAMPLE_COLOR

The response and the counts

binference.simulate.mock_unfolding() smears an exponential spectrum of 8000 events. It has 20 true bins and 25 measured bins:

mu = binference.simulate.mock_unfolding(8000, n_true=20, n_reco=25, rng=3)
R = mu.response.matrix                   # (measured bins, true bins)
counts = mu.measurements["reco_counts"]

fig, axes = plt.subplots(2, 1, height_ratios=[1.4, 1])
fig.set_figheight(5.8)
c0, ground = np.array(to_rgb("C0")), np.array(to_rgb(plt.rcParams["axes.facecolor"]))
ramp = LinearSegmentedColormap.from_list("ramp", [0.15 * c0 + 0.85 * ground, c0])
im = axes[0].imshow(R, origin="lower", aspect="auto", cmap=ramp)
axes[0].set(xlabel="true bin k", ylabel="measured bin j")
fig.colorbar(im, ax=axes[0], label="$R_{jk}$")
axes[1].stairs(counts, np.arange(len(counts) + 1) - 0.5, color=SAMPLE_COLOR, lw=1.8)
axes[1].set(xlabel="measured bin j", ylabel="events");
Top, a 25 by 20 grid shaded by probability, with a band along the diagonal that is a few bins wide. The band is darkest at high true bins, where the efficiency is highest. Bottom, a step histogram of counts that rises to about 490 events in measured bin 3 and falls to near zero by bin 24.

The response matrix (top), and the counts of detected events in the measured bins (bottom).

from_binned() turns the counts and the response into likelihood rows, one per measured bin, weighted by the counts. Each event measured in bin \(j\) gets the row \(R_{j\cdot}\). The efficiency enters as a selection on the measurement, because the response already includes it.

Inverting \(R\) directly amplifies the noise. Least squares on these counts gives “fractions” that swing between large positive and negative values:

naive, *_ = np.linalg.lstsq(R, counts, rcond=None)    # unregularised inversion
naive = naive / naive.sum()
print("matrix inversion, normalised:", naive[:6].round(1), "...")
matrix inversion, normalised: [  0.2  -0.4   2.8  -6.9  13.5 -21.6] ...

A smoothness prior regularises the unfolding: it stops the noise from growing. It infers how smooth the spectrum is, instead of fixing an iteration count or a regularisation strength by hand. Both fits use NUTS, with the chains in parallel processes:

lk = binference.Likelihoods.from_binned(mu.response, counts=counts)
print(lk, lk.selection)
flat = binference.infer(lk, method="nuts", rng=1, n_chains=2, n_warmup=400, n_samples=500,
                        n_jobs=-1)
smooth = binference.infer(lk, binference.priors.LogisticGP(), method="nuts", rng=1,
                          n_warmup=500, n_samples=500, n_jobs=-1, check=False)
d = smooth.diagnostics
print(f"divergent: {d['n_divergent']} of {smooth.n_draws}, max R-hat {d['rhat'].max():.3f}, "
      f"min bulk ESS {d['ess_bulk'].min():.0f}")
Likelihoods(N=25, K=20, dense, W=5456, selection='on_measurement', bins=Bins(K=20, range=(0, 1))) Selection(K=20, mode='on_measurement', scale='probability')
divergent: 0 of 2000, max R-hat 1.004, min bulk ESS 1560

check=False stops infer from warning, so that the diagnostics can be read here. The largest R-hat is below 1.01 and the smallest bulk effective sample size is above 400. Divergent transitions, if any, are rare; the pitfalls guide says when they matter.

The unfolded spectrum

truth = mu.truth.rho
fig, axes = plt.subplots(2, 1, sharex=True, sharey=True)
fig.set_figheight(5.1)
flat.plot(ax=axes[0], truth=truth, which="fractions", label="median")
smooth.plot(ax=axes[1], truth=truth, which="fractions", label="median")
axes[0].set_title("Dirichlet prior", loc="left")
axes[1].set_title("Gaussian-process prior", loc="left")
axes[0].legend(loc="upper right")
axes[1].get_legend().remove()
axes[0].set_xlabel("")
axes[1].set_xlabel("true value");
Two stacked step plots of the fraction per bin against the true value from 0 to 1, both falling steeply. The top bands are wide and ragged. The bottom bands are narrow and smooth, and follow the dashed truth.

The unfolded spectrum as population fractions, with 68% and 95% bands, under a Dirichlet prior (top) and a Gaussian-process prior (bottom). The truth is dashed.

The bands show the uncertainty that unfolding methods often leave out. The smoothness prior learns its own amplitude and length scale from the data:

for name, draws in smooth.info["hyperparameters"].items():
    print(f"{name}: {np.quantile(draws, [0.16, 0.5, 0.84]).round(2)}")
amplitude: [0.9  1.3  1.93]
length_scale: [0.4  0.57 0.85]

Tip

An order-2 random walk (LogisticRandomWalk(order=2)) is another natural prior here. But this spectrum is an exact exponential, and its log is a straight line, which the order-2 random walk does not penalise at all. The posterior then pushes the walk’s scale toward zero. This narrow region of the posterior makes NUTS report divergences. A Gaussian-process prior, target_accept=0.95, or a scale hyperprior bounded away from zero avoids it.

What to take away

  • Counts in measured bins and a response enter through Likelihoods.from_binned(response, counts=counts), which also attaches the efficiency.

  • A smoothness prior regularises the unfolding and infers its own strength.

  • The inputs guide shows how to build a response from simulated events. The unfolding background page relates this to the methods of particle physics.