Masked language modeling menyembunyikan token dan menebaknya dari konteks dua arah. Masked image modeling (MIM) melakukan hal yang sama pada gambar: citra dibelah menjadi patch kotak, sebagian besar patch disembunyikan, dan model diminta merekonstruksi piksel yang hilang. Varian paling terkenal, Masked Autoencoder (MAE), menyembunyikan 75% patch — rasio yang terdengar ekstrem sampai kita sadar piksel tetangga sangat redundan, sehingga sedikit patch terlihat pun sudah cukup memberi konteks. Chapter ini membangun MAE mini pada dataset digits, mengukur galat rekonstruksinya terhadap baseline, lalu membandingkan keluarga MIM dengan pendekatan generatif lain seperti denoising.
Dari Token ke Patch #
Langkah pertama mengadopsi MLM ke citra adalah mengganti token dengan patch — pembelahan yang sama dengan yang dipakai Vision Transformer (ViT), dibahas di chapter Vision Transformer . Gambar 8×8 piksel kita menjadi 16 patch berukuran 2×2. Arsitektur MAE kemudian bekerja tiga tahap: encoder hanya memproses patch yang terlihat (menghemat komputasi karena mayoritas patch tersembunyi), slot patch tersembunyi diisi satu token topeng yang dipelajari, dan decoder kecil menjahit ulang seluruh gambar. Satu detail penting: loss dihitung hanya pada piksel yang disembunyikan — memulihkan bagian yang terlihat bukanlah tugas.
| Aspek | MLM (teks) | MIM (gambar) |
|---|---|---|
| Unit yang disembunyikan | token kata/subword/karakter | patch piksel (misalnya 16×16) |
| Rasio topeng lazim | 15% | 50-75% (MAE: 75%) |
| Target loss | token asli pada posisi topeng | piksel asli pada patch topeng |
| Redundansi unit | moderat (kata tetangga berbeda makna) | tinggi (piksel tetangga mirip) |
| Output pretraining | representation kontekstual per token | representation spasial per patch |
Kolom redundansi menjelaskan kenapa rasio topeng gambar jauh lebih tinggi daripada teks: menyembunyikan 75% piksel masih menyisakan struktur yang cukup untuk ditebak, sedangkan menyembunyikan 75% kata hampir pasti membuat kalimat tak terbaca.
Demo: MAE Mini pada Dataset Digits #
Kita pakai 1797 gambar digit tulisan tangan 8×8 dari sklearn.datasets, dinormalkan ke rentang [0, 1]. Selama pelatihan, 8 dari 16 patch (50%) disembunyikan secara acak per gambar; encoder hanya membaca 8 patch terlihat. Pembandingnya sederhana namun jujur: menebak setiap piksel topeng dengan rata-rata seluruh dataset:
"""Masked image modeling (MIM) mini ala MAE pada dataset digits.
Setiap gambar digit 8x8 dipecah menjadi 16 patch 2x2. Saat pelatihan,
8 dari 16 patch (50%) disembunyikan secara acak. Encoder mini hanya
memproses patch yang terlihat; slot patch tersembunyi diisi satu token
topeng yang dipelajari. Decoder kecil lalu merekonstruksi seluruh
64 piksel, tetapi loss hanya dihitung pada patch yang disembunyikan.
Pembanding: menebak piksel topeng dengan rata-rata dataset.
Evaluasi ulang pada rasio topeng 75% (angka yang dipakai MAE asli).
Diuji di CPU dengan PyTorch 2.14, seed tetap, deterministik.
"""
import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
torch.manual_seed(0)
# ---------- 1. Data: gambar digits 8x8 dinormalkan ke [0, 1] ----------
digits = load_digits()
X = digits.data.astype(np.float32) / 16.0 # (1797, 64)
acak = np.random.default_rng(42).permutation(len(X))
X = X[acak]
target = digits.target[acak] # hanya untuk memilih contoh
X_latih = torch.tensor(X[:1500])
X_uji = torch.tensor(X[1500:])
target_uji = target[1500:]
SISI, PATCH, N_PATCH = 8, 2, 16 # 16 patch 2x2 per gambar
N_VIS, N_RUSAK = 8, 8 # 8 terlihat, 8 disembunyikan
print(f"data : {X_latih.shape[0]} gambar latih, {X_uji.shape[0]} gambar uji")
print(f"patch : {N_PATCH} patch {PATCH}x{PATCH} piksel per gambar {SISI}x{SISI}")
print(f"rasio : {N_RUSAK}/{N_PATCH} = {N_RUSAK / N_PATCH:.0%} patch disembunyikan")
print(f"baseline : tebak piksel topeng dengan rata-rata dataset "
f"({X_latih.mean():.3f})")
print()
def patchify(img):
"""(B, 64) -> (B, 16, 4): belah gambar jadi grid patch 2x2."""
b = img.reshape(-1, SISI, SISI)
p = b.reshape(-1, SISI // PATCH, PATCH, SISI // PATCH, PATCH)
return p.permute(0, 1, 3, 2, 4).reshape(-1, N_PATCH, PATCH * PATCH)
def pilih_topeng(B, n_rusak, generator=None):
"""Pilih n_rusak indeks patch acak per gambar, tanpa duplikat."""
if generator is not None:
r = torch.rand(B, N_PATCH, generator=generator)
else:
r = torch.rand(B, N_PATCH)
idx = r.topk(n_rusak, dim=1).indices # n_rusak indeks acak
topeng = torch.zeros(B, N_PATCH, dtype=torch.bool)
return topeng.scatter(1, idx, True) # True = disembunyikan
class MAEMini(nn.Module):
"""Encoder per-patch + token topeng + decoder rekonstruksi."""
def __init__(self):
super().__init__()
self.encoder = nn.Sequential(nn.Linear(4, 32), nn.ReLU())
self.token_topeng = nn.Parameter(torch.zeros(32))
self.pos = nn.Parameter(torch.zeros(N_PATCH, 8))
self.decoder = nn.Sequential(
nn.Linear(40 * N_PATCH, 128), nn.ReLU(), nn.Linear(128, 64)
)
def forward(self, img, topeng):
B = img.shape[0]
pat = patchify(img) # (B, 16, 4)
# urutkan indeks: patch terlihat (False) duluan -> ambil N_VIS
idx_vis = torch.argsort(topeng.int(), dim=1)[:, :N_VIS]
pat_vis = pat.gather(
1, idx_vis[..., None].expand(-1, -1, PATCH * PATCH))
z_vis = self.encoder(pat_vis) # encode patch terlihat
z = self.token_topeng.expand(B, N_PATCH, 32).clone()
z.scatter_(1, idx_vis[..., None].expand(-1, -1, 32), z_vis)
fitur = torch.cat([z, self.pos.expand(B, -1, -1)], dim=2)
return self.decoder(fitur.flatten(1)) # (B, 64) rekonstruksi
model = MAEMini()
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
mse = nn.MSELoss()
# ---------- 2. Pelatihan: rekonstruksi patch yang disembunyikan ----------
print("=== Pelatihan MAE-mini (loss pada patch tersembunyi saja) ===")
for ep in range(300):
i = torch.randint(0, X_latih.shape[0], (128,))
img = X_latih[i]
topeng = pilih_topeng(img.shape[0], N_RUSAK)
pred = model(img, topeng)
target_patch = patchify(img)
pred_p = patchify(pred)
loss = mse(pred_p[topeng], target_patch[topeng])
opt.zero_grad(); loss.backward(); opt.step()
if ep in (0, 299):
print(f" epoch {ep + 1:3d} : loss {loss.item():.4f}")
print()
# ---------- 3. Evaluasi: galat rekonstruksi vs baseline ----------
def galat(n_rusak, seed):
"""MSE pada piksel topeng uji + MSE baseline rata-rata dataset."""
g = torch.Generator().manual_seed(seed)
total_model, total_base, n = 0.0, 0.0, 0
with torch.no_grad():
for i in range(0, X_uji.shape[0], 100):
img = X_uji[i:i + 100]
topeng = pilih_topeng(img.shape[0], n_rusak, g)
pred = patchify(model(img, topeng))
asli = patchify(img)
total_model += mse(pred[topeng], asli[topeng]).item() * img.shape[0]
rata = X_latih.mean().expand_as(asli)
total_base += mse(rata[topeng], asli[topeng]).item() * img.shape[0]
n += img.shape[0]
return total_model / n, total_base / n
print("=== Galat rekonstruksi pada data uji (piksel topeng) ===")
for n_rusak, nama in ((8, "topeng 50% (8/16 patch)"), (12, "topeng 75% (12/16)")):
m, b = galat(n_rusak, seed=1234)
print(f" {nama:24s} : model {m:.4f} vs baseline {b:.4f} "
f"({b / m:.1f}x lebih baik)")
print()
# ---------- 4. Visualisasi satu gambar: asli, masukan, rekonstruksi ----------
KARAKTER = " .:-=+*#%@"
def gambar_ascii(piksel, topeng=None, judul=""):
"""Cetak gambar 8x8 sebagai karakter.
Bila topeng diberikan: piksel pada patch tersembunyi ditandai '?'
sehingga pembaca melihat patch mana saja yang dibaca model.
"""
baris = []
g = piksel.reshape(SISI, SISI)
t = topeng.reshape(-1).tolist() if topeng is not None else None
for r in range(SISI):
sel = []
for c in range(SISI):
if t is not None and t[(r // PATCH) * PATCH + c // PATCH]:
sel.append("?")
else:
sel.append(KARAKTER[min(int(g[r, c] * 9.99), 9)])
baris.append("".join(sel))
return f"{judul}\n" + "\n".join(baris)
print("=== Contoh rekonstruksi digit 0 (tanda ? = patch disembunyikan) ===")
i = int(np.flatnonzero(target_uji == 0)[0]) # digit 0: bentuk cincin
img = X_uji[i:i + 1]
g = torch.Generator().manual_seed(7)
topeng = pilih_topeng(1, N_RUSAK, g)
with torch.no_grad():
rekon = model(img, topeng)[0]
print(gambar_ascii(img[0], None, "gambar asli :"))
print()
print(gambar_ascii(img[0], topeng, "masukan model :"))
print()
print(gambar_ascii(rekon, None, "rekonstruksi penuh :"))
with torch.no_grad():
mse_satu = mse(patchify(rekon)[topeng], patchify(img[0])[topeng]).item()
print(f"MSE piksel topeng gambar ini : {mse_satu:.4f}")Output yang Dihasilkan:
data : 1500 gambar latih, 297 gambar uji
patch : 16 patch 2x2 piksel per gambar 8x8
rasio : 8/16 = 50% patch disembunyikan
baseline : tebak piksel topeng dengan rata-rata dataset (0.305)
=== Pelatihan MAE-mini (loss pada patch tersembunyi saja) ===
epoch 1 : loss 0.2216
epoch 300 : loss 0.0361
=== Galat rekonstruksi pada data uji (piksel topeng) ===
topeng 50% (8/16 patch) : model 0.0360 vs baseline 0.1436 (4.0x lebih baik)
topeng 75% (12/16) : model 0.0353 vs baseline 0.1416 (4.0x lebih baik)
=== Contoh rekonstruksi digit 0 (tanda ? = patch disembunyikan) ===
gambar asli :
*@%.
.@+=%
-@..@-
-@. #*
=% #*
=@ .@-
:@=#*
*@%.
masukan model :
??*@????
??@+????
????.@-
???? #*
=% #??
=@ .@??
:??#*??
??%.??
rekonstruksi penuh :
+%#.
.@*+*
-%..*.
-# =-
-* =-
:# .#-
.@*+%.
+%#.
MSE piksel topeng gambar ini : 0.0196Mari kita telaah kode di atas baris per baris:
- Baris 20-35 (data):
load_digitsmemberi 1797 gambar 8×8 bernilai 0-16; pembagian 16 dinormalkan ke [0, 1] agar loss mudah dibaca. Permutasidefault_rng(42)mengacak urutan sebelum split 1500/297. - Baris 39-43 (
patchify): reshape lima dimensi lalupermutemenyusun ulang sehingga indeks patch (baris-ke-blok, kolom-ke-blok) menjadi dimensi kedua — hasilnya 16 patch berisi 4 piksel. - Baris 46-53 (
pilih_topeng):topkatas nilai acak memilihn_rusakindeks tanpa duplikat;scattermenandai posisinya True pada tensor topeng. - Baris 57-84 (
MAEMini.forward):argsortmemposisikan indeks patch terlihat di depan,gathermengambilnya, encoder memproses hanya 8 patch itu, laluscatter_menaruh hasilnya kembali ke urutan asli sementara slot tersembunyi berisitoken_topeng.posadalah embedding posisi agar decoder tahu letak tiap patch. - Baris 87-99 (pelatihan): loss
mse(pred_p[topeng], target_patch[topeng])memfilter piksel tersembunyi saja — baris inilah yang membuat tugas benar-benar “menebak”, bukan menyalin. - Baris 104-124 (
galat): evaluasi diulang pada rasio 50% dan 75% dengan generator terpisah agar pilihan topeng uji deterministik. - Baris 133-148 (
gambar_ascii): tiap piksel dipetakan ke sepuluh karakter dari spasi (gelap penuh) sampai@(terang penuh); tanda?menandai piksel pada patch tersembunyi.
Membaca Angka dan Gambar #
Galat rekonstruksi 0.0360 melawan baseline 0.1436 berarti model empat kali lebih akurat daripada menebak rata-rata dataset — dan mengejutkannya, rasio 75% memberi galat yang hampir sama (0.0353). Angka itu membenarkan klaim MAE: struktur digit sangat redundan sehingga seperempat patch terlihat sudah mengandung hampir seluruh informasi bentuk. Visualisasi digit 0 memperlihatkan hal yang sama secara mata: meski delapan patch disembunyikan, rekonstruksi tetap mempertahankan siluet cincin dengan lubang tengah, dan derau halus muncul justru di sekitar tepi.
Representation yang baik dari hasil pretraining — apa pun pretext task-nya — akhirnya harus bisa diukur secara adil. Bagaimana kita tahu fitur pada layer tertentu lebih baik dari yang lain? Jawabannya adalah linear probe, alat ukur yang kita bahas di chapter Representation Learning.