"""
Wrappers for external nested samplers.
"""
import os
import sys
import glob
import shutil
import logging
import tempfile
from warnings import warn
from abc import ABC, abstractmethod
from typing import Sequence
import numpy as np
from gpry import mpi
from gpry.tools import (
NumpyErrorHandling,
generic_params_names,
remove_0_weight_samples,
check_and_return_bounds,
get_Xnumber,
)
# Helper for PolyChord, that needs a scalar-returning likelihood
ensure_scalar = lambda x: x[0] if hasattr(x, "__len__") else x
[docs]
class NestedSamplerNotInstalledError(Exception):
"""
Exception to be raised at initialization of any of the interfaces if the NS failed to
be imported.
"""
[docs]
class NSInterface(ABC):
"""
Meta-class for Nested Sampler interfaces.
"""
@abstractmethod
def __init__(self, bounds, verbosity=None):
pass
[docs]
@abstractmethod
def set_verbosity(self, verbose):
"""Sets the verbosity of the sampler at run time."""
[docs]
@abstractmethod
def set_prior(self, bounds):
"""Sets the prior used by the nested sampler."""
[docs]
@abstractmethod
def set_precision(self, **kwargs):
"""Sets precision parameters for the nested sampler."""
[docs]
@abstractmethod
def run(self, logp_func, param_names=None, out_dir=None, seed=None):
"""
Runs the nested sampler.
param_names (optional, otherwise x_[i] will be used) should be a list of sampled
parameter names, or a list of (name, label) tuples. Labels are interpreted as
LaTeX but should not include '$' signs.
Must return a tuple (X_MC, y_MC, w_MC, logZ, logZstd).
"""
[docs]
@abstractmethod
def delete_output(self, out_dir=None):
"""
Deletes the last sampler output.
If ``out_dir`` specified, deletes the one stored there instead.
"""
[docs]
@staticmethod
def process_out_dir(out_dir, default_prefix="ns_samples", random_if_undefined=True):
"""
Given an output root ``out_dir`` as ``folder/`` or ``folder/prefix``,
returns separately the folder path and the file name prefix.
If ``random_if_undefined`` is True (default), it returns a random temp folder.
Otherwise it raises ``ValueError``.
"""
if out_dir is None:
if random_if_undefined:
return tempfile.TemporaryDirectory().name, default_prefix
raise ValueError(
"No output root passed. Use ``random_if_undefined=True`` to generate a "
"random one."
)
base_dir, file_root = os.path.split(out_dir)
# If no slash in there, interpret as folder (since kwarg is 'out_dir')
if not base_dir:
base_dir, file_root = file_root, ""
base_dir = os.path.abspath(base_dir)
if file_root == "":
file_root = default_prefix
return base_dir, file_root
[docs]
class InterfacePolyChord(NSInterface):
"""
Interface for the PolyChord nested sampler, by W. Handley, M. Hobson & A. Lasenby.
See https://github.com/PolyChord/PolyChordLite
"""
def __init__(self, bounds, verbosity=3):
try:
from pypolychord.settings import PolyChordSettings # type: ignore
from pypolychord import run_polychord # type: ignore
self.globals = {"run_polychord": run_polychord}
except ModuleNotFoundError as excpt:
raise NestedSamplerNotInstalledError(
"External nested sampler Polychord cannot be imported. "
"Check out installation instructions at "
"https://github.com/PolyChord/PolyChordLite "
"(or select an alternative nested sampler, e.g. UltraNest)."
) from excpt
self.set_prior(bounds)
self.dim = len(bounds)
self.polychord_settings = PolyChordSettings(nDims=self.dim, nDerived=0)
# Don't write unnecessary files: takes lots of space and wastes time
self.settings = {
"read_resume": False,
"write_resume": False,
"write_live": False,
"write_dead": True,
"write_prior": False,
}
for setting, value in self.settings.items():
setattr(self.polychord_settings, setting, value)
self.set_verbosity(verbosity)
# Storage of last sample -- will only be defined for rank-0 MPI process
self.X_MC = None
self.y_MC = None
self.w_MC = None
self.last_polychord_result = None
[docs]
def set_verbosity(self, verbose):
"""Sets the verbosity of the sampler at run time."""
self.polychord_settings.feedback = verbose - 3
# 0: print header and result; not very useful: turn it to -1 if that's the case
if self.polychord_settings.feedback == 0:
self.polychord_settings.feedback = -1
[docs]
def set_prior(self, bounds):
"""Sets the prior used by the nested sampler."""
from pypolychord.priors import UniformPrior # type: ignore
self.prior = UniformPrior(*(bounds.T))
[docs]
def set_precision(self, **kwargs):
"""Sets precision parameters for the nested sampler."""
known = ["nlive", "num_repeats", "precision_criterion", "nprior", "max_ncalls"]
for p in known:
val = kwargs.pop(p, None)
if val is not None and p in [
"nlive",
"num_repeats",
"nprior",
"max_ncalls",
]:
val = get_Xnumber(val, "d", self.dim, int, p)
if val is not None:
setattr(self.polychord_settings, p, val)
if kwargs:
warn(f"Some precision parameters not recognized; ignored: {kwargs}")
[docs]
def run(self, logp_func, param_names=None, out_dir=None, seed=None):
"""
Runs the nested sampler.
param_names (optional, otherwise x_[i] will be used) should be a list of sampled
parameter names, or a list of (name, label) tuples. Labels are interpreted as
LaTeX but should not include '$' signs.
"""
# TEST! (for NORA only)
# More efficient for const-eval-speed GP's (not very significant)
# self.polychord_settings.synchronous = False
self.X_MC, self.y_MC, self.w_MC = None, None, None
self.logZ, self.logZstd = None, None
# Configure folders and settings
if mpi.is_main_process:
base_dir, file_root = self.process_out_dir(
out_dir, random_if_undefined=True
)
self.polychord_settings.base_dir = base_dir
self.polychord_settings.file_root = file_root
# Set seed (only that of rank 0 is used, and incremented internally by PolyChord)
# See line 83 in PolyChordLite/src/polychord/random_utils.F90
if seed is not None:
self.polychord_settings.seed = seed
mpi.share_attr(self, "polychord_settings")
# Run PolyChord!
# Flush stdout, since PolyChord can step over it if async (py not called with -u)
sys.stdout.flush()
with NumpyErrorHandling(all="ignore") as _:
self.last_polychord_result = self.globals["run_polychord"](
lambda X: (ensure_scalar(logp_func(X)), []),
nDims=self.dim,
nDerived=0,
settings=self.polychord_settings,
prior=self.prior,
)
# Process results
if mpi.is_main_process:
if param_names is None:
param_names = list(zip(*(2 * [generic_params_names(self.dim)])))
elif isinstance(param_names[0], str): # no labels specified
param_names = [(p, p) for p in param_names]
self.last_polychord_result.make_paramnames_files(param_names)
samples_T = np.loadtxt(self.last_polychord_result.root + ".txt").T
self.X_MC = samples_T[2:].T
# PolyChord stores chi**2 in 2nd col (contrary to getdist: -logp)
self.y_MC = -0.5 * samples_T[1]
self.w_MC = samples_T[0]
with open(self.last_polychord_result.root + ".stats", "r") as f:
for line in f:
if line.startswith("log(Z)"):
self.logZ, self.logZstd = [
float(n) for n in line.split("=")[1].split("+/-")
]
# Correct for the effect of the prior volume
self.logZ += np.log(np.prod(self.prior.b - self.prior.a))
return self.X_MC, self.y_MC, self.w_MC, self.logZ, self.logZstd
[docs]
def delete_output(self, out_dir=None):
"""
Deletes the last PolyChord output.
If ``out_dir`` specified, deletes the one stored there instead.
"""
if not mpi.is_main_process:
return
if out_dir is None:
base_dir = self.polychord_settings.base_dir
file_root = self.polychord_settings.file_root
else:
base_dir, file_root = self.process_out_dir(
out_dir, random_if_undefined=False
)
if not file_root:
# Delete whole folder
shutil.rmtree(base_dir)
return
files = glob.glob(os.path.join(base_dir, file_root + ".*"))
files += glob.glob(os.path.join(base_dir, file_root + "_*"))
files += glob.glob(os.path.join(base_dir, "clusters", file_root + "_*"))
for f in files:
if os.path.isfile(f):
os.remove(f)
# Delete empty folders that may have been created by PolyChord
try:
os.rmdir(os.path.join(base_dir, "clusters"))
os.rmdir(base_dir)
except OSError:
pass
[docs]
class InterfaceNessai(NSInterface):
"""
Interface for the ``nessai`` nested sampler, by M.J. Williams, J. Veitch and C.
Messenger.
See https://nessai.readthedocs.io
"""
def __init__(self, bounds, verbosity=3):
try:
from nessai.model import Model as NessaiModel # type: ignore
from nessai.flowsampler import FlowSampler # type: ignore
self.globals = {"NessaiModel": NessaiModel, "FlowSampler": FlowSampler}
except ModuleNotFoundError as excpt:
raise NestedSamplerNotInstalledError(
"External nested sampler 'nessai' cannot be imported. "
"Check out installation instructions at "
"https://nessai.readthedocs.io "
"(or select an alternative nested sampler, e.g. UltraNest)."
) from excpt
self.set_prior(bounds)
self.dim = len(self.bounds)
self.precision_settings = {}
self.flow_sampler_settings = {
"checkpointing": False,
"resume": False,
"plot": False,
"log_on_iteration": None, # will be defined later
# seed: None, # TODO: add seed
}
self.run_settings = {"plot": False, "save": False}
# TODO: could still avoid dumping nested_sampler_resume.pkl and proposal/
self.set_verbosity(verbosity)
# Storage of last sample -- will only be defined for rank-0 MPI process
self.X_MC = None
self.y_MC = None
self.w_MC = None
self.logZ = None
self.logZstd = None
self.output = None
self.last_nessai_result = None
[docs]
def set_verbosity(self, verbose):
"""Sets the verbosity of the sampler at run time."""
self.flow_sampler_settings["log_on_iteration"] = verbose > 3
[docs]
def set_prior(self, bounds):
"""Sets the prior used by the nested sampler."""
self.bounds = check_and_return_bounds(bounds)
[docs]
def set_precision(self, nlive=None, precision_criterion=None, **kwargs):
"""Sets precision parameters for the nested sampler."""
if nlive is not None:
self.precision_settings["nlive"] = get_Xnumber(
nlive, "d", self.dim, int, "nlive"
)
if precision_criterion is not None:
self.precision_settings["stopping"] = precision_criterion
if kwargs:
warn(f"Some precision parameters not recognized; ignored: {kwargs}")
[docs]
def run(self, logp_func, param_names=None, out_dir=None, seed=None):
"""
Runs the nested sampler.
param_names (optional, otherwise x_[i] will be used) should be a list of sampled
parameter names, or a list of (name, label) tuples. Labels are interpreted as
LaTeX but should not include '$' signs.
"""
NessaiModel = self.globals["NessaiModel"]
FlowSampler = self.globals["FlowSampler"]
class MyNessaiModel(NessaiModel):
"""
Translates the logp function into a nessai model
"""
def __init__(self, bounds, param_names):
if param_names is None:
param_names = generic_params_names(len(bounds))
self.log_prior_volume = np.sum(np.log(bounds[:, 1] - bounds[:, 0]))
self.bounds = dict(
(name, bounds[i]) for i, name in enumerate(param_names)
)
@property
def names(self):
return list(self.bounds)
def log_prior(self, x):
if not self.in_bounds(x).any():
return -np.inf
lp = np.single(0.0)
return lp
def log_likelihood(self, x):
if x.ndim == 0:
point = np.array([x[p] for p in self.names])
points = np.atleast_2d(point)
else:
points = np.array(
[[x[i][p] for p in self.names] for i in range(x.size)]
)
return logp_func(points)
self.output, _ = self.process_out_dir(out_dir, random_if_undefined=True)
with NumpyErrorHandling(all="ignore") as _:
sampler = FlowSampler(
MyNessaiModel(self.bounds, param_names=param_names),
output=self.output,
seed=seed,
**self.flow_sampler_settings,
**self.precision_settings,
)
sampler.run(**self.run_settings)
# Process results
self.last_nessai_result = sampler.ns.get_result_dictionary()
x = sampler.posterior_samples
# Copy the data from the structured array to the float array
dtype_new = np.dtype(
{
"names": x.dtype.names,
"formats": tuple([np.float64] * len(x.dtype.names)),
}
)
x = x.astype(dtype_new)
posterior_samples = x.view(np.float64).reshape(x.shape[0], -1)
self.X_MC = posterior_samples[:, :-3]
self.y_MC = posterior_samples[:, -2]
self.logZ = self.last_nessai_result["log_evidence"]
# Correct for the effect of the prior volume
self.logZ += np.log(np.prod(self.bounds[:, 1] - self.bounds[:, 0]))
self.logZstd = self.last_nessai_result["log_evidence_error"]
return self.X_MC, self.y_MC, None, self.logZ, self.logZstd
[docs]
def delete_output(self, out_dir=None):
"""
Deletes last the nessai output.
If ``out_dir`` specified, deletes the one stored there instead.
"""
if not mpi.is_main_process:
return
if out_dir is None:
output = self.output
else:
output, _ = self.process_out_dir(out_dir, random_if_undefined=False)
shutil.rmtree(output)
[docs]
class InterfaceUltraNest(NSInterface):
"""
Interface for the ``ultranest`` nested sampler, by J. Buchner.
See https://johannesbuchner.github.io/UltraNest
"""
def __init__(self, bounds, verbosity=3):
try:
import ultranest # type: ignore
except ModuleNotFoundError as excpt:
raise NestedSamplerNotInstalledError(
"External nested sampler 'UltraNest' cannot be imported. "
"Check out installation instructions at "
"https://johannesbuchner.github.io/UltraNest "
"(or select an alternative nested sampler, e.g. PolyChord or nessai)."
) from excpt
self.set_prior(bounds)
self.dim = len(self.bounds)
self.precision_settings = {}
self.sampler_settings = {
"resume": "overwrite",
"vectorized": True,
}
self.run_settings = {"viz_callback": False, "show_status": False}
self.set_verbosity(verbosity)
# Storage of last sample -- will only be defined for rank-0 MPI process
self.X_MC = None
self.y_MC = None
self.w_MC = None
self.logZ = None
self.logZstd = None
self.output = None
self.last_ultranest_result = None
[docs]
def set_verbosity(self, verbose):
"""Sets the verbosity of the sampler at run time."""
self.verbosity = verbose
[docs]
def set_prior(self, bounds):
"""Sets the prior used by the nested sampler."""
self.bounds = check_and_return_bounds(bounds)
widths = self.bounds[:, 1] - self.bounds[:, 0]
lowers = self.bounds[:, 0]
self.uniform_prior_transform = lambda quantiles: quantiles * widths + lowers
[docs]
def set_precision(
self,
nlive=None,
precision_criterion=None,
num_repeats=None,
max_ncalls=None,
**kwargs,
):
"""Sets precision parameters for the nested sampler."""
if nlive is not None:
self.precision_settings["min_num_live_points"] = get_Xnumber(
nlive, "d", self.dim, int, "nlive"
)
if precision_criterion is not None:
self.precision_settings["frac_remain"] = precision_criterion
if max_ncalls is not None:
self.precision_settings["max_ncalls"] = get_Xnumber(
max_ncalls, "d", self.dim, int, "max_ncalls"
)
if num_repeats is not None:
self.precision_settings["nsteps"] = get_Xnumber(
num_repeats, "d", self.dim, int, "num_repeats"
)
if kwargs:
warn(f"Some precision parameters not recognized; ignored: {kwargs}")
[docs]
def run(self, logp_func, param_names=None, out_dir=None, seed=None):
"""
Runs the nested sampler.
param_names (optional, otherwise x_[i] will be used) should be a list of sampled
parameter names, or a list of (name, label) tuples. Labels are interpreted as
LaTeX but should not include '$' signs.
"""
from ultranest import ReactiveNestedSampler # type: ignore
import ultranest.stepsampler as unst # type: ignore
from ultranest.mlfriends import SimpleRegion # type: ignore
if mpi.is_main_process:
self.output, _ = self.process_out_dir(out_dir, random_if_undefined=True)
sampler = ReactiveNestedSampler(
param_names or generic_params_names(self.dim),
logp_func,
self.uniform_prior_transform,
log_dir=self.output,
**self.sampler_settings,
)
if mpi.is_main_process:
if self.verbosity >= 5:
sampler.logger.setLevel(logging.DEBUG)
elif self.verbosity == 4:
sampler.logger.setLevel(logging.INFO)
else:
sampler.logger.setLevel(logging.WARNING)
# Use slice sampling
nsteps = self.precision_settings.pop("nsteps")
sampler.stepsampler = unst.SliceSampler(
nsteps=nsteps,
generate_direction=unst.generate_mixture_random_direction,
)
self.run_settings["region_class"] = SimpleRegion
with NumpyErrorHandling(all="ignore") as _:
self.last_ultranest_result = sampler.run(
**self.precision_settings,
**self.run_settings,
)
# Process results
if mpi.is_main_process:
w = self.last_ultranest_result["weighted_samples"]["weights"]
X = self.last_ultranest_result["weighted_samples"]["points"]
y = self.last_ultranest_result["weighted_samples"]["logl"]
self.w_MC, self.X_MC, self.y_MC = remove_0_weight_samples(w, X, y)
self.logZ = self.last_ultranest_result["logz"]
# Correct for the effect of the prior volume
self.logZ += np.log(np.prod(self.bounds[:, 1] - self.bounds[:, 0]))
self.logZstd = self.last_ultranest_result["logzerr"]
return self.X_MC, self.y_MC, self.w_MC, self.logZ, self.logZstd
[docs]
def delete_output(self, out_dir=None):
"""
Deletes the last ultranest output.
If ``out_dir`` specified, deletes the one stored there instead.
"""
if not mpi.is_main_process:
return
if out_dir is None:
output = self.output
else:
output, _ = self.process_out_dir(out_dir, random_if_undefined=False)
shutil.rmtree(self.output)
[docs]
class InterfaceBlackJAX(NSInterface):
"""
Interface for the BlackJAX nested sampler (Handley-lab fork with NS support).
Uses Nested Slice Sampling (NSS) with Hit-and-Run Slice Sampling as the
inner kernel. Fully JAX-native, JIT-compilable, and GPU-compatible.
See https://github.com/handley-lab/blackjax
"""
def __init__(self, bounds, verbosity=3):
try:
from blackjax.ns.nss import as_top_level_api as _nss_api
from blackjax.ns import utils as ns_utils
self.globals = {"nss_api": _nss_api, "ns_utils": ns_utils}
except (ModuleNotFoundError, ImportError, AttributeError) as excpt:
raise NestedSamplerNotInstalledError(
"BlackJAX nested sampler (handley-lab fork) cannot be imported. "
"Install it with: pip install git+https://github.com/handley-lab/blackjax.git "
"(or select an alternative nested sampler, e.g. UltraNest)."
) from excpt
self.set_prior(bounds)
self.dim = len(bounds)
self.precision_settings = {
# Match GPry's standard NORA cap used with PolyChord by default.
"nlive": 25 * self.dim,
"nprior": 250 * self.dim,
"max_steps": 5000,
"num_inner_steps": 5 * self.dim,
"precision_criterion": 0.01,
# ``num_delete`` controls how many particles are replaced per
# NSS outer step. The BlackJAX NSS kernel ``vmap``s the inner
# MCMC over this dimension; with the upstream default of 1, the
# vmap is over a singleton so each step does no parallel work.
# David Yallup's recommended target is roughly ``nlive//5``:
# the outer-step count drops ~5x, each step does ~5x more
# vectorized work, and per-call wall drops measurably (~40%
# in the 5D GaussianMix5D profile). Floor at 1 to retain
# correctness in pathological cases.
"num_delete": max(1, (25 * self.dim) // 5),
}
self.set_verbosity(verbosity)
# Storage
self.X_MC = None
self.y_MC = None
self.w_MC = None
self.logZ = None
self.logZstd = None
self._compiled_runtime_cache = {}
[docs]
def set_verbosity(self, verbose):
"""Sets the verbosity of the sampler at run time."""
self.verbose = verbose
[docs]
def set_prior(self, bounds):
"""Sets the prior used by the nested sampler."""
self.bounds = check_and_return_bounds(bounds)
[docs]
def set_precision(
self,
# Standardised ones used by NORA (PolyChord-compatible)
nlive=None,
num_repeats=None,
max_ncalls=None,
precision_criterion=None,
nprior=None,
# BlackJAX-specific (preferred if specified)
num_inner_steps=None, # = num_repeats
max_steps=None, # NS steps <= max_ncalls / num_repeats
num_delete=None, # number of particles deleted per step
**kwargs,
):
"""Sets precision parameters for the nested sampler."""
if nlive is not None:
self.precision_settings["nlive"] = get_Xnumber(
nlive, "d", self.dim, int, "nlive"
)
# Keep num_delete proportional to nlive unless the caller is also
# setting it explicitly (handled below).
if num_delete is None:
self.precision_settings["num_delete"] = max(
1, self.precision_settings["nlive"] // 5
)
if num_delete is not None:
self.precision_settings["num_delete"] = get_Xnumber(
num_delete, "d", self.dim, int, "num_delete"
)
# Prefer num_inner_steps if specified, but keep separate calls to
# get_Xnumber for clarity in error messages
if num_inner_steps is not None:
self.precision_settings["num_inner_steps"] = get_Xnumber(
num_inner_steps, "d", self.dim, int, "num_inner_steps"
)
elif num_repeats is not None:
self.precision_settings["num_inner_steps"] = get_Xnumber(
num_repeats, "d", self.dim, int, "num_repeats"
)
# Prefer max_steps if specified, but keep separate calls to
# get_Xnumber for clarity in error messages
if max_steps is not None:
self.precision_settings["max_steps"] = get_Xnumber(
max_steps, "d", self.dim, int, "max_steps"
)
elif max_ncalls is not None:
# One NSS outer step entails at least one deleted particle and a constrained
# MCMC update with multiple inner steps. This lower-bound mapping is still
# imperfect, but is materially closer to a likelihood-call budget than
# equating max_ncalls with the number of outer iterations.
max_ncalls = get_Xnumber(max_ncalls, "d", self.dim, int, "max_ncalls")
max_steps = max(1, max_ncalls // self.precision_settings["num_inner_steps"])
if precision_criterion is not None:
self.precision_settings["precision_criterion"] = precision_criterion
# NB: use of 'nprior' not implemented at the moment. Could be done by hand.
# if nprior is not None:
# self.precision_settings["nprior"] = get_Xnumber(
# nprior, "d", self.dim, int, "nprior"
# )
if kwargs:
warn(f"Some precision parameters not recognized; ignored: {kwargs}")
@staticmethod
def _stop_by_remaining_evidence(
dead_logls,
live_logl,
# Must nlive be = len(live_logl)? if so, remove this arg
nlive,
precision_criterion,
):
if (
precision_criterion is None
or precision_criterion <= 0
or len(dead_logls) <= 0
):
return False, None
def _logdiffexp(log_a, log_b):
"""Returns log(exp(log_a) - exp(log_b)) for log_a > log_b."""
return log_a + np.log1p(-np.exp(log_b - log_a))
logx_prev = 0.0
logz_dead = -np.inf
for idx, dead_logl in enumerate(dead_logls, start=1):
logx_curr = -idx / float(nlive)
logdx = _logdiffexp(logx_prev, logx_curr)
logz_dead = np.logaddexp(logz_dead, float(dead_logl) + logdx)
logx_prev = logx_curr
logz_live_upper = float(np.max(live_logl)) + logx_prev
logz_total_upper = np.logaddexp(logz_dead, logz_live_upper)
frac_remain = float(np.exp(logz_live_upper - logz_total_upper))
return frac_remain < precision_criterion, frac_remain
[docs]
def run(self, logp_func, param_names=None, out_dir=None, seed=None):
"""
Runs the BlackJAX nested sampler.
Parameters
----------
logp_func : callable
Log-likelihood function. Takes array X of shape (d,) or (n, d)
and returns scalar or array.
param_names : list, optional
Parameter names.
out_dir : str, optional
Not used (BlackJAX is in-memory), but kept for interface compatibility.
seed : int, optional
Random seed.
Returns
-------
(X_MC, y_MC, w_MC) : arrays of samples, log-likelihoods, and normalized weights.
"""
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
nss_api = self.globals["nss_api"]
ns_utils = self.globals["ns_utils"]
nlive = self.precision_settings["nlive"]
max_steps = self.precision_settings["max_steps"]
num_inner_steps = self.precision_settings["num_inner_steps"]
precision_criterion = self.precision_settings["precision_criterion"]
num_delete = self.precision_settings["num_delete"]
if seed is None:
seed = np.random.randint(0, 2**31)
rng_key = jax.random.PRNGKey(seed)
# Build parameter bounds dict for BlackJAX
if param_names is None:
param_names = generic_params_names(self.dim)
elif isinstance(param_names[0], (list, tuple)):
param_names = [p[0] for p in param_names]
elif not isinstance(param_names, Sequence):
raise ValueError("'param_names' must be a list of parameter names.")
bounds_dict = {
name: (float(self.bounds[i, 0]), float(self.bounds[i, 1]))
for i, name in enumerate(param_names)
}
# Generate initial particles from uniform prior
rng_key, init_key = jax.random.split(rng_key)
particles, logprior_fn = ns_utils.uniform_prior(init_key, nlive, bounds_dict)
# Prepare log-likelihood function
pure_callback_kwargs = (
{"vmap_method": "sequential"}
if "vmap_method" in jax.pure_callback.__code__.co_varnames
else {"vectorized": False}
)
def loglikelihood_fn(params):
x = jnp.array([params[name] for name in param_names])
def _numpy_logp(x_np):
# ``logp_func`` may return a 0-d or 1-element array; force
# to a plain Python float before promoting to np.float64
# to avoid the numpy-1.25 ndarray->scalar deprecation
# firing inside every NORA NS step (this callback is the
# tight inner loop of BlackJAX when no JAX adapter is
# available, e.g. in the e2e test runs).
return np.float64(
float(np.asarray(logp_func(np.asarray(x_np))).reshape(()))
)
result = jax.pure_callback(
_numpy_logp,
jax.ShapeDtypeStruct((), jnp.float64),
x,
**pure_callback_kwargs,
)
return result
# Build the NSS algorithm
algorithm = nss_api(
logprior_fn=logprior_fn,
loglikelihood_fn=loglikelihood_fn,
num_inner_steps=num_inner_steps,
num_delete=num_delete,
)
init_fn = algorithm.init
step_fn = algorithm.step
# Initialize the NS loop
rng_key, init_key = jax.random.split(rng_key)
state = init_fn(particles, rng_key=init_key)
# Run the NS loop, collecting dead particles
dead_list = []
dead_logls = []
for i in range(max_steps):
rng_key, step_key = jax.random.split(rng_key)
state, info = step_fn(step_key, state)
dead_list.append(info)
dead_logls.extend(np.sort(np.asarray(info.particles.loglikelihood).ravel()))
# Check stopping criterion
if precision_criterion is not None and i > 0:
# Check every ``nlive`` *dead particles*, not every ``nlive``
# steps. With ``num_delete > 1``, each outer step generates
# ``num_delete`` deaths -- the original ``(i + 1) % nlive``
# check fires once per ``nlive * num_delete`` particles, so
# convergence detection lags by ``num_delete x``. For
# ``num_delete=25`` and the precision_criterion=0.01
# default, that lag inflates the dead-particle pool ~10x
# and adds significant wall-time in NORA's downstream
# ranking + finalise.
check_every = max(1, nlive // max(1, num_delete))
if (i + 1) % check_every == 0:
should_stop, frac_remain = self._stop_by_remaining_evidence(
dead_logls=dead_logls,
live_logl=np.asarray(state.particles.loglikelihood),
nlive=nlive,
precision_criterion=precision_criterion,
)
if should_stop:
if self.verbose > 3:
print(
f"BlackJAX NS converged at step {i + 1}, "
f"remaining evidence fraction = {frac_remain:.4g}"
)
break
# Finalise: combine dead particles with final live points
dead_info = ns_utils.finalise(state, dead_list, update_info=False)
# Return dead particles with importance weights, not resampled posterior,
# which would concentrate samples at the mode (worse for NORA ranking)
rng_key, weight_key = jax.random.split(rng_key)
# ``ns_utils.log_weights`` is responsible for ~50% of per-call
# BlackJAX time (~0.9s on 5D NORA, per /tmp/gpry_finalise_timing.py).
logw = ns_utils.log_weights(weight_key, dead_info).mean(axis=-1)
particles = dead_info.particles
# Vectorized construction of X_mc.
X_mc = np.column_stack(
[np.asarray(particles.position[name]) for name in param_names]
)
y_mc = np.asarray(particles.loglikelihood)
# Convert log-weights to normalized importance weights.
w_mc = np.exp(logw - np.max(logw))
w_mc = w_mc / np.sum(w_mc)
self.X_MC = X_mc
self.y_MC = (
y_mc
)
self.w_MC = w_mc
return self.X_MC, self.y_MC, self.w_MC, None, None
[docs]
def delete_output(self, out_dir=None):
"""BlackJAX is in-memory, nothing to delete."""
pass
# Implemented interfaces as a dict, for convenience.
_ns_interfaces = {
"polychord": InterfacePolyChord,
"ultranest": InterfaceUltraNest,
"nessai": InterfaceNessai,
"blackjax": InterfaceBlackJAX,
}