Gradients helps learn factor parameters from a loss on GTSAM’s optimized
estimates. This tutorial calibrates an odometry scale using two planar
positions and a supervised endpoint. It implements a factor’s parameter
derivative in Python, computes the loss gradient, and takes one training step.
The scale is a NumPy vector owned by the application. A ParameterTag links
it to the factor’s derivative callback, and parameterGradient handles the
graph solve and noise weighting. The class guide
describes the other queries and the differentiation model.
The PyTorch tutorial extends this workflow to a small MLP with shared weights, automatic factor VJPs, and held-out evaluation.
import numpy as np
import gtsam
from gtsam.symbol_shorthand import X1. The calibration parameter and estimated positions¶
The learnable parameter is a scale multiplying the odometry sensor’s
reported displacement, stored in the vector step. The corrected
measurement is .
GTSAM estimates two planar positions, and , for each fixed scale. A prior factor attracts the start position to a noisy start anchor, another prior attracts the endpoint to a noisy end anchor, and the between factor compares with the scaled odometry measurement.
The positions are optimization variables stored in Values, while the scale
is an application parameter used to construct a factor. The supervised
endpoint target is a separate position used only in the training loss; it
is not one of the graph’s anchor measurements.
Figure 1. The estimation graph. Round nodes are variables: blue circles are the estimated positions and the gold circle is the learnable scale. Square nodes are factors: 0 is the start anchor, 1 is odometry, and 2 is the end anchor. The dashed arrow shows how the scale changes the odometry measurement.
step = np.array([1.0, 0.2])
start_anchor = np.array([0.0, 0.0])
end_anchor = np.array([1.2, 0.1])
target = np.array([1.0, 0.0])
scale = np.array([0.9]) # A one-coordinate parameter vector.
scale_tag = gtsam.ParameterTag("odometry scale")
penalty_weight = 0.05
print("Reported displacement:", step)
print("Displacement used by the factor:", scale[0] * step)Reported displacement: [1. 0.2]
Displacement used by the factor: [0.9 0.18]
2. The factor and its parameter derivative¶
The odometry factor compares the displacement between two Point2 states with
the scaled measurement. Its raw factor residual is
, with state Jacobians and .
The first callback below returns that raw factor residual and fills in the
Jacobians requested by CustomFactor.
The parameter callback supplies a vector–Jacobian product (VJP): the transpose of the raw factor residual’s parameter Jacobian multiplied by an input vector. That input is a factor residual cotangent, which expresses how the loss changes with a raw factor residual change. Here the parameter Jacobian is , so the VJP returns times the input, as a one-coordinate vector.
The shared scale_tag selects this parameter without storing its value or
registering factors. The callback returns None for other tags. GTSAM supplies
the solved states and already accounts for noise weighting, so the callback
only differentiates the raw factor residual. It holds both the solved states
and the cotangent fixed.
anchor_noise = gtsam.noiseModel.Isotropic.Sigma(2, 0.4)
odometry_noise = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.3, 0.5]))
def make_odometry_factor(parameter):
parameter = parameter.copy() # Keep this factor fixed during gradient queries.
def error(this, values, jacobians):
if jacobians is not None:
jacobians[0] = -np.eye(2)
jacobians[1] = np.eye(2)
return (values.atPoint2(X(1)) - values.atPoint2(X(0))
- parameter[0] * step)
def parameter_vjp(this, values, requested, factor_residual_cotangent):
if requested != scale_tag:
return None
return np.array([-step @ factor_residual_cotangent])
return gtsam.CustomFactor(
odometry_noise, [X(0), X(1)], error, parameter_vjp,
)
def make_graph(parameter):
graph = gtsam.NonlinearFactorGraph()
graph.add(gtsam.PriorFactorPoint2(X(0), start_anchor, anchor_noise))
graph.add(make_odometry_factor(parameter))
graph.add(gtsam.PriorFactorPoint2(X(1), end_anchor, anchor_noise))
return graph
graph = make_graph(scale)
print("Number of factors:", graph.size())Number of factors: 3
The noise models weight each factor’s coordinate errors. The anchors have standard deviation 0.4 in each coordinate, and odometry has standard deviations , so horizontal odometry errors receive more weight than vertical errors. Both optimization and parameter differentiation apply these weights automatically.
Ordinary prior factors can coexist with parameterized custom factors. They have no scale callback, yet they influence the fitted positions and therefore the scale gradient. The query needs neither their indices nor an explicit list of parameterized factors.
3. The optimized positions¶
The optimizer fits the two positions while the scale remains fixed at 0.9.
It starts from the positions in initial and minimizes the sum of squared
whitened factor residuals from the three factors. The result is stored in solution.
The fitted positions balance the anchors against the odometry measurement, so they generally differ from the anchor positions. At an optimized solution, the estimation objective’s gradient should be close to zero, a condition called stationarity. The reported gradient norm indicates how close the solution is to that condition.
initial = gtsam.Values()
initial.insert(X(0), start_anchor)
initial.insert(X(1), scale[0] * step)
solution = gtsam.GaussNewtonOptimizer(graph, initial).optimize()
stationarity_error = graph.linearize(solution).gradientAtZero().norm()
print("Estimated start:", solution.atPoint2(X(0)))
print("Estimated endpoint:", solution.atPoint2(X(1)))
print(f"Inner objective gradient norm: {stationarity_error:.2e}")Estimated start: [ 0.11707317 -0.02245614]
Estimated endpoint: [1.08292683 0.12245614]
Inner objective gradient norm: 1.44e-15
4. The training loss¶
The training loss combines squared endpoint error with a penalty on the scale.
Write for the optimized positions and
(penalty_weight) for the penalty coefficient. With the
supervised position target, the loss is
The scale affects this loss both through the optimized positions and through the penalty. The parameter derivative must include both contributions.
def outer_loss(estimate, parameter):
error = estimate.atPoint2(X(1)) - target
return 0.5 * error @ error + 0.5 * penalty_weight * (parameter @ parameter)
endpoint_error = solution.atPoint2(X(1)) - target
initial_loss = outer_loss(solution, scale)
print("Endpoint error:", endpoint_error)
print(f"Endpoint loss: {0.5 * endpoint_error @ endpoint_error:.8f}")
print(f"Scale penalty: {0.5 * penalty_weight * (scale @ scale):.8f}")
print(f"Total loss: {initial_loss:.8f}")Endpoint error: [0.08292683 0.12245614]
Endpoint loss: 0.01093618
Scale penalty: 0.02025000
Total loss: 0.03118618
5. The gradient through optimized positions¶
The endpoint loss has derivative with respect to the
optimized endpoint. Place it in a VectorValues under X(1). These keyed
loss derivatives are called state cotangents. Omitting X(0) gives it zero
direct loss derivative, since the training loss does not compare the start
position to a target.
parameterGradient propagates this loss derivative through the graph and
calls the factors’ VJPs for scale_tag. It sums their contributions and returns
the loss gradient through the optimized states. The direct scale penalty is
added in the next step.
Figure 2. Computing the scale gradient. parameterGradient invokes the factor callback and accumulates its contribution. The application adds the direct loss derivative.
gradients = gtsam.Gradients(graph, solution)
state_cotangents = gtsam.VectorValues()
state_cotangents.insert(X(1), endpoint_error)
through_estimates = gradients.parameterGradient(state_cotangents, scale_tag)
print("Scale gradient through the optimized positions:", through_estimates)Scale gradient through the optimized positions: [0.03923642]
6. The total scale gradient¶
The scale penalty contributes the direct derivative , where
is penalty_weight. Adding it to the derivative through the positions
gives the total gradient of the training loss:
Both terms use the same one-coordinate parameter vector. A larger parameter block would return a correspondingly larger vector, with every participating factor agreeing on its coordinate order.
direct_gradient = penalty_weight * scale
scale_gradient = through_estimates + direct_gradient
print(f"Through optimized positions: {through_estimates[0]:.10f}")
print(f"Direct scale penalty: {direct_gradient[0]:.10f}")
print(f"Total scale gradient: {scale_gradient[0]:.10f}")Through optimized positions: 0.0392364202
Direct scale penalty: 0.0450000000
Total scale gradient: 0.0842364202
7. Comparing with finite differences¶
A central finite difference estimates the total derivative using losses at and . Each trial builds factors with the new scale and reoptimizes the positions. The optimizer’s state gradient must be near zero before comparing derivatives; otherwise optimization error can dominate the finite difference.
The output compares the calculated gradient with finite differences at three step sizes. This example’s raw factor residuals are linear in both the positions and scale, so the derivative model agrees with reoptimization.
def solve_for_scale(parameter):
trial_graph = make_graph(parameter)
trial_solution = gtsam.GaussNewtonOptimizer(trial_graph, solution).optimize()
return trial_graph, trial_solution
for epsilon in (1e-4, 1e-5, 1e-6):
_, plus = solve_for_scale(scale + epsilon)
_, minus = solve_for_scale(scale - epsilon)
numerical = (
outer_loss(plus, scale + epsilon) - outer_loss(minus, scale - epsilon)
) / (2 * epsilon)
print(f"Step {epsilon:.0e}: gradient {scale_gradient[0]:.10f}, "
f"finite difference {numerical:.10f}")Step 1e-04: gradient 0.0842364202, finite difference 0.0842364202
Step 1e-05: gradient 0.0842364202, finite difference 0.0842364202
Step 1e-06: gradient 0.0842364202, finite difference 0.0842364202
8. A gradient-descent update¶
A gradient-descent update subtracts the loss derivative multiplied by a positive learning rate, , from the parameter:
The updated parameter needs a new position estimate before its loss can be
evaluated. With the default values and learning rate 0.5, the update lowers
the scale from 0.9 to about 0.858 and reduces the training loss. A new
Gradients object is then constructed for a derivative calculation at the
updated scale and solution.
learning_rate = 0.5
updated_scale = scale - learning_rate * scale_gradient
updated_graph, updated_solution = solve_for_scale(updated_scale)
updated_loss = outer_loss(updated_solution, updated_scale)
print(f"Scale: {scale[0]:.8f} -> {updated_scale[0]:.8f}")
print(f"Loss: {initial_loss:.8f} -> {updated_loss:.8f}")
# Compute the next gradient at the updated scale and solved positions.
updated_gradients = gtsam.Gradients(updated_graph, updated_solution)
updated_cotangents = gtsam.VectorValues()
updated_cotangents.insert(X(1), updated_solution.atPoint2(X(1)) - target)
next_gradient = (
updated_gradients.parameterGradient(updated_cotangents, scale_tag)
+ penalty_weight * updated_scale
)
print("Next scale gradient:", next_gradient)Scale: 0.90000000 -> 0.85788179
Loss: 0.03118618 -> 0.02782052
Next scale gradient: [0.07558357]
Training with more parameters¶
Each parameter vector needs one tag shared between its gradient queries and participating factors. A factor can recognize several tags and return a different VJP for each; several factors can recognize the same tag and their contributions are summed. Query only the tags being trained. Creating another tag with the same name produces a different identity, so keep the original tags across training steps.
An automatic-differentiation library can evaluate the raw factor VJP inside the callback. Differentiate the dot product of the supplied cotangent and raw factor residual with respect to the selected parameter vector, holding the solved states and cotangent fixed. Return a NumPy vector to GTSAM. This keeps the parameter values and their derivative graph entirely in Python.
The graph’s factors and captured parameters must stay fixed during queries. This factory copies each parameter vector into its factor, and a training update constructs a new graph. Noise models remain fixed throughout training; learning their parameters is outside this API.
Advanced: the derivative model¶
Gradients uses a Gauss-Newton model to differentiate the optimized estimates.
The factor residuals here are linear functions plus constants in the combined
positions and scale, called jointly affine factor residuals. In this case the
model agrees with exact differentiation.
Nonlinear factor residuals can contribute additional second derivatives to the solution derivative. These terms affect both the state Hessian, the estimation objective’s second derivative with respect to the positions, and the mixed state-parameter derivative, which describes how its state gradient changes with the parameter. Gauss-Newton omits the parts of both weighted by factor residuals, so exact reoptimization can give a different derivative even at a converged solution.
The implicit-function theorem obtains the solution derivative from the
estimation objective’s zero-gradient equation. The guide’s
advanced section
derives this equation and the terms used by Gradients.