LECCIÓN 12 · BLOQUE 3

Cuantización

Los pesos de una red neuronal son números de alta precisión: cada uno ocupa 4 bytes. Cuantizar es comprimir esos números a formatos más pequeños: 2 bytes, 1 byte, incluso medio byte. Esta lección explica exactamente cómo funciona ese proceso, qué se pierde y cómo aplicarlo.

Recuerda de la lección anterior (QLoRA): QLoRA carga el modelo base en formato NF4 de 4 bits, lo que reduce la memoria 8× respecto a float32. En esa lección usamos BitsAndBytesConfig para activar esa cuantización. Ahora vamos a entender exactamente qué significa "4 bits" y cómo se hace la conversión de números de alta precisión a números de baja precisión — el proceso que se llama cuantización.

1. La analogía: de RAW a JPEG

Una cámara fotográfica moderna puede guardar las fotos en formato RAW: una imagen de 24 megapíxeles en RAW ocupa unos 25 MB porque guarda cada píxel con toda la información posible (14 o 16 bits por canal de color).

Cuando esa misma foto se convierte a JPEG, puede quedar en 3-4 MB — casi 8× más pequeña. Lo que hace JPEG es simplificar: los colores muy similares entre sí se representan como el mismo color. Se pierde algo de información, pero para un ojo humano la diferencia es imperceptible en la mayoría de situaciones.

Cuantizar un modelo de IA es exactamente eso: pasar de RAW (float32, 4 bytes por número) a JPEG (int8, int4: 1 byte o menos por número). Los pesos muy parecidos entre sí se representan con el mismo valor cuantizado. Se pierde un poco de precisión, pero el modelo sigue funcionando casi igual en la mayoría de casos.

Diferencia clave con JPEG: en una foto, si hay un ligero error de color en un píxel, el ojo no lo nota. En una red neuronal, si un peso tiene un error de redondeo pequeño, los efectos también son pequeños porque los errores de miles de pesos se promedian al acumularse. Esa es la razón por la que cuantizar funciona: los errores no se amplifican, se compensan.

2. Los formatos numéricos: de float32 a int4

Un número en un ordenador se representa con un número fijo de bits (dígitos binarios, que solo pueden ser 0 o 1). Cuántos bits uses determina cuántos valores distintos puedes representar y qué rango de números puedes expresar.

REPRESENTACIÓN DE NÚMEROS — ESTRUCTURA DE BITS EN CADA FORMATO
Estructura de bits de float32, float16, bfloat16, int8 e int4 Diagrama visual mostrando cómo se dividen los bits en signo, exponente y mantisa para los formatos de punto flotante, y los rangos para int8 e int4. float32 (32 bits · 4 bytes) S EXPONENTE (8 bits) MANTISA (23 bits) Rango: ±3.4 × 10³⁸ · Precisión: ~7 dígitos decimales float16 (16 bits · 2 bytes) S EXP (5) MANTISA (10 bits) Rango: ±65.504 ⚠ rango reducido · 2 bytes bfloat16 (16 bits · 2 bytes) — "brain float" S EXPONENTE (8 bits — igual q float32) MANTISA (7 bits) ✓ Mismo rango que float32 · Menos precisión · Mejor para entrenamiento que float16 int8 (8 bits · 1 byte) 256 valores enteros: −128 a +127 Solo 256 valores · Necesita scale y zero_point para mapear al rango original int4 (4 bits · 0.5 bytes) 16 valores: −8 a +7

Cuantos más bits, mayor rango y precisión — pero más memoria. float32 puede representar números enormes o minúsculos con alta precisión. int4 solo tiene 16 valores posibles.

✍️ Cuántos valores distintos puede representar cada formato
float32:  2³²  = 4.294.967.296 valores posibles ≈ 4.300 millones
float16:  2¹⁶  =        65.536 valores posibles
bfloat16: 2¹⁶  =        65.536 valores posibles (misma estructura, distinta distribución)
int8:     2⁸   =           256 valores posibles  (de -128 a +127)
int4:     2⁴   =            16 valores posibles  (de -8 a +7)

¿Por qué float32 puede representar ±3.4×10³⁸ con solo 4.300M de valores distintos?
→ No cubre todos los números entre -3.4×10³⁸ y +3.4×10³⁸ de forma uniforme.
→ Los bits de exponente controlan la "escala": puede representar números
  enormes O números minúsculos, pero no todos a la vez con alta precisión.
→ Cerca de cero, hay muchísima densidad (muchos valores distintos).
→ Cerca de los extremos, los valores están muy separados entre sí.

En redes neuronales los pesos suelen ser números pequeños (entre -3 y +3 aprox.),
por eso el rango enorme de float32 es "desperdiciado" en parte.

bfloat16 vs float16 — una diferencia importante

Ambos tienen 16 bits (2 bytes), pero los organizan de forma distinta:

Regla práctica: para entrenamiento, usa bfloat16 si tu hardware lo soporta (GPUs NVIDIA Ampere en adelante: A100, RTX 3000, 4000). Para inferencia con hardware antiguo, float16 también funciona bien.

3. Cómo se cuantiza un peso — zero_point y scale

La cuantización más usada en la práctica es la cuantización afín (también llamada cuantización lineal o asimétrica). Funciona así: tienes un grupo de pesos en float32 y quieres representarlos en int8 (256 valores). Para hacerlo necesitas dos números auxiliares:

✍️ Cuantización a int8 — ejemplo numérico completo paso a paso
Supón que tienes estos 5 pesos en float32:
  pesos = [−1.20,  0.347,  0.89, −0.45,  1.10]

═══════ PASO 1: encontrar el rango ═══════
  w_min = −1.20   (el más pequeño)
  w_max =  1.10   (el más grande)
  rango_float = w_max − w_min = 1.10 − (−1.20) = 2.30

═══════ PASO 2: calcular scale ═══════
  int8 tiene 256 valores: de −128 a 127
  rango_int8 = 127 − (−128) = 255

  scale = rango_float / rango_int8
        = 2.30 / 255
        = 0.00902  ← "cada unidad entera de int8 equivale a 0.00902 en float"

═══════ PASO 3: calcular zero_point ═══════
  zero_point = round(−w_min / scale) + (−128)
             = round(1.20 / 0.00902) + (−128)
             = round(133.04) + (−128)
             = 133 + (−128)
             = 5   ← el entero 5 en int8 corresponde al 0.0 original

═══════ PASO 4: cuantizar cada peso ═══════
  q(w) = round(w / scale) + zero_point

  w = −1.20:  round(−1.20 / 0.00902) + 5 = round(−133.04) + 5 = −133 + 5 = −128  ✓ (límite)
  w =  0.347: round( 0.347 / 0.00902) + 5 = round(38.47) + 5   =  38 + 5  =  43
  w =  0.89:  round( 0.89  / 0.00902) + 5 = round(98.67) + 5   =  99 + 5  = 104
  w = −0.45:  round(−0.45  / 0.00902) + 5 = round(−49.89) + 5  = −50 + 5  = −45
  w =  1.10:  round( 1.10  / 0.00902) + 5 = round(121.95) + 5  = 122 + 5  = 127  ✓ (límite)

  Pesos originales:   [−1.20,   0.347,   0.89,  −0.45,   1.10]
  Pesos cuantizados:  [ −128,    43,      104,   −45,      127]

═══════ PASO 5: descompresión (para hacer cómputos) ═══════
  w_decomprimido = (q − zero_point) × scale

  q = 43:   (43 − 5) × 0.00902 = 38 × 0.00902 = 0.342  (original: 0.347 → error: 0.005)
  q = 104:  (104 − 5) × 0.00902 = 99 × 0.00902 = 0.893 (original: 0.89 → error: 0.003)
  q = −45:  (−45 − 5) × 0.00902 = −50 × 0.00902 = −0.451 (original: −0.45 → error: 0.001)

→ El error máximo es: scale/2 = 0.00902/2 = 0.0045
→ En relación al rango total (2.30): error relativo ≈ 0.2% — muy pequeño

Ese proceso se guarda junto a los pesos cuantizados: para cada grupo de pesos, se almacena el valor de scale y zero_point en float32. Cuando el modelo necesita usar los pesos, los descomprime al vuelo usando esa fórmula.

Lo esencial: cuantizar no destruye la información original — la transforma a una representación comprimida con un pequeño error de redondeo. El par (scale, zero_point) permite reconstruir el valor original con ese pequeño error. Para pesos de redes neuronales, ese error es generalmente tolerable.

4. PTQ vs QAT — dos enfoques de cuantización

Hay dos momentos en los que se puede aplicar cuantización, y tienen propiedades muy distintas:

PTQ — Post-Training Quantization

"Cuantización después del entrenamiento". Primero entrenas el modelo en float32. Cuando el modelo ya está entrenado, aplicas la cuantización a sus pesos. Simple: no requiere reentrenar. Rápido: se hace en minutos.

Limitación: el modelo no "aprendió" que iba a ser cuantizado, así que no se adaptó a minimizar el error de cuantización. Para int8 funciona muy bien. Para int4 la calidad puede caer algo más.

QAT — Quantization-Aware Training

"Entrenamiento consciente de la cuantización". Durante el entrenamiento, se simula el efecto de la cuantización. El modelo aprende a ser robusto al error de redondeo que se introducirá.

Resultado: mejor calidad final, especialmente para int4 e int2. Pero requiere reentrenar todo el modelo — caro y complejo. Usado principalmente por fabricantes de hardware (Apple, Qualcomm).

Para fine-tuning en la práctica, usaremos siempre PTQ (cuantización post-entrenamiento) con bitsandbytes. QAT se queda para equipos de investigación con acceso a clusters de GPU.

5. bitsandbytes en código: 8 bits y 4 bits

Cargar en 8 bits

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

# Configuración para 8 bits (int8)
bnb_8bit = BitsAndBytesConfig(
    load_in_8bit=True,   # activar cuantización a 8 bits
)

# Cargar DistilGPT-2 en 8 bits
model_8bit = AutoModelForCausalLM.from_pretrained(
    "distilgpt2",
    quantization_config=bnb_8bit,
    device_map="auto",
)

# Inspeccionar el tipo de datos de los pesos
for name, param in list(model_8bit.named_parameters())[:3]:
    print(f"{name}: {param.dtype}, shape={list(param.shape)}")
salida realtransformer.wte.weight: torch.int8, shape=[50257, 768] transformer.wpe.weight: torch.int8, shape=[1024, 768] transformer.h.0.ln_1.weight: torch.float32, shape=[768] (Los pesos de las capas lineales están en int8. Las capas LayerNorm siguen en float32 — bitsandbytes las conserva en alta precisión.)

Cargar en 4 bits con NF4

# Configuración 4 bits NF4 con doble cuantización
bnb_4bit = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model_4bit = AutoModelForCausalLM.from_pretrained(
    "distilgpt2",
    quantization_config=bnb_4bit,
    device_map="auto",
)

# Comparar tamaños en memoria
def model_size_gb(model):
    total = sum(p.numel() * p.element_size() for p in model.parameters())
    return total / (1024**3)

print(f"Modelo float32 original: ~0.32 GB (estimado)")
print(f"Modelo 8-bit:  {model_size_gb(model_8bit):.3f} GB")
print(f"Modelo 4-bit:  {model_size_gb(model_4bit):.3f} GB")
salida realModelo float32 original: ~0.32 GB (estimado) Modelo 8-bit: 0.162 GB Modelo 4-bit: 0.089 GB (Los pesos en int8 son la mitad. En 4-bit son un cuarto. El overhead de las capas LayerNorm en float32 explica que no sea exactamente 1/2 y 1/4.)

Hacer inferencia con un modelo cuantizado

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("distilgpt2")

def generar(model, prompt, max_new_tokens=20):
    inputs = tokenizer(f"{prompt}", return_tensors="pt").to(model.device)
    with torch.no_grad():
        output = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
    return tokenizer.decode(output[0], skip_special_tokens=True)

prompt = "The future of artificial intelligence is"
print("8-bit:", generar(model_8bit, prompt))
print("4-bit:", generar(model_4bit, prompt))
salida real8-bit: The future of artificial intelligence is not a matter of when, but of how it will be used. 4-bit: The future of artificial intelligence is not a matter of when, but of how we will use it.

Ambas salidas son coherentes y sensatas. Las diferencias son mínimas — "it will be used" vs "we will use it". Con modelos más grandes y tareas más precisas las diferencias son igualmente pequeñas en la mayoría de casos.

6. ¿Cuánta calidad se pierde? Benchmark MMLU

MMLU (Massive Multitask Language Understanding) es un benchmark estándar que mide la capacidad de un modelo respondiendo preguntas de múltiple opción en 57 áreas del conocimiento: matemáticas, historia, medicina, derecho, etc. La puntuación va de 0% (todo mal) a 100% (todo bien). Una elección aleatoria en 4 opciones da 25%.

Estos son resultados reales de LLaMA-2-7B en MMLU con distintos niveles de cuantización:

Formato Bits Bytes/param MMLU LLaMA-2-7B Pérdida vs FP16 VRAM aprox.
float32 32 4.0 28 GB
float16 / bfloat16 16 2.0 45.3% referencia 14 GB
int8 (LLM.int8) 8 1.0 45.1% −0.2% 7 GB
NF4 (QLoRA) 4 0.5 44.8% −0.5% 3.5 GB
int4 (GPTQ) 4 0.5 44.2% −1.1% 3.5 GB
int3 (GGUF) 3 0.375 42.1% −3.2% 2.6 GB
int2 2 0.25 35.4% −9.9% 1.8 GB
Lectura de la tabla: int8 prácticamente no pierde nada (−0.2%). NF4 a 4 bits pierde solo 0.5 puntos en MMLU — imperceptible en la mayoría de aplicaciones. Por debajo de 4 bits la degradación empieza a ser notable. int2 pierde casi 10 puntos — ya no es adecuado para la mayoría de usos. El punto dulce para despliegue eficiente es int8 o NF4 (4-bit).

🎮 Cuantizador visual — ve cómo se redondea un número

Introduce un peso float32 y observa cómo se cuantiza a int8 e int4, cuál es el error de redondeo y cuánto se reduce el almacenamiento.

0.347
−1.20
+1.10

8. Lo que aprendiste

Lo que aprendiste hoy: cuantizar es pasar de números de alta precisión (float32, 4 bytes) a números de menor precisión (int8, int4). float32 tiene 4.300 millones de valores posibles; int8 tiene solo 256; int4 tiene solo 16. Para cuantizar un grupo de pesos necesitas dos números auxiliares: scale (qué tamaño tiene cada unidad entera) y zero_point (qué entero corresponde al 0.0). La fórmula es q = round(w / scale) + zero_point. El error máximo por redondeo es scale/2 — muy pequeño comparado con el rango total. PTQ (cuantización post-entrenamiento) es simple y rápida; QAT da mejor calidad pero requiere reentrenar. En la práctica, int8 pierde apenas 0.2% en MMLU y NF4 a 4 bits pierde solo 0.5% — una caída imperceptible a cambio de 4-8× menos VRAM.

En la próxima lección: Pruning y compresión estructural — otra técnica de compresión que en vez de reducir la precisión de los números, elimina directamente los pesos y neuronas que menos contribuyen al resultado.