Evaluasi untuk teks

Validasi silang terstratifikasi, ketimpangan kelas, dan trade-off presisi-recall.

Daftar isi

Angka akurasi sebuah model klasifikasi teks hanya dapat dipercaya apabila protokol evaluasinya benar. Topik ini membahas validasi silang terstratifikasi sebagai protokol utama, metrik presisi, recall, dan F1 per kelas, jebakan ketimpangan kelas, serta trade-off presisi-recall melalui penggeseran ambang keputusan. Semua demo berjalan di sandbox, dan setiap angka pada keluaran adalah hasil eksekusi nyata yang dapat direproduksi.

Validasi silang terstratifikasi#

Satu pembagian tunggal data latih dan data uji membuat skor bergantung pada dokumen mana yang jatuh ke data uji. Validasi silang (cross-validation) mengurangi ketergantungan itu dengan membagi data menjadi lima lipatan, melatih pada empat lipatan, menguji pada satu lipatan yang tersisa, memutar perannya, dan merata-ratakan hasilnya. Varian terstratifikasi menjaga proporsi kelas tetap sama di setiap lipatan, sehingga penting apabila salah satu kelas berukuran kecil.

Demo berikut memakai korpus ulasan film yang seimbang, yaitu 1000 ulasan positif dan 1000 ulasan negatif. Vektorisator dan model digabung dalam satu pipeline dengan make_pipeline agar vektorisator di-fit ulang pada data latih setiap lipatan, sehingga tidak ada informasi data uji yang bocor. Simpan programnya sebagai stratified_cv.py dan jalankan dengan perintah python3 stratified_cv.py.

python stratified_cv.py
import warnings

warnings.filterwarnings("ignore")

import numpy as np
from nltk.corpus import movie_reviews
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline

dokumen = [movie_reviews.raw(fid) for fid in movie_reviews.fileids()]
label = [movie_reviews.categories(fid)[0] for fid in movie_reviews.fileids()]

lipatan = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)

print("Komposisi kelas pada setiap lipatan pengujian:")
for i, (i_latih, i_uji) in enumerate(lipatan.split(dokumen, label), start=1):
    n_pos = sum(1 for j in i_uji if label[j] == "pos")
    print(f"Lipatan {i}: {len(i_uji)} dokumen uji, {n_pos} positif")

pipeline = make_pipeline(TfidfVectorizer(), MultinomialNB())
skor = cross_val_score(pipeline, dokumen, label, cv=lipatan, scoring="accuracy")

print()
print("Akurasi per lipatan:", skor)
print(f"Rata-rata akurasi: {skor.mean():.3f}")
print(f"Simpangan baku: {skor.std():.3f}")
text Keluaran
Komposisi kelas pada setiap lipatan pengujian:
Lipatan 1: 400 dokumen uji, 200 positif
Lipatan 2: 400 dokumen uji, 200 positif
Lipatan 3: 400 dokumen uji, 200 positif
Lipatan 4: 400 dokumen uji, 200 positif
Lipatan 5: 400 dokumen uji, 200 positif

Akurasi per lipatan: [0.8    0.7825 0.8    0.815  0.835 ]
Rata-rata akurasi: 0.807
Simpangan baku: 0.018

Keluaran memperlihatkan efek stratifikasi dengan jelas. Setiap lipatan pengujian berisi 400 dokumen dengan 200 ulasan positif, sehingga proporsi 50:50 korpus terjaga di semua lipatan. Akurasi lipatan berkisar antara 0.7825 dan 0.835, dengan rata-rata 0.807 dan simpangan baku 0.018. Rentang kecil tersebut menggambarkan ketidakpastian estimasi, sesuatu yang tidak terlihat pada skor satu pembagian tunggal.

Presisi, recall, dan F1 per kelas#

Akurasi mencampur semua kesalahan menjadi satu angka, sedangkan matriks kebingungan (confusion matrix) memecahnya per jenis. Diagram berikut menampilkan matriks tersebut untuk dua kelas, yaitu kelas positif pertanyaan dan kelas negatif komentar.

PrediksipertanyaanPrediksikomentarAktualpertanyaanAktualkomentarTPditemukan dengan benarFNterlewatFPsalah ditandaiTNbenar diabaikan
Matriks kebingungan dua kelas

Presisi dan recall membaca matriks dari dua arah. Presisi mengukur seberapa murni prediksi positif, sedangkan recall mengukur seberapa banyak kelas positif yang benar ditemukan.

presisi⁡=TPTP+FPrecall⁡=TPTP+FN \operatorname{presisi} = \frac{TP}{TP + FP} \qquad \operatorname{recall} = \frac{TP}{TP + FN}

Kedua metrik saling berhubungan dan rata-rata harmoniknya disebut skor F1. Rata-rata harmonik dipilih karena menghukum nilai yang timpang, sehingga model dengan presisi tinggi tetapi recall nyaris nol memperoleh skor F1 yang rendah.

F1=2×presisi⁡×recall⁡presisi⁡+recall⁡ F_1 = \frac{2 \times \operatorname{presisi} \times \operatorname{recall}}{\operatorname{presisi} + \operatorname{recall}}

classification_report dari scikit-learn mencetak presisi, recall, dan F1 untuk setiap kelas sekaligus dua rata-rata penutup. Macro avg merata-ratakan ketiga metrik tanpa memandang ukuran kelas, sedangkan weighted avg menimbang setiap kelas sesuai support, yaitu jumlah dokumen sebenarnya per kelas. Perbedaan keduanya akan terlihat nyata pada demo ketimpangan kelas di bagian selanjutnya.

Ketimpangan kelas membuat akurasi menyesatkan#

Ketimpangan kelas terjadi apabila satu kelas jauh lebih banyak daripada kelas lainnya, misalnya pesan pertanyaan yang jarang di antara ribuan komentar biasa. Demo berikut membangun korpus sintetis 240 pesan forum dengan proporsi 90:10 secara deterministik, yaitu 216 komentar dan 24 pertanyaan, lalu membandingkan model MultinomialNB dengan baseline yang selalu menjawab kelas mayoritas. Simpan programnya sebagai evaluasi_ketimpangan.py dan jalankan dengan perintah python3 evaluasi_ketimpangan.py.

python evaluasi_ketimpangan.py
import warnings

warnings.filterwarnings("ignore")

import random

random.seed(0)

from sklearn.dummy import DummyClassifier
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import MultinomialNB

kata_bersama = ["materi", "kelas", "minggu", "praktikum", "bab", "tugas",
                "catatan", "diskusi", "jadwal", "soal"]
kata_tanya = ["bagaimana", "mengapa", "kapan", "apa", "cara", "contoh", "bisa", "tolong"]
kata_komen = ["terima", "kasih", "siap", "hadir", "pukul", "ruang", "dikumpulkan", "selesai"]


def buat_dokumen(kata_khas, jumlah_khas, jumlah_bersama):
    kata = random.choices(kata_khas, k=jumlah_khas) + random.choices(kata_bersama, k=jumlah_bersama)
    random.shuffle(kata)
    return " ".join(kata)


dokumen = [buat_dokumen(kata_komen, 2, 6) for _ in range(216)]
dokumen += [buat_dokumen(kata_tanya, 1, 6) for _ in range(24)]
label = ["komentar"] * 216 + ["pertanyaan"] * 24

X_latih, X_uji, y_latih, y_uji = train_test_split(
    dokumen, label, test_size=0.25, random_state=0, stratify=label
)

baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(X_latih, y_latih)
print("Akurasi baseline (selalu komentar):", accuracy_score(y_uji, baseline.predict(X_uji)))

vektor = CountVectorizer()
X_latih_v = vektor.fit_transform(X_latih)
X_uji_v = vektor.transform(X_uji)

model = MultinomialNB()
model.fit(X_latih_v, y_latih)
prediksi = model.predict(X_uji_v)
print("Akurasi MultinomialNB:", accuracy_score(y_uji, prediksi))
print()
print(classification_report(y_uji, prediksi, target_names=["komentar", "pertanyaan"], zero_division=0))
text Keluaran
Akurasi baseline (selalu komentar): 0.9
Akurasi MultinomialNB: 0.9666666666666667

              precision    recall  f1-score   support

    komentar       0.96      1.00      0.98        54
  pertanyaan       1.00      0.67      0.80         6

    accuracy                           0.97        60
   macro avg       0.98      0.83      0.89        60
weighted avg       0.97      0.97      0.96        60

Angka pada keluaran menunjukkan jebakannya. Baseline yang tidak belajar apa pun dan selalu menjawab komentar mencapai akurasi 0.9, hanya sedikit di bawah akurasi 0.97 milik model. Laporan klasifikasi mengungkap detail yang tersembunyi di balik angka itu. Kelas pertanyaan hanya berukuran 6 dokumen pada data uji, dan recall-nya 0.67 berarti model hanya mengenali 4 dari 6 pesan pertanyaan tersebut. Rata-rata macro F1 sebesar 0.89 berbeda jauh dari weighted avg 0.96, dan jarak itu menjadi tanda ketimpangan yang perlu dicurigai setiap kali metrik rata-rata terlihat terlalu bagus.

Trade-off presisi dan recall#

Model probabilistik menghasilkan peluang sebelum mengambil keputusan, dan ambang keputusan (decision threshold) menentukan pada peluang berapa suatu dokumen dinyatakan positif. Nilai default ambangnya 0.5. Menurunkan ambang membuat lebih banyak dokumen dinyatakan positif sehingga recall naik dan presisi cenderung turun, sedangkan menaikkan ambang memberikan efek sebaliknya. Demo berikut menyapu beberapa ambang pada model regresi logistik dengan fitur TF-IDF di atas korpus ulasan film, memakai peluang dari predict_proba. Simpan programnya sebagai evaluasi_ambang.py dan jalankan dengan perintah python3 evaluasi_ambang.py.

python evaluasi_ambang.py
import warnings

warnings.filterwarnings("ignore")

from nltk.corpus import movie_reviews
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split

dokumen = [movie_reviews.raw(fid) for fid in movie_reviews.fileids()]
label = [movie_reviews.categories(fid)[0] for fid in movie_reviews.fileids()]

X_latih, X_uji, y_latih, y_uji = train_test_split(
    dokumen, label, test_size=0.25, random_state=0, stratify=label
)

vektor = TfidfVectorizer(min_df=2, sublinear_tf=True)
X_latih_v = vektor.fit_transform(X_latih)
X_uji_v = vektor.transform(X_uji)

model = LogisticRegression(max_iter=1000, random_state=0)
model.fit(X_latih_v, y_latih)

proba = model.predict_proba(X_uji_v)[:, 1]
print("Kelas pada kolom probabilitas:", ", ".join(model.classes_))
print()
print(" ambang | prediksi pos | presisi | recall")
for ambang in (0.2, 0.3, 0.4, 0.5, 0.6, 0.7):
    prediksi = ["pos" if p >= ambang else "neg" for p in proba]
    n = prediksi.count("pos")
    presisi = precision_score(y_uji, prediksi, pos_label="pos", zero_division=0)
    recall = recall_score(y_uji, prediksi, pos_label="pos", zero_division=0)
    print(f"{ambang:>7.1f} | {n:>12} | {presisi:>7.2f} | {recall:>6.2f}")
text Keluaran
Kelas pada kolom probabilitas: neg, pos

 ambang | prediksi pos | presisi | recall
    0.2 |          499 |    0.50 |   1.00
    0.3 |          474 |    0.53 |   1.00
    0.4 |          367 |    0.68 |   1.00
    0.5 |          248 |    0.88 |   0.87
    0.6 |          141 |    0.96 |   0.54
    0.7 |           27 |    1.00 |   0.11

Tabel pada keluaran memperlihatkan trade-off dengan utuh. Pada ambang 0.2, model menandai 499 dokumen sebagai positif dan menemukan seluruh ulasan positif dengan recall 1.00, tetapi separuh tandanya keliru karena presisi hanya 0.50. Pada ambang 0.7, tandanya nyaris selalu benar dengan presisi 1.00, tetapi hanya 27 dari 250 ulasan positif yang ditemukan sehingga recall jatuh ke 0.11. Tidak ada ambang yang terbaik secara mutlak, karena pilihannya mengikuti kebutuhan, misalnya penyaringan konten yang harus jarang melewatkan sasaran lebih mengutamakan recall, sedangkan tindakan yang mahal lebih mengutamakan presisi. Fungsi precision_recall_curve menelusuri seluruh pasangan nilai ini secara otomatis bila kurva lengkapnya diperlukan.

Ringkasan#

  • Validasi silang terstratifikasi menjaga proporsi kelas di setiap lipatan dan menghasilkan rata-rata beserta simpangan bakunya, pada demo ini 0.807 dengan simpangan baku 0.018.
  • Presisi mengukur kemurnian prediksi positif, recall mengukur cakupan kelas positif, dan F1 menjadi rata-rata harmonik keduanya per kelas.
  • Pada korpus 90:10, baseline kelas mayoritas mencapai akurasi 0.9, sehingga akurasi tunggal menyesatkan dan selisih macro avg terhadap weighted avg menjadi penanda ketimpangan.
  • Menggeser ambang keputusan menukar presisi dengan recall, dan pilihannya harus diambil pada data validasi terpisah.

Referensi#