Model klasifikasi menjawab dengan satu label dari daftar yang sudah ditentukan — sistem itu tidak bisa menjawab “gambar mana yang paling mirip dengan ini?”. Image embedding mengubah posisi masalah: gambar dipetakan menjadi vektor fitur berdimensi tetap, dan kemiripan dua gambar dihitung sebagai kemiripan dua vektor. Dari pemetaan ini lahir image retrieval (pencarian gambar berdasar konten), deduplikasi, dan clustering koleksi foto. Pada chapter ini kita melatih CNN kecil pada dataset digits, membuang head klasifikasinya, mengambil aktivasi layer kedua terakhir sebagai embedding, lalu membangun mesin pencari mini dengan cosine similarity — semuanya berjalan nyata di CPU dengan PyTorch 2.14.

Gambar Menjadi Vektor #

Sebuah CNN terlatih pada dasarnya adalah dua bagian: backbone yang mengekstraksi fitur (deretan convolution dan pooling dari chapter Feature Map ) dan classifier head yang memetakan fitur ke kelas. Ambil keluaran bagian sebelum head — layer kedua terakhir — dan kita mendapatkan embedding: vektor yang merangkum isi gambar tanpa memaksanya jatuh ke salah satu kelas. Gambar yang isinya mirip menghasilkan vektor yang arahnya mirip, sehingga jarak atau sudut antar vektor menjadi ukuran kemiripan yang bisa dihitung untuk jutaan gambar sekali jalan.

Ukuran kemiripanRumusRentangCatatan
Dot producta · b = Σ aᵢbᵢtidak terbatassensitif terhadap panjang vektor
Cosine similarity(a · b) / (‖a‖ ‖b‖)-1 sampai 1hanya arah, tidak panjang
Euclidean distance‖a - b‖0 sampai ∞makin kecil makin mirip

Kemiripan Cosine #

Cosine similarity membagi dot product dengan hasil kali panjang kedua vektor — secara geometri, kosinus sudut di antara keduanya. Nilai 1 berarti arah persis sama, 0 berarti tegak lurus (tidak berhubungan), dan -1 berarti berlawanan. Untuk embedding hasil aktivasi ReLU yang selalu bernilai non-negatif, rentang praktisnya terletak di 0 sampai 1; angka absolutnya memang tinggi, tetapi yang dibaca adalah urutan relatifnya — kandidat dengan skor tertinggi adalah tetangga terdekat di ruang fitur.

Eksperimen: Melatih Backbone, Mencari Tetangga #

Berkas image_retrieval.py melatih CNN kecil (dua blok convolution + satu layer embedding 32 dimensi + head 10 kelas) pada seluruh 1797 citra digits dengan full-batch Adam — deterministik tanpa DataLoader. Setelah pelatihan, embedding seluruh dataset diekstraksi, matriks cosine similarity dihitung sekali dengan perkalian matriks, lalu lima gambar query mencari tiga tetangga terdekatnya.

python
"""Image embedding: backbone CNN -> vektor fitur -> pencarian gambar mirip.

Alur: latih CNN kecil pada digits (CPU, full-batch) -> ambil aktivasi
layer kedua terakhir sebagai embedding 32 dimensi -> kemiripan cosine
-> 5 gambar query -> top-3 gambar paling mirip.
"""

import torch
import torch.nn as nn
from sklearn.datasets import load_digits

torch.manual_seed(42)

digits = load_digits()
X = torch.tensor(digits.images, dtype=torch.float32).reshape(-1, 1, 8, 8)
y = torch.tensor(digits.target)
print(f"dataset : {tuple(X.shape)} (N, C, H, W), 10 kelas digit 0-9")


# ---------- 1. Model: backbone + embedding + classifier head ----------
class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Conv2d(1, 8, kernel_size=3, padding=1),  # -> (8, 8, 8)
            nn.ReLU(),
            nn.MaxPool2d(2),                            # -> (8, 4, 4)
            nn.Conv2d(8, 16, kernel_size=3, padding=1),  # -> (16, 4, 4)
            nn.ReLU(),
            nn.Flatten(),                               # -> 256
        )
        self.embedding = nn.Linear(256, 32)             # layer kedua terakhir
        self.head = nn.Linear(32, 10)                   # classifier head

    def forward(self, x, ambil_embedding=False):
        z = torch.relu(self.embedding(self.backbone(x)))
        if ambil_embedding:
            return self.head(z), z
        return self.head(z)


model = CNN()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

# ---------- 2. Latih full-batch (deterministik, tanpa DataLoader) ----------
for epoch in range(60):
    logits = model(X)
    loss = loss_fn(logits, y)
    opt.zero_grad()
    loss.backward()
    opt.step()
    if epoch in (0, 29, 59):
        benar = (logits.argmax(1) == y).float().mean().item()
        print(f"epoch {epoch:2d}: loss {loss.item():.4f}, "
              f"akurasi {benar:.3f}")

with torch.no_grad():
    benar = (model(X).argmax(1) == y).float().mean().item()
print(f"akurasi akhir pada seluruh data: {benar:.3f}")
print()

# ---------- 3. Ambil embedding 32 dimensi ----------
with torch.no_grad():
    _, fitur = model(X, ambil_embedding=True)
print(f"embedding: {tuple(fitur.shape)} -> tiap gambar jadi vektor 32 nilai")

norm = torch.nn.functional.normalize(fitur, dim=1)
kemiripan = norm @ norm.T  # matriks cosine similarity (1797 x 1797)
print(f"matriks kemiripan: {tuple(kemiripan.shape)}")

# Kontras: pasangan label sama vs label berbeda (diagonal dilewati)
sama_label = y.unsqueeze(0) == y.unsqueeze(1)
off = ~torch.eye(len(y), dtype=torch.bool)
rata_sama = kemiripan[sama_label & off].mean().item()
rata_beda = kemiripan[~sama_label & off].mean().item()
print(f"rerata kemiripan pasangan digit sama  : {rata_sama:.3f}")
print(f"rerata kemiripan pasangan digit beda  : {rata_beda:.3f}")
print()

# ---------- 4. Pencarian: 5 query -> top-3 paling mirip ----------
indeks_query = [0, 4, 7, 101, 345]
print("image retrieval (kemiripan cosine, query dikecualikan):")
for q in indeks_query:
    skor = kemiripan[q].clone()
    skor[q] = -1.0  # jangan pilih gambar itu sendiri
    top3 = torch.topk(skor, 3)
    baris = [f"query #{q} (digit {int(y[q])})"]
    for nilai, i in zip(top3.values, top3.indices):
        sama = "sama" if int(y[i]) == int(y[q]) else "BEDA"
        baris.append(f"#{int(i)} digit {int(y[i])} sim {nilai:.3f} [{sama}]")
    print("  " + " <- ".join(baris))

Output yang Dihasilkan:

text
dataset : (1797, 1, 8, 8) (N, C, H, W), 10 kelas digit 0-9
epoch  0: loss 2.3375, akurasi 0.098
epoch 29: loss 1.0232, akurasi 0.858
epoch 59: loss 0.2042, akurasi 0.946
akurasi akhir pada seluruh data: 0.952

embedding: (1797, 32) -> tiap gambar jadi vektor 32 nilai
matriks kemiripan: (1797, 1797)
rerata kemiripan pasangan digit sama  : 0.963
rerata kemiripan pasangan digit beda  : 0.862

image retrieval (kemiripan cosine, query dikecualikan):
  query #0 (digit 0) <- #855 digit 0 sim 0.997 [sama] <- #564 digit 0 sim 0.997 [sama] <- #642 digit 0 sim 0.996 [sama]
  query #4 (digit 4) <- #1788 digit 4 sim 0.996 [sama] <- #297 digit 4 sim 0.995 [sama] <- #64 digit 4 sim 0.995 [sama]
  query #7 (digit 7) <- #663 digit 7 sim 0.996 [sama] <- #862 digit 7 sim 0.995 [sama] <- #1164 digit 7 sim 0.995 [sama]
  query #101 (digit 0) <- #1598 digit 0 sim 0.996 [sama] <- #72 digit 0 sim 0.996 [sama] <- #1642 digit 0 sim 0.996 [sama]
  query #345 (digit 3) <- #709 digit 3 sim 0.998 [sama] <- #789 digit 3 sim 0.998 [sama] <- #175 digit 3 sim 0.998 [sama]

Mari kita telaah kode di atas baris per baris:

  1. Baris 12 (torch.manual_seed): inisialisasi bobot dan urutan angka acak dikunci; pelatihan full-batch tanpa DataLoader membuat seluruh keluaran dapat direproduksi persis di CPU.
  2. Baris 21-39 (class CNN): backbone (Conv2d → ReLU → MaxPool2d → Conv2d → ReLU → Flatten) mereduksi tiap gambar 8x8 menjadi vektor 256 nilai; self.embedding memetakan 256 → 32, dan self.head 32 → 10. Argumen ambil_embedding pada forward membuat satu panggilan mengembalikan logits dan vektor 32 dimensi sekaligus.
  3. Baris 42-44: Adam dengan learning rate 1e-3 dipilih karena konvergensi cepat pada model kecil; CrossEntropyLoss melatih head, bukan embedding secara langsung — embedding terbentuk sebagai efek samping tugas klasifikasi.
  4. Baris 47-55: 60 epoch full-batch menaikkan akurasi dari 0.098 ke 0.946. Pelatihan pada seluruh data tanpa pembagian train/test disengaja: tujuan kita adalah kualitas ruang fitur, bukan laporan generalisasi.
  5. Baris 64-65: satu forward pass dengan ambil_embedding=True menghasilkan tensor (1797, 32) — seluruh dataset menjadi 1797 vektor.
  6. Baris 68-69: torch.nn.functional.normalize membagi tiap vektor dengan panjangnya, sehingga norm @ norm.T langsung menghitung seluruh matriks cosine similarity (1797, 1797) dalam satu perkalian matriks.
  7. Baris 73-76: kontras kualitas ruang fitur terukur: rerata kemiripan pasangan digit sama 0.963 lawan digit berbeda 0.862 — jarak kelas terbentuk tanpa pernah melatih metrik kemiripan secara eksplisit.
  8. Baris 82-91: untuk tiap query, barisnya di matriks kemiripan dikloning lalu skornya sendiri disetel -1.0 agar tidak memilih diri sendiri; torch.topk mengembalikan tiga indeks tertinggi. Kelima query menghasilkan top-3 yang semuanya digit sama dengan skor sekitar 0.995 — angka 0 dan 4 di antara query pun berhasil menemukan tetangga sekelasnya.

Ruang fitur ini juga fondasi bagi tugas visual yang lebih spesifik. Chapter berikutnya, Face dan Landmark Detection , menyentuh salah satu penerapan paling sensitif terhadap privasi: mendeteksi wajah dan titik-titik kuncinya. Rujukan API lengkap tersedia di dokumentasi torch.nn.functional.normalize dan sklearn.metrics.pairwise.cosine_similarity .