Offline and Conservative Model-Based RL › Out-of-Distribution Actions and Model Uncertainty
World Models HandbookAll chapters Offline and Conservative Model-Based RL › Out-of-Distribution Actions and Model UncertaintyOne section · Sections in this chapter3 / 8 ↓
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
This section defines what "out of distribution" means for a learned model and why the action dimension is usually where the trouble starts. It builds the machinery for quantifying epistemic uncertainty, then constructs a gridworld where the failure is visible and unambiguous.
What is actually out of distribution
A dataset of continuous-control trajectories often covers a thin tube in joint state-action space. Even millions of transitions may leave reachable pairs poorly sampled. A robot moving through a hallway traces a narrow ribbon inside a higher-dimensional joint space. Three useful views of extrapolation arise; they overlap rather than partition the space.
- State OOD. The query state lies outside, or in a poorly covered part of, the behaviour state's distribution. This can happen when the policy drives into a new configuration; exact equality with a logged continuous state is not the test.
- Action OOD. The state is familiar but the proposed action is poorly supported conditional on that state. This is subtler: driving logs may cover a speed well but contain little evidence about full braking at that speed.
- Joint OOD. The pair is missing or rare in the logged joint distribution, even when the state and action have each appeared separately. State OOD or action OOD also makes the pair OOD; the distinctive case is a novel pairing of familiar marginals.
Action OOD is the one people underestimate. Consider a behaviour policy that is nearly deterministic, as some demonstration datasets are. In a well-covered state, the marginal may be large, but conditional on the action distribution is a narrow spike. The model has evidence for only a narrow range of actions at that state, even if it has many transitions. If an improvement step proposes an action 2 standard deviations away from the spike, the transition model may have little evidence about the consequence, but the value function still returns a number, and the optimiser may still trust it.
These views interact. In a low-dimensional example, a state far from logged states may be visibly unusual; in high dimensions, that judgment is harder. A state-only density model cannot detect a poorly supported action at a familiar state. A familiar-looking state and a familiar-looking action can also form a rare pair, so checking only cannot establish joint state-action coverage. The relevant question for a transition model is whether the pair has enough nearby evidence.
In reconstructive latent world models from Part III: Representing Agents and Worlds, an encoder maps observations into a latent space and a decoder reconstructs observations. Other architectures, including MuZero, need not have a decoder. If planning drives a latent state far beyond training support, its predicted transitions and values can become unreliable even though the vectors remain numerically valid. A model may not raise an exception or produce a NaN when this happens, so checking only numerical validity will miss the coverage problem.
Two kinds of uncertainty are routinely conflated, and conflating them causes real damage.
- Aleatoric uncertainty is irreducible randomness in the environment. A dice roll, a slipping wheel, a stochastic reward channel. It does not shrink with more data, because there is nothing more to know.
- Epistemic uncertainty is uncertainty about the model itself, arising from limited relevant evidence. It can shrink with informative data near the query point and often rises away from observed regions, though approximate or misspecified models need not show either trend reliably.
For a penalty meant to guard against model extrapolation, epistemic uncertainty is the quantity of interest: irreducible randomness alone does not show that the model lacks evidence. A risk-sensitive or safety objective may deliberately penalise stochastic outcomes for a different reason. Practical scores can mix these components. MOPO's published scale-based heuristic is one example, and its penalty coefficient must be tuned rather than read as a calibrated error bound.
Consider a robot driving on ice. If friction varies unpredictably, more examples may clarify the distribution of slides without making each slide deterministic. Penalising that randomness could make the robot avoid a well-understood route. By contrast, if the robot has never driven on a particular gravel surface, the uncertainty reflects missing evidence about that surface. A naive predictive-variance score can mix these two cases.
Quantifying epistemic uncertainty
We will compare five approaches to estimating or bounding uncertainty. They differ in cost, calibration, and how badly they can fail.
A schematic decomposition makes the distinction concrete before the individual estimator families are described.
import numpy as np
# A schematic variance decomposition over a one-dimensional input. Data cover
# the interval [2, 8]; aleatoric noise is a constant 0.04 everywhere. The
# epistemic term grows with distance from the covered interval, so the total
# predictive variance is non-zero even where the environment is well sampled.
_x = np.linspace(0.0, 10.0, 401)
_data_low, _data_high = 2.0, 8.0
distance = np.where(
_x < _data_low,
_data_low - _x,
np.where(_x > _data_high, _x - _data_high, 0.0),
)
aleatoric = np.full_like(_x, 0.04)
epistemic = 0.5 * (1.0 - np.exp(-0.5 * distance**2))
predictive = epistemic + aleatoric
No single family dominates: the choice depends on whether you need speed, a defensible statistical statement, or robustness to shared model bias.
Ensembles and disagreement. Train dynamics models with different random initialisations and different bootstrapped data subsets. Each predicts a next-state mean . Then
where:
- : the disagreement-based uncertainty score at
- : the next-state mean predicted by ensemble member with parameters
- : the mean prediction averaged over the members
- : the Euclidean norm
This is one possible disagreement score, not MOPO's practical penalty. MOPO uses the maximum Frobenius norm of a member's predicted Gaussian scale matrix across its probabilistic model ensemble, which its authors describe as a maximum standard-deviation score. Ensemble disagreement can be useful, but it is not a calibrated posterior uncertainty; members with shared architecture and data may agree even when they are all wrong. Nor does predictive variance automatically isolate epistemic uncertainty from environmental randomness. The disagreement captures how much the members differ from each other, not how far the ensemble as a whole is from the truth. If all members are biased in the same way, the disagreement stays small while the error stays large.
Count-based uncertainty. In discrete or discretisable spaces, uncertainty is a function of the visit count . A common form is
where:
- : the uncertainty score at
- : the number of times was visited in the dataset
- : a smoothing prior (set to 1 in our implementation below)
The second has the scaling of many concentration bounds. It is not, without a specified prior and observation-noise variance, an exact posterior standard deviation. Count-based scores are cheap and interpretable when counts are meaningful; we use one below to isolate the penalty mechanism from neural-network training noise. Their weakness is that "count" requires a definition of "the same event," and in continuous state spaces that definition is a discretisation, which introduces its own choices.
Density and likelihood models. Fit with a normalising flow or a VAE, then use a quantity such as as a proxy for low data density. It is not automatically a calibrated bound on transition-model error or a reliable test of support. BEAR instead uses behavior-action samples and an MMD constraint in policy improvement. FisherBRC learns a behavior density for its critic decomposition and regularizes action gradients of the offset; neither method defines this density score as a world-model reward penalty. Likelihood is not the same as support: a generative model can give high likelihood to out-of-distribution inputs, as likelihood-based OOD studies demonstrate.
Bayesian neural networks and variational inference. Place a prior over model parameters and use a posterior predictive distribution. These methods introduce inference and calibration choices that differ from those of a bootstrap ensemble; no single family wins across tasks and model classes. PILCO's Gaussian-process dynamics, discussed in Part VIII: Decision-Centric Research Lineages, provide posterior uncertainty under the GP model assumptions. Naive exact GP fitting has cubic time cost in the number of training points, so that particular implementation does not scale cheaply to large datasets.
Spectral and Lipschitz-based bounds. A smooth learned model alone does not guarantee a small error outside the data. If the error function is known to be Lipschitz, or both true and learned dynamics have compatible known Lipschitz bounds, an error measured near a query point can be extended with a distance term. Such a result is deterministic under its assumptions, though its constants may be too loose to guide planning. The needed quantity is a bound on how quickly error can change, not merely on how quickly the learned prediction changes.
Ensembles are easy to get subtly wrong. Three details matter.
- Diversity source. Varying only the initialisation often produces models that agree too much. Bootstrapping the data (sample transitions with replacement per member) or using different data orderings helps.
- Noise handling. If each member samples an independent stochastic successor during a rollout, raw sample spread mixes environmental randomness with model disagreement. Compare predictions under a controlled noise coupling, or separate disagreement among predicted means from each model's predictive variance. The two components need different interpretations; MOPO's published practical penalty uses a norm of predicted Gaussian scale (standard deviation), not the mean-disagreement formula above.
- Ensemble size. A small ensemble is a common computational compromise, but there is no universal member count at which disagreement becomes calibrated or further models cease to help. Check size against held-out model error and compute cost in the task at hand.
A gridworld where the failure is visible
Now we build something concrete. The goal is not to reproduce a benchmark. It is to construct the smallest system in which we can see the mechanism with our own eyes, compute everything exactly, and check the answer against ground truth. Minimality matters here. Offline RL failures on real benchmarks are opaque: you see a poor return, but you cannot easily separate model error from coverage. In a small grid we can isolate those two factors directly.
The environment is a grid. Row 3 is a corridor that runs the full width; the goal sits at the far right end of that row. The grid edge acts as a wall. The goal is absorbing with zero reward, and reaching it pays 1.0. The discount factor is .
The behaviour policy is a random walk along row 3, moving right with probability 0.7 and left with probability 0.3. It never leaves the corridor. This is the crucial design choice: the dataset covers one row out of seven, and covers it densely. Every other cell in the grid has never been seen.
The leftward moves matter. With a purely rightward policy, each of the 59 pre-goal corridor cells would be visited once per episode, while the absorbing goal would still receive 20 logged tail visits. The 30% leftward action instead makes the walk revisit earlier cells, producing a non-uniform pre-goal visit distribution.
The left edge clips a left action back to the same cell, while the rightward bias eventually carries most episodes to the goal. Because episodes reset at the start and stop after a short tail of goal self-transitions, the logged counts are not a stationary distribution of an endless reflecting walk. They need not be uniform along the corridor; the heatmap below shows the actual finite-episode coverage.
The optimal policy is trivially "walk right 59 times," so the return from the start state is . (The reward is paid on arrival at the goal, after 59 steps, which is step index 58 from the start.) So we can compute every quantity exactly and ask whether each method finds it.
import numpy as np
# A 7 x 60 grid. Row 3 is the corridor that the behaviour policy walks along;
# every other cell is never visited. The goal sits at the far right of row 3.
H, W = 7, 60
N_STATES = H * W
N_ACTIONS = 4
CORRIDOR_ROW = 3
START = CORRIDOR_ROW * W
GOAL = CORRIDOR_ROW * W + (W - 1)
GAMMA = 0.99
DELTAS = ((-1, 0), (1, 0), (0, -1), (0, 1))
ACTION_NAMES = ("up", "down", "left", "right")
def true_step(state, action):
"""Ground-truth transition. The goal is absorbing with zero reward."""
if state == GOAL:
return GOAL, 0.0
row, col = divmod(state, W)
d_row, d_col = DELTAS[action]
row = min(max(row + d_row, 0), H - 1) # the grid edge acts as a wall
col = min(max(col + d_col, 0), W - 1)
nxt = row * W + col
return nxt, 1.0 if nxt == GOAL else 0.0Next we roll out the behaviour policy and record the transitions. Each episode runs until the goal is reached, followed by a short tail of goal self-transitions. The logged tail includes only left and right actions at the goal, so the empirical model learns those two self-loops; up and down remain unobserved and receive the same uniform fallback as other unsupported pairs. The true goal is absorbing under every action. This distinction matters: samples of some terminal actions do not tell a purely empirical model that all terminal actions must self-loop unless that structure is supplied explicitly.
def behaviour_action(rng):
"""A random walk confined to row 3: 70% right, 30% left."""
return 3 if rng.random() < 0.7 else 2
rng = np.random.default_rng(0)
transitions = []
for _ in range(300):
state = START
for _ in range(400):
action = behaviour_action(rng)
nxt, reward = true_step(state, action)
transitions.append((state, action, reward, nxt))
state = nxt
if state == GOAL:
break
assert state == GOAL, (
"This fixed-seed episode must reach the goal before its tail"
)
for _ in range(20): # a short tail of goal self-transitions
action = behaviour_action(rng)
nxt, reward = true_step(state, action)
transitions.append((state, action, reward, nxt))
state = nxt
dataset = np.asarray(transitions, dtype=np.int64)We now tabulate the data. counts records how often each state-action pair was taken, reward_sums accumulates the observed reward, and next_counts records how often each particular successor followed. Everything downstream is built from these three arrays: the empirical model is a normalisation of them, and the uncertainty scores are functions of counts.
counts = np.zeros((N_STATES, N_ACTIONS))
reward_sums = np.zeros((N_STATES, N_ACTIONS))
next_counts = np.zeros((N_STATES, N_ACTIONS, N_STATES))
for state, action, reward, nxt in dataset:
counts[state, action] += 1
reward_sums[state, action] += reward
next_counts[state, action, nxt] += 1transitions collected : 49,937 state-action pairs observed : 120 of 1,680 grid cells visited : 60 of 420
The printed counts show 120 of 1,680 state-action pairs observed, about 7.1%. The 60 visited cells are exactly the ones in row 3. Every other cell is unobserved, and every method below has to decide what to do there. The 7.1% figure is worth pausing on: it is not an extreme number, and yet it is enough to make a naive planner fail completely, as we are about to see.
Fitting the empirical model
The model is deliberately simple: a maximum-likelihood empirical transition model with a uniform fallback for unobserved pairs. This is the tabular analogue of a neural dynamics model with no regularisation, and it has the property we want to study: off-support, it returns something arbitrary, determined by our fallback choice rather than by evidence.
where:
- : the estimated transition probability from to
- : the estimated expected reward at
- : the number of times appears in the dataset
- : the number of times followed
- : the number of states, used as the uniform fallback denominator
The uniform fallback is a stand-in for extrapolation, not a claim that other approximators behave identically. A neural network may continue a learned trend; a random forest prediction reflects its selected leaves; a Gaussian-process posterior mean approaches its prior mean when covariance with the training inputs vanishes, as can happen far away under a decaying kernel. None is automatically correct off-support. The uniform choice makes the failure mode vivid: an unvisited transition can land anywhere in the grid, including directly on the goal.
uniform = np.full(N_STATES, 1.0 / N_STATES)
P_hat = np.tile(uniform, (N_STATES, N_ACTIONS, 1))
R_hat = np.zeros((N_STATES, N_ACTIONS))
seen = counts > 0
P_hat[seen] = next_counts[seen] / counts[seen][:, None]
R_hat[seen] = reward_sums[seen] / counts[seen]Counting the uncertainty
We use a count-based uncertainty proxy because the state-action visit counts are exact and interpretable in this tabular toy. In a larger continuous-state system, an ensemble is one common alternative; the count score isolates the penalty mechanism from neural-network training noise.
where:
- : the count-based uncertainty score
- : the visit count from the dataset
- : the prior pseudo-count, fixed at 1 here
If a pair has never been observed, . If it has been observed 999 times, . This is monotone, bounded in , and it shrinks at roughly in the well-sampled regime. The version has a different decay rate, closer to common concentration-bound scaling; here we use only as a simple bounded heuristic. Choosing a bounded score also keeps the penalty comparable across state-action pairs: no single pair can swamp the sum with an arbitrarily large uncertainty value.
# Count-based uncertainty proxy in (0, 1]: exactly 1 for an unobserved pair.
UNCERTAINTY_PRIOR = 1.0
u_hat = UNCERTAINTY_PRIOR / (UNCERTAINTY_PRIOR + counts)
visits = counts.sum(axis=1).reshape(H, W)
uncertainty_grid = u_hat[:, 3].reshape(
H, W
) # right action, sampled in the corridor

Two things to notice. Right-action uncertainty is maximal off the corridor, because those pairs were never observed, and lower where rightward actions appear in the logs. The up and down actions remain maximally uncertain even on the corridor; that distinction is in the underlying state-action counts, though this heatmap shows only the right-action slice. A familiar state does not make every action at that state well supported.
A third thing to notice is that cell counts are not uniform even within the corridor. Fifty of the 59 nonterminal corridor cells have between 700 and 800 visits in this seeded run, but the two cells immediately before the goal have only 600 and 428. The absorbing goal has 6000 visits because every one of the 300 episodes adds a tail of 20 goal self-transitions. That bright goal cell should not be mistaken for stronger coverage of the approach states, and cell counts alone still say nothing about unsupported actions at those states.
Solving the model without pessimism
Now we plan. The generic recipe is value iteration on the learned model:
where:
- : the estimated action-value at under the learned model
- : the estimated state value, the maximum of over actions
- : the estimated reward
- : the estimated transition probability
- : the discount factor
iterated to convergence. This is the same fixed-point computation that underlies every model-based planner in Part VII: Planning and Agency, with a learned model in place of the true one. The reason it is dangerous offline is precisely that value iteration trusts the model everywhere: the operator steps through all four actions at every state, including states the model has never seen, and it takes whatever action looks best according to estimates that may be arbitrary.
def value_iteration(P, R, gamma=GAMMA, tol=1e-10, max_sweeps=6000):
"""Value iteration for a finite MDP given as dense (S, A, S) and (S, A) arrays."""
V = np.zeros(P.shape[0])
for _ in range(max_sweeps):
Q = R + gamma * (P @ V) # (S, A, S) @ (S,) -> (S, A)
V_next = Q.max(axis=1)
if np.max(np.abs(V_next - V)) < tol:
V = V_next
break
V = V_next
return V, R + gamma * (P @ V)To judge the result we also need the ground truth: the true MDP tensor, a direct policy evaluator, and the true optimal value. The evaluator solves the linear system directly. The selected policies' true returns therefore come from a linear solve; planner scores and the optimal value come from value iteration to the stated tolerance. This is one of the luxuries of the gridworld setting: we can compare a method's belief about a policy against the policy's actual value in the true environment with negligible numerical error on the comparison.
P_true = np.zeros((N_STATES, N_ACTIONS, N_STATES))
R_true = np.zeros((N_STATES, N_ACTIONS))
for state in range(N_STATES):
for action in range(N_ACTIONS):
nxt, reward = true_step(state, action)
P_true[state, action, nxt] = 1.0
R_true[state, action] = reward
def evaluate_policy(P, R, policy, gamma=GAMMA):
"""Exact evaluation of a fixed deterministic policy in the supplied MDP."""
grid = np.arange(N_STATES)
P_pi = P[grid, policy]
R_pi = R[grid, policy]
return np.linalg.solve(np.eye(N_STATES) - gamma * P_pi, R_pi)
def evaluate_in_true_mdp(policy):
return evaluate_policy(P_true, R_true, policy)
V_star, _ = value_iteration(P_true, R_true)PENALTY = 0.5
V_naive, Q_naive = value_iteration(P_hat, R_hat)
V_pess, Q_pess = value_iteration(P_hat, R_hat - PENALTY * u_hat)
pi_naive = Q_naive.argmax(axis=1)
pi_pess = Q_pess.argmax(axis=1)
V_true_naive = evaluate_in_true_mdp(pi_naive)
V_true_pess = evaluate_in_true_mdp(pi_pess)
V_model_naive_policy = evaluate_policy(P_hat, R_hat, pi_naive)
V_model_pess_policy = evaluate_policy(P_hat, R_hat, pi_pess)naive model planner : planner score +3.082 | real -0.000 pessimistic model planner : planner score +0.509 | real +0.558 true optimal policy : | real +0.558
There it is. The unpenalised planner estimates a return of about 3.08 from the start state. Under the true dynamics its greedy policy earns zero. The model's unsupported transition fallback has created a profitable imagined route that the policy exploits.
The reason is worth tracing. The supported route rightward along the corridor reaches the goal in 59 steps, worth in the true MDP. Moving vertically enters unsupported pairs whose fitted transition distribution is uniform over all 420 cells. Some of those random successors lie near the goal and have observed, rewarding actions. At the true absorbing goal, the logged left and right actions self-loop, but the unlogged up and down actions also get the model's uniform fallback, so imagined exits from the goal add another optimistic path. Repeated imagined opportunities make the unsupported route look better than the corridor, even though true vertical moves never teleport. Landing directly on the true goal earns no immediate reward in this implementation; the learned model can nevertheless imagine future reward after an unsupported goal action.
Once the policy steps into an unobserved cell, all four actions have identical estimated value, so the argmax picks the first one, "up." In the true grid it moves from through and to . Further up actions are clipped by the top boundary, leaving it at forever. It never reaches the goal. The value map below shows the same failure structurally: the unpenalised value function is flat across the unvisited region, a plateau of imagined reward.
The flatness is the tell. In the true grid, a policy that moves toward the goal would have values that vary with distance. Instead, the unpenalised model assigns roughly the same value to every unsampled cell because it uses the same uniform successor distribution for each unsupported pair. This plateau exposes the uniform extrapolation built into the toy model; other model errors need not produce the same visual pattern.
def greedy_trajectory(policy, steps=150):
state = START
path = [state]
for _ in range(steps):
state, _ = true_step(state, policy[state])
path.append(state)
return np.array(path)
path_naive = greedy_trajectory(pi_naive)
path_pess = greedy_trajectory(pi_pess)
rows_naive, cols_naive = np.divmod(path_naive, W)
rows_pess, cols_pess = np.divmod(path_pess, W)



The two trajectories show the mechanism. Without a penalty, the model's ignorance is read as opportunity and the policy moves off the data. With a penalty, the same model, the same dataset, and the same value iteration produce the optimal policy in this toy. A penalty of 0.5 per unobserved transition is enough to change the selected policy from zero return to the full optimum.