Writing · · 7 min read
How to Evaluate a World Model
Evaluation as a stack: one-step error, rollout error curves, perceptual metrics, action-sensitivity and counterfactual tests, downstream control, and physical probes. Each layer is run on three toy models, and each one catches something the others miss.
#world-models#evaluation#reinforcement-learning
A world model is good only relative to a question, and a single number asks only one question.
The usual report for a learned simulator is one metric: next-frame error, or FVD on generated clips. Each of those can be excellent for a model that is useless for planning. This note builds evaluation as a stack of tests, from cheapest to most expensive, and runs every layer on the same three toy models. The interesting result is that the ranking of the models changes from layer to layer. (For what a world model is, see what is a world model; for how one is used to plan, model predictive control and planning.)
The testbed
The ground truth is a driven, slightly damped pendulum. The state is angle and angular velocity; the action is a torque in .
import numpy as np
rng = np.random.default_rng(0)
DT = 0.1
def env_step(s, a):
"""Ground truth: a driven pendulum. s = (theta, omega), a = torque in [-1, 1]."""
th, om = s[..., 0], s[..., 1]
om2 = om + DT * (-9.8 * np.sin(th) + 2.0 * a - 0.1 * om)
return np.stack([th + DT * om2, om2], axis=-1)
# Logged transitions from a random policy, with sensor noise.
S = rng.uniform([-1.5, -3], [1.5, 3], size=(5000, 2))
A = rng.uniform(-1, 1, size=5000)
S2 = env_step(S, A) + rng.normal(0, 0.01, size=(5000, 2))
def fit(features):
X = features(S, A)
W, *_ = np.linalg.lstsq(X, S2, rcond=None)
return lambda s, a: features(np.atleast_2d(s), np.atleast_1d(a)) @ W
lin = fit(lambda s, a: np.c_[s, a, np.ones(len(s))]) # linear
blind = fit(lambda s, a: np.c_[s, np.sin(s[:, :1]), np.ones(len(s))]) # never sees the action
good = fit(lambda s, a: np.c_[s, np.sin(s[:, :1]), a, np.ones(len(s))]) # right features
models = {"linear": lin, "action-blind": blind, "sin-features": good}Three models with three different flaws. linear cannot represent . action-blind has the right physics but ignores the action entirely, which is the most common silent failure of video world models trained on passive footage. sin-features has the right function class and is limited only by noise.
Layer 1: one-step prediction error
The cheapest test: predict from the true and on held-out data.
| model | one-step MSE |
|---|---|
| linear | 0.00271 |
| action-blind | 0.00691 |
| sin-features | below 0.00001 |
The ranking is right, and the numbers are all small. One-step error is a necessary check (if it is bad, nothing else will be good) and almost never a sufficient one. It never feeds the model its own output, so it cannot see compounding.
Layer 2: the rollout error curve
Planning uses the model open loop: it starts from a real state and then consumes its own predictions. The test should do the same, and it should report a curve, not a point.
def rollout_error(f, s0, actions):
s_true, s_model, errs = s0.copy(), s0.copy(), []
for a in actions.T:
s_true, s_model = env_step(s_true, a), f(s_model, a)
errs.append(np.sqrt(np.mean((s_model - s_true) ** 2)))
return np.array(errs)
s0 = rng.uniform([-1, -1], [1, 1], size=(200, 2))
acts = rng.uniform(-1, 1, size=(200, 50))| model | h=1 | h=5 | h=10 | h=25 | h=50 |
|---|---|---|---|---|---|
| linear | 0.047 | 0.140 | 0.243 | 0.492 | 0.685 |
| action-blind | 0.085 | 0.135 | 0.194 | 0.298 | 0.463 |
| sin-features | 0.000 | 0.001 | 0.001 | 0.002 | 0.005 |
Read the middle row carefully. The model that ignores the action has the second-best long-horizon error, beating the linear model by a wide margin at 50 steps. The test actions are random, so their effects average out, and a model that predicts the action-free mean trajectory pays less than a model that tracks the action but gets the physics wrong. Rollout error under random actions rewards hedging. The shape of the curve (how fast it grows, where it bends) says more than any single horizon, and the earlier post on the bitter lesson in world models shows how a small consistent bias produces exactly this growth. But no point on the curve asks whether the action matters.
Layer 3: perceptual metrics, and what they cannot see
For pixel world models, MSE on frames punishes plausible-but-different futures and rewards blur. Three perceptual metrics are standard:
| metric | compares | paired? | what it misses |
|---|---|---|---|
| FID (Heusel et al., 2017) | Gaussian fits of image-classifier features over many frames | no | temporal coherence; which frame goes with which input |
| FVD (Unterthiner et al., 2018) | the same, with video-classifier features over clips | no | whether a clip is the right continuation of its context |
| LPIPS (Zhang et al., 2018) | deep-feature distance between a generated frame and its ground truth | yes | multimodal futures: a correct but different outcome is penalised |
The core of FID and FVD is the Fréchet distance between two Gaussians:
def sqrtm_psd(M):
w, V = np.linalg.eigh((M + M.T) / 2)
return (V * np.sqrt(np.clip(w, 0, None))) @ V.T
def frechet_distance(feat_real, feat_gen):
mu1, mu2 = feat_real.mean(0), feat_gen.mean(0)
S1, S2 = np.cov(feat_real, rowvar=False), np.cov(feat_gen, rowvar=False)
r1 = sqrtm_psd(S1)
return float(np.sum((mu1 - mu2) ** 2) + np.trace(S1 + S2 - 2 * sqrtm_psd(r1 @ S2 @ r1)))
real = rng.normal(size=(2000, 16)) # features of the true future clips
honest = real + rng.normal(0, 0.3, size=real.shape) # the right future, slightly off
shuffled = real[rng.permutation(len(real))] # a real future, but the wrong one| generator | Fréchet distance | per-sample squared error |
|---|---|---|
| honest | 0.042 | 1.45 |
| shuffled | 0.000 | 31.69 |
A model that returns a perfectly realistic future that belongs to a different input gets a perfect distributional score. FVD measures whether generated video looks like video. It does not measure whether it is the video that follows from this state and this action. Use it for realism, and pair it with a conditional test.
Layer 4: does the action change the future?
The direct test: hold the state fixed, vary the action, and compare how much the prediction moves with how much reality moves.
def action_sensitivity(f, s, a1=-1.0, a2=1.0):
pred = np.linalg.norm(f(s, np.full(len(s), a2)) - f(s, np.full(len(s), a1)), axis=-1)
real = np.linalg.norm(env_step(s, a2) - env_step(s, a1), axis=-1)
return float(np.mean(pred) / np.mean(real))| model | sensitivity ratio |
|---|---|
| linear | 1.002 |
| action-blind | 0.000 |
| sin-features | 1.001 |
A ratio of 1 means the model responds to the action as strongly as the world does. Zero means the action is decoration. For video models with learned or latent actions there is no ground-truth env_step, but the check survives: generate from the same context under every action code and measure the spread. A spread near zero means the actions have collapsed.
Layer 5: counterfactual consistency
Sensitivity checks magnitude at one step. A counterfactual test checks direction over many steps: run two different action plans from the same start, and compare the difference between the predicted futures with the difference between the true ones.
def counterfactual_gap(f, s0, plan_a, plan_b, H=20):
sa, sb, ta, tb = s0.copy(), s0.copy(), s0.copy(), s0.copy()
for t in range(H):
sa, sb = f(sa, plan_a[:, t]), f(sb, plan_b[:, t])
ta, tb = env_step(ta, plan_a[:, t]), env_step(tb, plan_b[:, t])
pred_delta, true_delta = sa - sb, ta - tb
cos = np.sum(pred_delta * true_delta, -1) / (
np.linalg.norm(pred_delta, axis=-1) * np.linalg.norm(true_delta, axis=-1) + 1e-9)
return float(np.mean(cos)), float(np.mean(np.linalg.norm(pred_delta - true_delta, axis=-1)))
pa, pb = np.ones((200, 50)), -np.ones((200, 50)) # full torque one way vs the other| model | cosine of deltas | error in the delta |
|---|---|---|
| linear | 0.994 | 0.499 |
| action-blind | 0.000 | 0.624 |
| sin-features | 1.000 | 0.003 |
The linear model points the right way and gets the size badly wrong after 20 steps. That is a specific, actionable finding: its sense of cause is right and its physics are not. Planning cares about exactly this quantity, because a planner compares futures under different actions and keeps the better one; errors common to both futures cancel, errors in the difference do not.
Layer 6: downstream control
The final test of a model used for planning is to plan with it and score the result in the real system. Random-shooting MPC, 256 plans of 15 steps, replanned every step, holding the pendulum at (a target the torque limit can sustain):
def mpc_cost(f, episodes=20, H=15, K=256, T=60, target=0.15):
costs = []
for ep in range(episodes):
s, cost = np.array([0.0, 0.0]), 0.0
for t in range(T):
plans = rng.uniform(-1, 1, size=(K, H))
sim, c = np.repeat(s[None], K, 0), np.zeros(K)
for h in range(H):
sim = f(sim, plans[:, h]); c += (sim[:, 0] - target) ** 2
s = env_step(s, plans[np.argmin(c), 0]) # act in the REAL system
cost += (s[0] - target) ** 2
costs.append(cost / T)
return float(np.mean(costs))| model | mean squared angle error |
|---|---|
| linear | 0.006 |
| action-blind | 0.041 |
| sin-features | 0.007 |
The linear model, worst at long-horizon rollout, ties the correct model here. Near , , and the task never leaves the region where the linear model is right. Downstream performance is task-relative: it certifies the model for this task and says nothing about swinging up from the bottom. The action-blind model, second-best on rollout error, is roughly seven times worse at control.
Layer 7: physical-consistency probes
Probes test properties the world is known to have, without needing ground-truth futures. With zero torque and friction, mechanical energy must not grow.
def energy(s):
return 0.5 * s[..., 1] ** 2 + 9.8 * (1 - np.cos(s[..., 0]))
def energy_rises(f, s0, H=200, w=20):
traj = [s0.copy()]
for t in range(H):
traj.append(f(traj[-1], np.zeros(len(s0))))
E = np.stack([energy(s) for s in traj])
return float(np.mean(E[w:] > E[:-w] + 1e-6)) # compare E across a w-step windowThe first version compared energy step to step () and flagged 46% of steps for every model, including the true environment: the discrete integrator lets energy wobble within a swing while it decays across swings. Probes must be calibrated on the real system before they are pointed at a model. With a 20-step window:
| model | windows where energy rises |
|---|---|
| true environment | 0.000 |
| linear | 0.064 |
| action-blind | 0.000 |
| sin-features | 0.000 |
For video world models the equivalents are object permanence (occlude an object for frames and check it reappears with the same identity), rigid-body consistency (a box keeps its size as it moves), and causal ordering (no effect before its contact).
Checklist
- Report one-step error, but never alone.
- Plot rollout error against horizon, under both random and planner-chosen actions; random actions reward models that ignore them.
- Use FVD or FID only for realism, and pair each with a paired metric such as LPIPS or a conditional test.
- Run an action-sensitivity test: vary only the action, measure the spread of predictions against the spread of reality, or across latent action codes.
- Run counterfactual pairs and check the difference between futures, which is what a planner consumes.
- Plan with the model and score in the real system, on the tasks it will be used for and one it will not.
- Calibrate every physical probe on ground truth first; if the real system fails the probe, the probe is wrong.
- Keep a table with all layers side by side. A model that wins every column is rare; the column it loses tells you what to fix.