Chapter Named Entity Recognition menandai entitas dengan memberi tag pada setiap token. Struktur itu bukan kekhususan NER — prinsip yang sama menampung banyak tugas: menentukan tipe kata (POS tagging), memotong kalimat menjadi frasa (chunking), hingga menandai batas entitas. Keluarga tugas ini disebut sequence labeling: input berupa rangkaian token, output berupa rangkaian tag yang sama panjang dan sejajar token demi token. Chapter ini membangun penanda POS bahasa Indonesia dengan RNN kecil, membedah cara loss CrossEntropy melewati padding, lalu memperlihatkan mengapa angka akurasi token bisa menyesatkan bila yang dievaluasi adalah entitas utuh.
Keluarga Tugas Tag-per-Token #
| Tugas | Output per token | Satuan yang dievaluasi | Metrik umum |
|---|---|---|---|
| POS tagging | tipe kata: NOUN, VERB, ADJ | token | akurasi token |
| Chunking | batas frasa: B-NP, I-NP, O | frasa utuh | presisi, recall, F1 frasa |
| NER | entitas: B-PER, I-ORG, O | entitas utuh (teks + jenis) | presisi, recall, F1 entitas |
Ketiganya berbagi anatomi yang sama: satu model membaca token demi token dan mengeluarkan satu skor kelas per posisi. Bedanya hanya isi kamus tag dan definisi “benar” saat evaluasi — perbedaan terakhir ini sering diremehkan dan dibedah pada bagian akhir chapter.
Struktur Sejajar dan Loss per Token #
Karena panjang kalimat bervariasi, batch dilatih dengan padding: kalimat pendek ditambah token <pad> hingga seragam. Tag <pad> diberi id 0, lalu CrossEntropyLoss(ignore_index=0) mengeluarkan posisi padding dari perhitungan loss. Tanpa argumen ini, model akan “belajar” memprediksi padding — kemampuan yang tidak berguna dan justru menggeser bobot dari token asli.
RNN menjadi encoder alami untuk tugas ini: hidden state membawa konteks kata sebelumnya, sehingga kata ambigu seperti “layak” dapat dibedakan dari posisinya dalam kalimat, bukan hanya dari kamusnya.
Demo: POS Tagging Bahasa Indonesia dengan RNN #
Berkas pos_tagging.py membangkitkan 120 kalimat mini dari empat templat, melatih Embedding(16) -> RNN(24) -> Linear, lalu menguji dua kalimat baru — salah satunya memuat kata yang tidak ada di vocabulary:
"""Sequence labeling: POS tagging dengan RNN kecil PyTorch.
Dataset mini buatan 120 kalimat dari templat dengan tag POS sejajar
per token (NOUN, VERB, ADJ, ADV, NUM, DET, PUNCT). Model:
Embedding(dim 16) -> RNN(24) -> Linear -> tag per token.
Loss: CrossEntropyLoss(ignore_index=PAD) sehingga token padding tidak
dihitung. Bagian 2: kontras akurasi token-level vs skor entity-level
pada contoh NER kecil.
"""
import random
import torch
import torch.nn as nn
torch.manual_seed(42)
acak = random.Random(42)
# ---------- 1. Dataset mini buatan ----------
NOUN = ["murid", "guru", "buku", "kota", "film", "makanan",
"harga", "mobil", "kucing", "pekerjaan"]
VERB = ["membaca", "menonton", "membeli", "menulis", "memasak",
"datang", "pergi", "menjual", "naik", "turun"]
ADJ = ["bagus", "besar", "kecil", "mahal", "murah",
"indah", "bersih", "cepat", "lambat", "enak"]
ADV = ["sangat", "cukup", "selalu", "sering", "kadang"]
NUM = ["dua", "tiga", "lima", "sepuluh"]
DET = ["ini", "itu"]
TEMPLAT = [
(["NOUN", "DET", "ADV", "ADJ", "PUNCT"], [NOUN, DET, ADV, ADJ]),
(["NOUN", "ADV", "VERB", "NOUN", "PUNCT"], [NOUN, ADV, VERB, NOUN]),
(["NUM", "NOUN", "VERB", "NOUN", "DET", "PUNCT"], [NUM, NOUN, VERB, NOUN, DET]),
(["NOUN", "ADJ", "ADV", "VERB", "PUNCT"], [NOUN, ADJ, ADV, VERB]),
]
def buat_dataset(jumlah):
data = []
for i in range(jumlah):
pola_tag, kolam_kata = TEMPLAT[i % len(TEMPLAT)]
kata = [acak.choice(kolam) for kolam in kolam_kata] + ["."]
data.append((kata, pola_tag))
acak.shuffle(data)
return data
data = buat_dataset(120)
latih, uji = data[:96], data[96:]
DAFTAR_TAG = ["<pad>", "NOUN", "VERB", "ADJ", "ADV", "NUM", "DET", "PUNCT"]
tag_ke_id = {tag: i for i, tag in enumerate(DAFTAR_TAG)}
vocab = {"<pad>": 0, "<unk>": 1}
for kata, _ in latih:
for token in kata:
if token not in vocab:
vocab[token] = len(vocab)
PANJANG_MAKS = max(len(kata) for kata, _ in latih)
PAD = 0
print(f"dataset: {len(data)} kalimat (96 latih | 24 uji) | "
f"vocab {len(vocab)} | tag {len(DAFTAR_TAG) - 1} | "
f"panjang maks {PANJANG_MAKS}")
def ubah_ke_ids(kata):
ids = [vocab.get(t, vocab["<unk>"]) for t in kata]
return ids[:PANJANG_MAKS] + [PAD] * (PANJANG_MAKS - len(ids))
X_latih = torch.tensor([ubah_ke_ids(k) for k, _ in latih])
y_latih = torch.tensor(
[[tag_ke_id[t] for t in tag] + [PAD] * (PANJANG_MAKS - len(tag))
for _, tag in latih]
)
X_uji = torch.tensor([ubah_ke_ids(k) for k, _ in uji])
y_uji = torch.tensor(
[[tag_ke_id[t] for t in tag] + [PAD] * (PANJANG_MAKS - len(tag))
for _, tag in uji]
)
# ---------- 2. Model RNN kecil: tag per token ----------
class PenandaPOS(nn.Module):
def __init__(self, jumlah_vocab, jumlah_tag, dim=16, tersembunyi=24):
super().__init__()
self.embedding = nn.Embedding(jumlah_vocab, dim, padding_idx=0)
self.rnn = nn.RNN(dim, tersembunyi, batch_first=True)
self.head = nn.Linear(tersembunyi, jumlah_tag)
def forward(self, token_ids):
vektor = self.embedding(token_ids) # (B, T, D)
keluaran, _ = self.rnn(vektor) # (B, T, H)
return self.head(keluaran) # (B, T, jumlah_tag)
model = PenandaPOS(len(vocab), len(DAFTAR_TAG))
criterion = nn.CrossEntropyLoss(ignore_index=PAD)
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
JUMLAH_EPOCH = 150
for epoch in range(1, JUMLAH_EPOCH + 1):
model.train()
optimizer.zero_grad()
logits = model(X_latih) # (B, T, jumlah_tag)
loss = criterion(
logits.reshape(-1, len(DAFTAR_TAG)), # (B*T, jumlah_tag)
y_latih.reshape(-1), # (B*T,)
)
loss.backward()
optimizer.step()
if epoch == 1 or epoch % 50 == 0:
print(f"epoch {epoch:3d} | loss latih {loss.item():.4f}")
# ---------- 3. Evaluasi token-level ----------
model.eval()
with torch.no_grad():
logits_uji = model(X_uji)
pred_uji = logits_uji.argmax(dim=2) # (B, T)
mask = y_uji != PAD
benar = (pred_uji == y_uji)[mask].sum().item()
total = mask.sum().item()
print(f"\nakurasi token-level data uji: {benar}/{total} = {benar / total:.3f}")
kalimat_baru = [
(["harga", "murah", "selalu", "turun", "."],
["NOUN", "ADJ", "ADV", "VERB", "PUNCT"]),
(["gajah", "besar", "selalu", "naik", "."],
["NOUN", "ADJ", "ADV", "VERB", "PUNCT"]),
]
print("prediksi kalimat baru:")
for kata, tag_benar in kalimat_baru:
with torch.no_grad():
pred = model(torch.tensor([ubah_ke_ids(kata)])).argmax(dim=2)[0]
tampil = []
for k, p, t in zip(kata, pred, tag_benar):
penanda = "(unk)" if k not in vocab else ""
tampil.append(f"{k}{penanda}:{DAFTAR_TAG[p]}")
benar_n = sum(int(p == tag_ke_id[t]) for p, t in zip(pred, tag_benar))
print(" " + " ".join(tampil))
print(f" benar {benar_n}/{len(kata)} token")
# ---------- 4. Token-level vs entity-level ----------
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 = [
(["Siti", "bertemu", "Budi", "di", "Surabaya", "."],
["B-PER", "O", "B-PER", "O", "B-LOC", "O"],
["B-PER", "O", "B-PER", "O", "B-PER", "O"]),
(["Rina", "bekerja", "di", "Bank", "Nusantara", "sejak", "Senin", "."],
["B-PER", "O", "O", "B-ORG", "I-ORG", "O", "B-DATE", "O"],
["B-PER", "O", "O", "B-ORG", "O", "O", "B-DATE", "O"]),
]
print("\nkontras token-level vs entity-level (2 kalimat NER kecil):")
total_token = benar_token = 0
emas, prediksi_semua = [], []
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_semua.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))
cocok = sum(1 for e in prediksi_semua if e in emas)
presisi = cocok / len(prediksi_semua)
recall = cocok / len(emas)
f1 = 2 * presisi * recall / (presisi + recall)
print(f"akurasi token-level : {benar_token}/{total_token} = "
f"{benar_token / total_token:.3f}")
print(f"presisi entity-level: {cocok}/{len(prediksi_semua)} = {presisi:.3f}")
print(f"recall entity-level : {cocok}/{len(emas)} = {recall:.3f}")
print(f"F1 entity-level : {f1:.3f}")Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji (CPU, PyTorch 2.14):
Output yang Dihasilkan:
dataset: 120 kalimat (96 latih | 24 uji) | vocab 44 | tag 7 | panjang maks 6
epoch 1 | loss latih 2.1385
epoch 50 | loss latih 0.0001
epoch 100 | loss latih 0.0001
epoch 150 | loss latih 0.0001
akurasi token-level data uji: 129/129 = 1.000
prediksi kalimat baru:
harga:NOUN murah:ADJ selalu:ADV turun:VERB .:PUNCT
benar 5/5 token
gajah(unk):ADJ besar:ADJ selalu:ADV naik:VERB .:PUNCT
benar 4/5 token
kontras token-level vs entity-level (2 kalimat NER kecil):
'Siti bertemu Budi di Surabaya .'
gold : PER 'Siti' | PER 'Budi' | LOC 'Surabaya'
pred : PER 'Siti' | PER 'Budi' | PER 'Surabaya'
'Rina bekerja di Bank Nusantara sejak Senin .'
gold : PER 'Rina' | ORG 'Bank Nusantara' | DATE 'Senin'
pred : PER 'Rina' | ORG 'Bank' | DATE 'Senin'
akurasi token-level : 12/14 = 0.857
presisi entity-level: 4/6 = 0.667
recall entity-level : 4/6 = 0.667
F1 entity-level : 0.667Mari kita telaah kode di atas baris per baris:
- Baris 20-45 (kosakata dan templat): tiap kata hanya tinggal di satu kolam tipe — “murid” selalu
NOUN, “membaca” selaluVERB— sehingga pemetaannya stabil untuk data templat. - Baris 48-62 (dataset dan kamus): 120 kalimat dibagi 96 latih dan 24 uji; vocabulary dibangun dari data latih dengan
<pad>id 0 dan<unk>id 1, tagPUNCTuntuk tanda baca. - Baris 67-78 (encoding): setiap kalimat dipadatkan ke
PANJANG_MAKS = 6token; tag di-pad dengan id 0 agar loss otomatis melewatinya. - Baris 85-95 (model RNN): embedding tiap token mengalir ke RNN; keluaran per langkah diproyeksikan
Linearmenjadi skor kelas — satu prediksi per posisi token. - Baris 99 (loss):
CrossEntropyLoss(ignore_index=PAD)merata-ratakan loss hanya atas token asli; tensor di-reshape(B*T, jumlah_tag)agar setiap posisi dihitung sebagai satu sampel. - Baris 102-114 (training loop): pola standar full-batch Adam dari chapter Training Loop ; loss mencapai 0,0001 sejak epoch 50.
- Baris 117-142 (evaluasi dan inferensi): akurasi dihitung hanya atas posisi non-padding lewat mask; dua kalimat baru diprediksi dengan penanda
(unk)untuk kata di luar vocabulary. - Baris 145-194 (kontras evaluasi): dua kalimat NER kecil dibedah dua cara — token-level versus entity-level — dengan presisi, recall, dan F1 dihitung manual.
Membaca Hasil: Sempurna di Dalam, Rapuh di Luar #
Akurasi token 1,000 pada data uji adalah perilaku yang wajar untuk dataset templat: setiap kata selalu muncul dengan tag yang sama, dan RNN menghafal pemetaan itu tanpa kesulitan. Kalimat baru pertama juga terprediksi benar seluruhnya. Kalimat kedua memperlihatkan batasnya: “gajah” tidak ada di vocabulary sehingga diubah menjadi <unk>, dan prediksinya meleset — embedding <unk> tidak pernah dilatih karena kata itu absen dari seluruh data latih. Model kata-per-kata tidak punya jalan keluar untuk kata yang benar-benar baru; solusi dunia nyata adalah fitur subkata seperti pada fastText yang dibahas di chapter Word2Vec dan fastText
.
Token-level Menyembunyikan, Entity-level Menceritakan #
Bagian akhir keluaran membandingkan dua cara menilai tag yang sama. Akurasi token mencapai 0,857 — hanya dua dari empat belas token yang salah. Namun kedua kesalahan itu menjatuhkan dua entitas utuh: “Surabaya” salah jenis (LOC diprediksi PER) dan “Bank Nusantara” kehilangan satu kata sehingga batasnya berubah. Hasilnya presisi, recall, dan F1 entity-level jatuh ke 0,667.
Angka 0,857 dan 0,667 menggambarkan kejadian yang sama — bedanya hanya cara menghitung benar. Evaluasi token memaafkan entitas yang setengah benar; evaluasi entity menuntut teks, jenis, dan batas yang persis sama dengan anotasi. Untuk aplikasi NER nyata, kesepakatan industri jatuh ke pilihan kedua karena entitas setengah benar tidak bisa dipakai di basis data. Pelaporan yang hanya menampilkan akurasi token karenanya berpotensi menyesatkan pembaca laporan.
Ketiga angka entity-level tadi — presisi, recall, F1 — hanyalah tiga dari sekian banyak metrik NLP. Chapter penutup section ini memetakan seluruh lanskap: metrik klasifikasi dan pelabelan, exact match untuk tanya-jawab, BLEU untuk teks generatif, dan perplexity untuk model bahasa.