Al cerrar el módulo 6 dejamos la caja de herramientas completa: sabemos entrenar con Keras, controlar el entrenamiento con callbacks, montar pipelines con tf.data y empaquetar modelos para producción. Toca cumplir la primera promesa pendiente desde el módulo 3: construir, de principio a fin, el clasificador de imágenes de producto de TecnoMarket. En esta lección desarrollarás un proyecto completo — exploración de datos, pipeline, modelo, entrenamiento, evaluación, análisis de errores, mejora iterativa y entrega — siguiendo la metodología del curso: prototipo con un dataset público (CIFAR-10) y traslado al caso ficticio de TecnoMarket.

Contenido

  1. Enunciado del proyecto y plan de trabajo
  2. Fase 1: exploración del dataset
  3. Fase 2: pipeline de datos con tf.data y aumento de datos
  4. Fase 3: el modelo — una mini-VGG con BN y dropout
  5. Fase 4: entrenamiento con callbacks y TensorBoard
  6. Fase 5: evaluación — matriz de confusión por clases
  7. Fase 6: análisis visual de errores y mejora iterativa
  8. Fase 7: entrega — guardado y conexión con la cola de revisión

Enunciado del proyecto y plan de trabajo

Contexto de negocio. Los vendedores de TecnoMarket suben fotos al dar de alta un artículo y hoy eligen la categoría a mano; el 12 % de las altas acaba mal clasificada y perjudica al buscador. Queremos un modelo que proponga la categoría automáticamente a partir de la foto, con la salvaguarda que diseñamos en 03-04: si la confianza no supera un umbral, el alta va a la cola de revisión humana.

Stand-in. Como no tenemos (todavía) el dataset real de TecnoMarket, prototipamos con CIFAR-10: 60 000 imágenes 32×32 en 10 clases (avión, coche, pájaro, gato, ciervo, perro, rana, caballo, barco, camión). Diez clases hacen de stand-in de diez categorías de producto; todo el pipeline será transplantable cuando lleguen las fotos reales.

Plan por fases (el mismo esqueleto que usaremos en todos los proyectos del módulo):

Fase Entregable
Exploración Conocer tamaños, clases, balance
Pipeline tf.data con aumento de datos integrado
Modelo CNN propia (mini-VGG de 03-03 + BN/dropout de 05-04)
Entrenamiento Callbacks (06-01), curvas, TensorBoard
Evaluación Exactitud en test + matriz de confusión interpretada
Mejora Iteraciones guiadas por diagnóstico
Entrega Modelo+preprocesado guardados (06-05) + umbral de confianza (03-04)

Fase 1: exploración del dataset

Nunca entrenes sobre datos que no has mirado. Trabajamos en tecnomarket-dl/notebooks/07_01_clasificador.ipynb (estructura de proyecto de 06-04):

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt

tf.random.set_seed(42)  # reproducibilidad, como fijamos en 06-04
np.random.seed(42)

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()

CLASES = ["avion", "coche", "pajaro", "gato", "ciervo",
          "perro", "rana", "caballo", "barco", "camion"]

print(x_train.shape, x_test.shape)   # (50000, 32, 32, 3) (10000, 32, 32, 3)
print(np.bincount(y_train.flatten())) # 5000 por clase: dataset balanceado

Puntos que verificamos y por qué importan:

  • Forma: 32×32×3 — imágenes en color, muy pequeñas. Nuestra CNN debe estar dimensionada para ello (la fórmula de tamaños de salida de 03-02 nos dirá cuántos bloques caben antes de quedarnos sin píxeles).
  • Balance: 5 000 imágenes por clase. Esto legitima usar exactitud como métrica principal; en 07-03 veremos qué pasa cuando no hay balance.
  • Inspección visual: pinta una rejilla de 25 imágenes con su etiqueta. Verás que a 32×32 hasta a un humano le cuesta distinguir gato de perro — calibra expectativas.
plt.figure(figsize=(8, 8))
for i in range(25):
    plt.subplot(5, 5, i + 1)
    plt.imshow(x_train[i])
    plt.title(CLASES[int(y_train[i])], fontsize=8)
    plt.axis("off")
plt.tight_layout()

Fase 2: pipeline de datos con tf.data y aumento de datos

Aplicamos el patrón map → shuffle → batch → prefetch de 06-01, y le añadimos el aumento de datos con capas de Keras que vimos en 05-04. Reservamos además un conjunto de validación separado del test:

# Separar validación (últimas 5000 de train) — el test queda congelado (06-05)
x_val, y_val = x_train[45000:], y_train[45000:]
x_train, y_train = x_train[:45000], y_train[:45000]

BATCH = 64
AUTOTUNE = tf.data.AUTOTUNE

# Aumento de datos como capas (05-04): solo actúa en entrenamiento
aumento = tf.keras.Sequential([
    tf.keras.layers.RandomFlip("horizontal"),
    tf.keras.layers.RandomTranslation(0.1, 0.1),
    tf.keras.layers.RandomZoom(0.1),
], name="aumento")

def preparar(x, y):
    return tf.cast(x, tf.float32) / 255.0, y   # normalización a [0, 1]

train_ds = (tf.data.Dataset.from_tensor_slices((x_train, y_train))
            .map(preparar, num_parallel_calls=AUTOTUNE)
            .shuffle(10_000)
            .batch(BATCH)
            .map(lambda x, y: (aumento(x, training=True), y),
                 num_parallel_calls=AUTOTUNE)
            .prefetch(AUTOTUNE))

val_ds = (tf.data.Dataset.from_tensor_slices((x_val, y_val))
          .map(preparar).batch(BATCH).prefetch(AUTOTUNE))

test_ds = (tf.data.Dataset.from_tensor_slices((x_test, y_test))
           .map(preparar).batch(BATCH).prefetch(AUTOTUNE))

Detalles que marcan la diferencia:

  • El aumento va después del batch y con training=True explícito: se aplica a lotes completos y solo en el pipeline de entrenamiento. Validación y test ven las imágenes originales, como exige la validación honesta de 06-05.
  • Volteo horizontal sí, vertical no: un coche boca abajo no es una foto de catálogo realista. El aumento debe generar variaciones plausibles (criterio de 05-04).
  • La normalización (/255) vive en preparar; en la fase de entrega la meteremos dentro del modelo para que viaje con él (06-05).

Fase 3: el modelo — una mini-VGG con BN y dropout

Retomamos la mini-VGG que construimos en 03-03 (bloques Conv-Conv-Pool con filtros que se duplican) y le incorporamos las mejoras de 05-04: BatchNormalization tras cada convolución y Dropout creciente:

from tensorflow.keras import layers, models

def bloque_conv(x, filtros, dropout):
    for _ in range(2):
        x = layers.Conv2D(filtros, 3, padding="same", use_bias=False)(x)
        x = layers.BatchNormalization()(x)
        x = layers.Activation("relu")(x)
    x = layers.MaxPooling2D()(x)
    return layers.Dropout(dropout)(x)

entradas = layers.Input(shape=(32, 32, 3))
x = bloque_conv(entradas, 32, 0.2)    # 32x32 -> 16x16
x = bloque_conv(x, 64, 0.3)           # 16x16 -> 8x8
x = bloque_conv(x, 128, 0.4)          # 8x8   -> 4x4
x = layers.Flatten()(x)
x = layers.Dense(128, activation="relu")(x)
x = layers.Dropout(0.5)(x)
salidas = layers.Dense(10, activation="softmax")(x)

modelo = models.Model(entradas, salidas, name="tecnomarket_cnn_v1")
modelo.summary()   # ~1.2 M de parámetros

Decisiones explicadas:

  • API funcional (introducida en 03-03): nos deja factorizar el bloque en una función y facilita futuras cirugías (extraer embeddings como en 03-04, por ejemplo).
  • use_bias=False antes de BN: la BatchNormalization ya aporta su propio desplazamiento; el sesgo de la convolución sería redundante.
  • Dropout creciente (0.2 → 0.5): las capas profundas, con más parámetros, necesitan más regularización (05-04).
  • Tres bloques de pooling llevan de 32×32 a 4×4 — con la fórmula de 03-02 se comprueba que un cuarto bloque dejaría mapas de 2×2, demasiado pobres.

Fase 4: entrenamiento con callbacks y TensorBoard

Compilamos con Adam y entropía cruzada (02-04) y entrenamos con los callbacks de 06-01:

modelo.compile(optimizer=tf.keras.optimizers.Adam(1e-3),
               loss="sparse_categorical_crossentropy",
               metrics=["accuracy"])

callbacks = [
    tf.keras.callbacks.EarlyStopping(monitor="val_accuracy", patience=8,
                                     restore_best_weights=True),
    tf.keras.callbacks.ModelCheckpoint("models/cnn_v1_mejor.keras",
                                       monitor="val_accuracy",
                                       save_best_only=True),
    tf.keras.callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5,
                                         patience=3, min_lr=1e-5),
    tf.keras.callbacks.TensorBoard(log_dir="logs/cnn_v1"),
]

historia = modelo.fit(train_ds, validation_data=val_ds,
                      epochs=60, callbacks=callbacks)

Mientras entrena (en una GPU modesta, ~15-25 min; en CPU, cuenta con más de una hora), abre TensorBoard como en 06-04 (tensorboard --logdir logs) y vigila:

  • Curvas train/val juntas y subiendo: gracias al aumento de datos y al dropout, la brecha entre entrenamiento y validación se mantiene pequeña (sin ellos, esta misma red se dispara a 99 % en train y se estanca en ~72 % en validación — sobreajuste de libro, 05-04).
  • Escalones al bajar el learning rate: ReduceLROnPlateau suele provocar una mejora visible de 1-2 puntos cuando actúa.

Resultado típico honesto: la validación se estanca entre el 82 % y el 85 % hacia la época 40-50. No esperes más de una CNN propia entrenada desde cero sobre 32×32; está bien.

Fase 5: evaluación — matriz de confusión por clases

La exactitud global no basta para una decisión de negocio: necesitamos saber qué confunde el modelo.

from sklearn.metrics import confusion_matrix, classification_report

test_loss, test_acc = modelo.evaluate(test_ds)
print(f"Exactitud en test: {test_acc:.3f}")   # típico: 0.82 - 0.85

y_prob = modelo.predict(test_ds)
y_pred = np.argmax(y_prob, axis=1)

print(classification_report(y_test.flatten(), y_pred, target_names=CLASES))
cm = confusion_matrix(y_test.flatten(), y_pred)

Interpretación típica de la matriz (los números exactos variarán):

Confusión frecuente Lectura Análogo en TecnoMarket
gato ↔ perro Animales pequeños, texturas parecidas a 32×32 «auriculares» ↔ «cascos gaming»
avión ↔ barco Fondos azules dominan la imagen productos fotografiados sobre el mismo fondo
coche ↔ camión Misma familia semántica «móvil» ↔ «tablet»
rana, barco Clases con >90 % de acierto categorías visualmente distintivas

La lección de negocio: los errores no se reparten uniformemente. Las parejas confundibles son exactamente donde el umbral de confianza de 03-04 tendrá que trabajar más.

Fase 6: análisis visual de errores y mejora iterativa

Antes de tocar nada, mira los fallos con tus ojos:

errores = np.where(y_pred != y_test.flatten())[0]
plt.figure(figsize=(10, 6))
for i, idx in enumerate(errores[:15]):
    plt.subplot(3, 5, i + 1)
    plt.imshow(x_test[idx])
    plt.title(f"real: {CLASES[int(y_test[idx])]}\npred: {CLASES[y_pred[idx]]}"
              f" ({y_prob[idx].max():.2f})", fontsize=7)
    plt.axis("off")
plt.tight_layout()

Verás tres familias de errores: imágenes genuinamente ambiguas (ni tú aciertas), errores con confianza baja (el umbral los atrapará) y errores con confianza alta (los peligrosos). Con ese diagnóstico, itera en este orden — de lo más barato a lo más caro:

  1. Más épocas / paciencia: si las curvas seguían subiendo al parar, es mejora gratis.
  2. Ajustar el aumento de datos: si hay brecha train-val, intensifícalo; si el train no llega a buen nivel, suavízalo (el aumento excesivo también daña, 05-04).
  3. Ajustar regularización: mover dropout ±0.1 según la brecha.
  4. Capacidad: un cuarto bloque convolucional o más filtros — solo si el train se ha quedado corto.
  5. Cambiar de arquitectura: la puerta que abriremos en 07-05.

Regla de oro del módulo: un cambio por iteración, con semilla fija (06-04), o no sabrás qué causó qué.

Fase 7: entrega — guardado y conexión con la cola de revisión

Siguiendo 06-05, empaquetamos modelo + preprocesado como una unidad, para que la API no pueda desincronizarse del entrenamiento:

# Modelo de inferencia: normalización dentro + probabilidades fuera
entrada = layers.Input(shape=(32, 32, 3), dtype=tf.uint8)
x = layers.Rescaling(1.0 / 255)(tf.cast(entrada, tf.float32))
prob = modelo(x, training=False)
modelo_servicio = models.Model(entrada, prob)
modelo_servicio.save("models/clasificador_categorias_v1.keras")

Y aplicamos la política de 03-04 con el umbral de confianza:

UMBRAL = 0.80  # calibrado sobre validación, nunca sobre test

def clasificar_alta(imagen_uint8):
    p = modelo_servicio.predict(imagen_uint8[np.newaxis, ...], verbose=0)[0]
    if p.max() >= UMBRAL:
        return {"categoria": CLASES[int(p.argmax())], "auto": True}
    return {"categoria": None, "auto": False, "destino": "cola_revision",
            "sugerencias": [CLASES[i] for i in p.argsort()[-3:][::-1]]}

Con el umbral en 0.80, un resultado típico es que el modelo resuelve automáticamente en torno al 70 % de las altas con una exactitud sobre ese subconjunto cercana al 92-94 %, y deriva el resto a revisión humana con las tres categorías más probables como sugerencia — humanos y modelo colaborando, como diseñamos en 03-04. El servicio se expondría con la misma plantilla FastAPI de 06-05.

Errores Comunes y Consejos

  • Aplicar el aumento de datos también a validación/test: infla artificialmente la dificultad y te hace tomar malas decisiones. El aumento solo vive en train_ds.
  • Elegir el umbral de confianza mirando el test: el test es el conjunto congelado (06-05); si lo usas para calibrar, ya no mide nada. Umbral y ajustes, siempre con validación.
  • Cambiar tres cosas a la vez entre iteraciones: no sabrás qué funcionó. Un cambio, una semilla fija, una comparación.
  • Frustrarse por no pasar del 85 %: es el techo razonable de este enfoque con este dataset. La mejora grande no vendrá de insistir, sino de cambiar de estrategia (07-05).
  • Olvidar restore_best_weights=True: sin ello, EarlyStopping te deja los pesos de la última época, no los mejores.

Ejercicios

  1. Añade un cuarto bloque convolucional de 256 filtros (ajustando el dropout) y compara exactitud y tiempo de entrenamiento con la versión de tres bloques. ¿Compensa?
  2. Genera la lista de las 20 predicciones erróneas con mayor confianza. ¿Qué parejas de clases aparecen? Propón un cambio de aumento de datos dirigido a ellas.
  3. Calcula, para umbrales 0.6, 0.7, 0.8 y 0.9 sobre el conjunto de validación, qué porcentaje de imágenes se automatiza y qué exactitud tiene ese subconjunto. Presenta la tabla y elige umbral justificando el compromiso.

Soluciones

  1. Sustituye la última llamada por x = bloque_conv(x, 256, 0.4) tras el bloque de 128 (los mapas pasan de 4×4 a 2×2, justo en el límite). Resultado típico: +0.5-1 punto de exactitud a cambio de ~1.5× tiempo por época. Compensa solo si ese punto importa; documenta ambas cifras.
  2. idx = errores[np.argsort(-y_prob[errores].max(axis=1))[:20]] y pinta como en la fase 6. Suelen dominar gato↔perro y coche↔camión. Cambio dirigido: RandomContrast(0.2) o recortes aleatorios (RandomCrop con padding) que fuercen a la red a fijarse en la forma y no en el fondo.
  3. Para cada umbral u: mask = p_val.max(axis=1) >= u; cobertura = mask.mean(); exactitud automatizada = (pred_val[mask] == y_val_flat[mask]).mean(). Tabla típica: 0.6 → 85 %/89 %; 0.7 → 78 %/91 %; 0.8 → 70 %/93 %; 0.9 → 55 %/96 %. La elección depende del coste de un error frente al coste de revisar: para altas de catálogo, 0.8 suele ser un buen compromiso.

Conclusión

Has entregado el primer proyecto completo de TecnoMarket: un clasificador de categorías con pipeline tf.data, aumento de datos, una mini-VGG regularizada, entrenamiento controlado por callbacks, evaluación con matriz de confusión, análisis de errores y un empaquetado listo para servir tras un umbral de confianza con cola de revisión. El resultado — 82-85 % en test — es honesto para una CNN entrenada desde cero, y en 07-05 lo batiremos con transfer learning sin escribir apenas más código. Antes, cambiamos de modalidad: en la próxima lección cumplimos la promesa de 04-04 y construimos un generador de texto carácter a carácter para los borradores de descripciones de producto de TecnoMarket.

Curso de Deep Learning

Módulo 1: Introducción a Deep Learning

Módulo 2: Fundamentos de Redes Neuronales

Módulo 3: Redes Neuronales Convolucionales (CNN)

Módulo 4: Redes Neuronales Recurrentes (RNN)

Módulo 5: Técnicas Avanzadas en Deep Learning

Módulo 6: Herramientas y Frameworks

Módulo 7: Proyectos Prácticos

Módulo 8: Consideraciones Éticas y Futuro del Deep Learning

© Copyright 2026. Todos los derechos reservados