fast.ai · Parte 2 · Unidad 3 de 5
Diffusion models desde cero: DDPM, DDIM y Karras
El núcleo matemático de todo lo que hoy llamamos "difusión" — desde el paper original de 2020 hasta las mejoras que hicieron viable generar una imagen en segundos en vez de minutos.
Antes de leerU2, entrenar redes profundas · lección 3 §2, gradientes
🎯 Al terminar vas a poder
- Explicar con tus palabras qué es el proceso forward de difusión y por qué
ᾱ_tpermite saltar a cualquier nivel de ruido en un solo cálculo. - Escribir el loop de entrenamiento de un DDPM (noisificar, predecir ε, MSE) y el loop de sampling (de T−1 a 0), y saber en qué paso no se agrega ruido.
- Implementar un paso de DDIM con su
eta, y explicar qué cambia entreeta=0(determinista) yeta=1(equivalente a DDPM). - Reconocer la misma matemática en
diffusers(DDPMScheduler,DDIMScheduler,scheduler.step). - Describir por qué Karras et al. reemplazan el timestep entero por un
sigmacontinuo con preconditioning, y diagnosticar los errores típicos de una implementación desde cero.
1. DDPM: noising hacia adelante, denoising hacia atrás
Denoising Diffusion Probabilistic Models (Ho et al., 2020) simplificó una idea que técnicamente existía desde 2015 (Sohl-Dickstein et al., inspirados en termodinámica de no-equilibrio) pero era difícil de entrenar. El proceso hacia adelante (forward) agrega ruido gaussiano a una imagen en pasos sucesivos, según un schedule de varianza creciente:
β = torch.linspace(β_min, β_max, n_steps) # variance schedule, lineal: 1e-4 → 0.02 en el paper
α = 1. - β
ᾱ = torch.cumprod(α, dim=0) # producto acumulado
# noisificar la imagen x0 hasta el timestep t, en un solo paso:
xt = ᾱ[t].sqrt()*x0 + (1-ᾱ[t]).sqrt()*ruido
La genialidad matemática del paper es que ᾱ (el producto acumulado de todos los pasos hasta
t) permite saltar directo a cualquier nivel de ruido en un solo cálculo, sin
simular los t pasos intermedios uno por uno — esto es lo que hace viable entrenar en la
práctica: durante el entrenamiento, se elige un timestep al azar por ejemplo del batch, se noisifica
directo a ese nivel, y se le pide a una red neuronal que prediga qué ruido se agregó.
Por qué funciona el salto: un solo paso del forward es
x_t = √α_t · x_{t−1} + √(1−α_t) · ε_t, con cada ε_t gaussiano independiente.
Si metés la expresión de x_{t−1} adentro, el término de señal queda
√(α_t·α_{t−1}) · x_{t−2}, y los dos términos de ruido son gaussianas independientes de
varianzas α_t(1−α_{t−1}) y (1−α_t). La suma de dos gaussianas independientes es
una gaussiana cuya varianza es la suma: α_t − α_t·α_{t−1} + 1 − α_t = 1 − α_t·α_{t−1}.
Repetido t veces, la señal se multiplica por √ᾱ_t (el producto de todos los
√α) y el ruido acumulado es una única gaussiana de varianza 1 − ᾱ_t. De ahí que
x_t = √ᾱ_t · x0 + √(1−ᾱ_t) · ε sea exacto, no una aproximación.
Cómo se lee. La distribución de la imagen ruidosa en el paso t, dada la limpia, es una gaussiana centrada en la imagen atenuada por √ᾱt y con varianza 1 − ᾱt. ᾱt es el producto de todos los α hasta t (torch.cumprod). La tercera igualdad es cómo se muestrea de esa gaussiana con un ε ~ 𝒩(0, I): la línea xt = ᾱ[t].sqrt()*x0 + (1-ᾱ[t]).sqrt()*ruido.
Fijate también que con β lineal de 1e-4 a 0.02 y 1000 pasos,
ᾱ_999 queda en el orden de 4·10⁻⁵: la "imagen" al final del forward es ruido
con una traza prácticamente nula de señal, que es exactamente lo que se necesita para poder arrancar el
sampling desde torch.randn puro.
📐 Derivación rigurosa: La distribución posterior q(x_{t-1} | x_t, x_0) y el sampler de DDPM
En el proceso reverso de difusión, queremos conocer , pero es intratable porque requiere integrar sobre todo el espacio de datos . Sin embargo, condicionando sobre la imagen limpia original , la probabilidad a posteriori se vuelve analíticamente tratable mediante la regla de Bayes:
Las tres distribuciones de la derecha son gaussianas conocidas:
Al expandir los exponentes cuadráticos y agrupar los términos en función de :
El coeficiente de determina la varianza posterior :
Y completando cuadrados, la media a posteriori resulta ser:
Como en inferencia no conocemos , usamos la reparametrización del forward para despejar la estimación de la imagen limpia:
Sustituyendo esta en la fórmula de y simplificando con :
Esta es exactamente la fórmula que ejecuta la línea mean = (x_t - β[t]/(1-ᾱ[t]).sqrt() * ε_hat) / α[t].sqrt() en el sampler de DDPM. La red neuronal no necesita aprender a predecir distribuciones complejas: solo aprende a predecir el vector de ruido gaussiano agregado en el paso , y toda la física probabilística del des-noising queda garantizada por la regla de Bayes.
t se colapsan en el producto acumulado ᾱ_t, y se noisifica en una sola línea. Entrenar es elegir un t al azar por ejemplo, ensuciar hasta ahí y pedirle a la red el ruido que se agregó.2. Entrenar un DDPM es sorprendentemente simple
Todo el entrenamiento se reduce a una MSELoss entre el ruido real agregado y el ruido que
predice un U-Net (arquitectura que se cubre en la próxima unidad):
class DDPMCB(TrainCB):
def before_batch(self, learn):
x0 = learn.batch[0]
n = len(x0)
ε = torch.randn(x0.shape) # ruido real: el target
t = torch.randint(0, self.n_steps, (n,)) # un timestep al azar POR EJEMPLO del batch
ᾱ_t = self.ᾱ[t].reshape(-1,1,1,1) # [n] → [n,1,1,1] para que broadcastee con [n,c,h,w]
xt = ᾱ_t.sqrt()*x0 + (1-ᾱ_t).sqrt()*ε # noisificar en un paso
learn.batch = ((xt, t), ε) # input: (imagen ruidosa, t) — target: el ruido
learn = Learner(model, dls, nn.MSELoss(), cbs=[DDPMCB(n_steps=1000, ...)])
learn.fit(epochs)
Vale la pena detenerse en por qué se predice el ruido y no la imagen limpia, si son
matemáticamente intercambiables (x0 = (x_t − √(1−ᾱ_t)·ε) / √ᾱ_t). Ho et al. probaron ambas y
predecir ε entrena mucho mejor con una MSE simple: el ruido tiene siempre la misma escala (varianza 1)
sea cual sea t, mientras que la imagen limpia es casi imposible de adivinar a
t alto y trivial a t bajo — con el target en x0 la loss queda dominada por los
timesteps difíciles salvo que la reponderes por ᾱ_t. Parametrizar en ε es una forma de
reponderación implícita que "sale gratis".
El otro detalle es el t distinto por ejemplo: cada batch cubre así un rango amplio de niveles
de ruido, y el gradiente promedia sobre todos. Si usaras un solo t para todo el batch, cada
paso de optimización vería un único nivel de ruido y el entrenamiento se volvería mucho más ruidoso.
3. Samplear con DDPM: mil pasos hacia atrás
La generación (sampling) es el proceso inverso: arrancar de ruido puro
(x_T = torch.randn(...)) y, para cada timestep de 999 a 0, pedirle al modelo que prediga el
ruido presente, restarlo con la fórmula del paper, y repetir — mil pasos, cada uno una pasada por la red
completa. Este es el Algoritmo 2 de Ho et al., tal cual:
@torch.no_grad()
def sample_ddpm(model, sz, n_steps, α, ᾱ, β):
x_t = torch.randn(sz) # x_T: ruido puro
for t in reversed(range(n_steps)): # T-1, T-2, ..., 0
t_batch = torch.full((sz[0],), t, dtype=torch.long)
ε_hat = model((x_t, t_batch)) # 1) predecir el ruido presente
mean = (x_t - β[t]/(1-ᾱ[t]).sqrt() * ε_hat) / α[t].sqrt() # 2) media de p(x_{t-1} | x_t)
z = torch.randn_like(x_t) if t > 0 else torch.zeros_like(x_t) # 3) ruido fresco, salvo en el último paso
x_t = mean + β[t].sqrt() * z # σ_t = √β_t en la variante simple del paper
return x_t
Cada iteración hace tres cosas: predice ε, calcula la media de la distribución del paso anterior (que es
x_t menos una fracción del ruido predicho, reescalada), y le suma ruido gaussiano nuevo de
desvío σ_t. Ese ruido fresco no es un detalle: es lo que hace al proceso estocástico
y evita que los errores del modelo se acumulen de manera determinista. La excepción es t=0:
ahí ya estás en la imagen final y sumarle ruido solo la ensuciaría — por eso el z = 0.
El notebook 15_DDPM escribe la misma media de otra forma, pasando por la estimación de la
imagen limpia (x_0_hat = (x_t − √(1−ᾱ_t)·ε_hat) / √ᾱ_t, opcionalmente recortada a
[−1, 1]) y combinándola con x_t usando ᾱ_{t−1}. Es álgebra
equivalente — la versión con x_0_hat es la que después se generaliza a DDIM. En el notebook,
generar imágenes reconocibles de Fashion-MNIST toma exactamente esos 1000 pasos secuenciales; no hay forma
de paralelizarlos porque cada paso depende del resultado del anterior.
4. DDIM: el mismo modelo, 10× menos pasos
Mil pasos secuenciales por imagen es exactamente el motivo por el que la generación con difusión temprana
era lenta. Denoising Diffusion Implicit Models (Song et al., 2020) resuelve esto sin
reentrenar nada: reformula el proceso de sampling para que sea determinista (dado el mismo ruido inicial,
siempre da la misma imagen) y permite saltar pasos. Un paso de DDIM va de un
t a un t−1 que puede estar a 10 o 20 timesteps de distancia:
def ddim_step(x_t, noise, abar_t, abar_t1, eta):
# abar_t: ᾱ del paso actual; abar_t1: ᾱ del paso siguiente (más limpio); noise: ε_hat del modelo
bbar_t, bbar_t1 = 1 - abar_t, 1 - abar_t1 # "beta barra": la varianza del ruido acumulado
sig = eta * ((bbar_t1/bbar_t).sqrt() * (1 - abar_t/abar_t1).sqrt()) # cuánto ruido fresco se reinyecta
x_0_hat = (x_t - bbar_t.sqrt()*noise) / abar_t.sqrt() # 1) estimar la imagen limpia
x_t = abar_t1.sqrt()*x_0_hat + (bbar_t1 - sig**2).sqrt()*noise # 2) re-noisificarla al nivel t-1
if eta > 0: x_t = x_t + sig * torch.randn_like(x_t) # 3) ruido fresco (solo si eta>0)
return x_t
La lectura en palabras: primero se usa el ruido predicho para estimar cómo sería la imagen limpia
(x_0_hat); después se la vuelve a ensuciar, pero al nivel de ruido del paso destino,
reusando la misma dirección de ruido noise en vez de tirar una nueva. Como el destino puede
ser cualquier ᾱ de la tabla, nada obliga a avanzar de a un timestep.
El parámetro eta interpola entre DDPM puro (eta=1, estocástico: sig
coincide con el σ_t del paso de DDPM cuando los timesteps son consecutivos) y DDIM puro
(eta=0: sig=0, no se agrega ruido, y la trayectoria queda completamente
determinada por x_T). Ese determinismo es útil por sí mismo: permite interpolar entre dos
imágenes interpolando sus x_T, y hace reproducible cualquier generación con solo fijar la
semilla. El notebook mide el efecto real de reducir pasos usando FID (Fréchet Inception
Distance) y KID (Kernel Inception Distance) — métricas estándar que comparan la
distribución estadística de imágenes generadas contra imágenes reales, más bajo es mejor:
x̂₀ y la re-ensucia al nivel que quiera, así que puede saltar de a 10 y, con η = 0, ser determinista. Diez veces menos pasadas por la red sin reentrenar nada.| Pasos de sampling | Calidad |
|---|---|
| 1000 (DDPM completo) | Referencia |
| 333 → 200 → 100 | FID/KID prácticamente estables |
| 50 | Degradación notoria pero aceptable |
| 25 | Calidad claramente peor |
La conclusión práctica, medida directamente: se puede bajar de 1000 a ~100 pasos sin pérdida de calidad perceptible — una mejora de 10× en velocidad de generación sin reentrenar el modelo, solo cambiando cómo se samplea. Esto es, literalmente, la diferencia entre Stable Diffusion tardando minutos o segundos en generar una imagen.
🟢 Lo mismo, en diffusers
Todo lo de esta unidad existe empaquetado en la librería diffusers de Hugging Face como
schedulers: un objeto que sabe el schedule de β, cómo noisificar para entrenar
(add_noise) y cómo dar un paso hacia atrás (step). La red sigue siendo tuya;
el scheduler es la matemática de esta unidad.
from diffusers import DDPMScheduler, DDIMScheduler
sched = DDPMScheduler(num_train_timesteps=1000, beta_schedule="linear")
# entrenamiento: equivale al before_batch de §2
t = torch.randint(0, 1000, (n,))
xt = sched.add_noise(x0, ε, t)
# sampling con DDIM en 50 pasos: equivale al loop de ddim_step
sched = DDIMScheduler(num_train_timesteps=1000, beta_schedule="linear")
sched.set_timesteps(50) # elige 50 t's espaciados de 999 a 0
x = torch.randn(1, 3, 64, 64)
for t in sched.timesteps:
ε_hat = unet(x, t).sample
x = sched.step(ε_hat, t, x, eta=0.0).prev_sample # DDPMScheduler.step no tiene eta: siempre estocástico
Cambiar de sampler es cambiar la clase del scheduler (DDPMScheduler,
DDIMScheduler, EulerDiscreteScheduler, DPMSolverMultistepScheduler…)
sin tocar el modelo — exactamente lo que el notebook demuestra a mano al pasar de 1000 pasos de DDPM a
100 de DDIM con la misma red.
5. Karras: preconditioning en vez de un schedule fijo
El paper de Karras et al. (2022), "Elucidating the Design Space of Diffusion-Based
Generative Models", replantea el problema desde otro ángulo: en vez de un schedule de ruido fijo e
indexado por timestep entero, muestrea directamente el nivel de ruido (sigma) de una
distribución log-normal continua, y reescala el input/output del modelo con tres coeficientes derivados
analíticamente (c_skip, c_out, c_in) para que la red siempre vea
activaciones de escala consistente sin importar cuánto ruido tenga la imagen:
sig_data = 0.5 # desvío estándar del dataset (Fashion-MNIST normalizado, en el notebook)
def scalings(sig):
totvar = sig**2 + sig_data**2
return sig_data**2/totvar, sig*sig_data/totvar.sqrt(), 1/totvar.sqrt() # c_skip, c_out, c_in
def noisify(x0):
sig = (torch.randn([len(x0)])*1.2-1.2).exp() # log-normal, no lineal
sig = sig.reshape(-1,1,1,1)
c_skip,c_out,c_in = scalings(sig)
noised_input = x0 + torch.randn_like(x0)*sig
target = (x0 - c_skip*noised_input)/c_out
return (noised_input*c_in, sig.squeeze()), target
Los tres coeficientes tienen una lectura directa. c_in normaliza la entrada: una imagen con
ruido de desvío sig tiene varianza total sig² + sig_data², y dividir por su raíz
la deja en varianza 1 — la misma preocupación por la escala de las activaciones de la
Unidad 2, ahora aplicada al input. c_skip y
c_out deciden qué tiene que predecir la red: con mucho ruido (sig grande)
c_skip ≈ 0 y la red predice esencialmente la imagen limpia; con poco ruido
c_skip ≈ 1 y la red predice solo la corrección chica que falta. Es una interpolación continua
entre "predecir x0" y "predecir ε", eligiendo automáticamente la que está mejor condicionada en cada nivel
de ruido.
La idea central que sobrevive el detalle matemático: en vez de tratar "cuánto ruido hay" como un índice
discreto en una tabla fija (como hacía DDPM con sus 1000 timesteps), tratarlo como una cantidad continua
que se puede escalar analíticamente da un espacio de diseño mucho más flexible — es la base conceptual de
los samplers modernos (Euler, Heun, LMS) que reemplazaron por completo al sampler original de DDPM en
herramientas como Stable Diffusion y ComfyUI. Cuando en esas herramientas ves "Karras" como opción de
schedule, es el espaciado de sigma de este paper (en diffusers,
use_karras_sigmas=True en los schedulers que lo soportan).
6. Errores comunes
Una implementación de difusión desde cero falla casi siempre por escalas o por broadcasting, no por la red.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| La loss baja pero las muestras son manchas borrosas o grises. | Entrenaste prediciendo x0 en vez de ε con MSE sin reponderar: la loss queda dominada por los t altos, donde x0 es imposible de adivinar. | Predecir ε (§2), o si querés x0, reponderar la loss por nivel de ruido — o directamente usar el preconditioning de Karras (§5), que resuelve esto de forma continua. |
| Error de shapes al noisificar, o peor: no hay error pero el ruido se aplica "por canal" de forma absurda. | Olvidaste .reshape(-1,1,1,1) en ᾱ_t: un tensor [n] broadcastea contra [n,c,h,w] por la última dimensión, no por la primera. | Siempre ᾱ[t].reshape(-1,1,1,1) (o [:, None, None, None]) antes de multiplicar. Verificá con xt.shape == x0.shape y con n ≠ w. |
| Las imágenes generadas tienen un grano fino de ruido encima, aunque la estructura esté bien. | Sumaste ruido fresco también en t=0. | En el último paso, z = 0 (§3). En DDIM con eta=0 no aplica porque nunca se suma. |
| A resoluciones altas (256 o más) el modelo genera bien la textura pero la composición global es incoherente o sale "lavada". | Schedule lineal de β: a resolución alta el último timestep todavía conserva señal visible (los píxeles vecinos están correlacionados y el ruido por píxel no alcanza a destruir la estructura). El sampling arranca desde ruido puro, que el modelo nunca vio. | Schedule coseno (Nichol & Dhariwal, 2021) o samplear sigma continuo con valores altos (Karras); en diffusers, beta_schedule="squaredcos_cap_v2". |
| Entrenamiento inestable, la loss oscila mucho entre batches. | Un solo t para todo el batch: cada paso ve un único nivel de ruido y el gradiente no promedia. | t = torch.randint(0, n_steps, (n,)) — un timestep por ejemplo (§2). |
| Muestras distintas con la misma semilla usando DDIM. | eta > 0, o el modelo en modo train (Dropout/BatchNorm activos). | eta=0 para determinismo, model.eval(), y la misma semilla para x_T. |
7. Resumen
- DDPM noisifica una imagen hacia adelante con un schedule fijo, y entrena una red para predecir el ruido agregado en cualquier paso — una MSE, nada más.
- El truco matemático de
ᾱ(producto acumulado) permite noisificar a cualquier nivel en un solo cálculo, sin simular pasos intermedios: la suma de gaussianas independientes es una gaussiana con las varianzas sumadas. - El sampling de DDPM va de T−1 a 0: predecir ε, calcular la media, sumar ruido fresco — salvo en el último paso.
- DDIM reformula el sampling para ser determinista (
eta=0) y saltar pasos — de 1000 a ~100 sin pérdida perceptible de calidad, medido con FID/KID. Endiffuserses cambiar la clase del scheduler. - Karras reemplaza el schedule discreto por un nivel de ruido continuo con preconditioning analítico (
c_in,c_skip,c_out) — la base de los samplers modernos. - Nada de esto requirió cambiar la arquitectura de la red — son todas mejoras al proceso matemático de noising/denoising alrededor de un modelo que sigue prediciendo ruido.
8. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Por qué x_t = √ᾱ_t·x0 + √(1−ᾱ_t)·ε es exacto y no una aproximación de los t pasos?
Porque cada paso suma una gaussiana independiente, y la suma de gaussianas independientes es otra gaussiana con varianza igual a la suma de varianzas. Componiendo los pasos, la señal se multiplica por el producto de los √α (es decir √ᾱ_t) y todo el ruido acumulado se resume en una única gaussiana de varianza 1 − ᾱ_t. (§1)
2. ¿Qué pasa si en el loop de sampling de DDPM sumás ruido también cuando t=0?
La imagen final queda con ruido gaussiano de desvío √β_0 encima, sin que quede ningún paso que lo saque. Es pequeño (β_0 = 1e-4) pero visible como grano; por eso z = 0 en el último paso. (§3)
3. Con eta=0 en ddim_step, ¿de qué depende la imagen generada?
Solo de x_T (el ruido inicial) y del modelo: sig=0, no se inyecta ruido fresco, y cada paso es una función determinista del anterior. Misma semilla, misma imagen — con cualquier número de pasos, aunque el resultado exacto cambie levemente con el espaciado. (§4)
4. Tenés un batch de 32 imágenes [32, 3, 64, 64] y un ᾱ[t] de shape [32]. ¿Qué devuelve ᾱ[t].sqrt() * x0 sin reshape?
Un error: [32] intenta alinearse con la última dimensión de x0 (64) y no coincide. Si el ancho fuera 32 casualmente, no habría error pero cada columna de la imagen se escalaría con el ᾱ de un ejemplo distinto — silencioso y catastrófico. Hace falta reshape(-1,1,1,1). (§2, §6)
5. ¿Por qué el notebook entrena prediciendo el ruido y no la imagen limpia, si una se deduce de la otra?
Porque ε tiene siempre varianza 1 sea cual sea t, así que la MSE pesa parecido todos los niveles de ruido. Predecir x0 con MSE cruda hace que los t altos (donde x0 es casi inadivinable) dominen la loss; habría que reponderar. Karras formaliza esto con c_skip/c_out. (§2, §5)
6. ¿Qué hace c_in en el preconditioning de Karras y con qué idea de la Unidad 2 se conecta?
Divide la entrada ruidosa por √(sig² + sig_data²) para que tenga varianza 1 independientemente del nivel de ruido — es la misma preocupación de mantener la escala de las activaciones estable (Kaiming, normalización), aplicada al input de la red. (§5)
9. Para practicar
✏️ Ejercicio 1 — Un DDPM completo en 2D (CPU, un minuto)
Antes de un U-Net y de imágenes, entrená difusión sobre puntos en un círculo: cada "imagen" es un vector de 2 números. Todo el pipeline (schedule, noisificar, predecir ε, samplear de T−1 a 0) cabe en una pantalla y corre en CPU. Tendrías que ver que las muestras finales caen sobre el círculo de radio 1.
import torch, math, torch.nn as nn
torch.manual_seed(0)
# dataset: 4096 puntos sobre un círculo de radio 1
theta = torch.rand(4096) * 2 * math.pi
x0_all = torch.stack([theta.cos(), theta.sin()], 1)
T = 200
beta = torch.linspace(1e-4, 0.02, T); alpha = 1 - beta; abar = alpha.cumprod(0)
model = nn.Sequential(nn.Linear(3, 128), nn.SiLU(), nn.Linear(128, 128), nn.SiLU(), nn.Linear(128, 2))
opt = torch.optim.Adam(model.parameters(), 2e-3)
def pred_eps(x, t): return model(torch.cat([x, (t.float()/T)[:, None]], 1)) # t entra como una feature más
for step in range(3000):
x0 = x0_all[torch.randint(0, 4096, (256,))]
t = torch.randint(0, T, (256,)) # un t por ejemplo
eps = torch.randn_like(x0)
a = abar[t][:, None] # [256] → [256,1]: el reshape que en imágenes es (-1,1,1,1)
xt = a.sqrt()*x0 + (1-a).sqrt()*eps
loss = ((pred_eps(xt, t) - eps)**2).mean()
opt.zero_grad(); loss.backward(); opt.step()
print("loss final:", round(loss.item(), 4))
@torch.no_grad()
def sample_ddpm(m=512):
x = torch.randn(m, 2)
for t in reversed(range(T)):
e = pred_eps(x, torch.full((m,), t))
mean = (x - beta[t]/(1-abar[t]).sqrt()*e) / alpha[t].sqrt()
x = mean + (beta[t].sqrt()*torch.randn_like(x) if t > 0 else 0)
return x
xs = sample_ddpm()
r = xs.norm(dim=1)
print(f"radio medio {r.mean():.3f} ± {r.std():.3f} (ideal: 1.000 ± 0)")
Pista / qué esperar
Radio medio cerca de 1 con un desvío chico (del orden de 0.05 a 0.1). Si te da radio ≈ 0 con desvío grande, probablemente el [:, None] falta o el sampling suma ruido en t=0. Probá también sacar el if t > 0 y mirá cómo sube el desvío del radio.
✏️ Ejercicio 2 — DDIM sobre el mismo modelo: 20 pasos en vez de 200
Con el modelo del ejercicio anterior ya entrenado, implementá ddim_step tal como está en §4 y sampleá en 20 pasos. Verificá el determinismo con eta=0 y compará el radio con el de DDPM.
def ddim_step(x_t, noise, abar_t, abar_t1, eta):
bbar_t, bbar_t1 = 1 - abar_t, 1 - abar_t1
sig = eta * ((bbar_t1/bbar_t).sqrt() * (1 - abar_t/abar_t1).sqrt())
x_0_hat = (x_t - bbar_t.sqrt()*noise) / abar_t.sqrt()
x_t = abar_t1.sqrt()*x_0_hat + (bbar_t1 - sig**2).sqrt()*noise
if eta > 0: x_t = x_t + sig * torch.randn_like(x_t)
return x_t
@torch.no_grad()
def sample_ddim(m=512, steps=20, eta=0., seed=0):
torch.manual_seed(seed)
x = torch.randn(m, 2)
ts = torch.linspace(T-1, 0, steps).long() # 20 timesteps espaciados, de 199 a 0
for i, t in enumerate(ts):
abar_t = abar[t]
abar_t1 = abar[ts[i+1]] if i+1 < steps else torch.tensor(1.) # el último salto va a "sin ruido"
e = pred_eps(x, torch.full((m,), int(t)))
x = ddim_step(x, e, abar_t, abar_t1, eta)
return x
a = sample_ddim(eta=0.); b = sample_ddim(eta=0.)
print("determinista:", torch.allclose(a, b))
for eta in (0., 0.5, 1.):
r = sample_ddim(eta=eta).norm(dim=1)
print(f"eta={eta}: radio medio {r.mean():.3f} ± {r.std():.3f}")
Pista / qué esperar
determinista: True con eta=0 (y False si repetís la comparación con eta=1). El radio medio tendría que quedar cerca de 1 con 20 pasos — diez veces menos que el DDPM del ejercicio 1 — y el desvío crece un poco al subir eta. Probá steps=5 para ver dónde se rompe.
10. Fuentes y para seguir
- fastai/course22p2 — notebooks
15_DDPM.ipynb,20_DDIM.ipynby23_karras.ipynb; el código completo, con U-Net, del que esta unidad muestra solo el núcleo. - Deep Unsupervised Learning using Nonequilibrium Thermodynamics (Sohl-Dickstein et al., 2015) — el origen de la idea; leelo después de DDPM para ver qué simplificó Ho.
- Denoising Diffusion Probabilistic Models (Ho et al., 2020) — los Algoritmos 1 y 2 de la página 4 son exactamente §2 y §3; la sección 3.2 explica por qué predecir ε.
- Denoising Diffusion Implicit Models (Song et al., 2020) — la ecuación (12) es
ddim_stepy la (16) definesigen función deeta. - Elucidating the Design Space of Diffusion-Based Generative Models (Karras et al., 2022) — la Tabla 1 resume todos los samplers anteriores en un mismo marco;
c_skip,c_out,c_inestán en la sección 5. - Documentación de diffusers — la referencia de schedulers para mapear cada fórmula de esta unidad a la clase que la implementa.