Calibrating an optical potential with CalibrationConfig: emcee vs dynesty#

This notebook shows how CalibrationConfig acts as a uniform interface to external inference libraries. We calibrate a nuclear optical-model potential to mock $n + {}^{40}$Ca elastic differential cross-section data using both emcee (MCMC using ensemble sampling) and dynesty (nested sampling), then compare the resulting posteriors and predictive posterior bands.

The key interface points provided by CalibrationConfig:

sampler

method used

emcee

config.log_posterior, config.starting_location

dynesty

config.log_likelihood, config.prior_transform, config.ndim

import corner
import dynesty
import emcee
import jitr
import matplotlib.pyplot as plt
import numpy as np
from jitr.optical_potentials.potential_forms import (
    thomas_safe,
    woods_saxon_prime_safe,
    woods_saxon_safe,
)

import rxmc
from rxmc.config import CalibrationConfig, ParameterConfig
from rxmc.params import Parameter
from rxmc.priors import TruncatedNormalPrior
Using database version X4-2025-12-31 located in: /mnt/home/beyerkyl/x4db/unpack_exfor-2025/X4-2025-12-31

Reaction, optical-model, and mock data#

We use the same minimal optical-model potential setup as in 30s_optical_potential_calibration.ipynb.

Ca40 = (40, 20)
neutron = (1, 0)
E_lab = 14.1

rxn = jitr.reactions.ElasticReaction(target=Ca40, projectile=neutron)

mso = 1.0 / jitr.utils.constants.WAVENUMBER_PION


def central_potential(r, Vv, Wv, Rv, av, Wd, Rd, ad):
    return -(Vv + 1j * Wv) * woods_saxon_safe(r, Rv, av) + (
        4j * ad * Wd
    ) * woods_saxon_prime_safe(r, Rd, ad)


def spin_orbit_potential(r, Vso, Wso, Rso, aso):
    return (Vso + 1j * Wso) * mso**2 * thomas_safe(r, Rso, aso)


R = 1.2 * 40 ** (1 / 3)
fixed_spin_orbit = (6.0, -3, R, 0.45)


def extract_params(ws, *x):
    Vv, Wv, Rv, av, Wd, Rd, ad = x
    return (Vv, Wv, Rv, av, Wd, Rd, ad), fixed_spin_orbit


params = [
    Parameter("Vv", unit="MeV"),
    Parameter("Wv", unit="MeV"),
    Parameter("Rv", unit="fm"),
    Parameter("av", unit="fm"),
    Parameter("Wd", unit="MeV"),
    Parameter("Rd", unit="fm"),
    Parameter("ad", unit="fm"),
]

omp = rxmc.elastic_diffxs_model.ElasticDifferentialXSModel(
    "dXS/dA",
    interaction_central=central_potential,
    interaction_spin_orbit=spin_orbit_potential,
    calculate_interaction_from_params=extract_params,
    params=params,
    model_name="minimal_elastic_demo",
)
angles_deg = np.linspace(2.0, 160.0, 28)
true_params = np.array(
    [48.0, 3.5, 1.1 * 40 ** (1 / 3), 0.7, 21, 1.2 * 40 ** (1 / 3), 0.5]
)

template_obs = rxmc.elastic_diffxs_observation.ElasticDifferentialXSObservation(
    x=angles_deg,
    y=np.ones_like(angles_deg, dtype=float),
    Elab=E_lab,
    reaction=rxn,
    quantity="dXS/dA",
    measurement_quantity="dXS/dA",
    y_units="barn / steradian",
    dataset_label="template",
)

y_true = omp.evaluate(template_obs, *true_params)

rng = np.random.default_rng(42)
y_stat_err = 0.2 * np.maximum(y_true, 1e-4)
y_mock = np.clip(y_true + rng.normal(scale=y_stat_err * 1.3), 1e-6, None)

obs = rxmc.elastic_diffxs_observation.ElasticDifferentialXSObservation(
    x=angles_deg,
    y=y_mock,
    Elab=E_lab,
    reaction=rxn,
    quantity="dXS/dA",
    measurement_quantity="dXS/dA",
    y_units="barn / steradian",
    y_stat_err=y_stat_err,
    dataset_label="mock elastic dataset",
)
plt.errorbar(np.rad2deg(obs.x), obs.y, obs.y_stat_err, linestyle="none", marker=".")
plt.xlabel(r"$\theta$ [deg]")
plt.ylabel(r"$d\sigma/d\Omega$ [b/Sr]")
plt.yscale("log")
plt.title(r"Mock $n + {}^{40}$Ca differential cross-section data")
Text(0.5, 1.0, 'Mock $n + {}^{40}$Ca differential cross-section data')
../_images/2c893fa1420f0a3b9e967842a2e05e7b4d43310bd03655bf70564088eba6ea52.png

Build CalibrationConfig#

We use TruncatedNormalPrior here to enforce physical constraints.

prior_mean = np.array(
    [50.0, 3.0, 1.2 * 40 ** (1 / 3), 0.65, 18.0, 1.2 * 40 ** (1 / 3), 0.65]
)
prior_std = np.array([7.0, 7.0, 0.5, 0.20, 10.0, 0.5, 0.20])
lower = np.zeros_like(prior_mean)
upper = prior_mean + 10 * prior_std

prior = TruncatedNormalPrior(
    mu=prior_mean,
    sigma=prior_std,
    lower=lower,
    upper=upper,
    seed=0,
)

model_config = ParameterConfig(
    params=omp.params,
    prior=prior,
    initial_proposal_distribution=prior,
)

constraint = rxmc.constraint.Constraint(
    observations=[obs],
    physical_model=omp,
    likelihood_model=rxmc.likelihood_model.LikelihoodModel(),
)
evidence = rxmc.evidence.Evidence(constraints=[constraint])

config = CalibrationConfig(evidence=evidence, model_config=model_config)

print("ndim:", config.ndim)
print("parameters:", config.parameter_names)
print("prior objects:", [type(p).__name__ for p in config.prior])
ndim: 7
parameters: ['Vv', 'Wv', 'Rv', 'av', 'Wd', 'Rd', 'ad']
prior objects: ['TruncatedNormalPrior']

emcee: ensemble MCMC#

CalibrationConfig.log_posterior is passed directly as the log-probability function; CalibrationConfig.starting_location generates the initial walker positions by drawing from the proposal distribution.

nwalkers = 32
nsteps = 5000

p0 = config.starting_location(nwalkers)

sampler_emcee = emcee.EnsembleSampler(
    nwalkers,
    config.ndim,
    config.log_posterior,
)
state = sampler_emcee.run_mcmc(p0, nsteps, progress=True)
100%|██████████| 5000/5000 [05:03<00:00, 16.47it/s]
tau = sampler_emcee.get_autocorr_time(quiet=True)
burnin = int(2 * np.max(tau))
thin = max(1, int(0.5 * np.min(tau)))
flat_emcee = sampler_emcee.get_chain(discard=burnin, thin=thin, flat=True)

print(f"Autocorrelation times: {np.round(tau, 1)}")
print(f"Burn-in: {burnin}  thin: {thin}")
print(f"Posterior samples: {len(flat_emcee)}")
print(f"Mean acceptance fraction: {sampler_emcee.acceptance_fraction.mean():.3f}")
The chain is shorter than 50 times the integrated autocorrelation time for 7 parameter(s). Use this estimate with caution and run a longer chain!
N/50 = 100;
tau: [200.51739056 200.00513277 183.65262029 143.22836146 211.13930814
 166.72607996 197.35966807]
Autocorrelation times: [200.5 200.  183.7 143.2 211.1 166.7 197.4]
Burn-in: 422  thin: 71
Posterior samples: 2048
Mean acceptance fraction: 0.320
chain = sampler_emcee.get_chain()
logp_chain = sampler_emcee.get_log_prob()

fig, axes = plt.subplots(config.ndim + 1, 1, figsize=(10, 9), sharex=True)
for i, (ax, name) in enumerate(zip(axes[:-1], config.parameter_names)):
    ax.plot(chain[:, :, i], alpha=0.3, lw=0.6, color="tab:blue")
    ax.axhline(true_params[i], color="r", lw=1.5, linestyle="--")
    ax.set_ylabel(name)
axes[-1].plot(logp_chain, alpha=0.3, lw=0.6, color="tab:gray")
axes[-1].set_ylabel(r"$\log p$")
axes[-1].set_xlabel("step")
fig.suptitle("emcee chains (red dashes = truth)")
fig.tight_layout()
../_images/26d5218dc881270fd6a949ced322495a74f537885cc86cd42a6a1640bc8a5a0a.png

dynesty: nested sampling#

CalibrationConfig.log_likelihood and CalibrationConfig.prior_transform provide the two functions dynesty needs.

sampler_dyn = dynesty.NestedSampler(
    config.log_likelihood,
    config.prior_transform,
    config.ndim,
    nlive=200,
    sample="rwalk",
)
sampler_dyn.run_nested(dlogz=0.5, print_progress=True)
2813it [03:14, 14.46it/s, +200 | bound: 12 | nc: 1 | ncall: 64251 | eff(%):  4.704 | loglstar:   -inf < 132.820 <    inf | logz: 119.434 +/-  0.254 | dlogz:  0.003 >  0.500]
results_dyn = sampler_dyn.results
flat_dynesty = results_dyn.samples_equal()

print(f"Posterior samples: {len(flat_dynesty)}")
print(f"log Z = {results_dyn.logz[-1]:.2f} ± {results_dyn.logzerr[-1]:.2f}")
print(f"Efficiency: {results_dyn.eff:.2f} %")
Posterior samples: 3013
log Z = 119.43 ± 0.38
Efficiency: 4.70 %

Comparing posteriors#

Corner plot overlaying the prior (orange), emcee posterior (blue), and dynesty posterior (green). Red dashed lines mark the true parameter values used to generate the mock data.

n_corner = 2000
rng_plot = np.random.default_rng(1)
prior_samples = prior.rvs(n_corner)
idx_e = rng_plot.choice(len(flat_emcee), min(n_corner, len(flat_emcee)), replace=False)
idx_d = rng_plot.choice(
    len(flat_dynesty), min(n_corner, len(flat_dynesty)), replace=False
)

fig = plt.figure(figsize=(8, 8))


def corner_kwargs(color, alpha=0.4):
    return dict(
        labels=config.parameter_names,
        truths=true_params,
        truth_color="red",
        label_kwargs={"fontsize": 11},
        show_titles=True,
        plot_datapoints=False,
        plot_density=False,
        plot_contours=True,
        fill_contours=True,
        no_fill_contours=False,
        contour_kwargs={
            "colors": color,
            "linewidths": 1.5,
            "alpha": alpha,
        },
        hist_kwargs={
            "density": True,
            "histtype": "stepfilled",
            "alpha": alpha,
            "color": color,
            "edgecolor": "k",
        },
        labelpad=0.4,
    )


corner.corner(
    flat_emcee[idx_e],
    fig=fig,
    **corner_kwargs("tab:green"),
)
corner.corner(
    flat_dynesty[idx_d],
    fig=fig,
    **corner_kwargs("tab:orange"),
)
corner.corner(
    prior_samples,
    fig=fig,
    **corner_kwargs("tab:blue", alpha=0.2),
)

# Legend
from matplotlib.lines import Line2D

handles = [
    Line2D([0], [0], color="tab:green", label="emcee"),
    Line2D([0], [0], color="tab:orange", label="dynesty"),
    Line2D([0], [0], color="tab:blue", label="prior"),
]
fig.legend(handles=handles, loc="upper right", fontsize=12)
fig.suptitle(r"Posterior comparison: n + $^{40}$Ca OMP calibration", y=1.01)
Text(0.5, 1.01, 'Posterior comparison: n + $^{40}$Ca OMP calibration')
../_images/90723977214849172e751abe3a943b6a7c1443ccf6a7c62b42746b8c7eda08d8.png

While Markov-chain based samplers like the one in emcee are typically great, nested sampling is often better suited when the posterior has multi-modality. Here, we can see in the emcee posterior, that different modes appear, which indicates that convergence was poor.

Comparing predictive posteriors#

We propagate 200 posterior samples from each method through the model and show the 5th–95th percentile predictive band.

n_pred = 200
rng_pred = np.random.default_rng(2)

idx_e_pred = rng_pred.choice(len(flat_emcee), n_pred, replace=False)
idx_d_pred = rng_pred.choice(len(flat_dynesty), n_pred, replace=False)

y_pred_emcee = np.array(
    [omp.visualizable_model_prediction(obs, *s) for s in flat_emcee[idx_e_pred]]
)
y_pred_dynesty = np.array(
    [omp.visualizable_model_prediction(obs, *s) for s in flat_dynesty[idx_d_pred]]
)
y_true_vis = omp.visualizable_model_prediction(obs, *true_params)

angles_plot = np.rad2deg(obs.visualization_workspace.angles)
fig, ax = plt.subplots(figsize=(8, 4))

ax.errorbar(
    np.rad2deg(obs.x),
    obs.y,
    yerr=obs.y_stat_err,
    fmt="ko",
    label="Mock data",
    zorder=5,
)
ax.plot(angles_plot, y_true_vis, "k--", lw=2, label="Truth")

for y_pred, label, color in [
    (y_pred_emcee, "emcee 90%", "tab:green"),
    (y_pred_dynesty, "dynesty 90%", "tab:orange"),
]:
    lo, hi = np.percentile(y_pred, [5, 95], axis=0)
    ax.fill_between(angles_plot, lo, hi, alpha=0.4, color=color, label=label)

ax.set_yscale("log")
ax.set_xlabel(r"$\theta$ (deg)")
ax.set_ylabel(r"$d\sigma/d\Omega$ (b/sr)")
ax.legend()
ax.set_title(r"Predictive posterior: $n + {}^{40}$Ca")
fig.tight_layout()
../_images/9facc790c021458f328ce55be76e893185ee6ff858ecb7c5b0592c14c629c10e.png