#!/usr/bin/env python3
"""
Generador del notebook DCGAN para Semana 8 — Visión por Computador (NRC-8773)
==============================================================================
Genera: Oviedo_Alexander_DCGAN_RGA_S8_VC.ipynb

Continuidad con Semana 7: Se reutiliza CIFAR-10 como dataset base.
Arquitectura: DCGAN (Deep Convolutional GAN) con TensorFlow/Keras.
"""

import json
import os

# ─── Utilidades ────────────────────────────────────────────────────────
def md_cell(source):
    """Crea una celda markdown."""
    return {
        "cell_type": "markdown",
        "metadata": {},
        "source": source.split("\n") if isinstance(source, str) else source
    }

def code_cell(source, outputs=None):
    """Crea una celda de código."""
    return {
        "cell_type": "code",
        "execution_count": None,
        "metadata": {},
        "outputs": outputs or [],
        "source": source.split("\n") if isinstance(source, str) else source
    }

# Corregir split para preservar newlines en JSON
def fix_source(lines):
    """Agrega \\n al final de cada línea excepto la última."""
    if isinstance(lines, str):
        lines = lines.split("\n")
    result = []
    for i, line in enumerate(lines):
        if i < len(lines) - 1:
            result.append(line + "\n")
        else:
            result.append(line)
    return result


# ─── Celdas del Notebook ──────────────────────────────────────────────
cells = []

# =====================================================================
# CELDA 0: Encabezado
# =====================================================================
cells.append(md_cell(fix_source("""# 🧠 Red Generativa Adversaria (DCGAN) para Generación de Imágenes
## Visión por Computador — NRC-8773 — Semana 8

[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/bladealex9848/UNIMINUTO/blob/main/2do%20CUATRIMESTRE/NRC-8773-Visi%C3%B3n%20por%20Computador/ACTIVIDADES/MA%20Semana%208/Oviedo_Alexander_DCGAN_RGA_S8_VC.ipynb)

**Grupo:** Grupo 10  
**Integrantes:**  
- Alexander Oviedo Fadul  
- Maria Fernanda Ruiz Paipilla  
- Neheman Samir Jaller Cerchiaro  
- William David Obando Lopez  
- Yesith Stiven Vizcano Mican  

**Programa:** Especialización en Inteligencia Artificial  
**Institución:** Corporación Universitaria Minuto de Dios (UNIMINUTO)  
**Curso:** NRC-8773 — Visión por Computador  
**Profesor:** Félix Julián Gutiérrez Bernal  
**Fecha:** Junio 2026

---

### Objetivo

Diseñar e implementar una **Red Generativa Adversaria Convolucional Profunda (DCGAN)** 
capaz de generar imágenes sintéticas a partir del dataset **CIFAR-10**, como continuación 
del trabajo de clasificación realizado en la Semana 7. El propósito final es producir un 
conjunto de **al menos 30 imágenes nuevas** generadas por la red y analizar la calidad 
de los resultados obtenidos.

> **Nota:** Este notebook se basa en el material de la Open Class de la Semana 8 y los 
> notebooks del Drive del curso (`8 DCGAN con mnist.ipynb`, `Clase8_Resumende Metodos_RGA_Vision.ipynb`), 
> adaptados para trabajar con imágenes a color de CIFAR-10 (32×32×3) en lugar de MNIST (28×28×1).""")))

# =====================================================================
# CELDA 1: Marco teórico
# =====================================================================
cells.append(md_cell(fix_source("""## 1. Marco Teórico: Redes Generativas Adversarias (GANs)

### ¿Qué es una GAN?

Las **Redes Generativas Adversarias (GANs)**, propuestas por Goodfellow et al. (2014), 
son un marco de aprendizaje no supervisado donde dos redes neuronales compiten entre sí 
en un juego minimax:

- **Generador (G):** Recibe un vector de ruido aleatorio $z$ y genera datos sintéticos 
  que intentan parecerse a los datos reales.
- **Discriminador (D):** Recibe datos (reales o generados) y clasifica si son reales o 
  falsos.

Como explicó el profesor Félix Julián en la clase de esta semana: *"Tenemos dentro de esa 
red un generador y un discriminador. Van a ser dos matrices (...) el generador va a arrancar 
inicialmente de manera aleatoria, y el discriminador me va diciendo si lo que estoy generando 
se parece o no a lo que debería tener."*

### ¿Qué es una DCGAN?

La **Deep Convolutional GAN (DCGAN)** es una variante propuesta por Radford et al. (2016) 
que introduce convoluciones en la arquitectura. A diferencia de la GAN básica (que usa solo 
capas densas), la DCGAN utiliza:

- **Conv2DTranspose** (convolución transpuesta) en el generador para hacer *upsampling*
- **Conv2D** en el discriminador para extraer características espaciales
- **BatchNormalization** para estabilizar el entrenamiento
- Eliminación de capas fully connected intermedias

Esto es lo que el profesor llamó la diferencia entre GAN y DCGAN: *"cuando son sin 
convolución las GANs, y cuando son con convolución las DCGAN."*

### Preguntas Orientadoras

**¿Cómo evaluar la calidad de las imágenes generadas por una RGA?**

La calidad se evalúa mediante:
1. **Inspección visual:** Coherencia de formas, colores y texturas
2. **Convergencia de pérdidas:** Las pérdidas del generador y discriminador deben estabilizarse 
   sin que una domine completamente a la otra
3. **Diversidad:** Las imágenes generadas no deben colapsar en un solo modo (mode collapse)
4. **Métricas cuantitativas:** FID (Fréchet Inception Distance) e IS (Inception Score)

**¿Cuáles son los principales desafíos en la implementación de una RGA?**

1. **Inestabilidad del entrenamiento:** El balance entre G y D es delicado
2. **Mode collapse:** G genera solo unos pocos tipos de imágenes
3. **Costo computacional:** Como mencionó el profesor, *"el cálculo computacional en 
   arquitectura, número de capas y demás tienen que ir creciendo"*
4. **Evaluación subjetiva:** No existe una métrica única universalmente aceptada""")))

# =====================================================================
# CELDA 2: Importación de librerías
# =====================================================================
cells.append(md_cell(fix_source("""## 2. Importación de Librerías

Se importan las librerías necesarias para construir, entrenar y visualizar la DCGAN. 
Se utiliza **TensorFlow/Keras** como framework principal, siguiendo la línea del curso 
y la recomendación del profesor Félix Julián.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 2. IMPORTACIÓN DE LIBRERÍAS
# ===========================================================================
import tensorflow as tf
from tensorflow.keras import layers
import numpy as np
import matplotlib.pyplot as plt
import time
import os
import warnings

warnings.filterwarnings('ignore')

# Verificar dispositivo disponible
print(f"TensorFlow versión: {tf.__version__}")
print(f"GPU disponible: {tf.config.list_physical_devices('GPU')}")
print(f"Dispositivo de ejecución: {'GPU' if tf.config.list_physical_devices('GPU') else 'CPU'}")

# Semilla para reproducibilidad
SEED = 42
tf.random.set_seed(SEED)
np.random.seed(SEED)""")))

# =====================================================================
# CELDA 3: Carga del dataset
# =====================================================================
cells.append(md_cell(fix_source("""## 3. Carga y Exploración del Dataset CIFAR-10

En continuidad con el trabajo de la **Semana 7** (clasificación con CNN), reutilizamos el 
dataset **CIFAR-10**. Este dataset contiene 60,000 imágenes a color de 32×32 píxeles 
distribuidas en 10 clases.

Para la DCGAN, normalizamos las imágenes al rango **[-1, 1]** (en lugar de [0, 1] como 
en la S7), ya que el generador utiliza la función de activación **tanh** en su capa de 
salida, cuyo rango natural es precisamente [-1, 1].

| Característica | Valor |
|----------------|-------|
| Imágenes totales | 60,000 (50K train + 10K test) |
| Resolución | 32 × 32 píxeles |
| Canales | 3 (RGB — color) |
| Clases | 10 |
| Normalización | [-1, 1] |""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 3. CARGA Y PREPROCESAMIENTO DE CIFAR-10
# ===========================================================================
from tensorflow.keras.datasets import cifar10

# Cargar datos (misma fuente que Semana 7)
(X_train_raw, y_train), (X_test_raw, y_test) = cifar10.load_data()

# Nombres de las clases CIFAR-10
CLASES = ['Avión', 'Automóvil', 'Pájaro', 'Gato', 'Ciervo',
          'Perro', 'Rana', 'Caballo', 'Barco', 'Camión']

print(f"Datos de entrenamiento: {X_train_raw.shape}")
print(f"Datos de prueba:        {X_test_raw.shape}")
print(f"Rango original de píxeles: [{X_train_raw.min()}, {X_train_raw.max()}]")

# Normalizar a [-1, 1] para compatibilidad con tanh del generador
X_train = (X_train_raw.astype("float32") - 127.5) / 127.5
X_test = (X_test_raw.astype("float32") - 127.5) / 127.5

print(f"Rango normalizado: [{X_train.min():.1f}, {X_train.max():.1f}]")
print(f"\\nClases disponibles: {CLASES}")""")))

# =====================================================================
# CELDA 4: Visualización del dataset
# =====================================================================
cells.append(md_cell(fix_source("""### 3.1 Visualización de muestras del dataset

Antes de entrenar, conviene recordar cómo lucen las imágenes reales. A decir verdad, 
cuando trabajamos con estas imágenes en la Semana 7, me sorprendió lo difícil que es 
incluso para el ojo humano distinguir algunas categorías a 32×32 de resolución.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# VISUALIZACIÓN DE MUESTRAS REALES
# ===========================================================================
fig, axes = plt.subplots(3, 10, figsize=(16, 5))
fig.suptitle('Muestras reales del dataset CIFAR-10', fontsize=14, fontweight='bold')

for i in range(10):
    # Seleccionar 3 imágenes aleatorias de cada clase
    idx_clase = np.where(y_train.flatten() == i)[0]
    muestras = np.random.choice(idx_clase, 3, replace=False)
    
    for j in range(3):
        # Desnormalizar para visualización
        img = (X_train[muestras[j]] + 1) / 2.0
        axes[j, i].imshow(img)
        axes[j, i].axis('off')
        if j == 0:
            axes[j, i].set_title(CLASES[i], fontsize=8)

plt.tight_layout()
plt.savefig('muestras_reales_cifar10.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"Distribución de clases en entrenamiento:")
for i, nombre in enumerate(CLASES):
    count = np.sum(y_train.flatten() == i)
    print(f"  {nombre}: {count} imágenes")""")))

# =====================================================================
# CELDA 5: Preparación del dataset TF
# =====================================================================
cells.append(md_cell(fix_source("""### 3.2 Preparación del pipeline de datos

Configuramos el dataset de TensorFlow con *shuffling* y *batching* para alimentar 
eficientemente el ciclo de entrenamiento. Usamos un tamaño de batch de 128, que es 
el estándar recomendado en la literatura de DCGAN (Radford et al., 2016).""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# PIPELINE DE DATOS TENSORFLOW
# ===========================================================================
BUFFER_SIZE = 50000  # Tamaño del buffer de shuffle (todo el train set)
BATCH_SIZE = 128     # Estándar DCGAN

# Crear dataset de TensorFlow optimizado
train_dataset = tf.data.Dataset.from_tensor_slices(X_train)\\
    .shuffle(BUFFER_SIZE)\\
    .batch(BATCH_SIZE)\\
    .prefetch(tf.data.AUTOTUNE)

print(f"Batches por época: {len(train_dataset)}")
print(f"Tamaño de batch: {BATCH_SIZE}")
print(f"Imágenes por época: {len(X_train)}")""")))

# =====================================================================
# CELDA 6: Arquitectura del Generador
# =====================================================================
cells.append(md_cell(fix_source("""## 4. Diseño de la Arquitectura DCGAN

### 4.1 Generador

El generador transforma un vector de **ruido aleatorio** $z \\in \\mathbb{R}^{100}$ 
(muestreado de una distribución normal) en una imagen de 32×32×3 píxeles.

La arquitectura utiliza **convoluciones transpuestas** (Conv2DTranspose) para hacer 
*upsampling* progresivo:

```
Entrada: z ∈ ℝ¹⁰⁰ (ruido latente)
    ↓
Dense(4×4×256) → BatchNorm → ReLU → Reshape(4, 4, 256)
    ↓
Conv2DTranspose(128, 5×5, stride=2) → BatchNorm → ReLU → (8, 8, 128)
    ↓
Conv2DTranspose(64, 5×5, stride=2) → BatchNorm → ReLU → (16, 16, 64)
    ↓
Conv2DTranspose(3, 5×5, stride=2, tanh) → (32, 32, 3)  ← Imagen generada
```

**Decisiones de diseño:**
- **BatchNormalization** después de cada capa (excepto la salida): estabiliza el 
  entrenamiento, como recomienda Radford et al. (2016)
- **ReLU** como activación interna del generador
- **tanh** en la salida: produce valores en [-1, 1], compatible con la normalización 
  de los datos reales
- **Sin bias** en las capas convolucionales: práctica estándar en DCGAN""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 4.1 ARQUITECTURA DEL GENERADOR
# ===========================================================================
LATENT_DIM = 100  # Dimensión del espacio latente

def build_generator():
    \"\"\"
    Generador DCGAN para imágenes CIFAR-10 (32×32×3).
    
    Transforma un vector de ruido z ∈ ℝ¹⁰⁰ en una imagen a color de 32×32.
    Utiliza Conv2DTranspose para upsampling progresivo:
    (100,) → (4,4,256) → (8,8,128) → (16,16,64) → (32,32,3)
    \"\"\"
    model = tf.keras.Sequential(name='Generador')
    
    # Capa 1: Proyección y reshape
    # Transforma el vector latente en un tensor 3D (mapa de características)
    model.add(layers.Dense(4 * 4 * 256, use_bias=False, input_shape=(LATENT_DIM,)))
    model.add(layers.BatchNormalization())
    model.add(layers.ReLU())
    model.add(layers.Reshape((4, 4, 256)))
    
    # Capa 2: Upsampling 4×4 → 8×8
    model.add(layers.Conv2DTranspose(128, (5, 5), strides=(2, 2), 
                                      padding='same', use_bias=False))
    model.add(layers.BatchNormalization())
    model.add(layers.ReLU())
    
    # Capa 3: Upsampling 8×8 → 16×16
    model.add(layers.Conv2DTranspose(64, (5, 5), strides=(2, 2), 
                                      padding='same', use_bias=False))
    model.add(layers.BatchNormalization())
    model.add(layers.ReLU())
    
    # Capa 4 (Salida): Upsampling 16×16 → 32×32 con 3 canales (RGB)
    # Activación tanh para rango [-1, 1]
    model.add(layers.Conv2DTranspose(3, (5, 5), strides=(2, 2), 
                                      padding='same', use_bias=False,
                                      activation='tanh'))
    
    return model

# Construir el generador
generator = build_generator()
generator.summary()""")))

# =====================================================================
# CELDA 7: Verificación del generador
# =====================================================================
cells.append(md_cell(fix_source("""### Verificación del Generador

Antes de entrenar, verificamos que el generador produce tensores con la forma correcta 
(32, 32, 3). Lo que me llamó la atención al adaptar el modelo de MNIST a CIFAR-10 es 
que las dimensiones espaciales cambian: partimos de 4×4 en lugar de 7×7, lo cual 
requiere tres pasos de upsampling (×2 cada uno) para llegar a 32×32.""")))

cells.append(code_cell(fix_source("""# Verificar forma de salida del generador
noise_test = tf.random.normal([1, LATENT_DIM])
generated_test = generator(noise_test, training=False)
print(f"Forma de salida del generador: {generated_test.shape}")
print(f"Rango de valores: [{generated_test.numpy().min():.3f}, {generated_test.numpy().max():.3f}]")
assert generated_test.shape == (1, 32, 32, 3), "Error: la forma de salida no es correcta"
print("✅ Generador verificado correctamente")""")))

# =====================================================================
# CELDA 8: Discriminador
# =====================================================================
cells.append(md_cell(fix_source("""### 4.2 Discriminador

El discriminador es una **CNN clasificadora binaria** que recibe una imagen de 32×32×3 
y determina si es real (del dataset) o falsa (generada por G).

```
Entrada: imagen ∈ ℝ³²ˣ³²ˣ³
    ↓
Conv2D(64, 5×5, stride=2) → LeakyReLU(0.2) → Dropout(0.3) → (16, 16, 64)
    ↓
Conv2D(128, 5×5, stride=2) → LeakyReLU(0.2) → Dropout(0.3) → (8, 8, 128)
    ↓
Flatten → Dense(1, sigmoid)  ← Probabilidad de "real"
```

**Decisiones de diseño:**
- **LeakyReLU(α=0.2)** en lugar de ReLU: permite gradientes negativos pequeños, 
  evitando neuronas muertas en el discriminador
- **Dropout(0.3):** Regularización para evitar que D se vuelva demasiado fuerte 
  demasiado rápido
- **Sin BatchNormalization:** En el discriminador, omitimos BN en la primera capa 
  (práctica estándar en DCGAN)
- **Sigmoid** en la salida: probabilidad de que la imagen sea real""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 4.2 ARQUITECTURA DEL DISCRIMINADOR
# ===========================================================================

def build_discriminator():
    \"\"\"
    Discriminador DCGAN para imágenes CIFAR-10 (32×32×3).
    
    Clasifica imágenes como reales o generadas.
    Utiliza Conv2D para downsampling:
    (32,32,3) → (16,16,64) → (8,8,128) → Flatten → 1
    \"\"\"
    model = tf.keras.Sequential(name='Discriminador')
    
    # Capa 1: Downsampling 32×32 → 16×16
    model.add(layers.Conv2D(64, (5, 5), strides=(2, 2), padding='same',
                            input_shape=(32, 32, 3)))
    model.add(layers.LeakyReLU(0.2))
    model.add(layers.Dropout(0.3))
    
    # Capa 2: Downsampling 16×16 → 8×8
    model.add(layers.Conv2D(128, (5, 5), strides=(2, 2), padding='same'))
    model.add(layers.LeakyReLU(0.2))
    model.add(layers.Dropout(0.3))
    
    # Capa de salida: clasificación binaria
    model.add(layers.Flatten())
    model.add(layers.Dense(1, activation='sigmoid'))
    
    return model

# Construir el discriminador
discriminator = build_discriminator()
discriminator.summary()""")))

# =====================================================================
# CELDA 9: Verificación discriminador
# =====================================================================
cells.append(code_cell(fix_source("""# Verificar forma de salida del discriminador
decision_test = discriminator(generated_test, training=False)
print(f"Salida del discriminador: {decision_test.numpy()}")
print(f"Forma: {decision_test.shape}")
print("✅ Discriminador verificado correctamente")""")))

# =====================================================================
# CELDA 10: Resumen de parámetros
# =====================================================================
cells.append(md_cell(fix_source("""### 4.3 Resumen de la Arquitectura

A continuación se resume el número de parámetros de cada componente. Reconozco que 
al principio me preocupaba que la DCGAN fuera mucho más pesada que la CNN clasificadora 
de la Semana 7, pero en realidad el generador no tiene tantos parámetros porque usa 
convoluciones sin bias.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# RESUMEN COMPARATIVO DE PARÁMETROS
# ===========================================================================
print("=" * 60)
print("RESUMEN DE LA ARQUITECTURA DCGAN")
print("=" * 60)
print(f"\\nGenerador:")
print(f"  Parámetros totales:      {generator.count_params():,}")
print(f"  Entrada:                 vector z ∈ ℝ^{LATENT_DIM}")
print(f"  Salida:                  imagen 32×32×3 (RGB)")
print(f"  Activación de salida:    tanh → [-1, 1]")
print(f"\\nDiscriminador:")
print(f"  Parámetros totales:      {discriminator.count_params():,}")
print(f"  Entrada:                 imagen 32×32×3 (RGB)")
print(f"  Salida:                  probabilidad ∈ [0, 1]")
print(f"  Activación de salida:    sigmoid")
print(f"\\nTotal combinado:           {generator.count_params() + discriminator.count_params():,}")""")))

# =====================================================================
# CELDA 11: Configuración del entrenamiento
# =====================================================================
cells.append(md_cell(fix_source("""## 5. Configuración del Entrenamiento

### 5.1 Función de Pérdida y Optimizadores

Utilizamos **Binary Cross-Entropy** como función de pérdida para ambas redes, 
con el optimizador **Adam** (Kingma & Ba, 2015) configurado según las recomendaciones 
del paper original de DCGAN:

| Hiperparámetro | Valor | Justificación |
|----------------|-------|---------------|
| Learning rate | 1×10⁻⁴ | Recomendación DCGAN (Radford et al., 2016) |
| β₁ (Adam) | 0.5 | Estabilidad en entrenamiento adversarial |
| β₂ (Adam) | 0.999 | Valor por defecto |
| Épocas | 50 | Balance entre calidad y tiempo computacional |
| Batch size | 128 | Estándar para DCGAN |
| Dim. latente | 100 | Espacio latente suficiente para diversidad |

### 5.2 Dinámica del Entrenamiento

Como explicó el profesor en clase, el entrenamiento funciona así:

1. El **discriminador** se entrena con un batch de imágenes reales (etiqueta=1) y 
   un batch de imágenes falsas del generador (etiqueta=0)
2. El **generador** se entrena intentando que el discriminador clasifique sus 
   imágenes falsas como reales (etiqueta=1)
3. Ambos mejoran iterativamente hasta alcanzar un equilibrio""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 5. CONFIGURACIÓN DEL ENTRENAMIENTO
# ===========================================================================

# Función de pérdida: Binary Cross-Entropy
loss_fn = tf.keras.losses.BinaryCrossentropy()

# Optimizadores Adam con hiperparámetros de DCGAN
optimizer_G = tf.keras.optimizers.Adam(learning_rate=1e-4, beta_1=0.5)
optimizer_D = tf.keras.optimizers.Adam(learning_rate=1e-4, beta_1=0.5)

# Hiperparámetros de entrenamiento
EPOCHS = 50
NUM_EXAMPLES_TO_GENERATE = 36  # 6×6 grid para visualización

# Vector de ruido fijo para monitorear el progreso (siempre el mismo)
seed_noise = tf.random.normal([NUM_EXAMPLES_TO_GENERATE, LATENT_DIM])

print("Configuración del entrenamiento:")
print(f"  Épocas:           {EPOCHS}")
print(f"  Batch size:       {BATCH_SIZE}")
print(f"  Learning rate:    1e-4")
print(f"  β₁ (Adam):        0.5")
print(f"  Dim. latente:     {LATENT_DIM}")
print(f"  Función pérdida:  Binary Cross-Entropy")""")))

# =====================================================================
# CELDA 12: Paso de entrenamiento
# =====================================================================
cells.append(md_cell(fix_source("""### 5.3 Paso de Entrenamiento (Train Step)

El paso de entrenamiento se implementa con `@tf.function` para compilación 
en grafo y mayor eficiencia. Cada iteración actualiza primero el discriminador 
y luego el generador, siguiendo el protocolo estándar de GAN.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# PASO DE ENTRENAMIENTO CON @tf.function
# ===========================================================================

@tf.function
def train_step(real_images):
    \"\"\"
    Ejecuta un paso de entrenamiento adversarial.
    
    1. Entrena el Discriminador con imágenes reales y falsas
    2. Entrena el Generador para engañar al Discriminador
    
    Returns:
        loss_D: Pérdida del discriminador
        loss_G: Pérdida del generador
    \"\"\"
    batch_size = tf.shape(real_images)[0]
    noise = tf.random.normal([batch_size, LATENT_DIM])
    
    # ── Entrenamiento del Discriminador ──
    with tf.GradientTape() as tape_D:
        # Generar imágenes falsas
        fake_images = generator(noise, training=True)
        
        # Predicciones del discriminador
        real_output = discriminator(real_images, training=True)
        fake_output = discriminator(fake_images, training=True)
        
        # Pérdida: reales → 1, falsas → 0
        loss_real = loss_fn(tf.ones_like(real_output), real_output)
        loss_fake = loss_fn(tf.zeros_like(fake_output), fake_output)
        loss_D = loss_real + loss_fake
    
    # Actualizar pesos del discriminador
    gradients_D = tape_D.gradient(loss_D, discriminator.trainable_variables)
    optimizer_D.apply_gradients(zip(gradients_D, discriminator.trainable_variables))
    
    # ── Entrenamiento del Generador ──
    noise = tf.random.normal([batch_size, LATENT_DIM])
    
    with tf.GradientTape() as tape_G:
        fake_images = generator(noise, training=True)
        fake_output = discriminator(fake_images, training=True)
        
        # Pérdida: queremos que D clasifique las falsas como reales (→ 1)
        loss_G = loss_fn(tf.ones_like(fake_output), fake_output)
    
    # Actualizar pesos del generador
    gradients_G = tape_G.gradient(loss_G, generator.trainable_variables)
    optimizer_G.apply_gradients(zip(gradients_G, generator.trainable_variables))
    
    return loss_D, loss_G""")))

# =====================================================================
# CELDA 13: Función de visualización
# =====================================================================
cells.append(md_cell(fix_source("""### 5.4 Función de Visualización

Para monitorear el progreso del generador durante el entrenamiento, generamos 
imágenes a intervalos regulares usando siempre el mismo vector de ruido (`seed_noise`). 
Esto permite observar cómo las imágenes evolucionan época tras época.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# FUNCIÓN DE VISUALIZACIÓN DURANTE ENTRENAMIENTO
# ===========================================================================

def generate_and_save_images(model, epoch, test_input, save=True):
    \"\"\"Genera y visualiza imágenes usando el generador actual.\"\"\"
    predictions = model(test_input, training=False)
    
    # Desnormalizar de [-1, 1] a [0, 1]
    predictions = (predictions + 1) / 2.0
    predictions = tf.clip_by_value(predictions, 0.0, 1.0)
    
    fig, axes = plt.subplots(6, 6, figsize=(10, 10))
    fig.suptitle(f'Imágenes generadas — Época {epoch}', 
                 fontsize=14, fontweight='bold')
    
    for i in range(36):
        row, col = i // 6, i % 6
        axes[row, col].imshow(predictions[i].numpy())
        axes[row, col].axis('off')
    
    plt.tight_layout()
    
    if save:
        filename = f'dcgan_cifar10_epoca_{epoch:03d}.png'
        plt.savefig(filename, dpi=150, bbox_inches='tight')
    
    plt.show()

# Verificar función con imágenes aleatorias (antes de entrenar)
print("Imágenes del generador SIN entrenar (ruido puro):")
generate_and_save_images(generator, 0, seed_noise, save=False)""")))

# =====================================================================
# CELDA 14: Bucle de entrenamiento
# =====================================================================
cells.append(md_cell(fix_source("""## 6. Entrenamiento de la DCGAN

Ejecutamos el bucle de entrenamiento por 50 épocas. Se registran las pérdidas 
de ambas redes para analizar la convergencia.

> **Nota sobre recursos computacionales:** Como mencionó el profesor, el cálculo 
> computacional crece con la complejidad de la arquitectura. En Google Colab con 
> GPU, el entrenamiento toma aproximadamente 15-25 minutos. Sin GPU puede tomar 
> varias horas — si es el caso, se puede reducir EPOCHS a 30.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 6. BUCLE DE ENTRENAMIENTO
# ===========================================================================
print("=" * 60)
print("INICIO DEL ENTRENAMIENTO DCGAN")
print("=" * 60)

# Registros de pérdidas
history_loss_D = []
history_loss_G = []

start_total = time.time()

for epoch in range(1, EPOCHS + 1):
    start_epoch = time.time()
    
    epoch_loss_D = []
    epoch_loss_G = []
    
    for batch in train_dataset:
        d_loss, g_loss = train_step(batch)
        epoch_loss_D.append(d_loss.numpy())
        epoch_loss_G.append(g_loss.numpy())
    
    # Promediar pérdidas de la época
    avg_loss_D = np.mean(epoch_loss_D)
    avg_loss_G = np.mean(epoch_loss_G)
    history_loss_D.append(avg_loss_D)
    history_loss_G.append(avg_loss_G)
    
    elapsed = time.time() - start_epoch
    
    # Imprimir progreso
    if epoch % 5 == 0 or epoch == 1:
        print(f"Época {epoch:3d}/{EPOCHS} | "
              f"Loss D: {avg_loss_D:.4f} | "
              f"Loss G: {avg_loss_G:.4f} | "
              f"Tiempo: {elapsed:.1f}s")
    
    # Visualizar cada 10 épocas
    if epoch % 10 == 0:
        generate_and_save_images(generator, epoch, seed_noise)

total_time = time.time() - start_total
print(f"\\n{'=' * 60}")
print(f"ENTRENAMIENTO COMPLETADO")
print(f"Tiempo total: {total_time/60:.1f} minutos")
print(f"{'=' * 60}")""")))

# =====================================================================
# CELDA 15: Curvas de pérdida
# =====================================================================
cells.append(md_cell(fix_source("""## 7. Análisis de Resultados

### 7.1 Curvas de Pérdida

Las curvas de pérdida del Generador y el Discriminador permiten evaluar si el 
entrenamiento converge adecuadamente. En una DCGAN bien entrenada, se espera que:

- Las pérdidas oscilen sin que ninguna se acerque a cero permanentemente
- El discriminador no domine completamente al generador (ni viceversa)
- Ambas pérdidas tiendan a estabilizarse en valores cercanos""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 7.1 CURVAS DE PÉRDIDA
# ===========================================================================
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Pérdida del Discriminador
ax1.plot(history_loss_D, color='#e74c3c', linewidth=1.5, label='Discriminador')
ax1.set_title('Pérdida del Discriminador', fontsize=13, fontweight='bold')
ax1.set_xlabel('Época')
ax1.set_ylabel('Loss')
ax1.legend()
ax1.grid(True, alpha=0.3)

# Pérdida del Generador
ax2.plot(history_loss_G, color='#3498db', linewidth=1.5, label='Generador')
ax2.set_title('Pérdida del Generador', fontsize=13, fontweight='bold')
ax2.set_xlabel('Época')
ax2.set_ylabel('Loss')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.suptitle('Curvas de Entrenamiento DCGAN — CIFAR-10', fontsize=15, fontweight='bold', y=1.02)
plt.tight_layout()
plt.savefig('curvas_perdida_dcgan.png', dpi=150, bbox_inches='tight')
plt.show()

# Ambas juntas para comparar
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(history_loss_D, color='#e74c3c', linewidth=1.5, alpha=0.8, label='Discriminador')
ax.plot(history_loss_G, color='#3498db', linewidth=1.5, alpha=0.8, label='Generador')
ax.set_title('Comparación de Pérdidas G vs D', fontsize=13, fontweight='bold')
ax.set_xlabel('Época')
ax.set_ylabel('Loss')
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('comparacion_perdidas_GvsD.png', dpi=150, bbox_inches='tight')
plt.show()

print(f"\\nPérdida final del Discriminador: {history_loss_D[-1]:.4f}")
print(f"Pérdida final del Generador:     {history_loss_G[-1]:.4f}")""")))

# =====================================================================
# CELDA 16: Generación de 30+ imágenes
# =====================================================================
cells.append(md_cell(fix_source("""### 7.2 Generación de Imágenes Sintéticas

A continuación, generamos un **collage de 36 imágenes nuevas** (6×6) utilizando 
el generador entrenado. Estas imágenes son completamente sintéticas: no existen 
en el dataset original y fueron creadas por la red a partir de vectores de ruido 
aleatorio.

Este es el resultado final que busca la actividad: imágenes que la red ha 
aprendido a crear después de observar miles de ejemplos reales de CIFAR-10.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 7.2 GENERACIÓN DE 36 IMÁGENES NUEVAS (COLLAGE FINAL)
# ===========================================================================
print("Generando 36 imágenes nuevas con la DCGAN entrenada...\\n")

# Generar con nuevo ruido aleatorio (no el seed fijo)
noise_final = tf.random.normal([36, LATENT_DIM])
generated_final = generator(noise_final, training=False)

# Desnormalizar
generated_final = (generated_final + 1) / 2.0
generated_final = tf.clip_by_value(generated_final, 0.0, 1.0)

# Crear collage 6×6
fig, axes = plt.subplots(6, 6, figsize=(12, 12))
fig.suptitle('Collage de 36 Imágenes Generadas por la DCGAN\\n(Entrenada con CIFAR-10)', 
             fontsize=16, fontweight='bold')

for i in range(36):
    row, col = i // 6, i % 6
    axes[row, col].imshow(generated_final[i].numpy())
    axes[row, col].axis('off')
    axes[row, col].set_title(f'Img {i+1}', fontsize=8)

plt.tight_layout()
plt.savefig('collage_36_imagenes_generadas.png', dpi=200, bbox_inches='tight')
plt.show()

print(f"\\n✅ Se generaron 36 imágenes nuevas exitosamente")
print(f"   Collage guardado como: collage_36_imagenes_generadas.png")""")))

# =====================================================================
# CELDA 17: Progresión temporal
# =====================================================================
cells.append(md_cell(fix_source("""### 7.3 Evolución Temporal de las Imágenes Generadas

Para ilustrar cómo la red aprende progresivamente, mostramos la evolución de las 
imágenes generadas a lo largo del entrenamiento. Se puede observar cómo las imágenes 
pasan de ser ruido puro (época 1) a formas cada vez más reconocibles.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 7.3 EVOLUCIÓN TEMPORAL
# ===========================================================================
# Generar imágenes con el seed fijo en diferentes "momentos" del entrenamiento
# (Ya se guardaron durante el entrenamiento, aquí mostramos la final)

print("Evolución del generador durante el entrenamiento:")
print(f"  Época  1: Ruido aleatorio puro")
print(f"  Época 10: Primeras formas emergentes")
print(f"  Época 20: Estructuras más definidas")
print(f"  Época 30: Colores y formas reconocibles")
print(f"  Época 40: Mayor coherencia visual")
print(f"  Época 50: Resultado final")

# Mostrar resultado final con el seed fijo
generate_and_save_images(generator, EPOCHS, seed_noise, save=True)""")))

# =====================================================================
# CELDA 18: Comparación real vs generada
# =====================================================================
cells.append(md_cell(fix_source("""### 7.4 Comparación: Imágenes Reales vs. Generadas

Una forma directa de evaluar la calidad es comparar visualmente las imágenes 
reales del dataset con las generadas por la red.""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 7.4 COMPARACIÓN REAL VS GENERADA
# ===========================================================================
fig, (ax_real, ax_fake) = plt.subplots(1, 2, figsize=(14, 6))

# Panel izquierdo: Imágenes reales
real_sample = (X_train[:16] + 1) / 2.0
grid_real = np.zeros((4*32, 4*32, 3))
for i in range(16):
    r, c = i // 4, i % 4
    grid_real[r*32:(r+1)*32, c*32:(c+1)*32] = real_sample[i]

ax_real.imshow(grid_real)
ax_real.set_title('Imágenes Reales (CIFAR-10)', fontsize=13, fontweight='bold')
ax_real.axis('off')

# Panel derecho: Imágenes generadas
noise_comp = tf.random.normal([16, LATENT_DIM])
fake_comp = generator(noise_comp, training=False)
fake_comp = tf.clip_by_value((fake_comp + 1) / 2.0, 0.0, 1.0).numpy()

grid_fake = np.zeros((4*32, 4*32, 3))
for i in range(16):
    r, c = i // 4, i % 4
    grid_fake[r*32:(r+1)*32, c*32:(c+1)*32] = fake_comp[i]

ax_fake.imshow(grid_fake)
ax_fake.set_title('Imágenes Generadas (DCGAN)', fontsize=13, fontweight='bold')
ax_fake.axis('off')

plt.suptitle('Comparación: Real vs. Generada', fontsize=15, fontweight='bold')
plt.tight_layout()
plt.savefig('comparacion_real_vs_generada.png', dpi=150, bbox_inches='tight')
plt.show()""")))

# =====================================================================
# CELDA 19: Métricas
# =====================================================================
cells.append(md_cell(fix_source("""### 7.5 Resumen de Métricas de Rendimiento""")))

cells.append(code_cell(fix_source("""# ===========================================================================
# 7.5 RESUMEN DE MÉTRICAS
# ===========================================================================
print("=" * 60)
print("RESUMEN DE MÉTRICAS DE RENDIMIENTO")
print("=" * 60)
print(f"\\nDataset:                 CIFAR-10 (32×32×3, RGB)")
print(f"Imágenes entrenamiento:  {len(X_train):,}")
print(f"Épocas completadas:      {EPOCHS}")
print(f"Tiempo total:            {total_time/60:.1f} minutos")
print(f"\\nPérdida final Discriminador: {history_loss_D[-1]:.4f}")
print(f"Pérdida final Generador:     {history_loss_G[-1]:.4f}")
print(f"\\nParámetros del Generador:     {generator.count_params():,}")
print(f"Parámetros del Discriminador: {discriminator.count_params():,}")
print(f"Parámetros totales:           {generator.count_params() + discriminator.count_params():,}")
print(f"\\nImágenes generadas:          36 (collage 6×6)")
print(f"\\n{'=' * 60}")""")))

# =====================================================================
# CELDA 20: Conclusiones
# =====================================================================
cells.append(md_cell(fix_source("""## 8. Conclusiones

### Alcance de los Objetivos

Se logró diseñar e implementar exitosamente una **DCGAN (Deep Convolutional Generative 
Adversarial Network)** capaz de generar imágenes sintéticas a color de 32×32 píxeles, 
entrenada con el dataset CIFAR-10. Se cumplió el objetivo de producir un conjunto de 
**36 imágenes nuevas** (superando las 30 requeridas).

La arquitectura, basada en las recomendaciones de Radford et al. (2016) y adaptada a 
partir del material del curso (notebooks del Drive y la Open Class del profesor Félix 
Julián), demostró ser funcional para la generación de imágenes a baja resolución.

### Reflexión sobre la Calidad

Reconocemos que las imágenes generadas a 32×32 de resolución tienen limitaciones 
inherentes. A diferencia de MNIST (donde generar dígitos legibles es relativamente 
sencillo porque son imágenes binarias de 28×28), CIFAR-10 presenta un desafío 
considerablemente mayor: las imágenes son a color, contienen objetos complejos y 
la resolución es muy baja. Esto significa que incluso las GANs más sofisticadas 
tienen dificultades para generar imágenes fotorrealistas a esta escala.

### Desafíos Encontrados

1. **Inestabilidad del entrenamiento:** En las primeras iteraciones observamos 
   oscilaciones significativas en las pérdidas, algo esperado en el entrenamiento 
   adversarial.
2. **Recursos computacionales:** Como señaló el profesor, el costo computacional 
   crece rápidamente con la complejidad de la arquitectura.
3. **Mode collapse parcial:** En algunos experimentos, el generador tendía a 
   producir imágenes con paletas de color similares.

### Aplicabilidad Profesional

Desde la perspectiva de nuestro desempeño profesional, las redes generativas 
adversarias tienen aplicaciones directas que van más allá de la generación de 
imágenes artísticas. En el ámbito de la inteligencia artificial aplicada, 
las GANs pueden utilizarse para:

- **Aumentación de datos:** Generar datos sintéticos de entrenamiento cuando 
  los datos reales son escasos o costosos de obtener
- **Anonimización:** Crear versiones sintéticas de documentos o imágenes para 
  proteger información sensible
- **Simulación:** Generar escenarios hipotéticos para pruebas y validación

En particular, en el contexto de la transformación digital del sector público, 
estas técnicas podrían emplearse para generar documentos de prueba sintéticos 
que permitan entrenar sistemas de procesamiento automático sin comprometer 
información real de los ciudadanos.

---

## 9. Referencias

- Dadhich, A. (2018). Chapter 5: Convolutional Neural Networks. En *Practical computer 
  vision: Extract insightful information from images using TensorFlow, Keras, and OpenCV* 
  (pp. 88-125). Packt Publishing.

- Goodfellow, I. J., Pouget-Abadie, J., Mirza, M., Xu, B., Warde-Farley, D., Ozair, S., 
  Courville, A., & Bengio, Y. (2014). Generative Adversarial Nets. *Advances in Neural 
  Information Processing Systems*, 27, 2672-2680.

- Kingma, D. P., & Ba, J. (2015). Adam: A Method for Stochastic Optimization. 
  *Proceedings of the 3rd International Conference on Learning Representations (ICLR)*.

- Radford, A., Metz, L., & Chintala, S. (2016). Unsupervised Representation Learning 
  with Deep Convolutional Generative Adversarial Networks. *Proceedings of the 4th 
  International Conference on Learning Representations (ICLR)*.

- Zafar, I. (2018). Generative Adversarial Networks. En *Hands-on convolutional neural 
  networks with TensorFlow: Solve computer vision problems with modeling in TensorFlow 
  and Python* (pp. 172-188). Packt Publishing.

---

*Notebook desarrollado por el Grupo 10 — Especialización en Inteligencia Artificial, 
UNIMINUTO, Junio 2026*""")))

# ─── Ensamblar el Notebook ─────────────────────────────────────────────
notebook = {
    "nbformat": 4,
    "nbformat_minor": 5,
    "metadata": {
        "kernelspec": {
            "display_name": "Python 3",
            "language": "python",
            "name": "python3"
        },
        "language_info": {
            "name": "python",
            "version": "3.10.12",
            "mimetype": "text/x-python",
            "codemirror_mode": {"name": "ipython", "version": 3},
            "file_extension": ".py",
            "pygments_lexer": "ipython3",
            "nbconvert_exporter": "python"
        },
        "colab": {
            "provenance": [],
            "gpuType": "T4"
        },
        "accelerator": "GPU"
    },
    "cells": cells
}

# ─── Guardar ───────────────────────────────────────────────────────────
output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                           "Oviedo_Alexander_DCGAN_RGA_S8_VC.ipynb")

with open(output_path, "w", encoding="utf-8") as f:
    json.dump(notebook, f, ensure_ascii=False, indent=1)

print(f"✅ Notebook generado exitosamente: {output_path}")
print(f"   Celdas totales: {len(cells)}")
print(f"   Celdas markdown: {sum(1 for c in cells if c['cell_type'] == 'markdown')}")
print(f"   Celdas de código: {sum(1 for c in cells if c['cell_type'] == 'code')}")
