AI_Menu_Search / scripts /22_eval300.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
8.94 kB
"""
평가셋 300개 기반 검색엔진 품질 평가
=====================================
실행:
.venv/Scripts/python.exe scripts/22_eval300.py
.venv/Scripts/python.exe scripts/22_eval300.py --no-hyde
.venv/Scripts/python.exe scripts/22_eval300.py --save-xlsx # 결과 엑셀 저장
"""
import sys
import re
import json
import argparse
from pathlib import Path
from collections import defaultdict
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.search_engine import MenuSearchEngine
EVAL_PATH = Path(__file__).parent.parent / "data" / "eval_queries_300.json"
RESULT_PATH = Path(__file__).parent.parent / "data" / "eval_results_300.json"
XLSX_PATH = Path(__file__).parent.parent / "data" / "eval_results_300.xlsx"
def normalize_path(path: str) -> str:
if not path:
return ""
path = re.sub(r"\s*>\s*", ">", str(path))
path = re.sub(r"\(.*?\)", "", path)
return path.strip().lower()
def matches(pred: str, ans: str) -> bool:
if not pred or not ans:
return False
if pred == ans:
return True
pred_end = pred.split(">")[-1].strip()
ans_end = ans.split(">")[-1].strip()
if pred_end and ans_end and pred_end == ans_end:
pp = pred.split(">")
ap = ans.split(">")
return sum(1 for p in pp if p in ap) >= min(2, len(ap))
return False
def save_xlsx(detail_rows: list[dict], summary: dict, xlsx_path: Path):
"""평가 결과를 엑셀로 저장 (상세 + 요약 시트)"""
import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Font, Alignment
df = pd.DataFrame(detail_rows)
df = df[["id", "rank", "source", "결과", "menu_id", "menu_name",
"query", "정답경로", "예측1위", "예측2위", "예측3위", "예측4위", "예측5위", "mrr"]]
with pd.ExcelWriter(xlsx_path, engine="openpyxl") as writer:
df.to_excel(writer, index=False, sheet_name="상세결과")
# 요약 시트
rows_summary = [
["지표", "값"],
["평가셋 (n)", summary["n"]],
["HyDE", summary["hyde"]],
["Acc@1", f"{summary['acc1']:.1%}"],
["Acc@3", f"{summary['acc3']:.1%}"],
["Acc@5", f"{summary['acc5']:.1%}"],
["MRR@5", f"{summary['mrr']:.3f}"],
[],
["[소스별]", "Acc@1", "Acc@3", "Acc@5", "n"],
]
for src_row in summary.get("by_source", []):
rows_summary.append([src_row["source"], f"{src_row['acc1']:.1%}",
f"{src_row['acc3']:.1%}", f"{src_row['acc5']:.1%}", src_row["n"]])
rows_summary.append([])
rows_summary.append(["[순위구간별]", "Acc@1", "Acc@5", "n"])
for rk_row in summary.get("by_rank", []):
rows_summary.append([rk_row["group"], f"{rk_row['acc1']:.1%}",
f"{rk_row['acc5']:.1%}", rk_row["n"]])
ws_s = writer.book.create_sheet("요약")
for r in rows_summary:
ws_s.append(r)
# 상세 시트 서식
ws = writer.sheets["상세결과"]
col_widths = [5, 8, 10, 7, 14, 20, 38, 42, 42, 42, 42, 42, 42, 8]
for i, w in enumerate(col_widths, 1):
from openpyxl.utils import get_column_letter
ws.column_dimensions[get_column_letter(i)].width = w
green = PatternFill("solid", start_color="C6EFCE")
yellow = PatternFill("solid", start_color="FFEB9C")
red = PatternFill("solid", start_color="FFC7CE")
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
label = row[3].value # 결과 컬럼
fill = green if label == "O" else (yellow if label == "~" else red)
row[3].fill = fill
print(f"\n엑셀 저장: {xlsx_path}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--no-hyde", action="store_true", help="HyDE 비활성화")
parser.add_argument("--save-xlsx", action="store_true", help="결과 엑셀 저장")
args = parser.parse_args()
use_hyde = not args.no_hyde
with open(EVAL_PATH, encoding="utf-8") as f:
eval_set = json.load(f)
print(f"평가셋: {len(eval_set)}개 | HyDE: {'ON' if use_hyde else 'OFF'}")
print("검색엔진 초기화 중...")
engine = MenuSearchEngine.get_instance()
print("준비 완료\n")
acc1, acc3, acc5, mrr_list = [], [], [], []
wrong_cases = []
detail_rows = []
by_source = defaultdict(lambda: {"acc1": [], "acc3": [], "acc5": []})
for item in eval_set:
query = item["query"]
menu_path = item["menu_path"]
source = item["source"]
hits = engine.search(query, top_n=5, threshold=0.0, use_hyde=use_hyde)
paths = [normalize_path(h.get("menu_path", "")) for h in hits]
names = [h.get("menu_name", "") for h in hits]
ans = normalize_path(menu_path)
hit1 = matches(paths[0], ans) if paths else False
hit3 = any(matches(p, ans) for p in paths[:3])
hit5 = any(matches(p, ans) for p in paths[:5])
# MRR
rr = 0.0
for k, p in enumerate(paths[:5], 1):
if matches(p, ans):
rr = 1.0 / k
break
mrr_list.append(rr)
acc1.append(hit1)
acc3.append(hit3)
acc5.append(hit5)
by_source[source]["acc1"].append(hit1)
by_source[source]["acc3"].append(hit3)
by_source[source]["acc5"].append(hit5)
label = "O" if hit1 else ("~" if hit3 else "X")
print(f" [{label}] [{item['id']:3d}] {query[:30]:<30} -> {paths[0] if paths else '(없음)'}")
if not hit5:
wrong_cases.append({"id": item["id"], "query": query,
"expected": menu_path, "got": paths[0] if paths else ""})
# 상세 행
pad = lambda lst, n: lst + [""] * (n - len(lst))
p5 = pad(paths, 5)
detail_rows.append({
"id": item["id"], "rank": item["rank"], "source": source,
"결과": label,
"menu_id": item["menu_id"], "menu_name": item["menu_name"],
"query": query,
"정답경로": menu_path,
"예측1위": p5[0], "예측2위": p5[1], "예측3위": p5[2],
"예측4위": p5[3], "예측5위": p5[4],
"mrr": round(rr, 4),
})
n = len(acc1)
print(f"\n{'='*62}")
print(f"== 평가 결과 (n={n}) HyDE={'ON' if use_hyde else 'OFF'} ==")
print(f"{'='*62}")
print(f" Acc@1 : {sum(acc1)}/{n} = {sum(acc1)/n:.1%}")
print(f" Acc@3 : {sum(acc3)}/{n} = {sum(acc3)/n:.1%}")
print(f" Acc@5 : {sum(acc5)}/{n} = {sum(acc5)/n:.1%}")
print(f" MRR@5 : {sum(mrr_list)/n:.3f}")
print(f"{'='*62}")
# 소스별 분석
print(f"\n[소스별]")
src_rows = []
for src in ["human", "llm"]:
d = by_source[src]
if not d["acc1"]:
continue
ns = len(d["acc1"])
print(f" {src:8s} (n={ns:3d}) Acc@1={sum(d['acc1'])/ns:.1%} "
f"Acc@3={sum(d['acc3'])/ns:.1%} Acc@5={sum(d['acc5'])/ns:.1%}")
src_rows.append({"source": src, "n": ns,
"acc1": sum(d["acc1"])/ns,
"acc3": sum(d["acc3"])/ns,
"acc5": sum(d["acc5"])/ns})
# top200 vs 201+ 분석
by_rank = defaultdict(lambda: {"acc1": [], "acc5": []})
for item, a1, a5 in zip(eval_set, acc1, acc5):
grp = "top200" if item["rank"] <= 200 else "201+"
by_rank[grp]["acc1"].append(a1)
by_rank[grp]["acc5"].append(a5)
print(f"\n[순위 구간별]")
rk_rows = []
for grp in ["top200", "201+"]:
d = by_rank[grp]
if not d["acc1"]:
continue
ng = len(d["acc1"])
print(f" {grp:8s} (n={ng:3d}) Acc@1={sum(d['acc1'])/ng:.1%} Acc@5={sum(d['acc5'])/ng:.1%}")
rk_rows.append({"group": grp, "n": ng,
"acc1": sum(d["acc1"])/ng,
"acc5": sum(d["acc5"])/ng})
# Top5 완전 오답
print(f"\n[Top5 오답 {len(wrong_cases)}개]")
for w in wrong_cases[:10]:
print(f" [{w['id']:3d}] {w['query'][:28]:<28}")
print(f" 정답: {w['expected']}")
print(f" 예측: {w['got']}")
# 결과 저장
summary = {
"n": n, "hyde": "ON" if use_hyde else "OFF",
"acc1": sum(acc1)/n, "acc3": sum(acc3)/n,
"acc5": sum(acc5)/n, "mrr": sum(mrr_list)/n,
"by_source": src_rows, "by_rank": rk_rows,
}
with open(RESULT_PATH, "w", encoding="utf-8") as f:
json.dump({"summary": summary, "detail": detail_rows}, f, ensure_ascii=False, indent=2)
print(f"\n결과 JSON 저장: {RESULT_PATH}")
if args.save_xlsx:
save_xlsx(detail_rows, summary, XLSX_PATH)
if __name__ == "__main__":
main()