Taxonomies of World Models › Implementing the Comparison
World Models HandbookAll chapters Taxonomies of World Models › Implementing the ComparisonOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
We begin with the shared substrate: the simulator, the dataset, and the nonlinear sensor. Everything downstream consumes these arrays.
import numpy as np
rng = np.random.default_rng(11)
DT = 0.05 # integration step (seconds)
NOISE_STD = 0.08 # process noise on angular velocity
def pendulum_step(theta, omega, u):
"""One Euler step of a damped pendulum with torque input u."""
omega_next = omega + DT * (-np.sin(theta) - 0.15 * omega + u)
omega_next += NOISE_STD * np.sqrt(DT) * rng.normal()
return theta + DT * omega_next, omega_next
N_TRAIN_EPISODES, N_TEST_EPISODES, EP_LEN = 30, 10, 120
episodes = []
for _ in range(N_TRAIN_EPISODES + N_TEST_EPISODES):
theta, omega = rng.uniform(-np.pi, np.pi), 0.0
th, om, ac = [theta], [omega], []
for _ in range(EP_LEN):
u = float(np.clip(rng.normal(0.0, 1.2), -2.0, 2.0))
theta, omega = pendulum_step(theta, omega, u)
th.append(theta)
om.append(omega)
ac.append(u)
episodes.append((np.array(th), np.array(om), np.array(ac)))
train_episodes = episodes[:N_TRAIN_EPISODES]
test_episodes = episodes[N_TRAIN_EPISODES:]Each episode holds 121 states and 120 actions, giving 120 paired transitions per episode and 3,600 training transitions in total. The actions are independent Gaussian torque samples clipped to a realistic range. They provide broad exploratory coverage without the deliberately designed excitation sequence that a classical system-identification experiment would use. This is deliberate: we want to see how far generic regression gets with ordinary exploratory data rather than idealized persistently exciting inputs.
Next we build the sensor. The key design decision is the : without it, a linear map of linear-in-feature dynamics would still be linear in observation coordinates, and Contestant A would unfairly escape its fate. Real sensors, cameras above all, are emphatically nonlinear, and the stands in for that fact.
OBS_DIM = 16
def features(theta, omega):
"""The physically sufficient statistics of the pendulum state."""
return np.stack([np.sin(theta), np.cos(theta), omega], axis=-1)
proj = rng.normal(size=(OBS_DIM, 3)) / np.sqrt(3.0)
def observe(theta, omega):
"""A fixed nonlinear 'sensor': random projection followed by tanh."""
return np.tanh(features(theta, omega) @ proj.T)
obs_episodes = [(observe(th, om), ac, th, om) for th, om, ac in train_episodes]
test_obs_episodes = [
(observe(th, om), ac, th, om) for th, om, ac in test_episodes
]To see what the sensor does to the state, we plot two views of the same training transitions: raw observation coordinates and the sufficient features, both colored by the true angle. In feature coordinates the state organizes into a clean ring parameterized by ; in observation coordinates the same data is a tangle with no visible angular structure. This is the coordinate problem Contestant A must solve with nothing but a linear map.


Now Contestant A: ordinary least squares from concatenated observation-and-action to next observation. This is the simplest thing that deserves the name "world model," and it is worth internalizing how little machinery it involves.
def stack(episode_list):
X = np.concatenate([o[:-1] for o, _, _, _ in episode_list])
U = np.concatenate([ac for _, ac, _, _ in episode_list])[:, None]
Y = np.concatenate([o[1:] for o, _, _, _ in episode_list])
return X, U, Y
X_train, U_train, Y_train = stack(obs_episodes)
print(
f"Training transitions: {X_train.shape[0]}, observation dimension: {X_train.shape[1]}"
)
phi_train = np.concatenate([X_train, U_train], axis=1)
W_obs, *_ = np.linalg.lstsq(phi_train, Y_train, rcond=None)
resid_one_step = Y_train - phi_train @ W_obs
print(
f"Observation-space linear model, one-step RMSE: {np.sqrt((resid_one_step**2).mean()):.4f}"
)Contestant B learns a compact state estimator in PyTorch. Its three outputs are trained to match the sufficient features on the simulator's training trajectories. This supervision is not a general recipe for world-model learning because a real agent rarely receives the true state. It is useful here because it creates a controlled middle case: observation-space dynamics must first pass through an imperfect learned encoder, while the transition itself is held fixed across B and C.
import torch
import torch.nn as nn
torch.manual_seed(0)
LATENT_DIM = 3
F_train = np.concatenate(
[features(th[:-1], om[:-1]) for _, _, th, om in obs_episodes]
)
F_next_train = np.concatenate(
[features(th[1:], om[1:]) for _, _, th, om in obs_episodes]
)
encoder = nn.Sequential(
nn.Linear(OBS_DIM, 64),
nn.SiLU(),
nn.Linear(64, 64),
nn.SiLU(),
nn.Linear(64, LATENT_DIM),
)
optimizer = torch.optim.Adam(encoder.parameters(), lr=1e-3)
x_t = torch.tensor(X_train, dtype=torch.float32)
feature_t = torch.tensor(F_train, dtype=torch.float32)
loss_history = []
for epoch in range(600):
z = encoder(x_t)
loss = ((z - feature_t) ** 2).mean()
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_history.append(loss.item())
with torch.no_grad():
z_train = encoder(x_t).numpy()
print(f"Final latent state-estimation loss: {loss_history[-1]:.6f}")The training curve below shows state-estimation loss descending smoothly. Low supervised loss confirms that the sensor can be untangled on the training distribution, but rollout evaluation is still needed because small initial-state errors can persist or amplify under dynamics.

Contestant C receives the true features directly. Both B and C use the deterministic pendulum update with the stochastic term removed, so their difference comes only from learned versus oracle initialization. We also fit a one-step linear feature model solely to estimate the scale of process noise from residuals for the stochastic extension.
g_train = np.concatenate([F_train, U_train], axis=1)
W_feat, *_ = np.linalg.lstsq(g_train, F_next_train, rcond=None)
feat_residuals = F_next_train - g_train @ W_feat
noise_cov = np.cov(feat_residuals.T)
print("Feature-space linear model, residual std per dimension:")
print(np.sqrt(np.diag(noise_cov)).round(4))Evaluation happens in terms of the physical angle . The observation model therefore needs a small regularized linear readout from sensor coordinates to . Contestants B and C already represent those quantities explicitly, so they require no reporting model.
theta_train = np.concatenate([th[:-1] for _, _, th, om in obs_episodes])
sc_targets = np.stack([np.sin(theta_train), np.cos(theta_train)], axis=1)
readout_design = np.column_stack([X_train, np.ones(len(X_train))])
readout_penalty = np.eye(readout_design.shape[1])
readout_penalty[-1, -1] = 0.0
W_readout = np.linalg.solve(
readout_design.T @ readout_design + 1e-6 * readout_penalty,
readout_design.T @ sc_targets,
)Now the evaluation harness. Each contestant receives a rollout function that starts from an initial observation (or feature vector) and applies a held-out action sequence entirely inside its own imagination. Errors are computed on the wrapped angle difference so that crossings of do not masquerade as huge mistakes.
HORIZON = 60
def wrap(angle):
return np.arctan2(np.sin(angle), np.cos(angle))
def rollout_obs_linear(o0, actions_h):
o = o0.copy()
thetas = []
for u in actions_h:
o = np.concatenate([o, [u]]) @ W_obs
sc = np.concatenate([o, [1.0]]) @ W_readout
thetas.append(np.arctan2(sc[0], sc[1]))
return np.array(thetas)
def structured_feature_step(f, u, omega_noise=0.0):
theta = np.arctan2(f[0], f[1])
omega = f[2]
omega_next = omega + DT * (-np.sin(theta) - 0.15 * omega + u) + omega_noise
theta_next = theta + DT * omega_next
return features(theta_next, omega_next)
def rollout_latent(o0, actions_h):
with torch.no_grad():
z = encoder(torch.tensor(o0, dtype=torch.float32)).numpy()
thetas = []
for u in actions_h:
z = structured_feature_step(z, u)
thetas.append(np.arctan2(z[0], z[1]))
return np.array(thetas)
def rollout_oracle_feature(f0, actions_h):
f = f0.copy()
thetas = []
for u in actions_h:
f = structured_feature_step(f, u)
thetas.append(np.arctan2(f[0], f[1]))
return np.array(thetas)
rmse_curves = {}
example_predictions = {}
contestants = [
("observation linear", rollout_obs_linear, "observation"),
("learned latent", rollout_latent, "observation"),
("oracle feature", rollout_oracle_feature, "feature"),
]
for name, rollout, initializer in contestants:
sq_errs = []
for idx, (o_seq, ac, th, om) in enumerate(test_obs_episodes):
initial_state = (
features(th[0], om[0]) if initializer == "feature" else o_seq[0]
)
pred = rollout(initial_state, ac[:HORIZON])
truth = th[1 : HORIZON + 1]
sq_errs.append(wrap(pred - truth) ** 2)
if idx == 0:
example_predictions[name] = pred
rmse_curves[name] = np.sqrt(np.mean(sq_errs, axis=0))
true_example = test_episodes[0][0][1 : HORIZON + 1]
check_step = HORIZON // 2
for name in rmse_curves:
print(
f"{name:>18s}: angle RMSE at step {check_step} = {rmse_curves[name][check_step - 1]:.3f} rad"
)Finally, the stochastic extension. We estimate the angular-velocity noise scale from Contestant C's one-step residuals, inject Gaussian innovations into the structured transition, and sample two hundred rollouts from one held-out initial state with zero torque. For contrast we also simulate true noisy pendulum trajectories from the same initial state.
N_SAMPLES, STOCH_HORIZON = 200, 80
estimated_omega_noise = max(float(np.std(feat_residuals[:, 2])), 1e-6)
_, _, th_ref, om_ref = test_obs_episodes[1]
f_init = features(th_ref[0], om_ref[0])
samples = np.zeros((N_SAMPLES, STOCH_HORIZON))
for s in range(N_SAMPLES):
f = f_init.copy()
for h in range(STOCH_HORIZON):
f = structured_feature_step(
f,
0.0,
omega_noise=estimated_omega_noise * rng.standard_normal(),
)
samples[s, h] = np.arctan2(f[0], f[1])
deterministic_rollout = rollout_oracle_feature(f_init, np.zeros(STOCH_HORIZON))
bands = np.percentile(samples, [5, 25, 50, 75, 95], axis=0)
true_noisy = np.zeros((5, STOCH_HORIZON))
for k in range(5):
th_s, om_s = th_ref[0], om_ref[0]
for h in range(STOCH_HORIZON):
th_s, om_s = pendulum_step(th_s, om_s, 0.0)
true_noisy[k, h] = th_sAll computation is done, and what follows are three views of the same underlying arrays.