zero2robot · Phase 3 · Advancedch3.9-mpc · mpc.py

Chapter 3.9

Plan Through Your EngineSampling-Based MPC (CEM / MPPI)

By the end you can

  1. Kill the misconception that you must LEARN a policy to control a robot. With a model — the engine you built in ch3.3-3.6 — you can PLAN: at every control step, sample action sequences, roll each through the model, score by a cost you write down, and act on the best. Cartpole swing-up falls to this with ZERO learning.
  2. Implement sampling-based MPC from scratch: sample N action sequences over a horizon H, roll each through the model, refine the sampling distribution for a couple of iterations, execute the first action, and re-plan from where you actually land (RECEDING horizon).
  3. Hold both refinement rules in one file and read the ~15-line diff: CEM refits a Gaussian to the ELITE fraction; MPPI takes a softmax-WEIGHTED average of every sample. Same loop, one idea differs (the course's signature device).
  4. Feel the honest ceiling: MPC needs a good model AND compute per step (N x H model rollouts every tick). Cripple the plan (--break: too short a horizon, or too few samples) and the pole never comes up — the search must look far enough ahead, with enough tries, to find the energy-pumping swing.

See it work

live · P2
look-ahead horizon
far enough to see falling now buys the swing later
the fan-out · one plan step18 samples · H=25
↑ upright↓ hanging down
the swing-up · realised episodeupright @ step 78
upright (cos ≥ 0.9)+10-1cos0306090119control step →step 78
Look-ahead horizon 25: the sampled plans fan out and MPPI commits to the best. The pole swings up, reaching upright at step 78 and holding there. Mean plan cost 1.21.

This is planning through a model with zero learning — no policy is trained. Every control step re-solves swing-up by sampling action sequences, rolling each through the model, and committing to the first action of the best. The honest ceiling: it needs a good model and compute every step, and here the model is the world (the best case) — with a sim-to-sim gap the plan would optimise an imagined trajectory reality won't follow. Real geometry from mpc.py (seed 0, mppi); poster reads with JS off.

Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

Every controller you built until now learned. This one doesn't.

Behavior cloning copied a demonstrator. PPO and SAC learned a policy the hard way — thousands of episodes of acting, failing, and nudging weights until a network mapped states to good actions. Different as they are, they share one assumption so deep you probably never questioned it: that controlling a robot means first learning a policy. Pour experience in, read actions out.

This chapter throws the network away.

You spent chapters 3.3 through 3.6 prying open mj_step — integrators, joints as constraints you solve for, contact as a complementarity you project — until the simulator stopped being a black box and became an engine you understand. Step back and notice what an engine actually is: a function that takes a state and an action and hands you the next state. That is the one thing you need to plan. If you can ask "what happens if I do this?" and get an honest answer, you don't have to learn what to do — you can search for it, live, at every control step. That is Model Predictive Control, and it is how a large fraction of real robots are actually driven.

The task: swing a pole up with no demonstrator and no reward to learn from

Cartpole again, but harder, and pointed the other way. In chapter 2.1 the pole started near upright and PPO learned to balance it. Here the pole starts hanging straight down, and the only actuator pushes the cart sideways. You cannot torque the pole up directly — the system is underactuated. The only way up is to pump: rock the cart to build the pole's energy, let it swing higher each time, and catch it at the top. It is the canonical demonstration that planning beats reacting, because acting greedily — always reducing your cost right now — never lets the pole fall the "wrong" way long enough to gain the momentum that brings it up.

There is no training run in this chapter. No demonstrator, no learned reward, no replay buffer. Just a model and a search. The pole swings up anyway.

The idea, in four steps

At every control step, from the state you are actually in:

  1. Sample N candidate action sequences, each H steps long — a spray of possible short-term plans.
  2. Roll each one forward through the model and score it with a cost you simply write down (upright pole, centered cart, settled motion). No network estimates the value; the engine tells you exactly what each plan leads to.
  3. Refine the distribution you're sampling from toward the plans that scored well, and repeat step 1–2 a couple of times.
  4. Execute only the first action of the best plan, let the real world advance one tick, and re-plan from where you actually landed.

Step 4 is the "predictive" and the "receding horizon" in Model Predictive Control: you always re-decide from the true current state, throwing away the tail of last step's plan, because no model is perfect and the world drifts. You plan far, commit little, and plan again.

The model is a second copy of the engine

MPC's whole premise is that you have a model — something you can fast-forward and, crucially, rewind, so you can try one plan, snap back to the start, and try the next. We keep the real world and the model as two separate objects on purpose. The planner may only touch sim; the true state only ever advances through env. Saving and restoring the model's state is just reading and writing MuJoCo's qpos/qvel — the full state the engine needs to continue:

mpc.py#modelsha256:3aec574bb4…
# The MODEL you plan through. MPC's whole premise is that you HAVE one: a thing# that maps (state, action) -> next state, that you can fast-forward and rewind at# will. Here it is a second copy of the cartpole simulator — the same mj_step you# took apart in ch3.3-3.6. We keep the "real world" (env) and the "model" (sim)# as SEPARATE objects on purpose: the planner may only touch `sim`; the true state# only ever advances through `env.step`. Here model == world (a PERFECT model);# ch3.6 already measured what happens when they differ — hold that thought for the# ceiling at the end of the chapter.env = CartpoleEnv()   # the real world: we read its state and apply one action per ticksim = CartpoleEnv()   # the imagined model: the planner rolls candidate plans through THISCONTROL_SUBSTEPS = CartpoleEnv.FRAME_SKIP  # each control step = this many mj_step physics ticks (50 Hz control)SLIDER = sim.model.joint("slider").qposadr[0]  # cart position index in qpos/qvelHINGE = sim.model.joint("hinge").qposadr[0]    # pole angle index (0 = upright, pi = hanging down)  def get_state(e: CartpoleEnv) -> tuple[np.ndarray, np.ndarray]:    """Snapshot the full dynamical state (qpos, qvel) — everything mj_step needs to continue."""    return e.data.qpos.copy(), e.data.qvel.copy()  def set_state(e: CartpoleEnv, qpos: np.ndarray, qvel: np.ndarray) -> None:    """Rewind the model to a saved state. mj_forward refreshes derived quantities    (site positions, etc.) so the next mj_step continues from EXACTLY here."""    e.data.qpos[:] = qpos    e.data.qvel[:] = qvel    e.data.ctrl[:] = 0.0    mujoco.mj_forward(e.model, e.data)  def step_model(e: CartpoleEnv, action: float) -> None:    """Advance the model one control step: hold `action` for CONTROL_SUBSTEPS ticks."""    e.data.ctrl[0] = float(np.clip(action, -1.0, 1.0))    for _ in range(CONTROL_SUBSTEPS):        mujoco.mj_step(e.model, e.data)  def state_cost(e: CartpoleEnv, action: float) -> float:    """The TASK, written down as a cost to minimize — there is no learning here, you    just SAY what 'good' means. Swing-up wants the pole UPRIGHT, the cart near center,    and the motion SETTLED so it does not whirl straight through the top. This one    function scores both the imagined rollouts (the planner) and the realized step."""    theta = wrap_angle(float(e.data.qpos[HINGE]))       # angle from upright; 0 up, +-pi down    x = float(e.data.qpos[SLIDER])                       # cart offset from rail center    return ((1.0 - np.cos(theta))                        # 0 upright, 2 hanging down (the dominant term)            + 0.1 * x * x                                # keep the cart near center            + 0.01 * float(e.data.qvel[HINGE]) ** 2      # settle the pole (don't spin through the top)            + 0.005 * float(e.data.qvel[SLIDER]) ** 2    # settle the cart            + 0.001 * float(action) ** 2)                # mild effort penalty  def upright_cos(e: CartpoleEnv) -> float:    """cos(angle-from-upright): +1 exactly up, -1 hanging down. The honest 'is it up?' readout."""    return float(np.cos(wrap_angle(float(e.data.qpos[HINGE]))))

The cost function is the task. There is no learned objective anywhere: you state what "good" means — pole upright, cart centered, motion settled — and the search does the rest. Read state_cost and notice that this single, hand-written function scores both the imagined rollouts and the realized step. Writing the objective down instead of learning it is the whole trade this chapter makes.

Same loop, one idea differs: CEM vs MPPI

Here is the entire planner. Sample, roll, score — then update the sampling mean toward the good plans. The two update rules in the course's "same file, one idea differs" tradition are about fifteen lines apart:

mpc.py#plannersha256:e60204afe0…
# The one function MPC turns on. Given the current true state and a warm-started# mean plan, it samples N action sequences, rolls each through the MODEL, scores# them, and refines the sampling distribution `iters` times. CEM and MPPI differ# ONLY in how the scores update the mean (the two branches below) — everything# around them is identical. That is the "same file, one idea differs" of the course.  def rollout_cost(q0: np.ndarray, v0: np.ndarray, seq: np.ndarray) -> float:    """Total cost of one candidate action sequence, IMAGINED through the model from    (q0, v0). We REWIND the model to the shared start, then step the whole horizon,    accumulating state_cost — this is the single call MPC makes N x H times a step."""    set_state(sim, q0, v0)    total = 0.0    for action in seq:        step_model(sim, action)        total += state_cost(sim, float(action))    return total  def plan(q0: np.ndarray, v0: np.ndarray, mean: np.ndarray) -> tuple[np.ndarray, np.ndarray]:    """Return (refined mean plan, the sampled first-actions) for ONE control step.    `mean` is warm-started from last step's plan, shifted one tick — most of the    thinking carries over, so a couple of iterations is enough."""    mean = mean.copy()    std = np.full(args.horizon, args.init_std)    first_samples = None    for _ in range(args.iters):        # sample N sequences around the current mean, clipped to the actuator range        noise = rng.standard_normal((args.samples, args.horizon))        samples = np.clip(mean[None, :] + std[None, :] * noise, -1.0, 1.0)  # (N, H)        costs = np.array([rollout_cost(q0, v0, seq) for seq in samples])    # (N,) score each plan        if first_samples is None:            first_samples = samples[:, 0].copy()  # kept only to visualize the fan-out        if args.method == "cem":            # CEM: keep the ELITE fraction (the lowest-cost plans) and refit the            # Gaussian to them. The mean marches toward the elites; the std shrinks            # as they agree, so the search focuses in on the good region.            n_elite = max(1, int(args.elite_frac * args.samples))            elite = samples[np.argsort(costs)[:n_elite]]            mean = elite.mean(axis=0)            std = elite.std(axis=0) + 0.05  # floor keeps it from collapsing to zero        else:  # mppi            # MPPI: no hard cutoff. Weight EVERY sample by exp(-cost / temperature)            # (a softmax over negative cost) and take the weighted average. Good            # plans dominate smoothly; nothing is thrown away.            weights = np.exp(-(costs - costs.min()) / args.temperature)            weights = weights / weights.sum()            mean = (weights[:, None] * samples).sum(axis=0)    return mean, first_samples
  • CEM (the cross-entropy method) draws a hard line: sort by cost, keep the elite fraction — the few lowest-cost plans — and refit the Gaussian to them. The mean marches toward the elites; the std shrinks as they agree, so the search focuses in.
  • MPPI (model-predictive path integral) refuses to throw anything away. Every sample gets a weight exp(-cost / temperature) — a softmax over negative cost — and the new mean is the weighted average of all of them. Good plans dominate smoothly; the temperature sets how sharply.

They are two answers to one question — how much should a plan's score move the mean? — and at the limit they meet: CEM with a single elite keeps the one best plan, and MPPI as the temperature goes to zero collapses to that same plan. Exercise 3 makes you fill both updates in and prove they agree there.

Run it

python curriculum/phase3_advanced/ch3.9_mpc/mpc.py --seed 0
python .../mpc.py --seed 0 --method mppi

The reference numbers are from --device cpu, the configuration this book can promise is bitwise-deterministic under --seed: numpy's sampling and CPU mj_step are both deterministic, so the same seed run twice matches byte for byte. Every run also plays a no-plan baseline — uniform-random actions from the same start — so the payoff is right there in the output:

                        mean cost  upright frac
MPPI                        1.210          1.00
random (no plan)            1.988          0.00
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.05 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

upright_frac is the fraction of the settle window (the last quarter of the episode) the pole spends within about 25° of straight up. MPC swings up and holds1.00 — while random flails at 0.00, and the smooth cost sits far below the baseline. You solved an underactuated control problem with zero learning: no policy, no training, just a model and a search. And it reproduces — seeds 0, 1, 2 all swing up, for both CEM and MPPI. Open the recording to watch the upright-cos curve climb from −1 to +1:

rerun outputs/ch3.9-mpc/mpc.rrd

Break it: a plan that can't see far enough

The claim is a mechanism claim, not a magic one, so measure its edge rather than trust it. --break horizon drops the look-ahead from 25 steps to 3 — same model, same sampler, same cost, just a myopic plan:

python .../mpc.py --seed 0 --break horizon

The pole never comes up. upright_frac collapses to 0.00, seed after seed. A three-step plan cannot see that letting the pole fall further now is what buys the swing later, so it does the locally sensible thing forever and gets nowhere — exactly the greedy failure swing-up is designed to expose. --break samples (3 samples instead of 64) fails the same way for the other reason: too few tries to stumble on an energy-pumping sequence at all. Look-ahead and search width are not free knobs you can shrink; they are what make planning work.

The honest ceiling — and the trade against learning

MPC looks like a free lunch here, and it is not. It cost you two things.

It needs a model. In this chapter the model is the world — a perfect copy of the simulator — which is the best case that exists, and the reason the swing comes up so cleanly. Chapter 3.6 already showed you the other side: run a policy in an engine that only approximates the true dynamics and it degrades, because it is optimizing an imagined trajectory the real world will not follow. MPC has exactly that exposure. A plan is only as good as the model it is dreamed through; a wrong model plans confidently toward the wrong place.

It needs compute at every step. MPC does no work up front and then re-solves an optimization every single tickN × H model rollouts, times a few refinement iterations, before it can even move. That is the mirror image of a learned policy, which pays a huge one-time training cost and then acts in microseconds with no model at all. This is the real axis this chapter adds to the course: learn once and react fast, or carry a model and plan every step. Neither dominates. The frontier systems you'll meet later blend them — a learned model to plan through, a learned policy to warm-start the search — but you can't understand those hybrids until you've felt both pure ends, and this is the end the physics-engine arc was quietly building toward all along.

Read the real thing

google-deepmind/mujoco_mpc (MJPC) is this file grown up. It is an interactive controller that plans through mj_step — the same engine you plan through here — fast enough to drive humanoids and manipulators in real time. Open its mjpc/planners/: the cross_entropy planner is our CEM, the sampling (predictive sampling) and MPPI-style planners are our weighted-average update, and each task's cost is defined exactly the way state_cost is — a residual you write down, not a reward you learn. The one thing MJPC has that we don't is throughput: it rolls its candidate plans through the model across many CPU threads at once, which is the honest answer to this chapter's ceiling — MPC's price is compute-per-step, and you pay it with cores. Read our whole planner first; then read theirs and see the same idea running a hundred times faster.

Exercises

Three, in exercises/. The first (ex1_predict_planning) is the misconception, head on: predict whether a search through a model can control the cartpole with no learning at all, then run it and watch it swing up. The second (ex2_predict_break) hands you the --break horizon failure to generate yourself — predict what a myopic plan does, then measure the pole refusing to rise. The third (ex3_completion_update) blanks out both the CEM elite-refit and the MPPI weighted-average and asks you to fill them in and prove they agree at the limit — the fifteen-line diff that is the entire difference between the two methods.

What's next

You now hold both pure ends of control: a policy that learns once and reacts, and a planner that carries a model and searches every step. The physics-engine arc closes here — you opened mj_step, then used the thing you understood to plan through it. What's missing is a model you didn't get for free: real robots don't ship with a perfect simulator of themselves. The world-model chapters (3.1–3.2) learned dynamics from data and then acted in imagination; put that together with what you just built and you get the modern recipe — learn a model, then plan through it — which is where the frontier of this field actually lives.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch3.9.

    Objective tested: THE MISCONCEPTION this chapter kills. Every controller you built before this one LEARNED — BC cloned a demonstrator, PPO/SAC trained a policy over thousands of episodes. The reflex is: "to control a robot you must LEARN a policy." This chapter throws the network away and CONTROLS BY PLANNING — sampling action sequences, rolling them through the model (the engine you built in ch3.3-3.6), and acting on the best. No demonstrator, no reward to learn from, no training run.

    THE EXPERIMENT: run sampling-based MPC on cartpole SWING-UP (the pole starts hanging DOWN; the one actuator only pushes the cart), and read the upright fraction and mean cost against the no-plan random baseline the same run prints.

    PREDICT before you run: with a PERFECT model and ZERO learning, what does MPC do?

    • A) nothing useful — you cannot control a robot without LEARNING a policy first; the pole stays down, no better than the random baseline

    • B) it beats random by a little, but underactuated swing-up needs a learned policy to actually get the pole up and hold it

    • C) it SOLVES it — the pole swings up and balances (upright_frac -> 1.0) at a cost far below the random baseline, with no training at all; both CEM and MPPI

    (~3 s per method on a CPU.)

    Before you run, write one sentence: WHY can a search through a model swing the pole up when the actuator can never push the pole directly?

    Estimated learner time: 15 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  2. predict-then-run

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — predict-then-run + BREAK IT, ch3.9.

    Objective tested: the HONEST CEILING. MPC is not magic — it works because the plan looks far enough ahead, with enough tries, to find the energy-pumping swing. Take either away and it fails. This is the chapter's --break: you GENERATE the failure yourself and measure it, rather than taking the ceiling on faith.

    --break horizon drops the planning horizon from 25 steps to 3. Everything else is unchanged — same model, same sampler, same cost. The planner still optimizes; it just cannot SEE far enough. A 3-step plan cannot tell that swinging the cart the "wrong" way now (letting the pole fall further) is what builds the momentum to come up later.

    THE EXPERIMENT: run MPC normally, then with --break horizon, from the same start, and compare the upright fraction.

    PREDICT before you run: what does the too-short horizon do?

    • A) no change — MPC re-plans every step, so a short horizon just re-decides more often and still swings up

    • B) it FAILS — the pole never comes up (upright_frac -> 0.0). A myopic plan greedily reduces cost right now and never pays the short-term cost that buys the swing

    • C) it gets WORSE but still solves it eventually — upright_frac drops to ~0.5

    (Try --break samples too — 3 samples instead of 64 — for the other way to cripple the search.)

    Before you run, write one sentence: WHY is swing-up a problem where acting greedily on a short horizon is exactly wrong?

    Estimated learner time: 15 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  3. code-completion

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — code-completion, ch3.9.

    Objective tested: the ~15-line diff that IS this chapter. Sampling-based MPC is one loop — sample N action sequences, roll each through the model, score them — and the ONLY thing CEM and MPPI disagree on is how the scores update the sampling mean. Fill in both updates and feel how little separates them.

    You are given N sampled action sequences samples (shape (N, H)) and their costs costs (shape (N,), lower is better). Return the new mean plan (shape (H,)).

    • CEM — keep the ELITE fraction (the lowest-cost sequences) and set the new mean to their average. A hard cutoff: elites in, everyone else out.
    • MPPI — keep EVERYONE, but weight sequence i by exp(-(cost_i - min_cost) / temperature) (a softmax over negative cost), and set the new mean to that weighted average. A soft cutoff: good plans dominate smoothly, nothing is discarded.

    Fill in the two functions below (replace the raise NotImplementedError). Then run pytest checks.py in this directory — the checks compare your updates against the reference on fixed (samples, costs), and confirm CEM and MPPI agree when the elite fraction is tiny and the temperature is low (both then collapse to 'trust the best').

    Before you code, PREDICT: as temperature -> 0, which single sample does the MPPI mean approach? (Answer in a comment. It is the same one CEM keeps when n_elite == 1.)

    Estimated learner time: 25 minutes.

    Run it locally:

    pytest curriculum/phase3_advanced/ch3.9_mpc/exercises/suggested/checks.py -k ex3

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight frommpc.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
b7ad540c60f2492201946e38bb7756eee31c7997d7409dbbf8feac910302a9c3
#model
3aec574bb4d64a9afde68bd4a946a259938a57192b9eb3aeb709ee7c5f6d023e
#planner
e60204afe0d25361bba6bff72de47eebe1b647e7bab2409af493b8939e38cde1
#control
9c4c970b9c74047c6627ed51279362211d660aa35bbedca9ecbe4e744572231e
#report
5a0cdeaa1dd1fdfc41b64bb5e3a5b66b0c96580612c4b2988423497b92607b6f