Colab runs this notebook with JITR_QUICK=1 – fewer data points and live points – so the posterior is coarser than the one rendered in the documentation. Drop that line from the setup cell, on a runtime you can keep alive, to reproduce the full result.
Bayesian calibration: \(\alpha\) + \(^{40,44,48}\)Ca at 29 MeV¶
This notebook follows quickstart, which fit a single optical potential to \(\alpha\) + \(^{44}\)Ca.
Here, we will go further, performing a full Bayesian calibration of the same optical model potential to \(\alpha\) + \(^{40,44,48}\)Ca at 29 MeV. Again, we use the Oeschler, 1972 (EXFOR F0567), a very interesting paper which notices that the large-angle behavior of the elastic scattering cross section for \(\alpha\)-particles strongly depends on the shell structure of the target. Targets which can form a stable, closed shell compound nucleus by absorbing an \(\alpha\) show cross sections we would typically attribute to an absorptive optical potential - strongly decreasing at large angles. On the other hand, closed shell targets showed enhanced elastic cross sections at large angles, consistent with very little absorption.
We will investigate this trend by inferring posterior distributions of the optical model parameters, given the experimental data from Oeschler, 1972. Based on this, we will be able to make uncertainty quantified statements about this physics trend. Along the way, we will learn the basics of Bayesian calibration of optical potentials using jitr.
Oeschler, 1972 reports no experimental uncertainties, so we will have to infer the errors along with the potential. For this, we will assume a Gaussian model-data defect, and use a simple, two-term covariance which makes few assumptions.
As in the quickstart, we will first set up the reaction, solver and potential. Then, we will
write down our two-parameter error model and put priors on everything
sample the posterior for each isotope using nested sampling
compare the potentials between isotopes
check the posterior predictive distribution against the data using credible interval bands and empirical coverage
This is the second notebook in a series of three: the quickstart introduced the reaction and a least-squares fit, this notebook performs the full calibration around a single mode for each isotope, and alpha_ca48_ambiguity.ipynb follows up by mapping the discrete ambiguity in the \(^{48}\)Ca potential that the prior here deliberately resolves.
Running the Bayesian model calibration for three isotopes, with wide priors, will take much longer than the single least-squares optimization problem of the quickstart, expect this whole notebook to take \(\sim\) 60 minutes
1import os
2
3# keep every process single-threaded: we will parallelize over likelihood
4# evaluations with multiprocessing below, and nested BLAS/numba threads would
5# oversubscribe the CPU. Must be set before numpy/jitr are imported.
6os.environ.setdefault("OMP_NUM_THREADS", "1")
7os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
8os.environ.setdefault("MKL_NUM_THREADS", "1")
9os.environ.setdefault("NUMBA_NUM_THREADS", "1")
10
11import numpy as np
12import pandas as pd
13from matplotlib import pyplot as plt
14from scipy import stats
15
16# JITR_QUICK=1 runs a cut-down version of the notebook (fewer data points and live points) for CI
17QUICK = os.environ.get("JITR_QUICK", "0") == "1"
Set up the reaction¶
1from jitr.reactions.reaction import Reaction
2
3targets = {"Ca40": (40, 20), "Ca44": (44, 20), "Ca48": (48, 20)}
4alpha = (4, 2)
5reactions = {
6 key: Reaction(target=target, projectile=alpha, process="El")
7 for key, target in targets.items()
8}
9
10# calculate kinematics for a given lab energy
11energy_lab = 29
12kinematics = {
13 key: reaction.kinematics(energy_lab) for key, reaction in reactions.items()
14}
Read in data¶
The data file contains the ratio of the elastic differential cross section to Rutherford for the three targets, digitized from Fig. 1 of Oeschler et al..
1df = pd.read_csv("./alpha_ca_ratio_ruth.csv")
2if QUICK:
3 df = df.iloc[::4]
4data = {
5 key: {
6 "x": group["angle_cm_deg"].to_numpy(),
7 "y": group["ratio_to_rutherford"].to_numpy(),
8 }
9 for key, group in df.groupby("target")
10}
11colors = {"Ca40": "#109618", "Ca44": "#3366CC", "Ca48": "#FF9900"}
12isotope_label = {key: rf"$^{{{A}}}$Ca" for key, (A, Z) in targets.items()}
1plt.figure(figsize=(6, 4))
2for key, dataset in data.items():
3 plt.plot(
4 dataset["x"],
5 dataset["y"],
6 linestyle="none",
7 marker=".",
8 label=isotope_label[key],
9 color=colors[key],
10 )
11
12plt.yscale("log")
13plt.legend(ncol=3, framealpha=0)
14plt.xlabel(r"$\theta_{cm}$ [deg]")
15plt.ylabel(r"$(\frac{d\sigma}{d\Omega}) / (\frac{d\sigma}{d\Omega})_\text{Rutherford}$")
16plt.title(r"$^{A}$Ca($\alpha,\alpha$) at 29 MeV")
17plt.tight_layout()
18plt.show()
Notice the experimental evidence for trend highlighted in the paper - closed shell \(^{48}\)Ca and expecially \(^{40}\)Ca have very enhanced elastic cross sections at backwards angles, relative to the mid-shell \(^{44}\)Ca.
Compile the solver¶
Each target gets its own solver, evaluated on the angles at which that target was measured. Building a solver compiles code, so we do it once here and keep the objects around.
1from jitr.rmatrix import Solver as SolverKernel
2from jitr.utils import utils
3from jitr.xs import elastic
4
5lmax = 60
6
7
8def build_solver(key, angles_deg):
9 A, Z = targets[key]
10 interaction_range_fm = 1.2 * (A ** (1 / 3) + 4 ** (1 / 3)) + 2
11 channel_radius_dimensionless = utils.suggested_dimensionless_channel_radius(
12 interaction_range_fm, kinematics[key].k
13 )
14 channel_radius = channel_radius_dimensionless / kinematics[key].k
15 N = utils.suggested_basis_size(channel_radius_dimensionless, zeros_per_node=8)
16 print(
17 f"Compiling solver for {reactions[key]} at {energy_lab} MeV: "
18 f"channel radius {channel_radius:1.2f} fm, {N} nodes, {lmax} partial waves"
19 )
20 return elastic.DifferentialWorkspace.build_from_system(
21 reaction=reactions[key],
22 kinematics=kinematics[key],
23 channel_radius_fm=channel_radius,
24 solver=SolverKernel(N),
25 lmax=lmax,
26 angles=np.deg2rad(angles_deg),
27 )
28
29
30solvers = {key: build_solver(key, data[key]["x"]) for key in targets}
Compiling solver for 40-Ca(alpha,el) at 29 MeV: channel radius 10.94 fm, 64 nodes, 60 partial waves
Compiling solver for 44-Ca(alpha,el) at 29 MeV: channel radius 11.05 fm, 64 nodes, 60 partial waves
Compiling solver for 48-Ca(alpha,el) at 29 MeV: channel radius 11.15 fm, 64 nodes, 60 partial waves
1# jit warmup - do this before forking worker processes later so they inherit compiled code
2for solver in solvers.values():
3 _ = solver.xs(central_potential=np.zeros_like(solver.radial_grid()))
Define the interaction model and parameters¶
A real volume Woods-Saxon, an imaginary volume term and an imaginary surface term, plus the Coulomb potential of a uniformly charged sphere. The seven free parameters are the depths \(V\), \(W_v\), \(W_s\) and the geometry \(r_v, a_v, r_w, a_w\) (radii in units of \(A^{1/3}\) fm; the surface term shares the imaginary geometry).
1from jitr.optical_potentials.potential_forms import (
2 coulomb_charged_sphere as coulomb,
3)
4from jitr.optical_potentials.potential_forms import (
5 woods_saxon_prime_safe as wsp,
6)
7from jitr.optical_potentials.potential_forms import (
8 woods_saxon_safe as ws,
9)
10
11
12def U_central(r, Vv, Wv, Ws, Rv, av, Rw, aw):
13 return (
14 -Vv * ws(r, Rv, av)
15 - 1j * Wv * ws(r, Rw, aw)
16 - 1j * Ws * (-4 * aw) * wsp(r, Rw, aw)
17 )
18
19
20def calculate_xs_ratio(theta, key):
21 """Ratio to Rutherford on the data angles of `key` for optical model parameters `theta`."""
22 solver = solvers[key]
23 reaction = reactions[key]
24 # each solver has its own radial quadrature grid (it depends on the channel radius)
25 rgrid = solver.radial_grid()
26 Vv, Wv, Ws, rv, av, rw, aw = theta
27 A_factor = reaction.target.A ** (1 / 3)
28 Zz = reaction.target.Z * reaction.projectile.Z
29 xs = solver.xs(
30 central_potential=U_central(
31 rgrid, Vv, Wv, Ws, rv * A_factor, av, rw * A_factor, aw
32 ),
33 coulomb_potential=coulomb(rgrid, Zz, 1.3 * A_factor),
34 )
35 return xs.dsdo / solver.rutherford
36
37
38names = ["Vv", "Wv", "Ws", "rv", "av", "rw", "aw"]
39labels = [r"$V$", r"$W_v$", r"$W_s$", r"$r_v$", r"$a_v$", r"$r_w$", r"$a_w$"]
40n_omp = len(names)
The error model¶
We do not know the experimental uncertainties, and the optical model is not a perfect description of the reaction, so we need a model for the residuals \(\epsilon_i \equiv y_i - f(\theta_i;\alpha)\), where \(f(\theta;\alpha) \equiv f_i\) is the model prediction at angle \(\theta_i^{cm}\), for OMP parameters \(\alpha\).
We put a multivariate Gaussian prior on the residuals, \(\boldsymbol{\epsilon} | b , s \sim \mathcal{N}(0,\boldsymbol{\Sigma}(b,s))\) with covariance
introducing two unknown parameters: \(s\) is a relative error, uncorrelated from point to point, and \(b\) is a relative error common to all points. The former acts like a relative statistical uncertainty, the latter like an uncertainty in the overall normalization, or more generally a coherent misfit of the model.
The data span four decades and the natural errors are multiplicative, so we evaluate the likelihood in log space, \(r_i = \log y_i - \log f_i\). Take the two error sources to be independent lognormal factors, so we have \(y_i = f_i\, A_i\, B\) with:
\(\mathbb{E} \left[ A_i \right] = \mathbb{E} \left[ B \right] = 1\),
\(\mathrm{Var}[B] = b^2\)
\(\mathrm{Var}[A_i] = \frac{s^2}{1+b^2}\) - the same for every point,
which reproduces \(\Sigma\) in linear space exactly:
and gives a homeoscedastic (the same at all points \(x_i\)) error model in log space
Such a diagonal-plus-rank-one matrix can be inverted with the Sherman-Morrison formula, and its determinant follows from the matrix determinant lemma, so the likelihood costs \(O(N)\) - no dense solve required.
The log-likelihood is therefore
The last term is the Jacobian that makes this a density for the data \(y\) rather than for \(\log y\). It is a constant (not \(\alpha\), \(b\), or \(s\) dependent) here, but keeping it makes the evidence comparable to a linear-space likelihood.
1ERR_NAMES = ["log_s", "log_b"]
2ERR_LABELS = [r"$s$", r"$b$"]
3n_err = len(ERR_NAMES)
4ndim = n_omp + n_err
5all_labels = labels + ERR_LABELS
6
7
8def unpack(theta):
9 return theta[:n_omp], np.exp(theta[n_omp:]) # potential parameters, (s, b)
10
11
12def log_likelihood(theta, key):
13 omp, (s, b) = unpack(theta)
14 y = data[key]["y"]
15 if np.any(omp[3:] <= 0): # radii and diffusenesses must be positive
16 return -1e300
17 f_pred = calculate_xs_ratio(omp, key) # f_i = f(theta_i; alpha)
18 if np.any(f_pred <= 0) or not np.all(np.isfinite(f_pred)):
19 return -1e300
20 log_residuals = np.log(y) - np.log(f_pred) # r_i = log y_i - log f_i
21 n_pts = log_residuals.size
22
23 # Sigma^log = d I + beta 11^T - homoscedastic, because both error terms are relative
24 common_log_var = np.log1p(b**2) # beta, added to every entry by the shared factor B
25 point_log_var = np.log1p(s**2 / (1 + b**2)) # d, from the independent factors A_i
26
27 # Sherman-Morrison:
28 # r^T (Sigma^log)^-1 r = (r^T r)/d - beta (1^T r)^2 / (d (d + N beta))
29 shared_term_denom = point_log_var + n_pts * common_log_var # d + N beta
30 mahalanobis = (
31 log_residuals @ log_residuals / point_log_var
32 - common_log_var
33 * np.sum(log_residuals) ** 2
34 / (point_log_var * shared_term_denom)
35 )
36 # matrix determinant lemma: det(Sigma^log) = d^(N-1) (d + N beta)
37 log_det = (n_pts - 1) * np.log(point_log_var) + np.log(shared_term_denom)
38
39 jacobian = -np.sum(np.log(y)) # density for y, not log y
40 return -0.5 * (mahalanobis + log_det + n_pts * np.log(2 * np.pi)) + jacobian
41
42
43def predictive_draw(s, b, f_pred, rng):
44 """One synthetic data set drawn from the error model around the model prediction f_pred."""
45 # log y_i = log f_i + independent relative scatter (A_i) + one shared relative shift (B)
46 log_noise = (
47 np.sqrt(np.log1p(s**2 / (1 + b**2))) * rng.normal(size=f_pred.shape)
48 + np.sqrt(np.log1p(b**2)) * rng.normal()
49 )
50 return f_pred * np.exp(log_noise)
Prior¶
We take, for each isotope, a Gaussian prior wide enough (\(\sim\)20 MeV in \(V\), 0.2 fm in the diffusenesses, 0.3 \(A^{1/3}\) fm \(\approx\) 1 fm in the radii) to let the data move the parameters freely and to admit the continuous \(V\)-\(r_v\) ambiguity, while resolving any multi-modality in \(V\). We center the prior on physically resonable numbers, which encode our physical observation that \(^{44}\)Ca is more absorbing. We will truncate the prior for each parameter at 0 on physical bounds (purely attractive and absorbing potential, and distances \(R_{v/w}\) and \(a_{v/w}\) strictly positive), but we wont force upper bounds.
For \(^{48}\)Ca we make one further choice. With a \(\sim\)20 MeV wide prior on \(V\), the posterior splits into two discrete families, one near \(V \approx 160\) MeV and one near \(V \approx 205\) MeV - the well-known discrete ambiguity of \(\alpha\)-nucleus potentials, in which depths corresponding to different numbers of interior nodes of the scattering wavefunction produce nearly identical cross sections. To make a meatballs-meatballs comparison between isotopes, we tighten the \(^{48}\)Ca prior on \(V\) to \(205 \pm 10\) MeV, selecting the deep family - the one continuously connected to the posterior of its closed-shell partner, \(^{40}\)Ca. What the data alone have to say about this choice is the subject of the follow-on notebook, alpha_ca48_ambiguity.ipynb.
The error scales are unknown by orders of magnitude, so they get wide log-uniform priors centered at 5% and are sampled as logarithms.
1prior_center = {
2 "Ca40": np.array([160, 15, 10, 1.4, 0.5, 1.4, 0.3]),
3 "Ca44": np.array([150, 20, 20, 1.4, 0.5, 1.4, 0.3]),
4 "Ca48": np.array([205, 15, 10, 1.4, 0.5, 1.4, 0.3]),
5}
6prior_std = {
7 "Ca40": np.array([20, 10, 10, 0.3, 0.2, 0.3, 0.2]),
8 "Ca44": np.array([20, 10, 10, 0.3, 0.2, 0.3, 0.2]),
9 # tight in V: select the deep family of the discrete ambiguity (see above)
10 "Ca48": np.array([10, 10, 10, 0.3, 0.2, 0.3, 0.2]),
11}
12
13omp_prior = {
14 key: stats.truncnorm(
15 a=-center / prior_std[key], b=np.inf, loc=center, scale=prior_std[key]
16 )
17 for key, center in prior_center.items()
18}
19
20err_prior = [
21 stats.loguniform(5e-2, 2.0), # s: relative, point-to-point independent error
22 stats.loguniform(5e-2, 2.0), # b: relative, fully correlated error
23]
24
25
26def prior_transform(u, key):
27 omp = omp_prior[key].ppf(u[:n_omp])
28 err = [np.log(p.ppf(ui)) for p, ui in zip(err_prior, u[n_omp:], strict=True)]
29 return np.concatenate([omp, err])
Sampling the posterior¶
There are many third party libraries that can sample a posterior distribution given a likeihood and prior. We will use a neat one: dynesty, which does something called nested sampling, which has the advantages that it
naturally handles multimodal posteriors
computes the Bayesian evidence along with the prior for free
Each likelihood evaluation is an R-matrix solve, a few milliseconds, and independent of the others, so we hand dynesty a multiprocessing pool (fork so that the workers inherit the compiled solvers; Linux/macOS only). Expect \(\sim\) 20 minutes per isotope with 4 independent processes.
1import multiprocessing
2from functools import partial
3
4import dynesty
5
6n_workers = min(4, os.cpu_count())
7nlive = 50 if QUICK else 250
8dlogz = 5.0 if QUICK else 1.0
9print(f"Using {n_workers} worker processes, nlive={nlive}, dlogz={dlogz}")
Using 4 worker processes, nlive=250, dlogz=1.0
1%%time
2results = {}
3with multiprocessing.get_context("fork").Pool(n_workers) as pool:
4 for key in targets:
5 sampler = dynesty.NestedSampler(
6 partial(log_likelihood, key=key),
7 partial(prior_transform, key=key),
8 ndim,
9 nlive=nlive,
10 sample="rwalk",
11 pool=pool,
12 queue_size=n_workers,
13 rstate=np.random.default_rng(0),
14 )
15 sampler.run_nested(dlogz=dlogz, print_progress=False)
16 results[key] = sampler.results
17 print(
18 f"{key}: log Z = {results[key].logz[-1]:.2f} +/- {results[key].logzerr[-1]:.2f}, "
19 f"{int(np.sum(results[key].ncall))} calls, efficiency {results[key].eff:.1f} %"
20 )
Ca40: log Z = 813.43 +/- 0.69, 208615 calls, efficiency 3.8 %
Ca44: log Z = 924.54 +/- 0.63, 164178 calls, efficiency 3.9 %
Ca48: log Z = 873.63 +/- 0.64, 188583 calls, efficiency 3.9 %
CPU times: user 25.7 s, sys: 1.9 s, total: 27.6 s
Wall time: 50min 39s
Now, for each system, we take our posterior samples, and make draws from the posterior predictive distribution \(p(\boldsymbol{f}(\alpha) + \boldsymbol{\epsilon}(a,b) | \boldsymbol{y} )\) by pushing those optical potential samples back through jitr to get cross sections, and then through our error model.
1%%time
2n_draws = 100 if QUICK else 300
3n_noise_replicates = 4
4rng = np.random.default_rng(42)
5
6posterior_samples = {} # equally weighted samples (all)
7posterior_draws = {} # subset used for predictions
8posterior_model = {} # model curves for the subset
9posterior_predictive = {} # model + error model, for the data
10for key, res in results.items():
11 samples = res.samples_equal(rstate=rng)
12 posterior_samples[key] = samples
13 draws = samples[
14 rng.choice(len(samples), size=min(n_draws, len(samples)), replace=False)
15 ]
16 posterior_draws[key] = draws
17 posterior_model[key] = np.array(
18 [calculate_xs_ratio(unpack(s)[0], key) for s in draws]
19 )
20 posterior_predictive[key] = np.array(
21 [
22 predictive_draw(*unpack(s)[1], m, rng)
23 for _ in range(n_noise_replicates)
24 for s, m in zip(draws, posterior_model[key], strict=True)
25 ]
26 )
CPU times: user 11.3 s, sys: 5.3 ms, total: 11.3 s
Wall time: 11.3 s
Posterior distributions¶
First the seven potential parameters for the three isotopes on one corner plot, with the priors underlaid as hatched histograms on the diagonal panels. The panels are zoomed to the posteriors, so the priors appear as nearly flat slices: every parameter is likelihood-dominated. Within each isotope the expected \(V\)-\(r_v\) anticorrelation is visible. Look at the \(V\) column: for \(^{40}\)Ca the data have pulled \(V\) some 35 MeV - nearly two prior widths - away from the prior centre. Each posterior occupies a single discrete family; for \(^{48}\)Ca that is by construction, a choice examined in alpha_ca48_ambiguity.ipynb.
1import corner
2from matplotlib.colors import to_rgba
3from matplotlib.patches import Patch
4
5
6def corner_overlay(samples, labels, keys, priors=None, ranges=None, **kwargs):
7 """Overlay corner plots for several data sets with a common range.
8
9 Ranges are driven by the posteriors (central 99%), so the panels zoom in on
10 them; `priors` (dict key -> (n, ndim) sample array) are drawn on the 1D panels
11 only, as hatched histograms on the full-prior density scale - where the
12 posterior is likelihood-dominated they appear as small, nearly flat slices.
13 """
14 if ranges is None:
15 allsamples = np.vstack([samples[key] for key in keys])
16 ranges = list(
17 zip(
18 np.percentile(allsamples, 0.5, axis=0),
19 np.percentile(allsamples, 99.5, axis=0),
20 strict=True,
21 )
22 )
23 npanel = len(ranges)
24 fig = None
25 for key in keys:
26 c = colors[key]
27 fig = corner.corner(
28 samples[key],
29 labels=labels,
30 color=c,
31 fig=fig,
32 range=ranges,
33 bins=30,
34 smooth=1.0,
35 plot_datapoints=False,
36 plot_density=False,
37 levels=(0.68, 0.95),
38 fill_contours=True,
39 contourf_kwargs={
40 "colors": [(0, 0, 0, 0), to_rgba(c, 0.2), to_rgba(c, 0.45)]
41 },
42 contour_kwargs={"colors": [to_rgba(c, 1.0)], "linewidths": 1.2},
43 hist_kwargs={
44 "density": True,
45 "histtype": "stepfilled",
46 "facecolor": to_rgba(c, 0.35),
47 "edgecolor": to_rgba(c, 1.0),
48 "linewidth": 1.8,
49 },
50 **kwargs,
51 )
52 if priors is not None:
53 axes = np.array(fig.axes).reshape(npanel, npanel)
54 for key in keys:
55 c = colors[key]
56 for i in range(npanel):
57 draws = priors[key][:, i]
58 lo, hi = ranges[i]
59 counts, edges = np.histogram(draws, bins=30, range=(lo, hi))
60 density = counts / (len(draws) * np.diff(edges))
61 axes[i, i].stairs(
62 density,
63 edges,
64 fill=True,
65 facecolor=to_rgba(c, 0.08),
66 edgecolor=to_rgba(c, 0.45),
67 hatch="///",
68 linewidth=0.8,
69 )
70 handles = [
71 Patch(
72 facecolor=to_rgba(colors[key], 0.35),
73 edgecolor=colors[key],
74 linewidth=1.8,
75 label=isotope_label[key],
76 )
77 for key in keys
78 ]
79 if priors is not None:
80 handles.append(
81 Patch(
82 facecolor=to_rgba("gray", 0.08),
83 edgecolor=to_rgba("gray", 0.6),
84 hatch="///",
85 label="prior",
86 )
87 )
88 fig.legend(handles=handles, loc="upper right", fontsize=14, framealpha=0)
89 return fig
90
91
92n_prior_draws = 40000
93prior_rng = np.random.default_rng(7)
94omp_prior_draws = {
95 key: omp_prior[key].rvs((n_prior_draws, n_omp), random_state=prior_rng)
96 for key in targets
97}
98
99omp_samples = {key: posterior_samples[key][:, :n_omp] for key in targets}
100fig = corner_overlay(omp_samples, labels, list(targets), priors=omp_prior_draws)
101plt.show()
Notice that \(^{48}\)Ca has much more volume absorption than the other two, and that \(W_s\) is not constrained for that isotope.
Now let’s look at the posteriors for the error parameters (transformed back from their logarithms):
1err_prior_draws = np.column_stack(
2 [p.rvs(n_prior_draws, random_state=prior_rng) for p in err_prior]
3)
4err_samples = {key: np.exp(posterior_samples[key][:, n_omp:]) for key in targets}
5fig = corner_overlay(
6 err_samples,
7 ERR_LABELS,
8 list(targets),
9 priors={key: err_prior_draws for key in targets},
10)
11plt.show()
We now build a table of medians and central 68% intervals for everything, as well as calculate the volume integrals:
1def volume_integral_per_nucleon(samples, key):
2 """J_V / (4 A) [MeV fm^3] for each posterior sample."""
3 A = targets[key][0]
4 r = np.linspace(0, 20, 2000)
5 out = np.zeros(len(samples), dtype=complex)
6 for i, (Vv, Wv, Ws, rv, av, rw, aw) in enumerate(samples[:, :n_omp]):
7 Rv = rv * A ** (1 / 3)
8 Rw = rw * A ** (1 / 3)
9 out[i] += np.trapezoid(Vv * ws(r, Rv, av) * r**2, r)
10 out[i] += 1j * np.trapezoid(
11 (Wv * ws(r, Rw, aw) + Ws * (-4 * aw) * wsp(r, Rw, aw)) * r**2, r
12 )
13
14 return 4 * np.pi / (4 * A) * out
15
16
17jv_samples = {
18 key: volume_integral_per_nucleon(posterior_samples[key], key) for key in targets
19}
20
21
22def summarize(samples):
23 q = np.percentile(samples, [16, 50, 84], axis=0)
24 return [f"{m:.3g} [{lo:.3g}, {hi:.3g}]" for lo, m, hi in q.T]
25
26
27pd.DataFrame(
28 {
29 isotope_label[key]: summarize(
30 np.column_stack(
31 [
32 posterior_samples[key][:, :n_omp],
33 err_samples[key],
34 jv_samples[key].real,
35 jv_samples[key].imag,
36 ]
37 )
38 )
39 for key in targets
40 },
41 index=names
42 + [n.removeprefix("log_") for n in ERR_NAMES]
43 + ["J_V/4A [MeV fm^3]"]
44 + ["J_W/4A [MeV fm^3]"],
45)
| $^{40}$Ca | $^{44}$Ca | $^{48}$Ca | |
|---|---|---|---|
| Vv | 196 [192, 200] | 148 [144, 151] | 210 [209, 212] |
| Wv | 18.1 [17.4, 19.1] | 18.3 [17.6, 19] | 32 [31.1, 32.6] |
| Ws | 2.19 [0.793, 4.53] | 1.25 [0.446, 2.33] | 7.56 [2.15, 13.1] |
| rv | 1.36 [1.35, 1.38] | 1.23 [1.21, 1.25] | 1.31 [1.3, 1.31] |
| av | 0.503 [0.49, 0.517] | 0.715 [0.702, 0.73] | 0.555 [0.552, 0.559] |
| rw | 1.5 [1.48, 1.53] | 1.66 [1.64, 1.68] | 1.26 [1.24, 1.28] |
| aw | 0.19 [0.176, 0.205] | 0.389 [0.382, 0.396] | 0.161 [0.146, 0.173] |
| s | 1.21 [1.15, 1.3] | 0.823 [0.78, 0.874] | 0.497 [0.461, 0.763] |
| b | 0.109 [0.0629, 0.256] | 0.133 [0.0765, 0.271] | 0.299 [0.137, 1.26] |
| J_V/4A [MeV fm^3] | 577 [573, 580] | 367 [364, 370] | 558 [555, 560] |
| J_W/4A [MeV fm^3] | 69.8 [68.3, 71.4] | 96.7 [94.2, 99.5] | 74.2 [72.9, 76.3] |
1fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
2for key in targets:
3 axes[0].hist(
4 jv_samples[key].real,
5 bins=50,
6 density=True,
7 color=colors[key],
8 alpha=0.5,
9 label=isotope_label[key],
10 )
11 axes[1].hist(
12 jv_samples[key].imag,
13 bins=50,
14 density=True,
15 color=colors[key],
16 alpha=0.5,
17 label=isotope_label[key],
18 )
19
20axes[0].set_xlabel(r"$J_V / 4A$ [MeV fm$^3$]")
21axes[1].set_xlabel(r"$J_W / 4A$ [MeV fm$^3$]")
22axes[1].legend(framealpha=0)
23
24fig.suptitle("volume integral per nucleon pair")
25plt.tight_layout()
26plt.show()
Note that, as we expected, the imaginary volume integral is statistically much larger for the mid-shell \(^{44}\)Ca. Also interesting - \(^{44}\)Ca is significantly less binding than its closed shell counterparts.
Posterior predictive distributions¶
Propagating posterior samples through the model gives the spread of model curves; adding a draw from the error model on top gives the posterior predictive distribution for the data, which is what should be compared to the points.
1def plot_predictive(ax, key, label=None):
2 x = data[key]["x"]
3 pred = np.clip(posterior_predictive[key], 1e-6, None)
4 lo95, lo68, hi68, hi95 = np.percentile(pred, [2.5, 16, 84, 97.5], axis=0)
5 ax.fill_between(
6 x, lo95, hi95, color=colors[key], alpha=0.2, label="95% posterior predictive"
7 )
8 ax.fill_between(
9 x, lo68, hi68, color=colors[key], alpha=0.4, label="68% posterior predictive"
10 )
11 ax.plot(
12 x,
13 np.median(posterior_model[key], axis=0),
14 color=colors[key],
15 linewidth=1.5,
16 label="median model",
17 )
18 ax.plot(x, data[key]["y"], ".", color="k", alpha=0.5, markersize=4, label="data")
19 ax.set_yscale("log")
20 ax.set_ylim(1e-4, 2)
21 ax.set_ylabel(
22 r"$(\frac{d\sigma}{d\Omega}) / (\frac{d\sigma}{d\Omega})_\text{Rutherford}$"
23 )
24 ax.set_title(label or rf"{isotope_label[key]}($\alpha,\alpha$) at 29 MeV")
25 ax.legend(framealpha=0, fontsize=8)
26
27
28fig, axes = plt.subplots(len(targets), 1, figsize=(7, 4 * len(targets)), sharex=True)
29for ax, key in zip(axes, targets, strict=True):
30 plot_predictive(ax, key)
31axes[-1].set_xlabel(r"$\theta_{cm}$ [deg]")
32plt.tight_layout()
33plt.show()
Empirical coverage¶
If the error model is right, the central \(q\) credible interval of the posterior predictive distribution should contain a fraction \(q\) of the data points, for every \(q\). Plotting the fraction actually covered against \(q\) is a direct check of the calibration of the whole model, potential and error model together.
1credibility = np.linspace(0.01, 0.99, 50)
2
3
4def empirical_coverage(pred, y, q):
5 lo, hi = np.percentile(pred, [50 * (1 - q), 50 * (1 + q)], axis=0)
6 return np.mean((y >= lo) & (y <= hi))
7
8
9plt.figure(figsize=(5, 5))
10for key in targets:
11 coverage = np.array(
12 [
13 empirical_coverage(posterior_predictive[key], data[key]["y"], q)
14 for q in credibility
15 ]
16 )
17 plt.plot(credibility, coverage, color=colors[key], label=isotope_label[key])
18 print(
19 f"{key}: coverage at 68% = {empirical_coverage(posterior_predictive[key], data[key]['y'], 0.68):.2f}, "
20 f"at 95% = {empirical_coverage(posterior_predictive[key], data[key]['y'], 0.95):.2f}, "
21 f"mean |coverage - credibility| = {np.mean(np.abs(coverage - credibility)):.3f}"
22 )
23plt.plot([0, 1], [0, 1], "k:", label="perfect calibration")
24plt.xlabel("credibility")
25plt.ylabel("empirical coverage")
26plt.legend(framealpha=0)
27plt.tight_layout()
28plt.show()
Ca40: coverage at 68% = 0.70, at 95% = 0.94, mean |coverage - credibility| = 0.052
Ca44: coverage at 68% = 0.69, at 95% = 0.98, mean |coverage - credibility| = 0.020
Ca48: coverage at 68% = 0.81, at 95% = 1.00, mean |coverage - credibility| = 0.092
Summary¶
We have used jitr to do a full Bayesian calibration of the optical model potentials for \(\alpha\)s on Ca isotopes, with uncertainty quantified resolution of shell effects: the imaginary volume integral is statistically larger for mid-shell \(^{44}\)Ca than for its closed-shell neighbors - exactly the absorption trend Oeschler et al. observed in the backward-angle data.
Note that we have intentionally set our priors to focus on one of several families of parameters that describe the data nearly equally well. This is well known discrete ambiguity of the \(\alpha\) + Ca optical potential. In a Bayesian context, this ambiguity is exactly a posterior multimodality, something we can explore by widening our prior. This is done in alpha_ca48_ambiguity, which builds on this example.
Another interesting conclusion we can draw is that nearly all 3 isotopes want small surface peaks \(W_s \lesssim 5\) MeV (although \(W_s\) is barely constrained for \(^{48}\)Ca). An interesting follow up would be to eliminate the surface term, and see if the simpler 6 parameter potential can describe our data just as well. In fact, the Bayesian evidence, which dynesty calculates for us for free, is exactly the metric that goes into a Bayes’ factor, which one would use for such a Bayesian model comparison!