Skip to content

Add Cuthbert EnKF#209

Merged
mattlevine22 merged 11 commits intomainfrom
dw-add-enkf
Apr 23, 2026
Merged

Add Cuthbert EnKF#209
mattlevine22 merged 11 commits intomainfrom
dw-add-enkf

Conversation

@DanWaxman
Copy link
Copy Markdown
Collaborator

@DanWaxman DanWaxman commented Apr 22, 2026

There is now an EnKF in cuthbert, as of v0.0.10. This commit adds integrations to this filter, including:

  • Upgrading the required cuthbert version
  • Updating the corresponding filter_config information
  • Make EnKF the default discrete-time filter
  • Add EnKF integration code
  • Update tests accordingly
  • Add to documentation (including discrete-time LTI profile likelihood notebook)

Remaining:

  • Let's run this on some real examples and really check that this is better than EKF in order to be the default.

There is now an EnKF in cuthbert, as of v0.0.10. This commit adds integrations to this filter, including:
- Upgrading the required cuthbert version
- Updating the corresponding filter_config information
- Make EnKF the default discrete-time filters
- Add EnKF integration code
- Update tests accordingly
- Add to documentation (including discrete-time LTI profile likelihood notebook)
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR integrates the new cuthbert-backed discrete-time Ensemble Kalman Filter (EnKF) into dynestyx, makes it the default discrete-time filter, and updates configs/tests/docs accordingly.

Changes:

  • Bump cuthbert/cuthbertlib minimum versions to 0.0.10 and add EnKF dispatch/integration in the cuthbert discrete backend.
  • Make EnKFConfig() the default discrete-time Filter() configuration and update config documentation accordingly.
  • Extend/adjust tests to cover EnKF behavior (shapes, observation callable support, deterministic CRN behavior), and update docs/navigation.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pyproject.toml Updates required cuthbert versions to pick up EnKF support.
dynestyx/inference/filter_configs.py Updates EnKFConfig docs/default backend (cuthbert) and clarifies default filter role.
dynestyx/inference/integrations/cuthbert/discrete.py Adds EnKF backend integration and unifies Gaussian-site recording helper.
dynestyx/inference/integrations/utils.py Adds covariance_from_cholesky helper used across integrations.
dynestyx/inference/filters.py Switches default discrete-time filter to EnKFConfig() and uses shared covariance helper.
tests/fixtures.py Extends fixtures to include EnKF configs and parametrizations.
tests/test_filters.py Adds EnKF-specific tests and a default-filter smoke test.
docs/index.md Updates external library description to mention EnKF support in Cuthbert.
docs/faq.md Updates guidance to reflect EnKF default and links to a new deep dive notebook.
docs/api_reference/public/inference/filter_configs.md Updates the filter config summary table to reflect EnKF as default discrete filter.
mkdocs.yml Adds a new deep dive notebook to the docs nav.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/faq.md Outdated
Comment thread dynestyx/inference/integrations/cuthbert/discrete.py Outdated
Comment thread dynestyx/inference/integrations/cuthbert/discrete.py Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Agent-Logs-Url: https://github.com/BasisResearch/dynestyx/sessions/804f2085-9a89-43cc-890b-77758f2f48ab

Co-authored-by: DanWaxman <11810745+DanWaxman@users.noreply.github.com>
Comment thread docs/api_reference/public/inference/filter_configs.md Outdated
Comment thread docs/deep_dives/discrete_time_lti_profile_likelihood.ipynb
if isinstance(filter_config, PFConfig):
_add_sites_pf(name, states, record_kwargs)
particles = states.particles
particles = states.particles[1:]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this [1:] (and subsequents) catching a previous off-by-one error??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, it seems like this problem should be managed upstream, e.g. in compute_cuthbert_filter or even cuthbert_filter.

Currently, the batched filter computations would not pass through the above changes (but does use compute_cuthbert_filter).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And is there a test that would have failed before but now can pass? Wondering how we missed this...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And is there a test that would have failed before but now can pass? Wondering how we missed this...

Yup, added a test for this test_filters.py::test_cuthbert_filtered_distribution_shapes_match_observations. The error was mostly innocuous, which is why we didn't see it before. This only gets passed forward as filtered_dists, and it didn't affect the prediction behavior at all because of how scans work.

If so, it seems like this problem should be managed upstream, e.g. in compute_cuthbert_filter or even cuthbert_filter.

Yeah, that seems fair.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

@DanWaxman
Copy link
Copy Markdown
Collaborator Author

DanWaxman commented Apr 22, 2026 via email

Comment on lines +314 to +324
if not isinstance(obs_model, (LinearGaussianObservation, GaussianObservation)):
probe_u = jnp.zeros(())
probe_t = jnp.zeros(())
try:
probe_d0 = obs_model(jnp.zeros((state_dim,)), probe_u, probe_t)
probe_d1 = obs_model(jnp.ones((state_dim,)), probe_u, probe_t)
except Exception:
probe_d0 = probe_d1 = None
if probe_d0 is not None and probe_d1 is not None:
chol0 = _extract_gaussian_chol(probe_d0, obs_dim)
_check_state_independent_noise(chol0, probe_d1, obs_dim)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be more readable as a utility function.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

filtered_cov = covariance_from_cholesky(chol_cov)

if add_filtered_states_cov:
numpyro.deterministic(f"{name}_filtered_states_cov", filtered_cov)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At 650 lines and supporting 4 different filters and their own utilities (and some shared), I think this file deserves some refactoring into multiple files.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I started this actually in #192. I think it makes sense to continue the refactoring there.

Copy link
Copy Markdown
Collaborator

@mattlevine22 mattlevine22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main thoughts are:

  1. There were some off-by-one fixes [1:]...
  • how did we miss these? Can we have a test to protect us?
  • To what extent can this be handled higher up in the Cuthbert integration (e.g. cuthbert_filter)?
  • And is this fixed for the batched case as well (different branches of filters it seems)
  1. The cuthbert/discrete.py file has gotten really long and tough to follow.
  • Would highly recommend refactoring into multiple files and some shared utilities.

@DanWaxman
Copy link
Copy Markdown
Collaborator Author

Main thoughts are:

  1. There were some off-by-one fixes [1:]...
  • how did we miss these? Can we have a test to protect us?

The issue was mostly innocuous, it just got passed forward to predict_times where it didn't really do anything. We were always using the logged states, I think, which were properly indexed.

  • To what extent can this be handled higher up in the Cuthbert integration (e.g. cuthbert_filter)?

This is a good point, handled in 7e3c3fc. We don't have access to cuthbert_filter directly (that's a function in cuthbert), but it's now handled in compute_cuthbert_filter.

  • And is this fixed for the batched case as well (different branches of filters it seems)

Yup, also done by doing this in compute_cuthbert_filter

  1. The cuthbert/discrete.py file has gotten really long and tough to follow.
  • Would highly recommend refactoring into multiple files and some shared utilities

Agreed. This is part underway in #192, which I think is a more natural place to undertake this refactoring.

Copy link
Copy Markdown
Collaborator

@mattlevine22 mattlevine22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

  • Confirmed that Tutorial docs (NUTS + SVI) produce similar outputs (and speed) with EnKF and EKF
  • Confirmed that test_science for discretized L63 produces similar posteriors w/ NUTS + EKF and NUTS + EnKF

@mattlevine22 mattlevine22 merged commit c70d726 into main Apr 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants