AgroChat / evaluate.py
mutoni1923's picture
Deploy AgroChat without large model weights
ee140b6
Raw History Blame Contribute Delete
5.36 kB
"""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()