From 8d785068ef5e611bac789a867acded399c5cd841 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:47:18 +0200 Subject: [PATCH 1/2] Add one-shot IMM logdet fix workflow --- .github/workflows/one-shot-imm-logdet-fix.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/one-shot-imm-logdet-fix.yml diff --git a/.github/workflows/one-shot-imm-logdet-fix.yml b/.github/workflows/one-shot-imm-logdet-fix.yml new file mode 100644 index 0000000000..4bcb15e972 --- /dev/null +++ b/.github/workflows/one-shot-imm-logdet-fix.yml @@ -0,0 +1,74 @@ +name: One-shot IMM logdet fix + +permissions: + contents: write + +on: + push: + branches: + - agent/fix-imm-logdet-underflow + +jobs: + patch-and-test: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Check out branch + uses: actions/checkout@v7 + with: + ref: agent/fix-imm-logdet-underflow + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Apply IMM likelihood fix and regression test + run: | + python - <<'PY' + from pathlib import Path + + implementation = Path("src/pyrecest/filters/interacting_multiple_model_filter.py") + text = implementation.read_text(encoding="utf-8") + old_import = """ asarray,\n diag,\n empty,\n""" + new_import = """ asarray,\n diag,\n diagonal,\n empty,\n""" + if text.count(old_import) != 1: + raise RuntimeError("Unexpected IMM backend import block") + text = text.replace(old_import, new_import, 1) + + old_likelihood = """ det_value = float(linalg.det(innovation_covariance))\n if det_value <= 0.0:\n raise ValueError(\n \"Innovation covariance must be positive definite to evaluate the IMM likelihood.\"\n )\n logdet = float(log(array(det_value)))\n""" + new_likelihood = """ try:\n cholesky_factor = linalg.cholesky(innovation_covariance)\n except (np.linalg.LinAlgError, RuntimeError, ValueError) as exc:\n raise ValueError(\n \"Innovation covariance must be positive definite to evaluate the IMM likelihood.\"\n ) from exc\n logdet = 2.0 * float(log(diagonal(cholesky_factor)).sum())\n""" + if text.count(old_likelihood) != 1: + raise RuntimeError("Unexpected IMM determinant likelihood implementation") + implementation.write_text( + text.replace(old_likelihood, new_likelihood, 1), encoding="utf-8" + ) + + tests = Path("tests/filters/test_interacting_multiple_model_filter.py") + test_text = tests.read_text(encoding="utf-8") + if "import math\n" not in test_text: + test_text = test_text.replace("import copy\n", "import copy\nimport math\n", 1) + + marker = """ def test_rejects_nonfinite_transition_matrix_and_mode_probabilities(self):\n""" + regression = """ def test_linear_likelihood_handles_positive_definite_determinant_underflow(self):\n predicted_state = GaussianDistribution(\n array([0.0, 0.0]),\n array([[0.0, 0.0], [0.0, 0.0]]),\n check_validity=False,\n )\n measurement_noise = array([[1e-200, 0.0], [0.0, 1e-200]])\n\n log_likelihood = (\n InteractingMultipleModelFilter._log_linear_measurement_likelihood(\n array([0.0, 0.0]),\n predicted_state,\n eye(2),\n measurement_noise,\n )\n )\n\n expected = -math.log(2.0 * math.pi) + 200.0 * math.log(10.0)\n self.assertTrue(math.isfinite(log_likelihood))\n self.assertAlmostEqual(log_likelihood, expected, places=12)\n\n""" + if regression not in test_text: + if test_text.count(marker) != 1: + raise RuntimeError("Unexpected IMM test insertion point") + test_text = test_text.replace(marker, regression + marker, 1) + tests.write_text(test_text, encoding="utf-8") + PY + + - name: Install package and run focused tests + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + PYRECEST_BACKEND=numpy python -m pytest -q tests/filters/test_interacting_multiple_model_filter.py + + - name: Commit verified fix + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/pyrecest/filters/interacting_multiple_model_filter.py tests/filters/test_interacting_multiple_model_filter.py + git rm .github/workflows/one-shot-imm-logdet-fix.yml + git commit -m "Stabilize IMM Gaussian log likelihood" + git push origin HEAD:agent/fix-imm-logdet-underflow From 417dcc8a99ecea862d2b50d71a840f5721c8c322 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:48:01 +0000 Subject: [PATCH 2/2] Stabilize IMM Gaussian log likelihood --- .github/workflows/one-shot-imm-logdet-fix.yml | 74 ------------------- .../interacting_multiple_model_filter.py | 10 ++- .../test_interacting_multiple_model_filter.py | 22 ++++++ 3 files changed, 28 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/one-shot-imm-logdet-fix.yml diff --git a/.github/workflows/one-shot-imm-logdet-fix.yml b/.github/workflows/one-shot-imm-logdet-fix.yml deleted file mode 100644 index 4bcb15e972..0000000000 --- a/.github/workflows/one-shot-imm-logdet-fix.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: One-shot IMM logdet fix - -permissions: - contents: write - -on: - push: - branches: - - agent/fix-imm-logdet-underflow - -jobs: - patch-and-test: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - name: Check out branch - uses: actions/checkout@v7 - with: - ref: agent/fix-imm-logdet-underflow - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Apply IMM likelihood fix and regression test - run: | - python - <<'PY' - from pathlib import Path - - implementation = Path("src/pyrecest/filters/interacting_multiple_model_filter.py") - text = implementation.read_text(encoding="utf-8") - old_import = """ asarray,\n diag,\n empty,\n""" - new_import = """ asarray,\n diag,\n diagonal,\n empty,\n""" - if text.count(old_import) != 1: - raise RuntimeError("Unexpected IMM backend import block") - text = text.replace(old_import, new_import, 1) - - old_likelihood = """ det_value = float(linalg.det(innovation_covariance))\n if det_value <= 0.0:\n raise ValueError(\n \"Innovation covariance must be positive definite to evaluate the IMM likelihood.\"\n )\n logdet = float(log(array(det_value)))\n""" - new_likelihood = """ try:\n cholesky_factor = linalg.cholesky(innovation_covariance)\n except (np.linalg.LinAlgError, RuntimeError, ValueError) as exc:\n raise ValueError(\n \"Innovation covariance must be positive definite to evaluate the IMM likelihood.\"\n ) from exc\n logdet = 2.0 * float(log(diagonal(cholesky_factor)).sum())\n""" - if text.count(old_likelihood) != 1: - raise RuntimeError("Unexpected IMM determinant likelihood implementation") - implementation.write_text( - text.replace(old_likelihood, new_likelihood, 1), encoding="utf-8" - ) - - tests = Path("tests/filters/test_interacting_multiple_model_filter.py") - test_text = tests.read_text(encoding="utf-8") - if "import math\n" not in test_text: - test_text = test_text.replace("import copy\n", "import copy\nimport math\n", 1) - - marker = """ def test_rejects_nonfinite_transition_matrix_and_mode_probabilities(self):\n""" - regression = """ def test_linear_likelihood_handles_positive_definite_determinant_underflow(self):\n predicted_state = GaussianDistribution(\n array([0.0, 0.0]),\n array([[0.0, 0.0], [0.0, 0.0]]),\n check_validity=False,\n )\n measurement_noise = array([[1e-200, 0.0], [0.0, 1e-200]])\n\n log_likelihood = (\n InteractingMultipleModelFilter._log_linear_measurement_likelihood(\n array([0.0, 0.0]),\n predicted_state,\n eye(2),\n measurement_noise,\n )\n )\n\n expected = -math.log(2.0 * math.pi) + 200.0 * math.log(10.0)\n self.assertTrue(math.isfinite(log_likelihood))\n self.assertAlmostEqual(log_likelihood, expected, places=12)\n\n""" - if regression not in test_text: - if test_text.count(marker) != 1: - raise RuntimeError("Unexpected IMM test insertion point") - test_text = test_text.replace(marker, regression + marker, 1) - tests.write_text(test_text, encoding="utf-8") - PY - - - name: Install package and run focused tests - run: | - python -m pip install --upgrade pip - python -m pip install -e . pytest - PYRECEST_BACKEND=numpy python -m pytest -q tests/filters/test_interacting_multiple_model_filter.py - - - name: Commit verified fix - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/pyrecest/filters/interacting_multiple_model_filter.py tests/filters/test_interacting_multiple_model_filter.py - git rm .github/workflows/one-shot-imm-logdet-fix.yml - git commit -m "Stabilize IMM Gaussian log likelihood" - git push origin HEAD:agent/fix-imm-logdet-underflow diff --git a/src/pyrecest/filters/interacting_multiple_model_filter.py b/src/pyrecest/filters/interacting_multiple_model_filter.py index 4ed276933b..eaf6d4b0ed 100644 --- a/src/pyrecest/filters/interacting_multiple_model_filter.py +++ b/src/pyrecest/filters/interacting_multiple_model_filter.py @@ -13,6 +13,7 @@ array, asarray, diag, + diagonal, empty, exp, eye, @@ -612,12 +613,13 @@ def _log_linear_measurement_likelihood( @ measurement_matrix.T + meas_noise ) - det_value = float(linalg.det(innovation_covariance)) - if det_value <= 0.0: + try: + cholesky_factor = linalg.cholesky(innovation_covariance) + except (np.linalg.LinAlgError, RuntimeError, ValueError) as exc: raise ValueError( "Innovation covariance must be positive definite to evaluate the IMM likelihood." - ) - logdet = float(log(array(det_value))) + ) from exc + logdet = 2.0 * float(log(diagonal(cholesky_factor)).sum()) mahalanobis_distance = float( innovation.T @ linalg.solve(innovation_covariance, innovation) ) diff --git a/tests/filters/test_interacting_multiple_model_filter.py b/tests/filters/test_interacting_multiple_model_filter.py index 585fce8da2..386f553091 100644 --- a/tests/filters/test_interacting_multiple_model_filter.py +++ b/tests/filters/test_interacting_multiple_model_filter.py @@ -1,4 +1,5 @@ import copy +import math import unittest import numpy.testing as npt @@ -140,6 +141,27 @@ def test_external_mode_probability_update(self): array([0.8807970779778823, 0.11920292202211755]), ) + def test_linear_likelihood_handles_positive_definite_determinant_underflow(self): + predicted_state = GaussianDistribution( + array([0.0, 0.0]), + array([[0.0, 0.0], [0.0, 0.0]]), + check_validity=False, + ) + measurement_noise = array([[1e-200, 0.0], [0.0, 1e-200]]) + + log_likelihood = ( + InteractingMultipleModelFilter._log_linear_measurement_likelihood( + array([0.0, 0.0]), + predicted_state, + eye(2), + measurement_noise, + ) + ) + + expected = -math.log(2.0 * math.pi) + 200.0 * math.log(10.0) + self.assertTrue(math.isfinite(log_likelihood)) + self.assertAlmostEqual(log_likelihood, expected, places=12) + def test_rejects_nonfinite_transition_matrix_and_mode_probabilities(self): filter_bank = [ MockGaussianFilter(GaussianDistribution(array([0.0]), array([[1.0]]))),