Back to Classical ML
AI/ML · Classical ML

Kalman Filter & State-Space Models

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.

SupervisedUnsupervisedRegressionSequence modelingDensity estimationAnomaly detection

Two uses with one algorithm, and they classify differently. Forecasting observations is supervised in the same sense ARIMA is; estimating a latent state that is never measured — a position from noisy sensors — is unsupervised latent-variable inference. The filter does both because the state and the observation are separate objects in the model, which is precisely what the earlier entries in this group do not have.

Also known as: Linear-Gaussian state space, LQE, EKF / UKF (variants), RTS smoother, Structural time series, BayesDLM, Dynamic linear model

Intuition

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.

Assumptions it makes
  • 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
Objective

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
Optimization

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.

Hyperparameters
  • 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

Convergence & cost

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.

Applications

How, where, and why it is used

Time-Series Forecasting

Primary fit
How

Structural 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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 fit
How

Score 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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

Adapted
How

Two 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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
Also applied in

Control & Operations

Primary fit
How

The 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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

Adapted
How

One 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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
Deployment

What it costs to run

Training

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.

Inference

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.

Retraining

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.

Drift & monitoring
  • 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
Production gotchas
  • 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
Trade-offs

Pros and cons, in context

Strengths
  • 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.

Limitations
  • 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.

Implementation

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.

1. Intuitive

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 states

O(n_x^3) per step in hand-written triple loops, and matmul(K, H) is recomputed inside the covariance update for every element.

2. IdiomaticNumPy + SciPy

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_likelihood

What 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.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(n_x^3) per step in LAPACK, with the likelihood accumulated as a by-product of the solve.

3. OptimizedNumPy + SciPy

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 < threshold

What 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.