Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Univariate Kalman filter

Imports

%load_ext autoreload
%autoreload 2

import logging

import numpy as np

import helpers.hintrospection as hintros
import helpers.htutorial as ut

ut.config_notebook()

# Initialize logger.
logging.basicConfig(level=logging.INFO)
_LOG = logging.getLogger(__name__)
WARNING (pytensor.tensor.blas): Using NumPy C-API based implementation for BLAS functions.
vim support installed: restart the notebook, if needed
Python 3.12.3
Linux 7d77d2247121 6.12.67-linuxkit #1 SMP Sun Jan 25 02:26:28 UTC 2026 aarch64 aarch64 aarch64 GNU/Linux
import helpers.hio as hio
import L09_05_02_univariate_kalman_filter_utils as time_ut

dst_dir = "figures"
hio.create_dir(dst_dir, incremental=True)
!cp msml610/tutorials/figures/*.png msml610/lectures_source/figures

Part 1: Sum and Product of Gaussians

Cell 1.1: Representing a Gaussian

Goal:

  • Introduce time_ut.Gaussian(mean, var), the (mean, var) named tuple used to represent a belief throughout this notebook
x = time_ut.Gaussian(3.4, 10.1)
print("x=", x)
print("x.mean=", x.mean)
print("x.var=", x.var)
N(mu=3.400, sigma^2=10.100)
x.mean= 3.4
x.var= 10.1

Cell 1.2: Sum of Gaussians

  • Given 2 Gaussians XX and YY
    X∼Normal(μ1,σ12)X \sim Normal(\mu_1, \sigma_1^2)
    Y∼Normal(μ2,σ22)Y \sim Normal(\mu_2, \sigma_2^2)
  • For correlated Gaussians with correlation coefficient ρ\rho, the sum Z=X+YZ = X + Y is a Gaussian Normal(μ,σ2)Normal(\mu, \sigma^2) with:
    μ=μ1+μ2\mu = \mu_1 + \mu_2
    σ2=σ12+σ22+2ρσ1σ2\sigma^2 = \sigma_1^2 + \sigma_2^2 + 2\rho\sigma_1\sigma_2
  • Interpretation:
    • The mean is the sum of the means (by linearity)
    • For independent Gaussians (ρ=0\rho = 0), the variance is the sum of variances (uncertainty increases)
    • Positive correlation increases variance, negative correlation decreases it

Implementation: time_ut.gaussian_sum(x, y)

# Sum two Gaussians.
x = time_ut.Gaussian(10, 0.2**2)
y = time_ut.Gaussian(15, 0.7**2)

z = time_ut.gaussian_sum(x, y)
print("z=", z)
N(mu=25.000, sigma^2=0.530)
ax = time_ut.plot_gaussian(x, label="x")
time_ut.plot_gaussian(y, ax=ax, label="y")
_ = time_ut.plot_gaussian(z, ax=ax, label="z")
<Figure size 800x300 with 1 Axes>

Implementation: time_ut.cell1_1_plot_gaussian_sum()

  • Plots input Gaussians XX (blue) and YY (yellow) as filled PDFs, the analytical sum ZZ (red line), and a numerical sum via sampling (light coral histogram)
hintros.print_obj_info(time_ut.cell1_1_plot_gaussian_sum)
time_ut.cell1_1_plot_gaussian_sum()
Loading...

Usage

  • Inputs

    • mu1 (μ1\mu_1): mean of XX
    • sigma1 (σ1\sigma_1): standard deviation of XX
    • mu2 (μ2\mu_2): mean of YY
    • sigma2 (σ2\sigma_2): standard deviation of YY
    • rho (ρ\rho): correlation coefficient between XX and YY
  • Panels

    • left: XX and YY as filled PDFs, the analytical sum ZZ, and the sampled numerical sum

Guided usage

  • Set rho=0
    • Observe ZZ’s variance equals σ12+σ22\sigma_1^2 + \sigma_2^2 exactly
  • Raise rho toward 1
    • Observe ZZ’s variance grows beyond σ12+σ22\sigma_1^2 + \sigma_2^2
  • Lower rho toward -1
    • Observe ZZ’s variance shrinks below σ12+σ22\sigma_1^2 + \sigma_2^2
  • In every case, observe ZZ’s mean stays μ1+μ2\mu_1 + \mu_2

Cell 1.3: Product of Gaussians

  • Given 2 Gaussians XX and YY
    X∼Normal(μX,σX2)X \sim Normal(\mu_X, \sigma_X^2)
    Y∼Normal(μY,σY2)Y \sim Normal(\mu_Y, \sigma_Y^2)
  • The product Z=X⋅YZ = X \cdot Y (PDF multiplication) is a Gaussian Normal(μZ,σZ2)Normal(\mu_Z, \sigma_Z^2) with:
    μZ=μXσY2+μYσX2σX2+σY2\mu_Z = \frac{\mu_X \sigma_Y^2 + \mu_Y \sigma_X^2}{\sigma_X^2 + \sigma_Y^2}
    σZ2=σX2σY2σX2+σY2\sigma_Z^2 = \frac{\sigma_X^2 \sigma_Y^2}{\sigma_X^2 + \sigma_Y^2}
  • Interpretation:
    • Reduces variance by incorporating more information
    • If one Gaussian XX is narrower (more accurate), the result leans towards XX
    • If the 2 Gaussians are similar (measures corroborate), the result becomes more certain

Gaussian products in terms of precision

  • The precision of a Gaussian is

    τ=1σ2\tau = \frac{1}{\sigma^2}
  • The precision of the product is the sum of the precisions

    τZ=τX+τY\tau_Z = \tau_X + \tau_Y

    σZ2=11σX2+1σY2\sigma_Z^2 = \frac{1}{\frac{1}{\sigma_X^2} + \frac{1}{\sigma_Y^2}}
  • The mean is the average of the means weighted by the precisions

    μZ=σZ2(μXσX2+μYσY2)\mu_Z = \sigma_Z^2 (\frac{\mu_X}{\sigma_X^2} + \frac{\mu_Y}{\sigma_Y^2})
  • The mean is averaged towards the more certain Gaussian

  • The variance is smaller than both

Implementation: time_ut.gaussian_multiply(x, y)

# Product of two equal Gaussians.
x = time_ut.Gaussian(10, 1.0)

z = time_ut.gaussian_multiply(x, x)
print("z=", z)

# The result is more certain than both.
N(mu=10.000, sigma^2=0.500)
ax = time_ut.plot_gaussian(x, label="x")
time_ut.plot_gaussian(x, ax=ax, label="x")
_ = time_ut.plot_gaussian(z, ax=ax, label="z")
<Figure size 800x300 with 1 Axes>
# Product of two different Gaussians.
x = time_ut.Gaussian(10, 0.2**2)
y = time_ut.Gaussian(15, 0.7**2)

z = time_ut.gaussian_multiply(x, y)
print("z=", z)

ax = time_ut.plot_gaussian(x, label="x")
time_ut.plot_gaussian(y, ax=ax, label="y")
_ = time_ut.plot_gaussian(z, ax=ax, label="z")
N(mu=10.377, sigma^2=0.037)
<Figure size 800x300 with 1 Axes>
x = time_ut.Gaussian(10.2, 1)
y = time_ut.Gaussian(9.7, 1)

z = time_ut.gaussian_multiply(x, y)
print("z=", z)

ax = time_ut.plot_gaussian(x, label="x")
time_ut.plot_gaussian(y, ax=ax, label="y")
_ = time_ut.plot_gaussian(z, ax=ax, label="z")
N(mu=9.950, sigma^2=0.500)
<Figure size 800x300 with 1 Axes>

Implementation: time_ut.cell1_2_plot_gaussian_product()

  • Plots input Gaussians XX (blue) and YY (yellow) as filled PDFs, the analytical product ZZ (red line), and a numerical product via importance sampling (light coral histogram)
hintros.print_obj_info(time_ut.cell1_2_plot_gaussian_product)
time_ut.cell1_2_plot_gaussian_product()
Loading...

Usage

  • Inputs

    • mu1 (μ1\mu_1): mean of XX
    • sigma1 (σ1\sigma_1): standard deviation of XX
    • mu2 (μ2\mu_2): mean of YY
    • sigma2 (σ2\sigma_2): standard deviation of YY
  • Panels

    • left: XX and YY as filled PDFs, the analytical product ZZ, and the sampled numerical product

Guided usage

  • Set sigma1 much smaller than sigma2
    • Observe ZZ’s mean sits close to mu1: the product pulls the mean toward the more certain (narrower) Gaussian
  • Set mu1 close to mu2, both with small sigmas
    • Observe ZZ becomes very sharp: when the inputs agree, the product is highly certain
  • Compare ZZ’s width to both inputs
    • Observe it is always narrower than either input

Part 2: Tracking the Dog

Cell 2.1: The predict-update cycle

  • The intuition is the same as the discrete case
  • There is a cycle of prediction and updates
    1. Predict: prior = x_est using system model
    2. Update: posterior = likelihood * prior
  • Create prior (using current estimate and system model)
    • prior = predict(x, process_model)
  • Create likelihood (using measurement)
    • likelihood = gaussian(z, sensor_var)
  • Update belief using prior and likelihood
    • x = update(prior, likelihood)
  • Sum adds uncertainty; multiplication reduces uncertainty
  • Let’s assume that the dog moves in the hallway, back and forth (it’s not circular), and we have a sensor that measures the distance of the dog from one extreme

We can use Newton’s equation of motion to compute the position of the dog, based on current position and velocity

x‾k=xk−1+vkΔt\overline{x}_k = x_{k-1} + v_k \Delta_t
  • xk−1x_{k-1} has uncertainty quantified by a Gaussian
  • vkv_k has also uncertainty quantified by a Gaussian

We can compute the sum of 2 Gaussians in terms of mean and uncertainty. It makes sense since we know that uncertainty becomes larger.

The likelihood z∣xz | x is the probability of measures given the current state.

Cell 2.2: Simulating the dog and its sensor

Goal:

  • Simulate a dog moving at constant velocity, with a noisy sensor, to generate the data the filter will track in Cell 2.3

Implementation: time_ut.DogSimulation(...)

np.random.seed(13)

# Variance in the dog's movement.
process_var = 1.0
# Variance in the sensor.
sensor_var = 2.0

# Dog's initial position.
x = time_ut.Gaussian(0.0, 20.0**2)
velocity = 1.0
# Time step in seconds.
dt = 1.0
# Displacement to add to x (representing how to model the movement of the
# dog).
process_model = time_ut.Gaussian(velocity * dt, process_var)

# Simulate dog and get measurements.
dog = time_ut.DogSimulation(
    x0=x.mean,
    velocity=process_model.mean,
    measurement_var=sensor_var,
    process_var=process_model.var,
)

# Simulate dog and collect measurements and actual positions.
n_steps = 10
sim_data = [dog.move_and_sense() for _ in range(n_steps)]
zs = [m for m, _ in sim_data]
actual_positions = [pos for _, pos in sim_data]
print("zs=", zs)
[1.3535959735108178, 1.8820653967131618, 4.341047429453569, 7.156332673205118, 6.938695089418526, 6.843912342028484, 9.846824080052299, 12.553482049375292, 16.2730841073834, 14.800411177015299]

Cell 2.3: Running the Kalman filter

Goal:

  • Run the predict-update cycle from Cell 2.1 on the simulated data, and check that the posterior’s uncertainty settles below the sensor’s
# Perform Kalman filter on measurements.
kf_info = []
for z, actual_pos in zip(zs, actual_positions):
    prior = time_ut.predict(x, process_model)
    likelihood = time_ut.Gaussian(z, sensor_var)
    x = time_ut.update(prior, likelihood)
    kf_info.append(
        time_ut.KfInfo(
            prior=prior, measurement=z, actual_pos=actual_pos, posterior=x
        )
    )

print(time_ut.kf_info_to_df(kf_info))
   predict_x  predict_var          z  actual_pos   update_x  update_var
0   1.000000   401.000000   1.353596    0.287609   1.351841    1.990074
1   2.351841     2.990074   1.882065    1.243106   2.070349    1.198409
2   3.070349     2.198409   4.341047    3.588208   3.735724    1.047258
3   4.735724     2.047258   7.156333    5.938396   5.960160    1.011677
4   6.960160     2.011677   6.938695    8.417082   6.949397    1.002911
5   7.949397     2.002911   6.843912    8.628093   7.396253    1.000727
6   8.396253     2.000727   9.846824   10.190939   9.121670    1.000182
7  10.121670     2.000182  12.553482   12.104680  11.337631    1.000045
8  12.337631     2.000045  16.273084   13.231983  14.305380    1.000011
9  15.305380     2.000011  14.800411   14.838272  15.052895    1.000003
  • The uncertainty after prediction is larger than the uncertainty after update (as usual)
  • The variance of the prior at time 0 is very large, but after we measure, the variance of the measurement “dominates”
  • The posterior values are always between the measurement and the prior
  • After a few cycles the posterior variance is around 1, which is smaller than the sensor variance (~2): using a model + measurements is better
# Plot Kalman filter results.
time_ut.plot_kf_info(kf_info, show_actual_pos="scatter")

Cell 2.4: Bad initial estimate

Goal:

  • Start the filter’s belief far from the dog’s true starting position, and check that it still converges given enough measurements
seed = 42
process_var = 2.0
sensor_var = 2.0**2
# Belief about initial position.
initial_position = 400
initial_pos_var = 1.0
# Actual initial position.
actual_initial_pos = 0.0
n_steps = 100

kf_info = time_ut._run_dog_simulation(
    seed,
    process_var,
    sensor_var,
    initial_position,
    actual_initial_pos=actual_initial_pos,
    initial_pos_var=initial_pos_var,
    n_steps=n_steps,
)
time_ut.plot_kf_info(
    kf_info, show_prior="none", show_actual_pos="line", show_posterior="line"
)
<Figure size 1200x600 with 1 Axes>
# Belief about initial position.
initial_position = 400
initial_pos_var = 100.0
# Actual initial position.
actual_initial_pos = 0.0

kf_info = time_ut._run_dog_simulation(
    seed,
    process_var,
    sensor_var,
    initial_position,
    actual_initial_pos=actual_initial_pos,
    initial_pos_var=initial_pos_var,
    n_steps=n_steps,
)
time_ut.plot_kf_info(
    kf_info, show_prior="none", show_actual_pos="line", show_posterior="line"
)
<Figure size 1200x600 with 1 Axes>

Cell 2.5: Extreme amount of noise

Goal:

  • Push the sensor noise to an extreme, and check that the filter still recovers the dog’s position as long as the process model is trusted
seed = 42
process_var = 2.0
sensor_var = 300.0**2
initial_position = 0.0
n_steps = 1000

kf_info = time_ut._run_dog_simulation(
    seed, process_var, sensor_var, initial_position, n_steps=n_steps
)
time_ut.plot_kf_info(
    kf_info, show_prior="none", show_actual_pos="line", show_posterior="line"
)
<Figure size 1200x600 with 1 Axes>
  • Even with extreme amounts of noise we recover the position of the dog
  • This is because the process error is small (we can trust the model)

Cell 2.6: Too much belief in the model

Goal:

  • Have the dog accelerate, violating the constant-velocity process model, and check that an over-confident filter fails to track it
seed = 42
process_var = 2.0
sensor_var = 300.0**2
acceleration = 0.04
initial_position = 0.0
n_steps = 300

kf_info = time_ut._run_dog_simulation(
    seed,
    process_var,
    sensor_var,
    initial_position,
    acceleration=acceleration,
    n_steps=n_steps,
)
time_ut.plot_kf_info(
    kf_info, show_prior="none", show_actual_pos="line", show_posterior="line"
)
<Figure size 1200x600 with 1 Axes>
  • The filter is not able to follow the change of velocity of the dog
seed = 42
process_var = 2.0
sensor_var = 2.0**2
acceleration = 0.04
initial_position = 0.0
n_steps = 50

kf_info = time_ut._run_dog_simulation(
    seed,
    process_var,
    sensor_var,
    initial_position,
    acceleration=acceleration,
    n_steps=n_steps,
)
time_ut.plot_kf_info(
    kf_info,
    show_prior="none",
    show_actual_pos="line",
    show_posterior="line",
    show_measurements="none",
)
<Figure size 1200x600 with 1 Axes>

Cell 2.7: Interactively exploring the filter

Goal:

  • Let students sweep every parameter from Cells 2.4-2.6 at once, to consolidate the intuition for how the filter balances the process model against sensor measurements

Implementation: time_ut.cell2_interactive_dog_simulation()

  • Prior (predict) as red up-triangles: prediction from motion model
  • Measurement as black circles: noisy sensor readings
  • Posterior (update) as green down-triangles: filtered estimate
  • Posterior uncertainty as shaded green bands (+-1, 2, 3 sigma)
hintros.print_obj_info(time_ut.cell2_interactive_dog_simulation)
time_ut.cell2_interactive_dog_simulation()

Usage

  • Inputs

    • process_var (σp2\sigma_p^2): variance in the dog’s movement model
    • sensor_var (σs2\sigma_s^2): variance in the sensor measurements
    • initial_position (x0x_0): belief about the dog’s starting position
    • actual_initial_pos: the dog’s true starting position (can differ from the belief to simulate a wrong initial estimate)
    • initial_pos_var: uncertainty (variance) in the initial position belief; large values mean we are very uncertain about where the dog starts
    • acceleration: dog’s acceleration (m/s2m/s^2); non-zero values make the dog speed up over time, testing the filter’s ability to track
    • seed: random seed for reproducibility
  • Panels

    • left: prior, measurements, posterior, and posterior uncertainty bands over time
    • Comments: the current parameters, and the final posterior mean/variance

Guided usage

  • Lower sensor_var far below process_var
    • Observe the posterior in Comments tracks the measurements closely: the filter trusts the sensor
  • Lower process_var far below sensor_var
    • Observe the posterior changes less: the motion model dominates
  • Let the simulation run its full 25 steps
    • Observe the final posterior variance in Comments stabilizes below both input variances
  • Is it better to have precise measurements (sensor_var << process_var) or vice versa?