Inicio / Inteligencia Artificial / AI Engineering Pro / Deep Learning con PyTorch

Deep Learning con PyTorch

Tensores, autograd, training loop, LoRA fine-tuning y export.

Intermedio

Deep Learning con PyTorch

¿Por Qué PyTorch para el AI Engineer?

PyTorch es el framework dominante en investigación y cada vez más en producción. Como AI Engineer necesitas PyTorch para:

  • Fine-tuning de modelos pre-entrenados
  • Crear embeddings personalizados
  • Debuggear problemas de modelos
  • Evaluar componentes de pipelines de IA
  • Prototipar soluciones custom cuando un LLM genérico no basta

Fundamentos de PyTorch

Tensores

import torch

# Los tensores son la estructura de datos fundamental de PyTorch:
# generalización de matrices a N dimensiones (escalar=0D, vector=1D, matriz=2D, tensor=3D+)
x = torch.tensor([1.0, 2.0, 3.0])   # Vector 1D con 3 elementos
y = torch.zeros(3, 4)                # Matriz 3x4 de ceros
z = torch.randn(2, 3)                # Valores aleatorios (distribución normal estándar)

# Operaciones
# unsqueeze(0) añade una dimensión: (3,) → (1, 3) para compatibilidad con matmul
# matmul: multiplicación de matrices (1,3) @ (2,3) → usa broadcasting interno
result = torch.matmul(x.unsqueeze(0), z)  # Multiplicación de matrices

# GPU: mover tensores a GPU para cálculos ~10-100x más rápidos
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tensor_gpu = x.to(device)  # .to() copia el tensor al dispositivo especificado
print(f"Dispositivo: {tensor_gpu.device}")  # cuda:0 o cpu

Autograd: Diferenciación Automática

# PyTorch rastrea operaciones para calcular gradientes
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2 + 3 * x + 1  # y = x² + 3x + 1

y.backward()              # Calcula dy/dx
print(x.grad)             # tensor([7.]) → dy/dx = 2x + 3 = 2(2) + 3 = 7

Definir un Modelo

import torch.nn as nn

class SentimentClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_classes):
        super().__init__()
        # Embedding: tabla de lookup que convierte índices enteros en vectores densos
        # Cada token del vocabulario tiene un vector aprendible de embed_dim dimensiones
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        # Capa Transformer con multi-head attention
        # nhead=4: 4 cabezas de atención en paralelo (embed_dim debe ser divisible por nhead)
        # batch_first=True: el tensor de entrada tiene forma (batch, seq, features)
        self.encoder = nn.TransformerEncoderLayer(
            d_model=embed_dim, nhead=4, batch_first=True
        )
        # Clasificador: reduce progresivamente las dimensiones hasta num_classes
        self.classifier = nn.Sequential(
            nn.Linear(embed_dim, 128),
            nn.ReLU(),                  # Activación no lineal
            nn.Dropout(0.3),            # Apaga 30% de neuronas aleatoriamente (previene overfitting)
            nn.Linear(128, num_classes)
        )

    def forward(self, x):
        # x: (batch, seq_len) → índices de tokens
        embedded = self.embedding(x)        # (batch, seq_len, embed_dim)
        encoded = self.encoder(embedded)    # (batch, seq_len, embed_dim)
        # mean(dim=1): promedia sobre la secuencia para obtener una representación fija
        # Convierte secuencia de largo variable en un vector de tamaño fijo
        pooled = encoded.mean(dim=1)        # (batch, embed_dim)
        return self.classifier(pooled)      # (batch, num_classes)

Loop de Entrenamiento

model = SentimentClassifier(vocab_size=10000, embed_dim=256, num_classes=3)
model = model.to(device)

# AdamW: variante de Adam con weight decay desacoplado (mejor regularización)
# lr=1e-4: learning rate conservador, adecuado para modelos transformer
# weight_decay=0.01: penaliza pesos grandes, previene overfitting
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
# CrossEntropyLoss: función de pérdida estándar para clasificación multiclase
# Internamente aplica softmax + negative log likelihood
criterion = nn.CrossEntropyLoss()
# CosineAnnealingLR: reduce el learning rate siguiendo una curva coseno
# T_max=10: completa un ciclo en 10 épocas (de lr inicial hasta ~0)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)

for epoch in range(10):
    model.train()  # Activa dropout y batch norm en modo entrenamiento
    total_loss = 0

    for batch in train_loader:
        inputs, labels = batch["input_ids"].to(device), batch["labels"].to(device)

        optimizer.zero_grad()           # 1. Limpia gradientes de la iteración anterior
        outputs = model(inputs)          # 2. Forward pass: calcula predicciones
        loss = criterion(outputs, labels)# 3. Calcula la pérdida
        loss.backward()                  # 4. Backward pass: calcula gradientes

        # Gradient clipping: limita la norma del gradiente a 1.0
        # para evitar gradient explosion (común en transformers)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        optimizer.step()                 # 5. Actualiza pesos del modelo
        total_loss += loss.item()

    scheduler.step()  # Ajusta el learning rate según la curva coseno
    avg_loss = total_loss / len(train_loader)
    print(f"Epoch {epoch+1}: loss={avg_loss:.4f}, lr={scheduler.get_last_lr()[0]:.6f}")

Fine-Tuning con Hugging Face + PyTorch

Caso Real: Fine-Tuning de un Clasificador de Intención

from transformers import AutoModelForSequenceClassification, AutoTokenizer
from transformers import Trainer, TrainingArguments

# DistilBERT: versión ligera de BERT, ~60% más rápida, ideal para clasificación
# multilingual-cased: soporta 104 idiomas, respeta mayúsculas/minúsculas
model_name = "distilbert-base-multilingual-cased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(
    model_name, num_labels=5   # 5 clases de intención (ej: compra, soporte, queja, etc.)
)

# Dataset desde archivos JSONL (un JSON por línea)
from datasets import load_dataset
dataset = load_dataset("json", data_files={"train": "train.jsonl", "test": "test.jsonl"})

def tokenize(batch):
    # padding="max_length": rellena siempre a 128 tokens (vs True que rellena al máximo del batch)
    # truncation=True: corta textos más largos que max_length
    return tokenizer(batch["text"], padding="max_length", truncation=True, max_length=128)

# batched=True: procesa en lotes para mayor eficiencia (vs uno por uno)
dataset = dataset.map(tokenize, batched=True)

# Entrenar
training_args = TrainingArguments(
    output_dir="./results",                # Directorio para checkpoints y logs
    num_train_epochs=3,                    # 3 épocas: suficiente para fine-tuning
    per_device_train_batch_size=16,        # Batch de 16 en train (limitado por VRAM)
    per_device_eval_batch_size=32,         # Batch de 32 en eval (no guarda gradientes, permite más)
    learning_rate=2e-5,                    # LR estándar para fine-tuning de BERT
    weight_decay=0.01,                     # Regularización L2 desacoplada
    eval_strategy="epoch",                # Evalúa al final de cada época
    save_strategy="epoch",                # Guarda checkpoint cada época
    load_best_model_at_end=True,           # Al terminar, carga el mejor checkpoint
    metric_for_best_model="f1",            # Selecciona el "mejor" por F1 score
    fp16=torch.cuda.is_available(),        # Precisión mixta 16-bit: 2x más rápido en GPU
    dataloader_num_workers=4,              # 4 procesos paralelos para cargar datos
)

from sklearn.metrics import f1_score, accuracy_score
import numpy as np

def compute_metrics(eval_pred):
    logits, labels = eval_pred
    preds = np.argmax(logits, axis=-1)
    return {
        "accuracy": accuracy_score(labels, preds),
        "f1": f1_score(labels, preds, average="weighted"),
    }

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    compute_metrics=compute_metrics,
)

trainer.train()

LoRA: Fine-Tuning Eficiente

from peft import LoraConfig, get_peft_model, TaskType

# LoRA (Low-Rank Adaptation): en vez de re-entrenar todos los parámetros,
# inserta matrices pequeñas de bajo rango en capas específicas del modelo
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,       # Tipo de tarea: modelado de lenguaje causal (generación)
    r=16,                                # Rango de las matrices LoRA: controla capacidad expresiva
                                         # Mayor r = más capacidad pero más VRAM. Típico: 8-64
    lora_alpha=32,                       # Factor de escala: el peso real es alpha/r = 32/16 = 2x
                                         # Amplifica la contribución de los adaptadores LoRA
    lora_dropout=0.05,                   # Dropout del 5% en matrices LoRA para regularización
    target_modules=["q_proj", "v_proj"], # Adapta las proyecciones Query y Value de la atención
                                         # Son las más impactantes según el paper original de LoRA
)

# Inyecta matrices LoRA en los módulos objetivo y congela todos los demás parámetros
model = get_peft_model(base_model, lora_config)

# Verificar parámetros entrenables
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 6,738,415,616 || trainable%: 0.0622

¿Por qué LoRA?

  • Entrena ~0.1% de los parámetros
  • Requiere mucha menos VRAM
  • Los adaptadores son pequeños (~MB vs GB del modelo completo)
  • Se pueden combinar múltiples adaptadores para diferentes tareas

Exportación y Serving

TorchScript para Producción

# TorchScript: compila el modelo a un grafo intermedio independiente de Python
# Ventajas: ejecutable en C++, menor latencia, no requiere el código fuente original
# script() compila todo incluyendo control de flujo (if/for)
# Alternativa: torch.jit.trace() — más simple pero no captura branches condicionales
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")

# Cargar sin necesidad del código original de la clase del modelo
loaded = torch.jit.load("model_scripted.pt")
output = loaded(input_tensor)

ONNX para Interoperabilidad

import torch.onnx

# ONNX (Open Neural Network Exchange): formato universal para ejecutar modelos
# en runtimes optimizados (ONNX Runtime, TensorRT) sin depender de PyTorch

# dummy_input: entrada de ejemplo para que PyTorch trace el grafo computacional
# Simula 1 secuencia de 128 tokens con IDs aleatorios del vocabulario (0-9999)
dummy_input = torch.randint(0, 10000, (1, 128)).to(device)

torch.onnx.export(
    model,
    dummy_input,
    "model.onnx",
    input_names=["input_ids"],              # Nombre de la entrada en el grafo ONNX
    output_names=["logits"],                # Nombre de la salida en el grafo ONNX
    dynamic_axes={                          # Sin esto, el modelo solo aceptaría batch=1, seq=128
        "input_ids": {0: "batch", 1: "sequence"}  # Hace estas dimensiones variables en runtime
    },
    opset_version=14,                       # Versión de operadores ONNX (14 = buena compatibilidad)
)

Cuantización

# Cuantización dinámica (CPU)
quantized = torch.quantization.quantize_dynamic(
    model, {nn.Linear}, dtype=torch.qint8
)

# Reducción típica: 4x menos memoria, ~2x más rápido
# Con pérdida mínima de precisión (<1% degradación)

Métricas Clave en Entrenamiento

Métrica Qué Indica Acción
Training loss ↓ Modelo aprende Normal si baja gradualmente
Validation loss ↑ Overfitting Early stopping, más datos, regularización
Gradient norm Estabilidad Clipping si explota
Learning rate Velocidad de aprendizaje Warm-up + decay
GPU utilization Eficiencia Ajustar batch size

Resumen

Como AI Engineer, PyTorch te da las herramientas para:

  1. Entender cómo funcionan los modelos que usas
  2. Fine-tunear modelos para tu dominio con LoRA/QLoRA
  3. Evaluar rendimiento con métricas correctas
  4. Exportar modelos para serving eficiente
  5. Debuggear problemas de calidad en pipelines de IA

🧠 Preguntas de Repaso

1. ¿Qué técnica permite fine-tunear un modelo de 6.7 mil millones de parámetros entrenando solo ~0.06% de ellos?

  • A) Transfer Learning clásico con todas las capas congeladas
  • B) LoRA (Low-Rank Adaptation), que añade matrices de bajo rango a las proyecciones Q y V
  • C) Cuantización dinámica a int8
  • D) Gradient clipping con max_norm=1.0

Respuesta: B) — LoRA añade matrices de bajo rango (r=16) a las proyecciones Query y Value del modelo. Esto permite entrenar solo ~0.06% de los parámetros originales (ej: 4.2M de 6.7B), con adaptadores de solo unos MB en vez de GB.

2. ¿Cuál es la función de torch.jit.script() al exportar un modelo PyTorch?

  • A) Convierte el modelo a formato ONNX universal
  • B) Aplica cuantización automática al modelo
  • C) Compila el modelo a un grafo intermedio ejecutable en C++ sin necesidad del código fuente Python
  • D) Genera un contenedor Docker con el modelo

Respuesta: C) — TorchScript compila el modelo a un grafo intermedio (Intermediate Representation) que puede ejecutarse en el runtime de C++ de PyTorch sin necesidad del código fuente original en Python.

3. ¿Por qué se usa gradient clipping (clip_grad_norm_ con max_norm=1.0) en el entrenamiento de transformers?

  • A) Para aumentar la velocidad de entrenamiento
  • B) Para evitar gradient explosion, que es un problema común en transformers
  • C) Para reducir el uso de memoria GPU
  • D) Para mejorar la precisión del modelo en validación

Respuesta: B) — El gradient clipping previene la explosión de gradientes (gradient explosion), un problema frecuente en arquitecturas transformer. Limita la norma del gradiente a un valor máximo (1.0), estabilizando el entrenamiento.

4. La cuantización dinámica a torch.qint8 ofrece aproximadamente:

  • A) 2x menos memoria y 1.5x más velocidad
  • B) 4x menos memoria y ~2x más velocidad con menos de 1% de degradación
  • C) 8x menos memoria pero 50% de degradación en precisión
  • D) No afecta la memoria, solo reduce la latencia

Respuesta: B) — La cuantización dinámica a int8 reduce el uso de memoria aproximadamente 4x y mejora la velocidad ~2x, con una pérdida mínima de precisión (menos del 1% de degradación).