File size: 6,746 Bytes
77c4780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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>            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 <csv> 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()