Chapter Tokenization pada section NLP sudah memperkenalkan tiga granularitas token: word, subword, dan karakter, lalu memakai kamus subword buatan tangan. LLM nyata tidak memakai kamus buatan — kamusnya dibangun otomatis oleh algoritma byte pair encoding (BPE) dari korpus pelatihan. Chapter ini membedah BPE sampai ke baris-barisnya: kita implementasikan dari nol, latih pada korpus mini, amati vocabulary tumbuh sepuluh iterasi, lalu pakai hasilnya untuk men-tokenisasi kalimat baru dan merangkai format chat. Bagian penutupnya membahas konsekuensi yang spesifik LLM: vocabulary puluhan-atasan ribu entri menggelembungkan tabel embedding dan memengaruhi seberapa “mahal” sebuah bahasa dihitung dalam token.

Kenapa Subword Menang di LLM #

Dua granularitas ekstrem sama-sama kalah oleh biaya pada skala LLM. Tokenizer karakter memang bebas OOV, tetapi satu kalimat menjadi ratusan token — padahal biaya attention tumbuh kuadratik terhadap panjang sekuens. Tokenizer word menghasilkan sekuens pendek, tetapi vocabulary bahasa apa pun tumbuh tajam ke ratusan ribu entri dan setiap kata baru tetap menjadi OOV. Subword menempati titik tengah, dan BPE membangunnya secara otomatis dari data:

GranularitasUkuran vocabulary LLM nyataOOVPanjang sekuens
Karakterkecil (puluhan-batusan)tidak adasangat panjang
Subword BPE30-250 ribu entrihampir tidak adasedang
Kata utuhratusan ribu-jutaantinggipendek

Kunci dari baris kedua tabel: setiap teks tetap bisa di-tokenisasi. Kata yang tak terdapat di vocabulary dipecah menjadi potongan subword yang sudah dikenal, sampai ke karakter bila perlu — tokenizer modern bahkan bekerja di tingkat byte sehingga secara teknis tidak pernah gagal.

Algoritma BPE: Menggabungkan Pasangan Terfrekuen #

BPE memulai dari vocabulary karakter, lalu menjalankan satu loop pendek berulang kali:

Setiap iterasi menambah tepat satu token baru — pasangan simbol yang paling sering bersebelahan di korpus. Frekuensi dihitung dengan bobot frekuensi kata (kata yang muncul lima kali menyumbang lima kali), dan penggabungan diterapkan ke seluruh korpus sekaligus. Hasil sampingnya adalah urutan penggabungan: daftar pasangan berurutan yang dipakai ulang saat meng-encode teks baru, dengan mengulang penggabungan dari karakter secara berurutan.

Implementasi Mini: Melatih BPE pada Korpus Kecil #

Korpus kita berisi sembilan kata jenis seputar makan dan hewan, masing-masing dengan frekuensinya. Program berikut menjalankan sepuluh iterasi penggabungan, lalu meng-encode kalimat baru dan merangkai satu giliran chat:

python
"""BPE mini: melatih tokenizer byte pair encoding dari nol pada korpus kecil.

Algoritme BPE (byte pair encoding): mulai dari karakter, lalu ulangi —
hitung pasangan simbol bersebelahan yang paling sering muncul pada korpus
(dibobot frekuensi kata), gabungkan pasangan itu di seluruh korpus.
Vocabulary tumbuh satu entri per penggabungan. Setelah 10 iterasi,
tokenizer dipakai meng-encode kalimat baru memakai urutan penggabungan
yang telah dipelajari, ditutup demo special token format chat.
Pure Python tanpa dependensi, deterministik.
"""

from collections import Counter

# ---------- 1. Korpus mini: kata -> frekuensi ----------
KORPUS = {
    "makan": 5, "ikan": 3, "makanan": 2, "memakan": 2,
    "kucing": 2, "anjing": 2, "burung": 2, "minum": 2, "air": 2,
}

# setiap kata disimpan sebagai daftar simbol (awalnya karakter)
kata_simbol = {k: list(k) for k in KORPUS}

vokab_awal = sorted({s for kata in kata_simbol.values() for s in kata})
print("=== Latih BPE: 10 iterasi penggabungan ===")
print(f"vocabulary awal : {len(vokab_awal)} karakter -> {''.join(vokab_awal)}")
print(f"jumlah kata jenis: {len(KORPUS)}, total kemunculan: {sum(KORPUS.values())}")
print()


def hitung_pasangan(kata_simbol, frek):
    """Frekuensi tiap pasangan simbol bersebelahan, dibobot frekuensi kata."""
    jumlah = Counter()
    for kata, simbol in kata_simbol.items():
        for i in range(len(simbol) - 1):
            jumlah[(simbol[i], simbol[i + 1])] += frek[kata]
    return jumlah


def gabung_kata(simbol, pasangan):
    """Gabungkan satu pasangan menjadi simbol baru pada satu kata."""
    hasil, i = [], 0
    while i < len(simbol):
        if (i < len(simbol) - 1
                and (simbol[i], simbol[i + 1]) == pasangan):
            hasil.append(simbol[i] + simbol[i + 1])
            i += 2
        else:
            hasil.append(simbol[i])
            i += 1
    return hasil


urutan_gabung = []
vokab_dipelajari = list(vokab_awal)  # vocab terakumulasi: karakter + hasil gabung
N_ITER = 10
for iterasi in range(1, N_ITER + 1):
    jumlah = hitung_pasangan(kata_simbol, KORPUS)
    if not jumlah:
        break
    # pasangan terfrekuen; seri diputus leksikografis agar deterministik
    pasangan, frek = sorted(
        jumlah.items(), key=lambda kv: (-kv[1], kv[0])
    )[0]
    kata_simbol = {
        kata: gabung_kata(simbol, pasangan)
        for kata, simbol in kata_simbol.items()
    }
    urutan_gabung.append(pasangan)
    vokab_dipelajari.append(pasangan[0] + pasangan[1])
    contoh = [k for k, s in kata_simbol.items() if pasangan[0] + pasangan[1] in s]
    print(f"iter {iterasi:2d}: gabung {pasangan} (frekuensi {frek:2d})"
          f" -> '{pasangan[0] + pasangan[1]}'"
          f" | vocab jadi {len(vokab_dipelajari)} entri"
          f" | dipakai di: {', '.join(sorted(contoh)[:3])}")

aktif = sorted({sim for daftar in kata_simbol.values() for sim in daftar})
vokab_akhir = sorted(set(vokab_dipelajari))
print()
print(f"vocabulary terakumulasi: {len(vokab_akhir)} entri"
      f" ({len(vokab_awal)} karakter + {len(urutan_gabung)} token gabungan)")
print(f"token gabungan yang dipelajari: {' '.join(sorted(set(vokab_dipelajari) - set(vokab_awal)))}")
print(f"simbol masih aktif di korpus : {len(aktif)} entri -> {' '.join(aktif)}")
print()

# ---------- 2. Encode kalimat baru dengan urutan penggabungan ----------
def encode(kata):
    """Tokenisasi satu kata: mulai dari karakter, terapkan tiap gabungan."""
    simbol = list(kata)
    for pasangan in urutan_gabung:
        simbol = gabung_kata(simbol, pasangan)
    return simbol


print("=== Tokenisasi kalimat baru (huruf kecil, spasi pemisah kata) ===")
kalimat = "kucing memakan makanan enak"
token_semua = []
for kata in kalimat.split():
    token = encode(kata)
    token_semua.extend(token)
    tanda = " *" if kata in KORPUS else "  (kata baru)"
    print(f"  {kata:9s} -> {' | '.join(token)}{tanda}")
print(f"total token: {len(token_semua)} dari {len(kalimat.split())} kata")
print()

# ---------- 3. Special token untuk format chat ----------
SPECIAL = ["<|pengguna|>", "<|asisten|>", "<|akhir|>"]
id_khusus = {t: i for i, t in enumerate(SPECIAL)}
id_vokab = {t: i + len(SPECIAL) for i, t in enumerate(vokab_akhir)}

print("=== Special token dan format chat ===")
print(f"special token : {SPECIAL} (ID 0-{len(SPECIAL) - 1}, tak pernah digabung)")
obrolan = [
    ("pengguna", "makanan kucing"),
    ("asisten", "ikan"),
]
urutan_token, urutan_id = [], []
for peran, isi in obrolan:
    urutan_token.append(f"<{peran}>")
    urutan_id.append(id_khusus[f"<|{peran}|>"])
    for kata in isi.split():
        for t in encode(kata):
            urutan_token.append(t)
            urutan_id.append(id_vokab[t])
    urutan_token.append("<akhir>")
    urutan_id.append(id_khusus["<|akhir|>"])
print("percakapan 'makanan kucing' -> 'ikan' menjadi urutan token:")
print("  " + " ".join(f"{t:>12s}" for t in urutan_token))
print("  " + " ".join(f"{i:>12d}" for i in urutan_id))

Output yang Dihasilkan:

text
=== Latih BPE: 10 iterasi penggabungan ===
vocabulary awal : 12 karakter -> abcegijkmnru
jumlah kata jenis: 9, total kemunculan: 22

iter  1: gabung ('a', 'n') (frekuensi 16) -> 'an' | vocab jadi 13 entri | dipakai di: anjing, ikan, makan
iter  2: gabung ('k', 'an') (frekuensi 12) -> 'kan' | vocab jadi 14 entri | dipakai di: ikan, makan, makanan
iter  3: gabung ('a', 'kan') (frekuensi  9) -> 'akan' | vocab jadi 15 entri | dipakai di: makan, makanan, memakan
iter  4: gabung ('m', 'akan') (frekuensi  9) -> 'makan' | vocab jadi 16 entri | dipakai di: makan, makanan, memakan
iter  5: gabung ('i', 'n') (frekuensi  6) -> 'in' | vocab jadi 17 entri | dipakai di: anjing, kucing, minum
iter  6: gabung ('in', 'g') (frekuensi  4) -> 'ing' | vocab jadi 18 entri | dipakai di: anjing, kucing
iter  7: gabung ('i', 'kan') (frekuensi  3) -> 'ikan' | vocab jadi 19 entri | dipakai di: ikan
iter  8: gabung ('a', 'i') (frekuensi  2) -> 'ai' | vocab jadi 20 entri | dipakai di: air
iter  9: gabung ('ai', 'r') (frekuensi  2) -> 'air' | vocab jadi 21 entri | dipakai di: air
iter 10: gabung ('an', 'j') (frekuensi  2) -> 'anj' | vocab jadi 22 entri | dipakai di: anjing

vocabulary terakumulasi: 22 entri (12 karakter + 10 token gabungan)
token gabungan yang dipelajari: ai air akan an anj ikan in ing kan makan
simbol masih aktif di korpus : 16 entri -> air an anj b c e g ikan in ing k m makan n r u

=== Tokenisasi kalimat baru (huruf kecil, spasi pemisah kata) ===
  kucing    -> k | u | c | ing *
  memakan   -> m | e | makan *
  makanan   -> makan | an *
  enak      -> e | n | a | k  (kata baru)
total token: 13 dari 4 kata

=== Special token dan format chat ===
special token : ['<|pengguna|>', '<|asisten|>', '<|akhir|>'] (ID 0-2, tak pernah digabung)
percakapan 'makanan kucing' -> 'ikan' menjadi urutan token:
    <pengguna>        makan           an            k            u            c          ing      <akhir>    <asisten>         ikan      <akhir>
             0           21            7           18           24           10           16            2            1           14            2

Mari kita telaah kode di atas baris per baris:

  1. Baris 15-21: korpus disimpan sebagai kamus kata→frekuensi, dan setiap kata sebagai daftar simbol yang awalnya karakternya sendiri. Representasi inilah yang terus ditulis ulang oleh loop BPE.
  2. Baris 30-36: hitung_pasangan memeriksa setiap dua simbol bersebelahan pada setiap kata dan menambah frekuensi kata ke pasangan tersebut ke dalam Counter — pasangan dalam kata yang sering muncul menang atas pasangan dalam kata yang jarang.
  3. Baris 39-50: gabung_kata memindai satu kata dari kiri, mengganti setiap kemunculan pasangan dengan simbol gabungan. Pemindaian melompat dua simbol setelah menggabung sehingga pasangan berturut tidak digabung ganda.
  4. Baris 61-63: kandidat diurutkan dengan kunci (-frekuensi, pasangan) lalu diambil yang pertama — pasangan terfrekuen menang, dan seri diputus leksikografis agar keluaran program deterministik.
  5. Baris 64-69: penggabungan diterapkan ke seluruh kata, pasangan dicatat ke urutan_gabung, dan token baru masuk ke vocabulary terakumulasi vokab_dipelajari.
  6. Baris 76-82: dua ukuran vocabulary dibedakan. Vocabulary terakumulasi (22 entri) berisi semua token yang pernah dipelajari; simbol aktif (16 entri) hanya yang masih benar-benar muncul di korpus — token seperti akan sudah terserap habis menjadi makan, tetapi tetap tersimpan karena teks baru bisa saja membutuhkannya.
  7. Baris 86-91: encode men-tokenisasi kata baru dengan mengulang urutan penggabungan yang sama, dari karakter ke atas. Kalimat “kucing memakan makanan enak” menjadi 13 token: memakan terpecah m + e + makan, makanan menjadi makan + an, dan “enak” — kata yang tak pernah ada di korpus — turun ke empat karakter tanpa gagal.
  8. Baris 106-125: tiga special token mendapat ID 0-2 dan tidak pernah digabung. Percakapan dua giliran diratakan menjadi satu urutan token: penanda peran, isi pengguna, penanda akhir, penanda peran asisten, isi asisten, penanda akhir.

Perhatikan pola pada keluaran iterasi: pasangan a+n menang pertama (frekuensi 16) karena muncul di hampir semua kata, disusul k+an lalu a+kan — token makan terbentuk bertahap dari tiga penggabungan. Urutan itulah yang membuat frekuensi tinggi di korpus diterjemahkan menjadi token utuh, sementara kata langka tetap terpecah.

Vocabulary Besar dan Konsekuensinya #

Setiap entri vocabulary harus punya baris di tabel embedding. Dengan vocabulary 128 ribu dan dimensi model 4096, tabel embedding saja sudah memuat sekitar 524 juta parameter — dan lapis keluaran memproyeksikan balik ke vocabulary yang sama, sehingga softmax dihitung di atas 128 ribu skor tiap posisi. Ukuran vocabulary karenanya adalah trade-off nyata: tokenizer yang lebih halus memotong teks lebih pendek (sekuens lebih murah), tetapi setiap token tambahan memperbesar dua matriks terbesar model.

Konsekuensinya terasa pada bahasa seperti Indonesia: imbuhan yang produktif membuat kata jarang bertahan utuh, sehingga teks bahasa Indonesia lazimnya memakan lebih banyak token per kata dibanding bahasa Inggris, dan context window terpakai lebih cepat. Pustaka tokenizers dari Hugging Face menyediakan implementasi BPE (termasuk varian byte-level), Unigram, dan WordPiece siap pakai — melatih tokenizer sejuta-token berjalan dalam hitungan menit pada CPU, jauh lebih murah daripada melatih modelnya.

Special token di bagian akhir demo adalah kontrak antara tokenizer dan format percakapan: model diajari bahwa <|pengguna|> membuka giliran pengguna dan <|akhir|> menutupnya. Format dan template semacam ini dibedah lebih jauh pada chapter Prompt Format dan Chat Template . Sebelum sampai ke sana, kita perlu melihat tahap yang memakan hampir seluruh komputasi LLM: pretraining pada korpus raksasa — pokok bahasan chapter berikutnya.