kalman-py
Fast, exact and numerically robust Kalman filters for Python
Quick Start · Highlights · Benchmarks · Architecture · Roadmap · Development · Documentation
kalman-py is a typed Kalman filtering library with linear, extended and unscented filters,
RTS smoothers, maximum-likelihood noise learning and consistency diagnostics. It runs on NumPy
out of the box, and on an optional JAX backend that compiles the whole time loop with
jax.lax.scan. On linear-Gaussian problems it produces the same estimates as
FilterPy and pykalman
(within 1e-11), so it can replace them without changing your results.
📌 Project status
|
|
Linear KF, EKF and UKF with RTS smoothers, each on the NumPy and JAX backends, step-by-step or batch. Joseph-form and square-root covariance updates. |
|
|
Q/R learning by EM or gradient-based maximum likelihood, and NIS/NEES consistency checks with plotting helpers. |
|
|
Reproducible comparison with FilterPy and pykalman on five frozen scenarios (S1–S5), shared with the C, C++ and Rust implementations. |
|
|
GitHub Actions on every push and pull request: Python 3.10–3.14 on Linux, macOS and Windows, with and without JAX, the minimum supported dependency versions, the docs, and a comparison report against FilterPy and pykalman. |
|
|
Documentation site (MkDocs Material) with guides, an API reference and a migration guide from FilterPy, plus three tutorial notebooks: target tracking, multi-rate sensor fusion and learning the noise, published at joslo2345.github.io/kalman-py. |
|
|
Release automation is ready: a GitHub release builds, checks and tests the package, then publishes it to PyPI with trusted publishing. The first release (0.1.0) is pending the PyPI-side setup. See the changelog. |
✨ Highlights
|
⚡ Compiled JAX backend The time loop runs in jax.lax.scan, compiled once and reused across filters.
Small matrices use fused kernels instead of BLAS calls: 0.4 µs per step on a
constant-velocity model.Benchmarks → |
🎯 Exact by construction Same estimates as FilterPy (within 1e-14) and pykalman (within 1e-11) on linear problems; the batch steady-state shortcut is bitwise identical to the full computation. Equivalence tests → |
🧮 Automatic Jacobians Write f and h once with jax.numpy: the EKF derives its
Jacobians with jax.jacfwd on either backend, and the UKF needs none.EKF / UKF example → |
|
🛡️ Numerical robustness Joseph-form updates with exact symmetry by default; square_root=True keeps every
covariance positive-definite by construction, even in float32.Stability results → |
📈 Noise learningfit_noise estimates Q and R by EM or by BFGS on the exact likelihood (autodiff
through the filter), reaching a higher likelihood than pykalman's EM in less time.Learning example → |
🩺 Diagnostics built in NIS/NEES consistency checks against χ² bounds (no SciPy needed) over Monte Carlo runs, and plots of estimates with uncertainty bands. Diagnostics example → |
🚀 Quick Start
kalman-py isn't on PyPI yet. Install it from a clone, with the optional extras you need:
pip install -e ".[jax,plot]" # or just -e . for NumPy only
Linear filter and smoother
import numpy as np
from kalman_py import KalmanFilter
dt = 0.1
F = np.array([[1.0, dt], [0.0, 1.0]]) # constant velocity: state (position, velocity)
H = np.array([[1.0, 0.0]]) # measure position
Q = 0.5 * np.array([[dt**3 / 3, dt**2 / 2], [dt**2 / 2, dt]])
R = np.array([[0.25]])
kf = KalmanFilter(F, H, Q, R, x0=[0.0, 1.0], P0=np.eye(2))
rng = np.random.default_rng(0)
zs = (np.arange(1, 201) * dt + 0.5 * rng.standard_normal(200))[:, None]
# Real-time: one measurement at a time
kf.predict()
kf.update(zs[0])
# Batch: the whole sequence at once (backend="jax" compiles it), then smooth
result = kf.filter(zs)
smoothed = kf.smooth(result)
print(result.means[-1], float(result.log_likelihood))
(x0, P0) is the prior before the first prediction, as in FilterPy. Pass
square_root=True for the square-root form.
Extended and unscented filters
import jax
import jax.numpy as jnp
from kalman_py import ExtendedKalmanFilter, UnscentedKalmanFilter
jax.config.update("jax_enable_x64", True)
def f(x, dt): # 2-D constant velocity: state (px, py, vx, vy)
return jnp.array([x[0] + dt * x[2], x[1] + dt * x[3], x[2], x[3]])
def h(x): # range and bearing from a sensor at the origin
return jnp.array([jnp.hypot(x[0], x[1]), jnp.arctan2(x[1], x[0])])
def wrap_bearing(z, z_pred): # bearing residuals in [-pi, pi)
d = jnp.asarray(z) - jnp.asarray(z_pred)
return d.at[1].set((d[1] + jnp.pi) % (2 * jnp.pi) - jnp.pi)
truth = np.column_stack([np.linspace(-200, -150, 100), np.linspace(60, -20, 100)])
truth = np.hstack([truth, np.tile([0.5, -0.8], (100, 1))])
radar = np.asarray(jax.vmap(h)(truth)) + rng.normal(0, [0.5, 0.01], (100, 2))
model = dict(Q=0.01 * np.eye(4), R=np.diag([0.25, 1e-4]), x0=truth[0], P0=np.eye(4))
ekf = ExtendedKalmanFilter(f, h, **model, residual_z=wrap_bearing) # Jacobians by autodiff
ukf = UnscentedKalmanFilter(f, h, **model, residual_z=wrap_bearing, square_root=True)
ekf_result = ekf.filter(radar, dt=1.0, backend="jax")
ukf_result = ukf.filter(radar, dt=1.0, backend="jax")
ukf_smoothed = ukf.smooth(ukf_result)
The target crosses the negative x-axis, where bearings wrap from +π to −π; residual_z is all
either filter needs to handle it.
Learn Q and R from data
from kalman_py.learning import fit_noise
fit = fit_noise(F, H, zs, x0=[0.0, 1.0], P0=np.eye(2)) # gradient-based when JAX is installed
print(fit.R, fit.log_likelihood, fit.converged)
Check consistency and plot
from kalman_py.diagnostics import consistency_check
from kalman_py.plotting import plot_estimates
check = consistency_check(result.nis, dof=1)
print(f"{check.fraction_inside:.0%} of steps inside the 95% NIS bounds")
axes = plot_estimates(smoothed, labels=["position", "velocity"])
📊 Benchmarks
Measured against FilterPy (per-step mode) and pykalman (batch mode) on the shared scenarios in
tests/vectors/, with the same inputs, seeds and precision for every
library. Estimates match in every linear case, so the comparison is about speed, stability and
features.
| Scenario | kalman-py | Best other | Result |
|---|---|---|---|
| S1 · 1-D constant velocity, batch | 0.41 µs/step (JAX) | 38.0 µs (pykalman) | 93× faster |
| S2 · 2-D constant velocity, batch | 0.58 µs/step (JAX) | 39.0 µs (pykalman) | 67× faster |
| S5 · 15-state INS, batch | 5.5 µs/step (JAX) | 52.3 µs (pykalman) | 9.5× faster |
| S1/S2/S5 · per-step loop | 13.3 / 13.4 / 22.6 µs | 10.3 / 10.6 / 18.9 µs (FilterPy) | 0.78–0.84× (slower) |
| S3 · range-bearing, EKF & UKF | RMSE 0.890, NEES 4.00 | RMSE 0.890 (FilterPy) | same accuracy |
| S4 · float32 stability | square-root & JAX: never fail | textbook filter: 67,589 steps | see below |
In per-step mode both libraries make the same number of small NumPy calls; kalman-py's extra cost is the symmetrization that keeps every covariance exactly symmetric.
Float32 stability (S4)
Full results table (generated by scripts/make_table.py)
Measured on Apple M3 Pro (arm64), macOS 27.0.1, python-3.12.13, commit 9a2edc7, 2026-10-05.
| Scenario | Filter | Precision | Metric | kalman-py | filterpy | kalman-py-jax | kalman-py-numpy | kalman-py-sqrt | naive | pykalman | Ours vs best other |
|---|---|---|---|---|---|---|---|---|---|---|---|
| S1 | KF batch | float64 | max_abs_diff (state) | n/a | n/a | n/a | n/a | n/a | n/a | 5.99e-12 | n/a |
| S1 | KF batch | float64 | peak_memory (bytes) | n/a | n/a | n/a | 1,524,384 | n/a | n/a | 1,136,154 | n/a |
| S1 | KF batch | float64 | rmse (state) | 0.4233 | n/a | n/a | 0.4233 | n/a | n/a | 0.4233 | 1.00x |
| S1 | KF batch | float64 | time_per_step (ns) | 406.8 | n/a | n/a | 2,606 | n/a | n/a | 38,047 | 93.53x |
| S1 | KF per-step | float64 | max_abs_diff (state) | n/a | 1.78e-15 | n/a | n/a | n/a | n/a | n/a | n/a |
| S1 | KF per-step | float64 | peak_memory (bytes) | 164,260 | 163,912 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S1 | KF per-step | float64 | rmse (state) | 0.4233 | 0.4233 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S1 | KF per-step | float64 | time_per_step (ns) | 13,252 | 10,320 | n/a | n/a | n/a | n/a | n/a | 0.78x |
| S2 | KF batch | float64 | max_abs_diff (state) | n/a | n/a | n/a | n/a | n/a | n/a | 3.32e-12 | n/a |
| S2 | KF batch | float64 | peak_memory (bytes) | n/a | n/a | n/a | 4,165,368 | n/a | n/a | 3,866,229 | n/a |
| S2 | KF batch | float64 | rmse (state) | 0.5073 | n/a | n/a | 0.5073 | n/a | n/a | 0.5073 | 1.00x |
| S2 | KF batch | float64 | time_per_step (ns) | 577.5 | n/a | n/a | 2,786 | n/a | n/a | 38,978 | 67.49x |
| S2 | KF per-step | float64 | max_abs_diff (state) | n/a | 3.55e-15 | n/a | n/a | n/a | n/a | n/a | n/a |
| S2 | KF per-step | float64 | peak_memory (bytes) | 325,324 | 324,632 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S2 | KF per-step | float64 | rmse (state) | 0.5073 | 0.5073 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S2 | KF per-step | float64 | time_per_step (ns) | 13,442 | 10,556 | n/a | n/a | n/a | n/a | n/a | 0.79x |
| S3 | EKF | float64 | nees (-) | 4.002 | 4.002 | n/a | n/a | n/a | n/a | n/a | – |
| S3 | EKF | float64 | rmse (state) | 0.8896 | 0.8896 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S3 | UKF | float64 | nees (-) | 4.001 | 3.979 | n/a | n/a | n/a | n/a | n/a | – |
| S3 | UKF | float64 | rmse (state) | 0.8896 | 0.8896 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S4 | KF | float32 | steps_to_failure (steps) | 2,753 | n/a | 1,000,000 | n/a | 1,000,000 | 67,589 | n/a | 0.04x |
| S4 | KF | float32 | steps_to_indefinite (steps) | 2,753 | n/a | 1,000,000 | n/a | 1,000,000 | 1,000,000 | n/a | 0.0028x |
| S5 | KF batch | float64 | peak_memory (bytes) | n/a | n/a | n/a | 42,821,472 | n/a | n/a | 45,672,193 | n/a |
| S5 | KF batch | float64 | rmse (state) | 0.1865 | n/a | n/a | 0.1865 | n/a | n/a | 0.1865 | 1.00x |
| S5 | KF batch | float64 | time_per_step (ns) | 5,535 | n/a | n/a | 23,730 | n/a | n/a | 52,290 | 9.45x |
| S5 | KF per-step | float64 | peak_memory (bytes) | 1,222,492 | 1,220,296 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S5 | KF per-step | float64 | rmse (state) | 0.1865 | 0.1865 | n/a | n/a | n/a | n/a | n/a | 1.00x |
| S5 | KF per-step | float64 | time_per_step (ns) | 22,639 | 18,923 | n/a | n/a | n/a | n/a | n/a | 0.84x |
- KF per-step rows:
kalman-pyis the step-by-step API (NumPy). KF batch rows:kalman-pyis the JAX backend on CPU andkalman-py-numpythe NumPy backend; JAX has no peak_memory because tracemalloc can't see its buffers. max_abs_diff is the largest difference betweenkalman-pyand that library's estimates. - S3 UKF: kalman-py redraws sigma points from the predicted distribution before each update, while FilterPy reuses the propagated ones, so their estimates differ slightly; the EKFs agree to ~1e-13. A consistent filter has average NEES = 4.
- S4 (float32): FilterPy is n/a because it converts float32 inputs to float64 internally (its identity matrix is float64), so it can't run S4 as specified.
- S4:
kalman-pyis the default configuration (NumPy backend, Joseph form);kalman-py-sqrtusessquare_root=True;kalman-py-jaxis the JAX backend. 1,000,000 means the filter never failed.
Reproduce everything:
ENV="$(git rev-parse --short HEAD),<cpu>,<os>,python-$(python -c 'import platform; print(platform.python_version())'),$(date +%F)"
uv run python benchmarks/run.py "$ENV" # S1, S2, S5: speed, accuracy, memory
uv run python benchmarks/accuracy.py "$ENV" # S3 accuracy, S4 stability
uv run python scripts/make_table.py results/results.csv kalman-py --readme README.md
uv run python scripts/plot_benchmarks.py # the charts above
🏗️ Architecture
flowchart LR
subgraph API["Filter classes"]
KF["KalmanFilter"]
EKF["ExtendedKalmanFilter"]
UKF["UnscentedKalmanFilter"]
end
subgraph Backends
NP["numpy_backend<br/>step + batch loops"]
JX["jax_backend<br/>jit + lax.scan"]
end
KF & EKF & UKF -- "backend='numpy'" --> NP
KF & EKF & UKF -- "backend='jax'" --> JX
NP & JX --> RES["FilterResult<br/>means · covs · priors · NIS · log-likelihood"]
RES --> SM["RTS smoother"]
RES --> DIAG["diagnostics<br/>NIS / NEES / χ²"]
RES --> PLOT["plotting"]
JX --> LEARN["learning.fit_noise<br/>EM · BFGS"]
NP --> LEARN
| Component | Responsibility |
|---|---|
KalmanFilter, ExtendedKalmanFilter, UnscentedKalmanFilter |
Validate inputs, keep the step-by-step state (x, P), and dispatch filter/smooth to a backend. |
backends.numpy_backend |
Pure-function filters and smoothers; Joseph and square-root forms; exact steady-state shortcut in batch mode. Needs only NumPy. |
backends.jax_backend |
The same algorithms compiled with jax.lax.scan, with fused kernels for small matrices and vmap over sigma points. Optional. |
result |
FilterResult / SmootherResult, generic over NumPy or JAX arrays and registered as JAX pytrees. |
learning |
fit_noise: maximum-likelihood Q and R by EM (NumPy) or BFGS through the JAX filter. |
diagnostics, plotting |
NEES/NIS consistency checks with in-house χ² quantiles; matplotlib helpers (optional extra). |
🗺️ Roadmap
| Step | Description |
|---|---|
| First PyPI release | 0.1.0 through the release workflow (trusted publishing), then a conda-forge recipe once the API is stable |
| Missing features | A control input (B u), angle-valued UKF states (x_mean_fn/residual_x), missing measurements in batch mode |
| Published benchmarks | Numbers from a dedicated, frequency-pinned machine, alongside the C, C++ and Rust implementations |
🛠️ Development
uv sync --all-extras # add --group docs for the documentation tools
uv run pytest # fast tests
uv run pytest -m "" # everything, including slow comparison tests
uv run ruff check && uv run ruff format --check && uv run mypy src tests
uv sync --all-extras --group docs
uv run --no-sync mkdocs serve # documentation at http://127.0.0.1:8000
CI (.github/workflows/ci.yml) runs lint and types, the tests on
Python 3.10–3.14 × Linux/macOS/Windows (with JAX, and without it on 3.10 and 3.14), and a
comparison job. That job runs every pass condition including the slow tests, benchmarks against
FilterPy and pykalman, and compares speed with the latest main run. Timing regressions over
10% are reported as warnings, because shared runners vary by 10–20%; set REGRESSION_MODE to
--fail to turn them into errors.
- 🐞 Found a bug? Open an issue with a minimal reproduction.
- 🧪 Changing numerics? The equivalence tests against FilterPy and pykalman and the stability
property tests must keep passing; benchmark scenarios in
tests/vectors/are frozen. - 📐 Conventions: filters predict before the first update; covariances stay exactly symmetric; float32 inputs stay float32 end to end.
- 📚 Docs and tutorials: code in the README and in
docs/is executed by the tests, and the notebooks inexamples/are re-run in CI, so keep them runnable.
🙏 Acknowledgements
kalman-py is measured against, and owes a great deal to, FilterPy and pykalman, and is built on NumPy and JAX.
📄 License
Released under the MIT License.
Metadata
Release files for kalman-py 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kalman_py-0.1.0.tar.gz | 90.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kalman_py-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 132.1 kB
Release files / kalman_py-0.1.0.tar.gz
| Download URL | kalman_py-0.1.0.tar.gz |
|---|---|
| Size | 90.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b1d3696dda25ebfef800535dc0f5888de0a65f27021a816780eb4b5703dd9b70
|
|
BLAKE2b-256 checksum How to use checksums |
25d408ea76ebd031ca072a6d1cf8511428f824e924e6eb42e1000982210f74c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / kalman_py-0.1.0-py3-none-any.whl
| Download URL | kalman_py-0.1.0-py3-none-any.whl |
|---|---|
| Size | 41.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3f06663a5dcc5c2feb31fcc96ba46e43e22f658f66e414219f58ea8481b80461
|
|
BLAKE2b-256 checksum How to use checksums |
aa56fbc3835ec2a40a475b847383524e3567be669059f99b62a0d4cad0645172
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log