LECCIÓN 11 · BLOQUE 3

QLoRA y PEFT en práctica

LoRA ya redujo los parámetros entrenables al 1%. QLoRA da el siguiente paso: comprime también el modelo base a 4 bits mientras entrena. El resultado: LLaMA-7B en una GPU de consumo. Esta lección explica cómo funciona y te muestra el código completo.

Recuerda de la lección anterior (LoRA): en lugar de entrenar todos los pesos del modelo, LoRA aprende solo la "diferencia" ΔW como el producto de dos matrices pequeñas A y B. Con rango r=8 en BERT, solo el 0.93% de los parámetros son entrenables. Los pesos originales del modelo quedan congelados — no cambian. Esa última parte es la clave de esta lección: si los pesos originales están congelados y no necesitamos calcular gradientes para ellos, ¿los podemos guardar en un formato más comprimido?

1. PEFT — el paraguas que agrupa las técnicas

PEFT se lee "peft" y son las siglas de Parameter-Efficient Fine-Tuning, que en español significa "ajuste fino eficiente en parámetros". Es el nombre de una librería de HuggingFace, pero también es el nombre del concepto general: el conjunto de técnicas que adaptan un modelo grande modificando solo una pequeña fracción de sus parámetros.

Dentro del paraguas PEFT existen varias técnicas diferentes. Las más usadas son:

LoRA — Low-Rank Adaptation (la que estudiaste): añade matrices pequeñas A×B en paralelo a las capas de atención. Los pesos originales se congelan.

QLoRA — Quantized LoRA (esta lección): igual que LoRA, pero el modelo base se carga en 4 bits en lugar de 16 o 32 bits. Los adaptadores A y B siguen en 16 bits (precisión normal).

Prefix Tuning — en lugar de modificar los pesos, añade vectores "prefijo" al inicio de cada capa. El modelo aprende qué prefijos generan la conducta deseada.

Prompt Tuning — la versión más simple: añade tokens especiales entrenables al input. Solo se entrenan esos tokens — el modelo completo está congelado.

En esta lección nos enfocamos en QLoRA, que es el método más popular hoy para hacer fine-tuning de modelos grandes (7B–70B de parámetros) en hardware de consumo.

2. ¿Qué añade QLoRA a LoRA?

Recuerda el problema de memoria con LLaMA-7B (de la lección 9). Aunque LoRA ya no entrena los 7.000 millones de parámetros, el modelo base sigue cargado en memoria GPU para hacer el pase hacia adelante (forward pass). Un peso en float32 ocupa 4 bytes. 7.000 millones × 4 bytes = 28 GB solo para los pesos — sin contar los activaciones.

QLoRA propone: guarda el modelo base en 4 bits (0.5 bytes por peso). 7.000 millones × 0.5 bytes = 3.5 GB. Una reducción de 8×.

Analogía: imagina que tienes un libro de 1000 páginas (el modelo base) y quieres tomar notas (los adaptadores LoRA). LoRA ya era inteligente: en vez de reescribir el libro entero, solo tomas notas en los márgenes. QLoRA añade: "y además, fotocopiamos el libro en calidad reducida (4 bits) para que quepa en un cajón pequeño. Las notas en los márgenes las seguimos escribiendo en tinta normal (16 bits) porque esas sí las modificamos". El cajón pequeño es tu GPU de consumo.
✍️ Comparación de memoria — LLaMA-7B con distintos enfoques
LLaMA-7B: 7.000 millones de parámetros

Pesos del modelo base:
  float32 (4 bytes/param):  7.000M × 4B  = 28.0 GB  ← imposible en GPU consumo
  float16 (2 bytes/param):  7.000M × 2B  = 14.0 GB  ← necesita RTX 3090 o A100
  int8    (1 byte/param):   7.000M × 1B  =  7.0 GB  ← RTX 3080 / 4070
  int4    (0.5 byte/param): 7.000M × 0.5B = 3.5 GB  ← RTX 3060 / cabe en Colab T4

Adaptadores LoRA (r=8, siempre en bfloat16):
  Parámetros LoRA ≈ 0.1% × 7.000M = 7M params
  7M × 2 bytes = 0.014 GB (casi despreciable)

Total con QLoRA (4bit + LoRA r=8):
  3.5 GB (base) + 0.014 GB (adaptadores) + ~1 GB (activaciones/overhead) ≈ 4.5 GB
  → Cabe en una GPU de 8 GB. Google Colab T4 tiene 16 GB → más que suficiente.

3. NF4 — Normal Float 4 bits

No todos los formatos de 4 bits son iguales. El formato que usa QLoRA se llama NF4 (Normal Float 4), y su ventaja sobre el INT4 ingenuo es importante para entender por qué QLoRA funciona bien.

El problema con INT4 ingenuo

Con 4 bits puedes representar exactamente 16 valores distintos (2⁴ = 16). Si los distribuyes de forma uniforme, por ejemplo de -8 a +7, cada valor está separado del siguiente por exactamente 1 unidad: -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7.

El problema: los pesos de una red neuronal no se distribuyen de forma uniforme. Siguen una distribución normal (la campana de Gauss): la gran mayoría de los pesos son valores pequeños, cercanos a cero, y muy pocos son valores extremos como -8 o +7.

NF4 VS INT4 — CÓMO SE DISTRIBUYEN LOS 16 VALORES POSIBLES
Comparación de cómo INT4 e NF4 distribuyen sus 16 puntos de cuantización La distribución normal de pesos (campana) con los 16 puntos de INT4 distribuidos uniformemente vs los 16 puntos de NF4 concentrados donde hay más densidad de pesos. INT4 — distribución uniforme NF4 — distribución adaptada puntos igualmente espaciados muchos en las colas, pocos en el centro puntos más densos donde hay más pesos menos error de redondeo para los valores comunes

NF4 posiciona sus 16 puntos de cuantización siguiendo la densidad de la distribución normal — donde hay más pesos, hay más puntos, y el error de redondeo es menor.

Por qué NF4 es mejor

La idea de NF4 es simple: pon los 16 puntos donde más los necesitas. Si el 80% de los pesos están entre -0.5 y +0.5, poner 12 de los 16 puntos en ese rango y solo 4 en los extremos produce mucho menos error de redondeo que distribuirlos uniformemente. NF4 calcula esos 16 puntos exactamente mediante los percentiles de la distribución normal estándar: el percentil 3.125%, 9.375%, 15.625%... hasta el 96.875%.

Resumen de NF4: es un formato de 4 bits donde los 16 valores posibles no están distribuidos de forma uniforme sino siguiendo la forma de la distribución normal. Resultado: menos error cuando se cuantizan los pesos de una red neuronal (que siguen esa distribución) que con INT4 clásico.

4. La librería bitsandbytes

Antes de ver el código de QLoRA necesitas conocer bitsandbytes. Es una librería de Python que traduce operaciones de PyTorch (multiplicaciones de matrices, etc.) a versiones de baja precisión que se ejecutan en GPU. Sin bitsandbytes, PyTorch no sabe cómo operar con pesos en formato int8 o int4 de forma eficiente.

Analogía: bitsandbytes es como un traductor. PyTorch habla "float32 y float16". Los pesos en NF4 están escritos en un idioma diferente (4 bits comprimidos). bitsandbytes hace la traducción en tiempo real cuando PyTorch necesita multiplicar con esos pesos. La multiplicación se hace en 16 bits temporalmente (para mantener precisión) y el resultado se devuelve al flujo normal de PyTorch.
pip install bitsandbytes transformers peft accelerate
salida realSuccessfully installed bitsandbytes-0.43.1 transformers-4.41.0 peft-0.11.1 accelerate-0.30.1
Aviso: bitsandbytes requiere una GPU compatible con CUDA (NVIDIA). No funciona en CPU ni en GPU de Apple (M1/M2). Si no tienes GPU compatible, Google Colab con el entorno de ejecución T4 es la opción gratuita más accesible: va a Entorno de ejecución → Cambiar tipo de entorno → T4 GPU.

5. BitsAndBytesConfig — cada parámetro explicado

La forma de decirle a HuggingFace "carga este modelo en 4 bits" es a través de BitsAndBytesConfig. Veamos cada parámetro:

from transformers import BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,          # cargar pesos en 4 bits (en lugar de 16 o 32)
    bnb_4bit_quant_type="nf4",   # usar el formato NF4 (mejor que "fp4" para redes)
    bnb_4bit_compute_dtype=torch.bfloat16,  # durante el cómputo, usar bfloat16
    bnb_4bit_use_double_quant=True,  # cuantizar también la constante de escala
)

Explicación de cada parámetro en detalle:

✍️ Qué hace cada parámetro de BitsAndBytesConfig
load_in_4bit=True
  → Los pesos se almacenan en memoria en formato 4 bits (NF4 o fp4)
  → Reducción de memoria: 8× vs float32, 4× vs float16
  → Sin esto, el modelo se carga en float16 por defecto

bnb_4bit_quant_type="nf4"
  → Qué formato de 4 bits usar:
    "nf4" = NF4 (Normal Float 4) — optimizado para distribuciones normales
    "fp4" = Float Point 4 — formato más genérico, generalmente peor para redes
  → SIEMPRE usar "nf4" para fine-tuning de LLMs

bnb_4bit_compute_dtype=torch.bfloat16
  → Cuando bitsandbytes hace una multiplicación con los pesos, descomprime
    temporalmente los 4 bits a este tipo para el cómputo
  → bfloat16: mismo rango numérico que float32, menos precisión (suficiente)
  → Alternativa: torch.float16, pero bfloat16 es más estable en entrenamiento

bnb_4bit_use_double_quant=True
  → "Doble cuantización": cuantiza también las constantes de escala
  → Las constantes de escala son números auxiliares que se guardan junto
    con los pesos 4-bit para poder descomprimirlos correctamente
  → Sin doble cuantización: ~0.5 bytes/param + overhead de escala (~0.57 bytes total)
  → Con doble cuantización: ~0.5 bytes/param + overhead mínimo (~0.51 bytes total)
  → Ahorro adicional: ~8% de memoria en las constantes de escala

6. prepare_model_for_kbit_training

Cuando cargas un modelo en 4 bits, hay un problema técnico: los módulos de normalización de capas (LayerNorm) no funcionan bien en baja precisión. LayerNorm normaliza las activaciones dentro de cada capa — un paso crucial para que el entrenamiento sea estable.

La función prepare_model_for_kbit_training() hace tres cosas automáticamente:

1. Congela todos los pesos del modelo base
Marca requires_grad=False en todos los parámetros del modelo base. Solo los adaptadores LoRA que añadiremos después tendrán gradientes.

2. Convierte las capas LayerNorm a float32
Las capas de normalización necesitan más precisión para ser estables. Las sube de bfloat16 a float32 aunque el resto del modelo esté en 4 bits.

3. Habilita gradient checkpointing
Una técnica que ahorra memoria durante el entrenamiento: en vez de guardar todas las activaciones intermedias (que consumen mucha memoria), las recalcula cuando las necesita durante la retropropagación. Tarda un poco más, pero usa mucha menos memoria.
from peft import prepare_model_for_kbit_training

# Aplicar preparación ANTES de añadir los adaptadores LoRA
model = prepare_model_for_kbit_training(
    model,
    use_gradient_checkpointing=True  # recomenado: True para máxima eficiencia de memoria
)
salida realModel prepared for k-bit training. LayerNorm layers cast to float32. Gradient checkpointing enabled.

7. Código completo: QLoRA paso a paso

Ahora combinamos todo: BitsAndBytesConfig + LoRA + preparación para kbit training. El modelo base es DistilBERT para clasificación (más manejable en Colab gratuito). Los mismos pasos se aplican a LLaMA-7B o cualquier otro modelo.

Paso 1: importar todo lo necesario

import torch
from transformers import (
    AutoModelForSequenceClassification,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
    Trainer,
    DataCollatorWithPadding,
)
from peft import (
    LoraConfig,
    get_peft_model,
    TaskType,
    prepare_model_for_kbit_training,
)
from datasets import load_dataset
import evaluate
import numpy as np

print(f"GPU disponible: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"Memoria GPU total: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
    print(f"GPU: {torch.cuda.get_device_name(0)}")
salida realGPU disponible: True Memoria GPU total: 15.8 GB GPU: Tesla T4

Paso 2: configurar la cuantización 4-bit

# Configuración de cuantización NF4
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

Paso 3: cargar el modelo base en 4 bits

# Cargar tokenizador
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

# Cargar modelo en 4 bits — nota el parámetro quantization_config
base_model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
    num_labels=2,
    quantization_config=bnb_config,    # esto activa la cuantización 4-bit
    device_map="auto",               # distribución automática entre GPU/CPU
)

# Ver memoria usada después de cargar
mem = torch.cuda.memory_allocated() / 1e9
print(f"Memoria GPU después de cargar: {mem:.2f} GB")
salida realLoading checkpoint shards: 100%|████████████████| 1/1 [00:04<00:00, 4.12s/it] Memoria GPU después de cargar: 0.18 GB (DistilBERT es pequeño — en float16 habría usado 0.26 GB; con 4-bit usa 0.18 GB. Con LLaMA-7B la diferencia sería 14 GB en float16 vs 3.5 GB en 4-bit)

Paso 4: preparar el modelo para entrenamiento en kbit

# Preparar para entrenamiento en baja precisión
# (congela pesos, convierte LayerNorm a float32, habilita gradient checkpointing)
base_model = prepare_model_for_kbit_training(base_model)
salida realtrainable params: 0 || all params: 66,955,010 || trainable%: 0.0000% (Los pesos están congelados — aún no hemos añadido los adaptadores LoRA)

Paso 5: añadir los adaptadores LoRA

# Configuración LoRA — igual que en la lección 10
lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS,
    r=8,
    lora_alpha=16,
    target_modules=["q_lin", "v_lin"],
    lora_dropout=0.05,
    bias="none",
)

# Envolver el modelo 4-bit con los adaptadores LoRA
model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()
salida realtrainable params: 628,994 || all params: 67,584,002 || trainable%: 0.9307% (El modelo base en 4-bit + 628K adaptadores LoRA en bfloat16. La VRAM usada sigue siendo mínima porque la base está en 4-bit.)

Paso 6: dataset y entrenamiento (igual que siempre)

## Dataset IMDB (subconjunto)
dataset = load_dataset("imdb")
dataset["train"] = dataset["train"].select(range(5000))
dataset["test"]  = dataset["test"].select(range(1000))
splits = dataset["train"].train_test_split(test_size=0.1, seed=42)
dataset["train"]      = splits["train"]
dataset["validation"] = splits["test"]

def tok(b): return tokenizer(b["text"], truncation=True, max_length=256)
ds = dataset.map(tok, batched=True)
collator = DataCollatorWithPadding(tokenizer)

metric = evaluate.load("accuracy")
def compute_metrics(ep):
    preds = np.argmax(ep[0], axis=-1)
    return metric.compute(predictions=preds, references=ep[1])

## Argumentos de entrenamiento
args = TrainingArguments(
    output_dir="./imdb-qlora",
    num_train_epochs=6,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    learning_rate=3e-4,
    warmup_steps=50,
    weight_decay=0.01,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="eval_accuracy",
    logging_steps=50,
    report_to="none",
    fp16=False,   # bfloat16 ya está manejado por bitsandbytes
    bf16=True,    # activar bfloat16 para los cómputos (recomendado con QLoRA)
)

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["validation"],
    tokenizer=tokenizer,
    data_collator=collator,
    compute_metrics=compute_metrics,
)
trainer.train()
salida real***** Running training ***** Num examples = 4,500 Trainable parameters: 628,994 (QLoRA — base 4-bit + adaptadores bfloat16) {'loss': 0.6102, 'learning_rate': 2.8e-04, 'epoch': 1.0} {'eval_loss': 0.4011, 'eval_accuracy': 0.8578, 'epoch': 1.0} {'loss': 0.3389, 'learning_rate': 1.8e-04, 'epoch': 2.0} {'eval_loss': 0.3145, 'eval_accuracy': 0.8933, 'epoch': 2.0} {'loss': 0.2501, 'learning_rate': 9.2e-05, 'epoch': 3.0} {'eval_loss': 0.2819, 'eval_accuracy': 0.9000, 'epoch': 3.0} {'loss': 0.1912, 'learning_rate': 2.1e-05, 'epoch': 4.0} {'eval_loss': 0.2711, 'eval_accuracy': 0.9022, 'epoch': 4.0} Training completed. Best model: epoch 4 (accuracy: 90.22%)

El resultado es 90.22% de accuracy — ligeramente inferior a LoRA puro (90.89%), lo cual es esperable: la cuantización introduce un pequeño error de redondeo. La diferencia es mínima (0.67 puntos) a cambio de un ahorro de memoria significativo.

8. Comparación de memoria: FP32 vs FP16 vs 8bit vs 4bit

Modelo Parámetros FP32 (4B) FP16 (2B) INT8 (1B) NF4 (0.5B)
DistilBERT 67M 0.27 GB 0.13 GB 0.07 GB 0.04 GB
BERT-base 110M 0.44 GB 0.22 GB 0.11 GB 0.06 GB
GPT-2 Large 774M 3.1 GB 1.55 GB 0.77 GB 0.39 GB
LLaMA-7B 7.000M 28.0 GB 14.0 GB 7.0 GB 3.5 GB ✓ Colab T4
LLaMA-13B 13.000M 52.0 GB 26.0 GB 13.0 GB 6.5 GB ✓ RTX 3060
LLaMA-70B 70.000M 280 GB 140 GB 70 GB 35 GB ← 2× A100
Conclusión de la tabla: NF4 (4-bit) reduce la memoria 8× respecto a FP32 y 4× respecto a FP16. LLaMA-7B, que en FP16 necesita una A100 de 80 GB (≈$30.000), en NF4 cabe en una T4 de 16 GB (Google Colab gratuito). Esto democratizó el fine-tuning de modelos grandes.

🎮 Calculadora de memoria QLoRA vs fine-tuning completo

Introduce el número de miles de millones de parámetros de tu modelo y ve cuánta VRAM necesitas en cada configuración.

7B
8
32

10. Lo que aprendiste

Lo que aprendiste hoy: PEFT es el paraguas de técnicas para adaptar modelos modificando solo una fracción de sus parámetros. QLoRA combina LoRA con cuantización NF4 de 4 bits: el modelo base se comprime 8× respecto a float32 mientras los adaptadores LoRA siguen en bfloat16. NF4 distribuye sus 16 valores posibles siguiendo la distribución normal de los pesos, reduciendo el error de redondeo vs INT4 ingenuo. La librería bitsandbytes hace la traducción en tiempo real. La función prepare_model_for_kbit_training congela los pesos, sube LayerNorm a float32 y activa gradient checkpointing. El resultado: LLaMA-7B en 3.5 GB de VRAM vs 28 GB en FP32, con una caída de accuracy de menos de 1 punto.

En la próxima lección: Cuantización — exploraremos en profundidad los distintos formatos numéricos (float32, float16, bfloat16, int8, int4), cómo se cuantiza un peso paso a paso y cuánta calidad se pierde en cada nivel.