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:
| Stage | In Alpamayo-R1 | In the project |
|---|---|---|
| Input | Camera video, ego motion, a text prompt | The last 4 observations and an instruction |
| Model | A vision-language model | A small transformer over 5 tokens |
| Output | Trajectory tokens (and a flow expert at inference) | One acceleration token from a 33-token vocabulary |
| Supervised training | Imitation on driving logs | Imitation on a teacher's demonstrations |
| Finding failures | Closed-loop simulation, data selection | A data engine on fresh scenarios |
| Reinforcement learning | GRPO on reasoning, consistency and trajectory rewards | GRPO on progress, collision and comfort |
| Safety | Outside the released model | The 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.
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.
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)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.
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
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, dataOne 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.
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
)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_rewardsEach 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:
| Seed | Sampled jerk before | after | Collisions after (greedy and sampled) |
|---|---|---|---|
| 0 | 1.23 | 1.01 | 0 and 0 |
| 1 | 1.45 | 1.15 | 0 and 0 |
| 2 | 1.34 | 1.06 | 0 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.
| Seed | Collisions | Mean speed |
|---|---|---|
| 0 | 30.7 percent | 11.05 m/s |
| 1 | 30.7 percent | 11.03 m/s |
| 2 | 37.5 percent | 11.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
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 policyThe RSS filter (with 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
- Why does the project use discrete action tokens rather than a regression head?
- Every collision of the imitation model was a hard-braking scenario. What does that tell you about where to collect data?
- Why does sampling from the imitation model drive more roughly than taking its most likely action, and why does that matter for GRPO?
- The progress-only reward removed the emergency brake even with a KL penalty. Why did the penalty not stop it?
- The guard removed every collision of the careless model. Why is that not a reason to ship it?
- The eager instruction made the guard step in on 14.5 percent of steps. What would you change, and what would you not?
Answers
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, withcheck mini_driverpassing.- 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.