Files
tugas-josjis/lab1_chunking.py
T
2026-11-02 11:00:37 +07:00

325 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LAB 1 - EKSPERIMEN CHUNKING (versi 3)
Membaca PDF, memotongnya dengan 3 ukuran chunk (overlap 15%), lalu
menganalisis kualitas potongan: terpotong di tengah kalimat/kata,
mengandung pola tabel, atau memuat simbol matematika yang rusak.
Dua metode dibandingkan:
- "tetap" : potong tepat per N karakter (naif, sering memutus kata/kalimat)
- "cerdas" : potong di batas kalimat terdekat (atau batas kata jika tidak
ada), dan awal chunk berikutnya selalu di awal kata.
Panjang chunk = maksimal "ukuran" karakter.
"""
import random
import re
from pathlib import Path
from pypdf import PdfReader
from pypdf.errors import PdfReadError
random.seed(42) # agar sampel acak reproducible
FOLDER = Path("dokumen")
PENGATURAN = [(500, 75), (1000, 150), (1500, 225)] # (ukuran, overlap) = 15%
JUMLAH_SAMPEL = 3
JENDELA_CARI = 0.3 # cari batas kalimat/kata di 30% terakhir sebuah chunk
# Karakter Private Use Area (simbol matematika yang rusak saat ekstraksi)
POLA_SIMBOL_RUSAK = re.compile(r"[-]")
POLA_AKHIR_KALIMAT = re.compile(r"[.!?:;][\"')\]]*$")
POLA_BATAS_KALIMAT = re.compile(r"[.!?][\"')\]]*(?=\s|$)")
POLA_SPASI = re.compile(r"\s")
POLA_KOLOM = re.compile(r"\S\s{3,}\S") # 3+ spasi di antara dua token
# ==========================================
# MEMBACA PDF
# ==========================================
def baca_pdf(path):
"""Mengembalikan (teks_gabungan, jumlah_halaman)."""
reader = PdfReader(path)
if reader.is_encrypted:
try:
reader.decrypt("")
except Exception:
raise ValueError("PDF terenkripsi dan tidak bisa dibuka tanpa password")
halaman = [(p.extract_text() or "") for p in reader.pages]
return "\n".join(halaman), len(reader.pages)
# ==========================================
# CHUNKING
# ==========================================
def _validasi(ukuran, tumpang):
if ukuran <= 0:
raise ValueError("Ukuran chunk harus > 0")
if tumpang < 0 or tumpang >= ukuran:
raise ValueError("Overlap harus 0 <= tumpang < ukuran")
def potong_tetap(teks, ukuran=800, tumpang=100):
"""Potong per N karakter. Mengembalikan daftar (mulai, akhir)."""
_validasi(ukuran, tumpang)
posisi = []
mulai = 0
langkah = ukuran - tumpang
while mulai < len(teks):
akhir = min(mulai + ukuran, len(teks))
if teks[mulai:akhir].strip():
posisi.append((mulai, akhir))
if akhir >= len(teks): # hindari chunk ekor yang isinya hanya overlap
break
mulai += langkah
return posisi
def _cari_akhir(teks, mulai, batas, ukuran):
"""Pilih titik akhir chunk (<= batas): batas kalimat > batas kata > batas keras."""
awal_cari = max(mulai + 1, batas - int(ukuran * JENDELA_CARI))
wilayah = teks[awal_cari:batas]
# 1. batas kalimat terakhir di dalam jendela
cocok = None
for cocok in POLA_BATAS_KALIMAT.finditer(wilayah):
pass
if cocok:
return awal_cari + cocok.end()
# 2. spasi/baris baru terakhir (chunk berakhir tepat di akhir sebuah kata)
spasi = None
for spasi in POLA_SPASI.finditer(wilayah):
pass
if spasi:
return awal_cari + spasi.start()
# 3. tidak ada spasi sama sekali (mis. string panjang tanpa jeda)
return batas
def potong_cerdas(teks, ukuran=800, tumpang=100):
"""Potong di batas kalimat/kata. Mengembalikan daftar (mulai, akhir)."""
_validasi(ukuran, tumpang)
n = len(teks)
posisi = []
mulai = 0
while mulai < n and teks[mulai].isspace():
mulai += 1
while mulai < n:
batas = min(mulai + ukuran, n)
akhir = n if batas >= n else _cari_akhir(teks, mulai, batas, ukuran)
if teks[mulai:akhir].strip():
posisi.append((mulai, akhir))
if akhir >= n:
break
# awal chunk berikutnya = mundur sebesar overlap, lalu geser maju
# ke awal kata terdekat supaya tidak dimulai di tengah kata
baru = max(akhir - tumpang, mulai + 1)
while baru < akhir and not teks[baru - 1].isspace():
baru += 1
if baru <= mulai:
baru = akhir
while baru < n and teks[baru].isspace():
baru += 1
mulai = baru
return posisi
METODE = {"tetap": potong_tetap, "cerdas": potong_cerdas}
# ==========================================
# ANALISIS CHUNK
# ==========================================
def analisis_chunk(teks_penuh, mulai, akhir):
chunk = teks_penuh[mulai:akhir]
isi = chunk.rstrip()
berakhir_kalimat = bool(POLA_AKHIR_KALIMAT.search(isi))
# Terpotong di tengah kata: karakter terakhir chunk dan karakter
# berikutnya di teks asli sama-sama huruf/angka.
ada_lanjutan = akhir < len(teks_penuh)
terpotong_kata = (
ada_lanjutan
and chunk[-1:].isalnum()
and teks_penuh[akhir:akhir + 1].isalnum()
)
baris = [b for b in (x.strip() for x in chunk.splitlines()) if b]
baris_tabel = sum(1 for b in baris if "|" in b or "\t" in b or POLA_KOLOM.search(b))
ada_tabel = baris_tabel >= 2
jumlah_simbol = len(POLA_SIMBOL_RUSAK.findall(chunk))
kata = isi.split()
return {
"panjang": len(chunk),
"berakhir_kalimat": berakhir_kalimat,
"terpotong_kata": terpotong_kata,
"ada_tabel": ada_tabel,
"jumlah_simbol_rusak": jumlah_simbol,
"ada_simbol_rusak": jumlah_simbol > 0,
"akhir_kata": " ".join(kata[-3:]) if kata else "",
}
def persen(bagian, total):
return f"{bagian / total * 100:.0f}%" if total else "-"
def rapikan(teks):
"""Ubah baris baru/spasi berlebih jadi satu spasi agar enak dibaca di terminal."""
return " ".join(teks.split())
# ==========================================
# PROGRAM UTAMA
# ==========================================
def main():
if not FOLDER.is_dir():
raise SystemExit(f"❌ ERROR: Folder '{FOLDER}/' tidak ditemukan!")
daftar_pdf = sorted(FOLDER.glob("*.pdf")) # sorted -> hasil konsisten
if not daftar_pdf:
raise SystemExit(f"❌ ERROR: Tidak ada file PDF di folder '{FOLDER}/'!")
print("=" * 70)
print(" LAB 1 — EKSPERIMEN CHUNKING")
print("=" * 70)
print(f"\n📁 Folder : {FOLDER.resolve()}")
print(f"📄 Jumlah PDF : {len(daftar_pdf)} file")
for p in daftar_pdf:
print(f" - {p.name}")
# ---------- Langkah 1: baca PDF ----------
pdf_path = daftar_pdf[0]
try:
teks, n_halaman = baca_pdf(pdf_path)
except (PdfReadError, ValueError, OSError) as e:
raise SystemExit(f"❌ ERROR: Gagal membaca '{pdf_path.name}': {e}")
if not teks.strip():
raise SystemExit(f"❌ ERROR: PDF '{pdf_path.name}' kosong / hasil scan (butuh OCR)!")
print("\n" + "=" * 70)
print(" LANGKAH 1 — MEMBACA PDF")
print("=" * 70)
print(f"\n📄 File yang diuji : {pdf_path.name}")
print(f"📏 Total karakter : {len(teks):,}")
print(f"📑 Jumlah halaman : {n_halaman}")
print("\n--- 500 KARAKTER PERTAMA ---")
print(teks[:500])
print("--- (akhir 500 karakter) ---")
# ---------- Langkah 2: potong ----------
print("\n" + "=" * 70)
print(" LANGKAH 2 — POTONG 3 UKURAN (OVERLAP 15%), 2 METODE")
print("=" * 70)
posisi_semua = {} # (ukuran, metode) -> daftar posisi
print(f"\n{'Ukuran':<10}{'Overlap':<10}{'Chunk (tetap)':<16}{'Chunk (cerdas)':<16}")
print("-" * 52)
for ukuran, overlap in PENGATURAN:
for nama, fungsi in METODE.items():
posisi_semua[(ukuran, nama)] = fungsi(teks, ukuran, overlap)
print(f"{ukuran:<10}{overlap:<10}"
f"{len(posisi_semua[(ukuran, 'tetap')]):<16}"
f"{len(posisi_semua[(ukuran, 'cerdas')]):<16}")
# ---------- Langkah 3: contoh chunk (metode cerdas) ----------
print("\n" + "=" * 70)
print(f" LANGKAH 3 — CONTOH {JUMLAH_SAMPEL} CHUNK ACAK TIAP UKURAN (metode cerdas)")
print("=" * 70)
for ukuran, _ in PENGATURAN:
posisi = posisi_semua[(ukuran, "cerdas")]
print(f"\n{'#' * 70}\n## UKURAN MAKS = {ukuran} KARAKTER ({len(posisi)} chunk)\n{'#' * 70}")
for i, (m, a) in enumerate(random.sample(posisi, min(JUMLAH_SAMPEL, len(posisi))), 1):
info = analisis_chunk(teks, m, a)
print(f"\n--- Contoh {i} (posisi {m}–{a}) ---")
print(f" Panjang : {info['panjang']} karakter")
print(f" Berakhir kalimat : {'✅ YA' if info['berakhir_kalimat'] else '❌ TIDAK'}")
print(f" Putus di tg. kata : {'❌ YA' if info['terpotong_kata'] else '✅ tidak'}")
print(f" Pola tabel : {'⚠️ YA' if info['ada_tabel'] else '✅ tidak'}")
simbol = f"⚠️ {info['jumlah_simbol_rusak']} simbol" if info["ada_simbol_rusak"] else "✅ tidak"
print(f" Simbol rusak : {simbol}")
print(f" Awal : {rapikan(teks[m:m + 100])}")
print(f" Akhir : ...{rapikan(teks[max(m, a - 100):a])}")
# ---------- Langkah 4: rekap (semua chunk) ----------
print("\n" + "=" * 70)
print(" LANGKAH 4 — REKAP & KESIMPULAN (dihitung dari semua chunk)")
print("=" * 70)
ringkasan = {}
for (ukuran, nama), posisi in posisi_semua.items():
semua = [analisis_chunk(teks, m, a) for m, a in posisi]
ringkasan[(ukuran, nama)] = {
"jumlah_chunk": len(semua),
"terpotong_kalimat": sum(not x["berakhir_kalimat"] for x in semua),
"terpotong_kata": sum(x["terpotong_kata"] for x in semua),
"ada_tabel": sum(x["ada_tabel"] for x in semua),
"ada_simbol_rusak": sum(x["ada_simbol_rusak"] for x in semua),
"rata_panjang": sum(x["panjang"] for x in semua) / len(semua),
}
kolom = (f"{'Ukuran':<8}{'Metode':<8}{'Chunk':<7}{'Rata2 pjg':<11}"
f"{'Putus kalimat':<16}{'Putus kata':<14}{'Tabel':<12}{'Simbol':<10}")
print("\n" + kolom)
print("-" * len(kolom))
for (ukuran, nama), r in ringkasan.items():
n = r["jumlah_chunk"]
def sel(k):
return f"{r[k]} ({persen(r[k], n)})"
print(f"{ukuran:<8}{nama:<8}{n:<7}{r['rata_panjang']:<11.0f}"
f"{sel('terpotong_kalimat'):<16}{sel('terpotong_kata'):<14}"
f"{sel('ada_tabel'):<12}{sel('ada_simbol_rusak'):<10}")
def jml(nama, k):
return sum(r[k] for (u, nm), r in ringkasan.items() if nm == nama)
tot = {nm: jml(nm, "jumlah_chunk") for nm in METODE}
jumlah_cerdas = [ringkasan[(u, "cerdas")]["jumlah_chunk"] for u, _ in PENGATURAN]
print("\n--- KESIMPULAN OTOMATIS ---")
print(f"1. Jumlah chunk (metode cerdas) per ukuran: {' → '.join(map(str, jumlah_cerdas))} "
f"({'menurun' if jumlah_cerdas == sorted(jumlah_cerdas, reverse=True) else 'tidak monoton'} "
f"seiring ukuran bertambah).")
print(f"2. Chunk putus di tengah kata: tetap {jml('tetap', 'terpotong_kata')}/{tot['tetap']} "
f"({persen(jml('tetap', 'terpotong_kata'), tot['tetap'])}) → cerdas "
f"{jml('cerdas', 'terpotong_kata')}/{tot['cerdas']} "
f"({persen(jml('cerdas', 'terpotong_kata'), tot['cerdas'])}).")
print(f"3. Chunk tidak berakhir di akhir kalimat: tetap {jml('tetap', 'terpotong_kalimat')}/{tot['tetap']} "
f"({persen(jml('tetap', 'terpotong_kalimat'), tot['tetap'])}) → cerdas "
f"{jml('cerdas', 'terpotong_kalimat')}/{tot['cerdas']} "
f"({persen(jml('cerdas', 'terpotong_kalimat'), tot['cerdas'])}).")
print(f"4. Chunk dengan pola tabel (metode cerdas): {jml('cerdas', 'ada_tabel')}/{tot['cerdas']} "
f"({persen(jml('cerdas', 'ada_tabel'), tot['cerdas'])}); "
f"dengan simbol rusak: {jml('cerdas', 'ada_simbol_rusak')}/{tot['cerdas']} "
f"({persen(jml('cerdas', 'ada_simbol_rusak'), tot['cerdas'])}).")
if jml("cerdas", "terpotong_kalimat") / tot["cerdas"] > 0.5:
print("5. Sebagian besar chunk masih tidak berakhir dengan tanda baca akhir kalimat "
"(baris PDF sering berhenti di judul/daftar/tabel); pertimbangkan pemotongan "
"per halaman atau per bagian/judul.")
else:
print("5. Metode cerdas menjaga sebagian besar chunk berakhir di batas kalimat.")
print("\n" + "=" * 70)
print(" ✅ Eksperimen chunking selesai.")
print("=" * 70)
if __name__ == "__main__":
main()