-
Notifications
You must be signed in to change notification settings - Fork 6
Steady state kalman filtering #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
AdrienCorenflos
wants to merge
4
commits into
main
Choose a base branch
from
steady-state
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,8 @@ | ||
| from cuthbertlib.kalman import generate | ||
| from cuthbertlib.kalman.filtering import predict | ||
| from cuthbertlib.kalman.filtering import ( | ||
| SteadyStateFilterParams, | ||
| compute_steady_state_filter_params, | ||
| predict, | ||
| ) | ||
| from cuthbertlib.kalman.filtering import update as filter_update | ||
| from cuthbertlib.kalman.smoothing import update as smoother_update |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,7 @@ def update( | |
| chol_R: ArrayLike, | ||
| y: ArrayLike, | ||
| log_normalizing_constant: ArrayLike = 0.0, | ||
| steady_state_params: "SteadyStateFilterParams | None" = None, | ||
| ) -> tuple[tuple[Array, Array], Array]: | ||
| """Update the mean and square root covariance with a linear Gaussian observation. | ||
|
|
||
|
|
@@ -74,6 +75,16 @@ def update( | |
| y: Observation. | ||
| log_normalizing_constant: Optional input of log normalizing constant to be added to | ||
| log normalizing constant of the Bayesian update. | ||
| steady_state_params: Optional precomputed steady-state parameters | ||
| (see | ||
| [`SteadyStateFilterParams`][cuthbertlib.kalman.filtering.SteadyStateFilterParams]). | ||
| When provided the QR | ||
| decomposition is skipped; `elem.U` is used as the posterior | ||
| Cholesky covariance and `K` as the Kalman gain. For the | ||
| sequential filter the caller is responsible for supplying a | ||
| `SteadyStateFilterParams` whose `K` and `elem.U` reflect the | ||
| Riccati steady state, not the parallel-scan values produced by | ||
| [`compute_steady_state_filter_params`][cuthbert.gaussian.kalman.compute_steady_state_filter_params]. | ||
|
|
||
| Returns: | ||
| Updated mean and square root covariance as well as the log marginal likelihood. | ||
|
|
@@ -95,26 +106,36 @@ def update( | |
| y_hat = H @ m + d | ||
| y_diff = y - y_hat | ||
|
|
||
| M = jnp.block( | ||
| [ | ||
| [H @ chol_P, chol_R], | ||
| [chol_P, jnp.zeros((n_x, n_y), dtype=chol_P.dtype)], | ||
| ] | ||
| ) | ||
| chol_S = tria(M) | ||
| chol_Py = chol_S[n_y:, n_y:] | ||
|
|
||
| Gmat = chol_S[n_y:, :n_y] | ||
| Imat = chol_S[:n_y, :n_y] | ||
|
|
||
| my = m + Gmat @ solve_triangular(Imat, y_diff, lower=True) | ||
| if steady_state_params is None: | ||
| M = jnp.block( | ||
| [ | ||
| [H @ chol_P, chol_R], | ||
| [chol_P, jnp.zeros((n_x, n_y), dtype=chol_P.dtype)], | ||
| ] | ||
| ) | ||
| chol_S = tria(M) | ||
| chol_Py = chol_S[n_y:, n_y:] | ||
| Gmat = chol_S[n_y:, :n_y] | ||
| Imat = chol_S[:n_y, :n_y] | ||
| my = m + Gmat @ solve_triangular(Imat, y_diff, lower=True) | ||
| else: | ||
| Imat = steady_state_params.chol_S | ||
| chol_Py = steady_state_params.elem.U | ||
| my = m + steady_state_params.K @ y_diff | ||
|
|
||
| ell = multivariate_normal.logpdf(y, y_hat, Imat, nan_support=False) | ||
| return (my, chol_Py), jnp.asarray(ell + log_normalizing_constant) | ||
|
|
||
|
|
||
| def associative_params_single( | ||
| F: Array, c: Array, chol_Q: Array, H: Array, d: Array, chol_R: Array, y: Array | ||
| F: Array, | ||
| c: Array, | ||
| chol_Q: Array, | ||
| H: Array, | ||
| d: Array, | ||
| chol_R: Array, | ||
| y: Array, | ||
| steady_state_params: "SteadyStateFilterParams | None" = None, | ||
| ) -> FilterScanElement: | ||
| """Single time step for scan element for square root parallel Kalman filter. | ||
|
|
||
|
|
@@ -126,6 +147,14 @@ def associative_params_single( | |
| d: Observation shift. | ||
| chol_R: Generalized Cholesky factor of the observation noise covariance. | ||
| y: Observation. | ||
| steady_state_params: Optional precomputed steady-state parameters | ||
| (see | ||
| [`SteadyStateFilterParams`][cuthbertlib.kalman.filtering.SteadyStateFilterParams]). | ||
| When provided the QR | ||
| decomposition is skipped; `A`, `U`, and `Z` are reused from | ||
| the stored element and only the observation-dependent `b`, | ||
| `eta`, and `ell` are evaluated, replacing the expensive | ||
| per-step QR with cheap matrix–vector products. | ||
|
|
||
| Returns: | ||
| Prepared scan element for the square root parallel Kalman filter. | ||
|
|
@@ -136,41 +165,170 @@ def associative_params_single( | |
|
|
||
| ny, nx = H.shape | ||
|
|
||
| # joint over the predictive and the observation | ||
| Psi_ = jnp.block([[H @ chol_Q, chol_R], [chol_Q, jnp.zeros((nx, ny))]]) | ||
| inn = y - H @ c - d # innovation relative to the prior prediction | ||
|
|
||
| Tria_Psi_ = tria(Psi_) | ||
| if steady_state_params is None: | ||
| # joint over the predictive and the observation | ||
| Psi_ = jnp.block([[H @ chol_Q, chol_R], [chol_Q, jnp.zeros((nx, ny))]]) | ||
| Tria_Psi_ = tria(Psi_) | ||
|
|
||
| Psi11 = Tria_Psi_[:ny, :ny] | ||
| Psi21 = Tria_Psi_[ny : ny + nx, :ny] | ||
| U = Tria_Psi_[ny : ny + nx, ny:] | ||
| Psi11 = Tria_Psi_[:ny, :ny] | ||
| Psi21 = Tria_Psi_[ny : ny + nx, :ny] | ||
| U = Tria_Psi_[ny : ny + nx, ny:] | ||
|
|
||
| # pre-compute inverse of Psi11: we apply it to matrices and vectors alike. | ||
| Psi11_inv = solve_triangular(Psi11, jnp.eye(ny), lower=True) | ||
|
|
||
| K = Psi21 @ Psi11_inv # local Kalman gain | ||
| HF = H @ F | ||
| A = F - K @ HF # corrected transition matrix | ||
|
|
||
| # pre-compute inverse of Psi11: we apply it to matrices and vectors alike. | ||
| Psi11_inv = solve_triangular(Psi11, jnp.eye(ny), lower=True) | ||
| # information filter (Z_filter is the pre-padded (nx, ny) version) | ||
| Z_filter = HF.T @ Psi11_inv.T | ||
| if nx > ny: | ||
| Z = jnp.concatenate([Z_filter, jnp.zeros((nx, nx - ny))], axis=1) | ||
| else: | ||
| Z = tria(Z_filter) | ||
| else: | ||
| A, _, U, _, Z, _ = steady_state_params.elem | ||
| K = steady_state_params.K | ||
| Psi11 = steady_state_params.chol_S | ||
| Psi11_inv = steady_state_params.Psi11_inv | ||
| Z_filter = steady_state_params.Z_filter | ||
|
|
||
| b = c + K @ inn # corrected transition offset | ||
| eta = Z_filter @ (Psi11_inv @ inn) | ||
| ell = jnp.asarray( | ||
| multivariate_normal.logpdf(y, H @ c + d, Psi11, nan_support=False) | ||
| ) | ||
|
|
||
| # predictive model given one observation | ||
| K = Psi21 @ Psi11_inv # local Kalman gain | ||
| HF = H @ F # temporary variable | ||
| A = F - K @ HF # corrected transition matrix | ||
| return FilterScanElement(A, b, U, eta, Z, ell) | ||
|
|
||
| b = c + K @ (y - H @ c - d) # corrected transition offset | ||
|
|
||
| # information filter | ||
| Z = HF.T @ Psi11_inv.T | ||
| eta = Psi11_inv @ (y - H @ c - d) | ||
| eta = Z @ eta | ||
| class SteadyStateFilterParams(NamedTuple): | ||
| """Precomputed, time-invariant quantities for a steady-state Kalman filter. | ||
|
|
||
| In a time-invariant linear Gaussian SSM the filter gain and posterior | ||
| covariance converge to constants. Passing an instance of this class as the | ||
| `steady_state_params` argument to | ||
| [`associative_params_single`][cuthbertlib.kalman.filtering.associative_params_single] | ||
| or [`update`][cuthbertlib.kalman.filtering.update] skips the expensive | ||
| per-step QR decomposition and reuses the | ||
| constant `A`, `U`, and `Z` blocks. | ||
|
|
||
| Attributes: | ||
| elem: A `FilterScanElement` whose `A`, `U`, and `Z` fields hold | ||
| the steady-state values. The `b`, `eta`, and `ell` fields are | ||
| unused (they depend on the observation). | ||
| K: Steady-state Kalman gain matrix, shape `(nx, ny)`. | ||
| chol_S: Lower-triangular Cholesky factor of the steady-state innovation | ||
| covariance `S = H P_pred H^T + R`, shape `(ny, ny)`. | ||
| Psi11_inv: Inverse of `chol_S`, shape `(ny, ny)`. Precomputed so | ||
| that the per-step cost is pure matrix–vector products with no | ||
| triangular solve. | ||
| Z_filter: Pre-padded information gain matrix `(H F)^T S^{-T}`, | ||
| shape `(nx, ny)`. Used to form `eta` without the QR | ||
| decomposition; distinct from the square `Z` in `elem` which is | ||
| used by the associative combine operator. | ||
| """ | ||
|
|
||
| elem: FilterScanElement | ||
| K: Array | ||
| chol_S: Array | ||
| Psi11_inv: Array | ||
| Z_filter: Array | ||
|
|
||
|
|
||
| def compute_steady_state_filter_params( | ||
| F: Array, | ||
| chol_Q: Array, | ||
| H: Array, | ||
| chol_R: Array, | ||
|
Comment on lines
+243
to
+246
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these can have type |
||
| ) -> SteadyStateFilterParams: | ||
| """Compute steady-state filter parameters for a time-invariant linear Gaussian SSM. | ||
|
|
||
| For a time-invariant model the `A`, `U`, and `Z` fields of each | ||
| associative scan element are identical at every time step — they depend | ||
| only on `F`, `chol_Q`, `H`, and `chol_R`, not on the observation. | ||
| This function extracts those constant fields once (along with the Kalman | ||
| gain `K` and the innovation Cholesky `chol_S`) by performing the same | ||
| block-triangularization as | ||
| [`associative_params_single`][cuthbertlib.kalman.filtering.associative_params_single] | ||
| but without | ||
| evaluating the observation-dependent terms. | ||
|
|
||
| The returned | ||
| [`SteadyStateFilterParams`][cuthbertlib.kalman.filtering.SteadyStateFilterParams] | ||
| can be passed to | ||
| [`associative_params_single`][cuthbertlib.kalman.filtering.associative_params_single] | ||
| or [`update`][cuthbertlib.kalman.filtering.update] to skip the per-step QR | ||
| decomposition and only recompute the cheap observation-dependent | ||
| quantities `b`, `eta`, and `ell` at every step. | ||
|
|
||
| This function is intended to be called **outside of JIT** as a one-off | ||
| pre-computation. The returned params can then be passed into a JIT-compiled | ||
| filter call for all subsequent runs. | ||
|
|
||
| Note: | ||
| The `K` stored here is the *parallel-scan* gain, derived from | ||
| `chol_Q` alone (see | ||
| [`associative_params_single`][cuthbertlib.kalman.filtering.associative_params_single]). | ||
| When using [`update`][cuthbertlib.kalman.filtering.update] in a | ||
| sequential filter, supply a | ||
| [`SteadyStateFilterParams`][cuthbertlib.kalman.filtering.SteadyStateFilterParams] | ||
| whose `K` and `elem.U` reflect the Riccati steady state instead. | ||
|
|
||
| Args: | ||
| F: State transition matrix, shape `(nx, nx)`. | ||
| chol_Q: Generalized Cholesky factor of the transition noise covariance, | ||
| shape `(nx, nx)`. | ||
| H: Observation matrix, shape `(ny, nx)`. | ||
| chol_R: Generalized Cholesky factor of the observation noise covariance, | ||
| shape `(ny, ny)`. | ||
|
|
||
| Returns: | ||
| [`SteadyStateFilterParams`][cuthbertlib.kalman.filtering.SteadyStateFilterParams] | ||
| containing the constant `A`, `U`, | ||
| `Z`, gain `K`, innovation Cholesky `chol_S`, precomputed | ||
| `Psi11_inv`, and pre-padded information gain `Z_filter`. | ||
| """ | ||
| F = jnp.asarray(F) | ||
| chol_Q = jnp.asarray(chol_Q) | ||
| H = jnp.asarray(H) | ||
| chol_R = jnp.asarray(chol_R) | ||
|
|
||
| ny, nx = H.shape | ||
|
|
||
| # Mirrors the block-triangularization in associative_params_single, | ||
| # stopping before the observation-dependent b, eta, ell. | ||
| Psi_ = jnp.block([[H @ chol_Q, chol_R], [chol_Q, jnp.zeros((nx, ny))]]) | ||
| Tria_Psi_ = tria(Psi_) | ||
|
|
||
| chol_S = Tria_Psi_[:ny, :ny] # innovation Cholesky | ||
| Psi21 = Tria_Psi_[ny : ny + nx, :ny] | ||
| U = Tria_Psi_[ny : ny + nx, ny:] # posterior sqrt covariance | ||
|
|
||
| Psi11_inv = solve_triangular(chol_S, jnp.eye(ny), lower=True) | ||
| K = Psi21 @ Psi11_inv # Kalman gain | ||
|
|
||
| HF = H @ F | ||
| A = F - K @ HF | ||
|
|
||
| Z_filter = HF.T @ Psi11_inv.T # (nx, ny); used for eta, before padding | ||
| Z = Z_filter | ||
| if nx > ny: | ||
| Z = jnp.concatenate([Z, jnp.zeros((nx, nx - ny))], axis=1) | ||
| else: | ||
| Z = tria(Z) | ||
|
|
||
| # local log marginal likelihood | ||
| ell = jnp.asarray( | ||
| multivariate_normal.logpdf(y, H @ c + d, Psi11, nan_support=False) | ||
| ) | ||
| dummy_b = jnp.zeros(nx) | ||
| dummy_eta = jnp.zeros(nx) | ||
| dummy_ell = jnp.array(0.0) | ||
|
|
||
| return FilterScanElement(A, b, U, eta, Z, ell) | ||
| elem = FilterScanElement(A=A, b=dummy_b, U=U, eta=dummy_eta, Z=Z, ell=dummy_ell) | ||
| return SteadyStateFilterParams( | ||
| elem=elem, K=K, chol_S=chol_S, Psi11_inv=Psi11_inv, Z_filter=Z_filter | ||
| ) | ||
|
|
||
|
|
||
| def filtering_operator( | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of the extra arg could we have
get_observation_paramspossibly returnSteadyStateFilterParamsif using steady state?