zero2robot · Phase 4 · Capstonech4-offline-primer · offline.py

Chapter 4

Offline RL PrimerBeat the Data With Its Own Reward

By the end you can

  1. Define the offline RL setting — learn from a FIXED dataset with NO environment interaction — and why it is the setting real correction/demo data lives in
  2. Explain why BC's ceiling is the dataset's average quality (it clones, ignoring reward) and why offline RL can exceed it (it uses the reward to prefer above-average actions)
  3. Implement AWAC from scratch in one file — a twin-Q critic (ch2.2 idiom) + advantage-weighted regression policy extraction (ch1.1's BC loss, reweighted)
  4. Measure, don't assert, that offline RL BEATS BC on the SAME fixed mixed-quality dataset, with ch1.6 error bars (Wilson CI + a difference CI)
  5. Show the naive-offline-RL failure — maximize Q with no data constraint and the critic OVERESTIMATES out-of-distribution actions (worst on narrow data) — which is WHY the advantage constraint exists
  6. Supply the offline prior 4.3 needs: HIL-SERL fine-tunes an RL policy with the learner's correction data as a PRIOR; that prior is exactly an offline-RL policy extracted from logged corrections, which this chapter builds

See it work

live · P2
seed
the AWAC win holds on every seed — flip through them
BC vs AWAC · same fixed datasetseed 0 · N = 100
0%10%20%30%40%success3%behavior cloning3/10027%offline RL · AWAC27/100
difference CI (AWAC − BC) · excludes 0 every seed → significant
0 · no gain+0.1+0.2+0.3s0+0.15..+0.34s1+0.04..+0.23s2+0.07..+0.25
the Break-It · naive vs AWACnarrow data · expert-only
02468mean|Q|7.1naive max-Q1.1AWAC≈ 6.62× inflatedbounded

Drop the constraint and maximize Q: on narrow data the critic overestimates actions it never saw, so |Q| inflates ≈6.62× while eval collapses. AWAC anchors to the data and stays bounded. On the broad expert+random mix, coverage keeps naive honest — the damage is coverage-dependent.

the mechanism — the reward is the only difference
BC minimizes ‖π(s) − a‖² — it clones the average of the mix, so it can't tell a good action from a bad one.
AWAC is the same loss × exp(A/β), β = 0.3 — each sample weighted by advantage A = Q(s,a) − V(s). Above-average actions pull; junk is ignored.
random · return -16expert · -2.3BC · the averageAWAC · above average

Schematic — the extraction, not a measured curve. It is why offline RL exceeds BC's ceiling without any environment interaction.

Seed 0: on the same fixed mixed-quality dataset, behavior cloning reaches the target 3% of the time, offline RL with AWAC 27%. The difference confidence interval is +0.15 to +0.34 — it excludes zero, so the win is statistically significant, and it holds on all three seeds. It is honestly modest: AWAC still stops about 0.109 m short, far from the scripted expert's 0.000 m. The win is the reward-aware extraction, not a bigger network. The Break-It: naive maximize-Q with no data constraint inflates its Q-values about 6.62-fold on narrow expert-only data, while AWAC stays bounded near 1.08.

The gap is real, significant, and seed-robust — but modest: AWAC reaches ~0.109 m (success 27%), still short of the scripted expert (~0.000 m). BC is a fair clone; the win is the reward-aware extraction. Real measured numbers from offline.py (seeds 0, 1, 2, cpu); poster reads with JS off.

AWACadvantage-weighted — leans on the expert-like actions in the mix
BCbehaviour cloning — imitates the whole mix, good and bad alike
same mixed data, two learners · AWAC re-weights toward the good actions · poster reads with JS off
Open in Colabsoon

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

The setting: you can't collect more

Every learning method so far got to act. Behavior cloning (1.1) had a dataset, but PPO and SAC (2.1, 2.2) learned by stepping the env millions of times, and even the corrections chapter (4.2) closes the loop — you fly the policy, catch it failing, and record the fix. Now take that away. You have a fixed pile of logged data — some good demonstrations, some clumsy or failed attempts — and you cannot collect one more transition while you learn. No rollouts, no exploration, no on-policy correction. Just the log.

This is not a contrived constraint; it is the normal one. Robot data is expensive and sometimes dangerous to collect, and the data you already have — teleop sessions, logged corrections, a fleet's worth of mixed-quality trajectories — is the data you get. The question this chapter answers by measuring: given a fixed, mixed-quality log, what is the best policy you can squeeze out of it?

Why behavior cloning leaves reward on the table

BC has an answer: clone the data. Regress a network from observation to the action the log took, with plain MSE (1.1). But look at what MSE does to a mixed dataset. In a given state it minimizes squared error to every logged action at once, so it converges to their average. Average a clean expert action with a random flail and you get a watered-down action that is neither. BC's ceiling is therefore the dataset's average quality — and it has no way to do better, because it never looks at the reward. It cannot tell a good action from a bad one; it only knows they were both in the log.

We build the dataset to make this bite. It is the canonical "expert + random" mix: a --expert_frac slice is the scripted IK reacher from 2.2 (near-optimal), and the rest is a uniform-random policy (flails, rarely reaches). Measured behavior returns: expert ~-2.3, random ~-16.0 — genuinely mixed quality. The random half is not only junk to average over; it also covers the action space, which the offline learner will need.

offline.py#datasha256:454bac5906…
# THE FIXED DATASET. This is the whole premise: we build it ONCE, then never# touch the env again during learning. It is MIXED QUALITY on purpose — a# `--expert_frac` slice is the clean scripted IK reacher (near-optimal), and the# rest is a RANDOM policy (uniform torques — flails, rarely reaches). This is the# canonical offline mix (the D4RL "expert + random" recipe): some good# demonstrations buried in a lot of junk. BC averages over ALL of it and gets# dragged toward the junk; offline RL uses the reward to tell the two apart. The# random half also does the offline learner a favor — it COVERS the state-action# space, so the critic sees what bad actions cost. We store full transitions# (obs, action, reward, next_obs, terminated): BC needs only (obs, action), the# critic needs the rest.def build_dataset(num_episodes: int) -> dict[str, torch.Tensor]:    env = PusherReachEnv()    n_expert = int(round(args.expert_frac * num_episodes))    cols: dict[str, list] = {k: [] for k in ("obs", "action", "reward", "next_obs", "terminated")}    ret_expert, ret_noisy = [], []    for ep in range(num_episodes):        obs = env.reset(seed=args.seed + ep)  # episode ep uses a distinct, reproducible seed        is_expert = ep < n_expert        ep_return, done = 0.0, False        while not done:            action = (reach_action(env) if is_expert  # scripted IK reach — the expert                      else rng.uniform(-1, 1, size=act_dim).astype(np.float32))  # the junk half            next_obs, reward, done, info = env.step(action)            cols["obs"].append(obs)            cols["action"].append(action)            cols["reward"].append(reward)            cols["next_obs"].append(next_obs)            cols["terminated"].append(float(info["terminated"]))            obs = next_obs            ep_return += reward        (ret_expert if is_expert else ret_noisy).append(ep_return)    data = {k: torch.tensor(np.array(v), dtype=torch.float32, device=device) for k, v in cols.items()}    data["reward"] = data["reward"].unsqueeze(1)    data["terminated"] = data["terminated"].unsqueeze(1)    print(f"dataset: {num_episodes} episodes / {len(data['obs'])} transitions "          f"({n_expert} expert, {num_episodes - n_expert} random)")    print(f"  behavior return: expert {np.mean(ret_expert):7.2f}  random {np.mean(ret_noisy):7.2f}  "          f"(BC clones the mix of these; offline RL should beat it)")    return data  data = build_dataset(args.episodes)N = len(data["obs"])  def sample_batch() -> tuple[torch.Tensor, ...]:    idx = torch.randint(0, N, (args.batch_size,))  # seeded global torch RNG -> reproducible on CPU    return (data["obs"][idx], data["action"][idx], data["reward"][idx],            data["next_obs"][idx], data["terminated"][idx])

What offline RL does instead: use the reward

Offline RL's move is to not treat all logged actions equally. It uses the reward to prefer the actions that were above average, and it does this in two steps you already know how to build.

Step one: a critic. Fit a Q-function on the fixed data — the reward-to-go of taking action a in state s. This is exactly 2.2's twin-Q critic: two Q-networks, clipped double-Q (bootstrap from the min), and slow-moving target networks. The only change from SAC is that there is no replay buffer being filled by a live policy — the "buffer" is the frozen dataset, sampled forever.

Step two: advantage-weighted policy extraction. Now extract a policy. Here is the whole trick, and it is one line different from BC. BC regresses toward the logged action with a uniform weight. AWAC regresses toward the same logged action, but weights each sample by

weight = exp( A(s, a) / beta ),   A(s, a) = Q(s, a) - Q(s, pi(s))

the advantage of the logged action over what the current policy would do. An action the reward says beat the policy gets weight > 1 and is pulled toward; a bad action gets a weight near zero and is ignored. That is the entire difference between "clone the data" and "extract the best policy the data supports." beta is the temperature: small sharpens the weighting toward pure filtering, large melts it back into BC.

offline.py#modelsha256:3463dd917f…
# Policy: obs -> action MLP, tanh-bounded to the env's [-1, 1] torque range. This# is deliberately the SAME network BC and offline RL both train — the algorithms# differ ONLY in the loss (see region: train), which is the whole lesson. Critic:# a Q(obs, action) net; we build TWO (twin critics, ch2.2's clipped double-Q) and# always bootstrap from the MIN, the brace that fights Q overestimation.class Policy(nn.Module):    def __init__(self, hidden_dim: int):        super().__init__()        self.net = nn.Sequential(            nn.Linear(obs_dim, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, act_dim),        )     def forward(self, obs: torch.Tensor) -> torch.Tensor:        return torch.tanh(self.net(obs))  # actions live in [-1, 1]  class Critic(nn.Module):    def __init__(self, hidden_dim: int):        super().__init__()        self.net = nn.Sequential(            nn.Linear(obs_dim + act_dim, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),            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))

The two learners share the same network and the same regression, and differ only in that weight — so read their losses side by side:

AWAC is BC's regression toward the logged action, but each sample scaled by the exponentiated advantage of that action over what the policy would do — and here it is in code:

LAWAC(θ)=E(s,a)D ⁣[exp ⁣(1βA(s,a))  πθ(s)a2],A(s,a)=Q(s,a)Q(s,π(s))\mathcal{L}_{\mathrm{AWAC}}(\theta) = \mathbb{E}_{(s,a)\sim\mathcal{D}}\!\left[\, \exp\!\Big(\tfrac{1}{\beta}\,A(s,a)\Big)\; \big\lVert \pi_\theta(s) - a \big\rVert^2 \,\right], \qquad A(s,a) = Q(s,a) - Q\big(s, \pi(s)\big)
offline.py#trainsha256:58e0c4bfa6…
# The two learners, side by side. BC and offline RL share the policy network and# the SAME regression — predict the dataset action — and differ in ONE line: the# per-sample weight. That is the entire point of the chapter, so read the losses# together.def train_bc() -> Policy:    """Behavior cloning: uniform-weighted MSE to the dataset action (ch1.1). The    ceiling is the data's average quality — it cannot tell a good action from a    bad one, because it never looks at the reward."""    policy = Policy(args.hidden_dim).to(device)    opt = torch.optim.Adam(policy.parameters(), lr=args.lr)    for step in range(args.steps):        obs, action, *_ = sample_batch()        loss = F.mse_loss(policy(obs), action)        opt.zero_grad()        loss.backward()        opt.step()        if args.rerun and step % 50 == 0:            rr.set_time("step", sequence=step)            rr.log("bc/loss", rr.Scalars([loss.item()]))    return policy  def train_offline() -> tuple[Policy, float]:    """Offline RL. First a twin-Q critic learns the reward-to-go on the FIXED    data (bootstrapping from the policy's own next action, ch2.2 idiom). Then the    policy is extracted:      AWAC  advantage-weighted regression — the BC loss, but each sample scaled by            exp(A/beta) where A = Q(s,a_data) - Q(s,pi(s)). Actions the reward            says beat the current policy get pulled toward; bad ones get ignored.      naive (--naive) NO constraint: maximize Q(s, pi(s)) directly. Offline, the            policy walks to out-of-distribution actions where Q is a fantasy — the            overestimation the constraint exists to prevent. Worst on narrow data            (--expert_frac 1.0). This is the Break It.    """    policy = Policy(args.hidden_dim).to(device)    q1, q2 = Critic(args.hidden_dim).to(device), Critic(args.hidden_dim).to(device)    q1_targ = copy.deepcopy(q1).requires_grad_(False)    q2_targ = copy.deepcopy(q2).requires_grad_(False)    policy_opt = torch.optim.Adam(policy.parameters(), lr=args.lr)    critic_opt = torch.optim.Adam(list(q1.parameters()) + list(q2.parameters()), lr=args.lr)    mean_q = float("nan")    for step in range(args.steps):        obs, action, reward, next_obs, terminated = sample_batch()        # --- critic: TD-regress both Q's toward r + gamma * min_targ Q(s', pi(s')) ---        with torch.no_grad():            next_action = policy(next_obs)            min_next_q = torch.min(q1_targ(next_obs, next_action), q2_targ(next_obs, next_action))            target_q = reward + args.gamma * (1.0 - terminated) * min_next_q        q1_loss, q2_loss = F.mse_loss(q1(obs, action), target_q), F.mse_loss(q2(obs, action), target_q)        critic_opt.zero_grad()        (q1_loss + q2_loss).backward()        critic_opt.step()        # --- policy extraction: the ONE line that separates the algorithms ---        pred = policy(obs)        if args.naive:  # maximize Q with no anchor to the data -> divergence offline            policy_loss = -torch.min(q1(obs, pred), q2(obs, pred)).mean()        else:  # AWAC: advantage-weighted regression toward the dataset action            with torch.no_grad():                q_data = torch.min(q1(obs, action), q2(obs, action))          # Q of the logged action                value = torch.min(q1(obs, pred), q2(obs, pred))               # V(s) = Q(s, pi(s)) baseline                weight = torch.exp((q_data - value) / args.beta).clamp(max=args.weight_clip)            policy_loss = (weight * (pred - action).pow(2).mean(1, keepdim=True)).mean()        policy_opt.zero_grad()        policy_loss.backward()        policy_opt.step()        # --- soft target update (Polyak), ch2.2 idiom ---        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)        mean_q = 0.5 * (q1(obs, action) + q2(obs, action)).mean().item()        if args.rerun and step % 50 == 0:            rr.set_time("step", sequence=step)            rr.log("offline/critic_loss", rr.Scalars([(q1_loss + q2_loss).item() / 2]))            rr.log("offline/policy_loss", rr.Scalars([policy_loss.item()]))            rr.log("offline/mean_q", rr.Scalars([mean_q]))  # watch this EXPLODE under --naive    return policy, mean_q

Why naive offline RL is not enough (and why the constraint exists)

A reasonable objection: if we have a Q-function, why the weighting ceremony — why not just train the policy to maximize Q, the way DDPG or SAC do? Run --naive and find out. Maximizing Q with no anchor to the data lets the policy walk to out-of-distribution actions — actions the log never contains — where the Q-function is pure extrapolation. Offline, there is no env step to check that fantasy, so the critic overestimates those actions and the policy happily chases the overestimate.

Here is the honest, measured subtlety, and it is the best part of the lesson. How badly this bites depends on how narrow your data is. On the broad expert+random mix, the random half covers the action space, the critic stays calibrated, and even naive maximize-Q survives. But on narrow data — expert-only, --naive --expert_frac 1.0, which is the shape of real demo and correction logs — the naive critic inflates: measured mean |Q| ~7x the AWAC critic's while its eval collapses toward random. AWAC's advantage weighting is the fix that does not depend on coverage: it only ever asks the critic about actions the data actually contains, so there is nothing to extrapolate into. That is why offline RL is its own algorithm and not just "Q-learning on a log."

The result: measured, honest

Train both on the same fixed dataset, then grade them with 1.6's error bars — a Wilson interval on each success rate and a difference CI that decides whether the gap is real. On the default free-tier config (mixed dataset, held out over 100 rollouts), across seeds 0, 1, 2:

learner success rate mean final dist
behavior cloning 0.03 / 0.07 / 0.05 0.154 / 0.147 / 0.144 m
offline RL (AWAC) 0.27 / 0.20 / 0.21 0.109 / 0.100 / 0.089 m

The difference CI (offline − BC) excludes zero on every seed — the win is significant, not noise, and it holds across seeds. Offline RL beat BC on the same data by using the reward BC ignored.

Be honest about the size of it. Offline RL reaches ~0.09–0.11 m (random leaves ~0.176 m), still well short of the scripted expert's ~0.0001 m — this is a primer on a tiny free-tier budget, not a leaderboard entry. And BC does not improve much even as you clean the data up (exercise 2 sweeps 15% → 60% expert): it stays ~0.03–0.05 success as long as the log still carries a meaningful junk fraction, because averaging any junk into the regression target corrupts it. The mechanism is the lesson, not the number: reward-aware extraction beats cloning, and the advantage constraint is what makes it safe to do offline.

Now the boundary that locates the whole mechanism, and it is the most important honesty in the chapter. Take the junk away entirely — --expert_frac 1.0, a clean expert log and nothing else — and AWAC's edge over BC evaporates. Measured across seeds 0, 1, 2: BC jumps to ~0.2 success (there is no bad action left to average in, so cloning the expert is the right move) and actually holds a slightly lower final distance than AWAC (~0.053 m vs ~0.076 m); the success-rate difference CI is marginal and not seed-robust — a tie on seed 0, a hair above zero on 1 and 2. AWAC lost nothing; it simply had nothing left to reweight. That is the real headline, stated carefully: offline RL beats cloning when the log carries suboptimal actions worth down-weighting — the mixed, correction-shaped data of the real world. On a pristine expert log, cloning already does the reweighting for free, and the honest reading is a wash. This is why the default dataset is a mix, and why the win to trust is the seed-robust one on that mix, not a blanket claim that "AWAC beats BC."

offline.py#reportsha256:f75dccf0d9…
# Train both, then grade them with error bars. The headline is the difference CI:# does offline RL BEAT BC on the SAME fixed dataset, and is the gap real at this N?bc_policy = train_bc()offline_policy, offline_mean_q = train_offline()mode = "naive-maxQ" if args.naive else "AWAC"print(f"\ntrained BC and offline ({mode}); offline final mean|Q| over data = {abs(offline_mean_q):.1f}") bc_succ, bc_dist = evaluate(bc_policy)off_succ, off_dist = evaluate(offline_policy)n_pool = args.n_seeds * args.eval_episodesbc_k, off_k = int(bc_succ.sum()), int(off_succ.sum())gap = diff_ci(off_k, n_pool, bc_k, n_pool)  # offline - BC; excludes 0 => realgap_real = gap[0] > 0 or gap[1] < 0verdict = ("offline RL BEATS BC" if gap[0] > 0 else           "BC beats offline RL" if gap[1] < 0 else "tie (diff CI spans 0)") print(f"\n[headline] BC vs offline RL on the same fixed dataset (N={n_pool} rollouts each):")print(f"  BC       success {bc_k}/{n_pool} = {bc_k/n_pool:.2f}  {tuple(round(x,2) for x in wilson_ci(bc_k,n_pool))}"      f"   mean final dist {bc_dist.mean():.4f} m")print(f"  offline  success {off_k}/{n_pool} = {off_k/n_pool:.2f}  {tuple(round(x,2) for x in wilson_ci(off_k,n_pool))}"      f"   mean final dist {off_dist.mean():.4f} m")print(f"  diff CI (offline - BC): {gap[0]:+.2f}..{gap[1]:+.2f}  ->  {verdict}"      f"  ({'SIGNIFICANT' if gap_real else 'not significant at this N'})")print(f"  (random baseline ~{RANDOM_DIST} m; lower dist is better)")if args.naive:    print(f"\n[Break It] naive offline RL (maximize Q, no data constraint): mean|Q| over data = {abs(offline_mean_q):.1f}.")    print("  On narrow data (--expert_frac 1.0) this inflates ~7x vs AWAC's ~1 while eval collapses toward random:")    print("  the policy chases OOD actions the critic OVERESTIMATES. On the broad expert+random mix, coverage")    print("  keeps the critic honest and naive survives — which is exactly why narrow correction data needs the constraint.") if args.rerun:    for name, k, dist in (("bc", bc_k, bc_dist), ("offline", off_k, off_dist)):        lo, hi = wilson_ci(k, n_pool)        rr.log(f"eval/{name}/success_rate", rr.Scalars([k / n_pool]))        rr.log(f"eval/{name}/success_ci", rr.Scalars([lo, hi]))        rr.log(f"eval/{name}/mean_dist", rr.Scalars([float(dist.mean())])) torch.save(offline_policy.state_dict(), args.out / "offline_policy.pt")torch.save(bc_policy.state_dict(), args.out / "bc_policy.pt")  # the BC baseline arm, for the site ONNX demometrics = {    "mode": mode,    "episodes": args.episodes,    "expert_frac": args.expert_frac,    "steps": args.steps,    "beta": args.beta,    "n_pooled": n_pool,    "bc_success_rate": round(bc_k / n_pool, 6),    "offline_success_rate": round(off_k / n_pool, 6),    "bc_mean_final_dist": round(float(bc_dist.mean()), 6),    "offline_mean_final_dist": round(float(off_dist.mean()), 6),    "diff_ci_lo": round(gap[0], 6),    "diff_ci_hi": round(gap[1], 6),    "gap_significant": bool(gap_real),    "offline_mean_abs_q": round(abs(offline_mean_q), 4),    "naive": bool(args.naive),    "seed": args.seed,    "smoke": bool(args.smoke),}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")print(f"\nmetrics: {args.out / 'metrics.json'}")if args.rerun:    print(f"recording: {args.out / 'offline.rrd'} — open it with: rerun {args.out / 'offline.rrd'}")

Why this is the prior 4.3 builds on

The capstone's RL post-training (4.3, HIL-SERL) fine-tunes an RL policy starting from your correction data as a prior — and sample efficiency is the whole point, so it cannot afford to start from scratch. That prior is precisely what this chapter builds: a policy extracted from a fixed log of mixed-quality behavior, using the reward, without the extrapolation error that sinks naive offline RL. Correction data is narrow — exactly the regime where the naive approach fails and the advantage constraint earns its keep. When 4.3 says "offline-primed," this is the priming.

Running it

python curriculum/phase4_capstone/ch4_offline_primer/offline.py --seed 0
# Break it (narrow data): ... --seed 0 --naive --expert_frac 1.0
# Investigate:            ... --seed 0 --expert_frac 0.6

Determinism: --seed fixes the dataset, both trainings, and every eval reset, so two CPU runs produce byte-identical metrics.json. CPU is the one configuration this book promises is bitwise-reproducible; on a GPU or an Apple mps backend the same seed reproduces the result statistically, not bitwise — the numbers above are --device cpu, and the exercises force it.

Read the real thing

The AWAC you just built is the honest core of a real algorithm, not a toy of one. Two readings show you where the production implementations go from here.

The AWAC paper — Nair et al. 2020, Accelerating Online Reinforcement Learning with Offline Datasets — is where the advantage weight comes from. The exp(A / beta) you wrote is not a heuristic someone tuned; it falls out as the closed-form solution to a KL-constrained policy-improvement step, which is why the weight is an exponential of the advantage and why beta is a temperature. Read it for the derivation the code can only assert.

IQL — Kostrikov's reference implementation, ikostrikov/implicit_q_learning — is the "what the grown-up version does differently" reading. Find the one place our critic is still asked about an action it never saw: the Q(s, pi(s)) baseline in the advantage. IQL removes even that, replacing it with an expectile regression that estimates the state value without ever querying an out-of-sample action — closing the last crack the advantage constraint left open. The expectile loss is a single line; find it, and you have the whole idea.

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) — predict-then-run, ch4 offline primer.

    Objective tested: the chapter's headline claim — that offline RL (AWAC) extracts a BETTER policy than behavior cloning from the SAME fixed, mixed-quality dataset, by using the reward BC ignores — AND the RL-doctrine reading underneath it (ch2.1 spike, H2): grade the signal ACROSS seeds, not on one run.

    THE SETUP. The fixed dataset is a mix: 30% clean scripted-expert episodes, 70% random-policy junk (the canonical "expert + random" offline mix). BC regresses the AVERAGE action per state, so the junk drags it down. AWAC fits a Q-function and reweights that same regression toward the actions the reward says were above-average.

    THE QUESTION. On this mixed dataset, does the offline learner BEAT BC — higher held-out success rate AND lower mean final distance — on EVERY one of seeds 0, 1, 2?

    PREDICT before you run: (a) yes, offline RL clearly beats BC on all three seeds; (b) they roughly tie (BC's average is already good enough); (c) BC wins (the reward signal is too weak to help offline). Write your choice and one sentence of why in PREDICTION.

    Then run this file. It trains BOTH learners on each of seeds 0, 1, 2 (each run is bit-reproducible on CPU) and prints per-seed success + final distance. The variance you read is ACROSS seeds — which is why offline RL, like all RL, is graded on a multi-seed signal.

    Estimated learner time: ~4 minutes (three short training runs, each trains BC and offline together).

    Run it locally:

    pytest curriculum/phase4_capstone/ch4_offline_primer/exercises/suggested/checks.py -k ex1
  2. hyperparameter-investigation

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — dataset-quality investigation, ch4 offline primer.

    Objective tested: the MECHANISM behind the headline. Offline RL beats BC because BC is capped by the AVERAGE quality of its data — it clones the mix. So what happens to the BC-vs-offline gap as you clean up the dataset? This is a hyperparameter investigation, not a bug-hunt (ch2.1 spike, H1): form a directional hypothesis, then read it against a seed-robust signal.

    THE KNOB. --expert_frac sets the fraction of dataset episodes that come from the clean scripted expert (the rest are random junk). We compare a mostly-junk dataset (0.15) against a mostly-clean one (0.6).

    PREDICT before you run: as the data gets cleaner (expert_frac 0.15 -> 0.6), does BC (a) catch up to offline RL — a cleaner average is a better clone; (b) stay far behind — averaging ANY junk into the regression target corrupts it, so the ratio barely matters; (c) overtake offline RL? Write your choice and a one-sentence mechanism in PREDICTION.

    Then run this file. It trains BC + offline on both dataset qualities across seeds 0, 1 and prints each arm's mean success. Read the MEANS, not one cherry-picked seed: commit your PREDICTION first, then let the per-arm means show you whether cleaning the data closed the BC-vs-offline gap — and reconcile the result against your mechanism.

    Estimated learner time: ~5 minutes (four short training runs).

    Run it locally:

    pytest curriculum/phase4_capstone/ch4_offline_primer/exercises/suggested/checks.py -k ex2
  3. predict-then-run

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch4 offline primer.

    Objective tested: the chapter's SHARPEST failure — why offline RL needs to be its own algorithm at all. You already fit a Q-function. So why can't the policy just MAXIMIZE it (the DDPG/SAC move) and skip the advantage-weighting bookkeeping? Because offline, with no fresh data to correct it, the policy drifts to actions the log never contained, where Q is an unchecked extrapolation — and the damage scales with how NARROW the data is.

    THE SETUP. --naive swaps AWAC's advantage-weighted regression for plain maximize-Q policy extraction (no anchor to the dataset action). --expert_frac sets how much of the fixed dataset is the clean scripted expert; the rest is a RANDOM policy that COVERS the action space. We run naive on TWO datasets: narrow --expert_frac 1.0 (expert-only — the shape of real demo/correction logs) broad --expert_frac 0.3 (the default expert+random mix — random half = coverage) and read the critic's mean |Q| over the data at the end of training.

    THE QUESTION. Under naive maximize-Q, what happens to |Q|? (a) it blows up on BOTH — maximize-Q always diverges offline, coverage or not; (b) it blows up on the NARROW data but stays bounded on the broad mix — the random half covers the action space, so the critic keeps seeing what bad actions cost and can't inflate them; (c) it stays bounded on BOTH — the twin-Q min (clipped double-Q, ch2.2) already caps overestimation, so the constraint is redundant. Write your choice and one sentence of why in PREDICTION.

    Then, BEFORE you run, answer SELF_EXPLANATION in your own words: you HAVE a Q, so why can't the policy just maximize it — what does the critic report at an action the log never contained, and what stops a plain maximizer from walking straight into it? Committing your reasoning first is the whole exercise.

    Then run this file. It trains naive offline RL on the narrow and broad datasets across seeds 0, 1 (each run is bit-reproducible on CPU) and prints each arm's mean |Q| plus the eval it produces. The blowup you read is seed-robust — which is why the graded check asserts it over N seeds, not on one run.

    Estimated learner time: ~5 minutes (four short training runs).

    Run it locally:

    pytest curriculum/phase4_capstone/ch4_offline_primer/exercises/suggested/checks.py -k ex3
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.95 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromoffline.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
f0905eef64cb409ba1db347037bc4491eee35470a1646995d476ca693a53a5e3
#data
454bac590613c3ebe197ffc0acb76068d7e2c7592036b0476b2586c7cb9014b7
#model
3463dd917fc306a9624087c06f8e66b7c54e32575a17288a3273aaa5b538a8ab
#stats
2f42573d8b25069cc61f84d306337c1f7010b788ae4372fb11cabfc75f942d4c
#eval
91baf9f308b3a9bc44be59d301ebe379a39a0929616459ba91543fb81f1b33ca
#train
58e0c4bfa6795193c0f9dae7ce17c0634b548ad02955cc04bb9c70e26c498ffa
#report
f75dccf0d943dafe7ff8dd785972909ad047345bfb93af68af3281df18e65f18