En 05-01 montaste el esqueleto de una DCGAN — generador con Conv2DTranspose, discriminador convolucional, la metáfora del falsificador y el policía — y dejamos pendiente lo más delicado: el bucle de entrenamiento completo. Con el GradientTape que dominaste en 06-01, ya tienes la herramienta exacta para escribirlo. En este cuarto proyecto entrenarás una GAN de principio a fin sobre Fashion-MNIST (prendas de ropa como stand-in de fotos de producto) como prototipo de un generador de imágenes para las creatividades promocionales de TecnoMarket, aprendiendo a leer la dinámica de un entrenamiento adversario: qué es normal, qué es mode collapse y qué tocar cuando algo va mal.

Contenido

  1. Enunciado del proyecto y expectativas realistas
  2. Fase 1: datos — Fashion-MNIST con tf.data
  3. Fase 2: generador y discriminador (retomando 05-01)
  4. Fase 3: las dos pérdidas adversarias
  5. Fase 4: un paso de entrenamiento con GradientTape, línea a línea
  6. Fase 5: el bucle por épocas con rejillas de muestras
  7. Fase 6: leer la dinámica — lo normal, el mode collapse y qué tocar
  8. Fase 7: evaluación, guardado del generador y uso posterior

Enunciado del proyecto y expectativas realistas

Contexto de negocio. El equipo de marketing de TecnoMarket quiere explorar la generación de imágenes para creatividades promocionales (fondos, variaciones de producto, ilustraciones de campaña). Antes de invertir, piden un prototipo educativo que demuestre que el equipo domina la mecánica generativa.

Stand-in. Fashion-MNIST: 60 000 imágenes 28×28 en escala de grises de prendas (camisetas, zapatillas, bolsos...), un stand-in razonable de fotos de producto simples. Pequeño, rápido de entrenar y suficientemente variado para ver los fenómenos que importan.

Expectativas, por escrito antes de empezar: una DCGAN de este tamaño produce prendas reconocibles pero borrosas a 28×28. No es StyleGAN ni un producto comercial; los sistemas de producción actuales usan modelos de difusión (los verás mencionados en 08-03) y toda generación de imágenes comerciales arrastra implicaciones éticas que trataremos en 08-01. El objetivo aquí es dominar el mecanismo adversario — que es también la base conceptual para entender los sistemas modernos.

Fase 1: datos — Fashion-MNIST con tf.data

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

tf.random.set_seed(42)

(x_train, _), _ = tf.keras.datasets.fashion_mnist.load_data()

# Normalizar a [-1, 1]: el generador terminara en tanh (05-01)
x_train = (x_train.astype("float32") - 127.5) / 127.5
x_train = x_train[..., np.newaxis]           # (60000, 28, 28, 1)

BATCH = 128
train_ds = (tf.data.Dataset.from_tensor_slices(x_train)
            .shuffle(60_000)
            .batch(BATCH, drop_remainder=True)
            .prefetch(tf.data.AUTOTUNE))

Detalle importante que ya anticipamos en 05-01: el rango [-1, 1] (no [0, 1]) porque la salida del generador será tanh. Generador y datos reales deben hablar el mismo idioma numérico o el discriminador los distinguirá por el rango, no por el contenido. No hay etiquetas ni conjunto de test: en una GAN los «datos» son solo la referencia de realidad.

Fase 2: generador y discriminador (retomando 05-01)

Instanciamos el esqueleto DCGAN de 05-01 adaptado a 28×28. No reexplicamos las piezas (repásalas allí); anotamos las decisiones:

from tensorflow.keras import layers, models

DIM_RUIDO = 100

def crear_generador():
    return models.Sequential([
        layers.Input(shape=(DIM_RUIDO,)),
        layers.Dense(7 * 7 * 256, use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),
        layers.Reshape((7, 7, 256)),
        layers.Conv2DTranspose(128, 5, strides=1, padding="same", use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),        # 7x7
        layers.Conv2DTranspose(64, 5, strides=2, padding="same", use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),        # 14x14
        layers.Conv2DTranspose(1, 5, strides=2, padding="same",
                               activation="tanh"),              # 28x28, [-1,1]
    ], name="generador")

def crear_discriminador():
    return models.Sequential([
        layers.Input(shape=(28, 28, 1)),
        layers.Conv2D(64, 5, strides=2, padding="same"),
        layers.LeakyReLU(), layers.Dropout(0.3),                # 14x14
        layers.Conv2D(128, 5, strides=2, padding="same"),
        layers.LeakyReLU(), layers.Dropout(0.3),                # 7x7
        layers.Flatten(),
        layers.Dense(1),                                        # logit: real o falso
    ], name="discriminador")

generador = crear_generador()
discriminador = crear_discriminador()

Recordatorios de 05-01 aplicados: Conv2DTranspose con strides=2 duplica la resolución (7→14→28), BN y LeakyReLU estabilizan el generador, el discriminador usa dropout (no BN) y devuelve un logit sin sigmoid — la pérdida lo gestionará.

Fase 3: las dos pérdidas adversarias

El juego de 05-01, ahora en código. Ambas pérdidas parten de la misma entropía cruzada binaria sobre logits:

bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)

def perdida_discriminador(logits_reales, logits_falsos):
    # El policia quiere: reales -> 1, falsas -> 0
    perdida_real = bce(tf.ones_like(logits_reales), logits_reales)
    perdida_falsa = bce(tf.zeros_like(logits_falsos), logits_falsos)
    return perdida_real + perdida_falsa

def perdida_generador(logits_falsos):
    # El falsificador quiere que SUS falsas parezcan reales (etiqueta 1)
    return bce(tf.ones_like(logits_falsos), logits_falsos)

# Dos optimizadores separados: cada red aprende por su cuenta (05-01)
opt_gen = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)
opt_disc = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)

La asimetría es la esencia: el discriminador puntúa las mismas imágenes falsas con objetivo 0 y el generador con objetivo 1. Los hiperparámetros lr=2e-4, beta_1=0.5 son los clásicos de DCGAN — funcionan; no los toques en la primera pasada.

Fase 4: un paso de entrenamiento con GradientTape, línea a línea

Aquí se junta todo: el GradientTape de 06-01 nos deja calcular dos conjuntos de gradientes de un mismo grafo y aplicárselos a redes distintas. Comentado línea a línea:

@tf.function                      # compila el paso a grafo: ~5-10x mas rapido (06-01)
def paso_entrenamiento(imagenes_reales):
    ruido = tf.random.normal([BATCH, DIM_RUIDO])          # 1. lote de ruido

    with tf.GradientTape() as tape_g, tf.GradientTape() as tape_d:
        imagenes_falsas = generador(ruido, training=True)  # 2. falsificar

        logits_reales = discriminador(imagenes_reales, training=True)  # 3. juzgar reales
        logits_falsos = discriminador(imagenes_falsas, training=True)  # 4. juzgar falsas

        perd_g = perdida_generador(logits_falsos)          # 5. cuanto engaña
        perd_d = perdida_discriminador(logits_reales, logits_falsos)  # 6. cuanto acierta

    # 7. Gradientes de la perdida de CADA red respecto a SUS variables
    grads_g = tape_g.gradient(perd_g, generador.trainable_variables)
    grads_d = tape_d.gradient(perd_d, discriminador.trainable_variables)

    # 8. Cada optimizador actualiza solo su red
    opt_gen.apply_gradients(zip(grads_g, generador.trainable_variables))
    opt_disc.apply_gradients(zip(grads_d, discriminador.trainable_variables))
    return perd_g, perd_d

Puntos finos que debes entender, no solo copiar:

  • Dos tapes, una pasada: ambos graban las mismas operaciones, pero el paso 7 pide a cada tape gradientes solo de su pérdida respecto a sus variables. Al actualizar el generador no tocamos el discriminador, y viceversa — si mezclaras variables, cada red sabotearía a la otra.
  • training=True en ambas redes siempre: la BN del generador debe usar estadísticas del lote también cuando sus imágenes alimentan la pérdida del discriminador.
  • @tf.function: el mismo decorador que vimos en 06-01; en un bucle personalizado como este, la diferencia de velocidad es enorme.
  • Este es el patrón que Keras fit() no te da hecho: dos redes, dos pérdidas enfrentadas, un paso simultáneo. Por eso la promesa de 05-01 tuvo que esperar a 06-01.

Fase 5: el bucle por épocas con rejillas de muestras

En una GAN, la pérdida no cuenta toda la verdad (fase 6), así que el bucle genera periódicamente una rejilla de muestras con el mismo ruido fijo, para comparar épocas en igualdad de condiciones:

ruido_fijo = tf.random.normal([16, DIM_RUIDO], seed=42)   # SIEMPRE el mismo

def guardar_rejilla(epoca):
    muestras = generador(ruido_fijo, training=False)
    muestras = (muestras + 1) / 2                  # [-1,1] -> [0,1] para pintar
    plt.figure(figsize=(4, 4))
    for i in range(16):
        plt.subplot(4, 4, i + 1)
        plt.imshow(muestras[i, :, :, 0], cmap="gray")
        plt.axis("off")
    plt.savefig(f"logs/gan/rejilla_epoca_{epoca:03d}.png")
    plt.close()

EPOCAS = 50
for epoca in range(1, EPOCAS + 1):
    perdidas_g, perdidas_d = [], []
    for lote in train_ds:
        pg, pd = paso_entrenamiento(lote)
        perdidas_g.append(float(pg)); perdidas_d.append(float(pd))
    print(f"Epoca {epoca:3d} | G: {np.mean(perdidas_g):.3f} "
          f"| D: {np.mean(perdidas_d):.3f}")
    if epoca % 5 == 0 or epoca == 1:
        guardar_rejilla(epoca)
        generador.save(f"models/generador_epoca_{epoca:03d}.keras")

En GPU, cada época tarda ~20-40 s (50 épocas ≈ media hora); en CPU es lento — reduce a 15-20 épocas o usa Colab (01-05). Guardamos checkpoints del generador cada 5 épocas: en las GAN, la «mejor época» se elige mirando rejillas, no pérdidas, así que conviene poder volver atrás.

Fase 6: leer la dinámica — lo normal, el mode collapse y qué tocar

Evolución visual típica y honesta (tus épocas exactas variarán):

Épocas Qué verás en la rejilla
1-3 Ruido gris con manchas: nada reconocible
5-10 Manchas claras sobre fondo oscuro: «proto-prendas» borrosas
15-25 Siluetas claras: se distinguen camisetas, pantalones, zapatos
30-50 Prendas reconocibles con textura básica; bordes aún blandos

Pérdidas: qué es normal. A diferencia de un clasificador, aquí las pérdidas no deben converger a cero: es un equilibrio, no un descenso. Sano: G oscilando en ~0.7-1.5 y D en ~1.0-1.3, ambas moviéndose sin tendencia clara. Señales de alarma:

Síntoma Diagnóstico Qué tocar (de 05-01)
D → 0 y G crece sin parar Discriminador demasiado fuerte: el generador no recibe señal útil Baja el lr del discriminador (p. ej. 1e-4), o añade ruido/etiquetas suavizadas (0.9 en vez de 1) a los reales
Las 16 muestras de la rejilla son casi idénticas Mode collapse: el generador encontró una imagen que engaña y la repite Más entropía: sube lr del generador ligeramente, revisa el suavizado de etiquetas, reinicia desde un checkpoint anterior
Todo oscila violentamente y las muestras empeoran Learning rates demasiado altos Baja ambos lr a la mitad
Rejillas estancadas 15+ épocas Equilibrio muerto Prueba más capacidad en el generador o más épocas; a veces solo necesita tiempo

El diagnóstico se hace con las rejillas primero y las curvas después — exactamente al revés que en los proyectos anteriores.

Fase 7: evaluación, guardado del generador y uso posterior

Evaluación cualitativa. El protocolo mínimo: (1) rejillas del ruido fijo época a época — ¿mejoran?; (2) diversidad — genera 64 muestras nuevas y comprueba que aparecen varios tipos de prenda; (3) un vistazo a vecinos — para alguna muestra generada, busca la imagen real más parecida del dataset y verifica que no es una copia memorizada.

Métricas cuantitativas, solo el concepto. En investigación se usa el FID (Fréchet Inception Distance): compara estadísticas de imágenes reales y generadas en el espacio de características de una red preentrenada — menor es mejor. Para este prototipo basta saber que existe y que la inspección visual disciplinada es el estándar práctico a esta escala.

Entrega. Solo se despliega el generador — el discriminador fue el entrenador personal y se queda en casa:

generador.save("models/generador_creatividades_v1.keras")

# Uso posterior: nuevas imagenes desde ruido
gen = tf.keras.models.load_model("models/generador_creatividades_v1.keras")
nuevas = gen(tf.random.normal([8, DIM_RUIDO]), training=False)
nuevas = ((nuevas + 1) / 2).numpy()   # a [0,1]: el "des-preprocesado" viaja documentado (06-05)

Informe honesto para marketing: «dominamos la mecánica generativa y podemos producir imágenes sintéticas de baja resolución; para creatividades en calidad de producción se necesitan modelos de difusión (08-03) y un análisis previo de implicaciones éticas y de derechos (08-01) — toda imagen sintética publicada debe identificarse como tal».

Errores Comunes y Consejos

  • Entrenar el discriminador hasta la perfección «primero»: un discriminador perfecto da gradiente casi nulo al generador. Deben crecer juntos: un paso cada uno, como en nuestro bucle.
  • Cambiar el ruido de la rejilla en cada época: sin ruido fijo no puedes saber si mejora el generador o solo cambió la muestra. El ruido fijo es tu conjunto congelado particular (06-05).
  • Interpretar las pérdidas como en un clasificador: que la pérdida del generador suba no significa que empeore — quizá el discriminador mejoró. Rejillas primero.
  • Olvidar drop_remainder=True: un último lote de tamaño distinto puede romper formas dentro de @tf.function.
  • Desesperar en la época 10: las GAN son lentas en arrancar y no lineales en mejorar. Juzga cada 5-10 épocas, con checkpoints para volver al mejor punto.

Ejercicios

  1. Añade suavizado de etiquetas al discriminador (reales = 0.9 en lugar de 1.0) y compara la estabilidad de las curvas y las rejillas con la versión original durante 20 épocas.
  2. Entrena con DIM_RUIDO = 2 y visualiza qué genera el modelo recorriendo una malla de valores del ruido (por ejemplo, de -2 a 2 en cada dimensión). ¿Qué observas sobre el espacio latente? ¿Aparece mode collapse?
  3. Interpola en el espacio latente: toma dos vectores de ruido z1, z2, genera imágenes para z = (1-t)*z1 + t*z2 con t de 0 a 1 en 8 pasos, y muestra la transición. Relación con los embeddings de 03-04.

Soluciones

  1. Cambia en perdida_discriminador: bce(tf.ones_like(logits_reales) * 0.9, logits_reales). Efecto típico: la pérdida de D deja de hundirse hacia 0, la de G oscila menos y las rejillas progresan de forma más estable — es uno de los remedios de 05-01 en acción.
  2. Con solo 2 dimensiones latentes, la malla [(x, y) for x in np.linspace(-2,2,8) for y in np.linspace(-2,2,8)] genera una rejilla donde regiones vecinas producen prendas parecidas: visualizas el espacio latente entero. La diversidad total baja (2 dimensiones dan poco margen) y el riesgo de collapse aumenta: verás pocas clases de prenda representadas. Conclusión: la dimensión del ruido acota cuánta variedad puede codificar el generador.
  3. for t in np.linspace(0, 1, 8): imgs.append(gen((1-t)*z1 + t*z2, training=False)). La transición es suave: una camiseta se transforma gradualmente en un pantalón pasando por formas intermedias plausibles. Como en los embeddings de 03-04, la cercanía en el espacio latente codifica similitud semántica — el generador ha organizado el ruido en un mapa continuo de prendas.

Conclusión

Cuarto proyecto entregado y la promesa más antigua del curso saldada: el bucle adversario completo, escrito a mano con dos GradientTape, dos pérdidas y dos optimizadores, con rejillas de ruido fijo como instrumento de evaluación y un diagnóstico práctico de la dinámica GAN (equilibrio, no convergencia; mode collapse y sus remedios). El resultado es un prototipo honesto — prendas reconocibles, no fotografía de producto — y un informe claro de qué haría falta para producción. Queda un proyecto, y es el que cierra el círculo: en 07-05 volveremos al clasificador de imágenes de 07-01 y batiremos su 85 % con transfer learning, comparando cifras cara a cara para tomar la decisión final 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