A filter can produce confident, beautiful, perfectly wrong estimates — and never tell you. This is the capstone: the statistical toolkit (NEES, NIS, whiteness, robust costs) that monitors and repairs any fuser you built across the last twenty lessons. The skill that separates people who run filters from people who ship them.
You spent twenty lessons building fusers. A Kalman filter that blends GPS and an IMU. An EKF that tracks a nonlinear vehicle. A factor graph that fuses LiDAR and inertial data into a trajectory. Each one runs, prints numbers, draws a smooth little estimate that hugs the truth in your test. You ship it. Three weeks later, in the field, the robot confidently drives into a wall.
Here is the terrifying part: the filter never warned you. Right up to the moment of impact, it reported a tight, shrinking uncertainty — "I know exactly where I am, to within a few centimeters." It was certain. It was also completely wrong. The gap between what the filter claimed to know and what it actually knew is the single most dangerous bug in all of estimation, and it has a name: filter inconsistency, the overconfident special case of which is divergence.
Let's make "consistency" precise, because the whole capstone hangs on it. A filter is consistent when its reported uncertainty is honest: when it says "my error has standard deviation 2 meters," the error really does have standard deviation about 2 meters. Formally, a consistent estimator is unbiased (its errors average to zero) and its actual error covariance matches the covariance it reports. An inconsistent filter is one where those two disagree — and there are two ways to disagree:
| Failure | What goes wrong | The danger |
|---|---|---|
| Overconfident (P too small) | reported uncertainty is smaller than the true error | the silent killer. The filter trusts itself too much, ignores corrective measurements, and diverges — while reporting it's fine |
| Underconfident (P too large) | reported uncertainty is larger than the true error | wasteful but safe. The filter is needlessly sloppy, throws away good information, but won't surprise you |
Overconfidence is the killer because it is self-reinforcing. Recall from the Kalman filter (Lesson 5) that the gain K — the trust slider that decides how much to believe a new measurement — is computed from P. If P is too small, the filter thinks it already knows the answer, so K shrinks, so it barely listens to the GPS fix that would have corrected it, so its error grows, so… and the only signal it gives you is an ever-tightening, ever-more-confident, ever-more-wrong report. Underconfidence has no such feedback loop: an over-cautious filter just wastes information and limps along.
Watch it happen. Below, a 1D position filter tracks an object. A single slider controls how much we under-tell the filter about its measurement noise — how badly we lie to it about how noisy the sensor is. Slide it right and the reported uncertainty band tightens beautifully while the true error (the gap to the dashed truth) blows up. The filter looks more confident as it gets more wrong.
The filter estimate (with its shaded ±2σ band) tracks the dashed truth. The "R underclaim" slider tells the filter its sensor is better than it really is. Push it right: the band collapses to a thin ribbon (the filter screams "I'm certain!") while the estimate peels away from the truth. That widening gap with a shrinking band is overconfidence.
At lie factor 1× the filter is honest: the truth stays comfortably inside the band most of the time, which is what "±2σ covers it ~95% of the time" should look like. Crank the lie to 20× and the truth spends almost the entire run outside a band that has shrunk to a thread. If you only logged the estimate and its band, you would conclude the filter was doing brilliantly. It is doing catastrophically. The band lied because we lied to the filter about R.
Every earlier lesson taught you to build a fuser. None taught you to verify one. That gap is exactly where real systems die. A Kalman filter (Lesson 5) with a mistuned process noise Q diverges silently. An EKF (Lesson 7) whose linearization is too crude becomes overconfident as the trajectory curves. A data-association layer (Lesson 12) that lets a wrong match through poisons the state. A factor graph (Lesson 17) with one bad loop closure warps the whole trajectory. Different fusers, same failure: the reported uncertainty stops matching reality. The tools in this lesson detect and repair all of them with one shared vocabulary — which is why it ships last.
If we can't trust the filter's own confidence report, what can we trust? We need a quantity that the filter cannot fake — something measurable, that carries a built-in prediction of how big it should be, so we can compare what we got against what we expected. That quantity already exists inside every Kalman-family filter you've built. It is called the innovation, and learning to read it is the foundation of everything that follows.
Every recursive filter, at each step, does two things: it predicts what the next measurement should be, then it sees the actual measurement. The innovation (also called the measurement residual) is the difference between the two — the part of the measurement the filter did not see coming, the genuinely new information:
where ν (the Greek letter nu) is the innovation, z is the actual measurement we just received from the sensor, and ẑ (z-hat) is the measurement the filter predicted from its current state estimate. In a linear filter the prediction is ẑ = H·x̂, where H is the measurement matrix that maps the state estimate x̂ into the space the sensor lives in (e.g. if the state is position-and-velocity but the GPS only sees position, H picks out the position).
That word — innovation — is exact, not poetic. If the measurement were perfectly predictable, ν would be zero and the sensor told the filter nothing it didn't already know. The innovation is precisely the news: the surprise, the new content, the thing the filter must react to. The Kalman gain K multiplies this innovation to produce the correction: x̂new = x̂ + K·ν. No innovation, no update.
Here is the magic that makes the innovation an auditing tool rather than just a number. The filter does not only produce ν; it also produces a prediction of how big ν should be — its covariance. This predicted innovation covariance is called S:
Read this as two sources of expected surprise added together. H·P·HT is the filter's own uncertainty about the state (P), pushed forward into measurement space by H — "I'm not sure where I am, so I'm not sure what I'll measure." R is the sensor's own measurement noise — "even if I knew exactly where I am, the sensor reading would jitter by this much." Together they say: given how uncertain I am and how noisy my sensor is, my prediction error ν should have spread S.
Here is the central theorem of filter diagnostics, and it is worth feeling why each piece must hold. If a filter is consistent — its model is correct and its covariances are honest — then its innovation sequence ν1, ν2, ν3, … must have three properties:
| Property | Statement | Why it must hold |
|---|---|---|
| 1. Zero-mean | E[ν] = 0 | If the filter's prediction were biased — systematically too high or too low — the innovations would have a nonzero average. A correct, unbiased filter's surprises cancel out to zero on average. |
| 2. White | E[νi·νj] = 0 for i≠j | The innovation is the new information at each step. After the filter absorbs νi, nothing about it should help predict νj — otherwise there was leftover structure the filter failed to use. Correlated innovations mean the model is missing something. |
| 3. Covariance S | cov(ν) = S | The actual spread of the innovations must match the spread S the filter predicted. If the real spread is bigger than S, the filter is overconfident; if smaller, underconfident. |
Let's derive property 1 in one line so it doesn't feel like an assertion. The measurement is z = H·x + noise (true state x, zero-mean sensor noise). The prediction is ẑ = H·x̂. So the innovation is ν = z − ẑ = H·(x − x̂) + noise. The term (x − x̂) is the state estimation error. If the filter is unbiased, the average of (x − x̂) is zero, and the noise averages to zero, so the average of ν is zero. Bias in — bias out. A nonzero-mean innovation is a direct fingerprint of a biased estimate (a miscalibrated sensor, an unmodeled constant force, a frame offset).
Property 2 (whiteness) is the subtlest and most useful. The optimality of the Kalman filter is equivalent to its innovations being white — this is a famous result (Kailath, 1968). If today's surprise can be predicted from yesterday's surprise, then yesterday's innovation still contained information the filter did not extract, which means the filter is not optimal: its model is wrong. We'll build the whiteness test in Chapter 4. Property 3 gives us the most practical online test, NIS, which is next.
Computing the innovation and its covariance is three lines — you already write them inside every Kalman update. The trick of this lesson is to log them so you can audit later:
python import numpy as np def innovation(z, x_hat, P, H, R): # z : the measurement we just received (vector, dim m) # x_hat : current state estimate (vector, dim n) # P : current state covariance (n x n) # H : measurement matrix (m x n) maps state -> measurement # R : sensor noise covariance (m x m) z_pred = H @ x_hat # what the filter EXPECTED to measure nu = z - z_pred # the INNOVATION: the surprise / the news S = H @ P @ H.T + R # how big the surprise SHOULD be return nu, S # log BOTH every step -> this is your audit trail
That S is the line everyone computes (it's needed for the Kalman gain anyway) and almost nobody logs. The entire consistency toolkit is built from one habit: store ν and S at every step, then test them offline or online. Everything downstream — NIS, whiteness — is a statistic of these two arrays.
We have the innovation ν and its predicted spread S. Property 3 says the real spread of ν should match S. But ν is a vector and S is a matrix — we can't eyeball "does this 3-vector have that 3×3 spread?" We need to collapse the comparison into a single number per step, scaled so that "expected" is a known value. That number is the Normalized Innovation Squared, or NIS, and it is the most important online diagnostic in all of estimation.
Read this carefully, because the S−1 in the middle is the whole point. We are not just squaring the innovation; we are squaring it in units of its own predicted standard deviation. In 1D this is transparent: ε = ν²/S = (ν/√S)², which is "how many predicted standard deviations away was the measurement, squared." An innovation of 2 m when S predicted a spread of 1 m gives ε = 4 (two sigma out). The same 2 m innovation when S predicted a spread of 4 m gives ε = 0.25 (half a sigma — perfectly normal). The normalization by S is what makes NIS comparable across steps and sensors.
Here is the beautiful result. If the filter is consistent, the innovation ν is a zero-mean Gaussian with covariance S. A standard fact of statistics: a Gaussian vector "whitened" by its own inverse-covariance and squared — exactly the form νTS−1ν — follows a chi-square distribution (written χ²) with degrees of freedom equal to the dimension of the measurement, dim(z). So:
The expected value of a chi-square with k degrees of freedom is exactly k. So a consistent filter measuring a 2D position (dim(z) = 2) should produce NIS values averaging 2. A filter fusing a 3D LiDAR point (dim(z) = 3) should average 3. The expected NIS is just the measurement dimension — an astonishingly simple target. Persistent NIS far above dim(z) ⇒ overconfident / diverging. Persistent NIS far below ⇒ underconfident.
One NIS value is noisy: a chi-square with 2 DOF has a healthy spread, so any single step can be 0.1 or 8 by pure chance even for a perfect filter. The fix is to average NIS over a window of N consecutive steps and test the average, which is far less noisy. The averaged statistic is itself (a scaled) chi-square: the sum of N independent χ²k variables is χ²Nk. So the time-averaged NIS over N steps should lie inside a confidence interval derived from a chi-square with N·dim(z) degrees of freedom, divided by N.
The two-sided 95% confidence interval for the average is [χ²0.025(N·k) / N, χ²0.975(N·k) / N], where χ²0.025 and χ²0.975 are the lower- and upper-tail critical values of the chi-square distribution. If ε̅ falls inside this band, the filter is consistent. Outside the top ⇒ overconfident; below the bottom ⇒ underconfident.
Let's compute a real confidence band so the abstract formula becomes a concrete number you can check against. Suppose we fuse a 2D position measurement, so dim(z) = 2, and we average over N = 50 steps. The averaged NIS scales as a chi-square with N·dim(z) = 50×2 = 100 degrees of freedom, divided by 50.
From a chi-square table, the 2.5% and 97.5% critical values for 100 DOF are:
Divide both by N = 50 to get the bounds on the average NIS:
So the verdict rule for this setup is concrete and memorable:
| Average NIS over 50 steps | Verdict | Fix |
|---|---|---|
| 1.484 ≤ ε̅ ≤ 2.591 | CONSISTENT — the filter is honest | nothing — ship it |
| ε̅ > 2.591 | OVERCONFIDENT — surprises bigger than S claims | inflate Q (process) and/or R (measurement) |
| ε̅ < 1.484 | UNDERCONFIDENT — surprises smaller than S claims | shrink Q and/or R — you're being too cautious |
Notice the band is centered near 2 (the expected value, dim(z)) but is not [1.484, 2.591] symmetric — chi-square is right-skewed. And notice the band tightens as N grows: with N = 50 we get [1.48, 2.59], a window of ~1.1; with N = 200 the band would shrink toward [1.74, 2.27]. More steps → tighter band → a more sensitive test. This is why you average over a generous window before pronouncing judgment.
Here is the full online monitor: compute ε each step, average over a sliding window, and compare to the chi-square band. This is the literal code you would drop into a production filter to raise an alarm:
python import numpy as np from scipy.stats import chi2 def nis(nu, S): # nu : innovation vector (dim m) ; S : predicted innovation covariance (m x m) return float(nu @ np.linalg.solve(S, nu)) # nu^T S^-1 nu (solve beats inv: stabler) def nis_band(dim_z, N, conf=0.95): # two-sided confidence band on the WINDOW-AVERAGED NIS a = (1 - conf) / 2 # 0.025 for 95% dof = N * dim_z # sum of N chi2_k is chi2_(N*k) lo = chi2.ppf(a, dof) / N # lower bound on the AVERAGE hi = chi2.ppf(1 - a, dof) / N # upper bound on the AVERAGE return lo, hi # --- run the monitor over a logged trajectory of (nu, S) pairs --- eps = np.array([nis(nu, S) for nu, S in log]) # NIS per step N, dim_z = 50, 2 lo, hi = nis_band(dim_z, N) # e.g. (1.484, 2.591) avg = eps[-N:].mean() # average over the last window if avg > hi: status = "OVERCONFIDENT -> inflate Q and/or R" elif avg < lo: status = "UNDERCONFIDENT -> shrink Q and/or R" else: status = "CONSISTENT" print(f"avg NIS = {avg:.2f} band = [{lo:.2f}, {hi:.2f}] -> {status}")
The compact form, for when you just need the verdict inline:
python ok = lambda eps, lo, hi: lo <= np.mean(eps) <= hi # True == consistent
The key engineering choices: use np.linalg.solve(S, nu) not inv(S) (numerically stabler, and S can be near-singular when P collapses — itself a divergence symptom); average over a window long enough to be statistically meaningful (N ≥ 30) but short enough to react to a developing problem; and re-derive the band from the actual dim(z) and N, never hard-code [1.48, 2.59] from a different setup.
NIS audits the filter's measurement predictions, and it needs no ground truth, which makes it the workhorse you run online. But NIS has a blind spot: it only checks the state in the directions the sensor sees (through H). If your filter is overconfident about a quantity the sensor never measures — a hidden velocity bias, an unobservable yaw — NIS can look perfectly healthy while the actual state error is exploding in the dark. To audit the full state error directly, we need the gold-standard test, and it carries one price: it needs the truth.
The Normalized Estimation Error Squared (NEES) is NIS's sibling, but instead of normalizing the innovation by S, it normalizes the actual state error by the filter's reported state covariance P:
where η (eta) is the NEES, x is the true state, x̂ is the filter's estimate, and P is the filter's reported state covariance. The quantity (x − x̂) is the actual estimation error — the thing the filter is supposed to be small and the thing P is supposed to describe. NEES asks the most direct question possible: is the real error as big as the filter's P says it should be?
The same beautiful statistics apply, with one change: NEES collapses the full state error, so its degrees of freedom equal the state dimension dim(x), not the measurement dimension:
A consistent filter tracking a 4-state vehicle (position-x, position-y, velocity-x, velocity-y — dim(x) = 4) should produce NEES values averaging 4. NEES above 4 means the real error exceeds what P claims (overconfident); below 4 means P is too fat (underconfident). Same logic as NIS, but now testing the entire state, including the parts no sensor directly sees.
Because NEES needs simulation anyway, the standard protocol runs the filter M times with different random noise seeds (a Monte-Carlo ensemble), and at each time step averages NEES across the M runs. This average NEES over M independent runs is far less noisy than any single run, and again follows a scaled chi-square: M independent χ²n sum to χ²Mn, so the M-run average has band [χ²0.025(M·n)/M, χ²0.975(M·n)/M].
Let's compute the real band, exactly as Bar-Shalom prescribes it. We Monte-Carlo a 4-state filter (dim(x) = 4) over M = 50 independent runs and average NEES at each time step. The average scales as chi-square with M·dim(x) = 50×4 = 200 degrees of freedom, divided by 50.
The 2.5% and 97.5% chi-square critical values for 200 DOF are:
Divide by M = 50:
So with 50 Monte-Carlo runs, a consistent 4-state filter's average NEES at each time step should sit inside [3.255, 4.821], centered near the expected value 4. Plot the average-NEES-over-time curve with these two horizontal lines:
| Average NEES (50 runs) | Verdict | Meaning |
|---|---|---|
| inside [3.255, 4.821] | CONSISTENT | P honestly describes the real state error — deploy with confidence |
| above 4.821 (often rising) | OVERCONFIDENT / DIVERGING | real error exceeds P; the filter's covariance is collapsing too fast |
| below 3.255 | UNDERCONFIDENT | P is inflated; the filter is needlessly conservative |
The classic divergence signature on a NEES plot is unmistakable and worth memorizing: the curve starts inside the band, then climbs steadily above the upper bound and never comes back. That rising NEES is the filter's covariance P shrinking faster than its real error — overconfidence, captured numerically, before you ever ship.
python import numpy as np from scipy.stats import chi2 def nees(x_true, x_hat, P): e = x_true - x_hat # the ACTUAL state error (needs truth!) return float(e @ np.linalg.solve(P, e)) # e^T P^-1 e # Monte-Carlo: run the SAME filter over M seeds, store NEES per step per run. M, T, n = 50, 200, 4 # 50 runs, 200 steps, 4-state NEES = np.zeros((M, T)) for m in range(M): filt = make_filter() # fresh filter, fresh noise seed for t in range(T): x_true = step_world(t) # simulator knows the truth z = observe(x_true) filt.predict(); filt.update(z) NEES[m, t] = nees(x_true, filt.x, filt.P) avg = NEES.mean(axis=0) # average across runs, per time step lo = chi2.ppf(0.025, M*n) / M # 3.255 for M=50, n=4 hi = chi2.ppf(0.975, M*n) / M # 4.821 frac_in = np.mean((avg >= lo) & (avg <= hi)) # fraction of steps inside the band print(f"band [{lo:.2f}, {hi:.2f}] fraction consistent = {frac_in:.0%}")
If frac_in is near 100%, the filter is well-tuned and you can deploy it. If avg climbs out the top and stays there, you have caught divergence on the bench — before it cost you a robot. The whole point of NEES is to find that out here, in simulation, where the only thing that crashes is a number.
Many simulated runs of a 4-state filter; the curve is the NEES averaged across runs at each time step, against the chi-square consistency band [3.26, 4.82] for M = 50, dim(x) = 4 (expected value 4). Slide the covariance scale: at 1.0 the curve sits in the band (consistent); shrink P (<1) and the curve climbs ABOVE the band (overconfident); inflate P (>1) and it sinks BELOW (underconfident).
NIS and NEES test whether the filter's covariance is the right size. But a filter can have the right-size covariance and still be subtly broken in a different way: its model can be wrong — it can assume the vehicle moves in straight lines when it actually turns, or that acceleration is constant when there's a sinusoidal force. This kind of bug doesn't always blow up the innovation magnitude; instead it leaves a pattern in the innovations over time. The test that catches patterns is the whiteness test, and it audits Property 2 from Chapter 1.
Recall: for a consistent filter the innovation at each step is new information, so consecutive innovations should be uncorrelated — "white" like white noise, no memory from one step to the next. If today's innovation can be predicted from yesterday's, the filter left information on the table: there was structure in the data its model failed to capture. Correlated innovations are the signature of an unmodeled dynamic.
We measure "does ν at step i predict ν at step i+τ?" with the sample autocorrelation of the innovation sequence at lag τ (tau, the time gap). For a scalar innovation sequence of length N:
where ρ(τ) (rho) is the correlation between innovations τ steps apart, normalized so that ρ(0) = 1 (everything is perfectly correlated with itself). For a white sequence, ρ(τ) is a spike at τ = 0 and approximately zero everywhere else. For a filter with an unmodeled dynamic, ρ(τ) decays slowly, oscillates, or has bumps — the leftover structure showing through.
The sample autocorrelation is never exactly zero at nonzero lags even for perfect white noise — finite samples wobble. The standard 95% confidence band for white noise is ±1.96/√N, where N is the number of innovations. Any ρ(τ) poking outside this band at a nonzero lag is statistically significant correlation — a red flag.
Worked number: with N = 400 innovations, the band is ±1.96/√400 = ±1.96/20 = ±0.098. So for a white sequence, you expect almost all ρ(τ) values at τ ≥ 1 to lie within ±0.098 (about 5% can poke out by chance). If you instead see ρ(1) = 0.6, ρ(2) = 0.4, ρ(3) = 0.25 — a smooth decaying tail way outside ±0.098 — that is unmistakable correlation, and your model is missing a dynamic.
The shape of the autocorrelation tail even hints at what is unmodeled:
| Autocorrelation shape | Likely cause | Fix |
|---|---|---|
| spike at 0, flat elsewhere | healthy — white innovations | nothing |
| slowly decaying positive tail | unmodeled drift / a state the model treats as constant but isn't (e.g. a bias) | add the missing state (estimate the bias) |
| oscillating (+,−,+,−) | unmodeled periodic dynamic (a turn, a vibration, a sinusoidal force) | add the dynamic to the motion model |
| large at lag 1, then small | process noise Q slightly mistuned, or a one-step delay | tune Q; check timing/sync (Lesson 15) |
This is the crucial complement to NIS: a filter can pass NIS (right-size covariance on average) yet fail whiteness (a structured pattern in the residuals). The two together pin down both kinds of error — wrong size (NIS/NEES) and wrong shape (whiteness).
python import numpy as np def innovation_autocorr(nu, max_lag=30): nu = np.asarray(nu, float) nu = nu - nu.mean() # remove any DC (also a bias check!) denom = np.sum(nu * nu) # = rho(0) numerator -> normalizes to 1 rho = [] for tau in range(max_lag + 1): num = np.sum(nu[:len(nu) - tau] * nu[tau:]) # sum nu_i * nu_(i+tau) rho.append(num / denom) return np.array(rho) # rho[0] == 1.0 by construction def is_white(nu, max_lag=30): rho = innovation_autocorr(nu, max_lag) band = 1.96 / np.sqrt(len(nu)) # 95% white-noise band, e.g. 0.098 at N=400 out = np.sum(np.abs(rho[1:]) > band) # count lags poking outside the band frac = out / max_lag # expect ~5% by chance if truly white return frac < 0.10, rho, band # white if few lags breach white, rho, band = is_white(innovations) print("WHITE -- model OK" if white else "CORRELATED -- unmodeled dynamic!")
Two subtle but load-bearing details. First, subtracting the mean (nu - nu.mean()) does double duty — it's required for a clean autocorrelation and it surfaces any bias (a large DC component is itself the zero-mean violation from Chapter 1). Second, the test allows ~5% of lags to breach the band by chance, so we flag only when the breach fraction is high — one stray lag is not a model error, a decaying tail of them is.
The innovation autocorrelation ρ(τ) as bars, with the ±1.96/√N confidence band. Toggle the model: well-tuned gives a lone spike at lag 0 and grass elsewhere (white). mis-modeled (straight-line model on a turning target) gives a decaying / oscillating tail poking far outside the band — the leftover structure of an unmodeled dynamic.
A diagnosis is only useful if it points to a repair. The whole reason we run NIS, NEES, and the whiteness test is to know which knob to turn. This chapter is the decision table that converts each test result into a specific action on the two noise covariances every Kalman-family filter exposes: Q (process noise — how much you distrust your motion model) and R (measurement noise — how much you distrust your sensor).
First, fix the two knobs in your mind, because tuning is hopeless if you confuse them. Q is the filter's admission that its prediction is imperfect — larger Q means "my motion model is rough, grow my uncertainty fast between measurements, so I'll listen harder to the next sensor reading." R is the filter's belief about sensor noise — larger R means "this sensor is noisy, don't snap to it." A too-small Q or a too-small R both cause overconfidence (the covariance collapses), but they collapse it through different doors, and the tests tell them apart.
| Test result | Diagnosis | Action |
|---|---|---|
| NIS too high (above band) | filter overconfident — surprises bigger than S claims | inflate Q and/or R. Bigger S → smaller NIS. Start with Q if the prediction is the suspect, R if the sensor is. |
| NIS too low (below band) | filter underconfident — surprises smaller than S claims | shrink Q and/or R. You're being wastefully cautious; tighten to use the information. |
| innovations correlated (fail whiteness) | the MODEL is wrong — an unmodeled dynamic | fix the model, not the noise. Add the missing state/motion mode. Inflating Q only masks it (and makes the filter sloppy everywhere). |
| NEES >> NIS | state error not reflected in P, but innovations look OK | overconfidence in an unobserved direction. The sensor doesn't see the diverging state; check observability and inflate Q on that state. |
| all pass | consistent | ship it. |
The single most common tuning mistake is to treat every failing test by cranking Q. If NIS is high, yes, inflating Q can help. But if the whiteness test fails — correlated innovations — inflating Q is the wrong fix. Correlation means your model is missing a dynamic; no amount of noise tuning adds the missing physics. Worse, cranking Q to hide correlated residuals makes the filter believe its own motion model less everywhere, so it gets sloppy on the parts it was modeling fine. Wrong-size errors are a tuning problem (Q, R). Wrong-shape errors are a modeling problem (add states/dynamics). The tests separate the two so you fix the right thing.
Walk a concrete iteration on a 2D tracker (dim(z) = 2, so target NIS ≈ 2, band roughly [1.48, 2.59] for N = 50):
Notice the discipline: change one knob at a time, re-run, re-test. And always check whiteness alongside NIS — if whiteness had failed at step 1, no amount of R-tuning would have fixed it, and the table would have sent you to the model instead.
Consistency tests catch a filter whose noise model is wrong. But there is a second, equally lethal way fusion fails: a single gross outlier — one wild GPS multipath fix, one wrong data association (Lesson 12), one false loop closure in a pose graph (Lesson 17) — that is not noise at all, but a flat-out wrong number. The other half of debugging is making your estimator robust to these, so one bad measurement doesn't wreck the whole estimate. This chapter is the robust-cost toolkit.
Every fuser in this series ultimately minimizes a sum of squared residuals — the L2 (least-squares) cost. For residuals ri (how far each measurement is from the current estimate), the cost is:
The squaring is the problem. A residual of 10 contributes 100 to the cost; a residual of 100 contributes 10,000. So a single gross outlier dominates the entire sum, and the optimizer — trying to minimize total cost — bends the whole estimate toward the outlier to reduce that one enormous term. One bad GPS fix can drag a trajectory meters off; one wrong loop closure can fold a whole map.
Worked number: fit a constant to readings [10, 11, 9, 12, 90]. The least-squares answer is the mean = (10+11+9+12+90)/5 = 132/5 = 26.4 — a value the four good readings would never suggest, yanked up 15 units by the single outlier. The squared cost wants to sit near the 90 because the penalty for ignoring it (a residual of ~64, squared = ~4000) is gigantic. Least-squares has no defense: it assumes every residual is honest Gaussian noise, and an outlier is not.
The fix is to replace the squared cost with a robust cost (an M-estimator) that grows slower than r² for large residuals, so an outlier can't dominate. The key concept is the influence function — the derivative of the cost, which is how hard a residual pulls the estimate. For L2, the influence is 2r, which grows without bound: the bigger the outlier, the harder it pulls. A robust cost bounds that influence so distant outliers pull with limited (or vanishing) force.
The Huber loss is the gentlest robust cost and the most widely used. It behaves exactly like L2 for small residuals (so well-behaved measurements are treated optimally) but switches to a linear penalty beyond a threshold δ (delta), so large residuals pay only linearly:
The two pieces are stitched to match in value and slope at |r| = δ, so the cost is smooth. The payoff is in the influence function (the derivative): for small residuals it's r (same as least-squares, grows with the residual); but for large residuals it saturates at ±δ — a constant. No matter how huge the outlier, its pull is capped at δ. A residual of 1000 pulls exactly as hard as a residual of δ.
Take δ = 3. Compare how L2 and Huber score a normal residual vs an outlier, and — the part that matters — how hard each pulls:
| Residual r | L2 influence (2r) | Huber influence | Effective weight |
|---|---|---|---|
| r = 1 (normal) | 2 | r = 1 | 1.00 (full trust) |
| r = 3 (= δ) | 6 | δ = 3 | 1.00 (still full, at the knee) |
| r = 30 (outlier) | 60 | δ = 3 (capped!) | 0.10 = δ/|r| = 3/30 |
| r = 90 (gross) | 180 | δ = 3 (capped!) | 0.033 = 3/90 |
The "effective weight" column is the key insight: Huber is equivalent to iteratively reweighted least squares where each residual gets weight min(1, δ/|r|). The normal residual (r=1) keeps full weight 1.0. The gross outlier (r=90) is automatically down-weighted to 3/90 = 0.033 — it contributes 3% of what least-squares would have let it contribute. The outlier is still in the sum, but its voice is throttled to a whisper. That's robustness, derived.
Huber down-weights but never fully rejects — a gross outlier still pulls with weight δ/|r|, small but nonzero. For SLAM back-ends drowning in bad loop closures, you want something that drives the worst outliers' influence toward zero, and does it in closed form (no iteration). Dynamic Covariance Scaling (DCS) (Agarwal et al., ICRA 2013) does exactly this. It scales each constraint's information by a factor
where Φ (Phi) is a tuning parameter (the residual scale beyond which a constraint is suspect) and r² is the squared residual. For a small residual (r² < Φ), s = 1 — full weight, ordinary least squares. For a large residual, s shrinks like 2Φ/r², so the constraint's effective information is scaled down by s² — a far steeper roll-off than Huber. Crucially DCS is a closed-form scaling: you compute s once per constraint, no iterative reweighting loop, which makes it dramatically faster than switchable constraints for large pose graphs.
Worked DCS number: take Φ = 9 (so 3σ is the suspicion threshold). For a normal residual r = 1: s = min(1, 18/(9+1)) = min(1, 1.8) = 1.0 — full weight. For an outlier r = 30 (r² = 900): s = min(1, 18/(9+900)) = 18/909 = 0.0198 — the constraint's influence is scaled to ~2%, even harder than Huber's 10% on a comparable residual. The bad loop closure is all but switched off, in one formula.
The most explicit approach gives each (loop-closure) constraint its own switch variable sij ∈ [0, 1] that the optimizer is free to adjust. Switchable Constraints (Sünderhauf & Protzel, IROS 2012) augment the cost so each constraint is weighted by its switch, plus a penalty for turning a switch off:
The optimizer now jointly solves for the poses and the switches. For a good loop closure, keeping sij = 1 costs little (the residual is small) and avoids the penalty, so the switch stays on. For a bad loop closure, the residual rij is huge, so the optimizer would rather pay the penalty Λ(1−sij)² and drive sij → 0, switching the bad constraint off entirely. The optimizer disables wrong loop closures by itself — no manual outlier list. The trade is cost: switchable constraints add a variable per loop and need iteration, which is why DCS (closed-form, no extra variables) was developed as the faster sibling.
There's a cheaper first line of defense than any robust cost: reject the outlier before it ever enters the filter. This is exactly the chi-square gate from data association (Lesson 12), and now you can see it's just NIS used as a guard. Before accepting a measurement, compute its NIS ε = νTS−1ν and check it against a chi-square threshold (e.g. the 99% value of χ²dim(z)):
For dim(z) = 2, χ²0.99(2) ≈ 9.21. So any measurement whose NIS exceeds 9.21 is more than a 99% surprise — almost certainly an outlier or a wrong association — and gets rejected before it can poison the state. Gating (reject) and robust costs (down-weight) are complementary: gate the obvious outliers cheaply up front, and let a robust cost gracefully handle the borderline ones that slip through. The same NIS statistic that diagnoses the filter (Chapter 2) also protects it (here).
Fit a line through inlier points with one gross outlier (drag its slider). L2 (least-squares) is yanked toward the outlier; Huber and DCS down-weight it and hug the inliers. The inset shows each cost's influence function — L2 grows forever, Huber saturates at ±δ, DCS rolls back toward zero.
python import numpy as np def huber_weight(r, delta=3.0): # effective IRLS weight: 1 for inliers, delta/|r| for outliers (bounded influence) a = np.abs(r) return np.where(a <= delta, 1.0, delta / a) def dcs_weight(r, phi=9.0): # DCS scale s = min(1, 2*phi/(phi + r^2)); effective info weight is s^2 s = np.minimum(1.0, 2.0 * phi / (phi + r*r)) return s * s def robust_fit_constant(y, kind="huber", iters=10): # iteratively reweighted least squares to fit a robust mean of y est = np.median(y) # robust init (median ignores outliers) for _ in range(iters): r = y - est w = huber_weight(r) if kind == "huber" else dcs_weight(r) est = np.sum(w * y) / np.sum(w) # weighted mean -> outliers barely count return est y = np.array([10, 11, 9, 12, 90]) # one gross outlier (90) print("L2 (mean) =", y.mean()) # 26.40 <- wrecked by the outlier print("Huber =", robust_fit_constant(y, "huber")) # ~10.5 <- ignores it print("DCS =", robust_fit_constant(y, "dcs")) # ~10.5 <- ignores it harder
The compact, inline version of the Huber down-weight you can drop into any residual loop:
python w = np.minimum(1.0, delta / np.abs(r)) # 1 for |r|<=delta, else delta/|r| (bounded pull)
The L2 mean comes out at 26.4 — dragged 15 units off by the single 90. Huber and DCS both land near 10.5, right where the four honest readings live, because the outlier's weight collapsed to ~δ/90 (Huber) or ~(2Φ/90²)² (DCS). Same data, the robust cost simply refuses to be bullied by one bad number.
This is the payoff that ties Chapters 0–5 into one live instrument. A tracking filter runs on the left; its consistency monitor runs on the right. You hold a single dial — the Q/R mis-tuning — and watch the two halves of the screen contradict each other: the estimate's covariance band tightens (the filter growing confident) while the live NIS plot climbs above its chi-square band (the filter being caught lying). Overconfidence, red-handed, exactly as it happens in a real divergence.
Left: the filter estimate + its shrinking ±2σ band tracking the truth (dots = noisy measurements). Right: live NIS against its chi-square consistency band (expected value = dim(z) = 1). Slide R trust right (tell the filter the sensor is far better than it is): the band collapses while NIS shoots ABOVE the green band — the filter screams confidence as it diverges. Press Run.
Don't just watch; falsify. Run this and confirm each prediction with your own eyes:
This is the whole capstone in one widget. The estimate's own confidence report is unreliable — it tightened precisely as the filter got worse. The NIS plot, computed from the same innovations the filter already has, caught the divergence with no ground truth, online, live. That asymmetry — the estimate looking better while NIS looks worse — is the visual signature of overconfidence, and once you've seen it you'll recognize it in every real divergence.
Every lesson in this series ends with the same four chapters — Use Cases, Practical Application, Debugging, Connections — because a diagnostic you can't point at in shipped code is one you don't really own. The consistency toolkit isn't academic: NIS gates, NEES validation, and robust kernels run inside the most-deployed autonomy stacks on Earth, right now. Tour them and you'll recognize every tool from the last seven chapters.
PX4 / ArduPilot EKF innovation gates — NIS, online. Every consumer and commercial drone running PX4's EKF2 or ArduPilot computes, for each GPS / barometer / magnetometer / optical-flow update, an innovation test ratio — which is precisely NIS normalized against a threshold (the EK2_*_GATE / EK3_*_GATE parameters are the chi-square gate of Chapter 6). If the ratio exceeds the gate, the measurement is rejected before it enters the filter. Pilots literally tune these gates; the autopilot logs the innovations and ratios so you can post-flight diagnose a bad sensor — the exact log-and-test discipline of Chapters 1–2.
Autonomous-driving track consistency monitoring — NIS + gating. Every multi-object tracker in a self-driving stack runs a chi-square gate (Lesson 12) on each detection-to-track association, and the same NIS statistic monitors whether each track's filter stays consistent. A track whose NIS persistently exceeds its band is flagged as diverging — the perception system knows which tracks to distrust, online, exactly when it matters (a fast cut-in, a partially occluded pedestrian).
SLAM back-end robust kernels — Huber & DCS, everywhere. The big optimization libraries that power modern SLAM — g2o, GTSAM, Ceres — all ship robust kernels as first-class citizens. Ceres has HuberLoss, CauchyLoss, TukeyLoss; GTSAM has noiseModel::Robust wrapping Huber/DCS; g2o has the classic robust kernels and DCS. Turn on a Huber kernel for the loop-closure factors and a single false loop closure (Chapter 6) stops folding your whole map — the optimizer down-weights it automatically.
Monte-Carlo NEES validation before deployment — the bench gate. Serious teams do not ship a filter until it has passed a Monte-Carlo NEES test (Chapter 3): hundreds of simulated runs, NEES averaged per step, plotted against the chi-square band, with deployment blocked if the curve escapes. This is the pre-flight check that catches overconfidence on the bench — in aerospace navigation, defense tracking, and any team that read Bar-Shalom. The filter must prove consistency in sim before it's trusted with hardware.
| System | Tool from this lesson | Where it runs | What it catches |
|---|---|---|---|
| PX4 / ArduPilot EKF2 | NIS gate (innovation test ratio) | online, on the drone | bad GPS/baro/mag fixes; sensor faults |
| AV multi-object tracker | NIS + chi-square gate | online, in the car | wrong associations; diverging tracks |
| SLAM back-end (g2o/GTSAM/Ceres) | Huber / DCS robust kernels | in the optimizer | false loop closures; outlier constraints |
| Navigation filter validation | Monte-Carlo NEES | offline, on the bench | overconfidence / divergence pre-deploy |
| Any logged filter | innovation whiteness test | offline, post-run | unmodeled dynamics / wrong motion model |
Walk one real update through PX4's EKF2, because it's the consistency toolkit in production. A GPS position arrives. The filter computes the innovation ν = zGPS − H·x̂ and the innovation covariance S = HPHT + R. It forms the test ratio — NIS divided by the gate threshold: ratio = νTS−1ν / gate². If ratio ≤ 1, the fix is consistent with the filter's prediction and gets fused. If ratio > 1 (a multipath fix reflected off a building, say, giving a 50 m jump), the measurement is rejected — it never touches the state. PX4 logs the innovation and the ratio so that, post-flight, you can open the log and see exactly which fixes were rejected and why. That is Chapters 1, 2, and 6 — innovation, NIS, and chi-square gating — running thousands of times per flight, on a $200 flight controller.
You now have the tests; this recurring "now do it" chapter turns them into a workflow you can run on any fuser, plus a concrete runbook for the call every estimation engineer eventually gets: "the filter diverged in the field — figure out why." The goal is to make diagnosis mechanical, so you're never staring at a wall of numbers guessing.
Step 6 hides a real decision. Match the kernel to how dirty your data is:
| Situation | Kernel | Why |
|---|---|---|
| clean data, rare outliers | chi-square gate only | cheap; reject the few gross ones, no kernel overhead |
| moderate outliers, want optimality on inliers | Huber | quadratic for inliers (optimal), linear (bounded) for outliers; the safe default |
| many outliers, large pose graph, speed matters | DCS | closed-form steep roll-off; faster than switchable, near-rejects gross outliers |
| need to KNOW which constraints were rejected | switchable constraints | explicit per-edge switch you can inspect; pay the extra variables for the audit trail |
You get the call. The position estimate flew off into nonsense mid-mission. Don't guess — run the tests in order, and each result eliminates a class of cause:
Six steps, and each test partitions the space of causes: bias vs spread, model vs noise, observed vs unobserved, inlier-noise vs outlier. By the end you don't have a guess — you have a localized cause and the matching fix from Chapter 5's table. The discipline is: never tune a knob until the tests tell you which knob.
This is the chapter that ties the whole series together. Every failure mode from lessons 1–20 — overconfidence, bad associations, calibration drift, outliers, observability loss — shows up as a specific, recognizable pattern in the consistency tests you just learned. Here is the master catalog: for each failure, the symptom it presents, which test detects it, and the fix. Internalize this table and you can debug any fuser in this series from its innovation log.
| Failure mode | Symptom | Test that detects it | Fix |
|---|---|---|---|
| Overconfidence (the silent killer) | P collapses while error grows; estimate looks great | NIS high (above band) / NEES rising | inflate Q and/or R (Ch 5) |
| Underconfidence | filter sluggish, wastes information, P too fat | NIS / NEES below band | shrink Q and/or R |
| Unmodeled dynamics (wrong motion model) | filter lags maneuvers; structured residuals | whiteness fails (correlated ν) | add the missing state/mode (e.g. coordinated-turn) |
| Bad data association (Lesson 12) | sudden state jump; one wild innovation accepted | NIS gate on that update (a huge ε) | tighten the chi-square gate; robust kernel |
| Calibration / time-sync error (Lesson 15) | innovations grow with motion (still when stopped, large when moving/turning) | nonzero / motion-correlated innovation mean | re-calibrate extrinsics; fix the timestamp offset |
| Outliers / multipath | occasional gross innovations among healthy ones | NIS spikes on individual updates | chi-square gate + Huber/DCS (Ch 6) |
| Observability loss | a state drifts unchecked; NIS looks fine | NEES >> NIS (Ch 5) | add a sensor that sees it; inflate Q there |
This one deserves a worked symptom because it's so commonly misdiagnosed. Suppose your LiDAR is mounted with a small rotational miscalibration relative to the IMU (a 2° extrinsic error, Lesson 15), or its timestamps lag the IMU by 20 ms (a time-sync error). When the robot sits still, the two sensors agree and the innovations are tiny — the filter looks perfect. But the moment the robot moves or turns, the miscalibration projects the LiDAR's points into the wrong place, and the innovations grow in proportion to the motion. The fingerprint is unmistakable: innovations near zero at rest, large during motion, scaling with angular/linear velocity. This is why a filter that passes every static bench test can fall apart in motion — and why the shape of the innovation-vs-motion relationship points straight at calibration, not noise.
When in doubt, two questions partition almost every fusion failure:
Mean and whiteness — two checks — sort a divergence into bias / model / noise / observability, and each maps to a distinct fix from the catalog. That is the entire debugging discipline of the series, compressed.
You now hold the diagnostic toolkit that audits and repairs every fuser in this series. Before we name what comes next, here is everything in one place — the cheat-sheet to screenshot, the series-wide wrap-up, the motto, the real sources, and the cross-links.
This is the capstone because the consistency toolkit is the one thing that applies to all twenty earlier lessons. Here is the map — every fuser you built, and the specific tool here that monitors or repairs it:
| Earlier lesson | The fuser | How this capstone monitors / repairs it |
|---|---|---|
| sf-01..03 | fusion taxonomy, architectures, two-measurement blend | NIS tests whether the blend's claimed precision is honest; the inverse-variance weighting is right only if S is right |
| sf-04..05 | Bayes filter, Kalman filter | NIS/NEES are defined from the KF's innovation and S; a mistuned Q/R is caught here and tuned by Chapter 5's table |
| sf-06..08 | complementary filters, EKF, UKF | EKF/UKF overconfidence from crude linearization shows as rising NEES; whiteness catches the linearization error |
| sf-09..11 | information filter, particle filter, covariance intersection | same chi-square consistency logic; CI is the conservative fix when NEES says you're overconfident from unknown correlations |
| sf-12 | data association | the chi-square gate (Chapter 6) is the association gate; NIS is the statistic both share |
| sf-13..14 | inertial navigation, INS/GNSS coupling | IMU-bias divergence shows as a nonzero innovation mean; GNSS multipath is rejected by the NIS gate |
| sf-15..16 | calibration & time-sync, visual-inertial odometry | calibration/sync errors show as motion-correlated innovations (Chapter 10) — the signature that points back to Lesson 15 |
| sf-17..20 | LiDAR-inertial factor graphs, driving fusion, BEV fusion | false loop closures are killed by Huber/DCS/switchable constraints (Chapter 6); the back-end's robust kernel is this lesson |
Every one of those twenty fusers, no matter how different, fails in the same handful of ways — and every failure is visible in the innovation. That is the unifying truth of the series: you build a fuser with the math of lessons 1–20, and you trust it with the tests of lesson 21.
These go deeper on machinery this capstone audits:
This was Lesson 21 of 22 — the diagnostic capstone. Lessons 1–20 taught you to build fusers; this lesson taught you to verify and repair them with one filter-agnostic toolkit. The recurring chapter structure you've walked all series (Why → concepts → showcase → Use Cases, Practical, Debugging, Connections) closes here. One lesson remains: Lesson 22 — the Sensor Fusion Atlas, the whole map drawn at once, every relationship, filter, and diagnostic connected into a single mental model you can navigate end to end.