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:
"""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:
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 5Mari kita telaah kode di atas baris per baris:
- Baris 33-35: vocabulary dibangun dari himpunan kata korpus — persis pola
build_vocabpada chapter sebelumnya, tanpa token khusus karena demo ini tidak memerlukan padding. - Baris 39-46: perulangan ganda membangun pasangan skip-gram. Untuk setiap posisi
i, indeksjberjalan darii - WINDOWsampaii + WINDOW(dipagarimax/minagar tetap di dalam kalimat), dan posisiisendiri dilewati. - Baris 55-60: dua
nn.Embeddingdibuat lalu diinisialisasi kecil (normal_(0, 0.05)) agar semua vektor berangkat dari titik yang saling berdekatan. Salinanvektor_awaldisimpan untuk perbandingan sebelum-sesudah. - Baris 65-73: satu epoch memproses seluruh 96 pasangan sekaligus (full-batch).
w_masuk(pusat)mengambil vektor pusat berbentuk(96, 16);torch.randintmengambil 3 kata acak per pasangan sebagai contoh negatif;torch.bmmmenghitung perkalian titik negatif secara batch. Loss-logsigmoidadalah binary cross-entropy dua suku: label 1 untuk pasangan positif, label 0 untuk negatif. - Baris 83-84 (
kosinus): fungsi F.cosine_similarity menghitung kemiripan dua baris tabel embedding; ingat argumendim=0karena kedua vektor berupa tensor satu dimensi. - 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”.
| Aspek | Word2Vec | fastText |
|---|---|---|
| Unit yang diembedding | kata utuh | kata + subword n-gram (3-6 karakter) |
| Vektor kata OOV | tidak tersedia | dirangkai dari n-gram yang dikenal |
| Ukuran berkas model | lebih ringkas | lebih besar (menyimpan semua n-gram) |
| Kecocokan bahasa | morfologi sederhana | berimbuhan 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.