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:
| Tahap | Operasi | Bentuk keluaran |
|---|---|---|
| Masukan | batch token id | (B, T) |
| Embedding lookup | nn.Embedding | (B, T, D) |
| Mean pooling bermask | rata-rata token non-<pad> | (B, D) |
| Linear + ReLU | nn.Linear | (B, H) |
| Linear keluaran | nn.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:
"""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:
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:
- Baris 20-28 (
__init__):nn.Embedding(jumlah_vocab, 32, padding_idx=0)membuat tabel 90 × 32. Argumenpadding_idx=0membekukan baris<pad>di nol — posisi padding tidak pernah berkontribusi dan tidak pernah diperbarui optimizer. - Baris 31-37 (
forward): mask(token_ids != 0)menandai token asli; rata-rata dihitung dengan menjumlahkanvektor * masklalu 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. - Baris 25-27 (kepala classifier):
nn.Sequential(Linear(32, 16), ReLU(), Linear(16, 3))menghasilkan logits tiga kelas; urutanNAMA_KELASpadalatih.pynanti menentukan makna tiap kolom. - Baris 40-53 (fungsi bantu):
hitung_parametermenjumlahkanp.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:
"""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:
SEMUA TEST LULUSArsitektur 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.