Model NLP lahir dalam ragam bentuk: classifier kalimat, penanda token, sistem tanya-jawab, penerjemah, hingga model bahasa generatif — dan tiap bentuk menuntut cara menilai yang berbeda. Satu angka akurasi global tidak cukup; bahkan satu definisi “benar” pun bisa berbeda antar tugas. Chapter penutup section ini memetakan metrik yang wajib dikenal, lalu menghitung semuanya secara manual pada contoh mini yang bisa diverifikasi dengan kalkulator: presisi, recall, dan F1 per jenis entitas, exact match, BLEU-1 dan BLEU-2, hingga perplexity.

Peta Metrik per Tugas #

MetrikTugas yang memakainyaPertanyaan yang dijawab
Akurasi tokenPOS taggingberapa persen tag token yang tepat?
Presisi, recall, F1 entity-levelNER, chunkingberapa entitas utuh (teks + jenis + batas) yang benar?
Exact matchtanya-jawab ekstraktifberapa jawaban yang persis sama setelah normalisasi?
BLEUpenerjemahan, peringkasan generatifseberapa banyak n-gram kandidat muncul di referensi?
Perplexitymodel bahasa, generationseberapa yakin model memprediksi token berikutnya?

Presisi, recall, dan akurasi klasifikasi telah dibahas dari sudut pandang model umum pada chapter Metrik Klasifikasi , dan pembacaan tabel kesalahannya pada Confusion Matrix . Chapter ini memfokuskan pada kekhasan NLP: satuan evaluasi yang bisa lebih kecil atau lebih besar daripada satu teks, serta dua metrik generatif yang belum kita sentuh.

Demo: Empat Metrik dalam Satu Berkas #

Berkas metrik_nlp.py memuat empat bagian: skor NER per jenis entitas pada tiga kalimat, exact match pada empat pasang tanya-jawab, BLEU-1 dan BLEU-2 pada dua kalimat kandidat, dan perplexity dua model bahasa atas kalimat yang sama:

python
"""Metrik evaluasi NLP: token vs entity-level, exact match, BLEU, perplexity.

Bagian 1: presisi/recall/F1 per jenis entitas (token-level vs entity-level)
pada 3 kalimat NER kecil dengan tag BIO.
Bagian 2: exact match pada 4 pasang tanya-jawab singkat.
Bagian 3: BLEU-1 dan BLEU-2 dihitung manual langkah demi langkah pada
pasangan kalimat pendek, termasuk efek clipping dan brevity penalty.
Bagian 4: perplexity dihitung manual dari probabilitas token.
"""

import math
from collections import Counter

# ---------- 1. Token-level vs entity-level (per jenis entitas) ----------
def ekstrak_entitas(kata_list, tag_list):
    entitas, sedang = [], None
    for kata, tag in zip(kata_list, tag_list):
        if tag.startswith("B-"):
            if sedang:
                entitas.append(sedang)
            sedang = (tag[2:], [kata])
        elif tag.startswith("I-") and sedang and tag[2:] == sedang[0]:
            sedang[1].append(kata)
        else:
            if sedang:
                entitas.append(sedang)
            sedang = None
    if sedang:
        entitas.append(sedang)
    return [(jenis, " ".join(k)) for jenis, k in entitas]


contoh = [
    (["Budi", "pergi", "ke", "Bandung", "."],
     ["B-PER", "O", "O", "B-LOC", "O"],
     ["B-PER", "O", "O", "B-PER", "O"]),
    (["Siti", "bekerja", "di", "PT", "Maju", "Jaya", "."],
     ["B-PER", "O", "O", "B-ORG", "I-ORG", "I-ORG", "O"],
     ["B-PER", "O", "O", "B-ORG", "I-ORG", "O", "O"]),
    (["Rapat", "diadakan", "di", "Jakarta", "pada", "12", "Maret", "."],
     ["O", "O", "O", "B-LOC", "O", "B-DATE", "I-DATE", "O"],
     ["O", "O", "O", "B-LOC", "O", "B-DATE", "I-DATE", "O"]),
]

print("== 1. token-level vs entity-level (NER, 3 kalimat) ==")
total_token = benar_token = 0
emas, prediksi = [], []
for kata, gold, pred in contoh:
    total_token += len(kata)
    benar_token += sum(g == p for g, p in zip(gold, pred))
    emas_k = ekstrak_entitas(kata, gold)
    pred_k = ekstrak_entitas(kata, pred)
    emas.extend(emas_k)
    prediksi.extend(pred_k)
    print(f"  '{' '.join(kata)}'")
    print("    gold : " + ", ".join(f"{j} '{n}'" for j, n in emas_k))
    print("    pred : " + ", ".join(f"{j} '{n}'" for j, n in pred_k))
print(f"akurasi token-level : {benar_token}/{total_token} = "
      f"{benar_token / total_token:.3f}")

print("skor per jenis entitas (P = presisi, R = recall):")
jenis_semua = sorted({j for j, _ in emas} | {j for j, _ in prediksi})
jumlah_emas = jumlah_pred = jumlah_cocok = 0
for jenis in jenis_semua:
    emas_j = [n for j, n in emas if j == jenis]
    pred_j = [n for j, n in prediksi if j == jenis]
    cocok = sum(1 for n in pred_j if n in emas_j)
    p = cocok / len(pred_j) if pred_j else 0.0
    r = cocok / len(emas_j) if emas_j else 0.0
    f1 = 2 * p * r / (p + r) if p + r else 0.0
    print(f"  {jenis:5s} emas {len(emas_j)} pred {len(pred_j)} cocok {cocok} "
          f"| P {p:.3f} | R {r:.3f} | F1 {f1:.3f}")
    jumlah_emas += len(emas_j)
    jumlah_pred += len(pred_j)
    jumlah_cocok += cocok
p_mikro = jumlah_cocok / jumlah_pred
r_mikro = jumlah_cocok / jumlah_emas
f1_mikro = 2 * p_mikro * r_mikro / (p_mikro + r_mikro)
print(f"mikro  : P {p_mikro:.3f} | R {r_mikro:.3f} | F1 {f1_mikro:.3f} "
      f"({jumlah_cocok} cocok dari {jumlah_pred} prediksi / {jumlah_emas} emas)")

# ---------- 2. Exact match ----------
print("\n== 2. exact match (4 pasang tanya-jawab) ==")


def normalisasi(teks):
    return teks.strip().rstrip(".,!?").lower()


pasangan = [
    ("Siapa penemu bola lampu?", "thomas edison", "Thomas Edison."),
    ("Kota apa yang menjadi pusat pemerintahan Indonesia?", "jakarta", "Jakarta"),
    ("Kapan musim hujan biasanya mulai?", "oktober", "November"),
    ("Berapa hasil 2 tambah 3?", "5", "lima"),
]
benar_em = sum(
    1 for _, jawaban, prediksi_jawaban in pasangan
    if normalisasi(jawaban) == normalisasi(prediksi_jawaban)
)
for pertanyaan, jawaban, prediksi_jawaban in pasangan:
    status = "BENAR" if normalisasi(jawaban) == normalisasi(prediksi_jawaban) else "SALAH"
    print(f"  emas '{jawaban}' vs prediksi '{prediksi_jawaban}' -> {status}")
print(f"exact match = {benar_em}/{len(pasangan)} = {benar_em / len(pasangan):.3f}")

# ---------- 3. BLEU manual ----------
print("\n== 3. BLEU-1 dan BLEU-2 (hitungan manual) ==")


def presisi_ngram(kandidat, referensi, n):
    kandidat_n = Counter(
        tuple(kandidat[i:i + n]) for i in range(len(kandidat) - n + 1)
    )
    referensi_n = Counter(
        tuple(referensi[i:i + n]) for i in range(len(referensi) - n + 1)
    )
    total = sum(kandidat_n.values())
    cocok = sum(min(jumlah, referensi_n.get(ngram, 0))
                for ngram, jumlah in kandidat_n.items())
    return cocok, total


def brevity_penalty(kandidat, referensi):
    if len(kandidat) >= len(referensi):
        return 1.0
    return math.exp(1 - len(referensi) / len(kandidat))


referensi = "budi membaca buku baru di perpustakaan".split()
kandidat_a = "budi membaca buku di perpustakaan".split()
kandidat_b = "budi membaca membaca buku di perpustakaan".split()

for nama, kandidat in (("kandidat A", kandidat_a), ("kandidat B", kandidat_b)):
    print(f"  {nama}: '{' '.join(kandidat)}'")
    print(f"  referensi    : '{' '.join(referensi)}'")
    p_nilai = []
    for n in (1, 2):
        cocok, total = presisi_ngram(kandidat, referensi, n)
        p = cocok / total
        p_nilai.append(p)
        print(f"  P{n} = {cocok}/{total} = {p:.4f} "
              f"(clipping: jumlah n-gram kandidat dibatasi jumlah di referensi)")
    bp = brevity_penalty(kandidat, referensi)
    print(f"  BP = {bp:.4f} (|kandidat| {len(kandidat)} vs |referensi| {len(referensi)})")
    bleu1 = p_nilai[0] * bp
    bleu2 = math.sqrt(p_nilai[0] * p_nilai[1]) * bp
    print(f"  BLEU-1 = P1 x BP = {bleu1:.4f}")
    print(f"  BLEU-2 = akar(P1 x P2) x BP = {bleu2:.4f}")
    print()

# ---------- 4. Perplexity manual ----------
print("== 4. perplexity (hitungan manual) ==")
kalimat_ppl = "budi membaca buku di taman".split()
model_a = [0.5, 0.25, 0.5, 1.0]
model_b = [0.25, 0.25, 0.25, 0.25]

for nama, peluang in (("model A", model_a), ("model B", model_b)):
    nll = -sum(math.log(p) for p in peluang)
    rata_nll = nll / len(peluang)
    ppl = math.exp(rata_nll)
    rincian = ", ".join(f"p={p:g}" for p in peluang)
    print(f"  {nama}: '{' '.join(kalimat_ppl)}'")
    print(f"    peluang per token: {rincian}")
    print(f"    NLL total = -sum(ln p) = {nll:.4f} | rata-rata = {rata_nll:.4f}")
    print(f"    perplexity = exp(rata-rata NLL) = {ppl:.4f}")

Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji. Setiap angka pada keluaran dapat dihitung ulang dengan kalkulator dari definisi di prosa:

Output yang Dihasilkan:

text
== 1. token-level vs entity-level (NER, 3 kalimat) ==
  'Budi pergi ke Bandung .'
    gold : PER 'Budi', LOC 'Bandung'
    pred : PER 'Budi', PER 'Bandung'
  'Siti bekerja di PT Maju Jaya .'
    gold : PER 'Siti', ORG 'PT Maju Jaya'
    pred : PER 'Siti', ORG 'PT Maju'
  'Rapat diadakan di Jakarta pada 12 Maret .'
    gold : LOC 'Jakarta', DATE '12 Maret'
    pred : LOC 'Jakarta', DATE '12 Maret'
akurasi token-level : 18/20 = 0.900
skor per jenis entitas (P = presisi, R = recall):
  DATE  emas 1 pred 1 cocok 1 | P 1.000 | R 1.000 | F1 1.000
  LOC   emas 2 pred 1 cocok 1 | P 1.000 | R 0.500 | F1 0.667
  ORG   emas 1 pred 1 cocok 0 | P 0.000 | R 0.000 | F1 0.000
  PER   emas 2 pred 3 cocok 2 | P 0.667 | R 1.000 | F1 0.800
mikro  : P 0.667 | R 0.667 | F1 0.667 (4 cocok dari 6 prediksi / 6 emas)

== 2. exact match (4 pasang tanya-jawab) ==
  emas 'thomas edison' vs prediksi 'Thomas Edison.' -> BENAR
  emas 'jakarta' vs prediksi 'Jakarta' -> BENAR
  emas 'oktober' vs prediksi 'November' -> SALAH
  emas '5' vs prediksi 'lima' -> SALAH
exact match = 2/4 = 0.500

== 3. BLEU-1 dan BLEU-2 (hitungan manual) ==
  kandidat A: 'budi membaca buku di perpustakaan'
  referensi    : 'budi membaca buku baru di perpustakaan'
  P1 = 5/5 = 1.0000 (clipping: jumlah n-gram kandidat dibatasi jumlah di referensi)
  P2 = 3/4 = 0.7500 (clipping: jumlah n-gram kandidat dibatasi jumlah di referensi)
  BP = 0.8187 (|kandidat| 5 vs |referensi| 6)
  BLEU-1 = P1 x BP = 0.8187
  BLEU-2 = akar(P1 x P2) x BP = 0.7090

  kandidat B: 'budi membaca membaca buku di perpustakaan'
  referensi    : 'budi membaca buku baru di perpustakaan'
  P1 = 5/6 = 0.8333 (clipping: jumlah n-gram kandidat dibatasi jumlah di referensi)
  P2 = 3/5 = 0.6000 (clipping: jumlah n-gram kandidat dibatasi jumlah di referensi)
  BP = 1.0000 (|kandidat| 6 vs |referensi| 6)
  BLEU-1 = P1 x BP = 0.8333
  BLEU-2 = akar(P1 x P2) x BP = 0.7071

== 4. perplexity (hitungan manual) ==
  model A: 'budi membaca buku di taman'
    peluang per token: p=0.5, p=0.25, p=0.5, p=1
    NLL total = -sum(ln p) = 2.7726 | rata-rata = 0.6931
    perplexity = exp(rata-rata NLL) = 2.0000
  model B: 'budi membaca buku di taman'
    peluang per token: p=0.25, p=0.25, p=0.25, p=0.25
    NLL total = -sum(ln p) = 5.5452 | rata-rata = 1.3863
    perplexity = exp(rata-rata NLL) = 4.0000

Mari kita telaah kode di atas baris per baris:

  1. Baris 15-31 (ekstraksi entitas): tag BIO dilipat kembali menjadi daftar (jenis, teks); entitas hanya dianggap utuh bila B- dan I--nya berurutan dan sewarna.
  2. Baris 33-80 (bagian 1): akurasi token dihitung dulu, lalu tiap jenis entitas dinilai terpisah — presisi dari sisi prediksi, recall dari sisi anotasi, F1 sebagai rata-rata harmoniknya; agregat mikro menjumlahkan seluruh jenis sebelum membagi.
  3. Baris 86-103 (bagian 2): normalisasi menyeragamkan kapital dan membuang tanda baca ujung sebelum perbandingan persis — “Thomas Edison.” dan “thomas edison” dianggap sama.
  4. Baris 109-125 (presisi n-gram): Counter menghitung n-gram kandidat dan referensi; min membatasi jumlah tiap n-gram kandidat maksimal sebesar jumlahnya di referensi — mekanisme clipping yang mencegah pengulangan kata menaikkan skor.
  5. Baris 122-125 (brevity penalty): kalimat kandidat yang lebih pendek dari referensi dihukum eksponensial agar model tidak menuai skor tinggi dengan menghasilkan teks super ringkas.
  6. Baris 128-148 (bagian 3): dua kandidat dibandingkan ke satu referensi; BLEU-1 memakai presisi unigram, BLEU-2 memakai rata-rata geometrik presisi unigram dan bigram.
  7. Baris 150-164 (bagian 4): perplexity dihitung dari peluang token yang diberikan model — negatif logaritma natural dirata-ratakan lalu dieksponensialkan kembali.

Membaca Bagian 1: Satu Angka, Tiga Kisah #

Akurasi token 0,900 dan F1 mikro 0,667 berasal dari prediksi yang sama. Bedanya: evaluasi token memaafkan “Bandung” yang salah jenis dan “PT Maju Jaya” yang kehilangan kata terakhir, sedangkan evaluasi entity menganggap keduanya salah total. Tabel per jenis menambah ketajaman: DATE sempurna, LOC presisi 1,000 tetapi recall 0,500 (satu lokasi terlewat), PER recall 1,000 tetapi presisi 0,667 (satu lokasi salah dibaca sebagai orang), dan ORG nol karena satu-satunya entitas organisasi terpotong. Pola seperti ini menunjuk perbaikan data atau fitur secara langsung — kegunaan yang hilang bila hanya satu angka agregat yang dilaporkan.

Membaca Bagian 2 dan 3: Exact Match dan BLEU #

Exact match memberi 2/4 karena “November” dan “lima” memang bukan jawaban emasnya walau semantiknya dekat — metrik ini keras: sebagian benar tetap salah. BLEU lebih lunak: kandidat A memang kehilangan kata “baru” sehingga P1 sempurna 1,000 tetapi BP menurunkan BLEU-1 ke 0,8187. Kandidat B memperlihatkan clipping: kata “membaca” muncul dua kali padahal referensi hanya satu, jadi unigram yang dihitung 5 dari 6 — pengulangan tidak diganjar skor.

Membaca Bagian 4: Perplexity sebagai “Banyaknya Pilihan” #

Perplexity adalah eksponensial dari rata-rata negatif log-likelihood: PPL = exp( -(1/T) * Σ ln p(token) ). Model A pada keluaran menghasilkan PPL 2,000 — secara intuitif, pada tiap langkah modelnya ragu-ragu seperti memilih dari 2 kata. Model B yang peluangnya seragam 0,25 menghasilkan PPL 4,000 — ragu seperti memilih dari 4 kata. Semakin rendah perplexity, semakin tajam model menempatkan peluang pada kata yang benar-benar muncul berikutnya.

Penerapan produksi biasanya memakai pustaka seperti Evaluate Hugging Face agar definisi metrik konsisten antar eksperimen; hitungan manual pada chapter ini tetap penting untuk membaca dan memverifikasi angka yang dihasilkan pustaka. Evaluasi model bahasa generatif akan kembali menjadi tema besar pada chapter Evaluasi LLM — dengan perplexity dan BLEU sebagai fondasi yang kini sudah kita pegang.