Physical robots do not receive low-dimensional state vectors from an oracle. They observe camera pixels, depth maps, and motor currents. Building a world model directly in pixel space by autoregressively generating future video frames is computationally prohibitive and semantically misaligned. Most pixels in a camera frame describe static background, ambient illumination, or texture variations that have no causal influence on manipulation dynamics.
A latent world model resolves this issue by splitting predictive dynamics into three distinct components:
This lesson constructs a latent world model on the tabletop scene, compares pixel-only training against position-probed auxiliary supervision, and demonstrates why high pixel reconstruction fidelity diverges from control utility.
The figure displays open-loop multi-step visual rollouts up to 2.0 s (20 steps) into the future from a held-out initial state. The top row shows ground-truth frames rendered by MuJoCo. The subsequent rows show rollouts produced by latent models trained under two distinct objectives: pure pixel reconstruction and pixel reconstruction augmented with position probes.

At short horizons (0.5 s), both models maintain plausible tabletop geometry. As the prediction horizon extends to 1.0 s and 2.0 s, the pure pixel model blurs contact boundaries. Because the puck occupies fewer than 3% of the image pixels, the pixel loss permits the puck to dissolve or drift while still minimizing aggregate mean squared error across the table surface.
Observations $o_t$ are $64 \times 64 \times 3$ RGB images captured from the overhead camera. The encoder consists of three convolutional layers with stride 2 and ReLU activations, followed by a linear projection to a $d = 32$ latent vector $z_t \in \mathbb{R}^{32}$.
The latent transition model computes an action-conditioned residual update:
\[z_{t+1} = z_t + g_\theta(z_t, a_t),\]where $g_\theta$ is a three-layer multi-layer perceptron with 256 units and SiLU activations taking the concatenated vector $[z_t, a_t] \in \mathbb{R}^{34}$ as input.
The decoder mirrors the encoder using transposed convolutions with stride 2 to map $z_t$ back to $\hat{o}_t \in [0, 1]^{64 \times 64 \times 3}$ via a sigmoid output layer:
\[\hat{o}_t = \text{dec}(z_t) .\]An optional linear probe predicts the planar coordinates of the pusher and all three pucks directly from the latent embedding:
\[\hat{p}_t = W_{\text{probe}} z_t + b_{\text{probe}}, \qquad \hat{p}_t \in \mathbb{R}^8 .\]Given an observed trajectory sequence $(o_t, a_t, o_{t+1}, \dots, o_{t+K})$, the models are trained across unrolled horizons of $K = 5$ steps. Under the pure pixel objective, the loss sums reconstruction error across the unrolled sequence:
\[\mathcal{L}_{\text{pixel}}(\theta) = \frac{1}{K+1} \sum_{k=0}^{K} \big\| \text{dec}(\hat{z}_{t+k}) - o_{t+k} \big\|_2^2 ,\]where $\hat{z}t = \text{enc}(o_t)$ and $\hat{z}{t+k+1} = \hat{z}{t+k} + g\theta(\hat{z}{t+k}, a{t+k})$.
Under the position-augmented objective, privileged coordinate supervision penalizes geometric drift in latent space:
\[\mathcal{L}_{\text{pos}}(\theta) = \mathcal{L}_{\text{pixel}}(\theta) + \frac{\lambda}{K+1} \sum_{k=0}^{K} \big\| \hat{p}_{t+k} - p_{t+k} \big\|_2^2 ,\]with scaling coefficient $\lambda = 100$.
Let an image $o \in \mathbb{R}^{N}$ contain $N = 64 \times 64 \times 3 = 12288$ subpixel values. Suppose a puck has radius $r = 30$ mm on a table of width $0.6$ m, covering approximately $M \approx 300$ pixels.
If a predicted puck is displaced by distance $\Delta x$ such that it does not overlap with the true puck at all, the penalty incurred in the total loss is bounded by:
\[\Delta \mathcal{L}_{\text{pixel}} \le \frac{2 M}{N} \cdot 1.0 \approx \frac{600}{12288} \approx 0.048 .\]Conversely, a slight shift in lighting, camera vignette, or table reflection that changes every pixel by merely $5\%$ produces:
\[\Delta \mathcal{L}_{\text{illum}} = \frac{N}{N} \cdot (0.05)^2 = 0.0025 .\]A gradient step optimizing $\mathcal{L}_{\text{pixel}}$ dedicates its capacity to matching the $97\%$ of background pixels. For downstream model-predictive control, a 5 mm error in puck position causes a planned push to miss contact completely, converting a task success into a failure, while background illumination errors are irrelevant. Evaluating world models by pixel MSE alone obscures this failure mode.
INPUT: tabletop.xml; expert-guided exploration rendered from top camera (64 x 64 RGB); standard state model of Lesson 23.2
PROCESS: train pixel-only and position-probed latent models; evaluate open-loop pixel MSE, detected puck position error, and missing object rate over horizons H in {1, 5, 10, 20}
OUTPUT: quantitative evaluation table; runs/l23_3/frames.png
```python file=examples/l23_3_latent_world_model.py import multiprocessing import os from concurrent.futures import ProcessPoolExecutor from pathlib import Path
import numpy as np import torch
from mjcourse import tabletop as tt from mjcourse.worldmodel import data, latent, standard
OUT = Path(file).resolve().parents[1] / “runs” / “l23_3” FAST = os.environ.get(“MJC_FAST”) == “1” TRAIN, TEST, STEPS = (40, 8, 300) if FAST else (600, 50, 30000) HORIZONS = (1, 5, 10, 20)
def collect(seed: int, n: int) -> list[dict]: with ProcessPoolExecutor(min(32, os.cpu_count() or 1), mp_context=multiprocessing.get_context(“spawn”)) as pool: return list(pool.map(data._collect, [(seed, i, {“render”: True}) for i in range(n)], chunksize=2))
def train(objective: str) -> str: torch.set_num_threads(max(1, int(os.environ.get(“SLURM_CPUS_PER_TASK”, 16)) // 2)) episodes = collect_cached(10, TRAIN) model = latent.fit(episodes, objective, steps=STEPS, log=lambda m: print(f” [{objective}] {m}”, flush=True)) path = OUT / f”latent_{objective}.pt” torch.save(model, path) return str(path)
def collect_cached(seed: int, n: int) -> list[dict]: import pickle
path = OUT / f"images_{seed}_{n}.pkl"
if not path.exists():
path.write_bytes(pickle.dumps(collect(seed, n)))
return pickle.loads(path.read_bytes())
def score(episodes: list[dict], predict, starts_per_episode: int = 4) -> dict: rng = np.random.default_rng(0) rows = {h: {“pixel”: [], “puck”: [], “missing”: []} for h in HORIZONS} for e in episodes: target = tt.OBJECTS.index(e[“task”][0]) for t0 in rng.integers(0, len(e[“actions”]) - max(HORIZONS), starts_per_episode): pred = predict(e, t0, max(HORIZONS)) for h in HORIZONS: true_img, img = e[“images”][t0 + h], pred[h] rows[h][“pixel”].append(np.mean((img.astype(float) - true_img.astype(float)) ** 2) / 255.0 ** 2) found = latent.puck_positions(img)[tt.OBJECTS[target]] rows[h][“missing”].append(found is None) if found is not None: rows[h][“puck”].append(np.linalg.norm(found - e[“states”][t0 + h][4 + 4 * target:6 + 4 * target])) return {h: {“pixel”: float(np.mean(r[“pixel”])), “puck”: float(np.mean(r[“puck”])) if r[“puck”] else float(“nan”), “missing”: float(np.mean(r[“missing”]))} for h, r in rows.items()}
def state_model_errors(episodes: list[dict]) -> dict: mlp = standard.model(“one_step”) rng = np.random.default_rng(0) rows = {h: [] for h in HORIZONS} for e in episodes: target = tt.OBJECTS.index(e[“task”][0]) for t0 in rng.integers(0, len(e[“actions”]) - max(HORIZONS), 4): pred = mlp.rollout(e[“states”][t0][None], e[“actions”][None, t0:t0 + max(HORIZONS)])[0] for h in HORIZONS: i = 4 + 4 * target rows[h].append(np.linalg.norm(pred[h, i:i + 2] - e[“states”][t0 + h][i:i + 2])) return {h: float(np.mean(v)) for h, v in rows.items()}
def figure(episode: dict, models: dict) -> None: import matplotlib matplotlib.use(“Agg”) import matplotlib.pyplot as plt
shown = (0, 5, 10, 20)
rows = {"MuJoCo (true)": [episode["images"][20 + h] for h in shown]}
for name, model in models.items():
pred = model.predict(episode["images"][20], episode["actions"][20:40])
rows[f"latent model, {name}"] = [pred[h] for h in shown]
fig, axes = plt.subplots(len(rows), len(shown), figsize=(2.0 * len(shown), 2.1 * len(rows)), constrained_layout=True)
for r, (label, images) in enumerate(rows.items()):
for c, img in enumerate(images):
axes[r, c].imshow(img)
axes[r, c].set_xticks([])
axes[r, c].set_yticks([])
if r == 0:
axes[r, c].set_title(f"{shown[c] / 10:.1f} s ahead", fontsize=9)
if c == 0:
axes[r, c].set_ylabel(label, fontsize=8)
fig.savefig(OUT / "frames.png", dpi=150)
plt.close(fig)
if name == “main”: OUT.mkdir(parents=True, exist_ok=True) train_eps, test_eps = collect_cached(10, TRAIN), collect_cached(11, TEST) print(f”(1) {TRAIN} training and {TEST} held-out episodes with images; detector error on true images “ f”{1000 * latent.detector_error(test_eps):.2f} mm”) with ProcessPoolExecutor(2, mp_context=multiprocessing.get_context(“spawn”)) as pool: paths = dict(zip((“pixels”, “positions”), pool.map(train, (“pixels”, “positions”)))) models = {k: torch.load(p, weights_only=False) for k, p in paths.items()} print(f”(2) trained two latent models, {STEPS} steps each”) print(“(3) open-loop prediction on held-out states”) results = {“first image copied forward”: score(test_eps, lambda e, t0, h: e[“images”][t0:t0 + h + 1].repeat(h + 1, 0))} for name, model in models.items(): results[f”latent, {name}”] = score(test_eps, lambda e, t0, h, m=model: m.predict(e[“images”][t0], e[“actions”][t0:t0 + h])) print(f” {‘model’:<28}{‘quantity’:<26}” + ““.join(f”{h:>9d}” for h in HORIZONS) + “ (steps of 0.1 s)”) for name, res in results.items(): print(f” {name:<28}{‘pixel MSE (x 1000)’:<26}” + ““.join(f”{1000 * res[h][‘pixel’]:9.2f}” for h in HORIZONS)) print(f” {‘’:<28}{‘pushed puck error (mm)’:<26}” + ““.join(f”{1000 * res[h][‘puck’]:9.1f}” for h in HORIZONS)) print(f” {‘’:<28}{‘pushed puck missing’:<26}” + ““.join(f”{res[h][‘missing’]:9.2f}” for h in HORIZONS)) sm = state_model_errors(test_eps) print(f” {‘state model (23.2)’:<28}{‘pushed puck error (mm)’:<26}” + ““.join(f”{1000 * sm[h]:9.1f}” for h in HORIZONS)) figure(test_eps[2], models) print(f”wrote {OUT / ‘frames.png’}”) ```
Open-loop prediction evaluation on held-out test episodes:
| Model | Metric | 1 step (0.1 s) | 5 steps (0.5 s) | 10 steps (1.0 s) | 20 steps (2.0 s) |
|---|---|---|---|---|---|
| First image copied | Pixel MSE ($\times 10^3$) | 1.82 | 8.41 | 18.25 | 38.10 |
| Puck position error (mm) | 4.8 | 21.3 | 43.1 | 82.5 | |
| Pushed puck missing | 0.00 | 0.00 | 0.00 | 0.00 | |
| Latent, pixels only | Pixel MSE ($\times 10^3$) | 0.94 | 2.15 | 4.38 | 9.12 |
| Puck position error (mm) | 6.2 | 19.8 | 38.7 | 71.4 | |
| Pushed puck missing | 0.00 | 0.02 | 0.08 | 0.22 | |
| Latent, positions probe | Pixel MSE ($\times 10^3$) | 0.98 | 2.24 | 4.51 | 9.35 |
| Puck position error (mm) | 3.1 | 8.6 | 16.4 | 31.2 | |
| Pushed puck missing | 0.00 | 0.00 | 0.01 | 0.04 | |
| State model (Lesson 23.2) | Puck position error (mm) | 1.0 | 3.8 | 8.1 | 20.6 |
Three empirical findings emerge:
We have evaluated dynamics prediction open-loop across both state and latent spaces. In Lesson 23.4, we close the loop: using these learned models inside model-predictive control to discover how model inaccuracies influence real robot decision making.