Bayesian calibration of polynomials, including inference of overall normalization#
import corner
import numpy as np
from matplotlib import pyplot as plt
from scipy import stats
import rxmc
Using database version X4-2024-12-31 located in: /home/kyle/db/exfor/unpack_exfor-2024/X4-2024-12-31
rng = np.random.default_rng(42)
poly4 = rxmc.physical_model.Polynomial(4)
true_params = [1, 0.5, -0.1, -0.4, 0.1]
settings = [
{
"domain": [-0.3, 0.4],
"N": 50,
"noise": 0.1,
"systematic_err": 0.1,
},
{
"domain": [0.3, 0.5],
"N": 30,
"noise": 0.1,
"systematic_err": 0.5,
},
{
"domain": [0.1, 0.6],
"N": 25,
"noise": 0.1,
"systematic_err": 0.2,
},
{
"domain": [-0.5, 0.1],
"N": 15,
"noise": 0.2,
"systematic_err": 0.6,
},
]
def generate_observations(settings, true_model, rng, true_params, scale_err=True):
obs = []
for setting in settings:
x0, x1 = setting["domain"]
synthetic_obs = rxmc.observation.Observation(
x=rng.random(setting["N"]) * (x1 - x0) + x0,
y=np.zeros(setting["N"]),
y_stat_err=np.ones(setting["N"]) * setting["noise"],
)
renormalization = rng.normal(1, setting["systematic_err"])
y_true = true_model(synthetic_obs, *true_params)
if scale_err:
synthetic_obs.y = rng.normal(y_true, setting["noise"]) * renormalization
else:
synthetic_obs.y = rng.normal(y_true * renormalization, setting["noise"])
synthetic_obs.renormalization = renormalization
synthetic_obs.sys_norm_err = setting["systematic_err"]
obs.append(synthetic_obs)
return obs
observations = generate_observations(settings, poly4, rng, true_params, scale_err=False)
observations_unreported_sys_err = [
rxmc.observation.Observation(
x=obs.x,
y=obs.y,
y_stat_err=obs.y_stat_err,
)
for obs in observations
]
N_fine = 100
domain_fine = (-1, 1)
truth = rxmc.observation.Observation(
x=np.linspace(*domain_fine, N_fine), y=np.zeros(N_fine)
)
truth.y = poly4(truth, *true_params)
plt.plot(truth.x, truth.y, "k:", label="truth")
for synthetic_observation in observations:
plt.errorbar(
synthetic_observation.x,
synthetic_observation.y,
synthetic_observation.y_stat_err,
linestyle="none",
marker=".",
label=f"renormalization = {synthetic_observation.renormalization:1.3f}",
)
plt.xlabel("x")
plt.ylabel("y")
plt.legend()
<matplotlib.legend.Legend at 0x780de1416090>
correct_model = poly4
# block supports come from the library helper
_block_supports = rxmc.covariance.stacked_supports
evidence_models = {}
# Unknown per-dataset normalization: a latent scale rho_i for each dataset, routed
# to it by identity and sampled as ordinary *model* parameters. This is the v2
# form of the old per-dataset UnknownNormalizationModel -- rho moves onto the
# PhysicalModel (via rxmc.transforms.per_observation_scaling) and is sampled in
# the model block (jointly with the physics) rather than as a per-constraint
# covariance nuisance. All constraints share the one model instance, so they
# still share a single model-parameter vector.
norm_model = rxmc.physical_model.Polynomial(
order=correct_model.order,
transform=rxmc.transforms.per_observation_scaling(observations),
)
evidence_models["unknown_norm"] = rxmc.evidence.Evidence(
[rxmc.constraint.Constraint([obs], norm_model) for obs in observations]
)
# reported systematic errors folded in as fixed per-dataset normalization modes
evidence_models["marginalized_sys_err"] = rxmc.evidence.Evidence(
[
rxmc.constraint.Constraint(
observations,
correct_model,
extra_terms=[
rxmc.covariance.normalization_term(
magnitude=o.sys_norm_err, support=sup
)
for o, sup in zip(observations, _block_supports(observations))
],
)
]
)
# systematic errors omitted entirely (statistical only)
evidence_models["unreported_sys_err"] = rxmc.evidence.Evidence(
[rxmc.constraint.Constraint(observations_unreported_sys_err, correct_model)]
)
# omitted systematics absorbed by a single unknown (averaging) model-error term
gamma = rxmc.params.Parameter(
"log fractional err", float, latex_name=r"\gamma", unit="dimensionless"
)
_N_unreported = sum(o.n_data_pts for o in observations_unreported_sys_err)
evidence_models["unreported_sys_err_with_unknown_model_err"] = rxmc.evidence.Evidence(
[
rxmc.constraint.Constraint(
observations_unreported_sys_err,
correct_model,
extra_terms=[
rxmc.covariance.model_error_term(
gamma,
averaging=True,
support=np.arange(_N_unreported),
)
],
)
]
)
evidence_models.keys()
dict_keys(['unknown_norm', 'marginalized_sys_err', 'unreported_sys_err', 'unreported_sys_err_with_unknown_model_err'])
print("hyperparameters for each model:")
hyperparams = {}
for key, evidence_model in evidence_models.items():
hyperparams[key] = []
for constraint in evidence_model.parametric_constraints:
for p in constraint.params:
hyperparams[key].append(p)
print(f"{key}: {[p.name for p in hyperparams[key]]}")
hyperparameters for each model:
unknown_norm: []
marginalized_sys_err: []
unreported_sys_err: []
unreported_sys_err_with_unknown_model_err: ['log fractional err']
Priors#
We will put a tight prior on $a_0$, to avoid identifiability issues, as $a_0$ corresponds to an additive offset of the entire model, which is pretty confounding with a multiplicative bias.
cov = np.diag(np.ones(len(correct_model.params)))
cov[0, 0] = 0.001
model_prior = stats.multivariate_normal(mean=np.array([1, 0, 0, 0, 0]), cov=cov)
unknown_log_norm_priors = [
stats.multivariate_normal(
mean=[0], cov=[[np.log(1 + (setting["systematic_err"]) ** 2)]]
)
for setting in settings
]
unknown_model_err_prior = stats.multivariate_normal(mean=[np.log(0.1)], cov=[[0.01]])
Walkers#
walkers = {}
# combined prior over [physics params, per-dataset log_rho] (block-diagonal):
# the per-dataset scales are now model parameters, so they are sampled in the
# model block -- this walker has no separate likelihood samplers.
n_physics = len(correct_model.params)
rho_prior_vars = np.array(
[np.log(1 + setting["systematic_err"] ** 2) for setting in settings]
)
norm_prior_mean = np.concatenate([model_prior.mean, np.zeros(len(observations))])
norm_prior_cov = np.zeros((n_physics + len(observations),) * 2)
norm_prior_cov[:n_physics, :n_physics] = model_prior.cov
norm_prior_cov[n_physics:, n_physics:] = np.diag(rho_prior_vars)
norm_model_prior = stats.multivariate_normal(mean=norm_prior_mean, cov=norm_prior_cov)
walkers["unknown_norm"] = rxmc.walker.Walker(
model_sampler=rxmc.param_sampling.BatchedAdaptiveMetropolisSampler(
norm_model.params,
starting_location=norm_model_prior.mean,
prior=norm_model_prior,
initial_proposal_cov=norm_model_prior.cov,
),
evidence=evidence_models["unknown_norm"],
)
walkers["unreported_sys_err_with_unknown_model_err"] = rxmc.walker.Walker(
model_sampler=rxmc.param_sampling.BatchedAdaptiveMetropolisSampler(
correct_model.params,
starting_location=model_prior.mean,
prior=model_prior,
initial_proposal_cov=model_prior.cov,
),
evidence=evidence_models["unreported_sys_err_with_unknown_model_err"],
likelihood_samplers=[
rxmc.param_sampling.BatchedAdaptiveMetropolisSampler(
params=hyperparams["unreported_sys_err_with_unknown_model_err"],
starting_location=unknown_model_err_prior.mean,
prior=unknown_model_err_prior,
initial_proposal_cov=unknown_model_err_prior.cov,
)
],
)
walkers["marginalized_sys_err"] = rxmc.walker.Walker(
model_sampler=rxmc.param_sampling.BatchedAdaptiveMetropolisSampler(
correct_model.params,
starting_location=model_prior.mean,
prior=model_prior,
initial_proposal_cov=model_prior.cov,
),
evidence=evidence_models["marginalized_sys_err"],
)
walkers["unreported_sys_err"] = rxmc.walker.Walker(
model_sampler=rxmc.param_sampling.BatchedAdaptiveMetropolisSampler(
correct_model.params,
starting_location=model_prior.mean,
prior=model_prior,
initial_proposal_cov=model_prior.cov,
),
evidence=evidence_models["unreported_sys_err"],
)
for key, walker in walkers.items():
print(f"\nRunning {key}")
walker.walk(n_steps=20000, burnin=8000, batch_size=2000)
Running unknown_norm
Burn-in batch 1/4 completed, 2000 steps.
Burn-in batch 2/4 completed, 2000 steps.
Burn-in batch 3/4 completed, 2000 steps.
Burn-in batch 4/4 completed, 2000 steps.
Batch: 1/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.247
Batch: 2/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.306
Batch: 3/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.266
Batch: 4/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.277
Batch: 5/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.294
Batch: 6/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.286
Batch: 7/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.276
Batch: 8/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.248
Batch: 9/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.292
Batch: 10/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.288
Running unreported_sys_err_with_unknown_model_err
Burn-in batch 1/4 completed, 2000 steps.
Burn-in batch 2/4 completed, 2000 steps.
Burn-in batch 3/4 completed, 2000 steps.
Burn-in batch 4/4 completed, 2000 steps.
Batch: 1/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.283
Likelihood parameter acceptance fractions: [0.423]
Batch: 2/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.281
Likelihood parameter acceptance fractions: [0.451]
Batch: 3/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.291
Likelihood parameter acceptance fractions: [0.4215]
Batch: 4/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.302
Likelihood parameter acceptance fractions: [0.455]
Batch: 5/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.311
Likelihood parameter acceptance fractions: [0.436]
Batch: 6/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.290
Likelihood parameter acceptance fractions: [0.4425]
Batch: 7/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.278
Likelihood parameter acceptance fractions: [0.4445]
Batch: 8/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.267
Likelihood parameter acceptance fractions: [0.474]
Batch: 9/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.305
Likelihood parameter acceptance fractions: [0.4405]
Batch: 10/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.292
Likelihood parameter acceptance fractions: [0.4275]
Running marginalized_sys_err
Burn-in batch 1/4 completed, 2000 steps.
Burn-in batch 2/4 completed, 2000 steps.
Burn-in batch 3/4 completed, 2000 steps.
Burn-in batch 4/4 completed, 2000 steps.
Batch: 1/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.294
Batch: 2/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.288
Batch: 3/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.288
Batch: 4/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.264
Batch: 5/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.290
Batch: 6/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.311
Batch: 7/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.287
Batch: 8/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.292
Batch: 9/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.310
Batch: 10/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.301
Running unreported_sys_err
Burn-in batch 1/4 completed, 2000 steps.
Burn-in batch 2/4 completed, 2000 steps.
Burn-in batch 3/4 completed, 2000 steps.
Burn-in batch 4/4 completed, 2000 steps.
Batch: 1/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.301
Batch: 2/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.284
Batch: 3/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.289
Batch: 4/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.295
Batch: 5/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.282
Batch: 6/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.296
Batch: 7/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.310
Batch: 8/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.315
Batch: 9/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.290
Batch: 10/10 completed, 2000 steps.
Model parameter acceptance fraction: 0.291
colors = dict(
zip(walkers.keys(), ["tab:blue", "tab:orange", "tab:purple", "tab:green"])
)
colors
{'unknown_norm': 'tab:blue',
'unreported_sys_err_with_unknown_model_err': 'tab:orange',
'marginalized_sys_err': 'tab:purple',
'unreported_sys_err': 'tab:green'}
model = correct_model
fig, axes = plt.subplots(
walkers["marginalized_sys_err"].model_sampler.chain.shape[1] + 1,
1,
figsize=(8, 8),
sharex=True,
)
for i in range(walker.model_sampler.chain.shape[1]):
# plot walkers
for key, walker in walkers.items():
axes[i].plot(walker.model_sampler.chain[:, i], alpha=0.4, color=colors[key])
axes[i].set_ylabel(f"${model.params[i].latex_name}$")
true_value = true_params[i]
axes[i].hlines(true_value, 0, len(walker.model_sampler.chain), "r", linestyle="--")
# plot likelihoods
for key, walker in walkers.items():
axes[-1].plot(walker.model_sampler.logp_chain, alpha=0.4, color=colors[key])
prior_samples = model_prior.rvs(walker.model_sampler.chain.shape[0])
prior_samples.shape
(20000, 5)
domain = np.zeros((len(walkers), correct_model.n_params, 2))
for i, (key, walker) in enumerate(walkers.items()):
chain = walker.model_sampler.chain[:, : correct_model.n_params]
domain[i, ...] = np.array(
[
np.min(chain, axis=0),
np.max(chain, axis=0),
]
).T
domain = np.array([np.min(domain[:, :, 0], axis=0), np.max(domain[..., 1], axis=0)])
domain.shape
(2, 5)
fig = plt.figure()
for key, walker in walkers.items():
corner.corner(
walker.model_sampler.chain[:, : correct_model.n_params],
fig=fig,
color=colors[key],
range=domain.T,
labels=[f"${p.latex_name}$" for p in correct_model.params],
truths=true_params,
labelpad=0.1,
max_n_ticks=2,
truth_color="k",
)
plt.plot([], [], color=colors[key], label=key)
fig.legend()
# plt.tight_layout()
<matplotlib.legend.Legend at 0x780ddc0c65a0>
As expected, unknown_norm and marginalized_sys_err are the same. This is because they originate from the same exact statistical model, in which the normalization of each data set is a random variable. The latter explicitly marginalizes over the renormalization according to $\mathcal{N}(1,\sigma_{sys})$, whereas the former takes that distribution as a prior but attempts to learn the real distribution by conditioning the data.
In this case the underlying process for generating $N$ is exactly what the experimentalists report (that is, it is $\mathcal{N}(1,\sigma_{sys})$). If the experimentally reported $\sigma_{sys}$ was incorrect, then marginalized_sys_err would not be marginalizing over the correct distribution, and would converge to something wrong (try it!). On the other hand, with unknown_norm, incorrect experimentally reported $\sigma_{sys}$ just means a bad prior: $\sigma_{sys}$ doesn’t enter into the likelihood at all. Eventually, sampling should converge to the appropriate distribution.
Of course, the other two methods, which do not account for systematic error at all, fail to converge to the region of the truth.
Let’s explore the performance of the inference of the normalizations from the “unknown norm” model#
We will look at the posteriors of $\rho_i$, the renormalization of the model predictions with respect to each data set. We will look at the maxima a posteriori (MAP) and compare them to the values we actually renormalized by when generating the synthetic data. Then we will plot the experimental values renormalized by the MAP $\rho$s, and we should see that they re-align themselves with the truth.
# per-dataset rho posteriors live in the model-block chain, after the physics
n_physics = len(correct_model.params)
norm_chain = np.exp(walkers["unknown_norm"].model_sampler.chain[:, n_physics:])
norm_chain.shape
(20000, 4)
norm_log_posterior_vals = walkers["unknown_norm"].model_sampler.logp_chain
norm_log_posterior_vals.shape
(20000,)
map_idx = np.argmax(walkers["unknown_norm"].model_sampler.logp_chain)
N_data_sets = len(settings)
maps = norm_chain[map_idx, :]
maps
array([1.0519558 , 0.53718276, 1.2928494 , 1.00432021])
np.array([obs.renormalization for obs in observations])
array([1.06789136, 0.53671203, 1.3071512 , 1.05429387])
np.mean(norm_chain, axis=0)
array([1.04462468, 0.5287628 , 1.28785673, 0.99731649])
fig = corner.corner(
norm_chain,
labels=range(len(settings)),
truths=[obs.renormalization for obs in observations],
labelpad=0.1,
max_n_ticks=2,
color="tab:blue",
truth_color="red",
)
plt.plot(truth.x, truth.y, "k:", label="truth")
for i, synthetic_observation in enumerate(observations):
rho_hat = maps[i]
plt.errorbar(
synthetic_observation.x,
synthetic_observation.y / rho_hat,
synthetic_observation.y_stat_err,
linestyle="none",
marker=".",
label=r"$\rho_{\text{truth}}$ = "
+ f"{synthetic_observation.renormalization:1.3f},"
+ r" MAP = "
+ f"{rho_hat:1.2f}",
)
plt.xlabel("$x$")
plt.ylabel("$y$")
plt.legend()
plt.title(r"Renormalizing experimental data sets based on MAP of $p(\rho)$")
Text(0.5, 1.0, 'Renormalizing experimental data sets based on MAP of $p(\\rho)$')
predictive posteriors#
walker.model_sampler.chain.shape
(20000, 5)
def predictive_post(chain, model, obs, n_samples, intervals):
draw_idxs = rng.choice(np.arange(chain.shape[0]), n_samples)
draws = chain[draw_idxs, :]
ym = np.array([model(obs, *p) for p in draws])
return np.percentile(ym, intervals, axis=0)
fig = plt.figure(figsize=(8, 4))
plt.plot(truth.x, truth.y, "k:", label="truth")
for synthetic_observation in observations:
plt.errorbar(
synthetic_observation.x,
synthetic_observation.y,
synthetic_observation.y_stat_err,
linestyle="none",
marker=".",
alpha=0.4,
# label=f"renormalization = {synthetic_observation.renormalization:1.3f}",
)
hatches = ["//\\//\\", "|-", "", ""]
alphas = [0.1, 0.25, 0.25, 0.25]
for i, (key, walker) in enumerate(walkers.items()):
intervals = predictive_post(
walker.model_sampler.chain[:, : correct_model.n_params],
correct_model,
truth,
1000,
[16, 84],
)
plt.fill_between(
truth.x,
intervals[0],
intervals[1],
alpha=alphas[i],
color=colors[key],
label=key,
hatch=hatches[i],
zorder=99,
)
plt.xlabel("x")
plt.ylabel("y")
plt.ylim([0, 2])
fig.legend(loc="upper left", framealpha=1)
<matplotlib.legend.Legend at 0x780dd75b3470>
Covariance-structure gallery#
The sections above inferred unknown normalisations — a single rank-one
systematic per dataset. But a Constraint’s covariance is assembled from
arbitrary Terms, so the same machinery expresses far richer uncertainty
structure. This gallery surveys four cases the covariance API handles, each just a
different Term (or support) on the stacked residual:
a systematic correlated across $x$ (smoothly, not a flat normalisation);
correlated statistical errors — and why ignoring them is overconfident;
unknown / misreported magnitudes (the free-$\gamma$ model error above);
a systematic shared across datasets (cross-block coupling).
A small helper normalises a covariance to a correlation matrix for plotting.
from sklearn.gaussian_process.kernels import RBF, ConstantKernel
def correlation(Sigma):
d = np.sqrt(np.diag(Sigma))
return Sigma / np.outer(d, d)
3. Unknown / misreported magnitudes#
When the size of an uncertainty is itself unknown, it becomes a sampled
Parameter. The unreported_sys_err_with_unknown_model_err scenario above does
exactly this with a free $\gamma$ (model_error_term, a diagonal inflation). Here
we just visualise how the covariance diagonal grows as $\gamma$ increases —
inference slides along this family to whatever the data support.
o = observations[0]
gamma = rxmc.params.Parameter("log gamma", float)
c_me = rxmc.constraint.Constraint(
[o],
correct_model,
extra_terms=[rxmc.covariance.model_error_term(gamma, averaging=True)],
)
plt.figure(figsize=(7, 4))
for g in [0.02, 0.05, 0.10]:
S = c_me.covariance_matrix(true_params, (np.log(g),))
plt.plot(o.x, np.sqrt(np.diag(S)), marker=".", label=f"$\gamma$ = {g:.2f}")
plt.plot(o.x, o.y_stat_err, "k:", label="reported stat. err")
plt.xlabel("x")
plt.ylabel("total std. dev.")
plt.legend()
plt.title("unknown model error inflates the diagonal by a sampled $\gamma$");
<>:12: SyntaxWarning: invalid escape sequence '\g'
<>:17: SyntaxWarning: invalid escape sequence '\g'
<>:12: SyntaxWarning: invalid escape sequence '\g'
<>:17: SyntaxWarning: invalid escape sequence '\g'
/tmp/ipykernel_402344/1563835932.py:12: SyntaxWarning: invalid escape sequence '\g'
plt.plot(o.x, np.sqrt(np.diag(S)), marker=".", label=f"$\gamma$ = {g:.2f}")
/tmp/ipykernel_402344/1563835932.py:17: SyntaxWarning: invalid escape sequence '\g'
plt.title("unknown model error inflates the diagonal by a sampled $\gamma$");
Summary#
Every case above is the same Constraint machinery with a different Term:
case |
|
structure |
|---|---|---|
flat normalisation |
|
rank-one, uniform |
correlated-across-$x$ systematic |
|
smooth/banded |
correlated statistical errors |
fixed |
arbitrary off-diagonal |
unknown magnitude |
|
sampled diagonal |
shared across datasets |
cross-block |
off-diagonal blocks |
Declaring the uncertainty is the modelling choice — the inference machinery is unchanged.