Skip to content

Ensemble Kalman Filter

The EnKF treats the filtering distribution as Gaussian, but represents it with an ensemble of \(N\) members \(x^{(i)}\) instead of storing a mean and covariance and linearizing \(f\) or \(h\). The implied mean and covariance are the usual sample mean and sample covariance of the members.

Predict. Each member is advanced with the dynamics and process noise. Multiplicative inflation (optional) rescales deviations from the new ensemble mean by a factor \((1+\delta)\) to combat underspread ensembles.

Update. From deviations in state and observation space, form empirical cross-covariance \(C_{xy}\) and innovation covariance \(S\) in observation space (including observation noise). The Kalman gain \(K \approx C_{xy} S^{-1}\) gives a Kalman-like correction to each member (e.g. stochastic EnKF with random observation perturbations).

The EnKF allows for storing its predicted states, \(x_{t \mid t - 1}\), through the store_predicted_ensemble flag. This flag is required when the filtering outputs are to be used by an EnRTS smoother.

Large observation dimensions. Setting ensemble_subspace=True performs the analysis in the \(N\)-dimensional ensemble subspace via the Woodbury identity. It is algebraically exact and cheaper whenever \(N\) is much smaller than the observation dimension. This cost can be further reduced by passing scalar or diagonal chol_R. It is incompatible with both localization callbacks below. See the cuthbertlib filtering documentation for the details.

See Algorithm 2 in Appendix A in Calvello, Reich, and Stuart., Ensemble Kalman Methods: A Mean Field Perspective for the EnKF algorithm which accomodates non-linear observation functions \(h\). Note that this algorithm corresponds to the perturbed_obs = True (Default) option in the EnKF implementation. This boolean flag is represented by s in Algorithm 10.2 of Sanz-Alonso et al., Inverse Problems and Data Assimilation, which was only written for linear \(h\).

cuthbert.ensemble_kalman.ensemble_kalman_filter

Implements the high-level Ensemble Kalman Filter (EnKF).

See Algorithm 10.2, Sanz-Alonso et al., Inverse Problems and Data Assimilation. Based in part on the CD-Dynamax implementation.

EnKFState

Bases: NamedTuple

Ensemble Kalman filter state.

key instance-attribute

ensemble instance-attribute

model_inputs instance-attribute

log_normalizing_constant instance-attribute

predicted_ensemble = None class-attribute instance-attribute

n_particles property

Number of particles.

mean property

Ensemble mean.

chol_cov property

Generalised Cholesky factor of the ensemble sample covariance.

no_covariance_modifier(cross_covariance, model_inputs)

Return an empirical covariance unchanged.

Source code in cuthbert/ensemble_kalman/ensemble_kalman_filter.py
def no_covariance_modifier(
    cross_covariance: Array, model_inputs: ArrayTreeLike
) -> Array:
    """Return an empirical covariance unchanged."""
    return cross_covariance

build_filter(init_sample, get_dynamics, get_observations, n_particles, inflation=0.0, perturbed_obs=True, store_predicted_ensemble=False, modify_cross_covariance=no_covariance_modifier, construct_chol_innovation_covariance=None, ensemble_subspace=False)

Builds an Ensemble Kalman Filter object.

Parameters:

Name Type Description Default
init_sample InitSample

Function of a JAX random key only, generates a single sample from the initial distribution.

required
get_dynamics GetEnKFDynamics

Function to get dynamics function (x_t, key) -> x_{t+1} ~ p(x_{t+1} | x_t) from model inputs.

required
get_observations GetEnKFObservations

Function to get observation function, chol_R, and y from model inputs.

required
n_particles int

Number of particles.

required
inflation float

Multiplicative inflation factor for ensemble deviations, applied in the predict step.

0.0
perturbed_obs bool

If True, use perturbed observations (stochastic EnKF).

True
store_predicted_ensemble bool

Whether to store the incoming forecast ensemble in each filter state, as required by the EnRTS smoother.

False
modify_cross_covariance ModifyCrossCovariance

Function that modifies the empirical state-observation cross-covariance in the update step. Defaults to the identity.

no_covariance_modifier
construct_chol_innovation_covariance ConstructCholInnovationCovariance | None

Optional function that constructs a generalized Cholesky factor of the localized innovation covariance matrix. None (default) uses the standard, unlocalized form of the ensemble Kalman update.

None
ensemble_subspace bool

If True, perform the analysis in the n_particles-dimensional ensemble subspace. Algebraically exact, and cheaper in the state dimension whenever n_particles is much smaller than the observation dimension. It is incompatible with both localization arguments above, and permits a scalar or 1D chol_R. Defaults to False.

When using it, prefer to have get_observations return a scalar or 1D chol_R wherever the observation noise allows. A dense 2D factor is applied by triangular solve rather than by scaling, which restores a quadratic dependence on the observation dimension and requires storing the factor densely; both are avoided entirely by the structured forms. A dense factor is also refactored at cubic cost in the observation dimension at every step with missing observations.

False

Returns:

Type Description
Filter

Filter object for the EnKF.

Raises:

Type Description
ValueError

If n_particles is less than 2, or if ensemble_subspace is combined with either localization argument.

Source code in cuthbert/ensemble_kalman/ensemble_kalman_filter.py
def build_filter(
    init_sample: InitSample,
    get_dynamics: GetEnKFDynamics,
    get_observations: GetEnKFObservations,
    n_particles: int,
    inflation: float = 0.0,
    perturbed_obs: bool = True,
    store_predicted_ensemble: bool = False,
    modify_cross_covariance: ModifyCrossCovariance = no_covariance_modifier,
    construct_chol_innovation_covariance: ConstructCholInnovationCovariance
    | None = None,
    ensemble_subspace: bool = False,
) -> Filter:
    """Builds an Ensemble Kalman Filter object.

    Args:
        init_sample: Function of a JAX random key only, generates a single sample from
            the initial distribution.
        get_dynamics: Function to get dynamics function (x_t, key) -> x_{t+1} ~ p(x_{t+1} | x_t) from model inputs.
        get_observations: Function to get observation function, chol_R, and y from model inputs.
        n_particles: Number of particles.
        inflation: Multiplicative inflation factor for ensemble deviations, applied in the predict step.
        perturbed_obs: If True, use perturbed observations (stochastic EnKF).
        store_predicted_ensemble: Whether to store the incoming forecast ensemble
            in each filter state, as required by the EnRTS smoother.
        modify_cross_covariance: Function that modifies the empirical
            state-observation cross-covariance in the update step. Defaults to
            the identity.
        construct_chol_innovation_covariance: Optional function that
            constructs a generalized Cholesky factor of the localized innovation
            covariance matrix. ``None`` (default) uses the standard, unlocalized
            form of the ensemble Kalman update.
        ensemble_subspace: If True, perform the analysis in the n_particles-dimensional
            ensemble subspace. Algebraically exact, and cheaper in the state dimension
            whenever ``n_particles`` is much smaller than the observation dimension. It
            is incompatible with both localization arguments above, and permits a scalar
            or 1D ``chol_R``. Defaults to False.

            When using it, prefer to have ``get_observations`` return a scalar or 1D
            ``chol_R`` wherever the observation noise allows. A dense 2D factor is
            applied by triangular solve rather than by scaling, which restores a
            quadratic dependence on the observation dimension and requires storing
            the factor densely; both are avoided entirely by the structured forms.
            A dense factor is also refactored at cubic cost in the observation
            dimension at every step with missing observations.

    Returns:
        Filter object for the EnKF.

    Raises:
        ValueError: If ``n_particles`` is less than 2, or if ``ensemble_subspace`` is
            combined with either localization argument.
    """
    if n_particles < 2:
        raise ValueError("n_particles must be at least 2 for EnKF.")

    if ensemble_subspace:
        if modify_cross_covariance is not no_covariance_modifier:
            raise ValueError(
                "ensemble_subspace=True is incompatible with modify_cross_covariance."
            )
        if construct_chol_innovation_covariance is not None:
            raise ValueError(
                "ensemble_subspace=True is incompatible with "
                "construct_chol_innovation_covariance."
            )

    return Filter(
        init_prepare=partial(
            init_prepare,
            init_sample=init_sample,
            n_particles=n_particles,
            store_predicted_ensemble=store_predicted_ensemble,
        ),
        filter_prepare=partial(
            filter_prepare,
            init_sample=init_sample,
            n_particles=n_particles,
            store_predicted_ensemble=store_predicted_ensemble,
        ),
        filter_combine=partial(
            filter_combine,
            get_dynamics=get_dynamics,
            get_observations=get_observations,
            inflation=inflation,
            perturbed_obs=perturbed_obs,
            store_predicted_ensemble=store_predicted_ensemble,
            modify_cross_covariance=modify_cross_covariance,
            construct_chol_innovation_covariance=(construct_chol_innovation_covariance),
            ensemble_subspace=ensemble_subspace,
        ),
        associative=False,
    )

init_prepare(init_sample, n_particles, store_predicted_ensemble=False, key=None)

Prepare the initial state for the EnKF.

Parameters:

Name Type Description Default
init_sample InitSample

Function of a JAX random key only, generates a single sample from the initial distribution.

required
n_particles int

Number of particles.

required
store_predicted_ensemble bool

Whether to store incoming forecast ensembles.

False
key KeyArray | None

JAX random key.

None

Returns:

Type Description
EnKFState

Initial EnKF state.

Raises:

Type Description
ValueError

If key is None.

Source code in cuthbert/ensemble_kalman/ensemble_kalman_filter.py
def init_prepare(
    init_sample: InitSample,
    n_particles: int,
    store_predicted_ensemble: bool = False,
    key: KeyArray | None = None,
) -> EnKFState:
    """Prepare the initial state for the EnKF.

    Args:
        init_sample: Function of a JAX random key only, generates a single sample from
            the initial distribution.
        n_particles: Number of particles.
        store_predicted_ensemble: Whether to store incoming forecast ensembles.
        key: JAX random key.

    Returns:
        Initial EnKF state.

    Raises:
        ValueError: If key is None.
    """
    if key is None:
        raise ValueError("A JAX PRNG key must be provided.")

    # Sample ensemble from initial distribution
    keys = random.split(key, n_particles)
    ensemble = jax.vmap(init_sample)(keys)
    predicted_ensemble = dummy_tree_like(ensemble) if store_predicted_ensemble else None

    return EnKFState(
        key=key,
        ensemble=ensemble,
        model_inputs=None,
        log_normalizing_constant=jnp.array(0.0),
        predicted_ensemble=predicted_ensemble,
    )

filter_prepare(model_inputs, init_sample, n_particles, store_predicted_ensemble=False, key=None)

Prepare a state for an EnKF step.

Parameters:

Name Type Description Default
model_inputs ArrayTreeLike

Model inputs.

required
init_sample InitSample

Function of a JAX random key only, sampling from the initial distribution. Bind initial parameters when building the filter.

required
n_particles int

Number of particles.

required
store_predicted_ensemble bool

Whether to store incoming forecast ensembles.

False
key KeyArray | None

JAX random key.

None

Returns:

Type Description
EnKFState

Prepared EnKF state with dummy ensemble.

Raises:

Type Description
ValueError

If key is None.

Source code in cuthbert/ensemble_kalman/ensemble_kalman_filter.py
def filter_prepare(
    model_inputs: ArrayTreeLike,
    init_sample: InitSample,
    n_particles: int,
    store_predicted_ensemble: bool = False,
    key: KeyArray | None = None,
) -> EnKFState:
    """Prepare a state for an EnKF step.

    Args:
        model_inputs: Model inputs.
        init_sample: Function of a JAX random key only, sampling from the initial
            distribution. Bind initial parameters when building the filter.
        n_particles: Number of particles.
        store_predicted_ensemble: Whether to store incoming forecast ensembles.
        key: JAX random key.

    Returns:
        Prepared EnKF state with dummy ensemble.

    Raises:
        ValueError: If key is None.
    """
    model_inputs = tree.map(lambda x: jnp.asarray(x), model_inputs)
    if key is None:
        raise ValueError("A JAX PRNG key must be provided.")

    # Infer state shape from init_sample
    dummy_particle = jax.eval_shape(init_sample, key)
    x_dim = dummy_particle.shape[0]
    ensemble = dummy_tree_like(
        jax.ShapeDtypeStruct((n_particles, x_dim), dummy_particle.dtype)
    )
    predicted_ensemble = ensemble if store_predicted_ensemble else None

    return EnKFState(
        key=key,
        ensemble=ensemble,
        model_inputs=model_inputs,
        log_normalizing_constant=jnp.array(0.0),
        predicted_ensemble=predicted_ensemble,
    )

filter_combine(state_1, state_2, get_dynamics, get_observations, inflation=0.0, perturbed_obs=True, store_predicted_ensemble=False, modify_cross_covariance=no_covariance_modifier, construct_chol_innovation_covariance=None, ensemble_subspace=False)

Combine previous EnKF state with prepared state for current step.

Implements the EnKF predict + update cycle.

Parameters:

Name Type Description Default
state_1 EnKFState

EnKF state from the previous time step.

required
state_2 EnKFState

EnKF state prepared for the current step.

required
get_dynamics GetEnKFDynamics

Function to get dynamics function and chol_Q from model inputs.

required
get_observations GetEnKFObservations

Function to get observation function, chol_R, and y from model inputs.

required
inflation float

Multiplicative inflation factor.

0.0
perturbed_obs bool

If True, use perturbed observations.

True
store_predicted_ensemble bool

Whether to store the incoming forecast ensemble.

False
modify_cross_covariance ModifyCrossCovariance

Function that modifies the empirical state-observation cross-covariance using the current model inputs. Defaults to the identity.

no_covariance_modifier
construct_chol_innovation_covariance ConstructCholInnovationCovariance | None

Optional function that constructs a generalized Cholesky factor of the localized innovation covariance matrix. None (default) uses the standard, unlocalized form of the ensemble Kalman update.

None
ensemble_subspace bool

If True, perform the analysis in the ensemble subspace.

False

Returns:

Type Description
EnKFState

Updated EnKF state.

Source code in cuthbert/ensemble_kalman/ensemble_kalman_filter.py
def filter_combine(
    state_1: EnKFState,
    state_2: EnKFState,
    get_dynamics: GetEnKFDynamics,
    get_observations: GetEnKFObservations,
    inflation: float = 0.0,
    perturbed_obs: bool = True,
    store_predicted_ensemble: bool = False,
    modify_cross_covariance: ModifyCrossCovariance = no_covariance_modifier,
    construct_chol_innovation_covariance: ConstructCholInnovationCovariance
    | None = None,
    ensemble_subspace: bool = False,
) -> EnKFState:
    """Combine previous EnKF state with prepared state for current step.

    Implements the EnKF predict + update cycle.

    Args:
        state_1: EnKF state from the previous time step.
        state_2: EnKF state prepared for the current step.
        get_dynamics: Function to get dynamics function and chol_Q from model inputs.
        get_observations: Function to get observation function, chol_R, and y from model inputs.
        inflation: Multiplicative inflation factor.
        perturbed_obs: If True, use perturbed observations.
        store_predicted_ensemble: Whether to store the incoming forecast ensemble.
        modify_cross_covariance: Function that modifies the empirical
            state-observation cross-covariance using the current model inputs.
            Defaults to the identity.
        construct_chol_innovation_covariance: Optional function that
            constructs a generalized Cholesky factor of the localized innovation
            covariance matrix. ``None`` (default) uses the standard, unlocalized
            form of the ensemble Kalman update.
        ensemble_subspace: If True, perform the analysis in the ensemble subspace.

    Returns:
        Updated EnKF state.
    """
    key_pred, key_update, key_next = random.split(state_1.key, 3)

    # Predict
    dynamics_fn = get_dynamics(state_2.model_inputs)
    predicted = enkf_lib.predict(
        key_pred,
        state_1.ensemble,
        dynamics_fn,
        inflation,
    )

    # Update
    observation_fn, chol_R, y = get_observations(state_2.model_inputs)
    # The ensemble-subspace path rejects any non-default modifier by identity, so pass
    # the library default straight through rather than a partial that merely wraps it.
    # `build_filter` has already checked that no real modifier was supplied.
    cross_covariance_modifier = (
        enkf_lib.no_covariance_modifier
        if ensemble_subspace
        else partial(modify_cross_covariance, model_inputs=state_2.model_inputs)
    )
    construct_chol_S = (
        None
        if construct_chol_innovation_covariance is None
        else partial(
            construct_chol_innovation_covariance,
            model_inputs=state_2.model_inputs,
        )
    )

    updated, ll = enkf_lib.filter_update(
        key_update,
        predicted,
        observation_fn,
        chol_R,
        y,
        perturbed_obs,
        cross_covariance_modifier=cross_covariance_modifier,
        construct_chol_innovation_covariance=construct_chol_S,
        ensemble_subspace=ensemble_subspace,
    )

    return EnKFState(
        key=key_next,
        ensemble=updated,
        model_inputs=state_2.model_inputs,
        log_normalizing_constant=state_1.log_normalizing_constant + ll,
        predicted_ensemble=predicted if store_predicted_ensemble else None,
    )