← All lessonsRobot arm

ACT

LeRobot’s default policy for the SO-101 is ACT, the Action Chunking Transformer. Before your own arm, it runs here on the simulated task it was first built for.

What does ACT do in one step, and how do we know a trained ACT works?

Overview

This workshop uses the real lerobot package, version 0.6.1, with its simulator gym-aloha and a GPU.

TaskTwo arms, one cube.→Chunks100 actions per look.→InsideThe network, part by part.→TrainTwo runs, the loss falls.→Evaluate200 fixed starts.

The mental model: ACT looks at the camera once and writes down the next two seconds of commands. The robot plays them, then ACT looks again.

The task

Two simulated robot arms face each other across a table, as in the ALOHA setup ACT was made for. The task, in the dataset’s own words:

Top camera at the start: both arms open, the red cube on the table between them
Step 0: the cube waits on the table.
Top camera at step 220: the left gripper holds the cube in the air
Step 220: the left arm holds it in the air.
  • Camera: one, called top, looking down, 480 × 640 pixels.
  • State: 14 numbers, 7 per arm: six joint angles in radians, then the gripper opening.
  • Action: 14 targets in the same order, sent 50 times a second.
  • Episode: at most 400 steps, 8 seconds.
  • Data: demonstrations by a person driving the simulated arms, frames in all.

Each episode places the cube at a new random spot in a 20 × 20 cm square. The episode’s seed fixes that spot, so seed 1001 always means the same cube position.

The simulator scores every step with a stage:

  • Stage 1: the right gripper touches the cube.
  • Stage 2: the right gripper has lifted it off the table.
  • Stage 3: the left gripper touches it while it is still on the table. No run on this page ever scored it.
  • Stage 4: the left gripper holds it in the air. This is success.

Why a simulator? It can replay the same 200 cube positions for every policy, exactly. With the real arm in Your SO-101, you will set out fixed positions by hand instead.

Action chunks

ACT’s output is not one action but 100: the next two seconds at 50 steps a second. LeRobot then plays all 100 before it looks at the camera again.

On start 1001 the published policy looked only times, at steps 0, 100 and 200. It finished the handover at step . Play it and predict what the command does at step 100.

Chunk timeline

Top camera at the selected step
Camera at step 0.
0

How to read the timeline:

  • Blue and magenta alternate chunk by chunk: the commands the robot actually received. When averaging, it is one blue line.
  • A dot marks a step where ACT looked at the camera and wrote a new chunk.
  • Faint grey lines (second option only) are the chunks written at every 10th step. The others are left out so the lines stay readable.
  • The dashed line is the step whose camera picture is shown.

Why chunks at all? Imitation learning showed that small mistakes add up with every decision. Three decisions leave far fewer chances to drift than 227.

The price is blindness between looks. If the cube slips at step 120, ACT notices only at step 200.

The ACT paper’s answer is temporal ensembling: look at every step, and average every prediction made so far for the current step. Older predictions weigh more:

\[a_t = \frac{\sum_i w_i\, \hat a_t^{(i)}}{\sum_i w_i}, \qquad w_i = e^{-0.01\, i}, \quad i = 0 \text{ for the oldest}\]

Action chunk
a sequence of future actions predicted together from one observation, here 100 of them.
n_action_steps
how many actions of each chunk the robot plays before ACT looks again. LeRobot’s default is 100.
Temporal ensembling
looking every step and averaging all predictions for the current step.

Inside ACT

One look turns a camera picture and 14 joint numbers into a chunk of 100 × 14 commands. The network does it in four parts:

How to read the diagram:

  • Boxes are parts of the network, with their parameter counts.
  • Labels on arrows are the tensor shapes for one frame.
  • The dashed part runs only during training.

The code below is LeRobot’s own, from modeling_act.py, shortened; the shape comments are added.

Image → 300 tokens

lerobot 0.6.1 · modeling_act.py · ACT.forward
cam_features = self.backbone(img)["feature_map"]                  # (B, 512, 15, 20)
cam_features = self.encoder_img_feat_input_proj(cam_features)     # 1×1 convolution, still (B, 512, 15, 20)
cam_features = einops.rearrange(cam_features, "b c h w -> (h w) b c")  # 300 tokens of 512 numbers

The backbone is ResNet-18, pretrained on ImageNet, as in Convolutional networks. It shrinks each side 32 times: 480 / 32 = 15 rows, 640 / 32 = 20 columns.

Each of the 15 × 20 = 300 places becomes one token. A fixed sine pattern marks each token’s place, so attention can tell where in the picture it came from.

Encoder: 302 tokens

lerobot 0.6.1 · modeling_act.py · ACT.forward
encoder_in_tokens = [self.encoder_latent_input_proj(latent_sample)]                 # z: 32 → 512
encoder_in_tokens.append(self.encoder_robot_state_input_proj(batch[OBS_STATE]))     # joints: 14 → 512
encoder_in_tokens.extend(list(cam_features))                                        # + 300 image tokens
encoder_out = self.encoder(encoder_in_tokens, pos_embed=encoder_in_pos_embed)       # 4 self-attention layers

The encoder is four layers of self-attention, as in Attention and transformers. Every token attends to every other, so the joint numbers and the whole picture are read together.

Decoder: 100 queries

lerobot 0.6.1 · modeling_act.py · ACT.forward
decoder_in = torch.zeros((self.config.chunk_size, batch_size, self.config.dim_model))   # (100, B, 512)
decoder_out = self.decoder(decoder_in, encoder_out, encoder_pos_embed=encoder_in_pos_embed,
                           decoder_pos_embed=self.decoder_pos_embed.weight.unsqueeze(1))
actions = self.action_head(decoder_out.transpose(0, 1))                                 # (B, 100, 14)

The decoder starts from 100 empty slots, one per future step. Each slot gets a learned position, decoder_pos_embed, so slot 37 knows it stands for step 37.

Each slot reads the encoder’s 302 tokens through cross-attention. The last layer, action_head, turns its 512 numbers into 14 commands.

z: training only

lerobot 0.6.1 · modeling_act.py · ACT.forward
if self.config.use_vae and ACTION in batch and self.training:
    vae_encoder_input = [cls_embed, robot_state_embed, action_embed]    # 1 + 1 + 100 tokens
    ...
    latent_sample = mu + log_sigma_x2.div(2).exp() * torch.randn_like(mu)
else:
    latent_sample = torch.zeros([batch_size, self.config.latent_dim])   # on the robot: z = 0

This is the conditional VAE from Autoencoders and VAEs. During training, a separate encoder reads the demonstration’s 100 true actions and squeezes their style into 32 numbers, z.

On the robot there are no true actions to read, so z is set to 0. That training-only encoder is a third of ACT’s parameters.

Every number

Loss

ACT is trained by imitation: its chunk should match the 100 actions the person actually sent. The loss has two terms.

act_tour.py · the loss, written outOpen source ↗
predicted, (mu, log_sigma_x2) = policy.model(batch)                 # z is sampled inside, from mu and sigma
keep = ~batch["action_is_pad"].unsqueeze(-1)                        # chunk entries past the episode's end don't count
l1 = ((batch["action"] - predicted).abs() * keep).sum() / (keep.sum() * predicted.shape[-1])
kl = (-0.5 * (1 + log_sigma_x2 - mu ** 2 - log_sigma_x2.exp())).sum(-1).mean()
torch.manual_seed(seed)
lerobot_loss, _ = policy.forward(batch)
Full file · act_tour.py · 139 lines
"""ACT, opened up: the policy LeRobot trains, run on its own simulated task (two arms hand over a cube).

Run (the lerobot environment, see README.md; the trained policies come from act_runs.sh):
    MUJOCO_GL=egl ~/.virtualenvs/lerobot/bin/python classes/ml/labs/lerobot/act_tour.py

The first run downloads the dataset (lerobot/aloha_sim_transfer_cube_human) into ~/.cache/huggingface/lerobot.
"""
from pathlib import Path

import gymnasium as gym
import gym_aloha  # noqa: F401  (registers the ALOHA tasks with gymnasium)
import numpy as np
import torch
from lerobot.configs import PreTrainedConfig
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.envs.utils import preprocess_observation
from lerobot.policies.act.modeling_act import ACTPolicy
from lerobot.policies.factory import make_pre_post_processors

REPO = "lerobot/aloha_sim_transfer_cube_human"
RUNS = Path.home() / "lerobot-runs/w4b"
PUBLISHED = RUNS / "published_migrated"                     # lerobot/act_aloha_sim_transfer_cube_human, 100,000 steps
OURS = RUNS / "train_act/checkpoints/010000/pretrained_model"  # our own run, 10,000 steps


def load(path, **overrides):
    """The policy and its two processors: normalise on the way in, un-normalise on the way out.
    `overrides` change settings that need no retraining, such as n_action_steps."""
    config = PreTrainedConfig.from_pretrained(path)
    for key, value in overrides.items():
        setattr(config, key, value)
    policy = ACTPolicy.from_pretrained(path, config=config).eval()
    pre, post = make_pre_post_processors(policy.config, pretrained_path=str(path))
    return policy, pre, post


def parts(policy):
    """Parameter count of each named part of the network."""
    counts = {}
    for name, p in policy.model.named_parameters():
        part = name.split(".")[0]
        counts[part] = counts.get(part, 0) + p.numel()
    return counts


def training_batch(policy, size=8, seed=0):
    """One batch as the training script builds it: each frame with its next 100 actions."""
    dataset = LeRobotDataset(REPO, video_backend="pyav",
                             delta_timestamps={"action": [i / 50 for i in range(policy.config.chunk_size)]})
    loader = torch.utils.data.DataLoader(dataset, batch_size=size, shuffle=True, generator=torch.Generator().manual_seed(seed))
    return next(iter(loader))


def tokens(policy, batch):
    """What the transformer encoder reads: one latent token, one joint token, and the image as a grid of tokens."""
    features = policy.model.backbone(batch["observation.images.top"])["feature_map"]   # ResNet-18's last layer
    return {"image": tuple(batch["observation.images.top"].shape), "feature_map": tuple(features.shape),
            "image_tokens": features.shape[2] * features.shape[3], "all_tokens": 2 + features.shape[2] * features.shape[3]}


def loss_by_hand(policy, batch, seed=0):
    """ACT's training loss written out, next to LeRobot's own policy.forward on the same batch."""
    policy.train()
    batch = dict(batch, **{"observation.images": [batch["observation.images.top"]]})
    torch.manual_seed(seed)
    predicted, (mu, log_sigma_x2) = policy.model(batch)                 # z is sampled inside, from mu and sigma
    keep = ~batch["action_is_pad"].unsqueeze(-1)                        # chunk entries past the episode's end don't count
    l1 = ((batch["action"] - predicted).abs() * keep).sum() / (keep.sum() * predicted.shape[-1])
    kl = (-0.5 * (1 + log_sigma_x2 - mu ** 2 - log_sigma_x2.exp())).sum(-1).mean()
    torch.manual_seed(seed)
    lerobot_loss, _ = policy.forward(batch)
    policy.eval()
    return {"l1": l1.item(), "kl": kl.item(), "loss": (l1 + policy.config.kl_weight * kl).item(), "lerobot": lerobot_loss.item()}


@torch.no_grad()
def z_effect(policy, batch, seed=0):
    """Does the style variable z change the chunk? Compare z = 0 (what the robot uses) with z drawn at random."""
    batch = dict(batch, **{"observation.images": [batch["observation.images.top"]]})
    model, gen = policy.model, torch.Generator().manual_seed(seed)
    model.eval()                                                        # eval mode: the model sets z = 0
    zero = model(batch)[0]
    shifts = []
    for _ in range(4):
        z = torch.randn(zero.shape[0], policy.config.latent_dim, generator=gen)
        swap = model.encoder_latent_input_proj.register_forward_hook(
            lambda layer, _, __: torch.nn.functional.linear(z.to(layer.weight.device), layer.weight, layer.bias))
        shifts.append((model(batch)[0] - zero).abs().mean().item())   # the same frames, a random z instead of zeros
        swap.remove()
    return {"chunk_size": zero.abs().mean().item(), "shift_random_z": float(np.mean(shifts))}


@torch.no_grad()
def rollout(policy, pre, post, seed, steps=400, keep_chunks=False):
    """One closed-loop episode in the simulator: observe, act, repeat, 50 times a second for 8 seconds."""
    env = gym.make("gym_aloha/AlohaTransferCube-v0", obs_type="pixels_agent_pos", render_mode="rgb_array", max_episode_steps=steps)
    observation, _ = env.reset(seed=seed)
    policy.reset()
    actions, rewards, chunks, frames = [], [], [], []
    for t in range(steps):
        obs = pre(preprocess_observation(observation))
        if keep_chunks and (policy.config.temporal_ensemble_coeff is not None or len(policy._action_queue) == 0):
            chunks.append((t, post(policy.predict_action_chunk(obs))[0].cpu().numpy()))   # the chunk this call writes
        action = post(policy.select_action(obs))[0].cpu().numpy()
        if t % 10 == 0:
            frames.append(observation["pixels"]["top"])
        observation, reward, terminated, truncated, info = env.step(action)
        actions.append(action)
        rewards.append(reward)
        if terminated or truncated:
            break
    env.close()
    return {"success": bool(max(rewards) == 4), "stage": int(max(rewards)), "actions": np.array(actions),
            "chunks": chunks, "frames": frames}


def cube_start(seed):
    """Where the simulator puts the cube for a given start (the same rule gym_aloha uses)."""
    return np.random.RandomState(seed).uniform([0.0, 0.4], [0.2, 0.6])


if __name__ == "__main__":
    torch.set_num_threads(8)
    policy, pre, post = load(PUBLISHED)
    for part, n in parts(policy).items():
        print(f"  {part:36s} {n:12,d}")
    print(f"total {sum(parts(policy).values()):,} parameters")

    batch = training_batch(policy)
    for key in ("observation.images.top", "observation.state", "action", "action_is_pad"):
        print(f"  batch {key:24s} {tuple(batch[key].shape)}")
    normalised = pre(dict(batch))
    print("tokens:", tokens(policy, normalised))
    print("loss:", loss_by_hand(policy, normalised))
    print("z:", z_effect(policy, normalised))

    episode = rollout(policy, pre, post, seed=1000, keep_chunks=True)
    print(f"start 1000 (cube at {cube_start(1000).round(3)}): stage {episode['stage']}, success {episode['success']}, "
          f"chunks written at steps {[t for t, _ in episode['chunks']]}")
  • l1 is the average absolute miss over all 100 × 14 commands, in normalised units. The padding flags from LeRobot data leave out entries past the episode’s end.
  • kl is the KL term from Autoencoders and VAEs: it keeps z’s distribution close to a standard normal.

The total is \(\text{L1} + 10 \times \text{KL}\). Why L1 and not the squared error? The ACT paper found that L1 models the action sequence more precisely, and LeRobot keeps it.

Does z matter after training? z_effect runs the same 8 frames twice: once with z = 0, once with a random z.

act_tour.py · does z change the chunk?Open source ↗
z = torch.randn(zero.shape[0], policy.config.latent_dim, generator=gen)
swap = model.encoder_latent_input_proj.register_forward_hook(
    lambda layer, _, __: torch.nn.functional.linear(z.to(layer.weight.device), layer.weight, layer.bias))
shifts.append((model(batch)[0] - zero).abs().mean().item())   # the same frames, a random z instead of zeros

Full file: act_tour.py, in its first card above ↑

Posterior collapse
a VAE whose latent variable ends up carrying no information, because the KL term pushed it to plain noise.

Training

We trained ACT ourselves twice with LeRobot’s training script, changing only the seed:

act_runs.sh · one training runOpen source ↗
lerobot-train --policy.type=act --dataset.repo_id=lerobot/aloha_sim_transfer_cube_human --dataset.video_backend=pyav \
  --steps=10000 --batch_size=8 --log_freq=250 --save_freq=2500 --policy.push_to_hub=false --policy.device=cuda \
  --output_dir=$2 --num_workers=8 --seed=$1
  • Optimizer: AdamW from Optimizers, learning rate 0.00001, weight decay 0.0001, gradients clipped at length 10. The rate stays constant: ACT’s settings have no schedule.
  • Batch: 8 frames, each with its chunk of 100 actions.
  • Length: 10,000 steps are 80,000 frames, 4 passes over the frames. That took minutes per run on an RTX 4060 Ti.
  • Checkpoints: a saved copy of the network every 2,500 steps.

The published policy, lerobot/act_aloha_sim_transfer_cube_human, used the same settings for steps, ten times longer.

Each checkpoint was then tested on the same 200 starts (next section). Predict before you look: will the checkpoint with the lowest loss do best?

Loss and success

Training loss, every 250 steps.
Success on 200 starts, at each checkpoint.

Is that noise? Every checkpoint faced the same 200 cube positions. And with 200 starts, a success rate near 50% is uncertain by about \(\sqrt{0.5 \times 0.5 / 200} \approx 3.5\) points, far less than these jumps.

So the loss curve cannot pick the checkpoint. Only driving the robot can, as Generalization and evaluation warned.

Evaluation

An evaluation runs the policy in a closed loop: observe, act, observe the result, act again. LeRobot’s lerobot-eval does it for 200 starts, 10 simulators at a time:

act_runs.sh · one evaluationOpen source ↗
lerobot-eval --policy.path=$policy "$@" --env.type=aloha --env.task=AlohaTransferCube-v0 --eval.n_episodes=200 \
  --eval.batch_size=10 --eval.use_async_envs=false --policy.device=cuda --seed=1000 --output_dir=$out

--seed=1000 gives the starts seeds 1000 to 1199, so every policy meets the same 200 cube positions. One episode took about seconds, 9 minutes for all 200.

The map shows each start where its cube began, marked by the stage the policy reached:

200 starts

How to read the map:

  • Position is where the cube started, in metres, in the simulator’s table coordinates.
  • Blue dots succeeded (stage 4).
  • Orange dots lifted the cube but failed the handover (stage 2).
  • Magenta dots touched the cube without lifting it (stage 1).
  • Red crosses never touched it (stage 0).

Closed-loop evaluation
running the policy on the robot or simulator, so each action changes what it sees next.
Success rate
the share of a fixed set of starts that end in success.
Checkpoint
the network’s parameters saved at one point in training.

Looking more often

One setting changes how ACT is run without retraining it: n_action_steps, how much of each chunk is played before the next look. The published policy was tested again on the same 200 starts:

act_runs.sh · the one thing variedOpen source ↗
evaluate published_migrated eval200_pub_n50 --policy.n_action_steps=50
evaluate published_migrated eval200_pub_n25 --policy.n_action_steps=25
evaluate published_migrated eval200_pub_ens --policy.n_action_steps=1 --policy.temporal_ensemble_coeff=0.01

Recap

  • gym_aloha simulates two arms; seeds 1000–1199 fix 200 cube positions, and stage 4 means the handover worked.
  • predict_action_chunk writes 100 future commands from one look; n_action_steps decides how many are played before the next look.
  • ACT.forward turns the image into 300 tokens, adds a joint token and a z token, encodes all 302, and decodes 100 actions.
  • policy.forward scores a chunk with L1 plus 10 × KL; on this task the KL term leaves z unused.
  • lerobot-train ran 10,000 AdamW steps per seed: the loss fell smoothly, and success did not follow it.
  • lerobot-eval measured each checkpoint on the same 200 starts: the only evidence that a policy works.

Next, your own SO-101: record demonstrations, train this same ACT, and measure it on fixed object positions.

Key concepts

ACT
Action Chunking Transformer: a camera picture and joints in, a chunk of future actions out.
Action chunk
Future actions predicted together from one observation.
n_action_steps
How much of a chunk is played before the next look.
Temporal ensembling
Look every step; average all predictions for the current step.
Token
One 512-number input to the transformer: an image place, the joints, or z.
Posterior collapse
A VAE latent that ends up carrying nothing.
Closed-loop evaluation
Running the policy so its actions change what it sees next.
Checkpoint
Parameters saved during training.