Sampling-Based Planning and Model Predictive Control › Random Shooting and Cross-Entropy Methods

Michael BrenndoerferJuly 7, 202618 min read
World Models HandbookAll chapters
Part 7 of 12Planning and Agency
Chapter 37Sampling-Based Planning and Model Predictive Control › Random Shooting and Cross-Entropy Methods
Reading section 2 of 8 · Random Shooting and Cross-Entropy Methods · 18 minSection 2 of 8 · 18 min

Book contents

Section 2 of 8 · 18 min read

Sampling-Based Planning and Model Predictive Control › Random Shooting and Cross-Entropy MethodsOne 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

Random shooting is the most stripped-down planner you can write. You fix a planning horizon HH, which is the number of future steps you intend to consider. You fix a control distribution, usually uniform over the admissible action set U=[−umax⁡,umax⁡]da\mathcal{U} = [-u_{\max}, u_{\max}]^{d_a}. You draw NN independent action sequences,

a0:H−1(i)∼UH,i=1,…,N,a^{(i)}_{0:H-1} \sim \mathcal{U}^H, \qquad i = 1, \dots, N,

roll each sequence through the model to obtain a predicted trajectory τ(i)\tau^{(i)}, evaluate a cost J(τ(i))J(\tau^{(i)}), and return the sequence with the lowest cost. An open-loop controller may execute the whole sequence. When random shooting is used inside MPC, only the first action or a short block is executed before replanning. That is the entire search algorithm.

Notice what is absent from this recipe. There is no gradient, no Hessian, no linearization, no convexity assumption, and no requirement that the model be differentiable at all. The planner treats the dynamics as an opaque function mapping a state and an action to a next state. It queries that function only in the forward direction. In optimization language this is zeroth-order or derivative-free optimization: it uses function values alone to search. This single property is what makes random shooting compatible with trained neural networks, with simulators that contain lookup tables or branch logic, and with physical systems that can only be probed by experiment. It is also why the method scales so well with parallel hardware: every one of the NN rollouts is independent, so the whole batch can be evaluated on a GPU at once.

The reason this works at all is that a model rollout may be cheap relative to acting in the real world. The limitation is the curse of dimensionality of action-sequence space: a regular grid with resolution ϵ\epsilon along each coordinate contains (1/ϵ)Hda(1/\epsilon)^{H d_a} length-HH sequences for an action dimension dad_a. A 10-step horizon with two action coordinates and 100 levels per coordinate would contain 10020100^{20} grid points. Random sampling avoids enumerating that grid but does not remove the dimensional dependence. For a regular grid with NN points in a HdaH d_a-dimensional unit box, the per-coordinate spacing scales as N−1/(Hda)N^{-1/(H d_a)}. Random samples do not form a regular grid, so this curve is a resolution heuristic rather than an exact nearest-neighbor law; it still exposes the exponential dependence on sequence-space dimension.

The shape of that heuristic is worth seeing. The cell below evaluates N−1/(Hda)N^{-1/(H d_a)} over five decades of point budget for three sequence-space dimensions and plots the result on log-log axes.

In[3]:
Code
import numpy as np

# Regular-grid spacing along one coordinate scales as N^(-1/d), where
# d = H * d_a is the dimension of action-sequence space. These are analytical
# heuristic curves, not sampled measurements.
sample_budgets = np.unique(np.round(np.logspace(1, 5, 80)).astype(int))
sequence_dims = [2, 10, 20]  # (H=1, d_a=2), (H=5, d_a=2), (H=10, d_a=2)
spacing_curves = {
    dim: sample_budgets.astype(float) ** (-1.0 / dim) for dim in sequence_dims
}
Out[4]:
Visualization
Log-log curves showing sample spacing shrinking far more slowly in higher dimensions.
Analytical regular-grid spacing heuristic $N^{-1/(H d_a)}$ along one coordinate as a function of point budget, for dimensions $H d_a = 2$, 10, and 20. It illustrates why comparable coordinate resolution requires exponentially more points as dimension grows; it is not a measurement of random-sample nearest-neighbor distance.

Multiplying the grid-point budget by 10,00010{,}000 reduces this spacing by a factor of 100 in two dimensions, but only by a factor of about 1.58 in twenty dimensions. The calculation is the argument: holding coordinate resolution fixed requires a budget exponential in dimension.

The practical consequence is that uniform sampling becomes inefficient as horizons and action dimensions grow. Whether it remains adequate depends on task structure, action parameterization, and compute budget.

CEM replaces the fixed proposal with a parametric distribution refitted to the lowest-cost samples in each current batch. In the diagonal-Gaussian form used here, every time-action coordinate has a mean μ\mu and standard deviation σ\sigma and is sampled independently before clipping. The refitted mean moves toward the current elites, while the spread may contract or expand coordinate by coordinate. In the version implemented below:

  1. Initialize μ0=0\mu_0 = \mathbf{0} and σ0=umax⁡1\sigma_0 = u_{\max} \mathbf{1}.
  2. For iteration k=1,…,Kiterk = 1, \dots, K_{\text{iter}}:
    • Draw NN samples a(i)∼N(μk−1,diag⁡(σk−12))a^{(i)} \sim \mathcal{N}(\mu_{k-1}, \operatorname{diag}(\sigma_{k-1}^2)), clipped to [−umax⁡,umax⁡][-u_{\max}, u_{\max}].
    • Evaluate each sample's cost Ji=J(rollout(s,a(i)))J_i = J(\text{rollout}(s, a^{(i)})).
    • Select the top NeliteN_\text{elite} samples by lowest cost.
    • Set μk\mu_k and σk\sigma_k to the mean and standard deviation of those elites.
  3. Return μKiter\mu_{K_{\text{iter}}} as the plan.

The reason this works is the general elite-set idea of evolutionary methods. The elite samples identify a region with low observed cost, and refitting narrows the proposal distribution toward that region. Because the standard deviation usually shrinks, subsequent samples concentrate more finely on promising actions. The name "cross-entropy" comes from choosing a sampling distribution that is close in Kullback-Leibler divergence to an ideal rare-event or elite distribution, as developed in de Boer et al.'s tutorial. In the finite-sample Gaussian version used here, maximum-likelihood fitting to elites produces their sample mean and standard deviation. This is iterative distribution fitting; it should not be treated as literal projected gradient ascent on the original objective.

This iterative refitting distinguishes CEM from one larger random batch. Random shooting spends its budget on independent samples from a fixed proposal; CEM uses earlier evaluations to change later proposals. This can help when a representable low-cost region occupies little of the original proposal. It can hurt when elites span unrelated modes, because a single fitted Gaussian may average them into a poor region. A smaller elite fraction, explicit smoothing, covariance control, or a mixture proposal can change that behavior, but each must be tuned empirically.

Related Method: MPPI

Model Predictive Path Integral control (MPPI) is a related sampling controller that perturbs a nominal control sequence and forms a cost-weighted update. In a simplified presentation, lower-cost rollouts receive weights proportional to exp⁡(−Ji/λ)\exp(-J_i / \lambda); the full derivation also contains sampling-correction terms. Williams, Aldrich, and Theodorou derive MPPI under stochastic-control and generalized importance-sampling assumptions. CEM instead fits its proposal to a hard elite set. Their relative performance depends on dynamics, objective, proposal, hyperparameters, and budget; neither update is uniformly more robust to a "rugged" cost surface.

Let us set up the running example. Our system is a 2D double integrator, a simplified model for a point mass commanded by acceleration in two Cartesian axes. The state is s=(x,y,vx,vy)s = (x, y, v_x, v_y) and the action a=(ax,ay)a = (a_x, a_y) is an acceleration command, bounded by ∣ai∣≤umax⁡|a_i| \le u_{\max}. The discrete-time dynamics are

xt+1=xt+vx,tΔt+12ax,tΔt2,vx,t+1=vx,t+ax,tΔt,x_{t+1} = x_t + v_{x,t} \Delta t + \tfrac{1}{2} a_{x,t} \Delta t^2,\quad v_{x,t+1} = v_{x,t} + a_{x,t} \Delta t,

and identically in yy. The cost encodes our intent. To reach a goal gg we penalize squared distance from gg over the trajectory plus a small penalty on control effort:

J(s,a0:H−1)=∑t=1H∥pt−g∥2+α∑t=0H−1∥at∥2.J(s, a_{0:H-1}) = \sum_{t=1}^{H} \|p_t - g\|^2 + \alpha \sum_{t=0}^{H-1} \|a_t\|^2.

A few remarks on why this example is worth the space. Position-only feedback does not directly damp the double integrator's velocity, so reaching a target smoothly requires accelerating and then decelerating. The displayed update is the exact one-step solution of continuous constant-acceleration dynamics when the action is held fixed over each interval. Because the nominal dynamics are available in closed form, we can use them as the planner's model and separately perturb the simulated plant. That isolates the effect of replanning under a controlled model-plant mismatch. The effort term with weight α\alpha discourages unnecessarily large or oscillatory controls; it does more than merely break exact ties.

Let us implement this system and the two planners.

In[5]:
Code
import time

import matplotlib.pyplot as plt
import numpy as np

DT = 0.1
U_MAX = 1.0
EFFORT_WEIGHT = 0.05


def batched_rollout(states, actions, dt=DT):
    """states: (B, 4), actions: (B, T, 2) -> trajectories (B, T+1, 4)."""
    B, T, _ = actions.shape
    s = states.astype(float).copy()
    out = np.empty((B, T + 1, 4))
    out[:, 0] = s
    for t in range(T):
        a = actions[:, t]
        x, y, vx, vy = s[:, 0], s[:, 1], s[:, 2], s[:, 3]
        s = np.stack(
            [
                x + vx * dt + 0.5 * a[:, 0] * dt**2,
                y + vy * dt + 0.5 * a[:, 1] * dt**2,
                vx + a[:, 0] * dt,
                vy + a[:, 1] * dt,
            ],
            axis=1,
        )
        out[:, t + 1] = s
    return out


def batched_cost(states, actions, goal):
    """Trajectory cost, evaluated in parallel over a batch of candidate plans."""
    traj = batched_rollout(states, actions)
    pos_err = np.sum(
        (traj[:, 1:, :2] - np.asarray(goal)[None, None, :]) ** 2, axis=(1, 2)
    )
    effort = EFFORT_WEIGHT * np.sum(actions**2, axis=(1, 2))
    return pos_err + effort, traj

Two small comments on the helpers. First, we batch everything. A planner that evaluates thousands of candidate sequences per control tick benefits strongly from vectorized rollout, so we operate on arrays of shape (B, T, 2). A Python loop over candidate plans would add interpreter overhead and would make the later timing comparison measure implementation style as much as planner structure. Second, the cost is defined on the equally spaced trajectory returned by batched_rollout. In a learned system, this function could call a neural transition model instead of the analytic equations. A replacement model can use the same planner interface when it matches the expected batch shapes, state semantics, time step, and output convention.

Now the two planners. Random shooting is a one-liner wrapped in a function.

In[6]:
Code
def random_shooting(
    state, goal, horizon, n_samples=1000, u_max=U_MAX, rng=None
):
    if rng is None:
        rng = np.random.default_rng(0)
    actions = rng.uniform(-u_max, u_max, size=(n_samples, horizon, 2))
    states = np.tile(np.asarray(state, dtype=float)[None, :], (n_samples, 1))
    values, _ = batched_cost(states, actions, goal)
    best_idx = int(np.argmin(values))
    return actions[best_idx], float(values[best_idx]), values

Read the function from top to bottom and you see the whole algorithm in four lines: sample, roll out, evaluate, return the argmin. The returned cost array supports diagnostics, but its shape needs context. A flat high-cost batch suggests the proposal found no useful distinction; a flat low-cost batch may mean that many plans work. A single low outlier can be a better plan or a noisy or model-exploiting anomaly, so it should be checked rather than trusted automatically.

The cross-entropy method shares the same evaluation loop but keeps the sample distribution as explicit state. The keyword mu_init lets a caller provide a warm-start mean; we will use it later for MPC.

In[7]:
Code
def cem(
    state,
    goal,
    horizon,
    n_samples=500,
    n_elite=50,
    n_iters=8,
    u_max=U_MAX,
    rng=None,
    mu_init=None,
    sigma_init=None,
    cost_fn=None,
):
    if rng is None:
        rng = np.random.default_rng(0)
    if cost_fn is None:

        def cost_fn(states, actions):
            return batched_cost(states, actions, goal)

    mu = (
        np.zeros((horizon, 2))
        if mu_init is None
        else np.array(mu_init, dtype=float)
    )
    sigma = (
        np.full((horizon, 2), u_max)
        if sigma_init is None
        else np.array(sigma_init, dtype=float)
    )
    states = np.tile(np.asarray(state, dtype=float)[None, :], (n_samples, 1))

    history = []
    for _ in range(n_iters):
        actions = rng.normal(
            mu[None, :, :], sigma[None, :, :], size=(n_samples, horizon, 2)
        )
        actions = np.clip(actions, -u_max, u_max)
        values, _ = cost_fn(states, actions)
        elite_idx = np.lexsort((np.arange(n_samples), values))[:n_elite]
        elites = actions[elite_idx]
        mu = elites.mean(axis=0)
        sigma = elites.std(axis=0) + 1e-4
        history.append(float(values.min()))
    best = np.clip(mu, -u_max, u_max)
    return best, mu, np.array(history)

There are three implementation details worth dwelling on because they are easy to get wrong. The first is the cost_fn indirection. Passing the cost as a callable, rather than hard-coding the goal-distance cost, is what lets us later bolt on obstacle penalties and other constraint terms without touching the planner. The planner's job is to minimize whatever scalar the cost function returns; it has no opinion about what that scalar means.

The second is the np.clip inside the sample loop. Without it, the elite set could contain inadmissible actions. Clipping keeps evaluated controls inside the box but creates point mass at its boundaries: the elite moments are fitted to clipped samples while the next batch comes from an unclipped normal. When substantial proposal mass lies outside the action box, a truncated distribution or bounded reparameterization avoids that mismatch.

The third is the tiny constant 1e-4 added to the standard deviation. It prevents the distribution from collapsing to a point, which would make every subsequent sample identical and freeze the search, and it also prevents division-by-zero type pathologies in any downstream code that uses sigma. The value is a floor on exploration; if the planner needs to keep searching aggressively, it should be larger, and if the planner should commit to the plan it has found, it should be smaller.

The concentration that the mean and standard deviation updates produce is worth seeing directly. The cell below runs a short CEM with a fixed seed and records the first-step action samples at the first and fourth iterations, together with the elite mean fitted at each. Ties inside the elite set are broken by lowest cost, then lowest sample index.

In[8]:
Code
# A short CEM run recorded online so the narrowing of the sampling distribution
# is visible. State, horizon, seed, sample budget, and elite count are fixed; the
# elite set is the lowest-cost fraction with ties broken by lowest sample index.
cem_demo_state = np.zeros(4)
cem_demo_goal = np.array([3.0, 2.0])
cem_demo_horizon = 10
cem_demo_n = 400
cem_demo_elite = 40
cem_demo_rng = np.random.default_rng(5)
cem_demo_mu = np.zeros((cem_demo_horizon, 2))
cem_demo_sigma = np.full((cem_demo_horizon, 2), U_MAX)
cem_demo_rounds = []
for _ in range(4):
    cem_demo_samples = cem_demo_rng.normal(
        cem_demo_mu[None, :, :],
        cem_demo_sigma[None, :, :],
        size=(cem_demo_n, cem_demo_horizon, 2),
    )
    cem_demo_samples = np.clip(cem_demo_samples, -U_MAX, U_MAX)
    cem_demo_states = np.tile(cem_demo_state[None, :], (cem_demo_n, 1))
    cem_demo_vals, _ = batched_cost(
        cem_demo_states, cem_demo_samples, cem_demo_goal
    )
    cem_demo_elite_idx = np.lexsort((np.arange(cem_demo_n), cem_demo_vals))[
        :cem_demo_elite
    ]
    cem_demo_rounds.append(
        {
            "samples": cem_demo_samples[:, 0, :].copy(),
            "fitted_mu": cem_demo_samples[cem_demo_elite_idx]
            .mean(axis=0)[0]
            .copy(),
        }
    )
    cem_demo_mu = cem_demo_samples[cem_demo_elite_idx].mean(axis=0)
    cem_demo_sigma = cem_demo_samples[cem_demo_elite_idx].std(axis=0) + 1e-4
Out[9]:
Visualization
Scatter of sampled actions tightening from a wide cloud to a dense cluster.
First-step action samples drawn from a clipped diagonal-Gaussian CEM proposal at the first and fourth iterations, with each fitted elite mean marked. In this fixed-seed run the later cloud is more concentrated around the region selected by the elite updates.

Let us now compare the two planners on a single starting state. The start is the origin, the goal is (3,2)(3, 2), and the horizon is 25 steps, which at Δt=0.1\Delta t = 0.1 s is 2.5 seconds of simulated time.

In[10]:
Code
state0 = np.array([0.0, 0.0, 0.0, 0.0])
goal = np.array([3.0, 2.0])
PLAN_HORIZON = 25

RS_SAMPLES = 500
CEM_SAMPLES = 800
CEM_ELITE = 80
CEM_ITERS = 15

rs_actions, rs_best, _ = random_shooting(
    state0,
    goal,
    PLAN_HORIZON,
    n_samples=RS_SAMPLES,
    rng=np.random.default_rng(0),
)
cem_actions, _, cem_history = cem(
    state0,
    goal,
    PLAN_HORIZON,
    n_samples=CEM_SAMPLES,
    n_elite=CEM_ELITE,
    n_iters=CEM_ITERS,
    rng=np.random.default_rng(1),
)

rs_traj = batched_rollout(state0[None, :], rs_actions[None, :, :])[0]
cem_traj = batched_rollout(state0[None, :], cem_actions[None, :, :])[0]
rs_cost_val = float(
    batched_cost(state0[None, :], rs_actions[None, :, :], goal)[0][0]
)
cem_cost_val = float(
    batched_cost(state0[None, :], cem_actions[None, :, :], goal)[0][0]
)
Out[11]:
Console
Random shooting best cost after 500 samples: 250.931
CEM (15 iterations, 800 samples each) returned mean-plan cost: 158.677
Ratio of costs (CEM / RS): 0.632

In this fixed-seed run, CEM achieves a lower objective value than random shooting. The accounting matters: random shooting evaluates 500 sequences, while CEM evaluates 15×800=12,00015 \times 800 = 12{,}000, so this is a mechanism demonstration rather than an efficiency benchmark. A fair efficiency comparison would equalize wall-clock time or model evaluations and repeat across seeds. Here the paths show the plans selected under the stated, unequal budgets; the objective values establish only which plan scores lower under this chapter's cost function.

Out[12]:
Visualization
Two position paths from the origin toward a goal star; the CEM path extends farther and has the lower objective value in this run.
Planned position trajectories from one fixed-seed random-shooting batch and one CEM run on the 2D double integrator under different rollout budgets. In this illustrative run, CEM produces the lower-cost plan; this is not an equal-compute benchmark.
Line chart of best cost falling steeply over 15 CEM iterations.
Lowest sampled cost in each CEM iteration for this fixed-seed run. The downward trend is an observed trace, not a monotonicity guarantee.

The figure records a cost advantage for this run, and the right panel shows the lowest sampled cost in each CEM iteration. A steep early drop followed by a plateau is consistent with the proposal locating and refining one low-cost region. A curve still falling at the budget boundary suggests that extra iterations are worth testing. A flat, high curve is less diagnostic: poor initialization, inadequate proposal variance, too few samples, an unsuitable elite fraction, or a flat objective can all produce it.

The elite fraction is one knob behind that diagnostic. The sweep below holds the start state, horizon, sample budget, iteration count, and pseudorandom seed fixed, changing the number of elites. Within this deterministic implementation, the configured independent variable is the fraction; the result is still one optimizer seed on one task. Ties are ordered by cost and then sample index.

In[13]:
Code
# Elite-fraction sweep on the single-state planning problem. Start state,
# horizon, sample budget, iteration count, and seed are fixed so that any change
# in plan cost is attributable to the elite fraction alone.
elite_fractions = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5]
ELITE_SWEEP_SAMPLES = 2000
ELITE_SWEEP_ITERS = 15
elite_sweep_costs = []
for frac in elite_fractions:
    n_elite = max(1, int(round(frac * ELITE_SWEEP_SAMPLES)))
    plan_frac, _, _ = cem(
        state0,
        goal,
        PLAN_HORIZON,
        n_samples=ELITE_SWEEP_SAMPLES,
        n_elite=n_elite,
        n_iters=ELITE_SWEEP_ITERS,
        rng=np.random.default_rng(4),
    )
    cost_frac = float(
        batched_cost(state0[None, :], plan_frac[None, :, :], goal)[0][0]
    )
    elite_sweep_costs.append(cost_frac)

elite_fractions = np.array(elite_fractions)
elite_sweep_costs = np.array(elite_sweep_costs)
Out[14]:
Visualization
Line chart of plan cost against elite fraction, lowest at a moderate fraction.
Planned trajectory cost in one deterministic seed sweep as the CEM elite fraction varies, holding the start state, horizon, sample budget, and iteration count fixed. The run illustrates that both very selective and very broad refits can underperform an intermediate setting; robust tuning requires multiple seeds and tasks.

Two practical notes before we move on. First, the split between samples and CEM iterations matters, not just their product. Iterations let the proposal migrate, whereas samples improve each empirical elite estimate; too few samples can make that estimate unstable. Which split is better therefore depends on dimension, elite fraction, objective, and overhead. Second, in a deterministic open-loop setting, both planners produce a plan calibrated to one starting state. That plan is not a feedback controller: it does not react to disturbances, model error, or unexpected observations. To close the loop, we need MPC.