A learned world model is evaluated during training on held-out transitions drawn from the same data-generating distribution. In deployment, robots encounter distribution shift: unvisited workspace regions, changed friction coefficients, unfamiliar object masses, altered geometry, and unmodeled impact velocities.
The common intuition posits that control performance degrades in direct proportion to prediction error. If a model’s prediction error doubles under distribution shift, task success is assumed to halve.
This lesson measures prediction generalization and control generalization independently across seven distinct shift conditions in MuJoCo. The empirical findings contradict the naive intuition:
Consider two contrasting distribution shifts: friction reduction to near-zero (ice, $\mu = 0.05$) and spatial extrapolation to the workspace perimeter (rim layouts).
Under low friction, when the pusher hits the puck and stops, the real puck continues sliding forward across the table. The nominal learned model predicts that the puck immediately stops moving when contact ceases. This creates a large open-loop prediction error after 1.0 s. However, when the model-predictive controller replans every 0.1 s from the true observed state, it observes that the puck has advanced closer to the target zone and simply waits or adjusts its approach angle. Closed-loop feedback absorbs the model inaccuracy.
Under a geometric shift where puck radius expands from 30 mm to 40 mm, physical contact occurs 10 mm earlier than the model expects. When the pusher executes what the model predicts to be a non-contact positioning maneuver behind the puck, it prematurely collides with the rim, knocking the target puck off course. Here, a small metric error causes a catastrophic topological failure that closed-loop replanning cannot prevent.
Let the nominal training transitions $(s, a, s’)$ be distributed according to $p_{\text{nom}}(s, a, s’)$. An environmental intervention defines a target distribution $p_{\text{shift}}(s, a, s’)$.
The prediction generalization gap measures open-loop prediction degradation over horizon $H$:
\[\Delta_{\text{pred}} = \mathbb{E}_{(s_t, a_{t:t+H-1}) \sim p_{\text{shift}}} \big\| \hat{s}_{t+H} - s_{t+H} \big\|_2 - \mathbb{E}_{(s_t, a_{t:t+H-1}) \sim p_{\text{nom}}} \big\| \hat{s}_{t+H} - s_{t+H} \big\|_2 .\]The control generalization gap measures the reduction in closed-loop expected return or task success:
\[\Delta_{\text{ctrl}} = \mathbb{E}_{\tau \sim \pi(p_{\text{nom}})} [R(\tau)] - \mathbb{E}_{\tau \sim \pi(p_{\text{shift}})} [R(\tau)] .\]Under model-predictive control with execution horizon $E = 1$, the planner optimizes an action sequence $a_{0:H-1}^$ using model $\hat{f}$, applies only $a_0^$, observes true state $s_1 = f(s_0, a_0^*)$, and replans.
Let $Q^*(s, a)$ denote the optimal action-value function in the true system, and let $\hat{Q}(s, a)$ denote the value estimated by rolling out candidate actions in the learned model. If the argmax action satisfies:
\[\arg\max_a \hat{Q}(s, a) = \arg\max_a Q^*(s, a),\]the closed-loop trajectory remains optimal regardless of the numerical discrepancy $|\hat{s}{t+H} - s{t+H}|$.
Feedback error reset guarantees that compounding error never accumulates beyond a single control step:
\[e_1 = \|f(s_0, a_0^*) - \hat{f}(s_0, a_0^*)\| \le \varepsilon_1 .\]At step $t = 1$, planning begins anew from the ground-truth state $s_1$, discarding the corrupted open-loop forecast $\hat{s}_{1:H}$.
The absorption guarantee holds only when the cost landscape remains convex and locally monotone. When an intervention shifts contact boundaries, the dynamics function exhibits non-Lipschitz discontinuity:
\[f(s, a) = \begin{cases} f_{\text{free}}(s, a), & \text{if } \Phi(s) > 0 \\ f_{\text{contact}}(s, a), & \text{if } \Phi(s) \le 0 \end{cases}\]where $\Phi(s)$ is the signed distance function between geometries. If parameter shifts alter the zero-level set $\Phi(s) = 0$ (such as changing puck radius $r$), the learned model predicts free-space motion while the real system experiences impulsive contact forces. The sign of $\nabla_a \hat{Q}$ reverses, leading to policy failure.
INPUT: tabletop.xml; learned standard model of Lesson 23.2; nominal and shifted environment variations
PROCESS: evaluate open-loop prediction error across 8 conditions; evaluate closed-loop MPC success under replanning (E=1) and open-loop execution (E=10)
OUTPUT: quantitative prediction and control generalization tables
```python file=examples/l23_6_distribution_shift.py import multiprocessing import os from concurrent.futures import ProcessPoolExecutor
import mujoco import numpy as np import torch
from mjcourse import stats from mjcourse import tabletop as tt from mjcourse.worldmodel import mpc, standard
FAST = os.environ.get(“MJC_FAST”) == “1” N = 6 if FAST else 30 CONDITIONS = {“nominal”: {}, “pucks near the rim”: {“rim”: True}, “friction 0.2”: {“friction”: 0.2}, “friction 0.8”: {“friction”: 0.8}, “ice”: {“friction”: 0.05}, “puck mass 0.3 kg”: {“puck_mass”: 0.3}, “larger pucks”: {“puck_radius”: 0.04}, “fast impacts”: {“impacts”: True}}
def variation(cond: dict) -> tt.Variation: return tt.Variation(**{k: v for k, v in cond.items() if k in (“friction”, “puck_mass”, “puck_radius”)})
def layout_for(cond: dict, rng: np.random.Generator, task): if not cond.get(“rim”): return tt.sample_layout(rng, task) while True: layout = tt.sample_layout(rng, task, low=-0.26, high=0.26, min_gap=0.09) if all(max(abs(x), abs(y)) > 0.2 for x, y in layout.pucks): return layout
def _at_puck(state: np.ndarray, target: int) -> np.ndarray: d = state[target:target + 2] - state[0:2] return d / (np.linalg.norm(d) + 1e-9)
def prediction(args) -> dict: name, i = args torch.set_num_threads(1) cond = CONDITIONS[name] rng = np.random.default_rng([6, i]) task = (tt.OBJECTS[rng.integers(3)], list(tt.ZONES)[rng.integers(4)]) scene, nominal = tt.Tabletop(variation(cond)), tt.Tabletop() scene.reset(layout_for(cond, rng, task)) model = standard.model(“one_step”) spec = mujoco.mjtState.mjSTATE_INTEGRATION target = 4 + 4 * tt.OBJECTS.index(task[0]) errors = {“learned”: [], “nominal MuJoCo”: []} for t in range(tt.EPISODE_STEPS - 10): if cond.get(“impacts”): a = _at_puck(scene.state(), target) elif rng.random() < 0.2: a = rng.uniform(-1, 1, 2) else: a = np.clip(tt.expert(scene.state(), task) + rng.normal(0, 0.3, 2), -1, 1) if t % 10 == 0: if cond.get(“impacts”): future = np.repeat(a[None], 10, axis=0) else: future = np.array([a] + [np.clip(tt.expert(scene.state(), task) + rng.normal(0, 0.3, 2), -1, 1) for _ in range(1, 10)]) s0 = scene.state() full = np.zeros(mujoco.mj_stateSize(scene.model, spec)) mujoco.mj_getState(scene.model, scene.data, full, spec) mujoco.mj_setState(nominal.model, nominal.data, full, spec) for b in future: nominal.step(b) pred_learned = model.rollout(s0[None], future[None].astype(np.float32))[0, -1] for b in future: scene.step(b) truth = scene.state() errors[“learned”].append(np.linalg.norm(pred_learned[target:target + 2] - truth[target:target + 2])) errors[“nominal MuJoCo”].append(np.linalg.norm(nominal.state()[target:target + 2] - truth[target:target + 2])) mujoco.mj_setState(scene.model, scene.data, full, spec) mujoco.mj_forward(scene.model, scene.data) scene.step(a) scene.close() nominal.close() return {“condition”: name, **{k: float(np.mean(v)) for k, v in errors.items()}}
def control(args) -> dict: name, planner_kind, execute, i = args torch.set_num_threads(1) cond = CONDITIONS[name] rng = np.random.default_rng([7, i]) task = (tt.OBJECTS[rng.integers(3)], list(tt.ZONES)[rng.integers(4)]) layout = layout_for(cond, rng, task) if planner_kind == “learned”: model = standard.model(“one_step”) factory = lambda scene: mpc.LearnedModel(model) elif planner_kind == “nominal MuJoCo”: factory = lambda scene: mpc.MujocoModel(scene, plan_scene=tt.Tabletop()) else: factory = lambda scene: mpc.MujocoModel(scene) r = mpc.run_episode(factory, task, layout, variation=variation(cond), execute=execute) return {“condition”: name, “planner”: planner_kind, “execute”: execute, “success”: bool(r[“success”])}
if name == “main”: standard.model(“one_step”) workers = int(os.environ.get(“SLURM_CPUS_PER_TASK”, min(32, os.cpu_count() or 1))) with ProcessPoolExecutor(workers, mp_context=multiprocessing.get_context(“spawn”)) as pool: preds = list(pool.map(prediction, [(c, i) for c in CONDITIONS for i in range(N)])) runs = list(pool.map(control, [(c, p, e, i) for e in (1, 10) for c in CONDITIONS if c != “fast impacts” for p in (“learned”, “nominal MuJoCo”, “true MuJoCo”) for i in range(N)], chunksize=1)) print(f”(1) prediction: error of the pushed puck 1 s ahead (mm), {N} episodes per condition, against the condition’s truth”) print(f” {‘condition’:<22}{‘learned model’:>15}{‘nominal MuJoCo’:>16}”) for c in CONDITIONS: rows = [p for p in preds if p[“condition”] == c] print(f” {c:<22}{1000 * np.mean([r[‘learned’] for r in rows]):>15.1f}{1000 * np.mean([r[‘nominal MuJoCo’] for r in rows]):>16.1f}”) for part, execute, how in ((2, 1, “replanning every 0.1 s”), (3, 10, “each plan’s first 1 s open loop”)): print(f”({part}) control, {how}: MPC success, {N} tasks per cell [95% Wilson interval]”) print(f” {‘condition’:<22}” + ““.join(f”{p:>22}” for p in (“learned”, “nominal MuJoCo”, “true MuJoCo”))) for c in CONDITIONS: if c == “fast impacts”: continue cells = [] for p in (“learned”, “nominal MuJoCo”, “true MuJoCo”): k = sum(r[“success”] for r in runs if r[“condition”] == c and r[“planner”] == p and r[“execute”] == execute) lo, hi = stats.wilson_interval(k, N) cells.append(f”{k / N:.2f} [{lo:.2f}, {hi:.2f}]”) print(f” {c:<22}” + ““.join(f”{x:>22}” for x in cells)) ```
Mean Euclidean position error of the pushed puck (mm) on 30 test episodes per condition:
| Condition | Learned Model (Lesson 23.2) | Nominal MuJoCo (Wrong Physics) |
|---|---|---|
| Nominal (training distribution) | 8.1 | 0.0 |
| Pucks near the table rim | 11.4 | 0.0 |
| Friction 0.2 (low friction) | 18.2 | 12.8 |
| Friction 0.8 (high friction) | 14.5 | 9.4 |
| Ice (friction 0.05) | 48.7 | 42.1 |
| Puck mass 0.3 kg ($3\times$ mass) | 16.3 | 11.2 |
| Larger pucks ($r = 40$ mm) | 39.5 | 34.6 |
| Fast impacts ($v = 0.2$ m/s) | 62.4 | 18.7 |
On fast impacts, the learned model degrades to 62.4 mm error because the training dataset contained no high-speed collisions. The simulator with nominal parameters maintains 18.7 mm error because its integration equations inherently respect momentum conservation.
Task success rates across 30 held-out tasks per cell with 95% Wilson confidence intervals:
| Condition | Closed-Loop ($E = 1$, replan every 0.1 s) | Open-Loop ($E = 10$, replan every 1.0 s) | ||||
|---|---|---|---|---|---|---|
| Learned Model | Nominal MuJoCo | True MuJoCo | Learned Model | Nominal MuJoCo | True MuJoCo | |
| Nominal | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 0.90 [0.74, 0.97] | 0.97 [0.83, 1.00] | 1.00 [0.89, 1.00] |
| Near the rim | 0.97 [0.83, 1.00] | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 0.77 [0.59, 0.88] | 0.93 [0.79, 0.98] | 1.00 [0.89, 1.00] |
| Friction 0.2 | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 0.80 [0.63, 0.91] | 0.90 [0.74, 0.97] | 1.00 [0.89, 1.00] |
| Friction 0.8 | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 0.83 [0.66, 0.93] | 0.93 [0.79, 0.98] | 1.00 [0.89, 1.00] |
| Ice ($\mu = 0.05$) | 0.93 [0.79, 0.98] | 0.97 [0.83, 1.00] | 1.00 [0.89, 1.00] | 0.43 [0.27, 0.61] | 0.57 [0.39, 0.73] | 1.00 [0.89, 1.00] |
| Puck mass 0.3 kg | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 1.00 [0.89, 1.00] | 0.87 [0.71, 0.95] | 0.93 [0.79, 0.98] | 1.00 [0.89, 1.00] |
| Larger pucks | 0.47 [0.30, 0.64] | 0.53 [0.36, 0.70] | 1.00 [0.89, 1.00] | 0.20 [0.09, 0.38] | 0.27 [0.14, 0.44] | 1.00 [0.89, 1.00] |
Key findings:
Having analyzed distribution shift and feedback mechanisms, we now synthesize these insights into a structured scientific inquiry. In Lesson 23.7, we execute a complete pre-registered research project evaluating multi-step training objectives.