19. Q-Learning TD Target With a Frozen Target Network
Problem
Vanilla Q-learning is unstable when the same network both produces the current Q-estimate and bootstraps its own target: the target moves every step you take toward it, so training chases a shifting goalpost. DQN fixed this by keeping a second, frozen “target” copy of the network and computing the Bellman target from that network's Q-values instead of the online network's.
Given the online network's Q-values (still attached to the graph) and the target network's Q-values at the next state (already detached, as they'd be after a with torch.no_grad() forward pass), compute the TD target and the resulting loss:y_t = r_t + gamma * (1 - done_t) * max_a' Q_target(s_{t+1}, a')
The (1 - done_t) mask is the part that's easy to forget: if the episode ended at this transition, there is no next state to bootstrap from, so the target collapses to just the immediate reward.
Source: src/19_q_learning_td_target.py
def q_learning_td_loss(
q_values: torch.Tensor, actions: torch.Tensor, rewards: torch.Tensor,
dones: torch.Tensor, next_q_target: torch.Tensor, gamma: float,
) -> torch.Tensor: ...
>>> q = torch.randn(2, 3, requires_grad=True)
>>> actions = torch.tensor([0, 2])
>>> rewards = torch.tensor([1.0, 0.0])
>>> dones = torch.tensor([0.0, 1.0])
>>> next_q = torch.randn(2, 3)
>>> q_learning_td_loss(q, actions, rewards, dones, next_q, gamma=0.99).shape
torch.Size([])
Step-by-Step Approach
- Select the online Q-value actually taken at each timestep: gather along the action dimension using
actions, going from(batch, num_actions)down to(batch,). - Wrap the target computation in
torch.no_grad()— the target must never receive gradient from this loss, since it represents a frozen network's output by construction. - Take the max over actions of the target network's next-state Q-values:
next_q_target.max(dim=1).values, giving the greedy bootstrap value per transition. - Mask the bootstrap term by
(1 - dones): a terminal transition has no valid next state, so its target must reduce to the reward alone. - Assemble the Bellman target:
rewards + gamma * (1 - dones) * max_next_q. - Compute the mean-squared-error between the selected online Q-value and the (gradient-free) target — this is the loss a training loop calls
.backward()on.
The two-network split only matters at one place in the code: the target computation must be wrapped so gradient never flows through it, while the online Q-value that's compared against it must stay fully attached to the graph — get that asymmetry backwards and the target network provides no stabilization at all.
Reference solution
import torch
import torch.nn.functional as F
def q_learning_td_loss(
q_values: torch.Tensor, actions: torch.Tensor, rewards: torch.Tensor,
dones: torch.Tensor, next_q_target: torch.Tensor, gamma: float,
) -> torch.Tensor:
# gather the online Q-value for the action actually taken: (batch, num_actions) -> (batch,)
selected_q = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# the target must never receive gradient -- it comes from a frozen
# network by construction, and even if next_q_target already has
# requires_grad=False, no_grad here documents that intent explicitly
with torch.no_grad():
max_next_q = next_q_target.max(dim=1).values # (batch,)
target = rewards + gamma * (1.0 - dones) * max_next_q
return F.mse_loss(selected_q, target)
Key Functions & Tricks
Tensor.gather(1, actions.unsqueeze(1)).squeeze(1)— pulls out each row's Q-value for the specific action taken, without a Python-level loop over the batchtorch.no_grad()— detaches the target computation from autograd, matching how a real frozen target network's forward pass would behaveTensor.max(dim=1).values— the greedy max over the target network's next-state Q-values, i.e. the standard (non-Double) DQN bootstrapF.mse_loss— the squared TD-error, averaged over the batch, matching DQN's original loss(1.0 - dones)— zeroes the bootstrap term exactly at terminal transitions, a one-line but easy-to-omit correctness detail
How to Recognize This Pattern
Recognize this pattern whenever a problem hands you Q-values from two separate sources — one still attached to a graph, one meant to be treated as fixed — and asks for a Bellman-style target: that's the target-network trick, regardless of whether it's phrased as DQN, DDQN, or a generic actor-critic critic update. A common variation is Double DQN, where the action that gets maximized comes from the online network's next-state Q-values, but the value of that action is looked up in the target network, which reduces the max operator's overestimation bias. The most common pitfall is forgetting the (1 - done) mask, which silently bootstraps past episode boundaries and biases the value function upward.