Principles of Robot Autonomy I · Lesson 13 of 15 · Stanford AA274A

Where Am I? — The Kalman Filter, EKF, and UKF

A robot never knows its pose exactly. Its motion model drifts; its sensors lie. The Kalman family fuses a noisy prediction with a noisy measurement into a single best estimate — the pose the whole stack runs on.

Prerequisites: Lesson 2 (motion models) + Lesson 7 (sensors) + a Gaussian and a derivative. We build the rest.
12
Chapters
6
Simulations
0
Magic

Chapter 0: A Robot Never Knows Exactly Where It Is

You command your TurtleBot to roll forward one meter. Did it move exactly one meter? Almost certainly not. One wheel slipped a little on the dusty floor. The motor controller overshot by a hair. The battery sagged, so the commanded velocity was slightly low. After one step the error is millimeters. After a hundred steps, driving blind, the error is meters — the robot thinks it's in the kitchen when it's actually in the hallway. This silent accumulation is called dead-reckoning drift, and it is the reason a robot can never trust its motion alone.

How bad is the drift? Suppose each 1 m step has a tiny, unbiased error of standard deviation just 2 cm. Errors are random, so they don't simply add — the variance adds, and the spread grows with the square root of the number of steps. After n steps the position uncertainty is about 0.02·√n meters:

Steps nDistancePosition uncertainty (1σ)
11 m±2 cm
2525 m±10 cm
100100 m±20 cm
400400 m±40 cm

And that's the optimistic case with no bias. A constant 1% scale error (the robot consistently overshoots) grows linearly — 4 m off after 400 m. Dead reckoning is a leaky bucket: it loses accuracy every step and has no way to refill. You need an external reference.

"Fine," you say, "then just use a sensor." So you bolt on a laser rangefinder that measures the distance to a known wall. But the sensor is noisy too — each reading jitters by a few centimeters, and occasionally it returns garbage. Trust the sensor blindly and your pose estimate twitches and jumps with every reading; you never settle on a confident answer.

So we have two unreliable witnesses. The motion model (Lesson 2) says "you should be about here" but drifts over time. The sensor (Lesson 7) says "I see a wall this far away" but jitters every reading. Neither is trustworthy alone. The whole job of this lesson is to fuse them — to combine a noisy prediction with a noisy measurement into one estimate that is better than either input, and to track exactly how uncertain that estimate is.

The big idea, in one sentence. A filter doesn't pick the prediction or the measurement — it takes a weighted average of both, weighting each by how much you trust it. The Kalman filter is the optimal way to do that weighting when everything is Gaussian and linear, and the EKF/UKF extend it to the curved, nonlinear world a real robot lives in.

Before any math, let's just watch the problem. Below, a robot moves along a line. Its true position (faint) drifts under disturbances. We get noisy sensor pings (orange dots scattered around the truth). The "raw measurement" estimate (orange) jumps with every reading; the "filtered" estimate (teal) glides smoothly close to the truth. Same noisy data — one is fused, one is not.

Noisy world vs. filtered estimate

Press Run. The faint grey line is the unknown truth. Orange dots are raw sensor pings (noisy). The orange trace trusts each ping blindly — jittery. The teal trace is filtered — it fuses motion + sensor and stays smooth and close. Crank the sensor noise and watch the raw trace explode while the filter holds.

Sensor noise 0.06 ready

Notice what the filter is buying you: smoothness without lag, accuracy without trust. It does not believe any single sensor reading, and it does not blindly extrapolate the motion model. It holds a running belief — a best guess plus a measure of how sure it is — and nudges that belief toward each new reading by exactly the right amount.

That phrase "a best guess plus a measure of how sure it is" is the seed of everything. A naive estimator stores one number (my position is 3.2). A filter stores two: a mean (3.2) and a variance (± how sure). The variance is what lets the filter know whether to lean on the prediction or the measurement. Where does this belief, and its update rule, come from? From a recursive engine called the Bayes filter — our Chapter 1.

Motion model
"you moved ~1 m forward" — drifts, gets less sure
+
Sensor
"wall is ~2.1 m away" — noisy, but anchors to the world
Filter
fused pose estimate + how sure we are
Why can't a robot just trust its motion model (how far it told the wheels to go) to know where it is?

Chapter 1: Belief & the Bayes Filter

The single most important shift in this lesson is from storing a guess to storing a belief. A guess is one number. A belief is a whole probability distribution over where the robot might be — it encodes both the best estimate and the uncertainty around it. Stanford writes the belief over the state at time t as:

bel(xt) = p(xt | z1:t, u1:t)

Read that slowly. xt is the state at time t — for a wheeled robot, the pose (x, y, θ). z1:t is every measurement we've gathered from step 1 up to now. u1:t is every control command we've issued. So bel(xt) is "the probability distribution over the state, given everything we've ever sensed and every move we've made." That conditional distribution is exactly the robot's internal knowledge of where it is.

State must be "complete." All of this works only under the Markov property: the state xt is complete if knowing it makes the entire past irrelevant to the future. In words: if you tell me exactly where the robot is and how it's oriented now, the history of how it got there adds nothing for predicting the next step. This is what lets the filter be recursive — it only ever needs the previous belief, not the whole logbook.

Two beliefs: prediction and corrected

The Bayes filter juggles two distributions. After a control command but before seeing the new measurement, we have the prediction belief (Stanford draws it with a bar):

bel̅(xt) = p(xt | z1:t−1, u1:t)

The bar means "before the latest measurement." It folds in the newest control ut but not the newest measurement zt. After we incorporate zt, we get the corrected belief bel(xt) (no bar). The whole filter is just a two-beat loop turning one into the other, forever:

Predict
push the old belief through the motion model; uncertainty grows
Update
multiply by the measurement likelihood; uncertainty shrinks
↻ every tick

The two equations

The prediction step uses the law of total probability — sum over every place the robot could have been, weighted by how likely the motion model says it would land at xt:

bel̅(xt) = ∫ p(xt | ut, xt−1) · bel(xt−1) dxt−1

Here p(xt | ut, xt−1) is the state-transition model: "if I was at xt−1 and commanded ut, where do I end up?" The update step uses Bayes' rule — multiply the prediction by how likely the measurement would be at each candidate state, then renormalize:

bel(xt) = η · p(zt | xt) · bel̅(xt)

Here p(zt | xt) is the measurement model: "if I were truly at xt, how likely is this reading zt?" and η (eta) is just a normalizer that forces the result back into a valid probability distribution (it's the denominator of Bayes' rule). That's it. Predict with the motion model; correct with the measurement model. Every filter in this lesson is a special case of these two lines.

Why this matches Chapter 0's two witnesses. The prediction step is the motion model talking ("you should be here, but I'm less sure now"). The update step is the sensor talking ("I see this, so you're probably here"). The integral spreads the belief out (uncertainty grows); the multiplication concentrates it (uncertainty shrinks). Sensing adds knowledge; moving loses it.

Watch one full predict→update cycle on a discretized belief (a histogram over a hallway). Press Predict: the bar chart smears rightward and flattens (motion adds uncertainty). Press Update: a measurement sharpens it back toward a peak. This is the Bayes filter, made of bars.

The Bayes filter as a histogram

A robot in a 1-D hallway. The bars are bel(x) — probability the robot is in each cell. Predict shifts & blurs the belief (motion). Update multiplies by a sensor likelihood peaked at the reading, then renormalizes (correction). Alternate them and watch belief flow.

uniform-ish prior

The discrete Bayes filter, by hand

To make those two equations feel concrete (and to prove the histogram sim isn't smoke), let's run one cycle of the discrete Bayes filter — the version where the state space is a finite list of cells, so the integral becomes a sum. A robot lives in a 3-cell hallway, cells A, B, C. Its prior belief is bel = [0.5, 0.3, 0.2] (probably in A). We command "move right one cell," but the motion is noisy: 80% of the time it lands one cell right, 20% it stalls in place.

Predict (law of total probability — sum over where it came from). For each destination cell, add up every source cell's probability times the chance the motion took it there:

So bel̅ = [0.10, 0.46, 0.44]. The belief shifted right and flattened — it used to peak hard at A (0.5); now it's spread across B and C. Moving lost knowledge, exactly as promised.

Update (Bayes' rule — multiply by the measurement likelihood, renormalize). The robot's sensor says "I see a door," and doors are at B (likelihood 0.6) but rare elsewhere (A: 0.1, C: 0.3). Multiply elementwise:

These don't sum to 1, so normalize by η = 1/(0.010+0.276+0.132) = 1/0.418 = 2.39: bel = [0.024, 0.660, 0.316]. The "door" measurement pulled the belief sharply toward B (0.66). Sensing gained knowledge — the belief re-sharpened. That's one full predict→update cycle, with nothing but sums and products. The continuous Kalman filter is this exact dance, but with Gaussians instead of bar charts.

Where does η come from? It's not a free parameter — it's forced. After multiplying by the likelihood, the numbers no longer sum to 1, so they aren't a valid probability distribution. η is just 1 over their sum, scaling them back to total probability 1. It's the denominator of Bayes' rule, p(z), the "probability of the measurement averaged over all states." You never have to think about it: divide by the sum and move on.

The catch that motivates everything else

That hand calculation worked because there were only 3 cells. For a continuous robot pose, the belief is a function over all of ℝ3, and the prediction integral over "every place the robot could have been" has no closed form. You can't enumerate infinitely many cells. So the rest of the lesson is about a brilliant cheat: assume the belief is always a Gaussian. If it is, the two scary equations collapse into a handful of matrix multiplies that update just (μ, Σ). That's the Kalman filter. First, why Gaussians make the integral disappear — Chapter 2.

In the Bayes filter, which step increases the robot's uncertainty, and which step decreases it?

Chapter 2: Why the Gaussian? — The One Distribution That Survives

If we must pick one shape for the belief and stick with it forever, which shape is magic? The answer is the Gaussian (the bell curve, also called the normal distribution). It is magic for one specific reason that we'll make precise: when you push a Gaussian through the operations the Bayes filter needs — linear maps, sums, products — you get another Gaussian back. The shape is closed under exactly the operations we need. Nothing else is.

The 1-D Gaussian: just a mean and a variance

A one-dimensional Gaussian is fully described by two numbers. Its density is:

p(x) = (1 / √(2πσ²)) · e−(x − μ)² / (2σ²)

Don't be intimidated — only two symbols carry meaning. μ (mu) is the mean: the center of the bell, the best single guess. σ² (sigma-squared) is the variance: how wide the bell is, i.e. how uncertain we are. (σ itself is the standard deviation.) The whole leading fraction is just a constant that makes the area under the curve equal 1. We write "X is distributed as a Gaussian with mean μ and variance σ²" as X ~ N(μ, σ²). That is the entire belief: two numbers.

This is why a filter stores TWO numbers, not one. A naive estimator keeps only μ ("I'm at 3.2"). A Gaussian belief keeps μ and σ² ("I'm at 3.2, give or take 0.4"). That second number — the uncertainty — is what tells the filter whether to trust a new measurement. Drop it and you've thrown away the whole game.

The multivariate Gaussian: a mean vector and a covariance matrix

A robot pose lives in more than one dimension (x, y, θ), so we need the multivariate Gaussian. Now the mean is a vector μ ∈ ℝn (the center, one coordinate per state variable), and the spread is a covariance matrix Σ ∈ ℝn×n:

p(x) = (1 / √(det(2πΣ))) · exp(−½ (x − μ) Σ−1 (x − μ))

The covariance matrix Σ is the multivariate version of variance. Its diagonal entries are the variances of each coordinate (how unsure we are about x, about y, about θ). Its off-diagonal entries are covariances — how the errors in two coordinates are correlated. A non-zero x–y covariance means "if I'm wrong about x, I'm probably wrong about y in a correlated way." Geometrically, Σ is an uncertainty ellipse: the diagonal sets the width and height, the off-diagonal tilts it.

The three properties that make Gaussians survive the filter

Here is the payoff. Stanford lists three closure properties, and every one is load-bearing for the Kalman filter:

The whole magic trick. The Bayes filter needs an integral (predict) and a product (update). Property 1+2 make the predict integral come out Gaussian; property 3 makes the update product come out Gaussian. So if the belief starts Gaussian and the models are linear, the belief stays Gaussian forever — and tracking a whole distribution collapses to tracking just (μ, Σ). That collapse is the Kalman filter.

The affine property, worked with numbers

Property 1 is the engine of the predict step, so let's see it move. Suppose a robot's position is X ~ N(μ = 2, σ² = 0.5). It moves under the map Y = AX + b with A = 1 (position carries over) and b = 3 (commanded +3 m). The property says Y ~ N(Aμ+b, Aσ²A) = N(1·2+3, 1·0.5·1) = N(5, 0.5). The mean shifted by the commanded move; the variance was unchanged (a pure shift adds no uncertainty by itself).

Now scale instead: Z = 2X (e.g. a units change, or velocity doubling a displacement). Then Z ~ N(2·2, 2·0.5·2) = N(4, 2.0). Notice the variance grew by A² = 4, not by A. Spread scales with the square of the linear factor — which is exactly the a²σ² in the 1-D predict and the FΣF in the matrix predict. The transpose F appears because, in multiple dimensions, you scale the covariance on both sides.

Why FΣF and not FΣ. Covariance is a quadratic quantity (it lives in units of state-squared). A linear map A stretches space by A in each direction, so it stretches a variance by A on the left and A on the right — hence AΣA. In 1-D this is just a·σ²·a = a²σ². Get this and you understand every covariance-propagation formula in the lesson at a glance.

Play with a 2-D covariance ellipse. Drag the variance sliders to fatten it along x or y; drag the correlation slider to tilt it. The ellipse is the "1-sigma" contour — the region the robot believes it's probably inside. This same ellipse will breathe in the tracking showcase: growing on predicts, shrinking on updates.

The covariance ellipse you can reshape

σx² and σy² set the width/height; ρ (rho) is the correlation that tilts the ellipse. The dots are samples drawn from this Gaussian — they cluster inside the 1-σ contour. Tilt it and watch x and y become coupled.

σx² 1.4 σy² 0.7 ρ 0.40
Common misconception. "Real robot noise is Gaussian." It usually isn't — wheel slip is one-sided, sensor outliers are heavy-tailed, and a global-localization belief can be genuinely multi-modal ("I'm in one of three identical corridors"). The Gaussian is an assumption we impose for tractability, not a fact about the world. It works astonishingly well for position tracking (small, unimodal error). It fails for global localization — which is exactly why Lesson 14's particle filter exists. Knowing when the assumption breaks is half of using a filter well.
Why is the Gaussian the distribution of choice for parametric filters like the Kalman filter?

Chapter 3: The 1-D Kalman Filter — Five Equations, By Hand

Let's collapse the Bayes filter onto a Gaussian belief in the simplest possible world: a robot moving along a single line, so the state is just a scalar position x. We assume (the three Kalman assumptions from the source):

Under these three, the belief is Gaussian for all time, so we only ever track a mean μ and a variance σ². The Bayes filter's two steps become five scalar equations. (We'll use the convention σR² = process/motion noise, σQ² = measurement/sensor noise, matching Stanford's R and Q.)

Predict (project the belief forward through motion)

μ̅ = a·μ + b·u
σ̅² = a²·σ² + σR²

The mean rides the motion model forward. The variance grows: it's scaled by a² (here a=1, so unchanged) and then the process noise σR² is added (Gaussian property 2!). Moving makes us less sure. The bar denotes "predicted, pre-measurement."

Update (fold in the measurement)

K = (c·σ̅²) / (c²·σ̅² + σQ²)
μ = μ̅ + K·(z − c·μ̅)
σ² = (1 − K·c)·σ̅²

The middle quantity K is the Kalman gain — the star of the next chapter. The term (z − c·μ̅) is the innovation: the surprise, how far the actual measurement is from what we predicted we'd measure. The new mean is the prediction nudged toward the measurement by K×surprise. The new variance shrinks by the factor (1 − K·c): sensing makes us more sure.

A full step, by hand, with actual numbers

Let's grind one complete cycle. Take c = 1 (sensor reads position directly) and a = 1.

Setup. Prior belief: μ = 2.0 m, σ² = 0.40 (we're moderately unsure). We command the robot to move +1.0 m, so b·u = 1.0. Process noise σR² = 0.20. Then the sensor reads z = 3.6 m with measurement noise σQ² = 0.10.

StepComputationResult
Predict meanμ̅ = 1·2.0 + 1.0μ̅ = 3.0 m
Predict varσ̅² = 1²·0.40 + 0.20σ̅² = 0.60 (grew from 0.40)
Innovationz − c·μ̅ = 3.6 − 3.0+0.6 m of surprise
Gain K0.60 / (0.60 + 0.10)K = 0.857
Update meanμ = 3.0 + 0.857·(0.6)μ = 3.514 m
Update varσ² = (1 − 0.857)·0.60σ² = 0.086 (shrank from 0.60)

Look at what happened. We predicted 3.0; the sensor said 3.6. K = 0.857 means we trust the sensor a lot (because σQ² = 0.10 is much smaller than σ̅² = 0.60), so the fused estimate 3.514 lands close to the measurement — but not all the way; the prediction still pulls it back a little. And crucially the variance dropped from 0.60 to 0.086: after fusing, we are far more confident than from either source alone. That is the entire Kalman filter: predict (grow uncertainty), then update (blend toward the measurement and shrink uncertainty).

Sanity check on the blend. The fused variance 0.086 is smaller than both inputs (the predicted 0.60 and the measurement's 0.10). Fusing two independent Gaussian estimates always produces a tighter one than either alone — that's the whole reason to fuse instead of choosing. Two noisy witnesses who roughly agree make you more confident than either witness alone.

From scratch in Python

python
def kf_1d_step(mu, var, u, z, a=1.0, b=1.0, c=1.0, R=0.2, Q=0.1):
    # --- predict (motion) ---
    mu_bar  = a * mu + b * u          # project mean forward
    var_bar = a * a * var + R         # grow variance by process noise
    # --- update (measurement) ---
    K = (c * var_bar) / (c * c * var_bar + Q)   # Kalman gain (trust slider)
    mu_new  = mu_bar + K * (z - c * mu_bar)      # nudge toward the surprise
    var_new = (1.0 - K * c) * var_bar             # shrink variance
    return mu_new, var_new

# reproduce the worked example exactly:
mu, var = kf_1d_step(2.0, 0.4, u=1.0, z=3.6, R=0.2, Q=0.1)
print(round(mu,3), round(var,3))   # -> 3.514 0.086

A second cycle — watch the recursion compound

The filter is recursive: the output of one cycle is the input to the next. Take our result (μ = 3.514, σ² = 0.086) as the new prior. Command another +1.0 m move; the sensor now reads z = 4.7.

StepComputationResult
Predict mean3.514 + 1.0μ̅ = 4.514 m
Predict var0.086 + 0.20σ̅² = 0.286
Gain K0.286 / (0.286 + 0.10)K = 0.741
Update mean4.514 + 0.741·(4.7 − 4.514)μ = 4.652 m
Update var(1 − 0.741)·0.286σ² = 0.074

Two things to notice across cycles. First, the gain dropped from 0.857 to 0.741: as the estimate sharpens (smaller σ̅² entering the update), the filter trusts each new measurement a little less and its own prediction a little more. Second, the variance keeps trending down (0.086 → predict-to-0.286 → update-to-0.074), settling toward a steady-state value where the per-step variance growth from process noise exactly cancels the shrink from updating. The filter is learning, and you can watch it converge in the numbers.

Run that loop tick after tick — predict, update, predict, update — and you have a working filter. The idiomatic NumPy version just promotes the scalars to matrices; we'll do that in Chapter 5. First, the one quantity that controls everything: the gain K.

In the 1-D update μ = μ̅ + K·(z − c·μ̅), what does the term (z − c·μ̅) represent?

Chapter 4: The Gain K — A Trust Slider

Of the five equations, one number decides everything: the Kalman gain K. With c = 1 it has a beautifully readable form:

K = σ̅² / (σ̅² + σQ²) = (prediction uncertainty) / (prediction uncertainty + measurement uncertainty)

Read K as a fraction between 0 and 1 — a literal trust slider sliding between "trust the prediction" and "trust the measurement." It compares the two uncertainties and hands the bigger weight to whichever source is more confident (smaller variance).

The two extremes, worked out

K → 1 (trust the measurement). If the sensor is sharp (σQ² tiny) or the prediction is vague (σ̅² huge), then K ≈ 1. The update becomes μ ≈ μ̅ + 1·(z − μ̅) = z. The filter snaps to the measurement and almost ignores the prediction.

Example: σ̅² = 10, σQ² = 0.01 → K = 10/10.01 = 0.999. The fused estimate is essentially the raw reading.

K → 0 (trust the prediction). If the sensor is junk (σQ² huge) or the prediction is razor-sharp (σ̅² tiny), then K ≈ 0. The update becomes μ ≈ μ̅ + 0 = μ̅. The filter ignores the measurement and rides the motion model.

Example: σ̅² = 0.01, σQ² = 10 → K = 0.01/10.01 = 0.001. The noisy reading barely moves the estimate.

The gain auto-tunes itself, every single step. You never hand-set K. The filter computes it fresh from the current uncertainties. Right after a long blind stretch (big σ̅²), the next measurement gets a big K and yanks the estimate into place. Once the estimate is sharp (small σ̅²), K shrinks and the filter politely declines to be jerked around by sensor jitter. This self-tuning is why the Kalman filter is "optimal" — it weights each source by exactly its reliability, moment to moment.

Watch the gain settle, step by step

The gain isn't static — it evolves as the filter gains confidence. Here's a robot at rest (no motion, a=1, b=0) with steady process noise σR² = 0.04 and a sensor of fixed noise σQ² = 0.25, starting from a vague prior σ0² = 4.0. Each tick: predict (var += 0.04), then compute K and shrink the var. Watch K fall toward a steady value as the estimate sharpens:

Tickσ̅² (after predict)K = σ̅²/(σ̅²+0.25)σ² (after update)
14.00 + 0.04 = 4.044.04/4.29 = 0.942(1−0.942)·4.04 = 0.235
20.235 + 0.04 = 0.2750.275/0.525 = 0.5240.131
30.131 + 0.04 = 0.1710.171/0.421 = 0.4060.102
40.102 + 0.04 = 0.1420.142/0.392 = 0.3620.091
→ ~0.34→ ~0.085

The first measurement gets a near-1 gain (0.942) and yanks the vague estimate into place. By tick 4 the gain has dropped to 0.36 and is converging toward a steady-state gain — the equilibrium where the shrink from updating exactly balances the growth from process noise. At steady state the filter no longer "learns" net confidence; it just maintains a fixed uncertainty, trusting each measurement a fixed fraction. This steady-state gain is what classical engineers precompute as the "α–β filter" constants. The Kalman filter derives them automatically from the noise ratio.

The steady-state gain depends only on the noise ratio. Solve for the fixed point and you'll find the converged K depends only on σR²/σQ² — the ratio of how fast you lose knowledge to how noisy your sensor is. Double both noises and K is unchanged. This is why tuning a Kalman filter is really tuning one ratio, not two absolute numbers.

Why this is the optimal weight (intuition)

If you have two independent noisy estimates of the same quantity — one with variance σ1², one with σ2² — the minimum-variance way to combine them weights each inversely to its variance. Concretely the fused mean is (μ11² + μ22²) / (1/σ1² + 1/σ2²) and the fused variance is 1/(1/σ1² + 1/σ2²) — "inverse variances add." The Kalman gain is exactly that inverse-variance weighting, algebraically rearranged into the (z − μ̅) innovation form. The noisier a source, the smaller its slice of the blend. There's no cleverer linear combination; K is provably the best one, which is the precise sense in which the Kalman filter is "optimal."

Slide the two uncertainties below and watch K respond, and watch the fused Gaussian (teal) sit between the prediction (purple) and the measurement (orange) — always closer to whichever is sharper, and always taller (more certain) than either input.

The trust slider: blending two Gaussians

Purple = prediction belief, orange = measurement likelihood, teal = the fused result. Drag the variances. As σQ² (measurement) shrinks, K→1 and the teal peak slides onto the orange. As it grows, K→0 and teal sits on purple. Note the teal is always narrower (more certain) than both.

σ̅² pred 1.5 σQ² meas 0.6 K = 0.71
Misconception: "a high gain means a good filter." No — a high K just means the filter currently trusts the sensor more than its own prediction. A chronically high K can signal you've set process noise σR² too high (the filter never builds confidence in its model) or measurement noise σQ² too low (the filter over-trusts a sensor and chases its jitter). Tuning a Kalman filter is largely tuning the ratio σR² : σQ², because that ratio sets where K settles.
Your prediction is very uncertain (σ̅² large) and your sensor is very precise (σQ² tiny). What does the Kalman gain K do, and what does the filter do?

Chapter 5: The Matrix Kalman Filter — Tracking in 2D

A real state is a vector, so μ becomes a vector and σ² becomes the covariance matrix Σ. The scalars a, b, c become matrices. Nothing conceptually changes — the same five equations, promoted to linear algebra. Here is the dictionary (Stanford's notation: F = motion matrix, B = control matrix, H = measurement matrix, R = process-noise covariance, Q = measurement-noise covariance):

SymbolShapeMeaning
Fn×nstate-transition matrix — how the state evolves on its own (was "a")
Bn×mcontrol matrix — how command u affects state (was "b")
Hk×nmeasurement matrix — maps state into what the sensor sees (was "c")
Rn×nprocess-noise covariance — how much the motion model can be off
Qk×kmeasurement-noise covariance — how noisy the sensor is

The five matrix equations

Predict:  μ̅ = Fμ + Bu    Σ̅ = FΣF + R
Update:  K = Σ̅H(HΣ̅H + Q)−1    μ = μ̅ + K(z − Hμ̅)    Σ = (I − KH)Σ̅

Compare line-for-line to the scalar version: a²σ² became FΣF (Gaussian property 1, the affine map on a covariance). The "+R" is still the added process noise (property 2). K is now a matrix, and the division became a matrix inverse. The innovation (z − Hμ̅) is now a vector of surprises. That's the whole algorithm.

Worked example: constant-velocity tracking

We'll track a target that moves on a line, but now the state holds both position and velocity: x = [p, v]. This is the classic "constant-velocity model." Over a timestep Δt = 1, position advances by velocity, velocity stays put. There's no control (B = 0), and the sensor measures position only (it can't see velocity directly — the filter must infer velocity). So:

F = [[1, 1],[0, 1]]    H = [1, 0]    R = [[0.01, 0],[0, 0.01]]    Q = [0.5]

Prior: μ = [0, 1] (at position 0, believed moving at 1 unit/step), Σ = [[1, 0],[0, 1]]. Measurement: z = 1.2 (sensor sees position).

Predict the mean — μ̅ = Fμ:

μ̅ = [[1,1],[0,1]]·[0,1] = [0·1 + 1·1, 0·0 + 1·1] = [1, 1]

Position moved from 0 to 1 (carried by velocity 1); velocity stayed at 1. Exactly the constant-velocity prediction.

Predict the covariance — Σ̅ = FΣF + R. First FΣ = [[1,1],[0,1]]·[[1,0],[0,1]] = [[1,1],[0,1]]. Then (FΣ)F = [[1,1],[0,1]]·[[1,0],[1,1]] = [[2,1],[1,1]]. Add R:

Σ̅ = [[2.01, 1.00],[1.00, 1.01]]

Notice the off-diagonal 1.00 appeared from nothing! Position and velocity are now correlated — the prediction step taught the filter that position-error and velocity-error move together. This coupling is what lets a position-only sensor correct the velocity estimate.

Innovation covariance S = HΣ̅H + Q. With H = [1, 0], HΣ̅H picks out the top-left entry 2.01, so S = 2.01 + 0.5 = 2.51.

Gain K = Σ̅HS−1. Σ̅H = first column of Σ̅ = [2.01, 1.00]. Divide by S = 2.51:

K = [2.01/2.51, 1.00/2.51] = [0.801, 0.398]

Two gains! K's top entry (0.801) corrects position; its bottom entry (0.398) corrects velocity — even though the sensor never measured velocity. The correlation we just grew is what carries the position-surprise into a velocity correction.

Update the mean. Innovation = z − Hμ̅ = 1.2 − 1 = 0.2. So μ = μ̅ + K·(0.2) = [1, 1] + [0.160, 0.080] = [1.160, 1.080]. The position was nudged toward the reading, and the velocity estimate edged up too — the filter inferred "you're a touch further along than I thought, so you're probably going a touch faster."

The headline result. A sensor that measures only position just improved the estimate of velocity. That is the superpower of the matrix Kalman filter: by tracking the full covariance, surprises in one variable propagate into corrections of correlated variables you can't even sense directly. This is how a GPS that only gives position lets a Kalman filter estimate your speed and heading.

From scratch in NumPy

python
import numpy as np

def kf_step(mu, Sig, u, z, F, B, H, R, Q):
    # predict
    mu_bar  = F @ mu + B @ u
    Sig_bar = F @ Sig @ F.T + R
    # update
    S = H @ Sig_bar @ H.T + Q                 # innovation covariance
    K = Sig_bar @ H.T @ np.linalg.inv(S)      # Kalman gain (matrix)
    y = z - H @ mu_bar                         # innovation (surprise)
    mu_new  = mu_bar + K @ y
    I = np.eye(len(mu))
    Sig_new = (I - K @ H) @ Sig_bar
    return mu_new, Sig_new

# constant-velocity tracker reproducing the worked numbers:
F = np.array([[1.,1.],[0.,1.]]); B = np.zeros((2,1)); H = np.array([[1.,0.]])
R = np.eye(2)*0.01; Q = np.array([[0.5]])
mu = np.array([0.,1.]); Sig = np.eye(2)
mu, Sig = kf_step(mu, Sig, np.zeros(1), np.array([1.2]), F, B, H, R, Q)
print(mu.round(3))   # -> [1.16 1.08]

The "from scratch" and the "idiomatic" version are the same here — the matrix form is the clean form. We just promoted scalars to arrays and division to np.linalg.inv. Now let's watch it run continuously, tracking a moving target.

In the matrix predict step Σ̅ = FΣF + R, what does adding R accomplish?

Chapter 6: 2D Tracking — The Ellipse That Follows

Let's put the matrix filter to work on a problem you can see: tracking a target that wanders across the plane. The state is now four numbers — position and velocity in both x and y: x = [px, py, vx, vy]. The constant-velocity model says each position advances by its velocity each step:

F = [[1,0,Δt,0],[0,1,0,Δt],[0,0,1,0],[0,0,0,1]]

The sensor measures position only (a noisy ping of where the target is), so H picks out the first two coordinates: H = [[1,0,0,0],[0,1,0,0]]. The filter never directly sees velocity — it infers it from how position changes, exactly as in Chapter 5, now in 2D.

Watch three things at once. (1) The estimate (teal dot) glides smoothly through the cloud of jittery measurements (orange dots) — it doesn't chase each one. (2) The covariance ellipse (teal outline) breathes: it grows between measurements (predict adds R) and snaps tighter when a measurement arrives (update shrinks it). (3) The estimate slightly lags or leads the truth depending on the noise balance — that's the trust slider K at work, frame by frame.
2D constant-velocity tracker

Grey = true target path. Orange dots = noisy position measurements. Teal dot + ellipse = the Kalman estimate & its uncertainty. Press Run. Increase measurement noise and watch the orange scatter explode while the teal estimate stays smooth (and its ellipse grows, because it trusts the model more).

Meas noise Q 1.5 Proc noise R 0.050

Try the two failure modes by hand. Crank Q way up (sensor is garbage): the filter stops trusting measurements, K shrinks, the ellipse grows, and the estimate coasts on the constant-velocity model — smooth but it can drift if the target turns. Crank R way up (model is untrusted): the filter trusts every measurement, K→1, the estimate becomes as jittery as the orange dots. The sweet spot is in between, and finding it is the art of filter tuning.

The four-dimensional belief, made concrete

The covariance Σ is now 4×4, and its structure tells a story. The top-left 2×2 block is the position uncertainty (the ellipse you see). The bottom-right 2×2 block is the velocity uncertainty (invisible, but real). The off-diagonal blocks are the position–velocity correlations — and after the F matrix mixes them each predict step, those correlations are what let a position-only sensor sharpen the hidden velocity estimate, exactly as in Chapter 5's worked example, now happening 14 times a second.

Trace one tick in your head: predict advances each position by its estimated velocity (the Δt entries in F) and inflates Σ by R; update takes the 2-D position innovation (z − Hμ̅), runs it through the 4×2 gain K, and corrects all four state components — including the two velocities the sensor never saw. The estimate's smoothness is the velocity estimate doing its job: a good velocity lets the predict step place the next estimate accurately before the noisy measurement even arrives.

Why the estimate can lead the measurements. A pure measurement-averaging tracker always lags — it can only react to where the target was. The Kalman filter holds a velocity estimate, so its predict step extrapolates where the target will be. On a straight run it can sit slightly ahead of the latest noisy ping. That predictive lead is impossible without tracking velocity in the state — and it's why Kalman filters power everything from missile guidance to finger-tracking on a touchscreen.
Misconception: "the constant-velocity model means the target must move at constant velocity." It doesn't — the process noise R is exactly the budget for the target to break the model (accelerate, turn). A larger R says "I expect the velocity to change a lot," keeping the filter responsive. Too small an R and a turning target outruns the filter (the estimate lags behind on every corner, because the model insists the velocity is nearly constant). The model is a prior, not a law — and R sets how strongly you hold that prior.
In the 2D tracker, why does the covariance ellipse grow between measurements and shrink when a measurement arrives?

Chapter 7: The Extended Kalman Filter — Bending the Line

The Kalman filter's whole magic rested on one word: linear. F, B, H were matrices, and a linear map of a Gaussian is a Gaussian. But a real robot's world is curved. A differential-drive robot's motion has cosθ and sinθ in it. A range sensor measures √((mx−x)² + (my−y)²) — a square root, hopelessly nonlinear. Push a Gaussian through those and it warps into a banana shape that is no longer Gaussian. The clean machinery breaks.

The general nonlinear models replace the matrices with functions:

xt = g(ut, xt−1) + εt     zt = h(xt) + δt

where g is the nonlinear motion model and h is the nonlinear measurement model. The Extended Kalman Filter (EKF)'s key idea is delightfully pragmatic: don't compute the warped banana exactly — approximate g and h by their best straight-line fit at the current estimate, then run the ordinary Kalman filter on that line.

The trick is linearization. Near a point, any smooth curve looks like a straight line (its tangent). So we take a first-order Taylor expansion of g and h around our current best guess — replacing the curve with its tangent line. The slope of that tangent, for a vector function, is the Jacobian (the matrix of all partial derivatives). The Jacobian is the local F (or H). We relinearize every step, because the best tangent point moves as the estimate moves.

The derivation, in one breath

Start from the Taylor expansion of the motion model about the previous mean μt−1:

g(ut, xt−1) ≈ g(ut, μt−1) + G·(xt−1 − μt−1)

The first term is a constant (g at the mean); the second is a linear function of x with slope-matrix G = ∇xg. But that's precisely the affine form A·x + b that the Kalman filter already knows how to handle! So we just feed this linearization into the KF machinery: the mean rides the real curve (μ̅ = g(u, μ), the constant term), and the covariance rides the linear slope (Σ̅ = GΣG + R, the affine-property formula with G in the F-slot). The measurement model gets the identical treatment, expanded about the predicted mean μ̅, giving H = ∇xh. That's the entire EKF derivation: Taylor-expand, then run the KF on the tangent.

The two expansion points differ on purpose. The motion Jacobian G is evaluated at the previous mean μt−1 (that's the best estimate available before the move). The measurement Jacobian H is evaluated at the predicted mean μ̅t (the best estimate available after the move but before the measurement). Always linearize about your most current estimate — that's where the tangent line fits best, so the approximation error is smallest.

The EKF equations — the KF with g, h, and Jacobians

Define G = ∂g/∂x evaluated at the previous mean μt−1, and H = ∂h/∂x evaluated at the predicted mean μ̅t. Then:

Predict:  μ̅ = g(ut, μ)    Σ̅ = GΣG + R
Update:  K = Σ̅H(HΣ̅H + Q)−1    μ = μ̅ + K(z − h(μ̅))    Σ = (I − KH)Σ̅

Spot the difference from Chapter 5. The mean is pushed through the full nonlinear function (μ̅ = g(...), not Fμ) — we use the real curve for the best estimate. But the covariance is propagated through the linearized Jacobian (GΣG) — we use the tangent line for the uncertainty. The innovation uses the real h: (z − h(μ̅)). That's the entire EKF: KF equations with g/h for the means and G/H for the covariances.

Worked example: the Jacobian of a range/bearing measurement

This is the heart of EKF localization (next chapter). A robot at pose (x, y, θ) measures a landmark at known map location (mx, my). The sensor returns the range r (distance) and bearing φ (angle relative to the robot's heading). The measurement model h is (from the source):

h = [ r, φ ] = [ √((mx−x)² + (my−y)²),   atan2(my−y, mx−x) − θ ]

That square root and arctangent are why we need the EKF. The Jacobian H = ∂h/∂(x,y,θ) is a 2×3 matrix. Let q = (mx−x)² + (my−y)² (the squared distance, so r = √q). Working out the partial derivatives gives:

H = [[ −(mx−x)/√q, −(my−y)/√q, 0 ],
      [ (my−y)/q, −(mx−x)/q, −1 ]]

Let's plug in actual numbers. Robot at (x, y, θ) = (1, 2, 0). Landmark at (mx, my) = (4, 6). Then:

H = [[−0.6, −0.8, 0], [0.16, −0.12, −1]]

Sanity-check the signs — they encode geometry. The range row says: move the robot +1 in x (the −0.6 entry) and the measured range decreases by 0.6 (you got closer to the landmark on its left). The bearing row's last entry is exactly −1: rotate the robot by +1 radian and the measured bearing drops by 1 radian (turning left makes a landmark appear more to your right). The Jacobian isn't abstract — each entry is "how much does this reading change if I nudge this state coordinate," and the numbers match physical intuition.

Confirming the Jacobian numerically (finite differences)

python
import numpy as np

def h(state, m):
    x, y, th = state
    dx, dy = m[0]-x, m[1]-y
    r   = np.hypot(dx, dy)
    phi = np.arctan2(dy, dx) - th
    return np.array([r, phi])

def H_analytic(state, m):
    x, y, th = state
    dx, dy = m[0]-x, m[1]-y
    q = dx*dx + dy*dy; r = np.sqrt(q)
    return np.array([[-dx/r, -dy/r, 0],
                     [ dy/q, -dx/q, -1]])

def H_numeric(state, m, eps=1e-6):       # finite-difference check
    J = np.zeros((2, 3))
    for i in range(3):
        s1 = np.array(state, float); s1[i] += eps
        s0 = np.array(state, float); s0[i] -= eps
        J[:, i] = (h(s1, m) - h(s0, m)) / (2*eps)
    return J

state, m = [1.,2.,0.], [4.,6.]
print(H_analytic(state, m).round(3))   # [[-0.6 -0.8  0.] [ 0.16 -0.12 -1.]]
print(np.allclose(H_analytic(state,m), H_numeric(state,m)))  # True

That allclose(...) == True is how you debug a hand-derived Jacobian in practice: wiggle each state coordinate by ε, watch how h changes, and confirm the slope matches your formula. A wrong Jacobian is the #1 cause of a diverging EKF.

When linearization fails. The tangent line is a good fit only near the linearization point. If the function is sharply curved or the uncertainty Σ is large (the belief spans a region where the curve bends a lot), the straight-line approximation is bad, the propagated covariance is wrong, and the EKF can diverge — confidently converge to the wrong answer. The two fixes: keep uncertainty small (good initialization, frequent updates) or use a method that doesn't linearize at all. The latter is the UKF, Chapter 9.
How does the EKF handle a nonlinear measurement model h(x)?

Chapter 8: EKF Localization — Finding the Robot on a Known Map

Now the payoff for the whole robot-autonomy stack. Localization is the problem of determining the robot's pose relative to a given map. The map is a list of landmarks — point features with known global coordinates mj = (mj,x, mj,y). The robot drives around (odometry → prediction) and, whenever it sees a landmark, measures its range and bearing (sensor → update). EKF localization fuses these into a continuously-corrected pose. This is hw4's Problem 1, and it's how a real TurtleBot knows where it is in a mapped building.

The motion model g (differential drive)

The control is u = [V, ω] (forward velocity, turn rate). Over a timestep Δt with a zero-order hold, integrating the differential-drive dynamics gives the discrete motion model g (for ω not near zero):

x' = x + (V/ω)(sin(θ+ωΔt) − sinθ)
y' = y − (V/ω)(cos(θ+ωΔt) − cosθ)
θ' = θ + ωΔt

When ω → 0 (driving nearly straight) this is numerically unstable (dividing by a tiny ω), so we use the straight-line limit: x' = x + V·cosθ·Δt, y' = y + V·sinθ·Δt, θ' = θ. The cos/sin make g nonlinear — hence EKF, not KF.

The motion Jacobian G, with numbers

The covariance prediction needs G = ∂g/∂(x,y,θ), a 3×3 matrix. Since x and y don't appear on the right-hand side of g except as the carried-over x, y (slope 1), the only nontrivial column is the θ one. Differentiating the straight-line form (small ω) gives the clean result:

G = [[1, 0, −V·sinθ·Δt], [0, 1, V·cosθ·Δt], [0, 0, 1]]

Plug in numbers. Robot at θ = 0, commanded V = 0.5 m/s for Δt = 1 s. Then −V·sin0·1 = 0 and V·cos0·1 = 0.5, so:

G = [[1, 0, 0], [0, 1, 0.5], [0, 0, 1]]

Read the (1,3) entry, 0.5: if the heading θ is off by a little, the predicted y position is off by 0.5× as much (because at θ=0 the robot drives along x, so a heading error tips it sideways in y). That single off-diagonal is how a heading uncertainty leaks into a position uncertainty during prediction — and conversely how a position-correcting landmark sighting can tighten the heading estimate. The covariance step Σ̅ = GΣG + R then grows and rotates the pose ellipse according to this G, and adds the process-noise budget R.

The measurement model h (range & bearing)

Exactly the h from Chapter 7: for the landmark we're looking at, h returns the predicted range and bearing, and H is the 2×3 Jacobian we computed. The full EKF-localization loop:

1. Predict
μ̅ = g(u, μ); Σ̅ = GΣG + R — odometry rolls the pose forward, uncertainty grows
2. Predict z
for each visible landmark j: expected reading h(μ̅, mj) and Jacobian Hj
3. Update
K, μ, Σ via the EKF correction — the real reading pulls the pose back, uncertainty shrinks

A concrete prediction–then–correction

Robot believes it's at μ = (1, 2, 0). It uses the Chapter 7 landmark at (4, 6), so it predicts it should see range r̂ = 5.0 and bearing φ̂ = atan2(4, 3) − 0 = 0.927 rad (about 53°). Now the real sensor returns z = (range 4.6, bearing 0.95). The innovation is the surprise: (4.6 − 5.0, 0.95 − 0.927) = (−0.4, +0.023). A negative range innovation means "I'm closer to the landmark than I thought," so the update will shift the robot's estimated position toward the landmark.

Let's actually compute the position correction. Take the predicted covariance Σ̅ = diag(0.3, 0.3, 0.1) (modest uncertainty), the Chapter-7 Jacobian H = [[−0.6, −0.8, 0], [0.16, −0.12, −1]], and measurement noise Q = diag(0.04, 0.01). We need S = HΣ̅H + Q. The range row of HΣ̅ is [−0.6·0.3, −0.8·0.3, 0] = [−0.18, −0.24, 0]; dotting with the range row of H gives 0.6·0.18 + 0.8·0.24 = 0.108 + 0.192 = 0.30. So the range–range entry of S is 0.30 + 0.04 = 0.34. Focusing on the dominant range channel, its gain into x is roughly Σ̅xx·Hr,x/Srr = 0.3·(−0.6)/0.34 = −0.53.

The x-correction from the range innovation is then (−0.53)·(−0.4) = +0.21 m: the negative gain times the negative surprise yields a positive push in x — toward the landmark, which sits at larger x. Run the same arithmetic for y and you get a positive push toward y = 6 as well. The pose estimate slides from (1, 2) toward (4, 6) by the amount the geometry and the relative uncertainties justify, and Σ shrinks along the landmark's line of sight. The signs of H carried the surprise into a correction in exactly the right physical direction — which is the whole reason we computed that Jacobian so carefully in Chapter 7.

Why one landmark isn't enough — and why drift along walls happens. A single range/bearing reading constrains the pose in some directions but not all. A robot seeing one landmark can fix its distance and angle to that landmark, but can still slide in the unconstrained direction with the measurement happily agreeing the whole time. (The hw4 note: "if you stop with only one wall in view, your EKF estimate may have drifted parallel to that wall — turn about yourself to see it corrected.") Multiple landmarks from different directions pin all three pose coordinates. Geometry, not just math.

Multiple landmarks and data association

Real scans return several landmarks at once. Because the measurements are conditionally independent given the pose, the multi-measurement update is just a loop: apply the standard EKF correction once per landmark, as if no motion happened in between. But there's a subtlety the source dwells on: data associationwhich map landmark produced this reading? With unknown correspondences, you pick the landmark j that minimizes the Mahalanobis distance between the actual reading and the predicted one:

dj = (z − hj) Sj−1 (z − hj),   Sj = HjΣ̅Hj + Q

The Mahalanobis distance is "how many standard deviations of surprise is this association" — an innovation weighted by its covariance, so a big innovation through a very uncertain channel can still be a plausible match. A validation gate (dj ≤ γ) rejects readings that don't match any landmark well, so a wandering pedestrian's leg doesn't corrupt your pose.

Misconception: "EKF localization solves global localization." It does not. The EKF maintains a single Gaussian, so it can only do position tracking — refining a pose you already roughly know. If the initial pose is unknown (global localization) or the robot is "kidnapped" and teleported, a unimodal Gaussian can't represent "I might be in any of three identical hallways." That genuinely multi-modal belief needs the non-parametric particle filter — Monte Carlo Localization, Lesson 14.
In EKF localization, what is the role of the Mahalanobis distance and validation gate?

Chapter 9: The Unscented Kalman Filter — No Jacobians, Just Samples

The EKF's weakness is the linearization itself: it replaces a curve with a single tangent line at one point (the mean), then propagates the whole covariance through that one line. If the function bends within the spread of the belief, that's a lousy approximation. The Unscented Kalman Filter (UKF) attacks this with a different philosophy, captured in one slogan: it is easier to approximate a probability distribution than an arbitrary nonlinear function.

The unscented transform, in three beats. (1) Pick a small set of carefully chosen sigma points — deterministic sample points that capture the mean and covariance of the belief exactly. (2) Push each sigma point through the true nonlinear function (no Jacobians, no derivatives at all). (3) Recover a new mean and covariance from the weighted, transformed points. You never linearize; you let the real function tell you where the points land, then fit a Gaussian to them.

Picking the sigma points

For an n-dimensional belief N(μ, Σ) we use 2n+1 sigma points. The center one is the mean. The other 2n are placed symmetrically along the principal axes of the covariance, at a distance set by a spread parameter. For the 1-D case (n=1), the three sigma points are simply:

χ0 = μ,   χ1 = μ + √((1+λ)σ²),   χ2 = μ − √((1+λ)σ²)

where λ is a tuning scalar (the points sit a few standard deviations out). The square root of the covariance is what spreads the points to match the belief's width. In higher dimensions, √Σ becomes a matrix square root (Cholesky factor), and the points spread along its columns.

Worked example: the unscented transform on y = x²

Let's push a Gaussian through the nonlinear function y = x², by hand. Belief: μ = 2, σ² = 1. Take λ = 0 for simplicity, so the spread distance is √((1+0)·1) = 1. The three sigma points and equal weights w = 1/3 each (with λ=0, all weights equal 1/(2(n+λ)) for the outer points and λ/(n+λ) for the center; with n=1, λ=0 this gives 0, 1/2, 1/2 — let's use that standard weighting):

Sigma pointx valuey = x²weight w
χ0 (center)2.04.00
χ1 (+)2 + 1 = 3.09.00.5
χ2 (−)2 − 1 = 1.01.00.5

Recover the mean — weighted average of the transformed points: μy = 0·4 + 0.5·9 + 0.5·1 = 5.0.

Recover the variance — weighted spread of transformed points around μy: σy² = 0.5·(9−5)² + 0.5·(1−5)² = 0.5·16 + 0.5·16 = 16.0.

Compare to the EKF's answer. The EKF would linearize y = x² at μ = 2: the derivative is 2x = 4, so it predicts μy ≈ h(2) = 4 and σy² ≈ 4²·1 = 16. The true mean of x² for x~N(2,1) is E[x²] = μ² + σ² = 4 + 1 = 5. The UKF got the mean exactly right (5.0) — it captured the curvature bias that the EKF missed (the EKF said 4, off by the whole σ²). This is the UKF's headline advantage: it captures effects to higher order than the EKF's single tangent line, for free, with no derivatives.

Why the UKF beats the EKF here. Squaring a spread-out distribution shifts its mean up (both tails contribute positive y), a curvature effect a tangent line can't see. The UKF's points sampled both sides of the curve and averaged the real outputs, so they felt the bend. The EKF's single tangent at the mean was blind to it. The more curved h is across the belief's width, the bigger the UKF's edge.

From transform to full filter step

The unscented transform we just did is the core; a full UKF step wraps the predict and update around it. To predict: take the sigma points of bel(xt−1), push them through the motion model g, then recover μ̅ and Σ̅ from the transformed cloud (plus add process noise R). To update: take sigma points of the predicted belief, push them through the measurement model h to get a predicted measurement cloud, then compute the gain from the cross-covariance between the state cloud and the measurement cloud:

K = Σxz · S−1,   μ = μ̅ + K(z − ẑ),   Σ = Σ̅ − K·S·K

where S is the measurement cloud's covariance (plus Q) and Σxz is the cross-covariance between the state sigma points and their measurements. Compare to the EKF gain Σ̅H(HΣ̅H+Q)−1: the term Σ̅H (state-to-measurement coupling via the Jacobian) is replaced by the empirically measured cross-covariance Σxz. Same structure, but no derivative anywhere — the sigma points discovered the coupling by being pushed through the real h.

The weighting subtlety. The mean and covariance use slightly different weights (often called wm and wc), and a third parameter β lets the covariance weight of the center point encode prior knowledge that the distribution is Gaussian (β = 2 is optimal for Gaussians). The spread parameters (α, κ, combined into λ) control how far out the sigma points sit. These knobs are why the UKF is sometimes called "tunable" — but the defaults work for most robotics problems, and the headline behavior (capturing curvature the EKF misses) holds regardless.

From scratch (1-D unscented transform)

python
import numpy as np

def unscented_1d(mu, var, f, lam=0.0):
    n = 1
    spread = np.sqrt((n + lam) * var)
    chi = np.array([mu, mu + spread, mu - spread])   # 2n+1 = 3 sigma points
    wm  = np.array([lam/(n+lam), 0.5/(n+lam), 0.5/(n+lam)])  # weights
    Y   = f(chi)                                     # push each through TRUE f
    mu_y  = np.sum(wm * Y)
    var_y = np.sum(wm * (Y - mu_y)**2)
    return mu_y, var_y

mu_y, var_y = unscented_1d(2.0, 1.0, lambda x: x**2)
print(round(mu_y,2), round(var_y,2))   # -> 5.0 16.0  (EKF would say 4.0, 16.0)

Watch the transform happen. Below, a Gaussian (left) has its three sigma points pushed through y = x² (the curve), landing as transformed points (right) that we average into the output Gaussian. Drag the input mean and width; the sigma points climb the curve and the output Gaussian reshapes — and the UKF's recovered mean tracks the true warped mean, not the tangent-line guess.

The unscented transform, live

Input Gaussian (purple, bottom) → sigma points (dots) climb the curve y = x² → transformed points fit an output Gaussian (teal, right). The dashed orange shows the EKF's tangent-line guess for comparison. Move the sliders: where the curve bends, UKF (teal) and EKF (orange) diverge.

input μ 1.4 input σ² 0.70
Misconception: "the UKF is always worth it over the EKF." Not always. The UKF needs no Jacobians (a real win when h is a tangle of code that's painful to differentiate), and it's more accurate for strong nonlinearities. But it's more expensive (2n+1 function evaluations per step) and still assumes a unimodal Gaussian belief — so like the EKF it cannot do global localization. For mildly nonlinear, well-initialized tracking, the EKF is often plenty. Pick the UKF when the Jacobian is nasty or the curvature is steep.
What is the core difference between how the EKF and UKF handle a nonlinearity?

Chapter 10: Showcase — A Robot Localizes Itself, Live

Everything converges here. A robot drives a loop through a known map of landmarks (purple squares). It can't see its own pose — it only has noisy odometry (predict) and noisy range/bearing readings to landmarks (update). The teal robot is the EKF estimate; the faint grey robot is the unknown truth; the teal ellipse is the live pose covariance Σ, breathing as the filter predicts and corrects. Beams flash to the landmarks the robot is currently using to localize.

This is the whole lesson in one sim. Predict grows the ellipse (odometry drift). A landmark sighting fires the update, K pulls the estimate toward truth, the ellipse snaps tight. Crank process noise and the robot trusts its sensors more (ellipse stays small but jumpy). Crank measurement noise and it coasts on odometry (ellipse balloons, estimate drifts). Toggle sensor dropout to blind it — watch the ellipse swell with no corrections, then collapse the instant a landmark reappears.
EKF localization — the live tracker

Purple squares = mapped landmarks. Grey = true pose. Teal robot + ellipse = EKF estimate & uncertainty. Teal beams = landmarks in use. Press Run. Sliders set process noise (trust in odometry) and measurement noise (trust in sensors). Dropout blinds the sensor — watch the ellipse balloon, then snap back when sight returns.

Proc noise 1.0 Meas noise 1.0
ready — press Run

Push it until it breaks. Set measurement noise to the max and turn dropout on for a while: the ellipse grows huge, the estimate wanders off the truth on pure dead-reckoning — exactly the Chapter 0 problem, now visible as a swelling ellipse. Then drop a landmark back in: a single good reading collapses the ellipse and yanks the estimate home. That snap is the Kalman gain doing its job — a huge Σ̅ means a near-1 gain, so the fresh measurement dominates. The filter automatically becomes greedy for information exactly when it's most lost.

From here to the real stack. This estimate — pose + covariance — is what every other module in the autonomy stack (Lesson 1) consumes. The planner plans from this pose. The controller tracks a trajectory relative to this pose. When the ellipse is huge, a good autonomy system slows down or seeks landmarks (active localization). The filter isn't a side calculation; it's the foundation the whole robot stands on.

Chapter 11: Connections & Cheat-Sheet

You can now derive, by hand and in code, the entire Kalman family — and you understand exactly where each one lives on the autonomy stack: the filter is the Localization & SLAM module (Lesson 1's purple band), fusing the motion model (Lesson 2) with the sensors (Lesson 7) into the pose estimate every other module runs on.

The family, at a glance

FilterHandlesHowUse when
Bayes filterany beliefpredict integral + Bayes update; intractable in generalthe theory all others specialize
Kalman (KF)linear + Gaussian5 matrix eqs; F, H, Klinear models (constant-velocity tracking)
EKFnonlinear, unimodallinearize g,h via Jacobians G,Hmildly nonlinear, good init (position tracking)
UKFnonlinear, unimodalsigma points through true f; no Jacobiansstrong nonlinearity / nasty Jacobian
Particle (Lesson 14)nonlinear, multi-modalswarm of weighted samplesglobal localization / kidnapped robot

Cheat-sheet

Bayes filter: bel̅(x) = ∫ p(x|u,x') bel(x') dx'  (predict); bel(x) = η p(z|x) bel̅(x)  (update). Predict grows uncertainty; update shrinks it.

1-D KF (5 eqs): μ̅=aμ+bu; σ̅²=a²σ²+R; K=σ̅²/(σ̅²+Q); μ=μ̅+K(z−μ̅); σ²=(1−K)σ̅².

Gain K = trust slider: K→1 trust the measurement; K→0 trust the prediction; it auto-tunes from the uncertainties.

Matrix KF: μ̅=Fμ+Bu, Σ̅=FΣF+R; K=Σ̅H(HΣ̅H+Q)−1, μ=μ̅+K(z−Hμ̅), Σ=(I−KH)Σ̅.

EKF: same, but μ̅=g(u,μ), ẑ=h(μ̅), and F→G=∂g/∂x, H=∂h/∂x (Jacobians). Range/bearing H is 2×3.

UKF: 2n+1 sigma points capture (μ,Σ) → push through true f → refit Gaussian. No derivatives; captures curvature.

Go deeper on Engineermaxxing

These lessons unpack each piece in its own right — cross-link your understanding:

"What I cannot create, I do not understand." — Richard Feynman.
You can now create a filter that fuses a noisy prediction with a noisy measurement into a confident pose. Next, Lesson 14: when one Gaussian isn't enough — the particle filter and Monte Carlo Localization.
A robot's initial pose is completely unknown — it could be in any of several identical-looking corridors. Which filter is appropriate, and why can't the EKF or UKF do it?