Tutorials / Choosing an engine

Choosing an engine

binference offers several engines that compute the same posterior. This tutorial runs all of them on one problem, and compares their accuracy, speed and diagnostics. NUTS, which is exact for any prior, is the reference.

import time
import warnings

import matplotlib.pyplot as plt
import numpy as np

import binference

bins = binference.Bins.linear(0, 3, 20)
mock = binference.simulate.mock_1d(1500, bins, noise=(0.1, 0.3), rng=11)
lk = mock.likelihoods

Running the engines

fits, times = {}, {}
for method in ["nuts", "laplace", "vb", "bootstrap"]:
    t0 = time.perf_counter()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")      # we look at the diagnostics ourselves below
        fits[method] = binference.infer(lk, method=method, rng=1, n_samples=500, check=False)
    times[method] = time.perf_counter() - t0
t0 = time.perf_counter()
est = binference.infer(lk, method="map")
times["map"] = time.perf_counter() - t0
print({k: f"{v:.1f} s" for k, v in times.items()})
{'nuts': '29.4 s', 'laplace': '0.1 s', 'vb': '0.1 s', 'bootstrap': '1.4 s', 'map': '0.0 s'}

Accuracy against the reference

The engines agree on the means and differ most in the standard deviations.

ref = fits["nuts"]
fig, axes = plt.subplots(2, 1, sharex=True)
fig.set_figheight(5.8)
zc = bins.centers
styles = {"laplace": "s", "vb": "^", "bootstrap": "D"}
for m, mk in styles.items():
    axes[0].plot(zc, fits[m].mean - ref.mean, mk, ms=6, label=m)
    axes[1].plot(zc, fits[m].std / ref.std, mk, ms=6, label=m)
axes[0].plot(zc, est.rho - ref.mean, "x", color="k", ms=7, mew=2, label="map")
axes[0].axhline(0, color="0.6", lw=1)
axes[1].axhline(1, color="0.6", lw=1)
axes[0].set(ylabel="mean − NUTS")
axes[1].set(xlabel="z", ylabel="sd / NUTS sd", yscale="log")
axes[1].set_yticks([0.1, 0.2, 0.5, 1, 2], ["0.1", "0.2", "0.5", "1", "2"])
axes[1].minorticks_off()
fig.legend(*axes[0].get_legend_handles_labels(), loc="outside lower center", ncol=3);
Two stacked scatter plots against z. Top, the differences from the NUTS mean lie close to zero for Laplace and VB. They scatter by up to 0.07 for MAP and 0.04 for the bootstrap. Bottom, on a log scale, Laplace lies near one. The bootstrap lies near one except in the sparse bins above z of 2.4. VB lies between 0.1 and 0.5 in most bins.

Each engine’s posterior mean minus the NUTS mean (top), and its posterior standard deviation over the NUTS one (bottom), per bin. The MAP estimate has no standard deviation.

  • Mean-field VB is far too narrow. It ignores the coupling between the objects’ bins and the population, which is strong when likelihoods are broad. binference always flags its results as approximate.

  • Laplace is approximate, and its Pareto k-hat \(\hat k\) says how far to trust it.

  • The bootstrap describes the spread of the MAP estimate over resampled catalogues. That is a different quantity from the posterior uncertainty.

  • MAP gives a single point. With a flat prior it can sit exactly on zero in some bins.

print("Laplace k-hat:", round(fits["laplace"].info["k_hat"], 2),
      "approximate:", fits["laplace"].approximate)
print("VB approximate:", fits["vb"].approximate)
print("MAP optimality gap:", f"{est.kkt_gap:.1e}")
Laplace k-hat: 1.12 approximate: True
VB approximate: True
MAP optimality gap: 4.5e-15

Gibbs on this problem

Gibbs is exact too, but on this problem it mixes slowly. Every sampled result carries diagnostics, and binference.infer warns when they look bad. A Gibbs run with the default settings triggers a warning:

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    gibbs = binference.infer(lk, method="gibbs", rng=1)
print(caught[0].message if caught else "no warnings")
model = binference.PopulationModel(lk)
print("fraction of missing information:", round(model.missing_information(est.rho), 3))
Diagnostics: max R-hat = 1.246 > 1.01; min bulk ESS = 12 (3.0 per chain < 100); min tail ESS = 34 (8.7 per chain < 100). Hint: Gibbs mixes slowly when likelihoods are broad compared with the bins (missing information 1.00: autocorrelation time over 1000); run longer or use method='nuts'.
fraction of missing information: 0.995

The fraction of missing information printed above is close to one, which is why Gibbs mixes slowly here (Gibbs sampling). The trace of the bin with the largest R-hat shows it:

worst = int(np.argmax(gibbs.diagnostics["rhat"]))
gibbs.plot_trace(idx=[worst]);
Four wiggly lines over 1000 iterations. Each wanders slowly over a wide range, and the chains spend long stretches at different levels.

The fraction in the bin with the largest R-hat, over the iterations of each of the four chains of the Gibbs run.

The four chains have not mixed. Run much longer, or use NUTS.

With narrow likelihoods every object’s bin is nearly known, and Gibbs is the faster sampler. Both runs below pass their diagnostics; the comparison is in effective samples per second:

narrow = binference.simulate.mock_1d(1500, bins, noise=(0.01, 0.03), rng=11)
per_second, narrow_fits = {}, {}
for m in ["nuts", "gibbs"]:
    t0 = time.perf_counter()
    narrow_fits[m] = r = binference.infer(narrow.likelihoods, method=m, rng=1, n_samples=500,
                                          check=False)
    dt = time.perf_counter() - t0
    per_second[m] = r.diagnostics["ess_bulk"].min() / dt
    print(f"{m:6s} {dt:5.1f} s, min ESS per second: {per_second[m]:8.1f}")
nuts    10.5 s, min ESS per second:     48.4
gibbs    0.9 s, min ESS per second:   1032.4

The engines guide says which engine to use when, and what method="auto" chooses.