Saltar al contenido
Lección 5 de 12

LoRA y QLoRA en Profundidad

8 min read

Entendiendo LoRA Desde Primeros Principios

LoRA (Low-Rank Adaptation of Large Language Models) es la técnica que hizo el fine-tuning accesible para todos. Antes de sumergirte en el código de entrenamiento, necesitas entender cómo funciona — no solo a alto nivel, sino lo suficientemente profundo para tomar decisiones informadas sobre rango, alpha, módulos objetivo y cuándo elegir QLoRA sobre LoRA. Esta lección te da esa comprensión.

Cómo Funciona LoRA

La Idea Central: Descomposición de Bajo Rango

En un transformer preentrenado, cada capa contiene matrices de pesos que transforman las entradas. Para una capa lineal con matriz de pesos W de dimensiones (d_out x d_in), una actualización estándar durante el entrenamiento produce una nueva matriz W' = W + delta_W, donde delta_W tiene las mismas dimensiones que W.

La idea clave de LoRA: la actualización delta_W para la adaptación de tarea tiene un rango intrínseco bajo. En lugar de aprender una matriz completa (d_out x d_in), puedes descomponer la actualización en dos matrices más pequeñas:

delta_W = B @ A

Donde:
  A tiene dimensiones (r x d_in)   — la proyeccion descendente
  B tiene dimensiones (d_out x r)  — la proyeccion ascendente
  r << min(d_in, d_out)            — el rango

Para una capa de atención típica donde d_in = d_out = 4096 y rango r = 16:

  • Actualización completa: 4096 x 4096 = 16,777,216 parámetros
  • Actualización LoRA: (4096 x 16) + (16 x 4096) = 131,072 parámetros
  • Reducción: 99.2%

Durante la inferencia, las matrices LoRA se fusionan: W_final = W + (alpha/r) * B @ A. El modelo fusionado tiene exactamente la misma arquitectura y velocidad que el original — no hay sobrecarga de inferencia.

Inicialización

La matriz A se inicializa con valores Gaussianos aleatorios. La matriz B se inicializa en ceros. Esto significa que la actualización LoRA comienza como cero (B @ A = 0), así que el entrenamiento comienza desde los pesos preentrenados y gradualmente se aleja de ellos. Esta inicialización es crítica para la estabilidad.

Parámetros Clave

Rango (r)

El rango determina la expresividad de la adaptación. Mayor rango significa más parámetros y más capacidad para aprender adaptaciones complejas.

# Comparacion de rango para una capa de 4096 dimensiones
# r=8:   2 * 4096 * 8  = 65,536 params por capa
# r=16:  2 * 4096 * 16 = 131,072 params por capa
# r=32:  2 * 4096 * 32 = 262,144 params por capa
# r=64:  2 * 4096 * 64 = 524,288 params por capa

Guías prácticas:

  • r=8: Adaptación mínima. Bueno para cambios simples de estilo o cumplimiento de formato con datasets grandes.
  • r=16: El punto de partida por defecto. Suficiente para la mayoría de tareas incluyendo seguimiento de instrucciones, adaptación de dominio y formateo de salida.
  • r=32: Cuando r=16 muestra subajuste (la pérdida de entrenamiento se estanca demasiado alto). Bueno para tareas de razonamiento complejo.
  • r=64: El máximo que típicamente necesitarás. Solo cuando la tarea requiere un cambio de comportamiento significativo. Rendimientos decrecientes más allá de esto.

Cómo elegir: Comienza con r=16. Si la pérdida de validación sigue disminuyendo cuando termina el entrenamiento, prueba r=32. Si r=16 entrena bien pero la calidad de evaluación es insuficiente, prueba r=32 o r=64. Si r=8 funciona tan bien como r=16, usa r=8 para un tamaño de adaptador más pequeño.

Alpha (lora_alpha)

El parámetro alpha escala la actualización LoRA: actualizacion_efectiva = (alpha / r) * B @ A. Controla la tasa de aprendizaje para los pesos LoRA relativa al modelo base.

Configuraciones comunes:

  • alpha = r (ej., alpha=16, r=16): Factor de escala de 1.0. Conservador. Buen valor por defecto.
  • alpha = 2*r (ej., alpha=32, r=16): Factor de escala de 2.0. Adaptación más agresiva. A menudo funciona bien en la práctica.
  • alpha = r/2: Muy conservador. Usar cuando quieres desviación mínima del modelo base.

Consejo: La relación alpha/r importa más que los valores individuales. Un alpha=32 con r=16 es equivalente a alpha=64 con r=32 en términos de escalado. Muchos profesionales establecen alpha = 2 * r y ajustan la tasa de aprendizaje en su lugar.

Módulos Objetivo

LoRA puede aplicarse a cualquier capa lineal, pero los objetivos más comunes son las capas de atención:

from peft import LoraConfig

# Conservador: solo proyecciones de query y value de atencion
config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
)

# Recomendado: todas las proyecciones de atencion
config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)

# Agresivo: todas las capas lineales (atencion + MLP)
config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules="all-linear",  # Abreviatura en versiones recientes de PEFT
)

Lo que dice la investigación: Aplicar LoRA a todas las capas lineales (incluyendo gate_proj, up_proj, down_proj en el MLP) produce consistentemente mejores resultados que solo atención, con un aumento moderado en parámetros entrenables. Para QLoRA en hardware con memoria limitada, comienza solo con atención y expande si la calidad es insuficiente.

Dropout (lora_dropout)

El dropout de LoRA pone aleatoriamente en cero elementos en las capas LoRA durante el entrenamiento, actuando como regularización.

  • 0.0: Sin dropout. Usar para datasets grandes donde el sobreajuste es menos probable.
  • 0.05: Regularización ligera. Buen valor por defecto.
  • 0.1: Regularización moderada. Usar para datasets pequeños (menos de 500 ejemplos).

QLoRA: Cuantización de 4 Bits

QLoRA extiende LoRA cuantizando el modelo base a precisión de 4 bits, reduciendo dramáticamente la memoria.

NF4 (NormalFloat 4-bit)

QLoRA introduce el tipo de dato NF4, diseñado específicamente para pesos de redes neuronales con distribución normal. NF4 proporciona mejor representación teórico-informacional que la cuantización estándar INT4.

from transformers import BitsAndBytesConfig
import torch

# Configuracion de cuantizacion QLoRA
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",           # NormalFloat 4-bit
    bnb_4bit_compute_dtype=torch.bfloat16, # Computar en bfloat16
    bnb_4bit_use_double_quant=True,        # Doble cuantizacion
)

Doble Cuantización

La cuantización estándar almacena constantes de cuantización (factores de escala) en FP32. La doble cuantización cuantiza estas constantes también, ahorrando aproximadamente 0.4 bits adicionales por parámetro. Para un modelo de 7B, esto ahorra aproximadamente 350MB de VRAM.

Optimizadores Paginados

QLoRA usa optimizadores paginados que descargan estados del optimizador a memoria CPU cuando la memoria GPU se agota. Esto previene errores de memoria insuficiente durante picos de entrenamiento:

from transformers import TrainingArguments

training_args = TrainingArguments(
    optim="paged_adamw_8bit",  # Adam de 8 bits con paginacion
    # ... otros argumentos
)

Cálculo de Ahorro de Memoria

Calculemos los requisitos exactos de memoria para fine-tuning de Llama 3.1 8B:

Fine-tuning completo (FP16):
  Pesos del modelo:      8B * 2 bytes = 16 GB
  Gradientes:            8B * 2 bytes = 16 GB
  Optimizador (Adam):    8B * 8 bytes = 64 GB  (2 estados * 4 bytes cada uno)
  Total:                 ~96 GB

LoRA (base FP16, r=16, capas de atencion):
  Pesos del modelo:      8B * 2 bytes = 16 GB (congelados, sin gradientes)
  Params LoRA:           ~20M * 2 bytes = 40 MB
  Gradientes LoRA:       ~20M * 2 bytes = 40 MB
  Optimizador:           ~20M * 8 bytes = 160 MB
  Total:                 ~17 GB

QLoRA (base 4-bit, r=16, capas de atencion):
  Pesos del modelo:      8B * 0.5 bytes = 4 GB (cuantizado 4-bit)
  Params LoRA:           ~20M * 2 bytes = 40 MB
  Gradientes LoRA:       ~20M * 2 bytes = 40 MB
  Optimizador (8-bit):   ~20M * 4 bytes = 80 MB
  Total:                 ~5 GB

Estos son aproximados — el uso real varía con el tamaño de batch, longitud de secuencia y memoria de activaciones. Pero las proporciones son claras: QLoRA usa aproximadamente el 5% de la memoria requerida para fine-tuning completo.

LoRA vs QLoRA: Cuándo Usar Cada Uno

| Factor | LoRA (FP16) | QLoRA (4-bit) | |--------|-------------|---------------| | Memoria | ~17GB para 8B | ~5GB para 8B | | Velocidad de entrenamiento | Más rápido | ~10-15% más lento | | Calidad | Ligeramente mejor | Muy cercano | | Hardware | GPU 24GB+ | GPU 16GB funciona | | Ideal para | Cuando tienes la VRAM | Cuando la memoria es limitada |

Recomendación práctica: Si tienes suficiente VRAM para LoRA, usa LoRA — es ligeramente más rápido y evita artefactos de cuantización. Si tienes memoria limitada (como la mayoría), QLoRA produce resultados que están dentro del 1-2% de la calidad de LoRA. La brecha de calidad es lo suficientemente pequeña como para que la calidad de tu dataset importe mucho más que la elección entre LoRA y QLoRA.

Ejemplo de Configuración Completa

from peft import LoraConfig, TaskType
from transformers import BitsAndBytesConfig
import torch

# QLoRA: cuantizacion de 4 bits para el modelo base
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# Configuracion LoRA
lora_config = LoraConfig(
    r=16,                          # Rango
    lora_alpha=32,                 # Alpha (2x rango)
    target_modules=[               # Que capas adaptar
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    lora_dropout=0.05,             # Regularizacion ligera
    bias="none",                   # No entrenar terminos de sesgo
    task_type=TaskType.CAUSAL_LM,  # Modelado de lenguaje causal
)

Esta configuración es un fuerte punto de partida para la mayoría de los proyectos de fine-tuning. En la próxima lección, la usaremos para entrenar un modelo de principio a fin con el ecosistema de Hugging Face.