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.
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:
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:
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."
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 tree | SLAM: Factor Graphs |
| The graph formalism on its own, with a live pose-graph playground | Factor Graphs |
| How vision and inertial data get fused at all: loose vs tight, MSCKF, windows | Classical VIO |
| The VIO systems view: initialisation, observability, what ships | Visual-Inertial Odometry |
| Covariance, Mahalanobis, least squares to MAP, robust costs | Uncertainty, 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.
| Lens | The question it asks | What 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.) |
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.
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:
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:
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:
Scatter the three outer products in, one at a time, and add:
| Factor | Its row of A | What 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.
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:
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 Λ:
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:
Subtract it:
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:
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 eliminate | d | New block pairs = d(d−1)/2 | Consequence |
|---|---|---|---|
| An interior pose of an odometry-only chain | 2 | 2·1/2 = 1 | The chain stays a chain. Free. This is why odometry-only marginalisation never hurt anyone. |
| A keyframe that observed 30 landmarks, plus its 2 chain neighbours | 32 | 32·31/2 = 496 | 496 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 landmarks | 500 | 500·499/2 = 124,750 | Every 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.
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.
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.
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).
| Pile | Count | Entries each | Symmetric copy? | Nonzeros |
|---|---|---|---|---|
| Diagonal, poses | 170 blocks | 3×3 = 9 | Already symmetric | 170 × 9 = 1,530 |
| Diagonal, landmarks | 500 blocks | 2×2 = 4 | Already symmetric | 500 × 4 = 2,000 |
| Odometry, pose–pose | 169 edges | 3×3 = 9 | ×2 — (i,j) and (j,i) | 169 × 2 × 9 = 3,042 |
| Observations, pose–landmark | 500 × 4 = 2,000 blocks | 3×2 = 6 | ×2 — the transpose block is 2×3 | 2,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."
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 N | EKF state n = 3 + 2N | Dense Σ (doubles) | Bytes | Update cost O(n2) |
|---|---|---|---|---|
| 50 | 103 | 10,609 | 85 kB | 1.1 × 104 |
| 500 | 1,003 | 1,006,009 | 8.05 MB | 1.0 × 106 |
| 5,000 | 10,003 | 100,060,009 | 800 MB | 1.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:
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.
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 doubles | Dense factorisation | Growth | Sparse factorisation | Growth | Ratio |
|---|---|---|---|---|---|
| 900 → 1,800 | 1.22 → 7.73 ms | ×6.3 | 0.77 → 2.76 ms | ×3.6 | 1.6× |
| 1,800 → 3,600 | 7.73 → 54.10 ms | ×7.0 | 2.76 → 6.06 ms | ×2.2 | 8.9× |
| 3,600 → 7,200 | 54.10 → 424.01 ms | ×7.8 | 6.06 → 18.54 ms | ×3.1 | 22.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.
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()
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 answer | Why 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 answer | Why 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. |
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.
| Year | What changed | The paper |
|---|---|---|
| 1986 | The 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 |
| 1997 | Reframe SLAM as a network of relative pose constraints and optimise it globally. The pose graph. | Lu & Milios, Globally consistent range scan alignment, Autonomous Robots |
| 2002 | Rao-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 |
| 2006 | Square-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 |
| 2010 | Proof 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–12 | The 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–17 | IMU preintegration on the manifold makes tightly-coupled visual-inertial smoothing real-time. | Forster, Carlone, Dellaert & Scaramuzza, RSS 2015 / T-RO 2017 |
| 2020–24 | The 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) |
Almost every backend discussion is secretly about placing one of these five on a map. Learn the axes, not the list.
| Backend | What it keeps | Cost per update | Relinearises? | Where it still wins |
|---|---|---|---|---|
| EKF-SLAM | Current pose + all landmarks, dense Σ | O(N2) | Never | Nowhere, for mapping. Historically important; still a great teaching object. |
| FastSLAM (RBPF) | M sampled trajectories, each with independent landmark filters | O(M log N) | Per particle, implicitly | 2-D grid SLAM on cheap hardware — gmapping is still deployed. |
| MSCKF | A short window of clone poses; landmarks marginalised at once via nullspace projection | O(window3), bounded | No — and FEJ deliberately freezes the linearisation point further, to fix observability rather than to improve the estimate | 4–10 W platforms, no loop closures, tight latency. OpenVINS. |
| Fixed-lag smoother | The last K keyframes + their landmarks; older states marginalised into a dense prior | O(K3) — bounded by construction | Yes, inside the window | The default for shipping VIO. VINS-Mono, OKVIS, Kimera. |
| Full smoother (iSAM2) | Everything, forever | Amortised near-constant; spikes on loop closure | Yes — fluid, only where needed | Mapping, 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 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.
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.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.
| Symptom | What most people blame | What it actually is | The 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.
When you need to explain this to a teammate, structure beats completeness. This is the script:
Step 6 is what turns a good explanation into a trustworthy one. Everyone has heard the sparsity story; almost nobody volunteers the counter-case.
"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.
Not "know about". Do, with a marker in your hand, from memory:
Chapter 1 does the derivation that makes all of this precise: what marginalisation is, what it costs, and why the answer is not "accuracy".
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.
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 Σ.
The information form (also called the canonical form) multiplies the exponent out and keeps the two things that survive:
Λ 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:
| Operation | In 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 answer | Free — μ is right there. | Requires solving Λx = η. |
| Sparsity | Dense as soon as anything is correlated. | Nonzero at (i, j) iff i and j appear in a common factor. |
A factor graph's cost is a sum of squared residuals, one per measurement:
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:
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 ½:
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δ:
Move the constant vector across and you have the normal equations:
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.
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:
Now scatter the three contributions into a 3×3 grid, one at a time, and add:
| Factor | Rows/cols it touches | What 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.
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.
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:
Marginalising means integrating a out: p(b) = ∫ p(a, b) da. Complete the square in a inside the exponent. The exponent is
Group everything that contains a. Two of the five terms above do:
That is a quadratic in a with linear coefficient (ηa − Λabb), so its stationary point is a* = Λaa−1(ηa − Λabb). Complete the square about it — add and subtract ½a*TΛaaa* — and the whole exponent, both blocks, becomes:
Only the first term contains a, and ∫ exp(−½(a−a*)TΛaa(a−a*)) da = (2π)n/2|Λaa|−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:
(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:
| Piece | Value | Where it goes |
|---|---|---|
| constant in b | +½ ηaTΛaa−1ηa | Into the normalisation. Irrelevant to the estimate. |
| linear in b | − bT ΛbaΛaa−1ηa | Adds to ηbTb → the η' correction. |
| quadratic in b | +½ bT (ΛbaΛaa−1Λab) b | Adds to −½bTΛbbb → the Λ' correction. |
Collect the b-quadratic terms: −½bTΛbbb + ½bT(ΛbaΛaa−1Λab)b = −½bT(Λbb − Λ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:
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:
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}.
The correction is
Subtract entry by entry: (0,0): 8 − 3.2 = 4.8. (0,1): −4 − 0 = −4. (1,1): 4 − 0 = 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}.
Look at entry (0,1). It was 0. It is now −2.
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.
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):
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].
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.
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):
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:
Subtract it from Λbb = I4. Every diagonal entry: 1 − 0.25 = 0.75. Every off-diagonal entry: 0 − 0.25 = −0.25. All sixteen:
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.
| Quantity | Before eliminating l | After |
|---|---|---|
| Matrix size | 5 × 5 (25 slots) | 4 × 4 (16 slots) |
| Nonzeros | 13 | 16 |
| Density | 52% | 100% |
| Off-diagonal nonzeros | 8 | 12 (all new) |
| Cholesky cost, n3/3 dense | ≈ 42 flops | ≈ 21 flops |
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:
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)/2 | nnz before → after | Verdict |
|---|---|---|---|
| 2 | 1 | 7 → 4 | Cheaper. Marginalise freely. |
| 3 | 3 | 10 → 9 | Break-even. |
| 4 | 6 | 13 → 16 | Starts to hurt. |
| 10 | 45 | 31 → 100 | 3× the nonzeros for one variable. |
| 50 | 1,225 | 151 → 2,500 | A long-lived feature is a catastrophe. |
| 200 | 19,900 | 601 → 40,000 | Why 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.
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.
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:
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):
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:
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:
| Iter | x̂ | h(x̂) | r = h − z | J = x/h | δ = −r/J | new x̂ |
|---|---|---|---|---|---|---|
| 0 | 1.000000 | 3.162278 | −1.837722 | 0.316228 | +5.811388 | 6.811388 |
| 1 | 6.811388 | 7.442782 | +2.442782 | 0.915167 | −2.669221 | 4.142168 |
| 2 | 4.142168 | 5.114446 | +0.114446 | 0.809896 | −0.141309 | 4.000859 |
| 3 | 4.000859 | 5.000687 | +0.000687 | 0.800062 | −0.000859 | 4.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.
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.
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.
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 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.
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.
A shipping VIO system does not choose. It runs both, at different rates, for different reasons.
| Layer | What it is | Rate | State | Budget |
|---|---|---|---|---|
| IMU propagation | Pure integration of the last optimised state. No optimisation at all — it is a filter's predict step with no update. | 200 Hz | 16 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 optimiser | Fixed-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 graph | Global smoother over keyframe poses only. Landmarks already marginalised into relative pose constraints. | On loop closure, ~0.1 Hz | 6 × number of keyframes | No 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.
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
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."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 cause | What it would look like | Ruled in or out by… |
|---|---|---|
| Process noise Q too small | trace(P) collapses and the collapse rate scales with how much you shrink Q | Sweep Q by 10×. If the yaw covariance still collapses (just slower), Q is not the cause. |
| Data association errors | Bursty innovations, occasional large NIS spikes, a visibly corrupted map | Look at the NIS distribution. Association faults move the tail; this fault moves the whole distribution. |
| Linearisation inconsistency | Yaw specifically — not x, not y — becomes overconfident, and the effect is worse the more the robot turns | The 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"
Three live threads, all worth naming with a year:
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.
| t | Say and write | The tell that you have it |
|---|---|---|
| 0:00 | Draw 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:40 | Scatter: 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:20 | Point 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:00 | Marginalise 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:50 | Circle 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. |
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.
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:
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.
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:
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
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:
Step 4 — solve Λδ = η by hand. Write the four equations with δ = (a, b, c, d):
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:
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.
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.
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.
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
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.
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:
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.
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:
Now the arithmetic that makes it worth doing. Take C = 100 cameras, P = 20,000 points, each point seen by k = 4 cameras.
| Route | What you factor | Flops | At 10 GFLOP/s |
|---|---|---|---|
| Naive — dense Cholesky on the whole thing | n = 6(100) + 3(20,000) = 60,600 | n3/3 = 7.4 × 1013 | 2.1 hours |
| Schur, step 1: invert V | 20,000 independent 3×3 inverses, ~30 flops each | 6 × 105 | 0.06 ms |
| Schur, step 2: form S | per point, k2 = 16 blocks of (6×3)(3×3)(3×6) | 5.2 × 107 | 5 ms |
| Schur, step 3: factor S | dense 600×600 | (600)3/3 = 7.2 × 107 | 7 ms |
| Schur total | 1.2 × 108 | 12 ms |
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.
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.
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.
ORB-SLAM-family systems run both structures, and knowing why is a design lesson in itself.
| Component | Structure | Size | Rate | Wall time | Why this one |
|---|---|---|---|---|---|
| Local BA | Bundle adjustment over the covisible keyframes | ~10 KF × ~300 points → 60 + 900 = 960 unknowns | Every new keyframe, ~4 Hz | 8–15 ms, DENSE_SCHUR | Points matter here — you are refining structure, not just trajectory. |
| Pose-graph optimisation | Pose graph over ALL keyframes; points frozen | 5,000 KF × 6 = 30,000 unknowns, ~5,800 edges | On loop closure, ~0.1 Hz | 40–150 ms sparse Cholesky | Correcting global drift does not need structure — and dropping the points removes 99% of the variables. |
| Global BA | Full BA over everything | 5,000 KF × 100,000 points = 330,000 unknowns | After a large loop, once | 20–60 s, ITERATIVE_SCHUR + Schur-Jacobi preconditioner | Only worth it once the pose graph has already removed the gross error. |
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.
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);
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:
| Problem | Nullspace dimension | What is free |
|---|---|---|
| 2-D pose graph | 3 | global x, y, yaw |
| 3-D pose graph | 6 | global translation (3) + rotation (3) |
| Stereo / RGB-D bundle adjustment | 6 | as above; scale is fixed by the baseline |
| Monocular bundle adjustment | 7 | as above plus scale — the classic extra one |
| Monocular visual-inertial | 4 | global position (3) + yaw. Roll and pitch are fixed by gravity; scale by the accelerometer. |
| Symptom | Root cause | The metric that reveals it |
|---|---|---|
| Cholesky throws immediately, on the first iteration | No gauge fix at all — you forgot the prior on the first pose | Smallest 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 wanders | A weak gauge fix — a prior with σ = 1000 m technically makes Λ invertible but leaves it catastrophically ill-conditioned | Condition number of Λ. Above ~1012 in double precision you have lost most of your digits before the solve begins. |
| Crash appears only after a specific keyframe | A disconnected component — tracking was lost, and a group of poses has no path to the anchored pose | Run 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 iteration | Scale gauge unfixed — the cost is genuinely invariant to a global scaling | Track the median point depth across iterations. A steady geometric decay is a scale drift, not convergence. |
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.
| Strategy | What happens to the old variable | What you lose | Who does it |
|---|---|---|---|
| Keep | Stays in the graph forever | Nothing — but compute and memory grow without bound | iSAM2, offline BA |
| Drop | Variable and its factors are deleted | Information. The constraints that touched it are gone. Genuinely lossy. | ORB-SLAM's local BA window (safe because the global map still holds it) |
| Marginalise | Schur-complemented into a dense prior over its neighbours | Sparsity, and the ability to relinearise that information ever again | VINS-Mono, OKVIS, Kimera, every fixed-lag smoother. MSCKF marginalises features too, but by nullspace projection rather than by Schur complement — its own section below. |
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:
| Row | Arithmetic | η |
|---|---|---|
| 0 | 5(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.4 | 0.0 |
| 2 | 0(0.1) + (−4)(1.1) + 4(2.1) = −4.4 + 8.4 | 4.0 |
Step 2 — marginalise x0 out of the window. From Chapter 1, Λ' = [[4.8, −4], [−4, 4]]. For the information vector:
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.
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 spans | Dimension D | Doubles | Arithmetic | Bytes |
|---|---|---|---|---|
| 2 keyframes × 15 | 30 | 30 × 30 = 900 | 900 × 8 B = 7,200 B, and 7,200 / 1,024 | 7.03 kB |
| 4 keyframes × 15 | 60 | 60 × 60 = 3,600 | 3,600 × 8 B = 28,800 B, and 28,800 / 1,024 | 28.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.
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:
| Choice | What it does | Consequence |
|---|---|---|
| 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 value | Slightly worse linearisation. Correct nullspace dimension. Consistent. |
| Relinearise the prior at the current estimate | Recompute 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. |
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.
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) | 0 | 0 | 0 | (1,0,0) |
| ny — slide the world in y | (0,1,0) | 0 | 0 | 0 | (0,1,0) |
| nz — slide the world in z | (0,0,1) | 0 | 0 | 0 | (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.
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
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:
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:
| p1x | p1y | θ1 | p2x | p2y | θ2 | fx | fy | |
|---|---|---|---|---|---|---|---|---|
| P row 1 | −1 | 0 | 1 | 0 | 0 | 0 | 1 | 0 |
| P row 2 | 0 | −1 | −2 | 0 | 0 | 0 | 0 | 1 |
| V row 3 | 0 | 0 | 0 | −1 | 0 | 1 | 1 | 0 |
| V row 4 | 0 | 0 | 0 | 0 | −1 | 0 | 0 | 1 |
| O row 5 | −1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
| O row 6 | 0 | −1 | −2 | 0 | 1 | 0 | 0 | 0 |
| O row 7 | 0 | 0 | −1 | 0 | 0 | 1 | 0 | 0 |
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
Now multiply, one row at a time. This is the arithmetic that makes the claim yours:
| Row | J·nψ, every nonzero term | Result |
|---|---|---|
| P1 | (−1)(0) + (1)(1) + (1)(−1) = 0 + 1 − 1 | 0 |
| P2 | (−1)(0) + (−2)(1) + (1)(2) = 0 − 2 + 2 | 0 |
| V3 | (−1)(0) + (1)(1) + (1)(−1) = 0 + 1 − 1 | 0 |
| V4 | (−1)(2) + (1)(2) = −2 + 2 | 0 |
| O5 | (−1)(0) + (1)(0) | 0 |
| O6 | (−1)(0) + (−2)(1) + (1)(2) = −2 + 2 | 0 |
| 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.
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:
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.
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:
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.
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.
| Criterion | Typical threshold | What it protects against |
|---|---|---|
| Median parallax of tracked features since the last keyframe | > 10 px (VINS-Mono) at 640×480 | A 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 cap | The IMU factor. Preintegration error grows with the interval, so a keyframe every 20 s makes the inertial constraint worthless. |
| Rotation-only motion | Reject as keyframe if translation < ~1–2 cm | Pure rotation gives parallax with no baseline — the features move but no depth information is created. A classic degeneracy. |
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:
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
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
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.
| Z | b | d = f b/Z | σZ/Z = σd/d | σZ |
|---|---|---|---|---|
| 10 m | 0.2 m | 9.2 px | 5.4% | 0.54 m |
| 30 m | 0.2 m | 3.07 px | 16.3% | 4.89 m |
| 30 m | 0.6 m | 9.2 px | 5.4% | 1.63 m |
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.
This is the VINS-Mono shape, which is close enough to OKVIS and Kimera that it answers for all of them.
| Item | Count | Dimension each | Total |
|---|---|---|---|
| Keyframe states (p, v, q, ba, bg) | 10 | 15 | 150 |
| Feature inverse depths | ~150 | 1 | 150 |
| Camera–IMU extrinsic | 1 | 6 | 6 |
| Camera–IMU time offset td | 1 | 1 | 1 |
| State dimension | 307 | ||
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):
| Phase | Time | Notes |
|---|---|---|
| Feature tracking + keyframe decision | 8–15 ms | Separate thread; overlaps the solve |
| Linearise ~910 factors | 6–10 ms | Dominated by reprojection Jacobians |
| Assemble Λ (307×307) | 1–2 ms | Scatter-add; cache-friendly if you sort by variable |
| Solve (dense Cholesky at this size) | 2–4 ms | 3073/3 ≈ 9.6×106 flops |
| × 4–8 LM iterations | 30–60 ms | The real cost |
| Marginalise the oldest keyframe | 1–3 ms | Schur complement over a = 50 eliminated states — see the derivation under the table |
| Total | 40–80 ms | Against a 250 ms deadline. Headroom is deliberate: a bad keyframe can double the iteration count. |
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:
Now price the Schur complement Λ' = Λbb − ΛbaΛaa−1Λab, in three pieces:
| Piece | Arithmetic | Flops |
|---|---|---|
| Factor Λaa (50×50) | a3/3 = 503/3 = 125,000/3 | 4.2×104 |
| Back-solve for Λaa−1Λab (50×257) | 2a2b = 2 × 2,500 × 257 | 1.3×106 |
| The outer product Λba(·) (257×50 × 50×257) | 2ab2 = 2 × 50 × 66,049 | 6.6×106 |
| Total | dominated 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.
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.
| Branch | Trigger | What is removed | What happens to the IMU |
|---|---|---|---|
| MARGIN_OLD | The second-newest frame is a keyframe | The 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_NEW | The second-newest frame is not a keyframe | That 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:
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:
| Object | Shape | What it is |
|---|---|---|
| r | 2M × 1 | Stacked reprojection residuals, 2 rows per observing frame |
| Hx | 2M × 15N | Jacobian w.r.t. the N cloned poses in the state |
| Hf | 2M × 3 | Jacobian w.r.t. the feature's 3-D position — a variable that is not in the state |
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:
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.
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
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:
Expand the square. Λ is symmetric, so the two cross terms are equal and combine:
Collect the terms by their order in dnew:
| Order in dnew | Term | What it becomes |
|---|---|---|
| Quadratic | ½ dnewTΛdnew | Λ is unchanged. This is the frozen Jacobian — it never moves, which is FEJ. |
| Linear | dnewTΛδx − ηTdnew = −(η − Λδx)Tdnew | ηnew = η − Λδx. This is the residual moving with the estimate. |
| Constant | ½δxTΛδx − ηTδx | Irrelevant. 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()
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."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 cause | Would also show | Ruled out by |
|---|---|---|
| Bad camera–IMU extrinsic calibration | Reprojection error inflated, especially during rotation | 0.4 px RMS. A bad extrinsic cannot hide inside a healthy reprojection error. |
| Gyro bias not being estimated | The bias estimate would sit pinned at its initial value and the IMU residuals would show a consistent offset | IMU residuals are centred and inside their band. |
| Not enough features / degenerate scene | Feature count collapses, reprojection residuals get noisy, the covariance would grow | The 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 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:
Lam - B @ solve(A, C) is not exactly symmetric in floating point, and an asymmetric matrix has complex eigenvalue pairs that Cholesky simply refuses.| Candidate cause | Would also show | Ruled out by |
|---|---|---|
| Genuine gauge freedom (no prior anchoring the window) | Cholesky fails on every keyframe from the first one, not after four minutes | It ran clean for 400 keyframes. A gauge is present from t = 0 or not at all. |
| An outlier feature with an absurd inverse depth | Reprojection RMS spikes on exactly the failing keyframe; removing the track fixes it permanently | Reprojection 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 profile | It fails during ordinary translating flight and survives hover. |
| The marginalisation prior has lost positive-definiteness through repeated float32 Schur complements | Nothing 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 λmin/λmax on a log axis against keyframe index; a straight downward line is a signed diagnosis.
| Fix | What it costs | When 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 float32 | 4 × 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 ελmax | A 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. |
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.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.
Everything so far has been Gauss–Newton: linearise, solve the normal equations, apply the step, repeat.
It is the Newton method with the second-derivative term dropped — the true Hessian of ½rTWr is JTWJ + ∑i wiri∇2ri, 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.
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.
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.
LM adds a damping term and, crucially, checks the result before committing:
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.
Start at x = 0.1 with λ = 100 and D = diag(Λ). The damped system is (Λ + λΛ)δ = η, so δ = η / (Λ(1 + λ)).
| it | x | r = x2−4 | C = ½r2 | λ | δ | new x | new C | verdict |
|---|---|---|---|---|---|---|---|---|
| 0 | 0.100000 | −3.990000 | 7.960050 | 100 | +0.197525 | 0.297525 | 7.649834 | accept, λ→10 |
| 1 | 0.297525 | −3.911479 | 7.649834 | 10 | +0.597579 | 0.895104 | 5.116127 | accept, λ→1 |
| 2 | 0.895104 | −3.198790 | 5.116127 | 1 | +0.893413 | 1.788517 | 0.320967 | accept, λ→0.1 |
| 3 | 1.788517 | −0.801208 | 0.320967 | 0.1 | +0.203624 | 1.992141 | 0.000492 | accept, λ→0.01 |
| 4 | 1.992141 | −0.031374 | 0.000492 | 0.01 | +0.007797 | 1.999938 | 3×10−8 | accept, λ→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 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.
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–Marquardt | Dogleg | |
|---|---|---|
| Rejected step costs | A full re-factorisation — λ changed, so (Λ + λD) is a different matrix | Nothing. The factorisation is reused; only the trust radius changes. |
| Best when | Cheap factorisation, or few rejections expected | Expensive factorisation — large sparse problems where each Cholesky is the dominant cost |
| Requires | Nothing extra | Λ to be positive definite (it uses the GN step directly) |
"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
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
‖δ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Λδ:
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
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:
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
| Coefficient | Formula | Arithmetic | Value |
|---|---|---|---|
| a | dTd | (−0.403846)2 + (1.346154)2 = 0.1630917 + 1.8121302 | 1.9752219 |
| b | 2 δCTd | 2[(0.153846)(−0.403846) + (0.153846)(1.346154)] = 2(−0.0621302 + 0.2071006) | 0.2899408 |
| c | δCTδC − Δ2 | 2(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
DoglegOptimizer.Stage 4 — the step.
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 reduction | Fraction of the GN reduction |
|---|---|---|---|---|
| Cauchy point | [0.153846, 0.153846] | 0.217571 | 0.1538462 | 24.6% |
| Dogleg | [−0.098540, 0.995133] | 1.000000 | 0.5587284 | 89.4% |
| Gauss–Newton (rejected, too long) | [−0.250000, 1.500000] | 1.520691 | 0.6250000 | 100% |
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:
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.
Every iteration of every method above ends in "solve Λδ = η". How you solve it is where the orders of magnitude live.
| Method | What it forms | Cost | Condition number | Use when |
|---|---|---|---|---|
| Dense Cholesky | Λ = LLT | n3/3 | κ(Λ) = κ(J)2 | n < ~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)2 | Pose graphs, sparse BA. The workhorse. |
| Sparse QR (SuiteSparseQR) | J = QR directly — Λ is never formed | ~2× Cholesky | κ(J) — not squared | Ill-conditioned problems, single precision, incremental (this is what iSAM uses). |
| Schur + dense | Reduced camera system S | see Ch 2 | κ(J)2 | Bundle adjustment with C < ~1000. |
| Conjugate gradients (ITERATIVE_SCHUR) | Nothing — only matrix-vector products | O(nnz) per iteration, √κ iterations | Sensitive to κ; needs a preconditioner | Very large BA (104+ cameras) where forming S is itself infeasible. |
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) | Route | Effective κ | Digits surviving (of 16) |
|---|---|---|---|
| 103 | Normal equations | 106 | 10 |
| 103 | QR on J | 103 | 13 |
| 105 | Normal equations | 1010 | 6 |
| 105 | QR on J | 105 | 11 |
| 108 | Normal equations | 1016 | 0 — 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.
| Library | Mental model | Assumes | Best at | Where 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. |
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 first | How far to go | Which way to go |
| Cost evaluations per iteration | 1, plus one per rejected step | 3–10 for a Wolfe-condition search |
| Handles indefinite curvature | Yes — the damping fixes it | Needs an explicit modification |
| Robotics use | Ceres, GTSAM, g2o — all of them | Rare; 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.
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.
| Phase | Sliding window (n ≈ 307) | Pose graph (n = 30,000) |
|---|---|---|
| Evaluate residuals + Jacobians | 6–10 ms (60%) | 8 ms (20%) |
| Assemble Λ (scatter-add) | 1–2 ms (15%) | 4 ms (10%) |
| Symbolic analysis + ordering | < 0.1 ms | 3 ms (7%) |
| Numeric factorisation | 2–4 ms (25%) | 22 ms (55%) |
| Triangular solves | < 0.5 ms | 3 ms (8%) |
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.
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:
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.
| Choice | What happens | When it bites |
|---|---|---|
| Weight-only (drop the ρ'' term) | Λ stays positive semi-definite by construction; convergence is first-order in the weights | Slower near the solution; usually fine. This is what most hand-rolled implementations do. |
| Full Triggs correction | Correct 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. |
That table row makes a failure claim, so here is the failure, in one scalar. Take the Cauchy kernel with scale c = 1:
Differentiate it twice — both derivatives, shown, because the second one is the whole point:
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:
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:
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:
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:
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 = 2 | Cholesky |
|---|---|---|---|---|---|---|
| 0.5 | 0.25 | 0.800000 | −0.640000 | +0.600000 | +1.920 | fine |
| 1.0 | 1.00 | 0.500000 | −0.250000 | 0.000000 | 0.000 | singular — the knife edge |
| 2.0 | 4.00 | 0.200000 | −0.040000 | −0.600000 | −0.480 | fails |
| 3.0 | 9.00 | 0.100000 | −0.010000 | −0.800000 | −0.320 | fails |
| 10.0 | 100.0 | 0.009901 | −0.000098 | −0.980198 | −0.038 | fails |
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.
| Problem | n | m | Structure | Solver | Wall time |
|---|---|---|---|---|---|
| Sliding-window VIO | ~307 | ~1,995 | Small, banded plus a dense prior | Dense Cholesky, LM | 2–4 ms per iteration |
| Local bundle adjustment | ~960 | ~3,600 | Arrowhead, few cameras | DENSE_SCHUR | 8–15 ms |
| Pose graph, 5k poses | 30,000 | 34,800 | Banded plus loop-closure spikes | Sparse Cholesky + COLAMD, dogleg | 40–150 ms |
| Global BA, 1k cameras | 330,000 | ~1,000,000 | Arrowhead, many cameras | ITERATIVE_SCHUR+ Schur-Jacobi | 20–60 s |
| Incremental mapping | grows | grows, +6/edge | Bayes tree | iSAM2 | 1–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:
| Problem | Residual rows, itemised | Shape of J | Shape of Λ = JTWJ |
|---|---|---|---|
| Sliding-window VIO | 900 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,600 | 3,600 × 960 | 960 × 960, arrowhead |
| Pose graph (5k poses) | ~5,800 relative-pose edges × 6 rows = 34,800 | 34,800 × 30,000, 12 nonzeros per row | 30,000 × 30,000, 0.066% fill |
| Global BA (100k points) | ~500,000 observations × 2 rows = 1,000,000 | 106 × 3.3×105 | 330,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 item | Flops | Measured time | Achieved rate |
|---|---|---|---|
| 900 reprojection factors, analytic J | 900 × 180 = 1.6 × 105 | part of the 6–10 ms row | — |
| 9 IMU preintegration factors (15×30 blocks) | 9 × ~20,000 = 1.8 × 105 | part of the 6–10 ms row | — |
| 1 dense marginalisation prior (60×307 matvec) | ~2 × 104 | part of the 6–10 ms row | — |
| Total linearisation | 3.6 × 105 | 6–10 ms | 0.045 GFLOP/s |
| Dense Cholesky, 307×307 | 9.6 × 106 (27× more) | 2–4 ms | 3.2 GFLOP/s |
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.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 k | Flat λ ×= 10 | Nielsen λ ×= ν, ν ×= 2 | Which is more aggressive |
|---|---|---|---|
| 1 | ×10 | ×2 | flat |
| 2 | ×100 | ×8 | flat |
| 3 | ×1,000 | ×64 | flat |
| 4 | ×104 | ×1,024 | flat |
| 5 | ×105 | ×32,768 | flat |
| 6 | ×106 | ×2,097,152 | Nielsen — 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.
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.
| Symptom | What it means |
|---|---|
| λ climbs monotonically instead of falling | Steps 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 nonsense | The 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.
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.
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:
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.
| Quantity | Count |
|---|---|
| IMU samples per keyframe interval | 50 |
| Keyframe intervals in the window | 9 |
| LM iterations per solve | ~6 |
| Re-integrations per solve, naive | 50 × 9 × 6 = 2,700 |
| With preintegration | 9 — computed once, on arrival |
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.
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:
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:
Δ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:
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:
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:
Which gives the three preintegrated deltas in their usual boxed form:
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:
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.
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:
The velocity delta is a plain sum:
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)Δt2 | contribution | Δv after |
|---|---|---|---|---|---|
| 0 | 0.000 | 0.000000 | ½(0.90)(0.0001) = 0.000045 | 0.000045 | 0.009 |
| 1 | 0.009 | 0.000090 | ½(1.10)(0.0001) = 0.000055 | 0.000145 | 0.020 |
| 2 | 0.020 | 0.000200 | ½(0.70)(0.0001) = 0.000035 | 0.000235 | 0.027 |
| 3 | 0.027 | 0.000270 | ½(0.90)(0.0001) = 0.000045 | 0.000315 | 0.036 ✓ |
| Δp total | 0.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.
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:
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.
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:
Compute the Jacobian for our example. Each sample subtracts b once, and each contributes Δt of velocity, so:
For position, each sample's bias error contributes both a direct ½Δt2 and the accumulated velocity error times Δt:
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:
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.
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.
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(φ) | col 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | 0.999800007 | −0.019998667 | 0 |
| row 2 | 0.019998667 | 0.999800007 | 0 |
| row 3 | 0 | 0 | 1 |
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 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | 0.999200107 | −0.039989334 | 0 |
| row 2 | 0.039989334 | 0.999200107 | 0 |
| row 3 | 0 | 0 | 1 |
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":
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 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | 1 − 0.0000667 = 0.999933335 | +0.499983·0.02 = 0.009999667 | 0 |
| row 2 | −0.499983·0.02 = −0.009999667 | 0.999933335 | 0 |
| row 3 | 0 | 0 | 1 |
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ΔRg | col 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | −0.019994667 | −0.000399947 | 0 |
| row 2 | 0.000399947 | −0.019994667 | 0 |
| row 3 | 0 | 0 | −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 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | 0.9992001066930 | −0.0399893325873 | −0.0000079989332 |
| row 2 | 0.0399893325873 | 0.9992000267036 | 0.0003998933312 |
| row 3 | −0.0000079989332 | −0.0003998933312 | 0.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:
| ΔRexact | col 1 | col 2 | col 3 |
|---|---|---|---|
| row 1 | 0.9992001066716 | −0.0399893331201 | −0.0000079989333 |
| row 2 | 0.0399893331201 | 0.9992000266823 | 0.0003998933312 |
| row 3 | −0.0000079989333 | −0.0003998933312 | 0.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:
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.
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:
and for a window short enough that Θ is at most about 1 rad — which is every keyframe interval — the bracket is just Θ/12, so:
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.
| Situation | T | ω | δbg | ΔR error | Specific force it leaks (g·ε) | Position over the window |
|---|---|---|---|---|---|---|
| One keyframe interval, at threshold | 0.25 s | 90°/s | 0.01 | 2.03×10−7 rad = 0.000012° | 2.0×10−6 m/s2 | 6×10−8 m |
| One keyframe interval, 5× over threshold | 0.25 s | 90°/s | 0.05 | 5.07×10−6 rad = 0.00029° | 5.0×10−5 m/s2 | 1.6×10−6 m |
| Initialisation window, slow motion | 5 s | 30°/s | 0.02 | 1.54×10−3 rad = 0.089° | 0.0152 m/s2 | 0.189 m |
| Initialisation window, fast motion | 5 s | 90°/s | 0.03 | 1.25×10−3 rad = 0.072° | 0.0123 m/s2 | 0.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:
| Window T | ω | δbg that reaches ε* = 1.02 mrad | vs the 0.01 rad/s rule |
|---|---|---|---|
| 0.25 s (one keyframe interval) | 90°/s | 0.709 rad/s | 71× slack — the rule never binds here |
| 1 s | 90°/s | 0.094 rad/s | 9× slack |
| 2 s | 90°/s | 0.040 rad/s | 4× slack |
| 5 s (initialisation, or a stationary stretch) | 30°/s | 0.016 rad/s | 1.6× — this is where the rule bites |
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.
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 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.
Drive it in this order, and read the numbers — they are the argument.
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.
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.
| Quantity | Observable? | Why |
|---|---|---|
| Global position (x, y, z) | No — 3 DOF | Nothing measures absolute position. All measurements are relative. |
| Yaw about gravity | No — 1 DOF | Gravity fixes two rotational directions; rotation about gravity leaves every measurement unchanged. |
| Roll and pitch | Yes | The accelerometer measures specific force, and at rest that is −g. Gravity is an absolute vertical reference. |
| Metric scale | Yes — with excitation | The accelerometer is metric. Double-integrating it gives metres, which anchors the vision system's arbitrary scale. |
| Accelerometer and gyro biases | Yes — with excitation | Estimated 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.
| Motion | What becomes unobservable | Why |
|---|---|---|
| Constant velocity (a car on a motorway) | Scale, and accelerometer bias | Specific force is constant, so a scale error and a bias error produce identical measurements. Nothing can separate them. |
| Constant acceleration | Accelerometer bias vs gravity direction | A constant bias and a mis-estimated gravity vector are the same signal. |
| Pure rotation | All depth / structure | No baseline, so no parallax, so no triangulation. Features move but carry no depth information. |
| Hover / stationary | Scale, velocity | Same as constant velocity, with v = 0. |
| Item | Value | Consequence |
|---|---|---|
| IMU rate | 200 Hz | 5 ms per sample. Preintegration must complete inside the callback: ~2 µs of work, 0.4 ms/s of CPU total. Trivially affordable. |
| IMU message size | 6 floats + timestamp = 28 B | 5.6 kB/s. Never the bottleneck. |
| ImuFactor (the one coded below) | 9-dim residual (ΔR, Δv, Δp), 9×9 covariance | 81 doubles = 648 B per factor. Nine of them in the window = 5.8 kB. |
| … plus its bias between-factor | 6-dim residual (ba, bg), 6×6 covariance | 36 doubles = 288 B. A separate factor, one per interval. |
| CombinedImuFactor (the alternative) | 15-dim residual (9 preintegration + 6 bias random walk), 15×15 covariance | 225 doubles = 1.8 kB per factor. One factor instead of two. |
| Gyro bias instability | 10 °/h = 0.0028 °/s | Over 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 bias | 0.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 td | 1–30 ms typical, often drifting | At 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.
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.
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.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.
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.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.
| Symptom | Reading | What it rules out |
|---|---|---|
| Reprojection error | 0.35 px RMS, flat | Not a calibration or tracking failure — the vision half is fitting perfectly. |
| Accel bias estimate | Wanders 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 window | 0.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 residuals | Inside their band | Not 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
IntegrationCovariance term above is papering over.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.
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.
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.
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.
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.
| Ordering | cj sequence, in elimination order | Fill edges | flops | nnz(L) = 78 + 2·fill | ratio |
|---|---|---|---|---|---|
| Natural | 3,3,3,3,5,5,5,4,5,5,5,5,5,5,5,4,3,2,1,0 | 47 | 560 | 172 | 2.21× |
| Reverse | 3,3,3,3,1,2,2,2,2,3,2,3,3,3,2,2,2,2,1,0 | 15 | 242 | 108 | 1.38× |
| Landmarks first | 3,3,3,3,3,2,2,2,4,3,3,2,3,2,2,2,2,1,1,0 | 17 | 260 | 112 | 1.44× |
| Minimum degree | 1,2,2,2,2,2,2,2,3,3,2,2,3,2,2,2,3,2,1,0 | 11 | 210 | 100 | 1.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.
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 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.
| Stage | Object | Shape / dtype | How the number is obtained |
|---|---|---|---|
| Graph | variables | 5,000 poses × 6 DOF = 30,000 scalar unknowns | SE(3) has 6 DOF, so each pose contributes a 6-vector to the tangent space |
| Graph | factors | 4,999 odometry + 800 loop closures = 5,799 between-factors, + 1 gauge prior | a chain of P poses has P−1 consecutive edges; the prior is what removes the 6-DOF gauge freedom |
| Linearise | residual r | float64[34,800] | 5,799 factors × 6 residual rows + 6 prior rows |
| Linearise | Jacobian J | 34,800 × 30,000, float64, 417,564 nonzeros | each 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 | Λ = JTWJ | 30,000 × 30,000, 597,528 nonzeros, float64 | see 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 |
| Symbolic | in: pattern only | int32 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 |
| Symbolic | out: ordering + forecast | int32[30,000] permutation, plus nnz(L) ≈ 1.1×106 and cm->fl ≈ 2.0×108 | AMD produces the permutation; the elimination tree produces the counts before a single multiply happens |
| Numeric | factor L | ~1.1×106 nonzeros → 8.8 MB of float64 | nnz from the symbolic phase × 8 bytes |
| Solve | Δx | float64[30,000] = 240 kB | two triangular solves, L then LT |
| Retract | x ← x ⊕ Δx | 5,000 × expSE(3)(Δxi) with Δxi ∈ ℝ6 | the increment lives in the tangent space; the state lives on the manifold |
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:
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:
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 ℓaℓb 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:
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:
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.
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.
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.
| Step | Eliminate | Surviving neighbours | Pairs it must connect | New? | Running fill | cj | cj2 + 3cj | Running flops |
|---|---|---|---|---|---|---|---|---|
| 1 | 0 | {1, 5} | (1,5) | yes | 1 | 2 | 4 + 6 = 10 | 10 |
| 2 | 1 | {2, 5} (5 via the new edge) | (2,5) | yes | 2 | 2 | 4 + 6 = 10 | 20 |
| 3 | 2 | {3, 5} | (3,5) | yes | 3 | 2 | 4 + 6 = 10 | 30 |
| 4 | 3 | {4, 5} | (4,5) | no — original edge | 3 | 2 | 4 + 6 = 10 | 40 |
| 5 | 4 | {5} | none | — | 3 | 1 | 1 + 3 = 4 | 44 |
| 6 | 5 | {} | none | — | 3 | 0 | 0 + 0 = 0 | 44 |
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:
| Step | Eliminate | Surviving neighbours | New fill | cj | cj2 + 3cj | Running flops |
|---|---|---|---|---|---|---|
| 1 | 0 | {1} | 0 — one neighbour, no pair exists | 1 | 1 + 3 = 4 | 4 |
| 2 | 1 | {2} | 0 | 1 | 1 + 3 = 4 | 8 |
| 3 | 2 | {3} | 0 | 1 | 1 + 3 = 4 | 12 |
| 4 | 3 | {4} | 0 | 1 | 1 + 3 = 4 | 16 |
| 5 | 4 | {5} | 0 | 1 | 1 + 3 = 4 | 20 |
| 6 | 5 | {} | 0 | 0 | 0 + 0 = 0 | 20 |
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.
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
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.Second hand trace, and it is the Schur complement in miniature. Six leaves all connected to one hub — a landmark seen by six keyframes.
| Ordering | What happens | cj sequence | Fill | flops = ∑j(cj2 + 3cj) |
|---|---|---|---|---|
| Leaves first, hub last | Each 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) | 0 | 6×(1+3) + 0 = 24 |
| Hub first, leaves after | The 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) | 15 | 54+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.
| In this instrument | Where 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 ordering | Ceres: linear_solver_ordering (groups). GTSAM: Ordering::Colamd, Ordering::ColamdConstrainedLast. SuiteSparse: amd_order / colamd. |
| Flop estimate | CHOLMOD: cm->fl after cholmod_analyze, and cm->lnz for the factor nonzeros. This is the single most useful number nobody logs. |
| Step-by-step elimination | GTSAM's Bayes tree — ISAM2::getISAM2().printStats() and the clique structure. The tree is the elimination history, kept alive so it can be updated incrementally. |
| Ordering | What it does | Good on | Bad on |
|---|---|---|---|
| Natural (variable insertion / timestamp) | Eliminate in the order variables were created | Pure chains — genuinely optimal, zero fill | Anything with loop closures. The fill front sweeps the whole cycle. |
| Reverse | Newest first | Sliding windows, where the newest states are the ones you want to keep as the reduced system | Same cycle problem, from the other end |
| Landmarks first | All structure variables, then all poses | Bundle 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 variable | Almost 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.
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 ordering | AMD ordering | |
|---|---|---|
| nnz(Λ) — the matrix | 5,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)/n | 2.6e7 / 30,000 = 867 | 1.1e6 / 30,000 = 37 |
| Column profile | banded — the front width is nearly constant as it sweeps, so cj ≈ c̄ throughout | skewed — most columns are tiny, a handful of root supernodes are dense, so RMS(cj) ≈ 2.2 c̄ ≈ 82 |
| ∑j cj2 = n · RMS(c)2 | 30,000 × 8672 = 2.3 × 1010 | 30,000 × 822 = 2.0 × 108 |
| Numeric factorisation @ 5 GFLOP/s | 2.3e10 / 5e9 = ~4 s | 2.0e8 / 5e9 = ~40 ms |
| Solution | identical 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.
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 symptom | Root cause | Metric to print | The 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.
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."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 choice | Cost, in numbers | Fits in 400 ms? |
|---|---|---|
| Full batch re-solve, natural ordering | 4 s per factorisation × 3 Gauss–Newton iterations = 12 s | No — 30× over. Same answer to 10−9, so nothing in the output tells you why. |
| Full batch re-solve, AMD ordering | 40 ms × 3 iterations = 120 ms, plus one symbolic analysis amortised across the three | Yes, 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 subtree | Yes 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.
Three honest limitations, and volunteering them is exactly the kind of thing that reads as senior:
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:
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.
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.
| Concept | The 30-second explanation | Key equation | Tool | Classic paper | 2022+ 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 |
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.
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."
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.
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.
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:
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.
| Symptom | Root cause | The 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. |
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.
| Axis | Classical choice | Modern choice | When the classical one is still right |
|---|---|---|---|
| Estimator | EKF-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 odometry | MSCKF (clone poses, marginalise features) | Fixed-lag smoother with FEJ | Often. Under ~5 W with no loop closures, a consistent MSCKF gives comparable accuracy at lower cost. OpenVINS is the reference. |
| Linear solve | Normal equations + Cholesky | QR / square-root, or iterative + preconditioner | Whenever κ(J) < ~104 and you are in double precision — which is most of the time. Cholesky is half the flops. |
| Loop closure | Bag-of-words + geometric verification + robust kernel | Learned global descriptors (NetVLAD-family), still with geometric verification | Both need the verification. The learned descriptor changes recall, not the consequence of a false positive. |
| Frontend | Hand-crafted features (ORB, SIFT) + RANSAC | Learned 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 models | Hand-tuned, from Allan variance and a chi-square sweep | Learned per-measurement covariance | By default. A learned covariance must ship with a validity monitor and a fallback, or it fails confidently. |
| Global optimality | Good initialisation, then LM, and hope | Convex relaxation with a certificate (SE-Sync) | Whenever you have a good initialisation from odometry — which in SLAM you almost always do. |
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.
The question. “EKF-SLAM in 2-D with 500 landmarks. How big is the covariance matrix, and what happens when I double the landmarks?”
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 4×.
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×108 ≈ 1.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.
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.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.
| Question | Working | Answer |
|---|---|---|
| EKF-SLAM with 500 landmarks: how big is the covariance? | n = 3 + 2(500) = 1003; 10032 = 1,006,009; × 8 B = 8,048,072; / 220 | 8 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,009 | 32 MB — exactly 4× |
| Dense Cholesky of a 30,000-unknown pose graph, at 10 GFLOP/s? | 30,0003 = 2.7×1013; /3 = 9×1012; /1010 | 900 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; /1010 | 5 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,528 | 6 × 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 eliminated | 600 × 600 |
| …and the cost of building and solving it? | 20,000 × [33 inverse + 42 pairs × 162] = 5.2×107; + 6003/3 = 7.2×107 | 1.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/4 | 50 |
| Naive re-integrations per solve (10 KF window, 6 LM iterations)? | 50 × 9 × 6 | 2,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/3600 | 0.0028 °/s |
| Cross-track error from a 5 ms camera–IMU time offset at 10 m/s? | 10 × 0.005 | 5 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 diagonal | 45 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 = 7 | n = 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.005 | 0.05 m per edge, cost /4 |
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.
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.
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.
| Paper | Why this one |
|---|---|
| Dellaert & Kaess, Square Root SAM, IJRR 2006 | The 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 2012 | The 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 2010 | The 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 2017 | The 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 2016 | The 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.
| Repo | Go straight to |
|---|---|
| borglab/gtsam | examples/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-solver | examples/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-Mono | vins_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_vins | ov_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/g2o | g2o/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. |
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-up | The 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. |
marginalization_factor.cpp, with a thread pool over the row blocks") is worth more than any amount of general fluency.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.
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.
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