지난 글에서 후보들을 한 축 위의 점수로 옮겼다. 이제 A가 0.73, B가 0.10이라는 숫자가 손에 있다. 여기서 곧바로 「A가 낫다」로 넘어가고 싶어지는데, 그 전에 물어야 할 것이 하나 있다. 이 차이가 문제를 다시 100개 뽑아도 남아 있을 차이인가.
평가 세트는 있을 수 있는 모든 질문에서 뽑아낸 표본이다. 다른 100개를 뽑았다면 점수가 달랐을 것이고, 그 흔들림보다 작은 차이는 차이가 아니다.
점수 하나의 오차 폭
정답률처럼 맞고 틀리고를 세는 점수라면 오차 폭이 간단한 식으로 나온다. 정답률 를 문제 개로 쟀을 때, 그 추정의 흔들림은 이렇게 잰다.
95% 신뢰구간은 여기에 1.96을 곱해 양쪽으로 벌린 범위다. 신뢰구간은 같은 방식으로 표본을 계속 새로 뽑아 구간을 그리면 그중 95%가 참값을 품는 범위를 말한다.
숫자를 넣어 보면 감이 온다. 100문제로 72%를 받았다면 오차 폭이 대략 ±8.8%p다. 즉 참실력은 63%일 수도 81%일 수도 있다. 여기서 74%를 받은 모델과 견주는 것은 큰 의미가 없다.
이 분모의 제곱근 안에 있어서 문제를 네 배로 늘려야 오차 폭이 절반이 된다. 이 성질이 평가 예산에서 가장 중요한 사실이다. 100문제에서 200문제로 늘려도 폭이 30%밖에 안 줄고, 눈에 띄게 정밀해지려면 자릿수를 올려야 한다.
두 점수의 차이
우리가 실제로 궁금한 것은 점수 하나가 아니라 두 점수의 차이다. 그리고 차이의 오차 폭은 각각의 오차 폭보다 더 넓다 — 흔들리는 것이 둘이기 때문이다.
100문제로 잰 72%와 74%의 차이는 신뢰구간이 −10%p에서 +14%p에 걸쳐 있다. B가 20%p 나쁠 수도 있다는 뜻이라 아무 말도 못 한다. 1,000문제로 늘려도 아직 0을 품는다. 이 2%p를 말하려면 4,000문제가 필요하다.
순위표에서 1점 차이로 순서가 바뀌는 것을 볼 때 이 그림을 떠올리면 된다. 벤치마크 대부분이 수백에서 수천 문제 규모라, 1~2점 차이는 대체로 표본을 다시 뽑으면 뒤집히는 차이다.
같은 문제로 재면 훨씬 적게 든다
그런데 4,000문제를 만들 수는 없다. 다행히 위 계산은 두 모델을 서로 다른 문제 세트로 쟀을 때의 이야기다. 실무에서는 같은 세트를 두 모델에 돌리므로 훨씬 나은 자리에 있다.
같은 문제로 재면 「이 문제가 원래 어려웠나」라는 흔들림이 두 모델에 똑같이 걸려 차이에서 상쇄된다. 남는 것은 결과가 갈린 문제뿐이다. 회귀 테스트에서 본 네 칸 표의 대각선 밖 두 칸이 그것이다.
100문제를 같은 세트로 돌려 A가 72점, B가 82점을 받았고 그 안을 열어 보니 이랬다고 하자.
| B 성공 | B 실패 | |
|---|---|---|
| A 성공 | 70 | 2 |
| A 실패 | 12 | 16 |
둘이 갈린 문제는 14개다. A만 맞힌 것 2개, B만 맞힌 것 12개. 이 두 수만 보고 판정하는 방법을 맥니마 검정이라 부른다.
이 값이면 유의확률이 0.01보다 작다. 우연이라면 열두 대 둘로 갈릴 일이 거의 없다는 뜻이다. 같은 10%p 차이를 두 세트로 따로 쟀다면 오차 폭이 ±11.6%p라 아무 말도 못 했을 텐데, 짝지어 보니 100문제로 결론이 난다.
정리하면 이렇다.
| 재는 방식 | 100문제로 말할 수 있는 차이 |
|---|---|
| 두 세트를 따로 뽑아 잰다 | 12%p 넘어야 한다 |
| 같은 세트를 둘에 돌린다 | 갈린 문제가 한쪽으로 몰리면 6%p로도 된다 |
그러니 평가 세트는 반드시 두 후보에 똑같이 돌린다. 편해서가 아니라 훨씬 예민한 자를 공짜로 얻기 때문이다.
점수가 0과 1이 아닐 때
루브릭 점수나 선호 점수처럼 0과 1 사이의 값이면 위의 이항 공식이 안 맞는다. 이때 편한 방법이 부트스트랩이다 — 가진 결과에서 복원 추출로 표본을 여러 벌 다시 만들어, 그 흔들림을 그대로 오차 폭으로 쓰는 방법이다.
import random
def paired_diff_ci(diffs, rounds=2000, level=0.95):
"""문제별 (B점수 - A점수) 목록에서 차이의 신뢰구간을 구한다."""
n = len(diffs)
means = []
for _ in range(rounds):
sample = [diffs[random.randrange(n)] for _ in range(n)]
means.append(sum(sample) / n)
means.sort()
lo = means[int(rounds * (1 - level) / 2)]
hi = means[int(rounds * (1 + level) / 2) - 1]
return lo, hi
문제별 점수가 아니라 문제별 차이를 넣는 것이 요점이다. 그래야 짝지은 이점이 살아 있고, 구간이 0을 품는지만 보면 판정이 끝난다.
유의한데 쓸모없는 차이
을 키우면 어떤 차이든 언젠가 유의해진다. 10만 문제로 재면 0.2%p 차이도 통계적으로 유의하다. 그러나 그 0.2%p 때문에 추론 비용이 세 배인 모델로 갈아탈 사람은 없다.
그래서 판정을 두 번 한다.
- 통계적 유의성: 이 차이가 표본을 다시 뽑아도 남아 있나
- 실용적 유의성: 남아 있다면, 그만큼이 우리 결정에 영향을 주나
두 번째 질문의 문턱은 통계가 아니라 우리가 정한다. 「정답률이 3%p 이상 오르면 갈아탄다」처럼 재기 전에 미리 적어 두는 것이 좋다. 결과를 보고 나서 문턱을 정하면 결국 원하던 결론이 나온다.
여러 번 견주면 우연이 낀다
프롬프트 후보 스무 개를 만들어 전부 돌린 뒤 제일 좋은 것을 고르는 일이 흔하다. 여기에 함정이 있다. 유의수준 5%로 스무 번 견주면 전부 똑같은 후보라도 하나쯤은 유의하게 나온다.
간단한 대응이 셋 있다.
- 문턱을 나눈다. 스무 번 견줄 거면 5%를 20으로 나눠 0.25%를 문턱으로 쓴다. 보수적이지만 계산이 쉽다
- 다시 확인한다. 1등으로 뽑힌 후보를 홀드아웃 세트에 한 번 더 돌린다. 매일 보지 않고 아껴 둔 세트가 여기서 값을 한다
- 몇 번 봤는지 적는다. 보고서에 「후보 스무 개 중 1등」이라고 적으면 읽는 사람이 알아서 할인해 읽는다
두 번째가 가장 든든하다. 골라내는 데 쓴 세트와 확인하는 데 쓴 세트를 나누는 것은 통계 지식 없이도 지킬 수 있는 규칙이다.
정리
평가 점수는 언제나 표본에서 나온 추정이고, 추정에는 폭이 있다. 폭을 함께 적지 않은 점수는 반쪽이다.
실무에서 기억할 것은 셋이다. 문제 수를 네 배로 늘려야 폭이 절반이 되고, 같은 세트를 두 후보에 돌리면 훨씬 적은 문제로 갈리며, 여러 번 견주면 우연히 이기는 후보가 나온다.
여기까지는 채점 결과가 이미 숫자로 있다고 가정했다. 다음은 그 숫자를 만들어 내는 자리 — 채점 기준을 어떻게 쓰는가다.
읽어주셔서 감사합니다. 😊

