Source code for blackjax.adaptation.mclmc_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.
"""Algorithms to adapt the MCLMC kernel parameters, namely step size and L."""

from typing import NamedTuple

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

from blackjax.diagnostics import effective_sample_size
from blackjax.util import generate_unit_vector, incremental_value_update, pytree_size


[docs] class MCLMCAdaptationState(NamedTuple): """Represents the tunable parameters for MCLMC adaptation. L The momentum decoherent rate for the MCLMC algorithm. step_size The step size used for the MCLMC algorithm. inverse_mass_matrix A matrix used for preconditioning. """
[docs] L: float
[docs] step_size: float
[docs] inverse_mass_matrix: float
[docs] def mclmc_find_L_and_step_size( mclmc_kernel, num_steps, state, rng_key, logdensity_fn=None, frac_tune1=0.1, frac_tune2=0.1, frac_tune3=0.1, desired_energy_var=5e-4, trust_in_estimate=1.5, num_effective_samples=150, diagonal_preconditioning=True, params=None, l_factor=0.4, ): """ Finds the optimal value of the parameters for the MCLMC algorithm. Parameters ---------- mclmc_kernel The kernel function built by ``mclmc.build_kernel``. Its call signature must be ``kernel(rng_key, state, logdensity_fn, inverse_mass_matrix, L, step_size)``, matching the standard BlackJAX kernel pattern. num_steps The number of MCMC steps that will subsequently be run, after tuning. state The initial state of the MCMC algorithm. rng_key The random number generator key. logdensity_fn The log-density function of the target distribution. frac_tune1 The fraction of tuning for the first step of the adaptation. frac_tune2 The fraction of tuning for the second step of the adaptation. frac_tune3 The fraction of tuning for the third step of the adaptation. desired_energy_var The desired energy variance for the MCMC algorithm. trust_in_estimate The trust in the estimate of optimal stepsize. num_effective_samples The number of effective samples for the MCMC algorithm. diagonal_preconditioning Whether to do diagonal preconditioning (i.e. a mass matrix) params Initial params to start tuning from (optional) l_factor The factor scaling the estimated autocorrelation length to obtain momentum decoherence length L. Returns ------- final_state The final integrator state after the three tuning phases. final_params An ``MCLMCAdaptationState`` containing the adapted ``L``, ``step_size``, and ``inverse_mass_matrix``. total_num_tuning_integrator_steps The total number of integrator steps consumed across all three tuning phases (frac_tune1 + frac_tune2 + frac_tune3 of ``num_steps``). Example ------- .. code-block:: python kernel = blackjax.mcmc.mclmc.build_kernel(integrator=integrator) ( blackjax_state_after_tuning, blackjax_mclmc_sampler_params, num_tuning_steps, ) = blackjax.mclmc_find_L_and_step_size( mclmc_kernel=kernel, logdensity_fn=logdensity_fn, num_steps=num_steps, state=initial_state, rng_key=tune_key, diagonal_preconditioning=preconditioning, ) Notes ----- **Live divergence monitoring (jax-tap >= 0.3.0)** The internal tuning scan exposes a per-step divergence flag as its ``ys`` output (``True`` = divergence on that step). Users who install ``jax-tap >= 0.3.0`` can observe this stream with no changes to BlackJAX:: import jaxtap # pip install "jax-tap>=0.3.0" with jaxtap.record( select_ys=lambda ys: ys[0], # the single divergence-flag leaf alert_ys=lambda e: "divergence" if e.value else None, alert_ys_once=True, # one stderr line then silence; drop for per-step ) as rec: state, params, _ = blackjax.mclmc_find_L_and_step_size( mclmc_kernel=kernel, num_steps=N, state=init_state, rng_key=key, logdensity_fn=logdensity_fn, ) divergence_steps = [ e.step for e in rec.events if e.kind == "output" and e.value ] **Checking for degenerate warmup** BlackJAX does not emit runtime warnings; checking is the user's responsibility. *Before calling* — verify the initial gradient is finite:: from jax.flatten_util import ravel_pytree ok = jnp.all(jnp.isfinite(ravel_pytree(state.logdensity_grad)[0])) # finite logdensity + non-finite gradient = model/solver/support issue (#973) *After calling* — a collapsed warmup leaves ``step_size`` orders of magnitude below the posterior scale; healthy and frozen runs differ by ~6 orders:: ratio = final_params.step_size * num_steps / final_params.L # ratio ≈ 1 → healthy; ratio << 1 → likely frozen """ if logdensity_fn is None: raise ValueError( "logdensity_fn is required. Pass the log-density function of the " "target distribution." ) dim = pytree_size(state.position) if params is None: params = MCLMCAdaptationState( jnp.sqrt(dim), jnp.sqrt(dim) * 0.25, inverse_mass_matrix=jnp.ones((dim,)) ) part1_key, part2_key = jax.random.split(rng_key, 2) total_num_tuning_integrator_steps = 0 num_steps1, num_steps2 = round(num_steps * frac_tune1), round( num_steps * frac_tune2 ) num_steps2 += diagonal_preconditioning * (num_steps2 // 3) num_steps3 = round(num_steps * frac_tune3) state, params = make_L_step_size_adaptation( kernel=mclmc_kernel, logdensity_fn=logdensity_fn, dim=dim, frac_tune1=frac_tune1, frac_tune2=frac_tune2, desired_energy_var=desired_energy_var, trust_in_estimate=trust_in_estimate, num_effective_samples=num_effective_samples, diagonal_preconditioning=diagonal_preconditioning, )(state, params, num_steps, part1_key) total_num_tuning_integrator_steps += num_steps1 + num_steps2 if num_steps3 >= 2: # at least 2 samples for ESS estimation state, params = make_adaptation_L( mclmc_kernel, logdensity_fn, frac=frac_tune3, l_factor=l_factor )(state, params, num_steps, part2_key) total_num_tuning_integrator_steps += num_steps3 return state, params, total_num_tuning_integrator_steps
[docs] def make_L_step_size_adaptation( kernel, logdensity_fn, dim, frac_tune1, frac_tune2, diagonal_preconditioning, desired_energy_var=1e-3, trust_in_estimate=1.5, num_effective_samples=150, ): """Adapts the stepsize and L of the MCLMC kernel. Designed for unadjusted MCLMC""" decay_rate = (num_effective_samples - 1.0) / (num_effective_samples + 1.0) def predictor(previous_state, params, adaptive_state, rng_key): """does one step with the dynamics and updates the prediction for the optimal stepsize Designed for the unadjusted MCHMC""" time, x_average, step_size_max = adaptive_state rng_key, nan_key = jax.random.split(rng_key) # dynamics next_state, info = kernel( rng_key=rng_key, state=previous_state, logdensity_fn=logdensity_fn, inverse_mass_matrix=params.inverse_mass_matrix, L=params.L, step_size=params.step_size, ) # step updating — thread info so handle_nans can use the kernel's truthful # nonans flag (#969) instead of re-deriving from the already-reverted next_state. success, state, step_size_max, energy_change = handle_nans( previous_state, next_state, params.step_size, step_size_max, info.energy_change, info.nonans, nan_key, ) # The step-size adaptation exploits the scaling relation Var[E] = O(eps^6) # for the leapfrog integrator (see Bou-Rabee & Sanz-Serna, 2018). # xi measures the energy-variance ratio relative to the target; the # exponent 6.0 throughout this block originates from that relation. xi = ( jnp.square(energy_change) / (dim * desired_energy_var) ) + 1e-8 # small offset to prevent log(0) divergence weight = jnp.exp( -0.5 * jnp.square(jnp.log(xi) / (6.0 * trust_in_estimate)) ) # Gaussian weight that down-weights step sizes far from the optimum x_average = decay_rate * x_average + weight * ( xi / jnp.power(params.step_size, 6.0) ) time = decay_rate * time + weight step_size = jnp.power( x_average / time, -1.0 / 6.0 ) # invert the Var[E] = O(eps^6) relation to obtain the optimal step size step_size = (step_size < step_size_max) * step_size + ( step_size > step_size_max ) * step_size_max # if the proposed stepsize is above the stepsize where we have seen divergences params_new = params._replace(step_size=step_size) adaptive_state = (time, x_average, step_size_max) return state, params_new, adaptive_state, success def step(iteration_state, weight_and_key): """does one step of the dynamics and updates the estimate of the posterior size and optimal stepsize""" mask, rng_key = weight_and_key state, params, adaptive_state, streaming_avg = iteration_state state, params, adaptive_state, success = predictor( state, params, adaptive_state, rng_key ) x = ravel_pytree(state.position)[0] # update the running average of x, x^2 streaming_avg = incremental_value_update( expectation=jnp.array([x, jnp.square(x)]), incremental_val=streaming_avg, weight=mask * success * params.step_size, ) # Enabling seam: per-step divergence flag (True = diverged) is the scan ys. # jaxtap y-taps observe it via select_ys=lambda ys: ys[0] — see Notes in # mclmc_find_L_and_step_size. return (state, params, adaptive_state, streaming_avg), jnp.logical_not(success) def run_steps(xs, state, params): """Run adaptation steps via scan; return (final_carry, per_step_div_flags).""" carry, div_flags = jax.lax.scan( step, init=( state, params, (0.0, 0.0, jnp.inf), (0.0, jnp.array([jnp.zeros(dim), jnp.zeros(dim)])), ), xs=xs, ) return carry, div_flags def L_step_size_adaptation(state, params, num_steps, rng_key): num_steps1, num_steps2 = round(num_steps * frac_tune1), round( num_steps * frac_tune2 ) L_step_size_adaptation_keys = jax.random.split( rng_key, num_steps1 + num_steps2 + 1 ) L_step_size_adaptation_keys, final_key = ( L_step_size_adaptation_keys[:-1], L_step_size_adaptation_keys[-1], ) # we use the last num_steps2 to compute the diagonal preconditioner mask = jnp.concatenate((jnp.zeros(num_steps1), jnp.ones(num_steps2))) # run the steps; ys (per-step divergence flags) available to jaxtap y-taps (state, params, _, (_, average)), _ = run_steps( xs=(mask, L_step_size_adaptation_keys), state=state, params=params ) L = params.L inverse_mass_matrix = params.inverse_mass_matrix if num_steps2 > 1: x_average, x_squared_average = average[0], average[1] # See metric_estimators.sample_variance_diagonal for the array-based # equivalent (E[x^2] - E[x]^2 on raw draws). Not usable here: # x_average/x_squared_average are step-size-weighted streaming # aggregates, not a raw draws array — materializing a draws buffer # at this call site would change semantics. variances = x_squared_average - jnp.square(x_average) L = jnp.sqrt(jnp.sum(variances)) if diagonal_preconditioning: inverse_mass_matrix = variances params = params._replace(inverse_mass_matrix=inverse_mass_matrix) L = jnp.sqrt(dim) # readjust the stepsize steps = round(num_steps2 / 3) # we do some small number of steps keys = jax.random.split(final_key, steps) (state, params, _, _), _ = run_steps( xs=(jnp.ones(steps), keys), state=state, params=params ) return state, MCLMCAdaptationState(L, params.step_size, inverse_mass_matrix) return L_step_size_adaptation
[docs] def make_adaptation_L(kernel, logdensity_fn, frac, l_factor): """determine L by the autocorrelations (around 10 effective samples are needed for this to be accurate)""" def adaptation_L(state, params, num_steps, key): num_steps_3 = round(num_steps * frac) adaptation_L_keys = jax.random.split(key, num_steps_3) def step(state, key): next_state, _ = kernel( rng_key=key, state=state, logdensity_fn=logdensity_fn, inverse_mass_matrix=params.inverse_mass_matrix, L=params.L, step_size=params.step_size, ) return next_state, next_state.position state, samples = jax.lax.scan( f=step, init=state, xs=adaptation_L_keys, ) flat_samples = jax.vmap(lambda x: ravel_pytree(x)[0])(samples) ess = effective_sample_size(flat_samples[None, ...]) return state, params._replace( L=l_factor * params.step_size * jnp.mean(num_steps_3 / ess) ) return adaptation_L
[docs] def handle_nans( previous_state, next_state, step_size, step_size_max, kinetic_change, kernel_nonans, key, ): """Adaptation-level NaN handler. If the kernel reported a divergence (via its truthful ``info.nonans`` after #969 fix), reduce ``step_size_max`` and return the pre-step state. The kernel's own ``handle_nans`` already sanitises ``next_state`` for both divergence signatures: * Case-1: NaN position or momentum (position overshoot through a hard boundary). * Case-2: finite position + momentum but NaN ``logdensity`` (dominant under ``velocity_verlet`` at moderate overshoot on bounded targets). Parameters ---------- kernel_nonans ``info.nonans`` from the MCLMC kernel — truthful after the #969 fix. Returns ------- success ``True`` when the step was clean (no divergence and finite energy change). """ reduced_step_size = 0.8 # multiplicative shrinkage applied on NaN recovery # Consume the kernel's truthful flag; AND with energy finiteness as a # defense-in-depth guard that catches any residual NaN propagation. nonans = jnp.logical_and(kernel_nonans, jnp.isfinite(kinetic_change)) state, step_size, kinetic_change = jax.tree.map( lambda new, old: jax.lax.select(nonans, jnp.nan_to_num(new), old), (next_state, step_size_max, kinetic_change), (previous_state, step_size * reduced_step_size, 0.0), ) state = jax.lax.cond( jnp.isnan(next_state.logdensity), lambda: state._replace( momentum=generate_unit_vector(key, previous_state.position) ), lambda: state, ) return nonans, state, step_size, kinetic_change