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
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
# 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.
# 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
# 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
# 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.
# 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
| cpu-laptop | expected wall-clock on cpu-laptop: ~4.51 min (measured) | measured |
| mps | wall-clock on mps: not yet measured | pending |
| t4 | expected wall-clock on t4: ~12.76 min (measured) | measured |
| 4090 | wall-clock on 4090: not yet measured | pending |
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.