r"""Evaluation harness for the supervisor recommendation system. Computes ranking metrics (Hit@k, Precision@k, Recall@k, MRR, nDCG@k, MAP) over a ground-truth set of (research_title -> actual_supervisor) pairs, in-process (loads artifacts once, then loops recommend_top_k). Ground-truth sources: --input CSV with a title column and an actual-supervisor column. Configure with --title-col / --actual-col. --from-publications Build an eval set from data/publications.csv using judul_publikasi as the query title and dosen as the actual supervisor(s). This is a PROXY (publications are not thesis supervisions) and is not leave-one-out, so treat the numbers as an upper-bound sanity check. Usage (run from backend/ with the project venv): ..\.venv\Scripts\python.exe scripts\evaluate_recommendations.py --from-publications --sample 300 ..\.venv\Scripts\python.exe scripts\evaluate_recommendations.py --input eval.csv --title-col judul --actual-col dosen """ from __future__ import annotations import argparse import os import sys import time import numpy as np import pandas as pd BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if BACKEND_DIR not in sys.path: sys.path.insert(0, BACKEND_DIR) from services.artifacts import load_artifacts # noqa: E402 from services.evaluation import ( # noqa: E402 average_precision, hit_at_k, ndcg_at_k, normalize_name, precision_at_k, recall_at_k, reciprocal_rank, split_actual_names, ) from services.recommender import recommend_top_k # noqa: E402 def build_eval_set(args) -> pd.DataFrame: if args.from_publications: path = args.publications or os.path.join(BACKEND_DIR, "data", "publications.csv") df = pd.read_csv(path) title_col = "judul_publikasi" if "judul_publikasi" in df.columns else args.title_col actual_col = "dosen" if "dosen" in df.columns else args.actual_col else: if not args.input: raise SystemExit("Sediakan --input atau gunakan --from-publications") df = pd.read_csv(args.input) title_col, actual_col = args.title_col, args.actual_col for col in (title_col, actual_col): if col not in df.columns: raise SystemExit(f"Kolom '{col}' tidak ada. Kolom tersedia: {list(df.columns)}") out = df[[title_col, actual_col]].rename(columns={title_col: "title", actual_col: "actual"}) out = out.dropna(subset=["title", "actual"]) out = out[out["title"].astype(str).str.strip().str.len() >= 3] out = out[out["actual"].astype(str).str.strip().str.len() >= 1].reset_index(drop=True) if args.sample and args.sample < len(out): out = out.sample(n=args.sample, random_state=args.seed).reset_index(drop=True) return out def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--input", default="") parser.add_argument("--from-publications", action="store_true") parser.add_argument("--publications", default="") parser.add_argument("--title-col", default="title") parser.add_argument("--actual-col", default="actual") parser.add_argument("--ks", default="1,3,5,10") parser.add_argument("--sample", type=int, default=0) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--artifacts-dir", default=os.path.join(BACKEND_DIR, "models")) parser.add_argument("--data-dir", default=os.path.join(BACKEND_DIR, "data")) parser.add_argument("--output", default=os.path.join(BACKEND_DIR, "scripts", "eval_details.csv")) args = parser.parse_args() ks = sorted({int(x) for x in args.ks.split(",") if x.strip()}) max_k = max(ks) eval_df = build_eval_set(args) print(f"Eval set: {len(eval_df)} item | k = {ks}") # Avoid the PKL/NOTEBOOK loader; use the default split-lang loader. os.environ.pop("ARTIFACTS_MODE", None) print("Loading artifacts...") store = load_artifacts(artifacts_dir=args.artifacts_dir, data_dir=args.data_dir) # Accumulators per-k agg = {k: {"hit": 0.0, "p": 0.0, "r": 0.0, "ndcg": 0.0, "ap": 0.0} for k in ks} mrr_sum = 0.0 rows = [] latencies = [] n_empty = 0 for i, row in eval_df.iterrows(): title = str(row["title"]) actual = set(split_actual_names(row["actual"])) t0 = time.perf_counter() rec = recommend_top_k(store=store, title=title, top_k=max_k) latencies.append((time.perf_counter() - t0) * 1000.0) ranked = [normalize_name(r["nama_dosen"]) for r in rec.get("recommendations", [])] if not ranked: n_empty += 1 rr = reciprocal_rank(ranked, actual) mrr_sum += rr detail = { "title": title[:120], "actual": "; ".join(sorted(actual)), "predicted_top": "; ".join(ranked[:max_k]), "detected_lang": rec.get("detected_lang"), "closest_topic_id": rec.get("closest_topic_id"), "rr": round(rr, 4), } for k in ks: agg[k]["hit"] += hit_at_k(ranked, actual, k) agg[k]["p"] += precision_at_k(ranked, actual, k) agg[k]["r"] += recall_at_k(ranked, actual, k) agg[k]["ndcg"] += ndcg_at_k(ranked, actual, k) agg[k]["ap"] += average_precision(ranked, actual, k) detail[f"hit@{k}"] = int(hit_at_k(ranked, actual, k)) rows.append(detail) if (i + 1) % 50 == 0: print(f" {i + 1}/{len(eval_df)} processed...") n = len(eval_df) denom = float(n) if n else 1.0 print("\n" + "=" * 78) print(f"HASIL EVALUASI (n={n}, empty_recs={n_empty})") print("=" * 78) header = f"{'k':>3} | {'Hit@k':>7} | {'Prec@k':>7} | {'Recall@k':>8} | {'nDCG@k':>7} | {'MAP@k':>7}" print(header) print("-" * len(header)) for k in ks: a = agg[k] print(f"{k:>3} | {a['hit']/denom:>7.4f} | {a['p']/denom:>7.4f} | " f"{a['r']/denom:>8.4f} | {a['ndcg']/denom:>7.4f} | {a['ap']/denom:>7.4f}") print("-" * len(header)) print(f"MRR (global): {mrr_sum/denom:.4f}") if latencies: lat = np.array(latencies) print(f"Latensi /recommend (in-process): mean={lat.mean():.1f}ms " f"p50={np.percentile(lat,50):.1f}ms p95={np.percentile(lat,95):.1f}ms") details_df = pd.DataFrame(rows) details_df.to_csv(args.output, index=False, encoding="utf-8-sig") print(f"\nDetail per-item disimpan: {args.output}") if __name__ == "__main__": main()