Klasifikasi memetakan sekuens ke satu label, tetapi banyak tugas menuntut keluaran yang juga berupa sekuens: terjemahan mengubah kalimat bahasa sumber menjadi kalimat bahasa target, peringkasan mengubah paragraf panjang menjadi paragraf pendek, dan peramalan multi-langkah mengubah riwayat deret menjadi deret masa depan. Panjang masukan dan keluaran bisa berbeda, sehingga pola feedforward tidak lagi memadai. Kerangka sequence-to-sequence (seq2seq) dari Sutskever et al. (2014) memecahnya menjadi dua model rekuren: encoder membaca masukan sampai habis dan merangkumnya, decoder membangkitkan keluaran token demi token dari rangkuman itu. Chapter ini membangun versi mininya di PyTorch pada tugas terjemahan sintetis yang bisa dilatih sampai tuntas dalam hitungan detik.
Encoder, Context Vector, Decoder #
Encoder adalah LSTM yang membaca seluruh sekuens sumber; hidden state terakhirnya dijadikan context vector — kompresi seluruh makna masukan dalam satu vektor berdimensi H. Decoder adalah LSTM lain yang memulai dari context vector sebagai hidden awal, menerima token pembuka BOS, lalu membangkitkan token keluaran satu per satu sampai memutuskan berhenti dengan token EOS. Struktur ini membebaskan panjang keluaran dari panjang masukan:
Pada pelatihan, decoder menerima token target yang asli sebagai masukan langkah berikutnya — teknik teacher forcing yang dibahas penuh pada chapter sesudahnya. Pada inferensi, tidak ada token asli yang tersedia, sehingga keluaran argmax dari langkah sebelumnya diumpankan kembali.
Encoder-Decoder Mini pada Tugas Salin-Geser #
Tugas uji kita adalah terjemahan sintetis: sekuens token [a, b, c, d] harus diterjemahkan menjadi [b, c, d, EOS] — salin isinya sambil geser satu langkah ke kiri, tutup dengan token akhir. Digit 0-3 pada empat posisi menghasilkan tepat 256 sekuens berbeda; 192 dipakai untuk melatih dan 64 sisanya disimpan sebagai data uji yang belum pernah dilihat model, sehingga keberhasilan di data uji membuktikan model mempelajari aturan gesernya, bukan menghafal:
"""Sequence-to-sequence mini: encoder-decoder pada tugas salin-geser.
Tugas: sekuens token [3, 1, 2, 0] harus diterjemahkan menjadi versi
bergeser satu langkah ke kiri [1, 2, 0, EOS]. Encoder LSTM merangkum
masukan menjadi context vector; decoder LSTM membangkitkan keluaran token
per token dengan teacher forcing. Model dilatih pada sebagian sekuens
sintetis lalu diuji pada sekuens yang belum pernah dilihat.
"""
import numpy as np
import torch
import torch.nn as nn
torch.manual_seed(42)
np.random.seed(42)
DIGIT, PANJANG, EMBED, HIDDEN = 4, 4, 16, 32
VOCAB = DIGIT + 2 # token digit + BOS + EOS
BOS, EOS = 4, 5
N_LATIH, N_UJI = 192, 64
# Seluruh kemungkinan sekuens (4^4 = 256), diacak lalu dibagi dua
semua = torch.cartesian_prod(*[torch.arange(DIGIT)] * PANJANG)
semua = semua[torch.randperm(semua.size(0))]
src_latih, src_uji = semua[:N_LATIH], semua[N_LATIH:]
tgt_latih = torch.cat([src_latih[:, 1:], torch.full((N_LATIH, 1), EOS)], dim=1)
tgt_uji = torch.cat([src_uji[:, 1:], torch.full((N_UJI, 1), EOS)], dim=1)
# Masukan decoder: BOS + target tanpa token terakhir (teacher forcing)
dec_in_latih = torch.cat(
[torch.full((N_LATIH, 1), BOS), tgt_latih[:, :-1]], dim=1
)
class EncoderDecoder(nn.Module):
def __init__(self):
super().__init__()
self.emb = nn.Embedding(VOCAB, EMBED)
self.encoder = nn.LSTM(EMBED, HIDDEN, batch_first=True)
self.decoder = nn.LSTM(EMBED, HIDDEN, batch_first=True)
self.out = nn.Linear(HIDDEN, VOCAB)
def forward(self, src, dec_in):
_, konteks = self.encoder(self.emb(src)) # (h_n, c_n) akhir
h, _ = self.decoder(self.emb(dec_in), konteks)
return self.out(h) # (N, T, VOCAB)
model = EncoderDecoder()
optim = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
print("Tugas salin-geser: target = input geser kiri 1 langkah + EOS")
print(f"Sekuens mungkin {4**PANJANG} | latih {N_LATIH} | uji {N_UJI} "
f"(tidak tumpang tindih)")
print()
EPOCH = 300
for epoch in range(1, EPOCH + 1):
logits = model(src_latih, dec_in_latih)
loss = loss_fn(logits.reshape(-1, VOCAB), tgt_latih.reshape(-1))
optim.zero_grad()
loss.backward()
optim.step()
if epoch in (1, 60, 120, 180, 240, 300):
print(f"epoch {epoch:3d} loss = {loss.item():.4f}")
def prediksi(model, src):
"""Bangkitkan token satu per satu; masukan decoder = prediksi sendiri."""
n = src.size(0)
_, konteks = model.encoder(model.emb(src))
token = torch.full((n, 1), BOS)
h = konteks
hasil = []
for _ in range(PANJANG):
langkah, h = model.decoder(model.emb(token), h)
token = model.out(langkah[:, -1]).argmax(dim=1, keepdim=True)
hasil.append(token)
return torch.cat(hasil, dim=1)
def akurasi_cocok(model, src, tgt):
with torch.no_grad():
pred = prediksi(model, src)
return (pred == tgt).all(dim=1).float().mean().item()
print()
print(f"Akurasi sekuens penuh (bangkitan mandiri): latih "
f"{akurasi_cocok(model, src_latih, tgt_latih):.3f} | uji "
f"{akurasi_cocok(model, src_uji, tgt_uji):.3f}")
with torch.no_grad():
contoh = prediksi(model, src_uji[:3])
print()
for i in range(3):
print(f" input {src_uji[i].tolist()}")
print(f" target {tgt_uji[i].tolist()}")
print(f" predik {contoh[i].tolist()}")Output yang Dihasilkan:
Tugas salin-geser: target = input geser kiri 1 langkah + EOS
Sekuens mungkin 256 | latih 192 | uji 64 (tidak tumpang tindih)
epoch 1 loss = 1.7889
epoch 60 loss = 0.0283
epoch 120 loss = 0.0037
epoch 180 loss = 0.0019
epoch 240 loss = 0.0012
epoch 300 loss = 0.0008
Akurasi sekuens penuh (bangkitan mandiri): latih 1.000 | uji 1.000
input [1, 0, 2, 0]
target [0, 2, 0, 5]
predik [0, 2, 0, 5]
input [1, 1, 3, 3]
target [1, 3, 3, 5]
predik [1, 3, 3, 5]
input [0, 1, 0, 1]
target [1, 0, 1, 5]
predik [1, 0, 1, 5]Mari kita telaah kode di atas baris per baris:
- Baris 17-20: ukuran ditetapkan — 4 jenis digit, sekuens 4 token, embedding 16 dimensi, hidden 32, ditambah dua token khusus BOS (pembuka) dan EOS (penutup) sehingga vocabulary berisi 6 token.
- Baris 22-25:
torch.cartesian_prodmenghasilkan seluruh 256 sekuens mungkin,randpermmengacaknya dengan seed tetap, lalu dibagi 192 latih dan 64 uji tanpa tumpang tindih. - Baris 27-33: target dibangun dengan menggeser sumber satu langkah ke kiri (
src[:, 1:]) dan menempel EOS di ujung; masukan decoder adalah BOS diikuti target tanpa token terakhir — pola standar teacher forcing. - Baris 36-47: kelas
EncoderDecoder. Baris 45 menjalankan encoder dan menyimpan hidden state terakhirnya sebagaikonteks; baris 46 menjalankan decoder dengankontekssebagai state awal; baris 47 memproyeksikan setiap langkah decoder ke vocabulary. - Baris 59-67: training loop penuh-batch dengan Adam dan cross-entropy; loss dicetak pada enam titik epoch. Loss anjlok dari 1,79 ke 0,0008 dalam 300 epoch.
- Baris 70-81: fungsi
prediksimembangkitkan tanpa melihat target: decoder menerima prediksi argmax-nya sendiri sebagai token berikutnya, tepat seperti kondisi inferensi sungguhan. - Baris 84-93: akurasi dihitung atas sekuens penuh — prediksi harus cocok token demi token. Hasilnya 1,000 pada data latih dan 1,000 pada data uji: seluruh 64 sekuens uji diterjemahkan benar meskipun tidak pernah dilihat saat pelatihan.
- Baris 95-101: tiga contoh nyata dari data uji — setiap prediksi identik dengan target, termasuk posisi EOS (angka 5).
Perhatikan pembagian peran pada hasil: encoder berhasil merangkum keempat token sumber ke dalam context vector 32 dimensi, dan decoder berhasil mengekstrak urutannya kembali plus memutuskan kapan berhenti. Pola yang sama — dengan vocabulary jutaan token dan hidden ribuan dimensi — menjadi kerangka terjemahan mesin neuronal pada era sebelum transformer.
Kekuatan demo di atas bergantung pada satu bantuan besar yang kita berikan kepada decoder selama pelatihan: token asli selalu tersedia sebagai masukan langkah berikutnya. Bagaimana jika bantuan itu dicabut? Chapter penutup section ini membahas teacher forcing beserta tagihannya yang muncul saat inferensi.