zero2robot · Phase 0 · Foundationsch0.0-quickstart · quickstart.py

Chapter 0.0

Your First Robot Policy in a Few Minutes

By the end you can

  1. Get a win: train a policy that visibly pushes the T-block onto a target it never saw, in a few minutes on a laptop
  2. See the whole loop end to end (demonstrations -> tiny network -> rollout -> a success number vs a random floor) before learning why any piece works
  3. Read a success rate honestly: the trained policy solves ~half of held-out starts, the random floor solves none, and that gap is the point
  4. Leave with the map: every simplification here (scripted demos, in-memory data, plain-MSE MLP) is a real chapter ahead (0.4, 1.1), so Phase 0 reads as "now understand what you just did"

See it work

live

You'll train this, and it works

The tiny behavior-cloning policy you train in this chapter, replayed on a held-out start: the amber pusher gets behind the block and walks it home. The block starts 0.200 m from the target and lands at 0.028 m, inside the 0.03 m ring. One real success, recorded from quickstart.py --seed 0.

PushT — trained policy's winning rollouttarget
115/115
play or scrub the recorded rollout · poster reads with JS off
held-out success · 25 random starts the policy never trained on
trained policy
12/25 · 48%
random actions
0/25 · 0%

A modest, real win: this seed reaches 48% of held-out starts (12 of 25; 28–48% across seeds 0–3), against a 0% random floor. The replay above is one of those 12 successes — not a solved task. ch1.1 is the same method done right and reaches higher.

Real recorded rollout from quickstart.py --seed 0 (300 expert demos, 600 epochs, cpu). A deliberately tiny budget shown before the mechanics — the fast taste of the whole loop, not the best you can do. Scene geometry, the 115-frame path, and the 12/25 vs 0/25 headline are all read from the chapter's committed vizdata; poster reads with JS off.

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

Runs this chapter's generated notebook on a free Colab T4 — no local install, no account beyond a Google login.

Or run it locally:

python curriculum/phase0_foundations/ch0.0_quickstart/quickstart.py --seed 0

Run from the repo root after the one-time setup (git clone + uv sync). Every artifact also takes --smoke for a fast check.

See it work

That blue T-shape is being pushed onto the green target by a neural network. Nobody told it what a "T" is, or where the target is, or which way to shove. It was shown a few hundred examples of a pushing task being done, and asked to copy them. And this is the copy, running on a start it was never shown. No planner, no reward, no search. A stack of matrix multiplies that learned to push.

You are going to train that network yourself, right now, before we explain a single thing about how it works. That order is deliberate. The rest of Phase 0 takes the simulator, the data format, and the learning apart piece by piece; it reads very differently once you've already watched the whole thing succeed.

The whole loop, once

Everything is in one file, quickstart.py, and it does four things in order: gets demonstrations, trains a network to imitate them, rolls the result out on fresh starts, and prints how often it worked. Run it:

python curriculum/phase0_foundations/ch0.0_quickstart/quickstart.py --seed 0
wall-clock · rendered from wallclock.csvone source · measured tiers
cpuexpected wall-clock on cpu-laptop: ~2.83 min (measured)measured
mpsexpected wall-clock on mps: ~2.84 min (measured)measured
t4expected wall-clock on t4: ~1.7 min (measured)measured
l40sexpected wall-clock on l40s: ~0.94 min (measured)measured

Demonstrations. A demonstration is just a recording of the task being done well: at each instant, what the world looked like and what the driver did about it. Here the "driver" is a scripted expert that ships with the PushT environment, a hand-tuned controller somebody wrote so you don't have to. We run it a few hundred times and keep what it saw and did.

quickstart.py#demossha256:df8eee938b…
# A demonstration is a list of (observation, action) pairs — what the world# looked like, and what a competent driver did about it. In ch0.4 that driver# is YOU, with a mouse. Here it's the scripted expert from the shared PushT env# (a hand-tuned controller that took real engineering to write — that effort is# exactly what behavior cloning lets us skip). We drive it a few hundred times# and just remember what it saw and did. No dataset file, no disk: two arrays.def collect_demos(n_episodes: int, seed: int):    env = PushTEnv()    observations, actions, expert_successes = [], [], 0    for i in range(n_episodes):        obs = env.reset(seed + i)  # seed+i per episode: each demo is a different, reproducible start        expert = ScriptedExpert(noise=0.0, seed=seed + i)        done, info = False, {}        while not done:            action = expert.action(env)            observations.append(obs.copy())  # the obs we acted ON, never the terminal one            actions.append(action.copy())            obs, _, done, info = env.step(action)        expert_successes += bool(info["success"])    return np.asarray(observations, np.float32), np.asarray(actions, np.float32), expert_successes  demo_obs, demo_actions, expert_successes = collect_demos(args.demos, args.seed)print(f"demos: {args.demos} expert episodes ({expert_successes} solved) -> {len(demo_obs)} (obs, action) pairs")

A tiny network. Three linear layers (a linear layer is just a matrix multiply plus an added offset). Its whole job is to map "the world looks like this" to "so do that". Boring on purpose.

quickstart.py#modelsha256:8d4a0b3bde…
# Rescale every input and output dimension to [-1, 1] using its min/max over the# demos — meters, sin/cos and velocities all live on different scales, and a# network learns far faster when they don't. The stats live INSIDE the model so# the policy is self-contained. (ch1.1 shows how a lie in these stats sinks a# policy that every loss curve swears is healthy.)def minmax(x):  # -> (min, range) with zero-width dims (the constant target pose) held safe    lo = x.min(0)    span = np.where(x.max(0) - lo < 1e-4, np.float32(1.0), x.max(0) - lo)    return torch.from_numpy(lo), torch.from_numpy(span.astype(np.float32))  class BCPolicy(nn.Module):    """obs float32[10] -> action float32[2]. A 3-layer MLP: no planner, no    search, no idea what a "T" is. It maps "when the world looks like this" to    "do that", and that is enough to push a block home most of the time."""     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, stat)  # travels with the weights, never trained     def forward(self, obs):        normalized = (2.0 * (obs - self.obs_min) / self.obs_range - 1.0).clamp(-1.0, 1.0)        action = self.net(normalized)        return (action + 1.0) / 2.0 * self.act_range + self.act_min  obs_min, obs_range = minmax(demo_obs)act_min, act_range = minmax(demo_actions)policy = BCPolicy(args.hidden_dim, obs_min, obs_range, act_min, act_range).to(device)

Training. Show it a demonstrated state, ask what it would do, penalize the gap to what the expert actually did, repeat. That's the entire method. The loop is short enough to read in one sitting.

quickstart.py#trainsha256:b3fe213fe7…
# The entire training method: predict the action the expert took, penalize the# squared error, repeat. No DataLoader, no early stopping — the demos are two# tensors in memory and a "batch" is a slice of a shuffled index permutation.train_obs = torch.from_numpy(demo_obs).to(device)train_actions = torch.from_numpy(demo_actions).to(device)optimizer = torch.optim.Adam(policy.parameters(), lr=1e-3)scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs)  # decay to 0 so the last epochs settleshuffle = torch.Generator().manual_seed(args.seed)for epoch in range(args.epochs):    epoch_loss, num_batches = 0.0, 0    for batch in torch.randperm(len(train_obs), generator=shuffle).split(256):        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    scheduler.step()    if args.rerun:        rr.set_time("epoch", sequence=epoch)        rr.log("policy/loss", rr.Scalars([epoch_loss / num_batches]))    if epoch % 100 == 0 or epoch == args.epochs - 1:        print(f"epoch {epoch:3d}  loss {epoch_loss / num_batches:.5f}")

The honest test. Loss on the demonstrations tells you the network memorized them; it does not tell you the policy can push a block. So we turn it loose on 25 starting positions it never trained on and count the ones where the block actually reaches the target. And we roll out a random policy on the same 25 starts as a floor. If "it works" is going to mean anything, it has to beat flailing.

quickstart.py#evalsha256:141cc52dc0…
# The only test that counts: turn the policy loose on starts it never trained# on and see whether the block reaches the target. Eval seeds are offset by# 10_000 so they can't collide with a demo start. We also roll out a RANDOM# policy on the same starts — the floor the trained policy has to clear for# "it works" to mean anything. And we save the first success for the browser.def rollout(act_fn, seed, record=False):    env = PushTEnv()    obs, done, info = env.reset(seed), False, {}    frames = []    while not done:        action = act_fn(obs)        if record:            px, py = env.pusher_pos            tx, ty, tyaw = env.tee_pose            pos_err, _ = env._errors()            frames.append([round(float(v), 5) for v in (px, py, tx, ty, tyaw, pos_err)])        obs, _, done, info = env.step(action)    return bool(info["success"]), frames  policy.eval()  def policy_action(obs):    with torch.no_grad():        return policy(torch.from_numpy(obs).to(device).unsqueeze(0))[0].cpu().numpy()  def random_action(_obs):    return rng.uniform(-1.0, 1.0, size=PushTEnv.ACT_DIM).astype(np.float32)  successes, saved_rollout = 0, Nonefor episode in range(args.eval_episodes):    solved, frames = rollout(policy_action, 10_000 + args.seed + episode, record=True)    successes += solved    if solved and saved_rollout is None:  # keep the first honest success for replay        saved_rollout = {"seed": 10_000 + args.seed + episode, "frames": frames}    if args.rerun:        rr.set_time("eval_episode", sequence=episode)        rr.log("eval/success", rr.Scalars([float(solved)]))random_successes = sum(rollout(random_action, 10_000 + args.seed + e)[0] for e in range(args.eval_episodes)) success_rate = successes / args.eval_episodesrandom_rate = random_successes / args.eval_episodesprint(f"\n  trained policy: {successes}/{args.eval_episodes} solved  ({success_rate:.0%})")print(f"  random policy:  {random_successes}/{args.eval_episodes} solved  ({random_rate:.0%})   <- the floor you cleared")if success_rate > random_rate:  # the real run: a visible, honest win over the random floor    print("\nYou trained a robot policy and it works. Now go build every piece of it, starting in ch0.1.")else:  # only the tiny --smoke budget lands here; it exists to check the plumbing, not to win    print("\n(smoke budget — too small to solve the task; the full run is where it works.)") metrics = {    "seed": args.seed,    "demos": args.demos,    "epochs": args.epochs,    "eval_episodes": args.eval_episodes,    "expert_successes": expert_successes,    "successes": successes,    "success_rate": round(success_rate, 6),    "random_successes": random_successes,    "random_rate": round(random_rate, 6),    "smoke": bool(args.smoke),}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")# The rollout the site replays: one real, held-out success (never cherry-picked# beyond "the first one"). None only if the tiny --smoke budget solved nothing.rollout_path = args.out / "rollout.json"rollout_path.write_text(json.dumps({    "success_rate": round(success_rate, 6),    "random_rate": round(random_rate, 6),    "target_pose": [round(float(v), 5) for v in PushTEnv.TARGET_POSE],    "rollout": saved_rollout,}, indent=2) + "\n")print(f"\nmetrics: {args.out / 'metrics.json'}   rollout: {rollout_path}")if args.rerun:    print(f"recording: {args.out / 'quickstart.rrd'} — open it with: rerun {args.out / 'quickstart.rrd'}")

At --seed 0 the trained policy solves 12 of 25 held-out starts (48%). The random floor solves 0 of 25. That gap, from nothing to roughly half, is the whole point of this chapter, and you produced it in a few minutes on a laptop with no GPU. (It's seed-dependent: across the first few seeds the policy lands between about a quarter and a half of the starts. Never zero, always well clear of the floor.)

What we skipped, and where it comes back

This was the shortest honest path to a working policy, which means it cut corners you should know about, and every one of them is a real chapter ahead, not a hand-wave:

  • The demonstrations came from a script. Real imitation learning starts with your demonstrations. In ch0.4 you drive the pusher yourself and record them, and you learn what a dataset physically is.
  • The data lived in memory. We never wrote a dataset to disk. The actual format, the one real robots record into, is ch0.4 and ch0.5.
  • We trained with plain squared error and didn't ask what breaks. That's behavior cloning, and it has failure modes that no loss curve will confess to. ch1.1 is this exact method, built properly, with those failures induced and measured, including why 48% and not 100%.
  • The simulator was a black box. What env.step actually does (the physics loop you just trusted a few hundred thousand times) is ch0.1, the very next thing.

So the rest of Phase 0 is not new material piled on top of this. It is this, slowed down: now let's understand what you just did.

Exercise

One, in exercises/, and it's worth doing before you move on. All 300 of those demonstrations succeeded at the task. So did the win really need 300, or would a handful of perfect demos have done just as well? Commit to an answer, then let the code settle it.

What's next

You have a policy that works and no idea why. Chapter 0.1 opens the simulator you just called env.step on and shows you the loop underneath: the first of the pieces that, put back together, are the thing now pushing a block across your screen.

Practice

  1. predict-then-run

    Exercise 1

    The one honest thing this quickstart hides on purpose — WHERE the win comes from.

    Objective tested: the one honest thing this quickstart hides on purpose — WHERE the win comes from. The policy you just trained solved ~half of its held-out starts. It learned that from 300 scripted demonstrations. So: was it the 300, or would a handful have done? Every one of those 300 expert episodes SUCCEEDS; if success is what the network copies, five perfect demos ought to be plenty.

    THE SETUP. This reruns quickstart.py with --demos 5 (and a short --epochs 300, since five demos train in seconds) on the SAME held-out eval starts, seed 0. All five demos succeed. Then it compares the trained policy's success rate to the random-action floor of 0/25.

    PREDICT before you run: on the 25 held-out starts, the 5-demo policy will...

    • A) still clear the floor comfortably — the expert solved all 5, and success is what behavior cloning copies, so the skill carries over

    • B) collapse to roughly the random floor — five trajectories don't cover the states the policy actually steers itself into, and it has nothing to imitate there

    • C) do even BETTER than the 300-demo run — less data means less averaging over conflicting demonstrations, so a sharper, more decisive policy

    (under a minute on CPU). Estimated learner time: 10 minutes (mostly the run).

    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.