| """Eksplorasi 1: kapan semantic (neural) menang atas lexical (BM25)? |
| |
| Temuan sebelumnya: setelah fielded indexing, MiniLM tidak menambah nilai |
| (alpha optimal -> 1.0). Hipotesis: itu karena query eval BERBAGI kosakata |
| dengan dokumen, jadi BM25 sudah cukup. Uji: query PARAPHRASE yang sengaja |
| menghindari kata di listing, dengan relevansi didefinisikan via ORACLE |
| METADATA (gender/geo/fasilitas/harga) yang tidak bergantung kata. |
| |
| Kalau neural unggul di paraphrase tapi BM25 unggul di literal -> terbukti |
| batas lexical vs semantic, dengan bukti dari data sendiri. |
| |
| Usage: cd backend && python -m scripts.explore_lexical_vs_semantic |
| """ |
| from __future__ import annotations |
| import json, sys |
| from pathlib import Path |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
| from app.indexing.loader import load_all_indexes |
| from app.preprocessing import PreprocessingPipeline |
| from app.search.gazetteer import haversine_km |
| from scripts.eval_smart import load_listings |
|
|
| ROOT = Path(__file__).resolve().parents[2] |
|
|
| |
| |
| |
| CASES = [ |
| {"intent": "kos perempuan", "literal": "kos putri", |
| "semantic": "hunian khusus muslimah", "oracle": ("gender", "putri")}, |
| {"intent": "kos laki-laki", "literal": "kos putra", |
| "semantic": "indekos untuk pria lajang", "oracle": ("gender", "putra")}, |
| {"intent": "dekat ITERA", "literal": "kos dekat itera", |
| "semantic": "tinggal dekat institut teknologi sumatera", |
| "oracle": ("geo", (-5.3668, 105.3149, 3.0))}, |
| {"intent": "dekat UNILA", "literal": "kos dekat unila", |
| "semantic": "akomodasi sekitar universitas negeri lampung", |
| "oracle": ("geo", (-5.3645, 105.2434, 3.0))}, |
| {"intent": "ada AC", "literal": "kos ac", |
| "semantic": "kamar dengan pendingin ruangan sejuk", "oracle": ("fac", "ac")}, |
| {"intent": "ada wifi", "literal": "kos wifi", |
| "semantic": "tersedia koneksi internet nirkabel", "oracle": ("fac", "wifi")}, |
| {"intent": "murah", "literal": "kos murah", |
| "semantic": "sewa kamar ramah kantong ekonomis", "oracle": ("price", 600000)}, |
| {"intent": "kamar mandi dalam", "literal": "kos kamar mandi dalam", |
| "semantic": "toilet pribadi di dalam kamar", "oracle": ("fac", "mandi")}, |
| ] |
|
|
|
|
| def relevant(row, oracle) -> bool: |
| kind, val = oracle |
| if kind == "gender": |
| return row.tipe == val |
| if kind == "geo": |
| if row.koordinat_lat is None: |
| return False |
| lat, lng, km = val |
| return haversine_km(float(row.koordinat_lat), float(row.koordinat_lng), lat, lng) <= km |
| if kind == "fac": |
| return any(val in str(f).lower() for f in (row.fasilitas or [])) |
| if kind == "price": |
| return (row.harga_per_bulan or 10**9) <= val |
| return False |
|
|
|
|
| def p_at_k(ranked_ids, listings, oracle, k=5) -> float: |
| top = ranked_ids[:k] |
| if not top: |
| return 0.0 |
| return sum(1 for d in top if d in listings and relevant(listings[d], oracle)) / len(top) |
|
|
|
|
| def main() -> int: |
| idx = load_all_indexes(ROOT / "data" / "indexes", include_neural=True) |
| bm25, tfidf, neural = idx["bm25"], idx["tfidf"], idx["indobert"] |
| pipe = PreprocessingPipeline() |
| pre = lambda s: pipe.process(s).processed |
| listings = load_listings() |
|
|
| def bm25_ids(q): return [h.doc_id for h in bm25.query(pre(q), top_k=5)] |
| def tfidf_ids(q): return [h.doc_id for h in tfidf.query(pre(q), top_k=5)] |
| def neural_ids(q): return [h.doc_id for h in neural.query(q, top_k=5)] |
|
|
| agg = {"literal": {"bm25": [], "neural": [], "tfidf": []}, |
| "semantic": {"bm25": [], "neural": [], "tfidf": []}} |
| print(f"{'intent':<22} {'mode':<9} {'BM25':>5} {'TFIDF':>6} {'Neural':>7}") |
| for c in CASES: |
| for mode in ("literal", "semantic"): |
| q = c[mode] |
| pb = p_at_k(bm25_ids(q), listings, c["oracle"]) |
| pt = p_at_k(tfidf_ids(q), listings, c["oracle"]) |
| pn = p_at_k(neural_ids(q), listings, c["oracle"]) |
| agg[mode]["bm25"].append(pb); agg[mode]["tfidf"].append(pt); agg[mode]["neural"].append(pn) |
| print(f"{c['intent']:<22} {mode:<9} {pb:>5.2f} {pt:>6.2f} {pn:>7.2f}") |
|
|
| print("\n=== RATA-RATA P@5 ===") |
| print(f"{'mode':<10} {'BM25':>6} {'TFIDF':>6} {'Neural':>7}") |
| out = {} |
| for mode in ("literal", "semantic"): |
| n = len(agg[mode]["bm25"]) |
| mb = sum(agg[mode]["bm25"]) / n |
| mt = sum(agg[mode]["tfidf"]) / n |
| mn = sum(agg[mode]["neural"]) / n |
| out[mode] = {"bm25": mb, "tfidf": mt, "neural": mn} |
| print(f"{mode:<10} {mb:>6.3f} {mt:>6.3f} {mn:>7.3f}") |
| gap_lit = out["literal"]["neural"] - out["literal"]["bm25"] |
| gap_sem = out["semantic"]["neural"] - out["semantic"]["bm25"] |
| print(f"\nGap neural-BM25 @ literal : {gap_lit:+.3f}") |
| print(f"Gap neural-BM25 @ semantic: {gap_sem:+.3f}") |
| print("INTERPRETASI:", "neural unggul saat kosakata MISMATCH (semantic)" |
| if gap_sem > gap_lit else "neural tidak menolong walau paraphrase") |
| (ROOT / "eval" / "explore_lexical_vs_semantic.json").write_text( |
| json.dumps(out, indent=2), encoding="utf-8") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|