Anteriormente exploramos UMAP, una técnica no supervisada para proyectar datos de alta dimensión en espacios más simples, preservando estructuras locales y globales. Hoy seguimos profundizando en ese objetivo: cómo aprender buenas representaciones internas o embeddings de los datos, pero esta vez, utilizando redes neuronales.
| Técnica | ¿Qué hace? | ¿Cómo? | Tipo |
|---|---|---|---|
| PCA | Encuentra direcciones de máxima varianza lineal | Descomposición lineal | Lineal |
| UMAP | Preserva topología local/global en proyección | Vecindarios + teoría de grafos + optimización | No lineal |
| Autoencoder | Aprende una codificación eficiente (embedding) | Red neuronal entrenada para reconstrucción | No lineal aprendida |
En todos los casos anteriores, estamos construyendo un espacio latente $ h \in \mathbb{R}^d $ que captura información esencial de la instancia de entrada $ x \in \mathbb{R}^D $, con $ d \ll D $. La diferencia radica en el mecanismo que genera ese embedding:
Formalmente, un embedding es una función $ f: \mathbb{R}^D \to \mathbb{R}^d $, con $ d \ll D $, que proyecta un vector de alta dimensión en un espacio más compacto, preservando ciertas propiedades clave de los datos originales (e.g., distancias, densidades, conectividad local, separabilidad de clases).
| Método | Tipo de técnica | Mapa $ f(x) $ aprendido | Optimiza... | Preserva... |
|---|---|---|---|---|
| PCA | Lineal, global | $ f(x) = W^\top x $ | Máxima varianza | Varianza global |
| t-SNE | No lineal, local | implícito (no función) | Divergencia de Kullback-Leibler | Vecindad local (probabilística) |
| UMAP | No lineal, local | implícito (no función) | Cross-entropy entre grafos fuzzy | Conectividad topológica local/global |
| Autoencoder | No lineal, aprendida | Red neuronal entrenada | Error de reconstrucción (e.g., MSE) | Información suficiente para reconstrucción |
Busca una base ortonormal $ W \in \mathbb{R}^{D \times d} $ que maximice:
$\text{Var}(W^\top X) = \sum_{i=1}^{d} \lambda_i$
Donde $ \lambda_i $ son los autovalores de la matriz de covarianza. La proyección $ W^\top x $ es el embedding.
PCA es óptimo bajo reconstrucción lineal con error cuadrático.
Minimiza la divergencia KL entre distribuciones de probabilidad en alta y baja dimensión:
$\text{KL}(P \| Q) = \sum_{i \neq j} p_{ij} \log \frac{p_{ij}}{q_{ij}}$
t-SNE genera mapas donde los puntos similares permanecen cercanos, pero no hay función explícita $ f(x) $.
Basado en la teoría de simplicial sets. Construye dos grafos difusos:
Optimiza:
$\mathcal{L} = \sum_{i,j} w_{ij}^{(high)} \log \frac{w_{ij}^{(high)}}{w_{ij}^{(low)}} + (1 - w_{ij}^{(high)}) \log \frac{1 - w_{ij}^{(high)}}{1 - w_{ij}^{(low)}}$
UMAP busca preservar la estructura topológica local/global mediante grafos probabilísticos.
Una red neuronal con arquitectura encoder-decoder. Optimiza:
$\min_{\theta, \phi} \sum_{i=1}^n \|x_i - g_\phi(f_\theta(x_i))\|^2$
Donde:
El embedding latente está aprendido como representación compacta que permite reconstruir la entrada.
Un autoencoder es una red neuronal que se entrena para copiar su entrada a la salida, pero con una trampa: entre medio debe comprimir la información en una representación intermedia llamada embedding latente.

Piensa en un compresor de archivos que reduce el tamaño de un documento sin perder lo esencial. El autoencoder hace algo parecido con datos: aprende a comprimirlos en una representación más pequeña y luego reconstruirlos. La diferencia es que nadie le dice qué es "lo esencial" — lo descubre solo minimizando el error de reconstrucción.
El objetivo central de un autoencoder es:
Aprender una representación comprimida, útil y significativa de los datos sin usar etiquetas.¶
Más concretamente:
El autoencoder se entrena sin saber qué número hay en la imagen. Solo quiere reconstruir bien lo que vio. Pero para hacerlo, debe aprender a representar la información de forma eficiente. Esa eficiencia es lo que nos interesa.
Un autoencoder tiene dos partes:
Encoder $ f_\theta(x) $:
Transforma los datos originales $ x \in \mathbb{R}^D $ en una versión comprimida $ h \in \mathbb{R}^d $, donde usualmente $ d \ll D $.
Es decir, aprende una función $ f_\theta: \mathbb{R}^D \to \mathbb{R}^d $.
Decoder $ g_\phi(h) $:
Intenta reconstruir la entrada original desde el embedding latente: $ \hat{x} = g_\phi(h) \in \mathbb{R}^D $.
Es decir, aprende una función $ g_\phi: \mathbb{R}^d \to \mathbb{R}^D $.
La red se entrena minimizando la diferencia entre la entrada original $ x_i $ y su reconstrucción $ \hat{x}_i = g_\phi(f_\theta(x_i)) $. Esto se expresa como:
$\min_{\theta, \phi} \sum_{i=1}^{n} \left\| x_i - g_\phi(f_\theta(x_i)) \right\|^2$
Esta es una pérdida por reconstrucción, y en este caso usamos el error cuadrático medio (MSE).
El autoencoder trata de copiar lo mejor posible la entrada, pero pasando por una compresión intermedia.
Si la salida es parecida a la entrada, quiere decir que el embedding capturó bien la información relevante.
Supongamos que una imagen (aplanada) es un vector de 3 píxeles:
Entrada original:
$ x = [0.2,\ 0.8,\ 0.5] $
Salida reconstruida por el autoencoder:
$ \hat{x} = [0.1,\ 0.7,\ 0.4] $
El MSE (error cuadrático medio) sería:
$\text{MSE} = \frac{1}{3} \left[(0.2 - 0.1)^2 + (0.8 - 0.7)^2 + (0.5 - 0.4)^2\right] = \frac{1}{3}(0.01 + 0.01 + 0.01) = 0.01$
El objetivo del entrenamiento es minimizar ese error.
Las imágenes, como las del dataset MNIST, son matrices de píxeles.
Por ejemplo, una imagen MNIST es:
$\text{Imagen de 28×28} \Rightarrow \text{una matriz con 28 filas y 28 columnas}$
Pero las redes neuronales densas (fully connected) esperan vectores como entrada, no matrices.
"Aplanar" una imagen significa convertir la matriz de 28×28 en un vector de 784 valores (28 × 28 = 784).
Así, cada píxel ocupa una posición en una lista (vector), y podemos alimentar ese vector a una red neuronal.
### EJEMPLO
import numpy as np
from sklearn.metrics import mean_squared_error
import matplotlib.pyplot as plt
# ===========================
# Entrada original vs reconstrucción
# ===========================
x_original = np.array([0.2, 0.8, 0.5]) # Entrada original
x_reconstruida = np.array([0.1, 0.7, 0.4]) # Salida del autoencoder
# ===========================
# Cálculo manual del MSE
# ===========================
errores = (x_original - x_reconstruida) ** 2
mse_manual = np.mean(errores)
# ===========================
# Cálculo con sklearn
# ===========================
mse_sklearn = mean_squared_error(x_original, x_reconstruida)
# ===========================
# Resultados
# ===========================
print("Errores por componente:", errores)
print("MSE (cálculo manual):", mse_manual)
print("MSE (usando sklearn):", mse_sklearn)
# ===========================
# Visualización
# ===========================
labels = ['pixel 1', 'pixel 2', 'pixel 3']
x = np.arange(len(labels))
fig, ax = plt.subplots()
ax.bar(x - 0.15, x_original, width=0.3, label='Original')
ax.bar(x + 0.15, x_reconstruida, width=0.3, label='Reconstruida')
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("Intensidad")
ax.set_title("Reconstrucción vs Entrada original")
ax.legend()
plt.show()
No existe un umbral absoluto o universal para decir si un MSE es "bueno" o "malo". Depende del contexto, de la escala de los datos, y del tipo de problema.
El valor del MSE depende de:
La escala de los datos:
El número de dimensiones:
La complejidad de los datos:
Dicho esto, hay tres criterios concretos para evaluar si el MSE es razonable:
¿El MSE bajó respecto a un modelo base o al inicio del entrenamiento?
¿La reconstrucción se ve bien?
¿El autoencoder profundo mejora respecto a PCA?
Por lo tanto: "No importa sólo que el número del MSE sea bajo. Importa que sea más bajo que antes, que la reconstrucción tenga sentido, y que el modelo esté aprendiendo algo útil."
El encoder y el decoder son funciones parametrizadas por redes neuronales (con pesos $ \theta $ y $ \phi $), que se ajustan mediante backpropagation.
El vector $ h = f_\theta(x) \in \mathbb{R}^d $ se llama embedding latente. Es:
Nota clave: A diferencia de métodos como PCA, en los autoencoders el espacio latente es no lineal y aprendido, no fijo.
Imaginen que el encoder es un periodista que tiene que resumir una noticia larga (la entrada) en 3 frases (el embedding latente). El decoder es otro periodista que, usando solo esas 3 frases, debe reconstruir el artículo completo. El autoencoder se entrena hasta que esta reconstrucción sea lo más parecida posible al original.
Aunque su idea es simple —copiar la entrada—, los autoencoders se han transformado en una herramienta poderosa para entender, transformar y generar datos. Son el punto de partida hacia los modelos generativos más sofisticados de hoy.
Los autoencoders tienen una larga historia que antecede a los modelos modernos de deep learning. Algunos hitos importantes:
| Año | Evento clave |
|---|---|
| 1986 | Rumelhart, Hinton y Williams formalizan el algoritmo de backpropagation, que permite entrenar redes con múltiples capas ocultas. Esto abre la puerta al entrenamiento de arquitecturas profundas como los autoencoders. |
| 1990s | Los autoencoders se estudian como técnica de reducción de dimensionalidad no lineal, pero el entrenamiento de redes profundas seguía siendo difícil. |
| 2006 | Hinton y Salakhutdinov publican "Reducing the Dimensionality of Data with Neural Networks" (Science, 2006), mostrando que los autoencoders profundos pueden aprender representaciones compactas útiles si se inicializan correctamente. En paralelo, Hinton et al. proponen las Deep Belief Networks basadas en Restricted Boltzmann Machines, marcando el inicio del deep learning moderno. |
| 2010s | Renacimiento de autoencoders en el contexto de: |
A continuación, mostramos visualmente los resultados de aplicar PCA, t-SNE, UMAP y Autoencoder (con 2D en la capa latente) sobre MNIST.
Al final del lab haremos una discusión completa sobre cuándo conviene usar cada método. Por ahora, el objetivo es desarrollar intuición visual.
Este ejercicio muestra cómo distintos algoritmos de reducción de dimensionalidad proyectan los datos de MNIST (imágenes de 28×28 píxeles) a 2 dimensiones. El objetivo es comparar:
import os
# Limitar threads para reducir conflictos OpenMP / MKL / BLAS
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
# TensorFlow
os.environ["TF_NUM_INTRAOP_THREADS"] = "1"
os.environ["TF_NUM_INTEROP_THREADS"] = "1"
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" # workaround; no es lo ideal, pero a veces evita el crash
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import umap
import tensorflow as tf
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense
# Limitar threads también desde TensorFlow
tf.config.threading.set_intra_op_parallelism_threads(1)
tf.config.threading.set_inter_op_parallelism_threads(1)
# ==========================================================
# 1. Cargar y preparar datos
# ==========================================================
(x_train, y_train), _ = mnist.load_data()
x_train = x_train[:2000].astype("float32") #/ 255.0 # usar solo 2000 muestras para acelerar el proceso
y_train = y_train[:2000] # labels para visualización
x_train = x_train.reshape((len(x_train), -1)) # aplanar imágenes a vectores de 784 dimensiones
# ==========================================================
# 2. Reducción lineal con PCA
# ==========================================================
pca = PCA(n_components=2, random_state=42).fit_transform(x_train)
# ==========================================================
# 3. Reducción no lineal con t-SNE
# ==========================================================
tsne = TSNE( #
n_components=2,
perplexity=30,
init="pca", # usar PCA para inicialización puede ayudar a la estabilidad
learning_rate="auto",
random_state=42
).fit_transform(x_train)
# ==========================================================
# 4. Reducción no lineal con UMAP
# ==========================================================
umap_embedding = umap.UMAP(
n_components=2,
random_state=42
).fit_transform(x_train)
# ==========================================================
# 5. Autoencoder con espacio latente 2D
# ==========================================================
input_img = Input(shape=(784,))
encoded = Dense(128, activation="relu")(input_img)
encoded = Dense(64, activation="relu")(encoded)
encoded_2d = Dense(2)(encoded) # Espacio latente
decoded = Dense(64, activation="relu")(encoded_2d)
decoded = Dense(128, activation="relu")(decoded)
decoded = Dense(784, activation="sigmoid")(decoded)
autoencoder = Model(input_img, decoded)
encoder = Model(input_img, encoded_2d)
autoencoder.compile(optimizer="adam", loss="mse")
autoencoder.fit(
x_train,
x_train,
epochs=20, # prueba primero con menos epochs
batch_size=256,
shuffle=True,
verbose=0
)
ae_embedding = encoder.predict(x_train, verbose=0)
# ==========================================================
# 6. Visualización comparativa unificada
# ==========================================================
methods = {
"PCA": pca,
"t-SNE": tsne,
"UMAP": umap_embedding,
"Autoencoder": ae_embedding
}
fig, axs = plt.subplots(1, 4, figsize=(20, 5), sharex=False, sharey=False)
palette = sns.color_palette("tab10", 10)
for ax, (name, emb) in zip(axs, methods.items()):
sns.scatterplot(
x=emb[:, 0],
y=emb[:, 1],
hue=y_train,
palette=palette,
ax=ax,
legend=False,
s=10
)
ax.set_title(name)
ax.set_xticks([])
ax.set_yticks([])
fig_tmp, ax_tmp = plt.subplots()
scatter = sns.scatterplot(
x=methods["PCA"][:, 0],
y=methods["PCA"][:, 1],
hue=y_train,
palette=palette,
ax=ax_tmp,
legend="full"
)
handles, labels = scatter.get_legend_handles_labels()
plt.close(fig_tmp)
fig.legend(
handles,
labels,
title="Dígito",
loc="center left",
bbox_to_anchor=(1.01, 0.5),
borderaxespad=0,
frameon=True
)
plt.suptitle("Comparación de embeddings 2D en MNIST", fontsize=14)
plt.tight_layout(rect=[0, 0, 0.95, 1])
plt.show()
| Método | Estructura que preserva |
|---|---|
| PCA | Varianza global (direcciones lineales de mayor varianza) |
| t-SNE | Vecindades locales (probabilidades de cercanía entre pares) |
| UMAP | Vecindades locales y conectividad topológica (gráfico fuzzy) |
| Autoencoder | Información útil para reconstrucción de los datos |
En cambio, los autoencoders sí aprenden una función explícita $ f_\theta(x) $ → puedes codificar nuevos ejemplos sin reentrenar.
Los embeddings entrenados son representaciones vectoriales aprendidas automáticamente por un modelo (como un autoencoder o una red neuronal profunda) para capturar la estructura subyacente de los datos en un espacio de menor dimensión.
| Ventaja | Explicación |
|---|---|
| Compresión no lineal | A diferencia de PCA, los embeddings aprendidos pueden capturar relaciones complejas y no lineales en los datos. |
| Representación significativa | El modelo aprende qué dimensiones son más relevantes para la tarea de reconstrucción, clasificación o predicción. |
| Reutilización eficiente | Una vez entrenado, el encoder puede usarse como extractor de características para nuevos datos sin tener que reentrenar todo el sistema. |
| Reducción de ruido | El proceso de codificación puede aprender a filtrar información irrelevante o ruidosa. |
| Espacio estructurado | En el espacio latente, instancias similares están cercanas → ideal para clustering o búsqueda semántica. |
| Escalabilidad | Los embeddings se pueden usar como input para modelos más simples (SVM, KNN, etc.), mejorando eficiencia. |
Una vez que tienes el encoder entrenado, puedes usarlo para cualquier tarea que necesite una representación compacta de los datos: clasificación con modelos más simples (SVM, regresión logística), clustering en el espacio latente, búsqueda por similitud, o detección de anomalías. La clave es que el embedding captura estructura útil sin haber visto etiquetas.
¿Cómo cambia nuestra forma de trabajar con datos cuando, en lugar de usar atributos manuales, usamos embeddings aprendidos por una red? ¿Qué implicancias tiene esto para tareas como clustering, visualización o predicción?
Aprender MNIST usando PCA y Autoencoders
# !pip install keras
# !pip install tensorflow
# Importamos las librerías necesarias
import numpy as np
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from keras.datasets import mnist
from keras.models import Model
from keras.layers import Input, Dense
# ======================================
# Cargar el dataset MNIST
# ======================================
# Cargamos los datos de entrenamiento (imágenes y etiquetas)
(x_train, y_train), _ = mnist.load_data()
# Seleccionamos solo las primeras 2000 imágenes para hacer el entrenamiento más rápido
# Convertimos los valores de 0-255 a valores entre 0 y 1 (normalización)
x_train = x_train[:2000].astype('float32') / 255.
# Reescribimos cada imagen 28x28 como un vector de 784 elementos (flatten)
x_train = x_train.reshape((len(x_train), -1))
# Aseguramos que las etiquetas coincidan en cantidad con los datos
y_train = y_train[:2000]
# ======================================
# PCA para 32 componentes
# ======================================
# Creamos un modelo PCA que reduzca las dimensiones de 784 a 32
pca = PCA(n_components=32)
# Ajustamos el modelo PCA a los datos de entrenamiento
x_pca = pca.fit_transform(x_train)
# Reconstruimos las imágenes desde las 32 dimensiones latentes
x_pca_inv = pca.inverse_transform(x_pca)
# ======================================
# Autoencoder simple (undercomplete)
# ======================================
# Definimos la entrada de 784 dimensiones (una imagen flatten de 28x28)
input_img = Input(shape=(784,))
# Capa de codificación: reduce a 32 dimensiones usando una activación ReLU
encoded = Dense(32, activation='relu')(input_img)
# Capa de decodificación: reconstruye la imagen original de vuelta a 784 dimensiones
# Usamos 'sigmoid' para que los valores estén entre 0 y 1
decoded = Dense(784, activation='sigmoid')(encoded)
# Definimos el modelo completo: input → encoded → decoded
autoencoder = Model(input_img, decoded)
# Compilamos el modelo con el optimizador 'adam' y pérdida de error cuadrático medio (MSE)
autoencoder.compile(optimizer='adam', loss='mse')
# Entrenamos el autoencoder usando las imágenes como input y también como target (reconstrucción)
autoencoder.fit(x_train, x_train, epochs=10, batch_size=256, shuffle=True)
# ======================================
# Reconstrucciones
# ======================================
# Obtenemos las reconstrucciones del autoencoder para las primeras 10 imágenes del set
x_ae = autoencoder.predict(x_train[:10])
# ======================================
# Visualización comparativa
# ======================================
# Definimos una función que dibuja:
# - Fila 1: imágenes originales
# - Fila 2: reconstrucciones con PCA
# - Fila 3: reconstrucciones con Autoencoder
def plot_comparison(original, recon_pca, recon_ae):
fig, axs = plt.subplots(3, 10, figsize=(20, 6))
for i in range(10):
# Fila 1: Imagen original
axs[0, i].imshow(original[i].reshape(28, 28), cmap='gray')
axs[0, i].axis('off')
# Fila 2: Reconstrucción con PCA
axs[1, i].imshow(recon_pca[i].reshape(28, 28), cmap='gray')
axs[1, i].axis('off')
# Fila 3: Reconstrucción con Autoencoder
axs[2, i].imshow(recon_ae[i].reshape(28, 28), cmap='gray')
axs[2, i].axis('off')
# Etiquetas a la izquierda de cada fila
axs[0, 0].set_ylabel("Original")
axs[1, 0].set_ylabel("PCA")
axs[2, 0].set_ylabel("Autoencoder")
# Mostrar el gráfico
plt.show()
# Llamamos a la función de visualización para mostrar los resultados comparativos
plot_comparison(x_train[:10], x_pca_inv[:10], x_ae)
La imagen muestra tres filas de dígitos:
El código está bien estructurado. Sin embargo, hay dos posibles problemas críticos en la práctica que explican el mal desempeño:
autoencoder.fit(x_train, x_train, epochs=10, batch_size=256, shuffle=True)
Solo 10 épocas y con 2000 datos es probablemente muy poco para que el autoencoder logre aprender una representación significativa. Esto es especialmente cierto si se usan dos capas de tamaño 784 → 32 → 784, lo que requiere más datos para capturar bien los patrones.
Solución: prueba con 50 o 100 épocas.
encoded = Dense(32, activation='relu')(input_img)
decoded = Dense(784, activation='sigmoid')(encoded)
Esto es una red muy shallow (poca profundidad). A veces una sola capa intermedia no es suficiente para que el autoencoder aprenda buenas reconstrucciones no lineales. Puede haber aprendido un "promedio" visual (de ahí que todos parecen 9s o 8s).
Solución sugerida: usa una arquitectura ligeramente más profunda:
# Encoder
encoded = Dense(128, activation='relu')(input_img)
encoded = Dense(64, activation='relu')(encoded)
encoded = Dense(32, activation='relu')(encoded)
# Decoder
decoded = Dense(64, activation='relu')(encoded)
decoded = Dense(128, activation='relu')(decoded)
decoded = Dense(784, activation='sigmoid')(decoded)
autoencoder.summary() y revisar si tiene suficientes parámetros para la tarea.Este resultado muestra lo importante que es elegir una buena arquitectura y entrenar suficientemente. Aunque el código esté bien escrito, el modelo puede no aprender si no tiene capacidad suficiente o tiempo de entrenamiento.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense
# ======================================
# Cargar datos con etiquetas
# ======================================
(x_train, y_train), _ = mnist.load_data()
x_train = x_train[:2000].astype('float32') #/ 255. # Normalizamos [0,1]
x_train = x_train.reshape((len(x_train), -1)) # Flatten: 28x28 → 784
y_train = y_train[:2000] # Cortamos también las etiquetas
# ======================================
# PCA para 32 componentes
# ======================================
pca = PCA(n_components=32)
x_pca = pca.fit_transform(x_train) # Proyección a 32D
x_pca_inv = pca.inverse_transform(x_pca) # Reconstrucción a 784D
# ======================================
# Autoencoder profundo (undercomplete)
# ======================================
input_img = Input(shape=(784,))
# Encoder profundo
x = Dense(128, activation='relu')(input_img)
x = Dense(64, activation='relu')(x)
encoded = Dense(32, activation='relu')(x) # Embedding latente 32D
# Decoder profundo
x = Dense(64, activation='relu')(encoded)
x = Dense(128, activation='relu')(x)
decoded = Dense(784, activation='sigmoid')(x) # Salida reconstruida
# Modelo autoencoder completo
autoencoder = Model(input_img, decoded)
autoencoder.compile(optimizer='adam', loss='mse')
# Entrenamiento del autoencoder
autoencoder.fit(x_train, x_train, epochs=50, batch_size=256, shuffle=True, verbose=1)
# Reconstrucciones del autoencoder (10 primeras imágenes)
x_ae = autoencoder.predict(x_train[:10])
# ======================================
# Visualización comparativa
# ======================================
def plot_comparison(original, recon_pca, recon_ae):
fig, axs = plt.subplots(3, 10, figsize=(20, 6))
for i in range(10):
# Fila 1: Imágenes originales
axs[0, i].imshow(original[i].reshape(28, 28), cmap='gray')
axs[0, i].axis('off')
# Fila 2: Reconstrucción con PCA
axs[1, i].imshow(recon_pca[i].reshape(28, 28), cmap='gray')
axs[1, i].axis('off')
# Fila 3: Reconstrucción con Autoencoder
axs[2, i].imshow(recon_ae[i].reshape(28, 28), cmap='gray')
axs[2, i].axis('off')
# Etiquetas a la izquierda de cada fila
axs[0, 0].set_ylabel("Original", fontsize=12)
axs[1, 0].set_ylabel("PCA", fontsize=12)
axs[2, 0].set_ylabel("Autoencoder", fontsize=12)
plt.tight_layout()
plt.show()
# Ejecutamos la visualización comparativa
plot_comparison(x_train[:10], x_pca_inv[:10], x_ae)
Tanto PCA como el autoencoder logran reconstruir razonablemente bien la forma general de los dígitos al comprimir de 784 a 32 dimensiones. Sin embargo, PCA tiende a producir reconstrucciones más borrosas y suavizadas, porque su compresión es lineal y depende de componentes principales globales.
El autoencoder, en cambio, aprende una representación no lineal adaptada a los datos, lo que le permite capturar mejor ciertos rasgos característicos de los dígitos. En varios ejemplos, sus reconstrucciones preservan de forma más fiel la estructura visual del número original, aunque todavía presentan algo de difuminación.
import seaborn as sns
from sklearn.decomposition import PCA
# =====================================
# 1. Definir el encoder
# =====================================
# Este modelo va desde la entrada (784D) hasta la capa latente (32D)
encoder = Model(inputs=input_img, outputs=encoded)
# =====================================
# 2. Obtener embeddings latentes
# =====================================
z_auto = encoder.predict(x_train, verbose=0) # shape: (2000, 32)
print("Shape del embedding latente:", z_auto.shape)
# =====================================
# 3. Reducir el embedding 32D a 2D para visualizar
# =====================================
pca_latent = PCA(n_components=2, random_state=42)
z_2d = pca_latent.fit_transform(z_auto)
# =====================================
# 4. Graficar
# =====================================
plt.figure(figsize=(8, 6))
sns.scatterplot(
x=z_2d[:, 0],
y=z_2d[:, 1],
hue=y_train,
palette="tab10",
s=20
)
plt.title("PCA del espacio latente aprendido por el Autoencoder")
plt.xlabel("Componente 1")
plt.ylabel("Componente 2")
plt.legend(title="Dígito", bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.show()
import umap
import seaborn as sns
encoder = Model(inputs=input_img, outputs=encoded)
z_auto = encoder.predict(x_train, verbose=0).astype("float32")
z_umap = umap.UMAP(
n_neighbors=15,
min_dist=0.1,
n_components=2,
random_state=42
).fit_transform(z_auto)
plt.figure(figsize=(8, 6))
sns.scatterplot(
x=z_umap[:, 0],
y=z_umap[:, 1],
hue=y_train,
palette="tab10",
s=20
)
plt.title("UMAP del espacio latente aprendido por el Autoencoder")
plt.xlabel("UMAP 1")
plt.ylabel("UMAP 2")
plt.legend(title="Dígito", bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.show()
Al proyectar el espacio latente de 32 dimensiones con PCA, observamos una nube con bastante solapamiento entre clases. Esto sugiere que la estructura del embedding aprendido por el autoencoder no se organiza principalmente de manera lineal en 2D.
En cambio, al aplicar UMAP sobre ese mismo embedding, emergen grupos mucho más definidos para varios dígitos. Esto indica que el autoencoder sí aprendió una representación latente informativa, pero su geometría parece ser más bien no lineal. UMAP logra revelar mejor esa estructura local y parte de la organización global del espacio aprendido.
Este resultado indica que el autoencoder ha aprendido una representación latente útil y significativa: dígitos similares están cerca, diferentes están más separados, y la estructura global es coherente. UMAP proyecta esta estructura a 2D revelando el "mapa mental" aprendido por la red.
Este notebook compara dos formas de reducir dimensionalidad y reconstruir imágenes:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense
numpy, matplotlib: procesamiento y visualización de datos.PCA: reducción lineal de dimensionalidad.tensorflow.keras: para construir y entrenar el autoencoder.(x_train, y_train), _ = mnist.load_data()
x_train = x_train[:2000].astype('float32') / 255.
x_train = x_train.reshape((len(x_train), -1))
y_train = y_train[:2000]
pca = PCA(n_components=32)
x_pca = pca.fit_transform(x_train)
x_pca_inv = pca.inverse_transform(x_pca)
x_pca_inv: reconstrucción a partir del embedding PCA.from keras.models import Model
from keras.layers import Input, Dense
input_img = Input(shape=(784,))
# Encoder
x = Dense(128, activation='relu')(input_img)
x = Dense(64, activation='relu')(x)
encoded = Dense(32, activation='relu')(x)
# Decoder
x = Dense(64, activation='relu')(encoded)
x = Dense(128, activation='relu')(x)
decoded = Dense(784, activation='sigmoid')(x)
autoencoder = Model(input_img, decoded)
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train, x_train, epochs=50, batch_size=256, shuffle=True, verbose=1)
loss='mse': minimiza el error cuadrático medio entre imagen original y reconstrucción.optimizer='adam': optimizador estándar robusto y rápido.batch_size=256: número de imágenes procesadas por paso.shuffle=True: reordena los datos entre épocas para mejor generalización.x_ae = autoencoder.predict(x_train[:10])
def plot_comparison(original, recon_pca, recon_ae):
...
plot_comparison(x_train[:10], x_pca_inv[:10], x_ae)
En este ejercicio vamos a construir y entrenar un autoencoder totalmente conectado para aprender a reconstruir imágenes de caras de personas famosas usando el dataset lfw_people que viene con sklearn.
El objetivo es comprimir cada imagen a una representación más pequeña (embedding latente) y luego tratar de reconstruir la imagen original desde esa representación. Vamos a comparar visualmente las imágenes originales y sus versiones reconstruidas.
# ========================================================
# AUTOENCODER con dataset de caras famosas (LFW)
# ========================================================
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_lfw_people
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense
from tensorflow.keras.optimizers import Adam
# tf.config.threading.set_intra_op_parallelism_threads(1)
# tf.config.threading.set_inter_op_parallelism_threads(1)
# ----------------------------
# 1. Cargar dataset de imágenes de caras
# ----------------------------
lfw = fetch_lfw_people(min_faces_per_person=40, resize=0.4)
X = lfw.images
n_samples, h, w = X.shape
print("Tamaño del dataset:", X.shape)
# ----------------------------
# 2. Aplanar y normalizar
# ----------------------------
X_flat = X.reshape((n_samples, h * w)).astype("float32") # sklearn ya devuelve los valores en [0, 255] como float32; verificar con X.max() antes de decidir si normalizar
# ----------------------------
# 3. Dividir en entrenamiento y prueba
# ----------------------------
X_train, X_test = train_test_split(X_flat, test_size=0.2, random_state=42)
# ----------------------------
# 4. Definir arquitectura del autoencoder
# ----------------------------
input_dim = X_flat.shape[1]
input_img = Input(shape=(input_dim,))
# Encoder
encoded = Dense(512, activation="relu")(input_img)
encoded = Dense(256, activation="relu")(encoded)
latent = Dense(128, activation="relu")(encoded)
# Decoder
decoded = Dense(256, activation="relu")(latent)
decoded = Dense(512, activation="relu")(decoded)
output_img = Dense(input_dim, activation="sigmoid")(decoded)
autoencoder = Model(input_img, output_img)
autoencoder.compile(optimizer=Adam(learning_rate=0.001), loss="mse")
# ----------------------------
# 5. Entrenar el autoencoder
# ----------------------------
history = autoencoder.fit(
X_train, X_train,
epochs=20,
batch_size=128,
shuffle=True,
validation_data=(X_test, X_test),
verbose=1
)
# ----------------------------
# 6. Obtener reconstrucciones
# ----------------------------
decoded_imgs = autoencoder.predict(X_test, verbose=0)
# ----------------------------
# 7. Visualización: Original vs Reconstruida
# ----------------------------
n = 10
plt.figure(figsize=(20, 4))
for i in range(n):
ax = plt.subplot(2, n, i + 1)
plt.imshow(X_test[i].reshape((h, w)), cmap="gray")
plt.title("Original")
plt.axis("off")
ax = plt.subplot(2, n, i + 1 + n)
plt.imshow(decoded_imgs[i].reshape((h, w)), cmap="gray")
plt.title("Reconstruida")
plt.axis("off")
plt.suptitle("Reconstrucción de caras usando Autoencoder")
plt.tight_layout()
plt.show()
# ----------------------------
# 8. Evaluar reconstrucción con MSE
# ----------------------------
mse = mean_squared_error(X_test, decoded_imgs)
print("Error cuadrático medio (MSE) en test:", round(mse, 5))
# ----------------------------
# 9. Graficar pérdida
# ----------------------------
plt.figure(figsize=(10, 4))
plt.plot(history.history["loss"], label="Entrenamiento")
plt.plot(history.history["val_loss"], label="Validación")
plt.xlabel("Época")
plt.ylabel("Pérdida (MSE)")
plt.title("Evolución de la pérdida del Autoencoder")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
autoencoder.summary()
print("Min reconstrucción:", decoded_imgs.min())
print("Max reconstrucción:", decoded_imgs.max())
print("Min de normalizado X:", X_flat.min())
print("Max de normalizado X:", X_flat.max())
print("Min de todo X:", X.min())
print("Max de todo X:", X.max())
Este autoencoder recibe una imagen de entrada representada por 1850 píxeles y la comprime gradualmente a través del encoder:
$1850 \rightarrow 512 \rightarrow 256 \rightarrow 128$
La capa de 128 neuronas corresponde al espacio latente o bottleneck. Esa es la representación comprimida que la red aprende de cada cara. Luego, el decoder intenta reconstruir la imagen original recorriendo el camino inverso:
$128 \rightarrow 256 \rightarrow 512 \rightarrow 1850$
La arquitectura es simétrica, lo que la hace más fácil de interpretar: el encoder reduce dimensionalidad y el decoder expande esa representación para reconstruir la entrada.
Un punto importante es la gran cantidad de parámetros. Este modelo tiene más de 2.2 millones de parámetros entrenables, lo cual es mucho para un dataset relativamente pequeño como LFW. Esto ocurre porque las capas densas conectan cada neurona con todas las de la capa siguiente, y cuando la entrada tiene 1850 variables, el número de pesos crece muy rápido. Por ejemplo, solo la primera capa densa ya tiene cientos de miles de parámetros. Esto hace que el modelo tenga mucha capacidad, pero también aumenta el riesgo de sobreajuste y vuelve el entrenamiento más costoso computacionalmente.
Además, el resumen distingue entre tres tipos de parámetros:
En otras palabras: los trainable params son lo que el modelo aprende como representación, mientras que los optimizer params son variables auxiliares que permiten entrenar mejor ese modelo. No forman parte directa de la red al momento de hacer predicciones, pero sí consumen memoria durante el entrenamiento.
Finalmente, los valores reportados abajo muestran que:
Esto es coherente con usar una capa final con activación sigmoid, ya que esa función restringe la salida al rango $[0,1]$, que coincide con la escala de las imágenes normalizadas.
Aunque el código es correcto, hay dos causas principales técnicas que hacen que un autoencoder tradicional no sea lo más adecuado:
.reshape(...), esa estructura se pierde completamente.Las capas densas no pueden capturar relaciones espaciales locales.
Por eso, aunque la pérdida baja, el modelo simplemente podría aprender a producir un promedio con ruido, y no una reconstrucción fiel.
mse puede ser engañosa en imágenes¶Las convoluciones:
Ya vimos que un autoencoder aprende una función de identidad aproximada comprimiendo la entrada en un espacio latente y luego reconstruyéndola. El problema es que cuando la entrada son imágenes, la arquitectura densa que usamos hasta ahora tiene limitaciones estructurales importantes.
Se entrena para minimizar el error cuadrático medio (MSE):
$\mathcal{L}_{\text{recon}} = \frac{1}{n} \sum_{i=1}^n \| x_i - \hat{x}_i \|_2^2 = \frac{1}{n} \sum_{i=1}^n \| x_i - D(E(x_i)) \|_2^2$
Esto penaliza reconstrucciones que se alejan mucho del valor original por pixel.
Una capa convolucional (Conv2D) es un tipo de capa que aplica un conjunto de filtros aprendibles sobre una imagen de entrada para extraer características locales como bordes, texturas o formas.
Piensa en una ventana que se desliza por una imagen.
Imagina un pequeño patrón (filtro o kernel) que se desliza sobre toda la imagen. En cada posición, este patrón compara su contenido con la región local de la imagen y produce un número (una activación). En otras palabras, en cada posición, compara lo que “ve” con un pequeño filtro, y produce una salida numérica que resume esa comparación. Esto da lugar a un nuevo mapa de características (feature map).
Por ejemplo:
Cada filtro actúa como un detector de patrones específicos.

Sea $ x \in \mathbb{R}^{H \times W} $ una imagen de entrada y $ k \in \mathbb{R}^{f \times f} $ un filtro de convolución (por ejemplo, $ 3 \times 3 $).
La convolución se define como:
$(x * k)(i, j) = \sum_{m=0}^{f-1} \sum_{n=0}^{f-1} x(i+m, j+n) \cdot k(m,n)$
Cada filtro se aplica sobre toda la imagen para generar un mapa de activación. El resultado es una nueva imagen (más pequeña) que contiene dónde el patrón fue detectado.
Es como medir qué tan bien "calza" el patrón del filtro en cada región de la imagen.
Cada valor de la salida es un producto punto entre:
Un subparche de la imagen de tamaño igual al kernel
El kernel
Los filtros (también llamados kernels) son pequeñas matrices de pesos aprendibles que se deslizan sobre la imagen para extraer patrones locales, como bordes, texturas o formas.
Cada filtro detecta una característica específica en la imagen.
Un filtro de $ 3 \times 3 $:
$\text{Filtro (kernel)} =\begin{bmatrix}-1 & 0 & 1 \\-2 & 0 & 2 \\-1 & 0 & 1 \\\end{bmatrix}$
Esto detecta bordes verticales (¡es el clásico filtro de Sobel!).
Pero en una red neuronal… ¡el filtro no lo defines tú!
La red aprende automáticamente estos valores durante el entrenamiento.
Es decir, el filtro parte aleatorio y va adaptándose según la pérdida.
La operación que aplica el filtro a la imagen es una convolución discreta (en práctica, es correlación cruzada):
$\text{Output}(i,j) = \sum_{u=0}^{k-1} \sum_{v=0}^{k-1} I(i+u, j+v) \cdot K(u,v)$
Durante el entrenamiento, los valores del filtro se ajustan para minimizar la función de pérdida (por ejemplo, el error de reconstrucción en un autoencoder).
Cada filtro termina especializándose:
Depende de cuántas características quieras que la red aprenda.
Por ejemplo:
Conv2D(filters=32, kernel_size=(3,3))
| Etapa de la red | Tamaño espacial | Cantidad típica de filtros | Intuición |
|---|---|---|---|
| Entrada | grande (e.g. 48×48) | 16 – 64 | Patrones simples |
| Medio | 24×24 – 12×12 | 64 – 128 | Patrones más complejos |
| Profunda | 6×6 – 3×3 | 128 – 512 | Combinaciones abstractas |
Más filtros = más capacidad de aprendizaje
Pero también más parámetros, más overfitting y más costo computacional
Para un filtro de tamaño $ k \times k $ y una entrada con $ C $ canales:
$\text{Parámetros por filtro} = k \cdot k \cdot C + 1 \text{ (bias)}$
Ejemplo:
Conv2D(32, kernel_size=3)$\text{Total parámetros} = (3 \cdot 3 \cdot 1 + 1) \cdot 32 = 320$

(1-0) + (-5 3) + (2 -3) + (-4-3) + (0 1) + (-6-2) + (9-2) + (2 0) + (-5* 3) = -30
Supongamos que tenemos una imagen de entrada $ x \in \mathbb{R}^{4 \times 4} $:
$x = \begin{bmatrix}1 & 2 & 0 & 1 \\4 & 5 & 1 & 2 \\1 & 7 & 8 & 1 \\0 & 1 & 2 & 3\end{bmatrix}$
Y queremos aplicar el siguiente filtro $ k $ de detección de bordes verticales:
$k = \begin{bmatrix}-1 & 0 & 1 \\-1 & 0 & 1 \\-1 & 0 & 1\end{bmatrix}$
Tomamos el primer parche $ 3 \times 3 $ de la imagen:
$\text{patch}_{(1,1)} = \begin{bmatrix}1 & 2 & 0 \\4 & 5 & 1 \\1 & 7 & 8\end{bmatrix}$
Multiplicamos elemento a elemento con el filtro y sumamos:
$(x * k)(1,1) =(-1)\cdot1 + 0\cdot2 + 1\cdot0 + (-1)\cdot4 + 0\cdot5 + 1\cdot1 + (-1)\cdot1 + 0\cdot7 + 1\cdot8$
$= -1 + 0 + 0 - 4 + 0 + 1 - 1 + 0 + 8 = \boxed{3}$
Nuevo parche:
$\text{patch}_{(1,2)} = \begin{bmatrix}2 & 0 & 1 \\5 & 1 & 2 \\7 & 8 & 1\end{bmatrix}$
$$ (x * k)(1,2) = (-1)\cdot2 + 0\cdot0 + 1\cdot1 + (-1)\cdot5 + 0\cdot1 + 1\cdot2 + (-1)\cdot7 + 0\cdot8 + 1\cdot1 $$$$ = -2 + 0 + 1 -5 + 0 + 2 -7 + 0 + 1 = \boxed{-10} $$La dimensión de salida por eje es:
$$ \left\lfloor \frac{H - f + 2p}{s} \right\rfloor + 1 $$y análogamente para el ancho, donde:
1. Sin padding, stride 1 entrada $32\times 32$, filtro $3\times 3$
$$ 30\times 30 $$2. Con padding 1, stride 1 entrada $32\times 32$, filtro $3\times 3$
$$ 32\times 32 $$3. Sin padding, stride 2 entrada $32\times 32$, filtro $3\times 3$
$$ 15\times 15 $$En nuestro caso anterior:
Con stride = 1 y sin padding, obtendremos una matriz de salida de tamaño $ 2 \times 2 $ (porque $ (4 - 3 +2*0)/1+ 1 = 2 $).
$$ x * k = \begin{bmatrix} 3 & -10 \\ ? & ? \end{bmatrix} $$(Se continua con las posiciones $ (2,1) $ y $ (2,2) $ para completar el mapa)
| Elemento | Intuición |
|---|---|
| $ x $ | imagen original |
| $ k $ | lupa que busca un patrón |
| $ x * k $ | imagen nueva que muestra dónde el patrón fue detectado |
| $ (i, j) $ | posición donde se aplica el filtro |
| Resultado | Imagen más pequeña con activaciones |
import matplotlib.pyplot as plt
import numpy as np
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense
# Cargar y preparar datos (MNIST)
(x_train, y_train), (_, _) = mnist.load_data()
x_train = x_train.astype('float32') / 255.0
x_train = np.expand_dims(x_train, axis=-1)
model = Sequential([
Conv2D(8, (3, 3), activation='relu', input_shape=(28, 28, 1)),
MaxPooling2D(),
Flatten(),
Dense(10, activation='softmax')
])
model.compile(
optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
model.fit(x_train, y_train, epochs=3, batch_size=128)
# Visualizar filtros aprendidos (pesos de la primera capa Conv2D)
filters = model.layers[0].get_weights()[0] # (3, 3, 1, 8)
plt.figure(figsize=(10, 2))
for i in range(filters.shape[-1]):
f = filters[:, :, 0, i]
plt.subplot(1, 8, i+1)
plt.imshow(f, cmap='gray')
plt.axis('off')
plt.title(f"Filtro {i}")
plt.suptitle("Filtros aprendidos por la primera capa")
plt.tight_layout()
plt.show()
f.max()
Un filtro convolucional es una matriz de pesos que se multiplica (producto punto) con una pequeña región de la imagen de entrada. Esta operación se repite sobre toda la imagen (sliding window). El resultado es un mapa de activación que resalta ciertas características según el filtro.
En general, interpretamos el tipo de patrón que aprende cada filtro, en función de sus valores (brillo = peso alto positivo; negro = negativo; gris = cerca de cero). Por ejemplo,
| Observación visual | Intuición / posible patrón que detecta |
|---|---|
| Gradiente claro a oscuro hacia derecha | Detecta transiciones horizontales claras → oscuras |
| Líneas horizontales con contraste | Posiblemente un borde horizontal |
| Líneas verticales con contraste | Borde vertical o transiciones fuertes de arriba a abajo |
Cada filtro aprende a detectar una característica que ayude a minimizar la pérdida del modelo. Como esta es la primera capa, detectan patrones muy básicos como:
Estas características serán combinadas por capas posteriores para identificar formas más complejas (como un dígito o parte de una cara).
En Keras, por ejemplo:
Conv2D(filters=32, kernel_size=(3,3), activation='relu', padding='same')
Esto significa:
ReLU se aplica para introducir no linealidad.padding='same' se agregan ceros para mantener tamaño, con validla salida es más pequeña.Las capas MaxPooling2D reducen la resolución espacial de las imágenes, guardando solo lo más importante:
$$ \mathbb{R}^{48 \times 48 \times 32} \xrightarrow{\text{MaxPooling}} \mathbb{R}^{24 \times 24 \times 32} $$Cada bloque reduce un 2x2 a un 1x1 tomando el valor máximo.
Esto forma parte del encoder.
Para reconstruir la imagen, necesitamos aumentar resolución. Hay dos formas:
UpSampling2D: duplicar valores por interpolación.
Conv2DTranspose: también llamada deconvolución, aprende pesos.
Hasta llegar a:
$$ \hat{x} \in \mathbb{R}^{48 \times 48 \times 1} $$Se reconstruye la imagen paso a paso descomprimiendo la representación latente.
Notar que pasamos de 1 canal a 32 y luego a 64. Esto significa que la red aprende múltiples filtros, cada uno produciendo un mapa de activación distinto. En capas tempranas, estos filtros suelen responder a patrones locales simples, como bordes o texturas. A medida que aumenta la profundidad, la red puede combinar estos patrones en representaciones más complejas. Incrementar el número de canales aumenta la capacidad representacional del modelo, aunque también incrementa su costo computacional.
| Parte | Qué hace |
|---|---|
| Conv2D | Aprende filtros que responden a patrones locales del input (detecta patrones locales como bordes, texturas, formas) |
| MaxPooling | Reduce la resolución espacial resumiendo activaciones locales (resume la imagen, reemplaza una ventana local por su valor máximo, reduciendo la resolución espacial y preservando la activación más alta en esa región). |
| Dense (latente) | Proyecta la representación intermedia a una representación latente compacta |
| UpSampling | Aumenta la resolución espacial de la representación, los detalles se refinan luego mediante convoluciones |
| Conv2D final | Produce la imagen reconstruida combinando las features decodificadas en un mapa de salida final |
| Elemento | Densa | Convolucional |
|---|---|---|
| Captura relaciones espaciales | ❌ No | ✅ Sí |
| Número de parámetros | Muy alto | Mucho menor |
| Preserva forma de imagen | ❌ No | ✅ Sí |
| Requiere flatten | ✅ Sí | ❌ No (sólo en el espacio latente h) |
| Eficiente en imágenes | ❌ No | ✅ Sí |
Formalmente, el autoencoder convolucional aprende una proyección no lineal:
$$ x \mapsto z = E(x) \in \mathcal{Z} \subset \mathbb{R}^d $$donde $ \mathcal{Z} $ es un espacio latente de dimensión reducida que captura lo más relevante de la imagen, y $ D(z) $ intenta reconstruir el original.
"El autoencoder aprende una función $ E $ que toma una entrada $ x $ (por ejemplo, una imagen) y la proyecta a una representación latente $ z $, que vive en un subconjunto $ \mathcal{Z} $ del espacio euclidiano de dimensión $ d $".
Un autoencoder convolucional está aprendiendo a representar imágenes complejas en un espacio más pequeño y manejable. Este espacio se llama espacio latente.
Imagina que quieres reconocer personas con los ojos cerrados o en baja resolución. Lo que te interesa no es cada píxel exacto, sino las características importantes: forma de la cabeza, pelo, nariz, etc.
El encoder aprende a guardar eso (lo esencial) en $ z $.
Es como si en vez de recordar una cara pixel por pixel, recordaras un boceto: forma del rostro, distancia entre ojos, sonrisa... Esa es la representación latente.
| Propiedad | Descripción |
|---|---|
| Invariancia local | Las convoluciones capturan patrones locales (bordes, texturas). |
| Compartición de pesos | Cada filtro aprende una sola función aplicada en toda la imagen. |
| Reducción de parámetros | Mucho menos parámetros que una red densa. |
| Preservación espacial | A diferencia del aplanado, se mantiene la estructura $2D$. |
Una vez entrenado, puedes:
El autoencoder convolucional aprende a comprimir imágenes de manera que aún se puedan reconstruir. Si lo hace bien, significa que captó la esencia visual de los datos, sin necesidad de etiquetas.
El decoder puede realizar dos operaciones distintas por capas:
Eso es lo que hacen UpSampling y Conv2D juntos.
Supón que tienes un tensor en el decoder de tamaño:
$$[ 12 \times 12 \times 64 ]$$Eso significa:
Ahora quieres pasar, por ejemplo, a algo como: $$ [ 24 \times 24 \times 32 ] $$ Eso requiere dos cambios al mismo tiempo:
Una sola operación no necesariamente hace ambas cosas de la forma más clara. Por eso se separa:
Hace esto:
$$ [ 12 \times 12 \times 64 ;\to; 24 \times 24 \times 64 ] $$Sube alto y ancho, pero no cambia los canales.
Toma cada pixel o activación y la “expande” espacialmente.
Por ejemplo, con UpSampling2D(size=2), un valor puede replicarse en un bloque $2\times2$.
Si antes tenías algo como: $$ [ \begin{bmatrix} 1 & 3\ 2 & 4 \end{bmatrix} ] $$ después de upsampling podría quedar:
$$ [ \begin{bmatrix} 1 & 1 & 3 & 3\ 1 & 1 & 3 & 3\ 2 & 2 & 4 & 4\ 2 & 2 & 4 & 4 \end{bmatrix} ] $$Eso pasa en cada canal.
Entonces si tenías 64 canales, sigues teniendo 64 canales, pero ahora cada mapa es más grande.
Luego aplicas una convolución:
$$ [ 24 \times 24 \times 64 ;\to; 24 \times 24 \times 32 ] $$Aquí:
Si usas Conv2D(32, ...), produces 32 mapas de activación, así que la salida tiene 32 canales.
Después del upsampling, la imagen queda más grande pero “tosca”, como una versión agrandada sin refinamiento.
La convolución posterior sirve para:
Es decir:
UpSampling aumenta resolución, y Conv2D aprende cómo refinar esa resolución expandida.
Esto es clave.
En una capa convolucional, cada filtro ve todos los canales de entrada.
Si la entrada es:
$$ [ 24 \times 24 \times 64 ] $$y usas 32 filtros de tamaño $3\times3$, entonces cada filtro tiene tamaño:
$$ [ 3 \times 3 \times 64 ] $$Cada uno produce un mapa de salida.
Entonces:
Por eso:
$$ [ 24 \times 24 \times 64 ;\xrightarrow{\text{Conv2D}(32)}; 24 \times 24 \times 32 ] $$No está “borrando” canales uno a uno. Está aprendiendo 32 nuevas combinaciones de los 64 canales anteriores.
Supón:
$$ [ z \to 12 \times 12 \times 64 ] $$Luego:
Y eso ya parece una imagen reconstruida.
Piensa así:
O más técnico:
UpSampling por sí solo no inventa detalle fino inteligentemente. Solo agranda la representación.
La parte “inteligente” viene de la convolución aprendida después.
En el decoder,
UpSamplingaumenta la resolución espacial de los mapas de activación, pero mantiene fijo el número de canales. Luego,Conv2Drefina esa representación expandida y produce un nuevo conjunto de canales aprendidos. Así, el decoder puede aumentar alto y ancho mientras reorganiza progresivamente la información hasta reconstruir la imagen.
Primero agrando el mapa, después uso convoluciones para limpiarlo, refinarlo y cambiar el número de canales.
En este ejercicio trabajaremos con un autoencoder convolucional para aprender representaciones comprimidas de imágenes de rostros humanos usando el dataset lfw_people (caras famosas) que viene en sklearn.
El objetivo es que el modelo aprenda a comprimir una imagen y luego reconstruirla lo mejor posible desde esa representación latente.
Conv2D y MaxPooling2D para codificar (encoder).UpSampling2D y Conv2D para reconstruir (decoder).mse (error cuadrático medio).# ========================================================
# AUTOENCODER CONVOLUCIONAL FUNCIONAL con dataset de caras LFW
# ========================================================
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_lfw_people
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
from skimage.transform import resize
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, UpSampling2D
from tensorflow.keras.optimizers import Adam
# ----------------------------
# 1. Cargar dataset y reescalar imágenes a 48x48
# ----------------------------
lfw = fetch_lfw_people(min_faces_per_person=40, resize=1.0)
X_raw = lfw.images # (n, h_original, w_original)
# Redimensionamos cada imagen a 48x48 (manteniendo escala de grises)
X = np.array([resize(img, (48, 48), anti_aliasing=True) for img in X_raw])
# Normalizamos a [0, 1] #si ya vienen normalizadas solo dejar en float
print("Min de todo X:", X.min())
print("Max de todo X:", X.max())
X = X.astype('float32') # skimage.resize normaliza a [0, 1] automáticamente
# Agregamos dimensión del canal → (n, 48, 48, 1)
X = np.expand_dims(X, axis=-1)
# Confirmamos dimensiones
n_samples, h, w, c = X.shape
print("Número de imágenes en el dataset:", X.shape[0])
print("Tamaño del dataset:", X.shape)
# ----------------------------
# 2. Dividir en entrenamiento y test
# ----------------------------
X_train, X_test = train_test_split(X, test_size=0.2, random_state=42)
# ----------------------------
# 3. Definir el modelo de Autoencoder Convolucional
# ----------------------------
input_img = Input(shape=(h, w, c)) # (48, 48, 1)
# Encoder
x = Conv2D(64, (3, 3), activation='relu', padding='same')(input_img)
x = MaxPooling2D((2, 2), padding='same')(x) # → 24x24
x = Conv2D(32, (3, 3), activation='relu', padding='same')(x)
encoded = MaxPooling2D((2, 2), padding='same')(x) # → 12x12
# Decoder
x = Conv2D(32, (3, 3), activation='relu', padding='same')(encoded)
x = UpSampling2D((2, 2))(x) # 12x12 → 24x24
x = Conv2D(64, (3, 3), activation='relu', padding='same')(x)
x = UpSampling2D((2, 2))(x) # 24x24 → 48x48
decoded = Conv2D(1, (3, 3), activation='sigmoid', padding='same')(x)
# Modelo
autoencoder = Model(input_img, decoded)
autoencoder.compile(optimizer=Adam(learning_rate=0.0001), loss='mse')#mae #mse #La función mse tiende a producir imágenes borrosas.
# binary_crossentropy: si los valores están en [0,1] y tienes fondo negro/blanco.
# mae: puede dar más nitidez en algunos casos.
# ----------------------------
# 4. Entrenar el modelo
# ----------------------------
history = autoencoder.fit(X_train, X_train,
epochs=50,
batch_size=64,
shuffle=True,
validation_data=(X_test, X_test),
verbose=1)
# ----------------------------
# 5. Visualización de reconstrucciones
# ----------------------------
decoded_imgs = autoencoder.predict(X_test)
n = 10
plt.figure(figsize=(20, 4))
for i in range(n):
# Original
ax = plt.subplot(2, n, i + 1)
plt.imshow(X_test[i].reshape(h, w), cmap='gray')
plt.title("Original")
plt.axis('off')
# Reconstruida
ax = plt.subplot(2, n, i + 1 + n)
plt.imshow(decoded_imgs[i].reshape(h, w), cmap='gray')
plt.title("Reconstruida")
plt.axis('off')
plt.suptitle("Reconstrucción de caras con Autoencoder Convolucional (ajustado)")
plt.tight_layout()
plt.show()
# ----------------------------
# 6. Evaluar con MSE
# ----------------------------
mse = mean_squared_error(X_test.reshape(len(X_test), -1),
decoded_imgs.reshape(len(decoded_imgs), -1))
print("Función de pérdida\n (puede ser error cuadrático medio, MSE) en test:", round(mse, 5))
# ----------------------------
# 7. Evolución de la pérdida
# ----------------------------
plt.figure(figsize=(10, 4))
plt.plot(history.history['loss'], label='Entrenamiento')
plt.plot(history.history['val_loss'], label='Validación')
plt.xlabel("Época")
plt.ylabel("Pérdida (MSE)")
plt.title("Evolución de la pérdida del Autoencoder Convolucional")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
La parte superior muestra:
Porque los autoencoders están limitados por el tamaño del espacio latente (y filtros) y tienden a aprender solo las características más relevantes y repetibles. El modelo descarta detalles que considera menos útiles para minimizar la pérdida.
La curva de pérdida representa cómo el modelo va mejorando su capacidad de reconstrucción a lo largo del tiempo.
Donde:
MSE mide la distancia promedio entre la imagen original y la reconstruida. Cuanto más bajo, mejor.
Eso es bastante bajo para imágenes normalizadas en $[0,1]$: el modelo reconstruye bien la estructura global de las caras, aunque todavía suaviza detalles finos.
Este gráfico muestra que el autoencoder ha aprendido una buena representación latente del conjunto de caras:
Una hipótesis razonable sería aumentar filtros o profundidad. Pero esto no garantiza mejora automática: hay que comparar contra una métrica y contra la visualización. En la siguiente prueba vamos a usar una arquitectura más grande y veremos que, en esta corrida, el resultado es muy parecido al modelo simple.
Esto es una lección importante: más parámetros no siempre significan mejores reconstrucciones.
autoencoder.summary()
print("Min reconstrucción:", decoded_imgs.min())
print("Max reconstrucción:", decoded_imgs.max())
print("Min de todo X:", X.min())
print("Max de todo X:", X.max())
Los autoencoders convolucionales aprenden una representación comprimida preservando la estructura espacial de la imagen. Sin embargo, la calidad de reconstrucción no depende solo de hacer la red más profunda: también importan el cuello de botella, la función de pérdida, el tamaño del dataset y la regularización. Pérdidas como mse tienden a suavizar detalles, y una arquitectura más grande puede capturar patrones más complejos, pero solo si esa capacidad extra se traduce en mejor generalización.
input_img = Input(shape=(48, 48, 1))
Recibimos imágenes de tamaño 48x48 en escala de grises (1 canal).
Este bloque reduce la dimensionalidad de la imagen, manteniendo la estructura local (bordes, texturas, etc).
x = Conv2D(64, (3, 3), activation='relu', padding='same')(input_img)
padding='same', así que el tamaño de la imagen no cambia: 48×48 → 48×48.x = MaxPooling2D((2, 2), padding='same')(x)
x = Conv2D(32, (3, 3), activation='relu', padding='same')(x)
x = MaxPooling2D((2, 2), padding='same')(x)
Resultado: una representación compacta y densa de la imagen en un espacio latente de tamaño 12×12×32.
encoded = ...
Ahora vamos a descomprimir la representación anterior paso a paso.
x = Conv2D(32, (3, 3), activation='relu', padding='same')(encoded)
x = UpSampling2D((2, 2))(x)
x = Conv2D(64, (3, 3), activation='relu', padding='same')(x)
x = UpSampling2D((2, 2))(x)
decoded = Conv2D(1, (3, 3), activation='sigmoid', padding='same')(x)
sigmoid asegura que los valores queden entre 0 y 1, igual que la imagen original.autoencoder = Model(input_img, decoded)
Este modelo va de una imagen a su reconstrucción.
autoencoder.summary()
Esto imprimirá una tabla como esta:
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) [(None, 48, 48, 1)] 0
conv2d (Conv2D) (None, 48, 48, 64) ...
max_pooling2d (MaxPooling2D) (None, 24, 24, 64) 0
...
conv2d_5 (Conv2D) (None, 48, 48, 1) ...
=================================================================
Total params: XXXXXX
Trainable params: XXXXXX
Si quieres generar un gráfico con la arquitectura, puedes usar:
from tensorflow.keras.utils import plot_model
plot_model(autoencoder, show_shapes=True, show_layer_names=True)
# !pip install pydot
# conda install anaconda::graphviz
from tensorflow.keras.utils import plot_model
plot_model(autoencoder, show_shapes=True, show_layer_names=True)
Ahora vamos a entrenar un autoencoder convolucional más profundo. La intuición inicial podría ser: si agregamos más filtros, más capas y Batch Normalization, deberíamos obtener mejores reconstrucciones.
Pero esa es justamente la pregunta que queremos poner a prueba. En machine learning no basta con que una arquitectura sea más grande: debe mejorar en validación/test o entregar una mejora visual clara.
# ========================================================
# AUTOENCODER CONVOLUCIONAL AVANZADO CON LFW
# ========================================================
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_lfw_people
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
from skimage.transform import resize
import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, UpSampling2D, BatchNormalization, Activation
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau
from tensorflow.keras.optimizers import Adam
np.random.seed(42)
tf.random.set_seed(42)
# ----------------------------
# 1. Cargar y preprocesar imagenes (48x48 en escala de grises)
# ----------------------------
lfw = fetch_lfw_people(min_faces_per_person=40, resize=1.0)
X_raw = lfw.images
# Redimensionar manteniendo el rango original y luego normalizar a [0, 1]
X = np.array([
resize(img, (48, 48), anti_aliasing=True, preserve_range=True)
for img in X_raw
], dtype="float32")
if X.max() > 1.0:
X = X / 255.0
X = np.clip(X, 0.0, 1.0)
X = np.expand_dims(X, axis=-1) # (n, 48, 48, 1)
print("Min X:", X.min(), " - Max X:", X.max())
print("Shape:", X.shape)
# ----------------------------
# 2. Dividir en entrenamiento y test
# ----------------------------
X_train, X_test = train_test_split(X, test_size=0.2, random_state=42)
# ----------------------------
# 3. Arquitectura del Autoencoder Convolucional Profundo
# ----------------------------
def conv_block(x, filters):
x = Conv2D(filters, (3, 3), padding="same", use_bias=False)(x)
x = BatchNormalization(momentum=0.9)(x)
x = Activation("relu")(x)
x = Conv2D(filters, (3, 3), padding="same", use_bias=False)(x)
x = BatchNormalization(momentum=0.9)(x)
x = Activation("relu")(x)
return x
input_img = Input(shape=(48, 48, 1))
# Encoder: al bajar resolucion espacial aumentamos filtros.
x = conv_block(input_img, 32)
x = MaxPooling2D((2, 2), padding="same")(x) # 48x48 -> 24x24
x = conv_block(x, 64)
x = MaxPooling2D((2, 2), padding="same")(x) # 24x24 -> 12x12
x = conv_block(x, 128)
encoded = MaxPooling2D((2, 2), padding="same", name="embedding_6x6x128")(x) # 12x12 -> 6x6
# Decoder: reconstruimos invirtiendo la piramide de resoluciones.
x = conv_block(encoded, 128)
x = UpSampling2D((2, 2))(x) # 6x6 -> 12x12
x = conv_block(x, 64)
x = UpSampling2D((2, 2))(x) # 12x12 -> 24x24
x = conv_block(x, 32)
x = UpSampling2D((2, 2))(x) # 24x24 -> 48x48
decoded = Conv2D(1, (3, 3), activation="sigmoid", padding="same")(x)
# ----------------------------
# 4. Compilar y configurar callbacks
# ----------------------------
autoencoder = Model(input_img, decoded)
autoencoder.compile(optimizer=Adam(learning_rate=0.001), loss="mse")
latent_shape = autoencoder.get_layer("embedding_6x6x128").output.shape[1:]
latent_dim = int(np.prod(latent_shape))
print(f"Espacio latente: {latent_shape} = {latent_dim} valores")
callbacks = [
EarlyStopping(monitor="val_loss", patience=15, min_delta=1e-4, restore_best_weights=True),
ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=5, min_lr=1e-5, verbose=1),
ModelCheckpoint("best_autoencoder.keras", monitor="val_loss", save_best_only=True)
]
# ----------------------------
# 5. Entrenar el modelo
# ----------------------------
history = autoencoder.fit(
X_train, X_train,
epochs=100,
batch_size=64,
shuffle=True,
validation_data=(X_test, X_test),
callbacks=callbacks,
verbose=1
)
# ----------------------------
# 6. Evaluar y visualizar reconstrucciones
# ----------------------------
decoded_imgs = autoencoder.predict(X_test, verbose=0)
n = min(10, len(X_test))
plt.figure(figsize=(20, 4))
for i in range(n):
ax = plt.subplot(2, n, i + 1)
plt.imshow(X_test[i].reshape(48, 48), cmap="gray")
plt.title("Original")
plt.axis("off")
ax = plt.subplot(2, n, i + 1 + n)
plt.imshow(decoded_imgs[i].reshape(48, 48), cmap="gray")
plt.title("Reconstruida")
plt.axis("off")
plt.suptitle("Reconstruccion de caras con Autoencoder Convolucional Avanzado")
plt.tight_layout()
plt.show()
# ----------------------------
# 7. Evaluar error
# ----------------------------
mse = mean_squared_error(
X_test.reshape(len(X_test), -1),
decoded_imgs.reshape(len(decoded_imgs), -1)
)
print("Error cuadratico medio (MSE) en test:", round(mse, 5))
# ----------------------------
# 8. Visualizar evolucion de la perdida
# ----------------------------
plt.figure(figsize=(10, 4))
plt.plot(history.history["loss"], label="Entrenamiento")
plt.plot(history.history["val_loss"], label="Validacion")
plt.xlabel("Epoca")
plt.ylabel("Perdida (MSE)")
plt.title("Evolucion de la perdida del Autoencoder Convolucional")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Comparemos los dos autoencoders convolucionales entrenados sobre las mismas caras LFW redimensionadas a $48 \times 48$:
| Modelo | Cuello de botella | Parámetros aprox. | MSE en test |
|---|---|---|---|
| Convolucional simple | $12 \times 12 \times 32 = 4608$ valores | 147 mil | 0.00248 |
| Convolucional avanzado | $6 \times 6 \times 128 = 4608$ valores | 721 mil | 0.00286 |
La conclusión es que el modelo avanzado no mejora de forma clara. De hecho, en esta corrida obtiene un MSE levemente mayor, aunque visualmente las reconstrucciones se ven bastante parecidas.
¿Por qué puede pasar esto?
mse premia reconstrucciones promedio y suaves; por eso diferencias finas de textura pueden no reflejarse bien en la métrica.La lección importante es esta:
Más profundo no significa automáticamente mejor. En autoencoders, la calidad depende del balance entre arquitectura, cuello de botella, datos, función de pérdida y objetivo pedagógico. Si el modelo simple reconstruye casi igual y tiene muchos menos parámetros, puede ser la mejor opción para explicar la idea central.
Para mejorar de verdad, no bastaría con agregar capas. Habría que cambiar la pregunta experimental: por ejemplo, usar un cuello de botella más restrictivo, comparar con SSIM, usar una pérdida perceptual, aumentar datos, o evaluar si el embedding sirve para clustering o clasificación.
Esta arquitectura sirve para mostrar cómo se construye un autoencoder convolucional más profundo. La idea no es afirmar que será automáticamente mejor, sino observar cómo cambia el diseño cuando agregamos capacidad. Debe equilibrar dos cosas:
Por eso este modelo usa una pirámide convolucional:
48x48x1 -> 24x24x32 -> 12x12x64 -> 6x6x128
El embedding sigue siendo espacialmente compacto (6x6), pero aumenta canales (128). Notar que esto mantiene la misma cantidad total de valores latentes que el modelo simple: $6 \times 6 \times 128 = 12 \times 12 \times 32 = 4608$.
Este modelo es un autoencoder convolucional: recibe una imagen de entrada, la transforma en una representación interna o embedding, y luego intenta reconstruir la imagen original.
La entrada es una imagen en escala de grises de tamaño $48 \times 48 \times 1$.
El bloque conv_block(x, filters) aplica dos veces la misma secuencia para aprender patrones visuales más complejos antes de reducir la resolución con MaxPooling:
$ \text{Conv2D} \rightarrow \text{BatchNormalization} \rightarrow \text{ReLU} $
En términos prácticos:
Conv2D(filters, (3,3), padding="same") aprende patrones locales de la imagen.padding="same" mantiene el tamaño espacial de la imagen.BatchNormalization estabiliza el entrenamiento normalizando las activaciones internas.ReLU introduce no linealidad y deja pasar sólo activaciones positivas.use_bias=False es razonable porque BatchNormalization ya incorpora un término de desplazamiento.Cada bloque mantiene la resolución espacial, pero cambia la cantidad de canales según el número de filtros.
El encoder reduce progresivamente la resolución espacial usando MaxPooling2D.
| Etapa | Operación | Salida |
|---|---|---|
| Entrada | Imagen original | $48 \times 48 \times 1$ |
| Bloque conv 32 | Aprende 32 mapas de activación | $48 \times 48 \times 32$ |
| MaxPooling | Reduce resolución a la mitad | $24 \times 24 \times 32$ |
| Bloque conv 64 | Aprende patrones más abstractos | $24 \times 24 \times 64$ |
| MaxPooling | Reduce resolución a la mitad | $12 \times 12 \times 64$ |
| Bloque conv 128 | Aprende patrones de mayor nivel | $12 \times 12 \times 128$ |
| MaxPooling | Genera el embedding | $6 \times 6 \times 128$ |
La idea central es que, a medida que baja la resolución espacial, el modelo aumenta el número de filtros. Es decir, pierde detalle espacial fino, pero gana capacidad para representar patrones más abstractos.
El embedding final es:
$6 \times 6 \times 128$
Esto puede interpretarse como una representación compacta en términos espaciales: la imagen original de $48 \times 48$ se resume en una grilla de $6 \times 6$, donde cada posición tiene 128 características aprendidas.
El decoder invierte la pirámide del encoder. Parte desde el embedding y recupera progresivamente la resolución espacial usando UpSampling2D.
| Etapa | Operación | Salida |
|---|---|---|
| Embedding | Representación interna | $6 \times 6 \times 128$ |
| Bloque conv 128 | Refina la representación latente | $6 \times 6 \times 128$ |
| UpSampling | Duplica la resolución espacial | $12 \times 12 \times 128$ |
| Bloque conv 64 | Reduce canales y organiza información | $12 \times 12 \times 64$ |
| UpSampling | Duplica la resolución espacial | $24 \times 24 \times 64$ |
| Bloque conv 32 | Refina detalles locales | $24 \times 24 \times 32$ |
| UpSampling | Recupera tamaño original | $48 \times 48 \times 32$ |
| Conv2D final | Reconstruye una imagen de 1 canal | $48 \times 48 \times 1$ |
UpSampling2D no aprende parámetros: simplemente aumenta la resolución espacial. Las capas convolucionales posteriores son las que aprenden a transformar esa representación ampliada en una reconstrucción más coherente.
La última capa es:
$ \text{Conv2D}(1, 3 \times 3, \text{sigmoid}) $
Produce una imagen reconstruida de tamaño $48 \times 48 \times 1$.
La activación sigmoid fuerza los valores de salida al intervalo $[0,1]$, lo cual es consistente si las imágenes de entrada fueron normalizadas dividiendo los píxeles por 255.
El autoencoder aprende una función de codificación y decodificación:
$ \text{imagen original} \rightarrow \text{embedding} \rightarrow \text{imagen reconstruida} $
El objetivo no es clasificar la imagen, sino reconstruirla. Para hacerlo bien, el modelo debe aprender una representación interna que capture las estructuras relevantes de la imagen: bordes, texturas, formas locales y patrones espaciales.
Aunque el embedding tiene menor resolución espacial, no necesariamente tiene menos valores totales que la imagen original.
La imagen original tiene:
$48 \times 48 \times 1 = 2304$ valores
El embedding tiene:
$6 \times 6 \times 128 = 4608$ valores
Por lo tanto, esta arquitectura comprime espacialmente, pero no reduce la dimensionalidad total en sentido estricto. Es un cuello de botella espacial, no necesariamente un cuello de botella dimensional. Si se busca una compresión más fuerte, habría que reducir más la resolución, disminuir canales o agregar una capa densa latente más pequeña.
Al entrenarlo, el MSE de test debería ser comparable o menor que el del ejemplo convolucional anterior. Si no baja claramente, conviene mirar primero:
val_loss disminuye de forma sostenida,EarlyStopping está cortando demasiado temprano,Nota: Para imágenes continuas como estas, binary_crossentropy no es necesariamente mejor que mse. Puede servir en datos normalizados, pero tiene más sentido cuando los pixeles se interpretan como probabilidades binarias. En este caso docente, mse hace más clara la comparación entre modelos.
Respuesta:
Depende del objetivo.
| Objetivo | Método sugerido |
|---|---|
| Reducción de dimensión lineal, rápida, interpretable | PCA |
| Visualización 2D que preserve estructura local | t-SNE / UMAP |
| Aprender representaciones compactas paramétricas que puedan ser usadas para otras tareas (e.g. reconstrucción, generación, clasificación) | Autoencoder |
Intuición:
Conclusión:
Si lo que quieres es un mapa útil para visualización, usa t-SNE o UMAP. Si quieres una representación aprendida que se pueda aplicar a nuevos datos o reutilizar, usa un autoencoder.
Respuesta: Un autoencoder es entrenado para que $ g(f(x)) \approx x $. Sin restricciones, la red puede simplemente aprender a copiar la entrada → sin extraer estructura.
Estrategias para evitarlo:
Undercomplete autoencoder:
Regularización explícita:
Intuición:
Si el autoencoder es muy poderoso y no lo limitamos, simplemente va a memorizar. Como un estudiante que copia sin entender. Por eso, le ponemos límites para que tenga que encontrar ‘resúmenes’ o representaciones significativas del input.
Respuesta: Los Variational Autoencoders (VAEs) son una extensión probabilística de los autoencoders.
Autoencoder clásico: aprende un punto en un espacio latente $ h = f(x) $.
VAE: aprende una distribución $ q(h|x) $, típicamente gaussiana → mapea cada entrada a una región difusa del espacio latente.
¿Por qué es útil?
Intuición:
Un autoencoder aprende un punto por ejemplo; un VAE aprende una nube. Así, podemos explorar el espacio latente, no sólo reconociendo, sino también ‘imaginando’ ejemplos nuevos.
Regularización = controlar la capacidad del modelo.
| Tipo | Idea principal | Resultado deseado |
|---|---|---|
| Sparse | Forzar activación mínima en capas ocultas | Representaciones parciales y robustas |
| Denoising | Entrenar con datos corruptos (ruido, dropout) | Representaciones más estables |
| Contractive | Penalizar cambios abruptos en el embedding | Invariancia a pequeñas perturbaciones |
Ahora que sabemos cómo entrenar redes para comprimir datos y reconstruirlos… ¿Y si en vez de un punto en el espacio latente, aprendemos una distribución? ¿Y si ese espacio puede ser usado para crear e interpolar? Bienvenidos al mundo de los modelos generativos: VAEs, GANs, y beyond.