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.
| # Cross-covariance | ||
| C_xy = x_dev.T @ y_dev / (N - 1) | ||
| if tapers is not None: | ||
| C_xy = tapers.cross * C_xy |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
I.e. modified_cross, modified_marginal = covariance_modify(cross, marginal, model_inputs)
what do you think?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Former is better: more functional and more transparent!
There was a problem hiding this comment.
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.
| # 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.
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.
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.