Implementación práctica de redes neuronales: de cero y con bibliotecas

Capítulo 12

Tiempo estimado de lectura: 14 minutos

+ Ejercicio

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, helpers

2) 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 dX

5) 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) / batch

6) 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 → backwardstep. 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 b y el reshape de y).
  • Escala de inicialización: si logits saturan (muy grandes), baja la varianza inicial o el lr.
  • Gradientes: imprime normas de dW para 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 guardados

2) 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_loader

5) 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)  # logits

6) 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 history

8) 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 payload

10) 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

AspectoDesde cero (NumPy)Con biblioteca (PyTorch)
GradientesLos implementas y depurasAutograd los calcula
Velocidad/escalaLimitadaAlta, GPU disponible
FlexibilidadAlta pero manualAlta con componentes estándar
Buenas prácticasDependen de tu disciplinaPatrones establecidos (DataLoader, checkpoints)
Cuándo usarAprendizaje y prototipos didácticosProyectos 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.

Ahora responde el ejercicio sobre el contenido:

En un entrenamiento con PyTorch, ¿qué práctica distingue correctamente la validación del entrenamiento para ahorrar memoria y evitar actualizar el modelo?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

En validación se desactiva el comportamiento de entrenamiento con model.eval() y se evita el cómputo de gradientes usando torch.no_grad(), reduciendo uso de memoria y evitando actualizaciones.

Siguiente capítulo

Interpretabilidad, depuración y evaluación robusta de modelos neuronales

Arrow Right Icon
Portada de libro electrónico gratuitaIntroducción a las Redes Neuronales: Del Perceptrón al Deep Learning
92%

Introducción a las Redes Neuronales: Del Perceptrón al Deep Learning

Nuevo curso

13 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.