당신의 A/B 평가는 쌍을 이루고 있지만, 통계 테스트는 그렇지 않을 수 있습니다
요약
A/B 테스트 시 프롬프트 성능 평가에서 독립 샘플 방식인 Wald 표준 오차 대신 쌍을 이룬 데이터에 적합한 맥네머 테스트(McNemar test)를 사용해야 함을 설명합니다. 잘못된 통계 모델 사용이 데이터 해석 오류와 불필요한 데이터 수집 요구로 이어질 수 있음을 경고합니다.
핵심 포인트
- 프롬프트 A/B 평가는 동일 데이터셋을 사용하므로 '쌍을 이룬(paired)' 구조임
- 독립 샘플용 Wald SE를 사용하면 성능 차이를 과소평가할 위험이 있음
- 쌍을 이룬 데이터에는 불일치 빈도를 사용하는 맥네머 테스트가 적합함
- 올바른 통계 검정은 더 적은 데이터로도 유의미한 순위 결정을 가능하게 함
쌍을 이룬 평가(Paired eval), 잘못된 테스트: 하나의 100개 항목 세트에서 두 프롬프트에 대해 점수를 매기면 쌍을 이룬 결과(paired outcomes)가 나옵니다. 따라서 이들의 순위를 매기려면 두 비율의 Wald 표준 오차(two-proportion Wald SE)가 아닌 맥네머 테스트(McNemar)가 필요합니다. 동일한 데이터에 대해, Wald는 1.01 SE를 읽고 '데이터를 더 수집하라'고 말했습니다. 반면 맥네머는 2.65 SE를 읽고 순위 매기기를 허용했습니다. 동일한 100개 항목에 대해 정반대의 결정이 내려진 것입니다.
저는 두 설정 간의 차이가 2 표준 오차(standard errors)보다 작을 때 순위를 매기기를 거부하는 작은 평가 도우미(eval helper)를 출시했습니다. 아이디어는 좋았습니다. 거부하는 방식은 정직했습니다. 지난주 이 도구가 동일한 100개 항목 세트에서 점수를 매긴 두 프롬프트를 살펴보고 다음과 같이 출력했습니다:
순위: 구별 불가(INDISTINGUISHABLE) - 6.95 통합 SE(pooled SE) 대비 차이 7.00 pp = 1.01 SE < 2.0. "프롬프트 B"를 "프롬프트 A"보다 높게 순위를 매기는 것은 허용되지 않습니다.
겉보기에 7포인트의 개선이 있었지만, 노이즈(noise)로 처리되었습니다. 해당 문장을 정직하게 해석하면 "항목을 더 수집하라"는 뜻입니다. 그리고 그것은 틀렸습니다. 반올림 오차도 아니었고, 아슬아슬한 차이도 아니었습니다. 정확히 그 데이터에 대해 올바른 테스트를 적용하면 순위는 이미 결정되었으며, 저는 멈출 수 있었습니다.
버그는 산술 연산에 있었던 것이 아닙니다. 산술 연산이 어떤 테스트에서 실행되었느냐의 문제였습니다. 제 도우미는 두 개의 쌍을 이룬 실행(paired runs)을 마치 두 개의 독립적인 샘플(independent samples)인 것처럼 취급하고 있었습니다.
요약(TL;DR)
- 하나의 작업 세트(task set)에 있는 두 개의 프롬프트(prompt)는 쌍을 이룹니다(paired): 각 작업은
(pass_A, pass_B)쌍을 생성하며, 둘 다 통과하거나(both-pass) 둘 다 실패하는(both-fail) 작업은 차이에 대한 정보를 전혀 제공하지 않습니다. - 제가 사용한 하네스(harness)는 두 비율의 차이에 대한 Wald 표준 오차(Standard Error, SE), 즉
pooled = sqrt(se_A**2 + se_B**2)를 사용했습니다. 이는 독립 샘플(independent-samples) 공식이며, 쌍을 이룬 구조(pairing)를 완전히 무시합니다. 제 쇼케이스(showcase)에서 이로 인해 실제 성능 손실이 발생했습니다. - 쌍을 이룬 테스트(paired test)는 맥네머 검정(McNemar test)입니다:
SE = 100*sqrt(b+c)/n공식을 사용하며, 불일치하는 빈도(discordant counts)인b와c만을 사용합니다. 동일한 100개 항목에 대해 이 방식은 1.01이 아닌 2.65 SE를 반환했으며, 순위 산정(ranking)이 가능해졌습니다. 불일치하는 쌍이 단 7개뿐일 때, 도구는 정확한 이항 분포(exact binomial) 값인p=0.0156(z-equivalent 2.42)을 출력하므로, 결정이 정규 근사(normal approximation)에만 의존하지 않습니다. - 제 자체 스윕(sweep)에서 얻은 535개의 실제 쌍을 이룬 관측치(paired observations) 중, 한 쌍은
c=0일 때 Wald 방식으로는 2.65 SE, McNemar 방식으로는 6.24 SE로 나타났습니다. 이는 엄격하게 중첩된(strictly nested) 결과로, Wald 방식은 이를 경계선 바로 위에 있는 것으로 보고하며 결정론적(deterministic)이라고 표시할 방법이 없습니다. - 수정 작업은 약 15줄 정도면 충분합니다. 어려운 점은 공식이 아니라, 자신의 실행(runs)이 쌍을 이루고 있다는 사실을 알아차리는 것입니다.
이것이 무엇이고 무엇이 아닌가. 아래의 두 프롬프트 표는 구성된 예시입니다. 두 테스트가 불일치하는 경계선에 정확히 위치하도록 빈도(counts)를 선택했으므로, 네 개의 숫자로 이를 재현할 수 있습니다. 535개 관측치 표는 제가 작성한 합성 마커 피스처(synthetic marker fixture)에서 가져온 것이며, 누군가의 운영 시스템(production system)에서 가져온 것이 아닙니다. 측정된 것이 아니라 제 상수(constants)에 의해 강제된 숫자가 있는 경우, 동일한 단락 내에 명시했습니다. 이러한 습관이 이 글의 핵심 중 하나입니다.
하나의 세트에서 두 프롬프트가 쌍을 이루는 이유
동일한 100개의 평가 작업(eval tasks)에 프롬프트 A와 프롬프트 B를 실행합니다. 작업 7은 두 프롬프트 모두를 실패하게 하거나, 둘 다 통과하게 하거나, 혹은 정확히 하나만 실패하게 합니다. 마지막 그룹만이 어떤 프롬프트가 더 나은지를 알려줍니다. 둘 다 통과하는 작업과 둘 다 실패하는 작업은 난이도를 공유합니다. 즉, 이들은 두 통과율(pass rates)을 함께 움직일 뿐, 그 차이(gap)에 대해서는 아무것도 말해주지 않습니다.
저를 함정에 빠뜨렸던 사례를 2x2 표로 나타내면 다음과 같습니다:
B pass B fail
A pass 55 0 <- b = 0 (A pass, B fail)
A fail 7 38 <- c = 7 (A fail, B pass)
...```
프롬프트 A는 55번 통과하고, 프롬프트 B는 62번 통과합니다. 동일한 항목들입니다. 100개의 태스크 중 93개는 일치(concordant)합니다: 55개는 둘 다 통과했고, 38개는 둘 다 실패했습니다. 7개의 태스크는 불일치(discordant)하며, 이 7개는 모두 같은 방향으로 움직였습니다. 즉, A가 실패한 곳에서 B가 통과했습니다. 반대 방향으로 움직인 경우는 0개입니다. B의 통과 집합은 A의 통과 집합을 완전히 포함하고 있습니다. 이는 매우 강력한 진술이지만, 두 주변 비율(marginal rates)만을 살펴보는 테스트에는 보이지 않습니다.
## 내 테스트 프레임워크(harness)가 출력한 것, 그리고 왜 그것이 틀린 수치였는가
거절(refusal)은 `rank()` 함수에서 발생했습니다. 내부적으로 이 함수는 두 개의 독립적인 비율(independent proportions)에 대해 배우는 방식과 동일하게 통합 표준 오차(pooled standard error)를 계산합니다:
pooled = sqrt(p1.se ** 2 + p2.se ** 2)
각 `se`는 하나의 주변 비율에 대한 이항 표준 오차(binomial standard error)입니다. 55/100와 62/100의 경우, 이를 통합하면 6.95 포인트가 되며, 7포인트의 차이는 1.01 SE로 나누어집니다. 그리고 가드(guard)는 이를 "구분 불가능(indistinguishable)" 항목으로 분류합니다. 만약 두 샘플이 독립적으로 추출되었다면 공정했을 것입니다. 하지만 그렇지 않았습니다. 93개의 일치하는 태스크는 양쪽 열에 모두 존재하는 동일한 93개의 태스크이며, 공식은 그럼에도 불구하고 이들에 대해 전체 분산(variance)을 부과했습니다.
이러한 수치를 믿는 대가는 실제 예산의 낭비입니다. 각 항목이 1000개라면, 동일한 7포인트 차이가 마침내 기준치를 통과하게 됩니다:
RANK: "prompt B" > "prompt A" - gap 7.00 pp = 3.18 SE >= 2.0. Ranking is allowed.
결국 테스트 프레임워크는 올바른 테스트가 100개의 항목만으로도 이미 내렸을 결론을 얻기 위해 10배의 평가 비용을 요구한 셈입니다. [세트 전체에 대해 판사 모델(judge model)을 실행하기 위해 토큰당 비용을 지불하는](https://finops.spinov.online/blog/llm-judge-cost-deterministic-pre-gate) 사람에게, 이는 통계적 실수에서 청구서로 이어지는 직행 노선과 같습니다.
## McNemar 테스트는 대신 무엇을 하는가
McNemar 테스트 (McNemar's test; Quinn McNemar, Psychometrika, 1947, 쌍을 이루는 명목 데이터(paired nominal data)를 위한 표준 테스트)는 일치하는 쌍(concordant pairs)을 버리고 오직 `b`와 `c`만을 살펴봅니다. 차이의 표준 오차(standard error)는 `100*sqrt(b+c)/n`이 되며, 검정 통계량(test statistic)은 `|c-b|/sqrt(b+c)`가 됩니다. 저는 이를 `mcnemar(name_a, b, name_c, c, n)` 함수와 함께 동일한 라이브러리에 추가했습니다. 정확히 동일한 100개의 항목에 대해 실행하면 다음과 같습니다:
McNEMAR: 불일치 쌍(discordant pairs) prompt A=0, prompt B=7 (일치하는 쌍 93개, n=100).
SE = 2.65 pp (100*sqrt(b+c)/n); 주변 격차(marginal gap) 7.00 pp = 2.65 SE >= 2.0 -> "prompt B"를 "prompt A"보다 상위에 순위를 매기는 것이 허용됨.
적은 불일치 수 (b+c=7 < 25): 여기서는 정규 근사(normal approximation)가 반보수적(anti-conservative)입니다. 정확한 양측 이항 분포(exact two-sided binomial) p=0.0156 (z-equivalent 2.42), 연속성 수정(continuity-corrected) z=2.27. 세 가지 모두 2.0 기준을 통과합니다; 결정은 변하지 않습니다.
...
동일한 2.0 임계값에 대해 2.65 SE가 적용되었습니다. 허용됩니다. 데이터는 같지만, 결정은 정반대입니다. 그리고 세 번째 줄을 주목하십시오. 불일치 쌍이 7개뿐일 때 2.65는 정규 근사치이며, 도구는 제가 이에만 의존하도록 내버려 두지 않습니다. 그 옆에는 정확한 양측 이항 분포 `p=0.0156` (z-equivalent 2.42)과 연속성 수정된 z값인 2.27이 나란히 놓여 있습니다. 세 가지 모두 2.0 기준을 통과합니다. 행에서 가장 큰 숫자를 신뢰하지 않고도 동일한 결정에 도달했습니다. `NESTED` 줄이 발생하는 이유는 하나의 불일치 수가 0이기 때문인데, 이는 두 프롬프트가 양방향 모두에서 의견이 일치한 적이 없음을 의미합니다. 즉, 이 샘플에서 B는 항목별로 A를 압도합니다. 이는 노이즈가 섞인 7점 차이와는 질적으로 다른 것이며, 주변값(marginals)을 통합하는 테스트는 이를 포착할 수 없습니다.
중요하기 때문에 정직하게 한 가지 덧붙입니다. `NESTED`가 0을 포함하는 무엇이든 순위를 매길 수 있는 면죄부는 아닙니다. `b=0, c=1`인 쌍 또한 중첩(nested)되어 있지만, 도구는 이를 1.00 SE로 출력하며 기준치에 전혀 미치지 못합니다. 순위 결정은 여전히 z값에 달려 있습니다. 이 경우 z는 2.65이고 중첩이 엄격하므로 둘 다 일치합니다. 저는 의도적으로 이들을 별도로 표시합니다.
## 535개의 실제 관측치에서도 나타나는 동일한 괴리
구성된 예시는 깔끔하지만, 제가 선을 맞추기 위해 조정할 수 있는 결과물은 무엇이든 불신해야 합니다. 그래서 여기 제가 직접 고르지 않은 데이터에서 나타나는 동일한 현상이 있습니다. 단조롭지만 전체적이지는 않은(monotone-but-not-total) 마커 고정 장치(marker fixture)의 스윕(sweep) 결과인 `P_PATHS=12 W_PER_TICK=3 T_TICKS=400 SEEDS=20`입니다. 여기서 오거부(false reject)는 단일 정수 증인(single-integer witness)이 잘못 거부한 기록된 쓰기(landed write)를 의미합니다.
해당 스윕의 셀(cells)들은 하나의 근본적인 추출(draw)을 공유합니다. 경로(Path)와 결과(outcome)는 축 노브(axis knobs)와 독립적으로 선형 합동 생성기(LCG)에서 나오므로, 각 샘플링된 위치에서의 원시 `(tick, path, outcome)`는 모든 셀에 걸쳐 동일합니다. 실행 과정에서는 다른 작업을 수행하기 전에, 시드(seed)당 33개의 샘플링된 지점 전체에 대해 다음을 확인합니다:
(tick,path,outcome) skew 6:6 vs 7:5 identical: True (33 sampled points)
(tick,path,outcome) streams 8 vs 4 identical: True (33 sampled points)
ALL PAIRED (raw observations identical across cells): True
관측치는 동일하지만 판정(verdict)만 바뀝니다. 이것이 쌍을 이룬(paired) 것의 정의이며, 여기서도 두 비율의 표준 오차(two-proportion SE)가 잘못된 도구인 이유입니다. 오거부 지표를 교차 집계(Cross-tabbing)하면 실제 `b/c` 카운트가 나옵니다. 두 테스트를 나란히 비교하면 다음과 같습니다:
pair | b / c / n | Wald (rank) | McNemar (paired)
6:6 vs 7:5 | 142 / 128 / 535 | 0.87 SE | 0.85 SE
...
세 번째 행을 읽어보십시오. 8:4 대 9:3은 39개의 불일치 쌍(discordant pairs)을 가지며, 모두 한 방향으로 나타나 `c=0`입니다. 실제 카운트에서도 다시 엄격한 중첩(Strict nesting)이 나타납니다. 두 테스트 모두 여기서 2.0 기준을 통과하므로 둘 다 순위(ranking)를 허용합니다. 하지만 Wald는 기준선을 아주 살짝 넘긴 2.65를 보고하는 반면, McNemar은 6.24를 보고하며 중첩(NESTED) 상태임을 알립니다. 이 중 하나는 이 샘플에서 결과가 결정론적(deterministic)임을 알려주며, 다른 하나는 "운 좋게 임계값을 간신히 넘긴 것"과 "결정된 것"을 구분할 수 없습니다. 두 가드(guards)가 모두 전체 내용을 출력하는 해당 쌍은 다음과 같습니다:
PAIR 8:4 vs 9:3 marginal FR: 8:4=172/535 9:3=133/535
8:4: 32.1% (k=172 n=535 SE=2.02)
9:3: 24.9% (k=133 n=535 SE=1.87)
...
해당 표에 대한 두 가지 정직한 관찰 결과가 있습니다. 첫째, 두 테스트는 방향성에 대해 결코 의견이 일치하지 않는 경우가 없습니다. 각 테스트가 어느 버전을 더 높게 평가하든, 다섯 쌍 모두에서 두 테스트의 결과는 일치합니다. 둘째, 2.0 임계값(threshold)에서는 다섯 가지 중 어느 것도 결정이 뒤집히지 않습니다. 첫 번째 행인 6:6 대 7:5의 경우, Wald 테스트에서는 0.87, McNemar 테스트에서는 0.85로 나타나며, 둘 다 기준치 미만으로 기각되었습니다. 두 번째부터 다섯 번째 행은 두 테스트 모두 기준치 위에 있습니다. 변하는 것은 보고된 표준 오차(SE)이며, 때로는 2.31 대 5.55처럼 큰 차이를 보이기도 하고, `c=0` 중첩(nesting)이 언급되는지 여부가 달라집니다.
그리고 그 격차는 양방향으로 나타나며, 이것이 바로 "McNemar가 항상 더 강력하다(more powerful)"는 말이 잘못된 교훈인 이유입니다. 6:6 대 7:5에서 Wald는 0.87인 반면 McNemar는 0.85이며, streams=2 대 streams=1에서는 Wald가 20.01인 반면 McNemar는 15.13입니다. 즉, 불일치 쌍(discordant pairs)이 많고 한쪽으로 치우쳐 있을 때, 독립 표본 표준 오차(independent-samples SE)는 보수적인 것이 아니라 오히려 분산(variance)을 과소평가하게 됩니다. 규칙은 어느 한 테스트가 승리하는 것이 아니라, 데이터에 대해 해당 설계가 요구하는 테스트를 수행해야 한다는 것입니다.
그렇다면 결정이 뒤집히는 지점은 어디일까요? 기준선 근처입니다. 이 특정 스윕(sweep)은 우연히 쌍들을 2.0 라인에서 멀리 떨어뜨려 놓았기 때문에, 두 테스트의 SE가 갈라지더라도 모든 판단에서 두 테스트가 일치하게 됩니다. 구성된 100개 항목의 예시는 바로 그 라인 위에 놓여 있으며, 이곳이 테스트 간의 차이가 미적인 수준을 넘어 '예' 또는 '아니오'로 바뀌는 지점입니다. 저는 이를 속이기 위해 설계한 것이 아닙니다. 제가 지켜본 대부분의 설정 대결(config bake-offs)이 실제로 결정되는 지점이 바로 이처럼 몇몇 항목에 의해 어느 한쪽으로 기우는 곳이기 때문입니다.
## 제가 의도적으로 주장하지 않는 한 가지
여러분은 75.9%, 64.9%와 같은 잘못된 기각(false-reject) 수준을 눈치채고 이에 대해 제가 무언가 말해주기를 원할 수도 있습니다. 저는 말하지 않을 것이며, 동일한 라이브러리가 그 이유입니다. 해당 셀들에 대해 구성-독립성 조사(construction-independence probe)를 실행하면 다음과 같은 결과가 나옵니다.
구성상 강제됨 [streams=8]: 조건부 75.9% (k=406 n=535 SE=1.85)는 비조건부 74.7% (k=493 n=660 SE=1.69)와 구별할 수 없음 (0.48 SE < 2.0)
해당 수준(level)은 `n=660`일 때의 비조건부 통과율(pass-the-gate rate)인 74.7%와 동일합니다. 이는 fixture가 결과(outcome)와 스탬프(stamp)를 서로 다른 LCG 단계에서 추출하기 때문입니다. 따라서 이 수준은 측정값이 아니라 제가 설정한 상수들의 인위적인 결과물(artifact)이며, 저는 이 수치들을 연구 결과로 인용하지 않습니다. 해당 검증(probe)을 통해 살아남는 것은 불일치 구조(discordance structure)인 `b`와 `c`이며, 이것이 진정한 셀 간 비교(between-cell comparison)입니다. McNemar 테스트의 입력값은 실제이지만, 그 옆에 놓인 수준(levels)은 실제가 아닙니다. 검증을 실행하고 그 결과를 보고하는 것만이 제가 이 구분을 신뢰하는 유일한 이유입니다.
## 약 15줄 정도의 수정 방법
특별히 영리한 기법은 없습니다. 불일치 횟수(discordant count)와 제곱근을 사용하는 것뿐입니다:
disc = b + c
conc = n - disc
...
...
_(작성자 생략: 래퍼(wrapper)는 불일치가 없는 경우인 `b==c` 상황과 로컬 출력(localized output)을 처리합니다. 전체 함수는 `measure.py`에 있습니다.)_
공식은 쉬운 부분입니다. 실제로 당신을 보호해 주는 부분은 상류(upstream)에 있으며, 이 함수에는 전혀 포함되어 있지 않습니다. 라이브러리는 당신의 두 실행(runs)이 쌍을 이루고 있는지(paired) 알 수 없습니다. 관측값들이 동일한 항목이며 동일한 순서로 배치되어 있고 오직 결과(outcome)만 바뀌었음을 확인함으로써, 당신이 직접 이를 확립해야 합니다. 이것이 sweep이 `mcnemar()`를 호출하기 전에 실행하는 체크 과정이며, 만약 원시 관측값(raw observations)이 달랐다면 쌍을 이룬 것으로 취급하기를 거부했을 것입니다. 쌍을 이루지 않은 데이터에 쌍체 검정(paired test)을 수행하는 것 자체가 오류입니다.
한계점을 명확히 말씀드리자면, `SE = 100*sqrt(b+c)/n`은 McNemar 테스트에 대한 정규 근사(normal approximation)입니다. 불일치 쌍(discordant pairs)이 몇 개 되지 않는 경우에는 대신 정확한 이항 분포(exact binomial)를 사용해야 합니다. 이것이 `b+c`가 25 미만으로 떨어지면 함수가 자동으로 이를 출력하는 이유이며, 저는 이 출력 없이는 단순한 SE 값을 읽지 않을 것입니다. z 임계값(z threshold) 2.0은 제가 테스트 프레임워크(harness)의 나머지 부분에서 가져온 관례일 뿐, 법칙은 아닙니다. 그리고 이 중 그 어떤 것도 실제로 비용을 발생시키는 실패 모드인 '조용한 허위 수용(silent false accept)'에는 영향을 미치지 않습니다. 왜냐하면 그것은 산술(arithmetic)의 문제가 아니라 당신의 ground truth(실제 참값)에 관한 문제이기 때문입니다.
## 당신의 코드는 어떻게 작동하나요
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기