Source code for sequince.steps.mutation.adaptive.proposal

"""Adapt proposal parameters from completed mutation metrics."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import numpy as np

from ...base import SMCStep, validate_n_steps
from ...context import StepContext
from ..metrics import MutationMetricsOutput
from .base import AdaptiveMutationStep

if TYPE_CHECKING:
    from orng import RandomGenerator

    from ....state import ParticleState


[docs] @dataclass(frozen=True) class AdaptiveGaussianProposal(AdaptiveMutationStep): """Adapt proposal scale after a complete mutation block (eager execution). The wrapped step must return finalized ``MutationMetricsOutput`` in its output. Its output is preserved. The scale in ``context.metadata`` is initialized before mutation and updated afterward for the next call using ``scale * exp(adaptation_rate * (acceptance - target_acceptance))``. Proposal kernels must read ``scale_key`` from the context; this wrapper does not modify proposal objects. Wrap an entire ``RepeatedMutationStep`` to keep the scale fixed across all its rounds. Place conditional execution outside this wrapper. Runtime scale lives in the context; checkpoint state forwards to the child. """ mutation: SMCStep scale_key: str = "proposal_scale" initial_scale: float = 1.0 target_acceptance: float | None = 0.3 adaptation_rate: float = 0.1 min_scale: float = 1e-3 max_scale: float = 10.0 def __post_init__(self) -> None: if not isinstance(self.mutation, SMCStep): raise TypeError("mutation must be an SMCStep.") if not self.scale_key: raise ValueError("scale_key must be nonempty.") values = ( self.initial_scale, self.min_scale, self.max_scale, self.adaptation_rate, ) if not all(np.isfinite(value) for value in values): raise ValueError("Scale parameters must be finite.") if not 0 < self.min_scale <= self.initial_scale <= self.max_scale: raise ValueError( "Require 0 < min_scale <= initial_scale <= max_scale." ) if self.adaptation_rate < 0: raise ValueError("adaptation_rate must be nonnegative.") if ( self.target_acceptance is not None and not 0 <= self.target_acceptance <= 1 ): raise ValueError("target_acceptance must lie in [0, 1].") def _prepare_mutation( self, context: StepContext, n_steps: int | None, ) -> tuple[StepContext, int | None]: scale = float(context.metadata.get(self.scale_key, self.initial_scale)) if not np.isfinite(scale) or scale <= 0: raise ValueError("Proposal scale must be finite and positive.") scale = float(np.clip(scale, self.min_scale, self.max_scale)) context = context.with_metadata(**{self.scale_key: scale}) return context, n_steps def _adapt( self, metrics: MutationMetricsOutput, context: StepContext, *, initial_context: StepContext, n_steps: int | None, ) -> StepContext: scale = float(initial_context.metadata[self.scale_key]) if self.target_acceptance is not None and metrics.n_attempted > 0: acceptance = float(metrics.acceptance) if np.isfinite(acceptance): # Bound in log space to avoid overflow for large learning rates. log_scale = np.log(scale) + self.adaptation_rate * ( acceptance - self.target_acceptance ) scale = float( np.exp( np.clip( log_scale, np.log(self.min_scale), np.log(self.max_scale), ) ) ) return context.with_metadata(**{self.scale_key: scale})
[docs] def apply_with_n_steps( self, state: ParticleState, context: StepContext, rng: RandomGenerator, *, n_steps: int, ) -> tuple[ParticleState, StepContext, Any]: return self._apply( state, context, rng, n_steps=validate_n_steps(n_steps) )
__all__ = ["AdaptiveGaussianProposal"]