Chapter sebelumnya menunjukkan bahwa dua jalur masukan dengan dimensi berbeda dapat diproyeksikan ke ruang bersama lalu difusi. Pertanyaan berikutnya lebih spesifik untuk pasangan gambar-teks: bagaimana dua encoder yang arsitekturnya sama sekali berbeda — CNN untuk piksel dan embedding layer untuk kata — bisa menghasilkan vektor yang dapat dibandingkan? Jawabannya adalah proyeksi terpisah menuju satu dimensi bersama ditambah prosedur penyelarasan yang menarik pasangan benar saling mendekat. Chapter ini membangun kedua encoder itu dalam skala mainan, memverifikasi setiap bentuk tensor, lalu mengukur statistik kosinusnya sebelum dan sesudah pelatihan — dan menemukan satu jebakan penting yang akan diselesaikan pelatihan kontrastif pada chapter berikutnya.

Dua Encoder Menuju Satu Ruang #

Rencana arsitekturnya sederhana. Setiap modalitas memakai encoder yang wajar untuk datanya, lalu keluaran masing-masing diproyeksikan ke dimensi bersama d = 8. Tabel berikut merinci jalur yang akan kita bangun:

ModalitasEncoderBentuk antaraProyeksi akhir
Gambar 8x8Conv2d 4 kanal + pooling(n, 4, 2, 2) → (n, 16)Linear 16 → 8
Teks 3 katann.Embedding 4 dim + mean(n, 3, 4) → (n, 4)Linear 4 → 8

Data sintetisnya memakai tiga kelas gambar 8x8 — garis mendatar, garis tegak, dan titik pusat — yang masing-masing berpasangan dengan caption pendek (“sebuah garis mendatar”, “sebuah garis tegak”, “sebuah titik pusat”). Penyelarasan dilakukan dengan loss MSELoss antar pasangan: vektor gambar ditarik mendekati vektor caption-nya.

Diagram di atas memperlihatkan bahwa kedua jalur hanya bertemu di ruang bersama — tidak ada lapisan yang membaca piksel dan kata sekaligus. Seluruh kesesuaian keduanya menjadi tanggung jawab proyeksi akhir.

Demo: Proyeksi dan Penyelarasan #

Program berikut membangun kedua encoder, mencetak bentuk tensor di setiap tahap, melatih penyelarasan selama 300 epoch, lalu mengukur ulang statistik kosinus pada 30 pasangan uji yang tidak ikut dilatih:

python
"""Image-text representation: encoder gambar mini (CNN) dan encoder
teks mini (embedding kata) diproyeksikan masing-masing ke ruang bersama
d=8, lalu disejajarkan dengan loss MSE antar pasangan (gambar, caption).

Data sintetis: gambar 8x8 tiga kelas (garis mendatar, garis tegak,
titik pusat) dengan caption pendek. Pelatihan MSE berhasil menurunkan
loss, tetapi hasilnya JATUH KE SOLUSI DEGENERATE: semua vektor menumpuk
di satu arah sehingga kosinus pasangan benar dan salah kelas nyaris
sama. Angka ini menjadi bukti empiris mengapa diperlukan negatif
(kontrastif) -- dibahas pada chapter berikutnya. CPU, PyTorch 2.14.
"""

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(7)

D_BERSAMA = 8
CAPTION = [
    ["sebuah", "garis", "mendatar"],
    ["sebuah", "garis", "tegak"],
    ["sebuah", "titik", "pusat"],
]
KATA_KE_ID = {k: i for i, k in enumerate(
    ["sebuah", "garis", "mendatar", "tegak", "titik", "pusat"])}


def buat_gambar(kelas, generator):
    """Gambar 8x8 satu kanal: garis mendatar, garis tegak, atau titik."""
    img = torch.zeros(1, 8, 8)
    if kelas == 0:
        img[0, 4, :] = 1.0
    elif kelas == 1:
        img[0, :, 4] = 1.0
    else:
        img[0, 3:5, 3:5] = 1.0
    return img + 0.15 * torch.randn(1, 8, 8, generator=generator)


def buat_dataset(n_per_kelas, generator):
    """Kembalikan (gambar, token_id, kelas) untuk n_per_kelas tiap kelas."""
    gambar, token, kelas = [], [], []
    for k in range(3):
        for _ in range(n_per_kelas):
            gambar.append(buat_gambar(k, generator))
            token.append(torch.tensor([KATA_KE_ID[w] for w in CAPTION[k]]))
            kelas.append(k)
    return torch.stack(gambar), torch.stack(token), torch.tensor(kelas)


gen_latih = torch.Generator().manual_seed(1)
gen_uji = torch.Generator().manual_seed(2)
g_latih, t_latih, k_latih = buat_dataset(30, gen_latih)
g_uji, t_uji, k_uji = buat_dataset(10, gen_uji)


class EncoderGambar(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = nn.Conv2d(1, 4, kernel_size=3, padding=1)
        self.pool = nn.AdaptiveAvgPool2d(2)
        self.proj = nn.Linear(4 * 2 * 2, D_BERSAMA)

    def forward(self, x):                       # (n, 1, 8, 8)
        h = F.relu(self.conv(x))                # (n, 4, 8, 8)
        h = self.pool(h)                        # (n, 4, 2, 2)
        h = h.flatten(1)                        # (n, 16)
        return self.proj(h)                     # (n, 8)


class EncoderTeks(nn.Module):
    def __init__(self):
        super().__init__()
        self.emb = nn.Embedding(len(KATA_KE_ID), 4)
        self.proj = nn.Linear(4, D_BERSAMA)

    def forward(self, ids):                     # (n, L)
        h = self.emb(ids).mean(dim=1)           # (n, 4)
        return self.proj(h)                     # (n, 8)


enc_g, enc_t = EncoderGambar(), EncoderTeks()


def statistik(z_g, z_t, kelas):
    """Kosinus rata-rata pasangan benar vs pasangan salah kelas."""
    z_g = F.normalize(z_g, dim=1)
    z_t = F.normalize(z_t, dim=1)
    matriks = z_g @ z_t.T                       # (n, n)
    sama = kelas[:, None] == kelas[None, :]
    return matriks[sama].mean().item(), matriks[~sama].mean().item()


print("=== 1. Bentuk data dan jalur encoder ===")
print(f"gambar latih : {tuple(g_latih.shape)}  (n, 1, 8, 8)")
print(f"token id     : {tuple(t_latih.shape)}  (n, panjang_caption)")
print(f"contoh caption kelas 0 -> id {t_latih[0].tolist()}")
with torch.no_grad():
    h = enc_g.pool(F.relu(enc_g.conv(g_uji[:1])))
    z_g, z_t = enc_g(g_uji), enc_t(t_uji)
print(f"konvolusi    : {tuple(g_uji[:1].shape)} -> {tuple(h.shape)}"
      "  (n, 4, 2, 2) setelah pooling")
print(f"embedding    : gambar {tuple(z_g.shape)}, teks {tuple(z_t.shape)}"
      "  -- keduanya d=8")
s0, l0 = statistik(z_g, z_t, k_uji)
print(f"SEBELUM pelatihan: kosinus pasangan benar {s0:.3f}, "
      f"salah kelas {l0:.3f}")
print()

print("=== 2. Pelatihan penyelarasan (MSE antar pasangan) ===")
opt = torch.optim.Adam(list(enc_g.parameters()) + list(enc_t.parameters()),
                       lr=0.01)
loss_fn = nn.MSELoss()
for e in range(300):
    opt.zero_grad()
    loss = loss_fn(enc_g(g_latih), enc_t(t_latih))
    loss.backward()
    opt.step()
with torch.no_grad():
    loss_akhir = loss_fn(enc_g(g_latih), enc_t(t_latih)).item()
print(f"loss MSE akhir (latih): {loss_akhir:.4f}")
print()

print("=== 3. Ruang bersama setelah penyelarasan (data uji) ===")
with torch.no_grad():
    z_g, z_t = enc_g(g_uji), enc_t(t_uji)
print(f"norm rata-rata : gambar {z_g.norm(dim=1).mean():.2f}, "
      f"teks {z_t.norm(dim=1).mean():.2f}")
s1, l1 = statistik(z_g, z_t, k_uji)
print(f"SETELAH pelatihan: kosinus pasangan benar {s1:.3f}, "
      f"salah kelas {l1:.3f}")
print(f"selisih benar - salah kelas: {s1 - l1:.3f}")

with torch.no_grad():
    z_tn = F.normalize(z_t, dim=1)
    proto = torch.stack([z_tn[k_uji == c].mean(dim=0) for c in range(3)])
    proto = F.normalize(proto, dim=1)
    contoh = F.normalize(z_g[13:14], dim=1) @ proto.T
print("kosinus gambar 'garis tegak' vs 3 prototipe caption:",
      " ".join(f"{c:.2f}" for c in contoh[0].tolist()))

Output yang Dihasilkan:

text
=== 1. Bentuk data dan jalur encoder ===
gambar latih : (90, 1, 8, 8)  (n, 1, 8, 8)
token id     : (90, 3)  (n, panjang_caption)
contoh caption kelas 0 -> id [0, 1, 2]
konvolusi    : (1, 1, 8, 8) -> (1, 4, 2, 2)  (n, 4, 2, 2) setelah pooling
embedding    : gambar (30, 8), teks (30, 8)  -- keduanya d=8
SEBELUM pelatihan: kosinus pasangan benar 0.040, salah kelas 0.042

=== 2. Pelatihan penyelarasan (MSE antar pasangan) ===
loss MSE akhir (latih): 0.0000

=== 3. Ruang bersama setelah penyelarasan (data uji) ===
norm rata-rata : gambar 0.50, teks 0.50
SETELAH pelatihan: kosinus pasangan benar 1.000, salah kelas 0.993
selisih benar - salah kelas: 0.007
kosinus gambar 'garis tegak' vs 3 prototipe caption: 1.00 1.00 0.99

Mari kita telaah kode di atas baris per baris:

  1. Baris 29-38 (buat_gambar): setiap kelas digambar dengan pola berbeda pada kisi 8x8 — baris ke-4, kolom ke-4, atau blok 2x2 di tengah — lalu diberi derau Gaussian 0,15 agar encoder tidak sekadar menghafal piksel persis.
  2. Baris 41-49 (buat_dataset): caption diubah menjadi barisan token id lewat kamus KATA_KE_ID, sehingga encoder teks menerima masukan integer seperti pada chapter Word Embedding .
  3. Baris 58-70 (EncoderGambar): konvolusi 4 kanal membaca pola lokal, AdaptiveAvgPool2d(2) memampatkan 8x8 menjadi 2x2 per kanal, lalu flatten menghasilkan 16 fitur yang diproyeksikan ke 8.
  4. Baris 72-81 (EncoderTeks): tiga kata caption menjadi vektor embedding 4 dimensi, dirata-ratakan menjadi satu vektor kalimat, lalu diproyeksikan ke dimensi yang sama dengan jalur gambar — titik ini yang membuat kedua ruang dapat dibandingkan.
  5. Baris 86-93 (statistik): fungsi menghitung matriks kosinus (n gambar) x (n teks), lalu memisahkan rata-rata sel pasangan benar dan pasangan salah kelas memakai mask perbandingan kelas.
  6. Baris 111-123: pelatihan menarik setiap vektor gambar mendekati vektor caption pasangannya dengan MSELoss selama 300 epoch pada 90 pasangan latih.
  7. Baris 135-141: rata-rata embedding teks per kelas menjadi prototipe caption; satu gambar uji dibandingkan terhadap ketiga prototipe itu.

Jebakan Penyelarasan Naif #

Angka pada bagian ketiga memperlihatkan hasil yang tampak bagus tetapi keliru. Loss MSE memang jatuh ke 0,0000 dan kosinus pasangan benar mencapai 1,000 — tetapi kosinus pasangan salah kelas ikut naik ke 0,993, sehingga selisihnya hanya 0,007. Norm kedua encoder juga menyusut menjadi 0,50. Inilah solusi degenerate: cara termudah mengecilkan MSE bukan dengan membuat pasangan benar saling dekat secara informatif, melainkan dengan menyusutkan semua vektor ke arah yang sama. Bukti terakhirnya tegas — gambar “garis tegak” mendapat kosinus 1,00, 1,00, dan 0,99 terhadap ketiga prototipe caption yang berbeda, artinya ruang bersama ini tidak bisa lagi membedakan caption mana yang cocok.

Gejala serupa juga muncul pada sistem nyata dalam bentuk modality gap: kumpulan vektor gambar dan kumpulan vektor teks cenderung menempati wilayah terpisah di ruang bersama meskipun pasangannya telah diselaraskan, karena kedua encoder tetap membawa statistik datanya masing-masing. Kedua masalah ini — degenerasi penyelarasan naif dan pemisahan wilayah modalitas — mengantar kita ke solusi yang mengubah bidang ini: CLIP dan keluarga pelatihan kontrastif image-text yang memakai caption lain di batch sebagai negatif. Chapter Contrastive Image-Text Learning membangun versi mininya dan membuktikan retrieval gambar ke teks yang benar-benar bekerja.