Source code for smolgp.solvers.kalman

from __future__ import annotations

import jax
import jax.numpy as jnp
from tinygp.helpers import JAXArray

from smolgp.helpers import kalman_gain, transition_sequence
from smolgp.solvers.base import log_prob_from_v_S


[docs] def KalmanFilter(kernel, X, y, R, return_v_S=False): """ Wrapper for jitted kalman_filter function Parameters: kernel: StateSpaceModel kernel X: data coordinates, e.g. time or (time, texp, instid) y: observations, shape (N, D) R: observation noise covariance, shape (N, D, D) Returns: m_filtered: filtered means P_filtered: filtered covariances m_predicted: predicted means P_predicted: predicted covariances """ H = kernel.observation_model A = kernel.transition_matrix Q = kernel.process_noise m0 = jnp.zeros(kernel.dimension) P0 = kernel.stationary_covariance() if not isinstance(P0, JAXArray): P0 = P0.to_dense() # needed for carry in jax.lax.scan t = kernel.coord_to_sortable(X) H_all = jax.vmap(H)(X) output = kalman_filter(A, Q, H_all, R, t, y, m0, P0) if return_v_S: return output else: m_filtered, P_filtered, m_predicted, P_predicted, v, S = output return m_filtered, P_filtered, m_predicted, P_predicted
[docs] @jax.jit def kalman_filter(A, Q, H_all, R, t, y, m0, P0): """ Jax implementation of the Kalman filter algorithm See Theorem 4.2 (pdf page 77) in "Bayesian Filtering and Smoothing" by Simo S{\"a}rkk{\"a} for detailed description of the algorithm and notation. e.g. _prev is _{k-1} _pred is _k^{-} Total runtime complexity is O(N*d^3) where N is the number of time steps and d is the dimension of the state vector. """ A_all, Q_all = transition_sequence(A, Q, t) def step(carry, data): """ Routine for a single step of the Kalman filter Parameters: carry: (x_prev, P_prev) - previous state and covariance data: (A_prev, Q_prev, H_k, R_k, y_k) for the current time step Returns: - Conditioned state (m_k) and covariance (P_k) to carry to next iteration - Full output for completed scan (m_k, P_k, m_pred, P_pred) """ # Unpack previous state and covariance m_prev, P_prev = carry A_prev, Q_prev, H_k, R_k, y_k = data # Predict (Eq. 4.20) m_pred = A_prev @ m_prev P_pred = A_prev @ P_prev @ A_prev.T + Q_prev # Update (Eq. 4.21) y_pred = H_k @ m_pred # predicted observation v_k = y_k - y_pred # "innovation" or "surprise" term S_k = H_k @ P_pred @ H_k.T + R_k # uncertainy in predicted observation K_k = kalman_gain(S_k, P_pred @ H_k.T) # Kalman gain m_k = m_pred + K_k @ v_k # conditioned state estimate P_k = P_pred - K_k @ S_k @ K_k.T # conditioned covariance estimate return (m_k, P_k), (m_k, P_k, m_pred, P_pred, v_k, S_k) # Initialize carry with prior state and covariance init_carry = (m0, P0) # Run the filter over all time steps, unpack, and return results _, outputs = jax.lax.scan(step, init_carry, (A_all, Q_all, H_all, R, y)) return outputs
[docs] def KalmanLoglike(kernel, X, y, R): """ Wrapper for the jitted kalman_loglike function Same arguments as :func:`KalmanFilter`, but returns just the marginal log likelihood. """ A = kernel.transition_matrix Q = kernel.process_noise m0 = jnp.zeros(kernel.dimension) P0 = kernel.stationary_covariance() if not isinstance(P0, JAXArray): P0 = P0.to_dense() # needed for carry in jax.lax.scan t = kernel.coord_to_sortable(X) H_all = jax.vmap(kernel.observation_model)(X) # see KalmanFilter's note return kalman_loglike(A, Q, H_all, R, t, y, m0, P0)
[docs] @jax.jit def kalman_loglike(A, Q, H_all, R, t, y, m0, P0): r""" Optimized function to return just the marginal log likelihood. The performance boost here comes from three optimisations: 1. Replace `solve` with a simple division in :func:`~smolgp.helpers.kalman_gain` when `D==1`. 2. Prebuilds A(Δ) and Q(Δ) with vmap instead of computing them on-the-fly inside the scan. 3. Splitting: separate scans for the y-independant covariance and the y-dependent mean, so each carries one array instead of (m, P). #3 is only relevant for `d == 2` kernels (with nontrivial Q). At that size, the covariance half of the scan fits just under a code-generation threshold, so splitting it from the mean scan (which is always small) is a ~10x speedup as both scans compile into short instructions whereas carrying both is over the threshold and compiles to ~7x more instructions per step. d == 1 fits as a single scan, so splitting is actually marginally slower (10%), and d > 2 is over the threshold regardless, so splitting is irrelevant. """ D = R.shape[-1] A_all, Q_all = transition_sequence(A, Q, t) def factor(carry, data): """Pass 1: the y-independent covariance recursion, accumulating the log-determinant.""" P_prev, logdet = carry A_prev, Q_prev, H_k, R_k = data # Predict (Eq. 4.20) P_pred = A_prev @ P_prev @ A_prev.T + Q_prev # Update (Eq. 4.21), covariance path only PHt = P_pred @ H_k.T S_k = H_k @ PHt + R_k K_k = kalman_gain(S_k, PHt) # K S K^T = K (P H^T)^T P_k = P_pred - K_k @ PHt.T # For D == 1 the log-determinant is one log, so accumulate it here # rather than materializing it. For D > 1 it comes out of the batched # Cholesky in log_prob_from_v_S instead. if D == 1: logdet = logdet + jnp.log(S_k[0, 0]) return (P_k, logdet), (K_k, S_k) (_, logdet), (K_all, S_all) = jax.lax.scan( factor, (P0, jnp.zeros((), dtype=jnp.result_type(P0))), (A_all, Q_all, H_all, R) ) if D == 1: def solve(carry, data): """Pass 2: the y-dependent mean recursion, accumulating the quadratic form.""" m_prev, quad = carry A_prev, H_k, K_k, S_k, y_k = data m_pred = A_prev @ m_prev v_k = y_k - H_k @ m_pred m_k = m_pred + K_k @ v_k return (m_k, quad + v_k[0] * v_k[0] / S_k[0, 0]), None (_, quad), _ = jax.lax.scan( solve, (m0, jnp.zeros((), dtype=jnp.result_type(P0))), (A_all, H_all, K_all, S_all, y), ) # The D*log(2*pi) term is per-step but constant, so it is summed once here. loglike = -0.5 * (quad + logdet + len(t) * jnp.log(2.0 * jnp.pi)) return jnp.where(jnp.isfinite(loglike), loglike, -jnp.inf) def solve(carry, data): """Pass 2 for D > 1: emit the innovations for one batched reduction.""" m_prev = carry A_prev, H_k, K_k, y_k = data m_pred = A_prev @ m_prev v_k = y_k - H_k @ m_pred return m_pred + K_k @ v_k, v_k _, v_all = jax.lax.scan(solve, m0, (A_all, H_all, K_all, y)) return log_prob_from_v_S(v_all, S_all)
[docs] @jax.jit def kalman_gains(A, H_all, R, t, P_predicted): """ The `y`-independent per-step quantities needed to replay kalman_filter's mean-path recursion for many different observation vectors, given P_predicted from ONE prior call to kalman_filter (any `y` -- P_predicted never depends on it). Pointwise in k (jax.vmap, no scan needed): A_k, H_k, and the Kalman gain K_k (given P_predicted[k]) don't depend on any other step. Args: H_all: shape (N, D, dim) -- the observation model already evaluated over the full coordinates (``jax.vmap(kernel.observation_model)(X)``), NOT the bare function; see KalmanFilter's own note on why. Returns: A_all: shape (N, dim, dim) -- A(0, Delta_k) H_all: shape (N, D, dim) -- passed through unchanged K_all: shape (N, dim, D) -- Kalman gain at step k """ N = len(t) def gains_at_k(k, P_pred_k): Delta = jax.lax.cond(k > 0, lambda i: t[i] - t[i - 1], lambda _: 0.0, k) A_k = A(0, Delta) H_k = H_all[k] S_k = H_k @ P_pred_k @ H_k.T + R[k] K_k = kalman_gain(S_k, P_pred_k @ H_k.T) return A_k, H_k, K_k return jax.vmap(gains_at_k)(jnp.arange(N), P_predicted)
[docs] @jax.jit def kalman_filter_batched_mean(A_all, H_all, K_all, y_batch, m0): """ Batched-mean-path replay of kalman_filter's forward recursion, given PRECOMPUTED, `y`-independent gains (kalman_gains). Processes M observation/residual batches in a single jax.lax.scan. Runtime: O(N * M * dim * D), vs. O(M * N * dim^3) for M independent calls to kalman_filter. Parameters: A_all: (N, dim, dim), H_all: (N, D, dim), K_all: (N, dim, D) -- from kalman_gains y_batch: (M, N, D) -- batch of M observation/residual arrays m0: (dim,) -- prior mean, shared/broadcast across the batch Returns: m_filtered_batch: (M, N, dim) m_predicted_batch: (M, N, dim) """ N = A_all.shape[0] M = y_batch.shape[0] dim = m0.shape[0] def step(carry, k): m_prev_batch = carry # (M, dim) A_k, H_k, K_k = A_all[k], H_all[k], K_all[k] m_pred_batch = jnp.einsum("ij,mj->mi", A_k, m_prev_batch) y_pred_batch = jnp.einsum("di,mi->md", H_k, m_pred_batch) v_batch = y_batch[:, k, :] - y_pred_batch m_k_batch = m_pred_batch + jnp.einsum("id,md->mi", K_k, v_batch) return m_k_batch, (m_k_batch, m_pred_batch) init_carry = jnp.broadcast_to(m0, (M, dim)) _, (m_filtered_T, m_predicted_T) = jax.lax.scan(step, init_carry, jnp.arange(N)) return jnp.moveaxis(m_filtered_T, 0, 1), jnp.moveaxis(m_predicted_T, 0, 1)