35 min

Training ACT for real

A real training run on real SO-101 demonstrations, with measured wall-clock numbers from a 2018 laptop and an honest account of what that laptop cannot do.

Where you are. You can name every part of ACT and read its config as the specification. Now you run one, on demonstrations recorded from the arm Module 4 puts on your desk.

The number nobody checks

You type the training command. It prints its configuration and goes quiet. Somewhere in that configuration is a line reading steps: 100000, and somewhere else, in a file you looked at last week, is the fact that your dataset has 11,939 frames.

Nothing connects those two numbers for you. Not the output, not a warning, not the progress bar. The run will simply take as long as it takes, and you will pay for it in hours of laptop time or in dollars of rented GPU.

So connect them yourself, before you press return. At LeRobot’s default batch size of 8, seeing every frame once takes

11,93981,492 optimizer steps\frac{11{,}939}{8} \approx 1{,}492 \text{ optimizer steps}

so a hundred thousand steps is 67 passes over your data. LeRobot’s own hardware guide says these policies typically converge in five to ten. The default is somewhere between seven and thirteen times more training than the same project’s documentation recommends, and it is the setting you get if you type nothing.

The idea in one paragraph

Training a chunked policy is a dataloader, a normaliser, an optimiser and a decision about when to stop. The architecture, which took a whole lesson to explain, is the part you will spend the least time on. What actually determines whether you get a working policy is how you cut chunks out of episodes, whether you normalised per joint, how many passes over the data you can afford, and which checkpoint you keep. This lesson does one real run on real demonstrations, reports what it cost on a 2018 laptop, and is honest about the result, which is not the result I expected.

What one training sample is

one episode: 239 frames at 30 fps, about 8 seconds frame 0 frame 238 episode ends observation at t next 20 actions mask = 1 everywhere padded with the last action mask = 0, excluded from the loss divide the loss by the number of real steps, not by the padded length
How one training sample is cut from an episode: the observation at a single frame plus the window of actions that follows it, with a sample near the end of the episode padded and masked

Wider than the screen; scroll it sideways.

A sample is the state at frame tt paired with the next kk actions. Two details are easy to get wrong and both are silent.

Chunks must not straddle episodes. If your index runs over a flat table of frames, a window near the end of episode 12 will spill into the start of episode 13, and you will teach the model that the correct continuation of finishing a pick is starting a fresh one. Nothing crashes. The policy just develops a habit of resetting halfway through.

Chunks near the end of an episode are short. You can drop those samples, or pad them and mask the padding out of the loss. Dropping them throws away exactly the part of the task where the robot completes the job, so ACT pads: repeat the last action to fill the window, and carry a mask that says which steps were real.

Normalise per joint, or train the wrong thing

Read the ranges each joint covers across the dataset. On these 50 episodes the shoulder pan sweeps 181 units of travel and the gripper 33 - a factor of five and a half between the widest joint and the narrowest.

Now imagine an L1 loss on raw values. An error of one unit counts the same everywhere, so the shoulder, which moves five and a half times as far, contributes roughly five and a half times the loss, and gradient descent spends its capacity there. The gripper gets the leftovers. The gripper is the joint that decides whether you picked the object up.

The run

cd module-03-robot-learning/solutions
python train_act_lite.py --model act --chunk 20

Everything below was measured on a 2018 quad-core Intel laptop, CPU only, with no GPU available. The model is ACTLite from the last lesson at reduced size: width 128 instead of 512, two transformer layers instead of four, a 20-step chunk instead of 100, and no cameras. 1,079,366 parameters against real ACT’s roughly 80 million.

The split is the first five episodes held out and the remaining 45 for training: 10,742 samples, 168 optimizer steps per epoch at batch 64.

epochtrain L1held-out L1
10.43970.3288
20.31460.2905
30.28000.2773
40.25800.2648
50.24420.2523

It learns. Held-out error falls every epoch and was still falling when the run ended, which means five epochs is short for this configuration rather than convergence.

The result I did not want

Same data, same split, same 5 epochs, same batch: a plain three-layer MLP that maps the state straight to 20 actions with no transformer, no latent and no attention.

image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 1 2 3 4 5 epochs over the 45 training episodes 0.20 0.22 0.24 0.26 0.28 0.30 0.32 0.34 0.36 held-out action error (L1, normalised units) ACT-lite, 1.08M weights 96 s per epoch plain MLP, 328k weights 3 s per epoch
Held-out action error per epoch for ACT-lite and a plain MLP on the same data, labelled with what each epoch cost

The MLP wins. It reaches 0.2231 held-out against ACT-lite’s 0.2524, with a third of the parameters, in a twenty-sixth of the wall clock.

Results like this are worth publishing rather than quietly rerunning until they go away. A course that only shows the run where the sophisticated model won teaches you to expect something that will not happen to you, and the first time it does not happen you will assume you broke something.

Now the honest reading, which is four claims and none of them is “ACT is bad”.

There is nothing to attend over. Real ACT’s encoder mixes hundreds of image tokens; mine mixes two. A transformer whose entire job is relating two tokens to each other is an expensive way to write a small MLP. The architecture is built for an input this version does not have.

Five epochs is short, and the two curves are not parallel. ACT-lite’s held-out error is still dropping steeply at epoch 5 while the MLP’s is flattening. This comparison measures a fixed and small budget, not the asymptote.

Half of ACT-lite’s parameters are the CVAE encoder, which contributes gradient noise and KL pressure during training and is deleted afterwards. That is a real cost with a real benefit, and the benefit does not appear in a held-out L1 number.

Held-out action error is not success rate. Lesson 6 already showed a model with the lowest one-step error behaving worst in closed loop. Lesson 11 makes that gap the whole point. A fair verdict on ACT-lite versus this MLP requires rollouts, several seeds, and the harness from Lesson 13, and I have not run those here.

The tail of the chunk is guesswork

Break the held-out error down by position in the chunk.

image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 5 10 15 19 position in the predicted chunk (0 is the next action) 0.00 0.05 0.10 0.15 0.20 0.25 0.30 0.35 held-out action error (L1) ACT-lite plain MLP
Held-out action error against position in the predicted chunk, for both models: both are far more accurate about the next action than about the twentieth

The MLP predicts the very next action to within 0.093 and the action twenty steps later to within 0.355 - it is nearly four times worse at the far end. ACT-lite is flatter, starting at 0.178 and ending at 0.357, which is what you would expect from a model that was designed to spend capacity on the whole chunk rather than on the next step.

Put that beside Lesson 6 and something uncomfortable falls out. Executing a chunk open-loop means executing your least confident predictions last, at the exact moment you have been blind the longest. That is the argument for n_action_steps being smaller than chunk_size: predict far, commit near.

What this laptop cannot do

Everything above is state-only. Add the cameras and you add a ResNet-18 per view, and the ground shifts.

LeRobot’s own hardware guide, which calls its own numbers indicative to within about 50 percent, for five epochs on roughly 50 episodes at 640x480:

MachinePolicyWall clock
RTX 4090 or 3090act30 to 60 minutes
L4 or A10Gact1 to 2 hours
Apple Silicon Max, MPS, batch 4act6 to 14 hours
CPU onlyanything“Don’t train. Use Colab or rent a GPU.”

That last row is a direct quotation and it is correct. Read it as a boundary rather than a defeat: the laptop is genuinely good at dataset work, streaming, eval-harness development, small baselines and policy inference, and genuinely unsuited to training a visuomotor policy.

Renting, and what it costs

Prices read on 2026-08-09, and they will be wrong within months.

Hugging Face Jobs plugs into the training CLI with one flag, uploads your dataset and pushes the trained model back. A10G-small is $1.00 per hour, T4-small $0.40 and documented as ACT-only. RunPod is roughly a third of that on community capacity - an RTX 4090 at $0.34 per hour - in exchange for managing the box yourself.

At those rates the arithmetic is not frightening. A one-hour ACT run on a community 4090 is about 35 cents. Three seeds of two policies, which is the comparison Lesson 19 asks you for, lands around five to eight dollars.

Which checkpoint do you keep?

Not the one with the best rollout score. Selecting a checkpoint by trying each on the test rollouts and keeping the winner is choosing on your test set, and the number you then report is no longer an estimate of anything.

Keep a held-out split of episodes, score checkpoints on it, and let the rollouts be the measurement rather than the selector. LeRobot supports this directly with dataset.eval_split and eval_steps. The script here does the same thing by holding out whole episodes, which matters: splitting on frames rather than episodes puts neighbouring near-identical frames on both sides of the line and every number you measure becomes a lie.

Check yourself

1. Your dataset has 40,000 frames and you train at batch size 32. How many steps is 8 epochs, and what does that say about a default of 100,000?

40,000/32=1,25040{,}000 / 32 = 1{,}250 steps per epoch, so 8 epochs is 10,000 steps. The default of 100,000 is 80 epochs, eight to sixteen times the documented five to ten. Do this arithmetic before every run: it is one division, and it is the difference between a 30-minute rental and a five-hour one.

2. Why pad short chunks at the end of an episode instead of dropping those samples?

Because dropping them removes the last kk frames of every demonstration, which is where the task is completed. Your policy would then be trained mostly on approach and grasp and barely on release and retreat. Padding keeps the sample and the mask keeps the padded steps out of the loss - provided you also divide by the number of real steps rather than by the padded length, which is the bug LeRobot fixed in v0.6.0.

3. What breaks if the normalisation statistics used at inference differ from the ones used in training?

Everything, quietly. The network learned a mapping in normalised space; un-normalising with different numbers applies a different affine transform to its output. Joint commands come out shifted or scaled, the arm moves to a plausible-looking wrong pose, and nothing errors. Shipping the statistics with the checkpoint, as meta/stats.json does, is what stops this.

4. Both models predict the next action far more accurately than the twentieth. What should that change about how you deploy them?

Execute fewer actions than you predict. Predicting a long chunk gives the model a long-horizon training signal and keeps the motion coherent, but the far end of the chunk is its least confident output and is also what runs when you have been blind longest. Setting n_action_steps below chunk_size keeps the horizon and shortens the commitment, at the cost of more frequent inference.

5. In this run a plain MLP beat ACT-lite on held-out error. Name two reasons that is not evidence about ACT.

Any two of: there are no images, so the encoder has two tokens to attend over instead of hundreds, and attention over two tokens is a costly identity function; the model was shrunk to a fifth of ACT’s width and half its depth; five epochs is a fixed small budget and ACT-lite’s curve was still falling steeply while the MLP’s was flattening; and held-out action error is not success rate, which is the only measurement that decides which policy you would rather have on a robot.

6. Why must the training-split statistics exclude the validation episodes, and why must the split be by episode rather than by frame?

Statistics computed over the validation episodes leak their distribution into the normaliser, so the held-out number is measured on data the pipeline has already partly seen. Splitting by frame is far worse: consecutive frames at 30 fps are nearly identical, so a frame-level split puts near-duplicates on both sides and the held-out error mostly measures memorisation. Whole episodes on one side of the line or the other is the only split that means anything.

Do this

About forty minutes, most of it waiting.

1. Size the run before you start it. Take your own dataset - the one you recorded in Module 2, or the Hub dataset used here - and write down: total frames, batch size, steps per epoch, and the step count for 5 and for 10 epochs. Keep those four numbers next to you. Every training command you type for the rest of this course should be checked against them.

2. Fill in the holes and train. code/train_act_lite.py leaves you ChunkDataset.__getitem__ (the padding and the mask) and the inner training step. Then:

python train_act_lite.py --model act --chunk 20
python train_act_lite.py --model mlp --chunk 20

Compare against the table above. Your losses should land close to mine because the seed is fixed; your wall clock will not, and that is the point of quoting both.

3. Run the ablation and be careful reading it. Train with --chunk 1, which is ACT with the chunking removed:

python train_act_lite.py --model act --chunk 1

It reaches a much lower held-out error - 0.128 against 0.252 - and this does not mean chunking hurt. Predicting one action is an easier problem than predicting twenty, so the two numbers are not measuring the same task and cannot be compared. Write one sentence explaining that to yourself. Then write a second naming the experiment that would settle it, and notice that it requires rollouts rather than losses.

4. Point it at your own data. If you finished Module 2, run the same script with --path set to your recording directory. Same code, same format, your robot.

What you can now do

You can size a training run from your dataset in one division, cut chunks out of episodes without leaking across boundaries or discounting the ends, normalise per joint from training-split statistics, and read a loss curve for what it does and does not tell you. You can state what a CPU laptop can and cannot train, with measured numbers rather than folklore, and say what renting a GPU for this module actually costs.

What you can now do

You can size a training run from your own dataset, train a chunked policy end to end, read its loss curves, and say precisely when to stop using your laptop and rent a GPU.