BotManifold
Tutorial

Route conditioning

Why a driving policy needs to know where it is going, what a policy does at a fork without the route, and the ways to give it the route: commands, branches, waypoints and text.

The camera cannot tell you where to turn

Look at a photo taken a few metres before a junction. The road forks. Both branches are legal, both are equally visible, and the images from drivers who took the left and drivers who took the right are identical. The difference is a fact that is not in the pixels: where each driver was going.

Imitation learning from such data has a problem. If the model only sees the image, the demonstrations at this spot say "left" half the time and "right" half the time, for no reason it can observe. A planner in a modular stack avoids this by being given a route from a navigation system. A learned driver needs the same input. This is route conditioning, introduced for end-to-end driving as command-conditional imitation learning (Codevilla et al., 1710.02410), and it is the part of the problem that no amount of sensor or model quality can remove.

A fork in the road

A car drives forward at 5 m/s and steers sideways. At x=0x = 0 the road forks into a left and a right branch which separate over 10 m. The expert takes each branch half the time, and steers to the centre of the chosen branch.

libs/tutorial/route_conditioning.py (world)
def centre(x: torch.Tensor, side: torch.Tensor) -> torch.Tensor:
    """Lateral position of a branch centre at distance x (side is +1 left, -1 right)."""
    return side * BRANCH * (x / SPLIT).clamp(0.0, 1.0)


def on_road(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
    """True where the car is within HALF_WIDTH of either branch centre."""
    left = (y - centre(x, torch.ones_like(x))).abs() < HALF_WIDTH
    right = (y - centre(x, -torch.ones_like(x))).abs() < HALF_WIDTH
    return left | right


def expert_velocity(
    x: torch.Tensor, y: torch.Tensor, side: torch.Tensor
) -> torch.Tensor:
    """Lateral speed that tracks the chosen branch centre."""
    slope = side * BRANCH / SPLIT * SPEED * ((x >= 0) & (x < SPLIT)).float()
    return 1.5 * (centre(x, side) - y) + slope

Four learners are trained on the same expert drives (150 noisy drives each, so the data also covers states slightly off the centre line), and then driven closed loop: 1,000 drives, half asking for the left branch and half for the right.

libs/tutorial/route_conditioning.py (policies)
def train(kind: str, steps: int = 1200, seed: int = 0) -> nn.Module:
    """Trains one of ``"average"``, ``"regression"``, ``"sampling"`` or ``"commanded"``.

    ``regression`` and ``commanded`` minimise squared error on the lateral speed;
    ``sampling`` predicts a categorical over three speeds (left, straight, right) and is
    sampled at drive time. Only ``commanded`` sees the route.
    """
    torch.manual_seed(seed)
    state, side, label = demonstrations(150, seed=seed)
    x, y = state[:, 0], state[:, 1]
    command = side if kind == "commanded" else None
    inputs = features(x, y, command, blind=kind == "average")
    outputs = 3 if kind == "sampling" else 1
    policy = _net(inputs.shape[1], outputs)
    optimizer = torch.optim.Adam(policy.parameters(), lr=3e-3)
    for _ in range(steps):
        out = policy(inputs)
        if kind == "sampling":
            target = torch.where(label > 0.5, 0, torch.where(label < -0.5, 2, 1))
            loss = nn.functional.cross_entropy(out, target)
        else:
            loss = ((out.squeeze(-1) - label) ** 2).mean()
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    return policy


SAMPLING_SPEEDS = torch.tensor([2.0, 0.0, -2.0])


@torch.no_grad()
def drive(
    kind: str, policy: nn.Module, side: torch.Tensor, seed: int = 5
) -> dict[str, float]:
    """Closed-loop drives with a requested route (``side``) for every car.

    Returns:
        ``off_road``: share of drives that ever left the road; ``right_branch``: share
        that ended within one branch half-width of the requested branch.
    """
    generator = torch.Generator().manual_seed(seed)
    n = len(side)
    x = -10.0 * torch.ones(n)
    y = 0.1 * torch.randn(n, generator=generator)
    off = torch.zeros(n, dtype=torch.bool)
    for _ in range(int((LENGTH + 10.0) / (SPEED * DT))):
        command = side if kind == "commanded" else None
        out = policy(features(x, y, command, blind=kind == "average"))
        if kind == "sampling":
            pick = torch.multinomial(out.softmax(-1), 1, generator=generator)[:, 0]
            velocity = SAMPLING_SPEEDS[pick]
        else:
            velocity = out.squeeze(-1)
        y = y + DT * velocity
        x = x + DT * SPEED
        off |= ~on_road(x, y) & (x > 0)
    final = (y - centre(x, side)).abs() < HALF_WIDTH
    return {
        "off_road": off.float().mean().item(),
        "right_branch": final.float().mean().item(),
    }
LearnerWhat it seesLeft the roadTook the requested branch
AverageOnly how far along the road it is100 percent0
RegressionPosition (progress and lateral offset), no route0 in this run50 percent
SamplingPosition, no route; picks a steering speed from a learned distribution28 percent in this run50 percent
CommandedPosition and the route (left or right)0100 percent
Four panels of closed-loop paths through a forking road drawn as a grey shape. Sees only progress: every path goes straight and leaves the road after the fork. Sees position, no route: every path takes the upper branch. Samples a branch, no route: paths split between both branches, with some sharp corrections. Route as input: green paths take the upper branch and blue paths the lower, as requested.
Ten closed-loop drives per learner. Green asked for the left (upper) branch and blue for the right. Without the route, the learner either goes straight or commits to a branch regardless of what was asked.

Reading the table row by row:

  • Average. A regression that cannot see its own lateral position outputs the mean of the two experts: no sideways motion. That is straight ahead, and the road ends. This is the squared-error averaging failure from the prediction module, and it is as bad as it sounds.
  • Regression with position. It stays on the road, and that is more subtle than it looks. The learner can see how far it has drifted sideways, and once it has drifted a little, that tells it which branch it "must" be on. So it commits to a branch from tiny, random initial offsets, and keeps going. In the run in the figure every drive took the upper branch; across five seeds the rate of leaving the road varied from 0 to 58 percent. The route accuracy is 50 percent because it ignores the request, not because it is guessing well. This is the inertia of imitation: the policy reads its own recent motion as a stand-in for the missing route.
  • Sampling. A generative policy can represent the two branches and pick one, which fixes averaging (the action-head comparison makes the same point). But it picks at random, so half the drives go the wrong way, and the sampled steering speeds in this toy sometimes cause overshoots that leave the road (4 to 31 percent across five seeds).
  • Commanded. It takes the requested branch in all 1,000 drives and stays on the road. The route is the missing variable, and giving it to the model removes the ambiguity in the data.

The lesson goes beyond forks. If a variable decides what the expert does and the model cannot observe it, the model will substitute something it can observe. Here it was its own motion; in real data it might be the speed, the previous frame, or the brake light of the car in front. Route conditioning gives the model the variable itself.

How the route is given

The toy uses a two-valued command. Real systems use several forms, and they differ in how much they say and how they fail.

FormExampleStrengthWeakness
High-level commandleft, right, straight, follow laneSimple; matches what a turn-by-turn navigator saysSays nothing about which junction or lane; ambiguous with two junctions in a row
Command-specific branchesOne output head per command, picked by the commandThe command cannot be ignored: it selects the headEach head sees only its share of the data; more commands, more heads
Waypoints or a polylineA list of points the ego car should followPrecise and lane-level; works with many junction shapesA wrong or stale route is followed faithfully; needs an encoder (see sensor encoders)
Goal pointA target position some seconds aheadCompact; links to goal-conditioned planningSays nothing about the path to it
Language"Take the second exit"Flexible; what a VLA already acceptsNeeds grounding in the scene, and may be ignored

The command paper itself describes both of the first two designs: feeding the command to the network as an input, and using it to choose among command-specific output branches. The branch design takes the choice out of the network's hands, which matters because a command that enters as one input among many can be ignored. The toy's commanded learner takes the first design and still follows the command in 1,000 of 1,000 drives, because the data make the command the only thing that explains the expert's choice.

What goes wrong in practice

  • Ignored commands. If the training data let the model predict the expert's action without the command (the lane is almost always straight, or the command correlates with something easier to see), the model learns to ignore it. Evaluate with the command changed, as the toy does: ask for left and right from the same scene and check that the behaviour differs.
  • Ambiguity. "Turn left" does not say which left. Two junctions close together need a lane-level route or a distance to the manoeuvre.
  • Stale or wrong routes. The navigator can be wrong, late or out of date. A policy that follows the route into a closed road or a one-way street is doing what it was trained to do. The route is an intent, not a permission: the safety layers (see the safety module) must still be able to refuse it.
  • Command timing. When does a command become active, and when does it stop? Switch it too late and the car has already committed to a lane.
  • Missing in the open model. The Alpamayo repository FAQ says route conditioning is not included in the released model. A published number for such a model is a number without a route, which is a different problem from the one a planner in a car solves.

Why this matters for a VLA

A VLA has a natural place for the route: the text prompt. "Turn left at the next junction" is an instruction, and the instruction-following behaviour from the VLA module (in our arm experiments, a red or green square named in the instruction) is the same mechanism. All the failure modes above carry over: whether the model really uses the instruction, what it does when the instruction is ambiguous or contradicts the scene, and whether it can say "I can't do that". Expect to need a command-changed evaluation for any driving model that claims to follow a route.

Reference implementation (PyTorch)

Tests are in libs/tutorial/tests/test_route_conditioning.py. They check the road geometry (the branches separate so the middle is off the road after the fork), that the expert steers to the chosen branch, that a progress-only policy leaves the road, that policies without the route take the requested branch half the time, and that the commanded policy takes it nearly always. Run uv run --group tutorial python -m libs.tutorial.route_conditioning.

Check yourself

  1. Why do left-turn and right-turn demonstrations from the same spot look identical to the model?
  2. What does a squared-error regression without the route do at the fork, and why?
  3. The regression that sees its own position stays on the road. Why is that still a failure?
  4. What is the difference between giving the command as an input and selecting a command-specific branch?
  5. How would you test that a model really uses its route input?
  6. Why should a route be treated as an intent rather than a permission?
Answers
  1. The image at the fork is the same whichever way the driver went. The variable that decided it, the route, is not in the pixels.
  2. The learner that sees only its progress outputs the mean of the left and right experts, which is no sideways motion: straight ahead, off the road in every drive.
  3. It reads the route off its own sideways drift and commits to a branch regardless of what was asked, so it takes the requested branch only half the time (and leaves the road in some seeds).
  4. As an input, the command is one feature among many and the network can learn to ignore it if the data allow. As a branch selector, the command picks which output head is used, so it cannot be ignored, but each head sees only its share of the data.
  5. Change the route for the same scene (left, then right) and check that the behaviour changes accordingly.
  6. Navigation can be wrong, late or stale. A policy that follows a route into a closed road is doing what it was trained to do, so the safety layers must still be able to refuse it.

Quiz

Answer each question; feedback appears as soon as you choose.

Question 1 of 5

At a fork, a regression policy that cannot see its own lateral position and has no route goes straight. Why?

Question 2 of 5

The learner that sees its position but not the route took the requested branch only half the time. What was it doing?

Question 3 of 5

Why can feeding the command as one input among many fail where command-specific branches do not?

Question 4 of 5

How do you test whether a model really uses its route input?

Question 5 of 5

Why treat the route as an intent rather than a permission?

0 of 5 answered

Sources

On this page