zero2robot · Phase 2 · Reinforcementch2.2-sac · sac.py

Chapter 2.2

SAC and the Off-Policy Bargain

By the end you can

  1. Implement SAC from scratch in one file — squashed-Gaussian policy (reparam + tanh log-prob correction), twin Q critics + target networks, auto-tuned entropy temperature, soft target updates
  2. Explain the off-policy bargain — why a replay buffer reuses each transition many times, and the instability (Q divergence) that twin-Q + target nets + entropy tame
  3. Measure, don't assert, that off-policy SAC is more SAMPLE-EFFICIENT than on-policy PPO on the dense-reward pusher-reach (env-steps-to-solve, head-to-head)
  4. State WHEN off-policy wins (dense reward + cheap env + heavy sample reuse) and when it does not

See it work

live · P2

The SAC arm reaches

The trained policy (deterministic eval — tanh of the mean, no sampling) on a held-out target it never trained on. Nobody wrote the gains; SAC found them by reaching, storing every reach, and replaying the buffer.

target
SAC · held-out seed 500002reached target ✓

replay buffer — every transition stored, reused across many gradient steps (the off-policy bargain)

twin-Q + target nets — the min of two critics, read off slow copies, kills the divergence replay invites

auto-tuned entropy — a temperature that pays the policy to keep exploring, not collapse

recorded rollout · poster reads with JS off

The bargain — 11.1× fewer environment steps

Eval distance to target vs environment steps, same task, on one shared axis. Off-policy SAC reuses its replay buffer; on-policy PPO discards each rollout. Distance is exactly the signal env-steps-to-solve is defined on.

0.000.050.100.150.20random ≈ 0.176 msolved < 0.05 mSAC solves · 18k050k100k150k200kenvironment steps →dist (m)PPO 0.13 mSAC 0.04 m
SAC off-policy · replay reusePPO on-policy · discards rollouts

Nothing is free

The bargain is paid for. SAC takes one gradient step per environment step — far more compute per step than PPO — and off-policy learning only stays stable because of the three braces above (twin-Q + target networks, a reparameterized squashed-Gaussianpolicy, and an auto-tuned entropy temperature).

Honest caveats: the on-policy reference is untuned (a tuned PPO with reward normalization would do better — the point is off-policy needs far less tuning to be sample-efficient here); and the win is this regime — dense reward, cheap env, heavy replay reuse — not a claim that off-policy always wins. On a sparse-reward or expensive-rollout task the ledger reads differently. Real curves + rollout from sac.py (seed 0, cpu); poster reads with JS off.

SAC pusher-reach — a trained policy reaches a draggable target, liveA two-link arm driven by a trained SAC policy homes its fingertip onto a green target. Live, you can drag the target anywhere in the arm's reach and watch it re-reach; the dashed ring is the 2 cm success tolerance.drag me →
drag the green target (or arrow-keys when focused) · poster reads with JS off
Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

What PPO threw away

In chapter 2.1, PPO learned by acting — and then it deleted the evidence. Look again at the loop: it collects a rollout, takes ten passes of minibatch SGD on exactly that rollout, and throws every transition away. It has to. On-policy means the update is only valid for data drawn from the current policy; the moment the weights move, yesterday's rollout is off-distribution and the ratio that keeps PPO honest stops meaning anything. PPO buys its famous stability by being wasteful — each environment step is used once and discarded.

That waste has a name when it starts to hurt: sample efficiency. On a real robot, where every environment step is a second of wall-clock and a little wear on a motor, using each interaction exactly once is a luxury you cannot afford. So here is the question this chapter answers by measuring, not asserting: can we reuse experience — learn from transitions the current policy would never have chosen — without the whole thing diverging?

The bargain

Soft Actor-Critic takes the opposite deal. Keep everything. Every transition the policy ever generated goes into a fixed-size replay buffer, and each gradient step samples a fresh batch from the entire history — data from a hundred different past policies, replayed dozens of times over. That is off-policy learning, and it is where the sample efficiency comes from.

Nothing is free. Off-policy learning is unstable in exactly the way PPO's trust region was built to prevent. You are training a Q-function to predict returns and bootstrapping it from its own estimates on stale data; small overestimates compound into a feedback loop that blows the value function to infinity. SAC pays for the bargain with three braces, and you build all three from scratch:

  1. Twin Q critics with target networks (clipped double-Q). Two independent Q-functions; always bootstrap from the minimum of the pair, read off slow-moving copies — the target networks. The min kills systematic overestimation; the slow targets stop the value function from chasing itself.
  2. A squashed-Gaussian policy, trained by the reparameterization trick, so the actor can follow the Q-gradient straight through its own sampled actions.
  3. The maximum-entropy objective with an auto-tuned temperature alpha, which pays the policy to stay uncertain — to keep exploring — instead of collapsing onto whatever the current, still-imperfect Q-function happens to like best.

The task: dense reward is what off-policy exploits

Cartpole gave PPO a +1-per-step alive bonus — a signal with no gradient telling you which way is better, which is why an on-policy method that reasons over whole trajectories suited it. Pusher-reach (common/envs/pusher_reach) is the opposite, and deliberately so: a planar two-link arm, a torque on each joint, and a reward of -distance from the fingertip to a seeded-random target, every step. That dense per-step signal is exactly what an off-policy learner can bootstrap on from a buffer full of old transitions. The pairing is the point — SAC's machinery earns its keep precisely when the reward is dense.

The baselines fix the scale. A random policy leaves the fingertip about 0.176 m from the target; the scripted IK reacher in the env gets it to ~0.0001 m. SAC has to climb from the first toward the second by acting, storing, and replaying.

The shape of the file

sac.py is one loop wrapped around the three braces. It reuses common/: the pusher-reach env, the seeding helper, the device banner.

The squashed-Gaussian actor — a state-dependent mean and log-std, a reparameterized sample, and the tanh log-prob correction that most from-scratch implementations get subtly wrong:

sac.py#modelsha256:8f475fa1b0…
def layer_init(layer: nn.Linear) -> nn.Linear:    """Plain fan-in init; SAC is far less init-sensitive than PPO (no on-policy    trust region to protect), so we skip the orthogonal-gain ceremony."""    return layer  LOG_STD_MIN, LOG_STD_MAX = -5.0, 2.0  class Actor(nn.Module):    """Squashed-Gaussian policy. The net outputs a STATE-DEPENDENT mean and    log-std (unlike ch2.1's PPO, where log-std was a bare parameter); we sample a    Gaussian, then squash through tanh so actions land in [-1, 1]. The squash    needs a log-prob CORRECTION — tanh compresses probability mass, and the    change-of-variables term log(1 - tanh(x)^2) accounts for it. Omit it and the    entropy term is wrong and SAC's exploration falls apart."""     def __init__(self, obs_dim: int, act_dim: int, hidden_dim: int):        super().__init__()        self.trunk = nn.Sequential(            layer_init(nn.Linear(obs_dim, hidden_dim)), nn.ReLU(),            layer_init(nn.Linear(hidden_dim, hidden_dim)), nn.ReLU(),        )        self.mean = layer_init(nn.Linear(hidden_dim, act_dim))        self.log_std = layer_init(nn.Linear(hidden_dim, act_dim))     def forward(self, obs: torch.Tensor):        h = self.trunk(obs)        log_std = torch.clamp(self.log_std(h), LOG_STD_MIN, LOG_STD_MAX)        return self.mean(h), log_std     def sample(self, obs: torch.Tensor):        """Returns (action, log_prob, deterministic_action). `rsample` is the        REPARAMETERIZATION trick: it keeps the sample differentiable w.r.t. the        net so the actor loss can backprop through the sampled action."""        mean, log_std = self(obs)        normal = torch.distributions.Normal(mean, log_std.exp())        x = normal.rsample()        action = torch.tanh(x)        # tanh change-of-variables correction, summed over action dims        log_prob = normal.log_prob(x) - torch.log(1.0 - action.pow(2) + 1e-6)        log_prob = log_prob.sum(1, keepdim=True)        return action, log_prob, torch.tanh(mean)  class Critic(nn.Module):    """A single Q(obs, action) network. We build TWO of these (twin critics) and    always bootstrap from the MIN of the pair — clipped double-Q, the brace that    stops the Q-function from chasing its own overestimates into divergence."""     def __init__(self, obs_dim: int, act_dim: int, hidden_dim: int):        super().__init__()        self.net = nn.Sequential(            layer_init(nn.Linear(obs_dim + act_dim, hidden_dim)), nn.ReLU(),            layer_init(nn.Linear(hidden_dim, hidden_dim)), nn.ReLU(),            layer_init(nn.Linear(hidden_dim, 1)),        )     def forward(self, obs: torch.Tensor, action: torch.Tensor) -> torch.Tensor:        return self.net(torch.cat([obs, action], dim=1))  obs_dim, act_dim = PusherReachEnv.OBS_DIM, PusherReachEnv.ACT_DIMactor = Actor(obs_dim, act_dim, args.hidden_dim).to(device)q1 = Critic(obs_dim, act_dim, args.hidden_dim).to(device)q2 = Critic(obs_dim, act_dim, args.hidden_dim).to(device)# Target critics: slow-moving copies the bootstrap targets are read from. They# are what --break removes. deepcopy + requires_grad_(False): never trained by# gradient descent, only nudged by the soft update (region: update).q1_targ = copy.deepcopy(q1).requires_grad_(False)q2_targ = copy.deepcopy(q2).requires_grad_(False)actor_opt = torch.optim.Adam(actor.parameters(), lr=args.lr)critic_opt = torch.optim.Adam(list(q1.parameters()) + list(q2.parameters()), lr=args.lr)# alpha (entropy temperature). Optimize log_alpha for a positivity-free# parameterization; alpha = exp(log_alpha). Fixed at args.alpha under --no-autotune.log_alpha = torch.tensor(float(np.log(args.alpha)), dtype=torch.float32, device=device, requires_grad=args.autotune)alpha_opt = torch.optim.Adam([log_alpha], lr=args.lr) if args.autotune else None

That correction inside sample is the one line worth slowing down for. We draw a Gaussian and squash it through tanh so actions land in [-1, 1], but tanh compresses probability mass near the edges — a change of variables the log-prob has to account for, or the entropy term SAC optimizes is simply wrong. The log(1 - tanh(x)^2) subtraction is that accounting. Omit it and the temperature tunes against a fiction; exploration quietly falls apart.

The replay buffer is the bargain, so it stays in plain sight — a circular NumPy buffer, no framework. Note what it stores: terminated, not done. A time-limit truncation must still bootstrap from the real next state (the ch2.1 lesson, carried over unchanged); only a true terminal masks the future value. Pusher-reach never terminates early, so terminated is always zero and every target bootstraps:

sac.py#replaysha256:d093649c07…
class ReplayBuffer:    """A fixed-size circular buffer of transitions — the off-policy bargain in    one data structure. Every (obs, action, reward, next_obs, terminated) tuple    the policy generates is stored and REUSED across many gradient steps, exactly    what on-policy PPO throws away. We store `terminated` (NOT `done`): a    time-limit truncation must still bootstrap from next_obs, so only a TRUE    terminal masks the future value. Pusher-reach never terminates early    (terminate_on_success=False), so terminated is always 0 and every target    bootstraps — the ch2.1 truncation lesson, carried over unchanged."""     def __init__(self, capacity: int, obs_dim: int, act_dim: int):        self.capacity = capacity        self.obs = np.zeros((capacity, obs_dim), dtype=np.float32)        self.actions = np.zeros((capacity, act_dim), dtype=np.float32)        self.rewards = np.zeros((capacity, 1), dtype=np.float32)        self.next_obs = np.zeros((capacity, obs_dim), dtype=np.float32)        self.terminated = np.zeros((capacity, 1), dtype=np.float32)        self.ptr, self.size = 0, 0     def add(self, obs, action, reward, next_obs, terminated):        i = self.ptr        self.obs[i], self.actions[i], self.rewards[i, 0] = obs, action, reward        self.next_obs[i], self.terminated[i, 0] = next_obs, float(terminated)        self.ptr = (self.ptr + 1) % self.capacity        self.size = min(self.size + 1, self.capacity)     def sample(self, batch_size: int):        # torch.randint draws from the seeded global torch RNG -> reproducible        idx = torch.randint(0, self.size, (batch_size,)).numpy()        t = lambda a: torch.as_tensor(a[idx], device=device)  # noqa: E731        return t(self.obs), t(self.actions), t(self.rewards), t(self.next_obs), t(self.terminated)  buffer = ReplayBuffer(args.buffer_size, obs_dim, act_dim)

The whole algorithm is one update function — critic step, actor step, temperature step, then the soft target nudge:

The critic regresses both Q's toward the entropy-augmented Bellman target: the clipped double-Q of the next action, minus the entropy bonus, bootstrapped one step — and here it is in code:

y=r+γ(1d)(mini=1,2Qθˉi(s,a)    αlogπϕ(as)),aπϕ(s)y = r + \gamma\,(1 - d)\Big(\min_{i=1,2} Q_{\bar{\theta}_i}(s', a') \;-\; \alpha \log \pi_\phi(a' \mid s')\Big), \qquad a' \sim \pi_\phi(\,\cdot \mid s')
sac.py#updatesha256:7d8427ca00…
def sac_update() -> dict:    """One SAC gradient step on a replay batch: critic update, then actor +    temperature, then the soft target nudge. This is the whole algorithm."""    obs, action, reward, next_obs, terminated = buffer.sample(args.batch_size)    alpha = log_alpha.exp().detach()     # --- critic update: regress both Q's toward the entropy-augmented target ---    with torch.no_grad():        next_action, next_logp, _ = actor.sample(next_obs)        # Break It: bootstrapping off the ONLINE critics removes the slow target        # and the Q-estimate chases itself -> divergence (the whole point of targets).        tq1 = (q1 if args.break_bug else q1_targ)(next_obs, next_action)        tq2 = (q2 if args.break_bug else q2_targ)(next_obs, next_action)        # clipped double-Q: the MIN of the pair, minus the entropy bonus (soft value)        min_next_q = torch.min(tq1, tq2) - alpha * next_logp        target_q = reward + args.gamma * (1.0 - terminated) * min_next_q    q1_loss = F.mse_loss(q1(obs, action), target_q)    q2_loss = F.mse_loss(q2(obs, action), target_q)    critic_loss = q1_loss + q2_loss    critic_opt.zero_grad()    critic_loss.backward()    critic_opt.step()     # --- actor update: maximize (min-Q - alpha*logprob) via the reparam sample ---    new_action, logp, _ = actor.sample(obs)    min_q = torch.min(q1(obs, new_action), q2(obs, new_action))    actor_loss = (alpha * logp - min_q).mean()  # ascend min_q, keep entropy high    actor_opt.zero_grad()    actor_loss.backward()    actor_opt.step()     # --- temperature update: push alpha until entropy sits at target_entropy ---    if args.autotune:        alpha_loss = -(log_alpha.exp() * (logp.detach() + target_entropy)).mean()        alpha_opt.zero_grad()        alpha_loss.backward()        alpha_opt.step()     # --- soft target update: Polyak-average the targets a hair toward online ---    if not args.break_bug:        with torch.no_grad():            for online, targ in ((q1, q1_targ), (q2, q2_targ)):                for p, tp in zip(online.parameters(), targ.parameters()):                    tp.mul_(1.0 - args.tau).add_(args.tau * p)    return {"q_loss": critic_loss.item() / 2.0, "actor_loss": actor_loss.item(),            "alpha": float(log_alpha.exp().item()), "q_value": min_q.mean().item()}

The training loop: act (random during warmup, then the policy), store the transition, and once the buffer has warmed up, take exactly one gradient step per environment step. Because there is a single env, global_step is the environment-step count — so the eval curve reads directly as return-versus-env-steps, the sample-efficiency signal the whole chapter turns on:

sac.py#trainsha256:eb3266c578…
# The loop: act (random during warmup, then policy), store the transition, and# once the buffer has enough, take one gradient step PER env step. `curve` records# (env_step, mean_return, mean_dist) at each eval — that IS the return-vs-env-steps# sample-efficiency curve the chapter measures SAC against on-policy PPO.next_obs = env_reset()ep_return, recent_returns = 0.0, []curve, steps_to_solve = [], Nonestats = {"q_loss": float("nan"), "actor_loss": float("nan"), "alpha": args.alpha, "q_value": float("nan")}for global_step in range(1, args.total_steps + 1):    if global_step <= args.learning_starts:  # warmup: uniform random actions fill the buffer        action = np.random.uniform(-1.0, 1.0, size=act_dim).astype(np.float32)    else:        with torch.no_grad():            action, _, _ = actor.sample(torch.as_tensor(next_obs, dtype=torch.float32, device=device).unsqueeze(0))        action = action[0].cpu().numpy()     obs_after, reward, done, info = env.step(action)    # store `terminated` (not `done`): a truncation must still bootstrap (see replay)    buffer.add(next_obs, action, reward, obs_after, info["terminated"])    next_obs = obs_after    ep_return += reward    if done:        recent_returns.append(ep_return)        ep_return, next_obs = 0.0, env_reset()  # autoreset     if global_step > args.learning_starts:  # one gradient step per env step        stats = sac_update()     if global_step % args.eval_interval == 0 or global_step == args.total_steps:        eval_return, eval_dist, eval_success = evaluate(args.eval_episodes)        curve.append((global_step, round(eval_return, 3), round(eval_dist, 5)))        if steps_to_solve is None and eval_dist < SOLVE_DIST:            steps_to_solve = global_step  # first env step the policy holds the target        mean_train = float(np.mean(recent_returns[-20:])) if recent_returns else float("nan")        if args.rerun:            rr.set_time("global_step", sequence=global_step)            rr.log("charts/eval_return", rr.Scalars([eval_return]))            rr.log("charts/eval_dist", rr.Scalars([eval_dist]))            rr.log("charts/eval_success", rr.Scalars([eval_success]))            rr.log("charts/train_return", rr.Scalars([mean_train]))            rr.log("replay/size", rr.Scalars([float(buffer.size)]))            for name, value in stats.items():                rr.log(f"losses/{name}", rr.Scalars([value]))        print(f"step {global_step:6d}/{args.total_steps}  eval_return {eval_return:8.1f}  "              f"eval_dist {eval_dist:.4f}m  success {eval_success:.2f}  "              f"alpha {stats['alpha']:.3f}  q_loss {stats['q_loss']:.3f}")

Run it

python curriculum/phase2_reinforcement/ch2.2_sac/sac.py --seed 0 --device cpu
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~3.08 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~5.08 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

On a CPU laptop the default 30k-step config takes about three minutes, and the held-out eval distance trends toward the scripted baseline — in fits and starts, as RL does — while the success rate works its way up:

step   4000/30000  eval_dist 0.1483m  success 0.00
step  12000/30000  eval_dist 0.0626m  success 0.10
step  18000/30000  eval_dist 0.0358m  success 0.30
step  24000/30000  eval_dist 0.0475m  success 0.10
step  30000/30000  eval_dist 0.0434m  success 0.40
eval: mean final dist 0.0434m  success 0.40  return -5.8
      (random ~0.176m, scripted 0.0001m, solve<0.05m)
sample efficiency: solved (eval_dist<0.05m) at 18000 env steps

Nobody wrote the arm's controller gains this time; SAC found them by reaching, storing every reach, and replaying the buffer dry. This is one seed, and RL is noisy — the eval distance wobbles from step to step (watch it dip to 0.036 at 18k, drift back to 0.048 at 24k) because a held-out eval over ten episodes is a small sample and the policy is still moving. The exercises read the signal across seeds for exactly this reason.

The bargain, measured against PPO

The headline claim — off-policy beats on-policy here — is measured head-to-head, not asserted. compare_ppo_sac.py runs SAC and a compact on-policy PPO reference (the ch2.1 family, retargeted to pusher-reach) on the same env and counts the environment steps each needs to drive the eval distance below the 0.05 m solve bar:

=== sample efficiency: env steps to eval mean final dist < 0.05 m (seed 0) ===
  SAC (off-policy):  18,000 env steps       [budget 30,000]
  PPO (on-policy):   NOT solved in budget   [budget 200,000]
  -> SAC solved; PPO did not reach the bar within 200,000 steps.

The on-policy reference is not broken — it learns, dragging the fingertip from the random baseline (~0.176 m) down to a plateau around 0.13 m. It just plateaus there, still short of the bar SAC cleared at 18k, after spending its entire 200k-step budget — an order of magnitude more environment interaction. That is the off-policy bargain paid off: replaying each transition dozens of times converts the same dense signal into far more learning per environment step.

Two honesties keep that claim from becoming a slogan. First, the PPO reference is untuned. A PPO with reward normalization, an entropy bonus, and a tuned learning rate would close some of this gap — the point is not that PPO cannot solve pusher-reach, it is that off-policy needs far less of that tuning to be sample-efficient here. Second, the win is the sample-efficiency gap in environment steps, not a claim that off-policy always wins. It wins in this regime — dense reward, cheap env, heavy sample reuse. Change the regime and the ledger changes with it, which is the next section.

Why the comparison lives in its own file and not inside sac.py: a faithful second RL algorithm does not fit under the 450-line cap alongside SAC. The teaching artifact stays SAC; the measurement rig is companion tooling, and it imports no RL framework — plain torch and numpy, the ch2.1 PPO inlined.

When off-policy does NOT win

The lesson is when, not always. Off-policy replay wins here because three things line up: the reward is dense (every stored transition carries a usable gradient), the env is cheap (so PPO's extra environment steps are the cost that dominates, and SAC's per-step gradient work stays affordable), and the task tolerates heavy reuse (old transitions stay informative). Flip any one of them — a sparse reward where most stored transitions say nothing, or an expensive rollout where SAC's many gradient steps come to dominate the wall-clock — and the bargain gets worse. The replay-size exercise lets you feel one edge of this directly.

Break it (optional, not graded)

--break bootstraps the Q-target off the online critics instead of the target networks. Run it and the failure is not subtle: the Q-loss blows up by five or six orders of magnitude (a healthy run settles around 0.003; this one posts q_loss in the tens of thousands), the eval distance never leaves the random baseline, and the auto-tuned alpha runs away chasing an entropy target the diverging critics have made meaningless. That is the exact feedback loop the slow target network exists to break. This is a teaching toggle, not a graded bug-hunt: per the RL doctrine, a single-run bug can heal across seeds, so the graded exercises are multi-seed instead.

Exercises

Two, in exercises/, both predict-then-run and both graded on a seed-robust signal rather than one lucky run. The first has you predict whether SAC's reach survives across seeds 0–2 before you train them; the second is a hyperparameter investigation — starve the replay buffer to 1/20th its size and predict what happens to the bargain, then read the effect against the default across the same three seeds.

Read the real thing

The single-file SAC you just built mirrors CleanRL's sac_continuous_action.py, pinned at v1.0.0 (commit c37a3ec). It is the same algorithm at the same altitude — one script, roughly 310 lines, much of it argparse and TensorBoard — so reading it next is a graduation, not a leap. Three points, ours set beside theirs.

The squashed-Gaussian actor. Ours is the Actor in the model region: a state-dependent mean and log-std, an rsample reparameterized draw, and the log(1 - tanh(x)^2) correction on one line. CleanRL's is Actor.get_action (sac_continuous_action.py:135–147 @ c37a3ec) — the same three moves: rsample at line 139, the tanh squash at 140, and the log-prob correction at line 144. Two differences worth seeing. Its forward bounds the log-std by squashing through tanh into [-5, 2] (lines 130–131) where we hard-clamp; and it carries action_scale/action_bias buffers (lines 117–123) to remap the [-1, 1] tanh output onto arbitrary action bounds. Pusher-reach's torques already live in [-1, 1], so we cut the rescale — one fewer thing between you and the correction.

The twin critics and the bargain. Ours are two Critics and their deepcopy targets in model, bootstrapped from the min in update. CleanRL's SoftQNetwork (lines 91–103) is instantiated four-up — qf1, qf2, qf1_target, qf2_target — at lines 186–191, and the same clipped-double-Q target is assembled at lines 248–253. Its replay, though, is not hand-rolled: it imports stable_baselines3's ReplayBuffer (lines 205–211) with handle_timeout_termination=True (line 210) — the library doing exactly the terminated-not-done bookkeeping our replay region spells out by hand.

What it adds, and why. Three things we stripped. Vectorized envs — gym.vector.SyncVectorEnv (line 180) behind a make_env thunk (lines 75–87) — so global_step is no longer one env step; we kept the single env so the sample-efficiency curve reads straight off the loop. Delayed, TD3-style updates: the actor and target networks fire every policy_frequency steps (line 265), not every step like ours. And split learning rates, q-lr 1e-3 against policy-lr 3e-4 (lines 56, 58), where we share one. None of these changes the bargain — they tune it. The off-policy machinery is identical; production just adds the knobs and the plumbing this chapter held back to keep the algorithm in view.

Read next: open the file at get_action (lines 135–147) — the part you already own — then follow global_step down from line 216; CleanRL's write-up walks the full derivation at docs.cleanrl.dev/rl-algorithms/sac.

What's next

You now have two RL policies in hand and a working sense of the on-policy / off-policy trade — PPO's stable-but-hungry rollouts against SAC's sample-efficient replay. Chapter 2.3 attacks the other axis entirely: instead of squeezing more learning out of each environment step, it runs thousands of environments at once on the GPU with MJX, and the wall-clock cliff that opens up is its own lesson. And the actor you just trained does not retire — sac.py saved it to outputs/ch2.2-sac/sac_actor.pt, and chapter 2.6 drags it out of pusher-reach's perfect world to see how a policy trained on clean observations holds up when the sensors start to lie. The replay-and-entropy substrate you built here also returns in Phase 4, where HIL-SERL builds human-in-the-loop corrections on top of exactly this off-policy machinery.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — multi-seed predict-then-run, ch2.2.

    Objective tested: the chapter's core claim — that SAC's replay + entropy + twin-Q machinery actually LEARNS to reach on the dense-reward pusher-reach env — AND the RL-doctrine lesson underneath it (ch2.1 spike, H2): a single training run is NOISE. You predict a signal that must hold ACROSS seeds, then you run several seeds and read whether the strong signal (the fingertip closing on the target) survives seed-to-seed variance.

    THE QUESTION. The random baseline leaves the fingertip ~0.176 m from the target; the scripted IK reacher gets ~0.0001 m. At a reduced training budget (total_steps below the chapter default), does SAC drive the held-out eval mean final distance clearly below the random baseline on EVERY one of seeds 0, 1, 2?

    PREDICT before you run: (a) yes, all three seeds land well under the random baseline; (b) it learns on average but at least one seed stalls near random; (c) the reduced budget is too short and none clearly beat random. Write your choice and one sentence of why in PREDICTION.

    Then run this file. It trains SAC on seeds 0, 1, 2 and prints each seed's eval mean final distance. Notice these are DETERMINISTIC per seed (the whole pipeline is seeded) — the variance you read is ACROSS seeds, which is exactly why RL is graded on a multi-seed signal, not one run.

    Estimated learner time: 20 minutes (mostly waiting on three short SAC runs).

    Run it locally:

    pytest curriculum/phase2_reinforcement/ch2.2_sac/exercises/suggested/checks.py -k ex1
  2. hyperparameter-investigation

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — hyperparameter investigation, ch2.2.

    Objective tested: the mechanism BEHIND the off-policy bargain. SAC is sample-efficient because a big replay buffer lets it REUSE each transition across many gradient steps. So what happens to the bargain when you starve the buffer? This is a hyperparameter investigation, not a single bug-hunt (ch2.1 spike, H1): you form a directional hypothesis and read it against a seed-robust signal.

    THE KNOB. --buffer_size (default 100000) is the replay capacity. Shrink it hard (here: 2000, ~20 episodes) and the buffer overwrites old experience fast — the learner sees a narrow, recent, near-on-policy slice of data instead of the whole history.

    PREDICT before you run: relative to the default buffer, does a 20x-smaller replay (a) clearly slow or break learning (final distance stays higher), (b) make little measurable difference at this budget, or (c) HELP (recent data is fresher)? Write your choice and a one-sentence mechanism in PREDICTION.

    Then run this file. It trains the default buffer and the shrunk buffer on seeds 0, 1, 2 and prints each arm's per-seed eval final distance and mean. Read the MEANS: the per-seed numbers move, and a real hyperparameter effect has to show up in the average, not in one cherry-picked seed (the reason the graded check asserts the default's strong learns-signal over seeds, and treats the buffer effect as an observation you interpret).

    Estimated learner time: 30 minutes (six short SAC runs).

    Run it locally:

    pytest curriculum/phase2_reinforcement/ch2.2_sac/exercises/suggested/checks.py -k ex2

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromsac.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
d91783a7e5a69fed1e5106f3047ce978332cad6f55613663a5e47995c3c0a62a
#env
92dba03857c0b457d145d6e384412359f08c0a27918a3a95b99f653b7f907a1a
#model
8f475fa1b05875cdbe542b221840aa185c1c6c4835369658ed9b5a31920526c0
#replay
d093649c07cd455a30199738cdf3b6e0b2eb39652f2321419c3f0f2a12ea5564
#update
7d8427ca00d976e04482299b1e7565234112b9c58ce92b27408d32a0317bb956
#eval
89f51273777765aec5bce64a8e952af57fad47bed60db6ff1d3092a3886a055d
#train
eb3266c57827bb7c7e4e5fb020e88300e8f5b6ff38703e93a43aa24bf4ac675d
#report
e2cc4242b5e907961ec2cc9383e8cd6852c086615faddda64a0936c90855b15e