<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Shaka's AI Journal]]></title><description><![CDATA[Personal AI engineering journal — computer vision, deep learning, and deployment. Study notes, working code, and real projects from coursework and independent practice.]]></description><link>https://shaka-ai.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a8ef2e3923670c989379174/02bd2948-376b-48da-8ef2-bd3ec4a7686b.png</url><title>Shaka&apos;s AI Journal</title><link>https://shaka-ai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 05:33:27 GMT</lastBuildDate><atom:link href="https://shaka-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Teaching a Computer 102 Flower Species: From Training to a Live API]]></title><description><![CDATA[The previous post covered the theory: what image classification is, how a CNN works through its backbone and head, and how to prepare a dataset. All of that stayed at the conceptual level.
This one is]]></description><link>https://shaka-ai.hashnode.dev/flower-classification-training-fastapi-serving</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/flower-classification-training-fastapi-serving</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 14 Sep 2026 08:31:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/5ad9797b-b322-43fa-b722-5b8be36aab4c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The previous post covered the theory: what image classification is, how a CNN works through its backbone and head, and how to prepare a dataset. All of that stayed at the conceptual level.</p>
<p>This one is the hands-on half. A model gets trained to tell <strong>102 flower species</strong> apart, from daffodils to sunflowers, using the real <strong>Oxford Flowers 102</strong> dataset, then wrapped into an <strong>API</strong> another application can call. Every number in this post comes from an actual GPU run, not an estimate. Full code lives in the <a href="https://github.com/arielshakaramiro/flowers102-efficientnet-classifier">GitHub repo</a>.</p>
<blockquote>
<p>💡 <strong>Haven't read the previous post?</strong> This one assumes backbone/head and transfer learning concepts are already familiar. "Quick Recap" boxes throughout should help if not.</p>
</blockquote>
<hr />
<h2>Table of Contents</h2>
<ol>
<li><p><a href="#case-study-oxford-flowers-102">Case Study: Oxford Flowers 102</a></p>
</li>
<li><p><a href="#environment-setup">Environment Setup</a></p>
</li>
<li><p><a href="#preparing-the-dataset">Preparing the Dataset</a></p>
</li>
<li><p><a href="#plot-twist-a-closer-look-at-the-original-script">Plot Twist: A Closer Look at the Original Script</a></p>
</li>
<li><p><a href="#building-the-model-with-transfer-learning">Building the Model with Transfer Learning</a></p>
</li>
<li><p><a href="#the-full-training-loop">The Full Training Loop</a></p>
</li>
<li><p><a href="#real-results-accuracy-precision-recall">Real Results: Accuracy, Precision, Recall</a></p>
</li>
<li><p><a href="#from-model-to-api-with-fastapi">From Model to API with FastAPI</a></p>
</li>
<li><p><a href="#cheat-sheet">Cheat Sheet</a></p>
</li>
<li><p><a href="#test-your-understanding">Test Your Understanding</a></p>
</li>
</ol>
<hr />
<h2>Case Study: Oxford Flowers 102</h2>
<p>The dataset: <a href="https://www.robots.ox.ac.uk/~vgg/data/flowers/102/"><strong>Oxford Flowers 102</strong></a>, a collection of flower photos across 102 categories. It's a classic image classification benchmark precisely because it has so many classes, several of them visually similar to each other, the same kind of mix-up as ginger, galangal, and turmeric from the previous post, just scaled up to 102 categories.</p>
<p>The backbone: <strong>EfficientNet-B1</strong>, same as before. No architecture designed from scratch here, just a pretrained backbone with a swapped-out classifier.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/281a4044-366d-4800-90ba-cf80fdfeccb7.png" alt="Real sample images from the Oxford Flowers 102 dataset with their class labels" style="display:block;margin:0 auto" />

<p><em>Actual samples from the validation set, labeled using the verified</em> <code>cat_to_name.json</code> <em>mapping.</em></p>
<h2>Environment Setup</h2>
<pre><code class="language-bash">pip install torch torchvision scikit-learn scipy pandas matplotlib
</code></pre>
<p><code>scikit-learn</code> is new here, needed specifically for evaluation metrics: accuracy, precision, recall.</p>
<h2>Preparing the Dataset</h2>
<p>Flowers 102 doesn't come as a neat folder-per-class structure like the previous post's examples. Labels live in a separate <code>.mat</code> file (<code>imagelabels.mat</code>). This is a good opportunity to build a custom <code>Dataset</code>, the same pattern as the CSV approach covered earlier, just with a different label source.</p>
<pre><code class="language-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  # shift to 0-indexed
</code></pre>
<p>Two details differ from the transforms in the previous post:</p>
<ul>
<li><p><code>RandomResizedCrop</code> and <code>RandomHorizontalFlip</code> apply only to training data. This augmentation exposes the model to more angle and position variation from the same images, so it doesn't just memorize them.</p>
</li>
<li><p>The <code>Normalize</code> values <code>[0.485, 0.456, 0.406]</code> and <code>[0.229, 0.224, 0.225]</code> are the R/G/B mean and standard deviation from ImageNet, the large dataset EfficientNet was originally pretrained on. Normalizing with the same numbers keeps the input "speaking the same language" as those pretrained weights.</p>
</li>
</ul>
<blockquote>
<p>🎯 <strong>Guess First:</strong> Why does augmentation only apply to training data, not validation?</p>
<p><strong>Jawaban:</strong> Validation needs to evaluate the model under consistent, representative conditions. Randomized crops and flips would make validation scores noisy and hard to compare across epochs. Augmentation's job is helping the model generalize during training, not shaping what it's measured against.</p>
</blockquote>
<h2>Plot Twist: A Closer Look at the Original Script</h2>
<p>Before getting to training, here's an interesting part of putting this post together. The original bootcamp script got read line by line while adapting it, and a few things turned out to need fixing for correct, reproducible results. This is part of the process of verifying code before publishing it, so it's documented here rather than glossed over.</p>
<ol>
<li><p><strong>File order can drift out of sync with labels.</strong> The original script reads image filenames with plain <code>os.listdir()</code>. The problem: <code>os.listdir()</code> doesn't guarantee sorted order across systems, while the label file (<code>imagelabels.mat</code>) assumes files follow the order <code>image_00001.jpg, image_00002.jpg, ...</code>. If the order drifts, images and labels can get silently swapped with no error at all. Testing this directly on a small sample confirmed it: plain <code>os.listdir()</code> order really did differ from <code>sorted()</code>. The fix is a one-word wrap: <code>sorted(...)</code>.</p>
</li>
<li><p><strong>Augmentation leaked into validation.</strong> The original script builds one dataset with the training transform, then splits it into train/val afterward. That means validation images inherit random augmentation too, when they should be processed consistently. Fixed by building two separate dataset instances, each with its own transform, joined through the same split indices.</p>
</li>
<li><p><strong>The train/val split skipped the dataset's official split.</strong> Flowers 102 ships with an official split from its original paper (<code>setid.mat</code>), useful for comparing results against other research. The original script did its own random split instead. The final version uses the official one.</p>
</li>
<li><p><code>pretrained=True</code> <strong>is deprecated</strong> in newer torchvision versions, replaced with the <code>weights=</code> API.</p>
</li>
<li><p><strong>The 102 flower class names.</strong> An early draft retyped the name list by hand from memory, and it turned out to contain 104 entries instead of 102. An <code>assert</code> caught it before it went anywhere. The final version fetches the name mapping from a public reference (<code>cat_to_name.json</code>) at runtime instead of relying on manual transcription.</p>
</li>
</ol>
<p>All of these fixes are verified, and the full notebook, including the actual run output, is in the linked GitHub repo.</p>
<h2>Building the Model with Transfer Learning</h2>
<p>The transfer learning concept from the previous post, applied to a real case now.</p>
<blockquote>
<p>🔁 <strong>Quick Recap:</strong> transfer learning means reusing an already-pretrained backbone (from ImageNet, say) and swapping out just the final classifier layer to match the target classes. No architecture gets designed from scratch.</p>
</blockquote>
<pre><code class="language-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)
)
</code></pre>
<p>The pretrained EfficientNet-B1 backbone is used as-is; only the <code>classifier</code> gets swapped to output 102 classes. Out of 6,643,846 total parameters in this model, only 130,662 are actually trained from scratch, just the classifier portion.</p>
<h2>The Full Training Loop</h2>
<p>Training ran for 15 epochs, logging loss, accuracy, precision, and recall each epoch, with the best checkpoint saved along the way.</p>
<pre><code class="language-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 &gt; 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)
</code></pre>
<p>Two details that answer questions the previous post might have raised:</p>
<ul>
<li><p><code>model.train()</code> vs <code>model.eval()</code> is the concrete implementation of "set training mode" mentioned earlier. During the <code>val</code> phase, <code>torch.set_grad_enabled(False)</code> automatically turns off gradient computation.</p>
</li>
<li><p>Two checkpoints get saved: <code>best.pt</code> for the model with the best validation accuracy seen so far, <code>last.pt</code> for whatever state the model is in at the final epoch.</p>
</li>
</ul>
<h2>Real Results: Accuracy, Precision, Recall</h2>
<p>Here are the actual numbers from a 15-epoch GPU run (Google Colab, Tesla T4):</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Best validation accuracy</td>
<td>89.41% (epoch 8)</td>
</tr>
<tr>
<td>Test accuracy (6,149 images)</td>
<td>87.95%</td>
</tr>
<tr>
<td>Test precision (weighted)</td>
<td>89.76%</td>
</tr>
<tr>
<td>Test recall (weighted)</td>
<td>87.95%</td>
</tr>
</tbody></table>
<p>Training accuracy climbs fast in the first few epochs (from 15.6% to around 90%), while validation accuracy peaks earlier, at epoch 8, then plateaus and drifts slightly downward through epoch 15. That pattern points to mild overfitting past epoch 8: the model keeps fitting the training data more closely while its performance on unseen data stops improving. That's why the <code>best.pt</code> checkpoint, not <code>last.pt</code>, gets used for final evaluation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/bef04405-ef5a-4007-922e-1fd8c5b9d7c5.png" alt="Training vs validation loss and accuracy curves over 15 epochs" style="display:block;margin:0 auto" />

<p><em>Validation accuracy (orange) peaks at epoch 8, then validation loss starts climbing again even as training loss keeps dropping, a textbook overfitting signature.</em></p>
<p>The weighted averages above hide a fair amount of variation between classes. Looking at the full 102-class classification report, several classes hit perfect precision and recall, 1.00, including <em>bird of paradise</em> and <em>black-eyed susan</em>. Others are much harder: <em>mallow</em> sits at 0.41 precision, <em>japanese anemone</em> at 0.46 recall. Visual similarity between certain flower classes is a likely factor here, along with the fairly small per-class training sample (around 10 images per class on average, given this dataset's official split).</p>
<blockquote>
<p>📌 With 102 classes and uneven performance across them, accuracy alone can be misleading. A model can look good overall while struggling badly on specific classes. Per-class precision and recall catch what the average hides.</p>
</blockquote>
<h2>From Model to API with FastAPI</h2>
<p>The model is trained and the best checkpoint (<code>best.pt</code>) is saved. Next: making it usable in the real world.</p>
<pre><code class="language-bash">pip install fastapi uvicorn pillow torch torchvision
</code></pre>
<pre><code class="language-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)
</code></pre>
<p>Three things that matter for serving this correctly:</p>
<ol>
<li><p><strong>The model architecture has to match training exactly</strong>, including the number of classes and the classifier structure. A mismatch can make <code>load_state_dict</code> fail outright, or worse, load successfully while producing wrong predictions with no error at all.</p>
</li>
<li><p><strong>The transform has to match validation, not training.</strong> Use the same consistent <code>Resize</code> and <code>CenterCrop</code>, not random augmentation.</p>
</li>
<li><p><code>model.eval()</code> <strong>and</strong> <code>torch.no_grad()</code> keep the model in pure inference mode.</p>
</li>
</ol>
<p>Running the server:</p>
<pre><code class="language-bash">uvicorn main:app --reload
</code></pre>
<pre><code class="language-bash">curl -X POST "http://localhost:8000/predict/" -F "file=@path_to_your_image.jpg"
</code></pre>
<pre><code class="language-json">{"predicted_class": "sunflower"}
</code></pre>
<p>The model is now callable from any application, just send an image to the <code>/predict/</code> endpoint.</p>
<h2>Cheat Sheet</h2>
<ul>
<li><p>[ ] Use a custom <code>Dataset</code> when labels don't come in a tidy folder structure</p>
</li>
<li><p>[ ] Sort file listings manually when labels come from a separate array (like a <code>.mat</code> file)</p>
</li>
<li><p>[ ] Augmentation applies to training data only, validation stays consistent</p>
</li>
<li><p>[ ] A dataset's official split beats a custom random split for reproducibility</p>
</li>
<li><p>[ ] Save two checkpoints: <code>best.pt</code> and <code>last.pt</code></p>
</li>
<li><p>[ ] Validation accuracy peaking then declining signals overfitting, not a bug</p>
</li>
<li><p>[ ] Per-class precision and recall complement accuracy, especially with uneven class performance</p>
</li>
<li><p>[ ] Serving architecture must match training architecture exactly</p>
</li>
<li><p>[ ] Serving transform follows validation transform, not training</p>
</li>
</ul>
<h2>Test Your Understanding</h2>
<p><strong>1. Validation accuracy rises until epoch 8, then plateaus and drifts down through epoch 15. What does that mean?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Mild overfitting. The model keeps improving on training data, but its performance on unseen data stops improving past a certain point. This is why the best checkpoint, not the last one, gets used for deployment.</p>
</blockquote>
<p><strong>2. A model has 88% overall accuracy but only 46% recall on one class. What does that tell you?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> The model is solid overall but specifically weak on that class, missing a large share of its actual samples. High overall accuracy can mask this when that class makes up a small fraction of the total data.</p>
</blockquote>
<p><strong>3. Why does the serving transform need to match validation, not training?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Training transforms include random augmentation meant to help the model generalize, not to represent an image "as-is." At inference time, images need consistent processing, matching exactly how the model was evaluated during validation.</p>
</blockquote>
<p><strong>4. Why does file order need manual sorting when labels come from a separate file?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Functions like <code>os.listdir()</code> don't guarantee consistent file ordering across systems. If labels assume files follow a specific numeric order, any mismatch can silently swap images and labels with no error raised.</p>
</blockquote>
<p><strong>5. What's the point of a dataset having an official train/val/test split from its original paper?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> It makes results comparable to other research or experiments using the same dataset. A custom random split makes training numbers hard to compare across different runs or studies.</p>
</blockquote>
<hr />
<p>The two big questions left open at the end of the previous post, how to actually train the model and how to serve it, are answered here in full, from raw data to a callable API endpoint. Full code and the executed notebook are in the <a href="https://github.com/arielshakaramiro/flowers102-efficientnet-classifier">GitHub repo</a>.</p>
<p><em>Part of an ongoing Computer Vision series.</em></p>
]]></content:encoded></item><item><title><![CDATA[Ngajarin Komputer Kenal 102 Jenis Bunga: Dari Training Sampai Jadi API]]></title><description><![CDATA[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. ]]></description><link>https://shaka-ai.hashnode.dev/training-model-serving-flowers102-pytorch</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/training-model-serving-flowers102-pytorch</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 14 Sep 2026 08:29:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/222e170e-45b0-4f2b-8fb5-8a6836114443.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Artikel sebelumnya membahas teorinya: apa itu image classification, cara kerja CNN lewat backbone dan head, sampai cara menyiapkan dataset. Semuanya masih di level konsep.</p>
<p>Sekarang bagian prakteknya. Model dilatih untuk membedakan <strong>102 jenis bunga</strong>, dari daffodil sampai sunflower, memakai dataset asli <strong>Oxford Flowers 102</strong>, lalu dibungkus jadi <strong>API</strong> yang siap dipakai aplikasi lain. Semua angka di artikel ini berasal dari eksekusi nyata di GPU, bukan estimasi. Kode lengkapnya ada di <a href="https://github.com/arielshakaramiro/flowers102-efficientnet-classifier">repo GitHub</a>.</p>
<blockquote>
<p>💡 <strong>Belum baca artikel sebelumnya?</strong> Tulisan ini mengasumsikan konsep backbone/head dan transfer learning sudah familiar. Kotak "Kilas Balik" di beberapa bagian akan membantu kalau belum sempat baca.</p>
</blockquote>
<hr />
<h2>Daftar Isi</h2>
<ol>
<li><p><a href="#studi-kasus-oxford-flowers-102">Studi Kasus: Oxford Flowers 102</a></p>
</li>
<li><p><a href="#persiapan-lingkungan">Persiapan Lingkungan</a></p>
</li>
<li><p><a href="#menyiapkan-dataset">Menyiapkan Dataset</a></p>
</li>
<li><p><a href="#plot-twist-iseng-cek-ulang-skripnya">Plot Twist: Iseng Cek Ulang Skripnya</a></p>
</li>
<li><p><a href="#membangun-model-dengan-transfer-learning">Membangun Model dengan Transfer Learning</a></p>
</li>
<li><p><a href="#training-loop-lengkap">Training Loop Lengkap</a></p>
</li>
<li><p><a href="#hasil-asli-accuracy-precision-recall">Hasil Asli: Accuracy, Precision, Recall</a></p>
</li>
<li><p><a href="#dari-model-ke-api-dengan-fastapi">Dari Model ke API dengan FastAPI</a></p>
</li>
<li><p><a href="#cheat-sheet">Cheat Sheet</a></p>
</li>
<li><p><a href="#uji-pemahaman-kamu">Uji Pemahaman Kamu</a></p>
</li>
</ol>
<hr />
<h2>Studi Kasus: Oxford Flowers 102</h2>
<p>Dataset yang dipakai: <a href="https://www.robots.ox.ac.uk/~vgg/data/flowers/102/"><strong>Oxford Flowers 102</strong></a>, 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.</p>
<p>Backbone yang dipakai: <strong>EfficientNet-B1</strong>, sama seperti yang dibahas di artikel sebelumnya. Arsitektur tidak dirancang dari nol, cukup pakai backbone pretrained dan ganti bagian classifier-nya.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/4b8039f5-f4b1-4227-a0fa-ef72cc5449ae.png" alt="Contoh gambar asli dari dataset Oxford Flowers 102 beserta label kelasnya" style="display:block;margin:0 auto" />

<p><em>Sampel asli dari validation set, lengkap dengan label yang sudah diverifikasi lewat mapping</em> <code>cat_to_name.json</code><em>.</em></p>
<h2>Persiapan Lingkungan</h2>
<pre><code class="language-bash">pip install torch torchvision scikit-learn scipy pandas matplotlib
</code></pre>
<p><code>scikit-learn</code> ditambahkan khusus untuk menghitung metrik evaluasi: accuracy, precision, recall.</p>
<h2>Menyiapkan Dataset</h2>
<p>Dataset Flowers 102 tidak datang dalam format folder per kelas yang rapi seperti contoh di artikel sebelumnya. Labelnya ada di file <code>.mat</code> terpisah (<code>imagelabels.mat</code>). Kesempatan bagus untuk praktik bikin custom <code>Dataset</code>, pola yang sama seperti pendekatan CSV yang sudah dibahas, hanya sumber labelnya beda.</p>
<pre><code class="language-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
</code></pre>
<p>Dua detail yang beda dari transform di artikel sebelumnya:</p>
<ul>
<li><p><code>RandomResizedCrop</code> dan <code>RandomHorizontalFlip</code> 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.</p>
</li>
<li><p><code>Normalize</code> dengan angka <code>[0.485, 0.456, 0.406]</code> dan <code>[0.229, 0.224, 0.225]</code> 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.</p>
</li>
</ul>
<blockquote>
<p>🎯 <strong>Coba Tebak Dulu:</strong> Kenapa augmentasi hanya diterapkan ke data training, bukan data validation?</p>
<p><strong>Jawaban:</strong> 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.</p>
</blockquote>
<h2>Plot Twist: Iseng Cek Ulang Skripnya</h2>
<p>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.</p>
<ol>
<li><p><strong>Urutan file bisa tidak sinkron dengan label.</strong> Skrip asli memakai <code>os.listdir()</code> polos untuk membaca daftar file gambar. Masalahnya, <code>os.listdir()</code> tidak menjamin urutan terurut di semua sistem, padahal file label (<code>imagelabels.mat</code>) mengasumsikan urutan <code>image_00001.jpg, image_00002.jpg, ...</code>. Kalau urutannya meleset, gambar dan label bisa tertukar tanpa error apa pun. Diuji langsung dengan data kecil: benar saja, urutan <code>os.listdir()</code> polos beda dari <code>sorted()</code>. Perbaikannya simpel, tinggal bungkus dengan <code>sorted(...)</code>.</p>
</li>
<li><p><strong>Augmentasi ikut bocor ke data validasi.</strong> 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.</p>
</li>
<li><p><strong>Split train/val tidak pakai split resmi.</strong> Dataset Flowers 102 sebenarnya sudah punya split resmi dari paper aslinya (<code>setid.mat</code>), yang bisa dipakai supaya hasil bisa dibandingkan dengan riset lain. Skrip asli malah split random sendiri. Versi final memakai split resmi tersebut.</p>
</li>
<li><p><code>pretrained=True</code> <strong>sudah deprecated</strong> di versi torchvision terbaru, diganti ke API <code>weights=</code>.</p>
</li>
<li><p><strong>Nama 102 kelas bunga.</strong> Draf pertama sempat mengetik ulang daftar nama secara manual dari ingatan, dan ternyata salah hitung: 104 nama, bukan 102. Ketahuan lewat <code>assert</code> sebelum sempat dipakai kemana-mana. Solusi finalnya mengambil mapping nama langsung dari referensi publik (<code>cat_to_name.json</code>) saat runtime, bukan mengandalkan hasil ketikan.</p>
</li>
</ol>
<p>Semua perbaikan ini sudah diverifikasi, dan notebook lengkapnya (beserta hasil eksekusi asli) ada di repo GitHub yang ditautkan di awal artikel.</p>
<h2>Membangun Model dengan Transfer Learning</h2>
<p>Konsep transfer learning dari artikel sebelumnya, sekarang diterapkan di kasus nyata.</p>
<blockquote>
<p>🔁 <strong>Kilas Balik:</strong> 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.</p>
</blockquote>
<pre><code class="language-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)
)
</code></pre>
<p>Backbone EfficientNet-B1 yang sudah pretrained dipakai apa adanya, hanya bagian <code>classifier</code> 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).</p>
<h2>Training Loop Lengkap</h2>
<p>Training berjalan 15 epoch, dengan pencatatan loss, accuracy, precision, dan recall di tiap epoch, plus penyimpanan checkpoint model terbaik.</p>
<pre><code class="language-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 &gt; 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)
</code></pre>
<p>Dua detail yang menjawab pertanyaan yang mungkin muncul dari artikel sebelumnya:</p>
<ul>
<li><p><code>model.train()</code> vs <code>model.eval()</code> adalah implementasi konkret dari "set mode training" yang dibahas sebelumnya. Di fase <code>val</code>, <code>torch.set_grad_enabled(False)</code> otomatis mematikan perhitungan gradien.</p>
</li>
<li><p>Dua file checkpoint disimpan: <code>best.pt</code> untuk model dengan akurasi validasi terbaik sejauh proses training, <code>last.pt</code> untuk kondisi model di epoch paling akhir.</p>
</li>
</ul>
<h2>Hasil Asli: Accuracy, Precision, Recall</h2>
<p>Berikut angka sebenarnya dari training 15 epoch di GPU (Google Colab, Tesla T4):</p>
<table>
<thead>
<tr>
<th>Metrik</th>
<th>Nilai</th>
</tr>
</thead>
<tbody><tr>
<td>Best validation accuracy</td>
<td>89,41% (epoch 8)</td>
</tr>
<tr>
<td>Test accuracy (6.149 gambar)</td>
<td>87,95%</td>
</tr>
<tr>
<td>Test precision (weighted)</td>
<td>89,76%</td>
</tr>
<tr>
<td>Test recall (weighted)</td>
<td>87,95%</td>
</tr>
</tbody></table>
<p>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 <code>best.pt</code> yang dipakai untuk evaluasi akhir, bukan <code>last.pt</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/962d8a30-ecfd-44d6-9ee0-d9f51f6a2e30.png" alt="Kurva loss dan akurasi training vs validasi selama 15 epoch" style="display:block;margin:0 auto" />

<p><em>Val accuracy (oranye) memuncak di epoch 8, lalu val loss mulai naik lagi meski train loss terus turun, tanda klasik overfitting.</em></p>
<p>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 <em>bird of paradise</em> dan <em>black-eyed susan</em>. Sebagian kelas lain jauh lebih sulit, seperti <em>mallow</em> dengan precision 0,41 dan <em>japanese anemone</em> 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).</p>
<blockquote>
<p>📌 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.</p>
</blockquote>
<h2>Dari Model ke API dengan FastAPI</h2>
<p>Model sudah dilatih dan checkpoint terbaik (<code>best.pt</code>) sudah tersimpan. Bagian selanjutnya: bagaimana model ini dipakai di dunia nyata.</p>
<pre><code class="language-bash">pip install fastapi uvicorn pillow torch torchvision
</code></pre>
<pre><code class="language-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)
</code></pre>
<p>Tiga hal yang wajib diperhatikan supaya serving-nya tidak meleset:</p>
<ol>
<li><p><strong>Arsitektur model harus identik</strong> dengan yang dipakai saat training, termasuk jumlah kelas dan struktur classifier. Kalau beda, <code>load_state_dict</code> bisa gagal, atau lebih berbahaya lagi, berhasil dimuat tapi menghasilkan prediksi yang salah tanpa error.</p>
</li>
<li><p><strong>Transform harus sama persis</strong> dengan yang dipakai saat validasi, bukan training. Pakai <code>Resize</code> dan <code>CenterCrop</code> yang konsisten, bukan augmentasi random.</p>
</li>
<li><p><code>model.eval()</code> <strong>dan</strong> <code>torch.no_grad()</code> memastikan model dalam mode inference murni.</p>
</li>
</ol>
<p>Menjalankan servernya:</p>
<pre><code class="language-bash">uvicorn main:app --reload
</code></pre>
<pre><code class="language-bash">curl -X POST "http://localhost:8000/predict/" -F "file=@path_to_your_image.jpg"
</code></pre>
<pre><code class="language-json">{"predicted_class": "sunflower"}
</code></pre>
<p>Model sekarang bisa dipanggil dari aplikasi apa pun, tinggal kirim gambar ke endpoint <code>/predict/</code>.</p>
<h2>Cheat Sheet</h2>
<ul>
<li><p>[ ] Custom <code>Dataset</code> dipakai saat label tidak datang dalam format folder rapi</p>
</li>
<li><p>[ ] Urutan file harus di-sort manual kalau labelnya berasal dari array terpisah (mis. file <code>.mat</code>)</p>
</li>
<li><p>[ ] Augmentasi hanya untuk data training, validasi tetap konsisten</p>
</li>
<li><p>[ ] Split resmi dari paper dataset lebih baik dipakai daripada split random sendiri</p>
</li>
<li><p>[ ] Simpan dua checkpoint: <code>best.pt</code> dan <code>last.pt</code></p>
</li>
<li><p>[ ] Val accuracy yang memuncak lalu menurun adalah tanda overfitting, bukan bug</p>
</li>
<li><p>[ ] Precision dan recall per kelas melengkapi accuracy, terutama saat performa antar kelas tidak merata</p>
</li>
<li><p>[ ] Arsitektur model saat serving harus identik dengan saat training</p>
</li>
<li><p>[ ] Transform saat serving mengikuti transform validasi, bukan training</p>
</li>
</ul>
<h2>Uji Pemahaman Kamu</h2>
<p><strong>1. Val accuracy naik sampai epoch 8, lalu stagnan dan sedikit menurun sampai epoch 15. Apa artinya?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 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.</p>
</blockquote>
<p><strong>2. Model punya akurasi keseluruhan 88%, tapi recall untuk satu kelas hanya 46%. Apa artinya?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 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.</p>
</blockquote>
<p><strong>3. Kenapa transform saat serving harus sama dengan transform validasi, bukan training?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 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.</p>
</blockquote>
<p><strong>4. Kenapa urutan file gambar perlu di-sort manual kalau labelnya dari file terpisah?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Fungsi seperti <code>os.listdir()</code> 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.</p>
</blockquote>
<p><strong>5. Apa gunanya dataset punya split resmi (train/val/test) dari paper aslinya?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 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.</p>
</blockquote>
<hr />
<p>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 <a href="https://github.com/arielshakaramiro/flowers102-efficientnet-classifier">repo GitHub</a>.</p>
<p><em>Bagian dari seri belajar Computer Vision.</em></p>
]]></content:encoded></item><item><title><![CDATA[Teaching a Computer to "See": A Practical Guide to Image Classification with CNNs & PyTorch]]></title><description><![CDATA[Some grocery apps can identify a vegetable just from a photo. Point the camera at ginger, galangal, or turmeric (three roots people mix up constantly at the market) and the app tells you which is whic]]></description><link>https://shaka-ai.hashnode.dev/image-classification-cnn-pytorch-guide</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/image-classification-cnn-pytorch-guide</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 14 Sep 2026 08:27:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/d97d673a-8d28-4187-aee6-89ab378e7fca.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Some grocery apps can identify a vegetable just from a photo. Point the camera at ginger, galangal, or turmeric (three roots people mix up constantly at the market) and the app tells you which is which. Underneath, there's one core idea at work: <strong>image classification</strong>.</p>
<p>This post breaks that idea down from scratch, starting with pixels and ending with a CNN ready to train in PyTorch.</p>
<blockquote>
<p>💡 <strong>How to read this:</strong> a few "Guess First" boxes are scattered through the post. Take a guess before checking the answer, it helps the concept stick.</p>
</blockquote>
<hr />
<h2>Table of Contents</h2>
<ol>
<li><p><a href="#what-image-classification-actually-is">What Image Classification Actually Is</a></p>
</li>
<li><p><a href="#computer-vision-vs-image-processing">Computer Vision vs Image Processing</a></p>
</li>
<li><p><a href="#cnn-anatomy-backbone-vs-head">CNN Anatomy: Backbone vs Head</a></p>
</li>
<li><p><a href="#getting-comfortable-with-pytorch">Getting Comfortable with PyTorch</a></p>
</li>
<li><p><a href="#building-a-cnn-from-scratch">Building a CNN from Scratch</a></p>
</li>
<li><p><a href="#transfer-learning">Transfer Learning</a></p>
</li>
<li><p><a href="#preparing-a-dataset">Preparing a Dataset</a></p>
</li>
<li><p><a href="#the-training-loop">The Training Loop</a></p>
</li>
<li><p><a href="#cheat-sheet">Cheat Sheet</a></p>
</li>
<li><p><a href="#test-your-understanding">Test Your Understanding</a></p>
</li>
</ol>
<hr />
<h2>What Image Classification Actually Is</h2>
<p>The definition is simple: feed in an image, get back what it's a picture of. Input is an image, output is a class or label.</p>
<p>That simple idea powers a lot of everyday applications: face recognition, disease detection in medical scans, object recognition in self-driving systems, and yes, the grocery app that tells vegetables apart.</p>
<p>Before going further, one thing is worth internalizing: to a computer, an image is just numbers.</p>
<ul>
<li><p>An image is made of <strong>pixels</strong>, the smallest unit of a picture.</p>
</li>
<li><p>On a computer, an image becomes a <strong>matrix</strong>: rows by columns, each cell holding a value for the light intensity at that point.</p>
</li>
<li><p>A color image is really three matrices stacked together: Red (R), Green (G), Blue (B), forming a single 3-dimensional block of data.</p>
</li>
</ul>
<blockquote>
<p>🎯 <strong>Guess First:</strong> A 100×100 color image is made up of how many matrices?</p>
<p><strong>Jawaban:</strong> Three (R, G, B), each 100×100, stacked into a single 100×100×3 block.</p>
</blockquote>
<h2>Computer Vision vs Image Processing</h2>
<p>These two terms get mixed up often, but they serve different goals.</p>
<table>
<thead>
<tr>
<th></th>
<th>Image Processing</th>
<th>Computer Vision</th>
</tr>
</thead>
<tbody><tr>
<td>Goal</td>
<td>Manipulate how an image looks</td>
<td>Interpret what an image contains</td>
</tr>
<tr>
<td>Input</td>
<td>Image</td>
<td>Image or video</td>
</tr>
<tr>
<td>Output</td>
<td>A modified image</td>
<td>An interpretation: description, coordinates, class</td>
</tr>
<tr>
<td>Examples</td>
<td>Sharpening, blurring, edge detection</td>
<td>Object detection, image classification</td>
</tr>
</tbody></table>
<p>One image processing technique underpins CNNs directly: <strong>convolution</strong>, sliding a kernel (a small matrix) across an image to produce a specific effect.</p>
<p>A natural question follows: do the numbers inside that kernel have to be set by hand? They don't. Kernel values start out randomly initialized, then get updated automatically through <strong>training</strong> until the model settles on the best values for its data. A hand-designed kernel is fixed and doesn't adapt; a trained one shapes itself around whatever data it sees.</p>
<p>That's the foundation for this post's main subject: <strong>image classification with CNNs (Convolutional Neural Networks)</strong>.</p>
<h2>CNN Anatomy: Backbone vs Head</h2>
<p>A CNN works in two phases: look closely first, then decide. These are called the <strong>Backbone</strong> and the <strong>Head</strong>.</p>
<h3>The Backbone, feature extractor</h3>
<p>The backbone learns visual features from an image: edges, corners, textures, patterns, eventually objects. It works progressively. Early layers pick up simple features like lines and basic shapes. Deeper layers capture increasingly complex, abstract features, from object parts to whole objects. As the network goes deeper, the spatial size of the image shrinks while the information packed into it gets denser.</p>
<p>Each convolutional layer contains kernels (filters) that sweep across the image and produce a feature map. Two parameters matter most:</p>
<ul>
<li><p><code>in_channels</code>, the number of input channels. For the first layer receiving a color image directly, this is 3 (R, G, B).</p>
</li>
<li><p><code>out_channels</code>, the number of output channels, equal to the number of filters used. Use 32 filters, get 32 stacked feature maps out.</p>
</li>
</ul>
<p>Multiple filters help because each one learns to look for something different: texture, color, edges. More filters mean the model captures a richer range of visual patterns.</p>
<blockquote>
<p>⚠️ <strong>A hard rule when stacking layers:</strong> a layer's <code>in_channels</code> (or <code>in_features</code>) must match the previous layer's <code>out_channels</code> (or <code>out_features</code>). Skip this and the code throws an error the moment it runs. It's the single most common mistake when designing a CNN architecture for the first time.</p>
</blockquote>
<p>A linear layer only accepts a 1-dimensional vector, while an image is a matrix. Force that combination and the image needs to be <em>flattened</em> first. Example: a 28×28 image flattens into a vector of 784 (28 times 28). For a large image, say 1000×1000 pixels, that flattened vector balloons and becomes impractical.</p>
<p>Convolutional layers are useful precisely because they accept matrix input directly. By the time the gradual extraction process is done, the representation is already compact and information-dense before it gets flattened.</p>
<p>There's no need to design a backbone from scratch either. Proven architectures already exist and can be reused directly: ResNet, VGG, EfficientNet.</p>
<h3>The Head, decision maker</h3>
<p>Once the backbone finishes extracting features, the result (a vector, after flattening) moves into the <strong>head</strong> for classification. The head is typically one or more fully connected (FC) layers.</p>
<p>Every neuron in an FC layer connects to every neuron in the layer before it, hence "fully connected." The final output layer needs exactly as many neurons as there are classes. Classifying digits 0 through 9 means 10 output neurons; a 15-category vegetable dataset means 15.</p>
<p>The last layer usually applies <strong>softmax</strong>, converting raw outputs into a probability for each class. The class with the highest probability becomes the model's prediction.</p>
<blockquote>
<p>🎯 <strong>Guess First:</strong> A model classifies animals into 3 classes: cat, dog, panda. Its softmax output is <code>[0.15, 0.15, 0.70]</code>. What does it predict?</p>
<p><strong>Jawaban:</strong> Panda, since it has the highest probability of the three.</p>
</blockquote>
<p>In short: the backbone turns a raw image into a feature representation through convolution, pooling, and non-linear activation. The head takes that representation and picks the most likely class based on softmax probabilities.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/2d2c47aa-18f8-42ec-9f42-17500662abd6.png" alt="Backbone to Head flow in a CNN" style="display:block;margin:0 auto" />

<p><em>A raw image gets distilled into shrinking feature maps, flattened into a vector, then classified through FC layers into a final prediction.</em></p>
<h2>Getting Comfortable with PyTorch</h2>
<p>PyTorch is a tool for building and training deep learning models. Its API feels a lot like NumPy, so prior NumPy experience speeds things up considerably. The difference is that PyTorch plugs directly into hardware accelerators: CUDA GPUs, Apple Silicon, or plain CPU.</p>
<p>A few basics worth knowing upfront:</p>
<ul>
<li><p>Default data types: integers become <code>int64</code>, decimals become <code>float32</code>.</p>
</li>
<li><p>A tensor's default device is CPU. Moving it to GPU is a single call: <code>.to('cuda')</code>.</p>
</li>
<li><p><strong>Autograd</strong> is PyTorch's mechanism for computing gradients automatically, the core of training. That computation happens automatically; there's no manual gradient math involved.</p>
</li>
</ul>
<pre><code class="language-python">import torch

x = torch.randn(2, 2)
x = x.to('cuda')

a = torch.ones(2, 3)
b = torch.zeros(2, 3)
c = a + b
</code></pre>
<h2>Building a CNN from Scratch</h2>
<h3>Linear Layer</h3>
<pre><code class="language-python">import torch.nn as nn

layer = nn.Linear(in_features=5, out_features=10)
</code></pre>
<p>This layer takes 5 input values and produces 10 output values. Because it's fully connected, there are 5 times 10 connections behind the scenes.</p>
<h3>A Custom Model</h3>
<p>Building a custom architecture means writing a class that extends <code>nn.Module</code>. This is mandatory: PyTorch is designed so that any model recognized this way can actually go through training.</p>
<pre><code class="language-python">import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self, num_classes):
        super().__init__()
        self.block1 = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=10, kernel_size=3),
            nn.ReLU(),
        )
        self.block2 = nn.Sequential(
            nn.Conv2d(in_channels=10, out_channels=10, kernel_size=3),
            nn.ReLU(),
        )
        self.flatten = nn.Flatten()
        self.classifier = nn.Linear(in_features=10 * 24 * 24, out_features=num_classes)

    def forward(self, x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.flatten(x)
        x = self.classifier(x)
        return x
</code></pre>
<p>Three things worth double-checking:</p>
<ol>
<li><p>Block two's <code>in_channels</code> (10) has to match block one's <code>out_channels</code> (10).</p>
</li>
<li><p><code>in_features</code> on the classifier has to match the flattened output size. For a 28×28 input with no padding, each Conv2d with kernel size 3 shrinks each side by 2 pixels: 28 becomes 26, then 26 becomes 24. That leaves a 24×24 map, so <code>in_features</code> = 10 times 24 times 24. When the math gets confusing, printing the output shape after each block is the fastest way to check.</p>
</li>
<li><p>The final layer's <code>out_features</code> has to match the number of classes in the dataset.</p>
</li>
</ol>
<h2>Transfer Learning</h2>
<p>Rather than designing an architecture from zero, a pretrained, already-proven backbone can be reused directly. <code>torchvision</code>'s model zoo has plenty: ResNet, VGG, EfficientNet.</p>
<p>The pattern: load the backbone, then override its final classifier layer to match the target number of classes. Layer naming differs across architectures.</p>
<pre><code class="language-python">import torch.nn as nn
from torchvision import models

# EfficientNet-B1
model = models.efficientnet_b1(weights="IMAGENET1K_V1")
model.classifier[1] = nn.Linear(in_features=1280, out_features=15)

# ResNet-50
model = models.resnet50(weights="IMAGENET1K_V1")
model.fc = nn.Linear(in_features=2048, out_features=15)
</code></pre>
<p>These architectures already extract general visual features well. The remaining work is teaching the model to recognize the specific classes in the target dataset.</p>
<h2>Preparing a Dataset</h2>
<p>Two common approaches exist for organizing data before training.</p>
<h3>Folder per Class</h3>
<pre><code class="language-plaintext">dataset/
├── train/
│   ├── class1/
│   │   ├── image1.jpg
│   │   └── image2.jpg
│   └── class2/
│       ├── image3.jpg
│       └── image4.jpg
├── val/
│   ├── class1/
│   └── class2/
</code></pre>
<p>Each image sits in the folder matching its class. This structure is recognized directly by built-in framework utilities: <code>ImageFolder</code> in PyTorch, <code>ImageDataGenerator</code> in TensorFlow.</p>
<pre><code class="language-python">from torchvision import datasets, transforms
from torch.utils.data import DataLoader

transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor()
])

train_dataset = datasets.ImageFolder(root='dataset/train', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
</code></pre>
<p>The upside is simplicity and broad framework support. The downside shows up with random filenames or extra metadata needs, like multi-label data.</p>
<h3>CSV File</h3>
<p>The alternative is a CSV listing image paths alongside their labels.</p>
<pre><code class="language-plaintext">image_path,class_label
dataset/images/image1.jpg,0
dataset/images/image2.jpg,1
dataset/images/image3.jpg,0
</code></pre>
<pre><code class="language-python">import pandas as pd
from PIL import Image
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms

class CustomDataset(Dataset):
    def __init__(self, csv_file, transform=None):
        self.data = pd.read_csv(csv_file)
        self.transform = transform

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

    def __getitem__(self, idx):
        img_path = self.data.iloc[idx, 0]
        image = Image.open(img_path)
        label = int(self.data.iloc[idx, 1])
        if self.transform:
            image = self.transform(image)
        return image, label

transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor()
])

train_dataset = CustomDataset(csv_file='dataset/train_labels.csv', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
</code></pre>
<p>This is more flexible for datasets carrying extra metadata or scattered across an unorganized directory structure. The tradeoff is custom code, since no framework ships a built-in loader for this shape.</p>
<blockquote>
<p>📌 <strong>Which one to pick?</strong> Clean, simple, already-organized-by-category data: use folders. Complex data with extra metadata or messy file layouts: use CSV.</p>
</blockquote>
<h3>Dataset vs DataLoader</h3>
<p>A <strong>Dataset</strong> holds all the data, images and labels together. Pulling one sample from it returns one image plus one label. Feeding an entire dataset to a model at once is usually impossible given GPU memory limits.</p>
<p>A <strong>DataLoader</strong> splits the dataset into <strong>batches</strong>, small chunks processed incrementally. <code>DataLoader(batch_size=128)</code> produces batches shaped <code>[128, 3, 64, 64]</code>: 128 images, 3 RGB channels, 64×64 pixels, plus 128 matching labels.</p>
<h2>The Training Loop</h2>
<p>Each epoch (one full pass through the training data) runs through the following steps.</p>
<pre><code class="language-python">model.train()
train_loss, train_acc = 0, 0

for images, labels in train_loader:
    images, labels = images.to(device), labels.to(device)

    outputs = model(images)
    loss = criterion(outputs, labels)

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    train_loss += loss.item()
    train_acc += (outputs.argmax(1) == labels).float().mean().item()

train_loss /= len(train_loader)
train_acc /= len(train_loader)
</code></pre>
<p>The test/validation step follows the same shape, minus <code>loss.backward()</code> and <code>optimizer.step()</code>. That step is pure evaluation, not learning.</p>
<p>The loss function for multi-class problems: <strong>Cross Entropy Loss</strong>.</p>
<p>Finally, save the model whenever it improves, so it can be reused later without retraining from zero.</p>
<pre><code class="language-python">if test_acc &gt; best_acc:
    best_acc = test_acc
    torch.save(model.state_dict(), 'best_model.pth')
</code></pre>
<h2>Cheat Sheet</h2>
<ul>
<li><p>[ ] A CNN is a Backbone (feature extraction) plus a Head (classification)</p>
</li>
<li><p>[ ] A layer's <code>in_channels</code>/<code>in_features</code> must match the previous layer's <code>out_channels</code>/<code>out_features</code></p>
</li>
<li><p>[ ] <code>out_channels</code> equals the number of filters used</p>
</li>
<li><p>[ ] The final output layer needs one neuron per class</p>
</li>
<li><p>[ ] Linear layers need 1D input, so images must be flattened first</p>
</li>
<li><p>[ ] Convolutional layers accept matrix input directly</p>
</li>
<li><p>[ ] Kernel values come from training, not manual design</p>
</li>
<li><p>[ ] A Dataset holds the data; a DataLoader splits it into batches</p>
</li>
<li><p>[ ] Transfer learning reuses a pretrained backbone, overriding just the classifier</p>
</li>
<li><p>[ ] The multi-class loss function of choice: Cross Entropy Loss</p>
</li>
</ul>
<h2>Test Your Understanding</h2>
<p><strong>1. Why can't an image go straight into a linear layer?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> A linear layer only accepts a 1-dimensional vector, while an image is a matrix. The fix is to flatten it first, or use a convolutional layer instead, which is built to accept matrix input directly.</p>
</blockquote>
<p><strong>2. If a convolutional layer uses 64 filters, what's its out_channels?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 64 — <code>out_channels</code> always equals the number of filters used.</p>
</blockquote>
<p><strong>3. A dataset has 20 fruit classes. How many neurons should the final output layer have?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 20 — the number of output neurons must match the number of classes.</p>
</blockquote>
<p><strong>4. What's the difference between the training step and the test/validation step in a training loop?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> They follow the same shape: forward pass, compute loss. The difference is the test/validation step skips optimization (backpropagation and parameter updates), since that step is purely for evaluation, not learning.</p>
</blockquote>
<p><strong>5. When does a CSV-based approach make more sense than folder-per-class?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> When the dataset carries extra information (metadata, multi-label) or the images live scattered across a directory structure that isn't neatly organized by class. CSV trades a bit of custom code for more flexibility.</p>
</blockquote>
<hr />
<p>The next post picks up from <a href="https://shaka-ai.hashnode.dev/flower-classification-training-fastapi-serving">here</a>: training a real model to tell 102 flower species apart, all the way through to an API another app can call.</p>
<p><em>Part of an ongoing Computer Vision series.</em></p>
]]></content:encoded></item><item><title><![CDATA[Ngajarin Komputer "Melihat": Panduan Lengkap Image Classification dengan CNN & PyTorch]]></title><description><![CDATA[Ada aplikasi belanja yang bisa langsung mengenali jenis sayur dari foto. Kamera diarahkan ke jahe, lengkuas, atau kunyit, tiga bahan yang sering tertukar di pasar, dan aplikasinya langsung menjawab. D]]></description><link>https://shaka-ai.hashnode.dev/image-classification-cnn-pytorch</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/image-classification-cnn-pytorch</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 14 Sep 2026 08:24:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/211348ef-f577-4fbd-a3b4-56436c79d2a2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ada aplikasi belanja yang bisa langsung mengenali jenis sayur dari foto. Kamera diarahkan ke jahe, lengkuas, atau kunyit, tiga bahan yang sering tertukar di pasar, dan aplikasinya langsung menjawab. Di balik itu semua ada satu konsep: <strong>image classification</strong>.</p>
<p>Artikel ini membongkar konsepnya dari nol: mulai dari apa itu piksel, sampai model CNN siap dilatih dengan PyTorch.</p>
<blockquote>
<p>💡 <strong>Cara baca artikel ini:</strong> ada beberapa kotak "Coba Tebak Dulu" di sepanjang tulisan. Tebak dulu sebelum lihat jawabannya, biar lebih nempel.</p>
</blockquote>
<hr />
<h2>Daftar Isi</h2>
<ol>
<li><p><a href="#apa-itu-image-classification-sebenarnya">Apa Itu Image Classification, Sebenarnya?</a></p>
</li>
<li><p><a href="#computer-vision-vs-image-processing">Computer Vision vs Image Processing</a></p>
</li>
<li><p><a href="#anatomi-cnn-backbone-vs-head">Anatomi CNN: Backbone vs Head</a></p>
</li>
<li><p><a href="#kenalan-sama-pytorch">Kenalan Sama PyTorch</a></p>
</li>
<li><p><a href="#membangun-cnn-dari-nol">Membangun CNN dari Nol</a></p>
</li>
<li><p><a href="#transfer-learning">Transfer Learning</a></p>
</li>
<li><p><a href="#menyiapkan-dataset">Menyiapkan Dataset</a></p>
</li>
<li><p><a href="#training-loop">Training Loop</a></p>
</li>
<li><p><a href="#cheat-sheet">Cheat Sheet</a></p>
</li>
<li><p><a href="#uji-pemahaman-kamu">Uji Pemahaman Kamu</a></p>
</li>
</ol>
<hr />
<h2>Apa Itu Image Classification, Sebenarnya?</h2>
<p>Definisinya sederhana. Kasih gambar, sistem menjawab gambar itu tentang apa. Input berupa gambar, output berupa kelas atau label.</p>
<p>Dari definisi sesederhana itu, lahir banyak aplikasi yang dipakai sehari-hari: face recognition, deteksi penyakit dari gambar medis, pengenalan objek pada kendaraan otonom, sampai aplikasi belanja yang mengenali jenis sayur dari foto.</p>
<p>Sebelum masuk lebih dalam, ada satu hal dasar yang perlu dipahami. Bagi komputer, gambar itu sekadar angka.</p>
<ul>
<li><p>Gambar terdiri dari <strong>piksel</strong>, bagian terkecil dari sebuah gambar.</p>
</li>
<li><p>Di komputer, gambar direpresentasikan sebagai <strong>matriks</strong>: baris dikali kolom, tiap sel berisi angka intensitas cahaya di titik itu.</p>
</li>
<li><p>Gambar berwarna adalah gabungan tiga matriks: Merah (R), Hijau (G), Biru (B), ditumpuk jadi satu struktur 3 dimensi.</p>
</li>
</ul>
<blockquote>
<p>🎯 <strong>Coba Tebak Dulu:</strong> Kalau sebuah gambar berwarna berukuran 100×100 piksel, ada berapa banyak matriks yang menyusunnya?</p>
<p><strong>Jawaban:</strong> 3 matriks (R, G, B), masing-masing berukuran 100×100. Ditumpuk jadi satu blok data berukuran 100×100×3.</p>
</blockquote>
<h2>Computer Vision vs Image Processing</h2>
<p>Dua istilah ini sering ketuker, padahal tujuannya beda.</p>
<table>
<thead>
<tr>
<th></th>
<th>Image Processing</th>
<th>Computer Vision</th>
</tr>
</thead>
<tbody><tr>
<td>Tujuan</td>
<td>Memanipulasi tampilan gambar</td>
<td>Menginterpretasi isi gambar</td>
</tr>
<tr>
<td>Input</td>
<td>Gambar</td>
<td>Gambar atau video</td>
</tr>
<tr>
<td>Output</td>
<td>Gambar versi olahan</td>
<td>Interpretasi: deskripsi, koordinat, kelas</td>
</tr>
<tr>
<td>Contoh</td>
<td>Sharpening, blur, edge detection</td>
<td>Object detection, image classification</td>
</tr>
</tbody></table>
<p>Salah satu teknik image processing yang jadi fondasi CNN adalah <strong>convolution</strong>: proses menggeser sebuah kernel (matriks kecil) ke seluruh bagian gambar untuk menghasilkan efek tertentu.</p>
<p>Pertanyaan yang sering muncul: apakah angka-angka di kernel itu harus ditentukan manual satu per satu? Jawabannya tidak. Nilai kernel di-<em>initialize</em> secara random, lalu diperbarui otomatis lewat proses <strong>training</strong> sampai model menemukan sendiri kernel terbaik untuk datanya. Kernel yang ditentukan manual sifatnya kaku dan tidak adaptif. Lewat training, kemampuan model dalam mengekstrak fitur menyesuaikan dengan data yang dipelajari.</p>
<p>Inilah yang mendasari topik utama artikel ini: <strong>image classification dengan CNN (Convolutional Neural Network)</strong>.</p>
<h2>Anatomi CNN: Backbone vs Head</h2>
<p>CNN bekerja lewat dua fase: melihat detail dulu, baru memutuskan. Dua fase ini disebut <strong>Backbone</strong> dan <strong>Head</strong>.</p>
<h3>Backbone, si pengekstrak fitur</h3>
<p>Backbone bertugas mempelajari fitur visual dari gambar: tepi, sudut, tekstur, pola, sampai objek. Cara kerjanya bertahap. Layer awal menangkap fitur sederhana seperti garis dan bentuk dasar. Layer yang lebih dalam menangkap fitur yang makin kompleks, dari bagian objek sampai objek utuh. Seiring makin dalam jaringannya, ukuran spasial gambar makin mengecil, sementara informasinya makin padat.</p>
<p>Di dalam tiap convolutional layer ada kernel (filter) yang menyapu gambar dan menghasilkan feature map. Dua parameter kuncinya:</p>
<ul>
<li><p><code>in_channels</code>, jumlah channel input. Untuk layer pertama yang langsung menerima gambar berwarna, nilainya 3 (R, G, B).</p>
</li>
<li><p><code>out_channels</code>, jumlah channel output, sama dengan jumlah filter yang dipakai. Pakai 32 filter, hasilnya 32 feature map yang ditumpuk.</p>
</li>
</ul>
<p>Filter yang banyak berguna karena tiap filter belajar mencari hal yang berbeda. Ada yang fokus ke tekstur, ada yang ke warna, ada yang ke tepi objek. Semakin banyak filter, semakin kaya aspek visual yang bisa ditangkap model.</p>
<blockquote>
<p>⚠️ <strong>Aturan wajib saat menyusun layer:</strong> <code>in_channels</code> (atau <code>in_features</code>) suatu layer harus sama dengan <code>out_channels</code> (atau <code>out_features</code>) layer sebelumnya. Kalau tidak, siap-siap ketemu error saat kode dijalankan. Ini kesalahan paling umum pemula saat pertama kali merancang arsitektur CNN sendiri.</p>
</blockquote>
<p>Linear layer hanya menerima input berbentuk vektor 1 dimensi, sementara gambar berbentuk matriks. Kalau tetap mau pakai linear layer, gambar harus di-<em>flatten</em> dulu. Contoh: gambar 28×28 di-<em>flatten</em> jadi vektor berukuran 784 (28 dikali 28). Untuk gambar besar, katakanlah 1000×1000 piksel, hasil flatten-nya jadi raksasa dan tidak efisien.</p>
<p>Convolutional layer istimewa karena bisa langsung menerima input berbentuk matriks. Lewat proses ekstraksi bertahap, ukurannya sudah mengecil dan padat informasi sebelum akhirnya di-flatten.</p>
<p>Tidak perlu merancang backbone dari nol juga. Sudah ada arsitektur populer yang terbukti bagus dan tinggal dipakai: ResNet, VGG, EfficientNet.</p>
<h3>Head, si pengambil keputusan</h3>
<p>Setelah backbone selesai mengekstrak fitur, hasilnya (berupa vektor setelah di-flatten) diteruskan ke <strong>head</strong> untuk diklasifikasikan. Head biasanya berupa satu atau beberapa fully connected (FC) layer.</p>
<p>Tiap neuron di FC layer terhubung ke semua neuron di layer sebelumnya, makanya disebut <em>fully connected</em>. Layer output terakhir wajib punya jumlah neuron sama dengan jumlah kelas. Klasifikasi digit 0 sampai 9 berarti 10 neuron output, dataset 15 jenis sayuran berarti 15 neuron output.</p>
<p>Layer terakhir biasanya pakai <strong>softmax</strong>, yang mengubah nilai output jadi probabilitas tiap kelas. Kelas dengan probabilitas tertinggi jadi jawaban model.</p>
<blockquote>
<p>🎯 <strong>Coba Tebak Dulu:</strong> Model dilatih untuk klasifikasi hewan dengan 3 kelas: kucing, anjing, panda. Output softmax-nya <code>[0.15, 0.15, 0.70]</code>. Model memprediksi gambar itu apa?</p>
<p><strong>Jawaban:</strong> Panda, karena nilai probabilitasnya paling tinggi dibanding kucing dan anjing.</p>
</blockquote>
<p>Alur singkatnya: backbone mengubah gambar mentah jadi representasi fitur lewat konvolusi, pooling, dan aktivasi non-linear. Head menerima fitur itu dan memutuskan kelas paling sesuai berdasarkan probabilitas softmax.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/dbdf35b5-9162-4193-84a5-dd44ff22a7e1.png" alt="Alur Backbone ke Head pada CNN" style="display:block;margin:0 auto" />

<p><em>Gambar mentah diekstrak jadi feature map yang mengecil, di-flatten jadi vektor, lalu diklasifikasikan lewat FC layer sampai keluar prediksi.</em></p>
<h2>Kenalan Sama PyTorch</h2>
<p>PyTorch adalah tools untuk membangun dan melatih model deep learning. Konsepnya mirip NumPy, jadi kalau sudah familiar dengan NumPy, proses belajarnya jauh lebih cepat. Bedanya, PyTorch sudah terintegrasi dengan akselerator hardware: CUDA GPU, Apple Silicon, atau tetap CPU.</p>
<p>Beberapa hal dasar yang perlu diketahui:</p>
<ul>
<li><p>Data type default: bilangan bulat jadi <code>int64</code>, bilangan desimal jadi <code>float32</code>.</p>
</li>
<li><p>Device default sebuah tensor adalah CPU. Pindah ke GPU tinggal panggil <code>.to('cuda')</code>.</p>
</li>
<li><p><strong>Autograd</strong> adalah mekanisme PyTorch untuk menghitung gradien secara otomatis, inti dari proses training. Perhitungan gradien ini sudah otomatis dilakukan PyTorch, tidak perlu dihitung manual.</p>
</li>
</ul>
<pre><code class="language-python">import torch

x = torch.randn(2, 2)
x = x.to('cuda')

a = torch.ones(2, 3)
b = torch.zeros(2, 3)
c = a + b
</code></pre>
<h2>Membangun CNN dari Nol</h2>
<h3>Linear Layer</h3>
<pre><code class="language-python">import torch.nn as nn

layer = nn.Linear(in_features=5, out_features=10)
</code></pre>
<p>Layer ini menerima 5 nilai input dan mengeluarkan 10 nilai output. Karena fully connected, ada 5 dikali 10 koneksi di baliknya.</p>
<h3>Custom Model</h3>
<p>Untuk membangun arsitektur sendiri, dibuat sebuah class yang meng-<em>extend</em> <code>nn.Module</code>. Ini wajib. PyTorch didesain agar setiap model yang dibuat dikenali sebagai model deep learning, supaya proses training bisa dijalankan terhadapnya.</p>
<pre><code class="language-python">import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self, num_classes):
        super().__init__()
        self.block1 = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=10, kernel_size=3),
            nn.ReLU(),
        )
        self.block2 = nn.Sequential(
            nn.Conv2d(in_channels=10, out_channels=10, kernel_size=3),
            nn.ReLU(),
        )
        self.flatten = nn.Flatten()
        self.classifier = nn.Linear(in_features=10 * 24 * 24, out_features=num_classes)

    def forward(self, x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.flatten(x)
        x = self.classifier(x)
        return x
</code></pre>
<p>Tiga hal yang perlu diperhatikan:</p>
<ol>
<li><p><code>in_channels</code> block kedua (10) harus sama dengan <code>out_channels</code> block pertama (10).</p>
</li>
<li><p><code>in_features</code> pada <code>classifier</code> harus cocok dengan ukuran hasil flatten. Untuk input 28×28 tanpa padding, tiap Conv2d dengan kernel 3 mengecilkan sisi gambar sebanyak 2 piksel: 28 jadi 26, lalu 26 jadi 24. Jadi ukuran akhirnya 24×24, dan <code>in_features</code> = 10 dikali 24 dikali 24. Kalau bingung menghitung manual, cara praktisnya adalah mencetak (<em>print</em>) shape output di tiap block untuk mengecek ukurannya langsung.</p>
</li>
<li><p><code>out_features</code> di layer terakhir harus sama dengan jumlah kelas dataset.</p>
</li>
</ol>
<h2>Transfer Learning</h2>
<p>Daripada merancang arsitektur dari nol, backbone yang sudah pretrained dan terbukti bagus bisa langsung dipakai. Tersedia lewat model zoo <code>torchvision</code>. Beberapa nama besar: ResNet, VGG, EfficientNet.</p>
<p>Caranya: load backbone-nya, lalu override layer classifier terakhir sesuai jumlah kelas. Penamaan layer berbeda-beda tiap arsitektur.</p>
<pre><code class="language-python">import torch.nn as nn
from torchvision import models

# EfficientNet-B1
model = models.efficientnet_b1(weights="IMAGENET1K_V1")
model.classifier[1] = nn.Linear(in_features=1280, out_features=15)

# ResNet-50
model = models.resnet50(weights="IMAGENET1K_V1")
model.fc = nn.Linear(in_features=2048, out_features=15)
</code></pre>
<p>Arsitektur seperti ini sudah terbukti bagus mengekstrak fitur visual secara umum. Fokusnya jadi tinggal mengajari model mengenali kelas-kelas spesifik di dataset yang dipakai.</p>
<h2>Menyiapkan Dataset</h2>
<p>Ada dua pendekatan populer untuk menyusun data sebelum training.</p>
<h3>Folder per Kelas</h3>
<pre><code class="language-plaintext">dataset/
├── train/
│   ├── class1/
│   │   ├── image1.jpg
│   │   └── image2.jpg
│   └── class2/
│       ├── image3.jpg
│       └── image4.jpg
├── val/
│   ├── class1/
│   └── class2/
</code></pre>
<p>Tiap gambar diletakkan di folder sesuai kelasnya. Pendekatan ini didukung langsung oleh fungsi bawaan framework: <code>ImageFolder</code> di PyTorch, <code>ImageDataGenerator</code> di TensorFlow.</p>
<pre><code class="language-python">from torchvision import datasets, transforms
from torch.utils.data import DataLoader

transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor()
])

train_dataset = datasets.ImageFolder(root='dataset/train', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
</code></pre>
<p>Kelebihannya: gampang dipahami, banyak framework yang mendukung langsung. Kekurangannya: kurang fleksibel kalau nama file acak atau butuh info tambahan (misalnya multilabel).</p>
<h3>File CSV</h3>
<p>Alternatifnya, pakai file CSV berisi path gambar dan labelnya.</p>
<pre><code class="language-plaintext">image_path,class_label
dataset/images/image1.jpg,0
dataset/images/image2.jpg,1
dataset/images/image3.jpg,0
</code></pre>
<pre><code class="language-python">import pandas as pd
from PIL import Image
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms

class CustomDataset(Dataset):
    def __init__(self, csv_file, transform=None):
        self.data = pd.read_csv(csv_file)
        self.transform = transform

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

    def __getitem__(self, idx):
        img_path = self.data.iloc[idx, 0]
        image = Image.open(img_path)
        label = int(self.data.iloc[idx, 1])
        if self.transform:
            image = self.transform(image)
        return image, label

transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor()
])

train_dataset = CustomDataset(csv_file='dataset/train_labels.csv', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
</code></pre>
<p>Lebih fleksibel, terutama untuk dataset dengan metadata tambahan atau file yang tersebar di direktori yang tidak terorganisir. Konsekuensinya, butuh kode custom karena tidak ada dukungan bawaan langsung dari framework.</p>
<blockquote>
<p>📌 <strong>Kapan pakai yang mana?</strong> Dataset sudah rapi per kategori dan sederhana, pakai folder. Dataset kompleks dengan metadata tambahan atau file berantakan, pakai CSV.</p>
</blockquote>
<h3>Dataset vs DataLoader</h3>
<p><strong>Dataset</strong> menampung semua data, gambar dan label sekaligus. Ambil satu sampel dari dataset, hasilnya satu gambar plus satu label. Memberi seluruh dataset sekaligus ke model biasanya tidak memungkinkan karena keterbatasan memori GPU.</p>
<p><strong>DataLoader</strong> memecah dataset jadi <strong>batch</strong>, kelompok kecil yang diproses bertahap. <code>DataLoader(batch_size=128)</code> menghasilkan batch berbentuk <code>[128, 3, 64, 64]</code>: 128 gambar, 3 channel RGB, ukuran 64×64, beserta 128 labelnya.</p>
<h2>Training Loop</h2>
<p>Setiap epoch (satu putaran penuh lewat seluruh data training) melewati tahapan berikut.</p>
<pre><code class="language-python">model.train()
train_loss, train_acc = 0, 0

for images, labels in train_loader:
    images, labels = images.to(device), labels.to(device)

    outputs = model(images)
    loss = criterion(outputs, labels)

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    train_loss += loss.item()
    train_acc += (outputs.argmax(1) == labels).float().mean().item()

train_loss /= len(train_loader)
train_acc /= len(train_loader)
</code></pre>
<p>Tahap test/validation alurnya mirip, hanya saja tanpa <code>loss.backward()</code> dan <code>optimizer.step()</code>. Tahap ini murni evaluasi, bukan belajar.</p>
<p>Loss function untuk kasus multi-kelas: <strong>Cross Entropy Loss</strong>.</p>
<p>Terakhir, simpan model kalau performanya membaik, supaya bisa dipakai lagi nanti tanpa training ulang dari nol.</p>
<pre><code class="language-python">if test_acc &gt; best_acc:
    best_acc = test_acc
    torch.save(model.state_dict(), 'best_model.pth')
</code></pre>
<h2>Cheat Sheet</h2>
<ul>
<li><p>[ ] CNN terdiri dari Backbone (ekstraksi fitur) dan Head (klasifikasi)</p>
</li>
<li><p>[ ] <code>in_channels</code>/<code>in_features</code> layer saat ini harus sama dengan <code>out_channels</code>/<code>out_features</code> layer sebelumnya</p>
</li>
<li><p>[ ] <code>out_channels</code> sama dengan jumlah filter yang dipakai</p>
</li>
<li><p>[ ] Output layer terakhir punya neuron sebanyak jumlah kelas</p>
</li>
<li><p>[ ] Linear layer butuh input 1D, gambar harus di-flatten dulu</p>
</li>
<li><p>[ ] Convolutional layer bisa langsung menerima input matriks</p>
</li>
<li><p>[ ] Nilai kernel tidak ditentukan manual, didapat lewat training</p>
</li>
<li><p>[ ] Dataset menampung data, DataLoader memecahnya jadi batch</p>
</li>
<li><p>[ ] Transfer learning memakai backbone pretrained, cukup override classifier-nya</p>
</li>
<li><p>[ ] Loss function untuk multi-kelas: Cross Entropy Loss</p>
</li>
</ul>
<h2>Uji Pemahaman Kamu</h2>
<p><strong>1. Kenapa gambar tidak bisa langsung dimasukkan ke linear layer?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Linear layer hanya menerima input berbentuk vektor 1 dimensi, sedangkan gambar berbentuk matriks. Solusinya di-flatten dulu, atau langsung pakai convolutional layer yang memang didesain menerima input matriks.</p>
</blockquote>
<p><strong>2. Kalau sebuah convolutional layer memakai 64 filter, berapa out_channels-nya?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 64 — <code>out_channels</code> selalu sama dengan jumlah filter yang digunakan.</p>
</blockquote>
<p><strong>3. Dataset kamu punya 20 kelas gambar buah. Berapa jumlah neuron di output layer terakhir?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> 20 — jumlah neuron output harus sama dengan jumlah kelas.</p>
</blockquote>
<p><strong>4. Apa bedanya training step dan test/validation step dalam training loop?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Alurnya mirip: forward pass, hitung loss. Bedanya test/validation step tidak melakukan optimisasi (backpropagation dan update parameter), karena tahap ini murni untuk mengevaluasi, bukan melatih model.</p>
</blockquote>
<p><strong>5. Kapan sebaiknya pakai pendekatan CSV dibanding folder per kelas?</strong></p>
<blockquote>
<p><strong>Jawaban:</strong> Saat dataset punya informasi tambahan (metadata, multilabel) atau gambarnya tersebar di direktori yang tidak terorganisir rapi per kelas. CSV memberi fleksibilitas lebih meski butuh sedikit kode custom.</p>
</blockquote>
<hr />
<p>Artikel berikutnya melanjutkan dari <a href="https://shaka-ai.hashnode.dev/training-model-serving-flowers102-pytorch">sini</a>: melatih model asli untuk membedakan 102 jenis bunga, lengkap sampai jadi API yang bisa dipanggil aplikasi lain.</p>
<p><em>Bagian dari seri belajar Computer Vision.</em></p>
]]></content:encoded></item><item><title><![CDATA[Computer Vision for Beginners: From Pixels to Building Your Own API (OpenCV + FastAPI)]]></title><description><![CDATA[🧠 TL;DR — A digital image is just a bunch of numbers arranged in a matrix. Once that clicks, everything else in Computer Vision — grayscale, convolution, even CNNs — starts making a lot more sense. I]]></description><link>https://shaka-ai.hashnode.dev/computer-vision-beginners-opencv-fastapi</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/computer-vision-beginners-opencv-fastapi</guid><category><![CDATA[Computer Vision]]></category><category><![CDATA[opencv]]></category><category><![CDATA[Python]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[image processing]]></category><category><![CDATA[AI]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 13 Sep 2026 16:58:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/9d687b47-5f56-4174-81ee-09526f2e4201.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>🧠 <strong>TL;DR</strong> — A digital image is just a bunch of numbers arranged in a matrix. Once that clicks, everything else in Computer Vision — grayscale, convolution, even CNNs — starts making a lot more sense. In this post we'll break the concepts down step by step, code along with OpenCV, then wrap it all into an API with FastAPI.</p>
</blockquote>
<p>Ever wondered how your phone knows there's a face in a photo? Or how a CCTV system can "get suspicious" when someone walks into a restricted area? It all starts with one deceptively simple idea: <strong>a computer doesn't actually "see" an image the way we do — it just sees numbers.</strong></p>
<p>Once you understand how a computer "sees" those numbers, the door into Computer Vision opens up a lot wider. Let's break it down piece by piece, with a few checkpoints along the way where you can pause and test yourself. 👇</p>
<h2>📋 What You'll Learn</h2>
<ul>
<li><p>What Computer Vision Actually Is</p>
</li>
<li><p>Anatomy of a Digital Image</p>
</li>
<li><p>Image Processing vs Computer Vision — What's the Difference?</p>
</li>
<li><p>Where Computer Vision Gets Used</p>
</li>
<li><p>Hands-On: 5 Basic Operations with OpenCV</p>
</li>
<li><p>Bonus: Advanced Edge Detection (Sobel, Laplacian, Canny+Blur)</p>
</li>
<li><p>From Manual Kernels to CNNs</p>
</li>
<li><p>Wrapping It Into an API with FastAPI</p>
</li>
<li><p>Bonus: Raw Image Endpoints &amp; Real Deployment Proof</p>
</li>
<li><p>Cheat Sheet &amp; Mini Quiz</p>
</li>
</ul>
<blockquote>
<p>💡 If you're publishing this on Hashnode, turn on the <strong>"Table of Contents"</strong> toggle in the post settings — Hashnode auto-generates working jump-links from the headings in this article.</p>
</blockquote>
<hr />
<h2>What Computer Vision Actually Is</h2>
<p>The classic definition of AI: a system that can think and act like a human. Now, if that intelligence is pointed at <strong>language</strong>, that's NLP. If it's pointed at <strong>sight</strong>, that's <strong>Computer Vision</strong>.</p>
<blockquote>
<p>💡 <strong>Computer Vision</strong> is the branch of AI that lets computers understand and interpret visual information from images or video — detecting objects, recognizing faces, even making decisions based on what it "sees."</p>
</blockquote>
<p>But before a computer can "think" about an image, it needs a more basic foundation first: <strong>Image Processing</strong>. Think of it like learning a language — you can't write poetry before you know the alphabet. Image processing is that alphabet.</p>
<p>🧩 <strong>Quick check #1:</strong> If NLP is linguistic intelligence, what kind of intelligence is Computer Vision?</p>
<p><em>(try answering in your head before scrolling down 👇)</em></p>
<blockquote>
<p>✅ <strong>Answer:</strong> <strong>Visual</strong> intelligence — the ability to understand information from images/video, not text.</p>
</blockquote>
<h2>Anatomy of a Digital Image</h2>
<p>Here's the part people most often skip past: <strong>a digital image is a matrix of numbers.</strong></p>
<p>Every image is made up of <strong>pixels</strong> — the smallest unit of an image — and each pixel holds a value representing <strong>light intensity</strong> at that point. The darker it is, the closer that value gets to 0. The brighter it is, the higher the value.</p>
<h3>Grayscale vs RGB</h3>
<table>
<thead>
<tr>
<th></th>
<th>Grayscale</th>
<th>RGB (Color)</th>
</tr>
</thead>
<tbody><tr>
<td>Number of channels</td>
<td>1</td>
<td>3 (Red, Green, Blue)</td>
</tr>
<tr>
<td>Value range per pixel</td>
<td>0 (black) – 255 (white)</td>
<td>3 values per pixel (R, G, B)</td>
</tr>
<tr>
<td>Representation</td>
<td>2D matrix</td>
<td>3D matrix (3 stacked layers)</td>
</tr>
</tbody></table>
<p>Color is really just a combination of three numbers. For example:</p>
<ul>
<li><p><code>R=255, G=0, B=0</code> → bright red 🔴</p>
</li>
<li><p><code>R=G=125, B=0</code> → yellowish 🟡</p>
</li>
<li><p><code>R=0, G=0, B=0</code> → pure black ⚫</p>
</li>
</ul>
<p>That's also why every color picker in design software shows three RGB sliders — those literally are the pixel numbers you're adjusting.</p>
<h3>Resolution &amp; Color Depth</h3>
<p>A resolution of <code>1920 × 1080</code> means 1920 pixels wide and 1080 pixels tall. If it's a color image, the total number of values the computer has to store is:</p>
<pre><code class="language-plaintext">1920 × 1080 × 3 channels = 6,220,800 numbers
</code></pre>
<p>...for just <strong>one</strong> image. That's why high-resolution images are "heavier" to process — more detail means more numbers to crunch.</p>
<p>Meanwhile, <strong>color depth</strong> determines how many possible color levels there are: 8-bit = 256 levels (0–255), 24-bit RGB = roughly <strong>16 million</strong> possible color combinations.</p>
<p>🧮 <strong>Try it yourself:</strong> How many total numbers represent a 640×480 grayscale image?</p>
<p><em>(pause, do the math, then scroll)</em></p>
<blockquote>
<p>✅ <strong>Answer:</strong> 640 × 480 × 1 channel = <strong>307,200 numbers</strong>. Compare that to the color version, which needs 3× as many — 921,600 numbers! That's why converting to grayscale is a common trick for speeding up processing.</p>
</blockquote>
<h3>Image Dimensions: 2D vs 3D</h3>
<p>One more characteristic that tends to get mentioned later: images also have a "dimensionality" to how they're represented.</p>
<ul>
<li><p><strong>2D images</strong> — grayscale, just a single layer of pixel matrix.</p>
</li>
<li><p><strong>3D images</strong> — color images, where the RGB channels are stacked into one data structure (a typical shape looks like <code>224 × 224 × 3</code>).</p>
</li>
</ul>
<p>You'll run into this term a lot once you get into deep learning — especially when people talk about a model's "input shape."</p>
<h2>Image Processing vs Computer Vision — What's the Difference?</h2>
<p>These two terms often get used interchangeably, but they're actually different — even though they're closely related.</p>
<table>
<thead>
<tr>
<th></th>
<th>Image Processing</th>
<th>Computer Vision</th>
</tr>
</thead>
<tbody><tr>
<td>Input</td>
<td>Image</td>
<td>Image / video</td>
</tr>
<tr>
<td>Output</td>
<td>Image (manipulated)</td>
<td>Interpretation (label, coordinates, description)</td>
</tr>
<tr>
<td>Level of operation</td>
<td>Low-level, pixel-by-pixel</td>
<td>More complex and holistic</td>
</tr>
<tr>
<td>Examples</td>
<td>Blur, crop, edge detection</td>
<td>Object detection, classification, face recognition</td>
</tr>
</tbody></table>
<p>The simple version: <strong>image processing turns an image into another image; computer vision turns an image into understanding.</strong> And usually, computer vision needs image processing as a first step before it can "understand" what's in the image.</p>
<p>Image processing itself covers a lot of ground — filtering, smoothing, contrast enhancement, segmentation, color transformation — and it's used widely across fields: medicine, photography, surveillance, and as the foundation for nearly every AI application that deals with visual data.</p>
<h2>Where Computer Vision Gets Used</h2>
<p>This isn't just theory — computer vision is already deployed across plenty of industries, often without you noticing:</p>
<p><strong>🎓 Education</strong></p>
<ul>
<li><p>Exam proctoring — detecting cheating during online exams. The system watches student behavior via webcam and flags suspicious activity like talking to someone else, looking away repeatedly, or leaving the camera frame for too long.</p>
</li>
<li><p>Handwriting recognition — automatically grading handwritten exam answers or assignments.</p>
</li>
</ul>
<p><strong>🏦 Banking / Administrative</strong></p>
<ul>
<li>Signature and handwriting verification on documents, for authenticity checks.</li>
</ul>
<p><strong>🏥 Healthcare</strong></p>
<ul>
<li><p>Disease detection in medical imaging — scanning MRIs, CT scans, or X-rays to automatically detect things like cancer, heart abnormalities, or tumors with high accuracy.</p>
</li>
<li><p>Microscopic image analysis — examining cells or tissue samples to spot abnormalities such as infections or pathological changes.</p>
</li>
</ul>
<p><strong>🏭 Manufacturing</strong></p>
<ul>
<li>Automated quality inspection (visual inspection) — detecting defects, cracks, or size mismatches on the production line (a classic example: checking phone screens for defects) with speed and precision well beyond the human eye.</li>
</ul>
<p><strong>🔒 Security / Surveillance</strong></p>
<ul>
<li><p>Smart surveillance — CCTV systems (including in smart cities or mining sites) detecting suspicious activity or behavior, like someone entering a restricted area.</p>
</li>
<li><p>PPE compliance checks — automatically recognizing whether workers are wearing helmets, masks, gloves, and other required gear.</p>
</li>
</ul>
<p><strong>📄 Others</strong></p>
<ul>
<li>OCR for document processing, and face recognition, which is now standard in plenty of everyday apps.</li>
</ul>
<p>Almost every one of these use cases starts from the same place: <strong>processing image pixels until the computer can "read" the pattern in them.</strong></p>
<h2>Hands-On: 5 Basic Operations with OpenCV</h2>
<p>Enough theory — let's write some code. We'll use <strong>OpenCV</strong>, one of the most popular Python libraries for image processing, so we don't have to implement algorithms from scratch.</p>
<blockquote>
<p>⚠️ <strong>Gotcha to remember:</strong> OpenCV reads images in <strong>BGR</strong> format, not RGB! Also, coordinate (0,0) is at the top-left corner, not the center like a regular Cartesian system.</p>
</blockquote>
<h3>1️⃣ Image Cropping</h3>
<p>Cropping is really just <strong>NumPy array slicing</strong> — since the image OpenCV reads is literally a 2D array.</p>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')

# [y_start:y_end, x_start:x_end] — rows first, then columns
cropped_image = image[50:200, 100:300]

cv2.imwrite('cropped_image.jpg', cropped_image)
</code></pre>
<h3>2️⃣ Grayscale</h3>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite('grayscale_image.jpg', grayscale_image)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/438a3e0d-bcb4-4ef1-be75-7f77300cc4af.jpg" alt="Grayscale result" style="display:block;margin:0 auto" />

<p><em>Real output from the code above — the original color image converted to black and white, keeping only light intensity.</em></p>
<h3>3️⃣ Channel Split</h3>
<p>Really handy once you get into <strong>semantic segmentation</strong> — each channel can represent a mask for a different object class.</p>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')
blue_channel, green_channel, red_channel = cv2.split(image)

cv2.imwrite('red_channel.jpg', red_channel)
cv2.imwrite('green_channel.jpg', green_channel)
cv2.imwrite('blue_channel.jpg', blue_channel)
</code></pre>
<h3>4️⃣ Convolution — The Heart of Image Processing</h3>
<p>This is the single most important concept in this whole article. Picture a <strong>kernel</strong> (a small matrix, say 5×5) sliding slowly across the image. At every position, the kernel's values get multiplied with the pixels underneath, then summed into one output value.</p>
<p>Different kernels = different effects:</p>
<ul>
<li><p>An averaging kernel → <strong>blur</strong> 🌫️</p>
</li>
<li><p>Kernel <code>[[0,-1,0],[-1,4,-1],[0,-1,0]]</code> → <strong>edge detection</strong> ✏️</p>
</li>
<li><p>Identity kernel → image stays unchanged</p>
</li>
</ul>
<pre><code class="language-python">import cv2
import numpy as np

image = cv2.imread('input_image.jpg')

kernel = np.ones((5, 5), np.float32) / 25  # blur kernel
convolved_image = cv2.filter2D(image, -1, kernel)

cv2.imwrite('convolved_image.jpg', convolved_image)
</code></pre>
<p>🧪 <strong>Try it yourself:</strong> Swap the kernel above for the edge-detection kernel <code>[[0,-1,0],[-1,4,-1],[0,-1,0]]</code>. What happens?</p>
<blockquote>
<p>✅ <strong>Answer:</strong> The result shows the <strong>edges</strong> of the objects in the photo, instead of a blurred whole image. That's because this kernel "highlights" the intensity differences between neighboring pixels — exactly where an object's edges are.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/917ded11-9644-41a2-800e-42132dce5d1b.jpg" alt="Convolution result with an edge-detection kernel" style="display:block;margin:0 auto" />

<p><em>This isn't a simulation — it's the actual output of the edge-detection kernel above, applied to a Spider-Man image. Notice how the kernel "pops out" the lines of the costume.</em></p>
<h3>5️⃣ Line Detection (Canny + Hough Transform)</h3>
<pre><code class="language-python">import cv2
import numpy as np

image = cv2.imread('input_image.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

edges = cv2.Canny(gray, 50, 150, apertureSize=3)
lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)

if lines is not None:
    for rho, theta in lines[:, 0]:
        a, b = np.cos(theta), np.sin(theta)
        x0, y0 = a * rho, b * rho
        x1, y1 = int(x0 + 1000 * (-b)), int(y0 + 1000 * (a))
        x2, y2 = int(x0 - 1000 * (-b)), int(y0 - 1000 * (a))
        cv2.line(image, (x1, y1), (x2, y2), (0, 0, 255), 2)

cv2.imwrite('line_detected_image.jpg', image)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/44884f37-3b1a-4aeb-a62c-49e9ed5f1d62.jpg" alt="Line detection result" style="display:block;margin:0 auto" />

<p><em>The red lines are the output of the Hough Transform — detecting straight-line patterns from the edges Canny found earlier.</em></p>
<p>How it works: <strong>Canny</strong> finds the edges first, then <strong>Hough Transform</strong> looks for straight-line patterns among those edge points — kind of like how you'd connect dots into a line yourself.</p>
<blockquote>
<p>💡 <strong>Practical note:</strong> if the detected lines look too short or odd on a large-resolution image, the line length (<code>1000</code> in the code above) can be made dynamic based on the image dimensions: <code>line_length = max(img_height, img_width)</code>. The Hough Transform threshold can also be lowered (say, from <code>200</code> to <code>80</code>) if too few lines are being detected.</p>
</blockquote>
<h2>Bonus: Advanced Edge Detection — Sobel, Laplacian &amp; Canny + Gaussian Blur</h2>
<p>Canny isn't the only way to detect edges. A few other variations that are commonly used:</p>
<p><strong>Canny, reading straight from grayscale</strong></p>
<pre><code class="language-python">import cv2

img = cv2.imread('input_image.jpg', cv2.IMREAD_GRAYSCALE)
edges = cv2.Canny(img, 100, 200)
cv2.imwrite('canny_edge_detection.jpg', edges)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/616f0082-c4be-47e4-b7e2-cdbc49526f30.jpg" alt="Canny result" style="display:block;margin:0 auto" />

<p><strong>Sobel Operator</strong> — computes intensity gradients separately along the horizontal and vertical axes, then combines them into one magnitude.</p>
<pre><code class="language-python">sobelx = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)
sobely = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)

gradient_magnitude = cv2.magnitude(sobelx, sobely)
gradient_magnitude = cv2.convertScaleAbs(gradient_magnitude)
cv2.imwrite('sobel_edge_detection.jpg', gradient_magnitude)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/c0255cfe-94af-4186-9c1b-25d0fb145b18.jpg" alt="Sobel result" style="display:block;margin:0 auto" />

<p><strong>Laplacian Operator</strong> — uses the second derivative, sensitive to intensity changes in every direction at once (not just horizontal/vertical like Sobel).</p>
<pre><code class="language-python">laplacian = cv2.Laplacian(img, cv2.CV_64F)
laplacian_abs = cv2.convertScaleAbs(laplacian)
cv2.imwrite('laplacian_detection.jpg', laplacian_abs)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/7f0d6589-a9f6-47ef-9b39-0a0b3eb8457a.jpg" alt="Laplacian result" style="display:block;margin:0 auto" />

<p><strong>Gaussian Blur + Canny</strong> — smooth the image first before running Canny, so it doesn't pick up false edges caused by noise.</p>
<pre><code class="language-python">blur = cv2.GaussianBlur(img, (5, 5), 1.4)
edges = cv2.Canny(blur, threshold1=100, threshold2=200)
cv2.imwrite('canny_edge_detection_blurred.jpg', edges)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/508952db-0411-4868-98f5-c83b7596fbbc.jpg" alt="Gaussian Blur + Canny result" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Operator</th>
<th>Characteristics</th>
<th>When to use it</th>
</tr>
</thead>
<tbody><tr>
<td>Canny</td>
<td>Multi-stage, thin &amp; clean edges</td>
<td>General edge detection, basis for Line Detection</td>
</tr>
<tr>
<td>Sobel</td>
<td>Separate horizontal &amp; vertical gradients</td>
<td>When you need to know edge direction</td>
</tr>
<tr>
<td>Laplacian</td>
<td>Second derivative, sensitive in every direction</td>
<td>Fast, but more prone to noise</td>
</tr>
<tr>
<td>Gaussian Blur + Canny</td>
<td>Canny with smoothing pre-processing</td>
<td>Noisy images, want a cleaner result</td>
</tr>
</tbody></table>
<h2>From Manual Kernels to CNNs: The Leap That Changes Everything</h2>
<p>Now here's the important question: <strong>how do we know what kernel values are "right" for a given task?</strong></p>
<p>The answer: we don't have to define them manually. This is where <strong>Convolutional Neural Networks (CNNs)</strong> come in — instead of a kernel with fixed values, a CNN <strong>learns the best kernel values through training</strong>, adapting to whatever data it's given.</p>
<blockquote>
<p>🚀 This is exactly why CNNs are so much more powerful than manual convolution: their kernels are adaptive, not hardcoded.</p>
</blockquote>
<p>Architecturally, a CNN is still a regular neural network at its core — just with a series of <strong>convolutional layers</strong> added, whose job is to find the best possible values to fill those filters so that feature extraction from the image is maximized.</p>
<p>CNNs are a branch of deep learning built specifically for image data — a different "family" from RNNs, Transformers, or LSTMs, which are typically used for sequential data like text. If you're already familiar with those architectures from the NLP side, think of a CNN as their "cousin," purpose-built for visual data.</p>
<p>CNNs are a big topic on their own that we'll dig into more later — but you now know the foundation they're built on: <strong>convolution</strong>, which you just got hands-on with above.</p>
<h2>Wrapping It Into an API with FastAPI</h2>
<p>Finally, all five operations above can be turned into a service via an API. The idea: receive an image → process it in memory (no disk writes) → return the result as <strong>base64</strong> — since an API endpoint can't directly return a raw image file.</p>
<h3>Installation</h3>
<pre><code class="language-bash">pip install fastapi uvicorn pillow opencv-python
</code></pre>
<h3>Setup &amp; Helper Functions</h3>
<pre><code class="language-python">from fastapi import FastAPI, UploadFile, File
from PIL import Image, ImageOps
import io, base64, cv2
import numpy as np

app = FastAPI()

def pil_image_to_base64(image: Image.Image):
    buffered = io.BytesIO()
    image.save(buffered, format="JPEG")
    return base64.b64encode(buffered.getvalue()).decode("utf-8")

def cv2_image_to_base64(image):
    _, buffer = cv2.imencode('.jpg', image)
    return base64.b64encode(buffer).decode("utf-8")
</code></pre>
<p>With those two helper functions, each operation just needs one endpoint:</p>
<p><code>/crop/</code> — crop the image to given coordinates</p>
<pre><code class="language-python">@app.post("/crop/")
async def crop_image(file: UploadFile = File(...), x: int = 0,
                      y: int = 0, width: int = 100, height: int = 100):
    image = Image.open(io.BytesIO(await file.read()))
    cropped_image = image.crop((x, y, x + width, y + height))
    return {"image_base64": pil_image_to_base64(cropped_image)}
</code></pre>
<p><code>/grayscale/</code> — convert to grayscale</p>
<pre><code class="language-python">@app.post("/grayscale/")
async def grayscale_image(file: UploadFile = File(...)):
    image = Image.open(io.BytesIO(await file.read()))
    grayscale_image = ImageOps.grayscale(image)
    return {"image_base64": pil_image_to_base64(grayscale_image)}
</code></pre>
<p><code>/channel_split/</code> — extract a single color channel</p>
<pre><code class="language-python">@app.post("/channel_split/")
async def channel_split(file: UploadFile = File(...), channel: str = "red"):
    image = cv2.imdecode(np.frombuffer(await file.read(), np.uint8),
                          cv2.IMREAD_COLOR)
    (blue, green, red) = cv2.split(image)
    if channel == "red":
        return {"image_base64": cv2_image_to_base64(red)}
    elif channel == "green":
        return {"image_base64": cv2_image_to_base64(green)}
    else:
        return {"image_base64": cv2_image_to_base64(blue)}
</code></pre>
<p><code>/convolution/</code> — apply a blur filter</p>
<pre><code class="language-python">@app.post("/convolution/")
async def convolution(file: UploadFile = File(...)):
    image = cv2.imdecode(np.frombuffer(await file.read(),
                          np.uint8), cv2.IMREAD_COLOR)
    kernel = np.ones((5, 5), np.float32) / 25
    convolved_image = cv2.filter2D(image, -1, kernel)
    return {"image_base64": cv2_image_to_base64(convolved_image)}
</code></pre>
<p><code>/line_detection/</code> — detect lines via Canny + Hough Transform</p>
<pre><code class="language-python">@app.post("/line_detection/")
async def line_detection(file: UploadFile = File(...)):
    image = cv2.imdecode(np.frombuffer(await file.read(),
                          np.uint8), cv2.IMREAD_COLOR)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, 50, 150, apertureSize=3)
    lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)

    if lines is not None:
        for rho, theta in lines[:, 0]:
            a, b = np.cos(theta), np.sin(theta)
            x0, y0 = a * rho, b * rho
            x1, y1 = int(x0 + 1000 * (-b)), int(y0 + 1000 * (a))
            x2, y2 = int(x0 - 1000 * (-b)), int(y0 - 1000 * (a))
            cv2.line(image, (x1, y1), (x2, y2), (0, 0, 255), 2)

    return {"image_base64": cv2_image_to_base64(image)}
</code></pre>
<p>Five endpoints, five image-processing operations you've learned from the ground up — and now ready to be called by anyone over HTTP. 🎉</p>
<h3>Bonus: Raw Image Response Endpoints (Not Base64)</h3>
<p>The five endpoints above return JSON containing base64 — a great format when the API is called from another application (web/mobile/backend), but not exactly pleasant to look at directly in a browser or Swagger UI, since it just shows up as a wall of text.</p>
<p>The fix is simple: build a version of each endpoint whose <strong>response is the raw image file itself</strong> (<code>media_type="image/jpeg"</code>), instead of JSON. Just add two helper functions to convert to raw bytes (not base64), then wrap the result in FastAPI's <code>Response</code>:</p>
<pre><code class="language-python">from fastapi.responses import Response

def pil_image_to_bytes(image: Image.Image) -&gt; bytes:
    buffered = io.BytesIO()
    image.save(buffered, format="JPEG")
    return buffered.getvalue()

def cv2_image_to_bytes(image) -&gt; bytes:
    _, buffer = cv2.imencode('.jpg', image)
    return buffer.tobytes()
</code></pre>
<p>Then build an "-image" version of each endpoint. Here's grayscale, for example:</p>
<pre><code class="language-python">@app.post("/grayscale-image/")
async def grayscale_image_raw(file: UploadFile = File(...)):
    image = Image.open(io.BytesIO(await file.read()))
    grayscale_image = ImageOps.grayscale(image)
    return Response(content=pil_image_to_bytes(grayscale_image), media_type="image/jpeg")
</code></pre>
<p>The same pattern repeats for <code>/crop-image/</code>, <code>/channel_split-image/</code>, <code>/convolution-image/</code>, and <code>/line_detection-image/</code> — the logic is identical to the base64 versions, only the <code>return</code> line changes.</p>
<blockquote>
<p>💡 Now your API has two "flavors" of endpoints: JSON+base64 for other programs to call, and raw image for quick testing or dropping straight into an <code>&lt;img src="..."&gt;</code> tag.</p>
</blockquote>
<h3>Real Proof: Deploying &amp; Testing with ngrok</h3>
<p>Every piece of code above has already been validated to work (tested through FastAPI's <code>TestClient</code>, with real requests hitting the actual endpoint functions, not just eyeballed). But beyond that — two of these endpoints (<code>/grayscale/</code> and <code>/grayscale-image/</code>) were also tested through an actual <strong>live public deployment</strong>: the server ran on Google Colab, was exposed to the internet with <a href="https://ngrok.com">ngrok</a>, and was hit from the outside through the Swagger UI (<code>/docs</code>) that FastAPI generates automatically. Here's the proof:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/1bd7dd94-27f6-4c75-9683-2efda8db7681.png" alt="Testing result of the /grayscale-image/ endpoint via Swagger UI, server genuinely online" style="display:block;margin:0 auto" />

<p><em>The</em> <code>/grayscale-image/</code> <em>endpoint tested with</em> <code>Spiderman.jpg</code><em>, with the result rendered directly as an image in the response panel. Notice the response headers:</em> <code>content-type: image/jpeg</code><em>,</em> <code>server: uvicorn</code><em>, and</em> <code>ngrok-agent-ips</code> <em>— proof the request genuinely went through an ngrok tunnel from the internet, not a local simulation.</em></p>
<p>If you want to try deploying your own version from Google Colab (free), here's the gist:</p>
<ol>
<li><p>Install <code>nest-asyncio</code> and <code>pyngrok</code>.</p>
</li>
<li><p>Sign up for a free ngrok account and grab your authtoken from the <a href="https://dashboard.ngrok.com/get-started/your-authtoken">ngrok dashboard</a>.</p>
</li>
<li><p>Run <code>uvicorn</code> inside an <code>asyncio.create_task()</code> (not a plain <code>uvicorn.run()</code> — Colab already has its own event loop, which will conflict if you call it directly).</p>
</li>
<li><p>ngrok will hand you a public URL (<code>https://xxxx.ngrok-free.dev</code>) that tunnels to <code>localhost:8000</code> on your Colab instance.</p>
</li>
</ol>
<p><em>(The finer details — including a few common gotchas like port conflicts and a</em> <code>SystemExit</code> <em>that can actually crash your Colab kernel if the old server isn't shut down properly — might get their own post, since there's too much to unpack here.)</em></p>
<h2>Cheat Sheet &amp; Mini Quiz</h2>
<p>Before you close this tab, let's consolidate everything you've learned:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>What it does</th>
<th>Input → Output</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody><tr>
<td>Cropping</td>
<td>Cut out a specific area</td>
<td>Image → cropped image</td>
<td><code>/crop/</code></td>
</tr>
<tr>
<td>Grayscale</td>
<td>Strip out color info</td>
<td>Color image → black &amp; white</td>
<td><code>/grayscale/</code></td>
</tr>
<tr>
<td>Channel Split</td>
<td>Separate R/G/B channels</td>
<td>1 image → 3 images, one per channel</td>
<td><code>/channel_split/</code></td>
</tr>
<tr>
<td>Convolution</td>
<td>Apply a filter via a kernel</td>
<td>Image → filtered image</td>
<td><code>/convolution/</code></td>
</tr>
<tr>
<td>Line Detection</td>
<td>Detect lines (Canny+Hough)</td>
<td>Image → image with lines marked</td>
<td><code>/line_detection/</code></td>
</tr>
</tbody></table>
<h3>🎯 Quick Quiz — Test Your Understanding</h3>
<p><em>Try answering these three questions in your head before checking the answer under each one.</em></p>
<p><strong>1. Why does OpenCV sometimes make image colors look "off" when displayed directly with another library?</strong></p>
<blockquote>
<p>✅ Because OpenCV reads images in <strong>BGR</strong> format, not RGB. If you display it directly with a library that assumes RGB (like matplotlib), the red and blue channels get swapped. The fix: convert it first with <code>cv2.COLOR_BGR2RGB</code>.</p>
</blockquote>
<p><strong>2. What's the most fundamental difference between image processing and computer vision?</strong></p>
<blockquote>
<p>✅ Image processing: <strong>image in, image out</strong> (manipulated). Computer vision: <strong>image/video in, interpretation out</strong> (labels, coordinates, descriptions).</p>
</blockquote>
<p><strong>3. Why is a CNN considered "better" than convolution with a manual (predefined) kernel?</strong></p>
<blockquote>
<p>✅ Because a CNN's kernel values are <strong>learned automatically through training</strong> and become adaptive to the data — whereas a manual kernel is fixed and requires a lot of trial-and-error to fit a specific case.</p>
</blockquote>
<h3>📖 Glossary of Key Terms</h3>
<p>Bookmark this section for a quick reference whenever you forget a term:</p>
<ul>
<li><p><strong>Pixel</strong> — the smallest unit of a digital image; its value represents light intensity at that point.</p>
</li>
<li><p><strong>Channel</strong> — a single matrix layer within an image (e.g., R, G, or B); the number of channels determines an image's color dimensionality.</p>
</li>
<li><p><strong>Resolution</strong> — the number of pixels wide × tall in an image.</p>
</li>
<li><p><strong>Color Depth</strong> — the number of bits used to represent each pixel's value (determines the number of possible levels/colors).</p>
</li>
<li><p><strong>Kernel / Filter</strong> — a small matrix slid across an image to perform a convolution operation.</p>
</li>
<li><p><strong>Convolution</strong> — a multiply-and-sum operation between a kernel and the overlapping image area, slid across the whole image.</p>
</li>
<li><p><strong>Edge Detection</strong> — a technique for detecting the edges/boundaries of objects in an image.</p>
</li>
<li><p><strong>Hough Transform</strong> — a method for extracting lines from edge-detection output, based on connectivity patterns between edge points.</p>
</li>
<li><p><strong>Semantic Segmentation</strong> — a computer vision technique for assigning a class label to every pixel in an image.</p>
</li>
<li><p><strong>CNN (Convolutional Neural Network)</strong> — a deep learning architecture for image data, where kernel/filter values are learned automatically through training rather than defined manually.</p>
</li>
<li><p><strong>Base64</strong> — an encoding format for representing binary data (like an image) as text, commonly used when sending images through an API/JSON response.</p>
</li>
<li><p><strong>ngrok</strong> — a tunneling service that makes a local server (e.g., on Colab) accessible via a temporary public URL, without needing an actual cloud deployment.</p>
</li>
</ul>
<hr />
<h3>Wrapping Up</h3>
<p>Computer Vision looks intimidating from the outside, but once you've got the fundamentals down — pixels, matrices, and convolution — every advanced topic (CNNs, object detection, segmentation) turns out to be built on that same foundation. These same concepts will keep showing up all the way through to modern deep learning.</p>
<p>If you try any of the code above, share your results in the comments — I'd love to see what kernels you experimented with! 👇</p>
<p><strong>Full source code</strong> (notebook, README, all verified working) is open on GitHub: <a href="https://github.com/arielshakaramiro/computer-vision-image-processing">github.com/arielshakaramiro/computer-vision-image-processing</a></p>
<hr />
<p><em>This article is part of a learning-notes series on Computer Vision &amp; Image Processing. Follow along for the next post in the series on Convolutional Neural Networks (CNNs).</em></p>
<p><strong>Image credit:</strong> The Spider-Man illustration used as the example image throughout this article was sourced from <a href="https://id.pinterest.com/pin/828169819021977010/">Pinterest</a>, used purely for demonstrating image-processing techniques. Spider-Man is a trademark/copyright of Marvel/Sony.</p>
<p><strong>Tags:</strong> <code>computer-vision</code> <code>opencv</code> <code>python</code> <code>machine-learning</code> <code>fastapi</code> <code>image-processing</code> <code>ai</code> <code>deep-learning</code></p>
]]></content:encoded></item><item><title><![CDATA[Computer Vision untuk Pemula: Dari Piksel Sampai Bikin API-nya Sendiri (OpenCV + FastAPI)]]></title><description><![CDATA[🧠 TL;DR — Gambar digital cuma kumpulan angka dalam bentuk matriks. Begitu kamu paham itu, semua konsep di Computer Vision — dari grayscale, convolution, sampai CNN — jadi jauh lebih masuk akal. Di po]]></description><link>https://shaka-ai.hashnode.dev/computer-vision-pemula-opencv-fastapi</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/computer-vision-pemula-opencv-fastapi</guid><category><![CDATA[Computer Vision]]></category><category><![CDATA[opencv]]></category><category><![CDATA[Python]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[image processing]]></category><category><![CDATA[AI]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[DeepLearning]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 13 Sep 2026 16:54:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/f5a05f7d-cfec-4daf-8ebe-b1b3013d42f3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>🧠 <strong>TL;DR</strong> — Gambar digital cuma kumpulan angka dalam bentuk matriks. Begitu kamu paham itu, semua konsep di Computer Vision — dari grayscale, convolution, sampai CNN — jadi jauh lebih masuk akal. Di post ini kita bongkar konsepnya pelan-pelan, sambil coding bareng pakai OpenCV, terus kita bungkus jadi API pakai FastAPI.</p>
</blockquote>
<p>Pernah kepikiran nggak, gimana caranya HP kamu bisa tahu ada wajah di foto? Atau gimana caranya CCTV bisa "curiga" kalau ada orang masuk area terlarang? Semua itu berawal dari satu ide sederhana yang sering diremehkan orang: <strong>komputer sebenarnya nggak "melihat" gambar seperti kita — dia cuma melihat angka.</strong></p>
<p>Begitu kamu paham cara komputer "melihat" angka ini, pintu masuk ke dunia Computer Vision jadi jauh lebih terang. Yuk kita bongkar satu-satu, plus ada beberapa titik di mana kamu bisa berhenti sejenak buat nge-test pemahaman kamu sendiri. 👇</p>
<h2>📋 Yang Akan Kamu Pelajari</h2>
<ul>
<li><p>Apa Itu Computer Vision, Sebenarnya?</p>
</li>
<li><p>Anatomi Sebuah Gambar Digital</p>
</li>
<li><p>Image Processing vs Computer Vision — Bedanya Apa?</p>
</li>
<li><p>Di Mana Computer Vision Dipakai?</p>
</li>
<li><p>Praktik: 5 Operasi Dasar dengan OpenCV</p>
</li>
<li><p>Bonus: Edge Detection Lanjutan (Sobel, Laplacian, Canny+Blur)</p>
</li>
<li><p>Dari Kernel Manual ke CNN</p>
</li>
<li><p>Bungkus Jadi API dengan FastAPI</p>
</li>
<li><p>Bonus: Endpoint Versi Gambar Langsung &amp; Bukti Deploy Nyata</p>
</li>
<li><p>Cheat Sheet &amp; Kuis Mini</p>
</li>
</ul>
<blockquote>
<p>💡 Kalau kamu publish di Hashnode, aktifkan toggle <strong>"Table of Contents"</strong> di pengaturan artikel (bagian <em>Post Settings</em> saat publish) — Hashnode otomatis bikin navigasi loncat-ke-section yang beneran jalan, dari heading yang ada di artikel ini.</p>
</blockquote>
<hr />
<h2>Apa Itu Computer Vision, Sebenarnya?</h2>
<p>Definisi klasik AI: sistem yang bisa berpikir dan bertindak layaknya manusia. Nah, kalau kecerdasan itu diarahkan ke <strong>bahasa</strong>, itu namanya NLP. Kalau diarahkan ke <strong>penglihatan</strong>, itu namanya <strong>Computer Vision</strong>.</p>
<blockquote>
<p>💡 <strong>Computer Vision</strong> adalah cabang AI yang membuat komputer bisa memahami dan menafsirkan informasi visual dari gambar atau video — mendeteksi objek, mengenali wajah, bahkan mengambil keputusan dari apa yang "dilihatnya".</p>
</blockquote>
<p>Tapi sebelum komputer bisa "mikir" soal gambar, dia butuh fondasi yang lebih dasar dulu: <strong>Image Processing</strong>. Ibarat belajar bahasa, kamu nggak bisa langsung nulis puisi sebelum kenal huruf. Image processing itu "huruf"-nya.</p>
<p>🧩 <strong>Quick check #1:</strong> Kalau NLP itu kecerdasan linguistik, Computer Vision itu kecerdasan apa?</p>
<p><em>(coba jawab dulu di kepala kamu sebelum baca baris berikutnya 👇)</em></p>
<blockquote>
<p>✅ <strong>Jawaban:</strong> Kecerdasan <strong>visual</strong> — kemampuan memahami informasi dari gambar/video, bukan teks.</p>
</blockquote>
<h2>Anatomi Sebuah Gambar Digital</h2>
<p>Ini bagian paling penting yang sering dilewatkan orang: <strong>gambar digital itu matriks angka.</strong></p>
<p>Setiap gambar tersusun dari <strong>piksel</strong> — satuan terkecil sebuah gambar — dan tiap piksel punya nilai yang menyatakan <strong>intensitas cahaya</strong> di titik itu. Makin gelap, nilainya makin mendekati 0. Makin terang, nilainya makin tinggi.</p>
<h3>Grayscale vs RGB</h3>
<table>
<thead>
<tr>
<th></th>
<th>Grayscale</th>
<th>RGB (Berwarna)</th>
</tr>
</thead>
<tbody><tr>
<td>Jumlah channel</td>
<td>1</td>
<td>3 (Red, Green, Blue)</td>
</tr>
<tr>
<td>Rentang nilai per piksel</td>
<td>0 (hitam) – 255 (putih)</td>
<td>3 nilai per piksel (R, G, B)</td>
</tr>
<tr>
<td>Bentuk representasi</td>
<td>Matriks 2D</td>
<td>Matriks 3D (ditumpuk 3 layer)</td>
</tr>
</tbody></table>
<p>Warna itu sebenarnya cuma hasil kombinasi tiga angka. Contoh:</p>
<ul>
<li><p><code>R=255, G=0, B=0</code> → merah menyala 🔴</p>
</li>
<li><p><code>R=G=125, B=0</code> → kekuningan 🟡</p>
</li>
<li><p><code>R=0, G=0, B=0</code> → hitam pekat ⚫</p>
</li>
</ul>
<p>Ini juga alasan kenapa color picker di software desain selalu nunjukkin tiga slider RGB — itu literally angka-angka piksel yang lagi kamu atur.</p>
<h3>Resolusi &amp; Color Depth</h3>
<p>Resolusi <code>1920 × 1080</code> artinya ada 1920 piksel di lebar dan 1080 di tinggi. Kalau gambarnya berwarna, total angka yang harus disimpan komputer adalah:</p>
<pre><code class="language-plaintext">1920 × 1080 × 3 channel = 6.220.800 angka
</code></pre>
<p>...hanya untuk <strong>satu</strong> gambar. Ini kenapa gambar resolusi tinggi lebih "berat" diproses — makin detail, makin banyak angka yang harus dihitung.</p>
<p>Sementara <strong>color depth</strong> menentukan berapa banyak level warna yang mungkin: 8-bit = 256 level (0–255), 24-bit RGB = sekitar <strong>16 juta warna</strong> kombinasi.</p>
<p>🧮 <strong>Coba hitung sendiri:</strong> Berapa total angka yang direpresentasikan gambar grayscale 640×480?</p>
<p><em>(pause dulu, hitung manual, baru scroll)</em></p>
<blockquote>
<p>✅ <strong>Jawaban:</strong> 640 × 480 × 1 channel = <strong>307.200 angka</strong>. Bandingkan dengan versi berwarnanya yang butuh 3× lebih banyak — 921.600 angka! Itu sebabnya konversi ke grayscale sering dipakai buat mempercepat pemrosesan.</p>
</blockquote>
<h3>Dimensi Gambar: 2D vs 3D</h3>
<p>Satu karakteristik lagi yang sering disebut belakangan: gambar juga punya "dimensi" representasi.</p>
<ul>
<li><p><strong>Gambar 2D</strong> — gray scale, cuma satu lapisan matriks piksel.</p>
</li>
<li><p><strong>Gambar 3D</strong> — gambar berwarna, di mana channel-channel RGB ditumpuk jadi satu struktur data (contoh bentuknya: array <code>224 × 224 × 3</code>).</p>
</li>
</ul>
<p>Istilah ini bakal sering muncul lagi pas kamu belajar deep learning — terutama waktu ngomongin "input shape" di sebuah model.</p>
<h2>Image Processing vs Computer Vision — Bedanya Apa?</h2>
<p>Dua istilah ini sering dianggap sama padahal beda — meski saling terkait erat.</p>
<table>
<thead>
<tr>
<th></th>
<th>Image Processing</th>
<th>Computer Vision</th>
</tr>
</thead>
<tbody><tr>
<td>Input</td>
<td>Gambar</td>
<td>Gambar / video</td>
</tr>
<tr>
<td>Output</td>
<td>Gambar (sudah dimanipulasi)</td>
<td>Interpretasi (label, koordinat, deskripsi)</td>
</tr>
<tr>
<td>Level operasi</td>
<td>Low-level, per piksel</td>
<td>Lebih kompleks &amp; menyeluruh</td>
</tr>
<tr>
<td>Contoh</td>
<td>Blur, crop, edge detection</td>
<td>Deteksi objek, klasifikasi, face recognition</td>
</tr>
</tbody></table>
<p>Gampangnya: <strong>image processing mengubah gambar jadi gambar lain, computer vision mengubah gambar jadi pemahaman.</strong> Dan biasanya, computer vision butuh image processing sebagai langkah awal sebelum bisa "mengerti" apa yang ada di gambar.</p>
<p>Operasi image processing sendiri macam-macam bentuknya — filtering, penghalusan gambar, peningkatan kontras, segmentasi, sampai transformasi warna — dan dipakai luas di banyak bidang: dunia medis, fotografi, pengawasan (surveillance), sampai jadi fondasi hampir semua aplikasi AI yang berurusan sama visual.</p>
<h2>Di Mana Computer Vision Dipakai?</h2>
<p>Ini bukan teori kosong — computer vision udah dipakai di banyak industri, sering tanpa kamu sadari:</p>
<p><strong>🎓 Education</strong></p>
<ul>
<li><p>Pengawasan ujian — mendeteksi kecurangan lewat webcam: siswa ngobrol sama orang lain, sering menoleh, atau keluar dari frame kamera dalam waktu lama otomatis kena flag sebagai peringatan.</p>
</li>
<li><p>Pengenalan tulisan tangan — menilai jawaban ujian atau tugas tulisan tangan siswa secara otomatis.</p>
</li>
</ul>
<p><strong>🏦 Banking / Administratif</strong></p>
<ul>
<li>Verifikasi tanda tangan dan tulisan tangan pada dokumen untuk keperluan validasi keaslian.</li>
</ul>
<p><strong>🏥 Healthcare</strong></p>
<ul>
<li><p>Deteksi penyakit pada citra medis — memindai MRI, CT scan, atau X-ray untuk mendeteksi kanker, kelainan jantung, atau tumor secara otomatis dengan akurasi tinggi.</p>
</li>
<li><p>Analisis citra mikroskopis — memeriksa sel/jaringan untuk menemukan abnormalitas seperti infeksi atau perubahan patologis.</p>
</li>
</ul>
<p><strong>🏭 Manufaktur</strong></p>
<ul>
<li>Inspeksi kualitas otomatis (visual inspection) — mendeteksi cacat, retak, atau ketidaksesuaian ukuran di lini produksi (contoh klasik: cek cacat pada layar HP) dengan kecepatan dan ketelitian yang jauh di atas mata manusia.</li>
</ul>
<p><strong>🔒 Security / Surveillance</strong></p>
<ul>
<li><p>Pengawasan keamanan — sistem CCTV pintar (termasuk di kota pintar atau area tambang) mendeteksi aktivitas atau perilaku mencurigakan, misalnya orang yang masuk ke area terlarang.</p>
</li>
<li><p>Pengecekan kelengkapan APD pekerja — otomatis mengenali apakah pekerja sudah pakai helm, masker, sarung tangan, dan perlengkapan wajib lainnya.</p>
</li>
</ul>
<p><strong>📄 Lainnya</strong></p>
<ul>
<li>OCR untuk pemrosesan dokumen, dan face recognition yang sekarang udah jadi standar di banyak aplikasi sehari-hari.</li>
</ul>
<p>Hampir semua use case di atas dimulai dari hal yang sama: <strong>memproses piksel gambar sampai komputer bisa "membaca" polanya.</strong></p>
<h2>Praktik: 5 Operasi Dasar dengan OpenCV</h2>
<p>Cukup teori — sekarang kita coding. Kita akan pakai <strong>OpenCV</strong>, salah satu library Python paling populer untuk image processing, biar nggak perlu implementasi algoritma dari nol.</p>
<blockquote>
<p>⚠️ <strong>Gotcha yang wajib diingat:</strong> OpenCV membaca gambar dalam format <strong>BGR</strong>, bukan RGB! Titik (0,0) juga ada di pojok kiri atas, bukan di tengah seperti koordinat kartesius biasa.</p>
</blockquote>
<h3>1️⃣ Image Cropping</h3>
<p>Cropping itu sebenarnya cuma <strong>slicing NumPy array</strong> — karena gambar yang dibaca OpenCV memang berbentuk array 2D.</p>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')

# [y_start:y_end, x_start:x_end] — baris dulu, baru kolom
cropped_image = image[50:200, 100:300]

cv2.imwrite('cropped_image.jpg', cropped_image)
</code></pre>
<h3>2️⃣ Grayscale</h3>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')
grayscale_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite('grayscale_image.jpg', grayscale_image)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/cabcbf32-9d6e-4e2d-b5f1-2c2520f1f243.jpg" alt="Hasil grayscale" style="display:block;margin:0 auto" />

<p><em>Hasil nyata dari kode di atas — asli berwarna diubah jadi hitam-putih, cuma nyimpen intensitas cahaya.</em></p>
<h3>3️⃣ Channel Split</h3>
<p>Berguna banget saat masuk ke <strong>semantic segmentation</strong> — tiap channel bisa merepresentasikan mask kelas objek yang berbeda.</p>
<pre><code class="language-python">import cv2

image = cv2.imread('input_image.jpg')
blue_channel, green_channel, red_channel = cv2.split(image)

cv2.imwrite('red_channel.jpg', red_channel)
cv2.imwrite('green_channel.jpg', green_channel)
cv2.imwrite('blue_channel.jpg', blue_channel)
</code></pre>
<h3>4️⃣ Convolution — Jantungnya Image Processing</h3>
<p>Ini konsep paling penting di seluruh artikel ini. Bayangkan sebuah <strong>kernel</strong> (matriks kecil, misal 5×5) yang digeser pelan-pelan di atas gambar. Di setiap posisi, nilai kernel dikalikan dengan piksel di bawahnya, lalu dijumlahkan jadi satu nilai output.</p>
<p>Kernel yang berbeda = efek yang berbeda:</p>
<ul>
<li><p>Kernel isi rata-rata → <strong>blur</strong> 🌫️</p>
</li>
<li><p>Kernel <code>[[0,-1,0],[-1,4,-1],[0,-1,0]]</code> → <strong>edge detection</strong> ✏️</p>
</li>
<li><p>Kernel identitas → gambar nggak berubah</p>
</li>
</ul>
<pre><code class="language-python">import cv2
import numpy as np

image = cv2.imread('input_image.jpg')

kernel = np.ones((5, 5), np.float32) / 25  # kernel blur
convolved_image = cv2.filter2D(image, -1, kernel)

cv2.imwrite('convolved_image.jpg', convolved_image)
</code></pre>
<p>🧪 <strong>Coba sendiri:</strong> Ganti kernel di atas dengan kernel edge detection <code>[[0,-1,0],[-1,4,-1],[0,-1,0]]</code>. Apa yang terjadi?</p>
<blockquote>
<p>✅ <strong>Jawaban:</strong> Gambar hasilnya akan menampilkan <strong>garis-garis tepi (edge)</strong> dari objek di foto, bukan lagi gambar utuh yang blur. Ini karena kernel tersebut "menonjolkan" perbedaan intensitas antar piksel bertetangga — persis di mana tepi objek berada.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/428a0806-1983-4f6f-af37-4dbe35d7ddbd.jpg" alt="Hasil convolution dengan kernel edge detection" style="display:block;margin:0 auto" />

<p><em>Ini bukan simulasi — ini hasil beneran dari kernel edge detection di atas, diterapkan ke gambar Spider-Man. Lihat gimana kernelnya "menonjolkan" garis-garis kostumnya.</em></p>
<h3>5️⃣ Line Detection (Canny + Hough Transform)</h3>
<pre><code class="language-python">import cv2
import numpy as np

image = cv2.imread('input_image.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

edges = cv2.Canny(gray, 50, 150, apertureSize=3)
lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)

if lines is not None:
    for rho, theta in lines[:, 0]:
        a, b = np.cos(theta), np.sin(theta)
        x0, y0 = a * rho, b * rho
        x1, y1 = int(x0 + 1000 * (-b)), int(y0 + 1000 * (a))
        x2, y2 = int(x0 - 1000 * (-b)), int(y0 - 1000 * (a))
        cv2.line(image, (x1, y1), (x2, y2), (0, 0, 255), 2)

cv2.imwrite('line_detected_image.jpg', image)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/73ffa86d-00b7-4a83-ace3-91c02293502d.jpg" alt="Hasil line detection" style="display:block;margin:0 auto" />

<p><em>Garis-garis merah adalah hasil Hough Transform — mendeteksi pola garis lurus dari tepi-tepi yang ditemukan Canny sebelumnya.</em></p>
<p>Cara kerjanya: <strong>Canny</strong> dulu cari tepi-tepinya, lalu <strong>Hough Transform</strong> mencari pola garis dari titik-titik tepi tersebut — mirip cara kamu menghubungkan titik-titik jadi garis lurus.</p>
<blockquote>
<p>💡 <strong>Catatan praktik:</strong> kalau garis hasil deteksi kelihatan terlalu pendek/aneh di gambar resolusi besar, panjang garis (<code>1000</code> di kode atas) bisa dibuat dinamis mengikuti dimensi gambar: <code>line_length = max(img_height, img_width)</code>. Threshold Hough Transform juga bisa diturunkan (mis. dari <code>200</code> ke <code>80</code>) kalau garis yang kedeteksi kurang banyak.</p>
</blockquote>
<h2>Bonus: Edge Detection Lanjutan — Sobel, Laplacian &amp; Canny + Gaussian Blur</h2>
<p>Canny bukan satu-satunya cara deteksi tepi. Beberapa variasi lain yang sering dipakai:</p>
<p><strong>Canny langsung dari grayscale</strong></p>
<pre><code class="language-python">import cv2

img = cv2.imread('input_image.jpg', cv2.IMREAD_GRAYSCALE)
edges = cv2.Canny(img, 100, 200)
cv2.imwrite('canny_edge_detection.jpg', edges)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/16fc6e8f-2f2e-4c72-8931-214e2df69d92.jpg" alt="Hasil Canny" style="display:block;margin:0 auto" />

<p><strong>Sobel Operator</strong> — gradien intensitas dihitung terpisah di sumbu horizontal &amp; vertikal, lalu digabung jadi satu magnitude.</p>
<pre><code class="language-python">sobelx = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)
sobely = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)

gradient_magnitude = cv2.magnitude(sobelx, sobely)
gradient_magnitude = cv2.convertScaleAbs(gradient_magnitude)
cv2.imwrite('sobel_edge_detection.jpg', gradient_magnitude)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/5fc44202-d313-4783-b916-6aee060d8cc6.jpg" alt="Hasil Sobel" style="display:block;margin:0 auto" />

<p><strong>Laplacian Operator</strong> — pakai turunan kedua, sensitif ke perubahan intensitas di segala arah sekaligus (bukan cuma horizontal/vertikal kayak Sobel).</p>
<pre><code class="language-python">laplacian = cv2.Laplacian(img, cv2.CV_64F)
laplacian_abs = cv2.convertScaleAbs(laplacian)
cv2.imwrite('laplacian_detection.jpg', laplacian_abs)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/226a23ac-5654-4d16-978b-d78f318ac2e7.jpg" alt="Hasil Laplacian" style="display:block;margin:0 auto" />

<p><strong>Gaussian Blur + Canny</strong> — smoothing dulu sebelum Canny, biar nggak nangkep tepi-tepi palsu akibat noise.</p>
<pre><code class="language-python">blur = cv2.GaussianBlur(img, (5, 5), 1.4)
edges = cv2.Canny(blur, threshold1=100, threshold2=200)
cv2.imwrite('canny_edge_detection_blurred.jpg', edges)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/e235b2e1-bd4f-4542-beb7-fa9bbeedcbab.jpg" alt="Hasil Gaussian Blur + Canny" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Operator</th>
<th>Karakteristik</th>
<th>Kapan dipakai</th>
</tr>
</thead>
<tbody><tr>
<td>Canny</td>
<td>Multi-tahap, hasil tepi tipis &amp; bersih</td>
<td>Deteksi tepi umum, dasar Line Detection</td>
</tr>
<tr>
<td>Sobel</td>
<td>Gradien terpisah horizontal &amp; vertikal</td>
<td>Butuh tahu arah tepi</td>
</tr>
<tr>
<td>Laplacian</td>
<td>Turunan kedua, sensitif segala arah</td>
<td>Cepat, tapi rentan noise</td>
</tr>
<tr>
<td>Gaussian Blur + Canny</td>
<td>Canny + pre-processing smoothing</td>
<td>Gambar noise tinggi</td>
</tr>
</tbody></table>
<h2>Dari Kernel Manual ke CNN: Lompatan yang Mengubah Segalanya</h2>
<p>Sekarang pertanyaan pentingnya: <strong>gimana kita tahu nilai kernel yang "benar" untuk tugas tertentu?</strong></p>
<p>Jawabannya: kita nggak perlu menentukannya secara manual. Di sinilah <strong>Convolutional Neural Network (CNN)</strong> masuk — alih-alih kernel yang nilainya tetap (fixed), CNN <strong>mempelajari nilai kernel terbaik lewat proses training</strong>, menyesuaikan dengan data yang diberikan.</p>
<blockquote>
<p>🚀 Inilah kenapa CNN jauh lebih powerful dibanding operasi convolution manual: kernelnya adaptif, bukan hardcoded.</p>
</blockquote>
<p>Dari sisi arsitektur, basic-nya CNN tetap neural network biasa — cuma ditambah serangkaian <strong>convolutional layer</strong> yang tugasnya nyari nilai-nilai terbaik buat mengisi filter, supaya ekstraksi fitur dari gambar jadi maksimal.</p>
<p>CNN sendiri adalah turunan dari deep learning yang khusus dipakai untuk data gambar — beda "keluarga" dengan RNN, Transformer, atau LSTM yang biasa dipakai buat data sekuensial seperti teks. Kalau kamu sudah kenal arsitektur-arsitektur itu dari sisi NLP, CNN adalah versi "sepupunya" yang didesain khusus buat data visual.</p>
<p>CNN adalah topik besar tersendiri yang akan kita bahas lebih dalam nanti — tapi sekarang kamu udah tahu fondasinya: <strong>convolution</strong>, yang barusan kita praktikkan sendiri di atas.</p>
<h2>Bungkus Jadi API dengan FastAPI</h2>
<p>Terakhir, kelima operasi di atas bisa langsung "dijual" sebagai layanan lewat API. Idenya: terima gambar → proses di memori (tanpa simpan ke disk) → kembalikan hasilnya dalam format <strong>base64</strong> — karena endpoint API nggak bisa langsung ngembaliin file gambar mentah.</p>
<h3>Instalasi</h3>
<pre><code class="language-bash">pip install fastapi uvicorn pillow opencv-python
</code></pre>
<h3>Setup &amp; Helper Function</h3>
<pre><code class="language-python">from fastapi import FastAPI, UploadFile, File
from PIL import Image, ImageOps
import io, base64, cv2
import numpy as np

app = FastAPI()

def pil_image_to_base64(image: Image.Image):
    buffered = io.BytesIO()
    image.save(buffered, format="JPEG")
    return base64.b64encode(buffered.getvalue()).decode("utf-8")

def cv2_image_to_base64(image):
    _, buffer = cv2.imencode('.jpg', image)
    return base64.b64encode(buffer).decode("utf-8")
</code></pre>
<p>Dari dua helper function itu, tinggal bikin satu endpoint per operasi:</p>
<p><code>/crop/</code> — potong gambar sesuai koordinat</p>
<pre><code class="language-python">@app.post("/crop/")
async def crop_image(file: UploadFile = File(...), x: int = 0,
                      y: int = 0, width: int = 100, height: int = 100):
    image = Image.open(io.BytesIO(await file.read()))
    cropped_image = image.crop((x, y, x + width, y + height))
    return {"image_base64": pil_image_to_base64(cropped_image)}
</code></pre>
<p><code>/grayscale/</code> — konversi ke grayscale</p>
<pre><code class="language-python">@app.post("/grayscale/")
async def grayscale_image(file: UploadFile = File(...)):
    image = Image.open(io.BytesIO(await file.read()))
    grayscale_image = ImageOps.grayscale(image)
    return {"image_base64": pil_image_to_base64(grayscale_image)}
</code></pre>
<p><code>/channel_split/</code> — ambil salah satu channel warna</p>
<pre><code class="language-python">@app.post("/channel_split/")
async def channel_split(file: UploadFile = File(...), channel: str = "red"):
    image = cv2.imdecode(np.frombuffer(await file.read(), np.uint8),
                          cv2.IMREAD_COLOR)
    (blue, green, red) = cv2.split(image)
    if channel == "red":
        return {"image_base64": cv2_image_to_base64(red)}
    elif channel == "green":
        return {"image_base64": cv2_image_to_base64(green)}
    else:
        return {"image_base64": cv2_image_to_base64(blue)}
</code></pre>
<p><code>/convolution/</code> — terapkan filter blur</p>
<pre><code class="language-python">@app.post("/convolution/")
async def convolution(file: UploadFile = File(...)):
    image = cv2.imdecode(np.frombuffer(await file.read(),
                          np.uint8), cv2.IMREAD_COLOR)
    kernel = np.ones((5, 5), np.float32) / 25
    convolved_image = cv2.filter2D(image, -1, kernel)
    return {"image_base64": cv2_image_to_base64(convolved_image)}
</code></pre>
<p><code>/line_detection/</code> — deteksi garis via Canny + Hough Transform</p>
<pre><code class="language-python">@app.post("/line_detection/")
async def line_detection(file: UploadFile = File(...)):
    image = cv2.imdecode(np.frombuffer(await file.read(),
                          np.uint8), cv2.IMREAD_COLOR)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, 50, 150, apertureSize=3)
    lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)

    if lines is not None:
        for rho, theta in lines[:, 0]:
            a, b = np.cos(theta), np.sin(theta)
            x0, y0 = a * rho, b * rho
            x1, y1 = int(x0 + 1000 * (-b)), int(y0 + 1000 * (a))
            x2, y2 = int(x0 - 1000 * (-b)), int(y0 - 1000 * (a))
            cv2.line(image, (x1, y1), (x2, y2), (0, 0, 255), 2)

    return {"image_base64": cv2_image_to_base64(image)}
</code></pre>
<p>Lima endpoint, lima operasi image processing yang udah kamu pelajari dari awal — dan sekarang siap dipanggil orang lain lewat HTTP request. 🎉</p>
<h3>Bonus: Endpoint Versi "Gambar Langsung" (Bukan Base64)</h3>
<p>Kelima endpoint di atas balikin JSON berisi base64 — format ini paling cocok kalau API-nya dipanggil dari aplikasi lain (web/mobile/backend), tapi kurang enak dilihat langsung di browser atau Swagger UI, karena cuma nongol sebagai teks panjang.</p>
<p>Solusinya gampang: bikin versi endpoint yang <strong>response-nya langsung file gambar</strong> (<code>media_type="image/jpeg"</code>), bukan JSON. Cukup tambah dua helper function buat convert ke bytes mentah (bukan base64), lalu bungkus pakai <code>Response</code> dari FastAPI:</p>
<pre><code class="language-python">from fastapi.responses import Response

def pil_image_to_bytes(image: Image.Image) -&gt; bytes:
    buffered = io.BytesIO()
    image.save(buffered, format="JPEG")
    return buffered.getvalue()

def cv2_image_to_bytes(image) -&gt; bytes:
    _, buffer = cv2.imencode('.jpg', image)
    return buffer.tobytes()
</code></pre>
<p>Lalu bikin versi "-image" dari tiap endpoint. Contoh buat grayscale:</p>
<pre><code class="language-python">@app.post("/grayscale-image/")
async def grayscale_image_raw(file: UploadFile = File(...)):
    image = Image.open(io.BytesIO(await file.read()))
    grayscale_image = ImageOps.grayscale(image)
    return Response(content=pil_image_to_bytes(grayscale_image), media_type="image/jpeg")
</code></pre>
<p>Pola yang sama tinggal diulang buat <code>/crop-image/</code>, <code>/channel_split-image/</code>, <code>/convolution-image/</code>, dan <code>/line_detection-image/</code> — logikanya identik dengan versi base64, cuma baris <code>return</code>-nya yang beda.</p>
<blockquote>
<p>💡 Sekarang API kamu punya dua "rasa" endpoint: yang JSON+base64 buat dipanggil program lain, dan yang gambar langsung buat testing cepat atau dipakai langsung di <code>&lt;img src="..."&gt;</code>.</p>
</blockquote>
<h3>Bukti Nyata: Deploy &amp; Testing Pakai ngrok</h3>
<p>Semua kode di atas sudah divalidasi jalan (dites lewat <code>TestClient</code> FastAPI, request beneran sampai ke fungsi endpoint-nya, bukan cuma dibaca sekilas). Tapi lebih dari itu — dua endpoint di antaranya (<code>/grayscale/</code> dan <code>/grayscale-image/</code>) sudah dites langsung lewat <strong>deployment publik sungguhan</strong>: server jalan di Google Colab, di-expose ke internet pakai <a href="https://ngrok.com">ngrok</a>, dan diakses dari luar lewat Swagger UI (<code>/docs</code>) yang otomatis dibikin FastAPI. Ini buktinya:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/a1771281-bca6-46c5-8648-9fe86f2a74c7.png" alt="Hasil testing endpoint /grayscale-image/ lewat Swagger UI, server beneran online" style="display:block;margin:0 auto" />

<p><em>Endpoint</em> <code>/grayscale-image/</code> <em>dites pakai</em> <code>Spiderman.jpg</code><em>, hasilnya langsung dirender sebagai gambar di response panel. Perhatikan response header:</em> <code>content-type: image/jpeg</code><em>,</em> <code>server: uvicorn</code><em>, dan</em> <code>ngrok-agent-ips</code> <em>— bukti request-nya beneran lewat tunnel ngrok dari internet, bukan simulasi lokal.</em></p>
<p>Kalau kamu mau coba deploy versi kamu sendiri dari Google Colab (gratis), garis besarnya:</p>
<ol>
<li><p>Install <code>nest-asyncio</code> dan <code>pyngrok</code>.</p>
</li>
<li><p>Daftar akun ngrok gratis, ambil authtoken dari <a href="https://dashboard.ngrok.com/get-started/your-authtoken">dashboard ngrok</a>.</p>
</li>
<li><p>Jalankan <code>uvicorn</code> di dalam <code>asyncio.create_task()</code> (bukan <code>uvicorn.run()</code> biasa — Colab udah punya event loop sendiri yang bakal bentrok kalau dipanggil langsung).</p>
</li>
<li><p>ngrok bakal kasih URL publik (<code>https://xxxx.ngrok-free.dev</code>) yang nge-tunnel ke <code>localhost:8000</code> di Colab kamu.</p>
</li>
</ol>
<p><em>(Detail lengkapnya — termasuk beberapa jebakan umum kayak port conflict dan</em> <code>SystemExit</code> <em>yang bisa bikin kernel Colab crash kalau server lama nggak dimatikan dengan benar — mungkin saya bahas terpisah di post lain, karena cukup panjang buat dijelasin di sini.)</em></p>
<h2>Cheat Sheet &amp; Kuis Mini</h2>
<p>Sebelum kamu tutup tab ini, yuk konsolidasi semua yang udah dipelajari:</p>
<table>
<thead>
<tr>
<th>Operasi</th>
<th>Fungsinya</th>
<th>Input → Output</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody><tr>
<td>Cropping</td>
<td>Potong area tertentu</td>
<td>Gambar → gambar terpotong</td>
<td><code>/crop/</code></td>
</tr>
<tr>
<td>Grayscale</td>
<td>Hapus info warna</td>
<td>Gambar berwarna → hitam-putih</td>
<td><code>/grayscale/</code></td>
</tr>
<tr>
<td>Channel Split</td>
<td>Pisah channel R/G/B</td>
<td>1 gambar → 3 gambar per channel</td>
<td><code>/channel_split/</code></td>
</tr>
<tr>
<td>Convolution</td>
<td>Terapkan filter via kernel</td>
<td>Gambar → gambar terfilter</td>
<td><code>/convolution/</code></td>
</tr>
<tr>
<td>Line Detection</td>
<td>Deteksi garis (Canny+Hough)</td>
<td>Gambar → gambar bergaris tertandai</td>
<td><code>/line_detection/</code></td>
</tr>
</tbody></table>
<h3>🎯 Kuis Cepat — Tes Pemahaman Kamu</h3>
<p><em>Coba jawab dulu ketiga pertanyaan ini di kepala kamu sebelum baca jawabannya di bawah masing-masing soal.</em></p>
<p><strong>1. Kenapa OpenCV kadang bikin warna gambar "aneh" kalau langsung ditampilkan pakai library lain?</strong></p>
<blockquote>
<p>✅ Karena OpenCV membaca gambar dalam format <strong>BGR</strong>, bukan RGB. Kalau kamu tampilkan langsung pakai library yang mengasumsikan RGB (misalnya matplotlib), channel merah dan birunya akan tertukar. Solusinya: konversi dulu pakai <code>cv2.COLOR_BGR2RGB</code>.</p>
</blockquote>
<p><strong>2. Apa perbedaan paling mendasar antara image processing dan computer vision?</strong></p>
<blockquote>
<p>✅ Image processing: <strong>input gambar, output gambar</strong> (yang sudah dimanipulasi). Computer vision: <strong>input gambar/video, output interpretasi</strong> (label, koordinat, deskripsi).</p>
</blockquote>
<p><strong>3. Kenapa CNN dianggap "lebih baik" daripada convolution dengan kernel manual (predefined)?</strong></p>
<blockquote>
<p>✅ Karena nilai kernel di CNN <strong>dipelajari otomatis lewat training</strong> dan jadi adaptif terhadap data — sementara kernel manual bersifat tetap (fixed) dan butuh banyak trial-and-error untuk cocok dengan kasus tertentu.</p>
</blockquote>
<h3>📖 Glosarium Istilah Penting</h3>
<p>Simpan bagian ini buat referensi cepat kalau nanti lupa istilah:</p>
<ul>
<li><p><strong>Piksel</strong> — satuan terkecil dari gambar digital; nilainya menyatakan intensitas cahaya pada titik tersebut.</p>
</li>
<li><p><strong>Channel</strong> — satu lapisan matriks pada gambar (mis. R, G, atau B); jumlah channel menentukan dimensi warna gambar.</p>
</li>
<li><p><strong>Resolusi</strong> — jumlah piksel lebar × tinggi pada suatu gambar.</p>
</li>
<li><p><strong>Color Depth</strong> — jumlah bit yang dipakai untuk merepresentasikan nilai tiap piksel (menentukan jumlah level/warna yang mungkin).</p>
</li>
<li><p><strong>Kernel / Filter</strong> — matriks kecil yang digeser di atas gambar untuk melakukan operasi convolution.</p>
</li>
<li><p><strong>Convolution</strong> — operasi perkalian &amp; penjumlahan antara kernel dan area gambar yang bertumpukan, digeser ke seluruh gambar.</p>
</li>
<li><p><strong>Edge Detection</strong> — teknik mendeteksi tepi/garis batas objek pada gambar.</p>
</li>
<li><p><strong>Hough Transform</strong> — metode untuk mengekstrak garis dari hasil edge detection berdasarkan pola keterhubungan titik-titik tepi.</p>
</li>
<li><p><strong>Semantic Segmentation</strong> — teknik computer vision untuk memberi label kelas pada setiap piksel gambar.</p>
</li>
<li><p><strong>CNN (Convolutional Neural Network)</strong> — arsitektur deep learning untuk data gambar, di mana nilai kernel/filter dipelajari otomatis lewat training, bukan ditentukan manual.</p>
</li>
<li><p><strong>Base64</strong> — format encoding untuk merepresentasikan data biner (seperti gambar) sebagai teks, umum dipakai saat mengirim gambar lewat response API/JSON.</p>
</li>
<li><p><strong>ngrok</strong> — layanan tunneling yang bikin server lokal (mis. di Colab) bisa diakses lewat URL publik sementara, tanpa perlu deploy ke cloud beneran.</p>
</li>
</ul>
<hr />
<h3>Penutup</h3>
<p>Computer Vision kelihatannya rumit dari luar, tapi begitu kamu pegang fondasinya — piksel, matriks, dan convolution — semua topik lanjutan (CNN, object detection, segmentation) jadi bangunan yang berdiri di atas dasar yang sama. Konsep yang sama ini juga yang bakal kamu pakai terus sampai ke deep learning modern sekalipun.</p>
<p>Kalau kamu coba salah satu kode di atas, share hasilnya di kolom komentar ya — penasaran lihat kernel eksperimen kamu! 👇</p>
<p><strong>Kode lengkapnya</strong> (notebook, README, semua sudah dites jalan) tersedia terbuka di GitHub: <a href="https://github.com/arielshakaramiro/computer-vision-image-processing">github.com/arielshakaramiro/computer-vision-image-processing</a></p>
<hr />
<p><em>Artikel ini bagian dari seri catatan belajar Computer Vision &amp; Image Processing. Follow untuk nggak ketinggalan seri lanjutannya soal Convolutional Neural Network (CNN).</em></p>
<p><strong>Sumber gambar:</strong> Ilustrasi Spider-Man yang dipakai sebagai contoh gambar di sepanjang artikel ini diambil dari <a href="https://id.pinterest.com/pin/828169819021977010/">Pinterest</a>, dipakai murni untuk keperluan demonstrasi teknik image processing. Karakter Spider-Man adalah hak cipta Marvel/Sony.</p>
<p><strong>Tags:</strong> <code>computer-vision</code> <code>opencv</code> <code>python</code> <code>machine-learning</code> <code>fastapi</code> <code>image-processing</code> <code>ai</code> <code>deep-learning</code></p>
]]></content:encoded></item><item><title><![CDATA[GPT-2 From Scratch vs Fine-Tuning: A Text Generation Experiment and Its Honest Findings]]></title><description><![CDATA[There are two main ways to build a text generation model: train one from nothing, or take a model that already knows a lot and teach it something new. This post runs both, on the same small compute bu]]></description><link>https://shaka-ai.hashnode.dev/gpt2-from-scratch-vs-fine-tuning-text-generation</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/gpt2-from-scratch-vs-fine-tuning-text-generation</guid><category><![CDATA[nlp]]></category><category><![CDATA[gpt2]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[huggingface]]></category><category><![CDATA[Python]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[AI]]></category><category><![CDATA[Beginner Developers]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sat, 12 Sep 2026 14:21:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/b1770dfd-2c2c-47ef-83e7-7fdd8f6f2ca2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There are two main ways to build a text generation model: train one from nothing, or take a model that already knows a lot and teach it something new. This post runs both, on the same small compute budget (a single Colab session, no premium GPU) — and the results turned out to be more instructive than if everything had gone smoothly.</p>
<p>The full project (3 notebooks + results documentation) is on <a href="https://github.com/arielshakaramiro/gpt2-text-generation-lab">GitHub</a>.</p>
<h2>Table of Contents</h2>
<ul>
<li><p><a href="#why-run-both-approaches">Why Run Both Approaches?</a></p>
</li>
<li><p><a href="#guess-first">Guess First</a></p>
</li>
<li><p><a href="#case-study-1-code-generation-from-scratch">Case Study 1: Code Generation From Scratch</a></p>
</li>
<li><p><a href="#shipping-it-as-an-api">Shipping It as an API</a></p>
</li>
<li><p><a href="#case-study-2-fine-tuning-an-indonesian-gpt-2">Case Study 2: Fine-Tuning an Indonesian GPT-2</a></p>
</li>
<li><p><a href="#plot-twist-two-numbers-two-different-stories">Plot Twist: Two Numbers, Two Different Stories</a></p>
</li>
<li><p><a href="#side-by-side-comparison">Side-by-Side Comparison</a></p>
</li>
<li><p><a href="#quick-quiz">Quick Quiz</a></p>
</li>
<li><p><a href="#summary">Summary</a></p>
</li>
</ul>
<h2>Why Run Both Approaches?</h2>
<p><strong>Training from scratch</strong> means the model starts from random weights — it knows nothing about language or code yet. It needs a lot of data and a lot of training steps before it produces anything coherent.</p>
<p><strong>Fine-tuning</strong> means starting from a model that's already been pretrained, then adapting it to a new task or domain with a much smaller dataset.</p>
<p>These two approaches carry genuinely different trade-offs, and the clearest way to understand them isn't through theory — it's running both yourself and looking closely at what actually comes out.</p>
<h2>Guess First</h2>
<p>Before reading further, take a guess:</p>
<blockquote>
<p>If a GPT-2 model is trained from scratch for 196 steps, and a pretrained GPT-2 model is fine-tuned for 250 steps on a tiny dataset (just 50 sentences) — which one do you think produces better text? Does a lower loss always mean a better model, or is there a catch?</p>
</blockquote>
<p>Hold onto your answer. We'll circle back in the Plot Twist section.</p>
<h2>Case Study 1: Code Generation From Scratch</h2>
<p>The first notebook trains a GPT-2 architecture (124M parameters) <strong>entirely from random weights</strong> to complete Python code snippets, using a filtered subset of the CodeParrot dataset (data-science-related code: numpy, pandas, sklearn, and similar).</p>
<p>The pipeline:</p>
<ol>
<li><p>Load the pre-filtered dataset (<code>huggingface-course/codeparrot-ds-train</code>) — 50,000 samples</p>
</li>
<li><p>Tokenize with a code-specific tokenizer</p>
</li>
<li><p>Build an empty GPT-2 config and initialize the model with random weights</p>
</li>
<li><p>Train for 1 epoch (196 steps)</p>
</li>
<li><p>Test by asking the model to complete code from a comment</p>
</li>
</ol>
<p>Final training loss: <strong>6.43</strong>. When asked to complete this prompt:</p>
<pre><code class="language-python"># create some data
x = np.random.randn(100)
y = np.random.randn(100)

# create dataframe from x and y
</code></pre>
<p>instead of continuing with pandas code, the model loops on open-source license boilerplate:</p>
<pre><code class="language-plaintext"># the of the data
# with the terms of the
# This is the terms of the terms of the Free Software Foundation...
# but WITHOUT ANY WARRANTIES OR CONDITIONS OF ANY WITHOUT ANY WARRANTY...
</code></pre>
<p>At first glance this looks like a failure. Look closer and it actually makes sense: a huge number of GitHub Python files open with a long, repeated license comment block. For a model starting from random weights, that pattern is simply easier to pick up early than the deeper structure of real Python code.</p>
<h2>Shipping It as an API</h2>
<p>The model from notebook 1 gets wrapped in a FastAPI endpoint and exposed through ngrok. What's interesting is that the <em>exact same</em> model produces noticeably different output once sampling parameters (<code>top_k=50, top_p=0.9, temperature=0.8</code>) are turned on — instead of looping on license text, it starts producing more code-like tokens (<code>import</code>, <code>def __init__</code>, <code>class</code>, <code>assert</code>), even though the result still isn't valid Python.</p>
<p>In other words, decoding strategy visibly shapes how "good" an output looks, even when the underlying model's actual competence hasn't changed at all.</p>
<h2>Case Study 2: Fine-Tuning an Indonesian GPT-2</h2>
<p>The third notebook runs in two parts. The first part is pure exploration of existing Indonesian models — fill-mask with Indonesian BERT and RoBERTa, then text generation with a pretrained Indonesian GPT-2 (no fine-tuning yet). Both fill-mask models confidently predict the preposition "di" for <code>"Ibu ku sedang bekerja [MASK] supermarket"</code> — BERT at 94.96% confidence, RoBERTa at 64.92%. A simple sanity check confirming these models behave as expected out of the box.</p>
<p>The second part fine-tunes <code>cahya/gpt2-small-indonesian-522M</code> on a dummy dataset of 50 sentences about dinosaur facts, written in several different styles (encyclopedic, casual slang, children's storytelling). After 10 epochs, the loss drops to <strong>1.27</strong> — far lower than notebook 1's.</p>
<p>Prompted with <code>"Trex merupakan"</code> ("T-Rex is"), it generates:</p>
<blockquote>
<p>Trex merupakan dinosaurus karnivora pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air. Bukti yang dapat diandalkan untuk hidup dan berburu di dalam air menunjukkan bahwa T-Rex memiliki adaptasi unik untuk hidup dan berburu di dalam air...</p>
</blockquote>
<p>On a first read, this sounds genuinely fluent. Which is exactly why it's worth a closer look.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/f4d04618-3c13-4cdf-ad39-bde42251bab6.png" alt="Two approaches compared" style="display:block;margin:0 auto" />

<h2>Plot Twist: Two Numbers, Two Different Stories</h2>
<p>Out of curiosity, I compared that generated sentence against the training data itself, and found this:</p>
<blockquote>
<p><strong>Training sample</strong> (about Spinosaurus): <em>"Spinosaurus adalah dinosaurus karnivora</em> <em><strong>pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air</strong></em><em>."</em></p>
<p><strong>Generated output</strong> (about T-Rex): <em>"Trex merupakan dinosaurus karnivora</em> <em><strong>pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air</strong></em><em>."</em></p>
</blockquote>
<p>Nearly identical, with just the subject swapped. With only 50 training examples repeated over 10 epochs fine-tuning an already-pretrained model, this is likely a textbook sign of overfitting: the model is stitching together memorized fragments rather than generating genuinely new text.</p>
<p>One methodological detail makes this even clearer: the notebook uses the same dataset for both training and evaluation (<code>eval_dataset=tokenized_dataset</code>, identical to <code>train_dataset</code>). That means the reported eval loss can't actually detect overfitting at all — a proper held-out validation split would be needed to measure that honestly.</p>
<p>So a dramatically lower loss and more fluent-sounding output in notebook 3 doesn't necessarily mean a better model; it could just as easily be a sign of memorizing a tiny dataset. Meanwhile, the higher loss and messier output in notebook 1 is simply what from-scratch training looks like on a limited compute budget, rather than evidence that the pipeline failed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/ea1e79f3-1bfc-4f19-b120-ab50dada5799.png" alt="Results summary" style="display:block;margin:0 auto" />

<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th></th>
<th>Notebook 1 — From Scratch</th>
<th>Notebook 3 — Fine-Tuned</th>
</tr>
</thead>
<tbody><tr>
<td>Starting point</td>
<td>Random weights</td>
<td>Pretrained model</td>
</tr>
<tr>
<td>Training data</td>
<td>50,000 code samples</td>
<td>50 dummy sentences</td>
</tr>
<tr>
<td>Steps</td>
<td>196 (1 epoch)</td>
<td>250 (10 epochs)</td>
</tr>
<tr>
<td>Final loss</td>
<td>6.43</td>
<td>1.27</td>
</tr>
<tr>
<td>Output</td>
<td>License boilerplate</td>
<td>Fluent, but memorized</td>
</tr>
<tr>
<td>Likely issue</td>
<td>Undertraining</td>
<td>Overfitting</td>
</tr>
</tbody></table>
<h2>Quick Quiz</h2>
<p><strong>Q1: Does a lower loss always mean a better model?</strong> Not necessarily. A low loss on a very small dataset (like the 50 sentences in notebook 3) can just as easily mean the model memorized the data rather than learning general language patterns.</p>
<p><strong>Q2: What happens when eval_dataset equals train_dataset?</strong> The evaluation metric stops being informative for detecting overfitting, since the model is being evaluated on the exact same data it was trained on.</p>
<p><strong>Q3: Why do notebook 1 and the API in notebook 2 produce different-looking output from the same model?</strong> Because they use different decoding strategies — notebook 1 uses default/deterministic decoding, while the notebook 2 API applies <code>top_k</code>/<code>top_p</code>/<code>temperature</code>, which changes how the next token gets sampled.</p>
<h2>Summary</h2>
<ul>
<li><p>[ ] Training from scratch needs far more data and steps than used here to produce coherent output</p>
</li>
<li><p>[ ] A low loss on a small dataset can signal overfitting, not success</p>
</li>
<li><p>[ ] Always use a separate validation split if you actually want to measure generalization</p>
</li>
<li><p>[ ] Decoding strategy (<code>top_k</code>, <code>top_p</code>, <code>temperature</code>) can change perceived output quality even when the model itself hasn't improved</p>
</li>
<li><p>[ ] Document results as they actually are, imperfections included — that's usually where the real lesson is</p>
</li>
</ul>
<p>All three notebooks, full results, and more detail on both findings above are in the <a href="https://github.com/arielshakaramiro/gpt2-text-generation-lab">GitHub repo</a>.</p>
]]></content:encoded></item><item><title><![CDATA[GPT-2 dari Nol vs Fine-Tuning: Eksperimen Text Generation dan Temuan Jujurnya]]></title><description><![CDATA[Ada dua cara utama membuat model text generation: melatihnya dari nol, atau mengambil model yang sudah pintar lalu mengajarinya hal baru. Di tulisan ini saya coba dua-duanya sekaligus, pada compute bu]]></description><link>https://shaka-ai.hashnode.dev/gpt2-dari-nol-vs-fine-tuning-text-generation</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/gpt2-dari-nol-vs-fine-tuning-text-generation</guid><category><![CDATA[nlp]]></category><category><![CDATA[gpt2]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[huggingface]]></category><category><![CDATA[Python]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[AI]]></category><category><![CDATA[Beginner Developers]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sat, 12 Sep 2026 14:19:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/da29a607-596a-40c1-a952-3b9ad6a84cb9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ada dua cara utama membuat model text generation: melatihnya dari nol, atau mengambil model yang sudah pintar lalu mengajarinya hal baru. Di tulisan ini saya coba dua-duanya sekaligus, pada compute budget yang sama-sama kecil (satu sesi Colab, tanpa GPU premium) — dan hasilnya justru jadi bahan belajar yang lebih menarik daripada kalau semuanya berjalan mulus.</p>
<p>Proyek lengkapnya (3 notebook + dokumentasi hasil) ada di <a href="https://github.com/arielshakaramiro/gpt2-text-generation-lab">GitHub</a>.</p>
<h2>Daftar Isi</h2>
<ul>
<li><p><a href="#kenapa-dua-pendekatan-sekaligus">Kenapa Dua Pendekatan Sekaligus?</a></p>
</li>
<li><p><a href="#coba-tebak-dulu">Coba Tebak Dulu</a></p>
</li>
<li><p><a href="#case-study-1-code-generation-dari-nol">Case Study 1: Code Generation dari Nol</a></p>
</li>
<li><p><a href="#deploy-jadi-api">Deploy Jadi API</a></p>
</li>
<li><p><a href="#case-study-2-fine-tuning-gpt-2-bahasa-indonesia">Case Study 2: Fine-Tuning GPT-2 Bahasa Indonesia</a></p>
</li>
<li><p><a href="#plot-twist-dua-angka-dua-cerita-berbeda">Plot Twist: Dua Angka, Dua Cerita Berbeda</a></p>
</li>
<li><p><a href="#tabel-perbandingan">Tabel Perbandingan</a></p>
</li>
<li><p><a href="#kuis-singkat">Kuis Singkat</a></p>
</li>
<li><p><a href="#ringkasan">Ringkasan</a></p>
</li>
</ul>
<h2>Kenapa Dua Pendekatan Sekaligus?</h2>
<p><strong>Training dari nol</strong> artinya model mulai dari bobot acak, tidak tahu apa-apa soal bahasa atau kode. Butuh data besar dan banyak langkah training sebelum menghasilkan sesuatu yang masuk akal.</p>
<p><strong>Fine-tuning</strong> artinya mulai dari model yang sudah dilatih sebelumnya (pretrained), lalu disesuaikan ke tugas atau domain baru dengan data yang jauh lebih sedikit.</p>
<p>Dua pendekatan ini punya trade-off yang benar-benar berbeda, dan cara paling jelas untuk memahaminya bukan dari teori, tapi dari menjalankan keduanya sendiri dan melihat apa yang sebenarnya keluar.</p>
<h2>Coba Tebak Dulu</h2>
<p>Sebelum baca lanjut, coba tebak dulu:</p>
<blockquote>
<p>Kalau GPT-2 dilatih dari nol selama 196 steps, dan GPT-2 pretrained di-fine-tune selama 250 steps pada dataset kecil (cuma 50 kalimat) — mana yang menurutmu menghasilkan teks lebih baik? Loss lebih rendah itu selalu berarti model lebih bagus, atau ada jebakannya?</p>
</blockquote>
<p>Simpan dulu jawabanmu. Nanti kita cocokkan di bagian Plot Twist.</p>
<h2>Case Study 1: Code Generation dari Nol</h2>
<p>Notebook pertama melatih arsitektur GPT-2 (124 juta parameter) <strong>dari bobot acak sepenuhnya</strong> untuk menyelesaikan potongan kode Python, memakai dataset CodeParrot yang sudah difilter untuk konten data science (numpy, pandas, sklearn, dst).</p>
<p>Alurnya:</p>
<ol>
<li><p>Load dataset yang sudah pre-filtered (<code>huggingface-course/codeparrot-ds-train</code>) — 50.000 sampel</p>
</li>
<li><p>Tokenisasi dengan tokenizer khusus kode</p>
</li>
<li><p>Bangun konfigurasi GPT-2 kosong, inisialisasi model dengan bobot acak</p>
</li>
<li><p>Training 1 epoch (196 steps)</p>
</li>
<li><p>Test: minta model melanjutkan kode dari komentar</p>
</li>
</ol>
<p>Hasil loss akhir: <strong>6.43</strong>. Waktu tes minta model melanjutkan:</p>
<pre><code class="language-python"># create some data
x = np.random.randn(100)
y = np.random.randn(100)

# create dataframe from x and y
</code></pre>
<p>Modelnya bukan melanjutkan kode pandas seperti yang diharapkan, malah muter-muter di teks lisensi open-source:</p>
<pre><code class="language-plaintext"># the of the data
# with the terms of the
# This is the terms of the terms of the Free Software Foundation...
# but WITHOUT ANY WARRANTIES OR CONDITIONS OF ANY WITHOUT ANY WARRANTY...
</code></pre>
<p>Awalnya kelihatan seperti kegagalan. Tapi kalau dipikir ulang, ini sebenarnya masuk akal: banyak file kode di GitHub diawali blok komentar lisensi yang panjang dan berulang di ribuan repo berbeda. Untuk model yang baru mulai belajar dari bobot acak, pola itu jauh lebih "mudah dihafal" duluan dibanding memahami struktur kode Python yang sesungguhnya.</p>
<h2>Deploy Jadi API</h2>
<p>Model dari notebook 1 lalu dibungkus jadi endpoint REST pakai FastAPI, di-expose lewat ngrok. Menariknya, model yang <strong>sama persis</strong> menghasilkan output yang terasa berbeda ketika parameter sampling (<code>top_k=50, top_p=0.9, temperature=0.8</code>) diaktifkan — bukan lagi muter-muter teks lisensi, tapi mulai muncul token yang lebih mirip kode (<code>import</code>, <code>def __init__</code>, <code>class</code>, <code>assert</code>), meski tetap belum jadi kode Python yang valid.</p>
<p>Jadi <em>strategi decoding</em> ternyata ikut menentukan kesan kualitas output, bahkan untuk model yang levelnya sama-sama belum matang.</p>
<h2>Case Study 2: Fine-Tuning GPT-2 Bahasa Indonesia</h2>
<p>Notebook ketiga jalan dua bagian. Bagian pertama cuma eksplorasi model Indonesia yang sudah ada — fill-mask pakai BERT dan RoBERTa Indonesia, lalu text generation pakai GPT-2 Indonesia pretrained (belum di-fine-tune apapun). Kedua model fill-mask sama-sama percaya diri menebak preposisi "di" untuk kalimat <code>"Ibu ku sedang bekerja [MASK] supermarket"</code> — BERT dengan confidence 94.96%, RoBERTa 64.92%. Sanity check sederhana yang menunjukkan model-model ini memang jalan sesuai ekspektasi.</p>
<p>Bagian kedua, iseng coba fine-tune <code>cahya/gpt2-small-indonesian-522M</code> pada dataset dummy berisi 50 kalimat fakta dinosaurus dalam berbagai gaya bahasa (ensiklopedia, gaul, cerita anak). Training 10 epoch, loss turun ke <strong>1.27</strong> — jauh lebih rendah dari notebook 1.</p>
<p>Waktu diminta melanjutkan prompt <code>"Trex merupakan"</code>, hasilnya:</p>
<blockquote>
<p>Trex merupakan dinosaurus karnivora pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air. Bukti yang dapat diandalkan untuk hidup dan berburu di dalam air menunjukkan bahwa T-Rex memiliki adaptasi unik untuk hidup dan berburu di dalam air...</p>
</blockquote>
<p>Kelihatan fasih banget di sekilas baca. Tapi ini yang jadi bahan renungan di bagian berikutnya.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/42c217e1-d5b9-4877-8c18-0de726113376.png" alt="Perbandingan dua pendekatan" style="display:block;margin:0 auto" />

<h2>Plot Twist: Dua Angka, Dua Cerita Berbeda</h2>
<p>Iseng saya bandingkan kalimat generate di atas dengan data training-nya sendiri, dan ketemu ini:</p>
<blockquote>
<p><strong>Data training</strong> (soal Spinosaurus): <em>"Spinosaurus adalah dinosaurus karnivora</em> <em><strong>pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air</strong></em>*."*</p>
<p><strong>Hasil generate</strong> (soal T-Rex): <em>"Trex merupakan dinosaurus karnivora</em> <em><strong>pertama yang diketahui memiliki adaptasi unik untuk hidup dan berburu di dalam air</strong></em>*."*</p>
</blockquote>
<p>Hampir sama persis, cuma subjeknya diganti. Dengan cuma 50 contoh dilatih 10 epoch pada model yang sudah pretrained, ini kemungkinan besar tanda overfitting: modelnya menjahit ulang potongan kalimat yang dihafal, alih-alih benar-benar men-generate kalimat baru.</p>
<p>Ada satu detail metodologi yang bikin ini makin jelas: notebook ini memakai dataset yang sama untuk training dan evaluasi (<code>eval_dataset=tokenized_dataset</code>, identik dengan <code>train_dataset</code>). Artinya loss evaluasi yang tercatat sebenarnya tidak bisa dipakai untuk mendeteksi overfitting sama sekali — butuh split validasi terpisah untuk mengukur ini secara jujur.</p>
<p>Jadi loss rendah dan output fasih di notebook 3 belum tentu tanda model yang lebih baik; bisa jadi cuma tanda menghafal dataset kecil. Sebaliknya, loss tinggi dan output berantakan di notebook 1 tetap konsekuensi wajar dari training dari nol dengan compute terbatas, bukan tanda pipeline-nya gagal.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/08c8b260-3e2e-42d3-99f2-43361a924acb.png" alt="Ringkasan hasil" style="display:block;margin:0 auto" />

<h2>Tabel Perbandingan</h2>
<table>
<thead>
<tr>
<th></th>
<th>Notebook 1 — Dari Nol</th>
<th>Notebook 3 — Fine-Tune</th>
</tr>
</thead>
<tbody><tr>
<td>Titik awal</td>
<td>Bobot acak</td>
<td>Model pretrained</td>
</tr>
<tr>
<td>Data training</td>
<td>50.000 sampel kode</td>
<td>50 kalimat dummy</td>
</tr>
<tr>
<td>Steps</td>
<td>196 (1 epoch)</td>
<td>250 (10 epoch)</td>
</tr>
<tr>
<td>Loss akhir</td>
<td>6.43</td>
<td>1.27</td>
</tr>
<tr>
<td>Output</td>
<td>Boilerplate lisensi</td>
<td>Fasih, tapi mirip hafalan</td>
</tr>
<tr>
<td>Kemungkinan masalah</td>
<td>Undertrained</td>
<td>Overfitting</td>
</tr>
</tbody></table>
<h2>Kuis Singkat</h2>
<p><strong>Q1: Loss yang lebih rendah selalu berarti model yang lebih baik?</strong> Tidak selalu. Loss rendah pada dataset yang sangat kecil (seperti 50 kalimat di notebook 3) bisa berarti model cuma menghafal, bukan belajar pola bahasa secara umum.</p>
<p><strong>Q2: Kalau eval_dataset sama dengan train_dataset, apa dampaknya?</strong> Metrik evaluasi jadi tidak informatif untuk mendeteksi overfitting, karena model dievaluasi pakai data yang sama persis dengan yang dipakai untuk belajar.</p>
<p><strong>Q3: Kenapa output notebook 1 dan API di notebook 2 beda, padahal model-nya sama?</strong> Karena strategi decoding beda — notebook 1 pakai decoding default/deterministik, sementara API di notebook 2 pakai <code>top_k</code>/<code>top_p</code>/<code>temperature</code>, yang mengubah cara token berikutnya dipilih.</p>
<h2>Ringkasan</h2>
<ul>
<li><p>[ ] Training dari nol butuh data + steps jauh lebih banyak dari yang dipakai di sini untuk hasil yang koheren</p>
</li>
<li><p>[ ] Loss rendah pada dataset kecil bisa jadi tanda overfitting, bukan keberhasilan</p>
</li>
<li><p>[ ] Selalu pakai validation split terpisah kalau mau benar-benar mengukur generalisasi</p>
</li>
<li><p>[ ] Strategi decoding (<code>top_k</code>, <code>top_p</code>, <code>temperature</code>) bisa mengubah kesan kualitas output meski model-nya sama</p>
</li>
<li><p>[ ] Dokumentasikan hasil apa adanya — termasuk yang belum sempurna — karena di situ justru pelajarannya</p>
</li>
</ul>
<p>Semua notebook, hasil lengkap, dan detail lebih jauh soal dua temuan di atas ada di <a href="https://github.com/arielshakaramiro/gpt2-text-generation-lab">repo GitHub-nya</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Transformer & Self-Attention: I Built an Encoder From Scratch, Then Caught It Overfitting]]></title><description><![CDATA[This week's topic is one of the most important building blocks of modern NLP: the Transformer and the self-attention mechanism behind it. Instead of just walking through the theory, I decided to build]]></description><link>https://shaka-ai.hashnode.dev/transformer-selfattention-overfitting</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/transformer-selfattention-overfitting</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[nlp]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[BERT]]></category><category><![CDATA[transformers]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Fri, 11 Sep 2026 17:18:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/44789f77-14cb-458d-8735-2f72a9ed0c46.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This week's topic is one of the most important building blocks of modern NLP: the Transformer and the self-attention mechanism behind it. Instead of just walking through the theory, I decided to build a Transformer Encoder from scratch for Indonesian intent classification. It didn't go as smoothly as planned — the first model overfit almost immediately, and chasing down why turned out to be the most interesting part of this post.</p>
<h2>Table of Contents</h2>
<ul>
<li><p><a href="#guess-first">Guess First</a></p>
</li>
<li><p><a href="#what-are-transformers--self-attention">What Are Transformers &amp; Self-Attention</a></p>
</li>
<li><p><a href="#the-project-an-indonesian-intent-classifier">The Project: an Indonesian Intent Classifier</a></p>
</li>
<li><p><a href="#plot-twist-caught-overfitting">Plot Twist: Caught Overfitting</a></p>
</li>
<li><p><a href="#investigating--fixing-it">Investigating &amp; Fixing It</a></p>
</li>
<li><p><a href="#why-did-every-model-end-up-near-perfect">Why Did Every Model End Up Near-Perfect?</a></p>
</li>
<li><p><a href="#quiz">Quiz</a></p>
</li>
<li><p><a href="#summary">Summary</a></p>
</li>
</ul>
<h2>Guess First</h2>
<p>Before reading on: if you train a 6-layer Transformer Encoder (millions of parameters) from scratch, using only 75 training sentences, for a fixed 20 epochs with no validation split — what do you think happens to the training loss?</p>
<ul>
<li><p>A) It decreases slowly and settles at a reasonable value</p>
</li>
<li><p>B) It collapses toward zero well before the epochs run out</p>
</li>
<li><p>C) It actually increases because the model is too small</p>
</li>
</ul>
<p>The answer is in the "Plot Twist" section below.</p>
<h2>What Are Transformers &amp; Self-Attention</h2>
<p>A Transformer is an architecture built to handle sequential data like text, without processing it word by word the way RNNs or LSTMs do. Introduced in "Attention Is All You Need" (Vaswani et al., 2017), its core idea is <strong>self-attention</strong>: every word in a sentence can directly "look at" every other word, regardless of position, to understand context.</p>
<p>Self-attention runs on three components:</p>
<ul>
<li><p><strong>Query (Q)</strong> — the representation of the word currently being processed</p>
</li>
<li><p><strong>Key (K)</strong> — the representation of every other word in the sequence</p>
</li>
<li><p><strong>Value (V)</strong> — the content or context carried by those other words</p>
</li>
</ul>
<p>Since a Transformer processes an entire sequence at once rather than one token at a time, it needs <strong>Positional Encoding</strong> to keep track of word order. The standard approach uses a sinusoidal formula (<code>sin</code>/<code>cos</code>) that gives each position a unique numerical fingerprint. <strong>Multi-Head Attention</strong> then runs several of these attention computations in parallel, so the model can capture different kinds of relationships between words at the same time.</p>
<h2>The Project: an Indonesian Intent Classifier</h2>
<p>To put these ideas into practice, I built an <strong>intent classifier</strong> — a model that guesses the intent behind a short sentence, across three categories: <code>greeting</code>, <code>sekarang_jam_berapa</code> (asking for the time), and <code>siapa_anda</code> (asking for identity).</p>
<p>The architecture is a <code>TransformerClassifier</code>, encoder-only, built on PyTorch's <code>nn.TransformerEncoder</code> (which already implements multi-head self-attention, a feed-forward network, and residual connections internally), sitting on top of embeddings from an Indonesian BERT tokenizer (<code>cahya/bert-base-indonesian-522M</code>).</p>
<h2>Plot Twist: Caught Overfitting</h2>
<p>The first training run looked "successful" — loss kept dropping. But a closer look revealed something off:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/transformer-self-attention-intent-classifier/main/assets/loss_curve_baseline.png" alt="Baseline loss curve" style="display:block;margin:0 auto" />

<p>Loss collapsed to nearly zero by epoch 9 out of 20 — the answer to the guess above is <strong>B</strong>. That's not a sign of good learning, it's a sign the model <strong>memorized</strong> the training set. Which makes sense: a 6-layer Transformer with a hidden dimension of 768 has millions of parameters, trained on just 75 sentences, with no validation split to signal when to stop.</p>
<p>So I audited my own system: what caused it, and how do you actually fix it?</p>
<h2>Investigating &amp; Fixing It</h2>
<p>Three things were tried at once:</p>
<ol>
<li><p><strong>Validation split + early stopping</strong> — training stops as soon as validation loss stops improving, instead of running a fixed number of epochs</p>
</li>
<li><p><strong>A smaller model</strong> (<code>scratch_small</code>: 2 layers, hidden size 128, higher dropout and weight decay) — to test whether regularization alone was enough</p>
</li>
<li><p><strong>Actual transfer learning</strong> (<code>finetuned_bert</code>) — fine-tuning pretrained Indonesian BERT weights, not just borrowing its tokenizer</p>
</li>
</ol>
<p>To avoid depending on one lucky or unlucky data split, all three variants were compared using <strong>5-fold Stratified Cross-Validation</strong>:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/transformer-self-attention-intent-classifier/main/assets/cv_comparison.png" alt="CV comparison" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Variant</th>
<th>CV Accuracy (mean ± std)</th>
</tr>
</thead>
<tbody><tr>
<td>scratch_large (baseline, fixed)</td>
<td>0.9895 ± 0.0211</td>
</tr>
<tr>
<td>scratch_small</td>
<td>0.9789 ± 0.0258</td>
</tr>
<tr>
<td><strong>finetuned_bert</strong></td>
<td><strong>1.0000 ± 0.0000</strong></td>
</tr>
</tbody></table>
<p>Early stopping and a validation split appeared to help: the exact same <code>scratch_large</code> architecture, once its training procedure changed (validation split + early stopping) and it was evaluated as a 5-fold average instead of a single split, went from a single-split accuracy of roughly 89–95% to an average CV accuracy of 98.95%. Worth noting: part of that jump also comes from the measurement itself — a 5-fold average is inherently more stable than one small split, so this isn't a perfectly isolated comparison. What's clearer is <code>finetuned_bert</code>: consistently ahead with zero variance across every fold, matching the hypothesis that pretrained language representations need far less data to generalize.</p>
<h2>Why Did Every Model End Up Near-Perfect?</h2>
<p>This is the part worth being honest about. All three variants scored above 97%, including the deliberately shrunk model. That's suspicious on its own.</p>
<p>So the dataset itself got a closer look. Vocabulary overlap between the three classes turned out to be only 8–10% (Jaccard similarity), and the overlapping words were mostly generic function words ("ada", "bisa", "ini", "yang", "kamu", "apa") — not topic-specific keywords. The actual keywords for each class (<code>jam/waktu/pukul</code> for time, <code>siapa/nama/dirimu</code> for identity, <code>hi/halo/selamat</code> for greetings) barely overlap at all. No duplicate rows were found either.</p>
<p>In other words, this task is inherently easy to separate lexically. That's not a bug and not data leakage — it's a property of a small, simple dataset. These results are valid evidence that the methodology (cross-validation, early stopping, transfer learning) works correctly, not proof that the model is generally "great." A larger, more ambiguous dataset would likely show clearer differences between the three variants.</p>
<h2>Quiz</h2>
<p><strong>1. Why does a Transformer need Positional Encoding while an RNN doesn't?</strong> a) A Transformer doesn't have enough parameters b) A Transformer processes all tokens at once, so it has no built-in sense of order c) Positional Encoding is only there to speed up training</p>
<p><em>Answer: b — an RNN processes tokens sequentially, so order is implicit in how it works, while a Transformer processes everything in parallel and needs explicit position information.</em></p>
<p><strong>2. In the experiment above, why did</strong> <code>scratch_large</code> <strong>jump from ~89% to 98.95% with the exact same architecture?</strong> a) Because more data was added b) Because the training procedure was fixed (validation split + early stopping), not the architecture c) Because the model was restarted from scratch</p>
<p><em>Answer: b — the architecture didn't change at all. What changed was the training procedure (validation split + early stopping) and the evaluation procedure (a 5-fold average instead of a single split). This shows the original problem was indeed about training strategy, though part of the numeric jump is also explained by the more stable measurement.</em></p>
<p><strong>3. Why should near-perfect scores across every model variant raise suspicion instead of being celebrated?</strong> a) Because it means the code must be buggy b) Because it can signal an easy or small task rather than a genuinely strong model c) Because accuracy above 95% is always invalid</p>
<p><em>Answer: b — a perfect score on a small, simple dataset doesn't automatically prove good generalization; the context needs to be checked first.</em></p>
<h2>Summary</h2>
<ul>
<li><p>[x] A Transformer uses self-attention (Query/Key/Value) to understand context between words globally</p>
</li>
<li><p>[x] Positional Encoding (sinusoidal) is needed because a Transformer doesn't process tokens sequentially</p>
</li>
<li><p>[x] Training without a validation split and early stopping is prone to overfitting, even on small datasets</p>
</li>
<li><p>[x] 5-fold Cross-Validation gives a far more stable performance estimate than a single split</p>
</li>
<li><p>[x] Transfer learning (fine-tuned BERT) won this experiment, but near-perfect scores across every variant still need the dataset's context checked, not taken at face value</p>
</li>
</ul>
<p>Full code, notebook, and experiment results are on GitHub: <a href="https://github.com/arielshakaramiro/transformer-self-attention-intent-classifier">transformer-self-attention-intent-classifier</a></p>
]]></content:encoded></item><item><title><![CDATA[Transformer & Self-Attention: Bangun Encoder dari Nol, Lalu Ketahuan Overfitting]]></title><description><![CDATA[Minggu ini masuk ke salah satu bagian paling penting dari NLP modern: Transformer dan mekanisme self-attention di baliknya. Tapi alih-alih cuma jelasin teorinya, aku coba praktikkan langsung — bangun ]]></description><link>https://shaka-ai.hashnode.dev/transformer-self-attention-overfitting</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/transformer-self-attention-overfitting</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[nlp]]></category><category><![CDATA[pytorch]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[BERT]]></category><category><![CDATA[transformers]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Fri, 11 Sep 2026 17:15:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/b8e7e9a9-936d-4a06-9733-6edaf3500b6e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Minggu ini masuk ke salah satu bagian paling penting dari NLP modern: Transformer dan mekanisme self-attention di baliknya. Tapi alih-alih cuma jelasin teorinya, aku coba praktikkan langsung — bangun Transformer Encoder dari nol buat klasifikasi intent teks berbahasa Indonesia. Hasilnya nggak semulus yang dibayangkan: model awal langsung overfitting, dan justru itu jadi bagian paling menarik dari tulisan ini.</p>
<h2>Daftar Isi</h2>
<ul>
<li><p><a href="#coba-tebak-dulu">Coba Tebak Dulu</a></p>
</li>
<li><p><a href="#apa-itu-transformer--self-attention">Apa Itu Transformer &amp; Self-Attention</a></p>
</li>
<li><p><a href="#proyek-intent-classifier-bahasa-indonesia">Proyek: Intent Classifier Bahasa Indonesia</a></p>
</li>
<li><p><a href="#plot-twist-overfitting-ketauan">Plot Twist: Overfitting Ketauan</a></p>
</li>
<li><p><a href="#investigasi--perbaikan">Investigasi &amp; Perbaikan</a></p>
</li>
<li><p><a href="#kenapa-semua-model-jadi-nyaris-sempurna">Kenapa Semua Model Jadi Nyaris Sempurna?</a></p>
</li>
<li><p><a href="#quiz">Quiz</a></p>
</li>
<li><p><a href="#ringkasan">Ringkasan</a></p>
</li>
</ul>
<h2>Coba Tebak Dulu</h2>
<p>Sebelum lanjut baca: kalau kamu latih Transformer Encoder 6-layer (jutaan parameter) dari nol, cuma pakai 75 kalimat data latih, selama 20 epoch tetap tanpa validation split — kira-kira apa yang terjadi ke training loss-nya?</p>
<ul>
<li><p>A) Turun pelan dan stabil di angka wajar</p>
</li>
<li><p>B) Turun drastis ke hampir nol jauh sebelum epoch-nya habis</p>
</li>
<li><p>C) Malah naik karena modelnya kekecilan</p>
</li>
</ul>
<p>Jawabannya ada di bagian "Plot Twist" di bawah.</p>
<h2>Apa Itu Transformer &amp; Self-Attention</h2>
<p>Transformer adalah arsitektur yang dirancang buat menangani data berurutan seperti teks, tanpa harus memprosesnya kata demi kata seperti RNN atau LSTM. Diperkenalkan lewat paper "Attention Is All You Need" (Vaswani dkk., 2017), inti dari Transformer ada di mekanisme <strong>self-attention</strong>: setiap kata dalam kalimat bisa langsung "melihat" kata lain di posisi mana pun untuk memahami konteks.</p>
<p>Tiga komponen kunci di balik self-attention:</p>
<ul>
<li><p><strong>Query (Q)</strong> — representasi kata yang sedang diproses</p>
</li>
<li><p><strong>Key (K)</strong> — representasi kata lain dalam urutan</p>
</li>
<li><p><strong>Value (V)</strong> — isi atau konteks dari kata-kata lain</p>
</li>
</ul>
<p>Karena semua token diproses sekaligus (bukan satu per satu), Transformer butuh <strong>Positional Encoding</strong> supaya tetap tahu urutan kata. Biasanya dipakai formula sinusoidal (<code>sin</code>/<code>cos</code>) yang memberi tiap posisi "sidik jari" numerik yang unik. Lalu <strong>Multi-Head Attention</strong> menjalankan beberapa proses attention ini secara paralel, supaya model menangkap berbagai jenis hubungan antar kata dalam satu waktu.</p>
<h2>Proyek: Intent Classifier Bahasa Indonesia</h2>
<p>Untuk mempraktikkan konsep di atas, aku bangun sebuah <strong>intent classifier</strong> — model yang menebak maksud dari kalimat pendek, di antara tiga kategori: <code>greeting</code> (sapaan), <code>sekarang_jam_berapa</code> (nanya waktu), dan <code>siapa_anda</code> (nanya identitas).</p>
<p>Arsitekturnya: <code>TransformerClassifier</code> encoder-only, memakai <code>nn.TransformerEncoder</code> bawaan PyTorch (yang di dalamnya sudah mengimplementasikan multi-head self-attention, feed-forward network, dan residual connection) di atas embedding dari tokenizer BERT Bahasa Indonesia (<code>cahya/bert-base-indonesian-522M</code>).</p>
<h2>Plot Twist: Overfitting Ketauan</h2>
<p>Training pertama kelihatan "berhasil" — loss-nya turun terus. Tapi begitu dilihat lebih detail, ada yang janggal:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/transformer-self-attention-intent-classifier/main/assets/loss_curve_baseline.png" alt="Baseline loss curve" style="display:block;margin:0 auto" />

<p>Loss-nya turun ke hampir nol sejak epoch ke-9 dari 20 — jawaban "Coba Tebak Dulu" di atas adalah <strong>B</strong>. Ini bukan tanda model belajar dengan baik, ini tanda model <strong>menghafal</strong> training set-nya. Masuk akal juga: Transformer 6-layer dengan hidden dimension 768 itu jutaan parameter, dilatih cuma dari 75 kalimat, tanpa validation split yang bisa kasih tahu kapan harus berhenti.</p>
<p>Jadi aku audit sistem aku sendiri: apa penyebabnya, dan gimana cara membenahinya?</p>
<h2>Investigasi &amp; Perbaikan</h2>
<p>Tiga hal dicoba sekaligus:</p>
<ol>
<li><p><strong>Validation split + early stopping</strong> — training berhenti begitu validation loss berhenti membaik, bukan di epoch tetap</p>
</li>
<li><p><strong>Model yang lebih kecil</strong> (<code>scratch_small</code>: 2 layer, hidden 128, dropout &amp; weight decay lebih tinggi) — untuk menguji apakah regularisasi saja sudah cukup</p>
</li>
<li><p><strong>Transfer learning beneran</strong> (<code>finetuned_bert</code>) — fine-tune bobot BERT Indonesia yang sudah pretrained, bukan cuma memakai tokenizer-nya</p>
</li>
</ol>
<p>Supaya hasilnya tidak bergantung pada satu split data yang kebetulan mudah atau sulit, ketiga varian dibandingkan pakai <strong>5-fold Stratified Cross-Validation</strong>:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/transformer-self-attention-intent-classifier/main/assets/cv_comparison.png" alt="Perbandingan CV" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Varian</th>
<th>CV Accuracy (mean ± std)</th>
</tr>
</thead>
<tbody><tr>
<td>scratch_large (baseline, diperbaiki)</td>
<td>0,9895 ± 0,0211</td>
</tr>
<tr>
<td>scratch_small</td>
<td>0,9789 ± 0,0258</td>
</tr>
<tr>
<td><strong>finetuned_bert</strong></td>
<td><strong>1,0000 ± 0,0000</strong></td>
</tr>
</tbody></table>
<p>Early stopping dan validation split kelihatan berdampak: model <code>scratch_large</code> dengan arsitektur yang sama persis, begitu cara trainingnya diubah (validation split + early stopping) dan dievaluasi lewat rata-rata 5 fold (bukan cuma satu split), akurasinya naik dari sekitar 89–95% (single split) ke rata-rata CV 98,95%. Perlu dicatat, sebagian kenaikan ini juga berasal dari cara mengukurnya — rata-rata 5 fold secara alami lebih stabil dibanding satu split kecil, jadi ini bukan perbandingan yang sepenuhnya terisolasi ke satu variabel. Yang lebih jelas kelihatan adalah <code>finetuned_bert</code>: unggul konsisten dengan variance nol di semua fold — sejalan dengan hipotesis bahwa representasi bahasa yang sudah dipelajari sebelumnya butuh lebih sedikit data untuk generalisasi.</p>
<h2>Kenapa Semua Model Jadi Nyaris Sempurna?</h2>
<p>Ini bagian yang paling penting untuk disampaikan jujur. Ketiga varian sama-sama mendapat skor di atas 97%, termasuk model yang sengaja dikecilkan. Itu mencurigakan.</p>
<p>Jadi datasetnya dicek langsung. Ternyata: overlap kosakata antar tiga kelas cuma 8–10% (Jaccard similarity), dan kata-kata yang overlap itu kebanyakan kata fungsi umum ("ada", "bisa", "ini", "yang", "kamu", "apa") — bukan kata kunci yang jadi ciri khas topik. Kata konten tiap kelas (<code>jam/waktu/pukul</code> vs <code>siapa/nama/dirimu</code> vs <code>hi/halo/selamat</code>) nyaris tidak beririsan sama sekali. Tidak ada duplikasi data juga.</p>
<p>Artinya, task ini secara alami mudah dipisahkan secara leksikal — bukan bug, bukan data leakage, tapi karakteristik dataset yang kecil dan sederhana. Hasil ini bukti valid bahwa metodologinya (cross-validation, early stopping, transfer learning) bekerja dengan benar, bukan klaim bahwa modelnya "hebat" secara umum. Dataset yang lebih besar dan ambigu kemungkinan akan menunjukkan perbedaan yang lebih jelas antar ketiga model.</p>
<h2>Quiz</h2>
<p><strong>1. Kenapa Transformer butuh Positional Encoding, sementara RNN tidak?</strong> a) Transformer tidak punya cukup parameter b) Transformer memproses semua token sekaligus, jadi tidak otomatis tahu urutannya c) Positional Encoding cuma untuk mempercepat training</p>
<p><em>Jawaban: b — RNN memproses token secara berurutan sehingga urutan implisit dari cara kerjanya, sementara Transformer memproses semua sekaligus sehingga butuh informasi posisi eksplisit.</em></p>
<p><strong>2. Di eksperimen di atas, kenapa</strong> <code>scratch_large</code> <strong>bisa naik dari sekitar 89% ke 98,95% padahal arsitekturnya sama persis?</strong> a) Karena datanya ditambah b) Karena cara training-nya diperbaiki (validation split + early stopping), bukan arsitekturnya c) Karena modelnya direstart dari awal</p>
<p><em>Jawaban: b — arsitekturnya tidak berubah sama sekali. Yang berubah adalah cara training (validation split + early stopping) sekaligus cara evaluasi (rata-rata 5 fold, bukan satu split). Ini menunjukkan masalah awal memang lebih ke soal strategi training, meskipun sebagian kenaikan angkanya juga dipengaruhi cara pengukuran yang lebih stabil.</em></p>
<p><strong>3. Kenapa skor yang nyaris sempurna di semua varian model justru perlu dicurigai, bukan langsung dirayakan?</strong> a) Karena itu artinya kodenya pasti ada bug b) Karena itu bisa jadi tanda task atau dataset-nya terlalu mudah atau kecil, bukan bukti model yang benar-benar hebat c) Karena akurasi di atas 95% selalu tidak valid</p>
<p><em>Jawaban: b — skor sempurna di dataset kecil dan sederhana tidak otomatis membuktikan generalisasi yang baik; konteksnya perlu dicek dulu.</em></p>
<h2>Ringkasan</h2>
<ul>
<li><p>[x] Transformer memakai self-attention (Query/Key/Value) untuk memahami konteks antar kata secara global</p>
</li>
<li><p>[x] Positional Encoding (sinusoidal) dibutuhkan karena Transformer tidak memproses token secara berurutan</p>
</li>
<li><p>[x] Training tanpa validation split dan early stopping rawan overfitting, bahkan di dataset kecil</p>
</li>
<li><p>[x] 5-fold Cross-Validation memberi estimasi performa yang jauh lebih stabil dibanding satu split</p>
</li>
<li><p>[x] Transfer learning (fine-tuned BERT) menang di eksperimen ini, tapi skor nyaris sempurna di semua varian tetap perlu dicek konteks dataset-nya, bukan langsung dipercaya begitu saja</p>
</li>
</ul>
<p>Kode lengkap, notebook, dan hasil eksperimennya ada di GitHub: <a href="https://github.com/arielshakaramiro/transformer-self-attention-intent-classifier">transformer-self-attention-intent-classifier</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a Spam Classifier by Fine-tuning BERT]]></title><description><![CDATA[A message like "Congratulations! You've won $10,000, click here to claim" reads as spam to pretty much anyone who gets it. What's worth digging into: a computer only sees a string of characters, so wh]]></description><link>https://shaka-ai.hashnode.dev/building-a-spam-classifier-by-fine-tuning-bert</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/building-a-spam-classifier-by-fine-tuning-bert</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[BERT]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Thu, 10 Sep 2026 06:13:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/2e55fcb4-2f5d-4bf8-90a4-5cd89b4df49f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A message like "Congratulations! You've won $10,000, click here to claim" reads as spam to pretty much anyone who gets it. What's worth digging into: a computer only sees a string of characters, so what lets it reach the same conclusion we do?</p>
<p>This note walks through building a spam classifier by <strong>fine-tuning BERT</strong>: why BERT fits this task, and code that's actually run and evaluated, not just described.</p>
<h2>Table of Contents</h2>
<ul>
<li><p><a href="#problem">The Problem: What Does Spam Look Like?</a></p>
</li>
<li><p><a href="#why-bert">Why BERT Instead of Bag-of-Words?</a></p>
</li>
<li><p><a href="#architecture">BERT's Architecture in 3 Blocks</a></p>
</li>
<li><p><a href="#pretraining">Why Is BERT Already "Smart" Before Fine-tuning?</a></p>
</li>
<li><p><a href="#fine-tuning">Fine-tuning for Spam Classification</a></p>
</li>
<li><p><a href="#dataset">Dataset &amp; Practical Pipeline</a></p>
</li>
<li><p><a href="#evaluation">Evaluation: Why Not Just Accuracy</a></p>
</li>
<li><p><a href="#plot-twist">Plot Twist: What Does the Official Bootcamp Notebook Actually Do?</a></p>
</li>
<li><p><a href="#demo">Inference Demo</a></p>
</li>
<li><p><a href="#quiz">Quick Quiz</a></p>
</li>
</ul>
<h2>The Problem: What Does Spam Look Like?</h2>
<p>We need a rule that survives the sheer variety of language and spam tricks. SPAM examples usually push urgency, dangle money or prizes, and include a link or number:</p>
<blockquote>
<p>"Loan approved in 5 minutes, message us now."</p>
</blockquote>
<p>HAM (not spam), on the other hand, has clear context and doesn't push you toward a click, link, or transfer:</p>
<blockquote>
<p>"Can you send me the revised file, please?"</p>
</blockquote>
<blockquote>
<p>💭 <strong>Guess First:</strong> Why would a simple rule like "flag any message containing the word 'free' or 'click'" fail often?</p>
<p>Answer: because the same word shows up in perfectly normal contexts too — "free shipping" is fine, but "free iPhone, click here" is a different story. The model needs to understand <em>context</em>, not just spot keywords.</p>
</blockquote>
<h2>Why BERT Instead of Bag-of-Words?</h2>
<p>This is what sets BERT apart from classic approaches like Bag-of-Words or TF-IDF:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Focus</th>
</tr>
</thead>
<tbody><tr>
<td>Bag-of-Words / TF-IDF</td>
<td>How many times a word appears</td>
</tr>
<tr>
<td>BERT</td>
<td>The meaning of a word in context</td>
</tr>
</tbody></table>
<p>BERT (an encoder-only Transformer) reads words with left-and-right context at once, through <em>self-attention</em>. The word "click" ends up "paying more attention" to words like "link," "prize," "now," making spam patterns easier to catch.</p>
<h2>BERT's Architecture in 3 Blocks</h2>
<ol>
<li><p><strong>Tokenizer (WordPiece)</strong> — text is split into subwords. Example: <code>"playing"</code> → <code>"play"</code> + <code>"##ing"</code>.</p>
</li>
<li><p><strong>Embedding Layer</strong> — three embeddings are summed:</p>
</li>
</ol>
<pre><code class="language-plaintext">Final embedding = Token Embedding + Position Embedding + Segment Embedding
</code></pre>
<ol>
<li><strong>Encoder Stack + Task Head</strong> — 12 encoder layers (BERT-Base). For classification, the <code>[CLS]</code> vector is taken and passed through a dense layer to produce the label.</li>
</ol>
<p>Inside each encoder layer: <strong>Multi-Head Self-Attention</strong> (every token "looks at" every other token), a <strong>Feed-Forward Network</strong> (processes each token's features), and <strong>Residual + LayerNorm</strong> (keeps training stable).</p>
<h2>Why Is BERT Already "Smart" Before Fine-tuning?</h2>
<p>Before fine-tuning, BERT is already pretrained on massive amounts of text through two tasks (the classic setup — some derivative models like RoBERTa drop the second one):</p>
<ul>
<li><p><strong>Masked Language Modeling (MLM):</strong> some tokens are hidden (<code>[MASK]</code>), and the model predicts them → learns left and right context simultaneously.</p>
</li>
<li><p><strong>Next Sentence Prediction (NSP):</strong> the model predicts whether sentence B follows sentence A → learns relationships between sentences.</p>
</li>
</ul>
<p>This is why fine-tuning for a specific task like spam detection can work with relatively little data. BERT already comes in with a head start on "understanding language."</p>
<h2>Fine-tuning for Spam Classification</h2>
<p>The fine-tuning setup is simple:</p>
<pre><code class="language-plaintext">Text (message) → BERT (encoder) → Spam? (0/1)
</code></pre>
<p>What gets trained: the classification head (dense layer) is always trained, and typically the entire BERT model is updated too (end-to-end), using cross-entropy loss for the two classes.</p>
<p>Starting hyperparameters:</p>
<pre><code class="language-python">learning_rate = 2e-5
batch_size = 16
epochs = 3
max_length = 128
</code></pre>
<h2>Dataset &amp; Practical Pipeline</h2>
<p>For the implementation, we use the <strong>SMS Spam Collection v.1</strong> (Almeida et al., 2011). The official release contains 5,574 English SMS messages; the GitHub mirror used here contains 5,572 rows (two fewer than the official release, likely a mirror-side parsing difference).</p>
<p>Verified statistics (computed directly from the data, not assumed):</p>
<table>
<thead>
<tr>
<th></th>
<th>Count</th>
</tr>
</thead>
<tbody><tr>
<td>Raw rows (mirror used)</td>
<td>5,572</td>
</tr>
<tr>
<td>After removing duplicates</td>
<td>5,169</td>
</tr>
<tr>
<td>Ham</td>
<td>4,516 (87.4%)</td>
</tr>
<tr>
<td>Spam</td>
<td>653 (12.6%)</td>
</tr>
</tbody></table>
<p>Stratified 80/10/10 split:</p>
<table>
<thead>
<tr>
<th>Split</th>
<th>Total</th>
<th>Ham</th>
<th>Spam</th>
</tr>
</thead>
<tbody><tr>
<td>Train</td>
<td>4,135</td>
<td>3,612</td>
<td>523</td>
</tr>
<tr>
<td>Validation</td>
<td>517</td>
<td>452</td>
<td>65</td>
</tr>
<tr>
<td>Test</td>
<td>517</td>
<td>452</td>
<td>65</td>
</tr>
</tbody></table>
<p>The classes are imbalanced (~87:13). This is why plain accuracy isn't the metric to trust, covered next.</p>
<p>Tokenization and training setup (matching the notebook exactly):</p>
<pre><code class="language-python">MODEL_NAME = "bert-base-uncased"
MAX_LENGTH = 128

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

def tokenize(texts):
    return tokenizer(
        list(texts),
        padding="max_length",
        truncation=True,
        max_length=MAX_LENGTH,
        return_tensors="pt",
    )

model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
</code></pre>
<h2>Evaluation: Why Not Just Accuracy</h2>
<p>With an imbalanced dataset (87% ham, 13% spam), a model that always guesses "ham" already scores 87% accuracy, while completely failing to catch spam. That's why we read:</p>
<ul>
<li><p><strong>Precision (spam):</strong> of everything predicted as spam, how much was actually spam?</p>
</li>
<li><p><strong>Recall (spam):</strong> of the actual spam, how much did the model catch?</p>
</li>
<li><p><strong>F1:</strong> the harmonic mean of precision and recall.</p>
</li>
</ul>
<p>Low recall → a lot of spam slips through. Low precision → normal messages get wrongly flagged as spam.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Accuracy</td>
<td>0.9923</td>
</tr>
<tr>
<td>Precision (spam)</td>
<td>0.9692</td>
</tr>
<tr>
<td>Recall (spam)</td>
<td>0.9692</td>
</tr>
<tr>
<td>F1 (spam)</td>
<td>0.9692</td>
</tr>
</tbody></table>
<p>Out of 517 test messages: 450 ham correctly predicted, 2 ham wrongly flagged as spam, 2 spam wrongly flagged as ham, 63 spam correctly predicted.</p>
<h2>Plot Twist: What Does the Official Bootcamp Notebook Actually Do?</h2>
<p>While digging further into the bootcamp material, I found the official reference notebook, and its implementation turns out to be <strong>different from the theory above</strong>. Instead of full fine-tuning, it <strong>freezes every BERT parameter</strong>:</p>
<pre><code class="language-python">for param in bert.parameters():
    param.requires_grad = False
</code></pre>
<p>Only a custom classification head (Linear 768→512→2) sitting on top of the frozen BERT gets trained. That's <strong>feature extraction</strong>, not the full fine-tuning described earlier. The same notebook also builds a Naive Bayes baseline for comparison, and the result is a bit surprising: on their test set, <strong>Naive Bayes (F1 spam 0.97) slightly outperforms the frozen-BERT model (F1 spam 0.94)</strong>.</p>
<p>BERT isn't failing here. The SMS Spam Collection dataset is relatively small and the patterns are fairly obvious: keywords like "free," "win," "claim" are already highly informative on their own, which lets a simpler model stay competitive (this reading hasn't been checked against actual feature importance, so treat it as a plausible explanation rather than a settled conclusion). The steadier takeaway: BERT's performance depends on dataset size, pattern complexity, and whether its parameters are actually being fine-tuned or just used as a frozen feature extractor.</p>
<p>For a fair comparison (same test set, not just numbers borrowed from another source), all three approaches — Naive Bayes, frozen-BERT, and full fine-tuning — were re-run in this notebook on the identical data split:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Accuracy</th>
<th>Precision (spam)</th>
<th>Recall (spam)</th>
<th>F1 (spam)</th>
</tr>
</thead>
<tbody><tr>
<td>Naive Bayes (baseline)</td>
<td>0.9845</td>
<td>0.9524</td>
<td>0.9231</td>
<td>0.9375</td>
</tr>
<tr>
<td>BERT frozen + custom head</td>
<td>0.9845</td>
<td>0.8904</td>
<td>1.0000</td>
<td>0.9420</td>
</tr>
<tr>
<td>BERT full fine-tuning</td>
<td>0.9923</td>
<td>0.9692</td>
<td>0.9692</td>
<td>0.9692</td>
</tr>
</tbody></table>
<p>Full fine-tuning does come out ahead on every metric for this split. But frozen-BERT has perfect recall (1.0000, meaning no spam slipped through at all), at the cost of the lowest precision of the three (more ham messages wrongly flagged as spam). There's a genuine trade-off here, depending on what matters more: catching every piece of spam, or avoiding false alarms on normal messages.</p>
<h2>Inference Demo</h2>
<p>Once trained, here's how the model gets tested on new text:</p>
<pre><code class="language-python">def predict_spam(texts, model=model, tokenizer=tokenizer):
    model.eval()
    enc = tokenizer(texts, padding=True, truncation=True, max_length=MAX_LENGTH, return_tensors="pt")
    with torch.no_grad():
        logits = model(**enc).logits
        probs = torch.softmax(logits, dim=1)[:, 1]
    return [
        {"text": t, "label": "SPAM" if p &gt;= 0.5 else "HAM", "p_spam": round(p.item(), 4)}
        for t, p in zip(texts, probs)
    ]
</code></pre>
<p>Sample inputs tested, with actual results:</p>
<table>
<thead>
<tr>
<th>Text</th>
<th>Prediction</th>
<th>p_spam</th>
</tr>
</thead>
<tbody><tr>
<td>"Congratulations! You've won a free iPhone, click the link to claim now."</td>
<td>SPAM</td>
<td>0.9988</td>
</tr>
<tr>
<td>"Hey, what time is the meeting tomorrow?"</td>
<td>HAM</td>
<td>0.0008</td>
</tr>
<tr>
<td>"URGENT loan approved in 5 minutes, WhatsApp us now!"</td>
<td><strong>HAM (missed)</strong></td>
<td>0.0006</td>
</tr>
<tr>
<td>"Can you send me the revised file, please?"</td>
<td>HAM</td>
<td>0.0058</td>
</tr>
</tbody></table>
<p>The third row is worth talking about. That text was written to sound like loan-scam spam (urgency, a push to make contact fast), but the model predicted HAM with high confidence. A likely explanation: the SMS Spam Collection dataset dates to around 2011, before "WhatsApp" was a common term in English-language spam, so this phrasing probably falls outside the patterns the model ever saw during training. A model with 99% accuracy can still miss on a sentence whose pattern isn't in its training data.</p>
<h2>Quick Quiz</h2>
<blockquote>
<p><strong>Q1.</strong> Why does BERT outperform TF-IDF on spam messages with highly varied phrasing? <strong>A:</strong> Because BERT captures <em>context</em> (through self-attention) instead of just counting word frequency.</p>
</blockquote>
<blockquote>
<p><strong>Q2.</strong> Why isn't accuracy alone enough for an imbalanced spam dataset (87% ham, 13% spam)? <strong>A:</strong> Because a model that always predicts "ham" already scores high accuracy without actually detecting any spam. Precision and recall are needed to see how it performs on the minority class.</p>
</blockquote>
<blockquote>
<p><strong>Q3.</strong> What's the difference between using BERT as a "frozen feature extractor" and "full fine-tuning"? <strong>A:</strong> With frozen feature extraction, every BERT parameter is frozen (<code>requires_grad=False</code>) and only a custom classification head is trained. With full fine-tuning, all of BERT's parameters get updated along with the head. Frozen is faster and cheaper to train, but not always more accurate.</p>
</blockquote>
<blockquote>
<p><strong>Q4.</strong> The model above scored 99.23% accuracy on the test set, yet misclassified "URGENT loan approved in 5 minutes, WhatsApp us now!" as HAM. Why would that happen? <strong>A:</strong> High test-set accuracy only measures performance on data whose patterns resemble the training data. That sentence uses a term ("WhatsApp") likely absent from the training set (dating to around 2011), putting it outside the distribution the model ever learned from. High accuracy doesn't guarantee generalization to new patterns.</p>
</blockquote>
<h2>Summary Checklist</h2>
<ul>
<li><p>[x] Understand the flow: data → tokenization → fine-tuning → evaluation</p>
</li>
<li><p>[x] Understand BERT's input: token, position, segment embeddings</p>
</li>
<li><p>[x] Know how to train BERT for binary classification</p>
</li>
<li><p>[x] Can read precision, recall, F1, and a confusion matrix</p>
</li>
<li><p>[x] Can test predictions on new text</p>
</li>
<li><p>[x] Understand the difference between BERT as a frozen feature extractor and full fine-tuning</p>
</li>
<li><p>[x] Understand why high test-set accuracy doesn't guarantee correctness on every new pattern</p>
</li>
</ul>
<hr />
<p><em>Full code available on</em> <a href="https://github.com/arielshakaramiro/bert-spam-classifier-arielshakaramiro"><em>GitHub</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Bikin Spam Classifier dengan Fine-tuning BERT]]></title><description><![CDATA[Pesan seperti "Selamat! Anda menang 10 juta, klik link untuk klaim" langsung terbaca sebagai spam oleh siapa pun yang menerimanya. Yang menarik untuk digali: komputer cuma melihat deretan huruf, jadi ]]></description><link>https://shaka-ai.hashnode.dev/bert-spam-classifier-fine-tuning</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/bert-spam-classifier-fine-tuning</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[BERT]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Thu, 10 Sep 2026 06:11:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/84037dd1-d22b-43ca-915c-382af0bb6b63.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Pesan seperti "Selamat! Anda menang 10 juta, klik link untuk klaim" langsung terbaca sebagai spam oleh siapa pun yang menerimanya. Yang menarik untuk digali: komputer cuma melihat deretan huruf, jadi apa yang membuatnya bisa "tahu" hal yang sama seperti kita?</p>
<p>Catatan ini membahas cara membangun spam classifier dengan <strong>fine-tuning BERT</strong>: alasan BERT cocok untuk tugas ini, sampai kode yang benar-benar dijalankan dan dievaluasi.</p>
<h2>Daftar Isi</h2>
<ul>
<li><p><a href="#masalah">Masalah: Spam Itu Seperti Apa?</a></p>
</li>
<li><p><a href="#kenapa-bert">Kenapa BERT, Bukan Bag-of-Words?</a></p>
</li>
<li><p><a href="#arsitektur">Arsitektur BERT dalam 3 Blok</a></p>
</li>
<li><p><a href="#pretraining">Kenapa BERT Sudah "Pintar" Sebelum Dilatih?</a></p>
</li>
<li><p><a href="#fine-tuning">Fine-tuning untuk Klasifikasi Spam</a></p>
</li>
<li><p><a href="#dataset">Dataset &amp; Pipeline Praktik</a></p>
</li>
<li><p><a href="#evaluasi">Evaluasi: Kenapa Bukan Cuma Accuracy</a></p>
</li>
<li><p><a href="#plot-twist">Plot Twist: Apa Kata Notebook Resmi Bootcamp?</a></p>
</li>
<li><p><a href="#demo">Demo Inferensi</a></p>
</li>
<li><p><a href="#quiz">Quiz Singkat</a></p>
</li>
</ul>
<h2>Masalah: Spam Itu Seperti Apa?</h2>
<p>Kita butuh aturan yang tahan variasi bahasa dan trik spam. Contoh SPAM biasanya punya ajakan cepat, iming-iming hadiah/uang, ada link atau nomor, dan nadanya mendesak:</p>
<blockquote>
<p>"Pinjaman cair 5 menit, WA sekarang."</p>
</blockquote>
<p>Sedangkan HAM (bukan spam) konteksnya jelas, dan tidak memaksa klik/link/transfer:</p>
<blockquote>
<p>"Tolong kirim file revisi ya."</p>
</blockquote>
<blockquote>
<p>💭 <strong>Coba Tebak Dulu:</strong> Menurutmu, kenapa cara sederhana seperti "cek apakah ada kata 'gratis' atau 'klik'" gampang salah tebak?</p>
<p>Jawaban: karena kata yang sama bisa muncul di konteks normal — "gratis ongkir" itu wajar, tapi "gratis iPhone klik link" beda cerita. Model butuh memahami <em>konteks</em>, bukan cuma mendeteksi kata kunci.</p>
</blockquote>
<h2>Kenapa BERT, Bukan Bag-of-Words?</h2>
<p>Ini yang bikin BERT beda dari pendekatan klasik seperti Bag-of-Words atau TF-IDF:</p>
<table>
<thead>
<tr>
<th>Pendekatan</th>
<th>Fokus</th>
</tr>
</thead>
<tbody><tr>
<td>Bag-of-Words / TF-IDF</td>
<td>Berapa kali sebuah kata muncul</td>
</tr>
<tr>
<td>BERT</td>
<td>Makna kata dalam konteks kalimat</td>
</tr>
</tbody></table>
<p>BERT (encoder-only Transformer) membaca kata dengan konteks kiri-kanan sekaligus lewat mekanisme <em>self-attention</em>. Kata "klik" akan lebih "memperhatikan" kata "link", "hadiah", "sekarang", sehingga pola spam lebih mudah terdeteksi.</p>
<h2>Arsitektur BERT dalam 3 Blok</h2>
<ol>
<li><p><strong>Tokenizer (WordPiece)</strong> — teks dipecah jadi subword. Contoh: <code>"playing"</code> → <code>"play"</code> + <code>"##ing"</code>.</p>
</li>
<li><p><strong>Embedding Layer</strong> — tiga embedding dijumlahkan:</p>
</li>
</ol>
<pre><code class="language-plaintext">Embedding final = Token Embedding + Position Embedding + Segment Embedding
</code></pre>
<ol>
<li><strong>Encoder Stack + Task Head</strong> — 12 layer encoder (BERT-Base). Untuk klasifikasi, vektor <code>[CLS]</code> diambil lalu dilewatkan ke Dense layer untuk menghasilkan label.</li>
</ol>
<p>Di dalam tiap layer encoder ada <strong>Multi-Head Self-Attention</strong> (tiap token "melihat" token lain), <strong>Feed-Forward Network</strong> (memproses fitur tiap token), dan <strong>Residual + LayerNorm</strong> (menstabilkan training).</p>
<h2>Kenapa BERT Sudah "Pintar" Sebelum Dilatih?</h2>
<p>Sebelum fine-tuning, BERT sudah dilatih lebih dulu (<em>pretraining</em>) di data teks yang sangat besar, lewat dua tugas (versi klasik — beberapa model turunan seperti RoBERTa membuang tugas kedua):</p>
<ul>
<li><p><strong>Masked Language Modeling (MLM):</strong> sebagian token disembunyikan (<code>[MASK]</code>), model menebak token yang hilang → belajar konteks kiri &amp; kanan sekaligus.</p>
</li>
<li><p><strong>Next Sentence Prediction (NSP):</strong> model menebak apakah kalimat B lanjutan kalimat A → belajar hubungan antar kalimat.</p>
</li>
</ul>
<p>Ini kenapa fine-tuning untuk tugas spesifik seperti spam classifier bisa jalan dengan data yang relatif sedikit. BERT sudah punya modal "paham bahasa" duluan.</p>
<h2>Fine-tuning untuk Klasifikasi Spam</h2>
<p>Arsitektur saat fine-tuning sederhana:</p>
<pre><code class="language-plaintext">Teks (pesan) → BERT (encoder) → Spam? (0/1)
</code></pre>
<p>Yang dilatih: kepala klasifikasi (Dense) pasti dilatih, dan biasanya seluruh parameter BERT ikut di-update (end-to-end), dengan loss cross-entropy untuk 2 kelas.</p>
<p>Hyperparameter awal yang dipakai:</p>
<pre><code class="language-python">learning_rate = 2e-5
batch_size = 16
epochs = 3
max_length = 128
</code></pre>
<h2>Dataset &amp; Pipeline Praktik</h2>
<p>Untuk implementasi, dipakai <strong>SMS Spam Collection v.1</strong> (Almeida et al., 2011), dataset benchmark publik. Rilis resmi berisi 5.574 pesan SMS berbahasa Inggris; mirror GitHub yang dipakai di sini berisi 5.572 baris (selisih 2 baris dari rilis resmi, kemungkinan perbedaan parsing pada mirror).</p>
<p>Statistik terverifikasi (dihitung langsung dari data, bukan asumsi):</p>
<table>
<thead>
<tr>
<th></th>
<th>Jumlah</th>
</tr>
</thead>
<tbody><tr>
<td>Total baris (mirror yang dipakai)</td>
<td>5.572</td>
</tr>
<tr>
<td>Setelah hapus duplikat</td>
<td>5.169</td>
</tr>
<tr>
<td>Ham</td>
<td>4.516 (87,4%)</td>
</tr>
<tr>
<td>Spam</td>
<td>653 (12,6%)</td>
</tr>
</tbody></table>
<p>Split stratified 80/10/10:</p>
<table>
<thead>
<tr>
<th>Split</th>
<th>Total</th>
<th>Ham</th>
<th>Spam</th>
</tr>
</thead>
<tbody><tr>
<td>Train</td>
<td>4.135</td>
<td>3.612</td>
<td>523</td>
</tr>
<tr>
<td>Validation</td>
<td>517</td>
<td>452</td>
<td>65</td>
</tr>
<tr>
<td>Test</td>
<td>517</td>
<td>452</td>
<td>65</td>
</tr>
</tbody></table>
<p>Kelas tidak seimbang (~87:13). Ini alasan kenapa metrik selain accuracy penting dibaca di bagian berikutnya.</p>
<p>Kode tokenisasi dan setup training (persis seperti di notebook):</p>
<pre><code class="language-python">MODEL_NAME = "bert-base-uncased"
MAX_LENGTH = 128

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

def tokenize(texts):
    return tokenizer(
        list(texts),
        padding="max_length",
        truncation=True,
        max_length=MAX_LENGTH,
        return_tensors="pt",
    )

model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
</code></pre>
<h2>Evaluasi: Kenapa Bukan Cuma Accuracy</h2>
<p>Dengan kelas tidak seimbang (87% ham, 13% spam), model yang asal tebak "semua ham" saja sudah dapat accuracy 87%, padahal gagal total mendeteksi spam. Karena itu kita baca:</p>
<ul>
<li><p><strong>Precision (spam):</strong> dari yang diprediksi spam, berapa yang benar-benar spam?</p>
</li>
<li><p><strong>Recall (spam):</strong> dari spam asli, berapa yang berhasil ditangkap?</p>
</li>
<li><p><strong>F1:</strong> rata-rata harmonik precision &amp; recall.</p>
</li>
</ul>
<p>Recall rendah → banyak spam lolos. Precision rendah → pesan normal ikut ketandai spam (false alarm).</p>
<table>
<thead>
<tr>
<th>Metrik</th>
<th>Nilai</th>
</tr>
</thead>
<tbody><tr>
<td>Accuracy</td>
<td>0,9923</td>
</tr>
<tr>
<td>Precision (spam)</td>
<td>0,9692</td>
</tr>
<tr>
<td>Recall (spam)</td>
<td>0,9692</td>
</tr>
<tr>
<td>F1 (spam)</td>
<td>0,9692</td>
</tr>
</tbody></table>
<p>Dari 517 pesan di test set: 450 ham diprediksi benar, 2 ham salah ditandai spam, 2 spam salah ditandai ham, 63 spam diprediksi benar.</p>
<h2>Plot Twist: Apa Kata Notebook Resmi Bootcamp?</h2>
<p>Sambil menelusuri materi bootcamp lebih jauh, ada notebook referensi resmi yang ternyata implementasinya <strong>beda dari teori di atas</strong>. Alih-alih fine-tuning penuh, notebook itu <strong>membekukan seluruh parameter BERT</strong>:</p>
<pre><code class="language-python">for param in bert.parameters():
    param.requires_grad = False
</code></pre>
<p>Yang dilatih cuma head klasifikasi custom (Linear 768→512→2) di atas BERT yang beku. Ini namanya <strong>feature extraction</strong>, bukan fine-tuning penuh seperti yang dijelaskan di bagian sebelumnya. Notebook itu juga bikin baseline Naive Bayes untuk perbandingan, dan hasilnya cukup mengejutkan: pada test set mereka, <strong>Naive Bayes (F1 spam 0,97) sedikit mengungguli BERT-frozen (F1 spam 0,94)</strong>.</p>
<p>BERT tidak otomatis gagal di sini. Dataset SMS Spam Collection relatif kecil dan polanya cukup jelas: kata kunci seperti "free", "win", "claim" sudah sangat informatif secara statistik, sehingga model sederhana bisa ikut bersaing (interpretasi ini belum diuji langsung lewat feature importance, jadi dianggap sebagai dugaan yang masuk akal, bukan kesimpulan final). Pelajaran yang lebih pasti: performa BERT bergantung pada ukuran data, kompleksitas pola, dan apakah parameter BERT-nya benar-benar ikut di-fine-tune atau cuma dipakai sebagai ekstraktor fitur beku.</p>
<p>Supaya perbandingan adil (test set yang sama, bukan sekadar comot angka dari sumber lain), ketiga pendekatan — Naive Bayes, BERT-frozen, dan BERT full fine-tuning — dijalankan ulang di notebook ini pada split data yang identik:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Accuracy</th>
<th>Precision (spam)</th>
<th>Recall (spam)</th>
<th>F1 (spam)</th>
</tr>
</thead>
<tbody><tr>
<td>Naive Bayes (baseline)</td>
<td>0,9845</td>
<td>0,9524</td>
<td>0,9231</td>
<td>0,9375</td>
</tr>
<tr>
<td>BERT frozen + head custom</td>
<td>0,9845</td>
<td>0,8904</td>
<td>1,0000</td>
<td>0,9420</td>
</tr>
<tr>
<td>BERT full fine-tuning</td>
<td>0,9923</td>
<td>0,9692</td>
<td>0,9692</td>
<td>0,9692</td>
</tr>
</tbody></table>
<p>Hasilnya: fine-tuning penuh memang unggul di semua metrik pada split ini. Tapi frozen-BERT punya recall sempurna (1,0000, artinya tidak ada satu pun spam yang lolos), meski dengan precision paling rendah di antara ketiganya (lebih banyak pesan ham yang salah ditandai spam). Ada trade-off yang berbeda di sini, tergantung mana yang lebih penting: menangkap semua spam, atau menghindari false alarm ke pesan normal.</p>
<h2>Demo Inferensi</h2>
<p>Setelah model dilatih, begini cara mengujinya ke teks baru:</p>
<pre><code class="language-python">def predict_spam(texts, model=model, tokenizer=tokenizer):
    model.eval()
    enc = tokenizer(texts, padding=True, truncation=True, max_length=MAX_LENGTH, return_tensors="pt")
    with torch.no_grad():
        logits = model(**enc).logits
        probs = torch.softmax(logits, dim=1)[:, 1]
    return [
        {"text": t, "label": "SPAM" if p &gt;= 0.5 else "HAM", "p_spam": round(p.item(), 4)}
        for t, p in zip(texts, probs)
    ]
</code></pre>
<p>Contoh input yang diuji, dengan hasil aktual:</p>
<table>
<thead>
<tr>
<th>Teks</th>
<th>Prediksi</th>
<th>p_spam</th>
</tr>
</thead>
<tbody><tr>
<td>"Congratulations! You've won a free iPhone, click the link to claim now."</td>
<td>SPAM</td>
<td>0,9988</td>
</tr>
<tr>
<td>"Hey, what time is the meeting tomorrow?"</td>
<td>HAM</td>
<td>0,0008</td>
</tr>
<tr>
<td>"URGENT loan approved in 5 minutes, WhatsApp us now!"</td>
<td><strong>HAM (meleset)</strong></td>
<td>0,0006</td>
</tr>
<tr>
<td>"Can you send me the revised file, please?"</td>
<td>HAM</td>
<td>0,0058</td>
</tr>
</tbody></table>
<p>Baris ketiga menarik untuk dibahas. Teks itu sengaja ditulis mirip spam pinjaman (urgensi, ajakan kontak cepat), tapi modelnya memprediksi HAM dengan keyakinan tinggi. Dugaan penyebabnya: dataset SMS Spam Collection berasal dari sekitar 2011, sebelum "WhatsApp" jadi istilah umum di teks spam berbahasa Inggris, jadi frasa ini kemungkinan besar berada di luar pola yang pernah dilihat model saat training. Model dengan accuracy 99% pun tetap bisa salah pada kalimat yang polanya tidak ada di data latihnya.</p>
<h2>Quiz Singkat</h2>
<blockquote>
<p><strong>Q1.</strong> Kenapa BERT lebih unggul dari TF-IDF untuk mendeteksi spam yang kalimatnya bervariasi? <strong>A:</strong> Karena BERT memahami <em>konteks</em> kata (lewat self-attention), bukan cuma menghitung frekuensi kemunculan kata.</p>
</blockquote>
<blockquote>
<p><strong>Q2.</strong> Kenapa accuracy saja tidak cukup untuk dataset spam yang tidak seimbang (87% ham, 13% spam)? <strong>A:</strong> Karena model yang selalu menebak "ham" pun sudah dapat accuracy tinggi tanpa benar-benar mendeteksi spam. Precision &amp; recall dibutuhkan untuk melihat performa di kelas minoritas.</p>
</blockquote>
<blockquote>
<p><strong>Q3.</strong> Apa beda "BERT sebagai feature extractor (frozen)" dengan "fine-tuning penuh"? <strong>A:</strong> Pada frozen, seluruh parameter BERT dibekukan (<code>requires_grad=False</code>) dan hanya head klasifikasi custom yang dilatih. Pada fine-tuning penuh, seluruh parameter BERT ikut ter-update bersama head-nya. Frozen lebih cepat &amp; hemat komputasi, tapi tidak selalu lebih akurat.</p>
</blockquote>
<blockquote>
<p><strong>Q4.</strong> Model di atas dapat accuracy 99,23% di test set, tapi salah memprediksi "URGENT loan approved in 5 minutes, WhatsApp us now!" sebagai HAM. Kenapa ini bisa terjadi? <strong>A:</strong> Accuracy tinggi di test set hanya mengukur performa pada data yang polanya mirip dengan data training. Kalimat itu memakai istilah ("WhatsApp") yang kemungkinan tidak ada di dataset training (sekitar 2011), jadi berada di luar distribusi yang pernah dipelajari model. Accuracy tinggi tidak menjamin generalisasi ke pola baru.</p>
</blockquote>
<h2>Checklist Ringkasan</h2>
<ul>
<li><p>[x] Paham alur: data → tokenisasi → fine-tuning → evaluasi</p>
</li>
<li><p>[x] Paham input BERT: token, posisi, segment embedding</p>
</li>
<li><p>[x] Tahu cara melatih BERT untuk klasifikasi 2 kelas</p>
</li>
<li><p>[x] Bisa membaca precision, recall, F1, confusion matrix</p>
</li>
<li><p>[x] Bisa menguji prediksi pada teks baru</p>
</li>
<li><p>[x] Paham beda BERT sebagai feature extractor (frozen) vs fine-tuning penuh</p>
</li>
<li><p>[x] Paham kenapa accuracy tinggi di test set tidak menjamin model benar di semua pola baru</p>
</li>
</ul>
<hr />
<p><em>Kode lengkap tersedia di</em> <a href="https://github.com/arielshakaramiro/bert-spam-classifier-arielshakaramiro"><em>GitHub</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Word Embedding & Sentiment Analysis dengan LSTM]]></title><description><![CDATA[Di tulisan sebelumnya soal RNN, saya sempat janji: bagian word embedding bakal dibahas terpisah. Ini janjinya — sekalian dipraktikkan langsung ke kasus nyata: menebak apakah review film itu positif at]]></description><link>https://shaka-ai.hashnode.dev/word-embedding-sentiment-analysis-lstm-glove</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/word-embedding-sentiment-analysis-lstm-glove</guid><category><![CDATA[word embedding]]></category><category><![CDATA[GloVe]]></category><category><![CDATA[LSTM]]></category><category><![CDATA[Sentiment analysis]]></category><category><![CDATA[nlp]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[AI Engineering]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 07 Sep 2026 12:05:40 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/8d386c86-ef59-4ee5-94ce-0885cdaad62e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Di <a href="https://shaka-ai.hashnode.dev/mengenal-recurrent-neural-network-rnn">tulisan sebelumnya soal RNN</a>, saya sempat janji: bagian word embedding bakal dibahas terpisah. Ini janjinya — sekalian dipraktikkan langsung ke kasus nyata: <strong>menebak apakah review film itu positif atau negatif</strong>, dari nol sampai jadi API yang bisa dipanggil dari luar.</p>
<blockquote>
<p><strong>TL;DR:</strong> Word embedding merepresentasikan kata sebagai vektor supaya makna kata ikut terbawa. Dipadukan dengan Bidirectional LSTM dan pretrained GloVe, model ini mencapai akurasi 87.75% di test set IMDB — lalu dibungkus jadi REST API yang bisa dipanggil dari luar.</p>
</blockquote>
<h2>Daftar Isi</h2>
<ul>
<li><p><a href="#apa-itu-word-embedding">Apa itu Word Embedding?</a></p>
</li>
<li><p><a href="#kenapa-pakai-pretrained-embedding-glove">Kenapa Pakai Pretrained Embedding (GloVe)?</a></p>
</li>
<li><p><a href="#arsitektur-embedding--bidirectional-lstm">Arsitektur: Embedding + Bidirectional LSTM</a></p>
</li>
<li><p><a href="#training--hasil">Training &amp; Hasil</a></p>
</li>
<li><p><a href="#uji-coba-prediksi">Uji Coba Prediksi</a></p>
</li>
<li><p><a href="#bonus-deploy-jadi-rest-api">Bonus: Deploy Jadi REST API</a></p>
</li>
<li><p><a href="#checklist-pemahaman">Checklist Pemahaman</a></p>
</li>
<li><p><a href="#kuis-singkat">Kuis Singkat</a></p>
</li>
</ul>
<hr />
<h2>Apa itu Word Embedding?</h2>
<p>🤔 Coba Tebak Dulu: kalau komputer cuma paham angka, gimana caranya kata "bagus" dan "keren" bisa "dikenali" mirip maknanya oleh model?</p>
<p>Jawabannya: lewat **vektor**. Setiap kata dipetakan ke sebuah titik di ruang berdimensi tinggi (misalnya 100 dimensi). Kata-kata dengan makna atau konteks pemakaian yang mirip akan punya vektor yang saling berdekatan. Jadi "bagus" dan "keren" akan punya posisi yang relatif dekat, sementara "bagus" dan "sepatu" akan jauh. Word embedding adalah teknik merepresentasikan kata sebagai **vektor angka berdimensi tetap**, sedemikian rupa sehingga kata-kata dengan makna atau konteks pemakaian yang mirip punya representasi vektor yang berdekatan secara matematis (diukur pakai cosine similarity atau euclidean distance).</p>
<p>Ini beda jauh dari cara representasi kata yang lebih sederhana seperti one-hot encoding (yang dipakai di praktik RNN name-classifier pada tulisan sebelumnya) — di one-hot encoding, semua kata berjarak sama satu sama lain, tidak ada informasi makna yang terbawa. Word embedding menangkap hubungan semantik itu.</p>
<h2>Kenapa Pakai Pretrained Embedding (GloVe)?</h2>
<p>Melatih embedding dari nol butuh dataset sangat besar supaya vektornya benar-benar merepresentasikan makna kata dengan baik. Solusinya: pakai <strong>pretrained embedding</strong> yang sudah dilatih di dataset raksasa oleh pihak lain — di tulisan ini saya pakai <strong>GloVe (Global Vectors for Word Representation)</strong>, spesifiknya varian <code>glove.6B.100d</code>: dilatih dari 6 miliar token, menghasilkan vektor 100 dimensi per kata.</p>
<p>Keuntungannya:</p>
<ul>
<li><p>Model tidak perlu belajar makna kata dari nol — cukup belajar cara memakai makna itu untuk tugas spesifik (dalam kasus ini: sentiment analysis).</p>
</li>
<li><p>Berguna terutama kalau dataset training kita sendiri relatif kecil.</p>
</li>
</ul>
<h2>Arsitektur: Embedding + Bidirectional LSTM</h2>
<p>Alur datanya:</p>
<pre><code class="language-plaintext">Kalimat → Tokenisasi → Embedding Layer (GloVe) → Bidirectional LSTM → Dropout → Linear → Skor Sentimen
</code></pre>
<p>Beberapa poin penting:</p>
<ul>
<li><p><strong>Embedding layer</strong> diinisialisasi dengan bobot GloVe yang sudah dilatih sebelumnya (bukan mulai dari acak).</p>
</li>
<li><p><strong>Bidirectional LSTM</strong> — LSTM dijalankan dua arah: dari kata pertama ke terakhir, dan dari kata terakhir ke pertama. Hidden state dari kedua arah digabung, sehingga model punya konteks penuh dari kedua sisi kalimat sebelum membuat keputusan.</p>
</li>
<li><p>Dipakai 2 layer LSTM bertumpuk, dengan dropout 0.5 untuk mengurangi overfitting.</p>
</li>
<li><p>Output akhir cuma 1 angka (lewat sigmoid) — mendekati 0 berarti negatif, mendekati 1 berarti positif. | Komponen | Nilai | |---|---| | Vocabulary size | 25.002 | | Embedding dimension | 100 | | Hidden dimension | 256 | | LSTM layers | 2, bidirectional | | Dropout | 0.5 | | Trainable parameters | 4.810.857 |</p>
</li>
</ul>
<h2>Training &amp; Hasil</h2>
<p>Dataset: <strong>IMDB Movie Reviews</strong> (50.000 review film, label positif/negatif). Dilatih 5 epoch dengan Adam optimizer dan BCEWithLogitsLoss.</p>
<p><strong>Hasil terverifikasi</strong> (langsung dari output training):</p>
<table>
<thead>
<tr>
<th>Epoch</th>
<th>Train Loss</th>
<th>Train Acc</th>
<th>Val Loss</th>
<th>Val Acc</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>0.658</td>
<td>60.43%</td>
<td>0.540</td>
<td>72.92%</td>
</tr>
<tr>
<td>2</td>
<td>0.556</td>
<td>71.12%</td>
<td>0.440</td>
<td>80.07%</td>
</tr>
<tr>
<td>3</td>
<td>0.417</td>
<td>81.63%</td>
<td>0.341</td>
<td>85.54%</td>
</tr>
<tr>
<td>4</td>
<td>0.321</td>
<td>87.10%</td>
<td>0.327</td>
<td>86.75%</td>
</tr>
<tr>
<td>5</td>
<td>0.284</td>
<td>88.73%</td>
<td>0.295</td>
<td>88.00%</td>
</tr>
</tbody></table>
<p>Hasil di test set: <strong>Loss 0.303, Accuracy 87.75%</strong> — cukup solid untuk model yang "hanya" 5 epoch training, berkat bantuan pretrained embedding tadi.</p>
<h2>Uji Coba Prediksi</h2>
<table>
<thead>
<tr>
<th>Kalimat</th>
<th>Skor</th>
<th>Interpretasi</th>
</tr>
</thead>
<tbody><tr>
<td>"This film is terrible"</td>
<td>0.0055</td>
<td>Sangat negatif ✅</td>
</tr>
<tr>
<td>"This film is great"</td>
<td>0.9846</td>
<td>Sangat positif ✅</td>
</tr>
</tbody></table>
<p>Model cukup percaya diri di dua contoh ekstrem ini — masuk akal, karena kata "terrible" dan "great" adalah sinyal sentimen yang kuat dan sering muncul di data training.</p>
<h2>Bonus: Deploy Jadi REST API</h2>
<p>Model yang sudah jadi nggak ada gunanya kalau cuma nangkring di notebook. Jadi saya bungkus jadi REST API pakai <strong>FastAPI</strong>, lalu di-expose ke internet lewat <strong>ngrok</strong> (supaya bisa diakses dari luar Colab tanpa perlu server sendiri).</p>
<p>Endpoint-nya sederhana:</p>
<ul>
<li><p><code>GET /</code> — health check</p>
</li>
<li><p><code>POST /predict/</code> — kirim <code>{"sentence": "..."}</code>, dapat balik skor + label (<code>very positive</code> / <code>positive</code> / <code>neutral</code> / <code>negative</code>) Contoh response nyata dari API yang jalan:</p>
</li>
</ul>
<pre><code class="language-json">{"sentence": "This film is great", "sentiment": "very positive", "score": 0.9846353530883789}
</code></pre>
<p>Skornya identik dengan hasil prediksi langsung di notebook training — masuk akal, karena API cuma memuat ulang bobot model yang sama.</p>
<blockquote>
<p>⚠️ <strong>Catatan keamanan:</strong> kalau kamu bikin setup serupa, jangan pernah hardcode ngrok authtoken langsung di kode yang bakal di-share atau di-push ke repo publik. Simpan sebagai secret/environment variable.</p>
</blockquote>
<p>Notebook lengkap (training + deployment) tersedia di GitHub: <a href="https://github.com/arielshakaramiro/sentiment-analysis-lstm-glove-arielshakaramiro">sentiment-analysis-lstm-glove-arielshakaramiro</a>.</p>
<blockquote>
<p><strong>Konteks modern:</strong> GloVe + Bidirectional LSTM adalah pendekatan yang bagus untuk memahami fondasi word embedding dan sequential modeling, tapi untuk sentiment analysis produksi saat ini, model berbasis <strong>Transformer</strong> (BERT, RoBERTa, atau versi yang lebih ringan seperti DistilBERT) umumnya memberi akurasi lebih tinggi karena punya representasi kontekstual yang lebih kaya — kata yang sama bisa punya makna berbeda tergantung konteks kalimatnya, sesuatu yang GloVe (representasi statis per kata) tidak bisa tangkap.</p>
</blockquote>
<h2>Checklist Pemahaman</h2>
<ul>
<li><p>[ ] Bisa menjelaskan bedanya word embedding dengan one-hot encoding</p>
</li>
<li><p>[ ] Paham kenapa pretrained embedding (GloVe) berguna, terutama untuk dataset kecil</p>
</li>
<li><p>[ ] Bisa menjelaskan kenapa bidirectional LSTM punya konteks lebih lengkap dibanding LSTM satu arah</p>
</li>
<li><p>[ ] Paham alur dasar men-deploy model PyTorch jadi REST API</p>
</li>
</ul>
<h2>Kuis Singkat</h2>
<p><strong>1. Apa keuntungan utama word embedding dibanding one-hot encoding?</strong> Word embedding menangkap hubungan semantik antar kata (kata dengan makna mirip punya vektor yang berdekatan), sementara one-hot encoding memperlakukan semua kata sebagai entitas yang sama-sama berjarak, tanpa informasi makna.</p>
<p><strong>2. Kenapa memakai LSTM bidirectional, bukan satu arah saja?</strong> Karena makna sebuah kata dalam kalimat kadang baru jelas setelah melihat kata-kata sesudahnya. LSTM bidirectional memproses kalimat dari dua arah sekaligus, sehingga setiap hidden state punya konteks dari kata sebelum <em>dan</em> sesudahnya.</p>
<p><strong>3. Kenapa pakai pretrained embedding seperti GloVe alih-alih melatih embedding sendiri dari nol?</strong> Karena melatih embedding yang benar-benar menangkap makna kata butuh dataset sangat besar. GloVe sudah dilatih dari miliaran token, jadi model kita tinggal memanfaatkan representasi makna yang sudah matang, alih-alih belajar dari nol dengan dataset yang jauh lebih kecil.</p>
<hr />
<p><em>Bagian dari seri "AI Notes &amp; Engineering" — catatan belajar AI Engineering saya.</em></p>
]]></content:encoded></item><item><title><![CDATA[Word Embedding & Sentiment Analysis with LSTM]]></title><description><![CDATA[In the previous RNN post, I mentioned word embeddings would get their own post. Here it is — paired with a real use case: predicting whether a movie review is positive or negative, from scratch all th]]></description><link>https://shaka-ai.hashnode.dev/word-embedding-and-sentiment-analysis-with-lstm</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/word-embedding-and-sentiment-analysis-with-lstm</guid><category><![CDATA[word embedding]]></category><category><![CDATA[GloVe]]></category><category><![CDATA[LSTM]]></category><category><![CDATA[Sentiment analysis]]></category><category><![CDATA[nlp]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[AI Engineering]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 07 Sep 2026 12:03:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/403d816f-24b6-455f-927c-24e529503587.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the <a href="https://shaka-ai.hashnode.dev/understanding-recurrent-neural-networks-rnn">previous RNN post</a>, I mentioned word embeddings would get their own post. Here it is — paired with a real use case: <strong>predicting whether a movie review is positive or negative</strong>, from scratch all the way to a callable API.</p>
<blockquote>
<p><strong>TL;DR:</strong> Word embeddings represent words as vectors so meaning carries over into the model. Paired with a Bidirectional LSTM and pretrained GloVe vectors, this model reaches 87.75% accuracy on the IMDB test set — then gets wrapped into a callable REST API.</p>
</blockquote>
<h2>Table of Contents</h2>
<ul>
<li><p><a href="#what-is-word-embedding">What is Word Embedding?</a></p>
</li>
<li><p><a href="#why-use-pretrained-embeddings-glove">Why Use Pretrained Embeddings (GloVe)?</a></p>
</li>
<li><p><a href="#architecture-embedding--bidirectional-lstm">Architecture: Embedding + Bidirectional LSTM</a></p>
</li>
<li><p><a href="#training--results">Training &amp; Results</a></p>
</li>
<li><p><a href="#testing-predictions">Testing Predictions</a></p>
</li>
<li><p><a href="#bonus-deploying-as-a-rest-api">Bonus: Deploying as a REST API</a></p>
</li>
<li><p><a href="#understanding-checklist">Understanding Checklist</a></p>
</li>
<li><p><a href="#quick-quiz">Quick Quiz</a></p>
</li>
</ul>
<hr />
<h2>What is Word Embedding?</h2>
<p>🤔 Guess First: if a computer only understands numbers, how can it "recognize" that words like "great" and "awesome" carry similar meaning?</p>
<p>The answer: **vectors**. Each word gets mapped to a point in a high-dimensional space (say, 100 dimensions). Words with similar meaning or usage context end up with vectors that sit close together. So "great" and "awesome" land near each other, while "great" and "shoe" end up far apart. Word embedding is a technique for representing words as **fixed-dimensional numeric vectors**, arranged so that words with similar meaning or usage context have vectors that are mathematically close (measured via cosine similarity or Euclidean distance).</p>
<p>This is a big step up from simpler representations like one-hot encoding (which we used in the RNN name-classifier from the previous post) — with one-hot encoding, every word is equally distant from every other word, carrying no information about meaning at all. Word embeddings capture that semantic relationship.</p>
<h2>Why Use Pretrained Embeddings (GloVe)?</h2>
<p>Training embeddings from scratch requires a massive dataset for the vectors to genuinely capture word meaning. The workaround: use <strong>pretrained embeddings</strong> already trained on a huge corpus by someone else — here I used <strong>GloVe (Global Vectors for Word Representation)</strong>, specifically <code>glove.6B.100d</code>: trained on 6 billion tokens, producing a 100-dimensional vector per word.</p>
<p>The benefits:</p>
<ul>
<li><p>The model doesn't need to learn word meaning from scratch — it just needs to learn how to use that meaning for the specific task (here, sentiment analysis).</p>
</li>
<li><p>Especially useful when your own training dataset is relatively small.</p>
</li>
</ul>
<h2>Architecture: Embedding + Bidirectional LSTM</h2>
<p>The data flow:</p>
<pre><code class="language-plaintext">Sentence → Tokenization → Embedding Layer (GloVe) → Bidirectional LSTM → Dropout → Linear → Sentiment Score
</code></pre>
<p>A few key points:</p>
<ul>
<li><p>The <strong>embedding layer</strong> is initialized with pretrained GloVe weights instead of random values.</p>
</li>
<li><p><strong>Bidirectional LSTM</strong> — the LSTM runs in both directions: first word to last, and last word to first. Hidden states from both directions are concatenated, giving the model full context from both sides of the sentence before it makes a decision.</p>
</li>
<li><p>Two stacked LSTM layers, with 0.5 dropout to reduce overfitting.</p>
</li>
<li><p>The final output is a single number (via sigmoid) — close to 0 means negative, close to 1 means positive. | Component | Value | |---|---| | Vocabulary size | 25,002 | | Embedding dimension | 100 | | Hidden dimension | 256 | | LSTM layers | 2, bidirectional | | Dropout | 0.5 | | Trainable parameters | 4,810,857 |</p>
</li>
</ul>
<h2>Training &amp; Results</h2>
<p>Dataset: <strong>IMDB Movie Reviews</strong> (50,000 reviews, positive/negative labels). Trained for 5 epochs with the Adam optimizer and BCEWithLogitsLoss.</p>
<p><strong>Verified results (pulled directly from the training run):</strong></p>
<table>
<thead>
<tr>
<th>Epoch</th>
<th>Train Loss</th>
<th>Train Acc</th>
<th>Val Loss</th>
<th>Val Acc</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>0.658</td>
<td>60.43%</td>
<td>0.540</td>
<td>72.92%</td>
</tr>
<tr>
<td>2</td>
<td>0.556</td>
<td>71.12%</td>
<td>0.440</td>
<td>80.07%</td>
</tr>
<tr>
<td>3</td>
<td>0.417</td>
<td>81.63%</td>
<td>0.341</td>
<td>85.54%</td>
</tr>
<tr>
<td>4</td>
<td>0.321</td>
<td>87.10%</td>
<td>0.327</td>
<td>86.75%</td>
</tr>
<tr>
<td>5</td>
<td>0.284</td>
<td>88.73%</td>
<td>0.295</td>
<td>88.00%</td>
</tr>
</tbody></table>
<p>Test set results: <strong>Loss 0.303, Accuracy 87.75%</strong> — a solid result for just 5 epochs of training, thanks to the pretrained embeddings doing a lot of the heavy lifting.</p>
<h2>Testing Predictions</h2>
<table>
<thead>
<tr>
<th>Sentence</th>
<th>Score</th>
<th>Interpretation</th>
</tr>
</thead>
<tbody><tr>
<td>"This film is terrible"</td>
<td>0.0055</td>
<td>Strongly negative ✅</td>
</tr>
<tr>
<td>"This film is great"</td>
<td>0.9846</td>
<td>Strongly positive ✅</td>
</tr>
</tbody></table>
<p>The model is fairly confident on these two clear-cut examples — which makes sense, since "terrible" and "great" are strong sentiment signals that show up frequently in the training data.</p>
<h2>Bonus: Deploying as a REST API</h2>
<p>A trained model sitting in a notebook isn't much use to anyone. So I wrapped it in a REST API using <strong>FastAPI</strong>, then exposed it to the internet via <strong>ngrok</strong> (so it's reachable from outside Colab without needing a dedicated server).</p>
<p>The endpoints are simple:</p>
<ul>
<li><p><code>GET /</code> — health check</p>
</li>
<li><p><code>POST /predict/</code> — send <code>{"sentence": "..."}</code>, get back a score and a label (<code>very positive</code> / <code>positive</code> / <code>neutral</code> / <code>negative</code>) A real response from the live API:</p>
</li>
</ul>
<pre><code class="language-json">{"sentence": "This film is great", "sentiment": "very positive", "score": 0.9846353530883789}
</code></pre>
<p>The score matches the direct prediction from the training notebook exactly — makes sense, since the API is just loading the same trained weights.</p>
<blockquote>
<p>⚠️ <strong>Security note:</strong> if you set up something similar, never hardcode your ngrok authtoken in code that will be shared or pushed to a public repo. Store it as a secret or environment variable instead.</p>
</blockquote>
<p>The full notebook (training + deployment) is available on GitHub: <a href="https://github.com/arielshakaramiro/sentiment-analysis-lstm-glove-arielshakaramiro">sentiment-analysis-lstm-glove-arielshakaramiro</a>.</p>
<blockquote>
<p><strong>Modern context:</strong> GloVe + Bidirectional LSTM is a great way to understand the fundamentals of word embeddings and sequential modeling, but for production sentiment analysis today, <strong>Transformer</strong>-based models (BERT, RoBERTa, or lighter variants like DistilBERT) generally achieve higher accuracy thanks to richer contextual representations — the same word can carry different meaning depending on sentence context, something GloVe (a static, per-word representation) can't capture.</p>
</blockquote>
<h2>Understanding Checklist</h2>
<ul>
<li><p>[ ] Can explain the difference between word embedding and one-hot encoding</p>
</li>
<li><p>[ ] Understand why pretrained embeddings (GloVe) are useful, especially for small datasets</p>
</li>
<li><p>[ ] Can explain why a bidirectional LSTM captures more context than a one-directional LSTM</p>
</li>
<li><p>[ ] Understand the basic flow of deploying a PyTorch model as a REST API</p>
</li>
</ul>
<h2>Quick Quiz</h2>
<p><strong>1. What's the main advantage of word embeddings over one-hot encoding?</strong> Word embeddings capture semantic relationships between words (similar-meaning words get vectors that are close together), while one-hot encoding treats every word as equally distant from every other word, with no meaning encoded at all.</p>
<p><strong>2. Why use a bidirectional LSTM instead of a one-directional one?</strong> Because a word's meaning in a sentence sometimes only becomes clear after seeing the words that follow it. A bidirectional LSTM processes the sentence in both directions at once, so every hidden state carries context from both before <em>and</em> after that point.</p>
<p><strong>3. Why use a pretrained embedding like GloVe instead of training your own from scratch?</strong> Because training embeddings that genuinely capture word meaning requires a massive dataset. GloVe was already trained on billions of tokens, so the model gets to build on a mature semantic representation instead of learning meaning from scratch on a much smaller dataset.</p>
<hr />
<p><em>Part of the "AI Notes &amp; Engineering" series — my personal AI engineering study notes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Understanding Recurrent Neural Networks (RNN): Architecture for Sequential Data]]></title><description><![CDATA[Why does a model that reads sentences, recognizes speech, or forecasts stock prices need a different architecture than a plain image classifier? The answer comes down to one word: sequence. That's whe]]></description><link>https://shaka-ai.hashnode.dev/understanding-recurrent-neural-networks-rnn</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/understanding-recurrent-neural-networks-rnn</guid><category><![CDATA[RNN]]></category><category><![CDATA[recurrent neural network]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[nlp]]></category><category><![CDATA[LSTM]]></category><category><![CDATA[gru]]></category><category><![CDATA[AI Engineering]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 07 Sep 2026 12:00:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/fdf04fb5-d110-4829-afba-9e2df6a950f4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Why does a model that reads sentences, recognizes speech, or forecasts stock prices need a different architecture than a plain image classifier? The answer comes down to one word: <strong>sequence</strong>. That's where Recurrent Neural Networks (RNNs) come in.</p>
<blockquote>
<p><strong>TL;DR:</strong> RNNs process data sequentially by "remembering" previous steps through a hidden state. Their main weakness (vanishing gradients) is addressed by more advanced variants: LSTM and GRU. The final section puts this into practice with a name-origin classifier.</p>
</blockquote>
<h2>Table of Contents</h2>
<ul>
<li><p><a href="#what-is-an-rnn">What is an RNN?</a></p>
</li>
<li><p><a href="#key-characteristics-of-rnns">Key Characteristics of RNNs</a></p>
</li>
<li><p><a href="#rnn-structure-and-how-it-works">RNN Structure and How It Works</a></p>
</li>
<li><p><a href="#limitations-of-rnns">Limitations of RNNs</a></p>
</li>
<li><p><a href="#rnn-vs-lstm-vs-gru">RNN vs LSTM vs GRU</a></p>
</li>
<li><p><a href="#training-rnns-bptt-and-its-challenges">Training RNNs: BPTT and Its Challenges</a></p>
</li>
<li><p><a href="#rnn-applications-in-nlp">RNN Applications in NLP</a></p>
</li>
<li><p><a href="#hands-on-classifying-name-origins-with-an-rnn">Hands-On: Classifying Name Origins with an RNN</a></p>
</li>
<li><p><a href="#understanding-checklist">Understanding Checklist</a></p>
</li>
<li><p><a href="#quick-quiz">Quick Quiz</a></p>
</li>
</ul>
<hr />
<h2>What is an RNN?</h2>
<p>🤔 Guess First: what actually sets an RNN apart from a regular feedforward network?</p>
<p>The answer: **memory**. A feedforward network processes each input independently — there's no "recall" of what came before. An RNN, on the other hand, carries information forward through a *hidden state*, letting it pick up context across a sequence. A Recurrent Neural Network (RNN) is a neural architecture built specifically for sequential data — text, audio, or time series. Unlike a feedforward network, an RNN factors in information from previous steps while processing the current input, which makes it well suited for tasks like speech recognition, natural language processing, and time-series analysis.</p>
<p>Visually, an RNN is often drawn as a single unit with a self-loop, which gets "unfolded" into a chain of units across time steps t-1, t, t+1, and so on.</p>
<h2>Key Characteristics of RNNs</h2>
<ol>
<li><p><strong>Short-term memory</strong> — the hidden state carries information from previous steps to influence the current decision, capturing dependencies between elements in the sequence.</p>
</li>
<li><p><strong>Recurrence</strong> — a feedback connection lets information from the neuron at time t-1 influence the neuron at time t.</p>
</li>
<li><p><strong>Parameter sharing</strong> — the same weights are reused at every time step, which keeps the parameter count (and training cost) far lower than giving each step its own set of weights.</p>
</li>
</ol>
<h2>RNN Structure and How It Works</h2>
<p>At each time step t, an RNN unit takes two inputs:</p>
<ul>
<li><p>the sequence input at time t (x_t), and</p>
</li>
<li><p>the hidden state from the previous step (h_{t-1}). The hidden state is then updated with:</p>
</li>
</ul>
<pre><code class="language-plaintext">h_t = f(W_h · h_{t-1} + W_x · x_t + b)
</code></pre>
<p>where <code>h_t</code> is the hidden state at time t, <code>W_h</code> is the weight for the previous hidden state, <code>W_x</code> is the weight for the current input, <code>b</code> is the bias term, and <code>f</code> is an activation function — typically <strong>tanh</strong>. ReLU is rarely used directly in the recurrent connection, since it's more prone to causing exploding gradients when unrolled across many time steps.</p>
<p>The flow, step by step:</p>
<ol>
<li><p><strong>Input</strong> — the sequence is fed in one element at a time (e.g., word by word in a sentence).</p>
</li>
<li><p><strong>Hidden state update</strong> — the current input is combined with the previous hidden state via the equation above.</p>
</li>
<li><p><strong>Output</strong> — the updated hidden state produces an output, either as a prediction for that time step or as input for the next one.</p>
</li>
</ol>
<h2>Limitations of RNNs</h2>
<p>Despite being well suited to sequential data, vanilla RNNs have two well-known limitations:</p>
<ul>
<li><p><strong>Vanishing gradients</strong> — over long sequences, the gradients used to update weights can shrink drastically, making it hard for the network to learn from steps far back in the sequence.</p>
</li>
<li><p><strong>Difficulty capturing long-term dependencies</strong> — a direct consequence of vanishing gradients: vanilla RNNs tend to "forget" information from early steps in long sequences. These two problems are exactly what motivated more advanced RNN variants: LSTM and GRU.</p>
</li>
</ul>
<h2>RNN vs LSTM vs GRU</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Vanilla RNN</th>
<th>LSTM</th>
<th>GRU</th>
</tr>
</thead>
<tbody><tr>
<td>Gating mechanism</td>
<td>None</td>
<td>Forget, input, output gates</td>
<td>Reset, update gates</td>
</tr>
<tr>
<td>Parameter complexity</td>
<td>Simplest</td>
<td>Most complex</td>
<td>Simpler than LSTM</td>
</tr>
<tr>
<td>Long-term dependency handling</td>
<td>Weak</td>
<td>Strong</td>
<td>Strong</td>
</tr>
<tr>
<td>Training speed</td>
<td>Fastest</td>
<td>Slower (more parameters)</td>
<td>Faster than LSTM</td>
</tr>
<tr>
<td>Best suited for</td>
<td>Short sequences</td>
<td>Long sequences, high accuracy needs</td>
<td>Long sequences, efficiency-focused</td>
</tr>
</tbody></table>
<p><em>Note: the "training speed" and "long-term dependency handling" rows reflect general tendencies, not absolute rules — actual performance still depends on implementation, data size, and hardware.</em></p>
<ul>
<li><p><strong>Vanilla RNN</strong> — the most basic form, and the foundation for more advanced models.</p>
</li>
<li><p><strong>LSTM (Long Short-Term Memory)</strong> — a modified RNN designed to tackle vanishing gradients using gates (forget, input, output) that regulate the flow of information.</p>
</li>
<li><p><strong>GRU (Gated Recurrent Unit)</strong> — a lighter version of LSTM with only two gates (reset, update), still effective at capturing long-term dependencies but computationally cheaper.</p>
</li>
</ul>
<h2>Training RNNs: BPTT and Its Challenges</h2>
<p>Training an RNN means optimizing it to predict accurate outputs from given inputs. The overall process:</p>
<ol>
<li><p><strong>Data collection</strong> — relevant and sufficiently large.</p>
</li>
<li><p><strong>Preprocessing</strong> — normalization, tokenization (for text), splitting into train/validation/test sets.</p>
</li>
<li><p><strong>Model initialization</strong> — choosing the number of layers, neurons, and activation functions.</p>
</li>
<li><p><strong>Loss function selection</strong> — matched to the task.</p>
</li>
<li><p><strong>Optimizer selection</strong> — SGD, Adam, or RMSprop.</p>
</li>
<li><p><strong>Backpropagation Through Time (BPTT)</strong> — a backpropagation variant that computes gradients across the full sequence, then updates weights based on every time step. Because BPTT is prone to vanishing/exploding gradients, a few complementary techniques are commonly used:</p>
</li>
</ol>
<ul>
<li><p><strong>Gradient clipping</strong> — caps gradient magnitude to prevent it from exploding.</p>
</li>
<li><p><strong>Learning rate tuning</strong> — too high destabilizes training; too low slows convergence.</p>
</li>
<li><p><strong>Regularization (dropout)</strong> — reduces overfitting risk in complex models.</p>
</li>
<li><p><strong>Transfer learning</strong> — reusing a model pretrained on a large dataset, then adapting it to a specific context.</p>
</li>
<li><p><strong>Cross-validation</strong> — checks that the model isn't overfitting to the training data.</p>
</li>
</ul>
<h2>RNN Applications in NLP</h2>
<p>RNNs — especially LSTM and GRU — show up across a wide range of language and sequence tasks:</p>
<ul>
<li><p><strong>Machine translation</strong> — processing a sentence in one language and producing an equivalent sentence in another, factoring in context from the whole sequence.</p>
</li>
<li><p><strong>Sentiment analysis</strong> — classifying a piece of text (a product review, a social media comment) as positive, negative, or neutral, based on word order.</p>
</li>
<li><p><strong>Text generation</strong> — learning language patterns from large text datasets to generate new, coherent sentences or paragraphs.</p>
</li>
<li><p><strong>Virtual assistants and chatbots</strong> — understanding conversational sequences to produce responses that fit the ongoing dialogue.</p>
</li>
<li><p><strong>Speech recognition</strong> — converting sequential audio signals into text, using context from preceding sound to improve accuracy.</p>
</li>
</ul>
<blockquote>
<p><strong>Note:</strong> this session's material focuses on RNNs. Word embeddings (vector representations of words, like Word2Vec/GloVe) are covered separately in <a href="https://shaka-ai.hashnode.dev/word-embedding-and-sentiment-analysis-with-lstm"><strong>"Word Embedding &amp; Sentiment Analysis with LSTM"</strong></a> — paired with a hands-on real-world example.</p>
</blockquote>
<blockquote>
<p><strong>Modern context:</strong> for many NLP tasks today (translation, large-scale text classification, chatbots), <strong>Transformer</strong>-based architectures (like BERT, GPT) are generally the go-to choice, since they parallelize better and capture long-range dependencies more effectively. RNN/LSTM/GRU remain well worth learning as the foundation for understanding sequential data processing, and are still used for certain cases (lightweight time-series work, resource-constrained devices, and similar).</p>
</blockquote>
<h2>Hands-On: Classifying Name Origins with an RNN</h2>
<p>To make the theory above less abstract, I implemented a vanilla RNN for a concrete task: <strong>guessing a name's language of origin</strong>, character by character, using exactly the equation covered above — <code>h_t = tanh(W_h · h_{t-1} + W_x · x_t)</code>.</p>
<p>The setup:</p>
<ul>
<li><p>Input: 57 unique characters (letters plus a few symbols)</p>
</li>
<li><p>Hidden size: 128</p>
</li>
<li><p>Output: 18 language/origin categories (Japanese, Irish, Spanish, and so on)</p>
</li>
<li><p>Training: 100,000 iterations, BPTT with manual SGD (learning rate 0.005) <strong>Verified training results (pulled directly from the run, not estimated):</strong></p>
</li>
</ul>
<table>
<thead>
<tr>
<th>Checkpoint</th>
<th>Loss</th>
</tr>
</thead>
<tbody><tr>
<td>Iteration 1,000</td>
<td>2.87</td>
</tr>
<tr>
<td>Iteration 50,000</td>
<td>1.28</td>
</tr>
<tr>
<td>Iteration 100,000</td>
<td>0.83</td>
</tr>
</tbody></table>
<p>The loss dropped steadily from ~2.87 to ~0.83 — a sign the model was actually learning, not just guessing.</p>
<p>On names that fall within the training distribution, predictions look reasonable:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Top-3 Predictions</th>
</tr>
</thead>
<tbody><tr>
<td>Abdiel</td>
<td>Spanish, French, English</td>
</tr>
<tr>
<td>Jackson</td>
<td>Scottish, English, Russian</td>
</tr>
<tr>
<td>Satoshi</td>
<td>Japanese, Polish, Italian</td>
</tr>
</tbody></table>
<p>But testing it on modern footballer names — clearly outside the original data distribution — the accuracy falls apart:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Prediction</th>
<th>Confidence</th>
</tr>
</thead>
<tbody><tr>
<td>ronaldo</td>
<td>Italian</td>
<td>56.1%</td>
</tr>
<tr>
<td>messi</td>
<td>Italian</td>
<td>74.7%</td>
</tr>
<tr>
<td>mbappe</td>
<td>Czech</td>
<td>21.8%</td>
</tr>
</tbody></table>
<p>This isn't a bug — it's a textbook case of <strong>distribution shift</strong>: the training data is a set of historical surnames grouped by language, not a database of modern public figures' nationalities. It's a good reminder that understanding your training data matters as much as understanding your architecture.</p>
<p>The full notebook (training, confusion matrix evaluation, and inference) is available on GitHub: <a href="https://github.com/arielshakaramiro/rnn-name-origin-classifier-arielshakaramiro">rnn-name-origin-classifier-arielshakaramiro</a>.</p>
<h2>Understanding Checklist</h2>
<p>Before moving on to the next post (word embeddings and LSTM in practice), check your understanding:</p>
<ul>
<li><p>[ ] Can explain how an RNN differs from a feedforward neural network</p>
</li>
<li><p>[ ] Understand the role of the hidden state and why parameters are shared across time steps</p>
</li>
<li><p>[ ] Can explain why vanishing gradients happen in vanilla RNNs</p>
</li>
<li><p>[ ] Know the core difference between LSTM and GRU</p>
</li>
<li><p>[ ] Understand BPTT's role in training RNNs</p>
</li>
</ul>
<h2>Quick Quiz</h2>
<p><strong>1. What's the main role of the hidden state in an RNN?</strong> It carries information from previous time steps to influence how the current input is processed — this is what gives an RNN the ability to "remember" context across a sequence.</p>
<p><strong>2. Why do vanilla RNNs struggle with long-term dependencies?</strong> Because of vanishing gradients — over long sequences, gradients propagated backward through BPTT shrink drastically, making it hard to update weights based on early steps in the sequence.</p>
<p><strong>3. What's the key difference between LSTM and GRU?</strong> LSTM has three gates (forget, input, output) and is more complex; GRU has only two (reset, update), making it simpler and computationally lighter while still handling long-term dependencies effectively.</p>
<hr />
<p><em>Part of the "AI Notes &amp; Engineering" series — my personal AI engineering study notes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Mengenal Recurrent Neural Network (RNN): Arsitektur untuk Data Sekuensial]]></title><description><![CDATA[Pernah kepikiran kenapa model yang bisa membaca kalimat, mengenali ucapan, atau memprediksi harga saham butuh arsitektur yang berbeda dari model klasifikasi gambar biasa? Jawabannya ada di satu kata: ]]></description><link>https://shaka-ai.hashnode.dev/mengenal-recurrent-neural-network-rnn</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/mengenal-recurrent-neural-network-rnn</guid><category><![CDATA[RNN]]></category><category><![CDATA[recurrent neural network]]></category><category><![CDATA[DeepLearning]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[nlp]]></category><category><![CDATA[LSTM]]></category><category><![CDATA[gru]]></category><category><![CDATA[AI Engineering]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Mon, 07 Sep 2026 11:58:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/374c0d94-68fc-46c1-8032-440871024568.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Pernah kepikiran kenapa model yang bisa membaca kalimat, mengenali ucapan, atau memprediksi harga saham butuh arsitektur yang berbeda dari model klasifikasi gambar biasa? Jawabannya ada di satu kata: <strong>urutan</strong>. Di sinilah Recurrent Neural Network (RNN) masuk.</p>
<blockquote>
<p><strong>TL;DR:</strong> RNN memproses data secara berurutan dengan "mengingat" langkah sebelumnya lewat hidden state. Kelemahan utamanya (vanishing gradient) diatasi oleh varian yang lebih canggih: LSTM dan GRU. Di bagian akhir, konsep ini dipraktikkan langsung untuk klasifikasi asal nama.</p>
</blockquote>
<h2>Daftar Isi</h2>
<ul>
<li><p><a href="#apa-itu-rnn">Apa itu RNN?</a></p>
</li>
<li><p><a href="#karakteristik-utama-rnn">Karakteristik Utama RNN</a></p>
</li>
<li><p><a href="#struktur-dan-cara-kerja-rnn">Struktur dan Cara Kerja RNN</a></p>
</li>
<li><p><a href="#kelemahan-rnn">Kelemahan RNN</a></p>
</li>
<li><p><a href="#rnn-vs-lstm-vs-gru">RNN vs LSTM vs GRU</a></p>
</li>
<li><p><a href="#melatih-rnn-bptt-dan-tantangannya">Melatih RNN: BPTT dan Tantangannya</a></p>
</li>
<li><p><a href="#aplikasi-rnn-dalam-nlp">Aplikasi RNN dalam NLP</a></p>
</li>
<li><p><a href="#praktik-klasifikasi-asal-nama-dengan-rnn">Praktik: Klasifikasi Asal Nama dengan RNN</a></p>
</li>
<li><p><a href="#checklist-pemahaman">Checklist Pemahaman</a></p>
</li>
<li><p><a href="#kuis-singkat">Kuis Singkat</a></p>
</li>
</ul>
<hr />
<h2>Apa itu RNN?</h2>
<p>🤔 Coba Tebak Dulu: menurutmu, apa yang membedakan RNN dari neural network biasa (feedforward)?</p>
<p>Jawabannya: **memori**. Neural network biasa memproses setiap input secara independen — tidak ada "ingatan" dari input sebelumnya. RNN, sebaliknya, menyimpan informasi dari langkah waktu sebelumnya lewat *hidden state*, sehingga bisa menangkap konteks dalam data yang berurutan. Recurrent Neural Network (RNN) adalah arsitektur jaringan saraf yang dirancang khusus untuk memproses data berurutan (sequential) seperti teks, audio, atau deret waktu. Bedanya dengan jaringan feedforward, RNN mempertimbangkan informasi dari langkah sebelumnya saat memproses input saat ini — sehingga cocok untuk tugas seperti pengenalan suara, pemrosesan bahasa alami, dan analisis deret waktu.</p>
<p>Secara visual, RNN sering digambarkan sebagai satu unit dengan koneksi <em>loop</em> ke dirinya sendiri, yang kemudian di-<em>unfold</em> menjadi rangkaian unit sepanjang langkah waktu t-1, t, t+1, dan seterusnya.</p>
<h2>Karakteristik Utama RNN</h2>
<ol>
<li><p><strong>Memori jangka pendek</strong> — RNN menyimpan informasi dari langkah sebelumnya dalam sebuah "memori" (hidden state) untuk mempengaruhi keputusan saat ini, sehingga bisa menangkap dependensi antar-elemen dalam urutan.</p>
</li>
<li><p><strong>Pengulangan (recurrence)</strong> — ada koneksi umpan balik yang memungkinkan informasi dari neuron di waktu t-1 mempengaruhi neuron di waktu t.</p>
</li>
<li><p><strong>Parameter sharing</strong> — RNN memakai parameter (bobot) yang sama di setiap langkah waktu, sehingga jumlah parameter yang perlu dilatih jauh lebih sedikit dibanding kalau tiap langkah waktu punya bobot sendiri.</p>
</li>
</ol>
<h2>Struktur dan Cara Kerja RNN</h2>
<p>Setiap unit RNN menerima dua input pada waktu t:</p>
<ul>
<li><p>input data urutan pada waktu t (x_t), dan</p>
</li>
<li><p>hidden state dari waktu sebelumnya (h_{t-1}). Hidden state ini kemudian diperbarui melalui persamaan:</p>
</li>
</ul>
<pre><code class="language-plaintext">h_t = f(W_h · h_{t-1} + W_x · x_t + b)
</code></pre>
<p>di mana <code>h_t</code> adalah hidden state pada waktu t, <code>W_h</code> adalah bobot untuk hidden state sebelumnya, <code>W_x</code> adalah bobot untuk input saat ini, <code>b</code> adalah bias, dan <code>f</code> adalah fungsi aktivasi — umumnya <strong>tanh</strong>. ReLU jarang dipakai langsung di koneksi rekuren karena lebih rawan menyebabkan <em>exploding gradient</em> saat di-unroll sepanjang banyak langkah waktu.</p>
<p>Alur kerjanya secara garis besar:</p>
<ol>
<li><p><strong>Input data</strong> — data urutan diterima satu per satu (misalnya kata demi kata dalam kalimat).</p>
</li>
<li><p><strong>Update hidden state</strong> — input saat ini digabungkan dengan hidden state sebelumnya lewat persamaan di atas.</p>
</li>
<li><p><strong>Output</strong> — dari hidden state yang sudah diperbarui, RNN menghasilkan output, baik untuk prediksi di langkah waktu itu maupun untuk diteruskan ke langkah berikutnya.</p>
</li>
</ol>
<h2>Kelemahan RNN</h2>
<p>Meski cukup powerful untuk data sekuensial, RNN vanilla punya dua kelemahan utama:</p>
<ul>
<li><p><strong>Vanishing gradient</strong> — pada urutan yang panjang, gradien yang dipakai untuk update bobot bisa mengecil drastis, sehingga jaringan kesulitan belajar dari langkah-langkah yang jauh di awal urutan.</p>
</li>
<li><p><strong>Kesulitan menangkap dependensi jangka panjang</strong> — akibat langsung dari vanishing gradient, RNN vanilla sering "lupa" informasi dari langkah-langkah awal pada sekuens yang panjang. Dua masalah inilah yang mendorong lahirnya varian RNN yang lebih canggih: LSTM dan GRU.</p>
</li>
</ul>
<h2>RNN vs LSTM vs GRU</h2>
<table>
<thead>
<tr>
<th>Aspek</th>
<th>Vanilla RNN</th>
<th>LSTM</th>
<th>GRU</th>
</tr>
</thead>
<tbody><tr>
<td>Mekanisme gate</td>
<td>Tidak ada</td>
<td>Forget, input, output gate</td>
<td>Reset, update gate</td>
</tr>
<tr>
<td>Kompleksitas parameter</td>
<td>Paling sederhana</td>
<td>Paling kompleks</td>
<td>Lebih sederhana dari LSTM</td>
</tr>
<tr>
<td>Menangani dependensi jangka panjang</td>
<td>Kurang baik</td>
<td>Baik</td>
<td>Baik</td>
</tr>
<tr>
<td>Kecepatan training</td>
<td>Paling cepat</td>
<td>Lebih lambat (parameter lebih banyak)</td>
<td>Lebih cepat dari LSTM</td>
</tr>
<tr>
<td>Cocok untuk</td>
<td>Sekuens pendek</td>
<td>Sekuens panjang, kebutuhan akurasi tinggi</td>
<td>Sekuens panjang, butuh efisiensi</td>
</tr>
</tbody></table>
<p><em>Catatan: baris "kecepatan training" dan "menangani dependensi jangka panjang" adalah kecenderungan umum, bukan aturan mutlak — performa nyata tetap tergantung implementasi, ukuran data, dan hardware yang dipakai.</em></p>
<ul>
<li><p><strong>Vanilla RNN</strong> — bentuk paling dasar, jadi fondasi model-model yang lebih kompleks.</p>
</li>
<li><p><strong>LSTM (Long Short-Term Memory)</strong> — versi modifikasi RNN yang dirancang khusus untuk mengatasi vanishing gradient dengan mekanisme <em>gate</em> (forget, input, output) yang mengontrol aliran informasi.</p>
</li>
<li><p><strong>GRU (Gated Recurrent Unit)</strong> — versi yang lebih sederhana dari LSTM (hanya reset dan update gate) tapi tetap efektif menangkap dependensi jangka panjang, dengan komputasi yang lebih ringan.</p>
</li>
</ul>
<h2>Melatih RNN: BPTT dan Tantangannya</h2>
<p>Pelatihan RNN bertujuan mengoptimalkan jaringan agar bisa memprediksi output yang akurat dari input yang diberikan. Prosesnya secara garis besar:</p>
<ol>
<li><p><strong>Pengumpulan data</strong> yang relevan dan cukup besar.</p>
</li>
<li><p><strong>Pra-pemrosesan</strong> — normalisasi, tokenisasi (untuk teks), pembagian data latih/validasi/uji.</p>
</li>
<li><p><strong>Inisialisasi model</strong> — menentukan jumlah lapisan, neuron, dan fungsi aktivasi.</p>
</li>
<li><p><strong>Pemilihan fungsi loss</strong> yang sesuai dengan tugas.</p>
</li>
<li><p><strong>Pemilihan optimizer</strong> — SGD, Adam, atau RMSprop.</p>
</li>
<li><p><strong>Backpropagation Through Time (BPTT)</strong> — varian backpropagation yang menghitung gradien dari keseluruhan urutan waktu, lalu memperbarui bobot berdasarkan seluruh langkah waktu tersebut. Karena BPTT rentan terhadap vanishing/exploding gradient, ada beberapa teknik tambahan yang biasa dipakai:</p>
</li>
</ol>
<ul>
<li><p><strong>Gradient clipping</strong> — membatasi magnitudo gradien supaya tidak meledak (exploding gradient).</p>
</li>
<li><p><strong>Pengaturan learning rate</strong> — terlalu besar bikin training tidak stabil, terlalu kecil bikin lambat konvergen.</p>
</li>
<li><p><strong>Regularisasi (dropout)</strong> — mengurangi risiko overfitting pada model yang kompleks.</p>
</li>
<li><p><strong>Transfer learning</strong> — memakai model yang sudah dilatih pada dataset besar, lalu disesuaikan ke konteks spesifik.</p>
</li>
<li><p><strong>Cross-validation</strong> — memastikan model tidak overfit terhadap data latih.</p>
</li>
</ul>
<h2>Aplikasi RNN dalam NLP</h2>
<p>RNN (terutama LSTM dan GRU) dipakai luas dalam berbagai tugas pemrosesan bahasa dan urutan:</p>
<ul>
<li><p><strong>Penerjemahan bahasa</strong> — memproses kalimat dalam satu bahasa dan menghasilkan kalimat setara di bahasa lain, dengan mempertimbangkan konteks seluruh urutan.</p>
</li>
<li><p><strong>Analisis sentimen</strong> — menentukan apakah sebuah teks (ulasan produk, komentar media sosial) bernada positif, negatif, atau netral, dengan memperhatikan urutan kata.</p>
</li>
<li><p><strong>Penghasilan teks (text generation)</strong> — mempelajari pola bahasa dari dataset besar untuk menghasilkan kalimat atau paragraf baru yang koheren.</p>
</li>
<li><p><strong>Asisten virtual dan chatbot</strong> — memahami urutan percakapan untuk memberi respons yang relevan dengan konteks dialog.</p>
</li>
<li><p><strong>Speech recognition</strong> — mengubah sinyal audio berurutan menjadi teks, dengan mempertimbangkan konteks suara sebelumnya.</p>
</li>
</ul>
<blockquote>
<p><strong>Catatan:</strong> materi sesi ini fokus pada RNN. Pembahasan word embedding (representasi kata sebagai vektor, seperti Word2Vec/GloVe) saya bahas terpisah di tulisan <a href="https://shaka-ai.hashnode.dev/word-embedding-sentiment-analysis-lstm-glove"><strong>"Word Embedding &amp; Sentiment Analysis dengan LSTM"</strong></a> — sekalian dipraktikkan ke kasus nyata.</p>
</blockquote>
<blockquote>
<p><strong>Konteks modern:</strong> untuk banyak tugas NLP saat ini (terjemahan, klasifikasi teks skala besar, chatbot), arsitektur berbasis <strong>Transformer</strong> (seperti BERT, GPT) umumnya jadi pilihan utama karena lebih mudah diparalelkan dan lebih baik menangkap dependensi jarak jauh. RNN/LSTM/GRU di tulisan ini tetap relevan dipelajari sebagai fondasi untuk memahami pemrosesan data sekuensial, dan masih dipakai untuk kasus-kasus tertentu (misalnya time-series ringan atau perangkat dengan resource terbatas).</p>
</blockquote>
<h2>Praktik: Klasifikasi Asal Nama dengan RNN</h2>
<p>Biar teori di atas nggak cuma di kepala, saya coba langsung implementasi vanilla RNN untuk kasus nyata: <strong>menebak asal bahasa dari sebuah nama</strong>, huruf demi huruf, persis pakai persamaan <code>h_t = tanh(W_h · h_{t-1} + W_x · x_t)</code> yang sudah dibahas.</p>
<p>Setup singkatnya:</p>
<ul>
<li><p>Input: 57 karakter unik (huruf + beberapa simbol)</p>
</li>
<li><p>Hidden size: 128</p>
</li>
<li><p>Output: 18 kategori bahasa/asal (Japanese, Irish, Spanish, dst.)</p>
</li>
<li><p>Training: 100.000 iterasi, BPTT + manual SGD (learning rate 0.005) <strong>Hasil training (diverifikasi langsung dari output, bukan estimasi):</strong></p>
</li>
</ul>
<table>
<thead>
<tr>
<th>Checkpoint</th>
<th>Loss</th>
</tr>
</thead>
<tbody><tr>
<td>Iterasi ke-1.000</td>
<td>2.87</td>
</tr>
<tr>
<td>Iterasi ke-50.000</td>
<td>1.28</td>
</tr>
<tr>
<td>Iterasi ke-100.000</td>
<td>0.83</td>
</tr>
</tbody></table>
<p>Loss turun stabil dari ~2.87 ke ~0.83 — tanda modelnya memang belajar, bukan cuma nebak acak.</p>
<p>Pas dites ke nama-nama di dalam distribusi dataset, hasilnya masuk akal:</p>
<table>
<thead>
<tr>
<th>Nama</th>
<th>Top-3 Prediksi</th>
</tr>
</thead>
<tbody><tr>
<td>Abdiel</td>
<td>Spanish, French, English</td>
</tr>
<tr>
<td>Jackson</td>
<td>Scottish, English, Russian</td>
</tr>
<tr>
<td>Satoshi</td>
<td>Japanese, Polish, Italian</td>
</tr>
</tbody></table>
<p>Tapi begitu saya coba nama-nama pesepakbola modern — yang jelas di luar distribusi dataset aslinya — hasilnya jadi jauh dari akurat:</p>
<table>
<thead>
<tr>
<th>Nama</th>
<th>Prediksi</th>
<th>Confidence</th>
</tr>
</thead>
<tbody><tr>
<td>ronaldo</td>
<td>Italian</td>
<td>56.1%</td>
</tr>
<tr>
<td>messi</td>
<td>Italian</td>
<td>74.7%</td>
</tr>
<tr>
<td>mbappe</td>
<td>Czech</td>
<td>21.8%</td>
</tr>
</tbody></table>
<p>Ini bukan bug — ini contoh nyata <strong>distribution shift</strong>: dataset training berisi nama-nama historis per kategori bahasa, bukan basis data kewarganegaraan tokoh publik modern. Justru dari sinilah kelihatan kenapa memahami data yang dipakai untuk melatih model itu sama pentingnya dengan memahami arsitekturnya.</p>
<p>Notebook lengkap (training, evaluasi confusion matrix, sampai inference) tersedia di GitHub: <a href="https://github.com/arielshakaramiro/rnn-name-origin-classifier-arielshakaramiro">rnn-name-origin-classifier-arielshakaramiro</a>.</p>
<h2>Checklist Pemahaman</h2>
<p>Sebelum lanjut ke topik berikutnya (praktik word embedding &amp; LSTM di kasus nyata), coba cek pemahamanmu:</p>
<ul>
<li><p>[ ] Bisa menjelaskan bedanya RNN dengan feedforward neural network</p>
</li>
<li><p>[ ] Paham fungsi hidden state dan kenapa parameter di-<em>share</em> antar langkah waktu</p>
</li>
<li><p>[ ] Bisa menjelaskan kenapa vanishing gradient terjadi di RNN vanilla</p>
</li>
<li><p>[ ] Tahu perbedaan mendasar antara LSTM dan GRU</p>
</li>
<li><p>[ ] Paham peran BPTT dalam melatih RNN</p>
</li>
</ul>
<h2>Kuis Singkat</h2>
<p><strong>1. Apa fungsi utama hidden state dalam RNN?</strong> Menyimpan informasi dari langkah waktu sebelumnya agar bisa mempengaruhi pemrosesan input saat ini — inilah yang memberi RNN kemampuan "mengingat" konteks dalam urutan.</p>
<p><strong>2. Kenapa RNN vanilla kesulitan menangkap dependensi jangka panjang?</strong> Karena masalah vanishing gradient — saat urutan panjang, gradien yang dipropagasi mundur (lewat BPTT) mengecil drastis, membuat bobot sulit diperbarui berdasarkan langkah-langkah awal urutan.</p>
<p><strong>3. Apa perbedaan utama LSTM dan GRU?</strong> LSTM punya tiga gate (forget, input, output) dan lebih kompleks; GRU hanya punya dua gate (reset, update), lebih sederhana dan lebih ringan secara komputasi, namun tetap efektif menangkap dependensi jangka panjang.</p>
<hr />
<p><em>Bagian dari seri "AI Notes &amp; Engineering" — catatan belajar AI Engineering saya.</em></p>
]]></content:encoded></item><item><title><![CDATA[Intent Classification with TF-IDF + Logistic Regression]]></title><description><![CDATA[The previous two notes used TF-IDF to measure similarity between texts (FAQ search). This time the approach is different: TF-IDF is used as a feature to train a classifier that predicts the intent beh]]></description><link>https://shaka-ai.hashnode.dev/intent-classification-tfidf-logistic-regression</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/intent-classification-tfidf-logistic-regression</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[scikit learn]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 06 Sep 2026 08:44:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/a338b954-a72b-4cc2-b21b-54a927da6f19.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The previous two notes used TF-IDF to <em>measure similarity</em> between texts (FAQ search). This time the approach is different: TF-IDF is used as a feature to <strong>train a classifier</strong> that predicts the intent behind a sentence — whether it's a greeting, an identity question, or a time question.</p>
<p>The model scored 100% accuracy. But that number turned out to be less straightforward than it looks.</p>
<blockquote>
<p>The dataset used contains 94 Indonesian sentences with 3 labels: <code>greeting</code>, <code>siapa_anda</code> (who are you), <code>sekarang_jam_berapa</code> (what time is it) — bootcamp practice material, not production data.</p>
</blockquote>
<h2>How It Works</h2>
<ol>
<li><p><strong>Preprocessing</strong> — lowercase, strip punctuation, stem with Sastrawi (same as the earlier FAQ search engine project).</p>
</li>
<li><p><strong>Training</strong> — <code>TfidfVectorizer</code> + <code>LogisticRegression</code> combined in a single <code>sklearn.Pipeline</code>.</p>
</li>
<li><p><strong>Evaluation</strong> — accuracy and classification report on the test set.</p>
</li>
</ol>
<h2>100% Accuracy — Coincidence or Real?</h2>
<p>The first split (80% train, 20% test) produced 100% accuracy on 19 test samples. A number that good is easy to get excited about, but also easy to be misled by — 19 samples is small, and a result this clean could just be luck.</p>
<p>To check, I ran <strong>5-fold stratified cross-validation</strong> — training and testing the model 5 times with different data splits:</p>
<pre><code class="language-plaintext">Scores per fold: [1. 1. 1. 1. 1.]
Mean: 1.0000 (std: 0.0000)
</code></pre>
<p>Still 100% across every fold. So this <strong>isn't a fluke from one lucky split</strong>. But it's also not proof the technique itself is remarkably powerful — a more plausible explanation: the three intents in this dataset are <strong>lexically very distinct</strong>. Sentences about time ("jam", "sekarang", "pukul") almost never share words with greetings ("halo", "selamat") or identity questions ("siapa", "kamu"). TF-IDF + Logistic Regression will always look "perfect" when the classes are already this far apart in vocabulary — it's not a representative benchmark for intents that are more similar to each other.</p>
<h2>Testing the Real Limits: Cases Outside the Training Data</h2>
<p>To actually find the model's limits, I tried sentences that don't appear in the training data at all — slang, ambiguous phrasing, and completely off-topic sentences:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/intent-classification-tfidf-logistic-arielshakaramiro/main/images/edge-case-confidence.png" alt="Model confidence on hard test cases" style="display:block;margin:0 auto" />

<p>Two interesting findings here:</p>
<ol>
<li><p><code>"kamu tau ga sekarang tanggal berapa"</code> (asking about the <strong>date</strong>, not the <strong>time</strong>) still gets classified as <code>sekarang_jam_berapa</code>. The model doesn't actually distinguish the concepts of "date" and "time" — it just recognizes the word pattern "sekarang" + "berapa" (now + how much/what), which happens to resemble the time-intent pattern from training.</p>
</li>
<li><p><code>"random kalimat tidak jelas maksudnya apa"</code> (a sentence with no clear topic at all) still gets forced into one of the 3 classes (<code>greeting</code>, with a confidence of just 0.439 — barely above a random guess among 3 classes). This model <strong>has no "unknown" class</strong>, so no matter the input, it will always return one of the 3 existing labels.</p>
</li>
</ol>
<blockquote>
<p><strong>This mirrors the Euclidean distance bug from the earlier FAQ search engine note:</strong> a system built without considering out-of-scope input will always force an answer rather than admit it doesn't know. The mechanism is different — there it was a distance threshold, here it's the lack of a rejection class — but the lesson is the same.</p>
</blockquote>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Finding</th>
</tr>
</thead>
<tbody><tr>
<td>Single-split accuracy</td>
<td>100% (19 test samples) — easy to misread</td>
</tr>
<tr>
<td>5-fold CV accuracy</td>
<td>100% (consistent) — not a fluke, but because the classes are lexically far apart</td>
</tr>
<tr>
<td>"Date" vs "time" case</td>
<td>Misclassified — the model matches word patterns, not concepts</td>
</tr>
<tr>
<td>Off-topic case</td>
<td>Forced into one of 3 classes, low confidence, no "unknown" class</td>
</tr>
</tbody></table>
<p><strong>Main takeaway:</strong> high accuracy on a small dataset with lexically distinct classes doesn't automatically mean the model is reliable for real-world cases that are more ambiguous or fall outside the training data's scope.</p>
<p>The full code and re-executed notebook are available in <a href="https://github.com/arielshakaramiro/intent-classification-tfidf-logistic-arielshakaramiro">this GitHub repo</a>.</p>
<hr />
<p><em>Part of my AI Engineering study notes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Klasifikasi Intent dengan TF-IDF + Logistic Regression]]></title><description><![CDATA[Dua catatan sebelumnya bahas TF-IDF untuk mengukur kemiripan teks (pencarian FAQ). Kali ini pendekatannya beda: TF-IDF dipakai sebagai fitur untuk melatih classifier yang memprediksi intent (niat) dar]]></description><link>https://shaka-ai.hashnode.dev/klasifikasi-intent-tfidf-logistic-regression</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/klasifikasi-intent-tfidf-logistic-regression</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[scikit learn]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 06 Sep 2026 08:42:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/c7b6f594-1da8-41c4-a28e-ea9a6a5d076b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Dua catatan sebelumnya bahas TF-IDF untuk <em>mengukur kemiripan</em> teks (pencarian FAQ). Kali ini pendekatannya beda: TF-IDF dipakai sebagai fitur untuk <strong>melatih classifier</strong> yang memprediksi intent (niat) dari sebuah kalimat — apakah itu sapaan, pertanyaan identitas, atau pertanyaan waktu.</p>
<p>Modelnya dapat akurasi 100%. Tapi angka itu ternyata tidak sesederhana kelihatannya.</p>
<blockquote>
<p>Dataset yang dipakai berisi 94 kalimat Bahasa Indonesia dengan 3 label: <code>greeting</code>, <code>siapa_anda</code>, <code>sekarang_jam_berapa</code> — dari materi latihan bootcamp, bukan data produksi.</p>
</blockquote>
<h2>Cara Kerja</h2>
<ol>
<li><p><strong>Preprocessing</strong> — lowercase, hapus tanda baca, stemming pakai Sastrawi (sama seperti proyek FAQ search engine sebelumnya).</p>
</li>
<li><p><strong>Training</strong> — <code>TfidfVectorizer</code> + <code>LogisticRegression</code> digabung dalam satu <code>sklearn.Pipeline</code>.</p>
</li>
<li><p><strong>Evaluasi</strong> — akurasi dan classification report di test set.</p>
</li>
</ol>
<h2>Akurasi 100% — Kebetulan atau Nyata?</h2>
<p>Split pertama (80% train, 20% test) menghasilkan akurasi 100% di 19 sampel test. Angka sebagus ini gampang bikin senang, tapi juga gampang menyesatkan — 19 sampel itu kecil, hasil sebagus ini bisa saja kebetulan.</p>
<p>Untuk memastikan, saya jalankan <strong>5-fold stratified cross-validation</strong> — melatih dan menguji model 5 kali dengan pembagian data yang berbeda-beda:</p>
<pre><code class="language-plaintext">Skor tiap fold: [1. 1. 1. 1. 1.]
Rata-rata: 1.0000 (std: 0.0000)
</code></pre>
<p>Tetap 100% di semua fold. Jadi ini <strong>bukan kebetulan hasil satu split yang beruntung</strong>. Tapi ini juga bukan bukti tekniknya luar biasa canggih — penjelasan yang lebih masuk akal: ketiga intent di dataset ini <strong>sangat berbeda secara leksikal</strong>. Kalimat soal waktu ("jam", "sekarang", "pukul") nyaris tidak pernah beririsan kata dengan kalimat sapaan ("halo", "selamat") atau kalimat identitas ("siapa", "kamu"). TF-IDF + Logistic Regression akan selalu terlihat "sempurna" kalau kelas-kelasnya sudah terpisah jauh secara kata — ini bukan ukuran yang representatif untuk kasus intent yang lebih mirip satu sama lain.</p>
<h2>Menguji Batas Sebenarnya: Kasus di Luar Data Training</h2>
<p>Untuk benar-benar tahu batas kemampuan model, saya coba kalimat yang tidak ada di data training sama sekali — termasuk slang, frasa ambigu, dan kalimat di luar topik:</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/intent-classification-tfidf-logistic-arielshakaramiro/main/images/edge-case-confidence.png" alt="Confidence model pada kasus uji sulit" style="display:block;margin:0 auto" />

<p>Dua hal menarik dari sini:</p>
<ol>
<li><p><code>"kamu tau ga sekarang tanggal berapa"</code> — ini nanya soal <strong>tanggal</strong>, bukan <strong>jam</strong> — tapi model tetap mengklasifikasikannya sebagai <code>sekarang_jam_berapa</code>. Model ini tidak benar-benar membedakan konsep "tanggal" dan "jam"; dia cuma mengenali pola kata "sekarang" + "berapa" yang kebetulan mirip pola intent waktu di data training.</p>
</li>
<li><p><code>"random kalimat tidak jelas maksudnya apa"</code> — kalimat ini di luar topik apa pun, tapi tetap dipaksa masuk ke salah satu dari 3 kelas (<code>greeting</code>, confidence cuma 0,439 — nyaris tebakan acak untuk 3 kelas). Model ini <strong>tidak punya kelas "tidak diketahui"</strong>, jadi apa pun inputnya, dia akan selalu memberi salah satu dari 3 label yang ada.</p>
</li>
</ol>
<blockquote>
<p><strong>Ini pola yang sama seperti bug Euclidean distance di catatan sebelumnya tentang FAQ search engine:</strong> sistem yang dibangun tanpa mempertimbangkan input di luar cakupan akan selalu "memaksakan" jawaban, bukan mengakui ketidaktahuannya. Bedanya cuma mekanismenya — di situ soal ambang batas jarak, di sini soal tidak adanya kelas penolakan.</p>
</blockquote>
<h2>Ringkasan</h2>
<table>
<thead>
<tr>
<th>Aspek</th>
<th>Temuan</th>
</tr>
</thead>
<tbody><tr>
<td>Akurasi single split</td>
<td>100% (19 sampel test) — rawan disalahartikan</td>
</tr>
<tr>
<td>Akurasi 5-fold CV</td>
<td>100% (konsisten) — bukan kebetulan, tapi karena kelas sangat terpisah secara leksikal</td>
</tr>
<tr>
<td>Kasus "tanggal" vs "jam"</td>
<td>Salah klasifikasi — model tidak membedakan konsep, cuma pola kata</td>
</tr>
<tr>
<td>Kasus di luar topik</td>
<td>Dipaksa masuk salah satu dari 3 kelas, confidence rendah, tidak ada kelas "tidak diketahui"</td>
</tr>
</tbody></table>
<p><strong>Pelajaran utama:</strong> akurasi tinggi di dataset kecil dengan kelas yang sangat berbeda secara leksikal tidak otomatis berarti model-nya andal untuk kasus dunia nyata yang lebih ambigu atau di luar cakupan data training.</p>
<p>Kode lengkap dan notebook yang sudah dijalankan ulang bisa dicek di <a href="https://github.com/arielshakaramiro/intent-classification-tfidf-logistic-arielshakaramiro">repo GitHub ini</a>.</p>
<hr />
<p><em>Bagian dari catatan belajar AI Engineering saya.</em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Simple TF-IDF Q&A Search Engine]]></title><description><![CDATA[After understanding how TF-IDF works in my previous note, I tried using it to build something real: an FAQ search engine. A user types a free-form question, the system finds the most similar question ]]></description><link>https://shaka-ai.hashnode.dev/simple-tfidf-qa-search-engine-python</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/simple-tfidf-qa-search-engine-python</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Python]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[chatbot]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 06 Sep 2026 04:11:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/43b798c1-53dc-4785-b000-a0eea9a0d346.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After understanding how TF-IDF works in my previous note, I tried using it to build something real: an FAQ search engine. A user types a free-form question, the system finds the most similar question in an FAQ database, and returns its answer.</p>
<p>Sounds simple. But once I started testing it with odd, off-topic questions, I found a bug that turned out to matter quite a bit for how "similarity" gets measured.</p>
<blockquote>
<p>The FAQ dataset used here is entirely fictional — the store name and every Q&amp;A pair are just examples for demonstrating the technique, not real business data.</p>
</blockquote>
<h2>How It Works</h2>
<ol>
<li><p><strong>Preprocessing</strong> — every question and answer is lowercased, stripped of punctuation, and each word is <em>stemmed</em> down to its root form using <a href="https://github.com/sastrawi/sastrawi">Sastrawi</a> (an Indonesian stemming library).</p>
</li>
<li><p><strong>Vectorization</strong> — all the preprocessed text is converted into TF-IDF vectors.</p>
</li>
<li><p><strong>Search</strong> — when a user types a question, the system computes a similarity score between the user's question and every question in the database, then returns the answer from whichever is most similar.</p>
</li>
</ol>
<h2>The Bug I Found: A Fragile Distance Threshold</h2>
<p>The first version of this system measured "similarity" using <strong>Euclidean distance</strong> (the straight-line distance between two vector points) with a fixed threshold: if the distance was below 1.0, it counted as a match.</p>
<p>I threw four test questions at it — three relevant, and one deliberately unrelated:</p>
<table>
<thead>
<tr>
<th>Test Question</th>
<th>Euclidean Distance</th>
<th>Cosine Similarity</th>
</tr>
</thead>
<tbody><tr>
<td>"how long does shipping take"</td>
<td>0.8294</td>
<td>0.6560</td>
</tr>
<tr>
<td>"how do I return a damaged item"</td>
<td>0.9124</td>
<td>0.5838</td>
</tr>
<tr>
<td>"is there any discount"</td>
<td>0.8952</td>
<td>0.5993</td>
</tr>
<tr>
<td><strong>"good fried rice recipe"</strong> <em>(off-topic)</em></td>
<td><strong>1.0000</strong></td>
<td><strong>0.0000</strong></td>
</tr>
</tbody></table>
<p>Look at the last row. A question about a fried rice recipe — clearly unrelated to a furniture FAQ — produced a Euclidean distance of <strong>exactly</strong> 1.0, sitting precisely on the threshold line. Not "almost" — genuinely right at the boundary. If the comparison had come out even marginally different, the system would have confidently treated it as a "match" and returned an irrelevant answer.</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro/main/images/euclidean-vs-cosine-comparison.png" alt="Euclidean vs Cosine comparison" style="display:block;margin:0 auto" />

<p>Compare that with <strong>cosine similarity</strong> (which measures the <em>angle</em> between two vectors, not their absolute distance): for the same question, the result is <code>0.0000</code> — a far clearer signal that there's genuinely no match at all.</p>
<p><strong>Why does this happen?</strong> Euclidean distance is sensitive to the length/density of text vectors, so two texts that are both "short and sparse" can end up looking close together in distance even when their content has nothing to do with each other. Cosine similarity doesn't have this problem, since it only measures the angle between vectors.</p>
<h2>The Fix I Applied</h2>
<ul>
<li><p>Switched the default metric from Euclidean distance to <strong>cosine similarity</strong>, using a minimum similarity threshold (0.15) instead of a distance threshold.</p>
</li>
<li><p>Made sure the user's question goes through the exact same preprocessing function as the training data — in the earlier version, the user's question was vectorized directly with no preprocessing at all, even though every FAQ entry had already been lowercased and stemmed.</p>
</li>
</ul>
<blockquote>
<p><strong>Honest note:</strong> that 0.15 threshold was picked manually based on these 4 test questions, not from systematic tuning across many phrasings. For "good fried rice recipe" specifically, the rejection was clean (similarity score of exactly zero) — but that's not a guarantee every off-topic question will be rejected this cleanly. A real production system would need testing against a much wider range of questions before relying on a single fixed threshold.</p>
</blockquote>
<h2>Try It Yourself</h2>
<pre><code class="language-bash">git clone https://github.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro.git
cd faq-search-engine-tfidf-sastrawi-arielshakaramiro
pip install -r requirements.txt
python train.py
python app.py
</code></pre>
<p>The full code, exploration notebook, and further audit details are available in <a href="https://github.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro">this GitHub repo</a>.</p>
<p>If you haven't read the TF-IDF fundamentals this project builds on, there's a previous note covering Bag of Words, TF-IDF, and Word Embedding from the ground up. Another way of putting TF-IDF to work — intent classification with Logistic Regression — is covered in a separate note.</p>
<blockquote>
<p><a href="https://shaka-ai.hashnode.dev/text-vectorizer-bow-tfidf-word-embedding-explained">https://shaka-ai.hashnode.dev/text-vectorizer-bow-tfidf-word-embedding-explained</a><br /><a href="https://shaka-ai.hashnode.dev/intent-classification-tfidf-logistic-regression">https://shaka-ai.hashnode.dev/intent-classification-tfidf-logistic-regression</a>  </p>
</blockquote>
<hr />
<p><em>Part of my AI Engineering study notes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Membangun Sistem Tanya-Jawab Sederhana dengan TF-IDF]]></title><description><![CDATA[Setelah paham cara kerja TF-IDF di catatan sebelumnya, saya coba pakai langsung untuk membangun sesuatu yang nyata: mesin pencari FAQ. User ketik pertanyaan bebas, sistem cari pertanyaan paling mirip ]]></description><link>https://shaka-ai.hashnode.dev/sistem-tanya-jawab-tfidf-sastrawi-python</link><guid isPermaLink="true">https://shaka-ai.hashnode.dev/sistem-tanya-jawab-tfidf-sastrawi-python</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[nlp]]></category><category><![CDATA[Python]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[chatbot]]></category><dc:creator><![CDATA[Muhammad Ariel Shakaramiro]]></dc:creator><pubDate>Sun, 06 Sep 2026 04:09:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8ef2e3923670c989379174/3d7a95db-0c65-46bf-bbf5-c7f69f2e0d3b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Setelah paham cara kerja TF-IDF di catatan sebelumnya, saya coba pakai langsung untuk membangun sesuatu yang nyata: mesin pencari FAQ. User ketik pertanyaan bebas, sistem cari pertanyaan paling mirip di database FAQ, lalu tampilkan jawabannya.</p>
<p>Kedengarannya sederhana. Tapi begitu mulai diuji dengan pertanyaan aneh-aneh, saya menemukan satu bug yang cukup penting soal cara mengukur "kemiripan" antar teks.</p>
<blockquote>
<p>Dataset FAQ yang dipakai di sini sepenuhnya fiktif — nama toko dan seluruh isi tanya-jawabnya cuma contoh untuk demonstrasi teknik, bukan data bisnis sungguhan.</p>
</blockquote>
<h2>Cara Kerjanya</h2>
<ol>
<li><p><strong>Preprocessing</strong> — setiap pertanyaan dan jawaban di-lowercase, tanda bacanya dihapus, lalu tiap kata di-<em>stem</em> ke bentuk dasarnya pakai <a href="https://github.com/sastrawi/sastrawi">Sastrawi</a> (library stemming Bahasa Indonesia).</p>
</li>
<li><p><strong>Vektorisasi</strong> — seluruh teks yang sudah diproses diubah jadi vektor TF-IDF.</p>
</li>
<li><p><strong>Pencarian</strong> — saat user mengetik pertanyaan, sistem menghitung skor kemiripan antara pertanyaan user dan setiap pertanyaan di database, lalu mengembalikan jawaban dari yang paling mirip.</p>
</li>
</ol>
<h2>Bug yang Ditemukan: Threshold Jarak yang Rapuh</h2>
<p>Versi awal sistem ini mengukur "kemiripan" pakai <strong>Euclidean distance</strong> (jarak garis lurus antar dua titik vektor) dengan ambang batas tetap: kalau jaraknya di bawah 1.0, dianggap cocok.</p>
<p>Saya coba lempar empat pertanyaan uji — tiga yang relevan, satu yang sengaja sama sekali tidak nyambung:</p>
<table>
<thead>
<tr>
<th>Pertanyaan Uji</th>
<th>Euclidean Distance</th>
<th>Cosine Similarity</th>
</tr>
</thead>
<tbody><tr>
<td>"berapa lama pengiriman barang"</td>
<td>0,8294</td>
<td>0,6560</td>
</tr>
<tr>
<td>"cara mengembalikan barang yang rusak"</td>
<td>0,9124</td>
<td>0,5838</td>
</tr>
<tr>
<td>"apakah ada diskon"</td>
<td>0,8952</td>
<td>0,5993</td>
</tr>
<tr>
<td><strong>"resep nasi goreng enak"</strong> <em>(di luar topik)</em></td>
<td><strong>1,0000</strong></td>
<td><strong>0,0000</strong></td>
</tr>
</tbody></table>
<p>Lihat baris terakhir. Pertanyaan soal resep nasi goreng — yang jelas tidak ada hubungannya dengan FAQ furnitur — menghasilkan Euclidean distance <strong>persis</strong> 1,0, tepat di angka ambang batasnya. Bukan "nyaris", tapi benar-benar pas di garis batas — kalau saja formula pembandingnya sedikit berbeda, sistem bisa saja menganggapnya "cocok" dan tetap memberi jawaban asal-asalan.</p>
<img src="https://raw.githubusercontent.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro/main/images/euclidean-vs-cosine-comparison.png" alt="Perbandingan Euclidean vs Cosine" style="display:block;margin:0 auto" />

<p>Bandingkan dengan <strong>cosine similarity</strong> (mengukur kemiripan <em>arah</em> dua vektor, bukan jarak absolutnya): untuk pertanyaan yang sama, hasilnya <code>0,0000</code> — sinyal yang jauh lebih tegas bahwa memang tidak ada kecocokan sama sekali.</p>
<p><strong>Kenapa ini terjadi?</strong> Euclidean distance sensitif terhadap panjang/kepadatan vektor teks, sehingga dua teks yang sama-sama "pendek dan jarang" bisa kelihatan berdekatan secara jarak, padahal isinya sama sekali tidak berhubungan. Cosine similarity tidak punya masalah ini karena yang diukur cuma sudut antar vektor.</p>
<h2>Perbaikan yang Saya Terapkan</h2>
<ul>
<li><p>Mengganti metrik default dari Euclidean distance ke <strong>cosine similarity</strong>, dengan ambang batas kemiripan minimum (0,15) alih-alih ambang batas jarak.</p>
</li>
<li><p>Memastikan pertanyaan dari user diproses dengan fungsi <em>preprocessing</em> yang <strong>sama persis</strong> seperti data training — di versi sebelumnya, pertanyaan user langsung divektorisasi tanpa preprocessing, padahal seluruh data FAQ sudah melalui proses lowercase + stemming.</p>
</li>
</ul>
<blockquote>
<p><strong>Catatan jujur:</strong> angka ambang batas 0,15 di atas saya pilih manual berdasarkan 4 pertanyaan uji ini, bukan hasil tuning sistematis di banyak variasi kalimat. Untuk contoh "resep nasi goreng enak" di atas, hasilnya memang tegas (skor kemiripannya nol) — tapi ini bukan jaminan semua pertanyaan di luar topik akan selalu ditolak sebaik ini. Sistem produksi sungguhan sebaiknya diuji dengan variasi pertanyaan yang jauh lebih banyak sebelum mengandalkan satu angka ambang batas tetap.</p>
</blockquote>
<h2>Coba Sendiri</h2>
<pre><code class="language-bash">git clone https://github.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro.git
cd faq-search-engine-tfidf-sastrawi-arielshakaramiro
pip install -r requirements.txt
python train.py
python app.py
</code></pre>
<p>Kode lengkap, notebook eksplorasi, dan detail audit lainnya ada di <a href="https://github.com/arielshakaramiro/faq-search-engine-tfidf-sastrawi-arielshakaramiro">repo GitHub ini</a>.</p>
<p>Kalau kamu belum baca dasar-dasar TF-IDF yang dipakai di proyek ini, ada catatan sebelumnya yang membahas Bag of Words, TF-IDF, dan Word Embedding dari awal. Pendekatan lain untuk memanfaatkan TF-IDF — klasifikasi intent dengan Logistic Regression — dibahas di catatan terpisah.</p>
<blockquote>
<p><a href="https://shaka-ai.hashnode.dev/text-vectorizer-bow-tfidf-word-embedding-dijelaskan">https://shaka-ai.hashnode.dev/text-vectorizer-bow-tfidf-word-embedding-dijelaskan</a><br /><a href="https://shaka-ai.hashnode.dev/klasifikasi-intent-tfidf-logistic-regression">https://shaka-ai.hashnode.dev/klasifikasi-intent-tfidf-logistic-regression</a>  </p>
</blockquote>
<hr />
<p><em>Bagian dari catatan belajar AI Engineering saya.</em></p>
]]></content:encoded></item></channel></rss>