Sensor Fusion: Classical to Modern · Lesson 21 of 22

Debugging & Consistency — How to KNOW Your Fusion Works

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.

Prerequisites: the Kalman filter (Lesson 5) and a little probability. We re-derive everything else.
11
Chapters
6
Simulations
20
Fusers it Monitors

Chapter 0: The Silent Killer — A Filter That Lies With Confidence

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.

The defining property of the silent killer. A diverging filter does not announce itself. Its reported covariance — the matrix P that says "here is my uncertainty" — shrinks toward zero, while its actual error grows without bound. The two curves move in opposite directions. The filter becomes more sure exactly as it becomes more wrong. You cannot catch this by looking at the estimate alone, because the estimate looks gorgeous. You catch it only with a statistical test of whether the claimed uncertainty matches reality.

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:

FailureWhat goes wrongThe danger
Overconfident (P too small)reported uncertainty is smaller than the true errorthe 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 errorwasteful 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 silent killer — reported confidence shrinks while real error grows

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.

R underclaim (lie factor)
honest filter: the band covers the truth

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.

The capstone thesis. You cannot trust a filter's own confidence report — it is a number the filter computes, and a buggy filter computes a buggy confidence. To know whether a fuser actually works, you need an external, statistical test that compares the filter's claimed uncertainty against the spread of quantities you can actually measure. This entire lesson is that test — a small toolkit (NIS, NEES, whiteness, robust costs) that audits and repairs every fuser in lessons 1–20.

Why this is the capstone, not just another lesson

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.

Why is an overconfident filter (reported P too small) more dangerous than an underconfident one (P too large)?

Chapter 1: The Innovation — The Quantity That Tells the Truth

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:

ν = z − ẑ

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 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: new = x̂ + K·ν. No innovation, no update.

The innovation comes with a prediction of its own size

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:

S = H·P·HT + R

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.

Why S is the secret weapon. S is the filter's own claim about how big its surprises should be. The innovation ν is the surprise we actually got. Comparing the two — is ν about the size S predicted, or wildly bigger, or suspiciously smaller? — is a self-check the filter performs against itself, using only quantities it already computes. We never need the truth. That comparison is the seed of NIS (next chapter) and the whole consistency toolkit.

The three signatures of a consistent filter

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:

PropertyStatementWhy it must hold
1. Zero-meanE[ν] = 0If 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. WhiteE[νi·νj] = 0 for i≠jThe 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 Scov(ν) = SThe 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.

The innovation in code

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.

The signature to remember. The innovation ν = z − ẑ is the only quantity that lets a filter grade itself without ground truth. A consistent filter's innovations are zero-mean (no bias), white (no leftover structure), and spread like S (honest covariance). Every diagnostic in this lesson tests one of these three properties. When all three hold, the filter is telling the truth about its own uncertainty.
A filter's innovations have a consistent positive average of +3 m (they're never near zero). Which property is violated, and what does it indicate?

Chapter 2: NIS — Auditing the Filter With No Ground Truth

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.

ε = νT·S−1·ν

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.

NIS is a self-grading score the filter cannot cheat. If the filter's covariance S is honest, ε should hover around a known value (we'll compute it). If the filter is overconfident — S too small — then S−1 is too big, so even ordinary innovations get scaled into huge ε values: NIS shoots up, ringing the alarm. If the filter is underconfident — S too large — NIS sags below its expected value. And crucially, computing ε needs only ν and S — both of which the filter already has. No ground truth. NIS works online, on the live robot.

What value should NIS land on? Enter chi-square

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:

ε ∼ χ²dim(z),    E[ε] = dim(z)

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.

The window test — averaging beats single steps

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.

ε̅ = (1/N)·Σ εi,    N·ε̅ ∼ χ²N·dim(z)

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.

Worked example — dim(z) = 2, N = 50, with actual bounds

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:

χ²0.025(100) ≈ 74.22,    χ²0.975(100) ≈ 129.56

Divide both by N = 50 to get the bounds on the average NIS:

lower = 74.22 / 50 = 1.484,    upper = 129.56 / 50 = 2.591

So the verdict rule for this setup is concrete and memorable:

Average NIS over 50 stepsVerdictFix
1.484 ≤ ε̅ ≤ 2.591CONSISTENT — the filter is honestnothing — ship it
ε̅ > 2.591OVERCONFIDENT — surprises bigger than S claimsinflate Q (process) and/or R (measurement)
ε̅ < 1.484UNDERCONFIDENT — surprises smaller than S claimsshrink 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.

The headline number. For an honest filter, average NIS ≈ dim(z). That's it. Measuring 2D? NIS should hover near 2. Measuring 3D LiDAR? near 3. If your logged average NIS is sitting at 12 when dim(z) is 2, the filter is wildly overconfident — its surprises are six times bigger than it claims they should be — and it is on its way to divergence. You learned this with zero access to ground truth. That is why NIS is the diagnostic you run on the live system.

NIS over a run, in code

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.

You fuse a 2D position sensor (dim(z) = 2) and your logged average NIS over a long run is sitting at 11. What does this tell you, and crucially, do you need ground truth to know it?

Chapter 3: NEES — The Gold-Standard Test (When You Have Ground Truth)

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:

η = (x − x̂)T·P−1·(x − x̂)

where η (eta) is the NEES, x is the true state, 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 catch, stated plainly. NEES needs x, the true state. On a real robot in the field you do not have x — if you did, you wouldn't need a filter. So NEES is not an online tool. It is a Monte-Carlo / simulation tool: you run the filter in simulation (or against a motion-capture / RTK-GPS ground-truth rig), where the true state is known, and you use NEES to validate the filter before deployment. NIS is the field monitor; NEES is the pre-flight check. You need both.

The chi-square target for NEES

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:

η ∼ χ²dim(x),    E[η] = dim(x)

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.

Monte-Carlo averaging — the right way to use NEES

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

Worked example — dim(x) = 4, M = 50 runs, with bounds

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:

χ²0.025(200) ≈ 162.73,    χ²0.975(200) ≈ 241.06

Divide by M = 50:

lower = 162.73 / 50 = 3.255,    upper = 241.06 / 50 = 4.821

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)VerdictMeaning
inside [3.255, 4.821]CONSISTENTP honestly describes the real state error — deploy with confidence
above 4.821 (often rising)OVERCONFIDENT / DIVERGINGreal error exceeds P; the filter's covariance is collapsing too fast
below 3.255UNDERCONFIDENTP 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.

NIS vs NEES — the division of labor. Both are chi-square consistency tests; the difference is what they normalize and what they cost. NIS = innovation normalized by S, DOF = dim(z), no ground truth — your online field monitor, but blind to unobserved state directions. NEES = full state error normalized by P, DOF = dim(x), needs ground truth — your Monte-Carlo pre-flight check that audits the entire state, including the parts no sensor sees. Validate with NEES in sim; monitor with NIS in the field.

NEES over a Monte-Carlo ensemble, in code

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.

Why is NEES a Monte-Carlo / simulation tool while NIS can run online on the real robot?
Monte-Carlo NEES — tune the filter inside / outside the chi-square band

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

Reported P scale (tuning) 1.00×
P scale 1.00× → consistent

Chapter 4: The Whiteness Test — Catching a Wrong Model

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.

The autocorrelation — how to see the correlation

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:

ρ(τ) = [ Σi νi·νi+τ ] / [ Σi νi² ]

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 picture to burn into memory. A healthy innovation autocorrelation looks like a single tall flagpole at lag 0 and flat grass everywhere else. A sick one looks like a fading ripple — significant values at lags 1, 2, 3… that decay gradually, or oscillate up and down. The flat grass means "the filter extracted everything; nothing is left to predict." The ripple means "the filter keeps making the same kind of mistake, step after step — there's a pattern it isn't modeling."

The confidence band — how big is "approximately zero"?

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.

Reading the diagnosis off the shape

The shape of the autocorrelation tail even hints at what is unmodeled:

Autocorrelation shapeLikely causeFix
spike at 0, flat elsewherehealthy — white innovationsnothing
slowly decaying positive tailunmodeled 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 smallprocess noise Q slightly mistuned, or a one-step delaytune 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).

Whiteness test in code

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.

What whiteness uniquely catches. NIS and NEES test the magnitude of the residuals; the whiteness test tests their structure over time. A flat autocorrelation (spike at 0, grass elsewhere) means the model extracted all available information. A patterned autocorrelation (decaying or oscillating tail outside ±1.96/√N) means an unmodeled dynamic — a turn the model treats as straight-line, a bias the model treats as constant, a vibration the model ignores. The shape of the tail even hints at the fix.

Innovation whiteness — well-tuned (white) vs mis-modeled (correlated)

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.

well-tuned: innovations are white (spike at 0 only)
Your filter passes the NIS test (average NIS ≈ dim(z)) but the innovation autocorrelation shows a slowly decaying tail with ρ(1)=0.5, ρ(2)=0.35, well outside the ±1.96/√N band. What's wrong?

Chapter 5: Tuning From the Tests — Turning Diagnoses Into Fixes

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.

The master tuning table

Test resultDiagnosisAction
NIS too high (above band)filter overconfident — surprises bigger than S claimsinflate 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 claimsshrink Q and/or R. You're being wastefully cautious; tighten to use the information.
innovations correlated (fail whiteness)the MODEL is wrong — an unmodeled dynamicfix the model, not the noise. Add the missing state/motion mode. Inflating Q only masks it (and makes the filter sloppy everywhere).
NEES >> NISstate error not reflected in P, but innovations look OKoverconfidence in an unobserved direction. The sensor doesn't see the diverging state; check observability and inflate Q on that state.
all passconsistentship it.

The most important subtlety — correlation is NOT a noise problem

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.

The classic anti-pattern. A filter lags behind a turning car. The engineer sees big innovations, cranks Q way up. NIS drops back into the band — "fixed!" But now the filter is wildly noisy on the straightaways, where the old Q was correct. They masked a model error (no turn-rate state) with a noise hack, and traded a localized bug for a global one. The whiteness test would have said "correlated — this is a model error" and pointed at the real fix: add a turn-rate state (a coordinated-turn model) so the filter can actually represent the maneuver.

Worked tuning loop — numbers

Walk a concrete iteration on a 2D tracker (dim(z) = 2, so target NIS ≈ 2, band roughly [1.48, 2.59] for N = 50):

  1. Initial run. Average NIS = 6.8. Above the band → overconfident. Whiteness test: passes (flat autocorrelation). So it's a noise-size problem, not a model problem.
  2. Which knob? Innovations are large at every step uniformly, including right after a measurement — that smells like the sensor being noisier than R claims. Double R.
  3. Re-run. Average NIS = 3.1. Still a touch high. Nudge R up another 30%.
  4. Re-run. Average NIS = 2.2, inside [1.48, 2.59]. Whiteness still passes. Consistent. Stop.

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.

The tuning mantra. NIS/NEES out of band → turn a noise knob (Q or R). Whiteness fails → fix the model (add a state or dynamic). NEES blows up but NIS is fine → the divergence is in a state the sensor can't see — an observability problem (Q on that state, or add a sensor). Three tests, three distinct classes of fix, no guessing.

Your NEES is climbing far above its chi-square band, but your NIS is sitting comfortably inside its band. What does this specific combination tell you?

Chapter 6: Robustness — Surviving Outliers & Bad Associations

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.

Why one outlier wrecks least-squares

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:

CL2 = Σi ri²

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 cure — bound the influence of large residuals

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.

Huber loss — quadratic near zero, linear in the tails

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:

Lδ(r) = ½ r²    if |r| ≤ δ
Lδ(r) = δ(|r| − ½δ)    if |r| > δ

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

Huber in one image. Imagine the cost as a valley you roll the estimate into. Least-squares' valley walls are parabolas that get steeper forever — a far-off outlier is a cliff that yanks you over. Huber's valley is parabolic in the middle but its walls straighten into constant-slope ramps past δ — a far-off outlier still pulls, but with a fixed, limited force, never a cliff. Set δ to about where honest noise ends (say 2–3σ), and anything beyond is treated as a suspected outlier with bounded influence.

Worked Huber down-weighting — numbers

Take δ = 3. Compare how L2 and Huber score a normal residual vs an outlier, and — the part that matters — how hard each pulls:

Residual rL2 influence (2r)Huber influenceEffective weight
r = 1 (normal)2r = 11.00 (full trust)
r = 3 (= δ)6δ = 31.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.

Dynamic Covariance Scaling (DCS) — a closed-form robust kernel

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

s = min( 1,  2Φ / (Φ + r²) )

where Φ (Phi) is a tuning parameter (the residual scale beyond which a constraint is suspect) and 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.

Switchable Constraints — let the optimizer turn bad edges OFF

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:

C = Σodom ri² + Σloops [ sij²·rij² + Λ(1 − sij)² ]

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.

Connecting back to chi-square gating (Lesson 12)

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

accept if νTS−1ν ≤ γ,    γ = χ²0.99(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).

The robustness toolkit, ranked. Chi-square gating (Lesson 12) — cheapest, rejects gross outliers before they enter; the front door. Huber — gentle, bounds influence at δ, keeps optimality for inliers; good default for moderate outlier rates. DCS — closed-form, rolls influence off steeply toward zero, fast on large graphs; the SLAM-back-end favorite. Switchable constraints — most explicit, the optimizer turns bad edges fully off, but adds variables and iteration; use when you must know which constraints were rejected.

Robust cost comparison — one gross outlier, L2 vs Huber vs DCS

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.

Outlier size 35
drag the outlier and watch L2 bend while Huber/DCS ignore it

Robust weighting in a least-squares fuse, in code

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.

Fitting a constant to [10, 11, 9, 12, 90], why does least-squares (L2) give 26.4 while Huber gives ~10.5?

Chapter 7: Showcase — Catch Overconfidence Red-Handed

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.

Live consistency monitor — mis-tune the filter and watch NIS escape the band

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.

R trust (overclaim sensor)
honest filter: NIS hovers near 1, inside the band

The guided experiment — reproduce the silent killer in four clicks

Don't just watch; falsify. Run this and confirm each prediction with your own eyes:

  1. R trust = 1×, press Run. The band breathes around the truth; NIS jitters around 1 (= dim(z)), staying inside the green band. This is the honest baseline — the consistent filter.
  2. Slide R trust to 5×. The covariance band visibly tightens — the filter is getting more confident. Watch NIS: its average creeps toward and then above the upper bound. The filter says it's surer; the monitor says it's lying.
  3. Slide to 15×. The band is now a thin ribbon clinging to a wandering estimate; NIS spends almost all its time above the band, often far above. This is full overconfidence — the silent killer made loud by the monitor.
  4. The lesson. The left half (estimate + band) looked more trustworthy as you slid the dial; the right half (NIS) screamed louder. Only the NIS monitor told the truth. A team logging only the estimate would have shipped this filter.

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.

What you just built. A production-grade consistency monitor: log ν and S, compute NIS, plot against the chi-square band, alarm when it escapes. This exact instrument runs (in spirit) inside PX4's EKF2, every autonomous-driving tracker, and every serious SLAM stack. It is the difference between a filter that might be working and a filter you can prove is working — or prove is not, before it costs you.

Chapter 8: Use Cases & Real Products — Consistency Tests in the Wild

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.

The pattern across products

SystemTool from this lessonWhere it runsWhat it catches
PX4 / ArduPilot EKF2NIS gate (innovation test ratio)online, on the dronebad GPS/baro/mag fixes; sensor faults
AV multi-object trackerNIS + chi-square gateonline, in the carwrong associations; diverging tracks
SLAM back-end (g2o/GTSAM/Ceres)Huber / DCS robust kernelsin the optimizerfalse loop closures; outlier constraints
Navigation filter validationMonte-Carlo NEESoffline, on the benchoverconfidence / divergence pre-deploy
Any logged filterinnovation whiteness testoffline, post-rununmodeled dynamics / wrong motion model

The deep case — how PX4 actually rejects a bad GPS fix

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.

The deployable insight. Reading a product's autonomy docs, you can now spot the consistency toolkit by its industry aliases: an "innovation gate" or "test ratio" is NIS-based gating; a "robust loss" / "M-estimator" / "Huber kernel" is Chapter 6; "Monte-Carlo consistency validation" or "5%/95% NEES bounds" is the bench gate. The vocabulary changes by field, but underneath it is always the same chi-square test on innovations or state error that you now derive from scratch.

PX4's EKF2 computes an "innovation test ratio" for each GPS fix and rejects the fix if the ratio exceeds the gate. What lesson concept is this, fundamentally?

Chapter 9: Practical Application — The Consistency Workflow & a Divergence Runbook

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.

The consistency workflow — the standard procedure

1. LOG
Store ν and S at every filter update (and x, x̂, P in sim). This is your audit trail — you can't diagnose what you didn't record.
2. NIS online
Compute ε = νTS−1ν per step; window-average; plot against the χ² band for dim(z). Runs on the live system, no truth.
3. NEES in sim
Monte-Carlo M runs; average NEES per step; plot against the χ² band for dim(x). The bench gate — audits the full state.
4. WHITENESS
Autocorrelation of ν vs ±1.96/√N. Spike-only = good; structured tail = wrong model.
5. TUNE / FIX
Out-of-band NIS/NEES → tune Q/R. Correlated → fix model. Then re-log and repeat.
6. PROTECT
Set the innovation gate (NIS threshold) and pick a robust kernel (Huber / DCS / switchable) sized to your outlier rate.
↻ ship, then keep NIS as a live monitor

Choosing the robust kernel for your outlier rate

Step 6 hides a real decision. Match the kernel to how dirty your data is:

SituationKernelWhy
clean data, rare outlierschi-square gate onlycheap; reject the few gross ones, no kernel overhead
moderate outliers, want optimality on inliersHuberquadratic for inliers (optimal), linear (bounded) for outliers; the safe default
many outliers, large pose graph, speed mattersDCSclosed-form steep roll-off; faster than switchable, near-rejects gross outliers
need to KNOW which constraints were rejectedswitchable constraintsexplicit per-edge switch you can inspect; pay the extra variables for the audit trail

The runbook: "my filter diverged — here's how I'd diagnose it"

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:

  1. Pull the innovation log and compute NIS over time. Find where it left the band. A NIS that was healthy then suddenly spiked at t = 412 s localizes the divergence to a moment — correlate it with what happened then (entered a tunnel? sensor dropout? sharp maneuver?).
  2. Check the innovation mean near the divergence. A nonzero mean (Chapter 1) → a bias crept in: a sensor drifted out of calibration, or a frame/time offset appeared (Lesson 15). Zero mean but large spread → not bias, keep going.
  3. Run the whiteness test on the window before divergence. Correlated innovations → model error: the vehicle did something the motion model can't represent (a turn, an acceleration). White but oversized → noise mistuning, not model.
  4. Look for a single gross innovation right at the onset. One enormous ν that the filter accepted → an outlier got through (a multipath GPS, a wrong association). Fix: add/ tighten the chi-square gate, add a robust kernel.
  5. If NIS looks fine but the estimate is still wrong, reach for NEES in sim. The divergence is in an unobserved state the sensor doesn't see (Chapter 5). Check observability; add a sensor or inflate Q on that state.
  6. Audit covariance collapse. Plot trace(P) over time. If it shrank toward zero before the divergence, the filter was on the overconfidence death-spiral (Chapter 0) — you under-claimed Q or R. Inflate the suspect knob.

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.

The reusable recipe. (1) Log ν and S always. (2) NIS online for live health, NEES in sim for the full-state pre-flight gate. (3) Whiteness to separate model bugs from noise bugs. (4) When it diverges, run the runbook — each test eliminates a class of cause until one remains. (5) Protect with a chi-square gate plus a robust kernel sized to your outlier rate. Same procedure whether the fuser is a Kalman filter, an EKF, a particle filter, or a factor graph — the consistency tests are filter-agnostic, which is exactly why this is the capstone.

A filter diverged in the field. You compute NIS and find it was healthy until a single enormous innovation at t=300s that the filter accepted, after which everything went wrong. What's the most likely cause and fix?

Chapter 10: Debugging & Failure Modes — The Series-Wide Catalog

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.

The master failure-mode catalog

Failure modeSymptomTest that detects itFix
Overconfidence (the silent killer)P collapses while error grows; estimate looks greatNIS high (above band) / NEES risinginflate Q and/or R (Ch 5)
Underconfidencefilter sluggish, wastes information, P too fatNIS / NEES below bandshrink Q and/or R
Unmodeled dynamics (wrong motion model)filter lags maneuvers; structured residualswhiteness fails (correlated ν)add the missing state/mode (e.g. coordinated-turn)
Bad data association (Lesson 12)sudden state jump; one wild innovation acceptedNIS 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 meanre-calibrate extrinsics; fix the timestamp offset
Outliers / multipathoccasional gross innovations among healthy onesNIS spikes on individual updateschi-square gate + Huber/DCS (Ch 6)
Observability lossa state drifts unchecked; NIS looks fineNEES >> NIS (Ch 5)add a sensor that sees it; inflate Q there

The most instructive entry — calibration error looks like motion-correlated innovations

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.

Why this fools people. A motion-correlated innovation looks superficially like "unmodeled dynamics" (whiteness might even fail). But cranking Q won't fix a calibration error — the geometry is simply wrong, and no amount of noise inflation corrects a 2° mounting offset. The tell is the correlation with motion magnitude: noise/model problems show structure in time; calibration problems show structure in motion. Plot innovation magnitude against angular velocity — a clear upward slope is calibration (Lesson 15), flat-but-correlated is a model error (Chapter 4).

The two questions that route any divergence

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.

The meta-lesson of the whole series. Every fuser you built — complementary blends, Kalman filters, EKFs, particle filters, covariance intersection, data association, VIO, factor graphs, learned BEV fusion — can fail in exactly the ways this catalog lists, and every one of those failures shows up as a pattern in the innovation. The consistency tests are not specific to any one filter; they are the universal language for grading a fuser against reality. Learn to read the innovation, and you can debug a fuser you've never seen before. That is the skill that ships systems.

A LiDAR-inertial filter has near-zero innovations when the robot is stationary but innovations that grow with how fast it's turning. The filter passes static bench tests but falls apart in motion. What's the cause, and why won't inflating Q fix it?

Chapter 11: Connections, References & Cheat-Sheet

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.

The consistency toolkit — cheat-sheet

The silent killer: a diverging filter's reported covariance P shrinks while its true error grows. The estimate looks great; it's lying. You need an external statistical test.

The innovation: ν = z − ẑ, with predicted spread S = HPHT + R. A consistent filter's innovations are zero-mean (no bias), white (no leftover structure), and spread like S (honest covariance).

NIS = νTS−1ν ∼ χ²dim(z). Expected value = dim(z). No ground truth → online field monitor. Above band = overconfident; below = underconfident.

NEES = (x−x̂)TP−1(x−x̂) ∼ χ²dim(x). Expected value = dim(x). Needs ground truth → Monte-Carlo bench gate. Audits the full state, including unobserved directions.

Whiteness: autocorrelation ρ(τ) = spike at 0, flat elsewhere (within ±1.96/√N). A structured tail = wrong model (unmodeled dynamic).

Tuning: NIS/NEES out of band → tune Q/R (noise). Whiteness fails → fix the model. NEES>>NIS → observability problem.

Robustness: chi-square gate rejects gross outliers up front (NIS threshold). Huber caps influence at δ (linear tails). DCS closed-form steep roll-off, fast. Switchable constraints let the optimizer turn bad edges off.

The motto

"What I cannot create, I cannot understand." — Richard Feynman.
And what I cannot test, I cannot ship. You can now create a fuser — and, more importantly, prove whether it's telling the truth before it costs you.

Series-wide wrap-up — this lesson monitors and repairs every fuser sf-01..sf-20

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 lessonThe fuserHow this capstone monitors / repairs it
sf-01..03fusion taxonomy, architectures, two-measurement blendNIS tests whether the blend's claimed precision is honest; the inverse-variance weighting is right only if S is right
sf-04..05Bayes filter, Kalman filterNIS/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..08complementary filters, EKF, UKFEKF/UKF overconfidence from crude linearization shows as rising NEES; whiteness catches the linearization error
sf-09..11information filter, particle filter, covariance intersectionsame chi-square consistency logic; CI is the conservative fix when NEES says you're overconfident from unknown correlations
sf-12data associationthe chi-square gate (Chapter 6) is the association gate; NIS is the statistic both share
sf-13..14inertial navigation, INS/GNSS couplingIMU-bias divergence shows as a nonzero innovation mean; GNSS multipath is rejected by the NIS gate
sf-15..16calibration & time-sync, visual-inertial odometrycalibration/sync errors show as motion-correlated innovations (Chapter 10) — the signature that points back to Lesson 15
sf-17..20LiDAR-inertial factor graphs, driving fusion, BEV fusionfalse 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.

Related lessons on Engineermaxxing

These go deeper on machinery this capstone audits:

References

  1. Bar-Shalom, Y., Li, X.-R., and Kirubarajan, T. Estimation with Applications to Tracking and Navigation: Theory Algorithms and Software. Wiley, 2001. The canonical reference for NEES/NIS, the chi-square consistency tests, and Monte-Carlo filter validation — Chapters 5–11 are the source for everything in Chapters 1–5 of this lesson.
  2. Sünderhauf, N. and Protzel, P. "Switchable Constraints for Robust Pose Graph SLAM." IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 2012. Introduces per-constraint switch variables the optimizer can turn off to reject false loop closures. doi:10.1109/IROS.2012.6385590
  3. Agarwal, P., Tipaldi, G. D., Spinello, L., Stachniss, C., and Burgard, W. "Robust Map Optimization Using Dynamic Covariance Scaling." IEEE International Conference on Robotics and Automation (ICRA), 2013. The closed-form DCS robust kernel; faster than switchable constraints. doi:10.1109/ICRA.2013.6630557
  4. MacTavish, K. and Barfoot, T. D. "At all Costs: A Comparison of Robust Cost Functions for Camera Correspondence Outliers." Conference on Computer and Robot Vision (CRV), 2015. A practical survey comparing Huber, Cauchy, Geman-McClure, DCS, and other M-estimators — how to pick a robust cost. doi:10.1109/CRV.2015.52
  5. PX4 Autopilot. "Using the ECL EKF (EKF2)" — official documentation on innovation test ratios, measurement gates, and consistency monitoring in a production flight stack. docs.px4.io
  6. Kailath, T. "An Innovations Approach to Least-Squares Estimation — Part I." IEEE Transactions on Automatic Control, vol. 13, no. 6, pp. 646–655, 1968. The foundational result that filter optimality is equivalent to white innovations. doi:10.1109/TAC.1968.1099025

Where this lesson sits in the series

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.

Why is "Debugging & Consistency" the natural capstone of a sensor-fusion series, rather than just another lesson?