BotManifold
Tutorial

Imitation learning and action chunking

Behaviour cloning, why it drifts (measured on a lane-keeping toy), DART and DAgger, action chunks and temporal ensembling, and why generative policies help, with our SO-100 measurements.

Behaviour cloning

Collect demonstrations as pairs (observation, action) and train a network to predict the action from the observation: supervised learning, exactly like classification or regression. It is the base of nearly every recent robot policy, including driving.

Why it fails: compounding error

Training data comes from the expert's states. At run time the policy makes a small error, lands in a state the expert never visited, errs more there, and drifts. Errors compound over the horizon. DAgger addresses this by having the expert label states the learner visits. A cheaper variant, DART, injects noise into the expert while collecting data so demonstrations contain recoveries.

The problem, measured

A lane-keeping toy makes the mechanism concrete. A car at 5 m/s is nudged by a random heading disturbance every 0.1 s. The expert steers gently near the lane centre and firmly once it is more than 0.3 m off. A small network learns to imitate it from 20 clean demonstrations.

libs/tutorial/imitation.py (world)
def expert_action(state: torch.Tensor) -> torch.Tensor:
    """The expert's steering: gentle near the lane centre, firm once off by 0.3 m.

    Near the centre it mostly damps the heading; beyond 0.3 m it steers back hard.
    Clean demonstrations rarely leave the gentle zone, so they never show the firm
    correction, which is the point of the toy.
    """
    e, psi = state[..., 0], state[..., 1]
    firm = 3.0 * torch.relu(e.abs() - 0.3) * torch.sign(e)
    return (-2.5 * psi - 0.05 * e - firm).clamp(-1.0, 1.0)


def step(
    state: torch.Tensor, action: torch.Tensor, disturbance: torch.Tensor
) -> torch.Tensor:
    """One time step: heading changes with the action plus a disturbance."""
    e, psi = state[..., 0], state[..., 1]
    e = e + DT * SPEED * torch.sin(psi)
    psi = psi + DT * action + disturbance
    return torch.stack([e, psi], dim=-1)

The trap: clean demonstrations almost never leave the gentle zone (0.15 percent of their states are more than 0.3 m off), so the learner never sees the firm correction. At test time, with somewhat stronger disturbances, it drifts past 0.3 m, does not know to steer back, and keeps drifting.

libs/tutorial/imitation.py (collect)
def collect(
    episodes: int,
    horizon: int,
    *,
    learner: nn.Module | None = None,
    action_noise: float = 0.0,
    disturbance: float = 0.01,
    generator: torch.Generator,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Rolls out and records (state, expert action) pairs.

    Who drives decides which states are seen; the label is always the expert's action:

    * behaviour cloning: the expert drives (``learner`` None, no noise);
    * DART: the expert drives with ``action_noise`` added to what is executed;
    * DAgger: the ``learner`` drives and the expert only labels.
    """
    state = torch.zeros(episodes, 2)
    state[:, 0] = 0.05 * torch.randn(episodes, generator=generator)
    states, labels = [], []
    for _ in range(horizon):
        label = expert_action(state)
        states.append(state)
        labels.append(label)
        if learner is not None:
            with torch.no_grad():
                executed = learner(state).squeeze(-1)
        else:
            executed = label + action_noise * torch.randn(episodes, generator=generator)
        noise = disturbance * torch.randn(episodes, generator=generator)
        state = step(state, executed, noise)
    return torch.cat(states), torch.cat(labels)
libs/tutorial/imitation.py (methods)
def behaviour_cloning(
    seed: int = 0, episodes: int = 20, horizon: int = 100
) -> nn.Module:
    """Fits the learner to clean expert demonstrations."""
    generator = torch.Generator().manual_seed(seed)
    torch.manual_seed(seed)
    learner = _policy()
    _fit(learner, *collect(episodes, horizon, generator=generator))
    return learner


def dart(
    seed: int = 0, episodes: int = 20, horizon: int = 100, noise: float = 0.5
) -> nn.Module:
    """Fits to demonstrations with perturbed executed actions; labels stay clean."""
    generator = torch.Generator().manual_seed(seed)
    torch.manual_seed(seed)
    learner = _policy()
    _fit(learner, *collect(episodes, horizon, action_noise=noise, generator=generator))
    return learner


def dagger(
    seed: int = 0, episodes: int = 20, horizon: int = 100, rounds: int = 4
) -> nn.Module:
    """Starts from behaviour cloning, then repeatedly lets the learner drive and the
    expert label the states it reaches, refitting on everything collected so far."""
    generator = torch.Generator().manual_seed(seed)
    torch.manual_seed(seed)
    learner = _policy()
    states, labels = collect(episodes, horizon, generator=generator)
    _fit(learner, states, labels)
    per_round = episodes // rounds
    for _ in range(rounds - 1):
        new_states, new_labels = collect(
            per_round, horizon, learner=learner, disturbance=0.03, generator=generator
        )
        states, labels = (
            torch.cat([states, new_states]),
            torch.cat([labels, new_labels]),
        )
        _fit(learner, states, labels)
    return learner
Left: share of episodes leaving the lane against episode length. Behaviour cloning rises from about 0 at 2.5 seconds to 0.54 at 30 seconds; DART and DAgger stay at 0. Right: mean absolute lateral offset over 30 seconds; the expert, DART and DAgger hover around 0.18 m while behaviour cloning climbs to about 0.48 m.
300 test episodes per point. The same network and the same amount of expert labelling; only which states were labelled differs.
Episode lengthBehaviour cloning leaves the laneDARTDAggerExpert
5 s5%0%0%0%
10 s17 to 22%0%0%0%
15 s29 to 36%0%0%0%
30 s50 to 59%0%0%0%

(Range over 3 training seeds.) The failure grows with time because every extra second is another chance to wander into states the demonstrations never covered. Ross, Gordon and Bagnell (1011.0686) put it formally: sequential prediction breaks the i.i.d. assumption of supervised learning, because the policy's own predictions decide what it sees next; their remedy (DAgger) is guaranteed to find a policy that performs well on the distribution of states it induces.

Two fixes: who drives during data collection

Behaviour cloningDARTDAgger
Who drivesThe expertThe expert, with noise added to what is executedThe learner
Who labelsThe expertThe expert (clean command)The expert, on the learner's states
States coveredOnly the expert'sThe expert's plus nearby disturbancesWherever the learner goes
CostCheapestOne collection passRepeated rounds, an expert on call

DAgger trains, lets the learner drive, asks the expert what it would have done in the states the learner reached, adds those to the data and retrains. DART (1703.09327) gets most of the benefit without letting the learner drive: it injects noise into the expert's executed actions so the demonstrations themselves contain recoveries, while the labels stay the expert's clean commands. Its authors argue that DAgger's on-policy labelling is tedious for human supervisors, and report on a simulated Humanoid task that DART is up to 3 times faster in computation time and that the noise lowers the supervisor's reward during data collection by only 5 percent.

Both fix the toy completely: 0 failures in 30-second episodes for all three seeds. With humans as experts, DART-style collection (deliberately disturb the robot or car, record the human's correction) is usually the practical choice; with a scripted or model expert, DAgger is easy.

Same idea on a robot arm

In our simulated SO-100 arm experiments, a pick-and-place policy trained on clean scripted demonstrations failed. With demonstrations collected under injected disturbance and labelled with the clean plan, success rose from 0 to 94 percent (together with a suitable chunk length).

Action chunking

Predict not one action but a chunk of the next k actions, and execute several before predicting again. Benefits: fewer decisions per second (less compounding), smoother motion, and the ability to represent a temporally extended intention (a pause, a commitment to one mode). In LeRobot terms there are two knobs: how many actions the model predicts (chunk_size) and how many it executes before re-planning (n_action_steps).

Three rows of boxes. Each row is a chunk of ten predicted actions, the first four dark (executed) and the last six light (dropped). Each row starts four steps after the previous one.
chunk_size = 10, n_action_steps = 4. The policy predicts ten actions, executes four, then plans again from the new observation, so the last six predictions are never used.

Same idea on a robot arm

On our SO-100 reach task, chunk length accounted for about half of the failure, with compounding error the other half. Chunk length is a real hyperparameter, not a detail.

What the chunk length trades

Inside a chunk the policy runs open loop: it does not look at new observations until the chunk ends. That is both the benefit and the risk:

Longer chunks executedShorter chunks executed
Fewer decisions, so fewer chances to compound errorsReact sooner to surprises
Smoother motion, one consistent intentionCan switch intention mid-motion (dithering)
Fewer inference calls (latency budget)More inference calls
Blind to disturbances until the chunk endsEach decision sees fresh, possibly noisy observations

ACT (Action Chunking with Transformers, 2304.13705) predicts chunks with a transformer and trains with a conditional VAE (the VAE module); the paper reports 80 to 90 percent success on fine bimanual tasks such as slotting a battery, from only 10 minutes of demonstrations. It also uses temporal ensembling: it predicts a new chunk at every step and averages all the predictions that cover the current time step, weighting them with exponentially decaying weights, which smooths execution while still reacting at every step. Diffusion Policy generates the chunk with a diffusion model and uses receding horizon control: predict a chunk, execute the first part, re-predict.

Why generative policies

Plain regression averages modes. If the demonstrations go around an obstacle on the left or the right, the average goes into the obstacle. A VAE (ACT) or a diffusion/flow model (Diffusion Policy, pi0) represents the distribution and picks one mode. The same logic applies to driving: at a junction several trajectories are valid, and a good planner should commit to one.

Left: demonstrations going around an obstacle on either side, a straight red regression line through the obstacle, and a green dashed policy that follows one side. Right: drift from the expert path over time, growing for clean demonstrations and staying small for demonstrations with recoveries.
Left: averaging two valid paths drives through the obstacle; a generative policy picks one. Right: an illustrative simulation (not our SO-100 data) of error that accumulates when the policy never saw recovery, against error that is pulled back.

Reference implementation (PyTorch)

The lane-keeping study is libs/tutorial/imitation.py, with tests in libs/tutorial/tests/test_imitation.py: the expert is gentle near the centre and firm beyond 0.3 m, clean demonstrations almost never leave the gentle zone while noisy execution reaches the firm zone far more often, behaviour cloning leaves the lane in more than 30 percent of 30-second episodes and far more often than in 5-second ones, and DART and DAgger each keep failures under 2 percent. Run uv run --group tutorial python -m libs.tutorial.imitation.

A second toy makes the point: every demonstration passes an obstacle on the left or the right, and the observation is the same for both. A regression policy averages the two; an ACT-style conditional VAE draws a latent that picks a side. Tests are in libs/tutorial/tests/test_chunked_policy.py; run uv run --group tutorial python -m libs.tutorial.chunked_policy. The real ACT uses transformers for the encoder and decoder; this version uses MLPs to keep the idea visible.

libs/tutorial/chunked_policy.py (regression)
class RegressionPolicy(nn.Module):
    """Predicts the whole chunk with one forward pass and a mean-squared-error loss."""

    def __init__(self, hidden: int = 128) -> None:
        """Creates the MLP from an observation to a flattened chunk."""
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1, hidden), nn.ReLU(), nn.Linear(hidden, CHUNK * 2)
        )

    def forward(self, obs: torch.Tensor) -> torch.Tensor:
        """Returns a chunk of shape (batch, CHUNK, 2)."""
        return self.net(obs).view(-1, CHUNK, 2)

    def loss(self, obs: torch.Tensor, chunk: torch.Tensor) -> torch.Tensor:
        """Mean squared error to the demonstrated chunk."""
        return ((self(obs) - chunk) ** 2).mean()
libs/tutorial/chunked_policy.py (cvae)
class CvaeChunkPolicy(nn.Module):
    """ACT-style policy: a style latent z explains which demonstration this was.

    During training an encoder reads the demonstrated chunk and produces z. The
    decoder maps (observation, z) to the chunk. At run time there is no
    demonstration to read, so z is drawn from the prior (or set to zero).
    """

    def __init__(self, hidden: int = 128) -> None:
        """Creates the encoder and decoder MLPs."""
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(CHUNK * 2, hidden), nn.ReLU(), nn.Linear(hidden, 2 * LATENT)
        )
        self.decoder = nn.Sequential(
            nn.Linear(1 + LATENT, hidden), nn.ReLU(), nn.Linear(hidden, CHUNK * 2)
        )

    def decode(self, obs: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
        """Maps an observation and a latent to a chunk (batch, CHUNK, 2)."""
        return self.decoder(torch.cat([obs, z], dim=-1)).view(-1, CHUNK, 2)

    def loss(
        self, obs: torch.Tensor, chunk: torch.Tensor, beta: float = 0.05
    ) -> torch.Tensor:
        """Reconstruction error plus beta times the KL of the latent to N(0, I)."""
        mean, log_var = self.encoder(chunk.flatten(1)).chunk(2, dim=-1)
        z = mean + torch.exp(0.5 * log_var) * torch.randn_like(mean)
        recon = ((self.decode(obs, z) - chunk) ** 2).mean()
        kl = (-0.5 * (1 + log_var - mean**2 - log_var.exp()).sum(dim=-1)).mean()
        return recon + beta * kl

    @torch.no_grad()
    def act(self, obs: torch.Tensor, sample_prior: bool = True) -> torch.Tensor:
        """Predicts a chunk with z sampled from the prior, or z = 0 if not sampling."""
        shape = (len(obs), LATENT)
        z = torch.randn(shape) if sample_prior else torch.zeros(shape)
        return self.decode(obs, z)

In our test run, the regression policy's paths pass through the obstacle (closest approach 0.21 against an obstacle radius of 0.6), while most latent-sampled paths clear it and both sides are used. Some prior samples still fall between the two modes, so a small share of paths do not clear: the same "holes in the prior" effect as in the VAE module.

Check yourself

  1. Why do clean expert demonstrations teach nothing about recovery?
  2. What does DART change in data collection, and why must the label stay the clean command?
  3. What does a larger chunk length buy and cost?
  4. Give a driving example where regression to the mean produces an unsafe plan.
  5. In the lane-keeping toy, why does behaviour cloning get worse as episodes get longer?
  6. DART and DAgger both fixed the toy. What does each change, and when would you choose which?
  7. What does temporal ensembling buy over executing one chunk at a time?
Answers
  1. Clean demonstrations stay near the centre (0.15 percent of their states are more than 0.3 m off), so the learner never sees an off-course state or the correction for it. Once it drifts there at test time it has no data on what to do.
  2. DART adds noise to the actions the expert executes while collecting, so the car wanders into nearby off-course states. The label stays the expert's clean command so the learner learns the correction, not the noise.
  3. Longer chunks mean fewer decisions (less compounding), smoother motion with one intention, and fewer inference calls. They cost reaction time: inside a chunk the policy is open loop and blind to disturbances.
  4. Passing a stopped car: demonstrations go around it on the left or the right, and the average of the two goes straight into it.
  5. Every extra second is another chance to drift into states the demonstrations never covered. Leaving the lane rose from 5 percent of 5-second episodes to 50 to 59 percent of 30-second ones.
  6. DART changes the data collection (the expert drives with injected noise) and needs one pass, so it suits human experts. DAgger changes who drives (the learner) and asks the expert to label the learner's states over repeated rounds, which is easy when the expert is a program.
  7. It predicts a new chunk at every step and averages all predictions covering the current step with decaying weights, so execution is smooth like a chunk while still reacting to new observations every step.

Quiz

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

Question 1 of 5

What is compounding error in behaviour cloning?

Question 2 of 5

In DART-style data collection, what is executed and what is the label?

Question 3 of 5

In our reach experiment, how much of the failure was due to chunk length?

Question 4 of 5

What does n_action_steps control in LeRobot?

Question 5 of 5

Why do generative policies help when demonstrations pass an obstacle on either side?

0 of 5 answered

Sources

On this page