Ensemble Kalman Filter (EnKF): Theory and Python Implementation for High-Dimensional Data Assimilation

A from-scratch numpy implementation of the Ensemble Kalman Filter (EnKF). We measure how EnKF's RMSE converges to the analytic Kalman filter as ensemble size N grows (O(1/sqrt(N)) sampling error), benchmark against UKF and the particle filter on the 40-dimensional Lorenz-96 model, and reproduce filter divergence from an undersized ensemble along with its fix via covariance inflation.

What is the Ensemble Kalman Filter?

In Particle Filter Python Implementation, we noted that particle filters can handle arbitrary nonlinear, non-Gaussian state-space models but face the curse of dimensionality: the number of particles required grows exponentially with state dimension. The Unscented Kalman Filter (UKF) mitigates this by handling nonlinearity through deterministic sigma points (\(2n+1\) of them for state dimension \(n\) ), but the number of sigma points scales linearly with \(n\) , and the covariance update requires an \(O(n^3)\) Cholesky decomposition.

The Ensemble Kalman Filter (EnKF) sits between these two approaches. Like the particle filter, it represents the probability distribution with a Monte Carlo “ensemble” of samples, but it performs no weighting or importance resampling — instead it applies the same update equations as the linear Kalman filter, using the ensemble’s sample mean and sample covariance. The ensemble size \(N\) is a free parameter chosen by the user and, unlike the UKF’s sigma points, is not tied to the state dimension \(n\) . This property makes EnKF a standard data assimilation technique used in operational weather forecasting and ocean data assimilation, where the state dimension can reach \(10^6\) –\(10^9\) .

EnKF was introduced by Geir Evensen in 1994. It has since developed as a middle ground between the particle filter (which also uses Monte Carlo sampling for covariance approximation) and the UKF or nonlinear Kalman smoothers (which use the Gaussian-approximation Kalman update).

State-Space Model

Consider a general nonlinear state-space model:

\[ x_k = f(x_{k-1}) + w_{k-1}, \quad w_{k-1} \sim \mathcal{N}(0, Q) \tag{1} \] \[ y_k = h(x_k) + v_k, \quad v_k \sim \mathcal{N}(0, R) \tag{2} \]

where \(f\) is the state transition function, \(h\) is the observation function, and \(w_{k-1}, v_k\) are zero-mean Gaussian process and observation noise with covariances \(Q, R\) . Unlike the particle filter, EnKF assumes the noise is Gaussian (as do the UKF and the Kalman filter). \(f\) and \(h\) may be nonlinear.

Algorithm Derivation

Forecast Step: Ensemble Propagation

Given the analysis (post-update) ensemble at time \(k-1\) , \(\{x_{k-1}^{a,(i)}\}_{i=1}^{N}\) , propagate each member independently through the state transition model:

\[ x_k^{f,(i)} = f(x_{k-1}^{a,(i)}) + w_{k-1}^{(i)}, \quad w_{k-1}^{(i)} \sim \mathcal{N}(0, Q), \quad i = 1, \ldots, N \tag{3} \]

Compute the ensemble mean and the sample covariance (using the unbiased \(N-1\) divisor):

\[ \bar{x}_k^f = \frac{1}{N} \sum_{i=1}^{N} x_k^{f,(i)} \tag{4} \] \[ P_k^f = \frac{1}{N-1} \sum_{i=1}^{N} \left(x_k^{f,(i)} - \bar{x}_k^f\right)\left(x_k^{f,(i)} - \bar{x}_k^f\right)^T \tag{5} \]

Equations (4)-(5) approximate the linear Kalman filter’s predicted covariance \(P_{k|k-1} = A P_{k-1|k-1} A^T + Q\) (Eq. 4 in our Kalman filter article) using the ensemble’s Monte Carlo sample statistics. Even if \(f\) is nonlinear, we simply push each ensemble member through \(f\) — no Jacobian (as in EKF) or sigma-point generation (as in UKF) is needed.

Analysis Step: Ensemble Update with Perturbed Observations

The key idea in the EnKF analysis step is that the observation \(y_k\) is not applied identically to every member. Instead, each member receives an independent noise perturbation, forming perturbed observations. Burgers, van Leeuwen & Evensen (1998) showed this is necessary for the updated ensemble’s covariance to match the true analysis covariance.

\[ y_k^{(i)} = y_k + v_k^{(i)}, \quad v_k^{(i)} \sim \mathcal{N}(0, R), \quad i = 1, \ldots, N \tag{6} \]

Transform each forecast member through the observation function:

\[ z_k^{f,(i)} = h\left(x_k^{f,(i)}\right) \tag{7} \] \[ \bar{z}_k^f = \frac{1}{N} \sum_{i=1}^{N} z_k^{f,(i)} \tag{8} \]

Compute the state-observation cross-covariance and the observation (innovation) covariance as ensemble statistics:

\[ P_k^{xz} = \frac{1}{N-1} \sum_{i=1}^{N} \left(x_k^{f,(i)} - \bar{x}_k^f\right)\left(z_k^{f,(i)} - \bar{z}_k^f\right)^T \tag{9} \] \[ P_k^{zz} = \frac{1}{N-1} \sum_{i=1}^{N} \left(z_k^{f,(i)} - \bar{z}_k^f\right)\left(z_k^{f,(i)} - \bar{z}_k^f\right)^T + R \tag{10} \]

Ensemble Kalman gain:

\[ K_k = P_k^{xz} \left(P_k^{zz}\right)^{-1} \tag{11} \]

Update each member individually:

\[ x_k^{a,(i)} = x_k^{f,(i)} + K_k\left(y_k^{(i)} - z_k^{f,(i)}\right), \quad i = 1, \ldots, N \tag{12} \]

Equations (9)-(12) mirror the Kalman filter update equations (Eq. 5-9) exactly. The only difference is that instead of computing \(H P_{k|k-1} H^T\) and \(H P_{k|k-1}\) analytically, we approximate them with the ensemble sample covariances \(P_k^{zz}, P_k^{xz}\) . Even if \(h\) is nonlinear, Eq. (7) only requires pushing ensemble members through \(h\) — conceptually similar to the UKF’s sigma-point transform, except the sigma points are \(2n+1\) deterministic points while the EnKF ensemble is \(N\) random points, and \(N\) can be chosen independently of the dimension \(n\) .

Python Implementation

We implement Eq. (3)-(12) directly in numpy.

import numpy as np

def enkf_step(ensemble, f, h, Q, R, z, rng, inflation=1.0):
    """One step of the perturbed-observation EnKF (Eq. 3-12).

    ensemble : (N, n) each row is an ensemble member
    f, h     : state transition / observation functions (single member in/out)
    Q, R     : process / observation noise covariance
    z        : observation vector (m,)
    inflation: multiplicative covariance inflation factor (1.0 = no inflation)
    """
    N, n = ensemble.shape
    m = R.shape[0]

    # ---- Forecast step (Eq. 3-5) ----
    Xf = np.array([f(ensemble[i]) for i in range(N)])
    Xf += rng.multivariate_normal(np.zeros(n), Q, size=N)
    xf_mean = Xf.mean(axis=0)

    # Multiplicative covariance inflation: inflate deviations around the mean
    if inflation != 1.0:
        Xf = xf_mean + inflation * (Xf - xf_mean)

    # ---- Analysis step (Eq. 6-12) ----
    Zf = np.array([h(Xf[i]) for i in range(N)])
    zf_mean = Zf.mean(axis=0)

    Ex = Xf - xf_mean
    Ez = Zf - zf_mean
    Pxz = (Ex.T @ Ez) / (N - 1)          # Eq. 9
    Pzz = (Ez.T @ Ez) / (N - 1) + R      # Eq. 10

    K = Pxz @ np.linalg.inv(Pzz)         # Eq. 11 (ensemble Kalman gain)

    Y = z + rng.multivariate_normal(np.zeros(m), R, size=N)  # Eq. 6: perturbed obs
    Xa = Xf + (Y - Zf) @ K.T             # Eq. 12

    return Xa


def run_enkf(x0_ensemble, f, h, Q, R, observations, rng, inflation=1.0):
    """Run EnKF sequentially over an observation series, returning ensemble means."""
    ensemble = x0_ensemble.copy()
    n = x0_ensemble.shape[1]
    means = np.zeros((len(observations), n))
    for k, z in enumerate(observations):
        ensemble = enkf_step(ensemble, f, h, Q, R, z, rng, inflation=inflation)
        means[k] = ensemble.mean(axis=0)
    return means

For comparison, we also implement the standard (linear) Kalman filter that provides the analytic solution (identical to the implementation in the Kalman filter article).

class KalmanFilter:
    """Linear Kalman Filter (analytic solution, used as the EnKF convergence target)"""

    def __init__(self, A, H, Q, R, x0, P0):
        self.A, self.H, self.Q, self.R = A, H, Q, R
        self.x, self.P = x0.copy(), P0.copy()

    def predict(self):
        self.x = self.A @ self.x
        self.P = self.A @ self.P @ self.A.T + self.Q
        return self.x.copy()

    def update(self, z):
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.P = (np.eye(len(self.x)) - K @ self.H) @ self.P
        return self.x.copy()

Experiment 1: Convergence to the KF in a Low-Dimensional Linear-Gaussian Problem

In a linear-Gaussian model, EnKF should converge to the standard Kalman filter’s analytic solution as \(N \to \infty\) . We verify this on a 4-dimensional (position and velocity along two axes) constant-velocity model. The state transition matrix \(A\) and observation matrix \(H\) (position only) follow the same structure as the 2D version used in the Kalman filter article.

We fix a single observation sequence, compute the standard KF’s (deterministic) analytic solution once, then run EnKF for ensemble sizes \(N \in \{10, 20, 50, 200, 1000\}\) across 40 random seeds each, averaging the RMSE between the EnKF estimate and the KF analytic solution.

Ns = [10, 20, 50, 200, 1000]
n_trials = 40
results = {}
for N in Ns:
    rmses = []
    for trial in range(n_trials):
        rng = np.random.default_rng(1000 * N + trial)
        ens0 = x0 + rng.multivariate_normal(np.zeros(n), P0, size=N)
        enkf_means = run_enkf(ens0, f, h, Q, R, observations, rng)
        rmse = np.sqrt(np.mean((enkf_means[:, :2] - kf_means[:, :2]) ** 2))
        rmses.append(rmse)
    results[N] = (np.mean(rmses), np.std(rmses))
    print(f"N={N}: EnKF-vs-KF position RMSE = {np.mean(rmses):.5f}")

# fit RMSE ~ C / sqrt(N)
C = np.mean([results[N][0] * np.sqrt(N) for N in Ns])

Results (position-component RMSE, averaged over 40 trials):

Ensemble size \(N\)EnKF-vs-KF RMSEStd. dev. (40 trials)\(C/\sqrt{N}\) fit
100.357870.051090.28667
200.214730.026430.20270
500.117060.009130.12820
2000.057290.005090.06410
10000.025380.001690.02867

The fitted coefficient is \(C = 0.9065\) . As \(N\) increases 100-fold from 10 to 1000, the RMSE drops from \(0.358\) to \(0.025\) — a factor of roughly 14.1, close to the \(\sqrt{100} = 10\) order predicted by the theory. This confirms the classic Monte Carlo result that sampling error decays as \(O(1/\sqrt{N})\) — and also illustrates the slow convergence: a 100x larger ensemble buys only a 10x reduction in error. For reference, the standard KF’s own RMSE against the true state is 0.4990; the EnKF-vs-KF residual at \(N=1000\) (0.0254) is far smaller than this, meaning a large ensemble is effectively indistinguishable from the analytic KF.

Relationship between ensemble size N and EnKF sampling error. Left: RMSE converges to the KF analytic solution as O(1/sqrt(N)). Right: even an N=20 EnKF produces a trajectory nearly matching the analytic solution

Experiment 2: High-Dimensional Data Assimilation with the Lorenz-96 Model

EnKF’s practical strength lies in its behavior in high dimensions. We test it on the Lorenz-96 model (40 variables, chaotic dynamics), a standard testbed for weather data assimilation.

\[ \frac{dx_j}{dt} = (x_{j+1} - x_{j-2})x_{j-1} - x_j + F, \quad j = 1, \ldots, n \tag{13} \]

We use \(F=8\) (chaotic regime) and \(n=40\) , integrated with 4th-order Runge-Kutta (\(\Delta t = 0.05\) ). Only the 20 even-indexed components are observed (partial observation, \(R = I_{20}\) ), and the true trajectory is generated with process noise \(Q = 0.01 I_{40}\) .

def lorenz96_step(x, dt=0.05, F=8.0):
    """One RK4 integration step of the Lorenz-96 model (Eq. 13)."""
    def rhs(x):
        return (np.roll(x, -1) - np.roll(x, 2)) * np.roll(x, 1) - x + F
    k1 = rhs(x)
    k2 = rhs(x + 0.5 * dt * k1)
    k3 = rhs(x + 0.5 * dt * k2)
    k4 = rhs(x + dt * k3)
    return x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)

We ran EnKF (ensemble size \(N=40\) , multiplicative covariance inflation 1.10), UKF (\(n=40\) , so \(2n+1=81\) sigma points), and a bootstrap particle filter (same structure as in the particle filter article, with systematic resampling) on the identical 200-step observation series.

Results (full-state RMSE over 200 steps):

MethodSample / sigma-point countFull-state RMSELast-100-step RMSEWall time
EnKF\(N=40\)2.22100.63920.58s
EnKF (undersized, no localization)\(N=20\)4.3268--
UKF\(2n+1=81\)0.8782-1.30s
Particle filter\(N=20\)5.2875-0.25s
Particle filter\(N=1000\)5.2371-11.34s

(For reference, the true trajectory’s climatological standard deviation is 3.6792. A method whose RMSE exceeds this is doing worse than simply answering the climatological mean without using any observations.)

EnKF with \(N=40\) tracks the system well once past the initial transient, reaching a last-100-step RMSE of 0.6392 — on par with or better than UKF’s 0.8782. It achieves this using only 40 samples versus UKF’s 81 sigma points (roughly half), and runs in 0.58s versus UKF’s 1.30s (about 2.3x faster). However, shrinking the ensemble to \(N=20\) with the same inflation degrades the RMSE to 4.33, close to the climatological standard deviation (3.68). This reflects “spurious correlations from rank-deficient sample covariance” that inflation alone cannot fix — operational EnKF implementations address this with covariance localization, which is beyond the scope of this article.

By contrast, the particle filter fails to track the system at either \(N=20\) or \(N=1000\) , with RMSE (5.29, 5.24) both close to the climatological standard deviation. Measuring the effective sample size (ESS), we find a mean ESS of only \(3.48\) at \(N=20\) (nearly all weight concentrated on a handful of particles), and even at \(N=1000\) the mean ESS only reaches \(20.12\) . Against a 40-dimensional observation likelihood, the weight variance explodes and most particles are effectively discarded — classic weight degeneracy. This is a direct experimental confirmation of the “curse of dimensionality” discussed in our particle filter article: EnKF’s robustness in high dimensions stems precisely from updating based on summary statistics (ensemble mean and covariance) rather than evaluating and weighting each member’s individual likelihood.

EnKF and UKF tracking results and RMSE evolution on the 40-dimensional Lorenz-96 model

Experiment 3: Covariance Inflation and Filter Divergence

When the ensemble size is small, sampling error causes the sample covariance to underestimate the true forecast uncertainty, making the filter overconfident in its own prediction and increasingly dismissive of new observations. If this continues, the estimate keeps drifting away from the true state while the covariance stays artificially small — a phenomenon known as filter divergence. We reproduce this using a smaller, 10-variable Lorenz-96 model (\(n=10\) , 5 observed components) with ensemble size \(N=8\) (about 80% of the dimension, a setting prone to rank-deficient sample covariance).

for k, z in enumerate(observations):
    ensemble = enkf_step(ensemble, f, h, Q, R, z, rng, inflation=inflation)
    # compare inflation=1.0 (no inflation) vs inflation=1.05 (multiplicative)

RMSE every 20 steps, with no inflation (inflation=1.0) versus multiplicative covariance inflation of 1.05 (deviations scaled by 1.05 immediately after Eq. 3):

Time step \(k\)RMSE (no inflation)RMSE (inflation=1.05)
05.2125.149
201.6731.875
400.8691.368
600.5040.870
800.9241.417
1000.6011.332
1202.0710.505
1402.3951.606
1603.6311.932
1804.8752.012

Without inflation, the filter tracks well through roughly \(k=60\) (RMSE around 0.5), but from \(k=120\) onward the RMSE grows monotonically, reaching 4.875 by \(k=180\) — worse than the system’s climatological standard deviation of 3.7134, meaning the filter has become worse than a climatological guess that ignores observations entirely: textbook filter divergence. This is the self-reinforcing pattern where rank-deficient sample covariance underestimates uncertainty, and the filter progressively trusts observations less and less. With multiplicative inflation of 1.05 applied, RMSE stays at or below 2.0 throughout, with no divergence.

Averaged over all 200 steps, the overall RMSE is 2.313 without inflation versus 1.658 with 1.05x inflation (about 28% improvement); restricted to the last 50 steps, it improves from 3.686 to 1.783 (about 52% improvement) — clear numerical evidence that covariance inflation suppresses filter divergence. That said, more inflation is not always better: in this experiment the optimum was around 1.02-1.05, and 1.10 actually caused divergence to re-emerge — the inflation factor is a hyperparameter that requires tuning.

Comparison of filter divergence with and without covariance inflation at ensemble size N=8. Top: RMSE evolution (no inflation diverges in the second half; inflation=1.05 stays stable). Bottom: ensemble covariance trace (log scale)

Choosing Among Filter Methods

We compare EnKF against the Kalman-family filters covered elsewhere on this blog, along the axes of linearity, dimensionality, and computational cost.

PropertyKFEKFUKFEnKF (this article)Particle filter
Nonlinearity handlingNone (linear only)1st-order linearizationSigma points (2nd-order accurate)Ensemble covariance approximationArbitrary (no distributional assumption)
Distributional assumptionGaussianGaussianGaussianGaussianArbitrary
Representative points / samples--\(2n+1\) (deterministic)\(N\) (stochastic, freely chosen)\(N\) (stochastic, freely chosen)
High-dimensional scalability\(O(n^3)\) (covariance ops)\(O(n^3)\) (Jacobian + covariance)\(O(n^3)\) , sample count scales with \(n\)Sample count independent of \(n\)Curse of dimensionality (\(N\) grows exponentially)
MultimodalityNoNoNoNo (Gaussian approximation)Yes
Typical applicationLinear control, navigationMild nonlinearityModerate nonlinearity, moderate dimensionWeather / ocean data assimilation (very high dimension)Low-dimensional, strongly nonlinear, non-Gaussian

EnKF shares the particle filter’s idea of replacing covariance propagation with a Monte Carlo ensemble approximation, but its update equations are the same Gaussian-approximation Kalman update as the linear Kalman filter. This compromise sacrifices the ability to represent multimodal posteriors, but avoids both the UKF’s constraint that sigma-point count scales with dimension and the particle filter’s curse of dimensionality — making it viable for ultra-high-dimensional problems like numerical weather prediction, where the state dimension can reach the millions. Conversely, in low-dimensional problems with strong nonlinearity or non-Gaussianity, EnKF still carries sampling error, as Experiments 1 and 2 above show, so UKF or the particle filter can be preferable.

A well-regarded Japanese-language book covering Kalman filter derivations through implementation examples. It provides a solid foundation for understanding how EnKF’s update equations (Eq. 9-12) correspond to the linear Kalman filter’s update equations (Eq. 5-9).

The above is an Amazon Associates link.

References

  • Evensen, G. (1994). “Sequential Data Assimilation with a Nonlinear Quasi-Geostrophic Model Using Monte Carlo Methods to Forecast Error Statistics.” Journal of Geophysical Research, 99(C5), 10143-10162.
  • Burgers, G., van Leeuwen, P. J., & Evensen, G. (1998). “Analysis Scheme in the Ensemble Kalman Filter.” Monthly Weather Review, 126(6), 1719-1724.
  • Evensen, G. (2009). Data Assimilation: The Ensemble Kalman Filter (2nd ed.). Springer.
  • Lorenz, E. N. (1996). “Predictability: A Problem Partly Solved.” Proceedings of the Seminar on Predictability, ECMWF.
  • Houtekamer, P. L., & Mitchell, H. L. (1998). “Data Assimilation Using an Ensemble Kalman Filter Technique.” Monthly Weather Review, 126(3), 796-811.