Fundamentos de Redes Convolucionales (CNN)

deep-learning
cnn
Convoluciones, filtros, pooling, mapas de activación y comparación entre CNN y MLP.
Author

Wilder Ramírez Delgado

Published

September 2, 2026

Fundamentos de Redes Convolucionales (CNN)

Serie Deep Learning · del perceptrón a los transformers.

Open in Colab

En este notebook estudiamos convoluciones, filtros, pooling, mapas de activación y la comparación entre CNN y MLP, para entender por qué una arquitectura espacial es necesaria cuando el dato es una imagen.

1. Introducción

Este notebook continúa el camino del curso (Perceptrón → MLP → Backpropagation → Generalización) y responde una pregunta central:

¿Por qué necesitamos una arquitectura especializada para imágenes si una MLP ya puede clasificarlas?

La respuesta no está en «más capas» ni en «más neuronas». Está en la estructura espacial del dato visual y en los sesgos inductivos que una CNN introduce de forma explícita: localidad, compartición de pesos y aprendizaje jerárquico de representaciones.

Una métrica no es una conclusión. Es evidencia para construir una conclusión.

Qué ya se conoce (y no se volverá a explicar)

Perceptrón, MLP, activaciones, pérdida, backpropagation, Train / Validation / Test, matriz de confusión, Accuracy, Precision, Recall, F1, ROC/AUC, generalización, underfitting, overfitting y regularización. Aquí se aplican para comparar MLP y CNN.

Objetivo general

Construir una comprensión profunda y aplicada de las CNN: motivación, mecanismo interno, flujo de información, entrenamiento y análisis interpretativo de resultados.

Competencias que se desarrollan

  • Explicar por qué una imagen es un tensor con estructura espacial.
  • Justificar técnicamente las limitaciones de una MLP en visión (sin afirmar que «se pierde toda la información»).
  • Entender la convolución como detector local antes de usar Conv2D.
  • Distinguir kernel, feature map y activación.
  • Interpretar filtros, stride, padding y pooling.
  • Calcular parámetros de Conv2D y Dense y leer model.summary().
  • Construir, entrenar y evaluar una CNN simple.
  • Comparar MLP y CNN con evidencia experimental, no con eslóganes.
  • Analizar errores y activaciones internas con criterio prudente.

Recorrido de la sesión

IMAGEN → REPRESENTACIÓN ESPACIAL → LIMITACIÓN DE MLP → CONVOLUCIÓN → KERNEL → FEATURE MAP → STRIDE / PADDING → POOLING → CNN → ENTRENAMIENTO → EVALUACIÓN → INTERPRETACIÓN DE ACTIVACIONES.

Filosofía de trabajo: ENTENDER → IMPLEMENTAR → MEDIR → OBSERVAR → COMPARAR → INFERIR → DECIDIR.

# Configuracion general del notebook
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    classification_report,
    confusion_matrix,
    precision_recall_fscore_support,
)
import tensorflow as tf
from tensorflow.keras import layers, models

np.random.seed(42)
tf.random.set_seed(42)

plt.style.use("seaborn-v0_8")

print("TensorFlow:", tf.__version__)

# Datos base para distintas secciones
(x_train_mnist, y_train_mnist), (x_test_mnist, y_test_mnist) = tf.keras.datasets.mnist.load_data()
(x_train_fashion, y_train_fashion), (x_test_fashion, y_test_fashion) = tf.keras.datasets.fashion_mnist.load_data()

# Imagen RGB de ejemplo (incluida en scikit-learn)
img_rgb = datasets.load_sample_image("china.jpg")

print("MNIST train:", x_train_mnist.shape)
print("Fashion-MNIST train:", x_train_fashion.shape)
print("Fashion-MNIST test:", x_test_fashion.shape)
print("Imagen RGB ejemplo:", img_rgb.shape)
TensorFlow: 2.21.0
MNIST train: (60000, 28, 28)
Fashion-MNIST train: (60000, 28, 28)
Fashion-MNIST test: (10000, 28, 28)
Imagen RGB ejemplo: (427, 640, 3)

Interpretación

Este bloque inicial carga librerías, fija semillas y prepara datasets de referencia para que todas las secciones posteriores sean reproducibles y coherentes.

Su significado pedagógico es que un experimento de Deep Learning no comienza en la arquitectura, sino en una configuración controlada de datos y entorno.

El aprendizaje esperado es valorar la reproducibilidad y la preparación de insumos como parte formal del flujo de trabajo.

La conexión con la siguiente sección es que, con los datos cargados, se puede analizar primero la estructura espacial de las imágenes y luego motivar la necesidad de CNN.

Los datasets cargados se reutilizan: MNIST y la imagen RGB para entender convolución; Fashion-MNIST para el experimento comparable MLP vs CNN.

2. Introducción: por qué una red densa no es suficiente para imágenes

Una imagen, vista como dato, es un tensor cuyos valores tienen significado junto con su posición. Dos píxeles con el mismo valor pueden representar cosas distintas si están en lugares diferentes del objeto.

Esa propiedad se llama estructura espacial: patrones como bordes, esquinas y texturas emergen de relaciones locales entre vecinos, no de valores aislados.

Qué ocurre al hacer Flatten

Una MLP requiere vectorizar la entrada. Al aplanar 28 × 28 se obtiene un vector de 784 valores.

Flatten no destruye los valores. El vector conserva todas las intensidades. Lo que desaparece es la representación explícita de vecindad en la arquitectura: la red ya no trata al píxel (i, j) como vecino de (i, j+1), salvo que aprenda esa relación de forma indirecta a partir de los datos.

Una MLP podría aprender relaciones entre píxeles, pero no incorpora de fábrica sesgos inductivos útiles para visión:

  • localidad (conectividad local): lo relevante suele estar cerca;
  • compartición de pesos (weight sharing): el mismo detector se reutiliza en toda la imagen;
  • cierta equivarianza a traslaciones: un patrón desplazado tiende a producir una respuesta desplazada.

Además, en redes densas la cantidad de parámetros crece rápido. Incluso en imágenes pequeñas, la primera capa puede concentrar cientos de miles de pesos, aumentando costo computacional y riesgo de sobreajuste.

La motivación de las CNN nace aquí: usar conectividad local y pesos compartidos para aprender patrones visuales de forma más coherente con la geometría de la imagen.

Dos flujos conceptuales (Fashion-MNIST, 28 × 28)

MLP

Imagen 28×28  →  Flatten  →  vector de 784  →  Dense  →  Dense  →  clasificación

CNN

Imagen 28×28×1  →  Conv2D  →  activación  →  Pooling
                →  Conv2D  →  activación  →  Pooling
                →  Flatten  →  Dense  →  clasificación

La diferencia no es «CNN tiene más capas». Es cuándo y cómo se aplana: en la CNN, Flatten ocurre después de haber extraído evidencia espacial.

Intuición conceptual

En visión, detectar un borde no debería depender de su ubicación exacta. La arquitectura ideal debe reutilizar el mismo detector en toda la imagen.

Idea clave

En imágenes, no basta con aprender una función: se necesita una arquitectura que respete estructura espacial.

Aquí es donde nacen las CNN.

# Visualizacion: imagen original vs imagen aplanada
img_gray = x_train_mnist[0]
flat = img_gray.flatten()

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].imshow(img_gray, cmap="gray")
axes[0].set_title("Imagen original (28x28)")
axes[0].axis("off")

axes[1].plot(flat, color="tab:blue")
axes[1].set_title("Version aplanada (vector de 784)")
axes[1].set_xlabel("Indice en el vector")
axes[1].set_ylabel("Intensidad")

plt.tight_layout()
plt.show()

print("Valores conservados al aplanar:", img_gray.size, "==", flat.size)
print("Flatten conserva intensidades; elimina la grilla de vecindad explicita.")

# Comparacion de parametros: capa densa vs un kernel compartido
params_dense_28 = 28 * 28 * 256 + 256
params_dense_64 = 64 * 64 * 512 + 512
params_kernel_3x3 = 3 * 3 * 1 + 1  # un filtro, 1 canal, con bias

print(f"Capa densa 784 -> 256 (con bias): {params_dense_28:,}")
print(f"Capa densa 64x64 -> 512 (con bias): {params_dense_64:,}")
print(f"Un kernel 3x3 + bias (1 canal, 1 filtro): {params_kernel_3x3}")
print("Ese kernel se reutiliza en todas las posiciones (weight sharing).")
print("Una CNN con muchos filtros y una cabeza densa amplia PUEDE tener mas parametros que una MLP pequena.")
print("El numero de parametros se mide; no se asume.")

Valores conservados al aplanar: 784 == 784
Flatten conserva intensidades; elimina la grilla de vecindad explicita.
Capa densa 784 -> 256 (con bias): 200,960
Capa densa 64x64 -> 512 (con bias): 2,097,664
Un kernel 3x3 + bias (1 canal, 1 filtro): 10
Ese kernel se reutiliza en todas las posiciones (weight sharing).
Una CNN con muchos filtros y una cabeza densa amplia PUEDE tener mas parametros que una MLP pequena.
El numero de parametros se mide; no se asume.

Interpretación

La comparación entre imagen y vector no muestra que «se pierda toda la información». Los 784 valores siguen ahí. Lo que se pierde es la representación explícita de vecindad: la MLP recibe una lista, no una grilla.

Ese detalle es determinante porque en visión los patrones relevantes nacen de relaciones de proximidad (bordes, trazos, contornos). Una MLP puede, en principio, redescubrir esas relaciones, pero lo hace sin los sesgos de localidad y compartición de pesos.

El cálculo de parámetros ilustra otro costo: 784 × N crece rápido. Un kernel 3 × 3 tiene pocos pesos, pero se aplica en muchas posiciones. Eso no garantiza que toda CNN tenga menos parámetros que toda MLP: depende de filtros, canales y capas densas finales.

Idea clave

Aplanar una imagen facilita el cómputo en MLP, pero retira de la arquitectura la estructura espacial que la CNN va a explotar de forma explícita.

Reto técnico

Si una imagen 64 × 64 en escala de grises se conecta a una capa densa de 512 neuronas, ¿cuántos parámetros aparecen en esa capa? Compáralo con 32 kernels 3 × 3 de 1 canal. ¿Qué cambia si la cabeza densa de la CNN es muy ancha?

3. Representación de imágenes como tensores

En Deep Learning, una imagen se modela como tensor porque cada eje tiene significado:

  • Escala de grises: (alto, ancho).
  • RGB: (alto, ancho, canales) con canales R, G y B.

Un valor alto en un píxel no tiene significado aislado; adquiere sentido al compararse con su entorno local y con sus canales asociados. Por eso, preservar dimensiones y orden es fundamental para que la red procese correctamente la información visual.

En práctica, muchas implementaciones convierten datos de uint8 (0 a 255) a float32 normalizado (0 a 1), lo que estabiliza el entrenamiento.

Intuición conceptual

Una imagen RGB puede entenderse como tres “capas transparentes” superpuestas. Cambiar una capa modifica la apariencia final, lo cual muestra por qué el eje de canales no se puede ignorar.

Idea clave

Trabajar con imágenes en CNN exige respetar la semántica de cada dimensión del tensor: alto, ancho y canales.

# Visualizacion de dimensiones y canales RGB
print("Shape imagen RGB:", img_rgb.shape)

r = img_rgb[:, :, 0]
g = img_rgb[:, :, 1]
b = img_rgb[:, :, 2]

fig, axes = plt.subplots(1, 4, figsize=(16, 4))
axes[0].imshow(img_rgb)
axes[0].set_title("Imagen RGB original")
axes[0].axis("off")

axes[1].imshow(r, cmap="Reds")
axes[1].set_title("Canal R")
axes[1].axis("off")

axes[2].imshow(g, cmap="Greens")
axes[2].set_title("Canal G")
axes[2].axis("off")

axes[3].imshow(b, cmap="Blues")
axes[3].set_title("Canal B")
axes[3].axis("off")

plt.tight_layout()
plt.show()
Shape imagen RGB: (427, 640, 3)

Interpretación

Separar canales permite ver que una imagen RGB no es un bloque indivisible, sino una composición de tres fuentes de información alineadas espacialmente.

Esto importa porque una CNN procesa esa estructura multicanal para aprender patrones que dependen tanto de intensidad como de color relativo.

El aprendizaje esperado es comprender que definir correctamente la forma de entrada (alto, ancho, canales) es una decisión conceptual, no solo sintáctica.

La conexión con convolución es que los filtros actuarán precisamente sobre este tensor para producir mapas de activación.

Idea clave

Una imagen RGB se aprende como tensor estructurado, no como lista plana de valores.

Reto técnico

Tomar una imagen RGB y construir una versión de un solo canal (por ejemplo solo R). Comparar visualmente con la imagen original y explicar qué información cromática se pierde.

4. Convolución: intuición antes de la fórmula

La convolución es el núcleo operativo de una CNN. Consiste en mover una ventana pequeña (kernel) por la imagen para responder una pregunta local en cada posición.

En cada desplazamiento ocurre una secuencia fija: 1. Se toma un bloque local de la imagen (ventana). 2. Se compara con el kernel mediante multiplicación elemento a elemento. 3. Se suman los productos para producir un valor de salida. 4. El kernel se desplaza (según el stride) y se repite el proceso.

Al recorrer toda la imagen aparece un feature map: el mapa de respuestas del kernel.

Formalmente, para entrada X y kernel K:

Y(i,j) = Σu Σv X(i+u, j+v) * K(u,v)

Tres términos que no deben confundirse

Concepto Qué es
Kernel Matriz de pesos. En visión clásica se diseña a mano; en una CNN se aprende.
Feature map Resultado de aplicar el kernel sobre la entrada (o sobre un mapa previo).
Activación Feature map después de la no linealidad (ReLU, etc.), cuando esta existe.

Conv2D(..., activation="relu") combina convolución y activación en una sola capa. Conceptualmente siguen siendo dos operaciones.

Intuición conceptual

Un kernel no «entiende» objetos completos; detecta regularidades locales (transiciones, orientaciones, texturas). Varias capas y varios filtros permiten construir patrones cada vez más complejos.

Idea clave

Convolucionar es evaluar, posición por posición, si un patrón local está presente en la imagen.

# Ejemplo didactico de convolucion: calculo manual + implementacion
small_img = np.array([
    [1, 2, 0, 1],
    [3, 1, 2, 2],
    [0, 1, 3, 1],
    [2, 2, 1, 0]
], dtype=float)

kernel = np.array([
    [1, 0],
    [0, -1]
], dtype=float)

def conv2d_valid(image, k):
    h, w = image.shape
    kh, kw = k.shape
    out = np.zeros((h - kh + 1, w - kw + 1))
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            region = image[i:i + kh, j:j + kw]
            out[i, j] = np.sum(region * k)
    return out

conv_result = conv2d_valid(small_img, kernel)

# Calculo manual paso a paso para la posicion (0, 0)
region_00 = small_img[0:2, 0:2]
product_00 = region_00 * kernel
manual_00 = np.sum(product_00)

# Segunda posicion para ver el desplazamiento
region_01 = small_img[0:2, 1:3]
manual_01 = np.sum(region_01 * kernel)

print("Matriz de entrada:\n", small_img)
print("\nKernel:\n", kernel)
print("\nRegion local (0,0):\n", region_00)
print("\nProducto elemento a elemento region * kernel:\n", product_00)
print("\nSuma de productos -> salida(0,0):", manual_00)
print("\nRegion local (0,1) [el kernel se desplazo 1 columna]:\n", region_01)
print("Suma de productos -> salida(0,1):", manual_01)
print("\nSalida completa de la convolucion (valid):\n", conv_result)
print("Este mapa de salida ES el feature map del kernel (antes de una activacion).")

fig, axes = plt.subplots(1, 4, figsize=(14, 3.2))
axes[0].imshow(small_img, cmap="Blues")
axes[0].add_patch(Rectangle((-0.5, -0.5), 2, 2, fill=False, edgecolor="crimson", linewidth=2))
axes[0].set_title("Entrada 4x4\nventana (0,0)")
axes[0].set_xticks(range(4))
axes[0].set_yticks(range(4))

axes[1].imshow(small_img, cmap="Blues")
axes[1].add_patch(Rectangle((0.5, -0.5), 2, 2, fill=False, edgecolor="crimson", linewidth=2))
axes[1].set_title("Misma entrada\nventana (0,1)")
axes[1].set_xticks(range(4))
axes[1].set_yticks(range(4))

axes[2].imshow(kernel, cmap="coolwarm")
axes[2].set_title("Kernel 2x2\n(pesos)")
axes[2].axis("off")

axes[3].imshow(conv_result, cmap="viridis")
axes[3].set_title("Feature map (valid)")
axes[3].axis("off")

plt.tight_layout()
plt.show()
Matriz de entrada:
 [[1. 2. 0. 1.]
 [3. 1. 2. 2.]
 [0. 1. 3. 1.]
 [2. 2. 1. 0.]]

Kernel:
 [[ 1.  0.]
 [ 0. -1.]]

Region local (0,0):
 [[1. 2.]
 [3. 1.]]

Producto elemento a elemento region * kernel:
 [[ 1.  0.]
 [ 0. -1.]]

Suma de productos -> salida(0,0): 0.0

Region local (0,1) [el kernel se desplazo 1 columna]:
 [[2. 0.]
 [1. 2.]]
Suma de productos -> salida(0,1): 0.0

Salida completa de la convolucion (valid):
 [[ 0.  0. -2.]
 [ 2. -2.  1.]
 [-2.  0.  3.]]
Este mapa de salida ES el feature map del kernel (antes de una activacion).

Interpretación

El cálculo manual y el cálculo programado produjeron el mismo principio: cada valor de salida se obtiene evaluando una región local con una regla fija. El recuadro rojo muestra qué zona está procesando el kernel en cada paso.

Eso significa que la convolución transforma la imagen en un feature map: un mapa de evidencia, no una copia modificada de los píxeles. El kernel es la regla; el feature map es el resultado.

Todavía no hay aprendizaje: este kernel fue escrito a mano. En una CNN, esos números se convierten en pesos ajustables y se actualizan por backpropagation.

¿Qué está pasando realmente?

Cada elemento de la salida responde a una pregunta local: «¿este patrón está aquí?».

Cuando la respuesta es alta, el patrón del kernel coincide con la región observada; cuando es baja, la coincidencia es débil. Así comienza la detección automática de rasgos visuales.

Idea clave

Primero se entiende la operación (ventana → producto → suma → desplazamiento). Después se usará Conv2D.

Ejemplos reales de convolución (TensorFlow)

Para cerrar el puente entre el ejemplo matricial y el uso en visión, conviene observar la misma operación sobre imágenes reales importadas al inicio del notebook.

Se usa tf.nn.conv2d con kernels fijos de Sobel. Esto replica el tipo de operación que una CNN ejecuta internamente cuando aprende filtros.

Intuición conceptual

Sobel estima gradientes de intensidad: responde fuerte donde hay cambios bruscos (bordes) y responde débil donde la imagen es casi uniforme.

# Convolucion real (TensorFlow) sobre MNIST y sobre la imagen RGB importada

def conv2d_single_channel(image2d, kernel2d, padding="SAME"):
    """Convolucion 2D con tf.nn.conv2d para un solo canal (H, W)."""
    img = tf.constant(image2d, dtype=tf.float32)
    k = tf.constant(kernel2d, dtype=tf.float32)

    img4d = img[tf.newaxis, :, :, tf.newaxis]  # (1, H, W, 1)
    k4d = k[:, :, tf.newaxis, tf.newaxis]      # (kh, kw, 1, 1)

    out = tf.nn.conv2d(
        img4d,
        k4d,
        strides=[1, 1, 1, 1],
        padding=padding,
    )
    return out[0, :, :, 0].numpy()


# Kernels Sobel (deteccion de bordes orientados)
sobel_x = np.array([[-1, 0, 1],
                    [-2, 0, 2],
                    [-1, 0, 1]], dtype=np.float32)

sobel_y = np.array([[-1, -2, -1],
                    [ 0,  0,  0],
                    [ 1,  2,  1]], dtype=np.float32)

# Ejemplo 1: digito MNIST real (importado al inicio del notebook)
digit = x_train_mnist[0].astype("float32") / 255.0

gx = conv2d_single_channel(digit, sobel_x)
gy = conv2d_single_channel(digit, sobel_y)
mag_digit = np.sqrt(gx ** 2 + gy ** 2)

# Ejemplo 2: imagen RGB real (china.jpg) convertida a float32
img = img_rgb.astype("float32") / 255.0
gray = np.mean(img, axis=2)

gx_rgb = conv2d_single_channel(gray, sobel_x)
gy_rgb = conv2d_single_channel(gray, sobel_y)
mag_rgb = np.sqrt(gx_rgb ** 2 + gy_rgb ** 2)

fig, axes = plt.subplots(2, 3, figsize=(14, 8))

axes[0, 0].imshow(digit, cmap="gray")
axes[0, 0].set_title("MNIST: entrada real")
axes[0, 0].axis("off")

axes[0, 1].imshow(gx, cmap="magma")
axes[0, 1].set_title("MNIST: conv Sobel X")
axes[0, 1].axis("off")

axes[0, 2].imshow(mag_digit, cmap="magma")
axes[0, 2].set_title("MNIST: magnitud bordes")
axes[0, 2].axis("off")

axes[1, 0].imshow(gray, cmap="gray")
axes[1, 0].set_title("RGB->grises: entrada real")
axes[1, 0].axis("off")

axes[1, 1].imshow(gx_rgb, cmap="magma")
axes[1, 1].set_title("RGB: conv Sobel X")
axes[1, 1].axis("off")

axes[1, 2].imshow(mag_rgb, cmap="magma")
axes[1, 2].set_title("RGB: magnitud bordes")
axes[1, 2].axis("off")

plt.tight_layout()
plt.show()

print("Shapes -> digit:", digit.shape, "| mag_digit:", mag_digit.shape)
print("Shapes -> gray:", gray.shape, "| mag_rgb:", mag_rgb.shape)

Shapes -> digit: (28, 28) | mag_digit: (28, 28)
Shapes -> gray: (427, 640) | mag_rgb: (427, 640)

Interpretación

Los mapas generados por Sobel no son meras visualizaciones: son respuestas de convolución que miden cambios de intensidad. En el dígito MNIST, los bordes del trazo aparecen como curvas de alta magnitud. En la fotografía RGB, la magnitud resalta contornos de objetos y transiciones de iluminación.

Esto significa que la convolución transforma la entrada en una representación donde lo relevante ya no es el nivel de gris absoluto, sino la presencia de estructuras locales (gradientes).

El aprendizaje esperado es reconocer que una CNN aprende filtros para producir mapas similares en espíritu, pero adaptados a la tarea mediante entrenamiento.

La conexión con el curso es directa: lo que aquí se fija manualmente (Sobel), en una CNN se aprende por backpropagation.

La conexión con la siguiente sección es que los kernels clásicos (borde, blur, sharpen) son el mismo mecanismo convolucional, solo que con distintos patrones numéricos.

Idea clave

Una convolución real convierte intensidades en evidencia de estructura local (bordes, texturas, transiciones).

Reto técnico

  1. Repetir el experimento con padding="VALID" y explicar qué cambia en el tamaño del mapa y en la información de bordes.
  2. Modificar un coeficiente del kernel Sobel y explicar cómo cambia la sensibilidad del detector (qué tipo de bordes se enfatiza más o menos).

Kernel manual frente a kernel aprendido

  • Kernel manual (Sobel, blur, sharpen): un humano elige los coeficientes para un efecto conocido (bordes, suavizado, realce).
  • Kernel aprendido: los coeficientes son parámetros. Empiezan aleatorios y se ajustan para reducir la pérdida de la tarea.

La operación es la misma. Cambia quién elige los números y con qué criterio.

Ejemplo adicional: efecto del kernel de forma más evidente

Para aislar el efecto del kernel, se construirá una imagen sintética con patrones controlados: una línea vertical, una línea horizontal y una diagonal. Luego se aplicarán detectores orientados para observar qué patrón responde más en cada caso.

Intuición conceptual

Si un kernel está diseñado para detectar bordes verticales, su respuesta debe ser alta en estructuras verticales y más baja en otras orientaciones.

Idea clave

El kernel actúa como un detector selectivo: no responde igual a todos los patrones.

# Ejemplo adicional: imagen sintetica para evidenciar selectividad del kernel
synthetic = np.zeros((64, 64), dtype=np.float32)

# Patrones controlados
synthetic[:, 18:22] = 1.0             # linea vertical
synthetic[40:44, :] = 1.0             # linea horizontal
for i in range(10, 54):               # diagonal
    synthetic[i, i] = 1.0

# Kernels orientados (Sobel)
k_vertical = np.array([[-1, 0, 1],
                       [-2, 0, 2],
                       [-1, 0, 1]], dtype=np.float32)

k_horizontal = np.array([[-1, -2, -1],
                         [ 0,  0,  0],
                         [ 1,  2,  1]], dtype=np.float32)

# Reutiliza helper de la seccion, pero mantiene fallback por robustez
if "conv2d_single_channel" in globals():
    out_v = conv2d_single_channel(synthetic, k_vertical, padding="SAME")
    out_h = conv2d_single_channel(synthetic, k_horizontal, padding="SAME")
else:
    img4d = tf.constant(synthetic)[tf.newaxis, :, :, tf.newaxis]
    kv4d = tf.constant(k_vertical)[:, :, tf.newaxis, tf.newaxis]
    kh4d = tf.constant(k_horizontal)[:, :, tf.newaxis, tf.newaxis]
    out_v = tf.nn.conv2d(img4d, kv4d, strides=[1, 1, 1, 1], padding="SAME")[0, :, :, 0].numpy()
    out_h = tf.nn.conv2d(img4d, kh4d, strides=[1, 1, 1, 1], padding="SAME")[0, :, :, 0].numpy()

fig, axes = plt.subplots(1, 3, figsize=(13, 4))
axes[0].imshow(synthetic, cmap="gray")
axes[0].set_title("Imagen sintetica")
axes[0].axis("off")

axes[1].imshow(np.abs(out_v), cmap="magma")
axes[1].set_title("Respuesta kernel vertical")
axes[1].axis("off")

axes[2].imshow(np.abs(out_h), cmap="magma")
axes[2].set_title("Respuesta kernel horizontal")
axes[2].axis("off")

plt.tight_layout()
plt.show()

Interpretación

La salida confirma la selectividad del kernel: el detector vertical activa con mayor intensidad alrededor de la línea vertical, mientras el detector horizontal responde con más fuerza cerca de la línea horizontal.

Esto significa que el valor de activación no depende solo de la intensidad del píxel, sino de la coincidencia estructural entre patrón local y kernel.

El aprendizaje esperado es comprender por qué una CNN puede combinar muchos filtros: cada uno aporta evidencia distinta sobre la misma imagen.

La conexión con la siguiente sección es directa: los filtros clásicos y aprendidos se diferencian en sus coeficientes, pero comparten exactamente este comportamiento selectivo.

Idea clave

Un kernel no “ve” todo por igual; responde preferentemente al patrón para el cual fue diseñado.

Reto técnico

Cambiar la diagonal por una línea diagonal más gruesa y analizar cuál de los dos kernels (vertical/horizontal) responde más y por qué.

5. Filtros y kernels

En términos operativos, un kernel es una pequeña matriz de pesos. Cuando ese kernel se desplaza por toda la imagen produciendo un mapa de respuesta, se habla de un filtro aplicado sobre la entrada. El mapa de respuesta es el feature map.

La elección de valores del kernel define qué patrón se destaca: - kernels de borde responden a cambios bruscos de intensidad, - kernels de blur promedian vecinos para suavizar, - kernels de sharpen incrementan contraste local para realzar detalle.

Kernel manual frente a kernel aprendido

En visión clásica estos kernels se diseñaban a mano. En Deep Learning, una CNN inicia con pesos aleatorios y los ajusta por backpropagation para aprender filtros útiles para la tarea.

No son dos operaciones distintas: son la misma convolución con distinto origen de los pesos.

Intuición conceptual

Un filtro puede verse como un detector especializado. Si la imagen contiene el patrón que ese detector espera, la respuesta aumenta; si no, permanece baja. Tras ReLU, esa respuesta se convierte en activación.

Idea clave

La potencia de una CNN no depende de un único filtro, sino de aprender muchos detectores locales complementarios.

# Aplicacion de kernels clasicos a una imagen en escala de grises
img_gray_large = np.mean(img_rgb, axis=2)  # conversion simple a grises

kernels = {
    "Borde": np.array([[-1, -1, -1],
                       [-1,  8, -1],
                       [-1, -1, -1]], dtype=float),
    "Blur": (1/9) * np.ones((3, 3), dtype=float),
    "Sharpen": np.array([[0, -1,  0],
                          [-1,  5, -1],
                          [0, -1,  0]], dtype=float)
}

def conv2d_same(image, k):
    kh, kw = k.shape
    ph, pw = kh // 2, kw // 2
    padded = np.pad(image, ((ph, ph), (pw, pw)), mode="constant")
    out = np.zeros_like(image, dtype=float)
    for i in range(image.shape[0]):
        for j in range(image.shape[1]):
            region = padded[i:i+kh, j:j+kw]
            out[i, j] = np.sum(region * k)
    return out

results = {name: conv2d_same(img_gray_large, k) for name, k in kernels.items()}

fig, axes = plt.subplots(1, 4, figsize=(18, 4))
axes[0].imshow(img_gray_large, cmap="gray")
axes[0].set_title("Original en grises")
axes[0].axis("off")

for ax, (name, res) in zip(axes[1:], results.items()):
    ax.imshow(res, cmap="gray")
    ax.set_title(name)
    ax.axis("off")

plt.tight_layout()
plt.show()

Interpretación

Aplicar varios kernels sobre la misma entrada demuestra que cada filtro actúa como un detector especializado: unos resaltan cambios bruscos, otros suavizan y otros realzan detalles.

Ese comportamiento explica por qué los kernels funcionan: convierten comparaciones locales en respuestas numéricas que cuantifican presencia de patrones.

El aprendizaje esperado es reconocer que la CNN construye su comprensión visual sobre estas transformaciones, no sobre la imagen original sin procesar.

La conexión con aprendizaje automático es directa: en una CNN real, estos filtros no se programan a mano, se aprenden para maximizar desempeño en la tarea.

Idea clave

Un filtro útil es aquel que transforma píxeles en evidencia discriminativa para clasificar.

Reto técnico

Añadir un cuarto kernel diseñado manualmente (por ejemplo detector horizontal) y explicar qué tipo de patrón enfatiza respecto a los tres ya mostrados.

6. Stride y padding

Al aplicar convolución, dos decisiones de diseño afectan directamente la geometría de salida:

  • Stride (S): número de píxeles que avanza el kernel en cada desplazamiento.
  • Padding (P): borde artificial agregado alrededor de la imagen (usualmente ceros).

Si el stride aumenta, la salida se reduce porque se evalúan menos posiciones. Si se usa padding, se preserva mejor información en bordes y se controla el tamaño del mapa resultante.

Fórmula de dimensiones

Para alto (el ancho es análogo):

[ H_{out} = + 1 ]

donde H es el alto de entrada, K el tamaño del kernel, P el padding por lado y S el stride.

En Keras: - padding="valid" equivale a P = 0 (sin borde extra). - padding="same" elige P para que, con stride=1, la salida conserve alto y ancho de la entrada.

Estas decisiones no son meramente técnicas: alteran cuánta información espacial llega a las capas siguientes, el costo computacional y la resolución de los feature maps.

Intuición conceptual

Stride alto equivale a muestrear la imagen con menor densidad. Padding, en cambio, evita que las zonas cercanas al borde queden sistemáticamente subrepresentadas.

Idea clave

Stride y padding definen el equilibrio entre eficiencia computacional y conservación de detalle espacial.

# Comparacion de stride y padding usando Keras
sample = x_train_mnist[1].astype("float32") / 255.0
sample_4d = sample[np.newaxis, ..., np.newaxis]

kernel_init = tf.constant_initializer(np.array([[1, 0, -1],
                                                [1, 0, -1],
                                                [1, 0, -1]], dtype=np.float32).reshape(3, 3, 1, 1))

def apply_conv(img4d, strides, padding):
    layer = layers.Conv2D(
        filters=1,
        kernel_size=(3, 3),
        strides=strides,
        padding=padding,
        use_bias=False,
        kernel_initializer=kernel_init,
        trainable=False,
    )
    out = layer(img4d)
    return out.numpy()[0, :, :, 0]


def dim_salida(H, K, S, P):
    return int(np.floor((H + 2 * P - K) / S) + 1)


H, K = 28, 3
print("Formula: H_out = floor((H + 2P - K) / S) + 1")
print(f"valid, stride=1  (P=0, S=1): predicho {dim_salida(H, K, 1, 0)}")
print(f"same,  stride=1  (P=1, S=1): predicho {dim_salida(H, K, 1, 1)}")
print(f"same,  stride=2  (P=1, S=2): predicho {dim_salida(H, K, 2, 1)}")

out_valid_s1 = apply_conv(sample_4d, strides=1, padding="valid")
out_same_s1 = apply_conv(sample_4d, strides=1, padding="same")
out_same_s2 = apply_conv(sample_4d, strides=2, padding="same")

print("\nShapes reales:")
print("valid, stride=1:", out_valid_s1.shape)
print("same,  stride=1:", out_same_s1.shape)
print("same,  stride=2:", out_same_s2.shape)

fig, axes = plt.subplots(1, 4, figsize=(16, 4))
axes[0].imshow(sample, cmap="gray")
axes[0].set_title("Entrada 28x28")
axes[0].axis("off")

axes[1].imshow(out_valid_s1, cmap="gray")
axes[1].set_title(f"valid, stride=1\n{out_valid_s1.shape}")
axes[1].axis("off")

axes[2].imshow(out_same_s1, cmap="gray")
axes[2].set_title(f"same, stride=1\n{out_same_s1.shape}")
axes[2].axis("off")

axes[3].imshow(out_same_s2, cmap="gray")
axes[3].set_title(f"same, stride=2\n{out_same_s2.shape}")
axes[3].axis("off")

plt.tight_layout()
plt.show()
Formula: H_out = floor((H + 2P - K) / S) + 1
valid, stride=1  (P=0, S=1): predicho 26
same,  stride=1  (P=1, S=1): predicho 28
same,  stride=2  (P=1, S=2): predicho 14

Shapes reales:
valid, stride=1: (26, 26)
same,  stride=1: (28, 28)
same,  stride=2: (14, 14)

Interpretación

Los tamaños impresos deben coincidir con la fórmula. Si no coinciden, hay que revisar P (en same con kernel 3 × 3, Keras usa P = 1).

Cambiar padding y stride altera la cantidad y la resolución de la información que atraviesa la red:

  • valid reduce el mapa (aquí, 28 → 26): se pierde un marco de borde.
  • same con stride 1 conserva 28 × 28: útil cuando se quiere mantener resolución.
  • stride 2 reduce a la mitad (28 → 14): menos costo, menos detalle fino.

No es un ajuste menor: modifica la resolución de las representaciones internas y el costo de las capas siguientes.

Idea clave

Stride y padding determinan cuánto detalle espacial conserva la red antes de clasificar.

Reto técnico

Probar kernel_size=5, stride=1 y comparar con kernel_size=3, stride=2. Predice H_out con la fórmula antes de ejecutar.

7. Mapas de activación y jerarquía espacial

Un feature map es la salida de un filtro aplicado sobre la entrada (o sobre una activación previa). Cada mapa indica dónde y con qué intensidad aparece el patrón al que responde ese kernel.

Una capa Conv2D(filters=32, ...) no aplica «un» filtro 32 veces: aprende 32 kernels distintos. Cada kernel produce un feature map. La salida es un tensor

alto × ancho × canales

donde canales = número de filtros.

En una CNN, las primeras capas suelen capturar regularidades locales relativamente simples. Al apilar capas, esas respuestas se combinan y dan lugar a representaciones más abstractas. Esta es la jerarquía espacial de características: de lo local y elemental hacia lo estructural.

Esa descripción es conceptual y general. No implica que cada filtro tenga una interpretación humana simple («este canal detecta mangas»). Un canal puede responder con intensidad en ciertas regiones sin que podamos nombrarlo con precisión.

Intuición conceptual

Una capa temprana puede responder a transiciones locales; una capa posterior combina esas respuestas. La red compone patrones gradualmente.

Idea clave

Los feature maps son evidencia observable de cómo la red transforma píxeles en características. El kernel es el detector; el mapa es la respuesta.

# Visualizacion de feature maps con una capa convolucional simple
sample_img = x_train_mnist[2].astype("float32") / 255.0
sample_img_4d = sample_img[np.newaxis, ..., np.newaxis]

n_filtros = 4
conv_demo = models.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Conv2D(n_filtros, (3, 3), activation="relu", padding="same")
])

feature_maps = conv_demo.predict(sample_img_4d, verbose=0)[0]

print("Entrada:", sample_img_4d.shape, "  ->  (batch, alto, ancho, canales)")
print("Salida Conv2D(filters=%d):" % n_filtros, feature_maps.shape)
print("Hay %d kernels y, por tanto, %d feature maps." % (n_filtros, feature_maps.shape[-1]))
print("Parametros de esta capa:", conv_demo.count_params(),
      "  (calculo: (3*3*1 + 1) * %d = %d)" % (n_filtros, (3 * 3 * 1 + 1) * n_filtros))
print("Estos kernels son aleatorios: aun no hay aprendizaje. Solo ilustran la geometria.")

fig, axes = plt.subplots(1, 5, figsize=(16, 3))
axes[0].imshow(sample_img, cmap="gray")
axes[0].set_title("Entrada")
axes[0].axis("off")

for i in range(n_filtros):
    axes[i + 1].imshow(feature_maps[:, :, i], cmap="viridis")
    axes[i + 1].set_title(f"Mapa {i + 1}")
    axes[i + 1].axis("off")

plt.tight_layout()
plt.show()
WARNING:tensorflow:TensorFlow GPU support is not available on native Windows for TensorFlow >= 2.11. Even if CUDA/cuDNN are installed, GPU will not be used. Please use WSL2 or the TensorFlow-DirectML plugin.
Entrada: (1, 28, 28, 1)   ->  (batch, alto, ancho, canales)
Salida Conv2D(filters=4): (28, 28, 4)
Hay 4 kernels y, por tanto, 4 feature maps.
Parametros de esta capa: 40   (calculo: (3*3*1 + 1) * 4 = 40)
Estos kernels son aleatorios: aun no hay aprendizaje. Solo ilustran la geometria.

Interpretación

Los feature maps muestran que una sola capa puede generar múltiples representaciones de la misma imagen. Cada canal es la respuesta de un kernel distinto.

Como los pesos aún son aleatorios, no debemos afirmar «este filtro detecta bordes» o «este filtro detecta un dígito». Lo observable es más modesto: unos canales responden con mayor intensidad en ciertas regiones; otros pueden quedar casi inactivos tras ReLU.

El aprendizaje esperado es comprender que la potencia de una CNN surge de combinar detectores en paralelo (filters) y apilarlos en profundidad (jerarquía).

Idea clave

Un feature map es evidencia espacial de la respuesta de un kernel. Interpretar qué detecta exige prudencia, sobre todo antes de entrenar.

Reto técnico

Seleccionar dos mapas con patrones visualmente distintos. Describe dónde se activan, no qué objeto crees que detectan.

8. Pooling

Pooling es una operación de resumen espacial aplicada sobre feature maps. No aprende nuevos patrones: comprime los ya detectados.

  • Max pooling conserva la activación más fuerte de cada ventana.
  • Average pooling conserva el promedio de la ventana.

¿Para qué sirve? - Reducir dimensionalidad y costo computacional. - Conservar respuestas relevantes (sobre todo con max). - Introducir cierta tolerancia a pequeñas variaciones de posición. - Controlar la cantidad de información que avanza a capas profundas.

Qué se conserva y qué se pierde: - Se conserva información de presencia de rasgos fuertes (por ejemplo, si hubo una activación alta en una región). - Se pierde resolución espacial: la localización dentro de cada ventana se vuelve más gruesa.

Precisión conceptual. Pooling no hace al modelo completamente invariante a traslaciones. Aporta una tolerancia local limitada. Un desplazamiento grande sigue pudiendo cambiar la representación y, con ella, la predicción.

Intuición conceptual

Si un patrón importante aparece ligeramente desplazado, max pooling puede mantener su señal dominante. Eso no equivale a «la red ya no depende de la posición».

Idea clave

Pooling resume, reduce costo y suaviza variaciones pequeñas. No extrae nuevos patrones y no otorga invariancia total.

# Ejemplo manual y visual de max pooling vs average pooling
pool_input = np.array([
    [1, 3, 2, 0],
    [4, 6, 5, 1],
    [0, 2, 8, 7],
    [1, 3, 4, 2]
], dtype=float)

pool_input_4d = pool_input[np.newaxis, ..., np.newaxis]

max_pool = layers.MaxPooling2D(pool_size=(2, 2), strides=2)
avg_pool = layers.AveragePooling2D(pool_size=(2, 2), strides=2)

max_out = max_pool(pool_input_4d).numpy()[0, :, :, 0]
avg_out = avg_pool(pool_input_4d).numpy()[0, :, :, 0]

print("Entrada:\n", pool_input)
print("Max pooling 2x2:\n", max_out)
print("Average pooling 2x2:\n", avg_out)

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(pool_input, cmap="magma")
axes[0].set_title("Entrada")
axes[0].axis("off")

axes[1].imshow(max_out, cmap="magma")
axes[1].set_title("Max pooling")
axes[1].axis("off")

axes[2].imshow(avg_out, cmap="magma")
axes[2].set_title("Average pooling")
axes[2].axis("off")

plt.tight_layout()
plt.show()
Entrada:
 [[1. 3. 2. 0.]
 [4. 6. 5. 1.]
 [0. 2. 8. 7.]
 [1. 3. 4. 2.]]
Max pooling 2x2:
 [[6. 5.]
 [3. 8.]]
Average pooling 2x2:
 [[3.5  2.  ]
 [1.5  5.25]]

Interpretación

El experimento muestra dos estrategias de resumen distintas sobre el mismo mapa de activación: max conserva la evidencia dominante y average conserva tendencia media.

Esto impacta directamente en la representación que recibe la red: una más selectiva y otra más suavizada.

El aprendizaje esperado es comprender que el tipo de pooling afecta qué señales sobreviven al flujo hacia capas profundas.

La conexión con arquitectura es crítica: una compresión excesiva en etapas tempranas puede eliminar detalles relevantes para la clasificación.

¿Qué estamos perdiendo?

  • Precisión espacial fina dentro de cada ventana.
  • Información exacta de distribución local de activaciones.

¿Qué estamos ganando?

  • Reducción de dimensionalidad y costo computacional.
  • Mayor robustez ante desplazamientos pequeños o ruido local.
  • Menor sensibilidad a variaciones irrelevantes de posición.

Idea clave

Pooling define un compromiso entre detalle espacial y robustez representacional.

Reto técnico

Construir un caso de entrada donde max y average den decisiones cualitativamente distintas sobre qué región parece más importante y justificar por qué ocurre.

Nota. «Mayor robustez ante desplazamientos pequeños» no significa invariancia completa. El pooling opera sobre ventanas locales; un cambio grande de posición sigue siendo un cambio de representación.

9. Arquitectura básica conv-pool-flatten-dense

Una CNN de clasificación suele organizarse en dos bloques funcionales:

  1. Extracción de características (Conv + activación + Pool): transforma la imagen en mapas que capturan patrones relevantes.
  2. Decisión de clase (Flatten + Dense): convierte esa representación en probabilidades por clase.

Flujo conceptual: - La convolución detecta patrones locales (kernels aprendibles). - La activación introduce no linealidad. - El pooling compacta la representación y reduce resolución. - Flatten linealiza los mapas después de la extracción espacial. - La salida softmax expresa la probabilidad estimada por clase.

Arquitectura que se construye a continuación:

Input (28, 28, 1)
  → Conv2D(8, 3×3, ReLU, same)
  → MaxPooling(2×2)
  → Flatten
  → Dense(32, ReLU)
  → Dense(10, softmax)

Antes de ejecutar model.summary()

Predice, sin correr la siguiente celda:

  1. ¿Cómo cambiará el shape tras Conv2D con padding="same"?
  2. ¿Dónde disminuirá la resolución espacial?
  3. ¿Dónde aumentará el número de canales?
  4. ¿Qué capas tienen parámetros (Param # > 0)?
  5. ¿Qué capas no tienen parámetros?
  6. ¿Cuántos parámetros tiene la Conv2D? Fórmula: (k_h × k_w × canales_entrada + bias) × filtros.

Idea clave

En una CNN, conv/pool extraen evidencia; flatten/dense integran esa evidencia para clasificar.

# Arquitectura minima conv-pool-flatten-dense en Keras
cnn_minima = models.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Conv2D(8, (3, 3), activation="relu", padding="same"),
    layers.MaxPooling2D((2, 2)),
    layers.Flatten(),
    layers.Dense(32, activation="relu"),
    layers.Dense(10, activation="softmax")
])

cnn_minima.summary()
Model: "sequential_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d_4 (Conv2D)               │ (None, 28, 28, 8)      │            80 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_1 (MaxPooling2D)  │ (None, 14, 14, 8)      │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten (Flatten)               │ (None, 1568)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 32)             │        50,208 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 10)             │           330 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 50,618 (197.73 KB)
 Trainable params: 50,618 (197.73 KB)
 Non-trainable params: 0 (0.00 B)
# Calculo manual de parametros y tabla de capas (cnn_minima)
def params_conv2d(kh, kw, cin, n_filt, bias=True):
    return (kh * kw * cin + (1 if bias else 0)) * n_filt


def params_dense(n_in, n_out, bias=True):
    return n_in * n_out + (n_out if bias else 0)


# Arquitectura de cnn_minima:
# Conv2D(8, 3x3, same) sobre 28x28x1 -> 28x28x8
# MaxPool 2x2 -> 14x14x8
# Flatten -> 1568
# Dense(32) ; Dense(10)
p_conv = params_conv2d(3, 3, 1, 8)
p_pool = 0
p_flat = 0
p_d32 = params_dense(14 * 14 * 8, 32)
p_d10 = params_dense(32, 10)
p_total_manual = p_conv + p_pool + p_flat + p_d32 + p_d10

print("Calculo manual")
print(f"  Conv2D  (3*3*1 + 1)*8 = {p_conv}")
print(f"  MaxPool               = {p_pool}")
print(f"  Flatten               = {p_flat}")
print(f"  Dense32 1568*32 + 32  = {p_d32}")
print(f"  Dense10    32*10 + 10 = {p_d10}")
print(f"  Total manual          = {p_total_manual}")
print(f"  model.count_params()  = {int(cnn_minima.count_params())}")

filas = []
for layer in cnn_minima.layers:
    filas.append({
        "Capa": layer.__class__.__name__,
        "Nombre": layer.name,
        "Shape_salida": str(getattr(layer, "output_shape", None) or tuple(getattr(layer.output, "shape", ()))) ,
        "Filtros_o_unidades": getattr(layer, "filters", None) or getattr(layer, "units", None),
        "Parametros": int(layer.count_params()),
    })
df_capas_minima = pd.DataFrame(filas)
print("\nReporte de capas (cnn_minima)")
print(df_capas_minima.to_string(index=False))

conv_layer = next(l for l in cnn_minima.layers if isinstance(l, layers.Conv2D))
print("\nContraste Conv2D: manual", p_conv, "vs capa", int(conv_layer.count_params()))
dense32 = [l for l in cnn_minima.layers if isinstance(l, layers.Dense)][0]
print("Contraste Dense(32): manual", p_d32, "vs capa", int(dense32.count_params()))
Calculo manual
  Conv2D  (3*3*1 + 1)*8 = 80
  MaxPool               = 0
  Flatten               = 0
  Dense32 1568*32 + 32  = 50208
  Dense10    32*10 + 10 = 330
  Total manual          = 50618
  model.count_params()  = 50618

Reporte de capas (cnn_minima)
        Capa          Nombre      Shape_salida  Filtros_o_unidades  Parametros
      Conv2D        conv2d_4 (None, 28, 28, 8)                 8.0          80
MaxPooling2D max_pooling2d_1 (None, 14, 14, 8)                 NaN           0
     Flatten         flatten      (None, 1568)                 NaN           0
       Dense           dense        (None, 32)                32.0       50208
       Dense         dense_1        (None, 10)                10.0         330

Contraste Conv2D: manual 80 vs capa 80
Contraste Dense(32): manual 50208 vs capa 50208

Interpretación

El summary() permite seguir el flujo de información: la imagen entra como tensor espacial y sale como vector de probabilidades.

Comprueba tus predicciones con la tabla y el cálculo manual de la celda anterior:

  • Conv2D + same conserva 28 × 28 y pasa de 1 a 8 canales.
  • MaxPooling2D reduce a 14 × 14; los canales se mantienen; 0 parámetros.
  • Flatten linealiza 14 × 14 × 8 = 1568; 0 parámetros.
  • Dense concentra la mayoría de los parámetros: aquí suele estar el bloque más costoso en una CNN pequeña.

Si el cálculo manual no coincide con Param #, revisa canales de entrada, bias y número de filtros antes de dudar de Keras.

Idea clave

Conv y Pool construyen evidencia; Flatten y Dense convierten esa evidencia en decisión. Saber de dónde sale cada número de summary() es parte de saber diseñar.

Reto técnico

Si cambias a Conv2D(16, (5, 5), padding="valid"), predice shape de salida y parámetros antes de modificar el modelo.

10. CNN vs MLP (experimento central)

Esta sección formaliza la pregunta de la sesión con un experimento comparable.

MLP en imágenes

28×28 → Flatten → 784 → Dense → Dense → 10 clases
  • Conserva los valores, pero no la vecindad explícita.
  • Cada neurona densa se conecta a todas las entradas (784 × N parámetros en la primera capa).
  • No incorpora, por arquitectura, localidad ni compartición de pesos.

CNN en imágenes

28×28×1 → Conv → ReLU → Pool → Conv → ReLU → Pool → Flatten → Dense → 10 clases
  • Opera sobre vecindades locales.
  • Reutiliza el mismo kernel en todas las posiciones (weight sharing).
  • Construye una jerarquía de representaciones antes de aplanar.

Estas diferencias son sesgos inductivos: supuestos que la arquitectura impone antes de ver los datos. No garantizan, por sí solos, que la CNN gane siempre.

Diseño experimental (comparación razonable)

Se entrenan modelo_mlp y modelo_cnn con:

  • el mismo Fashion-MNIST;
  • el mismo preprocessing (normalización a [0, 1] y canal explícito);
  • los mismos conjuntos Train / Validation / Test;
  • las mismas clases;
  • el mismo optimizador, pérdida, batch y número máximo de épocas;
  • la misma semilla.

La variable que se quiere observar es, principalmente, la arquitectura.

No se afirma de antemano que la CNN tenga menos parámetros ni mejor accuracy. Eso se mide.

Idea clave

CNN no es «una MLP con más capas». Cambia cómo el modelo usa la estructura espacial.

# Experimento comparable: modelo_mlp vs modelo_cnn (Fashion-MNIST)
SEED = 42
EPOCHS_CMP = 5
BATCH_CMP = 128
VAL_SIZE = 0.15

class_names = [
    "T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
    "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"
]

if hasattr(tf.keras.utils, "set_random_seed"):
    tf.keras.utils.set_random_seed(SEED)
else:
    np.random.seed(SEED)
    tf.random.set_seed(SEED)

x_all = x_train_fashion.astype("float32") / 255.0
x_test = x_test_fashion.astype("float32") / 255.0
x_all = np.expand_dims(x_all, axis=-1)
x_test = np.expand_dims(x_test, axis=-1)
y_all = y_train_fashion.copy()
y_test = y_test_fashion.copy()

x_train, x_val, y_train, y_val = train_test_split(
    x_all, y_all, test_size=VAL_SIZE, random_state=SEED, stratify=y_all
)

print("Train:", x_train.shape, y_train.shape)
print("Val:  ", x_val.shape, y_val.shape)
print("Test: ", x_test.shape, y_test.shape)

modelo_mlp = models.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Flatten(),
    layers.Dense(128, activation="relu"),
    layers.Dense(10, activation="softmax"),
], name="modelo_mlp")

modelo_cnn = models.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Conv2D(16, (3, 3), activation="relu", padding="same"),
    layers.MaxPooling2D((2, 2)),
    layers.Conv2D(32, (3, 3), activation="relu", padding="same"),
    layers.MaxPooling2D((2, 2)),
    layers.Flatten(),
    layers.Dense(64, activation="relu"),
    layers.Dense(10, activation="softmax"),
], name="modelo_cnn")

for m in (modelo_mlp, modelo_cnn):
    m.compile(optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"])

params_mlp = int(modelo_mlp.count_params())
params_cnn = int(modelo_cnn.count_params())
print(f"\nParametros MLP: {params_mlp:,}")
print(f"Parametros CNN: {params_cnn:,}")
print("No se asume que la CNN tenga menos parametros; se compara lo construido.")

print("\n--- Resumen MLP ---")
modelo_mlp.summary()
print("\n--- Resumen CNN ---")
modelo_cnn.summary()


def entrenar(modelo, nombre):
    t0 = time.perf_counter()
    hist = modelo.fit(
        x_train, y_train,
        validation_data=(x_val, y_val),
        epochs=EPOCHS_CMP,
        batch_size=BATCH_CMP,
        verbose=1,
    )
    segundos = time.perf_counter() - t0
    print(f"Tiempo {nombre}: {segundos:.1f} s")
    return hist, segundos


history_mlp, tiempo_mlp = entrenar(modelo_mlp, "MLP")
history_cnn, tiempo_cnn = entrenar(modelo_cnn, "CNN")

# Alias para celdas posteriores (curvas, activaciones, evaluacion)
cnn_clase = modelo_cnn
history = history_cnn


def metricas_split(modelo, X, y):
    loss, acc = modelo.evaluate(X, y, verbose=0)
    return float(loss), float(acc)


def fila_modelo(nombre, modelo, hist, segundos, n_params):
    tr_loss, tr_acc = metricas_split(modelo, x_train, y_train)
    va_loss, va_acc = metricas_split(modelo, x_val, y_val)
    te_loss, te_acc = metricas_split(modelo, x_test, y_test)
    return {
        "Modelo": nombre,
        "Parametros": n_params,
        "Epocas": len(hist.history["loss"]),
        "Tiempo_s": round(segundos, 2),
        "Train_loss": tr_loss,
        "Val_loss": va_loss,
        "Test_loss": te_loss,
        "Train_acc": tr_acc,
        "Val_acc": va_acc,
        "Test_acc": te_acc,
        "Gap": tr_acc - va_acc,
    }


df_mlp_cnn = pd.DataFrame([
    fila_modelo("MLP", modelo_mlp, history_mlp, tiempo_mlp, params_mlp),
    fila_modelo("CNN", modelo_cnn, history_cnn, tiempo_cnn, params_cnn),
])

print("\nComparacion MLP vs CNN (valores medidos, no inventados):")
display_cols = ["Modelo", "Parametros", "Train_acc", "Val_acc", "Test_acc", "Gap", "Tiempo_s"]
print(df_mlp_cnn[display_cols].to_string(index=False, float_format=lambda v: f"{v:.4f}"))

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].bar(df_mlp_cnn["Modelo"], df_mlp_cnn["Parametros"], color=["tab:orange", "tab:green"])
axes[0].set_title("Numero de parametros")
axes[0].set_ylabel("Parametros")

axes[1].bar(df_mlp_cnn["Modelo"], df_mlp_cnn["Test_acc"], color=["tab:orange", "tab:green"])
axes[1].set_title("Test accuracy")
axes[1].set_ylim(0, 1)
plt.tight_layout()
plt.show()
Train: (51000, 28, 28, 1) (51000,)
Val:   (9000, 28, 28, 1) (9000,)
Test:  (10000, 28, 28, 1) (10000,)

Parametros MLP: 101,770
Parametros CNN: 105,866
No se asume que la CNN tenga menos parametros; se compara lo construido.

--- Resumen MLP ---
Model: "modelo_mlp"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ flatten_1 (Flatten)             │ (None, 784)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 128)            │       100,480 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_3 (Dense)                 │ (None, 10)             │         1,290 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 101,770 (397.54 KB)
 Trainable params: 101,770 (397.54 KB)
 Non-trainable params: 0 (0.00 B)

--- Resumen CNN ---
Model: "modelo_cnn"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d_5 (Conv2D)               │ (None, 28, 28, 16)     │           160 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_2 (MaxPooling2D)  │ (None, 14, 14, 16)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_6 (Conv2D)               │ (None, 14, 14, 32)     │         4,640 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_3 (MaxPooling2D)  │ (None, 7, 7, 32)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten_2 (Flatten)             │ (None, 1568)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_4 (Dense)                 │ (None, 64)             │       100,416 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_5 (Dense)                 │ (None, 10)             │           650 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 105,866 (413.54 KB)
 Trainable params: 105,866 (413.54 KB)
 Non-trainable params: 0 (0.00 B)
Epoch 1/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.8046 - loss: 0.5704 - val_accuracy: 0.8562 - val_loss: 0.4150

Epoch 2/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.8543 - loss: 0.4168 - val_accuracy: 0.8684 - val_loss: 0.3709

Epoch 3/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.8680 - loss: 0.3740 - val_accuracy: 0.8762 - val_loss: 0.3492

Epoch 4/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.8770 - loss: 0.3472 - val_accuracy: 0.8796 - val_loss: 0.3378

Epoch 5/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.8828 - loss: 0.3276 - val_accuracy: 0.8829 - val_loss: 0.3308

Tiempo MLP: 4.2 s

Epoch 1/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 3s 6ms/step - accuracy: 0.7935 - loss: 0.5822 - val_accuracy: 0.8659 - val_loss: 0.3814

Epoch 2/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 2s 5ms/step - accuracy: 0.8683 - loss: 0.3715 - val_accuracy: 0.8822 - val_loss: 0.3255

Epoch 3/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 2s 6ms/step - accuracy: 0.8857 - loss: 0.3241 - val_accuracy: 0.8943 - val_loss: 0.2972

Epoch 4/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 2s 5ms/step - accuracy: 0.8957 - loss: 0.2951 - val_accuracy: 0.9022 - val_loss: 0.2791

Epoch 5/5

399/399 ━━━━━━━━━━━━━━━━━━━━ 2s 5ms/step - accuracy: 0.9025 - loss: 0.2741 - val_accuracy: 0.9041 - val_loss: 0.2695

Tiempo CNN: 11.8 s



Comparacion MLP vs CNN (valores medidos, no inventados):

Modelo  Parametros  Train_acc  Val_acc  Test_acc    Gap  Tiempo_s

   MLP      101770     0.8911   0.8829    0.8653 0.0082    4.2400

   CNN      105866     0.9085   0.9041    0.8926 0.0044   11.8500

Detente aquí: lee la tabla antes de concluir

Los números de la celda anterior salen de count_params(), fit(), evaluate() y el cronómetro. No hay cifras escritas a mano.

Responde con evidencia de este experimento:

  1. ¿Cuál modelo obtiene mayor Train Accuracy?
  2. ¿Cuál obtiene mayor Validation Accuracy?
  3. ¿Cuál obtiene mayor Test Accuracy?
  4. ¿Cuál presenta mayor generalization gap (train − val)?
  5. ¿Cuál utiliza más parámetros?
  6. ¿Cuál tarda más?
  7. ¿El modelo con mayor Train Accuracy es necesariamente el mejor?
  8. ¿La CNN ofrece evidencia de mejor generalización en este experimento?
  9. ¿El incremento de costo computacional (si lo hay) está acompañado de una mejora útil?
  10. ¿Qué evidencia adicional necesitarías antes de afirmar que una arquitectura es superior?

Recuerda: una métrica no es una conclusión. Un modelo puede ser más preciso y mucho más caro; otro puede tener menos parámetros y peor test; otro puede lucir bien en train y fallar en val.

La siguiente celda construye la interpretación a partir de df_mlp_cnn. No adelantes un ganador genérico («las CNN siempre son mejores»).

# Interpretacion dinamica de df_mlp_cnn (RESULTADO -> DECISION)
assert "df_mlp_cnn" in globals(), "Ejecuta antes la celda del experimento MLP vs CNN."

fila_mlp = df_mlp_cnn.set_index("Modelo").loc["MLP"]
fila_cnn = df_mlp_cnn.set_index("Modelo").loc["CNN"]


def mejor(col, mayor=True):
    a, b = fila_mlp[col], fila_cnn[col]
    if np.isclose(a, b):
        return "empate practico"
    if mayor:
        return "MLP" if a > b else "CNN"
    return "MLP" if a < b else "CNN"


print("RESULTADO (medido)")
print(df_mlp_cnn[["Modelo", "Parametros", "Train_acc", "Val_acc", "Test_acc",
                  "Gap", "Tiempo_s", "Train_loss", "Val_loss", "Test_loss"]]
      .to_string(index=False, float_format=lambda v: f"{v:.4f}"))

print("\nOBSERVACION")
print(f"- Mayor Train Acc: {mejor('Train_acc')}")
print(f"- Mayor Val Acc:   {mejor('Val_acc')}")
print(f"- Mayor Test Acc:  {mejor('Test_acc')}")
print(f"- Mayor gap:       {mejor('Gap')}")
print(f"- Mas parametros:  {mejor('Parametros')}")
print(f"- Mas tiempo:      {mejor('Tiempo_s')}")

print("\nCOMPARACION")
d_test = fila_cnn["Test_acc"] - fila_mlp["Test_acc"]
d_gap = fila_cnn["Gap"] - fila_mlp["Gap"]
d_par = fila_cnn["Parametros"] - fila_mlp["Parametros"]
d_t = fila_cnn["Tiempo_s"] - fila_mlp["Tiempo_s"]
print(f"Diferencia Test Acc (CNN - MLP): {d_test:+.4f}")
print(f"Diferencia Gap     (CNN - MLP): {d_gap:+.4f}")
print(f"Diferencia params  (CNN - MLP): {d_par:+,.0f}")
print(f"Diferencia tiempo  (CNN - MLP): {d_t:+.2f} s")

print("\nINFERENCIA")
if fila_cnn["Test_acc"] > fila_mlp["Test_acc"] and fila_cnn["Gap"] <= fila_mlp["Gap"]:
    print("En este experimento, la CNN obtiene mejor test y un gap no peor.")
    print("Hay evidencia de que la arquitectura convolucional aprovecha mejor")
    print("la estructura espacial de estas imagenes, no solo de que 'aprende mas train'.")
elif fila_cnn["Test_acc"] > fila_mlp["Test_acc"] and fila_cnn["Gap"] > fila_mlp["Gap"]:
    print("En este experimento, la CNN mejora test pero con mayor gap.")
    print("La mejora no se lee automaticamente como mejor generalizacion.")
elif fila_mlp["Test_acc"] > fila_cnn["Test_acc"]:
    print("En este experimento, la MLP obtiene mayor test accuracy.")
    print("Eso no refuta las CNN en general; cuestiona *esta* comparacion")
    print("(capacidad, epocas, inicializacion).")
else:
    print("Las diferencias de test son nulas o minimas. La arquitectura no")
    print("quedo separada con claridad bajo este presupuesto experimental.")

if fila_cnn["Train_acc"] > fila_mlp["Train_acc"] and fila_cnn["Test_acc"] < fila_mlp["Test_acc"]:
    print("Ademas: mayor train no coincidio con mayor test. El modelo que mas")
    print("ajusta no es, por ese hecho, el mejor.")

print("\nLIMITACION")
print("Una semilla, un dataset, un numero de epocas y dos arquitecturas concretas.")
print("No se concluye que 'las CNN siempre son mejores que las MLP'.")

print("\nDECISION")
if fila_cnn["Test_acc"] >= fila_mlp["Test_acc"]:
    print("Con esta evidencia, se adopta la CNN como arquitectura base para")
    print("el resto del notebook (evaluacion por clase y activaciones).")
else:
    print("La evidencia no favorece de forma clara a la CNN. Aun asi se usa")
    print("modelo_cnn en las secciones siguientes porque el objetivo es")
    print("aprender a leer convoluciones, no coronar un ganador.")
RESULTADO (medido)
Modelo  Parametros  Train_acc  Val_acc  Test_acc    Gap  Tiempo_s  Train_loss  Val_loss  Test_loss
   MLP      101770     0.8911   0.8829    0.8653 0.0082    4.2400      0.3060    0.3308     0.3709
   CNN      105866     0.9085   0.9041    0.8926 0.0044   11.8500      0.2530    0.2695     0.2969

OBSERVACION
- Mayor Train Acc: CNN
- Mayor Val Acc:   CNN
- Mayor Test Acc:  CNN
- Mayor gap:       MLP
- Mas parametros:  CNN
- Mas tiempo:      CNN

COMPARACION
Diferencia Test Acc (CNN - MLP): +0.0273
Diferencia Gap     (CNN - MLP): -0.0039
Diferencia params  (CNN - MLP): +4,096
Diferencia tiempo  (CNN - MLP): +7.61 s

INFERENCIA
En este experimento, la CNN obtiene mejor test y un gap no peor.
Hay evidencia de que la arquitectura convolucional aprovecha mejor
la estructura espacial de estas imagenes, no solo de que 'aprende mas train'.

LIMITACION
Una semilla, un dataset, un numero de epocas y dos arquitecturas concretas.
No se concluye que 'las CNN siempre son mejores que las MLP'.

DECISION
Con esta evidencia, se adopta la CNN como arquitectura base para
el resto del notebook (evaluacion por clase y activaciones).

11. Lectura de la CNN ya construida

La CNN del experimento (modelo_cnn) ya está entrenada con el mismo pipeline que la MLP. No se vuelve a entrenar: se lee.

Arquitectura utilizada:

Input (28, 28, 1)
  → Conv2D(16, 3×3, ReLU, same)
  → MaxPooling(2×2)
  → Conv2D(32, 3×3, ReLU, same)
  → MaxPooling(2×2)
  → Flatten
  → Dense(64, ReLU)
  → Dense(10, softmax)

Qué observar en el reporte de capas

  • Dónde baja la resolución (pooling).
  • Dónde suben los canales (segunda Conv2D).
  • Dónde aparece Flatten (después de la extracción espacial, no al inicio).
  • Qué fracción de parámetros vive en la cabeza densa.

Idea clave

Una CNN didáctica bien construida conecta preparación, arquitectura y evaluación en un único flujo reproducible. El summary() se interpreta; no se deja como adorno.

# Verificacion de datos y lectura de modelo_cnn (sin reentrenar)
print("Conjuntos (los mismos del experimento MLP vs CNN):")
print("Train:", x_train.shape, y_train.shape)
print("Val:  ", x_val.shape, y_val.shape)
print("Test: ", x_test.shape, y_test.shape)
print("Rango de pixeles train: [%.3f, %.3f]" % (x_train.min(), x_train.max()))

print("\n--- model.summary() de la CNN del experimento ---")
modelo_cnn.summary()

filas_capas = []
for layer in modelo_cnn.layers:
    n_filt = getattr(layer, "filters", None)
    n_units = getattr(layer, "units", None)
    filas_capas.append({
        "Capa": layer.__class__.__name__,
        "Nombre": layer.name,
        "Shape_salida": str(getattr(layer, "output_shape", None) or tuple(getattr(layer.output, "shape", ()))) ,
        "Filtros_o_unidades": n_filt if n_filt is not None else n_units,
        "Parametros": int(layer.count_params()),
    })

df_capas_cnn = pd.DataFrame(filas_capas)
print("\nCapa | shape de salida | filtros/unidades | parametros")
print(df_capas_cnn.to_string(index=False))

# Calculo manual de una Conv2D y de una Dense (para contrastar con Param #)
conv1 = next(l for l in modelo_cnn.layers if isinstance(l, layers.Conv2D))
dense_hid = [l for l in modelo_cnn.layers if isinstance(l, layers.Dense)][0]

p_conv1_manual = (3 * 3 * 1 + 1) * 16
print("\nConv2D 16 filtros 3x3, 1 canal, bias:")
print("  manual (3*3*1 + 1)*16 =", p_conv1_manual)
print("  summary / count_params =", int(conv1.count_params()))

# Tras dos poolings 2x2 sobre 28x28 same: 7x7x32 = 1568
p_dense_manual = 7 * 7 * 32 * 64 + 64
print("Dense 64 sobre 7x7x32:")
print("  manual 1568*64 + 64 =", p_dense_manual)
print("  summary / count_params =", int(dense_hid.count_params()))
Conjuntos (los mismos del experimento MLP vs CNN):
Train: (51000, 28, 28, 1) (51000,)
Val:   (9000, 28, 28, 1) (9000,)
Test:  (10000, 28, 28, 1) (10000,)
Rango de pixeles train: [0.000, 1.000]

--- model.summary() de la CNN del experimento ---
Model: "modelo_cnn"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d_5 (Conv2D)               │ (None, 28, 28, 16)     │           160 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_2 (MaxPooling2D)  │ (None, 14, 14, 16)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_6 (Conv2D)               │ (None, 14, 14, 32)     │         4,640 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_3 (MaxPooling2D)  │ (None, 7, 7, 32)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten_2 (Flatten)             │ (None, 1568)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_4 (Dense)                 │ (None, 64)             │       100,416 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_5 (Dense)                 │ (None, 10)             │           650 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 317,600 (1.21 MB)
 Trainable params: 105,866 (413.54 KB)
 Non-trainable params: 0 (0.00 B)
 Optimizer params: 211,734 (827.09 KB)

Capa | shape de salida | filtros/unidades | parametros
        Capa          Nombre       Shape_salida  Filtros_o_unidades  Parametros
      Conv2D        conv2d_5 (None, 28, 28, 16)                16.0         160
MaxPooling2D max_pooling2d_2 (None, 14, 14, 16)                 NaN           0
      Conv2D        conv2d_6 (None, 14, 14, 32)                32.0        4640
MaxPooling2D max_pooling2d_3   (None, 7, 7, 32)                 NaN           0
     Flatten       flatten_2       (None, 1568)                 NaN           0
       Dense         dense_4         (None, 64)                64.0      100416
       Dense         dense_5         (None, 10)                10.0         650

Conv2D 16 filtros 3x3, 1 canal, bias:
  manual (3*3*1 + 1)*16 = 160
  summary / count_params = 160
Dense 64 sobre 7x7x32:
  manual 1568*64 + 64 = 100416
  summary / count_params = 100416

Interpretación

El reporte de capas convierte la arquitectura en geometría visible:

  • Primera Conv2D: 28 × 28 × 1 → 28 × 28 × 16. Suben canales; se mantiene resolución (same).
  • Primer pooling: 28 × 28 → 14 × 14. Bajan alto y ancho; 0 parámetros.
  • Segunda Conv2D: 14 × 14 × 16 → 14 × 14 × 32. Otra vez más canales, misma resolución espacial.
  • Segundo pooling: 14 × 14 → 7 × 7.
  • Flatten transforma la representación espacial 7 × 7 × 32 en un vector de 1568 valores. Los valores se conservan, pero la organización espacial deja de estar representada explícitamente para las capas densas posteriores. A partir de este punto, la arquitectura ya no aplica operaciones locales sobre regiones vecinas ni compartición espacial de pesos, como sí lo hacen las capas convolucionales.
  • Dense: concentra una fracción grande de los parámetros. En CNN pequeñas, la cabeza densa suele ser más cara que las convoluciones.

Si el cálculo manual coincidió con Param #, ya puedes explicar de dónde salen esos números. Si no coincidió, el error está en canales de entrada, bias o el shape que llega a Dense.

Idea clave

Saber leer summary() es saber argumentar una arquitectura, no solo ejecutarla.

Reto técnico

¿Qué pasaría con el shape y con los parámetros si eliminaras el segundo pooling? Predice antes de experimentar.

12. Curvas de entrenamiento (MLP y CNN)

El entrenamiento no se evalúa solo al final. Las curvas permiten observar convergencia, velocidad, estabilidad y gap.

Se grafican, para ambos modelos:

  • train loss y validation loss;
  • train accuracy y validation accuracy.

Se aplican (sin volver a desarrollar) las ideas de la sesión de generalización:

  • underfitting: train y val bajos, poco aprendizaje;
  • overfitting: train mejora y val se estanca o empeora (gap creciente);
  • ajuste razonable: ambas curvas mejoran y el gap se mantiene moderado.

Una buena métrica final con mala dinámica puede ocultar problemas. El gap se interpreta junto con el nivel absoluto de accuracy, no aislado.

Última época ≠ mejor época observada en validación. Que el modelo siga mejorando en TRAIN no implica que siga mejorando su capacidad de generalizar. Un gap pequeño tampoco selecciona, por sí solo, al mejor modelo.

Idea clave

Interpretar curvas es parte de comparar arquitecturas: no basta la última fila de la tabla.

# Curvas de MLP y CNN a partir de history (sin numeros inventados)
def plot_dos_historiales(h_mlp, h_cnn):
    fig, axes = plt.subplots(2, 2, figsize=(12, 8))
    pares = [
        (h_mlp, "MLP", axes[0, 0], axes[0, 1]),
        (h_cnn, "CNN", axes[1, 0], axes[1, 1]),
    ]
    for hist, titulo, ax_l, ax_a in pares:
        ep = range(1, len(hist.history["loss"]) + 1)
        ax_l.plot(ep, hist.history["loss"], marker="o", label="Train loss")
        ax_l.plot(ep, hist.history["val_loss"], marker="o", label="Val loss")
        ax_l.set_title(f"{titulo}: perdida")
        ax_l.set_xlabel("Epoch")
        ax_l.set_ylabel("Loss")
        ax_l.legend()

        ax_a.plot(ep, hist.history["accuracy"], marker="o", label="Train acc")
        ax_a.plot(ep, hist.history["val_accuracy"], marker="o", label="Val acc")
        ax_a.set_title(f"{titulo}: accuracy")
        ax_a.set_xlabel("Epoch")
        ax_a.set_ylabel("Accuracy")
        ax_a.legend()
    plt.tight_layout()
    plt.show()


plot_dos_historiales(history_mlp, history_cnn)


def resumir_curvas(hist, nombre):
    """Compara la mejor epoca segun val_loss con la ultima epoca. Indices 0-based; reporte en 1-based."""
    h = hist.history
    tr_acc = h["accuracy"]
    va_acc = h["val_accuracy"]
    tr_loss = h["loss"]
    va_loss = h["val_loss"]
    n = len(va_loss)
    i_best = int(np.argmin(va_loss))
    i_last = n - 1

    def paquete(i):
        return {
            "epoca": i + 1,
            "train_loss": float(tr_loss[i]),
            "val_loss": float(va_loss[i]),
            "train_acc": float(tr_acc[i]),
            "val_acc": float(va_acc[i]),
            "gap": float(tr_acc[i] - va_acc[i]),
        }

    mejor = paquete(i_best)
    ultima = paquete(i_last)

    print(f"\nMODELO: {nombre}")
    print(f"Epocas ejecutadas: {n}")
    print(f"Mejor epoca segun validation loss: {mejor['epoca']}")
    print("Ultima epoca:", ultima["epoca"])
    print("Recordatorio: ultima epoca != mejor epoca observada en validacion.")

    print("\nEn la mejor epoca:")
    print(f"  Train Loss: {mejor['train_loss']:.4f}")
    print(f"  Validation Loss: {mejor['val_loss']:.4f}")
    print(f"  Train Accuracy: {mejor['train_acc']:.4f}")
    print(f"  Validation Accuracy: {mejor['val_acc']:.4f}")
    print(f"  Generalization Gap: {mejor['gap']:+.4f}")

    print("\nEn la ultima epoca:")
    print(f"  Train Loss: {ultima['train_loss']:.4f}")
    print(f"  Validation Loss: {ultima['val_loss']:.4f}")
    print(f"  Train Accuracy: {ultima['train_acc']:.4f}")
    print(f"  Validation Accuracy: {ultima['val_acc']:.4f}")
    print(f"  Generalization Gap: {ultima['gap']:+.4f}")

    mejoro_train = ultima["train_loss"] < mejor["train_loss"]
    subio_val = ultima["val_loss"] > mejor["val_loss"]
    bajo_val = ultima["val_loss"] < mejor["val_loss"]
    poco_avance = (tr_acc[-1] <= tr_acc[0]) and (va_acc[-1] <= va_acc[0])

    print("\nInterpretacion")
    if poco_avance:
        print("  Los resultados sugieren que no hay evidencia suficiente para atribuir")
        print("  el problema unicamente a sobreajuste; podria existir falta de capacidad,")
        print("  optimizacion insuficiente u otras causas.")
    elif i_best < i_last and mejoro_train and subio_val:
        print("  Despues de la mejor epoca de validacion, el modelo continuo mejorando")
        print("  su ajuste sobre entrenamiento, pero esa mejora dejo de trasladarse al")
        print("  conjunto de validacion. Este comportamiento es compatible con evidencia")
        print("  de sobreajuste posterior.")
    elif (bajo_val or i_best == i_last) and (ultima["val_acc"] >= mejor["val_acc"] or not subio_val):
        print("  Entrenamiento y validacion continuan mostrando una evolucion coherente,")
        print("  por lo que no existe evidencia clara de divergencia en este intervalo.")
    else:
        print("  Los resultados sugieren un comportamiento mixto entre la mejor epoca")
        print("  y la ultima: conviene leer juntas val_loss, val_acc y el gap, no una")
        print("  sola de esas senales.")

    return {"nombre": nombre, "n": n, "mejor": mejor, "ultima": ultima, "i_best": i_best}


def interpretar_curvas_comparadas(rep_mlp, rep_cnn):
    print("\n" + "=" * 60)
    print("COMPARACION DE CURVAS: MLP vs CNN")
    print("=" * 60)
    print("Preguntas (responde con los numeros del reporte, no de memoria):")
    print("1. Cual modelo alcanza antes su mejor val_loss?")
    print("2. Cual mantiene mejor coherencia entre entrenamiento y validacion?")
    print("3. Cual presenta menor gap en su mejor epoca?")
    print("4. La ultima epoca representa realmente el mejor estado de cada modelo?")
    print("5. Alguno muestra evidencia de continuar aprendiendo el entrenamiento")
    print("   sin mejorar validacion?")
    print("Un gap pequeno no implica, por si solo, que el modelo sea el mejor.")

    print("\nRESULTADO")
    for r in (rep_mlp, rep_cnn):
        print(
            f"- {r['nombre']}: mejor epoca {r['mejor']['epoca']}/{r['n']} "
            f"(val_loss={r['mejor']['val_loss']:.4f}, val_acc={r['mejor']['val_acc']:.4f}, "
            f"gap={r['mejor']['gap']:+.4f}); "
            f"ultima epoca {r['ultima']['epoca']} "
            f"(val_loss={r['ultima']['val_loss']:.4f}, val_acc={r['ultima']['val_acc']:.4f}, "
            f"gap={r['ultima']['gap']:+.4f})"
        )

    print("\nOBSERVACION")
    if rep_mlp["mejor"]["epoca"] < rep_cnn["mejor"]["epoca"]:
        print("- El mejor val_loss de la MLP ocurre antes que el de la CNN.")
    elif rep_cnn["mejor"]["epoca"] < rep_mlp["mejor"]["epoca"]:
        print("- El mejor val_loss de la CNN ocurre antes que el de la MLP.")
    else:
        print("- Ambos modelos alcanzan su mejor val_loss en la misma epoca.")

    for r in (rep_mlp, rep_cnn):
        if r["mejor"]["epoca"] == r["ultima"]["epoca"]:
            print(f"- En {r['nombre']}, la ultima epoca coincide con la mejor segun val_loss.")
        else:
            print(
                f"- En {r['nombre']}, la ultima epoca ({r['ultima']['epoca']}) "
                f"no es la mejor observada ({r['mejor']['epoca']})."
            )

    print("\nCOMPARACION")
    if abs(rep_mlp["mejor"]["gap"] - rep_cnn["mejor"]["gap"]) < 1e-6:
        print("- El gap en la mejor epoca es practicamente el mismo.")
    elif rep_cnn["mejor"]["gap"] < rep_mlp["mejor"]["gap"]:
        print("- La CNN presenta menor gap en su mejor epoca.")
    else:
        print("- La MLP presenta menor gap en su mejor epoca.")
    print("- Ese gap debe leerse junto a val_acc, val_loss, test, parametros y tiempo.")
    if "df_mlp_cnn" in globals():
        print("- La tabla df_mlp_cnn aporta test y costo; no se elige un modelo solo por el gap.")

    print("\nINFERENCIA")
    print("Que un modelo continue mejorando sobre TRAIN no significa que continue")
    print("mejorando su capacidad de generalizacion. En este experimento, la evidencia")
    print("de las curvas se combina con df_mlp_cnn; no se declara un ganador por una metrica.")

    print("\nLIMITACION")
    print("Una semilla, un presupuesto de epocas y dos arquitecturas concretas.")
    print("La mejor epoca segun val_loss no se usa aqui para detener el entrenamiento;")
    print("solo para analizar si la ultima epoca era el mejor estado observado.")

    print("\nDECISION")
    print("Para el resto del notebook se sigue usando el modelo ya entrenado (ultima epoca),")
    print("conscientes de que ese estado puede no coincidir con el de menor val_loss.")


rep_mlp = resumir_curvas(history_mlp, "MLP")
rep_cnn = resumir_curvas(history_cnn, "CNN")
interpretar_curvas_comparadas(rep_mlp, rep_cnn)


MODELO: MLP
Epocas ejecutadas: 5
Mejor epoca segun validation loss: 5
Ultima epoca: 5
Recordatorio: ultima epoca != mejor epoca observada en validacion.

En la mejor epoca:
  Train Loss: 0.3276
  Validation Loss: 0.3308
  Train Accuracy: 0.8828
  Validation Accuracy: 0.8829
  Generalization Gap: -0.0000

En la ultima epoca:
  Train Loss: 0.3276
  Validation Loss: 0.3308
  Train Accuracy: 0.8828
  Validation Accuracy: 0.8829
  Generalization Gap: -0.0000

Interpretacion
  Entrenamiento y validacion continuan mostrando una evolucion coherente,
  por lo que no existe evidencia clara de divergencia en este intervalo.

MODELO: CNN
Epocas ejecutadas: 5
Mejor epoca segun validation loss: 5
Ultima epoca: 5
Recordatorio: ultima epoca != mejor epoca observada en validacion.

En la mejor epoca:
  Train Loss: 0.2741
  Validation Loss: 0.2695
  Train Accuracy: 0.9025
  Validation Accuracy: 0.9041
  Generalization Gap: -0.0016

En la ultima epoca:
  Train Loss: 0.2741
  Validation Loss: 0.2695
  Train Accuracy: 0.9025
  Validation Accuracy: 0.9041
  Generalization Gap: -0.0016

Interpretacion
  Entrenamiento y validacion continuan mostrando una evolucion coherente,
  por lo que no existe evidencia clara de divergencia en este intervalo.

============================================================
COMPARACION DE CURVAS: MLP vs CNN
============================================================
Preguntas (responde con los numeros del reporte, no de memoria):
1. Cual modelo alcanza antes su mejor val_loss?
2. Cual mantiene mejor coherencia entre entrenamiento y validacion?
3. Cual presenta menor gap en su mejor epoca?
4. La ultima epoca representa realmente el mejor estado de cada modelo?
5. Alguno muestra evidencia de continuar aprendiendo el entrenamiento
   sin mejorar validacion?
Un gap pequeno no implica, por si solo, que el modelo sea el mejor.

RESULTADO
- MLP: mejor epoca 5/5 (val_loss=0.3308, val_acc=0.8829, gap=-0.0000); ultima epoca 5 (val_loss=0.3308, val_acc=0.8829, gap=-0.0000)
- CNN: mejor epoca 5/5 (val_loss=0.2695, val_acc=0.9041, gap=-0.0016); ultima epoca 5 (val_loss=0.2695, val_acc=0.9041, gap=-0.0016)

OBSERVACION
- Ambos modelos alcanzan su mejor val_loss en la misma epoca.
- En MLP, la ultima epoca coincide con la mejor segun val_loss.
- En CNN, la ultima epoca coincide con la mejor segun val_loss.

COMPARACION
- La CNN presenta menor gap en su mejor epoca.
- Ese gap debe leerse junto a val_acc, val_loss, test, parametros y tiempo.
- La tabla df_mlp_cnn aporta test y costo; no se elige un modelo solo por el gap.

INFERENCIA
Que un modelo continue mejorando sobre TRAIN no significa que continue
mejorando su capacidad de generalizacion. En este experimento, la evidencia
de las curvas se combina con df_mlp_cnn; no se declara un ganador por una metrica.

LIMITACION
Una semilla, un presupuesto de epocas y dos arquitecturas concretas.
La mejor epoca segun val_loss no se usa aqui para detener el entrenamiento;
solo para analizar si la ultima epoca era el mejor estado observado.

DECISION
Para el resto del notebook se sigue usando el modelo ya entrenado (ultima epoca),
conscientes de que ese estado puede no coincidir con el de menor val_loss.

Interpretación

Compara las cuatro curvas con la tabla df_mlp_cnn y con el reporte de resumir_curvas():

  • ¿Qué modelo desciende más rápido en loss?
  • ¿El gap se abre, se mantiene o se cierra?
  • ¿Hay señales de underfitting (ambas curvas bajas) o de overfitting (train alto, val estancado)?
  • ¿La última época es la de menor val_loss en cada modelo?

Aplica el esquema:

OBSERVACIÓN → lo que muestran las curvas (nivel, velocidad, estabilidad, gap, mejor época vs última).
COMPARACIÓN → MLP y CNN en su mejor época y en la última; no solo el gap.
INFERENCIA → qué sugiere eso sobre generalización en este experimento.
LIMITACIÓN → pocas épocas, una semilla, un dataset, dos arquitecturas concretas.
DECISIÓN → no elegir un modelo solo porque tenga menor gap. Combinar val_acc, val_loss, test, parámetros, tiempo y la forma de las curvas.

No se reexplica Early Stopping ni overfitting: se reutilizan como herramientas de lectura. Un gap pequeño no implica un modelo bueno.

Idea clave

Una buena métrica final sin buena dinámica de entrenamiento puede ocultar problemas de generalización. Que TRAIN siga bajando no significa que la validación siga mejorando.

13. Evaluación de la CNN en TEST

La tabla MLP vs CNN ya incluyó test loss y test accuracy. Ahora se descompone el comportamiento de modelo_cnn por clase.

Se calcularán, todos por código:

  • test loss y test accuracy (otra vez, para fijar el punto de evaluación);
  • precision, recall y F1 (macro y por clase);
  • classification_report;
  • matriz de confusión;
  • las confusiones más frecuentes fuera de la diagonal;
  • ejemplos de aciertos y errores, con la confianza del modelo (probabilidad de la clase predicha).

Confianza no es certeza: un modelo puede asignar 0.99 a una clase incorrecta. Eso es un error seguro, no una verdad.

Antes de la interpretación automática, observa el reporte y pregunta:

  • ¿Qué clase tiene mejor F1? ¿Cuál el peor?
  • ¿Hay una clase con recall alto y precision baja (o al revés)?
  • ¿Las confusiones frecuentes coinciden con prendas visualmente parecidas? Formúlalo como posible explicación y contrástalo con imágenes reales.
# Evaluacion TEST de la CNN: reporte, matriz, errores y ejemplos
test_loss_cnn, test_acc_cnn = modelo_cnn.evaluate(x_test, y_test, verbose=0)
print(f"TEST CNN  loss={test_loss_cnn:.4f}  accuracy={test_acc_cnn:.4f}")

y_prob = modelo_cnn.predict(x_test, verbose=0)
y_pred = np.argmax(y_prob, axis=1)
y_conf = y_prob.max(axis=1)

print("\nclassification_report:")
print(classification_report(y_test, y_pred, target_names=class_names, digits=4))

prec, rec, f1, supp = precision_recall_fscore_support(
    y_test, y_pred, labels=np.arange(len(class_names)), zero_division=0
)
df_clases = pd.DataFrame({
    "Clase": class_names,
    "Precision": prec,
    "Recall": rec,
    "F1": f1,
    "Support": supp,
})
print("\nMetricas por clase:")
print(df_clases.to_string(index=False, float_format=lambda v: f"{v:.4f}"))

i_best = int(np.argmax(f1))
i_worst = int(np.argmin(f1))
i_low_rec = int(np.argmin(rec))
i_low_prec = int(np.argmin(prec))
print("\nClase con mejor F1:", class_names[i_best], f"{f1[i_best]:.4f}")
print("Clase con peor F1:", class_names[i_worst], f"{f1[i_worst]:.4f}")
print("Clase con menor recall:", class_names[i_low_rec], f"{rec[i_low_rec]:.4f}")
print("Clase con menor precision:", class_names[i_low_prec], f"{prec[i_low_prec]:.4f}")
print("Una sola metrica no basta: combina F1, precision, recall y support.")

cm = confusion_matrix(y_test, y_pred)
fig, ax = plt.subplots(figsize=(8, 6))
im = ax.imshow(cm, cmap="Blues")
ax.set_title("Matriz de confusion (TEST, CNN)")
ax.set_xlabel("Predicha")
ax.set_ylabel("Real")
ax.set_xticks(range(len(class_names)))
ax.set_xticklabels(class_names, rotation=45, ha="right")
ax.set_yticks(range(len(class_names)))
ax.set_yticklabels(class_names)
for i in range(cm.shape[0]):
    for j in range(cm.shape[1]):
        ax.text(j, i, int(cm[i, j]), ha="center", va="center", fontsize=8)
fig.colorbar(im, ax=ax, fraction=0.046)
plt.tight_layout()
plt.show()

pares = []
for i in range(cm.shape[0]):
    for j in range(cm.shape[1]):
        if i != j and cm[i, j] > 0:
            pares.append({
                "Clase_real": class_names[i],
                "Clase_predicha": class_names[j],
                "Errores": int(cm[i, j]),
            })
df_confusiones = pd.DataFrame(pares).sort_values("Errores", ascending=False).reset_index(drop=True)
print("\nConfusiones fuera de la diagonal (mayor a menor):")
print(df_confusiones.head(8).to_string(index=False))

# Simetria de la confusion mas frecuente
if len(df_confusiones) > 0:
    top = df_confusiones.iloc[0]
    inv = df_confusiones[
        (df_confusiones["Clase_real"] == top["Clase_predicha"])
        & (df_confusiones["Clase_predicha"] == top["Clase_real"])
    ]
    print("\nConfusion mas frecuente:", dict(top))
    if len(inv):
        print("En sentido inverso:", int(inv.iloc[0]["Errores"]), "errores.")
    else:
        print("No hay errores en el sentido inverso de ese par.")


def mostrar_casos(indices, titulo):
    n = min(5, len(indices))
    if n == 0:
        print(titulo, ": no hay ejemplos en este conjunto.")
        return
    fig, axes = plt.subplots(1, n, figsize=(2.4 * n, 3))
    if n == 1:
        axes = [axes]
    fig.suptitle(titulo)
    for ax, idx in zip(axes, indices[:n]):
        ax.imshow(x_test[idx, :, :, 0], cmap="gray")
        real = class_names[int(y_test[idx])]
        pred = class_names[int(y_pred[idx])]
        ax.set_title(f"real: {real}\npred: {pred}\np={y_conf[idx]:.2f}", fontsize=8)
        ax.axis("off")
    plt.tight_layout()
    plt.show()


ok = np.where(y_pred == y_test)[0]
err = np.where(y_pred != y_test)[0]
rng = np.random.default_rng(42)

mostrar_casos(rng.choice(ok, size=min(5, len(ok)), replace=False) if len(ok) else np.array([]),
              "Aciertos (muestra)")
mostrar_casos(rng.choice(err, size=min(5, len(err)), replace=False) if len(err) else np.array([]),
              "Errores (muestra)")

if len(err):
    mostrar_casos(err[np.argsort(y_conf[err])[::-1][:5]], "Errores con alta confianza")
    mostrar_casos(err[np.argsort(y_conf[err])[:5]], "Errores con baja confianza")
if len(ok):
    mostrar_casos(ok[np.argsort(y_conf[ok])[::-1][:5]], "Aciertos con alta confianza")

# Imagenes de la confusion mas frecuente (si existe)
if len(df_confusiones):
    r_name, p_name = df_confusiones.iloc[0]["Clase_real"], df_confusiones.iloc[0]["Clase_predicha"]
    r_id, p_id = class_names.index(r_name), class_names.index(p_name)
    mask = (y_test == r_id) & (y_pred == p_id)
    idxs = np.where(mask)[0]
    mostrar_casos(idxs[:5], f"Errores frecuentes: {r_name} -> {p_name}")
    print("Una posible explicacion visual debe contrastarse con estas imagenes; no se inventa.")
TEST CNN  loss=0.2969  accuracy=0.8926

classification_report:
              precision    recall  f1-score   support

 T-shirt/top     0.8789    0.7840    0.8288      1000
     Trouser     0.9939    0.9760    0.9849      1000
    Pullover     0.8675    0.7920    0.8280      1000
       Dress     0.8922    0.9020    0.8971      1000
        Coat     0.8292    0.8110    0.8200      1000
      Sandal     0.9818    0.9720    0.9769      1000
       Shirt     0.6440    0.7760    0.7039      1000
     Sneaker     0.9498    0.9650    0.9573      1000
         Bag     0.9675    0.9830    0.9752      1000
  Ankle boot     0.9679    0.9650    0.9664      1000

    accuracy                         0.8926     10000
   macro avg     0.8973    0.8926    0.8938     10000
weighted avg     0.8973    0.8926    0.8938     10000


Metricas por clase:
      Clase  Precision  Recall     F1  Support
T-shirt/top     0.8789  0.7840 0.8288     1000
    Trouser     0.9939  0.9760 0.9849     1000
   Pullover     0.8675  0.7920 0.8280     1000
      Dress     0.8922  0.9020 0.8971     1000
       Coat     0.8292  0.8110 0.8200     1000
     Sandal     0.9818  0.9720 0.9769     1000
      Shirt     0.6440  0.7760 0.7039     1000
    Sneaker     0.9498  0.9650 0.9573     1000
        Bag     0.9675  0.9830 0.9752     1000
 Ankle boot     0.9679  0.9650 0.9664     1000

Clase con mejor F1: Trouser 0.9849
Clase con peor F1: Shirt 0.7039
Clase con menor recall: Shirt 0.7760
Clase con menor precision: Shirt 0.6440
Una sola metrica no basta: combina F1, precision, recall y support.


Confusiones fuera de la diagonal (mayor a menor):
 Clase_real Clase_predicha  Errores
T-shirt/top          Shirt      168
   Pullover          Shirt      112
       Coat          Shirt       99
      Shirt    T-shirt/top       80
   Pullover           Coat       73
      Shirt           Coat       59
       Coat       Pullover       52
      Shirt       Pullover       47

Confusion mas frecuente: {'Clase_real': 'T-shirt/top', 'Clase_predicha': 'Shirt', 'Errores': np.int64(168)}
En sentido inverso: 80 errores.

Una posible explicacion visual debe contrastarse con estas imagenes; no se inventa.

Interpretación de la evaluación (RESULTADO → OBSERVACIÓN → COMPARACIÓN → INFERENCIA → LIMITACIÓN → DECISIÓN)

Usa los objetos df_clases, df_confusiones y las figuras, no cifras memorizadas.

RESULTADO. El classification_report descompone el test accuracy global. Macro avg trata cada clase igual; weighted avg pondera por support.

OBSERVACIÓN. Identifica la mejor y la peor clase según F1, y si el peor F1 coincide o no con el menor recall o la menor precision. Una clase puede tener precision alta y recall bajo: el modelo es cauto al predecirla, pero se le escapan muchos positivos.

COMPARACIÓN. Las filas altas de df_confusiones deben leerse junto a precision/recall de esas dos clases. Si A se predice como B con frecuencia, ¿B «roba» recall a A? ¿El par es simétrico?

INFERENCIA. En Fashion-MNIST es plausible que prendas de silueta parecida se confundan. Eso es una hipótesis visual: solo se sostiene si las imágenes mostradas la hacen creíble. No concluyas que una clase es «difícil» por una sola columna del reporte.

LIMITACIÓN. Un test fijo, una semilla y una arquitectura no separan «la clase es intrínsecamente difícil» de «este modelo no aprendió un rasgo útil».

DECISIÓN. Si una confusión domina el error, el siguiente experimento (más filtros, otro kernel, más datos de esa clase) debería atacar esa evidencia, no el accuracy global en abstracto.

Preguntas sobre las imágenes:

  • ¿Los errores parecen simétricos?
  • ¿Hay errores de alta confianza? Eso muestra que confianza ≠ certeza.
  • ¿Los aciertos de alta confianza se ven «fáciles» o también hay casos ambiguos bien resueltos?

14. Visualización de activaciones internas

Las métricas globales indican rendimiento, pero no muestran cómo la red construye representaciones. Para eso se inspeccionan salidas de capas convolucionales.

Recordatorio:

  • Kernel: pesos de la capa.
  • Feature map: respuesta de aplicar esos pesos.
  • Activación: esa respuesta tras la no linealidad (aquí, ReLU ya va incluida en Conv2D).

Al visualizar:

  • capas tempranas suelen responder a patrones locales relativamente simples;
  • capas posteriores combinan representaciones anteriores y pueden responder a estructuras más abstractas.

Eso es una descripción conceptual. No afirma que un canal «detecta mangas», «detecta zapatos» o «detecta bordes» salvo que la evidencia visual sea muy clara y, aun así, se formule como hipótesis.

Idea clave

Mirar activaciones ayuda a entender la jerarquía espacial. No sustituye a la evaluación ni autoriza narrativas antropomórficas.

# Extraccion y visualizacion de activaciones de dos capas convolucionales
sample_idx = 10
sample_input = x_test[sample_idx:sample_idx + 1]

_ = modelo_cnn(sample_input, training=False)

conv_layers = [layer for layer in modelo_cnn.layers if isinstance(layer, tf.keras.layers.Conv2D)]
if len(conv_layers) < 2:
    raise ValueError("Se requieren al menos dos capas Conv2D para visualizar activaciones.")

layer_outputs = [conv_layers[0].output, conv_layers[1].output]
try:
    _ins = modelo_cnn.inputs
except Exception:
    _ins = modelo_cnn.input
activation_model = tf.keras.Model(inputs=_ins, outputs=layer_outputs)

act1, act2 = activation_model.predict(sample_input, verbose=0)

print("Imagen de entrada:", sample_input.shape)
print("Activacion capa temprana:", act1.shape, "  (batch, alto, ancho, canales)")
print("Activacion capa posterior:", act2.shape)
print("La resolucion espacial suele bajar; el numero de canales suele subir.")


def resumen_canales(act, nombre):
    energia = act[0].mean(axis=(0, 1))
    inactivos = int(np.sum(energia < 1e-5))
    print(f"{nombre}: media por canal (primeros 8): {np.round(energia[:8], 4)}")
    print(f"{nombre}: canales casi inactivos (media < 1e-5): {inactivos} / {act.shape[-1]}")


resumen_canales(act1, "Capa temprana")
resumen_canales(act2, "Capa posterior")

n_show = 6
fig, axes = plt.subplots(2, n_show, figsize=(14, 5))
fig.suptitle("Activaciones internas (canales; no etiquetarlos como objetos)")

for i in range(n_show):
    axes[0, i].imshow(act1[0, :, :, i], cmap="viridis")
    axes[0, i].set_title(f"Conv1 ch {i}")
    axes[0, i].axis("off")
    axes[1, i].imshow(act2[0, :, :, i], cmap="viridis")
    axes[1, i].set_title(f"Conv2 ch {i}")
    axes[1, i].axis("off")

plt.tight_layout()
plt.show()

plt.figure(figsize=(3, 3))
plt.imshow(sample_input[0, :, :, 0], cmap="gray")
plt.title(f"Entrada: {class_names[int(y_test[sample_idx])]}")
plt.axis("off")
plt.show()
Imagen de entrada: (1, 28, 28, 1)
Activacion capa temprana: (1, 28, 28, 16)   (batch, alto, ancho, canales)
Activacion capa posterior: (1, 14, 14, 32)
La resolucion espacial suele bajar; el numero de canales suele subir.
Capa temprana: media por canal (primeros 8): [0.0305 0.0343 0.0322 0.1098 0.2126 0.294  0.0658 0.0489]
Capa temprana: canales casi inactivos (media < 1e-5): 0 / 16
Capa posterior: media por canal (primeros 8): [0.0946 0.1135 0.1434 0.1244 0.1406 0.078  0.0585 0.1684]
Capa posterior: canales casi inactivos (media < 1e-5): 0 / 32

Preguntas (antes de leer la explicación)

  1. ¿Todos los canales responden igual?
  2. ¿Hay mapas casi inactivos?
  3. ¿Qué regiones producen mayor activación?
  4. ¿Cómo cambia la resolución espacial entre la capa temprana y la posterior?
  5. ¿Cómo cambia el número de canales?
  6. ¿Las representaciones posteriores son directamente interpretables como objetos?
  7. ¿Qué limitaciones tiene interpretar feature maps de esta manera?

Explicación

Lo habitual es que no todos los canales respondan igual: unos se encienden en ciertas regiones, otros quedan cerca de cero (sobre todo tras ReLU). Las regiones de mayor activación indican dónde ese canal es sensible, no qué concepto humano representa.

Entre capas, la resolución espacial suele bajar (por el pooling intermedio) y el número de canales suele subir. Las representaciones posteriores son combinaciones de las anteriores: más abstractas y, a menudo, menos legibles como imagen.

Limitaciones de esta inspección:

  • vemos unos pocos canales, no todos;
  • ReLU apaga valores negativos, así que el mapa no es el feature map lineal crudo;
  • un canal intenso no equivale a un detector semántico;
  • la visualización no explica, por sí sola, un error de clasificación.

Fórmulas prudentes: «este canal responde con mayor intensidad en determinadas regiones»; «parece sensible a ciertas estructuras locales». Evita: «este filtro detecta zapatos».

Idea clave

Cuanto más profunda la capa, más abstracta suele ser la representación. Eso no autoriza a ponerle nombre de prenda a cada mapa.

15. Cierre: ¿por qué CNN para imágenes?

Porque introduce sesgos inductivos adecuados para datos espaciales:

  • conectividad local (el detector mira una vecindad, no los 784 píxeles a la vez);
  • compartición de pesos (el mismo kernel se reutiliza en todo el plano);
  • aprendizaje jerárquico de representaciones (de patrones locales a combinaciones más abstractas).

Flujo consolidado:

IMAGEN → CONVOLUCIÓN → FEATURE MAP → ACTIVACIÓN → POOLING
      → JERARQUÍA DE REPRESENTACIONES → CLASIFICACIÓN

Una CNN no es simplemente una MLP con más capas. Su arquitectura cambia la forma en que el modelo utiliza la estructura espacial de los datos.

Flatten al inicio conserva valores y elimina la vecindad explícita. Flatten al final, después de conv/pool, linealiza evidencia ya extraída.

El experimento de esta sesión aporta evidencia sobre MLP frente a CNN en Fashion-MNIST, con una semilla y un presupuesto de épocas. Esa evidencia informa una decisión de diseño; no demuestra un teorema («CNN siempre gana»).

Próximos pasos (no se implementan aquí)

Arquitecturas más profundas, transferencia de aprendizaje, visualizaciones tipo Grad-CAM y aumento de datos avanzado quedan para sesiones posteriores.

Idea clave

En visión, mejorar resultados no consiste solo en aumentar neuronas; consiste en utilizar una arquitectura cuyo sesgo inductivo coincida con la geometría del dato.

16. Amenazas a la validez

La comparación MLP vs CNN de este notebook depende de decisiones que no son la arquitectura:

  • la profundidad y el ancho concretos de cada modelo;
  • la inicialización y la semilla;
  • el número de épocas y el batch;
  • el optimizador y el learning rate;
  • el preprocessing y el split;
  • el hardware (el tiempo no es una constante física);
  • la variabilidad aleatoria de una sola corrida.

Por tanto:

«La CNN fue mejor en este experimento» no significa «la CNN siempre será mejor».

Un único dataset (Fashion-MNIST), una única semilla y un presupuesto corto de épocas no autorizan a generalizar a fotografías naturales, a otras resoluciones ni a otras familias de modelos.

Esquema que debe quedar instalado:

OBSERVACIÓN → qué midieron la tabla, las curvas y la matriz.
INFERENCIA → qué sugiere eso sobre el uso de la estructura espacial aquí.
LIMITACIÓN → qué no cubre el diseño.
DECISIÓN → si adoptamos la CNN como base para seguir experimentando en visión, no como verdada universal.

17. Ejercicios (sin soluciones completas)

Trabaja sobre el notebook. No copies cifras de memoria: mídalas.

  1. Cambia el número de filtros de la primera Conv2D (por ejemplo 8 y 32) y analiza parámetros y desempeño. ¿Más filtros implica automáticamente mejor test?
  2. Sustituye kernels 3 × 3 por 5 × 5. Predice el cambio de parámetros y de H_out (si pasas a valid). Luego mide.
  3. Compara padding="same" frente a "valid" en la primera capa. ¿Qué cambia en bordes, shape y accuracy?
  4. Prueba strides=2 en una convolución. Relaciona resolución, tiempo y métricas.
  5. Elimina un pooling. ¿Qué ocurre con el shape, los parámetros de la Dense y el riesgo de sobreajuste?
  6. Compara dos CNN que difieran en un solo factor (filtros o pooling o padding). Reporta la tabla con valores programáticos.
  7. Identifica la confusión más frecuente fuera de la diagonal. ¿Es simétrica? Contrástala con precision/recall de esas clases.
  8. Elige una predicción incorrecta de alta confianza. ¿La imagen es ambigua para un humano? Una posible explicación visual no es una prueba.
  9. Interpreta un feature map de la capa temprana: describe regiones de alta respuesta, no nombres de prendas.
  10. Explica, en un párrafo, por qué un Flatten temprano cambia el tratamiento de la estructura espacial aunque conserve los 784 valores.

Criterio: medir → observar → comparar → inferir → decidir. Una métrica aislada no cierra el ejercicio.

💬 ¿Te sirvió?Deja en los comentarios una duda o un caso donde aplicarías esto —respondo todos. Sígueme para no perderte el próximo artículo de la serie ycomparte con alguien que esté aprendiendo deep learning.👉 El código completo está disponible para ejecutar directamente.