Learning in the Loop
Where machine learning belongs in a probabilistic robot — learned models inside an unchanged Bayes filter, calibration as the admission test, and the filter itself as a differentiable program you can train without ground truth.
As this book is being written, the issue of learning inverse sensor models from data remains relatively unexplored.
In this chapter
Every model in this book so far was written by hand. The beam mixture's four densities, the six parameters of the odometry model, the likelihood field's smoothing kernel — someone chose those functional forms, and someone tuned their constants. This chapter asks the 2026 question honestly: where does machine learning belong in a probabilistic robot?
The answer that organizes everything else is a restriction, not a permission. Learning replaces the boxes inside the Bayes filter; it does not replace the filter. A learned is admissible exactly when it is a calibrated density, because the Bayes filter is a machine that turns stated confidence into decisions: it multiplies likelihoods together, normalizes, and acts on the result. Feed it a model that overstates its own certainty and it will not merely be wrong — it will be confidently wrong, and it will stop listening.
Two things follow, and they are the chapter's two "aha"s. First, a model can be more accurate and still poison a filter, because accuracy and calibration are different properties and only one of them is what the filter consumes. Calibration is measurable, it is repairable, and by the end of the chapter you will have measured and repaired it. Second, the Bayes filter is itself a differentiable program: "tuning and " and "training a network" are the same act — gradient descent on a proper scoring rule, straight through the filter equations. The capstone ships a calibrated learned sensor model as a toggle, and cites the benchmark at the end of this chapter as its justification.
The problem: an accurate model that lies
An engineer wires up Rusty's new LiDAR on a Tuesday afternoon. The datasheet says ranging accuracy cm, so the beam model of Chapter 10 gets m, a small spike at for dropouts, and a thin uniform floor for whatever else happens. No unexpected-obstacle component: the room is empty, the model has no reason to expect anything in front of the wall.
The model is accurate. Its peak sits on the correct range. Ask it for the most likely reading and it is right more often than the model the robot shipped with. Then Monte Carlo localization is switched on and the robot loses itself inside twenty seconds — not by drifting, but by committing, hard, to a pose that is 30 cm wrong and refusing every subsequent scan that disagrees.
Watch one full sweep of the slider before reading on. Three things are happening, and each one has a piece of mathematics later in this chapter.
The reliability curve sags below the diagonal. The diagonal is the promise: of all the observations for which the model claimed "at most this far out", that fraction really should land at most that far out. A curve below the diagonal means every interval the model quotes catches fewer observations than advertised. At cm the expected calibration error is — the model's 50% interval is closer to a 30% interval — and the statistic of its PIT histogram is over 1400 on 14 degrees of freedom. That is not a borderline call; that is a model that is lying in a way you could detect from a hundred readings.
The PIT histogram is a U. Readings keep landing in the tails the model declared empty. The shape of the miscalibration tells you its cause: a U means too narrow, a hump means too wide, a tilt means biased. This is a diagnostic instrument, not a score, and it is the single most useful plot in this chapter.
The filter downstream cares, and it does not care about what you expected. The two right-hand tiles come from a real importance-weighting step over twelve particle clouds in the Apartment. As the claimed width shrinks, the effective sample size collapses — at cm a 150-particle cloud is doing the work of one particle — and the posterior error rises even as the model looks sharper and more confident.
Building intuition
Learning, 1999 style — and what it cannot fix
Learning is not something modern practice grafted onto Thrun, Burgard and Fox. The 1999–2000 draft
already fits the beam model's intrinsic parameters by expectation-maximization
(learn_intrinsic_parameters, Table 6.2, derived in
Chapter 10); it already trains a neural network to invert the
measurement model by sampling triplets from the forward model (§9.3.2); it learns maps with EM, and
it learns POMDP value functions by nearest-neighbor function approximation. The epigraph to this
chapter is that book noticing its own frontier.
So run it. Take 4000 range readings drawn from the true sensor in the Apartment and hand them to
learn_intrinsic_parameters, initialized at the engineer's hand-tuned guess. EM does its job: the
mean log-likelihood climbs from to nats per beam in forty iterations and
moves from m to m, essentially the truth. And yet the fitted model is
still measurably miscalibrated, with an ECE of against the true model's — because
EM inherited a defect from its initialization that no amount of iterating can repair.
The engineer's model set : no unexpected-obstacle component at all. In EM, a component's responsibility is proportional to its weight, so a weight of exactly zero produces zero responsibility, which produces a new weight of exactly zero. A component initialized at zero can never come back. EM did the only thing left to it and inflated from to , smearing a uniform floor across the whole range to absorb readings that came from an exponential concentrated in front of the wall.
That is the first honest lesson about learning in a robot. Maximum likelihood inside a family only searches that family. When the family is wrong — and it always is, a little — the fit will spend its freedom compensating in whatever direction the parameterization allows, and the result can be an excellent likelihood attached to a badly shaped uncertainty.
The question is not "how accurate", it is "how honest"
Point accuracy asks: is the model's best guess close to the truth? Calibration asks something the filter cares about far more: when the model says 70%, does it happen 70% of the time?
Gneiting, Balabdaoui and Raftery gave the field the sentence that settles the priority. The goal of probabilistic forecasting, they argue, is to maximize the sharpness of the predictive distributions subject to calibration — sharpness being how concentrated the forecast is, a property of the forecast alone, and calibration being the statistical consistency between forecasts and what actually happens, a joint property of the forecast and the world. Sharpness is what you want. Calibration is what you are allowed. Take them in the wrong order and you get exactly the widget above: a model that wins on confidence and loses on reality.
Overconfidence is a filter-killer, not an aesthetic complaint
Here is the mechanism, before the algebra. A particle filter weights each particle by . Sharpen the likelihood and you sharpen the weights: one particle takes almost all the mass, the effective sample size collapses, and resampling clones that single particle four hundred times. The cloud now has one hypothesis. If the truth is not underneath it, there is nothing left to re-weight and no recovery is possible — the particle deprivation of Chapter 12, arriving not from bad luck but from an overconfident model.
The knob in that widget, , is the honest way to talk about this. It does not change what the model says, only how loudly: . And it is not an artificial device — Chapter 10 showed that treating correlated beams as independent multiplies every log-odds by roughly , which is exactly a tempering exponent greater than one that nobody chose and nobody wrote down.
The mathematics
Notation for this chapter
| Symbol | Meaning |
|---|---|
| Learnable parameters: network weights, log r and log q, a temperature — anything gradient descent may move. | |
| A learned observation model. Must be a proper density in z_t: non-negative and integrating to one. | |
| Its predictive CDF. v = F_θ(z \mid x) is the probability integral transform (PIT) of an observation. | |
| A scoring rule: the penalty a forecast density q pays when z materializes. Negatively oriented — lower is better. | |
| Expected calibration error: mean absolute distance between claimed and observed coverage, over B = 15 bins. | |
| Likelihood tempering exponent. Deliberately not γ, which is reserved for the discount factor. | |
| Soft-resampling mixture weight. Deliberately not α, which is reserved for motion-model noise. | |
| Training loss. The negative log score unless stated otherwise. | |
| Spread (sample standard deviation) of the per-particle log-likelihoods within a cloud. |
One bridging note on . Chapter 10 writes the tempered likelihood as , with counting how many raw beams it takes to make one independent beam — a discount. This chapter writes , so that a single slider can pass continuously through from underconfident to overconfident. The two are reciprocals of each other: Chapter 10's is this chapter's . Nothing else changes.
Proper scoring rules
You cannot train a model without a loss, and not every loss is safe. A scoring rule that a forecaster can improve by misreporting its beliefs is a machine for producing liars.
Strict propriety is what makes the negative log-likelihood the default training loss for anything that will live inside a Bayes filter: it is the loss whose unique optimum is the truth, so a model that minimizes it has no incentive left to shade its uncertainty in either direction.
DerivationF1 — Why the log score is strictly proper
Step 1 — write the expected score. For a forecast evaluated on data drawn from the true density ,
Step 2 — add and subtract the entropy. Insert :
Step 3 — Gibbs' inequality. , by Jensen's inequality applied to the convex function :
Step 4 — the equality condition. is strictly convex, so Jensen holds with equality only if is constant -almost everywhere; since both integrate to one, that constant is one and almost everywhere. Hence the expected log score is uniquely minimized at the truth, with minimum value the entropy — the irreducible part, which no model can score below.
Two caveats worth stating. For continuous densities the score is a differential log score, so its absolute value carries an arbitrary unit convention (change metres to centimetres and every NLL in this chapter shifts by ); only differences between models on the same data are meaningful. And the argument assumes wherever — a model that assigns zero density to something that happens takes an infinite penalty, which is the correct behavior and the reason every implementation in this book floors its densities before taking a logarithm.
Calibration, PIT, and ECE
The PIT is the whole diagnostic in one line, and it is worth seeing why it works. If really is drawn from the density the model claims, then is uniform — the classic inverse-transform argument run backwards. So any structure in the histogram of PIT values is evidence of misspecification, and its shape names the defect:
| Symbol | Meaning |
|---|---|
| Calibrated. Nothing to fix — improve it by making it sharper, if you can do so without bending this. | |
| Forecast too narrow (overconfident). Observations keep landing in tails the model called empty. | |
| Forecast too wide (underconfident). The model hedges; it is honest but wasteful. | |
| Biased. The predicted mean is systematically off — a modelling error, not an uncertainty error. |
One technical repair matters for range sensors. The beam model has an atom: a lump of probability mass sitting exactly on the maximum range. For a discontinuous CDF the plain PIT is not uniform even for a perfect model — every dropout beam would pile up at . The standard fix is the randomized PIT, which spreads each atom uniformly across the jump it makes:
For a continuous forecast and this reduces to the plain PIT, so it costs nothing to always use it. Skip it and you will spend an afternoon "fixing" the calibration of a model whose only sin was reporting dropouts honestly.
- In
- a model with a predictive CDF, an evaluation set D = {(x_i, z_i)}, bin count B
- Out
- reliability bins, ECE, mean NLL, PIT histogram and its χ²
- for all do
- ,
- endfor
- for to do
- endfor
- return bins, , , histogram of ,
The two numbers this returns are not redundant, and it is important to hold both: the mean NLL measures accuracy and sharpness together, and the ECE measures honesty alone. A model can win one and lose the other. The benchmark at the end of this chapter is a table in which exactly that happens.
Calibration and sharpness are different axes
DerivationF2 — Murphy's decomposition: reliability, resolution, uncertainty
The clean version of the calibration/sharpness split lives on the Brier score, the squared-error scoring rule for a binary outcome: with the forecast and the outcome. It is strictly proper, and it decomposes exactly.
Step 1 — bin the forecasts. Group the forecasts into bins, bin holding of them. Write for the mean forecast in bin , for the observed frequency in bin , and for the overall base rate.
Step 2 — replace each forecast by its bin mean. Within a bin, (this is the estimator's only approximation, and the source of its binning bias). Then
Step 3 — add and subtract , then . Inside bin ,
and the cross term vanishes on summing over the bin, because by the definition of .
Step 4 — expand the last term the same way, now around the base rate: , and .
Step 5 — collect.
Reliability is exactly what the reliability diagram plots — squared distance from the diagonal, lower is better. Resolution is sharpness that pays: how far the model's conditional frequencies move away from the base rate, higher is better. Uncertainty is the problem's own difficulty and no model changes it.
The moral is the one Gneiting states as a principle. You may buy a better score with sharpness, but only while reliability stays near zero; a model that buys score by shrinking its intervals past what the data supports pays for it in reliability, and — this is the part the score itself hides — pays again downstream, where a Bayes filter compounds the overstatement across every update.
The tempered update, and what it costs
DerivationF3 — Overconfidence poisons the filter, in two exact statements
Step 1 — tempering a Gaussian scales its precision. For , raising to the power gives
which is the same Gaussian in with replaced by . So in the information form of Chapter 6, the update adds
Step 2 — read what that means. Claimed information grows linearly in while the measurement carries exactly as much information as it did before. Ten correlated beams treated as independent produce a covariance times too tight around an estimate that has not improved at all. Nothing in the filter's own output reveals this: the residuals shrink together with the covariance, which is precisely why Chapter 11 grades filters by NEES against ground truth and NIS against the innovations.
Step 3 — the particle-filter version is worse, because it is exponential. Let be the per-particle log-likelihood within a cloud, with mean and spread . Tempered weights are , and
Step 4 — evaluate with the Gaussian moment generating function. If then , so the numerator is , the denominator is , and
The mean cancels — only the spread of the log-likelihoods matters — and enters squared, inside an exponential. With a spread of nats across the cloud, leaves 1.8% of the population alive and leaves of it. This is the closed form the Tempering Lab's "predicted ESS" tile evaluates live, and the library's self-checks confirm it to within across .
Step 5 — connect it to the failure you can see. An overconfident model widens just as surely as raising does, because both scale the log-likelihood. Once approaches one, resampling clones a single hypothesis and the filter's diversity is gone; the kidnapped-robot recovery of Chapter 12 becomes impossible, not unlikely.
The repair is one scalar, fitted on data the model has never seen.
- In
- a trained model, a held-out validation set V
- Out
- the scale s minimizing validation loss
- define
- where is the model with its scale parameter multiplied by
- by golden-section search on
- return
Three properties make this the first thing to try and the last thing to skip. It has one parameter, so it cannot overfit a validation set of any reasonable size. It is fitted on held-out data, so it corrects the model rather than memorizing the training set. And for a density it does not reorder hypotheses at all — raising a Gaussian likelihood to the power is identical to scaling its width by , and neither operation moves the mode. It buys calibration without costing accuracy. That is why Guo et al.'s temperature scaling took over the classification literature, and it transposes to a range density without modification.
Learning the model from the model you already have
Thrun et al.'s §9.3.2 recipe is still the right one, and it is worth stating in its modern form because it explains where a learned sensor model's training data comes from when the robot has no labels: sample the forward model, fit the inverse. You have a simulator, a map, and a physical model of the sensor; that is a generator of pairs at any volume you like.
- In
- a forward simulator, a map, a model family p̂_θ, sample count N
- Out
- θ fitted by NLL descent, plus a fitted temperature
- for to do
- sample a pose over the free space of
- // the noise-free expected range
- // the forward model, §9.3.2
- endfor
- split into
- // strictly proper, by F1
- return , , and
Line 5 is where the circularity lives, and this chapter will not pretend otherwise. A model trained on samples from a simulator and then evaluated against samples from the same simulator is calibrated with respect to the simulator. Everything measured in this chapter's widgets is of that kind. The only honest transfer test is the one the 1999 draft already describes in §9.3.4 — localize a real robot in a known environment and assemble training pairs from its recorded measurements — and it is still the expensive, correct answer in 2026.
The filter is a differentiable program
The reason to care is not that gradient descent is fashionable. It is that hand-tuning and is the single most common and least principled activity in applied estimation, and there is a loss function that does it properly — one that does not require ground truth.
DerivationF4 — Evidence via innovations: training without ground truth
Step 1 — chain rule over time. The joint likelihood of the whole measurement sequence factorizes with no approximation:
Step 2 — recognize each factor. is the one-step-ahead predictive density of the measurement — precisely the normalizer that Chapter 5 named the evidence and threw away.
Step 3 — in the linear-Gaussian case it is available in closed form. The filter has already computed the predicted state ; pushing it through the measurement model gives
Step 4 — sum the logs. With the innovation ,
Every quantity in that expression is something the filter already computes, and not one of them is a state. The loss is a function of measurements the robot actually took and predictions it actually made. Minimizing it is exactly prediction-error identification in the sense of classical system identification, and it is the reason a robot can learn its own noise parameters from an afternoon of driving with no motion-capture rig in sight.
Two failure modes are worth naming. The loss is invariant to anything that leaves the predictive distribution unchanged, so parameters that only ever appear inside as a sum are not separately identifiable from a single measurement (see the worked example below). And a model that is structurally wrong can still minimize this loss by inflating until nothing is surprising — evidence training will happily hand you an honest, useless filter, which is a much better failure than a dishonest, useless one.
DerivationF5 — The gradient of the Kalman filter
Take the scalar filter of Chapter 6, with , , and , . Parameterize by : the logs are not cosmetic, they enforce positivity through the parameterization, so gradient descent can never propose a negative variance.
Step 1 — the filter, as a composed map. Five equations, all smooth in :
Step 2 — differentiate the prediction. Writing for and using :
Step 3 — differentiate the innovation and the gain. and , hence by the quotient rule
This is the scalar instance of the matrix identity , which is where the gradient of any Kalman gain comes from.
Step 4 — differentiate the update, and notice the recursion.
The right-hand sides depend on : the sensitivities are carried forward alongside the state, one extra pair of numbers per parameter. With two parameters, this forward-mode recursion is cheaper than reverse-mode backpropagation through time and gives bit-identical gradients; with a network in the loop, reverse mode wins and an autodiff framework does exactly this bookkeeping for you.
Step 5 — differentiate the loss. For the evidence loss of F4, each term contributes
Read the first bracket: when the squared innovation exceeds the claimed innovation variance (, the filter was surprised), the coefficient is negative and descent increases . When the filter is consistently unsurprised, it shrinks. Gradient descent on the evidence is a feedback controller whose set point is — the NIS consistency criterion of Chapter 11, arrived at from an entirely different direction.
A worked example you can check by hand
Take the shortest possible trajectory: one step, , , a known initial state (, ), and unit noise parameters , so . The robot reads .
Run the filter:
The evidence loss is one Gaussian term:
Now the gradients. Since , the prediction does not depend on at all, so and only the first bracket of F5 Step 5 survives. With and :
Both gradients are negative, so descent grows both variances: the filter was surprised — a innovation against a claimed is a event whose square is twice the claimed variance — and the honest response is to admit more uncertainty.
And the two gradients are equal, which is the lesson hiding in the arithmetic. After one step from a known initial state, the loss depends on only through the sum : no single measurement can tell process noise from measurement noise. What separates them is time. Process noise accumulates across steps and measurement noise does not, so the autocorrelation of the innovation sequence carries the information that identifies the split — which is why the trainer below needs a few hundred steps of trajectory, not a few.
- In
- a logged trajectory (u_{1:T}, z_{1:T}), a loss ∈ {evidence, state-NLL}, learning rate, epochs
- Out
- θ = (log r, log q)
- initialize
- for epoch to epochs do
- , , ,
- for to do
- run one filter step, propagating by F5
- // evidence: no ground truth
- // F5, Step 5
- endfor
- endfor
- return
Two things in that widget deserve more than a glance. The trainer starts with a filter that believes the cart is wildly unpredictable and the sensor is nearly perfect — the classic tuning mistake, in both directions at once — and the purple band is far too tight for it. As the loss falls, the band breathes out to a width that contains the truth about 95% of the time, and the mean NEES walks to
- Nobody told it what 1 was. The evidence loss never saw the gray dashed line.
And the "cheat" toggle is there to make the alternative concrete. With ground truth you can minimize the state NLL directly, which is what a differentiable filter trained in simulation usually does. It converges faster and to a slightly different place, and it is unavailable on any robot that is not in a motion-capture room. When someone reports a learned filter that was trained on state supervision and deployed without it, this toggle is the question to ask them.
Where the gradient dies
Everything above differentiates cleanly because the Kalman filter is a chain of smooth functions. Particle filters are not: they contain a categorical draw, and a categorical draw has no useful derivative.
DerivationF6 — Soft resampling carries gradient, and λ is exactly what it costs
Step 1 — why resampling has no gradient. Multinomial or low-variance resampling maps weights to a multiset of surviving indices, then sets every surviving weight to . Nudge one weight by and, almost surely, the same indices survive with the same new weights: the map is piecewise constant, so its derivative is zero almost everywhere and undefined on a measure-zero set of ties. A gradient travelling backwards from the loss reaches the first resample node and stops.
Step 2 — why the reparameterization trick does not save you. For a Gaussian you can write and push the gradient through the sample. A categorical distribution has no such smooth reparameterization: the outcome is an index, and indices do not interpolate.
Step 3 — move the dependence into the weight instead. Draw indices from a mixture of the weights and the uniform,
and correct with the importance ratio . Unbiasedness is one line: for any test function ,
which is the pre-resampling estimator, exactly, for every .
Step 4 — measure the gradient that survives. Differentiating the log of with respect to the log of ,
and averaging under the proposal that actually draws the indices, the cancels:
So is not merely related to how much gradient crosses a resample node — in expectation it is the fraction that dies, whatever the weights are. At (the classical resampler) nothing survives; at everything does, and the "resampler" has become plain importance sampling that never concentrates its particles at all. A filter unrolled over resamples transmits : gradients from the distant past are gone regardless, which is one honest reason differentiable particle filters are trained on short windows.
- In
- a weighted particle set, mixture weight λ ∈ [0,1], a seeded RNG
- Out
- M particles with importance weights w′ that depend smoothly on w
- normalize so that
- for all
- , ,
- for to do
- while do ; endwhile
- add to with weight
- endfor
- return
Learning to act: distributions over actions
The chapter's thesis has a control-side sibling. Chapter 23 already samples action sequences and weights them; a diffusion policy learns the sampler.
The argument for it is the argument for particle filters, transposed. Suppose an obstacle can be passed on the left or on the right, and the demonstrations show both. A policy trained to regress the mean action drives straight into the obstacle — the average of two good plans is not a plan. Learning a sampler preserves the two modes, and the robot commits to one of them.
That is genuinely in the spirit of this book: represent what you cannot summarize. But note the asymmetry, because it is the reason this section is short and has no widget. A diffusion policy is a sampler, not a density: you can draw actions from it, but you cannot cheaply evaluate the probability of an action, so it does not compose with Bayes' rule the way a calibrated sensor model does. Everything else in this chapter can be multiplied into a belief. This cannot. The full derivation of the diffusion ELBO is out of scope here, and the honest reference is Chi et al., whose IJRR version is the standard treatment.
Implementation in Rust
Three modules carry the chapter: the instrument, the learned model, and the trainer.
The instrument
Calibration is model-agnostic on purpose. Grading consumes PIT values and log scores, never a model, so the same code grades a hand-tuned mixture, a network, and a Kalman filter's innovation sequence.
use rand::Rng;
/// Anything that can serve the Bayes filter as p(z | x, m).
///
/// Chapter 10's `BeamModel` and `LikelihoodField` implement it, and so does
/// this chapter's `LearnedBeamModel`. The second and third methods are what
/// make a model *auditable*: a density you cannot integrate is a density you
/// cannot grade, and an atom you do not declare will masquerade as a defect.
pub trait ObservationModel {
/// log p(z | x, m) for a whole scan — what the filter multiplies in.
fn log_likelihood(&self, z: &Scan, x: &Se2, m: &OccGrid) -> f64;
/// Per-beam predictive CDF F(z_k | z*), needed for the PIT.
fn beam_cdf(&self, z_k: f64, z_star: f64) -> f64;
/// Mass of the atom at `z_k`, if the model has one. The beam mixture's
/// max-range spike is an atom; a likelihood field has none.
fn beam_atom(&self, _z_k: f64) -> f64 {
0.0
}
}
pub const N_BINS: usize = 15;
#[derive(Debug, Clone, Copy)]
pub struct ReliabilityBin {
pub nominal: f64,
pub empirical: f64,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct CalibrationReport {
pub bins: [ReliabilityBin; N_BINS],
/// D25.3, in the quantile-binned form: mean |observed − claimed|.
pub ece: f64,
/// Largest gap from the diagonal — the Kolmogorov statistic.
pub max_calibration_error: f64,
/// Mean negative log score, nats per beam. Accuracy *and* sharpness.
pub mean_nll: f64,
/// Pearson χ² of the PIT histogram against uniform, N_BINS − 1 d.o.f.
pub pit_chi2: f64,
}
/// Randomized PIT: spread each atom uniformly across the jump it makes, so a
/// dropout beam does not pile up at v = 1 and look like miscalibration.
pub fn randomized_pit<M: ObservationModel>(
model: &M,
z_k: f64,
z_star: f64,
rng: &mut impl Rng,
) -> f64 {
let upper = model.beam_cdf(z_k, z_star);
let lower = upper - model.beam_atom(z_k);
(lower + rng.random::<f64>() * (upper - lower)).clamp(0.0, 1.0)
}
pub fn calibrate<M: ObservationModel>(
model: &M,
eval: &[(f64, f64)], // (z*, z) pairs from a held-out set
log_density: impl Fn(f64, f64) -> f64,
rng: &mut impl Rng,
) -> CalibrationReport {
let mut pit: Vec<f64> = eval
.iter()
.map(|&(z_star, z)| randomized_pit(model, z, z_star, rng))
.collect();
let mean_nll = -eval
.iter()
.map(|&(z_star, z)| log_density(z, z_star))
.sum::<f64>()
/ eval.len() as f64;
// Sorting turns every empirical-CDF query into an O(1) index lookup.
pit.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = pit.len() as f64;
let mut bins = [ReliabilityBin { nominal: 0.0, empirical: 0.0, count: 0 }; N_BINS];
let (mut ece, mut worst) = (0.0, 0.0_f64);
for b in 0..N_BINS {
let nominal = (b + 1) as f64 / N_BINS as f64;
let count = pit.partition_point(|&v| v <= nominal);
let empirical = count as f64 / n;
let gap = (empirical - nominal).abs();
ece += gap / N_BINS as f64;
worst = worst.max(gap);
bins[b] = ReliabilityBin { nominal, empirical, count };
}
// χ² of the histogram (not the CDF) against a uniform expectation.
let expected = n / N_BINS as f64;
let mut counts = [0usize; N_BINS];
for &v in &pit {
counts[((v * N_BINS as f64) as usize).min(N_BINS - 1)] += 1;
}
let pit_chi2 = counts
.iter()
.map(|&c| (c as f64 - expected).powi(2) / expected)
.sum();
CalibrationReport { bins, ece, max_calibration_error: worst, mean_nll, pit_chi2 }
}The learned model
The hand-tuned beam model claims one for every range, which is false on every real LiDAR: a beam returning from 7 m is not the beam returning from 70 cm. The learned model makes the mixture parameters functions of the expected range — a two-layer MLP that emits four mixture logits and a log-width, trained by the NLL that F1 licensed.
The design decision worth recording: training happens natively with candle; the browser never
trains a net. Inference is a dependency-free forward pass — two matrix multiplies and a GELU — so the
same weights run in the WASM widget with no framework attached.
use candle_core::{DType, Device, Result, Tensor};
use candle_nn::{loss, AdamW, Linear, Module, Optimizer, ParamsAdamW, VarBuilder, VarMap};
/// Learned per-beam range density: MLP(z*) -> (mixture logits, log σ_hit).
///
/// The *shape* of the mixture is kept from Chapter 10 — hit, short, max, rand —
/// because that structure is physics and the network has nothing better to
/// offer. What is learned is how the four weights and the hit width vary with
/// range, which is exactly the part the hand-tuned model got wrong.
pub struct BeamNet {
l1: Linear,
l2: Linear,
max_range: f64,
}
impl BeamNet {
pub fn new(vb: VarBuilder, hidden: usize) -> Result<Self> {
Ok(Self {
l1: candle_nn::linear(2, hidden, vb.pp("l1"))?,
l2: candle_nn::linear(hidden, 5, vb.pp("l2"))?,
max_range: 8.0,
})
}
/// Features are deliberately dimensionless: a network trained on metres and
/// deployed on a sensor with a different range would otherwise extrapolate.
fn features(&self, z_star: &Tensor) -> Result<Tensor> {
let u = (z_star / self.max_range)?;
Tensor::cat(&[&u, &u.sqrt()?], 1)
}
/// Negative log-likelihood of a batch under the predicted mixture. This is
/// the strictly proper log score of D25.1 — nothing else is trained.
pub fn nll(&self, z_star: &Tensor, z: &Tensor) -> Result<Tensor> {
let out = self.l2.forward(&self.l1.forward(&self.features(z_star)?)?.gelu()?)?;
let logits = out.narrow(1, 0, 4)?;
let log_w = candle_nn::ops::log_softmax(&logits, 1)?; // weights on the simplex
let sigma = (out.narrow(1, 4, 1)?.exp()? + 1e-3)?; // positivity by construction
// log of each component density, then log-sum-exp against the weights.
let comps = Tensor::cat(
&[
self.log_hit(z, z_star, &sigma)?,
self.log_short(z, z_star)?,
self.log_max(z)?,
self.log_rand(z)?,
],
1,
)?;
let log_mix = (comps + log_w)?.log_sum_exp(1)?;
log_mix.neg()?.mean_all()
}
}
/// One epoch of `learn_observation_model`, lines 8–9.
pub fn train(pairs: &[(f64, f64)], epochs: usize, dev: &Device) -> Result<VarMap> {
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, DType::F64, dev);
let net = BeamNet::new(vb, 64)?;
let mut opt = AdamW::new(varmap.all_vars(), ParamsAdamW { lr: 1e-3, ..Default::default() })?;
let z_star = Tensor::from_iter(pairs.iter().map(|p| p.0), dev)?.reshape((pairs.len(), 1))?;
let z = Tensor::from_iter(pairs.iter().map(|p| p.1), dev)?.reshape((pairs.len(), 1))?;
for _ in 0..epochs {
let loss = net.nll(&z_star, &z)?;
opt.backward_step(&loss)?;
}
Ok(varmap)
}The trainer
DiffKf1d is the whole of F5, transcribed. It is about forty lines, it has no dependencies, and it
is the engine the trainer widget runs in the browser — the derivation is the source code.
/// A scalar Kalman filter with θ = (log r, log q) exposed and differentiated.
///
/// Logs, not variances, so that descent cannot propose a negative variance:
/// the constraint lives in the parameterization rather than in a clamp.
pub struct DiffKf1d {
pub log_r: f64,
pub log_q: f64,
}
pub enum DkfLoss {
/// F4. Scored on one-step-ahead predictions of measurements the robot took.
Evidence,
/// The supervised alternative: available in simulation and motion capture,
/// and nowhere else.
StateNll,
}
impl DiffKf1d {
/// Loss and its exact gradient in a single forward pass (F5).
pub fn loss_and_grad(&self, log: &TrajLog1d, loss: DkfLoss) -> (f64, f64, f64) {
let (r, q) = (self.log_r.exp(), self.log_q.exp());
let (a, b) = (log.a, log.b);
let (mut mu, mut sigma2) = (log.mu0, log.sigma0 * log.sigma0);
// Sensitivities ∂(μ, σ²)/∂θ. The initial belief is given, not learned.
let (mut d_mu, mut d_sig) = ([0.0_f64; 2], [0.0_f64; 2]);
let (mut total, mut grad) = (0.0, [0.0_f64; 2]);
for t in 0..log.z.len() {
let u = log.u.get(t).copied().unwrap_or(0.0);
let mu_bar = a * mu + b * u;
let sigma2_bar = a * a * sigma2 + r;
let d_mu_bar = [a * d_mu[0], a * d_mu[1]];
// ∂r/∂log r = r, ∂r/∂log q = 0.
let d_sig_bar = [a * a * d_sig[0] + r, a * a * d_sig[1]];
let nu = log.z[t] - mu_bar;
let s = sigma2_bar + q;
let d_nu = [-d_mu_bar[0], -d_mu_bar[1]];
let d_s = [d_sig_bar[0], d_sig_bar[1] + q];
let k = sigma2_bar / s;
let d_k = [
(d_sig_bar[0] - k * d_s[0]) / s,
(d_sig_bar[1] - k * d_s[1]) / s,
];
let mu_new = mu_bar + k * nu;
let sigma2_new = (1.0 - k) * sigma2_bar;
match loss {
DkfLoss::Evidence => {
total += 0.5 * ((core::f64::consts::TAU * s).ln() + nu * nu / s);
for p in 0..2 {
// Negative when ν² > S: descent widens a surprised filter.
grad[p] += 0.5 * ((1.0 - nu * nu / s) * (d_s[p] / s) + 2.0 * nu * d_nu[p] / s);
}
}
DkfLoss::StateNll => {
let e = log.x[t] - mu_new;
total += 0.5 * ((core::f64::consts::TAU * sigma2_new).ln() + e * e / sigma2_new);
for p in 0..2 {
let d_mu_new = d_mu_bar[p] + d_k[p] * nu + k * d_nu[p];
let d_sig_new = (1.0 - k) * d_sig_bar[p] - d_k[p] * sigma2_bar;
grad[p] += 0.5
* ((1.0 - e * e / sigma2_new) * (d_sig_new / sigma2_new)
- 2.0 * e * d_mu_new / sigma2_new);
}
}
}
d_mu = [
d_mu_bar[0] + d_k[0] * nu + k * d_nu[0],
d_mu_bar[1] + d_k[1] * nu + k * d_nu[1],
];
d_sig = [
(1.0 - k) * d_sig_bar[0] - d_k[0] * sigma2_bar,
(1.0 - k) * d_sig_bar[1] - d_k[1] * sigma2_bar,
];
mu = mu_new;
sigma2 = sigma2_new;
}
(total, grad[0], grad[1])
}
}Soft resampling is the same low-variance comb as Thrun's Table 4.4, walked through instead of , with the importance ratio attached to each survivor:
use rand::Rng;
/// `soft_resample(X_t, λ, rng)` — F6. Additive: `ParticleFilter` opts in by
/// calling this instead of `low_variance_sampler`, and nothing else changes.
pub fn soft_resample<S: Clone>(
particles: &mut Vec<Particle<S>>,
lambda: f64,
rng: &mut impl Rng,
) -> f64 {
let m = particles.len();
let total: f64 = particles.iter().map(|p| p.weight).sum();
let w: Vec<f64> = particles.iter().map(|p| p.weight / total).collect();
let lambda = lambda.clamp(0.0, 1.0);
let q: Vec<f64> = w.iter().map(|wi| lambda * wi + (1.0 - lambda) / m as f64).collect();
let step = 1.0 / m as f64;
let start = rng.random_range(0.0..step);
let (mut out, mut acc, mut i, mut transmission) = (Vec::with_capacity(m), q[0], 0usize, 0.0);
for k in 0..m {
let u = start + k as f64 * step;
while u > acc && i < m - 1 {
i += 1;
acc += q[i];
}
out.push(Particle { state: particles[i].state.clone(), weight: w[i] / q[i] });
// ∂ log w′ᵢ / ∂ log wᵢ — the fraction of the gradient this survivor passes.
transmission += ((1.0 - lambda) / m as f64) / q[i];
}
*particles = out;
transmission / m as f64 // ≈ 1 − λ, exactly, in expectation
}The tests that pin it
#[test]
fn worked_example_ch25_one_step_evidence() {
// One step, r = q = 1, σ₀ = 0, z₁ = 2 — the example derived in the text.
let log = TrajLog1d { a: 1.0, b: 0.0, u: vec![0.0], z: vec![2.0], x: vec![0.0],
mu0: 0.0, sigma0: 0.0 };
let kf = DiffKf1d { log_r: 0.0, log_q: 0.0 };
let (loss, d_log_r, d_log_q) = kf.loss_and_grad(&log, DkfLoss::Evidence);
// L = ½(ln 4π + 2) = 2.265512…
assert_relative_eq!(loss, 0.5 * ((4.0 * PI).ln() + 2.0), epsilon = 1e-12);
// Equal and negative: the filter was surprised, and one measurement cannot
// separate process noise from measurement noise.
assert_relative_eq!(d_log_r, -0.25, epsilon = 1e-12);
assert_relative_eq!(d_log_q, -0.25, epsilon = 1e-12);
}
#[test]
fn analytic_gradient_matches_central_differences() {
let log = simulate_traj_1d(120, 0.96, 0.1, 0.05, 0.4, &mut SmallRng::seed_from_u64(2025));
for loss in [DkfLoss::Evidence, DkfLoss::StateNll] {
for theta in [(-1.5, 0.4), (0.3, -0.8), (-3.0, -3.0)] {
let kf = DiffKf1d { log_r: theta.0, log_q: theta.1 };
let (_, g_r, g_q) = kf.loss_and_grad(&log, loss);
let (fd_r, fd_q) = finite_diff_grad(&kf, &log, loss, 1e-5);
assert_relative_eq!(g_r, fd_r, max_relative = 1e-5);
assert_relative_eq!(g_q, fd_q, max_relative = 1e-5);
}
}
}
#[test]
fn evidence_training_recovers_the_noises_without_ground_truth() {
// 1500 steps generated with r = 0.08, q = 0.25; start deliberately wrong
// in both directions and never look at `log.x`.
let log = simulate_traj_1d(1500, 1.0, 0.05, 0.08, 0.25, &mut SmallRng::seed_from_u64(1234));
let mut kf = DiffKf1d { log_r: 0.4_f64.ln(), log_q: 0.02_f64.ln() };
for _ in 0..12_000 {
kf.sgd_step(&log, DkfLoss::Evidence, 0.05 / log.z.len() as f64);
}
assert!((kf.log_r.exp() / 0.08).ln().abs() < 0.2); // r → 0.085
assert!((kf.log_q.exp() / 0.25).ln().abs() < 0.1); // q → 0.250
}All three tests have twins in the TypeScript port that drives the widgets on this page
(lib/learn/__checks_ch25__.ts, eleven invariants, all passing). The last one is the chapter's real
claim in executable form: starting from five times too large and twelve times too small, and
using nothing but the measurements, the trainer lands on against a true and
against a true .
Putting it together: the clinic benchmark
The chapter's empirical thesis needs a table, and the table is generated by cargo run --example clinic
— the same computation the Calibration Clinic runs live in your browser, so every number here is one
you can reproduce by clicking a button in the widget above.
The setup: the true sensor is a beam mixture with m and a real unexpected-obstacle component (). The model family available to the engineer is mis-specified — it has no at all. Held-out evaluation is 1200 beams from random poses in the Apartment; downstream is one importance-weighting step over twelve 150-particle clouds, each displaced about 42 cm from the truth.
| model | claimed | held-out NLL | ECE | ESS after one update | posterior error |
|---|---|---|---|---|---|
| hand-tuned (the Tuesday afternoon) | 0.050 m | 0.937 | 0.1074 | 0.99% | 0.292 m |
EM, learn_intrinsic_parameters | 0.112 m | 0.290 | 0.0286 | 1.20% | 0.170 m |
| best held-out NLL, width only | 0.113 m | 0.352 | 0.0248 | 1.20% | 0.172 m |
| + fitted temperature (held-out NLL) | 0.115 m | 0.352 | 0.0226 | 1.23% | 0.171 m |
| + temperature fitted for calibration | 0.142 m | 0.381 | 0.0143 | 1.62% | 0.162 m |
| the true sensor (unavailable in practice) | 0.110 m | 0.223 | 0.0209 | 1.18% | 0.157 m |
Read the last three rows against each other, because that is the entire chapter in six numbers.
The best likelihood is not the best filter. EM wins the NLL column decisively — it is the only model here that fits the mixture weights as well as the width — and it still loses the posterior-error column to a model with a worse NLL by 0.09 nats. The widened, best-calibrated model gives up score in exchange for honesty, and honesty is what the filter downstream converts into accuracy: a 60% larger effective sample size and 5% less error, from a model that any leaderboard would rank below the one it beats.
Overconfidence is expensive and calibration is cheap. The hand-tuned model is not subtly worse. It is 4.5× the ECE of anything fitted, and it leaves the posterior 0.292 m from the truth when the prior was already only 0.424 m away — it converts a scan into a third of the correction it should have bought. Fixing it required no retraining and no new data: one scalar, fitted on 800 held-out beams, in about fifty function evaluations.
And the honest model still wins on score. The true sensor's NLL of 0.223 is far below everything else, and no amount of temperature scaling closes that gap, because tempering cannot invent the component the family is missing. Calibration is a repair, not a substitute for getting the structure right. That is worth saying loudly in a chapter about learning: the largest single improvement available in this table comes from the model family, not from the fitting procedure.
The honesty ledger
The chapter closes where it should — with a line drawn between what is principled and what is alchemy, as of 2026.
Principled. A learned component that emits a calibrated density and is dropped into an unchanged Bayes filter: this is just a better , and every theorem in Parts II through V still holds. Training noise parameters by maximizing evidence, which is prediction-error identification with a fifty-year pedigree and needs no ground truth. Post-hoc calibration on held-out data, which has one parameter and cannot lie to you in any interesting way. Differentiable filters that keep the filter's structure as an algorithmic prior and learn only its contents.
Still alchemy. End-to-end "filters" whose belief is a vector of activations that nobody has
graded — if you cannot draw its reliability diagram, do not call it a posterior. Learned resampling
heuristics published without a variance analysis. Sim-to-real transfer of learned sensor models: our
own learned model was trained in the simulator that also evaluates it, which is exactly the
circularity flagged after learn_observation_model, and no reliability diagram computed inside the
simulator says anything about the building. And diffusion policies' uncertainty: they are samplers,
not densities, so they do not compose with Bayes' rule, and a policy that produces diverse actions is
not thereby a policy that knows what it does not know.
A useful sanity check before adopting any of this: Greenberg, Yannay and Mannor (NeurIPS 2023) showed that a Kalman filter optimized as carefully as the network it is compared against closes much of the reported gap in the differentiable-filtering literature. The technique in this chapter — train the classical filter's parameters properly — is also the baseline that most learned filters should have been compared against.
Chapter 26 puts this to work: its stack exposes a calibrated learned sensor model as a toggle, and its retrospective quotes this table.
Exercises
- Foundation exerciseDifficulty 2 of 3Propriety, and a score you can game
Prove D25.1 for the log score without looking at derivation F1 — expected score equals entropy plus KL, and Gibbs' inequality does the rest. Then construct a scoring rule that a beam model could game: find and two densities with , and say in one sentence what a model trained on it would do to . (Hint: reward the density at the observation's mode rather than at the observation.)
- Foundation exerciseDifficulty 3 of 3Differentiate the filter yourself
Derive for the scalar Kalman filter under the evidence loss, carrying the sensitivity recursion of F5 over two steps rather than one. Verify your expression against
DiffKf1d::loss_and_gradby central differences at ; the test skeleton isanalytic_gradient_matches_central_differencesabove. Then explain why the one-step worked example has and the two-step version does not. - Foundation exerciseDifficulty 3 of 3The ESS lemma
Derive from the Gaussian moment generating function (F3, Steps 3–4), stating precisely where you used the assumption that the per-particle log-likelihoods are Gaussian. Then compute how large a spread your particle filter can tolerate before leaves fewer than 5% of particles effective, and compare with the spread the Tempering Lab reports for a 16-beam likelihood-field scan.
- Conceptual exerciseDifficulty 2 of 3Predict, then temper
Before touching the Tempering Lab: predict what happens to the kidnap-recovery percentage at , and predict whether helps or hurts tracking accuracy between kidnaps. Run both for at least three kidnap cycles each. Explain both outcomes using F3, and say which of the two effects — precision growth or ESS collapse — dominates the failure you saw.
- Conceptual exerciseDifficulty 2 of 3Find the two optima
In the Calibration Clinic, use the slider to locate the width that minimizes the mean NLL and the width that minimizes the ECE. They are different. Before reading the two right-hand tiles, predict which of those two models gives the lower posterior error — then check. Now explain the gap using the reliability diagram: which end of the PIT histogram is the NLL optimum trading away, and what is it buying with it?
- Practical exerciseDifficulty 2 of 3Grade the likelihood field
Implement
ObservationModelandcalibratefor Chapter 10'sLikelihoodField. Its per-beam density is a Gaussian on the distance to the nearest occupied cell plus a uniform floor, so its CDF is available in closed form. Is it calibrated? Produce its PIT histogram over 1200 beams in the Apartment and identify where it bulges — then explain the bulge from the model's structure (hint: what does the likelihood field do with a max-range beam, and with a beam that hits an unmapped obstacle?). - Practical exerciseDifficulty 3 of 3Train through the resampler
Wire
soft_resampleinto Chapter 8'sParticleFilterand train a single parameter — the sensor model's width — end-to-end through three steps of 1-D hallway MCL, using the state NLL of the filter mean as the loss. Sweep and plot both the gradient magnitude reaching the parameter and the variance of the resulting estimate. Does the learned width agree with the post-hoc temperature fromfit_temperature? If it does not, say which loss each one is optimizing and which of the two you would ship.
References
- Thrun, S., Burgard, W., and Fox, D. (2005) Probabilistic Robotics. MIT Press.link to Probabilistic Robotics (opens in a new tab)
The ancestor of this chapter, twice over: learn_intrinsic_parameters (Table 6.2) fits the beam model by EM, and the section on learning inverse measurement models trains a function approximator on triplets sampled from the forward model — self-supervised sim-to-model learning, in 1999. The epigraph is that section noticing its own frontier.
- Gneiting, T. and Raftery, A. E. (2007) Strictly Proper Scoring Rules, Prediction, and Estimation. Journal of the American Statistical Association 102(477), 359–378.doi:10.1198/016214506000001437 (opens in a new tab)
The reference treatment of D25.1. Everything this chapter trains, it trains on a strictly proper score, and this paper is why that phrase has content.
- Gneiting, T., Balabdaoui, F., and Raftery, A. E. (2007) Probabilistic Forecasts, Calibration and Sharpness. Journal of the Royal Statistical Society Series B 69(2), 243–268.doi:10.1111/j.1467-9868.2007.00587.x (opens in a new tab)
The source of the principle this chapter is organized around — maximize sharpness subject to calibration — and of the PIT-histogram diagnostic that the Calibration Clinic plots.
- Abbeel, P., Coates, A., Montemerlo, M., Ng, A. Y., and Thrun, S. (2005) Discriminative Training of Kalman Filters. Robotics: Science and Systems I.doi:10.15607/RSS.2005.I.038 (opens in a new tab)
The robotics ancestor of the Differentiable Filter Trainer: learn the noise covariances instead of hand-designing them, and beat a carefully hand-tuned filter on a real rover.
- Guo, C., Pleiss, G., Sun, Y., and Weinberger, K. Q. (2017) On Calibration of Modern Neural Networks. ICML 2017.link to On Calibration of Modern Neural Networks (opens in a new tab)
Established that accuracy and calibration diverge in modern networks, and that a single temperature fixes most of it. This chapter's B = 15 bins, its ECE definition, and its post-hoc scaling all come from here.
- Karkus, P., Hsu, D., and Lee, W. S. (2018) Particle Filter Networks with Application to Visual Localization. Conference on Robot Learning (CoRL) 2018.link to Particle Filter Networks with Application to Visual Localization (opens in a new tab)
Section 3 introduces soft resampling — the mixture proposal and importance correction of derivation F6, with the trade-off parameter written α where this book writes λ.
- Chen, X. and Li, Y. (2024) An Overview of Differentiable Particle Filters for Data-Adaptive Sequential Bayesian Inference. Foundations of Data Science (AIMS); arXiv:2302.09639.link to An Overview of Differentiable Particle Filters for Data-Adaptive Sequential Bayesian Inference (opens in a new tab)
The current map of the differentiable-filtering field, from Jonschkowski et al.'s RSS 2018 differentiable particle filters onward: which components get learned, which resampling relaxations exist, and what each objective actually optimizes.
- Chi, C., Xu, Z., Feng, S., Cousineau, E., Du, Y., Burchfiel, B., Tedrake, R., and Song, S. (2025) Diffusion Policy: Visuomotor Policy Learning via Action Diffusion. The International Journal of Robotics Research 44(10–11), 1684–1704.doi:10.1177/02783649241273668 (opens in a new tab)
The extended version of the RSS 2023 paper, and the standard treatment of D25.6. Read it for the multimodality argument: a learned sampler keeps the two ways around an obstacle that a regressed mean averages into the obstacle.
