Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Ensemble Kalman Filter from Scratch

This notebook builds an Ensemble Kalman Filter (EnKF) step by step, using gaussx ensemble primitives on the chaotic Lorenz-63 system.

What you’ll learn:

  1. Computing empirical covariance as a structured LowRankUpdate operator with gaussx.ensemble_covariance
  2. Computing cross-covariance between state and observation ensembles with gaussx.ensemble_cross_covariance
  3. Numerically stable covariance updates with gaussx.joseph_update
  4. Deriving process noise from a stationary covariance with gaussx.process_noise_covariance

Background

The Ensemble Kalman Filter (Evensen, 1994) is a Monte Carlo approximation to the Kalman filter designed for high-dimensional, nonlinear systems. Instead of propagating a mean and covariance matrix explicitly, it maintains an ensemble of JJ particles {x(j)}j=1J\{x^{(j)}\}_{j=1}^{J} that collectively represent the state distribution.

The key idea: at each step the forecast covariance is estimated empirically from the ensemble spread, so no linearization (tangent linear model) is needed. The update step then applies the standard Kalman gain formula using these empirical statistics.

EnKF algorithm sketch

Forecast: Propagate each particle through the (possibly nonlinear) dynamics xt∣t−1(j)=f(xt−1∣t−1(j))+qt(j)x^{(j)}_{t|t-1} = f(x^{(j)}_{t-1|t-1}) + q^{(j)}_t.

Analysis:

  1. Compute ensemble covariance: P^=1J∑j(x(j)−xˉ)(x(j)−xˉ)⊤\hat{P} = \frac{1}{J}\sum_j (x^{(j)} - \bar{x})(x^{(j)} - \bar{x})^\top
  2. Compute cross-covariance between state and predicted observations: Cxy=1J∑j(x(j)−xˉ)(y(j)−yˉ)⊤C_{xy} = \frac{1}{J}\sum_j (x^{(j)} - \bar{x})(y^{(j)} - \bar{y})^\top
  3. Compute innovation covariance: Cyy=1J∑j(y(j)−yˉ)(y(j)−yˉ)⊤+RC_{yy} = \frac{1}{J}\sum_j (y^{(j)} - \bar{y})(y^{(j)} - \bar{y})^\top + R
  4. Kalman gain: K=Cxy Cyy−1K = C_{xy} \, C_{yy}^{-1}
  5. Perturbed-observation update: xt∣t(j)=xt∣t−1(j)+K(yt+ϵ(j)−Hxt∣t−1(j))x^{(j)}_{t|t} = x^{(j)}_{t|t-1} + K(y_t + \epsilon^{(j)} - H x^{(j)}_{t|t-1})

Define the Lorenz-63 system

The Lorenz-63 system is a classic chaotic dynamical system:

dxdt=σ(y−x)\frac{dx}{dt} = \sigma(y - x)
dydt=x(ρ−z)−y\frac{dy}{dt} = x(\rho - z) - y
dzdt=xy−βz\frac{dz}{dt} = xy - \beta z

With the standard parameters σ=10\sigma = 10, ρ=28\rho = 28, β=8/3\beta = 8/3, the system exhibits the famous butterfly attractor. We discretize with a 4th-order Runge-Kutta integrator.

One RK4 step:  [1. 1. 1.]  ->  [1.01256719 1.2599178  0.98489097]

Generate the true trajectory and observations

We observe only the xx and yy components of the Lorenz state with additive Gaussian noise. The zz component must be inferred.

True trajectory shape: (500, 3)
Observations shape: (500, 2)

Initialize the ensemble

We draw J=50J = 50 particles from a Gaussian centered at the true initial state with moderate uncertainty. In practice the initial ensemble would come from a climatological prior.

Initial ensemble shape: (50, 3)
Ensemble mean: [ 1.80543028 -1.45576339 25.35649402]

Inspect the ensemble covariance operator

Before running the filter, let’s see what gaussx.ensemble_covariance returns. It produces a LowRankUpdate operator -- a structured representation of P^=(1/J)∑jδjδj⊤\hat{P} = (1/J)\sum_j \delta_j \delta_j^\top where δj=x(j)−xˉ\delta_j = x^{(j)} - \bar{x}. This is rank ≤J\leq J and avoids materializing the full d×dd \times d matrix (crucial when dd is large).

Type: LowRankUpdate
Materialized covariance:
 [[ 1.89403362  0.18874692 -0.22508983]
 [ 0.18874692  1.90614772 -0.60569166]
 [-0.22508983 -0.60569166  2.1204657 ]]

Naive covariance:
 [[ 1.89403362  0.18874692 -0.22508983]
 [ 0.18874692  1.90614772 -0.60569166]
 [-0.22508983 -0.60569166  2.1204657 ]]
Max difference: 8.881784197001252e-16

Run the Ensemble Kalman Filter

The main loop: forecast each particle through the nonlinear Lorenz dynamics, then perform the EnKF analysis update using gaussx ensemble primitives.

We add a small amount of process noise (additive inflation) to prevent ensemble collapse, a common practical technique.

EnKF means shape: (500, 3)
EnKF ensembles shape: (500, 50, 3)

Joseph-form covariance update

The standard EnKF implicitly updates the covariance via the ensemble. For diagnostics or hybrid filters, one sometimes needs the explicit updated covariance. The Joseph form

Pupd=(I−KH) Ppred (I−KH)⊤+KRK⊤P_{\text{upd}} = (I - KH)\,P_{\text{pred}}\,(I - KH)^\top + K R K^\top

is numerically more stable than the naive (I−KH)Ppred(I - KH) P_{\text{pred}} formula. Let’s demonstrate gaussx.joseph_update on the final step.

Joseph-form updated covariance:
 [[ 0.14559597  0.23077515  0.00331839]
 [ 0.23077515  0.44376559 -0.01157417]
 [ 0.00331839 -0.01157417  0.16298755]]

Naive updated covariance:
 [[ 0.14559597  0.23077515  0.00331839]
 [ 0.23077515  0.44376559 -0.01157417]
 [ 0.00331839 -0.01157417  0.16298755]]

Joseph form is symmetric: True

Process noise from stationary covariance

For a linearized version of the Lorenz system, we can compute the Jacobian at a fixed point and use gaussx.process_noise_covariance to derive the process noise QQ that is consistent with a given stationary covariance P∞P_\infty:

Q=P∞−A P∞ A⊤Q = P_\infty - A \, P_\infty \, A^\top

This is useful when you know the long-run statistics but need the step-to-step noise for a filter.

Linearized transition A:
 [[0.9        0.1        0.        ]
 [0.28       0.99       0.        ]
 [0.         0.         0.97333333]]

Empirical stationary covariance Pinf:
 [[57.3357036  57.32115367 -0.2265445 ]
 [57.32115367 77.09044449  1.01167262]
 [-0.2265445   1.01167262 79.84132981]]

Implied process noise Q:
 [[ -0.19492842 -17.43753787  -0.12656099]
 [-17.43753787 -34.73986691   0.09856581]
 [ -0.12656099   0.09856581   4.2014282 ]]
Q is symmetric: True

Plot: 3D Lorenz attractor

The true trajectory, ensemble mean estimate, and ensemble spread projected onto the Lorenz attractor.

<Figure size 1000x800 with 1 Axes>

Plot: time series with ensemble spread

Each state component over time, showing the true value, ensemble mean, and ±2σ\pm 2\sigma spread computed from the ensemble.

<Figure size 1200x800 with 3 Axes>

Plot: RMSE over time

<Figure size 1200x300 with 1 Axes>
Mean RMSE: 0.320
Final RMSE: 0.435

Summary

  • gaussx.ensemble_covariance(particles) returns a LowRankUpdate operator representing the empirical covariance. This structured representation avoids materializing a full d×dd \times d matrix and enables efficient Woodbury-based solves when the ensemble size J≪dJ \ll d.

  • gaussx.ensemble_cross_covariance(particles_theta, particles_G) computes the cross-covariance matrix between two ensembles, giving the (d1,d2)(d_1, d_2) array needed for the Kalman gain.

  • gaussx.joseph_update(P_pred, K, H, R) provides the numerically stable Joseph-form covariance update, guaranteeing symmetry even when the Kalman gain is approximate.

  • gaussx.process_noise_covariance(A, Pinf) derives the process noise Q=P∞−AP∞A⊤Q = P_\infty - A P_\infty A^\top from a stationary covariance, useful when calibrating a filter from long-run statistics.

Together these primitives let you build ensemble Kalman methods that are fully compatible with JAX transforms (jit, vmap, grad) while preserving structured linear algebra where it matters.

References

  • Evensen, G. (1994). Sequential data assimilation with a nonlinear quasi-geostrophic model using Monte Carlo methods to forecast error statistics. J. Geophys. Res., 99(C5), 10143--10162.
  • Lorenz, E. N. (1963). Deterministic nonperiodic flow. J. Atmos. Sci., 20(2), 130--141.
  • Bucy, R. S. & Joseph, P. D. (1968). Filtering for Stochastic Processes with Applications to Guidance. Interscience.
  • Sarkka, S. (2013). Bayesian Filtering and Smoothing. Cambridge University Press.