Dua chapter sebelumnya memetakan seluruh teks ke satu label. Named Entity Recognition (NER) bekerja lebih halus: model harus menandai potongan tertentu di dalam kalimat. Kalimat “Budi bertemu Siti di Bandung pada Senin” memuat empat informasi berharga — dua nama orang, satu nama tempat, satu waktu — dan tugas NER adalah mengembalikan pasangan (teks, jenis) untuk masing-masingnya. Aplikasinya luas: pengindeksan berita, penautan entitas ke basis data, hingga ringkasan otomatis dokumen. Chapter ini membangun dataset mini 20 kalimat ber-tag BIO, melatih classifier fitur sederhana per token, lalu membedah pola kesalahannya secara jujur.

Entitas dan Format BIO #

Jenis entitas standar mencakup PER (orang: Budi, Siti), LOC (lokasi: Jakarta, Yogyakarta), ORG (organisasi: PT Maju Jaya, Universitas Nusantara), dan DATE (tanggal: 5 Januari, Senin). Karena entitas bisa terdiri dari beberapa kata, NER memakai notasi per token: tag B-X menandai token awal entitas jenis X, I-X token lanjutannya, dan O token di luar entitas mana pun.

TagArtiContoh pada “Siti bekerja di Bank Nusantara”
B-PERawal entitas orangSiti
I-ORGlanjutan entitas organisasiNusantara
B-LOCawal entitas lokasiBandung
Obukan bagian entitasbekerja, di

Diagram berikut memetakan satu kalimat lengkap ke rangkaian tagnya:

Demo: Classifier Fitur Sederhana per Token #

Berkas ner_bio.py mengubah setiap token menjadi fitur kecil — kata itu sendiri, huruf kapital, digit, posisi awal kalimat — lalu melatih LogisticRegression untuk memprediksi tag token tersebut:

python
"""Named Entity Recognition (NER) sebagai sequence labeling per token.

Dataset mini buatan 20 kalimat bahasa Indonesia dengan entitas ORANG,
LOKASI, ORGANISASI, TANGGAL ber-tag BIO. Tiap token diubah menjadi
vektor fitur sederhana (kata, kapital, digit, posisi) lalu dilatih
dengan LogisticRegression per token. Evaluasi token-level + tampilan
prediksi satu kalimat + ekstraksi entitas dari tag BIO.
"""

import random

from sklearn.feature_extraction import DictVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

acak = random.Random(5)

ORANG = ["Budi", "Siti", "Andi", "Dewi", "Rina", "Joko"]
LOKASI = ["Jakarta", "Bandung", "Surabaya", "Yogyakarta", "Medan", "Bali"]
ORG = ["PT Maju Jaya", "PT Sinar Abadi", "Universitas Nusantara", "Bank Nusantara"]
TANGGAL = ["Senin", "Selasa", "5 Januari", "17 Agustus", "20 Mei"]


def tag_entitas(kata_list, jenis):
    """Beri tag BIO untuk satu entitas multi-token."""
    return [f"B-{jenis}"] + [f"I-{jenis}"] * (len(kata_list) - 1)


def gabungkan(bagian):
    """Rangkai list (kata, tag) berturut-turut menjadi satu kalimat."""
    kata, tag = [], []
    for k, t in bagian:
        kata.extend(k)
        tag.extend(t)
    return kata, tag


def buat_kalimat(nomor):
    o1, o2 = acak.sample(ORANG, 2)
    lokasi = acak.choice(LOKASI)
    org = acak.choice(ORG)
    tgl = acak.choice(TANGGAL)
    if nomor % 4 == 0:
        bagian = [
            ([o1], tag_entitas([o1], "PER")),
            (["bertemu"], ["O"]),
            ([o2], tag_entitas([o2], "PER")),
            (["di"], ["O"]),
            ([lokasi], tag_entitas([lokasi], "LOC")),
            (["pada"], ["O"]),
            (tgl.split(), tag_entitas(tgl.split(), "DATE")),
            (["."], ["O"]),
        ]
    elif nomor % 4 == 1:
        bagian = [
            ([o1], tag_entitas([o1], "PER")),
            (["bekerja", "di"], ["O", "O"]),
            (org.split(), tag_entitas(org.split(), "ORG")),
            (["sejak"], ["O"]),
            (tgl.split(), tag_entitas(tgl.split(), "DATE")),
            (["."], ["O"]),
        ]
    elif nomor % 4 == 2:
        bagian = [
            (org.split(), tag_entitas(org.split(), "ORG")),
            (["membuka", "kantor", "baru", "di"], ["O"] * 4),
            ([lokasi], tag_entitas([lokasi], "LOC")),
            (["."], ["O"]),
        ]
    else:
        bagian = [
            ([o1], tag_entitas([o1], "PER")),
            (["dan"], ["O"]),
            ([o2], tag_entitas([o2], "PER")),
            (["berangkat", "ke"], ["O", "O"]),
            ([lokasi], tag_entitas([lokasi], "LOC")),
            (["pada"], ["O"]),
            (tgl.split(), tag_entitas(tgl.split(), "DATE")),
            (["."], ["O"]),
        ]
    return gabungkan(bagian)


kalimat_semua = [buat_kalimat(i) for i in range(20)]

# ---------- 1. Fitur per token ----------
def fitur_token(kata, posisi):
    return {
        "kata": kata.lower(),
        "kapital": kata[0].isupper(),
        "digit": kata[0].isdigit(),
        "awal_kalimat": posisi == 0,
    }


urutan = list(range(20))
acak.shuffle(urutan)
indeks_latih, indeks_uji = urutan[:15], urutan[15:]


def kumpulkan_token(daftar_indeks):
    fitur, tag = [], []
    for i in daftar_indeks:
        kata_list, tag_list = kalimat_semua[i]
        for posisi, kata in enumerate(kata_list):
            fitur.append(fitur_token(kata, posisi))
        tag.extend(tag_list)
    return fitur, tag


X_latih, y_latih = kumpulkan_token(indeks_latih)
X_uji, y_uji = kumpulkan_token(indeks_uji)
print(f"dataset: 20 kalimat | {len(y_latih) + len(y_uji)} token | "
      f"latih {len(y_latih)} token (15 kalimat) | "
      f"uji {len(y_uji)} token (5 kalimat)")

# ---------- 2. Latih classifier per token ----------
vektor = DictVectorizer()
X_latih_v = vektor.fit_transform(X_latih)
model = LogisticRegression(max_iter=1000, random_state=5)
model.fit(X_latih_v, y_latih)
prediksi = model.predict(vektor.transform(X_uji))

akurasi = accuracy_score(y_uji, prediksi)
print(f"akurasi token-level pada data uji: {akurasi:.3f}")

benar_per_tag = {}
total_per_tag = {}
for tag_asli, tag_pred in zip(y_uji, prediksi):
    total_per_tag[tag_asli] = total_per_tag.get(tag_asli, 0) + 1
    if tag_asli == tag_pred:
        benar_per_tag[tag_asli] = benar_per_tag.get(tag_asli, 0) + 1
print("akurasi per tag (data uji):")
for tag in sorted(total_per_tag):
    benar = benar_per_tag.get(tag, 0)
    print(f"  {tag:8s} {benar:2d}/{total_per_tag[tag]:2d}")

print("token yang salah diprediksi (kata | kapital | gold -> prediksi):")
for fitur, gold, pred in zip(X_uji, y_uji, prediksi):
    if gold != pred:
        print(f"  {fitur['kata']:14s} {str(fitur['kapital']):5s} {gold} -> {pred}")

# ---------- 3. Prediksi satu kalimat penuh ----------
kata_tampil, _ = kalimat_semua[indeks_uji[0]]
fitur_tampil = [fitur_token(k, i) for i, k in enumerate(kata_tampil)]
pred_tampil = model.predict(vektor.transform(fitur_tampil))
gold_tampil = kalimat_semua[indeks_uji[0]][1]

print("\nprediksi satu kalimat (token | kapital | gold | prediksi):")
for kata, gold, pred in zip(kata_tampil, gold_tampil, pred_tampil):
    tanda = "" if gold == pred else "  <- beda"
    print(f"  {kata:14s} {str(kata[0].isupper()):5s} {gold:8s} {pred:8s}{tanda}")

# ---------- 4. Ekstraksi entitas dari tag BIO hasil prediksi ----------
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(kata)) for jenis, kata in entitas]


print("\nentitas dari prediksi kalimat di atas:")
for jenis, nama in ekstrak_entitas(kata_tampil, pred_tampil.tolist()):
    print(f"  {jenis:8s} {nama}")
print("entitas dari gold tag kalimat di atas:")
for jenis, nama in ekstrak_entitas(kata_tampil, gold_tampil):
    print(f"  {jenis:8s} {nama}")

Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji (CPU, scikit-learn 1.5.2):

Output yang Dihasilkan:

text
dataset: 20 kalimat | 180 token | latih 135 token (15 kalimat) | uji 45 token (5 kalimat)
akurasi token-level pada data uji: 0.911
akurasi per tag (data uji):
  B-DATE    2/ 4
  B-LOC     2/ 3
  B-ORG     2/ 3
  B-PER     6/ 6
  I-DATE    1/ 1
  I-ORG     5/ 5
  O        23/23
token yang salah diprediksi (kata | kapital | gold -> prediksi):
  universitas    True  B-ORG -> B-PER
  senin          True  B-DATE -> B-PER
  medan          True  B-LOC -> B-PER
  senin          True  B-DATE -> B-PER

prediksi satu kalimat (token | kapital | gold | prediksi):
  Budi           True  B-PER    B-PER   
  bekerja        False O        O       
  di             False O        O       
  PT             True  B-ORG    B-ORG   
  Maju           True  I-ORG    I-ORG   
  Jaya           True  I-ORG    I-ORG   
  sejak          False O        O       
  Selasa         True  B-DATE   B-DATE  
  .              False O        O       

entitas dari prediksi kalimat di atas:
  PER      Budi
  ORG      PT Maju Jaya
  DATE     Selasa
entitas dari gold tag kalimat di atas:
  PER      Budi
  ORG      PT Maju Jaya
  DATE     Selasa

Mari kita telaah kode di atas baris per baris:

  1. Baris 19-22 (kosakata entitas): enam nama orang, enam kota, empat organisasi, dan lima bentuk tanggal menjadi bahan isi templat.
  2. Baris 38-82 (empat templat kalimat): tiap templat merangkai entitas dan kata penghubung menjadi pasangan (token, tag) yang sejajar — “Budi” selalu berpasangan dengan B-PER, “PT” dengan B-ORG.
  3. Baris 87-94 (fitur token): empat fitur memadai sebagai ilustrasi — NER sungguhan memakai ratusan fitur (bentuk kata, awalan, tipe kata sekitarnya).
  4. Baris 96-98 (pembagian per kalimat): 15 kalimat latih dan 5 kalimat uji dipisahkan utuh — membelah token kalimat yang sama ke kedua sisi akan membuat kata tetangganya bocor ke data latih dan menaikkan akurasi secara semu.
  5. Baris 118-125 (latih dan evaluasi): DictVectorizer mengubah kamus fitur menjadi matriks sparse; akurasi token-level dihitung atas seluruh token data uji.
  6. Baris 133-141 (laporan kesalahan): akurasi per tag dan daftar token yang salah dicetak agar pola kesalahan terbaca.
  7. Baris 144-152 (prediksi satu kalimat): satu kalimat data uji ditampilkan token demi token berikut tag gold dan prediksinya.
  8. Baris 155-180 (ekstraksi entitas): fungsi ekstrak_entitas mengembalikan tag BIO menjadi daftar (jenis, teks) — bentuk yang akhirnya dipakai aplikasi.

Membaca Pola Kesalahan #

Akurasi token-level 0,911 terdengar tinggi, tetapi daftar kesalahan memperlihatkan pola yang seragam: “Universitas” (B-ORG), “Senin” (B-DATE), dan “Medan” (B-LOC) semuanya salah diprediksi menjadi B-PER. Fitur “kata” tidak menolong karena ketiga nama itu memang absen dari data latih; satu-satunya sinyal yang tersisa adalah huruf kapital, dan di data latih entitas berkata kapital yang paling sering muncul justru nama orang. Model lalu menyimpulkan aturan berlebihan: kapital berarti orang.

Kesalahan seperti ini tidak terlihat dari satu angka akurasi — diagnosa memerlukan confusion matrix per tag seperti pada chapter Confusion Matrix . Solusi praktisnya dua arah: memperkaya fitur konteks (kata sebelum dan sesudah) agar “Universitas” yang didahului kata kerja terbaca beda dari nama orang di awal kalimat, atau memakai model berurutan seperti RNN yang membaca kalimat kiri ke kanan — pokok bahasan chapter Sequence Labeling . Untuk data nyata skala besar, pendekatan modern berbasis transformer tersedia di dokumen task token classification Hugging Face .

NER hanyalah satu penghuni dari keluarga tugas yang lebih besar: setiap token kalimat mendapat tag, apa pun makna tagnya. Chapter berikutnya melebarkan pandangan ke keluarga itu — dari penandaan tipe kata sampai pengelompokan frasa — beserta cara menghitung loss dan evaluasinya.