Phase 04 - Lesson 09

Generación de Imágenes — GANs

Una GAN son dos redes neuronales en un juego fijo. Una dibuja, la otra critica. Mejoran juntas hasta que los dibujos engañan al crítico.

Tipo: Build Lenguajes: Python Prerrequisitos: Fase 4 Lección 03 (CNNs), Fase 3 Lección 06 (Optimizadores), Fase 3 Lección 07 (Regularización) Tiempo: ~75 minutos

Objetivos de Aprendizaje

  • Explicar el juego minimax entre generador y discriminador y por qué el equilibrio corresponde a p_model = p_data
  • Implementar una DCGAN en PyTorch y lograr que genere imágenes sintéticas coherentes de 32x32 en menos de 60 líneas
  • Estabilizar el entrenamiento de GAN con los tres trucos estándar: pérdida no saturante, normalización espectral, TTUR (regla de actualización de dos escalas de tiempo)
  • Leer curvas de entrenamiento que distinguen la convergencia saludable del colapso de modo, la oscilación y el discriminador-gana-por-completo

El Problema

La clasificación enseña a una red a mapear imágenes a etiquetas. La generación invierte el problema: muestrear nuevas imágenes que parezcan provenir de la misma distribución. No existe una salida "correcta" contra la cual puedas comparar; solo existe una distribución que quieres imitar.

Las funciones de pérdida estándar (MSE, entropía cruzada) no pueden medir "¿esta muestra provino de la distribución real?". Minimizar el error por píxel produce promedios borrosos, no muestras realistas. El avance fue aprender la pérdida: entrenar una segunda red cuyo trabajo es distinguir lo real de lo falso, y usar su juicio para empujar al generador.

Las GANs (Goodfellow et al., 2014) definieron ese marco. Para 2018, StyleGAN ya producía rostros de 1024x1024 indistinguibles de fotografías. Los modelos de difusión desde entonces han tomado el trono en calidad y controlabilidad, pero cada truco que hace práctica a la difusión — elecciones de normalización, espacios latentes, pérdidas de características — se entendió primero en las GANs.

El Concepto

Las dos redes

flowchart LR
    Z["z ~ N(0, I)<br/>ruido"] --> G["Generador<br/>convoluciones transpuestas"]
    G --> FAKE["Imagen falsa"]
    REAL["Imagen real"] --> D["Discriminador<br/>clasificador convolucional"]
    FAKE --> D
    D --> OUT["P(real)"]

    style G fill:#dbeafe,stroke:#2563eb
    style D fill:#fef3c7,stroke:#d97706
    style OUT fill:#dcfce7,stroke:#16a34a

El generador G toma un vector de ruido z y produce una imagen. El discriminador D toma una imagen y produce un solo escalar: la probabilidad de que la imagen sea real.

El juego

G quiere que D se equivoque. D quiere acertar. Formalmente:

min_G max_D  E_x[log D(x)] + E_z[log(1 - D(G(z)))]

Léelo de derecha a izquierda: D está maximizando la exactitud en imágenes reales (log D(real)) y falsas (log (1 - D(fake))). G está minimizando la exactitud de D en las falsas — quiere que D(G(z)) sea alto.

Goodfellow demostró que este minimax tiene un equilibrio global donde p_G = p_data, D produce 0.5 en todas partes, y la divergencia de Jensen-Shannon entre las distribuciones generada y real es cero. La parte difícil es llegar ahí.

Pérdida no saturante

La forma anterior es numéricamente inestable. Al inicio del entrenamiento, D(G(z)) está cerca de cero para cada falsa, así que log(1 - D(G(z))) tiene gradientes que se desvanecen con respecto a G. La solución: invertir la pérdida de G.

L_D = -E_x[log D(x)] - E_z[log(1 - D(G(z)))]
L_G = -E_z[log D(G(z))]                          # no saturante

Ahora, cuando D(G(z)) está cerca de cero, la pérdida de G es grande y su gradiente es informativo. Toda GAN moderna entrena con esta variante.

Reglas de arquitectura de la DCGAN

Radford, Metz, Chintala (2015) destilaron años de experimentos fallidos en cinco reglas que hacen estable el entrenamiento de GAN:

  1. Reemplaza el pooling con convoluciones con stride (en ambas redes).
  2. Usa batch norm tanto en el generador como en el discriminador, excepto en la salida de G y la entrada de D.
  3. Elimina las capas totalmente conectadas en arquitecturas más profundas.
  4. G usa ReLU en todas las capas excepto en la salida (tanh en la salida para [-1, 1]).
  5. D usa LeakyReLU (negative_slope=0.2) en todas las capas.

Toda GAN moderna basada en convoluciones (StyleGAN, BigGAN, GigaGAN) todavía parte de estas reglas y reemplaza las piezas una a la vez.

Modos de falla y sus firmas

flowchart LR
    M1["Colapso de modo<br/>G produce un conjunto<br/>estrecho de salidas"] --> S1["Pérdida de D baja,<br/>pérdida de G oscilando,<br/>la variedad de muestras cae"]
    M2["Gradientes desvanecientes<br/>D gana por completo"] --> S2["Exactitud de D ~100%,<br/>pérdida de G enorme y estática"]
    M3["Oscilación<br/>G y D siguen intercambiando<br/>victorias para siempre"] --> S3["Ambas pérdidas oscilan<br/>descontroladamente sin tendencia a la baja"]

    style M1 fill:#fecaca,stroke:#dc2626
    style M2 fill:#fecaca,stroke:#dc2626
    style M3 fill:#fecaca,stroke:#dc2626
  • Colapso de modo: G encuentra una imagen que engaña a D y produce solo esa. Solución: agrega discriminación por minibatch, normalización espectral o condicionamiento por etiqueta.
  • El discriminador gana: D se vuelve demasiado fuerte demasiado rápido, los gradientes de G se desvanecen. Solución: un D más pequeño, una tasa de aprendizaje de D más baja, o aplicar suavizado de etiquetas en las etiquetas reales.
  • Oscilación: las dos redes intercambian victorias sin nunca acercarse al equilibrio. Solución: TTUR (D aprende más rápido que G por un factor de 2-4), o cambia a la pérdida de Wasserstein.

Evaluación

Las GANs no tienen verdad de referencia (ground truth), entonces ¿cómo sabes si están funcionando?

  • Inspección de muestras — simplemente mira 64 muestras al final de cada época. Innegociable.
  • FID (Fréchet Inception Distance) — distancia entre las distribuciones de características de Inception-v3 de los conjuntos real y generado. Cuanto más bajo, mejor. Estándar de la comunidad.
  • Inception Score — más antiguo, más frágil; prefiere FID.
  • Precisión/Recall para modelos generativos — mide la calidad (precisión) y la cobertura (recall) por separado. Más informativo que el FID por sí solo.

Para una pequeña ejecución con datos sintéticos, la inspección de muestras es suficiente.

Constrúyelo

Paso 1: Generador

Un pequeño generador DCGAN que toma ruido de 64 dimensiones y produce una imagen de 32x32.

import torch
import torch.nn as nn

class Generator(nn.Module):
    def __init__(self, z_dim=64, img_channels=3, feat=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.ConvTranspose2d(z_dim, feat * 4, kernel_size=4, stride=1, padding=0, bias=False),
            nn.BatchNorm2d(feat * 4),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat * 4, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 2),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat * 2, feat, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat, img_channels, kernel_size=4, stride=2, padding=1, bias=False),
            nn.Tanh(),
        )

    def forward(self, z):
        return self.net(z.view(z.size(0), -1, 1, 1))

Cuatro convoluciones transpuestas, cada una con kernel_size=4, stride=2, padding=1 para que dupliquen limpiamente el tamaño espacial. Activaciones de salida en [-1, 1] mediante tanh.

Paso 2: Discriminador

Espejo del generador. LeakyReLU, convoluciones con stride, termina con un logit escalar.

class Discriminator(nn.Module):
    def __init__(self, img_channels=3, feat=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(img_channels, feat, kernel_size=4, stride=2, padding=1),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 2),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat * 2, feat * 4, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 4),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat * 4, 1, kernel_size=4, stride=1, padding=0),
        )

    def forward(self, x):
        return self.net(x).view(-1)

La última convolución reduce un mapa de características 4x4 a 1x1. La salida es un solo escalar por imagen; aplica sigmoid solo durante el cálculo de la pérdida.

Paso 3: Paso de entrenamiento

Alterna: actualiza D una vez, luego G una vez, en cada batch.

import torch.nn.functional as F

def train_step(G, D, real, z, opt_g, opt_d, device):
    real = real.to(device)
    bs = real.size(0)

    # D step
    opt_d.zero_grad()
    d_real = D(real)
    d_fake = D(G(z).detach())
    loss_d = (F.binary_cross_entropy_with_logits(d_real, torch.ones_like(d_real))
              + F.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))
    loss_d.backward()
    opt_d.step()

    # G step
    opt_g.zero_grad()
    d_fake = D(G(z))
    loss_g = F.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))
    loss_g.backward()
    opt_g.step()

    return loss_d.item(), loss_g.item()

El G(z).detach() en el paso de D es crítico: no queremos que los gradientes fluyan hacia G durante su actualización. Olvidar eso es el bug clásico de principiante.

Paso 4: Bucle completo de entrenamiento sobre formas sintéticas

from torch.utils.data import DataLoader, TensorDataset
import numpy as np

def synthetic_images(num=2000, size=32, seed=0):
    rng = np.random.default_rng(seed)
    imgs = np.zeros((num, 3, size, size), dtype=np.float32) - 1.0
    for i in range(num):
        r = rng.uniform(6, 12)
        cx, cy = rng.uniform(r, size - r, size=2)
        yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
        mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
        color = rng.uniform(-0.5, 1.0, size=3)
        for c in range(3):
            imgs[i, c][mask] = color[c]
    return torch.from_numpy(imgs)

device = "cuda" if torch.cuda.is_available() else "cpu"
data = synthetic_images()
loader = DataLoader(TensorDataset(data), batch_size=64, shuffle=True)

G = Generator(z_dim=64, img_channels=3, feat=32).to(device)
D = Discriminator(img_channels=3, feat=32).to(device)
opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))

for epoch in range(10):
    for (batch,) in loader:
        z = torch.randn(batch.size(0), 64, device=device)
        ld, lg = train_step(G, D, batch, z, opt_g, opt_d, device)
    print(f"epoch {epoch}  D {ld:.3f}  G {lg:.3f}")

Adam(lr=2e-4, betas=(0.5, 0.999)) es el valor por defecto de la DCGAN — el beta1 bajo evita que el término de momento estabilice demasiado el juego adversarial.

Paso 5: Muestreo

@torch.no_grad()
def sample(G, n=16, z_dim=64, device="cpu"):
    G.eval()
    z = torch.randn(n, z_dim, device=device)
    imgs = G(z)
    imgs = (imgs + 1) / 2
    return imgs.clamp(0, 1)

Cambia siempre al modo eval antes de muestrear. Para la DCGAN esto importa porque se usan las estadísticas en ejecución (running stats) del batch norm en lugar de las estadísticas del batch.

Paso 6: Normalización espectral

Un reemplazo directo del BN en el discriminador que garantiza que la red sea 1-Lipschitz. Soluciona la mayoría de las fallas de "D gana con demasiada fuerza".

from torch.nn.utils import spectral_norm

def build_sn_discriminator(img_channels=3, feat=64):
    return nn.Sequential(
        spectral_norm(nn.Conv2d(img_channels, feat, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat, feat * 2, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat * 2, feat * 4, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat * 4, 1, 4, 1, 0)),
    )

Cambia Discriminator por build_sn_discriminator() y a menudo no necesitarás el truco del TTUR. La normalización espectral es la mejora de robustez individual más fácil que puedes aplicar.

Úsalo

Para una generación seria, usa pesos preentrenados o cambia a difusión. Dos bibliotecas estándar:

  • torch_fidelity calcula FID / IS en tu generador sin escribir código de evaluación personalizado.
  • pytorch-gan-zoo (heredado) y StudioGAN traen implementaciones probadas de DCGAN, WGAN-GP, SN-GAN, StyleGAN y BigGAN.

En 2026, las GANs siguen siendo la mejor opción para: generación de imágenes en tiempo real (latencia <10 ms), transferencia de estilo, traducción imagen-a-imagen con control preciso (Pix2Pix, CycleGAN). La difusión gana en fotorrealismo y condicionamiento por texto.

Entrégalo

Esta lección produce:

  • outputs/prompt-gan-training-triage.md — un prompt que lee la descripción de una curva de entrenamiento y elige el modo de falla (colapso de modo, D-gana, oscilación) más la única solución recomendada.
  • outputs/skill-dcgan-scaffold.md — una skill que escribe un scaffold de DCGAN a partir de z_dim, el image_size objetivo y num_channels, incluyendo el bucle de entrenamiento y el guardador de muestras.

Ejercicios

  1. (Fácil) Entrena la DCGAN anterior sobre el conjunto de datos sintético de círculos y guarda una cuadrícula de 16 muestras al final de cada época. ¿En qué época los círculos generados se vuelven claramente circulares?
  2. (Medio) Reemplaza el batch norm del discriminador con normalización espectral. Entrena ambas versiones en paralelo. ¿Cuál converge más rápido? ¿Cuál tiene menor varianza entre tres semillas (seeds)?
  3. (Difícil) Implementa una DCGAN condicional: alimenta la etiqueta de clase tanto en G como en D (concatena el one-hot al ruido en G, concatena un canal de embedding de clase en D). Entrena sobre el conjunto de datos sintético "círculos vs cuadrados" de la lección 7 y muestra que el condicionamiento por clase funciona muestreando con etiquetas específicas.

Términos Clave

Término Lo que dice la gente Lo que realmente significa
Generador (G) "La red que dibuja cosas" Mapea ruido a imágenes; entrenado para engañar al discriminador
Discriminador (D) "El crítico" Clasificador binario; entrenado para distinguir imágenes reales de generadas
Minimax "El juego" min sobre G, max sobre D de una pérdida adversarial; el equilibrio es p_G = p_data
Pérdida no saturante "La versión numéricamente sensata" La pérdida de G es -log(D(G(z))) en lugar de log(1 - D(G(z))) para evitar gradientes desvanecientes al inicio del entrenamiento
Colapso de modo "El generador hace una sola cosa" G produce solo un pequeño subconjunto de la distribución de datos; soluciónalo con SN, discriminación por minibatch o un batch más grande
TTUR "Dos tasas de aprendizaje" D aprende más rápido que G, típicamente por un factor de 2-4; estabiliza el entrenamiento
Normalización espectral "Capa 1-Lipschitz" Una normalización de pesos que acota la constante de Lipschitz de cada capa; impide que D se vuelva arbitrariamente empinado
FID "Fréchet Inception Distance" Distancia entre las distribuciones de características de Inception-v3 de los conjuntos real y generado; la métrica de evaluación estándar

Lecturas Adicionales

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).