Phase 03 - Lesson 11
Introduccion a PyTorch
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Construiste el motor a partir de pistones y cigueniales. Ahora aprende el que todos realmente manejan.
Type: Build Languages: Python Prerequisites: Leccion 03.10 (Construye Tu Propio Mini Framework) Time: ~75 minutos
Objetivos de Aprendizaje
- Construir y entrenar redes neuronales usando nn.Module, nn.Sequential y autograd de PyTorch
- Usar tensores de PyTorch, aceleracion por GPU y el bucle de entrenamiento estandar (zero_grad, forward, loss, backward, step)
- Convertir los componentes de tu mini framework hecho desde cero a sus equivalentes en PyTorch
- Perfilar y comparar la velocidad de entrenamiento entre tu framework en Python puro y PyTorch en la misma tarea
El Problema
Tienes un mini framework funcional. Capas lineales, ReLU, dropout, batch norm, Adam, un DataLoader, un bucle de entrenamiento. Entrena una red de 4 capas en un problema de clasificacion de circulos, en Python puro.
Tambien es 500x mas lento que PyTorch en el mismo problema.
Tu mini framework procesa una muestra a la vez con bucles anidados en Python. PyTorch despacha las mismas operaciones a kernels optimizados en C++/CUDA que corren en GPU. En una sola NVIDIA A100, PyTorch entrena una ResNet-50 (25.6M de parametros) en ImageNet (1.28M de imagenes) en cerca de 6 horas. Tu framework tomaria aproximadamente 3,000 horas en la misma tarea -- si no se quedara sin memoria primero.
La velocidad no es la unica brecha. Tu framework no tiene soporte para GPU. No tiene diferenciacion automatica -- escribiste backward() a mano para cada modulo. No tiene serializacion. No tiene entrenamiento distribuido. No tiene precision mixta. No hay forma de depurar el flujo de gradientes sin instrucciones print.
PyTorch llena cada una de estas brechas. Y lo hace manteniendo exactamente el mismo modelo mental que ya construiste: Module, forward(), parameters(), backward(), optimizer.step(). Los conceptos se transfieren uno a uno. La sintaxis es casi identica. La diferencia es que PyTorch envuelve una decada de ingenieria de sistemas detras de la misma interfaz que disenaste desde cero.
El Concepto
Por Que Gano PyTorch
En 2015, TensorFlow exigia que definieras un grafo de computacion estatico antes de ejecutar cualquier cosa. Construias el grafo, lo compilabas y luego alimentabas datos a traves de el. Depurar significaba mirar fijamente visualizaciones de grafos. Cambiar la arquitectura significaba reconstruir el grafo desde cero.
PyTorch se lanzo en 2017 con una filosofia diferente: ejecucion eager. Escribes Python. Se ejecuta de inmediato. y = model(x) de hecho computa y ahora mismo, no "agrega un nodo a un grafo que computara y mas tarde". Esto hizo que las herramientas estandar de depuracion de Python funcionaran. print() funcionaba. pdb funcionaba. if/else en tu forward pass funcionaba.
Para 2020, el mercado ya se habia pronunciado. La participacion de PyTorch en articulos de investigacion de ML paso del 7% (2017) a mas del 75% (2022). Meta, Google DeepMind, OpenAI, Anthropic y Hugging Face usan PyTorch como su framework principal. TensorFlow 2.x adopto la ejecucion eager en respuesta -- una admision tacita de que el diseno de PyTorch era correcto.
La leccion: la experiencia del desarrollador se acumula. Un framework que es 10% mas lento pero 50% mas rapido de depurar gana siempre.
Tensores
Un tensor es un arreglo multidimensional con tres propiedades criticas: shape, dtype y device.
import torch
x = torch.zeros(3, 4) # shape: (3, 4), dtype: float32, device: cpu
x = torch.randn(2, 3, 224, 224) # batch of 2 RGB images, 224x224
x = torch.tensor([1, 2, 3]) # from a Python list
Shape es la dimensionalidad. Un escalar tiene shape (), un vector es (n,), una matriz es (m, n), un batch de imagenes es (batch, channels, height, width).
Dtype controla la precision y la memoria.
| dtype | Bits | Rango | Caso de uso |
|---|---|---|---|
| float32 | 32 | ~7 digitos decimales | Entrenamiento estandar |
| float16 | 16 | ~3.3 digitos decimales | Precision mixta |
| bfloat16 | 16 | Mismo rango que float32, menos precision | Entrenamiento de LLM |
| int8 | 8 | -128 a 127 | Inferencia cuantizada |
Device determina donde ocurre la computacion.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(3, 4, device=device)
x = x.to("cuda")
x = x.cpu()
Toda operacion exige que todos los tensores esten en el mismo device. Este es el error #1 de PyTorch que enfrentan los principiantes: RuntimeError: Expected all tensors to be on the same device. Corrigelo moviendo todo al mismo device antes de la computacion.
Reshape es de tiempo constante -- cambia los metadatos, no los datos.
x = torch.randn(2, 3, 4)
x.view(2, 12) # reshape to (2, 12) -- must be contiguous
x.reshape(6, 4) # reshape to (6, 4) -- works always
x.permute(2, 0, 1) # reorder dimensions
x.unsqueeze(0) # add dimension: (1, 2, 3, 4)
x.squeeze() # remove size-1 dimensions
Autograd
Tu mini framework exigia que implementaras backward() para cada modulo. PyTorch no. Registra cada operacion sobre tensores en un grafo aciclico dirigido (el grafo computacional) y luego recorre ese grafo en sentido inverso para computar gradientes automaticamente.
graph LR
x["x (leaf)"] --> mul["*"]
w["w (leaf, requires_grad)"] --> mul
mul --> add["+"]
b["b (leaf, requires_grad)"] --> add
add --> loss["loss"]
loss --> |".backward()"| add
add --> |"grad"| b
add --> |"grad"| mul
mul --> |"grad"| w
La diferencia clave respecto a tu framework: PyTorch usa autodiferenciacion basada en cinta (tape). Cada operacion se anexa a una "cinta" durante el forward pass. Llamar .backward() reproduce la cinta en sentido inverso.
x = torch.randn(3, requires_grad=True)
y = x ** 2 + 3 * x
z = y.sum()
z.backward()
print(x.grad) # dz/dx = 2x + 3
Tres reglas de autograd:
- Solo los tensores hoja (leaf) con
requires_grad=Trueacumulan gradientes - Los gradientes se acumulan por defecto -- llama
optimizer.zero_grad()antes de cada backward pass torch.no_grad()desactiva el seguimiento de gradientes (usalo durante la evaluacion)
nn.Module
nn.Module es la clase base para todo componente de red neuronal en PyTorch. Ya construiste esta abstraccion en la Leccion 10. La version de PyTorch agrega registro automatico de parametros, descubrimiento recursivo de modulos, gestion de device y serializacion via state dict.
import torch.nn as nn
class MLP(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.layer1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.layer2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.layer1(x)
x = self.relu(x)
x = self.layer2(x)
return x
Cuando asignas un nn.Module o nn.Parameter como atributo en __init__, PyTorch lo registra automaticamente. model.parameters() recolecta recursivamente cada parametro registrado. Por eso nunca tienes que reunir pesos manualmente como hacias en el mini framework.
Bloques de construccion clave:
| Modulo | Que hace | Parametros |
|---|---|---|
| nn.Linear(in, out) | Wx + b | in*out + out |
| nn.Conv2d(in_ch, out_ch, k) | Convolucion 2D | in_chout_chk*k + out_ch |
| nn.BatchNorm1d(features) | Normaliza activaciones | 2 * features |
| nn.Dropout(p) | Puesta a cero aleatoria | 0 |
| nn.ReLU() | max(0, x) | 0 |
| nn.GELU() | Lineal de error gaussiano | 0 |
| nn.Embedding(vocab, dim) | Tabla de consulta | vocab * dim |
| nn.LayerNorm(dim) | Normalizacion por muestra | 2 * dim |
Funciones de Perdida y Optimizadores
PyTorch trae versiones listas para produccion de todo lo que construiste.
Funciones de perdida (de torch.nn):
| Perdida | Tarea | Entrada |
|---|---|---|
| nn.MSELoss() | Regresion | Cualquier shape |
| nn.CrossEntropyLoss() | Clasificacion multiclase | Logits (no softmax) |
| nn.BCEWithLogitsLoss() | Clasificacion binaria | Logits (no sigmoid) |
| nn.L1Loss() | Regresion (robusta) | Cualquier shape |
| nn.CTCLoss() | Alineacion de secuencias | Log-probabilidades |
Nota: CrossEntropyLoss combina LogSoftmax + NLLLoss internamente. Pasa logits crudos, no salidas de softmax. Este es un error comun que produce gradientes incorrectos de forma silenciosa.
Optimizadores (de torch.optim):
| Optimizador | Cuando usarlo | LR tipica |
|---|---|---|
| SGD(params, lr, momentum) | CNNs, pipelines bien ajustados | 0.01--0.1 |
| Adam(params, lr) | Punto de partida por defecto | 1e-3 |
| AdamW(params, lr, weight_decay) | Transformers, fine-tuning | 1e-4--1e-3 |
| LBFGS(params) | Pequena escala, segundo orden | 1.0 |
El Bucle de Entrenamiento
Todo bucle de entrenamiento de PyTorch sigue el mismo patron de 5 pasos. Ya lo conoces de la Leccion 10.
sequenceDiagram
participant D as DataLoader
participant M as Model
participant L as Loss fn
participant O as Optimizer
loop Each Epoch
D->>M: batch = next(dataloader)
M->>L: predictions = model(batch)
L->>L: loss = criterion(predictions, targets)
L->>M: loss.backward()
O->>M: optimizer.step()
O->>O: optimizer.zero_grad()
end
El patron canonico:
for epoch in range(num_epochs):
model.train()
for inputs, targets in train_loader:
inputs, targets = inputs.to(device), targets.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, targets)
loss.backward()
optimizer.step()
Cinco lineas dentro del bucle de batch. Cinco lineas que entrenaron a GPT-4, Stable Diffusion y LLaMA. La arquitectura cambia. Los datos cambian. Estas cinco lineas no.
Dataset y DataLoader
El Dataset de PyTorch es una clase abstracta con dos metodos: __len__ y __getitem__. El DataLoader lo envuelve con batching, mezclado (shuffling) y carga de datos en multiples procesos.
from torch.utils.data import Dataset, DataLoader
class MNISTDataset(Dataset):
def __init__(self, images, labels):
self.images = images
self.labels = labels
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
return self.images[idx], self.labels[idx]
loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)
num_workers=4 crea 4 procesos para cargar datos en paralelo mientras la GPU entrena con el batch actual. En cargas limitadas por disco (imagenes grandes, audio), esto solo puede duplicar la velocidad de entrenamiento.
Entrenamiento en GPU
Mover un modelo a la GPU:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
Esto mueve recursivamente cada parametro y buffer a la GPU. Luego mueve cada batch durante el entrenamiento:
inputs, targets = inputs.to(device), targets.to(device)
La precision mixta reduce a la mitad el uso de memoria y duplica el rendimiento en GPUs modernas (A100, H100, RTX 4090) al ejecutar forward/backward en float16 mientras mantiene los pesos maestros en float32:
from torch.amp import autocast, GradScaler
scaler = GradScaler()
for inputs, targets in loader:
with autocast(device_type="cuda"):
outputs = model(inputs)
loss = criterion(outputs, targets)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
Comparacion: Mini Framework vs PyTorch vs JAX
| Caracteristica | Mini Framework (L10) | PyTorch | JAX |
|---|---|---|---|
| Autodiff | backward() manual | Autograd basado en cinta | Transformaciones funcionales |
| Ejecucion | Eager (bucles Python) | Eager (kernels C++) | Trazado + compilado JIT |
| Soporte para GPU | No | Si (CUDA, ROCm, MPS) | Si (CUDA, TPU) |
| Velocidad (MLP MNIST) | ~300s/epoca | ~0.5s/epoca | ~0.3s/epoca |
| Sistema de modulos | Clase Module personalizada | nn.Module | Funciones sin estado (Flax/Equinox) |
| Depuracion | print() | print(), pdb, breakpoint() | Mas dificil (el trazado JIT rompe print) |
| Ecosistema | Ninguno | Hugging Face, Lightning, timm | Flax, Optax, Orbax |
| Curva de aprendizaje | Tu lo construiste | Moderada | Empinada (paradigma funcional) |
| Uso en produccion | Problemas de juguete | Meta, OpenAI, Anthropic, HF | Google DeepMind, Midjourney |
Construyelo
Una MLP de 3 capas entrenada en MNIST usando solo primitivas de PyTorch. Sin wrappers de alto nivel. Sin torchvision.datasets. Descargamos y parseamos los datos crudos nosotros mismos.
Paso 1: Cargar MNIST Desde Archivos Crudos
MNIST viene en 4 archivos comprimidos con gzip: imagenes de entrenamiento (60,000 x 28 x 28), etiquetas de entrenamiento, imagenes de prueba (10,000 x 28 x 28), etiquetas de prueba. Los descargamos y parseamos el formato binario.
import torch
import torch.nn as nn
import struct
import gzip
import urllib.request
import os
def download_mnist(path="./mnist_data"):
base_url = "https://storage.googleapis.com/cvdf-datasets/mnist/"
files = [
"train-images-idx3-ubyte.gz",
"train-labels-idx1-ubyte.gz",
"t10k-images-idx3-ubyte.gz",
"t10k-labels-idx1-ubyte.gz",
]
os.makedirs(path, exist_ok=True)
for f in files:
filepath = os.path.join(path, f)
if not os.path.exists(filepath):
urllib.request.urlretrieve(base_url + f, filepath)
def load_images(filepath):
with gzip.open(filepath, "rb") as f:
magic, num, rows, cols = struct.unpack(">IIII", f.read(16))
data = f.read()
images = torch.frombuffer(bytearray(data), dtype=torch.uint8)
images = images.reshape(num, rows * cols).float() / 255.0
return images
def load_labels(filepath):
with gzip.open(filepath, "rb") as f:
magic, num = struct.unpack(">II", f.read(8))
data = f.read()
labels = torch.frombuffer(bytearray(data), dtype=torch.uint8).long()
return labels
Paso 2: Definir el Modelo
Una MLP de 3 capas: 784 -> 256 -> 128 -> 10. Activaciones ReLU. Dropout para regularizacion. Sin batch norm, para mantenerlo simple.
class MNISTModel(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 128),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)
La capa de salida produce 10 logits crudos (uno por digito). Sin softmax -- CrossEntropyLoss se encarga de eso internamente.
Conteo de parametros: 784256 + 256 + 256128 + 128 + 128*10 + 10 = 235,146. Minusculo para los estandares modernos. GPT-2 small tiene 124M. Esto entrena en segundos.
Paso 3: Bucle de Entrenamiento
El patron canonico forward-loss-backward-step.
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss = 0
correct = 0
total = 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
_, predicted = outputs.max(1)
correct += predicted.eq(labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0
correct = 0
total = 0
with torch.no_grad():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
_, predicted = outputs.max(1)
correct += predicted.eq(labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
Nota el torch.no_grad() durante la evaluacion. Esto desactiva autograd, reduciendo el uso de memoria y acelerando la inferencia. Sin el, PyTorch construye un grafo computacional que nunca usas.
Paso 4: Conectar Todo
def main():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
download_mnist()
train_images = load_images("./mnist_data/train-images-idx3-ubyte.gz")
train_labels = load_labels("./mnist_data/train-labels-idx1-ubyte.gz")
test_images = load_images("./mnist_data/t10k-images-idx3-ubyte.gz")
test_labels = load_labels("./mnist_data/t10k-labels-idx1-ubyte.gz")
train_dataset = torch.utils.data.TensorDataset(train_images, train_labels)
test_dataset = torch.utils.data.TensorDataset(test_images, test_labels)
train_loader = torch.utils.data.DataLoader(
train_dataset, batch_size=64, shuffle=True
)
test_loader = torch.utils.data.DataLoader(
test_dataset, batch_size=256, shuffle=False
)
model = MNISTModel().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
num_params = sum(p.numel() for p in model.parameters())
print(f"Device: {device}")
print(f"Parameters: {num_params:,}")
print(f"Train samples: {len(train_dataset):,}")
print(f"Test samples: {len(test_dataset):,}")
print()
for epoch in range(10):
train_loss, train_acc = train_one_epoch(
model, train_loader, criterion, optimizer, device
)
test_loss, test_acc = evaluate(
model, test_loader, criterion, device
)
print(
f"Epoch {epoch+1:2d} | "
f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
f"Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}"
)
torch.save(model.state_dict(), "mnist_mlp.pt")
print(f"\nModel saved to mnist_mlp.pt")
print(f"Final test accuracy: {test_acc:.4f}")
Salida esperada despues de 10 epocas: ~97.8% de exactitud en prueba. Tiempo de entrenamiento en CPU: ~30 segundos. En GPU: ~5 segundos. En tu mini framework con la misma arquitectura: ~45 minutos.
Usalo
Comparacion Rapida: Mini Framework vs PyTorch
| Mini Framework (Leccion 10) | PyTorch |
|---|---|
model = Sequential(Linear(784, 256), ReLU(), ...) |
model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), ...) |
pred = model.forward(x) |
pred = model(x) |
optimizer.zero_grad() |
optimizer.zero_grad() |
grad = criterion.backward() luego model.backward(grad) |
loss.backward() |
optimizer.step() |
optimizer.step() |
| Sin GPU | model.to("cuda") |
| backward manual para cada modulo | Autograd se encarga de todo |
La interfaz es casi identica. La diferencia es todo lo que esta debajo del capo.
Guardar y Cargar Modelos
torch.save(model.state_dict(), "model.pt")
model = MNISTModel()
model.load_state_dict(torch.load("model.pt", weights_only=True))
model.eval()
Siempre guarda el state_dict() (el diccionario de parametros), no el objeto del modelo. Guardar el objeto del modelo usa pickle, que se rompe cuando refactorizas el codigo. Los state dicts son portables.
Programacion de la Tasa de Aprendizaje
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=10
)
for epoch in range(10):
train_one_epoch(model, train_loader, criterion, optimizer, device)
scheduler.step()
PyTorch trae mas de 15 schedulers: StepLR, ExponentialLR, CosineAnnealingLR, OneCycleLR, ReduceLROnPlateau. Todos se conectan a la misma interfaz de optimizador.
Entregalo
Esta leccion produce dos artefactos:
outputs/prompt-pytorch-debugger.md-- un prompt para diagnosticar fallas comunes de entrenamiento en PyTorchoutputs/skill-pytorch-patterns.md-- una referencia de skill para patrones de entrenamiento en PyTorch
Ejercicios
Agrega batch normalization. Inserta
nn.BatchNorm1ddespues de cada capa lineal (antes de la activacion). Compara la exactitud en prueba y la velocidad de entrenamiento con la version solo con dropout. El batch norm deberia alcanzar 98%+ en menos epocas.Implementa un buscador de tasa de aprendizaje. Entrena por una epoca con tasa de aprendizaje que crece exponencialmente (de 1e-7 a 1.0). Grafica la perdida vs LR. La LR optima esta justo antes de que la perdida empiece a subir. Usa esto para elegir una mejor LR para el modelo de MNIST.
Porta a GPU con precision mixta. Agrega
torch.amp.autocastyGradScaleral bucle de entrenamiento. Mide el rendimiento (muestras/segundo) con y sin precision mixta en GPU. En una A100, espera ~2x de aceleracion.Construye un Dataset personalizado. Descarga Fashion-MNIST (mismo formato que MNIST pero con prendas de ropa). Implementa una clase
FashionMNISTDataset(Dataset)con__getitem__y__len__. Entrena la misma MLP y compara la exactitud. Fashion-MNIST es mas dificil -- espera ~88% vs ~98%.Reemplaza Adam con SGD + momentum. Entrena con
SGD(params, lr=0.01, momentum=0.9). Compara las curvas de convergencia. Luego agrega un schedulerCosineAnnealingLRy observa si SGD alcanza a Adam para la epoca 10.
Terminos Clave
| Termino | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Tensor | "Un arreglo multidimensional" | Un arreglo tipado y consciente del device, con soporte de diferenciacion automatica integrado en cada operacion |
| Autograd | "Backprop automatico" | Un sistema basado en cinta que registra operaciones durante el forward pass y luego las reproduce en sentido inverso para computar gradientes exactos |
| nn.Module | "Una capa" | La clase base para cualquier bloque de computacion diferenciable -- registra parametros, soporta anidamiento, gestiona modos de entrenamiento/evaluacion |
| state_dict | "Los pesos del modelo" | Un OrderedDict que mapea nombres de parametros a tensores -- la representacion portable y serializable de un modelo entrenado |
| .backward() | "Computar gradientes" | Recorrer el grafo computacional en sentido inverso, computando y acumulando gradientes para cada tensor hoja con requires_grad=True |
| .to(device) | "Mover a la GPU" | Transferir recursivamente todos los parametros y buffers al device especificado (CPU, CUDA, MPS) |
| DataLoader | "El pipeline de datos" | Un iterador que hace batching, mezcla y opcionalmente paraleliza la carga de datos de un Dataset |
| Precision mixta | "Usar float16" | Entrenar con forward/backward en float16 para ganar velocidad mientras se mantienen pesos maestros en float32 para estabilidad numerica |
| Ejecucion eager | "Ejecutarlo ahora" | Las operaciones se ejecutan de inmediato al ser llamadas, sin diferirse a un paso de compilacion posterior -- la decision de diseno central que diferencia a PyTorch de TF 1.x |
| zero_grad | "Reiniciar gradientes" | Poner a cero todos los gradientes de parametros antes del siguiente backward pass, ya que PyTorch acumula gradientes por defecto |
Lecturas Adicionales
- Paszke et al., "PyTorch: An Imperative Style, High-Performance Deep Learning Library" (2019) -- el articulo original que explica los tradeoffs de diseno de PyTorch
- PyTorch Tutorials: "Learning PyTorch with Examples" (https://pytorch.org/tutorials/beginner/pytorch_with_examples.html) -- el camino oficial de los tensores a nn.Module
- PyTorch Performance Tuning Guide (https://pytorch.org/tutorials/recipes/recipes/tuning_guide.html) -- precision mixta, workers del DataLoader, memoria fijada (pinned) y otras optimizaciones de produccion
- Horace He, "Making Deep Learning Go Brrrr" (https://horace.io/brrr_intro.html) -- por que el entrenamiento en GPU es rapido, con estrategias de optimizacion especificas de PyTorch