Chapter sebelumnya berakhir pada kegagalan yang informatif: penyelarasan MSE membuat semua vektor menumpuk di satu arah, sehingga ruang bersama kehilangan daya pembeda. Pelatihan kontrastif memperbaiki kegagalan itu dengan satu perubahan prinsip — selain menarik pasangan positif, kita juga menjauhkan pasangan negatif. Pada setting image-text, negatif paling murah berasal dari batch itu sendiri: jika satu batch berisi delapan pasangan (gambar, caption), maka caption milik pasangan lain otomatis menjadi negatif bagi gambar kita. Pola ini dipopulerkan CLIP dan menjadi fondasi model image-text modern. Chapter ini membangun mini CLIP yang benar-benar dilatih, membuktikan loss-nya turun dan retrieval-nya bekerja, lalu menutup dengan mekanisme klasifikasi zero-shot.

InfoNCE Simetris pada Batch #

Teori lengkap pasangan positif-negatif sudah kita bahas di chapter Contrastive Learning dan Positive dan Negative Pairs ; angle di sini adalah penerapannya lintas modalitas. Dua encoder — satu untuk gambar, satu untuk teks — masing-masing menghasilkan vektor ternormalisasi. Matriks kosinus berukuran 8x8 membandingkan setiap gambar terhadap setiap caption dalam batch; diagonalnya adalah pasangan positif, dan sisanya negatif.

Loss-nya simetris. Dari sisi gambar, setiap baris matriks adalah distribusi kandidat caption dan jawaban benarnya adalah indeks diagonal; cross-entropy atas baris menarik pasangan positif dan menjauhkan negatif. Dari sisi teks, kolom-kolom memainkan peran yang sama terhadap gambar. Loss akhir adalah rata-rata kedua arah — inilah “symmetric InfoNCE”. Pembagi temperature 0,07 mengubah skala kosinus [-1, 1] menjadi rentang logit yang cukup tajam agar softmax benar-benar memisahkan kandidat.

Diagram di atas menunjukkan mengapa kedua menara harus dilatih bersamaan: gradien dari loss menarik pasangan positif saling mendekat di ruang bersama sambil mendorong tujuh kandidat lain menjauh — tekanan yang hilang pada MSE murni.

Demo: Mini CLIP yang Benar-Benar Belajar #

Data sintetis memakai delapan “konsep” dengan prototipe gambar 10 dimensi dan prototipe teks 7 dimensi — dimensi sengaja dibuat berbeda agar kedua encoder benar-benar berdiri sendiri. Setiap batch latih berisi satu sampel dari tiap konsep sehingga ketujuh caption lain adalah negatif sejati:

python
"""Mini CLIP: pelatihan kontrastif image-text dengan InfoNCE simetris.

Delapan "konsep" sintetis; setiap konsep punya prototipe gambar 10 dim
dan prototipe teks 7 dim (dimensi sengaja berbeda). Batch berisi 8
pasangan dari 8 konsep BERBEDA sehingga 7 caption lain di batch menjadi
negatif sejati. Loss = cross-entropy dua arah atas matriks kosinus
(dibagi temperature 0.07) -- pola CLIP.

Setelah pelatihan: retrieval gambar->teks dan teks->gambar pada
10 ronde x 8 kandidat (data uji), plus klasifikasi zero-shot
(gambar -> konsep lewat prototipe teks). CPU, PyTorch 2.14.
"""

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

torch.manual_seed(11)

N_KONSEP = 8
D_GAMBAR, D_TEKS, D_EMB = 10, 7, 16
TEMP = 0.07
N_LATIH_PER, N_UJI_PER = 12, 3

gen_proto = torch.Generator().manual_seed(5)
proto_g = torch.randn(N_KONSEP, D_GAMBAR, generator=gen_proto)
proto_t = torch.randn(N_KONSEP, D_TEKS, generator=gen_proto)


def buat_pasangan(n_per_konsep, generator):
    """Sampel gambar/teks = prototipe konsep + derau Gaussian 0.35."""
    g = proto_g.repeat_interleave(n_per_konsep, dim=0)
    t = proto_t.repeat_interleave(n_per_konsep, dim=0)
    g = g + 0.35 * torch.randn_like(g, generator=generator)
    t = t + 0.35 * torch.randn_like(t, generator=generator)
    k = torch.arange(N_KONSEP).repeat_interleave(n_per_konsep)
    return g, t, k


g_latih, t_latih, k_latih = buat_pasangan(N_LATIH_PER,
                                          torch.Generator().manual_seed(1))
g_uji, t_uji, k_uji = buat_pasangan(N_UJI_PER,
                                    torch.Generator().manual_seed(2))

print("=== 1. Data kontrastif ===")
print(f"latih: {g_latih.shape[0]} pasangan, "
      f"uji: {g_uji.shape[0]} pasangan, {N_KONSEP} konsep")
print(f"dimensi mentah: gambar {D_GAMBAR}, teks {D_TEKS} "
      "(berbeda -- encoder terpisah)")


class MenaraGambar(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Linear(D_GAMBAR, D_EMB)

    def forward(self, x):
        return F.normalize(self.net(x), dim=1)


class MenaraTeks(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Linear(D_TEKS, D_EMB)

    def forward(self, x):
        return F.normalize(self.net(x), dim=1)


menara_g, menara_t = MenaraGambar(), MenaraTeks()
opt = torch.optim.Adam(list(menara_g.parameters()) + list(menara_t.parameters()),
                       lr=0.02)
label_batch = torch.arange(8)

print()
print("=== 2. Pelatihan InfoNCE simetris (batch 8 konsep berbeda) ===")
loss_jejak = []
for epoch in range(250):
    # susun batch: baris ke-b berisi 1 sampel dari tiap konsep
    total = 0.0
    for b in range(N_LATIH_PER):
        idx = torch.arange(N_KONSEP) * N_LATIH_PER + b
        zg = menara_g(g_latih[idx])
        zt = menara_t(t_latih[idx])
        logits = zg @ zt.T / TEMP           # (8, 8)
        loss = (F.cross_entropy(logits, label_batch)
                + F.cross_entropy(logits.T, label_batch)) / 2
        opt.zero_grad()
        loss.backward()
        opt.step()
        total += loss.item()
    if epoch in (0, 124, 249):
        loss_jejak.append(total / N_LATIH_PER)
print("loss: epoch 0 {:.4f} -> epoch 125 {:.4f} -> epoch 250 {:.4f}"
      .format(*loss_jejak))


def retrieval(zg, zt):
    """Top-1 dua arah atas matriks kosinus (8x8)."""
    m = zg @ zt.T
    benar = torch.arange(8)
    ak_g = (m.argmax(dim=1) == benar).float().mean().item()
    ak_t = (m.argmax(dim=0) == benar).float().mean().item()
    return ak_g, ak_t, m


print()
print("=== 3. Retrieval pada data uji (10 ronde x 8 kandidat) ===")
gen_ronde = torch.Generator().manual_seed(9)
jumlah_g = jumlah_t = 0
N_RONDE = 10
with torch.no_grad():
    for r in range(N_RONDE):
        pilih = torch.randint(0, N_UJI_PER, (N_KONSEP,), generator=gen_ronde)
        idx = torch.arange(N_KONSEP) * N_UJI_PER + pilih
        zg = menara_g(g_uji[idx])
        zt = menara_t(t_uji[idx])
        ak_g, ak_t, _ = retrieval(zg, zt)
        jumlah_g += ak_g
        jumlah_t += ak_t
print(f"akurasi retrieval gambar->teks: {jumlah_g / N_RONDE:.3f}")
print(f"akurasi retrieval teks->gambar: {jumlah_t / N_RONDE:.3f}")

print()
print("=== 4. Klasifikasi zero-shot lewat prototipe teks ===")
with torch.no_grad():
    proto_emb = menara_t(proto_t)             # 8 "prompt" tanpa derau
    z_gambar_uji = menara_g(g_uji)
    skor = z_gambar_uji @ proto_emb.T         # (n, 8)
    pred = skor.argmax(dim=1)
ak_zero = (pred == k_uji).float().mean().item()
print(f"akurasi zero-shot ({g_uji.shape[0]} gambar uji): {ak_zero:.3f}")

with torch.no_grad():
    idx8 = torch.arange(N_KONSEP) * N_UJI_PER  # 1 sampel per konsep
    baris = (menara_g(g_uji[:1]) @ menara_t(t_uji[idx8]).T)[0]
print("kosinus 1 gambar vs 8 caption (tiap konsep):",
      " ".join(f"{c:.2f}" for c in baris.tolist()))
print("caption benar pada indeks 0 -- nilai tertinggi di indeks",
      int(baris.argmax()))

Output yang Dihasilkan:

text
=== 1. Data kontrastif ===
latih: 96 pasangan, uji: 24 pasangan, 8 konsep
dimensi mentah: gambar 10, teks 7 (berbeda -- encoder terpisah)

=== 2. Pelatihan InfoNCE simetris (batch 8 konsep berbeda) ===
loss: epoch 0 1.4686 -> epoch 125 0.0055 -> epoch 250 0.0051

=== 3. Retrieval pada data uji (10 ronde x 8 kandidat) ===
akurasi retrieval gambar->teks: 0.950
akurasi retrieval teks->gambar: 0.887

=== 4. Klasifikasi zero-shot lewat prototipe teks ===
akurasi zero-shot (24 gambar uji): 1.000
kosinus 1 gambar vs 8 caption (tiap konsep): 0.95 0.05 -0.22 -0.52 0.34 -0.34 -0.02 -0.01
caption benar pada indeks 0 -- nilai tertinggi di indeks 0

Mari kita telaah kode di atas baris per baris:

  1. Baris 24-26 (proto_g, proto_t): delapan prototipe acak menjadi “identitas” tiap konsep; karena prototipe gambar dan teks digambar dari generator yang sama urutannya, pasangan ke-i dijamin saling berkorespondensi — pasangan positif sejati, bukan sekadar label buatan.
  2. Baris 30-41 (buat_pasangan): sampel dilatih diberi derau 0,35 di sekitar prototipe; fungsi mengembalikan kelas konsep k untuk keperluan evaluasi zero-shot.
  3. Baris 52-68 (MenaraGambar, MenaraTeks): kedua menara hanyalah nn.Linear menuju dimensi 16 diikuti F.normalize — normalisasi membuat perkalian titik menjadi kosinus, syarat matriks kesamaan yang sehat.
  4. Baris 78-95 (loop pelatihan): indeks torch.arange(N_KONSEP) * N_LATIH_PER + b mengambil satu sampel dari tiap konsep sehingga setiap batch berisi 8 konsep berbeda — tanpa trik ini, dua sampel sekonsep di batch yang sama akan saling menjadi “negatif palsu”. Matriks logits dibagi TEMP, lalu dua cross_entropy (baris dan kolom) dirata-rata.
  5. Baris 98-104 (retrieval): argmax per baris menguji gambar menemukan caption; argmax per kolom menguji arah sebaliknya; jawaban benar adalah indeks diagonal.
  6. Baris 109-120: sepuluh ronde evaluasi menarik satu sampel acak per konsep sehingga komposisi kandidat berganti-ganti; akurasi dilaporkan sebagai rata-rata ronde.
  7. Baris 126-136: delapan prototipe teks tanpa derau berperan sebagai “prompt kelas”; klasifikasi zero-shot cukup memilih prompt dengan kosinus tertinggi terhadap gambar, dan tiga baris terakhir mencetak satu contoh baris kosinus terhadap satu sampel caption dari tiap konsep.

Hasilnya kontras langsung dengan chapter sebelumnya. Loss turun dari 1,4686 ke 0,0051 — penurunan yang sehat, bukan degenerasi, karena negatif mencegah semua vektor menumpuk. Retrieval gambar→teks mencapai 0,950 dan teks→gambar 0,887 pada data yang tidak pernah dilatih, dan baris kosinus pada bagian terakhir memperlihatkan margin yang jelas: caption benar bernilai 0,95 sementara pesaing terdekat hanya 0,34.

Klasifikasi Zero-shot #

Bagian keempat keluaran memperlihatkan kemampuan paling berharga dari ruang bersama kontrastif: klasifikasi tanpa satu pun contoh berlabel. Untuk mengklasifikasikan gambar ke delapan konsep, kita tidak melatih kepala klasifikasi baru — cukup sematkan delapan “prompt” teks lalu pilih yang paling mirip dengan gambar. Akurasi 1,000 pada 24 gambar uji menunjukkan mekanismenya bekerja; pada CLIP nyata, prompt seperti “sebuah foto seekor kucing” bersaing dengan ribuan kelas lain, dan kualitas kalimat prompt ikut menentukan hasilnya — praktik yang dikenal sebagai prompt engineering.

Ruang bersama kontrastif menjawab “gambar dan teks mana yang cocok”, tetapi masih belum bisa menjawab pertanyaan majemuk seperti “apa warna benda di kiri meja?”. Untuk itu kita perlu arsitektur yang benar-benar membaca isi gambar sambil menalar dengan bahasa — vision-language model yang menggabungkan encoder visual dengan LLM, dibahas pada chapter Vision-Language Model .