Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Benchmark

# 3단계로 나눠서 돈다 (CPU 러너라 무거운 걸 매번 돌리면 너무 오래 걸림):
# - main이 아닌 브랜치에 push -> smoke (5개 문서, 몇 분) : 코드가 안 깨졌는지만 빠르게 확인
# - main에 push -> dev-only (20개 문서, ~1시간) : 회귀 체크
# - workflow_dispatch (full: true) -> 전체 90개 문서 (수동 트리거 전용, 여러 시간 소요)

on:
push:
paths:
- "src/**"
- "benchmarks/measure_recall.py"
- "scripts/**"
- "pyproject.toml"
- ".github/workflows/benchmark.yml"
workflow_dispatch:
inputs:
full:
description: "전체 90개 문서로 실행"
type: boolean
default: false

permissions:
contents: read

jobs:
benchmark:
runs-on: ubuntu-latest
timeout-minutes: 240

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: pyproject.toml

- name: 패키지 설치 (evidence-chunker + dev/tracking extras)
run: |
pip install -e ".[dev,tracking]"

- name: DagsHub Storage에서 벤치마크 PDF/QA 받기 (private 버킷, 토큰 인증)
env:
DAGSHUB_TOKEN: ${{ secrets.MLFLOW_TRACKING_PASSWORD }}
run: |
pip install dagshub boto3
python scripts/download_bench_data.py --out-dir ./data

- name: 데이터 경로 확인
run: |
echo "pdfs: $(ls data/pdfs | wc -l)개"
echo "auto_qa: $(ls data/auto_qa | wc -l)개"

- name: 실행 범위 결정 (smoke / dev / full)
id: scope
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.full }}" = "true" ]; then
echo "args=--mlflow" >> "$GITHUB_OUTPUT"
echo "label=full (90개)" >> "$GITHUB_OUTPUT"
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "args=--mlflow --dev-only" >> "$GITHUB_OUTPUT"
echo "label=dev-only (20개)" >> "$GITHUB_OUTPUT"
else
echo "args=--mlflow --dev-only --max-pdfs 5" >> "$GITHUB_OUTPUT"
echo "label=smoke (5개)" >> "$GITHUB_OUTPUT"
fi

- name: 벤치마크 실행 + MLflow(DagsHub)로 기록
env:
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
MLFLOW_TRACKING_USERNAME: ${{ secrets.MLFLOW_TRACKING_USERNAME }}
MLFLOW_TRACKING_PASSWORD: ${{ secrets.MLFLOW_TRACKING_PASSWORD }}
PYTHONUTF8: "1"
TORCHDYNAMO_DISABLE: "1"
run: |
echo "실행 범위: ${{ steps.scope.outputs.label }}"
python benchmarks/measure_recall.py \
--pdf-dir ./data/pdfs --qa-dir ./data/auto_qa --out-dir ./results \
${{ steps.scope.outputs.args }}

- name: 결과 요약을 Actions 탭에 바로 표시
if: always()
run: |
python scripts/summarize_results.py --results-dir ./results --label "${{ steps.scope.outputs.label }}"

- name: 결과 JSON을 Actions 아티팩트로 보관
if: always()
uses: actions/upload-artifact@v4
with:
name: benchmark-results-${{ github.run_number }}
path: results/
retention-days: 30
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 모델 파일 (용량 큼 — 로컬 다운로드)
# 모델 파일
models/

# 테스트 PDF
Expand All @@ -10,6 +10,9 @@ data/outputs/
# 보고서 (재생성 가능)
reports/

# MLflow
mlruns/

# Python
__pycache__/
*.pyc
Expand Down
150 changes: 146 additions & 4 deletions benchmarks/measure_recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
주의: normalize_for_em/has_token/em_hit은 benchmarks/generate_qa_docling.py의
동일 함수와 반드시 같은 규칙을 유지해야 한다 — 어긋나면 answer_spec이 의미를 잃는다.

통계: _ci()는 참고용 단순 근사치. 유의성 판단은 paired_statistics()의 문서 단위
클러스터 부트스트랩 CI(주 지표) + McNemar 검정(보조 지표, 질문 간 독립 가정)을 쓴다.

사용법:
python measure_recall.py --pdf-dir ./data/pdfs --qa-dir ./auto_qa --out-dir ./results
python measure_recall.py --pdf-dir ./data/pdfs --qa-dir ./auto_qa --out-dir ./results --dev-only
Expand All @@ -28,10 +31,14 @@
import argparse
import gc
import json
import math
import os
import re
from collections import Counter, defaultdict
from pathlib import Path

import numpy as np

# ---------------------------------------------------------------------------
# 하이퍼파라미터 — 라이브러리 기본값과 동일하게 유지 (sweep은 별도 실험)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -380,20 +387,102 @@ def _line(label, rows, width=28):


def _ci(n):
"""95% CI 반폭(pp) — 표본이 작을 때 관측된 갭이 실제로 유의한지 판단하는 기준."""
"""95% CI 반폭(pp) — 표본이 작을 때 관측된 갭이 실제로 유의한지 판단하는 기준.

단순 이항분포 최대분산(p=0.5) 근사(Wald)라, 같은 질문에 대한
baseline/EU 쌍대비교 구조나 문서 내 상관은 반영하지 않는다. 엄밀한
유의성 판단에는 아래 paired_statistics()를 쓸 것 — 이 함수는 빠른
참고용 오차범위로만 남겨둔다.
"""
return 1.96 * 0.5 / (n ** 0.5) * 100 if n else float("inf")


def paired_statistics(doc_ids, baseline, treatment, repeats: int = 50000, seed: int = 20260915) -> dict:
"""문서 단위 클러스터 부트스트랩 CI + 보조 McNemar 검정.

_ci()와 달리 같은 문서에서 나온 질문들을 묶어서(문서를 리샘플링 단위로
삼아) 차이의 95% percentile CI를 구한다 — 문서 내 질문 간 상관을
반영하는 방식. McNemar는 "baseline만 맞음 vs EU만 맞음"의 비대칭을
검정하는 보조 지표로 덧붙이되, 문서 간 의존성은 보정하지 않는다는
가정을 명시한다.

baseline/treatment: 질문별 0/1(또는 bool) 정오답 배열. doc_ids와 길이가
같아야 하며, 같은 인덱스가 같은 질문을 가리켜야 한다(쌍대비교 전제).
"""
b = np.asarray(list(baseline), dtype=np.int64)
e = np.asarray(list(treatment), dtype=np.int64)
doc_ids = list(doc_ids)
if len(doc_ids) != len(b) or len(b) != len(e) or not len(b):
raise ValueError("Paired vectors must be nonempty and equal length.")

grouped: dict = defaultdict(lambda: [0, 0]) # doc_id -> [n_questions, sum(e-b)]
for d, delta in zip(doc_ids, e - b):
grouped[d][0] += 1
grouped[d][1] += int(delta)
a = np.asarray([grouped[d] for d in sorted(grouped)], dtype=np.int64)

ci = None
if len(a) >= 2:
rng, values = np.random.default_rng(seed), []
for start in range(0, repeats, 2048):
idx = rng.integers(0, len(a), size=(min(2048, repeats - start), len(a)))
total = a[idx].sum(axis=1)
values.append(100 * total[:, 1] / total[:, 0])
ci = np.quantile(np.concatenate(values), [.025, .975]).tolist()

b_only = int(((b == 1) & (e == 0)).sum())
e_only = int(((b == 0) & (e == 1)).sum())
discordant = b_only + e_only
chi2 = max(abs(b_only - e_only) - 1, 0) ** 2 / discordant if discordant else 0.0
p = math.erfc(math.sqrt(chi2 / 2)) if discordant else 1.0

return {
"n_questions": len(b), "n_documents": len(a),
"baseline": float(b.mean()), "treatment": float(e.mean()),
"difference_pp": float(100 * (e - b).mean()),
"cluster_bootstrap_ci95_pp": ci,
"bootstrap": {"unit": "document", "statistic": "micro_rate_difference",
"method": "percentile", "repeats": repeats, "seed": seed},
"mcnemar_supplementary": {
"method": "chi_square_continuity_corrected", "chi2": chi2, "p_value": p,
"baseline_only": b_only, "treatment_only": e_only,
"assumption": "질문 간 독립 가정 — 문서 내 의존성은 보정하지 않음(보조 지표)",
},
"ci_note": ("문서를 독립 표집 단위로 취급한 근사치이며, LLM 생성 답변 정확도의 CI가 아님"
if ci is not None else "문서 2개 미만이라 CI 계산 불가"),
}


def run(pdf_dir: Path, qa_dir: Path, out_dir: Path, dev_only: bool, max_pdfs: int | None,
parity_check: bool = False) -> None:
parity_check: bool = False, use_mlflow: bool = False) -> None:
from sentence_transformers import SentenceTransformer
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"
tag = "dev20" if dev_only else "full90"

if use_mlflow:
import mlflow
if not os.environ.get("MLFLOW_TRACKING_URI"):
mlflow_db = (Path.cwd() / "mlflow.db").resolve()
mlflow.set_tracking_uri(f"sqlite:///{mlflow_db.as_posix()}")
mlflow.set_experiment("evidence-chunker-benchmark")
mlflow.start_run(run_name=tag)
mlflow.log_params({
"tag": tag,
"bbox_threshold": BBOX_THRESHOLD,
"sim_threshold": SIM_THRESHOLD,
"embed_model": EMBED_MODEL_NAME,
"encode_batch": ENCODE_BATCH,
"min_doc_n": MIN_DOC_N,
"max_pdfs": max_pdfs,
})

pairs = pdf_qa_pairs(pdf_dir, qa_dir, dev_only, max_pdfs)
if not pairs:
print("[ERR] PDF-QA 쌍 없음")
if use_mlflow:
mlflow.end_run(status="FAILED")
return

import evidence_chunker
Expand All @@ -413,6 +502,8 @@ def run(pdf_dir: Path, qa_dir: Path, out_dir: Path, dev_only: bool, max_pdfs: in

if not rows:
print("[ERR] 결과 없음")
if use_mlflow:
mlflow.end_run(status="FAILED")
return

N = len(rows)
Expand Down Expand Up @@ -481,6 +572,17 @@ def _mline(label, m, n):
print(f"\n [real_driver] both_right={rd['both_right']} "
f"baseline_win_eu_lose={rd['baseline_win_eu_lose']} "
f"eu_win_baseline_lose={rd['eu_win_baseline_lose']} both_wrong={rd['both_wrong']}")

paired_em = paired_statistics([r["doc_id"] for r in rows],
[r["b_em"] for r in rows], [r["e_em"] for r in rows])
ci = paired_em["cluster_bootstrap_ci95_pp"]
ci_str = f"[{ci[0]:+.1f}, {ci[1]:+.1f}]pp" if ci else "N/A(문서<2)"
mc = paired_em["mcnemar_supplementary"]
print(f"\n [EM 통계 검정] 문서 단위 부트스트랩 차이 {paired_em['difference_pp']:+.1f}pp "
f"95% CI {ci_str}")
print(f" [McNemar 보조] baseline만 정답 {mc['baseline_only']} EU만 정답 {mc['treatment_only']} "
f"chi2={mc['chi2']:.2f} p={mc['p_value']:.2e}")

fails = Counter(r["fail_reason"] for r in rows if r["fail_reason"])
if fails:
print(f"\n EU 실패 사유 상위")
Expand Down Expand Up @@ -521,7 +623,6 @@ def blk(rs):
"eu_em": round(_rate(rs, "e_em"), 4),
"ci_halfwidth_pp": round(_ci(len(rs)), 2)}

tag = "dev20" if dev_only else "full90"
summary = {
"config": {"scope": tag, "qa_dir": str(qa_dir), "embed_model": EMBED_MODEL_NAME,
"bbox_threshold": BBOX_THRESHOLD, "sim_threshold": SIM_THRESHOLD,
Expand Down Expand Up @@ -551,6 +652,7 @@ def blk(rs):
"eu_em": round(mbig["e_em"], 4)} if mbig else None),
"doc_question_counts": {d: len(v) for d, v in by_doc.items()},
"real_driver": dict(rd),
"paired_statistics_em": paired_em,
"fail_reasons": dict(fails),
"pipeline": {"n_eu": sum(r["n_eu"] for r in results), "n_split": ts,
"dedup_removed": tc, "hybrid_before_dedup": tb,
Expand All @@ -564,6 +666,26 @@ def blk(rs):
json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\n저장: bench_{tag}.json / bench_{tag}_rows.json ({len(rows)} rows)")

if use_mlflow:
mlflow.log_metrics(summary["macro_average"])
if summary.get("macro_average_min_n"):
mlflow.log_metrics({
f"minN_{k}": v for k, v in summary["macro_average_min_n"].items()
if isinstance(v, (int, float))
})
# QA 유형별(cell_value / table_about / context_dependent) 지표도
# DagsHub에서 바로 비교할 수 있게 별도 metric으로 남긴다.
for t, blk_v in summary.get("by_type", {}).items():
if not blk_v:
continue
mlflow.log_metrics({
f"type_{t}_{k}": v for k, v in blk_v.items()
if isinstance(v, (int, float))
})
mlflow.log_artifact(str(out_dir / f"bench_{tag}.json"))
mlflow.log_artifact(str(out_dir / f"bench_{tag}_rows.json"))
mlflow.end_run()


# ===========================================================================
# 6. CLI
Expand All @@ -579,12 +701,32 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--max-pdfs", type=int, default=None, help="추가 상한 (디버깅용)")
p.add_argument("--parity-check", action="store_true",
help="첫 문서에서 EvidenceChunker.build_corpus() 결과와 대조")
p.add_argument("--mlflow", action="store_true")
# 스윕 자동화
p.add_argument("--bbox-threshold", type=float, default=None, help="BBOX_THRESHOLD")
p.add_argument("--sim-threshold", type=float, default=None, help="SIM_THRESHOLD")
p.add_argument("--embed-model", type=str, default=None, help="EMBED_MODEL_NAME")
p.add_argument("--encode-batch", type=int, default=None, help="ENCODE_BATCH")
return p.parse_args()


def main() -> None:
global BBOX_THRESHOLD, SIM_THRESHOLD, EMBED_MODEL_NAME, ENCODE_BATCH, CTX_WINDOW_PT

args = parse_args()
run(args.pdf_dir, args.qa_dir, args.out_dir, args.dev_only, args.max_pdfs, args.parity_check)

if args.bbox_threshold is not None:
BBOX_THRESHOLD = args.bbox_threshold
CTX_WINDOW_PT = args.bbox_threshold # 두 값은 항상 같이 움직임
if args.sim_threshold is not None:
SIM_THRESHOLD = args.sim_threshold
if args.embed_model is not None:
EMBED_MODEL_NAME = args.embed_model
if args.encode_batch is not None:
ENCODE_BATCH = args.encode_batch

run(args.pdf_dir, args.qa_dir, args.out_dir, args.dev_only, args.max_pdfs, args.parity_check,
args.mlflow)


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ similarity = ["sentence-transformers>=2.2"]
langchain = ["langchain-core"]
llamaindex = ["llama-index-core"]
dev = ["pytest>=8", "sentence-transformers>=2.2", "llama-index-core"]
tracking = ["mlflow>=2.14"]

[project.urls]
Repository = "https://github.com/EvidenceChunker/Evidence-Chunker"
Expand Down
Loading
Loading