# Ngajarin Komputer Kenal 102 Jenis Bunga: Dari Training Sampai Jadi API

Artikel sebelumnya membahas teorinya: apa itu image classification, cara kerja CNN lewat backbone dan head, sampai cara menyiapkan dataset. Semuanya masih di level konsep.

Sekarang bagian prakteknya. Model dilatih untuk membedakan **102 jenis bunga**, dari daffodil sampai sunflower, memakai dataset asli **Oxford Flowers 102**, lalu dibungkus jadi **API** yang siap dipakai aplikasi lain. Semua angka di artikel ini berasal dari eksekusi nyata di GPU, bukan estimasi. Kode lengkapnya ada di [repo GitHub](https://github.com/arielshakaramiro/flowers102-efficientnet-classifier).

> 💡 **Belum baca artikel sebelumnya?** Tulisan ini mengasumsikan konsep backbone/head dan transfer learning sudah familiar. Kotak "Kilas Balik" di beberapa bagian akan membantu kalau belum sempat baca.

* * *

## Daftar Isi

1.  [Studi Kasus: Oxford Flowers 102](#studi-kasus-oxford-flowers-102)
    
2.  [Persiapan Lingkungan](#persiapan-lingkungan)
    
3.  [Menyiapkan Dataset](#menyiapkan-dataset)
    
4.  [Plot Twist: Iseng Cek Ulang Skripnya](#plot-twist-iseng-cek-ulang-skripnya)
    
5.  [Membangun Model dengan Transfer Learning](#membangun-model-dengan-transfer-learning)
    
6.  [Training Loop Lengkap](#training-loop-lengkap)
    
7.  [Hasil Asli: Accuracy, Precision, Recall](#hasil-asli-accuracy-precision-recall)
    
8.  [Dari Model ke API dengan FastAPI](#dari-model-ke-api-dengan-fastapi)
    
9.  [Cheat Sheet](#cheat-sheet)
    
10.  [Uji Pemahaman Kamu](#uji-pemahaman-kamu)
     

* * *

## Studi Kasus: Oxford Flowers 102

Dataset yang dipakai: [**Oxford Flowers 102**](https://www.robots.ox.ac.uk/~vgg/data/flowers/102/), kumpulan foto bunga dengan 102 kategori. Salah satu dataset klasik untuk benchmark image classification, karena jumlah kelasnya banyak dan beberapa jenisnya mirip satu sama lain. Mirip kasus jahe-lengkuas-kunyit di artikel sebelumnya, hanya saja skalanya jadi 102 kelas.

Backbone yang dipakai: **EfficientNet-B1**, sama seperti yang dibahas di artikel sebelumnya. Arsitektur tidak dirancang dari nol, cukup pakai backbone pretrained dan ganti bagian classifier-nya.

![Contoh gambar asli dari dataset Oxford Flowers 102 beserta label kelasnya](https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/4b8039f5-f4b1-4227-a0fa-ef72cc5449ae.png align="center")

*Sampel asli dari validation set, lengkap dengan label yang sudah diverifikasi lewat mapping* `cat_to_name.json`*.*

## Persiapan Lingkungan

```bash
pip install torch torchvision scikit-learn scipy pandas matplotlib
```

`scikit-learn` ditambahkan khusus untuk menghitung metrik evaluasi: accuracy, precision, recall.

## Menyiapkan Dataset

Dataset Flowers 102 tidak datang dalam format folder per kelas yang rapi seperti contoh di artikel sebelumnya. Labelnya ada di file `.mat` terpisah (`imagelabels.mat`). Kesempatan bagus untuk praktik bikin custom `Dataset`, pola yang sama seperti pendekatan CSV yang sudah dibahas, hanya sumber labelnya beda.

```python
import os
import torch
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
import scipy.io

class FlowersDataset(Dataset):
    def __init__(self, img_dir, all_files, labels, indices, transform=None):
        self.img_dir = img_dir
        self.files = all_files
        self.labels = labels
        self.indices = indices
        self.transform = transform

    def __len__(self):
        return len(self.indices)

    def __getitem__(self, i):
        idx = self.indices[i]
        img_path = os.path.join(self.img_dir, self.files[idx])
        image = Image.open(img_path).convert("RGB")
        label = int(self.labels[idx])
        if self.transform:
            image = self.transform(image)
        return image, label

data_transforms = {
    'train': transforms.Compose([
        transforms.RandomResizedCrop(224),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    ]),
    'eval': transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.ToTensor(),
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    ]),
}

mat_labels = scipy.io.loadmat('imagelabels.mat')
labels = mat_labels['labels'][0] - 1  # ubah ke 0-indexed
```

Dua detail yang beda dari transform di artikel sebelumnya:

*   `RandomResizedCrop` dan `RandomHorizontalFlip` dipakai khusus untuk data training. Teknik augmentasi ini membuat model melihat variasi sudut dan posisi yang lebih banyak dari gambar yang sama, supaya tidak mudah menghafal.
    
*   `Normalize` dengan angka `[0.485, 0.456, 0.406]` dan `[0.229, 0.224, 0.225]` adalah rata-rata dan standar deviasi channel R/G/B dari dataset ImageNet, dataset besar yang dipakai melatih EfficientNet sebelumnya. Menormalkan dengan angka yang sama membuat input berbicara "bahasa" yang sama dengan bobot pretrained-nya.
    

> 🎯 **Coba Tebak Dulu:** Kenapa augmentasi hanya diterapkan ke data training, bukan data validation?
> 
> **Jawaban:** Validasi perlu mengevaluasi model dengan kondisi konsisten dan representatif dari data asli. Kalau ikut diacak, skor validasi jadi tidak stabil dan sulit dibandingkan antar-epoch. Augmentasi tugasnya membantu model belajar lebih general saat training, bukan untuk diukur performanya.

## Plot Twist: Iseng Cek Ulang Skripnya

Sebelum lanjut ke training, ada bagian menarik dari proses menyusun ulang materi ini. Skrip asli dari sesi bootcamp sempat dicek ulang baris demi baris, dan ternyata ada beberapa hal yang perlu dibetulkan supaya hasilnya benar dan reproducible. Ini bagian dari proses belajar verifikasi kode sebelum dipublikasikan, jadi didokumentasikan di sini alih-alih disembunyikan.

1.  **Urutan file bisa tidak sinkron dengan label.** Skrip asli memakai `os.listdir()` polos untuk membaca daftar file gambar. Masalahnya, `os.listdir()` tidak menjamin urutan terurut di semua sistem, padahal file label (`imagelabels.mat`) mengasumsikan urutan `image_00001.jpg, image_00002.jpg, ...`. Kalau urutannya meleset, gambar dan label bisa tertukar tanpa error apa pun. Diuji langsung dengan data kecil: benar saja, urutan `os.listdir()` polos beda dari `sorted()`. Perbaikannya simpel, tinggal bungkus dengan `sorted(...)`.
    
2.  **Augmentasi ikut bocor ke data validasi.** Skrip asli membuat satu dataset dengan transform training, baru displit ke train/val sesudahnya. Akibatnya validasi ikut kena augmentasi acak, padahal seharusnya diproses secara konsisten. Diperbaiki dengan membuat dua instance dataset terpisah, masing-masing dengan transform sendiri, disatukan lewat indeks split yang sama.
    
3.  **Split train/val tidak pakai split resmi.** Dataset Flowers 102 sebenarnya sudah punya split resmi dari paper aslinya (`setid.mat`), yang bisa dipakai supaya hasil bisa dibandingkan dengan riset lain. Skrip asli malah split random sendiri. Versi final memakai split resmi tersebut.
    
4.  `pretrained=True` **sudah deprecated** di versi torchvision terbaru, diganti ke API `weights=`.
    
5.  **Nama 102 kelas bunga.** Draf pertama sempat mengetik ulang daftar nama secara manual dari ingatan, dan ternyata salah hitung: 104 nama, bukan 102. Ketahuan lewat `assert` sebelum sempat dipakai kemana-mana. Solusi finalnya mengambil mapping nama langsung dari referensi publik (`cat_to_name.json`) saat runtime, bukan mengandalkan hasil ketikan.
    

Semua perbaikan ini sudah diverifikasi, dan notebook lengkapnya (beserta hasil eksekusi asli) ada di repo GitHub yang ditautkan di awal artikel.

## Membangun Model dengan Transfer Learning

Konsep transfer learning dari artikel sebelumnya, sekarang diterapkan di kasus nyata.

> 🔁 **Kilas Balik:** transfer learning artinya memakai backbone yang sudah pretrained (misalnya dari ImageNet), lalu cukup mengganti bagian classifier terakhirnya sesuai jumlah kelas. Tidak perlu merancang arsitektur dari nol.

```python
from torchvision.models import efficientnet_b1, EfficientNet_B1_Weights
import torch.nn as nn

model = efficientnet_b1(weights=EfficientNet_B1_Weights.IMAGENET1K_V1)
num_ftrs = model.classifier[1].in_features

model.classifier = nn.Sequential(
    nn.Dropout(p=0.4, inplace=True),
    nn.Linear(num_ftrs, 102)
)
```

Backbone EfficientNet-B1 yang sudah pretrained dipakai apa adanya, hanya bagian `classifier` yang diganti supaya jumlah output-nya sesuai 102 kelas. Dari total 6.643.846 parameter di model ini, cuma 130.662 yang benar-benar dilatih dari nol (bagian classifier-nya saja).

## Training Loop Lengkap

Training berjalan 15 epoch, dengan pencatatan loss, accuracy, precision, dan recall di tiap epoch, plus penyimpanan checkpoint model terbaik.

```python
import torch.optim as optim
from sklearn.metrics import accuracy_score, precision_score, recall_score
import os

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
checkpoint_dir = "./checkpoints"
os.makedirs(checkpoint_dir, exist_ok=True)

def train_model(model, criterion, optimizer, num_epochs=15):
    best_acc = 0.0

    for epoch in range(num_epochs):
        for phase in ['train', 'val']:
            model.train() if phase == 'train' else model.eval()

            running_loss = 0.0
            all_preds, all_labels = [], []

            for inputs, labels in dataloaders[phase]:
                inputs, labels = inputs.to(device), labels.to(device)
                optimizer.zero_grad()

                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    loss = criterion(outputs, labels)
                    _, preds = torch.max(outputs, 1)
                    all_preds.extend(preds.cpu().numpy())
                    all_labels.extend(labels.cpu().numpy())

                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                running_loss += loss.item() * inputs.size(0)

            epoch_acc = accuracy_score(all_labels, all_preds)

            if phase == 'val':
                precision = precision_score(all_labels, all_preds, average='weighted')
                recall = recall_score(all_labels, all_preds, average='weighted')

                if epoch_acc > best_acc:
                    best_acc = epoch_acc
                    torch.save(model.state_dict(), f"{checkpoint_dir}/best.pt")
                torch.save(model.state_dict(), f"{checkpoint_dir}/last.pt")

    return best_acc

train_model(model, criterion, optimizer, num_epochs=15)
```

Dua detail yang menjawab pertanyaan yang mungkin muncul dari artikel sebelumnya:

*   `model.train()` vs `model.eval()` adalah implementasi konkret dari "set mode training" yang dibahas sebelumnya. Di fase `val`, `torch.set_grad_enabled(False)` otomatis mematikan perhitungan gradien.
    
*   Dua file checkpoint disimpan: `best.pt` untuk model dengan akurasi validasi terbaik sejauh proses training, `last.pt` untuk kondisi model di epoch paling akhir.
    

## Hasil Asli: Accuracy, Precision, Recall

Berikut angka sebenarnya dari training 15 epoch di GPU (Google Colab, Tesla T4):

| Metrik | Nilai |
| --- | --- |
| Best validation accuracy | 89,41% (epoch 8) |
| Test accuracy (6.149 gambar) | 87,95% |
| Test precision (weighted) | 89,76% |
| Test recall (weighted) | 87,95% |

Kurva akurasi training naik cepat di beberapa epoch pertama (dari 15,6% ke sekitar 90%), sementara akurasi validasi memuncak lebih awal, di epoch 8, lalu stagnan dan sedikit menurun sampai epoch 15. Pola ini menandakan overfitting ringan setelah epoch 8, model mulai terlalu menyesuaikan diri dengan data training, sementara performa di data yang belum pernah dilihat berhenti membaik. Inilah alasan checkpoint `best.pt` yang dipakai untuk evaluasi akhir, bukan `last.pt`.

![Kurva loss dan akurasi training vs validasi selama 15 epoch](https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/962d8a30-ecfd-44d6-9ee0-d9f51f6a2e30.png align="center")

*Val accuracy (oranye) memuncak di epoch 8, lalu val loss mulai naik lagi meski train loss terus turun, tanda klasik overfitting.*

Rata-rata weighted di atas menyembunyikan variasi yang cukup besar antar kelas. Dari classification report lengkap (102 kelas), beberapa kelas mencapai precision dan recall sempurna, 1,00, misalnya *bird of paradise* dan *black-eyed susan*. Sebagian kelas lain jauh lebih sulit, seperti *mallow* dengan precision 0,41 dan *japanese anemone* dengan recall 0,46. Kemungkinan penyebabnya adalah kemiripan visual antar kelas bunga tertentu, ditambah jumlah sampel training yang cukup kecil per kelas (rata-rata sekitar 10 gambar per kelas di split resmi dataset ini).

> 📌 Dengan 102 kelas dan performa yang tidak merata, akurasi saja bisa menipu. Model bisa terlihat bagus secara keseluruhan padahal buruk di beberapa kelas tertentu. Precision dan recall per kelas membantu menangkap itu.

## Dari Model ke API dengan FastAPI

Model sudah dilatih dan checkpoint terbaik (`best.pt`) sudah tersimpan. Bagian selanjutnya: bagaimana model ini dipakai di dunia nyata.

```bash
pip install fastapi uvicorn pillow torch torchvision
```

```python
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import JSONResponse
from PIL import Image
import torch
import torch.nn as nn
from torchvision import models, transforms
import io, json, urllib.request

app = FastAPI()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

model = models.efficientnet_b1(weights=None)
num_ftrs = model.classifier[1].in_features
model.classifier = nn.Sequential(
    nn.Dropout(p=0.4, inplace=True),
    nn.Linear(num_ftrs, 102)
)
model.load_state_dict(torch.load('checkpoints/best.pt', map_location=device))
model = model.to(device)
model.eval()

transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

with urllib.request.urlopen(
    "https://raw.githubusercontent.com/udacity/aipnd-project/master/cat_to_name.json"
) as resp:
    cat_to_name = json.load(resp)
class_names = [cat_to_name[str(i)] for i in range(1, 103)]

@app.post("/predict/")
async def predict(file: UploadFile = File(...)):
    try:
        image = Image.open(io.BytesIO(await file.read())).convert("RGB")
        input_tensor = transform(image).unsqueeze(0).to(device)

        with torch.no_grad():
            outputs = model(input_tensor)
            _, predicted = torch.max(outputs, 1)

        return JSONResponse(content={"predicted_class": class_names[predicted.item()]})
    except Exception as e:
        return JSONResponse(content={"error": str(e)}, status_code=400)
```

Tiga hal yang wajib diperhatikan supaya serving-nya tidak meleset:

1.  **Arsitektur model harus identik** dengan yang dipakai saat training, termasuk jumlah kelas dan struktur classifier. Kalau beda, `load_state_dict` bisa gagal, atau lebih berbahaya lagi, berhasil dimuat tapi menghasilkan prediksi yang salah tanpa error.
    
2.  **Transform harus sama persis** dengan yang dipakai saat validasi, bukan training. Pakai `Resize` dan `CenterCrop` yang konsisten, bukan augmentasi random.
    
3.  `model.eval()` **dan** `torch.no_grad()` memastikan model dalam mode inference murni.
    

Menjalankan servernya:

```bash
uvicorn main:app --reload
```

```bash
curl -X POST "http://localhost:8000/predict/" -F "file=@path_to_your_image.jpg"
```

```json
{"predicted_class": "sunflower"}
```

Model sekarang bisa dipanggil dari aplikasi apa pun, tinggal kirim gambar ke endpoint `/predict/`.

## Cheat Sheet

*   \[ \] Custom `Dataset` dipakai saat label tidak datang dalam format folder rapi
    
*   \[ \] Urutan file harus di-sort manual kalau labelnya berasal dari array terpisah (mis. file `.mat`)
    
*   \[ \] Augmentasi hanya untuk data training, validasi tetap konsisten
    
*   \[ \] Split resmi dari paper dataset lebih baik dipakai daripada split random sendiri
    
*   \[ \] Simpan dua checkpoint: `best.pt` dan `last.pt`
    
*   \[ \] Val accuracy yang memuncak lalu menurun adalah tanda overfitting, bukan bug
    
*   \[ \] Precision dan recall per kelas melengkapi accuracy, terutama saat performa antar kelas tidak merata
    
*   \[ \] Arsitektur model saat serving harus identik dengan saat training
    
*   \[ \] Transform saat serving mengikuti transform validasi, bukan training
    

## Uji Pemahaman Kamu

**1\. Val accuracy naik sampai epoch 8, lalu stagnan dan sedikit menurun sampai epoch 15. Apa artinya?**

> **Jawaban:** Tanda overfitting ringan. Model terus membaik di data training, tapi performanya di data yang belum pernah dilihat berhenti meningkat setelah titik tertentu. Ini alasan checkpoint terbaik (bukan checkpoint terakhir) yang dipakai untuk deployment.

**2\. Model punya akurasi keseluruhan 88%, tapi recall untuk satu kelas hanya 46%. Apa artinya?**

> **Jawaban:** Model bagus secara umum, tapi buruk khusus untuk kelas tersebut. Banyak sampel dari kelas itu gagal terdeteksi dengan benar. Akurasi keseluruhan yang tinggi bisa menyembunyikan masalah ini karena porsi kelas tersebut kecil dari total data.

**3\. Kenapa transform saat serving harus sama dengan transform validasi, bukan training?**

> **Jawaban:** Transform training mengandung augmentasi acak yang tujuannya membuat model belajar lebih general, bukan untuk representasi asli dari gambar. Saat inference, gambar perlu diproses secara konsisten, persis seperti cara model dievaluasi saat validasi.

**4\. Kenapa urutan file gambar perlu di-sort manual kalau labelnya dari file terpisah?**

> **Jawaban:** Fungsi seperti `os.listdir()` tidak menjamin urutan file yang konsisten di semua sistem. Kalau label diasumsikan mengikuti urutan penomoran file, ketidaksesuaian urutan bisa membuat gambar dan label tertukar tanpa error apa pun.

**5\. Apa gunanya dataset punya split resmi (train/val/test) dari paper aslinya?**

> **Jawaban:** Supaya hasil training bisa dibandingkan secara adil dengan penelitian atau eksperimen lain yang memakai dataset yang sama. Split random sendiri membuat angka hasil training sulit dibandingkan lintas eksperimen.

* * *

Dua pertanyaan besar dari akhir artikel sebelumnya, bagaimana cara training dan bagaimana cara membuat model serving, sudah terjawab lengkap di sini, dari data mentah sampai endpoint API yang siap dipanggil. Kode lengkap dan notebook yang sudah dijalankan ada di [repo GitHub](https://github.com/arielshakaramiro/flowers102-efficientnet-classifier).

*Bagian dari seri belajar Computer Vision.*
