Embedding yang kita rakit manual pada chapter sebelumnya hanya bisa dibuat untuk segelintir kata dengan fitur yang sudah dikenal manusia. Word2Vec — diperkenalkan Mikolov dkk. pada makalah “Efficient Estimation of Word Representations in Vector Space” (2013) — membalik prosesnya: beri model korpus besar, dan biarkan statistik kebersamaan kata menentukan seluruh angka vektornya. Hasilnya terbukti menangkap relasi makna seperti analogi raja - pria + wanita ≈ ratu. Chapter ini membedah cara kerjanya, melatih skip-gram sungguhan dengan PyTorch pada korpus mini, lalu memperkenalkan fastText yang merangkai vektor kata baru dari potongan subword — fitur yang cocok untuk bahasa berimbuhan seperti Indonesia.

Dua Arsitektur: Skip-gram dan CBOW #

Kedua arsitektur Word2Vec memanfaatkan jendela konteks (window) — kata-kata yang bertetangga dalam radius tertentu. Skip-gram bekerja dari pusat ke tepi: satu kata pusat dipakai untuk memprediksi kata-kata di sekitarnya. CBOW (continuous bag-of-words) bekerja sebaliknya: kata-kata konteks digabung untuk memprediksi kata pusat. Skip-gram unggul untuk kata jarang karena setiap kemunculannya dilatih sebagai pusat, sedangkan CBOW lebih cepat karena satu prediksi per posisi.

Diagram di atas menunjukkan alur satu pasangan pelatihan skip-gram. Kata pusat mengambil vektor dari tabel w_masuk; kata konteks mengambil vektor dari tabel w_keluar. Kedua tabel inilah seluruh parameter model — tidak ada layer tersembunyi lain.

Negative Sampling #

Tujuan asli skip-gram adalah klasifikasi softmax atas seluruh vocabulary: untuk satu kata pusat, naikkan peluang setiap kata konteks dan turunkan peluang V - 1 kata lainnya. Dengan V mencapai ratusan ribu, softmax penuh untuk setiap pasangan menjadi sangat mahal. Negative sampling menyederhanakannya menjadi beberapa keputusan biner: satu pasangan positif (pusat, konteks sejati) diberi label 1, dan K kata acak diberi label 0. Model hanya perlu membedakan pasangan yang benar-benar bertetangga dari pasangan yang dirangkai sembarangan — jauh lebih murah dan tetap cukup untuk membentuk ruang embedding yang bermakna.

Melatih Skip-gram dengan PyTorch #

Korpus mini berikut memuat tiga kelompok kata yang sengaja diberi konteks identik: kucing-anjing-tikus selalu ditemani “bermain”, “rumah”, “mengejar”, “bola”; ikan-katak-kepiting selalu ditemani “berenang”, “kolam”, “menyukai”, “air”. Skip-gram harus menemukan struktur tersebut sendiri:

python
"""Skip-gram sederhana dengan negative sampling di PyTorch, plus demo
subword n-gram ala fastText.

Korpus mini dirancang agar tiga kelompok kata (hewan darat, hewan air,
imbuhan makan/jalan) berbagi konteks yang sama. Skip-gram dilatih dengan
window 2, dimensi 16, 3 contoh negatif, Adam lr 0.1, 60 epoch penuh-batch.
Kemiripan kosinus sebelum vs sesudah training memperlihatkan pola nyata:
kata sekelompok menarik saling mendekat, kata lintas kelompok menjauh.
"""

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(42)

# ---------- 1. Korpus mini: 3 kelompok kata dengan konteks identik ----------
KORPUS = [
    "kucing bermain di rumah",
    "anjing bermain di rumah",
    "tikus bermain di rumah",
    "kucing mengejar bola",
    "anjing mengejar bola",
    "tikus mengejar bola",
    "ikan berenang di kolam",
    "katak berenang di kolam",
    "kepiting berenang di kolam",
    "ikan menyukai air",
    "katak menyukai air",
    "kepiting menyukai air",
]

kata_urut = sorted({kata for kalimat in KORPUS for kata in kalimat.split()})
kata_ke_id = {kata: i for i, kata in enumerate(kata_urut)}
V, D = len(kata_urut), 16
WINDOW, K_NEG, EPOCH = 2, 3, 60

# ---------- 2. Pasangan (pusat, konteks) dari jendela geser ----------
pasangan = []
for kalimat in KORPUS:
    ids = [kata_ke_id[kata] for kata in kalimat.split()]
    for i, id_pusat in enumerate(ids):
        for j in range(max(0, i - WINDOW), min(len(ids), i + WINDOW + 1)):
            if j != i:
                pasangan.append((id_pusat, ids[j]))

pusat = torch.tensor([p for p, c in pasangan])
konteks = torch.tensor([c for p, c in pasangan])
print("ukuran vocab V        :", V)
print("jumlah pasangan latih :", len(pasangan))
print("dimensi embedding D   :", D)
print()

# ---------- 3. Dua tabel embedding + negative sampling ----------
w_masuk = nn.Embedding(V, D)   # vektor kata pusat
w_keluar = nn.Embedding(V, D)  # vektor kata konteks
with torch.no_grad():
    w_masuk.weight.normal_(0.0, 0.05)
    w_keluar.weight.normal_(0.0, 0.05)
vektor_awal = w_masuk.weight.detach().clone()

optimizer = torch.optim.Adam([w_masuk.weight, w_keluar.weight], lr=0.1)
print(f"pelatihan skip-gram: window={WINDOW}, negatif={K_NEG}, "
      f"epoch={EPOCH}, full-batch")
for epoch in range(EPOCH):
    negatif = torch.randint(0, V, (len(pusat), K_NEG))
    v = w_masuk(pusat)                                  # (P, D)
    u_pos = w_keluar(konteks)                           # (P, D)
    u_neg = w_keluar(negatif)                           # (P, K, D)
    skor_pos = (v * u_pos).sum(dim=-1)                  # (P,)
    skor_neg = torch.bmm(u_neg, v.unsqueeze(-1)).squeeze(-1)  # (P, K)
    loss = -(F.logsigmoid(skor_pos).mean()
             + F.logsigmoid(-skor_neg).mean())
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if epoch == 0 or (epoch + 1) % 20 == 0:
        print(f"  epoch {epoch + 1:3d}  loss {loss.item():.4f}")
print()


# ---------- 4. Kemiripan kosinus sebelum vs sesudah training ----------
def kosinus(matriks, kata_a, kata_b):
    va = matriks[kata_ke_id[kata_a]]
    vb = matriks[kata_ke_id[kata_b]]
    return F.cosine_similarity(va, vb, dim=0).item()


uji = [
    ("kucing", "anjing"), ("kucing", "tikus"),
    ("ikan", "katak"), ("kepiting", "katak"),
    ("kucing", "ikan"), ("kucing", "kolam"), ("anjing", "air"),
]
print("=== Kemiripan kosinus (tabel w_masuk) ===")
print(f"  {'pasangan':22s}{'sebelum':>9s}{'sesudah':>9s}")
for kata_a, kata_b in uji:
    sebelum = kosinus(vektor_awal, kata_a, kata_b)
    sesudah = kosinus(w_masuk.weight, kata_a, kata_b)
    print(f"  {kata_a + ' - ' + kata_b:22s}{sebelum:9.3f}{sesudah:9.3f}")
print()

# ---------- 5. Subword n-gram ala fastText ----------
def trigram(kata):
    """Trigram karakter dengan penanda batas < dan >."""
    bertanda = "<" + kata + ">"
    return {bertanda[i:i + 3] for i in range(len(bertanda) - 2)}


print("=== Subword n-gram ala fastText (trigram berpenanda) ===")
for kata in ["makan", "memakan", "jalan", "berjalan"]:
    print(f"  {kata:9s} -> {sorted(trigram(kata))}")
bagi_makan = trigram("makan") & trigram("memakan")
bagi_jalan = trigram("jalan") & trigram("berjalan")
print("trigram 'makan' yang ada di 'memakan' :",
      len(bagi_makan), "dari", len(trigram("makan")))
print("trigram 'jalan' yang ada di 'berjalan':",
      len(bagi_jalan), "dari", len(trigram("jalan")))

Output yang Dihasilkan:

text
ukuran vocab V        : 15
jumlah pasangan latih : 96
dimensi embedding D   : 16

pelatihan skip-gram: window=2, negatif=3, epoch=60, full-batch
  epoch   1  loss 1.3882
  epoch  20  loss 0.8353
  epoch  40  loss 0.7277
  epoch  60  loss 0.7578

=== Kemiripan kosinus (tabel w_masuk) ===
  pasangan                sebelum  sesudah
  kucing - anjing           0.155    0.964
  kucing - tikus           -0.103    0.970
  ikan - katak             -0.221    0.963
  kepiting - katak         -0.003    0.962
  kucing - ikan             0.238    0.142
  kucing - kolam           -0.170    0.174
  anjing - air              0.074    0.173

=== Subword n-gram ala fastText (trigram berpenanda) ===
  makan     -> ['<ma', 'aka', 'an>', 'kan', 'mak']
  memakan   -> ['<me', 'aka', 'an>', 'ema', 'kan', 'mak', 'mem']
  jalan     -> ['<ja', 'ala', 'an>', 'jal', 'lan']
  berjalan  -> ['<be', 'ala', 'an>', 'ber', 'erj', 'jal', 'lan', 'rja']
trigram 'makan' yang ada di 'memakan' : 4 dari 5
trigram 'jalan' yang ada di 'berjalan': 4 dari 5

Mari kita telaah kode di atas baris per baris:

  1. Baris 33-35: vocabulary dibangun dari himpunan kata korpus — persis pola build_vocab pada chapter sebelumnya, tanpa token khusus karena demo ini tidak memerlukan padding.
  2. Baris 39-46: perulangan ganda membangun pasangan skip-gram. Untuk setiap posisi i, indeks j berjalan dari i - WINDOW sampai i + WINDOW (dipagari max/min agar tetap di dalam kalimat), dan posisi i sendiri dilewati.
  3. Baris 55-60: dua nn.Embedding dibuat lalu diinisialisasi kecil (normal_(0, 0.05)) agar semua vektor berangkat dari titik yang saling berdekatan. Salinan vektor_awal disimpan untuk perbandingan sebelum-sesudah.
  4. Baris 65-73: satu epoch memproses seluruh 96 pasangan sekaligus (full-batch). w_masuk(pusat) mengambil vektor pusat berbentuk (96, 16); torch.randint mengambil 3 kata acak per pasangan sebagai contoh negatif; torch.bmm menghitung perkalian titik negatif secara batch. Loss -logsigmoid adalah binary cross-entropy dua suku: label 1 untuk pasangan positif, label 0 untuk negatif.
  5. Baris 83-84 (kosinus): fungsi F.cosine_similarity menghitung kemiripan dua baris tabel embedding; ingat argumen dim=0 karena kedua vektor berupa tensor satu dimensi.
  6. Baris 103-105 (trigram): penanda < dan > melindungi informasi posisi awal/akhir kata sehingga “mak” di tengah kata berbeda maknanya dari awalan.

Hasilnya jujur dan mudah dibaca. Sebelum pelatihan, semua pasangan berkisar di sekitar nol (0,155; -0,221; 0,238) — angka acak tanpa pola. Setelah 60 epoch, empat pasangan sekelompok melonjak ke kisaran 0,96 sementara tiga pasangan lintas kelompok tertahan di 0,14-0,17. Skip-gram tidak pernah diberi tahu bahwa kucing dan anjing adalah hewan darat; model hanya melihat keduanya berbagi kata tetangga, lalu menaruh vektornya saling berdekatan — hipotesis distribusional bekerja dalam praktik. Catatan batas: korpus 12 kalimat jauh dari skala Word2Vec asli yang memakai miliaran pasangan, sehingga nilai 0,96 adalah pola pada korpus mainan, bukan kualitas embedding produksi.

fastText: Embedding dari Subword n-gram #

Keluaran bagian kelima memperlihatkan ide utama fastText (Bojanowski dkk., “Enriching Word Vectors with Subword Information”, 2017). Setiap kata dipecah menjadi n-gram karakter berpenanda batas: “makan” menghasilkan lima trigram, dan “memakan” memuat empat dari lima trigram tersebut. Vektor sebuah kata adalah gabungan vektor seluruh n-gramnya, sehingga kata yang belum pernah ditemui pun tetap punya vektor — dirangkai dari potongan yang sudah dikenal. Untuk bahasa Indonesia dengan imbuhan produktif (me-, ber-, -kan, -an), sifat ini mengubah “memakan” dari kata asing menjadi tetangga dekat “makan”.

AspekWord2VecfastText
Unit yang diembeddingkata utuhkata + subword n-gram (3-6 karakter)
Vektor kata OOVtidak tersediadirangkai dari n-gram yang dikenal
Ukuran berkas modellebih ringkaslebih besar (menyimpan semua n-gram)
Kecocokan bahasamorfologi sederhanaberimbuhan produktif seperti Indonesia

Skip-gram kita kini memiliki tabel embedding terlatih w_masuk berbentuk (15, 16) — setiap baris adalah vektor satu kata. Pertanyaan praktis terakhir: bagaimana model klasifikasi memakai tabel tersebut saat memproses batch token ID? Jawabannya adalah satu layer PyTorch yang bekerja sebagai tabel pencari — nn.Embedding, yang kita bedah pada chapter berikutnya.