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.
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)
¿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.
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
Dense. Sencillo, ideal para entender los fundamentos con Keras.Conv2DTranspose y Conv2D. BatchNormalization, LeakyReLU — el estándar de facto.1.3 Nuestro plan
- Setup — Instalación, imports, configuración de hiperparámetros y strategy.
- Dataset — Cargar MNIST con
tf.data, normalizar y crear pipelines. - Generador — Red Dense que transforma ruido z en una imagen 28×28.
- Discriminador — Red Dense que clasifica imágenes como reales o falsas.
- Training loop —
tf.GradientTapecon Binary Cross-Entropy, alternancia D/G. - Visualización — Grids por época, curvas de loss, interpolación del espacio latente.
- DCGAN — Upgrade completo con convoluciones.
- Debugging — Problemas reales y cómo resolverlos.
- Referencias — Papers, repos, documentación y siguientes pasos.
Setup: instalación, imports y configuración
2.1 Instalación
# 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
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")
layers para construir las redes.
tf.random.set_seed() fija la seed global de TensorFlow para reproducibilidad.
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.
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.
# ── 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)
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.
BATCH_SIZE = 256 — TensorFlow con GPU aprovecha mejor batches grandes que PyTorch
en general. En CPU, 128 o 64 funcionan igual.
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.9lleva demasiado momentum, lo que causa oscilaciones en el entrenamiento adversarial.beta_1=0.5reduce 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 usaloss.backward()+optimizer.step(). - Modelos: TF usa
keras.Sequentialo Functional API vs PyTorch usann.Module. - Data pipeline: TF usa
tf.data.Dataset(lazy, paralelizable) vs PyTorch usaDataLoader. - Gradientes: En TF hay que envolver el forward pass en
GradientTapeexplí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.
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.
# ── 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)
)
keras.datasets.mnist.load_data() devuelve arrays NumPy. No necesitamos torchvision ni transforms — TF incluye datasets directamente.
(x - 127.5) / 127.5, equivalente a Normalize((0.5,), (0.5,)) de torchvision pero en un solo paso.
(B, H, W, C) = (60000, 28, 28, 1). En PyTorch sería (B, C, H, W).
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.
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:
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")
.squeeze() elimina la dimensión de canal (28,28,1) → (28,28) para imshow.
next(iter(train_dataset)) obtiene el primer batch. .numpy() convierte el tensor TF a NumPy.
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.datano carga todo en memoria; aplica transformaciones sobre la marcha. - Prefetch:
.prefetch(AUTOTUNE)prepara datos en paralelo con el entrenamiento. En PyTorch se logra connum_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.datacon 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. AjustaIMG_SIZE=32yIMG_CHANNELS=3. - CelebA: Usa
tf.keras.utils.image_dataset_from_directory()otfds.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.
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.
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}")
Sequential — equivalente a nn.Sequential de PyTorch pero con Input layer explícito para definir el shape.
LeakyReLU(0.2) — en Keras es una capa separada, no un argumento de Dense. El slope de 0.2 es estándar en GANs.
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.
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).
training=False es necesario porque BatchNormalization se comporta diferente
en modo train (usa stats del batch) vs inference (usa running stats).
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)
TensorFlow/Keras ofrece tres formas de construir modelos:
- Sequential: Lo usamos aquí. Sencillo, ideal para stacks lineales.
Equivalente a
nn.Sequentialde 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 deforward().
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.
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.
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}")
Input(shape=(28, 28, 1)) — nótese el formato channels-last de TensorFlow.
En PyTorch sería (1, 28, 28).
Dropout(0.3) — regularización crucial en el Discriminador. Sin Dropout,
D aprende demasiado rápido y el Generador no puede seguirle el ritmo.
activation='sigmoid' en la última capa. Output en [0, 1]: 1 = "creo que
es real", 0 = "creo que es fake".
training=False desactiva Dropout (y BN si la hubiera).
Cuando entrenemos, pasaremos training=True dentro del GradientTape.
Total params: 533,249 (2.03 MB)
Trainable params: 533,249
Discriminador - Input: (4, 28, 28, 1) → Output: (4, 1)
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.
# ── 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])
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).
beta_1=0.5. En TF el
parámetro se llama beta_1, en PyTorch es betas=(0.5, 0.999).
fixed_noise — siempre generamos imágenes a partir del mismo ruido
para comparar la evolución del Generador entre épocas.
| Red | Capa | Parámetros |
|---|---|---|
| Generador | Dense(100, 256) + BN | 26,368 |
| Dense(256, 512) + BN | 132,096 | |
| Dense(512, 1024) + BN | 526,336 | |
| Dense(1024, 784) | 803,600 | |
| Total G | ~1.07M (2,832 non-trainable) | |
| Discriminador | Dense(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).
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.
6.1 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)
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.
tf.zeros_like — tensor de 0s. D debería asignar 0 (="fake") a las
imágenes generadas.
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
@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)
@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).
tf.GradientTape() — graba todas las operaciones dentro del bloque para
poder calcular gradientes después. Es el equivalente al autograd de PyTorch.
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.
detach(), aquí simplemente generamos nuevas.
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.
.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
# ── 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")
train_dataset — el pipeline tf.data
maneja el shuffling y batching automáticamente.
.numpy() convierte tensores TF a escalares. Esto fuerza la ejecución
(materializa el valor) — es O(1) para escalares.
training=False para generar imágenes limpias (sin dropout/BN en modo train).
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
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_lossse estabiliza alrededor deln(2) ≈ 0.693(equilibrio de Nash).G_lossbaja 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. Usatf.printytf.conden 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.
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
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()
- 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
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.
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()
z = (1-α)·z₁ + α·z₂. Para α=0 → z₁, para
α=1 → z₂.
z[tf.newaxis, ...] añade la dimensión de batch: (100,) → (1, 100).
En PyTorch usaríamos z.unsqueeze(0).
7.4 Widget: simulador de dinámicas de entrenamiento
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-gan → tfgan.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).
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.
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:
| # | Regla | Justificación |
|---|---|---|
| 1 | Reemplazar pooling con stride convolutions (D) y fractional-strided convolutions (G) | La red aprende su propio downsampling/upsampling |
| 2 | BatchNorm en G y D (excepto salida de G y primera capa de D) | Estabiliza el entrenamiento, previene mode collapse |
| 3 | Eliminar capas Dense (excepto para z → primer bloque) | Las convs respetan la estructura espacial |
| 4 | ReLU en G (excepto salida = tanh), LeakyReLU en D | Gradientes saludables en ambas redes |
| 5 | Adam con lr=0.0002, β₁=0.5 | Momentum bajo para estabilidad adversarial |
8.2 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():,}")
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.
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).
activation='tanh' para output en [-1, 1].
use_bias=False — cuando usamos BatchNorm, el bias del Dense/Conv anterior es
redundante (BN tiene su propio shift). Eliminarlo ahorra parámetros.
Parámetros: 1,548,161
8.3 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():,}")
strides=2 reduce
28×28 → 14×14.
strides=2 reduce 14×14 → 7×7.
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).
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:
# ── 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
| Criterio | GAN Dense (MLP) | DCGAN (Conv) |
|---|---|---|
| Parámetros totales | ~1.6M | ~1.76M |
| Estructura espacial | No la respeta (flatten + dense) | Sí (convoluciones locales) |
| Calidad en MNIST | Buena (dígitos reconocibles) | Mejor (más nítida, menos ruido) |
| Escalabilidad | No funciona bien en 64×64+ | Escala a 64×64, 128×128, 256×256 |
| Velocidad de convergencia | ~50 epochs para MNIST | ~20-30 epochs |
| Uso recomendado | Aprendizaje, datos tabulares | Imágenes, producción |
Ambas hacen lo mismo (upsampling aprendido), pero la API difiere:
| Aspecto | TensorFlow | PyTorch |
|---|---|---|
| Clase | layers.Conv2DTranspose | nn.ConvTranspose2d |
| Orden de args | filters, kernel, strides | in_ch, out_ch, kernel, stride, pad |
| Padding | padding='same' | padding=1 (manual) |
| Channel order | (B, H, W, C) channels-last | (B, C, H, W) channels-first |
| Bias por defecto | True | True |
padding='same' en TF es equivalente a calcular manualmente el padding en
PyTorch para que output_size = input_size × stride.
- WGAN / WGAN-GP: Reemplaza BCE con Wasserstein distance. Más estable. Arjovsky et al., 2017
- Progressive GAN: Crece de 4×4 a 1024×1024. Karras et al., 2018
- StyleGAN / StyleGAN2: Mapping network + AdaIN. Karras et al., 2019
- BigGAN: GANs a gran escala con class conditioning. Brock et al., 2019
- StyleGAN3: Elimina artefactos de aliasing. Karras et al., 2021
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.
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:
- WGAN-GP: Reemplaza BCE por Wasserstein distance + gradient penalty. La solución más fiable.
- Minibatch discrimination: Añade features al D que detectan si el batch carece de diversidad.
- Unrolled GAN: El generador optimiza mirando varios pasos adelante de D.
- Feature matching: Minimizar distancia de features intermedias de D entre reales y fakes.
9.2 Training inestable / oscilaciones
| Síntoma | Causa probable | Solución |
|---|---|---|
| Loss de D → 0 rápidamente | D demasiado fuerte para G | Reducir capacidad de D, label smoothing, más pasos de G por paso de D |
| Loss de G → 0 rápidamente | G encontró un exploit en D | Aumentar capacidad de D, entrenar D más pasos, spectral normalization |
| Losses oscilan sin converger | Learning rate demasiado alto | Reducir LR a 1e-4 o 5e-5, usar keras.optimizers.schedules |
| NaN en la loss | log(0) en BCE | Usar BinaryCrossentropy(from_logits=True), gradient clipping |
| Imágenes borrosas | G sin suficiente capacidad | Más filtros, usar DCGAN, más capas |
| Checkerboard artifacts | Conv2DTranspose con stride desalineado | Usar 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:
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:
# ── 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))
clipnorm=1.0 — limita la norma L2 de cada tensor de gradientes a 1.0.
Previene explosiones de gradientes sin afectar la dirección.
- 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 paquetetensorflow-addons. Alternativa: implementarlo manualmente con unkernel_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.
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
| Concepto | Qué hace | Có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
| Paper | Contribución | Añ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
- TensorFlow DCGAN Tutorial (oficial) — DCGAN con MNIST paso a paso, con GradientTape
- Keras Generative Examples — WGAN-GP, CycleGAN, StyleGAN, VAE y más con Keras
- Keras-GAN (GitHub) — Colección de +20 implementaciones de GANs en Keras
- StyleGAN3 (NVIDIA) — Implementación oficial de StyleGAN3
- Papers With Code — GANs — Papers de GANs indexados con código
- tf.keras.layers.Conv2DTranspose — Documentación oficial de la "deconvolución"
- Deconvolution and Checkerboard Artifacts (Distill) — Artículo visual sobre artifacts de Conv2DTranspose
- TensorBoard — Monitorización en tiempo real de métricas e imágenes
10.4 Próximos pasos
layers.Embedding + Concatenate.tf.GradientTape(persistent=True) para el GP.tf.data + GradientTape.tf.data + .cache() + .prefetch().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.