BotManifold
Tutorial

Discrete tokenizers: VQ-VAE and FSQ

How continuous images and actions become a vocabulary of tokens: vector quantization and its training tricks, dead codes and how to measure and fix them, finite scalar quantization, and action tokenizers from per-step bins to FAST-style frequency coding.

Why discrete tokens

Language models predict one token from a fixed vocabulary at a time. Images, video and robot actions are continuous. To use the same machinery (the Transformers module) on them, a tokenizer has to turn each continuous signal into integers and back. The VAE module's encoder and decoder are the starting point; the new part is a quantizer in the middle that forces the latent onto a finite set of values.

What a tokenizer has to balance

PropertyWhy it mattersMeasured by
FidelityDecoding the tokens should give back the signalReconstruction error
LengthEvery token is a step for the transformer (attention cost, latency)Tokens per image or per action chunk
Vocabulary useUnused tokens waste capacityCode usage and perplexity
Bits per tokenlog⁡2K\log_2 K for KK codes8 bits for 256 codes, 10 for 1,024

A tokenizer is a compression scheme, and the three numbers trade against each other: a 16 by 16 grid of tokens from a 1,024-code vocabulary carries 256×10=2,560256 \times 10 = 2{,}560 bits per image, whatever the image's resolution, which is why the decoder has to be good at filling in detail.

Vector quantization (VQ-VAE)

Neural Discrete Representation Learning (1711.00937) introduced the VQ-VAE. Keep a learned codebook of K vectors. The encoder output is replaced by its nearest codebook vector, and the index of that vector is the token. Two practical details:

  • Straight-through gradient. Picking the nearest entry is not differentiable. The trick copies the decoder's gradient straight back to the encoder output as if quantization were the identity.
  • Two extra loss terms. One moves codebook entries toward the encoder outputs, and the commitment term keeps the encoder from drifting away from its chosen entry.
Two scatter plots of an autoencoder's two-dimensional latent for four data clusters. Left: VQ with four code vectors marked by stars at the cluster centres. Right: FSQ with a three-by-three grid and each cluster inside its own cell.
Left: VQ assigns each latent to its nearest learned code vector (stars). Right: FSQ rounds each coordinate to one of three fixed levels, so the cells are a fixed 3 x 3 grid. Both reconstruct the four clusters in this toy run.

Dead codes

Because the codebook is learned, some entries can end up never chosen, so those tokens are wasted and clusters of data are squashed onto another cluster's code. This is called codebook collapse. In the exercise below, the same four-cluster task with four codes failed in 5 of 20 runs that differed only in their random initialisation (a run counts as failed if its final reconstruction error exceeds 0.2).

Measuring and fixing dead codes

Two numbers tell you whether a codebook is healthy: the share of codes that are used at all, and the perplexity of their frequencies, exp⁡(−∑kpklog⁡pk)\exp(-\sum_k p_k \log p_k), which reads as the effective vocabulary size (equal to KK when every code is used equally, 1 when one code takes everything). Log both during training.

libs/tutorial/quantization.py (restart)
@torch.no_grad()
def restart_dead_codes(
    quantizer: VectorQuantizer, z: torch.Tensor, dead: torch.Tensor
) -> int:
    """Moves each dead code onto a random encoder output; returns how many moved.

    A code nobody chooses gets no gradient and never recovers on its own. Placing it
    where the data actually is gives it a chance to win some inputs back.
    """
    count = int(dead.sum())
    if count:
        picks = torch.randint(0, len(z), (count,))
        quantizer.codebook.weight[dead] = z[picks]
    return count


def codebook_usage(indices: torch.Tensor, num_codes: int) -> tuple[float, float]:
    """Share of codes used, and perplexity exp(entropy) of their frequencies.

    Perplexity equals ``num_codes`` when every code is used equally and 1 when one
    code takes everything: the effective vocabulary size.
    """
    counts = torch.bincount(indices, minlength=num_codes).float()
    probs = counts / counts.sum()
    nonzero = probs[probs > 0]
    entropy = -(nonzero * nonzero.log()).sum()
    return (counts > 0).float().mean().item(), entropy.exp().item()

In the toy, healthy runs use all 4 codes with perplexity 4.0; a collapsed run uses 3 of 4 (perplexity 2.8) and its reconstruction error is 2.1 instead of 0.02, because two clusters share one code. Common remedies:

  • Restart dead codes: every so often, move any code that was not chosen onto a random recent encoder output. In the toy this cut failed runs from 5 of 20 to 2 of 20 (restart=True).
  • Initialise from the data: set the codebook to k-means centres of early encoder outputs.
  • Update codes by moving averages (EMA) instead of gradients, which tends to keep them closer to the data.
  • Avoid the codebook altogether: FSQ, below.

Finite scalar quantization (FSQ)

Finite Scalar Quantization: VQ-VAE Made Simple (2309.15505) proposes replacing the codebook with something much simpler: project the latent down to a few dimensions, and round each dimension to a small set of fixed values. The implicit codebook is the product of those sets (levels [3, 3] gives 9 codes; [5, 5, 5, 5] gives 625). As the abstract notes, the same downstream models trained on VQ-VAE tokens can be trained on these.

With no learned codebook there is nothing to go dead. In our exercise FSQ with levels [3, 3] failed in 0 of 20 runs, on the same seeds as VQ above. That is a toy result, not a claim about large models; the FSQ paper reports its own comparisons.

Tokenizing actions

Actions need a tokenizer too if a language model is to output them as tokens (the VLA models module). Two designs, and the trade-off between them, are easy to measure on a smooth 50-step chunk of 7 joint commands:

libs/tutorial/quantization.py (action_tokens)
def bin_tokens(
    chunk: torch.Tensor, bins: int, low: float = -1.0, high: float = 1.0
) -> tuple[torch.Tensor, torch.Tensor]:
    """Per-step, per-dimension binning (as in RT-2 and OpenVLA): one token per number.

    Returns:
        Integer tokens with the chunk's shape, and the de-tokenised chunk (bin
        centres).
    """
    width = (high - low) / bins
    tokens = ((chunk.clamp(low, high - 1e-6) - low) / width).floor().long()
    return tokens, low + (tokens.float() + 0.5) * width


def dct_matrix(n: int) -> torch.Tensor:
    """Orthonormal DCT-II matrix: row k is the k-th cosine basis over n samples."""
    k = torch.arange(n)[:, None].float()
    i = torch.arange(n)[None, :].float()
    matrix = torch.cos(torch.pi / n * (i + 0.5) * k) * (2.0 / n) ** 0.5
    matrix[0] /= 2**0.5
    return matrix


def dct_tokens(chunk: torch.Tensor, scale: float) -> tuple[torch.Tensor, torch.Tensor]:
    """FAST-style tokens: DCT along time, scale, round to integers.

    Smooth motion puts almost all its energy in the low-frequency coefficients, so
    after rounding most high-frequency coefficients are 0. FAST then compresses the
    integer sequence further with byte-pair encoding; here we just count non-zeros.

    Args:
        chunk: Actions, shape (time, dims).
        scale: Multiplier before rounding; larger keeps more detail and more tokens.

    Returns:
        The integer coefficients (time, dims) and the reconstructed chunk.
    """
    basis = dct_matrix(chunk.shape[0])
    coefficients = (basis @ chunk * scale).round()
    return coefficients.long(), basis.T @ (coefficients / scale)


def smooth_chunk(steps: int = 50, dims: int = 7, seed: int = 0) -> torch.Tensor:
    """A smooth multi-joint action chunk: a few low-frequency sines per dimension."""
    generator = torch.Generator().manual_seed(seed)
    t = torch.linspace(0, 1, steps)[:, None]
    freqs = torch.rand(3, dims, generator=generator) * 2 + 0.2
    phases = torch.rand(3, dims, generator=generator) * 6.28
    amps = torch.rand(3, dims, generator=generator) * 0.3
    return (
        amps[None] * torch.sin(6.28 * freqs[None] * t[..., None] + phases[None])
    ).sum(1)

Per-step binning (RT-2, OpenVLA) cuts each joint's range into KK bins and emits one token per joint per time step: always 50×7=35050 \times 7 = 350 tokens. More bins reduce the error (at most half a bin) but not the length.

Frequency coding (FAST, 2501.09747) first applies a discrete cosine transform along time, the transform JPEG uses for images. Smooth motion puts nearly all its energy in the first few frequencies, so after scaling and rounding most coefficients are zero; FAST then compresses the remaining integers further with byte-pair encoding (the Tokens, ViT and VLMs module).

Left: seven smooth joint trajectories over 50 steps. Middle: average DCT coefficient magnitude against frequency, large for the first five frequencies and near zero after about ten. Right: RMS error against tokens per chunk; binning sits at 350 tokens for every bin count, while the DCT curve reaches the same errors with 30 to 140 non-zero coefficients.
A smooth action chunk, its frequency content, and the error each tokenizer reaches for a given number of tokens.
TokenizerTokens for the chunkRMS error
Binning, 64 bins3500.0085
Binning, 256 bins3500.0022
DCT, scale 2055 non-zero0.0077
DCT, scale 100106 non-zero0.0021

The DCT reaches 256-bin accuracy with about a third of the tokens, before any byte-pair encoding. The gain depends on smoothness: jerky, high-frequency motion has energy at every frequency and compresses less. The FAST paper reports that per-step binning performs poorly on dexterous, high-frequency data, which is where its method helps most.

Reference implementation (PyTorch)

Tests are in libs/tutorial/tests/test_quantization.py and include the straight-through gradient, the nearest-code property, the grid bounds, usage and perplexity, dead-code restarts (which reduce failed runs), binning's half-bin error bound, and the DCT's orthonormality and compression. Run uv run --group tutorial python -m libs.tutorial.quantization.

libs/tutorial/quantization.py (vq)
class VectorQuantizer(nn.Module):
    """Snaps each vector to its nearest entry in a learned codebook."""

    def __init__(self, num_codes: int, dim: int, beta: float = 0.25) -> None:
        """Creates the codebook.

        Args:
            num_codes: Number of codebook entries (the vocabulary size).
            dim: Size of each entry.
            beta: Weight of the commitment loss that keeps the encoder near its code.
        """
        super().__init__()
        self.num_codes = num_codes
        self.codebook = nn.Embedding(num_codes, dim)
        self.codebook.weight.data.uniform_(-1.0, 1.0)
        self.beta = beta

    def forward(
        self, z: torch.Tensor
    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
        """Quantizes ``z`` of shape (batch, dim).

        Returns:
            The quantized vectors (with a straight-through gradient to ``z``), the
            integer code indices (batch,), and the VQ loss.
        """
        indices = torch.cdist(z, self.codebook.weight).argmin(dim=-1)
        chosen = self.codebook(indices)
        # Move codebook entries toward the encoder outputs, and the encoder toward
        # its chosen entries (the commitment term).
        loss = ((chosen - z.detach()) ** 2).mean()
        loss = loss + self.beta * ((z - chosen.detach()) ** 2).mean()
        # Straight-through estimator: the forward pass uses the code, the backward
        # pass pretends quantization was the identity.
        return z + (chosen - z).detach(), indices, loss
libs/tutorial/quantization.py (fsq)
class FiniteScalarQuantizer(nn.Module):
    """Rounds each dimension to a few fixed levels; no codebook is learned."""

    def __init__(self, levels: list[int]) -> None:
        """Creates the quantizer.

        Args:
            levels: Number of levels per dimension; every entry must be odd. The
                vocabulary size is the product of the levels.

        Raises:
            ValueError: If any level count is even or smaller than 3.
        """
        super().__init__()
        if any(level < 3 or level % 2 == 0 for level in levels):
            raise ValueError(f"levels must be odd and at least 3, got {levels}")
        self.register_buffer("levels", torch.tensor(levels))

    @property
    def num_codes(self) -> int:
        """Size of the implicit codebook."""
        return int(self.levels.prod().item())

    def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """Quantizes ``z`` of shape (batch, len(levels)).

        Returns:
            Quantized values in [-1, 1] (straight-through gradient) and one integer
            code per row in [0, num_codes).
        """
        half = (self.levels - 1) / 2
        bounded = torch.tanh(z) * half
        rounded = bounded + (bounded.round() - bounded).detach()
        digits = (rounded + half).long()
        radix = torch.cumprod(
            torch.cat([torch.ones(1, dtype=torch.long), self.levels[:-1]]), dim=0
        )
        return rounded / half, (digits * radix).sum(dim=-1)
libs/tutorial/quantization.py (collapse)
def failed_runs(
    make_quantizer: Callable[[], nn.Module],
    seeds: range,
    steps: int = 1200,
    threshold: float = 0.2,
    *,
    restart: bool = False,
) -> int:
    """Counts training runs whose final reconstruction error exceeds ``threshold``.

    A bad run usually means a codebook entry was never used (a "dead code"), so a
    whole cluster is reconstructed as another one. The quantizer is created after
    seeding, so a seed fixes its initialisation too.
    """
    failures = 0
    for seed in seeds:
        torch.manual_seed(100 + seed)
        _, _, _, error = train_autoencoder(
            make_quantizer(), steps, seed, restart=restart
        )
        failures += int(error > threshold)
    return failures

Where this appears

  • Image generation. VQGAN (2012.09841) and latent diffusion (2112.10752) both generate in a compressed latent space of an autoencoder instead of in pixels.
  • Actions. Discrete action tokens in a VLA (see the VLA models module) are a tokenizer problem; FAST compresses the action sequence before tokenising it.
  • Driving. AutoVLA (2506.13757) tokenizes continuous trajectories into discrete, feasible actions so one autoregressive model can generate them.

Check yourself

  1. Why does a VQ-VAE need a straight-through estimator?
  2. What is a dead code, and why does it hurt?
  3. How many codes does FSQ with levels [5, 3] have, and which parameters does it learn?
  4. Give one reason to prefer a continuous action expert over discrete tokens (see the VLA module).
  5. A codebook of 512 codes has perplexity 40. What does that tell you, and what would you try?
  6. Why does a DCT need fewer tokens than per-step binning for a smooth chunk, and when would it not?
Answers
  1. Choosing the nearest codebook entry has no useful gradient. The straight-through estimator copies the decoder's gradient back to the encoder output as if quantization were the identity, so the encoder can still learn.
  2. A codebook entry that is never chosen. Its token is wasted capacity, and the data it should have covered is squashed onto another code: in the toy a collapsed run reconstructs with error 2.1 instead of 0.02.
  3. 5 × 3 = 15 codes. It learns no codebook at all: only the projections before and after the rounding.
  4. Discrete tokens are generated one at a time and limited by quantisation; a continuous expert produces a whole smooth chunk in a few steps and handles several good answers (the VLA module's action-head comparison).
  5. Only about 40 codes are effectively used: most of the codebook is dead or rarely used. Try restarting dead codes, initialising from k-means, EMA updates, or switching to FSQ.
  6. Smooth motion puts nearly all its energy in a few low frequencies, so most DCT coefficients round to zero. Jerky, high-frequency motion has energy everywhere and compresses much less.

Quiz

Answer each question; feedback appears as soon as you choose.

Question 1 of 5

Why does a VQ-VAE use a straight-through estimator?

Question 2 of 5

What is a dead code in a VQ codebook?

Question 3 of 5

How many codes does FSQ with levels [5, 3] have?

Question 4 of 5

What does FSQ learn that VQ does not need to, and vice versa?

Question 5 of 5

Why would a model generate in a quantized latent space instead of in pixels?

0 of 5 answered

Sources

On this page