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.
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:
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.
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×.
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.
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.
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 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.
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%.
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.
pip install bitsandbytes transformers peft accelerate
Entorno de ejecución → Cambiar tipo de entorno → T4 GPU.
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:
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
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:
requires_grad=False en todos los parámetros del modelo base.
Solo los adaptadores LoRA que añadiremos después tendrán gradientes.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
)
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.
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)}")
# 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,
)
# 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")
# 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)
# 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()
## 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()
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.
| 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 |
Introduce el número de miles de millones de parámetros de tu modelo y ve cuánta VRAM necesitas en cada configuración.
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.