Add Localization for the EnKF - #273
Conversation
This commit adds an EnRTS smoother to complement the EnKF. This is based on the presentation of [Raanes (2016)](https://rmets.onlinelibrary.wiley.com/doi/10.1002/qj.2728), which also demonstrates an equivalence with the forward pass-only "ensemble Kalman smoother." Some implementation choices: - The EnRTS smoother requires the "predicted states," i.e., the ensemble $x_{t+1 \mid t}$. Under the current EnKf implementation, this would require re-computing $E_{t+1 \mid t}$, which felt wasteful. Moreover, since the EnKF implementation is of the stochastic EnKF, one would have to be rather careful with PRNG keys as well to make this happen. The compromise I took is thus to add a `store_predicted_states` option to the filter, and store the corresponding info in the `EnKFState`. To run the EnRTS filter, this information must be present. This comes at a modest memory cost (at least, for the typical EnKF application of N_particles << d_X). - I left this new argument to be `False` by default, documented its need in the EnRTS, and give errors if it is not in the EnKF state. The EnRTS tests in `cuthbertlib` includes a basic atomic test of the update, whilst the tests in cuthbert do a more end-to-end comparison to the RTS smoother with a large particle count.
These passed exactly on my machine, but apparently need a small epsilon on the GitHub CI machines.
# Conflicts: # cuthbert/ensemble_kalman/README.md # docs/api_cuthbert/ensemble_kalman/ensemble_kalman_filter.md # docs/api_cuthbert/ensemble_kalman/ensemble_rts_smoother.md # zensical.toml
This commit adds localization via covariance tapering, namely through the Gaspari-Cohn correlation function. The proposed API adds a `GetCovarianceTapers` callback to the filtering interface, which specifies a cross-covariance taper and, optionally, a marginal covariance taper. Custom tapers are possible, but we also provide Gaspari-Cohn in `cuthbertlib/ensemble_kalman/localization.py`. We purposefully avoid localization for smoothing, as there lacks a straightforward "correct" way to do so, as far as I can tell.
This commit adds a tutorial for localization based on Lorenz-96. Illusrates how EnKF with small ensemble sizes fails pretty badly in large dimensions and localized evolution, and how localization can help.
This adds Gaussian localization, which is differentiable with respect to the lengthscale and has infinite support. Includes some test coverage that gradients w.r.t. its lengthscale match finite different approximations.
| # because tapers.marginal cannot be applied to y_dev.T / sqrt(N-1) | ||
| # directly. So we must compute the Cholesky factor directly. | ||
| C_yy = tapers.marginal * (y_dev.T @ y_dev / (N - 1)) | ||
| chol_S = jnp.linalg.cholesky(C_yy + chol_R @ chol_R.T) |
There was a problem hiding this comment.
Aouch, a cholesky! Can we discuss this before it goes in? Can you write the actual equations so we see if we can avoid the cholesky
There was a problem hiding this comment.
I know I know I was sad I couldn't figure it out :(. The actual equations are:
where
which is used in the update
The issue is then that we must get a factor of
It's worth noting that I don't anticipate hitting this branch very often. It only happens if a marginal covariance taper is specified. To the best of my knowledge, it is more common to just supply a cross covariance taper, which avoids a Cholesky call.
There was a problem hiding this comment.
@jax.jit
def compute_enkf_cholesky_factor_qr(Y, L_rho, L_R):
"""
Computes the lower-triangular Cholesky factor of S = rho_yy * (Y @ Y.T) + R
using an augmented matrix QR formulation to avoid explicit covariance construction.
Parameters
----------
Y : jax.numpy.ndarray
Normalized ensemble perturbations array of shape (dy, N).
L_rho : jax.numpy.ndarray
Lower-triangular Cholesky factor of the tapering matrix, shape (dy, dy).
L_R : jax.numpy.ndarray
Lower-triangular Cholesky factor of the observation noise matrix, shape (dy, dy).
Returns
-------
L_S : jax.numpy.ndarray
Lower-triangular Cholesky factor of S, shape (dy, dy).
"""
dy, N = Y.shape
# Construct $\tilde{Y} = [\text{diag}(l_1)Y \mid \dots \mid \text{diag}(l_{d_y})Y]$
# L_rho[:, :, None] -> shape (dy, dy, 1)
# Y[:, None, :] -> shape (dy, 1, N)
# Z -> shape (dy, dy, N)
# Y_tilde -> shape (dy, dy * N)
Z = L_rho[:, :, None] * Y[:, None, :]
Y_tilde = Z.reshape((dy, dy * N))
# Construct the augmented matrix A = [L_R | Y_tilde]
A = jnp.hstack([L_R, Y_tilde])
# Compute QR decomposition of A^T. A^T is of shape (dy + N*dy, dy).
R_qr = jnp.linalg.qr(A.T, mode='r')
L_S_qr = R_qr.T
# Enforce positive diagonal elements for uniqueness
sign_diag = jnp.sign(jnp.diagonal(L_S_qr))
# Handle zeros to avoid zeroing out entire columns
zero_flag = (sign_diag < 0.5) & (sign_diag > -0.5)
sign_diag = jnp.where(zero_flag, jnp.ones_like(sign_diag), sign_diag)
L_S_qr = L_S_qr * sign_diag[None, :]
# Clean up tiny floating point noise above the diagonal
L_S_qr = jnp.tril(L_S_qr)
return L_S_qr
There was a problem hiding this comment.
@DanWaxman that should do the trick, I tested on some cases, trusting you to check properly :D
There was a problem hiding this comment.
Here
Btw, if this works can you add me as a co-author in the commit so I appear on the blame?
Co-authored-by: AdrienCorenflos adrien.corenflos@gmail.com
There was a problem hiding this comment.
Thanks Adrien! I agree that this works. I guess two things from there:
- I was trying to avoid assuming that
$\rho_{yy}$ had a computed Cholesky factor. We can compute it, but this additionally assumes that the taper is PSD/well-conditioned. This is probably a generally reasonable assumption, though I can imagine places where this runs into numerical trouble. - Sam had suggested moving to a more general
marginal_covariance_modifier(C_yy)API. I quite like this, as it is more general (and there are some things you may want to implement that are only possible this way; for example, shrinkage-based estimators). Using the above form would necessitate moving back to the taper-only form (or a more complicated API for the user). I don't have very strong feelings here, but it does seem relevant to talk through.
There was a problem hiding this comment.
I'm happy with requiring
Regarding 2, yeah I think the functional form would be good. Maybe we can expand the input function to be construct_chol_cross_covariance(Y, L_R) for ensemble perturbations L_rho=jnp.eye. What do you think?
There was a problem hiding this comment.
Then the default can be Adrien's function with L_rho=jnp.eye. What do you think?
Hmm, I think the drawback here is requiring a much larger tria solve if the user isn't actually doing tapering. I would probably prefer keeping a None path that reflects the current code structure, just using tria instead of chol for the cross-covariance-modified branch?
There was a problem hiding this comment.
oh yes good point, for sure!
There was a problem hiding this comment.
Updated this!
This commit adds use of generalized Cholesky factors under observation-space localization. This works by creating a general hook for creating an innovation covaraince factor, of which, tapering is a special case. The tapering special case is given as an augmented
triacall inconstruct_tapered_chol_innovation_covariance, assuming that the taper matrix has a Cholesky factor.The form of the tapered solve is due to Adrien Corenflos (see #273 (comment)).
The branching logic in the EnKF remains unchanged, i.e., allows a branch that skips localization, as (i) observation-space tapering here implies a larger-dimensional QR solve and (ii) missingness requires an unnecessary Cholesky pivot if a default is provided.
We now use "covariance modifier" protocols, `ModifyCrossCovariance` and `ModifyMarginalCovariance`, to provide arbitrary JAX-compatible covariance transforms, instead of a more limited tapering-only API.
Simplify API a bit by making the default cross-covariance the identity function. Keeps None as the default for marginal covariance since it exposes a different numerical pathway.
|
Any reason |
Sahel13
left a comment
There was a problem hiding this comment.
The docs can be cleaned up later as well, there are some other minor issues that also need to be fixed unrelated to the ensemble Kalman
|
Thanks! I updated the docs (I got a bit confused, was using the structure for |
Sahel13
left a comment
There was a problem hiding this comment.
I think it's best to wait to get @SamDuffield's opinion on this
This commit adds use of generalized Cholesky factors under observation-space localization. This works by creating a general hook for creating an innovation covaraince factor, of which, tapering is a special case. The tapering special case is given as an augmented `tria` call in `construct_tapered_chol_innovation_covariance`, assuming that the taper matrix has a Cholesky factor. The form of the tapered solve is due to Adrien Corenflow (see state-space-models#273 (comment)). The branching logic in the EnKF remains unchanged, i.e., allows a branch that skips localization, as (i) observation-space tapering here implies a larger-dimensional QR solve and (ii) missingness requires an unnecessary Cholesky pivot if a default is provided. Co-authored-by: AdrienCorenflos adrien.corenflos@gmail.com
| ConstructLocalizedCholInnovationCovariance = Callable[[Array, Array], Array] | ||
|
|
||
|
|
||
| def no_covariance_modifier(covariance: Array, *args: Any, **kwargs: Any) -> Array: |
There was a problem hiding this comment.
Do we need args and kwargs here?
There was a problem hiding this comment.
It's for typing, otherwise it doesn't conform to the ModifyCrossCovariance Protocol.
There was a problem hiding this comment.
Really? I'm only seeing CrossCovarianceModifier = Callable[[Array], Array] which would actually require no args or kwargs. Am I missing something?
There was a problem hiding this comment.
Oh I see it's with the cuthbert interface, hmm we should definitely be able to do this without needing args, kwargs
There was a problem hiding this comment.
For example I'd be happy to have two default functions for no_covariance_modifier in both cuthbert and cuthbertlib. They can even be called _no_covariance_modifier or similar so they don't need docstrings. I just don't like the args, kwargs 😄
| y: Array, | ||
| perturbed_obs: bool = True, | ||
| cross_covariance_modifier: CrossCovarianceModifier = no_covariance_modifier, | ||
| construct_localized_chol_innovation_covariance: ConstructLocalizedCholInnovationCovariance |
There was a problem hiding this comment.
I would maybe rename this construct_chol_innovation_covariance since there is no requirement that is has to be localised, this is only an obvious use case, but there may be others
| If False, use deterministic update. | ||
| cross_covariance_modifier: Function that modifies the empirical | ||
| state-observation cross-covariance. Defaults to the identity. | ||
| construct_localized_chol_innovation_covariance: Optional function that |
There was a problem hiding this comment.
Would state clearly in the docs here what the input and output are for construct_localized_chol_innovation_covariance or if you like consider a Protocol rather than Callable to make it clear
There was a problem hiding this comment.
I documented this in the docstring; I thought about more protocols, but they differ between cuthbert and cuthbertlib (the former takes in model inputs), and this seemed more easily confused.
There was a problem hiding this comment.
Happy with this, thanks a lot @DanWaxman! As always, if in future we find it's not suitably general for some use cases we can reevaluate
Think we also need @Sahel13 to approve as he is currently on Changes requested
This PR adds localization via covariance tapering, namely through the Gaspari-Cohn and Gaussian correlation functions.
GetCovarianceTaperscallback to the filtering interface, which specifies a cross-covariance taper and, optionally, a marginal covariance taper.cuthbertlib/ensemble_kalman/localization.py.We purposefully avoid localization for smoothing, as there lacks a straightforward "correct" way to do so, as far as I can tell.