Chapter sebelumnya menutup dengan satu kejanggalan: autoencoder mengompresi dengan baik, tetapi ruang latennya penuh wilayah yang tidak pernah dilatih — menarik z acak di wilayah itu membuat decoder menghasilkan keluaran yang tidak menyerupai data apa pun. Variational autoencoder (VAE) mengubah struktur latent itu secara sistematis: encoder tidak lagi menghasilkan satu titik z, melainkan sebuah distribusi, dan sebuah regularizer KL memaksa seluruh distribusi tersebut menempel pada prior N(0,1) yang kita pilih sendiri. Hasil akhirnya adalah model yang benar-benar bisa di-sampling: tarik z dari prior, decoder menghasilkan data baru yang masuk akal. Chapter ini membangun VAE mini di PyTorch, membuktikan reparameterization trick secara numerik, lalu membandingkan angka demi angka dengan autoencoder biasa pada data yang sama.

Masalah: Ruang Laten yang Berlubang #

Mengapa autoencoder gagal sebagai model generatif? Karena loss-nya hanya peduli pada satu hal: data latih harus bisa direkonstruksi lewat z miliknya sendiri. Tidak ada tekanan apa pun pada wilayah z yang tidak dikunjungi data. Encoder bebas menempatkan cluster di mana saja di sumbu z — hasilnya rentang z yang asimetris, bersela, dan penuh lubang. Ketika kita menarik z dari distribusi sederhana seperti N(0,1), sebagian sampel jatuh di lubang, dan decoder yang tidak pernah dilatih di sana menghasilkan keluaran tak bermakna.

VAE menutup lubang ini lewat tiga komponen yang bekerja bersama:

  • Distribusi posterior q(z|x) — encoder mengeluarkan dua angka per data: mu(x) dan sigma(x). Satu titik data tidak lagi punya satu z, melainkan sebuah awan peluang.
  • Reparameterization trick — pengambilan sampel ditulis sebagai z = mu + sigma · eps dengan eps ~ N(0,1), sehingga keacakan pindah ke variabel masukan eps dan gradien tetap mengalir ke mu serta sigma.
  • Regularizer KL — jarak Kullback-Leibler antara q(z|x) dan prior N(0,1), yang untuk Gaussian diagonal berbentuk ringkas:

KL = 0.5 · Σ (mu² + sigma² − 1 − ln sigma²)

Loss total VAE adalah penjumlahan keduanya: error rekonstruksi menarik model ke ketepatan, KL menahan posterior di sekitar prior. Kombinasi ini adalah bentuk negatif dari Evidence Lower Bound (ELBO) — kerangka yang diperkenalkan makalah asli VAE, Auto-Encoding Variational Bayes (arXiv:1312.6114) .

Demo: VAE Mini pada Data Tiga Cluster #

Data kita: tiga cluster 2D yang duduk pada satu busur — struktur berlubang yang sempurna untuk menguji ruang laten. Berkas vae_mini.py melatih autoencoder biasa sebagai pembanding, lalu VAE pada data yang sama:

python
"""Variational autoencoder mini pada data 2D tiga cluster (PyTorch).

Pembuka: verifikasi reparameterization trick (gradien mengalir lewat
z = mu + sigma*eps). Pembanding: autoencoder biasa yang rekonstruksinya
bagus tetapi ruang latennya berlubang -- decoder pada z di luar wilayah
terlatih menghasilkan titik jauh dari data. Pemain utama: VAE dengan
q(z|x) = N(mu(x), sigma(x)^2), sampling lewat reparameterization, dan
regularizer KL ke prior N(0,1), sehingga sampling z dari prior
menghasilkan titik dekat data. Deterministik (seed tetap), CPU.
"""

import math

import numpy as np
import torch
import torch.nn as nn

torch.manual_seed(0)
rng = np.random.default_rng(5)

# ---------- 1. Data: tiga cluster pada satu busur ----------
sudut = np.deg2rad([45.0, 90.0, 135.0])
pusat = np.stack(
    [3.0 + 3.0 * np.cos(sudut), 0.5 + 3.0 * np.sin(sudut)], axis=1
)
X = np.vstack([c + rng.normal(0, 0.30, size=(150, 2)) for c in pusat])
Xt = torch.tensor(X, dtype=torch.float32)
print("pusat cluster:", pusat.round(2).tolist())
print(f"data: {len(X)} titik 2D (tiga cluster, std 0.30)")
print()

# ---------- 2. Reparameterization trick: verifikasi ----------
mu_c = torch.tensor(1.5, requires_grad=True)
sigma_c = torch.tensor(0.5, requires_grad=True)
eps_c = torch.tensor(0.3)
(mu_c + sigma_c * eps_c).backward()
print("=== Reparameterization: z = mu + sigma * eps ===")
print(f"eps = 0.3 -> dz/dmu = {mu_c.grad.item():.1f},"
      f" dz/dsigma = {sigma_c.grad.item():.1f} (= eps)")
z_banyak = 2.0 + 0.5 * torch.randn(20000)
print(f"20000 sampel mu=2.0, sigma=0.5 -> mean {z_banyak.mean().item():.3f},"
      f" std {z_banyak.std().item():.3f}")
print()


def jarak_ke_data(x_gen):
    """Jarak tiap titik generasi ke data latih terdekat."""
    return np.sqrt(((x_gen[:, None, :] - X[None, :, :]) ** 2).sum(-1)).min(1)


def jarak_ke_pusat(x_gen):
    """Jarak tiap titik generasi ke pusat cluster terdekat."""
    return np.sqrt(((x_gen[:, None, :] - pusat[None, :, :]) ** 2).sum(-1)).min(1)


# ---------- 3. Pembanding: autoencoder biasa ----------
class Autoencoder(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = nn.Sequential(nn.Linear(2, 16), nn.Tanh(), nn.Linear(16, 1))
        self.decoder = nn.Sequential(nn.Linear(1, 16), nn.Tanh(), nn.Linear(16, 2))

    def forward(self, x):
        return self.decoder(self.encoder(x))


ae = Autoencoder()
opt = torch.optim.Adam(ae.parameters(), lr=1e-2)
for ep in range(800):
    loss = ((ae(Xt) - Xt) ** 2).sum(1).mean()
    opt.zero_grad()
    loss.backward()
    opt.step()
with torch.no_grad():
    z_latih = ae.encoder(Xt).squeeze(1)
zmin, zmax = z_latih.min().item(), z_latih.max().item()
p_luar = 0.5 * (1 + math.erf(zmin / math.sqrt(2))) + 0.5 * (1 - math.erf(zmax / math.sqrt(2)))
print("=== Autoencoder biasa (pembanding) ===")
print(f"MSE rekonstruksi : {loss.item():.4f} (bagus sebagai kompresor)")
for k in range(3):
    zk = z_latih[k * 150:(k + 1) * 150]
    print(f"  z cluster {k}: [{zk.min():.2f}, {zk.max():.2f}]")
print(f"z total [{zmin:.2f}, {zmax:.2f}];"
      f" P(sampel N(0,1) jatuh di luar rentang) = {p_luar:.3f}")
z_prior = rng.normal(0, 1, size=(1000, 1))
x_ae = ae.decoder(torch.tensor(z_prior, dtype=torch.float32)).detach().numpy()
print(f"sampling z~N(0,1): jarak ke data terdekat {jarak_ke_data(x_ae).mean():.3f},"
      f" dekat pusat {(jarak_ke_pusat(x_ae) < 0.9).mean():.3f}")
print()

# ---------- 4. VAE: q(z|x), reparameterization, KL ----------
class VAE(nn.Module):
    def __init__(self):
        super().__init__()
        self.enc_fc = nn.Sequential(nn.Linear(2, 16), nn.Tanh())
        self.mu = nn.Linear(16, 1)
        self.logvar = nn.Linear(16, 1)
        self.decoder = nn.Sequential(nn.Linear(1, 16), nn.Tanh(), nn.Linear(16, 2))

    def encode(self, x):
        h = self.enc_fc(x)
        return self.mu(h), self.logvar(h)

    def forward(self, x):
        mu, logvar = self.encode(x)
        eps = torch.randn_like(mu)
        z = mu + torch.exp(0.5 * logvar) * eps
        return self.decoder(z), mu, logvar


vae = VAE()
opt = torch.optim.Adam(vae.parameters(), lr=1e-2)
print("=== Pelatihan VAE (rekon deterministik z=mu + KL) ===")
for ep in range(1600):
    x_rekon, mu, logvar = vae(Xt)
    rekon = ((x_rekon - Xt) ** 2).sum(1).mean()
    kl = (0.5 * (mu ** 2 + logvar.exp() - 1 - logvar)).sum(1).mean()
    loss = rekon + kl
    opt.zero_grad()
    loss.backward()
    opt.step()
    if ep % 400 == 0 or ep == 1599:
        with torch.no_grad():
            rekon_det = ((vae.decoder(mu) - Xt) ** 2).sum(1).mean().item()
        print(f"  epoch {ep:4d}  rekon = {rekon_det:.3f}  KL = {kl.item():.3f}"
              f"  total = {rekon_det + kl.item():.3f}")
print()

with torch.no_grad():
    mu_latih, logvar_latih = vae.encode(Xt)
print("posterior q(z|x) per cluster:")
for k in range(3):
    muk = mu_latih[k * 150:(k + 1) * 150]
    sigmak = logvar_latih[k * 150:(k + 1) * 150].exp().sqrt()
    print(f"  cluster {k}: mu {muk.mean():+.2f}, sigma {sigmak.mean():.2f}")
print()

# ---------- 5. Sampling dari prior N(0,1) ----------
z_prior2 = rng.normal(0, 1, size=(1000, 1))
x_vae = vae.decoder(torch.tensor(z_prior2, dtype=torch.float32)).detach().numpy()
print("=== Sampling z ~ N(0,1) ===")
print(f"model AE : jarak ke data {jarak_ke_data(x_ae).mean():.3f},"
      f" dekat pusat {(jarak_ke_pusat(x_ae) < 0.9).mean():.3f}")
print(f"model VAE: jarak ke data {jarak_ke_data(x_vae).mean():.3f},"
      f" dekat pusat {(jarak_ke_pusat(x_vae) < 0.9).mean():.3f}")
print()
print("decoder pada tiga nilai z yang sama:")
for zv in (-1.5, 0.0, 1.5):
    zt = torch.tensor([[zv]], dtype=torch.float32)
    with torch.no_grad():
        xa = ae.decoder(zt).numpy()[0]
        xv = vae.decoder(zt).numpy()[0]
    xa = np.round(xa.astype(np.float64), 2).tolist()
    xv = np.round(xv.astype(np.float64), 2).tolist()
    dp = jarak_ke_pusat(np.array([xv]))[0]
    print(f"  z = {zv:+.1f}  AE -> {str(xa):16s} VAE -> {str(xv):16s}"
          f" (jarak VAE ke pusat {dp:.2f})")

Output yang Dihasilkan:

text
pusat cluster: [[5.12, 2.62], [3.0, 3.5], [0.88, 2.62]]
data: 450 titik 2D (tiga cluster, std 0.30)

=== Reparameterization: z = mu + sigma * eps ===
eps = 0.3 -> dz/dmu = 1.0, dz/dsigma = 0.3 (= eps)
20000 sampel mu=2.0, sigma=0.5 -> mean 1.994, std 0.502

=== Autoencoder biasa (pembanding) ===
MSE rekonstruksi : 0.0887 (bagus sebagai kompresor)
  z cluster 0: [-2.56, -1.00]
  z cluster 1: [-0.44, 0.18]
  z cluster 2: [0.45, 0.91]
z total [-2.56, 0.91]; P(sampel N(0,1) jatuh di luar rentang) = 0.188
sampling z~N(0,1): jarak ke data terdekat 0.360, dekat pusat 0.654

=== Pelatihan VAE (rekon deterministik z=mu + KL) ===
  epoch    0  rekon = 20.069  KL = 0.109  total = 20.178
  epoch  400  rekon = 0.131  KL = 0.940  total = 1.071
  epoch  800  rekon = 0.138  KL = 0.992  total = 1.130
  epoch 1200  rekon = 0.121  KL = 0.938  total = 1.059
  epoch 1599  rekon = 0.124  KL = 0.993  total = 1.117

posterior q(z|x) per cluster:
  cluster 0: mu +1.15, sigma 0.42
  cluster 1: mu -0.02, sigma 0.29
  cluster 2: mu -1.14, sigma 0.46

=== Sampling z ~ N(0,1) ===
model AE : jarak ke data 0.360, dekat pusat 0.654
model VAE: jarak ke data 0.091, dekat pusat 0.880

decoder pada tiga nilai z yang sama:
  z = -1.5  AE -> [5.07, 2.7]      VAE -> [0.78, 2.55]     (jarak VAE ke pusat 0.12)
  z = +0.0  AE -> [2.62, 3.45]     VAE -> [2.98, 3.46]     (jarak VAE ke pusat 0.04)
  z = +1.5  AE -> [-1.25, 1.3]     VAE -> [5.18, 2.53]     (jarak VAE ke pusat 0.11)

Mari kita telaah kode di atas baris per baris:

  1. Baris 22-27: data dibangun dari tiga pusat pada busur setengah lingkaran; setiap cluster berisi 150 titik dengan simpangan 0,30 — celah antar cluster inilah yang nanti menguji ruang laten.
  2. Baris 33-42: verifikasi reparameterization — turunan z terhadap mu tepat 1,0 dan terhadap sigma tepat 0,3 (sama dengan eps); secara statistik, 20000 sampel menghasilkan mean 1,994 dan std 0,502, sesuai mu = 2.0 dan sigma = 0.5 yang dipakai.
  3. Baris 57-64: kelas Autoencoder pembanding — arsitektur dan ukuran layer sama dengan VAE agar perbandingannya adil.
  4. Baris 67-88: diagnosis ruang laten AE — MSE rekonstruksi 0,0887 (kompresor yang bagus), tetapi tiga cluster menempati rentang z yang bersela: [-2.56, -1.00], [-0.44, 0.18], [0.45, 0.91]; 18,8% sampel N(0,1) jatuh di luar rentang terlatih, dan sampling menghasilkan titik berjarak rata-rata 0,360 dari data terdekat.
  5. Baris 92-108: kelas VAE — encoder berhenti di mu dan logvar (log varians untuk menjaga kenumerikan), lalu forward menulis sampel sebagai z = mu + exp(0.5·logvar) · eps — reparameterization persis seperti diverifikasi di poin 2.
  6. Baris 114-126: training loop — loss = rekonstruksi + KL; total turun dari 20,178 ke 1,117. Angka rekonstruksi dicetak untuk z = mu (deterministik) supaya lintasan loss tidak bergetar karena keacakan eps.
  7. Baris 129-135: hasil struktural yang paling penting — posterior tiga cluster menempel rapi pada prior: mu di +1,15, −0,02, −1,14 dengan sigma 0,29-0,46; distribusi-distribusi itu saling menutupi sepanjang sumbu z.
  8. Baris 139-157: uji akhir — sampling 1000 titik z ~ N(0,1) lalu membandingkan decoder kedua model pada tiga nilai z yang sama.

Tiga kelompok angka menutup argumen. Pertama, loss VAE turun dari 20,178 ke 1,117 dengan komponen rekonstruksi 0,124 — sedikit lebih buruk dari 0,0887 milik AE, harga yang dibayar untuk regularisasi KL. Kedua, sampling dari prior: 88,0% sampel VAE mendarat dekat pusat cluster dengan jarak rata-rata ke data 0,091 — empat kali lebih dekat daripada AE (0,360, hanya 65,4% valid). Ketiga, tabel tiga nilai z: pada z = +1.5 (di luar rentang z AE), decoder AE mengeluarkan [-1.25, 1.3] — jauh dari busur — sedangkan decoder VAE mengeluarkan [5.18, 2.53], hanya 0,11 dari pusat cluster 0. Perhatikan juga bahwa AE menempatkan cluster 0 pada z negatif dan VAE pada z positif: arah sumbu laten AE murni kebetulan pelatihan, sedangkan VAE terpaku pada prior yang kita tentukan.

Autoencoder vs Variational Autoencoder #

AspekAutoencoderVAE
Keluaran encoder untuk satu xsatu titik zdistribusi N(mu, sigma²)
Bentuk ruang latenbebas, cenderung berlubangdipaksa mendekati prior N(0,1)
Sampling dari modeltidak terjamin bermaknaz ~ N(0,1) lalu decoder
Losshanya rekonstruksirekonstruksi + KL (negatif ELBO)
Kualitas rekonstruksilebih tajamsedikit lebih kabur
Status probabilistikbukan model peluang P(x) eksplisitaproksimasi P(x) terkontrol

VAE memperbaiki struktur ruang laten dengan mengikatnya pada distribusi — tetapi seluruh latihannya tetap satu jaringan yang mengejar satu loss. Keluarga pendekatan berikutnya meninggalkan formula peluang sama sekali: dua jaringan yang saling menyaingi, generator dan discriminator — pembuka dunia GAN pada chapter berikutnya.