Compositionality and Systematic Generalization › Implementation: Factorized Versus Monolithic
World Models HandbookAll chapters Compositionality and Systematic Generalization › Implementation: Factorized Versus MonolithicOne 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 will now build both models in PyTorch, train them on identical data from the six training worlds, and compare fixed-action autoregressive rollouts on all nine. The monolithic model receives the world's identity as a single index and uses one shared prediction head. The factorized model receives the two mechanism indices separately and computes its prediction as the sum of two mechanism-specific heads. The implemented models do not have matched parameter counts: the monolithic model has 5,001 trainable parameters and the factorized model has 9,906. The experiment therefore illustrates the two constructions under matched data and optimization, with model capacity left as a confound; it is not a capacity-controlled causal estimate of structure alone.
Before reading the code, it helps to know exactly what the comparison is and is not. It is a single-seed, single-split pedagogical illustration under matched data and optimization but unmatched parameter count. It does not satisfy the minimum serious-evaluation protocol stated above: it has no repeated seeds or splits, no planner that selects actions, and no adaptation study. It shows how this particular untrained world-index representation behaves on cells the data never touched; capacity-matched controls and the full evaluation suite would be required to isolate structure's causal contribution or make a deployment claim.
First, import the remaining libraries and define the ground-truth world. The mechanism tables store the parameters for each variant, and a single rollout function composes them exactly as the equation above specifies.
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
## Mechanism A variants: (decay alpha, curvature kappa)
FACTOR_A = {0: (0.65, 0.40), 1: (0.85, -0.30), 2: (0.95, 0.00)}
## Mechanism B variants: (action gain beta, state-action coupling gamma)
FACTOR_B = {0: (0.80, 0.00), 1: (1.60, 0.20), 2: (0.40, -0.20)}
def rollout_true(m, n, actions, rng):
"""Simulate the true world (m, n) from x0 = 0 under a given action sequence."""
alpha, kappa = FACTOR_A[m]
beta, gamma = FACTOR_B[n]
x = np.zeros(len(actions) + 1)
for t, u in enumerate(actions):
x[t + 1] = (
alpha * x[t]
+ kappa * np.tanh(x[t])
+ beta * u
+ gamma * u * x[t]
+ 0.02 * rng.normal()
)
return xNote how the simulator is written: the mechanism tables are looked up independently and their contributions are summed. The ground-truth generator is itself factorized, which means the swap test could be run exactly by exchanging rows of the two tables. This is one of the advantages of a synthetic study: every claim about mechanism identity is verifiable by construction, whereas in a real domain the same claims would require the diagnostics from the previous section.
Now the dataset. For each of the nine worlds we generate trajectories driven by random actions. The training split is six worlds; the anti-diagonal is held out. Each factor value still appears in multiple training worlds, which is exactly the primitive-coverage condition from the previous section.
ALL_PAIRS = [(m, n) for m in range(3) for n in range(3)]
TRAIN_PAIRS = [(0, 0), (1, 0), (0, 1), (1, 2), (2, 1), (2, 2)]
TEST_PAIRS = [(2, 0), (1, 1), (0, 2)]
def make_dataset(pairs, traj_per_pair=8, T=100, seed=0):
rng = np.random.default_rng(seed)
xs, us, xs_next, ms, ns = [], [], [], [], []
for m, n in pairs:
for _ in range(traj_per_pair):
actions = rng.normal(0.0, 1.0, size=T)
x = rollout_true(m, n, actions, rng)
xs.append(x[:-1])
us.append(actions)
xs_next.append(x[1:])
ms.append(np.full(T, m))
ns.append(np.full(T, n))
return tuple(np.concatenate(part) for part in (xs, us, xs_next, ms, ns))
data_train = make_dataset(TRAIN_PAIRS, seed=0)
data_test = make_dataset(TEST_PAIRS, traj_per_pair=4, seed=1)Training transitions: 4800 Held-out test transitions: 1200
The training pool holds 4,800 transitions (six worlds, eight trajectories each, 100 steps per trajectory), and the held-out worlds contribute 1,200 more. Every mechanism value remains abundantly observed in training, which is the primitive-coverage condition the split was designed to satisfy, so the split tests novelty of composition rather than novelty of primitives. The two models share their trunk but differ in one decisive place. The monolithic model embeds the world index (a single integer from 0 to 8) and has no way to relate world to worlds and : its embedding for index 4 is simply never trained. The factorized model embeds each mechanism separately and combines them with two additive heads, which mirrors the additive structure of the true dynamics.
It is worth pausing on what "never trained" means for the categorical-ID baseline, because it explains the coming divergence precisely. The embedding for world index 4 (the pair ) was initialized randomly and receives no direct gradient, since no training transition carries that index. Its contribution is therefore an untrained random vector, although the shared trunk is updated by seen cells and can change the missing-cell output indirectly. The baseline's held-out failure is consistent with an arbitrary world label that supplies no sharing rule; the unmatched parameter counts mean this run does not exclude capacity as a contributor to the performance gap.
def mlp(in_dim, hidden=64):
return nn.Sequential(
nn.Linear(in_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1),
)
class MonolithicModel(nn.Module):
"""One embedding per world; a novel world has a random, untrained embedding."""
def __init__(self, n_pairs=9, d=8, hidden=64):
super().__init__()
self.emb = nn.Embedding(n_pairs, d)
self.net = mlp(2 + d)
def forward(self, x, u, m, n):
e = self.emb(m * 3 + n)
return self.net(
torch.cat([x.unsqueeze(-1), u.unsqueeze(-1), e], dim=-1)
).squeeze(-1)
class FactorizedModel(nn.Module):
"""Mechanism A and mechanism B are embedded separately and composed additively."""
def __init__(self, n_a=3, n_b=3, d=8):
super().__init__()
self.emb_a = nn.Embedding(n_a, d)
self.emb_b = nn.Embedding(n_b, d)
self.head_a = mlp(2 + d)
self.head_b = mlp(2 + d)
def forward(self, x, u, m, n):
pred_a = self.head_a(
torch.cat([x.unsqueeze(-1), u.unsqueeze(-1), self.emb_a(m)], dim=-1)
).squeeze(-1)
pred_b = self.head_b(
torch.cat([x.unsqueeze(-1), u.unsqueeze(-1), self.emb_b(n)], dim=-1)
).squeeze(-1)
return pred_a + pred_bThe factorized forward pass is the additive composition rule from the missing-cell discussion made literal: pred_a + pred_b. Each head sees the state and action along with only its own mechanism's embedding, so the model is structurally incapable of learning a mechanism-A behavior that depends on mechanism B's identity. On the true additive dynamics, the sum can represent the conditional mean. The individual heads are not uniquely identifiable, however: because both receive , any shared function can be added to one head and subtracted from the other without changing the prediction. The experiment therefore tests recombination of the summed predictor, not whether either learned head recovers the semantic ground-truth mechanism. Recovering that stronger interpretation would require additional restrictions or normalization constraints.
Both models are trained with the same objective and budget: mean squared error on one-step prediction, an empirical expectation over transitions drawn from the six training worlds, optimized with Adam for 4000 minibatch steps. The loss never directly evaluates either model on a held-out combination. Observed held-out behavior therefore reflects the architectures' inductive couplings together with finite data, initialization, optimization, implicit regularization, shared-weight updates, and the unmatched capacity—not inductive bias in isolation.
def to_tensors(data):
xs, us, xs_next, ms, ns = data
return (
torch.as_tensor(xs, dtype=torch.float32),
torch.as_tensor(us, dtype=torch.float32),
torch.as_tensor(xs_next, dtype=torch.float32),
torch.as_tensor(ms, dtype=torch.long),
torch.as_tensor(ns, dtype=torch.long),
)
def train_model(model, data, steps=4000, batch=512, lr=1e-3, seed=0):
torch.manual_seed(seed)
xs, us, xs_next, ms, ns = to_tensors(data)
opt = torch.optim.Adam(model.parameters(), lr=lr)
for _ in range(steps):
idx = torch.randint(0, len(xs), (batch,))
pred = model(xs[idx], us[idx], ms[idx], ns[idx])
loss = torch.mean((pred - xs_next[idx]) ** 2)
opt.zero_grad()
loss.backward()
opt.step()
return model
torch.manual_seed(7)
mono_model = MonolithicModel()
fact_model = FactorizedModel()
mono_model = train_model(mono_model, data_train, seed=1)
fact_model = train_model(fact_model, data_train, seed=1)Evaluation happens in two modes. The one-step test uses fresh trajectories from the held-out worlds with the true state always as input, isolating compositional accuracy from compounding. The autoregressive test feeds each model's own predictions back as inputs for 120 steps while replaying the same fixed exogenous action sequence for the true system and both models. This measures multi-step predictive fidelity under state feedback. It is not a closed control loop, because no policy or planner chooses actions from the predicted state, and it does not by itself measure deployment decision usefulness.
def one_step_error(model, data):
xs, us, xs_next, ms, ns = to_tensors(data)
with torch.no_grad():
pred = model(xs, us, ms, ns)
return float(torch.mean((pred - xs_next) ** 2))
def rollout_model(model, m, n, actions):
x = torch.zeros(1)
traj = np.zeros(len(actions) + 1)
with torch.no_grad():
for t, u in enumerate(actions):
ut = torch.tensor([float(u)])
x = model(x, ut, torch.tensor([m]), torch.tensor([n]))
traj[t + 1] = float(x)
return traj
## Fixed evaluation trajectories, shared by both models and the true system
eval_rng = np.random.default_rng(42)
H = 120
episodes = {}
for m, n in ALL_PAIRS:
actions = eval_rng.normal(0.0, 1.0, size=H)
episodes[(m, n)] = (actions, rollout_true(m, n, actions, eval_rng))
err_mono = np.zeros((3, 3))
err_fact = np.zeros((3, 3))
rollouts = {}
for m, n in ALL_PAIRS:
actions, true_traj = episodes[(m, n)]
for name, model in [("monolithic", mono_model), ("factorized", fact_model)]:
traj = rollout_model(model, m, n, actions)
rollouts[(name, m, n)] = traj
mse = np.mean((traj[1:] - true_traj[1:]) ** 2)
if name == "monolithic":
err_mono[m, n] = np.nan_to_num(mse, nan=1e30, posinf=1e30)
else:
err_fact[m, n] = np.nan_to_num(mse, nan=1e30)one_step_test = {
"monolithic": one_step_error(mono_model, data_test),
"factorized": one_step_error(fact_model, data_test),
}One-step MSE on held-out combinations
monolithic: 2.3291
factorized: 0.0151
Autoregressive fixed-action rollout MSE over 120 steps
world split monolithic factorized
A=0,B=0 train 9.143e-03 7.632e-02
A=0,B=1 train 9.892e-03 3.843e-02
A=0,B=2 HELD OUT 9.933e+00 5.481e-01
A=1,B=0 train 2.815e-03 4.775e-03
A=1,B=1 HELD OUT 1.075e+02 1.614e-02
A=1,B=2 train 2.095e-02 2.182e-02
A=2,B=0 HELD OUT 8.673e+01 4.744e-01
A=2,B=1 train 1.293e-01 3.796e-01
A=2,B=2 train 5.464e+00 6.120e+00The held-out comparison supports the intended mechanism, but the training cells expose an additional stability issue. Four of the six training worlds have rollout MSE below for both models. The monolithic model alone is below on five: for the factorized model, is about , and the seen world is unstable for both at about and . On the three held-out worlds, the factorized errors are about , , and , whereas the monolithic errors are about , , and . The untrained monolithic embeddings account for a direct missing-cell mechanism, but the capacity confound and single run preclude a complete causal attribution; factorization alone does not guarantee stable autoregressive dynamics on every seen world.
Read the table with the taxonomy in mind. The held-out cells are pure novel recombinations: every mechanism value in them was seen in training. In this run, the factorized model's held-out MSEs are far below the categorical baseline's but two are roughly one to two orders of magnitude above most stable training cells; the experiment does not identify the cause of that gap or establish how it changes with more data. In particular, the additive heads have gauge freedom and need not correspond individually to the named mechanisms. The monolithic model's held-out embedding receives no direct gradient, so adding more transitions only from the same six seen worlds would not train that embedding; changing the representation or adding the missing world indices would.


The heatmaps separate two effects. The monolithic panel has large errors on all three outlined held-out cells, consistent with their untrained embeddings, but the seen cell is also unstable. The factorized model is much better on the held-out cells, yet its field is not uniform: its MSE ranges from about to , with the maximum again at the seen cell. This single run illustrates useful recombination and an autoregressive stability failure that affects both architectures; it is not a capacity-controlled causal estimate.
The panels also show why representational scaling and empirical error scaling must be kept separate. A factorized representation stores components along the mechanism axes rather than a separate model for every grid cell, but this two-mechanism experiment does not establish how its error profile scales to ten mechanisms. That stronger conclusion would require higher-dimensional runs or a bound covering estimation error, interaction misspecification, and autoregressive stability.

Reading the trajectory itself on a symlog scale, the factorized prediction qualitatively follows the true state over this rollout horizon, while the categorical-ID prediction later develops a large, nonmonotone transient excursion. The observation is consistent with the intended difference between an untrained world embedding and shared mechanism embeddings, but the single seed and unmatched capacities do not isolate that representation choice as the sole cause.
The symlog scale is worth a second look. On this trajectory, the monolithic state reaches magnitude about , compared with about for the true system and for the factorized model: a large runaway excursion, but not a multiple-orders-of-magnitude difference in state. The orders-of-magnitude separation appears in the per-step squared error below, whose maxima are about and . Feeding each wrong prediction into the next step creates an autoregressive prediction failure that one-step evaluation misses; a planning claim would additionally require a policy or planner to choose actions.
The horizon-resolved view completes the diagnosis: error is plotted against prediction depth on the held-out world.
## Per-step squared error over the rollout horizon on held-out world (1,1),
## using the fixed evaluation trajectories shared by both models (seed 42)
## and the autoregressive fixed-action rollouts computed earlier. No new sampling occurs here.
m_ho, n_ho = 1, 1
_, true_traj_ho = episodes[(m_ho, n_ho)]
err_horizon_mono = (
rollouts[("monolithic", m_ho, n_ho)][1:] - true_traj_ho[1:]
) ** 2
err_horizon_fact = (
rollouts[("factorized", m_ho, n_ho)][1:] - true_traj_ho[1:]
) ** 2