AI_Menu_Search / scripts /21_build_eval300.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
8.31 kB
"""
평가셋 300개 구축 스크립트
===========================
top200 메뉴에서 270개 (메뉴별 균등 분산) + 201위이하에서 30개 = 300개
핵심 원칙: 뽑힌 300개는 쿼리 인덱스에서 제거 (데이터 누수 방지)
실행:
.venv/Scripts/python.exe scripts/21_build_eval300.py
.venv/Scripts/python.exe scripts/21_build_eval300.py --seed 42 # 재현 가능
.venv/Scripts/python.exe scripts/21_build_eval300.py --dry-run # 통계만
"""
import sys
import re
import json
import random
import argparse
from pathlib import Path
from collections import defaultdict
sys.path.insert(0, str(Path(__file__).parent.parent))
from openpyxl import load_workbook
from openpyxl.cell.rich_text import CellRichText, TextBlock
EXCEL_PATH = Path(__file__).parent.parent / "data" / "MenuSearch_review_all_20260525_edit.xlsx"
EVAL_OUT = Path(__file__).parent.parent / "data" / "eval_queries_300.json"
INDEX_REMAINING_OUT = Path(__file__).parent.parent / "data" / "query_index_pool.json"
TOP200_N = 270 # top200에서 뽑을 수
OUTSIDE_N = 30 # 201+에서 뽑을 수
LLM_COL = 8
HUMAN_COL = 9
def parse_q(text: str) -> list[str]:
if not text or str(text).strip() in ("", "nan"):
return []
text = str(text).strip()
parts = re.split(r"\n?\s*\d+[.)]\s+", text)
if len(parts) > 1:
return [p.strip() for p in parts if len(p.strip()) >= 4]
return [p.strip() for p in re.split(r"\s*/\s*|\n", text) if len(p.strip()) >= 4]
def extract_kept(val) -> list[str]:
"""취소선 제외하고 유효 텍스트만 반환"""
if val is None:
return []
if isinstance(val, CellRichText):
kept = []
for block in val:
if isinstance(block, TextBlock):
if not (block.font and block.font.strike):
kept.append(str(block.text))
elif isinstance(block, str):
kept.append(block)
return parse_q("".join(kept))
return parse_q(str(val))
def load_all_queries(excel_path: Path) -> tuple[list[dict], list[dict]]:
"""top200 쿼리와 201+ 쿼리를 분리해서 반환"""
wb = load_workbook(excel_path, rich_text=True)
ws = wb.active
top200, outside = [], []
for row_idx in range(2, ws.max_row + 1):
rank = ws.cell(row_idx, 1).value
menu_id = ws.cell(row_idx, 2).value
menu_name = ws.cell(row_idx, 3).value
menu_path = ws.cell(row_idx, 4).value
if not menu_id or str(menu_id).strip() in ("", "nan"):
continue
try:
rank_int = int(rank)
except (TypeError, ValueError):
rank_int = 9999
menu_id = str(menu_id).strip()
menu_name = str(menu_name).strip() if menu_name else ""
menu_path = str(menu_path).strip() if menu_path else ""
llm_qs = extract_kept(ws.cell(row_idx, LLM_COL).value)
human_qs = extract_kept(ws.cell(row_idx, HUMAN_COL).value)
for q in human_qs:
item = dict(query=q, menu_id=menu_id, menu_name=menu_name,
menu_path=menu_path, rank=rank_int, source="human")
(top200 if rank_int <= 200 else outside).append(item)
for q in llm_qs:
item = dict(query=q, menu_id=menu_id, menu_name=menu_name,
menu_path=menu_path, rank=rank_int, source="llm")
(top200 if rank_int <= 200 else outside).append(item)
return top200, outside
def stratified_sample(queries: list[dict], n: int, rng: random.Random) -> list[dict]:
"""
메뉴별로 균등하게 n개 샘플링.
각 메뉴에서 최소 1개 보장 후, 남은 슬롯은 쿼리 수 비례 배분.
human 쿼리를 llm보다 우선 선택.
"""
# 메뉴별 그룹화 (human 먼저 정렬)
by_menu: dict[str, list[dict]] = defaultdict(list)
for item in queries:
by_menu[item["menu_id"]].append(item)
# 각 메뉴 내에서 human 우선 셔플
for mid in by_menu:
human = [x for x in by_menu[mid] if x["source"] == "human"]
llm = [x for x in by_menu[mid] if x["source"] == "llm"]
rng.shuffle(human)
rng.shuffle(llm)
by_menu[mid] = human + llm # human 먼저
menus = list(by_menu.keys())
n_menus = len(menus)
n = min(n, len(queries))
# 1단계: 메뉴별 최소 1개
selected: list[dict] = []
pool_by_menu: dict[str, list[dict]] = {m: list(v) for m, v in by_menu.items()}
for mid in menus:
if pool_by_menu[mid]:
selected.append(pool_by_menu[mid].pop(0))
if len(selected) >= n:
break
# 2단계: 남은 슬롯 채우기 (쿼리 많은 메뉴 우선)
remaining_flat = []
for mid in menus:
remaining_flat.extend(pool_by_menu[mid])
rng.shuffle(remaining_flat)
for item in remaining_flat:
if len(selected) >= n:
break
selected.append(item)
rng.shuffle(selected)
return selected
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--seed", type=int, default=2025, help="랜덤 시드")
parser.add_argument("--dry-run", action="store_true", help="통계만 출력, 파일 저장 안 함")
args = parser.parse_args()
rng = random.Random(args.seed)
print(f"엑셀 로드: {EXCEL_PATH}")
top200_pool, outside_pool = load_all_queries(EXCEL_PATH)
print(f" top200 풀: {len(top200_pool):,}개 ({len(set(x['menu_id'] for x in top200_pool))}개 메뉴)")
print(f" 201+ 풀: {len(outside_pool):,}개 ({len(set(x['menu_id'] for x in outside_pool))}개 메뉴)")
# ── 샘플링 ────────────────────────────────────────────────────────────────
eval_top200 = stratified_sample(top200_pool, TOP200_N, rng)
eval_outside = rng.sample(outside_pool, min(OUTSIDE_N, len(outside_pool)))
eval_set = eval_top200 + eval_outside
rng.shuffle(eval_set)
# eval ID 부여
for i, item in enumerate(eval_set, 1):
item["id"] = i
# ── 인덱스용 나머지 (eval 제외한 top200만) ───────────────────────────────
eval_ids = {(x["query"], x["menu_id"]) for x in eval_set}
index_pool = [x for x in top200_pool if (x["query"], x["menu_id"]) not in eval_ids]
# ── 통계 ──────────────────────────────────────────────────────────────────
eval_menus = len(set(x["menu_id"] for x in eval_set))
top200_menus_in_eval = len(set(x["menu_id"] for x in eval_top200))
outside_menus_in_eval = len(set(x["menu_id"] for x in eval_outside))
human_cnt = sum(1 for x in eval_set if x["source"] == "human")
llm_cnt = sum(1 for x in eval_set if x["source"] == "llm")
print(f"\n[평가셋 구성]")
print(f" 총 쿼리: {len(eval_set)}개")
print(f" top200 쿼리: {len(eval_top200)}개 ({top200_menus_in_eval}개 메뉴)")
print(f" 201+ 쿼리: {len(eval_outside)}개 ({outside_menus_in_eval}개 메뉴)")
print(f" 총 메뉴 수: {eval_menus}개")
print(f" human 쿼리: {human_cnt}개 | llm 쿼리: {llm_cnt}개")
print(f"\n[인덱스 풀 (eval 제외)]")
print(f" top200 남은 쿼리: {len(index_pool):,}개")
if args.dry_run:
print("\n[dry-run] 파일 저장 안 함")
return
# ── 저장 ──────────────────────────────────────────────────────────────────
with open(EVAL_OUT, "w", encoding="utf-8") as f:
json.dump(eval_set, f, ensure_ascii=False, indent=2)
print(f"\n평가셋 저장: {EVAL_OUT}")
with open(INDEX_REMAINING_OUT, "w", encoding="utf-8") as f:
json.dump(index_pool, f, ensure_ascii=False, indent=2)
print(f"인덱스 풀 저장: {INDEX_REMAINING_OUT}")
print("\n다음 단계: scripts/20_build_query_index.py --reset --from-pool")
if __name__ == "__main__":
main()