zero2robot · Phase 1 · Imitationch1.2-curate · curate.py

Chapter 1.2

Data Is the Policy

By the end you can

  1. Score demonstration quality with dataset-ONLY signals — episode outcome, demonstrator disagreement, coverage — never touching the environment
  2. Filter bad episodes and show curated data beats raw on held-out success despite training on FEWER episodes
  3. Explain why demonstrator disagreement measures task difficulty, not label noise — and why optimizing it as a quality proxy backfires (Break It)
  4. Re-train chapter 1.1's behavior cloning on the curated dataset and measure the jump

See it work

live · P2
which demos do you keep?

same behavior cloning, same compute — the dataset is the only lever · curated beats raw on fewer demos · poster reads with JS off

pusherthe agent that pushesT-blockthe thing being pushedtargetwhere it needs to end up

Two policies, side by side, on the same held-out starts. They were trained by the identical code — chapter 1.1's behavior cloning, unchanged — for the identical number of epochs. The only difference is the dataset. The one on the left learned from every episode you recorded. The one on the right learned from the subset that reached the goal: a bit more than half the episodes, but — since the ones it drops are the long, timed-out failures — only about a quarter of the frames. Watch the far starts. The left policy shoves the block toward the target and stalls when it needs to rotate; the right one commits to the turn. Same method, same compute, different data — and the data is what you can see.

That is the whole chapter. In 1.1 the network was the thing you built and the data was the thing you had. Here it flips: the data is the thing you build, and the network is a fixed function that turns it into behavior. Curating is programming, and this chapter is about doing it with measurements instead of vibes.

Open in Colabsoon

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

The problem

Chapter 1.1 ended on a confession. Behavior cloning fits the average action, and when two demonstrations disagree at the same state, the average of two good actions can be a bad one. We waved at that flaw and moved on. Now look at what it does to real data.

Your ch0.4 recordings are not uniform. Some runs you drove cleanly and the block clicked into place. Some you fumbled — you pushed when you should have rotated, chased the block into a wall, ran out of patience and the episode timed out with the T sitting crooked two centimeters off target. Every one of those frames is a labeled (observation, action) pair, and behavior cloning believes labels. Show it a demonstration that wandered and never rotated, and in the states that demonstration visited, you have taught the policy to wander and not rotate.

So the naive move — "I recorded 500 episodes, train on all 500" — is not obviously right. Some of those episodes are teaching the wrong thing. The question this chapter makes measurable: can you do better by throwing data away? And if so, how do you decide what to throw?

Build

curate.py is one file, about 340 lines, in six regions: setup, data, quality, curate, train, report. It scores every episode, filters on the score, re-trains 1.1's BC twice (raw, then curated), and reports the gap. No new machine learning — the model and training loop are chapter 1.1's, deliberately copied so "re-train on the curated data" is something you can read, not import.

Setup

curate.py#setupsha256:d5f4efa450…
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 ch1.1's bc.py).sys.path.insert(0, str(Path(__file__).resolve().parents[3])) from curriculum.common.device import banner, detect_device  # noqa: E402from curriculum.common.envs.pusht import PushTEnv, gen_demos, wrap_angle  # noqa: E402from curriculum.common.seeding import set_seed  # noqa: E402 # The two halves of your simulated ch0.4 session. `--data` overrides this with# your REAL recordings; without it we build a reproducible stand-in so the# chapter's numbers reproduce on your machine (same honesty as bc.py's smoke).CAREFUL_NOISE = 0.05  # a steady hand: the expert barely wobbles, reaches the goalSLOPPY_NOISE = 0.70   # a shaky hand: large action noise, most runs wander and fail parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--data", type=Path, default=None,                    help="an existing LeRobot dataset to curate (your ch0.4 session). Omitted => build the reproducible careful+sloppy stand-in")parser.add_argument("--out", type=Path, default=Path("outputs/ch1.2-curate"))parser.add_argument("--careful", type=int, default=250, help="stand-in only: # careful (mostly-good) episodes")  # smoke: 3parser.add_argument("--sloppy", type=int, default=250, help="stand-in only: # sloppy (mostly-bad) episodes")   # smoke: 3parser.add_argument("--epochs", type=int, default=300)  # cpu-laptop: minutes (trains TWICE) | smoke: 3parser.add_argument("--hidden_dim", type=int, default=256)parser.add_argument("--eval_episodes", type=int, default=50)  # held-out reset seeds | smoke: 3parser.add_argument("--knn", type=int, default=8, help="neighbours per frame for the disagreement estimate")parser.add_argument("--seed", type=int, default=0, help="seeds data generation, the split, the init, and the shuffle")parser.add_argument("--break", dest="break_mode", choices=("low_disagreement", "shortest"), default=None,                    help="Break It: replace the honest outcome filter with a plausible-but-wrong heuristic")parser.add_argument("--device", choices=("cpu", "cuda", "mps"), default=detect_device())  # 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)if args.smoke:  # smoke pins everything the CI byte-compare depends on    args.careful, args.sloppy, args.epochs, args.eval_episodes, args.device = 3, 3, 3, 3, "cpu"banner("ch1.2-curate", device=args.device)args.out.mkdir(parents=True, exist_ok=True)device = torch.device(args.device)if args.rerun:    import rerun as rr    rr.init("zero2robot/ch1.2-curate", spawn=False)    rr.save(str(args.out / "curate.rrd"))

The one new idea in the flags is the data source. --data points at a real LeRobot dataset — your ch0.4 session. Omit it and the file builds a reproducible stand-in: 250 "careful" episodes and 250 "sloppy" ones, a shaky hand modeled as a large action-noise std. The stand-in exists so the chapter's numbers reproduce on your machine; the lesson is identical on your own recordings, only the digits move. --break is the Break It flag, and we earn it at the end.

Data

curate.py#datasha256:ab4d137541…
# A curation module needs a dataset with BOTH good and bad episodes in it, or# there is nothing to curate. Real teleop is exactly that mixture; the scripted# expert with a large noise std is the reproducible stand-in (careful hand vs# shaky hand). Both halves are written by the SAME gen_demos as every other# PushT dataset, so `observation.state`/`action` are byte-identical in layout to# what bc.py trains on — the curated set drops straight back into chapter 1.1.def load_lerobot(root: Path):    from lerobot.datasets.lerobot_dataset import LeRobotDataset  # heavy import — after cheap failures     frames = LeRobotDataset("local/curate", root=root).hf_dataset.with_format("numpy")    return (np.stack(frames["observation.state"]), np.stack(frames["action"]),            np.asarray(frames["episode_index"]))  def build_stand_in(out: Path, careful: int, sloppy: int, seed: int):    """Careful + sloppy halves via gen_demos, concatenated into one array set.    Regenerated every run (never reuse a leftover dir): a cache from a different    --seed would silently train on the wrong data. gen_demos is deterministic,    so same seed -> bit-identical episodes whether just built or rebuilt."""    obs_parts, act_parts, eid_parts, next_eid = [], [], [], 0    for name, count, noise, seed0 in [("careful", careful, CAREFUL_NOISE, seed),                                      ("sloppy", sloppy, SLOPPY_NOISE, seed + 5000)]:        root = out / f"raw-{name}"        if root.exists():            shutil.rmtree(root)        gen_demos.main(["--episodes", str(count), "--seed", str(seed0), "--noise", str(noise),                        "--out", str(root), "--no-video", "--repo-id", f"zero2robot/pusht_{name}"])        o, a, e = load_lerobot(root)        obs_parts.append(o)        act_parts.append(a)        eid_parts.append(e + next_eid)        next_eid += int(e.max()) + 1    return np.concatenate(obs_parts), np.concatenate(act_parts), np.concatenate(eid_parts)  if args.data is not None:    if not (args.data / "meta" / "info.json").is_file():        sys.exit(f"no dataset at {args.data} — record one in ch0.4 first, or omit --data to build the stand-in")    obs, actions, episode_ids = load_lerobot(args.data)else:    obs, actions, episode_ids = build_stand_in(args.out, args.careful, args.sloppy, args.seed) episodes = np.unique(episode_ids)  # one row per demonstration; the unit we score and filterprint(f"raw dataset: {len(episodes)} episodes / {len(obs)} frames")

Whichever source you use, the file ends this region holding three plain arrays: observations, actions, and an episode id per frame. The episode is the unit we are about to judge — not the frame. A single bad frame is noise the average can absorb; a bad episode is a coherent stretch of wrong behavior covering a whole region of the state space, and that is what curation removes. Note that both halves of the stand-in are written by the same gen_demos as every other PushT dataset in this book, so the curated set you produce drops straight back into 1.1 with no format wrangling.

Quality

This is the chapter's core: three ways to measure demonstration quality using nothing but the dataset on your disk — no environment, no privileged information a learner grading their own recordings would not have.

curate.py#qualitysha256:5dca083764…
# Three honest, dataset-ONLY quality signals — no environment rollouts, nothing# the learner could not compute from the recording sitting on their disk.def episode_reached_goal(ep_obs: np.ndarray) -> bool:    """Did the block finish inside the task tolerance? The last recorded frame    carries tee_xy and sin/cos(yaw); decode and compare to PushT's own limits.    A demonstration that never reached the goal is a bad label source."""    last = ep_obs[-1]    pos_err = float(np.hypot(last[2], last[3]))    ang_err = float(abs(wrap_angle(np.arctan2(last[4], last[5]))))    return pos_err < PushTEnv.POS_TOL and ang_err < PushTEnv.ANG_TOL  def frame_disagreement(obs: np.ndarray, actions: np.ndarray, episode_ids: np.ndarray, k: int) -> np.ndarray:    """For each frame, how much do the demonstrations DISAGREE near its state?    Find the k nearest frames from OTHER episodes (same-episode neighbours are    temporal near-duplicates and trivially agree) and take the spread of their    actions. High spread = a state where good demonstrators chose differently —    the multimodality chapter 1.1 said MSE would average into mush."""    span = obs.max(0) - obs.min(0)    obs_n = torch.from_numpy(((obs - obs.min(0)) / np.where(span < 1e-4, 1.0, span)).astype(np.float32))    act_t = torch.from_numpy(actions.astype(np.float32))    eid_t = torch.from_numpy(episode_ids)    out = np.zeros(len(obs_n), dtype=np.float64)    for start in range(0, len(obs_n), 1000):  # chunk the distance matrix so it fits in memory        query, query_eid = obs_n[start:start + 1000], eid_t[start:start + 1000]        dist = torch.cdist(query, obs_n)        dist[query_eid[:, None] == eid_t[None, :]] = float("inf")  # mask same-episode neighbours        neighbours = act_t[dist.topk(k, largest=False).indices]    # (chunk, k, 2)        # population std (correction=0) — matches numpy's default so the exercise        # completion can use either library; the RANKING is what curation uses.        out[start:start + 1000] = neighbours.std(dim=1, correction=0).mean(dim=1).numpy()    return out  def coverage(ep_starts: np.ndarray, bins: int = 6) -> float:    """Fraction of a bins x bins grid over the arena that some episode STARTS in.    A dataset can be large and still blind to whole regions of the state space."""    cells = np.clip(((ep_starts + 0.3) / 0.6 * bins).astype(int), 0, bins - 1)    return len(set(map(tuple, cells))) / (bins * bins)  disagree = frame_disagreement(obs, actions, episode_ids, args.knn)# Roll the per-frame signals up to one row per episode.reached = np.array([episode_reached_goal(obs[episode_ids == e]) for e in episodes])lengths = np.array([int((episode_ids == e).sum()) for e in episodes])ep_disagree = np.array([disagree[episode_ids == e].mean() for e in episodes])ep_starts = np.array([obs[episode_ids == e][0][2:4] for e in episodes])print(f"quality: {int(reached.sum())}/{len(episodes)} episodes reached the goal | "      f"mean disagreement {ep_disagree.mean():.4f} | coverage {coverage(ep_starts):.2f}")if args.rerun:    for e in range(len(episodes)):  # scrub the per-episode quality signals in rerun        rr.set_time("episode", sequence=e)        rr.log("quality/reached_goal", rr.Scalars([float(reached[e])]))        rr.log("quality/disagreement", rr.Scalars([ep_disagree[e]]))        rr.log("quality/length", rr.Scalars([float(lengths[e])]))

Outcome. Did the block finish inside the task's own tolerance? The last recorded frame carries the block's pose; decode it and compare to PushT's POS_TOL and ANG_TOL. This is the bluntest signal and the most important one: a demonstration that did not accomplish the task is a bad source of labels for accomplishing the task. On the stand-in, 288 of 500 episodes reached the goal.

Disagreement. For each frame, find the nearest frames from other episodes and measure how much their actions vary. High disagreement means: near this state, demonstrators chose visibly different things. This is chapter 1.1's villain made into a number — the multimodality that MSE blurs into mush. Hold onto how this one behaves; it is the trap.

Coverage. What fraction of the arena do your episodes even start in? A dataset can be large and still blind to whole regions. Curation trades coverage against quality, and you want to watch that trade, not make it by accident.

Curate

curate.py#curatesha256:73dc924711…
# The honest filter keeps the episodes that reached the goal. Break It swaps in# a plausible-but-wrong ranking, keeping the SAME NUMBER of episodes so the only# thing that changes is WHICH ones — isolating the heuristic from dataset size.kept_by_outcome = episodes[reached]if args.break_mode is None:    kept = kept_by_outcome    selection = "outcome (reached the goal)"else:    budget = len(kept_by_outcome)  # match honest curation's episode count exactly    if args.break_mode == "low_disagreement":        order = episodes[np.argsort(ep_disagree)]          # "keep the demos that agree" — the trap    else:  # shortest        order = episodes[np.argsort(lengths)]              # "keep the efficient demos" — also a trap    kept = order[:budget]    selection = f"BREAK:{args.break_mode}" keep_mask = np.isin(episode_ids, kept)kept_starts = np.array([obs[episode_ids == e][0][2:4] for e in kept])print(f"curated ({selection}): {len(kept)}/{len(episodes)} episodes / {int(keep_mask.sum())} frames | "      f"coverage {coverage(kept_starts):.2f} | mean disagreement {ep_disagree[np.isin(episodes, kept)].mean():.4f}")

The honest filter is one line: keep the episodes that reached the goal. Notice the Break It path keeps the same number of episodes by a different ranking — so when we get there, the only variable is which episodes, never how many. That is what makes the comparison fair, and it is the difference between an experiment and an anecdote.

Train and report

curate.py#trainsha256:ee611bf311…
# A compact copy of chapter 1.1's behavior cloning — deliberately duplicated# (single-file doctrine), so "re-train 1.1 on the curated data" is something you# can read here, not a black box you import. Same 3-layer MLP, same in-model# min-max normalization, same cosine-decayed Adam. We call it TWICE (raw, then# curated) and compare the only number that matters: held-out success rate.class BCPolicy(nn.Module):    def __init__(self, hidden_dim, 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))     def forward(self, obs):  # (B,10) raw -> normalize -> net -> denormalize -> (B,2) raw        normalized_obs = 2.0 * (obs - self.obs_min) / self.obs_range - 1.0        normalized_action = self.net(normalized_obs.clamp(-1.0, 1.0))        return (normalized_action + 1.0) / 2.0 * self.act_range + self.act_min  def train_and_eval(frame_mask: np.ndarray, tag: str) -> dict:    """Fit BC on the frames selected by `frame_mask`, roll out on held-out seeds."""    set_seed(args.seed)  # each policy starts from the same seeded init, so the comparison is fair    ds_obs, ds_act = obs[frame_mask], actions[frame_mask]    obs_min, act_min = ds_obs.min(0), ds_act.min(0)    obs_range = np.where(ds_obs.max(0) - obs_min < 1e-4, np.float32(1.0), ds_obs.max(0) - obs_min)    act_range = np.where(ds_act.max(0) - act_min < 1e-4, np.float32(1.0), ds_act.max(0) - act_min)    policy = BCPolicy(args.hidden_dim, obs_min, obs_range, act_min, act_range).to(device)    train_obs = torch.from_numpy(ds_obs).to(device)    train_actions = torch.from_numpy(ds_act).to(device)    optimizer = torch.optim.Adam(policy.parameters(), lr=1e-3)    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs)    shuffle = torch.Generator().manual_seed(args.seed)    train_loss = float("nan")    for epoch in range(args.epochs):        epoch_loss, num_batches = 0.0, 0        for batch in torch.randperm(len(train_obs), generator=shuffle).split(128):            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.log(f"policy/loss/train/{tag}", rr.Scalars([loss.item()]))        scheduler.step()        train_loss = epoch_loss / num_batches     env = PushTEnv()    policy.eval()    successes, returns, near, far = 0, [], [0, 0], [0, 0]    for episode in range(args.eval_episodes):        obs_now = env.reset(seed=10_000 + args.seed + episode)  # held-out starts, never trained on        start_dist = float(np.hypot(obs_now[2], obs_now[3]))        episode_return, done, info = 0.0, False, {}        while not done:            with torch.no_grad():                action = policy(torch.from_numpy(obs_now).to(device).unsqueeze(0))[0].cpu().numpy()            obs_now, reward, done, info = env.step(action)            episode_return += reward        successes += bool(info["success"])        returns.append(episode_return)        bucket = near if start_dist < 0.15 else far  # split by difficulty: near vs far starts        bucket[0] += bool(info["success"])        bucket[1] += 1    return {"tag": tag, "n_frames": int(frame_mask.sum()),            "success_rate": successes / args.eval_episodes,            "mean_return": float(np.mean(returns)), "final_train_loss": train_loss,            "near_success": near, "far_success": far}  raw_result = train_and_eval(np.ones(len(obs), dtype=bool), "raw")curated_result = train_and_eval(keep_mask, "curated")

This is 1.1, copied. Same three-layer MLP, same in-model min-max normalization, same cosine-decayed Adam, same held-out reset seeds. We reseed before each of the two runs so the raw policy and the curated policy start from the identical initialization — the dataset is the only thing that differs between them. The eval also splits held-out episodes by starting distance, near versus far, because where the two policies differ turns out to matter more than by how much.

curate.py#reportsha256:623630eb46…
# The payoff, stated as one number: does curating — on FEWER episodes — lift the# held-out success rate? And WHERE (the far/near split shows the sloppy episodes# were poisoning the hard starts). rerun gets the same two bars to eyeball.delta = curated_result["success_rate"] - raw_result["success_rate"]for result in (raw_result, curated_result):    print(f"{result['tag']:8s}: {result['n_frames']:6d} frames -> success {result['success_rate']:.3f}  "          f"(near {result['near_success'][0]}/{result['near_success'][1]}, "          f"far {result['far_success'][0]}/{result['far_success'][1]})")print(f"delta (curated - raw): {delta:+.3f}")if args.rerun:    rr.set_time("episode", sequence=len(episodes))    rr.log("payoff/success/raw", rr.Scalars([raw_result["success_rate"]]))    rr.log("payoff/success/curated", rr.Scalars([curated_result["success_rate"]])) metrics = {    "seed": args.seed,    "smoke": bool(args.smoke),    "break_mode": args.break_mode or "none",    "n_episodes": int(len(episodes)),    "n_reached_goal": int(reached.sum()),    "n_kept": int(len(kept)),    "mean_disagreement_raw": round(float(ep_disagree.mean()), 6),    "mean_disagreement_kept": round(float(ep_disagree[np.isin(episodes, kept)].mean()), 6),    "coverage_raw": round(coverage(ep_starts), 6),    "coverage_kept": round(coverage(kept_starts), 6),    "raw_success_rate": round(raw_result["success_rate"], 6),    "curated_success_rate": round(curated_result["success_rate"], 6),    "delta_success_rate": round(delta, 6),    "raw_final_train_loss": round(raw_result["final_train_loss"], 6),    "curated_final_train_loss": round(curated_result["final_train_loss"], 6),}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")print(f"metrics: {args.out / 'metrics.json'}") # Write the curated dataset so you can re-run chapter 1.1 on it directly:#   python curriculum/phase1_imitation/ch1.1_bc/bc.py --data <out>/curated-datasetif not args.smoke:    curated_root = args.out / "curated-dataset"    if curated_root.exists():        shutil.rmtree(curated_root)    from lerobot.datasets.lerobot_dataset import LeRobotDataset    dataset = LeRobotDataset.create(repo_id="zero2robot/pusht_curated", fps=PushTEnv.CONTROL_HZ,                                    features=gen_demos.build_features(False), root=curated_root,                                    robot_type="pusher_2d", use_videos=False)    for e in kept:        for i in np.nonzero(episode_ids == e)[0]:            dataset.add_frame({"observation.state": obs[i], "action": actions[i], "task": gen_demos.TASK})        dataset.save_episode()    dataset.finalize()    print(f"curated dataset: {curated_root}  (re-train ch1.1 on it: bc.py --data {curated_root})")if args.rerun:    print(f"recording: {args.out / 'curate.rrd'} — open it with: rerun {args.out / 'curate.rrd'}")

Run it

python curriculum/phase1_imitation/ch1.2_curate/curate.py --seed 0 --device cpu
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~4.51 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~12.76 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

The result, with the default stand-in:

episodes held-out success near starts far starts
raw 500 8% 2/19 2/31
curated (outcome) 288 22% 3/19 8/31

Curating nearly tripled the success rate while removing 212 episodes. Read the two right-hand columns before you celebrate the left one: near the goal the policies are about the same, and the entire gap is on the far starts, where the sloppy episodes had been teaching the network to shove-and-stall. The bad data was not uniformly bad — it was poisoning a specific, identifiable region, and the outcome filter cut exactly that region out. Open the recording and scrub quality/disagreement and the two payoff/success bars:

rerun outputs/ch1.2-curate/curate.rrd

And 22% is still low — this is little data, briefly trained, so both policies fit on a laptop CPU in a few minutes. Scale either dataset up and both numbers climb; the point is that at equal method and compute, the curated data wins, and it wins by fixing failures the loss curve never mentioned.

Break it

Here is the move that should work and does not. Chapter 1.1 taught you that disagreement is behavior cloning's enemy. So curate on it directly: keep the episodes that agree most with their neighbors, and throw out the high-disagreement ones as noise.

python curriculum/phase1_imitation/ch1.2_curate/curate.py --seed 0 --device cpu --break low_disagreement

It keeps the same 288 episodes the honest filter would — just chosen by lowest disagreement instead of by outcome. And it makes the policy worse:

held-out success mean disagreement of kept set far starts
raw 8% 0.443 2/31
curated (outcome) 22% 0.380 8/31
break (low_disagreement) 12% 0.378 5/31

Stare at the middle column. The break's kept set has the lowest disagreement of the three — by the logic of chapter 1.1 it should be the best data. It produces a policy well short of honest curation, and it gives up on exactly the far starts curation rescued (5/31 against curation's 8/31).

The mechanism is the whole lesson. Demonstrator disagreement does not measure label noise. It measures difficulty. The states where good demonstrators diverge are the hard ones — the far approaches, the rotations, the recoveries — because those are the states with more than one reasonable thing to do. Rank episodes by low disagreement and you are ranking them by easiness, and you keep a tidy dataset of near-goal nudges that teaches a policy which fails the instant the task gets hard. The metric was real. Optimizing it was the mistake.

The transferable warning: a quality signal is not a quality objective. A number that correlates with good data in your analysis can point somewhere terrible when you filter on it, because filtering changes the distribution the number was describing. Outcome — did the task get done — is robust to this because it is defined by the goal, not by the data's own internal agreement. When you invent a data-quality score, ask what a dataset that maximizes it looks like before you trust it, because your filter will find that dataset.

Exercises

Four, in exercises/. Two ask you to commit to a prediction before the run is allowed to answer — curated-versus-raw, and the Break It. One is a bug-hunt where a single mask polarity error quietly curates the failures and every metric still prints. One has you implement the disagreement signal from its definition, since it is the number the whole chapter turns on.

What's next

You now have two levers on a behavior-cloning policy: the method (1.1) and the data (1.2). You have also seen the ceiling. Even the curated policy tops out well short of the expert, because it is still averaging, still committing to one action per state at 10 Hz, still unable to represent "go left OR go right" at a state where both are correct. No amount of data curation fixes that — it is the model class. Next chapter the policy stops predicting one instant at a time and commits to a chunk of the future at once, and the multimodality you have spent two chapters measuring finally gets a model that can hold two plans in mind instead of averaging them into a bad third one.

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

    Objective tested: the chapter's central claim — that curating your data (which THROWS EPISODES AWAY) can beat training on everything you recorded.

    THE SETUP. curate.py builds a raw dataset of 500 episodes: half careful (mostly reach the goal), half sloppy (mostly wander and fail). The honest filter keeps only the episodes that reached the goal — fewer than half — and re-trains chapter 1.1's BC on that smaller set. Both policies are evaluated on the same held-out reset seeds.

    PREDICT before you run: the curated policy trains on FEWER episodes than raw. On held-out success rate...

    • A) raw wins — more data is more data; you never help BC by deleting demos

    • B) curated wins despite the smaller dataset — the sloppy episodes were poisoning the states they covered, and dropping them is worth more than the frames you lose

    • C) they tie — BC averages, so a few bad demos wash out and change nothing

    (~5 minutes on CPU — it trains two policies at the chapter's default scale). Estimated learner time: 20 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.

  2. predict-then-run

    Exercise 2

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

    Objective tested: why demonstrator disagreement measures DIFFICULTY, not noise — and why using it as a quality knob backfires.

    THE SETUP. Chapter 1.1 taught you that disagreement is BC's enemy: when demos disagree, MSE averages them into mush. So here is the tempting move — curate by KEEPING THE EPISODES THAT AGREE MOST with their neighbours and throwing out the high-disagreement ones as "noise." That is exactly what --break low_disagreement does. It keeps the same NUMBER of episodes the honest outcome filter would, so the only thing that changes is WHICH ones.

    You will run three policies: raw (everything), curated (kept by outcome), and break (kept by lowest disagreement).

    PREDICT before you run — the break policy's held-out success will land...

    • A) above curated: minimizing disagreement is exactly what 1.1 said to do
    • B) below raw: keeping agreeable demos is strictly worse than keeping all of them
    • C) between the two — better than raw, but WORSE than honest curation, because the high-disagreement episodes it discarded were the HARD ones (far starts, rotations), and dropping them blinds the policy to exactly those states

    (~10 minutes on CPU: two default-scale curate runs). NOTE the trap only shows at full scale — at a small eval set the noise hides it, which is chapter 1.6's whole point. Estimated learner time: 25 minutes.

    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.

  3. bug-hunt

    Exercise 3

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

    This is the chapter artifact with EXACTLY ONE conceptual bug injected, in the curation step: a mask polarity error. Every quality metric still computes and prints; the run completes and writes metrics.json. The tell is the payoff — the "curated" policy comes out NO BETTER than raw, sometimes worse, and the delta that was supposed to be positive is not.

    Before you flip a character, write one sentence: the curated policy is no better than raw, so which of the two outcome groups is the filter actually keeping — and what does that make the data you kept?

    Find it by asking the one question the curate region turns on: of the two groups the outcome signal splits your episodes into, WHICH ONE is the good data, and which one is curate.py actually keeping? Fix the line, then re-run checks.py until the curated policy beats raw again.

    Objective tested: filter bad episodes / read a data-quality result honestly.

    Run: python ex3_bughunt_filter.py --smoke --seed 0 --no-rerun Estimated learner time: 20 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.2_curate/exercises/suggested/checks.py -k ex3
  4. code-completion

    Exercise 4

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

    Objective tested: what "demonstrator disagreement" actually IS, by implementing it. This is the quality signal the chapter leans on hardest and the one the Break It abuses — so you should be able to write it from the definition.

    THE TASK. Fill in frame_disagreement below. For each frame it must:

    1. normalize the observations per-dimension to make distances comparable (a constant dimension has range 0 — leave it alone, do not divide by zero),
    2. for each frame, find its k nearest frames FROM OTHER EPISODES (neighbours inside the same episode are temporal near-duplicates and would trivially agree — exclude them), and
    3. return, per frame, the spread — POPULATION standard deviation (numpy's default; in torch pass correction=0) — of those neighbours' actions, averaged over the action dimensions.

    A high value means: near this state, demonstrators chose visibly different actions. The chapter artifact does this with a chunked torch.cdist; you may use torch or plain numpy. checks.py compares your output to the reference on a small fixed fixture.

    Run the check: pytest curriculum/phase1_imitation/ch1.2_curate/exercises/suggested/checks.py -k ex4 Estimated learner time: 30 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.2_curate/exercises/suggested/checks.py -k ex4

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromcurate.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
d5f4efa450247cfdccbbf6188583a3207771009bcb42b7d67c647eff78609165
#data
ab4d137541ef0883bd717359210ace4996be1d01f796b00fcbcc5088ded02465
#quality
5dca083764ce3d83488ad80df99f7cf5e360c14c274b9aa85be1c64d604ccfa6
#curate
73dc924711fd9d99e5462eb33e2f3d6abaad5aea90708c7604f3e94e022f2853
#train
ee611bf3111028e990877ccf19c7eed6a0506e4afa26d342e34541db2bf722cf
#report
623630eb461c24d585a72876db953dc46ffe8bcbffb913309cffd2c698a01e69