Differentiable Planning › Code Implementation

Michael BrenndoerferJuly 8, 202617 min read
World Models HandbookAll chapters
Part 7 of 12Planning and Agency
Chapter 38Differentiable Planning › Code Implementation
Reading section 7 of 10 · Code Implementation · 17 minSection 7 of 10 · 17 min

Book contents

Section 7 of 10 · 17 min read

Differentiable Planning › Code ImplementationOne section · Sections in this chapter7 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We now build the planner. Each experiment isolates one point: autograd matches the analytic gradient at a tested action vector, Adam improves an open-loop plan, a Riccati terminal value improves a two-step MPC controller under a fixed optimization budget, and scalar dynamics expose contracting and expanding gradient chains.

The chapter-wide book setup runs before the first executable example so its plotting helpers are available throughout. Seeding controls the common random streams and improves repeatability within a fixed software and hardware environment; it does not by itself guarantee identical results across platforms or library versions.

Next we define the double integrator, the quadratic cost, and the horizon. Keeping the constants in one place makes it easy to vary the horizon later.

In[5]:
Code
import numpy as np
import torch

torch.manual_seed(0)

## Discrete-time double integrator: state z = [position, velocity]
dt = 0.25
A = torch.tensor([[1.0, dt], [0.0, 1.0]])
B = torch.tensor([[0.5 * dt**2], [dt]])

## Quadratic cost with target position 1.0 and target velocity 0.0
Q = torch.diag(torch.tensor([1.0, 0.1]))
R = torch.tensor([[0.01]])
goal = torch.tensor([1.0, 0.0])
z0 = torch.tensor([0.0, 0.0])

H = 12

The rollout and the cost are the two ingredients that autograd will differentiate. Notice that the rollout is an ordinary Python loop; the computation graph is built implicitly as the loop runs, exactly as it would be for an RNN. Autograd requires no planning-specific differentiation machinery or hand-derived adjoint, and the planner's autograd path does not assemble explicit Jacobians. The independent check below assembles GkG_k only for verification. The framework tracks the operations, and a single call to backward on the scalar cost computes and accumulates the full gradient in the action tensor's .grad field.

In[6]:
Code
def rollout(z_init, actions):
    """Roll the model forward. actions: (H,) tensor. Returns states (H+1, 2)."""
    b_col = B.squeeze(-1)
    states = [z_init]
    for t in range(actions.shape[0]):
        states.append(A @ states[-1] + b_col * actions[t])
    return torch.stack(states)


def trajectory_cost(states, actions, terminal_Q=Q):
    """Stage costs plus an optional quadratic terminal cost."""
    stage = sum((z - goal) @ Q @ (z - goal) for z in states[:-1])
    ctrl = sum(R.item() * a**2 for a in actions)
    terminal = (
        torch.tensor(0.0)
        if terminal_Q is None
        else (states[-1] - goal) @ terminal_Q @ (states[-1] - goal)
    )
    return stage + ctrl + terminal

Now we compute the gradient two ways. Autograd differentiates the rollout; the second calculation assembles the quadratic gradient independently. Agreement at the all-zero action vector is a focused numerical check of this implementation, consistent with the adjoint relation derived above.

In[7]:
Code
## --- Autograd gradient at the all-zero action sequence ---
a_flat = torch.zeros(H, requires_grad=True)
states0 = rollout(z0, a_flat)
J_val = trajectory_cost(states0, a_flat)
J_val.backward()
grad_autograd = a_flat.grad.detach().clone()


## --- Closed-form gradient from the quadratic structure ---
def control_to_state_matrix(k, horizon):
    """G_k such that z_k = A^k z_0 + G_k @ a for a of length `horizon`."""
    G = torch.zeros(2, horizon)
    for j in range(min(k, horizon)):
        G[:, j] = (torch.matrix_power(A, k - 1 - j) @ B).squeeze(-1)
    return G


M = torch.zeros(H, H)
b_lin = torch.zeros(H)
for k in range(H + 1):
    Gk = control_to_state_matrix(k, H)
    M += Gk.T @ Q @ Gk
    b_lin += Gk.T @ Q @ (torch.matrix_power(A, k) @ z0 - goal)
M += torch.eye(H) * R.item()

grad_closed_form = 2 * b_lin  # evaluated at a = 0, where grad J = 2 M a + 2 b
Out[8]:
Console
Objective J(a=0)             : 13.0000
Max |autograd - closed form| : 0.000e+00
Autograd gradient, first four: [-9.     -7.5625 -6.25   -5.0625]
Gradient norm                : 15.3996

The two gradients agree to numerical precision at this tested point. We check the gradient norm explicitly because agreement between two negligible vectors would be a weak test. Here the all-zero diagnostic vector has a substantial nonzero gradient.

Gradient-based optimization improves the trajectory

Now we hand the action sequence to Adam. The "parameters" are actions rather than model weights; the loop still zeroes gradients, computes the loss, backpropagates, and takes an update.

In[9]:
Code
actions = torch.linspace(-1.5, 1.5, H, requires_grad=True)
optimizer = torch.optim.Adam([actions], lr=0.02)

loss_history = []
for step in range(400):
    optimizer.zero_grad()
    states = rollout(z0, actions)
    loss = trajectory_cost(states, actions)
    loss.backward()
    optimizer.step()
    loss_history.append(loss.item())

with torch.no_grad():
    final_states = rollout(z0, actions).clone()
    final_actions = actions.clone()

We plot the loss curve and resulting phase trajectory. The loss trace shows optimization progress, not a convergence proof. For this Adam run, the optimizer starts from the declared linear ramp from −1.5-1.5 to 1.51.5; the all-zero vector used in the nearby gradient checks is separate from that run. We use R=0.01R=0.01. The deliberately high-loss ramp makes the reduction easy to see, but the 400-step budget does not reach the analytic optimum.

Before looking at the final plan, inspect the gradient by action index. The open-loop double integrator is not asymptotically stable and, because its unit eigenvalue has a nontrivial Jordan block, it is not Lyapunov stable: with nonzero initial velocity, its zero-input position grows linearly. With cumulative state costs, early actions affect more stage costs than late actions, so the all-zero profile is strongest at the start of the horizon. This is an example of the sum of future-cost contributions dominating the profile, not of a vanishing early-action gradient.

In[10]:
Code
## Recompute the gradient at the initial all-zero action sequence to inspect its profile.
probe_actions = torch.zeros(H, requires_grad=True)
probe_states = rollout(z0, probe_actions)
probe_loss = trajectory_cost(probe_states, probe_actions)
probe_loss.backward()
grad_profile_numpy = probe_actions.grad.detach().abs().numpy()

## Compare the earliest and latest action-gradient magnitudes.
grad_ratio_first_last = (
    float(grad_profile_numpy[0] / grad_profile_numpy[-1])
    if grad_profile_numpy[-1] != 0
    else float("nan")
)
grad_span_orders = (
    float(np.log10(grad_profile_numpy.max() / grad_profile_numpy.min()))
    if grad_profile_numpy.min() > 0
    else float("nan")
)

print(
    f"|dJ/da_0| / |dJ/da_{{H-1}}| : {grad_ratio_first_last:.4f}".replace(
        "_{{H-1}}", "[H-1]"
    )
)
print(f"Orders of magnitude spanned : {grad_span_orders:.3f}")
Out[11]:
Visualization
Log-scale bar chart of per-action gradient magnitudes across the plan horizon.
Per-action gradient magnitude at the all-zero diagnostic sequence for the twelve-step double integrator, on a logarithmic vertical axis. The first component is 144 times the last because it reaches more state-cost terms and its position influence grows with delay: at this zero profile, |dJ/da_j| = dt^2(H-j)^2. This noncontracting system can have linearly growing zero-input position and is not the contracting terminal-only example used later.
Out[12]:
Visualization
(<Figure size 900x750 with 1 Axes>, <Figure size 900x750 with 1 Axes>)
The planning objective over 400 Adam iterations, falling on a logarithmic vertical axis as the optimizer refines a linear action ramp from -1.5 to 1.5.
Line plot with a logarithmic vertical axis showing planning loss decreasing over Adam iterations from a nonzero linear-ramp initialization.
The improved open-loop trajectory in position-velocity space. It passes the target position and ends at approximately position 1.05 and velocity -0.28 after the fixed 400-step optimization budget.
Phase plot of a double-integrator trajectory approaching the marked goal state, overshooting the target position, and ending with negative velocity.

The objective decreases throughout the plotted run. Under this fixed budget, the trajectory approaches and overshoots the target position, ending near (x,v)=(1.05,−0.28)(x,v)=(1.05,-0.28) rather than settling exactly at (1,0)(1,0). In this run, the optimizer produces rising and then falling velocity while trading finite-horizon position and velocity tracking against action effort. No separately designed feedback law is used: given the model, cost, initialization, and optimizer, backpropagation through the known dynamics computes the action gradients, and Adam uses those gradients to produce the open-loop action sequence.

Ill-conditioning grows with the horizon in this example

We now assemble MM for increasing horizons in this fixed double-integrator problem. The true action Hessian is 2M2M, which has the same condition number as MM. Horizon growth need not worsen every system, but it does here.

In[13]:
Code
horizons = list(range(2, 41))
condition_numbers = []

for h in horizons:
    Mh = torch.zeros(h, h)
    for k in range(h + 1):
        Gk = control_to_state_matrix(k, h)
        Mh += Gk.T @ Q @ Gk
    Mh += torch.eye(h) * R.item()
    eigvals = torch.linalg.eigvalsh(Mh)
    # Curvature accumulates through the action-to-state sensitivity matrices G_k.
    condition_numbers.append(float(eigvals[-1] / eigvals[0]))

print(f"Condition number at H=2 : {condition_numbers[0]:.1f}")
print(f"Condition number at H=40: {condition_numbers[-1]:.1f}")
Out[14]:
Visualization
Line chart with a logarithmic vertical axis showing the condition number of M rising with horizon in the double-integrator example.
For this double-integrator single-shooting problem, the condition number of the action-space quadratic matrix M rises over horizons H=2 through H=40. The Hessian is 2M and has the same condition number. The vertical axis is logarithmic.

For this system, the condition number climbs by orders of magnitude over the tested range. This eigenvalue spread slows unpreconditioned fixed-step gradient descent. Curvature-aware or preconditioned methods can compensate for part of it, although approximate Hessians can remain inaccurate. Regularization choices and finite precision also matter.

For J(a)=a⊤Ma+2a⊤b+constJ(a)=a^\top Ma+2a^\top b+\text{const}, the Hessian has largest eigenvalue L=2λmax⁡(M)L=2\lambda_{\max}(M). Gradient descent with step α=1/L\alpha=1/L contracts Euclidean iterate error by at most q=1−1/κq=1-1/\kappa per step. For 0<τ<10<\tau<1 and κ>1\kappa>1, reaching ∥ak−a⋆∥≤τ∥a0−a⋆∥\lVert a_k-a^\star\rVert\leq\tau\lVert a_0-a^\star\rVert is therefore guaranteed once

k≥log⁡τlog⁡(1−1/κ).k \geq \frac{\log \tau}{\log(1-1/\kappa)}.

For an integer iteration count, take the ceiling of the right-hand side. When κ=1\kappa=1, the step α=1/L\alpha=1/L reaches the quadratic optimum in one update instead. This is a worst-case bound, not a prediction that every initialization will attain equality. We compare it with the realized iterate-error threshold for the H=12H=12 quadratic using the same learning rate.

In[15]:
Code
## Worst-case steps to reduce Euclidean iterate error to the declared tolerance
## with alpha = 1 / L, where L = 2 * lambda_max(M).
tolerance = 1e-2
condition_numbers_np = np.array(condition_numbers, dtype=float)
bound_steps = np.ceil(
    np.log(tolerance) / np.log(1.0 - 1.0 / condition_numbers_np)
)
## Handle the kappa=1 boundary or a value rounded to it.
bound_steps = np.maximum(bound_steps, 1.0)

## Realized iterate-error threshold for H=12 with the same alpha = 1 / L.
quadratic_eigvals = torch.linalg.eigvalsh(M)
quad_lr = 1.0 / (2.0 * float(quadratic_eigvals[-1]))
quad_optimum = torch.linalg.solve(M, -b_lin)
quad_actions = torch.zeros(H)
initial_iterate_error = torch.linalg.norm(quad_actions - quad_optimum)
h12_index = horizons.index(H)
actual_steps_h12 = None
for step in range(int(bound_steps[h12_index]) + 1):
    relative_error = (
        torch.linalg.norm(quad_actions - quad_optimum) / initial_iterate_error
    )
    if relative_error <= tolerance:
        actual_steps_h12 = step
        break
    quad_gradient = 2.0 * (M @ quad_actions + b_lin)
    quad_actions = quad_actions - quad_lr * quad_gradient

assert actual_steps_h12 is not None
assert actual_steps_h12 <= bound_steps[h12_index]

print(f"Worst-case bound at H=2  : {bound_steps[0]:.0f} steps")
print(f"Worst-case bound at H=12 : {bound_steps[h12_index]:.0f} steps")
print(f"Realized steps at H=12   : {actual_steps_h12} steps")
print(f"Worst-case bound at H=40 : {bound_steps[-1]:.0f} steps")
Out[16]:
Visualization
Log-log line chart of worst-case gradient descent steps versus condition number, with the realized H=12 step count marked below its bound.
Worst-case gradient-descent steps needed to reduce Euclidean iterate error to one percent with learning rate 1/L, where L is the largest Hessian eigenvalue. The marked H=12 run reaches the threshold within the bound. Both axes are logarithmic.

A Riccati terminal value improves a two-step horizon

We solve the discrete algebraic Riccati equation numerically, then compare three receding-horizon controllers under the same 100-iteration Adam budget per planning call: a declared two-step horizon without a terminal value, the same two-step horizon with the Riccati value, and a twelve-step reference without a terminal value. Finite optimizer budgets mean this is an empirical comparison of these implementations, not an exact Bellman-equivalence test.

In[17]:
Code
import scipy.linalg as sla

P_np = sla.solve_discrete_are(
    A.numpy().astype(float),
    B.numpy().astype(float),
    Q.numpy().astype(float),
    R.numpy().astype(float),
)
P = torch.tensor(P_np, dtype=torch.float32)

print(f"Riccati solution P:\n{P.numpy().round(4)}")
In[18]:
Code
def plan(z_init, horizon, terminal_Q, iters=100, lr=0.1):
    """Optimize an action sequence of length `horizon` from state z_init."""
    a = torch.zeros(horizon, requires_grad=True)
    opt = torch.optim.Adam([a], lr=lr)
    for _ in range(iters):
        opt.zero_grad()
        loss = trajectory_cost(rollout(z_init, a), a, terminal_Q=terminal_Q)
        loss.backward()
        opt.step()
    return a.detach()


def mpc_closed_loop(horizon, terminal_Q, steps=25):
    """Receding-horizon control: plan, apply the first action, replan."""
    z = z0.clone()
    trajectory = [z.clone()]
    total = 0.0
    for _ in range(steps):
        a_plan = plan(z, horizon, terminal_Q)
        with torch.no_grad():
            a0 = a_plan[0]
            total += float((z - goal) @ Q @ (z - goal) + R.item() * a0**2)
            z = A @ z + B.squeeze(-1) * a0
        trajectory.append(z.clone())
    return total, torch.stack(trajectory)


SHORT_HORIZON = 2
cost_short, traj_short = mpc_closed_loop(horizon=SHORT_HORIZON, terminal_Q=None)
cost_term, traj_term = mpc_closed_loop(horizon=SHORT_HORIZON, terminal_Q=P)
cost_long, traj_long = mpc_closed_loop(horizon=12, terminal_Q=None)

short_overshoot = float((traj_short[:, 0] - goal[0]).max())
term_overshoot = float((traj_term[:, 0] - goal[0]).max())
cost_reduction_percent = 100.0 * (cost_short - cost_term) / cost_short
Out[19]:
Console
Cumulative cost, H=2, no terminal value : 3.568
Cumulative cost, H=2 + Riccati value     : 2.824
Cumulative cost, H=12, no terminal value               : 3.113
Cost reduction from the terminal value                  : 20.9%
Peak overshoot, no terminal / Riccati                   : 0.0433 / 0.0052
Out[20]:
Visualization
Line chart comparing closed-loop position trajectories for short, short-plus-terminal-value, and long planning horizons.
Closed-loop position under three fixed-budget receding-horizon controllers: a two-step horizon without a terminal value, the same two-step horizon with the Riccati value, and a twelve-step reference without a terminal value. In this run, the Riccati term reduces the two-step controller's peak overshoot from about 0.043 to 0.005 and lowers its cumulative cost by about 21 percent.

The two-step controller without a terminal value overshoots the target by about 0.0430.043 in this deterministic run. Adding the Riccati value reduces peak overshoot to about 0.0050.005 and lowers the 25-step cumulative cost by about 21%21\% relative to the same two-step controller. The twelve-step curve is a reference, not a claimed trajectory match; with 100 Adam iterations per call its cumulative cost is about 10.3%10.3\% higher than the terminal-value controller's, an ordering influenced by finite optimization error. A terminal value is useful when it preserves decision-relevant values or gradients over candidate terminal states.

The gradient chain under stable and unstable dynamics

The final experiment isolates the spectral behavior of the Jacobian chain. For zt+1=ρzt+atz_{t+1}=\rho z_t+a_t and J=(zH−1)2J=(z_H-1)^2, the general derivative is

∂J∂at=2(zH−1)ρH−1−t.\frac{\partial J}{\partial a_t}=2(z_H-1)\rho^{H-1-t}.

At the code's evaluation point z0=0z_0=0 with all actions zero, zH=0z_H=0 and this reduces to −2ρH−1−t-2\rho^{H-1-t}. We sweep nonnegative ρ\rho and the horizon, recording the first-action gradient magnitude.

In[21]:
Code
def terminal_gradient_profile(rho, horizon):
    """|dJ/da_t| for J = (z_H - 1)^2 with z_{t+1} = rho * z_t + a_t."""
    a = torch.zeros(horizon, requires_grad=True)
    z = torch.tensor(0.0)
    for t in range(horizon):
        z = rho * z + a[t]
    ((z - 1.0) ** 2).backward()
    return a.grad.detach().abs().numpy()


rhos = [0.7, 0.9, 1.0, 1.1, 1.25]
sweep_horizons = list(range(1, 41))
first_action_grad = {
    rho: [float(terminal_gradient_profile(rho, h)[0]) for h in sweep_horizons]
    for rho in rhos
}

profile_stable = terminal_gradient_profile(0.9, 30)
profile_unstable = terminal_gradient_profile(1.1, 30)
Out[22]:
Visualization
(<Figure size 900x1050 with 1 Axes>, <Figure size 900x1050 with 1 Axes>)
The gradient with respect to the first action as the horizon grows, for scalar linear dynamics with nonnegative rho from 0.7 to 1.25. Values below one contract, values above one expand, and rho equal to one preserves this terminal-only sensitivity.
Log-scale line chart of gradient magnitude versus horizon for five spectral radii.
The gradient profile across action index at a fixed horizon of 30 steps. With rho=0.9 the early actions receive roughly twenty times less gradient than the final action, while with rho=1.1 the ordering reverses and the early actions dominate.
Log-scale line chart of gradient magnitude versus action index for two spectral radii.

The left panel shows the predicted exponential decay and growth for this terminal-only objective: for 0≤ρ<10\leq\rho<1 the first-action gradient tends toward zero, for ρ>1\rho>1 it grows, and at ρ=1\rho=1 it stays constant. More generally, contraction occurs when ∣ρ∣<1|\rho|<1. At fixed H=30H=30, the ρ=0.9\rho=0.9 first-action magnitude is 0.929≈0.0470.9^{29}\approx0.047 times the last-action magnitude; with ρ=1.1\rho=1.1 the ordering reverses. With plain gradient descent using one global learning rate, larger gradient components receive proportionally larger updates.

The update rule strongly affects relative step magnitudes. The next plot is an idealized first-step direction comparison, not a simulation of stateful Adam. It contrasts a normalized plain-gradient magnitude profile with elementwise normalization ∣gi∣/max⁡(∣gi∣,ϵ)|g_i|/\max(|g_i|,\epsilon). This sign-like normalization resembles Adam's first update only when moment state starts at zero, gradients dominate ϵ\epsilon, and clipping, weight decay, or other interventions do not materially alter the coordinatewise first update.

In[23]:
Code
## Gradient profile at a representative stable contraction factor and horizon.
imbalance_profile = terminal_gradient_profile(0.9, 30).astype(float)
plain_step_magnitudes = imbalance_profile / np.linalg.norm(imbalance_profile)

## Idealized first-step elementwise normalization, not stateful Adam.
normalized_coordinates = imbalance_profile / np.maximum(
    imbalance_profile, 1e-12
)
normalized_step_magnitudes = normalized_coordinates / np.linalg.norm(
    normalized_coordinates
)

print(
    "Plain-gradient magnitude ratio, late/early : "
    f"{plain_step_magnitudes[-1] / plain_step_magnitudes[0]:.3e}"
)
print(
    "Elementwise-normalized ratio, late/early  : "
    f"{normalized_step_magnitudes[-1] / normalized_step_magnitudes[0]:.3e}"
)
Out[24]:
Visualization
Log-scale line chart comparing a normalized plain-gradient magnitude profile with an idealized equalized first-step profile.
Idealized first-step magnitude directions for the rho=0.9, H=30 scalar gradient profile. Plain normalization preserves component ratios; elementwise sign-like normalization makes the nonzero magnitudes equal. This is not a simulation of Adam's stateful moments.