Source code for gigl.common.utils.feature_quantization.numpy_ops
"""NumPy feature quantization helpers for preprocessing.
Quantization runs in the data preprocessor, where feature data is stored as CPU
arrays and torch is not available. Dequantization lives in torch_ops.py because
the dataloader collate path operates on torch tensors that may already be on GPU.
"""
import numpy as np
from jaxtyping import Float, UInt8
from gigl.common.utils.feature_quantization import SUPPORTED_QUANTIZATION_BITS
[docs]
def quantize_ndarray(
features: Float[np.ndarray, "entities feature_dim"],
*,
bits: int,
clip_min: float | None = None,
clip_max: float | None = None,
) -> UInt8[np.ndarray, "entities packed_feature_dim"]:
"""Quantize a 2D float array into packed uint8 codes.
For multi-bit quantization, `clip_min` and `clip_max` are required and
define the min-max scaling range: values are clipped to that range, scaled
to `[0, 2**bits - 1]`, rounded to integer codes, then packed into bytes.
"""
if bits not in SUPPORTED_QUANTIZATION_BITS:
raise ValueError(
f"bits must be one of {SUPPORTED_QUANTIZATION_BITS}, got {bits}"
)
if features.ndim != 2:
raise ValueError(f"Expected a 2D feature array, got shape {features.shape}.")
if not np.isfinite(features).all():
raise ValueError("features must be finite; got NaN or Inf")
if bits == 1:
# 1-bit quantization keeps only sign; values restore from neg/pos means.
codes = (features > 0).astype(np.uint8)
else:
# Min-max scale using clipped values and map to integer buckets.
if clip_min is None or clip_max is None:
raise ValueError(f"{bits}-bit quantization requires clip_min/clip_max")
levels = (1 << bits) - 1
clipped = np.clip(features, clip_min, clip_max)
scaled = (clipped - clip_min) / (clip_max - clip_min)
codes = np.rint(scaled * levels).astype(np.uint8)
return _pack_codes(codes, bits)
def _pack_codes(codes: np.ndarray, bits: int) -> np.ndarray:
"""Pack low-bit feature codes high-bits-first along the final dimension."""
per_byte = 8 // bits
pad = (-codes.shape[-1]) % per_byte
if pad:
# Pad only the feature dimension of this 2D [row, feature] array.
codes = np.pad(codes, ((0, 0), (0, pad)), constant_values=0)
# Group the padded feature dimension into chunks that each form one byte.
# Valid bit widths pack exactly one byte per group, so the final sum is at
# most 255. uint16 is a conservative arithmetic dtype that avoids relying on
# NumPy's uint8 accumulator behavior before the final uint8 cast.
codes = codes.reshape(codes.shape[0], -1, per_byte).astype(np.uint16)
shifts = bits * np.arange(per_byte - 1, -1, -1, dtype=np.uint16)
weights = (1 << shifts).astype(np.uint16)
return np.sum(codes * weights, axis=-1).astype(np.uint8)