Weak-constraint 4D-Var: when the model is allowed to be wrong
Strong-constraint 4D-Var treats the model as exact and corrects only the initial state. Weak-constraint 4D-Var admits that the model makes errors, and estimates them alongside the state. This page follows the 4D-Var primer: the intuition, the probability, the mathematics, how the gradient changes, three formulations, and how it maps onto the ocean problem.
Same cost, plus a price for every model error
- The transition gets noise: \(\vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k\) with \(\gvect{\eta}_k \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_\eta)\). The cost gains \(\frac{1}{2} \sum_k \Vert \gvect{\eta}_k \Vert^2_{\gmat{\Sigma}_\eta^{-1}}\). Strong constraint is the limit \(\gmat{\Sigma}_\eta \to \mat{0}\).
- The controls grow from one state to a whole trajectory: \(D_x\) values become \(D_x (T+1)\). The analysis can now fit observations the model alone could not reach.
- The gradient is still one adjoint sweep in the forcing formulation; in the state formulation it becomes \(T\) independent pieces, parallel in time.
- \(\gmat{\Sigma}_\eta\) is the hard part. Too small and the model error leaks into the initial state; too large and the analysis chases observation noise. A basis for the model error turns \(\gmat{\Sigma}_\eta\) into a modelling choice.
- On Lorenz-96 with a biased model, strong constraint leaves a window error of 1.66; weak constraint cuts it to 0.25, and a one-number bias estimate to 0.12 while recovering the bias itself (0.99, true 1.0).
Intuition
In the 4D-Var primer's mechanical picture the model was a rigid rod: the trajectory had to be an exact model solution, so every misfit had to be blamed on the initial state. Weak constraint cuts the rod into one segment per observation interval and joins the segments with springs of stiffness \(\gmat{\Sigma}_\eta^{-1}\). The trajectory may now jump at the end of each interval, at a price.
- When the model is right, the springs are not needed: the jumps stay near zero and the result is close to strong constraint.
- When the model is wrong, strong constraint distorts the initial state to compensate, and over a long window it cannot. Weak constraint puts the blame where it belongs, at the times and places the model drifts.
- The jumps are information. Averaged over many windows, \(\gvect{\eta}^\star\) is a map of the model's systematic error.
The running example is Lorenz-96 again, now with a deliberately wrong model: the truth uses forcing \(F = 8\), the model \(F = 7\). The window is 20 observation times (twice the primer's), every second site is observed with \(\sigma_\varepsilon = 0.5\), and the background is as before.
| Lorenz-96, biased model | RMSE over the window | at \(t = 0\) | end of window | \(2\mathcal{J}^\star / N_\text{obs}\) |
|---|---|---|---|---|
| background | 3.39 | 1.01 | 3.76 | — |
| strong constraint | 1.66 | 0.59 | 2.39 | 7.6 |
| weak, forcing formulation, \(\sigma_\eta = 0.3\) | 0.25 | 0.33 | 0.39 | — |
| weak, reduced: one constant bias | 0.12 | 0.16 | 0.19 | 0.98 |
The last column is the strong-constraint primer's consistency check, with \(N_\text{obs} = 400\) observations. A value of 7.6 instead of about 1 is the classic signature of model error: the data cannot be fitted by any model trajectory.
Where it comes from
On the six-step page's terms, only the transition density changes. Strong constraint used a point mass; weak constraint uses a Gaussian around the model forecast:
\[ p(\vect{x}_0) = \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x), \] \[ p(\vect{x}_k \mid \vect{x}_{k-1}) = \mathcal{N}(f_k(\vect{x}_{k-1}), \gmat{\Sigma}_{\eta,k}), \] \[ p(\vect{y}_k \mid \vect{x}_k) = \mathcal{N}(h_k(\vect{x}_k), \gmat{\Sigma}_{\varepsilon,k}). \]The posterior is now over the whole trajectory, \(p(\vect{x}_{0:T} \mid \vect{y}_{1:T})\), and weak-constraint 4D-Var returns its mode. That is the smoothing problem: for a linear model the answer equals the Kalman smoother's mean. The two limits are worth keeping in mind. As \(\gmat{\Sigma}_\eta \to \mat{0}\) the jumps are forbidden and strong constraint returns. As \(\gmat{\Sigma}_\eta \to \infty\) consecutive states decouple and the method becomes a separate 3D-Var at each observation time, ignoring the dynamics.
The mathematics
Notation
| Symbol | Meaning | Shape | Units (ocean) |
|---|---|---|---|
| \(\vect{x}_k = \vect{x}(t_k)\) | state at observation time \(t_k\), \(k = 0, \ldots, T\) | \(\mathbb{R}^{D_x}\) (state) | m (SSH) or s⁻¹ (PV) |
| \(\gvect{\eta}_k\) | model error over interval \(k\): what the model missed between \(t_{k-1}\) and \(t_k\) | \(\mathbb{R}^{D_x}\) | state units per interval |
| \(\gmat{\Sigma}_{\eta,k}\), \(\gmat{\Sigma}_\eta\) | model-error covariance (per interval; when it is the same for every interval) | \(\mathbb{R}^{D_x \times D_x}\) | (state units per interval)² |
| \(\vect{w}_k\) | whitened model error, \(\gvect{\eta}_k = \gmat{\Sigma}_\eta^{1/2}\vect{w}_k\) | \(\mathbb{R}^{D_x}\) | dimensionless |
| \(\vect{y}_k, \gvect{\mu}_x, \gmat{\Sigma}_x, \gmat{\Sigma}_{\varepsilon,k}, f_k, \mat{F}_k, h_k, \mat{H}_k, \gvect{\xi}, \vect{x}^\dagger_k\) | as in the 4D-Var primer | — | |
| \(\varphi_p(\vect{s}), \chi_c(t), \gvect{\omega}, \gmat{\Sigma}_\omega\) | spatial and temporal model-error bases (\(p = 1, \ldots, N_\varphi\), \(c = 1, \ldots, N_\chi\)), coefficients \(\omega_{cp}\), their diagonal prior covariance | \(\mathbb{R}^{D_x \times N_\varphi}\) and \(\mathbb{R}^{T \times N_\chi}\) on the grid and times, \(\mathbb{R}^{N_\chi N_\varphi}\) (temporal, basis), \(\mathbb{R}^{N_\chi N_\varphi \times N_\chi N_\varphi}\) | — |
| \(\vect{w}_\omega\), \(\gmat{\Phi}\) | whitened coefficients, \(\gvect{\omega} = \gmat{\Sigma}_\omega^{1/2}\vect{w}_\omega\); space–time basis matrix with entries \(\chi_c(t_k)\varphi_p(\vect{s}_n)\) at grid points \(\vect{s}_n\) | \(\mathbb{R}^{N_\chi N_\varphi}\), \(\mathbb{R}^{T D_x \times N_\chi N_\varphi}\) | — |
The cost, in two equivalent forms
With the model errors as controls (the forcing formulation):
\[ \mathcal{J}(\vect{x}_0, \gvect{\eta}_{1:T}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \gvect{\eta}_k \Vert^2_{\gmat{\Sigma}_{\eta,k}^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(\vect{x}_k) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}, \] \[ \vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k. \]With the states as controls (the state formulation), substitute \(\gvect{\eta}_k = \vect{x}_k - f_k(\vect{x}_{k-1})\):
\[ \mathcal{J}(\vect{x}_0, \vect{x}_{1:T}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{x}_k - f_k(\vect{x}_{k-1}) \Vert^2_{\gmat{\Sigma}_{\eta,k}^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(\vect{x}_k) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}. \]The map between the two sets of controls is one-to-one, so the two costs have the same minima. They are different optimisation problems all the same: different conditioning, different paths, and, for a nonlinear model, possibly different local minima from the same start.
(time, obs)(time, obs)(state,)(state, state)(obs, obs) per time(state, state)(state,) → (state,)(time+1, state)(time, state)()# Shapes: N = 40 state values (state); T = 20 observation times (time); # M = 20 observed values per time (obs) def weak_cost(control, args): """Forcing formulation: control (ξ, w₁:T), with η_k = Σ_η^{1/2} w_k.""" whitened_initial, whitened_errors = control # (N,), (T, N) forward, background, prior_sqrt, error_sqrt, observations, obs_index, obs_std = args # x₀ = μ_x + Σ_x^{1/2} ξ initial = background + prior_sqrt.mv(whitened_initial) # (N,) def interval(state, whitened_error): # user (forward.step: one observation interval) # f_k(x_{k−1}) forecast = forward.step(state, forward.dt) # (N,) → (N,) # η_k = Σ_η^{1/2} w_k model_error = error_sqrt.mv(whitened_error) # (N,) # x_k = f_k(x_{k−1}) + η_k state = forecast + model_error # (N,) return state, state # x₁:T, one model run with a kick after every interval _, states = jax.lax.scan(interval, initial, whitened_errors) # → (T, N) # r_k = Σ_ε^{−1/2} (y_k − H_k x_k) residual = (observations - states[:, obs_index]) / obs_std # (T, M) # 𝒥_o = ½ ∑_k ‖r_k‖² obs_cost = 0.5 * einx.sum("time obs ->", residual**2) # → () # 𝒥_b = ½ ‖ξ‖² background_cost = 0.5 * einx.sum("state ->", whitened_initial**2) # → () # 𝒥_η = ½ ∑_k ‖η_k‖²_{Σ_η⁻¹} = ½ ∑_k ‖w_k‖² error_cost = 0.5 * einx.sum("time state ->", whitened_errors**2) # → () return background_cost + error_cost + obs_cost
Choosing \(\gmat{\Sigma}_\eta\)
- Scale. \(\gmat{\Sigma}_\eta\) is the variance of what the model gets wrong in one observation interval, so it scales with the interval length. In the example the true error is a forcing deficit of 1 per unit time, which is 0.1 per interval.
- Structure. A diagonal \(\gmat{\Sigma}_\eta\) allows any pattern of jumps, including grid-scale noise. Spatial correlations (a smooth kernel, or a basis) restrict the jumps to plausible model-error patterns.
- Random versus systematic. \(\mathcal{N}(\vect{0}, \gmat{\Sigma}_\eta)\) independent per interval describes random error. A bias, an error repeated every interval, is better written as one parameter shared over time; that is the reduced formulation below.
- Identifiability. A misfit early in the window can be explained by \(\vect{x}_0\) or by \(\gvect{\eta}_1\). \(\gmat{\Sigma}_x\) and \(\gmat{\Sigma}_\eta\) set the exchange rate; if both are poorly known, the split between them is too.
The gradient
Forcing formulation. The adjoint recursion is the strong-constraint one, run on the kicked trajectory. Because \(\gvect{\eta}_k\) enters exactly where \(\vect{x}_k\) does, its gradient comes free from the same sweep:
\[ \vect{x}^\dagger_k = \nabla_{\vect{x}_k}\mathcal{J}_{\text{o},k} + \mat{F}_{k+1}^\top \vect{x}^\dagger_{k+1}, \] \[ \nabla_{\gvect{\eta}_k}\mathcal{J} = \gmat{\Sigma}_{\eta,k}^{-1} \gvect{\eta}_k + \vect{x}^\dagger_k, \] \[ \nabla_{\vect{x}_0}\mathcal{J} = \gmat{\Sigma}_x^{-1}(\vect{x}_0 - \gvect{\mu}_x) + \mat{F}_1^\top \vect{x}^\dagger_1. \]State formulation. Each term couples only neighbouring states, so the gradient with respect to \(\vect{x}_k\) needs only intervals \(k\) and \(k+1\): \(\nabla_{\vect{x}_k}\mathcal{J} = \vect{a}_k - \mat{F}_{k+1}^\top \vect{a}_{k+1} + \nabla_{\vect{x}_k}\mathcal{J}_{\text{o},k}\), with \(\vect{a}_k = \gmat{\Sigma}_{\eta,k}^{-1}(\vect{x}_k - f_k(\vect{x}_{k-1}))\). All \(T\) model runs and adjoints are independent. Everything on the primer's gradient page still applies inside each interval: checkpointing, diffrax adjoints, the adjoint test.
# Shapes: N state values (state); T observation times (time) # --- forcing formulation: one backward sweep gives every gradient --- # x†_k = ∇_{x_k} 𝒥_{o,k} + F_{k+1}ᵀ x†_{k+1}, as in strong-constraint 4D-Var def backward(carry, inputs): state_prev, obs_forcing = inputs # x†_k = ∇_{x_k} 𝒥_{o,k} + F_{k+1}ᵀ x†_{k+1} lam = obs_forcing + carry # (N,) _, interval_vjp = jax.vjp(lambda s: forward.step(s, forward.dt), state_prev) # F_kᵀ x†_k, handed to the previous time (carry,) = interval_vjp(lam) # (N,) → (N,) return carry, lam grad_initial, lams = jax.lax.scan( # → (N,), (T, N) backward, jnp.zeros(N), (previous, obs_forcing), reverse=True ) # ∇_{w_k} 𝒥 = w_k + (Σ_η^{1/2})ᵀ x†_k: the kick at time k feels the same x†_k as x_k grad_errors = whitened_errors + jax.vmap(error_sqrt.T.mv)(lams) # (T, N) # ∇_ξ 𝒥 = ξ + (Σ_x^{1/2})ᵀ F_1ᵀ x†_1 grad_initial = whitened_initial + prior_sqrt.T.mv(grad_initial) # (N,) # --- state formulation: every interval differentiated at once --- # f_k(x_{k−1}) for k = 1…T, all in parallel forecasts, interval_vjps = jax.vmap( # (T, N) → (T, N) lambda s: jax.vjp(lambda u: forward.step(u, forward.dt), s) )(previous) # a_k = Σ_η⁻¹ (x_k − f_k(x_{k−1})) scaled = jax.vmap(error_cov_inv.mv)(states - forecasts) # (T, N) # ∇_{x_k} 𝒥 ∋ a_k − F_{k+1}ᵀ a_{k+1} + ∇_{x_k} 𝒥_{o,k}: local in time, no sweep (pulled,) = jax.vmap(lambda f, a: f(a))(interval_vjps, scaled) # (T, N)
| Gradient on Lorenz-96 (20 intervals, CPU) | Time | Working memory |
|---|---|---|
| forcing form (sequential scan) | 1.68 ms | 542 KB |
| state form (vmap over intervals) | 0.64 ms | 625 KB |
Even on one CPU the state formulation's gradient is about 2.6 times faster, because the 20 intervals run as one batched call instead of a chain. On several devices the gap grows with \(T\).
Formulation 1 · Model errors as controls (forcing)
per iteration one forward run with kicks + one adjoint run
Idea. Keep the strong-constraint machinery and add a kick \(\gvect{\eta}_k\) after every interval. Whitening both \(\vect{x}_0\) and \(\gvect{\eta}_k\) makes every prior term \(\frac{1}{2} \Vert \cdot \Vert^2\). This is what vardax's WeakFourDVar implements.
(state,)(time, state)(time, obs)(state,)(), (state,) + (time, state)# Shapes: N state values (state); T observation times (time) # not pinned (optimistix) # (ξ⋆, w⋆) = argmin 𝒥(ξ, w): L-BFGS over N(T+1) controls solution = optx.minimise( # → ((N,), (T, N)) weak_cost, optx.BFGS(rtol=1e-6, atol=1e-6), (jnp.zeros(N), jnp.zeros((T, N))), args=args, max_steps=3000, ) whitened_initial, whitened_errors = solution.value # η_k⋆ = Σ_η^{1/2} w_k⋆: the model-error estimate, one field per interval model_errors = jax.vmap(error_sqrt.mv)(whitened_errors) # (T, N) # --- the same through vardax --- # today (vardax) # control (x₀, η₁:T) in physical units; Σ_η⁻¹ applied by a linear solve per step weak = vardax.WeakFourDVar( forward, vardax.MaskedIdentity(), background, # (N,) prior_cov, # (N, N) PSD operator, Σ_x obs_cov, # (N, N) PSD operator, Σ_ε model_err_cov, # (N, N) PSD operator, Σ_η ) # today (vardax) analysis_initial, model_errors = weak(batch) # → (1, N), (1, T, N)
The size of \(\gmat{\Sigma}_\eta = \sigma_\eta^2 \mat{I}\) decides everything. Sweeping \(\sigma_\eta\) on the same window:
| \(\sigma_\eta\) (per interval) | Evaluations | \(\mathcal{J}^\star\) | RMSE \(t = 0\) | RMSE window | RMSE end |
|---|---|---|---|---|---|
| 0.03 | 418 | 803 | 0.57 | 1.13 | 2.64 |
| 0.1 | 1111 | 244 | 0.39 | 0.28 | 0.45 |
| 0.3 | 694 | 148 | 0.33 | 0.25 | 0.39 |
| 1.0 | 2698 | 52 | 0.47 | 0.37 | 0.50 |
Too small (\(\sigma_\eta = 0.03\)) and the method behaves almost like strong constraint. Around the true error size (0.1 to 0.3) the window error falls six-fold. Too large (\(\sigma_\eta = 1\)) and the jumps start absorbing observation noise, the error rises again, and the problem becomes harder to solve (more than 2,600 evaluations). Weakness: \(D_x (T+1)\) controls, a sequential run, and a \(\gmat{\Sigma}_\eta\) that must be right in both size and structure.
Formulation 2 · States as controls (time-parallel)
per iteration T independent interval runs + their adjoints, in parallel
Idea. Optimise every state in the window directly. The model appears only as a penalty on consecutive pairs, so nothing is run end to end, and all intervals can be computed at once. This is the basis of the time-parallel weak-constraint systems in operational development, usually inside a Gauss–Newton loop with a specially preconditioned inner solve (the saddle-point formulation).
(state,)(time, state)(time, obs)(state,)(), (state,) + (time, state)# Shapes: N state values (state); T observation times (time); M observed values (obs) def state_cost(control, args): """State formulation: control (ξ, x₁:T); the model only scores consecutive pairs.""" whitened_initial, states = control # (N,), (T, N) forward, background, prior_sqrt, error_std, observations, obs_index, obs_std = args # x₀ = μ_x + Σ_x^{1/2} ξ initial = background + prior_sqrt.mv(whitened_initial) # (N,) # (x₀, …, x_{T−1}): the start of every interval previous = einx.rearrange( # (N,), (T, N) → (T, N) "state, time state -> (1 + time) state", initial, states )[:-1] # f_k(x_{k−1}) for all k at once: no sequential dependence forecasts = jax.vmap(lambda s: forward.step(s, forward.dt))(previous) # (T, N) # Σ_η^{−1/2} (x_k − f_k(x_{k−1})), diagonal Σ_η = σ_η² I mismatch = (states - forecasts) / error_std # (T, N) # 𝒥_η = ½ ∑_k ‖x_k − f_k(x_{k−1})‖²_{Σ_η⁻¹} error_cost = 0.5 * einx.sum("time state ->", mismatch**2) # → () # r_k = Σ_ε^{−1/2} (y_k − H_k x_k) residual = (observations - states[:, obs_index]) / obs_std # (T, M) obs_cost = 0.5 * einx.sum("time obs ->", residual**2) # → () background_cost = 0.5 * einx.sum("state ->", whitened_initial**2) # → () return background_cost + error_cost + obs_cost # start from the background trajectory, which makes every mismatch zero initial_states = rollout(forward, background, T) # → (T, N)
On Lorenz-96, with \(\sigma_\eta = 0.1\) and started from the background trajectory (the same starting point as the forcing run), L-BFGS converged in 298 evaluations, but to a different local minimum: \(\mathcal{J}^\star = \) 879 against 244 for the forcing formulation, with window error 1.57 against 0.28. The two problems share their minima; on a 20-interval chaotic window they do not share their paths. The lesson is practical: the state formulation needs a good starting trajectory (from a strong-constraint analysis, an ensemble smoother, or shorter overlapping windows) and the Gauss–Newton structure that makes its inner problems quadratic. Its payoff is parallel speed, not robustness.
Formulation 3 · Reduced model error
per iteration one forward run + one adjoint run, over \(D_x + N_\chi N_\varphi\) controls
Idea. Write the model error in a few basis functions in space and time, with a prior on the coefficients:
\[ \eta(\vect{s}, t_k) = \sum_{c=1}^{N_\chi} \sum_{p=1}^{N_\varphi} \omega_{cp} \chi_c(t_k) \varphi_p(\vect{s}), \] \[ \omega_{cp} \sim \mathcal{N}(0, \sigma^2_{\omega,cp}), \] \[ \gmat{\Sigma}_{\eta_{1:T}} = \gmat{\Phi} \gmat{\Sigma}_\omega \gmat{\Phi}^\top. \]A bias is the simplest case: one temporal function \(\chi \equiv 1\), so the same error repeats every interval. Choosing the bases is choosing \(\gmat{\Sigma}_\eta\), and it puts the physics of the model error into the method: smooth wind-stress errors, a missing eddy flux at the western boundary, a seasonal drift.
(state,)(temporal, basis)(state, basis), (time, temporal)(time, obs)(state,)(temporal, basis)(time+1, state)# Shapes: N state values (state); T observation times (time); # P spatial basis functions (basis); C temporal basis functions (temporal) def reduced_error(coeffs, spatial, temporal, scales): """η(s, t_k) = ∑_{c,p} ω_{cp} χ_c(t_k) φ_p(s): model error in C × P coefficients.""" # ω = Σ_ω^{1/2} w_ω, whitened control weights = scales * coeffs # (C, P) # ∑_p ω_{cp} φ_p(s): one spatial field per temporal mode fields = einx.dot( # (C, P), (N, P) → (C, N) "temporal basis, state basis -> temporal state", weights, spatial ) # η_k = ∑_c χ_c(t_k) fields_c return einx.dot( # (T, C), (C, N) → (T, N) "time temporal, temporal state -> time state", temporal, fields ) def reduced_cost(control, args): whitened_initial, coeffs = control # (N,), (C, P) (forward, background, prior_sqrt, spatial, temporal, scales, observations, obs_index, obs_std) = args initial = background + prior_sqrt.mv(whitened_initial) # (N,) # η₁:T from C·P numbers instead of T·N model_errors = reduced_error(coeffs, spatial, temporal, scales) # → (T, N) def interval(state, model_error): # x_k = f_k(x_{k−1}) + η_k state = forward.step(state, forward.dt) + model_error # (N,) return state, state _, states = jax.lax.scan(interval, initial, model_errors) # → (T, N) residual = (observations - states[:, obs_index]) / obs_std # (T, M) obs_cost = 0.5 * einx.sum("time obs ->", residual**2) # → () # 𝒥_b = ½ ‖ξ‖² background_cost = 0.5 * einx.sum("state ->", whitened_initial**2) # → () # 𝒥_η = ½ ‖w_ω‖², the model-error prior, whitened error_cost = 0.5 * einx.sum("temporal basis ->", coeffs**2) # (C, P) → () prior_cost = background_cost + error_cost # → () return prior_cost + obs_cost # the bias case: C = 1, χ ≡ 1 (constant in time), P = 1, φ = uniform # then η is one number per interval, the forcing error times Δt
| Basis | Controls | Evaluations | Recovered \(\Delta F\) (true 1.0) | RMSE window | RMSE end |
|---|---|---|---|---|---|
| uniform, constant in time (\(N_\varphi = 1\)) | \(D_x + 1\) | 335 | 0.99 | 0.12 | 0.19 |
| 5 Fourier modes, constant in time | \(D_x + 5\) | 466 | 0.99 | 0.12 | 0.20 |
Because this model error really is a constant bias, one extra number fixes it: the analysis error halves again relative to the forcing formulation, and the estimate is a physical quantity. It also improves the forecast, which the other formulations cannot do directly: running the model forward with the estimated bias gives a 10-interval forecast error of 1.50, against 2.39 without it, and 1.81 when starting from the true state with the uncorrected model. The per-interval kicks of the forcing formulation average to an implied \(\Delta F\) of only 0.39, because they also absorb random misfit, so they correct its forecast much less (2.88 against 2.99). Weakness: the method can only fix errors its basis can express.
The three side by side
| Forcing | State | Reduced | |
|---|---|---|---|
| Controls | \(\vect{x}_0, \gvect{\eta}_{1:T}\): \(D_x (T+1)\) | \(\vect{x}_{0:T}\): \(D_x (T+1)\) | \(\vect{x}_0, \gvect{\omega}\): \(D_x + N_\chi N_\varphi\) |
| Model runs | sequential, one per iteration | T in parallel | sequential, one per iteration |
| \(\gmat{\Sigma}_\eta\) | explicit, any structure | explicit, any structure | implied, \(\gmat{\Phi} \gmat{\Sigma}_\omega \gmat{\Phi}^\top\) |
| Strength | reuses strong-constraint code; robust start | parallel in time | few controls; interpretable; corrects forecasts |
| Weakness | many controls; sequential | needs a good start; conditioning | blind outside the basis |
| Lorenz-96 window RMSE | 0.25 (\(\sigma_\eta = 0.3\)) | 1.57 (local minimum) | 0.12 |
| vardax | WeakFourDVar | not available | not available (docs/07: "not provided out of the box") |
Application: the Gulf Stream window
The QG model of the other pages is a good candidate for weak constraint: it omits unresolved eddy fluxes, uses an imperfect wind forcing, and has no topography.
(time, height, width)(cell, cell)(height, width) → (height, width)(time+1, height, width)(time, height, width)| Choice | At ocean scale |
|---|---|
| Forcing formulation | 180,224 controls for a 10-day window; feasible with checkpointed adjoints, but \(\gmat{\Sigma}_\eta\) must be smooth (a Matérn kernel or a diffusion-operator smoother) or the kicks fill with grid-scale noise |
| State formulation | one day per device; worth it for long windows, started from a strong-constraint or ensemble-smoother trajectory |
| Reduced model error | the natural fit: a wind-stress-curl error in a few spatial modes with daily or weekly temporal windows, priors from the SSH spectrum (\(\sigma_{\omega,a}^2 \propto \kappa_a^{-4}\), with \(\kappa_a\) the wavenumber of mode \(a\)), as in somax's forcing-basis design note |
| Diagnostics | \(2\mathcal{J}^\star / N_\text{obs}\) for the strong analysis first: a value far above 1 is the argument for weak constraint; then the mean of \(\gvect{\eta}^\star\) over many windows as a model-bias map |
Notes on vardax
Checked against vardax main (3e0f34a), alongside issues #94–#99 filed from the 4D-Var primer.
WeakFourDVaroptimises \(\gvect{\eta}_k\) in physical units and applies \(\gmat{\Sigma}_\eta^{-1}\) by an inner linear solve at every step. For a small \(\gmat{\Sigma}_\eta\) that direction is stiff (curvature \(1/\sigma_\eta^2\)), so whitening \(\gvect{\eta}_k = \gmat{\Sigma}_\eta^{1/2}\vect{w}_k\), the weak-constraint counterpart of #97, would help the optimiser most.- The docstring says \(\gmat{\Sigma}_\eta\) has a default ("identity scaled by a small variance if not supplied"), but
model_err_cov_opis a required argument. - No state formulation and no reduced model error. docs/07 points users to a custom
eqx.Module; the reduced form above is about thirty lines on top of the forcing cost, and somax's forcing-basis note already specifies the basis side. - No
forward_adjointslot, although the docs/12 table lists one (#95).
Code markers: today (vardax) names ship on vardax main (3e0f34a); not pinned marks optimistix. Lorenz-96 numbers come from a JAX run in double precision: truth F = 8, model F = 7, D_x = 40, 20 observation times of 10 RK4 steps, every second site observed with σ_ε = 0.5, Σ_x as in the primer.