Source code for smolgp.solvers.integrated.solver

from __future__ import annotations

from typing import Any

import equinox as eqx
import jax
import jax.numpy as jnp
from tinygp.helpers import JAXArray
from tinygp.solvers.quasisep.solver import QuasisepSolver

from smolgp.helpers import smoothing_gain
from smolgp.kernels.base import StateSpaceModel
from smolgp.solvers.base import Solver
from smolgp.solvers.integrated.kalman import (
    IntegratedKalmanFilter,
    integrated_kalman_filter_batched_mean,
    integrated_kalman_gains,
)
from smolgp.solvers.integrated.predict_exposure import predict_exposure
from smolgp.solvers.integrated.rts import (
    IntegratedRTSSmoother,
    integrated_rts_gains,
    integrated_rts_smoother_batched_mean,
)
from smolgp.solvers.state_coords import StateCoords


[docs] class IntegratedStateSpaceSolver(Solver): """ A solver that uses ``jax.lax.scan`` to implement Kalman filtering and RTS smoothing for integrated measurements """ def __init__( self, kernel: StateSpaceModel, X: JAXArray, noise: JAXArray, ): """Build a :class:`IntegratedStateSpaceSolver` for a given kernel and coordinates Args: kernel: The kernel function. X: The input coordinates. The coordinates for an integrated model should be a tuple of X = (t, delta, instid), where `t` is the usual coordinate (e.g. time) at the measurements (midpoints), `delta` is the integration range (e.g. exposure time) for each measurement, and `instid` is an index encoding which instrument the measurement corresponds to. noise: Observation noise covariance array of shape ``(N, D, D)``. state_coords: Bookkeeping indices for the discretized states used in Kalman/RTS """ self.kernel = kernel self.X = X self.noise = noise ## Preprocess state coordinates (exposure start/stops) ## and assign labels to each observation/state for bookkeeping: ## obsid -- array len(K): which observation (0,...,N-1) is being made at each state k ## instids -- array len(N): which instrument (0,...,Ninst-1) recorded observation n ## stateid -- array len(K): 0 for exposure-start, 1 for exposure-end tmid, delta, instid = self.X # unpack coordinates ## Construct interleaved time array of chronological exposure start/stop times ts = tmid - delta / 2 # Exposure start times te = tmid + delta / 2 # Exposure end times obsid = jnp.arange(len(tmid)).repeat(2) # Interleave start and end times into one array (fastest) # https://stackoverflow.com/questions/5347065/interleaving-two-numpy-arrays-efficiently t_states = jnp.empty((ts.size + te.size,), dtype=tmid.dtype) t_states = t_states.at[0::2].set(ts) # evens are start times t_states = t_states.at[1::2].set(te) # odds are end times stateid = jnp.tile(jnp.array([0, 1]), len(tmid)) # 0 for start, 1 for end # Have to re-sort because exposures can overlap # enforce end times before start times at same t sortidx = jnp.lexsort((-stateid, t_states)) t_states = t_states[sortidx] obsid = obsid[sortidx] stateid = stateid[sortidx] # 0 for t_s, 1 for t_e # Pack-up state_coords for Kalman and RTS functions. Note instid stays # per-*observation* (length N) while the others are per-*state* # (length K=2N) -- see StateCoords' own docstring. self.state_coords = StateCoords( t_states=t_states, instid=instid, obsid=obsid, stateid=stateid )
[docs] def Kalman(self, y, return_v_S=False) -> Any: """Wrapper for Kalman filter used with this solver""" sc = self.state_coords # noise (N, D, D) → R (N, D, D); y (..., N) → (N, D) y_nd = y[:, None] if y.ndim == 1 else y return IntegratedKalmanFilter( self.kernel, self.X, y_nd, sc.t_states, sc.obsid, sc.instid, sc.stateid, self.noise, return_v_S=return_v_S, )
[docs] def RTS(self, kalman_results) -> Any: """Wrapper for RTS smoother used with this solver""" sc = self.state_coords return IntegratedRTSSmoother( self.kernel, sc.t_states, sc.obsid, sc.instid, sc.stateid, kalman_results )
[docs] def smoothing_gains(self, P_filtered, P_predicted) -> JAXArray: """The RTS smoothing gains G_k; see :meth:`smolgp.solvers.solver.StateSpaceSolver.smoothing_gains`. Additionally threads the exposure reset bookkeeping, which the integrated smoother applies at every ``stateid == 0`` state. """ sc = self.state_coords return integrated_rts_gains( self.kernel.transition_matrix, self.kernel.reset_matrix, sc.t_states, sc.obsid, sc.instid, sc.stateid, P_filtered, P_predicted, )
[docs] def condition_batched_mean(self, y_batch: JAXArray) -> JAXArray: """ Batched-mean-path variant of condition() for M residual/observation arrays sharing this solver's (kernel, X, noise). See StateSpaceSolver.condition_batched_mean for the general idea; this additionally threads the reset-matrix bookkeeping through integrated_kalman_gains/integrated_kalman_filter_batched_mean. Args: y_batch: shape (M, N) or (M, N, D) Returns: m_smoothed_batch: shape (M, K, dim) """ y_batch_nd = y_batch[:, :, None] if y_batch.ndim == 2 else y_batch _M, N, D = y_batch_nd.shape sc = self.state_coords t_states, instid, obsid, stateid = ( sc.t_states, sc.instid, sc.obsid, sc.stateid, ) # 1. Covariance path, ONCE (dummy y; only P_filtered/P_predicted are used). y_dummy = jnp.zeros((N, D)) _m_f, P_filtered, _m_p, P_predicted = self.Kalman(y_dummy) # 2. y-independent gains, ONCE. A_aug = self.kernel.transition_matrix H_aug = self.kernel.observation_model RESET = self.kernel.reset_matrix A_all, H_all, K_all, Reset_all = integrated_kalman_gains( A_aug, H_aug, RESET, self.noise, self.X, t_states, obsid, instid, stateid, P_predicted, ) G_all = integrated_rts_gains( A_aug, RESET, t_states, obsid, instid, stateid, P_filtered, P_predicted ) # 3. Batched mean-path recursion, O(M) cheap. m0 = jnp.zeros(self.kernel.dimension) m_filtered_batch, m_predicted_batch = integrated_kalman_filter_batched_mean( A_all, H_all, K_all, Reset_all, obsid, stateid, y_batch_nd, m0 ) return integrated_rts_smoother_batched_mean( G_all, m_filtered_batch, m_predicted_batch, stateid )
[docs] @jax.jit def predict(self, X_test, conditioned_results, y=None) -> JAXArray: """ Algorithm for making predictions at arbitrary coordinates X_test Args: X_test : The test coordinates. If a tuple ``(t, delta, instid)`` with any ``delta > 0`` *and* ``y`` is also given, those test points are predicted as exposure-integrated averages (see :func:`~smolgp.solvers.integrated.predict_exposure.predict_exposure`) rather than instantaneous values. conditioned_results : The output of self.condition() y : The training data used to condition this solver. Only needed for ``delta>0`` test points. Returns: pred_mean : Predicted means of the states at X_test pred_var : Predicted variances of the states at X_test There are three cases for each test point: 1. Retrodiction : smoothing from the first data point using the prior as the prediction 2. Interpolation : filtering from most recent data point and smoothing from next future point 3. Extrapolation : predicting from final filtered point """ # Unpack conditioned results state_coords, conditioned_states, _ = conditioned_results ( (m_predicted, P_predicted), (m_filtered, P_filtered), (m_smoothed, P_smoothed), ) = conditioned_states t_states = state_coords.t_states # Unpack test coordinates t_test = self.kernel.coord_to_sortable(X_test) if isinstance(X_test, tuple): _, delta_test, instid_test = X_test else: delta_test, instid_test = None, None # Array shapes # N = len(self.X) # number of data points K = len(t_states) # number of states M = len(t_test) # number of test points # Prior covariance for retrodiction Pinf = self.kernel.stationary_covariance() if not isinstance(Pinf, JAXArray): # if multicomponent model Pinf = Pinf.to_dense() # needs to be array form here # Prior mean for retrodiction # mean = jnp.zeros(self.kernel.d) # TODO: mean function of base kernel # m0 = jnp.block([mean] + self.kernel.num_insts * [jnp.zeros(self.kernel.d)]) m0 = jnp.zeros(self.kernel.dimension) # Nearest/next past/future state for each datapoint k_nexts = jnp.searchsorted(t_states, t_test, side="right") # Method to use for test point past = k_nexts <= 0 # Retrodict future = k_nexts >= K # Extrapolate during = ~past & ~future # Interpolate cases = past.astype(int) * 0 + during.astype(int) * 1 + future.astype(int) * 2 # Shorthand for matrices A_aug = lambda dt: self.kernel.transition_matrix(0, dt) Q_aug = lambda dt: self.kernel.process_noise(0, dt) def kalman(k_prev, ktest): """ Kalman prediction from most recent filtered (but not RTS smoothed) state """ dt = t_test[ktest] - t_states[k_prev] m_k = m_filtered[k_prev] P_k = P_filtered[k_prev] A_star = A_aug(dt) Q_star = Q_aug(dt) m_star_pred = A_star @ m_k P_star_pred = A_star @ P_k @ A_star.T + Q_star return m_star_pred, P_star_pred def smooth(k_next, ktest, m_star_pred, P_star_pred): """ RTS smooth the prediction (ktest) using the nearest future data point (k_next) m_star_pred and P_star_pred are the output of kalman(k, k_star) """ # Next (future) predicted & smoothed state m_pred_next = m_predicted[k_next] P_pred_next = P_predicted[k_next] m_hat_next = m_smoothed[k_next] P_hat_next = P_smoothed[k_next] # Transition matrix dt = t_states[k_next] - t_test[ktest] A_k = A_aug(dt) # RTS update G_k = smoothing_gain(P_pred_next, P_star_pred @ A_k.T) m_star_hat = m_star_pred + G_k @ (m_hat_next - m_pred_next) P_star_hat = P_star_pred + G_k @ (P_hat_next - P_pred_next) @ G_k.T return m_star_hat, P_star_hat def retrodict(ktest): """Reverse-extrapolate from first datapoint t_star""" m_star, P_star = smooth(0, ktest, m0, Pinf) return m_star, P_star def interpolate(ktest): """Interpolate between nearest data points""" # Get nearest data point before and after the test point k_next = k_nexts[ktest] k_prev = k_next - 1 # 1. Kalman predict from most recent data point (in past) m_star_pred, P_star_pred = kalman(k_prev, ktest) # 2. RTS smooth from next nearest data point (in future) m_star_hat, P_star_hat = smooth(k_next, ktest, m_star_pred, P_star_pred) return m_star_hat, P_star_hat def extrapolate(ktest): """Kalman predict from from last datapoint t_star""" m_star, P_star = kalman(-1, ktest) return m_star, P_star def predict_instantaneous(ktest): """ Switch between retrodiction, interpolation, and extrapolation for a single instantaneous test point ktest """ return jax.lax.switch( cases[ktest], (retrodict, interpolate, extrapolate), (ktest) ) if delta_test is not None and y is not None: # Exposure-integrated prediction requires a non-zero exposure time (delta), # an instrument id (instid), and the training data (y). # If any of these are missing, fall back to instantaneous prediction. def predict_point(ktest): return jax.lax.cond( delta_test[ktest] > 0, lambda kt: predict_exposure( self.kernel, self.X, y, self.noise, state_coords, conditioned_states, t_test[kt], delta_test[kt], instid_test[kt], ), predict_instantaneous, ktest, ) else: predict_point = predict_instantaneous # Calculate predictions ktests = jnp.arange(0, M, 1) (pred_mean, pred_var) = jax.vmap(predict_point)(ktests) return pred_mean, pred_var