This commit is contained in:
2026-11-02 11:13:19 +07:00
commit 6a4c7fa4c6
31 changed files with 3036 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
"""Lab 3 - Ukur Hit@k (25 menit).
1. 15 pertanyaan uji beserta kata kunci jawaban dari dokumen.
2. hit_at_k untuk k = 1, 3, 5 pada dua koleksi dengan ukuran chunk berbeda.
3. Kesimpulan 3-4 kalimat: ukuran terbaik, pertanyaan yang gagal, dugaan penyebab.
Versi ini memakai ketiga PDF tentang kecerdasan buatan (lihat DAFTAR_PDF).
"""
import os
import re
import shutil
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from laporan_utils import Laporan
from rag_utils import FOLDER_DOKUMEN, baca_pdf, bangun_indeks, query
BASE = os.path.dirname(os.path.abspath(__file__))
OVERLAP = 0.15
UKURAN = (500, 1000) # dua koleksi yang dibandingkan
KS = (1, 3, 5)
P1 = "1_Pengenalan_Kecerdasan_Buatan.pdf"
P2 = "2_Machine_Learning_dan_Deep_Learning.pdf"
P3 = "3_Etika_Risiko_dan_Masa_Depan_AI.pdf"
DAFTAR_PDF = [P1, P2, P3]
# q = pertanyaan, kw = kata kunci yang harus ada (semuanya) di chunk jawaban,
# jenis = langsung (kata pertanyaan mirip teks) / parafrase (kata sengaja berbeda),
# file = PDF tempat jawaban berada. 5 soal per dokumen.
SOAL = [
{"q": "Siapa yang memperkenalkan istilah kecerdasan buatan pada Konferensi Dartmouth?",
"kw": ["McCarthy"], "jenis": "langsung", "file": P1},
{"q": "Siapa yang mengusulkan Tes Turing?",
"kw": ["Alan Turing"], "jenis": "langsung", "file": P1},
{"q": "Mesin buatan IBM yang menundukkan Kasparov di papan catur bernama apa?",
"kw": ["Deep Blue"], "jenis": "parafrase", "file": P1},
{"q": "Jenis AI yang hanya mampu mengerjakan satu tugas spesifik disebut apa?",
"kw": ["Narrow AI"], "jenis": "parafrase", "file": P1},
{"q": "Apa itu Natural Language Processing?",
"kw": ["pembuatan bahasa manusia"], "jenis": "langsung", "file": P1},
{"q": "Apa itu overfitting?",
"kw": ["hafal data latih"], "jenis": "langsung", "file": P2},
{"q": "Algoritma mana yang dipakai untuk membagi data ke dalam kelompok-kelompok?",
"kw": ["K-Means"], "jenis": "parafrase", "file": P2},
{"q": "Arsitektur apa yang dipakai untuk pengolahan gambar dan video?",
"kw": ["CNN"], "jenis": "langsung", "file": P2},
{"q": "Apa sebutan untuk model yang terlalu sederhana sehingga gagal menangkap pola?",
"kw": ["Underfitting"], "jenis": "parafrase", "file": P2},
{"q": "Arsitektur apa yang menjadi dasar model bahasa besar?",
"kw": ["Transformer"], "jenis": "langsung", "file": P2},
{"q": "Apa istilah untuk informasi keliru yang disampaikan model bahasa dengan nada meyakinkan?",
"kw": ["Halusinasi"], "jenis": "parafrase", "file": P3},
{"q": "Apa yang dimaksud dengan pelatihan ulang keterampilan?",
"kw": ["reskilling"], "jenis": "langsung", "file": P3},
{"q": "Mengapa keputusan penting tidak boleh diserahkan sepenuhnya kepada mesin?",
"kw": ["Pengawasan manusia"], "jenis": "parafrase", "file": P3},
{"q": "Teknologi apa yang menjalankan model langsung di perangkat milik pengguna?",
"kw": ["on-device"], "jenis": "parafrase", "file": P3},
{"q": "Isu etika apa yang muncul dari pemakaian karya orang lain sebagai bahan pelatihan model?",
"kw": ["Hak cipta"], "jenis": "parafrase", "file": P3},
]
# --------------------------------------------------------------------------- utilitas
def siapkan_dokumen():
"""Pastikan ketiga PDF ada di FOLDER_DOKUMEN; salin dari folder skrip/kerja bila belum ada."""
os.makedirs(FOLDER_DOKUMEN, exist_ok=True)
for nama in DAFTAR_PDF:
tujuan = os.path.join(FOLDER_DOKUMEN, nama)
if os.path.isfile(tujuan):
continue
for d in (BASE, os.getcwd()):
asal = os.path.join(d, nama)
if os.path.isfile(asal):
shutil.copy(asal, tujuan)
print(f" disalin: {nama} -> {FOLDER_DOKUMEN}")
break
else:
print(f" [PERINGATAN] {nama} tidak ditemukan di {FOLDER_DOKUMEN}, {BASE}, maupun {os.getcwd()}")
def rapikan(t):
"""Buang baris footer 'Halaman N', ratakan spasi/baris baru, huruf kecil."""
t = re.sub(r"^[ \t]*Halaman \d+[ \t]*$", " ", t, flags=re.MULTILINE)
return " ".join(t.split()).lower()
def cocok_kw(teks, kw):
"""True bila SEMUA kata kunci ada di teks (abaikan huruf besar/kecil dan pemisah baris)."""
t = rapikan(teks)
return all(k.lower() in t for k in kw)
STOPWORD = {"yang", "untuk", "dengan", "dalam", "pada", "dari", "atau", "dapat", "adalah", "apakah", "apa",
"siapa", "mana", "bagaimana", "mengapa", "tidak", "juga", "sebagai", "oleh", "kepada", "akan",
"data", "model", "itu", "ini", "serta", "agar", "karena", "hanya", "lebih"}
def kata(t):
"""Kata bermakna (>=4 huruf, bukan kata tugas) untuk menghitung kemiripan kosakata."""
return set(re.findall(r"[a-z]{4,}", rapikan(t))) - STOPWORD
def peringkat(idx, s):
"""Peringkat chunk pertama yang memuat semua kata kunci di SELURUH indeks (None bila tidak ada)."""
for r, a in enumerate(query(idx, s["q"], len(idx.chunks)), 1):
if cocok_kw(a["teks"], s["kw"]):
return r
return None
def hit_at_k(rank_list, k):
"""Proporsi soal yang chunk relevannya muncul di k teratas."""
return sum(r is not None and r <= k for r in rank_list) / len(rank_list)
def diagnosis(idx, s, r, k=5):
"""Penyebab gagal, dihitung dari data indeks (bukan tebakan)."""
ada = [c for c in idx.chunks if cocok_kw(c, s["kw"])]
if not ada:
return "kata kunci terbelah di batas chunk (tidak ada satu chunk pun yang memuatnya utuh)"
bersama = kata(s["q"]) & kata(ada[0])
info = (f"pertanyaan berbagi {len(bersama)} kata bermakna dengan chunk jawaban"
+ (f": {', '.join(sorted(bersama))}" if bersama else ""))
return f"chunk jawaban ada tetapi di peringkat {r} (> {k}); {info}"
# --------------------------------------------------------------------------- utama
def main():
print("=== Persiapan dokumen ===")
siapkan_dokumen()
# Sanity check 1: setiap kata kunci ada di PDF yang dimaksud
teks_file = {}
for f in DAFTAR_PDF:
p = os.path.join(FOLDER_DOKUMEN, f)
teks_file[f] = baca_pdf(p) if os.path.isfile(p) else ""
salah = [s for s in SOAL if not cocok_kw(teks_file.get(s["file"], ""), s["kw"])]
for s in salah:
print(f" [PERINGATAN] kata kunci {s['kw']} tidak ada di {s['file']}: {s['q']}")
if salah:
raise SystemExit("Perbaiki kata kunci pada SOAL lebih dulu; soal tersebut tidak mungkin terjawab.")
koleksi, ranks, hasil = {}, {}, {}
for u in UKURAN:
print(f"\n=== Koleksi ukuran {u} ===")
koleksi[u] = bangun_indeks(ukuran=u, overlap=OVERLAP, backend="auto")
ranks[u] = [peringkat(koleksi[u], s) for s in SOAL]
hasil[u] = {k: hit_at_k(ranks[u], k) for k in KS}
print({f"Hit@{k}": round(v, 3) for k, v in hasil[u].items()})
# Sanity check 2: kata kunci utuh dalam minimal satu chunk (batas atas Hit@k)
terbelah = [i for i, s in enumerate(SOAL, 1) if not any(cocok_kw(c, s["kw"]) for c in koleksi[u].chunks)]
if terbelah:
print(f" [PERINGATAN] soal no. {terbelah} tidak punya chunk yang memuat kata kunci utuh "
f"pada ukuran {u}; Hit@k tidak mungkin 1.0")
backend = koleksi[UKURAN[0]].backend
n_chunk = {u: len(koleksi[u].chunks) for u in UKURAN}
n_soal = len(SOAL)
n_lang = sum(s["jenis"] == "langsung" for s in SOAL)
n_para = n_soal - n_lang
# ---- kesimpulan: setiap klaim dihitung dari data
a, b = UKURAN
kunci = lambda u: (hasil[u][5], hasil[u][3], hasil[u][1])
terbaik = a if kunci(a) > kunci(b) else b if kunci(b) > kunci(a) else None
fmt = lambda u: "/".join(f"{hasil[u][k]:.2f}" for k in KS)
if terbaik:
lain = b if terbaik == a else a
s1 = (f"Ukuran {terbaik} karakter lebih baik: Hit@1/3/5 = {fmt(terbaik)} dibanding {fmt(lain)} pada "
f"ukuran {lain}.")
else:
s1 = f"Kedua ukuran seimbang: Hit@1/3/5 = {fmt(a)} (ukuran {a}) dan {fmt(b)} (ukuran {b})."
gagal = {u: [i for i, r in enumerate(ranks[u], 1) if r is None or r > 5] for u in UKURAN}
s2 = "Pada k=5, pertanyaan yang gagal: " + "; ".join(
f"ukuran {u} -> " + (", ".join(f"no. {i}" for i in gagal[u]) if gagal[u] else "tidak ada")
for u in UKURAN) + "."
penyebab = {(u, i): diagnosis(koleksi[u], SOAL[i - 1], ranks[u][i - 1])
for u in UKURAN for i in gagal[u]}
print("\nDiagnosis gagal (k=5):")
for (u, i), c in penyebab.items():
print(f" ukuran {u}, no. {i} ({SOAL[i - 1]['q']}): {c}")
if penyebab:
s3 = "Dugaan penyebab (dari diagnosis otomatis): " + "; ".join(
f"ukuran {u} no. {i} - {c}" for (u, i), c in penyebab.items()) + "."
else:
s3 = "Tidak ada kegagalan pada k=5, sehingga tidak ada penyebab yang perlu dianalisis."
# per jenis soal, Hit@5 dan Hit@1
per_jenis = {}
for jenis in ("langsung", "parafrase"):
idxs = [i for i, s in enumerate(SOAL) if s["jenis"] == jenis]
per_jenis[jenis] = (len(idxs), {u: (hit_at_k([ranks[u][i] for i in idxs], 1),
hit_at_k([ranks[u][i] for i in idxs], 5)) for u in UKURAN})
acak = "; ".join(f"ukuran {u}: {n_chunk[u]} chunk, peluang kebetulan pada k=5 sekitar {min(1, 5 / n_chunk[u]):.0%}"
for u in UKURAN)
s4 = f"Catatan: korpus hanya {len(DAFTAR_PDF)} dokumen kecil ({acak})"
if all(hasil[u][5] >= 0.999 for u in UKURAN):
s4 += ", sehingga Hit@5 yang sempurna belum membuktikan banyak; perbedaan antar ukuran lebih berarti " \
"dilihat pada Hit@1 dan pada korpus yang lebih besar."
else:
s4 += ", sehingga satu soal saja menggeser skor sekitar " \
f"{1 / n_soal:.0%}; gunakan korpus lebih besar sebelum menarik kesimpulan kuat."
kesimpulan = " ".join([s1, s2, s3, s4])
print("\nKesimpulan:", kesimpulan)
# ---- grafik
fig, ax = plt.subplots(figsize=(6.4, 3.2), dpi=150)
lebar = 0.8 / len(UKURAN)
for j, u in enumerate(UKURAN):
xs = [i + (j - (len(UKURAN) - 1) / 2) * lebar for i in range(len(KS))]
bars = ax.bar(xs, [hasil[u][k] for k in KS], lebar, label=f"ukuran {u}")
ax.bar_label(bars, fmt="%.2f", fontsize=8)
ax.set_xticks(range(len(KS)), [f"Hit@{k}" for k in KS])
ax.set_ylim(0, 1.12)
ax.set_ylabel("proporsi soal")
ax.set_title(f"Hit@k pada {n_soal} soal (overlap {int(OVERLAP * 100)}%, backend {backend})", fontsize=10)
ax.legend(loc="lower right", fontsize=8)
fig.tight_layout()
gambar = os.path.join(BASE, "_grafik_hit_at_k.png")
fig.savefig(gambar)
plt.close(fig)
# ---- laporan PDF
W = 28.35
L = Laporan(os.path.join(BASE, "Lab3_Hit_at_k.pdf"), "Lab 3 - Ukur Hit@k",
f"backend: {backend} | overlap {int(OVERLAP * 100)}% | koleksi ukuran {UKURAN[0]} dan {UKURAN[1]}")
L.h1(f"Langkah 1. {n_soal} pertanyaan uji")
L.p(f"Soal dibuat dari {len(DAFTAR_PDF)} dokumen di folder dokumen/ (5 soal per dokumen). Sebuah chunk dianggap "
f"benar bila memuat semua kata kunci jawaban. Sebanyak {n_lang} soal bernada langsung dan {n_para} soal "
"diparafrase (kata-katanya sengaja berbeda dari teks).")
L.tabel([["No", "Jenis", "Pertanyaan", "Kata kunci", "File"]] +
[[i, s["jenis"], s["q"], ", ".join(s["kw"]), s["file"].split("_")[0]] for i, s in enumerate(SOAL, 1)],
[1 * W, 2 * W, 9 * W, 3.5 * W, 1.5 * W])
L.h1("Langkah 2. Hasil Hit@k")
L.tabel([["Koleksi"] + [f"Hit@{k}" for k in KS] + ["Jumlah chunk"]] +
[[f"ukuran {u}"] + [f"{hasil[u][k]:.3f}" for k in KS] + [n_chunk[u]] for u in UKURAN],
[4 * W, 2.5 * W, 2.5 * W, 2.5 * W, 3 * W])
L.gambar(gambar, 13)
L.h2("Hit@1 dan Hit@5 menurut jenis soal")
L.tabel([["Jenis soal", "Jumlah"] + [f"ukuran {u} (Hit@1 / Hit@5)" for u in UKURAN]] +
[[j, n] + [f"{v[u][0]:.2f} / {v[u][1]:.2f}" for u in UKURAN] for j, (n, v) in per_jenis.items()],
[3.5 * W, 2 * W, 5 * W, 5 * W])
L.h2("Peringkat chunk pertama yang memuat jawaban, per soal")
L.p("Angka 1 berarti chunk teratas sudah benar. Angka > 5 atau '-' berarti gagal pada Hit@5.")
L.tabel([["No", "Jenis", f"Peringkat (ukuran {UKURAN[0]})", f"Peringkat (ukuran {UKURAN[1]})"]] +
[[i, s["jenis"]] + [ranks[u][i - 1] if ranks[u][i - 1] is not None else "-" for u in UKURAN]
for i, s in enumerate(SOAL, 1)],
[1.2 * W, 3 * W, 5 * W, 5 * W])
if penyebab:
L.h2("Diagnosis soal yang gagal (k=5)")
L.tabel([["Ukuran", "No", "Pertanyaan", "Diagnosis"]] +
[[u, i, SOAL[i - 1]["q"], c] for (u, i), c in penyebab.items()], [1.5 * W, 1 * W, 6 * W, 8.5 * W])
L.h1("Langkah 3. Kesimpulan")
L.p(kesimpulan)
L.halaman_baru()
L.h1(f"Lampiran: kode {os.path.basename(__file__)}")
L.kode(open(__file__, encoding="utf-8").read())
L.simpan()
os.remove(gambar)
print("\nLaporan: Lab3_Hit_at_k.pdf")
if __name__ == "__main__":
main()