A 05-01 vas muntar l'esquelet d'una DCGAN — generador amb Conv2DTranspose, discriminador convolucional, la metàfora del falsificador i el policia — i vam deixar pendent la part més delicada: el bucle d'entrenament complet. Amb el GradientTape que vas dominar a 06-01, ja tens l'eina exacta per escriure'l. En aquest quart projecte entrenaràs una GAN de cap a cap sobre Fashion-MNIST (peces de roba com a substitut de fotos de producte) com a prototip d'un generador d'imatges per a les creativitats promocionals de TecnoMarket, aprenent a llegir la dinàmica d'un entrenament adversari: què és normal, què és mode collapse i què tocar quan alguna cosa va malament.

Contingut

  1. Enunciat del projecte i expectatives realistes
  2. Fase 1: dades — Fashion-MNIST amb tf.data
  3. Fase 2: generador i discriminador (reprenent 05-01)
  4. Fase 3: les dues pèrdues adversàries
  5. Fase 4: un pas d'entrenament amb GradientTape, línia a línia
  6. Fase 5: el bucle per èpoques amb graelles de mostres
  7. Fase 6: llegir la dinàmica — el que és normal, el mode collapse i què tocar
  8. Fase 7: avaluació, desat del generador i ús posterior

Enunciat del projecte i expectatives realistes

Context de negoci. L'equip de màrqueting de TecnoMarket vol explorar la generació d'imatges per a creativitats promocionals (fons, variacions de producte, il·lustracions de campanya). Abans d'invertir, demanen un prototip educatiu que demostri que l'equip domina la mecànica generativa.

Substitut. Fashion-MNIST: 60 000 imatges 28×28 en escala de grisos de peces de roba (samarretes, sabatilles, bosses...), un substitut raonable de fotos de producte simples. Petit, ràpid d'entrenar i prou variat per veure els fenòmens que importen.

Expectatives, per escrit abans de començar: una DCGAN d'aquesta mida produeix peces de roba recognoscibles però borroses a 28×28. No és StyleGAN ni un producte comercial; els sistemes de producció actuals fan servir models de difusió (els veuràs esmentats a 08-03) i tota generació d'imatges comercials arrossega implicacions ètiques que tractarem a 08-01. L'objectiu aquí és dominar el mecanisme adversari — que és també la base conceptual per entendre els sistemes moderns.

Fase 1: dades — Fashion-MNIST amb tf.data

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt

tf.random.set_seed(42)

(x_train, _), _ = tf.keras.datasets.fashion_mnist.load_data()

# Normalitzar a [-1, 1]: el generador acabarà en tanh (05-01)
x_train = (x_train.astype("float32") - 127.5) / 127.5
x_train = x_train[..., np.newaxis]           # (60000, 28, 28, 1)

BATCH = 128
train_ds = (tf.data.Dataset.from_tensor_slices(x_train)
            .shuffle(60_000)
            .batch(BATCH, drop_remainder=True)
            .prefetch(tf.data.AUTOTUNE))

Detall important que ja vam anticipar a 05-01: el rang [-1, 1] (no [0, 1]) perquè la sortida del generador serà tanh. Generador i dades reals han de parlar el mateix idioma numèric o el discriminador els distingirà pel rang, no pel contingut. No hi ha etiquetes ni conjunt de test: en una GAN les «dades» són només la referència de realitat.

Fase 2: generador i discriminador (reprenent 05-01)

Instanciem l'esquelet DCGAN de 05-01 adaptat a 28×28. No en reexpliquem les peces (repassa-les allà); n'anotem les decisions:

from tensorflow.keras import layers, models

DIM_SOROLL = 100

def crear_generador():
    return models.Sequential([
        layers.Input(shape=(DIM_SOROLL,)),
        layers.Dense(7 * 7 * 256, use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),
        layers.Reshape((7, 7, 256)),
        layers.Conv2DTranspose(128, 5, strides=1, padding="same", use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),        # 7x7
        layers.Conv2DTranspose(64, 5, strides=2, padding="same", use_bias=False),
        layers.BatchNormalization(), layers.LeakyReLU(),        # 14x14
        layers.Conv2DTranspose(1, 5, strides=2, padding="same",
                               activation="tanh"),              # 28x28, [-1,1]
    ], name="generador")

def crear_discriminador():
    return models.Sequential([
        layers.Input(shape=(28, 28, 1)),
        layers.Conv2D(64, 5, strides=2, padding="same"),
        layers.LeakyReLU(), layers.Dropout(0.3),                # 14x14
        layers.Conv2D(128, 5, strides=2, padding="same"),
        layers.LeakyReLU(), layers.Dropout(0.3),                # 7x7
        layers.Flatten(),
        layers.Dense(1),                                        # logit: real o fals
    ], name="discriminador")

generador = crear_generador()
discriminador = crear_discriminador()

Recordatoris de 05-01 aplicats: Conv2DTranspose amb strides=2 duplica la resolució (7→14→28), BN i LeakyReLU estabilitzen el generador, el discriminador fa servir dropout (no BN) i retorna un logit sense sigmoide — la pèrdua se n'encarregarà.

Fase 3: les dues pèrdues adversàries

El joc de 05-01, ara en codi. Totes dues pèrdues parteixen de la mateixa entropia creuada binària sobre logits:

bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)

def perdua_discriminador(logits_reals, logits_falsos):
    # El policia vol: reals -> 1, falses -> 0
    perdua_real = bce(tf.ones_like(logits_reals), logits_reals)
    perdua_falsa = bce(tf.zeros_like(logits_falsos), logits_falsos)
    return perdua_real + perdua_falsa

def perdua_generador(logits_falsos):
    # El falsificador vol que les SEVES falses semblin reals (etiqueta 1)
    return bce(tf.ones_like(logits_falsos), logits_falsos)

# Dos optimitzadors separats: cada xarxa aprèn pel seu compte (05-01)
opt_gen = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)
opt_disc = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)

L'asimetria és l'essència: el discriminador puntua les mateixes imatges falses amb objectiu 0 i el generador amb objectiu 1. Els hiperparàmetres lr=2e-4, beta_1=0.5 són els clàssics de DCGAN — funcionen; no els toquis a la primera passada.

Fase 4: un pas d'entrenament amb GradientTape, línia a línia

Aquí s'ajunta tot: el GradientTape de 06-01 ens permet calcular dos conjunts de gradients d'un mateix graf i aplicar-los a xarxes diferents. Comentat línia a línia:

@tf.function                      # compila el pas a graf: ~5-10x més ràpid (06-01)
def pas_entrenament(imatges_reals):
    soroll = tf.random.normal([BATCH, DIM_SOROLL])        # 1. lot de soroll

    with tf.GradientTape() as tape_g, tf.GradientTape() as tape_d:
        imatges_falses = generador(soroll, training=True)  # 2. falsificar

        logits_reals = discriminador(imatges_reals, training=True)   # 3. jutjar reals
        logits_falsos = discriminador(imatges_falses, training=True) # 4. jutjar falses

        perd_g = perdua_generador(logits_falsos)           # 5. quant enganya
        perd_d = perdua_discriminador(logits_reals, logits_falsos)  # 6. quant encerta

    # 7. Gradients de la pèrdua de CADA xarxa respecte a les SEVES variables
    grads_g = tape_g.gradient(perd_g, generador.trainable_variables)
    grads_d = tape_d.gradient(perd_d, discriminador.trainable_variables)

    # 8. Cada optimitzador actualitza només la seva xarxa
    opt_gen.apply_gradients(zip(grads_g, generador.trainable_variables))
    opt_disc.apply_gradients(zip(grads_d, discriminador.trainable_variables))
    return perd_g, perd_d

Punts fins que has d'entendre, no només copiar:

  • Dos tapes, una passada: tots dos graven les mateixes operacions, però el pas 7 demana a cada tape gradients només de la seva pèrdua respecte a les seves variables. En actualitzar el generador no toquem el discriminador, i viceversa — si barregessis variables, cada xarxa sabotejaria l'altra.
  • training=True a totes dues xarxes sempre: la BN del generador ha de fer servir estadístiques del lot també quan les seves imatges alimenten la pèrdua del discriminador.
  • @tf.function: el mateix decorador que vam veure a 06-01; en un bucle personalitzat com aquest, la diferència de velocitat és enorme.
  • Aquest és el patró que Keras fit() no et dona fet: dues xarxes, dues pèrdues enfrontades, un pas simultani. Per això la promesa de 05-01 va haver d'esperar fins a 06-01.

Fase 5: el bucle per èpoques amb graelles de mostres

En una GAN, la pèrdua no explica tota la veritat (fase 6), així que el bucle genera periòdicament una graella de mostres amb el mateix soroll fix, per comparar èpoques en igualtat de condicions:

soroll_fix = tf.random.normal([16, DIM_SOROLL], seed=42)   # SEMPRE el mateix

def desar_graella(epoca):
    mostres = generador(soroll_fix, training=False)
    mostres = (mostres + 1) / 2                  # [-1,1] -> [0,1] per pintar
    plt.figure(figsize=(4, 4))
    for i in range(16):
        plt.subplot(4, 4, i + 1)
        plt.imshow(mostres[i, :, :, 0], cmap="gray")
        plt.axis("off")
    plt.savefig(f"logs/gan/graella_epoca_{epoca:03d}.png")
    plt.close()

EPOQUES = 50
for epoca in range(1, EPOQUES + 1):
    perdues_g, perdues_d = [], []
    for lot in train_ds:
        pg, pd = pas_entrenament(lot)
        perdues_g.append(float(pg)); perdues_d.append(float(pd))
    print(f"Època {epoca:3d} | G: {np.mean(perdues_g):.3f} "
          f"| D: {np.mean(perdues_d):.3f}")
    if epoca % 5 == 0 or epoca == 1:
        desar_graella(epoca)
        generador.save(f"models/generador_epoca_{epoca:03d}.keras")

En GPU, cada època triga ~20-40 s (50 èpoques ≈ mitja hora); en CPU és lent — redueix a 15-20 èpoques o fes servir Colab (01-05). Desem checkpoints del generador cada 5 èpoques: en les GAN, la «millor època» es tria mirant graelles, no pèrdues, així que convé poder tornar enrere.

Fase 6: llegir la dinàmica — el que és normal, el mode collapse i què tocar

Evolució visual típica i honesta (les teves èpoques exactes variaran):

Èpoques Què veuràs a la graella
1-3 Soroll gris amb taques: res de recognoscible
5-10 Taques clares sobre fons fosc: «proto-peces» borroses
15-25 Siluetes clares: es distingeixen samarretes, pantalons, sabates
30-50 Peces recognoscibles amb textura bàsica; vores encara toves

Pèrdues: què és normal. A diferència d'un classificador, aquí les pèrdues no han de convergir a zero: és un equilibri, no un descens. Sa: G oscil·lant en ~0.7-1.5 i D en ~1.0-1.3, totes dues movent-se sense tendència clara. Senyals d'alarma:

Símptoma Diagnòstic Què tocar (de 05-01)
D → 0 i G creix sense parar Discriminador massa fort: el generador no rep senyal útil Abaixa el lr del discriminador (p. ex. 1e-4), o afegeix soroll/etiquetes suavitzades (0.9 en lloc d'1) als reals
Les 16 mostres de la graella són gairebé idèntiques Mode collapse: el generador ha trobat una imatge que enganya i la repeteix Més entropia: apuja el lr del generador lleugerament, revisa el suavitzat d'etiquetes, reinicia des d'un checkpoint anterior
Tot oscil·la violentament i les mostres empitjoren Learning rates massa alts Abaixa tots dos lr a la meitat
Graelles estancades 15+ èpoques Equilibri mort Prova més capacitat al generador o més èpoques; de vegades només necessita temps

El diagnòstic es fa amb les graelles primer i les corbes després — exactament al revés que en els projectes anteriors.

Fase 7: avaluació, desat del generador i ús posterior

Avaluació qualitativa. El protocol mínim: (1) graelles del soroll fix època a època — milloren?; (2) diversitat — genera 64 mostres noves i comprova que hi apareixen diversos tipus de peça; (3) un cop d'ull als veïns — per a alguna mostra generada, busca la imatge real més semblant del dataset i verifica que no és una còpia memoritzada.

Mètriques quantitatives, només el concepte. En recerca es fa servir el FID (Fréchet Inception Distance): compara estadístiques d'imatges reals i generades a l'espai de característiques d'una xarxa preentrenada — com més baix, millor. Per a aquest prototip n'hi ha prou de saber que existeix i que la inspecció visual disciplinada és l'estàndard pràctic a aquesta escala.

Lliurament. Només es desplega el generador — el discriminador ha estat l'entrenador personal i es queda a casa:

generador.save("models/generador_creativitats_v1.keras")

# Ús posterior: noves imatges des de soroll
gen = tf.keras.models.load_model("models/generador_creativitats_v1.keras")
noves = gen(tf.random.normal([8, DIM_SOROLL]), training=False)
noves = ((noves + 1) / 2).numpy()   # a [0,1]: el "des-preprocessament" viatja documentat (06-05)

Informe honest per a màrqueting: «dominem la mecànica generativa i podem produir imatges sintètiques de baixa resolució; per a creativitats en qualitat de producció calen models de difusió (08-03) i una anàlisi prèvia d'implicacions ètiques i de drets (08-01) — tota imatge sintètica publicada s'ha d'identificar com a tal».

Errors Comuns i Consells

  • Entrenar el discriminador fins a la perfecció «primer»: un discriminador perfecte dona un gradient gairebé nul al generador. Han de créixer junts: un pas cadascun, com al nostre bucle.
  • Canviar el soroll de la graella a cada època: sense soroll fix no pots saber si millora el generador o només ha canviat la mostra. El soroll fix és el teu conjunt congelat particular (06-05).
  • Interpretar les pèrdues com en un classificador: que la pèrdua del generador pugi no vol dir que empitjori — potser el discriminador ha millorat. Graelles primer.
  • Oblidar drop_remainder=True: un últim lot de mida diferent pot trencar formes dins de @tf.function.
  • Desesperar-se a l'època 10: les GAN són lentes a arrencar i no lineals a millorar. Jutja cada 5-10 èpoques, amb checkpoints per tornar al millor punt.

Exercicis

  1. Afegeix suavitzat d'etiquetes al discriminador (reals = 0.9 en lloc d'1.0) i compara l'estabilitat de les corbes i les graelles amb la versió original durant 20 èpoques.
  2. Entrena amb DIM_SOROLL = 2 i visualitza què genera el model recorrent una malla de valors del soroll (per exemple, de -2 a 2 en cada dimensió). Què observes sobre l'espai latent? Hi apareix mode collapse?
  3. Interpola a l'espai latent: agafa dos vectors de soroll z1, z2, genera imatges per a z = (1-t)*z1 + t*z2 amb t de 0 a 1 en 8 passos, i mostra la transició. Relació amb els embeddings de 03-04.

Solucions

  1. Canvia a perdua_discriminador: bce(tf.ones_like(logits_reals) * 0.9, logits_reals). Efecte típic: la pèrdua de D deixa d'enfonsar-se cap a 0, la de G oscil·la menys i les graelles progressen de manera més estable — és un dels remeis de 05-01 en acció.
  2. Amb només 2 dimensions latents, la malla [(x, y) for x in np.linspace(-2,2,8) for y in np.linspace(-2,2,8)] genera una graella on regions veïnes produeixen peces semblants: visualitzes l'espai latent sencer. La diversitat total baixa (2 dimensions donen poc marge) i el risc de collapse augmenta: veuràs poques classes de peça representades. Conclusió: la dimensió del soroll acota quanta varietat pot codificar el generador.
  3. for t in np.linspace(0, 1, 8): imgs.append(gen((1-t)*z1 + t*z2, training=False)). La transició és suau: una samarreta es transforma gradualment en un pantaló passant per formes intermèdies plausibles. Com en els embeddings de 03-04, la proximitat a l'espai latent codifica similitud semàntica — el generador ha organitzat el soroll en un mapa continu de peces de roba.

Conclusió

Quart projecte lliurat i la promesa més antiga del curs saldada: el bucle adversari complet, escrit a mà amb dos GradientTape, dues pèrdues i dos optimitzadors, amb graelles de soroll fix com a instrument d'avaluació i un diagnòstic pràctic de la dinàmica GAN (equilibri, no convergència; mode collapse i els seus remeis). El resultat és un prototip honest — peces de roba recognoscibles, no fotografia de producte — i un informe clar de què caldria per a producció. Queda un projecte, i és el que tanca el cercle: a 07-05 tornarem al classificador d'imatges de 07-01 i batrem el seu 85 % amb transfer learning, comparant xifres cara a cara per prendre la decisió final de TecnoMarket.

Curs de Deep Learning

Mòdul 1: Introducció al Deep Learning

Mòdul 2: Fonaments de Xarxes Neuronals

Mòdul 3: Xarxes Neuronals Convolucionals (CNN)

Mòdul 4: Xarxes Neuronals Recurrents (RNN)

Mòdul 5: Tècniques Avançades en Deep Learning

Mòdul 6: Eines i Frameworks

Mòdul 7: Projectes Pràctics

Mòdul 8: Consideracions Ètiques i Futur del Deep Learning

© Copyright 2026. Tots els drets reservats