@d2l.add_to_class(d2l.Batch)
def td_target(self, bootstrap, gamma):
"""r_t + gamma (1 - terminated) V(s'), by a numpy bootstrap."""
return self.rew + gamma * (1 - self.term) * bootstrap(self.next_obs)Dive into Deep Learning · §15.1
Actor-critic and the credit-assignment dial
bootstrap the reward-to-go · one number, two learners · the \lambda dial and its telescoping identity · bias against variance, measured
The Monte Carlo weight waits for the episode to end. But \hat G_t = r_t + \gamma \hat G_{t+1}, and \hat V(s_{t+1}) is trained to predict exactly what \hat G_{t+1} samples. Substitute:
\delta_t = r_t + \gamma\, \hat V(s_{t+1}) - \hat V(s_t)
:eqref:eq_td_error’s scalar, with the max replaced by the policy’s own continuation.
A numpy array carries no gradient graph: the target is data by construction. No detach; the boundary is the detach.
If the critic were exact, \hat V = V^\pi:
E[\delta_t \mid s_t, a_t] = Q^\pi(s_t, a_t) - V^\pi(s_t).
One transition estimates what the Monte Carlo weight needed the whole remaining trajectory for.
The price: during training \hat V \ne V^\pi, so the update is biased. Variance traded for bias, the same bargain Q-learning struck, now on the policy side.
w \leftarrow w + \alpha_w\, \delta_t\, \nabla_w \hat V_w(s_t), \qquad \theta \leftarrow \theta + \alpha_\theta\, \delta_t\, \nabla_\theta \log \pi_\theta(a_t \mid s_t)
Batched episodes, one actor step per batch: a batched on-policy actor-critic, the single-environment teaching relative of A2C :cite:Mnih.Badia.Mirza.ea.2016. Against :numref:sec_deeprl’s loop, only the tail is replaced:
def train_ac(seed, ac, num_updates=num_updates):
"""The same loop with the sampled tail replaced by the bootstrap."""
rng, env = np.random.default_rng(seed), gym.make('CartPole-v1')
env.reset(seed=seed)
for _ in range(num_updates):
batch = d2l.rollout(env, ac.act, batch_episodes, rng)
for _ in range(critic_steps): # fresh target, one pass, repeat
fit_value(ac, batch.obs, batch.td_target(ac.value_np, gamma))
delta = batch.td_target(ac.value_np, gamma) - ac.value_np(batch.obs)
gnorm = policy_step_clip(ac, batch, d2l.normalize(delta))
yield float(batch.episode_returns().mean()), gnormCritic target td_target instead of G; critic_steps passes, first; weight = normalized \delta_t. Nothing else.
Early: actor-critic trails, the critic is still wrong. Late: both tie the 500 ceiling; only the bootstrapped side rests on it.
REINFORCE + baseline: longest stretch of perfect batches, per seed: [26, 13, 22]
actor-critic: longest stretch of perfect batches, per seed: [39, 22, 3]
A perfect batch = all eight episodes at 500. The long streaks live on the bootstrapped side only, a run-specific stability visualization of the mechanism: the Monte Carlo weight still carries every remaining coin flip, and a run at the ceiling is one noisy batch from a stumble.
REINFORCE + baseline: median pre-clip gradient norm 0.03, clip binds on 0% of updates
actor-critic: median pre-clip gradient norm 0.33, clip binds on 30% of updates
Both weights are normalized to unit variance, yet the actor-critic gradients are an order of magnitude larger: the baseline argument of :numref:sec_baselines read backwards. The state-dependent part of a weight cancels in expectation; the Monte Carlo weight spends much of its variance there, while \delta_t is almost all action-dependent signal. A better advantage estimate is a bigger step: :numref:sec_ppo.
Rerun the loop, measuring both candidate weights per batch:
Why survivable here: freshness keeps the critic’s training distribution matched to where its errors matter, reduced mismatch, not a guarantee (nonlinear TD can diverge even on-policy :cite:Tsitsiklis.VanRoy.1997). Our critic refreshes its target every pass, an aggressive fitted-TD loop; :numref:sec_dqn severs the freshness and reverses the choice: a frozen second copy, the target network.
\hat G^{(n)}_t = r_t + \cdots + \gamma^{n-1} r_{t+n-1} + \gamma^n \hat V(s_{t+n}), \qquad \hat A^{\textrm{GAE}}_t = (1-\lambda) \sum_{n \ge 1} \lambda^{n-1} \big(\hat G^{(n)}_t - \hat V(s_t)\big)
Telescoping identity :cite:Schulman.Moritz.Levine.ea.2016:
\hat A^{\textrm{GAE}}_t = \sum_{l \ge 0} (\gamma\lambda)^l\, \delta_{t+l}
Each depth telescopes into TD errors; swap the sums; the inner geometric sum is \lambda^l. TD(\lambda)’s eligibility traces ran this backward, per step; streaming settings still do :cite:Elsayed.Vasan.Mahmood.2024.
The reward-to-go scan of :numref:sec_baselines, run on TD errors: a new estimator is a new input to an old function.
lambda = 0 is the TD error; lambda = 1 is the Monte Carlo advantage
Both endpoints asserted, not assumed.
lambda = 0.0: best 20-update window, median 161.9, seeds [144. 145. 162. 302. 474.]
lambda = 0.5: best 20-update window, median 201.3, seeds [174. 183. 201. 312. 371.]
lambda = 0.9: best 20-update window, median 322.5, seeds [274. 319. 322. 488. 492.]
lambda = 0.95: best 20-update window, median 460.6, seeds [348. 372. 461. 486. 497.]
lambda = 1.0: best 20-update window, median 466.2, seeds [415. 456. 466. 469. 497.]
Five seeds per \lambda, a fifty-update sprint on small batches: the bottom of the dial trails decisively; from 0.9 up the arms arrive together. The sprint flatters the deep end (the critic starts ignorant); the stillness race went the other way.
lambda = 0.0: relative bias 1.34, relative variance 0.3, one-draw error 2.1
lambda = 0.5: relative bias 1.22, relative variance 0.4, one-draw error 1.9
lambda = 0.9: relative bias 0.57, relative variance 1.4, one-draw error 1.7
lambda = 0.95: relative bias 0.26, relative variance 2.4, one-draw error 2.5
lambda = 1.0: relative bias 0.00, relative variance 5.9, one-draw error 5.9
The shallow U: leaving \lambda = 1, variance collapses first and fastest; bias climbs more slowly. One-draw error is high at both pure ends, lowest strictly inside. Deployed PPO defaults commonly sit at \lambda \approx 0.9 to 0.97 (:numref:sec_ppo).
sec_deeprl is behind ussec_ppo, GAE by default), then sever freshness and pay (:numref:sec_dqn)