The math every estimator is built on — covariance you can trust, least squares from first principles, and costs that survive bad data. Derived by hand, designed into a real stack, and broken on purpose so you know each failure by its symptom.
It is 10:40 on a Tuesday. You are in a small room with a whiteboard, a staff perception engineer, and a laptop showing a plot from last week's field test.
The plot is from your SLAM system. The blue band is the reported 1σ position uncertainty — the filter's own estimate of how wrong it might be. It sits at 2 centimetres and stays there. The orange line is the actual error against a survey-grade ground truth. It walks out to 40 centimetres and stays there too.
The engineer taps the screen and says: "The filter is not broken. It converged, it never diverged, no covariance went negative, no solver failed. It is simply lying. Tell me why, and tell me how you would have found out before we drove into a wall."
Every robotics perception problem — SLAM, VIO, calibration, sensor fusion, tracking, even manipulation — is downstream of three ideas:
Any of the twenty-three later topics in this track falls back on these three the moment something misbehaves. That is why this is lesson 3 and not lesson 20.
This lesson does not re-teach the underlying theory. Four lessons on this site already do that well, and you should read them if any of the derivations below feel unfamiliar:
| If you want… | Read |
|---|---|
| Bayes' rule as an estimator, priors, posteriors, conjugacy | Bayesian Estimation |
| Factor graphs, the sparsity structure, iSAM, marginalisation | SLAM: Factor Graphs |
| Loss functions as a family, gradients, why a shape matters | Loss Functions |
| The theoretical floor on any unbiased estimator's covariance | Fisher Information & the CRLB |
What this lesson spends its words on instead is the practice: the derivations done by hand until they are yours, the system design with real latencies and byte counts, the failure taxonomy with the metric that reveals each one, and the tradeoffs you have to be able to defend.
| Lens | The question this topic keeps asking | What a shallow answer sounds like |
|---|---|---|
| CONCEPT | "Propagate this covariance through this function. Now gate a measurement with it." | Writing JΣJT from memory without being able to say where the J came from. |
| DESIGN | "Where does the covariance live in your stack, how big is it, and who consumes it?" | Boxes and arrows with no bytes, no rates, and no consumer. |
| CODE | "Implement weighted least squares. Now do it without forming an inverse." | np.linalg.inv(A) @ b, and no reaction when asked why that is bad. |
| DEBUG | "The filter is confident and wrong. Find it." | "Tune Q up." (Which direction? By how much? How would you know it worked?) |
| FRONTIER | "Would you let a network output your covariance?" | A yes or a no with no failure mode attached to either. |
"Where does covariance live?" is the question whose answer separates people who have read about filters from people who have shipped one. Here is the honest map for a mid-size mobile robot, with sizes and rates:
nav_msgs/Odometryfloat64[36] pose.covariance — a 6×6 row-major block for (x, y, z, roll, pitch, yaw), plus another 36 for twist. 576 bytes of covariance per message, at 50 Hz.Go back to the 2 cm claim and the 40 cm reality. There are exactly three families of cause, and a strong engineer names all three before choosing one.
1. Under-modelled process noise. Your motion model says the robot moves as commanded plus a small Gaussian kick. The real robot has wheel slip on a wet patch, a caster that binds, and a payload that shifts. The true state wanders faster than Q allows, so P shrinks toward zero while the error grows.
2. Correlated measurement noise treated as independent. Your LiDAR scan matcher returns a pose every 100 ms and you feed each one in as fresh, independent evidence. But consecutive scans overlap by 95%, so the same systematic error — a slightly wrong extrinsic, a slightly wrong scan-matching prior — appears in all of them. You are counting the same information forty times. P collapses; the bias does not.
3. Linearisation error. Every EKF replaces a nonlinear function with its tangent plane. The tangent plane never carries the curvature, so the propagated covariance is the covariance of the wrong distribution. Chapter 1 puts a number on exactly how wrong.
| Cause | Fingerprint in the data | The number that reveals it |
|---|---|---|
| Under-modelled Q | trace(P) decays monotonically to a floor; error keeps growing | Average NIS drifts steadily above the upper chi-square bound |
| Correlated measurements | NIS looks fine at first and degrades with measurement rate; decimating the input by 10× makes the filter honest again | Autocorrelation of the innovation sequence at lag 1 — should be near 0, will be 0.6–0.9 |
| Linearisation error | Only bad in high-curvature regimes: large bearing uncertainty, long range, fast rotation | Compare JΣJT against a 10k-sample Monte Carlo push-through; look at the mean shift, not just the spread |
The normalised innovation squared (NIS) is the whole diagnostic in one line. At each update you already compute the innovation y = z − ẑ and its covariance S. Then:
If the filter's model of the world is right, y is a zero-mean Gaussian with covariance S. The claim is then that NIS is chi-square distributed with m degrees of freedom, where m is the measurement dimension — so its expected value is exactly m and its variance is exactly 2m. That is a hard, checkable prediction that costs you nothing to test.
Do not take that claim on faith. It falls out of a single factorisation, and the factorisation is worth doing by hand once, because the identical three steps are also Mahalanobis distance, chi-square gating, and the whitening step inside every square-root filter. Derive it once here and you have derived four things.
Step 1 — S factors. S is a covariance, so it is symmetric, and for any real sensor with non-zero noise it is positive definite. Every symmetric positive-definite matrix has a unique Cholesky factor: a lower-triangular L with positive diagonal such that
L is the matrix square root of S in exactly the sense that σ is the square root of a scalar variance. For m = 1, L is the 1×1 matrix [σ] and everything below collapses to the scalar algebra you already do without thinking.
Step 2 — whiten. Define the whitened innovation u = L−1y. Covariance transforms as Cov(Ax) = A·Cov(x)·AT, so
So u is a vector of m independent standard normals. A correlated, badly scaled, unit-carrying innovation has been turned into m clean dimensionless numbers each with variance 1. That is what "whitening" means, and it is the single most reused trick in estimation — it is also why square-root filters carry L instead of S.
Step 3 — the quadratic form is a sum of squares. Substitute S−1 = (L LT)−1 = L−TL−1:
A sum of m squared independent standard normals is the definition of a chi-square with m degrees of freedom. No further argument is needed: E[NIS] = m and Var[NIS] = 2m follow from the definition. Swap S for P and y for the state error and you have NEES. Compare uTu against a threshold and you have the gate. Same three lines every time.
The same derivation on real numbers. Take the 2×2 matrix used in the NEES example two subsections down, P = [[0.0100, 0.0040], [0.0040, 0.0025]], with error e = (−0.12, +0.15). Cholesky by hand is two square roots and one division:
Check it before going on — the (2,2) entry of L LT is 0.0402 + 0.0302 = 0.0016 + 0.0009 = 0.0025. Correct. Now whiten by forward substitution. Notice that no inverse is ever formed; you solve two scalar equations top to bottom:
Forty-five — identical to the number the adjugate route produces below, but arrived at with two square roots instead of a determinant, and with something extra for free. You can now read the failure straight off u: the error is 1.2 standard deviations out along the first whitened axis and 6.6 along the second.
Compare that with the per-axis check a normal logging dashboard performs. Marginally, 0.12 against √0.0100 = 0.10 is 1.2σ, and 0.15 against √0.0025 = 0.05 is 3.0σ. The marginal view says "three sigma, borderline, watch it." The whitened view says "six point six sigma, this filter is broken." The entire gap between 3.0 and 6.6 is the off-diagonal 0.0040 — the correlation the marginal view throws away.
Worked example — the 2 cm claim. Position measurement is scalar (m = 1), the filter reports σ = 0.02 m so S = 0.0004 m2, and the observed error is 0.40 m. Then:
Expected value is 1. The 95th percentile of a 1-DOF chi-square is 3.841. You are at 400 — a hundred times past the point where a single sample would be suspicious. In standard-deviation terms, √400 = 20, so the filter is overconfident by a factor of 20. Not 20 percent. Twenty times.
The callout at the top of this chapter promised four lines. Here they are, from scratch, with the shapes written down, because the interesting question is not "did you get 400" — it is what happened in the middle of the expression, where S got inverted.
python import math import numpy as np def nis(y, S): """Normalised innovation squared. y : (m,) innovation z - z_hat [measurement units] S : (m, m) innovation covariance H P H^T + R [units squared] returns : scalar, dimensionless. E[nis] = m when the filter is honest. The four lines of real work are: form y, form S, solve, dot.""" y = np.asarray(y, dtype=float).reshape(-1) # (m,) S = np.asarray(S, dtype=float).reshape(y.size, y.size) return float(y @ np.linalg.solve(S, y)) # NOT y @ np.linalg.inv(S) @ y # ── Why solve() and never inv() ────────────────────────────────────── # COST np.linalg.solve() is one LU with partial pivoting plus two # triangular solves (m^2 each): ~2m^3/3 flops in total. # np.linalg.inv() does that same LU and THEN spends ~4m^3/3 more # building the explicit inverse -- about 3x the flops -- and you # still have to multiply. For an SPD S you can halve LU again # with a Cholesky (m^3/3), which is what whiten() below uses, or # scipy.linalg.cho_factor / cho_solve if you want it packaged. # STABILITY x = inv(S) @ y is not backward stable: the computed x can leave # a residual orders of magnitude larger than the factorisation # route (Higham, *Accuracy and Stability of Numerical Algorithms*, # ch. 14). On a range-bearing S with a condition number of 1e4 -- # routine, because range is centimetres and bearing is milliradians # -- that is digits you do not get back. # SAFETY np.linalg.cholesky(S) RAISES if S has drifted indefinite. # np.linalg.inv(S) cheerfully returns numbers. In a filter that # has been running for six hours, the raise is the feature. # ───────────────────────────────────────────────────────────────────── def whiten(y, S): """u = L^-1 y with S = L L^T. Cov(u) = I, so u is in units of sigma along each whitened axis. nis == u @ u, by construction.""" L = np.linalg.cholesky(S) # raises on non-PD -- good return np.linalg.solve(L, y) # forward substitution, O(m^2) # ── The 2 cm claim, reproduced exactly ─────────────────────────────── S = np.array([[0.0004]]) # sigma = 0.02 m -> S = 4e-4 m^2 y = np.array([0.40]) # observed position error, m print(nis(y, S)) # 400.0 (expected 1.0) print(math.sqrt(nis(y, S))) # 20.0 -> overconfident 20x in sigma # ── The 2x2 NEES example, and the whitened vector from the derivation ─ P = np.array([[0.0100, 0.0040], [0.0040, 0.0025]]) e = np.array([-0.12, 0.15]) print(np.linalg.cholesky(P)) # [[0.10, 0. ], [0.04, 0.03]] <- L, by hand above print(whiten(e, P)) # [-1.2, 6.6] <- 1.2 sigma and 6.6 sigma print(nis(e, P)) # 45.0 == (-1.2)**2 + 6.6**2 <- expected 2.0 print(np.sqrt(np.diag(P))) # [0.10, 0.05] <- the marginal view that misses it
Two things to say out loud while your hands are typing. First, that solve and cholesky are not stylistic preferences — the Cholesky is the same factorisation the derivation above needed, so the numerically correct implementation and the mathematically honest derivation are literally the same object. Second, that whiten is the more useful return value in practice: nis gives you one number that says "bad", while u tells you which direction is bad, which is the difference between "the filter is inconsistent" and "the filter is inconsistent along the bearing axis, go look at the extrinsic."
reshape(y.size, y.size) is there because the scalar case is the one people get wrong. A scalar S written as 0.0004 instead of [[0.0004]] makes np.linalg.solve throw, and the reflex fix — dividing instead of solving — is the habit that later becomes inv when m grows to 6. Write the general version once, use it everywhere, and the scalar case is free.Averaging makes the test tight. A single NIS of 4 means nothing (a 1-DOF chi-square exceeds 3.84 five percent of the time by luck). Average over a window of N updates and the sum is chi-square with N·m degrees of freedom, which concentrates hard.
Derive one row before you trust the table. Take N = 50, m = 1. Each NISk is chi-square with 1 degree of freedom, and independent chi-squares add their degrees of freedom when you add them, so the sum over the window satisfies
The 2.5th and 97.5th percentiles of a chi-square with 50 degrees of freedom are 32.36 and 71.42. But you report the average, not the sum, so divide both endpoints by N = 50:
That is the second row of the table below, end to end. The general rule — and this is the line to write on the whiteboard rather than memorising four bands — is
Two places people get this wrong, and anyone who has debugged a filter will check both. The degrees of freedom are N·m, not N — the m = 2 rows below use 100 and 200 degrees of freedom, not 50 and 100. And the divisor is N, not N·m, because you are averaging over updates while each update contributes m degrees of freedom; that is why the m = 2 rows centre on 2.00 rather than 1.00.
Doing it without scipy. Nobody hands you a chi-square table at a whiteboard. The Wilson–Hilferty transform says the cube root of a chi-square variable is very nearly normal, which inverts to a closed form needing one square root and one cube:
Run it for d = 50, by hand. First 2/(9·50) = 2/450 = 0.004444, then √0.004444 = 0.06667, then 1.96 × 0.06667 = 0.13067. So the inner bracket is 1 − 0.004444 − 0.13067 = 0.86489 at the low end and 1 − 0.004444 + 0.13067 = 1.12622 at the high end. Cube them and scale by d:
Against the exact 32.36 and 71.42 that is three significant figures from arithmetic you can do on a whiteboard while talking. This is the useful form of the fact: you do not memorise the table, you memorise one formula and generate whichever row you need.
| Window N | Meas. dim m | Expected average NIS | 95% acceptance band on the average |
|---|---|---|---|
| 30 | 1 | 1.00 | [0.560, 1.566] |
| 50 | 1 | 1.00 | [0.647, 1.428] |
| 50 | 2 | 2.00 | [1.484, 2.591] |
| 100 | 2 | 2.00 | [1.627, 2.411] |
| 500 | 1 | 1.00 | [0.880, 1.128] |
| 50 | 6 | 6.00 | [5.078, 6.998] |
With a 50-sample window on a 2-D measurement, anything outside [1.484, 2.591] is a real signal, not noise. This is a tighter test than most teams' entire regression suite, and it runs online with no ground truth. That last part matters: NIS needs only the filter's own innovations, so it works in the field on a customer's robot where you have no motion capture.
Read the table down the m = 1 column and you can see the whole power/latency tradeoff of a consistency monitor. At N = 30 the band is ±44%, which means a filter that is genuinely 30% overconfident sails through. At N = 500 the band is ±12% and almost nothing hides — but 500 updates at 10 Hz is fifty seconds, so you have traded detection latency for power. That tradeoff is the answer to "how long a window would you use?", and the honest answer is two windows: a short one for a live health flag on the robot and a long one for the CI gate on a recorded bag.
Note also the last row: a 6-D measurement (a full relative pose from a scan match) with a 50-sample window has band [5.078, 6.998] around an expectation of 6, i.e. ±16%. Higher-dimensional measurements give you a tighter test per update, because each update contributes m degrees of freedom rather than one. If you have the choice, gate on the full pose rather than on x alone.
And here is the whole thing as it actually ships — a rolling window, the band derived from N and m rather than pasted in as a constant, and a verdict per window:
python import math import numpy as np from scipy.stats import chi2 def nis_band(N, m, alpha=0.05): """Two-sided (1-alpha) acceptance band on the MEAN of N updates of dim m. dof = N * m (NOT N -- each update contributes m) divisor = N (NOT N*m -- you averaged over updates)""" dof = N * m return chi2.ppf(alpha / 2, dof) / N, chi2.ppf(1 - alpha / 2, dof) / N def nis_band_wh(N, m, z=1.96): """Wilson-Hilferty -- the whiteboard version, no scipy, 3 sig figs.""" d = N * m return tuple(d * (1 - 2 / (9 * d) + s * z * math.sqrt(2 / (9 * d))) ** 3 / N for s in (-1, +1)) def rolling_nis(innovations, S_list, N=50): """innovations : (K, m) one innovation vector per accepted update S_list : (K, m, m) the matching innovation covariances yields (k, window_mean, in_band) for every complete window.""" innovations = np.atleast_2d(innovations) m = innovations.shape[1] lo, hi = nis_band(N, m) vals = np.array([nis(y, S) for y, S in zip(innovations, S_list)]) csum = np.concatenate(([0.0], np.cumsum(vals))) # O(K) not O(K*N) for k in range(N, len(vals) + 1): w = (csum[k] - csum[k - N]) / N yield k, w, lo <= w <= hi print(nis_band(50, 1)) # (0.6471, 1.4284) <- the table row, exactly print(nis_band_wh(50, 1)) # (0.6470, 1.4285) <- and without scipy print(nis_band(50, 2)) # (1.4844, 2.5912) <- dof 100, divisor 50 print(nis_band(500, 1)) # (0.8799, 1.1277) <- 10x window, 4x tighter band # The 20x-overconfident filter, fed 50 identical bad updates: print(next(rolling_nis(np.full((50, 1), 0.40), np.full((50, 1, 1), 0.0004)))) # (50, 400.0, False) -- 400 against a ceiling of 1.428
The cumulative-sum trick in rolling_nis matters more than it looks. A naive re-mean over every window is O(K·N), which on an hour-long bag at 50 Hz with N = 500 is ninety million multiplies for a diagnostic. The prefix sum makes it O(K) and turns "we run consistency checks nightly" into "we run them on every merge."
Second worked example — NEES on a 2-D state, by hand. On the motion-capture rig you have truth. The filter reports
Step 1 — the error: e = xtrue − x̂ = (1.00 − 1.12, 2.00 − 1.85) = (−0.12, +0.15).
Step 2 — invert the 2×2. det P = 0.0100 × 0.0025 − 0.0040 × 0.0040 = 0.000025 − 0.000016 = 0.000009. For a 2×2 the inverse is the adjugate over the determinant, so
Step 3 — the matrix-vector product first, then the dot product. Never do this in one go on a whiteboard; you will drop a sign.
Expected value for a 2-D state is 2. You got 45 — the filter is overconfident by a factor of 22.5 in variance, which is √22.5 = 4.7× in standard deviation. And notice what the off-diagonal did: an error of only 12 cm and 15 cm produced a NEES of 45 because the error vector points across the correlated direction that P declared confident. A diagonal-only sanity check — "is 0.12 less than 3×√0.01 = 0.30? yes, fine" — would have passed this state as healthy. The full quadratic form catches it; the per-axis check does not.
sqrt(P[0,0]) and sqrt(P[1,1]) and never look at the cross term. If your uncertainty is elongated — and in robotics it always is, because range and bearing have wildly different precision — the marginal standard deviations hide the failure. Log the full quadratic form, not the diagonal.A correctly modelled filter produces innovations that are a white sequence — zero mean, and uncorrelated from one step to the next. That is not a nice-to-have, it is a theorem: if the innovations carried any predictable structure, the filter left information on the table and was not optimal. So the lag-1 autocorrelation is a second free test.
Worked by hand on eight innovations from a scan-matching update, in metres:
Mean: (0.31 + 0.28 + 0.33 + 0.25) = 1.17, and (−0.30 − 0.27 − 0.31 − 0.29) = −1.17, so the mean is exactly 0. A mean test passes. Now the products of consecutive terms:
| k | yk−1 | yk | product |
|---|---|---|---|
| 1 | +0.31 | +0.28 | +0.0868 |
| 2 | +0.28 | +0.33 | +0.0924 |
| 3 | +0.33 | +0.25 | +0.0825 |
| 4 | +0.25 | −0.30 | −0.0750 |
| 5 | −0.30 | −0.27 | +0.0810 |
| 6 | −0.27 | −0.31 | +0.0837 |
| 7 | −0.31 | −0.29 | +0.0899 |
| sum of products | +0.4413 | ||
And the sum of squares: 0.0961 + 0.0784 + 0.1089 + 0.0625 + 0.0900 + 0.0729 + 0.0961 + 0.0841 = 0.6890.
Is 0.640 large? That question has no answer until you know the noise floor, and the noise floor is where most treatments stop short. The standard result is that for a white sequence ρ1 is approximately normal with standard deviation 1/√N — and it is worth two sentences of motivation rather than being quoted.
Under the null hypothesis of whiteness, the numerator is a sum of N − 1 products ykyk−1 of independent zero-mean terms, so each product has mean 0 and variance E[yk2]E[yk−12] = σ4, and the products are uncorrelated to leading order — giving the numerator mean 0 and variance (N − 1)σ4. The denominator is a sum of N squares, which by the law of large numbers concentrates tightly on Nσ2 and can be treated as a constant. Divide:
Notice that σ2 cancels completely. The noise floor of this test does not depend on how noisy your sensor is, only on how many samples you looked at — which is why the same ±1.96/√N Bartlett band is drawn on every autocorrelation plot in every signal-processing textbook, with no reference to the signal. Now put the two window lengths through it explicitly:
| N | noise floor 1/√N | ρ1 = 0.640 is… | 95% threshold 1.96/√N | Verdict |
|---|---|---|---|---|
| 8 | 1/2.828 = 0.354 | 0.640/0.354 = 1.81σ | 0.693 | 0.640 < 0.693 — suggestive, cannot convict |
| 50 | 1/7.071 = 0.141 | 0.640/0.141 = 4.53σ | 0.277 | 0.640 > 0.277 — conclusive |
| 200 | 1/14.142 = 0.0707 | 0.640/0.0707 = 9.05σ | 0.139 | 0.640 > 0.139 — not arguable |
So the eight-sample hand computation is honest but under-powered: 1.81σ is a two-in-fourteen coincidence, and 0.640 sits below the 95% threshold of 0.693 for N = 8. Say that plainly rather than overclaiming — concluding "0.64, that's clearly broken" from eight samples is a failure to think about sample size. Run the identical computation over a realistic 200-sample window and it becomes a nine-sigma result. Innovations that are 64% predictable from the previous innovation are not innovations; they are a systematic error being re-observed, forty times a second, and counted as new evidence every time.
The ninety-second diagnosis later in this chapter promises "it is a five-line check". This is that check, and it is the first thing to run on the field log because it needs nothing except the innovations you already record:
python import numpy as np def whiteness(y): """Lag-1 autocorrelation of a scalar innovation sequence + Bartlett band. y : (N,) innovations from ONE measurement channel, in sensor units. returns (rho1, threshold, is_white). rho1 is dimensionless.""" y = np.asarray(y, dtype=float) N = y.size rho1 = (y[1:] * y[:-1]).sum() / (y ** 2).sum() # the whole statistic thr = 1.96 / np.sqrt(N) # 95% Bartlett band return float(rho1), float(thr), bool(abs(rho1) < thr) # ── The eight innovations worked by hand above ─────────────────────── y8 = np.array([0.31, 0.28, 0.33, 0.25, -0.30, -0.27, -0.31, -0.29]) print((y8[1:] * y8[:-1]).sum()) # 0.4413 <- the table of products print((y8 ** 2).sum()) # 0.6890 <- the sum of squares print(whiteness(y8)) # (0.6405, 0.6930, True) -- 8 samples literally cannot convict this filter # ── The same defect, 200 samples: an AR(1) innovation with phi = 0.64 ─ rng = np.random.default_rng(0) phi, N = 0.64, 200 v = np.zeros(N) e = rng.standard_normal(N) * np.sqrt(1 - phi ** 2) for k in range(1, N): v[k] = phi * v[k - 1] + e[k] print(whiteness(0.30 * v)) # (0.6917, 0.1386, False) -- 0.6917 / 0.0707 = 9.8 sigma. Not arguable. print(whiteness(rng.standard_normal(200))) # (-0.0368, 0.1386, True) -- what a healthy filter looks like # ── The vector case: whiten first, then correlate componentwise ─────── # For m > 1 do NOT correlate the raw innovation components -- they are # correlated with each other by construction (that is what S encodes). # Correlate u_k = L_k^-1 y_k instead: those components ARE independent # under the null, so each column gets its own Bartlett band. def whiteness_vec(Y, S_list): U = np.array([whiten(y, S) for y, S in zip(Y, S_list)]) # (K, m) return [whiteness(U[:, j]) for j in range(U.shape[1])]
The last function is the one that separates people who have run this on real data. On a 6-D pose innovation the raw components are correlated by design — S says so — and correlating them directly produces a lag-1 number that is meaningless. Whitening first makes each column independently testable, and it also tells you which degree of freedom is stale: a yaw column that fails while x and y pass is a different bug from all six failing together.
Impact is the part people underestimate. Here is the arithmetic that makes the case concrete, and it is worth memorising the shape of it.
The planner inflates every obstacle by 3σ from the localisation covariance. The robot is 0.70 m wide. The aisle is 1.20 m wide. So the nominal clearance is (1.20 − 0.70) / 2 = 0.25 m per side.
The filter predicted "never" and reality is "one pass in two". That factor is not 20; it is 1035, because the Gaussian tail is exponential in σ and a linear lie about σ becomes an astronomically nonlinear lie about risk. This is the single best answer to "why does covariance accuracy matter more than mean accuracy?" — the mean error enters the risk linearly, the covariance error enters it through the exponent.
Every one of these appears within the next two chapters, and all of them appear daily in estimation work. If you have to reach for any of them, the later derivations will feel harder than they are.
| Term | One-sentence definition | Where you meet it |
|---|---|---|
| Covariance P | The matrix of second central moments; its eigenvectors are the principal axes of the uncertainty ellipse and its eigenvalues are the squared semi-axis lengths. | The filter state |
| Information matrix Λ | P−1. Additive across independent measurements, which is exactly why factor graphs and bundle adjustment work in this form. | Optimisation back-ends |
| Innovation y | z − ẑ — the part of the measurement your model did not predict. The only new information in the update. | Every filter update |
| Innovation covariance S | H P HT + R — state uncertainty pushed into measurement space, plus sensor noise. The denominator of every gate. | Gating, NIS |
| Mahalanobis distance | The length of a vector measured in units of the local standard deviation, in every direction at once. | Data association |
| NIS / NEES | Consistency statistics; expected value equals measurement dimension / state dimension respectively. | Field diagnostics / sim CI |
| Residual vs innovation | Innovation uses the prior estimate; residual uses the posterior. Their covariances differ, and using the wrong one makes your gate optimistic. | A classic gotcha |
| Whiteness | Zero autocorrelation at every non-zero lag. A necessary property of an optimally filtered innovation sequence. | Filter validation |
| MAD | Median absolute deviation; σ̂ = 1.4826 × MAD is a robust scale estimate that survives up to 50% contamination. | Robust cost tuning |
The five-questions table at the top of this chapter promised this one, and a yes or a no with no failure mode attached is exactly the shallow answer that table warns against. So here is the answer with the literature and the failure mode attached.
The idea, and where it came from. Kendall & Gal, "What Uncertainties Do We Need in Bayesian Deep Learning for Computer Vision?", NeurIPS 2017, is the paper everyone in this area cites. It splits uncertainty into two kinds that behave completely differently: aleatoric uncertainty is noise inherent in the observation (a dark frame, a specular surface, a featureless corridor) and does not shrink with more training data; epistemic uncertainty is the model's ignorance and does shrink. Their mechanism for the aleatoric part is the one that matters for us — have the network emit a per-sample variance alongside the prediction and train both with a heteroscedastic Gaussian negative log-likelihood:
Read the two terms as a negotiation. The first term lets the network reduce its loss on hard examples by inflating σ; the second term charges it for doing so. The equilibrium is that σ2 tracks the squared error — which is precisely learned attenuation, and precisely a learned R. In practice you regress s = log σ2 rather than σ2 so the network cannot output a negative variance and the division never explodes.
The robotics version already exists. Liu et al., "TLIO: Tight Learned Inertial Odometry", IEEE RA-L 2020, has a network regress a 3-D displacement and its covariance from raw IMU windows and feeds both into a stochastic-cloning EKF — the learned covariance is the R of a real filter, not a plot in a paper. Brossard, Barrau & Bonnabel, "AI-IMU Dead-Reckoning", IEEE T-IV 2020, does the adjacent thing: a small CNN adapts the measurement covariance of pseudo-measurements inside an invariant EKF, and the learned adaptation is what makes 10 km of dead reckoning work. Russell & Reale, "Multivariate Uncertainty in Deep Learning", IEEE TNNLS 2021, is the one to reach for when you need the full matrix rather than a diagonal: they emit the Cholesky factor L directly, so the predicted covariance L LT is positive definite by construction — the same factorisation this chapter derived, used as a parameterisation trick.
The answer, with the failure mode. Yes, with a gate. A learned σ is calibrated on the distribution it was trained on, and the regime where you most need honest covariance is by definition the regime you did not train on: rain on the lidar, a new warehouse, a repainted floor, dusk. Ovadia et al., "Can You Trust Your Model's Uncertainty? Evaluating Predictive Uncertainty Under Dataset Shift", NeurIPS 2019, is the load-bearing citation here — they show that predictive-uncertainty quality degrades steadily as the test distribution shifts, and that post-hoc calibration fitted on an i.i.d. validation split does not transfer to shifted data. Guo et al., "On Calibration of Modern Neural Networks", ICML 2017, is the companion fact: modern deep networks are systematically overconfident even in-distribution — a 2016-era ResNet is worse calibrated than a 1998-era LeNet — and temperature scaling fixes it cheaply but only against the validation set you fitted the temperature on.
So the failure mode is not "the network is wrong". The failure mode is that the network is quietly wrong in exactly the direction that removes your safety margin, in exactly the conditions where you needed it, and it fails silently because a small σ looks like a confident, healthy system to everything downstream. That is the same 20× lie this chapter opened with, laundered through a training run.
The mitigation is the whole point of this chapter. A learned covariance is admissible if and only if it passes the same test a hand-tuned R has to pass: average NIS inside the chi-square band, on held-out bags, per operating regime, in CI. That last clause is the one that carries the weight — not one global number, but a band per regime (day/night, indoor/outdoor, slow/fast), because a learned R that is honest at 1 m/s and overconfident at 3 m/s reports a perfectly healthy global average. Two further hedges worth naming: deep ensembles (Lakshminarayanan et al., NeurIPS 2017) remain the strongest cheap epistemic estimate and degrade most gracefully under shift in the Ovadia study; and conformal prediction (Angelopoulos & Bates, 2021) gives distribution-free coverage guarantees, but only under exchangeability — which a robot driving into a new building violates by construction, so it is a guarantee about your calibration set, not about tomorrow.
A scalar filter tracks a drifting quantity for exactly N = 50 updates — the same window whose band you derived by hand as [0.647, 1.428], computed here from the Wilson–Hilferty formula rather than pasted in. You control two lies. Q ratio is how much process noise the filter models relative to the truth — slide it below 1 and the filter believes the world is calmer than it is. Measurement correlation makes consecutive sensor errors share a common component the filter does not model. Watch the 3σ envelope (teal) lose contact with the real error (orange), and watch the NIS bar leave its acceptance band.
What to notice, in order.
Once you have NIS, the questions move. The rest of this lesson is that movement:
| Chapter | The question | The thing you must be able to do cold |
|---|---|---|
| 1 | "Show me the covariance of this measurement in Cartesian coordinates." | Write the Jacobian, push the covariance through, and say when the linearisation stops being valid. |
| 2 | "Derive least squares. Now add a prior." | Get from a negative log-likelihood to the normal equations without notes, and name the four estimators that fall out of the same expression. |
| 3 | "One of your correspondences is garbage. What happens?" | Draw the influence function, explain why L2 has no defence, and pick a robust kernel with its scale parameter. |
| 4 | "Show me." | The fitting bench — drag an outlier and watch three estimators disagree. |
| 5 | — | The field guide: cheat sheet, design patterns, coding drills, debugging scenarios, and the reading list. |
When this failure shows up in the field, the whole diagnosis fits in ninety seconds. Here is the structure, using the opening scenario. Read it once, then say it out loud without looking.
[0:00 — name the class of failure] "Two centimetres claimed against forty measured is a consistency failure, not a divergence. The estimator converged to a wrong belief about its own accuracy, so I would not start by tuning — I would start by measuring the inconsistency."
[0:15 — give the number] "The statistic is NIS: innovation transpose, S inverse, innovation. For a scalar position update that is 0.40 squared over 0.02 squared, which is 400 against an expectation of 1. So we are overconfident by twenty times in sigma. And NIS needs no ground truth, so I can compute it on a customer's robot from the log we already record."
[0:40 — separate the causes] "There are three families. Under-modelled process noise shows up as trace of P decaying while the error grows. Correlated measurements fed in as independent show up as a lag-one innovation autocorrelation well away from zero — and it gets worse when you raise the sensor rate, which is the tell. Linearisation error only bites in high-curvature regimes, and I would test it with a Monte Carlo push-through against the Jacobian propagation."
[1:10 — say what you would do] "First thing I would run is the autocorrelation, because scan-to-scan pose updates overlapping by ninety-five percent is the most common version of this bug and it is a five-line check. If it comes back white, I go to the Q story. Either way, once fixed, the NIS band goes into CI on a recorded bag so it cannot regress silently."
Four moves: classify, quantify, enumerate causes with their distinguishing signals, commit to a first action with a reason. That structure works for almost every debugging question in this track.
| What separates the answers | Weak | Strong |
|---|---|---|
| Did you reach for a metric before a knob? | "I would increase Q." | "I would compute average NIS over a 50-sample window first." |
| Do you know what the statistic is distributed as? | "NIS should be small." | "NIS is chi-square with m degrees of freedom; expectation m; the 50-sample band for m = 2 is [1.48, 2.59]." |
| Can you separate causes rather than list them? | Lists three causes. | Lists three causes and the one observable that discriminates each from the others. |
| Do you close the loop? | Fixes it. | Fixes it and puts the consistency band in CI against a recorded bag. |
| Do you know the consumer? | Treats covariance as diagnostics. | Knows the planner turns 3σ into metres of clearance and can do that arithmetic by hand. |
A LiDAR returns range and bearing. The map wants Cartesian. Two questions drive this whole chapter: what is the covariance of that point in the robot's Cartesian frame — and is this next return the same landmark?
A covariance is not an error bar. An error bar is a number per axis; a covariance matrix is a shape. It encodes, for every direction in the state space at once, how far the truth is likely to be from your estimate along that direction. The diagonal gives you the per-axis spread, and the off-diagonal gives you the thing that per-axis logging throws away: whether being wrong in x predicts being wrong in y.
Read that outer product literally. Each entry Σij is the average of (xi − μi)(xj − μj). The diagonal is the average of a square, so it is non-negative and is the variance. The off-diagonal is an average of a product, so its sign says whether the two errors tend to move together.
Worked example 1 — a 2×2 covariance from five samples, every step visible. Five repeated observations of the same landmark, in metres:
Step 1 — the means. μx = (2 + 4 + 6 + 8 + 10) / 5 = 30 / 5 = 6. μy = (3 + 7 + 7 + 11 + 12) / 5 = 40 / 5 = 8.
Step 2 — centre the data. dx = (−4, −2, 0, +2, +4), dy = (−5, −1, −1, +3, +4).
Step 3 — the three sums, divided by N − 1 = 4 (Bessel's correction, because we estimated the mean from the same data):
| Entry | Sum of products | ÷ 4 |
|---|---|---|
| Σxx | 16 + 4 + 0 + 4 + 16 = 40 | 10 |
| Σyy | 25 + 1 + 1 + 9 + 16 = 52 | 13 |
| Σxy | 20 + 2 + 0 + 6 + 16 = 44 | 11 |
Step 4 — read the shape. The correlation is Σxy / √(ΣxxΣyy) = 11 / √130 = 11 / 11.402 = 0.965. Nearly perfectly correlated: the landmark is well localised along one line and badly localised across it. That is the classic range-bearing signature, and the marginal standard deviations (√10 = 3.16 and √13 = 3.61) tell you almost none of it.
Step 5 — the eigenvalues, by hand. For a 2×2, the characteristic polynomial is λ2 − (trace)λ + det = 0, with trace = 10 + 13 = 23 and det = 9:
The 1σ ellipse semi-axes are the square roots: √22.602 = 4.754 m along the major axis and √0.398 = 0.631 m along the minor. A ratio of 7.5 in length, 56.8 in variance — that ratio is the condition number of Σ, and it is the number that decides whether your solver will behave.
Step 6 — the orientation. The major eigenvector satisfies (Σ − λ1I)v = 0, so (10 − 22.602)vx + 11 vy = 0, i.e. −12.602 vx + 11 vy = 0, so vy/vx = 12.602/11 = 1.1456. The angle is arctan(1.1456) = 48.9° from the x axis.
| Dimension | √chi-square for 95% | Mass inside the 1σ contour |
|---|---|---|
| 1 | 1.960 | 68.3% |
| 2 | 2.448 | 39.4% |
| 3 | 2.796 | 19.9% |
| 6 (a full pose) | 3.548 | 1.4% |
Your sensor does not measure the quantity you want. A LiDAR measures range and bearing; the map wants x and y. A camera measures pixels; the planner wants metres. Every time you change coordinates you must carry the uncertainty with you, and the mechanism is one line of first-year calculus.
The derivation, which you should be able to produce in twenty seconds. Let y = f(x) with x = μ + δ, where δ is the (small) error with covariance Σ. Take the first-order Taylor expansion about μ:
So the error in y is approximately Jδ, and its covariance is
That is the whole derivation. The constant f(μ) drops out because covariance is about deviations from the mean, and J comes out of the expectation because it is a fixed matrix evaluated at a fixed point. Where does the Jacobian come from? It is the linear map that the error rides through, and nothing more.
Worked example 2 — polar to Cartesian, every number. A LiDAR return at range r = 10.0 m, bearing θ = 30°, with σr = 0.10 m and σθ = 2°.
Step 1 — convert the bearing sigma to radians, because every trigonometric derivative assumes radians: σθ = 2 × π/180 = 0.034907 rad, so σθ2 = 0.00121847.
Step 2 — the mean point. x = r cosθ = 10 × 0.866025 = 8.6603 m, y = r sinθ = 10 × 0.5 = 5.0000 m.
Step 3 — the Jacobian of (x, y) = (r cosθ, r sinθ) with respect to (r, θ):
Notice the units already: the second column has an r multiplying it, so a bearing error of one radian becomes a position error of r metres. Angular uncertainty is amplified by range. That single observation explains most of what a LiDAR-based map looks like.
Step 4 — multiply, in two stages. First JΣ (scaling column 1 by 0.01 and column 2 by 0.00121847):
Then (JΣ)JT, where JT = [[0.866025, 0.500000], [−5.000000, 8.660254]]:
| Entry | Arithmetic | Value |
|---|---|---|
| [0][0] | 0.00866025×0.866025 + (−0.00609235)×(−5.0) = 0.0075000 + 0.0304617 | 0.037962 |
| [0][1] | 0.00866025×0.5 + (−0.00609235)×8.660254 = 0.0043301 − 0.0527632 | −0.048431 |
| [1][0] | 0.005×0.866025 + 0.01055222×(−5.0) = 0.0043301 − 0.0527611 | −0.048431 |
| [1][1] | 0.005×0.5 + 0.01055222×8.660254 = 0.0025000 + 0.0913852 | 0.093885 |
Step 5 — sanity-check the answer against physics before you move on. σx = √0.037962 = 0.1948 m, σy = √0.093885 = 0.3064 m. Is that plausible? The along-range uncertainty is 0.10 m and the cross-range uncertainty is rσθ = 10 × 0.034907 = 0.3491 m — three and a half times larger. The ellipse is a thin sliver perpendicular to the beam, tilted at 30°, and its projections onto x and y are 0.195 and 0.306. Consistent.
JΣJT is an approximation, and the question that matters is always "when does it break?" The honest answer has a closed form for this particular function, which is why it is such a good example.
For Gaussian θ, the exact mean of r cosθ is r cosθ̄ · e−σθ2/2. The exponential is the bearing-shrinkage bias: averaging a cosine over an angular spread always pulls the result toward the origin, and the linearisation, which is blind to curvature, never sees it. Here is that error made numerical against a 200,000-sample Monte Carlo push-through:
| σθ | Linearised mean | True (Monte Carlo) mean | Mean shift | trace ratio (MC / linear) |
|---|---|---|---|---|
| 2° | (8.660, 5.000) | (8.655, 4.997) | 0.6 cm | 1.003 |
| 10° | (8.660, 5.000) | (8.530, 4.924) | 15 cm | 0.982 |
| 25° | (8.660, 5.000) | (7.879, 4.547) | 90 cm | 0.906 |
At 2° the linearisation is essentially exact — a 6 mm mean error against a 19 cm standard deviation is nothing. At 25° the mean is 90 cm wrong, which is three times the standard deviation of the estimate. Your EKF is not slightly imprecise at that point; it is confidently reporting a point that the true distribution almost never visits. The shape has also stopped being an ellipse — it is the classic banana, curved along the arc of possible bearings, and no symmetric ellipse can represent it.
See the EKF lesson for the filtering machinery and the UKF lesson for the sigma-point alternative; this lesson only cares about the covariance that comes out and whether you can defend it.
Now the second half of the opening question — is this next return the same landmark? Euclidean distance cannot answer that, because "close" means something different along the beam than across it.
The Mahalanobis distance measures a vector in units of the local standard deviation, in every direction at once:
The cleanest way to see what it does is to factor S = LLT (a Cholesky decomposition, L lower-triangular). Then
So Mahalanobis distance is ordinary Euclidean distance after whitening — after applying the linear map that turns the uncertainty ellipse into a unit circle. That sentence is the whole idea, and it also tells you how to implement it: a triangular solve, never a matrix inverse.
Worked example 3 — two residuals of identical length, opposite verdicts. Use the Σ from example 1: S = [[10, 11], [11, 13]], det = 9. Its inverse, again by the 2×2 adjugate rule:
Residual A: y = (3, 2). Euclidean length √13 = 3.606.
Residual B: y = (3, −2). The same Euclidean length, √13 = 3.606.
The 95% gate for a 2-DOF measurement is chi-square at 5.991. Residual A (d2 = 2.778) is accepted. Residual B (d2 = 32.111) is rejected — it sits at 5.7 standard deviations. Identical Euclidean length; an 11.6× difference in the statistic that decides the association. Residual A points along the elongated axis that S declares uncertain; residual B points across it.
Because d2 is a sum of squares of independent unit-variance Gaussians (that is exactly what whitening produced), it is chi-square distributed with m degrees of freedom. That is what turns a distance into a decision:
| Meas. dim m | 90% gate | 95% gate | 99% gate | Typical use |
|---|---|---|---|---|
| 1 | 2.706 | 3.841 | 6.635 | Scalar range, altimeter, wheel-odometry scalar |
| 2 | 4.605 | 5.991 | 9.210 | Image feature (u, v); 2-D landmark bearing/range |
| 3 | 6.251 | 7.815 | 11.345 | 3-D point, GPS fix, magnetometer vector |
| 6 | 10.645 | 12.592 | 16.812 | A full relative pose from scan matching |
Here is the data-association path on a mid-size warehouse AMR, with the shapes and the clock:
nav_msgs/Odometry: float64[36] pose covariance (6×6 row-major over x, y, z, roll, pitch, yaw) = 288 bytes, plus 288 for twist, at 50 Hz → 28.8 kB/s of covariance on the wire.The cost that is not free: carrying P. This is where EKF-SLAM died, and the arithmetic is worth having ready.
| Operation | Complexity | 15-state INS @ 200 Hz | EKF-SLAM, 500 3-D landmarks (n = 1515) |
|---|---|---|---|
| Memory for P | O(n2) | 225 doubles = 1.8 kB | 2.29M doubles = 18.4 MB |
| Propagate P = FPFT + Q | O(n3) | 3,375 flops → 0.7 Mflop/s | 3.5×109 flops → 700 Gflop/s at 200 Hz — impossible |
| Update P for one 2-D measurement | O(mn2) | 450 flops | 4.6M flops × 30 meas × 10 Hz = 1.4 Gflop/s |
| Sliding-window BA instead: 10 keyframes, 200 landmarks | sparse + Schur | — | Schur-reduced 150×150 dense → ~1.1M flops per iteration, < 1 ms |
The last row is the punchline, and it is the answer to "why did the field move from filtering to optimisation?" It is not that the filter is wrong — it is that a filter maintains a dense covariance over everything it has ever seen, while a factor graph maintains a sparse information matrix and marginalises what it no longer needs. Same statistics, different data structure, three orders of magnitude in cost. The factor-graph lesson has the sparsity structure in detail.
Store the square root, not the covariance. Every serious back-end (√SAM, GTSAM, the square-root information filter in an INS) carries a Cholesky or QR factor rather than P itself, for one reason: the condition number of L is the square root of the condition number of P. If κ(P) = 1012 then in double precision (about 16 significant digits) you have four digits of signal left after the worst cancellation. In square-root form κ(L) = 106 and you keep ten. That is the difference between a filter that survives a long mission and one that produces a non-positive-definite P at minute forty.
| Form | What you store | Condition number | Positive-definiteness | Used by |
|---|---|---|---|---|
| Covariance | P (n×n symmetric) | κ | Must be enforced; can be lost to rounding | Textbook KF, simple EKFs |
| Information | Λ = P−1 | κ | Same risk | Information filters, marginalisation |
| Square-root covariance | L with P = LLT | √κ | Structural — LLT is PSD by construction | SR-UKF, Apollo-era navigation |
| Square-root information | R with Λ = RTR | √κ | Structural | √SAM, GTSAM, iSAM2, VINS-Mono marginalisation |
nav_msgs/Odometry.pose.covariance is a 36-element row-major 6×6 over (x, y, z, rot-x, rot-y, rot-z), in the frame given by header.frame_id for position and child_frame_id for twist — mixing those two up is the single most common odometry bug in the ecosystem. A leading element of −1 conventionally means "covariance unknown"; a block of exact zeros means "I did not bother", and downstream fusion nodes such as robot_localization will either ignore your message or treat it as infinitely precise depending on the node. Never publish zeros.From scratch first — the version you should be able to write from nothing:
python import numpy as np def polar_to_xy(r, th): return np.array([r * np.cos(th), r * np.sin(th)]) def jacobian_polar(r, th): # rows = outputs (x, y), cols = inputs (r, theta) return np.array([[np.cos(th), -r * np.sin(th)], [np.sin(th), r * np.cos(th)]]) def propagate(Sigma, J): """First-order covariance transport: Sigma_y = J Sigma J^T.""" return J @ Sigma @ J.T def mahalanobis2(y, S): """Squared Mahalanobis distance, via Cholesky. NEVER np.linalg.inv(S): forming the inverse squares the condition number and costs 3x more.""" L = np.linalg.cholesky(S) # S = L L^T, raises if S is not PD w = np.linalg.solve(L, y) # whiten: w = L^-1 y return float(w @ w) # ||w||^2 def ellipse_axes(Sigma, conf=0.95, chi2_2dof=5.991): """Semi-axis lengths and orientation of the confidence ellipse.""" vals, vecs = np.linalg.eigh(Sigma) # eigh: symmetric, ascending, stable k = np.sqrt(chi2_2dof) # 2.448 for 95% in 2-D, NOT 1.96 order = np.argsort(vals)[::-1] vals, vecs = vals[order], vecs[:, order] axes = k * np.sqrt(np.maximum(vals, 0.0)) angle = np.arctan2(vecs[1, 0], vecs[0, 0]) return axes, angle # The worked example from above, verified r, th = 10.0, np.radians(30.0) S_polar = np.diag([0.10**2, np.radians(2.0)**2]) S_xy = propagate(S_polar, jacobian_polar(r, th)) # S_xy = [[ 0.037962, -0.048431], [-0.048431, 0.093885]] # sigma_x = 0.1948 m, sigma_y = 0.3064 m print(mahalanobis2(np.array([0.3, 0.2]), S_xy)) # 12.95 -> outside the 5.991 gate
Now the production form. Three changes, and each one is a question you should be ready for:
python from scipy.linalg import cho_factor, cho_solve, solve_triangular def gate_batch(Y, S, thresh=5.991): """Y: (N, m) innovations sharing one S. Returns (d2, keep). One factorisation amortised over all N rows -- this is the difference between 15 flops and 15N flops on the hot path.""" L = np.linalg.cholesky(S) # O(m^3/3), once Wt = solve_triangular(L, Y.T, lower=True) # (m, N), O(N m^2) d2 = np.einsum('ij,ij->j', Wt, Wt) # column norms, no temporaries return d2, d2 < thresh def joseph_update(P, K, H, R): """Symmetric, positive-definite-preserving covariance update. Costs ~2x the naive (I-KH)P but is the standard in flight software.""" I = np.eye(P.shape[0]) A = I - K @ H P = A @ P @ A.T + K @ R @ K.T return 0.5 * (P + P.T) # re-symmetrise every step
The three questions those changes invite: (1) Why never inv? Forming S−1 costs about 3× a Cholesky solve and squares the condition number, so you lose twice as many significant digits for no benefit. (2) Why factor once for a batch? Because S depends on the landmark, not the candidate — hoisting the factorisation out of the inner loop is where the real speed-up lives. (3) Why Joseph? Because (I − KH)P is a difference of two nearly equal matrices; catastrophic cancellation can push an eigenvalue negative. Joseph form is a sum of two PSD terms, so it cannot.
Failure mode 1 — loss of positive definiteness.
numpy.linalg.LinAlgError: Matrix is not positive definite from the gate, thirty to sixty minutes into a mission — never at startup. Or, more insidiously, no exception at all: the Kalman gain quietly goes to zero and the filter stops responding to measurements while continuing to report a shrinking covariance.min(eigvalsh(P)) and the asymmetry norm(P - P.T, 'fro') / norm(P, 'fro') on every update. Healthy: minimum eigenvalue stays several orders above machine epsilon times the maximum eigenvalue; asymmetry stays below 10−12. When asymmetry starts climbing through 10−8, you have hours, not days.Failure mode 2 — degrees where radians belong. This one deserves its own bullet because the signature is so distinctive.
deg. A NIS that is off by roughly three thousand is never a modelling subtlety.Failure mode 3 — the Jacobian evaluated at a stale point.
The invariant EKF makes the linearisation error state-independent. Barrau and Bonnabel, "The Invariant Extended Kalman Filter as a Stable Observer" (IEEE Transactions on Automatic Control, 2017), showed that if you define the estimation error as a group element rather than a vector difference — η = X̂−1X on SE(3) or SE2(3) — then for a class of systems called group-affine, the error dynamics are exactly log-linear. The Jacobian no longer depends on the estimate, so the two great EKF sins — the covariance depending on the (wrong) current estimate, and spurious observability of unobservable directions — simply vanish. Hartley et al., "Contact-Aided Invariant EKF for Legged Robot State Estimation" (IJRR 2020), put this on a real biped and it is now the default formulation in legged locomotion. If the question is "what would you do differently today", this is the answer that shows you read past the textbook.
Learned covariance. Brossard, Barrau and Bonnabel, "AI-IMU Dead-Reckoning" (IEEE Transactions on Intelligent Vehicles, 2020), train a small convolutional network that looks at raw IMU windows and outputs the measurement covariance for a set of pseudo-measurements — not the state, only the noise model. On KITTI it reaches around 1% translational drift using an IMU alone. The idea generalises: the estimator stays classical and auditable, and learning is confined to the one component nobody can hand-tune well. Liu et al., "Deep Inference for Covariance Estimation" (ICRA 2018), makes the same argument for visual measurements, predicting a Cholesky-parameterised covariance so the output is positive definite by construction.
The non-learned alternative is still competitive. Sigma-point transforms (the UKF) match the propagated mean and covariance to third order for Gaussian inputs at a cost of 2n+1 evaluations of the true nonlinear function — no Jacobian, no derivation, no chain rule bug. For n = 15 that is 31 propagations per step, which at 200 Hz is entirely affordable on modern hardware and was not in 2001. A large part of "the EKF is standard" is inertia from a compute budget that no longer applies.
The teal ellipse is the linearised 95% contour. The orange cloud is 900 samples pushed through the true nonlinear map. Push the bearing sigma up and watch the cloud bend into a banana that no ellipse can cover, and watch the true mean walk away from the linearised one.
What to look for. At σbearing = 2° the cloud fills the ellipse neatly and the mean shift readout is under a centimetre — the EKF is telling the truth. Take it past 10° and two things go wrong at once: the cloud curves out of the ellipse at the ends, and the true mean slides toward the sensor. Only the second one is a bias, and a bias is far more damaging than an inflated variance, because averaging more measurements does not remove it. This is the plot that explains why the UKF exists.
Derive least squares. It sounds like a warm-up. It is not. Writing down x̂ = (HTH)−1HTz immediately and stopping is what memorising a formula looks like. Starting from a probability distribution is what understanding looks like — and it is where all the interesting structure lives.
Here is the single most valuable structural fact in estimation, and it is worth stating before you write anything:
Start where the physics is. You have a measurement model: the sensor reports z, the world state is x, and they are related linearly through a known matrix H plus additive noise.
H is the observation matrix (m×n): row i says which combination of state elements measurement i sees. R is the measurement noise covariance (m×m): it says how wrong each sensor is and whether their errors are related. Nothing about squares yet.
The probability of seeing z given a hypothesised x is the Gaussian density evaluated at the residual:
Maximum likelihood means picking the x that makes the data least surprising. Logarithms are monotone, and the normalising constants do not depend on x, so maximising the density is the same as minimising the exponent:
Differentiate and set to zero. Expand the quadratic: J = ½(zTR−1z − 2xTHTR−1z + xTHTR−1Hx). Using ∂(xTAx)/∂x = 2Ax for symmetric A and ∂(xTb)/∂x = b:
The matrix on the left, Λ = HTR−1H, is the information matrix (also called the Fisher information for this linear-Gaussian model — see Fisher Information & the CRLB). Its inverse is the covariance of the estimate. Two facts fall out of that, and both carry weight:
Now set R = σ2I. Then R−1 = I/σ2, the scalar cancels from both sides, and the normal equations collapse to HTHx̂ = HTz — ordinary least squares. OLS is not a different method. It is this method with the assumption that every sensor is equally good and none of their errors are related. Say it that way and you have also answered "when should I not use OLS?"
Three sensors measure the same distance to a beacon. Two are good laser rangefinders, one is a cheap ultrasonic:
| Sensor | Reading zi | σi | Weight wi = 1/σi2 |
|---|---|---|---|
| Laser A | 10.20 m | 0.10 m | 100 |
| Laser B | 9.80 m | 0.10 m | 100 |
| Ultrasonic | 12.00 m | 1.00 m | 1 |
OLS — every sensor equally trusted. H is a column of ones, so HTH = 3 and HTz = 10.20 + 9.80 + 12.00 = 32.00:
WLS — trust in proportion to precision. Now HTR−1H = ∑wi and HTR−1z = ∑wizi:
Look at what happened. OLS put the estimate at 10.667 — 66 cm away from where the two precise sensors agree, because it let a sensor with ten times the noise cast an equal vote. WLS lands at 10.010, one centimetre from the laser average, and reports a standard deviation of 7 cm — better than either laser alone, because information added.
MAP — add what you already believed. Suppose a prior from the previous timestep says x ~ N(9.50, 0.202), so the prior information is 1/0.04 = 25. The MAP normal equation adds the prior as one more information term:
The prior pulled the estimate down by 5.6 cm and shrank the reported uncertainty from 7.05 cm to 6.65 cm. Both effects are correct: a prior is information, and information both moves the estimate and tightens it. This is also exactly a Kalman update — the prior is the predicted state, the measurements are the innovation, and the weighting is the Kalman gain in disguise.
| Name | The expression being minimised | Estimate | Reported σ |
|---|---|---|---|
| OLS | ½‖z − Hx‖2 | 10.667 m | — (assumes one σ) |
| WLS / GLS / MLE | ½(z − Hx)TR−1(z − Hx) | 10.010 m | 0.0705 m |
| MAP / Kalman / Tikhonov | above + ½(x − x̄)TΛ0(x − x̄) | 9.954 m | 0.0665 m |
Scalars hide the matrix structure, so do a line fit. Three points, model y = a + bx, unknowns θ = (a, b):
Step 1 — assemble HTH by accumulating over rows, because that is how you would actually build it in code and it makes the sparsity obvious. Each row contributes hihiT:
| Row | hi | hihiT | hizi |
|---|---|---|---|
| 1 | (1, 0) | [[1, 0], [0, 0]] | (1, 0) |
| 2 | (1, 1) | [[1, 1], [1, 1]] | (3, 3) |
| 3 | (1, 2) | [[1, 2], [2, 4]] | (4, 8) |
| sum | — | [[3, 3], [3, 5]] | (8, 11) |
Step 2 — solve the 2×2. det = 3×5 − 3×3 = 15 − 9 = 6, so (HTH)−1 = (1/6)[[5, −3], [−3, 3]]:
Step 3 — residuals, and the check that costs nothing. Predictions at x = 0, 1, 2 are 1.1667, 2.6667, 4.1667, so r = (−0.1667, +0.3333, −0.1667). Their sum is exactly zero — guaranteed whenever the model has an intercept, because the first normal equation is the statement ∑ri = 0. If your residuals do not sum to zero on a model with a constant term, you have a bug, full stop.
Step 4 — the parameter covariance. SSR = 0.02778 + 0.11111 + 0.02778 = 0.16667. With n − p = 3 − 2 = 1 degree of freedom, σ̂2 = 0.16667, and
So σa = √0.13889 = 0.373 and σb = √0.08333 = 0.289, with a negative correlation of −0.0833/(0.373×0.289) = −0.77. Intercept and slope are anti-correlated, which is physically obvious: raise the line at x = 0 and you must tilt it down to keep passing through the data. That correlation is why you must never quote slope and intercept uncertainty independently.
Now down-weight the third point with w = (1, 1, 0.01) — the same machinery, one extra factor per row:
| Row | wi | wihihiT | wihizi |
|---|---|---|---|
| 1 | 1 | [[1, 0], [0, 0]] | (1, 0) |
| 2 | 1 | [[1, 1], [1, 1]] | (3, 3) |
| 3 | 0.01 | [[0.01, 0.02], [0.02, 0.04]] | (0.04, 0.08) |
| sum | — | [[2.01, 1.02], [1.02, 1.04]] | (4.04, 3.08) |
The fit swung from (1.167, 1.500) to (1.010, 1.971) — essentially the exact line through the first two points, y = 1 + 2x. One weight changed from 1 to 0.01 and the slope moved by 31%. Remember that sensitivity number. It is what makes Chapter 3 necessary: if a weight can do that when you set it deliberately, an outlier can do it when you do not.
Every optimisation-based estimator you will meet in robotics is this cost function, assembled from many small terms. The engineering question is: what does assembling and solving it actually cost?
Take a representative sliding-window visual-inertial system — the shape of VINS-Mono, OKVIS, or a modern VIO back-end:
The factor-graph lesson has the sparsity structure and the elimination ordering in full. What matters here is the mapping: every factor in the graph is one term in the sum you just derived, its information matrix is that term's R−1, and the graph's edges are exactly the non-zero blocks of H.
Where R actually comes from, in descending order of honesty:
| Source | Example | Trustworthiness |
|---|---|---|
| Sensor datasheet | Gyro noise density 0.005 °/s/√Hz → σ2 = (0.005)2 × rate | Optimistic. Datasheets quote lab conditions and best-case units. |
| Allan variance from a static log | 6–12 h of stationary IMU data → noise density and bias instability read off the plot | The right answer for an IMU. Do this once per hardware revision. |
| Calibration residual statistics | Reprojection RMS from the checkerboard fit → pixel σ | Good, but optimistic: the target is planar, well-lit and centred. |
| Empirical, from a NIS sweep | Scale R until average NIS lands in its acceptance band on a recorded bag | Honest, and it absorbs the unmodelled effects. This is what teams actually ship. |
| Learned per-measurement | A network outputs a Cholesky factor per observation | Best accuracy, needs the guard rails from Chapter 1's frontier section. |
The numerical decision every back-end makes: do you form the normal equations or factor the Jacobian directly?
| Method | Approximate cost (m rows, n cols) | Effective condition number | When to use it |
|---|---|---|---|
| Normal equations + Cholesky | mn2 + n3/3 | κ(H)2 | Well-conditioned, m ≫ n, speed matters. Standard in SLAM back-ends, where the sparsity makes it a landslide win. |
| Householder QR on H | 2mn2 − 2n3/3 | κ(H) | The default for dense, moderately conditioned problems. Roughly 2× the cost, half the digits lost. |
| SVD | ≈ 2mn2 + 11n3 | κ(H) | Rank-deficient or nearly so: it gives you the null space explicitly, which is how you find your unobservable directions. |
The conditioning row is the one to remember. Forming HTH squares the condition number, so if κ(H) = 106 you lose twelve significant digits instead of six. In double precision (about sixteen) that is survivable; in single precision (about seven) it is fatal, which is precisely why square-root formulations exist and why an embedded team running float32 will care.
python import numpy as np def wls(H, z, R=None, x0=None, P0=None): """One function, four estimators. R = None, no prior -> OLS R given, no prior -> WLS / GLS / MLE R and prior given -> MAP == the Kalman update in information form Returns (x_hat, P_hat).""" H = np.atleast_2d(H); m, n = H.shape Rinv = np.eye(m) if R is None else np.linalg.inv(np.atleast_2d(R)) Lam = H.T @ Rinv @ H # information matrix (n, n) eta = H.T @ Rinv @ z # information vector (n,) if x0 is not None: # fold the prior in -- it is just one more term P0inv = np.linalg.inv(P0) Lam = Lam + P0inv eta = eta + P0inv @ x0 L = np.linalg.cholesky(Lam) # raises if the problem is rank deficient x_hat = np.linalg.solve(L.T, np.linalg.solve(L, eta)) P_hat = np.linalg.inv(Lam) # only if a caller actually needs the covariance return x_hat, P_hat # The worked example, all three estimators from one call H = np.ones((3, 1)); z = np.array([10.2, 9.8, 12.0]) R = np.diag([0.1**2, 0.1**2, 1.0**2]) print(wls(H, z)[0]) # [10.667] OLS print(wls(H, z, R)[0]) # [10.010] WLS, sigma 0.0705 print(wls(H, z, R, np.array([9.5]), np.array([[0.04]]))[0]) # [9.954], sigma 0.0665
That version is deliberately naive so the structure is visible. Here is what you write when it has to run:
python from scipy.linalg import cho_factor, cho_solve, solve_triangular def wls_sqrt(H, z, sigma, x0=None, sigma0=None): """Square-root form: whiten the rows, then let QR do the work. Never forms H^T H, so the condition number is kappa(H), not kappa(H)^2. `sigma` is the per-row standard deviation (a diagonal R).""" A = H / sigma[:, None] # whiten: R^-1/2 H b = z / sigma if x0 is not None: # a prior is just extra pseudo-measurements A = np.vstack([A, np.diag(1.0 / sigma0)]) b = np.concatenate([b, x0 / sigma0]) Q, Rr = np.linalg.qr(A) # A = Q R, R upper triangular x_hat = solve_triangular(Rr, Q.T @ b) # one triangular solve # Cov = (A^T A)^-1 = R^-1 R^-T -- one more triangular solve, no inverse of A^T A Rinv = solve_triangular(Rr, np.eye(Rr.shape[0])) return x_hat, Rinv @ Rinv.T
NormalPrior cost function; GTSAM with a PriorFactor. Same algebra, three names.Failure mode 1 — rank deficiency and gauge freedom.
Failure mode 2 — units in R, the highest-frequency real bug in this whole area.
Final cost = ½∑r2 over whitened residuals; for a correctly weighted problem the expectation is (M − N)/2. With M = 2,231 residuals and N = 750 parameters, that is 740. If Ceres reports 1.4×105, the ratio is 189 — either your weights are ~189× too tight or you have outliers, and Chapter 3 tells you how to tell those two apart.Failure mode 3 — the normal equations eating your precision.
np.linalg.cond(H) with np.linalg.cond(H.T @ H). The second should be the square of the first; when it comes back much larger than that, the product has already lost digits. Rule of thumb: you have about 16 − log10κ(HTH) reliable digits in double precision. When that number drops below about 4, switch to QR.jacobi preconditioner) before blaming the solver.Incremental solving. Kaess et al., "iSAM2: Incremental Smoothing and Mapping Using the Bayes Tree" (IJRR 2012), is the reason optimisation replaced filtering rather than merely competing with it. It keeps a factorisation in a data structure (the Bayes tree) that supports adding a factor and re-solving only the part of the tree that changed, so the cost of an update depends on how much the new measurement disturbs the graph, not on the graph's size. Loop closure is the expensive case, and it is expensive exactly when it should be.
Square-root everything. Demmel, Sommer, Cremers and Usenko, "Square Root Bundle Adjustment for Large-Scale Reconstruction" (CVPR 2021), and the sliding-window follow-up "Square Root Marginalization for Sliding-Window Bundle Adjustment" (ICCV 2021), do landmark elimination by nullspace projection on the square-root Jacobian rather than by Schur complement on the Hessian. The payoff is the conditioning argument above made concrete: their solver runs in single precision at accuracy the Schur-complement version cannot reach in single precision, which matters enormously on embedded and mobile GPUs.
Differentiable least squares. Pineda et al., "Theseus: A Library for Differentiable Nonlinear Optimization" (NeurIPS 2022), makes the whole Gauss–Newton solve a differentiable layer in PyTorch. The consequence for this chapter is direct: R stops being something you tune and becomes something you learn, by backpropagating a downstream task loss through the solver into the weights. The estimator stays a least-squares estimator — interpretable, with a residual you can inspect — and only the noise model is learned.
Certifiable global optimality. Rosen et al., "SE-Sync: A certifiably correct algorithm for synchronization over the special Euclidean group" (IJRR 2019), solves pose-graph SLAM through a semidefinite relaxation that returns a certificate that the answer is the global optimum, not just a local one. This kills the oldest objection to optimisation-based SLAM — "how do you know you are not in a local minimum?" — for the pose-graph case. It is a good thing to have an opinion about, because the honest limitation is that the certificate holds only under bounded noise and the relaxation is not tight once outliers appear.
The three range measurements from the worked example, plus a prior. Each source contributes 1/σ2 units of information, and the bar at the bottom is that stack. Switch estimator, then push the ultrasonic sensor's σ down toward the lasers and watch its slice of the bar — and its pull on the answer — grow.
Three things to try. (1) In OLS, drag the ultrasonic σ anywhere — the answer does not move, because OLS cannot hear you. That is the failure. (2) Switch to WLS and drag it from 1.00 to 0.10: the estimate walks from 10.01 up toward 10.67 as the bad sensor earns an equal vote. (3) Switch to MAP and drag the prior σ down to 0.05: the prior's information is 400, twice the two lasers combined, and the posterior collapses onto the prior mean. That last one is what an over-tight marginalisation prior does to a VIO window, and it is why "the estimate never moves from the initial guess" is a weighting bug.
Your feature matcher returns one wrong correspondence in forty. What does that do to your bundle adjustment — and how do you fix it?
Chapter 2 derived the quadratic cost from the Gaussian likelihood. That derivation is exactly correct — and exactly the problem, because your residuals are not Gaussian. A Gaussian says a 10σ residual happens once in 1023 trials. A feature matcher on a repetitive warehouse shelf produces one every forty matches. The cost function does not know that, so it treats the 10σ residual as a catastrophe to be fixed at any price.
Worked example 1 — one outlier in four points flips the sign of the slope. The truth is y = 1 + 2x. Three points are clean, and the fourth has a bad association:
Assemble by rows, exactly as in Chapter 2. HTH accumulates [[1, x], [x, x2]] over x = 0, 1, 2, 3:
The true slope is +2. Least squares reports −0.1. One outlier in four points did not degrade the fit; it inverted it. And the reported covariance will be a perfectly respectable-looking matrix, because nothing in the normal equations has any concept of a measurement being wrong.
Now look at the residuals, because this is the part nobody expects. At θ̂ = (2.4, −0.1) the predictions are 2.4, 2.3, 2.2, 2.1:
| x | z | prediction | residual r | Is this the outlier? |
|---|---|---|---|---|
| 0 | 1 | 2.4 | −1.4 | no |
| 1 | 3 | 2.3 | +0.7 | no |
| 2 | 5 | 2.2 | +2.8 ← largest | no |
| 3 | 0 | 2.1 | −2.1 | yes |
To reason about this rather than guess, describe a cost by three related functions of the (whitened) residual r:
| Function | Definition | What it means physically |
|---|---|---|
| ρ(r) | the cost itself | How much this residual contributes to the total you are minimising. |
| ψ(r) = ρ′(r) | the influence function | How hard this measurement pulls on the solution. This is the one that matters. |
| w(r) = ψ(r)/r | the weight | The equivalent least-squares weight, which is what you actually implement. |
Least squares: ρ = ½r2, ψ = r, w = 1. The influence function is a straight line through the origin with no upper bound. A residual ten times larger pulls ten times harder, forever. That single sentence is the entire diagnosis, and it is what you should say before proposing any fix.
Huber (Huber, 1964) keeps the quadratic near zero and switches to linear beyond a threshold δ:
The δ/2 in the linear branch is not decoration: it is exactly what makes ρ continuous at |r| = δ, since ½δ2 = δ(δ − δ/2). Deriving that on the spot is the difference between understanding the function and recalling it.
Cauchy (also called the Lorentzian) never goes linear — it goes logarithmic:
Worked example 2 — the three costs at three residuals, every number. Take δ = 1 and c = 1:
| r | L2: ρ / ψ | Huber: ρ / ψ / w | Cauchy: ρ / ψ / w | |||||
|---|---|---|---|---|---|---|---|---|
| 0.5 | 0.125 | 0.500 | 0.125 | 0.500 | 1.000 | 0.112 | 0.400 | 0.800 |
| 2.0 | 2.000 | 2.000 | 1.500 | 1.000 | 0.500 | 0.805 | 0.400 | 0.200 |
| 10.0 | 50.00 | 10.00 | 9.500 | 1.000 | 0.100 | 2.308 | 0.099 | 0.0099 |
Read the ψ columns down. At r = 10 the L2 measurement pulls with force 10. Huber pulls with force 1 — capped, but still pulling, and pulling as hard as a measurement at r = 1, forever. Cauchy pulls with force 0.099, which is less than a measurement at r = 0.5. Check the arithmetic on that Cauchy entry: ψ = 10/(1 + 100) = 10/101 = 0.09901, and ρ = 0.5×log(101) = 0.5×4.6151 = 2.3076.
Here is the derivation that makes robust estimation implementable, and it is three lines. You are minimising ∑i ρ(ri) with ri = zi − hiTθ. Set the gradient to zero:
Now use the definition w(r) = ψ(r)/r, so ψ(ri) = w(ri) ri:
Worked example 3 — IRLS from a least-squares start, and why it stalls. Continue the four-point problem. Residuals at the OLS solution are r = (−1.4, +0.7, +2.8, −2.1). Take Huber with δ = 1:
Accumulate HTWH by rows, wi[[1, xi], [xi, xi2]]:
| x | w | w[[1, x], [x, x2]] | w·z·(1, x) |
|---|---|---|---|
| 0 | 0.7143 | [[0.7143, 0], [0, 0]] | (0.7143, 0) |
| 1 | 1.0000 | [[1, 1], [1, 1]] | (3.0000, 3.0000) |
| 2 | 0.3571 | [[0.3571, 0.7143], [0.7143, 1.4286]] | (1.7857, 3.5714) |
| 3 | 0.4762 | [[0.4762, 1.4286], [1.4286, 4.2857]] | (0, 0) — z = 0 |
| sum | [[2.5476, 3.1429], [3.1429, 6.7143]] | (5.5000, 6.5714) | |
The slope moved from −0.100 to −0.075. It is still negative. Five more iterations bring it to (2.067, −0.050) — converging, slowly, to a fit that is still completely wrong. Huber from a least-squares start, on a masked problem, does not recover. That is not a bug in Huber; it is the direct consequence of bounded-but-non-zero influence plus a starting point where the outlier already won.
And the standard scale estimate makes it worse, not better. The textbook says to set δ from a robust scale estimate. The median absolute deviation is the usual choice:
The 1.4826 is 1/Φ−1(0.75) = 1/0.6745, the factor that makes MAD agree with the standard deviation for genuinely Gaussian data. Compute it on r = (−1.4, 0.7, 2.8, −2.1): sorted, (−2.1, −1.4, 0.7, 2.8), so median(r) = (−1.4 + 0.7)/2 = −0.35. The absolute deviations are |−1.4 + 0.35| = 1.05, |0.7 + 0.35| = 1.05, |2.8 + 0.35| = 3.15, |−2.1 + 0.35| = 1.75. Sorted: (1.05, 1.05, 1.75, 3.15), median = (1.05 + 1.75)/2 = 1.40.
Every residual is at or below 2.8, so with δ = 2.792 every weight is essentially 1 and the robust fit is the least-squares fit. The scale estimate was computed from a contaminated fit, so it inherited the contamination and then licensed it. This is masking closing the loop on itself.
Worked example 4 — the same problem, started from a consensus estimate. Suppose RANSAC picked the two points (0, 1) and (1, 3) and reported θ0 = (1, 2). Residuals are then (0, 0, 0, −7): the outlier finally stands alone. Use Cauchy with c = 1, so w = 1/(1 + r2):
The first three rows contribute the clean [[3, 3], [3, 5]] and (9, 13) from Chapter 2's identical accumulation; the fourth contributes 0.02×[[1, 3], [3, 9]] = [[0.02, 0.06], [0.06, 0.18]] and 0.02×0×(1, 3) = (0, 0):
One reweight, and the slope is 1.866 against a truth of 2.000. A second iteration gives (1.0986, 1.8511) and it settles near (1.100, 1.848). Compare the three:
| Method | Start | Intercept (true 1.0) | Slope (true 2.0) | Verdict |
|---|---|---|---|---|
| OLS | — | 2.400 | −0.100 | Destroyed — sign inverted |
| Huber IRLS, δ = 1 | OLS | 2.067 | −0.050 | Stalled — masked |
| Huber IRLS, δ from MAD | OLS | 2.398 | −0.102 | No effect — scale inflated by the contamination (every weight ≥ 0.994) |
| Cauchy IRLS, c = 1 | RANSAC (1, 2) | 1.100 | 1.848 | Recovered |
Four points is a deliberately brutal case — with 25% contamination and a high-leverage outlier at the end of the x range, no estimator can be comfortable. The lesson generalises: the initialisation matters more than the kernel. With forty points and 15% outliers, plain Huber IRLS from an OLS start recovers cleanly, which is exactly what the code lab below demonstrates.
The loss-functions lesson covers this family from the machine-learning side, where the same shapes appear as smooth L1, Charbonnier and Geman–McClure. The estimation vocabulary is different; the functions are identical.
In a real back-end a robust kernel is one line wrapped around a residual block. The design questions are which blocks get one, what δ means, and what it costs.
c++ // Ceres: the loss function wraps a cost function, per residual block. problem.AddResidualBlock(reproj_cost, new ceres::HuberLoss(1.345), pose, point); problem.AddResidualBlock(imu_cost, nullptr, pose_i, vel_i, bias_i, pose_j); problem.AddResidualBlock(prior_cost, nullptr, pose_0); // GTSAM equivalent: // noiseModel::Robust::Create(noiseModel::mEstimator::Huber::Create(1.345), // noiseModel::Isotropic::Sigma(2, 1.0));
Note which blocks got nullptr. That is the design decision, and it is the one worth being able to defend:
| Factor type | Robust kernel? | Why |
|---|---|---|
| Reprojection / feature match | Yes | Comes from data association, which is exactly where wrong answers are generated. |
| Loop closure | Yes, aggressively | A false loop closure is the most destructive error in SLAM — it welds two unrelated places together and no later optimisation undoes it. |
| Scan matching / ICP constraint | Yes | Can converge to a wrong local minimum in a self-similar corridor. |
| IMU preintegration | No | Physics, not association. If the IMU factor is wrong, the model or the calibration is wrong, and down-weighting hides it. |
| Marginalisation prior | Never | It is the compressed memory of everything the window has forgotten. Down-weighting it silently deletes history. |
| Gauge / anchor prior | Never | It exists to remove a null space. A robust kernel can switch it off and re-introduce the singularity. |
The tuning constants, and where they come from. Each is chosen so that if the data really is Gaussian, the robust estimator retains 95% of the efficiency of plain least squares — you pay 5% more variance as insurance:
| Kernel | Constant for 95% asymptotic efficiency | Convex? | Influence as r → ∞ |
|---|---|---|---|
| Huber | δ = 1.345σ | Yes | → δ (constant) |
| Cauchy | c = 2.3849σ | No | → 0, like c2/r |
| Tukey biweight | c = 4.685σ | No | Exactly 0 beyond c (hard rejection) |
| Geman–McClure | — (scale-free family) | No | → 0, like 1/r3 |
What it costs. Less than people fear, and the cost is in iterations rather than in flops:
| Item | Extra cost | On the 750-parameter, 2,231-residual window from Chapter 2 |
|---|---|---|
| Computing weights each iteration | O(M) — one division per residual | ~2,231 flops. Immeasurable next to the 1.1 Mflop factorisation. |
| Extra Gauss–Newton iterations | typically 1.3–2× | The real cost. A 20 ms solve becomes 26–40 ms. |
| Graduated non-convexity (3–5 outer stages) | 3–5× | 60–100 ms. Fine for loop closure, not for a 100 Hz pose loop. |
| RANSAC pre-stage | k minimal solves | At 60% inliers with a 5-point model, ~57 iterations for 99% confidence. |
The gate and the kernel are the same idea at different hardness. Chi-square gating from Chapter 1 is a truncated least-squares cost — weight 1 inside the gate, weight 0 outside. A robust kernel is the soft version. Production systems use both: gate first to remove obvious garbage cheaply, then a robust kernel to handle what survives. The gate is a decision you cannot take back; the kernel is one the optimiser revises on the next iteration.
python import numpy as np def huber_weight(r, delta): return np.minimum(1.0, delta / np.maximum(np.abs(r), 1e-12)) def cauchy_weight(r, c): return 1.0 / (1.0 + (r / c)**2) def mad_sigma(r): """Robust scale. 1.4826 = 1/Phi^-1(0.75), so this agrees with the standard deviation on clean Gaussian data.""" return 1.4826 * np.median(np.abs(r - np.median(r))) def irls(H, z, weight_fn, theta0=None, iters=30, tune=1.345, tol=1e-10): """Iteratively reweighted least squares. theta0 SHOULD come from RANSAC or LMedS -- an OLS start on a masked problem converges to the wrong basin (see the worked example).""" theta = np.linalg.lstsq(H, z, rcond=None)[0] if theta0 is None else np.array(theta0, float) for _ in range(iters): r = z - H @ theta s = max(mad_sigma(r), 1e-9) # re-estimate scale every iteration w = weight_fn(r, tune * s) Hw = H * w[:, None] # row scaling; never form a dense diag(w) new = np.linalg.solve(H.T @ Hw, Hw.T @ z) if np.max(np.abs(new - theta)) < tol: theta = new; break theta = new return theta, w # Diagnostics to print every single time -- see DEBUG below def weight_health(w, expected_outlier_rate=0.15): n_eff = w.sum()**2 / (w**2).sum() # effective sample size frac_down = (w < 0.5).mean() return dict(n_eff_frac=n_eff / len(w), frac_downweighted=frac_down, looks_right=abs(frac_down - expected_outlier_rate) < 0.10)
The production one-liners, and what each one hides:
python from scipy.optimize import least_squares # f_scale IS delta, and it applies to the residual you return -- so return # WHITENED residuals or f_scale means nothing. sol = least_squares(residual_fn, theta0, loss='huber', f_scale=1.345, jac=jac_fn) # loss: 'linear' (L2) | 'soft_l1' (pseudo-Huber) | 'huber' | 'cauchy' | 'arctan' # statsmodels: the classical M-estimator with scale handling built in # sm.RLM(z, H, M=sm.robust.norms.HuberT(t=1.345)).fit(scale_est='mad') # OpenCV, when the model is geometric: CONSENSUS first, polish after # cv2.findFundamentalMat(p1, p2, cv2.USAC_MAGSAC, 1.0, 0.999) # then re-fit on the inlier set with a robust kernel
Failure mode 1 — masking: the robust fit equals the least-squares fit.
HuberLoss, the final cost dropped slightly, and the estimate barely moved. Everyone concludes "there were no outliers".Failure mode 2 — δ too small: everything becomes an outlier.
Failure mode 3 — a robust kernel on a factor that is not an outlier source.
Graduated non-convexity puts the convexity trade back on the table. Yang, Antonante, Tzoumas and Carlone, "Graduated Non-Convexity for Robust Spatial Perception: From Non-Minimal Solvers to Global Outlier Rejection" (IEEE RA-L, 2020), start with the kernel's scale parameter so large that the cost is effectively convex, solve, then shrink the scale and re-solve, three to five times. You get the outlier rejection of a redescending kernel with something close to the initialisation-independence of a convex one, and — the practical payoff — no RANSAC stage at all for many problems. To "Huber or Cauchy?", GNC is the answer that recognises the question as a false dichotomy.
Learn the shape of the kernel instead of picking one. Barron, "A General and Adaptive Robust Loss Function" (CVPR 2019), defines a single two-parameter family whose shape parameter α recovers L2 (α = 2), Charbonnier (α = 1), Cauchy (α = 0), Geman–McClure (α = −2) and Welsch (α → −∞) as special cases. Because the family is differentiable in α, you can make α a free parameter and let the optimiser decide how robust to be — per problem, or per residual type. It became the default loss in a great deal of neural depth and flow work for exactly this reason.
Certifiable robustness at extreme outlier rates. Yang, Shi and Carlone, "TEASER: Fast and Certifiable Point Cloud Registration" (IEEE T-RO, 2021), combines truncated least squares with a decoupled estimation of scale, rotation and translation, and returns a certificate of global optimality. It handles outlier rates above 95% on point-cloud registration — a regime where RANSAC's required iteration count is astronomical. Worth knowing as the existence proof that "robust" and "certified" are not contradictory.
Treat the outlier decision as a variable. Sünderhauf and Protzel, "Switchable Constraints for Robust Pose Graph SLAM" (IROS 2012), attach a continuous switch to every loop closure and let the optimiser turn it off, with a prior penalising the switch. Agarwal et al.'s Dynamic Covariance Scaling (ICRA 2013) then showed this is equivalent to a particular robust kernel with a closed-form weight — a nice unification: the switch is w(r). Knowing those two lines of work are the same idea is a good thing to have in your pocket.
Three panels for the same three costs. ρ is what you minimise, ψ is how hard a residual pulls, w is the weight you implement. Drag the residual marker out to r = 10 and read the middle panel: that number is what decides whether one bad match owns your solution.
HuberLoss(1.345) to the bundle adjuster changed the answer by less than a millimetre, and concludes the dataset has no outliers. You are unconvinced. What do you ask them to plot, and what would confirm your suspicion?Everything in the previous three chapters exists so that you can answer one question with your hands instead of your mouth: what happens to my estimate when one measurement is wrong?
Below is a live fitting bench. Fourteen points sit near the line y = 1 + 2x. Three estimators fit them simultaneously — least squares, Huber and Cauchy — and the panel on the right draws each estimator's influence function ψ(r) with a dot showing where the point you are dragging sits on it.
That right-hand panel is the entire lesson. When you drag a point away from the line, watch the dot on the L2 curve climb forever while the Huber dot flattens onto its ceiling and the Cauchy dot turns around and comes back down. The lines on the left move because the dots on the right moved. Cause and effect, on one screen.
Drag any point (mouse or finger). The three fits re-solve on every frame. The right panel shows ψ(r) for each cost with the dragged point marked, and the bars underneath show what fraction of the total pull on each estimator comes from that single point. The last button switches the Cauchy solve's initialisation between the Huber fit (sensible) and the line through the marked point and its neighbour (adversarial) — the readout prints both costs and the gap between them either way, so you can find the second basin without pressing anything.
Do these in order. Each one isolates a claim from an earlier chapter, and each has a number you should be able to predict before you look.
Experiment 1 — clean data: all three agree. Press Reset. The three lines lie on top of one another and the fitted slope is within a few hundredths of 2.000. This is the point of the 95%-efficiency tuning constants from Chapter 3: a robust estimator costs you almost nothing when there is nothing to be robust against. If the robust fits were visibly worse here, δ and c would be too small.
Experiment 2 — one outlier, dragged slowly. Grab a middle point and pull it straight down. Watch three things at once:
Now read the share-of-pull bars under the influence panel, because that is where the surprise is. On clean data every point owns about 1/14 = 7.1% of the total pull, and the marked point reads 6.9%. Drag it down and the three bars do three different things — and only one of them does what the picture in your head predicts.
The L2 bar does not climb forever. It locks onto exactly 50.0% and stays there. Pull the point to the bottom of the plot, to a residual of −17.5, and the red bar reads 50.0%. Set benPts[6].y = -1000 in the console, so the residual is −937, and it still reads 50.0%. The reason is the two-line proof further down this chapter: at the least-squares optimum ∑ri = 0, so if the outlier is the lone opposite-sign residual then ∑|ri| = 2|rk| identically, and shareak = 1/2 for every configuration. One half is not a big number the outlier happens to reach; it is a ceiling that no configuration can exceed, and the moment the outlier becomes the lone opposite-sign residual it is also a floor. Anyone who tells you a single point can own 60% or 80% of a least-squares gradient has never printed the number.
Here is the whole drag, measured on the bench's own solver. Every cell is what the bars print, to four decimals instead of the one the canvas has room for:
| y of the marked point | r against the L2 fit | L2 bar | Huber bar (δ = 1) | r against the Cauchy fit | Cauchy bar (c = 1) |
|---|---|---|---|---|---|
| 9.976 (untouched) | −0.325 | 6.85% | 6.85% | −0.296 | 7.57% |
| 9.5 | −0.766 | 14.94% | 14.94% | −0.748 | 12.83% |
| 9.2 | −1.044 | 19.44% | 18.7564% ← locks here | −1.046 | 13.31% ← Cauchy's peak |
| 9.0 | −1.230 | 22.17% | 18.7564% | −1.247 | 13.03% |
| 8.0 | −2.157 | 32.68% | 18.7564% | −2.261 | 10.13% |
| 6.0 | −4.012 | 43.15% | 18.7564% | −4.278 | 6.21% |
| 4.0 | −5.867 | 48.00% | 18.7564% | −6.285 | 4.39% |
| 1.378 | −8.299 | 50.0000% ← locks here | 18.7564% | −8.913 | 3.16% |
| −3.024 (the Throw one outlier preset) | −12.382 | 50.0000% | 18.7564% | −13.319 | 2.14% |
| −8.5 (the drag floor) | −17.460 | 50.0000% | 18.7564% | −18.797 | 1.53% |
| −30 (console only) | −37.401 | 50.0000% | 18.7564% | −40.300 | 0.72% |
| −1000 (console only) | −937.049 | 50.0000% | 18.7564% | −1010.303 | 0.03% |
benPts[benSel].y = Math.max(benYlo + 0.5, …) with benYlo = −9, so the furthest you can drag point 6 is y = −8.5, a residual of −17.46. That is already past every threshold that matters, but if you want the 103 row yourself, open the console and run benPts[6].y = -1000; drawBench(). Naming the clamp is the point: an instrument that silently bounds its own input will make you believe a quantity saturated when it was only fenced, and "what does your visualiser clamp?" is a fair question to ask of any demo you build.The Huber bar locks too, at exactly 18.7564%, and that is the more surprising of the two because nobody teaches it. Read down the Huber column: from y = 9.2 to y = −1000 the number does not move in the fourth decimal. Neither does the fit. Print it and you get a = 1.06419040, b = 1.98972574 at a residual of −13, and a = 1.06419040, b = 1.98972574 at a residual of 106. Identical to eight decimals. The teal line in Experiment 2 does not "stop moving much" — past δ it stops moving at all, and there is a clean reason.
Watch what the outlier contributes to the weighted normal equations once its weight is wk = δ/|rk|. Its four accumulations are wk, wkxk, wkyk and wkxkyk. As |yk| grows, wk decays like 1/|yk|, so the first two go to zero — the outlier drops out of the normal matrix entirely — while
Those are constants. So in the limit the estimator is solving the thirteen-inlier least-squares problem with a fixed extra term on the right-hand side, and the displacement it causes is exactly
Do it by hand for this dataset. The thirteen inliers give s11 = 13, s12 = 65.3846, s22 = 463.3136, so det = 13·463.3136 − 65.3846² = 6023.077 − 4275.148 = 1747.929. The outlier sits at xk = 4.6154. Then
which is [0.092417, −0.003081], so at δ = 1 the predicted displacement is Δa = −0.092417, Δb = +0.003081. Check it against the solver: the thirteen inliers alone fit y = 1.156607 + 1.986645x; the converged Huber fit on all fourteen is y = 1.064190 + 1.989726x. Subtract: −0.092417 and +0.003081. Exact to six decimals, from arithmetic you can do on a whiteboard.
Only the Cauchy bar is non-monotonic, and it is the only one that behaves the way the influence panel's shape leads you to expect. It rises to a peak of 13.31% — nearly double an even share — at r = −1.046, which is c to within the resolution of a drag, because that is where ψCauchy peaks. Then it turns around and falls: 10.13%, 6.21%, 4.39%, 2.14% at the preset, 1.53% at the drag floor. Past that the tail has a closed form. For |r| ≫ c the numerator ψ ≈ c²/|r| while the denominator is nearly all inliers and settles at ∑|ψ| → 3.4750, so
Test it against the table: at r = −18.797 that predicts 1.531% and the solver prints 1.527%; at −40.300 it predicts 0.714% against 0.717%; at −1010.303 it predicts 0.0285% against 0.0287%. The Cauchy share decays like 1/|r| with no floor at all, and a two-term approximation nails it to three significant figures over two decades of residual. That is the difference between "the outlier gets ignored" as a slogan and as a rate.
One more reading, easy to miss and worth the ten seconds: the 50% lock has a visible switch-on point. Above y = 1.378 the L2 bar is at 49.9996% and rising; below it, exactly 50.0000% forever. What happens at that y is that the last inlier still sharing the outlier's sign — point i = 8, at x = 6.154 — has its residual dragged through zero, so from there down the outlier is the lone negative residual and the proof's hypothesis is satisfied. The theorem's precondition is a thing you can watch happen on the screen, which is the best possible way to remember that it has one.
Experiment 3 — the redescending trap, and why you cannot trip it by dragging. With the point pulled far out, bring it back toward the line slowly and watch the purple curve. The folklore says that somewhere around |r| ≈ c the Cauchy fit should lurch — two nearly-equal minima, the solver flipping between them. Do it and nothing lurches. The purple line slides back smoothly the whole way, and that is not a bug in the demo: that is the finding.
The reason is one line of the bench's source. It chains its solves — L2 seeds Huber, Huber seeds Cauchy, var tC = benchSolve(wCau, c, tH) — so the Cauchy solve never gets a cold start. It always begins at an answer that has already survived a bounded-influence kernel, and from there it simply cannot reach the second basin. Formally: I re-ran the same solver from four sensible starts — the OLS fit, the Huber fit, θ = (0, 0), and the ground truth (1, 2) — at c = 0.2, 0.3, 0.4, 0.5, 0.7, 1.0, 1.2 and 1.5. At every single c the four starts agree to a relative cost gap under 10−14, which is machine precision on a sum of fourteen logarithms. On this data, from any reasonable start, the non-convexity never bites.
So the bench gives you a button to aim at it deliberately. Press Cauchy start: Huber (warm) and it flips to OUTLIER LINE (cold): instead of seeding from the Huber fit, the Cauchy solve now starts on the exact line through the marked point and its neighbour — two points, one line, sitting right on top of the outlier. It is the worst legal initialisation and it is exactly what a minimal-sample hypothesis would hand you if the sample were contaminated. Now the lurch is something you can produce on demand:
< 1e-3 to 5.85. Step to 0.4, 0.3, 0.2 and it swings to a slope of about +18.2 — a line drawn essentially through the outlier and its neighbour, ignoring the other twelve points entirely.The readout under the canvas now prints both costs on every frame, so you never have to take this on trust: J from the Huber start, J from the outlier-line start, and the basin gap |Ja − Jb| / min(Ja, Jb) between them. It is green when the surface has one basin and red when it has two, and it goes red at exactly the c where the folklore says it should. That readout is the failure-mode section's second metric, wired live to a slider — and note it is printed whether or not the cold start is switched on, because a detector you have to remember to enable is a detector you will forget.
Now look at the Cauchy share bar while you are in the wrong basin. At c = 0.2 with the cold start on, it reads 41.4% — nearly six times an even share, and twenty times what the same bar reads in the good basin. Sit with that for a second, because it is the most vivid thing on the screen: the estimator has not failed to reject the outlier, it has adopted it. In that basin the fitted line passes through the bad point, so the bad point's residual is tiny and its Cauchy weight is near 1, while the twelve honest measurements are hundreds of units away, get weights near zero, and are the ones being discarded. A redescending kernel in the wrong basin does not degrade gracefully; it inverts, and it inverts with high confidence — small residuals, small cost, clean convergence, every internal check green. That is the exact shape of failure the failure-mode section's second-solve test exists to catch, and it is why "the cost looked fine" is never evidence.
| c | From a sensible start (OLS / Huber / zero / truth) → (a, b) | cost J | Cold start, the line through the outlier → (a, b) | cost J | ΔJ |
|---|---|---|---|---|---|
| 0.2 | (0.970, 2.008) | 0.4794 | (−88.706, 18.573) | 2.5997 | 5.4× |
| 0.3 | (0.970, 2.013) | 0.8041 | (−87.958, 18.423) | 5.4067 | 6.7× |
| 0.4 | (0.978, 2.013) | 1.1428 | (−86.610, 18.151) | 9.0464 | 7.9× |
| 0.5 | (0.992, 2.011) | 1.4961 | (−38.345, 7.727) | 10.2464 | 6.8× |
| 0.5364 | the second basin disappears here — bisected to four decimals on the basin-gap test | — | |||
| 0.6 | (1.008, 2.009) | 1.8649 | (1.008, 2.009) | 1.8649 | 1.0× |
| 1.0 | (1.065, 2.000) | 3.5076 | (1.065, 2.000) | 3.5076 | 1.0× |
Read the bottom rows first. At c = 1.0 the kernel is wide enough that the cost still has one basin, and even the malicious start gets pulled back to the right answer — identical to six decimals. At c = 0.2 it does not: the bad start ends at a slope of 18.573 against a truth of 2.000, sitting in a basin whose cost is 5.4× worse. Narrowing c is what manufactures the second basin. The redescending trap is not a property of the Cauchy kernel; it is a property of the Cauchy kernel tuned tight, and c is the knob that decides whether you have one basin or several. That distinction is what separates someone who has read about non-convexity from someone who has run it.
And it is worth knowing the threshold as a number, because you can bisect for it in about twenty lines. Run the basin-gap test at a given c, and binary-search on c for the point where it stops firing: on this dataset the second basin survives up to c = 0.5364 and is gone by c = 0.5365. Since the slider moves in steps of 0.1, that lands the transition between the c = 0.5 and c = 0.6 detents, which is exactly where you felt it snap. "My redescending kernel is safe above c = 0.54 on this data, and I found that by bisection on a two-start cost comparison" is a sentence with a method inside it. "Cauchy is non-convex so be careful" is not.
One further thing the button reveals that the folklore never mentions: it is not two basins, it is at least three. Seed the solve on the line through the outlier and its left neighbour instead of its right one — slope −15.65 rather than +18.68 — and at c = 0.2 the solver converges to a third distinct minimum at slope −15.551 with J = 2.6389, close to but measurably different from the +18.572 basin's J = 2.5996. Three local minima on fourteen points and two parameters. This matters for how you phrase the fix: "restart from a second initialisation and keep the lower cost" is a detector, not a solution — with k basins you would need to have sampled the right one. The actual solution is to remove the basins, which is what graduated non-convexity does by starting at a c so wide the surface is provably single-basin and annealing down while tracking the minimum. The bench's c = 1.0 row is the first step of that schedule.
Experiment 4 — leverage. Press Outlier at the far end, which puts the bad point at the largest x. Compare with dragging a point in the middle by the same amount. The far-end outlier does far more damage for the same residual, because its arm on the slope is longer. This is the leverage effect from Chapter 3: a robust cost bounds the influence of a large residual, but a leverage point can bend the model toward itself and thereby keep its own residual small. That is the case Huber cannot save you from.
Here is the trap in the demo itself, and you should catch it: the bars on screen cannot show you this. They plot the pull on the intercept, and leverage lives entirely in the arm x that the intercept share throws away. The derivation is two sections down; the number you should compute by hand while running this experiment is the slope share, |xkψk| / ∑i|xiψi|. For the far-end point at x = 10 it is roughly double what the bar reads:
| Far-end outlier (x = 10) | Fitted line | Intercept share (the bar) | Slope share (compute it) | Ratio | Slope error |
|---|---|---|---|---|---|
| L2 | y = 2.841 + 1.431x | 36.2% | 48.0% | 1.33× | 0.570 |
| Huber, δ = 1 | y = 1.284 + 1.936x | 16.3% | 28.7% | 1.76× | 0.064 |
| Cauchy, c = 1 | y = 1.048 + 1.997x | 1.8% | 3.6% | 1.99× | 0.003 |
Now compare that L2 slope error, 0.570, against the 0.025 the same estimator posted with the outlier in the middle. Same magnitude of corruption, same single bad point, 23× the slope damage, purely because x moved from 4.6 to 10. That factor of 23 is the whole content of the word "leverage", and it is a number, not an adjective.
Experiment 5 — δ too small. Slide Huber's δ down to 0.2 on clean data. The teal line starts jittering as you nudge points, and the fit becomes noticeably noisier than the red one. You are now down-weighting ordinary Gaussian residuals, throwing away real information for no reason — failure mode 2 from Chapter 3, visible to the eye.
"Noticeably noisier" is an eyeball judgement, and you cannot alarm on an eyeball. The quantity to log is the mean IRLS weight on data you believe is clean, ∑wi/N, evaluated at the converged fit. On the clean dataset — the fourteen points on your screen right now, after pressing Reset, seed 7, before you throw any outlier — it reads:
| δ | ∑wi/N on this dataset | Points below w = 1 (of 14) | What the teal line is doing |
|---|---|---|---|
| 1.0 (bench default) | 1.0000 | 0 | Kernel completely inert — the largest clean residual is 0.839, so nothing crosses δ. |
| 0.583 | 0.9735 | 1 | One point clipped. Invisible on screen. |
| 0.471 | 0.9472 | 3 | Still indistinguishable from the red line by eye. |
| 0.35 | 0.8869 | 5 | Now visibly following the red line less closely as you nudge points. |
| 0.20 | 0.6770 | 9 | Two thirds of the good data down-weighted. This is failure mode 2, on screen. |
Write down which dataset that reading is on, because it matters and it is where this chapter can mislead you. 0.677 is this dataset's number — one particular draw of fourteen Gaussian residuals. Draw fourteen fresh residuals and you get a different number: over 400 independent noise draws the same quantity at δ = 0.20 averages 0.789, with a spread wide enough that individual draws land anywhere from 0.61 to 0.94. The DEBUG section below reports the 400-draw ensemble figures, because a threshold you intend to alarm on must not be calibrated against one lucky draw. So when you see 0.677 here and 0.789 there, they are not two measurements of one thing that disagree — they are one solve versus an ensemble of solves, and the distance between them is itself the reason the DEBUG section exists. The full symptom → metric → threshold triple, and what that spread does to a naive alarm, is below.
| Experiment | What it proves | The number to quote |
|---|---|---|
| 1 — clean data | Robustness is nearly free when unneeded | All three slopes within ~0.05 of each other |
| 2 — one outlier | L2 influence is unbounded, but its share is capped at 1/2; Huber bounded and frozen; only Cauchy redescends | Share of total pull: L2 exactly 50.0% (bounded above by 1/2 — see the proof below), Huber locked at 18.8%, Cauchy peaks at 13.3% near |r| = c then decays like 1/|r| to 1.5% at the drag floor |
| 3 — slow return | Redescending costs are non-convex when c is tight, and the risk is a tuning risk, not a runtime one | Every sensible start agrees to < 10−14; the cold start splits off below c = 0.5364: J = 0.479 vs 2.600 at c = 0.2, identical at c = 0.6 and above |
| 4 — far-end outlier | Bounded influence ≠ high breakdown | L2 slope error 0.025 (mid) → 0.570 (x = 10). 23× |
| 5 — δ = 0.2 | An over-tight kernel discards good data | Mean IRLS weight 1.000 → 0.677 on this dataset (0.789 averaged over 400 draws); slope variance ×1.22 |
The bars are not decoration, but they are also not the whole story, and the fastest way to see why is to actually differentiate the cost instead of gesturing at it. Write the model as ri(θ) = yi − (a + b xi) with θ = (a, b), and the total cost as J(θ) = ∑i ρ(ri(θ)). By the chain rule:
Now the two partials, which is where the whole leverage story hides. Differentiating ri = yi − a − b xi:
So the gradient is a pair of sums, not one sum:
Read that carefully, because it says the demo is only telling you half the truth. The intercept arm weights every residual identically. The slope arm weights each residual by its own x. A point therefore owns two different fractions of the pull, and they are not the same number:
drawBench(): var a = Math.abs(psiFns[k](rs[i][k])); pulls[k] += a;. There is no x anywhere in it. So every bar you have been reading is intercept pull, and the arm — the exact quantity that makes a far-end outlier dangerous — is the thing the metric discards. This is a real limitation of the instrument, not a footnote, and if you demo this bench you should name it yourself before someone else does.Divide one share by the other and the leverage factor falls out in closed form. Let x̄ψ = ∑i|xiψi| / ∑i|ψi| be the influence-weighted mean arm. Then:
Check it on both datasets, using the Huber fit in each. Both checks close to four significant figures, which is what tells you the identity is exact and not a fit:
| Dataset (Huber, δ = 1) | ∑|xiψi| | ∑|ψi| | x̄ψ | xk of the outlier | xk / x̄ψ | Measured shareb/sharea |
|---|---|---|---|---|---|---|
| Outlier in the middle | 24.971 | 5.3315 | 4.6836 | 4.6154 | 0.9854 | 18.483 / 18.756 = 0.9854 |
| Outlier at the far end | 34.836 | 6.1302 | 5.6828 | 10.0000 | 1.7597 | 28.706 / 16.313 = 1.7597 |
The first row is the quiet one: a mid-range outlier sits almost exactly at the crowd's average arm, so its slope share and intercept share are the same number and the bar you can see is telling you the truth. The second row is the one that bites. A point's extra grip on the slope is exactly its arm divided by the crowd's average arm — 1.76× here, and it grows without bound as you push the point further out along x, because x̄ψ is set by the crowd and xk is not. That single line is the most compact statement of leverage you can put on a whiteboard, and it is why "bounded influence" and "high breakdown" are different guarantees.
With that established, here is the default state of the bench, worked out so you can check the numbers on screen against arithmetic. (The Share column is the intercept share, matching what the bars draw.)
| Cost | Fitted line | Outlier residual | |ψ| of the outlier | ∑|ψ| over all 14 | Share |
|---|---|---|---|---|---|
| L2 | y = 0.012 + 2.025x | −12.382 | 12.382 | 24.764 | 50.0% |
| Huber, δ = 1 | y = 1.064 + 1.990x | −13.272 | 1.000 | 5.332 | 18.8% |
| Cauchy, c = 1 | y = 1.065 + 2.000x | −13.319 | 0.075 | 3.487 | 2.1% |
Check the L2 row by hand. Its ψ is the residual itself, so the outlier contributes 12.382. The other thirteen points have a mean absolute residual of 0.952 against the L2 fit — inflated, because the fit has been dragged — so they contribute 13 × 0.952 = 12.38. Total 24.76, and 12.382 / 24.764 = 50.0%. One point in fourteen holds half the vote.
That 50.0% is not a rounding coincidence, and the two-line proof is worth having ready, because "why exactly half?" is the natural next question. At the least-squares optimum both gradient components vanish, which by the derivation above means ∑i ri = 0 and ∑i xiri = 0 exactly. Suppose one residual rk has the opposite sign to all the others — which is precisely what "one outlier pulled down through a clean line" means. Then ∑i≠k ri = −rk, and because those thirteen all share a sign, ∑i≠k|ri| = |∑i≠k ri| = |rk|. Therefore ∑i|ri| = 2|rk| and:
Run the identical argument on ∑xiri = 0 (all xi ≥ 0 here) and the slope share is exactly 1/2 too — the solver reports 114.294 for ∑|xiri| against 57.147 for the outlier alone, and 57.147 × 2 = 114.294. So under least squares, a lone opposite-sign outlier owns exactly half of both gradient components no matter how far out you drag it. Drag it to a residual of 106 and it still owns 50%, because it drags the fit until it does. That is unbounded influence stated as a theorem rather than a picture.
Check the Huber row. Past δ the influence is clamped at exactly δ = 1.000, no matter how far out the point goes. The thirteen inliers now sit at a mean absolute residual of 0.333 (the fit is nearly correct again), all inside δ, so each contributes its own residual: 13 × 0.333 = 4.33. Total 5.33, share 1.000 / 5.332 = 18.8%. Still five times an even share, but bounded.
The Huber and L2 checks both used the same shortcut — take the mean residual, push it through ψ, multiply by 13 — and both are exact. They are exact for a reason that does not survive to the next row, so notice it now. For L2, ψ(r) = r is linear in r; for Huber inside δ, ψ(r) = r is also linear. Whenever ψ is linear over the range you are averaging, mean(ψ(r)) = ψ(mean r) and the shortcut is an identity. Move to any redescending kernel and it stops being one.
Check the Cauchy row — and watch the shortcut break. The outlier first: ψ = r/(1 + r²) at r = −13.319 gives 13.319 / (1 + 177.40) = 13.319 / 178.40 = 0.0747. Now the thirteen inliers. Their mean absolute residual is 0.33191, and the tempting move is to evaluate ψ there: 0.33191 / (1 + 0.11016) = 0.29897. That is the wrong number. ψCauchy is concave over the inlier range, so by Jensen's inequality the mean of ψ sits below ψ of the mean. The quantity you actually need is mean(|ψ(ri)|) = 0.26251, a full 12% lower. Use it and everything closes: 13 × 0.26251 = 3.4126, plus the outlier's 0.0747 gives ∑|ψ| = 3.4873, and 0.0747 / 3.4873 = 2.14% — the table's 2.1%, and the bar on screen. Take the shortcut instead and you would have computed 13 × 0.29897 + 0.0747 = 3.9613 and reported a share of 1.89%, which matches nothing.
Because a claim like "trust the mean of ψ, not ψ of the mean" is exactly the kind of thing that should be checkable rather than asserted, here are all thirteen inliers term by term, against the converged Cauchy fit y = 1.0649 + 1.9997x. Add the last column over the thirteen inlier rows yourself; it comes to 3.4127:
| i | xi | ri | w = 1/(1+r²) | |ψ| = |r| w |
|---|---|---|---|---|
| 0 | 0.000 | +0.9009 | 0.5520 | 0.4973 |
| 1 | 0.769 | −0.0885 | 0.9922 | 0.0878 |
| 2 | 1.538 | −0.3957 | 0.8646 | 0.3421 |
| 3 | 2.308 | −0.0370 | 0.9986 | 0.0369 |
| 4 | 3.077 | −0.1122 | 0.9876 | 0.1108 |
| 5 | 3.846 | +0.2571 | 0.9380 | 0.2412 |
| 6 | 4.615 | −13.3188 | 0.0056 | 0.0747 ← the outlier |
| 7 | 5.385 | −0.4893 | 0.8069 | 0.3948 |
| 8 | 6.154 | −0.5984 | 0.7364 | 0.4406 |
| 9 | 6.923 | +0.4775 | 0.8143 | 0.3889 |
| 10 | 7.692 | +0.3578 | 0.8865 | 0.3172 |
| 11 | 8.462 | +0.3321 | 0.9006 | 0.2991 |
| 12 | 9.231 | −0.2347 | 0.9478 | 0.2224 |
| 13 | 10.000 | −0.0337 | 0.9989 | 0.0336 |
Two things jump out of that table that no summary statistic would have shown you. First, the outlier's weight is 0.0056 against a typical inlier's 0.90, so it enters the normal equations at about 0.6% of an ordinary measurement — the column sums to ∑w = 11.43, meaning Cauchy is solving a 14-point problem with roughly 11.4 points' worth of weight and has spent almost none of it on the bad one. Second, look at row 0: a perfectly good point with r = +0.9009 is the single largest inlier contributor at 0.4973, six times the outlier's. The most influential measurement in this fit is a clean one, which is the state you want and the exact opposite of what the L2 column shows. Share 2.14% is below the even share of 7.1%: the outlier has been demoted beneath an ordinary measurement, by arithmetic you just checked by hand.
You have now dragged points around a solver you have never seen. That is the wrong way round — what you cannot build, you do not yet own. The bench's entire estimator is twenty lines, and it is the same twenty lines you would write in any editor. Here it is, with the fourteen points frozen so you can run it and get the digits in the table above.
The one idea that makes it work is iteratively reweighted least squares. The robust gradient is ∑ψ(ri) ∂ri/∂θ. Define a weight w(r) = ψ(r)/r and that becomes ∑w(ri) ri ∂ri/∂θ, which is exactly the gradient of a plain weighted least-squares problem. So: freeze the weights at the current residuals, solve a weighted LS problem in closed form, recompute the weights, repeat. Each sweep is ordinary linear algebra; the robustness lives entirely in the weight function.
python import numpy as np # ── the bench's fourteen points, frozen ──────────────────────────────── # This is exactly what "Throw one outlier" gives you: seed 7, index 6 # pulled down by 13.0 from the true line y = 1 + 2x. X = np.array([0.0000, 0.7692, 1.5385, 2.3077, 3.0769, 3.8462, 4.6154, 5.3846, 6.1538, 6.9231, 7.6923, 8.4615, 9.2308, 10.0000]) Y = np.array([1.9658, 2.5146, 3.7458, 5.6427, 7.1057, 9.0134, -3.0243, 11.3434, 12.7725, 15.3868, 16.8053, 18.3178, 19.2894, 21.0286]) def w_huber(r, d): """The IRLS weight, w = psi(r)/r. Inside delta psi(r) = r so w = 1; outside, psi(r) = delta*sign(r) so w = delta/|r| and decays like 1/|r|. Clamp the divisor: r == 0 is legal and must give w = 1, not a NaN.""" a = np.abs(r) return np.where(a <= d, 1.0, d / np.maximum(a, 1e-12)) def solve_2x2(x, y, w): """One weighted normal-equation solve, in closed form. [ s11 s12 ] [a] [b1] s11 = sum w b1 = sum w*y [ s12 s22 ] [b] = [b2] s12 = sum w*x b2 = sum w*x*y s22 = sum w*x*x No lstsq, no np.linalg.solve, no forming an inverse: for a 2x2 the closed form is exact, branch-free and about twenty flops. Say out loud while writing it that det > 0 as long as two x values differ and the weights are positive -- that is the observability condition, and it is the same statement as 'you cannot fit a line to one point'.""" s11 = w.sum(); s12 = (w * x).sum(); s22 = (w * x * x).sum() b1 = (w * y).sum(); b2 = (w * x * y).sum() det = s11 * s22 - s12 * s12 return (s22 * b1 - s12 * b2) / det, (s11 * b2 - s12 * b1) / det def irls(x, y, d, sweeps=40, tol=1e-12): a, b = solve_2x2(x, y, np.ones_like(x)) # sweep -1: a plain OLS start for k in range(sweeps): w = w_huber(y - (a + b * x), d) # 1. reweight at current theta na, nb = solve_2x2(x, y, w) # 2. re-solve with those weights if abs(na - a) < tol and abs(nb - b) < tol: return na, nb, k # 3. stop when nothing moves a, b = na, nb return a, b, sweeps a, b, k = irls(X, Y, d=1.0) print(a, b, k) # 1.064177 1.989727 converged at sweep 7
Two details in there deserve attention. The np.maximum(a, 1e-12) guard exists because a residual of exactly zero is a perfectly ordinary event (a point that lands on the line) and the naive d/abs(r) would hand you an inf weight and a NaN fit. And the closed-form 2×2 is not laziness — the general form of this same solve is a Cholesky of JTWJ, which is what Chapter 1 argued for and what every real solver does; at n = 2 the Cholesky is those five sums.
Now instrument the loop and print the sweeps. This is the trace that connects the code to the table three sections up:
| Sweep | a (intercept) | b (slope) | ∑wi | det | What just happened |
|---|---|---|---|---|---|
| −1 (OLS start) | +0.012309 | 2.024788 | 14.0000 | 1884.62 | Every point at weight 1. This is the red line. |
| 0 | +0.918721 | 2.004124 | 11.8278 | 1393.55 | Six points down-weighted — and five of them are good. |
| 1 | +1.052771 | 1.991280 | 13.0308 | 1737.38 | The fit has straightened; the good points come back to weight 1. |
| 2 | +1.064147 | 1.989728 | 13.0754 | 1758.23 | Only the outlier is still down-weighted. Effectively converged. |
| 5 | +1.064177 | 1.989727 | 13.0753 | 1758.23 | Sixth decimal place still settling. |
| 7 | +1.064177 | 1.989727 | 13.0753 | 1758.23 | |Δθ| < 10−12. The 40-sweep cap is never reached. |
The ∑w column is the most informative thing in that table, and it is free. At convergence it reads 13.0753, which factors by hand: thirteen inliers at weight exactly 1, plus the outlier at δ/|r| = 1/13.2719 = 0.0753. Thirteen plus 0.0753. The weight vector is a per-measurement verdict, and you can read the estimator's opinion of every point straight off it without any extra instrumentation.
Now read sweep 0, because it is masking caught in the act. ∑w = 11.8278, not 13.08 — six points are below weight 1. One is the outlier (w = 1/12.3818 = 0.081). The other five are honest measurements at i = 0, 5, 9, 10 and 11, whose residuals against the contaminated OLS line exceed δ = 1 — 1.954, 1.213, 1.357, 1.218, 1.173 — even though their residuals against the true line y = 1 + 2x are only 0.966, 0.321, 0.541, 0.421 and 0.395. Every one of them is an ordinary draw from the 0.35-sigma noise, and every one of them is being penalised for the outlier's crime. On the very first sweep the estimator is punishing five good points and barely touching the bad one. It recovers by sweep 1 here; on the far-end dataset, or with a tighter δ, it does not. That is the whole argument for a consensus initialisation in one column of numbers, and "I print ∑w per sweep" is a far better answer to "how would you debug your robust solver?" than "I'd look at the residuals".
And now the library form — but only now, because being handed this line before you have written the loop teaches you nothing:
python from scipy.optimize import least_squares res = least_squares(lambda t: Y - (t[0] + t[1] * X), x0=[0.0, 0.0], loss='huber', f_scale=1.0) # f_scale IS delta print(res.x) # [1.064177 1.989727] -- identical to the loop, to 6 decimals res = least_squares(lambda t: Y - (t[0] + t[1] * X), x0=[0.0, 0.0], loss='cauchy', f_scale=1.0) # f_scale IS c print(res.x) # [1.064873 1.999739] -- the purple line, to 4 decimals
The trap in that one-liner is f_scale. SciPy does not take δ directly; it evaluates ρ on the scaled square z = (f/f_scale)² and multiplies the result back by f_scale², which for loss='huber' works out to exactly the Huber kernel with δ = f_scale — hence the six-decimal agreement above. Leave f_scale at its default of 1.0 while your residuals are in pixels and you have silently declared every measurement past one pixel an outlier. The units of f_scale are the units of your residual, and forgetting that is the single most common way this call goes wrong. In C++ the same knob is new ceres::HuberLoss(delta) passed as the loss argument to AddResidualBlock; in GTSAM it is noiseModel::Robust::Create(mEstimator::Huber::Create(k), base), where the base noise model carries the covariance and the robust wrapper carries only the kernel — two separate objects, and mixing up which one holds the scale is the GTSAM version of the same bug.
The solver above produces an answer. Everything this chapter actually reads — both share arms, the frozen-fit displacement, the basin gap — is instrumentation wrapped around it, and each piece is short enough to write while talking. Here they are, in the order they appear in the chapter, each returning a number you can check against a table above.
python def shares(x, y, theta, psi): """Both gradient arms, because the canvas only draws the first one. grad J = ( -sum psi_i , -sum x_i psi_i ). A point therefore owns TWO fractions and they differ by exactly |x_k| / xbar_psi -- the leverage multiplier. Returning one of them and calling it 'influence' is the bug the bench itself ships with, and naming it is worth more than hiding it.""" r = y - (theta[0] + theta[1] * x) p = np.abs(psi(r)) return p / p.sum(), (np.abs(x) * p) / (np.abs(x) * p).sum() sa, sb = shares(X, Y, huber_fit, lambda r: np.clip(r, -1.0, 1.0)) print(sa[6], sb[6]) # 0.18756 0.18483 -- mid outlier: the arms agree # On the far-end dataset the same call prints 0.16313 and 0.28706: the # slope arm is 1.76x the bar you can see. That ratio is |x_k| / xbar_psi. def frozen_offset(x, y, k, delta): """Where the Huber fit lands once |r_k| > delta, WITHOUT solving for it. Past delta the outlier's weight is delta/|r|, so its normal-matrix terms decay to zero while its right-hand-side terms tend to the constants -delta and -delta*x_k. It has stopped being a measurement and become a constant force. The displacement is therefore closed-form and does not depend on the residual at all -- which is what 'bounded influence' means when you write it down instead of gesturing at it.""" m = np.ones(len(x), bool); m[k] = False # inliers only A = np.column_stack([np.ones(m.sum()), x[m]]) H = A.T @ A # the 2x2 normal matrix return -delta * np.linalg.solve(H, np.array([1.0, x[k]])) print(frozen_offset(X, Y, 6, 1.0)) # [-0.092417 0.003081] # Check: inliers alone fit (1.156607, 1.986645); IRLS on all 14 with # delta=1 converges to (1.064190, 1.989726). The difference is exactly the # two numbers above, and it is LINEAR in delta -- halve delta, halve both. def basin_gap(x, y, c, k): """Non-convexity has no internal tell, so buy one for a second solve. Start A: the Huber fit -- what any sane pipeline would hand you. Start B: the exact line through point k and its neighbour -- what a CONTAMINATED minimal sample would hand you. If the two converge to different costs, c is tight enough to have carved the outliers their own minimum. Note what this does NOT prove: with three or more basins, two starts give a lower bound on the global minimum, not a certificate.""" th_a = irls_cauchy(x, y, c, init=huber_fit(x, y, 1.0)) b = (y[k + 1] - y[k]) / (x[k + 1] - x[k]) th_b = irls_cauchy(x, y, c, init=np.array([y[k] - b * x[k], b])) Ja, Jb = cauchy_cost(th_a, c), cauchy_cost(th_b, c) return abs(Ja - Jb) / min(Ja, Jb), th_a, th_b # Bisect for the width at which the second basin appears -- 20 lines and # it converts "Cauchy is non-convex, be careful" into a number you own. lo, hi = 0.2, 1.5 for _ in range(50): mid = 0.5 * (lo + hi) lo, hi = (mid, hi) if basin_gap(X, Y, mid, 6)[0] > 1e-3 else (lo, mid) print(lo) # 0.5364 -- safe above, two basins below
All three functions are on the canvas right now. The share arms are the bars (first arm only — that is the instrument's stated limitation). The frozen offset is why the teal line stops moving. The basin gap is the coloured number in the readout, recomputed on every frame from two full IRLS solves, which is affordable precisely because an IRLS solve is a fixed number of 2×2 accumulations. If you can write these three functions from memory you can rebuild every claim in this chapter from the data, which is a materially different position from remembering that the claims were made.
You will not always have this bench. Sometimes you have a marker and a colleague to convince. Here is the same demonstration in six strokes, in the order to draw them:
Six strokes, ninety seconds, and you have covered the mechanism, the trap, and the tradeoff. The right-hand plot is the one that earns the follow-up question, because it is the one almost nobody draws.
"The teal line looks jittery" and "the Cauchy fit lurches" are observations you can make on a fourteen-point toy with your eyes. Neither survives contact with a robot, where the fit is invisible, the data is a bag replay from a customer site, and the only thing you have is whatever you thought to log. A failure mode without a logged metric and a threshold is not a failure mode you can find. So here are both of them written the way an on-call runbook writes them: symptom → metric → healthy reading → broken reading.
Failure mode 1 — δ is too tight (Experiment 5). The symptom is a robust fit that is noisier than plain least squares on data with no outliers in it, which sounds absurd until you realise you are throwing away good measurements. The metric is the one quantity IRLS hands you for free every sweep: the mean applied weight on data you have independent reason to believe is clean, ∑wi/N. Log it per solve; it costs one accumulator.
Here is that metric, alongside the thing it is a proxy for — the standard deviation of the fitted slope over 400 independent noise draws. The last column is the classical efficiency: the variance least squares achieves divided by the variance you achieved, which is exactly the quantity the 95%-efficiency tuning constant from Chapter 3 is named after.
Two of these columns are single-solve measurements and four are ensemble measurements, and the table says which is which, because conflating them is the exact mistake this section is about. Columns 2 and 3 are the bench's own clean dataset — the fourteen points on your screen, the numbers Experiment 5 quotes, reproducible by pressing Reset and moving the δ slider. Columns 4 to 7 are averages over 400 fresh noise draws of the same generator. A metric you alarm on is always the second kind.
| δ | ∑w/N on this dataset | Points below w = 1, this dataset | ∑w/N, mean over 400 draws | sd(slope) over 400 draws | Variance vs OLS | Efficiency |
|---|---|---|---|---|---|---|
| ∞ (plain OLS) | 1.0000 | 0 | 1.0000 | 0.03077 | 1.000× | 100.0% |
| 1.0 (bench default) | 1.0000 | 0 | 1.0000 | 0.03078 | 1.000× | 100.0% |
| 0.583 (= 1.345 σ̂, σ̂ = 0.434 from the clean OLS residuals) | 0.9735 | 1 | 0.9873 | 0.03090 | 1.008× | 99.2% |
| 0.471 (= 1.345 σtrue) | 0.9472 | 3 | 0.9680 | 0.03130 | 1.034× | 96.7% |
| 0.35 (= 1.0 σtrue) | 0.8869 | 5 | 0.9227 | 0.03215 | 1.092× | 91.6% |
| 0.20 (= 0.57 σtrue) | 0.6770 | 9 | 0.7888 | 0.03402 | 1.223× | 81.8% |
Three things to take from that table, all of them quotable. First, the tuning constant is validated, not asserted: δ = 1.345σ buys 96.7% efficiency here, against the textbook asymptotic figure of 95% — and the small excess is honest, because 95% is the asymptotic result for a location estimate and this is fourteen points and two parameters. When someone asks "where does 1.345 come from and does it actually work?", that is the answer with a measurement attached.
Second, the healthy and broken readings are separated by a threshold you can write down. Healthy: mean weight > 0.95 on believed-clean data, which corresponds to a variance penalty under 4%. Broken: mean weight 0.789 at δ = 0.20 (the 400-draw figure; this particular dataset reads 0.677), with two thirds of the good points down-weighted and 22% more variance than doing nothing at all. Alarm at mean_weight < 0.9 and you catch it, because 0.9 sits in the gap between the 0.923 you get at a merely aggressive δ and the 0.789 you get at a broken one.
And now break that threshold, because as stated it does not survive contact with a single solve. Every number in the ensemble column is a mean, and a mean tells you nothing about whether one reading crosses a line. The bench's own dataset already proves the point: at δ = 0.35 the ensemble says 0.9227, comfortably healthy, but this dataset reads 0.8869 — already below the 0.9 alarm, on data with no outlier in it at all. Press Reset, set δ = 0.35, and you are looking at a false alarm.
So measure the spread, not just the mean. Here is the same sweep re-run at 4,000 draws — the means land within 0.004 of the 400-draw column above, so nothing has changed except that the tails are now resolved — reporting what fraction of individual 14-point solves would trip an alarm at 0.9:
| δ | mean ∑w/N | sd of ∑w/N | P(single solve < 0.9) | P(mean of 5 solves < 0.9) | P(mean of 20 solves < 0.9) | Verdict at a 0.9 alarm |
|---|---|---|---|---|---|---|
| 0.583 | 0.9868 | 0.0153 | 0.0% | 0.0% | 0.0% | Silent, correctly. |
| 0.471 | 0.9673 | 0.0256 | 1.4% | 0.0% | 0.0% | Silent, correctly. |
| 0.35 (healthy-ish) | 0.9209 | 0.0414 | 28.9% | 13.4% | 1.0% | 1 solve in 3 is a false alarm. A 20-solve window fixes it. |
| 0.30 | 0.8886 | 0.0494 | 56.8% | 69.9% | 85.5% | The genuine boundary. Fires more the longer you watch, which is what a true positive looks like. |
| 0.25 | 0.8446 | 0.0577 | 82.3% | 98.3% | 100% | Caught. |
| 0.20 (broken) | 0.7850 | 0.0658 | 96.4% | 100% | 100% | Caught — but note 3.6% of single solves miss it. |
Read the middle rows. On one 14-point solve the 0.9 alarm has a 28.9% false-alarm rate at a δ that is fine, and a 3.6% miss rate at a δ that is broken. Ship that and you will either be paged every third keyframe or you will teach the team to ignore the page, which is worse. The metric was never wrong; the sampling was. Average it over a window of solves first and the same threshold becomes usable: at 20 solves the false-alarm rate at δ = 0.35 falls to 1.0% and detection at δ ≤ 0.25 is 100%. The window shrinks the standard error by √M — 0.0414/√20 = 0.0093 — so the healthy mean 0.9209 sits (0.9209 − 0.9)/0.0093 = 2.3 standard errors above the line, and the broken mean 0.7850 sits (0.9 − 0.7850)/(0.0658/√20) = 7.8 standard errors below it. Those two numbers are the design, and note they are not symmetric: the alarm is deliberately placed close to the healthy side and far from the broken side, because missing a broken kernel costs you a silently degraded estimate for as long as it runs, while a 1% false-alarm rate costs you one glance at a dashboard.
That is the general lesson, and it transfers to every metric in this chapter. A threshold is not defined by the gap between two means. It is defined by the gap between two means measured in standard errors of the statistic you actually alarm on, and if the statistic is one small solve you may not have any gap at all. The fix is almost always free: keep a ring buffer, alarm on the window.
python # Failure mode 1, instrumented the way you would actually ship it. # One accumulator in the solver; one ring buffer in the monitor. import collections class WeightMonitor: """Alarms on the WINDOWED mean IRLS weight, never on a single solve. On 14-residual solves the per-solve statistic has sd ~ 0.04, so a 0.9 threshold false-alarms on 29% of perfectly healthy frames. Averaging 20 solves divides that sd by sqrt(20) = 4.5 and the false-alarm rate goes to 1%. WINDOW is therefore not a smoothing nicety -- it is the parameter that makes the threshold mean anything.""" WINDOW = 20 LO = 0.90 # below: delta too tight, you are discarding good data HI = 0.9999 # pinned at 1: delta too loose, kernel is inert def __init__(self): self.buf = collections.deque(maxlen=self.WINDOW) def push(self, w): # w = the final sweep's weight vector self.buf.append(float(w.mean())) if len(self.buf) < self.WINDOW: return None # never judge on a partial window m = sum(self.buf) / self.WINDOW if m < self.LO: return ("kernel too tight", m) # failure mode 2 of Chapter 3 if m > self.HI: return ("kernel inert", m) # failure mode 1: plain LS with extra steps return None
Two details in that class are the ones worth defending out loud. WINDOW = 20 is not a taste parameter — it is the smallest window for which the healthy mean clears the threshold by more than two standard errors (2.3) while the broken mean misses it by nearly eight, and if your residual count per solve were 400 instead of 14 the per-solve sd would be roughly √(400/14) ≈ 5× smaller and you could shrink the window to one or two solves. The window length is a function of how many residuals each solve has, and being able to say that sentence is the difference between choosing 20 and guessing 20. And the upper bound matters as much as the lower one, which is the third reading of this table.
Third, and this is the one that catches people out on this very bench: at the default δ = 1.0 the mean weight is 1.0000. Not 0.99 — exactly 1. The largest clean residual in the dataset is 0.839, so nothing ever crosses δ and the kernel is completely inert. That is why Experiment 1 shows all three lines on top of each other, and it is also the diagnostic for the opposite failure: a mean weight pinned at exactly 1.0 on data you know contains outliers means your δ is too loose and you are running plain least squares with extra steps. The metric catches both ends. Chapter 3's failure mode 1 and failure mode 2 are the same number read in two directions.
Failure mode 2 — a redescending kernel in a second basin (Experiment 3). The symptom is a fit that is confidently wrong: the solver converges, the reported cost is small, the covariance looks fine, and the answer is nonsense. Nothing internal to a single solve reveals it, because a local minimum satisfies every convergence test a local minimum is asked to satisfy. The metric therefore has to come from outside the solve: run it twice from two different initialisations and compare the final costs.
python # Non-convexity has no internal tell. Buy one for the price of a second solve. th_a = irls_cauchy(X, Y, c, init=ols_fit(X, Y)) # cheap, sensible start th_b = irls_cauchy(X, Y, c, init=minimal_sample_fit(X, Y)) # independent start J_a, J_b = cauchy_cost(th_a, c), cauchy_cost(th_b, c) basin_gap = abs(J_a - J_b) / min(J_a, J_b) if basin_gap > 1e-3: # Two basins. Keep the lower-cost answer, and log it: this is the # signal that c is too tight for your data, NOT that the solver broke. log.warn("cauchy multi-basin: J=%.4f vs %.4f, dslope=%.3f", J_a, J_b, abs(th_a[1] - th_b[1]))
This exact metric is running live under the canvas. The bench computes both solves on every frame — Cauchy from the Huber fit and Cauchy from the outlier line — and prints both costs and the basin_gap between them in the readout, green under 10−3 and red above it. Move the c slider and watch it flip. You are not reading a table about a diagnostic; you are driving the diagnostic.
Run it against sensible pairs of starts and it reports clean — OLS versus Huber versus zero versus truth all give basin_gap under 10−14 at every c from 0.2 to 1.5, which is the honest finding from Experiment 3. Point the second start at the outlier and it fires immediately:
| c | J from the sensible start | J from the adversarial start | basin_gap | Δslope | Verdict |
|---|---|---|---|---|---|
| 1.0 | 3.5076 | 3.5076 | < 10−12 | 0.000 | One basin. Convex enough in practice; ship it. |
| 0.7 | 2.2501 | 2.2501 | < 10−12 | 0.000 | One basin. |
| 0.6 | 1.8649 | 1.8649 | < 10−12 | 0.000 | One basin — the last safe slider detent. |
| 0.5364 | bisection boundary — the second basin appears here and exists for every smaller c | ||||
| 0.5 | 1.4961 | 10.2464 | 5.85 | 5.716 | Two basins. Keep J = 1.4961, warn. |
| 0.4 | 1.1428 | 9.0464 | 6.92 | 16.138 | Two basins. |
| 0.3 | 0.8041 | 5.4067 | 5.72 | 16.410 | Two basins, and the bad one is catastrophic. |
| 0.2 | 0.4794 | 2.5997 | 4.42 | 16.565 | Two basins. c is far too tight for this data. (A third exists at J = 2.6389, slope −15.551, reachable from the outlier's other neighbour.) |
Notice what basin_gap does not do: it does not grow monotonically as c tightens. It peaks at 6.92 near c = 0.4 and then falls back to 4.42 at c = 0.2, because both costs are shrinking as the kernel narrows and the ratio is not a stable ordering of severity. Use it as a detector, never as a severity score. The quantity that does track severity is the last column, Δslope, which climbs to 16.6 — and slope error is the thing your downstream consumer feels. If you log one number, log the parameter disagreement; if you alarm on one number, alarm on the cost gap.
The same button works on the leverage dataset, and the numbers there are worth a look because the second basin is wider. Press Outlier at the far end, turn the cold start on, and the two-point start is now the line through the last two points, slope −17.24. That basin survives all the way to c = 0.6 — at c = 0.6 the warm solve reports J = 1.9458 and the cold one J = 9.6609 — where on the mid-outlier dataset c = 0.6 was already safe. A high-leverage outlier does not just corrupt the answer; it enlarges the region of kernel widths in which a bad initialisation can trap you. That is a second, independent reason leverage points deserve a consensus stage in front of the optimiser rather than a tighter kernel behind it.
The tell to carry away, and it is transferable: a robust solve that converges cleanly but whose cost depends on where you started it is not a solver bug, it is a tuning bug — c is tight enough to have carved the outliers their own minimum. The fix is not a different solver or a tighter tolerance; it is either a wider c, or a consensus initialisation, or graduated non-convexity, which is precisely the idea of starting at a c so wide the cost is convex and annealing it down while tracking the minimum. And the second solve that buys you the whole diagnostic costs one extra IRLS run — on the 400-residual problem sized in the next section, under 60 microseconds of arithmetic.
One honest caveat on the detector, and it should be named up front: two starts detect two basins, and this cost has at least three. A restart policy that samples k initialisations gives you the best of the k you happened to try, and nothing more — it is a lower bound on the global minimum with no certificate attached. That is precisely the gap graduated non-convexity closes: instead of sampling basins it removes them, by beginning at a width where the surrogate cost provably has one minimum and annealing down while the minimiser tracks it continuously. The bench's c = 1.0 row is the first step of that schedule, and the c = 0.5364 boundary is the point below which the schedule stops being optional.
The bench is deliberately small so that a single point can dominate visibly. Production problems are usually larger, and the fair question is whether any of this still matters. It does, but differently:
| Quantity | 14 residuals, 1 outlier | 400 residuals, 40 outliers (10%) | What it means |
|---|---|---|---|
| Even share of pull | 7.1% | 0.25% | No single point can dominate; the population of outliers can. |
| Share held by the bad data under L2 | 50% | ~55–70% (40 points × large residuals) | Ten percent of the data buying the majority of the vote is exactly as bad, just less dramatic per point. |
| Does plain Huber from an OLS start recover? | No — masking wins | Yes — see the code lab, slope 1.21 → 2.008 | Masking is a small-sample and high-leverage problem. With many balanced residuals, IRLS finds the right basin. |
| RANSAC iterations for 99% confidence | trivial (n is tiny) | at 90% inliers with a 2-point model: 3 iterations; at 50%: 17; at 20%: 113 | Consensus is cheap while the inlier ratio is high and explodes as it drops. That is why matching quality dominates pipeline cost. |
| Best tool | RANSAC or LMedS, then Cauchy | χ2 gate, then Huber IRLS | Small and contaminated needs consensus; large and mildly contaminated needs a cheap convex kernel. |
"400 residuals" is still a count, and counts do not decide architectures. The sentence "I would ship Huber because the solve time is bounded" is the central design claim of this chapter, and up to this point it has been an assertion with no number behind it. Here is the number.
Make the problem concrete: a monocular VIO front end, keyframe rate 20 Hz, 400 tracked landmarks, refining the current camera pose only (motion-only bundle adjustment — the map points are held fixed, which is what a front end does). Then:
| Object | Shape | Units | Size |
|---|---|---|---|
| Residual vector r | (400, 2) → 800 × 1 | pixels | 800 floats = 3.2 kB at fp32 |
| Jacobian J = ∂r/∂ξ | (800, 6) | px per rad, px per m | 4 800 floats = 19.2 kB |
| Weight vector w (IRLS) | (800,) or (400,) shared per landmark | dimensionless | one accumulator |
| Normal matrix H = JTWJ | (6, 6) — 21 unique entries | px² per rad² etc. | fits in registers |
| Gradient g = JTW r | (6,) | — | fits in registers |
| Update Δξ | (6,) in se(3) | 3 rad + 3 m | — |
At 20 Hz the whole frame budget is 50 ms. Detection, tracking and matching eat most of it; a realistic allocation gives the optimiser about 10 ms. Now count what one IRLS sweep costs, which is the entire point — it is a fixed count, computable in advance:
| Per IRLS sweep | Work | MACs |
|---|---|---|
| Reweight: wi = w(ri, δ) | 800 compares + 800 divides | ~1 600 |
| Accumulate H = JTWJ | 800 rows × 21 unique entries (H is symmetric) | 16 800 |
| Accumulate g = JTW r | 800 rows × 6 | 4 800 |
| Cholesky of a 6×6 and back-substitute | 6³/3 | 72 |
| Total, one sweep | — | ≈ 23 300 |
| Five sweeps (this bench converged in 7 at 10−12; 4–5 is normal at a production tolerance) | — | ≈ 117 000 |
At a conservative 2 GMAC/s on one embedded core that is about 58 µs of arithmetic, plus the 4 000 residual-and-Jacobian evaluations that dominate the real cost (each a projection and a 2×6 analytic Jacobian). Call it a few hundred microseconds wall clock. Against a 10 ms allocation that is a margin of roughly 30×, and — this is the part that matters — the margin does not depend on the data. k sweeps of a fixed-size accumulation is a worst-case execution time you can put in a table and defend to a safety reviewer. That is what "bounded" means; it was never about the constant.
Now price the alternative on the same problem. RANSAC's iteration count is the Fischler–Bolles formula N = log(1 − p) / log(1 − ws), with p = 0.99 confidence, w the inlier ratio and s the minimal-sample size — s = 2 for a line, s = 3 for P3P pose. Every iteration also needs a consensus pass over all 400 landmarks:
| Inlier ratio w | N, s = 2 (line) | N, s = 3 (P3P) | Consensus reprojections at s = 3 | vs 5 IRLS sweeps (2 000 evals) |
|---|---|---|---|---|
| 0.90 | 3 | 4 | 1 600 | 0.8× — cheaper than IRLS |
| 0.80 | 5 | 7 | 2 800 | 1.4× |
| 0.50 | 17 | 35 | 14 000 | 7× |
| 0.30 | 49 | 169 | 67 600 | 34× |
| 0.20 | 113 | 574 | 229 600 | 115× |
That table is the design argument, and it is not the one people expect. RANSAC is not "slower" — at 90% inliers it is cheaper than the IRLS loop. The problem is the shape of the curve: N depends on w, w is a property of the scene, and you do not know w until you have run the thing. Cost blows up by 115× exactly when matching degrades — motion blur, low texture, a wet road at night, a repeated facade — which is precisely the frame where you least want the optimiser to overrun and drop a control cycle. A cost that is cheap on the easy frames and unbounded on the hard ones is the wrong shape for a fixed-rate loop, no matter how good its average is.
Be honest about the demo's limits, because they are real:
Every idea you just dragged around on that canvas has an author and a date, and naming them is worth doing for a reason beyond politeness: it keeps clear which parts are settled and which parts are still moving. Four of them are load-bearing in this chapter alone.
| Paper | Year | Where it shows up on the bench | What it changed |
|---|---|---|---|
| Huber, "Robust Estimation of a Location Parameter", Annals of Mathematical Statistics 35(1) | 1964 | The teal curve, the δ slider, and the 1.345 constant | Reframed robustness as a minimax problem — find the estimator with the best worst-case variance over a neighbourhood of the Gaussian — and showed the answer is quadratic in the middle and linear in the tails. Before this, "robust" meant heuristic outlier deletion; after it, it meant a specific ρ, and the influence function ψ = ρ' became the object you reason about. |
| Beaton & Tukey, "The Fitting of Power Series…", Technometrics 16(2) | 1974 | The idea behind the purple curve's descent | Introduced the biweight, the first widely used redescending kernel — ψ that returns to exactly zero rather than merely decaying. Made "an outlier can be given literally no vote" a design option, and non-convexity the price of it. |
| Fischler & Bolles, "Random Sample Consensus", CACM 24(6) | 1981 | The N = log(1−p)/log(1−ws) column in the budget table | Inverted the whole approach: instead of down-weighting outliers in a fit over all the data, fit to a minimal sample and let the data vote. Gave the iteration-count formula that turns "how many hypotheses?" into arithmetic, and made high-breakdown estimation practical in vision. |
| Rousseeuw, "Least Median of Squares Regression", JASA 79(388) | 1984 | The "RANSAC or LMedS" cell in the 400-residual table | Proved you can reach the theoretical maximum 50% breakdown point by minimising the median squared residual rather than the sum — the formal statement of why a bounded influence function is not the same guarantee as surviving many outliers. |
| Black & Rangarajan, "On the Unification of Line Processes, Outlier Rejection, and Robust Statistics…", IJCV 19(1) | 1996 | The w column in the per-inlier Cauchy table | Showed that every redescending kernel is an outlier process in disguise: minimising ∑ρ(ri) is exactly equivalent to jointly minimising ∑wiri² + ∑Φ(wi) over both the parameters and a hidden per-measurement weight. That is why the w values you read off the table are not an implementation detail — they are the latent inlier/outlier labels, solved for continuously instead of decided by a threshold. |
| Yang, Antonante, Tzoumas & Carlone, "Graduated Non-Convexity for Robust Spatial Perception", IEEE RA-L 5(2) | 2020 | The c = 1.0 row of the two-basin table, and the fix named in the failure-mode section | The modern answer to the trap Experiment 3 sets. Start with the kernel so wide the surrogate cost is convex, solve, then anneal the width down while tracking the minimum — recovering the redescending kernel's outlier rejection without needing a consensus initialisation. Made a certifiable, initialisation-free robust back end practical, and is the single citation to have ready when someone says "so how do you actually use Geman–McClure in production?" |
Two more worth knowing because they answer the obvious follow-up, "and how do you pick δ and c on real data?" — the question the bench dodges with a slider. Barron, "A General and Adaptive Robust Loss Function" (CVPR 2019), builds a one-parameter family whose shape parameter α interpolates L2, Huber-like, Cauchy and Geman–McClure, and — the actual contribution — derives the normalising constant that makes it a proper probability distribution, so α and the scale can be learned by gradient descent alongside the model instead of tuned. Chebrolu, Läbe, Vysotska, Behley & Stachniss, "Adaptive Robust Kernels for Non-Linear Least Squares Problems" (IEEE RA-L 6(2), 2021), takes that family into the robotics setting and estimates the shape per-problem via expectation-maximisation inside the solver, which is the version you would actually wire into GTSAM.
Reading this chapter is not the same as being able to produce it under a whiteboard marker while someone waits. Close the page. Set a timer for ten minutes. Every answer below is a number you can check against something earlier in this chapter, so there is no ambiguity about whether you got it.
| # | Drill | Answer | What it is testing |
|---|---|---|---|
| 1 | Fourteen points, one dragged below a clean line. Under least squares, what fraction of the gradient does that one point own? Prove it in two lines. | Exactly 1/2, for both gradient components, for any residual. From ∑ri = 0 with one opposite sign, ∑|ri| = 2|rk|. | That you reach for the optimality condition, not for a simulation. The word "exactly" is what earns the follow-up. |
| 2 | Same data, Huber with δ = 1. The thirteen inliers sit at mean |r| = 0.333. Share? | 13 × 0.333 = 4.33, plus the clamped 1.000, gives 5.33. 1.000/5.332 = 18.8%. | That you know ψ clamps at δ and that the inliers are inside δ so their ψ is just r. |
| 3 | Now Cauchy, c = 1. Why can you not get the inlier total by evaluating ψ at their mean residual? | ψCauchy is concave over the inlier range, so mean(ψ) = 0.2625 < ψ(mean) = 0.2990. The shortcut is exact only where ψ is linear — L2 everywhere, Huber inside δ. | Jensen, and the discipline to notice when a shortcut you just used twice stops being valid. |
| 4 | The bad point is at x = 10; ∑|xψ| = 34.836 and ∑|ψ| = 6.130. How much more of the slope does it own than the bar on screen suggests? | x̄ψ = 5.683, so the ratio is 10/5.683 = 1.76×. | That you can separate the two gradient arms — the single most common gap, because the demo hides it. |
| 5 | 400 landmarks, 6-DoF pose-only, 5 IRLS sweeps. Size the solve, and say why it is the one you ship at 20 Hz. | r is (400,2) = 800; J is (800,6). Per sweep 800×21 + 800×6 ≈ 23 k MAC; five sweeps ≈ 117 kMAC, ≈ 58 µs. Fixed count → a WCET you can budget against 10 ms. | Order-of-magnitude literacy, and whether "bounded" means something to you or is a word you repeat. |
| 6 | Inlier ratio drops to 20%. How many RANSAC P3P hypotheses for 99% confidence, and what is the design consequence? | N = log(0.01)/log(1−0.2³) = 574, times a 400-point consensus pass = 230 k reprojections, 115× the IRLS route. Cost is data-dependent, so it cannot be bounded in advance. | That you can evaluate the Fischler–Bolles formula in your head to one significant figure and know what it implies for a fixed-rate loop. |
| 7 | Your robust fit is noisier than plain least squares on clean data. Name the metric, the healthy value, the broken value — and say what you average it over before alarming. | Mean IRLS weight ∑w/N, on believed-clean data. Healthy > 0.95; at δ = 0.2 the 400-draw mean is 0.789 with 22% excess variance. Alarm at 0.9, but on a 20-solve window: on one 14-residual solve the statistic has sd 0.041, which false-alarms on 29% of healthy δ = 0.35 frames. | Whether your failure modes come with instrumentation or only with adjectives — and whether you can tell a threshold from a threshold plus a sampling plan. |
| 8 | A Cauchy solve converges cleanly and reports a small cost, but the answer is wrong. What do you run, and what does the result not prove? | The solve again, from an independent initialisation, and compare final costs. Differing costs ⇒ two basins ⇒ c is too tight. Cost: one extra solve, < 60 µs. It does not prove you found the global minimum — this cost has at least three basins; two starts give a lower bound with no certificate. | That you know a local minimum passes every convergence test, so the diagnostic has to come from outside the solve — and that you know the limits of your own detector. |
| 9 | You drag one point of fourteen straight down forever. Sketch, without running anything, what the L2 / Huber / Cauchy share-of-pull bars do. | L2 rises and locks at exactly 50.0%. Huber rises and locks at 18.8% the moment |r| > δ, because the fit itself freezes. Cauchy peaks at 13.3% near |r| = c and then decays like c²/(|r|·∑|ψ|) — 1.5% at |r| = 19, 0.03% at |r| = 1000. | Whether you can predict an instrument's output from the influence function alone. Two locks and one decay; getting "L2 climbs forever" is the standard wrong answer. |
| 10 | Why does the Huber fit stop moving entirely past δ, and how far from the inlier-only fit does it land? Give the formula and the number. | Past δ the outlier's weight is δ/|r|, so its normal-matrix terms → 0 while its right-hand-side terms → −δ and −δxk: a constant force. Hence Δθ = −δ H−1[1, xk]T. Here H = [[13, 65.385],[65.385, 463.314]], det 1747.93, xk = 4.615 → Δa = −0.0924, Δb = +0.00308, linear in δ. | The single best answer to "what does bounded influence actually buy you?" — a bias you can compute in advance rather than a promise that things stay finite. |
| 11 | Cauchy with c = 0.2 on this data. Is the non-convexity a runtime risk? Defend the answer with a measurement and give the threshold. | Not from a sensible start: OLS / Huber / zero / truth all agree to < 10−14 at every c tried. It becomes a risk only from a start aimed at the outlier, and the second basin exists only for c < 0.5364 (found by bisection on the two-start cost gap). So: a tuning risk with a measurable boundary, not a lurking runtime hazard. | Whether you can distinguish "the cost is non-convex" (a fact about the surface) from "my solver is at risk" (a fact about the surface and the initialisation policy). |
If you got eight of eleven inside ten minutes, this topic is yours. The ones people miss are 4, 8, 9 and 10: drill 4 because the visualisation actively trains the wrong intuition, drill 8 because "run it twice" feels too cheap to be the professional answer and is, and drills 9 and 10 because everyone has been taught "L2 influence is unbounded" as a slogan and almost nobody has watched what the share and the fit actually do while the residual runs away.
Here is the question this whole lesson has been building toward: "Our AMR reports two-centimetre localisation uncertainty and it clips a rack about once a week. Where do you look first?" There is no partial credit for knowing what a covariance is. You need the whole chain loaded — what the number claims, what would prove it false, what it costs to check, and what you would change on Monday — and you need it in one breath.
Everything below is that load-out, in the order you will need it. Read section 1 first; do the derivations in section 1b out loud until they are muscle memory; run section 3 with a timer; keep section 4 for the day a bug like these lands on you; use section 7 whenever you need the numbers back at your fingertips.
| Concept | The 30-second explanation | Key equation | Tool | Classic paper | Modern reference |
|---|---|---|---|---|---|
| Covariance | Not an error bar — a shape. Its eigenvectors are the axes of the uncertainty ellipse, its eigenvalues the squared semi-axis lengths. In 2-D the 95% contour sits at 2.448σ, not 1.96σ. | Σ = E[(x−μ)(x−μ)T] | np.cov, eigh |
Kalman 1960 | Barfoot, State Estimation for Robotics, 2nd ed. 2024 |
| Covariance propagation | First-order Taylor: the error rides through the Jacobian, so the covariance gets sandwiched by it. Valid while f is nearly linear over one sigma. | Σy = JΣJT | autodiff, jax.jacfwd |
Schmidt 1966 (EKF on Apollo) | Barrau & Bonnabel, InEKF, IEEE TAC 2017 |
| Mahalanobis distance | Euclidean distance after whitening — length measured in local sigmas in every direction at once. Chi-square with m DOF, which turns a distance into a decision. | d2 = yTS−1y | cholesky + triangular solve |
Mahalanobis 1936 | MAGSAC++, CVPR 2020 (threshold-free gating) |
| NIS / NEES | The consistency test. Expected value equals the measurement dimension / state dimension. NIS needs no ground truth, so it runs in the field on customer hardware. | E[NIS] = m | a four-line online monitor | Bar-Shalom, Li & Kirubarajan 2001 | Standard in modern VIO evaluation harnesses |
| Least squares | Maximum likelihood under Gaussian noise. Squares appear because the Gaussian has a quadratic exponent — nothing more mystical than that. | (HTR−1H)x̂ = HTR−1z | lstsq, QR |
Legendre 1805 / Gauss 1809 | Demmel et al., Square Root BA, CVPR 2021 |
| WLS → MAP | A prior is one more information term. Adding P−1 to the normal equations gives MAP, which is the Kalman update in information form. | (HTR−1H + P−1)x̂ = HTR−1z + P−1x̄ | Ceres, GTSAM, g2o | Lu & Milios 1997 (pose graphs) | Kaess et al., iSAM2, IJRR 2012 |
| Information form | Λ = P−1 is additive across independent measurements, which is exactly why factor graphs and incremental solvers can exist at all. | Λ = ∑i hihiT/σi2 | GTSAM Bayes tree | Thrun et al., sparse extended information filters, IJRR 2004 | Demmel et al., square-root marginalisation, ICCV 2021 |
| Robust cost | A different derivative. ψ = ρ′ is the pull; L2's is unbounded, Huber's saturates, Cauchy's redescends. IRLS turns any of them into weighted least squares. | w(r) = ψ(r)/r | ceres::HuberLoss |
Huber 1964 | Barron, adaptive robust loss, CVPR 2019 |
| Robust scale | MAD survives up to 50% contamination; 1.4826 makes it agree with σ on clean Gaussian data. Estimating scale from a contaminated fit is how masking happens. | σ̂ = 1.4826 × MAD | scale_est='mad' |
Rousseeuw & Croux 1993 | Yang et al., GNC, IEEE RA-L 2020 |
| Consensus vs refinement | RANSAC and LMedS find the solution when contamination is high; a robust kernel polishes it. Consecutive stages, not alternatives. | k = log(1−p) / log(1−ws) p = success probability you want, w = inlier fraction, s = minimal sample size. One draw is all-inlier with probability ws, so k draws all fail with probability (1−ws)k; set that to 1−p and take logs. Homography (s = 4) at w = 0.5, p = 0.99: ws = 0.0625, so k = log(0.01)/log(0.9375) = −2/−0.02803 = 71.4 → 72 iterations. |
cv2.USAC_MAGSAC |
Fischler & Bolles 1981 | Yang, Shi & Carlone, TEASER, IEEE T-RO 2021 |
A cheat sheet you can only recite is a liability, because the follow-up to every row above is "where does that come from?" and a memorised constant has no follow-up. Each derivation below takes under a minute at a whiteboard. Rehearse them until you can talk and write at the same time; that is the actual skill being tested.
(a) Why the 2-D 95% ellipse is 2.448σ and not 1.96σ. Start from the Gaussian density. Every point on a constant-density contour has the same exponent, so the contour is the set
which is an ellipse. Now whiten: define u = Σ−½(x − μ). The condition becomes uTu = c — a circle of radius √c — and u is standard normal with independent components. So c is a sum of m squared standard normals, which is the definition of a chi-square with m degrees of freedom. That is the entire link between "ellipse" and "chi-square", and it is two lines.
For m = 2 the chi-square CDF has a closed form you can quote: P(χ22 ≤ c) = 1 − e−c/2. Set it to 0.95:
And now the punchline, which is the part that actually matters. 1.96 is the one-dimensional number, from 2(1 − Φ(1.96)) = 0.05. Feed it into the 2-D formula and see what you really drew: c = 1.962 = 3.8416, so the enclosed mass is 1 − e−1.9208 = 1 − 0.1465 = 0.854. An engineer who plots a "95% ellipse" at 1.96σ is plotting an 85.4% ellipse and labelling it 95%. On a planner that inflates obstacles by that contour, one pass in seven is outside the region you told it was safe.
| Dimension m | χ2m at 95% | Radius in σ (√c) | χ2m at 99.73% | Radius in σ | Where it shows up |
|---|---|---|---|---|---|
| 1 | 3.841 | 1.960 | 9.000 | 3.000 | range-only gate; the familiar 1.96 and 3σ |
| 2 | 5.991 | 2.448 | 11.829 | 3.439 | 2-D innovation gate; ground-plane position ellipse |
| 3 | 7.815 | 2.796 | 14.156 | 3.762 | 3-D position ellipsoid; landmark covariance |
| 6 | 12.592 | 3.549 | 20.062 | 4.479 | relative-pose gate on a loop closure |
Read the third column down: the "number of sigmas" that buys you 95% grows with dimension, because you are asking a larger set of directions to all behave at once. The two numbers you actually need at your fingertips are 5.991 (2-D gate) and 12.592 (6-DOF pose gate); everything else you can regenerate from −2 ln(1−p) when m = 2, or admit you would look up.
(b) Why Σy = JΣJT, in three lines and one honest caveat. Let y = f(x) and expand about the mean x̄:
Then δy = y − ȳ ≈ J δx, and
The whole proof is the third equality: J is evaluated at the fixed point x̄, so it is a constant matrix and comes outside the expectation. Say that sentence out loud — it is what distinguishes someone who derived the formula from someone who copied it, and it immediately tells you the failure mode: the instant J is re-evaluated at a random point (an iterated EKF, a relinearisation), that step is no longer free.
The caveat is the term you threw away, ½ δxTH δx. Two consequences, and you should volunteer both. First, the neglected curvature matters only when f bends appreciably over one sigma — make it concrete: for polar→Cartesian at r = 10 m with σθ = 5 mrad, the arc sags below its chord by r(1 − cos σθ) = 10 × 1.25×10−5 = 0.125 mm, against a 5 cm cross-range spread — 0.25%, so linearise without apology. Second, the surviving second-order term is a bias: E[y] ≈ f(x̄) + ½ tr(HΣ). Averaging more measurements shrinks variance as 1/N and does nothing at all to that bias, which is why a bad linearisation shows up as a confident wrong answer rather than a noisy one.
(c) Why "least squares" has squares in it at all. Take z = Hx + v with v ~ N(0, R). The likelihood is
Maximising a likelihood is minimising its negative logarithm, and the logarithm of an exponential of a quadratic is a quadratic. The squares come from the Gaussian's exponent and from nowhere else — there is no separate "principle of least squares" to appeal to. Differentiate and set to zero:
The immediate corollary is worth having ready: change the noise model and you change the cost. Laplace noise gives you L1 and the median; a Gaussian core with heavy tails gives you Huber. "Robust cost" is not a hack bolted onto least squares — it is least squares under a different, more honest likelihood.
(d) Why MAP is the Kalman update — with a two-line numerical check. Multiply the likelihood by a Gaussian prior p(x) ∝ exp(−½(x − x̄)TP−1(x − x̄)). Negative logs add, so the cost gains one more quadratic, and the stationary point becomes
Two independent quadratics added means two information matrices added — Λ = ∑i Λi. That additivity is the whole reason factor graphs exist: a new measurement is a new term, never a rebuild.
Worked example 1 — MAP and Kalman agree, every digit. Scalar state, H = 1. Prior x̄ = 0 with σ0 = 1, so P = 1. One measurement z = 4 with σ = 0.5, so R = 0.25. Information form first:
Now the Kalman route, which must give the same answer or one of us is wrong:
Four yes/no questions decide which estimator you should be naming. Toggle them; the canvas lights the path and prints the cost function, the solver and the sentence to say.
Prompt A — "Design the uncertainty story for a warehouse AMR's localisation stack."
nav_msgs/Odometry carries two covariance blocks — pose and twist — each 6 × 6 = 36 doubles: 2 × 36 = 72 doubles × 8 B = 576 B per message, and 576 × 50 = 28,800 B/s = 28.8 kB/s. If you only ever fill the pose block, halve it: 36 × 8 = 288 B and 288 × 50 = 14,400 B/s = 14.4 kB/s.Prompt B — "We are moving from an EKF to a factor graph. Convince me it is worth the rewrite."
Prompt C — "Would you let a neural network output your measurement covariance?"
Prompt D — "Our loop closures occasionally weld two aisles together. Design the defence."
A drill is not finished when the code is right. It is finished when you can state, without looking, the shape and dtype of every array on every line, and hand-evaluate one row of the output. That is what the follow-ups are made of, so every drill below ships a shapes header and one fully worked instantiation.
Drill 1 — "Gate a batch of innovations." The key lines, and what to narrate while typing:
python # shapes: S (2,2) float64 innovation covariance, symmetric positive definite # Y (N,2) float64 stacked innovations, one per row, N = 300 L = np.linalg.cholesky(S) # L: (2,2) lower, S = L @ L.T -- factor once, outside the loop Wt = np.linalg.solve(L, Y.T) # Y.T: (2,300) -> Wt: (2,300) "whiten -- this is why it is Mahalanobis" d2 = np.einsum('ij,ij->j', Wt, Wt) # d2: (300,) float64 -- column norms, no temporaries keep = d2 < 5.991 # keep: (300,) bool -- chi-square, 2 DOF, 95%
Worked example 5 — hand-evaluate one column before you claim it works. Take a lidar innovation covariance of σ = 20 cm along track and 10 cm across, uncorrelated:
For one innovation y = [0.30, 0.05] the whitened vector is the triangular solve L u = y, which here is just an elementwise divide: u = [0.30/0.2, 0.05/0.1] = [1.5, 0.5]. Then
Say what 2.50 means: √2.50 = 1.58, so that measurement is 1.58 sigma out in the whitened metric — comfortably inside the gate even though its along-track component is 30 cm, which sounds alarming in metres and is only 1.5σ in the units that matter. Now push it to y = [0.60, 0.05]: u = [3.0, 0.5], d2 = 9.00 + 0.25 = 9.25 > 5.991, rejected. Being able to move one number and flip the decision, live, is the whole point of the drill.
What this drill is really checking: that you did not call np.linalg.inv(S); that 5.991 is a chi-square quantile you can derive (section 1b(a)) and not a magic number; and that the factorisation is hoisted out of the loop.
Expect these three follow-ups, in this order.
Y.T is (2, N) and the solve treats each innovation as a column of the right-hand side. If Y arrives as (2, N) instead, Y.T is (N, 2) and np.linalg.solve raises a shape error for every N ≠ 2 — and silently returns garbage when N = 2, which is exactly the size of the fixture in your unit test. Assert Y.shape[1] == S.shape[0] at the top of the function and say so unprompted.LinAlgError, and that is a feature, not a nuisance to be caught and swallowed. It means the covariance has lost positive definiteness and you want to know at that instant, not forty minutes into the mission when it finally trips something else (see debug row 2).(Wt**2).sum(0)?" — be honest: at N = 300 they are indistinguishable; einsum avoids materialising the (2, N) squared temporary, which starts to matter at N in the tens of thousands, e.g. a full lidar sweep gated at once. Do not oversell a micro-optimisation — claiming a 300-element speedup is a worse signal than admitting it is a wash.Drill 2 — "Weighted least squares with a prior, and no matrix inverse."
python # shapes: H (N,p) design, z (N,) measurements, sig (N,) per-measurement sigma # x0 (p,) prior mean, sig0 (p,) prior sigma. N = 4 rows, p = 2 params A = np.vstack([H / sig[:, None], np.diag(1.0 / sig0)]) # A: (N+p, p) = (6,2) -- prior = extra rows b = np.concatenate([z / sig, x0 / sig0]) # b: (N+p,) = (6,) Q, Rr = np.linalg.qr(A) # Q: (6,2), Rr: (2,2) -- kappa(H), not kappa(H)^2 x = solve_triangular(Rr, Q.T @ b) # Q.T @ b: (2,) -> x: (2,) float64
Worked example 6 — make "the prior is worth k measurements" a number. Fit a line, so p = 2, from N = 4 points all with σ = 0.1 m. Start with a deliberately weak prior x0 = [0, 0], σ0 = [10, 10]. The four measurement rows each carry weight 1/0.1 = 10; the two prior rows carry 1/10 = 0.1. What lands on the intercept diagonal of ATA is:
The prior is a 1-in-40,000 nudge — that is what "weak prior" means numerically, and it is a far better answer than "it barely matters". Now tighten σ0 to 0.05: the prior row carries 1/0.05 = 20, contributing 202 = 400, exactly matching all four measurements combined. At σ0 = 0.05 the prior is worth four measurements, and you can say that in a sentence with the arithmetic behind it. The general rule falls straight out: a prior of σ0 is worth (σ/σ0)2 measurements of noise σ. Converting a knob into a count is the kind of sentence people remember.
What to say while typing: "A prior is just extra pseudo-measurements — stacking the square-root information as rows is algebraically identical to adding P−1 to the normal equations (section 1b(d)), and it keeps the condition number at κ(H) instead of squaring it."
Put a number on the conditioning too, because "squaring the condition number" is a phrase people repeat without pricing. Float64 has ε ≈ 2.2 × 10−16, so you carry about 16 decimal digits. A merely awkward Jacobian with κ(H) = 104 gives normal equations at κ = 108: you lose 8 digits and keep 8, which is fine. In float32 (ε ≈ 1.2 × 10−7, ~7 digits) the same problem has κ(HTH) > 1/ε and the solve returns noise. That is the actual argument for square-root methods on a mobile GPU, and it is why the √BA paper exists.
Expect "so when would you form the normal equations anyway?" — when the problem is large and sparse, because the sparsity win is three orders of magnitude, double precision absorbs the conditioning cost, and QR on a sparse matrix suffers fill-in that Cholesky with a good ordering avoids. The honest answer names the trade rather than declaring one method better.
Drill 3 — "IRLS with a Huber weight."
python # shapes: H (N,p) design, z (N,) measurements, th (p,) parameters # r (N,) residuals, s () scalar robust scale, w (N,) weights in [0,1] for _ in range(30): r = z - H @ th # r: (N,) float64 s = 1.4826 * np.median(np.abs(r - np.median(r))) # robust scale, scalar w = np.minimum(1.0, 1.345 * s / np.maximum(np.abs(r), 1e-12)) th = np.linalg.solve(H.T @ (w[:, None] * H), H.T @ (w * z)) # (p,p) solve -> th: (p,)
Both constants must be defensible, because both will be asked about. 1.4826 = 1/Φ−1(0.75): for clean Gaussian data the median absolute deviation converges to 0.6745σ, so dividing by 0.6745 — i.e. multiplying by 1.4826 — makes the robust scale agree with σ. 1.345 is the Huber tuning constant giving 95% asymptotic efficiency at the Gaussian: you accept a 5% larger variance than ordinary least squares on clean data in exchange for bounded influence on dirty data. Neither is a magic number; both are a stated price.
Worked example 7 — masking, computed twice so you can see it happen. Five residuals at the current iterate, one of them an outlier:
Route 1 — robust scale (what the code does). Sorted, r is (−0.20, −0.05, 0.10, 0.15, 3.00), so median(r) = 0.10. Then |r − 0.10| = (0, 0.30, 0.05, 0.15, 2.90); sorted that is (0, 0.05, 0.15, 0.30, 2.90), so MAD = 0.15. Hence
The four clean points keep full weight; the outlier is down-weighted 10×. The kernel engaged.
Route 2 — sample standard deviation (the bug). mean(r) = 3.00/5 = 0.60. Deviations (−0.50, −0.80, −0.45, −0.65, 2.40), squares (0.2500, 0.6400, 0.2025, 0.4225, 5.7600), sum 7.2750, divided by N−1 = 4 gives 1.8188, so σ̂ = 1.3486. Then δ = 1.345 × 1.3486 = 1.8139 and
median to std and the robust estimator quietly became least squares. This is the arithmetic behind debug row 4 — "adding HuberLoss changed the answer by less than a millimetre".What to say: "The gradient of any ρ is ∑ψ(r)h, and writing ψ(r) = w(r)·r turns it straight back into the weighted normal equations — so a robust cost reuses the WLS solver unchanged." Then, unprompted: "I would initialise from RANSAC rather than least squares, because on a masked problem an OLS start converges in the wrong basin, and I would log the weight histogram so I can prove the kernel actually engaged." That unprompted sentence is the drill — the code was never the hard part.
Drill 4 — "Propagate a covariance through a nonlinear function, and validate it."
python # units and magnitudes -- state them before you type, or the assert is unfalsifiable: # r = 10.0 m range to the landmark # th = 0.5 rad bearing (~28.6 deg) # sr = 0.02 m range sigma (2 cm, a good lidar) # st = 0.005 rad bearing sigma (5 mrad, ~0.29 deg) # shapes: Sx (2,2) = diag(sr**2, st**2) polar; J (2,2); Sy (2,2) Cartesian J = np.array([[np.cos(th), -r*np.sin(th)], [np.sin(th), r*np.cos(th)]]) # J: (2,2) float64, evaluated at (r, th) Sy = J @ Sx @ J.T # Sy: (2,2), symmetric by construction # validate -- this is the part people skip: rs = r + sr*rng.standard_normal(20000) # rs: (20000,) ts = th + st*rng.standard_normal(20000) # ts: (20000,) Smc = np.cov(np.stack([rs*np.cos(ts), rs*np.sin(ts)])) # stack: (2,20000) -> Smc: (2,2) assert np.abs(Sy - Smc).max() / np.abs(Smc).max() < 0.05
Worked example 8 — every entry of that Jacobian and the sanity check that needs no computer. With cos(0.5) = 0.87758 and sin(0.5) = 0.47943:
Multiply out the top-left entry by hand so you can show the mechanism: Σy,11 = 0.87762 × 4.0×10−4 + 4.79432 × 2.5×10−5 = 3.081×10−4 + 5.746×10−4 = 8.827×10−4. Note which term dominates: the bearing contribution is nearly twice the range contribution even though σθ is only 5 mrad, because it arrives multiplied by r2. The full result:
Now the check you can do in your head, and should say before anyone asks. A rotation cannot change two independent standard deviations, only their orientation — so the ellipse axes must come out as exactly the along-beam and cross-beam spreads:
Verify without an eigensolver, using the two rotation invariants:
Two multiplications and a subtraction proved a 2×2 eigen-decomposition. Trace and determinant are invariant under rotation, so if your propagated covariance has the right trace and determinant it has the right axes — and if it does not, you have a bug in J, not in your intuition. This check is what someone does who has debugged the code rather than read about it.
Does the assert actually hold? Quote the two error sources separately rather than hoping. Monte Carlo sampling error on a covariance entry from N samples is about √(2/N) = √(2/20000) = 1.0%, so the 5% threshold has a 5× margin against noise alone. The linearisation error is far smaller: over one bearing sigma the arc sags below its chord by r(1 − cos σθ) = 10 × 1.25×10−5 = 0.125 mm against the 5 cm cross-range spread, i.e. 0.25%. Run with a fixed seed and the observed relative discrepancy at N = 20,000 is about 1.5% — sampling-dominated, as predicted, and comfortably inside the assert. If you cannot say which of the two errors your tolerance is protecting against, the tolerance is a wish, not a test — and a hoped-for tolerance is exactly the kind of flaky assert that gets a CI job disabled six weeks later.
What to say: "The Jacobian carries an r in its second column, so bearing error is amplified by range — the ellipse is a sliver perpendicular to the beam with cross-range spread rσθ, 5 cm here against 2 cm in range." Then validate against Monte Carlo without being asked, and close on the thing that actually kills systems: the mean shift, not the covariance error. The bias is rσθ2/2 — 0.125 mm at 10 m, 0.75 mm at 60 m, growing linearly in range. But divide it by the cross-range spread rσθ and the ratio is σθ/2, independent of range. That inversion is worth stating explicitly, because most people assume long range is what breaks an EKF: it is not, it is coarse bearing. A 5 mrad lidar is 0.25% biased at any range; a 2° radar (35 mrad) is 1.75%; a 17° bearing estimate is 15% and the linearisation is finished. And whichever regime you are in, averaging kills variance as 1/N and does nothing whatsoever to the bias — a confident wrong answer, not a noisy one.
A debug hypothesis is only as good as the metric that separates it from its nearest decoy, with the value that distinguishes them. "I would look at the innovations" settles nothing. "I would compute average NIS; if it is near 1 the covariance is honest and my hypothesis is wrong" is a claim you can falsify. Every row below names that metric and the number.
| Symptom | Most likely root cause | The metric that reveals it | Fix |
|---|---|---|---|
| Reported σ = 2 cm, measured error = 40 cm; no divergence, no crash | Under-modelled process noise, or correlated measurements fed in as independent | Average NIS = (0.40/0.02)2 = 202 = 400 against an expectation of m = 1 — see worked example 9. Then the lag-1 innovation autocorrelation separates the two candidates: near 0 (say |ρ1| < 0.1) means the innovations are white and the covariance is simply too small → a Q problem; 0.6–0.9 means each innovation half-repeats the last one, so you are counting the same information several times → correlated measurements. This is the discriminating number; NIS alone cannot tell them apart, because both inflate it. | Inflate Q along the offending directions, or decimate / decorrelate the measurement stream. Sanity-check the scale of the fix before applying it: NIS is 400× too large, so if it is purely a Q deficit the missing process variance is of order (400 − 1) × the current innovation variance — if that implies a physically absurd Q, the cause is correlation, not Q. Then put the NIS band in CI so it cannot regress. |
LinAlgError: not positive definite, forty minutes into every mission and never at startup |
Cancellation in the naive (I−KH)P update on an ill-conditioned P | Minimum eigenvalue of P plus the asymmetry ‖P−PT‖F / ‖P‖F, logged every step. Asymmetry climbing through 10−8 is the early warning. | Re-symmetrise (free) → Joseph form (2× the update cost) → square-root form (the permanent fix). |
| Bundle adjustment converges in one iteration and returns the initial guess | Units in R — residuals in normalised coordinates weighted with pixel-scale sigmas, wrong by fx2 | Final cost against its chi-square expectation (M−N)/2, plus the per-residual-block breakdown. One block owning 99% of the cost names the culprit immediately. | Whiten consistently: pick one residual space and express R in the same units. Add a test asserting the final cost is within 5× of (M−N)/2. |
Adding HuberLoss changed the answer by less than a millimetre |
Masking — δ came from a scale estimated on a contaminated fit, so nothing was ever down-weighted | The weight histogram. If every weight is above 0.9, the kernel never engaged. Cross-check against a RANSAC or LMedS fit; disagreement beyond the parameter σ confirms the wrong basin. | Initialise IRLS from a consensus fit, and fix δ from prior sensor knowledge rather than from contaminated residuals. |
| VIO drifts slowly over minutes; reprojection error, NIS and tracking all healthy; a restart helps briefly | A robust kernel was attached to the marginalisation prior, so the window keeps forgetting its own history | The applied weight on every non-association factor. Priors and IMU factors must read exactly 1.000; anything below that is the bug, and no other diagnostic surfaces it. | Remove the kernel from prior and IMU blocks, and assert weight == 1 in the factor builder so it cannot come back. |
| The solver reports success but the covariance carries eigenvalues around 1012 | Gauge freedom — monocular VIO has four unobservable directions (global position and yaw about gravity) | Count the eigenvalues of Λ below 10−9λmax. Exactly 4 is expected; more means a structural defect such as a landmark seen from a single view. | Gauge-fix the first pose or add a weak prior on those four directions. If the count is wrong, hunt the disconnected variable instead. |
| Problem | Classical | Modern | When to use which |
|---|---|---|---|
| Propagating uncertainty through a nonlinearity | EKF — one Jacobian, cheapest possible | InEKF (exact log-linear error for group-affine systems); UKF (2n+1 sigma points, no Jacobian) | EKF while the function is near-linear over one sigma. InEKF whenever the state lives on a Lie group, which in robotics is nearly always. UKF when the Jacobian is painful to derive and 31 propagations fit the budget. |
| Choosing the noise model R | Datasheet plus a NIS-driven scale sweep on recorded bags | Learned per-measurement covariance with a Cholesky head (AI-IMU, DICE) | Hand-tuned for anything safety-critical that has no online consistency monitor. Learned once you have the monitor, the fallback, and a domain you can characterise. |
| Estimating a state from many measurements | EKF — O(n2) per update, dense P | Factor graph plus iSAM2 — sparse, incremental, relinearises the past | Filter for a small fixed state at high rate (attitude, a 15-state INS). Graph the moment landmarks enter the state or you want to revisit past decisions. |
| Solving the normal equations | Cholesky on HTR−1H with a Schur complement for landmarks | Square-root formulations (√BA, square-root marginalisation) | Cholesky in double precision on well-conditioned problems — it is genuinely faster. Square-root whenever you are in float32, on a mobile GPU, or κ(H) is above about 108. |
| Surviving outliers | Chi-square gate → RANSAC → Huber kernel | Graduated non-convexity; adaptive learned loss; certifiable truncated least squares (TEASER) | Gate plus Huber inside a real-time loop — convex, deterministic, bounded time. GNC or TEASER offline, at loop closure, or anywhere contamination climbs above about 30%. |
| Validating the estimator | NEES in simulation, NIS on hardware, both against chi-square bands | The same tests, wired into CI against recorded bags with a regression gate | Both, always. The modern part is not a new statistic — it is that the statistic is asserted automatically instead of eyeballed in a notebook once a quarter. |
| Tuning the whole thing | Grid sweeps over Q, R and δ on recorded bags | Differentiable least squares (Theseus): backpropagate a task loss into the weights | Sweeps when you have fewer than about five knobs and good bags. Differentiable tuning when the knob count is large, the downstream metric is what you actually care about, and you can afford the training loop. |
The one book. Timothy Barfoot, State Estimation for Robotics (Cambridge, 2nd edition 2024). It is the only text that carries covariance, least squares, MAP and Lie-group state together in one consistent notation, and its treatment of what "the mean" even means on a manifold is the thing most engineers are missing. If you read one thing on estimation, read the first three chapters. Two runners-up worth owning: Bar-Shalom, Li & Kirubarajan, Estimation with Applications to Tracking and Navigation (2001), which is the source of the consistency-testing material behind Chapter 0; and Huber & Ronchetti, Robust Statistics (2nd ed., 2009) for the influence-function theory behind Chapter 3.
Five papers, and why each one.
One more if you have the time: Pineda et al., "Theseus: A Library for Differentiable Nonlinear Optimization" (NeurIPS, 2022) — the cleanest statement of what changes when the solver becomes a differentiable layer and R becomes something you learn rather than tune.
Five repositories, and exactly what to look at.
ceres-solver/ceres-solver — open include/ceres/loss_function.h. Every kernel in this lesson is there in about ten lines each, plus the corrector that converts ρ and ψ into the rescaled residual and Jacobian a Gauss–Newton step needs. It is the best short reference implementation of Chapter 3 anywhere.borglab/gtsam — gtsam/linear/NoiseModel.h shows how a robust model composes with a base noise model, which is exactly what makes "δ is in whitened units" true rather than a convention. Then read gtsam/nonlinear/ISAM2.cpp for the incremental machinery.HKUST-Aerial-Robotics/VINS-Mono — vins_estimator/src/factor/marginalization_factor.cpp. This is where "the marginalisation prior is the P−1x̄ term" stops being abstract and becomes several hundred lines of real code.MIT-SPARK/TEASER-plusplus — the graduated non-convexity loop and the truncated-least-squares scale solver. Small, readable, and the fastest way to understand what "certifiable" actually buys you.facebookresearch/theseus — the differentiable Gauss–Newton and Levenberg–Marquardt layers plus the implicit-function-theorem backward pass. Read the backward pass; it is the part that makes a learned R possible.Everything above is knowledge. This section is practice — the part that decides whether the knowledge arrives on time. Fluency is mostly retrieval speed on a small set of numbers, and it is trainable.
The order-of-magnitude drill. Cover the right two columns. Answer each to one significant figure, out loud, in under five seconds. If you need a calculator for any row, you do not know that row yet.
| Question | Answer (1 s.f.) | The arithmetic, in one line |
|---|---|---|
| 2-D innovation gate at 95%? | 6 | −2 ln(0.05) = 5.991; radius √5.991 = 2.45σ |
| 6-DOF relative-pose gate at 95%? | 13 | χ26 at 0.95 = 12.59; radius 3.55σ |
| Bytes of P for a 15-state INS? | 2 kB | 152 = 225 doubles × 8 B = 1,800 B |
| Bytes of P for 500 landmarks + 15 nav states? | 20 MB | 1,5152 × 8 B = 18.4 MB |
| Flops per dense covariance propagation at n = 1,515? | 4 × 109 | n3 = 1,5153 = 3.5 × 109 |
| NIS when σ is reported 20× too small, m = 1? | 400 | (20σ/σ)2 = 202; variance error, not sigma error |
| Two 6×6 covariance blocks at 50 Hz on the wire? | 30 kB/s | 2 × 36 × 8 = 576 B × 50 = 28.8 kB/s |
| RANSAC iterations, homography, 50% inliers, p = 0.99? | 70 | log(0.01)/log(1 − 0.54) = 71.4 → 72 |
| MAD-to-sigma consistency factor? | 1.5 | 1/Φ−1(0.75) = 1/0.6745 = 1.4826 |
| Huber δ for 95% efficiency at the Gaussian? | 1.3σ | 1.345 × the robust scale, in whitened units |
| Cross-range spread at 10 m with 5 mrad bearing noise? | 5 cm | rσθ = 10 × 0.005; range term is only 2 cm |
| Schur speed-up, 10 keyframes × 15 + 200 landmarks × 3? | 100× | 7503/1503 = 53 = 125 |
| Monte Carlo samples for ~1% covariance accuracy? | 20,000 | √(2/N) = 0.01 ⇒ N = 2/10−4 |
| NIS window std at m = 2, N = 50? | 0.3 | √(2m/N) = √(4/50) = 0.283; band 2 ± 0.85 |
| Digits lost forming the normal equations at κ(H) = 104? | 8 | κ2 = 108; fine in float64, fatal in float32 |
Three of these — 5.991, 1.4826, 1.345 — are the constants most often quoted without provenance, and each has a one-line origin in the table's third column. If someone asks "where does 1.345 come from?" and you say "it is the standard value", you have just told them how you treat every other constant in your code.
The rehearsal protocol. Reading this chapter is not practising it. Three passes, in this order, and the third is the one that matters:
Option 1 is the answer, and the argument is ordering, not merit. It is roughly four lines of code, it needs no ground truth so it runs on customer hardware in the field, and it is the only one of the three that changes what you can know rather than what you can compute. Concretely: on this stack it would have returned 400 against an expectation of 1 (worked example 9) on day one, which is the entire bug, found before a single line of the estimator was touched. And it makes the other two options evaluable — without it, "the factor graph made things better" is an opinion.
Option 0 is a genuinely good engineering decision and still the wrong first move. Relinearisation is a real fix for a real defect — section 1b(b) shows the discarded second-order term is a bias that no amount of data averages away, and a smoother is the principled cure. But it is a quarter of work, it introduces an elimination ordering, a marginalisation policy, and at least one failure mode invisible to every existing diagnostic (debug row 5). You would be shipping a larger, subtler estimator with no instrument capable of telling you whether it is more honest than the one it replaced. If NIS was 400 before, it can be 400 after, and nobody would know.
Option 2 is defensible, has real precedent, and is out of order by two steps. AI-IMU reaches roughly 1% drift on KITTI doing exactly this, and R genuinely is the one component nobody hand-tunes well. But a learned R is a component whose only safety argument is the consistency monitor: its guard rails (clamped eigenvalues, Cholesky parameterisation, a fallback to the hand-tuned prior) all key off the monitor tripping. Shipping the learned head first means shipping a model whose out-of-distribution behaviour is unobservable. The ordering is monitor → fallback path → learned head, and it is not negotiable.
The transferable form of the answer, which is what the question is really probing: when three improvements are all real, ship the one that makes the other two measurable. That sentence works for an estimation stack, a latency budget, and an ML platform without changing a word.
"It is better to be roughly right than precisely wrong."
— attributed to Carveth Read, and the whole of Chapter 3 in nine words
Want to pressure-test all of this? The Studio button at the top runs a timed practice session on exactly this material.
Bridges from this lesson: Bayesian Estimation · SLAM: Factor Graphs · Loss Functions · Fisher Information & the CRLB · EKF · UKF