Robotics Engineering · Lesson 7 of 26

SLAM Backend
Why the field left filters

The question at the heart of every modern SLAM system, answered from first principles — with an information matrix, a fill-in count, and a solve time you can predict before you run it.

Prerequisites: matrix multiply + the idea of least squares. The rest is built here.
8
Chapters
9
Interactive Sims
3
Code Labs

Chapter 0: The Move

A design review for a warehouse robot. The whiteboard already has a camera, an IMU and a shelf drawn on it from the frontend discussion. Someone circles the estimator box and asks the question that sits underneath every modern SLAM system:

"Why did the field move from EKF-SLAM to factor graphs?"

The reflex answer is "graphs are more accurate." That answer is not wrong, exactly. It is worse than wrong — it is unfalsifiable, it explains nothing, and it usually means the speaker has used a library but never opened it.

The question is not about preference. It is asking whether you can reconstruct a fifteen-year argument from first principles in five minutes. The real answer has exactly three legs, and every one of them is a structural claim you can put a number on:

  1. Sparsity. A filter marginalises the past. Marginalisation is a Schur complement, and a Schur complement fills in. The EKF covariance is dense by construction; the smoother's information matrix is as sparse as the graph of measurements. Dense means O(n2) memory and O(n3) solve; sparse means near-linear.
  2. Relinearisation. A filter linearises each measurement exactly once, at whatever estimate it happened to hold at the time, and that linearisation is welded into the state forever. A smoother keeps the raw measurements and re-linearises all of them at the current best estimate on every iteration. When the estimate improves, the whole history improves with it.
  3. What marginalisation actually costs. Not accuracy — marginalisation is exact for a linear system. It costs you fill-in and a frozen linearisation point. And in SLAM the frozen linearisation point is not a rounding error: it manufactures information about directions the robot genuinely cannot observe, which makes the filter provably, permanently overconfident.

Leg three is the one that lands. Most engineers stop at "sparsity". The complete answer adds "and marginalisation is exact, so the cost is not accuracy — the cost is fill-in plus a linearisation you can never take back, and here is the consistency proof."

What this lesson is, and is not

This site already teaches the underlying theory well, in four places. If any derivation below feels unfamiliar, read the corresponding lesson first — this one will not repeat them.

If you want…Read
Factor graphs from zero: variables, factors, elimination, iSAM, the Bayes treeSLAM: Factor Graphs
The graph formalism on its own, with a live pose-graph playgroundFactor Graphs
How vision and inertial data get fused at all: loose vs tight, MSCKF, windowsClassical VIO
The VIO systems view: initialisation, observability, what shipsVisual-Inertial Odometry
Covariance, Mahalanobis, least squares to MAP, robust costsUncertainty, Least Squares & Robust Costs

What this lesson spends its words on is the craft: the derivation you can do with a marker in your hand, the architecture with real byte counts and millisecond budgets, the failure taxonomy with the metric that reveals each fault, and the tradeoff you have to defend when someone senior disagrees with you.

Five lenses on this topic

LensThe question it asksWhat a shallow answer sounds like
CONCEPT"Write the information matrix for a three-pose chain. Now marginalise the middle pose."Naming the Schur complement without being able to produce the four numbers it leaves behind.
DESIGN"Draw me the backend. What runs at what rate, and what is the deadline?"Boxes and arrows with no rates, no state dimension, no solve time.
CODE"Assemble the normal equations for a pose graph and take one Gauss-Newton step."Calling gtsam.LevenbergMarquardtOptimizer and going quiet.
DEBUG"The backend used to solve in 20 ms. It now takes 4 seconds and the answer is identical. Go.""Too many loop closures." (Then why is the answer identical, and why did the iteration count not move?)
FRONTIER"Is the filter really dead?""Yes." (It is not. On a 4 W flight controller the filter is often still the right call, and there is a 2022–2024 literature bringing it back with better mathematics.)

The picture that settles the question

Before any algebra, draw two matrices. This is the single highest-value picture in the whole argument, because it makes everything visible in one glance.

On the left, EKF-SLAM: the state is the robot pose plus every landmark, and the object you carry forward is the covariance matrix Σ. Every landmark becomes correlated with every other landmark within a few updates, because they are all observed through the same drifting robot pose. Σ goes dense and stays dense.

On the right, a smoother: the state is every pose and every landmark, nothing is ever marginalised, and the object you carry is the information matrix Λ = Σ−1. Its entry (i, j) is nonzero if and only if variables i and j appear together in some measurement. Cameras see a handful of landmarks. Landmarks are seen by a handful of cameras. So Λ is overwhelmingly zero.

The one-breath version: "The covariance is dense because correlation is transitive. The information matrix is sparse because conditional independence is not — two landmarks that were never seen in the same frame have exactly zero information coupling. Filters store the dense object. Smoothers store the sparse one. Everything else follows."

Produce the four numbers, not the word

That paragraph asserted "nonzero if and only if variables i and j appear together in some measurement" and then walked away from it. Do not let it. A claim like that earns nothing until you can show it — and it takes just six numbers, written by hand.

So here is the whole thing — the sparsity claim, the Schur complement, and the fill-in — in numbers small enough to do standing up. Chapter 1 derives this properly, from the Gaussian integral. This is the sixty-second version you can carry in your head.

Step 1: one factor, one outer product. Stack every linearised measurement into one tall Jacobian A, so the normal-equation matrix is Λ = ATWA, where W is the block-diagonal matrix of measurement weights. That sum splits into one term per measurement row-block:

Λ = ATWA = ∑k AkT wk Ak

Now look at a single odometry factor between poses i and j. Its residual is r = (xj − xi) − u, so its row-block Ak has nonzeros in exactly two block columns — column i and column j — and structural zeros in every other column. Write Ak = [ … 0 … Ji … 0 … Jj … 0 … ]. Multiply it out:

AkT w Ak deposits JiTwJi at (i, i),  JiTwJj at (i, j),  JjTwJi at (j, i),  JjTwJj at (j, j) — and nothing anywhere else.

Every other block of that outer product is a product with a structural zero, so it is exactly zero — not small, zero. Four blocks per factor, no matter how big the map is. That single line is the "nonzero iff co-observed" claim: an entry (i, j) can only become nonzero if some factor's row-block touched both i and j, because that is the only way a nonzero ever gets deposited there.

Step 2: assemble a three-pose chain, entry by entry. Work in one dimension with unit information so the arithmetic is trivial. Three poses x0, x1, x2; a prior on x0 and two odometry factors. The three rows of A are:

A = [ [1, 0, 0]; [−1, 1, 0]; [0, −1, 1] ],   W = I

Scatter the three outer products in, one at a time, and add:

FactorIts row of AWhat AkTAk deposits
prior on x0[1, 0, 0]Λ00 += 1. One block, because the row touches one variable.
odometry x0–x1[−1, 1, 0]Λ00 += 1, Λ11 += 1, Λ01 += −1, Λ10 += −1
odometry x1–x2[0, −1, 1]Λ11 += 1, Λ22 += 1, Λ12 += −1, Λ21 += −1

Adding: Λ00 = 1 + 1 = 2, Λ11 = 1 + 1 = 2, Λ22 = 1, Λ01 = −1, Λ12 = −1, and Λ02 = 0, because no row of A has a nonzero in both column 0 and column 2.

Λ = [ [2, −1, 0], [−1, 2, −1], [0, −1, 1] ]

Step 3: invert it and watch the sparsity evaporate. The determinant of that matrix is 2(2−1) + 1(−1) = 1, so the inverse comes out in whole numbers:

Σ = Λ−1 = [ [1, 1, 1], [1, 2, 2], [1, 2, 3] ]

Nine nonzeros out of nine. Same posterior, zero approximation, and the structural zero at (0, 2) is gone — because x0 and x2 genuinely are correlated (both depend on the shared x1), even though no measurement links them. That is the whole "covariance is dense, information is sparse" claim, in two 3×3 grids you can draw in fifteen seconds.

Step 4: marginalise x1, with all four numbers on the board. Split into keep a = {x0, x2} and drop b = {x1}. Read the four sub-blocks straight off Λ:

Λaa = [ [2, 0], [0, 1] ],  Λab = [ [−1], [−1] ],  Λbb = [2],  Λba = [ [−1, −1] ]

The Schur complement is Λaa − ΛabΛbb−1Λba. Do the correction term first — it is a 2×1 times a scalar times a 1×2:

[ [−1], [−1] ] · ½ · [ [−1, −1] ] = ½ · [ [1, 1], [1, 1] ] = [ [0.5, 0.5], [0.5, 0.5] ]

Subtract it:

Λaa − ΛabΛbb−1Λba = [ [2, 0], [0, 1] ] − [ [0.5, 0.5], [0.5, 0.5] ] = [ [1.5, −0.5], [−0.5, 0.5] ]
There are the four numbers. Point at the off-diagonal. Before marginalisation, the (x0, x2) entry of Λ was exactly 0 — a structural zero, because no measurement ever touched both poses. After marginalising x1 it is −0.5. Nobody added a measurement. Removing a variable manufactured a coupling between its neighbours. That is fill-in, and it is one number.

Step 5: prove the fill-in cost you nothing in accuracy. This is the move that separates leg three from leg one. Take the covariance from Step 3 and simply delete the row and column of x1 — marginalisation in moment form is a deletion, nothing more:

Σaa = [ [1, 1], [1, 3] ]  ⇒  Σaa−1 = 1(3−1) [ [3, −1], [−1, 1] ] = [ [1.5, −0.5], [−0.5, 0.5] ]

Entry for entry, that is the Schur complement from Step 4. So marginalisation is exact — the marginal it produces is the true marginal of the joint, to the last decimal. What it destroyed was not accuracy. It was the zero.

Step 6: generalise, and get the number that kills the EKF. Eliminating a variable with d neighbours turns those d neighbours into a fully connected clique, because ΛabΛbb−1Λba is a dense d×d block correction. New off-diagonal block pairs created: d(d − 1)/2.

What you eliminatedNew block pairs = d(d−1)/2Consequence
An interior pose of an odometry-only chain22·1/2 = 1The chain stays a chain. Free. This is why odometry-only marginalisation never hurt anyone.
A keyframe that observed 30 landmarks, plus its 2 chain neighbours3232·31/2 = 496496 block pairs where there were a handful. This is the sliding-window prior of Chapter 3, and why it is dense.
An EKF's previous pose, which observed all N = 500 landmarks500500·499/2 = 124,750Every landmark now couples to every other landmark. The map block goes dense in one step.

Check that last row against the 8 MB you are about to compute. 124,750 landmark-landmark block pairs, each a 2×2 block = 4 numbers, counted twice for symmetry: 124,750 × 4 × 2 = 998,000 entries. The landmark part of a 2-D EKF covariance is 1000×1000 = 1,000,000 cells, of which 500 × 4 = 2,000 are the diagonal blocks. 1,000,000 − 2,000 = 998,000. The two numbers match exactly, because "the EKF covariance is dense" and "one elimination made the map a clique" are the same sentence.

The whole argument, in order: four blocks per factor → the tridiagonal Λ → its dense inverse → the Schur complement with −0.5 where a zero used to be → Σaa−1 equals it, so the marginal is exact → d(d−1)/2 → 124,750. Six steps, and every one of them is a number you can write down rather than an adjective you can be argued out of.
Two matrices, one decision

Slide the landmark count and watch what each representation costs. The left pattern is an EKF covariance after a few dozen updates; the right is the information matrix of the equivalent smoother. Nothing is approximated in either — they describe the same posterior.

Landmarks N 30
Obs. per landmark 4

Push the slider to 120 landmarks and read the two numbers. The covariance is holding roughly sixty thousand doubles that are all genuinely nonzero; the information matrix is holding a few thousand, and the rest of the grid is structural zero. That gap is not a constant factor. It grows with N.

Put a number on it

Adjectives invite argument; arithmetic ends it. Here is the arithmetic for a mid-size problem, and it takes twenty seconds to do in your head.

Take a 2-D EKF-SLAM state: 3 numbers for the robot pose (x, y, θ) and 2 per landmark. With N = 500 landmarks the state dimension is n = 3 + 2×500 = 1003.

Now count its nonzeros in three piles. Each 2-D pose block is 3×3 = 9 numbers, each landmark block is 2×2 = 4, and each pose-landmark cross block is 3×2 = 6 (that 6 is the cross block, not a pose block — a common slip).

PileCountEntries eachSymmetric copy?Nonzeros
Diagonal, poses170 blocks3×3 = 9Already symmetric170 × 9 = 1,530
Diagonal, landmarks500 blocks2×2 = 4Already symmetric500 × 4 = 2,000
Odometry, pose–pose169 edges3×3 = 9×2 — (i,j) and (j,i)169 × 2 × 9 = 3,042
Observations, pose–landmark500 × 4 = 2,000 blocks3×2 = 6×2 — the transpose block is 2×32,000 × 2 × 6 = 24,000

Add the piles and divide by the smoother's own dimension: nnz = 1,530 + 2,000 + 3,042 + 24,000 = 30,572, out of 1,5102 = 2,280,100 cells. That is 1.34% fill. (Every ×2 above is the transpose block, and that is also where the leading 2 in a phrase like "2 × 500 × 4 blocks" comes from — Λ is symmetric, so every off-diagonal block is stored twice.)

Put the two side by side and the reconciliation writes itself: the filter's matrix is 1,003×1,003 at 100% fill = 1,006,009 stored numbers; the smoother's is 1,510×1,510 at 1.34% fill = 30,572 stored numbers. The bigger problem stores 33 times fewer numbers.

And then the sentence that reconciles the two: "but the smoother is solving a bigger problem, because it keeps all the poses too. It wins anyway, and the reason it wins is that its bigger matrix is sparse and the filter's smaller matrix is not."

Do the memory arithmetic

Say the numbers rather than the adjectives. Here is the same comparison at three map sizes, and every cell is one multiplication you can do while you are drawing.

Landmarks NEKF state n = 3 + 2NDense Σ (doubles)BytesUpdate cost O(n2)
5010310,60985 kB1.1 × 104
5001,0031,006,0098.05 MB1.0 × 106
5,00010,003100,060,009800 MB1.0 × 108

Ten times the map, one hundred times the cost. That is the whole reason nobody runs EKF-SLAM on a warehouse-scale map, and it is a two-line derivation you can produce from nothing.

Now the same for a pose graph. Poses are 6 DOF, so each pose contributes a 6×6 diagonal block, and each edge contributes a 6×6 off-diagonal block plus its transpose. For P poses with P−1 odometry edges and L loop closures:

nnz(Λ) = 36 · [ P + 2(P − 1) + 2L ]

At P = 5,000 and L = 800: nnz = 36 × [5,000 + 9,998 + 1,600] = 36 × 16,598 = 597,528 nonzeros. The dense matrix at that size is 30,0002 = 9 × 108 entries. The pose graph is at 0.066% fill — a factor of 1,506 fewer numbers, and the gap widens linearly with P.

The number that ends the discussion. Dense Cholesky of a 30,000×30,000 matrix costs n3/3 = 9 × 1012 flops. At a realistic 10 GFLOP/s that is 900 seconds — fifteen minutes per solve. A sparse Cholesky with a good elimination ordering on the same problem finishes in roughly 40 milliseconds. Same answer, twenty-two thousand times faster.

Stop trusting the prose — build the matrix

Every number above this line is a claim. The right reflex is "have I ever actually checked that?" — and the difference between an engineer who has and one who has not is audible in the first sentence either of them says about it.

So here is the whole argument as forty lines of numpy and scipy. No GTSAM, no Ceres, no g2o — those come at the end, and only after you have earned the right to use them. The script does three things: it assembles the stacked Jacobian A block by block, it counts the nonzeros of Λ = ATWA so the 597,528 and the 0.066% fall out of a running program instead of a paragraph, and it times a dense Cholesky against a sparse LU on the identical matrix.

python
import numpy as np, time
import scipy.linalg as sla, scipy.sparse as sp, scipy.sparse.linalg as spla

D = 6                                          # 6 DOF per pose

def chain(P, L, rng):
    """(P-1) odometry edges, then L loop closures between non-adjacent poses."""
    e = [(i, i + 1) for i in range(P - 1)]
    seen, gap = set(e), max(2, P // 20)
    while len(e) < (P - 1) + L:
        j = int(rng.integers(gap, P)); i = int(rng.integers(0, j - gap + 1))
        if (i, j) not in seen:
            seen.add((i, j)); e.append((i, j))
    return e

def build_A(P, edges, rng, sparse=False):
    """Stacked measurement Jacobian A: 6 rows per factor, two dense 6x6 blocks."""
    r_, c_, v_ = [], [], []
    def put(r0, c0, B):                        # scatter one 6x6 block
        for a in range(D):
            for b in range(D):
                r_.append(r0 + a); c_.append(c0 + b); v_.append(B[a, b])
    put(0, 0, 2.0 * np.eye(D))                 # prior anchors pose 0
    for k, (i, j) in enumerate(edges):         # relative-pose Jacobians are DENSE
        row = D * (k + 1)
        put(row, D * i, -(np.eye(D) + 0.25 * rng.standard_normal((D, D))))
        put(row, D * j,  (np.eye(D) + 0.25 * rng.standard_normal((D, D))))
    A = sp.coo_matrix((v_, (r_, c_)),
                      shape=(D * (1 + len(edges)), D * P)).tocsr()
    return A if sparse else A.toarray()

# --- 1. dense path: watch the fill percentage fall out of the matrix ------
for P, L in [(3, 0), (8, 2), (200, 30)]:
    rng = np.random.default_rng(7)
    A = build_A(P, chain(P, L, rng), rng)
    W = np.eye(A.shape[0])                     # unit weights, for clarity
    Lam = A.T @ W @ A                          # the normal-equation matrix
    nnz = int((np.abs(Lam) > 0).sum())         # structural zeros are exactly 0.0
    print(f"P={P:4d} L={L:3d}  nnz={nnz:7d}  cells={Lam.size:9d}"
          f"  fill={100 * nnz / Lam.size:7.3f}%"
          f"  formula={36 * (P + 2 * (P - 1) + 2 * L):7d}")

# --- 2. the warehouse-sized problem: P=5000, L=800 -----------------------
rng = np.random.default_rng(7)
P, L = 5000, 800
A = build_A(P, chain(P, L, rng), rng, sparse=True)
Lam = (A.T @ A).tocsc(); Lam.eliminate_zeros()
n, cells = D * P, (D * P) ** 2
print(f"\nP={P} L={L}: n={n} nnz={Lam.nnz} cells={cells}"
      f" fill={100 * Lam.nnz / cells:.4f}% ratio={cells / Lam.nnz:.1f}x")

# --- 3. same matrix, two factorisations, measured ------------------------
def best_of(f, reps=5):
    t = []
    for _ in range(reps):
        t0 = time.perf_counter(); f(); t.append(time.perf_counter() - t0)
    return min(t)

for P in [150, 300, 600, 1200]:
    rng = np.random.default_rng(7)
    A = build_A(P, chain(P, max(10, P // 8), rng), rng, sparse=True)
    Lam = (A.T @ A).tocsc(); Lam.eliminate_zeros()
    n = D * P
    Ld = Lam.toarray()                              # densify: 100% of the cells
    t_d = best_of(lambda: sla.cho_factor(Ld, lower=True))
    t_s = best_of(lambda: spla.splu(Lam))
    print(f"n={n:5d}  fill={100 * Lam.nnz / n ** 2:.3f}%"
          f"  dense {t_d * 1e3:6.2f}ms  sparse {t_s * 1e3:5.2f}ms"
          f"  ratio {t_d / t_s:4.1f}x  {(n ** 3 / 3) / t_d / 1e9:3.0f} GF/s")

# --- 4. and they agree, to ten decimal places ----------------------------
b = np.random.default_rng(1).standard_normal(Lam.shape[0])
x_dense = sla.cho_solve(sla.cho_factor(Ld, lower=True), b)
x_sparse = spla.splu(Lam).solve(b)
print(f"\nmax |x_dense - x_sparse| = {np.abs(x_dense - x_sparse).max():.2e}")

Two details in build_A are worth pausing on, because both are easy to get wrong. First, the odometry Jacobian blocks are dense 6×6 random-ish matrices, not ±I. On a pose graph the relative-pose residual differentiates to a negated adjoint, which is a full 6×6; using ±I would make each diagonal block diagonal too and undercount the nonzeros by 6×. Second, (np.abs(Lam) > 0) is safe here even though floating-point equality usually is not: a structural zero is a sum of products in which one factor was never written, so it is bit-exactly 0.0, not a small number.

Run it. This is the output, measured on an M-series laptop:

output
P=   3 L=  0  nnz=    252  cells=      324  fill= 77.778%  formula=    252
P=   8 L=  2  nnz=    936  cells=     2304  fill= 40.625%  formula=    936
P= 200 L= 30  nnz=  23688  cells=  1440000  fill=  1.645%  formula=  23688

P=5000 L=800: n=30000 nnz=597528 cells=900000000 fill=0.0664% ratio=1506.2x
n=  900  fill=2.151%  dense   1.22ms  sparse  0.77ms  ratio  1.6x  199 GF/s
n= 1800  fill=1.080%  dense   7.73ms  sparse  2.76ms  ratio  2.8x  252 GF/s
n= 3600  fill=0.541%  dense  54.10ms  sparse  6.06ms  ratio  8.9x  287 GF/s
n= 7200  fill=0.271%  dense 424.01ms  sparse 18.54ms  ratio 22.9x  293 GF/s

max |x_dense - x_sparse| = 3.47e-10

The counts are exact and bit-reproducible — the seed is pinned, so 252, 936, 23,688, 597,528 and 3.47e-10 come out identical on every run and every machine. The milliseconds are not: they move a few percent run to run and a lot more between an M-series laptop and a Jetson. Keep track of which of your numbers are theorems and which are measurements; conflating the two is how arguments go wrong.

Read the first block first. nnz and formula agree on every row — 252, 936, 23,688 — so 36·[P + 2(P−1) + 2L] is not a heuristic, it is an identity, and you can quote it without hedging. Note also that at P = 3 the matrix is 78% full: sparsity is an asymptotic property, and a three-pose graph is not sparse at all.

Then the second block: 597,528 nonzeros in 900,000,000 cells, 0.0664% fill, 1,506× fewer stored numbers. Those are the numbers from four paragraphs ago, and they now have a provenance.

The third block is where the argument stops being about memory and starts being about time. Watch what each doubling of n does:

n doublesDense factorisationGrowthSparse factorisationGrowthRatio
900 → 1,8001.22 → 7.73 ms×6.30.77 → 2.76 ms×3.61.6×
1,800 → 3,6007.73 → 54.10 ms×7.02.76 → 6.06 ms×2.28.9×
3,600 → 7,20054.10 → 424.01 ms×7.86.06 → 18.54 ms×3.122.9×

The dense column converges on ×8 = 23 per doubling. That is n3, measured, not asserted. The sparse column sits between ×2 and ×3 — linear plus the fill-in the elimination introduces. (The first sparse row's ×3.6 is setup overhead dominating a 0.77 ms measurement; rows that small are noise, so ignore them.) The last column is the one that matters: the advantage is not a constant factor. It was 1.6× and three doublings later it is 23×, and it keeps going.

Extrapolate the measured n3 to the pose graph you actually have: 424 ms × (30,000/7,200)3 = 424 ms × 72.3 = 31 seconds on a laptop hitting 293 GFLOP/s. That is the same 9 × 1012 flops as the callout above — the callout's 900 seconds assumes 10 GFLOP/s, which is the honest figure for one core of an embedded CPU, the thing actually bolted to the robot. Quote 15 minutes for the robot and half a minute for the workstation and you will be right on both machines. Extrapolating the sparse column linearly gives 18.54 ms × 4.17 = 77 ms, in the same neighbourhood as the 40 ms a proper CHOLMOD/AMD Cholesky achieves, because splu is a general-purpose LU that does not exploit symmetry.

The detail that closes it, and it is not a flop count. A dense 30,000×30,000 double matrix is 30,0002 × 8 B = 7.2 GB of resident RAM. The sparse one is 597,528 × 12 B ≈ 7.2 MB. On a Jetson-class board with 8 GB shared between perception, planning and the OS, the dense solve is not slow — it does not exist. Time is the answer people expect; memory is the answer that ends the conversation.

And the last line of the output is the one that protects you from the obvious objection. max |x_dense - x_sparse| = 3.47e-10: the two factorisations return the same solution to ten decimal places. Sparsity is not an approximation, a relaxation, or a trade. It is the same answer, obtained by not multiplying by zero.

Only now do you get to say the library line:

python
# ...and this is what the library does for you, with a better ordering,
# a symmetric factorisation, robust kernels and manifold retractions:
result = gtsam.LevenbergMarquardtOptimizer(graph, initial).optimize()
Field note: the script embodies three decisions, not just syntax. "Build A rather than Λ directly, because the square-root form is better conditioned — that is Dellaert's 2006 point"; "the blocks are dense 6×6 because a relative-pose Jacobian is an adjoint, and using ±I would make the nonzero count wrong by 6×"; "time best-of-five, not the mean, because you want the machine's capability, not its interruptions." Three sentences, and every one of them is a claim you could be challenged on and defend.

Two answers, same question

It is worth reading these side by side, because the difference is not knowledge — both answers come from someone who knows what a factor graph is. The difference is structure and arithmetic.

The weak answerWhy it fails
"Factor graphs are more accurate because they optimise the whole trajectory instead of just the current pose."Accuracy is the consequence, not the reason. And "optimise the whole trajectory" is a description, not a mechanism — the natural follow-up, "why can you afford to?", has nowhere to go.
"The EKF is O(n2) and factor graphs are sparse."Correct and shallow. It states two facts without connecting them. The follow-up — "why is the EKF dense?" — is the actual question, and it has a one-word answer this response never reaches.
"Graphs let you do loop closure."Filters can incorporate loop closures too; EKF-SLAM does it all the time. What filters cannot do is retroactively correct the trajectory, because the trajectory is gone.
The strong answerWhy it works
"Because marginalisation is a Schur complement, and a Schur complement makes the neighbours of the eliminated variable a clique. A filter marginalises every past pose, so its matrix is the fill-in of the smoother's. Dense at 104 unknowns is fifteen minutes; sparse is forty milliseconds."One mechanism, one consequence, one number. Every clause is falsifiable.
"The second reason is relinearisation. A filter bakes each Jacobian in at the estimate it held at the time. A smoother keeps the raw measurements and re-linearises everything each iteration, so when the estimate improves, the past improves with it."This is the leg almost nobody gives. It also sets up the consistency argument when the discussion goes deeper.
"And the honest caveat: marginalisation itself is exact for a linear system. What it costs is fill-in and a frozen linearisation point. On a platform where you would marginalise anyway — a 4 W drone with no map — a well-designed filter is still competitive."Owning the limits of your own claim is the strongest form of technical credibility.

Fifteen years in eight lines

The history is worth knowing cold, because the sequence tells you which paper to name when you make a claim, and naming the right paper is worth more than any adjective.

YearWhat changedThe paper
1986The stochastic map: put the robot and all landmarks in one Gaussian state. EKF-SLAM is born, and so is the dense covariance.Smith, Self & Cheeseman, Estimating uncertain spatial relationships in robotics
1997Reframe SLAM as a network of relative pose constraints and optimise it globally. The pose graph.Lu & Milios, Globally consistent range scan alignment, Autonomous Robots
2002Rao-Blackwellised particle filter: sample the trajectory, and conditioned on it the landmarks decouple entirely. A different escape from the dense covariance.Montemerlo, Thrun et al., FastSLAM, AAAI
2006Square-root smoothing: never form the information matrix at all, factor the Jacobian directly. Halves the condition number in the exponent and makes it incremental.Dellaert & Kaess, Square Root SAM, IJRR
2010Proof that EKF-SLAM's inconsistency is a rank error in the linearised observability matrix, not a tuning problem.Huang, Mourikis & Roumeliotis, Observability-based rules for designing consistent EKF SLAM estimators, IJRR
2011–12The tooling arrives: g2o makes graph optimisation fast and easy to embed; iSAM2 and the Bayes tree make it genuinely incremental with fluid relinearisation.Kümmerle et al. (ICRA 2011); Kaess et al. (IJRR 2012)
2015–17IMU preintegration on the manifold makes tightly-coupled visual-inertial smoothing real-time.Forster, Carlone, Dellaert & Scaramuzza, RSS 2015 / T-RO 2017
2020–24The filter comes back for constrained platforms — OpenVINS makes a consistent MSCKF the reference implementation, and equivariant filter design attacks the linearisation problem at its root.Geneva et al. (ICRA 2020); Fornasier, van Goor, Mahony & Weiss (2022–23)
The framing that impresses: "It was never filters versus graphs. It was a question of what you can afford to keep. In 1986 you could not afford to keep the trajectory, so you marginalised it and paid in density. By 2006 sparse linear algebra made keeping it cheaper than throwing it away. The decision flipped because the cost function changed, not because someone discovered a better idea."

The five backends worth comparing

Almost every backend discussion is secretly about placing one of these five on a map. Learn the axes, not the list.

BackendWhat it keepsCost per updateRelinearises?Where it still wins
EKF-SLAMCurrent pose + all landmarks, dense ΣO(N2)NeverNowhere, for mapping. Historically important; still a great teaching object.
FastSLAM (RBPF)M sampled trajectories, each with independent landmark filtersO(M log N)Per particle, implicitly2-D grid SLAM on cheap hardware — gmapping is still deployed.
MSCKFA short window of clone poses; landmarks marginalised at once via nullspace projectionO(window3), boundedNo — and FEJ deliberately freezes the linearisation point further, to fix observability rather than to improve the estimate4–10 W platforms, no loop closures, tight latency. OpenVINS.
Fixed-lag smootherThe last K keyframes + their landmarks; older states marginalised into a dense priorO(K3) — bounded by constructionYes, inside the windowThe default for shipping VIO. VINS-Mono, OKVIS, Kimera.
Full smoother (iSAM2)Everything, foreverAmortised near-constant; spikes on loop closureYes — fluid, only where neededMapping, offline reconstruction, anything that revisits. GTSAM.

The two axes that organise the table are what you marginalise and when you relinearise. Every row is a different point on those two axes. If you can say that sentence, you understand the table instead of remembering it.

Where this sits in a real stack

"Where does the backend actually live?" is the question that separates people who have shipped one from people who have read about one. Here is the honest data flow for a visual-inertial system on a mobile robot, with rates and sizes attached to every arrow.

Frontend — features & tracks
Camera 20 Hz, 640×480 mono. ~150 tracked features per frame, each 2 floats + an id = 12 B → 1.8 kB/frame, 36 kB/s. Covered in SLAM Frontend.
↓ keyframe decision (parallax > 10 px OR tracked < 20 features)
Keyframes at ~4 Hz
One keyframe every ~250 ms. Between two keyframes sit 50 IMU samples at 200 Hz — these become ONE preintegrated factor, not fifty.
why those two thresholds (the because-clauses are the whole point)
The keyframe rule, justified
parallax > 10 px BECAUSE below that the two views triangulate a depth whose uncertainty exceeds the baseline that produced it. Triangulated depth error is σz = z2σpx/(fB). Ten pixels of parallax at f = 400 px and z = 5 m implies a baseline B = 10z/f = 12.5 cm, giving σz = (25 × 0.5)/(400 × 0.125) = 0.25 m — already twice the baseline. Halve the parallax to 5 px and B falls to 6.3 cm while σz doubles to 0.50 m, eight times the baseline: that landmark's depth is now pure prior, it contributes no information, and it still costs one inverse-depth unknown plus two residual rows per observation. tracked < 20 BECAUSE a 6-DOF relative pose needs 5 correspondences minimum (the five-point algorithm); 20 is the margin that survives RANSAC throwing away 40–60% of them as outliers and still leaves an over-determined system.
One preintegrated factor, justified
ONE factor BECAUSE re-integrating 50 samples inside every one of the ~8 Gauss-Newton iterations is 400 integrations per keyframe, and at 4 Hz that is 1,600 integrations/s — versus 50/s for a single forward pass plus a first-order bias-Jacobian correction when the bias estimate moves. Same 32× ratio at any window size; it is what makes tightly-coupled VIO fit in the budget at all (Chapter 5).
↓ factors handed to the backend
Sliding-window optimiser
10 keyframes × 15 states (p, v, q, ba, bg) = 150, plus ~150 inverse depths, plus 6 extrinsic and 1 time offset → state dimension ≈ 307. Solves in 25–60 ms; the deadline is the 250 ms keyframe period.
↓ marginalise the oldest keyframe
Marginalisation prior
A DENSE block over the states that touched the removed keyframe — typically 30×30 to 60×60. It carries a linearisation point that is now frozen. You accept that prior BECAUSE the alternative price is unbounded: keep every past state and the window grows by one keyframe every 250 ms. The solve is O(n3), so every doubling of the window multiplies it by 8: 10 keyframes takes 40 ms, 20 keyframes takes ~320 ms, and the 250 ms deadline is gone five seconds into the drive. Ten minutes in, the state would be 2,400 keyframes × 15 = 36,000 numbers instead of 307. The prior buys a constant state dimension — that is the entire trade, and its cost is d(d−1)/2 blocks of fill-in plus a Jacobian you may never re-evaluate (Chapter 3).
↓ pose + covariance published
Consumers
nav_msgs/Odometry at 200 Hz (IMU-propagated between solves): pose 7 doubles = 56 B, covariance 36 doubles = 288 B. The planner turns σ into clearance in metres; the controller gates on it.
↓ on loop closure, asynchronously
Global pose graph (separate thread)
5,000 poses × 6 DOF = 30,000 unknowns, ~5,800 edges. Sparse Cholesky with a good ordering: ~40 ms. With a bad ordering: seconds. Chapter 6 makes you feel that difference.
Two clocks, not one. The window optimiser has a hard-ish deadline (finish before the next keyframe). The global pose graph has none — it runs on its own thread and merges its result when it is ready. Put loop closure inside the real-time window and sooner or later a 4-second solve lands in a 250 ms budget.

The failure this chapter is really about

Here is the field report that motivates everything after this page. A warehouse robot runs EKF-SLAM. After twenty minutes of driving loops in an aisle, the filter reports a heading uncertainty of 0.2°. The true heading error, measured against a survey, is 4°.

It did not diverge. The covariance never went negative. No solver failed. The filter simply became certain of something it had no right to be certain about, and it did so because of the linearisation it froze at the first update.

SymptomWhat most people blameWhat it actually isThe metric that reveals it
Heading covariance collapses far below the true error"Q is too small"Linearisation at inconsistent points gives the linearised system a 2-D unobservable subspace where the true one is 3-D. The filter gains information about global yaw that no measurement contains.Numerical rank of the stacked Jacobian: should be n − 3 for 2-D SLAM. Measure the dimension of the numerical nullspace, not just NEES.
Update time grows quadratically with map size"We need a faster CPU"The covariance is dense; the update touches every entry.Wall time per update vs landmark count on a log-log plot. Slope 2 is the diagnosis.
A late loop closure barely moves the trajectory"The loop closure was weak"The old poses were marginalised. There is nothing left to correct — the filter has no representation of where it was.Compare the trajectory delta after loop closure against a batch re-solve on the same bag.

That first row is the one to remember. It has a name and a paper: observability-based inconsistency in EKF-SLAM, characterised by Huang, Mourikis and Roumeliotis (IJRR 2010). In 2-D SLAM the global position and heading are unobservable — three degrees of freedom that no relative measurement can pin down. The true system knows this. The linearised system, with Jacobians evaluated at different estimates at different times, does not: its unobservable subspace has dimension 2. The missing dimension is yaw, and the filter quietly harvests fictitious information about it on every single update.

The transferable version: when an estimator is confidently wrong in a direction that is physically unobservable, suspect the linearisation policy, not the noise parameters. Tuning Q cannot fix a rank error.

The whole argument in ninety seconds

When you need to explain this to a teammate, structure beats completeness. This is the script:

1. Name the object (10 s)
"The question is really about which matrix you carry: the covariance or the information matrix."
2. Draw both patterns (20 s)
Two squares on the board. Left: filled in. Right: a thin band plus a few off-diagonal dots for loop closures.
3. Explain WHY each is that shape (25 s)
"Marginalising a variable Schur-complements it out and makes its neighbours a clique. Filters marginalise every pose. So the filter's matrix is the fill-in of the smoother's."
4. Give the cost (15 s)
"Dense is n3/3 to factor. Sparse with a decent ordering is close to linear. At 30,000 unknowns that is 15 minutes versus 40 milliseconds."
5. Add the second leg unprompted (20 s)
"And there is a second reason nobody mentions: relinearisation. The filter's Jacobians are frozen at the estimate it held at the time. That is what makes EKF-SLAM inconsistent, not just slow."
6. Concede honestly (10 s)
"On a 4 W flight controller with no loop closures, an MSCKF still beats a smoother on accuracy-per-watt. The field did not abandon filters; it stopped using them for mapping."

Step 6 is what turns a good explanation into a trustworthy one. Everyone has heard the sparsity story; almost nobody volunteers the counter-case.

Graphs win because they are sparse — but the smoother carries every pose and every landmark, so its state is much larger than the filter's. How do you reconcile that?

The three questions behind the question

"Why did the field move to factor graphs?" is a container. Inside it sit three sharper questions, and each one has its own chapter.

If the follow-up is…The real question is…Go here
"Show me the matrix." / "What does marginalisation do to it?"Do you understand sparsity as a structural property rather than a performance adjective?Chapters 1 and 2
"But you cannot keep every pose forever — what do you do?"Do you know how bounded compute is actually bought, and what it costs?Chapter 3
"Which library, and why?" / "It got slow. Why?"What does shipping one actually involve?Chapters 4 and 6

A fourth question shows up on visual-inertial teams: "and how do you get the IMU into that graph without re-integrating two hundred samples every iteration?" That is Chapter 5, and it is the single most common place where people who can draw a factor graph fall over.

What you should be able to do by the end

Not "know about". Do, with a marker in your hand, from memory:

So is the Kalman filter dead in robotics?

Chapter 1 does the derivation that makes all of this precise: what marginalisation is, what it costs, and why the answer is not "accuracy".

Chapter 1: Filtering vs Optimisation

Start with the smallest problem that shows everything. Three robot poses in a line. Odometry between consecutive ones, a prior on the first. Write the matrix.

This is the derivation. It takes four minutes, it is entirely mechanical, and if you can do it cold you have already answered every follow-up in this chapter.

A Gaussian has two faces

Everything here rests on one fact: a Gaussian can be written two equivalent ways, and the two ways make different operations cheap.

The moment form is the one you learned first: a mean μ and a covariance Σ.

p(x) ∝ exp( −½ (x − μ)T Σ−1 (x − μ) )

The information form (also called the canonical form) multiplies the exponent out and keeps the two things that survive:

p(x) ∝ exp( −½ xT Λ x + ηT x ),   Λ = Σ−1,  η = Λμ

Λ is the information matrix (sometimes the precision matrix) and η is the information vector. Nothing has been approximated — these are two parameterisations of the identical distribution. But look at what each one makes easy:

OperationIn moment form (μ, Σ)In information form (η, Λ)
Marginalise a variable out
"forget where I was"
Trivial. Delete its row and column from Σ. Cost: zero.Expensive. A Schur complement, and it destroys sparsity.
Condition on a measurement
"I just saw a landmark"
Expensive. The Kalman update: an inverse, and it touches every entry of Σ.Trivial. Add JTΣz−1J into Λ at the rows and columns of the variables involved. Cost: the size of one block.
Read the answerFree — μ is right there.Requires solving Λx = η.
SparsityDense as soon as anything is correlated.Nonzero at (i, j) iff i and j appear in a common factor.
This table IS the whole answer. A filter's core loop is condition, then marginalise. It has to pick one parameterisation, and it picks the one that makes marginalisation free — then pays for conditioning on every update, and pays again in density. A smoother never marginalises at all, so it picks the parameterisation that makes conditioning free and stays sparse. Same mathematics, opposite choice, and the choice was forced by which operation you perform.

Why Λ is sparse: the one-sentence proof

A factor graph's cost is a sum of squared residuals, one per measurement:

C(x) = ½ ∑f rf(x)T Σf−1 rf(x)

Linearise each residual about the current estimate x: rf(x + δ) ≈ rf + Jfδ, where rf is the residual evaluated at x and Jf = ∂rf/∂x is evaluated there too. Now do the algebra in full — it is one substitution, one expansion and one derivative, and the object it produces is the matrix the entire rest of this lesson is about.

Step 1 — substitute. Rewrite the cost as a function of the step δ instead of the state x:

C(x + δ) ≈ ½ ∑f ( rf + Jfδ )T Σf−1 ( rf + Jfδ )

Step 2 — expand the product. Multiplying the bracket out gives four terms. The two cross terms, rTΣ−1Jδ and δTJTΣ−1r, are 1×1 scalars and are transposes of each other, so they are equal and collapse into one term of twice the size, cancelling the ½:

C(x + δ) ≈ ½ ∑f rfTΣf−1rf  +  δT ( ∑f JfTΣf−1rf )  +  ½ δT ( ∑f JfTΣf−1Jf ) δ

The greyed term is the cost you already have. It contains no δ, so it cannot move the minimum — drop it. What survives is a plain quadratic in δ: a gradient term and a curvature term, and nothing else.

Step 3 — differentiate and set to zero. For a quadratic δTg + ½δTHδ with H symmetric (and JTΣ−1J is symmetric by construction), the gradient is exactly g + Hδ:

∂C/∂δ = ∑f JfTΣf−1rf  +  ( ∑f JfTΣf−1Jf ) δ  =  0

Move the constant vector across and you have the normal equations:

( ∑f JfT Σf−1 Jf ) δ = − ∑f JfT Σf−1 rf

Two footnotes worth having ready, because both come up constantly. The left matrix is the Gauss-Newton Hessian: the exact Hessian carries an extra ∑f (∂2rf/∂x2)TΣf−1rf term, which Gauss-Newton drops because it is multiplied by the residual and therefore small near a good solution — and large when the fit is bad, which is exactly why Gauss-Newton diverges on a bad initialisation (Chapter 4). And it is the same object as the Λ = Σ−1 of the table above: the curvature of the negative log-likelihood is the inverse covariance. One symbol, two jobs, no coincidence.

Name the left matrix Λ and the right vector η. Now the key observation: Jf is zero everywhere except in the columns of the variables that factor f actually touches. A range measurement touches one pose and one landmark. An odometry factor touches two poses. So JfTΣf−1Jf is a tiny outer product scattered into two or three block rows and columns, and everything else stays exactly zero.

Λij ≠ 0 if and only if variables i and j appear together in at least one factor. The information matrix is the adjacency matrix of the factor graph, with blocks instead of ones. That one sentence closes the sparsity question.

Worked example 1: the three-pose chain, by hand

Work in one dimension so every number fits on a whiteboard. Three poses x0, x1, x2 on a line. Three factors:

Take the odometry residual r = (xj − xi) − u. Its Jacobian with respect to (xi, xj) is J = [−1, +1]. So its contribution to Λ is:

JT w J = 4 · [−1, +1]T[−1, +1] = 4 · [ [1, −1], [−1, 1] ] = [ [4, −4], [−4, 4] ]

Now scatter the three contributions into a 3×3 grid, one at a time, and add:

FactorRows/cols it touchesWhat it adds
prior on x0, w = 1(0,0) onlyΛ00 += 1
odom 0→1, w = 4{0, 1}Λ00 += 4,   Λ11 += 4,   Λ01 −= 4,   Λ10 −= 4 (symmetric)
odom 1→2, w = 4{1, 2}Λ11 += 4,   Λ22 += 4,   Λ12 −= 4,   Λ21 −= 4 (symmetric)

Adding those up entry by entry: Λ00 = 1 + 4 = 5, Λ11 = 4 + 4 = 8, Λ22 = 4, Λ01 = −4, Λ12 = −4, and Λ02 = 0, because no factor touches both x0 and x2.

Λ = [ [5, −4, 0], [−4, 8, −4], [0, −4, 4] ]

Tridiagonal. That single structural zero at (0,2) is the entire argument of this lesson in one entry. Now watch what marginalisation does to it.

Marginalisation is a Schur complement — the derivation

Split the variables into the ones you want to drop (call them a) and the ones you want to keep (b). Write the information form blockwise:

Λ = [ [Λaa, Λab], [Λba, Λbb] ],   η = [ ηa; ηb ]

Marginalising means integrating a out: p(b) = ∫ p(a, b) da. Complete the square in a inside the exponent. The exponent is

−½( aTΛaaa + 2aTΛabb + bTΛbbb ) + ηaTa + ηbTb

Group everything that contains a. Two of the five terms above do:

−½ aTΛaaa − aTΛabb + ηaTa  =  −½ aTΛaaa + aT( ηa − Λabb )

That is a quadratic in a with linear coefficient (ηa − Λabb), so its stationary point is a* = Λaa−1a − Λabb). Complete the square about it — add and subtract ½a*TΛaaa* — and the whole exponent, both blocks, becomes:

−½(a − a*)TΛaa(a − a*)  +  ½ a*TΛaaa*  −  ½ bTΛbbb  +  ηbTb

Only the first term contains a, and ∫ exp(−½(a−a*)TΛaa(a−a*)) da = (2π)n/2aa|−1/2, a constant with no b in it. It disappears into the normalisation. The other three terms are constants as far as the a-integral is concerned and pass straight through it. That is the entire trick, and it is why the result is exact rather than approximate: nothing was dropped, one integral was evaluated in closed form.

Now expand the surviving a* term — this is where the two corrections come from, and it is the step most write-ups skip:

½ a*TΛaaa* = ½( ηa − Λabb )T Λaa−1 ( ηa − Λabb )

(using Λaaa* = ηa − Λabb, so one Λaa cancels against the two Λaa−1). Multiply it out and sort the three pieces by their power of b, remembering Λba = ΛabT:

PieceValueWhere it goes
constant in b+½ ηaTΛaa−1ηaInto the normalisation. Irrelevant to the estimate.
linear in b− bT ΛbaΛaa−1ηaAdds to ηbTb → the η' correction.
quadratic in b+½ bTbaΛaa−1Λab) bAdds to −½bTΛbbb → the Λ' correction.

Collect the b-quadratic terms: −½bTΛbbb + ½bTbaΛaa−1Λab)b = −½bTbb − ΛbaΛaa−1Λab)b. Collect the b-linear terms: ηbTb − (ΛbaΛaa−1ηa)Tb. Compare against the standard information form −½bTΛ'b + η'Tb and read off both results:

Λ' = Λbb − Λba Λaa−1 Λab
η' = ηb − Λba Λaa−1 ηa

Note the shared factor Λaa−1Λab and Λaa−1ηa. In code you never form Λaa−1; you solve ΛaaX = Λab and Λaay = ηa once, then take Λbb − ΛbaX and ηb − Λbay — which is exactly the np.linalg.solve pair in the CODE section below.

That is the Schur complement of Λaa in Λ. Two things to say about it while you write it:

  1. It is exact, and you just watched why: the only operation performed on the exponent was completing a square, which is an identity, and the only integral performed had a closed form. No term was truncated, no series expanded. For a linear-Gaussian system marginalising loses nothing at all — the reduced system gives numerically identical estimates for the kept variables. (The linearisation that produced Λ in the first place is an approximation; the marginalisation of that linear system is not. Keeping those two straight is the whole of Chapter 3, where you will also check it numerically in the Code Lab.)
  2. It is not free. The correction term ΛbaΛaa−1Λab is an outer product over all the neighbours of a. Every pair of neighbours that was structurally zero becomes nonzero.

Worked example 2: marginalise a leaf, then marginalise the middle

Same Λ = [[5, −4, 0], [−4, 8, −4], [0, −4, 4]]. Two cases, both by hand.

Case A — drop x0 (a leaf, degree 1). Here a = {0}, b = {1, 2}.

Λaa = [5],  Λab = [−4, 0],  Λba = [−4; 0],  Λbb = [[8, −4], [−4, 4]]

The correction is

Λba Λaa−1 Λab = [−4; 0] × (1/5) × [−4, 0] = (1/5) [ [16, 0], [0, 0] ] = [ [3.2, 0], [0, 0] ]

Subtract entry by entry: (0,0): 8 − 3.2 = 4.8. (0,1): −4 − 0 = −4. (1,1): 4 − 0 = 4.

Λ' = [ [4.8, −4], [−4, 4] ]

The zero pattern is unchanged — there were no zeros to destroy, because x0 had exactly one neighbour and a single neighbour cannot form a new pair. Marginalising a leaf is free.

Case B — drop x1 (the middle, degree 2). Now a = {1}, b = {0, 2}.

Λaa = [8],  Λab = [−4, −4],  Λbb = [ [5, 0], [0, 4] ]
Λba Λaa−1 Λab = (1/8) [ [16, 16], [16, 16] ] = [ [2, 2], [2, 2] ]
Λ' = [ [5, 0], [0, 4] ] − [ [2, 2], [2, 2] ] = [ [3, −2], [−2, 2] ]

Look at entry (0,1). It was 0. It is now −2.

That −2 is fill-in, and it is the whole lesson. Poses 0 and 2 never shared a measurement. Nothing in the world directly relates them. But once you forget x1, the only way to preserve the information that flowed through it is to write down a direct coupling between its neighbours. The graph gained an edge that no sensor ever measured.

The general rule falls straight out: eliminating a variable of degree d makes its d neighbours a clique, creating up to d(d−1)/2 edges. For d = 2 that is one edge. For d = 10 it is 45. For d = 50 it is 1,225.

Worked example 3: the landmark that densifies everything

This is the EKF-SLAM story in four numbers. One landmark l, observed from three poses, each observation with weight 1 and residual r = l − xi (Jacobian [−1, +1] again). Assemble Λ over (x0, x1, x2, l):

Λ = [ [1, 0, 0, −1], [0, 1, 0, −1], [0, 0, 1, −1], [−1, −1, −1, 3] ]

The three poses are mutually uncoupled — the top-left 3×3 block is the identity. Now marginalise the landmark: a = {l}, Λaa = [3], Λab = [−1, −1, −1].

Λba Λaa−1 Λab = (1/3) · [ [1,1,1], [1,1,1], [1,1,1] ]
Λ' = I3 − (1/3)·13×3 = [ [2/3, −1/3, −1/3], [−1/3, 2/3, −1/3], [−1/3, −1/3, 2/3] ]

A completely dense 3×3. Six structural zeros became six nonzeros from a single elimination. Now imagine a thousand landmarks and a robot that marginalises its pose after every step, and you have derived the EKF-SLAM covariance from scratch.

Say it in one sentence: "The EKF covariance is not a different matrix from the smoother's information matrix. It is the smoother's matrix after you Schur-complement out every pose you no longer keep. Density is not a property of the filter — it is the arithmetic residue of forgetting."

Worked example 4: degree four — counting the clique entry by entry

The rule "d neighbours become a clique, up to d(d−1)/2 new edges" was asserted three paragraphs ago. Assertions are not proofs. Compute it — the same star as above with one more pose, so d = 4, small enough to write every entry and big enough that the count is not trivially 1.

Four poses x0…x3, one landmark l, four observations, weight 1 each, residual ri = (l − xi) − zi so Ji = [−1, +1] over (xi, l). Scatter each one exactly as in Worked example 1: Λii += 1, Λll += 1, Λil −= 1, Λli −= 1. Four factors later, over the ordering (x0, x1, x2, x3, l):

Λ = [ [1, 0, 0, 0, −1], [0, 1, 0, 0, −1], [0, 0, 1, 0, −1], [0, 0, 0, 1, −1], [−1, −1, −1, −1, 4] ]

Count the nonzeros before. Five on the diagonal (four poses at 1, the landmark at 4, which is 1 per observation) plus eight off-diagonal (four −1 entries, each appearing twice by symmetry) = 13 nonzeros in 25 slots, 52% dense. The four poses are mutually uncoupled: the top-left 4×4 block is I4, sixteen slots holding four nonzeros.

Now eliminate l. a = {l}, so Λaa = [4], Λab = [−1, −1, −1, −1], Λba is its transpose, and the correction is a 4×1 times 1×1 times 1×4 outer product:

Λba Λaa−1 Λab = (1/4) · [ [1,1,1,1], [1,1,1,1], [1,1,1,1], [1,1,1,1] ] = 0.25 · 14×4

Subtract it from Λbb = I4. Every diagonal entry: 1 − 0.25 = 0.75. Every off-diagonal entry: 0 − 0.25 = −0.25. All sixteen:

Λ' = [ [0.75, −0.25, −0.25, −0.25], [−0.25, 0.75, −0.25, −0.25], [−0.25, −0.25, 0.75, −0.25], [−0.25, −0.25, −0.25, 0.75] ]

Now the count the rule predicted. Twelve off-diagonal entries were structurally zero and are now −0.25. Twelve entries is six edges, because Λ is symmetric and one edge owns two entries — and d(d−1)/2 = 4·3/2 = 6. The rule is not an estimate; it is the exact count, and "up to" only because two neighbours that already shared a factor were already coupled.

QuantityBefore eliminating lAfter
Matrix size5 × 5 (25 slots)4 × 4 (16 slots)
Nonzeros1316
Density52%100%
Off-diagonal nonzeros812 (all new)
Cholesky cost, n3/3 dense≈ 42 flops≈ 21 flops
Read the middle row again. The matrix got smaller — you deleted a variable — and it gained nonzeros, 13 → 16. That is the arithmetic that eventually kills EKF-SLAM: you are trading dimension, which grows linearly, for density, which grows quadratically. At d = 3 the trade is neutral (10 nonzeros → 9). At d = 4 it turns against you. It never turns back.

The general form falls straight out of the same three lines. For a landmark seen by d poses with unit weights, Λaa = [d], Λab = −1T, and:

Λ' = Id − (1/d) 1 1T  —  diagonal (d−1)/d, every off-diagonal −1/d

That matrix has a name outside robotics: it is the centering matrix. Applied to a vector it returns that vector minus its own mean. Which is exactly the physical statement: once the landmark is gone, the only thing the four poses know about each other is their deviation from the group average. The landmark's whole job was to be that average, and marginalising it did not delete that job — it redistributed it across six new edges.

The check that proves nothing was invented. Sum any row of Λ': 0.75 − 3(0.25) = 0. So Λ'·1 = 0 — the all-ones vector is in the nullspace, meaning "slide every pose by the same amount" costs nothing. Now check the original: row 0 of the 5×5 gives 1 − 1 = 0, and the landmark row gives −4 + 4 = 0, so Λ·1 = 0 too. The rank deficiency survived the elimination exactly — one gauge direction before, one after. Marginalisation moved information around; it neither created nor destroyed any. Contrast this with the EKF's inconsistency later in the chapter, where a nullspace of dimension 3 silently becomes 2. That is the difference between an exact operation and a broken one, and it is testable in one line.

python
import numpy as np
d   = 4
Lam = np.eye(d + 1); Lam[:d, d] = -1; Lam[d, :d] = -1; Lam[d, d] = d
S   = Lam[:d, :d] - np.outer(Lam[:d, d], Lam[d, :d]) / Lam[d, d]
print(S)                              # 0.75 on the diagonal, -0.25 everywhere else
print((Lam != 0).sum(), (S != 0).sum())  # 13 16  <- fewer variables, more nonzeros
print(np.allclose(S @ np.ones(d), 0))   # True — the gauge freedom survived exactly

Now scale the same three lines. This table is the order-of-magnitude drill: memorise the shape, not the numbers.

d (poses seeing the landmark)New edges d(d−1)/2nnz before → afterVerdict
217 → 4Cheaper. Marginalise freely.
3310 → 9Break-even.
4613 → 16Starts to hurt.
104531 → 1003× the nonzeros for one variable.
501,225151 → 2,500A long-lived feature is a catastrophe.
20019,900601 → 40,000Why you drop the track instead.

Two engineering consequences follow, and both show up in later chapters. One: the cost of eliminating a degree-d variable is O(d2) work to form the outer product and O(d2) memory to store the result, so elimination order matters enormously — eliminate low-degree variables first and the fill-in stays bounded, which is precisely what COLAMD does inside Ceres and GTSAM (Chapter 4). Two: in bundle adjustment you eliminate the points, not the cameras, because a point is seen by maybe 10–30 cameras while a camera sees 500–2,000 points; eliminating points costs a 30×30-ish fill per point, eliminating cameras would cost a 2,000×2,000 fill per camera. That asymmetry is the entire reason the reduced camera system in Chapter 2 is built the way it is.

The one-breath version: "Marginalising a variable of degree d is exact, costs O(d2), and makes its d neighbours a clique — d(d−1)/2 new edges. For a landmark seen by 4 poses that is 6 new edges and the information matrix goes from 13 nonzeros to 16 while shrinking. So the question is never 'is marginalisation lossy' — it is not — the question is what degree the variable had."

The second leg: relinearisation

Sparsity is the leg everyone reaches. Relinearisation is the leg that separates a working understanding from a deep one.

Every one of these systems is nonlinear. A range measurement is a square root, a bearing is an arctangent, a rotation composition is a matrix product on a manifold. To use the linear machinery above, you evaluate the Jacobian J at some linearisation point — a specific value of the state.

A filter has one shot. It receives a measurement, linearises about its current estimate, folds the result into Λ (or equivalently into Σ), and throws the raw measurement away. If the estimate later improves by two metres, that Jacobian is still evaluated at the old, wrong point. It is welded in.

A smoother keeps the raw measurements. Every iteration it re-evaluates every residual and every Jacobian at the newest estimate and rebuilds Λ from scratch. When the estimate improves, the linearisation of the entire history improves with it.

Worked example 5: one bad linearisation, with numbers

A robot at unknown position x on a line measures its range to a landmark 3 m off to the side. The measurement model is a square root:

h(x) = √(x2 + 32),   J(x) = dh/dx = x / √(x2 + 9)

The true position is x = 4, so the noiseless range is √(16 + 9) = 5. Take z = 5.00 exactly, with σ = 0.2 m. Now suppose your prior estimate is bad: x̂ = 1.0.

Step 1 — evaluate at the prior. h(1) = √10 = 3.162278. Residual r = h − z = 3.162278 − 5 = −1.837722. Jacobian J = 1/3.162278 = 0.316228.

Step 2 — the filter's single update. The Gauss-Newton step is δ = −(JTJ)−1JTr = −r/J (the σ cancels for a scalar):

δ = 1.837722 / 0.316228 = 5.811388  →  x̂ = 1.0 + 5.811388 = 6.811388

It overshot the true value of 4 by 2.81 m — because it took a step sized by the shallow slope at x = 1, where the range barely changes with position, and applied it in a region where the slope is steep.

Step 3 — and here is the damage. The filter also reports its confidence, and it computes that from the same frozen Jacobian:

Λ = J22 = 0.3162282 / 0.04 = 0.1 / 0.04 = 2.5 →  σreported = 1/√2.5 = 0.632 m

It is 2.811 m wrong and claims 0.632 m of uncertainty. That is a 4.45σ error reported as if it were routine. A consistency monitor would flag it; the filter itself has no idea.

Step 4 — the smoother, same data, iterated. It does not throw the measurement away, so it just keeps going:

Iterh(x̂)r = h − zJ = x/hδ = −r/Jnew x̂
01.0000003.162278−1.8377220.316228+5.8113886.811388
16.8113887.442782+2.4427820.915167−2.6692214.142168
24.1421685.114446+0.1144460.809896−0.1413094.000859
34.0008595.000687+0.0006870.800062−0.0008594.000000

Three extra evaluations of the same measurement and it lands on 4.000000. And its reported uncertainty uses the Jacobian at the answer: Λ = 0.82/0.04 = 16, so σ = 0.25 m — and this time the estimate really is that good.

Two failures in one, and they compound. The filter's estimate is wrong and its covariance is wrong, and the covariance is wrong in the optimistic direction. A frozen Jacobian at a shallow point produces a small J, and a small J produces a small Λ. Being lost makes the filter more confident, not less.
Frozen Jacobian vs relinearisation

The curve is the true cost of the range measurement. The dashed parabola is the quadratic model built at the current linearisation point. Move the prior, then step the smoother and watch the parabola follow the estimate down — while the filter's single step stays where it landed.

Prior estimate x̂ 1.0

Drive the slider to both ends and read the numbers under the plot — they are the point of the widget, and they say something more precise than "linearisation error is bad".

Pull the prior down to 0.7. The x-axis stretches to keep the filter's marker on the chart — at the far low end it has to run out past 20, which is itself the finding. The single step lands at 9.147 — 5.147 m past the truth, on the far side of the minimum — and because the frozen Jacobian there is J = 0.7/√9.49 = 0.2272, the reported σ is 0.2/0.2272 = 0.880 m. That is a 5.8σ error announced as routine. Push it further, to 0.3, and the step lands at 20.25 with σ = 2.01 m: still 8.1σ. The step size blows up because δ = −r/J divides by a Jacobian that is going to zero.

Now push the prior up to 8. The step lands at 4.215 — still short of the truth, still on the same side it started from, it does not overshoot — with J = 8/√73 = 0.9363, so σ = 0.214 m against a 0.215 m error: 1.0σ, an honest report. At 9 it is 4.270 with σ = 0.211 m, 1.3σ. One relinearisation from either would finish the job.

The asymmetry is the lesson, and it is geometric. h(x) = √(x2+9) has slope x/h, which saturates to 1 for large x and collapses to 0 near x = 0. A frozen Jacobian is only dangerous where the measurement function is flat — where a large change in state produces almost no change in the measurement. Far out on the steep side the linearisation is nearly perfect and one step is nearly enough. So the failure is not "far from the optimum" in general; it is "far from the optimum in a direction the sensor barely sees". That is what a robot experiences after a fast turn (yaw barely observed by a forward camera), after a brief occlusion (depth barely observed by a short baseline), or when a landmark sits nearly along the line of motion. Those are precisely the moments an EKF welds in a bad Jacobian and then reports confidence computed from that same bad Jacobian.

Press Relinearise & step repeatedly from the 0.7 setting: the teal parabola re-forms at each new estimate, the model tracks the true cost instead of a memory of it, and the estimate walks down to 4.000000 in four or five steps. The filter's warm marker never moves again, because the filter no longer has the measurement — only its linearised shadow.

The consistency argument, and the number that proves it

The relinearisation problem in SLAM is not merely inaccurate; it is structurally wrong in a way that has been proved.

In 2-D SLAM with only relative measurements — odometry and landmark observations — three degrees of freedom are fundamentally unobservable: global x, global y, and global heading. Translate and rotate the entire map and the robot together and every single measurement is unchanged. No amount of data can pin them down. The correct posterior therefore has an information matrix with a 3-dimensional nullspace.

Huang, Mourikis and Roumeliotis (IJRR 2010) showed that when an EKF evaluates its Jacobians at different state estimates at different times — which it must, because the estimate changes — the linearised system's unobservable subspace has dimension 2, not 3. The missing direction is global heading.

The consequence, stated plainly: the filter believes it has measured something no sensor measured. It gains fictitious information about global yaw on every update, so its yaw covariance shrinks without bound while the true yaw error keeps growing. This is not a tuning problem. Inflating Q slows it down; it does not fix a rank deficiency.

Two families of fix exist, and naming them is a strong signal:

That last bullet is the punchline of the chapter. The smoother does not solve the consistency problem by being cleverer. It solves it by never creating the inconsistency in the first place.

Where the two live in a real stack

A shipping VIO system does not choose. It runs both, at different rates, for different reasons.

LayerWhat it isRateStateBudget
IMU propagationPure integration of the last optimised state. No optimisation at all — it is a filter's predict step with no update.200 Hz16 stored (p 3, v 3, q 4, ba 3, bg 3); 15 error-state DoF — attitude lives in the 3-dimensional tangent space, which is the dimension the covariance actually has.< 50 µs, hard. Runs in the IMU callback.
Window optimiserFixed-lag smoother, relinearises everything inside the window every iteration.4 Hz (per keyframe)≈ 307 (see Ch 0)250 ms period, 25–60 ms typical. Firm deadline.
Pose graphGlobal smoother over keyframe poses only. Landmarks already marginalised into relative pose constraints.On loop closure, ~0.1 Hz6 × number of keyframesNo deadline; separate thread; merged when done.

The 16-versus-15 in that first row is a shape question worth stating precisely. The nominal state you store is 16 doubles because a unit quaternion needs 4. The covariance is 15×15 and never 16×16: a unit quaternion is constrained to the 3-sphere, so a 4-dimensional Gaussian over it would be singular by construction — the direction along q itself carries zero variance, giving a rank-deficient, non-invertible covariance that will produce a NaN the first time anything inverts it. The fix is the error-state formulation: the estimate lives in the 16-number manifold, the uncertainty lives in the 15-dimensional tangent space, and they are joined by a retraction, q ← q ⊗ exp(½δθ), applied after every solve. Layer two has the same split: its "≈ 307" is tangent-space dimension too. If you say "15-dimensional state" nobody blinks; "16×16 covariance" is the phrase that reveals the misunderstanding.

Bandwidth on the wire. The 200 Hz odometry topic carries a 7-double pose (56 B) plus a 36-double covariance (288 B) plus a header (~40 B) ≈ 384 B per message → 77 kB/s. The keyframe stream to the pose-graph thread carries a pose plus a 32-byte-per-feature descriptor set for ~150 features ≈ 5 kB per keyframe → 20 kB/s. Neither is a problem; the 20 Hz image stream at 640×480×1 B = 307 kB per frame = 6.1 MB/s is, and it is why the frontend and backend usually live in the same process.

The design question hiding here: "why does the 200 Hz output come from integration rather than the optimiser?" Because the optimiser runs at 4 Hz and takes 40 ms, so its answer is up to 290 ms stale. At 1.5 m/s that is 43 cm of position error handed to a controller. Integration between solves is not a hack — it is the only way to get a low-latency pose out of a high-latency estimator.

Assemble Λ from factors, then marginalise

Twenty-eight lines, no library. This is what a whiteboard-to-keyboard translation looks like.

python
import numpy as np

def add_prior(Lam, eta, i, meas, sigma, x):
    w = 1.0 / sigma**2
    r = x[i] - meas                       # J = +1
    Lam[i, i] += w
    eta[i]    -= w * r                    # eta = -J^T w r

def add_between(Lam, eta, i, j, meas, sigma, x):
    """r = (x_j - x_i) - meas,  so J = [-1, +1] over (x_i, x_j)."""
    w = 1.0 / sigma**2
    r = (x[j] - x[i]) - meas
    Lam[i, i] += w;  Lam[j, j] += w       # (-1)(-1)w and (+1)(+1)w
    Lam[i, j] -= w;  Lam[j, i] -= w       # (-1)(+1)w — the coupling
    eta[i]    += w * r                    # -(-1) w r
    eta[j]    -= w * r                    # -(+1) w r

def marginalize(Lam, eta, drop):
    """Schur-complement the variables in `drop` out of the system."""
    keep = [i for i in range(len(eta)) if i not in drop]
    A = Lam[np.ix_(drop, drop)]           # Lam_aa
    B = Lam[np.ix_(keep, drop)]           # Lam_ba
    X = np.linalg.solve(A, Lam[np.ix_(drop, keep)])   # never form an inverse
    y = np.linalg.solve(A, eta[drop])
    return Lam[np.ix_(keep, keep)] - B @ X, eta[keep] - B @ y

x   = np.array([0.0, 1.0, 2.0])
Lam = np.zeros((3, 3)); eta = np.zeros(3)
add_prior(Lam, eta, 0, 0.0, 1.0, x)
add_between(Lam, eta, 0, 1, 1.0, 0.5, x)
add_between(Lam, eta, 1, 2, 1.0, 0.5, x)
print(Lam)              # [[ 5 -4  0] [-4  8 -4] [ 0 -4  4]]
print(marginalize(Lam, eta, [1])[0])   # [[ 3 -2] [-2  2]]  <- the zero is gone

The production form, for comparison. GTSAM hides the assembly entirely, but the object it builds is precisely the matrix above:

python
import gtsam
from gtsam import symbol_shorthand as S

graph = gtsam.NonlinearFactorGraph()
graph.add(gtsam.PriorFactorPose2(S.X(0), gtsam.Pose2(0, 0, 0),
          gtsam.noiseModel.Diagonal.Sigmas([1.0, 1.0, 0.1])))
graph.add(gtsam.BetweenFactorPose2(S.X(0), S.X(1), gtsam.Pose2(1, 0, 0),
          gtsam.noiseModel.Diagonal.Sigmas([0.5, 0.5, 0.05])))
# ... more factors ...
result = gtsam.LevenbergMarquardtOptimizer(graph, initial).optimize()
marg   = gtsam.Marginals(graph, result)
print(marg.marginalCovariance(S.X(1)))   # 3x3, recovered from the factor, not inverted
Why the last line matters. "marginalCovariance does not invert Λ. It recovers selected entries of Σ from the Cholesky factor by back-substitution — Kaess & Dellaert's covariance recovery, RSS 2009. Inverting a 30,000×30,000 information matrix to read one 6×6 block would be the single most expensive mistake in the codebase."

The filter that becomes certain of its own heading

Field report: an indoor AMR running EKF-SLAM in a repetitive aisle. Over twenty minutes the reported 1σ heading uncertainty falls to 0.2°. The true heading error, against a survey, is 4°. No divergence, no crash, smooth trajectory.

Candidate causeWhat it would look likeRuled in or out by…
Process noise Q too smalltrace(P) collapses and the collapse rate scales with how much you shrink QSweep Q by 10×. If the yaw covariance still collapses (just slower), Q is not the cause.
Data association errorsBursty innovations, occasional large NIS spikes, a visibly corrupted mapLook at the NIS distribution. Association faults move the tail; this fault moves the whole distribution.
Linearisation inconsistencyYaw specifically — not x, not y — becomes overconfident, and the effect is worse the more the robot turnsThe rank test below. Also: scatter yaw NEES against accumulated rotation; a clean positive slope is the fingerprint.

The metric that reveals it. Stack the Jacobians of every measurement over a window into one matrix M, and compute its singular values. For 2-D SLAM the correct answer is exactly three singular values at machine zero (the three unobservable gauge directions).

python
sv = np.linalg.svd(M, compute_uv=False)
tol  = sv[0] * max(M.shape) * np.finfo(float).eps
null = int((sv < tol).sum())
print(f"nullspace dim = {null}  (expect 3 for 2-D SLAM)")
# healthy smoother : 3   —  x, y, yaw gauge freedom
# EKF-SLAM         : 2   —  yaw has been silently "measured"
The transferable form: when an estimator is overconfident in one specific direction and that direction is physically unobservable, stop tuning noise and start counting the rank. A rank error and a tuning error look identical on a covariance plot and are completely different bugs.

What is changing at the frontier

Three live threads, all worth naming with a year:

The four-minute drill

Everything above is inert until your hand can produce it unaided. Run this drill against a timer until all six steps come out without recall pauses. The step budgets keep you honest.

tSay and writeThe tell that you have it
0:00Draw three poses and the prior. Write the odometry residual r = (xj − xi) − u and its Jacobian [−1, +1] before touching the matrix.You wrote the residual first. Writing the matrix first means you are recalling it, not deriving it.
0:40Scatter: JTwJ = w·[[1,−1],[−1,1]] into rows/cols {i, j}. Fill the 3×3 to [[5,−4,0],[−4,8,−4],[0,−4,4]].You say "5 because the prior's 1 lands on top of the odometry's 4" without pausing.
1:20Point at the (0,2) zero and say the sparsity theorem out loud: nonzero iff two variables share a factor.You point at a specific entry, not at the matrix.
2:00Marginalise x1: Λ' = Λbb − ΛbaΛaa−1Λab = [[5,0],[0,4]] − (1/8)[[16,16],[16,16]] = [[3,−2],[−2,2]].The 16s come from (−4)(−4) instantly, and you name the 1/8 as Λaa−1.
2:50Circle the new −2. "Fill-in. Degree 2 → d(d−1)/2 = 1 new edge. Degree 4 → 6, and nnz goes 13 → 16 while the matrix shrinks."You give a second value of d unprompted. That is what turns a fact into a rule.
3:30"And the second cost is relinearisation — the absorbed factor is frozen at an old point. Here is a range measurement where that gives a 5.8σ error reported as 0.88 m."You reached the second leg unprompted. Most explanations stop at sparsity.
Three questions to ask yourself before you look away from the board. (1) Can you state, in one sentence and without the matrix, why Λ is sparse? (2) Can you compute the fill-in count for a landmark seen by 10 poses in under three seconds — and is the 45 you just said edges or entries? (3) Can you name the direction the EKF becomes falsely confident in, and the exact number a rank test should return? If any answer needs a rehearsal, that is the section to re-read, not the whole chapter.
Marginalisation is exact — so what exactly does the EKF lose relative to a smoother?
The full answer, one level deeper. The distractors are each half-true and that is the point: the Schur complement really does integrate the variable out, so "it loses information" sounds right — but the integral is exact, and the reduced system reproduces the kept variables' estimates to machine precision. The correct answer's decisive follow-on is the one sentence trimmed out of the option so you have to supply it yourself: "for a linear system with fixed Jacobians, the filter and the smoother are numerically identical — so neither of the two costs is information loss; one is a change of sparsity pattern and the other is a change of linearisation point." Say that and the question is closed. And option 2 is worth a word: the Schur complement is a subtraction of similar quantities and can lose digits on badly conditioned problems, which is exactly why square-root form (FRONTIER, above) exists — but it is a second-order concern, not the answer to "what does the EKF lose".

Chapter 2: Graphs, Bundles & Sparsity

A robot drives in a square, comes back to where it started, and the loop does not close by twenty centimetres. Draw the graph and predict — before you solve anything — where that twenty centimetres ends up.

The answer is predictable from the structure alone, and being able to predict it is the proof that a pose graph is not a black box to you.

The pose graph, stated once

A pose graph is a factor graph in which the only variables are robot poses and the only factors are relative pose measurements. Landmarks do not appear — they have already been converted into pose-to-pose constraints by the frontend (scan matching, PnP, ICP; see SLAM Frontend).

Two kinds of edge, and they differ only in where they came from:

The cost is the sum over edges of the squared relative-pose error:

C(x) = ½ ∑(i,j)∈E ‖ log( Zij−1 Xi−1 Xj ) ‖2Σij

Where Xi is pose i as an element of SE(2) or SE(3), Zij is the measured relative transform, and log(·) maps the residual transform into the tangent space so it becomes a plain vector you can square. If that line is unfamiliar, the derivation lives in Coordinate Frames & Rigid-Body Math — here we care about the structure, which is identical in one dimension.

Worked example: a four-pose loop, one exact Gauss-Newton step

Strip everything to 1-D so the arithmetic is whiteboard-sized and the structure is untouched. Four poses in a cycle:

Step 1 — build Λ. Each edge (i, j) adds w to Λii and Λjj and subtracts w from Λij and Λji. Four edges plus the prior:

Λ = [ [3, −1, 0, −1], [−1, 2, −1, 0], [0, −1, 2, −1], [−1, 0, −1, 2] ]

Notice what that is: the graph Laplacian of a 4-cycle, plus 1 in the top-left corner from the prior. The diagonal is each node's degree; the off-diagonals are −1 where an edge exists. The two structural zeros, (0,2) and (1,3), are the two pairs of poses that are diagonally opposite in the loop and therefore never measured against each other.

Step 2 — initialise from odometry. x = (0, 1, 2, 3). Residuals: the three odometry edges are exactly satisfied (r = 0). The loop-closure residual is

r30 = (x0 − x3) − z30 = (0 − 3) − (−2.8) = −3 + 2.8 = −0.2

Step 3 — build η = −JTWr. Only the loop edge has a nonzero residual. Its Jacobian with respect to (x0, x3) is [+1, −1] (because the residual is written x0 − x3 − z), so:

g0 = (+1)(1)(−0.2) = −0.2,   g3 = (−1)(1)(−0.2) = +0.2,   η = −g = (0.2, 0, 0, −0.2)

Step 4 — solve Λδ = η by hand. Write the four equations with δ = (a, b, c, d):

3a − b − d = 0.2  |   −a + 2b − c = 0  |   −b + 2c − d = 0  |   −a − c + 2d = −0.2

From the second, c = 2b − a. Substitute into the fourth: −a − (2b − a) + 2d = −0.2, so 2d = 2b − 0.2 and d = b − 0.1. Substitute both into the third:

−b + 2(2b − a) − (b − 0.1) = 0  ⇒  2b − 2a + 0.1 = 0  ⇒  b = a − 0.05

Finally the first: 3a − (a − 0.05) − (a − 0.15) = a + 0.2 = 0.2, so a = 0. Back-substituting: b = −0.05, c = 2(−0.05) − 0 = −0.10, d = −0.05 − 0.10 = −0.15.

δ = (0, −0.05, −0.10, −0.15)  →  x* = (0, 0.95, 1.90, 2.85)

Step 5 — check the residuals afterwards. This is the payoff. r01 = 0.95 − 0 − 1 = −0.05. r12 = 1.90 − 0.95 − 1 = −0.05. r23 = 2.85 − 1.90 − 1 = −0.05. r30 = 0 − 2.85 + 2.8 = −0.05.

The 0.2 m of loop error came back as exactly 0.05 m on every one of the four edges. The optimiser did not "put the error where the loop closure is". It spread it, evenly, because all four edges had equal weight. And the total cost fell from ½(0.2)2 = 0.02 to 4 × ½(0.05)2 = 0.005 — exactly a factor of four, which is the number of edges in the cycle.

That is the prediction you make before solving, and it generalises: in a cycle of n edges with equal weight, an error e is distributed as e/n per edge and the cost drops from ½e2 to ½e2/n.

The weighted version, which is what actually happens

Real edges do not have equal weight, and the general law is worth memorising because it makes you sound like you have watched a solver run.

For a cycle where edge k has weight wk = 1/σk2, the residual e is shared inversely to the weight — each edge absorbs the share

rk = e · (1/wk) / ∑m (1/wm) = e · σk2 / ∑m σm2

Do it with numbers. Five odometry edges with σ = 0.10 m (w = 100) and one loop closure with σ = 0.02 m (w = 2500). The reciprocal weights sum to 5×0.01 + 1×0.0004 = 0.0504. For a loop error of e = 0.13 m:

The tight loop-closure edge absorbs almost nothing, because it is the most trusted measurement in the graph. Those are exactly the numbers the Code Lab below will produce, so you can check the solver against your own arithmetic.

The one-sentence version of this insight: "A loop closure does not 'snap' the trajectory to the closure. It re-weights the entire loop. Which is why a single bad loop closure with an optimistic covariance destroys the whole map rather than just one pose — the error you injected gets distributed lovingly over every edge in the cycle."

Bundle adjustment: the same idea, a different shape

Bundle adjustment (BA) keeps the landmarks as variables instead of collapsing them into pose-to-pose constraints. Two variable types: C camera poses (6 numbers each) and P 3-D points (3 numbers each). One factor type: a reprojection residual connecting one camera to one point.

Because no factor ever touches two cameras, and no factor ever touches two points, the information matrix has a very particular shape:

Λ = [ [U, W], [WT, V] ]

Because P is usually a hundred times larger than C, this matrix is drawn as an arrowhead: a thin dense spine along the top and left, an enormous block-diagonal body, and nothing else.

The Schur trick, with the flops written out

You never solve the full system. You eliminate all the points at once — which is legal and cheap precisely because V is block-diagonal, so V−1 is P independent 3×3 inversions. The Schur complement leaves the reduced camera system:

S = U − W V−1 WT,   S · δcam = ηcam − W V−1 ηpt
then back-substitute:   δpt = V−1 ( ηpt − WT δcam )

Now the arithmetic that makes it worth doing. Take C = 100 cameras, P = 20,000 points, each point seen by k = 4 cameras.

RouteWhat you factorFlopsAt 10 GFLOP/s
Naive — dense Cholesky on the whole thingn = 6(100) + 3(20,000) = 60,600n3/3 = 7.4 × 10132.1 hours
Schur, step 1: invert V20,000 independent 3×3 inverses, ~30 flops each6 × 1050.06 ms
Schur, step 2: form Sper point, k2 = 16 blocks of (6×3)(3×3)(3×6)5.2 × 1075 ms
Schur, step 3: factor Sdense 600×600(600)3/3 = 7.2 × 1077 ms
Schur total1.2 × 10812 ms
Six hundred thousand times faster, for the same answer. Not an approximation, not a heuristic — a reordering of the elimination that exploits a structural zero pattern. This is the number to have in your pocket when someone asks why bundle adjustment is tractable at all.

And note the fill-in is still there, obediently: S is 600×600 and dense, because eliminating a point makes all k cameras that saw it a clique. The trick is not that fill-in was avoided — it is that the fill-in landed in the small block. Choosing which block gets the fill-in is the entire art of elimination ordering, and Chapter 6 lets you feel it.

The arrowhead, and where the fill-in lands

Left: the bundle-adjustment information matrix. Right: what is left after eliminating the points. Move the sliders and read the two flop counts — the crossover where Schur becomes worth the code is much lower than people expect.

Cameras C 100
Points P 20000
Cameras per point k 4

Drag P down to a few hundred and watch the advantage shrink — with few points and many cameras the reduced system is no smaller than the original and the Schur bookkeeping is pure overhead. That crossover is why Ceres exposes DENSE_SCHUR, SPARSE_SCHUR and SPARSE_NORMAL_CHOLESKY instead of picking one for you.

The two backends in a shipping visual SLAM stack

ORB-SLAM-family systems run both structures, and knowing why is a design lesson in itself.

ComponentStructureSizeRateWall timeWhy this one
Local BABundle adjustment over the covisible keyframes~10 KF × ~300 points → 60 + 900 = 960 unknownsEvery new keyframe, ~4 Hz8–15 ms, DENSE_SCHURPoints matter here — you are refining structure, not just trajectory.
Pose-graph optimisationPose graph over ALL keyframes; points frozen5,000 KF × 6 = 30,000 unknowns, ~5,800 edgesOn loop closure, ~0.1 Hz40–150 ms sparse CholeskyCorrecting global drift does not need structure — and dropping the points removes 99% of the variables.
Global BAFull BA over everything5,000 KF × 100,000 points = 330,000 unknownsAfter a large loop, once20–60 s, ITERATIVE_SCHUR + Schur-Jacobi preconditionerOnly worth it once the pose graph has already removed the gross error.
The sequencing question: "why run pose-graph optimisation before global BA and not just do the BA?" Because BA is a local method with a cost surface full of local minima. Handed a trajectory that is two metres out of alignment, it will happily converge to a beautifully consistent but wrong reconstruction. The pose graph — cheap, and with a far better-conditioned cost surface — gets you into the right basin first.

Memory, in bytes. The local BA problem holds a 960×960 information matrix, but only the 60×60 reduced system is ever dense: 3,600 doubles = 29 kB. The pose graph holds nnz(Λ) = 36 × [5,000 + 2(4,999) + 2(800)] = 597,528 nonzeros; in compressed-sparse-column form with 8-byte values and 4-byte indices that is about 7.2 MB. Its Cholesky factor with a good ordering runs 3–8× that. Budget 50 MB and you will not be surprised.

From scratch, then Ceres

The from-scratch Schur complement, exploiting the block-diagonal V:

python
import numpy as np

def schur_solve(U, W, V_blocks, eta_c, eta_p, obs):
    """U: (6C,6C) block-diagonal.  W: dict (c,p) -> 6x3.  V_blocks: list of 3x3.
    obs: list of (camera, point) pairs.  Returns (dcam, dpt)."""
    S  = U.copy()
    rc = eta_c.copy()
    Vi = [np.linalg.inv(Vb) for Vb in V_blocks]      # P independent 3x3 inverses
    # by point, because a point's cameras form the clique
    for p, Vip in enumerate(Vi):
        cams = [c for (c, q) in obs if q == p]
        for ci in cams:
            Wi = W[(ci, p)]                          # 6x3
            rc[6*ci:6*ci+6] -= Wi @ Vip @ eta_p[3*p:3*p+3]
            for cj in cams:                          # k^2 blocks: the fill-in
                S[6*ci:6*ci+6, 6*cj:6*cj+6] -= Wi @ Vip @ W[(cj, p)].T
    dcam = np.linalg.solve(S, rc)                    # 6C x 6C — small and dense
    dpt  = np.zeros_like(eta_p)
    for p, Vip in enumerate(Vi):                     # back-substitute, embarrassingly parallel
        acc = eta_p[3*p:3*p+3].copy()
        for (c, q) in obs:
            if q == p: acc -= W[(c, p)].T @ dcam[6*c:6*c+6]
        dpt[3*p:3*p+3] = Vip @ acc
    return dcam, dpt

And the production form, where the same decision is one enum:

cpp
ceres::Solver::Options opts;
opts.linear_solver_type = ceres::DENSE_SCHUR;      // <~100 cameras
// opts.linear_solver_type = ceres::SPARSE_SCHUR;  // 100..1000 cameras
// opts.linear_solver_type = ceres::ITERATIVE_SCHUR; // 1000+, with:
// opts.preconditioner_type = ceres::SCHUR_JACOBI;
opts.trust_region_strategy_type = ceres::LEVENBERG_MARQUARDT;
opts.num_threads = 8;
// This is the line that tells Ceres the arrowhead exists.
// Without it, Ceres does not know your points are points.
ceres::ParameterBlockOrdering* ord = new ceres::ParameterBlockOrdering;
for (auto& pt  : points)  ord->AddElementToGroup(pt.data(),  0);   // eliminate FIRST
for (auto& cam : cameras) ord->AddElementToGroup(cam.data(), 1);   // eliminate LAST
opts.linear_solver_ordering.reset(ord);
Say this while you write the ordering block: "Group 0 is eliminated first. Putting points in group 0 is the Schur complement — I am telling the solver to make the cameras a clique instead of the points. If I swapped the groups, I would be forming a 60,000×60,000 reduced system instead of a 600×600 one."

"Cholesky failed: matrix is not positive definite"

The single most common backend crash, and almost never a numerical accident.

A pose graph with only relative measurements is gauge-free: translate and rotate the whole solution and every residual is unchanged. That means Λ is singular by construction, with a nullspace of a very specific dimension:

ProblemNullspace dimensionWhat is free
2-D pose graph3global x, y, yaw
3-D pose graph6global translation (3) + rotation (3)
Stereo / RGB-D bundle adjustment6as above; scale is fixed by the baseline
Monocular bundle adjustment7as above plus scale — the classic extra one
Monocular visual-inertial4global position (3) + yaw. Roll and pitch are fixed by gravity; scale by the accelerometer.
SymptomRoot causeThe metric that reveals it
Cholesky throws immediately, on the first iterationNo gauge fix at all — you forgot the prior on the first poseSmallest eigenvalues of Λ: count how many sit at machine zero. If it is exactly 3, 6 or 7, it is gauge, not a bug.
Cholesky succeeds but LM's λ climbs monotonically and the solution wandersA weak gauge fix — a prior with σ = 1000 m technically makes Λ invertible but leaves it catastrophically ill-conditionedCondition number of Λ. Above ~1012 in double precision you have lost most of your digits before the solve begins.
Crash appears only after a specific keyframeA disconnected component — tracking was lost, and a group of poses has no path to the anchored poseRun a connected-components pass on the graph. More than one component means more than one gauge freedom, and the count of components tells you exactly how many extra priors you need.
Monocular BA "converges" with the entire map shrinking each iterationScale gauge unfixed — the cost is genuinely invariant to a global scalingTrack the median point depth across iterations. A steady geometric decay is a scale drift, not convergence.
The transferable rule: a singular information matrix in SLAM is almost always a gauge, and a gauge is a physical statement, not a bug. Count the zero eigenvalues before you touch the solver. If the count matches the table above, add the prior. If it does not, you have a genuinely disconnected or degenerate problem, and no prior will save it.

The Schur complement is under attack

Your bundle adjustment runs fine on 50 cameras and hangs on 500. What changed, and what do you do about it?

Chapter 3: Marginalisation & Sliding Windows

So keep everything. Great. Except this drone flies for thirty minutes at four keyframes a second, which is 7,200 keyframes and about a million landmarks, on a processor with two gigabytes of RAM and a 250 millisecond budget. Now what?

The honest answer is that you do not keep everything. Every shipping visual-inertial system bounds its state, and the real question is how you bound it and what that costs you. Get this chapter right and you can answer any question about VINS-Mono, OKVIS, MSCKF or Kimera without having read their source.

Three ways to bound the state, and only three

StrategyWhat happens to the old variableWhat you loseWho does it
KeepStays in the graph foreverNothing — but compute and memory grow without boundiSAM2, offline BA
DropVariable and its factors are deletedInformation. The constraints that touched it are gone. Genuinely lossy.ORB-SLAM's local BA window (safe because the global map still holds it)
MarginaliseSchur-complemented into a dense prior over its neighboursSparsity, and the ability to relinearise that information ever againVINS-Mono, OKVIS, Kimera, every fixed-lag smoother. MSCKF marginalises features too, but by nullspace projection rather than by Schur complement — its own section below.
The distinction most people blur: dropping and marginalising are not the same operation with different costs. Dropping throws away information and keeps the matrix sparse. Marginalising keeps every bit of the information and pays for it in density. Say which one you mean, every time.

Worked example: marginalisation really is lossless

Take the chain from Chapter 1 with actual measured values so η is nonzero. Prior says x0 = 0.1 with σ = 1 (w = 1). Odometry x0→x1 = 1.0 and x1→x2 = 1.0, both with σ = 0.5 (w = 4). These are mutually consistent, so you can read the answer straight off the measurements — x0 = 0.1 from the prior, then +1.0 and +1.0 — giving μ = (0.1, 1.1, 2.1), and the η computation below doubles as a check that Λμ really does reproduce it. That makes it a perfect test case, because you know the truth in advance.

Step 1 — the information form. Λ is the same as Chapter 1, and η = Λμ. Do the matrix-vector product one row at a time:

RowArithmeticη
05(0.1) + (−4)(1.1) + 0(2.1) = 0.5 − 4.4−3.9
1(−4)(0.1) + 8(1.1) + (−4)(2.1) = −0.4 + 8.8 − 8.40.0
20(0.1) + (−4)(1.1) + 4(2.1) = −4.4 + 8.44.0

Step 2 — marginalise x0 out of the window. From Chapter 1, Λ' = [[4.8, −4], [−4, 4]]. For the information vector:

η' = ηb − ΛbaΛaa−1ηa = (0, 4) − [−4; 0] · (1/5) · (−3.9) = (0, 4) − (3.12, 0) = (−3.12, 4.0)

Step 3 — solve the reduced system. Two equations: 4.8x1 − 4x2 = −3.12 and −4x1 + 4x2 = 4. Add them: 0.8x1 = 0.88, so x1 = 1.1, and then x2 = x1 + 1 = 2.1.

Identical to the full solve, to the last digit. Marginalisation is not an approximation. This is worth saying explicitly, because most people believe fixed-lag smoothers are "approximate smoothers". They are not — for the linearised system they are exact. The approximation enters somewhere else entirely, and that is the next section.

What it actually costs, part one: fill-in

Chapter 1 established the rule: eliminating a variable of degree d makes its d neighbours a clique. In a sliding window this compounds every single keyframe.

Consider a window of 10 keyframes where each keyframe observes 150 landmarks and consecutive keyframes share about 60% of them. When the oldest keyframe slides out:

In VINS-Mono the resulting marginalisation prior is a dense matrix over the remaining window states. "State" here means one scalar degree of freedom, and a keyframe block is 15 of them (p, v, q, ba, bg = 3 + 3 + 3 + 3 + 3). So the prior spans 30 states when it touches two surviving keyframes and 60 when it touches four. Do the bytes at both ends, in doubles:

Prior spansDimension DDoublesArithmeticBytes
2 keyframes × 153030 × 30 = 900900 × 8 B = 7,200 B, and 7,200 / 1,0247.03 kB
4 keyframes × 156060 × 60 = 3,6003,600 × 8 B = 28,800 B, and 28,800 / 1,02428.1 kB

So 7–29 kB, and doubling D quadrupled it — the prior is a dense square, so its footprint is O(D2). That sounds trivial, and at this size it is: fill-in is not the expensive part in a fixed-lag window, because the window is small by construction. Extrapolate the same square to a 7,200-keyframe trajectory, though, and D = 108,000 gives 108,0002 × 8 B = 93 GB. That is the EKF-SLAM disaster in one multiplication, and it is why the bound on the window is the only thing making this legal.

The trap in this material. It is easy to memorise "marginalisation causes fill-in, fill-in is bad" and then be unable to explain why VINS-Mono marginalises anyway. The correct framing: in a bounded window, fill-in is bounded too, so it is cheap. Fill-in is catastrophic in a growing graph — which is exactly the EKF-SLAM case, where you marginalise forever and the clique grows with the map.

What it actually costs, part two: the frozen linearisation

This is the expensive part, and it is the part that gets asked about.

When you marginalise, you Schur-complement the linearised system. The Jacobians that went into Λaa, Λab and Λbb were all evaluated at one specific estimate — the one you held at the moment of marginalisation. The resulting prior is a quadratic in the kept variables, and that quadratic is welded to that linearisation point forever. The raw measurements are gone; you cannot rebuild it.

So on the next iteration, when the kept states move, you face a choice:

ChoiceWhat it doesConsequence
Leave the prior's Jacobian at the old point (FEJ)The prior residual is re-evaluated at the new estimate, but its Jacobian stays frozen at the marginalisation-time valueSlightly worse linearisation. Correct nullspace dimension. Consistent.
Relinearise the prior at the current estimateRecompute the Jacobian each iteration — "more accurate", surely?The prior's Jacobians now use a different linearisation point from the one embedded in the marginalised information. The unobservable subspace collapses. The estimator manufactures information about global position and yaw.
This is the single most counter-intuitive fact in sliding-window SLAM. Relinearising the marginalisation prior makes the estimator more accurate per-iteration and less correct overall. It is a bug that looks like an optimisation. The one-sentence explanation is worth memorising: "because the marginalised information was computed with one set of Jacobians, and mixing it with a different set breaks the rank of the unobservable subspace."

How big is the effect? Monocular visual-inertial odometry has exactly 4 unobservable directions: global x, y, z and yaw about gravity. (Roll and pitch are observable because gravity gives you an absolute reference; scale is observable because the accelerometer is metric.) Without FEJ, the linearised system's unobservable subspace collapses to 3, and the missing direction is yaw — the result is due to Huang, Mourikis & Roumeliotis, "Observability-based rules for designing consistent EKF SLAM estimators", IJRR 29(5), 2010, and its visual-inertial form to Li & Mourikis, "High-precision, consistent EKF-based visual-inertial odometry", IJRR 32(6), 2013. That is the sentence everyone repeats. The rest of this section derives it, because a sentence you cannot derive is a sentence you will fumble under follow-up.

The four nullvectors, written out

An unobservable direction is a perturbation of the whole state vector that leaves every residual unchanged. Write ĝ for the unit gravity direction in the global frame, and let the state hold keyframes (pi, vi, θi, ba, bg) and features pf. The four are:

Directionδpiδviδθiδbδpf
nx — slide the world in x(1,0,0)000(1,0,0)
ny — slide the world in y(0,1,0)000(0,1,0)
nz — slide the world in z(0,0,1)000(0,0,1)
nψ — spin the world about gravity−(pi × ĝ)−(vi × ĝ)0−(pf × ĝ)

Read the fourth row carefully, because it is the one that causes all the trouble. The three translation vectors are constant — they do not depend on the estimate at all. The yaw vector's entries are built out of pi, vi and pf: it moves when the estimate moves. Two blocks of your Jacobian linearised at two different estimates are therefore being asked to annihilate two different vectors, and no single vector satisfies both. That is the entire mechanism, and everything below is that sentence with numbers attached.

The biases are zero in every row for a reason worth saying out loud: biases live in the body frame, and rotating or sliding the whole world does not change what an accelerometer strapped to the robot reads. Roll and pitch are absent because rotating the world about any non-gravity axis does change the measured gravity vector, which is exactly why they are observable.

Worked example: watch the yaw direction disappear, entry by entry

Full 3-D VIO has 15 states per keyframe, so let us do the planar analogue — same mechanism, small enough to write on a whiteboard. Planar world, states (px, py, θ) per keyframe, (fx, fy) for one landmark. Scale is metric because the IMU is, so the unobservable set is global x, global y and global yaw: 3 directions, the planar shadow of the 4.

The window. Two keyframes and one landmark, so 3 + 3 + 2 = 8 states, in the column order

x = ( p1x, p1y, θ1,   p2x, p2y, θ2,   fx, fy )

The linearisation point xlin. p1 = (0, 0), θ1 = 0; p2 = (2, 0), θ2 = 0; f = (2, 1). Keyframe 2 is two metres ahead of keyframe 1; the landmark is level with keyframe 2 and one metre to its left.

The three factor blocks. Keyframe 0 has already been marginalised, so:

The derivatives, once. With R(θ) = [[c, −s], [s, c]] and u = f − pi:

∂r/∂pi = −R(θi)T    ∂r/∂f = R(θi)T    ∂r/∂θi = (d/dθ)[R(θ)T] u = R(θi)T (uy, −ux)

At θ1 = θ2 = 0 every RT is the identity, so the yaw column is simply (uy, −ux). For block P, u = f − p1 = (2, 1), giving (1, −2). For block V, u = f − p2 = (0, 1), giving (1, 0). For block O the same identity applies with w = p2 − p1 = (2, 0), giving (0, −2). Assemble:

p1xp1yθ1p2xp2yθ2fxfy
P row 1−10100010
P row 20−1−200001
V row 3000−10110
V row 40000−1001
O row 5−10010000
O row 60−1−201000
O row 700−100100

The three nullvectors, at xlin. Sliding the world: nx = (1,0,0, 1,0,0, 1,0) and ny = (0,1,0, 0,1,0, 0,1) — every position moves, no angle does. Spinning the world about the origin by ε sends a point p to p + ε(−py, px) and every heading to θ + ε, so

nψ = ( −p1y, p1x, 1,   −p2y, p2x, 1,   −fy, fx ) = ( 0, 0, 1,   0, 2, 1,   −1, 2 )

Now multiply, one row at a time. This is the arithmetic that makes the claim yours:

RowJ·nψ, every nonzero termResult
P1(−1)(0) + (1)(1) + (1)(−1) = 0 + 1 − 10
P2(−1)(0) + (−2)(1) + (1)(2) = 0 − 2 + 20
V3(−1)(0) + (1)(1) + (1)(−1) = 0 + 1 − 10
V4(−1)(2) + (1)(2) = −2 + 20
O5(−1)(0) + (1)(0)0
O6(−1)(0) + (−2)(1) + (1)(2) = −2 + 20
O7(−1)(1) + (1)(1)0

Seven zeros. Repeat for nx and ny and you get seven zeros each (row P1: −1 + 1 = 0; row V3: −1 + 1 = 0; row O5: −1 + 1 = 0; the rest touch no x column). So rank J = 8 − 3 = 5, nullity 3. The singular values are 3.570, 2.000, 1.732, 1.732, 1.120, 0, 0 — and the seventh is absent because J has only 7 rows. Three exact zeros in an 8-column problem, which is the correct answer for planar visual-inertial.

The same window, one linearisation point out of step

Now run one LM iteration. The landmark is the least-constrained variable in the window, so it moves the most: re-triangulation shifts it 5 cm in y, from f = (2, 1.00) to f = (2, 1.05). The keyframes barely move — the odometry factor pins them — so take them as unchanged, which isolates the effect in a single number.

Relinearise the live blocks at xnow. Only one entry changes: block V's yaw column is (uy, −ux) with u = f − p2 = (0, 1.05), so J[V3, θ2] goes from 1.00 to 1.05. Block P cannot follow — its raw measurement was destroyed at marginalisation time — so it stays at 1.00. Block O does not depend on f at all, so it is untouched.

Multiply the same nψ through again. Six rows are unchanged and still give zero. Row V3 is now:

(−1)(0) + (1.05)(1) + (1)(−1) = 1.05 − 1 = +0.05

And if you instead try the new yaw nullvector nψ(xnow) = (0,0,1, 0,2,1, −1.05, 2), block V is happy but the frozen prior is not: row P1 gives (1)(1) + (1)(−1.05) = −0.05. Neither vector is annihilated by every block, and no combination is either, because the two blocks disagree by a fixed offset.

The number on the page. Rank goes 5 → 6. Nullity goes 3 → 2. The singular values become 3.570, 2.025, 1.732, 1.732, 1.120, 0.0107, 0 — the yaw direction did not vanish, it acquired a small but strictly positive singular value. That is far worse than vanishing. A zero singular value announces itself: Cholesky fails, the solver complains, someone investigates. A singular value of 0.0107 solves cleanly, converges, and quietly hands you a covariance that says you know your heading.

How fast does the fiction accumulate? The information gained in the yaw direction from one such observation is ‖W1/2Jnψ2 / ‖nψ2. Here ‖nψ2 = 1 + 4 + 1 + 1 + 4 = 11, and with a landmark-position sigma of 2 cm the weight is w = 1/0.022 = 2500, so:

ΔIyaw = 2500 × (0.05)2 / 11 = 2500 × 0.0025 / 11 = 0.568 rad−2 per observation

Information adds. One keyframe of the sliding-window design below carries ~900 reprojection factors, so after a single keyframe Iyaw = 0.568 × 900 = 511, giving σyaw = 1/√511 = 0.044 rad = 2.53°. After 100 keyframes — twenty-five seconds of flight — Iyaw = 0.568 × 90,000 = 51,120 and σyaw = 0.253°. It keeps falling as 1/√N forever, because nothing in the estimator knows the information is invented. Meanwhile the true yaw error is doing a random walk upward. That crossing — reported sigma falling as 1/√N while true error grows as √N — is the signature, and now you can quote both curves.

What FEJ actually does, stated precisely. The loose version is "freeze the prior's Jacobian". The precise version, and the one Huang et al. prove: evaluate the Jacobian of every factor using the first estimate of each state the prior touches, while evaluating every residual at the current estimate. In our example that means block V computes its yaw column from flin = (2, 1.00) even though its residual uses fnow = (2, 1.05). The nullvector is then the same for all blocks and the nullity is back to 3. Li & Mourikis's OC-VINS reaches the same place differently — it projects the Jacobians onto the correct nullspace after computing them at the current estimate, which keeps the linearisation fresher at the cost of an explicit projection every update.
The cost of FEJ, quantified from the same example. The frozen Jacobian entry is 1.00 where the true one is 1.05: a 5% error in one column of one block. That is the whole price. You are trading a 5% linearisation error for the difference between a nullity of 3 and a nullity of 2. Say the trade in one breath — "I take a few percent of Jacobian staleness to avoid manufacturing half a rad−2 of yaw information per observation" — and you have shown you know it is a trade rather than a free win.

Keyframes: how you decide what is worth keeping

A sliding window over every frame would be useless — twenty frames at 20 Hz is one second of motion, during which a slow-moving robot barely translates, and a window with no baseline cannot triangulate anything. So the window slides over keyframes, and the keyframe policy is a real design decision with real numbers.

CriterionTypical thresholdWhat it protects against
Median parallax of tracked features since the last keyframe> 10 px (VINS-Mono) at 640×480A window with no baseline. Ten pixels at f = 460 px is 1.245° of apparent motion, which buys 5% relative depth accuracy — both numbers derived immediately below.
Tracked-feature count< 20 features (VINS-Mono) or < 70% of the reference keyframe (ORB-SLAM)Losing tracking entirely. If you wait for parallax while features vanish, you get a keyframe with nothing in common with the last one.
Time since last keyframe> 0.5 s hard capThe IMU factor. Preintegration error grows with the interval, so a keyframe every 20 s makes the inertial constraint worthless.
Rotation-only motionReject as keyframe if translation < ~1–2 cmPure rotation gives parallax with no baseline — the features move but no depth information is created. A classic degeneracy.

Where the 10-pixel threshold comes from, in two steps

Nobody picked 10 px because it sounded round. It falls out of a depth-uncertainty budget, and being able to reproduce that derivation at the whiteboard is worth more than remembering the number.

Step 1 — pixels to degrees. A displacement of δ pixels at focal length f subtends an angle arctan(δ/f). With the VINS-Mono threshold δ = 10 px and a typical 640×480 fisheye-rectified f = 460 px:

arctan(10 / 460) = arctan(0.021739) = 0.021736 rad = 0.021736 × (180/π) = 1.245°

The arctan barely matters at this size — the small-angle correction is δ3/3 = 3.4×10−6 rad, about 0.016% — so "pixels divided by focal length, in radians" is the version to do in your head.

Step 2 — degrees to depth uncertainty. Triangulation from a baseline b gives Z = f b / d, where d is the disparity in pixels. Differentiate: dZ/dd = −f b/d2 = −Z2/(f b), so

σZ = Z2σd / (f b)   ⇒   σZ/Z = Z σd / (f b)

Take the flight case: b = 0.2 m of travel between keyframes, Z = 10 m, f = 460 px, and a feature-tracker standard deviation σd = 0.5 px. Then

σZ/Z = (10 × 0.5) / (460 × 0.2) = 5 / 92 = 0.05435  ⇒  5.4%,   and σZ = 0.05435 × 10 m = 0.54 m at 10 m

Now the tidy part. Since d = f b/Z, that whole expression collapses to σZ/Z = σd/d. The relative depth error is just the tracker noise divided by the parallax, in pixels, and depth cancels out entirely. Check it: d = 460 × 0.2 / 10 = 9.2 px, and 0.5 / 9.2 = 5.4%. Same answer. So VINS-Mono's 10 px threshold is exactly the statement "do not create a keyframe until the geometry supports roughly 5% relative depth" — 0.5/10 = 5.0%.

What that costs at range. Because the pixel threshold is depth-independent, holding 5.4% at Z = 30 m still needs d = 9.2 px, and d = f b/Z gives b = 9.2 × 30 / 460 = 0.6 m of baseline — three times as far travelled, so at fixed speed three times as long between keyframes. At Z = 30 m with the original b = 0.2 m you only earn d = 460 × 0.2 / 30 = 3.07 px, and 0.5/3.07 = 16.3% relative depth: 4.9 m of uncertainty on a 30 m point. That is the honest answer to "why does my VIO get worse outdoors" — the scene got further away, the parallax per metre travelled fell as 1/Z, and the same keyframe policy now emits keyframes carrying a third of the depth information they used to.

Zbd = f b/ZσZ/Z = σd/dσZ
10 m0.2 m9.2 px5.4%0.54 m
30 m0.2 m3.07 px16.3%4.89 m
30 m0.6 m9.2 px5.4%1.63 m
The follow-up that catches people: "why not just use time?" Because the useful quantity is information gained, not time elapsed. A robot standing still for ten seconds should produce zero keyframes; a robot doing a fast lateral move should produce several per second. Every criterion above is a proxy for "has the geometry changed enough to be worth a new variable?"
The window slides — and what happens behind it

Step the window forward and watch the oldest keyframe leave. Switch between marginalise (information kept, prior densifies) and drop (information lost, the oldest kept state pinned where it happened to be).

Both branches genuinely solve, every step, over the same measurements: a chain with a w = 1 prior on the first state, w = 4 odometry between neighbours, and a w = 9 skip factor between k and k+2 standing in for a landmark seen by three keyframes. The curve plots |window solve − keep-everything solve|. The marginalise curve sits on the axis at ~10−13 m because the Schur complement is exact — that is computed, not assumed. The drop curve is the information you actually threw away, in metres.

Window size K 6

A real sliding window, sized in numbers

This is the VINS-Mono shape, which is close enough to OKVIS and Kimera that it answers for all of them.

ItemCountDimension eachTotal
Keyframe states (p, v, q, ba, bg)1015150
Feature inverse depths~1501150
Camera–IMU extrinsic166
Camera–IMU time offset td111
State dimension307

Factors in the window: 9 IMU preintegration factors (one per consecutive keyframe pair, each a 15-dimensional residual), roughly 900 reprojection factors (150 features × ~6 keyframes each, 2-dimensional), and one marginalisation prior. That one prior is dense over the states it touches; everything else is a two- or three-variable factor.

Timing on an ARM Cortex-A72 class core, per keyframe (250 ms budget):

PhaseTimeNotes
Feature tracking + keyframe decision8–15 msSeparate thread; overlaps the solve
Linearise ~910 factors6–10 msDominated by reprojection Jacobians
Assemble Λ (307×307)1–2 msScatter-add; cache-friendly if you sort by variable
Solve (dense Cholesky at this size)2–4 ms3073/3 ≈ 9.6×106 flops
× 4–8 LM iterations30–60 msThe real cost
Marginalise the oldest keyframe1–3 msSchur complement over a = 50 eliminated states — see the derivation under the table
Total40–80 msAgainst a 250 ms deadline. Headroom is deliberate: a bad keyframe can double the iteration count.
The latency budget question: "your solve takes 60 ms and you publish at 200 Hz. How?" You do not publish the solve. You publish the IMU-propagated state, which is the last optimised keyframe pose integrated forward with the current bias estimate. When a new solve lands, you re-propagate from the corrected state. The optimiser's job is to keep the integration start point honest, not to be in the output path.

Where the 50 marginalised states come from, and what they cost

The marginalise row in that table is the one almost nobody can break down, so break it down. VINS-Mono parameterises each feature by an inverse depth in the keyframe that first observed it — its anchor. When the oldest keyframe leaves, two things must go with it:

a = 15 + 35 = 50 eliminated    b = 307 − 50 = 257 kept

Now price the Schur complement Λ' = Λbb − ΛbaΛaa−1Λab, in three pieces:

PieceArithmeticFlops
Factor Λaa (50×50)a3/3 = 503/3 = 125,000/34.2×104
Back-solve for Λaa−1Λab (50×257)2a2b = 2 × 2,500 × 2571.3×106
The outer product Λba(·) (257×50 × 50×257)2ab2 = 2 × 50 × 66,0496.6×106
Totaldominated by the last term≈8×106

Eight million flops — the same order as one 307×307 Cholesky, which is why marginalisation lands at 1–3 ms rather than being free or being the bottleneck. And note which term dominates: 2ab2. The cost is quadratic in what you keep and only linear in what you remove, so marginalising more aggressively barely helps; shrinking the window does.

VINS-Mono's actual policy: two-way marginalisation

Here is the detail about VINS-Mono that comes up more than any other, and the one that anyone who only knows "marginalise the oldest keyframe" gets wrong roughly half the time. VINS-Mono does not have one marginalisation rule. It has two, and it picks between them by asking whether the second-newest frame was declared a keyframe.

BranchTriggerWhat is removedWhat happens to the IMU
MARGIN_OLDThe second-newest frame is a keyframeThe oldest keyframe plus every feature anchored to it. Both its IMU preintegration factor and all its reprojection factors are Schur-complemented into the prior.Folded into the prior. The chain stays intact because the departing frame is at the end of the chain.
MARGIN_SECOND_NEWThe second-newest frame is not a keyframeThat non-keyframe's visual measurements are dropped outright. Nothing is Schur-complemented; the window keeps all its keyframes.Kept. Its preintegration is re-integrated into the neighbouring interval, so the chain from the previous keyframe to the newest frame is continuous.

Why branch 2 is not "just be consistent and marginalise". Two independent reasons, and you want both:

The one-sentence version to have ready: "VINS-Mono marginalises the oldest keyframe when the second-newest frame earned keyframe status, and otherwise discards the second-newest frame's visual measurements while re-integrating its IMU into the next interval — so the keyframe set only ever loses frames from the old end, and the inertial chain is never cut." That sentence names both branches, states the trigger, and gets the IMU asymmetry right, which is three things most answers miss.

MSCKF does not do this at all — the left-nullspace contrast

MSCKF appears in the "marginalise" row above, and it does marginalise features — but not with a Schur complement, and this is a genuine trap. MSCKF never puts a feature in the state vector in the first place. Its state is a sliding clone of camera poses only. So there is nothing to Schur-complement out; instead it eliminates the feature from the measurement equation, algebraically, before the update.

Take a feature tracked across M frames. Stack all its observations and linearise:

ObjectShapeWhat it is
r2M × 1Stacked reprojection residuals, 2 rows per observing frame
Hx2M × 15NJacobian w.r.t. the N cloned poses in the state
Hf2M × 3Jacobian w.r.t. the feature's 3-D position — a variable that is not in the state
r ≈ Hx δx + Hf δpf + n

Compute A = left-nullspace(Hf), shape 2M × (2M − 3), by taking the last 2M − 3 columns of Q from a QR factorisation of Hf. By construction ATHf = 0, so multiplying through by AT:

ATr ≈ ATHx δx + 0 + ATn

The feature is gone. What remains is a constraint on the poses alone, with the feature never having entered the state and never needing a covariance entry.

Do the dimension count for M = 6 (a feature tracked across six cloned frames): the stacked system has 2M = 12 rows. Hf is 12×3 with rank 3, so its left nullspace has dimension 12 − 3 = 9. After projection you have 9 rows of pose-only constraint, the 3-D feature is eliminated, and the state dimension is unchanged at 15N — no fill-in in the state at all, ever. Compare with the sliding-window smoother, which would have carried that feature as 1 to 3 extra state dimensions and then paid 2ab2 to remove it.

State the trade, because it is the follow-up. MSCKF buys a state whose dimension depends only on the number of cloned poses, never on the number of features — that is why it runs on a Mars helicopter. What it gives up is total: the feature can never be relinearised, never be re-observed after it leaves the tracking window, and never contribute to a loop closure, because it was never stored. A sliding-window smoother keeps its features in the state precisely so it can relinearise them for several keyframes. MSCKF marginalises the feature the moment it uses it; a fixed-lag smoother marginalises it the moment it leaves. Both are marginalisation; the difference is when, and it changes what the system can do afterwards.
The sentence that shows you have read the paper: "The 9 rows are also correlated by ATn even though n was white, so MSCKF follows the projection with a second QR to compress the stacked measurement down to a size the EKF update can absorb in O(state2) rather than O(rows × state2). Mourikis & Roumeliotis, ICRA 2007." If you can add that, the MSCKF question is closed.

Marginalisation with FEJ, from scratch

One line of the class below carries the whole idea, and it is the line people copy without understanding: return self.Lam, self.eta - self.Lam @ dx. Derive it first, because "the residual moves and the Jacobian does not" is a slogan, and a slogan will not survive "show me why the update to η is exactly −Λδx."

Setup. When you marginalise you get a quadratic in the delta coordinates about the linearisation point. Write d = x − xlin. The prior's contribution to the cost is

C(d) = ½ dTΛd − ηTd

Every solver iteration, though, the optimiser wants its normal equations expressed about the current estimate xnow, not about xlin. Let δx = xnow − xlin be how far you have drifted since marginalisation, and let dnew = x − xnow be the step the solver is about to choose. Then d = δx + dnew. Substitute and expand:

C = ½(δx + dnew)TΛ(δx + dnew) − ηT(δx + dnew)

Expand the square. Λ is symmetric, so the two cross terms are equal and combine:

C = ½ dnewTΛdnew + dnewTΛδx − ηTdnew + ( ½δxTΛδx − ηTδx )

Collect the terms by their order in dnew:

Order in dnewTermWhat it becomes
Quadratic½ dnewTΛdnewΛ is unchanged. This is the frozen Jacobian — it never moves, which is FEJ.
LineardnewTΛδx − ηTdnew = −(η − Λδx)Tdnewηnew = η − Λδx. This is the residual moving with the estimate.
Constant½δxTΛδx − ηTδxIrrelevant. It does not contain dnew, so ∂C/∂dnew kills it and the argmin is unaffected. It shifts the reported cost value, nothing else.

Matching the standard form ½dnewTΛdnew − ηnewTdnew + const gives ηnew = η − Λδx, which is the line of code. Remember the constant-term point — it is the reason a fixed-lag smoother's reported chi-square jumps when you re-anchor a prior even though the estimate does not move at all, which is a real and confusing artefact in logs.

Do not take that on trust. Four lines confirm it numerically: evaluate the same physical point x* two ways, once as a delta about xlin using (Λ, η) and once as a delta about xnow using (Λ, ηnew), and check the difference is exactly the predicted constant.

python
# Verify eta_new = eta - Lam @ dx by re-expressing the SAME quadratic twice.
rng = np.random.default_rng(0)
M    = rng.standard_normal((5, 5)); Lam = M @ M.T + 5 * np.eye(5)   # SPD
eta  = rng.standard_normal(5)
dx   = rng.standard_normal(5) * 0.1          # drift since marginalisation
eta_new = eta - Lam @ dx                     # the line under test

cost = lambda L, e, d: 0.5 * d @ L @ d - e @ d
d_star  = rng.standard_normal(5)              # any test point, as a delta from x_lin
const   = 0.5 * dx @ Lam @ dx - eta @ dx      # the discarded constant
assert abs(cost(Lam, eta, d_star) - (cost(Lam, eta_new, d_star - dx) + const)) < 1e-12

# and the thing that actually matters: the MINIMISER is the same point in the world.
x_lin = np.zeros(5)
assert np.allclose(x_lin + np.linalg.solve(Lam, eta),
                   (x_lin + dx) + np.linalg.solve(Lam, eta_new), atol=1e-12)
print("re-anchoring is exact; cost differs by the constant %.6f only" % const)

The second assertion is the one to point at: the argmin expressed in world coordinates is identical. Re-anchoring a marginalisation prior to a new linearisation point is not an approximation and does not lose information — it is a change of variables. What FEJ freezes is Λ, not the anchor.

python
import numpy as np

class MargPrior:
    """A dense Gaussian prior produced by Schur-complementing states out.
    The linearisation point is stored WITH it and never updated. That is FEJ."""

    def __init__(self, Lam, eta, drop, keep, x_lin):
        A = Lam[np.ix_(drop, drop)]
        B = Lam[np.ix_(keep, drop)]
        self.Lam   = Lam[np.ix_(keep, keep)] - B @ np.linalg.solve(A, Lam[np.ix_(drop, keep)])
        self.eta   = eta[keep]              - B @ np.linalg.solve(A, eta[drop])
        self.x_lin = x_lin[keep].copy()     # FROZEN. This is the whole trick.
        self.keep  = keep

    def contribute(self, x_now):
        """Return (Lambda, eta) to add to the current normal equations.
        The RESIDUAL moves with the estimate; the JACOBIAN does not."""
        dx = x_now[self.keep] - self.x_lin      # how far we have drifted since
        # the prior in delta coordinates about x_lin, re-expressed about x_now:
        return self.Lam, self.eta - self.Lam @ dx

    # The bug you must NOT write:
    #   def relinearize(self, x_now):  self.x_lin = x_now[self.keep]
    # It looks like an improvement. It collapses the unobservable subspace.

And the same idea in GTSAM, where the fixed-lag smoother owns the bookkeeping:

python
import gtsam
from gtsam import BatchFixedLagSmoother, ISAM2, ISAM2Params

# Option A — fixed lag. States older than `lag` seconds are marginalised.
smoother = BatchFixedLagSmoother(lag=3.0)
smoother.update(new_factors, new_values, key_timestamps)
#   internally: build the elimination ordering so the expiring keys come FIRST,
#   eliminate them, keep the resulting clique as a LinearContainerFactor.

# Option B — incremental, keep everything, relinearise only what moved.
p = ISAM2Params()
p.relinearizeThreshold = 0.01     # relinearise a variable once it has moved this far
p.relinearizeSkip      = 1        # check every update, not every 10th
isam = ISAM2(p)
isam.update(new_factors, new_values)
est = isam.calculateEstimate()
What to say about relinearizeThreshold: "This is the knob that makes iSAM2 different from batch. Setting it to zero makes it a batch smoother that pays the full price every update. Setting it too high freezes bad linearisations in place. 0.01 to 0.1 in the relevant units is the usual range, and the right diagnostic is the fraction of the Bayes tree that gets re-eliminated per update — if it is regularly above 30%, the threshold is not buying you anything."

The window that drifts while every residual looks healthy

Field report: a sliding-window VIO on a ground robot. Over a ten-minute mission the yaw estimate drifts about 6°. The reported yaw standard deviation falls monotonically to 0.1°. Reprojection errors are 0.4 px RMS throughout — textbook. IMU residuals are inside their expected band. No solver failures, no rejected steps, no divergence.

Candidate causeWould also showRuled out by
Bad camera–IMU extrinsic calibrationReprojection error inflated, especially during rotation0.4 px RMS. A bad extrinsic cannot hide inside a healthy reprojection error.
Gyro bias not being estimatedThe bias estimate would sit pinned at its initial value and the IMU residuals would show a consistent offsetIMU residuals are centred and inside their band.
Not enough features / degenerate sceneFeature count collapses, reprojection residuals get noisy, the covariance would growThe covariance is shrinking. A data-starved estimator gets less confident, not more.
Marginalisation prior relinearised each iteration (no FEJ)Everything above stays healthy, because every individual factor is being fitted well. Only the rank is wrong.

The metric that reveals it. Two options, one cheap and one definitive:

python
# Definitive: count the unobservable directions of the linearised window.
# Monocular VIO must have exactly 4 (global x, y, z, and yaw about gravity).
sv   = np.linalg.svd(J_stacked, compute_uv=False)
tol  = sv[0] * max(J_stacked.shape) * np.finfo(float).eps
print("nullspace dim =", int((sv < tol).sum()))     # FEJ: 4.  no FEJ: 3.

# Cheap field test: drive a closed loop and stop where you started.
# Plot reported yaw sigma against accumulated |yaw rate| dt.
# FEJ:    sigma grows, slowly. No FEJ: sigma falls, monotonically.
# A yaw sigma that DECREASES while the robot is turning is impossible.
The transferable tell: when every residual is healthy and the estimate is still wrong, stop looking at the factors and look at the rank. Residuals measure fit. They say nothing about whether the problem you are fitting has the right number of degrees of freedom. Healthy residuals plus a shrinking covariance in an unobservable direction is a rank bug, every time.

A second failure: the prior that quietly stops being positive definite

The FEJ bug above is a modelling failure and it lives in every fixed-lag textbook. This second one is a numerical failure, it is specific to sliding windows, and it will not appear in any textbook because it only shows up after a few hundred keyframes on a real robot.

Field report: an embedded VIO in single precision, running fine for four to six minutes. Then, intermittently, one keyframe where the solver reports Cholesky failed: matrix is not positive definite, LM rejects every step and ratchets λ up by 10× each time until it hits its cap, the pose output freezes at the last accepted estimate — and then the next keyframe solves normally and everything looks fine again. Reprojection errors are healthy on either side. There is nothing wrong with the data.

The mechanism. Each marginalisation applies Λ' = Λbb − ΛbaΛaa−1Λab. That is a subtraction of two nearly equal quantities along whatever directions were weakly constrained — and the gauge directions are, by construction, the weakest ones present. Two things then compound:

Candidate causeWould also showRuled out by
Genuine gauge freedom (no prior anchoring the window)Cholesky fails on every keyframe from the first one, not after four minutesIt ran clean for 400 keyframes. A gauge is present from t = 0 or not at all.
An outlier feature with an absurd inverse depthReprojection RMS spikes on exactly the failing keyframe; removing the track fixes it permanentlyReprojection RMS is flat across the failure, and the failure recurs on different keyframes with different feature sets.
Degenerate motion (pure rotation, hovering)The IMU excitation metric collapses and the failure correlates with the motion profileIt fails during ordinary translating flight and survives hover.
The marginalisation prior has lost positive-definiteness through repeated float32 Schur complementsNothing else — every residual and every factor is healthy. Only Λprior's spectrum is sick.

The metric that reveals it — log it per keyframe, it costs microseconds:

python
# One line per keyframe. On a 50x50 prior this is tens of microseconds.
ev = np.linalg.eigvalsh(prior.Lam)          # symmetric eigenvalues, ascending
ratio = ev[0] / ev[-1]                       # min / max
print("kf %d  lam_min %.3e  lam_max %.3e  ratio %.3e  asym %.3e" %
      (kf, ev[0], ev[-1], ratio,
       np.abs(prior.Lam - prior.Lam.T).max() / np.abs(prior.Lam).max()))

# HEALTHY : ratio stays above ~1e-6 and wanders without trend; asym ~1e-16 (float64).
# BROKEN  : ratio walks DOWN monotonically - 1e-6 -> 1e-9 -> 1e-12 -> ~1e-14 over a
#           few hundred keyframes - then ev[0] crosses zero and Cholesky refuses.
#           asym climbs to ~1e-7 in float32 long before ev[0] goes negative, so it
#           is the EARLIER alarm of the two. Alarm on asym > 1e-9.

Why the monotone walk is the whole diagnosis. A one-off numerical accident gives you a bad eigenvalue on one keyframe and a good one on the next. A drift from 10−6 to 10−14 over 400 keyframes is an accumulator, and the only accumulator in the window is the prior — every other object is rebuilt from raw measurements each keyframe. Plot λminmax on a log axis against keyframe index; a straight downward line is a signed diagnosis.

FixWhat it costsWhen to reach for it
Symmetrise every time: Lam = 0.5 * (Lam + Lam.T)One pass over a 50×50 matrix. Free.Always. There is no argument against it and it removes the asymmetry half of the problem outright.
Do the Schur complement in float64 even if the solver runs in float324 × 8 = 32 kB more for a 60×60 prior. Nothing.Always, on any platform with an FPU. The prior is the one object that accumulates.
Clamp the spectrum: eigendecompose and floor λi at ελmaxA 50×50 eigh, ~50 µs. Adds a small artificial prior.As a safety net with an alarm attached — never silently. If it fires often, the real bug is upstream.
Square-root marginalisation (QR on the factor, never forming Λ)QR is ~2× the flops of Cholesky.The principled fix. κ is never squared, so float32 becomes genuinely safe — this is exactly the Demmel et al. result in the frontier section below.
Its sibling, which the same metric catches: marginalising a keyframe whose landmarks were never properly triangulated. An inverse depth initialised from a two-view triangulation with 2 px of parallax has enormous uncertainty along the ray; Schur-complementing it folds that near-singular block into the prior, and the prior gains enormous information in the inverse-depth directions rather than losing it. The symptom is different — no Cholesky failure, but the window snaps to a wrong scale and stays there, confidently. The metric is np.linalg.cond(prior.Lam): healthy is 104–106, and anything above 108 means you folded in a direction you had no business folding in. The fix is a gate, not a solver change: refuse to marginalise a feature whose triangulation parallax is below threshold — drop it instead. Dropping an unconstrained landmark is free (see the fill-in section above); marginalising a badly conditioned one is not.
The second transferable tell: a failure that appears only after N iterations of a loop, and clears itself, is almost never in the data — it is in whatever object survives the loop. In a sliding window exactly one object survives: the prior. Instrument the survivor's spectrum before you instrument anything else.

The frontier: making the prior sparse again

A colleague sends a PR that relinearises the marginalisation prior at each iteration, arguing it reduces linearisation error. Do you merge it?
What to actually ask for in that PR review — the two diagnostics. (1) The nullspace-dimension test. Stack the window's Jacobian and count singular values below sv[0] × max(shape) × eps. Monocular VIO must report 4; the PR's build will report 3. That is a two-line check and it is not negotiable — ask for it in CI. (2) The closed-loop yaw-sigma plot. Drive a loop, stop where you started, plot reported yaw σ against accumulated |ω| dt. With FEJ, σ grows slowly. Without it, σ falls monotonically while the robot is turning, which is physically impossible — and by the arithmetic earlier in this chapter it reaches about 2.5° after one keyframe and 0.25° after a hundred.

Why the other three are tempting and still wrong. Option A has the symmetry backwards: the prior is not exempted by choice, it cannot be relinearised, because the raw measurements that built it were destroyed at marginalisation time; freezing its Jacobian is the only way to keep one convention. Option B fails for the same reason — relinearising the IMU factors moves them away from the prior, widening the mismatch rather than closing it; the correct move is the opposite one, freezing the shared states' linearisation point everywhere. Option C treats a rank error as a tuning error: inflating the covariance slows the fictitious information down but never removes the direction, so σyaw still falls as 1/√N, just with a bigger constant.

Chapter 4: Solvers & Libraries

You have used GTSAM. Why not Ceres? And why does Ceres default to Levenberg–Marquardt rather than plain Gauss–Newton — what actually goes wrong without the damping?

This is a two-part question wearing one hat. The first part is about what each library assumes. The second is a numerical-methods question that most robotics engineers have never had to answer with a number, and it is trivially answerable if you have ever watched Gauss–Newton diverge.

Gauss–Newton, and the one line where it goes wrong

Everything so far has been Gauss–Newton: linearise, solve the normal equations, apply the step, repeat.

Λ δ = η,   Λ = JTWJ,   η = −JTWr,   x ← x ⊕ δ

It is the Newton method with the second-derivative term dropped — the true Hessian of ½rTWr is JTWJ + ∑i wiri2ri, and Gauss–Newton throws away the second sum. That is a good trade when the residuals are small (near the solution) and a terrible one when they are large (far from it).

The failure is not subtle. Gauss–Newton computes the step that would be exactly right if the model were linear, and then takes all of it. When the linearisation is only valid over a centimetre and the step is five metres, the step is nonsense.

Worked example: Gauss–Newton blowing up, with numbers

The smallest possible instance. One residual r(x) = x2 − 4, so the answer is x = 2, and the cost is C(x) = ½r2. Start at x = 0.1.

Evaluate. r = 0.01 − 4 = −3.99. J = dr/dx = 2x = 0.2. So Λ = J2 = 0.04 and η = −Jr = −(0.2)(−3.99) = +0.798.

Gauss–Newton step.

δ = η/Λ = 0.798 / 0.04 = 19.95  →  x = 0.1 + 19.95 = 20.05

Evaluate the new cost. r(20.05) = 402.0 − 4 = 398.0, so C = ½(398)2 = 79,202, up from C(0.1) = ½(3.99)2 = 7.96. The step made the cost ten thousand times worse.

Why: at x = 0.1 the slope is 0.2, so the linear model says "to reduce r by 3.99 you need to move 19.95". That is true of the tangent line and wildly false of the parabola. Gauss–Newton has no mechanism to notice — it does not evaluate the cost after stepping, and it has no notion of how far it should trust its own model.

Levenberg–Marquardt: a leash on the step

LM adds a damping term and, crucially, checks the result before committing:

( Λ + λ D ) δ = η,   D = I  (Levenberg)  or  D = diag(Λ)  (Marquardt)

Read the two limits, because they are the whole intuition:

The algorithm is then: take the step, evaluate the true new cost, and if it went down, accept and reduce λ (trust the model more); if it went up, reject, increase λ, and try again from the same point.

Worked example: the same problem, LM, five iterations by hand

Start at x = 0.1 with λ = 100 and D = diag(Λ). The damped system is (Λ + λΛ)δ = η, so δ = η / (Λ(1 + λ)).

itxr = x2−4C = ½r2λδnew xnew Cverdict
00.100000−3.9900007.960050100+0.1975250.2975257.649834accept, λ→10
10.297525−3.9114797.64983410+0.5975790.8951045.116127accept, λ→1
20.895104−3.1987905.1161271+0.8934131.7885170.320967accept, λ→0.1
31.788517−0.8012080.3209670.1+0.2036241.9921410.000492accept, λ→0.01
41.992141−0.0313740.0004920.01+0.0077971.9999383×10−8accept, λ→0.001

Verify the first row by hand: Λ = J2 = (0.2)2 = 0.04, so δ = 0.798 / (0.04 × 101) = 0.798 / 4.04 = 0.197525. Then x = 0.297525, r = 0.088521 − 4 = −3.911479, and C = ½(3.911479)2 = 7.649834 — lower than 7.960050, so the step is accepted.

The pattern to point at: λ falls by 10× every accepted step, so the algorithm walks smoothly from "gradient descent with a tiny step" to "full Gauss–Newton" as the linearisation becomes trustworthy. That is not a heuristic bolted on; it is the entire content of LM. Five iterations from a starting point where Gauss–Newton increased the cost by a factor of ten thousand.
The leash: Gauss–Newton vs Levenberg–Marquardt

The curve is C(x) = ½(x2 − 4)2. Press GN step from a bad starting point and watch it leave the chart. Press LM step and watch λ do its job. Now drag λ and watch the dashed grey ghost arrow: it is recomputed live at the current x, so sliding from log₁₀λ = −3 to +3 shrinks it continuously from the full Gauss–Newton overshoot (which runs off the right edge) down to a near-invisible steepest-descent nudge. Because the chart's x-axis stops at 5.2, the ruler underneath the curve is the honest read: it is log-scale step magnitude, with the orange dot pinned at the Gauss–Newton step and the grey dot sliding six decades left as you damp. The readout below prints the ghost's δ and what fraction of the Gauss–Newton step it is — that fraction is 1/(1+λ), the interpolation, as a number.

start x 0.10
log₁₀ λ 2.00

Dogleg, and when it beats LM

Powell's dogleg is the third option every library ships. Instead of damping, it defines an explicit trust region of radius Δ and picks the best step inside it: the Gauss–Newton step if it fits, the Cauchy (steepest-descent) point if even that is too far, and a blend of the two along a two-segment path otherwise — hence "dogleg".

Levenberg–MarquardtDogleg
Rejected step costsA full re-factorisation — λ changed, so (Λ + λD) is a different matrixNothing. The factorisation is reused; only the trust radius changes.
Best whenCheap factorisation, or few rejections expectedExpensive factorisation — large sparse problems where each Cholesky is the dominant cost
RequiresNothing extraΛ to be positive definite (it uses the GN step directly)

Worked example: computing a dogleg step, every number

"How do you actually compute the dogleg step?" is the guaranteed follow-up, and the answer is four short stages. Two variables keeps every intermediate printable. Take the linearised system

Λ = [[8, 2], [2, 1]],   η = [1, 1]T,   Δ = 1.0

This is a deliberately awkward block: its eigenvalues are 8.531 and 0.469, so κ(Λ) = 18.2. One direction is stiff, one is soft — the shape you get whenever a well-observed image-plane direction sits next to a badly-observed depth.

Stage 1 — the Gauss–Newton step, and does it fit? det Λ = (8)(1) − (2)(2) = 4, so

Λ−1 = ¼ [[1, −2], [−2, 8]],   δGN = Λ−1η = ¼ [1−2, −2+8]T = [−0.250000, 1.500000]T

‖δGN‖ = √(0.0625 + 2.25) = √2.3125 = 1.520691. That is bigger than Δ = 1.0, so the full GN step is outside the trust region. Stage 1 fails, and we go on.

Stage 2 — the Cauchy point, derived not quoted. The Cauchy point is the minimiser of the same quadratic model restricted to the steepest-descent ray δ = αη. Substitute that ray into the model m(δ) = −ηTδ + ½δTΛδ:

m(αη) = −α(ηTη) + ½α2TΛη)  ⇒   dm/dα = −ηTη + αηTΛη = 0
α* = (ηTη) / (ηTΛη),   δC = α* η

Now evaluate the two inner products as scalars. ηTη = 12 + 12 = 2. For the denominator, first Λη = [8(1)+2(1), 2(1)+1(1)]T = [10, 3]T, then ηT(Λη) = 1(10) + 1(3) = 13. So

α* = 2/13 = 0.1538462,   δC = [0.153846, 0.153846]T,   ‖δC‖ = 0.153846√2 = 0.217571

0.217571 < 1.0, so the Cauchy point is comfortably inside the region. We are in the interesting case: ‖δC‖ < Δ < ‖δGN‖. The step lives on the second leg of the dogleg.

Stage 3 — the blending scalar τ, as an explicit quadratic. Walk from δC toward δGN and stop when you hit the boundary:

δ(τ) = δC + τ(δGN − δC),  τ ∈ [0, 1],   solve ‖δ(τ)‖2 = Δ2

Write d = δGN − δC = [−0.250000 − 0.153846, 1.500000 − 0.153846]T = [−0.403846, 1.346154]T. Expanding ‖δC + τd‖2 − Δ2 = 0 gives aτ2 + bτ + c = 0 with

CoefficientFormulaArithmeticValue
adTd(−0.403846)2 + (1.346154)2 = 0.1630917 + 1.81213021.9752219
b2 δCTd2[(0.153846)(−0.403846) + (0.153846)(1.346154)] = 2(−0.0621302 + 0.2071006)0.2899408
cδCTδC − Δ22(0.153846)2 − 1.0 = 0.0473373 − 1−0.9526627

Discriminant: b2 − 4ac = 0.0840660 − 4(1.9752219)(−0.9526627) = 0.0840660 + 7.5268807 = 7.6109467, so √(b2−4ac) = 2.7587944. The positive root is

τ = (−b + √(b2 − 4ac)) / (2a) = (−0.2899408 + 2.7587944) / 3.9504438 = 2.4688536 / 3.9504438 = 0.6249560
Why you always take the positive root, and why there is exactly one. c = ‖δC2 − Δ2 is negative by construction — we only reach stage 3 after checking that the Cauchy point is inside the region. The product of the roots is c/a < 0, so one root is negative and one is positive: the quadratic always has exactly one admissible τ, and it is always in (0, 1) because the far endpoint δGN is outside. No case analysis, no line search, no failure branch. That determinism is a large part of why dogleg is the default in GTSAM's DoglegOptimizer.

Stage 4 — the step.

δDL = δC + 0.6249560 d = [0.153846 − 0.252386, 0.153846 + 0.841287]T = [−0.098540, 0.995133]T

Check it: ‖δDL‖ = √(0.0097101 + 0.9902897) = √0.9999998 = 1.000000. Exactly on the trust-region boundary, which is what stage 3 solved for.

Was it worth the arithmetic? Score each candidate step by the reduction the model predicts, ηTδ − ½δTΛδ:

Stepδ‖δ‖Predicted reductionFraction of the GN reduction
Cauchy point[0.153846, 0.153846]0.2175710.153846224.6%
Dogleg[−0.098540, 0.995133]1.0000000.558728489.4%
Gauss–Newton (rejected, too long)[−0.250000, 1.500000]1.5206910.6250000100%

The dogleg step captures 89.4% of the full Gauss–Newton reduction while obeying a trust radius the GN step violates by 52%. Taking the Cauchy point instead — which is what a naive "too far, fall back to gradient descent" implementation does — would have thrown away three quarters of the available progress.

Now the sentence about rejection, made concrete. Suppose the true cost does not fall by anything like 0.559 and the step is rejected. Dogleg halves the radius to Δ = 0.5 and re-solves. Nothing about Λ, δGN, δC, a or b changes — only c:

c = 0.0473373 − 0.25 = −0.2026627 ⇒  τ = (−0.2899408 + 1.2981838)/3.9504438 = 0.2552227 ⇒  δ = [0.050775, 0.497415]T

That retry cost two multiplies, one subtraction and one square root — call it ten flops. The equivalent LM retry means picking a new λ and factorising (Λ + λD) again. On the 5,000-pose graph from the earlier design tables that factorisation is 40 ms, and a bad iteration commonly needs three tries: 120 ms of pure re-factorisation versus ~10 flops. That is the whole argument, and it is why the row above says a rejected dogleg step is free.

For completeness, the λ that would give LM a step of norm exactly 1.0 on this system is λ = 0.2107 with Marquardt damping D = diag(Λ) — but LM has no way to know that in advance. It has to guess λ, factorise, measure ‖δ‖ after the fact, and guess again. Dogleg parameterises the same curve by the thing you actually want to control.

python
import numpy as np

def dogleg_step(Lam, eta, Delta):
    """Powell's dogleg. Lam (n,n) SPD, eta (n,), trust radius Delta.
       Returns (step, which_leg). ONE factorisation, reused for every Delta."""
    L      = np.linalg.cholesky(Lam)                       # the expensive part — do it once
    d_gn   = np.linalg.solve(L.T, np.linalg.solve(L, eta))
    if np.linalg.norm(d_gn) <= Delta:
        return d_gn, "gauss-newton"                        # stage 1: it fits
    # stage 2: Cauchy point = model minimiser along the steepest-descent ray
    alpha  = (eta @ eta) / (eta @ Lam @ eta)
    d_c    = alpha * eta
    if np.linalg.norm(d_c) >= Delta:                       # even that is too far
        return Delta * d_c / np.linalg.norm(d_c), "cauchy"
    # stage 3: walk d_c -> d_gn, stop on the boundary. a*t^2 + b*t + c = 0
    d      = d_gn - d_c
    a      = d @ d
    b      = 2.0 * (d_c @ d)
    c      = d_c @ d_c - Delta * Delta                     # < 0 by the check above
    tau    = (-b + np.sqrt(b*b - 4*a*c)) / (2*a)          # exactly one positive root
    return d_c + tau * d, "dogleg"

# the worked example above, to the digit
Lam = np.array([[8., 2.], [2., 1.]]); eta = np.array([1., 1.])
print(dogleg_step(Lam, eta, 1.0))   # ([-0.09854,  0.99513], 'dogleg')
print(dogleg_step(Lam, eta, 0.5))   # ([ 0.05077,  0.49742], 'dogleg')  <- no new Cholesky
print(dogleg_step(Lam, eta, 2.0))   # ([-0.25   ,  1.5    ], 'gauss-newton')

The line that carries the idea: L is computed before the Delta loop, not inside it. Every rejected step re-enters at the tau line. In a real implementation you hoist the Cholesky and both candidate steps out of the trust-region loop entirely, so a rejection touches nothing but three scalars.

The sentence that comes from having run these: "On a big sparse pose graph I would try dogleg first, because a rejected LM step means re-factorising a matrix that took 40 ms, and dogleg's rejection is free. On a small dense window, LM's simplicity wins and the difference is noise."

Under LM sits a linear solve, and that is the real choice

Every iteration of every method above ends in "solve Λδ = η". How you solve it is where the orders of magnitude live.

MethodWhat it formsCostCondition numberUse when
Dense CholeskyΛ = LLTn3/3κ(Λ) = κ(J)2n < ~1000. A sliding window (n ≈ 300) is squarely here.
Sparse Cholesky (CHOLMOD)Same, with a fill-reducing permutation (AMD / COLAMD)j cj2, where cj is column j's nonzero count in Lκ(J)2Pose graphs, sparse BA. The workhorse.
Sparse QR (SuiteSparseQR)J = QR directly — Λ is never formed~2× Choleskyκ(J) — not squaredIll-conditioned problems, single precision, incremental (this is what iSAM uses).
Schur + denseReduced camera system Ssee Ch 2κ(J)2Bundle adjustment with C < ~1000.
Conjugate gradients (ITERATIVE_SCHUR)Nothing — only matrix-vector productsO(nnz) per iteration, √κ iterationsSensitive to κ; needs a preconditionerVery large BA (104+ cameras) where forming S is itself infeasible.

The condition-number argument, in digits

This is the one piece of numerical analysis every SLAM backend engineer genuinely needs, and it fits in three lines.

Forming the normal equations squares the condition number: κ(JTJ) = κ(J)2. In IEEE double precision the machine epsilon is 2.2 × 10−16, roughly 16 significant digits. The digits you keep after a solve are approximately log10(1/ε) − log10κ.

κ(J)RouteEffective κDigits surviving (of 16)
103Normal equations10610
103QR on J10313
105Normal equations10106
105QR on J10511
108Normal equations10160 — the answer is noise

κ(J) = 105 is not exotic. It happens the moment your problem mixes a well-observed direction (along-track position, constrained by a hundred features) with a badly observed one (scale in a nearly-constant-velocity segment). Six surviving digits is still fine for metres and radians; in single precision (ε ≈ 1.2 × 10−7, 7 digits) the same problem returns nothing.

The answer to "why square-root SLAM?": not speed — QR is about twice the flops of Cholesky. It is that QR never forms JTJ, so it never squares κ. That buys you 5 digits at κ(J) = 105, which is exactly what lets Demmel et al. (CVPR 2021) run bundle adjustment in single precision and match a double-precision Schur solver.

The libraries, and what each one assumes about you

LibraryMental modelAssumesBest atWhere it hurts
Ceres
Google, 2010
A generic nonlinear least-squares solver. You hand it residual blocks and parameter blocks; it knows nothing about robots.You will manage the graph yourself; you want automatic differentiation; you may need weird cost functions.Batch problems, calibration, anything non-standard. Autodiff via Jet types means you write the residual once and get exact Jacobians free.No incremental mode. No graph semantics — marginals are awkward, and you must hand-declare the Schur ordering.
GTSAM
Georgia Tech, 2010—
A factor graph library. Variables have Lie-group types; factors are first-class; elimination produces a Bayes tree.Your problem really is a factor graph, and you care about incrementality and marginals.iSAM2 (fluid relinearisation, incremental updates), cheap marginal covariances, fixed-lag smoothing, IMU preintegration built in.Steeper learning curve. Templates. The abstraction fights you when your problem is not a graph.
g2o
Kümmerle et al., ICRA 2011
A hyper-graph of vertices and edges with hand-written Jacobians and specialised block solvers.You will write your own Jacobians and you want minimal overhead.Pose graphs and BA at speed with a small footprint. This is what ORB-SLAM uses.Less general, thinner documentation, no autodiff, no incremental mode.
SymForce
Skydio, RSS 2022
Symbolic. You write the residual in SymPy; it generates flat, branch-free, allocation-free C++ with analytically simplified Jacobians.Your residuals are stable enough to be code-generated ahead of time, and you care about embedded performance.Reported 3–5× over hand-written Ceres code on their benchmarks; the generated code has no dynamic allocation, which matters on an MCU.Codegen step in the build. Debugging generated code. Overkill unless you are actually compute-bound.
The one-liner that answers "which would you pick": "GTSAM if the problem is genuinely incremental and I want marginals; Ceres if it is a batch problem with unusual residuals and I want autodiff; g2o if I am embedding into something that already uses it; SymForce if profiling says I am spending my budget in Jacobian evaluation. The choice is about incrementality and Jacobians, not about speed — they all end up calling CHOLMOD."

Trust region vs line search, said once

Both LM and dogleg are trust-region methods: they decide how far to trust the model, then compute a step inside that region. The alternative family is line search: compute a direction first, then hunt along it for a good step length.

Robotics backends are almost universally trust-region, and the reason is worth knowing. A line search needs several cost evaluations per iteration, and in a SLAM problem one cost evaluation means re-evaluating every residual in the graph — hundreds of thousands of them. A trust-region method makes one proposal, evaluates it once, and either takes it or shrinks. That is a single cost evaluation per attempt.

Trust region (LM, dogleg)Line search (BFGS, NCG)
Decides firstHow far to goWhich way to go
Cost evaluations per iteration1, plus one per rejected step3–10 for a Wolfe-condition search
Handles indefinite curvatureYes — the damping fixes itNeeds an explicit modification
Robotics useCeres, GTSAM, g2o — all of themRare; appears in some SfM initialisation

Ceres exposes both (TRUST_REGION and LINE_SEARCH) and its own documentation recommends trust region for essentially every bundle-adjustment-shaped problem. Being able to say why — the cost of a residual sweep — is a better answer than naming the default.

Where the time actually goes

Anyone who has not profiled a backend assumes the linear solve dominates. In a small window it does not, and the profile is worth memorising because it changes what you would optimise.

PhaseSliding window (n ≈ 307)Pose graph (n = 30,000)
Evaluate residuals + Jacobians6–10 ms (60%)8 ms (20%)
Assemble Λ (scatter-add)1–2 ms (15%)4 ms (10%)
Symbolic analysis + ordering< 0.1 ms3 ms (7%)
Numeric factorisation2–4 ms (25%)22 ms (55%)
Triangular solves< 0.5 ms3 ms (8%)
What this changes. In the window, the lever is Jacobian evaluation — which is exactly the case SymForce makes, and exactly why analytic Jacobians beat autodiff in a hot loop. In the global graph, the lever is the ordering. Optimising the wrong one is the most common wasted week in this field, and the fix is to run a profiler for ten minutes first.

Read the summary, not the wall clock. Ceres' Solver::Summary::FullReport() prints exactly this breakdown, plus the iteration table with per-step cost, cost change, gradient norm, step norm and trust-region radius. GTSAM's ISAM2Result gives you variablesRelinearized and variablesReeliminated — the two numbers that tell you whether the incremental machinery is actually helping. Naming these fields is a cheap and very reliable signal that you have shipped something.

Robust kernels, and the correction everyone forgets

Every backend wraps its data-association-derived factors in a robust kernel, because one false loop closure with a quadratic cost will destroy a map. The theory of Huber and Cauchy lives in Uncertainty, Least Squares & Robust Costs; what belongs here is how the solver actually implements it, because that is the follow-up.

A robust cost replaces ½s with ρ(s), where s = rTWr is the squared Mahalanobis norm. The solver does not rewrite its linear algebra — it rescales the block:

Λf = ρ'(s) JTWJ + 2ρ''(s) (JTWr)(JTWr)T,   ηf = −ρ'(s) JTWr

The first term is just iteratively reweighted least squares — multiply the block by the weight ρ'(s). The second term is the Triggs correction (Triggs et al., Bundle Adjustment — A Modern Synthesis, 2000), and it is what makes the linearisation a true second-order model of the robustified cost.

ChoiceWhat happensWhen it bites
Weight-only (drop the ρ'' term)Λ stays positive semi-definite by construction; convergence is first-order in the weightsSlower near the solution; usually fine. This is what most hand-rolled implementations do.
Full Triggs correctionCorrect second-order model, faster convergenceρ'' is negative in the redescending region, so the block can become indefinite. Ceres clamps it (see CorrectJacobian); if you write it yourself and do not clamp, Cholesky fails on outlier-heavy iterations.

Worked example: watching the Triggs block go indefinite

That table row makes a failure claim, so here is the failure, in one scalar. Take the Cauchy kernel with scale c = 1:

ρ(s) = c2 log(1 + s/c2) = log(1 + s)  (c = 1)

Differentiate it twice — both derivatives, shown, because the second one is the whole point:

ρ'(s) = 1/(1 + s),    ρ''(s) = d/ds (1+s)−1 = −(1 + s)−2

Now a residual that is a genuine outlier: r = 3 with W = 1, so the squared Mahalanobis norm is s = rTWr = 9 — three sigma out. Evaluate both derivatives there:

ρ'(9) = 1/(1 + 9) = 0.1,    ρ''(9) = −1/(1 + 9)2 = −1/100 = −0.01

Take a one-dimensional Jacobian J = 2 so the arithmetic is visible. The two pieces of the rescaled block are JTWJ = 2(1)(2) = 4 and JTWr = 2(1)(3) = 6. Substitute into the equation above:

Λf = ρ'(s) JTWJ + 2ρ''(s) (JTWr)(JTWr)T = (0.1)(4) + 2(−0.01)(6)(6) = 0.4 − 0.72 = −0.32
There it is. A 1×1 information block equal to −0.32. In one dimension "positive definite" means "positive", so this block is indefinite, and np.linalg.cholesky on a matrix containing it raises LinAlgError. That is exactly the "Cholesky fails on outlier-heavy iterations" claim in the table row above, and it took one residual to produce — not a pathological graph, just a measurement three sigma out with a kernel scale of one.

The clamp, and what it restores. Ceres' Corrector (internal/ceres/corrector.cc) tests rho[2] <= 0 and, when it is, drops the rank-1 term entirely and scales by √ρ' alone. That is max(ρ'', 0) by another name:

Λf = ρ'(s) JTWJ + 2 · max(ρ''(s), 0) · (JTWr)(JTWr)T = (0.1)(4) + 0 = +0.4

Positive, factorisable, and identical to plain IRLS with weight 0.1. You lose the second-order term — convergence near the solution goes from quadratic to linear in the weights — and you keep a solver that runs.

Where the boundary actually sits. In one dimension the whole expression factors, which turns the clamp from a hack into a threshold you can quote:

Λf = ρ'(s) J2 [ 1 + 2 s ρ''(s)/ρ'(s) ]

For Cauchy, 2sρ''/ρ' = 2s · (−(1+s)−2) · (1+s) = −2s/(1+s), so the bracket is 1 − 2s/(1+s) = (1 − s)/(1 + s). It is negative whenever s > 1, i.e. whenever the residual exceeds the kernel scale c. Check it against our numbers: at s = 9 the bracket is (1−9)/(1+9) = −0.8, and (0.1)(4)(−0.8) = −0.32 — the same answer by a different route.

Residual r (c = 1)s = r2ρ'(s)ρ''(s)Bracket (1−s)/(1+s)Λf with J = 2Cholesky
0.50.250.800000−0.640000+0.600000+1.920fine
1.01.000.500000−0.2500000.0000000.000singular — the knife edge
2.04.000.200000−0.040000−0.600000−0.480fails
3.09.000.100000−0.010000−0.800000−0.320fails
10.0100.00.009901−0.000098−0.980198−0.038fails

Read the last column downward. Every residual past the kernel scale produces a negative block. The reason a hand-rolled Triggs implementation appears to work on a clean dataset and dies the first time a false loop closure lands is that the clean dataset never left the first row of this table.

What to say if asked "so is the correction worth it?" "Full Triggs when residuals are near the kernel scale, because the second-order model is genuinely better there; clamped when they are not, which in a graph with real outliers is most of them. The honest summary is that the correction buys convergence rate in the regime where you did not need it and turns indefinite in the regime where you did — which is why every production solver ships the clamp on by default and nobody turns it off."
The follow-up worth pre-empting: "does a robust kernel change the covariance you report?" Yes, and in the direction people do not expect. Down-weighting measurements reduces the effective sample size, so Λ shrinks and the reported uncertainty grows. A robustified solution that reports the same covariance as the non-robust one has a bug in the weighting.

Picking the solver from the problem shape

Name the machine before you quote a latency. Every wall time in this chapter — this table and the profile table above — is single-threaded on one 3.0 GHz x86-64 core with AVX2, double precision, SuiteSparse CHOLMOD 4.x with AMD ordering, and the working set resident in a 32 MB L3. On a Jetson Orin Cortex-A78AE core at 2.2 GHz with a 2 MB L2, multiply by roughly 2.5×; on a Cortex-A53 companion core, by 6–8×. Quoting "40 ms for a 5k-pose graph" without saying which core is the tell that you memorised a table instead of profiling a system — which is precisely the failure this chapter's profile section warns about.
ProblemnmStructureSolverWall time
Sliding-window VIO~307~1,995Small, banded plus a dense priorDense Cholesky, LM2–4 ms per iteration
Local bundle adjustment~960~3,600Arrowhead, few camerasDENSE_SCHUR8–15 ms
Pose graph, 5k poses30,00034,800Banded plus loop-closure spikesSparse Cholesky + COLAMD, dogleg40–150 ms
Global BA, 1k cameras330,000~1,000,000Arrowhead, many camerasITERATIVE_SCHUR
+ Schur-Jacobi
20–60 s
Incremental mappinggrowsgrows,
+6/edge
Bayes treeiSAM21–5 ms typical, 100–500 ms on a big loop closure

n is the number of columns of J (the state dimension, so Λ is n×n); m is the number of rows of J (the total residual dimension). Where every m comes from — because "n = 307" tells you the size of Λ and nothing about the work that builds it, and you cannot size a problem you can only half-count:

ProblemResidual rows, itemisedShape of JShape of Λ = JTWJ
Sliding-window VIO900 reprojection factors × 2 rows = 1,800; 9 IMU preintegration factors × 15 rows = 135; one marginalisation prior over the states it touches = 60. Total 1,995.1,995 × 307 (0.6% dense — a reprojection row touches 9 of the 307 columns)307 × 307
Local BA (10 KF, 300 points)300 points × ~6 observing keyframes × 2 rows = 3,6003,600 × 960960 × 960, arrowhead
Pose graph (5k poses)~5,800 relative-pose edges × 6 rows = 34,80034,800 × 30,000, 12 nonzeros per row30,000 × 30,000, 0.066% fill
Global BA (100k points)~500,000 observations × 2 rows = 1,000,000106 × 3.3×105330,000 × 330,000, arrowhead

Note that m > n in every row, which is the whole reason least squares is the right frame: you always have more equations than unknowns, and the ratio m/n (6.5 for the window, 3.75 for local BA, 1.16 for the pose graph, 3.0 for global BA) is a direct read on how over-constrained the problem is. A pose graph at m/n = 1.16 is the least redundant of the four, which is exactly why a single bad loop closure moves it so far.

The number to volunteer: a 307×307 dense Cholesky is 3073/3 ≈ 9.6 million flops — about 1 ms at 10 GFLOP/s on the reference core named above, and in practice 2–4 ms because the assembly and the memory traffic dominate at that size. Anyone who tells you the linear solve is the bottleneck in a sliding window has not profiled it; linearising 900 reprojection factors costs more than factoring the matrix they build.

Now back that claim with the second number, because on flops alone it looks false. Count one reprojection factor with analytic Jacobians: R pw + t is 9 multiplies and 9 adds = 18; the perspective divide and K are ~7; the weighted residual is ~8; the 2×3 ∂π/∂pc is ~10; chaining it into the 2×6 pose block is 36 multiplies and 24 adds = 60; the 2×3 point block is ~30. Call it ~180 flops per factor. So:

Work itemFlopsMeasured timeAchieved rate
900 reprojection factors, analytic J900 × 180 = 1.6 × 105part of the 6–10 ms row
9 IMU preintegration factors (15×30 blocks)9 × ~20,000 = 1.8 × 105part of the 6–10 ms row
1 dense marginalisation prior (60×307 matvec)~2 × 104part of the 6–10 ms row
Total linearisation3.6 × 1056–10 ms0.045 GFLOP/s
Dense Cholesky, 307×3079.6 × 106 (27× more)2–4 ms3.2 GFLOP/s
Read those two rows together and the claim stops being folklore. Linearisation does 27× fewer flops than the factorisation and still takes 3× longer — a 70× gap in achieved throughput. The cause is arithmetic intensity, not flop count. Cholesky's inner loop is a rank-k update: each double you load off the stack is reused ~n times, it vectorises, and it runs at 3.2 GFLOP/s. A reprojection factor loads a 3-double point, a 7-double pose and a 2-double measurement — about 100 scattered bytes — to do 180 flops, so its intensity is 1.8 flop/byte, and the loads are indirect (a pointer chase through parameter-block pointers), branchy (behind-camera checks, robust-kernel branch), and often behind a virtual call per residual block. With Ceres' Jet<double, 9> autodiff instead of analytic Jacobians the flop count rises ~10× to 1.6×106 and the wall time barely moves, which is the same fact from the other side: you are not flop-bound, you are latency-bound. That is why SymForce's flat, branch-free, allocation-free generated code wins 3–5× without changing a single mathematical operation.

Levenberg–Marquardt in thirty lines

python
import numpy as np

def lm(residual, jacobian, x0, iters=30, lam=1e-3, tol=1e-10):
    """residual(x) -> (m,) ; jacobian(x) -> (m, n). Marquardt damping."""
    x    = np.asarray(x0, dtype=float).copy()
    r    = residual(x)
    cost = 0.5 * r @ r
    nu   = 2.0                            # Nielsen's rejection multiplier — DOUBLES each reject
    for it in range(iters):
        J   = jacobian(x)
        Lam = J.T @ J                       # the information matrix
        eta = -J.T @ r
        D   = np.diag(np.maximum(np.diag(Lam), 1e-12))   # Marquardt: scale-invariant
        while True:
            try:
                L  = np.linalg.cholesky(Lam + lam * D)          # never inv()
                dx = np.linalg.solve(L.T, np.linalg.solve(L, eta))
            except np.linalg.LinAlgError:
                lam *= nu; nu *= 2; continue                     # not PD: damp harder
            r_new = residual(x + dx)
            c_new = 0.5 * r_new @ r_new
            # gain ratio: actual reduction / reduction the model PREDICTED
            pred  = 0.5 * (dx @ (lam * D @ dx + eta))
            rho   = (cost - c_new) / max(pred, 1e-300)
            if rho > 0:                                       # accept
                x, r, cost = x + dx, r_new, c_new
                lam = max(lam * max(1/3, 1 - (2*rho - 1)**3), 1e-12)
                nu  = 2.0                                    # reset the ratchet on every accept
                break
            lam *= nu; nu *= 2                              # reject: 2x, then 4x, then 8x…
            if lam > 1e12: return x, cost, it              # genuinely stuck
        if np.linalg.norm(dx) < tol: break
    return x, cost, it

Two lines worth defending. The rho line is the gain ratio — the ratio of the cost reduction you actually got to the one the quadratic model promised. Nielsen's update rule (the 1 - (2ρ-1)3 factor) is what Ceres uses; a rho near 1 means the model is trustworthy, so loosen the leash a lot. And the except LinAlgError branch is not defensive programming — a Cholesky failure is exactly the signal that the damped matrix is not positive definite, which means λ is too small.

Nielsen's rule is a pair, and half of it is the part people drop. The decrease factor above is only the accept half; the reject half is the nu ratchet — lam *= nu; nu *= 2, with nu reset to 2 on every accept. Ceres implements exactly this pair (see TrustRegionMinimizer: radius_ /= decrease_factor_ then decrease_factor_ *= 2). Quoting the accept half alone and pairing it with a bare lam *= 10, as an earlier draft of this code did, is a claim of fidelity to Ceres that the code does not have — and it is precisely the level of detail worth checking whenever you cite a library's default.

The two rules differ in a way you can put a number on. After k consecutive rejections from the same point:

Consecutive rejections kFlat λ ×= 10Nielsen λ ×= ν, ν ×= 2Which is more aggressive
1×10×2flat
2×100×8flat
3×1,000×64flat
4×104×1,024flat
5×105×32,768flat
6×106×2,097,152Nielsen — it crosses over here

The flat rule is geometric, 10k. Nielsen's is super-geometric, 2k(k+1)/2, so it is gentler for the first five rejections and then overtakes hard. That shape is the point: one unlucky step (a single outlier that just entered the window) barely moves the leash, so you do not throw away a good λ over noise; but a genuinely broken linearisation escalates out of the region in a handful of iterations instead of grinding. A flat 10× is neither patient nor decisive.

Three λ policies appear in this chapter, deliberately, and they are not the same rule. The hand table earlier uses a flat λ/10 on accept, ×10 on reject — chosen for legibility, because "100 → 10 → 1 → 0.1 → 0.01" is a column you can verify in your head and Nielsen's factors are not. The widget below the table steps λ by ±1 decade so the slider position stays readable. The code above implements Nielsen's actual pair. All three walk λ from "gradient descent" to "Gauss–Newton" as the model earns trust and all three escalate on rejection — they agree in behaviour and differ in constants. If you reproduce the hand table with the code, expect the same trajectory shape and different λ values in column five.

The analytic Jacobian that is subtly wrong

Field report: a new residual type was added by hand with hand-derived Jacobians. The solver still converges, the cost still goes down, and the map still looks right. But an optimisation that used to take 5 iterations now takes 180, and λ ends the run at 104 instead of 10−6.

SymptomWhat it means
λ climbs monotonically instead of fallingSteps are being rejected. The model is predicting reductions that do not happen — that is a Jacobian that does not match the residual.
Convergence is linear, not quadratic (error falls by a constant factor, not by squaring)The strongest signal. A correct Gauss–Newton near the solution roughly squares the error each step: 1e-2 → 1e-4 → 1e-8. Getting 1e-2 → 5e-3 → 2.5e-3 means your J is approximately right and exactly wrong.
The final cost is right but the covariance is nonsenseThe covariance comes from Λ = JTWJ. A wrong J can still find the right minimum by luck (LM will damp its way there) while reporting a completely fictional uncertainty.

The metric that reveals it: a finite-difference gradient check, and the threshold matters.

python
def check_jacobian(residual, jacobian, x, eps=1e-6):
    J  = jacobian(x)
    Jn = np.zeros_like(J)
    for k in range(len(x)):                          # CENTRAL differences: O(eps^2)
        dp = x.copy(); dp[k] += eps
        dm = x.copy(); dm[k] -= eps
        Jn[:, k] = (residual(dp) - residual(dm)) / (2 * eps)
    rel = np.abs(J - Jn).max() / max(1.0, np.abs(Jn).max())
    print(f"max relative error {rel:.3e}")
    return rel
# central differences at eps=1e-6 are good to ~1e-9, so:
#   rel < 1e-7   correct
#   rel ~ 1e-3   a sign flip, a missing chain-rule term, or degrees vs radians
#   rel ~ 1.0    wrong variable ordering, or a transpose

Ceres ships this as ceres::GradientChecker and will run it on every residual block if you set Solver::Options::check_gradients = true. GTSAM has gtsam::numericalDerivative11 and friends. Run it in a unit test on every new factor type, once, forever. A wrong Jacobian is the single most expensive bug in this codebase because it degrades gracefully — it never crashes, it just quietly makes everything worse.

The tell, in transferable form: a solver whose λ is climbing while the cost still decreases is not struggling with a hard problem. It is being lied to. Hard problems make λ oscillate; wrong Jacobians make it climb.

Where solvers are going

Your optimiser converges, but it takes 180 iterations where it used to take 5, and λ ends up at 104. First thing you check?

Chapter 5: VIO & IMU Preintegration

Your IMU runs at 200 hertz and your keyframes at four. So there are fifty samples between keyframes. Put them in the graph.

This is the problem where people who can draw a factor graph fall over, because the obvious answer — "add a factor per IMU sample" — is wrong for a reason that only becomes visible when you count the work.

Why the obvious answer fails: count the integrations

Suppose you add one variable per IMU sample. A ten-keyframe window then holds 10 × 50 = 500 states of 15 numbers each = 7,500 unknowns instead of 150. That alone kills it — 7,5003/3 = 1.4 × 1011 flops per iteration, about 14 seconds.

So instead you keep only the keyframe states and make the IMU produce a single constraint between consecutive keyframes. But now you hit the real problem. Integrating the IMU forward from keyframe i to keyframe j requires knowing the state at i:

pj = pi + viΔt + ½gΔt2 + ∑k [ Rk(ak − ba) … ]

Every term in that sum depends on Rk, the orientation at sample k, which depends on Ri. So the moment the optimiser adjusts pose i — which it does on every iteration — all fifty integration steps must be redone.

QuantityCount
IMU samples per keyframe interval50
Keyframe intervals in the window9
LM iterations per solve~6
Re-integrations per solve, naive50 × 9 × 6 = 2,700
With preintegration9 — computed once, on arrival
That is the entire motivation. Preintegration is not a clever numerical trick; it is a change of variables that makes the IMU constraint independent of the states it constrains, so it survives every relinearisation without being recomputed. Lupton & Sukkarieh proposed it in 2012; Forster, Carlone, Dellaert & Scaramuzza put it properly on the SO(3) manifold with correct uncertainty propagation (RSS 2015, T-RO 2017).

The change of variables, in words first

Write the integral in the body frame of the first keyframe rather than the world frame. That sentence is the summary; the algebra underneath it is four lines long, and you should be able to produce all four at a whiteboard, because "derive preintegration for me" is a question that gets asked exactly this way.

Derive it in four lines — do not memorise the boxes

Line 1 — the exact world-frame propagation. Nothing clever has happened yet. This is Newton's second law integrated over a window of N steps of length Δt, with Δtij = NΔt, in the world frame:

pj = pi + viΔtij + ½gΔtij2 + ∑k=ij−1 [ ΔvWikΔt + ½ Rk(ak − ba)Δt2 ]

where ΔvWik = ∑m<k Rm(am − ba)Δt is the world-frame velocity change accumulated inside the window. Gravity has already been pulled out front, and that step is worth doing once so you never have to think about it again: each of the N steps contributes ½gΔt2 directly plus gΔt of carried velocity to every later step, so the total is ½gΔt2[N + 2·N(N−1)/2] = ½g(NΔt)2 = ½gΔtij2. Exactly. Gravity is constant in the world frame, so it never needed to be inside the sum at all. (Notice this is the same two-term structure — carried velocity plus a half-acceleration term — as the hand-worked table further down. The derivation and the arithmetic are the same object.)

Line 2 — chain the rotations and factor Ri out. Every orientation inside the window is the keyframe orientation followed by whatever the gyro has measured since:

Rk = Ri ΔRik,    ΔRik = ∏m=ik−1 Exp( (ωm − bg)Δt )

ΔRik is built from gyro samples and the gyro bias and nothing else. Substitute it everywhere Rk appears. Ri carries no k, so it comes straight out of the sum on the left:

k [ ΔvWikΔt + ½ RiΔRik(ak−ba)Δt2 ] = Rik [ ΔvikΔt + ½ ΔRik(ak−ba)Δt2 ]

with Δvik = RiTΔvWik = ∑m<kΔRim(am−ba)Δt, the same velocity change expressed in frame i.

Line 3 — this is the whole trick. Left-multiply both sides by RiT. On the right, RiTRi = I and the pose vanishes:

RiT( pj − pi − viΔtij − ½gΔtij2 ) = ∑k [ ΔvikΔt + ½ ΔRik(ak−ba)Δt2 ]

Line 4 — name the right-hand side and read off the residual. Call the right-hand side Δpij. Every symbol in it is a raw measurement or a bias. Every symbol that depends on where the robot actually is — pi, vi, Ri, g — is trapped on the left. Subtract Δpij from both sides and the equation becomes a residual that is zero when the states agree with the measurements:

rΔp = RiT( pj − pi − viΔtij − ½gΔtij2 ) − Δpij = 0
The line to reproduce under pressure is line 3. If all you retain is "left-multiply by RiT so the Ri that factored out of the sum cancels", you can rebuild the other three from Newton on the spot. The other two deltas are the same derivation at different depths: Δv is line 1 stopped one integration earlier (vj = vi + gΔtij + RiΔvij ⇒ rΔv = RiT(vj − vi − gΔtij) − Δvij), and ΔR is one line with no integration at all: Rj = RiΔRij ⇒ ΔRij = RiTRj, so rΔR = Log( ΔRijT RiT Rj ). The rotation residual lives in the Lie algebra because a difference of rotation matrices is not a rotation; Log maps the discrepancy back to a 3-vector you can put in a least-squares cost.

Which gives the three preintegrated deltas in their usual boxed form:

ΔRij = ∏k=ij−1 Exp( (ωk − bg) Δt )
Δvij = ∑k ΔRik (ak − ba) Δt
Δpij = ∑k [ Δvik Δt + ½ ΔRik (ak − ba) Δt2 ]

Notice what is not in those expressions: pi, vi, Ri, and gravity. The deltas depend only on the raw measurements and the biases. The optimiser can move the keyframe states as much as it likes; the deltas are unaffected.

Gravity and the initial state re-enter only when the factor is evaluated — which is line 4 of the derivation, read right to left:

rΔp = RiT( pj − pi − viΔt − ½gΔt2 ) − Δpij

That residual is cheap: a few matrix-vector products. It is the sum over fifty samples that you avoided. And it is worth saying out loud that this is not a new equation anyone had to invent — it is the world-frame propagation you wrote in line 1, rearranged so the measurement-only part sits on one side and the state-only part sits on the other. Every factor in this whole lesson has that shape. The art was noticing that the rearrangement was possible.

Worked example: preintegrate four samples, entirely by hand

Drop to one dimension — no rotation, no gravity — so the arithmetic is visible. Four accelerometer samples at Δt = 0.01 s, with an accelerometer bias estimate ba = 0.10 m/s2:

ameas = (1.00, 1.20, 0.80, 1.00) m/s2  →  a − b = (0.90, 1.10, 0.70, 0.90)

The velocity delta is a plain sum:

Δv = (0.90 + 1.10 + 0.70 + 0.90) × 0.01 = 3.60 × 0.01 = 0.036 m/s

The position delta needs care, because each step carries the velocity accumulated so far. Step k contributes ΔvkΔt + ½(ak−b)Δt2, where Δvk is the delta-v before that sample:

kΔv beforeΔvk·Δt½(a−b)Δt2contributionΔv after
00.0000.000000½(0.90)(0.0001) = 0.0000450.0000450.009
10.0090.000090½(1.10)(0.0001) = 0.0000550.0001450.020
20.0200.000200½(0.70)(0.0001) = 0.0000350.0002350.027
30.0270.000270½(0.90)(0.0001) = 0.0000450.0003150.036 ✓
Δp total0.000740 m

Now prove it is reusable. Take an initial state p0 = 5.0 m, v0 = 2.0 m/s, over T = 4 × 0.01 = 0.04 s.

p1 = p0 + v0T + Δp = 5.0 + 2.0(0.04) + 0.00074 = 5.0 + 0.08 + 0.00074 = 5.08074 m

Direct step-by-step integration from (5.0, 2.0) gives 5.08074000. Identical. Now change the initial state to p0 = 9.0, v0 = −1.0 without touching the deltas:

p1 = 9.0 + (−1.0)(0.04) + 0.00074 = 9.0 − 0.04 + 0.00074 = 8.96074 m

Direct integration gives 8.96074000. Zero re-integration, exact answer. That is the whole idea, and you can demonstrate it on a whiteboard in ninety seconds.

The catch: the deltas do depend on the bias

Look back at the definitions. ba and bg are inside the sums. And the bias is a state the optimiser estimates — it will change on every iteration. If a bias change forced a re-integration, you would be back where you started.

The fix is a first-order Taylor expansion in the bias, using Jacobians accumulated during the same integration pass:

Δv(b + δb) ≈ Δv(b) + (∂Δv/∂ba) δba + (∂Δv/∂bg) δbg

Compute the Jacobian for our example. Each sample subtracts b once, and each contributes Δt of velocity, so:

∂Δv/∂ba = −∑k Δt = −4(0.01) = −0.04  (= −T, exactly)

For position, each sample's bias error contributes both a direct ½Δt2 and the accumulated velocity error times Δt:

∂Δp/∂ba = −[ 0.00005 + 0.00015 + 0.00025 + 0.00035 ] = −0.0008

Check that against the closed form: for constant Δt the answer must be −½T2 = −½(0.04)2 = −0.0008. ✓ That cross-check is exactly the kind of thing to do out loud.

Apply a bias update of δba = +0.02 m/s2:

Δv' = 0.036 + (−0.04)(0.02) = 0.036 − 0.0008 = 0.0352 m/s
Δp' = 0.00074 + (−0.0008)(0.02) = 0.00074 − 0.000016 = 0.000724 m

Full re-integration with b = 0.12 gives Δv = 0.0352 and Δp = 0.000724. Exactly equal — because in one dimension without rotation the bias enters linearly, so the first-order correction is not an approximation at all.

Where it stops being exact, and this is the follow-up. On SO(3) the gyro bias enters through a matrix exponential: ΔR = ∏ Exp((ωk − bg)Δt). That is genuinely nonlinear, so the first-order correction is valid only for small δbg. VINS-Mono and OKVIS both re-propagate from raw samples when the gyro bias estimate moves more than a threshold on the order of 0.01 rad/s (about 0.57°/s). The accelerometer bias enters Δv and Δp linearly given ΔR, so it is far more forgiving.

Worked example 3: the same correction on SO(3), where it is not exact

Everything above lives in the regime where the answer is exact by construction, so it proves the mechanism and nothing about the threshold. Citing "0.01 rad/s" without a number behind it is trivia. So here is the rotation arithmetic, done the same way — by hand, every entry — and then the closed form it hands you, which is the thing you actually want to carry away.

Setup. Two gyro samples, constant rate ω = (0, 0, 2.0) rad/s about the body z axis, Δt = 0.01 s, so T = 0.02 s. Current gyro bias estimate bg = 0. The optimiser hands back a bias change δbg = (0.02, 0, 0) rad/s.

Read the axes before the arithmetic. δbg is on x, and ω is on z. That is deliberate, and it is the first thing to say. If the bias error were on the same axis as the rotation, the two exponentials would commute, the product would collapse to Exp(ẑ(ω − δb)T), and the first-order correction would be exact to machine precision — we checked, and the residual is 0.0 by construction, not 10−16. All of the nonlinearity in gyro-bias correction is non-commutativity. Saying "SO(3) is nonlinear so it is approximate" says nothing; saying "the error is exactly the part of δbg that does not commute with the rotation" says the whole thing.

Step 1: the incremental rotation, all nine entries. The per-step rotation vector is φ = (ω − bg)Δt = (0, 0, 0.02), so ‖φ‖ = 0.02 rad. Rodrigues with K = hat(φ/‖φ‖) = hat(ẑ):

Exp(φ) = I + sin(0.02)·K + (1 − cos 0.02)·K2,   sin 0.02 = 0.019998667,   cos 0.02 = 0.999800007
Exp(φ)col 1col 2col 3
row 10.999800007−0.0199986670
row 20.0199986670.9998000070
row 3001

Both steps are identical because ω is constant, so ΔR(bg) = Exp(φ)2 = Rz(0.04): cos 0.04 = 0.999200107, sin 0.04 = 0.039989334.

ΔR(bg)col 1col 2col 3
row 10.999200107−0.0399893340
row 20.0399893340.9992001070
row 3001

Step 2: the right Jacobian of SO(3). This is the object that does not exist in the 1-D story, and it is where the whole difference lives. Jr(φ) is defined by Exp(φ + δφ) ≈ Exp(φ)·Exp(Jr(φ)δφ) — "how does a small nudge to the rotation vector show up as a small rotation applied on the right of the result":

Jr(φ) = I − [(1 − cos t)/t2] hat(φ) + [(t − sin t)/t3] hat(φ)2,   t = ‖φ‖

At t = 0.02 the two scalar coefficients are (1 − cos t)/t2 = 0.499983333 and (t − sin t)/t3 = 0.166663333 (the series ½ − t2/24 and 1/6 − t2/120 — which is also the small-angle branch you must code, because both expressions are 0/0 at t = 0). With hat(φ) having entries ±0.02 and hat(φ)2 = diag(−0.0004, −0.0004, 0):

Jr(φ)col 1col 2col 3
row 11 − 0.0000667 = 0.999933335+0.499983·0.02 = 0.0099996670
row 2−0.499983·0.02 = −0.0099996670.9999333350
row 3001

Step 3: accumulate the gyro-bias Jacobian. The recursion is J ← ΔRk,k+1T J − Jr(φ)Δt. Starting from J = 0:

After step 0: J = −Jr(φ)(0.01) = rows (−0.009999333, −0.000099997, 0), (+0.000099997, −0.009999333, 0), (0, 0, −0.01).

After step 1: J = Exp(φ)T·Jstep0 − Jr(φ)(0.01), giving

JΔRgcol 1col 2col 3
row 1−0.019994667−0.0003999470
row 20.000399947−0.0199946670
row 300−0.02

Sanity-check the (3,3) entry against the flat case: −0.02 = −T, exactly the accelerometer answer, because the z axis is the rotation axis and along it nothing rotates. The off-diagonal −0.000399947 terms are the entire SO(3) correction, and they are three orders of magnitude smaller — which already tells you the answer is going to be small.

Step 4: apply the correction. JΔRg·δbg is the first column times 0.02 = (−0.000399893, +0.0000079989, 0). Exponentiate that tiny vector (‖·‖ = 4.0×10−4) and right-multiply:

ΔRfirst = ΔR(bg)Exp(Jδbg)col 1col 2col 3
row 10.9992001066930−0.0399893325873−0.0000079989332
row 20.03998933258730.99920002670360.0003998933312
row 3−0.0000079989332−0.00039989333120.9999999200107

Step 5: the ground truth. Re-integrate from raw samples at the new bias. ψ = (ω − δbg)Δt = (−0.0002, 0, 0.02), ‖ψ‖ = 0.020001000, and ΔRexact = Exp(ψ)2:

ΔRexactcol 1col 2col 3
row 10.9992001066716−0.0399893331201−0.0000079989333
row 20.03998933312010.99920002668230.0003998933312
row 3−0.0000079989333−0.00039989333120.9999999200107

Step 6: the residual angle. Compare the two the only way rotations may be compared — compose one with the inverse of the other and take the Log. Seven of the nine entries agree to eleven decimals; the disagreement is entirely in the (1,2)/(2,1) pair, and it is 5.33×10−10:

ΔRfirstTΔRexact = I + hat( (0, 0, 5.3329×10−10) )  ⇒  ‖Log(·)‖ = 5.3329×10−10 rad = 3.06×10−8 degrees

Now repeat at δbg = 0.2 rad/s — ten times larger, twenty times VINS-Mono's threshold. Same machinery, and the residual comes out at 5.3329×10−8 rad = 3.06×10−6 degrees. Exactly one hundred times bigger for a ten-times-bigger bias step. That factor of 100 is not a coincidence and it is the most useful thing on this page: the first-order correction is correct to first order by construction, so the residual is second order in δbg. It is not "approximately linear"; the linear part is exact, and what is left is a clean quadratic.

The closed form, so you can defend the threshold instead of quoting it

Run that arithmetic over a whole family of ω, T and δbg (the widget below does exactly this, and it was checked against 75 numerically integrated cases with the worst disagreement under 1%) and the residual collapses onto a formula. For a constant rate ω with the bias change perpendicular to it, writing Θ = ωT for the total rotation swept in the window:

‖Log(ΔRfirstTΔRexact)‖ = [ (Θ − sin Θ) / (2Θ2) ] · ( ‖δbg,⊥‖ T )2

and for a window short enough that Θ is at most about 1 rad — which is every keyframe interval — the bracket is just Θ/12, so:

residual ≈ 112 ω ‖δbg,⊥2 T3

Check it against the hand example. Θ = 2.0 × 0.02 = 0.04, so (Θ − sinΘ)/(2Θ2) = (0.04 − 0.039989334)/(0.0032) = 1.0666×10−5/0.0032 = 3.3333×10−3, and (δb·T)2 = (0.02×0.02)2 = 1.6×10−7. Product = 5.333×10−10 rad, against the 5.3329×10−10 we integrated. ✓

Three exponents, and they are the whole answer to "when do you re-propagate": quadratic in the bias step, linear in the rotation rate, and cubic in the window length. The window is the dangerous one, because it is the term nobody thinks about.

And one non-monotonicity worth having ready, because the widget shows it and it looks like a bug. Push the gyro rate past the point where Θ = ωT exceeds a half-turn and the error starts coming down again: at T = 2 s the residual at δbg = 0.03 is 0.033° at 90°/s but only 0.016° at 180°/s. That is real, not a numerical artefact. The coefficient (Θ − sinΘ)/(2Θ2) has a genuine maximum, and differentiating it gives 2sinΘ − ΘcosΘ − Θ = 0, which Θ = π satisfies exactly (−π(−1) + 0 − π = 0). The peak value is (π − 0)/(2π2) = 1/(2π) ≈ 0.159. Physically: over a full half-turn the non-commuting parts of the bias error begin to cancel against themselves, and by Θ = 2π the coefficient has halved. Worst case for the first-order correction is a half-turn inside one window — not the fastest rotation, the half-turn. That sentence is a genuine "I have looked at this" signal, and the arithmetic behind it is two lines.
SituationTωδbgΔR errorSpecific force it leaks (g·ε)Position over the window
One keyframe interval, at threshold0.25 s90°/s0.012.03×10−7 rad = 0.000012°2.0×10−6 m/s26×10−8 m
One keyframe interval, 5× over threshold0.25 s90°/s0.055.07×10−6 rad = 0.00029°5.0×10−5 m/s21.6×10−6 m
Initialisation window, slow motion5 s30°/s0.021.54×10−3 rad = 0.089°0.0152 m/s20.189 m
Initialisation window, fast motion5 s90°/s0.031.25×10−3 rad = 0.072°0.0123 m/s20.153 m

Read the "specific force it leaks" column, not the "ΔR error" one. Nobody cares about a hundredth of a degree in the abstract. A tilt error ε in ΔR rotates gravity into the horizontal channel of the specific force, leaking g·sinε ≈ 9.81ε m/s2 of fake acceleration into Δv and Δp. So there is a natural, physical criterion for "when does the truncation matter", and it needs no citation at all:

The criterion, derivable in ten seconds at a whiteboard: the ΔR truncation error stops being harmless the moment the specific force it leaks exceeds the accelerometer bias you are already estimating. For a decent consumer MEMS at ba = 0.01 m/s2, that is ε* = 0.01/9.81 = 1.02×10−3 rad = 0.058°. Everything else is algebra: set the closed form equal to ε* and solve for δbg.
Window Tωδbg that reaches ε* = 1.02 mradvs the 0.01 rad/s rule
0.25 s (one keyframe interval)90°/s0.709 rad/s71× slack — the rule never binds here
1 s90°/s0.094 rad/s9× slack
2 s90°/s0.040 rad/s4× slack
5 s (initialisation, or a stationary stretch)30°/s0.016 rad/s1.6× — this is where the rule bites
So here is the answer that beats reciting the number. "The 0.01 rad/s re-propagation threshold is not a keyframe-interval guard — at a quarter of a second you would need seventy times that much bias error before ΔR mattered. It is an initialisation guard. The residual is cubic in the window, and initialisation is exactly when the windows are seconds long and the gyro bias estimate makes its one large jump from zero to its true value. Set the closed form equal to the tilt that leaks one accelerometer bias, and you land on 0.016 rad/s for a five-second window — so 0.01 is that number with about 1.6× of margin, which is what you would pick yourself." That is a defended number. Two caveats to keep it honest: the closed form assumes a constant rotation axis (a real trajectory with a time-varying axis moves the coefficient by a few tens of percent — we checked, and a 2 rad/s wobble superimposed on 90°/s changed it by under 8%), and production stacks accumulate these Jacobians through a linearised error-state transition matrix rather than the analytic recursion, which loses another order or two. Both push the same way: keep the margin.
And the reason it is worth knowing all of this precisely: the natural follow-up is "so why not just always re-propagate?" The honest answer is a cost one. Re-propagation is 50 samples × 9 intervals of Exp, Jr and three matrix products — roughly the 2,700-integration bill from the top of this chapter, back again, on every iteration where the bias moved. Preintegration bought you that; a threshold is how you keep the purchase while staying correct in the one regime where first order is not good enough.
One delta, many states — and a bias that moves

These are the same four samples you just integrated by hand — (1.00, 1.20, 0.80, 1.00) m/s2 at 100 Hz, ba = 0.10, so the readout below should say Δv = 0.036000 and Δp = 0.00074000 the moment the page loads. The bars are the bias-corrected specific force. Move the initial velocity and watch the three reconstructed trajectories shift while the delta stays fixed. Then move the bias and compare the first-order correction against a full re-integration.

initial v₀ (m/s) 2.00
bias change δb 0.00

Two things to notice before moving on. The three curves are parallel: one delta, three initial states, and the shape never changes — that is the independence from line 3 of the derivation, drawn. And the first-order/re-integration difference stays at machine epsilon for every position of the bias slider, including ±0.5, because in one dimension the bias enters linearly and "first order" is not an approximation at all. To see the correction actually fail you need rotation, which is a different widget, because it is a different phenomenon.

The SO(3) branch — how wrong is first order, really?

The curve is the residual angle ‖Log(ΔRfirstTΔRexact)‖ in degrees, swept over the gyro-bias change δbg. Both rotations are built with the real machinery — Rodrigues, the right Jacobian, the transpose-and-accumulate recursion, and Log — at 200 Hz over the window you choose. It is the hand calculation above, swept. δbg is applied perpendicular to the rotation axis, because a parallel one commutes and gives exactly zero. The dashed vertical rule is VINS-Mono's 0.01 rad/s re-propagation threshold; the dotted horizontal rule is the tilt that leaks one accelerometer bias (0.058°). The pale second curve is the closed form — it should sit on top of the integrated one.

gyro rate ω (°/s) 90
window T (s) 2.00
gyro-bias change δbg (rad/s) 0.010

Drive it in this order, and read the numbers — they are the argument.

  1. Pull T down to 0.25 s (one keyframe interval) and push δbg to the far right, 0.05 rad/s — five times VINS-Mono's threshold. The curve flattens onto the axis and the ε* rule leaves the top of the plot entirely. The readout says 2.9×10−4 degrees, which is 200× below the tilt that would leak a single accelerometer bias. A keyframe interval does not care about the gyro bias step. At all.
  2. Put δbg back to 0.01 and drag T from 0.25 s to 5 s. The residual goes 1.16×10−5° → 7.96×10−3°, a lift of 684× for a 20× longer window. From 0.25 s to 1 s it is 57× against the cubic law's 64× — the T3 term, visible — and then it flattens off, because Θ = ωT has passed a radian and the (Θ − sinΘ)/(2Θ2) coefficient stops growing. Two behaviours, one formula, both on the screen.
  3. Now find the crossing that actually matters. Set δbg = 0.05 and walk T up: 1.00 s gives 0.017°, 1.50 s gives 0.048°, and at 1.75 s the curve crosses the dotted 0.058° rule and the verdict flips to RE-PROPAGATE. Above that line the truncation is injecting more fake specific force than the accelerometer bias you are estimating, so the bias state absorbs it and every residual stays healthy while the estimate quietly degrades.

The dotted horizontal rule — not the dashed vertical one — is the real threshold. The vertical rule at 0.01 rad/s is a fixed heuristic with no knowledge of your window length; the horizontal rule is physics, and it moves the answer by more than an order of magnitude across the T slider. The verdict line under the plot tells you which of the two you are on the wrong side of, and when they disagree it says so, because "the rule fires but the arithmetic says you are fine" is the honest state of a 0.25 s keyframe at 0.02 rad/s — and being able to say that out loud, with the factor, is the difference between reciting a threshold and owning one.

Observability: what VIO can and cannot know

The other half of understanding VIO. Monocular visual-inertial odometry has exactly four unobservable degrees of freedom, and being able to list them — and say why the others are not on the list — is the whole story.

QuantityObservable?Why
Global position (x, y, z)No — 3 DOFNothing measures absolute position. All measurements are relative.
Yaw about gravityNo — 1 DOFGravity fixes two rotational directions; rotation about gravity leaves every measurement unchanged.
Roll and pitchYesThe accelerometer measures specific force, and at rest that is −g. Gravity is an absolute vertical reference.
Metric scaleYes — with excitationThe accelerometer is metric. Double-integrating it gives metres, which anchors the vision system's arbitrary scale.
Accelerometer and gyro biasesYes — with excitationEstimated as states. But see the degeneracies below.

The degeneracies are where it gets interesting. "With excitation" is doing a lot of work in that table.

MotionWhat becomes unobservableWhy
Constant velocity (a car on a motorway)Scale, and accelerometer biasSpecific force is constant, so a scale error and a bias error produce identical measurements. Nothing can separate them.
Constant accelerationAccelerometer bias vs gravity directionA constant bias and a mis-estimated gravity vector are the same signal.
Pure rotationAll depth / structureNo baseline, so no parallax, so no triangulation. Features move but carry no depth information.
Hover / stationaryScale, velocitySame as constant velocity, with v = 0.
The number to attach: scale observability comes from the variation in specific force, not its magnitude. A practical online check is the standard deviation of ‖ameas‖ − g over the window. Below about 0.05 m/s2, scale is effectively unobservable and you should stop trusting it — lock it, or bring in a wheel-odometry or GNSS factor.

The inertial half of the stack, in numbers

ItemValueConsequence
IMU rate200 Hz5 ms per sample. Preintegration must complete inside the callback: ~2 µs of work, 0.4 ms/s of CPU total. Trivially affordable.
IMU message size6 floats + timestamp = 28 B5.6 kB/s. Never the bottleneck.
ImuFactor (the one coded below)9-dim residual (ΔR, Δv, Δp), 9×9 covariance81 doubles = 648 B per factor. Nine of them in the window = 5.8 kB.
… plus its bias between-factor6-dim residual (ba, bg), 6×6 covariance36 doubles = 288 B. A separate factor, one per interval.
CombinedImuFactor (the alternative)15-dim residual (9 preintegration + 6 bias random walk), 15×15 covariance225 doubles = 1.8 kB per factor. One factor instead of two.
Gyro bias instability10 °/h = 0.0028 °/sOver a 0.25 s keyframe interval: 0.0007°. Negligible within an interval, which is why the deltas are trustworthy; it matters across the window, which is why bias is a state.
Accel bias0.01 m/s2 (a decent consumer MEMS)Over 5 s of unaided integration: ½(0.01)(25) = 12.5 cm of position error. That number is why VIO needs a camera and why the camera needs to keep up.
Camera–IMU time offset td1–30 ms typical, often driftingAt 1 m/s, a 5 ms error is 5 mm. At 10 m/s it is 5 cm. This is why VINS-Mono estimates td online as a state.

Know which of those two factors you are describing. The code below builds gtsam.ImuFactor — a 9-dimensional residual with a 9×9 information matrix — and then adds the bias random walk as a separate BetweenFactorConstantBias. It is explicitly not the combined form. Quoting "15×15" while pointing at an ImuFactor is the kind of half-remembered detail that undermines everything after it, because a 9-dimensional residual cannot carry a 15×15 information matrix; the dimensions have to agree.

Why pick the split form? Because the two noise models have nothing to do with each other and you will want to tune them independently. The preintegration covariance comes from the gyro and accelerometer white noise densities and is propagated by the integration itself; the bias covariance comes from the random-walk densities and grows as √Δt. Keeping them in separate factors means you can loosen the bias walk on a thermally noisy unit without touching the measurement model at all — and when the bias estimate misbehaves, you can drop the bias factor's sigma to zero and re-run to see whether the bias state was the sink for your error. Pick CombinedImuFactor when you want the correlation between the preintegration error and the bias error carried explicitly (it is real, and the split form throws it away), or when factor count itself is the cost — on a very long window, halving the number of factors halves the graph-construction overhead. GTSAM's own examples default to the combined form; VINS-Mono, which is not GTSAM, is closer in spirit to the split one.

The dataflow, with the rate on every arrow. IMU callback at 200 Hz → append to the current preintegration accumulator (2 µs) → also propagate the published state forward (10 µs) → publish odometry at 200 Hz. Image callback at 20 Hz → track features (8 ms) → keyframe test → if yes, close the accumulator into a factor and hand the window to the optimiser (40–80 ms, own thread) → on completion, reset the propagation origin to the new optimised state.

The accumulator, then the library

python
import numpy as np

def hat(w): return np.array([[0,-w[2],w[1]],[w[2],0,-w[0]],[-w[1],w[0],0]])

def Exp(w):
    th = np.linalg.norm(w)
    if th < 1e-9: return np.eye(3) + hat(w)          # first order near zero
    K = hat(w / th)
    return np.eye(3) + np.sin(th)*K + (1-np.cos(th))*K@K   # Rodrigues

def rightJacobian(w):
    """Jr(w): Exp(w + dw) ~= Exp(w) @ Exp(Jr(w) @ dw). The object that makes the
    gyro-bias Jacobian differ from the flat accelerometer one."""
    t = np.linalg.norm(w); W = hat(w)
    if t < 1e-8: return np.eye(3)      # both coefficients below are 0/0 at t=0
    return (np.eye(3)
            - ((1 - np.cos(t)) / t**2) * W          # -> 1/2 - t^2/24
            + ((t - np.sin(t)) / t**3) * (W @ W))    # -> 1/6 - t^2/120

class Preintegrator:
    def __init__(self, ba, bg):
        self.ba, self.bg = ba.copy(), bg.copy()   # the bias this delta was built AT
        self.dR, self.dv, self.dp, self.dt = np.eye(3), np.zeros(3), np.zeros(3), 0.0
        self.JR_bg = np.zeros((3,3))       # d(dR)/d(bg)
        self.Jv_ba = np.zeros((3,3)); self.Jp_ba = np.zeros((3,3))
        self.Jv_bg = np.zeros((3,3)); self.Jp_bg = np.zeros((3,3))

    def add(self, a, w, dt):
        f  = a - self.ba
        # ORDER MATTERS: p uses the v from BEFORE this sample, v uses the R from before
        self.dp    += self.dv*dt + 0.5*self.dR@f*dt*dt
        self.Jp_ba += self.Jv_ba*dt - 0.5*self.dR*dt*dt
        self.Jp_bg += self.Jv_bg*dt - 0.5*self.dR@hat(f)@self.JR_bg*dt*dt
        self.dv    += self.dR@f*dt
        self.Jv_ba += -self.dR*dt
        self.Jv_bg += -self.dR@hat(f)@self.JR_bg*dt   # gyro bias reaches v THROUGH dR
        # --- the rotation and its bias Jacobian, LAST, using this step's increment ---
        phi = (w - self.bg)*dt
        dRk = Exp(phi)
        self.JR_bg  = dRk.T @ self.JR_bg - rightJacobian(phi)*dt
        self.dR     = self.dR @ dRk
        self.dt    += dt

    def corrected(self, ba_new, bg_new):
        """First-order bias update. Cheap. Valid only for SMALL dbg."""
        dba, dbg = ba_new - self.ba, bg_new - self.bg
        if np.linalg.norm(dbg) > 0.01:              # ~0.57 deg/s
            raise ValueError("gyro bias moved too far - re-propagate from raw samples")
        return (self.dR @ Exp(self.JR_bg @ dbg),
                self.dv + self.Jv_ba @ dba + self.Jv_bg @ dbg,
                self.dp + self.Jp_ba @ dba + self.Jp_bg @ dbg)

# The test that catches the bug this code used to have. Perturb the bias, correct
# first-order, and compare against a full re-integration at the new bias. If JR_bg
# is never accumulated the rotation correction is a silent no-op and the error stays
# FIRST order in db; with the recursion above it drops to SECOND order:
#   db scale   1e-4     1e-3     1e-2
#   correct    2.5e-11  2.5e-9   2.5e-7    <- x100 per decade  (second order)
#   broken     3.7e-5   3.7e-4   3.7e-3    <- x10  per decade  (first order)
# Assert the SLOPE, not a magnitude. A no-op correction still looks small.
The two lines that matter, and why they look strange. The accelerometer Jacobians accumulate flatly — Jv_ba += -dR*dt just piles up terms, because the bias sits in a vector sum and the derivative of a sum is a sum. The gyro one cannot, because the bias sits inside a product of matrix exponentials: perturbing sample k does not add to the answer, it gets rotated by every sample after it. That is what JR_bg = dRk.T @ JR_bg - Jr(phi)*dt encodes — the transpose pulls the accumulated derivative back into the current step's frame before the new term is added, so by the end every historical contribution has been carried through all the rotation that happened since. The second sentence to have ready: the same non-commutativity is why Jv_bg and Jp_bg exist at all. The gyro bias reaches velocity and position only by tilting ΔR, which is why those recursions carry dR @ hat(f) @ JR_bg — a rotation error times the specific force is a fake acceleration, which is exactly the g·ε leak the previous section put a number on.
Ship this with the test, not with a comment. A missing JR_bg accumulation is the perfect silent bug: corrected() returns dR @ Exp(0) = dR, no exception, no NaN, residuals still inside their band, and the estimator merely drifts slightly worse than it should. It is the same failure class as the wrong Jacobian in Chapter 4 — it degrades gracefully, so nothing ever crashes and nobody ever looks. The finite-difference-slope test above is four lines and it is the only thing that catches it.

The GTSAM form, where the same object is a factor:

python
import gtsam
params = gtsam.PreintegrationParams.MakeSharedU(9.81)      # U = z-up
params.setAccelerometerCovariance(np.eye(3) * 0.01**2)
params.setGyroscopeCovariance(np.eye(3) * 0.0017**2)
params.setIntegrationCovariance(np.eye(3) * 1e-8)          # discretisation error

pim = gtsam.PreintegratedImuMeasurements(params, bias_estimate)
for (a, w, dt) in imu_batch:
    pim.integrateMeasurement(a, w, dt)                        # once, on arrival

graph.add(gtsam.ImuFactor(X(i), V(i), X(j), V(j), B(i), pim))
graph.add(gtsam.BetweenFactorConstantBias(B(i), B(j),
          gtsam.imuBias.ConstantBias(),
          gtsam.noiseModel.Isotropic.Sigma(6, sigma_bias_rw * np.sqrt(dt_total))))
# ^ the bias RANDOM WALK factor. Forgetting it lets the bias jump arbitrarily
#   between keyframes, which absorbs real motion and destroys scale.
Two things to say while writing that. First: ImuFactor is a five-variable factor — two poses, two velocities, one bias. That is what a fill-in-heavy variable looks like in a real graph, and it is why marginalising a keyframe in VIO produces a prior over so many states. Second: the bias random-walk factor's sigma must scale as √Δt, because a random walk's variance grows linearly in time. Getting that wrong is a silent, slow bug.

Scale that collapses on a motorway

Field report: a VIO stack on a delivery vehicle. In the city it is excellent, under 0.5% drift. On a straight motorway segment the estimated scale drifts by 8% over four minutes and the accelerometer bias estimate wanders to an implausible 0.4 m/s2. Reprojection errors stay at 0.35 px throughout. No warnings.

SymptomReadingWhat it rules out
Reprojection error0.35 px RMS, flatNot a calibration or tracking failure — the vision half is fitting perfectly.
Accel bias estimateWanders to 0.4 m/s2 (datasheet says < 0.05)The tell. The optimiser is putting real motion into the bias state because nothing distinguishes them.
Std. dev. of ‖a‖ − g over the window0.02 m/s2 (city: 1.8)Confirms it. There is no excitation, so scale and bias are the same direction in state space.
IMU factor residualsInside their bandNot a preintegration bug. The constraint is satisfied — by the wrong state.

The metric that reveals it, and the value that distinguishes it from its nearest decoy:

python
# Excitation monitor - runs online, needs no ground truth.
exc = np.std(np.linalg.norm(acc_window, axis=1) - 9.81)
#   > 0.5  m/s^2  : healthy, scale well constrained
#   0.05-0.5      : marginal, widen the scale prior
#   < 0.05        : scale UNOBSERVABLE - freeze it or bring in another sensor

# The nearest decoy is "too few features", which looks similar in the
# trajectory but has the OPPOSITE covariance signature:
#   too few features  -> position covariance GROWS, feature count falls
#   no excitation     -> position covariance looks fine, only SCALE degrades,
#                        and the accel bias state absorbs the error
The transferable tell: when a state estimate wanders to a physically implausible value while every residual stays healthy, that state is unobservable in the current motion and is acting as a sink for error. Check the bias estimate against its datasheet before you check anything else — a bias eight times its specification is not a bias, it is the optimiser telling you the problem is degenerate.

Inertial estimation is moving fast

Why can you not just add a factor between every pair of consecutive IMU samples and let the optimiser sort it out?

Options 0 and 1 are each half right, and half is the usual answer. Say only the state dimension and the follow-up is "fine, then keep only keyframe states — now what?"; say only the relinearisation and you have skipped the reason the naive graph is unsolvable before relinearisation ever runs. Option 2 is true of a poorly-built factor but it is not why per-sample factors are rejected: the correlation is handled by preintegrating the covariance alongside the deltas, which the ImuFactor does.

Transfer test: your keyframe rate drops from 4 Hz to 1 Hz on a long straight motorway stretch — the frontend sees little parallax so it stops promoting keyframes. Which of these degrades first, and what metric shows it?

Option 1 is the trap for anyone who has just learned the T3 law and is looking for somewhere to use it: the law is cubic in T but linear in ω, and a motorway straight is precisely where ω ≈ 0, so the coefficient it multiplies is nearly zero. The keyframe interval quadrupling does nothing here. Option 3 is real and will follow, but it is a consequence, not the first failure, and it will show up as a slow rise in reprojection residual rather than a silent one. Option 2 has the sign backwards — fewer keyframes is a smaller graph, and the ordering does not care about the rate. Option 0 is first because the degeneracy is instant: the moment specific force stops varying, scale and accel bias are the same direction in state space, and nothing in the residuals will tell you — which is exactly the field report in the motorway debugging section above, arriving by a different road.

Chapter 6: The Sparsity Inspector

Everything in the last five chapters comes down to one number that nobody ever plots: the number of nonzeros in the Cholesky factor. Not in the matrix — in the factor. It is what you actually pay for, it is entirely determined by the elimination ordering, and it can differ by three orders of magnitude between two orderings of the same graph.

This chapter is an instrument for feeling that. Build a pose graph, choose an ordering, and step the elimination forward one variable at a time. Watch the fill-in edges appear in the graph and the fill-in entries appear in the factor. The flop counter at the bottom is the bill.

Read this before you touch it. The elimination ordering does not change the answer. Not by a digit. Every ordering produces the identical solution, with identical residuals and identical covariances. All it changes is how much arithmetic you do to get there — which is why an ordering bug is one of the most confusing performance problems in this field, and why Chapter 7's debugging scenario is built around one.
Sparsity inspector — the graph, the factor, and the bill

Solid lines are measured edges. Dashed orange lines are fill-in — couplings no sensor ever measured, created by elimination. Grey nodes have already been eliminated.

Poses 16
Loop closures 2
Landmarks 4
ELIMINATION ORDER

Six things to do with it

  1. Set loop closures to 0 and landmarks to 0, then eliminate everything in natural order. Both sliders, not just the first one. You get 0 fill edges and 60 flops — and you can check that by hand: 16 poses in a chain give cj = 1 for the first fifteen and 0 for the last, so 15×(1²+3·1) + 0 = 15×4 = 60. A chain eliminated end to end is a perfect elimination ordering — every variable you remove has exactly one surviving neighbour, so there is no pair to connect and no clique can form. This is why odometry-only estimation is cheap.
  2. Keep landmarks at 0, add one loop closure, repeat. The chain became a cycle, and a cycle cannot be eliminated without fill: 6 fill edges, 102 flops, up from 60 on the identical number of variables. Watch the fill edge chase the elimination front around the loop — every step, the front node connects to the far end of the loop, and that edge is new. One measurement, +70% arithmetic.
  3. Now put landmarks back to 4 with loop closures still at 0, and look again. The pose chain has no cycle in it at all, and yet you get 34 fill edges and 404 flops. Where did the cycles come from? Every landmark is a cycle. A landmark seen by three consecutive keyframes creates the triangle {pk, pk+1, m} — and, through the chain edges, a 4-cycle pk–pk+1–pk+2–m–pk. Any shared observation is a loop closure in the graph-theoretic sense; "loop closure" is just the name we give the ones that span a long time gap. This is the single most common misreading of a sparsity plot: engineers attribute the fill to the labelled loop closures and never notice the structure factors underneath, which on a feature-rich session are producing most of it.
  4. Set landmarks to 8 and compare "Natural" against "Landmarks first". Natural eliminates poses and landmarks interleaved by index; landmarks-first eliminates every landmark before touching a pose. That is the Schur complement of Chapter 2, expressed purely as an ordering — and the flop bar shows what it buys.
  5. Turn loop closures up to 8 and compare all four orderings. This is the situation in the debugging scenario: a graph that was cheap becomes expensive not because it grew, but because its connectivity changed and the ordering never adapted.
  6. Watch the degree readout as you step. Fill created at a step is exactly (d choose 2) minus the edges that already existed among the neighbours. Eliminating a degree-2 node costs at most one edge; a degree-10 node costs up to 45. Minimum degree is greedy on exactly that number.

Check the instrument against arithmetic before you trust it

An instrument you have not calibrated is a decoration. So here are the exact numbers the widget produces at its default settings — 16 poses, 2 loop closures, 4 landmarks, which is 20 variables and 29 edges — for all four orderings. Every one of these is reproducible by hand with the table you will build two sections from now, and by the twenty-line Python function you will write one section from now.

Orderingcj sequence, in elimination orderFill edgesflopsnnz(L) = 78 + 2·fillratio
Natural3,3,3,3,5,5,5,4,5,5,5,5,5,5,5,4,3,2,1,0475601722.21×
Reverse3,3,3,3,1,2,2,2,2,3,2,3,3,3,2,2,2,2,1,0152421081.38×
Landmarks first3,3,3,3,3,2,2,2,4,3,3,2,3,2,2,2,2,1,1,0172601121.44×
Minimum degree1,2,2,2,2,2,2,2,3,3,2,2,3,2,2,2,3,2,1,0112101001.28×

Do one of them by hand so the rest are credible. Take the minimum-degree row and bucket its column counts: one cj = 1 at the start and one at the end, so 2×(1+3) = 8; thirteen cj = 2, so 13×(4+6) = 130; four cj = 3, so 4×(9+9) = 72; one cj = 0 contributing nothing. Total 8 + 130 + 72 = 210 — the number in the bar chart. Now the natural row the same way: five 3s → 5×18 = 90, ten 5s → 10×40 = 400, two 4s → 2×28 = 56, one 2 → 10, one 1 → 4, one 0. Total 560. Worst over best is 560/210 = 2.67×, which is what the readout under the bar chart prints.

Two things are worth noticing in that table beyond the totals. First, natural is not merely worse on average, it is worse in shape — its middle is a plateau of 5s, which is the elimination front carrying the outstanding loop closures with it, while minimum degree never once exceeds 3. Second, reverse and landmarks-first are nearly tied and both beat natural comfortably, which is the honest version of "the Schur complement is an ordering": landmarks-first is not magic, it is one particular good ordering, and on this graph a plain reverse sweep happens to be marginally better. On a real bundle-adjustment graph — thousands of landmarks, tens of cameras — landmarks-first wins by orders of magnitude, because there the structure block is 99% of the variables. Which ordering wins is a property of the graph, not of the name of the ordering. That sentence is the difference between having read about the Schur complement and having used it.

One honest disclosure about the button labelled AMD. This instrument runs exact greedy minimum degree — at each step it scans every live variable and picks the true minimum. Real AMD computes an upper bound on the degree from a quotient-graph representation, which is why it is Approximate Minimum Degree. On graphs this small the two produce the same ordering; on a 30,000-variable graph the exact version is unusable (it is O(n²) per step) and the approximate one is near-linear. Volunteering that distinction is a small thing that reads as having actually used the library rather than the idea.
Which nonzero convention this chapter uses. Everywhere below — in the prose, in the tables, and in the readout under the instrument — nnz means the full symmetric count: both triangles plus the diagonal. So nnz(Λ) = 2|E| + n and nnz(L) = 2·∑jcj + n. That makes the fill ratio on screen reproducible: divide the two printed numbers and you get the printed ratio. It also gives you a one-line identity worth internalising, nnz(L) = nnz(Λ) + 2·(fill edges) — at the instrument's default (16 poses, 2 loop closures, 4 landmarks) that reads 78 + 2×47 = 172, exactly the number printed under the right-hand matrix. CHOLMOD's own cm->lnz is the triangular count, so it will be about half; do not compare the two conventions without halving one of them, which is a genuine source of "my fill ratio doesn't match the paper" confusion.

The data that actually flows through the solver

The instrument draws a graph, but a backend does not consume a graph — it consumes arrays with shapes and dtypes. Here is the entire pipeline for the 5,000-pose warehouse session used later in this chapter, so the counting above is anchored to something you could allocate.

StageObjectShape / dtypeHow the number is obtained
Graphvariables5,000 poses × 6 DOF = 30,000 scalar unknownsSE(3) has 6 DOF, so each pose contributes a 6-vector to the tangent space
Graphfactors4,999 odometry + 800 loop closures = 5,799 between-factors, + 1 gauge priora chain of P poses has P−1 consecutive edges; the prior is what removes the 6-DOF gauge freedom
Lineariseresidual rfloat64[34,800]5,799 factors × 6 residual rows + 6 prior rows
LineariseJacobian J34,800 × 30,000, float64, 417,564 nonzeroseach between-factor row block is 6×12 (two poses), so 5,799×6×12 = 417,528, plus 6×6 for the prior. Density = 12 / 30,000 = 0.04%
Normal equationsΛ = JTWJ30,000 × 30,000, 597,528 nonzeros, float64see the arithmetic in the experiment section below. Values alone: 597,528×8 B = 4.8 MB. Dense would be 30,0002×8 B = 7.2 GB — a factor of 1,500
Symbolicin: pattern onlyint32 row/col index arrays. No float data is read.this is the whole point — the phase depends on which entries are nonzero, never on their values
Symbolicout: ordering + forecastint32[30,000] permutation, plus nnz(L) ≈ 1.1×106 and cm->fl ≈ 2.0×108AMD produces the permutation; the elimination tree produces the counts before a single multiply happens
Numericfactor L~1.1×106 nonzeros → 8.8 MB of float64nnz from the symbolic phase × 8 bytes
SolveΔxfloat64[30,000] = 240 kBtwo triangular solves, L then LT
Retractx ← x ⊕ Δx5,000 × expSE(3)(Δxi) with Δxi ∈ ℝ6the increment lives in the tangent space; the state lives on the manifold
The one engineering decision in that table, said out loud. "We run the symbolic phase once and cache the ordering across all Gauss–Newton iterations, because the sparsity pattern does not change while the values do — relinearising recomputes the numbers in J, not which entries of J are occupied. Re-running AMD every iteration is the classic wasted-time bug: it is pure overhead, it produces a bit-identical answer, and on a 30,000-variable problem it can be a larger share of the solve than the numeric factorisation it is preparing for. The pattern only changes when a factor is added or removed — a new loop closure, a marginalised keyframe — and that is exactly when you re-analyse."

What the flop model is doing — derived, not asserted

The bar chart is not a stopwatch, it is arithmetic you can check. Derive it in three steps from what a single elimination step physically does. Take the matrix at the moment column j is about to be eliminated, and split it by the pivot:

Λ(j) = [  d    bT  ;  b    S  ]    d ∈ ℝ,  b ∈ ℝcj,  S ∈ ℝcj × cj

Step 1 — forming the column costs ~3cj. The pivot is Ljj = √d: one square root, a constant, so it does not scale. The rest of the column is ℓ = b / √d, which is cj divisions. Counting the load of b and the store of ℓ alongside the divide — because on a sparse factorisation you are memory-bound far more often than you are divide-bound — that is three touches per entry, 3cj. The coefficient 3 is a memory-traffic bookkeeping constant, not a theorem; what matters is that this term is linear in cj.

Step 2 — the rank-1 update costs cj2. Eliminating the pivot Schur-complements it out of the trailing block: S ← S − bbT/d = S − ℓℓT. The outer product ℓℓT of a length-cj vector with itself is a cj×cj matrix, and every one of those entries is one multiply and one subtract — one fused multiply-add. So the update touches exactly cj × cj = cj2 entries. (Exploiting symmetry halves it to cj(cj+1)/2; the model keeps the full square because the constant is irrelevant next to the shape, and because supernodal codes do work on full blocks.)

Step 3 — sum over columns. Every column is eliminated exactly once and the costs are additive:

flops ≈ ∑j ( cj2 + 3cj )

That ℓℓT in step 2 is also where the fill comes from, which is why one derivation buys both facts. S may have a structural zero at (a, b), but ℓab is generically nonzero whenever both a and b are neighbours of j — so the subtraction writes a value into a slot that was empty. Fill and flops are the same event counted two ways: fill counts the slots that changed from zero, flops counts every slot that was written at all.

Sanity-check the model against the dense case, with the sum done. If every column is full, cj = n − 1 − j for j = 0 … n−1, so as j sweeps forward cj sweeps the integers n−1, n−2, …, 1, 0. The quadratic term is therefore the sum of the first n squares:

j=0n−1 (n − 1 − j)2 = ∑k=0n−1 k2 = (n−1)n(2n−1) / 6 = (2n3 − 3n2 + n) / 6

The leading term is 2n3/6 = n3/3 — the textbook dense Cholesky count, recovered exactly. Put a number on the approximation so you know what "≈" is worth here. At n = 100:

exact = 99 · 100 · 199 / 6 = 1,970,100 / 6 = 328,350   vs   n3/3 = 1,000,000 / 3 = 333,333

The leading term overshoots by 4,983, which is 1.5% — and the gap is O(n2)/O(n3) = O(1/n), so it shrinks to 0.15% by n = 1,000. The model is the same formula as the dense count; sparsity just makes most cj small, and that is the entire difference between four seconds and forty milliseconds.

And cj is exactly the number of surviving neighbours variable j has at the moment it is eliminated. That is why the ordering is the algorithm: choosing an ordering is choosing the sequence of cj, and the cost is the sum of their squares.

The one-breath version: "Sparse Cholesky costs the sum of the squares of the column counts of L. The column counts are the degrees in the elimination graph. So minimising fill is minimising degrees, greedily, which is exactly what AMD does — and finding the true optimum is NP-hard, which is why everybody uses the greedy heuristic and nobody feels bad about it."

Cite the NP-hardness properly, because it is the sentence that ends the "why not just search for the best ordering" follow-up. The result is Yannakakis, Computing the Minimum Fill-In is NP-Complete, SIAM J. Algebraic & Discrete Methods 2(1):77–79, 1981 — proved by reduction from optimal linear arrangement. The vocabulary around it is worth a sentence each, because the literature uses it: an ordering with zero fill is a perfect elimination ordering, a graph that has one is chordal (Rose, Triangulated graphs and the elimination process, JMAA 1970), and elimination is precisely the act of chordal completion — adding edges until the graph is chordal. So "minimum fill-in" is "cheapest chordal completion", and Yannakakis says you cannot have it. Trees and chains are chordal already, which is the formal reason odometry is free; a cycle of length ≥ 4 is the smallest non-chordal graph, which is the formal reason loop closures are not.

The heuristics, dated, so you can name them in order: minimum degree is Tinney & Walker (1967), sharpened to the near-linear-time AMD by Amestoy, Davis & Duff, An Approximate Minimum Degree Ordering Algorithm, SIAM J. Matrix Anal. Appl. 17(4):886–905, 1996. Its unsymmetric sibling COLAMD is Davis, Gilbert, Larimore & Ng, ACM TOMS 30(3), 2004 — that is the one GTSAM defaults to, because it orders the columns of the Jacobian rather than the rows of Λ. Nested dissection is George (SIAM J. Numer. Anal. 1973), made practical at scale by METIS (Karypis & Kumar, SIAM J. Sci. Comput. 1998). And the solver that assembles all of it is CHOLMOD — Chen, Davis, Hager & Rajamanickam, Algorithm 887: CHOLMOD, Supernodal Sparse Cholesky Factorization and Update/Downdate, ACM TOMS 35(3), 2008, which is what is running underneath Ceres, g2o and GTSAM when you pick a direct solver.

Trace a six-cycle by hand, once

Do this on paper before you touch the sliders. It takes two minutes and it is the whole chapter.

Six poses in a loop: edges (0,1), (1,2), (2,3), (3,4), (4,5), (5,0). Eliminate them in index order, writing the surviving-neighbour set at each step.

StepEliminateSurviving neighboursPairs it must connectNew?Running fillcjcj2 + 3cjRunning flops
10{1, 5}(1,5)yes124 + 6 = 1010
21{2, 5} (5 via the new edge)(2,5)yes224 + 6 = 1020
32{3, 5}(3,5)yes324 + 6 = 1030
43{4, 5}(4,5)no — original edge324 + 6 = 1040
54{5}none311 + 3 = 444
65{}none300 + 0 = 044

So the column-count sequence for the six-cycle in natural order is c = (2, 2, 2, 2, 1, 0) and the bill is 44 flops. That is the chapter's central equation evaluated end to end on a graph you can draw on a napkin. Note that step 4 still costs 10 flops even though it creates no fill: the outer product is written into S whether or not the slot was previously zero. Fill is a storage event; flops are an arithmetic event; they are correlated but they are not the same number, and confusing them is the single most common error in this material (there is one in this chapter's own prose further down, and it is flagged when we get to it).

Watch what happened at step 2: variable 1's neighbour set included variable 5, and 5 was not one of its original neighbours — it arrived from the fill edge created at step 1. Fill begets fill. The elimination front drags a growing frontier around the loop until it reaches the far side.

Now delete the (5,0) edge so it is a chain instead of a cycle, and run the identical table:

StepEliminateSurviving neighboursNew fillcjcj2 + 3cjRunning flops
10{1}0 — one neighbour, no pair exists11 + 3 = 44
21{2}011 + 3 = 48
32{3}011 + 3 = 412
43{4}011 + 3 = 416
54{5}011 + 3 = 420
65{}000 + 0 = 020

Chain: c = (1, 1, 1, 1, 1, 0), 0 fill, 20 flops. Cycle: c = (2, 2, 2, 2, 1, 0), 3 fill, 44 flops. Identical variable count, one extra measurement, and the bill goes from 20 to 44 — the loop closure costs 24 extra flops on a six-node graph, a 2.2× increase for a 17% increase in edges. That leverage is the entire subject. One edge — one loop closure — is the difference between a free factorisation and a frontier that sweeps the graph, and on a 5,000-pose session the same effect is what turns 40 ms into 4 s.

Now write the symbolic phase yourself

The hand trace above is an algorithm, and it is short enough to write from memory at a whiteboard — which is a realistic ask, because "implement symbolic elimination" is a genuine 20-minute coding-round problem. Note what it does not take as an argument: any floating-point data. It sees only the pattern.

python
def symbolic(adj, order):
    """adj: {v: set(neighbours)}.  order: the elimination sequence.
       Returns (fill_edge_count, flops, column_counts).  No float data anywhere."""
    adj   = {v: set(nb) for v, nb in adj.items()}   # never mutate the caller's graph
    alive = set(adj)
    fill, cols = set(), []
    for v in order:
        alive.discard(v)
        nb = sorted(alive & adj[v])                 # surviving neighbours  ->  c_j
        for a in range(len(nb)):
            for b in range(a + 1, len(nb)):
                if nb[b] not in adj[nb[a]]:          # a pair no sensor ever measured
                    adj[nb[a]].add(nb[b])              # fill BEGETS fill: the graph
                    adj[nb[b]].add(nb[a])              # we eliminate from is the
                    fill.add((nb[a], nb[b]))           # one we keep editing
        cols.append(len(nb))
    return len(fill), sum(c * c + 3 * c for c in cols), cols

def graph(edges, n):
    g = {i: set() for i in range(n)}
    for i, j in edges:
        g[i].add(j); g[j].add(i)
    return g

cycle = [(0,1),(1,2),(2,3),(3,4),(4,5),(5,0)]
print(symbolic(graph(cycle,      6), range(6)))   # (3,  44, [2,2,2,2,1,0]) the cycle
print(symbolic(graph(cycle[:-1], 6), range(6)))   # (0,  20, [1,1,1,1,1,0]) the chain

star = [(0, i) for i in range(1, 7)]                 # hub 0, six leaves
print(symbolic(graph(star,7), [1,2,3,4,5,6,0]))  # (0,  24, [1,1,1,1,1,1,0]) leaves
print(symbolic(graph(star,7), range(7)))         # (15, 154, [6,5,4,3,2,1,0]) hub

Four lines of output, four numbers you derived by hand two paragraphs ago. That is the check — if your implementation disagrees with the napkin, one of them is wrong and you know which trace to walk.

What to say while typing it. "The complexity here is bad on purpose — the double loop is O(cj2) per step, so a degree-1,000 variable does 500,000 set lookups. Production AMD never materialises those cliques; it stores each clique as a set element and represents a variable's neighbourhood as a union of elements, which is why it is called approximate minimum degree — the degree it computes is an upper bound obtained without expanding the union. Amestoy, Davis & Duff (SIAM J. Matrix Anal. Appl. 1996) is the paper; the trick is quotient-graph representation with supervariable and element absorption, and it takes the ordering from O(n·m) to near-linear in practice."

And the library form, which is what you would actually ship. The point of showing both is that the one-liner hides exactly the split you just implemented — analyze is the pattern-only pass, cholesky_inplace is the numeric pass, and they are separate calls precisely so you can call the first one once:

python
import numpy as np, scipy.sparse as sp
from sksparse.cholmod import analyze          # SuiteSparse CHOLMOD via scikit-sparse

Lam = sp.csc_matrix(...)                        # 30000 x 30000, 597528 nonzeros

# --- symbolic phase: runs ONCE, reads the pattern, never the values ---
F = analyze(Lam, ordering_method='amd')       # 'natural' / 'amd' / 'colamd' / 'nesdis'
print(F.P().shape)                               # (30000,)  int32 permutation

for it in range(n_gauss_newton_iters):
    Lam, eta = relinearise(x)                   # NEW VALUES, SAME PATTERN
    F.cholesky_inplace(Lam)                     # numeric phase reuses the ordering
    dx = F(eta)                                 # two triangular solves
    x  = retract(x, dx)
print(F.L().nnz)                                 # ~1.1e6  <- the number nobody logs

# pure-scipy fallback if SuiteSparse is not available:
from scipy.sparse.csgraph import reverse_cuthill_mckee
p = reverse_cuthill_mckee(Lam, symmetric_mode=True)   # bandwidth, not fill
Read the loop again. analyze is outside it and cholesky_inplace is inside it. If you have ever seen a backend call analyze (or GTSAM's Ordering::Colamd, or Ceres' ordering setup) inside the iteration loop, you have found free performance and a bit-identical answer — the two facts that together make it the most satisfying profiling result in this domain.
The one-breath version: "A chain has a perfect elimination ordering: eliminate from one end and every variable has exactly one surviving neighbour, so no clique can form. A cycle has none. Loop closures are what make SLAM a hard sparse-linear-algebra problem, and they are also the only reason the map is any good — so you cannot avoid them, you can only order around them."

The star, and the landmark you eliminated too early

Second hand trace, and it is the Schur complement in miniature. Six leaves all connected to one hub — a landmark seen by six keyframes.

OrderingWhat happenscj sequenceFillflops = ∑j(cj2 + 3cj)
Leaves first, hub lastEach leaf has exactly one surviving neighbour (the hub), so no pair exists to connect. Then the hub has no surviving neighbours at all.(1,1,1,1,1,1,0)06×(1+3) + 0 = 24
Hub first, leaves afterThe hub has six surviving neighbours, and none of them are connected to each other. All 6×5/2 pairs are new — the leaves become K6, and each subsequent leaf then eliminates out of a shrinking clique.(6,5,4,3,2,1,0)1554+40+28+18+10+4+0 = 154

Every entry in that last column, so nothing is asserted: 6²+18 = 36+18 = 54; 5²+15 = 25+15 = 40; 4²+12 = 16+12 = 28; 3²+9 = 9+9 = 18; 2²+6 = 4+6 = 10; 1²+3 = 1+3 = 4; 0. Total 154 against 24.

Same graph, same answer, 15 extra edges and 154/24 = 6.4× the arithmetic. Those two ratios are different numbers and it is worth understanding why, because reading one as the other is the mistake this section exists to prevent. Fill counts edges — each new nonzero is worth exactly 1 no matter which column it lands in. Flops weight each column quadratically — one column of count 6 costs 36, while six columns of count 1 cost 6 between them. A fill count is a linear functional on the elimination; a flop count is a quadratic one. They move together in direction and never in the same proportion, so quoting a fill ratio as a speed ratio (or the reverse) will be wrong every single time. Here it is wrong low: 6.4 < 15. On a graph where the fill concentrates into one enormous frontal matrix the flop ratio races ahead of the fill ratio instead — which is exactly what the 5,000-pose experiment below shows, where the nonzeros differ by 24× and the time by 100×.

And here is the subtlety worth saying out loud: in bundle adjustment you deliberately eliminate the hubs first, because the resulting clique lands on the cameras — which is a 600-dimensional block instead of a 60,000-dimensional one. The same operation is a disaster in one context and the entire optimisation in another. What changes is where the fill lands, not how much of it there is.

What the instrument's readouts are called in real tools

In this instrumentWhere you find it for real
nnz(Λ)CHOLMOD: A->nzmax. Ceres: Solver::Summary::num_residuals and the Jacobian's nnz. GTSAM: GaussianFactorGraph::size() and the factor dimensions.
nnz(L)CHOLMOD: cholmod_analyze then L->nzmax — and note it is available before any numeric work, because it comes from the symbolic phase.
Elimination orderingCeres: linear_solver_ordering (groups). GTSAM: Ordering::Colamd, Ordering::ColamdConstrainedLast. SuiteSparse: amd_order / colamd.
Flop estimateCHOLMOD: cm->fl after cholmod_analyze, and cm->lnz for the factor nonzeros. This is the single most useful number nobody logs.
Step-by-step eliminationGTSAM's Bayes tree — ISAM2::getISAM2().printStats() and the clique structure. The tree is the elimination history, kept alive so it can be updated incrementally.
The systems takeaway: "The symbolic phase is separate from the numeric phase and runs first, so nnz(L) and the flop count are known before any arithmetic happens. That means the solve time is predictable, and I would log it — a backend that suddenly needs ten times the flops for the same graph has an ordering problem, and you can catch it in the symbolic phase rather than by watching a wall clock."

Why minimum degree usually wins, and when it does not

OrderingWhat it doesGood onBad on
Natural (variable insertion / timestamp)Eliminate in the order variables were createdPure chains — genuinely optimal, zero fillAnything with loop closures. The fill front sweeps the whole cycle.
ReverseNewest firstSliding windows, where the newest states are the ones you want to keep as the reduced systemSame cycle problem, from the other end
Landmarks firstAll structure variables, then all posesBundle adjustment. This is the Schur complement.Graphs where a landmark is seen by very many cameras — a degree-50 landmark makes a 50-clique
Minimum degree (AMD / COLAMD)Greedily eliminate the currently-lowest-degree variableAlmost everything. It is the default in CHOLMOD, Ceres and GTSAM for good reason.Nothing common. It can be beaten by nested dissection on very large, very regular problems, and it is not incremental — which is precisely the gap iSAM2's Bayes tree fills.

Constrained ordering is the practical wrinkle. In a sliding window you cannot use a pure fill-reducing ordering, because the variables you are about to marginalise must be eliminated first — that is what marginalising them means. So you use a constrained COLAMD: "these variables go last, order the rest however you like." Ceres calls it ParameterBlockOrdering with groups; GTSAM calls it Ordering::ColamdConstrainedLast. Knowing that this API exists is a genuine signal of having shipped a backend.

The twenty-four-fold experiment

One concrete comparison, worth doing in the instrument and worth memorising as a number. Twenty-four is the ratio of the nonzeros; keep reading, because the ratio of the time is not twenty-four, for exactly the reason the star taught.

Take a graph of 5,000 poses with 800 loop closures — a realistic warehouse session. Set the ordering to natural, and the elimination front carries a clique whose size grows with the span of the outstanding loop closures. Set it to AMD and the front stays local.

First, derive nnz(Λ) rather than being told it. This is a 30-second back-of-envelope computation:

Natural orderingAMD ordering
nnz(Λ) — the matrix5,000×36 + 2×5,799×36 = 180,000 + 417,528 = 597,528
identical — ordering does not change the matrix, only the order you visit it in
nnz(L) — the factor~2.6 × 107~1.1 × 106
Fill ratio nnz(L)/nnz(Λ)2.6e7 / 597,528 = 44×1.1e6 / 597,528 = 1.8×
Mean column count c̄ = nnz(L)/n2.6e7 / 30,000 = 8671.1e6 / 30,000 = 37
Column profilebanded — the front width is nearly constant as it sweeps, so cj ≈ c̄ throughoutskewed — most columns are tiny, a handful of root supernodes are dense, so RMS(cj) ≈ 2.2 c̄ ≈ 82
j cj2 = n · RMS(c)230,000 × 8672 = 2.3 × 101030,000 × 822 = 2.0 × 108
Numeric factorisation @ 5 GFLOP/s2.3e10 / 5e9 = ~4 s2.0e8 / 5e9 = ~40 ms
Solutionidentical to 10−9

Read the last three rows carefully, because they are the payoff of the whole chapter. The nonzeros differ by 2.6e7/1.1e6 = 24×. The time differs by 4 s / 40 ms = 100×. Both statements are true and they are not in conflict: flops are ∑cj2, so they depend on the shape of the column profile and not only on its sum. For a fixed nnz(L), a flat profile minimises ∑cj2 (Cauchy–Schwarz), so the banded natural ordering is as cheap as 2.6e7 nonzeros can possibly be — and it is still a hundred times slower. AMD's profile is the skewed one, which costs it a factor of RMS/mean ≈ 2.2 squared ≈ 4.8 above its own flat lower bound of 8 ms, and it wins anyway by more than an order of magnitude. You cannot read a speed ratio off a fill ratio. If you need the flop number, get it from the symbolic phase — cm->fl after cholmod_analyze — which computes ∑cj2 exactly instead of estimating it from a mean.

This table is the debugging scenario in Chapter 7. Everything observable about the answer is unchanged. The iteration count is unchanged. The residuals are unchanged. Only nnz(L) moved, and it moved by a factor of twenty-four. If you ever profile a backend, that ratio is the first number to print.

Five ways this goes wrong in production, and the number that names each one

Everything above is the happy path. Here is the debugging half, which is what actually gets asked. Each row gives the symptom you would observe from outside, the cause, the metric to print, and — the part that matters — the value that distinguishes it from the row above it, because in a real incident all five present as "the backend got slow".

#Observable symptomRoot causeMetric to printThe value that separates it from its neighbours
1 Solve time up 40× overnight. Iteration count, χ2 and covariances bit-identical on the same bag. The ordering changed: a library upgrade flipping the default, a hardcoded ordering left in from a sliding-window experiment, or a constrained-ordering group someone added. nnz(L) and its ratio to nnz(Λ), from the symbolic phase. nnz(Λ) is unchanged and nnz(L) jumped. That pair is unique to this row — every other row moves nnz(Λ), the iteration count, or χ2.
2 Solve time creeps up steadily over a long session even though the sliding window holds a fixed number of variables. The marginalisation prior is densifying. Every marginalised keyframe leaves a clique on its neighbours; those cliques accumulate into a prior block that trends toward fully dense. Block density of the marginal prior factor: nnz(prior) / (dim × dim). Here nnz(Λ) itself grows while n is constant. In row 1 nnz(Λ) was flat. If you see the matrix getting denser at constant size, it is marginalisation, not ordering.
3 Per-iteration time is high but the factorisation profile looks fine. More time is spent before the first multiply than after it. analyze / AMD / Ordering::Colamd is being called inside the Gauss–Newton loop. Wall time split symbolic vs numeric, per iteration, plus nnz(L) per iteration. nnz(L) is identical on every iteration and symbolic time is constant across them. If the pattern were genuinely changing, both would vary. Constant + repeated = pure waste.
4 CHOLMOD throws "matrix not positive definite" at column 18,342, and pose 18,342 in your state looks completely healthy. Rank deficiency — a missing gauge prior, or a landmark observed exactly once so its 3×3 block is singular. The failure is real; the index is a red herring. The permutation: P[18342] — then look at that variable. The reported column is in permuted order, not your variable order. Un-permuting before blaming a pose is the whole trick; skipping it sends teams debugging an innocent keyframe for a day.
5 Fine at 1,000 poses, unusable at 4,000 — same environment, same loop-closure rate, same ordering, AMD already on. The elimination tree is deepening. AMD is near-optimal on planar-ish graphs; a robot revisiting one aisle from many headings builds a locally dense subgraph that is not planar-ish at all. cm->fl plotted against n on log–log — read the slope. Slope ≈ 1 means you are memory-bound and healthy. Slope ≥ 1.5 means fill is compounding and the fix is structural — nested dissection, or a windowed/incremental formulation — not a different heuristic.

Reproduce rows 1 and 5 in the instrument right now, because they are the two you will be asked about. For row 1: leave every slider alone and click between Natural and Minimum degree. nnz(Λ) stays at 78 in both — it is printed and it does not move — while the flop bar goes 560 → 210 and the fill ratio goes 2.21× → 1.28×. That is row 1 in miniature: the matrix is identical, the bill is not. For row 5: pin the ordering to Minimum degree, set loop closures to 8, and sweep the pose slider from 6 to 34. The flop count does not merely scale with the number of variables — watch it accelerate as the loop closures start to overlap in span, which is the log–log slope of row 5 rendered as a slider you can push.

The one-line habit that makes all five cheap. Log n, nnz(Λ), nnz(L), cm->fl and the symbolic wall time on every solve. Four integers and one float per iteration, a few dozen bytes, essentially free — and they come from the symbolic phase, so you have them before any arithmetic runs. With those five series in a dashboard, rows 1, 2, 3 and 5 are all visible as a shape change in a graph rather than a week of bisecting a solver. Nobody logs them, which is precisely why saying you would is a differentiator.

Now budget it — the design question this chapter answers

"Architect the backend for a 33-minute warehouse session. What do you choose, and what does it cost?" The chapter has now handed you every number needed to answer that in a single pass, with no hand-waving. Keyframes at 2.5 Hz for 2,000 s gives the 5,000 poses we have been costing all along, so the latency budget is 1 / 2.5 Hz = 400 ms per update — the backend must be done before the next keyframe lands or the queue grows without bound.

Design choiceCost, in numbersFits in 400 ms?
Full batch re-solve, natural ordering4 s per factorisation × 3 Gauss–Newton iterations = 12 sNo — 30× over. Same answer to 10−9, so nothing in the output tells you why.
Full batch re-solve, AMD ordering40 ms × 3 iterations = 120 ms, plus one symbolic analysis amortised across the threeYes, with 280 ms of headroom. The ordering alone is the whole difference.
Incremental (iSAM2 Bayes tree)1–5 ms on a normal keyframe; 100–500 ms when a loop closure invalidates a large subtreeYes on the common path; the loop-closure spike can exceed the budget, so it goes off the critical path — solve it on a second thread, publish the corrected trajectory when ready, and let the frontend keep dead-reckoning against the last published estimate.

And the bandwidth budget, because "it's memory-bound" is a claim you should be able to check. A full numeric factorisation writes L, which is 1.1×106 nonzeros × 8 B = 8.8 MB. At 2.5 Hz that is 8.8 MB × 2.5 = 22 MB/s of write traffic. A single DDR4 channel delivers roughly 15 GB/s, so the factorisation is using 0.15% of available bandwidth. Memory bandwidth is not the constraint at this size. The constraint is the serial dependency chain in the elimination tree — a column cannot be factored until its children are — which is exactly why the GPU work in the frontier section optimises tree depth rather than fill, and exactly why throwing more cores at a deep tree does nothing. Being able to say "I checked, and it isn't bandwidth" with the arithmetic attached is worth more than any amount of confident intuition.

And the observability line item, since it is the cheapest thing in the design: the five numbers from the callout above are four int64s and one double, so ~40 B per solve. At 2.5 Hz for 33 minutes that is 5,000 records × 40 B = 200 kB per session — less than one camera frame. There is no budget argument against logging them.

What this instrument does not show, and why that matters

Three honest limitations, and volunteering them is exactly the kind of thing that reads as senior:

Where the ordering problem is going — and it is not 2012 any more

The classical citations above stop between 1981 and 2012, and if you leave the topic there you will sound like you learned it from a textbook and stopped. Three live threads, dated:

The frontier answer in one sentence. "Direct sparse Cholesky with AMD is still the default and still correct for a few-thousand-pose graph, but the last five years have moved the frontier in two directions at once — iterative and matrix-free methods that make fill irrelevant (Power BA, CVPR 2023), and GPU-parallel direct solvers where the objective changes from minimum fill to minimum tree depth. If someone tells me their ordering is the bottleneck, my first question is whether they should be factoring at all."
Your backend's solve time jumped 40× overnight. The iteration count, the final chi-squared and every reported covariance are bit-identical to last week's run on the same bag. Which single number do you print first, and what do you expect it to show?

Why the invariance is the whole clue. "Bit-identical residuals, chi-squared and covariances" is not colour — it is the diagnostic. It rules out every numerical cause: a changed condition number would perturb the answer in the last digits, a changed trust-region schedule would change the iteration count, and a new bad sensor stream would change chi-squared. The only class of change that costs 40× the arithmetic and leaves the answer bit-identical is a change in elimination ordering — a library upgrade flipping the default from AMD to natural, someone hard-coding an ordering for a sliding-window experiment and leaving it in, a constrained-ordering group being added, or the graph's connectivity changing enough (a relocalisation module that started firing) that the cached ordering is now bad for it. nnz(L) and cm->fl come from the symbolic phase, so you get the answer without running a single numeric factorisation — and if you had been logging that ratio all along, the alert would have fired at 3 a.m. instead of the pager firing on latency.

The instrument is the other test. If you can predict the flop bar before you press the button — and now you can, because you have the formula, the derivation and four hand-checked examples — you understand elimination ordering. The Field Guide is next.

Chapter 7: The Field Guide

Tuesday morning, and the nightly regression dashboard is red. A teammate puts a laptop on your desk, turns it round, and says: “Solve time went from twenty milliseconds to four seconds overnight. Same dataset. Same residuals. Same answer to nine decimal places. What do you want me to plot?”

Nobody in that conversation wants a lecture on sparse linear algebra. They want one metric, named in about eight seconds, with the number that would confirm it and the number that would rule out the obvious decoy. You either have that pair loaded or you do not.

Everything in this chapter exists to make those eight seconds automatic. Read it on the train. Then close it and try to reproduce the numbers drill from memory — that is the actual test, because the gap between knowing that ordering changes nnz(L) and saying “forty times on nnz(L), and nnz(Λ) barely moved, so it is the ordering and not the graph” is the gap between understanding and fluency.

One structural note before the tables. Six of the nine sections below are compressions — they assume you have already done the derivations in Chapters 1 to 6 and they exist to make retrieval fast under pressure. Two of them (the worked numbers in section 6 and the drills in section 3) are expansions: they are written out longer than you would ever say them out loud, because the only way to produce a one-line answer under pressure is to have produced the ten-line version at least once at a desk.

1 · The cheat sheet

ConceptThe 30-second explanationKey equationToolClassic paper2022+ paper
Information matrix The inverse covariance. Its (i,j) block is nonzero exactly when i and j share a measurement, so it is the graph. Λ = ∑f JfTΣf−1Jf GTSAM, Ceres Thrun, Liu et al., Sparse Extended Information Filters, IJRR 2004 Demmel et al., Square Root BA, CVPR 2021
Marginalisation Integrate a variable out. Exact for a linear system; the cost is fill-in plus a frozen linearisation. Λ' = Λbb − ΛbaΛaa−1Λab Any Schur eliminator Sibley, Matthies & Sukhatme, Sliding window filter, JFR 2010 Demmel et al., Square Root Marginalization, ICCV 2021
Fill-in Eliminating a degree-d variable makes its neighbours a clique — up to d(d−1)/2 new edges no sensor measured. cj = |surviving neighbours of j|, flops ≈ ∑cj2 CHOLMOD, AMD/COLAMD George & Liu, Computer Solution of Large Sparse PD Systems, 1981 Weber et al., Power Bundle Adjustment, CVPR 2023
Pose graph Variables are poses; factors are relative transforms. Loop error is spread over the cycle inversely to weight. C = ½∑‖log(Zij−1Xi−1Xj)‖2 g2o, GTSAM Lu & Milios, Globally consistent range scan alignment, 1997 Rosen et al., SE-Sync, IJRR 2019 (certifiable)
Bundle adjustment Cameras and points as variables; the arrowhead structure lets you eliminate all points at once. S = U − WV−1WT Ceres *_SCHUR Triggs et al., Bundle Adjustment — A Modern Synthesis, 2000 Ren et al., MegBA, ECCV 2022
Levenberg–Marquardt Gauss–Newton with a leash. λ interpolates between Newton and gradient descent, adapted by the gain ratio. (Λ + λ diagΛ)δ = η Ceres, g2o, GTSAM Moré, The LM algorithm: implementation and theory, 1978 Martiros et al., SymForce, RSS 2022
iSAM2 / Bayes tree Keep everything, but re-eliminate only the subtree a new factor invalidates, and relinearise only variables that moved. — (a directed clique tree over the elimination) GTSAM ISAM2 Kaess et al., iSAM2, IJRR 2012 Pineda et al., Theseus, NeurIPS 2022 (differentiable)
IMU preintegration Define the inertial deltas in the first body frame so they do not depend on the states they constrain. Δvij = ∑ΔRik(ak−ba)Δt GTSAM ImuFactor Forster et al., On-Manifold Preintegration, T-RO 2017 Fornasier et al., Equivariant filter for VIO, T-RO 2023
FEJ Freeze the Jacobian of a marginalised prior at its marginalisation-time estimate, or the unobservable subspace collapses. dim null(J) = 4 for monocular VIO OpenVINS, VINS-Mono Huang, Mourikis & Roumeliotis, IJRR 2010 Geneva et al., OpenVINS, ICRA 2020

Where those symbols come from

A cheat sheet that you can only recite is worth nothing, because the second question is always “what is U?” Every equation in that table is reconstructible in one or two sentences from the residual alone. Here is each one rebuilt from scratch, in the order a whiteboard would want them.

The pattern. Every row of that table is the same three-step move: write the residual, take JTΣ−1J, then argue about where the zeros are. Sparsity, marginalisation, the Schur complement and fill-in are not four topics. They are one topic asked from four directions, and being able to connect them is what real understanding sounds like.

2 · System design patterns

Prompt A — "Design the backend for a warehouse AMR fleet: 2-D LiDAR, wheel odometry, IMU, a 200 m × 80 m building, eight-hour shifts."

Frame it as two loops with two clocks
A bounded real-time loop (fixed-lag smoother over the last ~2 s of scan-match constraints, 10 Hz, < 20 ms) and an unbounded background loop (pose graph over all keyframes, triggered by loop closures, no deadline, separate thread).
Put numbers on the map
A keyframe every 0.5 m of travel → at 1.2 m/s that is 2.4 Hz. An eight-hour shift at 50% duty is 4 h × 3600 s × 2.4 = ~35,000 keyframes. Now pin the DOF down, because ambiguity hides here: pure 2-D LiDAR pose graph is SE(2), so 35,000 × 3 = 105,000 unknowns; if you also estimate a 2-D velocity and a gyro-bias scalar per keyframe to fuse the IMU, it is 35,000 × 6 = 210,000. Both are defensible. Ambiguity is not.
Then derive the cap instead of asserting it
200 m × 80 m with 3 m aisles is roughly 4,000 m of unique drivable centreline (about twenty 200 m aisles plus the cross-runs). At one keyframe per 0.5 m of unique path that is 4,000 / 0.5 = 8,000 keyframes — and every revisit after that adds a loop-closure factor, not a variable. So the graph stops growing after roughly the first 55 minutes of the shift and the remaining seven hours only make it better conditioned. 8,000 × 3 = 24,000 unknowns, which is a 5 ms sparse solve, not a 15-minute dense one.
Name the ordering strategy
Constrained COLAMD in the window (marginalised states first), plain AMD in the global graph. Say out loud that the ordering is recomputed when the connectivity changes, not every solve.
Name the health monitor
nnz(L)/nnz(Λ), solve time, chi-square on loop-closure factors, and the count of rejected LM steps. All four go to the fleet telemetry, because all four are silent failures otherwise.
Name what crosses the boundary
Real-time → background, one keyframe record at 2.4 Hz: an SE(2) pose (3 doubles = 24 B), the 3×3 information block of the window's marginal on it (9 doubles = 72 B, symmetric so 6 would do), a timestamp and id (16 B), and ~1 KB of scan descriptor for place recognition. Call it 1.1 KB per keyframe → ~2.6 KB/s — four orders of magnitude below the raw scan stream, which is the point. Background → real-time, on loop closure only: a corrected pose (3 doubles) plus a 3×3 prior on the newest window state (9 doubles) = 12 doubles, 96 B, applied as a delta composed into the window's anchor, never as a state teleport — the controller downstream cannot survive a discontinuity in its pose input. Queue discipline: a lock-free single-producer/single-consumer ring of 64 keyframe records; if the background thread falls behind, it drains to the newest and drops the middle, because a stale loop-closure candidate is worthless and blocking the real-time producer is a safety event. Resident memory: 8,000 keyframes × 1.1 KB ≈ 8.8 MB of descriptors plus ~200 KB of graph — the map fits in L3-adjacent DRAM on an embedded SoC, and that is why this architecture is shippable.

Prompt B — "We are adding a second robot. Should they share a map?" The framework: (1) is the constraint between the robots observable — do they ever see each other or the same place? (2) What is the bandwidth of the shared object — a full graph is megabytes, a set of keyframe descriptors is kilobytes. (3) Who owns the gauge — two independently-gauge-free graphs need one anchor between them, or the merged problem is rank-deficient by 3 (2-D) or 6 (3-D). (4) What happens on a false inter-robot loop closure — and the honest answer is that it is worse than a false intra-robot one, because it corrupts two maps at once. Name distributed pose-graph optimisation (Choudhary et al., IJRR 2017) and the robust variant (Lajoie et al., DOOR-SLAM, RA-L 2020) as prior art.

Prompt C — "Your backend has a 250 ms budget and occasionally takes 4 seconds. Ship it or fix it?" The framework: separate throughput from latency, and identify who is downstream. If a controller reads the output, a 4 s stall is a safety event and you need a bounded-time path (IMU propagation) that never touches the optimiser. If only the map consumer reads it, you can run the slow path asynchronously and merge. Then name the specific fix: bound the work (fixed lag), or bound the variance of the work (iSAM2's incremental re-elimination), or move the unbounded part off the critical path. "Make it faster" is not an answer; "make it bounded" is.

Prompt D — "How do you know the backend is healthy in the field, with no ground truth?" Four signals, none of which need truth: chi-square of each factor family against its expected distribution; the ratio nnz(L)/nnz(Λ); the fraction of LM steps rejected; and the trace of the marginal covariance on a fixed reference keyframe, which must be non-decreasing between loop closures. A covariance that shrinks with no loop closure is a rank bug (Chapters 1 and 3).

Prompt E — "Design the backend for a handheld AR headset: rolling-shutter mono camera at 30 Hz, IMU at 800 Hz, 512 MB total memory budget." This is Prompt A's mirror image and it is worth working to the same depth, because every constraint pushes the opposite way: the warehouse robot had space and time and one bad sensor; the headset has neither space nor time and a sensor that lies about when.

Two loops again, but the fast one is not the optimiser
An 800 Hz IMU propagator (integrate, no optimisation, ~50 µs per sample, hard bounded) is what the renderer's late-stage reprojection reads — it must produce a pose every 1.25 ms forever, no exceptions. Behind it, a fixed-lag VIO smoother at 10 Hz corrects the propagator's anchor. Nobody renders from the optimiser. If you say the words “the renderer waits for the solve” you have failed the prompt: at 90 Hz display, 11 ms of extra motion-to-photon latency is nausea.
Keyframe rate: earn it from parallax, not from a timer
Trigger a keyframe when median feature parallax exceeds ~20 px or 0.3 s has elapsed, whichever comes first. In normal head motion that is 3–4 Hz — one keyframe every 8 to 10 camera frames at 30 Hz. A fixed timer is wrong in both directions: standing still it fills the window with degenerate zero-baseline keyframes (no triangulation, rank drop); turning fast it starves the window of overlap and the tracker breaks.
Window length and state dimension, spelled out
10 keyframes, which at 3.5 Hz is about 2.9 s of history. State: 10 × 15 = 150 (pose 6 + velocity 3 + gyro bias 3 + accel bias 3 per keyframe), plus 50 actively-tracked landmarks × 3 = 150, plus the online-calibrated camera–IMU extrinsic 6 and the time offset 1 = 7. Total n = 307 — the same 307 as the sliding-window row of the numbers drill in section 6, and now you know where it came from. Why not 20 keyframes? Because dense cost goes as n3: doubling the window to n ≈ 600 is 8× the arithmetic for information that a loop closure would give you for free. Why not 5? Because the accelerometer needs several seconds of varied specific force to keep scale and accel bias separable — shorten the window and you re-enter the “no excitation” row of section 4.
Rolling shutter: put the line time in the residual or eat the error
A 1080-line sensor with a ~20 ms readout is 18.5 µs per line. Rotate the head at a gentle 60°/s and the top and bottom rows of one image are taken 20 ms and therefore 1.2° apart; at a 500 px focal length that is 0.021 rad × 500 = ~10 px of skew across the frame, which is thirty times your 0.3 px feature noise and entirely systematic. So: project each feature at the pose of its own row, tk = tframe + rowk × 18.5 µs, interpolated from the IMU propagator between keyframes. This is cheap — it is one extra term in the Jacobian — and skipping it is the single most common reason a headset VIO “works on the bench and swims in the room.”
Per-solve byte cost, and why it fits
Λ at n = 307 stored dense is 3072 × 8 B = 754 KB; L is the same again; the residual and Jacobian for ~300 observations × 2 rows × ~15 nonzero columns is ~72 KB. Working set per solve is under 2 MB, so after the first LM iteration the whole problem lives in last-level cache — which is exactly why 9.6×106 flops take 2–4 ms in practice instead of the 1 ms the flop count promises, and why it does not take 40. At 10 Hz with 6 LM iterations you move roughly 90 MB/s of memory traffic: negligible next to the camera stream. Resident totals against the 512 MB: relocalisation map capped at 1,500 keyframes × ~8 KB (200 descriptors × 32 B, pose, covariance) = 12 MB; optimiser working set 2 MB; IMU ring buffer 800 Hz × 10 s × 32 B = 256 KB. The backend's whole footprint is under 20 MB — you spend the 512 on the renderer and the app, and you say so, because an engineer who returns unused budget is more credible than one who claims to need all of it.
Name what crosses this boundary too
Smoother → propagator, at 10 Hz: a corrected pose (7 doubles as position + quaternion), velocity (3) and the two bias vectors (6) at the newest keyframe's timestamp = 16 doubles, 128 B. The propagator does not jump: it rewinds to that timestamp and replays its IMU ring buffer forward — at 800 Hz over a ~100 ms solve latency that is 80 samples, about 4 ms of integration, comfortably inside the 1.25 ms budget if you amortise it over the next few samples or run it on the correction thread. Propagator → smoother: nothing but the raw buffer pointer; the smoother owns the preintegration. Two threads, one SPSC ring, no locks on the 800 Hz path, and no allocation anywhere below 10 Hz.

The one sentence that ties Prompts A and E together, and it is worth saying explicitly at the end of either answer: in both systems the fast loop is bounded by construction and the slow loop is unbounded but off the critical path, and the only object crossing between them is a small, dated correction that is composed, never assigned. Change the sensors, the platform and three orders of magnitude of memory budget, and that invariant does not move. That is the architecture; everything else is parameters.

3 · Coding drills

Drill 1 — "Assemble the normal equations for a pose graph." The ten lines that matter, and they are the ones the Chapter 2 lab makes you write:

python
def add_between(Lam, eta, i, j, meas, w, x):
    r = (x[j] - x[i]) - meas          # J = [-1, +1]
    Lam[i,i] += w; Lam[j,j] += w
    Lam[i,j] -= w; Lam[j,i] -= w      # THE EDGE
    eta[i]   += w*r; eta[j] -= w*r    # eta = -J^T w r

Say while writing: "The off-diagonal is the graph edge — if I only wrote the diagonal, every pose would be independent and no information could propagate. And I am accumulating η = −JTWr, not JTWr, because I solve Λδ = η and then add δ."

Then immediately upgrade it, unprompted. The version above is a 1-D chain: x[j] - x[i] is a scalar subtraction, w is a scalar, and Lam[i,i] += w writes one entry. Nobody runs that. Write it, say “that is the scalar warm-up, here is the real one,” and write the SE(2) block form — because the real test is whether you know that w is a 3×3 matrix.

python
# x   : (N, 3) float64   -- [tx, ty, theta] per pose, N poses
# Lam : (3N, 3N) float64 -- information matrix, symmetric
# eta : (3N,)   float64  -- information vector
# Om  : (3, 3)  float64  -- Omega = Sigma^-1 for THIS measurement
def add_between_se2(Lam, eta, i, j, Z_ij, Om, X):
    # r in R^3 -- a manifold residual, NOT a subtraction
    r  = Log(inv(Z_ij) @ inv(X[i]) @ X[j])        # (3,)
    Ji = -Ad(inv(X[j]) @ X[i])                     # (3,3)  d r / d xi_i
    Jj =  np.eye(3)                              # (3,3)  d r / d xi_j
    I, J = slice(3*i, 3*i+3), slice(3*j, 3*j+3)
    Lam[I, I] += Ji.T @ Om @ Ji       # (3,3) into the diagonal block
    Lam[J, J] += Jj.T @ Om @ Jj
    Lam[I, J] += Ji.T @ Om @ Jj       # (3,3) -- THE EDGE, as a block
    Lam[J, I] += Jj.T @ Om @ Ji       # = the transpose of the line above
    eta[I]    -= Ji.T @ Om @ r        # (3,)  eta = -J^T Om r
    eta[J]    -= Jj.T @ Om @ r

Say the shapes out loud as you write them. "x is (N,3) but Λ is (3N,3N), because each pose contributes three columns. The residual is r = Log(Zij−1Xi−1Xj) in R3 — a Lie-algebra vector, not a difference of poses, because subtracting two headings across ±π is how you get a 6.28 rad residual and a solver that explodes. Ji is 3×3 and equals minus the adjoint; Jj is the 3×3 identity, which is only true in this right-perturbation convention — and I would state my convention, because the sign of Ji flips in the left one. And the four scalar writes became four 3×3 block writes, with Ω a full 3×3 information matrix, not a scalar weight: the x–y block is large, the θ–θ entry is usually much larger, and the cross terms are what encode a scan matcher that is confident along a wall and uncertain along it."

The scalar version and the block version have identical structure — four writes, two on the diagonal, two off — and that is the point worth landing: the graph does not care about block size. Every count multiplies by 9 for SE(2) or 36 for SE(3), every flop count by roughly 27 or 216, and nothing else changes. That is exactly why block-oriented solvers run the symbolic analysis on the scalar block graph and then call dense 3×3 or 6×6 kernels underneath.

Drill 2 — "Marginalise a variable."

python
X = np.linalg.solve(Laa, Lab)          # NOT inv(Laa) @ Lab
Lam_marg = Lbb - Lba @ X
eta_marg = eta_b - Lba @ np.linalg.solve(Laa, eta_a)

Say while writing: "Solve, never invert — inverting costs three times as much and squares the condition number. And I would store the linearisation point alongside this prior, because it is now frozen: relinearising it later is the FEJ bug."

Drill 3 — "Compute the fill-in of an elimination ordering." A pure graph problem, and a favourite because it has nothing to do with robotics until the last sentence:

python
def fill_in(n, edges, order):
    adj = [set() for _ in range(n)]
    for a, b in edges: adj[a].add(b); adj[b].add(a)
    alive, fill = set(range(n)), 0
    for v in order:
        alive.discard(v)
        nb = sorted(adj[v] & alive)
        for a in range(len(nb)):
            for b in range(a+1, len(nb)):
                if nb[b] not in adj[nb[a]]:
                    adj[nb[a]].add(nb[b]); adj[nb[b]].add(nb[a]); fill += 1
        adj[v].clear()
    return fill
# chain, natural order      -> 0    (perfect elimination ordering)
# chain, middle first       -> 1    (couples the two halves)
# 6-cycle, natural order    -> 3    (a loop closure cannot be free)
# 6-star, hub eliminated 1st-> 15   (= 6*5/2 — the landmark you marginalised too early)

Say while writing: "This is the symbolic phase of a sparse Cholesky, and it runs before any arithmetic. The last line is a landmark seen by six cameras: marginalise it first and you get a six-clique. That is not a bug — it is the Schur complement, and in bundle adjustment it is exactly what you want, because the clique lands in the small camera block."

Drill 4 — "Preintegrate an IMU segment." The order of the four lines is the whole drill:

python
for a, w, dt in imu:
    f = a - ba
    dp += dv*dt + 0.5*dR@f*dt*dt      # uses dv and dR from BEFORE this sample
    dv += dR@f*dt                     # then update dv...
    dR  = dR @ Exp((w - bg)*dt)       # ...and dR last

Say while writing: "Order matters: position integrates the velocity that was already accumulated, so it must be updated first. And I would accumulate the bias Jacobians in the same loop, because re-integrating on every bias update defeats the purpose."

Then put a number on the bug, because this is the one they will push on. Suppose you update dv before dp. The position line now uses dvnew = dvold + aΔt, so the increment becomes (dvold + aΔt)Δt + ½aΔt2 instead of dvoldΔt + ½aΔt2. Subtract: the per-sample error is aΔt2 — not ½aΔt2, because the half-term is still there. Exactly twice what a careless answer claims, and being able to say which factor and why is the difference between having read the code and having written it.

Now make it land. At 200 Hz, Δt = 5 ms, so a modest 1 m/s2 of specific force leaks 1 × (0.005)2 = 2.5×10−5 m = 25 microns per sample. Over the 50 samples between keyframes that is 50 × 25 µm = 1.25 mm of position error per segment — utterly invisible in one segment and smaller than your feature noise reprojects to. But it is signed with the acceleration, not zero-mean, so it does not average out: over a thousand segments it is a systematic 1.25 m of accumulated inflation on the inertial leg, which the optimiser resolves by quietly shrinking the visual scale to match. That is a scale bias that no chi-square will flag, and it is why this drill exists.

Drill 5 — "Build the Schur complement for a bundle adjustment with 3 cameras and 4 points." Small enough to write out in full at a whiteboard, which is exactly why it gets asked. Start by drawing the partition before writing any code, because the partition is the answer:

Λ = [[ U (18×18) , W (18×12) ], [ WT (12×18) , V (12×12) ]]

Give yourself a concrete visibility so the fill is visible: p1 seen by {c1, c2}, p2 by {c1, c2, c3}, p3 by {c2, c3}, p4 by {c1, c3}. Then eliminate all four points:

python
# U : (6C, 6C)          V : (P, 3, 3)      W : (C, P, 6, 3)
# bc: (6C,)             bp: (P, 3)         obs[j] -> list of cameras
S = U.copy(); b = bc.copy()
for j in range(P):                          # points, in ANY order
    Vinv = np.linalg.inv(V[j])              # (3,3) -- the only inv allowed
    cams = obs[j]
    for i in cams:
        Y = W[i][j] @ Vinv                    # (6,3)
        b[6*i:6*i+6] -= Y @ bp[j]              # (6,)
        for k in cams:                       # <-- THE CLIQUE
            S[6*i:6*i+6, 6*k:6*k+6] -= Y @ W[k][j].T   # (6,6)
dxc = np.linalg.solve(S, b)                  # (18,) -- reduced camera solve
for j in range(P):                          # back-substitute the points
    acc = bp[j].copy()
    for i in obs[j]: acc -= W[i][j].T @ dxc[6*i:6*i+6]
    dxp[j] = np.linalg.solve(V[j], acc)      # (3,)

Say while writing: "The inner double loop over cams is the clique. For point j seen by nj cameras I write nj2 blocks of 6×6, and the off-diagonal ones are couplings no measurement created — that is the fill-in, and its pattern is the co-visibility graph. With my visibility, c1–c2 is coupled through p1 and p2, c2–c3 through p2 and p3, c1–c3 through p2 and p4 — so all three pairs fill, U was block-diagonal and S is dense 18×18. That is (3 choose 2) = 3 new blocks, the d(d−1)/2 rule from the fill-in row of my cheat sheet. And np.linalg.inv appears exactly once, on a 3×3; everywhere else it is solve."

Then volunteer the honest cost, because at these sizes Schur barely wins. Naive: (18 + 12)3/3 = 303/3 = 9,000 flops. Schur: four 3×3 inverses at ~27 flops = 108; the pair loop runs ∑nj2 = 22 + 32 + 22 + 22 = 21 block products, each a (6×3)(3×3) then a (6×3)(3×6) at 54 + 108 = 162 flops, so 21 × 162 = 3,402; plus the reduced solve 183/3 = 1,944. Total ≈ 5,450 flops — a speed-up of 1.65×. Say that number out loud. The toy sizes of 3 and 4 exist so you can write the blocks, not so you can be impressed; the speed-up only becomes 105–106 when P ≫ C, and knowing where the method stops paying is worth more than knowing that it pays.

4 · Debugging scenarios

SymptomRoot causeThe metric that reveals it, and the value that separates it from its decoy
Solve time went from 20 ms to 4 s. Iteration count unchanged, residuals unchanged, solution identical to 10−9. Elimination ordering. New loop closures changed the connectivity; the ordering was pinned to insertion order. nnz(L)/nnz(Λ). Healthy is 1–3×; a bad ordering shows 40×+. The decoy is "the graph got bigger", which would move nnz(Λ) — and nnz(Λ) grew only linearly.
Reported yaw σ falls to 0.1° while true yaw error grows to 6°. Every residual healthy. Inconsistent linearisation — no FEJ on the marginalisation prior, or an EKF relinearising across updates. Numerical nullspace dimension of the stacked Jacobian: must be 4 for monocular VIO, 3 for 2-D SLAM. It reads one less. The decoy is "Q too small", which would also shrink x and y — here only yaw degrades.
Converges in 180 iterations where it used to take 5, λ ends at 104, cost still decreases. Analytic Jacobian does not match the residual. Central-difference relative error at ε = 10−6: correct is < 10−7, a sign error or missing chain-rule term shows ~10−3, a transpose shows ~1. The decoy is "hard problem", which makes λ oscillate rather than climb monotonically.
Scale drifts 8% on a straight motorway run; accel bias estimate wanders to 0.4 m/s2; reprojection error flat at 0.35 px. No excitation — scale and accelerometer bias are the same direction in state space under constant velocity. Std. dev. of ‖a‖ − g over the window. Healthy > 0.5 m/s2; unobservable < 0.05. The decoy is "not enough features", which makes the position covariance grow — here it looks fine and only scale degrades.
Cholesky throws "not positive definite" on the first iteration of a new dataset. Gauge not fixed, or the graph has more than one connected component. Count of eigenvalues at machine zero. Exactly 3 (2-D), 6 (3-D / stereo) or 7 (monocular) means gauge — add the prior. Any other count means disconnection; run connected components and the count of extra zeros equals the number of extra components.

5 · Classical vs modern, and when to use which

This table is the frontier view, and it is used differently from the others. The two middle columns are trivia — anyone can recite that iSAM2 superseded EKF-SLAM. The fourth column is the whole point. Read the table left to right and you sound current; read it right to left and you sound like someone who has shipped, because the question underneath every “should we use the new thing?” discussion is really “do you reach for the new thing reflexively, or because the constraint demands it?”

How to use it. When a modern method comes up, do three things in one breath: (1) confirm you know it and what it replaced, (2) state the condition under which the classical choice still wins, (3) name the number that decides between them. “Yes, we would use a fixed-lag smoother with FEJ — but on a 4 W platform with no loop closures I would benchmark it against a consistent MSCKF first, because OpenVINS gets comparable ATE there at roughly half the compute, and the crossover is whether you ever revisit.” That answer is unbeatable in both directions: you cannot be caught as out of date, and you cannot be caught as a cargo-culter.

The one row that is a genuine trap is the first. “Is the EKF dead?” invites a confident yes, and the confident yes is wrong — it is dead for mapping, where the dense covariance is fatal past a few hundred landmarks, and alive for bounded-state odometry on power-limited hardware. Split the question before you answer it. The people who get this wrong are not the ones who do not know; they are the ones who heard “the field moved on” and stopped there.

AxisClassical choiceModern choiceWhen the classical one is still right
EstimatorEKF-SLAM (dense covariance, one linearisation)Factor graph with fluid relinearisation (iSAM2)Never, for mapping. The dense covariance is fatal past a few hundred landmarks.
Bounded-state odometryMSCKF (clone poses, marginalise features)Fixed-lag smoother with FEJOften. Under ~5 W with no loop closures, a consistent MSCKF gives comparable accuracy at lower cost. OpenVINS is the reference.
Linear solveNormal equations + CholeskyQR / square-root, or iterative + preconditionerWhenever κ(J) < ~104 and you are in double precision — which is most of the time. Cholesky is half the flops.
Loop closureBag-of-words + geometric verification + robust kernelLearned global descriptors (NetVLAD-family), still with geometric verificationBoth need the verification. The learned descriptor changes recall, not the consequence of a false positive.
FrontendHand-crafted features (ORB, SIFT) + RANSACLearned dense flow (DROID-SLAM and successors)When compute is tight, or when you need a system you can debug. The learned frontend needs a GPU.
Noise modelsHand-tuned, from Allan variance and a chi-square sweepLearned per-measurement covarianceBy default. A learned covariance must ship with a validity monitor and a fallback, or it fails confidently.
Global optimalityGood initialisation, then LM, and hopeConvex relaxation with a certificate (SE-Sync)Whenever you have a good initialisation from odometry — which in SLAM you almost always do.

6 · The numbers drill

One significant figure, in your head, in ten seconds each. If you cannot do these you cannot make a design argument, because every design argument is a comparison of two of them.

But you do not get to ten seconds by starting there. Two of these get written out below with every intermediate value visible — the products, the byte counts, the divisions — because the compressed version in the table is only reproducible under pressure if you have produced the long version at least once. Do these two slowly with a pen. Then the table becomes a re-test rather than a first attempt.

Worked example 1 — EKF-SLAM memory, and the growth law

The question. “EKF-SLAM in 2-D with 500 landmarks. How big is the covariance matrix, and what happens when I double the landmarks?”

  1. State dimension. The robot pose in SE(2) is 3 numbers (x, y, θ). Each landmark is a 2-D point, so 2 numbers each. n = 3 + 2 × 500 = 3 + 1000 = 1003.
  2. Entry count. The covariance is n × n and dense — that is the whole complaint. 1003 × 1003 = 1,006,009 entries. (Do it as 10002 + 2×1000×3 + 32 = 1,000,000 + 6,000 + 9 if you want it without a calculator.)
  3. Bytes. Double precision is 8 bytes per entry. 1,006,009 × 8 = 8,048,072 bytes.
  4. Into human units. 8,048,072 / 1,048,576 = 7.67 MiB, which you round to ≈ 8 MB and say out loud.
  5. Now redo it at 1,000 landmarks, so the reader sees the law rather than being told it. n = 3 + 2 × 1000 = 2003. 2003 × 2003 = 4,012,009 entries. × 8 = 32,096,072 bytes = 30.6 MiB ≈ 32 MB.
  6. The ratio. 4,012,009 / 1,006,009 = 3.99. Doubling the landmarks quadrupled the memory, because n is linear in the landmark count and memory is n2.

What to say with it. “Eight megabytes at 500 landmarks sounds harmless, so let me extend the line: 32 MB at 1,000, half a gigabyte at 4,000, eight gigabytes at 16,000. And memory is the polite problem — the update touches every entry, so the time cost grows as n2 per step too, and at 4,000 landmarks that is 6.4×107 multiply-adds per measurement. A warehouse aisle has more than 16,000 distinguishable features. That is not a tuning problem you can optimise your way out of; it is the reason the field moved.” The number that makes this argument is not 8 MB. It is .

Worked example 2 — the bundle-adjustment Schur speed-up, denominator included

The question. “100 cameras, 20,000 points. How much does the Schur complement actually buy you?” Most answers produce the numerator and hand-wave the denominator. The denominator is the answer.

Step 1 — the naive size. 6 DOF per camera and 3 per point: 6 × 100 + 3 × 20,000 = 600 + 60,000 = 60,600 unknowns.

Step 2 — the naive cost. Dense Cholesky is n3/3. 60,6002 = 3.672×109; × 60,600 = 2.225×1014; divided by 3 = 7.42×1013 flops. At 10 GFLOP/s that is 7.42×1013 / 1010 = 7,420 s = 2.06 hours.

Step 3 — derive the denominator, do not assert it. Assume each point is seen by 4 cameras (80,000 observations, 800 per camera — a realistic reconstruction). Eliminating the points costs three things:

Step 4 — add them. 5.4×105 + 5.18×107 + 7.2×107 = 1.24×1081.2×108 flops. That is where the constant in the table comes from, and now it is reconstructible instead of magic.

Step 5 — divide. 7.42×1013 / 1.24×108 = 5.98×105 ≈ 6×105×. And in wall clock at 10 GFLOP/s: 1.24×108 / 1010 = 12.4 ms. Two hours becomes twelve milliseconds — which is exactly the “twelve milliseconds” in section 9's rehearsal list, and you should now be able to rebuild it rather than recall it.

The sanity check to say out loud. Notice that the two surviving terms are the same order: 5.2×107 to build S and 7.2×107 to factor it. That balance is not a coincidence, it is the design point — and it tells you the failure mode. Push the points per camera up (denser tracks, more nj2) and the build dominates; push the cameras up and the 6003 term dominates, which is why past a few thousand cameras people stop using DENSE_SCHUR and start exploiting sparsity inside S — the co-visibility graph — with SPARSE_SCHUR or a preconditioned conjugate-gradient method. If you can name where the balance tips, you have understood the method rather than the formula.

Now do them in your head

Same numbers, compressed to what you would actually say. Cover the answer column and work down; the target is one significant figure in ten seconds, and the two rows you just derived long-hand should now be the two fastest.

QuestionWorkingAnswer
EKF-SLAM with 500 landmarks: how big is the covariance?n = 3 + 2(500) = 1003; 10032 = 1,006,009; × 8 B = 8,048,072; / 2208 MB (7.67 MiB)
…and at 1,000 landmarks?n = 2003; 20032 = 4,012,009; × 8 = 32,096,072 B; ratio 4,012,009/1,006,00932 MB — exactly 4×
Dense Cholesky of a 30,000-unknown pose graph, at 10 GFLOP/s?30,0003 = 2.7×1013; /3 = 9×1012; /1010900 s ≈ 15 min
…and the same graph with AMD, using the nested-dissection bound ≈10n1.5?30,0001.5 = 30,000 × 173.2 = 5.20×106; ×10 = 5.2×107; /10105 ms — a 1.7×105 gap
Nonzeros in that pose graph (5,000 poses, 6 DOF, 800 loops)?5,000 + 2(4,999) + 2(800) = 5,000 + 9,998 + 1,600 = 16,598 blocks; × 36 = 597,5286 × 105 (0.07% fill)
BA with 100 cameras, 20,000 points: reduced system size?6 × 100 = 600 rows; the 60,000 point columns are all eliminated600 × 600
…and the cost of building and solving it?20,000 × [33 inverse + 42 pairs × 162] = 5.2×107; + 6003/3 = 7.2×1071.2×108 flops = 12 ms
…and the speed-up over the naive solve?60,6003 = 2.23×1014; /3 = 7.42×1013; ÷ 1.2×108≈ 6 × 105 × (2 h → 12 ms)
IMU samples between keyframes at 200 Hz and 4 Hz?200/450
Naive re-integrations per solve (10 KF window, 6 LM iterations)?50 × 9 × 62,700 (vs 9 with preintegration)
Position error from a 0.01 m/s2 accel bias over 5 s?½(0.01)(25)12.5 cm
Gyro bias instability of 10°/h, in °/s?10/36000.0028 °/s
Cross-track error from a 5 ms camera–IMU time offset at 10 m/s?10 × 0.0055 cm
Fill created by marginalising a landmark seen by 10 cameras?10 × 9 / 2 = 45 block edges; × 36 = 1,620 new scalar entries above the diagonal45 edges
Sliding-window VIO: what is n?10 KF × 15 (pose 6 + vel 3 + bg 3 + ba 3) = 150; + 50 landmarks × 3 = 150; + extrinsic 6 + time offset 1 = 7n = 307
…and its dense Cholesky?3072 = 94,249; × 307 = 2.89×107; /3 = 9.6×106; /1010≈ 1 ms (2–4 ms in practice)
Digits surviving a normal-equation solve at κ(J) = 105, double precision?16 − log10(1010)6 of 16
Loop error 0.2 m spread over a 4-edge cycle of equal weight?0.2/4 each; cost 0.02 → 0.0050.05 m per edge, cost /4
The whiteboard clock — rehearse it against a timer

Press Next question, then answer it out loud before the ring closes. Ten seconds, one significant figure. Press Reveal working only after you have committed — reading the answer first feels like learning and is not. Each question also snaps the slider to its problem size, so you watch the two cost curves separate at exactly the n you were just reasoning about.

Problem size n 30,000

What the two curves are. The upper curve is dense Cholesky, n3/3 flops — what you get if you hand a factor graph to a dense linear-algebra library and walk away. The lower curve is the nested-dissection bound, ≈10n1.5 flops, which is what a good ordering (AMD, COLAMD, or true nested dissection) achieves on a graph that is roughly planar — and a warehouse pose graph, drawn on a floor, is roughly planar. The constant 10 is George's 829/84 ≈ 9.87 for a √n × √n grid; it is a bound, not a promise, and a graph with many long-range loop closures does worse.

Read the gap, not the curves. At n = 1,000 the two are about 103 apart — both are instant, and nobody would notice a bad ordering. At n = 30,000 they are 1.7×105 apart: 900 seconds against 5 milliseconds. The gap grows as n1.5, which is the honest reason the ordering bug in section 4 shows up as “it was fine for six months and then one Tuesday it took four seconds.” The bug was always there; the graph simply crossed the n where the exponent starts to matter. Drag the slider slowly and find where the dense wall-clock label crosses one second: n3/3 = 1010 gives n3 = 3×1010, so n ≈ 3,100 — about 500 six-DOF keyframes. That is where a demo that ran fine on your desk stops running fine on the robot, and it is a far smaller number than most people guess.

7 · Recommended reading

The one book. Dellaert & Kaess, Factor Graphs for Robot Perception (Foundations and Trends in Robotics, 2017). About 130 pages, free as a PDF, and it is the only text that treats elimination ordering, the Bayes tree and incremental smoothing as one continuous idea rather than three tricks. If you read one thing on this topic, read sections 3 and 4.

Five papers, with why.

PaperWhy this one
Dellaert & Kaess, Square Root SAM, IJRR 2006The paper that turned SLAM into sparse linear algebra. Read it for the elimination-ordering section, which is the intellectual core of this whole lesson.
Kaess, Johannsson, Roberts, Ila, Leonard & Dellaert, iSAM2, IJRR 2012The Bayes tree, and the argument for relinearising only what moved. This is the answer to "how can you afford to keep everything?"
Huang, Mourikis & Roumeliotis, Observability-based rules for designing consistent EKF SLAM estimators, IJRR 2010The proof that EKF-SLAM's inconsistency is a rank error, not a tuning error. It is what makes the FEJ discussion rigorous rather than folklore.
Forster, Carlone, Dellaert & Scaramuzza, On-Manifold Preintegration for Real-Time Visual-Inertial Odometry, T-RO 2017The canonical preintegration reference, with the uncertainty propagation done properly on SO(3). Appendix included — it is where the bias Jacobians come from.
Cadena, Carlone, Carrillo, Latif, Scaramuzza, Neira, Reid & Leonard, Past, Present, and Future of SLAM, T-RO 2016The survey. Read it to be able to place any question in the field's map, and to have a defensible opinion about what "solved" means.

Five repositories, and what to look at inside each.

RepoGo straight to
borglab/gtsamexamples/Pose2SLAMExample.cpp for the shape of a graph, then gtsam/nonlinear/ISAM2.cpp — specifically ISAM2::update and the markedKeys logic, which is the incremental re-elimination in the flesh.
ceres-solver/ceres-solverexamples/bundle_adjuster.cc for the ordering setup, then internal/ceres/schur_eliminator_impl.h — the templated block sizes there are the reason Ceres BA is fast.
HKUST-Aerial-Robotics/VINS-Monovins_estimator/src/factor/integration_base.h (preintegration, midpoint method, bias Jacobians all in one file) and marginalization_factor.cpp (a Schur complement written out by hand, threads and all).
rpng/open_vinsov_msckf/src/state/State.h and the FEJ handling — every variable carries both its current estimate and its first estimate, and seeing that in code makes the whole consistency argument concrete.
RainerKuemmerle/g2og2o/core/block_solver.hpp — the cleanest short implementation of the arrowhead Schur complement in any of these codebases, and ORB-SLAM's Optimizer.cc shows it in use.

8 · Twelve follow-ups, and the one-line answer to each

These are the second questions — the ones that arrive after a good first answer, probing for the floor of your understanding. Each answer below is deliberately one sentence.

How to use this table. Answer in one sentence and then stop. Almost every one-liner below has a number sitting just behind it (100×, 2,700, 0.01 rad/s, dimension 4) that is exactly what “say more” is asking for — practise giving the sentence first and spending the number only on demand, because that discipline is what makes depth usable in a design review.

Rows 3 and 4 are a matched pair and they are the trap. “Does marginalisation lose information?” wants no. “So why not marginalise everything?” then punishes you if no was all you had. The two answers must be consistent, and the only consistent framing is the one from Chapter 3: marginalisation is exact for the linearised system, and what it costs is sparsity plus the right to relinearise. Say “exact” without the qualifier on row 3 and you have already lost row 4. Rows 1–2 and 8–9 are similar pairs: sparse/dense and ordering/QR. Whenever an answer feels satisfyingly absolute, assume the next question is built to break it, and pre-load the qualifier.

Follow-upThe one-line answer
"Why is the information matrix sparse?"Because Λij is nonzero exactly when i and j appear in a common factor, and a measurement touches two or three variables out of thousands.
"Why is the EKF covariance dense, then?"Because marginalising a variable makes its neighbours a clique, and the filter marginalises every past pose — every landmark ends up coupled to every other one through the poses that saw them.
"Does marginalisation lose information?"No — it is exact for the linearised system. It costs sparsity and the ability to relinearise, not information.
"So why not marginalise everything and stay fast?"Because the frozen linearisation is what makes an estimator inconsistent: mixed linearisation points give the linearised system the wrong unobservable-subspace dimension.
"How many unobservable directions does your problem have?"2-D SLAM: 3. 3-D pose graph and stereo BA: 6. Monocular BA: 7 (scale). Monocular VIO: 4 — gravity fixes roll and pitch, the accelerometer fixes scale.
"What does a loop closure do to the trajectory?"It redistributes the closure error over the whole cycle, in proportion to each edge's variance — which is why one over-confident false loop closure corrupts every pose in the loop, not one.
"Why Levenberg–Marquardt and not Gauss–Newton?"Gauss–Newton takes the full step its linear model asks for and never checks the result; on my toy problem that turned a cost of 8 into 79,000 in one step.
"Why does ordering matter if the answer is the same?"Because the cost is the sum of the squares of L's column counts, and the ordering sets those counts — on a real pose graph the gap between natural and AMD is about 100× in time and zero in accuracy.
"When would you use QR instead of Cholesky?"When κ(J) is large or you are in single precision, because forming the normal equations squares the condition number and QR does not.
"Why preintegrate rather than add IMU factors?"Because the naive integral depends on the initial pose and velocity, so it has to be redone on every relinearisation — roughly 2,700 times per solve in a ten-keyframe window.
"What breaks preintegration?"A large change in the gyro-bias estimate, because the rotation delta depends on it through a matrix exponential — past about 0.01 rad/s you re-propagate from raw samples.
"Is the filter dead?"For mapping, yes. For bounded-state odometry on a power-limited platform, no — and equivariant filter design is actively attacking the linearisation problem that killed it.

9 · The one-hour refresher

10 · One last question, and it is a judgement call

Every quiz in this lesson has been a concept check. This one is not. It is a judgement call, and the wrong options are not wrong because they are absurd — they are wrong because each one is something a competent engineer actually says.

Your fixed-lag smoother marginalises the oldest keyframe every cycle. What did that cost you, and how would it show up in your telemetry?

Why the second option beats the first, and it is one word. The first answer is not absurd — it is a true sentence deployed as a complete one, which is a harder habit to unlearn than a factual gap. “Marginalisation is exact” survives only with four words bolted on: for the linearised system. Add them and the same sentence becomes the strongest answer available, because the qualifier immediately produces both costs. Exactness is conditional on a linearisation point, so you have frozen one — and every later cycle relinearises the live states around it, mixing linearisation points and fabricating information along the yaw direction, which is why the numerical nullspace reads 3 where you derived 4. And integrating a variable out couples every neighbour it touched, so Λ densifies on the window's oldest block and nnz(L)/nnz(Λ) creeps — not a jump like the ordering bug in section 4, a slow climb, one clique per cycle. One qualifier, two costs, two metrics, and both metrics are cheap enough to ship in telemetry. Why the other two are wrong, specifically. “Information loss” inverts the result: marginalisation adds no residual and removes no constraint, so a chi-square that grows as the window slides is telling you about a different bug — usually a prior whose information was inflated by double-counting a factor that was kept as well as marginalised. And a climbing LM rejection count means the quadratic model is not predicting the cost decrease, which is a Jacobian that disagrees with its residual; that is section 4's third row, diagnosed by central differences, and marginalisation has nothing to do with it. Naming the metric is half the answer. Naming the metric that would not move is the other half.

The four sentences to walk away with

1. "The filter's covariance is the smoother's information matrix after you Schur-complement out every pose you decided not to keep. Density is the arithmetic residue of forgetting."
2. "Marginalisation is exact. What it costs is fill-in and a linearisation you can never take back — and the second one is what makes an estimator inconsistent, not inaccurate."
3. "The ordering is the algorithm. It changes nnz(L) by orders of magnitude and the answer by nothing, which is why an ordering bug looks like a hardware problem."
4. "Preintegration is a change of variables, not an approximation: put the deltas in the first body frame and they stop depending on the states they constrain, so they survive every relinearisation for free."

Want to test yourself under a clock? The Studio button runs a timed practice session on exactly this material.

"What I cannot create, I do not understand." — and in this field, what you cannot re-derive at a whiteboard, you cannot debug at three in the morning.

Bridges from this lesson: SLAM: Factor Graphs · Factor Graphs · Classical VIO · Visual-Inertial Odometry · Uncertainty & Robust Costs · Modern SLAM