Pretraining membeli cakupan lewat skala, tetapi mesin yang menjalankan semuanya tetap satu mekanisme: next-token prediction. Chapter ini membedah mesin itu sampai ke bagiannya: bagaimana keluaran model (logits) menjadi distribusi probabilitas lewat softmax, bagaimana pelatihan mengukur kesalahan lewat cross-entropy di setiap posisi sekaligus, dan bagaimana inference membangkitkan teks token demi token lewat loop autoregressive. Kita implementasikan decoder mini satu blok — miniatur arsitektur LLM nyata dengan causal mask — melatihnya pada korpus yang sama dengan chapter Pengenalan LLM , lalu mengamati angkanya bekerja.
Dari Logits ke Distribusi #
Bagian keluaran sebuah decoder bukanlah karakter, melainkan vektor logits: satu skor bilangan real untuk tiap entri vocabulary. Softmax mengubah vektor itu menjadi distribusi probabilitas yang berjumlah satu:
p(token ke-i) = exp(logit_i) / jumlah_semua exp(logit)Skor yang besar mendominasi distribusi; selisih beberapa poin logits sudah menggeser probabilitas tajam. Saat training, token yang benar ingin diberi probabilitas setinggi mungkin; saat inference, token berikutnya diambil dari distribusi tersebut. Mekanisme ini identik dengan yang sudah kita hitung manual pada chapter Attention — kini bedahnya pada sisi language model.
Pelatihan: Cross-Entropy dan Teacher Forcing #
Data pelatihan dibentuk dengan jendela geser: setiap jendela berisi konteks 24 karakter sebagai masukan dan versi tergeser satu posisinya sebagai target. Setiap posisi dalam jendela memprediksi token tepat setelah posisi tersebut, sehingga satu jendela menghasilkan 24 prediksi sekaligus:
Teknik memakai token asli sebagai konteks selama pelatihan — bukan prediksi model sendiri — bernama teacher forcing, prinsip yang sama dengan yang kita amati pada RNN section Sequence Modeling . Loss-nya cross-entropy rata-rata atas seluruh posisi dan jendela.
Demo: Decoder Mini Satu Blok #
Arsitektur kita: token embedding dan positional embedding berdimensi 32, satu blok decoder (dua head attention, FFN 64, dua LayerNorm dengan residual), dan kepala keluaran ke 14 karakter vocabulary — total 10.222 parameter:
"""Next-token prediction: decoder mini belajar pola, lalu membangkitkan.
Arsitektur: transformer decoder-only satu blok (d_model=32, 2 head,
causal mask) — miniatur resep arsitektur LLM nyata. Pelatihan memakai
teacher forcing: setiap posisi dalam jendela 24 karakter memprediksi
karakter berikutnya sekaligus (cross-entropy rata-rata). Setelah latih,
satu konteks dibedah (logits -> softmax -> distribusi), lalu teks
dibangkitkan greedy lewat loop autoregressive. Korpus sama dengan
chapter Pengenalan LLM agar hasilnya bisa dibandingkan. Diuji di CPU
dengan PyTorch 2.14, seed tetap, deterministik.
"""
import torch
import torch.nn as nn
torch.manual_seed(0)
# ---------- 1. Korpus (sama dengan chapter Pengenalan LLM) ----------
blok = "kucing makan ikan. anjing makan daging. burung makan biji. "
korpus = blok * 6
LATIH, UJI = korpus[: -len(blok)], korpus[-len(blok):]
karakter = sorted(set(korpus))
id_kar = {c: i for i, c in enumerate(karakter)}
V = len(karakter)
JENDELA = 24
print(f"korpus: {len(korpus)} karakter, vocabulary {V}: {''.join(karakter)!r}")
print()
def jendela(teks):
"""Jendela geser: input 24 karakter, target 24 karakter berikutnya."""
X, y = [], []
for i in range(len(teks) - JENDELA):
X.append([id_kar[c] for c in teks[i : i + JENDELA]])
y.append([id_kar[c] for c in teks[i + 1 : i + JENDELA + 1]])
return torch.tensor(X), torch.tensor(y)
X_latih, y_latih = jendela(LATIH)
X_uji, y_uji = jendela(UJI)
print(f"contoh latih: {X_latih.shape[0]} jendela x {JENDELA} posisi"
f" -> {X_latih.shape[0] * JENDELA} prediksi token berikutnya")
print()
# ---------- 2. Decoder mini: embedding + 1 blok + kepala keluaran ----------
class BlokDecoder(nn.Module):
"""Satu blok transformer decoder: causal self-attention + FFN."""
def __init__(self, d, kepala):
super().__init__()
self.h, self.d_k = kepala, d // kepala
self.wq, self.wk, self.wv, self.wo = (
nn.Linear(d, d) for _ in range(4)
)
self.ffn = nn.Sequential(nn.Linear(d, 64), nn.ReLU(), nn.Linear(64, d))
self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
def forward(self, x):
n, T, d = x.shape
def pecah(z):
return z.view(n, T, self.h, self.d_k).transpose(1, 2)
q, k, v = pecah(self.wq(x)), pecah(self.wk(x)), pecah(self.wv(x))
skor = q @ k.transpose(-2, -1) / self.d_k ** 0.5
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
skor = skor.masked_fill(mask, float("-inf")) # token hanya menoleh ke kiri
a = skor.softmax(dim=-1)
z = (a @ v).transpose(1, 2).contiguous().view(n, T, d)
x = self.norm1(x + self.wo(z))
x = self.norm2(x + self.ffn(x))
return x
class MiniGPT(nn.Module):
"""Decoder-only: token embedding + positional embedding + 1 blok."""
def __init__(self, vocab, d, T, kepala):
super().__init__()
self.embed = nn.Embedding(vocab, d)
self.pos = nn.Embedding(T, d)
self.blok = BlokDecoder(d, kepala)
self.kepala_keluaran = nn.Linear(d, vocab)
def forward(self, idx):
T = idx.shape[1]
posisi = torch.arange(T)
x = self.embed(idx) + self.pos(posisi)
x = self.blok(x)
return self.kepala_keluaran(x) # logits (batch, T, vocab)
model = MiniGPT(V, d=32, T=JENDELA, kepala=2)
n_param = sum(p.numel() for p in model.parameters())
print(f"arsitektur: 1 blok, d_model=32, 2 head, konteks {JENDELA}")
print(f"parameter : {n_param}")
print()
# ---------- 3. Pelatihan: teacher forcing pada semua posisi ----------
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
loss_fn = nn.CrossEntropyLoss()
EPOCH = 350
print("=== Pelatihan (teacher forcing, loss tiap 70 epoch) ===")
for ep in range(EPOCH):
logits = model(X_latih) # (batch, T, V)
loss = loss_fn(logits.reshape(-1, V), y_latih.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
if ep % 70 == 0 or ep == EPOCH - 1:
print(f" epoch {ep:3d} loss = {loss.item():.4f}")
print()
with torch.no_grad():
logits_uji = model(X_uji)
loss_uji = loss_fn(logits_uji.reshape(-1, V), y_uji.reshape(-1)).item()
benar = (logits_uji.argmax(-1) == y_uji).float().mean().item()
print(f"loss uji : {loss_uji:.4f} | akurasi per posisi: {benar:.3f}")
print()
# ---------- 4. Mekanisme: logits -> softmax -> distribusi ----------
with torch.no_grad():
tanda = "anjing makan "
ujung = korpus.find(tanda) + len(tanda)
konteks_str = korpus[ujung - JENDELA : ujung] # 24 karakter berakhir 'makan '
idx = torch.tensor([[id_kar[c] for c in konteks_str]])
logits_pos = model(idx)[0, -1] # logits posisi terakhir
prob = logits_pos.softmax(0)
urut = prob.argsort(descending=True)[:3]
print("=== Mekanisme pada satu posisi ===")
print(f"konteks 24 karakter berakhir ...{konteks_str[8:]!r}")
for i in urut:
print(f" logits[{karakter[i]!r:5s}] = {logits_pos[i].item():7.3f}"
f" -> probabilitas {prob[i].item():.3f}")
print()
# ---------- 5. Inference: loop autoregressive greedy ----------
def bangkit(model, benih, panjang):
"""Bangkitkan teks karakter demi karakter (selalu greedy)."""
teks = list(benih)
model.eval()
with torch.no_grad():
for _ in range(panjang):
idx = torch.tensor([[id_kar[c] for c in teks[-JENDELA:]]])
logits = model(idx)[0, -1]
teks.append(karakter[logits.argmax().item()])
return "".join(teks)
benih = LATIH[:JENDELA]
hasil = bangkit(model, benih, 48)
harapan = korpus[JENDELA : JENDELA + 48]
print("=== Inference: sampling autoregressive greedy 48 karakter ===")
print(f"benih : {benih!r}")
print(f"model : {hasil[JENDELA:]!r}")
print(f"asli : {harapan!r}")
print(f"sama persis dengan korpus asli: {hasil[JENDELA:] == harapan}")Output yang Dihasilkan:
korpus: 354 karakter, vocabulary 14: ' .abcdgijkmnru'
contoh latih: 271 jendela x 24 posisi -> 6504 prediksi token berikutnya
arsitektur: 1 blok, d_model=32, 2 head, konteks 24
parameter : 10222
=== Pelatihan (teacher forcing, loss tiap 70 epoch) ===
epoch 0 loss = 2.7854
epoch 70 loss = 0.4729
epoch 140 loss = 0.1021
epoch 210 loss = 0.0779
epoch 280 loss = 0.0747
epoch 349 loss = 0.0734
loss uji : 0.0731 | akurasi per posisi: 0.970
=== Mekanisme pada satu posisi ===
konteks 24 karakter berakhir ...'n. anjing makan '
logits['d' ] = 9.425 -> probabilitas 0.994
logits['b' ] = 3.561 -> probabilitas 0.003
logits['i' ] = 3.543 -> probabilitas 0.003
=== Inference: sampling autoregressive greedy 48 karakter ===
benih : 'kucing makan ikan. anjin'
model : 'g makan daging. burung makan biji. kucing makan '
asli : 'g makan daging. burung makan biji. kucing makan '
sama persis dengan korpus asli: TrueMari kita telaah kode di atas baris per baris, dengan fokus pada dua loop yang menjadi inti chapter ini:
- Baris 30-36: fungsi
jendelamenggeser jendela selebar 24 karakter;Xberisi konteks danyberisi teks yang sama tergeser satu posisi — target posisi ke-i adalah token ke-i+1. - Baris 50-57: constructor
BlokDecodermenyiapkan empat proyeksi attention (wq,wk,wv,wo), FFN dua lapis, dan dua LayerNorm. - Baris 66-68: inti decoder — skor attention
q @ kᵀ / √d_kdi-masked dengantorch.triu(..., diagonal=1): posisi j > i diisi-infsebelum softmax, sehingga bobot attention ke masa depan menjadi nol persis. - Baris 86-91:
MiniGPT.forwardmenjumlahkan token embedding dan positional embedding, melewati blok, lalu kepala keluaran menghasilkan logits berbentuk (batch, posisi, vocab) — satu vektor logits untuk tiap posisi. - Baris 105-112: training loop.
logits.reshape(-1, V)meratakan seluruh posisi semua jendela menjadi satu batch besar prediksi token berikutnya;CrossEntropyLossmenghukum posisi yang memberi probabilitas rendah pada token asli. Loss turun dari 2,785 ke 0,073. - Baris 122-135: mekanisme di satu posisi. Konteks berakhir
anjing makanmenghasilkan logitsd = 9,425— softmax mengubahnya menjadi probabilitas 0,994 untukd(awalan “daging”), menyisakan 0,003 untuk kandidat terdekat. - Baris 138-148: loop autoregressive. Setiap langkah mengambil 24 karakter terakhir, menjalankan model, mengambil argmax logits posisi terakhir, lalu menempelkan hasilnya ke akhir teks — konteks berikutnya kini memuat prediksi model sendiri.
Dua hasil menonjol dari keluaran. Pertama, generasi greedy 48 karakter sama persis dengan kelanjutan korpus — loop autoregressive menjaga pola kalimat tanpa satu pun kesalahan akumulasi. Kedua, akurasi per posisi 0,970 (bukan 1,000) karena posisi awal setiap jendela hanya melihat konteks satu-dua karakter: menebak karakter kedua “kucing” dari k saja memang masih multitafsir. Prediksi posisi terakhir — yang dipakai saat inference dengan konteks penuh — jauh lebih tajam, seperti distribusi 0,994 di atas.
Loop Autoregressive dan Biaya Inferensi #
Saat pelatihan, 271 jendela diproses dalam beberapa batch besar; saat inference, satu token baru menuntut satu maju model penuh. Biaya per token karenanya sebanding panjang konteks, dan LLM nyata menambahkan KV cache — menyimpan key dan value yang sudah dihitung agar token baru tidak menghitung ulang seluruh konteks. Strategi pemilihan token dari distribusi juga bervariasi: greedy seperti demo ini, temperature, top-k, sampai top-p — pilihan-pilihan itu dibedah pada chapter Inference dan Decoding .
Model ini kini bisa melanjutkan teks dengan pola sempurna — tetapi coba beri instruksi “tulis huruf b empat kali” dan model hanya akan melanjutkan pola kalimatnya, bukan mengikuti perintah. Jarak antara menebak kelanjutan dan mengikuti instruksi itulah yang ditambal instruction tuning, pokok bahasan chapter berikutnya.