Source code for protomotions.agents.common.fsq

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Finite scalar quantization modules."""

import torch
from tensordict import TensorDict
from torch import nn

from protomotions.agents.common.autoencoder import AutoEncoder
from protomotions.agents.common.fsq_config import FSQAutoEncoderConfig


[docs] class FiniteScalarQuantizer(nn.Module): """Finite scalar quantizer for continuous latent vectors. This implements the fixed-codebook scalar quantizer from "Finite Scalar Quantization: VQ-VAE Made Simple" (Mentzer et al., 2023, arXiv:2309.15505). Each latent dimension is independently bounded and rounded to one of ``num_fsq_levels`` integer codes, so the implicit codebook is the Cartesian product of the per-dimension scalar levels. The quantizer intentionally stays a plain ``nn.Module``: it owns buffers and tensor transforms only. The TensorDict/model contract is provided by ``FSQAutoEncoder``, which wraps this module inside an autoencoder bottleneck. """
[docs] def __init__(self, num_fsq_levels: int, num_fsq_scalars: int, eps: float = 1e-4): """Create a scalar quantizer with one shared level count per scalar. Args: num_fsq_levels: Number of discrete scalar values available to each latent scalar. This implementation requires an odd value so zero is one of the quantization levels. num_fsq_scalars: Number of scalar code dimensions in the flattened latent. eps: Small shrink factor used by the tanh bounding transform to avoid saturating exactly at the outermost level before straight-through rounding. """ super().__init__() if num_fsq_levels % 2 == 0: raise ValueError("FSQ requires an odd number of quantization levels") levels = torch.full((num_fsq_scalars,), num_fsq_levels, dtype=torch.float32) half_l = (levels - 1) * (1 - eps) / 2 half_width = (levels.long() // 2).to(torch.float32) self.num_fsq_levels = num_fsq_levels self.num_fsq_scalars = num_fsq_scalars self.register_buffer("L", levels * (half_l / half_width), persistent=False) self.register_buffer("half_width", half_width, persistent=False) self.register_buffer("half_L", self.L / 2, persistent=False)
[docs] def bound(self, z: torch.Tensor) -> torch.Tensor: """Map unbounded latent values into the relaxed FSQ code range.""" return z.tanh() * self.half_L.unsqueeze(0).to(z.device)
[docs] @staticmethod def round_ste(z: torch.Tensor) -> torch.Tensor: """Round with the straight-through estimator used for FSQ training.""" zhat = z.round() return z + (zhat - z).detach()
[docs] def quantize(self, z: torch.Tensor) -> torch.Tensor: """Bound and straight-through round latent values to scalar codes.""" return self.round_ste(self.bound(z))
[docs] def codes_to_indices(self, codes: torch.Tensor) -> torch.Tensor: """Convert centered scalar codes to non-negative FSQ indices.""" codes = codes + self.half_width.unsqueeze(0).to(codes.device) return torch.round(codes).long()
[docs] def indices_to_codes(self, indices: torch.Tensor) -> torch.Tensor: """Convert non-negative FSQ indices back to centered scalar codes.""" return indices.float() - self.half_width.unsqueeze(0).to(indices.device)
[docs] def calculate_perplexity(self, codes: torch.Tensor, skip: bool = False): """Return mean per-FSQ-scalar perplexity for utilization logging.""" if skip: return torch.tensor(0.0, device=codes.device) indices = self.codes_to_indices(codes.view(codes.shape[0], self.num_fsq_scalars)) perplexities = [] for scalar_idx in range(self.num_fsq_scalars): scalar_indices = indices[:, scalar_idx].flatten().long() counts = torch.bincount(scalar_indices, minlength=self.num_fsq_levels).float() probs = counts / counts.sum().clamp_min(1.0) entropy = -(probs * torch.log(probs + 1e-10)).sum() perplexities.append(torch.exp(entropy)) return torch.stack(perplexities).mean()
[docs] class FSQAutoEncoder(AutoEncoder): """Autoencoder with a finite scalar quantization bottleneck.""" supports_log_internals = True config: FSQAutoEncoderConfig
[docs] def __init__(self, config: FSQAutoEncoderConfig): super().__init__(config) self._validate_encoder_output_dim(config.num_fsq_scalars) self.quantizer = FiniteScalarQuantizer( config.num_fsq_levels, config.num_fsq_scalars )
def _validate_encoder_output_dim(self, num_fsq_scalars: int): """Fail early when the encoder cannot feed the FSQ bottleneck. FSQ quantizes one scalar per flattened latent dimension. If the encoder produces a different width, the error would otherwise surface later as an opaque broadcast failure inside the quantizer. """ encoder_output_dim = self._configured_output_dim( self.config.encoder, self.encoder_out_keys[0] ) if encoder_output_dim is None: return if encoder_output_dim != num_fsq_scalars: raise ValueError( "FSQAutoEncoder encoder output dim " f"({encoder_output_dim}) must match num_fsq_scalars " f"({num_fsq_scalars})." ) @classmethod def _configured_output_dim(cls, config, out_key: str): """Best-effort output-width lookup from module config objects.""" if hasattr(config, "num_out") and out_key in getattr(config, "out_keys", []): return config.num_out models = getattr(config, "models", None) if not models: return None for model_config in reversed(models): model_out_keys = getattr(model_config, "out_keys", []) if out_key in model_out_keys: return cls._configured_output_dim(model_config, out_key) return cls._configured_output_dim(models[-1], out_key) @property def num_fsq_levels(self) -> int: return self.quantizer.num_fsq_levels @property def num_fsq_scalars(self) -> int: return self.quantizer.num_fsq_scalars @property def L(self) -> torch.Tensor: return self.quantizer.L @property def half_width(self) -> torch.Tensor: return self.quantizer.half_width @property def half_L(self) -> torch.Tensor: return self.quantizer.half_L
[docs] def bound(self, z: torch.Tensor) -> torch.Tensor: return self.quantizer.bound(z)
[docs] @staticmethod def round_ste(z: torch.Tensor) -> torch.Tensor: return FiniteScalarQuantizer.round_ste(z)
[docs] def codes_to_indices(self, codes: torch.Tensor) -> torch.Tensor: return self.quantizer.codes_to_indices(codes)
[docs] def indices_to_codes(self, indices: torch.Tensor) -> torch.Tensor: return self.quantizer.indices_to_codes(indices)
[docs] def quantize(self, z: torch.Tensor) -> torch.Tensor: return self.quantizer.quantize(z)
[docs] def calculate_perplexity(self, codes: torch.Tensor, skip: bool = False): return self.quantizer.calculate_perplexity(codes, skip=skip)
[docs] def bottleneck( self, latent: torch.Tensor, tensordict: TensorDict, ) -> torch.Tensor: return self.quantize(latent)
[docs] def internal_logs( self, latent: torch.Tensor, tensordict: TensorDict, ): perplexity = self.calculate_perplexity(latent) return { "perplexity": perplexity.expand(tensordict.batch_size).clone(), }