The AY-Robots policies comparison table showing five trainable robot policies with parameter counts, GPU tier, inference latency per action step and minimum episode counts
action tokenizationFASTvision-language-actionLeRobotrobot learning

Action Tokenization: How FAST Turns Trajectories Into Tokens

AY-Robots ResearchAugust 23, 202617 min read

How a continuous joint trajectory becomes tokens a transformer can predict. 256-bin discretization, the FAST DCT plus BPE scheme, real defaults, and what each choice costs.

A servo takes a number. A transformer emits a symbol from a fixed vocabulary. Action tokenization is the glue between those two facts. Get it wrong and the model trains to a low loss, the checkpoint loads, and the arm still drifts, because its symbols cannot reconstruct the motion you recorded.

Two schemes matter: per-dimension per-timestep binning, used by RT-1, RT-2 and OpenVLA, and FAST, the frequency-space scheme behind Pi0-FAST. It ends on the part most write-ups skip: none of the five policies you can fine-tune here has a tokenizer knob, and that is the right default.

What you need to know

  • Binning maps every joint value at every timestep to one of N bins, almost always 256. Token count grows with control frequency and joint count.
  • At high control rates neighbouring timesteps land in the same bin, so next-token prediction collapses onto copying: the FAST paper reports error climbing until the model repeats the first action.
  • FAST (Frequency-space Action Sequence Tokenization, arXiv 2501.09747) runs a discrete cosine transform over time, rounds the coefficients, then compresses them with byte pair encoding.
  • Measured: a 50 Hz T-shirt folding chunk drops from 700 tokens to 53, a 5 Hz BridgeV2 chunk only from 35 to 20.
  • Compression buys training speed, not inference speed: about 750 ms per chunk against about 100 ms for diffusion Pi0 on a 4090.
  • The released FAST+ tokenizer ships vocab_size 2048, not the paper's 1024. Read the config, not the PDF.
  • None of the five trainable policies here predicts tokens at inference. All output continuous actions.

The gap tokenization has to close

An episode from an SO-100 is a table of floats: six joints, one row per control tick, stored by the LeRobot dataset format. Record at 30 Hz, ask the policy for one second of motion, and one training example is a 30 by 6 block.

python
import numpy as np

# one second of SO-100 motion at 30 Hz, six joints, radians
chunk = np.load("episode_004_actions.npy")
print(chunk.shape, chunk.dtype)     # (30, 6) float32
print(chunk.min(), chunk.max())     # -2.61 1.94

# a transformer cannot emit a float32. It emits an index into a vocabulary.
# tokenization decides how those 180 numbers become a sequence of indices.
What every tokenizer has to encode: an H by D block of joint targets.

A diffusion or flow matching head sidesteps this: it regresses the block and never needs a vocabulary. An autoregressive model cannot. Its output layer is a softmax over a fixed vocabulary, so reusing its language pre-training means expressing motion in the same currency as words. What follows is driven by what a language model finds easy to predict.

Binning: one symbol per joint per tick

Take each dimension independently, split its range into N uniform bins, emit the bin index. RT-1 (arXiv 2212.06817, December 2022) set the convention everyone copied: 256 bins per dimension, uniform within each variable's bounds, over 7 arm dimensions, 3 base dimensions and one mode dimension switching between arm, base and termination.

python
import numpy as np

N_BINS = 256

def bin_encode(chunk, lo, hi, n_bins=N_BINS):
    """chunk: (H, D) float. Returns (H*D,) int token ids."""
    clipped = np.clip(chunk, lo, hi)
    idx = ((clipped - lo) / (hi - lo) * (n_bins - 1)).round().astype(int)
    return idx.reshape(-1)          # row-major: t0d0 t0d1 ... t0d5 t1d0 ...

def bin_decode(tokens, lo, hi, H, D, n_bins=N_BINS):
    idx = np.asarray(tokens).reshape(H, D)
    return lo + idx / (n_bins - 1) * (hi - lo)

# quantile bounds, not min/max: outliers otherwise eat the resolution
lo = np.quantile(all_actions, 0.01, axis=0)
hi = np.quantile(all_actions, 0.99, axis=0)

tokens = bin_encode(chunk, lo, hi)
print(len(tokens))     # 180 for a 30 x 6 chunk. One token per number.
Binning in full. That is the whole scheme, which is both its appeal and its problem.

Every model made one variation. They look cosmetic and are not: the bin bounds decide how much of the 256-way resolution you actually use.

ModelBinsBin boundsHow tokens enter the vocabulary
RT-1 (2022)256Uniform within each variable's boundsDedicated discrete output, no language vocabulary
RT-2, PaLI-X (2023)256UniformPaLI-X has unique tokens for integers up to 1000, so bins map to them directly
RT-2, PaLM-E (2023)256UniformThe 256 least frequently used tokens are overwritten
OpenVLA (2024)2561st and 99th quantile of training actionsThe 256 least used Llama tokens, which are the last 256
FAST (2025)n/a, BPE over DCT coefficientsQuantile normalization to [-1, 1]In lerobot, the PaliGemma tail, skipping the last 128 tokens
Why quantiles and not min and max

OpenVLA gives the reason directly: the 1st and 99th quantile instead of min/max bounds "allows us to ignore outlier actions in the data that could otherwise drastically expand the discretization interval and reduce the effective granularity". One frame where a servo reads 4 radians because the bus dropped a packet costs resolution on every other frame. FAST inherits the advice: lerobot defaults to --normalization_mode QUANTILES. If an arm moves in visible steps, coarse bins are one candidate, alongside arm twitches then sags.

Where binning breaks, and it is not where you expect

Binning does not fail because 256 bins are too coarse. It fails on token count. One token per dimension per timestep means sequence length is H times D, so doubling the control frequency doubles the tokens per second.

DatasetControl frequencyTokens per 1 s chunk, binningTokens per 1 s chunk, FASTCompression
BridgeV25 Hz35201.75x
DROID15 Hz105293.6x
Table bussing20 Hz140285.0x
T-shirt folding50 Hz7005313.2x

Read that as a statement about your own recording rate. At 5 Hz compression is nearly irrelevant. At 50 Hz it is the difference between a workable sequence and one that dominates the context window, and every token still has to be predicted at inference.

The subtler failure is statistical. At 50 Hz the joint angle at tick t and tick t+1 usually differ by less than one bin width, so the best strategy under a next-token objective is to copy the previous token. The FAST paper measures it: as sampling density rises, error climbs until the model simply copies the first action. That model is not undertrained. It found the optimum of a badly posed objective.

The trap: a good loss curve on a policy that will not move

A policy that has collapsed onto copying reports excellent token accuracy, because most tokens genuinely are repeats. The loss curve looks like a success. You find out when the arm holds its start pose and drifts. See loss falls, policy does nothing. Token accuracy is not a proxy for trajectory quality, and no number of extra training steps repairs it.

FAST: compress the trajectory, then tokenize the compression

The paper is by Pertsch, Stachowicz, Ichter, Driess, Nair, Vuong, Mees, Finn and Levine, submitted 16 January 2025. The idea comes from JPEG and MP3: a smooth signal is sparse in the frequency domain, so transform first and quantize second. A joint trajectory is about as smooth as signals get.

  1. 1
    Normalize the chunk

    Bring every dimension into roughly [-1, 1]. Quantile normalization survives outliers. Not optional, and the step people skip.

    python
    lo = np.quantile(all_actions, 0.01, axis=0)
    hi = np.quantile(all_actions, 0.99, axis=0)
    norm = 2 * (chunk - lo) / (hi - lo) - 1
    norm = np.clip(norm, -1, 1)   # (30, 6)
  2. 2
    Discrete cosine transform along time

    One orthonormal DCT per dimension over the time axis. Same shape out, but the energy moves into the low-frequency coefficients: a slow reach puts almost all of it in the first three.

    python
    from scipy.fft import dct
    
    # tokenizer input is [batch, timesteps, action_dim], DCT over axis=1
    coeff = dct(norm[None], axis=1, norm="ortho")   # (1, 30, 6)
  3. 3
    Scale and round

    Multiply by the scale hyperparameter, round to integers. The lossy part: larger scale keeps more small coefficients and lengthens the sequence, smaller scale blurs the chunk.

    python
    SCALE = 10
    coeff_q = np.around(coeff * SCALE)
    print((coeff_q == 0).mean())
    # a large fraction of coefficients are now exactly zero
  4. 4
    Flatten low frequency first

    Row-major flattening puts the lowest-frequency component of every dimension first, so runs of zeros end up adjacent at the tail.

    python
    flat = coeff_q[0].flatten().astype(int)   # length 180
    # order: freq0-dim0..dim5, freq1-dim0..dim5, ...
  5. 5
    Byte pair encoding over the integers

    Integers become characters and BPE is trained over those strings, merging frequent runs. The runs are mostly zeros, so the tail collapses. Lossless.

    python
    # what the shipped tokenizer literally does. min_token shifts the
    # integer range so it can be addressed as unicode code points
    token_str = "".join(map(chr, np.maximum(flat - MIN_TOKEN, 0)))
    tokens = bpe_tokenizer(token_str)["input_ids"]

The whole pipeline is 158 lines of Python in processing_action_tokenizer.py, depending on nothing heavier than scipy and the tokenizers library. Using the released tokenizer takes four lines.

python
import numpy as np
from transformers import AutoProcessor

# FAST+, fitted on roughly 1M real robot action chunks
tokenizer = AutoProcessor.from_pretrained(
    "physical-intelligence/fast", trust_remote_code=True
)

# expects actions already normalized to [-1, 1]
action_data = np.random.rand(256, 50, 14)     # [batch, horizon, action_dim]
tokens = tokenizer(action_data)               # list[list[int]]
decoded = tokenizer.decode(tokens)            # back to [256, 50, 14]

print(np.mean([len(t) for t in tokens]))      # tokens per chunk
pip install transformers scipy is the entire dependency list.

The decoder needs the horizon and action dimension, because the token sequence does not encode its own shape. The tokenizer caches them from the last encode call, which works in a notebook and misbehaves in a serving loop where encode and decode run in different processes. Pass both to decode() explicitly when you deploy.

The defaults, and where paper and artifact disagree

The paper uses rounding scale 10 and BPE vocabulary size 1024 across its single-dataset experiments, and says the parameters are not very sensitive. The released tokenizer says something else. Its config, last modified 16 January 2025:

ParameterPaper, per-datasetphysical-intelligence/fast (FAST+)lerobot-train-tokenizer default
DCT rounding scale101010.0
BPE vocabulary size102420481024
min_token offsetnot stated-354learned during fit
Chunk length1 second of motionset at call time10 (--action_horizon)
Normalizationquantile recommendedcaller's jobQUANTILES
Encoded dimensionsallall"0:6,7:23" (--encoded_dims)
Chunks per episodenot statedn/a0.1 (--sample_fraction)

The vocabulary difference is not a typo. FAST+ covers single-arm, bi-manual and mobile manipulators at many control frequencies, so its coefficient range is wider. The negative min_token says the same: the smallest scaled coefficient during fitting was -354, and everything shifts by that to be addressable as a code point.

The unnormalized-input trap that eats a day

The encode path contains np.maximum(elem.flatten() - self.min_token, 0). Feed raw joint angles instead of values normalized to [-1, 1] and your coefficients land outside the fitted range. Anything below min_token is clamped to zero, silently: no exception, no warning, no log line. Training runs, loss falls, and reconstruction is garbage for exactly the chunks with the largest motion. Round-trip first and check the mean absolute error. If it is large, your normalization is wrong, not your model.

python
# run this before you spend money on a GPU
tokens = tokenizer(norm_chunks)                      # [B, H, D] in [-1, 1]
recon  = tokenizer.decode(tokens, time_horizon=H, action_dim=D)

mae = np.abs(recon - norm_chunks).mean()
p99 = np.percentile([len(t) for t in tokens], 99)

print(f"round-trip MAE {mae:.4f}   p99 tokens/chunk {p99:.0f}")
# MAE should be small relative to your action scale.
# p99 must stay below max_action_tokens (256 in lerobot pi0_fast).
Two numbers that catch most tokenizer mistakes before training starts.

Fitting a tokenizer to your own SO-100 data

FAST+ works across action spaces, but the paper's own framing is that a tokenizer fitted to your data compresses better. lerobot ships a command, and fitting takes minutes rather than GPU hours.

  1. 1
    Install the pi extras

    The FAST path sits behind the pi extra. Without it, an import error on scipy or tokenizers.

    bash
    git clone https://github.com/huggingface/lerobot.git
    cd lerobot
    pip install -e ".[pi]"
  2. 2
    Fit the tokenizer on your dataset

    --encoded_dims selects which dimension ranges to encode, which matters for bi-manual rigs. For a six-joint SO-100 it is "0:6".

    bash
    lerobot-train-tokenizer \
        --repo_id "yourname/so100-pick-place" \
        --action_horizon 10 \
        --encoded_dims "0:6" \
        --vocab_size 1024 \
        --scale 10.0 \
        --normalization_mode QUANTILES \
        --sample_fraction 0.1 \
        --output_dir "./fast_so100" \
        --push_to_hub \
        --hub_repo_id "yourname/fast-so100"
  3. 3
    Read the compression report

    The script samples up to 1000 chunks and logs compression ratio, mean and p99 token length. Its ratio is (horizon x action_dim) over mean tokens.

    text
    Compression Statistics:
      Average compression ratio: 4.31x
      Mean token length: 13.9
      P99 token length: 24
      Min token length: 8
      Max token length: 31
  4. 4
    Train the policy against it

    chunk_size and n_action_steps must match the horizon you fitted on, or the decoder reshapes into the wrong geometry.

    bash
    lerobot-train \
        --dataset.repo_id=yourname/so100-pick-place \
        --policy.type=pi0_fast \
        --policy.pretrained_path=lerobot/pi0fast-base \
        --policy.action_tokenizer_name=yourname/fast-so100 \
        --policy.chunk_size=10 \
        --policy.n_action_steps=10 \
        --policy.max_action_tokens=256 \
        --policy.dtype=bfloat16 \
        --policy.gradient_checkpointing=true \
        --batch_size=4 --steps=100000 --policy.device=cuda

If the vocabulary is too small for the coefficient range, fit() raises an assertion rather than degrading quietly, and warns when the alphabet comes within 100 entries of it. Raise the vocabulary rather than lowering the scale: lowering the scale throws away real motion.

The AY-Robots policies comparison table listing GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT with parameter counts, GPU tier, per-step inference latency and minimum episode counts
The five trainable policies. Latency is set by the action head, not the vision backbone.

What tokens cost you at inference

This is the part lost when FAST gets called a straight win. Compression is a training-time win. At inference the policy emits its 30 to 60 tokens one forward pass at a time, and nothing removes that.

ApproachWhat runs per chunkReported timeSource
Pi0 with flow matching10 denoising steps, batchedabout 100 ms on a 4090FAST paper, VI-E
Pi0-FAST30 to 60 autoregressive token decodesabout 750 ms on a 4090FAST paper, VI-E
RT-2 PaLI-X 55B8 binned tokens, 55B model1 to 3 HzRT-2 paper
RT-2 5B variant8 binned tokensabout 5 HzRT-2 paper
RT-111 binned dimensions3 Hz inferenceRT-1 paper

750 ms is fine for a one-second chunk executed open loop, and not fine if you wanted to replan. lerobot softens the cost with KV-caching and greedy decoding at temperature 0.0, but sequential forward passes still equal token count. Same inference latency budget as in action chunking: a longer chunk amortizes latency but stales the plan.

Autoregressive action tokens against a continuous action head
What tokenization buys
  • Reuses the pre-trained language model head unchanged, so any VLM becomes a VLA without surgery.
  • Plain cross-entropy next-token training. No noise schedule, no sampler, no action expert.
  • Pi0-FAST matched diffusion Pi0 on 10k hours of data with roughly 5x fewer GPU hours.
  • The tokenizer is a small deterministic artifact you can round-trip and measure before training.
  • FAST+ works as a black box across action spaces and control frequencies.
What it costs
  • Inference is serial in token count: about 750 ms per chunk against about 100 ms for flow matching.
  • Quantization is lossy in a way invisible to the loss curve. Reconstruction error needs its own check.
  • Token counts vary per chunk, so fast motions exceed max_action_tokens and get truncated while slow ones never do.
  • Two extra hyperparameters, scale and vocabulary size, covered by no metric the trainer reports.
  • Binning variants degrade badly above roughly 20 Hz, below many teleoperation recording rates.

Where the five trainable policies actually sit

Here is the honest part. If you are about to fine-tune something on an SO-100, tokenization is not your decision. Every policy on this platform predicts continuous actions at inference: no bin count and no FAST scale, because none of the five predicts tokens.

PolicyAction representation at inferencePer action stepTokenizer choice applies?
GR00T N1.7Diffusion action head over continuous actions152 msNo
GR00T N1.5Diffusion action head over continuous actions165 msNo
Pi0.5Flow matching action expert485 msNot at inference. FAST tokens sit in the vendor's pre-training only.
SmolVLAContinuous action prediction245 msNo
ACTDirect continuous regression over a chunk20 msNo. ACT never discretizes anything.

That row deserves its footnote. The Pi0.5 paper (arXiv 2504.16054, 22 April 2025) states that during pre-training all tasks including robot actions are represented with discrete tokens, which leads to simple, scalable and efficient training, and that post-training adds an action expert for finer granularity and more compute-efficient inference, because discrete representations require expensive autoregressive decoding. FAST is in the ancestry of the Pi0.5 checkpoint you fine-tune, and out of the loop when you run it.

Head to head comparison page on AY-Robots showing GR00T N1.7 against Pi0.5 across parameters, GPU tier, latency, minimum episodes and dataset format
GR00T N1.7 against Pi0.5: two continuous action heads, a 3x latency gap, no tokenizer setting.

That is a design decision, not a missing feature. The tokenizer is only a lever when you build an autoregressive VLA from a language backbone, and for a six-joint arm at 30 Hz the defaults are already the useful settings. To compare the families on published benchmarks, the arena lists Pi0-FAST, Pi0 and Pi0.5.

Two routes to the same fine-tuned policy

The full manual path. Right if you want to change the tokenizer, swap the backbone or reproduce a paper number. Budget a day for the first run, mostly on CUDA versions and dataset conversions.

  1. 1
    Get a dataset in LeRobot format

    Record your own or pull one from the hub. Check action dimension and frequency: both feed into token count.

    bash
    huggingface-cli download --repo-type dataset \
        yourname/so100-pick-place --local-dir ./data/so100
  2. 2
    Fit or select a tokenizer

    Fit on your data, or use lerobot/fast-action-tokenizer, fitted on over 1M real robot action sequences.

    bash
    lerobot-train-tokenizer \
        --repo_id yourname/so100-pick-place \
        --action_horizon 10 --encoded_dims "0:6" \
        --vocab_size 1024 --scale 10.0 \
        --normalization_mode QUANTILES \
        --output_dir ./fast_so100
  3. 3
    Rent a GPU and train

    pi0_fast is PaliGemma-based: Gemma 2B with a SigLIP vision tower. openpi lists over 70 GB for full fine-tuning, over 22.5 GB for LoRA.

    bash
    lerobot-train \
        --dataset.repo_id=yourname/so100-pick-place \
        --policy.type=pi0_fast \
        --policy.pretrained_path=lerobot/pi0fast-base \
        --policy.dtype=bfloat16 \
        --policy.gradient_checkpointing=true \
        --policy.chunk_size=10 --policy.n_action_steps=10 \
        --policy.max_action_tokens=256 \
        --steps=100000 --batch_size=4 --policy.device=cuda
  4. 4
    Serve it and wire up the arm

    You write the serving loop, camera plumbing, client-side normalization statistics and shutdown logic.

    bash
    uv run scripts/serve_policy.py policy:checkpoint \
        --policy.config=pi05_libero \
        --policy.dir=checkpoints/pi05_libero/my_experiment/20000
What the extra work buys

Full control over tokenizer, backbone and training loop. If your question is about action representation, this is the only route that answers it.

Failure modes that come from the tokenizer

Most policy failures are data problems, not representation problems. A handful of symptoms point at the tokenizer, and they are worth recognising because they look like model failures.

  • The arm holds its starting pose and drifts. Binning collapse: consecutive values fall in the same bin, so the model learned to copy.
  • Motion is right in shape but visibly quantized. Bin resolution eaten by outliers, or a DCT scale so low the mid-frequency coefficients rounded to zero.
  • Fast segments fail while slow segments work. Fast motion means more non-zero coefficients, more tokens, and hits the max_action_tokens ceiling first. Check the p99.
  • The policy stops mid-motion and resumes at the next chunk. A decode failure that silently returned a zero chunk. See policy freezes mid-motion.
  • Reconstruction is fine locally and terrible in the serving loop. The decoder is using a cached shape from a different call.
The silent zero chunk

Read lerobot's decode_actions_with_fast before trusting it on hardware. If the BPE decode raises, the handler logs a warning and substitutes np.zeros((time_horizon, action_dim)) for the coefficients. The inverse DCT of zeros is zeros, so the robot gets an all-zero chunk and the arm stops for one chunk duration. With relaxed_decoding=True, the default, short sequences are zero-padded on the right instead of failing. Count those warnings during evaluation: if the count is not zero, your success rate includes runs where the policy did nothing.

The AY-Robots arena leaderboard, a sortable table of 85 vision-language-action models with 332 benchmark results, each value linked to its source paper or model card
85 VLA models and 332 benchmark results, token-predicting and continuous-head policies in one table.

How to decide

Autoregressive VLA from a language backbone, data above about 20 Hz: use FAST, not binning. The paper's 20 Hz and 50 Hz results show binning making no progress. On 5 Hz data binning is defensible. With a working continuous action head, tokenization is a training-speed argument against an inference-speed penalty, which fits pre-training and not the last mile. Fine-tuning one of the five here: spend the attention on recording better episodes instead.

One caveat applies to every route. The control loop here runs at 20 to 485 ms per action step, and inference has to sit next to the servos for anything fast. Public-internet round trips on top of a 485 ms Pi0.5 step turn a working policy into a hesitant one. Remote inference suits slow pick-and-place, not fast reactive motion. Background in our overview of VLA models and the Pi0 flow matching write-up.

Five policies, real latency numbers, no tokenizer homework

GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT compared on parameters, GPU tier, per-step latency and the minimum number of episodes each needs before it does anything useful.

Compare the policies
What is action tokenization in a vision-language-action model?

The mapping between the discrete symbols a transformer predicts and the continuous joint targets a robot executes. An autoregressive model can only emit indices into a fixed vocabulary, so a trajectory is encoded as indices and decoded back. Diffusion and flow matching heads skip this entirely.

How does FAST differ from 256-bin discretization?

Binning assigns one token per dimension per timestep, so token count grows with control frequency. FAST runs a discrete cosine transform over the time axis, rounds the coefficients with a scale factor of 10, then compresses the sparse integers with byte pair encoding. On 50 Hz T-shirt folding data the paper reports 53 tokens per chunk against 700 for binning.

Is FAST faster at inference than diffusion?

No, slower. The paper reports about 750 ms per chunk for Pi0-FAST against about 100 ms for diffusion Pi0 on a 4090, because the autoregressive model decodes 30 to 60 tokens sequentially while diffusion runs 10 batched steps. FAST's advantage is training time: roughly 5x fewer GPU hours on 10k hours of data.

Which tokenizer should I use for a six-joint SO-100 arm?

Start with lerobot/fast-action-tokenizer, fitted on over 1M real robot action sequences. Then fit your own with lerobot-train-tokenizer using --encoded_dims "0:6" and compare compression ratio and p99 token length. Fitting takes minutes, so measure both.

Do the policies on AY-Robots let me choose a tokenizer?

No, and none needs one. GR00T N1.7 and N1.5 use a diffusion action head, Pi0.5 a flow matching action expert, SmolVLA and ACT predict continuous actions directly. FAST tokens appear in Pi0.5's ancestry from the vendor's pre-training, but not when you fine-tune or run the checkpoint.

Why does my tokenized policy train to a low loss but not move?

Almost always because the token distribution is dominated by repeats. At high control frequencies consecutive joint values land in the same bin, so predicting the previous token is usually correct: accuracy looks excellent while trajectory quality is worthless. Round-trip through the tokenizer rather than trusting the loss curve.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started