Dos rutas complementarias para implementar redes neuronales
En este capítulo recorrerás dos formas de llevar una red neuronal a código: (1) una implementación mínima “desde cero” de un perceptrón multicapa (MLP) para entender qué ocurre en cada operación; (2) una implementación práctica con una biblioteca estándar (PyTorch) para entrenar modelos reales con buenas prácticas de ingeniería. La idea no es elegir una u otra, sino usar la primera para afianzar intuición y la segunda para productividad.
Ruta 1: Implementación mínima desde cero (MLP)
Objetivo y alcance
Implementaremos un MLP pequeño con: inicialización de parámetros, forward, cálculo de pérdida, backward (gradientes), y actualización con descenso por gradiente. Para mantenerlo manejable, usaremos NumPy y un conjunto de datos tabular sintético. El foco está en la estructura del código y el flujo de tensores, no en exprimir rendimiento.
1) Organización del código por módulos
Una estructura simple y escalable (incluso para proyectos pequeños) ayuda a depurar y extender:
proyecto_mlp_desde_cero/ ├─ data.py # generación/carga de datos ├─ layers.py # capas (Linear), activaciones ├─ losses.py # funciones de pérdida ├─ model.py # MLP que compone capas ├─ optim.py # actualizador (SGD) ├─ train.py # bucle de entrenamiento/validación └─ utils.py # semillas, métricas, helpers2) Reproducibilidad: control de semillas
Para que los resultados sean comparables entre ejecuciones, fija semillas y evita fuentes de aleatoriedad no controladas.
# utils.py import numpy as np def set_seed(seed: int = 42): np.random.seed(seed)3) Datos de ejemplo (clasificación binaria tabular)
Generaremos un dataset sintético separable de forma no lineal para justificar el MLP.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
# data.py import numpy as np def make_toy_data(n=1000, seed=42): rng = np.random.default_rng(seed) X = rng.normal(size=(n, 2)) # frontera no lineal: círculo y = (X[:, 0]**2 + X[:, 1]**2 > 1.0).astype(np.int64) # split simple idx = rng.permutation(n) train_idx = idx[: int(0.8*n)] val_idx = idx[int(0.8*n):] return X[train_idx], y[train_idx], X[val_idx], y[val_idx]4) Capas: Linear y activación (con cachés para backward)
La clave de una implementación desde cero es guardar en caché lo necesario durante el forward para poder calcular gradientes en el backward. En una capa lineal, necesitas X para obtener dW y db.
# layers.py import numpy as np class Linear: def __init__(self, in_features, out_features, seed=42): rng = np.random.default_rng(seed) # inicialización simple (puedes ajustar escala) self.W = rng.normal(0, 0.1, size=(in_features, out_features)) self.b = np.zeros((1, out_features)) self.cache_X = None # gradientes self.dW = np.zeros_like(self.W) self.db = np.zeros_like(self.b) def forward(self, X): self.cache_X = X return X @ self.W + self.b def backward(self, dY): X = self.cache_X # dW: (in, batch)@(batch, out) => (in, out) self.dW = X.T @ dY # db: suma sobre batch self.db = np.sum(dY, axis=0, keepdims=True) # dX: (batch, out)@(out, in) => (batch, in) dX = dY @ self.W.T return dX class ReLU: def __init__(self): self.cache_X = None def forward(self, X): self.cache_X = X return np.maximum(0, X) def backward(self, dY): X = self.cache_X dX = dY * (X > 0) return dX5) Pérdida: entropía cruzada binaria con logits
Para estabilidad numérica, conviene trabajar con logits y usar una sigmoide estable. Aquí implementamos una versión simple. El backward devuelve el gradiente respecto a los logits.
# losses.py import numpy as np def sigmoid(x): # estable: evita overflow en exp pos = x >= 0 neg = ~pos out = np.empty_like(x) out[pos] = 1 / (1 + np.exp(-x[pos])) expx = np.exp(x[neg]) out[neg] = expx / (1 + expx) return out class BCEWithLogitsLoss: def __init__(self): self.cache_logits = None self.cache_y = None def forward(self, logits, y): # logits: (batch, 1), y: (batch,) o (batch,1) y = y.reshape(-1, 1).astype(np.float64) self.cache_logits = logits self.cache_y = y p = sigmoid(logits) eps = 1e-12 loss = -np.mean(y*np.log(p+eps) + (1-y)*np.log(1-p+eps)) return loss def backward(self): logits = self.cache_logits y = self.cache_y p = sigmoid(logits) # dL/dlogits = (p - y)/batch (promedio) batch = y.shape[0] return (p - y) / batch6) Modelo: MLP como composición de capas
El modelo orquesta el flujo: forward aplica capas en orden; backward recorre en orden inverso. Además, expone parámetros para que el optimizador los actualice.
# model.py from layers import Linear, ReLU class MLP: def __init__(self, in_features=2, hidden=16, seed=42): self.l1 = Linear(in_features, hidden, seed=seed) self.a1 = ReLU() self.l2 = Linear(hidden, 1, seed=seed+1) self.layers = [self.l1, self.a1, self.l2] def forward(self, X): out = X for layer in self.layers: out = layer.forward(out) return out # logits def backward(self, dlogits): grad = dlogits for layer in reversed(self.layers): grad = layer.backward(grad) return grad def parameters(self): # devuelve referencias a parámetros y gradientes return [ (self.l1, 'W', 'dW'), (self.l1, 'b', 'db'), (self.l2, 'W', 'dW'), (self.l2, 'b', 'db') ]7) Optimizador: SGD mínimo
El optimizador aplica la regla de actualización. Aquí implementamos SGD con tasa de aprendizaje fija.
# optim.py class SGD: def __init__(self, lr=0.1): self.lr = lr def step(self, params): for (obj, p_name, g_name) in params: p = getattr(obj, p_name) g = getattr(obj, g_name) setattr(obj, p_name, p - self.lr * g)8) Métricas y registro básico
Registra al menos pérdida y exactitud en entrenamiento y validación. Aunque sea en consola, mantén un formato consistente para luego volcarlo a CSV o a una herramienta de tracking.
# utils.py import numpy as np def accuracy_from_logits(logits, y): y = y.reshape(-1) preds = (logits.reshape(-1) > 0).astype(np.int64) return np.mean(preds == y)9) Bucle de entrenamiento paso a paso
Este es el “esqueleto” que se repite en casi cualquier entrenamiento: forward → pérdida → backward → step. Añadimos validación por época.
# train.py import numpy as np from data import make_toy_data from model import MLP from losses import BCEWithLogitsLoss from optim import SGD from utils import set_seed, accuracy_from_logits def iterate_minibatches(X, y, batch_size=64, seed=42): rng = np.random.default_rng(seed) idx = rng.permutation(len(X)) for start in range(0, len(X), batch_size): batch_idx = idx[start:start+batch_size] yield X[batch_idx], y[batch_idx] def train(epochs=20, lr=0.5, batch_size=64, seed=42): set_seed(seed) Xtr, ytr, Xva, yva = make_toy_data(n=2000, seed=seed) model = MLP(in_features=2, hidden=32, seed=seed) loss_fn = BCEWithLogitsLoss() opt = SGD(lr=lr) for epoch in range(1, epochs+1): # entrenamiento train_losses = [] train_accs = [] for Xb, yb in iterate_minibatches(Xtr, ytr, batch_size=batch_size, seed=seed+epoch): logits = model.forward(Xb) loss = loss_fn.forward(logits, yb) dlogits = loss_fn.backward() model.backward(dlogits) opt.step(model.parameters()) train_losses.append(loss) train_accs.append(accuracy_from_logits(logits, yb)) # validación (sin backward) val_logits = model.forward(Xva) val_loss = loss_fn.forward(val_logits, yva) val_acc = accuracy_from_logits(val_logits, yva) print(f"epoch={epoch:03d} train_loss={np.mean(train_losses):.4f} train_acc={np.mean(train_accs):.4f} val_loss={val_loss:.4f} val_acc={val_acc:.4f}") return model if __name__ == "__main__": train()10) Checklist de depuración (cuando “no aprende”)
- Dimensiones: verifica shapes en cada capa (especialmente
by el reshape dey). - Escala de inicialización: si logits saturan (muy grandes), baja la varianza inicial o el
lr. - Gradientes: imprime normas de
dWpara detectar ceros o explosiones. - Overfitting/underfitting: compara train vs val; si divergen rápido, reduce capacidad o añade regularización (en esta ruta, podrías añadir L2 manual).
Ruta 2: Implementación con biblioteca estándar (PyTorch)
Objetivo y enfoque
Ahora priorizamos productividad: definir el modelo con nn.Module, usar DataLoader, entrenar con un optimizador estándar, validar, registrar métricas y guardar/cargar pesos. Esto es lo que usarías en proyectos reales y en plataformas de aprendizaje con notebooks (por ejemplo, entornos tipo Jupyter/Colab o laboratorios guiados).
1) Estructura recomendada del proyecto
proyecto_pytorch/ ├─ datasets.py # Dataset y transforms ├─ models.py # definiciones de modelos ├─ train.py # entrenamiento/validación ├─ eval.py # evaluación final ├─ config.py # hiperparámetros ├─ utils.py # semillas, métricas, logging └─ checkpoints/ # pesos guardados2) Reproducibilidad en PyTorch
En GPU pueden existir fuentes de no determinismo. Aun así, fijar semillas y activar modos deterministas (cuando sea posible) mejora la trazabilidad.
# utils.py import os, random import numpy as np import torch def set_seed(seed=42, deterministic=True): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) if deterministic: torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False os.environ["PYTHONHASHSEED"] = str(seed)3) Uso de GPU (conceptual y práctico)
Conceptualmente, “usar GPU” significa mover tanto el modelo como los tensores al mismo dispositivo. En la práctica, el patrón es: detectar dispositivo → model.to(device) → en cada batch X.to(device), y.to(device). Si algo queda en CPU, obtendrás errores de device mismatch.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device)4) Dataset y DataLoader (ejemplo tabular)
Para tabular, un TensorDataset suele bastar. Para imágenes, usarías torchvision.datasets y transforms. Aquí mostramos tabular por simplicidad.
# datasets.py import torch from torch.utils.data import TensorDataset, DataLoader def make_loaders(Xtr, ytr, Xva, yva, batch_size=64): Xtr_t = torch.tensor(Xtr, dtype=torch.float32) ytr_t = torch.tensor(ytr, dtype=torch.long) Xva_t = torch.tensor(Xva, dtype=torch.float32) yva_t = torch.tensor(yva, dtype=torch.long) train_ds = TensorDataset(Xtr_t, ytr_t) val_ds = TensorDataset(Xva_t, yva_t) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False) return train_loader, val_loader5) Definir el modelo (MLP) con nn.Module
La biblioteca se encarga del autograd y del manejo de parámetros. Tú defines la arquitectura y el forward.
# models.py import torch.nn as nn class MLP(nn.Module): def __init__(self, in_features=2, hidden=32, num_classes=2): super().__init__() self.net = nn.Sequential( nn.Linear(in_features, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, num_classes) ) def forward(self, x): return self.net(x) # logits6) Función de pérdida y optimizador
Para clasificación multiclase con logits, una opción estándar es nn.CrossEntropyLoss (incluye softmax internamente). El optimizador puede ser SGD o Adam; aquí usamos Adam por conveniencia.
criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)7) Bucle de entrenamiento y validación (plantilla reutilizable)
Separa claramente modo entrenamiento (model.train()) y validación (model.eval()), y usa torch.no_grad() en validación para ahorrar memoria.
# train.py import torch from utils import set_seed def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for X, y in loader: X = X.to(device) y = y.to(device) optimizer.zero_grad() logits = model(X) loss = criterion(logits, y) loss.backward() optimizer.step() total_loss += loss.item() * X.size(0) preds = torch.argmax(logits, dim=1) correct += (preds == y).sum().item() total += X.size(0) return total_loss / total, correct / total @torch.no_grad() def validate(model, loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 for X, y in loader: X = X.to(device) y = y.to(device) logits = model(X) loss = criterion(logits, y) total_loss += loss.item() * X.size(0) preds = torch.argmax(logits, dim=1) correct += (preds == y).sum().item() total += X.size(0) return total_loss / total, correct / total def fit(model, train_loader, val_loader, criterion, optimizer, device, epochs=20): history = [] for epoch in range(1, epochs+1): tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) va_loss, va_acc = validate(model, val_loader, criterion, device) history.append({"epoch": epoch, "train_loss": tr_loss, "train_acc": tr_acc, "val_loss": va_loss, "val_acc": va_acc}) print(f"epoch={epoch:03d} train_loss={tr_loss:.4f} train_acc={tr_acc:.4f} val_loss={va_loss:.4f} val_acc={va_acc:.4f}") return history8) Registro de métricas: de prints a CSV o TensorBoard
Un primer paso es guardar history en CSV para graficar luego. Si quieres algo más completo, TensorBoard permite visualizar curvas y comparar experimentos.
# utils.py import csv def save_history_csv(history, path): with open(path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=history[0].keys()) writer.writeheader() writer.writerows(history)Ejemplo conceptual con TensorBoard (si lo habilitas): registrar train_loss, val_loss, train_acc, val_acc por época para detectar sobreajuste o estancamiento.
9) Guardado y carga de pesos (checkpoints)
Guarda el state_dict del modelo (y opcionalmente del optimizador) para reanudar entrenamiento o desplegar.
# checkpoints import torch def save_checkpoint(path, model, optimizer=None, epoch=None): payload = {"model_state": model.state_dict()} if optimizer is not None: payload["optim_state"] = optimizer.state_dict() if epoch is not None: payload["epoch"] = epoch torch.save(payload, path) def load_checkpoint(path, model, optimizer=None, map_location="cpu"): payload = torch.load(path, map_location=map_location) model.load_state_dict(payload["model_state"]) if optimizer is not None and "optim_state" in payload: optimizer.load_state_dict(payload["optim_state"]) return payload10) Paso a paso: entrenamiento completo (script mínimo)
Este script conecta todo: semillas, datos, loaders, modelo, pérdida, optimizador, entrenamiento, guardado.
# main.py import numpy as np import torch from data import make_toy_data from datasets import make_loaders from models import MLP from utils import set_seed, save_history_csv from train import fit from checkpoints import save_checkpoint def main(): set_seed(42) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") Xtr, ytr, Xva, yva = make_toy_data(n=5000, seed=42) train_loader, val_loader = make_loaders(Xtr, ytr, Xva, yva, batch_size=128) model = MLP(in_features=2, hidden=64, num_classes=2).to(device) criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) history = fit(model, train_loader, val_loader, criterion, optimizer, device, epochs=30) save_history_csv(history, "history.csv") save_checkpoint("checkpoints/mlp_toy.pt", model, optimizer=optimizer, epoch=len(history)) if __name__ == "__main__": main()Comparación práctica: desde cero vs biblioteca
| Aspecto | Desde cero (NumPy) | Con biblioteca (PyTorch) |
|---|---|---|
| Gradientes | Los implementas y depuras | Autograd los calcula |
| Velocidad/escala | Limitada | Alta, GPU disponible |
| Flexibilidad | Alta pero manual | Alta con componentes estándar |
| Buenas prácticas | Dependen de tu disciplina | Patrones establecidos (DataLoader, checkpoints) |
| Cuándo usar | Aprendizaje y prototipos didácticos | Proyectos reales y experimentación rápida |
Proyecto final propuesto: clasificador completo con documentación técnica
Enunciado
Entrena un clasificador y entrega: (a) código organizado por módulos; (b) registro de métricas; (c) checkpoints; (d) un breve informe técnico (README) justificando decisiones de arquitectura y regularización.
Opción A: Clasificación tabular
- Dataset: uno tabular con variables numéricas/categóricas (puedes preprocesar fuera del modelo o dentro con embeddings si aplica).
- Modelo: MLP con 2–4 capas, tamaños a elección.
- Regularización a documentar: dropout, weight decay (L2), early stopping, normalización de entradas.
- Métricas: accuracy y, si hay desbalance, F1 o AUC (elige y justifica).
Opción B: Clasificación de imágenes
- Dataset: un conjunto pequeño de imágenes (por ejemplo, 10 clases) con transformaciones básicas.
- Modelo: una CNN sencilla o un modelo preentrenado con fine-tuning (si decides usar transferencia, documenta qué capas congelas y por qué).
- Regularización a documentar: data augmentation, weight decay, dropout, scheduler de learning rate.
- Métricas: accuracy y matriz de confusión por clase.
Requisitos de entrega (checklist)
- Reproducibilidad: semillas fijadas y anotadas en
config.py. - Configuración: hiperparámetros centralizados (lr, batch_size, epochs, arquitectura).
- Entrenamiento/validación: curvas guardadas (CSV o TensorBoard).
- Checkpoints: guardar el mejor modelo según métrica de validación.
- Informe: explicar al menos 3 decisiones: tamaño de red, activaciones, regularización/early stopping, y criterio para seleccionar el “mejor” modelo.