fastcmb 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastcmb/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ fastcmb: an online emulator of Boltzmann codes for CMB analyses.
3
+
4
+ :class:`CMBEmulator` wraps a Boltzmann code -- an *oracle*, such as
5
+ :class:`CAMBOracle` -- and answers with a Gaussian-process prediction
6
+ wherever its error, in units of the log-likelihood, is below a
7
+ tolerance; elsewhere the code runs, and the point is learned. It needs
8
+ only numpy and scipy, and fits in any analysis code:
9
+
10
+ * CosmoFit: ``theory: {background: ..., fastcmb: ...}`` in place of
11
+ ``camb`` (built into CosmoFit, with ``fastcmb`` installed);
12
+ * cobaya: the theory ``fastcmb.cobaya.FastCAMB`` in place of ``camb``;
13
+ * anything else: call a :class:`CMBEmulator`.
14
+ """
15
+
16
+ from .camb_oracle import CAMBOracle
17
+ from .emulator import CMBEmulator, Result
18
+ from .gp import GP
19
+ from .online import OnlineEmulator
20
+
21
+ __all__ = ["GP", "CAMBOracle", "CMBEmulator", "OnlineEmulator", "Result", "__version__"]
22
+
23
+ __version__ = "0.3.0"
fastcmb/camb_oracle.py ADDED
@@ -0,0 +1,134 @@
1
+ """
2
+ CAMB as an oracle for :class:`~fastcmb.emulator.CMBEmulator`, with
3
+ parameters named as cobaya and most codes name them.
4
+
5
+ Parameters (missing ones take the default in brackets):
6
+
7
+ * ``H0`` -- or ``cosmomc_theta`` (``100 theta_MC``, as ``theta_MC_100``);
8
+ * ``ombh2``, ``omch2``; ``omk`` [0];
9
+ * ``mnu`` [0.06 eV, one massive species], ``nnu`` [3.044];
10
+ * ``ln1e10As`` (or ``logA``) or ``As``; ``ns``; ``tau``;
11
+ * ``w`` [-1], ``wa`` [0]: a CPL dark energy through CAMB's PPF module.
12
+
13
+ CAMB is imported on first use.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ import numpy as np
21
+
22
+ __all__ = ["CAMBOracle"]
23
+
24
+ _DEFAULTS = {"omk": 0.0, "mnu": 0.06, "nnu": 3.044, "w": -1.0, "wa": 0.0}
25
+
26
+
27
+ class CAMBOracle:
28
+ """
29
+ Parameters
30
+ ----------
31
+ lmax : int
32
+ Highest multipole of the spectra.
33
+ lens_potential_accuracy : int
34
+ CAMB's; 1 is enough for Planck's CMB spectra, 4 for its lensing.
35
+ halofit_version : str
36
+ CAMB's non-linear model for the lensing potential.
37
+ TCMB : float
38
+ CMB temperature [K].
39
+ accuracy : dict, optional
40
+ Passed to ``CAMBparams.set_accuracy`` (``AccuracyBoost``, ...).
41
+ """
42
+
43
+ def __init__(self, lmax: int = 2508, lens_potential_accuracy: int = 1,
44
+ halofit_version: str = "mead2020", TCMB: float = 2.7255,
45
+ accuracy: dict | None = None):
46
+
47
+ self.lmax = int(lmax)
48
+ self.lens_potential_accuracy = int(lens_potential_accuracy)
49
+ self.halofit_version = halofit_version
50
+ self.TCMB = float(TCMB)
51
+ self.accuracy = dict(accuracy or {})
52
+ self._camb = None
53
+
54
+ @property
55
+ def tag(self) -> str:
56
+ accuracy = ",".join(f"{k}={v}" for k, v in sorted(self.accuracy.items()))
57
+ return (f"camb|lmax={self.lmax}|lens={self.lens_potential_accuracy}"
58
+ f"|halofit={self.halofit_version}|T={self.TCMB}|{accuracy}")
59
+
60
+ def _import(self):
61
+ if self._camb is None:
62
+ import camb
63
+ self._camb = camb
64
+ return self._camb
65
+
66
+ def parameters(self, params: dict):
67
+ """The ``CAMBparams`` for ``params``."""
68
+
69
+ camb = self._import()
70
+ p = {**_DEFAULTS, **params}
71
+
72
+ pars = camb.CAMBparams()
73
+
74
+ theta = p.get("cosmomc_theta", p.get("theta_MC_100"))
75
+ cosmology = {
76
+ "ombh2": p["ombh2"], "omch2": p["omch2"], "omk": p["omk"], "mnu": p["mnu"], "nnu": p["nnu"],
77
+ "num_massive_neutrinos": 1 if p["mnu"] > 0 else 0, "TCMB": self.TCMB, "tau": p["tau"],
78
+ }
79
+
80
+ if theta is not None:
81
+ pars.set_cosmology(cosmomc_theta=float(theta) / 100.0, **cosmology)
82
+ else:
83
+ pars.set_cosmology(H0=p["H0"], **cosmology)
84
+
85
+ if "As" in p:
86
+ As = p["As"]
87
+ else:
88
+ As = math.exp(p.get("ln1e10As", p.get("logA"))) * 1.0e-10
89
+
90
+ pars.InitPower.set_params(As=As, ns=p["ns"])
91
+
92
+ if p["w"] != -1.0 or p["wa"] != 0.0:
93
+ pars.set_dark_energy(w=p["w"], wa=p["wa"], dark_energy_model="ppf")
94
+
95
+ pars.set_matter_power(redshifts=[0.0], kmax=2.0, silent=True)
96
+ pars.set_for_lmax(self.lmax + 500, lens_potential_accuracy=self.lens_potential_accuracy)
97
+ pars.NonLinearModel.set_params(halofit_version=self.halofit_version)
98
+
99
+ if self.accuracy:
100
+ pars.set_accuracy(**self.accuracy)
101
+
102
+ return pars
103
+
104
+ def __call__(self, params: dict) -> dict | None:
105
+
106
+ camb = self._import()
107
+
108
+ try:
109
+ results = camb.get_results(self.parameters(params))
110
+
111
+ total = results.get_cmb_power_spectra(
112
+ lmax=self.lmax, spectra=("total",), CMB_unit="muK", raw_cl=True,
113
+ )["total"]
114
+ pp = results.get_lens_potential_cls(lmax=self.lmax)[:, 0]
115
+
116
+ sigma8 = float(results.get_sigma8_0())
117
+ sigma8_cb = float(results.get_sigmaR(8.0, z_indices=[-1], var1="delta_nonu",
118
+ var2="delta_nonu")[0])
119
+
120
+ except camb.CAMBError:
121
+ return None
122
+
123
+ Cl = {
124
+ "ell": np.arange(self.lmax + 1),
125
+ "tt": total[:, 0], "ee": total[:, 1], "bb": total[:, 2], "te": total[:, 3], "pp": pp,
126
+ }
127
+
128
+ # CAMB can return NaN without raising; not a point to learn.
129
+ if not all(np.all(np.isfinite(v)) for v in Cl.values()) or not (
130
+ math.isfinite(sigma8) and math.isfinite(sigma8_cb)
131
+ ):
132
+ return None
133
+
134
+ return {"Cl": Cl, "sigma8": sigma8, "sigma8_cb": sigma8_cb}
fastcmb/cobaya.py ADDED
@@ -0,0 +1,133 @@
1
+ """
2
+ fastcmb as a cobaya theory.
3
+
4
+ In a cobaya input, in place of ``camb``::
5
+
6
+ theory:
7
+ fastcmb.cobaya.FastCAMB:
8
+ tolerance: 0.05
9
+ training_file: chains/emu/lcdm
10
+
11
+ It provides ``Cl`` -- what cobaya's CMB likelihoods ask for -- and the
12
+ derived ``sigma8``, from the parameters :mod:`fastcmb.camb_oracle`
13
+ names (``H0`` or ``cosmomc_theta``, ``ombh2``, ``omch2``, ``logA`` or
14
+ ``As``, ``ns``, ``tau``, and optionally ``mnu``, ``nnu``, ``omk``,
15
+ ``w``, ``wa``). Where the emulator is not sure, CAMB runs.
16
+
17
+ cobaya is imported here only, so ``fastcmb`` itself does not need it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ from cobaya.theory import Theory
24
+
25
+ from .camb_oracle import CAMBOracle
26
+ from .emulator import CMBEmulator
27
+
28
+ __all__ = ["FastCAMB"]
29
+
30
+ _INPUTS = ("H0", "cosmomc_theta", "theta_MC_100", "ombh2", "omch2", "omk", "mnu", "nnu",
31
+ "logA", "ln1e10As", "As", "ns", "tau", "w", "wa")
32
+
33
+
34
+ class FastCAMB(Theory):
35
+
36
+ tolerance: float = 0.05
37
+ sigma8_tolerance: float = 1e-3
38
+ min_train: int = 30
39
+ retrain_fraction: float = 0.1
40
+ max_train: int = 800
41
+ training_file: str | None = None
42
+ check_every: int = 200
43
+ lens_potential_accuracy: int = 1
44
+ halofit_version: str = "mead2020"
45
+ TCMB: float = 2.7255
46
+ lmax: int = 0
47
+
48
+ def initialize(self):
49
+ self._lmax = int(self.lmax)
50
+ self._lens = int(self.lens_potential_accuracy)
51
+ self.emulator = None
52
+
53
+ def get_can_support_params(self):
54
+ return list(_INPUTS)
55
+
56
+ def get_can_provide(self):
57
+ return ["Cl"]
58
+
59
+ def get_can_provide_params(self):
60
+ return ["sigma8"]
61
+
62
+ def must_provide(self, **requirements):
63
+
64
+ Cl = requirements.get("Cl")
65
+ if Cl:
66
+ self._lmax = max(self._lmax, *(int(v) for v in Cl.values()))
67
+
68
+ return {}
69
+
70
+ def initialize_with_provider(self, provider):
71
+
72
+ super().initialize_with_provider(provider)
73
+
74
+ if self._lmax == 0:
75
+ self._lmax = 2508
76
+
77
+ oracle = CAMBOracle(lmax=self._lmax, lens_potential_accuracy=self._lens,
78
+ halofit_version=self.halofit_version, TCMB=self.TCMB)
79
+
80
+ self.emulator = CMBEmulator(
81
+ oracle, inputs=sorted(self.input_params), tolerance=self.tolerance,
82
+ sigma8_tolerance=self.sigma8_tolerance, min_train=self.min_train,
83
+ retrain_fraction=self.retrain_fraction, max_train=self.max_train,
84
+ training_file=self.training_file, check_every=self.check_every,
85
+ )
86
+
87
+ def calculate(self, state, want_derived=True, **params_values_dict):
88
+
89
+ result = self.emulator(params_values_dict)
90
+
91
+ if result is None:
92
+ return False
93
+
94
+ state["Cl"] = result.Cl
95
+ state["emulated"] = result.emulated
96
+
97
+ if want_derived:
98
+ state["derived"] = {"sigma8": result.sigma8}
99
+
100
+ def get_Cl(self, ell_factor=False, units="FIRASmuK2"):
101
+ """As cobaya's ``camb`` returns them: ``tt``, ``te``, ``ee``, ``bb``, ``pp``."""
102
+
103
+ Cl = self.current_state["Cl"]
104
+
105
+ # cobaya's convention: the dimensionless spectra times u^2, with
106
+ # u from its table; the spectra here are in muK^2 at TCMB.
107
+ units_factor = {
108
+ "1": 1.0, "muK2": self.TCMB * 1e6, "K2": self.TCMB,
109
+ "FIRASmuK2": 2.7255e6, "FIRASK2": 2.7255,
110
+ }
111
+ if units not in units_factor:
112
+ raise ValueError(f"Units {units!r} not recognized; use one of {list(units_factor)}.")
113
+
114
+ factor = (units_factor[units] / (self.TCMB * 1e6)) ** 2
115
+
116
+ ell = Cl["ell"].astype(float)
117
+ out = {"ell": Cl["ell"]}
118
+
119
+ scale = np.ones_like(ell)
120
+ if ell_factor:
121
+ scale = ell * (ell + 1.0) / (2.0 * np.pi)
122
+
123
+ for name in ("tt", "te", "ee", "bb"):
124
+ out[name] = Cl[name] * factor * scale
125
+ out["et"] = out["te"]
126
+
127
+ # pp is [L(L+1)]^2 C_L / 2 pi already; raw C_L without ell_factor.
128
+ pp = Cl["pp"].copy()
129
+ if not ell_factor:
130
+ pp[1:] /= (ell[1:] * (ell[1:] + 1.0)) ** 2 / (2.0 * np.pi)
131
+ out["pp"] = pp
132
+
133
+ return out
fastcmb/compress.py ADDED
@@ -0,0 +1,148 @@
1
+ """
2
+ What is emulated, and how it is made smooth and small.
3
+
4
+ Each spectrum is a *block*. From ``l = 2``:
5
+
6
+ * ``tt``, ``ee``, ``bb``, ``pp``: ``ln C_l`` -- positive, and spanning
7
+ orders of magnitude;
8
+ * ``te``: the correlation ``C_TE / sqrt(C_TT C_EE)``, which crosses zero
9
+ where ``C_TE`` does and stays within [-1, 1];
10
+ * ``scalars``: ``ln sigma8`` (all matter) and ``ln sigma8`` of the cold
11
+ matter.
12
+
13
+ A block of spectra is projected on its leading principal components:
14
+ as many as it takes to reproduce every training spectrum to
15
+ :data:`PCA_TOLERANCE` in each multipole and, where the block has
16
+ likelihood weights (:mod:`fastcmb.metric`), to :data:`PCA_LOGL` in
17
+ the log-likelihood.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+
24
+ __all__ = ["BLOCKS", "PCA", "PCA_TOLERANCE", "from_blocks", "to_blocks"]
25
+
26
+ BLOCKS = ("tt", "te", "ee", "bb", "pp", "scalars")
27
+
28
+ #: Largest error, over the training set, a block's PCA may make: in
29
+ #: ``ln C_l`` (so 1e-3 is 0.1 %) or in the TE correlation.
30
+ PCA_TOLERANCE = 1e-3
31
+
32
+ #: Largest log-likelihood error, ``sqrt(delta^T F delta)``, a block's
33
+ #: PCA may make on a training spectrum.
34
+ PCA_LOGL = 0.01
35
+
36
+ #: Most components a block keeps.
37
+ MAX_COMPONENTS = 60
38
+
39
+
40
+ def to_blocks(Cl: dict, sigma8: float, sigma8_cb: float) -> dict:
41
+ """A CosmoFit ``Cl`` dict and the two ``sigma8`` as block vectors."""
42
+
43
+ tt, ee, te = Cl["tt"][2:], Cl["ee"][2:], Cl["te"][2:]
44
+
45
+ return {
46
+ "tt": np.log(tt),
47
+ "te": te / np.sqrt(tt * ee),
48
+ "ee": np.log(ee),
49
+ "bb": np.log(Cl["bb"][2:]),
50
+ "pp": np.log(Cl["pp"][2:]),
51
+ "scalars": np.log([sigma8, sigma8_cb]),
52
+ }
53
+
54
+
55
+ def from_blocks(blocks: dict, lmax: int) -> tuple[dict, float, float]:
56
+ """The inverse of :func:`to_blocks`: ``(Cl, sigma8, sigma8_cb)``."""
57
+
58
+ zeros = np.zeros(2)
59
+
60
+ tt = np.exp(blocks["tt"])
61
+ ee = np.exp(blocks["ee"])
62
+
63
+ Cl = {
64
+ "ell": np.arange(lmax + 1),
65
+ "tt": np.concatenate([zeros, tt]),
66
+ "ee": np.concatenate([zeros, ee]),
67
+ "bb": np.concatenate([zeros, np.exp(blocks["bb"])]),
68
+ "te": np.concatenate([zeros, blocks["te"] * np.sqrt(tt * ee)]),
69
+ "pp": np.concatenate([zeros, np.exp(blocks["pp"])]),
70
+ }
71
+
72
+ sigma8, sigma8_cb = np.exp(blocks["scalars"])
73
+
74
+ return Cl, float(sigma8), float(sigma8_cb)
75
+
76
+
77
+ def _svd(Z):
78
+ """
79
+ The thin SVD of ``Z`` ``(n, l)``, from the eigenvectors of the
80
+ smaller of its two Gram matrices -- for ``n`` a few hundred points of
81
+ ``l`` ~ 2500 multipoles, much faster than LAPACK's SVD of ``Z``. The
82
+ components it loses to rounding are ~1e-8 of the largest, far below
83
+ what any tolerance here keeps.
84
+ """
85
+
86
+ n, l = Z.shape
87
+
88
+ if n <= l:
89
+ w, U = np.linalg.eigh(Z @ Z.T)
90
+ order = np.argsort(w)[::-1]
91
+ s = np.sqrt(np.maximum(w[order], 0.0))
92
+ U = U[:, order]
93
+ keep = s > s[0] * 1e-12
94
+ Vt = (U[:, keep].T @ Z) / s[keep, None]
95
+ return U[:, keep], s[keep], Vt
96
+
97
+ w, V = np.linalg.eigh(Z.T @ Z)
98
+ order = np.argsort(w)[::-1]
99
+ s = np.sqrt(np.maximum(w[order], 0.0))
100
+ V = V[:, order]
101
+ keep = s > s[0] * 1e-12
102
+ U = (Z @ V[:, keep]) / s[keep]
103
+ return U, s[keep], V[:, keep].T
104
+
105
+
106
+ class PCA:
107
+ """Principal components of a block's training vectors ``(n, l)``."""
108
+
109
+ def __init__(self, Y, weights=None, tolerance: float = PCA_TOLERANCE,
110
+ logl: float = PCA_LOGL, max_components: int = MAX_COMPONENTS):
111
+
112
+ Y = np.asarray(Y, dtype=float)
113
+
114
+ self.mean = Y.mean(0)
115
+ Z = Y - self.mean
116
+
117
+ if Y.shape[1] <= 2:
118
+ # Nothing to compress: the scalars are their own components.
119
+ self.components = np.eye(Y.shape[1])
120
+ return
121
+
122
+ U, s, Vt = _svd(Z)
123
+
124
+ top = min(max_components, Vt.shape[0])
125
+
126
+ # The fewest components that reproduce every training vector; the
127
+ # residual loses one component at a time. A block that has not
128
+ # varied keeps none: its mean is the prediction.
129
+ residual = Z.copy()
130
+ k = 0
131
+ for k in range(1, top + 1):
132
+ residual -= np.outer(U[:, k - 1] * s[k - 1], Vt[k - 1])
133
+ if np.abs(residual).max() >= tolerance:
134
+ continue
135
+ if weights is None or np.sqrt((residual ** 2 @ weights).max()) < logl:
136
+ break
137
+
138
+ self.components = Vt[:k]
139
+
140
+ @property
141
+ def size(self) -> int:
142
+ return self.components.shape[0]
143
+
144
+ def project(self, Y):
145
+ return (np.asarray(Y, dtype=float) - self.mean) @ self.components.T
146
+
147
+ def reconstruct(self, coefficients):
148
+ return self.mean + coefficients @ self.components
fastcmb/emulator.py ADDED
@@ -0,0 +1,247 @@
1
+ """
2
+ The emulator as a function: parameters in, spectra out.
3
+
4
+ :class:`CMBEmulator` wraps any Boltzmann code -- an *oracle*, a function
5
+ from a parameter dict to spectra. At each call it predicts, and answers
6
+ with the prediction when its error, in units of ``ln L``, is below the
7
+ tolerance; otherwise it runs the oracle and learns the point.
8
+
9
+ This is all an analysis code needs: CosmoFit's ``fastcmb`` theory, the
10
+ cobaya theory in :mod:`fastcmb.cobaya`, or a script of one's own::
11
+
12
+ from fastcmb import CAMBOracle, CMBEmulator
13
+
14
+ emulator = CMBEmulator(CAMBOracle(lmax=2508))
15
+ result = emulator({"H0": 67.4, "ombh2": 0.0224, "omch2": 0.12,
16
+ "ln1e10As": 3.044, "ns": 0.965, "tau": 0.054})
17
+ result.Cl["tt"], result.sigma8, result.emulated
18
+
19
+ An oracle returns a dict with ``Cl`` -- ``ell``, ``tt``, ``te``, ``ee``,
20
+ ``bb`` (``C_l`` in muK^2) and ``pp`` (``[L(L+1)]^2 C_L / 2 pi``), from
21
+ ``l = 0`` to ``lmax`` -- and ``sigma8`` and ``sigma8_cb``; or ``None``
22
+ when it cannot compute the point.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+ from .compress import from_blocks, to_blocks
34
+ from .online import OnlineEmulator
35
+
36
+ __all__ = ["CMBEmulator", "Result"]
37
+
38
+ log = logging.getLogger("fastcmb")
39
+
40
+
41
+ @dataclass
42
+ class Result:
43
+ Cl: dict
44
+ sigma8: float
45
+ sigma8_cb: float
46
+ #: Whether the spectra are the emulator's (else the oracle's).
47
+ emulated: bool
48
+ #: The predicted ``ln L`` error, when there was a prediction.
49
+ error: float | None = None
50
+ #: Whether this is a prediction above the tolerance, given because
51
+ #: the call allowed it (``loose_tolerance``): good enough to tell
52
+ #: that a point is far worse than another, not to be kept.
53
+ approximate: bool = False
54
+
55
+
56
+ class CMBEmulator:
57
+ """
58
+ Parameters
59
+ ----------
60
+ oracle : callable
61
+ ``oracle(params) -> dict | None``, as described in the module.
62
+ It may also be given per call, to :meth:`__call__`.
63
+ lmax : int, optional
64
+ Highest multipole; by default the oracle's ``lmax``.
65
+ inputs : list of str, optional
66
+ The parameters the spectra depend on, in a fixed order. By
67
+ default the keys of the first call's parameters, sorted. A
68
+ parameter not listed is ignored -- it must not change the
69
+ spectra.
70
+ tag : str, optional
71
+ What the spectra are computed for (model, accuracy); points
72
+ stored under another tag are not read. By default the oracle's
73
+ ``tag``.
74
+ tolerance : float
75
+ Largest predicted ``ln L`` error the emulator answers with.
76
+ The measure is that of an idealized Planck (see
77
+ :mod:`fastcmb.metric`).
78
+ sigma8_tolerance : float
79
+ Largest predicted relative error of ``sigma8``.
80
+ min_train, retrain_fraction, max_train, training_file
81
+ As :class:`~fastcmb.online.OnlineEmulator`'s ``min_train``,
82
+ ``retrain_fraction``, ``max_train`` and ``path``.
83
+ check_every : int
84
+ After this many predictions, the oracle runs as well and the
85
+ error is measured; a prediction off by more than three times
86
+ the tolerance is learned. 0 never. While the checks find the
87
+ error within the tolerance, the interval between them doubles,
88
+ up to ten times ``check_every``; a check that does not brings
89
+ it back.
90
+ """
91
+
92
+ def __init__(self, oracle: Callable | None = None, lmax: int | None = None,
93
+ inputs: list[str] | None = None, tag: str | None = None,
94
+ tolerance: float = 0.05, sigma8_tolerance: float = 1e-3,
95
+ min_train: int = 30, retrain_fraction: float = 0.1, max_train: int = 800,
96
+ training_file: str | None = None, check_every: int = 200):
97
+
98
+ self.oracle = oracle
99
+ self.lmax = lmax if lmax is not None else getattr(oracle, "lmax", None)
100
+ self.inputs = None if inputs is None else list(inputs)
101
+ self.tag = tag if tag is not None else str(getattr(oracle, "tag", ""))
102
+ self.tolerance = float(tolerance)
103
+ self.sigma8_tolerance = float(sigma8_tolerance)
104
+ self.check_every = int(check_every)
105
+ self._options = {"min_train": min_train, "retrain_fraction": retrain_fraction,
106
+ "max_train": max_train, "path": training_file}
107
+
108
+ self.online: OnlineEmulator | None = None
109
+
110
+ self.n_oracle = 0
111
+ self.n_emulated = 0
112
+ #: Predictions given as approximate (see :meth:`__call__`).
113
+ self.n_approximate = 0
114
+ self.check_errors: list[float] = []
115
+ self._check_interval = self.check_every
116
+ self._next_check = self.check_every
117
+
118
+ # ---------------------------------------------------------
119
+
120
+ def _vector(self, params) -> np.ndarray:
121
+
122
+ if self.inputs is None:
123
+ self.inputs = sorted(params)
124
+
125
+ try:
126
+ return np.array([float(params[name]) for name in self.inputs])
127
+ except KeyError as error:
128
+ raise KeyError(f"fastcmb: the emulator's input {error} is missing from the parameters.") from None
129
+
130
+ def _online(self) -> OnlineEmulator:
131
+
132
+ if self.online is None:
133
+ if self.lmax is None:
134
+ raise ValueError("fastcmb: give lmax, or an oracle that has one.")
135
+ self.online = OnlineEmulator(self.inputs, self.lmax, tag=self.tag, **self._options)
136
+
137
+ return self.online
138
+
139
+ def __call__(self, params: dict, oracle: Callable | None = None,
140
+ loose_tolerance: float | None = None) -> Result | None:
141
+ """
142
+ The spectra at ``params``; ``None`` if the oracle cannot compute them.
143
+
144
+ With ``loose_tolerance``, a prediction whose error is above the
145
+ tolerance but below this is returned, marked ``approximate``,
146
+ instead of running the oracle. A sampler uses it to reject,
147
+ without the oracle, a point the prediction already shows to be
148
+ far worse than the current one; one it might accept, it asks
149
+ for again without.
150
+ """
151
+
152
+ oracle = oracle or self.oracle
153
+ if oracle is None:
154
+ raise ValueError("fastcmb: no oracle to compute the spectra with.")
155
+
156
+ x = self._vector(params)
157
+ online = self._online()
158
+
159
+ prediction = online.predict(x, tolerance=self.tolerance, scalar_tolerance=self.sigma8_tolerance)
160
+
161
+ trusted = (
162
+ prediction is not None
163
+ and prediction.error <= self.tolerance
164
+ and prediction.scalar_error <= self.sigma8_tolerance
165
+ )
166
+
167
+ if not trusted and loose_tolerance is not None and prediction is not None:
168
+
169
+ # The error above was bounded, stopping early; the exact
170
+ # one may be lower.
171
+ prediction = online.predict(x)
172
+ trusted = (prediction.error <= self.tolerance
173
+ and prediction.scalar_error <= self.sigma8_tolerance)
174
+
175
+ if not trusted and prediction.error <= loose_tolerance:
176
+ self.n_approximate += 1
177
+ Cl, sigma8, sigma8_cb = from_blocks(prediction.blocks, online.lmax)
178
+ return Result(Cl, sigma8, sigma8_cb, emulated=True, error=prediction.error,
179
+ approximate=True)
180
+
181
+ if not trusted:
182
+ return self._run(oracle, params, x, learn=True)
183
+
184
+ self.n_emulated += 1
185
+
186
+ if not (self.check_every and self.n_emulated >= self._next_check):
187
+ Cl, sigma8, sigma8_cb = from_blocks(prediction.blocks, online.lmax)
188
+ return Result(Cl, sigma8, sigma8_cb, emulated=True, error=prediction.error)
189
+
190
+ # A check: the oracle too, and the error measured.
191
+ result = self._run(oracle, params, x, learn=False)
192
+ self._next_check = self.n_emulated + self._check_interval
193
+
194
+ if result is not None:
195
+
196
+ truth = to_blocks(result.Cl, result.sigma8, result.sigma8_cb)
197
+ error = online.error_between(prediction.blocks, truth)
198
+ self.check_errors.append(error)
199
+
200
+ # Checks grow rarer while they pass.
201
+ if error <= self.tolerance:
202
+ self._check_interval = min(2 * self._check_interval, 10 * self.check_every)
203
+ else:
204
+ self._check_interval = self.check_every
205
+ self._next_check = self.n_emulated + self._check_interval
206
+
207
+ if error > 3.0 * self.tolerance:
208
+ log.warning(
209
+ "fastcmb: a prediction was off by %.3g in ln L (predicted %.3g); "
210
+ "the point is learned.", error, prediction.error,
211
+ )
212
+ online.add(x, truth)
213
+
214
+ return result
215
+
216
+ def _run(self, oracle, params, x, learn: bool) -> Result | None:
217
+
218
+ self.n_oracle += 1
219
+
220
+ out = oracle(params)
221
+
222
+ if out is None:
223
+ return None
224
+
225
+ result = Result(out["Cl"], float(out["sigma8"]), float(out["sigma8_cb"]), emulated=False)
226
+
227
+ if learn:
228
+ self.online.add(x, to_blocks(result.Cl, result.sigma8, result.sigma8_cb))
229
+
230
+ return result
231
+
232
+ # ---------------------------------------------------------
233
+
234
+ def stats(self) -> dict:
235
+ """Calls to the oracle and the emulator, and what the checks found."""
236
+
237
+ errors = np.array(self.check_errors)
238
+
239
+ return {
240
+ "oracle": self.n_oracle,
241
+ "emulated": self.n_emulated,
242
+ "approximate": self.n_approximate,
243
+ "training_points": 0 if self.online is None else self.online.size,
244
+ "checks": errors.size,
245
+ "check_error_median": float(np.median(errors)) if errors.size else None,
246
+ "check_error_max": float(errors.max()) if errors.size else None,
247
+ }