"""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위 프레임에 대해 사용자가 합리성을 ' '합리적 (○) / 애매 (△) / 비합리적 (×) 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('' + ''.join(f'' for v in VERSIONS) + '') 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'' ) md.append('
{v}
' f'{fn:02d}' f'
Frame {fn}
' f'
{desc}
' f'
{sym} {rating} ' f'({score}점)
' + (f'
{comment}
' if comment else '') + '
') 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""" Holdout 합리성 평가 {html_body} """ 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()