"""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'| {v} | ' 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''
f' '
f'Frame {fn} '
f'{desc} '
f'{sym} {rating} '
f'({score}점) '
+ (f'' if comment else '')
+ ' | '
)
md.append('
')
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()