Model klasifikasi pada chapter sebelumnya mencapai akurasi 0.969 dengan 1347 sampel latih. Bagaimana bila yang tersedia hanya 60 sampel? Jawaban klasiknya: perbanyak data lewat transformasi yang tidak mengubah label. Chapter ini menerapkan teori dari chapter Data Augmentation khusus untuk gambar: enam transformasi dasar dibangun dengan NumPy murni, lalu dampaknya dibuktikan lewat eksperimen terkendali — dua model identik, satu dengan data asli, satu dengan data yang diperluas noise dan geser acak.
Strategi: Perluas Data Latih Saja #
Aturan terpenting augmentasi hanya satu kalimat: transformasi hanya diterapkan pada data latih, tidak pernah pada validasi atau test. Keduanya harus menirukan data dunia nyata yang datang apa adanya; bila ikut diaugmentasi, angka evaluasi berhenti menjadi gambaran performa nyata. Diagram berikut menegaskan posisi transformasi di dalam pipeline:
Pemilihan transformasi bergantung domain. Pada digit tulisan tangan, geser dan noise aman; flip horizontal tidak aman karena angka 2 yang dicerminkan tidak lagi wajar, dan rotasi 180 derajat mengubah angka 6 menjadi angka 9. Strategi lanjutan seperti mixup — mencampur dua gambar beserta labelnya — dibahas sebagai bagian teori regularisasi.
Enam Transformasi Dasar dengan NumPy Murni #
Berkas transformasi_numpy.py menerapkan enam transformasi pada satu citra digits 8x8 tanpa pustaka gambar, lalu mencetak statistik piksel sebelum dan sesudah tiap operasi:
"""Transformasi augmentasi gambar dengan NumPy murni.
Enam transformasi dasar diterapkan pada satu citra digits 8x8: flip
horizontal, rotasi kecil (pemetaan balik + interpolasi bilinear),
crop+pad acak, geser, brightness, dan noise gaussian. Statistik piksel
(min, max, mean, std) dicetak sebelum dan sesudah tiap transformasi;
bentuk gambar tetap 8x8 agar kompatibel dengan model.
"""
import numpy as np
from sklearn.datasets import load_digits
rng = np.random.default_rng(42)
# ---------- transformasi dasar ----------
def flip_horizontal(img):
"""Balik kiri-kanan: kolom c dipetakan ke kolom (lebar-1-c)."""
return img[:, ::-1]
def rotasi_kecil(img, sudut_derajat):
"""Rotasi kecil lewat pemetaan balik + interpolasi bilinear."""
h, w = img.shape
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
sudut = np.deg2rad(sudut_derajat)
cos, sin = np.cos(sudut), np.sin(sudut)
r, c = np.meshgrid(np.arange(h), np.arange(w), indexing="ij")
# koordinat sumber untuk tiap piksel keluaran (rotasi balik -sudut)
rs = cy + (r - cy) * cos + (c - cx) * sin
cs = cx - (r - cy) * sin + (c - cx) * cos
# interpolasi bilinear dari 4 tetangga
r0 = np.clip(np.floor(rs).astype(int), 0, h - 1)
c0 = np.clip(np.floor(cs).astype(int), 0, w - 1)
r1 = np.clip(r0 + 1, 0, h - 1)
c1 = np.clip(c0 + 1, 0, w - 1)
dr, dc = np.clip(rs - r0, 0, 1), np.clip(cs - c0, 0, 1)
atas = img[r0, c0] * (1 - dc) + img[r0, c1] * dc
bawah = img[r1, c0] * (1 - dc) + img[r1, c1] * dc
return atas * (1 - dr) + bawah * dr
def crop_pad(img, atas, kiri):
"""Ambil jendela 6x6 lalu tempel di kanvas 8x8 dengan latar nol."""
jendela = img[atas:atas + 6, kiri:kiri + 6]
kanvas = np.zeros_like(img)
kanvas[1:7, 1:7] = jendela
return kanvas
def geser(img, dr, dc):
"""Geser isi gambar (dr, dc); area kosong diisi nol."""
keluar = np.zeros_like(img)
h, w = img.shape
sumber_r = slice(max(0, -dr), min(h, h - dr))
sumber_c = slice(max(0, -dc), min(w, w - dc))
tujuan_r = slice(max(0, dr), min(h, h + dr))
tujuan_c = slice(max(0, dc), min(w, w + dc))
keluar[tujuan_r, tujuan_c] = img[sumber_r, sumber_c]
return keluar
def brightness(img, faktor):
"""Kalikan intensitas lalu jepit kembali ke rentang [0, 1]."""
return np.clip(img * faktor, 0.0, 1.0)
def noise_gaussian(img, sigma):
"""Tambahkan derau gaussian lalu jepit ke [0, 1]."""
return np.clip(img + rng.normal(0.0, sigma, img.shape), 0.0, 1.0)
# ---------- statistik sebelum/sesudah ----------
digits = load_digits()
asli = digits.images[0].astype("float64") / 16.0 # digit 0, piksel 0..1
hasil = [
("asli", asli),
("flip horizontal", flip_horizontal(asli)),
("rotasi 10 derajat", rotasi_kecil(asli, 10)),
("crop+pad (2,1)", crop_pad(asli, 2, 1)),
("geser (1, -2)", geser(asli, 1, -2)),
("brightness x0.7", brightness(asli, 0.7)),
("noise sigma 0.10", noise_gaussian(asli, 0.10)),
]
print(f"bentuk tetap 8x8 untuk semua transformasi: "
f"{all(v.shape == (8, 8) for _, v in hasil)}")
print(f"\n{'transformasi':<20s} {'min':>6s} {'max':>6s} {'mean':>7s} {'std':>7s}")
for nama, img in hasil:
print(f"{nama:<20s} {img.min():>6.3f} {img.max():>6.3f} "
f"{img.mean():>7.3f} {img.std():>7.3f}")
# identitas piksel: flip tidak mengubah himpunan nilai
print(f"\nflip mempertahankan himpunan nilai piksel: "
f"{np.array_equal(np.sort(asli, axis=None), np.sort(hasil[1][1], axis=None))}")
print(f"rata-rata asli vs brightness x0.7: "
f"{asli.mean():.3f} vs {hasil[5][1].mean():.3f}")Output yang Dihasilkan:
bentuk tetap 8x8 untuk semua transformasi: True
transformasi min max mean std
asli 0.000 0.938 0.287 0.324
flip horizontal 0.000 0.938 0.287 0.324
rotasi 10 derajat 0.000 0.796 0.290 0.264
crop+pad (2,1) 0.000 0.938 0.203 0.290
geser (1, -2) 0.000 0.938 0.241 0.326
brightness x0.7 0.000 0.656 0.201 0.227
noise sigma 0.10 0.000 1.000 0.301 0.330
flip mempertahankan himpunan nilai piksel: True
rata-rata asli vs brightness x0.7: 0.287 vs 0.201Mari kita telaah kode di atas baris per baris:
- Baris 13 (
rng = np.random.default_rng(42)): generator acak ber-seed membuat keluaran identik pada setiap eksekusi. - Baris 17-19 (
flip_horizontal):img[:, ::-1]membalik urutan kolom; himpunan nilai piksel tidak berubah — statistiknya identik dengan baris asli. - Baris 22-40 (
rotasi_kecil): rotasi dikerjakan lewat pemetaan balik — tiap piksel keluaran bertanya “dari koordinat sumber mana saya berasal”;np.meshgridmenyusun koordinat seluruh piksel sekaligus, interpolasi bilinear mengambil rata-rata berbobot empat tetangga, dannp.clippada bobot membuat piksel luar batas meminjam nilai tepi. - Baris 43-48 (
crop_pad): jendela 6x6 diambil dari posisi(atas, kiri)lalu ditempel di tengah kanvas 8x8 berlatar nol — komposisi crop dan pergeseran. - Baris 51-60 (
geser): pergeseran dengan pengisian nol; dua pasangslicemenghitung wilayah sumber dan tujuan yang tumpang tindih. - Baris 63-70 (
brightness,noise_gaussian): dua transformasi intensitas — perkalian skala dan penambahan derau — keduanya dijepitnp.clipke rentang piksel sah[0, 1]. - Baris 74-98: statistik dicetak per transformasi; pemeriksaan terakhir memastikan flip tidak mengubah himpunan nilai dan brightness 0.7 menurunkan rata-rata 0.287 ke 0.201 — tepat 70%.
Setiap transformasi membawa efek dan risikonya sendiri — tabel berikut menjadi panduan pemilihan:
| Transformasi | Efek pada gambar | Risiko bila berlebihan |
|---|---|---|
| flip horizontal | objek bercermin kiri-kanan | makna berubah pada data asimetris kiri-kanan |
| rotasi kecil | objek miring beberapa derajat | identitas berubah pada rotasi besar (6 menjadi 9) |
| crop + pad | objek berpindah, tepi terpotong | objek utama bisa terpotong dari bidang |
| geser | objek berpindah tanpa berubah bentuk | objek keluar dari pandangan |
| brightness | intensitas naik atau turun merata | detail daerah redup tidak lagi terbedakan |
| noise gaussian | derau acak pada tiap piksel | sinyal tenggelam bila sigma terlalu besar |
Eksperimen: Dua Model Identik, Data Berbeda #
Berkas eksperimen_augmentasi.py menyusun uji terkendali. Dua CNN mini berarsitektur dan inisialisasi sama bermula dari 60 sampel latih; model baseline hanya melihat 60 sampel itu, model augmentasi melihat 60 sampel asli plus 300 varian hasil geser acak dan noise. Evaluasi berjalan pada dua kondisi: 300 sampel validasi bersih, dan salinan validasi yang digeser satu piksel — simulasi data nyata yang objeknya sedikit berpindah:
"""Eksperimen augmentasi: noise + geser vs tanpa augmentasi pada digits.
Dua CNN mini bermula dari data yang sama: 60 sampel asli (6 per kelas).
Model baseline hanya melihat 60 sampel itu; model augmentasi melihat
60 sampel asli + 300 varian (tiap sampel digandakan lima kali dengan
geser acak lalu noise gaussian). Augmentasi hanya menyentuh data latih.
Evaluasi memakai dua kondisi pada 300 sampel validasi yang sama:
(1) bersih -- kondisi terkendali, (2) bergeser (1,1) -- simulasi data
baru yang datang dengan objek sedikit berpindah posisi. Kedua model
dievaluasi pada kedua kondisi setiap 10 epoch.
"""
import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(42)
rng = np.random.default_rng(42)
def geser(img, dr, dc):
"""Geser isi gambar 8x8 sebesar (dr, dc); area kosong diisi nol."""
keluar = np.zeros_like(img)
h, w = img.shape
s_r = slice(max(0, -dr), min(h, h - dr))
s_c = slice(max(0, -dc), min(w, w - dc))
t_r = slice(max(0, dr), min(h, h + dr))
t_c = slice(max(0, dc), min(w, w + dc))
keluar[t_r, t_c] = img[s_r, s_c]
return keluar
def augmentasi(img, rng):
"""Satu varian baru: geser acak (-1..1 piksel) lalu noise sigma 0.08."""
dr, dc = rng.integers(-1, 2, size=2)
ges = geser(img, int(dr), int(dc))
return np.clip(ges + rng.normal(0.0, 0.08, ges.shape), 0.0, 1.0)
# ---------- 1. Data ----------
digits = load_digits()
X = digits.data.astype("float32").reshape(-1, 8, 8) / 16.0
y = digits.target.astype("int64")
X_pool, X_val, y_pool, y_val = train_test_split(
X, y, test_size=300, random_state=42, stratify=y
)
X_latih, _, y_latih, _ = train_test_split(
X_pool, y_pool, train_size=60, random_state=42, stratify=y_pool
)
X_tambahan = np.array([augmentasi(img, rng) for img in X_latih for _ in range(5)])
y_tambahan = np.repeat(y_latih, 5)
X_augg = np.concatenate([X_latih, X_tambahan])
y_augg = np.concatenate([y_latih, y_tambahan])
X_val_geser = np.array([geser(img, 1, 1) for img in X_val])
print(f"data latih baseline : {len(X_latih)} sampel asli")
print(f"data latih augmentasi: {len(X_latih)} asli + {len(X_tambahan)} varian "
f"= {len(X_augg)} sampel")
print(f"validasi : {len(X_val)} sampel, dua kondisi uji "
f"(bersih & bergeser)\n")
def ke_tensor(X):
"""(N, 8, 8) NumPy -> (N, 1, 8, 8) float32."""
return torch.tensor(X, dtype=torch.float32).unsqueeze(1)
X_val_t, y_val_t = ke_tensor(X_val), torch.tensor(y_val)
X_val_geser_t = ke_tensor(X_val_geser)
# ---------- 2. Model & training ----------
def buat_cnn():
return nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(16, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(32 * 2 * 2, 64),
nn.ReLU(),
nn.Linear(64, 10),
)
def latih(X_np, y_np, jumlah_epoch):
"""Latih CNN mini; kembalikan riwayat akurasi (bersih, bergeser)."""
torch.manual_seed(42) # init identik antar model
model = buat_cnn()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
X_t, y_t = ke_tensor(X_np), torch.tensor(y_np)
gen = torch.Generator().manual_seed(42)
riwayat = []
for epoch in range(1, jumlah_epoch + 1):
model.train()
urutan = torch.randperm(len(X_t), generator=gen)
for awal in range(0, len(X_t), 32):
pilih = urutan[awal:awal + 32]
optimizer.zero_grad()
loss = criterion(model(X_t[pilih]), y_t[pilih])
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
bersih = (model(X_val_t).argmax(dim=1) == y_val_t).float().mean().item()
geser = (model(X_val_geser_t).argmax(dim=1) == y_val_t).float().mean().item()
riwayat.append((bersih, geser))
return riwayat
JUMLAH_EPOCH = 60
riwayat_base = latih(X_latih, y_latih, JUMLAH_EPOCH)
riwayat_augg = latih(X_augg, y_augg, JUMLAH_EPOCH)
print(f"{'epoch':>5s} {'base-bersih':>12s} {'augg-bersih':>12s} "
f"{'base-geser':>11s} {'augg-geser':>11s}")
for e in range(10, JUMLAH_EPOCH + 1, 10):
bb, bg = riwayat_base[e - 1]
ab, ag = riwayat_augg[e - 1]
print(f"{e:>5d} {bb:>12.3f} {ab:>12.3f} {bg:>11.3f} {ag:>11.3f}")
bb, bg = riwayat_base[-1]
ab, ag = riwayat_augg[-1]
print(f"\nakurasi epoch ke-{JUMLAH_EPOCH}:")
print(f" baseline | bersih {bb:.3f} | bergeser {bg:.3f}")
print(f" augmentasi | bersih {ab:.3f} | bergeser {ag:.3f}")
print(f"selisih pada kondisi bergeser: {ag - bg:+.3f} "
f"({'augmentasi unggul' if ag > bg else 'baseline unggul'})")Output yang Dihasilkan:
data latih baseline : 60 sampel asli
data latih augmentasi: 60 asli + 300 varian = 360 sampel
validasi : 300 sampel, dua kondisi uji (bersih & bergeser)
epoch base-bersih augg-bersih base-geser augg-geser
10 0.113 0.643 0.100 0.200
20 0.450 0.733 0.103 0.447
30 0.537 0.757 0.123 0.503
40 0.717 0.783 0.173 0.507
50 0.760 0.797 0.143 0.590
60 0.793 0.793 0.150 0.600
akurasi epoch ke-60:
baseline | bersih 0.793 | bergeser 0.150
augmentasi | bersih 0.793 | bergeser 0.600
selisih pada kondisi bergeser: +0.450 (augmentasi unggul)Mari kita telaah kode di atas baris per baris:
- Baris 20-21: dua seed —
torch.manual_seed(42)untuk bobot,np.random.default_rng(42)untuk transformasi acak — membuat eksperimen dapat direproduksi. - Baris 36-40 (
augmentasi): satu varian dibangun dari dua operasi berurutan — geser acak ±1 piksel pada kedua sumbu lalu noise sigma 0.08 — meniru variasi posisi sekaligus kualitas rekaman. - Baris 48-53: pembagian berlapis — 300 sampel validasi dan 60 sampel latih (6 per kelas) diambil dengan
stratifyagar proporsi kelas terjaga. - Baris 55-58: perluasan data —
augmentasidipanggil lima kali per sampel, label direplikasinp.repeat, varian digabungkan ke data asli; 60 sampel menjadi 360 tanpa mengumpulkan gambar baru. - Baris 60 (
X_val_geser): kondisi uji kedua — salinan validasi yang digeser satu piksel; baris ini bagian evaluasi, bukan jalur latih, sehingga aturan train-only tetap utuh. - Baris 79-91 (
buat_cnn): arsitektur sama persis dengan chapter Klasifikasi Gambar — satu-satunya perbedaan kedua model adalah data. - Baris 94-117 (
latih):torch.manual_seed(42)dipanggil ulang di dalam fungsi sehingga kedua model mulai dari bobot identik. - Baris 120-137: kedua model dilatih 60 epoch, tabel dicetak tiap 10 epoch, dan ringkasan akhir menghitung selisih pada kondisi bergeser.
Membaca Hasil: Ketahanan Posisi #
Tiga fakta menonjol dari keluaran. Pertama, pada kondisi bersih kedua model berakhir sama di 0.793 — augmentasi tidak menurunkan performa pada kondisi yang sama dengan data latihnya. Kedua, pada kondisi bergeser baseline runtuh ke 0.150, nyaris setara tebakan acak sepuluh kelas; dengan 60 sampel yang semuanya terpusat, model menghafal posisi piksel, bukan bentuk digit. Ketiga, model augmentasi bertahan di 0.600 — varian geser mengajarkan bahwa digit yang sama bisa muncul di posisi sedikit berbeda. Baris epoch 10 menambah bukti: 0.643 lawan 0.113 — data yang lebih beragam juga mempercepat konvergensi.
Klasifikasi memberi satu label untuk satu gambar utuh. Kenyataannya, satu foto memuat banyak objek di banyak posisi — dan tugas Object Detection berikutnya menuntut model menjawab keduanya sekaligus: apa objeknya dan di mana ia berada.