fast.ai · Parte 2 · Unidad 5 de 5 (última)
Stable Diffusion completo: todas las piezas juntas
Las cuatro unidades anteriores construyeron cada pieza por separado: un framework de entrenamiento, redes estables, el proceso matemático de difusión, VAE y attention. Esta unidad las junta en el U-Net condicional real que genera imágenes a partir de texto.
Antes de leerU4, VAE y atención · U3 §4, DDIM · HF cap. 3 §5, LoRA (para §5)
🎯 Al terminar vas a poder
- Generar una imagen con
StableDiffusionPipeliney nombrar los cuatro componentes que carga (tokenizer + text encoder, U-Net, VAE, scheduler) y qué unidad de este curso explica cada uno. - Escribir el loop de sampling "a mano" con classifier-free guidance (
uncond + g·(cond − uncond)) y explicar qué cambia un negative prompt en esa fórmula. - Explicar con tus palabras qué hace el time embedding en un U-Net y por qué la cross-attention con CLIP es el mecanismo que conecta texto e imagen.
- Distinguir textual inversion de DreamBooth por qué se entrena en cada uno.
- Escribir un loop de entrenamiento con
torch.autocast+GradScaler, y saber por qué BF16 no necesita el scaler.
1. Arrancar por el final: el pipeline de diffusers
La Parte 2 real de fast.ai no empieza por la matemática — empieza en la Lección 9 con Stable Diffusion funcionando en cinco líneas, para que todo lo que sigue (las cuatro unidades anteriores de este curso) sea "abrir la caja". Vale la pena tener ese punto de partida a mano:
import torch
from diffusers import StableDiffusionPipeline
pipe = StableDiffusionPipeline.from_pretrained(
"CompVis/stable-diffusion-v1-4", # el checkpoint que usa la lección; hay versiones más nuevas
torch_dtype=torch.float16, # pesos en FP16: la mitad de VRAM, ver §6
).to("cuda")
g = torch.Generator("cuda").manual_seed(42) # sin esto, cada llamada da una imagen distinta
out = pipe(
"a photograph of an astronaut riding a horse",
guidance_scale=7.5, # cuánto seguir el prompt (§4)
num_inference_steps=30, # pasos del sampler (Unidad 3)
generator=g,
)
out.images[0].save("astronauta.png")
Lo importante no es la llamada sino lo que pipe tiene adentro, porque cada atributo es una de
las unidades que ya pasaste:
| Componente | Qué hace | Dónde lo viste |
|---|---|---|
pipe.tokenizer + pipe.text_encoder | Convierten el prompt en 77 vectores de 768 dimensiones (CLIP) | §4 de esta unidad; tokenización en el curso de HF |
pipe.unet | Predice el ruido en el latente, condicionado por t y por el texto | §2; el ε de la Unidad 3 |
pipe.vae | Comprime 3×512×512 a 4×64×64 y decodifica de vuelta | Unidad 4 |
pipe.scheduler | El schedule de β y el paso de sampling (DDIM, Euler, DPM-Solver…) | Unidad 3 |
El loop que pipe(...) ejecuta por dentro está escrito a mano en §4, con guidance incluido.
k y v de una cross-attention. El loop del medio es el scheduler.step de la Unidad 3, repetido treinta veces.2. El U-Net condicional: time embeddings + atención
El modelo real de difusión no es un CNN plano — es un U-Net: una serie de bloques que reducen resolución progresivamente (downs), un cuello de botella (mids), y bloques simétricos que la recuperan (ups), con conexiones directas entre niveles equivalentes de down/up (para no perder detalle fino durante la compresión). La arquitectura viene de segmentación médica (Ronneberger et al., 2015), donde el problema era el mismo: la salida tiene que tener la resolución de la entrada, pero decidir qué poner en cada píxel requiere contexto global. Sobre esa base, dos ingredientes lo convierten en un modelo de difusión:
- Time embedding: el timestep
t(cuánto ruido hay) se convierte en un embedding sinusoidal —igual en espíritu al positional encoding de un transformer— y se inyecta en cada bloque, típicamente modulando la activación con un escalado y un desplazamiento aprendidos (x*(1+scale) + shift), para que la red sepa en qué punto del proceso de denoising está. - Self-attention (de la Unidad 4) intercalada entre bloques convolucionales en las resoluciones intermedias — demasiado cara para aplicarla a la resolución completa, muy útil donde el mapa de activaciones ya es chico.
def timestep_embedding(tsteps, emb_dim, max_period=10000):
exponent = -math.log(max_period) * torch.linspace(0, 1, emb_dim//2)
emb = tsteps[:, None].float() * exponent.exp()[None, :]
return torch.cat([emb.sin(), emb.cos()], dim=-1) # [n, emb_dim]: frecuencias de alta a baja
class EmbTransformerBlk(nn.Module):
def forward(self, x, t):
emb = self.emb_proj(F.silu(t))[:, None]
scale, shift = torch.chunk(emb, 2, dim=2)
x = x + self.attn(self.nrm1(x))
x = x*(1+scale) + shift # condicionamiento por tiempo
return x + self.mlp(self.nrm2(x))
Por qué sinusoidal y no simplemente el número t: un escalar entre 0 y 999 es una feature
pésima para una red (una sola dimensión, escala enorme). Las emb_dim/2 frecuencias
distintas convierten ese escalar en un vector donde timesteps cercanos tienen embeddings parecidos y
lejanos, distintos — la misma razón por la que los transformers no le pasan "la posición 47" a la red en
crudo. El x*(1+scale) + shift es una forma de FiLM (modulación afín por feature): el
1+ hace que con scale=0 el bloque arranque sin modificar nada, la misma
precaución de "empezar cerca de la identidad" de las ResNets.
3. Difusión latente sobre un dataset real: LSUN bedrooms
Todo lo anterior se probó sobre Fashion-MNIST (28×28, un canal) por velocidad de iteración. La prueba de fuego es un dataset de fotos reales — LSUN bedrooms, habitaciones fotografiadas en condiciones reales, RGB, mucho más variado y de mayor resolución. El U-Net entrenado directamente sobre estas imágenes (no aún en un espacio latente comprimido por un VAE, en la versión "simple" del notebook) ya produce, tras suficiente entrenamiento, imágenes reconocibles de interiores con muebles, ventanas y perspectiva — usando exactamente el mismo sampler DDIM de la Unidad 3, ahora en color y a mayor escala.
La versión completa de Stable Diffusion agrega el VAE de la Unidad 4 en este punto: en vez de que el U-Net opere sobre los píxeles RGB directamente, opera sobre el latente comprimido que produce el encoder del VAE — reduciendo drásticamente el costo computacional de cada uno de los cientos de pasos de denoising, sin perder fidelidad visual perceptible en la imagen final decodificada. Esa es la contribución central del paper de Rombach et al. (2022), "High-Resolution Image Synthesis with Latent Diffusion Models": separar la compresión perceptual (el VAE, entrenado una vez) de la generación semántica (la difusión, que corre en el espacio chico).
En código, el cambio es mínimo y vale la pena verlo porque muestra dónde entra cada pieza. Antes de entrenar, cada imagen pasa una sola vez por el encoder y se guarda el latente (el VAE está congelado, así que no hay razón para recomputarlo en cada epoch); el U-Net entrena sobre esos latentes exactamente como en la Unidad 3; y al samplear, el resultado se decodifica al final:
# precomputar latentes (una vez, VAE congelado)
with torch.no_grad():
lat = vae.encode(imgs).latent_dist.sample() * vae.config.scaling_factor # 0.18215 en SD 1.x
# entrenar el U-Net sobre `lat` en vez de sobre `imgs` — el DDPMCB de la Unidad 3, sin cambios
# samplear: el loop de siempre, sobre un tensor [n, 4, 64, 64], y decodificar al final
imgs = vae.decode(lat / vae.config.scaling_factor).sample
El scaling_factor existe por la misma obsesión de la Unidad 2: los latentes que produce el
encoder tienen un desvío estándar bastante mayor que 1, y multiplicarlos por esa constante los deja en
escala unitaria para que el schedule de ruido (diseñado para datos de varianza ~1) funcione como está.
⚡ Física del espacio latente: Reducción 4.096× de FLOPs y el factor 0.18215
La innovación fundamental de Latent Diffusion Models (LDMs, Rombach et al., 2022) no es un nuevo algoritmo de difusión, sino un cambio en la geometría computacional del problema:
- Colapso cuadrático de la atención en píxeles: Generar una imagen de resolución estándar en el espacio de píxeles implica procesar una cuadrícula de tokens espaciales. Cada capa de self-attention requiere evaluar la matriz de afinidad , cuya complejidad es cuadrática: En float16, ¡una sola matriz de atención ocuparía 137 GB de VRAM por head!
- El factor de compresión del VAE: El encoder del VAE reduce la resolución espacial en un factor por dimensión espacial, pasando de a con 4 canales latentes. El número de tokens espaciales se reduce 64 veces: . Por lo tanto, la complejidad cuadrática de la atención se reduce exactamente en: El costo de cada paso de atención cae de 68.700 millones de operaciones a solo 16.7 millones (), permitiendo que el U-Net ejecute 30 a 50 iteraciones completas en una GPU hogareña de 8 GB de VRAM en 2 a 4 segundos.
- De dónde sale el factor : El encoder del VAE se entrenó con una función de costo combinada (reconstrucción + L1 + LPIPS + KL). En el punto de convergencia, las variables latentes tienen una desviación estándar empírica de . Dado que el framework de difusión asume entradas con varianza unitaria () para que el schedule de varianzas añada ruido en proporciones correctas, el factor de escala es simplemente el inverso exacto del desvío estándar medido: Sin este reescalado, la señal limpia inundaría completamente el ruido gaussiano en los timesteps iniciales, destruyendo el entrenamiento.
4. Condicionar con texto: CLIP y classifier-free guidance
Para que un prompt de texto controle la generación, el embedding de texto de CLIP (un encoder entrenado para que representaciones de imagen y de texto que describen lo mismo queden cerca en un espacio vectorial compartido — otra aplicación de contrastive learning) se inyecta en el U-Net vía cross-attention: la misma mecánica de Query/Key/Value de la Unidad 4, pero ahora Q viene de la imagen y K/V vienen del embedding de texto, dejando que cada región de la imagen "atienda" a las palabras del prompt más relevantes para ella.
Un detalle concreto que importa en la práctica: el text encoder de CLIP en SD 1.x recibe como máximo 77 tokens (incluyendo los de inicio y fin) y devuelve una matriz de 77×768. Un prompt más largo se trunca en silencio — lo que escribiste después del token 75 nunca llega al U-Net.
Classifier-free guidance es la técnica que hace que el resultado siga el prompt con más fuerza: durante el entrenamiento se descarta el condicionamiento de texto una fracción del tiempo (el modelo aprende tanto la versión condicional como la incondicional), y en inferencia se genera con ambas versiones y se extrapola en la dirección que aleja de la incondicional — el guidance scale controla cuánto se extrapola. En código es una resta y una suma:
pred = uncond + g * (cond - uncond) # g = guidance_scale; g=1 es "solo el prompt", g=0 es "sin prompt"
La lectura geométrica: cond − uncond es la dirección en la que el prompt mueve la predicción
de ruido respecto de "cualquier imagen"; multiplicarla por g > 1 exagera ese
desplazamiento. Es una extrapolación, no una interpolación — por eso funciona (imágenes más fieles al
prompt) y por eso tiene un techo: con g muy alto la predicción se aleja de todo lo que el
modelo vio, y aparecen colores saturados, contrastes quemados y artefactos.
📐 Derivación bayesiana del Classifier-Free Guidance (CFG)
Para entender por qué classifier-free guidance extrapola en vez de interpolar, recurrimos a la formulación de Score Matching de Song et al. y Ho & Salimans (2022).
Por la regla de Bayes, la probabilidad condicional de la imagen dado el texto es:
Tomando el gradiente respecto al estado latente (la función score):
Por la fórmula de Tweedie y la conexión fundamental entre score matching y predicción de ruido, sabemos que el score incondicional y condicional se relacionan directamente con las predicciones del U-Net:
Despejando el gradiente del clasificador :
Para obligar al modelo a seguir el condicionamiento con mayor severidad, Ho & Salimans multiplican la dirección del clasificador por un hiperparámetro de escala :
Reemplazando los scores por los estimadores de ruido:
Multiplicando toda la ecuación por , obtenemos directamente la regla de sampling guiado:
Esta derivación demuestra que CFG es equivalente a muestrear de una distribución de densidad proporcional a . Elevar la verosimilitud del texto a la potencia aplana las modas secundarias y colapsa la probabilidad en las modas de máxima coherencia con el texto, eliminando la ambigüedad generativa a costa de sacrificar entropía y diversidad.
guidance_scale no es «cuánto caso le hace al prompt» sino cuánto extrapola: la resta cond − uncond es la dirección en que el texto mueve la predicción de ruido, y multiplicarla por g > 1 la exagera. Funciona porque va más allá de la predicción condicional, y tiene techo por la misma razón.
Un negative prompt es la misma idea aplicada al revés: en vez de usar la versión
incondicional como referencia a evitar, se usa la predicción condicionada a lo que explícitamente no
querés ver. En la fórmula, uncond deja de ser el embedding del string vacío y pasa a ser el
embedding de "blurry, low quality, extra fingers"; el resto no cambia. El loop completo,
que es lo que pipe(...) hace por dentro:
tok = pipe.tokenizer; enc = pipe.text_encoder; unet = pipe.unet; vae = pipe.vae; sched = pipe.scheduler
def embed(texts):
ids = tok(texts, padding="max_length", max_length=tok.model_max_length, # 77
truncation=True, return_tensors="pt").input_ids.to("cuda")
with torch.no_grad(): return enc(ids)[0] # [n, 77, 768]
cond = embed([prompt])
uncond = embed([negative_prompt or ""]) # "" = guidance clásico; texto = negative prompt
emb = torch.cat([uncond, cond]) # un solo batch de 2 para no correr el U-Net dos veces
sched.set_timesteps(30)
lat = torch.randn((1, 4, 64, 64), generator=g, device="cuda", dtype=torch.float16) * sched.init_noise_sigma
for t in sched.timesteps:
inp = sched.scale_model_input(torch.cat([lat]*2), t) # algunos schedulers reescalan la entrada
with torch.no_grad(): u, c = unet(inp, t, encoder_hidden_states=emb).sample.chunk(2)
pred = u + 7.5 * (c - u) # classifier-free guidance
lat = sched.step(pred, t, lat).prev_sample # el ddim_step de la Unidad 3
with torch.no_grad(): img = vae.decode(lat / vae.config.scaling_factor).sample
Cada línea es una unidad de este curso: el tokenizer del curso de HF, el U-Net de §2, la resta de guidance
de arriba, el scheduler.step de la Unidad 3, el vae.decode de la Unidad 4. Que
uncond y cond vayan en el mismo batch es una optimización, no matemática:
duplica el costo de memoria del U-Net a cambio de una sola pasada.
5. Textual inversion y DreamBooth
Dos técnicas para enseñarle al modelo un concepto nuevo (un objeto, una persona, un estilo) sin reentrenar nada de la red principal:
- Textual inversion (Gal et al., 2022): se aprende un único embedding de texto nuevo (asociado a un token especial como
<mi-objeto>) que, insertado en un prompt, hace que el modelo genere ese concepto — se optimiza solo ese vector de 768 números, con el resto del modelo completamente congelado. La loss es la misma MSE sobre ε de la Unidad 3, con imágenes del concepto y prompts que contienen el token nuevo; el gradiente solo llega al embedding. - DreamBooth (Ruiz et al., 2022): en vez de aprender solo un embedding, hace fine-tuning del U-Net completo (o de un adapter) sobre un puñado de fotos del sujeto, asociándolo a un identificador raro (
"a photo of sks dog") para no chocar con conceptos que el modelo ya conoce. Como tocar el U-Net entero con 5 fotos arruina lo demás, el paper agrega una prior preservation loss: imágenes generadas por el modelo original para la clase genérica ("a photo of a dog") que se mezclan en el entrenamiento para que el modelo no olvide qué era un perro.
| Textual inversion | DreamBooth | |
|---|---|---|
| Qué se entrena | Un embedding (unos cientos de números) | El U-Net (o un adapter sobre él), a veces también el text encoder |
| Tamaño del resultado | Unos KB | El modelo completo, o un LoRA de decenas de MB |
| Fidelidad al sujeto | Limitada: solo puede "señalar" conceptos que el modelo ya sabe representar | Alta: puede aprender rasgos nuevos |
| Riesgo | Casi nulo para el modelo base | Overfitting y olvido; mitigado con prior preservation |
📜 Es LoRA, antes de llamarse LoRA en este contexto
DreamBooth suele combinarse hoy con adapters tipo LoRA para hacer el fine-tuning barato en memoria — el
mismo LoraConfig/peft que viste en el
Capítulo 11 de HF, aplicado a un U-Net
de difusión en vez de a un transformer de lenguaje. La técnica de "congelar casi todo, entrenar una
pieza chica y barata" es transversal a toda la disciplina, no específica de texto ni de imagen.
6. Mixed precision: el detalle que hace todo esto viable
Entrenar en FP16 en vez de FP32 reduce a la mitad la memoria y acelera notablemente el cómputo en GPUs
modernas — con un riesgo real de NaN por underflow en gradientes muy chicos. La solución
estándar (MixedPrecision en miniai, accelerate en el ecosistema de
Hugging Face) mantiene una copia maestra de los pesos en FP32, hace el forward/backward en FP16, y escala
dinámicamente el loss antes de calcular gradientes para evitar que valores pequeños colapsen a cero en la
representación de menor precisión. En PyTorch puro son dos objetos:
scaler = torch.amp.GradScaler("cuda") # torch.cuda.amp.GradScaler() en versiones anteriores
for xb, yb in dl:
opt.zero_grad()
with torch.autocast("cuda", dtype=torch.float16): # matmuls y convs en FP16, reducciones en FP32
loss = loss_fn(model(xb), yb)
scaler.scale(loss).backward() # loss × factor grande antes del backward: los gradientes no bajan a 0
scaler.step(opt) # des-escala los gradientes; salta el paso si encontró inf/NaN
scaler.update() # ajusta el factor: sube si todo fue bien, baja si hubo overflow
autocast decide por operación: las multiplicaciones de matrices y convoluciones van en FP16
(donde está la ganancia de velocidad), pero sumas grandes, softmax y normalizaciones se mantienen en FP32
porque acumular miles de términos en 16 bits pierde precisión. Los pesos del modelo siguen en FP32 todo el
tiempo; lo que se castea es la copia que entra a cada kernel. El GradScaler resuelve el otro
problema: FP16 no puede representar nada por debajo de ~6·10⁻⁸, y los gradientes de las
primeras capas de una red profunda caen fácilmente ahí. Multiplicar la loss por 2¹⁶ antes del
backward mueve todos los gradientes a un rango representable; el scaler los divide de vuelta antes de que
el optimizador los use.
BF16 no necesita scaler. FP16 gasta sus 16 bits en 5 de exponente y 10 de mantisa; BF16
gasta 8 de exponente (los mismos que FP32) y 7 de mantisa. Tiene menos precisión — solo unos 2-3 dígitos
decimales — pero exactamente el mismo rango que FP32: no hay underflow que escalar. Por eso en
GPUs que lo soportan (Ampere en adelante) el loop se simplifica a
with torch.autocast("cuda", dtype=torch.bfloat16) y un backward normal, y por
eso prácticamente todo el entrenamiento de LLMs actual es BF16.
autocast + GradScaler como circuito. Lo que se castea es la copia que entra a cada kernel, nunca los pesos; el scale existe porque FP16 gasta sus bits en mantisa y se queda sin rango. BF16 reparte los bits como FP32, así que el mismo loop se reduce a un autocast.🔧 Un peldaño en la misma escalera de precisión que ya conocés
FP32 → FP16/BF16 → NVFP4 son puntos sucesivos de la misma escalera de compromiso entre precisión y costo que ya recorriste a fondo en tu pipeline NVFP4 y en NVFP4 en difusión — con Flux2 y Wan 2.1 corriendo cuantizados a 4 bits por valor. Mixed precision (FP16/BF16) es el primer escalón de esa misma idea, aplicado durante el entrenamiento en vez de en inferencia: menos bits por número, mismo concepto de fondo, distinto punto del pipeline donde se aplica. Y el problema del rango es el mismo en los dos extremos: FP16 lo resuelve con un factor de escala global sobre la loss; un formato de 4 bits, que por sí solo casi no tiene rango, lo resuelve con factores de escala por bloque guardados aparte. La idea de "guardar la escala en otro lado y multiplicar después" no cambia, solo cambia la granularidad.
7. Errores comunes
La mayoría se ven en la imagen final, no en un traceback — conviene reconocer el síntoma visual.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Colores saturados, contrastes quemados, bordes "fritos"; la imagen sigue el prompt pero parece un póster mal impreso. | guidance_scale demasiado alto: la extrapolación uncond + g·(cond − uncond) se fue fuera de la distribución del modelo. | Bajar g al rango habitual (para SD 1.x, del orden de 5 a 9). Si necesitás más adherencia al prompt, mejor un negative prompt que más guidance. |
| Imagen borrosa o con ruido residual, aunque la composición esté bien. | Pocos pasos para el sampler elegido: DDIM o PNDM necesitan varias decenas; los solvers modernos (DPM-Solver++) llegan a lo mismo en 20. | Subir num_inference_steps o cambiar el scheduler por uno de orden superior — sin reentrenar nada, como en la Unidad 3. |
| Imagen completamente negra, o el pipeline devuelve NaN al decodificar. | El VAE en FP16 produce inf/NaN en su decoder (problema conocido, especialmente en SDXL). | Correr solo el VAE en FP32 (pipe.vae.to(torch.float32) y castear el latente), o usar un VAE reentrenado para FP16 (para SDXL existe una versión "fp16-fix" publicada por la comunidad). |
| No podés reproducir una imagen que te gustó: el mismo prompt da otra cosa. | Semilla sin fijar: x_T sale de torch.randn sin Generator. | generator=torch.Generator("cuda").manual_seed(n) y guardar n junto con prompt, pasos, guidance y scheduler — los cinco definen la imagen con DDIM (eta=0). |
| Lo que agregaste al final del prompt no tiene ningún efecto. | Superaste los 77 tokens de CLIP: el tokenizer trunca en silencio. | Contá los tokens con len(pipe.tokenizer(prompt).input_ids); poné lo importante primero; para prompts largos, algunas librerías dividen en ventanas de 77 y concatenan embeddings. |
NaN en la loss al entrenar en FP16 después de unos cientos de pasos. | Falta el GradScaler (underflow de gradientes) o el learning rate es demasiado alto para FP16. | Usar el loop de §6 con scaler, o pasar a BF16 si la GPU lo soporta. |
8. Resumen — cierre de fast.ai
StableDiffusionPipelinecarga cuatro componentes — tokenizer + text encoder (CLIP), U-Net, VAE, scheduler — y cada uno es una unidad de este curso.- El U-Net de difusión combina downs/mids/ups con time embeddings sinusoidales (para saber cuánto ruido hay, inyectados como
x·(1+scale)+shift) y self-attention intercalada en las resoluciones bajas. - La difusión latente corre ese U-Net sobre el espacio comprimido de un VAE, no sobre píxeles — el ahorro de cómputo que hace viable generar en segundos; los latentes se reescalan con
scaling_factorpara tener varianza ~1. - El condicionamiento por texto usa cross-attention con embeddings de CLIP (máximo 77 tokens); classifier-free guidance es
uncond + g·(cond − uncond), y un negative prompt reemplaza eluncond. - Textual inversion entrena un embedding; DreamBooth entrena el U-Net (hoy, típicamente vía LoRA) con prior preservation — la misma filosofía de "congelar casi todo" aplicada a imagen.
- Mixed precision es
autocast+GradScaleren FP16, o soloautocasten BF16 (mismo rango que FP32) — el primer escalón de la misma escalera de cuantización que ya recorriste hasta NVFP4 en tus propios proyectos.
Con esto se cierra fast.ai completo — Parte 1 (8 lecciones + bonus de ética, con la profundidad completa del libro) y Parte 2 (5 unidades condensadas cubriendo las 17 lecciones de fundamentos y difusión, sin libro de referencia). El recorrido completo del objetivo activo — HF LLM Course de punta a punta, después fast.ai de punta a punta — queda cumplido acá.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Con guidance_scale=1, ¿qué queda de la fórmula de classifier-free guidance, y para qué sirvió entonces correr la rama incondicional?
Queda pred = cond: la rama incondicional se cancela y fue cómputo desperdiciado. Con g=0 se ignora el prompt por completo. La guidance solo hace algo con g ≠ 1, y lo útil está en g > 1, que extrapola más allá de la predicción condicional. (§4)
2. ¿Qué cambia exactamente en el loop de §4 al usar un negative prompt en vez de guidance clásico?
Una sola cosa: uncond pasa a ser el embedding del negative prompt en vez del embedding del string vacío. La resta cond − uncond ahora apunta "desde lo que no querés hacia lo que querés", y la extrapolación aleja de eso. (§4)
3. ¿Por qué el VAE se corre una vez antes de entrenar y no dentro del loop, y por qué el latente se multiplica por scaling_factor?
Porque el VAE está congelado: el latente de cada imagen no cambia entre epochs, así que recomputarlo es cómputo tirado. El scaling_factor lleva los latentes a varianza ~1, que es la escala que el schedule de ruido de la Unidad 3 asume. (§3)
4. Querés enseñarle al modelo la cara de una persona específica. ¿Textual inversion o DreamBooth, y por qué?
DreamBooth: textual inversion solo aprende un embedding, así que solo puede "señalar" cosas que el U-Net ya sabe dibujar, y una cara concreta no está entre ellas. DreamBooth modifica el U-Net (típicamente vía LoRA) y puede aprender rasgos nuevos — a costa de necesitar prior preservation para no olvidar la clase general. (§5)
5. ¿Qué pasa si sacás scaler.scale(...) del loop de FP16 pero dejás autocast? ¿Y si en vez de FP16 usás BF16?
Con FP16 sin scaler, los gradientes pequeños (por debajo de ~6·10⁻⁸) se van a cero por underflow, y las primeras capas dejan de aprender o aparecen NaN. Con BF16 no hace falta el scaler: tiene 8 bits de exponente como FP32, así que el rango es el mismo y no hay underflow — el precio es menos mantisa (menos precisión), no menos rango. (§6)
6. Escribiste un prompt de 120 palabras y las últimas 40 describen el fondo, que nunca aparece. ¿Qué pasó?
El text encoder de CLIP en SD 1.x procesa 77 tokens; el tokenizer trunca lo demás sin avisar, así que el U-Net nunca vio la descripción del fondo. Contar tokens y poner lo importante al principio. (§4, §7)
10. Para practicar
✏️ Ejercicio 1 — Barrido de guidance con semilla fija (GPU)
Generá la misma imagen (mismo prompt, misma semilla, mismos pasos) con cinco valores de guidance_scale y mirá dónde empieza la saturación. Necesitás diffusers, transformers y una GPU; en tu RTX 5090 cada imagen tarda un par de segundos.
import torch
from diffusers import StableDiffusionPipeline
pipe = StableDiffusionPipeline.from_pretrained("CompVis/stable-diffusion-v1-4", torch_dtype=torch.float16).to("cuda")
prompt = "a watercolor painting of a lighthouse at dusk"
for g in (1.0, 3.0, 7.5, 15.0, 30.0):
gen = torch.Generator("cuda").manual_seed(1234) # misma semilla en cada vuelta
img = pipe(prompt, guidance_scale=g, num_inference_steps=30, generator=gen).images[0]
img.save(f"faro_g{g}.png")
# cuántos tokens usa el prompt (el límite es 77 con inicio y fin):
print(len(pipe.tokenizer(prompt).input_ids), "tokens")
Pista / qué esperar
Con g=1 la imagen es vaga y no muy fiel al prompt; entre 3 y 7.5 gana definición y adherencia; a 15 los colores empiezan a saturar y a 30 hay contrastes quemados y detalles "fritos". Como la semilla es la misma, la composición general se mantiene y podés ver el efecto de g aislado. Repetí con un negative_prompt="blurry, low contrast" a g=7.5.
✏️ Ejercicio 2 — Ver el underflow de FP16 con tus propios ojos (CPU)
Sin GPU ni modelo: comprobá qué gradiente sobrevive en cada formato y por qué el GradScaler existe.
import torch
for v in (1e-3, 1e-5, 1e-7, 1e-8, 1e-10, 1e-30):
f16 = torch.tensor(v, dtype=torch.float16).item()
b16 = torch.tensor(v, dtype=torch.bfloat16).item()
print(f"{v:>6.0e} fp16={f16:<12.3e} bf16={b16:<12.3e}")
# y el remedio: escalar antes, des-escalar después
g = torch.tensor(1e-8)
print("sin scaler:", g.half().float().item())
print("con scaler:", ((g * 2**16).half().float() / 2**16).item())
# precisión (no rango): cuántos valores distintos hay entre 1 y 2
print("eps fp16:", torch.finfo(torch.float16).eps, " eps bf16:", torch.finfo(torch.bfloat16).eps)
Pista / qué esperar
FP16 conserva 1e-3 y 1e-5, degrada 1e-7 (es un subnormal, con pocos dígitos válidos) y da exactamente 0 a partir de 1e-8. BF16 conserva todos hasta 1e-30 y más allá, con solo 2-3 dígitos significativos. Con el factor 2¹⁶, el 1e-8 sobrevive en FP16. Los eps muestran el precio de BF16: ~1e-3 contra ~1e-2 — ocho veces menos precisión a cambio del rango completo.
11. Fuentes y para seguir
- Practical Deep Learning for Coders — Part 2 — la Lección 9 es el pipeline de §1 y §4 abierto por dentro; las 20 a 25 son el U-Net y LSUN.
- High-Resolution Image Synthesis with Latent Diffusion Models (Rombach et al., 2022) — el paper de Stable Diffusion; la figura 3 es el diagrama completo de §3 y §4.
- Learning Transferable Visual Models From Natural Language Supervision (Radford et al., 2021) — CLIP; con leer la sección 2 alcanza para entender por qué sus embeddings sirven para condicionar.
- Classifier-Free Diffusion Guidance (Ho & Salimans, 2022) — cuatro páginas; la ecuación (6) es la línea
pred = uncond + g·(cond − uncond). - An Image is Worth One Word (Gal et al., 2022) y DreamBooth (Ruiz et al., 2022) — los dos papers de §5, publicados con semanas de diferencia; compará la sección de método de cada uno.
- U-Net: Convolutional Networks for Biomedical Image Segmentation (Ronneberger et al., 2015) — el origen de la arquitectura de §2; la figura 1 es el dibujo que todo el mundo copia.