Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Learning odometry corrections with PyTorch

A sensor that consistently overestimates a robot’s displacement can make its estimated path drift away from its true path. In this tutorial, a small neural network learns to correct those measurements. GTSAM estimates the robot’s positions using the corrected measurements, and we train the network to reduce the difference between those estimates and known reference positions.

Training requires the derivative of position error with respect to the network’s weights. Gradients computes the part of this derivative that passes through GTSAM’s position estimates, and PyTorch computes the network’s derivatives. The scale example introduces this calculation with one adjustable number instead of a neural network.

The example assumes basic Python, matrix multiplication, and the chain rule for derivatives. It requires NumPy, Plotly, PyTorch, and a GTSAM development build with the parameter-gradient API. Run it only where PyTorch is already installed; the installation cell below adds GTSAM and Plotly when running in Colab.

A C++ version using LibTorch implements the same training workflow without plots.

Open In Colab
PyTorch: 2.13.0

1. Generating measurements with a sensor bias

Each example path contains nine two-dimensional positions and eight measured displacements between them. A sensor feature fif_i, such as temperature scaled to lie between -1 and 1, determines a systematic measurement error called the bias. Write xitruex_i^{\mathrm{true}} for the true position at step ii, b(fi)b(f_i) for the two-coordinate bias, and ϵi\epsilon_i for small random noise. The measured displacement mim_i is

mi=(xi+1true−xitrue)+b(fi)+ϵi.m_i=(x_{i+1}^{\mathrm{true}}-x_i^{\mathrm{true}})+b(f_i)+\epsilon_i.

Noisy measurements of the first and last positions provide anchors for each path. GTSAM will combine these anchors with the displacement measurements to estimate all nine positions. The exact reference positions are saved in truth so that we can measure the estimation error afterward.

Four paths will be used for training, and two additional paths will be reserved for evaluating the trained network. The code calls these reserved paths validation_data. Varying phase changes the paths and the sensor features encountered along them.

Training trajectories: 4; held-out: 2

2. Predicting the sensor bias

The neural network takes one sensor feature and predicts a horizontal and a vertical bias. It first computes eight weighted sums of the input, adds eight adjustable offsets, and applies Tanh to each result. Tanh is a smooth nonlinear function with outputs between -1 and 1. A second weighted sum produces the two bias coordinates. This type of network is called a multilayer perceptron, or MLP.

The network has 34 adjustable numbers: 8 input weights, 8 hidden-layer offsets, 16 output weights, and 2 output offsets. PyTorch calls the adjustable offsets biases too; they are network parameters, whereas the sensor bias is the quantity we want the network to predict. We initialize the output weights near zero so that the first predictions make only small measurement corrections.

PyTorch uses double precision here to match GTSAM’s numerical calculations. Setting the random seed makes the initial weights repeatable. The torch.no_grad() block lets us set those starting weights without recording derivatives of the initialization.

Sequential(
  (0): Linear(in_features=1, out_features=8, bias=True)
  (1): Tanh()
  (2): Linear(in_features=8, out_features=2, bias=True)
)
Trainable coordinates: 34

3. Using the correction in an odometry factor

An odometry factor compares the displacement between two estimated positions with a corrected sensor measurement. Let θ\theta collect the network’s 34 adjustable numbers, and write its prediction for feature fif_i as bθ(fi)b_\theta(f_i). Subtracting that prediction from the measurement gives mi−bθ(fi)m_i-b_\theta(f_i). The prediction-minus-measurement difference is the raw factor residual:

ei(xi,xi+1,θ)=xi+1−xi−mi+bθ(fi).e_i(x_i,x_{i+1},\theta) =x_{i+1}-x_i-m_i+b_\theta(f_i).

The factor’s error function must also tell GTSAM how this difference changes when either position changes. These derivatives are two-by-two matrices, called Jacobians. Increasing the first position decreases the difference, so its Jacobian is −I-I, where II is the identity matrix. Increasing the second position increases the difference, giving Jacobian II.

The noise model determines how strongly GTSAM weights each coordinate error. We assign standard deviations of 0.08 and 0.12 to horizontal and vertical odometry errors, respectively. A smaller standard deviation gives an error more weight. The error function itself returns the raw difference; GTSAM applies these weights.

A second function supplies derivatives of the raw factor residual with respect to the network weights. We pass this function to CustomFactor for GTSAM to call when it needs derivatives; such a function is called a callback. Let LL denote the training loss, which will measure position error. GTSAM passes this function a two-coordinate vector cc that describes how the loss changes when the factor residual changes, including the effect of re-estimating the positions. The API calls cc a factor residual cotangent.

The chain rule converts cc into this factor’s contribution to each weight’s loss derivative. For one weight pp and the horizontal and vertical factor residual coordinates ex,eye_x,e_y, the contribution is

cx∂ex∂p+cy∂ey∂p.c_x\frac{\partial e_x}{\partial p} +c_y\frac{\partial e_y}{\partial p}.

torch.autograd.grad computes these sums for all the network parameters at once. The 34 resulting derivatives form this factor’s parameter gradient. This operation is called a vector–Jacobian product, or VJP, and it is the job of the parameter_vjp function below. grad_outputs=cotangent supplies the two coefficients cx,cyc_x,c_y. The positions and these coefficients are held fixed while PyTorch differentiates the network. GTSAM has already accounted for the noise weights, so the function uses the raw factor residual.

The returned parameter derivatives must follow one consistent order. PyTorch’s model.parameters() supplies the network’s weight matrices and offset vectors; we save that order in parameters. parameters_to_vector joins their derivatives into one vector of 34 numbers, and detach().numpy() returns those numbers to GTSAM as a NumPy array.

A ParameterTag lets the function recognize which group of parameters is being requested. Here the group contains all 34 network parameters. Every odometry factor and network gradient query uses network_tag to identify that group. The callback returns None for a different tag.

The factor stores a corrected measurement when it is constructed, so GTSAM can estimate positions without repeatedly evaluating the network. Its parameter callback evaluates the network again when a derivative is requested. The weights must therefore stay fixed between constructing the factor and finishing its derivative queries.

Odometry factors connect neighboring positions, and anchor factors constrain the two ends of each path. The diagram shows a shortened path with three positions. Round nodes are variables, and square nodes are factors.

Figure 1. A shortened path. Each D factor uses the same network parameters θ\theta to correct its measurement. Each A factor uses an endpoint measurement. The dashed arrows show which factors use the network.

4. Estimating positions for several paths

GTSAM estimates positions by minimizing the sum of squared factor residuals after applying the noise weights. The network parameters stay fixed during this position estimation.

We can put all the training paths in one graph by giving each position a distinct key, its identifier in the graph. X(i) creates such a key from an integer. Values stores a two-coordinate position under each key. solve_batch starts with guesses obtained by adding up the measured displacements from the first anchor.

The anchor factors keep the estimated path close to its measured endpoints. We give the start anchor a standard deviation of 0.03 and the end anchor a standard deviation of 0.25, so the start is more strongly constrained. The reference positions are returned separately in targets for the loss calculation; they are not used as additional position measurements.

5. Computing the training loss and its gradient

The position loss measures the average squared distance between the estimated positions and their reference positions. Write xk⋆(θ)x_k^\star(\theta) for an estimated position, yky_k for its reference, and NN for the number of positions being compared. For training, N=32N=32: eight positions after the start of each of the four paths. We also add a small penalty on the squared network parameters, with coefficient α\alpha, to discourage large parameter values. The total training loss is

L(θ)=12N∑k∥xk⋆(θ)−yk∥2+α2∥θ∥2.L(\theta)=\frac{1}{2N}\sum_k\|x_k^\star(\theta)-y_k\|^2 +\frac{\alpha}{2}\|\theta\|^2.

The derivative of the position loss with respect to position xk⋆x_k^\star is (xk⋆−yk)/N(x_k^\star-y_k)/N. The factor 1/21/2 in the loss cancels the factor 2 from differentiating the square. GTSAM calls these derivatives state cotangents. A VectorValues associates each derivative vector with its position’s key.

parameterGradient turns the position derivatives into derivatives with respect to the network weights. It uses the solved graph and calls the factors’ parameter functions with network_tag, adding their returned vectors. Ordinary anchor factors have no network parameters, but their influence on the estimated positions is included in this calculation.

The weight penalty contributes a second part of the total gradient because it depends directly on the weights. torch.autograd.grad computes this contribution from weight_penalty(), and we add it to the vector returned by parameterGradient. This call returns derivatives without changing the network’s weights.

Initial position loss: 0.122021
Total gradient norm: 1.796164

6. Comparing the gradient with finite differences

Small changes in the weights reveal how the loss responds in a chosen parameter direction. Choose a vector vv with length one in the 34-dimensional parameter space. For a small number hh, we solve the graph at weights θ+hv\theta+hv and again at θ−hv\theta-hv. The slope estimated from those two losses, called a central finite difference, is

L(θ+hv)−L(θ−hv)2h.\frac{L(\theta+hv)-L(\theta-hv)}{2h}.

The dot product vT∇θLv^T\nabla_\theta L predicts the slope in the same direction. The code compares this prediction with the finite difference at three step sizes, including the weight penalty in each loss. The vector vv changes all 34 parameters together, so the displayed slopes describe one direction through parameter space.

Each changed set of weights needs a newly constructed graph and newly estimated positions. This keeps the stored corrected measurements consistent with the network used by the derivative callback. parameters_to_vector saves the current weights, and vector_to_parameters assigns the perturbed weights before each solve. The finally block restores the saved weights afterward.

Step 1e-04: analytic -0.030936929, finite difference -0.030936927
Step 1e-05: analytic -0.030936929, finite difference -0.030936929
Step 1e-06: analytic -0.030936929, finite difference -0.030936929

7. Updating the network weights

Adam updates network weights using their loss derivatives and a running record of previous derivatives. We use PyTorch’s implementation with a learning rate of 0.03, which controls the size of the updates. Each iteration estimates positions with the current weights, computes the total gradient, and takes one Adam step.

PyTorch’s optimizer reads each parameter tensor’s derivative from its .grad field. weight_penalty().backward() fills these fields with the direct penalty derivatives. We then split GTSAM’s returned vector according to the sizes of the parameter tensors and add each part to the matching .grad field. zero_grad clears the fields before the next iteration so old derivatives are not added again.

The two reserved paths measure how well the current network works on examples that do not update its weights. evaluate solves their positions and reports the same position loss used for training. We save their initial estimates before the first update for comparison with the trained estimates, and record both training and validation losses every five updates.

Training position loss: 0.122021 -> 0.000286
Held-out position loss: 0.126805 -> 0.000165

8. Comparing the results

After 120 weight updates, both training and validation position losses are less than one percent of their initial values. The curves below report position error alone, leaving out the weight penalty. Their vertical axis uses a logarithmic scale: moving from 0.01 to 0.001 means a tenfold reduction in loss. Hover over a point to read its value and the number of Adam updates.

Loading...
Loading...

The trained network approximates the sensor bias that generated the measurements. Because we chose that bias when creating the data, we can compare the network’s predictions with it at 100 feature values between -1 and 1. Training used position errors rather than these bias values as its loss.

The first validation path also moves closer to its reference positions after training. The final plot compares the estimated positions before and after the weight updates with the reference path. Equal horizontal and vertical scales preserve the shape of the paths.

Loading...
Loading...

Advanced: why this gradient agrees with re-estimation

Changing a network weight changes a factor’s corrected measurement but does not change its position Jacobians, −I-I and II. The factor residual is therefore affine in the positions: a linear function plus a constant. This property makes the derivative computed by Gradients exact for this example, even though the network is nonlinear in its weights.

The two second-derivative terms omitted by the Gauss–Newton approximation both vanish for these factors. To see this, collect the positions into a vector XX and write BB for the matrix that forms neighboring position differences. Let mm collect the measurements and bθ(f)b_\theta(f) collect the predicted biases. Let WW be the fixed matrix of noise weights. Applying these weights, called whitening, gives the weighted factor residual vector

r=W(BX−m+bθ(f)),r=W\bigl(BX-m+b_\theta(f)\bigr),

The position Jacobian is the constant matrix WBWB. It changes neither with the positions nor with the network weights, so ∂XX2r=0\partial^2_{XX}r=0 and ∂Xθ2r=0\partial^2_{X\theta}r=0. The anchor factors have constant position Jacobians as well.

Other factor models can have position Jacobians that depend on the positions or the learned parameters. Their omitted second-derivative terms can be nonzero, so a gradient from Gradients can differ from a finite difference that re-solves the graph. The class guide derives those terms and explains the Gauss–Newton approximation.