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 kemiripan | Rumus | Rentang | Catatan |
|---|---|---|---|
| Dot product | a · b = Σ aᵢbᵢ | tidak terbatas | sensitif terhadap panjang vektor |
| Cosine similarity | (a · b) / (‖a‖ ‖b‖) | -1 sampai 1 | hanya 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.
"""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:
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:
- 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. - Baris 21-39 (
class CNN): backbone (Conv2d→ ReLU →MaxPool2d→Conv2d→ ReLU →Flatten) mereduksi tiap gambar 8x8 menjadi vektor 256 nilai;self.embeddingmemetakan 256 → 32, danself.head32 → 10. Argumenambil_embeddingpadaforwardmembuat satu panggilan mengembalikan logits dan vektor 32 dimensi sekaligus. - Baris 42-44: Adam dengan learning rate
1e-3dipilih karena konvergensi cepat pada model kecil;CrossEntropyLossmelatih head, bukan embedding secara langsung — embedding terbentuk sebagai efek samping tugas klasifikasi. - 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.
- Baris 64-65: satu forward pass dengan
ambil_embedding=Truemenghasilkan tensor(1797, 32)— seluruh dataset menjadi 1797 vektor. - Baris 68-69:
torch.nn.functional.normalizemembagi tiap vektor dengan panjangnya, sehingganorm @ norm.Tlangsung menghitung seluruh matriks cosine similarity(1797, 1797)dalam satu perkalian matriks. - 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.
- Baris 82-91: untuk tiap query, barisnya di matriks kemiripan dikloning lalu skornya sendiri disetel
-1.0agar tidak memilih diri sendiri;torch.topkmengembalikan 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
.