Dengan deret id dari Bagian 1, kita kini merakit model yang membacanya. Arsitektur yang dipilih adalah yang paling sederhana namun menjadi pembanding wajib di praktik NLP: satu tabel embedding menerjemahkan id kata menjadi vektor, satu perangkum merata-ratakan vektor seluruh kata menjadi satu vektor dokumen, dan satu classifier linear menuntun vektor dokumen ke tiga kelas sentimen. Pola ini persis arsitektur demo chapter Text Classification ; proyek kita menaikkannya menjadi modul terpisah yang dapat diimpor dan diuji.

Bentuk Tensor di Sepanjang Model #

Perjalanan tensor melalui model dirangkum tabel berikut:

TahapOperasiBentuk keluaran
Masukanbatch token id(B, T)
Embedding lookupnn.Embedding(B, T, D)
Mean pooling bermaskrata-rata token non-<pad>(B, D)
Linear + ReLUnn.Linear(B, H)
Linear keluarannn.Linear(B, 3) logits

Dua angka konfigurasi menentukan ukuran model: dimensi embedding D = 32 dan dimensi tersembunyi kepala classifier H = 16. Dengan vocabulary 90 token, total parameter hanya ribuan — cukup untuk data 300 ulasan sekaligus cepat di CPU. Mean pooling sengaja dipilih karena tidak punya parameter sama sekali; seluruh kapasitas belajar berada di embedding dan classifier, persis pembahasan chapter Embedding Layer .

Modul Model #

Modul model.py berisi class ModelSentimen, fungsi bantu hitung_parameter, dan peluang_kelas:

python Modul model (src/model.py)
"""Model klasifikasi sentimen: embedding + mean pooling + classifier linear.

Arsitektur ModelSentimen:
    token ids (B, T)
      -> nn.Embedding (B, T, D)          tabel vektor kata
      -> mean pooling bermask  (B, D)    rata-rata token non-padding
      -> nn.Linear + ReLU       (B, H)   lapis tersembunyi kecil
      -> nn.Linear              (B, 3)   logits tiga kelas sentimen
"""

import torch
import torch.nn as nn

ID_PAD_GLOBAL = 0  # id token <pad> yang diabaikan mean pooling


class ModelSentimen(nn.Module):
    """Klasifikasi teks tiga kelas dari rata-rata embedding kata."""

    def __init__(self, jumlah_vocab, dim_embedding=32,
                 dim_tersembunyi=16, jumlah_kelas=3):
        super().__init__()
        self.embedding = nn.Embedding(
            jumlah_vocab, dim_embedding, padding_idx=ID_PAD_GLOBAL)
        self.classifier = nn.Sequential(
            nn.Linear(dim_embedding, dim_tersembunyi),
            nn.ReLU(),
            nn.Linear(dim_tersembunyi, jumlah_kelas),
        )

    def forward(self, token_ids):
        vektor = self.embedding(token_ids)              # (B, T, D)
        mask = (token_ids != ID_PAD_GLOBAL)             # (B, T)
        mask = mask.unsqueeze(-1).float()               # (B, T, 1)
        jumlah_kata = mask.sum(dim=1).clamp(min=1.0)    # (B, 1)
        rata2 = (vektor * mask).sum(dim=1) / jumlah_kata  # (B, D)
        return self.classifier(rata2)                   # (B, jumlah_kelas)


def hitung_parameter(model):
    """Hitung total parameter yang dapat dipelajari."""
    return sum(p.numel() for p in model.parameters())


def peluang_kelas(logits):
    """Ubah logits menjadi peluang kelas lewat softmax."""
    return torch.softmax(logits, dim=-1)


if __name__ == "__main__":
    torch.manual_seed(42)
    model = ModelSentimen(jumlah_vocab=90)

    ids = torch.tensor([
        [3, 15, 24, 0, 0, 0, 0],   # 3 kata asli + 4 padding
        [3, 15, 24, 8, 9, 0, 0],   # 5 kata asli + 2 padding
    ])
    with torch.no_grad():
        logits = model(ids)
        peluang = peluang_kelas(logits)

    print(f"shape masukan  : {tuple(ids.shape)}")
    print(f"shape logits   : {tuple(logits.shape)}")
    print(f"total parameter: {hitung_parameter(model)}")
    print("peluang kelas  :")
    for baris in peluang:
        print("  ", [round(v, 3) for v in baris.tolist()])

Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji (CPU, PyTorch 2.14):

Output yang Dihasilkan:

text
shape masukan  : (2, 7)
shape logits   : (2, 3)
total parameter: 3459
peluang kelas  :
   [0.319, 0.395, 0.286]
   [0.323, 0.363, 0.314]

Mari kita telaah kode di atas baris per baris:

  1. Baris 20-28 (__init__): nn.Embedding(jumlah_vocab, 32, padding_idx=0) membuat tabel 90 × 32. Argumen padding_idx=0 membekukan baris <pad> di nol — posisi padding tidak pernah berkontribusi dan tidak pernah diperbarui optimizer.
  2. Baris 31-37 (forward): mask (token_ids != 0) menandai token asli; rata-rata dihitung dengan menjumlahkan vektor * mask lalu membagi jumlah kata asli, sehingga panjang kalimat tidak menggeser skala vektor dokumen. clamp(min=1.0) menjaga pembagian tetap aman untuk teks yang seluruhnya padding.
  3. Baris 25-27 (kepala classifier): nn.Sequential(Linear(32, 16), ReLU(), Linear(16, 3)) menghasilkan logits tiga kelas; urutan NAMA_KELAS pada latih.py nanti menentukan makna tiap kolom.
  4. Baris 40-53 (fungsi bantu): hitung_parameter menjumlahkan p.numel() seluruh parameter — angka 3.459 pada keluaran berasal dari 90×32 embedding + (32×16+16) + (16×3+3).

Perhatikan keluaran demo: dua teks dengan jumlah kata berbeda (3 dan 5) sama-sama menghasilkan logits berbentuk (2, 3). Inilah jasa mean pooling — panjang teks variabel diringkas menjadi satu vektor berdimensi tetap sebelum masuk classifier.

Uji Arsitektur #

Test model memeriksa empat hal: bentuk logits, jumlah parameter yang cocok dengan hitungan manual, ketidakpekaan terhadap padding, dan mengalirnya gradien:

python Test model (tests/test_model.py)
"""Test untuk modul model (arsitektur, shape, padding, gradien).

Jalankan: python3 test_model.py
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

import torch  # noqa: E402

from model import ModelSentimen, hitung_parameter, peluang_kelas  # noqa: E402


def buat_model():
    torch.manual_seed(42)
    return ModelSentimen(jumlah_vocab=90, dim_embedding=32,
                         dim_tersembunyi=16, jumlah_kelas=3)


def test_shape_logits():
    model = buat_model()
    ids = torch.randint(2, 90, (4, 7))  # batch 4, panjang 7
    logits = model(ids)
    assert logits.shape == (4, 3)
    assert torch.isfinite(logits).all()


def test_jumlah_parameter():
    model = buat_model()
    # embedding 90*32 + linear (32*16+16) + linear (16*3+3)
    assert hitung_parameter(model) == 90 * 32 + (32 * 16 + 16) + (16 * 3 + 3)


def test_padding_tidak_mengubah_logits():
    model = buat_model().eval()
    pendek = torch.tensor([[3, 15, 24, 0, 0, 0, 0]])
    lebih_pad = torch.tensor([[3, 15, 24, 0, 0, 0, 0]])
    with torch.no_grad():
        a = model(pendek)
        b = model(lebih_pad)
    assert torch.allclose(a, b)


def test_gradien_mengalir():
    model = buat_model()
    ids = torch.tensor([[3, 15, 24, 0, 0, 0, 0]])
    target = torch.tensor([2])
    logits = model(ids)
    loss = torch.nn.functional.cross_entropy(logits, target)
    loss.backward()
    assert model.embedding.weight.grad is not None
    assert model.classifier[0].weight.grad is not None


def test_peluang_kelas():
    logits = torch.tensor([[2.0, 1.0, 0.5]])
    p = peluang_kelas(logits)
    assert abs(p.sum().item() - 1.0) < 1e-6
    assert p.argmax().item() == 0


if __name__ == "__main__":
    test_shape_logits()
    test_jumlah_parameter()
    test_padding_tidak_mengubah_logits()
    test_gradien_mengalir()
    test_peluang_kelas()
    print("SEMUA TEST LULUS")

Output yang Dihasilkan:

text
SEMUA TEST LULUS

Arsitektur siap namun bobotnya masih acak — peluang tiga kelas pada keluaran demo berdekatan karena model belum belajar apa pun. Bagian berikutnya melatih bobot tersebut dengan loop PyTorch lengkap dan mengukur akurasinya.