jitr Quickstart: \(\alpha\) elastic scattering on \(^{48}\)Ca at 29 MeV

We will

  • compile a solver for a particular reaction and kinematics

  • define a parametric potential and generate samples of the interaction parameters

  • perform a Bayesian calibration of the parameters to experimental data from EXFOR

1import numpy as np
2from matplotlib import pyplot as plt
3from scipy import stats

Read in data

1import pandas as pd
2
3df = pd.read_csv(
4    "./alpha_ca48_ratio_ruth.txt", names=["angle", "xs", "xs_err"], skiprows=1
5)
1df.head()
angle xs xs_err
0 14.718 0.57851 0.030926
1 15.562 0.50721 0.027360
2 15.985 0.46463 0.025232
3 16.408 0.41639 0.022819
4 18.088 0.46469 0.025234
1x = df["angle"].to_numpy()
2y = df["xs"].to_numpy()
3y_err = df["xs_err"].to_numpy()

Set up reaction and compile solver

 1from jitr.reactions.reaction import Reaction
 2from jitr.rmatrix import Solver as SolverKernel
 3from jitr.utils import utils
 4from jitr.xs import elastic
 5
 6# define reaction system
 7alpha = (4, 2)
 8Ca48 = (48, 20)
 9reaction = Reaction(target=Ca48, projectile=alpha, process="El")
10
11# calculate kinematics for a given lab energy
12energy_lab = 28.2
13kinematics = reaction.kinematics(energy_lab)
14
15# set the channel radius, number of nodes, and number of partial waves
16interaction_range_fm = 1.2 * (48 ** (1 / 3) + 4 ** (1 / 3)) + 2
17channel_radius_dimensionless = utils.suggested_dimensionless_channel_radius(
18    interaction_range_fm, kinematics.k
19)
20channel_radius = channel_radius_dimensionless / kinematics.k
21N = utils.suggested_basis_size(channel_radius_dimensionless)
22lmax = 180
23
24# build a solver for the system and reaction of interest
25print(f"Compiling solver for {reaction} at {energy_lab} MeV")
26print(f" - channel radius {channel_radius:1.2f} fm")
27print(f" - {N} nodes")
28print(f" - {lmax} partial waves")
29
30solver = elastic.DifferentialWorkspace.build_from_system(
31    reaction=reaction,
32    kinematics=kinematics,
33    channel_radius_fm=channel_radius,
34    solver=SolverKernel(N),
35    lmax=lmax,
36    angles=np.deg2rad(x),
37)
38rgrid = solver.radial_grid()
39# jit warmup
40_ = solver.xs(central_potential=np.zeros_like(solver.radial_grid()))
41print("Done!")
Compiling solver for 48-Ca(alpha,el) at 28.2 MeV
 - channel radius 11.19 fm
 - 40 nodes
 - 180 partial waves
Done!

Define the interaction model and parameters

1from jitr.optical_potentials.potential_forms import (
2    coulomb_charged_sphere as coulomb,
3)
4from jitr.optical_potentials.potential_forms import (
5    woods_saxon_safe as ws,
6)
1def U_central(r, Vv, Wv, Rv, av, Rw, aw):
2    return -Vv * ws(r, Rv, av) - 1j * Wv * ws(r, Rw, aw)
3
4
5def V_Coulomb(r, Zz, RC):
6    return coulomb(r, Zz, RC)
 1def calculate_xs_ratio(theta):
 2    Vv, Wv, rv, av, rw, aw = theta
 3    A_factor = reaction.target.A ** (1 / 3)
 4    Zz = reaction.target.Z * reaction.projectile.Z
 5    xs = solver.xs(
 6        central_potential=U_central(
 7            rgrid,
 8            Vv,
 9            Wv,
10            rv * A_factor,
11            av,
12            rw * A_factor,
13            aw,
14        ),
15        coulomb_potential=V_Coulomb(rgrid, Zz, 1.3 * A_factor),
16    )
17    return xs.dsdo / solver.rutherford

Comparison to data

1log_err_term = np.sum(np.log(2 * np.pi * y_err))
2
3
4def log_likelihood(theta):
5    y_pred = calculate_xs_ratio(theta)
6    chi2 = np.sum((y_pred - y) ** 2 / y_err * 2)
7    logl = -0.5 * (chi2 + log_err_term)
8    return logl
1theta_0 = np.array([185, 25, 1.4, 0.5, 1.4, 0.4])
2log_likelihood(theta_0)
np.float64(312.92853381102304)
 1theta_bounds = np.array(
 2    [
 3        [120, 250],
 4        [5, 50],
 5        [1.15, 1.6],
 6        [0.4, 0.8],
 7        [1.1, 1.8],
 8        [0.4, 0.9],
 9    ]
10)

Maximum likelihood estimation (MLE)

Now that we have the solver compiled, fitting to data is easy. Notice that, since y_err is fixed and there is no correlation in the errors, the MLE in this case is in fact identical to the \(\chi^2\)-minimizing solution.

1from scipy.optimize import minimize
 1%%time
 2optimum = minimize(
 3    lambda x: -log_likelihood(x),
 4    theta_0,
 5    method="Nelder-Mead",
 6    bounds=theta_bounds,
 7    options={
 8        "maxiter": 5000,
 9        "adaptive": True,
10    },
11)
12optimum
CPU times: user 4.4 s, sys: 1.12 ms, total: 4.41 s
Wall time: 4.41 s
       message: Optimization terminated successfully.
       success: True
        status: 0
           fun: -540.6501336201458
             x: [ 1.806e+02  2.610e+01  1.509e+00  5.208e-01  1.401e+00
                  4.000e-01]
           nit: 452
          nfev: 775
 final_simplex: (array([[ 1.806e+02,  2.610e+01, ...,  1.401e+00,
                         4.000e-01],
                       [ 1.806e+02,  2.610e+01, ...,  1.401e+00,
                         4.000e-01],
                       ...,
                       [ 1.806e+02,  2.610e+01, ...,  1.401e+00,
                         4.000e-01],
                       [ 1.806e+02,  2.610e+01, ...,  1.401e+00,
                         4.000e-01]], shape=(7, 6)), array([-5.407e+02, -5.407e+02, -5.407e+02, -5.407e+02,
                       -5.407e+02, -5.407e+02, -5.407e+02]))
1y_mlem = calculate_xs_ratio(optimum.x)
 1plt.figure()
 2plt.errorbar(df["angle"], df["xs"], y_err, color="k", marker=".", linestyle="none")
 3plt.plot(df["angle"], y_mlem, color="r", label="MLE")
 4plt.yscale("log")
 5plt.legend()
 6plt.xlabel(r"$\theta$ [deg]")
 7plt.ylabel(r"$(\frac{d\sigma}{d\Omega}) / (\frac{d\sigma}{d\Omega})_{R}$")
 8plt.title(f"${reaction.reaction_latex}$ at {kinematics.Elab:1.0f} MeV")
 9plt.tight_layout()
10plt.show()
../../_images/7aec595c5ee1dbbbe09f51bb1e48456ad6044d4de194f2a82dd3b4993d229781.png

Not a bad result! Notice, however, the degradation of the quality of the fit at backwards angles. This is due to model discrepancy - the simple 6 parameter model we’re using is not flexible enough to resolve the full reaction dynamics.

Bayesian calibration

To do Bayesian calibration, we must first define a prior.

1prior_means = theta_0
2prior_std_devs = np.array([40, 10, 0.1, 0.1, 0.2, 0.2])
3prior = stats.multivariate_normal(
4    mean=prior_means,
5    cov=np.diag(prior_std_devs) ** 2,
6)

Sampling from the posterior distribution

There are many open source sampler and calibration libraries out there in Python. In this example, we will use a neat one: dynesty. Rather than the usual Markov-Chain Monte Carlo, this does something called nested sampling.

Note that this should take 5-10 minutes to converge.

1import dynesty
1sampler_dyn = dynesty.NestedSampler(
2    log_likelihood,
3    lambda x: stats.norm.ppf(x, loc=prior_means, scale=prior_std_devs),
4    prior_means.size,
5    nlive=200,
6    sample="rwalk",
7)
8sampler_dyn.run_nested(dlogz=1.0, print_progress=False)
1results_dyn = sampler_dyn.results
2flat_dynesty = results_dyn.samples_equal()
3
4print(f"Posterior samples: {len(flat_dynesty)}")
5print(f"log Z = {results_dyn.logz[-1]:.2f} ± {results_dyn.logzerr[-1]:.2f}")
6print(f"Efficiency: {results_dyn.eff:.2f} %")
Posterior samples: 3201
log Z = 530.35 ± 0.55
Efficiency: 4.80 %

Plot posterior distribution

1import corner
2from matplotlib.lines import Line2D
 1n_corner = 2000
 2prior_samples = prior.rvs(n_corner)
 3idx_d = np.random.default_rng(42).choice(
 4    len(flat_dynesty), min(n_corner, len(flat_dynesty)), replace=False
 5)
 6
 7fig = plt.figure(figsize=(8, 8))
 8
 9
10def corner_kwargs(color, alpha=0.4):
11    return dict(
12        labels=[
13            r"$V_v$",
14            r"$W_v$",
15            r"$r_v$",
16            r"$a_v$",
17            r"$r_w$",
18            r"$a_w$",
19            "$r_C$",
20        ],
21        label_kwargs={"fontsize": 11},
22        show_titles=True,
23        plot_datapoints=False,
24        plot_density=False,
25        plot_contours=True,
26        fill_contours=True,
27        no_fill_contours=False,
28        contour_kwargs={
29            "colors": color,
30            "linewidths": 1.5,
31            "alpha": alpha,
32        },
33        hist_kwargs={
34            "density": True,
35            "histtype": "stepfilled",
36            "alpha": alpha,
37            "color": color,
38            "edgecolor": "k",
39        },
40        labelpad=0.4,
41    )
42
43
44corner.corner(
45    prior_samples,
46    fig=fig,
47    **corner_kwargs("tab:blue", alpha=0.3),
48)
49corner.corner(
50    flat_dynesty[idx_d],
51    fig=fig,
52    **corner_kwargs("tab:orange"),
53)
54
55handles = [
56    Line2D([0], [0], color="tab:orange", label="posterior"),
57    Line2D([0], [0], color="tab:blue", label="prior"),
58]
59fig.legend(handles=handles, loc="upper right", fontsize=12)
<matplotlib.legend.Legend at 0x726b9c55ce60>
../../_images/bdc8d212d3754617b96dd324d43c993229ef1ec24b8448ab2a298ca0d7282939.png

Voila! Notice the famous discrete ambiguity in \(V_v\). The real depth can take values of \(\approx\)100, 135, 165, 185, 220… MeV, each of which adds an interior node to the scattering wavefunction. Because this effect is roughly the same across all partial waves, the predicted cross sections are roughly the same for each value. This is one of several well known “parameter ambiguities” observed when fitting optical potentials, and is discussed in (among other places) this 1971 review paper by Hodgson.

In a Bayesian context, we recognize that these so-called ambiguities are really posterior multimodalities. If one has a good reason to choose a particular mode (for example, microscopic esimtates of \(V_v\) for \(\alpha\) + \(^{48}\)Ca give \(\sim 185\) MeV), then one can narrow their prior to focus on that mode. In this example, we intentionally kept the prior broad to exhibit this multimodality. We also intentionally chose to use nested sampling, as one of its advantages is graceful handling of multi-modal posteriors.

Calculate predictive posterior

1n_pred = 500
2idx_d_pred = np.random.default_rng(43).choice(len(flat_dynesty), n_pred, replace=False)
3y_pred_dynesty = np.array([calculate_xs_ratio(s) for s in flat_dynesty[idx_d_pred]])

Plot predictive posterior distribution

We will plot the inner 90% credible interval (CI) of the posterior predictive distribution.

 1plt.figure()
 2plt.errorbar(df["angle"], df["xs"], y_err, color="k", marker=".", linestyle="none")
 3plt.plot(df["angle"], y_mlem, alpha=0.5, color="r", label="MLEM")
 4plt.fill_between(
 5    df["angle"],
 6    *np.percentile(y_pred_dynesty, [5, 95], axis=0),
 7    color="tab:blue",
 8    alpha=0.4,
 9    label="Posterior 90% CI",
10)
11plt.yscale("log")
12plt.legend()
13plt.xlabel(r"$\theta$ [deg]")
14plt.ylabel(r"$(\frac{d\sigma}{d\Omega}) / (\frac{d\sigma}{d\Omega})_{R}$")
15plt.title(f"${reaction.reaction_latex}$ at {kinematics.Elab:1.0f} MeV")
16plt.tight_layout()
17plt.show()
../../_images/d5e1ce6077fd9d792d0273fb3caa5fe7146ca843c5425321cfe7f283301d5e1d.png