Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced

Diffusion Models and Stable Diffusion

Forward noise, reverse denoising, DDPM, latent diffusion — how Stable Diffusion generates photorealistic images from text prompts.

40–52 min March 2026
Before any math — the core idea

Diffusion models learn one thing: given a slightly noisy image, predict the noise that was added. Run this backwards 1000 times starting from pure noise and you get a photorealistic image.

GANs generate images in one forward pass — fast but unstable. VAEs generate via a compressed latent code — stable but blurry. Diffusion models take a third path: learn to reverse a gradual noising process. The training objective is deceptively simple — take a real image, add a known amount of Gaussian noise, ask the model to predict what noise was added. Repeat this for every noise level from slightly noisy to pure noise. At generation time, start from pure Gaussian noise and iteratively denoise, guided by what the model learned.

The results are extraordinary — diffusion models produce images that are sharper, more diverse, and more faithful to text prompts than any previous approach. Stable Diffusion, DALL-E 3, Midjourney, and Google Imagen are all diffusion models. At e-commerce companies: Shopify uses Stable Diffusion fine-tuned on product catalogs to generate product variations. Adobe's Firefly (widely used by creative agencies) is diffusion-based. Every modern text-to-image system is built on this foundation.

🧠 Analogy — read this first

Teaching someone to restore old damaged photographs. You take a pristine photo and progressively scratch it — first a tiny scratch, then more, then more, until it is completely unrecognisable static. You train a restorer to undo each level of damage. After enough practice they can take completely random static and restore it step by step into a meaningful photograph. The key insight: each restoration step is easy — remove a small amount of noise. But chaining 1000 easy steps produces something remarkable.

The model never needs to generate from nothing. It only ever needs to answer: "given this noisy image at this noise level, what noise should I remove?" That is a much simpler task than "generate a photorealistic image from scratch."

🎯 Pro Tip
This module covers DDPM (the foundational algorithm) from scratch, then shows practical usage with the Diffusers library. Install: pip install diffusers transformers accelerate. Running Stable Diffusion requires a GPU with ≥4GB VRAM for fp16 or can run on CPU slowly.
The forward process

Adding noise — the Markov chain from image to pure noise

The forward process is fixed — not learned. It gradually adds Gaussian noise to an image over T timesteps (typically T=1000). At each timestep t, a small amount of noise is added according to a noise schedule β₁, β₂, …, β_T. By timestep T the image is indistinguishable from pure Gaussian noise. The key mathematical property: you can jump directly to any timestep t without simulating all steps sequentially. This is what makes training efficient.

Forward process — closed form for any timestep t
Noise schedule:
β_t ∈ (0, 1) linearly spaced from β₁=0.0001 to β_T=0.02
One step forward:
q(x_t | x_{t-1}) = N(x_t ; √(1−β_t) × x_{t-1}, β_t × I)
Jump to any t directly (closed form):
ᾱ_t = ∏(1−β_s) for s=1..t ← cumulative product
x_t = √ᾱ_t × x_0 + √(1−ᾱ_t) × ε where ε ~ N(0, I)
x_0 = original image | ε = pure noise | t sampled uniformly during training
python
import torch
import numpy as np
import matplotlib
matplotlib.use('Agg')

# ── DDPM noise schedule and forward process ───────────────────────────
class DDPMScheduler:
    """
    Linear noise schedule from Ho et al. 2020.
    Precomputes all coefficients needed for training and sampling.
    """
    def __init__(self, T: int = 1000, beta_start: float = 1e-4,
                  beta_end: float = 0.02):
        self.T = T

        # Linear noise schedule
        self.betas    = torch.linspace(beta_start, beta_end, T)
        self.alphas   = 1.0 - self.betas
        self.alpha_bar = torch.cumprod(self.alphas, dim=0)   # ᾱ_t

        # Precompute useful terms
        self.sqrt_alpha_bar       = torch.sqrt(self.alpha_bar)
        self.sqrt_one_minus_alpha_bar = torch.sqrt(1 - self.alpha_bar)

        # For reverse process
        self.sqrt_recip_alpha     = torch.sqrt(1.0 / self.alphas)
        self.alpha_bar_prev       = torch.cat([torch.tensor([1.0]),
                                                self.alpha_bar[:-1]])
        self.posterior_variance   = (self.betas *
                                      (1 - self.alpha_bar_prev) /
                                      (1 - self.alpha_bar))

    def add_noise(self, x0: torch.Tensor, t: torch.Tensor,
                   noise: torch.Tensor = None):
        """
        Forward process: add noise to x0 at timestep t.
        q(x_t | x_0) = √ᾱ_t × x_0 + √(1−ᾱ_t) × ε
        Returns noisy image and the noise added (training target).
        """
        if noise is None:
            noise = torch.randn_like(x0)

        # Gather coefficients for batch of timesteps
        sqrt_ab    = self.sqrt_alpha_bar[t].view(-1, 1, 1, 1)
        sqrt_1m_ab = self.sqrt_one_minus_alpha_bar[t].view(-1, 1, 1, 1)

        x_t = sqrt_ab * x0 + sqrt_1m_ab * noise
        return x_t, noise

# ── Demonstrate forward process ───────────────────────────────────────
scheduler = DDPMScheduler(T=1000)

# Synthetic "image" — batch of 4, shape (B, C, H, W)
x0 = torch.rand(4, 3, 64, 64) * 2 - 1   # values in [-1, 1]

print("Forward process — noise levels at different timesteps:")
print(f"{'t':>6} {'ᾱ_t':>10} {'SNR (dB)':>10} {'image_std':>12} {'noise_std':>12}")
print("─" * 54)

for t_val in [0, 100, 250, 500, 750, 900, 999]:
    t_tensor = torch.full((4,), t_val, dtype=torch.long)
    x_t, eps = scheduler.add_noise(x0, t_tensor)

    ab    = scheduler.alpha_bar[t_val].item()
    snr   = 10 * np.log10(ab / (1 - ab + 1e-8))
    print(f"  {t_val:>4}  {ab:>10.4f}  {snr:>10.2f}  "
          f"{x_t.std().item():>12.4f}  {eps.std().item():>12.4f}")

print(f"
At t=0:   ᾱ≈1 → almost original image")
print(f"At t=500: ᾱ≈0.06 → heavily noised")
print(f"At t=999: ᾱ≈0 → pure Gaussian noise")

# ── Noise schedule comparison ─────────────────────────────────────────
print("
Noise schedule types:")
schedules = {
    'Linear (DDPM)':   torch.linspace(1e-4, 0.02, 1000),
    'Cosine (DDIM)':   torch.tensor([
        1 - (np.cos((t/1000 + 0.008)/(1 + 0.008) * np.pi/2)**2) /
        (np.cos((0.008/1.008) * np.pi/2)**2)
        for t in range(1000)
    ]).float().clamp(0, 0.999),
}
for name, betas in schedules.items():
    ab_final = torch.cumprod(1 - betas, dim=0)[-1].item()
    print(f"  {name:<20}: β_T={betas[-1]:.4f}  ᾱ_T={ab_final:.6f}")
What the model learns

The U-Net denoiser — predict the noise, not the image

The learnable part of a diffusion model is a neural network that takes a noisy image x_t and a timestep t as input, and predicts the noise ε that was added. The architecture is a U-Net with time conditioning — the timestep t is embedded into a sinusoidal positional encoding and injected into every residual block via addition or cross-attention. The network must learn to denoise differently for each noise level — removing a tiny amount of noise at t=10 is very different from recovering structure at t=900.

U-Net denoiser — timestep conditioning at every level
Time embeddingt → sinusoidal encoding → MLP → time_emb (dim=256)
Input Convx_t (B,3,H,W) → (B,64,H,W)
ResBlock + time_emb(B,64,H,W) → (B,64,H,W) | time_emb injected by addition
Downsample × 3H→H/2→H/4→H/8 | channels: 64→128→256→512
Bottleneck + Self-Attention(B,512,H/8,W/8) | global context
Upsample × 3 + Skip ConnectionsH/8→H/4→H/2→H | concat encoder features
Output Conv(B,64,H,W) → (B,3,H,W) | predicted noise ε̂
python
import torch
import torch.nn as nn
import math

# ── Sinusoidal timestep embedding ─────────────────────────────────────
class SinusoidalTimeEmbedding(nn.Module):
    """
    Converts integer timestep t into a continuous embedding.
    Same approach as positional encoding in Transformers.
    Allows the model to distinguish noise levels.
    """
    def __init__(self, dim: int):
        super().__init__()
        self.dim = dim

    def forward(self, t: torch.Tensor) -> torch.Tensor:
        device   = t.device
        half_dim = self.dim // 2
        emb      = math.log(10000) / (half_dim - 1)
        emb      = torch.exp(torch.arange(half_dim, device=device) * -emb)
        emb      = t.float()[:, None] * emb[None, :]    # (B, half_dim)
        emb      = torch.cat([emb.sin(), emb.cos()], dim=1)  # (B, dim)
        return emb

# ── Residual block with time conditioning ────────────────────────────
class ResBlockTime(nn.Module):
    """
    ResNet block that takes both image features and time embedding.
    Time embedding is added to the feature map after first conv.
    """
    def __init__(self, in_ch: int, out_ch: int, time_dim: int):
        super().__init__()
        self.conv1    = nn.Sequential(
            nn.GroupNorm(8, in_ch), nn.SiLU(),
            nn.Conv2d(in_ch, out_ch, 3, padding=1),
        )
        self.time_proj = nn.Sequential(
            nn.SiLU(), nn.Linear(time_dim, out_ch),
        )
        self.conv2    = nn.Sequential(
            nn.GroupNorm(8, out_ch), nn.SiLU(),
            nn.Conv2d(out_ch, out_ch, 3, padding=1),
        )
        self.shortcut = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()

    def forward(self, x, time_emb):
        h    = self.conv1(x)
        # Inject time information — broadcast over spatial dimensions
        h    = h + self.time_proj(time_emb)[:, :, None, None]
        h    = self.conv2(h)
        return h + self.shortcut(x)

# ── Minimal denoising U-Net ───────────────────────────────────────────
class UNetDenoiser(nn.Module):
    def __init__(self, in_channels=3, base_ch=64, time_dim=256):
        super().__init__()
        # Time embedding
        self.time_emb = nn.Sequential(
            SinusoidalTimeEmbedding(base_ch),
            nn.Linear(base_ch, time_dim),
            nn.SiLU(),
            nn.Linear(time_dim, time_dim),
        )
        # Encoder
        self.conv_in  = nn.Conv2d(in_channels, base_ch, 3, padding=1)
        self.enc1     = ResBlockTime(base_ch,    base_ch*2, time_dim)
        self.down1    = nn.Conv2d(base_ch*2, base_ch*2, 4, 2, 1)
        self.enc2     = ResBlockTime(base_ch*2,  base_ch*4, time_dim)
        self.down2    = nn.Conv2d(base_ch*4, base_ch*4, 4, 2, 1)
        # Bottleneck
        self.mid      = ResBlockTime(base_ch*4,  base_ch*4, time_dim)
        # Decoder
        self.up1      = nn.ConvTranspose2d(base_ch*4, base_ch*2, 4, 2, 1)
        self.dec1     = ResBlockTime(base_ch*4, base_ch*2, time_dim)  # +skip
        self.up2      = nn.ConvTranspose2d(base_ch*2, base_ch, 4, 2, 1)
        self.dec2     = ResBlockTime(base_ch*2,  base_ch,   time_dim)  # +skip
        # Output
        self.conv_out = nn.Sequential(
            nn.GroupNorm(8, base_ch), nn.SiLU(),
            nn.Conv2d(base_ch, in_channels, 3, padding=1),
        )

    def forward(self, x_t: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        """
        x_t: (B, C, H, W) — noisy image at timestep t
        t:   (B,)          — integer timestep
        Returns: predicted noise ε̂  (same shape as x_t)
        """
        te   = self.time_emb(t)             # (B, time_dim)
        x    = self.conv_in(x_t)            # (B, 64, H, W)
        s1   = self.enc1(x, te)             # (B, 128, H, W)
        x    = self.enc2(self.down1(s1), te) # (B, 256, H/2, W/2)
        s2   = x
        x    = self.mid(self.down2(x), te)  # (B, 256, H/4, W/4)
        x    = self.up1(x)                  # (B, 128, H/2, W/2)
        x    = self.dec1(torch.cat([x, s2], 1), te)
        x    = self.up2(x)                  # (B, 64, H, W)
        x    = self.dec2(torch.cat([x, s1], 1), te)
        return self.conv_out(x)             # (B, 3, H, W) — predicted noise

# ── Shape and parameter check ─────────────────────────────────────────
model = UNetDenoiser(in_channels=3, base_ch=64, time_dim=256)
x_t   = torch.randn(2, 3, 64, 64)
t     = torch.randint(0, 1000, (2,))
eps_pred = model(x_t, t)

params = sum(p.numel() for p in model.parameters())
print(f"Denoising U-Net:")
print(f"  Input x_t:      {tuple(x_t.shape)}")
print(f"  Timestep t:     {t.tolist()}")
print(f"  Predicted noise:{tuple(eps_pred.shape)}")
print(f"  Parameters:     {params:,}")
Training and generation

DDPM training loop and reverse process sampling

Training is remarkably simple: sample a random image from the dataset, sample a random timestep t, add the corresponding amount of noise, ask the model to predict the noise, compute MSE loss. That is the entire training algorithm. No adversarial game, no posterior collapse, no mode collapse. This simplicity is why diffusion models train so reliably compared to GANs.

Sampling (generation) runs the reverse process: start from pure Gaussian noise x_T, iteratively denoise using the trained model, and arrive at a clean image x_0 after T steps. Each denoising step predicts the noise at the current timestep and subtracts it, producing a slightly cleaner image. The full T=1000 steps is slow — DDIM (denoising diffusion implicit models) achieves similar quality in 20–50 steps.

python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from torch.utils.data import Dataset, DataLoader

torch.manual_seed(42)

# ── Re-create scheduler and model from previous sections ──────────────
class DDPMScheduler:
    def __init__(self, T=1000, beta_start=1e-4, beta_end=0.02):
        self.T            = T
        self.betas        = torch.linspace(beta_start, beta_end, T)
        self.alphas       = 1.0 - self.betas
        self.alpha_bar    = torch.cumprod(self.alphas, dim=0)
        self.sqrt_ab      = torch.sqrt(self.alpha_bar)
        self.sqrt_1m_ab   = torch.sqrt(1 - self.alpha_bar)
        self.sqrt_recip_a = torch.sqrt(1.0 / self.alphas)
        self.alpha_bar_prev = torch.cat([torch.tensor([1.0]), self.alpha_bar[:-1]])
        self.post_var     = self.betas * (1 - self.alpha_bar_prev) / (1 - self.alpha_bar)

    def add_noise(self, x0, t, noise=None):
        if noise is None: noise = torch.randn_like(x0)
        sqrt_ab  = self.sqrt_ab[t].view(-1,1,1,1)
        sqrt_1m  = self.sqrt_1m_ab[t].view(-1,1,1,1)
        return sqrt_ab * x0 + sqrt_1m * noise, noise

    @torch.no_grad()
    def reverse_step(self, model, x_t, t_val):
        """One step of reverse diffusion: x_t → x_{t-1}."""
        t_tensor = torch.full((x_t.size(0),), t_val, dtype=torch.long)
        eps_pred = model(x_t, t_tensor)

        # DDPM reverse formula
        coef1    = self.sqrt_recip_a[t_val]
        coef2    = self.betas[t_val] / self.sqrt_1m_ab[t_val]
        mu       = coef1 * (x_t - coef2 * eps_pred)

        if t_val > 0:
            noise    = torch.randn_like(x_t)
            sigma    = torch.sqrt(self.post_var[t_val])
            return mu + sigma * noise
        return mu   # t=0: no noise added

    @torch.no_grad()
    def sample(self, model, shape, device='cpu', verbose=True):
        """Full reverse diffusion from x_T ~ N(0,I) to x_0."""
        x = torch.randn(shape, device=device)
        for t in reversed(range(self.T)):
            x = self.reverse_step(model, x, t)
            if verbose and t % 200 == 0:
                print(f"  t={t:>4}: x mean={x.mean():.4f}  std={x.std():.4f}")
        return x

# ── DDPM training loop ────────────────────────────────────────────────
class TinyDataset(Dataset):
    def __init__(self, n=256):
        self.data = torch.rand(n, 3, 32, 32) * 2 - 1   # [-1, 1]
    def __len__(self): return len(self.data)
    def __getitem__(self, i): return self.data[i]

scheduler = DDPMScheduler(T=100)   # T=100 for speed; production uses T=1000
model     = UNetDenoiser(in_channels=3, base_ch=32, time_dim=128)
optimizer = optim.AdamW(model.parameters(), lr=2e-4)
loader    = DataLoader(TinyDataset(256), batch_size=16, shuffle=True)

print("DDPM Training:")
print(f"{'Epoch':>6} {'Loss':>10}")
print("─" * 20)

for epoch in range(1, 11):
    total_loss = 0
    for x0 in loader:
        # Sample random timesteps — uniform over [0, T)
        t     = torch.randint(0, scheduler.T, (x0.size(0),))
        noise = torch.randn_like(x0)
        x_t, eps = scheduler.add_noise(x0, t, noise)

        # Predict noise at timestep t
        eps_pred = model(x_t, t)

        # Simple MSE loss on predicted vs actual noise
        loss = nn.functional.mse_loss(eps_pred, eps)
        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        total_loss += loss.item()

    if epoch % 2 == 0:
        print(f"  {epoch:>4}  {total_loss/len(loader):>10.6f}")

# ── Generate samples ──────────────────────────────────────────────────
print("
Generating samples (reverse diffusion):")
model.eval()
samples = scheduler.sample(model, shape=(2, 3, 32, 32), verbose=True)
print(f"
Generated shape: {tuple(samples.shape)}")
print(f"  Value range: [{samples.min():.3f}, {samples.max():.3f}]")
Latent diffusion — the Stable Diffusion trick

Why Stable Diffusion runs on consumer GPUs — diffusion in latent space

Running DDPM directly on 512×512 images requires 1000 U-Net forward passes on high-resolution feature maps — enormously expensive. Stable Diffusion's key insight: run diffusion in the latent space of a pretrained VAE, not in pixel space. A VAE encodes a 512×512×3 image into a 64×64×4 latent tensor — a 48× reduction in resolution. Diffusion in this compressed space is 48× faster per step with no loss in final quality, because the VAE decoder restores full resolution at the end. This is Latent Diffusion Models (LDM).

Stable Diffusion pipeline — four components working together
Text Encoder (CLIP)
In: "A red silk saree, studio photography"
Out: text_embeddings (77, 768) — semantic prompt representation
Status: Frozen — pretrained on 400M image-text pairs
VAE Encoder
In: Initial image (for img2img) or skip for txt2img
Out: z_0 (1, 4, 64, 64) — compressed latent
Status: Frozen — pretrained on large image dataset
U-Net Denoiser (with cross-attention)
In: Noisy latent z_t (1,4,64,64) + timestep t + text_embeddings
Out: Predicted noise ε̂ (1,4,64,64)
Status: This is the trainable diffusion model — 860M params in SD 1.5
VAE Decoder
In: Denoised latent z_0 (1,4,64,64)
Out: Final image (1, 3, 512, 512)
Status: Frozen — same VAE as encoder
python
# pip install diffusers transformers accelerate
# Requires ~4GB VRAM for fp16, or runs on CPU (slowly)

from diffusers import (
    StableDiffusionPipeline,
    StableDiffusionImg2ImgPipeline,
    DDIMScheduler,
    DPMSolverMultistepScheduler,
)
import torch
from PIL import Image
import numpy as np

# ── Text-to-image generation ──────────────────────────────────────────
def load_sd_pipeline(model_id: str = 'runwayml/stable-diffusion-v1-5',
                      device: str = 'cuda'):
    pipe = StableDiffusionPipeline.from_pretrained(
        model_id,
        torch_dtype=torch.float16 if device == 'cuda' else torch.float32,
        safety_checker=None,   # disable for production (add your own)
    ).to(device)

    # Swap to faster scheduler — DDIM: 20 steps vs DDPM: 1000 steps
    pipe.scheduler = DDIMScheduler.from_config(pipe.scheduler.config)

    # Memory optimisation
    pipe.enable_attention_slicing()    # reduces peak VRAM
    # pipe.enable_xformers_memory_efficient_attention()  # if xformers installed

    return pipe

print("""
# ── Example: generate Shopify fashion product ─────────────────────────
pipe = load_sd_pipeline()

image = pipe(
    prompt=(
        "A beautiful red silk dress with golden embroidery, "
        "product photography, white background, studio lighting, "
        "high quality, detailed fabric texture"
    ),
    negative_prompt=(
        "blurry, low quality, distorted, ugly, bad anatomy, "
        "watermark, text, logo"
    ),
    num_inference_steps=30,    # DDIM steps — 20-50 is the sweet spot
    guidance_scale=7.5,        # classifier-free guidance scale
    height=512, width=512,
    generator=torch.Generator().manual_seed(42),  # reproducible
).images[0]
image.save('dress_generated.png')
""")

# ── Classifier-free guidance — the key to text conditioning ──────────
print("Classifier-Free Guidance (CFG):")
print("""
# CFG runs the U-Net TWICE per step:
# 1. Conditioned on text prompt   → eps_text
# 2. Conditioned on empty prompt  → eps_uncond
#
# Final prediction = eps_uncond + guidance_scale × (eps_text - eps_uncond)
#
# guidance_scale=1.0: ignore text, pure diffusion
# guidance_scale=7.5: strong text adherence (standard)
# guidance_scale=15+: very literal, less creative, may distort
""")

# ── Faster sampling with DPM-Solver ──────────────────────────────────
print("Scheduler comparison — quality vs speed:")
schedulers = [
    ('DDPM', 1000, 'Original — slow, high quality'),
    ('DDIM', 50,   'Deterministic, 20× faster, similar quality'),
    ('DPM-Solver++', 20, 'Best speed/quality tradeoff — production default'),
    ('LCM', 4,     'Latent Consistency Model — 4 steps, real-time'),
]
for name, steps, note in schedulers:
    time_est = steps * 0.05   # ~50ms per step on T4 GPU
    print(f"  {name:<20}: {steps:>5} steps  ~{time_est:.1f}s/image  {note}")

# ── ControlNet — guide generation with structural input ───────────────
print("""
# ControlNet adds structural guidance to Stable Diffusion:
# - Canny edges: generate image matching an edge map
# - Depth map:   preserve 3D structure from reference
# - Pose:        generate person in specific pose
# - Segmentation: fill segments with generated content

from diffusers import StableDiffusionControlNetPipeline, ControlNetModel

controlnet = ControlNetModel.from_pretrained(
    'lllyasviel/sd-controlnet-canny', torch_dtype=torch.float16
)
pipe = StableDiffusionControlNetPipeline.from_pretrained(
    'runwayml/stable-diffusion-v1-5',
    controlnet=controlnet,
    torch_dtype=torch.float16,
)
# Used by Shopify to generate product images that match a garment silhouette
""")
Customising Stable Diffusion

DreamBooth and LoRA — fine-tuning on your own images

Pretrained Stable Diffusion generates generic content. For specific fashion product images, architectural styles, or brand-specific visual language, you need to fine-tune. Two efficient methods: DreamBooth fine-tunes the entire U-Net on 3–30 images of a specific concept and teaches the model a new token that refers to it. LoRA (Module 51) fine-tunes only 0.5% of the U-Net parameters — achieves similar results with 10× less memory and training time.

python
# ── DreamBooth fine-tuning overview ──────────────────────────────────
print("""
DreamBooth: teach Stable Diffusion a new concept in 3-30 images.

# 1. Pick a rare token as the concept identifier
CONCEPT_TOKEN = 'sks'    # rare token unlikely in training data
INSTANCE_PROMPT = f'a photo of {CONCEPT_TOKEN} denim jacket'
CLASS_PROMPT    = 'a photo of a denim jacket'

# 2. Prepare dataset
#    instance/: 5-10 photos of your specific product
#    class/:    100 generated photos of the general class
#              (prevents overfitting / prior preservation)

# 3. Train with prior preservation loss
#    loss = instance_loss + lambda_prior × class_loss
#    instance_loss: model learns to reconstruct your specific product
#    class_loss:    model retains knowledge of the general class

# 4. After training:
#    'a photo of sks denim jacket' → generates YOUR specific jacket
#    'a photo of sks denim jacket on a runway' → your jacket in new context

# Training command (HuggingFace diffusers):
# accelerate launch train_dreambooth.py
#   --pretrained_model_name_or_path='runwayml/stable-diffusion-v1-5'
#   --instance_data_dir='./instance'
#   --class_data_dir='./class'
#   --instance_prompt='a photo of sks denim jacket'
#   --class_prompt='a photo of a denim jacket'
#   --resolution=512 --train_batch_size=1
#   --gradient_accumulation_steps=1
#   --learning_rate=5e-6 --max_train_steps=800
#   --output_dir='./dreambooth-denim-jacket'
""")

# ── LoRA fine-tuning for Stable Diffusion ─────────────────────────────
print("""
LoRA for SD: same concept as Module 51 but applied to the U-Net.
Trains only the low-rank delta matrices in attention layers.
Result: 50-100MB .safetensors file vs 4GB full fine-tuned model.

from diffusers import StableDiffusionPipeline
from peft import LoraConfig, get_peft_model

# Apply LoRA to the U-Net's attention layers
unet      = pipe.unet
lora_cfg  = LoraConfig(
    r=4,
    lora_alpha=4,
    target_modules=['to_q', 'to_v', 'to_k', 'to_out.0'],
    lora_dropout=0.0,
)
unet = get_peft_model(unet, lora_cfg)
unet.print_trainable_parameters()
# trainable params: 797,696 || all params: 859,520,964 || trainable: 0.09%

# Load community LoRA weights (civitai.com, HuggingFace Hub)
pipe.load_lora_weights('path/to/streetwear-fashion.safetensors')
# Now generates streetwear fashion aesthetic without retraining

# Production pattern: load multiple LoRAs with different weights
# pipe.load_lora_weights(lora1, adapter_name='style1')
# pipe.load_lora_weights(lora2, adapter_name='product')
# pipe.set_adapters(['style1', 'product'], adapter_weights=[0.7, 1.0])
""")

# ── Textual Inversion — lightest fine-tuning ─────────────────────────
print("""
Textual Inversion: learn a new text token embedding (not U-Net weights).
Only 5-10 images needed. Resulting file is 100KB.
Weakest but fastest — good for simple style transfer.

# After training: '<vintage-wedding-style>' becomes a usable token
# pipe('a photo in <vintage-wedding-style> aesthetic')
""")
Errors you will hit

Every common diffusion model mistake — explained and fixed

Generated images are blurry or washed out — guidance_scale too low
Why it happens

Low classifier-free guidance scale means the model gives too little weight to the text prompt and too much weight to the unconditional branch. At guidance_scale=1.0 the model completely ignores the prompt and generates generic images. At guidance_scale=3.0 the text influence is weak. The result is blurry, prompt-agnostic outputs that look like random noise-reduction outputs.

Fix

Use guidance_scale=7.0–7.5 for general prompts — this is the standard well-tested value. For highly detailed prompts or complex compositions, try 8.0–12.0. Values above 15 tend to oversaturate colours and distort proportions. The sweet spot for photorealistic fashion photography is 7.0–9.0. Also check that your negative prompt includes 'blurry, low quality' — this explicitly pushes the model away from low-quality outputs.

DDPM training: loss converges but generated samples look like random noise
Why it happens

The model learned to predict the correct noise magnitude on average but not the correct spatial pattern — common when the noise schedule is wrong or the model is too small for the image resolution. Also caused by not conditioning on timestep t — if time embedding is not properly injected, the model cannot distinguish noise levels and learns a single average denoising operation that works for no specific level.

Fix

Verify time embedding is injected at every residual block. Print a histogram of t values during training — should be uniform across [0, T). Use T=1000 with β_start=1e-4 and β_end=0.02 (standard DDPM values). For 64×64 images, use at least base_ch=128. Check that model(x_t, t) and the noise schedule add_noise function use the same alpha_bar tensor — a mismatch here causes the model to learn to denoise at the wrong noise levels.

CUDA out of memory when running Stable Diffusion inference
Why it happens

Stable Diffusion 1.5 U-Net has 860M parameters — fp32 requires 3.4GB just for weights, plus activations during the forward pass. Running 30 inference steps with batch_size=1 at 512×512 in fp32 peaks at ~8GB VRAM. On a 4GB GPU this fails immediately.

Fix

Use fp16: pipe = StableDiffusionPipeline.from_pretrained(model_id, torch_dtype=torch.float16). This halves memory to ~1.7GB for weights. Enable attention slicing: pipe.enable_attention_slicing() — processes attention in chunks, reduces peak by ~30%. For very low VRAM: pipe.enable_sequential_cpu_offload() — moves model layers to CPU when not in use (slow but works on 4GB). Use SD 2.1 Turbo or LCM-LoRA for 4-step generation if latency is critical.

DreamBooth fine-tuning produces results but the model forgets how to generate general images
Why it happens

Language drift and catastrophic forgetting — the model overfits to the instance images and overwrites its general knowledge. Without prior preservation, the model learns 'denim jacket = this specific blue jacket' and loses the ability to generate any other denim jacket. Training too long (more than 1000 steps for 5-10 images) or using too high a learning rate amplifies this.

Fix

Always use prior preservation loss: generate 100–200 images of the general class using the unmodified model before training, and include them as class images. Use lambda_prior=1.0. Cap training steps at 400–800 for 5-10 instance images. Use learning rate 5e-6 (lower than standard fine-tuning). Alternatively use LoRA for DreamBooth — it is less prone to catastrophic forgetting because backbone weights are frozen.

What this looks like at work

Where diffusion models actually run in production

Almost every consumer-facing "generate an image" feature shipped since 2022 is a diffusion model behind an API, not a raw prompt box. Shopify merchants generate product variations and lifestyle shots from a single hero photo. Adobe Firefly powers Generative Fill inside Photoshop — select a region, describe what should be there, and a latent diffusion model inpaints it consistent with lighting and perspective. Canva's Magic Media and Amazon's advertising creative tools generate dozens of ad variations per SKU overnight so a marketing team can A/B test creative instead of commissioning a photoshoot for every size and background colour. Game studios use fine-tuned diffusion pipelines for concept art and texture generation — not to ship final assets untouched, but to compress a week of concept iteration into an afternoon.

Video generation is the newest frontier built on the same core idea. Runway Gen-3, Pika, Luma, and OpenAI's Sora extend the denoising U-Net (or a Transformer backbone doing the equivalent job) along a temporal axis — instead of denoising one image, the model denoises a whole clip of correlated frames at once, with extra attention layers that keep objects and motion coherent from frame to frame. This is dramatically more expensive than image diffusion: a five-second clip at even a modest frame rate is effectively dozens of images that all have to stay consistent with each other, which is why production video diffusion still runs on large GPU clusters rather than a single consumer card.

Diffusion dominates here ✓
Product photography variation and background swaps at catalog scale
Ad creative generation — many sizes/styles per SKU, generated overnight
Concept art, texture, and material generation for games and VFX
Inpainting/outpainting inside editing tools (Generative Fill, Magic Eraser)
Photo restoration and detail-preserving upscaling
Style-consistent brand imagery via a fine-tuned LoRA layered on a base model
Still weak spots ✗
Reliable text rendering inside images without a specialised model
Exact object counts or precise spatial layout without ControlNet guidance
True real-time interactive generation without heavy step-distillation
Long, character-consistent video generation across tens of seconds
Full-quality on-device inference on a phone without a distilled/quantised model
Guaranteeing outputs are free of visual artifacts on hands, text, and reflections
The ticket you would actually get

Slack message from the growth team: "We are launching 60 new denim SKUs next week. Can we get 8 lifestyle images per SKU — different backgrounds, different models, same product — by Thursday?" That is 480 images with a Thursday deadline, not a research problem. The real work is pipeline engineering: a LoRA fine-tuned on the brand's existing catalog for visual consistency, a ControlNet conditioned on each product's silhouette so the garment shape never distorts, a queue that batches prompts across a small GPU fleet using DPM-Solver++ at around 20 steps for throughput, a safety/NSFW classifier and a brand-compliance check on every output before it reaches a human reviewer, and a fallback path to a real photoshoot for the handful of images that fail review. The model call is a small fraction of the actual engineering effort.

Misconceptions

Five things people get wrong about diffusion models

Myth: The forward noising process is something the model learns

The forward process is fixed before training even starts — it is entirely determined by the noise schedule (the sequence of beta values), with no trainable parameters anywhere in it. The only learned component is the reverse process: the U-Net that predicts what noise was added at a given timestep. This is why you can swap the sampler (DDPM to DDIM to DPM-Solver++) or change the number of inference steps without retraining anything — none of that logic lives in the trained weights, it lives entirely in how you choose to run the fixed forward process backwards.

Myth: Diffusion models generate an image in one pass, the way a GAN does

A GAN's generator produces a full image in a single forward pass. A diffusion model necessarily runs many forward passes through the same network, each one removing a little more noise from an evolving canvas — even the fast samplers still take roughly 20 to 50 steps, not one. This iterative structure is not an implementation detail that could be optimised away without changing the model; it is the mechanism that makes diffusion training so much more stable than adversarial training, at the direct cost of slower generation.

Myth: Diffusion models are just fancy denoising autoencoders

The training objective does look exactly like denoising — predict the noise that was added to a real image. But a plain denoising autoencoder, run once, could never turn pure random static into a photorealistic image; it only knows how to clean up an already-mostly-correct input. What makes diffusion generative is that the network is trained across every noise level at once and then chained, at generation time, into a multi-step process that starts from pure noise rather than a corrupted real image. That chained reverse process is effectively performing iterative score-based sampling — repeatedly nudging a sample toward regions of higher data likelihood — which is a fundamentally different job than one-shot denoising.

Myth: Diffusion beat GANs because it is simply better in every respect

Diffusion is not faster at inference — a GAN's single forward pass will always beat a 20-to-50-step diffusion sampler on raw generation speed, and well-tuned GANs remain competitive on narrow, single-domain tasks like face generation. What diffusion actually won on is training stability (no adversarial balancing act, no mode collapse to fight), sample diversity, and faithfulness on complex, multi-object, prompt-driven generation at scale — the exact properties that matter for a general-purpose text-to-image system serving arbitrary prompts. It is a better foundation for that specific job, not an unconditional upgrade on every axis.

Myth: More denoising steps always produce a better image

Quality gains from additional steps flatten out sharply past roughly 30 to 50 steps with a good sampler like DPM-Solver++ — beyond that point you are mostly spending compute for an image nearly indistinguishable from a few steps earlier. Distilled models like LCM and SDXL-Turbo make this explicit: they train a student network to jump what would normally take many denoising steps in as few as one to four, trading a small, often barely perceptible quality cost for near real-time generation. Step count is a tunable engineering knob you trade against latency, not a fixed law that ties more computation directly to better output.

Interview prep

Diffusion models — 5 questions interviewers actually ask

Q1 — What is the noise schedule, and why does the choice of schedule matter?

The noise schedule is the sequence of beta values that controls how much Gaussian noise gets added at each of the T forward timesteps, and its cumulative product (alpha-bar) determines the signal-to-noise ratio at every point along the chain. A linear schedule, used in the original DDPM paper, spends a lot of the early timesteps barely perturbing the image and then destroys it very quickly near the end. A cosine schedule spreads the destruction more evenly, preserving more usable signal through the middle and late timesteps, which the Improved DDPM paper showed measurably improves perceptual sample quality. In short: the schedule determines what fraction of training effectively happens at "easy" versus "hard" noise levels, and a poorly chosen one wastes model capacity on noise levels that do not matter much for final quality.

Q2 — Why is sampling slow, and how do DDIM and distillation speed it up?

Reverse diffusion is inherently sequential: producing the image at step t minus one requires the output already computed at step t, so you cannot parallelise across timesteps the way you can across a batch during training. DDIM reformulates the reverse process as a non-Markovian, deterministic mapping that solves the same underlying differential equation but allows skipping directly from one timestep to a much earlier one, cutting a thousand sequential steps down to roughly twenty to fifty with little quality loss. Distillation goes further: a student network is trained to directly predict what several steps of the full teacher process would have produced, eventually collapsing the entire chain to as few as one to four steps, which is how models like LCM achieve near real-time generation.

Q3 — What is classifier-free guidance, and why is it preferred over classifier guidance?

Classifier-free guidance runs the same U-Net twice per step — once conditioned on the text prompt, once with the prompt dropped — and pushes the final prediction further in the direction the conditioned pass diverges from the unconditioned one, scaled by the guidance_scale parameter. The older alternative, classifier guidance, required training a separate classifier on noisy images at every noise level and using its gradient to steer sampling — expensive to train, tied to whatever labels that classifier was built for, and numerically fragile. Classifier-free guidance needs no second model: the same diffusion model is simply trained with prompt conditioning randomly dropped some fraction of the time, so one set of weights can do both the conditioned and unconditioned forward pass needed for guidance.

Q4 — Why does Stable Diffusion run diffusion in latent space instead of pixel space?

A pretrained VAE compresses a 512-by-512 pixel image down to a much smaller latent tensor before diffusion ever touches it, so every one of the many denoising steps operates on roughly forty-eight times fewer values. That compression is handled once, by a separate frozen network trained specifically for perceptual compression, which frees the diffusion U-Net's entire capacity for modelling semantic content rather than spending iterations removing noise from raw pixels. The VAE decoder restores full resolution only once, at the very end, after the expensive iterative part is already finished — which is the difference between Stable Diffusion running on a consumer GPU and needing a data-centre-scale setup for the same output quality.

Q5 — How would you decide between full fine-tuning, DreamBooth, LoRA, and textual inversion for teaching a model a new visual concept?

It comes down to how much data you have and how much you need to preserve the model's general knowledge. With only five to ten images of a single subject, textual inversion or DreamBooth with prior preservation loss is the right call — DreamBooth updates more of the network so it captures the subject better, at higher risk of catastrophic forgetting without prior preservation. For a broader style or a specific product line with more examples, LoRA is usually the right default: it trains a small fraction of parameters, produces a compact adapter file that is easy to version, swap, and combine with other LoRAs, and is much less prone to overwriting the base model's general capability. Full fine-tuning of the entire U-Net is rarely justified for an application team — it is expensive, slow to iterate on, and LoRA reaches comparable quality for a fraction of the cost in the vast majority of real product use cases.

What comes next

You understand how images are generated. Next: how the largest language models are built and aligned.

Diffusion models generate images by learning to reverse a noising process. LLMs generate text by learning to predict the next token — but at a scale and with emergent capabilities that make them qualitatively different from anything before. Module 64 covers how GPT, Claude, and Gemini are built: next-token pretraining at scale, RLHF alignment, DPO, instruction tuning, and the scaling laws that predict capability from compute.

Next — Module 64 · Generative AI
LLMs — Pretraining, RLHF, and Scaling Laws

How GPT, Claude, and Gemini are built. Next-token prediction at scale, RLHF alignment, DPO, and the laws that predict capability.

Start →

🎯 Key Takeaways

  • Diffusion models learn to reverse a fixed noising process. The forward process gradually adds Gaussian noise to an image over T=1000 steps until it becomes pure noise. The reverse process trains a U-Net to predict the noise at each step. Generation = start from pure noise, run the reverse process T times.
  • The closed-form forward process lets you jump to any timestep t directly: x_t = √ᾱ_t × x_0 + √(1−ᾱ_t) × ε where ᾱ_t is the cumulative product of (1−β_s). Training samples random t values and asks the model to predict ε from x_t — the entire training algorithm is this MSE loss.
  • The denoising U-Net takes a noisy image x_t and a timestep t as input. Timestep t is converted to a sinusoidal embedding and injected into every residual block. The architecture is identical to segmentation U-Net but with time conditioning — skip connections preserve spatial detail for precise denoising.
  • Stable Diffusion runs diffusion in the 64×64×4 latent space of a pretrained VAE, not in 512×512 pixel space. This 48× compression makes each denoising step 48× cheaper with no quality loss. The VAE decoder restores full resolution at the end. This is Latent Diffusion Models (LDM).
  • Classifier-free guidance (CFG) runs the U-Net twice per step: once with the text prompt and once without. The final prediction is: eps_uncond + scale × (eps_text − eps_uncond). guidance_scale=7.5 is the standard. Higher scale = more prompt adherence but potential distortion. Lower scale = more diversity but ignores prompt.
  • Fine-tuning options by cost: Textual Inversion (learn one new token, 100KB, 5 images, weakest) → LoRA (train 0.09% of U-Net, 50MB, 10-50 images, strong) → DreamBooth (fine-tune full U-Net, 4GB, 5-30 images, strongest). Always use prior preservation loss in DreamBooth to prevent catastrophic forgetting of general knowledge.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...