Skip to content

Distances and expansion history

class-e emulates the background observables a BAO, supernova or CMB-angle likelihood needs: \(H(z)\), the comoving distance \(\chi(z)\) and everything built from them (\(D_M\), \(D_A\), \(D_L\), \(D_H\), \(D_V\)), plus the scalars of the recombination era (\(r_d\), \(z_d\), \(r_*\), \(z_*\), \(\theta_*\)).

The point is to get them from one emulator, consistent with each other. Since

\[ \chi(z) = c \int_0^z \frac{dz'}{H(z')}, \]

and integration is linear, the emulator learns the integrand on a redshift grid; \(H(z)\) and \(\chi(z)\) at any redshift follow from the same prediction, with \(\chi' = c/H\) exactly. This is the "integral of a GP is a GP" idea, applied along \(z\) (the GP itself is over the cosmological parameters).

Generate

Background observables come from a CLASS run that stops after the thermodynamics (about 0.05 s per point), so large training sets are cheap:

theory:
  classy:
    extra_args:
      output: ""                     # background and thermodynamics only
      tol_background_integration: 1.0e-14   # strongly phantom w0wa: see below
    background:
      z_max: 1200.0                  # or an explicit grid: z: [0.0, 0.1, ...]
      n_z: 100                       # uniform in ln(1+z), from z = 0

observables: [Hz, chi, rs_drag, z_drag, rs_star, z_star, theta_star_100]
Observable Content Grid
Hz \(H(z)\) in km/s/Mpc z_bg
chi \(\chi(z)\) in Mpc (for validation: the emulator gets it from Hz) z_bg
rs_drag, rs_star comoving sound horizon at the drag epoch and at \(z_*\), Mpc —
z_drag, z_star their redshifts —
theta_star_100, theta_s_100 \(100\,\theta_*\), and CLASS's \(100\,\theta_s\) (the shooting input) —
H0 km/s/Mpc (for models sampled in \(\theta_s\)) —

Ready-made configurations: configs/lcdm-background.yaml and configs/w0wacdm-mnu-background.yaml.

Background tolerance for phantom dark energy

At CLASS's default tol_background_integration (1e-10), strongly phantom points (\(w_0 + w_a \approx -4\)) miss \(H_0 = 100h\) by up to 0.2% and \(\chi(1100)\) by \(2\times10^{-4}\). 1.0e-14 brings this to \(\sim10^{-6}\) at no extra cost; every w0wa configuration sets it.

Train

With grid/z_bg in the dataset, Hz trains a DistanceEmulator and chi is left out (it comes with Hz). The training section of configs/w0wacdm-mnu-background.yaml:

train:
  test_design: test
  backend: jax
  defaults:
    n_pca: 1
    inputs: {h: h, omega_b: omega_b, omega_cdm: omega_cdm, m_nu: m_nu, w0: w0, s: "w0 + wa"}
    baseline: {kind: cpl, m_ncdm: m_nu, w0: w0, wa: wa, N_ur: 2.0308}
  observables:
    Hz: {n_pca: 16}
    rs_drag: {}
    theta_star_100: {}
  out: bundles/w0wacdm-mnu-background.cemu
class-e train --config configs/w0wacdm-mnu-background.yaml

The baseline is what makes it accurate

The emulator does not learn \(H(z)\) itself but its ratio to an analytic baseline, baseline: {kind: cpl, ...}:

\[ \frac{H^2}{H_0^2} = \Omega_m a^{-3} + \Omega_r a^{-4} + \Omega_\nu(a) + \Omega_k a^{-2} + \Omega_{\rm DE}\, a^{-3(1+w_0+w_a)} e^{-3 w_a (1-a)} , \]

where each density is an expression of your parameters (h, omega_m, w0, wa, Omega_k, m_ncdm, N_ur, ...). Massive neutrinos follow the Komatsu et al. (2011) fit (radiation early, matter late, as in CLASS), which matches CLASS's \(H(z)\) to \(\lesssim 6\times10^{-5}\) at every redshift; counting them as matter misses it by 1% above \(z \sim 100\). The emulator then learns a ratio within \(10^{-4}\) of one.

The scalars take the same baseline: they become RatioEmulators over a RecombinationBaseline, the sound horizon \(r_s = \int_z^\infty c_s\,dz'/H\) on the analytic background (with fitting formulas for \(z_d\) and \(z_*\)). This matters for w0waCDM: as \(w_0 + w_a \to 0\) dark energy behaves like matter at early times and \(r_s\) drops by up to 10% (\(\theta_*\) moves by 20%), a sharp edge a GP fits poorly; the baseline follows it and leaves a ratio within \(\pm0.3\%\).

Models sampled in \(\theta_s\), and one bundle for CMB and background

For a model sampled in \(\theta_s\) rather than \(h\), write h: "H0 / 100": the expressions may read trained scalar observables, whose emulators are fitted first (H0 before everything) and stored with the wrapper.

The background observables come from the same CLASS run as the spectra, so a CMB configuration can list them too and train one bundle that serves CMB, BAO, SN and \(\theta_*\) likelihoods (configs/w0wacdm-mnu-theta.yaml). Give each emulator only the parameters it depends on, with per-observable inputs:

observables: [TT, TE, EE, Hz, H0, rs_drag, z_drag, rs_star, z_star, theta_star_100]
train:
  inputs: {theta_s_100: theta_s_100, ..., logA: logA, n_s: n_s, tau_reio: tau_reio, w0: w0, s: "w0 + wa"}
  observables:
    TT: {}
    H0: {n_pca: 1, inputs: &background {theta_s_100: theta_s_100, omega_b: omega_b, omega_cdm: omega_cdm,
                                         w0: w0, s: "w0 + wa"}}
    Hz: {n_pca: 16, log_space: false, inputs: *background,
         baseline: &cpl {kind: cpl, h: "H0 / 100", m_ncdm: "0.06", w0: w0, wa: wa, N_ur: 2.0308}}
    theta_star_100: {n_pca: 1, inputs: {<<: *background, tau_reio: tau_reio}, baseline: *cpl}

CLASS's \(z_*\) depends on reionization

CLASS defines \(z_*\) by an optical depth of one, reionization included. Across \(\tau_{\rm reio} \in [0.01, 0.125]\), \(z_*\) moves by 1%, \(r_s(z_*)\) and \(\theta_*\) by 0.6%, \(r_d\) by \(10^{-4}\); \(100\,\theta_s\) (at the visibility peak, CLASS's shooting input) and \(H(z)\) do not move. Emulators of z_star, rs_star, theta_star_100 (and, for \(10^{-5}\) accuracy, rs_drag, z_drag) must see tau_reio when it varies.

Without a baseline (baseline: mean, the default) the ratio is to the training mean: fine for \(\Lambda\)CDM, poor across a wide w0wa box.

Use

from class_e import EmulatorBundle

bundle = EmulatorBundle.load("bundles/w0wacdm-mnu-background.cemu")
hz = bundle["Hz"]                                   # a DistanceEmulator
z = [0.295, 0.51, 0.706, 0.934, 1.321, 2.33]
d = hz.distances(theta, z)                          # H, chi, D_M, D_A, D_L, D_H, D_V; (M, len(z))
d["D_M"] / bundle["rs_drag"].predict(theta)         # BAO D_M / r_d
hz.comoving_distance_jax(theta_1d, z)               # differentiable (JAX backend)

Any \(z\) from 0 to the grid's \(z_{\max}\) works. distances takes the curvature from the cpl baseline (its Omega_k), or Omega_k= explicitly, and turns \(\chi\) into \(D_M\) with a form exact and smooth through \(\Omega_k = 0\).

Accuracy

Held-out CLASS points, relative errors (JAX backend, float64, 16 PCA components for Hz).

Model, training points Quantity p50 p99 max
\(\Lambda\)CDM + \(m_\nu\) = 0.06 eV, 512 \(\chi(z)\) \(3\times10^{-9}\) \(2\times10^{-7}\) \(7\times10^{-7}\)
\(\theta_*\) \(3\times10^{-8}\) \(1\times10^{-7}\) \(1\times10^{-7}\)
w0waCDM + free \(m_\nu\), 2048 \(\chi(z)\) \(5\times10^{-9}\) \(9\times10^{-8}\) \(6\times10^{-7}\)
\(H(z)\), \(z < 3\) \(3\times10^{-9}\) \(1\times10^{-7}\) \(7\times10^{-7}\)
\(H(z)\), \(z > 100\) \(4\times10^{-8}\) \(8\times10^{-7}\) \(3\times10^{-6}\)
\(r_d\) \(8\times10^{-7}\) \(2\times10^{-5}\) \(8\times10^{-5}\)
\(\theta_*\) \(8\times10^{-7}\) \(3\times10^{-5}\) \(1\times10^{-4}\)

Without the baseline, the w0wa errors are \(10^3\)–\(10^4\) times larger (\(\chi\) worst \(9\times10^{-3}\), \(\theta_*\) worst \(1.3\times10^{-2}\)); counting the neutrinos as matter leaves \(H(z > 100)\) at \(3\times10^{-3}\). The largest remaining scalar errors sit at the prior edge \(w_0 + w_a \to 0\), where only ~7% of the training points fall; a denser design there reduces them.

Use float64

In float32 the JAX backend loses about a factor 50 on these targets. The CLI enables float64; from Python call class_e.stats.jax_config.configure_jax(x64=True) first.

How it works

DistanceEmulator wraps any backend. On the grid nodes \(z_i\) (uniform in \(x = \ln(1+z)\)) it learns

\[ r_i(\theta) = \frac{g(x_i;\theta)}{b(x_i;\theta)}, \qquad g = \frac{d\chi}{dx} = \frac{c\,(1+z)}{H(z)}, \]

with \(b\) the baseline. Between nodes \(r\) is a cubic spline, so \(H(z) = c(1+z)/(r\,b)\) at any \(z\), and \(\chi\) is a composite Gauss-Legendre integral of \(r\,b\), exact for the spline. Both are fixed linear operators applied to the emulator's output. With 100 nodes up to \(z = 1200\), the quadrature error is \(\sim10^{-9}\) with a CPL baseline.

See the Emulators API.