Nonparametric Filters
Histograms, importance sampling, particle filters, and the art of resampling — how a belief stops being a formula and becomes a population.
Each particle is a concrete instantiation of the state at time t, that is, a hypothesis as to what the true world state may be at time t.
In this chapter
Every filter so far has bought speed by betting on a shape. The Kalman filter bets the posterior is a Gaussian; the EKF bets it stays one after a nonlinearity; the UKF bets a handful of sigma points can carry it through. Those bets pay in microseconds — and they are unpayable in the corridor this book opened with, because a robot that has just seen a door genuinely believes it is at one of three places, and no single ellipse can say that.
This chapter drops the parametric bet entirely. Two representations do it. The first chops the state space into cells and stores one number per cell: exact on finite spaces, honest about its error on continuous ones, and hopelessly expensive past three dimensions. The second lets the samples go where the probability is — a bag of weighted hypotheses that costs per step and does not care about dimension at all. That second one, the particle filter, is the algorithm that still ships in production localization stacks in 2026, and by the end of this chapter you will have derived it, broken it on purpose, and written it in Rust.
Two smaller things get planted here and harvested later. A five-line special case — the binary static-state filter in log odds — grows into occupancy grid mapping in Chapter 13. And the trick of sampling from the wrong distribution on purpose reappears as a control algorithm in Chapter 23.
The problem with one ellipse
Rusty is in the Hallway. Three identical doors, one binary door detector, no idea where it started. The detector fires.
The true posterior is trimodal: three peaks of equal mass, and a lot of nearly-zero corridor between them. Hand that posterior to a Gaussian filter and it must answer with a mean and a covariance. Moment-match it honestly and the mean lands at the centroid of three separated hypotheses — a location the evidence never argued for, and one the robot is at most a third likely to be near — while the covariance inflates until it spans the corridor, reporting an uncertainty that is technically correct and operationally useless. Let the filter track a single mode instead, as an EKF started near one door will, and it becomes confidently wrong two times out of three with nothing in its state to record that a choice was ever made.
This is not a tuning problem. A Gaussian has one mode; the posterior has three; the mismatch is structural. So represent the belief with something that can hold three answers at once.
Watch a few full cycles before reading on. Three things are happening, and each is a section of this chapter.
The cloud is a distribution, not a list of guesses. The dashed purple line is the weighted mean, and during the multimodal phase it wanders across the corridor tracking the centroid of the surviving hypotheses rather than any one of them. That is not a bug in the readout — it is the correct expectation of a genuinely multimodal belief, and it is exactly the number a Gaussian filter would have been forced to report. The particles are the answer; the mean is a lossy summary of them, and in this corridor it is the worst summary available.
Weighting and resampling are different operations, and only one of them destroys anything. During the green phase nothing moves: the ticks change size because their weights changed. During the purple phase nothing changes size: the ticks move, because low-weight hypotheses were deleted and high-weight ones were cloned. Weighting is reversible bookkeeping. Resampling is selection, and selection is irreversible.
Diversity is a finite resource. Press Deprivation preset — 30 particles, a razor-sharp sensor — and re-roll the seed a few times. In a sizeable fraction of runs every particle near Rusty dies in one unlucky weighting, and from then on the filter converges tightly and confidently onto the wrong door and never recovers. It cannot recover: resampling only ever redistributes hypotheses that already exist, and the motion noise is far too small to walk one back.
Building intuition
Both representations in this chapter answer the same question — how do you write down a distribution you cannot write down? — and they answer it in opposite ways.
The histogram filter partitions the state space in advance and stores the probability mass of each region. It commits to a resolution before seeing any data. Everything the belief could ever want to say must be expressible as "this much mass in this box", so a fine grid can say a lot and costs a lot. Nothing is adaptive; the same arithmetic runs whether the robot is lost or perfectly localized.
The particle filter stores nothing in advance. It carries hypotheses, each a complete state vector, each with a weight, and the hypotheses themselves move — they follow the mass rather than waiting for it. Resolution is not a parameter; it is wherever the particles happen to be. That is the whole trade: the grid pays for the region it might need, the particle set pays for the region it currently believes in.
The particle filter's rhythm has three beats, and it is worth naming them before formalizing them.
| Symbol | Meaning | Note |
|---|---|---|
| Push every particle through the motion model, sampling its noise independently. The cloud spreads. Nothing about the measurement has been used. | Cost: M motion samples. | |
| Multiply each particle by the likelihood of the actual measurement at that particle. The cloud does not move; the particles change importance. | Cost: M likelihood evaluations. | |
| Draw a new population of M particles from the old one with probability proportional to weight. Heavy particles are cloned; light ones vanish. | Cost: O(M), one random number. |
Thrun's metaphor for the third beat is a roulette wheel whose arcs are the weights, spun times. It is the right picture and the wrong algorithm, and the next few pages are about why.
The mathematics
Notation this chapter adds
| Symbol | Meaning |
|---|---|
| Cell k of a decomposition of the state space, and its representative point (usually its centroid). | |
| Probability mass the grid belief assigns to cell k at time t. | |
| Log odds of a binary state. Chapter 13 writes the per-cell form as ℓ_{t,i}. | |
| The particle set: M hypotheses with their importance weights. | |
| Target and proposal density in importance sampling. Weights are the ratio f/g. | |
| Effective sample size, 1/Σ(w^{[i]})². The resampling trigger. | |
| KLD bound parameters: tolerated divergence, failure probability, number of occupied bins. |
Chopping the space
Start with the easy half. If the state space is finite — the robot is in one of rooms, the door is open or closed — then Chapter 5's integral is a sum, and the Bayes filter is implementable exactly as written, with no approximation of any kind.
- In
- the previous discrete belief, the control, the measurement
- Out
- {p_{k,t}}
- for all do
- endfor
- return
This is Thrun's Table 4.1, and it is the same two lines as the Bayes filter with replaced by . Line 2 is because in principle every cell can reach every other; in practice a robot moving a bounded distance per step has a banded kernel — only neighbours are reachable — and the cost drops to . Line 3 is always.
The interesting case is a continuous state space chopped into cells, which is where the word "histogram filter" applies and where approximation finally enters.
DerivationWhy the grid version is an approximation, and how big the error is
Decompose into disjoint convex regions. The grid belief stores one number per region, which means it is committed to a piecewise-constant density:
where is the region's volume. Everything else follows from that one commitment.
Step 1 — the region-conditioned likelihood is an average, exactly. Under the piecewise-uniform model,
because the constant cancels top and bottom. No approximation yet: this is the exact likelihood of the region under the model the grid has assumed.
Step 2 — replace the average by a probe. Evaluating that integral for every cell at every step is exactly the computation we were trying to avoid. So substitute the representative point (Thrun's eq. 4.3 takes the centroid):
Step 3 — bound the damage. Expanding about , the first-order term integrates to zero over a centroid-probed region, so the leading error is second order in the cell diameter :
So halving the cell size quarters the modelling error — and, in dimensions, multiplies the cell count by . That exchange rate is the entire argument of the next widget.
Step 4 — note what is not approximated. The recursion itself is still exact for the piecewise-constant model. The histogram filter's error is a representation error, not an inference error, which is what separates it from the EKF: the EKF is exactly right about an approximate distribution and approximately right about the update.
The ladder is where the curse of dimensionality stops being a slogan. A 1-D corridor at 2 cm resolution is 500 cells and a rounding error of a computation. The same resolution over — the state that Chapter 12 actually needs — is cells, and the naive prediction sum over ordered pairs of cells is operations per step. Banding rescues the exponent on the pair count but not on the cell count itself: you still have to store, touch, and normalize numbers. Grids survive in robotics exactly where and the map is static — which is to say, in Chapter 13, and essentially nowhere else.
The binary Bayes filter, and why it deserves its own box
Before leaving grids, one special case earns disproportionate attention: a state that is binary and static. Is this cell occupied? Is that door open? The state does not change, so there is no prediction step at all — the filter is pure evidence accumulation.
Write the belief in log odds:
Log odds run from to , which means no amount of confidence can numerically round to exactly 0 or 1 — the truncation problem that kills naive product-of-probabilities updates.
- In
- previous log odds, measurement
- Out
- ℓ_t
- return
Two features of that line are worth staring at. First, the update is an addition — the entire
filter is +=. Second, the measurement enters through the inverse model , not
the familiar forward model . That inversion is not laziness: when the state is one
bit and the measurement is a 1080p image or a 1080-beam scan, it is far easier to write down "how
likely is occupancy given this reading" than to describe the distribution over all readings that a
wall produces.
DerivationDeriving the log-odds recursion, and where η goes
Step 1 — Bayes rule with the normalizer made explicit. For a static ,
Step 2 — flip the measurement model. Apply Bayes rule once more to so that the inverse model appears:
Step 3 — write the same thing for and divide. This is the trick the whole derivation turns on. The opposite event gives
and dividing the two kills every term that does not depend on the hypothesis — , and the evidence , which is precisely the that a probability-space implementation has to compute:
Step 4 — take logarithms. Products become sums, and with denoting the prior in log odds,
The correction is the step readers most often drop, and there is a clean test for whether it belongs. Feed the filter a completely uninformative reading — one for which — and the increment must be zero. With the correction it is exactly zero. Without it, every uninformative reading would add the prior again, and a robot with a slightly pessimistic occupancy prior would map an empty room as solid just by staring at it. The inverse model is already a posterior; subtracting is what strips the prior back out and leaves only the evidence that reading actually contributed.
Consequence — order does not matter. Telescoping the recursion gives
a sum, and sums commute. For a genuinely static state, the order the evidence arrived in is irrelevant. Exercise 3 asks what breaks the moment the state can change.
Run one of these per grid cell and you have occupancy grid mapping. Chapter 13 is this box, tiled — including the clamping guard, which exists for exactly the reason the widget demonstrates: an unclamped cell that has seen a hundred consistent readings needs a hundred contradicting ones to change its mind, and a door that opens does not get a hundred.
Importance sampling, from first principles
Now the other branch. We want samples from the posterior . We cannot draw them, because we cannot even evaluate without the normalizer. What we can do is draw from something else.
Let be any density we can sample, with the single condition that it covers 's support: . Then for any statistic ,
Multiply and divide by ; recognize an expectation under . That is the entire idea, and it is worth appreciating how much it buys: you may sample from the wrong distribution on purpose, provided you carry a weight that records how wrong it was.
DerivationWhy weighted g-samples converge to f, and what the weights cost
Step 1 — the self-normalized estimator. In practice is known only up to a constant (it is a posterior; the normalizer is the thing we cannot compute). So use unnormalized and divide by their sum:
Numerator and denominator are each ordinary Monte Carlo averages under , converging by the law of large numbers to and respectively, where is the unknown constant. The ratio converges to and the constant never has to be known. Thrun's eq. 4.27 states this for indicator functions , which is the statement that the weighted empirical CDF converges to .
Step 2 — the support condition is not a technicality. If somewhere , no sample ever lands there and the estimator converges — confidently — to the wrong answer. In a particle filter this is particle deprivation, and it is the failure mode of the whole chapter.
Step 3 — the price of a bad proposal. The estimator's variance is governed by the variance of the weights. Write scaled to mean 1; then the classic result (Kong, Liu and Wong) is that the estimator behaves like an unweighted sample of size
Substituting for normalized weights summing to one,
which is the number every practical implementation watches. It is when the weights are uniform and when a single particle owns all the mass. Convergence of the estimator itself is — independent of the dimension of , which is the property that makes particles beat grids the moment . The constant, however, depends on how badly mismatches , and that constant is where all the engineering lives.
The particle filter targets the posterior
Now specialize. Take the target to be the posterior over the whole trajectory, — not because we want trajectories, but because in that space the algebra has no integrals in it.
DerivationThe particle filter as importance sampling on trajectories
Step 1 — factor the target. Applying Bayes rule and the Markov assumption exactly as in Chapter 5, but keeping every state instead of marginalizing:
Note the absence of integral signs. That is the payoff of working in trajectory space.
Step 2 — name the proposal. Assume inductively that the particles at are distributed according to . Line 4 of the algorithm draws , so the density the new particles actually follow is
This is the proposal distribution: the motion model applied to the previous belief. It is the easiest thing in the world to sample and it completely ignores .
Step 3 — take the ratio and watch it collapse. The target from Step 1 has the proposal from Step 2 sitting inside it as a factor, so the division is almost total:
Everything cancels except the measurement likelihood. That is why a particle filter is fifteen lines: choosing the motion model as the proposal makes the importance weight equal to the thing you were going to compute anyway. And never has to be evaluated, because resampling only needs weights up to a constant — normalize after the fact and it disappears.
Step 4 — resample, then marginalize. Drawing with probability proportional to produces particles distributed as proposal weight . And if is distributed according to , then its last component is trivially distributed according to — marginalization of a sample set is deleting a column.
The honest caveats. This argument is exact only as . For finite the self-normalization in Step 1 of the previous derivation introduces a bias of order : the weights are drawn in an -dimensional space but live, after normalization, in an -dimensional one. With the pathology is total — the single weight normalizes to 1 regardless of , and the "filter" ignores its sensor completely. In practice the bias is negligible for ; the variance discussed next is what actually hurts.
- In
- the previous particle set, the control, the measurement
- Out
- 𝒳_t
- for to do
- sample
- endfor
- for to do
- draw with probability
- add to
- endfor
- return
That is Thrun's Table 4.3, unchanged since 1999, and it is still the core of the localizer that ships as the ROS 2 navigation default in 2026. What has changed is lines 7–10, which modern implementations do not run every step and do not run this way.
Resampling, and the variance nobody mentions
Lines 8–9 as written are multinomial resampling: independent draws from the categorical distribution defined by the weights. Thrun's roulette wheel, spun times.
It is unbiased. It is also needlessly noisy, and the noise is not free — it is added directly to the estimator the filter is trying to compute.
DerivationOffspring variance: roulette versus comb
Let be the number of offspring particle receives.
Step 1 — both schemes are unbiased. For multinomial resampling, directly, so . For the comb, the pointers form a lattice of spacing with a uniformly random offset ; the expected number of lattice points falling in an interval of length is regardless of where the interval sits. Same expectation. Unbiasedness is not what distinguishes them.
Step 2 — multinomial variance. From the binomial,
For a particle carrying its fair share , this is : the standard deviation of its offspring count is as large as the count itself. Concretely, . A perfectly healthy particle has a 37% chance of being deleted, every step.
Step 3 — comb variance. Write with integer and . An interval of length contains either or points of a spacing- lattice, and by Step 1 the expectation must be , so
Every offspring count is within one of its expectation, always. When is an integer the count is deterministic. A particle with cannot be deleted at all.
Step 4 — and it is cheaper. Multinomial resampling needs random numbers and, done naively, an search per draw: . The comb needs one random number and one monotone sweep: , with a memory access pattern that is purely sequential. It is faster, quieter, and strictly easier to write.
The intermediate scheme. Stratified resampling draws one uniform per comb interval — — trading the comb's determinism for a little independence. Its variance sits between the two, and Exercise 6 asks you to measure it.
- In
- the weighted particle set
- Out
- a resampled set of M particles with uniform weights
- for to do
- while
- endwhile
- add to
- endfor
- return
Degeneracy, deprivation, and when not to resample
Two failure modes wear similar names and have opposite cures.
Weight degeneracy is what happens if you never resample. The weights are products of likelihoods, and a product of many terms concentrates: after a few dozen steps one particle holds essentially all the mass, , and the other particles are consuming CPU to represent nothing. The cure is to resample.
Particle deprivation is what happens if you resample too much. Every resample deletes hypotheses, and only the motion model's noise creates new ones. Thrun's thought experiment makes it vivid: a robot that is not moving and has no sensors. The state transition is deterministic, so no new states are ever introduced; the resampling step is pure random deletion. With probability one, the population collapses to identical copies of a single state — and to an outside observer the robot appears to have determined its position exactly, despite having no sensors at all. The cure is to resample less.
The standard reconciliation is to make resampling conditional on the diagnostic we already derived:
with the weights carried multiplicatively across steps when no resample happens (, resetting to when one does). The threshold is a convention, not a theorem; Exercise 7 sweeps it and reports where the optimum actually sits for the Hallway.
Three further defences, in increasing order of honesty:
- Use the low-variance sampler. Free, and it removes the single largest source of unnecessary deletion — the 37% figure above.
- More particles. Effective, and expensive, and it treats the symptom: if the proposal is bad, more samples from it are still bad samples.
- Inject fresh hypotheses when the evidence says you are lost. This is Augmented MCL, and Chapter 12 derives it properly, using the average measurement likelihood as the "am I lost?" detector.
KLD-adaptive sample size
There is a better answer than a fixed , and it comes from asking what is for.
During global localization the belief covers the whole map and needs thousands of particles to represent it. Ten seconds later it is a 20 cm blob and thirty particles would do. A fixed must be sized for the worst case and then wastes 99% of its work for the rest of the run. Fox's KLD-sampling makes a function of the belief's spread, measured as the number of histogram bins the samples actually occupy.
DerivationThe KLD bound, via Wilson–Hilferty
Suppose the true posterior is a discrete distribution over bins, and we draw samples from it. Let be the resulting maximum-likelihood estimate — the empirical bin frequencies.
Step 1 — the likelihood ratio statistic. The quantity is the log-likelihood-ratio statistic for the multinomial, and it converges in distribution to as grows. So
Step 2 — impose the confidence. We want that probability to be , so we need to be the quantile of :
Step 3 — approximate the quantile. The Wilson–Hilferty transformation says that is approximately normal with mean and variance . Inverting,
Step 4 — read off . Substituting into Step 2,
Everything on the right is to evaluate, and the only thing that depends on the belief is . Crucially, counts occupied bins, not grid bins — an empty bin costs nothing — so the bound falls automatically as the cloud condenses.
At and (so ), the bound reads:
| occupied bins | required |
|---|---|
| 3 | 93 |
| 10 | 217 |
| 100 | 1,347 |
| 500 | 5,755 |
- In
- number of occupied bins, tolerated KL divergence, failure probability
- Out
- the number of particles that suffices
- if then return
- return
In practice this is not called once per step but inside the resampling loop: draw a particle, check whether it landed in a bin nothing has landed in yet, and if so recompute the bound. Stop as soon as the number drawn meets it. The loop discovers how many particles it needs while it is filling the set. Toggle KLD-adaptive M in the arena at the top of this chapter and watch the population fall from the ceiling to a few dozen as the three clouds become one.
A worked example you can check by hand
Five particles, weights already normalized:
Effective sample size. , so
That is above the threshold, so a well-behaved filter would not resample here. We will do it anyway, to have something to check.
The comb. Take , which is a legal draw from . The pointers are , and we walk them against the cumulative weights:
| cumulative | pointers landing in | offspring | |||
|---|---|---|---|---|---|
| 1 | 0.10 | 0.10 | 0.50 | — | 0 |
| 2 | 0.30 | 0.40 | 1.50 | 0.15, 0.35 | 2 |
| 3 | 0.05 | 0.45 | 0.25 | — | 0 |
| 4 | 0.40 | 0.85 | 2.00 | 0.55, 0.75 | 2 |
| 5 | 0.15 | 1.00 | 0.75 | 0.95 | 1 |
Offspring counts . Particles 1 and 3 die; nobody is drawn more than times; the total is 5, as it must be. Notice particle 4: exactly, and it gets exactly 2 offspring for every legal value of — the comb is deterministic wherever the expectation is a whole number.
The variance gap. Multinomial gives ; the comb gives with :
| multinomial | comb | comb | |
|---|---|---|---|
| 1 | 0.4500 | 0.50 | 0.2500 |
| 2 | 1.0500 | 0.50 | 0.2500 |
| 3 | 0.2375 | 0.25 | 0.1875 |
| 4 | 1.2000 | 0.00 | 0.0000 |
| 5 | 0.6375 | 0.75 | 0.1875 |
| Σ | 3.5750 | 0.8750 |
Same expectation, one quarter the variance. Set the wheel widget above back to its defaults, press Spin ×1000, and the measured bars converge on exactly these two columns.
Implementation in Rust
The library is crates/ch08_particles. It depends on bayes_core (the BayesFilter trait from
Chapter 5) and sim (the Hallway from
Chapter 4), and it is consumed later by localize (Ch. 12),
ch13_occgrid, and ch17_fastslam. Two design decisions drive the whole module.
Weights live in log space. A likelihood is a product over beams; in a 360-beam scan that
product underflows f64 long before it becomes uninteresting. Every weight in this crate is a log
weight, normalized by log-sum-exp, and exponentiated only at the boundary where a resampler needs
actual probabilities.
The proposal and the likelihood are traits, not functions. Chapter 9's motion samplers and Chapter 10's sensor models plug into these two slots unchanged, which is what makes Chapter 12's MCL a fifty-line file rather than a rewrite.
use rand::rngs::SmallRng;
/// A weighted particle set. Weights are **log** weights, always.
///
/// `S` is deliberately unconstrained: the Hallway filter instantiates it with
/// `f64`, Chapter 12 with `Se2`, Chapter 17 with a pose *and* a map. Nothing in
/// this file cares.
pub struct ParticleSet<S> {
pub states: Vec<S>,
pub log_w: Vec<f64>,
}
impl<S> ParticleSet<S> {
/// A fresh set with uniform weights: log(1/M) each.
pub fn uniform(states: Vec<S>) -> Self {
let m = states.len();
Self { log_w: vec![-(m as f64).ln(); m], states }
}
pub fn len(&self) -> usize {
self.states.len()
}
/// Subtract the log-sum-exp so the weights sum to one in probability space.
///
/// Shifting by the maximum first is the whole trick: it makes the largest
/// exponential exactly 1, so nothing overflows and the smallest terms
/// underflow to 0 harmlessly instead of poisoning the sum with NaN.
pub fn normalize(&mut self) {
let max = self.log_w.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if !max.is_finite() {
// Every particle is impossible. Refuse to invent information:
// fall back to ignorance rather than propagate NaN.
let uniform = -(self.len() as f64).ln();
self.log_w.iter_mut().for_each(|l| *l = uniform);
return;
}
let log_z = max + self.log_w.iter().map(|l| (l - max).exp()).sum::<f64>().ln();
self.log_w.iter_mut().for_each(|l| *l -= log_z);
}
/// Weights in probability space. Assumes `normalize` has been called.
pub fn weights(&self) -> Vec<f64> {
self.log_w.iter().map(|l| l.exp()).collect()
}
/// M_eff = 1 / Σ wᵢ². M when uniform, 1 when one particle owns everything.
pub fn ess(&self) -> f64 {
let s: f64 = self.log_w.iter().map(|l| (2.0 * l).exp()).sum();
if s > 0.0 { 1.0 / s } else { 0.0 }
}
}
/// The proposal slot. Chapter 9's `sample_motion_model_odometry` implements it.
pub trait Proposal<S> {
type Control;
fn sample(&self, x: &S, u: &Self::Control, rng: &mut SmallRng) -> S;
}
/// The likelihood slot. Chapter 10's beam and likelihood-field models implement it.
pub trait Likelihood<S> {
type Measurement;
/// Log p(z | x). Log, because a 360-beam product is not representable otherwise.
fn log_lik(&self, z: &Self::Measurement, x: &S) -> f64;
}
/// The occupancy seed: one static binary cell, in log odds. Chapter 13 tiles this.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LogOdds(pub f64);
impl LogOdds {
/// ℓ = log(p / (1 − p)); ℓ = 0 is "no idea", which is p = 0.5.
pub fn from_prob(p: f64) -> Self {
LogOdds((p / (1.0 - p)).ln())
}
pub fn prob(self) -> f64 {
1.0 - 1.0 / (1.0 + self.0.exp())
}
/// `binary_Bayes_filter` — Thrun et al., Table 4.2.
///
/// `inverse_model` is p(x | z), *not* p(z | x): with a one-bit state and a
/// megapixel measurement, the inverse is the only one anyone can write down.
/// Subtracting the prior is what stops repeated readings from double-counting it.
pub fn update(&mut self, inverse_model: f64, prior: LogOdds) {
self.0 += (inverse_model / (1.0 - inverse_model)).ln() - prior.0;
}
/// The guard Chapter 13 inherits: bound how certain a cell may become, so
/// that a world which changes can still change the robot's mind.
pub fn clamp_to(&mut self, bound: f64) {
self.0 = self.0.clamp(-bound, bound);
}
}The resampler is the fifteen-line listing the chapter has been building toward, and it is a direct transcription of Table 4.4.
use rand::{Rng, rngs::SmallRng};
/// `Low_variance_sampler(𝒳ₜ, 𝒲ₜ)` — Thrun et al., Table 4.4.
///
/// One random number and one monotone sweep. Two consequences make this the
/// default everywhere: any particle with wᵢ ≥ 1/M is guaranteed at least one
/// offspring, and the pass is O(M) with a purely sequential access pattern
/// instead of O(M log M) with a binary search per draw.
pub fn low_variance_resample<S: Clone>(
states: &[S],
weights: &[f64],
rng: &mut SmallRng,
) -> Vec<S> {
let m = states.len();
let step = 1.0 / m as f64;
let r: f64 = rng.random_range(0.0..step); // the *only* randomness in here
let mut out = Vec::with_capacity(m);
let mut c = weights[0];
let mut i = 0usize;
for k in 0..m {
let u = r + k as f64 * step;
// The comb tooth walks forward; `i` never goes back, which is why the
// whole sweep is linear rather than M independent searches.
while u > c && i + 1 < m {
i += 1;
c += weights[i];
}
out.push(states[i].clone());
}
out
}
/// The same sweep, reporting offspring counts instead of particles.
///
/// This is what the chapter's worked example asserts on and what the in-page
/// wheel widget draws — the test and the figure share one implementation.
pub fn offspring_counts(weights: &[f64], r: f64) -> Vec<usize> {
let m = weights.len();
let step = 1.0 / m as f64;
let mut counts = vec![0usize; m];
let mut c = weights[0];
let mut i = 0usize;
for k in 0..m {
let u = r + k as f64 * step;
while u > c && i + 1 < m {
i += 1;
c += weights[i];
}
counts[i] += 1;
}
counts
}
/// Multinomial resampling — M independent draws. Kept for the comparison in
/// §"Resampling, and the variance nobody mentions", never used in anger.
pub fn multinomial_resample<S: Clone>(
states: &[S],
weights: &[f64],
rng: &mut SmallRng,
) -> Vec<S> {
let cdf: Vec<f64> = weights
.iter()
.scan(0.0, |acc, w| {
*acc += w;
Some(*acc)
})
.collect();
(0..states.len())
.map(|_| {
let u: f64 = rng.random_range(0.0..*cdf.last().unwrap());
let i = cdf.partition_point(|&c| c < u);
states[i.min(states.len() - 1)].clone()
})
.collect()
}The filter itself is then almost anticlimactic: propagate, add log-likelihoods, normalize, conditionally resample.
use bayes_core::BayesFilter;
use rand::{Rng, SeedableRng, rngs::SmallRng};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use crate::set::{Likelihood, ParticleSet, Proposal};
use crate::resample::low_variance_resample;
pub struct ParticleFilter<S, P: Proposal<S>, L: Likelihood<S>> {
pub set: ParticleSet<S>,
pub proposal: P,
pub likelihood: L,
/// Resample iff M_eff < threshold · M. 0.5 is the usual convention.
pub resample_threshold: f64,
pub rng: SmallRng,
}
impl<S, P, L> BayesFilter for ParticleFilter<S, P, L>
where
S: Clone + Send + Sync,
P: Proposal<S> + Sync,
L: Likelihood<S> + Sync,
{
type Belief = ParticleSet<S>;
type Control = P::Control;
type Measurement = L::Measurement;
fn predict(&mut self, u: &Self::Control) {
let Self { set, proposal, rng, .. } = self;
// Reborrow immutably: a `&mut P` captured by a closure is a unique
// borrow and would not be shareable across rayon's threads.
let proposal: &P = proposal;
// One seed per particle, drawn *serially* from the filter's generator.
// This is what buys reproducibility under rayon: the seeds are a
// function of the filter's seed alone, so thread scheduling cannot
// change the result. `thread_rng()` here would silently destroy it.
let seeds: Vec<u64> = (0..set.len()).map(|_| rng.random()).collect();
#[cfg(not(target_arch = "wasm32"))]
let states = set.states.par_iter_mut().zip(seeds.par_iter());
#[cfg(target_arch = "wasm32")]
let states = set.states.iter_mut().zip(seeds.iter());
states.for_each(|(x, &seed)| {
let mut r = SmallRng::seed_from_u64(seed);
*x = proposal.sample(x, u, &mut r);
});
}
fn correct(&mut self, z: &Self::Measurement) -> f64 {
let Self { set, likelihood, .. } = self;
// w ← w · p(z | x), in logs: the importance weight *is* the likelihood,
// because the proposal was the motion model. See the derivation above.
for (w, x) in set.log_w.iter_mut().zip(set.states.iter()) {
*w += likelihood.log_lik(z, x);
}
// Before normalizing, the sum of weights is the evidence p(z | z₁:ₜ₋₁).
// Chapter 12 uses a running average of it to notice it has been kidnapped.
let evidence = set.log_w.iter().map(|l| l.exp()).sum::<f64>();
set.normalize();
if set.ess() < self.resample_threshold * set.len() as f64 {
let w = set.weights();
set.states = low_variance_resample(&set.states, &w, &mut self.rng);
let uniform = -(set.len() as f64).ln();
set.log_w.iter_mut().for_each(|l| *l = uniform);
}
evidence
}
fn belief(&self) -> &ParticleSet<S> {
&self.set
}
}The #[cfg] pair is not decoration. rayon does not build for wasm32-unknown-unknown without
threads, so the WASM demos on this page compile the same source single-threaded. Because the
per-particle seeds are drawn serially, a run with seed 8 produces byte-identical particles in the
browser and on a 64-core workstation — which is the only reason the numbers in this chapter can be
trusted at all.
Finally, the KLD bound and the tests that pin the chapter's worked example.
use statrs::distribution::{ContinuousCDF, Normal};
/// `KLD_sample_size(k, ε, δ)` — Fox (2003), eq. 7.
///
/// `k_bins` counts the bins the particles *occupy*, not the bins in the grid.
/// An empty bin costs nothing, which is precisely why this number collapses as
/// the belief condenses.
///
/// This returns the raw bound. Clamping it to `[M_min, M_max]` is the caller's
/// job — the draw loop in `filter.rs` owns those limits because they are a
/// budget, not a statistical claim.
pub fn kld_sample_size(k_bins: usize, epsilon: f64, delta: f64) -> usize {
if k_bins <= 1 {
return 0;
}
let z = Normal::standard().inverse_cdf(1.0 - delta);
let k1 = (k_bins - 1) as f64;
let a = 2.0 / (9.0 * k1);
let inner = 1.0 - a + a.sqrt() * z;
((k1 / (2.0 * epsilon)) * inner.powi(3)).ceil() as usize
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resample::offspring_counts;
use approx::assert_relative_eq;
use rand::{Rng, SeedableRng, rngs::SmallRng};
/// The chapter's worked example, exactly: r = 0.15 on the five weights.
#[test]
fn worked_example_offspring_counts() {
let w = [0.10, 0.30, 0.05, 0.40, 0.15];
assert_eq!(offspring_counts(&w, 0.15), vec![0, 2, 0, 2, 1]);
}
#[test]
fn worked_example_effective_sample_size() {
let w = [0.10, 0.30, 0.05, 0.40, 0.15];
let ess = 1.0 / w.iter().map(|x| x * x).sum::<f64>();
assert_relative_eq!(ess, 3.508_771_929_824_56, epsilon = 1e-9);
assert!(ess > 5.0 / 2.0, "above M/2 — a sane filter would not resample here");
}
/// Unbiasedness is a claim about a mean, so test it as one — and tightness
/// is a claim about every single draw, so test that per draw.
#[test]
fn comb_is_unbiased_and_within_one() {
let w = [0.10, 0.30, 0.05, 0.40, 0.15];
let expected: Vec<f64> = w.iter().map(|x| 5.0 * x).collect();
let mut rng = SmallRng::seed_from_u64(8);
let trials = 100_000;
let mut sums = [0.0f64; 5];
for _ in 0..trials {
let r: f64 = rng.random_range(0.0..0.2);
let counts = offspring_counts(&w, r);
for i in 0..5 {
// Never more than one away from M·wᵢ — the comb's whole point.
assert!((counts[i] as f64 - expected[i]).abs() < 1.0);
sums[i] += counts[i] as f64;
}
}
for i in 0..5 {
assert_relative_eq!(sums[i] / trials as f64, expected[i], epsilon = 0.02);
}
}
/// Three rows of the table printed in the KLD section.
#[test]
fn kld_sample_size_matches_the_chapter_table() {
assert_eq!(kld_sample_size(3, 0.05, 0.01), 93);
assert_eq!(kld_sample_size(10, 0.05, 0.01), 217);
assert_eq!(kld_sample_size(100, 0.05, 0.01), 1347);
}
}Putting it together: the Hallway duel
The crate ships one example that settles the chapter's argument empirically:
$ cargo run --release --example hallway_duel -p ch08_particles
seed 8 · hallway 10.0 m · 3 doors · sensor p(hit) = 0.90 · motion σ = 0.14 m
t histogram(K=128) particles(M=1000) M_eff resampled
1 modes=3 MAP=2.02 modes=3 MAP=1.98 612.4 no
2 modes=3 MAP=4.51 modes=3 MAP=4.55 318.7 yes
…
12 modes=1 MAP=7.48 modes=1 MAP=7.51 41.2 yes
|MAP − truth| = 0.04 m 0.01 m
predict cost/step: 16 384 mul-add 1 000 motion samples
weights/step: 128 evals 1 000 evals
wall clock/step: 0.31 ms 0.12 ms (8 threads)
0.74 ms (wasm32, 1 thread)
deprivation sweep, M = 50, 10 seeds: 3 / 10 runs converged to the wrong door
M = 200, 10 seeds: 0 / 10Three things in that output are worth more than the rest of this chapter's prose.
Both filters agree. The histogram filter is the reference — its only approximation is the grid — and the particle filter tracks it to within a cell. That agreement is not decoration; it is how you know a stochastic implementation is correct.
The particle filter is cheaper and gets cheaper faster. 1,000 particles beat 128 cells in a one-dimensional problem, and the gap becomes absurd in three. The histogram's cost is fixed by the grid whether the robot is lost or not; the particle filter's is fixed by , and KLD sampling drops by an order of magnitude the moment the belief collapses to a single mode.
The failure rate is not zero and the honest number is printed. At , three runs in ten converge to the wrong door and stay there. This is particle deprivation, measured. It is the same phenomenon the arena's preset dramatizes, and the reason Chapter 12 adds recovery particles rather than trusting the filter to notice on its own.
Where this goes next: Chapter 12 turns this machinery into Monte Carlo localization by plugging in Chapter 9's samplers and Chapter 10's likelihood fields; Chapter 13 tiles the log-odds box across a map; Chapter 17 shows that a particle can carry a map as well as a pose if you Rao-Blackwellize the rest; Chapter 22 plans directly over particle beliefs; and Chapter 23 runs importance sampling over control sequences instead of states, which is the same derivation with the arrows turned around.
Exercises
- Foundation exerciseDifficulty 2 of 3The effective sample size, derived
Starting from with the weights scaled to mean one, show that for normalized weights summing to one, . Then verify the two extremes analytically: it equals exactly when all weights are , and equals 1 exactly when one particle holds all the mass. Finally compute it for the chapter's five weights and confirm the value 3.5088.
- Foundation exerciseDifficulty 3 of 3Both schemes are unbiased; only one is tight
Prove that for both multinomial and comb resampling. Then prove that the comb's offspring count is always or , and use that to derive where is the fractional part of . Compare with the multinomial's , and state the condition under which the comb's variance is exactly zero. Which of the two facts — same mean, smaller variance — is the reason the comb is preferred?
- Foundation exerciseDifficulty 2 of 3Order does not matter, until it does
Telescope the log-odds recursion to show that depends on the multiset of measurements but not on their order. Now suppose the binary state can flip with probability per step (a door someone opens). Write the prediction step for that case, show that it is not additive in log odds, and explain in one sentence why this is the assumption Chapter 13 makes when it declares the map static — and what it costs when a person walks through the room.
- Conceptual exerciseDifficulty 1 of 3Predict, then spin
In w8.2, press Degenerate to set the weights to with . Before pressing Spin ×1000, predict both offspring histograms. Specifically: under each scheme, what is the probability that particle 1 receives fewer than four offspring, and can particle 1 ever receive zero? Then run it and check. (Hint for the comb: what is , and what does Exercise 2 say about offspring counts when that number is not an integer?)
- Conceptual exerciseDifficulty 2 of 3Find the deprivation cliff
In w8.1, load the deprivation preset and bisect on the slider to find the smallest particle count for which fewer than 2 of 20 re-rolled seeds converge to the wrong door. Now switch on KLD mode and read the steady-state population once the belief has a single mode. The two numbers will disagree — KLD's will be smaller. Explain why, in terms of what each number is protecting against. (One is sizing for representing a converged belief; the other is sizing for surviving the transient.)
- Practical exerciseDifficulty 2 of 3Stratified resampling, and a variance ranking
Implement
stratified_resample— one uniform draw per comb interval, — beside the two schemes inresample.rs. Derive its per-particle offspring variance, then measure all three over trials on the chapter's weights and rank them. Does the measured ranking match your derivation? Add the measurement as a test with a tolerance wide enough to be seed-stable but tight enough to fail if someone swaps the schemes. - Practical exerciseDifficulty 3 of 3When not to resample, measured
Sweep
resample_thresholdfrom 0.0 (never resample) to 1.0 (resample every step) in steps of 0.1. For each value, run the Hallway filter at across 50 seeds and record two numbers: the deprivation rate (fraction of runs whose final MAP is at the wrong door) and the mean over the run. Plot both against the threshold. You should find a U-shaped failure curve — degeneracy on the left, deprivation on the right — and the bottom of that U is the empirical version of the convention. Report where it actually lands for this problem.
References
- Thrun, S., Burgard, W., and Fox, D. (2005) Probabilistic Robotics. MIT Press.link to Probabilistic Robotics (opens in a new tab)
Chapter 4 is this chapter's spine: the discrete Bayes filter (Table 4.1), the binary static-state filter (Table 4.2), the particle filter (Table 4.3), and the low-variance sampler (Table 4.4). The derivations here follow its structure and extend it where post-2005 practice has moved on.
- Gordon, N. J., Salmond, D. J., and Smith, A. F. M. (1993) Novel approach to nonlinear/non-Gaussian Bayesian state estimation. IEE Proceedings F (Radar and Signal Processing) 140(2), 107–113.doi:10.1049/ip-f-2.1993.0015 (opens in a new tab)
The bootstrap filter — the first practical particle filter, and the paper that made resampling standard. Everything in this chapter's second half is a refinement of its five-line algorithm.
- Fox, D. (2003) Adapting the Sample Size in Particle Filters Through KLD-Sampling. The International Journal of Robotics Research 22(12), 985–1003.doi:10.1177/0278364903022012001 (opens in a new tab)
The source of the KLD bound and its Wilson–Hilferty derivation. Not in the 1999 draft this book uses as its baseline; it is 2005-edition material, rebuilt here from the original paper.
- Li, T., Bolić, M., and Djurić, P. M. (2015) Resampling Methods for Particle Filtering: Classification, Implementation, and Strategies. IEEE Signal Processing Magazine 32(3), 70–86.doi:10.1109/MSP.2014.2330626 (opens in a new tab)
The taxonomy that organizes multinomial, stratified, systematic and residual resampling into one family, with the variance comparisons Exercise 6 asks you to reproduce.
- Chopin, N. and Papaspiliopoulos, O. (2020) An Introduction to Sequential Monte Carlo. Springer Series in Statistics.doi:10.1007/978-3-030-47845-2 (opens in a new tab)
The rigorous modern treatment. Read it for the convergence results this chapter states informally, and for the proof that the self-normalized estimator's bias is O(1/M).
- Elvira, V., Míguez, J., and Djurić, P. M. (2021) On the performance of particle filters with adaptive number of particles. Statistics and Computing 31.doi:10.1007/s11222-021-10056-0 (opens in a new tab)
KLD-sampling's modern successor: adapt M from online predictive statistics rather than from bin occupancy, with error bounds that follow the adaptation. The right reference if you find KLD's bin size doing too much of the work.
- Macenski, S., Moore, T., Lu, D. V., Merzlyakov, A., and Ferguson, M. (2023) From the desks of ROS maintainers: A survey of modern & capable mobile robotics algorithms in the robot operating system 2. Robotics and Autonomous Systems 168, 104493.doi:10.1016/j.robot.2023.104493 (opens in a new tab)
Evidence for this chapter's claim that Table 4.3 still ships: AMCL — this particle filter with Chapter 9's and Chapter 10's models — remains the Nav2 default localizer. Written by the people who maintain it.
- Chen, X. and Li, Y. (2025) An overview of differentiable particle filters for data-adaptive sequential Bayesian inference. Foundations of Data Science 7(4), 915–943.doi:10.3934/fods.2023014 (opens in a new tab)
Where resampling goes when you need gradients through it: soft and optimal-transport resamplers that keep the filter differentiable. Chapter 25 picks this up.
