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:
- Entender cómo funcionan los modelos que usas
- Fine-tunear modelos para tu dominio con LoRA/QLoRA
- Evaluar rendimiento con métricas correctas
- Exportar modelos para serving eficiente
- 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).