Skip to content

Add Localization for the EnKF - #273

Open
DanWaxman wants to merge 12 commits into
state-space-models:mainfrom
DanWaxman:dw-enkf-localization
Open

Add Localization for the EnKF#273
DanWaxman wants to merge 12 commits into
state-space-models:mainfrom
DanWaxman:dw-enkf-localization

Conversation

@DanWaxman

Copy link
Copy Markdown
Collaborator

This PR adds localization via covariance tapering, namely through the Gaspari-Cohn and Gaussian correlation functions.

  • 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 and Gaussian in cuthbertlib/ensemble_kalman/localization.py.
    • Gaspari-Cohn is more standard, but Gaussian has infinite support, and therefore has better gradient properties if one wants to perform gradient-based optimization of localization hyperparameters.
  • We add a tutorial using Lorenz-96.
  • We have end-to-end tests to show (i) localization doesn't crash things, basically and (ii) gradients w.r.t. lengthscale of a Gaussian taper are well-behaved, matching closely to a finite difference approximation.

We purposefully avoid localization for smoothing, as there lacks a straightforward "correct" way to do so, as far as I can tell.

DanWaxman and others added 8 commits August 4, 2026 15:57
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.
Co-authored-by: Matt Levine <mattlevine22@gmail.com>
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.
@DanWaxman
DanWaxman requested a review from SamDuffield August 10, 2026 13:57
@DanWaxman DanWaxman linked an issue Aug 10, 2026 that may be closed by this pull request
# Cross-covariance
C_xy = x_dev.T @ y_dev / (N - 1)
if tapers is not None:
C_xy = tapers.cross * C_xy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if the tapering API could be a function that modifies the cross or marginal covariance rather than an array itself (the current CovarianceTapers object). This would be more flexible to the user and allow tapering that doesn't require instantiating an extra d x d matrix. The only issue I see is I'm not sure it will play nice with the argsort/nan code above

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I.e. modified_cross, modified_marginal = covariance_modify(cross, marginal, model_inputs)

what do you think?

@DanWaxman DanWaxman Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I like this idea! One thing that I'm a bit unclear on is how to do this for cross vs. marginal. In particular, I think the marginal taper requires a direct Cholesky decomposition (unless Adrien has any ideas), but cross-covariance-only tapering can avoid this.

One option is to just write modify_cross_covariance and modify_marginal_covariance separately?

Another option would be to use a ModifiedCovariance return type that stores modified_cross_cov : Array and modified_marginal_cov : Array | None. I think I like the former better, but can also see merits in the latter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Former is better: more functional and more transparent!

@DanWaxman DanWaxman Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated! Relevant L96 example code now looks like (streamlined a little bit from the example notebook, which is slightly more general):

def periodic_distance(left, right):
    """Return pairwise shortest distances on the Lorenz–96 ring."""
    direct = jnp.abs(left[:, None] - right[None, :])
    return jnp.minimum(direct, state_dim - direct)

cross_distances = periodic_distance(state_locations, observation_locations)
marginal_distances = periodic_distance(observation_locations, observation_locations)

def modify_cross_covariance(C_xy, model_inputs):
    return gaspari_cohn(cross_distances, support_radius) * C_xy

def modify_marginal_covariance(C_yy, model_inputs):
    return gaspari_cohn(marginal_distances, support_radius) * C_yy

filter_obj = ensemble_kalman_filter.build_filter(
        init_sample=init_sample,
        get_dynamics=get_dynamics,
        get_observations=get_observations,
        n_particles=n_members,
        inflation=inflation,
        perturbed_obs=True,
        modify_cross_covariance=modify_cross_covariance,
        modify_marginal_covariance=modify_marginal_covariance,
    )

Passing in modify_marginal_covariance=None maintains the square root update. Modifiers are applied before pivoting from NaNs, so they should retain the correct indexing.

@SamDuffield SamDuffield added the enhancement New feature or request for existing methods label Aug 12, 2026
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I know I was sad I couldn't figure it out :(. The actual equations are:

$$ C_{yy} = \rho_{yy} \odot \sum_n (\hat{y}^{(n)}_{t | t-1} - \bar{\hat{y}}_{t | t-1}) (\hat{y}^{(n)}_{t | t-1} - \bar{\hat{y}}_{t | t-1})^\top / N-1 ,$$

where $\bar{\hat{y}}_{t | t-1}$ is the mean over the ensemble. The ensemble Kalman gain used later is then

$$ K = C_{xy} \underbrace{(C_{yy} + R)}_{S}{}^{-1}, $$

which is used in the update

$$ x^{(n)}_{t|t} = x^{(n)}_{t|t-1} + K (y_t - \hat{y}_{t|t-1}). $$

The issue is then that we must get a factor of $S.$ I didn't see an obvious way to get a factor of $S = C_{yy} + R$ unless the taper $\rho_{yy}$ comes factored, i.e., $\rho_{yy} = L_\rho L_\rho^\top$.

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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request for existing methods

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Localization for EnKF

4 participants