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

Chapter 0.0

Your First Robot Policy in Twenty 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 · P2

You trained this — and it works

The tiny behavior-cloning policy you trained in one sitting, 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 Colabsoon

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

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 · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~2.83 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

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

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, ch0.0.

    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.

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromquickstart.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
5140fab8310f1bb85ac81d5bd906b178fd69fcda0192a054ee69e2386ec4522b
#demos
df8eee938b2c128e7bcbc45266c42232b17cbb8c495b92d11c6b100f35d91c36
#model
8d4a0b3bde34349f16bd03791d56d5eaf50da47d32cc162a1944e83ce410f750
#train
b3fe213fe766ceb993fd9af78d54a04c7e975f427ed8c9bd48ed40477e3b119b
#eval
141cc52dc030ee292e65ae9b1407cebb4672e98e2b880d5e09fb984984e2d56e