Vector Quantization¶
Hyperbolic VQ-VAE quantizer bottlenecks for the Poincaré ball. Both take encoder
features (Euclidean tangent vectors at the origin) and return a PoincareVQOutput
with the straight-through quantized vector for the decoder, the discrete code indices,
an auxiliary loss, and the codebook perplexity. The encoder/decoder stay in user code
— these layers are only the quantization step. For when to pick each, see the
NN Layers guide.
Embedding VQ (HVQ-VAE, EMA codebook)¶
The codebook is an nnx.Variable buffer, so nnx.Optimizer(..., wrt=nnx.Param)
ignores it — it moves only through ema_update(z, indices, c), the hyperbolic moving
average of GGBall (Bu et al. 2026). Call ema_update in the train step after
optimizer.update. Set dead_code_revival=True (with a reset_key) to replace stale
codes with random encoder points.
hyperbolix.nn_layers.HypVQEmbeddingPoincare ¶
HypVQEmbeddingPoincare(
manifold_module: Poincare,
num_codes: int,
code_dim: int,
*,
rngs: Rngs,
init_scale: float = 0.001,
commitment_weight: float = 0.5,
squared_commitment: bool = False,
ema_decay: float = 0.99,
dead_code_threshold: float = 1.0,
dead_code_revival: bool = False,
out_dtype: dtype = jnp.float32,
param_dtype: DTypeLike = jnp.float32,
)
Bases: Module
Poincaré-ball vector quantizer with an EMA codebook (HVQ-VAE).
Forward (the lookup of Algorithm 1, copy-gradient STE of §3.2): x --expmap_0--> z (lift encoder tangent vector onto the ball) z --argmin d_D--> k (geodesic nearest code; non-differentiable) q = codebook[k] quantized = x + sg(logmap_0(q) - x) (STE: forward = logmap_0(q), backward flows to the encoder via x)
The codebook is an nnx.Variable buffer, not a parameter: with
nnx.Optimizer(model, tx, wrt=nnx.Param) the optimizer ignores it, and it
receives no gradient (the commitment loss stop-gradients q, the STE
stop-gradients logmap_0(q) - x). It moves only through :meth:ema_update,
which is the hyperbolic EMA of GGBall Eqs. 41-42.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
manifold_module
|
Poincare
|
Class-based Poincaré manifold instance (carries the dtype). |
required |
num_codes
|
int
|
Codebook size |
required |
code_dim
|
int
|
Code / latent dimension |
required |
rngs
|
Rngs
|
RNGs for codebook initialisation. |
required |
init_scale
|
float
|
Std of the zero-centred Gaussian codebook init (default: 1e-3). Tiny so the codes start well inside the ball. |
0.001
|
commitment_weight
|
float
|
Weight on the commitment loss |
0.5
|
squared_commitment
|
bool
|
Use the SQUARED geodesic distance |
False
|
ema_decay
|
float
|
|
0.99
|
dead_code_threshold
|
float
|
Per-code usage below which a code is "stale" and revived (default: 1.0). |
1.0
|
dead_code_revival
|
bool
|
Enable GGBall dead-code revival in :meth: |
False
|
out_dtype
|
dtype
|
Dtype of |
float32
|
param_dtype
|
DTypeLike
|
Storage dtype of the codebook and cluster-size buffers (default:
jnp.float32). Compute precision of manifold operations (forward and
EMA update) is set by |
float32
|
Source code in hyperbolix/nn_layers/poincare_vq.py
__call__ ¶
Quantize encoder tangent vectors x_NC at curvature c.
x_NC is the encoder output in the tangent space at the origin (plain
Euclidean features); c is supplied at call time (learnable-curvature
friendly).
Source code in hyperbolix/nn_layers/poincare_vq.py
ema_update ¶
ema_update(
z_NC: Float[Array, "N C"],
indices_N: Array,
c: float = 1.0,
*,
reset_key: Array | None = None,
) -> Array
In-place hyperbolic EMA codebook update — GGBall Eqs. 41-42.
Call this in the train step after optimizer.update (the codebook is
a buffer the optimizer never touches). Each code is pulled toward the
weighted gyromidpoint μ_j (Eq. 41) of the encoder points assigned to
it this batch, then blended with its old position (Eq. 42, weight
ema_decay = β on the old code). Codes with no assigned points are left
unchanged. When dead_code_revival is set, a per-code usage estimate is
advanced and any code below dead_code_threshold is replaced by a random
encoder point from this batch (needs reset_key).
Pass output.z (lifted encoder points) and output.indices from the
forward pass, and the same c. Mutates self.codebook and
self.cluster_size; returns n_dead (codes revived this step, 0 if
revival is off). All math runs in the manifold dtype.
Source code in hyperbolix/nn_layers/poincare_vq.py
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | |
MLR VQ (HyperVQ, implicit codebook)¶
The codebook is the rows of an internal HypRegressionPoincarePP, trained by plain
optax.adam. Selection is a Gumbel-Softmax straight-through sample over the MLR
scores; putting the STE on the categorical weights (not on z_q) is what lets the
gradient reach the MLR parameters. A deterministic flag (toggled by
model.eval() / model.train()) switches to an argmax MAP estimate at inference.
hyperbolix.nn_layers.HypVQMLRPoincare ¶
HypVQMLRPoincare(
manifold_module: Poincare,
num_codes: int,
code_dim: int,
*,
rngs: Rngs,
clamping_factor: float = 1.0,
smoothing_factor: float = 50.0,
out_dtype: dtype = jnp.float32,
param_dtype: DTypeLike = jnp.float32,
)
Bases: Module
Poincaré-ball vector quantizer with an implicit MLR codebook (HyperVQ).
Quantization-as-classification: a Poincaré MLR (HypRegressionPoincarePP)
scores each lifted token against num_codes hyperplanes, a code is sampled
via Gumbel-Softmax with a straight-through estimator on the categorical
weights, and the quantized vector is z_q = st_weights @ codes where the
implicit codebook is codes = bias · kernel (the MLR's scalar offsets times
its tangent normals). Putting the STE on the weights — not on z_q — is what
lets the gradient reach the MLR parameters; plain optax.adam then trains
them. HyperVQ is reconstruction-only, so output.loss is 0.
The deterministic flag (set by model.eval() / model.train() via
nnx.set_attributes) switches the categorical selection from a noisy
Gumbel-Softmax sample (training) to a deterministic argmax MAP estimate
(inference) — without it, every eval forward pass would sample a different
code and reconstructions would ghost.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
manifold_module
|
Poincare
|
Class-based Poincaré manifold instance (carries the dtype). |
required |
num_codes
|
int
|
Codebook size |
required |
code_dim
|
int
|
Code / latent dimension |
required |
rngs
|
Rngs
|
RNGs for the MLR parameter init. |
required |
clamping_factor
|
float
|
Passed through to |
1.0
|
smoothing_factor
|
float
|
Passed through to |
1.0
|
out_dtype
|
dtype
|
Dtype of |
float32
|
param_dtype
|
DTypeLike
|
Storage dtype of the MLR parameters (default: jnp.float32). Compute
precision of manifold operations is set by |
float32
|
Source code in hyperbolix/nn_layers/poincare_vq.py
__call__ ¶
__call__(
x_NC: Float[Array, "N C"],
c: float = 1.0,
*,
rngs: Rngs | None = None,
) -> PoincareVQOutput
Quantize encoder tangent vectors x_NC at curvature c.
rngs supplies the Gumbel noise and is required while training; it is
unused (and may be None) at eval (deterministic=True).
Source code in hyperbolix/nn_layers/poincare_vq.py
Output type¶
hyperbolix.nn_layers.PoincareVQOutput ¶
Bases: NamedTuple
Result of a Poincaré VQ forward pass (a JAX-pytree friendly tuple).
Attributes:
| Name | Type | Description |
|---|---|---|
quantized |
(Array, shape(N, C))
|
Decoder input — a tangent vector at the origin, cast to |
indices |
(Array, shape(N), int32)
|
Selected code index per token. Feeds perplexity and the EMA update. |
loss |
(Array, scalar)
|
Auxiliary VQ loss to add to the caller's reconstruction loss — the
commitment term for the embedding layer, |
perplexity |
Array, scalar float32
|
Codebook usage perplexity |
z |
(Array, shape(N, C))
|
Encoder points lifted onto the ball ( |
The GGBall weighted gyromidpoint that backs the EMA centroid is a reusable helper —
see poincare_weighted_midpoint under Primitives.
Example¶
import jax, jax.numpy as jnp
from flax import nnx
from hyperbolix.manifolds import Poincare
from hyperbolix.nn_layers import HypVQEmbeddingPoincare
manifold = Poincare(dtype=jnp.float64) # manifold ops in f64; quantized decoder input is f32
h = jax.random.normal(jax.random.PRNGKey(1), (256, 64)) * 0.3 # encoder tangent features
vq = HypVQEmbeddingPoincare(manifold, num_codes=128, code_dim=64, rngs=nnx.Rngs(0),
commitment_weight=0.5, ema_decay=0.99, dead_code_revival=True)
out = vq(h, c=1.0)
print(out.quantized.shape, out.indices.shape, float(out.loss)) # (256,64) (256,) + commitment loss
# In the train step, AFTER optimizer.update(model, grads):
vq.ema_update(out.z, out.indices, c=1.0, reset_key=jax.random.key(7))