roostField / Lab
Curriculum
Phase 03Lesson 3 of 6
80 min
Learn from demonstrationsWeeks 7–10

ACT: action chunking with transformers

ACT is built around a constraint you already optimize for a living: one forward pass should buy more than one output. Predicting a 50-action chunk attacks compounding error and amortizes inference latency at once — and opens the reactivity-versus-smoothness tradeoff your capstone studies.

After this lesson you can
  • Derive how a k-step chunk divides the compounding-error bound by k and cuts inference duty cycle from 50% to 1% on a 50 Hz WidowX loop.
  • Rebuild ACT at implementation level: ResNet18 tokens per camera, proprioception and latent tokens, a 4-layer encoder, and a decoder with k chunk queries.
  • Explain what the CVAE latent buys, predict the failure mode on each side of the KL weight, and state what the paper's ablation shows when you remove it.
  • Detect chunk-boundary pauses and ignored mid-chunk perturbations in logged episodes, and quantify the reaction lag that temporal ensembling adds.

Behavioral cloning left you with a diagnosis: a policy that predicts one action per observation compounds its own errors, each small mistake dragging the next observation further off the training distribution. ACT (opens in a new tab) — Action Chunking with Transformers, the policy behind the ALOHA bimanual rig — is the first fix you will train on your own hardware, and its core move should feel familiar: the same instinct as multi-token prediction and speculative decoding, make one expensive forward pass emit many outputs. What is new is what that buys on a robot. Chunking kk future actions does not just amortize inference latency; it changes the error-compounding math itself, because the policy makes kk times fewer decisions per episode.

Foundations: Defining the Compounding Error Model

Before deriving the benefit of action chunking, distinguish an error probability from an error magnitude. Consider an episode of TT control steps with per-step task cost bounded between 00 and cc. On states drawn from the expert's trajectory distribution, let the learned policy disagree with the expert with probability at most ε\varepsilon at each independent policy decision. Behavioral-cloning validation estimates this one-decision quantity; it does not promise that the same bound remains true after the learner has driven into states the expert never visited.

A wrong decision at step ii can move the robot off the expert distribution and, in the worst case, add cost for every one of the remaining TiT-i steps. A union-bound accounting therefore multiplies two factors: the number of opportunities to err and the number of future steps each error can poison. This is a deliberately coarse bound on bounded task cost. It is not a sum of squared state distances; mixing that different cost model into this argument would change the power of TT and produce a contradiction.

J(π)J(π)    cεd=0D1(Tdk)    cεDT  =  cεT2k,D=TkJ(\pi)-J(\pi^*) \;\le\; c\,\varepsilon \sum_{d=0}^{D-1}(T-dk) \;\le\; c\,\varepsilon\,D\,T \;=\; \frac{c\,\varepsilon\,T^2}{k}, \qquad D=\frac{T}{k}
There are D policy decisions. Decision d can affect at most the T-dk steps that remain, giving the familiar worst-case compounding bound.

For single-step control, k=1k=1 and D=TD=T, so the sum is proportional to εT2\varepsilon T^2. With chunks of kk actions, the policy is queried only D=T/kD=T/k times, and the same worst-case accounting becomes proportional to εT2/k\varepsilon T^2/k. Chunking removes decision points; it does not make the individual actions more accurate, and an error inside a committed chunk can still persist until the next observation and replan. That is why the benefit comes with the staleness cost quantified below.

Worked Example: Quantifying the Error Reduction

Consider a WidowX arm executing a 20-second task at 50 Hz. The total horizon is T=1000T = 1000 steps. We compare single-step BC (k=1k=1) with ACT (k=50k=50). Assume the policy deviates from the expert with probability ε=0.01\varepsilon = 0.01 per decision, and each deviation causes a state error of magnitude 1.0. The Lipschitz constant is L=1L=1 for simplicity.

MetricSingle-Step BC (k=1k=1)ACT (k=50k=50)
Number of Decisions (DD)100020
Expected Deviations (DεD \cdot \varepsilon)100.2
Error Bound Scaling (T2/kT^2/k)106/1=10610^6 / 1 = 10^6106/50=20,00010^6 / 50 = 20,000
Relative Error Reduction1x50x
Comparison of error bounds for single-step BC and ACT

The table shows that ACT reduces the error bound by a factor of 50. This is a significant improvement, but it is not a free lunch. The price is staleness. The last action in a chunk of size k=50k=50 is based on an observation that is 50 steps old. At 50 Hz, this is 1 second of staleness. If the object moves at 0.2 m/s, the object may have moved 20 cm by the time the last action is executed. This is the fundamental tradeoff: chunking reduces compounding error but increases staleness.

Checkpoint 01

Why does action chunking reduce the compounding error bound by a factor of kk?

Shorten the horizon: one decision per k actions

Recall the compounding-error argument from the BC lesson (treated carefully in Underactuated's imitation chapter (opens in a new tab)): if the cloned policy deviates from the expert with probability ε\varepsilon per decision, and each deviation can poison every remaining step of a TT-step episode, the performance gap grows like εT2\varepsilon T^2. The quadratic comes from counting decisions — each of TT decisions is a fresh chance to fall off-distribution, and a fall costs up to TT steps. Chunking attacks the count directly: committing to kk actions per query leaves only D=T/kD = T/k decisions per episode.

J(π)J(π)    cεd=0D1(Tdk)    cεDT  =  cεT2k,D=TkJ(\pi) - J(\pi^\ast) \;\le\; c\,\varepsilon \sum_{d=0}^{D-1} \bigl(T - d\,k\bigr) \;\le\; c\,\varepsilon\, D\, T \;=\; \frac{c\,\varepsilon\, T^{2}}{k}, \qquad D = \frac{T}{k}
Each of the D = T/k decisions risks an ε-deviation whose cost persists for at most the remaining steps. Setting k = 1 recovers the classic εT² bound; k = 50 divides it by 50.

This is a bound, not a mechanism — the mechanism is that within a chunk the policy replays a coherent snippet of demonstrated motion instead of re-deciding from a possibly-drifted observation 50 times. Numbers: a 20-second WidowX episode at 50 Hz is T=1000T = 1000 steps; single-step BC makes 1000 decisions, ACT with k=50k = 50 makes 20. If 2% of decisions go subtly wrong, expect ~20 bad decisions per episode from BC and ~0.4 from ACT. The price is printed on the label: for one full second the policy consumes no new observations, and the staleness formula ε=vΔt\varepsilon = v\,\Delta t from the time-and-latency lesson applies to every action — at 0.2 m/s, the last action of a 1 s chunk acts on a world that may have moved 20 cm.

Now the latency argument, which puts this lesson in your capstone's direct ancestry. ACT is small — the authors report about 10 ms per forward pass on an RTX 2080 Ti-class GPU. At 50 Hz, running it every step is a 50% inference duty cycle before rendering, logging, or anything else; once per k=50k = 50 chunk, it drops to 1%. For a π₀-class VLA served from OpenPI (opens in a new tab), where a forward pass costs on the order of 100 ms, the arithmetic stops being about efficiency: 100 ms per step against a 20 ms control period is 5x over budget, so chunking is mandatory, not an optimization. The chunk is the robot version of batching — an unaffordable per-step cost becomes an affordable per-second cost.

Checkpoint 02

ACT runs on a WidowX at 50 Hz with k = 50, executing chunks open-loop. Half a second into a chunk, you nudge the target block 4 cm sideways. What does the arm do?

The architecture, at rebuild level

ACT is a DETR-style encoder-decoder: five components you could reimplement in an afternoon. The reference configuration is around 80M parameters — after years of billion-parameter LLMs, tiny — a model you train from scratch (backbone excepted) on 50 demonstrations, on your own workstation, before lunch.

  • Per-camera backbone — an ImageNet-pretrained ResNet18 per camera. A 480x640 RGB frame comes out of the last conv stage as a 15x20 grid of 512-dim features (stride 32), flattened to 300 visual tokens per camera with 2D sinusoidal position embeddings. Two RealSense-class cameras give 600 tokens; ALOHA's four give 1200.
  • Proprioception token — the joint vector (7-dim for a WidowX AI: six joints plus gripper) through a linear layer to 512 dims: one token.
  • Latent token — a 32-dim style variable zz (next section) projected to 512 dims: one token. At test time z=0z = 0.
  • Transformer encoder — 4 layers, 8 heads, width 512, feed-forward 3200, self-attention over all 602 tokens. This is where 'the gripper camera sees the handle' meets 'joint 4 is near its limit'.
  • Transformer decoder — 7 layers with k fixed sinusoidal queries, one per chunk slot. Each query cross-attends into the encoder memory and emerges as a 512-dim vector; a linear head maps it to a 7-dim action. Output: the full (k, 7) chunk in one pass — no autoregression, no KV-cache growth, constant latency in k.
act_shapes.py — the forward pass as a shape auditpython
import numpy as np

B, K, A, D = 8, 50, 7, 512      # batch, chunk len, action dim (6 joints + gripper), width
CAMS, H, W = 2, 480, 640        # two RealSense-class cameras

def resnet18_stub(imgs):
    # last conv stage of ResNet18: stride 32, 512 channels
    n = imgs.shape[0]
    return np.zeros((n, D, H // 32, W // 32), dtype=np.float32)   # (n, 512, 15, 20)

def act_forward(imgs, qpos, z):
    """imgs: (B, CAMS, 3, H, W)   qpos: (B, A)   z: (B, 32), zeros at test time."""
    tokens = []
    for c in range(CAMS):
        f = resnet18_stub(imgs[:, c])                 # (B, 512, 15, 20)
        f = f.reshape(B, D, -1).transpose(0, 2, 1)    # (B, 300, 512) visual tokens
        tokens.append(f)                              # + 2D sinusoidal pos-emb (omitted)

    proprio = np.zeros((B, 1, D))    # linear: 7  -> 512, one token
    latent  = np.zeros((B, 1, D))    # linear: 32 -> 512, one token
    enc_in  = np.concatenate(tokens + [proprio, latent], axis=1)
    assert enc_in.shape == (B, CAMS * 300 + 2, D)     # (8, 602, 512)

    memory  = enc_in                  # stand-in: 4-layer encoder, 8 heads, ffn 3200
    queries = np.zeros((B, K, D))     # fixed sinusoidal embeddings, one per chunk slot
    decoded = queries                 # stand-in: 7-layer decoder cross-attends memory
    actions = decoded[..., :A]        # stand-in: linear head 512 -> 7
    assert actions.shape == (B, K, A)
    return actions

acts = act_forward(np.zeros((B, CAMS, 3, H, W)), np.zeros((B, A)), np.zeros((B, 32)))
print(acts.shape)   # (8, 50, 7): one query, one future action

Two details matter more than they look. First, the chunk loss is L1, not L2 — the paper found L1 noticeably more precise, which fits the mode-averaging story: L2 pulls toward the mean of nearby demonstrations, L1 toward the median, and on millimeter-tolerance grasps the difference shows. Second, the decoder queries make chunk generation one parallel pass: doubling kk barely moves inference latency, unlike an autoregressive head where latency scales with kk. That asymmetry — chunk length nearly free at inference, expensive only in staleness — matters when you tune kk.

The CVAE: giving demonstration style somewhere to live

The previous lesson's dataset work showed that human demonstrations of one task are a family of strategies, not one trajectory plus noise: approach from the left or right, fast or cautious, regrasp or push. A deterministic regressor must place its output somewhere among those modes, and with any averaging-flavored loss it lands between them — the frozen-in-the-middle behavior from the BC lesson. ACT's answer is a conditional VAE: during training, a separate encoder peeks at the answer — the ground-truth chunk — and compresses whatever the observation cannot explain (strategy, pace, style) into a 32-dim latent zz; the decoder reconstructs the chunk from observation plus zz. The objective takes three lines to derive:

logpθ(at:t+kot)  =  logpθ(at:t+kz,ot)p(z)dz  =  logEqϕ(zat:t+k,ot) ⁣[pθ(at:t+kz,ot)  p(z)qϕ(zat:t+k,ot)]\log p_\theta(a_{t:t+k} \mid o_t) \;=\; \log \int p_\theta(a_{t:t+k} \mid z, o_t)\, p(z)\, dz \;=\; \log\, \mathbb{E}_{q_\phi(z \mid a_{t:t+k},\, o_t)}\!\left[\frac{p_\theta(a_{t:t+k} \mid z, o_t)\; p(z)}{q_\phi(z \mid a_{t:t+k},\, o_t)}\right]
Multiply and divide by the approximate posterior q — the usual importance-sampling identity; q may condition on the ground-truth chunk because it exists only at training time.
logpθ(ao)    Eqϕ(za,o)[logpθ(az,o)]reconstruction    DKL(qϕ(za,o)p(z))information budget for z\log p_\theta(a \mid o) \;\ge\; \underbrace{\mathbb{E}_{q_\phi(z \mid a, o)}\bigl[\log p_\theta(a \mid z, o)\bigr]}_{\text{reconstruction}} \;-\; \underbrace{D_{\mathrm{KL}}\bigl(q_\phi(z \mid a, o)\,\Vert\, p(z)\bigr)}_{\text{information budget for } z}
Jensen's inequality on the log of the expectation. The KL term is literally a budget: it prices, in nats, how much information about the answer q is allowed to smuggle into z.

In practice ACT swaps the reconstruction log-likelihood for the L1 loss and prices the budget with a weight β\beta. The posterior encoder qϕq_\phi is a small BERT-style transformer over a CLS token, the embedded joint state, and the embedded action sequence; its CLS output parameterizes the mean and variance of zz, trained with the reparameterization trick. At inference the encoder is deleted and zz is set to 00, the mean of the prior N(0,I)\mathcal{N}(0, I) — the policy executes the typical style, deterministically:

L  =  at:t+ka^t:t+k1  +  βDKL(qϕ(za,o)N(0,I)),β=10 by default\mathcal{L} \;=\; \bigl\lVert a_{t:t+k} - \hat a_{t:t+k} \bigr\rVert_1 \;+\; \beta\, D_{\mathrm{KL}}\bigl(q_\phi(z \mid a, o)\,\Vert\, \mathcal{N}(0, I)\bigr), \qquad \beta = 10 \text{ by default}

β\beta is the knob between two failure modes. Too high: the KL crushes qq onto the prior, zz carries nothing, and the decoder degenerates into plain deterministic BC — mode-averaged and sloppy on multimodal segments. Too low: zz becomes a side channel encoding the actions themselves; training loss looks great because the decoder is handed the answer, but at test time z=0z = 0 carries none of it — good-looking loss curves, drunk-looking robot. Does the machinery earn its complexity? The paper's ablation says yes, exactly where multimodality predicts: on human demonstrations of cube transfer, removing the CVAE collapses success from roughly 35% to around 2%; on scripted single-mode demos it barely matters. Your teleop data is human data. Keep the CVAE.

Temporal ensembling: smoothness now, reactivity later

Executing chunks back-to-back open-loop has a second problem besides staleness: the seams show. Consecutive chunks come from observations kk steps apart, and a multimodal policy may commit to different valid strategies — a step discontinuity in the command stream every kk ticks. ACT's fix is to stop executing chunks and start voting with them: query the policy at every step, keep the last kk overlapping chunks alive, and note that the action for the current instant has been predicted up to NkN \le k times. Average those predictions with exponential weights:

a^t  =  i=0N1wiat(i)i=0N1wi,wi=emi,i=0 indexing the oldest live chunk\hat a_t \;=\; \frac{\sum_{i=0}^{N-1} w_i\, a_t^{(i)}}{\sum_{i=0}^{N-1} w_i}, \qquad w_i = e^{-m\, i}, \quad i = 0 \text{ indexing the oldest live chunk}
Note the direction: w_0 = 1 belongs to the OLDEST prediction. Older plans get more weight; new observations blend in gradually. Smaller m means faster incorporation of new information; the reference implementation defaults to m = 0.01.

That indexing direction is the whole tradeoff. After a perturbation, only chunks born after it know the world changed; their share of ensemble weight at lag τ\tau steps is

S(τ)  =  j=0τ1emjj=0k1emj    m0    τkS(\tau) \;=\; \frac{\sum_{j=0}^{\tau-1} e^{m j}}{\sum_{j=0}^{k-1} e^{m j}} \;\xrightarrow{\; m \to 0\;}\; \frac{\tau}{k}
j is the age of a prediction in steps; informed chunks are the τ newest and carry the τ smallest weights. With k = 50, m = 0.01 at 50 Hz: S reaches 50% only at τ ≈ 28 steps (560 ms) and 90% at τ ≈ 46 steps (920 ms).

The default ensemble therefore buys visibly smoother motion — each executed action is a consensus of up to 50 plans, washing out mode flips and jitter — at the price of a built-in reaction lag of half a chunk or more. The boundary problem (a jolt every second) becomes a distributed one (mild sluggishness always). For quasi-static tasks, a great trade; for anything dynamic, the first tradeoff your capstone exists to renegotiate — real-time chunking (opens in a new tab) is one published renegotiation you will meet in Phase 05.

temporal_ensemble.py — ACT-style ensembling in 30 linespython
import numpy as np
from collections import deque

class TemporalEnsembler:
    """Blend overlapping action chunks with ACT's exponential weights."""

    def __init__(self, chunk_size, action_dim, m=0.01):
        self.k = chunk_size
        self.m = m
        self.chunks = deque()    # (birth_step, (k, action_dim) array), oldest first
        self.t = 0

    def add_chunk(self, chunk):
        """chunk[i] is the action predicted for control step self.t + i."""
        self.chunks.append((self.t, np.asarray(chunk, dtype=np.float64)))
        if len(self.chunks) > self.k:
            self.chunks.popleft()

    def act(self):
        preds = []
        for birth, chunk in self.chunks:              # deque is oldest-first
            idx = self.t - birth
            if 0 <= idx < self.k:
                preds.append(chunk[idx])
        preds = np.stack(preds)                       # (n_live, action_dim)
        w = np.exp(-self.m * np.arange(len(preds)))   # w[0] = 1.0 for the OLDEST
        self.t += 1
        return (w[:, None] * preds).sum(axis=0) / w.sum()

if __name__ == "__main__":
    k = 50
    ens = TemporalEnsembler(chunk_size=k, action_dim=1, m=0.01)
    for t in range(200):
        target = 0.0 if t < 100 else 1.0     # the world changes at step 100
        ens.add_chunk(np.full((k, 1), target))  # lazy planner: aim at last seen target
        a = ens.act()
        if t in (99, 105, 115, 128, 145, 160):
            print(t, round(float(a[0]), 3))
    # blended action crosses 0.5 only ~28 steps (560 ms at 50 Hz) after the change

One systems fact hides in plain sight: ensembling requires a policy forward pass at every control step, since a fresh chunk must be born each tick. For ACT's 10 ms model at 50 Hz, that is an affordable 50% duty cycle on your RTX workstation. For a 100 ms VLA it is impossible — which is why the large-model world runs chunks open-loop or asynchronously, and why the pathologies below never went away; they just moved up the parameter count.

Checkpoint 03

ACT-style temporal ensembling averages overlapping chunks, but requires running inference at every control step. What does that do to the latency-amortization argument for chunking?

Hyperparameters that earn their tuning time

Most of ACT's configuration is inert — you will never touch the layer counts. Three knobs dominate, and all should be reasoned about in physical units. Chunk size first: kk is specified in timesteps, so its meaning depends on control rate. The paper's k=100k = 100 at ALOHA's 50 Hz is two seconds of committed motion; copy k = 100 onto a 25 Hz policy loop on your WidowX AI and you have silently committed to four. Convert to seconds, then match task dynamics: quasi-static tabletop work tolerates 1–2 s of commitment; anything that moves on its own (pouring, handovers, humans) wants far less — exactly why a fixed k is unsatisfying.

KnobReference defaultWhat it controlsSymptom when mis-set
Chunk size kk100 steps = 2 s at 50 HzOpen-loop commitment; decision count T/kT/k; staleness of late-chunk actionsToo small: jitter and compounding drift return. Too large: ignores perturbations, long boundary pauses
KL weight β\beta10Information budget of the latent zzToo high: mode-averaged, plain-BC sloppiness. Too low: great loss, imprecise robot once z=0z = 0
Image resolution480x640, every cameraMillimeter-scale visual precision at the gripperResizing to 224px saves GPU-hours and quietly costs the final centimeter of alignment
Ensembling mm0.01How fast new observations displace old plans in the blendOnly meaningful if you infer every step; larger m deepens the reaction lag
Optimizationlr 1e-5, batch 8, L1 lossStability of an ~80M-param transformer on tiny dataHot learning rates diverge; ACT is trained long, low, and boring
Capacity4 enc / 7 dec layers, width 512, 8 heads, FFN 3200Model size (~80M params)Almost never the first thing to tune
The ACT knobs that matter, with reference defaults from the paper

Budget expectations: 50 teleoperated demonstrations of a 20 s task is roughly 50,000 frames. The authors report about five hours of training on an 11 GB RTX 2080 Ti; a 24 GB-class RTX card does meaningfully better, with the ResNet forward over full-resolution images — not the transformer — dominating step time. This is the cheapest policy training in the course: a real sweep over k{25,50,100}k \in \{25, 50, 100\} and β{1,10,100}\beta \in \{1, 10, 100\} fits in a weekend, and the evaluation lesson will insist you spend the saved time on more evaluation episodes, not more seeds.

Failure signatures on the real arm

ACT's pathologies are periodic and mechanical — a gift: unlike distribution shift, they leave clean fingerprints in the episode logs your instrumentation phase taught you to record. Look for structure at the chunk period kΔtk\,\Delta t, the one timescale that exists in the policy and nowhere in the physics.

SignatureMechanismDetection in your logs
Metronome pause every kΔtk\,\Delta tOpen-loop execution blocks the control loop on inference at each boundaryAutocorrelation of joint speed peaks at the chunk period; command-timestamp gaps of ~inference latency every k ticks
Ghost grasp / ignored perturbationChunk computed before the object moved; the stale plan runs to completionTime from tracked-object jump to first commanded-trajectory deviation is a large fraction of kΔtk\,\Delta t
Jump at the seam / mode flipConsecutive chunks commit to different valid strategies with nothing blending themAction discontinuities concentrated exactly at boundary ticks; absent mid-chunk; joint effort spikes co-timed
Uniform sluggishnessTemporal ensembling weights old plans above new onesStep-response lag of roughly S(τ)-predicted magnitude everywhere, not just at boundaries
Failure signatures and where they show up in logged episodes

Do not wait to trip over these — probe for them. A perturbation probe is a scripted evaluation-time test: at a uniformly random phase within a chunk, displace the target 3–5 cm (a servo-mounted platform, or a consistent nudge with a logged keypress) and measure reaction latency — perturbation timestamp to first significant deviation of commanded actions, both on your Phase 01 logger's monotonic clock. Open-loop ACT should show reaction latency uniform between 0 and kΔtk\,\Delta t plus inference; ensembled ACT should match your S(τ)S(\tau) curve. If measurement disagrees with theory, your logging or your understanding is broken — both are cheap to fix now. This probe is the robot's time-to-first-token under load, almost nobody reports it, and it becomes a headline metric of the capstone.

Studio exercise 01

Measure the reactivity price of ensembling

Extend the TemporalEnsembler demo into an experiment: a 1-D world at 50 Hz where the target sits at 0 and steps to 1 at t = 2.0 s, with the demo's lazy planner emitting a k = 50 chunk each step. Compare open-loop chunking (re-plan only at boundaries, random step phase) against ensembling with m in {0.0, 0.01, 0.1}. For each, report (1) the 90% step-response time in ms across 100 random phases, (2) smoothness as RMS of the second difference of the executed actions, and (3) two sentences: which regime should a latency-aware scheduler target, and what does the flatness of the m sweep tell you?

Need a hint?

Predict before you run: the informed-weight-share formula S(τ) gives you the ensembled response times analytically — compute the τ where S crosses 0.9 for each m and treat simulation as verification. For open-loop chunking, the response time is uniform over the phase, so its mean is k·Δt/2 plus the boundary wait; simulate many phases rather than one.

Where this goes next: the demos you curated in Demonstration data: collection, quality, and splits are exactly what ACT trains on, and the multimodality you documented there is why the CVAE exists. Next, Diffusion Policy: actions as denoising keeps the chunk but replaces the single style latent with an iterative denoiser — a different answer to multimodality that buys sharper mode coverage and pays in inference steps, making this lesson's latency accounting even less optional. The reactivity-versus-smoothness dial both policies share is your capstone's object of study; you now own the instruments to measure it.