LECCIÓN 10 · BLOQUE 3

LoRA — Low-Rank Adaptation

En vez de cambiar los 7.000 millones de pesos de LLaMA, LoRA aprende solo la "diferencia" necesaria usando dos matrices pequeñas. El resultado: el 1% de los parámetros, el 95% del rendimiento. Esta lección lo explica desde la geometría hasta el código.

Recuerda de la lección anterior: calculamos que fine-tuning completo de LLaMA-7B necesita ~113 GB de VRAM — inaccesible para la mayoría. La motivación para PEFT es que el cambio que el modelo necesita para adaptarse a una tarea específica es de "bajo rango": matemáticamente pequeño en relación al tamaño del modelo. LoRA explota exactamente esa propiedad.

1. La intuición: no cambiar W, aprender el cambio

En fine-tuning completo, empezamos con los pesos del modelo preentrenado W y los modificamos directamente durante el entrenamiento. Al final, los pesos del modelo han cambiado de W a W_nuevo.

La diferencia entre el modelo original y el adaptado la podemos llamar ΔW (delta W — "el cambio en W"). Matemáticamente:

W_nuevo = W_original + ΔW

En fine-tuning completo, ΔW es una matriz del mismo tamaño que W — para una capa de atención 768×768 en BERT, ΔW también sería de 768×768 = 589.824 parámetros.

LoRA dice: "en la práctica, ΔW tiene rango bajo". Esto significa que, aunque ΔW sea una matriz de 768×768, el cambio real que importa vive en un espacio de dimensión mucho menor. Y si ΔW tiene rango bajo, podemos aproximarlo como el producto de dos matrices mucho más pequeñas:

ΔW ≈ A × B

Donde A tiene dimensiones d × r y B tiene dimensiones r × d, siendo r (el rango) un número mucho menor que d. Para BERT (d=768) con rango r=4: A sería 768×4 y B sería 4×768.

2. Conexión con el curso de álgebra lineal

En el curso de álgebra lineal estudiaste que el rango de una matriz es el número de filas (o columnas) linealmente independientes. Una matriz de "rango bajo" es una que puede expresarse como combinación de pocas columnas base.

Más concretamente: una matriz de tamaño m × n con rango r puede expresarse siempre como el producto de una matriz m × r y otra r × n. Esto es la esencia de la descomposición de valor singular (SVD) — si un estudiante tiene el rango de esa lección frescos, sabrá que SVD factoriza W = U × Σ × V^T donde Σ es diagonal con los valores singulares.

Analogía concreta: imagina una matriz 100×100 = 10.000 números. Si tiene rango 4, eso quiere decir que todos sus 10.000 números son combinaciones lineales de solo 4 "patrones base". Por tanto, puedes guardar esos 4 patrones (100×4 = 400 números) en vez de los 10.000 originales. 4% de la memoria para guardar el mismo contenido esencial.
✍️ El rango bajo explicado con una matriz pequeña
Matriz W (3×3), ¿cuál es su rango?
  W = [2  4  6]
      [1  2  3]
      [3  6  9]

Fila 1: [2, 4, 6] = 2 × [1, 2, 3]
Fila 2: [1, 2, 3] = 1 × [1, 2, 3]
Fila 3: [3, 6, 9] = 3 × [1, 2, 3]

¡Todas las filas son múltiplos de [1, 2, 3]! → rango = 1

Factorización de rango 1 (A × B):
  A = [[2], [1], [3]]  (columna de 3×1)
  B = [[1, 2, 3]]      (fila de 1×3)

  A × B = [2×1  2×2  2×3]   =   [2  4  6]
          [1×1  1×2  1×3]       [1  2  3]
          [3×1  3×2  3×3]       [3  6  9]  ✓

Parámetros originales: 3×3 = 9
Parámetros en A×B: 3×1 + 1×3 = 6 (ahorro del 33% — y con r=1)
Con r más pequeño que la dimensión, el ahorro escala mucho más.

3. El rango r — cuánto "espacio de adaptación" necesitas

El rango r (también escrito como lora_rank) es el hiperparámetro más importante de LoRA. Controla cuánta capacidad de adaptación tiene el modelo: cuán grande es el "módulo de aprendizaje" que añades.

✍️ Cálculo de parámetros con distintos valores de r — para una capa W_Q de BERT (768×768)
W_Q en BERT: dimensiones 768 × 768 = 589.824 parámetros (fine-tuning completo)

Con LoRA, añadimos A (768 × r) y B (r × 768):
  Parámetros de A = 768 × r
  Parámetros de B = r × 768
  Total LoRA      = 2 × 768 × r

Calculemos para distintos valores de r:

r = 1:   2 × 768 × 1   =   1.536 params   →  0.26% del original
r = 4:   2 × 768 × 4   =   6.144 params   →  1.04% del original
r = 8:   2 × 768 × 8   =  12.288 params   →  2.08% del original
r = 16:  2 × 768 × 16  =  24.576 params   →  4.17% del original
r = 32:  2 × 768 × 32  =  49.152 params   →  8.33% del original
r = 64:  2 × 768 × 64  =  98.304 params   → 16.67% del original

→ Con r=4 o r=8, usamos solo el 1-2% de los parámetros originales.
→ En la práctica, r=4 u r=8 suele ser suficiente para la mayoría de tareas.
→ Tareas muy específicas o complejas pueden necesitar r=16 o r=32.
→ r > 64 raramente aporta beneficio.

La elección del rango afecta directamente la memoria y la calidad:

4. La estructura de LoRA durante el entrenamiento

ESTRUCTURA DE LORA — PESOS CONGELADOS + ADAPTADORES
Diagrama de LoRA mostrando la capa original congelada y las matrices A y B añadidas en paralelo La entrada x fluye por dos caminos en paralelo: la capa original W congelada y las matrices pequeñas A y B. Ambas salidas se suman y se escalan por alpha/r. x (d dims) W d × d params 🔒 FIJO A d × r params ✏️ TRAIN B r × d params ✏️ TRAIN × α/r + h (salida) h = W·x + (α/r) · B·(A·x) Los gradientes solo fluyen por A y B — W nunca se actualiza

W (el modelo original, miles de millones de parámetros) está congelado. Solo A y B (miles de parámetros) se entrenan. La salida combina ambas ramas.

5. Qué matrices se adaptan — W_Q, W_K, W_V, W_O

En el paper original de LoRA, los autores aplicaron los adaptadores a las matrices de proyección en el mecanismo de atención del Transformer. Del curso de Transformer recuerdas que la atención multi-cabeza tiene cuatro matrices de proyección:

✍️ Las matrices de atención y su tamaño en BERT (768 dims)
En cada bloque Transformer, la atención multi-cabeza tiene:

  W_Q (Query):   768 × 768 = 589.824 params  → proyecta x a queries
  W_K (Key):     768 × 768 = 589.824 params  → proyecta x a keys
  W_V (Value):   768 × 768 = 589.824 params  → proyecta x a values
  W_O (Output):  768 × 768 = 589.824 params  → combina las cabezas

  Total por bloque de atención: 4 × 589.824 = 2.359.296 params

  BERT-base tiene 12 bloques → 12 × 2.359.296 = 28.311.552 params
  (42% de los 66M parámetros de BERT son solo estas matrices)

Con LoRA r=8, adaptando W_Q y W_V (las más importantes):
  Por bloque: 2 × (2 × 768 × 8) = 2 × 12.288 = 24.576 params
  Total 12 bloques: 12 × 24.576 = 294.912 params entrenables

  Parámetros LoRA / Parámetros totales = 294.912 / 66.000.000 = 0.45%
  ¡Menos del 0.5% de los parámetros originales!

En la librería PEFT, el parámetro target_modules especifica qué matrices adaptar. El valor típico para Transformers de tipo BERT es ["query", "value"] o ["q_proj", "v_proj"] dependiendo de la nomenclatura interna del modelo.

6. El factor de escala α (lora_alpha)

En el diagrama de la sección 4 viste que la contribución de A × B se escala por α/r antes de sumarse a la salida de W. Aquí α (la letra griega alfa, que se lee "alfa") es un hiperparámetro llamado lora_alpha en la librería PEFT.

La fórmula completa es:

h = W · x + (α / r) · B · A · x

El factor α/r controla cuánto "pesan" los adaptadores respecto a los pesos originales. En la práctica:

✍️ Por qué existe lora_alpha — intuición y valores prácticos
Sin factor de escala, la contribución de A×B crece con r:
  Si r=4: A×B es 768×4 × 4×768 → produce salidas de tamaño 768×768
  Si r=8: A×B es 768×8 × 8×768 → produce salidas de tamaño 768×768
  → El tamaño de la salida es igual, pero la "magnitud" de los valores
    tiende a ser mayor con r más grande (más parámetros, más suma).

El factor α/r normaliza esto:
  Con r=8 y alpha=16: escala = 16/8 = 2.0
  Con r=4 y alpha=16: escala = 16/4 = 4.0
  Con r=16 y alpha=16: escala = 16/16 = 1.0

Convención más común: alpha = 2 × r
  r=4  → alpha=8   → escala = 8/4 = 2.0
  r=8  → alpha=16  → escala = 16/8 = 2.0
  r=16 → alpha=32  → escala = 32/16 = 2.0
  (escala constante independiente de r — más fácil de comparar experimentos)

Otra convención: alpha = r (escala = 1.0 — sin amplificación)

¿Qué valor usar?
  → alpha = r es el más conservador (el paper original lo usa)
  → alpha = 2×r da un poco más de fuerza a los adaptadores
  → Experimentar entre r y 2r es lo habitual
Regla práctica: empieza con lora_r=8 y lora_alpha=16 (el doble). Si el modelo no converge o converge muy lento, prueba lora_alpha=8 (igual al rango). Si la tarea es muy específica o distinta del preentrenamiento, prueba lora_r=16.

7. Cómo se inicializan A y B (detalle importante)

La inicialización de las matrices A y B no es arbitraria — es crucial para que LoRA funcione sin perturbar el modelo al inicio del entrenamiento.

✍️ La inicialización garantiza que ΔW = 0 al inicio
Al inicio del fine-tuning, queremos que la contribución de LoRA sea CERO:
  h = W·x + (α/r)·B·A·x
  Si B·A = 0 al inicio → h = W·x (solo el modelo original)

¿Cómo garantizar B·A = 0?
  · B se inicializa en todos ceros (B = 0) → B·A = 0 ✓
  · A se inicializa con valores aleatorios pequeños (distribución Gaussiana)

¿Por qué A aleatoria y no también cero?
  Si tanto A como B fueran cero, el gradiente de A sería cero y nunca aprendería.
  Con A aleatoria, el gradiente de A es no-nulo y puede aprender desde el primer paso.

Durante el entrenamiento:
  · Paso 1: B=0, A=aleatorio → ΔW=0 (igual al modelo original)
  · Paso 2: backward pass actualiza B y A
  · B deja de ser cero, empieza a desarrollar estructura
  · A también cambia ligeramente
  · Gradualmente, A×B representa el cambio específico para la tarea

8. Código real con la librería PEFT

La librería peft de HuggingFace implementa LoRA (y otras técnicas PEFT) con muy poco código. Veamos el flujo completo para clasificación de sentimiento con DistilBERT.

Paso 1: instalar PEFT

pip install peft transformers datasets evaluate accelerate
salida realSuccessfully installed peft-0.11.1 transformers-4.41.0 ...

Paso 2: configurar LoRA

from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForSequenceClassification

# Cargar el modelo base (sin LoRA todavía)
base_model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
    num_labels=2
)

# Configuración de LoRA
lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS,    # SEQ_CLS = clasificación de secuencia
    r=8,                          # rango (el hiperparámetro más importante)
    lora_alpha=16,                # factor de escala α
    target_modules=["q_lin", "v_lin"],  # matrices a adaptar en DistilBERT
    lora_dropout=0.1,             # dropout en los adaptadores (regularización)
    bias="none",                  # no adaptar los biases
)

# Envolver el modelo base con los adaptadores LoRA
model = get_peft_model(base_model, lora_config)

# Ver cuántos parámetros son entrenables vs congelados
model.print_trainable_parameters()
salida realtrainable params: 628,994 || all params: 67,583,494 || trainable%: 0.9307% (Solo el 0.93% de los parámetros se entrena. El 99.07% está congelado.)

Paso 3: entrenar exactamente igual que con fine-tuning completo

Esta es la elegancia de LoRA: una vez que tienes el modelo envuelto con get_peft_model(), el entrenamiento es idéntico al fine-tuning completo. El Trainer no sabe (ni le importa) que la mayoría de los pesos están congelados.

from datasets import load_dataset
from transformers import (
    AutoTokenizer, TrainingArguments, Trainer, DataCollatorWithPadding,
    EarlyStoppingCallback
)
import evaluate
import numpy as np

## Dataset
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"]

## Tokenizar
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
def tok(b): return tokenizer(b["text"], truncation=True, max_length=256)
ds = dataset.map(tok, batched=True)
collator = DataCollatorWithPadding(tokenizer)

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

## Training — argumentos idénticos al fine-tuning completo
args = TrainingArguments(
    output_dir="./imdb-lora",
    num_train_epochs=8,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    learning_rate=3e-4,    # LoRA puede usar lr mayor que fine-tuning completo
    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",
    greater_is_better=True,
    logging_steps=50,
    report_to="none",
)

trainer = Trainer(
    model=model,              # el modelo con adaptadores LoRA
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["validation"],
    tokenizer=tokenizer,
    data_collator=collator,
    compute_metrics=compute_metrics,
    callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)

trainer.train()
salida real***** Running training ***** Num examples = 4,500 Trainable parameters: 628,994 (0.93% of 67,583,494) {'loss': 0.5921, 'learning_rate': 2.8e-04, 'epoch': 1.0} {'eval_loss': 0.3912, 'eval_accuracy': 0.8622, 'epoch': 1.0} {'loss': 0.3241, 'learning_rate': 1.8e-04, 'epoch': 2.0} {'eval_loss': 0.3014, 'eval_accuracy': 0.8978, 'epoch': 2.0} {'loss': 0.2318, 'learning_rate': 9.2e-05, 'epoch': 3.0} {'eval_loss': 0.2731, 'eval_accuracy': 0.9044, 'epoch': 3.0} {'loss': 0.1823, 'learning_rate': 1.1e-05, 'epoch': 4.0} {'eval_loss': 0.2654, 'eval_accuracy': 0.9089, 'epoch': 4.0} {'loss': 0.1341, 'learning_rate': 0.0, 'epoch': 5.0} {'eval_loss': 0.2788, 'eval_accuracy': 0.9044, 'epoch': 5.0} Early stopping triggered. Best model loaded from epoch 4 (accuracy: 90.89%) Training completed in 94.3 seconds.

Paso 4: guardar y cargar el modelo LoRA

# Guardar solo los adaptadores LoRA (muy pequeño)
model.save_pretrained("./imdb-lora-adapters")

# Verificar el tamaño
import os
size_bytes = sum(
    os.path.getsize(os.path.join("./imdb-lora-adapters", f))
    for f in os.listdir("./imdb-lora-adapters")
)
print(f"Tamaño de los adaptadores: {size_bytes / 1e6:.1f} MB")

# Para usar el modelo más tarde:
from peft import PeftModel

base = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased", num_labels=2
)
model_cargado = PeftModel.from_pretrained(base, "./imdb-lora-adapters")
print("Modelo LoRA cargado exitosamente")

# Para fusionar los pesos en producción (opcional, mejora velocidad de inferencia)
model_merged = model_cargado.merge_and_unload()
print("Adaptadores fusionados con el modelo base")
salida realTamaño de los adaptadores: 2.5 MB Modelo LoRA cargado exitosamente Adaptadores fusionados con el modelo base

Los adaptadores solo ocupan 2.5 MB — comparado con los ~260 MB del modelo completo. Esto es conveniente para distribuir: puedes compartir solo los 2.5 MB de adaptadores y el usuario descarga el modelo base (que puede ya tenerlo) desde HuggingFace.

9. Comparación: fine-tuning completo vs LoRA

Aspecto Fine-tuning completo LoRA (r=8)
Parámetros entrenables 66.955.010 (100%) 628.994 (0.93%)
Memoria GPU (DistilBERT) ~1.4 GB ~0.4 GB
Tiempo de entrenamiento 187 segundos (5k ejemplos) 94 segundos (5k ejemplos)
Accuracy en IMDB (5k train) 91.11% 90.89%
Tamaño del modelo guardado ~260 MB (modelo completo) ~2.5 MB (solo adaptadores)
Velocidad de inferencia Normal Idéntica después de merge_and_unload()
Learning rate típico 2e-5 3e-4 (puede ser mayor)
Conclusión: LoRA consigue 90.89% de accuracy vs 91.11% del fine-tuning completo — una diferencia de 0.22 puntos porcentuales. A cambio: 2× más rápido, 4× menos memoria, modelo guardado 100× más pequeño. Para DistilBERT (que ya era pequeño) las diferencias son modestas. Para LLaMA-7B, LoRA es la diferencia entre necesitar 2 A100 ($30.000) o una RTX 3090 ($800).

🎮 Calculadora de reducción de parámetros con LoRA

Ajusta la dimensión de la capa, el rango r y los módulos objetivo para ver exactamente cuántos parámetros se entrenan con LoRA en comparación con fine-tuning completo.

768
8
16
12

11. Lo que aprendiste

Lo que aprendiste hoy: LoRA (Low-Rank Adaptation) no modifica los pesos del modelo original — aprende la "diferencia" ΔW como el producto de dos matrices pequeñas A (d×r) y B (r×d). El rango r controla cuántos parámetros se añaden: con r=8 en BERT, solo el 0.93% de los parámetros son entrenables. Las matrices B y A se inicializan en cero y aleatoria respectivamente, garantizando que al inicio LoRA no perturba el modelo. El factor α/r escala la contribución de los adaptadores. En código, la librería PEFT hace todo esto en tres líneas: LoraConfig, get_peft_model y el Trainer habitual. El resultado es ~2× más rápido, 4× menos memoria, modelo guardado 100× más pequeño y 0.2 puntos porcentuales de accuracy menos — un trade-off que vale la pena casi siempre.

En la próxima lección: QLoRA y PEFT en práctica — combinar LoRA con cuantización de 4 bits para hacer fine-tuning de LLaMA-7B en Google Colab gratuito.