Source code for spotgp.samplers.blackjax

"""BlackJAX NUTS sampler for GP hyperparameters."""

import logging
import os

import jax
import jax.numpy as jnp
import numpy as np

from .base import MCMCSampler

logger = logging.getLogger("spotgp")


[docs] class BlackJAXSampler(MCMCSampler): """ NUTS sampler using the BlackJAX library. Inherits diagnostics, summary, plotting, and dict conversion from MCMCSampler. Adds ``run_map``, ``run_warmup``, and ``run_sampling`` for gradient-based No-U-Turn sampling with dual-averaging step-size adaptation. When multiple chains are requested, sampling is parallelized across available devices via ``jax.pmap``. Chains are distributed evenly across devices (``n_chains`` must be divisible by ``jax.device_count()``). On a single GPU this behaves identically to the previous ``jax.vmap`` implementation. Parameters ---------- gp : GPSolver A configured GPSolver instance. save_dir : str, optional Directory for all outputs produced by this sampler (corner plots, covariance plots, etc.). Created automatically if it does not exist. When set, ``save_checkpoint`` will default to saving the checkpoint inside this directory. checkpoint_file : str, optional Path to the checkpoint file. When provided, overrides the default ``save_dir/mcmc_checkpoint.npz``. If neither ``checkpoint_file`` nor ``save_dir`` is given, no checkpoint file is set until one is passed to a later method. """ def __init__(self, gp, save_dir="results", checkpoint_file="mcmc_checkpoint.npz"): super().__init__(gp) if save_dir is not None: import os os.makedirs(save_dir, exist_ok=True) self.save_dir = save_dir if checkpoint_file is not None: self._checkpoint_file = os.path.join(save_dir, checkpoint_file) else: self._checkpoint_file = None self._n_devices = jax.device_count()
[docs] def run_map(self, nopt=10, keys=None, checkpoint_file=None, theta0=None, **kwargs): """ Find MAP solutions via parallel multi-start optimization. Runs ``GPSolver.fit_map_parallel`` and stores the results. If the checkpoint file already contains MAP data, loads from it instead of re-running the optimization. Parameters ---------- nopt : int Number of independent optimization restarts (default 10). keys : list of str, optional Parameter names to optimize. If None, uses all bounded parameters from GPSolver. theta0 : dict, optional Initial parameter guess to include as one of the optimization starting points. Replaces one random start so the total number of restarts stays ``nopt``. checkpoint_file : str, optional Path to save/load MAP solutions. If provided, also updates the sampler's default checkpoint path. Defaults to ``self._checkpoint_file``. **kwargs Additional keyword arguments passed to ``GPSolver.fit_map_parallel`` (e.g. ``method``, ``maxiter``). Returns ------- all_theta_maps : list of dict All MAP solutions sorted by objective (best first). """ if checkpoint_file is not None: self._checkpoint_file = checkpoint_file path = self._checkpoint_file # Try loading from disk if checkpoint file is provided if path is not None and os.path.exists(path): data = np.load(path, allow_pickle=True) if "all_theta_maps" in data: all_theta_maps = list(data["all_theta_maps"]) data.close() logger.info(f"Loaded {len(all_theta_maps)} MAP solutions from {path}") self.all_theta_maps = all_theta_maps self.theta_map = all_theta_maps[0] return all_theta_maps data.close() # Run optimization gp = self.gp if keys is None: keys = list(gp.param_keys) logger.info(f"Finding MAP solution ({nopt} restarts, returning all)...") all_theta_maps, all_results = gp.fit_map_parallel( nopt=nopt, keys=keys, return_all=True, theta0=theta0, **kwargs) self.all_theta_maps = all_theta_maps self.theta_map = all_theta_maps[0] # Compute log-likelihood for each MAP solution map_loglikes = [] for tm in all_theta_maps: if isinstance(tm, dict): arr = np.array([float(tm[k]) for k in gp.param_keys], dtype=np.float64) else: arr = np.asarray(tm, dtype=np.float64) map_loglikes.append(float(gp.log_likelihood_fn(arr))) self.map_loglikes = np.array(map_loglikes) logger.info(f"MAP solution: {self.theta_map}") # Save to checkpoint file if path is not None: _path = path if path.endswith(".npz") else path + ".npz" # Merge with existing checkpoint data if present save_kwargs = {} if os.path.exists(_path): existing = np.load(_path, allow_pickle=True) for k in existing.files: save_kwargs[k] = existing[k] existing.close() save_kwargs["theta_map"] = self.theta_map save_kwargs["all_theta_maps"] = all_theta_maps save_kwargs["map_loglikes"] = self.map_loglikes np.savez(path, **save_kwargs) logger.debug(f"MAP solutions saved to {path}") self._map_completed = True return all_theta_maps
def _run_pathfinder_warmup(self, rng_key, theta_inits, log_posterior_fn, target_accept=0.8, maxiter=100, maxcor=10, num_elbo_samples=200): """Multi-path Pathfinder warmup: run single-path Pathfinder from each starting position, select the best by ELBO, and extract a diagonal mass matrix from the L-BFGS inverse Hessian factors. Parameters ---------- rng_key : jax.random.PRNGKey theta_inits : jnp.ndarray, shape (n_paths, n_params) Starting positions (e.g. top MAP solutions). log_posterior_fn : callable Un-normalized log-density. target_accept : float Target NUTS acceptance rate (used for step-size selection). maxiter : int Maximum L-BFGS iterations per path (default 100). maxcor : int L-BFGS history size (default 10). num_elbo_samples : int Samples per path for ELBO estimation (default 200). Returns ------- best_position : jnp.ndarray, shape (n_params,) Position from the path with highest ELBO. inv_mass_diag : jnp.ndarray, shape (n_params,) Diagonal inverse mass matrix from the best path's L-BFGS inverse Hessian approximation. step_size : float Initial NUTS step size derived from the mass matrix. all_positions : jnp.ndarray, shape (n_paths, n_params) Best position from each path (for chain initialization). """ from blackjax.vi.pathfinder import approximate as pf_approximate n_paths = theta_inits.shape[0] n_params = theta_inits.shape[1] logger.info(f"Pathfinder warmup: {n_paths} paths, " f"maxiter={maxiter}, maxcor={maxcor}...") best_states = [] for i in range(n_paths): path_key = jax.random.fold_in(rng_key, i) state, info = pf_approximate( path_key, log_posterior_fn, theta_inits[i], num_samples=num_elbo_samples, maxiter=maxiter, maxcor=maxcor, ) best_states.append(state) logger.info(f" Path {i}: ELBO = {float(state.elbo):.2f}") # Select best path by ELBO elbos = jnp.array([s.elbo for s in best_states]) best_idx = int(jnp.argmax(elbos)) best = best_states[best_idx] logger.info(f" Best path: {best_idx} (ELBO = {float(best.elbo):.2f})") # Extract diagonal inverse mass matrix from L-BFGS factors. # The approximate inverse Hessian is: # H^{-1} = diag(alpha) + beta @ gamma @ beta^T # We take the diagonal for a diagonal mass matrix. alpha = best.alpha beta = best.beta gamma = best.gamma bg = beta @ gamma # (n_params, 2*maxcor) bgbt_diag = jnp.sum(bg * beta, axis=1) # diag(beta @ gamma @ beta^T) inv_mass_diag = alpha + bgbt_diag # Clamp extreme values for stability (same logic as window_adaptation path) median_var = jnp.median(inv_mass_diag) inv_mass_diag = jnp.clip(inv_mass_diag, median_var * 1e-4, median_var * 1e4) # Ensure all positive inv_mass_diag = jnp.maximum(inv_mass_diag, 1e-10) # Step size heuristic: use dual averaging target rate step_size = float(jnp.median(jnp.sqrt(inv_mass_diag))) step_size = max(step_size, 1e-5) all_positions = jnp.array([s.position for s in best_states]) logger.info(f" Adapted step size: {step_size:.6f}") logger.info(f" Inv mass diag range: [{float(inv_mass_diag.min()):.2e}, " f"{float(inv_mass_diag.max()):.2e}]") return best.position, inv_mass_diag, step_size, all_positions
[docs] def run_warmup(self, n_warmup=500, theta_init=None, mass_matrix_method="hessian_map", step_size=None, rng_key=None, target_accept=0.8, progress_bar=False, n_chains=1, checkpoint_file=None, warmup_method="window_adaptation", pathfinder_maxiter=100, pathfinder_maxcor=10, pathfinder_num_elbo=200): """ Run warmup phase: adapt step size and mass matrix. Supports three warmup strategies: - ``"window_adaptation"`` (default): BlackJAX's standard dual-averaging window adaptation of both step size and mass matrix. - ``"pathfinder"``: multi-path Pathfinder via L-BFGS. - ``"dual_averaging"``: fixes the mass matrix (from Hessian at MAP) and only adapts the step size. After warmup, adapted parameters are stored on the sampler and a checkpoint is saved (if ``checkpoint_file`` is set). Parameters ---------- n_warmup : int Number of warmup steps (default 500). theta_init : dict or array_like, optional Initial position. If None, uses GPSolver's MAP estimate. Can also be a list of dicts or 2-D array for per-chain starting points. mass_matrix_method : {"hessian_map", "fisher", "laplace", "diagonal", None} Method to estimate the mass matrix. step_size : float, optional Initial NUTS step size. If None, a heuristic is used. rng_key : jax.random.PRNGKey, optional Random key. Default: PRNGKey(0). target_accept : float Target acceptance rate (default 0.8). progress_bar : bool If True, show progress during window adaptation. n_chains : int Number of chains (used to validate device count and store per-chain init positions). checkpoint_file : str, optional Override the default checkpoint file path. When set, updates ``self._checkpoint_file`` for all subsequent save/load operations. Defaults to ``save_dir/mcmc_checkpoint.npz`` when ``save_dir`` is set. warmup_method : {"window_adaptation", "pathfinder", "dual_averaging"} Warmup strategy. pathfinder_maxiter : int Max L-BFGS iterations for Pathfinder (default 100). pathfinder_maxcor : int L-BFGS history size for Pathfinder (default 10). pathfinder_num_elbo : int Number of ELBO samples for Pathfinder (default 200). """ import blackjax gp = self.gp n_devices = self._n_devices if n_chains > 1 and n_chains % n_devices != 0: raise ValueError( f"n_chains ({n_chains}) must be divisible by the number of " f"available devices ({n_devices}). Use n_chains in " f"{[n_devices * i for i in range(1, 5)]}.") if rng_key is None: rng_key = jax.random.PRNGKey(0) # Initial position # theta_init can be: # - None -> use GPSolver MAP estimate # - dict -> single starting point # - 1-D array -> single starting point # - list of dicts -> per-chain starting points # - 2-D array (n_chains, n_params) -> per-chain starting points _per_chain_inits = None if theta_init is None: if gp.map_estimate is None: gp.fit_map() theta_init = gp.map_estimate elif isinstance(theta_init, list) and len(theta_init) > 0 and isinstance(theta_init[0], dict): _per_chain_inits = jnp.array( [[float(d[k]) for k in gp.param_keys] for d in theta_init], dtype=jnp.float64) theta_init = _per_chain_inits[0] # best MAP for warmup elif isinstance(theta_init, dict): theta_init = jnp.array( [float(theta_init[k]) for k in gp.param_keys], dtype=jnp.float64) else: theta_init = jnp.asarray(theta_init, dtype=jnp.float64) if theta_init.ndim == 2: _per_chain_inits = theta_init theta_init = _per_chain_inits[0] # best MAP for warmup self._n_chains = n_chains self._per_chain_inits = _per_chain_inits self._theta_init = theta_init if checkpoint_file is not None: self._checkpoint_file = checkpoint_file warmup_key, sample_key = jax.random.split(rng_key) if warmup_method == "pathfinder": # -- Pathfinder warmup ------------------------------------ # Build init array for multi-path: use per-chain inits if # available, otherwise tile the single init. if _per_chain_inits is not None: pf_inits = _per_chain_inits else: pf_inits = theta_init[None, :] # single path best_pos, adapted_inv_mass, adapted_step_size, pf_positions = \ self._run_pathfinder_warmup( warmup_key, pf_inits, gp.log_posterior, target_accept=target_accept, maxiter=pathfinder_maxiter, maxcor=pathfinder_maxcor, num_elbo_samples=pathfinder_num_elbo, ) if step_size is not None: adapted_step_size = step_size # Override per-chain inits with pathfinder best positions self._per_chain_inits = pf_positions # Create a NUTS state at the best position for single-chain path warmup_state = blackjax.nuts( gp.log_posterior, step_size=adapted_step_size, inverse_mass_matrix=adapted_inv_mass, ).init(best_pos) elif warmup_method == "dual_averaging": # -- Dual averaging warmup (fixed mass matrix) ------------ # Estimate mass matrix (delegated to GPSolver) inv_mass = gp._get_mass_matrix(mass_matrix_method, theta_init) # For NUTS, use diagonal mass matrix (more robust than full) inv_mass_diag = jnp.diag(inv_mass) # Clamp extreme values for stability median_var = jnp.median(inv_mass_diag) inv_mass_diag = jnp.clip(inv_mass_diag, median_var * 1e-4, median_var * 1e4) # Initial step size: heuristic based on mass matrix scale if step_size is None: step_size = float(0.5 * jnp.min(jnp.sqrt(inv_mass_diag))) step_size = max(step_size, 1e-5) logger.info(f"Warmup: {n_warmup} steps (dual averaging, fixed mass matrix, " f"init step_size={step_size:.6f})...") from blackjax.adaptation.step_size import ( dual_averaging_adaptation, ) da_init, da_update, da_final = dual_averaging_adaptation( target=target_accept, ) da_state = da_init(step_size) kernel = blackjax.nuts( gp.log_posterior, step_size=step_size, inverse_mass_matrix=inv_mass_diag, ) warmup_state = kernel.init(theta_init) for i in range(n_warmup): warmup_key, step_key = jax.random.split(warmup_key) warmup_state, info = kernel.step(step_key, warmup_state) da_state = da_update(da_state, info.acceptance_rate) new_step_size = jnp.exp(da_state.log_step_size) kernel = blackjax.nuts( gp.log_posterior, step_size=new_step_size, inverse_mass_matrix=inv_mass_diag, ) adapted_step_size = jnp.exp(da_state.log_step_size_avg) adapted_inv_mass = inv_mass_diag logger.info(f" Adapted step size: {float(adapted_step_size):.6f}") # Re-init warmup_state with the final adapted step size kernel = blackjax.nuts( gp.log_posterior, step_size=adapted_step_size, inverse_mass_matrix=adapted_inv_mass, ) warmup_state = kernel.init(warmup_state.position) else: # -- Window adaptation warmup (default) ------------------- # Estimate mass matrix (delegated to GPSolver) inv_mass = gp._get_mass_matrix(mass_matrix_method, theta_init) # For NUTS, use diagonal mass matrix (more robust than full) inv_mass_diag = jnp.diag(inv_mass) # Clamp extreme values for stability median_var = jnp.median(inv_mass_diag) inv_mass_diag = jnp.clip(inv_mass_diag, median_var * 1e-4, median_var * 1e4) # Initial step size: heuristic based on mass matrix scale if step_size is None: step_size = float(0.5 * jnp.min(jnp.sqrt(inv_mass_diag))) step_size = max(step_size, 1e-5) logger.info(f"Warmup: {n_warmup} steps (window adaptation, " f"init step_size={step_size:.6f})...") warmup = blackjax.window_adaptation( blackjax.nuts, gp.log_posterior, is_mass_matrix_diagonal=True, initial_step_size=step_size, target_acceptance_rate=target_accept, progress_bar=progress_bar, ) adapt_results, adapt_info = warmup.run( warmup_key, theta_init, num_steps=n_warmup, ) adapted_step_size = adapt_results.parameters["step_size"] adapted_inv_mass = adapt_results.parameters["inverse_mass_matrix"] warmup_state = adapt_results.state logger.info(f" Adapted step size: {float(adapted_step_size):.6f}") del adapt_results, adapt_info, warmup # Store adapted parameters and checkpoint between warmup and # sampling so that warmup intermediates can be freed. self._adapted_step_size = float(adapted_step_size) self._adapted_inv_mass = np.asarray(adapted_inv_mass) self._last_state = warmup_state self._last_rng_key = sample_key self._info = { "step_size": self._adapted_step_size, "n_warmup": n_warmup, "n_samples": 0, "n_chains": n_chains, "n_divergent": 0, } self._warmup_completed = True if self._checkpoint_file is not None: self.save_checkpoint(append_samples=False) logger.info(" Warmup checkpoint saved; clearing warmup memory...") jax.clear_caches() # Warm up the log_posterior JIT kernel so CUDA timers are # accurate when the sampling scan launches. jax.block_until_ready(gp.log_posterior(theta_init))
[docs] def run_sampling(self, n_samples=1000): """ Run NUTS sampling using adapted parameters from ``run_warmup``. Must be called after ``run_warmup`` (or will use parameters restored from a checkpoint). Parameters ---------- n_samples : int Number of post-warmup samples per chain (default 1000). Returns ------- samples : jnp.ndarray Shape ``(n_samples, n_params)`` when ``n_chains=1``, or ``(n_chains, n_samples, n_params)`` when ``n_chains > 1``. info : dict Sampling diagnostics (arrays have a leading chain dimension when ``n_chains > 1``). """ import blackjax gp = self.gp n_devices = self._n_devices n_chains = self._n_chains adapted_step_size = self._adapted_step_size adapted_inv_mass = self._adapted_inv_mass warmup_state = self._last_state sample_key = self._last_rng_key n_warmup = self._info["n_warmup"] _per_chain_inits = getattr(self, "_per_chain_inits", None) theta_init = getattr(self, "_theta_init", warmup_state.position) # -- Sampling via lax.scan ----------------------------------- def _run_one_chain(state, chain_key): """Sample one chain via lax.scan.""" kernel = blackjax.nuts( gp.log_posterior, step_size=adapted_step_size, inverse_mass_matrix=adapted_inv_mass, ) chain_keys = jax.random.split(chain_key, n_samples) def one_step(carry, key_idx): st, n_div = carry key, _idx = key_idx st, info = kernel.step(key, st) n_div = n_div + info.is_divergent.astype(jnp.int32) return (st, n_div), (st.position, info) indices = jnp.arange(n_samples) (final_st, total_div), (positions, infos) = jax.lax.scan( one_step, (state, jnp.int32(0)), (chain_keys, indices), ) return final_st, total_div, positions, infos, chain_keys[-1] if n_chains > 1: chains_per_device = n_chains // n_devices logger.info(f"Sampling {n_samples} iterations x {n_chains} chains " f"across {n_devices} device(s)...") # If the state is already multi-chain (from a previous # run_sampling call), reuse it directly. Otherwise # initialize per-chain states from MAP solutions or jitter. is_multi_chain_state = warmup_state.position.ndim > 1 if is_multi_chain_state: states = warmup_state if sample_key.ndim > 1: rng_key = jax.random.fold_in(sample_key[0], 1) else: rng_key = jax.random.fold_in(sample_key, 1) sample_keys = jax.random.split(rng_key, n_chains) else: if _per_chain_inits is not None and _per_chain_inits.shape[0] >= n_chains: init_positions = _per_chain_inits[:n_chains] logger.info(f" Using {n_chains} distinct MAP solutions as chain init positions") else: jitter_key, sample_key = jax.random.split(sample_key) jitter_scale = 0.01 * jnp.sqrt(adapted_inv_mass) noise = jax.random.normal( jitter_key, shape=(n_chains, len(theta_init))) init_positions = warmup_state.position[None, :] \ + jitter_scale[None, :] * noise # Initialize NUTS states for each chain from init positions init_fn = blackjax.nuts( gp.log_posterior, step_size=adapted_step_size, inverse_mass_matrix=adapted_inv_mass, ).init states = jax.vmap(init_fn)(init_positions) sample_keys = jax.random.split(sample_key, n_chains) # Reshape for pmap: (n_devices, chains_per_device, ...) states = jax.tree.map( lambda x: x.reshape(n_devices, chains_per_device, *x.shape[1:]), states) sample_keys = sample_keys.reshape(n_devices, chains_per_device, -1) # pmap over devices, vmap over chains within each device all_final, all_div, all_pos, all_infos, all_last_keys = jax.pmap( jax.vmap(_run_one_chain) )(states, sample_keys) # Flatten device dimension: (n_devices, chains_per_device, ...) -> (n_chains, ...) all_final = jax.tree.map( lambda x: x.reshape(n_chains, *x.shape[2:]), all_final) all_div = all_div.reshape(n_chains) all_pos = all_pos.reshape(n_chains, n_samples, -1) all_infos = jax.tree.map( lambda x: x.reshape(n_chains, *x.shape[2:]), all_infos) all_last_keys = all_last_keys.reshape(n_chains, -1) # Shape: (n_chains, n_samples, n_params) self.samples = all_pos self._info = { "divergences": np.asarray(all_infos.is_divergent), "acceptance_rate": np.asarray(all_infos.acceptance_rate), "num_steps": np.asarray( all_infos.num_integration_steps), "step_size": float(adapted_step_size), "n_warmup": n_warmup, "n_samples": n_samples, "n_chains": n_chains, "n_divergent": int(jnp.sum(all_div)), } self._last_state = jax.tree.map(jnp.array, all_final) self._adapted_step_size = float(adapted_step_size) self._adapted_inv_mass = np.asarray(adapted_inv_mass) self._last_rng_key = jnp.array(all_last_keys) total_div = int(jnp.sum(all_div)) mean_accept = float(jnp.mean( jnp.array(self._info["acceptance_rate"]))) logger.info(f"NUTS complete: {n_chains} chains x {n_samples} samples, " f"{total_div} total divergences, " f"mean acceptance rate = {mean_accept:.3f}") else: logger.info(f"Sampling {n_samples} post-warmup iterations...") final_state, total_div, positions, infos, last_key = \ _run_one_chain(warmup_state, sample_key) self.samples = positions self._info = { "divergences": np.asarray(infos.is_divergent), "acceptance_rate": np.asarray(infos.acceptance_rate), "num_steps": np.asarray(infos.num_integration_steps), "step_size": float(adapted_step_size), "n_warmup": n_warmup, "n_samples": n_samples, "n_chains": 1, "n_divergent": int(total_div), } self._last_state = final_state self._adapted_step_size = float(adapted_step_size) self._adapted_inv_mass = np.asarray(adapted_inv_mass) self._last_rng_key = last_key mean_accept = float(np.mean(self._info["acceptance_rate"])) logger.info(f"NUTS complete: {n_samples} samples, " f"{int(total_div)} divergences, " f"mean acceptance rate = {mean_accept:.3f}") return self.samples, self._info
[docs] def save_checkpoint(self, path=None, append_samples=True, plot_corner=False): """ Save sampler state to disk for later resumption. When ``append_samples=True`` (the default), new samples are appended to any existing samples already stored in ``path``, and ``self.samples`` is cleared from memory. This enables a sample-checkpoint-clear loop that keeps memory usage constant. Parameters ---------- path : str, optional File path (saved as ``.npz``). If None, uses the ``checkpoint_file`` set in ``run_warmup``, or ``save_dir/checkpoint.npz`` if ``save_dir`` was set. append_samples : bool If True, append current ``self.samples`` to any samples already on disk, then clear ``self.samples`` from memory. If False, overwrite with only the current in-memory samples. plot_corner : bool If True, load all samples currently on disk after saving and write a corner plot to ``save_dir/corner_plot.png`` (or alongside the checkpoint file if ``save_dir`` is not set). """ import os if self._last_state is None: raise RuntimeError("No sampler state to save. Run run_warmup first.") if path is None: path = self._checkpoint_file if path is None and self.save_dir is not None: path = os.path.join(self.save_dir, "mcmc_checkpoint.npz") if path is None: raise ValueError( "No path provided, no checkpoint_file set, and no save_dir. " "Pass a path, set checkpoint_file in run_warmup, or set save_dir.") from ..io import _is_hdf5 if _is_hdf5(path): from ..io import save_sampler save_sampler(path, self, append_samples=append_samples) if plot_corner: self._corner_after_hdf5_save(path) return samples_to_save = np.asarray(self.samples) if self.samples is not None else None # Merge with samples already on disk if append_samples and samples_to_save is not None: import os _path = path if path.endswith(".npz") else path + ".npz" if os.path.exists(_path): existing = np.load(_path) if "samples" in existing and existing["samples"].size > 0: # multi-chain: (n_chains, n_samples, n_params) → concat on axis=1 # single-chain: (n_samples, n_params) → concat on axis=0 cat_axis = 1 if samples_to_save.ndim == 3 else 0 samples_to_save = np.concatenate( [existing["samples"], samples_to_save], axis=cat_axis) existing.close() save_kwargs = { # NUTS state (shape has leading chain dim when n_chains > 1) "position": np.asarray(self._last_state.position), "logdensity": np.asarray(self._last_state.logdensity), "logdensity_grad": np.asarray(self._last_state.logdensity_grad), # Adapted kernel parameters "step_size": np.asarray(self._adapted_step_size), "inverse_mass_matrix": np.asarray(self._adapted_inv_mass), "rng_key": np.asarray(self._last_rng_key), # Diagnostics (scalars) "n_warmup": np.asarray(self._info["n_warmup"]), "n_chains": np.asarray(getattr(self, "_n_chains", 1)), } if samples_to_save is not None: save_kwargs["samples"] = samples_to_save n_on_disk = samples_to_save.shape[0] else: save_kwargs["samples"] = np.array([]) n_on_disk = 0 # Preserve MAP solutions and their log-likelihoods if (hasattr(self, 'all_theta_maps') and self.all_theta_maps is not None and len(self.all_theta_maps) > 0): save_kwargs['theta_map'] = self.theta_map save_kwargs['all_theta_maps'] = np.array( self.all_theta_maps, dtype=object) # Compute log-likelihood for each MAP solution gp = self.gp map_loglikes = [] for tm in self.all_theta_maps: if isinstance(tm, dict): arr = np.array([float(tm[k]) for k in gp.param_keys], dtype=np.float64) else: arr = np.asarray(tm, dtype=np.float64) map_loglikes.append(float(gp.log_likelihood_fn(arr))) save_kwargs['map_loglikes'] = np.array(map_loglikes) np.savez(path, **save_kwargs) if append_samples: # Free in-memory samples and per-sample diagnostics self.samples = None self._info = { "step_size": self._info["step_size"], "n_warmup": self._info["n_warmup"], "n_samples": n_on_disk, "n_divergent": self._info.get("n_divergent", 0), } logger.debug(f"Checkpoint saved to {path} ({n_on_disk} samples on disk)") if plot_corner and n_on_disk > 0: import corner import matplotlib import matplotlib.pyplot as plt _chk = path if path.endswith(".npz") else path + ".npz" corner_dir = self.save_dir if self.save_dir is not None \ else os.path.dirname(os.path.abspath(_chk)) corner_path = os.path.join(corner_dir, "corner_plot.png") all_samples = self.load_samples(path) has_maps = (hasattr(self, 'all_theta_maps') and self.all_theta_maps is not None and len(self.all_theta_maps) > 0) if has_maps: fig, _ = self.plot_corner_map( samples=all_samples, checkpoint_path=path, savefig=corner_path) else: old_usetex = matplotlib.rcParams.get("text.usetex", False) matplotlib.rcParams["text.usetex"] = False try: fig = corner.corner( all_samples, labels=list(self.param_keys), show_titles=True, title_fmt=".3f", ) fig.savefig(corner_path, dpi=150, bbox_inches="tight") finally: matplotlib.rcParams["text.usetex"] = old_usetex plt.close(fig) logger.debug(f"Corner plot saved to {corner_path} " f"({n_on_disk} samples)")
[docs] def load_checkpoint(self, checkpoint_file=None): """ Restore sampler state from a checkpoint file. Loads only the NUTS state and adapted kernel parameters needed to resume sampling. Samples stored in the file are **not** loaded into memory — use ``load_samples`` to read them later. Parameters ---------- checkpoint_file : str, optional Path to a ``.npz`` checkpoint file. If provided, also updates the sampler's default checkpoint path. If None, uses the default ``save_dir/mcmc_checkpoint.npz``. """ import blackjax if checkpoint_file is not None: self._checkpoint_file = checkpoint_file path = self._checkpoint_file if path is None: raise ValueError( "No checkpoint_file provided and no save_dir was set. " "Pass a checkpoint_file or set save_dir.") from ..io import _is_hdf5 if _is_hdf5(path): from ..io import load_sampler load_sampler(path, self) return data = np.load(path) # Reconstruct NUTS state (works for both single and multi-chain: # arrays have a leading chain dimension when n_chains > 1) self._last_state = blackjax.mcmc.hmc.HMCState( position=jnp.asarray(data["position"]), logdensity=jnp.asarray(data["logdensity"]), logdensity_grad=jnp.asarray(data["logdensity_grad"]), ) n_chains = int(data["n_chains"]) if "n_chains" in data else 1 self._n_chains = n_chains if n_chains > 1: self._adapted_step_size = np.asarray(data["step_size"]) else: self._adapted_step_size = float(data["step_size"]) self._adapted_inv_mass = jnp.asarray(data["inverse_mass_matrix"]) self._last_rng_key = jnp.asarray(data["rng_key"]) n_on_disk = data["samples"].shape[0] if data["samples"].size > 0 else 0 n_warmup = int(data["n_warmup"]) # Restore MAP solutions if present if "all_theta_maps" in data: self.all_theta_maps = list(data["all_theta_maps"]) self.theta_map = (data["theta_map"].item() if data["theta_map"].ndim == 0 else data["theta_map"]) if "map_loglikes" in data: self.map_loglikes = np.asarray(data["map_loglikes"]) logger.info(f"Restored {len(self.all_theta_maps)} MAP solutions " f"from checkpoint") data.close() # Don't load samples into memory — keep it lightweight self.samples = None self._info = { "step_size": self._adapted_step_size, "n_warmup": n_warmup, "n_samples": n_on_disk, "n_chains": n_chains, "n_divergent": 0, } logger.info(f"Checkpoint loaded from {path} " f"({n_on_disk} samples on disk, {n_chains} chain(s), " f"not loaded into memory)")
@staticmethod def _make_batched_vmap(fn, n_particles, batch_size, n_devices=None): """Replace ``jax.vmap(fn)`` with a multi-GPU batched version. Particles are split into chunks of ``batch_size``, distributed across ``n_devices`` GPUs with ``pmap``, and each device evaluates its chunk with ``vmap``. When only one device is available (or ``n_devices=1``) it falls back to ``lax.map(vmap(fn), batches)`` so that only ``batch_size`` evaluations are live at once. Parameters ---------- fn : callable Scalar function of a single particle, e.g. ``loglikelihood_fn(theta) -> float``. n_particles : int Total number of particles. Must be divisible by ``batch_size``. batch_size : int Number of particles to evaluate simultaneously per device. n_devices : int, optional Number of JAX devices to use. Defaults to all visible devices. Returns ------- batched_fn : callable ``batched_fn(particles)`` with ``particles`` of shape ``(n_particles, ...)``, returns ``(n_particles, ...)``. """ if n_devices is None: n_devices = jax.device_count() if n_particles % batch_size != 0: raise ValueError( f"n_particles ({n_particles}) must be divisible by " f"particle_batch_size ({batch_size}).") n_batches = n_particles // batch_size if n_devices > 1 and n_batches >= n_devices: # Multi-GPU path: pmap across devices, scan over rounds if n_batches % n_devices != 0: raise ValueError( f"n_particles / particle_batch_size " f"({n_batches}) must be divisible by " f"n_devices ({n_devices}).") rounds_per_device = n_batches // n_devices def batched_fn(all_particles): # (n_devices, rounds_per_device, batch_size, ...) shaped = all_particles.reshape( n_devices, rounds_per_device, batch_size, *all_particles.shape[1:]) def _device_work(device_batches): # device_batches: (rounds_per_device, batch_size, ...) def _one_round(_, batch): return None, jax.vmap(fn)(batch) _, results = jax.lax.scan( _one_round, None, device_batches) return results # (rounds_per_device, batch_size, ...) # (n_devices, rounds_per_device, batch_size, ...) out = jax.pmap(_device_work)(shaped) flat = out.reshape(n_particles, *out.shape[3:]) # Strip pmap sharding so the next tempering step's # pmap (which creates a new mesh) won't clash. return jnp.array(np.asarray(flat)) else: # Single-GPU path: sequential scan over batches def batched_fn(all_particles): shaped = all_particles.reshape( n_batches, batch_size, *all_particles.shape[1:]) def _one_round(_, batch): return None, jax.vmap(fn)(batch) _, out = jax.lax.scan(_one_round, None, shaped) return out.reshape(n_particles, *out.shape[2:]) return batched_fn @staticmethod def _make_batched_update(raw_nuts_kernel, nuts_init_fn, tempered_logposterior_fn, step_size, inverse_mass_matrix, num_mcmc_steps, n_particles, batch_size, n_devices=None, max_num_doublings=10): """Batched MCMC rejuvenation distributed across GPUs. Replaces ``jax.vmap(mcmc_kernel)`` in ``blackjax.smc.base.update_and_take_last`` with a ``pmap``/``scan``-based version so that only ``batch_size`` NUTS chains are live simultaneously. Uses the raw NUTS kernel directly (not the wrapped ``SamplingAlgorithm``) so the tempered log-posterior can be swapped each step. Returns ``(update_fn, n_particles)``. """ if n_devices is None: n_devices = jax.device_count() if n_particles % batch_size != 0: raise ValueError( f"n_particles ({n_particles}) must be divisible by " f"particle_batch_size ({batch_size}).") n_batches = n_particles // batch_size def _single_mcmc(rng_key, position): state = nuts_init_fn(position, tempered_logposterior_fn) def body_fn(state, rng_key): new_state, info = raw_nuts_kernel( rng_key, state, tempered_logposterior_fn, step_size, inverse_mass_matrix, max_num_doublings) return new_state, info keys = jax.random.split(rng_key, num_mcmc_steps) last_state, info = jax.lax.scan(body_fn, state, keys) return last_state.position, info if n_devices > 1 and n_batches >= n_devices: if n_batches % n_devices != 0: raise ValueError( f"n_particles / particle_batch_size " f"({n_batches}) must be divisible by " f"n_devices ({n_devices}).") rounds_per_device = n_batches // n_devices def update_fn(keys, particles): k_shaped = keys.reshape( n_devices, rounds_per_device, batch_size, *keys.shape[1:]) p_shaped = particles.reshape( n_devices, rounds_per_device, batch_size, *particles.shape[1:]) def _device_work(dk, dp): def _one_round(_, args): bk, bp = args return None, jax.vmap( _single_mcmc)(bk, bp) _, results = jax.lax.scan( _one_round, None, (dk, dp)) return results positions, infos = jax.pmap( _device_work)(k_shaped, p_shaped) flat_pos = positions.reshape( n_particles, *positions.shape[3:]) flat_infos = jax.tree.map( lambda x: x.reshape( n_particles, *x.shape[3:]), infos) # Strip pmap sharding so the next tempering step's # pmap (which creates a new mesh) won't clash. flat_pos = jnp.array( np.asarray(flat_pos)) flat_infos = jax.tree.map( lambda x: jnp.array(np.asarray(x)), flat_infos) return flat_pos, flat_infos else: def update_fn(keys, particles): k_shaped = keys.reshape( n_batches, batch_size, *keys.shape[1:]) p_shaped = particles.reshape( n_batches, batch_size, *particles.shape[1:]) def _one_round(_, args): bk, bp = args return None, jax.vmap( _single_mcmc)(bk, bp) _, (positions, infos) = jax.lax.scan( _one_round, None, (k_shaped, p_shaped)) flat_pos = positions.reshape( n_particles, *positions.shape[2:]) flat_infos = jax.tree.map( lambda x: x.reshape( n_particles, *x.shape[2:]), infos) return flat_pos, flat_infos return update_fn, n_particles
[docs] def run_smc(self, n_particles=500, n_mcmc_steps=10, n_adapt_steps=25, target_ess=0.5, target_accept=0.6, rng_key=None, step_size=None, mass_matrix_method="hessian_map", theta_init=None, max_tempering_steps=200, checkpoint_every=10, checkpoint_file=None, particle_batch_size=None, max_num_doublings=10): """ Run adaptive tempered Sequential Monte Carlo. Starts from the prior and anneals toward the full posterior using an adaptive temperature schedule. At each tempering step, particles are resampled and rejuvenated with NUTS moves. The NUTS step size is re-adapted via dual averaging at each tempering stage using a representative particle. Parameters ---------- n_particles : int Number of SMC particles (default 500). n_mcmc_steps : int NUTS rejuvenation steps per tempering stage (default 10). n_adapt_steps : int Dual-averaging warmup steps to adapt the NUTS step size at each tempering stage (default 25). target_ess : float Target effective sample size as a fraction of ``n_particles`` (default 0.5). target_accept : float Target NUTS acceptance rate for dual averaging (default 0.6). rng_key : jax.random.PRNGKey, optional Random key. Default: PRNGKey(42). step_size : float, optional Initial NUTS step size. If None, a heuristic from the mass matrix is used. mass_matrix_method : str, optional Method to estimate the inverse mass matrix (default ``"hessian_map"``). Set to None to use an identity matrix. theta_init : dict or array_like, optional Reference point for mass matrix estimation. If None, the MAP estimate is used. max_tempering_steps : int Safety limit on the number of tempering stages (default 200). checkpoint_every : int Save a checkpoint every this many tempering steps (default 10). Set to 0 to disable periodic checkpointing. checkpoint_file : str, optional Override the default checkpoint file path. particle_batch_size : int, optional Process particles in batches of this size to limit GPU memory usage. When multiple GPUs are visible the batches are distributed across devices via ``jax.pmap``. ``n_particles`` must be divisible by this value (and by ``batch_size * n_devices`` for multi-GPU). If None, all particles are evaluated at once (original blackjax behavior). max_num_doublings : int, optional Maximum NUTS tree depth (default 10). Lower values (e.g. 5-6) reduce peak GPU memory per particle at the cost of shorter trajectories. Returns ------- samples : np.ndarray, shape (n_particles, n_params) Weighted posterior particles at the final temperature. info : dict Diagnostics including tempering schedule and log evidence estimate. """ import blackjax from blackjax.smc.resampling import systematic from blackjax.adaptation.step_size import dual_averaging_adaptation gp = self.gp n_devices = self._n_devices if rng_key is None: rng_key = jax.random.PRNGKey(42) if checkpoint_file is not None: self._checkpoint_file = checkpoint_file # --- Mass matrix and step size --------------------------------- if theta_init is None: if gp.map_estimate is None: gp.fit_map() theta_init = gp.map_estimate if isinstance(theta_init, dict): theta_init = jnp.array( [float(theta_init[k]) for k in gp.param_keys], dtype=jnp.float64) if mass_matrix_method is not None: inv_mass = gp._get_mass_matrix(mass_matrix_method, theta_init) inv_mass_diag = jnp.diag(inv_mass) median_var = jnp.median(inv_mass_diag) inv_mass_diag = jnp.clip(inv_mass_diag, median_var * 1e-4, median_var * 1e4) else: inv_mass_diag = jnp.ones(gp.n_params) if step_size is None: step_size = float(0.5 * jnp.min(jnp.sqrt(inv_mass_diag))) step_size = max(step_size, 1e-5) # --- Draw initial particles ------------------------------------ init_key, run_key = jax.random.split(rng_key) bounds = gp.bounds lo, hi = bounds[:, 0], bounds[:, 1] # Use Laplace approximation around MAP solutions when available, # otherwise fall back to uniform prior draws. if (hasattr(self, 'all_theta_maps') and self.all_theta_maps is not None and len(self.all_theta_maps) > 0): # Convert MAP solutions to arrays map_arrays = [] for tm in self.all_theta_maps: if isinstance(tm, dict): arr = jnp.array([float(tm[k]) for k in gp.param_keys], dtype=jnp.float64) else: arr = jnp.asarray(tm, dtype=jnp.float64) map_arrays.append(arr) # Full Hessian covariance (already computed for mass matrix) if mass_matrix_method is not None: cov = np.asarray(inv_mass) # (n_params, n_params) else: cov = np.diag(np.asarray(inv_mass_diag)) # Inflate covariance to broaden the Laplace approximation cov_inflated = 4.0 * cov try: L_cov = np.linalg.cholesky(cov_inflated) except np.linalg.LinAlgError: # Fall back to diagonal if Cholesky fails L_cov = np.diag(np.sqrt(np.abs(np.diag(cov_inflated)))) n_maps = len(map_arrays) # Distribute particles evenly across MAP solutions particles_per_map = n_particles // n_maps remainder = n_particles % n_maps all_particles = [] for i, mu in enumerate(map_arrays): n_i = particles_per_map + (1 if i < remainder else 0) init_key, draw_key = jax.random.split(init_key) z = jax.random.normal(draw_key, shape=(n_i, gp.n_params)) pts = jnp.asarray(mu) + z @ jnp.asarray(L_cov.T) all_particles.append(pts) particles = jnp.concatenate(all_particles, axis=0) # Clip to prior bounds particles = jnp.clip(particles, lo, hi) logger.info(f"Initialized {n_particles} particles from Laplace " f"approximation around {n_maps} MAP solution(s)") else: particles = (jax.random.uniform(init_key, shape=(n_particles, gp.n_params)) * (hi - lo) + lo) logger.info(f"Initialized {n_particles} particles from uniform prior") # --- Helper: adapt step size via dual averaging --------------- def _adapt_step_size(adapt_key, position, current_step_size, lam, n_steps): """Run short dual-averaging warmup on one particle at the tempered log-density ``log_prior + lam * log_likelihood``.""" def tempered_logdensity(theta): return gp.log_prior_fn(theta) + lam * gp.log_likelihood_fn(theta) da_init, da_update, _ = dual_averaging_adaptation( target=target_accept) da_state = da_init(current_step_size) kernel = blackjax.nuts( tempered_logdensity, step_size=current_step_size, inverse_mass_matrix=inv_mass_diag, max_num_doublings=max_num_doublings, ) state = kernel.init(position) for _ in range(n_steps): adapt_key, step_key = jax.random.split(adapt_key) state, info = kernel.step(step_key, state) da_state = da_update(da_state, info.acceptance_rate) new_ss = jnp.exp(da_state.log_step_size) kernel = blackjax.nuts( tempered_logdensity, step_size=new_ss, inverse_mass_matrix=inv_mass_diag, max_num_doublings=max_num_doublings, ) adapted_ss = float(jnp.exp(da_state.log_step_size_avg)) return max(adapted_ss, 1e-6) # --- Build SMC kernel factory --------------------------------- use_batched = particle_batch_size is not None import blackjax.mcmc.nuts as nuts_module raw_nuts_kernel = nuts_module.build_kernel() def _build_smc_kernel(ss): if not use_batched: # Standard blackjax path — vmap over all particles. # Use the raw kernel so SMC can swap the log-density # at each tempering step. return blackjax.adaptive_tempered_smc.build_kernel( logprior_fn=gp.log_prior_fn, loglikelihood_fn=gp.log_likelihood_fn, mcmc_step_fn=raw_nuts_kernel, mcmc_init_fn=nuts_module.init, resampling_fn=systematic, target_ess=target_ess, ) # ---------------------------------------------------------- # Batched path: replace jax.vmap with pmap/scan batches # so only particle_batch_size evaluations are live at once. # Uses the raw NUTS kernel so the tempered log-posterior # can be swapped at each tempering step. # ---------------------------------------------------------- import blackjax.smc.ess as ess_mod import blackjax.smc.solver as solver_mod from blackjax.smc.tempered import TemperedSMCState import blackjax.smc.base as smc_base from jax.scipy.special import logsumexp batched_ll = self._make_batched_vmap( gp.log_likelihood_fn, n_particles, particle_batch_size, n_devices) def _compute_delta(state): logprob = batched_ll(state.particles) n = logprob.shape[0] target_val = jnp.log(n * target_ess) max_delta = 1 - state.tempering_param def fun_to_solve(delta): log_w = jnp.nan_to_num(-delta * logprob) return ess_mod.log_ess(log_w) - target_val delta = solver_mod.dichotomy( fun_to_solve, 0.0, max_delta) return jnp.clip(delta, 0.0, max_delta) def _batched_tempered_kernel( rng_key, state, num_mcmc_steps_, tempering_param, mcmc_parameters): delta = tempering_param - state.tempering_param cur_ss = mcmc_parameters["step_size"] cur_imm = mcmc_parameters["inverse_mass_matrix"] # Batched weight function def log_weights_fn(position): return delta * gp.log_likelihood_fn(position) batched_weight_fn = self._make_batched_vmap( log_weights_fn, n_particles, particle_batch_size, n_devices) # Tempered log-posterior for MCMC rejuvenation. # Use the NEW temperature so particles are moved toward # the correct target, and to avoid 0 * log_likelihood # at lambda=0 (which produces NaN gradients when the GP # covariance is ill-conditioned). _new_tp = tempering_param def tempered_logposterior_fn(position): return (gp.log_prior_fn(position) + _new_tp * gp.log_likelihood_fn(position)) # Build batched MCMC update using raw kernel update_fn, _ = self._make_batched_update( raw_nuts_kernel, nuts_module.init, tempered_logposterior_fn, cur_ss, cur_imm, num_mcmc_steps_, n_particles, particle_batch_size, n_devices, max_num_doublings=max_num_doublings, ) # --- Resample, update, reweight (mirrors smc.base.step) resampling_key, updating_key = jax.random.split( rng_key, 2) resampling_idx = systematic( resampling_key, state.weights, n_particles) resampled = jax.tree.map( lambda x: x[resampling_idx], state.particles) keys = jax.random.split(updating_key, n_particles) new_particles, update_info = update_fn( keys, resampled) log_w = batched_weight_fn(new_particles) logsum_w = logsumexp(log_w) norm_const = logsum_w - jnp.log(n_particles) weights = jnp.exp(log_w - logsum_w) new_state = TemperedSMCState( new_particles, weights, state.tempering_param + delta) info = smc_base.SMCInfo( resampling_idx, norm_const, update_info) return new_state, info def kernel(rng_key, state, num_mcmc_steps, mcmc_parameters): delta = _compute_delta(state) tempering_param = delta + state.tempering_param return _batched_tempered_kernel( rng_key, state, num_mcmc_steps, tempering_param, mcmc_parameters) return kernel smc_state = blackjax.adaptive_tempered_smc.init(particles) # --- Checkpoint helper ---------------------------------------- chk_path = self._checkpoint_file def _save_smc_checkpoint(smc_st, lambdas_, step_sizes_, log_ev, run_key_, step_size_, inv_mass_diag_): if chk_path is None: return save_kwargs = dict( particles=np.asarray(smc_st.particles), weights=np.asarray(smc_st.weights), tempering_param=float(smc_st.tempering_param), tempering_schedule=np.array(lambdas_), step_sizes=np.array(step_sizes_), log_evidence=log_ev, step_size=step_size_, inverse_mass_matrix=np.asarray(inv_mass_diag_), rng_key=np.asarray(run_key_), n_particles=n_particles, n_mcmc_steps=n_mcmc_steps, n_adapt_steps=n_adapt_steps, # Include samples key for compatibility with load_samples samples=np.asarray(smc_st.particles), ) # Preserve MAP solutions if they exist on disk _chk = chk_path if chk_path.endswith(".npz") else chk_path + ".npz" if os.path.exists(_chk): existing = np.load(_chk, allow_pickle=True) if "all_theta_maps" in existing: save_kwargs["all_theta_maps"] = existing["all_theta_maps"] if "theta_map" in existing: save_kwargs["theta_map"] = existing["theta_map"] if "map_loglikes" in existing: save_kwargs["map_loglikes"] = existing["map_loglikes"] existing.close() np.savez(chk_path, **save_kwargs) logger.debug(f" Checkpoint saved to {chk_path} " f"(lambda={float(smc_st.tempering_param):.6f})") # --- Run tempering loop --------------------------------------- if use_batched: logger.info(f"SMC: {n_particles} particles, " f"batch_size={particle_batch_size}, " f"n_devices={n_devices}, " f"target_ess={target_ess:.2f}, " f"n_adapt={n_adapt_steps}, " f"target_accept={target_accept:.2f}") else: logger.info(f"SMC: {n_particles} particles, " f"target_ess={target_ess:.2f}, " f"n_adapt={n_adapt_steps}, " f"target_accept={target_accept:.2f}") lambdas = [0.0] step_sizes = [step_size] log_evidence = 0.0 for step in range(max_tempering_steps): run_key, step_key, adapt_key = jax.random.split(run_key, 3) smc_kernel = _build_smc_kernel(step_size) if use_batched: # Batched path handles params internally via raw kernel mcmc_params = {"step_size": step_size, "inverse_mass_matrix": inv_mass_diag} else: # Non-batched blackjax path needs extend_params so # unshared_parameters_and_step_fn sees shape[0]==1 # and treats them as shared across particles. from blackjax.smc.base import extend_params mcmc_params = extend_params( {"step_size": jnp.array(step_size), "inverse_mass_matrix": inv_mass_diag, "max_num_doublings": jnp.array(max_num_doublings)}) smc_state, smc_info = smc_kernel( step_key, smc_state, num_mcmc_steps=n_mcmc_steps, mcmc_parameters=mcmc_params, ) lam = float(smc_state.tempering_param) lambdas.append(lam) ll_inc = float(smc_info.log_likelihood_increment) if np.isfinite(ll_inc): log_evidence += ll_inc else: logger.warning(f" Warning: non-finite log_likelihood_increment " f"({ll_inc}) at step {step + 1}, skipping") logger.info(f" Step {step + 1}: lambda={lam:.6f}, " f"step_size={step_size:.6f}, log_Z={log_evidence:.2f}") if lam >= 1.0: step_sizes.append(step_size) _save_smc_checkpoint(smc_state, lambdas, step_sizes, log_evidence, run_key, step_size, inv_mass_diag) break # Adapt step size for the next tempering stage using a # high-weight particle as the warmup starting point. best_idx = int(jnp.argmax(smc_state.weights)) best_particle = smc_state.particles[best_idx] step_size = _adapt_step_size( adapt_key, best_particle, step_size, lam, n_adapt_steps) step_sizes.append(step_size) # Periodic checkpoint if (checkpoint_every > 0 and (step + 1) % checkpoint_every == 0): _save_smc_checkpoint(smc_state, lambdas, step_sizes, log_evidence, run_key, step_size, inv_mass_diag) else: logger.warning(f" Warning: reached max_tempering_steps=" f"{max_tempering_steps} without reaching lambda=1.0 " f"(final={lam:.6f})") _save_smc_checkpoint(smc_state, lambdas, step_sizes, log_evidence, run_key, step_size, inv_mass_diag) n_steps = len(lambdas) - 1 logger.info(f"SMC complete: {n_steps} tempering steps, " f"log_evidence={log_evidence:.2f}") # --- Store results -------------------------------------------- final_particles = np.asarray(smc_state.particles) self.samples = final_particles self._n_chains = 1 self._info = { "n_particles": n_particles, "n_mcmc_steps": n_mcmc_steps, "n_adapt_steps": n_adapt_steps, "n_tempering_steps": n_steps, "tempering_schedule": np.array(lambdas), "step_sizes": np.array(step_sizes), "log_evidence": log_evidence, "step_size": step_size, "n_warmup": 0, "n_samples": n_particles, "n_chains": 1, } return final_particles, self._info
[docs] @staticmethod def load_samples(path, flatten_chains=True): """ Read all samples from a checkpoint file without loading the sampler state. Parameters ---------- path : str Path to a ``.npz`` checkpoint file. flatten_chains : bool If True (default), collapse the chain dimension so the returned array is always ``(n_total, n_params)``. Set to False to get the raw ``(n_chains, n_samples, n_params)`` array for per-chain diagnostics (e.g. R-hat). Returns ------- samples : np.ndarray Shape ``(n_total, n_params)`` when ``flatten_chains=True``, or ``(n_chains, n_samples, n_params)`` otherwise. """ from ..io import _is_hdf5 if _is_hdf5(path): from ..io import load_samples return load_samples(path, flatten_chains=flatten_chains) data = np.load(path) samples = data["samples"].copy() data.close() if flatten_chains and samples.ndim == 3: n_chains, n_samp, n_params = samples.shape samples = samples.reshape(n_chains * n_samp, n_params) return samples
def _corner_after_hdf5_save(self, path): """Write a corner plot after an HDF5 save_checkpoint.""" import os from ..io import load_samples all_samples = load_samples(path) if all_samples is None or len(all_samples) == 0: return import corner import matplotlib import matplotlib.pyplot as plt corner_dir = (self.save_dir if self.save_dir is not None else os.path.dirname(os.path.abspath(path))) corner_path = os.path.join(corner_dir, "corner_plot.png") has_maps = (hasattr(self, 'all_theta_maps') and self.all_theta_maps is not None and len(self.all_theta_maps) > 0) if has_maps: fig, _ = self.plot_corner_map( samples=all_samples, checkpoint_path=path, savefig=corner_path) else: old_usetex = matplotlib.rcParams.get("text.usetex", False) matplotlib.rcParams["text.usetex"] = False try: fig = corner.corner( all_samples, labels=list(self.param_keys), show_titles=True, title_fmt=".3f") fig.savefig(corner_path, dpi=150, bbox_inches="tight") finally: matplotlib.rcParams["text.usetex"] = old_usetex plt.close(fig)
# ===================================================================== # Dynesty nested sampler # =====================================================================