Full Monte Carlo uncertainty budget¶
What this notebook does¶
We run a large Monte Carlo (N_MC samples) at the Planck 2018 + BAO baryon density, drawing three independent sources of uncertainty simultaneously:
Nuclear rates — each of the 12 key reaction rates is multiplied by \(\exp(p_i \sigma_i)\) where \(p_i \sim \mathcal{N}(0,1)\) and \(\sigma_i\) is the tabulated 1σ rate uncertainty.
Neutron lifetime \(\tau_n\) — drawn from \(\mathcal{N}(878.4,\, 0.5)\) s (PDG 2022 average). The neutron lifetime sets the overall normalisation of the n↔p weak rates and is the dominant single-parameter uncertainty on \(Y_P\).
Baryon density \(\Omega_b h^2\) — drawn from the Planck 2018 + BAO posterior \(\mathcal{N}(0.02242,\, 0.00014)\) (Planck 2018 TT,TE,EE+lowE+lensing+BAO).
The result is a joint sample of \((Y_P,\, {\rm D/H},\, {}^3{\rm He/H},\, {}^7{\rm Li/H})\) that captures the full theoretical uncertainty budget. We then plot histograms of each observable and a corner plot showing pairwise correlations.
Note: for a quick test use N_MC = 50; for publication quality use N_MC ≥ 500.
import importlib.util, subprocess, sys
# In Colab (or any environment without a local primat checkout), pip-install
# the released package; local dev runs already have it importable (editable
# install / repo on sys.path via the cell below), so this is a no-op there.
if importlib.util.find_spec("primat") is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "primat"], check=True)
import sys, os
sys.path.insert(0, os.path.abspath('..'))
import numpy as np
import matplotlib.pyplot as plt
import multiprocessing
from joblib import Parallel, delayed
from primat.config import DEFAULT_PARAMS
from primat.backend import run_bbn
from primat.config import PRIMATConfig
Why not run_mc here? This notebook’s Monte Carlo jointly varies three independent sources of uncertainty per sample: the 12 nuclear rate offsets, \(\tau_n\), and \(\Omega_b h^2\). primat.backend.run_mc only randomises nuclear rates/\(\tau_n\) around a fixed set of base params – it has no mechanism to draw \(\Omega_b h^2\) (or any other cosmological/physical parameter) per sample. run_mc is the right tool whenever a scan only needs rate/\(\tau_n\) uncertainty at fixed cosmological parameters, e.g.:
from primat.backend import run_mc
mc = run_mc(50, ['YPBBN', 'DoH'], params={'network': 'small', 'Omegabh2': 0.02242})
Here we instead keep the explicit per-sample loop below, calling primat.backend.run_bbn(params) directly for each draw – same backend-dispatch story as run_bbn/run_mc elsewhere (C extension when available, else pure Python), just driven by our own sampler since the 3-way joint draw is broader than what run_mc natively expresses.
Parallelization: Python backend (joblib) vs C backend (pthread)¶
This notebook uses joblib.Parallel to parallelize the Monte Carlo samples across worker processes (one BBN solve per worker). By default, N_JOBS = multiprocessing.cpu_count() so the job runs on all available cores.
Note on backends:
Python backend (default if C extension unavailable): each
run_bbncall dispatches to the pure-Python solver; joblib orchestrates the parallelization.C backend (default if available, see
primat.backend.run_bbn()):run_bbninternally uses the fast compiled C extension. When called from joblib workers, each worker still runs the C code, so you get both C’s speed advantage and joblib’s parallelization.
Both backends produce statistically equivalent results (mean, std, correlations agree to numerical precision ~1e-7–1e-8), but individual samples are not bit-for-bit identical due to different RNG implementations:
Python: NumPy’s
default_rngper workerC: xoshiro256** PRNG per thread
For comparison, you can force the Python backend in cell-3 by adding os.environ['PRIMAT_BACKEND_LOG'] = '1' and checking the output logs, or by passing force_backend='python' to run_bbn.
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
N_MC = 100 #100 # number of MC samples (use ≥500 for publication quality)
N_JOBS = multiprocessing.cpu_count()
MC_SEED = 0 # fixed seed for reproducibility
# Planck 2018 + BAO baryon density prior (TT+TE+EE+lowE+lensing+BAO)
OMEGA_B_H2_MEAN = 0.02242
OMEGA_B_H2_STD = 0.00014
# Neutron lifetime prior (PDG 2022 average)
TAU_N_MEAN = 878.4 # s
TAU_N_STD = 0.5 # s (1σ)
# The 12 key nuclear rate keys in DEFAULT_PARAMS
RATE_KEYS = list(PRIMATConfig().p_rxn.keys())[:12]
print(f"N_MC = {N_MC}, running on {N_JOBS} CPUs")
print(f"Nuclear rate keys: {RATE_KEYS}")
N_MC = 100, running on 8 CPUs
Nuclear rate keys: ['d_d__He3_n', 'He3_n__t_p', 'n_p__d_g', 't_d__a_n', 't_a__Li7_g', 'Li7_p__a_a', 'd_d__t_p', 'd_p__He3_g', 'Be7_n__Li7_p', 't_p__a_g', 'He3_a__Be7_g', 'He3_d__a_p']
# ---------------------------------------------------------------------------
# Worker function (module-level so joblib can pickle it)
# ---------------------------------------------------------------------------
# Each call:
# 1. Draws Ω_b h² from the Planck 2018 + BAO prior
# 2. Draws τ_n from its measurement uncertainty
# 3. Draws all 12 nuclear rate offsets p_i ~ N(0,1)
# 4. Runs a full run_bbn and returns the four key observables
def _run_one_full_mc(seed):
import sys, os
sys.path.insert(0, os.path.abspath('..'))
import numpy as np
from primat.config import DEFAULT_PARAMS
from primat.backend import run_bbn
rate_keys = [k for k in DEFAULT_PARAMS if k.startswith('p_')][:12]
rng = np.random.default_rng(seed)
ob2 = rng.normal(OMEGA_B_H2_MEAN, OMEGA_B_H2_STD) # Ω_b h²
tau_n = rng.normal(TAU_N_MEAN, TAU_N_STD) # τ_n [s]
p_vec = rng.standard_normal(len(rate_keys)) # nuclear rate offsets
params = {
'Omegabh2': ob2,
'tau_n': tau_n,
'network': 'small',
'verbose': False,
'debug': False,
'show_progress': False, # suppress per-sample "[primat] HT./MT./LT." stderr spam across parallel workers
**{k: float(v) for k, v in zip(rate_keys, p_vec)},
}
r = run_bbn(params)
return (
ob2, tau_n,
r['YPBBN'],
r['DoH'],
r['He3oH'],
r['Li7oH'],
)
print("Worker function defined.")
Worker function defined.
# ---------------------------------------------------------------------------
# Run the Monte Carlo
# ---------------------------------------------------------------------------
print(f"Running {N_MC} full MC samples ...")
raw = Parallel(n_jobs=N_JOBS)(
delayed(_run_one_full_mc)(MC_SEED + i) for i in range(N_MC)
)
raw = np.array(raw) # shape (N_MC, 6)
ob2_s, taun_s, YP_s, DoH_s, He3oH_s, Li7oH_s = raw.T
print(f"Done. Shapes: {YP_s.shape}")
print(f" YP : {YP_s.mean():.6f} ± {YP_s.std():.6f}")
print(f" D/H : {DoH_s.mean():.4e} ± {DoH_s.std():.4e}")
print(f" He3/H : {He3oH_s.mean():.4e} ± {He3oH_s.std():.4e}")
print(f" Li7/H : {Li7oH_s.mean():.4e} ± {Li7oH_s.std():.4e}")
Running 100 full MC samples ...
Done. Shapes: (100,)
YP : 0.246986 ± 0.000122
D/H : 2.4385e-05 ± 2.3900e-07
He3/H : 1.0403e-05 ± 3.5863e-08
Li7/H : 5.5504e-10 ± 6.9224e-12
Joint uncertainty: covariance and correlation via MCResult¶
The per-sample loop above jointly varies rates, \(\tau_n\) and \(\Omega_b h^2\),
so it cannot be produced by primat.backend.run_mc directly (see the note
above) – but the four resulting sample arrays are exactly the shape
primat.main.MCResult expects, so we can wrap them in one by hand and reuse
its cov()/corr() API instead of a hand-rolled
np.corrcoef call: this is the same joint-uncertainty machinery a
run_mc()-based script would use, so a reader can compare the two workflows
directly.
The YP-D/H correlation matters in practice because a joint cosmological likelihood that treats \(Y_P\) and D/H as independent measurements double-counts the nuclear-rate/\(\tau_n\) uncertainty they share – the off-diagonal term here is exactly the correction a proper joint (or profiled) likelihood needs.
# ---------------------------------------------------------------------------
# Wrap the four sample arrays in an MCResult to reuse its cov()/corr() API
# ---------------------------------------------------------------------------
import pandas as pd
from primat.main import MCQuantityResult, MCResult
_mc_products = {
'YPBBN': YP_s,
'DoH': DoH_s,
'He3oH': He3oH_s,
'Li7oH': Li7oH_s,
}
# `central` is not meaningful here (there is no single nominal run backing
# this three-source joint MC), so we use each observable's sample mean as a
# placeholder -- cov()/corr() only use `.values`, not `.central`.
mc = MCResult(
{name: MCQuantityResult(float(np.mean(values)), values)
for name, values in _mc_products.items()},
seed=MC_SEED,
)
corr_df = pd.DataFrame(mc.corr(), index=mc.quantity_names(), columns=mc.quantity_names())
cov_df = pd.DataFrame(mc.cov(), index=mc.quantity_names(), columns=mc.quantity_names())
print("Correlation matrix:")
display(corr_df.style.background_gradient(cmap='coolwarm', vmin=-1, vmax=1).format('{:.3f}'))
print(f"YP-D/H correlation: mc.corr('YPBBN', 'DoH') = {mc.corr('YPBBN', 'DoH'):.3f}")
print(f"YP-D/H covariance: mc.cov('YPBBN', 'DoH') = {mc.cov('YPBBN', 'DoH'):.4e}")
Correlation matrix:
| YPBBN | DoH | He3oH | Li7oH | |
|---|---|---|---|---|
| YPBBN | 1.000 | -0.524 | -0.524 | 0.558 |
| DoH | -0.524 | 1.000 | 1.000 | -0.999 |
| He3oH | -0.524 | 1.000 | 1.000 | -0.999 |
| Li7oH | 0.558 | -0.999 | -0.999 | 1.000 |
YP-D/H correlation: mc.corr('YPBBN', 'DoH') = -0.524
YP-D/H covariance: mc.cov('YPBBN', 'DoH') = -1.5463e-11
# ---------------------------------------------------------------------------
# Histograms of each observable
# ---------------------------------------------------------------------------
# Each histogram shows the full theoretical uncertainty from nuclear rates,
# τ_n, and Ω_b h² combined. The dashed vertical lines mark the ±1σ range.
obs_info = [
(YP_s, r'$Y_P$', 'tab:blue'),
(DoH_s, r'D/H', 'tab:orange'),
(He3oH_s, r'$^3{\rm He}/H$', 'tab:green'),
(Li7oH_s, r'$^7{\rm Li}/H$', 'tab:red'),
]
fig, axs = plt.subplots(2, 2, figsize=(10, 7))
axs = axs.flatten()
for ax, (samples, label, color) in zip(axs, obs_info):
mu, sig = samples.mean(), samples.std()
ax.hist(samples, bins=20, color=color, alpha=0.7, edgecolor='white')
ax.axvline(mu, color='black', lw=1.5, ls='-', label=f'mean = {mu:.4g}')
ax.axvline(mu - sig, color='black', lw=1, ls='--', label=f'±1σ = {sig:.3g}')
ax.axvline(mu + sig, color='black', lw=1, ls='--')
ax.set_xlabel(label, fontsize=12)
ax.set_ylabel('Counts', fontsize=10)
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.suptitle(f'Full uncertainty budget — {N_MC} MC samples\n'
r'(nuclear rates + $\tau_n$ + $\Omega_b h^2$)', fontsize=12)
plt.tight_layout()
plt.savefig('plots/mc_rates_histograms.pdf', bbox_inches='tight')
plt.show()
/var/folders/x_/t8nzcr8d7_91h7fdrxdr8b180000gp/T/ipykernel_96401/3286707919.py:32: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown
plt.show()
# ---------------------------------------------------------------------------
# Corner plot — pairwise correlations between observables
# ---------------------------------------------------------------------------
# Strong correlations reveal which observables share common rate dependencies.
# For example, YP and D/H are anti-correlated through the p+n→d+γ rate:
# a faster reaction depletes free neutrons faster, lowering YP and increasing D.
labels = [r'$Y_P$', r'D/H', r'$^3$He/H', r'$^7$Li/H']
samples_all = np.column_stack([YP_s, DoH_s, He3oH_s, Li7oH_s])
n = len(labels)
fig, axs = plt.subplots(n, n, figsize=(10, 10))
fig.subplots_adjust(hspace=0.05, wspace=0.05)
for i in range(n):
for j in range(n):
ax = axs[i, j]
if i == j:
# Diagonal: histogram
ax.hist(samples_all[:, i], bins=15, color='tab:blue', alpha=0.7)
elif i > j:
# Lower triangle: scatter plot
ax.scatter(samples_all[:, j], samples_all[:, i],
s=8, alpha=0.5, color='tab:blue')
# Pearson correlation coefficient, via MCResult.corr()
rho = mc.corr(mc.quantity_names()[j], mc.quantity_names()[i])
ax.set_title(f'r = {rho:.2f}', fontsize=8, pad=2)
else:
ax.set_visible(False)
# Labels only on edges
if j == 0:
ax.set_ylabel(labels[i], fontsize=10)
if i == n - 1:
ax.set_xlabel(labels[j], fontsize=10)
ax.tick_params(labelsize=7)
fig.suptitle('Pairwise correlations between BBN observables', fontsize=12, y=1.01)
plt.savefig('plots/mc_rates_corner.pdf', bbox_inches='tight')
plt.show()
# ---------------------------------------------------------------------------
# Summary table
# ---------------------------------------------------------------------------
print(f"{'Observable':<15} {'Mean':>14} {'1σ':>12} {'1σ/mean':>10}")
print('-' * 58)
for samples, label in [(YP_s, 'YP'), (DoH_s, 'D/H'),
(He3oH_s, 'He3/H'), (Li7oH_s, 'Li7/H')]:
mu, sig = samples.mean(), samples.std()
print(f"{label:<15} {mu:>14.6e} {sig:>12.4e} {sig/mu*100:>9.3f}%")
Observable Mean 1σ 1σ/mean
----------------------------------------------------------
YP 2.469856e-01 1.2227e-04 0.050%
D/H 2.438547e-05 2.3900e-07 0.980%
He3/H 1.040328e-05 3.5863e-08 0.345%
Li7/H 5.550395e-10 6.9224e-12 1.247%