"""
MCMC engine builder
The purpose of the engine builder is to provide a simple API to gradually assemble
the components needed by the MCMC engine. The builder is responsible of returning an
engine in a well-defined state. Furthermore, the builder can return different engine
implementations.
"""
from __future__ import annotations
import logging
import math
from collections.abc import Iterable, Mapping
from functools import partial
from typing import TYPE_CHECKING, cast
import jax
import jax.numpy as jnp
from liesel.option import Option
from .engine import Engine
from .epoch import EpochConfig, EpochManager, EpochType
from .kernel_sequence import KernelSequence
from .pytree import stack_leaves
from .types import (
JitterFunction,
JitterFunctions,
Kernel,
KeyArray,
ModelInterface,
ModelState,
Position,
QuantityGenerator,
)
from .warmup import stan_epochs
_EpochConfig = partial(EpochConfig, optional=None)
logger = logging.getLogger(__name__)
def _find_duplicate(xs: list[str]) -> Option[str]:
"""Checks if a list of strings contains any duplicates."""
set_ = set()
for x in xs:
if x in set_:
return Option(x)
else:
set_.add(x)
return Option(None)
[docs]
class EngineBuilder:
"""
The :class:`~liesel.goose.EngineBuilder` is used to construct an MCMC Engine.
.. rubric:: Liesel Workflow
The Liesel workflow usually looks something like this:
#. You intialize your :class:`~liesel.model.nodes.Var` objects with
:class:`~liesel.goose.MCMCSpec` that specify how each variable should be
sampled.
#. You initilize an EngineBuilder using the method
:meth:`~liesel.goose.LieselMCMC.get_engine_builder` of
:class:`~liesel.goose.LieselMCMC`.
#. Set the desired number of warmup and posterior samples with
:meth:`~liesel.goose.EngineBuilder.add_burnin`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`, and
:meth:`~liesel.goose.EngineBuilder.add_posterior`.
#. Build an :class:`~liesel.goose.Engine` with
:meth:`~liesel.goose.EngineBuilder.build`.
.. rubric:: General Workflow
The general workflow usually looks something like this:
#. Create a builder with :class:`~liesel.goose.EngineBuilder`.
#. Set the desired number of warmup and posterior samples with
:meth:`~liesel.goose.EngineBuilder.add_burnin`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`, and
:meth:`~liesel.goose.EngineBuilder.add_posterior`.
#. Set the model interface with :meth:`~liesel.goose.EngineBuilder.set_model`.
#. Set the initial values with
:meth:`~liesel.goose.EngineBuilder.set_initial_values`.
#. Add MCMC kernels with :meth:`~liesel.goose.EngineBuilder.add_kernel`.
#. Build an :class:`~liesel.goose.Engine` with
:meth:`~liesel.goose.EngineBuilder.build`.
Optionally, you can also:
- Add position keys to :attr:`~liesel.goose.EngineBuilder.positions_included` for
tracking. If you are using a
:class:`~liesel.model.Model` object, position keys are the names of variables or
nodes in the
model. Refer to :attr:`~liesel.goose.EngineBuilder.positions_included` for more
information.
- Add custom jittering for start values with
:meth:`~liesel.goose.EngineBuilder.set_jitter_fns`.
Parameters
----------
seed
Either an int or a key generated from jax.random.PRNGKey. Used for jittering
initial values and MCMC sampling.
num_chains
The number of chains to be used.
See Also
--------
liesel.goose.Engine : The MCMC engine returned by :meth:`build
<liesel.goose.EngineBuilder.build>`.
liesel.goose.LieselInterface : Interface for a :class:`~liesel.model.Model`.
liesel.goose.NUTSKernel : The NUTS kernel.
liesel.goose.HMCKernel : The HMC kernel.
liesel.goose.IWLSKernel : The IWLS kernel.
liesel.goose.RWKernel : The random walk kernel.
Notes
-----
By default, only position keys associated with an MCMC kernel is tracked. This
behavior can be adjusted with the fields
:attr:`~liesel.goose.EngineBuilder.positions_included` and
:attr:`~liesel.goose.EngineBuilder.positions_excluded`.
Examples
--------
.. rubric:: Liesel Workflow
For this example, we import ``tensorflow_probability`` as follows:
>>> import tensorflow_probability.substrates.jax.distributions as tfd
First, we set up a minimal model:
>>> mu = lsl.Var.new_param(0.0, name="mu", inference=gs.MCMCSpec(gs.NUTSKernel))
>>> dist = lsl.Dist(tfd.Normal, loc=mu, scale=1.0)
>>> y = lsl.Var.new_obs(jnp.array([1.0, 2.0, 3.0]), dist, name="y")
>>> model = lsl.Model([y])
Now we initialize the EngineBuilder and set the desired number of warmup and
posterior samples:
>>> builder = gs.LieselMCMC(model).get_engine_builder(seed=1, num_chains=4)
>>> builder.add_adaptation(1000)
>>> builder.add_posterior(1000)
Finally, we build the engine:
>>> engine = builder.build()
From here, you can continue with :meth:`~liesel.goose.Engine.sample_all_epochs` to
draw
samples from your posterior distribution.
.. rubric:: General Workflow
For this example, we import ``tensorflow_probability`` as follows:
>>> import tensorflow_probability.substrates.jax.distributions as tfd
First, we set up a minimal model:
>>> mu = lsl.Var.new_param(0.0, name="mu")
>>> dist = lsl.Dist(tfd.Normal, loc=mu, scale=1.0)
>>> y = lsl.Var.new_obs(jnp.array([1.0, 2.0, 3.0]), dist, name="y")
>>> model = lsl.Model([y])
Now we initialize the EngineBuilder and set the desired number of warmup and
posterior samples:
>>> builder = gs.EngineBuilder(seed=1, num_chains=4)
>>> builder.set_duration(warmup_duration=1000, posterior_duration=1000)
Next, we set the model interface and initial values:
>>> builder.set_model(gs.LieselInterface(model))
>>> builder.set_initial_values(model.state)
We add a NUTS kernel for the parameter ``"mu"``:
>>> builder.add_kernel(gs.NUTSKernel(["mu"]))
Finally, we build the engine:
>>> engine = builder.build()
From here, you can continue with :meth:`~liesel.goose.Engine.sample_all_epochs` to
draw
samples from your posterior distribution.
"""
if TYPE_CHECKING:
minimize_transition_infos: bool
"""Whether to retain only minimal transition information."""
positions_excluded: list[str]
"""Position keys excluded from the sampling results."""
positions_included: list[str]
"""Additional position keys to retain in the sampling results."""
show_progress: bool
"""Whether to show progress bars during sampling."""
store_kernel_states: bool
"""Whether to retain kernel states in the sampling results."""
def __init__(self, seed: int | KeyArray, num_chains: int):
if isinstance(seed, int):
keys = jax.random.split(jax.random.PRNGKey(seed), 3)
elif isinstance(seed, jax.Array):
keys = jax.random.split(seed, 3)
else:
raise TypeError(
"Provide either an int or a key from jax.random.PRNGKey as seed."
)
initial_values = [
_EpochConfig(EpochType.INITIAL_VALUES, duration=1, thinning=1)
]
self._epochs = EpochManager(configs=initial_values)
self._prng_key: KeyArray = keys[0]
self._engine_key: KeyArray = keys[1]
self._jitter_key: KeyArray = keys[2]
self._num_chains: int = num_chains
self._kernels: list[Kernel] = []
self._quantity_generators: list[QuantityGenerator] = []
self._model_state: Option[ModelState] = Option(None)
self._model: Option[ModelInterface] = Option(None)
self._jitter_fns: Option[JitterFunctions] = Option(None)
# public fields, only simple states
self.store_kernel_states: bool = False
self.minimize_transition_infos: bool = False
self.show_progress: bool = True
"""Whether to show progress bars during sampling."""
self.positions_included: list[str] = []
"""
List of additional position keys that should be tracked.
If a position key is tracked that means the correspond element of the model
state will be saved and included in the :class:`~liesel.goose.SamplingResults`
and the
posterior samples returned by
:meth:`~liesel.goose.SamplingResults.get_posterior_samples`.
By default, only position keys associated with an MCMC kernel are tracked. You
can easily add additional position keys by appending to this list.
Examples
--------
For this example, we import ``tensorflow_probability`` as follows:
>>> import tensorflow_probability.substrates.jax.distributions as tfd
Consider the following simple model, in which we use the logarithm of the scale
parameter in a normal distribution and take the exponential value for including
the actual scale:
>>> log_scale = lsl.Var.new_param(0.0, name="log_scale")
>>> scale = lsl.Var.new_calc(jnp.exp, log_scale, name="scale")
>>> dist = lsl.Dist(tfd.Normal, loc=0.0, scale=scale)
>>> y = lsl.Var.new_obs(jnp.array([1.0, 2.0, 3.0]), dist, name="y")
>>> model = lsl.Model([y])
Now we might want to set up an engine builder with a NUTS kernel for the
parameter ``"log_scale"``:
>>> builder = gs.EngineBuilder(seed=1, num_chains=4)
>>> builder.set_model(gs.LieselInterface(model))
>>> builder.add_kernel(gs.NUTSKernel(["log_scale"]))
By default, only the position key ``"log_scale"`` is tracked and will be
included in the results. Now, if you also want the value of ``"scale"`` to be
included, you can add it to the list of included position keys:
>>> builder.positions_included.append("scale")
>>> builder.positions_included
['scale']
Beware however that including many intermediate position keys can lead to large
results. In some cases it may be preferable to keep the tracked positions to a
minimum and recompute the intermediate values from the posterior samples.
"""
self.positions_excluded: list[str] = []
"""List of position keys that should not be tracked. Excluded keys override
additional keys."""
[docs]
def set_engine_seed(self, seed: int | KeyArray):
"""Sets a seed used to initialize the MCMC engine."""
if jnp.isscalar(seed):
seed_int: int = cast(int, seed)
self._engine_key = jax.random.PRNGKey(seed_int)
else:
seed_keyarray = cast(KeyArray, seed)
self._engine_key = seed_keyarray
@property
def engine_seed(self) -> KeyArray:
"""The seed for the engine's pseudo-random number generation."""
return self._engine_key
[docs]
def add_kernel(self, kernel: Kernel):
"""Adds a :class:`~liesel.goose.Kernel`."""
self._kernels.append(kernel)
@property
def kernels(self) -> tuple[Kernel, ...]:
"""Tuple of all Kernels that are present in the builder."""
return tuple(self._kernels)
[docs]
def add_quantity_generator(self, generator: QuantityGenerator):
"""Adds a ``QuantityGenerator``."""
self._quantity_generators.append(generator)
@property
def quantity_generators(self) -> tuple[QuantityGenerator, ...]:
"""Tuple of all quantity generators present in the builder."""
return tuple(self._quantity_generators)
[docs]
def set_initial_values(self, model_state: ModelState, multiple_chains=False):
"""
Sets the initial model state.
If ``multiple_chains`` is true the
:attr:`~liesel.goose.EngineBuilder.model_state` will be used as is;
otherwise :attr:`~liesel.goose.EngineBuilder.model_state` will be used as the
initial values for each
chain. Note that if ``multiple_chains`` is true, the first axis of each
leaf of :attr:`~liesel.goose.EngineBuilder.model_state` refers to the chain.
"""
if not multiple_chains:
model_states = stack_leaves(model_state for _ in range(self._num_chains))
else:
model_states = model_state
self._model_state = Option(model_states)
[docs]
def set_jitter_fns(self, jitter_fns: Mapping[str, JitterFunction] | None):
"""
Set the jittering functions.
A jittering function is a function that takes as input a key and a value,
and applies a random jittering (noise) to the input value
based on the given key.
If no jitter function is provided a `Warning` will be raised and the
values won't be jittered.
Parameters
----------
jitter_fns
A mapping where a jittering function is assigned to each position key.
A shallow copy is stored. Later changes to the supplied mapping do not
change the builder's configuration; call this method again to update it.
Examples
--------
>>> import jax
>>> import jax.numpy as jnp
>>> import tensorflow_probability.substrates.jax.distributions as tfd
>>> key = jax.random.PRNGKey(42)
In this example, we show how to use the method
:meth:`EngineBuilder.set_jitter_fns <liesel.goose.EngineBuilder.set_jitter_fns>`
to apply jittering
to the initial values of each chain.
First, we sample 500 data points from a Normal Distrbution
with mean 2.0 and standard deviation 1.0.
>>> n = 500
>>> true_mu = 2.0
>>> true_sigma = 1.0
>>> x_vec = tfd.Normal(loc=true_mu, scale=true_sigma).sample((n,), key)
Then, we define the distribution we want to sample from, which is
parametrized by a single parameter `mu`.
>>> mu = lsl.Var.new_param(1.0, name="mu")
>>> x_dist = lsl.Dist(tfd.Normal, loc=true_mu, scale=true_sigma)
>>> x = lsl.Var(x_vec, dist=x_dist, name="x")
Now, we can create the model.
>>> model = lsl.Model([x])
Finally, we build the model with :class:`~liesel.goose.EngineBuilder`. We will
use 4
parallel chains and sample our varaible using a
:class:`~liesel.goose.NUTSKernel`.
>>> builder = gs.EngineBuilder(seed=1337, num_chains=4)
>>> builder.set_model(gs.LieselInterface(model))
>>> builder.set_initial_values(model.state)
>>> builder.add_kernel(gs.NUTSKernel(["mu"]))
A jitter function takes as input a key and value and applies random jittering
to the given value using the key. In this case, we apply a uniform noise with
a minimum value of -1.0 and maximum value of 1.0. Notice that the shape of `val`
is `(4, 1)`, where the first dimension corresponds to the number of chains.
>>> def jitter_fn(key, val):
... jitter = jax.random.uniform(key, val.shape, val.dtype, -1.0, 1.0)
... return val + jitter
The method takes as input a dictionary where a
jittering function is assigned to each position key.
>>> builder.set_jitter_fns({"mu": jitter_fn})
"""
self._jitter_fns = Option(dict(jitter_fns) if jitter_fns is not None else None)
@property
def jitter_fns(self) -> Option[JitterFunctions]:
"""Jittering functions."""
return self._jitter_fns
@property
def model_state(self) -> Option[ModelState]:
"""Model state."""
return self._model_state
[docs]
def set_epochs(self, epochs: Iterable[EpochConfig]):
"""Sets epochs."""
initial_values = [
_EpochConfig(EpochType.INITIAL_VALUES, duration=1, thinning=1)
]
self._epochs = EpochManager(initial_values + list(epochs))
[docs]
def append_epochs(self, epochs: Iterable[EpochConfig]):
"""Appends epochs."""
for epoch in epochs:
self._epochs.append(epoch)
[docs]
def add_adaptation(
self,
duration: int,
init: float = 0.1,
term: float = 0.2,
base: int = 25,
thinning: int = 1,
):
"""
Adds adaptation epochs, for tuning kernel hyperparameters.
This follows the Stan Development Team (Stan Reference Manual 2021, Chapter 15.2
[#stanmanual]_).
Note that the order in which you add epochs matters.
Adaptation epochs should usually come before all other epochs.
Parameters
----------
duration
The number of adaptation samples.
init
The share of samples in the *initial fast* adaptation epoch, relative to \
the total number of adaptation samples (if given as a float), or the total \
number of init samples (if given as an integer).
term
The share of samples in the *final fast* adaptation epoch, relative to the \
total number of adaptation samples (if given as a float), or the total \
number of term samples (if given as an integer).
base
The number of samples in the *first slow* adaptation epoch.
thinning
Thinning applied in each adaptation.
Warnings
--------
Kernels which rely on history tuning might not be able to deal with thinning \
during adaptation.
See Also
--------
.add_posterior : For adding posterior epochs.
.add_burnin : For adding burnin epochs.
.add_adaptation : For adding adaptation epochs.
.set_duration : An alternative method to specify adaptation and posterior
epochs, see notes for details.
Notes
-----
There are some differences in the details between the methods
:meth:`~liesel.goose.EngineBuilder.add_posterior`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`,
:meth:`~liesel.goose.EngineBuilder.add_burnin` and
:meth:`~liesel.goose.EngineBuilder.set_duration`.
- :meth:`~liesel.goose.EngineBuilder.set_duration` overwrites all existing
epochs with the epochs defined
in this method. In contrast, the ``add_`` methods only add additional epochs
that are appended to the potentially existing ones.
- :meth:`~liesel.goose.EngineBuilder.set_duration` sets both adaptation and
posterior epochs, while each
``add_`` method only adds epochs of one type.
- :meth:`~liesel.goose.EngineBuilder.set_duration` has an argument
``term_duration`` that defines the
number of samples drawn in the *final fast* adaptation epoch, and it defaults
to a fixed value of 50 samples. In contrast,
:meth:`~liesel.goose.EngineBuilder.add_adaptation` defines
the number of samples drawn in the *final fast* adaptation epoch as a fraction
of the total number of adaptation samples, and allows users to change this
fraction via the argument ``term``. As a result, if
:meth:`~liesel.goose.EngineBuilder.add_adaptation`
is used, the length of the final fast
adaptation phase increases automatically, if the total number of warmup
samples increases, while it remains constant if
:meth:`~liesel.goose.EngineBuilder.set_duration` is
used.
References
----------
.. [#stanmanual] https://mc-stan.org/docs/2_28/reference-manual/hmc-algorithm-parameters.html
"""
if duration < 20:
raise ValueError("duration too short (< 20)")
if isinstance(init, float):
init_samples = int(init * duration)
else:
init_samples = init
if isinstance(term, float):
term_samples = int(term * duration)
else:
term_samples = term
if duration < init_samples + term_samples + base:
raise ValueError(
"duration too short (< init_samples + term_samples + base)"
)
epochs = []
epochs.append(_EpochConfig(EpochType.FAST_ADAPTATION, init_samples, thinning))
time_left = duration - init_samples - term_samples
this_time = base
while 3 * this_time <= time_left:
epochs.append(_EpochConfig(EpochType.SLOW_ADAPTATION, this_time, thinning))
time_left -= this_time
this_time *= 2
epochs.append(_EpochConfig(EpochType.SLOW_ADAPTATION, time_left, thinning))
epochs.append(_EpochConfig(EpochType.FAST_ADAPTATION, term_samples, thinning))
self.append_epochs(epochs)
[docs]
def add_burnin(self, duration: int, thinning: int = 1):
"""
Adds burnin epochs.
Note that the order in which you add epochs matters.
Adaptation epochs should usually come before all other epochs.
Warnings
--------
If you use any MCMC kernel that expects hyperparameter tuning an adaptation
phase, like :class:`~liesel.goose.NUTSKernel`, :class:`~liesel.goose.HMCKernel`
or
our default implementation of :class:`~liesel.goose.IWLSKernel`, you *must* add
an adaptation phase via :meth:`~liesel.goose.EngineBuilder.add_adaptation`. Pure
burnin
epochs like the ones added with :meth:`~liesel.goose.EngineBuilder.add_burnin`
do not perform any
adaptation during warmup. They are only applicable if you *exclusively* use
MCMC kernels that do not require adaptation, such as
:class:`~liesel.goose.GibbsKernel`
or classic, untuned IWLS kernels via :meth:`IWLSKernel.untuned
<liesel.goose.IWLSKernel.untuned>`.
See Also
--------
.add_posterior : For adding posterior epochs.
.add_burnin : For adding burnin epochs.
.add_adaptation : For adding adaptation epochs.
.set_duration : An alternative method to specify adaptation and posterior
epochs, see notes for details.
Notes
-----
There are some differences in the details between the methods
:meth:`~liesel.goose.EngineBuilder.add_posterior`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`,
:meth:`~liesel.goose.EngineBuilder.add_burnin` and
:meth:`~liesel.goose.EngineBuilder.set_duration`.
- :meth:`~liesel.goose.EngineBuilder.set_duration` overwrites all existing
epochs with the epochs defined
in this method. In contrast, the ``add_`` methods only add additional epochs
that are appended to the potentially existing ones.
- :meth:`~liesel.goose.EngineBuilder.set_duration` sets both adaptation and
posterior epochs, while each
``add_`` method only adds epochs of one type.
- :meth:`~liesel.goose.EngineBuilder.set_duration` has an argument
``term_duration`` that defines the
number of samples drawn in the *final fast* adaptation epoch, and it defaults
to a fixed value of 50 samples. In contrast,
:meth:`~liesel.goose.EngineBuilder.add_adaptation` defines
the number of samples drawn in the *final fast* adaptation epoch as a fraction
of the total number of adaptation samples, and allows users to change this
fraction via the argument ``term``. As a result, if
:meth:`~liesel.goose.EngineBuilder.add_adaptation`
is used, the length of the final fast
adaptation phase increases automatically, if the total number of warmup
samples increases, while it remains constant if
:meth:`~liesel.goose.EngineBuilder.set_duration` is
used.
"""
epoch = _EpochConfig(EpochType.BURNIN, duration, thinning)
self.append_epochs([epoch])
[docs]
def add_posterior(self, duration: int, thinning: int = 1):
"""
Adds posterior epochs.
Note that the order in which you add epochs matters.
Adaptation epochs should usually come before all other epochs.
See Also
--------
.add_posterior : For adding posterior epochs.
.add_burnin : For adding burnin epochs.
.add_adaptation : For adding adaptation epochs.
.set_duration : An alternative method to specify adaptation and posterior
epochs, see notes for details.
Notes
-----
There are some differences in the details between the methods
:meth:`~liesel.goose.EngineBuilder.add_posterior`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`,
:meth:`~liesel.goose.EngineBuilder.add_burnin` and
:meth:`~liesel.goose.EngineBuilder.set_duration`.
- :meth:`~liesel.goose.EngineBuilder.set_duration` overwrites all existing
epochs with the epochs defined
in this method. In contrast, the ``add_`` methods only add additional epochs
that are appended to the potentially existing ones.
- :meth:`~liesel.goose.EngineBuilder.set_duration` sets both adaptation and
posterior epochs, while each
``add_`` method only adds epochs of one type.
- :meth:`~liesel.goose.EngineBuilder.set_duration` has an argument
``term_duration`` that defines the
number of samples drawn in the *final fast* adaptation epoch, and it defaults
to a fixed value of 50 samples. In contrast,
:meth:`~liesel.goose.EngineBuilder.add_adaptation` defines
the number of samples drawn in the *final fast* adaptation epoch as a fraction
of the total number of adaptation samples, and allows users to change this
fraction via the argument ``term``. As a result, if
:meth:`~liesel.goose.EngineBuilder.add_adaptation`
is used, the length of the final fast
adaptation phase increases automatically, if the total number of warmup
samples increases, while it remains constant if
:meth:`~liesel.goose.EngineBuilder.set_duration` is
used.
"""
epoch = _EpochConfig(EpochType.POSTERIOR, duration, thinning)
self.append_epochs([epoch])
[docs]
def set_duration_simple(
self,
burnin_duration: int = 10,
posterior_duration: int = 10,
thinning_posterior: int = 1,
):
"""
Defines a simple epoch configuration that does not include any adaptative
warmup. Includes only burnin and posterior samples.
Warnings
--------
The default values of samples to be drawn are extremely low and not intended
for practical use -- instead, they are intended for quickly checking whether
your model runs successfully.
If you use any MCMC kernel that expects hyperparameter tuning an adaptation
phase, like :class:`~liesel.goose.NUTSKernel`, :class:`~liesel.goose.HMCKernel`
or
our default implementation of :class:`~liesel.goose.IWLSKernel`, you *must* add
an adaptation phase via :meth:`~liesel.goose.EngineBuilder.add_adaptation`. Pure
burnin
epochs like the ones added with :meth:`~liesel.goose.EngineBuilder.add_burnin`
do not perform any
adaptation during warmup. They are only applicable if you *exclusively* use
MCMC kernels that do not require adaptation, such as
:class:`~liesel.goose.GibbsKernel`
or classic, untuned IWLS kernels via :meth:`IWLSKernel.untuned
<liesel.goose.IWLSKernel.untuned>`.
See Also
--------
.add_posterior : For adding posterior epochs.
.add_burnin : For adding burnin epochs.
.add_adaptation : For adding adaptation epochs.
.set_duration : An alternative method to specify adaptation and posterior
epochs, see notes for details.
Notes
-----
There are some differences in the details between the methods
:meth:`~liesel.goose.EngineBuilder.add_posterior`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`,
:meth:`~liesel.goose.EngineBuilder.add_burnin` and
:meth:`~liesel.goose.EngineBuilder.set_duration`.
- :meth:`~liesel.goose.EngineBuilder.set_duration` overwrites all existing
epochs with the epochs defined
in this method. In contrast, the ``add_`` methods only add additional epochs
that are appended to the potentially existing ones.
- :meth:`~liesel.goose.EngineBuilder.set_duration` sets both adaptation and
posterior epochs, while each
``add_`` method only adds epochs of one type.
- :meth:`~liesel.goose.EngineBuilder.set_duration` has an argument
``term_duration`` that defines the
number of samples drawn in the *final fast* adaptation epoch, and it defaults
to a fixed value of 50 samples. In contrast,
:meth:`~liesel.goose.EngineBuilder.add_adaptation` defines
the number of samples drawn in the *final fast* adaptation epoch as a fraction
of the total number of adaptation samples, and allows users to change this
fraction via the argument ``term``. As a result, if
:meth:`~liesel.goose.EngineBuilder.add_adaptation`
is used, the length of the final fast
adaptation phase increases automatically, if the total number of warmup
samples increases, while it remains constant if
:meth:`~liesel.goose.EngineBuilder.set_duration` is
used.
"""
epochs = []
epochs.append(_EpochConfig(EpochType.BURNIN, burnin_duration, 1))
epochs.append(
_EpochConfig(EpochType.POSTERIOR, posterior_duration, thinning_posterior)
)
self.set_epochs(epochs)
[docs]
def set_duration(
self,
warmup_duration: int,
posterior_duration: int,
term_duration: int = 50,
thinning_posterior: int = 1,
thinning_warmup: int = 1,
):
"""
Sets epochs using the ``liesel.goose.stan_epochs`` function.
Note that ``term_duration`` needs to be long enough that tuning algorithms
like dual averaging can converge.
See Also
--------
.add_posterior : For adding posterior epochs.
.add_burnin : For adding burnin epochs.
.add_adaptation : For adding adaptation epochs.
.set_duration : An alternative method to specify adaptation and posterior
epochs, see notes for details.
Notes
-----
There are some differences in the details between the methods
:meth:`~liesel.goose.EngineBuilder.add_posterior`,
:meth:`~liesel.goose.EngineBuilder.add_adaptation`,
:meth:`~liesel.goose.EngineBuilder.add_burnin` and
:meth:`~liesel.goose.EngineBuilder.set_duration`.
- :meth:`~liesel.goose.EngineBuilder.set_duration` overwrites all existing
epochs with the epochs defined
in this method. In contrast, the ``add_`` methods only add additional epochs
that are appended to the potentially existing ones.
- :meth:`~liesel.goose.EngineBuilder.set_duration` sets both adaptation and
posterior epochs, while each
``add_`` method only adds epochs of one type.
- :meth:`~liesel.goose.EngineBuilder.set_duration` has an argument
``term_duration`` that defines the
number of samples drawn in the *final fast* adaptation epoch, and it defaults
to a fixed value of 50 samples. In contrast,
:meth:`~liesel.goose.EngineBuilder.add_adaptation` defines
the number of samples drawn in the *final fast* adaptation epoch as a fraction
of the total number of adaptation samples, and allows users to change this
fraction via the argument ``term``. As a result, if
:meth:`~liesel.goose.EngineBuilder.add_adaptation`
is used, the length of the final fast
adaptation phase increases automatically, if the total number of warmup
samples increases, while it remains constant if
:meth:`~liesel.goose.EngineBuilder.set_duration` is
used.
"""
epochs = stan_epochs(
warmup_duration,
posterior_duration,
term_duration=term_duration,
thinning_posterior=thinning_posterior,
thinning_warmup=thinning_warmup,
)
self.set_epochs(epochs)
@property
def epochs(self) -> tuple[EpochConfig, ...]:
"""Tuple of epoch configurations."""
return tuple(self._epochs._configs)
[docs]
def set_model(self, model: ModelInterface):
"""Sets the model interface for all kernels and quantity generators."""
# avoid circular import
from liesel.model import Model as LieselModel
if isinstance(model, LieselModel):
raise TypeError(
f"{model=} is a `lsl.Model` instance. Please wrap it in a"
" `gs.LieselInterface`."
)
self._model = Option(model)
[docs]
def build(self) -> Engine:
"""Builds the MCMC engine with the provided setup."""
# build list of position keys
pos_keys: list[str] = []
for ker in self._kernels:
pos_keys.extend(ker.position_keys)
dupl = _find_duplicate(pos_keys)
if dupl.is_some():
raise RuntimeError(
f"The position key {dupl.unwrap()} is claimed by multiple kernels"
)
# find good jittable number
epochs = self._epochs._configs # FIXME: use of private field
durations = [e.duration for e in epochs[1:]]
jit_duration = math.gcd(*durations)
# seeds
seeds = self._engine_key
if seeds.shape == (2,):
# no multi-chain key
seeds = jax.random.split(seeds, self._num_chains)
if seeds.shape != (self._num_chains, 2):
raise RuntimeError(
f"MCMC seed has the wrong dimensions {seeds}. "
f"Expected is {(self._num_chains, 2)}"
)
# check for duplicated identifiers in self._quantity_generators
idents = []
for qg in self._quantity_generators:
idents.append(qg.identifier)
dupl = _find_duplicate(idents)
if dupl.is_some():
raise RuntimeError(
f"The identifier {dupl.unwrap()} is used by multiple "
"quantity generators"
)
# set model interface for all kernels and quantity generators
model = self._model.expect("Model interface must be set")
for ker in self.kernels:
if not ker.has_model():
ker.set_model(model)
for qg in self.quantity_generators:
if not qg.has_model():
qg.set_model(model)
# assign identifiers to kernels
for idx, ker in enumerate(self.kernels):
if not ker.identifier:
ker.identifier = f"kernel_{idx:02d}"
# set initial values
model_states = self._model_state.expect("Model state must be set")
if self._jitter_fns.is_some():
jitter_fns = self._jitter_fns.unwrap()
jitter_fns_pos_keys = list(jitter_fns.keys())
missing_keys = []
for pos_key in pos_keys:
if pos_key not in jitter_fns_pos_keys:
missing_keys.append(pos_key)
if missing_keys:
pretty_keys = str(missing_keys)[1:-1]
logger.warning(
f"No jitter functions provided for position keys {pretty_keys}. "
"The initial values for these keys won't be jittered"
)
jitter_keys = jax.random.split(self._jitter_key, len(jitter_fns_pos_keys))
current_position = model.extract_position(jitter_fns_pos_keys, model_states)
jittered_position = Position({})
for i, pos_key in enumerate(jitter_fns_pos_keys):
jittered_position[pos_key] = jax.vmap(jitter_fns[pos_key])(
jax.random.split(jitter_keys[i], self._num_chains),
current_position[pos_key],
)
model_states = jax.vmap(model.update_state)(jittered_position, model_states)
else:
logger.warning(
"No jitter functions provided. The initial values won't be jittered"
)
# extending position keys
pos_keys.extend(self.positions_included)
pos_keys = [key for key in pos_keys if key not in self.positions_excluded]
return Engine(
seeds=seeds,
model_states=model_states,
kernel_sequence=KernelSequence(self.kernels),
epoch_configs=epochs,
jitted_sample_duration=jit_duration,
model=model,
position_keys=pos_keys,
minimize_transition_infos=self.minimize_transition_infos,
store_kernel_states=self.store_kernel_states,
quantity_generators=self.quantity_generators,
show_progress=self.show_progress,
)