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.
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.
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.
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.
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.
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.
Ambos tienen 16 bits (2 bytes), pero los organizan de forma distinta:
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.
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:
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.
Hay dos momentos en los que se puede aplicar cuantización, y tienen propiedades muy distintas:
"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.
"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.
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)}")
# 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")
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))
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.
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 |
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.
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.