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()
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>
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()