zero2robot · Phase 4 · Capstonech4.2-corrections · dagger.py

Chapter 4.2

CorrectionsHuman-in-the-Loop Data

By the end you can

  1. Close the ch1.1 covariate-shift loop — FIX behavior cloning by correcting the policy on its own mistakes
  2. Implement the DAgger loop from scratch (rollout -> collect visited states -> expert-label -> aggregate -> retrain)
  3. Explain why on-policy corrections cover the deployment states that more off-policy demos structurally cannot
  4. Grade the recovery honestly with Wilson intervals (ch1.6) — the BC-vs-DAgger gap is only real if the CIs separate
  5. See why the recovery is best-round-selected (on the held-out eval — a mild winner's curse that survives Bonferroni correction; see HONESTY), not monotonic — over-iterating a reactive clone regresses (Ross et al.)

See it work

live · P2
seed
recovery is real on every seed — but the winning round moves (peaks: 3, 4, 4)
the recovery · success vs roundseed 0
0%5%10%15%20%25%30%reactive-MLP ceiling ≈ 25%BC floor · covariate shiftBCDAgger 1DAgger 2DAgger 3DAgger 4correct → aggregate → retrain →best · selectedregresses ↓
non-monotonic · peak variespeaks 3, 4, 4
0102030BCD1D2D3D4s0·r3s1·r4s2·r4
the mechanism · the DAgger loopcorrect on its own mistakes
  1. 1BC only ever saw the states the EXPERT visited (near starts).
  2. 2Deployed on the full task, the clone DRIFTS into states no demo covered.
  3. 3DAgger rolls out the CURRENT policy and labels the states IT visits.
  4. 4Aggregate those (visited-state, expert-action) pairs into the dataset.
  5. 5Retrain, and iterate — the clone learns to recover from its own mistakes.

The clone is a reactive MLP (obs -> action, no memory), so even recovered it tops out around 25% on this task — DAgger fixes the covariate shift, it does not make a reactive policy omniscient.

Seed 0: behavior cloning sits at the covariate-shift floor, 6.5%. The best DAgger round is round 3 at 21.5%; the recovery difference interval, best minus BC, is +8.3 to +21.7 points and excludes zero, so the recovery is significant. The peak is round 3, not the last round 4 (8.5%) — over-iterating regresses. Across seeds 0 to 2 the peak round varies — 3, 4, 4 — so you select the best round.

DAgger closes ch1.1's covariate-shift loop. The BC floor (5.5%–6.5% across seeds) recovers to the best DAgger round (18.5%–21.5%); the recovery diff CI excludes 0 on every seed 0–2. But it is not monotonic — the peak round varies (3, 4, 4) and over-iterating a reactive clone regresses, so you select the best round, not the last (Ross et al.). Honest ceiling ≈25% (reactive MLP). Real measured numbers from dagger.py (N=200/round, seeds 0–2, cpu); the seed-0 recovery reads with JS off.

DAgger corrections — recovery from covariate shift, live on PushTThe dashed circle is the narrow region the BC demos started in (block-to-goal < 0.13 m). Behavior cloning never saw the far starts outside it and fails there. DAgger relabels the states the policy visits with the expert's action, so it covers those far starts and recovers — it is a modest reactive clone (~0.2 success), not a great PushT policy.where BC practicedtargetdrag to a far start →DAgger recovers from where BC failed →
DAgger recovers the covariate-shift loss (~0.2 success, not a great policy) · best round, not the last · poster reads with JS off
pusherthe agent that pushesT-blockthe thing being pushedtargetwhere it needs to end up
Open in Colabsoon

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

The death you diagnosed, now the fix

In chapter 1.1 you watched behavior cloning die, and you named the cause: covariate shift. BC only ever sees the states the demonstrator visited. At deploy time the policy drifts a little, lands in a state no demo covered, guesses worse, drifts further — and the block slides past the goal. You could not fix it by cloning harder, because the policy never acts during training, so it never sees the states its own mistakes create.

Phase 2 fixed this by throwing the demonstrator out and letting the policy act for reward (PPO, SAC). This chapter fixes it a different way — the way you reach for when you have an expert but not a reward function, which is most of real robotics. Keep the demonstrator. Just stop asking it the wrong question.

BC asks the expert: "show me some good trajectories." The trouble is those trajectories only cover the expert's states. DAgger (Dataset Aggregation, Ross, Gordon & Bagnell 2011) asks the expert a better question: "here is the state MY policy just wandered into — what would you do HERE?" Then it trains on the answer. The loop is almost embarrassingly direct:

  1. Roll out the current policy and record the states it actually visits — including the drifted, off-distribution ones BC never saw.
  2. Ask the expert what to do on each of those visited states.
  3. Aggregate the new (state, expert-action) pairs into the dataset.
  4. Retrain and repeat.

The policy learns to recover from its own mistakes, because its own mistakes are exactly what the dataset now covers.

The expert is a stand-in for your hand

Here the "expert" is the scripted PushT controller from common/envs/pusht — the same one that generated the demos in chapter 1.1. In this chapter it plays the role of a human teleoperator correcting the robot through the browser playground. That substitution is honest, not a shortcut: the mechanism is identical — label the state the policy is in with a good action. The browser follow-up (see demo/embed.yaml) swaps the scripted labeler for your hand on the mouse and changes nothing else in the loop. We use the scripted expert because it is free, deterministic, and lets the whole chapter run on a CPU laptop; the lesson transfers verbatim to the human-in-the-loop version.

That substitution also makes DAgger's one hard requirement impossible to miss — and it is the price you pay for the recovery. Behavior cloning asks the expert for a fixed batch of demonstrations once, offline, and never needs it again. DAgger needs an expert it can query on any state the policy wanders into, round after round — an interactive, queryable expert. When that expert is a person, those queries are real human labor in the loop. When you have only a frozen dataset and no way to ask "what would you do here", DAgger simply does not apply — and you are back to the offline-only tools of Phase 1.

Making covariate shift measurable

There is a catch we have to handle honestly. On the full PushT task, a reactive MLP trained on enough demos already limps to ~0.24 success — covariate shift is real but mild, and mild effects drown in the seed-to-seed noise of a free-tier eval (this is the exact trap chapter 1.6 taught you to respect). To see the recovery, we make the covariate shift bite, using chapter 1.6's held-out device:

  • The BC demos come from a narrow region — the block only ever starts close to the goal (--r_max 0.13). This is the demonstrator's limited practice set.
  • We deploy on the full task, where the block starts anywhere in the annulus.

BC never saw the far starts. It covariate-shifts hard and fails. And crucially, more narrow demos cannot fix it — they still do not cover the far starts. Only data collected where the policy actually goes can. That is the gap DAgger fills.

The shape of the file

dagger.py reuses common/: the PushT env, its scripted expert, the seeding helper, the device banner. The policy is chapter 1.1's, unchanged — a reactive MLP with normalization baked in as buffers. DAgger changes the data, never the policy. That is the whole point.

dagger.py#policysha256:7d9152ae0e…
# The ch1.1 behavior-cloning policy, unchanged: a reactive 3-layer MLP,# obs float32[10] -> action float32[2], with normalization living inside the# module as buffers. DAgger changes the DATA the policy trains on, never the# policy. That is the whole point — same clone, honest states.class BCPolicy(nn.Module):    def __init__(self, hidden_dim: int, stats: dict[str, np.ndarray]):        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),        )        for name, value in stats.items():            self.register_buffer(name, torch.from_numpy(value))  # saved with the weights, never trained     def forward(self, obs: torch.Tensor) -> torch.Tensor:        normalized = (2.0 * (obs - self.obs_min) / self.obs_range - 1.0).clamp(-1.0, 1.0)        return (self.net(normalized) + 1.0) / 2.0 * self.act_range + self.act_min  def norm_stats(obs: np.ndarray, act: np.ndarray) -> dict[str, np.ndarray]:    """Per-dim min/max -> [-1, 1] (ch1.1). Constant dims (the fixed target) carry    range 0; give them range 1 so they map to a constant, not a divide-by-zero."""    omin, amin = obs.min(0), act.min(0)    orange = np.where(obs.max(0) - omin < 1e-4, np.float32(1.0), obs.max(0) - omin)    arange = np.where(act.max(0) - amin < 1e-4, np.float32(1.0), act.max(0) - amin)    return {"obs_min": omin, "obs_range": orange, "act_min": amin, "act_range": arange}  def train_bc(obs: np.ndarray, act: np.ndarray, stats: dict, hidden: int, epochs: int, seed: int) -> BCPolicy:    """Plain MSE behavior cloning (ch1.1), seeded per call so training is    bit-reproducible on CPU. `stats` is FROZEN to the BC demos and passed in: if    we recomputed normalization on the DAgger aggregate, far-field flailing    frames would blow up the ranges and wreck the on-task fit."""    torch.manual_seed(seed)    policy = BCPolicy(hidden, stats).to(device)    opt = torch.optim.Adam(policy.parameters(), lr=1e-3)    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)    ot, at = torch.from_numpy(obs).to(device), torch.from_numpy(act).to(device)    shuffle = torch.Generator().manual_seed(seed)    for _ in range(epochs):        for batch in torch.randperm(len(ot), generator=shuffle).split(256):            loss = F.mse_loss(policy(ot[batch]), at[batch])            opt.zero_grad()            loss.backward()            opt.step()        sched.step()    return policy.eval()  def act_fn(policy: BCPolicy):    def act(obs: np.ndarray) -> np.ndarray:        with torch.no_grad():            return policy(torch.from_numpy(obs).to(device).unsqueeze(0))[0].cpu().numpy()    return act

The corrector collects demos from the narrow practice region, then labels the policy's on-policy rollouts. The one subtlety is the beta-mixture: early rounds execute mostly the expert (beta starts at 1.0 and decays), so the visited states stay near the manifold instead of flooding the dataset with far-off-manifold flailing; later rounds hand control to the policy and collect corrections on the states it causes.

dagger.py#datasha256:1add793a2a…
# The scripted expert is the corrector. In the browser follow-up this is YOUR# teleop; the mechanism — "label the state the policy is in with a good action" —# is identical, which is exactly why the offline stand-in is honest.def collect_bc_demos(n: int, seed: int, r_max: float):    """Expert demos from the NARROW practice region only: keep an episode iff the    block STARTS within r_max of the goal. This manufactures the covariate shift —    deployment (full annulus 0.10..0.24) has far starts these demos never cover."""    env = PushTEnv()    obs_buf, act_buf = [], []    accepted, s = 0, seed    while accepted < n:        obs = env.reset(s)        s += 1        if float(np.hypot(*env.tee_pose[:2])) > r_max:            continue  # start too far from the goal — outside the demonstrator's practice set        expert = ScriptedExpert(noise=0.0, seed=s)        done = False        while not done:            a = expert.action(env)            obs_buf.append(obs)            act_buf.append(a)            obs, _, done, _ = env.step(a)        accepted += 1    return np.asarray(obs_buf, np.float32), np.asarray(act_buf, np.float32)  def dagger_rollout(policy: BCPolicy, n: int, seed: int, beta: float):    """One DAgger round of data collection on the FULL deployment distribution.     At every state the trajectory visits, record the EXPERT's action (the label —    what a good controller, or your teleop hand, would do HERE). The action    EXECUTED is a beta-mixture: with prob beta the expert's, else the policy's.    High beta early keeps the visited states near the expert manifold instead of    flooding the set with far-off-manifold flailing; as beta decays the policy    drives and we collect corrections on the states IT causes (Ross et al.)."""    env = PushTEnv()    act = act_fn(policy)    obs_buf, act_buf = [], []    for i in range(n):        obs = env.reset(seed + i)  # full annulus — the deployment distribution        expert = ScriptedExpert(noise=0.0, seed=seed + i)        done = False        while not done:            expert_a = expert.action(env)  # the LABEL for this visited state            obs_buf.append(obs)            act_buf.append(expert_a)            drive = expert_a if rng.random() < beta else act(obs)  # beta-mix executes            obs, _, done, _ = env.step(drive)    return np.asarray(obs_buf, np.float32), np.asarray(act_buf, np.float32)  def eval_suites(policy: BCPolicy, n_seeds: int, ep: int) -> np.ndarray:    """n_seeds independent suites of `ep` rollouts on held-out full-distribution    starts. Returns (n_seeds, ep) bool successes; pooled they feed the Wilson CI."""    env = PushTEnv()    act = act_fn(policy)    out = np.zeros((n_seeds, ep), dtype=bool)    for s in range(n_seeds):        for e in range(ep):            obs = env.reset(EVAL_BASE + s * ep + e)  # never a start we trained on            done, info = False, {}            while not done:                obs, _, done, info = env.step(act(obs))            out[s, e] = bool(info["success"])    return out

The loop itself is four lines of intent — correct, aggregate, retrain, evaluate — wrapped around the Wilson intervals from chapter 1.6, because a recovery you cannot separate from noise is not a recovery.

dagger.py#loopsha256:570fee6322…
# Freeze normalization to the BC demos, then run the loop: BC seed policy, then# correct -> aggregate -> retrain, evaluating every round with its Wilson CI.obs, act = collect_bc_demos(args.bc_demos, args.seed, args.r_max)stats = norm_stats(obs, act)print(f"BC demos: {args.bc_demos} narrow-region episodes / {len(obs)} frames (r_max={args.r_max})")  def evaluate(policy, label: str, it: int):    o = eval_suites(policy, args.n_seeds, args.eval_episodes)    k, n = int(o.sum()), o.size    lo, hi = wilson_ci(k, n)    print(f"round {it} ({label:<6s}) success {k}/{n} = {k/n:.3f}  CI [{lo:.3f}, {hi:.3f}]  dataset {len(obs)}")    if args.rerun:        rr.set_time("dagger_round", sequence=it)        rr.log("eval/success_rate", rr.Scalars([k / n]))        rr.log("eval/ci_low", rr.Scalars([lo]))        rr.log("eval/ci_high", rr.Scalars([hi]))        rr.log("data/frames", rr.Scalars([float(len(obs))]))    return k, n  policy = train_bc(obs, act, stats, args.hidden_dim, args.epochs, args.seed)kbc0, nbc0 = evaluate(policy, "BC", 0)rounds = [("BC", kbc0, nbc0)]best_policy, best_rate = policy, kbc0 / nbc0  # best over ALL rounds incl. BC (Ross et al.), selected on the held-out eval — a mild winner's curse; the recovery survives Bonferroni multiplicity correction (see meta HONESTY)for it in range(1, args.dagger_iters + 1):    beta = args.beta_decay ** (it - 1)  # 1.0, then decaying — the DAgger mixture    ro, ra = dagger_rollout(policy, args.rollouts, args.seed + 1000 * it, beta)    obs = np.concatenate([obs, ro])  # AGGREGATE (the second D in DAgger)    act = np.concatenate([act, ra])    policy = train_bc(obs, act, stats, args.hidden_dim, args.epochs, args.seed)    k, n = evaluate(policy, "DAgger", it)    if k / n > best_rate:        best_policy, best_rate = policy, k / n    rounds.append((f"DAgger{it}", k, n))

Run it

python curriculum/phase4_capstone/ch4.2_corrections/dagger.py --seed 0 --device cpu

On a CPU laptop this takes about 1.5 min at the default config — this chapter lives comfortably on the free-tier floor.

wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~1.51 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

The success rate is graded on 200 held-out rollouts (20 suites × 10), each with its Wilson interval. Measured (seed 0, default config):

round 0 (BC    ) success  13/200 = 0.065  CI [0.038, 0.108]   <- covariate shift
round 1 (DAgger) success  17/200 = 0.085  CI [0.054, 0.132]
round 2 (DAgger) success  31/200 = 0.155  CI [0.111, 0.212]
round 3 (DAgger) success  43/200 = 0.215  CI [0.164, 0.277]   <- recovered
round 4 (DAgger) success  17/200 = 0.085  CI [0.054, 0.132]   <- over-iterated

BC 0.065 -> best DAgger3 0.215;  recovery diff CI [+0.08, +0.22]  (excludes 0)

BC sits at 0.065 and its interval tops out at 0.11. The best DAgger round reaches 0.215 with a lower bound of 0.16 — the two intervals do not overlap, and the difference interval excludes zero. The policy did not get a bigger network or a reward function; it got the states it was actually failing in, labeled. That is the recovery, and it holds on every seed 0–2 (BC ~0.06, best DAgger ~0.18–0.22, diff CI excludes zero every seed).

More is not better — select the best round

Look again at round 4: it fell back to 0.085. Aggregating corrections onto a still-weak reactive policy eventually floods the dataset with its own long failure trajectories, and the fit regresses. The round that peaks is not always the last — seed 0 peaks at round 3, seed 1 at round 4. This is not a bug to tune away; it is what Ross et al. tell you to expect, which is why the algorithm returns the best policy over rounds, not the final one.

One honesty note, because honest numbers are this chapter's whole subject: dagger.py selects that best round on the same held-out eval it then reports the BC-vs-DAgger gap against — there is no separate validation split — so the headline carries a mild winner's-curse selection bias. It is not an artifact. A non-selected round (round 2 at seed 0) already clears BC's interval on its own, and the recovery survives a Bonferroni correction across the four rounds. A production loop would hold out a separate split for the selection; we name the shortcut rather than bury it. dagger.py saves the best round's checkpoint and reports the whole success-vs-round curve. The "how many rounds?" exercise makes you read it.

An honest ceiling

DAgger recovers the covariate-shift loss; it does not turn a reactive MLP into a great PushT policy. The scripted expert is stateful (a multi-phase push controller), and a memoryless clone of it — averaging over a multimodal action distribution — tops out around 0.2–0.25 no matter how you cover the state space (that ceiling is chapter 1.3's ACT and 1.4's diffusion policy, which give the clone a memory and a multimodal head). DAgger's job here is narrow and real: close the gap between what BC saw and where the policy goes. It closes it.

Read the real thing

The paper to read beside this file is the original: Ross, Gordon & Bagnell, "A Reduction of Imitation Learning and Structured Prediction to No-Regret Online Learning" (2011). It is the source of the aggregate-and-retrain loop and of the best-round result you just watched happen. Read it for the theory dagger.py elides — the no-regret argument for why on-policy relabeling bounds an error that behavior cloning cannot, and the formal role of the beta schedule you set by hand here.

Then read the interactive expert this chapter stubbed out. LeRobot's teleop and record loop is the production version of dagger_rollout: a human on a controller labeling the states a policy visits, streamed to disk in the very (observation, action) format collect_bc_demos writes. Read it for the one line where "the expert labels the visited state" stops being a scripted function and becomes a person. The refinement to look up from there is HG-DAgger and the broader interactive-imitation line, which hand the human the decision of when to intervene — the ergonomic answer to DAgger's one real cost, an expert you can query at every state the policy reaches.

What's next

DAgger fixed the states you visit by relabeling them; it still needs an expert with the answers. The offline-RL primer next drops that assumption — it learns from a fixed batch of logged, imperfect interactions with no expert to query. From there, ch4.3 (HIL-SERL) is the capstone: it folds the human back in, but as an occasional corrector of a policy improving from its own reward, not a labeler of every state.

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.2.

    Objective tested: the chapter's headline — DAgger RECOVERS the covariate shift that killed behavior cloning. You predict, then you run the real loop and read the Wilson intervals, because a recovery you cannot separate from noise is not one.

    THE SETUP. dagger.py trains BC on demos from a NARROW start region (the block only starts close to the goal) and deploys on the FULL task (block anywhere). BC never saw the far starts — that is the manufactured covariate shift. Then DAgger rolls out the policy, has the scripted expert label the states it visits, aggregates, and retrains, for several rounds.

    PREDICT before you run: over the default config, does DAgger (a) leave BC's success essentially unchanged (the corrections do not help), (b) recover it — the best DAgger round's success interval clears BC's — or (c) make it worse? Write your choice and one sentence of why in PREDICTION.

    Then run this file. It runs the loop once (seed 0, ~2 min on a CPU laptop) and prints BC's success rate and the best DAgger round's, each with its Wilson interval, plus the difference interval that is the actual verdict.

    Estimated learner time: 25 minutes (mostly waiting on the run).

    Run it locally:

    pytest curriculum/phase4_capstone/ch4.2_corrections/exercises/suggested/checks.py -k ex1
  2. investigation

    Exercise 2

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

    Objective tested: DAgger is not "more rounds = better." Some round peaks, and later rounds can REGRESS — which is why Ross et al. return the BEST policy over rounds, not the last. This exercise makes you SEE that curve, and reason out WHY the regression happens BEFORE you read the mechanism.

    THE INVESTIGATION. dagger.py reports round_rates: the success rate at BC (round 0) and after each DAgger round. Run the default config and read the curve.

    Questions to answer from the numbers (record them in FINDINGS below):

    1. Which round is the PEAK? Is it the LAST round?
    2. Is the curve monotonic, or does it dip/regress somewhere?
    3. Measured across seeds 0-2 the peak lands at round 3 or 4 — does yours?

    There is no single right prediction here; the point is to SEE that the last round is not a safe default, which is why the artifact saves the best round's checkpoint, not the final one.

    Estimated learner time: 20 minutes.

    Run it locally:

    pytest curriculum/phase4_capstone/ch4.2_corrections/exercises/suggested/checks.py -k ex2

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromdagger.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
329ddeb922c3699d94b12e2f36046ed3a8f8f45ea7354ef366a9c57da7a88517
#stats
08850747dcf9ed73e0b9aec06390a6676be544b7ee3c48b14583e9b9282e292f
#policy
7d9152ae0e4a93918da1a76744e7897efefc979091f0201b7b8c08dcb5886eac
#data
1add793a2a42e725937ac8a39bb541ad3f4ed8c361fe3918b1b262e9b7fd0cf4
#loop
570fee6322ab4a012dcb622edbb9eea975ec74e3ee9c1976395426ed9067e219
#verdict
4f4968f44ed8e3cfc06645534aba1c82c39f0311dfdd21ad6011a1269105ffbe