Pipeline pada chapter sebelumnya bekerja mulus di ruang data dua dimensi. Naikkan skala ke gambar 512×512×3, dan setiap sampel menyimpan 786.432 angka; setiap langkah sampling mengevaluasi U-Net raksasa pada tensor sebesar itu, dan pelatihan mengulanginya jutaan kali. Latent Diffusion Model (LDM) dari Rombach dkk. — makalah yang menjadi dasar Stable Diffusion (arxiv.org/abs/2112.10752 ) — menjawab dengan pemindahan arena: kompres data lebih dulu dengan autoencoder, jalankan diffusion di ruang laten yang jauh lebih kecil, lalu dekode hasilnya sekali di akhir. Chapter ini membedah arsitektur lengkapnya — VAE, conditioning teks, classifier-free guidance — lalu membuktikan mekanismenya lewat analogi mini yang benar-benar dijalankan: kompresi data 2D menjadi laten 1D, diffusion di laten, dekode, dan perbandingan langsung melawan diffusion di ruang data.
Masalah: Diffusion di Ruang Piksel itu Mahal #
Biaya diffusion tumbuh mengikuti dimensi ruang kerjanya. Denoiser harus memproses seluruh tensor pada setiap langkah: dengan T ratusan langkah per sampel dan jutaan pasangan training, pengali dimensi menentukan biaya komputasi dan memori. Tabel berikut merangkum posisi kedua pendekatan:
| Aspek | Pixel diffusion | Latent diffusion (LDM) |
|---|---|---|
| Ruang kerja | piksel, mis. 512×512×3 | laten, mis. 64×64×4 |
| Elemen per sampel | ±786 ribu | ±16 ribu (≈1/48) |
| Biaya per langkah sampling | tinggi | jauh lebih rendah |
| Komponen tambahan | tanpa | VAE encoder + decoder |
| Risiko khas | biaya melonjak pada resolusi besar | detail halus bisa terbuang saat kompresi |
Inti gambarnya: piksel menyimpan redundansi besar (tetangga satu sama lain mirip), sedangkan struktur semantik gambar — bentuk objek, pose, gaya — hidup pada dimensi yang jauh lebih rendah. LDM memindahkan diffusion persis ke representasi semantik itu.
Anatomi Latent Diffusion #
Arsitektur LDM tersusun dari tiga komponen yang bekerja berurutan:
VAE — pasangan encoder-decoder dari chapter Autoencoder dan Variational Autoencoder — dipisahkan lebih dulu: encoder memampatkan gambar menjadi laten (misalnya faktor 48 lebih kecil), dan diffusion hanya menyentuh laten. Decoder dipanggil satu kali di akhir sampling untuk mengembalikan resolusi penuh; detail piksel yang terbuang saat kompresi diregenerasi oleh decoder, bukan dipelajari ulang oleh proses diffusion.
Conditioning teks memasukkan deskripsi bahasa ke dalam denoiser. Text encoder (misalnya CLIP atau T5) mengubah prompt menjadi deretan embedding token; embedding tersebut disuntikkan ke tiap blok U-Net lewat cross-attention — mekanisme persis keluarga yang kita bedah di section Attention dan Transformer : fitur laten gambar menjadi query, embedding teks menjadi key dan value, sehingga langkah denoising tahu bagian mana yang harus mengikuti teks.
Classifier-free guidance (CFG) mengatur seberapa keras sampel menuruti prompt. Denoiser dilatih dengan dua mode — bersyarat (dengan teks) dan tak bersyarat (teks dikosongkan secara acak saat training) — lalu saat sampling kedua tebakannya digabung:
ε_cfg = ε_uncond + w · (ε_cond − ε_uncond)
Bobot w = 0 memberi sampel tak bersyarat, w = 1 sampel bersyarat murni, dan w > 1 menarik hasil lebih jauh ke arah prompt. Nilai w yang terlalu besar membuat sampel tajam tetapi seragam — pertukaran kualitas lawan keragaman yang akan kita ukur pada chapter evaluasi berikutnya.
Analogi Mini: Kompresi, Diffusion di Laten, Dekode #
Berkas latent_diffusion_mini.py menjalankan analogi skala kecil dari keseluruhan mekanisme. Data dua cluster 2D berperan sebagai “gambar”. Jalur A melatih diffusion langsung pada data (padanan pixel diffusion). Jalur B melatih autoencoder dengan bottleneck 1D, menormalkan latennya, melatih diffusion di ruang laten 1D, lalu men-decode sampelnya. Kedua jalur dibandingkan pada statistik sampel yang sama:
"""Latent diffusion mini: kompresi AE -> diffusion 1D di latent -> decode.
Jalur A melatih diffusion langsung pada data 2D (ruang data / piksel).
Jalur B melatih autoencoder kecil, menormalkan latent 1D-nya, menjalankan
diffusion di ruang laten, lalu men-decode sampel kembali ke 2D.
Statistik sampel kedua jalur dibandingkan terhadap data.
Diuji pada CPU dengan PyTorch 2.14.
"""
import math
import numpy as np
import torch
import torch.nn as nn
torch.manual_seed(0)
rng = np.random.default_rng(0)
# ---------- 1. Data: dua cluster 2D ----------
N = 400
pusat_a = np.array([1.6, 1.6])
pusat_b = np.array([-1.6, -1.6])
data = np.vstack([
pusat_a + 0.35 * rng.standard_normal((N // 2, 2)),
pusat_b + 0.35 * rng.standard_normal((N // 2, 2)),
])
x0 = torch.tensor(data, dtype=torch.float32)
# ---------- 2. Jadwal derau cosine T = 150 ----------
T = 150
s = 0.008
def f_cos(t):
return np.cos((t / T + s) / (1.0 + s) * np.pi / 2.0) ** 2
ab_np = f_cos(np.arange(T + 1)) / f_cos(0.0)
beta = torch.tensor(np.clip(1.0 - ab_np[1:] / ab_np[:-1], 1e-5, 0.999),
dtype=torch.float32)
alpha = 1.0 - beta
alpha_bar = torch.cumprod(alpha, dim=0)
DIM_EMB = 32
def embedding_t(t):
"""Embedding sinusoidal timestep."""
setengah = DIM_EMB // 2
frekuensi = torch.exp(
-math.log(10000.0)
* torch.arange(setengah, dtype=torch.float32) / setengah
)
sudut = t.float().unsqueeze(1) * frekuensi
return torch.cat([torch.sin(sudut), torch.cos(sudut)], dim=1)
def buat_denoiser(dim_data, lebar):
return nn.Sequential(
nn.Linear(dim_data + DIM_EMB, lebar),
nn.SiLU(),
nn.Linear(lebar, lebar),
nn.SiLU(),
nn.Linear(lebar, dim_data),
)
class Denoiser(nn.Module):
def __init__(self, dim_data, lebar):
super().__init__()
self.net = buat_denoiser(dim_data, lebar)
def forward(self, x, t):
return self.net(torch.cat([x, embedding_t(t)], dim=1))
def latih_denoiser(data_latih, lebar, iterasi, lr=3e-3):
"""Latih epsilon_theta pada (N, dim) data; kembalikan model + loss ujung."""
model = Denoiser(data_latih.shape[1], lebar)
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_fn = nn.MSELoss()
loss_awal = loss_akhir = None
for i in range(1, iterasi + 1):
t = torch.randint(1, T + 1, (len(data_latih),))
ab = alpha_bar[t - 1].unsqueeze(1)
eps = torch.randn_like(data_latih)
xt = torch.sqrt(ab) * data_latih + torch.sqrt(1.0 - ab) * eps
loss = loss_fn(model(xt, t), eps)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if i == 1:
loss_awal = loss.item()
loss_akhir = loss.item()
return model, loss_awal, loss_akhir
def sampling_reverse(model, dim, n_sampel):
"""Balik T langkah dari derau murni menjadi sampel berdimensi dim."""
with torch.no_grad():
x = torch.randn(n_sampel, dim)
for t in range(T, 0, -1):
t_batch = torch.full((n_sampel,), t, dtype=torch.long)
eps_pred = model(x, t_batch)
ab = alpha_bar[t - 1]
beta_t = beta[t - 1]
mean = (x - (beta_t / torch.sqrt(1.0 - ab)) * eps_pred) \
/ torch.sqrt(alpha[t - 1])
if t > 1:
x = mean + torch.sqrt(beta_t) * torch.randn_like(x)
else:
x = mean
return x
def statistik_cluster(titik):
"""Fraksi cluster A, jarak rata-rata ke pusat terdekat, std dalam cluster."""
titik_np = titik if isinstance(titik, np.ndarray) else titik.numpy()
jarak_a = np.linalg.norm(titik_np - pusat_a, axis=1)
jarak_b = np.linalg.norm(titik_np - pusat_b, axis=1)
ke_a = jarak_a < jarak_b
jarak_min = np.minimum(jarak_a, jarak_b)
pusat_titik = np.where(ke_a[:, None], pusat_a, pusat_b)
simpangan = titik_np - pusat_titik
return ke_a.mean(), jarak_min.mean(), simpangan.std(axis=0).mean()
fraksi, jarak, std = statistik_cluster(data)
print(f"data : {N} titik 2 cluster | fraksi A {fraksi:.2f} | "
f"jarak pusat {jarak:.3f} | std dalam cluster {std:.3f}")
# ---------- 3. Jalur A: diffusion langsung di ruang data (2D) ----------
model_pixel, loss_a0, loss_a1 = latih_denoiser(x0, 128, 600)
sampel_pixel = sampling_reverse(model_pixel, 2, 200)
n_param_pixel = sum(p.numel() for p in model_pixel.parameters())
print(f"\njalur A (ruang data 2D) : {n_param_pixel} parameter | "
f"loss {loss_a0:.4f} -> {loss_a1:.4f}")
# ---------- 4. Jalur B: autoencoder -> diffusion di latent 1D ----------
class Autoencoder(nn.Module):
def __init__(self):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(2, 32), nn.SiLU(), nn.Linear(32, 1))
self.decoder = nn.Sequential(
nn.Linear(1, 32), nn.SiLU(), nn.Linear(32, 2))
def forward(self, x):
return self.decoder(self.encoder(x))
ae = Autoencoder()
opt_ae = torch.optim.Adam(ae.parameters(), lr=1e-2)
loss_fn = nn.MSELoss()
for _ in range(500):
rekon = ae(x0)
loss = loss_fn(rekon, x0)
opt_ae.zero_grad()
loss.backward()
opt_ae.step()
rekon_mse = loss_fn(ae(x0), x0).item()
with torch.no_grad():
z_data = ae.encoder(x0)
z_rata2 = z_data.mean()
z_std = z_data.std()
z_norm = (z_data - z_rata2) / z_std
print(f"jalur B: AE | recon MSE {rekon_mse:.4f} | "
f"z mean {z_rata2:.3f} std {z_std:.3f}")
model_lat, loss_b0, loss_b1 = latih_denoiser(z_norm, 64, 600)
z_sampel = sampling_reverse(model_lat, 1, 200)
with torch.no_grad():
z_balik = z_sampel * z_std + z_rata2
sampel_latent = ae.decoder(z_balik)
n_param_lat = (sum(p.numel() for p in ae.parameters())
+ sum(p.numel() for p in model_lat.parameters()))
print(f"jalur B (latent 1D) : {n_param_lat} parameter | "
f"loss {loss_b0:.4f} -> {loss_b1:.4f}")
# ---------- 5. Perbandingan sampel ----------
print("\n=== statistik sampel (200 titik) ===")
print("sumber | fraksi A | jarak pusat | std dalam cluster")
print("--------------+----------+-------------+------------------")
fa, ja, sa = statistik_cluster(data)
print(f"data | {fa:.2f} | {ja:.3f} | {sa:.3f}")
fp, jp, sp = statistik_cluster(sampel_pixel)
print(f"jalur A piksel| {fp:.2f} | {jp:.3f} | {sp:.3f}")
fl, jl, sl = statistik_cluster(sampel_latent)
print(f"jalur B laten | {fl:.2f} | {jl:.3f} | {sl:.3f}")
print("\nruang kerja : A 2 dimensi per sampel | B 1 dimensi per sampel")
print("sampel laten pertama:")
print(np.round(sampel_latent[:5].numpy(), 3))Output yang Dihasilkan:
data : 400 titik 2 cluster | fraksi A 0.50 | jarak pusat 0.441 | std dalam cluster 0.350
jalur A (ruang data 2D) : 21250 parameter | loss 1.0157 -> 0.3650
jalur B: AE | recon MSE 0.0602 | z mean 0.150 std 1.643
jalur B (latent 1D) : 6660 parameter | loss 0.9504 -> 0.3791
=== statistik sampel (200 titik) ===
sumber | fraksi A | jarak pusat | std dalam cluster
--------------+----------+-------------+------------------
data | 0.50 | 0.441 | 0.350
jalur A piksel| 0.53 | 0.400 | 0.321
jalur B laten | 0.48 | 0.355 | 0.324
ruang kerja : A 2 dimensi per sampel | B 1 dimensi per sampel
sampel laten pertama:
[[0.538 0.342]
[1.982 2.204]
[2.103 2.389]
[1.788 1.916]
[1.504 1.516]]Bacaan hasilnya sejajar dengan klaim LDM. Jalur B memakai 6.660 parameter (autoencoder + denoiser laten) melawan 21.250 pada jalur A — sekitar sepertiga — tetapi statistik sampelnya setara: fraksi cluster A 0.48 lawan 0.53 (data 0.50), jarak ke pusat 0.355 lawan 0.400 (data 0.441), dan sebaran dalam cluster 0.324 lawan 0.321 (data 0.350). Lima sampel pertama jalur B jatuh di sekitar kedua pusat cluster, dan sampel pertama [0.538, 0.342] berada di antara kedua pusat — wilayah kosong yang wajar muncul karena decoder meregenerasi sebaran dari kurva 1D, bukan menyalin data. Pada mesin uji CPU, jalur A menuntut sekitar 3,2 detik sementara jalur B selesai sekitar 1,2 detik — penghematan yang sebanding dengan pangkat ruang kerja (2 dimensi turun menjadi 1). Mari kita telaah kode di atas baris per baris:
- Baris 16-27 (data): dua cluster Gaussian 2D menggantikan “gambar”; struktur pentingnya adalah posisi dua pusat, persis struktur semantik yang harus selamat dari kompresi.
- Baris 29-55 (jadwal dan embedding): identik dengan chapter Denoising dan Noise Schedule
— cosine
T = 150, embedding sinusoidal timestep. - Baris 58-95 (
latih_denoiser): fungsi generik untuk dimensi data berapa pun; jalur A memanggilnya dengan data 2D lebar 128, jalur B dengan laten 1D lebar 64 — denoiser laten boleh lebih ramping karena dimensinya lebih kecil. - Baris 98-113 (
sampling_reverse): loop reverse yang sama, kini juga generik terhadap dimensi — bukti bahwa pemindahan ruang tidak mengubah matematika diffusion sedikit pun. - Baris 140-161 (
Autoencoderdan pelatihannya): encoder2 → 32 → 1dan decoder1 → 32 → 2, dilatih 500 iterasi pada loss rekonstruksi;recon MSE 0.0602— kecil dibanding varians data per dimensi (sekitar 2,7), artinya struktur antar-cluster utuh sementara sebagian detail dalam-cluster terbuang, padanan langsung dari “detail piksel diregenerasi decoder” pada LDM. - Baris 163-167 (normalisasi laten): statistik
zdihitung dari data lalu laten dinormalkan ke mean 0 std 1 — diffusion mengasumsikan data bervariasi sekitar skala unit, dan normalisasi ini adalah jembatannya; penyebutnya disimpan untuk dibalikkan setelah sampling. - Baris 171-175 (sampling di laten): reverse loop menghasilkan
zbaru dari derau murni, dinormalkan-balik, lalu didecode menjadi titik 2D — tiga baris yang merupakan seluruh “pipeline LDM” dalam miniatur. - Baris 116-125 dan 181-193 (evaluasi):
statistik_clustermengukur tiga sifat distribusi — keseimbangan cluster, kedekatan ke pusat, dan sebaran dalam cluster — dibandingkan pada data, jalur A, dan jalur B dalam satu tabel.
Kompromi yang Ditukar #
Kompresi tidak gratis. Detail yang berada di luar kapasitas laten tidak dapat dipulihkan oleh decoder — pada demo, itulah asal recon MSE 0.0602 — sehingga kualitas batas halus (tekstur kecil, teks tipis dalam gambar) bergantung pada kualitas VAE. Praktik modern menjawab dengan latent bervarians kecil yang distandarkan, VAE yang lebih besar, atau latent bertingkat. Sisi keuntungannya jauh lebih menonjol: seluruh beban training dan sampling diffusion turun seiring faktor kompresi, dan conditioning teks tetap bekerja karena cross-attention menempel pada U-Net, bukan pada piksel.
Kini seluruh keluarga model generatif kita — VAE, GAN, diffusion beserta varian latennya — mampu menghasilkan data. Pertanyaan penutup section ini bersifat pengukuran: bagaimana cara menilai bahwa sampel generatif memang bagus? Chapter Evaluasi Generative Model membedah FID, Inception Score, precision-recall, dan menutup dengan tabel sintesis seluruh keluarga.