lorenzennio/redist: v2.0.0
Authors: Lorenz Gärtner, TimWue02
Type: Software
Published: 2026-08-03

<p><code>redist</code> now differentiates under JAX, computes its bin integrals one to two
orders of magnitude faster, and carries fixes for five bugs — four of which
predate this release.</p>
<p>The major version reflects several changes in behaviour. Nothing was removed or
renamed, but results and requirements move, so read the breaking changes before
upgrading a running analysis.</p>
<h2>Breaking changes</h2>
<p><strong>Python 3.8 is no longer supported.</strong> 3.9 through 3.14 are, and 3.13 and 3.14
are now tested in CI. <code>combine()</code> was already broken on 3.8 — it used <code>dict | dict</code> — so nothing that worked there stops working.</p>
<p><strong>Bin integrals now use Gauss-Legendre quadrature by default.</strong> Yields move by
about one ulp and are no longer reproducible bit for bit across numpy versions;
they were, under adaptive quadrature. Agreement between the two rules is a few
ulp, at most <code>3.3e-16</code> relative on the shipped models. If a published result has
to reproduce to the bit, pass <code>quad="nquad"</code>.</p>
<p>Distributions that can only be evaluated a point at a time — anything EOS-backed
— are detected when the modifier is built and keep using adaptive quadrature, so
those models need no change. <code>cmod.quad</code> reports which rule was chosen.</p>
<p><strong><code>weight_bound=0</code> is applied instead of ignored.</strong> It was tested for truth
rather than for presence, so a bound of zero silently did nothing. Zero and
negative bounds are meaningful alongside <code>allow_negative_weights</code> and are now
honoured. A model passing <code>weight_bound=0</code> goes from unbounded weights to
all-zero ones.</p>
<p><strong><code>load()</code> honours a saved <code>allow_negative_weights</code>.</strong> Models saved with the flag
set previously reloaded without it, replacing negative weights by ones.
Reloading such a model now reproduces the weights it was saved with, which for
an affected model is a change of about a factor of fifty in the touched bins.</p>
<p><strong><code>add_to_model()</code> no longer edits the model handed to it.</strong> Callers using it for
its side effect rather than its return value will not see the modifier applied.</p>
<h2>Gradients with JAX</h2>
<p>The weight path runs on any pyhf backend. On the JAX backend the likelihood is
differentiable and composes with <code>jax.grad</code>, <code>jax.jit</code> and <code>jax.vmap</code>:</p>
<pre><code class="language-bash">pip install redistpy[jax]
</code></pre>
<pre><code class="language-python">pyhf.set_backend("jax")          # before building the modifier
model = modifier.load("model.json", alt_dist, null_dist)
grad = jax.grad(lambda pars: model.logpdf(pars, data)[0])(pars)
</code></pre>
<p>The distributions have to be written in <code>jax.numpy</code> and accept broadcast arrays.
Theory codes that are not — EOS among them — cannot be differentiated through,
and continue to work on NumPy.</p>
<h2>Performance</h2>
<p>End to end on a 2-D reweighted model, 20 likelihood-scale evaluations:</p>
<p>| kinematic bins | before | after | |
|---|---|---|---|
| 5x5 | 125.0 ms | 1.5 ms | 81x |
| 10x10 | 632.7 ms | 3.9 ms | 164x |
| 20x20 | 1974.8 ms | 14.2 ms | 139x |</p>
<p>From two changes: the Gauss-Legendre evaluation grid is cached instead of
rebuilt on every integral, which was about three quarters of the work in a call,
and Gauss-Legendre is now the default wherever it can be used.</p>
<p>The weight cache is also bounded now. It kept one entry per distinct parameter
point and never dropped any, which cost about 0.61 GB per million draws in a
scan or a Markov chain. It evicts least recently used entries under a new
<code>cache_size</code>, defaulting to 128.</p>
<h2>Bug fixes</h2>
<ul>
<li><strong>Wrong rotation for multiple correlated parameter groups.</strong> With two or more
groups, every group but the last was rotated with the wrong matrix <em>and</em>
centred on the wrong mean, because a loop variable leaked. Single-group models,
which is everything in the examples, were unaffected.</li>
<li><strong><code>save()</code> could not serialise its own binning.</strong> <code>bins</code> in its normal form, a
list of arrays, raised <code>TypeError: Object of type ndarray is not JSON serializable</code>.</li>
<li><strong><code>combine()</code> passed a <code>validate</code> keyword <code>pyhf.Workspace.combine</code> never
accepted</strong>, and used <code>dict | dict</code> on a claimed 3.8 floor. Together with the
above, <code>combine()</code> did not work at all against pyhf 0.7.x.</li>
<li><strong>A bin the null distribution does not populate now fails at construction</strong>,
naming the offending bins, instead of producing infinite weights and yields.</li>
<li><strong>The cutoff was silently dropped under Gauss-Legendre quadrature.</strong></li>
</ul>
<h2>Also</h2>
<ul>
<li><code>save()</code> now records <code>allow_negative_weights</code> and <code>quad_order</code>. Files written
by older versions omit them and fall back to the defaults, so they load exactly
as before.</li>
<li>Plots integrate with the modifier's own quadrature rule rather than assuming
the adaptive one.</li>
<li>The test suite went from 10 tests to 70, adding multi-channel workspaces,
<code>combine()</code>, save/load round-trips, the applier's index arithmetic, and the
JAX path end to end.</li>
<li>Releases are now published to PyPI by a workflow using trusted publishing.</li>
</ul>
<p><strong>Full changelog</strong>: https://github.com/lorenzennio/redist/compare/v1.0.5...v2.0.0</p>