@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: [6, 7, 4]
actor-critic: longest stretch of perfect batches, per seed: [36, 26, 11]
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.04, clip binds on 0% of updates
actor-critic: median pre-clip gradient norm 0.40, clip binds on 35% 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 222.5, seeds [138. 198. 223. 283. 328.]
lambda = 0.5: best 20-update window, median 203.3, seeds [175. 179. 203. 218. 321.]
lambda = 0.9: best 20-update window, median 419.5, seeds [387. 400. 420. 454. 486.]
lambda = 0.95: best 20-update window, median 406.9, seeds [327. 367. 407. 444. 482.]
lambda = 1.0: best 20-update window, median 449.7, seeds [293. 385. 450. 464. 479.]
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.05, relative variance 0.1, one-draw error 1.2
lambda = 0.5: relative bias 1.06, relative variance 0.1, one-draw error 1.2
lambda = 0.9: relative bias 0.95, relative variance 0.2, one-draw error 1.1
lambda = 0.95: relative bias 0.83, relative variance 0.3, one-draw error 0.9
lambda = 1.0: relative bias 0.00, relative variance 1.6, one-draw error 1.6
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)