Robustness, Calibration, and Safe Control › Distribution Shift and Stress Testing
World Models HandbookAll chapters Robustness, Calibration, and Safe Control › Distribution Shift and Stress TestingOne section · Sections in this chapter2 / 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
Distribution shift is the umbrella term for any mismatch between the distribution the world model was trained on and the distribution it is asked to act in. It is worth being precise about what can shift, because different kinds of shift demand different responses, and because a model's own uncertainty carries different amounts of information about each type.
- Input shift. The distribution of states and actions changes. In pure covariate shift, the conditional transition law given those inputs stays fixed. Observed inputs permit label-free distribution tests, but detection power depends on the change, representation, sample size, and test; visibility does not guarantee detection.
- Dynamics shift (concept shift). The conditional transition law changes, potentially at inputs that initially look familiar. The floor got wet; the payload got heavier; the actuator got slower. The model can be confidently wrong without obvious input novelty, and changed dynamics may alter the states visited later. This is the failure mode that motivates the entire chapter.
- Observation shift. The sensor or observation pathway changes: a new camera, changed calibration, lighting, or noise profile. An inaccurate state estimate can undermine an otherwise adequate transition model. Observation diagnostics and transition residuals can both signal a problem, but residuals alone need not identify its cause.
- Task shift. Goals, rewards, or constraints change. A change from docking to transit may alter the objective and also change visited states and actions. A new operating regime alone is input shift unless the task definition changes too.
- Hidden-parameter shift. A latent parameter value or its distribution changes, abruptly or gradually. Within a parameterized plant family, drifting friction is one mechanism of dynamics shift; it is not a definition covering every structural change.
The distinction affects what ensemble disagreement can reveal. The members may agree on a wrong transition when the conditional dynamics change. Conversely, disagreement can increase on novel inputs, but that response is not guaranteed: shared model bias can keep all members confident outside the data. We measure these responses in a particular synthetic example rather than assuming either is a reliable detector.
Why stress testing is not the same as held-out validation
Held-out validation measures prediction on its chosen test population. A same-generator split is useful for checking generalization within that generator, but may miss deployment conditions absent from it. Safety-relevant samples can include both rare in-distribution cases and out-of-distribution conditions. A large average RMSE improvement can coexist with serious boundary errors; report conditional and closed-loop outcomes rather than assuming the aggregate captures them.
Stress testing changes the population deliberately: under which plausible conditions does performance degrade, and which consequences follow? It can falsify a claim while also producing measurements. A useful output includes quantitative outcomes and an inventory of tested mechanisms, failures, and untested conditions. Failure to find a problem is evidence about the search performed, not proof of safety or evidence that the search was necessarily inadequate.
Good stress tests are built from a shift taxonomy for the specific domain, in which each category probes a distinct mechanism by which reality can diverge from training:
- Parameter stress. Perturb parameters over an explicitly justified domain, for example reducing friction, increasing mass, or reducing actuator gain. Search for adverse values within that domain; physical constraints may make it bounded.
- Input-distribution stress. Test high velocities, large commands, or near-boundary positions with sparse training coverage. Ensemble disagreement may rise there, but sparse coverage does not guarantee high estimated uncertainty.
- Structural stress. Add an effect outside the model class, such as an unrepresented delay or coupling. More data cannot remove that representational limitation while the class remains fixed. This tests a different limitation from parameter error.
- Temporal stress. Change the control rate, introduce jitter, or hold commands longer. Whether errors remain tolerable depends on the plant, controller, and timing. Evaluate closed-loop consequences rather than assuming a universal relation between rate difference and degradation.
When a plausible stress condition breaks the model, options include broadening data, changing the model class, restricting the operating domain, redesigning the controller or hardware, or adding detection and fallback. These approaches can complement one another but none universally handles rare or unbounded disturbances. A fallback needs its own operating assumptions. Part VI: Learning World Models at Scale covers learning and adaptation; here we evaluate diagnostic and control responses.
What we are building
Let us set up the running example. The plant is a mass on a spring, integrated with a semi-implicit Euler step at s. The state at step is the pair . The update advances velocity first and then uses that new velocity to advance position.
where:
- : position at step , the coordinate the safety envelope constrains
- : velocity at step
- : the acceleration computed at step and used to advance the velocity
- : the scalar control action applied at step ; it is the book's action under a local notation chosen to distinguish it from acceleration
- s: integration timestep
- : stiffness, the restoring force per unit displacement; : viscous damping, the drag proportional to velocity; : dry friction, a constant drag opposing motion; : actuator gain, how much acceleration one unit of command produces
- : process noise on velocity, the part of the dynamics no deterministic model can capture
The first equation computes acceleration; the second advances velocity and adds its innovation; the third uses that new velocity to advance position. Here denotes acceleration, not the action: the scalar action is . We observe the full simulated state , so no observation model or belief filter is implemented. Mass is normalized to one and action units are abstract; this oscillator is not a wheel-torque model. The simulator permits dry friction, but every experiment here sets . Semi-implicit Euler advances velocity before position; its stability depends on the timestep and dynamics, not on the method name alone.
import numpy as np
from scipy.stats import norm
DT = 0.1 # simulation step, seconds
U_VALID = 0.35 # action range covered by the training data
U_FALLBACK = 1.20 # stronger actuator authority used by the heuristic fallback
P_LIMIT = 0.60 # tested position envelope, not a proved invariant
SIGMA_P = 0.06 # process-noise standard deviation on velocity
def simulate(params, s0, actions, rng=None, sigma_p=0.0):
"""Semi-implicit Euler rollout of a damped oscillator with dry friction."""
k, c, gain, fric = params
p, v = float(s0[0]), float(s0[1])
traj = np.empty((len(actions) + 1, 2))
traj[0] = (p, v)
for t, u in enumerate(actions):
acc = -k * p - c * v - fric * np.sign(v) + gain * u
v = v + DT * acc
if sigma_p > 0.0:
v = v + rng.normal(0.0, sigma_p)
p = p + DT * v
traj[t + 1] = (p, v)
return trajBefore fitting anything, it is worth seeing the two regimes we keep returning to. The cell below rolls one outward initial condition and one constant braking command through the nominal plant and through a slippery floor whose damping has collapsed, leaving every other parameter unchanged.
# Two parameter tuples for a single illustrative rollout. The nominal tuple sits
# at the centre of the training ranges; the slippery tuple keeps stiffness and
# gain nominal and collapses damping toward zero. Damping is the only thing that
# changes, so any difference in behaviour is due to a hidden-parameter shift.
NOMINAL_PARAMS = (1.00, 0.90, 1.00, 0.0)
# A fixed low-damping regime for an illustrative, noise-free comparison.
SLIPPERY_PARAMS = (1.00, 0.05, 1.00, 0.0)
TRAJ_RNG = np.random.default_rng(20240518) # fixed seed, rollout is noise-free
TRAJ_STEPS = 60
traj_s0 = np.array([0.30, 0.60]) # outward position and velocity
traj_actions = np.full(TRAJ_STEPS, -0.35) # constant braking command
traj_nominal = simulate(NOMINAL_PARAMS, traj_s0, traj_actions, TRAJ_RNG, 0.0)
traj_slippery = simulate(SLIPPERY_PARAMS, traj_s0, traj_actions, TRAJ_RNG, 0.0)
traj_time = np.arange(TRAJ_STEPS + 1) * DT
The exploration policy samples abstract commands in from moderate initial conditions. Each episode draws a hidden parameter tuple. With only current state and command as inputs, parameter variation can produce different conditional outcomes at the same input. Even fixed parameters leave Gaussian process noise in this experiment. The regression estimates a conditional mean; its residual spread includes unresolved parameter variation, process noise, and fitting error rather than uncertainty from estimation alone.
K_RANGE = (0.80, 1.20)
C_RANGE = (0.70, 1.10)
GAIN_RANGE = (0.90, 1.10)
P_SCALE = 0.30 # initial-position scale
V_SCALE = 0.75 # initial-velocity scale
EP_LEN = 40 # steps per episode
def sample_nominal(rng):
return (
rng.uniform(*K_RANGE),
rng.uniform(*C_RANGE),
rng.uniform(*GAIN_RANGE),
0.0,
)
def collect_split(
rng,
n_episodes,
params_fn,
p_scale=P_SCALE,
v_scale=V_SCALE,
u_scale=U_VALID,
ep_len=EP_LEN,
):
"""Roll out episodes and return stacked (s, a) -> delta-s transitions."""
Xs, Ys = [], []
for _ in range(n_episodes):
s0 = rng.uniform(-1.0, 1.0, 2) * np.array([p_scale, v_scale])
actions = rng.uniform(-u_scale, u_scale, ep_len)
traj = simulate(params_fn(rng), s0, actions, rng, SIGMA_P)
Xs.append(np.column_stack([traj[:-1], actions]))
Ys.append(traj[1:] - traj[:-1])
return np.vstack(Xs), np.vstack(Ys)
rng = np.random.default_rng(20240517)
X_train, Y_train = collect_split(rng, 100, sample_nominal)
X_calib, Y_calib = collect_split(rng, 25, sample_nominal)
X_recal, Y_recal = collect_split(rng, 25, sample_nominal)
X_test, Y_test = collect_split(rng, 25, sample_nominal)The quadratic feature map below produces the 10-dimensional feature vector
where:
- : the constant term, allowing an affine offset in the prediction
- : the raw state and action coordinates
- : the squared terms, which let the model curve the prediction within the input range
- : the pairwise cross terms, which let the effect of one input depend on the value of another
- has entries in total, so it maps each input triple to a -dimensional feature vector.
The quadratic feature map can represent each fixed-parameter plant's linear conditional mean exactly because dry friction is zero here. The difficulty is omitted information: parameters vary across episodes, while the predictor receives only current state and action, not an episode parameter estimate or history. This produces unresolved variation for this representation. A history-conditioned identifier could reduce some of it; it is not necessarily irreducible for every world model. The fitted residual variance also contains finite-fit errors and possible bias, not just process noise. We use that approximate spread for the diagnostics below.
def features(X):
"""Quadratic features of the (position, velocity, action) triple."""
p, v, u = X[:, 0], X[:, 1], X[:, 2]
return np.column_stack(
[np.ones_like(p), p, v, u, p * p, v * v, u * u, p * v, p * u, v * u]
)
def fit_ensemble(Xtr, Ytr, Xcal, Ycal, n_members, rng):
"""Bootstrap-bagged ridge-free least squares, with a residual-variance head."""
Phi_tr = features(Xtr)
Phi_cal = features(Xcal)
members = []
for _ in range(n_members):
idx = rng.integers(0, len(Xtr), len(Xtr))
W, *_ = np.linalg.lstsq(Phi_tr[idx], Ytr[idx], rcond=None)
resid = Ycal - Phi_cal @ W
members.append((W, resid.var(axis=0)))
return members
RNG_MEMBERS = np.random.default_rng(11)
# Nine independent transition-bootstrap fits. Their spread is only a proxy
# for fit uncertainty, not a calibrated posterior over physical parameters.
members = fit_ensemble(X_train, Y_train, X_calib, Y_calib, 8, RNG_MEMBERS)
extra_member = fit_ensemble(
X_train, Y_train, X_calib, Y_calib, 1, np.random.default_rng(999)
)[0]
members.append(extra_member)
ALEATORIC = np.mean([var for _, var in members], axis=0)
def predict(members, X):
"""Return (mean, total variance, epistemic variance) for a batch of inputs."""
Phi = features(X)
stack = np.stack([Phi @ W for W, _ in members]) # (M, N, 2)
mean = stack.mean(axis=0)
epistemic = stack.var(axis=0)
residual_floor = np.mean(
[residual_var for _, residual_var in members], axis=0
)
return mean, epistemic + residual_floor, epistemicTwo variances are being combined, and the split matters:
- The epistemic proxy is the spread of the member predictions, , where is their mean and their count. It measures disagreement among these fits; it may grow on novel inputs but need not reveal shared bias.
- The held-out residual-variance floor is . It includes process noise, unresolved information for this predictor, and varying fitting errors. It is not a pure estimate of irreducible aleatoric uncertainty; a history-conditioned model may resolve some hidden-parameter variation. Centered variance also does not measure a constant residual bias.
Their sum is the code's approximate predictive variance, not a proven decomposition of true uncertainty. We keep disagreement separate to inspect its response to input novelty. The following output compares realized error with the claimed spread; calibration and decision utility require their own checks.
The next output compares one-step RMSE and mean predicted standard deviation by component. Similar values are a useful scale diagnostic, not proof of calibration; the coverage experiment below is a separate check. Complete trajectories are assigned to different data splits, but the transition bootstrap within each training split ignores temporal dependence and should not be interpreted as a statistically calibrated posterior.
mu, var, epi = predict(members, X_test)
rmse = np.sqrt(np.mean((Y_test - mu) ** 2, axis=0))
mean_pred_std = np.sqrt(var).mean(axis=0)one-step RMSE (delta-p, delta-v): 0.00614, 0.06144 mean predictive std (delta-p, delta-v): [0.00604842 0.06048423]
Building the stress suite
We build two stress sets. The input-shift generator increases initial position and velocity scales and draws abstract actions from , twice the training range, while retaining nominal plant parameter sampling. The dynamics-shift generator retains training-scale initial conditions and commands but draws damping from instead of . These are distinct interventions on the generator. Later states can change under either intervention, so a dynamics change does not imply that every encountered input remains in-distribution. The measured response of the ensemble, not input visibility alone, tells us what its diagnostic does here.
def collect_input_shift(rng_, n_episodes=25):
"""Expanded initial-state and action ranges with nominal plant sampling."""
Xs, Ys = [], []
for _ in range(n_episodes):
s0 = rng_.uniform(-1.0, 1.0, 2) * np.array([0.95, 1.60])
actions = rng_.uniform(-0.70, 0.70, EP_LEN)
traj = simulate(sample_nominal(rng_), s0, actions, rng_, SIGMA_P)
Xs.append(np.column_stack([traj[:-1], actions]))
Ys.append(traj[1:] - traj[:-1])
return np.vstack(Xs), np.vstack(Ys)
def sample_light_damping(rng_):
"""The floor got slippery: damping collapses, everything else is nominal."""
return (
rng_.uniform(*K_RANGE),
rng_.uniform(0.00, 0.05),
rng_.uniform(0.90, 1.10),
0.0,
)
X_in, Y_in = collect_input_shift(rng)
X_dyn, Y_dyn = collect_split(rng, 25, sample_light_damping)
def evaluate_regime(X, Y):
mu_r, var_r, epi_r = predict(members, X)
residual = Y - mu_r
return dict(
rmse=float(np.sqrt(np.mean(residual**2))),
pred_sd=float(np.sqrt(var_r).mean()),
epi_sd=float(np.sqrt(np.maximum(epi_r, 0.0)).mean()),
rms_z=float(np.sqrt(np.mean(residual**2 / var_r))),
)
stress_rows = [
("in-distribution", evaluate_regime(X_test, Y_test)),
("input shift", evaluate_regime(X_in, Y_in)),
("dynamics shift", evaluate_regime(X_dyn, Y_dyn)),
]We record error, predicted spread, standardized residual scale, and ensemble disagreement separately. Here . Correct conditional means and finite positive conditional variances imply . Values above one indicate larger squared errors than the predicted scale accounts for; values below one indicate the reverse. Bias and tail shape can affect this diagnostic, so neither result alone establishes interval calibration or useful conservatism. Disagreement may respond to novelty, but need not do so.
regime RMSE pred sd epistemic RMS z in-distribution 0.0437 0.0333 0.0012 1.02 input shift 0.0451 0.0337 0.0042 1.03 dynamics shift 0.0459 0.0333 0.0021 1.06
The table reports RMSE, predicted standard deviation, epistemic standard deviation, and RMS z for each regime.
For these seeds, compare the epistemic column with the total error and RMS z. The dynamics perturbation changes the plant, yet its aggregate one-step effect is small relative to the injected process noise. This experiment therefore does not demonstrate catastrophic one-step error or a dramatic calibration collapse. It shows why a modest change in aggregate error is not evidence that closed-loop behavior is unchanged. Although the shifted episodes start at training-scale initial states and actions, their later states need not have the same distribution.
Ensemble disagreement alone cannot establish whether the plant still obeys its old dynamics. A monitor can also use observed prediction residuals, independently justified disturbance bounds, or physical constraints. Each signal has blind spots, so stress tests should evaluate detection delay and false alarms, not merely the model's average uncertainty.

The two coordinates have different scales, so an aggregate comparison can hide component-specific behavior. Inspect the earlier per-coordinate output and the reliability curves as well. This plot is a summary of the specified tests, not a general validation of the uncertainty estimator.