← All notes and experiments

Kalman filtering: from sequential fitting to vehicle tracking

A least-squares fit uses all observations at once. A Kalman filter updates a state estimate as each observation arrives. We connect the two approaches, then track a moving vehicle and compare filtering, smoothing and a batch fit.

Prerequisites: Gaussian errors, covariance matrices and Python/NumPy. Install projects/requirements.txt. Run from the repository root or this notebook directory; the setup cell locates the shared projects/src code. All observations are synthetic and reproducible.

Show or hide code
from pathlib import Path
import sys
# Works from the repository root, projects/, or projects/notebooks/.
project_dir = next(p / "projects" for p in [Path.cwd(), *Path.cwd().parents]
                   if (p / "projects" / "src").is_dir())
sys.path.insert(0, str(project_dir / "src"))
import logging
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
from Measurement import Measurement, generate_data
from ModelState import ModelState, ModelStatePoly1
from KalmanFilter import KalmanFilter
logging.getLogger().setLevel(logging.WARNING)
pio.renderers.default = "notebook_connected"
np.random.seed(1234)

true_coeffs = np.array([3., 2.])  # descending powers: [slope, intercept]
measurements, y_true = generate_data(true_coeffs, num_points=12, noise_level=2.)

Kalman Filters

A Kalman filter is a recursive mathematical algorithm used to estimate the state of a dynamic system from a series of noisy and potentially incomplete measurements. It operates in two main steps: prediction and update. In the prediction step, the filter uses a mathematical model of the system to forecast its next state. In the update step, it incorporates new measurements, weighing them according to their estimated reliability, to refine its estimate. This process repeats in real time, allowing the filter to continuously improve its estimate as new data arrives.

One of the key elements of the Kalman filter is the need of mathematical model to allow prediction and updates to the state. This model can be learned from data if one has an appropriate data sample.

The Kalman filter is especially valuable because it can estimate variables that are not directly observable, and it does so in a way that minimizes the mean squared error of the estimates. It is most effective for linear systems with Gaussian noise, but extensions such as the Extended Kalman Filter (EKF), Gaussian Sum Filter (GSF) and Unscented Kalman Filter (UKF) exist for nonlinear problems.

A simple example

To fit a polynomial using a Kalman filter, we will set up a system that estimates the parameters of the polynomial in real-time. Given a polynomial has the form:

$$ y = a_0 + a_1 \cdot x + a_2 \cdot x^2+ \cdots + a_n \cdot x^n $$

Here, $a_0$, $a_1, \cdots a_n$ are the parameters to estimate. We'll construct a Kalman filter to estimate these coefficients based on a series of noisy observations.

State-Space Representation

We need to represent the problem in a state-space form suitable for applying the Kalman filter.

  1. State Vector $\mathbf{x} \in \mathbb{R}^{p} $: The state vector will consist of the polynomial coefficients:

    $$ \mathbf{x} = \begin{bmatrix} a_0 \\ a_1 \\ \vdots \\a_n \end{bmatrix} $$

  2. State Transition Model: The state transition equation is defined to be: $$ x_{k+1} = F x_k + N( 0, Q_{k+1}) $$ where $x_k$ is the state vector at time $k$, $F$ is the transition matrix and $Q_k$ is the process noise covariance which defines the expected variability or uncertainty in the models dynamics.

    Since we are only estimating the coefficients of a polynomial that are not changing over time (static parameters), the state transition model can be considered as an identity (i.e., the state does not change):

    $$ \mathbf{x}_{k+1} = \mathbf{x}_k $$

    Therefore, the state transition matrix $\mathbf{F}$ is:

    $$ \mathbf{F} = \mathbf{I} $$

  3. Observation Model: The observed data $y_k$ at a point $x_k$ can be modeled as:

    $$ y_k = a_0 + a_1 \cdot x_k + a_2 \cdot x_k^2+ \cdots + a_n \cdot x_k^n + v_k $$

    where $v_k$ is the measurement noise.

    This can be written in matrix form as:

    $$ y_k = \begin{bmatrix} 1 & x_k & \cdots & x_k^n \end{bmatrix} \begin{bmatrix} a_0 \\ a_1 \\ \vdots \\ a_n \end{bmatrix} + v_k $$

    Therefore, the observation matrix $\mathbf{H}_k$ is:

    $$ \mathbf{H}_k = \begin{bmatrix} 1 & x_k & \cdots & x_k^n \end{bmatrix} $$

Kalman Filter Equations

The Kalman filter consists of two main steps: prediction and update.

1. Prediction Step

$$ \mathbf{x}_{k|k-1} = \mathbf{F} \cdot \mathbf{x}_{k-1|k-1} $$

$$ \mathbf{P}_{k|k-1} = \mathbf{F} \cdot \mathbf{P}_{k-1|k-1} \cdot \mathbf{F}^\top + \mathbf{Q} $$

where:

  • $\mathbf{x}_{k|k-1} \in \mathbb{R}^{p}$ is the predicted state.
  • $\mathbf{P}_{k|k-1} \in \mathbb{R}^{p \times p}$ is the predicted state covariance.
  • $\mathbf{Q} \in \mathbb{R}^{p \times p}$ is the process noise covariance.

2. Update Step

$$ \mathbf{r}_k = z_k - \mathbf{H}_k \cdot \mathbf{x}_{k|k-1} $$

$$ \mathbf{S}_k = \mathbf{H}_k \cdot \mathbf{P}_{k|k-1} \cdot \mathbf{H}_k^\top + \mathbf{R_k} $$

$$ \mathbf{K}_k = \mathbf{P}_{k|k-1} \cdot \mathbf{H}_k^\top \cdot \mathbf{S}_k^{-1} $$

$$ \mathbf{x}_{k|k} = \mathbf{x}_{k|k-1} + \mathbf{K}_k \cdot \mathbf{r}_k $$

$$ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \cdot \mathbf{H}_k) \cdot \mathbf{P}_{k|k-1} $$

where:

  • $\mathbf{r}_k \in \mathbb{R}^{m}:$ is the innovation or measurement residual.
  • $\mathbf{S}_k \in \mathbb{R}^{m \times m}:$ is the innovation covariance.
  • $\mathbf{K}_k \in \mathbb{R}^{p \times m}:$ is the Kalman gain.
  • $\mathbf{R_k} \in \mathbb{R}^{m \times m}:$ is the measurement noise covariance.

It should be noted that the Joseph form of the covariance matrix can be used for better numerical stability: $$ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \cdot \mathbf{H}_k) \cdot \mathbf{P}_{k|k-1} \cdot (\mathbf{I} - \mathbf{K}_k \cdot \mathbf{H}_k)^T + \mathbf{K}_k \mathbf{R}_k \mathbf{K}_k^T $$

which can be shown to be equivalent to the normal form by first expanding the first term:

$$ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} - (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{K}_k^T + \mathbf{K}_k \mathbf{R}_k \mathbf{K}_k^T $$ Substituting in $\mathbf{K}_k = \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{S}_k^{-1}$: $$ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} - (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{K}_k^T + \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{S}_k^{-1} \mathbf{R}_k \mathbf{S}_k^{-1} \mathbf{H}_k \mathbf{P}_{k|k-1} $$

  • The term $(\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} \mathbf{H}_k^T$ simplifies to $\mathbf{K}_k \mathbf{R}_k$.
  • The cross terms involving $\mathbf{K}_k \mathbf{R}_k \mathbf{K}_k^T$ and $\mathbf{K}_k \mathbf{H}_k \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{K}_k^T$ cancel out due to the optimal gain definition.

After cancellation, the Joseph form reduces to:

$$ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1}, $$

matching the compact covariance update in exact arithmetic. The implementation uses the Joseph form

Python Implementation

Here is a simple Python implementation of a Kalman filter to fit a polynomial:

Explanation of the Code

  1. Initialization:

    • For fixed polynomial coefficients we use zero process noise; the measurement covariance comes from the supplied uncertainties.
    • A zero initial mean with a large finite covariance expresses a diffuse prior over the coefficients, rather than an absence of prior information.
  2. Prediction and Update Steps:

    • For each observed data point $(x_k, y_k)$, we perform the prediction step to estimate the next state.
    • We then construct the observation matrix $H_k$ based on the current input $x_k$ and perform the update step using the noisy measurement $y_k$.
  3. Output:

    • The filter updates its estimate of the polynomial coefficients with each new data point. The estimates should converge towards the true coefficients as more data is processed.

This approach demonstrates how the Kalman filter can be used for online, real-time estimation of parameters in a polynomial model, which is particularly useful in applications where data is arriving sequentially, and we want to update our estimates dynamically.

Prediction and update equations

For a linear model, the predicted state and covariance are $$x^-_k=F x^+_{k-1},\qquad P^-_k=F P^+_{k-1}F^T+Q.$$ With innovation $r_k=z_k-H_kx^-_k$ and covariance $S_k=H_kP^-_kH_k^T+R_k$, the gain and update are $$K_k=P^-_kH_k^TS_k^{-1},\qquad x^+_k=x^-_k+K_kr_k.$$ The shared implementation uses the Joseph covariance update $P^+=(I-KH)P^-(I-KH)^T+KRK^T$ to limit numerical asymmetry. The state classes define the observation matrix; the filter handles the update.

Show or hide code
# For y = a + b*x, ModelStatePoly1 stores [a, b] as a column vector.
example_state = ModelStatePoly1(np.zeros((2, 1)), np.array([2.]), np.eye(2))
np.testing.assert_array_equal(example_state.calc_H(), [[1., 2.]])
print("Observation matrix at x=2:", example_state.calc_H())
Observation matrix at x=2: [[1. 2.]]

A small exact-line check

With zero process noise, the polynomial coefficients are fixed. Repeated measurements should recover a known line. We also check the RTS smoother, which uses future observations to refine earlier states without counting any measurement twice. The filter is configured for ordered, fixed-step observations.

Show or hide code
test_measurements = [Measurement(np.array([[1.+x]]), np.array([float(x)]), np.array([[0.1]]))
                     for x in range(4)]
initial = ModelStatePoly1(np.zeros((2, 1)), np.array([0.]), np.eye(2)*1e6)
kf_test = KalmanFilter(initial, np.zeros((2, 2)), np.eye(2))
filtered = kf_test.fit(test_measurements)
smoothed = kf_test.smooth_rts(test_measurements, filtered)
np.testing.assert_allclose(filtered[-1].x.ravel(), [1., 1.], atol=1e-6)
assert all(np.trace(s.x_cov) <= np.trace(f.x_cov)+1e-8
           for s, f in zip(smoothed, filtered[1:]))
print("Recovered [intercept, slope]:", filtered[-1].x.ravel())
Recovered [intercept, slope]: [0.99999996 1.00000001]

The last smoothed estimate equals the last filtered estimate; earlier estimates can use the remaining observations.

Run Kalman Filter

Show or hide code
initial_state = ModelStatePoly1(np.zeros((2, 1)), np.array([0.]), np.eye(2)*1e6)
kf = KalmanFilter(initial_state, np.zeros((2, 2)), np.eye(2))
states = kf.fit(measurements)
x_data = np.array([m.global_pos.item() for m in measurements])
y_data = np.array([m.local_pos.item() for m in measurements])
sigma = np.array([np.sqrt(m.local_cov.item()) for m in measurements])
X = np.column_stack([np.ones_like(x_data), x_data])
batch_coeffs = np.linalg.lstsq(X/sigma[:, None], y_data/sigma, rcond=None)[0]
np.testing.assert_allclose(states[-1].x.ravel(), batch_coeffs, atol=1e-5)
print("Sequential [intercept, slope]:", states[-1].x.ravel())
print("Batch WLS [intercept, slope]:", batch_coeffs)
Sequential [intercept, slope]: [1.97962989 3.02507162]
Batch WLS [intercept, slope]: [1.97963236 3.02507129]

Plotting the results with Plotly

Show or hide code
from plotly import graph_objects as go

def make_kalman_slider( measurements: list , states: list, true_coeffs: np.ndarray) -> go.Figure:
    """
    Generate Kalman filter visualization for first n_points measurements.

    Parameters:
    -----------
    n_points : int
        Number of measurements to process (1 to len(x_data))

    Returns:
    --------
    plotly.graph_objects.Figure
        Interactive plot showing Kalman filter evolution
    """
    # Ensure n_points is within valid range
    y_max = np.max( [measurement.local_pos for measurement in measurements] )
    y_min = np.min( [measurement.local_pos for measurement in measurements] )
    y_std = np.std( [measurement.local_pos for measurement in measurements] )

    y_min = np.floor( y_min - 1 * y_std)
    y_max = np.ceil( y_max + 1 * y_std )
    x_min = np.min( [measurement.global_pos for measurement in measurements] )
    x_max = np.max( [measurement.global_pos for measurement in measurements] )
    x_delta = x_max - x_min
    x_min -= 0.05 * x_delta
    x_max += 0.05 * x_delta

    n_points =  len(measurements)

    # Get current data subset
    x_current = np.zeros(n_points)
    y_current = np.zeros(n_points)
    y_uncertainty = np.zeros(n_points)


    for i in range(n_points):
        measurement = measurements[i]
        x_current[i] = measurement.global_pos[0]
        y_current[i] = measurement.local_pos[0]
        y_uncertainty[i] = np.sqrt(measurement.local_cov[0, 0])

    x_fit = np.linspace(x_min, x_max, 100)
    y_true = np.zeros_like(x_fit)
    for i in range(len(true_coeffs)):
        y_true += true_coeffs[::-1][i] * x_fit**i


    # Create Plotly figure
    fig = go.Figure() 



    # Add measurement points with error bars
    fig.add_trace(go.Scatter(
        x=x_current,
        y=y_current,
        mode='markers',
        name='Measurements',
        marker=dict(size=8, color='red'),
        error_y=dict(
            type='data',
            array=y_uncertainty,
            visible=True,
            color='red'
        )
    ))

    # Add true polynomial curve
    fig.add_trace(go.Scatter(
        x=x_fit,
        y=y_true,
        mode='lines',
        name='True Polynomial',
        line=dict(color='green', width=2, dash='dash')
    ))


    for i in range(0,n_points):
        state = states[i+1]  # states[0] is initial state, skip it
        # Generate fitted polynomial curve using final estimates
        final_coeffs = state.x.ravel()
        y_fit = np.zeros_like(x_fit)
        for j in range(len(final_coeffs)):
            y_fit += final_coeffs[j] * x_fit**j

        # Calculate uncertainty band for fitted curve
        y_fit_uncertainty = np.zeros_like(x_fit)
        for j in range(len(x_fit)):
            x_val = x_fit[j]
            H_val = np.zeros((1, len(final_coeffs)))
            for k in range(len(final_coeffs)):
                H_val[0, k] = x_val**k
            variance = H_val @ state.x_cov @ H_val.T
            y_fit_uncertainty[j] = np.sqrt(variance[0, 0])

        fig.add_trace(go.Scatter(
          x=x_current[:i+1],
          y=y_current[:i+1],
          mode='markers',
          name='Used observations',
          marker=dict(size=12, color='orange'),
          visible=False,
          showlegend=True
        ))

        # Add Kalman filter estimate
        fig.add_trace(go.Scatter(
            x=x_fit,
            y=y_fit,
            mode='lines',
            name=f'Estimate ({i+1} points)',
            visible=False,
            line=dict(color='blue', width=2)
        ))

        # Add uncertainty band
        fig.add_trace(go.Scatter(
            x=np.concatenate([x_fit, x_fit[::-1]]),
            y=np.concatenate([y_fit - 2 * y_fit_uncertainty, 
                            (y_fit + 2 * y_fit_uncertainty)[::-1]]),
            fill='toself',
            fillcolor='rgba(0,100,80,0.2)',
            line=dict(color='rgba(255,255,255,0)'),
            visible=False,
            name='2σ Uncertainty'
        ))



    # Update layout
    fig.update_layout(
        title=f'Kalman Filter Polynomial Fitting - {n_points} Measurements Processed',
        xaxis_title='x',
        yaxis_title='y',
        xaxis_range=[-0.5+x_min, x_max + 0.5],
        yaxis_range=[y_min, y_max],
        height=600,
        showlegend=True,
        legend=dict(
          yanchor="top",
          y=0.99,
          xanchor="left",
          x=0.01
        )
    )

    # Create slider steps
    steps = []
    for i in range(n_points):
        step = dict(
            method="restyle",
            args=["visible", [False] * ((n_points)*3+2)],
            label=f"{i+1} points"
        )
        step["args"][1][0] =True  # Show measurements
        step["args"][1][1] = True 
        step["args"][1][i*3+2] = True
        step["args"][1][i*3+3] = True  # Show only the i-th trace
        step["args"][1][i*3+4] = True

        steps.append(step)

    sliders = [dict(
        active=0,
        currentvalue={"prefix": "Number of points: "},
        pad={"t": 50},
        steps=steps
    )]

    fig.update_layout(
        sliders=sliders,
        title="Sequential fit"
    )

    for trace in fig.data[2:5]:
        trace.visible = True
    return fig

fig = make_kalman_slider(measurements , states, true_coeffs)
fig.show()

Tracking position and velocity

Now the state is $x=[s,v]^T$, not a pair of polynomial coefficients. At fixed interval $\Delta t$, $F=\begin{pmatrix}1&\Delta t\0&1\end{pmatrix}$ and $H=[1,0]$: only position is observed. We use integrated white acceleration with spectral density $q$, $$Q=q\begin{pmatrix}\Delta t^3/3&\Delta t^2/2\\Delta t^2/2&\Delta t\end{pmatrix}.$$ The same $F,Q$ generate the truth and drive the filter, so any differences are estimation errors rather than an inconsistent simulation. The prior describes the state at the first observation time, before seeing that observation.

Reading the vehicle model step by step

Here the state components have physical meanings: $s$ is position and $v$ is velocity. Over an interval $\Delta t$, the mean prediction is $$\begin{pmatrix}s_{k+1}^-\\v_{k+1}^-\end{pmatrix} =\begin{pmatrix}1&\Delta t\\0&1\end{pmatrix} \begin{pmatrix}s_k^+\\v_k^+\end{pmatrix}.$$ Thus the predicted position advances by $v_k\Delta t$, while the predicted mean velocity stays the same. Random acceleration produces departures from that mean; its integrated covariance is the $Q$ given above. Position and velocity process errors are correlated, which is why $Q$ has off-diagonal entries.

Only position is measured in this example: $$z_k=H x_k+\eta_k,\qquad H=[1\;0],\qquad \eta_k\sim\mathcal N(0,R).$$ Velocity is inferred from successive positions and their dynamical relationship, rather than from a separate velocity sensor. The prediction carries the whole state covariance forward, including its position–velocity correlation: $$P_k^-=F P_{k-1}^+F^T+Q.$$

The innovation compares the new reading with its predicted position, $$r_k=z_k-Hx_k^-,\qquad S_k=HP_k^-H^T+R.$$ The gain $K_k=P_k^-H^TS_k^{-1}$ has one entry for each state component, so a single position innovation can update both position and velocity: $$x_k^+=x_k^-+K_kr_k.$$ The velocity update is possible because prediction develops a correlation between the two state components. The Joseph covariance update then accounts for the information supplied by that measurement.

A larger $R$ expresses less trust in the sensor. A larger process-noise strength expresses less confidence in the constant-velocity prediction. These assumptions affect both the responsiveness and reported uncertainty. A finite initial covariance is also prior information, even when the initial mean is zero.

Show or hide code
class ModelState1dVelocity(ModelState):
    """State [position, velocity], observed through position only."""
    def calc_H(self):
        return np.array([[1., 0.]])

rng = np.random.default_rng(42)
dt, num_steps, q = 1., 40, 0.01
A = np.array([[1., dt], [0., 1.]])
Q = q * np.array([[dt**3/3, dt**2/2], [dt**2/2, dt]])
measurement_noise_variance = 1.
R = np.array([[measurement_noise_variance]])
time_steps = np.arange(num_steps) * dt
truth = np.empty((num_steps, 2))
truth[0] = [0., 1.]
for k in range(1, num_steps):
    truth[k] = A @ truth[k-1] + rng.multivariate_normal(np.zeros(2), Q)
true_positions, true_velocities = truth.T
measurements_pos = true_positions + rng.normal(0, 1., num_steps)
measurements = [Measurement(np.array([[z]]), np.array([t]), R.copy())
                for t, z in zip(time_steps, measurements_pos)]
initial_state = ModelState1dVelocity(np.array([[0.], [1.]]), np.array([0.]), np.eye(2)*10.)
kf = KalmanFilter(initial_state, Q, A)
states = kf.fit(measurements)
states_smoothed = kf.smooth_rts(measurements, states)
Show or hide code
filtered_xy = np.array([s.x.ravel() for s in states[1:]])
smoothed_xy = np.array([s.x.ravel() for s in states_smoothed])
fig_vehicle = go.Figure()
fig_vehicle.add_scatter(x=time_steps, y=true_positions, name="Truth")
fig_vehicle.add_scatter(x=time_steps, y=measurements_pos, mode="markers", name="Measurements")
fig_vehicle.add_scatter(x=time_steps, y=filtered_xy[:, 0], name="Filtered")
fig_vehicle.add_scatter(x=time_steps, y=smoothed_xy[:, 0], name="RTS smoothed")
unc = np.sqrt([s.x_cov[0, 0] for s in states_smoothed])
fig_vehicle.add_scatter(x=np.r_[time_steps, time_steps[::-1]],
    y=np.r_[smoothed_xy[:, 0]-2*unc, (smoothed_xy[:, 0]+2*unc)[::-1]],
    fill='toself', line=dict(width=0), opacity=0.2, name='Smoothed ±2 sigma')
fig_vehicle.update_layout(title="Vehicle position: filtering and smoothing", xaxis_title="Time [s]", yaxis_title="Position [m]")
fig_vehicle.show()
for label, estimate in [('Measured', measurements_pos), ('Filtered', filtered_xy[:, 0]), ('Smoothed', smoothed_xy[:, 0])]:
    print(f"{label:10s} position RMSE: {np.sqrt(np.mean((estimate-true_positions)**2)):.3f} m")
Measured   position RMSE: 0.778 m
Filtered   position RMSE: 0.530 m
Smoothed   position RMSE: 0.330 m

Batch fit with process constraints

For the same linear Gaussian model, filtering followed by RTS smoothing is another way to solve one global quadratic problem: $$\chi^2=\|x_0-m_0\|_{P_0^{-1}}^2+ \sum_k\|z_k-Hx_k\|_{R^{-1}}^2+ \sum_{k=1}^{N-1}\|x_k-Fx_{k-1}\|_{Q^{-1}}^2.$$ Here $\|u\|_W^2=u^TWu$. We whiten each residual block with a Cholesky solve, then solve the stacked least-squares system. This is a process-noise example; a detector scattering model would need its own propagation and covariance. The first observation is at the prior time, so no extra propagation is added.

Constructing the global least-squares system

Stack all states into one vector $$u=[x_0^T,x_1^T,\ldots,x_{N-1}^T]^T.$$ There are two unknowns per time in this example. The global fit combines three types of residual, each with its own covariance:

  1. The prior row block selects $x_0$, with target $m_0$ and covariance $P_0$.
  2. A measurement row places $H$ in the columns for $x_k$, with target $z_k$ and covariance $R$.
  3. A process row block places $-F$ in the columns for $x_{k-1}$ and $I$ in those for $x_k$, with target zero and covariance $Q$.

For any block $Bu-b$ with covariance $C=LL^T$, whitening gives $$\widetilde B=L^{-1}B,\qquad\widetilde b=L^{-1}b.$$ Its squared Euclidean residual is exactly the corresponding covariance-weighted term in $\chi^2$. Stacking the whitened blocks produces one ordinary least-squares problem, $$\widehat u=\operatorname*{argmin}_u\|Au-b\|^2.$$ The implementation uses a least-squares solve for the mean. Under these linear Gaussian assumptions the full state covariance is $(A^TA)^{-1}$; its diagonal $2\times2$ blocks can be compared with the RTS marginal covariances.

The process residual $x_k-Fx_{k-1}$ represents the inferred random change between times. It is penalised according to $Q$, rather than forced to zero. A detector model with explicit scattering kicks would instead need its own kick variables, propagation matrices and constraints. Here the batch fit deliberately uses the same vehicle model as the filter and smoother, making their comparison meaningful.

Show or hide code
def batch_state_fit(z, F, Q, R, prior_mean, prior_cov):
    n, d = len(z), len(prior_mean)
    rows, rhs = [], []
    H = np.array([[1., 0.]])
    def add(block, value, covariance):
        L = np.linalg.cholesky(covariance)
        rows.append(np.linalg.solve(L, block))
        rhs.append(np.linalg.solve(L, np.asarray(value).reshape(-1)))
    block = np.zeros((d, n*d)); block[:, :d] = np.eye(d)
    add(block, prior_mean, prior_cov)
    for k in range(n):
        block = np.zeros((1, n*d)); block[:, k*d:(k+1)*d] = H
        add(block, [z[k]], R)
        if k:
            block = np.zeros((d, n*d))
            block[:, (k-1)*d:k*d] = -F
            block[:, k*d:(k+1)*d] = np.eye(d)
            add(block, np.zeros(d), Q)
    design, target = np.vstack(rows), np.concatenate(rhs)
    solution = np.linalg.lstsq(design, target, rcond=None)[0]
    covariance = np.linalg.solve(design.T @ design, np.eye(n*d))
    return solution.reshape(n, d), covariance

batch_xy, batch_cov = batch_state_fit(measurements_pos, A, Q, R,
    initial_state.x.ravel(), initial_state.x_cov)
np.testing.assert_allclose(batch_xy, smoothed_xy, atol=1e-8)
for k, state in enumerate(states_smoothed):
    np.testing.assert_allclose(state.x_cov, batch_cov[2*k:2*k+2, 2*k:2*k+2], atol=1e-8)
print("Batch fit and RTS agree for both states and covariance.")
Batch fit and RTS agree for both states and covariance.

Interpretation and exercise

The filter only uses observations available at that time. The smoother uses the whole sequence and improves earlier estimates; the final estimate is unchanged. The batch comparison checks both the means and uncertainties independently. A single simulated sequence does not guarantee a particular RMSE ordering.

Exercise: increase measurement variance by a factor of four and regenerate the observations with twice the noise standard deviation. Predict what will happen to the uncertainty band. Keep the simulated noise and filter covariance consistent.

Show or hide code
# Answer scaffold: compare the same observations under a larger stated R.
# This isolates the effect on covariance; regenerate data to study RMSE fairly.
noisier = [Measurement(m.local_pos.copy(), m.global_pos.copy(), 4*m.local_cov) for m in measurements]
wide = KalmanFilter(initial_state, Q, A).fit(noisier)
assert wide[-1].x_cov[0, 0] > states[-1].x_cov[0, 0]
print("Final position sigma, original/larger R:",
      np.sqrt(states[-1].x_cov[0, 0]), np.sqrt(wide[-1].x_cov[0, 0]))
Final position sigma, original/larger R: 0.6004928766424127 1.0413603729406733

Interactive experiment: tune the sequential estimate

Vary the assumed measurement noise and process noise to explore how the sequential estimate responds. Compare its responsiveness and uncertainty; the controls change the estimation assumptions as well as the sample size.

The controls use a separate, seeded synthetic example and run in your browser in the exported HTML article. Some notebook viewers do not execute embedded JavaScript; run scripts/check_notebooks.py --html and open the article preview to use the controls.

Show or hide code
from pathlib import Path
import sys
interactive_project = next(p / "projects" for p in [Path.cwd(), *Path.cwd().parents]
                           if (p / "projects" / "src").is_dir())
sys.path.insert(0, str(interactive_project / "src"))
from notebook_interactive import display_interactive
display_interactive("kalman")

Interactive controls load in the HTML article preview.

Conclusion

The static-coefficient example connects sequential estimation to batch fitting: with zero process noise and a diffuse prior, the final Kalman estimate closely matches WLS. For the dynamical example, the stronger check is that RTS smoothing and the global batch fit agree to numerical tolerance for both the state means and their marginal covariances, using the same prior, observations and process constraints.

On the simulated vehicle sequence, position RMSE falls from $0.778$ m for the measurements to $0.530$ m after filtering and $0.330$ m after smoothing. The filter uses observations available up to each time; the smoother also uses later observations to refine earlier states. Its final state is unchanged. The lower RMSE is a result for this seeded sequence, rather than a guarantee for every realisation.

The uncertainty calculation depends on the assumed dynamics and noise. Increasing the stated measurement variance widens the final position uncertainty in the exercise, but a covariance band is only as trustworthy as those assumptions. This example generates the truth with the same linear Gaussian process model used by the estimator. Before applying it to data, check innovations and their predicted covariance, and test sensitivity to the prior and noise choices. The shared implementation uses fixed $F,Q$ and ordered observation times; irregular sampling requires propagation and process covariance matched to each time interval.

For the original prediction/update formulation, see R. E. Kalman (1960), A New Approach to Linear Filtering and Prediction Problems.