Robotics Engineering · Lesson 3 of 26

Uncertainty, Least Squares
& Robust Costs

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.

Prerequisites: matrix multiply + the idea of a Gaussian. Everything else is built here.
6
Chapters
6
Simulations
3
Code Labs

Chapter 0: The Lying Filter

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."

This is the whole lesson in one question. A twenty-times overconfidence is not a bug you find by reading code. It is a modelling failure, and the only way to catch it is to have a number that measures the agreement between claimed uncertainty and observed error. That number exists, it takes four lines to compute, and most engineers have never heard of it.

Why estimation starts here

Every robotics perception problem — SLAM, VIO, calibration, sensor fusion, tracking, even manipulation — is downstream of three ideas:

  1. A covariance is a shape, not a number. It says which directions you know well and which you do not, and it changes shape every time it passes through a nonlinear function.
  2. Almost every estimator you will ever write is the same optimisation. Least squares, weighted least squares, MAP, the Kalman update, bundle adjustment, factor graphs — one derivation, five names, and knowing that they are the same thing is the single highest-leverage fact in the field.
  3. That optimisation assumes Gaussian noise, and your data is not Gaussian. One bad correspondence, one multipath GPS fix, one loop closure to the wrong aisle, and a quadratic cost hands the whole solution to the outlier.

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, conjugacyBayesian Estimation
Factor graphs, the sparsity structure, iSAM, marginalisationSLAM: Factor Graphs
Loss functions as a family, gradients, why a shape mattersLoss Functions
The theoretical floor on any unbiased estimator's covarianceFisher 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.

The five questions, on this topic

LensThe question this topic keeps askingWhat 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 uncertainty actually lives in a robot

"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:

Sensor datasheet → noise model
IMU: gyro noise density 0.005 °/s/√Hz, bias instability 10 °/h. LiDAR: σrange = 2 cm, σbearing = 0.1°. These become the diagonal of R.
↓ propagated by the motion model
Filter state covariance P
15-state INS error model (3 pos, 3 vel, 3 attitude, 3 gyro bias, 3 accel bias) → P is 15×15 = 225 doubles = 1.8 kB, propagated at the 200 Hz IMU rate.
↓ mapped into measurement space
Innovation covariance S = H P HT + R
For a 2-D landmark observation, S is 2×2. This is the matrix that decides whether a measurement is believable — the gate.
↓ published on the bus
ROS nav_msgs/Odometry
float64[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.
↓ consumed downstream
Planner / costmap / controller
The planner inflates obstacles by k·σ. The controller gates a manipulation attempt on trace(P) < threshold. Every lie in P becomes a physical margin that is too small.
The point in one sentence: "Covariance is not diagnostics. It is a control input. The planner literally multiplies it by a safety factor and turns it into clearance in metres. If it is wrong by 20×, the clearance is wrong by 20×."

Three reasons a filter lies, and how each one shows up

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.

CauseFingerprint in the dataThe number that reveals it
Under-modelled Qtrace(P) decays monotonically to a floor; error keeps growingAverage NIS drifts steadily above the upper chi-square bound
Correlated measurementsNIS looks fine at first and degrades with measurement rate; decimating the input by 10× makes the filter honest againAutocorrelation of the innovation sequence at lag 1 — should be near 0, will be 0.6–0.9
Linearisation errorOnly bad in high-curvature regimes: large bearing uncertainty, long range, fast rotationCompare JΣJT against a 10k-sample Monte Carlo push-through; look at the mean shift, not just the spread

The number that catches the lie: NIS

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:

NISk = ykT Sk−1 yk

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

S = L LT

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

Cov(u) = L−1 S L−T = L−1 (L LT) L−T = (L−1L)(LTL−T) = I · I = I

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:

yTS−1y = yTL−TL−1y = (L−1y)T(L−1y) = uTu = u12 + u22 + … + um2

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:

L11 = √0.0100 = 0.10     L21 = 0.0040 / L11 = 0.0040 / 0.10 = 0.040
L22 = √(0.0025 − L212) = √(0.0025 − 0.0016) = √0.0009 = 0.030

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:

row 1:   0.10 · u1 = −0.12  ⇒  u1 = −1.20
row 2:   0.040 · u1 + 0.030 · u2 = 0.15  ⇒  u2 = (0.15 + 0.048) / 0.030 = 0.198 / 0.030 = +6.60
uTu = (−1.20)2 + (6.60)2 = 1.44 + 43.56 = 45.0

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.

Why this derivation is worth owning. It is short enough to do in ninety seconds by hand, and it forces you to say the three things that separate a user from an engineer: that S is positive definite and therefore factorable, that L−1y is dimensionless, and that a chi-square is defined as a sum of squared standard normals rather than being a table you look things up in. Knowing only that "NIS should be about m" leaves you unable to answer "why m?" — and that is the question that matters.

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:

NIS = (0.40)2 / 0.0004 = 0.16 / 0.0004 = 400

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 four lines, in code

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."

The trap in this snippet. 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

k=1..50 NISk  ∼  χ2(N·m) = χ2(50)

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:

32.36 / 50 = 0.647      71.42 / 50 = 1.428

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

band on the average = [ χ20.025(N·m) / N ,   χ20.975(N·m) / N ]

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:

χ2p(d)  ≈  d · ( 1 − 2/(9d) + zp·√(2/(9d)) )3     with   z0.025 = −1.96,   z0.975 = +1.96

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:

low:   50 × 0.864893 = 50 × 0.64697 = 32.35   →   /50 = 0.647
high:   50 × 1.126223 = 50 × 1.42848 = 71.42   →   /50 = 1.428

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 NMeas. dim mExpected average NIS95% acceptance band on the average
3011.00[0.560, 1.566]
5011.00[0.647, 1.428]
5022.00[1.484, 2.591]
10022.00[1.627, 2.411]
50011.00[0.880, 1.128]
5066.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."

What this looks like in CI. One test, one assertion: replay a recorded bag, collect innovations, and assert that the fraction of windows outside the band is under 5%. Not "assert mean NIS is close to 1" — that hides a filter that is wildly inconsistent for ten seconds in a doorway and fine everywhere else. The fraction of failing windows is the metric that catches regime-dependent failures, and regime-dependent failures are the ones that reach customers.
NEES vs NIS — know both names. NEES (normalised estimation error squared) uses the true state: (x − x̂)T P−1 (x − x̂), expected value = state dimension. It is the stronger test, and it needs ground truth, so it lives in simulation and on the motion-capture rig. NIS uses only innovations, needs nothing external, and runs in production. Say both; say which one runs where.

Second worked example — NEES on a 2-D state, by hand. On the motion-capture rig you have truth. The filter reports

x̂ = (1.12, 1.85)    P = [[0.0100, 0.0040], [0.0040, 0.0025]]    xtrue = (1.00, 2.00)

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

P−1 = (1 / 0.000009) · [[0.0025, −0.0040], [−0.0040, 0.0100]] = [[277.78, −444.44], [−444.44, 1111.11]]

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.

P−1e = ( 277.78×(−0.12) + (−444.44)×0.15 ,   (−444.44)×(−0.12) + 1111.11×0.15 )
= ( −33.33 − 66.67 ,   53.33 + 166.67 ) = ( −100.0 ,   220.0 )
NEES = eT(P−1e) = (−0.12)×(−100.0) + (0.15)×(220.0) = 12.0 + 33.0 = 45.0

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.

The trap this exposes: most teams log 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.

Detecting cause 2: innovation whiteness

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.

ρ1 = ( ∑k=1..N−1 yk yk−1 ) / ( ∑k=0..N−1 yk2 )

Worked by hand on eight innovations from a scan-matching update, in metres:

y = [ +0.31, +0.28, +0.33, +0.25, −0.30, −0.27, −0.31, −0.29 ]

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:

kyk−1ykproduct
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.

ρ1 = 0.4413 / 0.6890 = 0.640

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:

sd(ρ1) ≈ √((N−1)σ4) / (Nσ2) = √(N−1)·σ2 / (N·σ2) = √(N−1)/N ≈ 1/√N

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:

Nnoise floor 1/√Nρ1 = 0.640 is…95% threshold 1.96/√NVerdict
81/2.828 = 0.3540.640/0.354 = 1.81σ0.6930.640 < 0.693 — suggestive, cannot convict
501/7.071 = 0.1410.640/0.141 = 4.53σ0.2770.640 > 0.277 — conclusive
2001/14.142 = 0.07070.640/0.0707 = 9.05σ0.1390.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 five-line whiteness check

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.

The finding, said plainly: "Mean-zero is necessary but not sufficient. My innovations here have a mean of exactly zero and a lag-1 autocorrelation of 0.64 — the sign flips in blocks, which is the fingerprint of a slowly varying bias, not of white noise. I would look for a stale extrinsic or an overlapping-measurement problem before touching Q."

What a 20× lie costs, in metres

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.

Vocabulary you need instant recall of

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.

TermOne-sentence definitionWhere you meet it
Covariance PThe 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 yz − ẑ — the part of the measurement your model did not predict. The only new information in the update.Every filter update
Innovation covariance SH P HT + R — state uncertainty pushed into measurement space, plus sensor noise. The denominator of every gate.Gating, NIS
Mahalanobis distanceThe length of a vector measured in units of the local standard deviation, in every direction at once.Data association
NIS / NEESConsistency statistics; expected value equals measurement dimension / state dimension respectively.Field diagnostics / sim CI
Residual vs innovationInnovation uses the prior estimate; residual uses the posterior. Their covariances differ, and using the wrong one makes your gate optimistic.A classic gotcha
WhitenessZero autocorrelation at every non-zero lag. A necessary property of an optimally filtered innovation sequence.Filter validation
MADMedian absolute deviation; σ̂ = 1.4826 × MAD is a robust scale estimate that survives up to 50% contamination.Robust cost tuning
The residual-vs-innovation gotcha, spelled out. After the update, the posterior residual z − H x̂+ is smaller than the innovation z − H x̂, because the update moved the estimate toward the measurement. Its covariance is (I − HK)R, not S. If you gate on the posterior residual using S you will accept nearly everything, including the outliers you built the gate to reject. Gate on the innovation, before the update.

Would you let a network output your covariance?

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:

L = |y − ŷ|2 / (2σ2) + ½ log σ2

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.

The answer, in one breath: "I would let a network output the covariance, because a learned R captures scene-dependent noise that a constant diagonal cannot — TLIO does exactly this in a real EKF. But I would ship it behind a NIS gate stratified by operating regime, because Ovadia 2019 shows learned uncertainty decalibrates under distribution shift, and distribution shift is the normal condition of a deployed robot. The network changes where R comes from. It does not change my obligation to prove R is honest."
The Overconfidence Meter

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.

Q ratio (modelled / true)1.00
Meas. correlation ρ0.00
 

What to notice, in order.

  1. Cause 1 — drive Q ratio to 0.06 or below. The teal envelope pinches shut while the orange error keeps wandering, and the "outside 3σ" readout jumps from 0% to 64% of steps against an expectation of 0.3%. On the default noise draw, mean NIS climbs to 1.59 at Q ratio 0.06 and 1.90 at the 0.02 minimum, against a ceiling of 1.428 — the bar turns red and the verdict reads "overconfident by 1.3× in sigma". The envelope collapse is the visual; the NIS number is the thing you can put in CI.
  2. Cause 2 — put Q back to 1.00 and push correlation to 0.9. The envelope looks plausible and the error looks plausible, but mean NIS falls to about 0.38 and the lag-1 autocorrelation readout climbs to 0.66 against a Bartlett threshold of 1.96/√50 = 0.28. Note the direction: correlated measurement noise pushes NIS down, not up, because the filter has partly tracked the correlated error into its own estimate, so the innovation it sees is smaller than S predicts. Down is still inconsistent — a filter that is under-confident by 2.6× in variance is throwing away information, and the same modelling error that shrinks NIS here inflates the error outside the envelope to 38% of steps.
  3. The interesting middle — set correlation to 0.5. Mean NIS sits at about 0.86, comfortably inside [0.647, 1.428], and yet the autocorrelation reads 0.38 against the same 0.28 threshold. NIS says healthy; whiteness says no. This is the case that survives code review, because every number on the dashboard except one looks fine. That is why you run both tests and why the verdict line reports both — a single consistency scalar is not a consistency test.
  4. Press "New noise draw" repeatedly at Q ratio 0.06. The bar flickers between red and green. That is not a bug — it is the finite power of a 50-sample window. The long-run average NIS at that setting is about 1.5 while the N = 50 ceiling is 1.428, so a single window convicts only about half the time. Widen to N = 500 and the ceiling drops to 1.128, where 1.5 can never hide. That tradeoff is exactly why the CI gate runs the band over a whole bag while the on-robot health flag runs a short window: the short window tells you fast and lies sometimes; the long window tells you slowly and does not.
The widget as a working answer. Everything on that canvas is one page of code: predict, innovate, compute NIS, average, compare to a band you derived rather than looked up. If you can rebuild this plot in fifteen minutes on a laptop — and you can, it is under sixty lines — you can answer any "how would you know your filter is lying?" question by building the answer instead of describing it.

How the rest of the lesson goes

Once you have NIS, the questions move. The rest of this lesson is that movement:

ChapterThe questionThe 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.
5The field guide: cheat sheet, design patterns, coding drills, debugging scenarios, and the reading list.

The ninety-second diagnosis

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 answersWeakStrong
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.
Field note — the opening move. When you meet a vague uncertainty requirement ("handle the noise"), do not start listing filters. Start by asking what the consumer of the estimate does with the uncertainty. "Does anything downstream read the covariance, or is it just logged?" If nothing reads it, the right answer is often to stop maintaining it honestly and spend the effort elsewhere. If the planner reads it, then it is a safety-relevant output and it needs a consistency test in CI. That question is the difference between knowing filters and knowing what the filter is for.
Your localisation node reports 2 cm 1σ while the true error is 40 cm. Before touching any tuning parameter, what is the first thing you compute, and what does it tell you?
Why that quiz has no throwaway option. All three are things a competent engineer would actually say, and two of them are correct facts in the wrong order. Ordering is the skill being tested: quantify the inconsistency with the statistic that runs anywhere, then attribute it, then tune. Naming NIS does not by itself demonstrate that you understand consistency; explaining why NIS comes before NEES even when truth is sitting right there on the laptop does.

Chapter 1: Covariance, Propagation & Mahalanobis Distance

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 a shape

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.

Σ = E[ (x − μ)(x − μ)T ]    with   μ = E[x]

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:

(2, 3)   (4, 7)   (6, 7)   (8, 11)   (10, 12)

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):

EntrySum of products÷ 4
Σxx16 + 4 + 0 + 4 + 16 = 4010
Σyy25 + 1 + 1 + 9 + 16 = 5213
Σxy20 + 2 + 0 + 6 + 16 = 4411
Σ = [[10, 11], [11, 13]]    det Σ = 10×13 − 11×11 = 130 − 121 = 9

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:

λ = [ 23 ± √(232 − 4×9) ] / 2 = [ 23 ± √(529 − 36) ] / 2 = [ 23 ± √493 ] / 2
√493 = 22.204  ⇒  λ1 = 45.204/2 = 22.602,   λ2 = 0.796/2 = 0.398

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.

The 1σ ellipse does NOT contain 68% of the probability — that is a 1-D fact. In 2-D, the ellipse at √(chi-square) = 1 contains 1 − e−0.5 = 39.4%. To draw a genuine 95% ellipse in 2-D you scale the semi-axes by √5.991 = 2.448, not by 1.96. Getting this wrong makes every uncertainty plot you have ever shipped optimistic — go check your plotting code.
Dimension√chi-square for 95%Mass inside the 1σ contour
11.96068.3%
22.44839.4%
32.79619.9%
6 (a full pose)3.5481.4%

Propagation through a nonlinear function

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 μ:

f(μ + δ) ≈ f(μ) + Jδ    where   Jij = ∂fi/∂xj evaluated at μ

So the error in y is approximately Jδ, and its covariance is

Σy = E[ (Jδ)(Jδ)T ] = J E[δδT] JT = J Σ JT

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.

Σpolar = [[0.01, 0], [0, 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, θ):

J = [[ ∂x/∂r, ∂x/∂θ ], [ ∂y/∂r, ∂y/∂θ ]] = [[ cosθ, −r sinθ ], [ sinθ, r cosθ ]]
= [[ 0.866025, −5.000000 ], [ 0.500000, 8.660254 ]]

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):

JΣ = [[ 0.00866025, −0.00609235 ], [ 0.00500000, 0.01055222 ]]

Then (JΣ)JT, where JT = [[0.866025, 0.500000], [−5.000000, 8.660254]]:

EntryArithmeticValue
[0][0]0.00866025×0.866025 + (−0.00609235)×(−5.0) = 0.0075000 + 0.03046170.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.09138520.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.

The one-line cross-check worth doing every time. Rotating a diagonal [[σr2, 0], [0, (rσθ)2]] by the bearing angle must reproduce your answer, because that is what J does here. Check the [0][0] entry: σr2cos230° + (rσθ)2sin230° = 0.01×0.75 + 0.121847×0.25 = 0.0075 + 0.030462 = 0.037962. Exactly the entry above. Doing this check is worth more than getting the multiplication right the first time.

Where the linearisation stops being true

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 meanTrue (Monte Carlo) meanMean shifttrace ratio (MC / linear)
(8.660, 5.000)(8.655, 4.997)0.6 cm1.003
10°(8.660, 5.000)(8.530, 4.924)15 cm0.982
25°(8.660, 5.000)(7.879, 4.547)90 cm0.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.

The rule of thumb worth memorising: linearisation is safe while the function is nearly linear over the span of one standard deviation. For polar-to-Cartesian that means rσθ should be small compared with r — equivalently σθ below roughly 5°. When it is not, you have three options in increasing cost: iterate the linearisation point (IEKF), use sigma points (UKF, 2n+1 propagations), or carry particles. Name the cost of each and you have answered the question fully.

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.

Mahalanobis distance: the only distance that matters

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:

d2 = (z − ẑ)T S−1 (z − ẑ) = yT S−1 y

The cleanest way to see what it does is to factor S = LLT (a Cholesky decomposition, L lower-triangular). Then

d2 = yT(LLT)−1y = (L−1y)T(L−1y) = ‖ L−1y ‖2

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:

S−1 = (1/9) · [[13, −11], [−11, 10]] = [[1.4444, −1.2222], [−1.2222, 1.1111]]

Residual A: y = (3, 2). Euclidean length √13 = 3.606.

S−1y = (1.4444×3 − 1.2222×2,   −1.2222×3 + 1.1111×2) = (4.3333 − 2.4444,   −3.6667 + 2.2222) = (1.8889, −1.4444)
d2 = 3×1.8889 + 2×(−1.4444) = 5.6667 − 2.8889 = 2.778

Residual B: y = (3, −2). The same Euclidean length, √13 = 3.606.

S−1y = (1.4444×3 + 1.2222×2,   −1.2222×3 − 1.1111×2) = (4.3333 + 2.4444,   −3.6667 − 2.2222) = (6.7778, −5.8889)
d2 = 3×6.7778 + (−2)×(−5.8889) = 20.333 + 11.778 = 32.111

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.

This example is the answer to "why not just use a distance threshold?" A Euclidean gate is a circle. The true acceptance region is the ellipse S declares. Any single circular threshold either rejects good associations along the uncertain axis or accepts garbage across the certain one. On a range-bearing sensor with a 7× axis ratio, you get both errors at once.

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 m90% gate95% gate99% gateTypical use
12.7063.8416.635Scalar range, altimeter, wheel-odometry scalar
24.6055.9919.210Image feature (u, v); 2-D landmark bearing/range
36.2517.81511.3453-D point, GPS fix, magnetometer vector
610.64512.59216.812A full relative pose from scan matching
Choosing the gate is a business decision, not a maths one. A 99% gate accepts 1% of good measurements being thrown away and lets more outliers in. A 90% gate rejects one in ten valid observations. In a feature-rich scene where you have 400 candidate matches per frame, gate tight (90%) — you can afford to lose good data. In a feature-starved corridor where you have eleven, gate loose (99%) and lean on a robust cost downstream to survive the outliers you let in. Weighing that tradeoff explicitly is what separates a memorised formula from an engineered choice.

Where this lives in a real system

Here is the data-association path on a mid-size warehouse AMR, with the shapes and the clock:

LiDAR scan → segmented candidates
VLP-16 at 10 Hz → 300k points/s → ~40 pole/corner candidates per scan, each (r, θ) plus a 2×2 Σpolar. Latency budget for the whole association step: 8 ms.
↓ propagate each: Σxy = J Σpolar JT
40 candidates in the sensor frame
40 × (2-vector + 2×2 matrix) = 40 × 6 doubles = 1.9 kB. Cost: 40 × ~30 flops = 1.2 kflop. Free.
↓ predict map landmarks into the sensor frame
Innovation covariance S = H P HT + R
One 2×2 per (candidate, landmark) pair. With 500 map landmarks that is 20,000 pairs — but a KD-tree on the predicted means with a radius of 3√λmax cuts it to ~3 candidates per query, so ~120 real pairs.
↓ gate: d2 = yTS−1y vs 5.991
Accepted associations
~28 of 40 survive. Cost per gate: a 2×2 Cholesky (4 flops) plus two triangular solves (6 flops) plus a dot product (3 flops) ≈ 15 flops. 120 × 15 = 1.8 kflop. Total association cost: well under 0.1 ms.
↓ update the filter / add factors to the graph
Posterior pose + covariance
Published as 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 number that surprises people: gating is essentially free. Fifteen floating-point operations per candidate pair. The expensive part is finding the candidate pairs, which is a spatial-indexing problem, not a statistics problem. If someone tells you their data association is slow, ask about the KD-tree before you ask about the maths.

The cost that is not free: carrying P. This is where EKF-SLAM died, and the arithmetic is worth having ready.

OperationComplexity15-state INS @ 200 HzEKF-SLAM, 500 3-D landmarks (n = 1515)
Memory for PO(n2)225 doubles = 1.8 kB2.29M doubles = 18.4 MB
Propagate P = FPFT + QO(n3)3,375 flops → 0.7 Mflop/s3.5×109 flops → 700 Gflop/s at 200 Hz — impossible
Update P for one 2-D measurementO(mn2)450 flops4.6M flops × 30 meas × 10 Hz = 1.4 Gflop/s
Sliding-window BA instead: 10 keyframes, 200 landmarkssparse + SchurSchur-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.

FormWhat you storeCondition numberPositive-definitenessUsed by
CovarianceP (n×n symmetric)κMust be enforced; can be lost to roundingTextbook KF, simple EKFs
InformationΛ = P−1κSame riskInformation filters, marginalisation
Square-root covarianceL with P = LLT√κStructural — LLT is PSD by constructionSR-UKF, Apollo-era navigation
Square-root informationR with Λ = RTR√κStructural√SAM, GTSAM, iSAM2, VINS-Mono marginalisation
ROS conventions that cause real field bugs. 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.

The code: propagate, gate, update

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.

Debugging: three failure modes

Failure mode 1 — loss of positive definiteness.

Failure mode 2 — degrees where radians belong. This one deserves its own bullet because the signature is so distinctive.

Failure mode 3 — the Jacobian evaluated at a stale point.

The frontier: invariant filters and learned noise

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.

Have the objection ready. The moment you propose a learned covariance, the right challenge is: how do you bound its failure? The answer: constrain the network to output a Cholesky factor (so it cannot emit a non-PD matrix), clamp the eigenvalues to a hand-derived physical range (so a hallucination cannot claim micrometre precision), and keep NIS monitoring in the loop — if the learned model drifts out of distribution, the consistency test catches it in fifty samples and you fall back to the hand-tuned prior. Learning the noise model is safe precisely because it is checkable online.

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.

Try it: the propagation bench

Propagation Bench — when does JΣJT stop being true?

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.

Range r (m)10.0
σrange (m)0.10
σbearing (deg)2.0
 

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.

Two candidate matches have the same Euclidean residual length of 3.6 m against a landmark whose innovation covariance is S = [[10, 11], [11, 13]]. One is at (3, 2) and one at (3, −2). Your gate is the 95% chi-square threshold for a 2-D measurement. What do you do with each, and what is the principle?

Chapter 2: Least Squares → Weighted → MAP

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.

One derivation, five estimators

Here is the single most valuable structural fact in estimation, and it is worth stating before you write anything:

Ordinary least squares, weighted least squares, generalised least squares, ridge regression, Tikhonov regularisation, MAP estimation, the Kalman update, and bundle adjustment are the same optimisation problem. They differ only in whether the noise covariance is a scaled identity, and whether there is a prior term. One derivation covers all eight names. Deriving it once, from the likelihood, is strictly faster than remembering eight formulas.

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.

z = Hx + v,    v ~ N(0, R)

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:

p(z | x) = (2π)−m/2 |R|−1/2 exp( −½ (z − Hx)T R−1 (z − Hx) )

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:

J(x) = ½ (z − Hx)T R−1 (z − Hx)
There is the answer to "why squares?" Not because squares are differentiable, not because they are convex, not because Gauss liked them. Squares appear because the Gaussian has a quadratic in its exponent. Change the noise distribution and the cost changes shape: Laplacian noise gives you an absolute-value cost (and the median instead of the mean), Student-t noise gives you a robust redescending cost — which is exactly Chapter 3. That one sentence is the difference between remembering least squares and understanding it.

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:

∂J/∂x = −HTR−1z + HTR−1Hx = 0
⇒   (HTR−1H) x̂ = HTR−1z    — the normal equations

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?"

Worked example 1 — three range measurements, three estimators

Three sensors measure the same distance to a beacon. Two are good laser rangefinders, one is a cheap ultrasonic:

SensorReading ziσiWeight wi = 1/σi2
Laser A10.20 m0.10 m100
Laser B9.80 m0.10 m100
Ultrasonic12.00 m1.00 m1

OLS — every sensor equally trusted. H is a column of ones, so HTH = 3 and HTz = 10.20 + 9.80 + 12.00 = 32.00:

OLS = 32.00 / 3 = 10.667 m

WLS — trust in proportion to precision. Now HTR−1H = ∑wi and HTR−1z = ∑wizi:

∑wizi = 100×10.20 + 100×9.80 + 1×12.00 = 1020 + 980 + 12 = 2012
∑wi = 100 + 100 + 1 = 201
WLS = 2012 / 201 = 10.010 m    σ̂2 = 1/201 = 0.004975 ⇒ σ̂ = 0.0705 m

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.

The sanity check: two identical sensors with σ = 0.10 should fuse to σ = 0.10/√2 = 0.0707 m. We got 0.0705 — the third sensor contributed 1 unit of information to the other 200, which is 0.5%. That is the correct amount for a sensor ten times worse. If your fusion code reports a σ larger than the best individual sensor, you have a bug.

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:

MAP = ( ∑wizi + Λ0x̄ ) / ( ∑wi + Λ0 ) = ( 2012 + 25×9.50 ) / ( 201 + 25 )
= ( 2012 + 237.5 ) / 226 = 2249.5 / 226 = 9.954 m    σ̂ = √(1/226) = 0.0665 m

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.

NameThe expression being minimisedEstimateReported σ
OLS½‖z − Hx‖210.667 m— (assumes one σ)
WLS / GLS / MLE½(z − Hx)TR−1(z − Hx)10.010 m0.0705 m
MAP / Kalman / Tikhonovabove + ½(x − x̄)TΛ0(x − x̄)9.954 m0.0665 m

Worked example 2 — a two-parameter fit, by hand, twice

Scalars hide the matrix structure, so do a line fit. Three points, model y = a + bx, unknowns θ = (a, b):

(x, y) = (0, 1), (1, 3), (2, 4)    H = [[1, 0], [1, 1], [1, 2]],   z = (1, 3, 4)

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:

RowhihihiThizi
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]]:

θ̂ = (1/6)[[5, −3], [−3, 3]] (8, 11) = (1/6)(40 − 33, −24 + 33) = (1/6)(7, 9) = (1.1667, 1.5000)

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

Cov(θ̂) = σ̂2(HTH)−1 = 0.16667 × (1/6)[[5, −3], [−3, 3]] = [[0.13889, −0.08333], [−0.08333, 0.08333]]

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:

RowwiwihihiTwihizi
11[[1, 0], [0, 0]](1, 0)
21[[1, 1], [1, 1]](3, 3)
30.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)
det = 2.01×1.04 − 1.02×1.02 = 2.0904 − 1.0404 = 1.0500
θ̂ = (1/1.05)[[1.04, −1.02], [−1.02, 2.01]] (4.04, 3.08)
= (1/1.05)( 1.04×4.04 − 1.02×3.08,   −1.02×4.04 + 2.01×3.08 )
= (1/1.05)( 4.2016 − 3.1416,   −4.1208 + 6.1908 ) = (1/1.05)(1.0600, 2.0700) = (1.0095, 1.9714)

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.

The geometric picture, for the whiteboard. OLS projects the data vector z orthogonally onto the column space of H. WLS does the same projection, but "orthogonal" is measured in the inner product ⟨u, v⟩ = uTR−1v — you stretch the space so that noisy directions are short, then project normally. The residual is always orthogonal (in the relevant inner product) to every column of H, which is exactly what HTR−1r = 0 says.

The design: what a real back-end assembles

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:

State vector x
10 keyframes × 15 (pose 6, velocity 3, gyro bias 3, accel bias 3) = 150  +  200 landmarks × 3 = 600. N = 750 unknowns.
↓ residual blocks assembled from the factor graph
Residual vector r
visual: 200 landmarks seen in ~5 keyframes × 2 px = 2,000 rows  |  IMU preintegration: 9 rows × 9 keyframe pairs = 81  |  marginalisation prior: 150 rows. M ≈ 2,231 scalar residuals.
↓ H (the Jacobian) is 2231 × 750 and mostly zero
Information matrix Λ = HTR−1H
750×750, but arrow-shaped: a dense 150×150 camera block, a block-diagonal 600×600 landmark block (200 independent 3×3s), and sparse coupling.
↓ Schur complement: eliminate the landmarks first
Reduced camera system
150×150 dense. Cholesky = 1503/3 ≈ 1.1 Mflop instead of 7503/3 ≈ 141 Mflop — a 125× reduction in the factorisation, and inverting 200 independent 3×3 blocks costs almost nothing.
↓ back-substitute for the landmarks, iterate
Refined poses + covariance
Published at keyframe rate (5–10 Hz). Reported wall-clock for systems of this size is tens of milliseconds per solve on a laptop-class CPU; incremental solvers (iSAM2) reuse the previous factorisation and update in single-digit milliseconds on pose graphs an order of magnitude larger.

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.

The marginalisation prior IS the P−1x̄ term. When a keyframe slides out of the window you do not delete it — you marginalise it, which means integrating it out and folding what it knew into a dense prior block on the states it touched. That block is algebraically identical to the prior term in the MAP derivation above. Making this connection is what it means to see filtering and smoothing as one thing rather than two.

Where R actually comes from, in descending order of honesty:

SourceExampleTrustworthiness
Sensor datasheetGyro noise density 0.005 °/s/√Hz → σ2 = (0.005)2 × rateOptimistic. Datasheets quote lab conditions and best-case units.
Allan variance from a static log6–12 h of stationary IMU data → noise density and bias instability read off the plotThe right answer for an IMU. Do this once per hardware revision.
Calibration residual statisticsReprojection RMS from the checkerboard fit → pixel σGood, but optimistic: the target is planar, well-lit and centred.
Empirical, from a NIS sweepScale R until average NIS lands in its acceptance band on a recorded bagHonest, and it absorbs the unmodelled effects. This is what teams actually ship.
Learned per-measurementA network outputs a Cholesky factor per observationBest 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?

MethodApproximate cost (m rows, n cols)Effective condition numberWhen to use it
Normal equations + Choleskymn2 + n3/3κ(H)2Well-conditioned, m ≫ n, speed matters. Standard in SLAM back-ends, where the sparsity makes it a landslide win.
Householder QR on H2mn2 − 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.

The code: one function, four estimators

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
The trick worth naming out loud: "a prior is just extra pseudo-measurements". Stacking Λ01/2 as additional rows of the whitened Jacobian is exactly equivalent to adding P−1 to the information matrix, and it keeps everything in square-root form. Ceres does this with a NormalPrior cost function; GTSAM with a PriorFactor. Same algebra, three names.

Debugging: three failure modes of a solver

Failure mode 1 — rank deficiency and gauge freedom.

Failure mode 2 — units in R, the highest-frequency real bug in this whole area.

Failure mode 3 — the normal equations eating your precision.

The frontier: incremental, square-root, differentiable, certifiable

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.

Try it: the information stacking bench

Information Stacking Bench

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.

σ of the ultrasonic (m)1.00
prior mean (m)9.50
prior σ (m)0.20
 

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 bundle adjustment converges in a single iteration and returns the initial guess unchanged. Ceres reports a final cost of 1.4×105 on a problem with 2,231 residuals and 750 parameters. What do you conclude, and what is the number you compare against?

Chapter 3: Outliers & Robust Cost Functions

Your feature matcher returns one wrong correspondence in forty. What does that do to your bundle adjustment — and how do you fix it?

Why one outlier can own the fit

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:

(0, 1), (1, 3), (2, 5)   clean   |   (3, 0)   outlier — should have been (3, 7)

Assemble by rows, exactly as in Chapter 2. HTH accumulates [[1, x], [x, x2]] over x = 0, 1, 2, 3:

HTH = [[4, 0+1+2+3], [6, 0+1+4+9]] = [[4, 6], [6, 14]],   det = 56 − 36 = 20
HTz = (1 + 3 + 5 + 0,   0×1 + 1×3 + 2×5 + 3×0) = (9, 13)
θ̂ = (1/20)[[14, −6], [−6, 4]] (9, 13) = (1/20)(126 − 78,   −54 + 52) = (1/20)(48, −2) = (2.400, −0.100)

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:

xzpredictionresidual rIs this the outlier?
012.4−1.4no
132.3+0.7no
252.2+2.8 ← largestno
302.1−2.1yes
This is masking, and it is worth knowing by name. The outlier dragged the fit so far that a perfectly good point now carries the largest residual. Any scheme that says "throw away the biggest residual" deletes the wrong measurement, and then the fit gets worse. The companion effect is swamping: good points look bad because a bad one moved the model. Both mean the same thing operationally — you cannot identify outliers from residuals computed against a contaminated fit.

The three functions that describe any cost

To reason about this rather than guess, describe a cost by three related functions of the (whitened) residual r:

FunctionDefinitionWhat it means physically
ρ(r)the cost itselfHow much this residual contributes to the total you are minimising.
ψ(r) = ρ′(r)the influence functionHow hard this measurement pulls on the solution. This is the one that matters.
w(r) = ψ(r)/rthe weightThe 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 δ:

ρH(r) = ½r2   if |r| ≤ δ;    δ(|r| − δ/2)   otherwise
ψH(r) = r   if |r| ≤ δ;    δ·sign(r)   otherwise   ⇒   wH(r) = min(1, δ/|r|)

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:

ρC(r) = (c2/2) · log(1 + (r/c)2)   ⇒   ψC(r) = r / (1 + (r/c)2)   ⇒   wC(r) = 1 / (1 + (r/c)2)

Worked example 2 — the three costs at three residuals, every number. Take δ = 1 and c = 1:

rL2: ρ / ψHuber: ρ / ψ / wCauchy: ρ / ψ / w
0.50.1250.5000.1250.5001.0000.1120.4000.800
2.02.0002.0001.5001.0000.5000.8050.4000.200
10.050.0010.009.5001.0000.1002.3080.0990.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.

Bounded vs redescending — the distinction the question is really about. Huber's influence is bounded: it stops growing, so no single outlier can dominate, and the cost stays convex, so there is exactly one minimum and any solver finds it. Cauchy's influence is redescending: it goes back toward zero, so a sufficiently gross outlier is effectively ignored — but ρ is now non-convex, and a solver started in the wrong place will happily converge to a fit that treats good data as outliers. Convexity and outlier immunity are a direct trade. That sentence is the answer to "Huber or Cauchy?"

IRLS: why a robust cost is still least squares

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:

∂/∂θ ∑i ρ(ri) = − ∑i ψ(ri) hi = 0

Now use the definition w(r) = ψ(r)/r, so ψ(ri) = w(ri) ri:

i w(ri) ri hi = 0   ⇒   (HTWH) θ = HTWz,    W = diag(w(ri))
Those are the weighted least squares normal equations from Chapter 2, unchanged. The only new thing is that W depends on the residuals, which depend on θ. So you iterate: solve, recompute residuals, recompute weights, solve again. That is iteratively reweighted least squares (IRLS), and it means a robust estimator reuses every line of your existing WLS solver. Saying this converts "robust cost" from an exotic topic into a two-line change.

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:

w = min(1, 1/|r|) = (1/1.4,   1,   1/2.8,   1/2.1) = (0.7143, 1.0000, 0.3571, 0.4762)

Accumulate HTWH by rows, wi[[1, xi], [xi, xi2]]:

xww[[1, x], [x, x2]]w·z·(1, x)
00.7143[[0.7143, 0], [0, 0]](0.7143, 0)
11.0000[[1, 1], [1, 1]](3.0000, 3.0000)
20.3571[[0.3571, 0.7143], [0.7143, 1.4286]](1.7857, 3.5714)
30.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)
det = 2.5476×6.7143 − 3.14292 = 17.1052 − 9.8776 = 7.2277
θ = (1/7.2277)( 6.7143×5.5 − 3.1429×6.5714,   −3.1429×5.5 + 2.5476×6.5714 )
= (1/7.2277)( 36.9285 − 20.6531,   −17.2857 + 16.7415 ) = (1/7.2277)(16.2754, −0.5442) = (2.2517, −0.0753)

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:

σ̂ = 1.4826 × median( |ri − median(r)| )

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.

σ̂ = 1.4826 × 1.40 = 2.076   ⇒   δ = 1.345 × 2.076 = 2.792

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.

The fix, and the sentence that earns the follow-up: "A robust cost is a refinement, not a search. It cleans up the last few percent around a solution that is already roughly right. To find that solution when a third of your data is wrong, you need a consensus method — RANSAC, least-median-of-squares, or graduated non-convexity — and then you polish with IRLS." Robust kernels and RANSAC are not alternatives; they are consecutive stages.

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):

w = (1,   1,   1,   1/(1 + 49) = 0.02)

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):

HTWH = [[3.02, 3.06], [3.06, 5.18]],    HTWz = (9, 13)
det = 3.02×5.18 − 3.062 = 15.6436 − 9.3636 = 6.2800
θ = (1/6.28)( 5.18×9 − 3.06×13,   −3.06×9 + 3.02×13 ) = (1/6.28)( 46.62 − 39.78,   −27.54 + 39.26 )
= (1/6.28)(6.84, 11.72) = (1.0892, 1.8662)

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:

MethodStartIntercept (true 1.0)Slope (true 2.0)Verdict
OLS2.400−0.100Destroyed — sign inverted
Huber IRLS, δ = 1OLS2.067−0.050Stalled — masked
Huber IRLS, δ from MADOLS2.398−0.102No effect — scale inflated by the contamination (every weight ≥ 0.994)
Cauchy IRLS, c = 1RANSAC (1, 2)1.1001.848Recovered

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.

Bounded influence is not the same as a high breakdown point, and the difference is leverage. Huber caps the influence of a large residual. It does nothing about a point with extreme x, because such a point can bend the model to pass near itself, keeping its own residual small. Formally, Huber regression has a breakdown point of 0% in the presence of leverage points; least-median-of-squares has 50%. So is Huber enough? For residual outliers with balanced leverage, yes; for leverage outliers, no — and in reprojection problems, a far-away landmark is a leverage point.

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.

The design: which factors get a kernel

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 typeRobust kernel?Why
Reprojection / feature matchYesComes from data association, which is exactly where wrong answers are generated.
Loop closureYes, aggressivelyA 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 constraintYesCan converge to a wrong local minimum in a self-similar corridor.
IMU preintegrationNoPhysics, not association. If the IMU factor is wrong, the model or the calibration is wrong, and down-weighting hides it.
Marginalisation priorNeverIt is the compressed memory of everything the window has forgotten. Down-weighting it silently deletes history.
Gauge / anchor priorNeverIt exists to remove a null space. A robust kernel can switch it off and re-introduce the singularity.
δ is in units of the whitened residual, not of pixels or metres. Ceres and GTSAM both apply the loss after the noise model, so a residual of 1.0 means "one sigma". That is why δ = 1.345 is a sensible default and why it is the same number for a camera in pixels and a LiDAR in metres. If your team is tuning δ separately per sensor, someone is applying it before whitening.

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:

KernelConstant for 95% asymptotic efficiencyConvex?Influence as r → ∞
Huberδ = 1.345σYes→ δ (constant)
Cauchyc = 2.3849σNo→ 0, like c2/r
Tukey biweightc = 4.685σNoExactly 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:

ItemExtra costOn the 750-parameter, 2,231-residual window from Chapter 2
Computing weights each iterationO(M) — one division per residual~2,231 flops. Immeasurable next to the 1.1 Mflop factorisation.
Extra Gauss–Newton iterationstypically 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-stagek minimal solvesAt 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.

The code: IRLS in twenty lines

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

Debugging: three ways a robust kernel goes wrong

Failure mode 1 — masking: the robust fit equals the least-squares fit.

Failure mode 2 — δ too small: everything becomes an outlier.

Failure mode 3 — a robust kernel on a factor that is not an outlier source.

The frontier: GNC, adaptive kernels, certificates

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.

Try it: influence functions side by side

Influence Functions — ρ, ψ and w side by side

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.

Huber δ1.0
Cauchy c1.0
residual r3.0
 
A colleague reports that adding 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?

Chapter 4: Showcase — The Fitting Bench

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.

Robust Fitting Bench — drag a point and watch three estimators disagree

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.

Huber δ1.0
Cauchy c1.0
 

Five experiments, and the number to read in each

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 pointr against the L2 fitL2 barHuber bar (δ = 1)r against the Cauchy fitCauchy bar (c = 1)
9.976 (untouched)−0.3256.85%6.85%−0.2967.57%
9.5−0.76614.94%14.94%−0.74812.83%
9.2−1.04419.44%18.7564% ← locks here−1.04613.31% ← Cauchy's peak
9.0−1.23022.17%18.7564%−1.24713.03%
8.0−2.15732.68%18.7564%−2.26110.13%
6.0−4.01243.15%18.7564%−4.2786.21%
4.0−5.86748.00%18.7564%−6.2854.39%
1.378−8.29950.0000% ← locks here18.7564%−8.9133.16%
−3.024 (the Throw one outlier preset)−12.38250.0000%18.7564%−13.3192.14%
−8.5 (the drag floor)−17.46050.0000%18.7564%−18.7971.53%
−30 (console only)−37.40150.0000%18.7564%−40.3000.72%
−1000 (console only)−937.04950.0000%18.7564%−1010.3030.03%
Why the last two rows say "console only". The pointer handler clamps a dragged point to the visible window, 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

wk yk = δ yk / |rk|  →  −δ        wk xk yk  →  −δ xk

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

Δθ = −δ · H−1 [ 1,  xk ]T,    H = ∑i≠k [ 1,  xi ]T[ 1,  xi ]

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

H−1[1, xk]T = (1/1747.929) · [ 463.3136 − 65.3846·4.6154 ,   13·4.6154 − 65.3846 ] = (1/1747.929) · [ 161.538 , −5.384 ]

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.

Say this sentence and the follow-up question changes. "Past δ, an outlier stops being a measurement and becomes a constant force of magnitude δ applied at its own x. Its weight in the normal matrix goes to zero, its contribution to the right-hand side goes to a constant, and the fit lands a fixed distance away that depends on δ and on the leverage — not on how bad the point is." And the displacement is linear in δ: halve δ to 0.5 and the same calculation gives Δa = −0.046209, exactly half; double it to 2 and you get −0.184834, exactly twice. That is the whole meaning of "bounded influence, permanent bias" as a formula rather than a mood.

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

shareCauchyk ≈ c² / ( |rk| · ∑i≠ki| ) = 1 / (3.4750 · |rk|)   at c = 1

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.

The one line to carry out of Experiment 2, and it is a better line than the folklore: 50.0% locked · 18.8% locked · 1.5% and still falling. Two of the three estimators reach a fixed share and sit there no matter how far you drag; only the redescending one keeps giving ground. And the two locks are locks for completely different reasons — L2 because ∑ri = 0 forces it, Huber because the outlier's weight decays exactly as fast as its residual grows. One point in fourteen, holding half the vote, permanently.

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.

That is a better claim than the folklore one, so make it the one you say. "Cauchy's cost is non-convex, and on my bench I could not make it matter — every sensible initialisation converges to the same minimum to machine precision at every kernel width I tried. What does reach the second basin is a start aimed at the outlier, and the width at which that second basin exists at all is c < 0.54 on this data. So I treat redescending non-convexity as a tuning risk with a measurable threshold, not as a runtime hazard that lurks in every solve." Non-convexity you cannot trigger from a sensible start is not a runtime risk; it is a property of the cost surface that your initialisation policy already handles. Knowing the difference — and having measured which one you have — is the whole gap between reading about this and running it.

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:

  1. Press Throw one outlier, then turn the cold start on. Set c = 1.0. Nothing happens — the purple line is still the good fit. One basin.
  2. Walk c down one step at a time. At c = 0.9, 0.8, 0.7, 0.6: still nothing. Same answer, same cost.
  3. Step from c = 0.6 to c = 0.5 and the purple line snaps to a slope of 7.7, and the readout under the canvas flips its basin gap from < 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.
  4. Turn the cold start back off at c = 0.2. The purple line jumps back to slope 2.008. Same data, same kernel, same c, same solver — two answers, and the only difference is where you started. That is what non-convexity costs you, made pressable.

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.

cFrom a sensible start (OLS / Huber / zero / truth) → (a, b)cost JCold start, the line through the outlier → (a, b)cost JΔJ
0.2(0.970, 2.008)0.4794(−88.706, 18.573)2.59975.4×
0.3(0.970, 2.013)0.8041(−87.958, 18.423)5.40676.7×
0.4(0.978, 2.013)1.1428(−86.610, 18.151)9.04647.9×
0.5(0.992, 2.011)1.4961(−38.345, 7.727)10.24646.8×
0.5364the 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.86491.0×
1.0(1.065, 2.000)3.5076(1.065, 2.000)3.50761.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 lineIntercept share (the bar)Slope share (compute it)RatioSlope error
L2y = 2.841 + 1.431x36.2%48.0%1.33×0.570
Huber, δ = 1y = 1.284 + 1.936x16.3%28.7%1.76×0.064
Cauchy, c = 1y = 1.048 + 1.997x1.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 datasetPoints below w = 1 (of 14)What the teal line is doing
1.0 (bench default)1.00000Kernel completely inert — the largest clean residual is 0.839, so nothing crosses δ.
0.5830.97351One point clipped. Invisible on screen.
0.4710.94723Still indistinguishable from the red line by eye.
0.350.88695Now visibly following the red line less closely as you nudge points.
0.200.67709Two 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.

ExperimentWhat it provesThe number to quote
1 — clean dataRobustness is nearly free when unneededAll three slopes within ~0.05 of each other
2 — one outlierL2 influence is unbounded, but its share is capped at 1/2; Huber bounded and frozen; only Cauchy redescendsShare 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 returnRedescending costs are non-convex when c is tight, and the risk is a tuning risk, not a runtime oneEvery 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 outlierBounded influence ≠ high breakdownL2 slope error 0.025 (mid) → 0.570 (x = 10). 23×
5 — δ = 0.2An over-tight kernel discards good dataMean IRLS weight 1.000 → 0.677 on this dataset (0.789 averaged over 400 draws); slope variance ×1.22

Reading the share-of-pull bars quantitatively — and what they leave out

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:

∂J/∂θ = ∑i ψ(ri) · ∂ri/∂θ

Now the two partials, which is where the whole leverage story hides. Differentiating ri = yi − a − b xi:

∂ri/∂a = −1        ∂ri/∂b = −xi

So the gradient is a pair of sums, not one sum:

∇J = ( −∑i ψ(ri) ,   −∑i xi ψ(ri) )

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:

shareak = |ψ(rk)| / ∑i |ψ(ri)|        sharebk = |xk ψ(rk)| / ∑i |xi ψ(ri)|
The bars on the canvas draw the first one only. Look at the accumulation in 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| / ∑ii| be the influence-weighted mean arm. Then:

sharebk / shareak = |xk| / x̄ψ

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|ψxk of the outlierxk / x̄ψMeasured shareb/sharea
Outlier in the middle24.9715.33154.68364.61540.985418.483 / 18.756 = 0.9854
Outlier at the far end34.8366.13025.682810.00001.759728.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.)

CostFitted lineOutlier residual|ψ| of the outlier∑|ψ| over all 14Share
L2y = 0.012 + 2.025x−12.38212.38224.76450.0%
Huber, δ = 1y = 1.064 + 1.990x−13.2721.0005.33218.8%
Cauchy, c = 1y = 1.065 + 2.000x−13.3190.0753.4872.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:

shareak = |rk| / (2|rk|) = 1/2,   exactly, for any rk

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:

ixiriw = 1/(1+r²)|ψ| = |r| w
00.000+0.90090.55200.4973
10.769−0.08850.99220.0878
21.538−0.39570.86460.3421
32.308−0.03700.99860.0369
43.077−0.11220.98760.1108
53.846+0.25710.93800.2412
64.615−13.31880.00560.0747  ← the outlier
75.385−0.48930.80690.3948
86.154−0.59840.73640.4406
96.923+0.47750.81430.3889
107.692+0.35780.88650.3172
118.462+0.33210.90060.2991
129.231−0.23470.94780.2224
1310.000−0.03370.99890.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.

The number to carry away: 50.0% → 18.8% → 2.1%. Same data, same three points of arithmetic, three completely different answers about who gets to decide the model. And notice the slopes: 2.025, 1.990, 2.000 — here L2 looks fine on the slope, because a single mid-range outlier mostly moves the intercept (0.012 against a truth of 1.000). Press Outlier at the far end and the slope goes too. Leverage is what turns an intercept error into a slope error.

Rebuild the bench's solver from nothing

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:

Sweepa (intercept)b (slope)∑widetWhat just happened
−1 (OLS start)+0.0123092.02478814.00001884.62Every point at weight 1. This is the red line.
0+0.9187212.00412411.82781393.55Six points down-weighted — and five of them are good.
1+1.0527711.99128013.03081737.38The fit has straightened; the good points come back to weight 1.
2+1.0641471.98972813.07541758.23Only the outlier is still down-weighted. Effectively converged.
5+1.0641771.98972713.07531758.23Sixth decimal place still settling.
7+1.0641771.98972713.07531758.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.

CODE — the twenty lines that turn the bench into an instrument

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.

The sixty-second whiteboard version

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:

  1. Draw six dots roughly on a line, and one dot well below it. Say: "one bad association in seven."
  2. Draw the least-squares line, visibly tilted toward the bad dot. Say: "the fit is a compromise nobody asked for."
  3. Draw a vertical stick from each dot to the line. Point at the good point with the biggest stick. Say: "and now the largest residual is on a good measurement — that is masking, and it is why 'delete the biggest residual' deletes the wrong one."
  4. To the right, draw axes and the line ψ = r. Say: "the pull on the solution is the derivative of the cost. For a quadratic, the derivative is the residual, so a residual ten times bigger pulls ten times harder, with no ceiling."
  5. Draw the flat top of Huber on the same axes. Say: "bounded influence — capped at δ, but still pulling forever."
  6. Draw Cauchy coming back down to zero. Say: "redescending — a gross outlier is ignored, at the price of a non-convex cost that needs a good initialisation."

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.

Two failure modes with the metric you would actually alarm on

"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 datasetPoints below w = 1, this dataset∑w/N, mean over 400 drawssd(slope) over 400 drawsVariance vs OLSEfficiency
∞ (plain OLS)1.000001.00000.030771.000×100.0%
1.0 (bench default)1.000001.00000.030781.000×100.0%
0.583 (= 1.345 σ̂, σ̂ = 0.434 from the clean OLS residuals)0.973510.98730.030901.008×99.2%
0.471 (= 1.345 σtrue)0.947230.96800.031301.034×96.7%
0.35 (= 1.0 σtrue)0.886950.92270.032151.092×91.6%
0.20 (= 0.57 σtrue)0.677090.78880.034021.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/Nsd of ∑w/NP(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.5830.98680.01530.0%0.0%0.0%Silent, correctly.
0.4710.96730.02561.4%0.0%0.0%Silent, correctly.
0.35 (healthy-ish)0.92090.041428.9%13.4%1.0%1 solve in 3 is a false alarm. A 20-solve window fixes it.
0.300.88860.049456.8%69.9%85.5%The genuine boundary. Fires more the longer you watch, which is what a true positive looks like.
0.250.84460.057782.3%98.3%100%Caught.
0.20 (broken)0.78500.065896.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:

cJ from the sensible startJ from the adversarial startbasin_gapΔslopeVerdict
1.03.50763.5076< 10−120.000One basin. Convex enough in practice; ship it.
0.72.25012.2501< 10−120.000One basin.
0.61.86491.8649< 10−120.000One basin — the last safe slider detent.
0.5364bisection boundary — the second basin appears here and exists for every smaller c
0.51.496110.24645.855.716Two basins. Keep J = 1.4961, warn.
0.41.14289.04646.9216.138Two basins.
0.30.80415.40675.7216.410Two basins, and the bad one is catastrophic.
0.20.47942.59974.4216.565Two 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.

What changes at 400 residuals instead of 14

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:

Quantity14 residuals, 1 outlier400 residuals, 40 outliers (10%)What it means
Even share of pull7.1%0.25%No single point can dominate; the population of outliers can.
Share held by the bad data under L250%~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 winsYes — see the code lab, slope 1.21 → 2.008Masking is a small-sample and high-leverage problem. With many balanced residuals, IRLS finds the right basin.
RANSAC iterations for 99% confidencetrivial (n is tiny)at 90% inliers with a 2-point model: 3 iterations; at 50%: 17; at 20%: 113Consensus is cheap while the inlier ratio is high and explodes as it drops. That is why matching quality dominates pipeline cost.
Best toolRANSAC or LMedS, then Cauchyχ2 gate, then Huber IRLSSmall and contaminated needs consensus; large and mildly contaminated needs a cheap convex kernel.

The shapes, the rate, and the budget

"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:

ObjectShapeUnitsSize
Residual vector r(400, 2) → 800 × 1pixels800 floats = 3.2 kB at fp32
Jacobian J = ∂r/∂ξ(800, 6)px per rad, px per m4 800 floats = 19.2 kB
Weight vector w (IRLS)(800,) or (400,) shared per landmarkdimensionlessone accumulator
Normal matrix H = JTWJ(6, 6) — 21 unique entriespx² 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 sweepWorkMACs
Reweight: wi = w(ri, δ)800 compares + 800 divides~1 600
Accumulate H = JTWJ800 rows × 21 unique entries (H is symmetric)16 800
Accumulate g = JTW r800 rows × 64 800
Cholesky of a 6×6 and back-substitute6³/372
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 wN, s = 2 (line)N, s = 3 (P3P)Consensus reprojections at s = 3vs 5 IRLS sweeps (2 000 evals)
0.90341 6000.8× — cheaper than IRLS
0.80572 8001.4×
0.50173514 000
0.304916967 60034×
0.20113574229 600115×

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.

The honest summary to give: "With hundreds of residuals and ten percent outliers, gate and Huber are enough and I would not reach further — the cost is convex, and five sweeps of a 6×6 accumulation over 800 residuals is about 117 kMAC, a fixed worst case I can put in the budget table against a 10 ms allocation inside a 50 ms frame. RANSAC at 20% inliers wants 574 P3P hypotheses and 230 k reprojections, and I cannot bound that in advance because the iteration count is a function of the inlier ratio. With a handful of high-leverage measurements — a loop closure, a GPS fix, a plane fit on sparse returns — the bench's drama is real, and there I want consensus first and a redescending kernel or graduated non-convexity after, in the back end where I own the latency budget."

What the bench does not show you, and should

Be honest about the demo's limits, because they are real:

Where this came from, and where it went

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.

PaperYearWhere it shows up on the benchWhat it changed
Huber, "Robust Estimation of a Location Parameter", Annals of Mathematical Statistics 35(1)1964The teal curve, the δ slider, and the 1.345 constantReframed 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)1974The idea behind the purple curve's descentIntroduced 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)1981The N = log(1−p)/log(1−ws) column in the budget tableInverted 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)1984The "RANSAC or LMedS" cell in the 400-residual tableProved 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)1996The w column in the per-inlier Cauchy tableShowed 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)2020The c = 1.0 row of the two-basin table, and the fix named in the failure-mode sectionThe 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.

How to deploy a citation without sounding like a bibliography. Do not list these. Attach exactly one to the moment it settles an argument. When you have just drawn the flat top and someone asks where δ comes from: "Huber 1964 — it is the minimax solution over a contamination neighbourhood, and 1.345σ is the setting that costs you 5% efficiency at the pure Gaussian." When you have just conceded that Cauchy is non-convex and they ask what you would do about it: "graduated non-convexity — Yang and Carlone, 2020 — anneal the kernel width from convex down, which is what lets you skip the consensus initialisation." One sentence each, at the exact moment it is load-bearing. That reads as fluency; a list reads as revision.

Practice: ten minutes, closed book, pen only

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.

#DrillAnswerWhat it is testing
1Fourteen 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.
2Same 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.
3Now 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.
4The 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?ψ = 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.
5400 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.
6Inlier 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.
7Your 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.
8A 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.
9You 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.
10Why 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.
11Cauchy 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.

The ten numbers worth committing to memory, because each one buys a whole sentence: 50% (a lone outlier's exact L2 share — a ceiling, not a climb) · 18.8% (the Huber share, locked, on this bench) · −0.0924 (the Huber fit's permanent intercept displacement, = δ H−1[1, xk], independent of how far the outlier goes) · 1.345σ (Huber's 95%-efficiency δ) · 1.76× (the leverage multiplier at x = 10 here) · 0.789 (400-draw mean weight at a broken δ = 0.2, vs 1.000 healthy — alarm on a 20-solve window, not one solve) · c = 0.54 (below which this data's Cauchy cost grows a second basin) · 117 kMAC (5 sweeps, 800 residuals, 6-DoF) · 50 ms (the 20 Hz frame budget) · 574 (P3P hypotheses at 20% inliers). And one more that is a trap rather than a tool: 50% again is the maximum achievable breakdown point (Rousseeuw 1984) — it matches the first number by arithmetic coincidence, not by connection, and saying so unprompted is a small, cheap demonstration that you know why each number is what it is.
On the bench, dragging a point far from the line makes the Cauchy fit move away and then come back to the clean fit, while the Huber fit moves and stays displaced. Explain both behaviours from the influence function, and say which you would ship in a real-time VIO front end.

Chapter 5: The Field Guide

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.

The answer to the opening question, so you have somewhere to aim. "Two centimetres reported against a once-a-week rack strike is a consistency claim I can test without ground truth. I would log the normalised innovation squared over a sliding window: if the filter is honest its average sits near the measurement dimension, and if the true error is 40 cm while it reports 2 cm, NIS comes back at (0.40/0.02)2 = 400 against an expectation of 1. That single number tells me the covariance is lying by a factor of 400 in variance before I have touched the model. Then the lag-1 innovation autocorrelation tells me which lie it is." Every number in that paragraph is derived somewhere below.

1 · The cheat sheet

ConceptThe 30-second explanationKey equationToolClassic paperModern 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−1 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 hihiTi2 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

1b · Derive it cold — four things you must never merely recall

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

(x − μ)T Σ−1 (x − μ) = c

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:

e−c/2 = 0.05  ⇒  c = −2 ln(0.05) = 2 × 2.9957 = 5.9915  ⇒  radius = √5.9915 = 2.4477

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
13.8411.9609.0003.000range-only gate; the familiar 1.96 and 3σ
25.9912.44811.8293.4392-D innovation gate; ground-plane position ellipse
37.8152.79614.1563.7623-D position ellipsoid; landmark covariance
612.5923.54920.0624.479relative-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̄:

y = f(x̄ + δx) ≈ f(x̄) + J δx,    J = ∂f/∂x |,    E[δx] = 0

Then δy = y − ȳ ≈ J δx, and

Σy = E[δy δyT] = E[J δx δxT JT] = J E[δx δxT] JT = JΣJT

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

p(z | x) ∝ exp(−½ (z − Hx)T R−1 (z − Hx))

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:

−HTR−1(z − Hx̂) = 0  ⇒  (HTR−1H) x̂ = HTR−1z

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

(HTR−1H + P−1) x̂ = HTR−1z + P−1

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:

Λ = 1/R + 1/P = 1/0.25 + 1/1 = 4 + 1 = 5  ⇒  P+ = 1/5 = 0.20,   σ+ = √0.20 = 0.447
x̂ = P+(z/R + x̄/P) = 0.20 × (4/0.25 + 0/1) = 0.20 × 16 = 3.2

Now the Kalman route, which must give the same answer or one of us is wrong:

K = P / (P + R) = 1 / (1 + 0.25) = 0.8
x̂ = x̄ + K(z − x̄) = 0 + 0.8 × 4 = 3.2     P+ = (1 − K)P = 0.2 × 1 = 0.20
Identical — 3.2 and 0.20 by both routes. Say the interpretation, not just the match: the measurement is four times as informative as the prior (4 versus 1 in information units), so the posterior sits four-fifths of the way from the prior to the measurement, 0.8 × 4 = 3.2, and the posterior standard deviation 0.447 m is smaller than either input — 0.5 m and 1 m — which is the one property no averaging scheme gives you for free. If someone asks "why not just average?", the naive average gives x̂ = (0 + 4)/2 = 2.0 with σ = √((1 + 0.25)/4) = √0.3125 = 0.559. Fusing beats averaging by 0.447/0.559 − 1 = 20% in standard deviation here, and by unboundedly much as the two sensors' precisions diverge — the average is what you get when you pretend R = P.
The Estimator Decision Tree — name the right thing in five seconds

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.

 

2 · System-design patterns

Prompt A — "Design the uncertainty story for a warehouse AMR's localisation stack."

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."

3 · Coding drills

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:

S = [[0.04, 0], [0, 0.01]]  ⇒  L = [[0.2, 0], [0, 0.1]]  (diagonal S ⇒ diagonal L with √ on the diagonal)

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

d2 = 1.52 + 0.52 = 2.25 + 0.25 = 2.50  <  5.991  ⇒  keep

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.

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:

measurements: 4 × 102 = 400   versus   prior: 0.12 = 0.01

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:

r = (0.10, −0.20, 0.15, −0.05, 3.00)

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

s = 1.4826 × 0.15 = 0.2224,    δ = 1.345s = 0.2991
wi = min(1, 0.2991/|ri|) = (1.000, 1.000, 1.000, 1.000, 0.0997)

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

woutlier = min(1, 1.8139/3.00) = 0.605
That contrast — 0.0997 against 0.605 — is masking, in two numbers. The outlier inflated the very scale estimate that was supposed to catch it, so δ grew until the outlier looked ordinary. Same code, same kernel, same data; one line changed from 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:

J = [[0.8776, −4.7943], [0.4794, 8.7758]]   (the second column is r × the first, rotated — that is the range amplification)
Σx = diag(0.022, 0.0052) = diag(4.0×10−4, 2.5×10−5)

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:

Σy = [[8.827×10−4, −8.835×10−4], [−8.835×10−4, 2.0173×10−3]]  m2

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:

along beam: σr = 0.02 m (2 cm)     across beam: rσθ = 10 × 0.005 = 0.05 m (5 cm)

Verify without an eigensolver, using the two rotation invariants:

trace: 8.827×10−4 + 2.0173×10−3 = 2.900×10−3  =  0.022 + 0.052 = 4×10−4 + 2.5×10−3
det: (8.827×10−4)(2.0173×10−3) − (8.835×10−4)2 = 1.781×10−6 − 7.806×10−7 = 1.00×10−6  =  (0.02 × 0.05)2

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.

4 · Debugging scenarios

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.

Worked example 9 — turning "it feels overconfident" into a single number, row 1 computed.
Scalar range residual, so the measurement dimension is m = 1 and E[NIS] = 1. The filter reports σ = 0.02 m, so S = σ2 = 4×10−4 m2. The observed error is 0.40 m. Then
NIS = yTS−1y = (0.40)2 / (0.02)2 = (0.40/0.02)2 = 202 = 400
against E[NIS] = m = 1. Say all three readings of that number, because each one carries different information: 400× too confident in variance, 20× in standard deviation, and the measurement sits 20 sigma out on a scale where 1.96 was supposed to be the 95% edge. From section 2, a fifty-sample window has standard deviation √(2m/N) = √(2/50) = 0.20, so an average NIS of 400 is (400 − 1)/0.20 ≈ 2,000 standard deviations outside the acceptance band. There is no tuning question here and no ambiguity to resolve — only which of the two causes it is, which is the next column's job.
SymptomMost likely root causeThe metric that reveals itFix
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−PTF / ‖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.

5 · Classical vs modern — and when to use which

ProblemClassicalModernWhen 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.

6 · Recommended reading

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.

  1. Barrau & Bonnabel, "The Invariant Extended Kalman Filter as a Stable Observer" (IEEE TAC, 2017). Read it for the idea that the definition of the error is a design choice, and that choosing it well makes the linearisation error state-independent. It is the deepest available fix to "the covariance lies", and it costs nothing at runtime.
  2. Kaess, Johannsson, Roberts, Ila, Leonard & Dellaert, "iSAM2: Incremental Smoothing and Mapping Using the Bayes Tree" (IJRR, 2012). Read it for why the field left filtering: the update cost depends on how much a new measurement disturbs the graph, not on the graph's size.
  3. Yang, Antonante, Tzoumas & Carlone, "Graduated Non-Convexity for Robust Spatial Perception" (IEEE RA-L, 2020). Read it for the resolution of the Huber-versus-Cauchy dilemma — anneal the scale from convex to redescending and get both properties, often with no RANSAC stage at all.
  4. Barron, "A General and Adaptive Robust Loss Function" (CVPR, 2019). Read it for the unification: one family containing L2, Charbonnier, Cauchy, Geman–McClure and Welsch, differentiable in its shape parameter, so robustness itself becomes learnable.
  5. Demmel, Sommer, Cremers & Usenko, "Square Root Bundle Adjustment for Large-Scale Reconstruction" (CVPR, 2021). Read it for the conditioning argument made concrete: nullspace marginalisation on the square-root Jacobian delivers single-precision accuracy the Schur-complement route cannot reach.

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.

  1. 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.
  2. borglab/gtsamgtsam/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.
  3. HKUST-Aerial-Robotics/VINS-Monovins_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.
  4. 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.
  5. 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.

7 · The numbers drill, and the rehearsal protocol

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.

QuestionAnswer (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 kB152 = 225 doubles × 8 B = 1,800 B
Bytes of P for 500 landmarks + 15 nav states?20 MB1,5152 × 8 B = 18.4 MB
Flops per dense covariance propagation at n = 1,515?4 × 109n3 = 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/s2 × 36 × 8 = 576 B × 50 = 28.8 kB/s
RANSAC iterations, homography, 50% inliers, p = 0.99?70log(0.01)/log(1 − 0.54) = 71.4 → 72
MAD-to-sigma consistency factor?1.51/Φ−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θ = 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:

  1. Whiteboard, silent, no notes (25 min). Reproduce the four derivations in section 1b from a blank surface: the chi-square ellipse, JΣJT, least squares as MLE, MAP as the Kalman update. Anything you cannot reproduce, you do not know — go back to the chapter that derives it (0, 1, 2 or 3) rather than re-reading the cheat sheet, which will only reinforce the recall you already have.
  2. Timer, typing, no autocomplete (30 min). Type all four drills from section 3 into a blank file. Before running anything, write the shape and dtype of every intermediate as a comment. Then run and check the worked instantiations reproduce: d2 = 2.50; the prior worth exactly four measurements at σ0 = 0.05; the outlier weight 0.0997 robust versus 0.605 masked; trace 2.900×10−3 and determinant 1.00×10−6. A drill you have run is worth ten you have read.
  3. Out loud, to a wall, with a stopwatch (20 min). Answer the chapter's opening question in ninety seconds: two-centimetre reported uncertainty, one rack strike a week, where do you look. Then the four system-design prompts in section 2, three minutes each, doing every multiplication audibly. This pass feels absurd and is the only one that transfers, because the failure mode under pressure is never "I had not heard of NIS" — it is "I knew it and could not assemble the sentence when it mattered."
The five sentences that carry the whole lesson. If everything else evaporates under pressure, these five, in this order, still carry the topic. (1) "A covariance is a shape, not an error bar — and in 2-D the 95% contour is 2.45σ, because it is a chi-square quantile, not a normal one." (2) "Least squares is maximum likelihood under a Gaussian; the squares come from the exponent and nowhere else." (3) "A prior is extra rows — MAP, the information form and the Kalman update are one algorithm written three ways." (4) "A robust cost is not a hack; it is the same estimator under a heavier-tailed likelihood, and IRLS means your existing WLS solver already implements it." (5) "None of that is worth anything without a consistency test, because an estimator that reports 2 cm while making 40 cm errors passes every other check you have."
The one sentence to carry away. "Least squares is maximum likelihood under a Gaussian; weighted least squares is the same thing with an honest noise model; MAP is the same thing with a prior; a robust cost is the same thing with a different derivative. Four names, one estimator — and the only way to know whether any of them is telling the truth is a consistency test."
Closing question: "Our AMR stack is an EKF with hand-tuned Q and R, no robust kernels, and no consistency instrumentation. You get to add exactly one thing this quarter. What, and why that rather than the other two?"
Answer first — then open: why all three are real, and only one is first

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