"""
Module implementing a wrapper for the Cobaya inference framework.
"""
import logging
from copy import deepcopy
from inspect import cleandoc
from typing import Union
import getdist # type: ignore
import numpy as np
from cobaya.yaml import yaml_load # type: ignore
from cobaya.sampler import Sampler # type: ignore
from cobaya.log import LoggedError # type: ignore
from cobaya.output import load_samples # type: ignore
from cobaya.tools import get_external_function, recursive_update # type: ignore
from cobaya.collection import SampleCollection # type: ignore
from cobaya.conventions import OutPar, minuslogprior_names # type: ignore
from gpry import mpi
from gpry.run import Runner, _default_mc_sampler
import gpry.mc as gprymc
__min_cobaya_version__ = "3.6"
yaml_info = """
# Options regarding the Bayesian optimization loop.
# NB: 'd' after a number means the dimensionality of the sampling space,
# and a number following 'd' means the power of the dimensionality factor.
# General options for the main loop
options:
# Number of finite initial truth evaluations before starting the learning loop
n_initial: 3d
# Maximum number of truth evaluations at initialization. If it is reached before
# `n_initial` finite points have been found, the run will fail. To avoid that, try
# decreasing the prior volume.
max_initial: 30d1.5
# Maximum number of truth evaluations before the run stops. This is useful for e.g.
# restricting the maximum computation resources.
max_total: 70d1.5
# Maximum number of sampling points accepted into the GP training set before the run
# stops. If this limit is frequently saturated, try decreasing the prior volume.
max_finite: # default (undefined) = max_total
# Number of points which are acquired with Kriging believer for every acquisition step.
# Gets adjusted (with 20% tol.) to match a multiple of the num. of parallel processes.
n_points_per_acq: d
# Number of iterations between full GP hyperparameters fits, including several
# restarts of the optimizer. If 'np.inf' or a large number, never refit with restarts.
fit_full_every: 2
# Similar to `fit_full_every`, but with a single optimizer run from the last optimum.
# Overridden by `fit_full_every` if it applies. Pass np.inf or a large number to never
# refit from last optimum (hyperparameters kept constant in that iteration).
fit_simple_every: 1 # every iteration
# The surrogate GP regressor used for interpolating the posterior
surrogate:
regressor:
# Spatial correlation kernel and params, e.g. RBF, Matern, {Matern: {nu: 2.5}}, ...
kernel: RBF
# Priors for the output and length scale, in normalised logp units
output_scale_prior: [1e-2, 1e3]
length_scale_prior: [1e-3, 1e1]
# Noise level in logp units; increase for numerically noisy likelihoods
noise_level: 1e-1
# Whether to fix the noise or to fit for a white-noise additive kernel term
noise_fixed: False
# Hyperparameter fitting: optimizer (from scipy) and number of restarts for full fits
optimizer: fmin_l_bfgs_b
# Number of restarts of the hyperparameter optimizer
n_restarts_optimizer: 20d
# Treatment of infinities and large negative values; False for no classifier
infinities_classifier:
svm:
# Difference in standard deviations ('s') for considering a value as -infinity
threshold: 20s
trust_region:
# Difference in standard deviations ('s') for considering a value as -infinity
threshold: 30s
# Factor by which to multiply the sides of the hyperrectangular trust region
factor: 2 # none for 1
# Factor used to clip the GPR from above, to avoid overshoots (undefined to disable)
clip_factor: 1.1 # none: no clipping
# Verbosity (set only if different from the overall verbosity)
verbose:
# The acquisition class, function and their options
gp_acquisition:
# Acquisition function and its arguments (if passed as dict)
acq_func:
LogExp:
zeta_scaling: 0.85 # larger z_scaling for more exploratory acquisition
# Verbosity (set only if different from the overall verbosity)
verbose:
# Acquisition engine: NORA or BatchOptimizer
engine: NORA
# Options for the engine
options_NORA:
# nested sampler used for acquisition
sampler: # undefined: in order, of available: polychord > ultranest > nessai
mc_every: 2 # number of iterations between full NS runs
nlive_per_training: 3 # number of live points per training sample
nlive_max: 25d # cap for the number of live points
num_repeats: 5d # number of steps of slice chains (polychord only)
precision_criterion_target: 0.01 # precision criterion for the NS
nprior_per_nlive: 10 # number of prior points in the initial sample, times nlive
max_ncalls: # maximum number of calls to the GPR model during NS (none: infinite)
options_BatchOptimizer:
proposer: # default (undefined): a mixture of uniform and centroids
acq_optimizer: fmin_l_bfgs_b # scipy optimizer to use
n_restarts_optimizer: 5d # number of restarts during hyperparameter fitting
n_repeats_propose: 10 # number of starting points drawn from the proposer
# Proposer used for drawing the initial training samples before running
# the acquisition loop. One of [reference, prior, uniform].
# Can be specified as dict with args, e.g. {reference: {max_tries: 1000}}
initial_proposer: reference
# Convergence criterion.
# (add or replace by DontConverge to run until evaluation budget exhausted)
# `policy` can be [n]ecessary (default), [s]ufficient, both ([ns]), or [m]onitoring
convergence_criterion:
CorrectCounter: {policy: s}
GaussianKL: {policy: n, limit: 5e-02} # ignored if NORA not used
TrainAlignment: {policy: n} # ignored if NORA not used
# Sampler used to generate intermediate and final samples from the surrogate model
mc_options: nested # pass as a dict for options
# Produce progress plots (inside the gpry_output dir).
# One can specify options detailing which plots will be made, and in which format, e.g.:
# {timing: True, convergence: True, trace: False, slices: False, format: svg}
# (Adds significant overhead for fast likelihoods.)
plots: False
# Fiducial point and/or MC samples
fiducial_point: # dict of {parameters: values}; can use keys 'logpost' and 'loglike'.
fiducial_mc: # path to MC samples in Cobaya-compatible format
# Function run each iteration after adapting the recently acquired points and
# the computation of the convergence criterion. See docs for implementation.
callback:
# Whether the callback function handles MPI-parallelization internally
# Otherwise run only by the rank-0 process
callback_is_MPI_aware:
# Change to increase or reduce verbosity. If None, it is handled by Cobaya.
# '3' produces general progress output (default for Cobaya if None),
# and '4' debug-level output
verbose:
"""
[docs]
class CobayaWrapper(Sampler):
"""GPry: a package for Bayesian inference of expensive likelihoods using GPs."""
sampler_type: str = _default_mc_sampler
_gpry_output_dir: str = "gpry_output"
# Resume:
_at_resume_prefer_new = [
"plots",
"callback",
"callback_is_MPI_aware",
"verbose",
"mc_options",
]
[docs]
@classmethod
def get_class_options(cls, **kwargs):
return yaml_load(yaml_info)
[docs]
def initialize(self):
"""
Initializes GPry.
"""
# Set some args for the Runner that are derived from Cobaya
if self.verbose is None:
if self.log.getEffectiveLevel() == logging.NOTSET:
self.verbose = 3
elif self.log.getEffectiveLevel() <= logging.DEBUG:
self.verbose = 4
elif self.log.getEffectiveLevel() <= logging.INFO:
self.verbose = 3
else:
self.verbose = 2
# Prepare output
self.path_checkpoint = self.get_checkpoint_dir(self.output)
self.output_strategy = "resume" if self.output.is_resuming() else "overwrite"
# Do a manual recursive update, since the depth of some settings here is >2:
defaults = self.get_class_options()
for attr in ["surrogate", "gp_acquisition"]:
upd_values = recursive_update(defaults[attr], getattr(self, attr, {}))
setattr(self, attr, upd_values)
# Grab the relevant acq options, merge them, and kick out the unused ones
gp_acq_input = deepcopy(self.gp_acquisition)
gp_acq_engine = gp_acq_input.pop("engine", "BatchOptimizer")
gp_acq_engine_options = None
for k in list(gp_acq_input):
if k.startswith("options_"):
gp_acq_engine_options = gp_acq_input.pop(k)
if k.lower().endswith(gp_acq_engine.lower()):
gp_acq_input.update(gp_acq_engine_options or {})
gp_acq_input = {gp_acq_engine: gp_acq_input}
# Initialize the runner
try:
self.gpry_runner = Runner(
loglike=self.model,
surrogate=self.surrogate,
gp_acquisition=gp_acq_input,
initial_proposer=self.initial_proposer,
convergence_criterion=self.convergence_criterion,
mc=self.mc_options,
options=self.options,
callback=(
get_external_function(self.callback) if self.callback else None
),
callback_is_MPI_aware=self.callback_is_MPI_aware,
checkpoint=self.path_checkpoint,
load_checkpoint=self.output_strategy,
seed=self._rng,
plots=self.plots,
verbose=self.verbose,
)
except (ValueError, TypeError) as excpt:
raise LoggedError(
self.log, f"Error when initializing GPry: {str(excpt)}"
) from excpt
# Set sampler type. Update info too, so that the output gets updated.
self.sampler_type = list(self.gpry_runner._mc_options)[0].lower()
self._updated_info["sampler_type"] = self.sampler_type
# Where the Cobaya-compatible samples will be stored
self.collection = None
# Fiducial quantities and samples, for comparison plots
if self.fiducial_point is not None:
self.set_fiducial_point(self.fiducial_point)
if self.fiducial_mc is not None:
self.set_fiducial_mc(self.fiducial_mc)
[docs]
def set_fiducial_point(self, fiducial_point):
"""
Sets a fiducial point for the GPry runner.
Parameters
----------
fiducial_point: dict
Dict containing the sampled parameters as keys with their fiducial values.
May containg ``logpost`` or ``loglike`` keys.
"""
try:
X = [
fiducial_point.get(p)
for p in self.model.parameterization.sampled_params()
]
logpost = fiducial_point.pop("logpost", None)
loglike = fiducial_point.pop("loglike", None)
self.gpry_runner.set_fiducial_point(X, logpost=logpost, loglike=loglike)
except (TypeError, AttributeError, KeyError) as excpt:
raise LoggedError(
self.log, f"Error when setting fiducial point {fiducial_point}: {excpt}"
) from excpt
[docs]
def set_fiducial_mc(self, path_to_fiducial_mc):
"""
Sets fiducial MC samples for the GPry runner.
Parameters
----------
path_to_fiducial_mc: str
Path to Cobaya-loadable MC samples with a compatible model.
"""
try:
fiducial_mc = load_samples(path_to_fiducial_mc, combined=True)
except FileNotFoundError as excpt:
raise LoggedError(
self.log,
f"Error when loading fiducial MC samples from {path_to_fiducial_mc}: "
f"{excpt}",
) from excpt
try:
X = fiducial_mc[
list(self.model.parameterization.sampled_params())
].to_numpy()
# Prefer likelihood in case priors are different
if "chi2" in fiducial_mc.columns:
logpost = None
loglike = -0.5 * fiducial_mc["chi2"].to_numpy()
else:
logpost = -fiducial_mc["minuslogpost"].to_numpy()
loglike = None
weights = fiducial_mc["weight"]
self.gpry_runner.set_fiducial_mc(
X, logpost=logpost, loglike=loglike, weights=weights
)
except Exception as excpt:
raise LoggedError(
self.log, f"Error when setting fiducial MC samples: {excpt}"
) from excpt
[docs]
def run(self):
"""
Gets the initial training points and starts the acquisition loop.
"""
if mpi.is_main_process:
self.log.info("Starting GPry learning loop...")
try:
self.gpry_runner.run()
except Exception as excpt:
raise LoggedError(
self.log,
f"GPry failed during learning: {excpt.__class__.__name__}: {excpt}",
) from excpt
if mpi.is_main_process:
if self.gpry_runner.has_converged:
self.log.info("Learning stage finished successfully!")
else:
self.log.info(
"Learning stage failed to converge! MC sample produced anyway."
)
# Plot surrogate triangle anyway
plot_mc_kwargs = {"output_dpi": 200}
if "ext" in self.plots:
plot_mc_kwargs["ext"] = self.plot["ext"]
try:
self.gpry_runner.plot_mc(**plot_mc_kwargs)
except Exception as excpt:
self.mpi_warning(
f"Could not do final corner plot for unconverged run: {excpt}"
)
# Preparing Cobaya-compatible samples
if mpi.is_main_process:
self.collection = self.last_mc_samples_as_collection()
# Write them, if applicable
if self.collection is not None:
self.collection.out_update()
[docs]
def last_mc_samples_as_collection(self):
"""
Creates a cobaya SampleCollection out of the last MC samples of GPry.
Fills individual likelihoods and derived parameters with nan's.
Returns
-------
SampleCollection
"""
last_mc_samples = self.gpry_runner.last_mc_samples(copy=True)
if last_mc_samples is None:
return None
collection = SampleCollection(
self.model,
output=self.output,
name="1",
resuming=False,
sample_type=self.sampler_type,
)
# Hack -- create contents directly, since checks for individual likelihoods,
# etc would fail.
collection._data[OutPar.weight] = last_mc_samples["w"]
collection._data[OutPar.minuslogpost] = -last_mc_samples[gprymc._name_logp]
for i, param in enumerate(self.model.parameterization.sampled_params()):
collection._data[param] = last_mc_samples["X"][:, i]
# Add prior and likelihood
logpriors = np.array(
[self.model.logpriors(X_i) for X_i in last_mc_samples["X"]]
)
collection._data[minuslogprior_names(self.model.prior)] = -logpriors
collection._data[OutPar.minuslogprior] = -np.sum(logpriors, axis=1)
collection._data[OutPar.chi2] = 2 * (
collection._data[OutPar.minuslogpost]
- collection._data[OutPar.minuslogprior]
)
return collection
[docs]
def samples(
self,
combined: bool = False,
skip_samples: float = 0,
to_getdist: bool = False,
) -> Union[SampleCollection, "getdist.MCSamples"]:
"""
Returns the last sample from the surrogate posterior.
Parameters
----------
combined: bool, default: False
If ``True`` returns the same, single posterior for all processes. Otherwise,
it is only returned for the root process.
skip_samples: int or float, default: 0
Skips some amount of initial samples (if ``int``), or an initial fraction of
them (if ``float < 1``). If concatenating (``combined=True``), skipping is
applied before concatenation. Forces the return of a copy.
If the sampler used to draw from the surrogate model is a nested sampler,
this setting has no effect, and raises a warning if greater than 0.
to_getdist: bool, default: False
If ``True``, returns a single :class:`getdist.MCSamples` instance, containing
all samples, for all MPI processes (``combined`` is ignored).
Returns
-------
SampleCollection, getdist.MCSamples
The sample from the surrogate posterior.
"""
if skip_samples and self.sampler_type.lower() == "nested":
self.mpi_warning(
"Initial samples should not be skipped in nested sampling. "
"Ignoring 'skip_samples' keyword."
)
collection = self.collection
if not combined and not to_getdist:
return collection # None for MPI ranks > 0
# In all remaining cases, we return the same for all ranks
if to_getdist:
if mpi.is_main_process:
collection = collection.to_getdist()
return mpi.bcast(collection)
[docs]
def products(
self,
combined: bool = False,
skip_samples: float = 0,
to_getdist: bool = False,
) -> dict:
"""
Returns the products of the run: an MC sample of the surrogate posterior under
``sample``, and the GPRy ``Runner`` object under ``runner``.
Parameters
----------
combined: bool, default: False
If ``True`` returns the same, single posterior for all processes. Otherwise,
it is only returned for the root process.
skip_samples: int or float, default: 0
Skips some amount of initial samples (if ``int``), or an initial fraction of
them (if ``float < 1``). If concatenating (``combined=True``), skipping is
applied before concatenation. Forces the return of a copy.
If the sampler used to draw from the surrogate model is a nested sampler,
this setting has no effect, and raises a warning if greater than 0.
to_getdist: bool, default: False
If ``True``, returns a single :class:`getdist.MCSamples` instance, containing
all samples, for all MPI processes (``combined`` is ignored).
"""
return {
"runner": self.gpry_runner,
"sample": self.samples(
combined=combined, skip_samples=skip_samples, to_getdist=to_getdist
),
}
[docs]
def plot(
self,
ext="png",
timing=True,
convergence=True,
trace=True,
slices=False,
corner=False,
corner_final=None,
):
"""
Creates some progress plots and saves them at the ``gpry_output`` path.
Parameters
----------
ext : str (default ``"png"``)
Format for the plots, among the available ones in ``matplotlib``.
timing : bool (default: True)
Plot histogram of timing per iteration (totals in legend).
convergence : bool (default: True)
Plot the evolution of the convergence criterion (included in ``trace`` plot).
trace : bool (default: True)
Plot the evolution of the run: convergence criterion, surrogate log(p) and
parameters.
slices : bool (default: False)
Plots slices per training samples.
Slow -- use for diagnosis only.
corner : bool (default: False)
Creates a corner plot per iteration (contours for current GP shown only if
using NORA). Slow -- use for diagnosis only.
corner_final : bool, optional (default: None)
Whether the final corner plot is created. Needs a surrogate mc sample.
If undefined, it is created only if the run has converged.
"""
self.gpry_runner.plot_progress(
ext=ext,
timing=timing,
convergence=convergence,
trace=trace,
slices=slices,
corner=corner,
corner_final=corner_final,
)
[docs]
@classmethod
def get_checkpoint_dir(cls, output=None):
"""
Folder where the checkpoint output of GPry is going to be saved, given a Cobaya
``OutputReadOnly`` instance.
Parameters
----------
output: cobaya.output.Output, cobaya.output.DummyOutput, optional
Cobaya output instance. Can be a dummy one or None, in which case a temporary
folder will be created.
Returns
-------
checkpoint_dir: str|None
Relative folder where the GPry checkpoint will be saved (None if dummy output)
Examples
--------
Assuming that ``cls._gpry_output_dir = "gpry_output"``:
>>> from cobaya.output import get_output
>>> cls.get_checkpoint_dir(get_output("folder/"))
'folder/gpry_output'
>>> cls.get_checkpoint_dir(get_output("folder/prefix"))
'folder/prefix_gpry_output'
>>> cls.get_checkpoint_dir(get_output()) # dummy output
None
"""
if output:
return output.add_suffix(cls._gpry_output_dir, separator="_")
return None
[docs]
@classmethod
def output_files_regexps(cls, output, info=None, minimal=False):
"""
Returns a list of tuples `(regexp, root)` of output files potentially produced.
If `root` in the tuple is `None`, `output.folder` is used.
If `minimal=True`, returns regexp's for the files that should really not be there
when we are not resuming: GPry checkpoint products and the MC sample from the
surrogate.
"""
return [
(None, cls.get_checkpoint_dir(output)),
(output.collection_regexp(name="1"), None),
]
[docs]
@staticmethod
def is_nora(info):
"""Returns True if NORA is being used."""
acq_method = list((info or {}).get("gp_acquisition", {}) or {})
return (
len(acq_method) > 0
and isinstance(acq_method[0], str)
and acq_method[0].lower() == "nora"
)
[docs]
@classmethod
def get_desc(cls, info=None):
nora_string = (
r"using the NORA parallelised acquisition approach \cite{Torrado:2023cbj}"
)
if info is None:
# Unknown case (no info passed)
nora_string = f" [(if gp_acquisition: NORA) {nora_string}]"
else:
nora_string = " " + nora_string if cls.is_nora(info) else ""
return (
"GPry: a package for Bayesian inference of expensive likelihoods "
r"with Gaussian Processes \cite{Gammal:2022eob}" + nora_string + "."
)
[docs]
@classmethod
def get_bibtex(cls):
return cleandoc(r"""
@article{Gammal:2022eob,
author = {{El Gammal}, Jonas and Sch\"oneberg, Nils and Torrado, Jes\'us and Fidler, Christian},
title = "{Fast and robust Bayesian Inference using Gaussian Processes with GPry}",
eprint = "2211.02045",
archivePrefix = "arXiv",
primaryClass = "astro-ph.CO",
month = "11",
year = "2022"
# Cite only if using NORA as gp_acquisition (see desc.)
# Do not forget to cite the nested sampler used too.
@article{Torrado:2023cbj,
author = {Torrado, Jes\'us and Sch\"oneberg, Nils and Gammal, Jonas El},
title = "{Parallelized Acquisition for Active Learning using Monte Carlo Sampling}",
eprint = "2305.19267",
archivePrefix = "arXiv",
primaryClass = "stat.ML",
month = "5",
year = "2023"
}""")