Topik Vision Transformer sengaja dibahas dari dua arah di Course ini. Chapter Vision Transformer pada section Computer Vision menyelesaikan sisi praktik patchify: memotong citra digits menjadi token dengan NumPy, angka per angka, namun berhenti sebelum encoder — bobot proyeksinya masih acak dan tidak ada satu pun parameter yang dilatih. Chapter ini menutup setengah yang tersisa dari sudut arsitektur: kita merakit ViT utuh dalam PyTorch, melatihnya pada 1.400 citra digits sampai akurasi uji yang nyata, lalu membaca kembali bobot attention token [CLS] untuk melihat patch mana yang jadi sorotan model. Seluruh blok bangunannya berasal dari chapter-chapter sebelumnya — tidak ada komponen baru selain cara menyusunnya.

Dari Piksel ke Urutan Token #

ViT memperlakukan gambar seperti kalimat. Citra H x W dipotong grid patch P x P (makalah asli memakai P = 16 pada gambar 224x224, menghasilkan 196 token), tiap patch diratakan menjadi vektor P x P piksel, lalu satu proyeksi linear nn.Linear(P*P, d_model) memetakan tiap vektor ke dimensi model. Dua pelengkap menyusul: token [CLS] — satu parameter terlatih yang diletakkan di depan urutan dan keluarannya dibaca classifier head — serta positional embedding. Berbeda dengan sinus chapter Positional Encoding , ViT memakai positional embedding yang ikut dilatih dari awal, satu vektor per posisi token.

Diagram di atas adalah keseluruhan arsitektur program kita: tanpa convolution dan tanpa pooling, hanya urutan 17 token (1 [CLS] + 16 patch) yang melewati satu blok encoder — blok pre-LN yang sama dengan chapter Transformer Encoder . Mekanika patchify-nya sendiri (loop baris demi baris, isi tiap patch sebagai blok piksel) sudah dibedah tuntas di chapter Computer Vision; implementasi tensor di sini hanya versi vektornya.

Program: ViT Mainan yang Dilatih Nyata #

Program berikut melatih ViTMini pada dataset digits scikit-learn: 1.797 citra 8x8 grayscale, 10 kelas digit. Konfigurasi sengaja kecil — patch 2x2 menghasilkan 16 token, d_model = 16, dua head, satu blok encoder, FFN sempit — namun seluruh jalurnya nyata: loss turun, akurasi naik, dan sorotan [CLS] terbaca setelah pelatihan. Dengan seed tetap, seluruh angka pada output dapat direproduksi:

python
"""Vision Transformer mainan: digits 8x8, patch 2x2, satu blok encoder.

Alur: citra 8x8 -> 16 patch 2x2 (grid 4x4) -> proyeksi linear 4->16 ->
token [CLS] + positional embedding terlatih -> 1 blok encoder ->
klasifikasi dari token [CLS]. Dilatih dengan Adam pada 1400 citra
digits; akurasi latih dan uji dilaporkan. Bobot attention token [CLS]
atas 16 patch dicetak untuk satu contoh uji. PyTorch 2.14, CPU.
"""

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import load_digits

torch.manual_seed(0)
np.set_printoptions(precision=3, suppress=True)


class MultiHeadAttention(nn.Module):
    """Multi-head attention manual dari chapter multi-head-attention."""

    def __init__(self, d_model, num_heads):
        super().__init__()
        assert d_model % num_heads == 0
        self.h = num_heads
        self.d_k = d_model // num_heads
        self.wq = nn.Linear(d_model, d_model)
        self.wk = nn.Linear(d_model, d_model)
        self.wv = nn.Linear(d_model, d_model)
        self.wo = nn.Linear(d_model, d_model)

    def forward(self, x):
        n, panjang, d = x.shape
        q = self.wq(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
        k = self.wk(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
        v = self.wv(x).view(n, panjang, self.h, self.d_k).transpose(1, 2)
        skor = q @ k.transpose(-2, -1) / self.d_k ** 0.5
        a = skor.softmax(dim=-1)
        z = (a @ v).transpose(1, 2).contiguous().view(n, panjang, d)
        return self.wo(z), a


class FeedForward(nn.Module):
    """Position-wise feed-forward: Linear -> GELU -> Linear."""

    def __init__(self, d_model, d_ff):
        super().__init__()
        self.l1 = nn.Linear(d_model, d_ff)
        self.l2 = nn.Linear(d_ff, d_model)

    def forward(self, x):
        return self.l2(F.gelu(self.l1(x)))


class EncoderBlock(nn.Module):
    """Blok encoder pre-LN dari chapter transformer-encoder.

    Keluaran blok dikembalikan bersama bobot attention agar token
    [CLS] bisa diperiksa setelah pelatihan.
    """

    def __init__(self, d_model, num_heads, d_ff):
        super().__init__()
        self.attn = MultiHeadAttention(d_model, num_heads)
        self.ffn = FeedForward(d_model, d_ff)
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)

    def forward(self, x):
        n = self.ln1(x)
        h, bobot = self.attn(n)
        h = x + h
        h = h + self.ffn(self.ln2(h))
        return h, bobot


class ViTMini(nn.Module):
    """Vision Transformer mini: patch 2x2, d_model 16, 1 blok encoder."""

    def __init__(self, ukuran_patch, d_model, num_heads, d_ff, n_kelas, n_patch):
        super().__init__()
        self.p = ukuran_patch
        self.proj = nn.Linear(ukuran_patch ** 2, d_model)  # patch -> token
        self.cls = nn.Parameter(torch.randn(1, 1, d_model) * 0.02)
        self.pos = nn.Parameter(torch.randn(1, n_patch + 1, d_model) * 0.02)
        self.blok = EncoderBlock(d_model, num_heads, d_ff)
        self.ln_akhir = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, n_kelas)

    def patchify(self, imgs):
        """(B, 8, 8) -> (B, 16, 4): grid patch 2x2 baris demi baris."""
        b, h, w = imgs.shape
        p = self.p
        x = imgs.view(b, h // p, p, w // p, p)
        x = x.permute(0, 1, 3, 2, 4).contiguous()
        return x.view(b, -1, p * p)

    def forward(self, imgs):
        tok = self.proj(self.patchify(imgs))          # (B, 16, D)
        b = tok.shape[0]
        z = torch.cat([self.cls.expand(b, -1, -1), tok], dim=1) + self.pos
        z, bobot = self.blok(z)
        cls_out = self.ln_akhir(z[:, 0])              # token [CLS]
        return self.head(cls_out), bobot


# ---------- data: digits 8x8, 1797 citra, 10 kelas ----------
digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32).view(-1, 8, 8)
y = torch.tensor(digits.target, dtype=torch.long)

g = torch.Generator().manual_seed(0)
urutan_acak = torch.randperm(len(X), generator=g)
n_latih = 1400
X_latih, y_latih = X[urutan_acak[:n_latih]], y[urutan_acak[:n_latih]]
X_uji, y_uji = X[urutan_acak[n_latih:]], y[urutan_acak[n_latih:]]

model = ViTMini(ukuran_patch=2, d_model=16, num_heads=2, d_ff=32,
                n_kelas=10, n_patch=16)

print("== Vision Transformer mainan pada digits ==")
print(f"data: {len(X)} citra 8x8, 10 kelas "
      f"({n_latih} latih / {len(X_uji)} uji)")
print()
print("== Alur satu citra ==")
contoh = X_latih[0:1]
with torch.no_grad():
    patch = model.patchify(contoh)
    tok = model.proj(patch)
    z = torch.cat([model.cls, tok], dim=1) + model.pos
    logits, _ = model(contoh)
print(f"gambar            : {tuple(contoh.shape)} (kelas {y_latih[0].item()})")
print(f"patch 2x2         : {tuple(patch.shape)}  (16 token, tiap 4 piksel)")
print(f"proyeksi linear   : {tuple(tok.shape)} (d_model 16)")
print(f"+ [CLS] + PE      : {tuple(z.shape)} (1 + 16 token)")
print(f"logits dari [CLS] : {tuple(logits.shape)}")
print()

print("== Pelatihan: Adam lr 1e-3, batch 64, 100 epoch ==")
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
BATCH = 64


@torch.no_grad()
def akurasi(model, X, y):
    pred = model(X)[0].argmax(dim=1)
    return (pred == y).float().mean().item()


for epoch in range(100):
    model.train()
    g2 = torch.Generator().manual_seed(epoch)   # urutan batch deterministik
    urut = torch.randperm(n_latih, generator=g2)
    for i in range(0, n_latih, BATCH):
        j = urut[i:i + BATCH]
        logits, _ = model(X_latih[j])
        loss = F.cross_entropy(logits, y_latih[j])
        opt.zero_grad()
        loss.backward()
        opt.step()
    if epoch % 25 == 0 or epoch == 99:
        model.eval()
        print(f"  epoch {epoch:2d} : loss batch akhir {loss.item():.4f}   "
              f"akurasi latih {akurasi(model, X_latih, y_latih):.3f}   "
              f"akurasi uji {akurasi(model, X_uji, y_uji):.3f}")
print()

model.eval()
print("== Bobot attention token [CLS] atas 16 patch ==")
with torch.no_grad():
    logits, bobot = model(X_uji[:1])
peringkat = bobot[0].mean(dim=0)[0, 1:]       # [CLS] -> 16 patch, rata-rata head
print(f"contoh uji: kelas asli {y_uji[0].item()}, "
      f"prediksi {int(logits[0].argmax())}")
print("grid 4x4 (baris/kolom = posisi patch pada citra):")
print(np.round(peringkat.reshape(4, 4).numpy(), 3))
top = int(peringkat.argmax())
print(f"patch paling diperhatikan: {top} "
      f"(baris {top // 4}, kolom {top % 4} pada grid)")

Output yang Dihasilkan:

text
== Vision Transformer mainan pada digits ==
data: 1797 citra 8x8, 10 kelas (1400 latih / 397 uji)

== Alur satu citra ==
gambar            : (1, 8, 8) (kelas 6)
patch 2x2         : (1, 16, 4)  (16 token, tiap 4 piksel)
proyeksi linear   : (1, 16, 16) (d_model 16)
+ [CLS] + PE      : (1, 17, 16) (1 + 16 token)
logits dari [CLS] : (1, 10)

== Pelatihan: Adam lr 1e-3, batch 64, 100 epoch ==
  epoch  0 : loss batch akhir 2.2815   akurasi latih 0.124   akurasi uji 0.096
  epoch 25 : loss batch akhir 0.2326   akurasi latih 0.938   akurasi uji 0.899
  epoch 50 : loss batch akhir 0.1234   akurasi latih 0.971   akurasi uji 0.922
  epoch 75 : loss batch akhir 0.0478   akurasi latih 0.983   akurasi uji 0.927
  epoch 99 : loss batch akhir 0.0143   akurasi latih 0.991   akurasi uji 0.932

== Bobot attention token [CLS] atas 16 patch ==
contoh uji: kelas asli 0, prediksi 0
grid 4x4 (baris/kolom = posisi patch pada citra):
[[0.061 0.016 0.059 0.04 ]
 [0.053 0.129 0.111 0.022]
 [0.029 0.154 0.066 0.068]
 [0.04  0.045 0.035 0.064]]
patch paling diperhatikan: 9 (baris 2, kolom 1 pada grid)

Mari kita telaah kode di atas baris per baris:

  1. Baris 20-41: class MultiHeadAttention — salinan persis dari chapter Multi-Head Attention : empat proyeksi, pemecahan head, skor terkalakan sqrt(d_k), softmax per baris, perangkaian kembali.
  2. Baris 44-53: class FeedForward — dua layer linear dengan GELU, memroses tiap token secara mandiri.
  3. Baris 56-75: class EncoderBlock — blok pre-LN dari chapter Transformer Encoder dengan satu perubahan: forward juga mengembalikan bobot attention agar sorotan [CLS] dapat diperiksa setelah pelatihan.
  4. Baris 78-89: konstruktor ViTMini. Baris 84: nn.Linear(4, 16) adalah proyeksi patch-ke-token; baris 85-86: cls dan pos adalah nn.Parameter terlatih — inilah perbedaan dengan PE sinus, posisi dihitung sebagai vektor yang dipelajari, bukan rumus tetap; baris 88-89: layer norm akhir dan classifier head 16 -> 10.
  5. Baris 91-97: patchify versi tensor. view(b, 4, 2, 4, 2) membelah citra menjadi grid 4x4 patch 2x2; permute menyusun ulang sehingga dimensi patch berdampingan; view(b, -1, 4) meratakan tiap patch menjadi vektor 4 piksel — hasil yang sama dengan loop manual di chapter Computer Vision.
  6. Baris 99-105: forward merangkai alur lengkap: proyeksi patch, penambahan [CLS] lewat torch.cat, penjumlahan positional embedding, satu blok encoder, lalu z[:, 0] mengambil token [CLS] sebagai satu-satunya masukan head.
  7. Baris 109-117: data digits dimuat dari sklearn.datasets, diskalakan / 16.0 ke rentang 0-1, dibentuk kembali (1797, 8, 8), lalu dipecah deterministik 1400 latih / 397 uji lewat randperm berseed.
  8. Baris 127-138: alur satu citra dicetak tahap demi tahap — (1, 8, 8) menjadi 16 token berdimensi 4, menjadi (1, 17, 16) setelah [CLS] dan PE, dan keluar sebagai 10 logits.
  9. Baris 141-166: pelatihan. Adam dengan learning rate 1e-3, batch 64; torch.Generator().manual_seed(epoch) membuat urutan batch berbeda tiap epoch namun tetap dapat direproduksi; F.cross_entropy menghitung loss; laporan dicetak tiap 25 epoch.
  10. Baris 169-180: pemeriksaan [CLS]. Baris 173 mengambil baris bobot attention milik token [CLS] (indeks 0) lalu membuang kolom [CLS] itu sendiri ([0, 1:]), rata-rata dua head; hasil diratakan menjadi grid 4x4 sesuai posisi patch pada citra.

Membaca Angka: Akurasi dan Sorotan [CLS] #

Akurasi uji mencapai 0,932 dari 0,096 di epoch nol — bukti bahwa arsitektur berbasis attention sungguh mempelajari isi citra, bukan sekadar menebak kelas mayoritas. Sorotan [CLS] pada contoh uji (kelas 0, diprediksi benar) memuncak di patch 9 dengan bobot 0,154 — posisi baris 2 kolom 1 di kawasan tengah-kiri citra, tempat goresan digit 0 melintas — sementara bobot terkecil 0,016 jatuh pada patch baris 0 kolom 1 di tepi atas. Model tidak pernah diberi tahu bahwa digit 0 berbentuk lingkaran; pola sorotan tersebut murni hasil pelatihan, dibaca dengan cara yang sama seperti kita membaca cross-attention di chapter Encoder-Decoder Transformer .

CNN lawan ViT #

AspekCNNVision Transformer
Bias inductivekuat: lokal, translasi-invarian, receptive field bertingkatnyaris nol: posisi lewat embedding, relasi lewat attention
Hubungan fitur jauhbaru terjangkau setelah tumpukan layerglobal sejak blok pertama
Kebutuhan dataefisien pada dataset sedangunggul setelah pra-pelatihan besar
Biaya pada token/piksel Nlinear O(N) per layer konvolusikuadrat O(N^2) pada panjang urutan
Komponenconvolution + pooling, bervariasihanya attention + MLP, seragam dan mudah diskalakan

Tabel tersebut menjelaskan dua wajah ViT sekaligus: tanpa bias spasial, model harus belajar pola lokal dari data sehingga datanya harus besar; tetapi komponen yang seragam membuat penambahan blok dan dimensi menjadi lugas — sifat yang menopang era model berskala raksasa. Dataset digits yang dipakai program kita terdokumentasi di sklearn.datasets.load_digits ; seluruh pelatihan di atas selesai dalam sekitar 15 detik pada CPU dengan PyTorch 2.14.

Encoder, decoder, mask, hingga penerapan pada citra — kita kini telah membangun seluruh keluarga Transformer dari komponen terkecil. Bab selanjutnya menarik kamera ke belakang: bagaimana keturunan arsitektur ini berkembang menjadi BERT, GPT, T5, dan berbagai varian efisiensi modern yang menopang model masa kini.