Source code for blackjax.adaptation.meads_adaptation

# Copyright 2020- The Blackjax Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Callable, NamedTuple

import jax
import jax.numpy as jnp
from jax.flatten_util import ravel_pytree

import blackjax.mcmc as mcmc
from blackjax.adaptation.base import AdaptationResults, return_all_adapt_info
from blackjax.adaptation.metric_buffers import MomentBlock, cgl_update_batch
from blackjax.adaptation.metric_estimators import sample_covariance_eigh_low_rank
from blackjax.base import AdaptationAlgorithm
from blackjax.mcmc.metrics import LowRankInverseMassMatrix
from blackjax.types import Array, ArrayLikeTree, ArrayTree, PRNGKey

__all__ = ["MEADSAdaptationState", "base", "maximum_eigenvalue", "meads_adaptation"]


[docs] class MEADSAdaptationState(NamedTuple): """State of the MEADS adaptation scheme. current_iteration Current iteration of the adaptation. step_size Step size for each fold, shape (num_folds,). position_sigma PyTree with per-fold per-dimension sample standard deviation of the position variable, leading axis has size num_folds. alpha Alpha parameter (momentum persistence) for each fold, shape (num_folds,). delta Delta parameter (slice translation) for each fold, shape (num_folds,). """
[docs] current_iteration: int
[docs] step_size: Array
[docs] position_sigma: ArrayTree
[docs] alpha: Array
[docs] delta: Array
[docs] def base( num_folds: int = 4, step_size_multiplier: float = 0.5, damping_slowdown: float = 1.0, ): """Maximum-Eigenvalue Adaptation of damping and step size for the generalized Hamiltonian Monte Carlo kernel :cite:p:`hoffman2022tuning`. Full implementation of Algorithm 3 with K-fold cross-chain adaptation and chain shuffling. Chains are divided into ``num_folds`` folds; at each step statistics from fold ``t mod K`` are used to update the parameters for fold ``(t+1) mod K``. Every K steps all chains are reshuffled across folds. Parameters ---------- num_folds Number of folds K to split chains into. Must divide num_chains evenly. step_size_multiplier Multiplicative factor applied to the raw step size heuristic (default 0.5 as in the paper). damping_slowdown Controls the damping floor in early iterations. The floor on γ is ``damping_slowdown / (t·ε)``, so higher values force stronger damping (higher α) in early iterations. Default is 1.0 as in the paper. Returns ------- init Function that initializes the warmup state. update Function that moves the warmup one step forward. """ if num_folds < 1: raise ValueError(f"num_folds must be >= 1, got {num_folds}.") def compute_parameters( positions: ArrayLikeTree, logdensity_grad: ArrayLikeTree, current_iteration: int, ): """Compute GHMC parameters from a single fold's chains. Parameters ---------- positions PyTree with leading axis of size n_per_fold. logdensity_grad PyTree with leading axis of size n_per_fold. current_iteration Global adaptation iteration index. Returns ------- step_size, position_sigma, alpha, delta Notes ----- This function uses the same chains' positions for both step-size and damping estimation. The full MEADS algorithm (Algorithm 3) uses cross-fold statistics: step size comes from the source fold, while damping uses the target fold's own positions. ``meads_adaptation`` implements this correctly; this lower-level helper is an approximation suitable for direct use of ``base()``. """ mean_position = jax.tree.map(lambda p: p.mean(axis=0), positions) sd_position = jax.tree.map(lambda p: p.std(axis=0), positions) normalized_positions = jax.tree.map( lambda p, mu, sd: (p - mu) / sd, positions, mean_position, sd_position, ) batch_grad_scaled = jax.tree.map( lambda grad, sd: grad * sd, logdensity_grad, sd_position ) # Algorithm 3, line 8: ε = min(1, step_size_multiplier / sqrt(λ_max(ḡ))) epsilon = jnp.minimum( step_size_multiplier / jnp.sqrt(maximum_eigenvalue(batch_grad_scaled)), 1.0, ) # Algorithm 3, line 9 (paper parameterization): # γ = max(1/sqrt(λ_max(θ̄)), damping_slowdown / (t·ε)) # With α = 1 - exp(-2·ε·γ) the floor gives # α_floor = 1 - exp(-2·damping_slowdown/t) # Higher damping_slowdown → higher α_floor → stronger damping in early # iterations. gamma = jnp.maximum( 1.0 / jnp.sqrt(maximum_eigenvalue(normalized_positions)), damping_slowdown / ((current_iteration + 1) * epsilon), ) alpha = 1.0 - jnp.exp(-2.0 * epsilon * gamma) delta = alpha / 2 return epsilon, sd_position, alpha, delta def init( positions: ArrayLikeTree, logdensity_grad: ArrayLikeTree ) -> MEADSAdaptationState: """Initialize with parameters computed from all chains, replicated per fold.""" step_size, sd_position, alpha, delta = compute_parameters( positions, logdensity_grad, 0 ) # Replicate scalar params across folds step_sizes = jnp.full((num_folds,), step_size) alphas = jnp.full((num_folds,), alpha) deltas = jnp.full((num_folds,), delta) # Replicate pytree params: (num_folds, *dims) per leaf sigmas = jax.tree.map( lambda s: jnp.repeat(s[None], num_folds, axis=0), sd_position ) return MEADSAdaptationState(0, step_sizes, sigmas, alphas, deltas) def update( adaptation_state: MEADSAdaptationState, positions: ArrayLikeTree, logdensity_grad: ArrayLikeTree, source_fold: int, ) -> MEADSAdaptationState: """Update the target fold's parameters using the source fold's statistics. Parameters ---------- adaptation_state Current adaptation state. positions Positions of chains in the source fold only. logdensity_grad Gradients of chains in the source fold only. source_fold Index of the fold whose statistics are used. The target fold that receives the updated parameters is ``(source_fold + 1) % num_folds``. Returns ------- Updated adaptation state. """ target = (source_fold + 1) % num_folds t = adaptation_state.current_iteration new_step_size, new_sigma, new_alpha, new_delta = compute_parameters( positions, logdensity_grad, t ) new_step_sizes = adaptation_state.step_size.at[target].set(new_step_size) new_sigmas = jax.tree.map( lambda s, v: s.at[target].set(v), adaptation_state.position_sigma, new_sigma, ) new_alphas = adaptation_state.alpha.at[target].set(new_alpha) new_deltas = adaptation_state.delta.at[target].set(new_delta) return MEADSAdaptationState( t + 1, new_step_sizes, new_sigmas, new_alphas, new_deltas ) return init, update
def _low_rank_apply(element: Array, U: Array, lam_pow: Array) -> Array: """Batched ``element + U @ ((lam_pow - 1) * (U.T @ element))``. ``element`` has shape ``(n, d)`` (a batch of ``n`` flat vectors), ``U`` has shape ``(d, k)``, ``lam_pow`` has shape ``(k,)``. Shared building block for the two low-rank whitening transforms below -- it mirrors the ``B`` / ``A*`` matrices inside :func:`~blackjax.mcmc.metrics.gaussian_euclidean_low_rank`'s ``scale`` closure, specialized to a batch of vectors so it composes with MEADS's per-fold ``jax.vmap``. """ Ue = element @ U # (n, k) return element + (Ue * (lam_pow - 1.0)) @ U.T def _low_rank_precondition_pos(pos: Array, sigma: Array, U: Array, lam: Array) -> Array: """Low-rank generalization of the legacy ``pos / sigma`` preconditioning. Mirrors ``M^{1/2} pos`` (i.e. ``metric.scale(_, pos, inv=False, trans=False)`` for :func:`~blackjax.mcmc.metrics.gaussian_euclidean_low_rank`); reduces to ``pos / sigma`` when ``lam == 1`` (the diagonal limit), matching the legacy preconditioning bit-for-bit. """ return _low_rank_apply(pos, U, 1.0 / jnp.sqrt(lam)) / sigma def _lrd_from_accumulated_covariance( acc: MomentBlock, k: int ) -> tuple[Array, Array, Array]: """Extract ``(sigma, U, lam)`` from a window-accumulated covariance (effective ``n = num_chains * window_steps``) via ``eigh`` of the accumulated correlation matrix, selecting the top-``k`` directions by ``|lam - 1|`` (the directions that deviate most from isotropic). This is what makes the low-rank metric estimable at high dimension (``d >> num_chains``): a single ``num_chains``-sized ensemble snapshot is ``p >> n`` noise-dominated once ``d`` exceeds ``num_chains``, but the window-accumulated covariance's effective ``n`` can comfortably exceed ``d`` given enough window steps. Delegates to :func:`~blackjax.adaptation.metric_estimators.sample_covariance_eigh_low_rank` (behavior-identical). """ lrd = sample_covariance_eigh_low_rank(acc.m2, acc.count, k) return lrd.sigma, lrd.U, lrd.lam def _lrd_diagonal_fallback(flat_positions: Array, k: int) -> tuple[Array, Array, Array]: """Diagonal-only fallback ``(sigma, U, lam)`` for MEADS-LRD's momentum metric, used before the accumulation window holds enough pooled samples to support a rank-``k`` estimate (FIX 1). Estimating an eigenbasis from a single ``num_chains``-sized ensemble snapshot is exactly the noise-dominated (``p >> n``) estimator that causes the high-d step-size/momentum instability these fixes address -- measured directly: routing through it even as a *pre-window* fallback (rather than attempting a low-rank correction from too little data) was enough to blow up the ensemble and collapse epsilon at ``d=40, num_chains=32``. So instead this returns ``lam = 1`` (no correction -- ``_low_rank_apply``'s ``(lam_pow - 1)`` term vanishes identically), degenerating the momentum metric to exactly the diagonal-only preconditioning ``low_rank_rank=None`` uses. ``U``'s columns are then irrelevant (they multiply a zero coefficient) -- any orthonormal set works; the leading standard basis vectors are the cheapest choice. Only ``sigma`` (a per-dimension population statistic, well-estimated from as few as 2 samples per dimension, unlike a joint eigenbasis) carries real information here. """ sigma = jnp.std(flat_positions, axis=0) sigma = jnp.where(sigma <= 0.0, 1.0, sigma) d = flat_positions.shape[-1] U = jnp.eye(d, k) lam = jnp.ones((k,)) return sigma, U, lam _LRD_EIGENVALUE_FLOOR = 1e-6 def _floor_lrd_eigenvalues(lam: Array) -> Array: """Clamp low-rank eigenvalues away from 0. A collinear or otherwise rank-deficient ensemble (e.g. a rank-1 initial ensemble) can make the sample/accumulated correlation matrix singular along one or more of the selected top-k directions, giving ``lam ~ 0`` — and ``float32`` ``eigh`` can even return slightly *negative* eigenvalues, whose ``sqrt`` is NaN. The whitening transform (``_low_rank_precondition_pos``) and the momentum metric both scale by ``sqrt(lam)``, so flooring keeps those factors finite. This guard is intentionally redundant with the step-size decoupling (which keeps ``sqrt(lam)`` out of the step-size heuristic entirely): the degenerate collapse (``rhat = inf``, NaN step size) only occurs if *both* guards are defeated. ``jnp.maximum(lam, floor)`` guards against small/negative-but-finite eigenvalues only — NaN passes through (``maximum(NaN, floor) = NaN``). """ return jnp.maximum(lam, _LRD_EIGENVALUE_FLOOR)
[docs] def meads_adaptation( logdensity_fn: Callable, num_chains: int, num_folds: int = 4, step_size_multiplier: float = 0.5, damping_slowdown: float = 1.0, adaptation_info_fn: Callable = return_all_adapt_info, low_rank_rank: int | None = None, low_rank_window_fraction: float = 0.5, ) -> AdaptationAlgorithm: """Adapt the parameters of the Generalized HMC algorithm. Full implementation of Algorithm 3 from :cite:p:`hoffman2022tuning` with K-fold cross-chain adaptation and periodic chain shuffling. Chains are divided into ``num_folds`` folds. At adaptation step ``t``, fold ``t mod K`` is frozen (its chains do not advance, Algorithm 3 line 4). For each active fold k, the step size is computed from fold ``(k-1) mod K``'s preconditioned gradients, and the damping is computed from fold k's own positions using that step size. Every K steps all chains are reshuffled randomly across folds to prevent fold-assignment bias. Parameters ---------- logdensity_fn The log density probability density function from which we wish to sample. num_chains Total number of chains. Must be divisible by ``num_folds``. num_folds Number of folds K to split chains into. Default is 4 as in the paper. step_size_multiplier Multiplicative factor for the step size heuristic. Default is 0.5 as in the paper. damping_slowdown Slows the damping decay relative to the iteration count. Default is 1.0 as in the paper. Higher values force stronger damping in early iterations. adaptation_info_fn Function to select the adaptation info returned. See return_all_adapt_info and get_filter_adapt_info_fn in blackjax.adaptation.base. By default all information is saved - this can result in excessive memory usage if the information is unused. low_rank_rank MEADS-LRD extension (opt-in, default ``None``). ``None`` adapts a *diagonal* momentum metric from the fold ensemble -- exactly the original behavior, bit-for-bit. An ``int`` instead adapts a rank-``low_rank_rank`` :class:`~blackjax.mcmc.metrics.LowRankInverseMassMatrix` from the **full population of all ``num_chains`` chains** (requires :func:`blackjax.mcmc.ghmc`'s dense/low-rank momentum-metric support, blackjax#950), generalizing MEADS the way MCLMC-LRD generalized MCLMC. Unlike the diagonal scale (estimated per-fold, from each fold's own ``num_chains // num_folds`` chains), the low-rank eigenbasis is estimated *once per step* from the pooled global population and then shared across all folds: a single fold's ensemble (paper default ``num_folds=4`` gives only 16 chains/fold) is too small for its top-k eigenvectors to be stable step-to-step, and the resulting jitter destabilizes ghmc's persistent momentum (measured regression: low-rank underperformed diagonal at ``num_folds=4`` despite beating it at ``num_folds=1``, where the per-fold estimate happens to already be the global one). The metric is a shared symmetric preconditioner, not a per-fold statistic like step size or damping, so pooling all chains to estimate it needs no special justification -- it is the same practice window adaptation uses for its diagonal/dense metric. The per-fold step-size and damping heuristics (Algorithm 3) are otherwise unchanged, except they now whiten by this shared global metric rather than a per-fold one, so they stay consistent with the metric ghmc actually samples with. The rank is clamped to ``min(low_rank_rank, num_chains - 1, d)`` (raises ``ValueError`` if ``num_chains - 1 < 1``). A rank-``d`` metric equals the full dense metric, so clamping by ``d`` is lossless and prevents shape disagreements in the jax.lax.cond branches. The metric *returned* by ``run()`` is the final state of the same window-accumulated estimator described under ``low_rank_window_fraction`` below. Two further fixes address a validated high-dimension (``d >> num_chains``) failure mode where a single-snapshot low-rank metric made MEADS-LRD *worse* than the diagonal baseline (a p >> n noise- dominated eigenbasis fed into ghmc's step-size heuristic collapsed ``epsilon`` to ~1e-3 and froze the chains): - The step-size heuristic (Algorithm 3, line 8) always whitens its gradients by the plain per-fold diagonal scale (``grad * sigma``), never by the low-rank metric, even when ``low_rank_rank`` is set. Whitening ``epsilon`` by a noisy low-rank eigenbasis couples the step size to whichever direction the estimate currently over-weights, which is what caused the collapse above; the low-rank metric still preconditions the *momentum* (where it helps), just not the step-size proxy. - Selected eigenvalues are floored away from 0 (see ``_floor_lrd_eigenvalues``) so a collinear/rank-deficient initial ensemble can't seed a degenerate metric that self-reinforces into ``rhat = inf``. Collinear / near-collinear initial ensembles (e.g. all chains on a 1-D offset line) do not crash — two redundant guards (the step-size decoupling and the eigenvalue floor) prevent the NaN collapse — but expect severe under-mixing (measured rhat≈5 on a rank-1 init); use a dispersed, full-rank initialization. low_rank_window_fraction Only used when ``low_rank_rank`` is not ``None``. Fraction of warmup steps, counted from the end, over which the low-rank metric's covariance is accumulated (default ``0.5``: the last half of warmup). A single ``num_chains``-sized ensemble snapshot is ``p >> n`` noise-dominated once the dimension ``d`` exceeds ``num_chains`` -- exactly the regime a single fold's estimate was already too noisy for (see ``low_rank_rank`` above), just worse, since now the *whole* population's snapshot is undersized too, and (measured directly) even routing through it as a one-off fallback is enough to destabilize the ensemble. Instead, a running Chan/Welford covariance accumulator (mirroring the pattern :func:`~blackjax.adaptation.mass_matrix.welford_algorithm` uses for the mass matrix, generalized to a whole ensemble per step) is updated with every chain's position at every step *inside* the window, giving an effective sample size of ``num_chains * window_steps``. Once that effective size exceeds ``2 * d`` (a bare minimum for the estimate to not be noise-dominated), the low-rank momentum metric switches on and keeps improving every further window step; before that point -- either because the step is before the window (the initial, still-transient fraction of warmup, mirroring why Stan's window adaptation excludes its own initial/final fast windows from mass-matrix estimation), or because the window hasn't yet pooled ``2 * d`` samples -- the momentum metric falls back to a purely diagonal one (:func:`_lrd_diagonal_fallback`, i.e. no low-rank correction at all, matching ``low_rank_rank=None``'s momentum exactly), never a low-rank estimate from too little data. Must be in ``[0.0, 1.0]``; ``0.0`` accumulates from step 0, ``1.0`` disables accumulation entirely (falls back to the purely diagonal momentum metric throughout the run). At GPU scale (independent re-validation, num_chains up to 1024 on a d≈390 hierarchical target): the low-rank metric's de-biasing reproduces (num_chains ≥ 256 with adequate warmup — cutting warmup at high num_chains under-accumulates the metric and re-introduces bias via step-size collapse) and holds as num_chains grows, but residual mean-error stabilizes slightly above strict certification thresholds; treat the low-rank metric as a robust improvement over the diagonal rather than a guarantee of unbiased means on such targets. Returns ------- A function that returns the last cross-chain state, a sampling kernel with the tuned parameter values (averaged across folds), and all the warm-up states for diagnostics. """ if num_folds < 1: raise ValueError(f"num_folds must be >= 1, got {num_folds}.") if num_chains % num_folds != 0: raise ValueError( f"num_chains ({num_chains}) must be divisible by num_folds ({num_folds})." ) n_per_fold = num_chains // num_folds low_rank_k: int | None = None if low_rank_rank is not None: if not hasattr(mcmc.ghmc, "_metric_from_momentum_inverse_scale"): raise RuntimeError( "low_rank_rank requires blackjax.mcmc.ghmc's dense/low-rank " "momentum-metric support (blackjax#950); the installed ghmc " "module predates it." ) low_rank_k = min(low_rank_rank, num_chains - 1) if low_rank_k < 1: raise ValueError( f"low_rank_rank={low_rank_rank} cannot be honored: the " f"low-rank metric is estimated from the full population of " f"num_chains={num_chains} chains, and that estimate needs " "num_chains - 1 >= 1. Increase num_chains." ) if not 0.0 <= low_rank_window_fraction <= 1.0: raise ValueError( "low_rank_window_fraction must be in [0.0, 1.0], got " f"{low_rank_window_fraction}." ) ghmc_kernel = mcmc.ghmc.build_kernel() adapt_init, _ = base(num_folds, step_size_multiplier, damping_slowdown) batch_init = jax.vmap(lambda p, r: mcmc.ghmc.init(p, logdensity_fn, r)) def one_step(carry, xs): rng_key, in_window = xs states, adaptation_state, lrd_accum = carry t = adaptation_state.current_iteration # Fold to freeze this step (Algorithm 3, line 4: "excluding k = t mod K") fold_to_skip = t % num_folds keys = jax.random.split(rng_key, num_chains + 1) chain_keys, shuffle_key = keys[:num_chains], keys[num_chains] # Reshape chain arrays to [num_folds, n_per_fold, *dims] for per-fold ops def to_folds(x): return x.reshape((num_folds, n_per_fold) + x.shape[1:]) folded_pos = jax.tree.map(to_folds, states.position) folded_grads = jax.tree.map(to_folds, states.logdensity_grad) # Per-fold scale (std across chains within each fold) # Result: PyTree with leaves [num_folds, *dims] folded_scales = jax.tree.map(lambda p: p.std(axis=1), folded_pos) # Preconditioned grads: grads_k * scale_k (Algorithm 3, line 7) precond_grads = jax.tree.map( lambda g, s: g * jnp.expand_dims(s, axis=1), folded_grads, folded_scales, ) # MEADS-LRD: estimate ONE rank-`low_rank_k` correlation eigenbasis # (sigma, U, lam) per step from the FULL population of all # num_chains chains -- NOT from each fold's own noisy n_per_fold # snapshot. A fold's ensemble is too small (paper default # n_per_fold=16) for its top-k eigenvectors to be stable # step-to-step; the resulting eigenvector jitter destabilizes ghmc's # persistent momentum. The metric is a shared symmetric # preconditioner (unlike step-size/damping, which stay genuinely # per-fold below), so pooling all chains to estimate it needs no # per-fold isolation -- the same practice window adaptation uses for # its diagonal/dense metric. This low-rank metric then preconditions # the damping heuristic below (via `_low_rank_precondition_pos`), so # alpha/delta stay consistent with the metric actually handed to # ghmc for sampling this step. It does NOT precondition the # step-size heuristic (see the ε-decouple fix just below). # # FIX (high-d, p >> n): a single num_chains-sized snapshot is noise- # dominated once d exceeds num_chains, so `lrd_accum` -- a running # Chan/Welford covariance pooled over every chain at every step # *inside* the accumulation window (see `low_rank_window_fraction`'s # docstring) -- is used instead once its effective sample count # exceeds 2*d (a bare minimum for a covariance estimate to not be # noise-dominated). Before that (outside the window, or too early # inside it), fall back to a purely diagonal metric # (`_lrd_diagonal_fallback`) rather than ever attempting a low-rank # estimate from too little data -- see that function's docstring for # why a single-snapshot estimate is unsafe even as a fallback. if low_rank_rank is not None: flat_all_pos = jax.vmap(lambda p: ravel_pytree(p)[0])(states.position) d = flat_all_pos.shape[-1] flat_folded_pos = flat_all_pos.reshape((num_folds, n_per_fold, d)) updated_lrd_accum = jax.lax.cond( in_window, lambda a: cgl_update_batch(a, flat_all_pos), lambda a: a, lrd_accum, ) enough_accumulated = updated_lrd_accum.count >= 2 * d use_accumulated = jnp.logical_and(in_window, enough_accumulated) global_sigma, global_U, global_lam = jax.lax.cond( use_accumulated, lambda a: _lrd_from_accumulated_covariance(a, low_rank_k), lambda a: _lrd_diagonal_fallback(flat_all_pos, low_rank_k), updated_lrd_accum, ) global_lam = _floor_lrd_eigenvalues(global_lam) else: updated_lrd_accum = lrd_accum # ε-decouple: the step-size heuristic (Algorithm 3, line 8) always # whitens by the plain per-fold diagonal scale, never by the # low-rank metric -- whitening it by a noisy low-rank eigenbasis # couples epsilon to whichever direction the estimate currently # over-weights (measured: collapsed epsilon ~20x at d=390), and the # low-rank metric's benefit is in the momentum, not the step size. precond_grads_for_step_size = precond_grads # Per-fold step size from each fold's own preconditioned grads. # Then roll by 1 so fold k gets the step size from fold k-1. # (Algorithm 3, line 8 + cross-fold roll) def fold_step_size(grads_k): return jnp.minimum( step_size_multiplier / jnp.sqrt(maximum_eigenvalue(grads_k)), 1.0, ) step_size_own = jax.vmap(fold_step_size)( precond_grads_for_step_size ) # [num_folds] # fold k uses step_size from fold k-1 step_size_rolled = jnp.roll(step_size_own, 1) # [num_folds] # fold k uses the momentum scale (std) from fold k-1 scales_rolled = jax.tree.map( # [num_folds, *dims] lambda s: jnp.roll(s, 1, axis=0), folded_scales ) # Per-fold damping from each fold's OWN (centered, scaled) positions # and the rolled step size from the left-neighbor fold. # Algorithm 3, lines 9-10 (paper parameterization): # γ_k = max(1/sqrt(λ_max(θ̄_k)), damping_slowdown/(t·ε_k)) # α_k = 1 - exp(-2·ε_k·γ_k) def fold_damping(pos_k, eps_k): # Center within the fold before eigenvalue estimation pos_k_centered = jax.tree.map(lambda p: p - p.mean(axis=0), pos_k) gamma = jnp.maximum( 1.0 / jnp.sqrt(maximum_eigenvalue(pos_k_centered)), damping_slowdown / ((t + 1) * eps_k), ) alpha = 1.0 - jnp.exp(-2.0 * eps_k * gamma) delta = alpha / 2 return alpha, delta # Divide each fold's positions by its own scale (centering done inside) precond_pos = jax.tree.map( lambda p, s: p / jnp.expand_dims(s, axis=1), folded_pos, folded_scales, ) if low_rank_rank is not None: # Same shared global (sigma, U, lam) as the step-size block above # -- broadcast across folds (in_axes=None), not rolled: unlike # the per-fold diagonal scale, there is only one metric this # step, so every fold whitens by (and every chain samples with) # the same eigenbasis. precond_pos_for_damping = jax.vmap( _low_rank_precondition_pos, in_axes=(0, None, None, None) )(flat_folded_pos, global_sigma, global_U, global_lam) else: precond_pos_for_damping = precond_pos alphas, deltas = jax.vmap(fold_damping)( precond_pos_for_damping, step_size_rolled ) # Broadcast per-fold parameters to per-chain arrays chain_step_sizes = jnp.repeat(step_size_rolled, n_per_fold) chain_scales = jax.tree.map( lambda s: jnp.repeat(s, n_per_fold, axis=0), scales_rolled ) chain_alphas = jnp.repeat(alphas, n_per_fold) chain_deltas = jnp.repeat(deltas, n_per_fold) if low_rank_rank is not None: # Feed ghmc the SAME global LowRankInverseMassMatrix for every # chain (no per-fold rolling -- there is only one metric this # step); blackjax#950 lets ghmc's momentum_inverse_scale accept # this directly (no elementwise squaring, unlike the legacy # diagonal path). chain_momentum_inverse_scale = LowRankInverseMassMatrix( sigma=jnp.repeat(global_sigma[None], num_chains, axis=0), U=jnp.repeat(global_U[None], num_chains, axis=0), lam=jnp.repeat(global_lam[None], num_chains, axis=0), ) else: chain_momentum_inverse_scale = chain_scales # Step all chains with their fold's parameters new_states, info = jax.vmap(ghmc_kernel, in_axes=(0, 0, None, 0, 0, 0, 0))( chain_keys, states, logdensity_fn, chain_step_sizes, chain_momentum_inverse_scale, chain_alphas, chain_deltas, ) # Restore fold_to_skip's chains: they do not advance this step # (Algorithm 3, line 4: "excluding k = t mod K"). # When num_folds==1 there is no meaningful cross-fold split, so all # chains advance (no fold is frozen). if num_folds > 1: fold_is_skipped = jnp.arange(num_folds) == fold_to_skip # [num_folds] chain_is_skipped = jnp.repeat(fold_is_skipped, n_per_fold) # [num_chains] def restore_skipped(new_val, old_val): mask = chain_is_skipped.reshape( chain_is_skipped.shape + (1,) * (new_val.ndim - 1) ) return jnp.where(mask, old_val, new_val) new_states = jax.tree.map(restore_skipped, new_states, states) new_adaptation_state = MEADSAdaptationState( current_iteration=t + 1, step_size=step_size_rolled, position_sigma=scales_rolled, alpha=alphas, delta=deltas, ) # Every num_folds steps: reshuffle chains across folds. # Skipped for num_folds==1 (single fold; shuffle would be a no-op). if num_folds > 1: perm = jax.random.permutation(shuffle_key, num_chains) new_states = jax.lax.cond( (t + 1) % num_folds == 0, lambda s: jax.tree.map(lambda x: x[perm], s), lambda s: s, new_states, ) return ( new_states, new_adaptation_state, updated_lrd_accum, ), adaptation_info_fn(new_states, info, new_adaptation_state) def run(rng_key: PRNGKey, positions: ArrayLikeTree, num_steps: int = 1000): key_init, key_adapt = jax.random.split(rng_key) rng_keys = jax.random.split(key_init, num_chains) init_states = batch_init(positions, rng_keys) init_adaptation_state = adapt_init(positions, init_states.logdensity_grad) if low_rank_rank is not None: # Accumulate the covariance over the LAST `low_rank_window_fraction` # of warmup steps only (skip the still-transient early fraction), # mirroring how Stan's window adaptation delimits its own windows. # `window_start`/`in_window_flags` are plain Python/concrete-shape # values -- num_steps is always a concrete int here, never traced. window_start = int(low_rank_window_fraction * num_steps) flat_init_pos = jax.vmap(lambda p: ravel_pytree(p)[0])(init_states.position) d = flat_init_pos.shape[-1] # Clamp the rank to the flattened dimension as well: rank > d makes the # two jax.lax.cond branches disagree on output shapes. A rank-d metric # equals the full dense metric, so this clamp is lossless. nonlocal low_rank_k low_rank_k = min(low_rank_k, d) init_lrd_accum = MomentBlock( count=jnp.zeros(()), mean=jnp.zeros((d,)), m2=jnp.zeros((d, d)) ) else: window_start = num_steps init_lrd_accum = None in_window_flags = jnp.arange(num_steps) >= window_start keys = jax.random.split(key_adapt, num_steps) (last_states, last_adaptation_state, last_lrd_accum), info = jax.lax.scan( one_step, (init_states, init_adaptation_state, init_lrd_accum), (keys, in_window_flags), ) if low_rank_rank is not None: # The metric returned to the caller is the FINAL state of the # same window-accumulated estimator `one_step` uses per-step # (effective n = num_chains * window_steps), falling back to the # diagonal-only estimate only if the window never accumulated # enough samples (e.g. num_steps too small). See # `low_rank_window_fraction`'s docstring. `low_rank_k` (== # min(low_rank_rank, num_chains - 1)) is reused unchanged. flat_final_pos = jax.vmap(lambda p: ravel_pytree(p)[0])( last_states.position ) # low_rank_k is set to a non-None int whenever low_rank_rank is # not None (validated above); assert narrows it for mypy. assert low_rank_k is not None d_final = flat_final_pos.shape[-1] use_accumulated_final = last_lrd_accum.count >= 2 * d_final final_sigma, final_U, final_lam = jax.lax.cond( use_accumulated_final, lambda a: _lrd_from_accumulated_covariance(a, low_rank_k), lambda a: _lrd_diagonal_fallback(flat_final_pos, low_rank_k), last_lrd_accum, ) final_lam = _floor_lrd_eigenvalues(final_lam) momentum_inverse_scale = LowRankInverseMassMatrix( sigma=final_sigma, U=final_U, lam=final_lam ) else: momentum_inverse_scale = jax.tree.map( lambda s: s.mean(axis=0), last_adaptation_state.position_sigma ) # Return mean parameters across folds for use with a single ghmc kernel parameters = { "step_size": last_adaptation_state.step_size.mean(), "momentum_inverse_scale": momentum_inverse_scale, "alpha": last_adaptation_state.alpha.mean(), "delta": last_adaptation_state.delta.mean(), } return AdaptationResults(last_states, parameters), info return AdaptationAlgorithm(run) # type: ignore[arg-type]
[docs] def maximum_eigenvalue(matrix: ArrayLikeTree) -> Array: """Estimate the largest eigenvalues of a matrix. We calculate an unbiased estimate of the ratio between the sum of the squared eigenvalues and the sum of the eigenvalues from the input matrix. This ratio approximates the largest eigenvalue well except in cases when there are a large number of small eigenvalues significantly larger than 0 but significantly smaller than the largest eigenvalue. This unbiased estimate is used instead of directly computing an unbiased estimate of the largest eigenvalue because of the latter's large variance. Parameters ---------- matrix A PyTree with equal batch shape as the first dimension of every leaf. The PyTree for each batch is flattened into a one dimensional array and these arrays are stacked vertically, giving a matrix with one row for every batch. """ X = jax.vmap(lambda m: jax.flatten_util.ravel_pytree(m)[0])(matrix) n, _ = X.shape S = X @ X.T diag_S = jnp.diag(S) lamda = jnp.sum(diag_S) / n lamda_sq = (jnp.sum(S**2) - jnp.sum(diag_S**2)) / (n * (n - 1)) return lamda_sq / lamda