지난 글에서 우리 손으로 만든 평가 세트를 갖고 있어야 한다는 이야기를 했다. 그 세트가 생기면 곧바로 다음 질문이 온다. 언제 돌리고, 결과를 무엇과 견주는가.
프롬프트 한 줄을 고쳤다고 하자. 다시 돌렸더니 72점, 고치기 전에도 72점이었다. 아무 일도 없었으니 배포해도 되겠다고 생각하기 쉽다. 그런데 안을 열어 보면 되던 문제 다섯 개가 깨졌고, 안 되던 문제 다섯 개가 새로 풀렸다. 평균이 같은 것은 두 수가 우연히 상쇄됐기 때문이다.
평균이 감추는 것
회귀(regression)는 예전에 되던 동작이 변경 때문에 깨지는 것을 말한다. 평균 점수는 회귀를 잡지 못한다. 잡으려면 문제 하나하나를 이전 실행과 짝지어 봐야 한다.
같은 100문제를 두 번 돌려 네 칸으로 나눈 표다. 대각선 위쪽 칸 둘은 아무 일도 없었던 문제고, 나머지 두 칸이 이번 변경이 실제로 한 일이다. 오른쪽 위 5건이 회귀, 왼쪽 아래 5건이 개선이다.
이 표를 보고 나면 판단이 달라진다. 「72점 유지」는 배포해도 좋다는 뜻이었지만, 「되던 것 다섯 개가 깨졌다」는 그 다섯 개가 무엇인지 보고 나서 정하자는 뜻이다. 깨진 다섯이 자주 안 오는 질문이면 넘어갈 만하고, 결제 관련 질문이면 못 넘어간다.
코드 회귀 테스트와 다른 점
「그냥 테스트를 짜면 되지 않나」 싶지만, 익숙한 단위 테스트를 그대로 옮기면 며칠 안에 아무도 안 보는 빨간 불이 된다. 성질이 셋 다르다.
| 갈래 | 코드 테스트 | 모델 평가 |
|---|---|---|
| 같은 입력의 결과 | 항상 같다 | 매번 조금씩 다르다 |
| 채점 | 같으냐 다르냐 | 얼마나 맞았느냐 |
| 통과 기준 | 전부 통과 | 기준선 대비 |
첫째, 같은 입력에 같은 출력이 안 나온다. temperature를 0으로 두어도 배치 구성과 커널 선택 때문에 토큰이 달라질 수 있다. 그래서 한 번 실패했다고 곧바로 회귀로 세면 안 된다.
둘째, 채점이 참·거짓이 아니다. 「서울입니다」와 「서울시입니다」를 어떻게 셀 것인지가 매번 문제가 된다. 채점 방식을 바꾸면 점수가 통째로 움직이므로, 채점기도 프롬프트만큼이나 조심해서 버전을 붙여야 한다.
셋째, 전부 통과가 목표가 아니다. 100점을 받는 평가 세트는 이미 쉬운 세트다. 목표는 만점이 아니라 어제보다 나빠지지 않는 것이라서, 기준선을 어딘가에 저장해 두는 일이 테스트 자체보다 중요하다.
비교가 성립하려면 무엇을 고정하나
두 실행을 견주려면 달라진 것이 하나뿐이어야 한다. 실무에서 가장 흔한 사고가 프롬프트도 고치고 모델 별칭도 그대로 둔 채 며칠 뒤에 돌려, 그 사이에 바뀐 모델 때문에 생긴 차이를 프롬프트 탓으로 읽는 것이다.
| 고정할 것 | 안 고정하면 |
|---|---|
| 모델 버전 | 제공사가 별칭 뒤 모델을 갈아 끼우면 원인이 섞인다 |
| 디코딩 설정 | temperature·top_p가 다르면 흔들림 폭이 달라진다 |
| 평가 세트 | 문제를 더하면서 비교하면 어느 쪽 효과인지 모른다 |
| 채점기 | 채점 프롬프트를 고치면 점수 전체가 움직인다 |
| 실행 회수 | 한 번과 세 번 평균은 서로 다른 자다 |
넷을 고정하고 하나만 바꾼다. 굳이 둘을 한꺼번에 바꿔야 한다면, 적어도 어느 쪽 때문인지 모른다는 사실을 기록에 남긴다.
흔들리는 문제를 골라낸다
첫째 성질 때문에 판정 절차가 한 겹 필요하다. 떨어진 문제를 바로 회귀로 세지 않고 몇 번 더 돌려 본다.
가운데 갈래가 실무에서 제일 성가시다. 세 번 중 한두 번만 통과하는 문제는 회귀도 아니고 통과도 아니다. 이런 문제를 격리 목록으로 옮긴다 — 회귀 수에서 빼되 지우지는 않고, 흔들리는 문제가 몇 개인지 따로 센다. 이 수가 늘어나면 그것 자체가 신호다. 보통은 정답이 여럿인데 채점기가 하나만 맞다고 우기는 자리다.
전체를 흔들림이 없는 쪽으로 만들려는 시도는 대체로 실패한다. 대신 평가 세트 전체를 세 번씩 돌려 다수결로 채점하면 흔들림이 크게 줄고, 비용은 세 배가 된다. 세 배를 낼 만한 자리인지는 세트 크기가 정한다.
기준선을 저장한다
기준선은 점수 하나가 아니라 문제별 결과 목록이어야 한다. 짝지어 세려면 그것이 필요하기 때문이다. 파일 하나로 저장소에 넣어 두면 충분하다.
def compare(baseline, current):
"""문제별 통과 여부 두 벌을 짝지어 회귀와 개선을 나눈다."""
regressed, fixed = [], []
for case_id, was_ok in baseline.items():
if case_id not in current:
continue
now_ok = current[case_id]
if was_ok and not now_ok:
regressed.append(case_id)
elif not was_ok and now_ok:
fixed.append(case_id)
return regressed, fixed
case_id가 실행마다 같아야 짝이 맞으므로, 문제에 순번이 아니라 바뀌지 않는 식별자를 붙인다. 순번을 쓰면 세트 가운데에 문제 하나를 끼워 넣는 순간 뒤가 전부 밀려 모든 문제가 회귀로 보인다.
기준선을 언제 갱신하느냐도 정해 두어야 한다. 회귀를 확인하고 받아들이기로 한 순간에만 갱신한다. 실행할 때마다 자동으로 덮어쓰면 매번 직전 실행과 비교하게 되어, 하루에 1점씩 열흘을 떨어져도 아무 경고가 안 뜬다.
CI에 붙일 때
전부를 막는 관문으로 세우면 곧 무시된다. 두 단계로 나누는 편이 오래간다.
- 막는 것: 회귀가 임계값을 넘거나, 절대 깨지면 안 되는 문제 몇 개가 깨진 경우
- 알리는 것: 그 밖의 회귀, 흔들리는 문제 수의 증가, 평균 점수의 하락
「절대 깨지면 안 되는 문제」를 따로 표시해 두는 것이 특히 값을 한다. 열 개쯤이면 충분하다. 안전 관련 거절, 가격을 지어내지 않는지, 사내 규정을 어기지 않는지 같은 것들이다. 이 목록은 회귀 임계값과 무관하게 하나만 깨져도 세운다.
평가 세트가 커지면 매 커밋마다 전부 돌리기 어려워진다. 그때는 커밋마다 부분 집합, 배포 전에 전체로 나눈다. 부분 집합은 무작위로 뽑지 말고 층을 나눠 뽑는다 — 무작위로 50개를 뽑으면 실행마다 세트가 달라져 짝이 안 맞는다.
정리
회귀 테스트가 하는 일은 좋아졌는지 말해 주는 것이 아니라 무엇이 나빠졌는지 세어 보여 주는 것이다. 평균은 그 질문에 답하지 못한다. 짝지어 세고, 흔들리는 것을 갈라 내고, 받아들이기로 한 순간에만 기준선을 옮긴다.
다음 자리는 그 짝의 왼쪽에 놓이는 것 — 무엇을 문제로 삼을 것인가다.
읽어주셔서 감사합니다. 😊

