Chapter Embedding Layer menuntaskan cara mengubah satu kata menjadi vektor yang bisa dipelajari. Text classification naik satu tingkat: satu keseluruhan teks — ulasan, berita, email, tiket keluhan — harus dipetakan ke satu label. Tugas ini menjadi dasar filter spam, deteksi bahasa, kategorisasi berita, dan triase tiket layanan pelanggan. Tantangan teknisnya khas NLP: panjang teks bervariasi, sedangkan classifier linear mengharapkan vektor berdimensi tetap. Chapter ini merangkai seluruh rantai transformasi dari enam chapter pertama menjadi satu model PyTorch utuh yang dilatih dan diuji nyata.

Pipeline: Dari Teks ke Label #

Alur kerjanya berupa rantai lima tahap yang masing-masing sudah kita bahas tersendiri:

Tahap pertama dan kedua mengikuti chapter Tokenization dan Vocabulary dan Token ID : teks dipecah menjadi token lalu dipetakan ke id integer. Tahap ketiga mengambil embedding dari tabel yang dilatih — persis mekanisme chapter Embedding Layer . Tahap keempat menjadi pemecah masalah panjang variabel: encoder merangkum T vektor kata menjadi satu vektor dokumen berdimensi tetap D, sebelum classifier linear dari section Dasar Neural Network menghasilkan skor kelas.

Arsitektur Encoder yang Umum #

Pilihan encoder menentukan batas kemampuan model. Tabel berikut merangkum lima keluarga arsitektur yang paling sering dipakai:

EncoderCara merangkumKekuatanBatasan
Bag of Wordshitung kemunculan katapaling sederhana, cepaturutan kata terbuang
Mean poolingrata-rata embedding katabaseline kuat, minim parameterurutan kata terbuang
RNN / LSTMbaca berurutan dengan hidden statemenangkap urutanlambat pada teks panjang, gradien menyusut
CNN teksfilter 1D geser atas n-grammenangkap frasa lokalkonteks jarak jauh terbatas
Transformer encoderself-attention antar semua tokenkonteks panjang, standar saat inibutuh data dan komputasi besar

Model demo chapter ini memilih mean pooling karena paling mudah dijelaskan dan sering menjadi pembanding pertama sebelum arsitektur yang lebih berat. RNN dibahas tuntas di section Sequence Modeling , sementara transformer menanti di section Attention dan Transformer .

Demo: Klasifikasi 200 Ulasan Mini dengan PyTorch #

Berkas klasifikasi_teks.py membangun 200 ulasan sintetis dari pola templat — 100 positif dengan kata kunci seperti “bagus” dan “senang”, 100 negatif dengan “buruk” dan “kecewa” — lalu melatih model Embedding(32) -> mean pooling -> Linear(32 -> 2):

python
"""Klasifikasi teks end-to-end dengan PyTorch.

Pipeline: teks -> token -> vocabulary/token id -> embedding ->
mean pooling -> linear classifier. Dataset mini buatan 200 ulasan toko
daring (100 positif + 100 negatif) dari pola templat dengan kata kunci
jelas. Dibagi 160 latih / 40 uji. Model: Embedding(dim 32) ->
mean pooling -> Linear(32 -> 2), Adam full-batch 80 epoch.
"""

import random

import torch
import torch.nn as nn

torch.manual_seed(42)
acak = random.Random(42)

# ---------- 1. Dataset mini buatan: 100 positif + 100 negatif ----------
KATA_POSITIF = ["bagus", "mantap", "memuaskan", "nyaman", "enak", "awet"]
KATA_POSITIF_SUBJEK = ["senang", "puas", "terkesan"]
KATA_NEGATIF = ["buruk", "mengecewakan", "rusak", "lambat", "parah", "menyebalkan"]
KATA_NEGATIF_SUBJEK = ["kecewa", "kesal", "menyesal"]

TEMPLAT_POSITIF = [
    "produk ini {kata} dan tahan lama",
    "pengiriman sangat {kata}",
    "kualitasnya {kata} sekali",
    "pelayanan tokonya {kata}",
    "saya {kata_subjek} dengan produk ini",
    "harga sebanding dengan kualitas yang {kata}",
    "belanja di toko ini {kata} sekali",
]
TEMPLAT_NEGATIF = [
    "produk ini {kata} dan cepat rusak",
    "pengiriman sangat {kata}",
    "kualitasnya {kata} sekali",
    "pelayanan tokonya {kata}",
    "saya {kata_subjek} dengan produk ini",
    "harga tidak sebanding dengan kualitas yang {kata}",
    "paket tiba dalam kondisi {kata}",
]


def buat_dataset(jumlah_per_kelas):
    data = []
    for _ in range(jumlah_per_kelas):
        kata = acak.choice(KATA_POSITIF)
        kata_subjek = acak.choice(KATA_POSITIF_SUBJEK)
        teks = acak.choice(TEMPLAT_POSITIF).format(kata=kata, kata_subjek=kata_subjek)
        data.append((teks, 1))  # label 1 = positif
    for _ in range(jumlah_per_kelas):
        kata = acak.choice(KATA_NEGATIF)
        kata_subjek = acak.choice(KATA_NEGATIF_SUBJEK)
        teks = acak.choice(TEMPLAT_NEGATIF).format(kata=kata, kata_subjek=kata_subjek)
        data.append((teks, 0))  # label 0 = negatif
    acak.shuffle(data)
    return data


data = buat_dataset(100)
latih, uji = data[:160], data[160:]

# ---------- 2. Vocabulary: dibangun dari data latih saja ----------
def tokenisasi(teks):
    return teks.lower().split()


frekuensi = {}
for teks, _ in latih:
    for token in tokenisasi(teks):
        frekuensi[token] = frekuensi.get(token, 0) + 1

vocab = {"<pad>": 0, "<unk>": 1}
for token, jumlah in sorted(frekuensi.items(), key=lambda x: (-x[1], x[0])):
    if jumlah >= 1:
        vocab[token] = len(vocab)

PANJANG_MAKS = max(len(tokenisasi(teks)) for teks, _ in latih)


def ubah_ke_ids(teks):
    ids = [vocab.get(t, vocab["<unk>"]) for t in tokenisasi(teks)]
    return ids[:PANJANG_MAKS] + [0] * (PANJANG_MAKS - len(ids))


print(f"contoh latih {len(latih)} | contoh uji {len(uji)} | "
      f"ukuran vocab {len(vocab)} | panjang maks {PANJANG_MAKS}")
print("contoh positif:", next(t for t, l in latih if l == 1))
print("contoh negatif:", next(t for t, l in latih if l == 0))

X_latih = torch.tensor([ubah_ke_ids(t) for t, _ in latih])
y_latih = torch.tensor([l for _, l in latih])
X_uji = torch.tensor([ubah_ke_ids(t) for t, _ in uji])
y_uji = torch.tensor([l for _, l in uji])


# ---------- 3. Model: embedding -> mean pooling -> linear ----------
class KlasifikasiTeks(nn.Module):
    def __init__(self, jumlah_vocab, dim_embedding=32, jumlah_kelas=2):
        super().__init__()
        self.embedding = nn.Embedding(jumlah_vocab, dim_embedding, padding_idx=0)
        self.classifier = nn.Linear(dim_embedding, jumlah_kelas)

    def forward(self, token_ids):
        vektor = self.embedding(token_ids)            # (B, T, D)
        mask = (token_ids != 0).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, 2) logits


model = KlasifikasiTeks(len(vocab))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)

# ---------- 4. Training loop full-batch ----------
JUMLAH_EPOCH = 80
for epoch in range(1, JUMLAH_EPOCH + 1):
    model.train()
    optimizer.zero_grad()
    logits = model(X_latih)
    loss = criterion(logits, y_latih)
    loss.backward()
    optimizer.step()

    if epoch == 1 or epoch % 20 == 0:
        model.eval()
        with torch.no_grad():
            benar = (model(X_uji).argmax(dim=1) == y_uji).sum().item()
        print(f"epoch {epoch:3d} | loss latih {loss.item():.4f} | "
              f"akurasi uji {benar / len(uji):.3f}")

# ---------- 5. Evaluasi akhir + prediksi kalimat baru ----------
model.eval()
with torch.no_grad():
    akurasi_latih = (model(X_latih).argmax(dim=1) == y_latih).float().mean().item()
    akurasi_uji = (model(X_uji).argmax(dim=1) == y_uji).float().mean().item()
print(f"\nakurasi akhir: latih {akurasi_latih:.3f} | uji {akurasi_uji:.3f}")

NAMA_KELAS = ["negatif", "positif"]
kalimat_baru = [
    "kualitasnya bagus sekali dan tahan lama",
    "pengiriman lambat dan paket rusak",
    "pelayanan tokonya menyebalkan sekali",
    "saya puas dengan produk ini",
]
print("\nprediksi kalimat baru:")
for teks in kalimat_baru:
    with torch.no_grad():
        peluang = torch.softmax(model(torch.tensor([ubah_ke_ids(teks)])), dim=1)[0]
    prediksi = peluang.argmax().item()
    print(f"  '{teks}'")
    print(f"    -> {NAMA_KELAS[prediksi]} "
          f"(peluang negatif {peluang[0]:.3f} | positif {peluang[1]:.3f})")

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

Output yang Dihasilkan:

text
contoh latih 160 | contoh uji 40 | ukuran vocab 45 | panjang maks 7
contoh positif: pengiriman sangat memuaskan
contoh negatif: harga tidak sebanding dengan kualitas yang mengecewakan
epoch   1 | loss latih 0.7495 | akurasi uji 0.650
epoch  20 | loss latih 0.0011 | akurasi uji 1.000
epoch  40 | loss latih 0.0000 | akurasi uji 1.000
epoch  60 | loss latih 0.0000 | akurasi uji 1.000
epoch  80 | loss latih 0.0000 | akurasi uji 1.000

akurasi akhir: latih 1.000 | uji 1.000

prediksi kalimat baru:
  'kualitasnya bagus sekali dan tahan lama'
    -> positif (peluang negatif 0.000 | positif 1.000)
  'pengiriman lambat dan paket rusak'
    -> negatif (peluang negatif 1.000 | positif 0.000)
  'pelayanan tokonya menyebalkan sekali'
    -> negatif (peluang negatif 1.000 | positif 0.000)
  'saya puas dengan produk ini'
    -> positif (peluang negatif 0.000 | positif 1.000)

Mari kita telaah kode di atas baris per baris:

  1. Baris 15-16 (seed): torch.manual_seed(42) mengunci inisialisasi bobot dan random.Random(42) mengunci pemilihan templat — dataset dan model identik pada setiap eksekusi.
  2. Baris 19-61 (dataset templat): dua kosakata perasaan menjadi sinyal pembeda; 100 ulasan per kelas dibangkitkan dari tujuh templat lalu diacak, 160 latih dan 40 uji.
  3. Baris 63-76 (vocabulary): kamus dibangun hanya dari token data latih, diawali <pad> ber-id 0 dan <unk> ber-id 1 — data uji tidak boleh memperluas kamus agar evaluasi jujur.
  4. Baris 78-83 (encoding): setiap teks dipotong atau dipanjangkan dengan id 0 hingga PANJANG_MAKS = 7 token sehingga batch berbentuk tensor (B, 7).
  5. Baris 98-109 (model): forward mengambil embedding (B, T, D), membangun mask token asli, lalu merata-ratakan hanya token non-padding — tanpa mask, kalimat pendek tersedot ke arah rata-rata padding.
  6. Baris 112-114 (komponen latih): CrossEntropyLoss menerima logits mentah; Adam dengan learning rate 0.05 cukup untuk model sekecil ini.
  7. Baris 117-131 (training loop): pola zero_grad -> forward -> loss -> backward -> step dari chapter Training Loop , dijalankan full-batch dengan log tiap 20 epoch.
  8. Baris 133-154 (evaluasi dan inferensi): model berpindah ke mode eval, akurasi akhir dihitung, lalu empat kalimat baru diprediksi lengkap dengan peluang softmax.

Membaca Hasil #

Loss latih turun 0,7495 menjadi 0,0000 dan akurasi uji menyentuh 1,000 — seluruh 40 ulasan held-out terklasifikasi benar, begitu pula keempat kalimat baru. Hasil setinggi ini wajar untuk data buatan berkata kunci jelas: satu kata “bagus” atau “buruk” hampir selalu menentukan label, sehingga rata-rata embedding saja memisahkan kedua kelas.

Mean pooling sengaja membuang urutan kata — keputusan yang murah tetapi berakibat: frasa “tidak bagus” dan “bagus, tidak masalah” menghasilkan rata-rata yang mirip. Rujukan API untuk nn.Embedding tersedia di docs.pytorch.org/docs/stable/generated/torch.nn.Embedding.html . Chapter berikutnya menghadapi kelemahan ini langsung: studi kasus sentimen tiga kelas di mana negasi menggagalkan model kata-per-kata.