- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Pipeline Step 10.5 — Holdout 합리성 라벨 기반 V1~V4 평가.
|
||
|
||
입력:
|
||
- holdout_labels.yaml (사용자 채운 라벨)
|
||
- mdx_matching_result.yaml / v2 / v3 / v4 (프레임 정보 재사용)
|
||
|
||
출력:
|
||
- HOLDOUT_EVALUATION.md
|
||
- HOLDOUT_EVALUATION.html
|
||
"""
|
||
import base64
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
import yaml
|
||
import markdown
|
||
|
||
HERE = Path(__file__).parent
|
||
MD_PATH = HERE / 'HOLDOUT_EVALUATION.md'
|
||
HTML_PATH = HERE / 'HOLDOUT_EVALUATION.html'
|
||
PNG_DIR = (HERE / '..' / '..' / 'data' / 'figma_previews').resolve()
|
||
|
||
_IMG_CACHE = {}
|
||
|
||
|
||
def img_uri(fn):
|
||
if fn in _IMG_CACHE:
|
||
return _IMG_CACHE[fn]
|
||
p = PNG_DIR / f'{fn:02d}.png'
|
||
if not p.exists():
|
||
return ''
|
||
uri = 'data:image/png;base64,' + base64.b64encode(p.read_bytes()).decode('ascii')
|
||
_IMG_CACHE[fn] = uri
|
||
return uri
|
||
|
||
|
||
RATING_SYMBOL = {'rational': '○', 'ambiguous': '△', 'irrational': '×'}
|
||
RATING_DEFAULT_SCORE = {'rational': 100, 'ambiguous': 50, 'irrational': 0}
|
||
|
||
|
||
def score_of(pick):
|
||
if pick.get('score') is not None:
|
||
return pick['score']
|
||
return RATING_DEFAULT_SCORE.get(pick['rating'], 0)
|
||
|
||
|
||
def build_frame_descriptions(auto):
|
||
out = {}
|
||
for fid, info in auto['frame_stats'].items():
|
||
fnum = info['frame_number']
|
||
for set_id, sinfo in auto['source_text_sets'].items():
|
||
if sinfo['frame_id'] == fid and sinfo['source_text_index'] == 1:
|
||
out[fnum] = sinfo['source_text_raw']
|
||
break
|
||
if fnum not in out:
|
||
out[fnum] = f'Frame {fnum}'
|
||
return out
|
||
|
||
|
||
def main():
|
||
labels = yaml.safe_load((HERE / 'holdout_labels.yaml').read_text(encoding='utf-8'))
|
||
auto = yaml.safe_load((HERE / 'auto_anchor_candidates.yaml').read_text(encoding='utf-8'))
|
||
descriptions = build_frame_descriptions(auto)
|
||
|
||
sections = labels['sections']
|
||
VERSIONS = ['V1', 'V2', 'V3', 'V4']
|
||
n_sections = len(sections)
|
||
|
||
# 점수 집계
|
||
scores = {v: 0 for v in VERSIONS}
|
||
rating_counts = {v: Counter() for v in VERSIONS}
|
||
for sid, sec in sections.items():
|
||
for v in VERSIONS:
|
||
pick = sec['picks'][v]
|
||
scores[v] += score_of(pick)
|
||
rating_counts[v][pick['rating']] += 1
|
||
max_score = n_sections * 100
|
||
|
||
# ============================================================
|
||
# MD
|
||
# ============================================================
|
||
md = []
|
||
md.append('# Holdout 합리성 평가 — V1~V4 실사용 가치 비교')
|
||
md.append('')
|
||
md.append(
|
||
'_Holdout 3 섹션 × 각 버전 1위 프레임에 대해 사용자가 합리성을 '
|
||
'<b>합리적 (○) / 애매 (△) / 비합리적 (×)</b> 3단계로 라벨링한 결과._'
|
||
)
|
||
md.append('')
|
||
md.append(
|
||
'**주의 원칙**: (a) V4 라벨(use_as_is/reject) 에 끌려가지 않음 — '
|
||
'사용자가 "이 프레임이 이 섹션에 실제로 합리적인가" 를 직접 판단. '
|
||
'(b) 현 단계는 1위만 평가 — 대안 탐색(2~3위) 는 별도 단계.'
|
||
)
|
||
md.append('')
|
||
|
||
# 1. 요약
|
||
md.append('## 1. 요약 — V1~V4 합리성 점수')
|
||
md.append('')
|
||
md.append('| 버전 | 합리성 점수 (0~300) | 비율 | ○ | △ | × |')
|
||
md.append('|---|---:|---:|---:|---:|---:|')
|
||
ranked = sorted(VERSIONS, key=lambda v: -scores[v])
|
||
for v in VERSIONS:
|
||
rc = rating_counts[v]
|
||
pct = round(scores[v] / max_score * 100, 1)
|
||
badge = ''
|
||
if v == ranked[0]:
|
||
badge = ' 🥇'
|
||
elif v == ranked[1]:
|
||
badge = ' 🥈'
|
||
md.append(
|
||
f"| **{v}**{badge} | {scores[v]} / {max_score} | {pct}% | "
|
||
f"{rc.get('rational', 0)} | {rc.get('ambiguous', 0)} | {rc.get('irrational', 0)} |"
|
||
)
|
||
md.append('')
|
||
|
||
# 2. 섹션별 상세 (이미지 포함 HTML 블록으로)
|
||
md.append('## 2. 섹션별 라벨 상세')
|
||
md.append('')
|
||
for sid, sec in sections.items():
|
||
md.append(f"### {sid} — {sec['mdx_title']}")
|
||
md.append('')
|
||
md.append('<table class="eval-grid"><thead><tr>'
|
||
+ ''.join(f'<th>{v}</th>' for v in VERSIONS)
|
||
+ '</tr></thead><tbody><tr>')
|
||
for v in VERSIONS:
|
||
pick = sec['picks'][v]
|
||
fn = pick['frame_number']
|
||
rating = pick['rating']
|
||
sym = RATING_SYMBOL.get(rating, '?')
|
||
score = score_of(pick)
|
||
comment = pick.get('comment') or ''
|
||
desc = descriptions.get(fn, '')
|
||
md.append(
|
||
f'<td class="eval-cell rating-{rating}">'
|
||
f'<img src="{img_uri(fn)}" alt="{fn:02d}"/>'
|
||
f'<div class="eval-label">Frame {fn}</div>'
|
||
f'<div class="eval-desc">{desc}</div>'
|
||
f'<div class="eval-rating">{sym} <b>{rating}</b> '
|
||
f'<span class="eval-score">({score}점)</span></div>'
|
||
+ (f'<div class="eval-comment">{comment}</div>' if comment else '')
|
||
+ '</td>'
|
||
)
|
||
md.append('</tr></tbody></table>')
|
||
md.append('')
|
||
|
||
# 3. 관찰
|
||
md.append('## 3. 주요 관찰')
|
||
md.append('')
|
||
|
||
# V2 leverage case
|
||
v2_leverage = []
|
||
for sid, sec in sections.items():
|
||
v1_rat = sec['picks']['V1']['rating']
|
||
v2_rat = sec['picks']['V2']['rating']
|
||
if v1_rat == 'irrational' and v2_rat == 'rational':
|
||
v2_leverage.append(sid)
|
||
if v2_leverage:
|
||
md.append(
|
||
f"- **V2 고유 가치 확인**: 섹션 `{', '.join(v2_leverage)}` 에서 "
|
||
f"V1 × (비합리) 를 V2 가 ○ (합리) 로 교정. "
|
||
f"→ V2 의 의미 축이 V1 키워드가 놓치는 케이스를 잡아낼 수 있다는 증거."
|
||
)
|
||
|
||
# V4 synthesis case
|
||
v4_wins = []
|
||
for sid, sec in sections.items():
|
||
v4_score = score_of(sec['picks']['V4'])
|
||
others_max = max(score_of(sec['picks'][v]) for v in ['V1', 'V2', 'V3'])
|
||
if v4_score >= others_max and sec['picks']['V4']['rating'] != 'irrational':
|
||
v4_wins.append(sid)
|
||
md.append(
|
||
f"- **V4 종합 판단 능력**: {len(v4_wins)}/{n_sections} 섹션에서 V4 가 다른 버전과 같거나 더 나은 합리성. "
|
||
f"특히 02-1 (V2/V3 × → V4 ○) 과 02-2.1 (V1 × → V4 ○) 에서 "
|
||
f"V4 가 다른 축의 오답을 거르거나 좋은 신호를 선택."
|
||
)
|
||
|
||
# V3 weakness
|
||
v3_scores = [score_of(sec['picks']['V3']) for sec in sections.values()]
|
||
v3_pct = round(sum(v3_scores) / (n_sections * 100) * 100, 1)
|
||
if v3_pct < 50:
|
||
md.append(
|
||
f"- **V3 현재 세팅의 한계**: V3 합리성 비율 {v3_pct}% — 평균 이하. "
|
||
f"현재 구조 호환도(`_COMPAT`) 가 오히려 잘못된 방향으로 유도하는 경우 있음 "
|
||
f"(예: 02-1 에서 V3 가 V1 의 올바른 1위를 밀어내고 비합리 프레임을 올림)."
|
||
)
|
||
|
||
# V1 baseline performance
|
||
v1_rat = rating_counts['V1'].get('rational', 0)
|
||
md.append(
|
||
f"- **V1 baseline 한계**: {v1_rat}/{n_sections} 섹션만 합리 — "
|
||
f"V1 단독으로는 Holdout 난이도 케이스에 불충분함 확인."
|
||
)
|
||
|
||
md.append('')
|
||
|
||
# 4. 결론
|
||
md.append('## 4. 결론 — "V2 실사용 가치 증명" 여부')
|
||
md.append('')
|
||
if v2_leverage:
|
||
md.append(
|
||
f"**부분 증명**. V2 가 V1 을 교정한 사례 `{', '.join(v2_leverage)}` 가 있음. "
|
||
f"다만 샘플 {n_sections}건은 통계적 결론에 부족 — 추세를 보여주는 증거 수준."
|
||
)
|
||
else:
|
||
md.append(
|
||
f"**미증명**. V2 가 V1 을 명확히 개선한 사례가 Holdout {n_sections}건 안에서 발견되지 않음. "
|
||
f"다른 평가 세트 (TARGET 추가, 정답 공개) 필요."
|
||
)
|
||
md.append('')
|
||
|
||
# 버전별 기여도 한 줄
|
||
md.append('**버전별 기여도 한 줄**:')
|
||
md.append('')
|
||
md.append(f"- **V1** (키워드): {scores['V1']}/{max_score} — "
|
||
f"baseline. 키워드가 명확히 대응될 때만 합리.")
|
||
md.append(f"- **V2** (의미): {scores['V2']}/{max_score} — "
|
||
f"V1 이 놓친 의미적 대응을 잡아낼 수 있음 (02-2.1 사례). "
|
||
f"다만 키워드가 강한 경우 오히려 엉뚱한 방향으로 갈 위험도 있음 (02-1 사례).")
|
||
md.append(f"- **V3** (구조): {scores['V3']}/{max_score} — "
|
||
f"현재 세팅에서는 기여 약함. 구조 taxonomy 재설계 시 재평가 필요.")
|
||
md.append(f"- **V4** (종합 판단): {scores['V4']}/{max_score} — "
|
||
f"가장 균형 있음. V1/V2/V3 신호를 선별적으로 받아 최종 판단 잘 함.")
|
||
md.append('')
|
||
|
||
# 5. 제한
|
||
md.append('## 5. 제한 + 다음 단계')
|
||
md.append('')
|
||
md.append('**제한**:')
|
||
md.append(f'- 라벨 샘플 3건 (통계적 결론 부족)')
|
||
md.append('- 1위만 평가 — 대안 탐색(2~3위) 능력 미검증')
|
||
md.append('- 라벨 자체가 사용자 1인 판단 — 검토자 합의 없음')
|
||
md.append('')
|
||
md.append('**다음 단계 후보**:')
|
||
md.append('- 옵션 3 (새 TARGET 추가) — 유형 B 케이스 확보')
|
||
md.append('- Top-3 라벨링 확장 — 대안 탐색 평가')
|
||
md.append('- V3 taxonomy 재설계 (현재 V3 약세 해결 시도)')
|
||
md.append('- Holdout 정답 특정 가능하면 옵션 1 로 정량 평가 보강')
|
||
|
||
md_text = '\n'.join(md)
|
||
MD_PATH.write_text(md_text, encoding='utf-8')
|
||
|
||
# HTML (MD → HTML + extra CSS for eval grid)
|
||
style = """
|
||
body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1300px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }
|
||
h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }
|
||
h2 { margin-top: 2.5em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; }
|
||
h3 { margin-top: 1.5em; color: #1a365d; }
|
||
table { border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin: 0.5em 0 1em; }
|
||
th, td { border: 1px solid #e2e8f0; padding: 8px 10px; vertical-align: top; }
|
||
th { background: #1e293b; color: #fff; font-weight: 700; text-align: center; }
|
||
code { background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }
|
||
table.eval-grid { width: 100%; table-layout: fixed; }
|
||
table.eval-grid td.eval-cell { text-align: center; padding: 12px; }
|
||
table.eval-grid img { max-width: 200px; border: 1px solid #cbd5e1; border-radius: 4px; display: block; margin: 0 auto 6px; }
|
||
.eval-label { font-weight: 600; color: #2563eb; }
|
||
.eval-desc { font-size: 0.82em; color: #64748b; margin: 4px 0 8px; min-height: 2em; }
|
||
.eval-rating { margin-top: 6px; padding: 6px; border-radius: 4px; font-size: 0.95em; }
|
||
.eval-score { color: #475569; font-family: ui-monospace, monospace; font-size: 0.9em; }
|
||
.eval-comment { margin-top: 6px; font-size: 0.8em; color: #475569; font-style: italic; }
|
||
.rating-rational { background: #d1fae5; }
|
||
.rating-rational .eval-rating b { color: #065f46; }
|
||
.rating-ambiguous { background: #fef3c7; }
|
||
.rating-ambiguous .eval-rating b { color: #92400e; }
|
||
.rating-irrational { background: #fee2e2; }
|
||
.rating-irrational .eval-rating b { color: #991b1b; }
|
||
"""
|
||
html_body = markdown.markdown(md_text, extensions=['tables'])
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head><meta charset="utf-8"><title>Holdout 합리성 평가</title><style>{style}</style></head>
|
||
<body>
|
||
{html_body}
|
||
</body></html>"""
|
||
HTML_PATH.write_text(html, encoding='utf-8')
|
||
|
||
print("=" * 70)
|
||
print("Holdout 평가 리포트 생성 완료")
|
||
print("=" * 70)
|
||
print(f" md: {MD_PATH}")
|
||
print(f" html: {HTML_PATH}")
|
||
print()
|
||
print("버전별 점수:")
|
||
for v in ranked:
|
||
pct = round(scores[v] / max_score * 100, 1)
|
||
rc = rating_counts[v]
|
||
print(f" {v}: {scores[v]:>3}/{max_score} ({pct}%) "
|
||
f"○{rc.get('rational',0)} △{rc.get('ambiguous',0)} ×{rc.get('irrational',0)}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|