"""
GPU-accelerated Extended Kalman Filter.
This module provides GPU-accelerated implementations of the Extended Kalman
Filter (EKF) for batch processing of multiple tracks with nonlinear dynamics.
The EKF handles nonlinear systems by linearizing around the current estimate:
x_k = f(x_{k-1}) + w (nonlinear dynamics)
z_k = h(x_k) + v (nonlinear measurement)
Key Features
------------
- Batch processing of multiple tracks with same or different dynamics
- Support for user-provided Jacobian functions
- Numerical Jacobian computation when analytic unavailable
- Runs on either GPU backend (CuPy on CUDA, MLX on Apple Silicon) through
:func:`pytcl.gpu._backend.get_compute_backend`
Backends and Precision
----------------------
The linear-algebra work is written against the backend-neutral operation
surface in :mod:`pytcl.gpu._backend`. CuPy computes in float64; MLX computes
in float32 (float64 is unsupported on the MLX GPU stream), so results on
Apple Silicon are precision-limited to roughly 1e-7 relative error (float32
epsilon; measured 4.7e-8 to 8.4e-7 across predict/update outputs -- see
:mod:`tests.unit.test_gpu_mlx_ekf`'s module docstring for the per-output
table) against the CPU reference in
:mod:`pytcl.dynamic_estimation.kalman.extended`.
The user-supplied ``f``, ``h``, and Jacobian callables receive the whole batch
as a single device array of the active backend and are called once, not once
per track. This is the contract shared by every filter in :mod:`pytcl.gpu`:
- ``f(x)`` and ``h(x)`` take ``(N, state_dim)`` and return ``(N, out_dim)``;
- ``F_jacobian(x)`` and ``H_jacobian(x)`` take ``(N, state_dim)`` and return
``(N, out_dim, state_dim)``.
Write them against :func:`pytcl.gpu.utils.get_array_module` rather than NumPy
directly, so the same callable runs on either backend. A callable that mixes a
host NumPy array into the expression raises ``TypeError`` on CuPy.
Examples
--------
>>> from pytcl.gpu.ekf import batch_ekf_predict, batch_ekf_update
>>> import numpy as np
>>>
>>> from pytcl.gpu.utils import get_array_module
>>>
>>> # Batched: x is (N, 2), and the result is (N, 2)
>>> def f_dynamics(x):
... xp = get_array_module(x)
... return xp.stack([x[:, 0] + x[:, 1], x[:, 1] * 0.99], axis=1)
>>>
>>> # Batched Jacobian: (N, 2, 2), constant here so broadcast it
>>> def F_jacobian(x):
... xp = get_array_module(x)
... return xp.broadcast_to(
... xp.array([[1.0, 1.0], [0.0, 0.99]]), (x.shape[0], 2, 2)
... )
>>>
>>> # Batch prediction over three tracks with a 2-D state
>>> x = np.zeros((3, 2))
>>> P = np.stack([np.eye(2)] * 3)
>>> Q = np.stack([np.eye(2) * 0.01] * 3)
>>> x_pred, P_pred = batch_ekf_predict(x, P, f_dynamics, F_jacobian, Q)
>>> x_pred.shape
(3, 2)
See Also
--------
pytcl.gpu._backend : Backend dispatch layer (CuPy / MLX).
pytcl.dynamic_estimation.kalman.extended : CPU reference implementation.
"""
from typing import Any, Callable, NamedTuple, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
from pytcl.gpu._backend import get_compute_backend
class BatchEKFPrediction(NamedTuple):
"""Result of batch EKF prediction.
Attributes
----------
x : ndarray
Predicted state estimates, shape (n_tracks, state_dim).
P : ndarray
Predicted covariances, shape (n_tracks, state_dim, state_dim).
"""
x: NDArray[np.floating]
P: NDArray[np.floating]
class BatchEKFUpdate(NamedTuple):
"""Result of batch EKF update.
Attributes
----------
x : ndarray
Updated state estimates.
P : ndarray
Updated covariances.
y : ndarray
Innovations.
S : ndarray
Innovation covariances.
K : ndarray
Kalman gains.
likelihood : ndarray
Measurement likelihoods.
"""
x: NDArray[np.floating]
P: NDArray[np.floating]
y: NDArray[np.floating]
S: NDArray[np.floating]
K: NDArray[np.floating]
likelihood: NDArray[np.floating]
def _compute_numerical_jacobian(
f: Callable[[Any], Any],
x: Any,
eps: Optional[float] = None,
) -> Any:
"""
Central-difference Jacobian of a batched callback.
Parameters
----------
f : callable
Maps ``(N, n)`` to ``(N, m)`` on the active backend.
x : array
Evaluation points, shape ``(N, n)``, on the active backend.
eps : float, optional
Finite-difference step. Defaults to a value matched to the backend's
precision: a float32 backend cannot resolve the 1e-7 step that is
right for float64, and using it there returns noise rather than a
derivative.
Returns
-------
J : array
Jacobians, shape ``(N, m, n)``, on the active backend.
Notes
-----
One pair of evaluations per input dimension for the whole batch, rather
than per item: ``2 * n`` calls instead of ``2 * N * n``.
"""
b = get_compute_backend()
if eps is None:
eps = 1e-7 if b.supports_float64 else 1e-3
x = b.asarray(x)
n = x.shape[1]
basis = b.eye(n)
columns = []
for i in range(n):
step = basis[i] * eps
f_plus = b.asarray(f(x + step))
f_minus = b.asarray(f(x - step))
columns.append((f_plus - f_minus) / (2 * eps))
return b.stack(columns, axis=-1)
[docs]
def batch_ekf_predict(
x: ArrayLike,
P: ArrayLike,
f: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
F_jacobian: Optional[
Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
],
Q: ArrayLike,
) -> BatchEKFPrediction:
"""
Batch EKF prediction for multiple tracks.
Parameters
----------
x : array_like
Current state estimates, shape (n_tracks, state_dim).
P : array_like
Current covariances, shape (n_tracks, state_dim, state_dim).
f : callable
Batched dynamics. Takes the whole ``(n_tracks, state_dim)`` device
array and returns ``(n_tracks, state_dim)``. Called once.
F_jacobian : callable or None
Batched Jacobian df/dx. Takes ``(n_tracks, state_dim)`` and returns
``(n_tracks, state_dim, state_dim)``. If None, computed numerically
with ``2 * state_dim`` evaluations of ``f`` over the whole batch.
Q : array_like
Process noise covariance, shape (state_dim, state_dim)
or (n_tracks, state_dim, state_dim).
Returns
-------
result : BatchEKFPrediction
Predicted states and covariances, as device arrays of the active
backend. Use :func:`pytcl.gpu.utils.to_cpu` to bring them back.
Raises
------
DependencyError
If neither CuPy nor MLX is installed.
Examples
--------
>>> import numpy as np
>>> from pytcl.gpu.ekf import batch_ekf_predict
>>> from pytcl.gpu.utils import get_array_module
>>> # Coordinated turn, evaluated for the whole batch at once
>>> def f_turn(x):
... xp = get_array_module(x)
... w = 0.01
... return xp.stack([x[:, 0] + xp.cos(w) * x[:, 2],
... x[:, 1] + xp.sin(w) * x[:, 3],
... x[:, 2], x[:, 3]], axis=1)
>>> def F_jacobian(x):
... xp = get_array_module(x)
... w = 0.01
... J = xp.array([[1.0, 0.0, xp.cos(w).item(), 0.0],
... [0.0, 1.0, xp.sin(w).item(), 0.0],
... [0.0, 0.0, 1.0, 0.0],
... [0.0, 0.0, 0.0, 1.0]])
... return xp.broadcast_to(J, (x.shape[0], 4, 4))
>>> n_tracks = 30
>>> x = np.random.randn(n_tracks, 4) * 0.1
>>> P = np.tile(np.eye(4) * 0.01, (n_tracks, 1, 1))
>>> Q = np.eye(4) * 0.001
>>> result = batch_ekf_predict(x, P, f_turn, F_jacobian, Q)
>>> result.x.shape
(30, 4)
Notes
-----
Both the dynamics and the covariance propagation stay on the device. The
callback is invoked once for the batch rather than once per track.
"""
b = get_compute_backend()
x_gpu = b.asarray(x)
P_gpu = b.asarray(P)
Q_gpu = b.asarray(Q)
n_tracks = x_gpu.shape[0]
state_dim = x_gpu.shape[1]
# One call for the whole batch. This used to convert to numpy and loop,
# invoking the callback once per track and once more per dimension for the
# numerical Jacobian.
x_pred_gpu = b.asarray(f(x_gpu))
if F_jacobian is not None:
F_gpu = b.asarray(F_jacobian(x_gpu))
else:
F_gpu = _compute_numerical_jacobian(f, x_gpu)
# Handle Q dimensions
if Q_gpu.ndim == 2:
Q_batch = b.broadcast_to(Q_gpu, (n_tracks, state_dim, state_dim))
else:
Q_batch = Q_gpu
# Covariance prediction on GPU: P_pred = F @ P @ F' + Q
FP = b.einsum("nij,njk->nik", F_gpu, P_gpu)
P_pred = b.einsum("nij,nkj->nik", FP, F_gpu) + Q_batch
# Ensure symmetry
P_pred = (P_pred + b.swapaxes(P_pred, -2, -1)) / 2
return BatchEKFPrediction(x=x_pred_gpu, P=P_pred)
[docs]
def batch_ekf_update(
x: ArrayLike,
P: ArrayLike,
z: ArrayLike,
h: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
H_jacobian: Optional[
Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
],
R: ArrayLike,
) -> BatchEKFUpdate:
"""
Batch EKF update for multiple tracks.
Parameters
----------
x : array_like
Predicted state estimates, shape (n_tracks, state_dim).
P : array_like
Predicted covariances, shape (n_tracks, state_dim, state_dim).
z : array_like
Measurements, shape (n_tracks, meas_dim).
h : callable
Batched measurement function. Takes ``(n_tracks, state_dim)`` and
returns ``(n_tracks, meas_dim)``. Called once.
H_jacobian : callable or None
Batched Jacobian dh/dx. Takes ``(n_tracks, state_dim)`` and returns
``(n_tracks, meas_dim, state_dim)``. If None, computed numerically.
R : array_like
Measurement noise covariance.
Returns
-------
result : BatchEKFUpdate
Update results including states, covariances, and statistics, as
device arrays of the active backend.
Raises
------
DependencyError
If neither CuPy nor MLX is installed.
Examples
--------
>>> import numpy as np
>>> from pytcl.gpu.ekf import batch_ekf_update
>>> from pytcl.gpu.utils import get_array_module
>>> # Polar measurement from a Cartesian state, batched
>>> def h_polar(x):
... xp = get_array_module(x)
... r = xp.sqrt(x[:, 0] ** 2 + x[:, 1] ** 2)
... theta = xp.arctan2(x[:, 1], x[:, 0])
... return xp.stack([r, theta], axis=1)
>>> def H_jacobian(x):
... xp = get_array_module(x)
... r = xp.sqrt(x[:, 0] ** 2 + x[:, 1] ** 2)
... row0 = xp.stack([x[:, 0] / r, x[:, 1] / r], axis=1)
... row1 = xp.stack([-x[:, 1] / r**2, x[:, 0] / r**2], axis=1)
... return xp.stack([row0, row1], axis=1)
>>> n_tracks = 20
>>> x = np.random.randn(n_tracks, 2)
>>> P = np.tile(np.eye(2), (n_tracks, 1, 1))
>>> z = np.random.randn(n_tracks, 2) * [100, 0.1] # r, theta
>>> R = np.diag([10.0, 0.01])
>>> result = batch_ekf_update(x, P, z, h_polar, H_jacobian, R)
>>> result.x.shape
(20, 2)
"""
b = get_compute_backend()
x_gpu = b.asarray(x)
P_gpu = b.asarray(P)
z_gpu = b.asarray(z)
R_gpu = b.asarray(R)
n_tracks = x_gpu.shape[0]
state_dim = x_gpu.shape[1]
meas_dim = z_gpu.shape[1]
z_pred_gpu = b.asarray(h(x_gpu))
if H_jacobian is not None:
H_gpu = b.asarray(H_jacobian(x_gpu))
else:
H_gpu = _compute_numerical_jacobian(h, x_gpu)
# Handle R dimensions
if R_gpu.ndim == 2:
R_batch = b.broadcast_to(R_gpu, (n_tracks, meas_dim, meas_dim))
else:
R_batch = R_gpu
# Innovation
y = z_gpu - z_pred_gpu
# Innovation covariance: S = H @ P @ H' + R
HP = b.einsum("nij,njk->nik", H_gpu, P_gpu)
S = b.einsum("nij,nkj->nik", HP, H_gpu) + R_batch
# Kalman gain: K = P @ H' @ S^{-1}
PHT = b.einsum("nij,nkj->nik", P_gpu, H_gpu)
S_inv = b.inv(S)
K = b.einsum("nij,njk->nik", PHT, S_inv)
# Updated state
x_upd = x_gpu + b.einsum("nij,nj->ni", K, y)
# Updated covariance (Joseph form)
eye = b.eye(state_dim)
I_KH = eye - b.einsum("nij,njk->nik", K, H_gpu)
P_upd = b.einsum("nij,njk->nik", I_KH, P_gpu)
P_upd = b.einsum("nij,nkj->nik", P_upd, I_KH)
KRK = b.einsum("nij,njk,nlk->nil", K, R_batch, K)
P_upd = P_upd + KRK
# Ensure symmetry
P_upd = (P_upd + b.swapaxes(P_upd, -2, -1)) / 2
# Likelihoods
mahal_sq = b.einsum("ni,nij,nj->n", y, S_inv, y)
_sign, logdet = b.slogdet(S)
log_likelihood = -0.5 * (mahal_sq + logdet + meas_dim * np.log(2 * np.pi))
likelihood = b.exp(log_likelihood)
return BatchEKFUpdate(
x=x_upd,
P=P_upd,
y=y,
S=S,
K=K,
likelihood=likelihood,
)
[docs]
class CuPyExtendedKalmanFilter:
"""
GPU-accelerated Extended Kalman Filter for batch processing.
Despite the historical name, this class runs on whichever GPU backend is
available: CuPy on CUDA devices, MLX on Apple Silicon.
Parameters
----------
state_dim : int
Dimension of state vector.
meas_dim : int
Dimension of measurement vector.
f : callable
Nonlinear dynamics function f(x) -> x_next.
h : callable
Nonlinear measurement function h(x) -> z.
F_jacobian : callable, optional
Jacobian of dynamics. If None, computed numerically.
H_jacobian : callable, optional
Jacobian of measurement. If None, computed numerically.
Q : array_like, optional
Process noise covariance.
R : array_like, optional
Measurement noise covariance.
Raises
------
DependencyError
If neither CuPy nor MLX is installed.
Examples
--------
>>> import numpy as np
>>> from pytcl.gpu.ekf import CuPyExtendedKalmanFilter
>>>
>>> # Nonlinear dynamics. Callbacks receive the WHOLE (n_tracks, dim)
>>> # batch as one device array, not a single state -- see the module
>>> # docstring's contract. Indexing x[0] here would take the first
>>> # track rather than the first state component, and raise on predict.
>>> def f(x):
... return np.stack([x[:, 0] + x[:, 1], x[:, 1] * 0.99], axis=-1)
>>>
>>> def h(x):
... return np.sqrt(x[:, 0] ** 2 + x[:, 1] ** 2)[:, None]
>>>
>>> ekf = CuPyExtendedKalmanFilter(
... state_dim=2, meas_dim=1,
... f=f, h=h,
... Q=np.eye(2) * 0.01,
... R=np.array([[0.1]]),
... )
"""
[docs]
def __init__(
self,
state_dim: int,
meas_dim: int,
f: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
h: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
F_jacobian: Optional[
Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
] = None,
H_jacobian: Optional[
Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
] = None,
Q: Optional[ArrayLike] = None,
R: Optional[ArrayLike] = None,
):
b = get_compute_backend()
self.state_dim = state_dim
self.meas_dim = meas_dim
self.f = f
self.h = h
self.F_jacobian = F_jacobian
self.H_jacobian = H_jacobian
if Q is None:
self.Q = b.eye(state_dim) * 0.01
else:
self.Q = b.asarray(Q)
if R is None:
self.R = b.eye(meas_dim)
else:
self.R = b.asarray(R)
[docs]
def predict(
self,
x: ArrayLike,
P: ArrayLike,
) -> BatchEKFPrediction:
"""Perform batch EKF prediction."""
return batch_ekf_predict(x, P, self.f, self.F_jacobian, self.Q)
[docs]
def update(
self,
x: ArrayLike,
P: ArrayLike,
z: ArrayLike,
) -> BatchEKFUpdate:
"""Perform batch EKF update."""
return batch_ekf_update(x, P, z, self.h, self.H_jacobian, self.R)
[docs]
def predict_update(
self,
x: ArrayLike,
P: ArrayLike,
z: ArrayLike,
) -> BatchEKFUpdate:
"""Combined prediction and update."""
pred = self.predict(x, P)
return self.update(pred.x, pred.P, z)
__all__ = [
"BatchEKFPrediction",
"BatchEKFUpdate",
"batch_ekf_predict",
"batch_ekf_update",
"CuPyExtendedKalmanFilter",
]