What it actually does
Separate what the system is doing from what you managed to measure. The state is the thing you care about — a position, a level, a trend — and it evolves by its own rules. The observation is a noisy, possibly incomplete look at it. Then run two steps forever. Predict: push the state forward and let the uncertainty grow, because time passing makes you less sure. Update: take the measurement and move the estimate toward it, by an amount that depends on which you trust more. That trade-off is the whole filter, and the Kalman gain is literally its arithmetic — when your prediction is uncertain and the sensor is good, the gain is near one and you believe the sensor; when the reverse, you barely move.
- The dynamics and observation are linear, or close enough that a linearization holds — where they are not, the EKF approximates and can diverge
- Process and measurement noise are Gaussian, zero-mean, and independent of each other and across time
- The noise covariances Q and R are known, which they essentially never are — they are the tuning, and the filter’s optimality is conditional on them
- The state is observable through the measurements, or parts of it drift with no correction and no warning
- Uncertainty is unimodal: a single Gaussian cannot represent genuinely not knowing which of two places you are
Likelihood
The prediction-error decomposition: the likelihood of a whole series is the product of one-step predictive densities, and the filter produces exactly those as it runs. That is what makes this a model rather than merely an algorithm — the same recursion that estimates the state also returns the number an optimizer needs to fit the unknown parameters, at no extra cost. It is also the reason ARIMA implementations run a Kalman filter internally: casting an ARIMA into state-space form and running this gives the exact likelihood, where conditional sum of squares only approximates it.
- the innovation — what the measurement said minus what the filter expected, and the only genuinely new information at time t
- innovation covariance: how surprised the filter was entitled to be, given its own uncertainty and the sensor noise
- the system matrices and noise covariances, fitted by maximizing this quantity when they are not known
- the observation matrix, mapping the state onto what is actually measured — often losing information on the way
How it is minimized
Closed-form recursive Bayesian updates; maximum likelihood via the prediction-error decomposition for unknown parameters
- the Kalman gain — prediction uncertainty divided by total uncertainty, which is the trust ratio written as arithmetic
- covariance of the state estimate before the measurement; it grows during the predict step
- covariance after the measurement, which can only shrink — information never destroys information
- how much uncertainty the measurement removed; with a perfect sensor this is zero and the state is known
For a linear system with Gaussian noise, the posterior over the state stays Gaussian forever, so the entire inference reduces to propagating a mean and a covariance. No sampling, no iteration, no approximation — the update is exact and it is the minimum-mean-squared-error estimator, which is a genuinely strong optimality claim and the reason the filter has survived since 1960. Two practical points dominate everything else. The process and measurement noise covariances are rarely known and are effectively the only tuning available, and getting their ratio wrong is what causes almost every filter failure in the field. And the covariance update above is numerically fragile: rounding can drive P non-positive-definite, at which point the filter reports impossible confidence and diverges, which is why the Joseph form and square-root implementations exist rather than being refinements.
Q (process noise)
How much the state is allowed to move on its own. Too small and the filter becomes over-confident, ignores measurements, and diverges; too large and it chases noise (the dominant tuning decision, usually set from physics or by maximizing the likelihood)
R (measurement noise)
Sensor variance. Often the one quantity actually known, from a datasheet or a calibration run
initial state and P0
Where to start and how unsure. A large P0 makes the filter converge quickly from an unknown start; too small and it refuses to move toward the truth
F, H (system matrices)
The dynamics and observation model. These encode the physics or the structure and are a modelling decision, not a tunable
variant
Linear, extended (EKF), unscented (UKF), or particle filter — chosen by how nonlinear the system is, and the EKF can diverge where the UKF does not
For a time-invariant system the covariance recursion converges to a fixed point of the Riccati equation, so the gain becomes constant and the filter reaches a steady state — which is a genuinely useful fact, since the constant gain can be precomputed and the covariance propagation dropped entirely. The failure modes are specific and well known. Divergence: with Q too small the filter becomes progressively more certain than it should be, the gain falls toward zero, and it stops listening to measurements while its reported covariance says everything is fine — the most dangerous failure here, because it looks confident. Numerical loss of positive definiteness produces the same behaviour from a different cause. And observability matters: if the observation matrix cannot see part of the state, that part is never corrected and drifts freely, which no amount of tuning fixes.
O(n_x^3) per step in the worst case for the covariance propagation, though for a small state — which is the usual case, four to twelve dimensions — this is a handful of tiny matrix operations and effectively free. Memory is O(n_x^2), constant in the length of the series, which is what makes it a streaming algorithm. With a time-invariant system the steady-state gain removes the covariance work entirely and each step becomes a couple of matrix-vector products.
How, where, and why it is used
Time-Series Forecasting
Primary fitStructural time series: write the series as unobserved components — local level, local trend, seasonal, cycle, regression effects — put each into the state vector, and let the filter estimate them jointly while the unknown variances are fitted by maximum likelihood. Forecasting is then just running the predict step forward with no measurements to correct it, and the covariance growth gives the intervals directly.
- Structural and Bayesian structural time-series models, where the decomposition into components is itself the deliverable
- Series with missing observations, which the filter handles natively by skipping the update step and letting uncertainty grow
- Multivariate forecasting with shared latent factors, which a univariate method cannot express
- Computing the exact likelihood for ARIMA and ETS, both of which have state-space representations
It is the most flexible framework in this group: time-varying parameters, irregular sampling, missing data, multiple observation sources and interventions all fit inside it without special cases, and the components come out separately rather than needing to be inferred from coefficients. That generality is also the cost — you must specify the state, the dynamics and the noise structure, and there are far more ways to specify them badly than for exponential smoothing. When the structure is genuinely unknown and the series is ordinary, a simpler method will usually match it with far less that can go wrong.
- Choose the components deliberately — local level versus local linear trend is a real modelling decision, and the second over-fits short series
- Handle missing observations by skipping the update rather than imputing, which is one of the framework’s genuine advantages
- Fit the variances by maximum likelihood rather than guessing them, since their ratio is what determines how smooth the fitted components are
- Put interventions and regressors into the state when their effect can change over time, and into the observation equation when it cannot
Rolling-origin backtesting with MASE, plus the filter’s own consistency checks — the innovations should be white, and their observed covariance should match the S_t the filter predicted. A model that forecasts adequately while failing those diagnostics is mis-specified in a way accuracy metrics will not reveal.
- Over-specifying the state: every extra component is more variance to estimate, and an unidentifiable one absorbs whatever is left over
- Q set to zero for a component that genuinely moves, which freezes it and pushes the error into the others
- Trusting long-horizon intervals when the state includes a trend, since the variance grows cubically with the horizon there
Anomaly Detection
Primary fitScore the normalized innovation squared: the innovation weighted by the covariance the filter itself predicted for it. That statistic is chi-squared distributed under a correctly-specified filter, so the threshold comes from a distribution rather than from a tuned multiple of a residual standard deviation — a genuinely rare property among detectors.
- Fault detection in navigation and tracking, where a failing sensor shows up as innovations too large for their predicted covariance
- Gating in multi-object tracking: a measurement whose innovation is outside the gate is not associated with that track at all
- Process and equipment monitoring with a physical model of the dynamics available
- Detecting level shifts and structural breaks, which appear as a run of same-signed innovations rather than one large one
The threshold is principled, which is unusual: the filter predicts how surprised it is entitled to be, so an alarm is a statement that the data is inconsistent with the model at a stated confidence rather than a tuned cutoff. It also separates two things most detectors conflate — an outlier, which is one large innovation, and a model failure, which is a sustained run of them. Against it: everything rests on the model being right, so a mis-specified filter produces innovations whose covariance is wrong and a threshold that means nothing, and diagnosing that requires the consistency tests rather than the detector’s own output.
- Tune Q and R on confirmed-clean data first — the detector is only as calibrated as the covariance the filter predicts
- Run the consistency test before trusting any threshold: if the normalized innovations are not chi-squared on clean data, the filter is mis-specified
- Gate measurements before they are used, or a single wild reading is absorbed into the state and corrupts every subsequent innovation
- Distinguish spikes from level shifts by looking at runs of innovations rather than individual values
Precision@k against confirmed faults, plus the normalized-innovation consistency check on clean data — the two together are the standard practice in navigation, and the second is what tells you whether the first number means anything.
- A wild measurement absorbed into the state, after which the filter is wrong and the following innovations are misleading
- Filter divergence producing tiny innovations and a confident, wrong state — the failure that looks like success
- Thresholds carried over from another deployment, since the covariance scale is specific to the fitted Q and R
Optimization
AdaptedTwo connections, both substantive. The filter is recursive least squares: it is the exact solution to a weighted least-squares problem solved incrementally, and the gain is what a batch solve would produce if you re-ran it after every measurement. And in control, the separation principle says the optimal controller for a linear-quadratic-Gaussian problem is exactly the optimal estimator followed by the optimal controller, designed independently — a rare decomposition result that makes an otherwise coupled problem tractable.
- Linear-quadratic-Gaussian control, where the filter is half of a provably optimal controller
- Recursive least squares for online parameter estimation, which is the filter with the state interpreted as coefficients
- The algebraic Riccati equation as a fixed point, whose solution is the steady-state gain and which is the same object that appears in optimal control
It is the clearest case in this section of an estimator with a genuine optimality proof rather than an empirical justification: minimum mean squared error among all estimators under the linear-Gaussian assumptions, and best linear estimator without the Gaussian part. The separation principle is worth the study on its own, because problems that decompose that cleanly are rare and recognizing one is what makes it solvable. What it does not do is optimize anything itself — it estimates, and the controller is a separate object.
- For recursive least squares, the state is the coefficient vector and Q controls how fast the coefficients are allowed to drift
- Scale the state components so the covariance is not dominated by whichever has the largest units
Compare the recursive estimate against a batch least-squares solve on the same data — with Q at zero they must agree to numerical tolerance, and that is an exact correctness test for the implementation.
- Applying the separation principle where the linear-quadratic-Gaussian assumptions do not hold, where it simply is not true
- Forgetting that estimator optimality is conditional on Q and R being right, which in practice they never quite are
- Numerical drift in the covariance turning an optimal estimator into a divergent one
Control & Operations
Primary fitThe standard estimator in navigation, guidance and control. Fuse an inertial measurement unit with GPS, wheel odometry with a laser scan, or a process model with a sparse sensor — the state carries what the system is doing, each sensor enters through its own observation equation with its own noise, and the filter combines them optimally without any of them needing to observe the whole state.
- Inertial navigation with GPS aiding, which is the canonical deployment and the one it was built for
- Robot localization and sensor fusion across sensors with different rates and different noise characteristics
- Process control, where the filter estimates unmeasured internal states for the controller to act on
- Battery state-of-charge and other estimation problems where the quantity of interest is simply not directly measurable
It handles the defining feature of the domain: several partial, noisy, asynchronous views of a system whose true state is not measurable. Each sensor contributes through its own observation equation, missing readings are handled by skipping an update, and the state estimate carries a covariance the controller can act on. It is also cheap enough to run in a control loop on embedded hardware. The limits are the model’s: strong nonlinearity needs an EKF that can diverge or a UKF that costs more, and multi-modal uncertainty — genuinely not knowing which of two places you are — cannot be represented by a single Gaussian at all, which is what particle filters exist for.
- Derive Q from the physics where possible rather than tuning it blindly; it is the parameter that determines whether the filter trusts its model
- Time-stamp and order measurements carefully — out-of-sequence updates corrupt the state, and asynchronous sensors make this the common bug
- Gate measurements before use, so a single wild reading cannot be absorbed into the state
- Check observability of the chosen state: a component the sensors cannot see will drift no matter how well the filter is tuned
Normalized estimation error squared against ground truth where it exists, and the innovation consistency test where it does not. In tracking, evaluate the full pipeline — track fragmentation and identity switches — rather than per-step position error alone.
- Divergence from an over-small Q, where the filter reports high confidence in a state that has drifted away from reality
- EKF linearization error accumulating on a strongly nonlinear system until the estimate is unrecoverable
- A single Gaussian forced onto genuinely multi-modal uncertainty, which produces an estimate sitting between two hypotheses and matching neither
Computer Vision
AdaptedOne filter per tracked object. A detector supplies noisy bounding boxes frame by frame; the filter carries position and velocity as state, predicts where each object should appear next, and the prediction is what associates a new detection with an existing track. That is the core of SORT and its descendants, and the reason a tracker keeps identities through brief occlusions — the state persists when the detection does not.
- Multi-object tracking, where a constant-velocity filter per track is still the standard motion model
- Gating and data association, using the innovation covariance to decide which detections could plausibly belong to which track
- Bridging missed detections and short occlusions, where the predict step continues with no measurement to correct it
- Smoothing jittery detections into stable trajectories for downstream consumption
It supplies the motion prior a per-frame detector has no way to express, at a cost small enough to run hundreds of tracks in real time. The uncertainty is what makes association principled rather than a distance threshold. Its limits are the model again: constant velocity is wrong through a turn, and a long occlusion lets the covariance grow until the gate admits anything. Learned motion models and appearance embeddings are what modern trackers add on top — they do not replace the filter, they supplement it.
- Model position and velocity in a coordinate system where constant velocity is roughly true; image-space pixels are not that system under perspective
- Scale R from the detector’s own confidence where it reports one, so a marginal detection moves the state less
- Cap how long a track survives without a measurement, or the growing covariance turns the gate into an accept-everything rule
Tracking metrics — MOTA, IDF1, identity switches — rather than per-frame localization error. The filter’s contribution shows up as identity preservation through occlusion, which per-frame accuracy does not measure.
- Constant velocity through a sharp turn, where the model is simply wrong and the innovations blow up
- Covariance growth during a long occlusion widening the gate until an unrelated detection is accepted
- One shared Q across objects with very different dynamics, which mis-tunes the filter for most of them
What it costs to run
Nothing to train when the matrices are known — the filter simply runs. Where the noise covariances are fitted, it is a small maximum-likelihood problem over a handful of parameters, with each evaluation one pass of the filter. Seconds.
A few small matrix operations per step with state dimension typically under a dozen, so microseconds and constant memory regardless of series length. This is a streaming algorithm by construction, which is why it runs inside control loops on embedded hardware.
The state updates continuously by design and needs no refit. The noise covariances drift with the sensors and the process, and are typically re-estimated on a much slower schedule — quarterly, or after a hardware change.
- Run the normalized innovation squared consistency test continuously: it should follow its chi-squared distribution, and departure means the model is wrong before any estimate looks wrong
- Watch for innovations that are consistently too small, which is the signature of an over-confident filter beginning to diverge rather than of a good fit
- Track the smallest eigenvalue of the covariance; approaching zero means numerical loss of positive definiteness is imminent
- Monitor measurement rejection rates at the gate, since a rising rate means either a failing sensor or an R that no longer matches it
- The standard covariance update is numerically fragile — use the Joseph form or a square-root filter, because a P that loses positive definiteness produces a confidently wrong state with no error raised
- Out-of-sequence measurements corrupt the state, and with asynchronous sensors this is the most common real bug; timestamps must be respected, not arrival order
- A too-small Q causes divergence that looks like confidence: the filter reports a shrinking covariance while the estimate drifts away from reality
- Symmetrize the covariance after every update, since accumulated round-off makes it asymmetric and then indefinite
- An unobservable state component drifts freely and nothing in the filter reports it — observability has to be checked when the model is designed, not diagnosed afterwards
Pros and cons, in context
Optimal in a precise sense, not merely a good heuristic
Minimum mean squared error among all estimators under the linear-Gaussian assumptions, and the best linear estimator without the Gaussian part. Rare in this section — most entries justify themselves empirically — and it is why the method has lasted since 1960.
Constant memory and constant cost per step
The state summarizes the entire history, so nothing grows with series length. That is what makes it a streaming algorithm and what puts it inside control loops on embedded hardware.
Fuses partial, asynchronous, missing observations naturally
Each sensor gets its own observation equation, a missing reading means skipping the update, and no sensor needs to see the whole state. Handled by the structure of the model rather than by special cases, which is the framework’s real advantage.
Carries calibrated uncertainty and its own diagnostics
The covariance is a first-class output, and the innovation consistency test says whether the model is right. Most methods here have no comparable self-check.
Q and R must be supplied, and getting them wrong is the usual failure
They are rarely known, they are effectively the only tuning, and their ratio determines everything. Almost every field failure of a Kalman filter traces back to this rather than to the algorithm.
Divergence looks like confidence
With Q too small the filter becomes progressively more certain while drifting away from reality, and its reported covariance says everything is fine. The most dangerous failure mode here precisely because nothing errors.
Linear and Gaussian, or approximate
The EKF linearizes and can diverge on strong nonlinearity; the UKF costs more; genuinely multi-modal uncertainty needs a particle filter. Each step away from the assumptions gives up the optimality that motivated the choice.
The model must be specified, and there are many ways to do it badly
State, dynamics, observation and noise structure are all decisions. That generality is the framework’s strength and the reason a simpler method with fewer choices frequently wins on an ordinary series.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Kalman filter - predict and update, transcribed.
Two steps forever. Predict pushes the state forward and lets the uncertainty
grow; update pulls it toward the measurement by an amount set by which one is
trusted more. Matrix operations are written out by hand so the arithmetic is
visible rather than delegated.
"""
def matmul(A, B):
rows, inner, cols = len(A), len(B), len(B[0])
out = [[0.0] * cols for _ in range(rows)]
for i in range(rows):
for j in range(cols):
total = 0.0
for k in range(inner):
total += A[i][k] * B[k][j]
out[i][j] = total
return out
def transpose(A):
return [[A[i][j] for i in range(len(A))] for j in range(len(A[0]))]
def add(A, B):
return [[A[i][j] + B[i][j] for j in range(len(A[0]))] for i in range(len(A))]
def invert(A):
"""Gauss-Jordan on the innovation covariance.
In a real filter this matrix is the size of the MEASUREMENT, not the state,
so it is usually 1x1 or 2x2 - which is why inverting it directly is
acceptable here and inverting the state covariance would not be.
"""
n = len(A)
augmented = [row[:] + [1.0 if i == j else 0.0 for j in range(n)]
for i, row in enumerate(A)]
for column in range(n):
pivot = max(range(column, n), key=lambda r: abs(augmented[r][column]))
if abs(augmented[pivot][column]) < 1e-12:
raise ValueError("innovation covariance is singular")
augmented[column], augmented[pivot] = augmented[pivot], augmented[column]
divisor = augmented[column][column]
for k in range(2 * n):
augmented[column][k] /= divisor
for row in range(n):
if row == column:
continue
factor = augmented[row][column]
for k in range(2 * n):
augmented[row][k] -= factor * augmented[column][k]
return [row[n:] for row in augmented]
def predict(x, P, F, Q):
"""x <- F x, P <- F P F^T + Q. Uncertainty GROWS: time passing makes the
estimate worse, which is what Q encodes."""
x_next = matmul(F, x)
P_next = add(matmul(matmul(F, P), transpose(F)), Q)
return x_next, P_next
def update(x, P, y, H, R):
"""The gain is prediction uncertainty over TOTAL uncertainty - the trust
ratio, written as arithmetic."""
n = len(x)
# innovation: what was measured minus what was expected
predicted = matmul(H, x)
v = [[y[i][0] - predicted[i][0]] for i in range(len(y))]
# S = H P H^T + R: how surprised the filter was entitled to be
S = add(matmul(matmul(H, P), transpose(H)), R)
K = matmul(matmul(P, transpose(H)), invert(S))
x_next = add(x, matmul(K, v))
# P <- (I - K H) P. Simple, and numerically the fragile form: rounding
# can drive P non-symmetric and then indefinite, at which point the
# filter reports impossible confidence.
identity = [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
factor = [[identity[i][j] - matmul(K, H)[i][j] for j in range(n)] for i in range(n)]
P_next = matmul(factor, P)
return x_next, P_next
def run(observations, x0, P0, F, H, Q, R):
x, P = x0, P0
states = []
for y in observations:
x, P = predict(x, P, F, Q)
x, P = update(x, P, y, H, R)
states.append(x)
return statesO(n_x^3) per step in hand-written triple loops, and matmul(K, H) is recomputed inside the covariance update for every element.
How this language actually wants it written.
"""Kalman filter - typed, Joseph form, log-likelihood accumulated."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from scipy.linalg import cho_factor, cho_solve
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass(frozen=True)
class LinearGaussianModel:
"""The model: dynamics, observation, and the two noise covariances.
Q and R are the only real tuning here, and their RATIO is what decides
whether the filter trusts its own prediction or the measurement. Almost
every field failure of a Kalman filter traces back to this rather than to
the algorithm.
"""
F: Matrix # (n_x, n_x) state transition
H: Matrix # (n_y, n_x) observation
Q: Matrix # (n_x, n_x) process noise
R: Matrix # (n_y, n_y) measurement noise
def __post_init__(self) -> None:
if self.F.shape[0] != self.F.shape[1]:
raise ValueError(f"F must be square, got {self.F.shape}")
if self.H.shape[1] != self.F.shape[0]:
raise ValueError(f"H has {self.H.shape[1]} columns but the state is {self.F.shape[0]}")
if not np.allclose(self.Q, self.Q.T) or not np.allclose(self.R, self.R.T):
raise ValueError("noise covariances must be symmetric")
@dataclass
class FilterState:
mean: Vector
covariance: Matrix
log_likelihood: float = 0.0
def predict(state: FilterState, model: LinearGaussianModel) -> FilterState:
"""Uncertainty GROWS: time passing makes the estimate worse."""
mean = model.F @ state.mean
covariance = model.F @ state.covariance @ model.F.T + model.Q
return FilterState(mean, _symmetrize(covariance), state.log_likelihood)
def update(state: FilterState, observation: Vector, model: LinearGaussianModel) -> FilterState:
"""Joseph form update, and the log-likelihood contribution for free."""
innovation = observation - model.H @ state.mean
S = model.H @ state.covariance @ model.H.T + model.R
# Cholesky rather than an explicit inverse: S is symmetric positive
# definite by construction, and its log-determinant - needed for the
# likelihood - falls out of the factor at no extra cost.
factor = cho_factor(S, lower=True)
gain = cho_solve(factor, model.H @ state.covariance).T
mean = state.mean + gain @ innovation
# JOSEPH FORM: (I - KH) P (I - KH)^T + K R K^T.
#
# Algebraically identical to (I - KH) P, and numerically far better: it is
# a sum of two symmetric positive-semidefinite terms, so it stays positive
# definite under round-off. The simple form does not, and a covariance
# that loses positive definiteness makes the filter report impossible
# confidence and diverge - with nothing raised.
identity = np.eye(state.mean.size)
closed = identity - gain @ model.H
covariance = closed @ state.covariance @ closed.T + gain @ model.R @ gain.T
# Prediction-error decomposition: the filter produces the one-step
# predictive density as it runs, so the likelihood costs nothing extra.
lower = factor[0]
log_det = 2.0 * np.log(np.diag(lower)).sum()
quadratic = float(innovation @ cho_solve(factor, innovation))
contribution = -0.5 * (log_det + quadratic + innovation.size * np.log(2.0 * np.pi))
return FilterState(mean, _symmetrize(covariance), state.log_likelihood + contribution)
def _symmetrize(covariance: Matrix) -> Matrix:
"""Round-off makes P asymmetric, and asymmetric becomes indefinite."""
return 0.5 * (covariance + covariance.T)
def run(
observations: Matrix,
initial: FilterState,
model: LinearGaussianModel,
) -> tuple[list[FilterState], float]:
"""Filter a whole series. A missing observation is a skipped update - the
state simply propagates and the uncertainty grows, which is one of the
framework's genuine advantages over a lag-based model."""
if observations.ndim != 2 or observations.shape[1] != model.H.shape[0]:
raise ValueError(f"expected (T, {model.H.shape[0]}) observations, got {observations.shape}")
state = initial
history: list[FilterState] = []
for observation in observations:
state = predict(state, model)
if np.isfinite(observation).all():
state = update(state, observation, model)
history.append(state)
return history, state.log_likelihoodWhat changed: The covariance update changes to the Joseph form, and that is a correctness change rather than a stylistic one: it is algebraically identical to the simple form and numerically far better, because it is a sum of two positive-semidefinite terms and therefore stays positive definite under round-off. A covariance that loses positive definiteness makes the filter report impossible confidence and diverge with nothing raised, which is the failure this entry names as the most dangerous. The explicit inverse becomes a Cholesky solve, whose log-determinant supplies the likelihood contribution at no extra cost, and the covariance is symmetrized after every step since accumulated asymmetry is how indefiniteness begins.
O(n_x^3) per step in LAPACK, with the likelihood accumulated as a by-product of the solve.
Faster, and the justification for each change.
"""Kalman filter - steady-state gain, and vectorized across many tracks."""
import numpy as np
from numpy.typing import NDArray
from scipy.linalg import solve_discrete_are
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
Tensor = NDArray[np.float64]
def steady_state_gain(F: Matrix, H: Matrix, Q: Matrix, R: Matrix) -> Matrix:
"""For a TIME-INVARIANT system the covariance recursion converges.
P is a fixed point of the discrete algebraic Riccati equation, so after a
transient the gain stops changing. Solving for that fixed point once
removes the entire covariance propagation from the loop - the O(n_x^3)
work per step disappears and each step becomes two matrix-vector products.
The cost is that the transient is gone too: the filter no longer converges
from an uncertain start, it simply begins at steady state. That is right
for a long-running tracker and wrong for a filter that must recover from
a bad initialization.
"""
P = solve_discrete_are(F.T, H.T, Q, R)
S = H @ P @ H.T + R
return P @ H.T @ np.linalg.inv(S)
class BatchTracker:
"""Many independent filters, one interpreter iteration per timestep.
A tracker runs hundreds of tracks with identical dynamics. Each is
sequential in time, but they are completely INDEPENDENT of one another -
so the loop over tracks disappears and only the loop over time remains,
exactly as for a panel of time series.
"""
def __init__(self, F: Matrix, H: Matrix, gain: Matrix, n_tracks: int) -> None:
# One contiguous float64 block per operand: every step multiplies
# against these, and a non-contiguous array forces a copy each time.
self._F = np.ascontiguousarray(F, dtype=np.float64)
self._H = np.ascontiguousarray(H, dtype=np.float64)
self._K = np.ascontiguousarray(gain, dtype=np.float64)
n_x = F.shape[0]
# State for every track, allocated once and updated in place.
self._state = np.zeros((n_tracks, n_x), dtype=np.float64)
self._predicted = np.empty_like(self._state)
self._innovation = np.empty((n_tracks, H.shape[0]), dtype=np.float64)
def step(self, measurements: Matrix, valid: NDArray) -> Matrix:
"""One timestep for every track at once.
measurements is (n_tracks, n_y); valid marks which tracks actually
received one. A track with no measurement simply keeps its prediction,
which is how occlusion is handled.
"""
# Predict: state @ F.T is the batched form of F @ x for every track.
np.matmul(self._state, self._F.T, out=self._predicted)
# Innovation for every track, built in place.
np.matmul(self._predicted, self._H.T, out=self._innovation)
np.subtract(measurements, self._innovation, out=self._innovation)
# Update only the tracks that were measured; the rest carry the
# prediction forward untouched.
np.copyto(self._state, self._predicted)
if valid.any():
self._state[valid] += self._innovation[valid] @ self._K.T
return self._state
def gate(self, measurements: Matrix, threshold: float, inverse_S: Matrix) -> NDArray:
"""Normalized innovation squared, for every track at once.
Chi-squared distributed under a correct model, so the threshold comes
from a distribution rather than a tuned multiple - which is what makes
this gate principled and a distance cutoff not.
"""
np.matmul(self._state, self._H.T, out=self._innovation)
np.subtract(measurements, self._innovation, out=self._innovation)
# v^T S^-1 v for every track, fused so the intermediate never exists.
nis = np.einsum("ij,jk,ik->i", self._innovation, inverse_S, self._innovation)
return nis < thresholdWhat changed: Two changes, and the first is algorithmic. For a time-invariant system the covariance recursion converges to a fixed point of the Riccati equation, so the gain stops changing — solving for it once removes the entire O(n_x^3) covariance propagation from the loop, and each step becomes two matrix-vector products. The trade is stated: the transient goes with it, so the filter begins at steady state rather than converging from an uncertain start. The second change is the same loop inversion the exponential-smoothing entry makes: a tracker runs hundreds of independent filters with identical dynamics, so the loop over tracks disappears and only the loop over time remains.
Eliminate Python-level loops over samples
Hundreds of independent tracks are advanced as one batched matrix product per timestep, so the interpreter runs once per step rather than once per track per step.
Trade-off: Every track must share the same dynamics, observation model and gain — which rules out per-track tuning, and a tracker with genuinely different object classes has to batch them separately.
Pre-allocate output arrays and use in-place operations
The state, prediction and innovation buffers are allocated once and rewritten every step, rather than allocating three arrays per timestep for the whole track set.
Trade-off: The buffers are shared mutable state on the object, so it is not reentrant, and the in-place chain reuses the innovation buffer for two different quantities in sequence.
Fuse adjacent operations so intermediates are never materialized
The normalized innovation squared for every track is a single einsum, so the intermediate S-inverse-times-innovation product never exists as an array.
Trade-off: The einsum expresses a quadratic form in a way that is materially harder to read than two matrix products, and einsum will not always pick the optimal contraction order without being told.
O(n_tracks * n_x^2) per timestep with no covariance work, versus O(n_tracks * n_x^3). Illustrative, not a measured benchmark.