zero2robot · Phase 1 · Imitationch1.1-bc · bc.py

Chapter 1.1

Behavior CloningThe Dumbest Thing That Works

By the end you can

  1. Implement supervised imitation from (obs, action) pairs in a single file
  2. Explain why MSE on actions is a defensible (and flawed) starting objective
  3. Diagnose covariate shift from a rerun trace of a failing rollout
  4. Run the full train→eval→export→browser loop with your own dataset

See it work

live · P2
PushT behavior-cloning policy — covariate-shift toyThe demonstrated-coverage region (dashed) is the set of block positions the training demos actually visited, derived from real scripted-expert rollouts. Inside it the policy pushes the block onto the target; dragged outside it, the policy makes small, confident, wrong movements.where the demos pushed the blocktargetdrag me out of distribution →
drag the block (or arrow-keys when focused) · poster reads with JS off
pusherthe agent that pushesT-blockthe thing being pushedtargetwhere it needs to end up

Drag the T-block a few centimeters off the path the policy wants — then let go and watch it come back for the block. This policy has no planner, no search, no reward function, no idea what a "T" is. It is a three-layer MLP that was shown a few hundred episodes of pushing and told: when the world looks like this, do that. Sometimes your drag lands somewhere the demonstrations covered, and the recovery looks deliberate, almost smug. Sometimes it doesn't, and the pusher commits — confidently, repeatedly — to an approach that will never work.

Try to find where it breaks. You will, and quickly, and that boundary you just found by hand is this chapter's actual subject. The policy is exactly as good as its demonstrations, no better, and the interesting question is never whether it works — it's where it stops working and what that looks like from the inside.

Open in Colabsoon

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

The problem

In chapter 0.4 you drove this pusher yourself, with your own two hands on the keys, and you succeeded at a task you could not begin to write down. Try it: write the rule you used for deciding when to stop pushing the bar and start nudging the stem. You rotated the block when it needed rotating. How did you know? You'd have to say something about the angle, and the contact point, and where the pusher happened to be — and by the third clause you're describing a controller you never consciously ran.

The scripted expert in curriculum/common/envs/pusht/scripted_expert.py is what writing it down actually costs: a two-phase state machine, contact-point selection in the block's body frame, a detour circle so the approach never plows through the block, a steering offset proportional to yaw error. It took real engineering hours, it is full of constants like 0.045 and 1.15, and it works for exactly one task on exactly one block. Nobody writes that controller for the thousandth task.

Behavior cloning skips the describing. You already produced the only artifact it needs — a dataset of (observation, action) pairs from chapter 0.4's teleop session — and the claim this chapter tests is almost insulting in its simplicity: fit a function from one column to the other with mean squared error, and driving skill falls out. What we build: a single file that loads your demos, fits an MLP, measures it honestly in rollouts, and ships the result to the browser. Then we break it in a way no loss curve will admit to.

Build

Four regions in dependency order: data, model, train, eval. The whole file is bc.py, about 270 lines, and every line of it is on the page — there is no framework underneath.

Setup

One thing to look for: every source of randomness in this file — the train/val split, the weight init, the batch shuffle — flows from the one --seed flag through set_seed.

bc.py#setupsha256:16c89e5949…
import argparseimport jsonimport shutilimport sysfrom pathlib import Path import numpy as npimport torchimport torch.nn as nn # Chapter artifacts run as loose scripts from the repo root; put the root on# sys.path so `curriculum.common` resolves (same pattern as tests/).sys.path.insert(0, str(Path(__file__).resolve().parents[3])) from curriculum.common.assert_parity import assert_parity  # noqa: E402from curriculum.common.device import banner, detect_device  # noqa: E402from curriculum.common.envs.pusht import PushTEnv, gen_demos  # noqa: E402from curriculum.common.export_onnx import export_policy  # noqa: E402from curriculum.common.seeding import set_seed  # noqa: E402 parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--data", type=Path, default=Path("outputs/pusht-demos"),                    help="LeRobot-format demo dataset (your ch0.4 teleop session, or gen_demos.py output)")parser.add_argument("--out", type=Path, default=Path("outputs/ch1.1-bc"))parser.add_argument("--epochs", type=int, default=600)  # cpu-laptop: minutes | smoke: 3parser.add_argument("--batch_size", type=int, default=128)parser.add_argument("--lr", type=float, default=1e-3, help="peak Adam lr; cosine-decays to 0 over --epochs")parser.add_argument("--hidden_dim", type=int, default=512)  # width is NOT the bottleneck — see the model regionparser.add_argument("--seed", type=int, default=0, help="seeds the split, the init, and the shuffle")parser.add_argument("--eval_episodes", type=int, default=50)  # T4: 50 | smoke: 5parser.add_argument("--normalize", choices=("full", "narrow"), default="full",                    help="narrow = stats from a lopsided slice of the demos; the Break It flag")parser.add_argument("--device", choices=("cpu", "cuda", "mps"), default=detect_device())  # T4: cuda | Mac: mps | cpu: deterministic (statistical repro on GPU/mps)parser.add_argument("--smoke", action="store_true",                    help="tiny self-contained CPU run for CI; two runs must produce byte-identical metrics.json")parser.add_argument("--rerun", dest="rerun", action="store_true", default=True)parser.add_argument("--no-rerun", dest="rerun", action="store_false", help="skip .rrd recording (CI smoke)")args = parser.parse_args() rng = set_seed(args.seed)  # seeds python/numpy/torch; returns the numpy Generator the split draws fromif args.smoke:  # smoke pins everything the CI byte-compare depends on    args.epochs, args.eval_episodes, args.device = 3, 5, "cpu"banner("ch1.1-bc", device=args.device)  # report the device the run ACTUALLY uses (after --smoke pins cpu)args.out.mkdir(parents=True, exist_ok=True)device = torch.device(args.device)if args.rerun:    import rerun as rr    rr.init("zero2robot/ch1.1-bc", spawn=False)    rr.save(str(args.out / "bc.rrd"))

The flags follow the house convention: free-tier defaults, a --smoke mode that runs tiny and fixed so CI can byte-compare two runs, and --no-rerun to skip recording. Two flags are new. --normalize you will meet properly in Break It. --device picks the fastest thing your machine has (cuda, then mps, then cpu); the reference numbers below are from --device cpu, the one configuration we can promise is bitwise-deterministic under --seed. On a GPU or an Apple mps backend the same seed reproduces the result statistically, not bitwise — your success rate will land near the number printed here, not exactly on it — and this book does not pretend otherwise.

Data

Look for what gets split: episodes, never frames.

bc.py#datasha256:2650003f8f…
# Two dataset paths, same format: the reference is `lerobot/pusht` from the# HF Hub (human demos); the path this chapter assumes is YOURS — the teleop# session you recorded in ch0.4, or the scripted-expert set from gen_demos.py.if args.smoke:    # Smoke runs are hermetic: CI regenerates its own tiny deterministic set.    # REGENERATE it every run (never reuse a leftover dir): a cache from a    # different --seed would train on seed-0 demos while metrics.json records    # seed 1 — silent wrong data. gen_demos is deterministic, so same seed ->    # bit-identical dataset whether it was just built or rebuilt.    args.data = args.out / "smoke-demos"    if args.data.exists():        shutil.rmtree(args.data)    gen_demos.main(["--episodes", "6", "--seed", str(args.seed),                    "--out", str(args.data), "--no-video"])if not (args.data / "meta" / "info.json").is_file():    sys.exit(f"no dataset at {args.data} — record one in ch0.4, or generate demos first:\n"             f"  python curriculum/common/envs/pusht/gen_demos.py "             f"--episodes 500 --seed 0 --out {args.data} --no-video") from lerobot.datasets.lerobot_dataset import LeRobotDataset  # noqa: E402  (heavy import — after the cheap failures) frames = LeRobotDataset("local/pusht-demos", root=args.data).hf_dataset.with_format("numpy")obs = np.stack(frames["observation.state"])  # (N, 10) — layout documented in pusht_env.pyactions = np.stack(frames["action"])         # (N, 2)  — pusher velocity in [-1, 1]episode_ids = np.asarray(frames["episode_index"])  # (N,) which demo each frame came from # Split by EPISODE, never by frame. Frames 0.1 s apart are near-duplicates;# a frame-level split puts one twin in train and one in val, and val loss# becomes a memorization test you can only pass. Episode-level keeps val# honest: whole trajectories the network has never seen.episode_order = rng.permutation(np.unique(episode_ids))val_episodes = episode_order[: max(1, len(episode_order) // 10)]train_episodes = episode_order[len(val_episodes):]in_val = np.isin(episode_ids, val_episodes) # Normalization stats: per-dim min/max, mapping each dim to [-1, 1] — the# same scheme the real PushT policies use (diffusion_policy, LeRobot).# full: stats over every training frame, so by construction no training# input ever leaves [-1, 1]. narrow: stats over only the ~20% of training# episodes whose block STARTS closest to the target — the demos you'd record# first while testing your teleop rig, block placed gently near the goal.# Both splits share the same stats either way, which is exactly why no loss# curve will flag the difference (Break It).if args.normalize == "narrow":    def block_start_distance(episode: int) -> float:        tee_x, tee_y = obs[episode_ids == episode][0][2:4]  # episode's first frame        return float(np.hypot(tee_x, tee_y))    easiest = sorted(train_episodes, key=block_start_distance)[: max(1, len(train_episodes) // 5)]    stats_frames = np.isin(episode_ids, easiest)else:    stats_frames = ~in_valobs_min, act_min = obs[stats_frames].min(0), actions[stats_frames].min(0)obs_range = obs[stats_frames].max(0) - obs_minact_range = actions[stats_frames].max(0) - act_min# The 4 target dims are constant in this phase -> range 0. A constant carries# no information; give it range 1 so it maps to a constant instead of a# division by zero.obs_range = np.where(obs_range < 1e-4, np.float32(1.0), obs_range)act_range = np.where(act_range < 1e-4, np.float32(1.0), act_range) train_obs = torch.from_numpy(obs[~in_val]).to(device)train_actions = torch.from_numpy(actions[~in_val]).to(device)val_obs = torch.from_numpy(obs[in_val]).to(device)val_actions = torch.from_numpy(actions[in_val]).to(device)print(f"dataset: {len(episode_order)} episodes / {len(obs)} frames "      f"({len(train_episodes)} train / {len(val_episodes)} val episodes), normalize={args.normalize}")

The dataset is LeRobot-format — the same format your ch0.4 teleop session produced, the same format lerobot/pusht's human demonstrations ship in, and the same format the reference generator writes. This chapter's numbers use the scripted expert's demos so they're exactly reproducible on your machine:

python curriculum/common/envs/pusht/gen_demos.py --episodes 500 --seed 0 \
    --out outputs/pusht-demos --no-video

Two decisions in this region carry most of the chapter's honesty. First, the split. Consecutive frames are 0.1 s apart; the block barely moves between them. Split frame-wise and nearly every validation frame has a near-twin in training — val loss becomes a memorization test you can only pass, and it will happily stay low while your policy learns nothing transferable. Splitting by episode keeps validation what it claims to be: whole trajectories the network has never seen.

Second, normalization. Raw observations mix meters (±0.35) with sin/cos (±1); raw actions are velocities. We rescale each dimension to [-1, 1] using its min and max over the training frames — the same scheme the real PushT policies use. The stats are computed from the data, which sounds like a triviality and is actually a loaded gun: the stats are a claim about what the world looks like, and the code will believe that claim long after it stops being true. --normalize full computes them from all training episodes. The other setting exists for Break It.

Model

Look at how little there is to look at.

bc.py#modelsha256:587270db35…
class BCPolicy(nn.Module):    """3-layer MLP, obs float32[10] -> action float32[2]. Deliberately boring.     The ceiling of behavior cloning is the data, not the network: this policy    can never act better than the demonstrations it averages over, and past a    point extra width just buys a sharper copy of the same mistakes (exercise    3 makes you measure where that point is). Normalization lives INSIDE the    model, as buffers: the checkpoint and the ONNX export carry their own    stats, so the playground (tensor contract v1) feeds raw observations.    """     def __init__(self, hidden_dim: int, obs_min, obs_range, act_min, act_range):        super().__init__()        self.net = nn.Sequential(            nn.Linear(PushTEnv.OBS_DIM, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, PushTEnv.ACT_DIM),        )        for name, stat in [("obs_min", obs_min), ("obs_range", obs_range),                           ("act_min", act_min), ("act_range", act_range)]:            self.register_buffer(name, torch.from_numpy(stat))  # saved with the weights, but never trained     def forward(self, obs: torch.Tensor) -> torch.Tensor:        # (B, 10) raw -> (B, 10) in [-1, 1] -> (B, 2) in [-1, 1] -> (B, 2) raw        normalized_obs = 2.0 * (obs - self.obs_min) / self.obs_range - 1.0        # The clamp guards the net against inputs outside the range it trained        # on. When the stats cover the demos (they define [-1, 1]) it never        # moves a training value — remember it exists. Break It wakes it up.        normalized_action = self.net(normalized_obs.clamp(-1.0, 1.0))        return (normalized_action + 1.0) / 2.0 * self.act_range + self.act_min  policy = BCPolicy(args.hidden_dim, obs_min, obs_range, act_min, act_range).to(device)

Three linear layers and two ReLUs. This is deliberate, and it is not modesty. The ceiling of behavior cloning is the data, not the network: the policy can never act better than the demonstrations it averages over, so capacity spent past "can represent the demonstrator" buys you nothing but a sharper copy of the same mistakes. Exercise 3 makes you measure this instead of trusting me — you'll 10x the network's training two different ways and watch which one the success rate responds to.

One structural decision deserves attention: the normalization stats live inside the model, as buffers. The forward pass takes raw observations, normalizes, predicts, denormalizes. That means the checkpoint and the ONNX export carry their own stats — the browser playground can feed the model observations straight from the simulator, and there is no separate stats file to lose, version-skew, or apply twice. Exercise 1 is about the "apply twice" failure, which every robotics codebase commits eventually.

The clamp in the forward pass never moves a training value — the training data defines [-1, 1], so nothing in it can leave. Remember that it exists.

Train

The loop is short enough to read in one breath.

bc.py#trainsha256:fb3058a61d…
# A plain loop — no DataLoader, no scheduler, no early stopping. The whole# dataset sits in memory as two tensors; "batching" is indexing a shuffled# permutation. MSE says: predict the average action the demonstrator took in# this state. Defensible (it's maximum likelihood under a Gaussian), flawed# (when demos disagree, the average of two good actions can be a bad one —# that flaw is chapter 1.2's opening problem).optimizer = torch.optim.Adam(policy.parameters(), lr=args.lr)# One concession to optimization reality: decay the lr to 0 over the run, or# the last epochs bounce around the minimum instead of settling into it# (measured: +6 points of success rate at the default config).scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs)shuffle = torch.Generator().manual_seed(args.seed)  # torch-side RNG: same seed -> same batch ordertrain_loss, global_step = float("nan"), 0for epoch in range(args.epochs):    epoch_loss, num_batches = 0.0, 0    for batch in torch.randperm(len(train_obs), generator=shuffle).split(args.batch_size):        loss = nn.functional.mse_loss(policy(train_obs[batch]), train_actions[batch])        optimizer.zero_grad()        loss.backward()        optimizer.step()        epoch_loss, num_batches = epoch_loss + loss.item(), num_batches + 1        if args.rerun:            rr.set_time("step", sequence=global_step)            rr.log("policy/loss/train", rr.Scalars([loss.item()]))        global_step += 1    scheduler.step()    train_loss = epoch_loss / num_batches    with torch.no_grad():        val_loss = nn.functional.mse_loss(policy(val_obs), val_actions).item()    if args.rerun:        rr.log("policy/loss/val", rr.Scalars([val_loss]))    if epoch % 50 == 0 or epoch == args.epochs - 1:        print(f"epoch {epoch:3d}  train_loss {train_loss:.5f}  val_loss {val_loss:.5f}") torch.save(policy, args.out / "bc_policy.pt")  # whole module — reloadable only where THIS file's BCPolicy is importable# TorchScript carries its own code, so assert_parity's CLI reloads it in a# fresh interpreter with no BCPolicy on the path (the cross-process gate):#   python curriculum/common/assert_parity.py <out>/bc_policy.onnx <out>/bc_policy.ts.pttorch.jit.script(policy.eval()).save(str(args.out / "bc_policy.ts.pt"))

No DataLoader, no gradient clipping, no early stopping: the dataset is two tensors in memory, and a "batch" is a slice of a shuffled index permutation. The one concession to optimization reality is the cosine decay on the learning rate — without it the last hundred epochs bounce around the minimum instead of settling into it, and the difference is measurable: +6 points of success rate at the default configuration (62% with the decay, 56% without).

MSE deserves a sentence of defense and a sentence of prosecution. Defense: minimizing squared error on actions is maximum likelihood under a Gaussian — it's not a hack, it's the textbook estimator for "predict the average action the demonstrator took in this state". Prosecution: it predicts the AVERAGE action. When demonstrations disagree — approach the block clockwise or counterclockwise, push the near tip or the far one — the average of two good actions can be a bad action, sometimes a catastrophic one. Even our scripted expert disagrees with itself: its action depends on an internal phase (approaching vs. mid-stroke) that the observation does not fully reveal, so the same observation carries different labels and the MLP splits the difference. Hold that thought; it is the entire reason chapter 1.2 exists.

Eval

Loss measured how well we imitate on the dataset's states. Rollouts ask the question we actually care about.

bc.py#evalsha256:dcd865519e…
# Loss measured how well we imitate on the dataset's states. Rollouts measure# what we actually care about: does the block reach the target when the POLICY# picks the states it visits? Those are different questions — Break It shows# just how different.env = PushTEnv()policy.eval()successes, episode_returns = 0, []for episode in range(args.eval_episodes):    # 10_000 + offset: demo episode i used reset seed (seed + i), so eval    # seeds are held out by construction — never graded on a start we trained on.    obs_now = env.reset(seed=10_000 + args.seed + episode)    episode_return, done, info = 0.0, False, {}    while not done:        with torch.no_grad():  # the rollout loop: obs -> action -> step (exercise 2 blanks this)            obs_batch = torch.from_numpy(obs_now).to(device).unsqueeze(0)  # (10,) -> (1, 10)            action = policy(obs_batch)[0].cpu().numpy()        obs_now, reward, done, info = env.step(action)        episode_return += reward        if args.rerun:            # offset each episode by the max horizon so traces don't overlap on one timeline            rr.set_time("sim_time", duration=episode * (PushTEnv.MAX_STEPS / PushTEnv.CONTROL_HZ) + env.data.time)            rr.log("policy/action", rr.Scalars(action.astype(np.float64)))            rr.log("eval/pos_err", rr.Scalars([info["pos_err"]]))    successes += bool(info["success"])    episode_returns.append(episode_return)    if args.rerun:        rr.log("eval/success", rr.Scalars([float(info["success"])]))        rr.log("eval/episode_return", rr.Scalars([episode_return]))success_rate = successes / args.eval_episodesprint(f"eval: {successes}/{args.eval_episodes} episodes succeeded "      f"(success rate {success_rate:.2f}), mean return {np.mean(episode_returns):.3f}") # The full loop ends in the browser: export to ONNX (tensor contract v1),# then prove torch and onnxruntime agree before the file goes anywhere.onnx_path = export_policy(policy, PushTEnv.OBS_DIM, PushTEnv.ACT_DIM, args.out / "bc_policy.onnx")parity_delta = assert_parity(policy, onnx_path, PushTEnv.OBS_DIM)print(f"exported {onnx_path} — torch/onnx parity delta {parity_delta:.2e}") metrics = {    "epochs": args.epochs,    "eval_episodes": args.eval_episodes,    "final_train_loss": round(train_loss, 6),    "final_val_loss": round(val_loss, 6),    "mean_episode_return": round(float(np.mean(episode_returns)), 6),    "normalize": args.normalize,    "parity_delta": round(parity_delta, 6),  # rounds to 0.0 in practice; the gate already asserted < 1e-4    "seed": args.seed,    "smoke": bool(args.smoke),    "success_rate": round(success_rate, 6),}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")print(f"metrics: {args.out / 'metrics.json'}")if args.rerun:    print(f"recording: {args.out / 'bc.rrd'} — open it with: rerun {args.out / 'bc.rrd'}")

The distinction matters because the policy chooses its own future inputs. One slightly-off action leads to a state slightly off the demonstration manifold, where the policy is slightly worse, which leads further off — the compounding spiral is called covariate shift, and it is the disease behavior cloning dies of. A loss number cannot see it, because the loss is computed on states the DEMONSTRATOR chose. Rollouts from 50 held-out reset seeds can. Note the seed arithmetic: demo episode i consumed reset seed seed + i, so the evaluation seeds 10_000 + … are new poses by construction — we never grade the policy on a start it trained on.

After the rollouts, two lines close the loop the whole book runs on: export_policy writes the ONNX under tensor contract v1, and assert_parity proves torch and onnxruntime produce the same actions before the file is allowed anywhere near the playground. The measured parity delta at the default config is about 2e-06 — comfortably under the 1e-4 gate.

Run it

python curriculum/phase1_imitation/ch1.1_bc/bc.py --seed 0
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~3.82 min (measured)measured
mpsexpected wall-clock on mps: ~9.1 min (measured)measured
t4expected wall-clock on t4: ~5.65 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

With 500 scripted demos and default flags, the reference run reaches 31/50 held-out episodes (62% success), mean return -36.6, final train loss 0.010, val loss 0.037. Open the recording:

rerun outputs/ch1.1-bc/bc.rrd

Two timelines. On step: policy/loss/train sawing downward, policy/loss/val flattening out around epoch 300 — after that you are polishing the fit, not learning new behavior. On sim_time: fifty eval episodes laid end to end, eval/pos_err and policy/action per step. The healthy signature is pos_err curves that dive and stay down; the sick ones dive, stall, and saw sideways — go find two or three of those now (about a third of episodes fail at this scale; you have plenty to choose from) and scrub policy/action at the stall. That's what averaged indecision looks like, and diagnosing it from the trace is a skill this book will ask of you thirty more times.

And 62% is worth staring at. The expert that produced the demonstrations succeeds on every one of the fifty seeds measured in the env README (100%). Same observations, same task — we lost a third of the competence in the copying. Some of that is averaging over the expert's hidden phase, some is covariate shift off the demo manifold. More demonstrations buy some of it back (exercise 4 quantifies how much); no amount buys back all of it.

Break it

Two runs, one flag apart (the smaller network and shorter schedule are deliberate — the note at the end of this section explains why, and it's worth the wait):

python curriculum/phase1_imitation/ch1.1_bc/bc.py --seed 0 --epochs 300 --hidden_dim 256
python curriculum/phase1_imitation/ch1.1_bc/bc.py --seed 0 --epochs 300 --hidden_dim 256 --normalize narrow

narrow computes the normalization stats from only the ~20% of training episodes whose block starts closest to the target — and changes nothing else. Same 500 episodes, same network, same epochs. This is not an exotic sabotage; it's the most ordinary bug in robot learning: you computed your stats the week you were testing the rig with the block placed gently near the goal, and never recomputed after you started collecting for real.

Now look at what training reports:

--normalize full --normalize narrow
final train loss 0.0161 0.0157
final val loss 0.0345 0.0355
rollout success 29/50 (58%) 23/50 (46%)

Read the loss rows first. Nothing flags the narrow run — train loss is marginally LOWER, val loss is a wash. Both splits are normalized with the same skewed stats, so both fit and both agree with each other. Every curve in rerun looks like the healthy screenshot from Run It. If you shipped policies on loss curves, you would ship this one.

The rollouts tell a different story, and WHERE they tell it is the lesson. Split the 50 eval episodes by the block's starting distance from the target: for starts inside the region the stats covered (under 0.15 m), the narrow policy essentially matches the honest one — 8/19 vs 9/19, a single-episode difference that is noise at this sample size. For far starts it gives up a quarter of its competence: 15/31 against the honest run's 20/31. Five of the six episodes the narrow policy loses are far starts — the damage lands exactly on the states the stats never described, and no metric computed ON THE DATASET can see it, because the dataset is precisely the thing that got misdescribed.

The mechanism is that clamp from the model region, awake for the first time. Min-max stats from near-goal episodes declare that tee_x lives in roughly [-0.13, +0.13]. A block starting at 0.20 m normalizes to 1.5 — and clamps to 1.0. The policy doesn't see a far block; it sees a block pinned to the edge of a world that ends where the easy demos ended. Open the two recordings side by side and scrub a far-start episode: in the narrow run, policy/action saturates toward a block-edge that isn't where the block is, the pusher arrives short, strokes empty table, re-approaches, strokes again. It isn't confused. It is certain, and it's certain about a world 0.13 meters wide.

The transferable lesson, and it's worth saying plainly: loss curves measure the dataset you gave them, not the world. Normalization stats are part of the model. Version them, recompute them when the data changes, and when a policy trains clean but acts wrong, check what its inputs look like AFTER normalization — rr.log one histogram and this entire class of bug confesses in seconds.

One more measured fact, because this book doesn't hide inconvenient ones: train the narrow run at the chapter's full default scale — 600 epochs, hidden_dim 512 — and the failure heals. The bigger network, given twice the schedule, learns a working strategy for blocks pinned to the edge of its clamped world ("push toward the edge until the block walks into view") and finishes at 37/50 — above the honest default run's 31/50. That should unsettle you more than the failure did: the bug didn't go away, the policy papered over it, and nothing in your metrics distinguishes "fixed" from "compensated". Chapter 1.6 is an entire chapter about why evaluation this shallow will eventually lie to you.

Exercises

Four, in exercises/: a bug-hunt where every training metric is healthy and only rollouts complain, the rollout loop with the middle missing, and two where you commit to a prediction before the run is allowed to tell you the answer.

What's next

Your BC policy averages. On PushT the averaging is mostly survivable — scalar velocities, one expert, its self-disagreements small enough to blur rather than break. But you saw the residue even here: a third of the competence gone, stalls where the expert's hidden phase made one observation carry two labels. Human demonstrations are worse — you never push the block the same way twice, and when demonstrations genuinely disagree, the average of two good trajectories is a trajectory through the space between them, which is to say: through failure. Next chapter the policy stops predicting the next instant's average and starts committing to plans — chunks of future actions predicted together — and the stalls you found in the rerun traces tonight are the first thing that disappears.

Practice

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

  1. bug-hunt

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — bug-hunt, ch1.1.

    This is the chapter artifact with EXACTLY ONE conceptual bug injected: a normalization applied somewhere it does not belong. The cruel part — train loss and val loss are IDENTICAL to bc.py's, epoch for epoch, and the ONNX export passes parity. Every number the training loop can print is healthy. Only the rollouts are much worse.

    Before you change a line, write one sentence: train and val loss are both healthy, so what does that rule out — and where in this pipeline does that leave the only place the bug can hide?

    Find the bug by holding every line against the model region's design decision (where does normalization live in this pipeline, and why?), fix it, and re-run checks.py until the smoke metrics agree with the chapter's.

    Objective tested: implement supervised imitation end-to-end / diagnose a failure no dataset metric can see.

    Run: python ex1_bughunt_normalize_twice.py --smoke --seed 0 --no-rerun Estimated learner time: 25 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.1_bc/exercises/suggested/checks.py -k ex1
  2. code-completion

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — code-completion, ch1.1.

    Objective tested: run the full loop — an evaluation rollout is the loop from chapter 0.1 with a policy writing the ctrl line. You get the signature and the docstring; the body is yours.

    Everything around the blank is real: checks.py drives your rollout with the scripted expert (which should succeed every episode) and with a do-nothing policy (which should never succeed). If both behave, your loop is right — same reset seeding, same step contract, same return accounting as bc.py.

    Run: python ex2_completion_rollout.py (rolls out the scripted expert) Estimated learner time: 20 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.1_bc/exercises/suggested/checks.py -k ex2
  3. hyperparameter-investigation

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — hyperparameter-investigation, ch1.1.

    Objective tested: the model region's claim that the ceiling of behavior cloning is the data. Here you buy the same 10x compute two different ways and see which one the success rate cares about.

    THE QUESTION. Starting from a deliberately starved baseline (20 demos, 60 epochs), you may 10x exactly one axis:

    ARM A: 10x the data    (200 demos, 60 epochs)
    ARM B: 10x the epochs  (20 demos, 600 epochs)
    

    PREDICT before you run: which arm wins on rollout success rate, and is the loser's val loss higher or lower than the winner's? Write both parts of your prediction in PREDICTION below, then run this file (a few minutes on CPU — it trains three policies).

    Estimated learner time: 30 minutes (mostly waiting on the runs).

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.1_bc/exercises/suggested/checks.py -k ex3
  4. predict-then-run

    Exercise 4

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch1.1.

    Objective tested: diagnose covariate shift — this time by inducing it on purpose and watching how dataset size changes the blast radius.

    THE SETUP. Two policies, trained identically except for demo count (50 vs 200). During each eval episode we shove the T-block sideways by delta meters at control step 25 — a state the policy has to recover from, exactly like a visitor dragging the block in the playground demo. We sweep delta and measure success at each size.

    PREDICT before you run: sketch success-vs-delta for both policies. Which statement matches your sketch?

    • A) The shove dominates: once the block teleports, both policies are off their data and fail about equally — demo count stops mattering

    • B) Demo count dominates: the 200-demo policy stays above the 50-demo policy at every shove size, shoved or not

    • C) The gap WIDENS with delta — extra demos buy robustness precisely in the rarely-visited states shoves create

    • D) The curves converge at large delta — far enough off-distribution, every BC policy is equally lost

    (several minutes on CPU — it trains two policies and rolls out the whole sweep).

    Estimated learner time: 35 minutes (mostly waiting on the runs).

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight frombc.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
16c89e594920a1fd92da0df084644ea793fdd6431b5c79ba6a9629e65391f26e
#data
2650003f8fb903b7e3f2e803739faa3016e2d70c122e059da3281cd81b89cd3d
#model
587270db3559f2c26095ad76f60fdb50ff25aa77581fae8faa90136e21152441
#train
fb3058a61d4aff4f7fbdd212332893dd2820045bff9cf76d5b33d43532d1ca53
#eval
dcd865519e82294b232f28edb9a0fddc6cf81d89df0e929c9f6b357484f0163c