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

The Cloud That Knows Where It Is

The Kalman filter (Lesson 13) carries a single Gaussian blob of belief. But a robot in a symmetric hallway could be in several places at once. The particle filter — the non-parametric cousin of the Kalman filter — represents that multimodal belief as a swarm of guesses, and Monte Carlo Localization turns the swarm loose on a map.

Prerequisites: The Bayes filter + a Gaussian bell curve + a little Python. That's it.
11
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: A Bell Curve Can't Be in Two Places at Once

You are a robot. Someone switches you on in the middle of a long office corridor and asks, "Where are you?" You look around. Every few meters there is an identical door, an identical fire extinguisher, an identical strip of fluorescent light. Your laser scanner reports a corridor 2.4 meters wide with a door 1 meter ahead. That description fits thirty spots in the building equally well.

Here is the honest answer to "where are you?": I could be at any of thirty places, and I have no reason yet to prefer one over another. A good belief is not a single best guess. It is a belief that is multimodal — it has several distinct peaks, one for each place the robot might really be.

In Lesson 13 you met the Kalman filter and its nonlinear relatives, the EKF and UKF. They are magnificent, but they all carry belief as one Gaussian — a single bell-shaped hill, defined by a mean (the best guess) and a covariance (how spread out it is). A Gaussian has exactly one peak. It is constitutionally incapable of saying "I'm probably here, OR here, OR here." Forced to summarize thirty equally-good hypotheses, a Gaussian smears one fat blob across the whole corridor and reports a mean that sits, absurdly, in a place the robot almost certainly is not.

The misconception to kill right now. "More sensors or a better Kalman filter would fix this." No. The limitation is structural, not a matter of tuning. A unimodal distribution cannot express two simultaneous high-probability hypotheses, no matter how good your measurements are. The fix is to change the shape of the belief we are allowed to carry — and that is exactly what a non-parametric filter does.

Watch the failure happen. Below, a robot lives in a corridor with several identical landmarks (the tall marks). Its true belief — what an honest filter should hold — has one bump under each landmark. Toggle between the true multimodal belief and the single Gaussian a Kalman filter is forced to fit to it. The Gaussian's mean (the orange tick) often lands in a gap between landmarks, where the robot demonstrably is not.

Why a Single Gaussian Fails in a Symmetric World

The corridor has identical landmarks (gray bars). Drag the landmark count slider. The purple curve is the true belief: equal bumps under each landmark. Toggle to see the best single Gaussian a Kalman filter could fit — its mean (orange) sits in the wrong place, and its variance balloons to cover everything.

Landmarks 4
A multimodal belief keeps every hypothesis alive.

The Gaussian is not wrong the way a bug is wrong. It is doing the best it can within a shape that fundamentally cannot represent "several places at once." We need a belief that can be any shape — two peaks, three peaks, a crescent, a ring. That is a non-parametric belief: one that does not commit, in advance, to a fixed functional form like a bell curve.

Parametric vs. non-parametric — the real distinction

Stanford's Lecture 13 organizes all tractable Bayes filters into two camps, and the split is exactly the one we just hit:

The histogram filter is the simplest non-parametric idea: chop the state space into bins and store one probability per bin, like a bar chart of belief. It works, but bins scale badly — a fine grid over a 3-D pose is millions of bins, most of them empty. The particle filter is the clever fix: instead of bins fixed in advance, it puts its "resolution" only where the belief actually lives, by carrying a cloud of samples that drifts to the interesting regions. Empty regions cost nothing because no particle sits there.

Here is the punchline that motivates the whole lesson. In Lesson 13 you learned to represent belief by its first two moments — mean and covariance — because that is all a Gaussian has. The particle filter throws that representation away and keeps the samples themselves. The recursion (predict, then correct on a measurement) is unchanged; only the container for the belief changes. That single swap buys you multimodality, native nonlinearity, global localization, and recovery from catastrophic failure — the four things the next ten chapters unpack.

The trick this lesson teaches is shockingly simple. Instead of storing a formula for the belief, we store a cloud of guesses — hundreds or thousands of candidate poses, called particles. Where many particles cluster, the belief is high. Where there are none, the belief is zero. The cloud can split into thirty clumps in a corridor, then collapse onto one once the robot rounds a corner and sees something distinctive. No formula could do that gracefully; a cloud does it for free.

Kalman / EKF (Lesson 13)
belief = one Gaussian (mean μ, covariance Σ) · one peak only
vs
Particle filter (this lesson)
belief = a cloud of M weighted samples · any shape, any number of peaks
Connection — this builds straight on the Bayes filter. Everything here is the same predict–correct loop you learned for the Bayes filter. We are just choosing a new representation for the belief. The Kalman filter chose "Gaussian." The particle filter chooses "a bag of samples." Same recursion, different container. We already have a standalone particle-filter lesson on this site; here we give it the robot-autonomy framing — localizing on a known map — which is where it earns its keep.
A robot is switched on in a corridor full of identical doors. Why can't a single Gaussian (Kalman) belief represent its situation honestly?

Chapter 1: A Belief Made of Samples

Let's make the idea precise. Stanford's Lecture 13 puts it in one line: represent the posterior belief by a set of random samples. Those samples are called particles. We collect the particles at time t into a set:

𝒳t = { xt[1], xt[2], …, xt[M] }

Read that carefully. Each xt[m] is one particle — a complete hypothesis about what the true state might be at time t. For a wheeled robot, each particle is itself a full pose: a triple (x, y, θ). The superscript [m] indexes which particle (the m-th of M); it is not an exponent. And M is the total number of particles — the size of the cloud.

The core idea. The density of particles is the probability. A region of state space crowded with particles is a region the robot thinks it probably occupies; a region with no particles is a region it has ruled out. We never write down a formula for the belief — we let the population of samples be the belief.

Why does a pile of dots approximate a probability distribution? Imagine you wanted to describe a bell curve to a friend over the phone, but you could only send them dots. You would scatter dots thickly where the curve is tall and sparsely where it is short. If your friend then drew a histogram of your dots, it would recover the bell curve. That is exactly what particles do: they are samples drawn from the distribution, so the histogram of the cloud reconstructs the distribution.

Formally, the particle filter wants its particles to be distributed according to the belief itself:

xt[m]  ∼  p( xt | z1:t, u1:t )  =  bel( xt )

The squiggle means "is sampled from." The right-hand side is the belief: the probability of state xt given all measurements z1:t and all controls u1:t up to now (the colon means "from step 1 through step t"). So a particle is a single random draw from the robot's current belief about its own pose.

Common confusion. "If particles are random draws, won't the answer be different every run?" Yes — and that's fine. A particle filter is a Monte Carlo method: it trades exactness for flexibility. Stanford's note is blunt: the cloud matches the true belief exactly only as M → ∞. In practice M ≈ 1000 is usually plenty, and the small run-to-run jitter is the price of being able to represent any distribution shape. The Kalman filter is exact but only for one shape; the particle filter is approximate but for all shapes.

How many particles is enough?

The cloud is a sampled portrait of the belief, so more particles means a finer portrait — just as more dots make a smoother histogram. But the cost grows linearly with M (every step touches every particle), and the accuracy improves only like 1/√M — the usual Monte Carlo rate. Quadrupling the particles only halves the sampling error. So you pick the smallest M that keeps the cloud from looking ragged. Play with it below: drag M and watch how faithfully the cloud reconstructs a known two-peaked belief.

That 1/√M rate is worth dwelling on, because it cuts both ways. The good news: it does not depend on the shape of the distribution — a two-peaked belief is no harder to approximate than a single bump, which is the entire reason particle filters can be multimodal for free. The bad news: it is a slow rate. Going from M = 100 to M = 10,000 (a hundredfold cost increase) buys only a tenfold accuracy gain. This is why nobody runs a particle filter with a million particles to chase precision — past a point you get diminishing returns, and the right move is a different representation, not more dots. For a 3-D robot pose, M between a few hundred and a couple thousand is the sweet spot, and adaptive schemes (KLD-sampling) even shrink M automatically when the cloud is tightly converged and grow it when the robot is lost.

Particles Reconstruct a Distribution

The smooth purple curve is a true two-peaked belief. The dots below are M particles sampled from it; the orange histogram is what you'd recover by binning them. Drag M: few particles → ragged, noisy histogram; many → a faithful copy of the curve. Press resample to draw a fresh cloud and see the run-to-run jitter.

M 200
200 particles approximate the belief.

Notice what you can do with this representation that a Gaussian forbids: the true curve has two peaks, and the cloud reproduces both. Try dragging M low — with only 15 particles the histogram is jagged and one peak might vanish entirely (a foreshadowing of particle depletion, Chapter 5). Crank M up and the histogram snaps to the curve. That dial — samples versus fidelity — is the whole engineering tradeoff of a particle filter.

Reading a number off the cloud

If the belief is just a pile of dots, how do you get an answer — a single estimate of where the robot is — out of it? You compute statistics of the cloud, exactly as you would for any sample set. The most common is the mean estimate: average the particles' positions.

t ≈ (1/M) Σm=1M xt[m]

That is the cloud's center of mass — the orange estimate you'll see in the showcase. (If the particles still carry weights rather than being equal, you use the weighted mean, Σ w[m] x[m].) You can also read off the spread (how confident the robot is) as the cloud's standard deviation, or, when the cloud is multimodal, you can cluster it and report each clump as a separate hypothesis — something a Gaussian's single mean simply cannot do.

Common confusion — the mean can be a lie. When the cloud is genuinely multimodal (two clumps, one at position 2 and one at position 8), the mean lands at 5 — right in the empty middle where no particle sits. The mean is only a sensible summary when the belief has a single mode. This is the same failure we pinned on the Gaussian in Chapter 0; the difference is that the particle filter still holds both clumps internally even when you carelessly summarize them with a bad mean. The information is there; you just have to ask the cloud the right question (which clump?) instead of the wrong one (what's the average?).
In a particle filter, what does a region of state space that is densely packed with particles mean?

Chapter 2: Predict, Weight, Resample — The Loop

A particle filter is the Bayes-filter recursion done on a cloud. Every cycle has exactly three beats. Burn them into memory; everything else in this lesson is detail on one of these three.

1. Predict
push each particle through the motion model + noise
2. Weight
score each particle by the measurement likelihood p(z|x)
3. Resample
draw a new cloud, each particle ∝ its weight
↻ every step

Beat 1 — Predict (the motion model)

The robot just executed a control ut (say, "drive forward 1 m, turn 10°"). We move every particle as if it were the true robot obeying that command. But wheels slip and odometry lies, so we don't move each particle deterministically — we sample a noisy outcome from the motion model:

t[m]  ∼  p( xt | ut, xt−1[m] )

The bar over marks a predicted particle (before we've looked at any measurement). Concretely, for each particle we compute where the command should take it, then add a little random jitter to its x, y, and θ. A cloud that started tight gets a bit fuzzier — motion always adds uncertainty, exactly as in the Bayes filter's predict step.

For a wheeled robot, the per-particle prediction is just the kinematics from Lesson 2 with noise stapled on. Given a command "drive distance d, turn angle δθ," each particle (x, y, θ) updates as:

θ′ = θ + δθ + εθ  ·  x′ = x + (d + εd) cosθ′  ·  y′ = y + (d + εd) sinθ′

where εd and εθ are small random draws from the motion-noise model — the slip in the wheels, the imperfection in the odometry. The crucial detail: every particle draws its own independent noise. So a cloud that was a tight dot before the move fans out into a little smear afterward, because each particle "imagines" a slightly different outcome of the same command. That fanning-out is the prediction step adding uncertainty — it is the cloud's honest admission that motion is imperfect and it now knows less precisely where it is.

Why noise per particle, not one shared noise. If you added the same random nudge to all particles, the cloud would translate rigidly — it would move but never spread, and the filter would become falsely overconfident. Independent noise per particle is what makes the cloud grow to reflect the new uncertainty. This is the single most common predict-step bug: drawing noise once instead of M times.

Beat 2 — Weight (the measurement model)

Now the robot takes a measurement zt (a laser range, a landmark bearing). We ask each predicted particle: if you were the true robot, how likely is this measurement? That likelihood is the particle's importance weight:

wt[m]  =  p( zt | x̄t[m] )

A particle whose predicted pose would have produced something close to the actual measurement gets a big weight; one that would have seen something completely different gets a tiny weight. Crucially, we do not move the particles in this step — we only attach a number to each. The cloud is now a set of weighted hypotheses.

Beat 3 — Resample (survival of the fittest)

Finally we build the new cloud 𝒳t by drawing M particles with replacement from the predicted set, each chosen with probability proportional to its weight. High-weight particles get copied many times; low-weight particles vanish. After resampling the weights reset to equal, and the population — how many copies of each pose survived — now encodes the belief. Stanford calls this "a probabilistic implementation of the Darwinian idea of survival of the fittest."

The three beats map exactly onto the Bayes filter. Predict = the motion update (belief spreads). Weight = the measurement likelihood. Resample = the normalization-and-correction that turns the weighted predicted belief into the corrected posterior. If you understood the Bayes filter, you already understand the particle filter; we've just swapped integrals for sums over samples.

A tiny worked example: 3 particles, one step

Let's run all three beats on a 1-D toy with M = 3, so we can do every number by hand. The robot moves along a line. Particles start at positions 2.0, 5.0, 8.0.

Predict. The command is "move +1.0." We add a sampled jitter to each (say the dice give +0.3, −0.2, +0.1):

Particlebefore+1.0 command+ jitterpredicted x̄
[1]2.03.0+0.33.3
[2]5.06.0−0.25.8
[3]8.09.0+0.19.1

Weight. The robot now measures "I am near position 6.0" with measurement noise σ = 1.0. Each particle's weight is the Gaussian likelihood of that measurement given the particle's predicted pose — we'll derive this exact formula in Chapter 3, but for now: the closer a particle is to 6.0, the bigger its weight.

Particlepredicted x̄distance to 6.0raw weight (Gaussian)
[1]3.32.70.026
[2]5.80.20.391
[3]9.13.10.0033

Particle [2] is the clear favorite — it predicted a pose right where the robot measured itself to be. Resample. Normalizing the weights gives probabilities 0.062, 0.930, 0.008. Drawing 3 new particles with those odds, we'd almost certainly get three copies of particle [2] (pose ≈ 5.8) and drop [1] and [3]. The new cloud has collapsed onto the measurement — the correction happened.

Step through it live:

One Loop, Three Particles, Stepped by Hand

A 1-D line. Press the buttons in order: Predict nudges every particle by the command + noise; Weight sizes each dot by its measurement likelihood (the orange tick is the measurement); Resample rebuilds the cloud, copying winners. Watch the cloud march toward the measurement.

3 particles at 2, 5, 8. Press Predict to begin.

That is the entire algorithm. Stanford's pseudocode (Algorithm 1) is the same three beats in a loop over m: sample a predicted particle, compute its weight, collect into a temporary set; then resample M times with probability proportional to weight. Written in words, beat by beat:

Algorithm: Particle_Filter
# inputs: prior particle set X_{t-1}, control u_t, measurement z_t
# output: posterior particle set X_t

X_bar = empty      # the temporary (predicted + weighted) set
X     = empty      # the new posterior set

for m = 1 to M:                          # --- PREDICT + WEIGHT ---
    x_bar[m] = sample from p(x_t | u_t, x_{t-1}[m])   # push particle through motion model
    w[m]     = p(z_t | x_bar[m])                       # importance weight = measurement likelihood
    add (x_bar[m], w[m]) to X_bar

for m = 1 to M:                          # --- RESAMPLE ---
    draw index i with probability proportional to w[i]
    add x_bar[i] to X

return X

Map each line back to the three beats. The first loop is Predict (sample from p(x_t | u_t, x_{t-1})) and Weight (w[m] = p(z_t | x_bar[m])) fused together — one pass over the cloud. The second loop is Resample: M draws, each picking a survivor with probability proportional to its weight. Notice the new set X may contain the same particle several times (heavy ones) and omit others entirely (light ones) — that's resampling with replacement.

Here is that pseudocode as runnable Python, the whole filter in fifteen lines, building on the weighting and resampling helpers we'll define in Chapters 3 and 4:

python
import numpy as np

def particle_filter_step(particles, u, z):
    # 1. PREDICT: move every particle by the control + sampled noise
    particles = motion_model(particles, u) + np.random.randn(*particles.shape) * motion_sigma
    # 2. WEIGHT: score each particle by how well it explains z
    w = measurement_likelihood(z, particles)   # p(z | x) per particle
    w = w / w.sum()                            # NORMALIZE (Chapter 3)
    # 3. RESAMPLE: draw a new cloud, each particle proportional to its weight
    particles = low_variance_resample(particles, w)   # (Chapter 4)
    return particles

That's it. The entire conceptual content of a particle filter fits on a postcard. The next three chapters fill in the two helpers — measurement_likelihood (Chapter 3) and low_variance_resample (Chapter 4) — and the one safety valve, N_eff gating (Chapter 5). Everything after that is applying this loop to robot localization on a map.

In the particle-filter loop, which step actually incorporates the measurement by changing which poses survive?

Chapter 3: Importance Weighting — Scoring a Guess

The weight is the heart of the correction step, so let's understand exactly where it comes from and how to compute it by hand. The weight answers one question per particle: given that this particle's pose were true, how probable is the measurement we actually got?

Why this is called "importance" weighting. We can't sample directly from the posterior — if we could, we wouldn't need a filter. So we sample from something we can sample from (the predicted belief, after the motion step) and then correct for the mismatch by weighting each sample by how well it explains the data. This is the textbook trick of importance sampling: draw from a convenient distribution, then re-weight to match the one you wanted. The measurement likelihood is precisely the right importance weight.

The Gaussian measurement model

Suppose the robot's sensor reports a scalar measurement z (a range, say), and the true relationship is "the sensor reads the distance plus zero-mean Gaussian noise of standard deviation σ." Then for a particle that predicts an expected measurement [m] (what the sensor should read if that particle were true), the likelihood of the actual reading z is the Gaussian bell evaluated at the gap between them:

w[m] = p(z | x[m]) = (1 / √(2πσ²)) · exp( − (z − ẑ[m])² / (2σ²) )

Define each symbol. z is the actual measurement. [m] is the measurement this particle predicts — computed by running the sensor model forward from the particle's pose (e.g. "if I were at this pose, the wall would be 2.3 m away"). σ is the sensor's noise standard deviation — how much you trust the reading. The exponent contains the squared error (z − ẑ)²: small error → exponent near 0 → weight near its peak; large error → large negative exponent → weight crushed toward zero.

What σ controls. σ is the width of the bell, i.e. how forgiving the weighting is. A small σ (sharp sensor, high trust) makes the weight plunge for any particle whose prediction is even slightly off — only near-perfect particles survive. A large σ (noisy sensor, low trust) makes the bell wide and flat, so even mediocre particles keep meaningful weight. Set σ too small and you'll discover the depletion problem of Chapter 5; too large and the filter barely corrects at all.

Worked example: weighting 5 particles by hand

Five particles predict the expected ranges below. The robot's laser actually reads z = 4.0 m, with sensor noise σ = 1.0 m. We compute each weight, then normalize so they sum to 1.

Because the 1/√(2πσ²) factor is the same for every particle, it cancels during normalization — so for weighting we only need the un-normalized kernel exp(−(z−ẑ)² / 2σ²). With σ = 1, the denominator 2σ² = 2.

Particlepredicted ẑerror (z−ẑ)error²exp(−err²/2)normalized w
[1]4.2−0.20.040.9800.345
[2]3.5+0.50.250.8820.310
[3]5.5−1.52.250.3250.114
[4]4.00.00.001.0000.352
[5]1.0+3.09.000.0110.004

Walk row [4]: it predicted exactly 4.0, error 0, so exp(0) = 1 — the maximum. Row [5] predicted 1.0, off by 3.0, so exp(−9/2) = exp(−4.5) ≈ 0.011 — almost dead. The raw kernels sum to 0.980 + 0.882 + 0.325 + 1.000 + 0.011 = 3.198. Dividing each by 3.198 gives the normalized weights in the last column, which now sum to 1.0.

Common confusion — you MUST normalize. The raw weights are likelihoods, not probabilities; they don't sum to 1 and their absolute scale is meaningless (it depends on σ and on how many measurements you multiplied together). Resampling needs a probability distribution over particles, so you always divide by the sum of weights first. Forgetting to normalize is the single most common particle-filter bug — it silently breaks resampling and your cloud either collapses or never corrects.

Multiple measurements multiply

Robots rarely take one reading. If a laser scan gives K independent range measurements, the joint likelihood is the product of the individual Gaussians (Stanford's conditional-independence assumption):

w[m] = ∏k=1K p( zk | x[m] )

The big-∏ is a product over the K measurements. Why a product? Because the measurements are assumed conditionally independent given the pose (Stanford's standard assumption): once you fix where the robot is, the K beams are independent draws, and independent probabilities multiply. A particle must explain all the beams to score well — one badly-mispredicted beam can torpedo an otherwise-good particle, which is exactly the discriminating power we want.

The underflow trap and the log fix

That product is a numerical landmine. A single laser scan can have hundreds of beams; multiply a few hundred numbers each around 0.1 and you get something like 10−200 — smaller than the smallest float, so it silently rounds to exactly 0. Now every particle's weight is 0, the normalization divides by zero, and the filter produces NaNs that poison the whole cloud. The fix is the same logarithm trick the occupancy grid used in Lesson 1: work in log-likelihoods, where the product becomes a sum:

log w[m] = Σk=1K log p(zk | x[m]) = − Σk (zk − ẑk)² / (2σ²) + const

Sums of moderate negative numbers never underflow. To turn the log-weights back into a probability distribution for resampling, we use the log-sum-exp trick: subtract the maximum log-weight from all of them (this shifts everything up so the largest is 0, which doesn't change the ratios), then exponentiate and normalize. Subtracting the max guarantees the biggest term is exp(0) = 1, so nothing overflows and the dominant particles keep full precision.

From scratch in Python, scoring one particle against one Gaussian measurement looks like this:

python
import numpy as np

def gaussian_weight(z, z_pred, sigma):
    # likelihood of measurement z if this particle predicts z_pred
    err = z - z_pred
    return np.exp(-(err**2) / (2 * sigma**2))   # un-normalized kernel; constant cancels later

z_pred = np.array([4.2, 3.5, 5.5, 4.0, 1.0])
w = gaussian_weight(4.0, z_pred, sigma=1.0)   # raw weights
w = w / w.sum()                                # NORMALIZE -> a probability distribution
print(np.round(w, 3))   # [0.345 0.310 0.114 0.352 0.004]  -- matches the table

And the idiomatic vectorized form for a whole laser scan — sum log-likelihoods over K beams, then normalize in log-space for numerical safety:

python
def scan_weights(z, z_pred, sigma):
    # z, z_pred: (M, K) -- M particles, K beams each
    log_w = -((z - z_pred)**2).sum(axis=1) / (2 * sigma**2)
    log_w -= log_w.max()              # subtract max for stability (shifts, doesn't change ratios)
    w = np.exp(log_w)
    return w / w.sum()                # normalized importance weights

Drag the controls below to feel the weighting. Move particles, change the measurement, and shrink or widen σ. The bar heights are the normalized weights — watch how a tight σ concentrates all the weight on the one closest particle, while a loose σ spreads it out.

Importance Weighting Lab

Six particles sit on a line (drag them). The orange tick is the measurement z (drag it too). Bars below each particle are its normalized weight — the Gaussian likelihood, normalized. Shrink σ to make the sensor "sharp": weight piles onto the nearest particle. Widen it to make the sensor "forgiving."

σ 1.0
Drag particles or the measurement tick.
You compute raw Gaussian weights for your particles and immediately resample without dividing by their sum. What goes wrong?

Chapter 4: Resampling — Survival of the Fittest

Weighting tells us which particles explain the data well, but it leaves us with an awkward object: a cloud where some particles are "heavy" and most are "light." If we kept iterating, almost all the computational effort would go to particles with negligible weight. Resampling fixes this by rebuilding the cloud so that population, not weight, carries the belief. We draw M new particles, each chosen with probability equal to its normalized weight, with replacement — so a heavy particle can be picked many times, and a light one not at all.

Why bother — the deeper reason. Stanford makes a subtle point: resampling matters for more than the measurement correction. Without it, particles slowly drift into low-probability regions and the cloud wastes most of its members representing places the robot almost certainly isn't. Resampling refocuses the cloud onto the regions that matter, so a modest M can stay accurate. It is the mechanism that makes the whole method computationally tractable.

The naive way: multinomial resampling

The obvious method: to draw one particle, pick a uniform random number u in [0, 1) and find which particle's "slice" of the unit interval it lands in — where slice widths equal the normalized weights. Repeat M times, each draw independent. This is multinomial resampling, and it works, but it is noisy: with M independent draws, a particle with weight 0.2 might by bad luck get picked 0 times or 5 times out of M = 10. That sampling noise is itself a source of error — it can wipe out a good hypothesis purely by chance.

The better way: low-variance (systematic) resampling

The standard fix is low-variance resampling (also called systematic resampling). Instead of M independent random draws, we make one random draw and then step through the cloud at evenly-spaced intervals. Picture the normalized weights laid end-to-end as segments on a ruler of total length 1. We place M equally-spaced "comb teeth," spacing 1/M apart, with the first tooth at a random offset r drawn uniformly from [0, 1/M). Each tooth that lands in a particle's segment selects one copy of that particle.

tooth positions:   r,   r + 1/M,   r + 2/M,   …,   r + (M−1)/M

Because the teeth are rigidly spaced, a particle whose weight occupies a fraction f of the ruler is guaranteed to be selected either ⌊fM⌋ or ⌈fM⌉ times — never wildly more or fewer. That eliminates the lottery-style variance of multinomial resampling. It is also faster: one pass, O(M), with a single random number.

Worked example: low-variance resample of 5 particles

Take the normalized weights from Chapter 3's table: 0.345, 0.310, 0.114, 0.352, 0.004. (They sum to 0.125 short of 1 due to rounding; let's use the clean set 0.35, 0.31, 0.11, 0.22, 0.01 which sums to exactly 1.00 for a tidy by-hand pass.) We resample M = 5 particles.

First, the cumulative weights — the right edge of each particle's segment on the ruler:

Particleweightsegment on rulercumulative (right edge)
[1]0.350.00 – 0.350.35
[2]0.310.35 – 0.660.66
[3]0.110.66 – 0.770.77
[4]0.220.77 – 0.990.99
[5]0.010.99 – 1.001.00

Spacing is 1/M = 1/5 = 0.20. Say the single random offset is r = 0.12. The five comb teeth fall at:

Toothposition r + k/5lands in segmentselects
00.120.00–0.35[1]
10.320.00–0.35[1]
20.520.35–0.66[2]
30.720.66–0.77[3]
40.920.77–0.99[4]

The new cloud is { [1], [1], [2], [3], [4] }: particle [1] survives twice (it was the heaviest), [2], [3], [4] survive once each, and particle [5] — weight 0.01 — is culled. Notice the cull is principled: [5]'s segment (0.99–1.00) is so thin no tooth landed in it. With multinomial draws, [5] might have gotten lucky and survived while a heavier particle died; low-variance resampling makes that essentially impossible.

From scratch in Python — the whole low-variance resampler is about eight lines:

python
import numpy as np

def low_variance_resample(particles, w):
    M = len(w)
    positions = (np.arange(M) + np.random.rand()) / M   # M evenly-spaced teeth, 1 random offset
    cumsum = np.cumsum(w)                                # right edges of each segment
    cumsum[-1] = 1.0                                  # guard against float round-off
    idx = np.searchsorted(cumsum, positions)             # which segment each tooth lands in
    return particles[idx]                              # copy the survivors; weights reset to 1/M

The verbose, explicit loop — the same algorithm written so every comb tooth is visible — helps if you've never seen the trick:

python
def low_variance_resample_explicit(particles, w):
    M = len(w)
    new = []
    r = np.random.rand() / M       # first tooth in [0, 1/M)
    c = w[0]                       # running cumulative weight
    i = 0
    for m in range(M):
        U = r + m / M              # position of the m-th tooth
        while U > c:                 # advance to the segment this tooth lands in
            i += 1
            c += w[i]
        new.append(particles[i])   # select that particle
    return np.array(new)

Press Resample below and watch the comb sweep the ruler. The bars are weights; the teeth are the M evenly-spaced sample points; the new cloud appears beneath. Run it repeatedly — notice that heavy particles always get roughly the right number of copies, with almost none of the random swing you'd see from independent draws.

Low-Variance Resampling — the Comb

Top: the weight ruler — each particle owns a segment as wide as its weight. The orange comb has M evenly-spaced teeth (one random offset). Each tooth selects whichever segment it lands in. Bottom: the resampled cloud (copy counts). Press Resample repeatedly — the offset changes but the survivor counts barely budge: that's the low variance.

M 12
Press Resample to sweep the comb.

Flip to multinomial mode and resample several times: the survivor counts jump around far more — sometimes a heavy particle gets unlucky, sometimes a light one gets copied. That extra jitter is "resampling variance," and it's exactly what the low-variance scheme is built to suppress.

Why multinomial is noisier — the same example, drawn independently

Re-run our 5-particle example, but this time the naive way. To draw one particle we pick a single uniform random number u in [0,1) and see which segment it lands in; we repeat that five independent times. Suppose the five independent draws come up u = 0.41, 0.08, 0.55, 0.40, 0.97. Tracing each against the cumulative edges (0.35, 0.66, 0.77, 0.99, 1.00):

Drawrandom ulands in segmentselects
10.410.35–0.66[2]
20.080.00–0.35[1]
30.550.35–0.66[2]
40.400.35–0.66[2]
50.970.77–0.99[4]

This particular run gives { [1], [2], [2], [2], [4] } — particle [2] (weight 0.31) got picked three times, while the heavier particle [1] (weight 0.35) got only one copy and particle [3] (weight 0.11) got zero. With a different set of five random numbers you'd get a different count entirely. The expected count of each particle is M×weight (correct on average), but any single run can swing far from it. Low-variance resampling pins each particle to almost exactly its expected count, run after run — the comb's rigid spacing removes the lottery.

Verified fact — both are unbiased. Both schemes are correct in expectation: average over infinitely many resamples and each particle is copied M×its-weight times. They differ only in variance around that average. Low-variance resampling is the default in nearly every production particle filter (ROS's amcl uses it) precisely because lower variance means a good hypothesis is far less likely to be killed by a run of bad luck.

The two-phase cost of resampling

One subtlety worth internalizing: resampling does the measurement correction and destroys diversity in the same stroke. Copying winners is exactly how the measurement reshapes the belief — but copying means clones, and clones are not independent hypotheses. We've quietly created the problem the next chapter is entirely about. Hold that thought: resampling is necessary, but resampling too eagerly is the road to ruin.

What is the key advantage of low-variance (systematic) resampling over naive multinomial resampling?

Chapter 5: Particle Depletion — When the Cloud Goes Stale

Resampling is a double-edged sword. It refocuses the cloud onto promising regions — but every resample copies survivors and discards the rest, which means the cloud loses diversity. Copy a particle five times and you have five identical dots, not five independent hypotheses. Do this every step and the cloud can collapse onto a tiny number of distinct poses. Stanford calls the failure mode weight collapse; in the literature it's also called particle depletion or sample impoverishment.

The misconception. "More resampling is always better — it focuses the cloud." Wrong, and dangerously so. Resampling destroys information: it throws away the variety of low-weight particles. If you resample when you don't need to, you can wipe out the very hypothesis that turns out to be correct, and the cloud can never recover it because no particle is left there. The cure is to resample only when necessary, and to gently re-inject diversity.

Measuring health: the effective sample size Neff

How do we know when the cloud is getting unhealthy? We compute the effective sample size, a single number that estimates how many of our M particles are "really pulling their weight":

Neff = 1 / Σm=1M ( w[m]

The sum is over the squared normalized weights. Let's read the two extremes to see why this formula is exactly right:

So Neff ranges from 1 (collapsed) to M (perfectly diverse). The standard rule: resample only when Neff drops below a threshold, typically M/2. Above the threshold, the weights are healthy enough that you skip resampling and just carry the weights forward — preserving diversity for free.

Worked example: Neff by hand

Take the clean weights from Chapter 4: 0.35, 0.31, 0.11, 0.22, 0.01 (M = 5). Square each and sum:

Σ w² = 0.35² + 0.31² + 0.11² + 0.22² + 0.01²
= 0.1225 + 0.0961 + 0.0121 + 0.0484 + 0.0001 = 0.2792
Neff = 1 / 0.2792 ≈ 3.58

So out of 5 particles, the cloud is "effectively" about 3.6 useful ones — reasonably healthy (3.58 > M/2 = 2.5, so we'd skip resampling this round). Now compare a near-collapsed cloud, weights 0.92, 0.05, 0.02, 0.01, 0.00: Σw² = 0.8464 + 0.0025 + 0.0004 + 0.0001 = 0.8494, giving Neff ≈ 1.18. That's well below 2.5 — this cloud is dying and we'd resample (and worry).

Two remedies for depletion

1. Adaptive resampling. As above: gate resampling on Neff < M/2. Resample only when the cloud is genuinely concentrated, never on every step.

2. Roughening (jitter). After resampling, add a small amount of random noise to every particle's pose. This breaks up the clones — five identical copies become five slightly-different hypotheses again — restoring diversity at the cost of a little extra spread. It's the particle-filter equivalent of mutation in evolution: copying alone leads to a monoculture; a dash of mutation keeps the population varied.

Roughening has its own Goldilocks dial. Too little jitter and the clones barely separate — depletion is only delayed. Too much and you smear the belief, blurring a tight, confident estimate back into a fog and throwing away hard-won information. A common heuristic scales the roughening noise with the spread of the cloud and shrinks it as M grows (larger clouds need less artificial diversity). In practice the motion model's own process noise often is the roughening: because the predict step adds noise every cycle anyway, a filter that resamples sparingly and always moves between resamples rarely needs explicit roughening at all.

One more worked Neff scenario

Suppose you have M = 1000 particles and, after a measurement, the normalized weights are spread so that effectively a few hundred carry most of the mass. You compute Σw² = 0.004, giving Neff = 1/0.004 = 250. Your threshold is M/2 = 500. Since 250 < 500, you resample — the cloud has concentrated enough that carrying the uneven weights forward would waste particles. After resampling (with a touch of roughening) the weights reset to uniform, Σw² returns to 1000·(1/1000)² = 0.001, and Neff snaps back to the full 1000. The next few steps, if the weights stay even, Neff stays high and you skip resampling — preserving diversity until the cloud genuinely needs another cull.

python
def n_eff(w):
    return 1.0 / np.sum(w**2)          # w must be normalized

# adaptive resampling + roughening, the production pattern
if n_eff(w) < len(w) / 2:               # only when the cloud is concentrated
    particles = low_variance_resample(particles, w)
    particles += np.random.randn(*particles.shape) * rough_sigma   # roughen: re-inject diversity
    w = np.ones(len(w)) / len(w)         # weights reset to uniform

Watch depletion happen, then watch the fixes prevent it. Below, a cloud is repeatedly resampled. With resample every step the diversity (and Neff) crashes toward 1 within a few rounds. Switch on adaptive + roughen and the cloud stays varied indefinitely.

Diversity Collapse & Its Cure

Each press of Step weights the cloud against a fixed measurement and (per the mode) resamples. The number of distinct particles and the live Neff are shown. In always-resample mode the cloud collapses to a few clones; adaptive+roughen keeps it alive.

distinct: 30 / 30 · N_eff: 30.0
Your particle weights are 0.6, 0.3, 0.07, 0.03 (M = 4). Roughly what is Neff, and should you resample if the threshold is M/2 = 2?

Chapter 6: Monte Carlo Localization — The Cloud on a Map

Now we point the particle filter at the actual robot problem: localization — figuring out the robot's pose relative to a known map. Stanford defines the localization problem precisely as "determining the pose of a robot relative to a given map of the environment." When the belief is carried by particles, the algorithm has a name: Monte Carlo Localization (MCL).

The localization taxonomy — which problem are we solving?

Before the algorithm, a map of the territory (Stanford devotes a whole section to it). Localization problems split along several axes, and which filter you want depends on where your problem lands:

There are further axes — static vs. dynamic environments (people moving around), passive vs. active (does the robot steer to localize better?), single- vs. multi-robot. This lesson, like Stanford's course, focuses on the core: local & global, static, passive, single-robot localization. Get that solid and the variations are extensions, not new ideas.

MCL is just the particle-filter loop with one addition: the map m enters both the motion model and the measurement model. The map is "a list of objects in the environment along with their properties" — for us, a set of point landmarks, each at a known global coordinate. Stanford's MCL pseudocode (Algorithm 4) is identical to the particle filter except that prediction and weighting both condition on m:

t[m] ∼ p( xt | ut, xt−1[m], m )   ·   wt[m] = p( zt | x̄t[m], m )

Global localization: the cloud starts everywhere

The thing MCL does that the EKF cannot is global localization — finding the robot when its initial pose is completely unknown. Stanford is explicit: position tracking (known start) suits Gaussian filters; global localization (unknown start) "is more appropriately addressed via non-parametric, multi-hypothesis filters, such as the particle filter."

The recipe is beautiful in its simplicity. For global localization, you initialize the cloud as a uniform distribution: scatter particles across the entire map, every reachable pose equally likely. The initial belief is bel(x0) = 1/|X|, uniform over all states. Then you run the loop: sense, weight, resample, move. Each sensing throws weight onto particles whose pose would have produced the observed landmarks; resampling kills the rest. The cloud, initially a fog over the whole building, condenses step by step onto the true pose.

This is the payoff of carrying a multimodal belief. In a symmetric building the cloud first condenses into several clumps — one per place that matches the sensor readings so far (exactly the multimodal belief Chapter 0 said a Gaussian couldn't hold). As the robot drives and accumulates more distinctive observations, the false clumps stop matching and die off, until one clump remains. A Gaussian filter, forced to pick a single mean, would have committed to the wrong place early and never recovered.

The landmark / ray-cast measurement model

How does a particle predict what it should see? For a range-and-bearing sensor — the workhorse of feature-based localization — a particle at pose (x, y, θ) predicts, for each landmark j at known position (mj,x, mj,y), an expected range and bearing:

j = √( (mj,x−x)² + (mj,y−y)² )  ·  φ̂j = atan2(mj,y−y, mj,x−x) − θ

The range j is just the Euclidean distance from the particle to the landmark. The bearing φ̂j is the angle to the landmark in the robot's own frame — the global angle (the atan2) minus the robot's heading θ. The particle's weight is then the Gaussian likelihood (Chapter 3) of the actual sensor reading given these predictions, multiplied over all visible landmarks. A particle whose pose explains every landmark observation gets a high weight; one that would have seen the landmarks in the wrong direction or at the wrong distance gets crushed.

(For a dense laser in an occupancy map you'd instead ray-cast from the particle to find expected wall distances along each beam — same idea, different geometry. Lesson 12 built the occupancy map; here we use sparse landmarks to keep the arithmetic clean.)

Worked example: predicting and weighting one landmark

Make it concrete with numbers. A landmark sits at global position mj = (10, 4). A particle hypothesizes the robot is at pose (x, y, θ) = (7, 4, 0) — i.e. 3 m west of the landmark, facing east (θ = 0). What range and bearing does this particle predict?

Now the robot's actual sensor reports r = 3.2 m, φ = 0.05 rad, with range noise σr = 0.3 and bearing noise σφ = 0.1. The particle's weight is the product of two Gaussians (range and bearing are independent, so the joint likelihood factors):

w ∝ exp(−(3.2−3.0)²/2·0.3²) · exp(−(0.05−0)²/2·0.1²)
= exp(−0.04/0.18) · exp(−0.0025/0.02) = exp(−0.222) · exp(−0.125) = 0.801 · 0.883 ≈ 0.707

A particle that predicted the landmark at (3.0 m, 0 rad) earns a healthy weight 0.71 because its prediction is close to the actual (3.2 m, 0.05 rad). Now imagine a rival particle on the opposite side of the landmark, hypothesizing pose (13, 4, π): it would predict range 3.0 (same distance!) but bearing 0 in its frame too — and if the world were symmetric, its weight could be just as high. That is how the multimodal belief arises naturally: two genuinely different poses can explain the same reading, and both keep their particles.

Markov localization — the umbrella. Stanford calls the whole family "Markov localization": the Bayes filter applied to pose estimation on a map. It comes in two flavors by belief representation — EKF localization (Gaussian belief, Lesson 13) and Monte Carlo Localization (particle belief, this lesson). The initial belief bel(x0) encodes what you know at startup: a tight Gaussian if the start pose is known (position tracking), or a uniform distribution over the whole map if it isn't (global localization). MCL handles all three regimes — position tracking, global localization, and the kidnapped-robot problem — with the same code; only the initial scatter and the random-injection rate change.

MCL from scratch

Here is the whole MCL loop in Python, landmark version. It scatters a uniform cloud, then runs predict–weight–resample. Note how the three beats from Chapter 2 map onto three blocks, and how the landmark measurement model from above (range to each landmark) is the heart of the weighting:

python
import numpy as np

def predicted_ranges(particles, landmarks):
    # particles: (M,3) [x,y,theta]; landmarks: (L,2). returns (M,L) ranges
    dx = landmarks[:, 0][None, :] - particles[:, 0][:, None]
    dy = landmarks[:, 1][None, :] - particles[:, 1][:, None]
    return np.hypot(dx, dy)

def mcl_step(particles, u, z, landmarks, mot_sig, sen_sig):
    # 1. PREDICT: move each particle by control u + independent noise
    particles = motion_model(particles, u)
    particles += np.random.randn(*particles.shape) * mot_sig
    # 2. WEIGHT: Gaussian likelihood of z given each particle (log-space)
    z_pred = predicted_ranges(particles, landmarks)         # (M,L)
    log_w  = -((z[None, :] - z_pred)**2).sum(axis=1) / (2 * sen_sig**2)
    log_w -= log_w.max()                                    # log-sum-exp stability
    w = np.exp(log_w); w /= w.sum()                         # normalize
    # 3. RESAMPLE only when the cloud is concentrated (Chapter 5)
    if 1.0 / np.sum(w**2) < len(w) / 2:
        particles = low_variance_resample(particles, w)
    return particles

# GLOBAL localization: start the cloud uniform over the whole map
M = 1000
particles = np.column_stack([
    np.random.uniform(0, map_w, M),
    np.random.uniform(0, map_h, M),
    np.random.uniform(0, 2*np.pi, M)])

Every line traces back to a chapter: the uniform initialization is global localization, the Gaussian log_w is Chapter 3's weighting, the Neff gate is Chapter 5, and low_variance_resample is Chapter 4. MCL is not a new algorithm — it's the particle filter you already built, pointed at a map.

Watch global localization converge. Below, a robot lives in a 2-D world with landmarks. Press Run and a cloud that starts as a uniform fog across the whole world condenses, over several sense-weight-resample cycles, onto the true pose (the white robot).

Global Localization — Fog to Fix

The dots are M particles; the white triangle is the (hidden-from-the-filter) true robot. Diamonds are known landmarks. The filter sees only ranges to landmarks. Press Run: the cloud condenses onto the robot. Drag landmark symmetry to make the world more ambiguous and watch the cloud split into multiple clumps before resolving.

Cloud scattered uniformly. Press Run to localize.
What makes Monte Carlo Localization able to solve global localization (unknown initial pose), unlike EKF localization?

Chapter 7: The Kidnapped Robot — Recovering From Total Failure

Here is the hardest localization problem, and the reason it matters. Imagine your robot is happily tracking its pose — cloud nicely converged on one spot — when someone picks it up and sets it down somewhere completely different. Or, less dramatically, it slips on a wet floor, or a wheel encoder dies for a moment, and its pose estimate silently becomes garbage. This is the kidnapped robot problem: the robot must recover from a localization estimate that has gone catastrophically wrong — and, crucially, it must be able to do so even though it has high confidence in a now-false belief.

Why this is the acid test of autonomy. Stanford frames it bluntly: robots aren't literally kidnapped in practice, but "the ability to recover from failure is essential for truly autonomous robots." Any estimator can track when things go right. The mark of a robust one is that it notices its belief no longer explains its sensors, and rebuilds the belief from scratch. The kidnapped-robot test is how we stress that ability.

Why plain MCL fails to recover

Run standard MCL and kidnap the robot, and watch it fail. After convergence, every particle sits in the (now wrong) old location. The robot's new sensor readings match none of them — so every particle gets a tiny weight. But resampling normalizes the weights, so it dutifully resamples from the cluster of equally-terrible particles, keeping the cloud glued to the wrong spot. The fatal flaw: there is no particle at the true new pose, and the filter has no mechanism to create one. A cloud can only ever resample poses it already contains. Once diversity is gone, the truth is unreachable.

Picture it numerically. After convergence, all 1000 particles sit within a few centimeters of the old pose. The robot is kidnapped 8 m away. Now the sensor reads ranges consistent with the new location, but every particle predicts ranges for the old location — errors of meters, so every raw weight is essentially exp(−huge) ≈ 0. Normalization rescues the arithmetic (it divides the near-zeros by their near-zero sum, yielding a uniform-ish distribution over the doomed cluster), so the filter happily resamples 1000 fresh copies of... the same wrong cluster. It will do this forever. The cloud is a sealed terrarium with no door to the truth.

The fix: Augmented MCL (inject random particles)

The standard cure is Augmented MCL: at every step, replace a small fraction of the cloud with brand-new particles scattered randomly across the map. These random injections are the filter's way of always asking, "...or am I somewhere completely different?" Most of the time the random particles get low weights and die immediately — cheap insurance. But after a kidnap, those random particles are the only ones that can land near the true new pose. When one does, it gets a huge weight, resampling multiplies it, and within a few steps the cloud teleports to the correct location.

Trace the recovery. Step 1 after the kidnap: inject, say, 30 random particles (3% of 1000). By luck, one lands within sensing range of the true new pose; its predicted ranges roughly match the real sensor, so its raw weight dwarfs the 970 stranded particles (which all score ≈0) and the other 29 random ones (mostly elsewhere). After normalization that one good particle holds nearly all the mass. Step 2: resampling copies it dozens of times; now there's a small cluster at the true pose. Step 3: that cluster, refined by roughening and more measurements, tightens; the stranded old cluster has been resampled out of existence because it kept scoring 0. Within a handful of steps the cloud has migrated 8 m to the truth — something plain MCL could never do.

How many to inject? Augmented MCL does it adaptively: it tracks a short- and long-term average of the measurement likelihood, and when the short-term average suddenly drops below the long-term one — the tell-tale sign that the cloud has stopped explaining the data — it ramps up the injection rate. When localization is healthy, injection drops to near zero. A simpler-but-effective version just injects a fixed small fraction (say 2–5%) of random particles every step.

The likelihood-ratio detector, by hand

How does the filter know it's lost? The honest signal is the total measurement likelihood — the sum of the raw (un-normalized) weights before normalizing. When the cloud explains the data well, that sum is healthy; when every particle is far from the truth, the sum craters. Augmented MCL keeps two running averages of this quantity: a fast one wfast and a slow one wslow, and injects random particles in proportion to:

pinject = max( 0,   1 − wfast / wslow )

Read it: when things are stable, wfast ≈ wslow, the ratio is ≈1, and pinject ≈ 0 — almost no injection. The instant a kidnap tanks the current likelihood, wfast drops fast while wslow lags behind, so the ratio falls below 1 and pinject jumps — flooding the map with fresh particles exactly when the robot is lost. As recovery succeeds, the likelihood climbs back, the ratio recovers, and injection switches off. It is an automatic "am I lost?" alarm wired straight to the recovery mechanism.

The misconception. "Just use more particles and you won't need injection." More particles delays depletion but does not solve kidnapping: after convergence and a few resamples, even a huge cloud has collapsed to one region, so it still has zero particles at a teleported pose. Random injection is a structural fix — it guarantees a nonzero chance of a particle near any pose, every step — whereas raw M is just a constant factor. You need the mechanism, not just the budget.
python
# Augmented MCL: one step with random-particle injection
def augmented_mcl_step(particles, w, u, z, m, inject_frac=0.03):
    particles = motion_update(particles, u)          # predict
    w = measurement_weights(particles, z, m)         # weight (per Ch.3)
    w = w / w.sum()                                  # normalize
    if n_eff(w) < len(w) / 2:
        particles = low_variance_resample(particles, w)
    n_inject = int(inject_frac * len(particles))     # a few % of the cloud
    particles[:n_inject] = random_poses(n_inject, m) # scatter fresh particles over the map
    return particles, np.ones(len(particles)) / len(particles)

Kidnap the robot yourself. Below, MCL converges, then the Kidnap button teleports the true robot. Toggle Augmented off and the cloud sits stranded at the old pose; toggle it on and watch random particles seed the new location and the cloud recover.

Kidnapping & Recovery

Press Run to converge, then Kidnap to teleport the true robot (white). With Augmented: off, the cloud stays stuck at the abandoned pose. With Augmented: on, sparse random injections (faint dots) find the new pose and the cloud snaps over within a few steps.

Press Run to converge first.
After a kidnap, why does plain MCL (no injection) fail to recover, no matter how long you run it?

Chapter 8: Particle Filter vs Kalman Filter — When to Use Which

You now know two whole families of Bayes filter: the parametric Gaussian filters of Lesson 13 (KF, EKF, UKF) and the non-parametric particle filter of this lesson. They solve the same recursion; they make opposite tradeoffs. Knowing which to reach for is a real engineering decision.

PropertyKalman / EKF / UKF (Lesson 13)Particle filter / MCL (this lesson)
Belief shapeone Gaussian (unimodal)any shape, multimodal
Storesmean μ + covariance Σ (a few numbers)M particles (hundreds–thousands of poses)
Nonlinearitylinearize (EKF) or sigma-points (UKF) — approximatehandled directly — just push samples through
Global localizationcannot (one peak)yes — start uniform, converge
Kidnapped robotcannot recoveryes, with random injection
Costcheap, fixed (matrix ops on a small state)O(M) per step — M can be large
Exactnessexact for linear-Gaussian systemsapproximate; exact only as M→∞

The catch: the curse of dimensionality

If particle filters are so flexible, why doesn't everyone use them for everything? Because of the brutal arithmetic of high-dimensional spaces. To cover a state space with particles densely enough to find the answer, the number of particles you need grows exponentially with the number of state dimensions. This is the curse of dimensionality.

The intuition: to tile a 1-D interval with particles spaced every 0.1 takes 10 particles. A 2-D square at the same resolution takes 10² = 100. A 3-D cube takes 10³ = 1000. A robot pose (x, y, θ) is 3-D — about a thousand particles cover it nicely, exactly Stanford's "M ≈ 1000." But a 7-joint robot arm's configuration is 7-D: at the same resolution, 107 = ten million particles. A full SLAM state with dozens of landmarks is hopeless for a naive particle filter.

State dimensionexampleparticles to tile at 0.1 spacingparticle filter?
1position on a line10trivial
3wheeled-robot pose (x, y, θ)1,000ideal
6drone pose in 3-D (SE(3))1,000,000borderline / clever sampling
77-DOF arm joint angles10,000,000no — use Gaussian
40+full SLAM (robot + 20 landmarks)astronomically manyno — factor it

This is not a tuning problem you can engineer around — it's geometry. The number of particles needed for a fixed resolution is (1/spacing)d, and exponentials in d defeat any constant-factor cleverness. The lesson: count your state dimensions before reaching for a particle filter.

Hybrids: the best of both worlds

The escape hatch, when part of your state is multimodal/nonlinear and part is comfortably Gaussian, is to split them. Rao-Blackwellized particle filters represent only the troublesome low-dimensional part with particles, and attach a small Kalman filter to each particle to handle the Gaussian remainder analytically. FastSLAM is the famous instance: it particle-filters the robot's trajectory (low-D, multimodal) while each particle carries a bank of tiny EKFs — one per landmark — for the map (Gaussian given the trajectory). You pay for particles only on the dimensions that truly need them. That's how a particle method scales to SLAM despite the curse — by refusing to particle-ize the dimensions a Gaussian already handles.

The practical rule of thumb. Particle filters shine in low-dimensional state spaces (a few dimensions) where the belief is genuinely multimodal or the dynamics are nastily nonlinear — robot localization on a known map being the poster child. For high-dimensional states, prefer a Gaussian filter (cheap, scales gracefully) and accept its unimodality, or use clever hybrids (Rao-Blackwellized particle filters factor out the parts you can handle with a Kalman filter, particle-izing only the low-dimensional, multimodal remainder — that's how FastSLAM works).

Compare the two filters side by side on the same nonlinear, multimodal problem. The Gaussian (EKF-style) belief is forced into one ellipse; the particle cloud is free to split. Step through and watch where the Gaussian goes wrong.

Same Problem, Two Filters

A 1-D state with a measurement consistent with two locations (symmetric world). Left track: a single Gaussian (mean+variance) — it smears across both, mean in the empty middle. Right track: a particle cloud — it keeps both peaks. Press Sense to apply the ambiguous measurement, Move to break the symmetry and watch the cloud resolve while the Gaussian flails.

Both filters start uncertain. Press Sense.
Why are particle filters usually a poor choice for very high-dimensional state spaces (e.g. a 7-DOF arm)?

Chapter 9: Showcase — Drive the Robot, Watch the Cloud Breathe

Everything now lives in one simulation. Below is a robot in a known 2-D world with rooms and landmarks. The particle cloud begins as a fog scattered across the whole map (global localization). As you drive the robot with the controls, it senses ranges to nearby landmarks, weights the cloud, and resamples — and the cloud collapses onto the true pose. You can watch the belief breathe: it tightens when the robot sees distinctive landmarks, loosens when it drives blind.

This is the whole lesson, running. Predict (Ch.2) moves every particle by your drive command plus noise. Weight (Ch.3) scores each by the Gaussian landmark likelihood. Resample (Ch.4) with Neff gating (Ch.5) rebuilds the cloud. MCL (Ch.6) does it on the map. And the Kidnap button teleports the robot — with Augmented MCL on (Ch.7), the cloud recovers; off, it stays stranded. Six chapters, one sim.
Interactive Monte Carlo Localization

Drive with the buttons (or arrow keys when focused). The white triangle is the true robot; the purple dots are the cloud; the orange triangle is the cloud's mean estimate; diamonds are landmarks. Sliders set particle count and noise. Press Kidnap to teleport the robot and watch recovery. The estimate-error readout shows how close the cloud's mean is to the truth.

Particles M 400 Motion noise 0.4 Sensor σ 1.2
Cloud scattered. Drive the robot to localize.

Experiment to feel the tradeoffs, exactly the ones the chapters named:

This is Monte Carlo Localization as it actually runs on real robots — ROS's amcl node is this exact algorithm (Adaptive Monte Carlo Localization) on an occupancy-grid map with a laser scanner. You just built its mental model from particles up.

You watched the belief change shape. A fog became several clumps became one tight cluster — and after a kidnap, snapped to a new location. No Gaussian could do that. The freedom to be any shape is the entire reason the particle filter exists, and you saw it earn that freedom in real time.

Chapter 10: Connections & Cheat-Sheet

You now hold the non-parametric half of robot localization. Here is everything compressed, plus where it sits in the series and on the wider site.

Cheat-sheet

Belief: a cloud of M weighted particles 𝒳t = {xt[m]}. Particle density = probability. Any shape, multimodal — the opposite of a single Gaussian.

The loop (every step): 1) Predict — push each particle through the motion model + noise. 2) Weight — w[m] = p(z | x[m]), the measurement likelihood. 3) Resample — draw M new particles ∝ weight.

Gaussian weight: w ∝ exp(−(z−ẑ)² / 2σ²); small σ = sharp/brittle, large σ = forgiving. Always normalize before resampling.

Low-variance resampling: one random offset r ∈ [0,1/M), M evenly-spaced comb teeth on the cumulative-weight ruler. Low variance, O(M).

Health: Neff = 1 / Σ(w[m])², ranges 1 (collapsed) to M (diverse). Resample only when Neff < M/2; roughen to restore diversity.

MCL: the particle filter on a known map. Start uniform for global localization; the cloud condenses onto the true pose.

Kidnapping: recover by injecting a few % random particles every step (Augmented MCL) — the only mechanism that can seed a teleported pose.

PF vs KF: PF = multimodal, nonlinear-friendly, handles global localization & kidnapping, but O(M) and cursed by dimensionality. KF/EKF = cheap, unimodal, scales to high-D.

Where this sits in the series

LessonTopicRelation to particle filters
11SLAM & factor graphsbuilding the map the filter localizes against; FastSLAM is a Rao-Blackwellized particle filter
12Occupancy mappingthe grid map a laser-based MCL ray-casts against
13Kalman, EKF, UKFthe parametric cousin — one Gaussian; this lesson is its multimodal counterpart
14Particle filters & MCL (you are here)non-parametric localization — the cloud
15The frontierwhere classical estimation meets learning: VLAs, world models, neural localization

Related lessons on Engineermaxxing

Go deeper on the pieces we touched:

"What I cannot create, I do not understand." — Richard Feynman.
You can now create a localizer that holds many hypotheses at once and recovers from total failure. Next, Lesson 15: the frontier — where these classical estimators meet learned perception and policy.
You must localize a robot whose initial pose is unknown, in a symmetric building, and it might occasionally be carried to a new room. Which filter, and why?