Source code for smolgp.solvers.state_coords
from __future__ import annotations
import equinox as eqx
import jax.numpy as jnp
from tinygp.helpers import JAXArray
[docs]
class StateCoords(eqx.Module):
r"""The state-level bookkeeping shared by every solver, filter, smoother,
and sampler in ``smolgp``.
A state space model is solved by stepping through a sorted timeline of
*states*. For an instantaneous kernel there is exactly one state per
observation, but for an integrated (exposure-averaged) kernel each
observation contributes *two* states: the start and end of its exposure
window. Therefore, the number of states ``K`` and the number of observations
``N`` are different, and the mapping between them has to be carried
around explicitly. Additionally, the integrated solver needs to know for
a given state what observation it belongs to and whether that state was
an exposure start or end, as these are handled differently.
That mapping is what this object holds.
Attributes:
t_states: shape ``(K,)``. The sortable coordinate (e.g. time) of each
state, in ascending order. Ties are broken so that exposure *ends*
(``stateid==1``) precede exposure *starts* (``stateid==0``) at the
same instant, via a ``jnp.lexsort((-stateid, t_states))``.
instid: shape ``(N,)`` **per observation, not per state**. Which
instrument recorded each observation. Index it through ``obsid``
(or use :meth:`instid_per_state`) to get a state's instrument.
obsid: shape ``(K,)``. Which observation ``0..N-1`` each state belongs
to. For an integrated kernel, the start and end states of one
exposure share an ``obsid``.
stateid: shape ``(K,)``. ``0`` for an exposure-start state (where
:meth:`~smolgp.kernels.base.StateSpaceModel.reset_matrix` is
applied), ``1`` for an exposure-end state or a plain instantaneous
observation (i.e. the states that carry data).
For an instantaneous kernel the "integrated" fields degenerate to a trivial
convention: ``K == N``, ``obsid == arange(N)``, ``stateid == 1``
everywhere (every state carries data). A single instrument dataset likewise
trivially has ``instid == 0`` everywhere.
"""
t_states: JAXArray
instid: JAXArray
obsid: JAXArray
stateid: JAXArray
[docs]
@classmethod
def instantaneous(cls, t_states: JAXArray, *, sort: bool = True) -> StateCoords:
"""The degenerate ``StateCoords`` for an instantaneous kernel: one
state per observation, all carrying data, all on one instrument.
The state timeline is sorted (every solver steps forward in time, and
:meth:`predict`'s ``searchsorted`` bracketing assumes it), with
``obsid`` recording which observation each state came from -- the same
role it plays for an integrated kernel, so the usual
``sort-by-obsid`` machinery (:meth:`instid_per_state`,
:meth:`~smolgp.gp.ConditionedStates.project_at_data`,
:func:`~smolgp.solvers.sample.data_order_indices`) maps results back
to input order unchanged. Already-sorted input yields the identity
permutation, so this is a no-op in the common case.
The sort is *stable*, so observations sharing a timestamp keep their
input order relative to each other.
Args:
t_states: the sortable coordinate of each observation, in input
order (not necessarily sorted).
sort: set ``False`` only if ``t_states`` is known to be sorted and
the identity ``obsid`` is wanted verbatim.
"""
K = t_states.shape[0]
order = (
jnp.argsort(t_states, stable=True) if sort else jnp.arange(K, dtype=int)
)
return cls(
t_states=t_states[order],
instid=jnp.zeros(K, dtype=int),
obsid=order,
stateid=jnp.ones(K, dtype=int),
)
@property
def num_states(self) -> int:
"""``K``, the number of states in the timeline."""
return self.t_states.shape[0]
@property
def num_obs(self) -> int:
"""``N``, the number of observations."""
return self.instid.shape[0]
[docs]
def instid_per_state(self) -> JAXArray:
"""``instid`` gathered to shape ``(K,)``: the instrument id of the
observation each state belongs to."""
return self.instid[self.obsid]