Changed the EnKF distribution to be low rank - #356
Conversation
Return LowRankMultivariateNormal for rank-deficient EnKF distributions (for both `Filter` and `Smooth`) Previously the distributions returned for the EnKF were always MultivariateNormal, with the covariance formed densely from the QR factors. When the ensemble covariance was low rank (n_particles - 1 < state_dim) this had two consequences: 1. The returned distributions were singular and yielded NaN when `.sample` or `.log_prob` were used. NumPyro accepts a singular covariance matrix, which then gives NaN downstream when it is factorized. 2. It was computationally expensive for large state_dim. The solution is to check whether the covariance is singular (a simple comparison of n_particles against state_dim) and return a LowRankMultivariateNormal in that case, which can be sampled. The same is done in filter_state_dist, used by DiscreteControlLoopSimulator. If in the non degenerate case, we use the same `MultivariateNormal` to ensure existence of densities. Note that this affects only the returned distributions. The filtered/smoothed states are unchanged and behavior is otherwise the same as before (`marginal_loglik` is identical). One design choice I am unsure of: I added a jitter parameter to the EnKF config (filtered_covariance_jitter, default 0.0). LowRankMultivariateNormal has no valid log_prob unless a jitter is added, so this lets the user obtain a distribution with a valid density when needed. The jitter does not affect the ability to sample, but it does change the resulting distribution. Claude claims to have solved 2 bugs: 1. In DiscreteControlLoopSimulator, filter_state_dist passed the rectangular ensemble factor as scale_tril, so the belief reported event_shape == (n_particles,). .mean and .log_prob would then raise an error. 2. Posterior rollout (predict_times with a filtered_result) returned all-NaN states: it grafts the filtered distributions in as the forecast initial condition and samples them, and those samples were NaN because the dense covariance was singular.
mattlevine22
left a comment
There was a problem hiding this comment.
looks quite reasonable to me!
Question: Do we want the new jitter to be used in regular MVN stuff too (currently it is)?
My recommendation would be:
- only use this jitter for the low rank MVN
- give it a > 0 default (so it protects from the .log_prob nan issue)
- rename / redoc this in the EnKF config to be clear that it is only for low rank and only for the recorded dists (I suggested a name above)
Certainly open to the current choice if you think its worth it.
| CuthbertOrCDDynamaxFilterSource = CuthbertOnlyFilterSource | CDDynamaxOnlyFilterSource | ||
|
|
||
|
|
||
| def _validate_filtered_covariance_jitter(filtered_covariance_jitter: float) -> None: |
There was a problem hiding this comment.
inference/configs/discretizer.py already has _validate_jitter_scale and _validate_covariance_jitter.
Can we use those?
| inflation_delta (float | None): Scale ensemble anomalies by | ||
| \(\sqrt{1 + \delta}\) before the update to prevent collapse. | ||
| `None` disables inflation. | ||
| filtered_covariance_jitter (float): Nonnegative \(\epsilon\) added to the |
There was a problem hiding this comment.
I'd worry a user thinks this will appear in the EnKF algorithm (which it doesn't).
How about a name like recorded_filtered_states_[lowrank]_cov_jitter?
Do we want this jitter usable even for full rank MVN? or just to deal w LowRank stuff
| ) | ||
| perturb_measurements: bool | None = None | ||
| inflation_delta: float | None = None | ||
| filtered_covariance_jitter: float = 0.0 |
There was a problem hiding this comment.
Maybe recorded_filtered_states_cov_jitter?
| ) | ||
|
|
||
| covariances = covariance_from_cholesky(states.chol_cov) | ||
| if covariance_jitter: |
There was a problem hiding this comment.
so this uses the jitter in the full-rank MVN...do we want to even allow that?
|
|
||
| ensemble = result.states.ensemble | ||
| assert len(result.dists) == len(obs_times) | ||
| for t, d in enumerate(result.dists): |
There was a problem hiding this comment.
Since this test uses default ...jitter=0.0, a d.log_prob(...) call would fail / NaN here right?
May want a regression test specifically that jitter protects you in that case (its the failure mode that made you build it).
Summary
Adresses #355.
Return LowRankMultivariateNormal for rank-deficient EnKF distributions (for both
FilterandSmooth)Previously the distributions returned for the EnKF were always
MultivariateNormal, with the covariance formed densely from the QR factors. When the ensemble covariance was low rank (n_particles - 1 < state_dim) this had two consequences:The returned distributions were singular and yielded NaN when
.sampleor.log_probwere used. NumPyro accepts a singular covariance matrix, which then gives NaN downstream when it is factorized.It was computationally expensive for large state_dim.
The solution is to check whether the covariance is singular (a simple comparison of
n_particlesagainststate_dim) and return aLowRankMultivariateNormalin that case, which can be sampled. The same is done in filter_state_dist, used by DiscreteControlLoopSimulator. If in the non degenerate case, we use the sameMultivariateNormalto ensure existence of densities.Note that this affects only the returned distributions. The filtered/smoothed states are unchanged and behavior is otherwise the same as before (
marginal_loglikis identical).One design choice I am unsure of: I added a jitter parameter to the EnKF config (filtered_covariance_jitter, default 0.0). LowRankMultivariateNormal has no valid log_prob unless a jitter is added, so this lets the user obtain a distribution with a valid density when needed. The jitter does not affect the ability to sample, but it does change the resulting distribution.
Bug fixes
Claude claims to have solved 2 bugs:
DiscreteControlLoopSimulator, filter_state_dist passed the rectangular ensemble factor as scale_tril, so the belief reported event_shape == (n_particles,)..meanand.log_probwould then raise an error.Verification
I'm still double checking that this yields the same results as before but I locally ran the following tests:
marginal_loglikas before, distributions agree when both work, new low rank works when the distributioj is degenerate.