LQR Example¶
This notebook demonstrates how to solve a finite-horizon Linear Quadratic Regulator (LQR) problem using a Gaussian Factor Graph in GTSAM. The goal is to minimize a quadratic cost w.r.t. dynamics constraints over a linear dynamical system using factor-graph-based optimization. The results are compared against results from classical Riccati equations. Adapted from https://
Author(s): Zhouyu Zhang
GTSAM Copyright 2010-2025, Georgia Tech Research Corporation, Atlanta, Georgia 30332-0415 All Rights Reserved
Authors: Frank Dellaert, et al. (see THANKS for the full author list)
See LICENSE for the license information
# In colab install gtsam and gtbook
try:
import google.colab
%pip install --quiet gtsam-develop
%pip install --quiet gtbook
except ImportError:
pass # Not in Colab
Proceed (Y/n)? y
Proceed (Y/n)? y
# Import required libraries
import numpy as np
import matplotlib.pyplot as plt
import gtsam
from gtsam import symbol
from gtsam.symbol_shorthand import X, U
import graphviz
from gtbook.display import show
Discrete-Time State-Space Form (2D State: Position and Velocity)¶
We look to work on a 1D double integrator system, in this sense, we define the state and input as:
We can think of it as a linear vehicle for which we can control the gas paddle (acceleration). Then the discrete-time dynamics become:
Where:
So:
LQR Problem Setup¶
We seek to minimize the infinite-horizon cost function:
Choose the weighting matrices:
- : penalizes position and velocity
- : penalizes control effort
# Define system dynamics: x_{k+1} = A x_k + B u_k
A = np.array([[1.0, 0.1],
[0.0, 1.0]])
B = np.array([[0.005],
[1.0]])
# Define cost matrices: minimize sum of (xᵀQx + uᵀRu)
Q = np.eye(2)
R = np.array([[1.0]])
Graphical Model Representation of Finite-Horizon LQR¶
We formulate the finite-horizon Linear Quadratic Regulator (LQR) problem as a factor graph, allowing us to interpret optimal control as a maximum a posteriori (MAP) estimation problem.
Problem Statement¶
Given discrete-time linear dynamics:
and a quadratic cost function:
we aim to find the state-control sequence minimizing , subject to initial state and dynamics.
MAP Estimation View¶
This control problem is equivalent to a MAP estimation over a factor graph:
This posterior can be written as a product of factors:
Prior Factor (Initial Condition)¶
We encode the prior on the initial state as a Gaussian:
where enforces the initial condition tightly.
Quadratic Cost Factors¶
Each quadratic cost is rewritten via Cholesky decomposition as:
Similarly, for control:
These are soft constraints, penalizing deviation from zero.
Dynamics Constraints (Hard Constraints)¶
Each dynamics equation:
is encoded as a hard constraint, modeled as a Gaussian with zero variance:
This enforces the equality exactly during optimization.
Terminal Cost¶
The terminal cost is also encoded as a soft quadratic factor:
# Horizon of the problem (i.e. how many discrete timesteps we want to consider)
# Note that in python the initial index starts at 0, so the terminal states would be N-1
N = 4
# Cost factors: L such that Q = LᵀL
Lx = np.linalg.cholesky(Q)
Lu = np.linalg.cholesky(R)
# Create a Gaussian factor graph for the linear-quadratic problem
graph = gtsam.GaussianFactorGraph()
initial_values = gtsam.Values()
# Set initial state: x_0 = [0, 1]^T, add a strong prior factor to fix the initial state
x0 = np.array([0.0, 1.0])
prior_noise = gtsam.noiseModel.Isotropic.Sigma(2, 1e-20)
graph.add(gtsam.JacobianFactor(X(0), np.eye(2), x0, prior_noise))
# Cost + dynamics
for k in range(N):
xk = X(k)
uk = U(k)
# Cost factor: x_k^T Q x_k
graph.add(gtsam.JacobianFactor(xk, Lx, np.zeros(2), gtsam.noiseModel.Unit.Create(2)))
# Cost factor: u_k^T R u_k, note that there is no cost term for terminal input
if k < N-1:
graph.add(gtsam.JacobianFactor(uk, Lu, np.zeros(1), gtsam.noiseModel.Unit.Create(1)))
# Dynamics hard constraint factor: x_{k+1} = A x_k + B u_k
if k <= N-2:
xk1 = X(k+1)
graph.add(gtsam.JacobianFactor(
xk1, np.eye(2),
xk, -A,
uk, -B,
np.zeros((2, 1)),
gtsam.noiseModel.Diagonal.Sigmas(np.zeros(2)) # hard constraint
))
# Terminal cost: x_N-1^T Q x_N-1
xN = X(N-1)
graph.add(gtsam.JacobianFactor(xN, Lx, np.zeros(2), gtsam.noiseModel.Unit.Create(2)))
# Solve the factor graph and retrieve the results
result = graph.optimize()
states = []
controls = []
for k in range(N):
xk = X(k)
uk = U(k)
states.append(result.at(xk))
if k < N-1:
controls.append(result.at(uk))
states = np.array(states)
controls = np.array(controls)
# Plotting
plt.figure(figsize=(10, 4))
plt.plot(states[:, 0], label='Position')
plt.plot(states[:, 1], label='Velocity')
plt.title('GTSAM-solved State Trajectories')
plt.legend()
states
array([[0. , 1. ],
[0.0968027 , 0.36053946],
[0.13167395, 0.12400146],
[0.14365837, 0.04085496]])

Visualizing the Factor Graph and Variable Elimination (Bayes Net)¶
Initial Factor Graph¶
The first plot generated by graphviz.Source(graph.dot())
visualizes the factor graph representation of the LQR problem.
- Ellipses represent variable nodes: states and controls .
- Black dots are factor nodes connecting variables — each represents either:
- A cost factor (state or control penalty),
- A dynamics constraint,
- Or a prior (for ).
The structure reflects the temporal nature of the problem:
- Each state is connected to its predecessor and successor via dynamics,
- Each control connects two consecutive states,
- Cost factors attach individually to each and .
graphviz.Source(graph.dot())
From Factor Graph to Bayes Net via Variable Elimination¶
After constructing a Gaussian factor graph to represent the finite-horizon LQR problem, we can use GTSAM’s eliminateSequential(ordering)
to transform the factor graph into a Bayes Net — a directed acyclic graphical model representing the posterior distribution over all variables in a factored form.
The following plots will visualize the Bayes Net obtained using the elimination order:
Structure of the Bayes Net¶
Each node in the plot represents a variable, and each arrow represents a dependency in a conditional Gaussian distribution arising from the variable elimination process. For example:
⇒
⇒
This structure means that once we solve for the “latest” variable (e.g., ), we can back-substitute through the DAG to recover the entire trajectory.
What Does Elimination Do?¶
In mathematical terms, elimination rewrites the joint Gaussian density:
as a sequence of conditionals:
Each conditional is Gaussian:
This allows us to represent the problem as a triangular system, suitable for efficient solving.
Effect of Elimination Ordering¶
Will the result (optimal trajectory) change if we change the elimination ordering?¶
No — the MAP solution is invariant under ordering for linear-Gaussian systems, because the solution to:
is unique and does not depend on the factorization path.
What changes?¶
Structure of the Bayes Net:
The set of conditionals and their dependencies change. E.g., if we eliminate all controls first, each may depend on more than just , introducing wider conditionals.Computational efficiency:
Different orderings lead to different fill-in patterns during matrix factorization (e.g., Cholesky). A poor ordering increases memory and time.Sparsity pattern:
An optimized ordering (e.g., using COLAMD or METIS) maintains sparse Jacobians and minimal cross-dependencies.Numerical stability (nonlinear problems):
Poor ordering may lead to ill-conditioned systems or less stable Gauss-Newton updates.
Analogy to QR/Cholesky¶
The process is equivalent to structured Gaussian elimination or QR factorization of the system , where variable elimination corresponds to pivoting rows and columns of the matrix in a specific order.
ordering = gtsam.Ordering()
for k in range(N):
ordering.push_back(X(k))
if k < N-1:
ordering.push_back(U(k))
# Generate a reversed ordering from above order
ordering_reversed = gtsam.Ordering()
for k in range(N-1, -1, -1):
ordering_reversed.push_back(X(k))
if k > 0:
ordering_reversed.push_back(U(k-1))
print("Ordering:", ordering_reversed)
Ordering: Position 0: x3, u2, x2, u1, x1, u0, x0
dag = graph.eliminateSequential(ordering_reversed)
show(dag, hints={"u":1, "x":0})
# The sparse matrix reprenstation of the system
plt.imshow(graph.jacobian(ordering)[0][2:,:], cmap="RdYlGn")

# The resulting Bayesian graph under a different ordering
# The resulting dependency would change, like we have described before
# New ordering
N = 4
ordering = gtsam.Ordering()
for k in range(N-1, -1, -1):
ordering.push_back(X(k))
for k in range(N-1, -1, -1):
if k > 0:
ordering.push_back(U(k-1))
print("New Ordering:", ordering)
dag = graph.eliminateSequential(ordering)
show(dag, hints={"u":1, "x":0})
Solving the LQR Problem via the Discrete-Time Riccati Equation¶
This code solves a finite-horizon Linear Quadratic Regulator (LQR) problem using the classical backward Riccati recursion followed by a forward rollout of optimal control inputs.
Backward Riccati Recursion¶
We compute the optimal feedback gain by solving the discrete Riccati equation backward from the terminal cost:
The recursion builds the list of gains , used to compute the optimal control law .
Forward Rollout of the Trajectory¶
Given the initial state and feedback gains:
- Compute each optimal control input:
- Apply dynamics to get the next state:
This yields the full trajectory of states and controls.
# Solve the LQR problem with the classical Riccati equation method
import numpy as np
import matplotlib.pyplot as plt
from scipy.linalg import solve_discrete_are
# System definition
A = np.array([[1.0, 0.1],
[0.0, 1.0]])
B = np.array([[0.005],
[1.0]])
Q = np.eye(2)
R = np.array([[1.0]])
x0 = np.array([0.0, 1.0])
N = 3 # Input horizon length (state horizon would be N+1=4)
# Backward Riccati recursion to compute K_t
P = Q.copy() # Terminal cost
K_list = []
for _ in range(N):
S = R + B.T @ P @ B
K = np.linalg.solve(S, B.T @ P @ A) # K = (R + Bᵀ P B)⁻¹ Bᵀ P A
K_list.insert(0, K) # prepend to use forward later
P = Q + A.T @ P @ A - A.T @ P @ B @ K # update P for next step
# Forward rollout of trajectory
x = x0.reshape(-1, 1)
states = [x.flatten()]
controls = []
for K in K_list:
u = -K @ x
controls.append(u.flatten())
x = A @ x + B @ u
states.append(x.flatten())
states = np.array(states)
controls = np.array(controls)
# Plot
plt.figure(figsize=(10, 4))
plt.plot(states[:, 0], label='Position')
plt.plot(states[:, 1], label='Velocity')
plt.title('Riccat-equation-solved State Trajectories')
plt.xlabel('Time step')
plt.legend()
plt.tight_layout()
plt.show()
# Optional: print values
print("States:\n", states)
print("Controls:\n", controls)

States:
[[0. 1. ]
[0.09685631 0.37126203]
[0.13283732 0.14222434]
[0.14670236 0.07074541]]
Controls:
[[-0.62873797]
[-0.22903769]
[-0.07147893]]