Pueden visitar esta version interactiva complementaria del curso: https://rrnn.criss-lab.com/
Una red neuronal básica aplica una secuencia de transformaciones lineales y no lineales sobre la entrada. En una red de dos capas, podemos escribir:
$$ f(x) = \sigma_2 \!\left(W_2 \, \sigma_1(W_1 x + b_1) + b_2\right) $$La idea clave es que cada capa toma una representación de los datos y la vuelve un poco más útil para la tarea final. Las primeras capas a menudo capturan patrones relativamente simples; las capas más profundas pueden combinarlos en representaciones más abstractas y útiles para la tarea.
Donde:
Las redes neuronales ajustan sus pesos $\theta$ minimizando una función de pérdida sobre los datos observados:
$$ \mathcal{L}(\theta) = \frac{1}{n} \sum_{i=1}^n \text{loss}(y_i, f_\theta(x_i)) $$Una forma útil de pensar el entrenamiento es:
$$ x \rightarrow \text{forward pass} \rightarrow \hat{y} \rightarrow \mathcal{L}(y,\hat{y}) \rightarrow \text{backpropagation} \rightarrow \text{update} $$En la etapa de update, lo que se actualiza son los parámetros aprendibles de la red, es decir:
En conjunto, solemos escribir todos esos parámetros como:
$\theta = \{W_1, b_1, W_2, b_2, \dots\}$
La etapa de backpropagation calcula los gradientes de la pérdida con respecto a cada parámetro. Por ejemplo:
$\frac{\partial \mathcal{L}}{\partial W_1}, \frac{\partial \mathcal{L}}{\partial b_1}, \frac{\partial \mathcal{L}}{\partial W_2}, \frac{\partial \mathcal{L}}{\partial b_2}$
Estos gradientes indican:
Una regla básica de actualización, usando gradient descent, es:
$W \leftarrow W - \eta \frac{\partial \mathcal{L}}{\partial W}$
$b \leftarrow b - \eta \frac{\partial \mathcal{L}}{\partial b}$
donde $\eta$ es el learning rate o tasa de aprendizaje.
La lógica es la siguiente:
La etapa de update no cambia los datos ni las activaciones intermedias:
cambia los pesos y sesgos del modelo para reducir la pérdida en la siguiente iteración.
Un autoencoder es una red neuronal que intenta aprender una representación interna de los datos obligándose a reconstruir su propia entrada:
$x \xrightarrow{f_\theta} h \xrightarrow{g_\phi} \hat{x}$
La idea general es simple: la red recibe un dato, lo transforma en una representación intermedia y luego trata de reconstruirlo lo mejor posible. Si esta tarea está bien restringida —por ejemplo, mediante un código latente pequeño, ruido de entrada o regularización— el modelo puede aprender patrones, regularidades y estructura en los datos, en lugar de limitarse a copiar la entrada de manera trivial.
Una pérdida común es el error cuadrático medio:
$\mathcal{L}(x, \hat{x}) = \|x - \hat{x}\|^2$
Sin embargo, la función de pérdida depende del tipo de dato que queremos reconstruir. Para variables continuas suele usarse MSE; para variables binarias o intensidades en $[0,1]$, puede ser más natural usar una salida sigmoidal con una pérdida tipo Bernoulli o binary cross-entropy.
Un autoencoder no es útil por el mero hecho de reconstruir bien, sino porque la forma en que se le obliga a reconstruir induce una representación interna informativa.
En un undercomplete autoencoder, el código latente $h$ tiene menor dimensión que la entrada $x$.
Por ejemplo:
La intuición es que estamos forzando a la red a pasar por un “cuello de botella”. Como no puede guardar toda la información original, debe aprender a conservar solo lo más importante para reconstruir bien la entrada.
Eso obliga al modelo a responder, implícitamente, la siguiente pregunta:
¿Qué parte de la información es realmente esencial y qué parte puede descartarse?
Por eso, este tipo de autoencoder se parece conceptualmente a PCA: ambos buscan una representación más compacta de los datos. La diferencia es que PCA solo aprende transformaciones lineales, mientras que un autoencoder puede aprender transformaciones no lineales.

En un overcomplete autoencoder, el código latente $h$ tiene una dimensión igual o incluso mayor que la de la entrada $x$.
Por ejemplo:
A primera vista, esto parece extraño: si la representación interna es más grande que la entrada, ¿no sería demasiado fácil copiar el dato?
Sí: ese es justamente el riesgo. Si no imponemos ninguna restricción adicional, la red podría aprender una solución trivial: simplemente copiar la entrada en lugar de aprender estructura útil. En ese caso, el autoencoder reconstruye bien, pero no aprende una representación interesante.
Por eso, un overcomplete autoencoder solo tiene sentido si agregamos regularización. La idea es impedir que el modelo use toda su capacidad de manera trivial. Algunas estrategias comunes son:
La intuición es:

En un denoising autoencoder, no le damos a la red la entrada limpia directamente, sino una versión alterada o corrupta, y le pedimos reconstruir la original.
Es decir, en vez de aprender:
$x \rightarrow x$
aprende algo más desafiante:
$\tilde{x} \rightarrow x$
donde $\tilde{x}$ es una versión ruidosa o incompleta de $x$.
Por ejemplo, podemos:
La intuición es muy importante: si el modelo recibe una entrada dañada y aun así logra reconstruir la señal limpia, entonces no puede estar simplemente copiando. Tiene que aprender patrones más estables y más profundos de los datos.
En otras palabras, el modelo aprende:
Por eso, los denoising autoencoders suelen aprender representaciones más útiles y más robustas que un autoencoder que solo intenta copiar la entrada exacta.

En este notebook primero revisamos las piezas generales de una red neuronal y luego vemos por qué esas mismas piezas permiten construir autoencoders capaces de aprender representaciones útiles de los datos.
Las funciones de activación introducen no linealidad en una red neuronal. Sin funciones de activación no lineales, una composición de capas afines sigue siendo una sola transformación afín, por lo que la red pierde capacidad para modelar relaciones no lineales complejas.
En una neurona, el forward pass ocurre en dos pasos. Primero se calcula una combinación lineal de entradas, pesos y sesgo:
Luego se aplica una función no lineal para producir la salida activada:
$$ a = \sigma(z) $$Esta separación es importante: la parte lineal mezcla información y la parte no lineal le da poder expresivo al modelo.
Porque sin activaciones no lineales, una red neuronal profunda colapsa a una sola transformación lineal. Aunque tenga muchas capas, no gana poder real de representación: sigue haciendo algo equivalente a $f(x) = Ax + c$.
La función de activación rompe esa linealidad y permite que la red aprenda relaciones complejas, fronteras de decisión curvas y representaciones internas mucho más ricas. En otras palabras, la profundidad solo aporta valor cuando entre capas introducimos no linealidad.
Sin no linealidad, una red profunda es solo una regresión lineal disfrazada.
ReLU no es derivable exactamente en $z = 0$. Sin embargo, esto no genera problemas prácticos importantes, porque la no derivabilidad ocurre solo en un punto y, en entrenamiento, usualmente se maneja mediante una convención o subgradiente en $z=0$.
max(0, z).tanh cumple mejor esta propiedad que sigmoid, porque produce valores entre $-1$ y $1$, mientras que sigmoid produce valores entre $0$ y $1$.Nota: Aunque ReLU es lineal en la región positiva, no es lineal en todo su dominio, porque corta todos los valores negativos en $0$. Ese quiebre en $z=0$ introduce la no linealidad que la red necesita para aprender funciones complejas. Además, para valores positivos su derivada es $1$, lo que ayuda a mantener un buen flujo de gradientes y facilita el entrenamiento de redes profundas.
En backpropagation, el gradiente se va propagando desde la salida hacia las capas anteriores. En ese proceso, la derivada de la función de activación, $\sigma'(z)$, aparece multiplicando la señal de error.
Por eso, si $\sigma'(z)$ es muy pequeña, el gradiente también se achica. Y si esto ocurre repetidamente a través de muchas capas, la señal de aprendizaje se debilita cada vez más.
La consecuencia es que los pesos de las primeras capas cambian muy poco, por lo que la red aprende lentamente o casi no aprende en esas capas.
Las funciones de activación son las que introducen no linealidad en una red neuronal. Gracias a ellas, la red puede aprender relaciones que una transformación lineal no puede capturar. Un ejemplo clásico es XOR, un problema donde la salida vale $1$ cuando las dos entradas son distintas y vale $0$ cuando son iguales. Ese patrón no puede resolverse con una sola frontera lineal. Sin funciones de activación, incluso una red con muchas capas seguiría comportándose como una transformación lineal. Con activaciones, en cambio, la red puede aprender desde relaciones simples no lineales como XOR hasta tareas mucho más complejas, como reconocimiento de imágenes o generación de texto.
Supongamos que la entrada es:
$x = (x_1, x_2, x_3, x_4, x_5)$
La capa oculta tiene 2 neuronas.
Cada una recibe las 5 entradas, pero con pesos y sesgo propios.
Primero calcula la combinación lineal:
$z_1^{(1)} = w_{11}^{(1)}x_1 + w_{12}^{(1)}x_2 + w_{13}^{(1)}x_3 + w_{14}^{(1)}x_4 + w_{15}^{(1)}x_5 + b_1^{(1)}$
Luego aplica la activación:
$h_1 = \sigma(z_1^{(1)})$
Primero calcula:
$z_2^{(1)} = w_{21}^{(1)}x_1 + w_{22}^{(1)}x_2 + w_{23}^{(1)}x_3 + w_{24}^{(1)}x_4 + w_{25}^{(1)}x_5 + b_2^{(1)}$
Luego aplica la activación:
$h_2 = \sigma(z_2^{(1)})$
Entonces, la salida de la capa oculta es:
$h = (h_1, h_2)$
Ahora cada una de las 5 neuronas de salida recibe como entrada las 2 activaciones ocultas.
$z_1^{(2)} = w_{11}^{(2)}h_1 + w_{12}^{(2)}h_2 + b_1^{(2)}$
$\hat{x}_1 = \sigma_{\text{out}}(z_1^{(2)})$
$z_2^{(2)} = w_{21}^{(2)}h_1 + w_{22}^{(2)}h_2 + b_2^{(2)}$
$\hat{x}_2 = \sigma_{\text{out}}(z_2^{(2)})$
$z_3^{(2)} = w_{31}^{(2)}h_1 + w_{32}^{(2)}h_2 + b_3^{(2)}$
$\hat{x}_3 = \sigma_{\text{out}}(z_3^{(2)})$
$z_4^{(2)} = w_{41}^{(2)}h_1 + w_{42}^{(2)}h_2 + b_4^{(2)}$
$\hat{x}_4 = \sigma_{\text{out}}(z_4^{(2)})$
$z_5^{(2)} = w_{51}^{(2)}h_1 + w_{52}^{(2)}h_2 + b_5^{(2)}$
$\hat{x}_5 = \sigma_{\text{out}}(z_5^{(2)})$
Entonces la salida completa de la red es:
$\hat{x} = (\hat{x}_1, \hat{x}_2, \hat{x}_3, \hat{x}_4, \hat{x}_5)$
$x = \begin{pmatrix} x_1 \\ x_2 \\ x_3 \\ x_4 \\ x_5 \end{pmatrix}$
$z^{(1)} = W^{(1)}x + b^{(1)}$
donde
$W^{(1)} = \begin{pmatrix} w_{11}^{(1)} & w_{12}^{(1)} & w_{13}^{(1)} & w_{14}^{(1)} & w_{15}^{(1)} \\ w_{21}^{(1)} & w_{22}^{(1)} & w_{23}^{(1)} & w_{24}^{(1)} & w_{25}^{(1)} \end{pmatrix}$
y
$b^{(1)} = \begin{pmatrix} b_1^{(1)} \\ b_2^{(1)} \end{pmatrix}$
Entonces:
$h = \sigma(z^{(1)})$
o sea,
$h = \sigma(W^{(1)}x + b^{(1)})$
con $h \in \mathbb{R}^2$
$z^{(2)} = W^{(2)}h + b^{(2)}$
donde
$W^{(2)} = \begin{pmatrix} w_{11}^{(2)} & w_{12}^{(2)} \\ w_{21}^{(2)} & w_{22}^{(2)} \\ w_{31}^{(2)} & w_{32}^{(2)} \\ w_{41}^{(2)} & w_{42}^{(2)} \\ w_{51}^{(2)} & w_{52}^{(2)} \end{pmatrix}$
y
$b^{(2)} = \begin{pmatrix} b_1^{(2)} \\ b_2^{(2)} \\ b_3^{(2)} \\ b_4^{(2)} \\ b_5^{(2)} \end{pmatrix}$
Luego aplicamos la activación de salida:
$\hat{x} = \sigma_{\text{out}}(z^{(2)}) = \sigma_{\text{out}}(W^{(2)}h + b^{(2)})$
con $\hat{x} \in \mathbb{R}^5$
Cada una de las 2 neuronas ocultas aprende una representación distinta de la entrada original de 5 variables. Luego, la capa de salida usa esas 2 activaciones para reconstruir o producir 5 valores de salida. Esta es justamente la estructura típica de un autoencoder simple con cuello de botella: $5 \rightarrow 2 \rightarrow 5$.
from IPython.display import HTML
from pathlib import Path
html = Path("autoencoder_5_2_5_back.html").read_text(encoding="utf-8")
html_escaped = html.replace("&", "&").replace('"', """)
HTML(f'''
<iframe
srcdoc="{html_escaped}"
width="80%"
height="1180"
style="border:none; border-radius:12px; align-self:center; margin: 20px auto; display: block;">
</iframe>
''')
Es equivalente a no usar activación: la salida es igual a la entrada.
“Si la entrada es negativa, apago la neurona. Si es positiva, dejo pasar la señal tal cual”.
Convierte cualquier valor real en un número entre 0 y 1, por lo que resulta natural interpretarla como probabilidad.
tanh (Tangente hiperbólica)¶Se parece a sigmoid, pero está centrada en cero, lo que suele ayudar al entrenamiento.
Toma un vector de scores y lo transforma en una distribución de probabilidad donde las salidas compiten entre sí.
| Función | Rango | Centrada en 0 | Saturación | Ideal para... |
|---|---|---|---|---|
| Lineal | $(-\infty, \infty)$ | Sí | No | Regresión |
| ReLU | $[0, \infty)$ | No | No en el lado positivo | Capas ocultas |
| Sigmoid | $(0, 1)$ | No | Sí | Salida binaria |
tanh |
$(-1, 1)$ | Sí | Sí | Capas ocultas como alternativa |
| Softmax | $(0, 1)$ (vector) | No aplica | No aplica | Clasificación multiclase |
No existe una activación universalmente mejor: su valor depende del rol que cumple la capa dentro del modelo.
from IPython.display import HTML
from pathlib import Path
html = Path("autoencoder_5_2_5_back.html").read_text(encoding="utf-8")
html_escaped = html.replace("&", "&").replace('"', """)
HTML(f'''
<iframe
srcdoc="{html_escaped}"
width="80%"
height="1180"
style="border:none; border-radius:12px; align-self:center; margin: 20px auto; display: block;">
</iframe>
''')
import numpy as np
import matplotlib.pyplot as plt
# Funciones de activación
def linear(x):
return x
def relu(x):
return np.maximum(0, x)
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def tanh(x):
return np.tanh(x)
def softmax(z):
e_z = np.exp(z - np.max(z, axis=0, keepdims=True)) # estabilidad numérica
return e_z / e_z.sum(axis=0, keepdims=True)
# Rango de valores
x = np.linspace(-3, 3, 400)
# Activaciones estándar
activations = {
"Lineal": linear(x),
"ReLU": relu(x),
"Sigmoid": sigmoid(x),
"Tanh": tanh(x),
}
# Plot activaciones estándar
plt.figure(figsize=(6, 3))
for name, y in activations.items():
plt.plot(x, y, label=name)
plt.title("Funciones de Activación (Lineal, ReLU, Sigmoid, Tanh)")
plt.axhline(0, color='gray', linestyle='--', linewidth=0.5)
plt.axvline(0, color='gray', linestyle='--', linewidth=0.5)
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
## Softmax con z = [x, 0.1, 0] para mostrar cómo varía la
# probabilidad de la primera componente con x, mientras
# las otras dos componentes permanecen constantes.
# z es una matriz 3xN donde la primera fila varía con x, y
# las otras dos filas son constantes
z = np.vstack([x, np.zeros_like(x)+.1, np.zeros_like(x)])
s = softmax(z)
plt.figure(figsize=(6, 3))
plt.plot(x, s[0], label='Softmax comp. 1')
plt.plot(x, s[1], label='Softmax comp. 2')
plt.plot(x, s[2], label='Softmax comp. 3')
plt.title("Softmax con z = [x, 0.1, 0]")
plt.xlabel("x")
plt.ylabel("Probabilidad")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Idea práctica para lo que viene: en autoencoders la activación de salida no se elige por moda, sino por el rango y naturaleza de los datos que queremos reconstruir.
Backpropagation es el algoritmo que permite ajustar los pesos de una red propagando el error desde la salida hacia las capas anteriores.
Conviene pensar el entrenamiento en dos fases:
Todo se apoya en la regla de la cadena:
$$ \frac{\partial \mathcal{L}}{\partial w} = \frac{\partial \mathcal{L}}{\partial a} \cdot \frac{\partial a}{\partial z} \cdot \frac{\partial z}{\partial w} $$Donde:
El truco computacional de backpropagation es que reutiliza resultados intermedios: no recalcula todo desde cero para cada peso, sino que va propagando gradientes capa por capa.



La red aprende ajustando los pesos con actualizaciones del tipo:
$$ w \leftarrow w - \eta \cdot \frac{\partial \mathcal{L}}{\partial w} $$Si la función de activación no es derivable, no podemos calcular correctamente cómo el error cambia respecto de los pesos anteriores.
Cada capa usa la derivada de su activación para retropropagar el error:
$$ \delta^{(l)} = \frac{\partial \mathcal{L}}{\partial a^{(l)}} \cdot \sigma'(z^{(l)}) $$Si $\sigma'$ no existe o vale casi cero en muchos puntos, el aprendizaje se vuelve muy difícil.
Supón una red simple de dos capas:
$$ a^{(1)} = \sigma(w^{(1)} x + b^{(1)}) \quad \rightarrow \quad a^{(2)} = \sigma(w^{(2)} a^{(1)} + b^{(2)}) $$Para actualizar $w^{(1)}$, necesitamos:
$$ \frac{\partial \mathcal{L}}{\partial w^{(1)}} = \frac{\partial \mathcal{L}}{\partial a^{(2)}} \cdot \frac{\partial a^{(2)}}{\partial a^{(1)}} \cdot \frac{\partial a^{(1)}}{\partial w^{(1)}} $$Y eso incluye las derivadas $\sigma'(z^{(2)})$ y $\sigma'(z^{(1)})$. Esa es la esencia de la retropropagación: descomponer un problema grande en derivadas locales fáciles de calcular.
| Razón | ¿Por qué importa la derivabilidad? |
|---|---|
| Retropropagación | Permite aplicar la regla de la cadena |
| Flujo de gradientes | Hace posible mover el error hacia capas anteriores |
| Evita bloqueos | Sin derivada útil, no hay aprendizaje efectivo |
| Optimización continua | Permite usar métodos de descenso por gradiente |
from IPython.display import HTML
from pathlib import Path
html = Path("autoencoder_5_2_5_back.html").read_text(encoding="utf-8")
html_escaped = html.replace("&", "&").replace('"', """)
HTML(f'''
<iframe
srcdoc="{html_escaped}"
width="80%"
height="1180"
style="border:none; border-radius:12px; align-self:center; margin: 20px auto; display: block;">
</iframe>
''')
Antes de entender SGD y otros optimizadores, conviene aclarar algunos conceptos básicos sobre cómo se recorren los datos durante el entrenamiento de una red neuronal.
Supongamos que tenemos un dataset con muchas observaciones. En teoría, podríamos usar todos los datos al mismo tiempo para calcular el gradiente y actualizar los parámetros. Pero en la práctica eso muchas veces es caro en tiempo y memoria. Por eso el dataset se divide en partes más pequeñas.
El dataset es el conjunto completo de ejemplos de entrenamiento.
Por ejemplo, si tenemos 10.000 imágenes para entrenar una red, entonces nuestro dataset contiene esas 10.000 observaciones.
Un batch es un conjunto de ejemplos que se procesa juntos en una actualización del modelo.
Si el batch tiene tamaño 32, eso significa que en ese paso la red procesa 32 observaciones, calcula la pérdida promedio en esas 32 observaciones y usa ese resultado para actualizar sus parámetros.
El tamaño del batch se conoce como batch size.
En aprendizaje profundo, casi siempre trabajamos con mini-batches, es decir, batches pequeños comparados con el dataset completo.
Por ejemplo:
Entonces el modelo no procesa los 10.000 ejemplos de una vez, sino grupos de 64.
A esto se le llama mini-batch training.
Una iteración es una sola actualización de los parámetros del modelo.
Es decir:
Todo eso corresponde a una iteración.
Si el dataset tiene 10.000 ejemplos y usamos batch size 100, entonces una pasada completa por el dataset requiere:
$$ \frac{10000}{100} = 100 $$iteraciones.
Una epoch corresponde a una pasada completa por todo el dataset de entrenamiento.
Siguiendo el ejemplo anterior:
Entonces después de 100 iteraciones, el modelo ha visto una vez todos los datos y ha completado 1 epoch.
Si entrenamos durante 20 epochs, eso significa que el modelo recorrió 20 veces el dataset completo.
Estos conceptos están conectados:
La relación básica es:
$$ \text{iteraciones por epoch} = \frac{\text{número de observaciones}}{\text{batch size}} $$Si el batch size disminuye:
Si el batch size aumenta:
Una epoch dice cuántas veces el modelo ha recorrido todo el dataset. Una iteración dice cuántas veces ya actualizó sus parámetros. El batch size dice cuántos ejemplos usó para decidir cada actualización.
Supongamos que tenemos:
Entonces:
$$ \text{iteraciones por epoch} = \frac{12000}{300} = 40 $$Eso significa:
El modelo no suele aprender mirando todos los datos a la vez, sino avanzando en pequeños bloques. Esos bloques son los batches, cada actualización es una iteración, y una pasada completa por los datos es una epoch.
Una vez que backpropagation entrega gradientes, el optimizador decide cómo usar esa información para actualizar los parámetros $\theta$ y reducir la pérdida $\mathcal{L}(\theta)$.
El gradiente apunta en la dirección de mayor aumento de la función de pérdida. Por eso, para minimizar, avanzamos en la dirección opuesta.
Idea puente: backpropagation calcula el gradiente; el optimizador decide el tamaño y la forma del paso.
Estás parado en algún punto de una montaña. Pero:
Llegar al punto más bajo posible (mínimo de la función de pérdida).
El gradiente es como una brújula:
Ese es el learning rate $ \eta $:
Imaginen que estamos en una montaña, con los ojos cerrados. Lo único que sentimos es hacia dónde baja más el suelo. Cada paso que damos es hacia ese lado, pero sin pasarnos. Así, poco a poco, vamos bajando hasta llegar al fondo del valle. Eso mismo hace una red: ajusta sus parámetros para bajar el error, paso a paso, guiada por la pendiente (el gradiente).
Una vez que obtenemos el gradiente mediante backpropagation, todavía falta decidir cómo actualizar los parámetros.
Todos los optimizadores parten de la misma idea general:
$$ \theta \leftarrow \theta - \text{(algo basado en el gradiente)} $$donde:
La diferencia entre optimizadores está en cómo transforman el gradiente en un paso concreto.
SGD usa el gradiente estimado con un mini-batch para actualizar directamente los parámetros.
$$ \theta \leftarrow \theta - \eta \cdot \nabla_{\theta}\mathcal{L}_{\text{mini-batch}} $$La lógica es simple:
SGD mira solo la pendiente actual y da un paso en dirección descendente.
Si el gradiente es grande, el paso es grande.
Si el gradiente es pequeño, el paso es pequeño.
Pero todos los parámetros comparten la misma escala global $ \eta $.
SGD usa solo la información del presente.
SGD puro puede avanzar lentamente y oscilar mucho, especialmente en superficies alargadas o con “valles” estrechos.
Momentum agrega una variable auxiliar que acumula parte del movimiento pasado:
$$ v_t = \gamma v_{t-1} + \eta \nabla \mathcal{L}(\theta_t) $$$$ \theta_{t+1} = \theta_t - v_t $$Aquí:
Entonces esta ecuación dice:
la nueva velocidad es una mezcla entre lo que veníamos haciendo y lo que el gradiente actual sugiere hacer.
Una vez construida esa velocidad acumulada, actualizamos los parámetros usando $ v_t $ en vez del gradiente puro.
Momentum no solo pregunta “¿qué dice el gradiente ahora?”,
sino también “¿en qué dirección me he estado moviendo últimamente?”.
Si durante varios pasos el gradiente apunta más o menos en la misma dirección, la velocidad acumulada crece y el avance se acelera.
Si hay pequeñas oscilaciones laterales, la memoria del movimiento ayuda a suavizarlas.
Es como una pelota bajando por una montaña: gana inercia en una dirección consistente y no cambia de rumbo violentamente por pequeñas irregularidades.
Momentum = gradiente actual + memoria del pasado.
No todas las coordenadas del espacio de parámetros se comportan igual.
Algunas pueden recibir gradientes muy grandes, otras muy pequeños.
Si usamos la misma escala para todas, algunas direcciones avanzan demasiado y otras demasiado poco.
RMSprop intenta adaptar el tamaño del paso por parámetro.
Aquí:
Esta ecuación no promedia la dirección del gradiente, sino su magnitud reciente.
Porque aquí no queremos saber si el gradiente fue positivo o negativo, sino qué tan grande fue.
Si promediáramos $ g_t $ directamente:
pero con $ g_t^2 $:
Comparada con SGD, aquí el gradiente ya no se multiplica solo por $ \eta $, sino por:
$$ \frac{\eta}{\sqrt{s_t + \epsilon}} $$Eso significa que el tamaño del paso ahora depende de $ s_t $.
Entonces RMSprop hace esto:
frena en coordenadas que vienen recibiendo gradientes grandes,
y permite avanzar más en coordenadas donde los gradientes han sido pequeños.
RMSprop regula la velocidad de cada parámetro por separado.
RMSprop no recuerda hacia dónde ibas,
pero sí recuerda qué tan brusco ha sido el terreno en cada coordenada.
Adam combina dos ideas:
Por eso usa dos promedios móviles.
Aquí:
Esta ecuación dice:
Adam guarda una versión suavizada del gradiente.
Aquí:
Esta ecuación dice:
Adam también estima qué tan grandes han sido los gradientes recientemente.
Como al principio se usa $ m_0 = 0 $ y $ v_0 = 0 $, los primeros valores de $ m_t $ y $ v_t $ quedan sesgados hacia abajo.
Por eso Adam corrige:
$$ \hat{m}_t = \frac{m_t}{1-\beta_1^t} \qquad \hat{v}_t = \frac{v_t}{1-\beta_2^t} $$En los primeros pasos, como todavía no hay suficiente historia acumulada, los promedios móviles serían artificialmente pequeños.
La corrección compensa ese “arranque en frío”.
Esta expresión combina dos piezas:
Da la dirección promedio corregida.
Esto le da a Adam memoria direccional.
Controla la escala del paso.
Si una coordenada ha tenido gradientes grandes recientemente, el denominador crece y el paso se reduce.
Entonces Adam hace ambas cosas a la vez:
Adam intenta responder dos preguntas al mismo tiempo:
Adam = Momentum + RMSprop + corrección de sesgo inicial.
Usa solo el gradiente actual.
$$ \theta \leftarrow \theta - \eta g_t $$Pregunta implícita:
“¿Qué dice la pendiente ahora?”
Usa el gradiente actual y además acumula dirección pasada.
$$ v_t = \gamma v_{t-1} + \eta g_t \qquad \theta_{t+1} = \theta_t - v_t $$Pregunta implícita:
“¿Qué dice la pendiente ahora y hacia dónde venía moviéndome?”
Usa el gradiente actual, pero adapta la escala del paso por parámetro.
$$ s_t = \rho s_{t-1} + (1-\rho)g_t^2 \qquad \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{s_t+\epsilon}}g_t $$Pregunta implícita:
“¿Qué tan grande debería ser el paso en esta coordenada?”
Combina dirección promedio y escala adaptativa.
$$ m_t = \beta_1 m_{t-1} + (1-\beta_1)g_t $$$$ v_t = \beta_2 v_{t-1} + (1-\beta_2)g_t^2 $$$$ \theta_t = \theta_{t-1} - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon} $$Pregunta implícita:
“¿Hacia dónde debería moverme y qué tan grande debería ser ese movimiento?”
| Optimizador | Qué recuerda | Qué adapta | Idea breve |
|---|---|---|---|
| SGD | Nada | Nada | Sigue el gradiente actual |
| Momentum | Dirección pasada | No | Acumula inercia |
| RMSprop | Magnitud reciente | Sí, por parámetro | Ajusta la velocidad local |
| Adam | Dirección y magnitud | Sí, por parámetro | Combina inercia y adaptación |
Todos usan gradientes.
Lo que cambia es cómo convierten ese gradiente en movimiento:
con memoria, con adaptación local, o con ambas cosas a la vez.
from IPython.display import HTML
from pathlib import Path
html = Path("optimizer_simulator_fit.html").read_text(encoding="utf-8")
html_escaped = html.replace("&", "&").replace('"', """)
HTML(f'''
<iframe
srcdoc="{html_escaped}"
width="100%"
height="980"
style="border:none; border-radius:12px;">
</iframe>
''')
import numpy as np
import plotly.graph_objects as go
# Función de pérdida
def loss_function(x, y):
return np.log(x**2 + y**2 + 1)
# Malla del terreno
x = np.linspace(-2, 2, 100)
y = np.linspace(-2, 2, 100)
X, Y = np.meshgrid(x, y)
Z = loss_function(X, Y)
# Trayectorias simuladas
adam_path = np.array([[-1.5, -1.5], [-1.2, -1.2], [-0.8, -0.9], [-0.4, -0.5], [-0.1, -0.2], [0, 0]])
sgd_path = np.array([[-1.5, -1.5], [-1.4, -1.3], [-1.3, -1.2], [-1.1, -1.0], [-0.9, -0.8], [-0.5, -0.6]])
adam_z = loss_function(adam_path[:, 0], adam_path[:, 1])
sgd_z = loss_function(sgd_path[:, 0], sgd_path[:, 1])
# Crear fotogramas de animación
frames = []
for i in range(1, len(adam_path) + 1):
frames.append(go.Frame(
data=[
go.Surface(x=X, y=Y, z=Z, colorscale='Viridis', opacity=0.8, showscale=False),
go.Scatter3d(x=adam_path[:i, 0], y=adam_path[:i, 1], z=adam_z[:i],
mode='lines+markers', line=dict(color='red', width=5),
marker=dict(size=5), name='Adam'),
go.Scatter3d(x=sgd_path[:i, 0], y=sgd_path[:i, 1], z=sgd_z[:i],
mode='lines+markers', line=dict(color='blue', width=5),
marker=dict(size=5), name='SGD')
],
name=f"frame{i}"
))
# Figura base
fig = go.Figure(
data=[
go.Surface(x=X, y=Y, z=Z, colorscale='Viridis', opacity=0.8, showscale=False),
go.Scatter3d(x=[], y=[], z=[], mode='lines+markers', line=dict(color='red'), name='Adam'),
go.Scatter3d(x=[], y=[], z=[], mode='lines+markers', line=dict(color='blue'), name='SGD')
],
frames=frames
)
# Layout de animación
fig.update_layout(
title="Animación de Trayectoria: Adam vs SGD",
scene=dict(xaxis_title='x', yaxis_title='y', zaxis_title='Pérdida'),
updatemenus=[dict(type='buttons',
showactive=False,
buttons=[dict(label='Play',
method='animate',
args=[None, {"frame": {"duration": 600, "redraw": True},
"fromcurrent": True}])])]
)
fig.show()

| Optimizador | Cuándo usarlo |
|---|---|
| SGD | Cuando quieres simplicidad y buena generalización con suficiente tiempo de entrenamiento |
| SGD + Momentum | Cuando buscas una opción sólida para redes profundas y trayectorias largas |
| RMSprop | Cuando el problema es secuencial o los gradientes cambian mucho entre pasos |
| Adam | Cuando necesitas un punto de partida robusto y efectivo en la práctica |
Las activaciones moldean el terreno. La función de pérdida define a dónde queremos llegar. El optimizador decide cómo moverse por ese terreno.
Distintos optimizadores generan trayectorias distintas:
from IPython.display import HTML
from pathlib import Path
import html
raw = Path("codificacion_capa_input.html").read_text(encoding="utf-8")
if "<head>" in raw:
raw = raw.replace("<head>", '<head><base target="_blank">', 1)
escaped = html.escape(raw, quote=True)
HTML(f"""
<iframe
srcdoc="{escaped}"
width="100%"
height="980"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
style="border:none; border-radius:12px;">
</iframe>
""")
Ahora ya tenemos casi todas las piezas que necesitaremos en el siguiente notebook. Un autoencoder no es otro tipo de entrenamiento: sigue siendo una red neuronal entrenada con forward pass, backpropagation y un optimizador.
La diferencia central está en la tarea.
En otras palabras: la mecánica de entrenamiento no cambia.
Lo que cambia es la forma de escribir el problema:
$$ h = f_{\theta}(x), \quad \hat{x} = g_{\phi}(h) $$y la pérdida ahora compara la entrada con su reconstrucción:
$$ \mathcal{L}_{\text{rec}}(x, \hat{x}) $$En un problema supervisado, la pregunta es “¿predije bien la etiqueta?”. En un autoencoder, la pregunta es “¿reconstruí bien la entrada?”.
Supón que la entrada es una imagen de $28 \times 28$ píxeles aplanada en un vector:
$$ x \in \mathbb{R}^{784} $$Un autoencoder simple podría verse así:
$$ 784 \rightarrow 128 \rightarrow 32 \rightarrow 128 \rightarrow 784 $$Si el cuello de botella obliga a comprimir información relevante, el modelo no puede memorizar trivialmente cada detalle de la entrada y debe aprender estructura útil.
Esta es una de las razones por las que más adelante veremos variantes como denoising, sparse o variational autoencoders.
Para pasar de redes neuronales básicas a autoencoders no necesitamos una nueva teoría de entrenamiento. Necesitamos reutilizar lo ya visto y cambiar el objetivo: comprimir y reconstruir bien los datos.
Cuando construyamos un autoencoder, una de las decisiones más importantes será cómo definir la capa de salida y la pérdida de reconstrucción. Esa elección depende del tipo de dato que queremos reconstruir.
| Tipo de dato de salida | Activación de salida sugerida | Pérdida típica | Comentario |
|---|---|---|---|
| Variables continuas sin cota clara | Lineal | MSE | Buena opción si los datos fueron estandarizados |
| Variables o pixeles en $[0,1]$ | Sigmoid | BCE o MSE | Útil cuando interpretamos la salida como intensidad o probabilidad |
| Variables acotadas en $[-1,1]$ | tanh |
MSE | Solo tiene sentido si los datos fueron llevados a ese rango |
| Categorías mutuamente excluyentes | Softmax | Cross-entropy | Caso especial; no suele ser la salida básica de un autoencoder denso estándar |
Punto importante: en autoencoders básicos, softmax no suele ser la salida natural, porque obliga a que las dimensiones compitan entre sí. Para reconstrucción de vectores o imágenes, normalmente queremos que cada componente pueda reconstruirse con más independencia.
Ahora que ya vimos activaciones, backpropagation y optimizadores, podemos precisar mejor esta idea. En redes neuronales hablamos de “caja negra” no porque el modelo sea invisible, sino porque ver sus componentes no equivale a entender su razonamiento.
Podemos inspeccionar pesos, gradientes, activaciones y salidas intermedias. Lo difícil es traducir todo eso a una explicación humana simple y estable.
Entre esos extremos puede haber miles, millones o incluso billones de parámetros. Cada parámetro participa en el cálculo, pero rara vez tiene un significado interpretable por sí solo.
En un programa tradicional podemos leer reglas explícitas. En una red neuronal, la “regla” está distribuida en muchos parámetros a la vez.
Porque backpropagation da una falsa sensación de transparencia si no somos cuidadosos.
Cuando hacemos backpropagation sí entendemos:
Pero eso no responde automáticamente preguntas como:
En otras palabras: backpropagation explica cómo ajustar el modelo, no necesariamente cómo interpretarlo.
No. Entrenar una red significa encontrar parámetros que reduzcan una pérdida. Entender una red significa poder dar una explicación legible y relativamente estable de sus representaciones y decisiones.
La información rara vez vive en una sola neurona. Normalmente está repartida entre muchas unidades al mismo tiempo.
Cada capa transforma la representación anterior. Después de varias composiciones no lineales, la relación entre entrada y salida deja de ser intuitiva para una lectura humana directa.
Un gradiente nos dice qué pasa cerca de un punto. Pero una explicación global del comportamiento del modelo es mucho más difícil.
Dos redes entrenadas para la misma tarea pueden llegar a soluciones internas distintas y tener desempeños similares. Eso sugiere que la interpretación no está fijada de manera única.
Sabemos que funciona, pero no necesariamente sabemos cómo piensa.
En autoencoders esta discusión sigue siendo relevante, incluso cuando tenemos acceso al espacio latente $h$.
Pero tener acceso al espacio latente no implica que cada dimensión tenga una interpretación humana clara. En muchos casos, el modelo aprende una representación útil para reconstruir, no una representación diseñada para ser semánticamente transparente.
Eso cambia en parte cuando imponemos estructura adicional, por ejemplo con regularización, sparsity o modelos variacionales. Aun así, la interpretabilidad nunca viene “gratis”.
No. Existen herramientas para abrirla parcialmente:
Estas herramientas ayudan, pero normalmente entregan aproximaciones o explicaciones parciales, no una lectura completa y definitiva del modelo.
El aprendizaje automático no es una caja negra porque no podamos mirar dentro, sino porque interpretar de manera humana, simple y estable lo que ocurre dentro sigue siendo difícil.
Backpropagation nos da el mecanismo de aprendizaje. La interpretabilidad intenta darnos el mecanismo de explicación. Son problemas relacionados, pero no son lo mismo.
Ejemplo ilustrativo en inglés: https://medium.com/the-feynman-journal/what-makes-backpropagation-so-elegant-657f3afbbd