The example every factor-graph library opens with — GTSAM’s
OdometryExample— written against this library. A robot drives along a line, odometry links consecutive poses, one GPS reading lands on the last pose, and the posterior over all poses comes back exact.It is the smallest problem in which this library does something a
Lux.jlpipeline cannot: the same factor is run in both directions and information travels backwards along the chain.
Sources: code:
messages.jl,runtests.jlTheory (CT-ML wiki): Bayesian Inversion · Gaussian Relations · Statistical Game · Bayesian Lens · Variational Free Energy · Lens
1. The model
with clamped to the observed reading . Everything is linear and everything is Gaussian, so the posterior is Gaussian and known in closed form — which is the point: this is an oracle, not a demo.
Circles are variables (wires), squares are factors. There is no cycle, so this is a
istree(g) == true graph and belief propagation is exact — see Factor Graphs for the
two distinct acyclicity notions and why it is istree and not isdag that matters here.
2. Writing it
The whole model is built from one factor type used three times.
using Lenticulum, LenticulumCore, Mycelium
μ₀, Σ₀ = [0.0], fill(1.0, 1, 1) # prior on x₁
u = ([2.0], [2.0]) # odometry measurements
Q = (fill(0.25, 1, 1), fill(0.25, 1, 1))
R = fill(0.5, 1, 1) # GPS noise
z₀ = [4.5] # the GPS reading
Id = fill(1.0, 1, 1)
b = GraphBuilder()
for v in (:x1, :x2, :x3, :z); variable!(b, v, 1); end
factor!(b, :prior, GaussianPrior(:x1, μ₀, Σ₀))
factor!(b, :odo1, GaussianFactor(1 => 1; noise = Q[1], channels = (:x, :y)))
factor!(b, :odo2, GaussianFactor(1 => 1; noise = Q[2], channels = (:x, :y)))
factor!(b, :gps, GaussianFactor(1 => 1; noise = R, channels = (:x, :y)))
factor!(b, :data, DataFactor(:z, z₀))
connect!(b, :prior, :x1, :x1; direction = Bidirectional())
connect!(b, :odo1, :x, :x1; direction = Bidirectional())
connect!(b, :odo1, :y, :x2; direction = Bidirectional())
connect!(b, :odo2, :x, :x2; direction = Bidirectional())
connect!(b, :odo2, :y, :x3; direction = Bidirectional())
connect!(b, :gps, :x, :x3; direction = Bidirectional())
connect!(b, :gps, :y, :z; direction = Bidirectional())
connect!(b, :data, :z, :z; direction = Emitting())
g = validate(build(b))
ps = (prior = NamedTuple(), odo1 = (A = Id, b = u[1]), odo2 = (A = Id, b = u[2]),
gps = (A = Id, b = [0.0]), data = NamedTuple())
st = (prior = NamedTuple(), odo1 = NamedTuple(), odo2 = NamedTuple(),
gps = NamedTuple(), data = NamedTuple())
marg, report, st, store = infer!(g, tree_schedule(g), ps, st)and that is the whole program. marg.x1, marg.x2, marg.x3 are
GaussianBeliefs; marg.z is the DiracBelief the clamp put there.
The odometry factor is the Gaussian factor with
is
GaussianFactor(1 => 1; noise = Q[k]) with and . GTSAM calls this a
between factor and gives it its own class; here it is a parameter choice, and sits
in ps where a neural network’s weights would. Nothing marks it as a measurement rather
than a learnable offset — see §6.
The unary measurement factor is that factor plus a clamp
GTSAM’s GPS factor is unary: it attaches to alone and carries inside itself. This library refuses that, on the grounds of Everything is a Factor: a variable is a wire and has no content of its own, whereas data is evidence, and evidence is a factor. So the measurement becomes two nodes and an auxiliary variable,
with DataFactor(:z, z₀) supplying the hard clamp of
Channels and Polarity. This costs one variable and buys three things:
- the noise model and the datum are separately addressable — swap the clamp for a different reading and nothing else changes;
- unclamping turns the measurement into a prediction with no code change (see §5);
- the clamp edge is
Emitting(), so schedule pruning never even tries to compute a message back into it.
3. What comes out, and against what
| mean | variance | |
|---|---|---|
The oracle is what GTSAM actually computes. Stack the poses into ; each factor contributes a quadratic , so the joint is one information form
with , and . That is the sparse Hessian a nonlinear factor-graph library builds by linearisation, and its tridiagonality is the chain structure written down — a loop closure is exactly what would put entries in the corners. Here it is dense and unapologetic, because being obviously right matters more than being fast in an oracle.
The test asserts equality, not closeness
belief_cov(marg[v])[1] ≈ Σ[i,i]holds to floating-point rounding, for every pose and at every chain length with inhomogeneous . On a tree, BP is not an approximation to elimination; it is elimination, reorganised.
These are smoothed marginals
, half its prior . Nothing in the model tells about the GPS reading directly — the information travelled
which is the backward sweep of tree_schedule, and requires each odo factor to be run
with observed and unobserved — the opposite of the direction it was written in. A
filter would report ; a smoother reports . Getting the
smoother for free from the schedule, rather than as a second algorithm, is the payoff of
Messages are Inversions.
This is why factors are relations, not functions
A
Lux.jllayer has one direction and its reverse pass carries a gradient.odo1here has two directions and both carry posteriors. Running it backwards is not differentiating it — it isassemble-ing a different Inversions and Bayesian Lenses out of the same factor, and it is exact.
4. The evidence falls out too
scalar_free_energy on the converged store returns
for the numbers above. The graph is never told this. It sums local energies and local entropies and applies the counting correction, and the marginal likelihood of the GPS reading is what is left. This is AutoBayes Remark 24 (see Factors are Parameterized Statistical Games) surviving a generalisation the paper does not make: from a two-factor composite to an -factor graph, via Bethe Free Energy.
The counting correction is doing real work here, and the chain is the cleanest place to see why. Every variable has degree exactly , so
Each of the two factors touching a variable charges in its own negentropy summand,
so the shared entropy is counted twice; the correction adds one copy back. Drop it and
is wrong by — on a chain, wrong by an amount that grows with , which is
what makes a chain a better test than the two-factor model of gaussian.md §4, where the
error is a single term and easy to mistake for a constant.
5. Turning the crank the other way
Unclamp the measurement and the same graph predicts instead of estimating. Delete the
DataFactor and marg.z becomes the predictive distribution
— the pushforward of the prior along the whole chain,
noise accumulated at every step. Clamp instead of and it becomes dead reckoning.
Clamp both ends and it is a bridge.
None of these are separate code paths. They are the same graph with the clamps moved, and Polarity Resolution works out which direction each factor must be run in from which messages have arrived — a fact about the graph at that moment, not a fact about the model. This is the property the README calls learning relations rather than functions, and the chain is the smallest example where it is visible.
6. What this is not
This is a linear factor graph, not a SLAM system
GTSAM’s real value is in the layer above this one, and none of it is here.
- No nonlinearity. A
Pose2chain has in the residual, and a real library linearises at the current estimate and iterates (Gauss–Newton, Levenberg–Marquardt, Dogleg). This library has no relinearisation loop;GaussianFactoris linear by construction. Iterating BP on a linearised graph and iterating the linearisation point are different loops, and only the first exists here. - No manifolds. Poses live on /; beliefs would need to be Gaussians in a
tangent space with a retraction, and
combinewould need to agree about which tangent space.GaussianBeliefis flat. - No loops. A chain is a tree. Close the loop — the actual reason SLAM is hard — and
istree(g)goes false,tree_scheduleis no longer available, and everything in §3 becomes approximate. See Loopy Message Passing. - No ordering. GTSAM’s performance is variable ordering (COLAMD, nested dissection) and
incremental updates (iSAM2).
Myceliumhas message schedules, which are the same information organised differently, and no elimination-order heuristics at all. - The odometry measurements are parameters. sits in
psandislearnable(::GaussianFactor)istrue, so a gradient step would happily edit the odometry readings to fit the GPS better. Nothing in the type system distinguishes “measured input” from “learnable weight”. That is the honest state of the library, not a design position, and it is the gap Everything is a Factor is pointing at when it says data should be factors: ought to be a clamped variable on a third channel, not an entry inps.
7. What this example caught
Two things, both of which passed on the two-factor model in runtests.jl:
-
combinewas ambiguous on(TrivialBelief, GaussianBelief).messages.jlhadcombine(::TrivialBelief, b)(untyped second argument) alongside the catch-allcombine(a::AbstractBelief, b::AbstractBelief)that throws. Neither is more specific than the other, so Julia reported aMethodError: ... is ambiguousfrom insideexcluded_marginal— i.e. the unit law for message pooling was unreachable for the one belief type that can actually be pooled. Fixed by restating the unit law at theAbstractBelieflevel. The failure was total rather than subtle only by luck: had the catch-all returned something instead of throwing, the ambiguity would have resolved silently in whichever order the methods were defined. -
The chain is where a missing counting correction stops looking like a constant. See §4.
What the chain’s depth buys
Expressively, nothing. A chain of linear-Gaussian factors eliminates to a single linear-Gaussian relation, so this graph represents exactly the joint Gaussian a flat model would — depth adds no model class. What it adds is sparsity: the block-tridiagonal precision of §3, which is why inference is rather than .
That is the linear-Gaussian case of a general question, and the general answer is different — see Depth in Implicit Learning.
The continuous version
Everything above discretises time by hand: one variable per pose, one factor per interval. The continuous alternative — a variable that carries a trajectory, with a Gauss–Markov prior — is designed in Time as a Base, and the connection is exact rather than analogical:
A Gauss–Markov process sampled at has a block-tridiagonal joint precision, which is precisely the precision of this chain.
So this graph is not merely like a continuous-time trajectory estimate; it is what one collapses to at a fixed set of query times. The operation the continuous version adds is interpolation — reading at a time no factor mentions — which is exactly what §6’s “no ordering” and the asynchronous-measurement problem need.
Related
- Factor Graphs — the bipartite structure and
istree - Messages are Inversions — why running
odo1backwards is exact and free - Schedules —
tree_schedule, and why two sweeps suffice - Bethe Free Energy — the counting correction of §4
- Everything is a Factor — why the GPS reading needs its own variable
- Polarity Resolution — how §5 works without new code
- Loopy Message Passing — what breaks when the chain closes
- Time as a Base — the same graph with continuous time, and where it comes from
- Depth in Implicit Learning — why this graph’s depth is sparsity rather than expressivity