File size: 5,360 Bytes
ee140b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
172
"""Evaluate baseline vs. fine-tuned embedder across thresholds.

Run:
    python evaluate.py

Writes:
    eval_results.csv

Uses the held-out test split (data/qa_test.csv) for retrieval accuracy and
a fixed list of out-of-domain questions for rejection rate.
"""
from __future__ import annotations

import csv
import time
from pathlib import Path

import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics import f1_score, precision_score, recall_score

BASE_DIR = Path(__file__).resolve().parent
DATA = BASE_DIR / "data"

OUT_OF_DOMAIN_TESTS = [
    "Who won the football match yesterday?",
    "Explain the history of ancient Rome.",
    "How do I write a Python web scraper?",
    "What is the capital of Japan?",
    "Recommend a romantic movie.",
    "How do I fix a broken laptop screen?",
    "Who is the current president of France?",
    "Write a poem about the ocean.",
    "What is the stock price of a technology company?",
    "Explain the rules of chess.",
    "What is the boiling point of mercury?",
    "How do I install Docker on Windows?",
]

THRESHOLDS = [0.30, 0.35, 0.40, 0.42, 0.45, 0.50, 0.55, 0.60]

CONFIGS = [
    ("baseline (pretrained MiniLM)",
    "sentence-transformers/all-MiniLM-L6-v2"),
    ("fine-tuned MiniLM",
    str(BASE_DIR / "models" / "agri-minilm")),
]


def load_csv(path: Path) -> list[dict]:
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def build(embedder_path: str):
    path = embedder_path
    if path.startswith("models/") or path.startswith(str(BASE_DIR)):
        if not Path(path).exists():
            print(f"[skip] {path} missing — run train_embedder.py first")
            return None, None
    model = SentenceTransformer(path)
    test_rows = load_csv(DATA / "qa_test.csv")
    corpus = [r["answer"] for r in test_rows]
    corpus_emb = model.encode(
        corpus, normalize_embeddings=True, convert_to_numpy=True
    )
    return model, (test_rows, corpus_emb)


def evaluate(model, payload, threshold: float):
    test_rows, corpus_emb = payload

    if not test_rows:
        raise ValueError("The test dataset is empty.")

    if len(OUT_OF_DOMAIN_TESTS) == 0:
        raise ValueError("The out-of-domain test set is empty.")

    questions = [r["question"] for r in test_rows]
    expected_answers = [r["answer"] for r in test_rows]

    # Encode each in-domain question once.
    t0 = time.perf_counter()
    question_emb = model.encode(
        questions,
        normalize_embeddings=True,
        convert_to_numpy=True,
        show_progress_bar=False,
    )
    elapsed_ms = (time.perf_counter() - t0) * 1000
    times = [elapsed_ms / len(questions)] * len(questions)

    # Similarity between every question and every answer in the corpus.
    scores_in = question_emb @ corpus_emb.T
    best_indices = np.argmax(scores_in, axis=1)
    best_scores = scores_in[np.arange(len(test_rows)), best_indices]

    # Correct if the retrieved answer matches the expected answer.
    top1 = sum(
        corpus_answer == expected
        for corpus_answer, expected in zip(
            [test_rows[i]["answer"] for i in best_indices],
            expected_answers,
        )
    )

    accepted_in = int(np.sum(best_scores >= threshold))

    # Encode out-of-domain questions once.
    ood_emb = model.encode(
        OUT_OF_DOMAIN_TESTS,
        normalize_embeddings=True,
        convert_to_numpy=True,
        show_progress_bar=False,
    )
    scores_ood = ood_emb @ corpus_emb.T
    best_ood_scores = np.max(scores_ood, axis=1)
    rejected = int(np.sum(best_ood_scores < threshold))

    n = len(test_rows)
    n_ood = len(OUT_OF_DOMAIN_TESTS)

    # 1 = in-domain, 0 = out-of-domain.
    y_true = [1] * n + [0] * n_ood
    y_pred = (
        (best_scores >= threshold).astype(int).tolist()
        + (best_ood_scores >= threshold).astype(int).tolist()
    )

    p = precision_score(y_true, y_pred, zero_division=0)
    r = recall_score(y_true, y_pred, zero_division=0)
    f = f1_score(y_true, y_pred, zero_division=0)

    return {
        "top1_accuracy": round(top1 / n, 4),
        "in_domain_acceptance": round(accepted_in / n, 4),
        "ood_rejection_rate": round(rejected / n_ood, 4),
        "precision": round(p, 4),
        "recall": round(r, 4),
        "f1": round(f, 4),
        "mean_ms": round(float(np.mean(times)), 2),
    }


def main():
    results = []
    for label, path in CONFIGS:
        model, payload = build(path)
        if model is None:
            continue
        print(f"\n=== {label} ===")
        for th in THRESHOLDS:
            m = evaluate(model, payload, th)
            print(
                f"  th={th:.2f}  top1={m['top1_accuracy']:.3f}  "
                f"in={m['in_domain_acceptance']:.3f}  "
                f"ood_rej={m['ood_rejection_rate']:.3f}  "
                f"P={m['precision']:.3f}  R={m['recall']:.3f}  "
                f"F1={m['f1']:.3f}  ms={m['mean_ms']:.1f}"
            )
            results.append({"config": label, "threshold": th, **m})

    out = BASE_DIR / "eval_results.csv"
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=results[0].keys())
        w.writeheader()
        w.writerows(results)
    print(f"\n[done] written to {out}")


if __name__ == "__main__":
    main()