{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# LLM 배심원 실험 — 재분석 노트북 (v2)\n",
    "\n",
    "**본실험 `main-v1`** 의 판정 자료를 다시 분석한다. 이 노트북 하나만 실행하면\n",
    "아래 표와 그림이 모두 재현된다.\n",
    "\n",
    "---\n",
    "\n",
    "## 이 실험이 묻는 것\n",
    "\n",
    "AI에게 채점을 맡길 때, **모델 하나에게 물을 것인가, 여러 모델의 다수결을 쓸 것인가.**\n",
    "그리고 그 답이 **과제의 난이도**와 **판정 상황**에 따라 어떻게 달라지는가.\n",
    "\n",
    "## 자료\n",
    "\n",
    "| 파일 | 내용 | 크기 |\n",
    "|---|---|---|\n",
    "| `items_main_v1.csv` | 문장쌍 문항과 정답 등급 | 900행 |\n",
    "| `judgments_main_v1.csv` | 모델 판정 원본 | 25,200행 |\n",
    "\n",
    "25,200 = **문항 900 × 심판 7개 × 조건 4가지** 이며 결측은 없다.\n",
    "\n",
    "## 분석 규칙\n",
    "\n",
    "**난이도는 정답 등급으로 정의한다.**\n",
    "\n",
    "| 정답 등급 | 난이도 | 왜 그런가 |\n",
    "|---|---|---|\n",
    "| 1 (무관) · 5 (거의 동일) | **쉬움** | 척도의 양 끝이라 판단이 명확하다 |\n",
    "| 2 (부분 중첩) · 4 (다른 대상) | **중간** | 한쪽 이웃과 헷갈릴 수 있다 |\n",
    "| 3 (다른 초점) | **어려움** | 양쪽 이웃 모두와 헷갈린다 |\n",
    "\n",
    "**코사인 유사도는 보조지표로만 쓴다.** 정답 등급이 자의적이지 않은지 확인하는\n",
    "용도이며, 난이도를 정하지 않는다."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 1. 준비\n",
    "\n",
    "아래 셀은 그림에 한글이 깨지지 않도록 글꼴을 잡아 준다.\n",
    "Colab에서는 나눔글꼴을 자동으로 내려받고, 개인 PC에서는 이미 설치된 글꼴을 찾아 쓴다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys, os, platform, subprocess\n",
    "\n",
    "# Colab이면 나눔글꼴 설치 (처음 한 번만, 약 20초)\n",
    "IN_COLAB = 'google.colab' in sys.modules\n",
    "if IN_COLAB:\n",
    "    subprocess.run('apt-get -qq -y install fonts-nanum', shell=True)\n",
    "    subprocess.run('fc-cache -fv', shell=True, capture_output=True)\n",
    "\n",
    "import matplotlib\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.font_manager as fm\n",
    "\n",
    "# 설치된 글꼴 중 한글을 지원하는 것을 순서대로 찾는다\n",
    "CANDIDATES = ['NanumGothic', 'NanumBarunGothic', 'Malgun Gothic',\n",
    "              'AppleGothic', 'Noto Sans KR', 'NanumSquare']\n",
    "installed = {f.name for f in fm.fontManager.ttflist}\n",
    "if IN_COLAB:                       # 새로 설치한 글꼴을 인식시킨다\n",
    "    for p in fm.findSystemFonts(fontpaths=['/usr/share/fonts'], fontext='ttf'):\n",
    "        try:\n",
    "            fm.fontManager.addfont(p)\n",
    "        except Exception:\n",
    "            pass\n",
    "    installed = {f.name for f in fm.fontManager.ttflist}\n",
    "\n",
    "FONT = next((c for c in CANDIDATES if c in installed), None)\n",
    "if FONT:\n",
    "    plt.rcParams['font.family'] = FONT\n",
    "    print('한글 글꼴 :', FONT)\n",
    "else:\n",
    "    print('한글 글꼴을 찾지 못했습니다. 그림의 한글이 깨질 수 있습니다.')\n",
    "plt.rcParams['axes.unicode_minus'] = False\n",
    "plt.rcParams['figure.dpi'] = 110\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "pd.set_option('display.width', 120)\n",
    "pd.set_option('display.max_columns', 30)\n",
    "print('pandas', pd.__version__, '· matplotlib', matplotlib.__version__)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 2. 자료 불러오기\n",
    "\n",
    "`items_main_v1.csv` 와 `judgments_main_v1.csv` 두 파일이 필요하다.\n",
    "\n",
    "- **개인 PC(주피터랩)** — 노트북과 같은 폴더, 또는 `data/` 폴더에 두면 자동으로 찾는다.\n",
    "- **Colab** — 파일이 없으면 업로드 창이 뜬다. 두 파일을 함께 선택하면 된다.\n",
    "\n",
    "두 CSV는 **UTF-8 BOM(`utf-8-sig`)** 으로 저장되어 있다. 윈도우에서 엑셀로 바로 열어도\n",
    "한글이 깨지지 않으며, 줄바꿈은 CRLF다. 파이썬에서 읽을 때는 아래처럼 인코딩을\n",
    "명시하는 것이 안전하다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "def find(name):\n",
    "    for p in [Path(name), Path('data')/name, Path('..')/'data'/name]:\n",
    "        if p.exists():\n",
    "            return p\n",
    "    return None\n",
    "\n",
    "need = ['items_main_v1.csv', 'judgments_main_v1.csv']\n",
    "paths = {n: find(n) for n in need}\n",
    "\n",
    "if any(v is None for v in paths.values()) and 'google.colab' in sys.modules:\n",
    "    from google.colab import files\n",
    "    print('두 파일을 선택해 업로드하세요:', ', '.join(need))\n",
    "    files.upload()\n",
    "    paths = {n: find(n) for n in need}\n",
    "\n",
    "missing = [n for n, v in paths.items() if v is None]\n",
    "if missing:\n",
    "    raise FileNotFoundError('파일을 찾지 못했습니다: ' + ', '.join(missing))\n",
    "\n",
    "# CSV는 윈도우 엑셀에서도 한글이 깨지지 않도록 UTF-8 BOM으로 저장되어 있다.\n",
    "# encoding='utf-8-sig' 를 명시하면 BOM이 첫 열 이름에 붙는 문제를 확실히 막는다.\n",
    "items = pd.read_csv(paths['items_main_v1.csv'], encoding='utf-8-sig')\n",
    "judg = pd.read_csv(paths['judgments_main_v1.csv'], encoding='utf-8-sig')\n",
    "print('문항 %d행, 판정 %d행' % (len(items), len(judg)))\n",
    "print('문장 결측 :', int(items[['s1', 's2']].isna().sum().sum()), '건')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 3. 자료 확인\n",
    "\n",
    "먼저 두 표가 어떻게 생겼는지 본다.\n",
    "\n",
    "### `items` — 문항 표\n",
    "\n",
    "한 줄이 **문장쌍 하나**다.\n",
    "\n",
    "| 열 | 뜻 |\n",
    "|---|---|\n",
    "| `item_id` | 문항 번호. 예 `econ_096_L3` 은 경제 도메인 96번 바탕에서 나온 정답 3짜리 문항 |\n",
    "| `domain` | 분야. `medai` 의료AI · `dart` 기업공시 · `econ` 경제 |\n",
    "| `level` | **정답 등급 1~5.** 문항을 만들 때 정해 둔 값이다 |\n",
    "| `cosine` | 두 문장의 임베딩 유사도(0~1). 문항을 만든 **뒤에** 측정한 보조지표 |\n",
    "| `s1`, `s2` | 문장 원문 |\n",
    "\n",
    "`item_id` 앞부분이 같은 문항끼리는 **`s1` 이 같다.** 기준 문장 하나에서\n",
    "등급별로 짝을 만들었기 때문이다. 아래 예시에서 세 줄의 `s1` 이 모두 같고\n",
    "`s2` 만 달라지는 것을 볼 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "display(items.head(3))\n",
    "print('\\n도메인별 문항 수')\n",
    "print(items['domain'].value_counts().to_string())\n",
    "print('\\n정답 등급별 문항 수')\n",
    "print(items['level'].value_counts().sort_index().to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### `judgments` — 판정 표\n",
    "\n",
    "한 줄이 **한 모델이 한 문항을 한 조건에서 매긴 점수 하나**다.\n",
    "\n",
    "| 열 | 뜻 |\n",
    "|---|---|\n",
    "| `model` | 심판 모델 7개 중 하나 |\n",
    "| `rubric` | 채점 기준표를 줬는가. `0` 안 줌 · `1` 줌 |\n",
    "| `anchor_shown` | 앞선 판정자의 답을 보여줬는가. `0` 안 보여줌 · `1` 보여줌 |\n",
    "| `true_level` | 정답 등급 (문항 표의 `level` 과 같다) |\n",
    "| `judgment` | **모델이 매긴 점수 1~5** |\n",
    "\n",
    "`rubric` 과 `anchor_shown` 을 조합하면 **네 가지 조건**이 된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "display(judg.head(3))\n",
    "\n",
    "COND_NAME = {(0,0): 'A 기본',   (1,0): 'B 루브릭',\n",
    "             (0,1): 'C 앵커',   (1,1): 'D 루브릭+앵커'}\n",
    "judg['조건'] = [COND_NAME[(r, a)] for r, a in zip(judg['rubric'], judg['anchor_shown'])]\n",
    "\n",
    "print('조건별 판정 수')\n",
    "print(judg['조건'].value_counts().sort_index().to_string())\n",
    "print('\\n심판별 판정 수')\n",
    "print(judg['model'].value_counts().to_string())\n",
    "print('\\n결측 판정 :', int(judg['judgment'].isna().sum()), '건')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 4. 두 가지 정확률\n",
    "\n",
    "이 실험에는 정확률이 **두 종류** 있다. 둘의 차이가 이 연구의 핵심이다.\n",
    "\n",
    "### 개별 정확률\n",
    "모델 한 명 한 명이 맞혔는지를 모두 세어 평균한 값이다.\n",
    "7명이 각자 채점했을 때 평균 몇 점이나 맞히는지를 뜻한다.\n",
    "\n",
    "### 배심원 정확률\n",
    "문항마다 **7명의 점수를 모아 중앙값**을 내고, 그 값이 정답과 같은지 본다.\n",
    "7명이 합의해서 하나의 답을 낼 때의 정확률이다.\n",
    "\n",
    "**배심원 정확률 − 개별 정확률 = 배심원 이득.**\n",
    "양수면 모으는 것이 이득이고, 음수면 모으는 것이 손해다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def summarize(df, by=None):\n",
    "    '''개별 정확률, 배심원 정확률, 이득을 계산한다.'''\n",
    "    d = df.copy()\n",
    "    d['맞음'] = (d['judgment'] == d['true_level']).astype(int)\n",
    "    keys = (by or []) + ['item_id']\n",
    "    per_item = d.groupby(keys).agg(\n",
    "        개별맞힌수=('맞음', 'sum'),\n",
    "        심판수=('맞음', 'size'),\n",
    "        중앙값=('judgment', 'median'),\n",
    "        정답=('true_level', 'first')).reset_index()\n",
    "    per_item['배심원맞음'] = (per_item['중앙값'] == per_item['정답']).astype(int)\n",
    "    g = per_item.groupby(by) if by else [((), per_item)]\n",
    "    rows = []\n",
    "    for k, v in (g if by else g):\n",
    "        rows.append(dict(zip(by or [], k if isinstance(k, tuple) else (k,))) | {\n",
    "            '문항수': len(v),\n",
    "            '개별': v['개별맞힌수'].sum() / v['심판수'].sum(),\n",
    "            '배심원': v['배심원맞음'].mean()})\n",
    "    out = pd.DataFrame(rows)\n",
    "    out['이득(%p)'] = (out['배심원'] - out['개별']) * 100\n",
    "    return out\n",
    "\n",
    "전체 = summarize(judg, ['조건']).sort_values('조건')\n",
    "display(전체.round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**표 읽는 법.** `개별` 은 심판 한 명 기준 정확률, `배심원` 은 7명의 중앙값 기준 정확률,\n",
    "`이득(%p)` 은 둘의 차이를 백분율 포인트로 나타낸 값이다.\n",
    "\n",
    "- **A 기본** — 아무 도움도 주지 않은 조건. 기준선이다.\n",
    "- **B 루브릭** — 채점 기준표를 준 조건. 개별·배심원 모두 오른다.\n",
    "- **C 앵커** — 틀린 힌트를 준 조건. **정확률이 절반 가까이 무너지고 배심원 이득도 사라진다.**\n",
    "- **D 루브릭+앵커** — 기준표가 힌트의 피해를 상당 부분 막아 준다."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 5. 결과 1 — 정답 등급별 정확률\n",
    "\n",
    "이제 정답 등급을 나눠서 본다. 기본 조건(A)부터 확인한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "LV_NAME = {1: '1 무관', 2: '2 부분 중첩', 3: '3 다른 초점',\n",
    "           4: '4 다른 대상', 5: '5 거의 동일'}\n",
    "\n",
    "기본 = judg[judg['조건'] == 'A 기본']\n",
    "등급별 = summarize(기본, ['true_level']).sort_values('true_level')\n",
    "등급별['정답 등급'] = 등급별['true_level'].map(LV_NAME)\n",
    "display(등급별[['정답 등급', '문항수', '개별', '배심원', '이득(%p)']].round(3)\n",
    "        .to_string(index=False))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def bar_pair(df, xlabels, title, note, figsize=(9.2, 4.6)):\n",
    "    '''개별·배심원 두 막대를 나란히 그린다.'''\n",
    "    fig, ax = plt.subplots(figsize=figsize)\n",
    "    x = np.arange(len(df)); w = 0.36\n",
    "    ax.bar(x - w/2, df['개별'], w, label='개별', color='#2a6fd0')\n",
    "    ax.bar(x + w/2, df['배심원'], w, label='배심원', color='#dd6229')\n",
    "    for i, (a, b) in enumerate(zip(df['개별'], df['배심원'])):\n",
    "        ax.text(i - w/2, a + .015, f'{a:.2f}', ha='center', fontsize=9)\n",
    "        ax.text(i + w/2, b + .015, f'{b:.2f}', ha='center', fontsize=9)\n",
    "        d = (b - a) * 100\n",
    "        ax.text(i, max(a, b) + .075, f'{d:+.0f}%p', ha='center', fontsize=10,\n",
    "                color='#c0392b' if d < 0 else '#dd6229', fontweight='bold')\n",
    "    ax.set_xticks(x); ax.set_xticklabels(xlabels, fontsize=10)\n",
    "    ax.set_ylim(0, 1.15); ax.set_ylabel('정확률')\n",
    "    ax.set_yticks([0, .25, .5, .75, 1.0])\n",
    "    ax.set_yticklabels(['0', '25%', '50%', '75%', '100%'])\n",
    "    ax.grid(axis='y', color='#e6e5e1'); ax.set_axisbelow(True)\n",
    "    for s in ('top', 'right', 'left'):\n",
    "        ax.spines[s].set_visible(False)\n",
    "    ax.tick_params(length=0)\n",
    "    ax.legend(frameon=False, ncol=2, loc='upper left')\n",
    "    ax.set_title(title, fontsize=13, loc='left', pad=12)\n",
    "    fig.text(0.01, -0.02, note, fontsize=9, color='#6b7480')\n",
    "    plt.tight_layout(); plt.show()\n",
    "\n",
    "bar_pair(등급별, [LV_NAME[g] for g in 등급별['true_level']],\n",
    "         '정답 등급별 정확률 — 조건 A (아무 도움 없음)',\n",
    "         '막대 위 숫자는 정확률, 가운데 굵은 숫자는 개별에서 배심원으로의 변화량')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 그림 설명\n",
    "\n",
    "가로축은 정답 등급이고, 파란 막대가 **개별**, 주황 막대가 **배심원**이다.\n",
    "막대 사이 굵은 숫자가 **배심원 이득**이다.\n",
    "\n",
    "읽어야 할 것이 셋 있다.\n",
    "\n",
    "**첫째, 곡선이 단조롭지 않다.** 정답 5(거의 동일)가 가장 쉽고, 정답 2(부분 중첩)가\n",
    "가장 어렵다. 정답 1(무관)은 중간이다. **정답 등급의 순서와 난이도의 순서가 다르다.**\n",
    "\n",
    "**둘째, 정답 2에서만 배심원이 개별보다 낮다.** 다른 구간에서는 모으는 것이 이득인데\n",
    "이 구간만 손해다. 7명이 **같은 방향으로** 틀리면 중앙값이 그 오답을 그대로 확정하기\n",
    "때문이다. 다수결은 실수가 서로 다른 방향일 때만 작동한다.\n",
    "\n",
    "**셋째, 정답 4에서 이득이 가장 크다.** 개별로는 절반 남짓 맞히던 것이 모으면 90%에\n",
    "가까워진다. 이 구간에서는 모델들의 오차가 서로 상쇄된다."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 6. 결과 2 — 난이도 세 구간\n",
    "\n",
    "정답 등급을 난이도로 묶는다. 양 끝(1·5)이 쉬움, 그 안쪽(2·4)이 중간, 가운데(3)가 어려움이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def 난이도(level):\n",
    "    return '쉬움' if level in (1, 5) else ('중간' if level in (2, 4) else '어려움')\n",
    "\n",
    "judg['난이도'] = judg['true_level'].map(난이도)\n",
    "순서 = ['쉬움', '중간', '어려움']\n",
    "\n",
    "표 = summarize(judg[judg['조건'] == 'A 기본'], ['난이도'])\n",
    "표['난이도'] = pd.Categorical(표['난이도'], 순서, ordered=True)\n",
    "표 = 표.sort_values('난이도')\n",
    "print(표[['난이도', '문항수', '개별', '배심원', '이득(%p)']].round(3).to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**표 읽는 법.** 세 구간의 문항 수가 366 / 350 / 184로 다르다. 정답 등급을 두 개씩 묶은\n",
    "결과이므로 어려움 구간이 가장 작다.\n",
    "\n",
    "쉬움에서 중간으로 갈 때 정확률이 크게 떨어지지만, 중간과 어려움은 비슷하다.\n",
    "**난이도를 세 구간으로 묶으면 정답 2의 특이한 움직임이 중간 구간 안에 가려진다.**\n",
    "그래서 앞 장의 등급별 표를 함께 보아야 한다."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 7. 결과 3 — 코사인은 보조지표로 쓸 만한가\n",
    "\n",
    "코사인 유사도는 두 문장이 의미상 얼마나 가까운지를 0~1로 잰 값이다.\n",
    "문항을 만든 **뒤에** 측정했으므로 정답을 정하는 데 쓰이지 않았다.\n",
    "\n",
    "여기서는 두 가지를 확인한다.\n",
    "\n",
    "1. **정답 등급이 올라가면 코사인도 올라가는가** — 정답이 자의적이지 않다는 증거가 된다\n",
    "2. **코사인으로 난이도를 나누면 정답 등급으로 나눈 것과 같은가** — 보조지표의 한계를 본다"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "요약 = items.groupby('level')['cosine'].agg(['count', 'min', 'mean', 'max']).round(3)\n",
    "요약.index = [LV_NAME[g] for g in 요약.index]\n",
    "요약.columns = ['문항수', '최소', '평균', '최대']\n",
    "print(요약.to_string())\n",
    "\n",
    "from scipy.stats import spearmanr\n",
    "rho, p = spearmanr(items['level'], items['cosine'])\n",
    "print('\\n정답 등급과 코사인의 순위상관  rho = %.3f' % rho)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(8.6, 4.4))\n",
    "for g in range(1, 6):\n",
    "    v = items.loc[items['level'] == g, 'cosine']\n",
    "    ax.scatter(v, np.full(len(v), g) + np.random.uniform(-.16, .16, len(v)),\n",
    "               s=9, alpha=.45, color='#2a6fd0', edgecolors='none')\n",
    "    ax.plot([v.mean()], [g], 'o', ms=10, color='#dd6229',\n",
    "            markeredgecolor='white', markeredgewidth=1.5, zorder=5)\n",
    "ax.set_yticks(range(1, 6)); ax.set_yticklabels([LV_NAME[g] for g in range(1, 6)])\n",
    "ax.set_xlabel('코사인 유사도'); ax.set_xlim(0.15, 1.02)\n",
    "ax.grid(axis='x', color='#e6e5e1'); ax.set_axisbelow(True)\n",
    "for s in ('top', 'right', 'left'):\n",
    "    ax.spines[s].set_visible(False)\n",
    "ax.tick_params(length=0)\n",
    "ax.set_title('정답 등급과 코사인 유사도 — 점 하나가 문항 하나', fontsize=13, loc='left', pad=12)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 그림 설명\n",
    "\n",
    "점 하나가 문항 하나이고, 세로로 흩뿌린 것은 점이 겹치지 않게 하려는 것이다.\n",
    "주황 점은 각 등급의 **평균**이다.\n",
    "\n",
    "**정답 등급이 올라갈수록 코사인도 확실히 올라간다.** 순위상관이 0.85를 넘는다.\n",
    "정답이 임의로 붙은 것이 아니라는 근거가 된다.\n",
    "\n",
    "그러나 두 가지 한계도 함께 보인다.\n",
    "\n",
    "**첫째, 정답 2와 3이 거의 완전히 겹친다.** 평균이 사실상 같아서 코사인만으로는\n",
    "두 등급을 구분할 수 없다.\n",
    "\n",
    "**둘째, 코사인은 0에서 시작하지 않는다.** 정답 1(무관)조차 0.23 아래로 내려가지\n",
    "않는다. 같은 언어·같은 문체의 문장은 내용이 전혀 달라도 임베딩상 최소한의 유사도를\n",
    "공유하기 때문이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def 코사인구간(c):\n",
    "    if c < 0.4 or c >= 0.8:\n",
    "        return '쉬움'\n",
    "    if c < 0.5 or c >= 0.7:\n",
    "        return '중간'\n",
    "    return '어려움'\n",
    "\n",
    "items['코사인난이도'] = items['cosine'].map(코사인구간)\n",
    "items['정답난이도'] = items['level'].map(난이도)\n",
    "\n",
    "교차 = pd.crosstab(items['정답난이도'], items['코사인난이도'])\n",
    "교차 = 교차.reindex(index=순서, columns=순서, fill_value=0)\n",
    "print('정답 기준(행) × 코사인 기준(열)\\n')\n",
    "print(교차.to_string())\n",
    "일치 = np.trace(교차.values)\n",
    "print('\\n두 기준이 같은 문항 : %d개 (%.1f%%)' % (일치, 100 * 일치 / len(items)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**표 읽는 법.** 대각선(쉬움-쉬움, 중간-중간, 어려움-어려움)이 두 기준이 일치한 문항이다.\n",
    "**절반이 채 안 된다.**\n",
    "\n",
    "특히 정답 기준으로 어려움인 문항 중 코사인 기준으로 쉬움인 것은 **0개**다.\n",
    "코사인이 낮거나 높은 양 극단은 정답 1과 5가 차지하므로, 정답 3이 그쪽에 들어갈 수 없다.\n",
    "**두 축이 구조적으로 얽혀 있다는 뜻이며, 코사인을 난이도로 쓰면 정답 등급을 다시\n",
    "쓰는 셈이 된다.** 코사인을 보조지표로만 두는 이유가 여기 있다."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 8. 결과 4 — 2×2 조건 효과\n",
    "\n",
    "이 실험의 본론이다. 심판에게 주는 정보를 두 축으로 바꿔 가며 네 조건을 만들었다.\n",
    "\n",
    "|  | 앵커 없음 | 앵커 있음 |\n",
    "|---|---|---|\n",
    "| **루브릭 없음** | A 기본 | C 앵커 |\n",
    "| **루브릭 있음** | B 루브릭 | D 둘다 |\n",
    "\n",
    "- **루브릭** — 5단계 채점 기준표를 프롬프트에 함께 준다\n",
    "- **앵커** — \"앞선 판정자는 N점으로 판정했습니다\"를 덧붙인다.\n",
    "  이 N은 **언제나 정답에서 한 칸 벗어난 값**이므로, 그대로 따르면 한 문항도 맞힐 수 없다"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "조건순 = ['A 기본', 'B 루브릭', 'C 앵커', 'D 루브릭+앵커']\n",
    "fig, axes = plt.subplots(2, 2, figsize=(11.4, 7.6))\n",
    "for ax, cname in zip(axes.ravel(), 조건순):\n",
    "    sub = summarize(judg[judg['조건'] == cname], ['true_level']).sort_values('true_level')\n",
    "    x = np.arange(5); w = 0.36\n",
    "    ax.bar(x - w/2, sub['개별'], w, color='#2a6fd0', label='개별')\n",
    "    ax.bar(x + w/2, sub['배심원'], w, color='#dd6229', label='배심원')\n",
    "    for i, (a, b) in enumerate(zip(sub['개별'], sub['배심원'])):\n",
    "        d = (b - a) * 100\n",
    "        ax.text(i, max(a, b) + .06, f'{d:+.0f}', ha='center', fontsize=9.5,\n",
    "                color='#c0392b' if d < 0 else '#dd6229', fontweight='bold')\n",
    "    tot = summarize(judg[judg['조건'] == cname])\n",
    "    ax.set_title('%s   개별 %.3f · 배심원 %.3f' %\n",
    "                 (cname, tot['개별'][0], tot['배심원'][0]), fontsize=11, loc='left')\n",
    "    ax.set_xticks(x); ax.set_xticklabels(['1', '2', '3', '4', '5'])\n",
    "    ax.set_ylim(0, 1.15); ax.set_yticks([0, .5, 1.0])\n",
    "    ax.set_yticklabels(['0', '50%', '100%'])\n",
    "    ax.grid(axis='y', color='#e6e5e1'); ax.set_axisbelow(True)\n",
    "    for s in ('top', 'right', 'left'):\n",
    "        ax.spines[s].set_visible(False)\n",
    "    ax.tick_params(length=0)\n",
    "axes[0][0].legend(frameon=False, ncol=2, loc='upper left', fontsize=9)\n",
    "fig.suptitle('네 조건 — 정답 등급별 개별·배심원 정확률', fontsize=14, x=0.02, ha='left')\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 그림 설명\n",
    "\n",
    "네 칸이 각각 한 조건이다. 가로축은 정답 등급 1~5, 막대 위 숫자는 배심원 이득(%p)이다.\n",
    "\n",
    "**A → B (루브릭을 주면)** 정답 3이 0.49에서 0.83으로 크게 오른다. 채점 기준을 알려주니\n",
    "\"같은 분야인데 초점이 다르다\"는 판단이 가능해진 것이다. 다만 정답 4는 오히려 떨어진다.\n",
    "기준표가 판정을 전반적으로 아래로 당기는데, 정답 4에서는 그 이동이 지나쳤다.\n",
    "\n",
    "**A → C (틀린 힌트를 주면)** 모든 등급이 무너진다. 전체 배심원 정확률이 0.579에서\n",
    "0.311로 절반 가까이 떨어진다. **그리고 배심원 이득이 사라진다.** 모든 심판이 같은\n",
    "힌트를 보고 같은 방향으로 끌려가므로, 7명의 의견이 사실상 한 명분이 된다.\n",
    "\n",
    "**C → D (힌트가 있어도 기준표가 있으면)** 0.311에서 0.586으로 회복된다.\n",
    "기준표가 힌트의 피해를 막아 주지만 완전히 상쇄하지는 못한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "피벗 = 전체.set_index('조건').loc[조건순]\n",
    "루브릭효과_앵커없음 = 피벗.loc['B 루브릭', '배심원'] - 피벗.loc['A 기본', '배심원']\n",
    "루브릭효과_앵커있음 = 피벗.loc['D 루브릭+앵커', '배심원'] - 피벗.loc['C 앵커', '배심원']\n",
    "앵커효과_루브릭없음 = 피벗.loc['C 앵커', '배심원'] - 피벗.loc['A 기본', '배심원']\n",
    "앵커효과_루브릭있음 = 피벗.loc['D 루브릭+앵커', '배심원'] - 피벗.loc['B 루브릭', '배심원']\n",
    "\n",
    "print('배심원 정확률 기준 효과 (%p)\\n')\n",
    "print('  루브릭 효과   앵커 없을 때 %+6.1f      앵커 있을 때 %+6.1f'\n",
    "      % (루브릭효과_앵커없음 * 100, 루브릭효과_앵커있음 * 100))\n",
    "print('  앵커  효과   루브릭 없을 때 %+6.1f     루브릭 있을 때 %+6.1f'\n",
    "      % (앵커효과_루브릭없음 * 100, 앵커효과_루브릭있음 * 100))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**표 읽는 법.** 같은 조작이라도 상대 조건에 따라 효과 크기가 크게 달라진다.\n",
    "이것을 **상호작용**이라 한다.\n",
    "\n",
    "- 루브릭은 앵커가 없을 때 +7.6%p지만, **앵커가 있을 때는 +27.4%p로 3배 이상** 커진다\n",
    "- 앵커의 피해는 루브릭이 없을 때 −26.8%p지만, **루브릭이 있으면 −6.9%p로 4분의 1로 줄어든다**\n",
    "\n",
    "**채점 기준표의 가치는 정확도를 조금 올리는 데 있지 않고, 판정이 외부 정보에 휘둘리지\n",
    "않게 붙잡아 주는 데 있다.**"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "# 9. 요약\n",
    "\n",
    "**1. 다수결은 조건부로만 이득이다.**\n",
    "전체로는 +7.8%p지만, 정답 2 구간에서는 −6.1%p로 손해였다.\n",
    "심판들이 같은 방향으로 틀리면 중앙값은 그 오답을 굳힌다.\n",
    "\n",
    "**2. 판정 독립성이 가장 중요하다.**\n",
    "앞선 답을 보여준 것만으로 배심원 정확률이 0.579에서 0.311로 떨어졌고,\n",
    "배심원 이득은 +7.8%p에서 +0.4%p로 사라졌다.\n",
    "**여러 모델을 모으는 것만으로는 신뢰도가 오르지 않는다. 어떻게 모으는지가 결정한다.**\n",
    "\n",
    "**3. 채점 기준표는 방어막이다.**\n",
    "정확도 개선(+7.6%p)보다 앵커 방어(피해를 −26.8%p에서 −6.9%p로) 효과가 훨씬 크다.\n",
    "\n",
    "**4. 난이도는 정답 등급의 순서와 다르다.**\n",
    "정답 5가 가장 쉽고 정답 2가 가장 어려웠다. 양 끝이 쉽고 가운데가 어렵다는 가정은\n",
    "절반만 맞았다.\n",
    "\n",
    "**5. 코사인은 보조지표까지가 적정하다.**\n",
    "정답 등급과 강하게 상관(rho ≈ 0.86)하므로 정답의 타당성을 뒷받침하지만,\n",
    "정답 2와 3을 구분하지 못하고 정답 등급과 구조적으로 얽혀 있어\n",
    "난이도 기준으로 쓰기에는 부적절하다.\n",
    "\n",
    "---\n",
    "\n",
    "### 한계\n",
    "\n",
    "- 심판 7개 모두 **추론 기능을 끈 상태**로 실행했다. 소형~중형 모델의 결과이므로\n",
    "  \"LLM 일반\"으로 확대 해석할 수 없다.\n",
    "- 문항의 **정답이 AI 생성물**이다. 사람 라벨 자료(KLUE-STS 등)로 교차 검증이 필요하다.\n",
    "- 정답 2 문항 일부에 **라벨 오류**가 확인되었다. 재검토 후 재집계하면 정답 2 구간의\n",
    "  수치는 달라진다."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}