MATHS + AI
Menú
Redes Neuronales con PyTorch

Curso interactivo · PyTorch

Redes neuronales, de la neurona a la imagen clasificada

Un recorrido completo y autocontenido: cómo se define una neurona artificial, qué tipos de capas existen, cómo aprende una red por descenso de gradiente y retropropagación, y cómo evitar que memorice en lugar de generalizar. Termina con un clasificador de imágenes construido paso a paso en PyTorch.

Requiere
Python y álgebra lineal básica
Librería
PyTorch (torch, torch.nn, torchvision)
Formato
12 módulos, teoría + demos + ejemplo final

1Fundamentos

¿Qué es una red neuronal?

Una función matemática construida por capas, cada una demasiado simple para hacer nada interesante por sí sola.

Una red neuronal artificial es, formalmente, una composición de funciones sencillas —combinaciones lineales seguidas de una no linealidad— organizadas en capas. Ninguna capa individual es especialmente expresiva: una capa es apenas una transformación afín z = Wx + b seguida de una función escalar aplicada elemento a elemento. Lo interesante ocurre al componer muchas de estas capas: la red completa puede aproximar funciones arbitrariamente complejas, siempre que tenga suficientes neuronas y una no linealidad adecuada entre capas. Este resultado no es una intuición vaga; tiene forma de teorema. El teorema de aproximación universal (Cybenko, 1989; Hornik, 1991) garantiza que una red con una sola capa oculta suficientemente ancha puede aproximar cualquier función continua en un dominio compacto con la precisión que se desee. En la práctica no se usan redes anchas de una sola capa: se prefieren redes profundas (muchas capas, cada una moderadamente ancha), porque la composición de transformaciones permite construir características jerárquicas —bordes, luego texturas, luego partes, luego objetos— con muchos menos parámetros que una única capa ancha.

Un poco de historia, en cuatro pasos

El modelo de neurona artificial más antiguo es el de McCulloch y Pitts (1943), una unidad binaria de umbral inspirada libremente en la neurona biológica. Rosenblatt formalizó en 1958 el perceptrón, la primera neurona entrenable mediante una regla de ajuste de pesos basada en el error. Minsky y Papert mostraron en 1969 que un perceptrón simple no puede resolver problemas no separables linealmente —el ejemplo canónico es la función XOR, que veremos de forma interactiva en la siguiente sección—, lo que enfrió el interés en el campo durante más de una década. El algoritmo de retropropagación, popularizado por Rumelhart, Hinton y Williams en 1986, resolvió el problema de entrenar redes con capas ocultas y permitió superar precisamente esa limitación. Desde entonces, el ingrediente que ha cambiado más ha sido la escala: más datos, más cómputo y arquitecturas más profundas, no una reformulación radical de la idea original.

La idea que sostiene todo el curso

Una red neuronal se especifica por su arquitectura (cómo están conectadas las capas) y se ajusta por sus parámetros (los pesos y sesgos de cada capa). Aprender es, en este contexto, un problema de optimización: encontrar los valores de esos parámetros que minimizan una función de pérdida sobre un conjunto de datos, mediante descenso de gradiente. Todo el curso desarrolla esta única frase.

2Fundamentos

La neurona artificial

Una suma ponderada, un sesgo, y una no linealidad al final.

Dado un vector de entrada x = (x₁, …, xₙ) ∈ ℝⁿ, una neurona artificial calcula primero una combinación afín de sus entradas:

z = w·x + b = w₁x₁ + w₂x₂ + … + wₙxₙ + b

donde w ∈ ℝⁿ es el vector de pesos y b ∈ ℝ es el sesgo (bias). Después, aplica una función de activación no lineal φ al resultado: la salida de la neurona es a = φ(z). Geométricamente, la ecuación w·x + b = 0 define un hiperplano en ℝⁿ: w es su vector normal y b controla el desplazamiento respecto al origen. Antes de aplicar φ, la neurona no hace otra cosa que medir a qué lado de ese hiperplano cae la entrada, y con qué margen. Una sola neurona con activación tipo escalón es, literalmente, un clasificador lineal binario —esto es exactamente el perceptrón de Rosenblatt.

En PyTorch, una capa de n neuronas totalmente conectadas a m entradas se implementa con nn.Linear(m, n), que internamente guarda una matriz de pesos W de tamaño n×m y un vector de sesgos b de tamaño n, y calcula y = xWᵀ + b para todo un lote de entradas a la vez.

Demo interactiva: el perceptrón como clasificador lineal

Ajusta los pesos w₁, w₂ y el sesgo b con los deslizadores y observa cómo se mueve la frontera de decisión (la recta w₁x₁ + w₂x₂ + b = 0) sobre cuatro puntos que representan una función lógica de dos entradas. El color de cada punto es su clase real; el anillo indica si el perceptrón, con los pesos actuales, la clasifica correctamente.

Frontera de decisión de un perceptrón Arrastra los deslizadores o pulsa «Autoajustar»
z = w₁x₁ + w₂x₂ + b Aciertos: / 4

Con AND y OR, siempre existe alguna recta que separa perfectamente los dos grupos: basta mover los deslizadores un poco para encontrarla, y «Autoajustar» la encuentra ejecutando unos pasos de descenso de gradiente sobre una regresión logística. Con XOR, en cambio, ninguna recta separa las dos clases —los puntos positivos están en esquinas opuestas—; es un problema no separable linealmente. la pérdida logística es convexa, así que «Autoajustar» converge de forma determinista a su único mínimo global — y, por la simetría de XOR, ese mínimo está en w₁≈w₂≈b≈0: una frontera que no favorece ningún punto y que, por tanto, no acierta mejor que el azar (2 de 4). Esta es precisamente la limitación que Minsky y Papert señalaron en 1969: un perceptrón resuelve solo problemas linealmente separables. La solución no es una neurona más lista, sino más neuronas en una capa oculta: cada neurona oculta traza su propia recta, y la capa de salida combina esas rectas en una región no convexa capaz de separar XOR. Esto es exactamente lo que veremos en la siguiente sección.

3Fundamentos

Funciones de activación

Sin no linealidad, apilar capas no sirve de nada.

Antes de catalogar las funciones de activación conviene entender por qué son imprescindibles. Si dos capas fueran puramente afines, su composición seguiría siendo afín: W₂(W₁x + b₁) + b₂ = (W₂W₁)x + (W₂b₁ + b₂), que tiene exactamente la misma forma que una sola capa con pesos W₂W₁ y sesgo W₂b₁ + b₂. Apilar cien capas lineales sin nada entre medias equivale, en poder expresivo, a una sola capa lineal. La función de activación —aplicada entre capas— es lo que rompe esa degeneración y permite que la profundidad aporte algo.

FunciónFórmulaRangoNota
Sigmoideσ(z) = 1 / (1 + e⁻ᶻ)(0, 1)Satura para |z| grande → gradientes casi nulos. Útil en la salida para probabilidades binarias.
Tanhtanh(z) = 2σ(2z) − 1(−1, 1)Centrada en cero, pero satura igual que la sigmoide.
ReLUmax(0, z)[0, ∞)Barata, no satura para z>0. Puede «morir» si z queda siempre negativo.
Leaky ReLUmax(αz, z)(−∞, ∞)Deja pasar un gradiente pequeño (α≈0.01) cuando z<0. Evita neuronas muertas.
GELU / SiLUz·Φ(z) / z·σ(z)≈(−0.17, ∞)Versiones suaves de ReLU; estándar en Transformers modernos.
Softmaxeᶻⁱ / Σⱼ eᶻʲ(0,1), suma 1Solo en la capa de salida para clasificación multiclase: convierte logits en una distribución de probabilidad.

La derivada de la sigmoide es σ'(z) = σ(z)(1−σ(z)), que alcanza un máximo de apenas 0.25 en z=0 y tiende a cero muy rápido en los extremos. En una red profunda, la regla de la cadena multiplica estas derivadas capa a capa: si cada una aporta un factor menor que 1, el gradiente que llega a las primeras capas se desvanece exponencialmente con la profundidad —el conocido problema del desvanecimiento del gradiente (vanishing gradient)—. Es la razón principal por la que ReLU y sus variantes desplazaron a sigmoide/tanh como activación por defecto en capas ocultas: para z>0, la derivada de ReLU es exactamente 1, sin atenuación.

Demo interactiva: forma y derivada de cada activación

Explorador de funciones de activación φ(z) en color, φ'(z) en línea punteada
φ(z) φ'(z)

Qué activación usar, en la práctica

En capas ocultas de arquitecturas convolucionales o densas «clásicas», ReLU sigue siendo un punto de partida razonable por su simplicidad y coste; Leaky ReLU o GELU son alternativas seguras si se observan neuronas muertas. En la capa de salida, la elección depende del problema: sigmoide para clasificación binaria (una probabilidad), softmax para clasificación multiclase excluyente (una distribución sobre C clases), y ninguna activación —salida lineal— para regresión.

Autoevaluación · Activaciones

1. ¿Por qué no basta con apilar capas puramente lineales (sin activación)?

2. ¿Qué problema resuelve ReLU frente a sigmoide/tanh en redes profundas?

3. ¿Cuándo usarías softmax como activación?

4Fundamentos

Tipos de capas

Distintas formas de conectar neuronas para distintos tipos de datos.

Capa totalmente conectada — nn.Linear

Cada neurona de salida se conecta a todas las entradas. Es la capa más general y la más cara en parámetros: una capa nn.Linear(1000, 500) tiene 1000×500 + 500 = 500 500 parámetros. Se usa en datos tabulares, en la parte final «clasificadora» de una CNN, y en cualquier situación donde no haya una estructura espacial o secuencial que aprovechar.

Capa convolucional — nn.Conv2d

Diseñada para datos con estructura espacial, como imágenes. En lugar de conectar cada neurona a toda la entrada, un filtro (o kernel) pequeño —por ejemplo 3×3— se desliza sobre la imagen calculando, en cada posición, una suma ponderada local. El mismo filtro se reutiliza en toda la imagen (compartición de pesos), lo que reduce drásticamente el número de parámetros frente a una capa densa equivalente y da a la red una propiedad muy deseable: si un filtro aprende a detectar un borde, lo detecta en cualquier posición de la imagen. Una capa convolucional aprende, típicamente, varios filtros a la vez —cada uno produce un canal de salida distinto—; los primeros filtros de una red suelen especializarse en bordes y texturas simples, y los de capas más profundas en patrones cada vez más abstractos (partes de objetos, objetos completos).

El tamaño espacial de la salida sigue la fórmula ⌊(W − K + 2P) / S⌋ + 1, donde W es el tamaño de entrada, K el tamaño del kernel, P el relleno (padding) y S el desplazamiento (stride).

Agrupamiento — nn.MaxPool2d / nn.AvgPool2d

Reduce la resolución espacial tomando el máximo (o el promedio) en pequeñas ventanas, típicamente 2×2. Aporta dos cosas: menos cómputo en las capas siguientes, y una forma de invariancia a pequeñas traslaciones —si el patrón detectado se desplaza uno o dos píxeles, el máximo de la ventana probablemente no cambia—.

Normalización — nn.BatchNorm2d / nn.BatchNorm1d

Normaliza las activaciones de cada canal a media 0 y varianza 1 dentro de cada lote de entrenamiento, y después aplica una escala y un desplazamiento aprendibles (γ, β) para no restringir lo que la capa puede representar. Estabiliza y acelera el entrenamiento al mantener las activaciones en un rango razonable capa tras capa, y tiene, además, un efecto regularizador suave: como la normalización depende del lote concreto, introduce algo de ruido en el entrenamiento.

Dropout — nn.Dropout

Durante el entrenamiento, anula aleatoriamente una fracción p de las activaciones en cada paso (típicamente p entre 0.2 y 0.5), y reescala el resto para compensar. Fuerza a la red a no depender en exceso de neuronas concretas, lo que reduce el sobreajuste. Es puramente una técnica de entrenamiento: durante la evaluación, Dropout se desactiva y todas las neuronas participan —de ahí la importancia de model.eval(), que veremos en la sección 9—. La desarrollamos en profundidad en la sección 10.

Capas recurrentes — nn.RNN, nn.LSTM, nn.GRU

Pensadas para datos secuenciales (texto, series temporales, audio): mantienen un estado interno que se actualiza en cada paso de la secuencia. Quedan fuera del alcance de este curso, centrado en redes densas y convolucionales, pero conviene saber que existen y para qué sirven.

Demo interactiva: paso hacia adelante en una red por capas

Esta pequeña red tiene una capa de entrada, dos capas ocultas y una capa de salida. El botón «Forward pass» anima el flujo de información de izquierda a derecha —así calcula una red su predicción—; el botón «Retropropagación» anima el flujo inverso, de derecha a izquierda —así se propaga el gradiente del error hacia atrás, tema de la sección 7—.

Propagación en una red de 4 capas Observa la dirección del flujo

5Fundamentos

Construir un modelo en PyTorch

Toda arquitectura se define subclasificando nn.Module.

En PyTorch, un modelo se define como una clase que hereda de nn.Module. El constructor __init__ declara las capas como atributos —cada una con sus propios parámetros entrenables—, y el método forward especifica, con código Python normal, cómo esas capas se combinan para transformar una entrada en una salida. Esto es lo que se conoce como grafo de cómputo definido en ejecución (define-by-run): a diferencia de frameworks con grafos estáticos, en PyTorch el grafo se construye dinámicamente cada vez que se llama a forward, lo que permite usar bucles, condicionales o cualquier lógica de Python dentro del modelo.

python
import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, in_dim=2, hidden=16, out_dim=1):
        super().__init__()
        self.fc1 = nn.Linear(in_dim, hidden)
        self.fc2 = nn.Linear(hidden, hidden)
        self.fc3 = nn.Linear(hidden, out_dim)
        self.act = nn.ReLU()

    def forward(self, x):
        x = self.act(self.fc1(x))
        x = self.act(self.fc2(x))
        x = self.fc3(x)          # sin activación: logit crudo
        return x

model = MLP()
print(model)

# Número total de parámetros entrenables
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Parámetros entrenables: {n_params}")

# Mover el modelo a GPU si está disponible
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)

Este MLP —perceptrón multicapa— es exactamente la generalización de la neurona única de la sección 2: dos capas ocultas con ReLU le dan la capacidad de resolver XOR, algo que un perceptrón individual no podía. Nótese que la última capa no lleva activación: se deja como salida lineal cruda (logit), porque —como veremos en la sección 6— la función de pérdida nn.BCEWithLogitsLoss o nn.CrossEntropyLoss aplican internamente la sigmoide o la softmax de forma numéricamente más estable que hacerlo a mano.

Error común

Aplicar softmax manualmente antes de nn.CrossEntropyLoss es un error frecuente: esa función ya combina LogSoftmax y NLLLoss internamente. Aplicar softmax dos veces no rompe el código, pero desvirtúa por completo el entrenamiento.

6Aprendizaje

La función de pérdida

Un único número que resume qué tan equivocado está el modelo.

En aprendizaje supervisado se dispone de un conjunto de ejemplos {(xᵢ, yᵢ)}ᵢ₌₁ᴺ y de un modelo f_θ parametrizado por θ (todos los pesos y sesgos de la red). El objetivo del entrenamiento es encontrar los parámetros θ que minimizan una función de pérdida L(θ), que mide la discrepancia entre las predicciones ŷᵢ = f_θ(xᵢ) y los valores reales yᵢ. La elección de L depende del tipo de problema:

ProblemaPérdidaFórmula (por ejemplo)Clase PyTorch
RegresiónError cuadrático medio (MSE)(ŷ − y)²nn.MSELoss
Clasificación binariaEntropía cruzada binaria−[y·log ŷ + (1−y)·log(1−ŷ)]nn.BCEWithLogitsLoss
Clasificación multiclaseEntropía cruzada−log ŷ_c  (c = clase correcta)nn.CrossEntropyLoss

La pérdida total sobre un lote de datos es, casi siempre, el promedio de la pérdida individual de cada ejemplo: L(θ) = (1/N) Σᵢ ℓ(f_θ(xᵢ), yᵢ). En PyTorch, la pérdida se calcula con una única llamada tras el forward:

python
criterion = nn.CrossEntropyLoss()   # espera logits crudos, no softmax
logits = model(x_batch)             # forma (N, num_clases)
loss = criterion(logits, y_batch)   # y_batch: enteros de clase, forma (N,)

nn.CrossEntropyLoss espera logits sin normalizar (no probabilidades) y las etiquetas como enteros de clase, no como vectores one-hot; es una de las convenciones de PyTorch que conviene memorizar, porque un desajuste en las formas o en el tipo de las etiquetas es una fuente habitual de errores silenciosos.

7Aprendizaje

Backpropagation y autograd

La regla de la cadena, aplicada de forma sistemática y automática.

Minimizar L(θ) por descenso de gradiente requiere conocer ∂L/∂θ para cada parámetro de la red —potencialmente millones de derivadas parciales—. La retropropagación es, simplemente, una aplicación eficiente de la regla de la cadena: como la red es una composición de funciones, la derivada de la pérdida respecto a los parámetros de una capa cualquiera se obtiene multiplicando las derivadas locales de cada capa, propagando el cálculo desde la salida (donde se conoce la derivada de la pérdida) hacia la entrada.

PyTorch automatiza este proceso mediante autograd. Cada tensor con requires_grad=True registra las operaciones que se le aplican, construyendo dinámicamente un grafo de cómputo. Al llamar a loss.backward(), PyTorch recorre ese grafo en sentido inverso y acumula en el atributo .grad de cada tensor hoja (típicamente, los pesos de cada capa) la derivada de la pérdida respecto a ese tensor.

python
logits = model(x_batch)
loss = criterion(logits, y_batch)

optimizer.zero_grad()   # limpia los gradientes acumulados del paso anterior
loss.backward()         # calcula d(loss)/d(theta) para todos los parámetros
optimizer.step()        # actualiza theta usando esos gradientes
El bug más común en PyTorch

Los gradientes se acumulan por defecto en .grad: cada llamada a backward() los suma a los ya existentes, no los reemplaza. Omitir optimizer.zero_grad() antes de cada backward() mezcla gradientes de distintos lotes y produce un entrenamiento incorrecto sin lanzar ningún error, lo que lo hace especialmente difícil de detectar.

Durante la evaluación o la inferencia no es necesario construir el grafo de cómputo —no se va a llamar a backward()—, así que conviene envolver ese código en torch.no_grad(): PyTorch deja de rastrear operaciones, lo que ahorra memoria y acelera el cálculo.

python
model.eval()
with torch.no_grad():
    logits = model(x_val)
    preds = logits.argmax(dim=1)

Para ver la retropropagación en acción sobre una arquitectura concreta, vuelve al diagrama animado de la sección 4 y pulsa «Retropropagación»: el flujo de color viaja de la salida hacia la entrada, en sentido opuesto al forward pass.

8Aprendizaje

Optimizadores

Autograd calcula el gradiente; el optimizador decide qué hacer con él.

La regla de actualización más simple es el descenso de gradiente: θ ← θ − η∇L(θ), donde η es la tasa de aprendizaje (learning rate). En la práctica casi nunca se calcula el gradiente sobre todo el conjunto de datos a la vez, sino sobre pequeños lotes (mini-batches): esto es descenso de gradiente estocástico (SGD), más barato por paso y, gracias al ruido que introduce, con cierta capacidad para escapar de mínimos locales poco profundos.

Momentum

Añadir momentum acumula una media móvil de gradientes pasados y actualiza en esa dirección: v ← βv + ∇L(θ), θ ← θ − ηv, con β típicamente 0.9. Esto amortigua las oscilaciones en direcciones donde el gradiente cambia de signo con frecuencia (barrancos estrechos de la superficie de pérdida) y acelera el avance en direcciones consistentes.

Adam

Adam (Adaptive Moment Estimation) mantiene, para cada parámetro individualmente, una estimación del primer momento (media, como el momentum) y del segundo momento (varianza) del gradiente, y usa ambos para adaptar la tasa de aprendizaje efectiva de cada parámetro por separado. Es, en la práctica, el optimizador por defecto más habitual: converge razonablemente rápido y es poco sensible a la elección exacta de η.

python
import torch.optim as optim

# SGD con momentum
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

# Adam, la opción por defecto habitual
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

# Programación de la tasa de aprendizaje: reduce lr cuando la
# pérdida de validación deja de mejorar
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode="min", factor=0.5, patience=3
)

Demo interactiva: descenso de gradiente sobre una superficie de pérdida

La curva representa una función de pérdida simplificada con una meseta y un mínimo poco profundo antes del mínimo global —una situación realista donde una tasa de aprendizaje mal elegida marca la diferencia entre converger, estancarse u oscilar sin control—.

Descenso de gradiente, paso a paso Prueba distintos η y compara con/sin momentum
Paso: 0 x: Pérdida:

Con una tasa de aprendizaje pequeña, la bola desciende con seguridad pero puede quedar atrapada en el mínimo local poco profundo. Con una tasa demasiado alta, cada paso sobrepasa el mínimo y la trayectoria oscila o diverge —observa cómo la pérdida crece en lugar de decrecer—. El momentum ayuda a atravesar la meseta y, con suerte, a escapar del mínimo local usando la inercia acumulada, aunque con una tasa de aprendizaje muy alta también puede amplificar la divergencia.

Autoevaluación · Backprop y optimizadores

1. ¿Qué ocurre si olvidas llamar a optimizer.zero_grad() antes de backward()?

2. Una tasa de aprendizaje demasiado alta suele manifestarse como…

3. ¿Qué distingue a Adam de SGD con momentum?

9Aprendizaje

El bucle de entrenamiento

La forma canónica que adopta prácticamente cualquier script de entrenamiento en PyTorch.

Dataset y DataLoader son las dos piezas que preparan los datos. Un Dataset personalizado solo necesita implementar __len__ (cuántos ejemplos hay) y __getitem__ (cómo obtener el ejemplo i-ésimo); DataLoader se encarga de agrupar ejemplos en lotes, barajarlos en cada época y, opcionalmente, cargarlos en paralelo con varios procesos.

python
from torch.utils.data import DataLoader

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader   = DataLoader(val_dataset,   batch_size=64, shuffle=False)

for epoch in range(num_epochs):
    # ----- entrenamiento -----
    model.train()                      # activa Dropout / BatchNorm en modo entrenamiento
    running_loss = 0.0
    for x_batch, y_batch in train_loader:
        x_batch, y_batch = x_batch.to(device), y_batch.to(device)

        optimizer.zero_grad()
        logits = model(x_batch)
        loss = criterion(logits, y_batch)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * x_batch.size(0)

    train_loss = running_loss / len(train_dataset)

    # ----- validación -----
    model.eval()                       # desactiva Dropout, BatchNorm usa estadísticas fijas
    val_loss, correct = 0.0, 0
    with torch.no_grad():
        for x_batch, y_batch in val_loader:
            x_batch, y_batch = x_batch.to(device), y_batch.to(device)
            logits = model(x_batch)
            loss = criterion(logits, y_batch)
            val_loss += loss.item() * x_batch.size(0)
            correct += (logits.argmax(1) == y_batch).sum().item()

    val_loss /= len(val_dataset)
    val_acc = correct / len(val_dataset)

    print(f"Época {epoch+1}: train_loss={train_loss:.4f}  "
          f"val_loss={val_loss:.4f}  val_acc={val_acc:.4f}")

Dos llamadas son fáciles de pasar por alto pero críticas: model.train() antes de entrenar y model.eval() antes de evaluar. Capas como Dropout y BatchNorm se comportan de forma distinta en cada modo —Dropout anula neuronas solo en entrenamiento; BatchNorm usa estadísticas del lote actual en entrenamiento, pero estadísticas acumuladas fijas en evaluación—. Olvidar el cambio de modo produce métricas de validación poco fiables sin que PyTorch avise de nada.

10Aprendizaje

Overfitting, underfitting y regularización

Un modelo que memoriza el conjunto de entrenamiento no ha aprendido nada útil.

El objetivo real del entrenamiento no es minimizar la pérdida sobre los datos ya vistos, sino generalizar a datos nuevos. Para medirlo, los datos se dividen habitualmente en tres subconjuntos: entrenamiento (ajusta los parámetros), validación (guía decisiones sobre hiperparámetros y cuándo detener el entrenamiento, sin tocar los pesos directamente) y test (evaluación final, una sola vez, con datos que el modelo ni el proceso de selección de hiperparámetros han visto nunca).

Un modelo underfitted (alto sesgo) es demasiado simple para capturar la estructura de los datos: la pérdida de entrenamiento ya es alta. Un modelo overfitted (alta varianza) ha aprendido detalles específicos del conjunto de entrenamiento —incluido su ruido— que no generalizan: la pérdida de entrenamiento sigue bajando mientras la de validación empieza a subir. Esta divergencia entre ambas curvas es la señal diagnóstica por excelencia.

Curvas de entrenamiento vs. validación Compara con y sin regularización
Pérdida de entrenamiento Pérdida de validación
Sin regularización: la pérdida de validación toca fondo hacia la época 8 y luego empeora — el modelo empieza a memorizar el ruido del conjunto de entrenamiento.

El arsenal contra el overfitting

Más datos y aumento de datos (data augmentation)

La forma más directa de reducir el overfitting es, sencillamente, disponer de más datos. Cuando conseguir más datos reales no es viable, el aumento de datos genera variaciones artificiales pero realistas de los ejemplos existentes —volteos horizontales, recortes aleatorios, pequeñas rotaciones, cambios de brillo— aplicadas solo al conjunto de entrenamiento. En imágenes, esto equivale a enseñarle al modelo invariancias que ya sabemos que son ciertas (una silla sigue siendo una silla si la imagen se voltea horizontalmente).

Dropout

Ya introducido en la sección 4: al anular aleatoriamente neuronas durante el entrenamiento, impide que la red dependa de coaliciones frágiles de neuronas concretas memorizando ejemplos específicos.

Weight decay (regularización L2)

Añade a la pérdida un término proporcional a la norma al cuadrado de los pesos: L_reg(θ) = L(θ) + (λ/2)Σθ². Matemáticamente es idéntico al término de penalización de la regresión ridge. Empuja los pesos hacia valores pequeños, lo que suaviza la función que aprende la red y reduce su sensibilidad a variaciones pequeñas en la entrada. En PyTorch se activa con el argumento weight_decay del optimizador (nótese que en Adam, weight_decay no es exactamente equivalente a L2 clásico por cómo interactúa con la normalización adaptativa; AdamW corrige esto desacoplando ambos términos).

Batch Normalization

Como se mencionó en la sección 4, el ruido introducido por normalizar con estadísticas de mini-lotes actúa como un regularizador suave, además de su función principal de estabilizar el entrenamiento.

Early stopping

Se monitoriza la pérdida de validación al final de cada época y se guarda el mejor checkpoint del modelo. Si la pérdida de validación no mejora durante un número de épocas consecutivas (patience), el entrenamiento se detiene y se recupera ese mejor checkpoint, aunque el entrenamiento hubiera continuado bajando la pérdida de entrenamiento.

Reducir la capacidad del modelo

Un modelo con menos parámetros (menos capas, menos canales o unidades por capa) tiene, literalmente, menos margen para memorizar el ruido del conjunto de entrenamiento. Es la palanca más directa, aunque también la que más fácilmente puede pasarse de frenada hacia el underfitting.

TécnicaEn PyTorch
Aumento de datostorchvision.transforms (RandomHorizontalFlip, RandomCrop, …)
Dropoutnn.Dropout(p=0.3)
Weight decayoptim.AdamW(params, weight_decay=1e-4)
Batch Normalizationnn.BatchNorm2d(num_channels)
Early stoppingLógica manual: guardar checkpoint si val_loss mejora
Autoevaluación · Overfitting y regularización

1. ¿Cuál es la señal más característica de overfitting en las curvas de entrenamiento?

2. ¿Qué hace exactamente Dropout durante el entrenamiento?

3. El «weight decay» (L2) matemáticamente equivale a…

11Práctica

Ejemplo completo: clasificación de imágenes

Fashion-MNIST con una CNN, aplicando cada concepto anterior en un pipeline real.

Fashion-MNIST es un conjunto de 70 000 imágenes en escala de grises de 28×28 píxeles, repartidas en 10 categorías de prendas de ropa (camiseta, pantalón, jersey, vestido, abrigo, sandalia, camisa, zapatilla, bolso, bota). Es un sustituto directo del MNIST clásico de dígitos, algo más difícil, y suficientemente ligero para entrenarse en minutos incluso sin GPU dedicada — un banco de pruebas razonable para ver un pipeline completo de principio a fin.

Paso 1 · Imports y dispositivo

python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
import torchvision
import torchvision.transforms as T

torch.manual_seed(0)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Usando dispositivo: {device}")

Paso 2 · Datos, transformaciones y aumento

Las transformaciones de aumento —volteo horizontal y recorte aleatorio con relleno— se aplican solo al conjunto de entrenamiento; validación y test usan únicamente normalización, porque deben reflejar la distribución real de datos que el modelo verá en producción.

python
MEAN, STD = 0.2860, 0.3530   # estadísticas del conjunto Fashion-MNIST

train_transform = T.Compose([
    T.RandomHorizontalFlip(),
    T.RandomCrop(28, padding=4),
    T.ToTensor(),
    T.Normalize((MEAN,), (STD,)),
])
eval_transform = T.Compose([
    T.ToTensor(),
    T.Normalize((MEAN,), (STD,)),
])

full_train = torchvision.datasets.FashionMNIST(
    root="./data", train=True, download=True, transform=train_transform)
test_set = torchvision.datasets.FashionMNIST(
    root="./data", train=False, download=True, transform=eval_transform)

# Se reserva un 10% del set de entrenamiento como validación
n_val = int(0.1 * len(full_train))
n_train = len(full_train) - n_val
train_set, val_set = random_split(full_train, [n_train, n_val])
val_set.dataset.transform = eval_transform   # validación sin aumento

train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2)
val_loader   = DataLoader(val_set,   batch_size=256, shuffle=False, num_workers=2)
test_loader  = DataLoader(test_set,  batch_size=256, shuffle=False, num_workers=2)

Paso 3 · Arquitectura: una CNN con regularización integrada

Dos bloques convolucionales (Conv → BatchNorm → ReLU, repetido dos veces, seguido de MaxPool y Dropout), y un clasificador denso final. Cada elección remite directamente a una sección anterior: BatchNorm estabiliza el entrenamiento (sección 4), Dropout combate el overfitting (sección 10), y la salida final es un logit crudo de 10 dimensiones pensado para nn.CrossEntropyLoss (sección 6).

python
class FashionCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.block1 = nn.Sequential(
            nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2),        # 28x28 -> 14x14
            nn.Dropout(0.25),
        )
        self.block2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2),        # 14x14 -> 7x7
            nn.Dropout(0.25),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 256), nn.ReLU(), nn.Dropout(0.5),
            nn.Linear(256, num_classes),   # logits crudos
        )

    def forward(self, x):
        x = self.block1(x)
        x = self.block2(x)
        return self.classifier(x)

model = FashionCNN().to(device)
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Parámetros entrenables: {n_params:,}")

Paso 4 · Función de pérdida, optimizador y planificador

python
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode="min", factor=0.5, patience=2
)

Paso 5 · Bucle de entrenamiento con early stopping

python
def run_epoch(loader, train_mode):
    model.train() if train_mode else model.eval()
    total_loss, correct, n = 0.0, 0, 0
    context = torch.enable_grad() if train_mode else torch.no_grad()
    with context:
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            if train_mode:
                optimizer.zero_grad()
            logits = model(x)
            loss = criterion(logits, y)
            if train_mode:
                loss.backward()
                optimizer.step()
            total_loss += loss.item() * x.size(0)
            correct += (logits.argmax(1) == y).sum().item()
            n += x.size(0)
    return total_loss / n, correct / n

best_val_loss = float("inf")
patience, patience_left = 5, 5
history = []

for epoch in range(1, 31):
    train_loss, train_acc = run_epoch(train_loader, train_mode=True)
    val_loss, val_acc = run_epoch(val_loader, train_mode=False)
    scheduler.step(val_loss)
    history.append((train_loss, val_loss, train_acc, val_acc))

    print(f"Época {epoch:02d} | train_loss={train_loss:.4f} acc={train_acc:.4f} | "
          f"val_loss={val_loss:.4f} acc={val_acc:.4f}")

    if val_loss < best_val_loss:
        best_val_loss, patience_left = val_loss, patience
        torch.save(model.state_dict(), "best_fashion_cnn.pt")
    else:
        patience_left -= 1
        if patience_left == 0:
            print("Early stopping: la pérdida de validación no mejora.")
            break

model.load_state_dict(torch.load("best_fashion_cnn.pt"))

Paso 6 · Evaluación final en el conjunto de test

python
test_loss, test_acc = run_epoch(test_loader, train_mode=False)
print(f"Test: loss={test_loss:.4f}  accuracy={test_acc:.4f}")

# Análisis más fino: matriz de confusión y métricas por clase
from sklearn.metrics import classification_report, confusion_matrix

model.eval()
all_preds, all_labels = [], []
with torch.no_grad():
    for x, y in test_loader:
        preds = model(x.to(device)).argmax(1).cpu()
        all_preds.append(preds)
        all_labels.append(y)
all_preds = torch.cat(all_preds).numpy()
all_labels = torch.cat(all_labels).numpy()

print(confusion_matrix(all_labels, all_preds))
print(classification_report(all_labels, all_preds))

Paso 7 · Interpretar los resultados

Con esta arquitectura y este pipeline, es razonable esperar una exactitud de test en torno al 92-93%. Tres lecturas conviene hacer siempre sobre los resultados, no solo sobre la cifra final:

Primero, comparar la brecha entre train_acc y val_acc en el registro de cada época: una brecha pequeña y estable indica que Dropout, weight decay y el aumento de datos están cumpliendo su función; una brecha creciente pediría más regularización, más aumento de datos, o un modelo más pequeño. Segundo, revisar la matriz de confusión: en Fashion-MNIST, las confusiones más frecuentes suelen ocurrir entre clases visualmente próximas —camisa/camiseta/jersey—, lo que es coherente con lo que cabría esperar y no un síntoma de que algo esté roto. Tercero, si test_acc es notablemente inferior a val_acc, sospechar de una fuga de información entre validación y test, o de que las estadísticas de normalización se calcularon de forma distinta en cada conjunto.

De vuelta al curso completo

Cada pieza de este ejemplo remite a una sección anterior: nn.Conv2d y nn.MaxPool2d (sección 4), la subclase de nn.Module (sección 5), nn.CrossEntropyLoss sobre logits crudos (sección 6), loss.backward() y optimizer.zero_grad() (sección 7), AdamW con weight_decay y el scheduler (sección 8), la estructura train()/eval() del bucle (sección 9), y Dropout, BatchNorm, aumento de datos y early stopping combinados (sección 10). No hay ningún concepto nuevo aquí: es la misma teoría, ensamblada.

12Práctica

Resumen y siguientes pasos

Un glosario de referencia rápida y hacia dónde seguir desde aquí.

TérminoEn una frase
NeuronaSuma ponderada de entradas más sesgo, seguida de una activación no lineal.
ActivaciónFunción no lineal que impide que la red colapse a una única transformación afín.
ConvoluciónFiltro pequeño compartido, deslizado sobre la entrada; explota la estructura espacial.
Pérdida (loss)Número que mide cuánto se equivoca el modelo; lo que el entrenamiento minimiza.
AutogradSistema de PyTorch que calcula automáticamente los gradientes por la regla de la cadena.
OptimizadorRegla que traduce gradientes en actualizaciones de los parámetros (SGD, Adam…).
OverfittingEl modelo memoriza el entrenamiento en lugar de generalizar; la validación empeora.
DropoutAnula neuronas al azar durante el entrenamiento para evitar coadaptación.
Weight decayPenalización L2 sobre los pesos; equivalente al término de ridge regression.
Early stoppingDetener el entrenamiento cuando la validación deja de mejorar, y quedarse con el mejor checkpoint.

Hacia dónde seguir

Este curso cubre el núcleo común a casi cualquier red neuronal moderna: neuronas, activaciones, capas densas y convolucionales, backpropagation, optimizadores y regularización. A partir de aquí, cuatro direcciones naturales:

Redes recurrentes y secuenciasnn.LSTM, nn.GRU, para texto, series temporales y audio, donde el orden de los datos importa. Transformers y atención — el mecanismo de self-attention que sustituyó a la recurrencia como arquitectura dominante para secuencias, y que también se usa hoy en visión (Vision Transformers). Transfer learning — en lugar de entrenar desde cero, partir de un modelo preentrenado en un conjunto de datos masivo (por ejemplo, con torchvision.models) y ajustarlo (fine-tuning) al problema propio, casi siempre con mejores resultados y mucho menos cómputo. Modelos generativos — autoencoders variacionales, GANs y modelos de difusión, que aprenden a generar datos nuevos en lugar de solo clasificarlos.

La documentación oficial de PyTorch (pytorch.org/tutorials) es, con diferencia, el mejor siguiente paso práctico: los tutoriales oficiales siguen exactamente los mismos patrones —Dataset, DataLoader, nn.Module, bucle de entrenamiento— que se han desarrollado en este curso.