From 34a35ae2d2e0042546845d65b8830c8006c31d90 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:58:00 +0200 Subject: [PATCH 1/3] Stabilize UKF covariance symmetrization --- src/pyrecest/filters/_ukf.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pyrecest/filters/_ukf.py b/src/pyrecest/filters/_ukf.py index f09c393b94..4c55662d37 100644 --- a/src/pyrecest/filters/_ukf.py +++ b/src/pyrecest/filters/_ukf.py @@ -8,15 +8,18 @@ # pylint: disable=no-name-in-module,no-member from pyrecest.backend import ( + absolute, asarray, einsum, expand_dims, eye, float64, linalg, + maximum, reshape, stack, transpose, + where, zeros, ) from pyrecest.sampling.sigma_points import JulierSigmaPoints, MerweScaledSigmaPoints @@ -47,6 +50,15 @@ def _as_vector(value, description): return value +def _stable_symmetric_average(matrix): + """Average a matrix with its transpose without finite overflow.""" + transposed = transpose(matrix) + scale = maximum(absolute(matrix), absolute(transposed)) + safe_scale = where(scale == 0.0, 1.0, scale) + normalized_average = 0.5 * (matrix / safe_scale + transposed / safe_scale) + return scale * normalized_average + + # --------------------------------------------------------------------------- # Unscented Kalman Filter # --------------------------------------------------------------------------- @@ -121,7 +133,7 @@ def predict(self, fx=None, dt=None, **fx_args): d = expand_dims(sigmas_f[i] - x_pred, -1) P_pred = P_pred + Wc[i] * (d @ transpose(d)) P_pred = P_pred + asarray(self.Q, dtype=float64) - P_pred = 0.5 * (P_pred + transpose(P_pred)) + P_pred = _stable_symmetric_average(P_pred) self.x = x_pred self.P = P_pred @@ -213,7 +225,7 @@ def update(self, z, R=None, hx=None, **hx_args): self.x = self.x + K @ (z - z_pred) self.P = self.P - K @ Pz @ transpose(K) - self.P = 0.5 * (self.P + transpose(self.P)) + self.P = _stable_symmetric_average(self.P) self._sigmas_f = None # clear cached sigma points From 1f64fc22d53c6ecf9d0f321f5f7d246c3d709831 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:58:10 +0200 Subject: [PATCH 2/3] Add UKF covariance overflow regression --- .../filters/test_ukf_covariance_stability.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/filters/test_ukf_covariance_stability.py diff --git a/tests/filters/test_ukf_covariance_stability.py b/tests/filters/test_ukf_covariance_stability.py new file mode 100644 index 0000000000..0af3c46c6e --- /dev/null +++ b/tests/filters/test_ukf_covariance_stability.py @@ -0,0 +1,36 @@ +import unittest + +import numpy as np +import numpy.testing as npt + +# pylint: disable=no-name-in-module,no-member +import pyrecest.backend +from pyrecest.backend import array, float64 +from pyrecest.distributions import GaussianDistribution +from pyrecest.filters.unscented_kalman_filter import UnscentedKalmanFilter + + +class UnscentedKalmanFilterCovarianceStabilityTest(unittest.TestCase): + @unittest.skipIf( + pyrecest.backend.__backend_name__ in ("pytorch", "jax"), + reason="Not supported on this backend", + ) + def test_predict_preserves_extreme_finite_process_covariance(self): + largest = np.finfo(np.float64).max + ukf = UnscentedKalmanFilter( + GaussianDistribution( + array([0.0], dtype=float64), + array([[1.0]], dtype=float64), + ) + ) + + with np.errstate(over="raise", invalid="raise"): + ukf.predict_identity(array([[largest]], dtype=float64)) + + covariance = ukf.filter_state.covariance() + self.assertTrue(np.isfinite(covariance).all()) + npt.assert_array_equal(covariance, array([[largest]], dtype=float64)) + + +if __name__ == "__main__": + unittest.main() From e56d354ed4547fd2fbc70e80f4649d1bc48bc029 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:02:47 +0200 Subject: [PATCH 3/3] Use the exported backend absolute-value helper --- src/pyrecest/filters/_ukf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pyrecest/filters/_ukf.py b/src/pyrecest/filters/_ukf.py index 4c55662d37..f6e3a4d1f5 100644 --- a/src/pyrecest/filters/_ukf.py +++ b/src/pyrecest/filters/_ukf.py @@ -8,7 +8,7 @@ # pylint: disable=no-name-in-module,no-member from pyrecest.backend import ( - absolute, + abs, asarray, einsum, expand_dims, @@ -53,7 +53,7 @@ def _as_vector(value, description): def _stable_symmetric_average(matrix): """Average a matrix with its transpose without finite overflow.""" transposed = transpose(matrix) - scale = maximum(absolute(matrix), absolute(transposed)) + scale = maximum(abs(matrix), abs(transposed)) safe_scale = where(scale == 0.0, 1.0, scale) normalized_average = 0.5 * (matrix / safe_scale + transposed / safe_scale) return scale * normalized_average