From Factor Graph to Linear System
This chapter follows one optimizer iteration end to end: how the factor graph is
turned into J and r, how each factor’s contribution is whitened, robustified
and scattered into the global system, and how the solved step gets back onto the
manifold. It describes what the code in core/ and linearizer/ actually does.
The graph
A Problem holds two arenas (core::problem):
- Variables — the unknowns, each a manifold element (
SE3,SO3,Rn, …), stored in aSlotMapand addressed by a stableVarKey. - Residual blocks — the factors, each a
ResidualBlockholding one boxedFactor, theVarKeys it connects, an optionalNoiseModel, and an optional robustLossFunction.
This is the factor graph: variables are nodes, residual blocks are the (hyper-)edges connecting them. There is no separate graph structure.
#![allow(unused)]
fn main() {
let pose_i = problem.add_variable(ManifoldType::SE3, pose_i_params);
let pose_j = problem.add_variable(ManifoldType::SE3, pose_j_params);
problem.add_residual_block(
&[pose_i, pose_j], // the edge's endpoints
Box::new(BetweenFactor::new(measurement)),
Some(Box::new(HuberLoss::new(1.0)?)),
);
}
Each block is assigned a residual_row_start_idx — its offset in the stacked
residual vector — and each variable a column offset in the tangent space. Those
two offsets are what turn a graph into a matrix.
What the objective actually is
Stacking every block’s residual gives , and the problem is
where is the whitened residual of block and its robust loss (identity when none is set). The three sections below are exactly the three transformations between “what the factor computes” and “what the solver sees”.
Step 1 — the factor linearizes itself
Every factor implements one method (factors::Factor):
#![allow(unused)]
fn main() {
fn linearize(
&self,
params: &[&[f64]], // one slice per connected variable
residual: &mut [f64], // output, length residual_dim()
jacobian: Option<faer::mat::MatMut<'_, f64>>, // output, jacobian_shape()
);
}
Given the current parameter values, it writes
where is the predicted measurement, the observation, and
whatever difference the measurement space requires — subtraction
for a Euclidean measurement, between + log for a pose measurement.
The Jacobian is with respect to the tangent space at the current estimate,
not the parameter vector. For an SE3 variable stored as 7 parameters the
block has 6 columns, and it is the derivative of the residual with respect to a
local perturbation applied as . This is what
makes the linear system well-posed on a manifold: the 7-parameter quaternion
representation is over-parameterised and would give a singular
.
Factors must obtain these derivatives from the manifold, not re-derive them. Every group in
apex-manifoldsreports the Jacobians of its own operations (act,compose,log,right_plus,between) through optional output arguments. A hand-written[R | −R[p]ₓ]is a second copy of a convention that can silently drift from the group’s —factors::commondeliberately contains no manifold derivatives for this reason.
The buffers are pre-allocated and reused across iterations; linearize never
allocates.
Step 2 — whitening by the noise model
A measurement with covariance must be weighted by its information.
The NoiseModel stores the square-root information
and applies it (core::noise):
so that —
the Mahalanobis distance. A Diagonal model is a per-row scale; a Dense model
is a triangular multiply.
Whitening happens before the robust loss, in both the block-evaluation path and the assembly path, and the two are documented to keep the same ordering. The loss must see the whitened norm, because “is this residual an outlier?” is a question about standard deviations, not raw units.
A factor either whitens internally or takes a
NoiseModel— never both. Some factors’ weighting is inseparable from the measurement (GICP’s combined point covariance, a smart factor’s internal elimination, an IMU preintegration’s information matrix), so they whiten as they go and reportwhitens_internally() == true. Registering one of those with a non-null noise model would whiten twice and silently over-weight the block; the two conventions are indistinguishable by inspecting a residual, so registration rejects the combination outright.
Step 3 — the robust loss becomes a reweighting
A robust loss turns the problem into a different least-squares
problem rather than changing the solver. core::corrector implements Ceres’s
corrector: given and the loss derivatives
,
so that and the gradient is correct. The rank-one term is what makes this more than naive iteratively-reweighted least squares: it carries the curvature of , so the Gauss-Newton model of the robustified problem stays accurate.
Ordering matters and is load-bearing: the Jacobian correction reads the
un-corrected residual, so correct_jacobian_in_place runs before
correct_residual_in_place. Reversing them silently changes the rank-one term.
After this step the solver sees an ordinary least-squares problem in — every optimizer and linear solver downstream is unaware that a robust loss was involved at all.
Step 4 — scattering into the global system
Each block has produced a small dense
Jacobian, where cols is the sum of its variables’ DOFs. Assembly places those
numbers into the global (linearizer::cpu):
- block ’s rows go at its
residual_row_start_idx, - the sub-block for variable goes at ’s column offset.
The result is block-sparse: a row touches only the variables its factor connects, which is precisely the graph’s adjacency.
The sparsity pattern is symbolic and built once. SymbolicStructure
computes the pattern and a value permutation before the iteration loop, and
every subsequent iteration only refills values in that fixed layout — the
pattern is a property of the graph, not of the estimate. Recomputing it per
iteration is the single most common way to make a solver slow.
Assembly is parallelised over blocks with rayon into disjoint row ranges, so no two threads write the same row. This is why the visiting order is load-bearing: values are pushed positionally against the symbolic pattern’s pairs, so any disagreement between the two orders would permute ’s rows against — a wrong system that still factorizes cleanly.
Fixed variables (gauge freedom)
A pose-graph or BA problem is invariant under a global transformation, so
is singular unless something is pinned. fix_variable
holds individual tangent components fixed:
#![allow(unused)]
fn main() {
for dof in 0..6 {
problem.fix_variable(first_pose, dof); // anchor the gauge
}
}
Fixed components are excluded from the column space, so they never enter the linear system and cannot move.
Step 5 — solve, and return to the manifold
The optimizer now has and , and forms
Which linear system gets solved is the optimizer’s business (Optimizers) and how it is solved is the backend’s (Linear Solvers). Both produce a tangent-space step .
The step is not added to the parameters. It is applied through the manifold’s right-plus:
for each variable’s own group, using the same right convention the Jacobians were taken with. Mixing conventions here — taking a left-Jacobian and applying a right update — produces a step that is wrong by a factor of the adjoint, and it still converges, just slowly and to a slightly different place.
The loop
build symbolic structure once, from the graph's adjacency
─── per iteration ────────────────────────────────────────────────
for each residual block (parallel):
factor.linearize(params) ──────────► r_i, J_i tangent-space
noise.whiten(...) ──────────► r̃_i, J̃_i Mahalanobis
corrector.correct(...) ──────────► r̂_i, Ĵ_i robustified
scatter into J, r at (row_start, col_offsets)
─────────────────────────────────────────────────────────────────
solve for h (Gauss-Newton / LM / Dog Leg × linear backend)
x ← x ⊞ h per-variable right-plus
check convergence
Every iteration repeats steps 1–5 at the new estimate: the residuals, the Jacobians and the robust weights are all recomputed, while the sparsity pattern and every cached symbolic structure are reused.
Why this is efficient
- Symbolic once, numeric many. The pattern of , of , and the permutation linking them are computed once and reused, so an iteration is a parallel value gather plus a parallel product.
- Blocks are independent. Linearization is embarrassingly parallel; only the scatter needs disjoint ranges, which the row layout guarantees.
- No allocation in the hot path. Factor buffers, the assembly arena and the solver workspaces are allocated once and reused.
- Manifold parameters stay contiguous. They live in
nalgebrastorage thatfaerviews without copying, so no conversion happens between assembly and the linear solve. - Structure is exploited, not discovered. The Schur partition, the elimination groups and the chunk layout all come from the graph’s adjacency, which is known before the first iteration.