Combining Models#
A common pattern in HEP is combining independent statistical models that share a parameter of interest but use different naming conventions. This page walks through the full workflow: aligning parameter names, merging states, and running a joint fit.
Setup: two independent measurements#
Two experiments each measure a particle mass with their own calibration uncertainties:
import jax
import jax.numpy as jnp
import everwillow as ew
import everwillow.statelib as sl
from everwillow.uncertainty import uncertainties
jax.config.update("jax_enable_x64", True)
# Experiment A: measures "mass" with a scale uncertainty
def nll_a(params, obs):
mass = params["mass"]
scale = params["scale_a"]
pred = mass * scale
return (
0.5 * ((pred - obs["m_a"]) / obs["err_a"]) ** 2
+ 0.5 * (scale - 1.0) ** 2 / 0.02**2
)
# Experiment B: measures the same quantity but calls it "m"
def nll_b(params, obs):
mass = params["m"]
scale = params["scale_b"]
pred = mass * scale
return (
0.5 * ((pred - obs["m_b"]) / obs["err_b"]) ** 2
+ 0.5 * (scale - 1.0) ** 2 / 0.03**2
)
Each model was developed independently, so the shared parameter has a different name:
state_a = sl.State.from_pytree({"mass": 125.0, "scale_a": 1.0})
state_b = sl.State.from_pytree({"m": 125.0, "scale_b": 1.0})
Aligning parameter names#
Use apply_transformations() to rename "m" → "mass" in model B:
state_b_aligned = sl.apply_transformations(
state_b,
{"m": sl.Transform(new_key="mass")},
)
The renamed state remembers the original key. When you call to_pytree(), model B still receives {"m": ...} - the rename is transparent to the NLL function.
Combined fit#
Before fitting, call prepare() to merge the states and build a combined NLL that dispatches each sub-pytree to the correct model. This is necessary because fit() takes a single NLL and a single state - prepare() produces both from the individual pieces:
obs = {"m_a": 125.5, "err_a": 1.5, "m_b": 124.8, "err_b": 2.0}
combined_nll, combined = ew.prepare([nll_a, nll_b], [state_a, state_b_aligned])
result = ew.fit(combined_nll, combined, obs)
print(result.params.to_pytree())
# ({'mass': 125.27, 'scale_a': 1.0}, {'m': 125.27, 'scale_b': 1.0})
# ^ same value in both models
To recover the individual states after fitting, use split():
state_a_fitted, state_b_fitted = sl.split(result.params)
print(state_a_fitted.to_pytree()) # {'mass': 125.27, 'scale_a': 1.0}
print(state_b_fitted.to_pytree()) # {'m': 125.27, 'scale_b': 1.0}
Comparing uncertainties#
The combined measurement has a smaller uncertainty on "mass" than either experiment alone:
# Individual fits
result_a = ew.fit(nll_a, state_a, obs)
result_b = ew.fit(nll_b, state_b, obs)
unc_a = uncertainties(nll_a, result_a.params, obs)
unc_b = uncertainties(nll_b, result_b.params, obs)
unc_combined = uncertainties(combined_nll, result.params, obs)
print(f"σ(mass) exp A only: {float(unc_a['mass']):.3f}")
print(f"σ(mass) exp B only: {float(unc_b['m']):.3f}")
print(f"σ(mass) combined: {float(unc_combined['mass']):.3f}")
# σ(mass) exp A only: 2.924
# σ(mass) exp B only: 4.245
# σ(mass) combined: 2.408
The combined constraint is tighter than either individual measurement - the merged state ensures both models are optimized with a single shared mass parameter.