Skip to content

ACT

BetaWorks, but under active development — APIs and results may shift. Gated against the reference implementation and against LeRobot's training. Runs closed loop on the arm at 30 Hz; no real-arm success rate has been measured.

ACT predicts a chunk of future actions — two seconds of them — from the current joint positions and camera images, in one forward pass. At inference the overlapping chunks from successive queries are blended by a temporal ensemble. Predicting a chunk instead of one step reduces the number of decisions per task, which reduces how often a small error can compound.

It is the image policy the SO-101 pipeline is built around: recorded with the leader arm, imported from LeRobot v3, trained on a GPU, deployed on the arm or on a Jetson.

A CVAE. During training an encoder reads the recorded action chunk and the joint state and produces a latent z; the decoder predicts the chunk from z and the observation. A KL term pulls z toward a unit Gaussian. At inference z is set to zero — the prior’s mean. The latent exists to absorb the variability between human demonstrations, which is why the paper finds it matters for human-collected data.

A DETR-style transformer. The decoder is an encoder–decoder transformer: the encoder attends over image feature tokens, the joint-state token and the latent token; the decoder turns K learned queries into K actions (see the departure from the reference for what those queries are made of). The attention is noeira/nn/primitives/cross_attention.mojo, which carries what DETR needs and plain self-attention cannot express: query and key lengths that differ, and positional embeddings added to queries and keys but not values.

A ResNet18 backbone with frozen BatchNorm. Each camera image goes through one shared ResNet18 (noeira/nn/models/resnet18.mojo). By default the backbone starts from ImageNet weights, fetched from the Hub with no PyTorch, and its BatchNorm statistics are frozen — both references do this, and trainable BatchNorm replaces the ImageNet statistics within a few hundred steps.

2 cameras240 × 320 eachResNet18shared · frozen BNjoints1 state tokenz= 0 at inferenceCVAE encodertraining only · reads the recorded chunkz ~ q(z | chunk, joints)encoder4 layersdecoder1 layerK / VK querieslearneda_t … a_t+K−1K = 60 · 2 s
observationlatentnetworksaction chunktraining only / learned queries
training lossL = L1(chunk, predicted chunk) + β · KL( q(z | chunk, joints) ‖ N(0, I) ), β = 10

Chunking and the temporal ensemble. The policy is queried every control step. Each query returns K future actions; the command for step t is a weighted average of every chunk that covers t, with weight exp(-m · i), where i counts from the oldest chunk covering t — the oldest prediction weighs most — and m = ACT_TEMPORAL_ENSEMBLE_M = 0.01 (noeira/deep_agents/act/inference.mojo).

weightquery t−3exp(0)query t−2exp(−m)query t−1exp(−2m)query texp(−3m)t−3t−2t−1tt+1t+2t+3t+4t+5t+6command at tweighted mean of 4 chunksThe oldest chunk weighs most.With m = 0.01 the spread is gentle:1.0 down to 0.55 across K = 60.
predictions for step tthe rest of each chunkexecuted command
temporal ensemblea_t = Σᵢ wᵢ Aᵢ[t] / Σᵢ wᵢ, wᵢ = exp(−m · i), i = 0 for the oldest chunk covering t, m = 0.01
  • Only the first decoder layer matters. The reference returns the stack of intermediate decoder outputs and takes index [0], so layers 2–7 of its published dec_layers = 7 model never affect the output. The port uses one decoder layer, which is output-equivalent and seven times cheaper.
  • The CVAE latent collapses. At the paper’s KL weight of 10 the KL term falls to ~0 within a few epochs and z carries no information. LeRobot’s ACT shows the same collapse on the same data, so it is faithful — but it means the model runs as a plain chunk predictor. Lowering the weight does not help: on LIBERO the KL trajectories at weights 0.1 and 10 agree to three digits. The decoder ignores z from the start, so the KL term is the encoder head’s only gradient, and Adam’s per-parameter step normalisation cancels its weight.

The reference feeds the decoder zeros as its target tokens and adds the learned queries only as positional embeddings to the attention’s queries and keys. With zero targets, the only route from “which chunk position” to the output is the cross-attention weights. On LIBERO every fit built that way — per task, multi-task, 150k steps, other KL weights, an extra loss on the chunk’s shape — converged to one action for all K positions: the policy drove with the chunk’s average, and scored at most 0.155. The per-position part of the output is a small residual, and the L1 fit removes it while it fits the per-state mean.

The port’s decoder targets are a second learned embedding per position (content queries, as in conditional-DETR-style decoders), so the position rides the residual stream and a different action per position is easy to express. On the same data this took LIBERO from 0.155 to 0.75, and the predicted chunks vary across positions as much as the recorded ones. examples/libero/libero_act_inspect.mojo reads any checkpoint’s chunk position by position; a spread near zero is the flat failure.

The reference gates still hold: loading a reference dump fills the content queries with zeros, which is the reference’s decoder exactly. Checkpoints saved before this change (21 Sep 2026) do not load — they lack the extra parameter — and need a refit. The query embeddings are also drawn from N(0, 1), as nn.Embedding draws them; an earlier Kaiming draw at std 0.09 was a porting bug.

The run dimensions live in one place, the RUN_* block of noeira/deep_agents/act/config.mojo, because they are the checkpoint’s parameter shapes and every script that loads a checkpoint must agree with them.

herepaper
chunk K60100
hidden dim256512
feedforward10243200
encoder / decoder layers4 / 14 / 7 (output-equivalent)
heads / latent8 / 328 / 32
batch168
images2 × 240×3204 × 480×640

K = 60 is the paper’s horizon, not a reduction: ALOHA chunks 100 steps at 50 Hz, which is two seconds; the SO-101 records at 30 fps, so two seconds is 60.

On the GPU trainer, batches are drawn and normalised on the device: the whole image column is uploaded once as uint8 and a device sampler gathers and converts each batch (noeira/deep_agents/act/data_gpu.mojo, the GPU_DATA switch in the training script). On an RTX 5090 this took an iteration from 144.8 ms to 61.6 ms. The cost is memory — the 50-episode store is 7.1 GB of images — and a different random stream from the host sampler, so a run with it on is not step-for-step comparable with one without.

Terminal window
# 1. import a LeRobot v3 recording (pure Mojo; needs curl and ffmpeg)
pixi run -e nvidia mojo run -I . examples/so101/act_so101_import_dataset.mojo \
--repo DenisLabs/record-test_20260828_092736 --height 240 --width 320
export ACT_STORE=~/.cache/noeira/act_so101/DenisLabs__record-test_20260828_092736_240x320.h5
# 2. train on the GPU (the real run; ~100k steps)
pixi run -e nvidia mojo build -I . -o /tmp/act_train_gpu \
examples/so101/act_so101_train_gpu.mojo
/tmp/act_train_gpu
# 3. open-loop evaluation on held-out episodes
pixi run mojo build -I . -Xlinker -ld_classic -o /tmp/act_eval \
examples/so101/act_so101_openloop_eval.mojo && /tmp/act_eval

examples/so101/act_so101_train_cpu.mojo is the same model on the CPU, useful as a smoke run. A project’s recording can come from the platform instead of the Hub — pixi run dataset-pull -- --project P --dataset D, then act_so101_import_dataset.mojo --project P --dataset D; see Projects.

The trainer writes runs/<id>/checkpoints/best.ckpt whenever validation improves and last.ckpt at every validation, with norm.json beside them. Validation is deterministic — the sampler’s RNG is pinned around each pass — so successive points on the curve score the same batches. Training stops after ACT_PATIENCE validations without improvement (10 by default).

All are optional and read at run time, so none needs a rebuild.

VariableRead byMeaning
ACT_STOREtrain, deploy, LIBERO trainthe .h5 store to train on
ACT_PROJECTtrainthe project the run is filed under (so101 on the arm, libero on LIBERO)
ACT_PRETRAINEDtrainbackbone init: unset or hub = ImageNet from the Hub; a directory = a torchvision dump; random = from scratch
ACT_NO_FREEZE_BNtrainleave BatchNorm trainable — an ablation
ACT_STEPStrainoptimizer steps
ACT_NO_MONITORtrainkeep a smoke run off the metrics dashboard
ACT_CKPTopen-loop evalthe checkpoint to evaluate
ACT_LR · ACT_KL · ACT_SHAPE · ACT_PATIENCELIBERO trainlearning rate, KL weight, chunk-shape term weight, early-stop patience (0 disables it)

The inference device of the deployment is a build-time choice, -DACT_GPU=1.

A checkpoint does not carry its normalisation statistics, and the training store is several gigabytes. The trainer therefore writes norm.json beside the checkpoints: the joint and action means and spreads, the demonstrated action and joint ranges, and the camera slot order (noeira/deep_agents/act/norm_file.mojo). pixi run project-promote carries it along as policies/<role>.norm.json. For a run that predates the file, examples/so101/act_so101_export_norm.mojo writes it from the store in seconds.

Terminal window
# dry run: reads the arm and cameras, prints what it would command
pixi run soarm-act-deploy -- --project so101-tower --role act --devices 0,1
# moves the follower — be at the desk
pixi run soarm-act-deploy -- --project so101-tower --role act --devices 0,1 --arm --seconds 30
# on the Jetson, inference on the Orin's GPU
pixi run -e jetson act-deploy-jetson -- --project so101-tower \
--devices /dev/soarm_cam_overhead,/dev/soarm_cam_wrist

Before arming, the deployment prints the follower’s pose against the demonstrated joint range and the camera slot mapping. While running, it clamps each command to the demonstrated action range (plus a 5 % margin) and counts how often that fires. On shutdown it ramps back to the starting pose rather than releasing torque where the arm stands. --store is optional; given, it replays a held-out episode through the policy before arming. See Deployment and Jetson.

The loop runs on a 30 Hz grid defined by wall clock. A query’s chunk enters the ensemble at the step its observation was captured, and the command is the ensemble’s action for the current step, so inference latency shifts which actions are used instead of slowing the motion.

examples/libero/libero_act_train.mojo is the same loop on LIBERO’s demonstrations: 28 proprio words (9 joints, their one-step difference and a task one-hot), 7 OSC_POSE action words, two 128×128 cameras and K = 40 (two seconds at 20 Hz), from a store re-rendered through noeira’s own cameras.

Terminal window
pixi run -e nvidia libero-demo-rerender
ACT_STORE=build/demos/libero_goal.rendered.h5 pixi run -e nvidia libero-act-train
pixi run -e nvidia libero-eval-batched -- --act runs/<id>/checkpoints

libero-eval-batched runs LIBERO’s own protocol on the frozen initial states. Its default ACT inference is the temporal ensemble; --act-exec N executes N steps of each chunk instead, and --act-ckpt last evaluates last.ckpt. --knn runs the vision-free nearest-neighbour control. See LIBERO.

MeasurementResult
LeRobot parity: stock LeRobot ACT on the same 50 episodes, same 40/10 splitours: val L1 0.3941 at epoch 7.7 (≈0.414 in LeRobot’s units, which divide by valid slots only); LeRobot: best 0.3726, plateau 0.3791
Closed loop on a Jetson Orin NX (CUDA)30.0 Hz — cameras 2.2 + preprocess 3.4 + forward 27.6 = 33.2 ms per query
LIBERO libero_goal, 200 episodes, temporal ensemble, 3 seeds0.75 ± 0.05 (0.81, 0.705, 0.735)
LIBERO controls: nearest demonstration over the same joint words, no camera0.795 (k=1), 0.72 (k=5)

How to read them:

  • Parity. LeRobot reached its number with 52M parameters, 480×640 images and chunk 100 against ~11M, 240×320 and 60, and both implementations show the same ~5–6× train/validation gap. The shared plateau near 0.38 is the data’s ceiling at 50 episodes; more episodes is the lever, not more code. The script is tools/act/lerobot_act_baseline.sh.
  • The Orin number is latency, not success. It means the policy is queried at the rate it was trained at. No success rate on the real arm has been measured yet.
  • LIBERO. The policy sits between the two vision-free controls, which match each state against the demonstrations using joints alone. In libero_goal every task shares one scene and objects move about a centimetre, so the cameras add little the joints do not already carry. The seed spread (sd 0.054) is larger than the error of one 200-episode eval (0.031), so a single seed is not a rate: ACT_SEED sets it. These rates come from noeira’s LIBERO port and are not comparable to LIBERO’s published tables.
Terminal window
pixi run -e act-ref python tools/act/dump_act_reference.py --out /tmp/act_ref
pixi run test-act # CPU, against the reference DETRVAE
pixi run -e apple test-act-gpu # GPU against CPU

The CPU model is compared against the reference layer by layer and end to end; the GPU model is compared against the CPU model. The PyTorch environment (-e act-ref) is for these gates only — nothing on the training or inference path imports it.