Chapter sebelumnya membangun sinyal supervisi dengan membelah satu vektor menjadi masukan dan target. Contrastive learning memakai pola yang lebih kaya: dua tampilan (view) dari objek yang sama dinyatakan sebagai pasangan positif, tampilan objek lain menjadi pasangan negatif, lalu encoder dilatih menarik positif saling mendekat dan mendorong negatif saling menjauh di ruang embedding. Keluarga metode inilah yang dipakai SimCLR dan MoCo untuk pretraining vision, serta CLIP yang menyandingkan gambar dengan teksnya. Pada chapter ini kita implementasikan loss-nya (InfoNCE), merasakan peran temperatur, lalu membuktikan lewat linear probe bahwa pretraining kontras menaikkan kualitas representation dibanding encoder acak.

Tarik Positif, Dorong Negatif #

Intuisinya sederhana: augmentasi acak dari satu titik (misalnya dirotasi atau diberi derau) tetap “titik yang sama”, sehingga kedua tampilan tersebut harus dipetakan berdekatan. Titik lain, apa pun augmentasinya, harus dipetakan berjauhan. Jika struktur data memuat klaster tersembunyi, tarikan lokal antar augmentasi akan merambat menjadi klaster global di ruang embedding — titik-titik satu klaster saling menarik lewat rantai augmentasi bertetangga.

Loss yang mengimplementasikan tarik-dorong tersebut bernama InfoNCE, dengan bentuk NT-Xent yang dipakai SimCLR:

text
L(i) = -log( exp(sim(z_i, z_j) / tau) / jumlah_k exp(sim(z_i, z_k) / tau) )

sim(z_i, z_j) = kosinus(z_i, z_j)   -> kemiripan arah dua embedding
tau           = temperatur skalar positif
j             = pasangan positif dari i, k = seluruh kandidat lain

Penyebut memuat semua kandidat dalam batch (positif dan seluruh negatif), sehingga meminimalkan loss berarti menaikkan peluang positif sekaligus menurunkan peluang negatif — softmax satu baris sekaligus menjadi tarik dan dorong. Parameter tau mengatur ketajaman: tau kecil membuat softmax tajam sehingga hanya negatif tersulit yang terdorong keras, tau besar membuat dorongan tersebar rata ke semua negatif. Praktik umum SimCLR memakai tau antara 0.1 dan 0.5.

Demo: Encoder Mini pada Data Tanpa Label #

Data kita berupa 600 titik 2D tanpa label: cakram dalam (radius 0–0.6) dan cincin luar (radius 1.6–2.0). Kedua keluarga titik ini tidak bisa dipisahkan garis lurus di ruang mentah. Pretraining memakai dua strategi augmentasi — hanya derau, versus rotasi acak plus derau — lalu kualitas representation diukur dengan linear probe:

python
"""Contrastive learning mini: tarik positif, dorong negatif (InfoNCE).

Data: 600 titik 2D tanpa label -- cakram dalam (radius 0-0.6) dan
cincin luar (radius 1.6-2.0). Dua keluarga titik ini TIDAK bisa
dipisahkan garis lurus di ruang mentah 2D.
Pretraining (tanpa label): dua augmentasi dari titik yang sama jadi
pasangan positif; titik lain dalam batch jadi negatif. Loss InfoNCE
(NT-Xent) menarik positif dekat dan mendorong negatif jauh pada ruang
embedding kosinus, dengan temperatur tau.
Dua strategi augmentasi dibandingkan: (A) derau kecil saja,
(B) rotasi sudut acak + derau kecil. Evaluasi: linear probe 16 label
(rata-rata 20 pengambilan sampel) memakai [sklearn LogisticRegression].
Diuji di CPU dengan PyTorch 2.14, seed tetap, deterministik.
"""

import math

import numpy as np

import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.linear_model import LogisticRegression

torch.manual_seed(3)

# ---------- 1. Data tanpa label: cakram dalam vs cincin luar ----------
def titik_cincin(r_lo, r_hi, n):
    r = r_lo + (r_hi - r_lo) * torch.rand(n)
    t = 2 * math.pi * torch.rand(n)
    return torch.stack([r * torch.cos(t), r * torch.sin(t)], dim=1)


N_PER = 300
X = torch.cat([titik_cincin(0.0, 0.6, N_PER), titik_cincin(1.6, 2.0, N_PER)])
y = torch.cat([torch.zeros(N_PER), torch.ones(N_PER)]).long()  # hanya untuk uji
acak = torch.randperm(X.shape[0])
X, y = X[acak], y[acak]
X_latih, y_latih = X[:500], y[:500]
X_uji, y_uji = X[500:], y[500:]
print(f"data tanpa label : {tuple(X_latih.shape)} latih + {tuple(X_uji.shape)} uji")
print(f"kelas (disimpan untuk evaluasi akhir, tidak untuk pretraining): 2")
print()


# ---------- 2. Intuisi temperatur pada softmax ----------
print("=== Intuisi temperatur (skor serupa tetap) ===")
skor = torch.tensor([0.9, 0.6, 0.2, -0.1])
for tau in (2.0, 0.5, 0.1):
    p = (skor / tau).softmax(0)
    print(f"  tau={tau:3.1f}  probabilitas = {[round(v, 3) for v in p.tolist()]}")
print("  tau kecil -> tajam (hanya negatif tersulit dihukum);")
print("  tau besar -> datar (semua negatif didorong rata).")
print()


# ---------- 3. Komponen: augmentasi, encoder, loss InfoNCE ----------
def rotasi(x, derajat_maks):
    """Putar tiap titik sebesar sudut acak dalam +-derajat_maks."""
    sudut = (2 * torch.rand(x.shape[0]) - 1) * derajat_maks * math.pi / 180
    c, s = torch.cos(sudut), torch.sin(sudut)
    return torch.stack([x[:, 0] * c - x[:, 1] * s,
                        x[:, 0] * s + x[:, 1] * c], dim=1)


def buat_encoder():
    """Encoder f: 2 -> 32 -> 16 (ReLU) yang akan diprobe."""
    return nn.Sequential(nn.Linear(2, 32), nn.ReLU(),
                         nn.Linear(32, 16), nn.ReLU())


def infonce(z, tau):
    """NT-Xent: logit positif vs seluruh 2B-2 negatif dalam batch."""
    B = z.shape[0]
    sim = z @ z.T / tau                    # kosinus / tau
    sim.fill_diagonal_(float("-inf"))      # abaikan pasangan dengan diri sendiri
    idx_pos = (torch.arange(B) + B // 2) % B   # pasangan dua-arah i <-> i+B
    return -sim.log_softmax(1)[torch.arange(B), idx_pos].mean()


def pretrain(mode, tau=0.5, epoch=500, B=256, lr=3e-3, seed=5):
    """SimCLR-mini: encoder f + kepala proyeksi g, tanpa label kelas."""
    torch.manual_seed(seed)
    f = buat_encoder()
    g = nn.Linear(16, 8)
    opt = torch.optim.Adam(list(f.parameters()) + list(g.parameters()), lr=lr)
    loss_awal = loss_akhir = None
    for ep in range(epoch):
        xb = X_latih[torch.randperm(X_latih.shape[0])[:B]]
        if mode == "derau":
            v1 = xb + 0.3 * torch.randn_like(xb)
            v2 = xb + 0.3 * torch.randn_like(xb)
        else:  # "rotasi": sudut acak +-180 derajat + derau kecil
            v1 = rotasi(xb, 180) + 0.1 * torch.randn_like(xb)
            v2 = rotasi(xb, 180) + 0.1 * torch.randn_like(xb)
        z = F.normalize(torch.cat([g(f(v1)), g(f(v2))], 0), dim=1)
        loss = infonce(z, tau)
        opt.zero_grad(); loss.backward(); opt.step()
        if ep == 0:
            loss_awal = loss.item()
        loss_akhir = loss.item()
    return f, loss_awal, loss_akhir


# ---------- 4. Pretraining dua strategi augmentasi ----------
print("=== Pretraining kontras (2 x 500 epoch, tanpa label) ===")
f_der, l0d, l1d = pretrain("derau")
print(f"  augmentasi derau   : loss {l0d:.3f} -> {l1d:.3f}")
f_rot, l0r, l1r = pretrain("rotasi")
print(f"  augmentasi rotasi  : loss {l0r:.3f} -> {l1r:.3f}")
print()


# ---------- 5. Linear probe: 8 label, rata-rata 10 sampel ----------
def probe(fitur_latih, fitur_uji, n_label=16, ambil=20):
    total = 0.0
    for s in range(ambil):
        rng = torch.Generator().manual_seed(100 + s)
        idx = torch.randperm(fitur_latih.shape[0], generator=rng)[:n_label]
        clf = LogisticRegression(max_iter=1000)
        clf.fit(fitur_latih[idx].numpy(), y_latih[idx].numpy())
        total += clf.score(fitur_uji.numpy(), y_uji.numpy())
    return total / ambil


print("=== Linear probe 16 label (rata-rata 20 pengambilan) ===")
with torch.no_grad():
    torch.manual_seed(11)
    f_acak = buat_encoder()
    baris = [
        ("fitur mentah 2D", X_latih, X_uji),
        ("encoder acak (tanpa pretrain)", f_acak(X_latih), f_acak(X_uji)),
        ("encoder pretrain augmentasi derau", f_der(X_latih), f_der(X_uji)),
        ("encoder pretrain augmentasi rotasi", f_rot(X_latih), f_rot(X_uji)),
    ]
    for nama, fl, fu in baris:
        print(f"  {nama:36s} -> akurasi {probe(fl, fu):.3f}")
print()


# ---------- 6. Efek tarik-dorong pada ruang embedding ----------
print("=== Geometri embedding encoder rotasi ===")
with torch.no_grad():
    z = F.normalize(f_rot(X_uji), dim=1).numpy()
    sama = y_uji.numpy()[:, None] == y_uji.numpy()
    d = z @ z.T
    dalam = d[sama & ~np.eye(len(z), dtype=bool)]
    print(f"  kosinus pasangan satu kelas (tarik) : {dalam.mean():.3f}")
    print(f"  kosinus pasangan beda kelas (dorong): {d[~sama].mean():.3f}")
    z_rot = F.normalize(f_rot(rotasi(X_uji, 90)), dim=1)
    z_asli = F.normalize(f_rot(X_uji), dim=1)
    inv = (z_rot * z_asli).sum(1).mean().item()
    print(f"  invariansi rotasi 90 derajat       : {inv:.3f}")

Output yang Dihasilkan:

text
data tanpa label : (500, 2) latih + (100, 2) uji
kelas (disimpan untuk evaluasi akhir, tidak untuk pretraining): 2

=== Intuisi temperatur (skor serupa tetap) ===
  tau=2.0  probabilitas = [0.315, 0.271, 0.222, 0.191]
  tau=0.5  probabilitas = [0.518, 0.284, 0.128, 0.07]
  tau=0.1  probabilitas = [0.952, 0.047, 0.001, 0.0]
  tau kecil -> tajam (hanya negatif tersulit dihukum);
  tau besar -> datar (semua negatif didorong rata).

=== Pretraining kontras (2 x 500 epoch, tanpa label) ===
  augmentasi derau   : loss 6.036 -> 5.157
  augmentasi rotasi  : loss 6.174 -> 5.362

=== Linear probe 16 label (rata-rata 20 pengambilan) ===
  fitur mentah 2D                      -> akurasi 0.540
  encoder acak (tanpa pretrain)        -> akurasi 0.613
  encoder pretrain augmentasi derau    -> akurasi 0.919
  encoder pretrain augmentasi rotasi   -> akurasi 0.998

=== Geometri embedding encoder rotasi ===
  kosinus pasangan satu kelas (tarik) : 0.818
  kosinus pasangan beda kelas (dorong): 0.536
  invariansi rotasi 90 derajat       : 0.940

Mari kita telaah kode di atas baris per baris:

  1. Baris 28-40 (data): titik_cincin membuat titik berjari-jali acak, lalu dua keluarga digabung. Label y disimpan semata-mata untuk evaluasi akhir — tidak pernah disentuh saat pretraining.
  2. Baris 47-54 (temperatur): vektor skor tetap dibagi tau berbeda-beda; dari tau=2.0 ke tau=0.1 probabilitas berubah dari nyaris merata menjadi terpusat (0.952) pada skor tertinggi. Inilah satu-satunya peran tau di InfoNCE.
  3. Baris 72-78 (infonce): matriks sim berisi kosinus antar seluruh pasangan dalam batch gabungan 2B; diagonal dinolkan (diri sendiri bukan kandidat), lalu log_softmax per baris dan ambil kolom pasangan positif (i ↔ i+B, dua arah).
  4. Baris 81-102 (pretrain): tiap epoch mengambil batch 256 titik, membuat dua augmentasi, menormalkan embedding lewat F.normalize, dan melatih encoder f plus kepala proyeksi g. Kepala g dibuang setelah pretraining — standar SimCLR.
  5. Baris 115-123 (probe): LogisticRegression dilatih pada 16 label acak di atas fitur beku, diulang 20 kali, lalu dirata-rata. Probe seperti inilah alat ukur standar kualitas representation (kita dalami di chapter Representation Learning).
  6. Baris 141-153 (geometri): rata-rata kosinus satu kelas versus beda kelas mengukur seberapa berhasil tarik-dorong; baris terakhir menguji invariansi — embedding sebelum dan sesudah data uji dirotasi 90 derajat masih sejajar (0.940).

Augmentasi Menentukan Apa yang Dipelajari #

Hasil probe membentuk tangga yang informatif: fitur mentah 0.540 (cakram vs cincin memang tak bisa dipisah garis lurus), encoder acak 0.613, pretrain derau 0.919, pretrain rotasi 0.998. Augmentasi derau hanya memberi informasi lokal sehingga hasilnya bagus tetapi tidak sempurna; augmentasi rotasi mengajarkan konsep yang lebih dalam — “identitas titik tidak berubah saat dirotasi” — yang untungnya persis memisahkan struktur radial cakram-cincin. Pelajarannya: pretext task menentukan invariansi yang dipelajari encoder, dan invariansi yang salah bikin representation tidak terpakai untuk downstream.

Rumus InfoNCE hidup atau tidaknya ditentukan kualitas pasangan positif-negatif yang kita sediakan. Bagaimana membangun pasangan itu, mengapa negatif dalam-batch begitu praktis, dan apa risiko negatif yang keliru — semuanya kita bedah di chapter Positive dan Negative Pairs.