Core concept

Behaviour cloning turns demonstrations into a policy by regression: record the states an expert visited and the actions it took, and fit a function from one to the other. No reward is needed, which is why cloning is the default way to learn manipulation from human demonstrations. Its weakness is structural. The regression is fit on the expert’s state distribution, but the clone is deployed on its own: every small error takes it to states the expert never visited, where its predictions are not supported by data, and errors there take it further away. This is covariate shift, and it is why a clone can match the expert’s actions closely on held-out demonstration states and still fail in closed loop.

DAgger (Dataset Aggregation) attacks the cause: let the clone drive, ask the expert what it would have done in every state the clone visits, add those labels to the dataset and fit again. After a few iterations the data covers the states the clone actually reaches. This lesson measures both on the point-mass task, together with a third remedy that teaches the same thing from a different side.

[!established] DAgger Ross, Gordon and Bagnell (arXiv:1011.0686) showed that behaviour cloning’s error can grow quadratically with the task horizon, because mistakes move the learner off the expert’s state distribution, and that iteratively aggregating expert labels on the learner’s own state distribution reduces this to a linear dependence. The algorithm in mjcourse.il.bc.dagger is theirs, with the mixing coefficient set to zero after the first iteration.

Visual intuition

Record 5 demonstrations, then Test on 20 starts. The learner here is a nearest-neighbour lookup in the recorded data, the simplest possible clone. Tests run with random pushes (1 per second by default), which the demonstrations did not contain.

  1. Record 20 demonstrations and test: most episodes (orange) are knocked off the demonstrated paths and never recover, and several touch the obstacle. In this lesson’s preparation, 1 of 20 succeeded.
  2. Record 10 more the same way and test again: the success count does not improve (0 of 20). More of the same data does not cover the states a push leads to.
  3. Click DAgger a few times (blue paths: the learner drives, the expert labels), testing after each: successes climb, not always monotonically (8, 7, then 14 of 20 after three iterations).
  4. Clear, set “pushes while demonstrating” to yes, record 20, and test: demonstrations that already contain recoveries do as well as DAgger or better (17 of 20).

The nearest-neighbour clone is a much weaker learner than the script’s MLP (1 of 20 against 53 of 100 after 20 clean demonstrations), because it cannot interpolate between demonstrated states at all. The pattern across the three remedies is the same.

```lab bc {“dock”: true, “title”: “Cloning an expert, and DAgger”, “height”: 340}


## Mathematics

### The task and the expert

The state is $s = (x, y, \dot x, \dot y)$ of a 0.5 kg mass, the action a force in $[-1, 1]^2$ N applied every 10 ms. Starts are drawn left of a round obstacle (radius 0.1 m) and the goal is on its right; success is stopping within 4 cm of it, slower than 5 cm/s, within 6 s. The expert steers with a saturated PD law to a waypoint 0.2 m above or below the obstacle, on the side the mass is already on, until it has passed it, then to the goal. Output (1): without pushes it succeeds from all 100 held-out starts in 2.36 s on average; with pushes (on average one per second, a velocity change of 0.4 m/s in a random direction) it succeeds in 95 of 100.

### Cloning, and where it fails

A two-layer MLP (64 tanh units) is fit by mean-squared error to 20 demonstrations recorded without pushes (4 700 state-action pairs). Output (2): without pushes it succeeds in 100 of 100 episodes; with pushes, in 53, against the expert's 95. The pushes are the same for both, so the gap is the clone's.

The cause is measurable. For every state visited during the pushed episodes, output (2) computes the distance to the nearest demonstrated state, in units of the demonstrations' standard deviation:

| Policy under pushes | Median | 90th percentile | 99th percentile | Furthest point: successes / failures |
|---|---|---|---|---|
| expert | 0.38 | 1.26 | 2.79 | 2.47 / 3.65 |
| clone | 1.08 | 2.75 | 4.28 | 2.77 / 4.07 |

The expert, pushed, also leaves the demonstrated states, but its feedback law does not need data to recover. The clone spends its episodes three times further from the data, and the episodes it fails are those that went furthest.

### DAgger, more data, and data with recoveries

Output (3) compares three ways to spend expert labels, on 3 seeds, with success measured under pushes on the same 100 held-out starts:

- **DAgger:** start from the 20 clean demonstrations; each iteration runs the current clone from 4 new starts, with pushes, and the expert labels every visited state. Five iterations.
- **More demonstrations:** 20, 30, 40 and 50 clean demonstrations.
- **Demonstrations recorded with pushes:** the same numbers of demonstrations, recorded while the expert was being pushed.

![Success under pushes against the number of expert labels](media/l14_1_labels.png)

*Figure: one line per seed. Regenerate with `examples/l14_1_bc_dagger.py` (runs/l14_1/labels.png).*

At about 11 000 to 12 000 labels, DAgger reaches 0.92, 0.95 and 0.94; more clean demonstrations reach 0.52, 0.63 and 0.54, no better than 20 of them; demonstrations recorded with pushes reach 0.92 to 0.95 at every budget tried. A single DAgger iteration (1 300 to 2 200 new labels) already lifts success from 0.53 to 0.64 to between 0.85 and 0.99. Quantity of data is not the issue; coverage is. DAgger obtains it by querying the expert where the learner goes, pushed demonstrations obtain it by perturbing the expert where it goes, and both work here because a push is the kind of error the clone makes. The pushed-demonstration remedy needs to know which perturbations matter; DAgger does not, but it needs an expert that can be queried in arbitrary states, which a human teleoperator often cannot do.

### Regression averages what it cannot separate

The expert's lateral force is discontinuous at $y = 0$: at $x = -0.2$ m and at rest it is −0.79 N just below the axis and +0.80 N just above. Output (4) queries the clone across $y = 0$: it outputs +0.04 to +0.29 N over the whole range from −3 to +3 cm. A mean-squared-error fit of a smooth function to two branches settles between them. Here it leans toward one side and pushes only weakly, which is why the clone touches the obstacle in 6% of episodes even without pushes, where the expert never does. More data near $y = 0$ sharpens the transition but cannot make a smooth regressor choose; policies that represent several modes (mixture models, diffusion policies) can.

## Implementation

```io
INPUT: point_mass.xml; the scripted expert; mjcourse.il.bc
PROCESS: evaluate the expert and a clone with and without pushes; measure distances to the demonstrations; compare DAgger, more demonstrations and pushed demonstrations at equal label budgets on 3 seeds; query the clone across the expert's discontinuity
OUTPUT: printed tables and a figure

```python file=src/mjcourse/il/pointmass.py “"”The point-mass navigation task and its scripted expert (Level 14).

State s = (x, y, vx, vy); action a = (fx, fy) in [-1, 1] N, applied every 10 ms physics step. The mass starts left of a round obstacle and must stop at the goal on its right: within 4 cm of it, slower than 5 cm/s. An episode lasts at most 6 s.

With push_rate > 0, the mass is kicked at random times (a velocity change of push m/s in a random direction, on average push_rate times per second): the states that follow are ones a demonstration without kicks never shows.

The expert goes around the obstacle on the side it is already on: it steers to a waypoint 0.2 m above or below the obstacle’s centre until it has passed it, then to the goal, with a saturated PD law. Its action is therefore discontinuous at y = 0, where the side flips. “””

from future import annotations

from dataclasses import dataclass

import mujoco import numpy as np

from mjcourse.paths import model_path

GOAL = np.array([0.35, 0.0]) START_LOW, START_HIGH = np.array([-0.40, -0.25]), np.array([-0.30, 0.25]) MAX_STEPS = 600

def expert(state: np.ndarray) -> np.ndarray: p, v = state[:2], state[2:] side = 1.0 if p[1] >= 0 else -1.0 target = np.array([0.0, 0.2 * side]) if p[0] < -0.02 else GOAL return np.clip(4.0 * (target - p) - 1.5 * v, -1.0, 1.0)

def sample_starts(rng: np.random.Generator, n: int) -> np.ndarray: return rng.uniform(START_LOW, START_HIGH, (n, 2))

@dataclass class Rollout: states: np.ndarray # (T, 4) states at which actions were chosen actions: np.ndarray # (T, 2) actions taken success: bool hit_obstacle: bool steps: int

class PointMass: def init(self): self.model = mujoco.MjModel.from_xml_path(str(model_path(“point_mass”))) self.data = mujoco.MjData(self.model) self.obstacle = self.model.geom(“obstacle”).id

def rollout(self, policy, start: np.ndarray, max_steps: int = MAX_STEPS, push_rate: float = 0.0,
            push: float = 0.4, rng: np.random.Generator | None = None) -> Rollout:
    m, d = self.model, self.data
    mujoco.mj_resetData(m, d)
    d.qpos[:] = start
    mujoco.mj_forward(m, d)
    states, actions, hit = [], [], False
    p_push = push_rate * m.opt.timestep
    for t in range(max_steps):
        if p_push > 0 and rng.random() < p_push:
            angle = rng.uniform(0, 2 * np.pi)
            d.qvel[:] += push * np.array([np.cos(angle), np.sin(angle)])
        s = np.r_[d.qpos, d.qvel]
        a = np.clip(np.asarray(policy(s), dtype=float), -1.0, 1.0)
        states.append(s)
        actions.append(a)
        d.ctrl[:] = a
        mujoco.mj_step(m, d)
        hit |= any(self.obstacle in (c.geom1, c.geom2) for c in d.contact[:d.ncon])
        if np.linalg.norm(d.qpos - GOAL) < 0.04 and np.linalg.norm(d.qvel) < 0.05:
            return Rollout(np.array(states), np.array(actions), True, hit, t + 1)
    return Rollout(np.array(states), np.array(actions), False, hit, max_steps)

def evaluate(policy, starts: np.ndarray, push_rate: float = 0.0, seed: int = 0) -> dict: “"”Closed-loop success, obstacle contacts and time on the given starts; with pushes, the i-th episode’s kicks come from a generator seeded with (seed, i), so policies are compared on the same kicks.””” sim = PointMass() runs = [sim.rollout(policy, s, push_rate=push_rate, rng=np.random.default_rng([seed, i])) for i, s in enumerate(starts)] return {“success”: float(np.mean([r.success for r in runs])), “hit”: float(np.mean([r.hit_obstacle for r in runs])), “steps”: float(np.mean([r.steps for r in runs if r.success])) if any(r.success for r in runs) else float(“nan”), “runs”: runs}

```python file=src/mjcourse/il/bc.py
"""Behaviour cloning by regression, and DAgger (Level 14).

`fit` trains an MLP that maps states to actions by mean-squared error, with inputs and
outputs standardized by the dataset's statistics. `dagger` alternates: run the current
policy, ask the expert what it would have done in every state visited, add those labels
to the dataset, and fit again (Ross, Gordon and Bagnell, arXiv:1011.0686, with beta = 0
after the first iteration).
"""

from __future__ import annotations

import numpy as np
import torch
from torch import nn


class Policy:
    """A trained regressor with its normalization; callable on one state."""

    def __init__(self, net: nn.Module, x_mean, x_std, y_mean, y_std):
        self.net, self.x_mean, self.x_std, self.y_mean, self.y_std = net, x_mean, x_std, y_mean, y_std

    @torch.no_grad()
    def __call__(self, state: np.ndarray) -> np.ndarray:
        x = torch.as_tensor((np.asarray(state) - self.x_mean) / self.x_std, dtype=torch.float32)
        return self.net(x).numpy() * self.y_std + self.y_mean


def fit(states: np.ndarray, actions: np.ndarray, steps: int = 3000, hidden: int = 64, lr: float = 1e-3,
        batch: int = 256, seed: int = 0, validation: tuple[np.ndarray, np.ndarray] | None = None) -> tuple[Policy, dict]:
    """Returns the policy and the training (and validation) mean-squared error in normalized units."""
    torch.manual_seed(seed)
    x_mean, x_std = states.mean(axis=0), states.std(axis=0) + 1e-6
    y_mean, y_std = actions.mean(axis=0), actions.std(axis=0) + 1e-6
    x = torch.as_tensor((states - x_mean) / x_std, dtype=torch.float32)
    y = torch.as_tensor((actions - y_mean) / y_std, dtype=torch.float32)
    net = nn.Sequential(nn.Linear(states.shape[1], hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(),
                        nn.Linear(hidden, actions.shape[1]))
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    gen = torch.Generator().manual_seed(seed)
    for _ in range(steps):
        idx = torch.randint(0, len(x), (min(batch, len(x)),), generator=gen)
        loss = (net(x[idx]) - y[idx]).pow(2).mean()
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    with torch.no_grad():
        info = {"train_mse": float((net(x) - y).pow(2).mean())}
        if validation is not None:
            vx = torch.as_tensor((validation[0] - x_mean) / x_std, dtype=torch.float32)
            vy = torch.as_tensor((validation[1] - y_mean) / y_std, dtype=torch.float32)
            info["val_mse"] = float((net(vx) - vy).pow(2).mean())
    return Policy(net, x_mean, x_std, y_mean, y_std), info


def dagger(sim, expert, states: np.ndarray, actions: np.ndarray, starts_per_iteration: np.ndarray,
           iterations: int, evaluate_fn, seed: int = 0, push_rate: float = 0.0) -> list[dict]:
    """DAgger from an initial dataset. Each iteration rolls out the current policy from the
    next block of starts, labels every visited state with the expert, aggregates and refits.
    Returns one row per policy (iteration 0 is plain behaviour cloning)."""
    rows = []
    for it in range(iterations + 1):
        policy, _ = fit(states, actions, seed=seed)
        rows.append({"iteration": it, "labels": len(states), **evaluate_fn(policy)})
        if it == iterations:
            break
        for j, start in enumerate(starts_per_iteration[it]):
            visited = sim.rollout(policy, start, push_rate=push_rate, rng=np.random.default_rng([seed, 1000 + it, j])).states
            states = np.vstack([states, visited])
            actions = np.vstack([actions, np.array([expert(s) for s in visited])])
    return rows

```python file=examples/l14_1_bc_dagger.py “"”Lesson 14.1: behaviour cloning, covariate shift and DAgger on the point-mass task (Level 14 checkpoint).

INPUT mjcourse.il.pointmass (task and scripted expert), mjcourse.il.bc (cloning and DAgger) PROCESS (1) the expert on 100 held-out starts, without and with random pushes (1 per second on average, 0.4 m/s each); (2) behaviour cloning on 20 expert demonstrations recorded without pushes, evaluated without and with pushes; covariate shift measured as the distance from every visited state to the nearest demonstrated state, for the expert and the clone; (3) success under pushes against the number of expert labels, 3 seeds: DAgger (5 iterations of 4 rollouts), cloning on more demonstrations without pushes, and cloning on demonstrations recorded with pushes; a figure; (4) the expert’s discontinuity at y = 0 and what regression makes of it OUTPUT printed tables and runs/l14_1/labels.png

Run: python examples/l14_1_bc_dagger.py “””

requires: torch

import os from multiprocessing import Pool from pathlib import Path

import numpy as np import torch

from mjcourse.il import bc from mjcourse.il import pointmass as pm

FAST = os.environ.get(“MJC_FAST”) == “1” PUSH = 1.0 # pushes per second at evaluation HELD_OUT = pm.sample_starts(np.random.default_rng(999), 30 if FAST else 100) FIGURE = Path(file).resolve().parents[1] / “runs” / “l14_1” / “labels.png”

def demonstrations(n: int, seed: int, push_rate: float = 0.0) -> tuple[np.ndarray, np.ndarray]: sim, rng = pm.PointMass(), np.random.default_rng(seed) runs = [sim.rollout(pm.expert, s, push_rate=push_rate, rng=np.random.default_rng([seed, 7, i])) for i, s in enumerate(pm.sample_starts(rng, n))] return np.vstack([r.states for r in runs]), np.vstack([r.actions for r in runs])

def scores(policy, push_rate: float = PUSH) -> dict: out = pm.evaluate(policy, HELD_OUT, push_rate=push_rate) return {k: out[k] for k in (“success”, “hit”, “steps”)} | {“runs”: out[“runs”]}

def nearest_distance(points: np.ndarray, data: np.ndarray, scale: np.ndarray) -> np.ndarray: “"”Distance from each point to its nearest neighbour in data, in units of the data’s std.””” a, b = points / scale, data / scale out = np.empty(len(a)) for i in range(0, len(a), 2000): out[i:i + 2000] = np.sqrt(((a[i:i + 2000, None, :] - b[None, :, :]) ** 2).sum(-1)).min(axis=1) return out

def part1_2() -> None: torch.set_num_threads(4) for label, rate in ((“without pushes”, 0.0), (“with pushes”, PUSH)): s = scores(pm.expert, rate) print(f” expert {label:<16} success {s[‘success’]:.2f}, obstacle touched {s[‘hit’]:.2f}, mean time {s[‘steps’] / 100:.2f} s”) states, actions = demonstrations(20, seed=0) policy, info = bc.fit(states, actions, seed=0) print(f” 20 demonstrations: {len(states)} state-action pairs; training MSE {info[‘train_mse’]:.4f} (normalized units)”) for label, rate in ((“without pushes”, 0.0), (“with pushes”, PUSH)): s = scores(policy, rate) print(f” clone {label:<16} success {s[‘success’]:.2f}, obstacle touched {s[‘hit’]:.2f}, mean time {s[‘steps’] / 100:.2f} s”) scale = states.std(axis=0) print(f” distance from visited states to the nearest demonstrated state (units of the demonstrations’ std), with pushes:”) for name, pol in ((“expert”, pm.expert), (“clone”, policy)): runs = scores(pol)[“runs”] visited = np.vstack([r.states for r in runs]) dist = nearest_distance(visited, states, scale) per_run = np.array([nearest_distance(r.states, states, scale).max() for r in runs]) failed = np.array([not r.success for r in runs]) line = f” {name:<7} median {np.median(dist):.2f}, 90th percentile {np.percentile(dist, 90):.2f}, 99th {np.percentile(dist, 99):.2f}” if failed.any() and (~failed).any(): line += (f”; largest distance per episode: successes median {np.median(per_run[~failed]):.2f}, “ f”failures median {np.median(per_run[failed]):.2f}”) print(line)

def curve(seed: int) -> dict: torch.set_num_threads(1) rng = np.random.default_rng([seed, 1]) states, actions = demonstrations(20, seed=seed) sim = pm.PointMass() evaluate = lambda p: {k: v for k, v in scores(p).items() if k != “runs”} # noqa: E731 iterations = 2 if FAST else 5 dagger_rows = bc.dagger(sim, pm.expert, states, actions, pm.sample_starts(rng, iterations * 4).reshape(iterations, 4, 2), iterations, evaluate, seed=seed, push_rate=PUSH) more, pushed = [], [] for n in ((20, 40) if FAST else (20, 30, 40, 50)): for rows, rate in ((more, 0.0), (pushed, PUSH)): s, a = demonstrations(n, seed=seed, push_rate=rate) policy, _ = bc.fit(s, a, seed=seed) rows.append({“labels”: len(s), **evaluate(policy)}) return {“seed”: seed, “dagger”: dagger_rows, “more”: more, “pushed”: pushed}

def part3() -> list[dict]: seeds = 1 if FAST else 3 if FAST: results = [curve(0)] else: with Pool(seeds) as pool: results = pool.map(curve, range(seeds)) for name, key in ((“DAgger (iteration: labels, success)”, “dagger”), (“more demonstrations without pushes”, “more”), (“demonstrations recorded with pushes”, “pushed”)): print(f” {name}”) for r in results: print(f” seed {r[‘seed’]}: “ + “, “.join(f”{row[‘labels’]}: {row[‘success’]:.2f}” for row in r[key])) final = {key: [r[key][-1][“success”] for r in results] for key in (“dagger”, “more”, “pushed”)} print(f” at the largest label budget, success per seed: DAgger {final[‘dagger’]}, more demonstrations {final[‘more’]}, “ f”pushed demonstrations {final[‘pushed’]}”) return results

def part4() -> None: states, actions = demonstrations(20, seed=0) policy, _ = bc.fit(states, actions, seed=0) print(f” {‘y (m)’:>8}{‘expert fy (N)’:>15}{‘clone fy (N)’:>14} at x = -0.2 m, at rest”) for y in (-0.03, -0.01, -0.003, 0.0, 0.003, 0.01, 0.03): s = np.array([-0.2, y, 0.0, 0.0]) print(f” {y:>8.3f}{pm.expert(s)[1]:>15.2f}{float(policy(s)[1]):>14.2f}”)

def figure(results: list[dict]) -> None: import matplotlib matplotlib.use(“Agg”) import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(6.4, 3.8), constrained_layout=True) styles = ((“dagger”, “DAgger”, “#0072B2”, “o”), (“more”, “more demonstrations, no pushes”, “#D55E00”, “s”), (“pushed”, “demonstrations recorded with pushes”, “#009E73”, “^”)) for key, label, colour, marker in styles: for i, r in enumerate(results): xs = [row[“labels”] / 1000 for row in r[key]] ys = [row[“success”] for row in r[key]] ax.plot(xs, ys, color=colour, marker=marker, alpha=0.8, label=label if i == 0 else None) ax.set_xlabel(“expert labels (thousands of state-action pairs)”) ax.set_ylabel(“success with pushes (100 held-out starts)”) ax.set_ylim(0, 1.02) ax.grid(alpha=0.3) ax.legend(frameon=False, loc=”lower right”) FIGURE.parent.mkdir(parents=True, exist_ok=True) fig.savefig(FIGURE, dpi=150) plt.close(fig)

if name == “main”: print(f”torch {torch.version}; {len(HELD_OUT)} held-out starts; pushes {PUSH} per second, 0.4 m/s”) print(“(1, 2) the expert, and a clone of 20 demonstrations”) part1_2() print(“(3) success with pushes against expert labels, one line per seed”) results = part3() figure(results) print(f” figure: {FIGURE.relative_to(FIGURE.parents[2])}”) print(“(4) the expert’s choice of side, and the clone’s”) part4()

Output:

```text
torch 2.14.1+cpu; 100 held-out starts; pushes 1.0 per second, 0.4 m/s
(1, 2) the expert, and a clone of 20 demonstrations
  expert without pushes   success 1.00, obstacle touched 0.00, mean time 2.36 s
  expert with pushes      success 0.95, obstacle touched 0.11, mean time 3.19 s
  20 demonstrations: 4700 state-action pairs; training MSE 0.0579 (normalized units)
  clone  without pushes   success 1.00, obstacle touched 0.06, mean time 2.42 s
  clone  with pushes      success 0.53, obstacle touched 0.24, mean time 3.53 s
  distance from visited states to the nearest demonstrated state (units of the demonstrations' std), with pushes:
    expert  median 0.38, 90th percentile 1.26, 99th 2.79; largest distance per episode: successes median 2.47, failures median 3.65
    clone   median 1.08, 90th percentile 2.75, 99th 4.28; largest distance per episode: successes median 2.77, failures median 4.07
(3) success with pushes against expert labels, one line per seed
  DAgger (iteration: labels, success)
    seed 0: 4700: 0.53, 6867: 0.99, 8059: 0.94, 9385: 0.94, 10413: 0.94, 11499: 0.92
    seed 1: 4718: 0.64, 6011: 0.85, 7267: 0.79, 8454: 0.93, 9774: 0.93, 10957: 0.95
    seed 2: 4715: 0.59, 6241: 0.92, 7650: 0.89, 8932: 0.83, 9970: 0.85, 11409: 0.94
  more demonstrations without pushes
    seed 0: 4700: 0.53, 7057: 0.56, 9417: 0.54, 11769: 0.52
    seed 1: 4718: 0.64, 7072: 0.52, 9423: 0.60, 11779: 0.63
    seed 2: 4715: 0.59, 7066: 0.61, 9424: 0.54, 11791: 0.54
  demonstrations recorded with pushes
    seed 0: 7265: 0.94, 10670: 0.94, 14170: 0.95, 17365: 0.92
    seed 1: 6191: 0.92, 9737: 0.93, 13147: 0.94, 16669: 0.95
    seed 2: 6835: 0.95, 10673: 0.95, 14059: 0.94, 17018: 0.94
  at the largest label budget, success per seed: DAgger [0.92, 0.95, 0.94], more demonstrations [0.52, 0.63, 0.54], pushed demonstrations [0.92, 0.95, 0.94]
  figure: runs/l14_1/labels.png
(4) the expert's choice of side, and the clone's
     y (m)  expert fy (N)  clone fy (N)     at x = -0.2 m, at rest
    -0.030          -0.68          0.04
    -0.010          -0.76          0.13
    -0.003          -0.79          0.17
     0.000           0.80          0.18
     0.003           0.79          0.19
     0.010           0.76          0.22
     0.030           0.68          0.29

Debugging

Low validation loss, poor closed-loop success. Covariate shift: validation states come from the expert’s distribution. Measure closed-loop success, and the distance from visited states to the data.

More demonstrations do not help. They come from the same distribution. Add data where the learner goes: DAgger, or perturbed demonstrations.

The clone hesitates or drives into an obstacle where the expert would choose a side. Averaged modes. Check the expert’s actions near the decision boundary; consider a multimodal policy class.

DAgger makes things worse for an iteration. The new labels are concentrated in states the current clone visits; a policy refit on them can lose accuracy elsewhere. Keep all data (aggregate, never replace) and run more iterations.

Exercise

Run DAgger with pushes at 2 per second during both rollouts and tests, 3 seeds, and report success against labels for DAgger and for clean demonstrations. How does the gap change with the rate of pushes?

Challenge

Replace the MLP with a mixture density network (two Gaussian components) and repeat output (4). Show whether the clone now chooses a side near $y = 0$, and measure its obstacle-contact rate without pushes on the 100 held-out starts.

Research connection

[!research] Covariate shift in learned manipulation Modern visuomotor policies trained on teleoperated demonstrations face the same problem with far larger state spaces, and the remedies in this lesson have direct descendants: on-policy corrections from a human or scripted expert (the DAgger family), noise injection during demonstration, and policy classes that represent multimodal action distributions (Chi et al., arXiv:2303.04137, in the reading list) or act in chunks to reduce compounding errors (Zhao et al., arXiv:2304.13705). An evaluation that reports only demonstration-distribution performance hides exactly the failure this lesson measures; held-out starts and perturbations at test time expose it (inference).

{"id": "14.1-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "A cloned policy has low error on held-out demonstration states but fails often in closed loop. What is the most likely cause?",
   "options": ["Too little training", "Covariate shift: the policy reaches states the demonstrations never covered", "The expert is wrong", "The simulation is non-deterministic"],
   "answer": 1,
   "explain": "<p>Output (2): visited states three times further from the data, and failures go furthest.</p>"},
  {"kind": "mcq", "q": "Which change fixed the clone's failures under pushes in output (3)?",
   "options": ["Recording more clean demonstrations", "Labelling the states the clone itself visits (DAgger), or recording demonstrations that include pushes", "Training longer", "A larger network"],
   "answer": 1,
   "explain": "<p>Both reached 0.92 to 0.95; more clean demonstrations stayed at 0.52 to 0.63.</p>"},
  {"kind": "mcq", "q": "Why does the clone output a small lateral force at y = 0 where the expert outputs about ±0.8 N?",
   "options": ["The network is too small", "Mean-squared-error regression of a smooth function settles between two action branches", "The data is noisy", "The expert is stochastic"],
   "answer": 1,
   "explain": "<p>Output (4): +0.04 to +0.29 N across ±3 cm against −0.79 and +0.80 N.</p>"},
  {"kind": "mcq", "q": "What does DAgger need that cloning from recorded demonstrations does not?",
   "options": ["A reward function", "An expert that can label arbitrary states the learner visits", "A simulator", "A larger dataset"],
   "answer": 1,
   "explain": "<p>The expert must answer queries on the learner's state distribution, which a scripted expert can and a human often cannot.</p>"}
]}

Next

Lesson 14.2 treats demonstrations as a dataset: how to store them, how to measure what they cover, and why a validation loss is not an evaluation.