Policies Learned in Imagination › Implementing Imagination-Based Learning

Michael BrenndoerferJuly 10, 202629 min read
World Models HandbookAll chapters
Part 7 of 12Planning and Agency
Chapter 40Policies Learned in Imagination › Implementing Imagination-Based Learning
Reading section 7 of 10 · Implementing Imagination-Based Learning · 29 minSection 7 of 10 · 29 min

Book contents

Section 7 of 10 · 29 min read

Policies Learned in Imagination › Implementing Imagination-Based LearningOne section · Sections in this chapter7 / 10 ↓
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

Let us build the experiment. We start with the environment and a data collection routine, then train the two models, then run actor-critic learning inside each.

The environment is small enough to express directly, and we will use the analytic reward for evaluation while the models learn their own reward head.

In[3]:
Code
import numpy as np
import torch
import torch.nn as nn

DT, DRAG = 0.1, 0.99
GAMMA, LAM = 0.99, 0.95
TARGET, CTRL = 2.0, 0.05
EVAL_HORIZON = 40

torch.set_num_threads(1)


def true_reward(x, a):
    """Analytic reward: used to score the real environment during evaluation."""
    return -((x - TARGET) ** 2) - CTRL * a**2

The analytic reward supplies labels for the real training transitions and scores real evaluation rollouts. During imagined actor updates, the learner sees only the fitted reward head. Keeping those roles distinct lets us compare model-predicted return with the actual task reward; it does not mean the reward head was trained without true-reward labels.

Data collection draws episodes with a chosen initial-state distribution and a chosen action scale. Both dataset parameters are what distinguish Model A from Model B.

In[4]:
Code
def collect(init_fn, action_scale, n_episodes=300, ep_len=40, seed=0):
    """Roll out the true environment under a uniform random behaviour policy."""
    rng = np.random.default_rng(seed)
    s_buf, a_buf, r_buf, sn_buf = [], [], [], []
    for _ in range(n_episodes):
        x, v = init_fn(rng)
        for _ in range(ep_len):
            a = rng.uniform(-action_scale, action_scale)
            r = true_reward(x, a)
            x_next = x + DT * v + rng.normal(0.0, 0.01)
            v_next = DRAG * v + DT * a + rng.normal(0.0, 0.01)
            s_buf.append((x, v))
            a_buf.append((a,))
            r_buf.append(r)
            sn_buf.append((x_next, v_next))
            x, v = x_next, v_next
    return (
        np.asarray(s_buf, dtype=np.float32),
        np.asarray(a_buf, dtype=np.float32),
        np.asarray(r_buf, dtype=np.float32)[:, None],
        np.asarray(sn_buf, dtype=np.float32),
    )


narrow = collect(
    lambda rng: (rng.normal(0.0, 0.25), rng.normal(0.0, 0.25)),
    action_scale=0.4,
    seed=1,
)
broad = collect(
    lambda rng: (rng.uniform(-3.0, 3.0), rng.uniform(-0.8, 0.8)),
    action_scale=1.0,
    seed=2,
)
Out[5]:
Console
narrow:  12000 transitions | |x| max 2.64 | |v| max 0.68
 broad:  12000 transitions | |x| max 7.17 | |v| max 1.47

The narrow dataset has only 9 of 12,000 positions within 0.2 of x=2.0x=2.0 and none at or beyond it; the broad dataset has 641 within that band. These counts measure one aspect of the wider collection distribution. The histograms show the positional difference, not the full action or velocity coverage.

Out[6]:
Visualization
Overlaid histograms compare positions in narrow and broad training datasets, with a vertical marker at the goal position x equals 2.
Positions in the two 12,000-transition datasets. Model A has sparse samples near the goal and none at or beyond x = 2.0; Model B has many samples around it. Initial-state and action sampling both differ between collection regimes.

The world model predicts the state delta and the normalized reward. Predicting a delta rather than an absolute next state is a small but meaningful inductive bias: it makes the identity dynamics the default, so the network only has to learn the correction.

The delta parameterization is worth a sentence of justification, because it is the kind of choice that looks cosmetic but is not. The true dynamics are close to the identity over a short step, since the position changes by Δt v\Delta t\,v and the velocity changes by a small amount. A network that predicts the next state directly has to relearn the identity mapping from scratch, spending capacity on reproducing something it already knows. A network that predicts the delta starts from the identity and only has to learn the small correction. This is the same reason residual connections help deep networks: the identity path is a strong prior, and it costs nothing to build it in.

In[7]:
Code
class WorldModel(nn.Module):
    """Predicts the next state and reward for (state, action) pairs."""

    def __init__(self, hidden=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(3, hidden),
            nn.SiLU(),
            nn.Linear(hidden, hidden),
            nn.SiLU(),
            nn.Linear(hidden, 3),
        )
        self.register_buffer("r_mu", torch.zeros(1))
        self.register_buffer("r_sigma", torch.ones(1))

    def forward(self, s, a):
        out = self.net(torch.cat([s, a], dim=-1))
        return s + out[:, :2], out[:, 2]

    def step(self, s, a):
        s_next, r_norm = self.forward(s, a)
        return s_next, self.r_mu + self.r_sigma * r_norm
In[8]:
Code
def train_world_model(
    data, steps=3000, batch=256, lr=3e-3, seed=0, bootstrap=False
):
    """Fit a dynamics-and-reward model by minibatch least squares."""
    torch.manual_seed(seed)
    s, a, r, sn = (torch.as_tensor(x) for x in data)
    r_norm = (r - r.mean()) / r.std()
    n = len(s)
    if bootstrap:
        idx = torch.randint(0, n, (n,))
        s, a, r_norm, sn = s[idx], a[idx], r_norm[idx], sn[idx]

    model = WorldModel()
    model.r_mu = r.mean().reshape(1)
    model.r_sigma = r.std().reshape(1)
    optimiser = torch.optim.Adam(model.parameters(), lr=lr)

    for _ in range(steps):
        idx = torch.randint(0, n, (batch,))
        s_hat, r_hat = model(s[idx], a[idx])
        loss = ((s_hat - sn[idx]) ** 2).mean() + (
            (r_hat - r_norm[idx, 0]) ** 2
        ).mean()
        optimiser.zero_grad()
        loss.backward()
        optimiser.step()

    model.eval()
    return model


model_narrow = train_world_model(narrow, seed=10)
model_broad = train_world_model(broad, seed=10)

Both models fit their training data well. The interesting question is what happens away from it.

The mechanism behind Model A's failure is visible in its reward head. Plotting each model's predicted reward along a slice of position reveals how far each one is willing to extrapolate, and where the optimizer will be pulled.

In[9]:
Code
probe_x = np.linspace(-3.0, 4.0, 200)
probe_v = np.zeros_like(probe_x)
probe_a = np.zeros_like(probe_x)
probe_s = torch.tensor(
    np.stack([probe_x, probe_v], axis=1), dtype=torch.float32
)
probe_act = torch.tensor(probe_a[:, None], dtype=torch.float32)

with torch.no_grad():
    probe_reward_true = true_reward(probe_s[:, 0], probe_act[:, 0]).numpy()
    probe_reward_narrow = (
        model_narrow.r_mu
        + model_narrow.r_sigma * model_narrow(probe_s, probe_act)[1]
    ).numpy()
    probe_reward_broad = (
        model_broad.r_mu
        + model_broad.r_sigma * model_broad(probe_s, probe_act)[1]
    ).numpy()
Out[10]:
Visualization
Line chart compares the analytic reward curve with reward predictions from narrow-coverage and broad-coverage models across position.
Zero-action reward predictions across position. Model A's narrow-data head peaks near x = 2.1 but underestimates the cost of overshoot: at x = 4 it predicts about -1.21 versus the true -4. Model B predicts about -4.06 there. This slice does not establish closed-loop policy quality.

Figure 2. Both heads peak near the goal, but Model A penalizes overshoot too weakly: at x=4x=4 and zero action it predicts about −1.21-1.21 instead of −4-4. Model B predicts about −4.06-4.06 there. The slice isolates reward extrapolation, not full closed-loop behavior.

The training loss does not report this out-of-distribution reward error. The next cell probes dynamics separately by rolling both models open loop from starts near the goal. Reward accuracy and transition accuracy are distinct; neither by itself certifies a useful policy.

We test them on a probe set of trajectories that start near the target at x=2.0x = 2.0 and roll out open loop under the true dynamics. Open-loop means the model receives only the initial state and the action sequence; every predicted state becomes the input for the next prediction, so errors compound.

Open-loop probing measures how state error grows when predictions become inputs. The narrow model has sparse goal-region data rather than none, and the probe compares its error with the broad model's under the same initial states and actions.

In[11]:
Code
def open_loop_error(model, s0, actions, true_states):
    """Per-step mean squared state error of an open-loop model rollout."""
    s = s0.clone()
    errors = []
    for t in range(actions.shape[1]):
        with torch.no_grad():
            s = model.step(s, actions[:, t])[0]
        errors.append(((s - true_states[t + 1]) ** 2).sum(-1).mean().item())
    return np.array(errors)


probe_rng = np.random.default_rng(7)
n_probe, probe_horizon = 32, 25
probe_s0 = torch.tensor(
    np.stack(
        [
            2.0 + 0.2 * probe_rng.normal(size=n_probe),
            0.5 * probe_rng.normal(size=n_probe),
        ],
        axis=1,
    ),
    dtype=torch.float32,
)
probe_actions = torch.tensor(
    probe_rng.uniform(-0.5, 0.5, size=(n_probe, probe_horizon, 1)),
    dtype=torch.float32,
)

true_probe_states = [probe_s0]
s = probe_s0
for t in range(probe_horizon):
    a = probe_actions[:, t, 0]
    s = torch.stack([s[:, 0] + DT * s[:, 1], DRAG * s[:, 1] + DT * a], dim=1)
    true_probe_states.append(s)

error_narrow = open_loop_error(
    model_narrow, probe_s0, probe_actions, true_probe_states
)
error_broad = open_loop_error(
    model_broad, probe_s0, probe_actions, true_probe_states
)
Out[12]:
Visualization
Line chart shows open-loop prediction error increasing rapidly with rollout step for the narrow-data model while remaining lower for the broad-data model.
Open-loop mean squared state error from 32 starts near the goal. Error grows for both models; at step 25 it is about 0.156 for Model A and 0.0183 for Model B. The narrow model is substantially worse on this probe, but these curves alone do not certify a policy.

Figure 3. Both models accumulate open-loop state error. At step one, the narrow and broad MSEs are approximately 6.58×10−56.58\times10^{-5} and 2.07×10−52.07\times10^{-5}; at step 25 they are 0.1560.156 and 0.01830.0183. The narrow model is roughly 8.5 times worse at the end of this probe.

The first-step errors are small but differ by a factor of about 3.2. Both errors then grow, with Model A ending substantially higher. The models fit their respective training samples, but we have not shown that their training losses are equal or that Model B is accurate everywhere its actor will go.

The probe separates small one-step error from larger multi-step drift. It shows why reporting only in-distribution training loss would be insufficient: these rollouts begin near a goal that Model A's collection policy rarely visited. Closed-loop policy evaluation remains a separate test.

Now the policy. The actor samples a Gaussian raw action and applies tanh⁡\tanh so the environment action stays in [−1,1][-1,1]. Its MLP mean and learned state-independent log-standard-deviation parameterize the raw Gaussian. The critic is a plain MLP. Bounding actions is part of the toy task specification, not a seed-specific rescue: without it, the earlier unbounded actor could produce exploding real trajectories even with the broad model.

In[13]:
Code
class Actor(nn.Module):
    """Tanh-squashed Gaussian policy; environment actions lie in [-1, 1]."""

    def __init__(self, hidden=64):
        super().__init__()
        self.body = nn.Sequential(
            nn.Linear(2, hidden),
            nn.SiLU(),
            nn.Linear(hidden, hidden),
            nn.SiLU(),
            nn.Linear(hidden, 1),
        )
        self.log_std = nn.Parameter(torch.tensor([-1.0]))

    def forward(self, s):
        return torch.tanh(self.body(s))

    def dist(self, s):
        mean = self.body(s)
        return torch.distributions.Normal(
            mean, self.log_std.exp().expand_as(mean)
        )


class Critic(nn.Module):
    def __init__(self, hidden=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, hidden),
            nn.SiLU(),
            nn.Linear(hidden, hidden),
            nn.SiLU(),
            nn.Linear(hidden, 1),
        )

    def forward(self, s):
        return self.net(s).squeeze(-1)

The λ\lambda-return recursion is a short backward loop. Note that the values entering the target are detached, which keeps the critic's regression target fixed within a step.

In[14]:
Code
def lambda_returns(rewards, values, gamma=GAMMA, lam=LAM):
    """Backward recursion for lambda-returns along an imagined rollout."""
    vals = [v.detach() for v in values]
    acc = torch.zeros_like(vals[0])
    out = [None] * len(rewards)
    for t in reversed(range(len(rewards))):
        delta = rewards[t] + gamma * vals[t + 1] - vals[t]
        acc = delta + gamma * lam * acc
        out[t] = vals[t] + acc
    return out

The imagination loop collects states, actions, rewards, values, and log-probabilities. Setting pathwise=True keeps the sampled action attached to the graph so gradients flow through the model; the default detaches it for the score-function estimate.

The pathwise flag is the switch between the two gradient estimators from earlier in the chapter, and it is worth tracing what it changes. When pathwise=False, the action is detached before it enters the model, so the model sees a constant input and the reward-objective gradient reaches the actor through the score-function term. If the optional entropy coefficient eta is positive, the actor also receives a direct entropy-regularization gradient; the plotted comparisons set eta=0. When pathwise=True, the action stays attached, so the reward and the next state both depend on the policy's parameters and the actor receives the analytic gradient through the model. The same rollout function supports both, which makes the comparison in the final cell a one-line change.

In[15]:
Code
def imagine(model, actor, critic, s0, horizon, pathwise=False):
    """Roll the world model forward under the actor for `horizon` steps."""
    s = s0
    states, actions, rewards, values, logps = [], [], [], [], []
    for _ in range(horizon):
        states.append(s)
        dist = actor.dist(s)
        raw_a = dist.rsample()
        a = torch.tanh(raw_a)
        a_model = a if pathwise else a.detach()
        actions.append(a_model)
        s_next, r_hat = model.step(s, a_model)
        rewards.append(r_hat)
        # The critic learns from imagined states, but its update must not
        # backpropagate through the actor or world-model rollout graph.  The
        # actor update below owns that graph in pathwise mode.
        values.append(critic(s.detach()))
        # The tanh transform is parameter-independent when its sampled raw
        # action is held fixed; its score is the raw Normal score.
        logps.append(dist.log_prob(raw_a.detach()).sum(-1))
        s = s_next
    values.append(critic(s.detach()))
    return states, actions, rewards, values, logps

Two evaluation helpers let us watch the gap. imagined_return scores the deterministic policy under the model; true_return scores the same policy in the real environment. Both use a 40-step horizon so the numbers are comparable even when the training horizon is shorter.

Both evaluations use the same deterministic mean policy, start states, horizon, and discount. The real evaluation uses the analytic, noise-free dynamics and reward; data collection alone adds small transition noise. The imagined evaluation uses the learned transition and reward heads. Their difference measures this complete model-versus-environment mismatch, not only transition error.

In[16]:
Code
@torch.no_grad()
def true_return(actor, s0, horizon=EVAL_HORIZON):
    """Closed-loop return of the deterministic policy in the real environment."""
    s = s0.clone()
    total = torch.zeros(s.shape[0])
    discount = 1.0
    for _ in range(horizon):
        a = actor(s)
        total = total + discount * true_reward(s[:, 0], a[:, 0])
        s = torch.stack(
            [s[:, 0] + DT * s[:, 1], DRAG * s[:, 1] + DT * a[:, 0]], dim=1
        )
        discount *= GAMMA
    return total.mean().item()


@torch.no_grad()
def imagined_return(actor, model, s0, horizon=EVAL_HORIZON):
    """Closed-loop return of the deterministic policy under the world model."""
    s = s0.clone()
    total = torch.zeros(s.shape[0])
    discount = 1.0
    for _ in range(horizon):
        a = actor(s)
        s, r_hat = model.step(s, a)
        total = total + discount * r_hat
        discount *= GAMMA
    return total.mean().item()

The training loop ties it together. The critic uses detached λ\lambda-targets, while the actor uses a sampled, finite-horizon reward-to-go and a state-dependent critic baseline. Both actor-gradient branches therefore target the same truncated discounted reward objective. The actor gradient is clipped, and the model parameters are excluded from its optimizer.

The score branch retains the outcome magnitude rather than normalizing advantages from the same samples, which would change the exact score estimate. Its sampled return is truncated at HH even though the critic target bootstraps there; this distinction is deliberate. Action squashing enforces the task's actuator limit and prevents the unbounded-policy divergence seen in the initial diagnostic run. Clipping controls update size but does not guarantee convergence or protection against model error.

In[17]:
Code
def train_imagination(
    model,
    horizon=20,
    iterations=250,
    batch=64,
    lr=1e-3,
    gamma=GAMMA,
    lam=LAM,
    eta=1e-3,
    seed=0,
    pathwise=False,
    uncertainty=None,
    penalty=0.0,
    eval_states=None,
    record_every=25,
):
    """Actor-critic training entirely inside the learned model."""
    if pathwise and penalty > 0.0:
        raise ValueError(
            "The toy pathwise branch does not differentiate uncertainty"
        )
    torch.manual_seed(seed)
    for parameter in model.parameters():
        parameter.requires_grad_(False)
    actor, critic = Actor(), Critic()
    opt_a = torch.optim.Adam(actor.parameters(), lr=lr)
    opt_c = torch.optim.Adam(critic.parameters(), lr=lr)
    history = {"iter": [], "imagined": [], "true": []}

    for it in range(iterations):
        s0 = torch.randn(batch, 2) * torch.tensor([0.25, 0.25])
        states, actions, rewards, values, logps = imagine(
            model, actor, critic, s0, horizon, pathwise
        )

        if uncertainty is not None and penalty > 0.0:
            rewards = [
                r - penalty * uncertainty(s.detach(), a.detach())
                for r, s, a in zip(rewards, states, actions)
            ]

        returns = lambda_returns(rewards, values, gamma, lam)
        v_stack = torch.stack(values[:-1])
        g_stack = torch.stack(returns).detach()

        critic_loss = ((v_stack - g_stack) ** 2).mean()
        opt_c.zero_grad()
        critic_loss.backward()
        opt_c.step()

        if pathwise:
            actor_loss = (
                -sum((gamma**t) * rewards[t] for t in range(horizon)).mean()
                / horizon
            )
        else:
            # A full sampled reward-to-go gives a score-function estimate of
            # the same truncated H-step objective as the pathwise branch.
            tail = torch.zeros_like(rewards[0])
            reward_to_go = [None] * horizon
            for t in reversed(range(horizon)):
                tail = rewards[t].detach() + gamma * tail
                reward_to_go[t] = tail
            advantage = torch.stack(reward_to_go) - v_stack.detach()
            discounts = torch.tensor(
                [gamma**t for t in range(horizon)], dtype=advantage.dtype
            )[:, None]
            actor_loss = (
                -(discounts * advantage * torch.stack(logps)).sum(dim=0).mean()
                / horizon
            )

        if eta > 0.0:
            entropy = torch.stack(
                [actor.dist(s).entropy().sum(-1) for s in states]
            ).mean()
            actor_loss = actor_loss - eta * entropy

        opt_a.zero_grad()
        actor_loss.backward()
        torch.nn.utils.clip_grad_norm_(actor.parameters(), 5.0)
        opt_a.step()

        if eval_states is not None and (
            it % record_every == 0 or it == iterations - 1
        ):
            history["iter"].append(it)
            history["imagined"].append(
                imagined_return(actor, model, eval_states)
            )
            history["true"].append(true_return(actor, eval_states))

    return {"actor": actor, "critic": critic, "history": history}
In[18]:
Code
torch.manual_seed(123)
eval_states = 0.02 * torch.randn(64, 2)
eval_states[:, 0] = eval_states[:, 0] + 0.0

res_narrow = train_imagination(
    model_narrow,
    seed=0,
    eval_states=eval_states,
    horizon=30,
    iterations=400,
    eta=0.0,
)
res_broad = train_imagination(
    model_broad,
    seed=0,
    eval_states=eval_states,
    horizon=30,
    iterations=400,
    eta=0.0,
)
Out[19]:
Visualization
Training curves compare imagined and real returns for policies learned with narrow-data and broad-data world models.
One seeded actor-critic run per model with bounded actions and a 30-step training rollout. Both real returns improve overall but fluctuate. At iteration 399, Model A's imagined and real 40-step returns are about -47.7 and -51.3; Model B's are about -47.2 and -47.1. This run shows a modest optimistic gap for Model A, not monotone exploitation or a universal coverage guarantee.

Figure 4. Both policies improve overall in this run, with visible fluctuations. Model A ends at approximately −47.7-47.7 imagined versus −51.3-51.3 real return; Model B ends at −47.2-47.2 versus −47.1-47.1. A positive imagination gap is visible for Model A, but this chart alone does not establish its cause.

Model B's final predicted and real returns happen to agree closely here; Model A's do not. Neither curve rises smoothly, and neither comparison proves that coverage alone caused the gap. The reward probe and real trajectory help identify the plausible mechanism: Model A underprices overshoot outside its dense data region.

The plot does not show critic loss or training stability directly. It does show why an imagined learning curve is not enough: a model can predict more return than the same policy earns in the environment. Repeating the experiment across actor seeds is needed before treating the size of that gap as a stable property of the collection regime.

In[20]:
Code
seed_check = []
for actor_seed in (0, 1, 2):
    for name, model, first_run in (
        ("A/narrow", model_narrow, res_narrow),
        ("B/broad", model_broad, res_broad),
    ):
        run = (
            first_run
            if actor_seed == 0
            else train_imagination(
                model,
                seed=actor_seed,
                eval_states=eval_states,
                horizon=30,
                iterations=400,
                eta=0.0,
            )
        )
        imagined = imagined_return(run["actor"], model, eval_states)
        real = true_return(run["actor"], eval_states)
        seed_check.append((actor_seed, name, imagined, real))
        print(
            f"seed {actor_seed} {name:>8}: imagined {imagined:7.2f} | real {real:7.2f}"
        )
Out[20]:
Console
seed 0 A/narrow: imagined  -47.68 | real  -51.26
seed 0  B/broad: imagined  -47.24 | real  -47.14
seed 1 A/narrow: imagined  -60.33 | real  -60.26
seed 1  B/broad: imagined  -49.83 | real  -50.71
seed 2 A/narrow: imagined  -47.78 | real  -51.26
seed 2  B/broad: imagined  -52.70 | real  -52.16

The three fixed seeds show variation in both real return and the sign or size of the gap. They are a robustness check for this toy protocol, not an uncertainty interval for a broader population of tasks.

The consequences for behavior are equally visible. Rolling each trained policy out in the real environment shows where it actually ends up, as opposed to where its imagination believes it is.

In[21]:
Code
@torch.no_grad()
def policy_positions(actor, s0, horizon=EVAL_HORIZON):
    """Closed-loop mean position of the deterministic policy in the real environment."""
    s = s0.clone()
    xs = [s[:, 0].mean().item()]
    for _ in range(horizon):
        a = actor(s)
        s = torch.stack(
            [s[:, 0] + DT * s[:, 1], DRAG * s[:, 1] + DT * a[:, 0]], dim=1
        )
        xs.append(s[:, 0].mean().item())
    return np.array(xs)


pos_narrow = policy_positions(res_narrow["actor"], eval_states)
pos_broad = policy_positions(res_broad["actor"], eval_states)
pos_steps = np.arange(len(pos_narrow))
Out[22]:
Visualization
Mean position trajectories compare policies trained in narrow-data and broad-data models against a horizontal goal-position reference.
Mean closed-loop position under the two seed-0 policies. Both pass the goal rather than settle there by step 40: Model A ends near x = 2.92 and Model B near x = 2.36. The shaded goal tolerance is x = 2.0 ± 0.15. Model A overshoots more in this run, consistent with its optimistic reward extrapolation, but not proof of coverage as the sole cause.

Figure 5. Neither policy settles inside the shaded 2.0±0.152.0\pm0.15 band at step 40. Model A ends near 2.922.92, Model B near 2.362.36. The legend maps each line to its trained model.

Next we sweep the imagination horizon while keeping the actor seed, iteration budget, optimizer, and 40-step evaluation protocol fixed. Horizon changes both the training objective and the trajectories seen by the optimizer. Short rollouts may omit useful delayed consequences; longer ones may accumulate more model error. Neither effect guarantees a monotone curve.

In[23]:
Code
horizons = [5, 10, 20, 30, 40]
sweep = {"horizon": [], "imagined": [], "true": [], "gap": []}
for h in horizons:
    out = train_imagination(
        model_narrow, horizon=h, seed=0, iterations=400, eta=0.0
    )
    imag = imagined_return(out["actor"], model_narrow, eval_states)
    real = true_return(out["actor"], eval_states)
    sweep["horizon"].append(h)
    sweep["imagined"].append(imag)
    sweep["true"].append(real)
    sweep["gap"].append(imag - real)
Out[24]:
Visualization
Line chart compares imagined and real policy returns across increasing imagination horizons for the narrow-coverage model.
Single-seed horizon sweep for Model A, evaluated over the same 40 real or imagined steps. Returns are nonmonotone: H = 20 gives a large optimistic gap, while H = 30 and 40 perform substantially better in reality. These are separately trained policies with different objectives, not a nested sequence of improvements.

Figure 6. At H=20H=20, predicted return is about −63-63 while real return is about −126-126; at H=30H=30, they are about −48-48 and −51-51. The gap can also be negative at shorter horizons. The comparison is a single-seed sensitivity check, not evidence that increasing HH must help or hurt.

Neither curve is monotone. The shortest training horizons perform poorly, plausibly because they provide little credit for reaching x=2x=2 from near the origin. The H=20H=20 run has a large positive gap, while the longer H=30H=30 and 4040 runs perform better under this fixed iteration budget. Horizon is therefore a hyperparameter to validate in the closed loop, not a one-direction safety dial.

These policies are trained separately, with nonconvex function approximation and finite optimization. A longer rollout changes the objective rather than enlarging a nested feasible set. The gap at a particular horizon reports model-versus-environment return for that trained policy; it does not identify the cause without inspecting trajectories and errors.

Now to value expansion. The next diagnostic starts from real replay states but uses a fixed, generated action sequence in both model and true dynamics. This isolates prefix-return prediction error; it is not an implementation of MVE's policy-generated imagined actions. As HH grows, the model's mean partial return can drift from truth while the coefficient on an HH-step terminal bootstrap, γH\gamma^H, decreases. The panels have different units and do not locate an optimal horizon.

In[25]:
Code
branch_rng = np.random.default_rng(11)
n_branch, branch_horizon = 64, 30
branch_idx = branch_rng.choice(len(narrow[0]), size=n_branch, replace=False)
branch_s0 = torch.tensor(narrow[0][branch_idx])
branch_actions = torch.tensor(
    branch_rng.uniform(-0.8, 0.8, size=(n_branch, branch_horizon, 1)),
    dtype=torch.float32,
)

true_branch_states = [branch_s0]
s = branch_s0.clone()
for t in range(branch_horizon):
    a = branch_actions[:, t, 0]
    s = torch.stack([s[:, 0] + DT * s[:, 1], DRAG * s[:, 1] + DT * a], dim=1)
    true_branch_states.append(s)

model_rewards, true_rewards = [], []
s = branch_s0.clone()
with torch.no_grad():
    for t in range(branch_horizon):
        a = branch_actions[:, t]
        s, r_hat = model_narrow.step(s, a)
        model_rewards.append(r_hat)
        true_rewards.append(true_reward(true_branch_states[t][:, 0], a[:, 0]))


def discounted_prefix(reward_list):
    out, acc, discount = [], torch.zeros_like(reward_list[0]), 1.0
    for r in reward_list:
        acc = acc + discount * r
        out.append(acc)
        discount *= GAMMA
    return out


model_prefix = discounted_prefix(model_rewards)
true_prefix = discounted_prefix(true_rewards)
bias_curve = np.array(
    [
        abs(m.mean().item() - t.mean().item())
        for m, t in zip(model_prefix, true_prefix)
    ]
)
bootstrap_weight = np.array([GAMMA ** (h + 1) for h in range(branch_horizon)])
horizon_axis = np.arange(1, branch_horizon + 1)
Out[26]:
Visualization
Line chart shows the absolute difference of batch-mean model and true prefix returns increasing with rollout horizon.
Absolute difference between batch-mean model and true H-step prefix returns under the same generated action sequence. It grows from nearly zero at H = 1 to about 8.9 at H = 30. This mean difference may hide cancellation across trajectories.
Line chart shows the value-function bootstrap weight decreasing geometrically with rollout horizon.
Coefficient gamma^H on a terminal value in an H-step expansion target. It falls from 0.99 at H = 1 to about 0.74 at H = 30. This coefficient alone says nothing about critic error or total target quality.

Figure 7a. The plotted quantity is the absolute difference of two batch means, not the mean absolute per-trajectory error. It grows from about 0.000060.00006 at one step to 8.918.91 at 30 under this fixed generated action sequence.

Figure 7b. The terminal-value coefficient decreases from 0.990.99 at one step to about 0.740.74 at 30 steps. It is a coefficient, not a measurement of the critic's error.

Together, the panels show why model and critic errors must both be considered, but they cannot be crossed or compared numerically: one is in reward units and the other is dimensionless. We did not measure critic error, target variance, or complete MVE target MSE, so this diagnostic does not locate a sweet spot. STEVE estimates target variance across horizons to set mixture weights; bias still needs separate scrutiny.

Now the regularizer. We train a three-member bootstrapped ensemble on the narrow data. Its next-state prediction spread is a heuristic for epistemic uncertainty. We divide position and velocity spread by fixed toy scales of 2 and 1 before taking a root-mean-square, so the penalty is scalar and dimensionless. We do not assume that disagreement perfectly tracks error or that it must explode outside the data.

In[27]:
Code
ensemble = [
    train_world_model(narrow, seed=s, bootstrap=True) for s in (101, 102, 103)
]


def disagreement(models, s, a):
    """Dimensionless RMS of standardized next-state ensemble spread."""
    preds = torch.stack([m(s, a)[0] for m in models])
    spread = preds.std(dim=0, unbiased=False) / s.new_tensor([2.0, 1.0])
    return torch.sqrt((spread**2).mean(dim=-1))


@torch.no_grad()
def trajectory_disagreement(actor, s0, models, horizon=EVAL_HORIZON):
    """Mean ensemble disagreement along the policy's closed-loop real trajectory."""
    s = s0.clone()
    readings = []
    for _ in range(horizon):
        a = actor(s)
        readings.append(disagreement(models, s, a).mean().item())
        s = torch.stack(
            [s[:, 0] + DT * s[:, 1], DRAG * s[:, 1] + DT * a[:, 0]], dim=1
        )
    return float(np.mean(readings))
In[28]:
Code
penalties = [0.0, 1.0, 5.0, 20.0]
penalty_return, penalty_disagreement = [], []
for p in penalties:
    out = train_imagination(
        model_narrow,
        seed=0,
        horizon=30,
        iterations=400,
        eta=0.0,
        uncertainty=lambda s, a: disagreement(ensemble, s, a),
        penalty=p,
    )
    penalty_return.append(true_return(out["actor"], eval_states))
    penalty_disagreement.append(
        trajectory_disagreement(out["actor"], eval_states, ensemble)
    )
Out[29]:
Visualization
Line chart shows real return changing as the uncertainty penalty increases.
Single-seed 40-step real return under increasing standardized ensemble penalty. Return improves modestly from about -51.3 at lambda = 0 to -49.5 at lambda = 20. This does not establish a general monotone benefit.
Line chart shows mean ensemble disagreement along the policy trajectory as the uncertainty penalty increases.
Mean standardized ensemble disagreement along the corresponding real trajectory falls slightly, from about 0.00349 to 0.00301. Disagreement is a proxy, not measured model error or a safety guarantee.

Figure 8a. In this run, real return improves modestly from approximately −51.3-51.3 with no penalty to −49.5-49.5 at λ=20\lambda=20.

Figure 8b. Standardized mean ensemble disagreement falls slightly, from approximately 0.003490.00349 to 0.003010.00301. This is not a direct measurement of real model error.

The left panel plots real return and the right panel plots disagreement. Both changes are modest for this seed: stronger penalty correlates with slightly better task return and slightly lower ensemble spread. The plot does not establish causality outside the specified training protocol or prove that the goal is unreachable under pessimism.

The lesson is narrower than either "pessimism always hurts" or "pessimism fixes exploitation." The penalty changes this actor's behavior enough to improve one real evaluation, but the ensemble score is not calibrated against transition error. A different seed, penalty scale, or misspecified ensemble could reverse the result. Closed-loop return and uncertainty diagnostics must be reported together.

Uncertainty penalties cannot create missing evidence. When interaction is available, targeted data collection can test the model in weakly covered regions. Choosing actions that improve both the task and what the agent knows is the subject of Active Perception, Dual Control, and Exploration.

For completeness, we compare score-function and pathwise actor updates on Model B under the same truncated discounted reward objective, seed, horizon, batch size, and iteration budget. Its open-loop probe was better than Model A's, but that does not guarantee accuracy on every visited state. One seed can illustrate behavior, not establish an estimator ranking.

In[30]:
Code
score_fn = train_imagination(
    model_broad,
    seed=3,
    horizon=30,
    iterations=400,
    eta=0.0,
    eval_states=eval_states,
)
pathwise = train_imagination(
    model_broad,
    seed=3,
    horizon=30,
    iterations=400,
    eta=0.0,
    pathwise=True,
    eval_states=eval_states,
)

comparison = {
    "score-function": true_return(score_fn["actor"], eval_states),
    "pathwise": true_return(pathwise["actor"], eval_states),
}
Out[31]:
Console
  score-function: real return -107.946
        pathwise: real return -46.632

Imagination gap at the end of training
 Model A: imagined -47.678 | real -51.259 | gap   3.581
 Model B: imagined -47.239 | real -47.143 | gap  -0.096

With seed 3, the pathwise policy reaches about −46.6-46.6 real return after 400 iterations while the score-function policy reaches about −107.9-107.9. This is a result of this configuration, not evidence that pathwise updates are always faster or safer. The actor losses are aligned on the same truncated objective; their gradient estimators and resulting optimization paths differ.

We have not run the corresponding estimator comparison on Model A, so we should not predict its outcome. To compare estimators more generally, vary actor seeds, inspect complete learning curves, and measure model error on the trajectories each estimator induces. Otherwise optimization and model mismatch remain entangled.