BotManifold
Tutorial

Project: build a mini driving VLA

The course project: an instruction-following car-following model built end to end, the same stages as Alpamayo-R1 at toy scale. Tokens in, an action token out, imitation, a data engine, GRPO on a driving reward, reward hacking, and a safety filter in front.

What you build

Every lab so far exercised one idea on its own toy. This project puts them together into one model, trained the way Alpamayo-R1 is trained, at a size that runs on a laptop in about two minutes:

StageIn Alpamayo-R1In the project
InputCamera video, ego motion, a text promptThe last 4 observations and an instruction
ModelA vision-language modelA small transformer over 5 tokens
OutputTrajectory tokens (and a flow expert at inference)One acceleration token from a 33-token vocabulary
Supervised trainingImitation on driving logsImitation on a teacher's demonstrations
Finding failuresClosed-loop simulation, data selectionA data engine on fresh scenarios
Reinforcement learningGRPO on reasoning, consistency and trajectory rewardsGRPO on progress, collision and comfort
SafetyOutside the released modelThe RSS filter in front

The scenarios are the car-following ones from Closed-loop simulation and the data engine: a lead car that usually cruises and sometimes brakes at 5 m/s² for up to 3 s. The code is libs/tutorial/mini_driver.py; the lab leaves four functions for you to write.

1. The teacher and the instruction

The teacher is the time-gap controller from the closed-loop module with one change: the instruction sets its following distance. "Relaxed" (instruction 0) keeps 1.4 s of travel, "eager" (instruction 1) keeps 0.7 s. Both brake at full strength when the time to collision falls under 2 s.

libs/tutorial/mini_driver.py (teacher)
def teacher(
    gap: torch.Tensor, speed: torch.Tensor, lead: torch.Tensor, style: torch.Tensor
) -> torch.Tensor:
    """A time-gap controller whose following distance is set by the instruction.

    It aims for 3 m plus ``HEADWAY[style]`` seconds of travel, matches the lead's
    speed, and brakes at full strength when the time to collision falls under 2 s.
    """
    wanted = 3.0 + HEADWAY[style] * speed
    accel = 0.4 * (gap - wanted) + 1.0 * (lead - speed)
    closing = (speed - lead).clamp(min=1e-3)
    emergency = (gap / closing < 2.0) & (speed > lead)
    accel = torch.where(emergency, torch.full_like(accel, -MAX_BRAKE), accel)
    return accel.clamp(-MAX_BRAKE, MAX_ACCEL)

On the 600 held-out scenarios the teacher never collides, averages 9.94 m/s and keeps a 16.1 m gap.

2. Tokens in, an action token out

The model sees what a VLA sees, in miniature: one instruction token (a learned embedding, like a word) and four observation tokens, one per recent step, each the gap, speed and closing speed projected to 32 numbers plus a learned position. Two transformer blocks from the attention lab mix them, and the output at the instruction token becomes logits over an action vocabulary: 33 accelerations from −6 to 2 m/s², 0.25 apart.

libs/tutorial/mini_driver.py (tokens)
def to_token(accel: torch.Tensor) -> torch.Tensor:
    """The index of the nearest action bin: an acceleration becomes a token."""
    return (accel[..., None] - BINS).abs().argmin(dim=-1)
libs/tutorial/mini_driver.py (model)
class MiniDriver(nn.Module):
    """Instruction token + observation tokens -> transformer -> action-token logits.

    The instruction is one learned embedding (like a word); each of the last
    ``HISTORY`` observations (gap, speed, closing speed) is projected to a token and
    given a learned position. The output at the instruction token is read out as
    logits over the ``BINS`` action vocabulary.
    """

    def __init__(self, dim: int = 32, heads: int = 4, layers: int = 2) -> None:
        """Creates the embeddings, the transformer blocks and the action head."""
        super().__init__()
        self.embed = nn.Linear(3, dim)
        self.position = nn.Parameter(0.02 * torch.randn(HISTORY, dim))
        self.instruction = nn.Embedding(len(HEADWAY), dim)
        self.blocks = nn.ModuleList(TransformerBlock(dim, heads) for _ in range(layers))
        self.head = nn.Linear(dim, len(BINS))

    def forward(self, history: torch.Tensor, style: torch.Tensor) -> torch.Tensor:
        """Maps history (batch, HISTORY, 3) and style (batch,) to logits."""
        tokens = torch.cat(
            [self.instruction(style)[:, None], self.embed(history) + self.position],
            dim=1,
        )
        for block in self.blocks:
            tokens = block(tokens)
        return self.head(tokens[:, 0])

Discrete actions are a choice, not a necessity. They make imitation a classification problem and, more importantly, give every action a log-probability, which GRPO needs. Alpamayo-R1 makes the same choice in training and swaps in a flow-matching expert at inference (Reading the Alpamayo-R1 code).

3. Imitation, and where it fails

The demonstrations come from 200 scenarios with no hard-braking lead, as if the fleet had not met one yet. The model is trained with cross-entropy on the teacher's action tokens.

libs/tutorial/mini_driver.py (imitation)
def demonstrations(
    scenes: Scenes, style: torch.Tensor, policy: Policy = teacher_policy
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    """Drives with ``policy``; returns each visited (history, style, teacher token)."""
    result = drive(scenes, style, policy, record=True)
    n = len(style)
    return (
        result["history"].reshape(n * STEPS, HISTORY, 3),
        style[:, None].expand(n, STEPS).reshape(-1),
        to_token(result["teacher"]).reshape(-1),
    )


def fit(
    model: MiniDriver,
    data: tuple[torch.Tensor, torch.Tensor, torch.Tensor],
    steps: int = 600,
    batch: int = 2048,
) -> float:
    """Behaviour cloning: cross-entropy of the model's logits on teacher tokens."""
    history, style, target = data
    optimizer = torch.optim.Adam(model.parameters(), lr=3e-3)
    generator = torch.Generator().manual_seed(0)
    for _ in range(steps):
        index = torch.randint(0, len(history), (batch,), generator=generator)
        loss = nn.functional.cross_entropy(
            model(history[index], style[index]), target[index]
        )
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    return loss.item()

On the held-out scenarios (30 percent with a hard-braking lead) the imitation model collides in 30.5 percent of them, and every collision is a hard-braking scenario. The model drives ordinary traffic well; it never saw the emergency brake used.

4. The data engine

libs/tutorial/mini_driver.py (engine)
def data_engine(
    data: tuple[torch.Tensor, torch.Tensor, torch.Tensor],
    model: MiniDriver,
    rounds: int = 2,
    pool: int = 2000,
    added: int = 8,
    seed: int = 0,
) -> tuple[MiniDriver, tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
    """Drives a fresh pool, keeps scenarios that crashed, has the teacher label them.

    Each round the model drives ``pool`` new scenarios (2 percent hard-braking
    leads); up to ``added`` of those it crashed in are driven again by the model with
    the teacher labelling every visited state, and a fresh model is fitted on all data.
    Stops early when the pool has no crash.
    """
    for round_ in range(rounds):
        scenes = make_scenarios(pool, 0.02, seed=500 + 10 * seed + round_)
        style = instructions(pool, 600 + 10 * seed + round_)
        crashed = drive(scenes, style, greedy(model))["crashed"]
        pick = crashed.nonzero().squeeze(-1)[:added]
        if len(pick) == 0:
            break
        failing = {key: value[pick] for key, value in scenes.items()}
        new = demonstrations(failing, style[pick], greedy(model))
        data = tuple(
            torch.cat([old, extra]) for old, extra in zip(data, new, strict=True)
        )
        torch.manual_seed(seed)
        model = MiniDriver()
        fit(model, data)
    return model, data

One round is enough: the model drives 2,000 fresh scenarios, crashes in 41, the teacher labels the states it visited in 8 of them, and the refitted model has no collisions on the held-out set. This is the loop from the closed-loop module, now on the model you built.

5. GRPO on a driving reward

Reinforcement learning needs a stochastic policy: GRPO samples several drives per scenario and compares them. Sampling from the imitation model is noticeably rougher than its greedy driving (mean jerk 1.23 against 0.60 m/s³), because it spreads probability over neighbouring action tokens.

libs/tutorial/mini_driver.py (reward)
def episode_reward(
    result: dict[str, torch.Tensor],
    collision_weight: float = 5.0,
    comfort_weight: float = 0.05,
) -> torch.Tensor:
    """Progress (mean speed / 10) minus a collision penalty and a jerk penalty."""
    progress = result["speed"].mean(dim=1) / 10.0
    jerk = result["accel"].diff(dim=1).abs().mean(dim=1) / DT
    return (
        progress - collision_weight * result["crashed"].float() - comfort_weight * jerk
    )
libs/tutorial/mini_driver.py (grpo)
def grpo_finetune(
    model: MiniDriver,
    iterations: int = 40,
    prompts: int = 32,
    group: int = 8,
    lr: float = 3e-4,
    kl_coef: float = 0.04,
    reward: Callable[[dict[str, torch.Tensor]], torch.Tensor] = episode_reward,
    seed: int = 0,
) -> list[float]:
    """GRPO: each scenario is a prompt, ``group`` sampled drives are its outputs.

    Every drive gets one reward; its advantage, against the other drives of the same
    scenario, is given to every action token it took. The loss is the clipped ratio of
    each token's probability with a k3 KL penalty to the starting model.

    Returns:
        The mean training reward at each iteration.
    """
    reference = copy.deepcopy(model).eval()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    history_of_rewards = []
    torch.manual_seed(seed)
    for it in range(iterations):
        scenes = make_scenarios(prompts, 0.3, seed=10_000 + 1000 * seed + it)
        style = instructions(prompts, 20_000 + 1000 * seed + it).repeat_interleave(
            group
        )
        drives = {k: v.repeat_interleave(group, 0) for k, v in scenes.items()}
        result = drive(drives, style, sampling(model), record=True)
        rewards = reward(result).view(prompts, group)
        advantages = group_advantages(rewards).reshape(-1, 1)
        n = prompts * group
        history = result["history"].reshape(n * STEPS, HISTORY, 3)
        styles = style[:, None].expand(n, STEPS).reshape(-1)
        taken = result["tokens"].reshape(-1, 1)

        def logp(net: MiniDriver) -> torch.Tensor:
            logits = net(history, styles)
            return torch.log_softmax(logits, -1).gather(1, taken).view(n, STEPS)

        with torch.no_grad():
            old_logp, ref_logp = logp(model), logp(reference)
        for _ in range(2):
            loss = grpo_loss(
                logp(model), old_logp, ref_logp, advantages, kl_coef=kl_coef
            )
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        history_of_rewards.append(rewards.mean().item())
    return history_of_rewards

Each scenario is a prompt, 8 sampled drives are its outputs, and each drive's reward is shared by all 150 action tokens it took, with the per-token clipped ratio and a k3 KL penalty to the starting model (RL for policies and reasoning). After 40 iterations:

SeedSampled jerk beforeafterCollisions after (greedy and sampled)
01.231.010 and 0
11.451.150 and 0
21.341.060 and 0

Progress is unchanged (9.95 m/s), safety is unchanged, and the sampled driving is about a fifth smoother. A modest gain, and an honest one: the imitation model was already close to the teacher, so RL refines rather than transforms it, which is the role it plays in Alpamayo-R1's recipe too.

6. A careless reward

Change one thing: reward progress alone, with no collision or comfort term and no KL penalty.

SeedCollisionsMean speed
030.7 percent11.05 m/s
130.7 percent11.03 m/s
237.5 percent11.35 m/s

The model unlearns the emergency brake. Braking costs progress and the reward never charges for a crash, so after 40 iterations it drives about 1.1 m/s faster and collides in every hard-braking scenario. A KL penalty of 0.04 does not prevent it (30.2 percent collisions in our run): the leash slows the drift, but it cannot supply a term the reward is missing.

7. The safety filter, and what the test supports

libs/tutorial/mini_driver.py (guard)
def guarded(model: MiniDriver) -> Policy:
    """The model with the RSS filter between it and the actuators."""
    act = greedy(model)

    def policy(history, style, gap, speed, lead):
        accel, tokens = act(history, style, gap, speed, lead)
        safe, overridden = rss_filter(accel, gap, speed, lead, brake_min=BRAKE_MIN)
        return safe, tokens, overridden

    return policy

The RSS filter (with bmin⁡=5b_{\min} = 5 m/s²) in front of the careless model removes every collision, but it overrides 11.9 percent of steps and the driving becomes abrupt (mean jerk 13.9 m/s³). The guard catches a bad policy; it does not make it a good one.

In front of the GRPO model it barely acts for the relaxed instruction (0.06 percent of steps) and acts on 14.5 percent of steps for the eager one. The eager instruction asks for a 0.7 s gap, less room than the envelope allows at speed, so the guard keeps stepping in. The instruction is an intent, not a permission (Route conditioning).

Zero collisions in the 600 held-out scenarios supports a collision rate below about 0.5 percent per scenario at 95 percent confidence (the rule of three from Safety engineering for learned drivers), in this operating domain, and nothing more.

The lab

libs/tutorial/labs/mini_driver.py leaves four functions to write: teacher, to_token, episode_reward and guarded. Run uv run --group tutorial python scripts/tutorial_labs.py check mini_driver; the tests build the imitation model and the data engine with your functions (about a minute). The GRPO and reward-hacking results are checked by test_mini_driver_rl.py and test_mini_driver_hacking.py. Run the whole project with uv run --group tutorial python -m libs.tutorial.mini_driver.

Check yourself

  1. Why does the project use discrete action tokens rather than a regression head?
  2. Every collision of the imitation model was a hard-braking scenario. What does that tell you about where to collect data?
  3. Why does sampling from the imitation model drive more roughly than taking its most likely action, and why does that matter for GRPO?
  4. The progress-only reward removed the emergency brake even with a KL penalty. Why did the penalty not stop it?
  5. The guard removed every collision of the careless model. Why is that not a reason to ship it?
  6. The eager instruction made the guard step in on 14.5 percent of steps. What would you change, and what would you not?
Answers
  1. A categorical head gives every action a log-probability, which GRPO needs for its ratio and KL terms, and it makes imitation a classification problem. Alpamayo-R1 makes the same choice in training.
  2. Imitation fails where the demonstrations never went. Data should be collected, or mined, where the model fails: the data engine picked 8 failing scenarios and fixed all of them.
  3. Sampling spreads probability over neighbouring action tokens, so consecutive actions jitter. GRPO trains the sampled policy, so a reward with a comfort term sharpens it; deployment then takes the most likely action.
  4. The KL penalty only slows how far the policy moves from the reference. It cannot add a cost the reward is missing: a reward that never charges for a crash makes dropping the brake a steady gain.
  5. The guard overrode 11.9 percent of steps and made the driving abrupt; the car's behaviour became the filter's worst-case braking. The guard is the last line of defence, not the driver.
  6. Change the instruction's meaning or limit it: a following gap the envelope accepts, or a planner that tells the user the request cannot be met. Do not loosen b_min to make the guard quieter; that empties the guarantee.

What to hand in

  • libs/tutorial/labs/mini_driver.py, with check mini_driver passing.
  • A short note with your own numbers: the imitation model's collision rate, the data engine's, the sampled jerk before and after GRPO, and what the progress-only reward did.

On this page