30 min

Diffusion Policy: sample the action, do not summarise it

Instead of predicting one action, learn to turn noise into an action, and every roll of the dice lands on a different valid answer instead of the average of all of them.

Where you are. You know that a squared-error loss regresses the conditional mean, and that on multimodal demonstrations the mean is an action nobody took. This lesson replaces the head that summarises with one that samples.

Static, resolving

Open any image generator and ask it for a mug on a table. Watch the preview instead of the result.

The first frame is television static. The second is static with a suggestion of a horizon in it. Somewhere around the twentieth there is a mug, with a handle, sitting on a table with a shadow.

Now press go again. You get a different mug. Different handle, different table, different light. What you never get is a grey smear - which is exactly what the average of every mug in the training set would look like.

An action is a much smaller object than an image. Sixty-four timesteps of seven joint angles is 448 numbers; even a small photograph runs to tens of thousands. Nothing in the procedure cares which of the two you hand it.

The idea in one paragraph

A diffusion policy never predicts an action. It learns to remove noise from an action, and you run it as a loop: start with a block of random numbers the same shape as an action chunk, ask the network which noise is sitting on it, subtract a little of that, and repeat about a hundred times. What falls out is one sample drawn from the distribution of actions the demonstrators took in this situation - not their average. Change the random numbers you started from and you get a different valid sample. So when half your demonstrators went left and half went right, this policy goes left or right; it never goes straight into the trolley, because straight is not a place any of the demonstrations lived and the loop has nothing pulling it there.

Two objectives wearing the same architecture

The network in a diffusion policy is not exotic. It can be the same stack of linear layers you used for behaviour cloning, or the same 1D convolutional net over time, or the same transformer. What changed is the question you ask it.

Regression headDiffusion head
Inputthe observationthe observation, a noisy action chunk, and how noisy it is
Outputan action chunka guess at the noise
Calledonce per decisiononce per denoising step, so ~100 times per decision
Answeralways the same for a given observationa fresh sample each time

The forward process is arithmetic, not learning

Pick a real action chunk a0a^0 out of the dataset. Define a fixed recipe for spoiling it by a controlled amount:

ak=αˉk  a0+1αˉk  ε,εN(0,I)a^{k} = \sqrt{\bar\alpha_k}\;a^{0} + \sqrt{1 - \bar\alpha_k}\;\varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I)

Here kk runs from 0 to about 100, ε\varepsilon is a block of standard normal noise the same shape as the chunk, and αˉk\bar\alpha_k is a number that slides from nearly 1 down to nearly 0 as kk grows. In words: at k=0k=0 you have the human’s action untouched, at k=100k=100 you have pure noise, and every step in between is a known blend of the two.

The training loss is mean squared error, and that is not a contradiction

Training is three lines. Draw a real chunk from the dataset. Pick a random click kk and a random noise block ε\varepsilon, and build aka^k with the formula above. Then ask the network to name the noise:

L=E[εεθ ⁣(ak,k,o)2]\mathcal{L} = \mathbb{E}\left[\left\lVert\, \varepsilon - \varepsilon_\theta\!\left(a^{k},\, k,\, o\right) \right\rVert^{2}\right]

a real chunk what a human did, straight from the dataset mix in noise a known amount, set by the step index k the network sees the noisy chunk, the step k and the observation. Names the noise squared difference the whole loss the noise itself you drew it, so you know it the label the network is graded against
Training a diffusion policy: mix known noise into a real action chunk, ask the network to name that noise, and score it against the noise you drew

Wider than the screen; scroll it sideways.

Stop on that for a second, because the last lesson said mean squared error regresses a mean and that regressing a mean is the whole problem. This objective is mean squared error.

What changed is the label. The target is no longer the action, which had two valid values; it is ε\varepsilon, the noise you drew yourself a moment ago and therefore know exactly. Averaging over the dataset is now harmless, because the quantity being averaged is a well-defined function of inputs you fully control.

The ambiguity has not been solved so much as moved. It now lives in the random block you start sampling from, and you draw a fresh one every time you ask the policy for an action.

Sampling is a loop with a dash of noise added back

To get an action, start from pure noise aKN(0,I)a^{K} \sim \mathcal{N}(0, I) and walk kk backwards:

ak1=1αk(akβk1αˉk  εθ ⁣(ak,k,o))+βk  za^{k-1} = \frac{1}{\sqrt{\alpha_k}}\left(a^{k} - \frac{\beta_k}{\sqrt{1 - \bar\alpha_k}}\;\varepsilon_\theta\!\left(a^{k}, k, o\right)\right) + \sqrt{\beta_k}\;z

In plain terms: subtract a fraction of the noise the network claims to see, then add back a smaller dash of fresh noise zz, and repeat. The final step drops the added noise and returns the chunk.

the block goes round again, k = 100 down to 1 pure noise an action-shaped block of random numbers the network one job: guess which noise is sitting on this block subtract some a small step downhill, plus a dash of fresh noise an action after the last pass, one sample, not the mean the observation images and joint angles, unchanged
Sampling an action chunk by denoising: start from random numbers, ask the network which noise is present, subtract some, and repeat, with the observation fed in at every pass

Wider than the screen; scroll it sideways.

Watch it split

The obstacle task from the last lesson, in miniature: one observation - how far ahead the obstacle is - and one action, the sideways-and-forward step to take. Half the demonstrations swerve left, half right, nothing in between. Two models, same data, same 2,000 gradient steps.

image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ −0.5 0.0 0.5 0.2 0.3 0.4 forward part what people demonstrated −0.5 0.0 0.5 sideways part of the action every sample here what regression predicts −0.5 0.0 0.5 what diffusion samples
Demonstrated actions, the single action a regression head predicts, and eight hundred samples from a diffusion head, at one fixed observation

Snapshot the sample cloud partway through the loop and you can see the decision being made. Early on it is a fuzzy ball centred on nothing in particular. By the last twenty steps it has pulled apart into two lumps, and each individual sample has already committed.

image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ k = 80 k = 60 k = 40 k = 20 k = 0
Six hundred samples partway through denoising, starting as one cloud of noise and separating into the two demonstrated modes

What Diffusion Policy adds on top of “diffusion”

Four design choices turn “diffusion” into a policy you can run on a robot. The first two are the paper’s argument; the last two are what the reference implementations actually ship.

  • Diffuse a sequence, not a single action. The thing being denoised is a chunk of HH future actions, so the same machinery that fixes multimodality also gives you action chunking for free.
  • Condition on the observation rather than diffusing it. The image encoder runs once per decision; only the small action-denoising network runs a hundred times. Diffusing images and actions jointly would be a hundred forward passes of the image encoder per control step.
  • Receding horizon. Predict a long chunk, execute a shorter prefix of it, then re-plan from a fresh observation.
  • A separate visual encoder per camera, with a spatial-softmax layer that reduces a feature map to a handful of keypoint coordinates rather than flattening it.

The bill: one decision costs a hundred forward passes

That multiplier is the entire reason this part of the field is moving. But it is a setting, not a law, and the trade is much better than the default suggests:

Check yourself

1. The training loss is mean squared error, which the previous lesson called the source of the problem. Why is it safe here?

Because the label changed. Regressing the action is dangerous when several actions are valid, since the minimiser is their mean. Here the label is ε\varepsilon, the noise you drew yourself before corrupting the chunk, and given the noisy chunk, the step index and the observation there is a single well-defined thing to predict. The ambiguity has moved into the random block you start sampling from, which you redraw for every decision.

2. Two robots are given exactly the same observation. Why do they not necessarily do the same thing, and is that a bug?

They start their sampling loops from different random noise, so they can land in different valleys. It is not a bug; it is the point. Any policy that is guaranteed to output the same action for the same observation is a function of the observation alone, and can therefore only ever report a single summary of what the demonstrators did. Determinism is precisely what you gave up in order to stop averaging.

3. Where does the compute go at inference, and what is the one knob that changes it?

Into repeating the same forward pass. One action chunk costs num_inference_steps passes of the denoising network - 100 by default in LeRobot v0.6.1 as of August 2026. The knob is that step count. Lowering it is a direct trade of sample quality for latency, and it is the first thing to try when a diffusion policy misses its control deadline. The image encoder is not the problem: it runs once per decision, because the policy conditions on the observation rather than diffusing it.

4. Why does Diffusion Policy denoise a whole chunk of future actions rather than one action at a time?

Three reasons stack. It reuses the mechanism you already need for action chunking, so one design buys both. It keeps a sampled chunk internally consistent - the whole plan comes out of one draw, so it commits to one route rather than re-flipping the coin every timestep. And it amortises the hundred denoising passes over HH executed actions instead of paying them per action.

5. A colleague reports 65% success for Diffusion Policy on a benchmark and compares it against a number from a paper published eight months earlier. Name two reasons the comparison may be invalid.

The configuration defaults moved: LeRobot’s horizon and n_action_steps quadrupled between v0.5.1 and v0.6.x, so “the default diffusion policy” is a different policy on either side of that line. And a loss-normalisation bug affecting padded actions was fixed in v0.6.0, so training numbers straddling that release are not measuring the same objective. Either alone is enough to break the comparison. Publish the version and the config, not just the percentage.

6. In the sampling loop you subtract the predicted noise and then add a smaller amount of fresh noise back. Why add any back at all?

Because a purely downhill walk from a given starting point is deterministic, and would slide into whichever valley the starting point happened to sit nearest, with the transition points between valleys decided entirely by the initial draw. The added noise keeps the walk exploratory in the early steps, when the sample has not yet committed, and it shrinks as kk falls so the last steps settle cleanly. The final step drops it entirely.

Do this

Two runs and one experiment, around twenty minutes on CPU.

cd module-03-robot-learning/solutions
python diffusion_toy.py

It trains both heads on the obstacle task and prints the left / straight / right table above. Then open code/diffusion_toy.py, which leaves the two halves of diffusion for you to write:

  1. The forward process. Fill in q_sample(a0, k, eps) so it returns αˉka0+1αˉkε\sqrt{\bar\alpha_k}a^0 + \sqrt{1-\bar\alpha_k}\varepsilon. Sanity-check it: at k=0k=0 the output should equal the input, and at k=99k=99 its standard deviation should be close to 1 regardless of the action.
  2. The sampling loop. Fill in sample(). If you get a cloud that never separates, print the mean and standard deviation of the samples at each kk and find the step where it stops shrinking.
  3. Cut the step count. python diffusion_toy.py --sweep runs the sampler at 100, 50, 20, 10 and 5 inference steps and reports the split and the time per action at each. Find the smallest number of steps at which the two modes are still clean. That is the number you would deploy with, and it is task-specific. Note that skipping steps needs the deterministic update rather than the ancestral one; the solution shows both.
  4. Break it on purpose. Change the coin flip in actions_for so every demonstration swerves the same way, and retrain. Both heads should now land on the demonstrated action - the diffusion head has no advantage when there is nothing to disambiguate, and it still costs a hundred times more to run. Knowing when not to reach for it is worth as much as knowing how it works.

What you can now do

You can write down the forward diffusion process and explain that it involves no learning at all. You can state the training objective, and defend the apparent contradiction of using mean squared error to escape a mean-squared-error problem by pointing at the label. You can trace the sampling loop and say which line makes two demonstrators’ routes stay separate. And you can price it: one decision costs as many network passes as you set inference steps, which is why the field kept the argument and is replacing the sampler.

What you can now do

You can explain the forward and reverse diffusion processes, say why the training loss is still mean squared error and why that is not a contradiction, and account for the latency it costs you.