Imitation learning for a robot arm, from scratch. A scripted expert shows a 6-DoF arm how to pick up a cube and put it on a pad, a neural network watches, and then the network does it alone on layouts it has never seen. No LeRobot, no robomimic, no pretrained anything. Just MuJoCo, PyTorch and a very patient expert.
The learned ACT-lite policy (not the scripted expert) on the first 5 evaluation layouts, back to back, no cherry-picking: these are seeds 100000-100004, never seen in training. 5/5 placed. Played at 1.5x.
- A pick-and-place task in MuJoCo on the ARMageddon arm with a real two-finger gripper: random cube position and yaw, random target pad, contact physics, no teleporting.
- A scripted expert (state machine + ARMageddon IK on privileged state) that succeeds on 100/100 evaluation layouts, and on 98/100 with 0.02 rad of correlated noise shoved into every joint command.
- DART noise injection while recording demos, so the dataset contains recoveries, not just the happy path.
- Three policies from scratch in PyTorch: BC-MLP, Chunk-MLP, and ACT-lite (transformer + action chunks + temporal ensembling).
- 38 trained-and-evaluated configs, 100 unseen layouts each, with 95% confidence intervals. Headline: with 200 demos, ACT-lite places the cube 98% of the time, Chunk-MLP 100%, plain BC 82%.
- The honest twist: the transformer is not the hero. Chunking is. An MLP that predicts 10 actions matches the transformer everywhere, and the transformer predicting 1 action is the worst thing in the repo (3%).
- 21 tests, and every number in the results tables comes straight from
results/results.json.
Teaching a robot by writing code for every situation does not scale. Teaching it with reinforcement learning means letting it flail for a few million episodes. Imitation learning is the third option: show it how it's done, a few dozen times, and let a network copy the vibe.
The setup here is the classic one from modern robot learning papers, scaled down to fit on a laptop:
- A task: pick a cube from a random spot (random yaw too) and place it on a randomly placed target pad. ARMageddon arm (project 1), parallel gripper, real contact physics.
- An expert that cheats: it reads the true cube pose straight from the simulator and solves IK with ARMageddon. It's basically a teacher with the answer key.
- Demonstrations recorded from the expert, with DART noise so the dataset also contains "oops, I drifted, here's how to get back".
- Policies trained on those demos with plain supervised learning: a BC MLP, an MLP with action chunks, and a small ACT-style transformer with action chunks and temporal ensembling.
- Evaluation on 100 unseen layouts per config, with confidence intervals, because "it worked when I tried it" is not a result.
Every tick (20 Hz) the policy sees a 20-dim observation
joint angles, joint velocities, gripper half-gap, cube position, cube yaw, pad position. The yaw goes in as
It outputs a 7-dim action
One trick that matters more than any architecture choice below: the network predicts the targets relative to the current joints, results.json).
Success = cube resting on the pad (center within 4.5 cm) and the gripper open. Max 10 s per episode.
A finite state machine (approach -> descend -> grasp -> lift -> carry -> lower -> release -> retreat) on privileged state. The key detail: it's a feedback controller, not a replayed trajectory. Every tick it reads the measured joints, takes a bounded Cartesian step toward the current phase's goal
(orientation gets the same treatment on SO(3) via the log map), and solves 6D IK from the current joints. Lost the cube mid-carry? Back to approach. Cube landed off the pad? Go get it again. That's what makes DART work: the teacher has to know how to recover from states it would never get into by itself.
Given demos
The catch is compounding error (Ross et al., 2011). The policy is trained on the expert's state distribution, but at test time it visits its own. A small mistake puts it in a slightly unfamiliar state, where it makes a bigger mistake, and so on. With a per-step error rate
DART (Laskey et al., 2017) fixes the distribution mismatch on the data side. While recording, the robot executes a perturbed action, but the dataset stores the clean one:
So the robot actually drifts off the nominal path, and every drifted state comes labeled with "here's how the expert would correct". Here
because white noise at 20 Hz just gets low-passed away by the servos and teaches nothing. Each episode draws its own
Instead of one action, predict the next
This is the main idea of ACT (Zhao et al., 2023). Predicting a chunk cuts the effective decision horizon by
Temporal ensembling. We still query every tick, so at time
where
The model, "ACT-lite". Observation split into 3 tokens (proprio, cube, pad), a 2-layer transformer encoder over them, then a 2-layer decoder with
L1 loss on standardized actions (separately per future step
Performance side quest: on a laptop RTX 3050 Ti under Windows the transformer step took ~28 ms, and a model 4x smaller was exactly as slow. Pure kernel-launch latency. Fixes: the whole train step (forward, backward, grad clipping, AdamW) is captured into a CUDA graph and replayed, attention is forced onto the plain-matmul SDPA backend (the fused kernels tile for 64+ tokens and we have 3 and 10), and TF32 is on. BC trains 20k steps in ~10 s, ACT-lite in ~2-3 min.
All numbers: success out of 100 rollouts on unseen layouts (seeds 100000-100099; demos use seeds below 60000), 95% Wilson interval in brackets. One training seed per cell unless stated. Measured, not vibes.
| policy | 10 demos | 25 demos | 50 demos | 100 demos | 200 demos |
|---|---|---|---|---|---|
| BC-MLP | 1% [0, 5] | 20% [13, 29] | 33% [25, 43] | 68% [58, 76] | 82% [73, 88] |
| Chunk-MLP (k=10) | 2% [1, 7] | 15% [9, 23] | 50% [40, 60] | 85% [77, 91] | 100% [96, 100] |
| ACT-lite (k=10) | 4% [2, 10] | 18% [12, 27] | 52% [42, 62] | 85% [77, 91] | 98% [93, 99] |
| scripted expert | 100% |
Training seeds at 50 demos (seeds 0 / 1 / 2): BC-MLP 33 / 34 / 24, Chunk-MLP 50 / 49 / 48, ACT-lite 52 / 46 / 47. So the BC vs. chunking gap (~30% vs. ~48%) is real, while Chunk-MLP vs. ACT-lite is a coin flip.
The same policies trained on clean demos (no noise while recording), next to the DART ones:
| policy | 10 | 25 | 50 | 100 | 200 |
|---|---|---|---|---|---|
| BC-MLP, clean demos | 3% | 5% | 18% | 35% | 86% |
| BC-MLP, DART demos | 1% | 20% | 33% | 68% | 82% |
| ACT-lite, clean demos | 2% | 18% | 52% | 77% | 89% |
| ACT-lite, DART demos | 4% | 18% | 52% | 85% | 98% |
| ensemble (default) | latest chunk, first action | open loop (replan every k) | |
|---|---|---|---|
| Chunk-MLP | 50% | 41% | 35% |
| ACT-lite | 52% | 46% | 55% |
| ACT-lite chunk size k | 1 | 5 | 10 | 20 |
|---|---|---|---|---|
| success | 3% | 48% | 52% | 42% |
1. Chunking is the thing, not the transformer. Going from 1 to 10 predicted actions is worth +17 to +19 points at 50 demos, +17 at 100 and +16 to +18 at 200. The transformer itself buys nothing over an MLP with the same chunked output: 50 vs. 52, 85 vs. 85, 100 vs. 98, all inside each other's error bars. And the transformer without chunks (k=1) collapses to 3%, way below the plain MLP's 33%. Small low-dim problem, small data: the MLP is just the better tool here, and I'm not going to pretend otherwise because transformers are cool.
2. Why does plain BC plateau? Because it can't see the clock. scripts/failure_modes.py sorts failed rollouts by what went wrong. At 200 demos all 18 of BC's failures are the same thing: it closes the gripper on the cube and then just sits there, forever. The expert pauses for 8 ticks (0.4 s) while the fingers close, then lifts. That pause is driven by a timer, and the timer isn't in the observation. Once the fingers have settled, "still waiting" and "time to lift" look identical, so a policy that maps one observation to one action learns "keep holding" and freezes in a fixed point. A chunked policy sees the lift coming 10 ticks ahead and sails through. This is exactly the failure the ACT paper uses to motivate chunking (pauses and non-Markovian behaviour in human demos), reproduced on a laptop. ACT-lite at 200 demos fails twice: once it never gets a grip, once it freezes at the same pause.
3. At 50 demos, everyone's problem is precision. For Chunk-MLP and ACT-lite about 40 of the ~50 failures are "never got a grip": the fingers arrive a centimetre off and the cube squirts out. That's not enough coverage of the workspace, and it's what more demos fix (85% at 100, 98-100% at 200). BC at 50 demos fails in every way possible, including dropping the cube off the pad (20 cases).
4. DART helps a lot in the middle, not at the ends. BC goes 18% -> 33% at 50 demos and 35% -> 68% at 100 with DART demos, ACT-lite 77% -> 85% at 100 and 89% -> 98% at 200. With 10 demos nothing works anyway, and BC at 200 is a draw (86% clean vs. 82% DART, well inside the noise). One caveat: DART demos are longer, since recoveries take time (48.9 vs. 39.0 ticks on average), so at equal demo count they also contain ~25% more samples. Part of the gain could be just more data. A sample-matched comparison is on the roadmap.
5. Temporal ensembling is nice, not magic. For Chunk-MLP it's the best way to execute (50% vs. 41% / 35%). For ACT-lite all three modes are within noise (46-55%). Either way, how you execute a chunk matters much less than having chunks at all.
6. Chunk size has a sweet spot. k=5 and k=10 are about the same; k=20 (1 s of motion) starts to hurt, since a longer chunk is a longer stretch of predicting the future without looking at it.
Caveats, because I'm a scientist (lol). One training seed per cell outside the seed study, so 5 points between neighbouring cells means nothing. Training uses CUDA graphs and TF32, which are not bitwise deterministic, so retraining a cell can land a few points away; the 3-seed spread at 50 demos is the realistic error bar. failure_modes.py re-runs the rollouts in a different process, so its success counts can differ by 1 from the table (CPU float math depends on the thread count, and contact physics amplifies the difference).
pip install -r requirements.txt # installs ARMageddon from GitHub toopython -m pytestpython scripts/collect.py --n 200 --noise 0.02 --out data/demos_dart.npzpython scripts/collect.py --n 200 --noise 0.0 --seed0 50000 --out data/demos_clean.npzpython scripts/run_experiments.pypython scripts/plot_results.pypython scripts/render_gif.py --ckpt checkpoints/act_dart_200_s0.ptThe ACT-lite checkpoint behind the GIF is committed, so render_gif.py works right after cloning, no training needed. run_experiments.py is resumable: it appends to results/results.json and skips configs that are already there. Delete the file to start over. A CUDA GPU is used for training if available; evaluation runs on CPU.
copycat/
env.py # MuJoCo scene (built with armageddon.mjcf), reset/step, observation, success check
expert.py # scripted expert: state machine + ARMageddon IK, recovers from perturbations
data.py # DART collection (multiprocessing), .npz storage, chunk targets
policies.py # BC-MLP, Chunk-MLP, ACT-lite transformer, temporal ensembling executor
train.py # L1 regression training loop, checkpoint save/load
evaluate.py # batched lockstep rollouts, Wilson confidence intervals
scripts/
collect.py # expert demos -> data/*.npz
run_experiments.py # trains + evaluates all 38 configs, resumable
plot_results.py # assets/results.png + the tables above
failure_modes.py # sorts failed rollouts by what went wrong
render_gif.py # assets/demo.gif from a checkpoint
tests/ # env determinism, expert success, dataset shapes, policies, training smoke test
results/ # results.json (every config), failure_modes.json
checkpoints/ # only act_dart_200_s0.pt (the GIF policy, ~1 MB) is committed
assets/ # demo.gif, results.png
This is project 3 of a from-scratch physical-AI series:
- ARMageddon: inverse kinematics for a 6-DoF arm, pure NumPy. The arm and the IK used here.
- YOINK: vision-based cube sorting with an RGB-D camera and a classical perception pipeline.
- COPYCAT (you are here): imitation learning, BC vs. action chunking, DART.
Next for this repo:
- Pixels instead of privileged state: a small CNN encoder on a MuJoCo camera, so the policy finds the cube itself.
- Full yaw range: right now the cube spawns within 40 degrees of facing the robot so the expert's grasp choice is unimodal. Full rotation makes the expert's choice jump between equivalent grasps, which is exactly the multimodality the ACT CVAE (or a diffusion policy) is for.
- DAgger as an upper bound for DART: query the expert on the learner's own states.
- Sample-matched DART ablation: compare at equal numbers of samples, not just equal numbers of demos.
- Diffusion Policy as a third contender.
- Multi-cube tasks, so the policy has to plan an order.
Why not just use LeRobot / robomimic?
Because then I'd learn how to call train.py. The point of this series is to build each piece myself and understand why it works (or doesn't). The whole library is about 700 lines.
Is this "real" ACT? It's ACT-lite. Same core ideas (action chunks, learned queries in a transformer decoder, temporal ensembling with exponential weights), minus the CVAE and the ResNet camera backbone. The expert is deterministic and the observations are low-dim, so neither would earn its keep here. And as the results show, on this task even the transformer part is optional.
The expert cheats with the true cube pose. Isn't that unfair? That's the standard setup: the expert is allowed privileged information, the policy learns from its demos. Here the policy also gets the cube pose, so it's "low-dim imitation". The pixels version, where the policy has to find the cube in a camera image, is next on the roadmap.
Why are actions joint targets and not end-effector poses?
It's what ACT does on real robots, and it means the policy has to learn IK implicitly, which is more interesting than learning where to put the hand. The relative parameterization (predict
Why does the cube only spawn within 40 degrees of facing the robot? A cube is symmetric every 90 degrees, so there are always several equally good grasps. If the expert's choice between them flips across the workspace, the demos become multimodal and any L1/L2 regressor averages two grasps into a garbage one in between. Restricting the yaw keeps the expert's choice unique, so the comparison is about compounding error, not multimodality. Multimodal grasps are the next experiment (that's what the CVAE in ACT, or diffusion policies, are for).
Can it run without a GPU? Yes. Everything works on CPU. Training ACT-lite on CPU is slow, the MLPs are fine. Evaluation always runs on CPU.
Why the name? Because it copies whatever it sees, it's small, and it occasionally knocks things off the table.
MIT, see LICENSE.

