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:
- Reemplaza el pooling con convoluciones con stride (en ambas redes).
- Usa batch norm tanto en el generador como en el discriminador, excepto en la salida de G y la entrada de D.
- Elimina las capas totalmente conectadas en arquitecturas más profundas.
- G usa ReLU en todas las capas excepto en la salida (tanh en la salida para [-1, 1]).
- 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_fidelitycalcula FID / IS en tu generador sin escribir código de evaluación personalizado.pytorch-gan-zoo(heredado) yStudioGANtraen 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 dez_dim, elimage_sizeobjetivo ynum_channels, incluyendo el bucle de entrenamiento y el guardador de muestras.
Ejercicios
- (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?
- (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)?
- (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
- Generative Adversarial Networks (Goodfellow et al., 2014) — el artículo que lo empezó todo
- DCGAN (Radford, Metz, Chintala, 2015) — las reglas de arquitectura que hicieron entrenables a las GANs
- Spectral Normalization for GANs (Miyato et al., 2018) — el truco de estabilización más útil de todos
- StyleGAN3 (Karras et al., 2021) — la GAN de vanguardia; se lee como un álbum de grandes éxitos de cada truco de la última década