💻 Tutorial paso a paso

Entrenando una GAN con TensorFlow

Construiremos una Generative Adversarial Network desde cero con TensorFlow / Keras: diseñaremos el Generador y el Discriminador, implementaremos un training loop personalizado con tf.GradientTape, visualizaremos la evolución del entrenamiento y escalaremos a una DCGAN con convoluciones. Cada línea de código está explicada.

⏱️ ~60 min 📊 Nivel: intermedio 🔥 TensorFlow 2.x · Keras · GANs · DCGAN · MNIST

Requisitos previos

  • Python 3.9+ y TensorFlow 2.x instalados
  • Conceptos básicos de redes neuronales: forward pass, loss, backpropagation
  • Haber leído la teoría de GANs (juego minimax, generador, discriminador)
  • Familiaridad con convoluciones (para la parte de DCGAN)
  • Opcional: GPU con CUDA (acelera el entrenamiento significativamente)
1

¿Qué vamos a construir?

Vamos a implementar una Generative Adversarial Network (GAN) completa desde cero con TensorFlow / Keras. Una GAN consta de dos redes neuronales que compiten: un Generador (G) que crea imágenes falsas a partir de ruido aleatorio, y un Discriminador (D) que intenta distinguir las imágenes reales de las generadas. Este juego adversarial fue propuesto por Ian Goodfellow et al. (2014) y revolucionó la IA generativa. En este tutorial usaremos la API de bajo nivel tf.GradientTape para tener control total del training loop.

Ruido z tf.random.normal Generador G z → imagen falsa fake 🖼️ 🖼️ Dataset real Discriminador D imagen → real/fake? Real? Fake? ← tf.GradientTape: gradientes de entrenamiento →

1.1 El juego en una frase

El Generador quiere engañar al Discriminador produciendo imágenes lo más realistas posible. El Discriminador quiere no ser engañado, clasificando correctamente cada imagen como real o generada. Cuando este juego se equilibra, el Generador produce imágenes indistinguibles de las reales.

1.2 Lo que construiremos

1.3 Nuestro plan

  1. Setup — Instalación, imports, configuración de hiperparámetros y strategy.
  2. Dataset — Cargar MNIST con tf.data, normalizar y crear pipelines.
  3. Generador — Red Dense que transforma ruido z en una imagen 28×28.
  4. Discriminador — Red Dense que clasifica imágenes como reales o falsas.
  5. Training looptf.GradientTape con Binary Cross-Entropy, alternancia D/G.
  6. Visualización — Grids por época, curvas de loss, interpolación del espacio latente.
  7. DCGAN — Upgrade completo con convoluciones.
  8. Debugging — Problemas reales y cómo resolverlos.
  9. Referencias — Papers, repos, documentación y siguientes pasos.
💡 Dataset elegido: MNIST. Usamos MNIST (dígitos 28×28 en escala de grises) porque es rápido de entrenar (~5 min en GPU, ~20 min en CPU), produce resultados visibles rápidamente y permite centrarse en la arquitectura GAN sin complicaciones de datasets grandes. Los mismos conceptos se aplican a CIFAR-10, CelebA o cualquier otro dataset.
2

Setup: instalación, imports y configuración

2.1 Instalación

Terminal instalar dependencias
# CPU (funciona en cualquier máquina)
pip install tensorflow matplotlib

# GPU CUDA 12.x (TensorFlow 2.16+)
pip install tensorflow[and-cuda] matplotlib

# Verificar GPU
python -c "import tensorflow as tf; print(tf.config.list_physical_devices('GPU'))"

2.2 Imports

Python gan_mnist_tf.py
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
import matplotlib.pyplot as plt
import numpy as np
import os
import time

# Reproducibilidad
SEED = 42
tf.random.set_seed(SEED)
np.random.seed(SEED)

# Verificar GPU
print(f"TensorFlow {tf.__version__}")
gpus = tf.config.list_physical_devices('GPU')
if gpus:
    print(f"GPU disponible: {gpus[0].name}")
    # Evitar que TF acapare toda la VRAM
    for gpu in gpus:
        tf.config.experimental.set_memory_growth(gpu, True)
else:
    print("Usando CPU")
L1-3 Importamos TensorFlow, Keras (integrado en tf desde 2.x) y la capa layers para construir las redes.
L11 tf.random.set_seed() fija la seed global de TensorFlow para reproducibilidad.
L21-22 set_memory_growth(True) hace que TF asigne VRAM de forma incremental en vez de reservar toda la memoria de la GPU al inicio. Crucial si compartes la GPU con otros procesos.
Salida esperada TensorFlow 2.16.1
GPU disponible: /physical_device:GPU:0

2.3 Hiperparámetros

Centralizamos todos los hiperparámetros al inicio del script. Esto facilita la experimentación: cambiar un valor aquí afecta a todo el pipeline.

Python hiperparámetros
# ── Hiperparámetros ──────────────────────────────────────
LATENT_DIM = 100       # Dimensión del vector de ruido z
IMG_SIZE = 28          # MNIST: 28x28
IMG_CHANNELS = 1       # Escala de grises
IMG_PIXELS = IMG_SIZE * IMG_SIZE * IMG_CHANNELS  # 784

BATCH_SIZE = 256       # TF rinde mejor con batches más grandes
EPOCHS = 50
LR_G = 2e-4            # Learning rate del Generador
LR_D = 2e-4            # Learning rate del Discriminador
BETA_1 = 0.5           # Adam beta1 (estándar en GANs)

# Directorio para guardar resultados
os.makedirs("gan_results_tf", exist_ok=True)
L2 LATENT_DIM = 100 — el tamaño del vector de ruido que alimenta al Generador. 100 es el valor estándar desde el paper original. Valores típicos: 64-256.
L7 BATCH_SIZE = 256 — TensorFlow con GPU aprovecha mejor batches grandes que PyTorch en general. En CPU, 128 o 64 funcionan igual.
L9-11 LR = 2e-4 y BETA_1 = 0.5 — estos valores vienen del paper de DCGAN (Radford et al., 2016) y son el estándar de facto para entrenar GANs con Adam.

El optimizador Adam con beta_1=0.5 (en vez del default 0.9) fue identificado como clave en el paper de DCGAN. La razón:

  • beta_1=0.9 lleva demasiado momentum, lo que causa oscilaciones en el entrenamiento adversarial.
  • beta_1=0.5 reduce el momentum, estabilizando las actualizaciones del Generador y Discriminador.
  • En TensorFlow: tf.keras.optimizers.Adam(learning_rate=2e-4, beta_1=0.5).
  • Alternativa: SGD funciona pero converge mucho más lento. RMSprop es otra opción popular (usado en WGAN).

Ambos frameworks son igualmente capaces, pero difieren en estilo:

  • Training loop: TF usa tf.GradientTape (eager mode) vs PyTorch usa loss.backward() + optimizer.step().
  • Modelos: TF usa keras.Sequential o Functional API vs PyTorch usa nn.Module.
  • Data pipeline: TF usa tf.data.Dataset (lazy, paralelizable) vs PyTorch usa DataLoader.
  • Gradientes: En TF hay que envolver el forward pass en GradientTape explícitamente. En PyTorch el grafo se construye automáticamente y se usa .detach() para cortarlo.
  • Formato de imagen: TF usa (B, H, W, C) channels-last por defecto vs PyTorch usa (B, C, H, W) channels-first.
3

Dataset: preparar MNIST

Cargamos el dataset MNIST y lo normalizamos al rango [-1, 1]. Este rango es clave: la última capa del Generador usará tanh, que produce valores en [-1, 1], así que las imágenes reales deben estar en el mismo rango. Usaremos tf.data.Dataset para crear un pipeline de datos eficiente con prefetch y shuffle.

Python cargar MNIST con tf.data
# ── Cargar MNIST ─────────────────────────────────────────
(x_train, _), (_, _) = keras.datasets.mnist.load_data()

# Normalizar [0, 255] → [-1, 1] y añadir dimensión de canal
x_train = x_train.astype("float32")
x_train = (x_train - 127.5) / 127.5           # [-1, 1]
x_train = x_train.reshape(-1, IMG_SIZE, IMG_SIZE, IMG_CHANNELS)

print(f"Dataset: {x_train.shape[0]:,} imágenes")
print(f"Shape: {x_train.shape}")
print(f"Rango: [{x_train.min():.1f}, {x_train.max():.1f}]")

# ── Crear tf.data pipeline ───────────────────────────────
BUFFER_SIZE = x_train.shape[0]  # 60,000

train_dataset = (
    tf.data.Dataset.from_tensor_slices(x_train)
    .shuffle(BUFFER_SIZE, seed=SEED)
    .batch(BATCH_SIZE, drop_remainder=True)
    .prefetch(tf.data.AUTOTUNE)
)
L2 keras.datasets.mnist.load_data() devuelve arrays NumPy. No necesitamos torchvision ni transforms — TF incluye datasets directamente.
L6 Normalizamos con (x - 127.5) / 127.5, equivalente a Normalize((0.5,), (0.5,)) de torchvision pero en un solo paso.
L7 TensorFlow usa channels-last: (B, H, W, C) = (60000, 28, 28, 1). En PyTorch sería (B, C, H, W).
L17-20 El pipeline tf.data: .shuffle() mezcla en un buffer, .batch() crea batches (descartando el último incompleto), y .prefetch(AUTOTUNE) prepara el siguiente batch en paralelo mientras la GPU entrena con el actual.
Salida esperada Dataset: 60,000 imágenes
Shape: (60000, 28, 28, 1)
Rango: [-1.0, 1.0]

3.1 Visualizar muestras reales

Siempre es buena práctica visualizar los datos antes de entrenar. Creamos una función reutilizable:

Python función de visualización
def show_images(images, nrow=8, title=""):
    """Muestra un grid de imágenes (tensor normalizado a [-1,1])."""
    images = (images + 1) / 2  # [-1,1] → [0,1]
    images = np.clip(images, 0, 1)

    n = min(len(images), nrow * nrow)
    fig, axes = plt.subplots(nrow, nrow, figsize=(10, 10))
    for i, ax in enumerate(axes.flat):
        if i < n:
            ax.imshow(images[i].squeeze(), cmap='gray')
        ax.axis('off')
    fig.suptitle(title, fontsize=14)
    plt.tight_layout()
    plt.show()

# Visualizar un batch de imágenes reales
real_batch = next(iter(train_dataset)).numpy()[:64]
show_images(real_batch, title="MNIST — Imágenes reales")
L3 Desnormalizamos de [-1,1] a [0,1] para que matplotlib las muestre correctamente.
L10 .squeeze() elimina la dimensión de canal (28,28,1) → (28,28) para imshow.
L17 next(iter(train_dataset)) obtiene el primer batch. .numpy() convierte el tensor TF a NumPy.
💡 ¿Por qué [-1, 1] y no [0, 1]? La activación tanh del Generador produce valores en [-1, 1] de forma natural, con gradientes saludables alrededor de 0. Si usáramos [0, 1] con sigmoid, los gradientes se saturan en los extremos (0 y 1), dificultando el aprendizaje.

tf.data.Dataset es el equivalente a DataLoader de PyTorch, pero con diferencias importantes:

  • Lazy evaluation: tf.data no carga todo en memoria; aplica transformaciones sobre la marcha.
  • Prefetch: .prefetch(AUTOTUNE) prepara datos en paralelo con el entrenamiento. En PyTorch se logra con num_workers.
  • Cache: Puedes añadir .cache() para mantener datos en memoria o disco tras la primera época.
  • Interleave: .interleave() permite leer múltiples archivos en paralelo (útil con TFRecords).
  • Para MNIST es equivalente, pero para datasets grandes tf.data con TFRecords es más escalable.

Sí. Cambia solo la línea del dataset y ajusta los hiperparámetros:

  • Fashion-MNIST: keras.datasets.fashion_mnist — misma estructura que MNIST pero con ropa (más difícil).
  • CIFAR-10: keras.datasets.cifar10 — 32×32, 3 canales RGB. Ajusta IMG_SIZE=32 y IMG_CHANNELS=3.
  • CelebA: Usa tf.keras.utils.image_dataset_from_directory() o tfds.load('celeb_a').
  • Custom: tf.keras.utils.image_dataset_from_directory('path/to/images') para cualquier carpeta de imágenes.

Para datasets más complejos, recomendamos la DCGAN del paso 8 desde el inicio.

4

El Generador: de ruido a imágenes

El Generador toma un vector de ruido z ∈ ℝ¹⁰⁰ muestreado de una distribución normal estándar y lo transforma en una imagen de 28×28 píxeles. En TensorFlow/Keras, usamos keras.Sequential con capas Dense, LeakyReLU y BatchNormalization.

z (100,) Dense 256 LeakyReLU+BN Dense 512 LeakyReLU+BN Dense 1024 LeakyReLU+BN Dense 784 tanh 🖼️ 28×28
Python Generador (Dense)
def build_generator(latent_dim=LATENT_DIM):
    """
    Generador Dense: z (100,) → imagen (28, 28, 1).
    Arquitectura: Dense → LeakyReLU → BN, repetido, → tanh.
    """
    model = keras.Sequential([
        # Input
        layers.Input(shape=(latent_dim,)),

        # Bloque 1: 100 → 256
        layers.Dense(256),
        layers.LeakyReLU(0.2),
        layers.BatchNormalization(momentum=0.8),

        # Bloque 2: 256 → 512
        layers.Dense(512),
        layers.LeakyReLU(0.2),
        layers.BatchNormalization(momentum=0.8),

        # Bloque 3: 512 → 1024
        layers.Dense(1024),
        layers.LeakyReLU(0.2),
        layers.BatchNormalization(momentum=0.8),

        # Capa de salida: 1024 → 784 → reshape a (28, 28, 1)
        layers.Dense(IMG_PIXELS, activation='tanh'),
        layers.Reshape((IMG_SIZE, IMG_SIZE, IMG_CHANNELS)),
    ], name='generator')

    return model

# Crear generador
G = build_generator()
G.summary()

# Verificar shape
z_test = tf.random.normal([4, LATENT_DIM])
fake_test = G(z_test, training=False)
print(f"\nGenerador - Input: {z_test.shape} → Output: {fake_test.shape}")
L6 En Keras usamos Sequential — equivalente a nn.Sequential de PyTorch pero con Input layer explícito para definir el shape.
L12 LeakyReLU(0.2) — en Keras es una capa separada, no un argumento de Dense. El slope de 0.2 es estándar en GANs.
L13 BatchNormalization(momentum=0.8) — en TF el momentum es 1 - decay. El default es 0.99; reducirlo a 0.8 hace que las estadísticas se adapten más rápido, útil en GANs.
L26-27 activation='tanh' produce output en [-1,1]. El Reshape convierte el vector de 784 a imagen (28,28,1). En PyTorch haríamos flat.view(-1, 1, 28, 28).
L38 training=False es necesario porque BatchNormalization se comporta diferente en modo train (usa stats del batch) vs inference (usa running stats).
Salida esperada Model: "generator"
Total params: 1,069,072 (4.08 MB)
Trainable params: 1,066,240
Non-trainable params: 2,832

Generador - Input: (4, 100) → Output: (4, 28, 28, 1)
💡 ¿Por qué BatchNormalization en el Generador? Sin BN, las activaciones internas pueden crecer o colapsar, haciendo que el Generador genere siempre la misma imagen (mode collapse) o que el training no converja. BN mantiene las activaciones centradas y con varianza controlada. Nota: los parámetros "Non-trainable" son los moving mean/variance de cada BN.

TensorFlow/Keras ofrece tres formas de construir modelos:

  • Sequential: Lo usamos aquí. Sencillo, ideal para stacks lineales. Equivalente a nn.Sequential de PyTorch.
  • Functional API: Para grafos no lineales (múltiples inputs/outputs, skip connections). Ejemplo: x = layers.Dense(256)(input_layer).
  • Model subclassing: Máxima flexibilidad, similar a heredar nn.Module. Escribes __init__ + call() en vez de forward().

Para una GAN vanilla, Sequential es perfecto. Para arquitecturas más complejas (cGAN, StyleGAN), usarías Functional o subclassing.

El equivalente con subclassing (más parecido al estilo PyTorch):

class Generator(keras.Model):

  def __init__(self):

    super().__init__()

    self.dense1 = layers.Dense(256)

    self.lrelu1 = layers.LeakyReLU(0.2)

    self.bn1 = layers.BatchNormalization()

    # ... más capas ...

  def call(self, z, training=False):

    x = self.bn1(self.lrelu1(self.dense1(z)), training=training)

    # ...

El concepto es idéntico. Usamos Sequential por simplicidad.

5

El Discriminador: real vs fake

El Discriminador es un clasificador binario: recibe una imagen y produce una probabilidad de que sea real (cercana a 1) o generada (cercana a 0). En Keras usamos capas Dense con LeakyReLU y Dropout como regularización.

🖼️ 28×28×1 Flatten Dense 512 LReLU+Drop Dense 256 LReLU+Drop Dense 128 LReLU+Drop Dense 1 sigmoid p [0, 1]
Python Discriminador (Dense)
def build_discriminator():
    """
    Discriminador Dense: imagen (28, 28, 1) → probabilidad [0, 1].
    Arquitectura: Flatten → Dense → LeakyReLU → Dropout, repetido, → sigmoid.
    """
    model = keras.Sequential([
        layers.Input(shape=(IMG_SIZE, IMG_SIZE, IMG_CHANNELS)),

        # Flatten: (28, 28, 1) → (784,)
        layers.Flatten(),

        # Bloque 1: 784 → 512
        layers.Dense(512),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        # Bloque 2: 512 → 256
        layers.Dense(256),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        # Bloque 3: 256 → 128
        layers.Dense(128),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        # Salida: 128 → 1 (probabilidad)
        layers.Dense(1, activation='sigmoid'),
    ], name='discriminator')

    return model

# Crear discriminador
D = build_discriminator()
D.summary()

# Verificar shape
pred_test = D(fake_test, training=False)
print(f"\nDiscriminador - Input: {fake_test.shape} → Output: {pred_test.shape}")
L7 Input(shape=(28, 28, 1)) — nótese el formato channels-last de TensorFlow. En PyTorch sería (1, 28, 28).
L15 Dropout(0.3) — regularización crucial en el Discriminador. Sin Dropout, D aprende demasiado rápido y el Generador no puede seguirle el ritmo.
L28 activation='sigmoid' en la última capa. Output en [0, 1]: 1 = "creo que es real", 0 = "creo que es fake".
L38 training=False desactiva Dropout (y BN si la hubiera). Cuando entrenemos, pasaremos training=True dentro del GradientTape.
Salida esperada Model: "discriminator"
Total params: 533,249 (2.03 MB)
Trainable params: 533,249

Discriminador - Input: (4, 28, 28, 1) → Output: (4, 1)
⚠️ NO uses BatchNormalization en el Discriminador (vanilla GAN). A diferencia del Generador, en el Discriminador vanilla usamos Dropout como regularizador en vez de BatchNorm. ¿Por qué? Porque BatchNorm introduce dependencias entre las muestras del batch, y el D necesita evaluar cada imagen individualmente. En la DCGAN del paso 8 sí la usaremos, pero sin BN en la primera y última capa.

5.1 Loss y optimizadores

Definimos la loss y los optimizadores por separado para G y D. En TensorFlow, usamos tf.keras.losses.BinaryCrossentropy y tf.keras.optimizers.Adam.

Python loss y optimizadores
# ── Loss: Binary Cross-Entropy ───────────────────────────
cross_entropy = keras.losses.BinaryCrossentropy(from_logits=False)

# ── Optimizadores separados para G y D ───────────────────
optimizer_G = keras.optimizers.Adam(
    learning_rate=LR_G, beta_1=BETA_1
)
optimizer_D = keras.optimizers.Adam(
    learning_rate=LR_D, beta_1=BETA_1
)

# ── Vector z fijo para visualización ─────────────────────
fixed_noise = tf.random.normal([64, LATENT_DIM])
L2 from_logits=False porque nuestro D ya tiene sigmoid. Si usáramos from_logits=True, podríamos quitar el sigmoid de D (más estable numéricamente — lo veremos en el collapsible).
L5-9 Cada red tiene su propio optimizador con beta_1=0.5. En TF el parámetro se llama beta_1, en PyTorch es betas=(0.5, 0.999).
L12 fixed_noise — siempre generamos imágenes a partir del mismo ruido para comparar la evolución del Generador entre épocas.
RedCapaParámetros
GeneradorDense(100, 256) + BN26,368
Dense(256, 512) + BN132,096
Dense(512, 1024) + BN526,336
Dense(1024, 784)803,600
Total G~1.07M (2,832 non-trainable)
DiscriminadorDense(784, 512)401,920
Dense(512, 256)131,328
Dense(256, 128)32,896
Dense(128, 1)129
Total D~533K

G es ~2x más grande que D. Esto es normal: G tiene que aprender a generar (tarea difícil), D solo tiene que clasificar (tarea más sencilla).

6

Training loop con GradientTape

Este es el corazón de toda GAN: el algoritmo de entrenamiento alternante. En TensorFlow, usamos tf.GradientTape para grabar las operaciones del forward pass y luego calcular los gradientes. En cada iteración del batch, primero entrenamos el Discriminador y luego el Generador.

FASE 1: Entrenar D ① D(real) → 1 loss_real = BCE(D(x), 1) ② D(fake) → 0 loss_fake = BCE(D(G(z)), 0) loss_D = (loss_real + loss_fake) / 2 tape_d.gradient(loss_D, D.trainable_variables) optimizer_D.apply_gradients(...) FASE 2: Entrenar G ③ G quiere que D(fake) → 1 loss_G = BCE(D(G(z)), 1) El truco: usamos label = 1 (real), para que el gradiente empuje a G a generar imágenes que D clasifique como reales. tape_g.gradient(loss_G, G.trainable_variables)

6.1 Funciones de loss

Python funciones de loss
def discriminator_loss(real_output, fake_output):
    """Loss del Discriminador: real → 1, fake → 0."""
    real_loss = cross_entropy(tf.ones_like(real_output), real_output)
    fake_loss = cross_entropy(tf.zeros_like(real_output), fake_output)
    return (real_loss + fake_loss) / 2

def generator_loss(fake_output):
    """Loss del Generador: D(fake) debe dar 1."""
    return cross_entropy(tf.ones_like(fake_output), fake_output)
L3 tf.ones_like(real_output) crea un tensor de 1s con la misma forma que la salida de D. D debería asignar 1 (="real") a las imágenes reales.
L4 tf.zeros_like — tensor de 0s. D debería asignar 0 (="fake") a las imágenes generadas.
L9 El truco "non-saturating": en vez de minimizar log(1 - D(G(z))), maximizamos log(D(G(z))) usando labels = 1. Esto produce gradientes más fuertes para G.

6.2 Paso de entrenamiento con @tf.function

Python train_step con GradientTape
@tf.function
def train_step(real_images):
    """Un paso de entrenamiento: actualiza D y G."""
    batch_size = tf.shape(real_images)[0]
    noise = tf.random.normal([batch_size, LATENT_DIM])

    # ═══════════════════════════════════════════════════
    # FASE 1: Entrenar el Discriminador
    # ═══════════════════════════════════════════════════
    with tf.GradientTape() as tape_d:
        # Generar fakes
        fake_images = G(noise, training=True)

        # D evalúa reales y fakes
        real_output = D(real_images, training=True)
        fake_output = D(fake_images, training=True)

        # Loss de D
        loss_d = discriminator_loss(real_output, fake_output)

    # Calcular gradientes SOLO respecto a D
    grads_d = tape_d.gradient(loss_d, D.trainable_variables)
    optimizer_D.apply_gradients(zip(grads_d, D.trainable_variables))

    # ═══════════════════════════════════════════════════
    # FASE 2: Entrenar el Generador
    # ═══════════════════════════════════════════════════
    noise = tf.random.normal([batch_size, LATENT_DIM])

    with tf.GradientTape() as tape_g:
        fake_images = G(noise, training=True)
        fake_output = D(fake_images, training=True)
        loss_g = generator_loss(fake_output)

    # Calcular gradientes SOLO respecto a G
    grads_g = tape_g.gradient(loss_g, G.trainable_variables)
    optimizer_G.apply_gradients(zip(grads_g, G.trainable_variables))

    return loss_d, loss_g, tf.reduce_mean(real_output), tf.reduce_mean(fake_output)
L1 @tf.function compila la función en un grafo TF, acelerando la ejecución ~2-5x. Sin esto, TF ejecuta en modo eager (más lento pero más fácil de depurar).
L10 tf.GradientTape() — graba todas las operaciones dentro del bloque para poder calcular gradientes después. Es el equivalente al autograd de PyTorch.
L21-22 tape_d.gradient(loss_d, D.trainable_variables) calcula los gradientes de la loss SOLO respecto a los pesos de D. No necesitamos detach() como en PyTorch — el tape solo registra las variables que le pedimos.
L28 Generamos nuevo ruido para la fase de G. Esto es un detalle: en PyTorch reutilizamos las fakes con detach(), aquí simplemente generamos nuevas.
L30-31 El segundo GradientTape registra G → D → loss_g. Los gradientes fluyen a través de D hasta los parámetros de G. Como pedimos G.trainable_variables, solo se actualizan los pesos de G.
💡 GradientTape vs .backward() — la diferencia clave: En PyTorch, el grafo computacional se construye automáticamente y usas .detach() para cortarlo. En TensorFlow, el tape solo registra lo que está dentro de su bloque with, y solo calcula gradientes respecto a las variables que especifiques. Ambos logran lo mismo, pero la filosofía es opuesta: PyTorch construye y luego corta, TensorFlow solo graba lo que necesita.

6.3 El loop de entrenamiento

Python training loop principal
# ── Logging ──────────────────────────────────────────────
G_losses, D_losses = [], []
D_real_acc, D_fake_acc = [], []

print("Iniciando entrenamiento...")
start_time = time.time()

for epoch in range(EPOCHS):
    g_loss_epoch, d_loss_epoch = 0.0, 0.0
    d_real_epoch, d_fake_epoch = 0.0, 0.0
    n_batches = 0

    for real_batch in train_dataset:
        loss_d, loss_g, d_real, d_fake = train_step(real_batch)

        g_loss_epoch += loss_g.numpy()
        d_loss_epoch += loss_d.numpy()
        d_real_epoch += d_real.numpy()
        d_fake_epoch += d_fake.numpy()
        n_batches += 1

    # ── Promedios del epoch ──────────────────────────────
    G_losses.append(g_loss_epoch / n_batches)
    D_losses.append(d_loss_epoch / n_batches)
    D_real_acc.append(d_real_epoch / n_batches)
    D_fake_acc.append(d_fake_epoch / n_batches)

    elapsed = time.time() - start_time
    if (epoch + 1) % 5 == 0 or epoch == 0:
        print(f"Epoch [{epoch+1:3d}/{EPOCHS}] | "
              f"D_loss: {D_losses[-1]:.4f} | G_loss: {G_losses[-1]:.4f} | "
              f"D(x): {D_real_acc[-1]:.3f} | D(G(z)): {D_fake_acc[-1]:.3f} | "
              f"Time: {elapsed:.0f}s")

    # ── Guardar grid cada 10 epochs ──────────────────────
    if (epoch + 1) % 10 == 0 or epoch == 0:
        samples = G(fixed_noise, training=False).numpy()
        samples = (samples + 1) / 2  # [-1,1] → [0,1]
        fig, axes = plt.subplots(8, 8, figsize=(8, 8))
        for i, ax in enumerate(axes.flat):
            ax.imshow(samples[i].squeeze(), cmap='gray')
            ax.axis('off')
        fig.suptitle(f'Epoch {epoch+1}', fontsize=14)
        plt.tight_layout()
        plt.savefig(f'gan_results_tf/epoch_{epoch+1:03d}.png',
                    bbox_inches='tight', dpi=100)
        plt.close()

print(f"\n✓ Entrenamiento completo en {time.time()-start_time:.0f}s")
L13 Iteramos directamente sobre train_dataset — el pipeline tf.data maneja el shuffling y batching automáticamente.
L16 .numpy() convierte tensores TF a escalares. Esto fuerza la ejecución (materializa el valor) — es O(1) para escalares.
L37 training=False para generar imágenes limpias (sin dropout/BN en modo train).
Salida esperada Iniciando entrenamiento...
Epoch [ 1/50] | D_loss: 0.4523 | G_loss: 2.1234 | D(x): 0.812 | D(G(z)): 0.298 | Time: 8s
Epoch [ 5/50] | D_loss: 0.5612 | G_loss: 1.6523 | D(x): 0.763 | D(G(z)): 0.325 | Time: 38s
Epoch [ 10/50] | D_loss: 0.6234 | G_loss: 1.2845 | D(x): 0.721 | D(G(z)): 0.387 | Time: 75s
Epoch [ 25/50] | D_loss: 0.6612 | G_loss: 1.0123 | D(x): 0.672 | D(G(z)): 0.448 | Time: 188s
Epoch [ 50/50] | D_loss: 0.6867 | G_loss: 0.8523 | D(x): 0.638 | D(G(z)): 0.486 | Time: 375s
✓ Entrenamiento completo en 375s
💡 ¿Cómo sé que va bien? Señales de un training saludable:
  • D(x) baja gradualmente desde ~0.9 hacia ~0.5-0.7 (D ya no está seguro de las reales).
  • D(G(z)) sube gradualmente desde ~0.1 hacia ~0.4-0.5 (D confunde las fakes con reales).
  • D_loss se estabiliza alrededor de ln(2) ≈ 0.693 (equilibrio de Nash).
  • G_loss baja progresivamente (G mejora generando).

En PyTorch, cuando entrenas D:

output_fake = D(fake_imgs.detach())

El .detach() corta el grafo para que los gradientes no fluyan a G.

En TensorFlow:

with tf.GradientTape() as tape_d:

  fake_images = G(noise, training=True)

  fake_output = D(fake_images, training=True)

grads_d = tape_d.gradient(loss_d, D.trainable_variables)

No necesitamos detach() porque pedimos gradientes solo respecto a D.trainable_variables. El tape calcula exactamente los gradientes que necesitamos sin ambigüedad.

Para la fase de G:

grads_g = tape_g.gradient(loss_g, G.trainable_variables)

Los gradientes fluyen: loss_g → D → G, pero solo se actualizan los pesos de G.

@tf.function convierte la función Python a un grafo TensorFlow optimizado. Los beneficios:

  • Speedup 2-5x: Elimina overhead de Python, fusiona operaciones, optimiza memoria.
  • XLA compilation: Puedes añadir @tf.function(jit_compile=True) para compilar con XLA, otro 20-50% de speedup en GPU.
  • Tracing: La primera llamada es lenta (tracing), las siguientes son rápidas (reutiliza el grafo).
  • Advertencia: No usar operaciones Python puras (print, if/else basados en valores de tensores) dentro de @tf.function. Usa tf.print y tf.cond en su lugar.

Para depurar, comenta el @tf.function y ejecuta en modo eager.

BinaryCrossentropy(from_logits=True) combina sigmoid + BCE en una sola operación, con mejor estabilidad numérica (evita log(0)):

  • Quita activation='sigmoid' de la última capa de D.
  • Cambia a keras.losses.BinaryCrossentropy(from_logits=True).
  • El output de D serán logits (no probabilidades), pero la loss se calcula correctamente internamente.

Es la práctica recomendada para producción. Con MNIST la diferencia es mínima, pero con datasets más difíciles previene NaN.

7

Visualización y monitorización

En una GAN, mirar solo la loss no basta. Necesitas vigilar las curvas de loss de ambas redes, la evolución visual de las generaciones y las métricas de confianza del Discriminador.

7.1 Curvas de loss

Python graficar losses
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# ── Panel 1: Losses ──
axes[0].plot(G_losses, label='Generator', color='#e84393', linewidth=2)
axes[0].plot(D_losses, label='Discriminator', color='#fd79a8', linewidth=2)
axes[0].axhline(y=np.log(2), color='rgba(253,203,110,0.5)',
                linestyle='--', label=f'Equilibrio (ln2≈{np.log(2):.3f})')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].set_title('GAN Losses')
axes[0].legend()
axes[0].grid(alpha=0.15)

# ── Panel 2: Confianza de D ──
axes[1].plot(D_real_acc, label='D(x) — reales', color='#00b894', linewidth=2)
axes[1].plot(D_fake_acc, label='D(G(z)) — fakes', color='#e17055', linewidth=2)
axes[1].axhline(y=0.5, color='rgba(255,255,255,0.2)',
                linestyle='--', label='Equilibrio (0.5)')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Probabilidad media')
axes[1].set_title('Confianza del Discriminador')
axes[1].legend()
axes[1].grid(alpha=0.15)
axes[1].set_ylim(0, 1)

plt.tight_layout()
plt.savefig('gan_results_tf/training_curves.png', dpi=150, bbox_inches='tight')
plt.show()
💡 Interpretación de las curvas:
  • Caso ideal: D_loss se estabiliza en ~0.693 (ln2), G_loss baja gradualmente. D(x) y D(G(z)) convergen hacia 0.5.
  • D domina: D_loss → 0, D(x) → 1, D(G(z)) → 0. G no puede aprender. Solución: reducir capacidad de D, label smoothing, entrenar G más pasos.
  • Mode collapse: G_loss oscila bruscamente, imágenes generadas iguales. Solución: paso 9.

7.2 Evolución visual de las generaciones

Python comparar generaciones entre épocas
def show_evolution(generator, noise):
    """Genera imágenes con el mismo ruido y muestra un grid."""
    samples = generator(noise, training=False).numpy()
    samples = (samples + 1) / 2  # [-1,1] → [0,1]

    fig, axes = plt.subplots(8, 8, figsize=(10, 10))
    for i, ax in enumerate(axes.flat):
        ax.imshow(samples[i].squeeze(), cmap='gray')
        ax.axis('off')
    fig.suptitle('Imágenes generadas (epoch final)', fontsize=14)
    plt.tight_layout()
    plt.savefig('gan_results_tf/final_generation.png', dpi=150, bbox_inches='tight')
    plt.show()

show_evolution(G, fixed_noise)

7.3 Interpolación en el espacio latente

Una forma potente de evaluar si G ha aprendido una representación suave es interpolar entre dos puntos del espacio latente. Si la transición es gradual, G ha aprendido una representación rica.

Python interpolación lineal en espacio latente
def interpolate_latent(generator, z1, z2, n_steps=10):
    """Interpola linealmente entre z1 y z2 en el espacio latente."""
    alphas = np.linspace(0, 1, n_steps)
    interpolations = []

    for alpha in alphas:
        z = (1 - alpha) * z1 + alpha * z2
        img = generator(z[tf.newaxis, ...], training=False)
        interpolations.append(img[0])

    images = np.array(interpolations)
    images = (images + 1) / 2
    return images

# Dos puntos aleatorios del espacio latente
z1 = tf.random.normal([LATENT_DIM])
z2 = tf.random.normal([LATENT_DIM])

interp = interpolate_latent(G, z1, z2, n_steps=12)

fig, axes = plt.subplots(1, 12, figsize=(15, 2))
for i, ax in enumerate(axes):
    ax.imshow(interp[i].squeeze(), cmap='gray')
    ax.axis('off')
plt.suptitle('Interpolación en espacio latente: z₁ → z₂', fontsize=12)
plt.tight_layout()
plt.savefig('gan_results_tf/interpolation.png', dpi=150, bbox_inches='tight')
plt.show()
L7 Interpolación lineal: z = (1-α)·z₁ + α·z₂. Para α=0 → z₁, para α=1 → z₂.
L8 z[tf.newaxis, ...] añade la dimensión de batch: (100,) → (1, 100). En PyTorch usaríamos z.unsqueeze(0).
💡 Interpolación esférica (slerp): Para espacios latentes gaussianos, la interpolación esférica es técnicamente más correcta que la lineal. Con MNIST la diferencia es mínima, pero con modelos grandes (StyleGAN) sí importa.

7.4 Widget: simulador de dinámicas de entrenamiento

🧪 Simulador de dinámicas GAN

Ajusta el learning rate y la capacidad del Discriminador para ver cómo afectan las curvas de loss. Observa cuándo D domina, cuándo hay equilibrio y cuándo el entrenamiento se vuelve inestable.

Para evaluar formalmente la calidad de una GAN:

  • FID (Fréchet Inception Distance): Compara la distribución de features de imágenes reales y generadas con Inception. Heusel et al., 2017
  • IS (Inception Score): Mide diversidad y calidad. Salimans et al., 2016

Para MNIST, la inspección visual es suficiente. Para CIFAR-10+ usa pip install tensorflow-gantfgan.eval.frechet_inception_distance() o el paquete pytorch-fid (también funciona con imágenes generadas por TF).

TensorFlow tiene integración nativa con TensorBoard. Puedes logear métricas en tiempo real durante el entrenamiento:

writer = tf.summary.create_file_writer('logs/gan')

with writer.as_default():

  tf.summary.scalar('g_loss', loss_g, step=epoch)

  tf.summary.scalar('d_loss', loss_d, step=epoch)

  tf.summary.image('generated', samples, step=epoch)

Luego ejecuta tensorboard --logdir=logs y abre http://localhost:6006. Es una ventaja significativa de TensorFlow sobre PyTorch (que necesita torch.utils.tensorboard como wrapper).

8

De GAN vanilla a DCGAN

La GAN vanilla con capas Dense funciona para MNIST, pero tiene limitaciones: las capas fully-connected no respetan la estructura espacial de las imágenes. La DCGAN (Radford et al., 2016) resuelve esto con una arquitectura basada en convoluciones. En TensorFlow/Keras usamos Conv2DTranspose para el Generador y Conv2D para el Discriminador.

DCGAN Generator: Conv2DTranspose (upsampling) z 100×1×1 Conv2DTranspose 256×7×7 BN + ReLU Conv2DTranspose 128×14×14 BN + ReLU Conv2DTranspose 1×28×28 tanh 🖼️ 28×28 DCGAN Disc Conv2D 28→14→7 LReLU+BN Conv2D → 1 → Sigmoid →D

8.1 Las reglas de DCGAN

El paper de DCGAN estableció 5 reglas arquitectónicas que se convirtieron en el estándar para GANs convolucionales:

#ReglaJustificación
1Reemplazar pooling con stride convolutions (D) y fractional-strided convolutions (G)La red aprende su propio downsampling/upsampling
2BatchNorm en G y D (excepto salida de G y primera capa de D)Estabiliza el entrenamiento, previene mode collapse
3Eliminar capas Dense (excepto para z → primer bloque)Las convs respetan la estructura espacial
4ReLU en G (excepto salida = tanh), LeakyReLU en DGradientes saludables en ambas redes
5Adam con lr=0.0002, β₁=0.5Momentum bajo para estabilidad adversarial

8.2 Generador DCGAN

Python Generador DCGAN
def build_dc_generator(latent_dim=LATENT_DIM):
    """
    DCGAN Generator: z (100,) → imagen (28, 28, 1).
    Usa Conv2DTranspose para upsampling progresivo.
    """
    model = keras.Sequential([
        layers.Input(shape=(latent_dim,)),

        # Proyectar y reshape: (100,) → (7, 7, 256)
        layers.Dense(7 * 7 * 256, use_bias=False),
        layers.BatchNormalization(),
        layers.ReLU(),
        layers.Reshape((7, 7, 256)),

        # (7, 7, 256) → (14, 14, 128)
        layers.Conv2DTranspose(128, kernel_size=4, strides=2,
                               padding='same', use_bias=False),
        layers.BatchNormalization(),
        layers.ReLU(),

        # (14, 14, 128) → (28, 28, 1)
        layers.Conv2DTranspose(IMG_CHANNELS, kernel_size=4, strides=2,
                               padding='same', use_bias=False,
                               activation='tanh'),
    ], name='dc_generator')

    return model

G_dc = build_dc_generator()
G_dc.summary()

# Verificar
z_test = tf.random.normal([4, LATENT_DIM])
out = G_dc(z_test, training=False)
print(f"\nDCGAN Generator: {z_test.shape} → {out.shape}")
print(f"Parámetros: {G_dc.count_params():,}")
L10 Dense(7*7*256) proyecta z a un tensor "latente" que luego hacemos Reshape a (7, 7, 256). Este es el único Dense en el Generador DCGAN.
L16-17 Conv2DTranspose(128, 4, strides=2, padding='same') — "deconvolución" con stride=2 que duplica la resolución espacial: 7×7 → 14×14. En PyTorch sería nn.ConvTranspose2d(256, 128, 4, 2, 1).
L22-24 Última capa sin BatchNorm (regla DCGAN #2). activation='tanh' para output en [-1, 1].
L10 use_bias=False — cuando usamos BatchNorm, el bias del Dense/Conv anterior es redundante (BN tiene su propio shift). Eliminarlo ahorra parámetros.
Salida esperada DCGAN Generator: (4, 100) → (4, 28, 28, 1)
Parámetros: 1,548,161

8.3 Discriminador DCGAN

Python Discriminador DCGAN
def build_dc_discriminator():
    """
    DCGAN Discriminator: imagen (28, 28, 1) → probabilidad [0, 1].
    Usa Conv2D con stride para downsampling progresivo.
    """
    model = keras.Sequential([
        layers.Input(shape=(IMG_SIZE, IMG_SIZE, IMG_CHANNELS)),

        # (28, 28, 1) → (14, 14, 128) — sin BN en primera capa
        layers.Conv2D(128, kernel_size=4, strides=2,
                      padding='same', use_bias=False),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        # (14, 14, 128) → (7, 7, 256)
        layers.Conv2D(256, kernel_size=4, strides=2,
                      padding='same', use_bias=False),
        layers.BatchNormalization(),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        # (7, 7, 256) → scalar
        layers.Flatten(),
        layers.Dense(1, activation='sigmoid'),
    ], name='dc_discriminator')

    return model

D_dc = build_dc_discriminator()
D_dc.summary()

pred = D_dc(out, training=False)
print(f"\nDCGAN Discriminator: {out.shape} → {pred.shape}")
print(f"Parámetros: {D_dc.count_params():,}")
L10-11 Primera capa Conv2D sin BatchNorm (regla DCGAN #2). strides=2 reduce 28×28 → 14×14.
L16-18 Segunda capa con BatchNorm. strides=2 reduce 14×14 → 7×7.
L22-23 Flatten() + Dense(1, sigmoid) colapsa todo a un escalar. Alternativa: usar Conv2D(1, 7) con kernel 7×7 para colapsar sin Flatten (como en el tutorial PyTorch).
Salida esperada DCGAN Discriminator: (4, 28, 28, 1) → (4, 1)
Parámetros: 214,785

8.4 Entrenar la DCGAN

El training loop es idéntico al de la GAN vanilla. Solo sustituimos G y D por las versiones convolucionales:

Python entrenar DCGAN (mismos optimizadores, modelos nuevos)
# ── Usar los modelos DCGAN ───────────────────────────────
G = G_dc
D = D_dc

optimizer_G = keras.optimizers.Adam(learning_rate=LR_G, beta_1=BETA_1)
optimizer_D = keras.optimizers.Adam(learning_rate=LR_D, beta_1=BETA_1)

# ── Reutilizar el mismo training loop del paso 6 ────────
# (copia el loop anterior, o refactorízalo en una función)
# El resultado con DCGAN típicamente:
# - Converge más rápido (~20 epochs vs ~50)
# - Produce dígitos más nítidos
# - Captura mejor los detalles espaciales (trazos, curvas)

8.5 Comparativa: Dense vs DCGAN

CriterioGAN Dense (MLP)DCGAN (Conv)
Parámetros totales~1.6M~1.76M
Estructura espacialNo la respeta (flatten + dense)Sí (convoluciones locales)
Calidad en MNISTBuena (dígitos reconocibles)Mejor (más nítida, menos ruido)
EscalabilidadNo funciona bien en 64×64+Escala a 64×64, 128×128, 256×256
Velocidad de convergencia~50 epochs para MNIST~20-30 epochs
Uso recomendadoAprendizaje, datos tabularesImágenes, producción
✅ La DCGAN es la GAN "estándar" para imágenes. Todas las GANs modernas (StyleGAN, BigGAN, etc.) heredan esta arquitectura convolucional. Si quieres generar imágenes de verdad, empieza siempre con DCGAN — nunca con Dense.

Ambas hacen lo mismo (upsampling aprendido), pero la API difiere:

AspectoTensorFlowPyTorch
Claselayers.Conv2DTransposenn.ConvTranspose2d
Orden de argsfilters, kernel, stridesin_ch, out_ch, kernel, stride, pad
Paddingpadding='same'padding=1 (manual)
Channel order(B, H, W, C) channels-last(B, C, H, W) channels-first
Bias por defectoTrueTrue

padding='same' en TF es equivalente a calcular manualmente el padding en PyTorch para que output_size = input_size × stride.

9

Problemas comunes y soluciones

Entrenar GANs es notoriamente difícil. A diferencia de un clasificador donde la loss baja y listo, en GANs la loss no es una métrica fiable de calidad. Aquí cubrimos los problemas más frecuentes y cómo diagnosticarlos y resolverlos.

9.1 Mode collapse

El mode collapse ocurre cuando el Generador colapsa a generar siempre la misma imagen (o un subconjunto pequeño), ignorando la diversidad del dataset real. Es el problema #1 de las GANs.

✓ Entrenamiento sano 3 7 1 9 0 5 2 Diversidad: todos los dígitos representados ✗ Mode collapse 1 1 1 7 1 1 1 Solo genera "1" (y algún "7"): colapso parcial

Diagnóstico:

  • Las imágenes generadas son todas (casi) iguales.
  • G_loss oscila en vez de bajar gradualmente.
  • D_loss baja a ~0 (D reconoce fácilmente el truco).

Soluciones:

  1. WGAN-GP: Reemplaza BCE por Wasserstein distance + gradient penalty. La solución más fiable.
  2. Minibatch discrimination: Añade features al D que detectan si el batch carece de diversidad.
  3. Unrolled GAN: El generador optimiza mirando varios pasos adelante de D.
  4. Feature matching: Minimizar distancia de features intermedias de D entre reales y fakes.

9.2 Training inestable / oscilaciones

SíntomaCausa probableSolución
Loss de D → 0 rápidamenteD demasiado fuerte para GReducir capacidad de D, label smoothing, más pasos de G por paso de D
Loss de G → 0 rápidamenteG encontró un exploit en DAumentar capacidad de D, entrenar D más pasos, spectral normalization
Losses oscilan sin convergerLearning rate demasiado altoReducir LR a 1e-4 o 5e-5, usar keras.optimizers.schedules
NaN en la losslog(0) en BCEUsar BinaryCrossentropy(from_logits=True), gradient clipping
Imágenes borrosasG sin suficiente capacidadMás filtros, usar DCGAN, más capas
Checkerboard artifactsConv2DTranspose con stride desalineadoUsar UpSampling2D + Conv2D en vez de Conv2DTranspose

9.3 Técnicas de estabilización

9.4 Implementar label smoothing

La técnica más fácil de implementar. Solo cambiamos los labels:

Python label smoothing en TensorFlow
def discriminator_loss_smooth(real_output, fake_output):
    """Loss de D con label smoothing."""
    # Labels con ruido uniforme en vez de 0/1 fijos
    real_labels = tf.random.uniform(
        tf.shape(real_output), minval=0.9, maxval=1.0
    )
    fake_labels = tf.random.uniform(
        tf.shape(fake_output), minval=0.0, maxval=0.1
    )
    real_loss = cross_entropy(real_labels, real_output)
    fake_loss = cross_entropy(fake_labels, fake_output)
    return (real_loss + fake_loss) / 2

# Esto hace que D no pueda ser "100% seguro", lo que:
# 1. Previene que D domine a G
# 2. Regulariza D de forma implícita
# 3. Reduce el riesgo de gradientes saturados

9.5 Gradient clipping

Otra técnica de estabilización fácil de implementar en TensorFlow gracias al parámetro clipnorm del optimizador:

Python gradient clipping
# ── Opción 1: clipnorm en el optimizador ─────────────────
optimizer_D = keras.optimizers.Adam(
    learning_rate=LR_D, beta_1=BETA_1, clipnorm=1.0
)

# ── Opción 2: manual dentro del GradientTape ────────────
grads_d = tape_d.gradient(loss_d, D.trainable_variables)
grads_d = [tf.clip_by_norm(g, 1.0) for g in grads_d]
optimizer_D.apply_gradients(zip(grads_d, D.trainable_variables))
L3 clipnorm=1.0 — limita la norma L2 de cada tensor de gradientes a 1.0. Previene explosiones de gradientes sin afectar la dirección.
L7-8 Versión manual: útil cuando quieres aplicar clipping solo a D, o con un valor diferente para G.
  • Instance noise: Añadir layers.GaussianNoise(0.1) antes de cada capa del D. Se reduce gradualmente con un schedule.
  • Spectral normalization: TensorFlow incluye tfa.layers.SpectralNormalization(layers.Dense(...)) en el paquete tensorflow-addons. Alternativa: implementarlo manualmente con un kernel_constraint.
  • Feature matching: Extraer features de una capa intermedia de D y minimizar tf.reduce_mean(tf.square(f_real - f_fake)).
  • Historical averaging: Penalizar tf.reduce_sum(tf.square(var - var_avg)) para estabilizar los parámetros.
10

Referencias y próximos pasos

Hemos construido una GAN completa desde cero con TensorFlow — primero con capas Dense, luego con convoluciones (DCGAN). Aquí recopilamos las referencias fundamentales del campo y los próximos pasos para seguir aprendiendo.

10.1 Resumen de lo aprendido

ConceptoQué haceCódigo TensorFlow
Generador z ~ N(0,1) → Dense/Conv2DT → tanh → imagen keras.Sequential([Dense → LeakyReLU → BN → ... → tanh, Reshape])
Discriminador imagen → Dense/Conv2D → sigmoid → probabilidad keras.Sequential([Flatten → Dense → LeakyReLU → Dropout → ... → sigmoid])
Loss Binary Cross-Entropy adversarial keras.losses.BinaryCrossentropy() — D minimiza, G maximiza D(G(z))
Training GradientTape separado para D y G tape.gradient(loss, model.trainable_variables)
DCGAN Conv2DTranspose (G) + Conv2D (D) 5 reglas: stride convs, BN, no Dense, ReLU/LReLU, Adam(β₁=0.5)
Debugging Mode collapse, balance D/G, label smoothing Monitorizar D(x) y D(G(z)), visual inspection, TensorBoard

10.2 Papers fundamentales

PaperContribuciónAño
Goodfellow et al. — Generative Adversarial Networks Paper original: formulación minimax, prueba teórica de convergencia 2014
Radford et al. — DCGAN Arquitectura convolucional estándar, trucos de entrenamiento, aritmética latente 2016
Arjovsky et al. — Wasserstein GAN Wasserstein distance como loss, weight clipping, entrenamiento más estable 2017
Gulrajani et al. — WGAN-GP Gradient penalty reemplaza weight clipping 2017
Miyato et al. — Spectral Normalization Normalización espectral de D para estabilidad 2018
Karras et al. — Progressive GAN Crecimiento progresivo de 4×4 a 1024×1024 2018
Karras et al. — StyleGAN Mapping network, AdaIN, control de estilo 2019
Brock et al. — BigGAN GANs a gran escala, class conditioning, truncation trick 2019
Salimans et al. — Improved Techniques for Training GANs Feature matching, minibatch discrimination, Inception Score 2016
Heusel et al. — TTUR FID metric, TTUR para convergencia 2017

10.3 Documentación y repositorios

10.4 Próximos pasos

🏁 Resumen final: Hemos construido una GAN completa desde cero con TensorFlow/Keras — desde entender la intuición del juego adversarial hasta implementar tanto una GAN vanilla con capas Dense como una DCGAN con convoluciones. Hemos aprendido a usar tf.GradientTape para training loops personalizados, a diagnosticar y solucionar problemas comunes (mode collapse, training inestable, gradientes saturados) y hemos explorado el ecosistema de variantes de GANs. La misma estructura — un Generador que crea, un Discriminador que juzga, un juego adversarial que los mejora mutuamente — es la base de toda la familia de GANs, desde la vanilla hasta StyleGAN3.