Deep Q-Networks

Dive into Deep Learning · §15.4

Deep Q-Networks
the triad, with a live counterexample · replay and the frozen copy · one boolean, two worlds · the max leans high, measured and repaired

Why This One Breaks

The policy family survived the network swap; Q-learning’s target contains the function being trained:

y = r + \gamma\, \big(1 - \mathbf{1}(s' \textrm{ terminal})\big) \max_{a'} Q_w(s', a')

  • the tabular trust argument (stochastic approximation of a contraction) dies under projection onto a function class :cite:Tsitsiklis.VanRoy.1997
  • the data arrive as a stream of near-duplicates, and one update now moves every state
  • the critic of :numref:sec_actorcritic survived its moving target because fresh data audited it; replay spends exactly that protection

The Deadly Triad

Function approximation + bootstrapping + off-policy data :cite:Sutton.Barto.2018,vanHasselt.Doron.Strub.ea.2018. Every region is an algorithm already taught:

Baird’s Counterexample, Live

Seven states, every true value 0, and w = 0 can say so. Expected updates, no noise, uniform (off-policy) weighting:

sup norm of w after 0, 500, 1000 sweeps: 10, 77, 335
the value the weights claim for state 7: 677, true value 0

Exponential divergence, and no learning rate fixes it: a property of the composed operator :cite:Baird.1995, not of sampling.

The Two Repairs

Replay, and the Off-Policy Licence

class ReplayBuffer:
    """A ring of transitions in preallocated numpy; sample() returns a Batch."""
    def __init__(self, capacity, obs_dim):
        self.obs = np.zeros((capacity, obs_dim), np.float32)
        self.act = np.zeros(capacity, np.int64)
        self.rew = np.zeros(capacity, np.float32)
        self.next_obs = np.zeros((capacity, obs_dim), np.float32)
        self.term = np.zeros(capacity, np.float32)
        self.capacity, self.size, self.ptr = capacity, 0, 0

    def add(self, obs, act, rew, next_obs, term):
        i = self.ptr
        self.obs[i], self.act[i], self.rew[i] = obs, act, rew
        self.next_obs[i], self.term[i] = next_obs, term
        self.ptr, self.size = (i + 1) % self.capacity, min(self.size + 1,
                                                           self.capacity)

    def __len__(self):
        return self.size

    def sample(self, batch_size, rng):
        i = rng.integers(self.size, size=batch_size)
        return d2l.Batch(self.obs[i], self.act[i], self.rew[i],
                         self.next_obs[i], self.term[i],
                         np.array([batch_size]))
  • the target never mentions who collected the transition: any policy’s data is valid (:numref:sec_qlearning)
  • the sampled Batch has no episode structure left: reward_to_go and gae die, td_target survives; one-step bootstrapping is the estimator built for the scramble
  • capacity 200k against a 50k budget: nothing evicted here

The Target Network

y = r + \gamma\, \big(1 - \mathbf{1}(s' \textrm{ terminal})\big) \max_{a'} Q_{w^-}(s', a'), \qquad w^- \leftarrow w \textrm{ every } C \textrm{ steps}

Between syncs the regression surface stands still.

:numref:sec_actorcritic’s critic refreshed its bootstrap every pass, because fresh data audited it. Replay removes the audit, so DQN reverses the choice: stability bought with staleness. The use_target=False arm syncs every step, the naive recipe in all but name.

The Training Loop

def train_dqn(seed, qnet, use_target=True, step=None):
    """DQN on CartPole; yields (env step, episode return, max_a Q(s0, a)).
    The jitted step's module traversal is cached once per run."""
    step = q_step if step is None else step
    rng, env = np.random.default_rng(seed), gym.make('CartPole-v1')
    target = nnx.clone(qnet)
    opt = nnx.Optimizer(qnet, optax.chain(
        optax.clip_by_global_norm(grad_clip), optax.adam(lr)), wrt=nnx.Param)
    step_fn = nnx.cached_partial(step, qnet, target, opt)
    buffer, s0 = ReplayBuffer(buffer_size, 4), np.zeros(4, np.float32)
    obs, ep_return = env.reset(seed=seed)[0], 0.0
    sync = sync_every if use_target else 1
    for t in range(1, num_env_steps + 1):
        a = d2l.epsilon_greedy(q_values(qnet, obs), epsilon(t), rng)
        next_obs, rew, terminated, truncated, _ = env.step(a)
        buffer.add(obs, a, rew, next_obs, float(terminated))
        obs, ep_return = next_obs, ep_return + rew
        if terminated or truncated:
            yield t, ep_return, q_values(qnet, s0).max()
            obs, ep_return = env.reset()[0], 0.0
        if len(buffer) >= warmup and t % train_freq == 0:
            b = buffer.sample(batch_size, rng)
            step_fn(jnp.asarray(b.obs), jnp.asarray(b.act),
                    jnp.asarray(b.rew), jnp.asarray(b.next_obs),
                    jnp.asarray(b.term))
        if t % sync == 0:
            nnx.update(target, nnx.state(qnet, nnx.Param))

Budgeted in environment steps (50k, one gradient step per two); epsilon_greedy and linear_schedule reused from :numref:sec_qlearning; the buffer stores terminated, never truncated.

One Boolean, Two Worlds

With the copy: every seed climbs into the hundreds, the strongest stretches near the ceiling, nothing settles. Without: collapse to a pole that falls immediately, values past 10^8, every seed, both tabs. The greedy policy collects data that confirms its own collapse.

The Wrong Statistics

               DQN: best 20-episode window per seed [256. 104. 233.]
                    final window [163.  98. 233.] (spread 136)
                    fifty episodes earlier [162.  94. 157.]
 no target network: best 20-episode window per seed [27. 25. 26.]
                    final window [ 9. 10. 10.] (spread 0)
                    fifty episodes earlier [10. 10. 10.]

The final window measures where the climb-and-fall cycle happened to be when we stopped: well over a hundred points of spread across seeds, and fifty episodes earlier it read differently. The best window is optimistic selection after the fact. Both describe the curve; the report is the fixed-budget greedy evaluation.

Converging Values, Churning Policy

continuing-task ceiling: 100; the no-target arm ends at 2e+08, 2e+08, 2e+08

The trace converges to roughly the right number; nothing diverges (one probe state: a sentinel, not a certificate). What churns is the greedy policy read off those values: greedy evaluations span 90 to 500 across tab-seeds, some tying the untaxed ceiling, some caught mid-stumble, on value changes too small to see. The update bootstraps through the time limit, so the objective it defines is the continuing one: no policy is worth more than 1/(1-\gamma) = 100 from the start. A seed settling above the line claims what cannot be earned, and across tabs and seeds, some do.

The Max Leans High

single estimator, E[max of the estimates]: 1.031
select with one, evaluate with the other: -0.006

One unit of bias from noise and a \max :cite:Thrun.Schwartz.1993; the double estimator removes it :cite:vanHasselt.2010. The hard \max is the \beta \to 0 corner of :numref:sec_regularized’s soft backup.

Double DQN in Three Lines

Select with the online network, evaluate with the frozen one :cite:Hasselt.Guez.Silver.2016:

@nnx.jit
def q_step_double(qnet, target, opt, obs, act, rew, next_obs, term):
    """q_step with selection split from evaluation, eq_double_dqn."""
    sel = qnet(next_obs).argmax(-1)
    fit_q(qnet, opt, obs, act, rew + gamma * (1 - term) * jnp.take_along_axis(
        target(next_obs), sel[:, None], -1).squeeze(-1))

       DQN: final value estimate at s0, per seed: [100.3 157.   94.4]
Double DQN: final value estimate at s0, per seed: [ 97.1 109.2  92.7]

Most tab-seeds end lower, typically by a few points: two actions is where the \max has least room to flatter itself, which is panel (b)’s prediction. At Atari’s eighteen actions the same three lines change scores decisively.

What Survived

  • n-step targets: free, :numref:sec_actorcritic built the dial
  • prioritized replay :cite:Schaul.Quan.Antonoglou.ea.2016, dueling :cite:Wang.Schaul.Hessel.ea.2016, distributional heads :cite:Bellemare.Dabney.Munos.2017; Rainbow’s measured ablation :cite:Hessel.Modayil.vanHasselt.ea.2018
  • PQN :cite:Gallici.Fellows.Ellis.ea.2025: LayerNorm + parallel environments, no buffer, no target network. The two fixes were one solution to a stability problem, not commandments.
  • today: PPO or a modern value agent; DQN is the laboratory where the failure modes are clearest. Next: the license at its limit, :numref:sec_offline.