트랜스포머 — 어텐션을 손으로 계산한다 · 교차 엔트로피와 퍼플렉시티 · 이론
손실 하나로 모델을 재는 법
한 줄 요약
모델이 정답 토큰에 준 확률의 로그를 뒤집어 평균 낸 것이 교차 엔트로피이고, 그것을 지수로 되돌린 것이 퍼플렉시티다. 퍼플렉시티는 "매 자리에서 평균 몇 갈래로 헤매는가" 로 읽는다.
왜 이게 필요했나
학습도 평가도 결국 숫자 하나를 보고 움직인다. 손실이 내려가면 계속 돌리고, 안 내려가면 무언가를 바꾼다. 그런데 그 숫자를 만드는 과정에는 오류를 내지 않으면서 조용히 틀리는 자리가 여러 곳 있다.
실제로 겪는 모습은 이렇다. 손실이 어느 순간 inf 가 되고 다음 배치부터 nan 이 번진다. 또는 손실 곡선이 멀쩡히 내려가는데 생성된 글은 형편없다. 또는 어제 잰 퍼플렉시티와 오늘 잰 값이 다른데 모델은 그대로다. 또는 남의 논문 숫자와 내 숫자가 두 배 차이 나는데 어느 쪽이 틀렸는지 알 수 없다.
원인은 대개 넷 중 하나다. 로그를 언제 취했는가, 라벨을 한 칸 밀었는가, 패딩 자리를 뺐는가, 로그의 밑이 무엇인가. 네 가지 모두 코드로는 한 줄이고, 틀려도 예외가 나지 않는다. 그래서 직접 재 보기 전에는 보이지 않는다.
로그를 언제 취하는가
소프트맥스는 점수 줄을 합이 1 인 분포로 바꾼다. 우리가 필요한 것은 그 확률이 아니라 로그 확률이다. 그러면 "확률을 구한 뒤 로그를 취하면 되지 않나" 싶은데, 그 순서가 바닥에서 무너진다.
배정밀도 실수가 담을 수 있는 가장 작은 양수는 5e-324 근처다. 정답 토큰의 점수가 나머지보다 한참 아래라면 그 확률은 그 아래로 내려가 0.0 으로 내려앉는다. 0 의 로그는 없다. 파이썬의 [math.log](https://docs.python.org/3/library/math.html) 는 그 자리에서 예외를 던지고, 예외를 피해 -inf 를 넣어 두면 그 자리의 손실이 +inf 가 되어 평균을 내는 순간 문장 전체가 inf 로 물든다.
답은 확률을 아예 만들지 않는 것이다.
# log p_i = x_i - (max + log sum exp(x - max))top = max(xs)lse = top + math.log(sum(math.exp(x - top) for x in xs))logp = [x - lse for x in xs]여기에는 나눗셈이 없다. 큰 값을 빼고 exp 하므로 넘치지도 않고, 로그 확률을 뺄셈으로 얻으므로 바닥나지도 않는다. 확률이 아무리 작아도 그 로그는 그냥 작은 음수일 뿐이다. 프레임워크가 소프트맥스와 손실을 한 함수로 묶어 파는 이유가 이것이다 — 두 연산을 따로 부르면 그 사이에서 정보가 사라진다.
기존 실습에서 다룬 소프트맥스 안정화는 위쪽을 막는 일이었다(exp 가 넘치는 것). 여기서 막는 것은 아래쪽이다. 같은 max 빼기가 두 가지 일을 하고 있지만, 무너지는 자리도 증상도 다르다.
한 자리에서 문장으로
한 자리의 손실은 한 줄이다.
손실(t) = -log p(정답 토큰 t)
정답에 확률 1 을 주었으면 0, 확률이 작아질수록 커진다. 다른 토큰에 무엇을 주었는지는 따로 세지 않는다. 합이 1 이므로 정답의 몫이 곧 나머지의 몫이기 때문이다.
문장의 손실은 그 값들의 평균이다. 합이 아니라 평균인 이유는 길이가 다른 문장을 견주기 위해서다. 합으로 재면 긴 문장은 늘 나쁜 문장이 된다. [statistics](https://docs.python.org/3/library/statistics.html) 의 평균이 하는 일과 같지만, 무엇을 분모에 넣을지가 뒤에서 문제가 된다.
퍼플렉시티는 눈금을 바꾼 것뿐이다
손실 1.06 이라는 값은 감이 오지 않는다. 지수로 되돌리면 읽히는 숫자가 된다.
퍼플렉시티 = exp(평균 손실)
눈금을 잡는 방법이 하나 있다. 아무것도 모르는 모델을 넣어 보는 것이다. 어휘 전체에 같은 점수를 주면 확률은 1/V 이고 손실은 log V 이므로 퍼플렉시티는 정확히 V, 즉 어휘 크기가 된다. 그러니 퍼플렉시티가 어휘 크기 근처라면 그 모델은 아무것도 배우지 못한 것이고, 그보다 크다면 균등 분포보다 못한 것이다.
여기서 바로 나오는 결론이 하나 있다. 어휘가 다른 두 모델의 퍼플렉시티는 견줄 수 없다. 눈금의 출발점이 다르기 때문이다. 토크나이저가 다르면 같은 글을 나누는 조각 수도 다르니 분모마저 달라진다. 논문의 숫자를 내 숫자와 나란히 놓기 전에 어휘와 토크나이저가 같은지부터 봐야 하는 이유다.
라벨은 한 칸 밀려 있다
언어 모델은 자리 t 의 출력으로 자리 t+1 의 토큰을 맞힌다. [Attention Is All You Need](https://arxiv.org/abs/1706.03762) 의 디코더가 하는 일이 그것이고, [Hugging Face 의 생성 문서](https://huggingface.co/docs/transformers/en/llm_tutorial)가 설명하는 다음 토큰 예측도 같은 이야기다.
그래서 손실을 잴 때 로짓은 뒤를 하나 버리고 토큰은 앞을 하나 버린다. 마지막 줄에는 맞힐 다음 토큰이 없고, 첫 토큰에는 그것을 예측한 줄이 없기 때문이다.
한 칸을 안 밀면 어떻게 되는가. 오류는 나지 않는다. 모델에게 "지금 보고 있는 토큰을 맞혀라" 라고 시킨 꼴이라 손실만 나쁘게 나온다. 반대로 밀어야 할 곳을 두 번 밀거나 방향을 반대로 밀면 숫자가 이상하게 좋아지기도 한다 — 자기가 이미 본 것을 답으로 내주는 셈이기 때문이다. 손실 곡선은 그럴듯한데 생성 결과가 형편없다면 여기를 먼저 본다.
패딩은 공짜 점수다
배치로 묶으려면 짧은 줄을 채워 길이를 맞춘다. 그 채움 토큰은 내용이 아니라 자리 표시다.
문제는 채움 토큰이 맞히기 너무 쉽다는 데 있다. 문장이 끝난 뒤에는 늘 같은 것이 오므로 모델이 금방 확신한다. 그 자리들을 평균에 넣으면 손실이 낮아지고, 채움이 많은 배치일수록 더 낮아진다. 모델은 그대로인데 배치 구성만 바꿔도 숫자가 좋아진다.
빼는 자리는 두 곳이다. 더하는 쪽에서도 빼고 나누는 쪽에서도 뺀다. 나누는 쪽을 빠뜨리면 남은 자리의 손실을 전체 길이로 나누게 되어 값이 조용히 작아진다. 이 실수는 특히 찾기 어렵다 — 방향이 늘 "좋아지는" 쪽이라 의심할 계기가 없기 때문이다.
비트로 재면
로그의 밑을 2 로 바꾸면 단위가 비트/토큰이 된다. 나누기 한 번이다.
비트/토큰 = 자연로그 손실 / ln 2
밑만 바꾼 것이므로 2 ** 비트 는 exp(자연로그 손실) 과 같은 값이다. 같은 것을 다른 자로 읽었을 뿐인데, 압축 쪽 문헌은 비트로 적고 딥러닝 쪽은 자연로그로 적는 일이 많아 숫자만 보면 두 배 가까이 차이 나는 것처럼 보인다. 남의 표를 옮겨 적기 전에 밑을 확인해야 한다.
현장에서 만나는 모습
첫째, 손실이 갑자기 inf 나 nan 이 된다. 확률을 먼저 만들고 로그를 취한 코드에서 아주 낮은 확률을 만난 순간이다. 로그 확률을 뺄셈으로 얻도록 고치면 사라진다.
둘째, 손실은 내려가는데 생성물이 나쁘다. 라벨 이동이 어긋났는지부터 본다. 답을 미리 보여 주고 맞히라고 시키면 손실은 얼마든지 내려간다.
셋째, 같은 모델의 퍼플렉시티가 실행마다 다르다. 평가 배치의 패딩 비율이 달라졌을 가능성이 크다. 마스킹을 제대로 하면 배치 구성이 바뀌어도 값이 흔들리지 않는다.
넷째, 남의 숫자와 두 배 차이 난다. 로그의 밑, 어휘 크기, 토크나이저, 분모를 토큰으로 잡았는지 낱말로 잡았는지를 차례로 맞춰 본다. 대개 그중 하나다.
다섯째, 손실 하나만 보고 배포했다가 사고가 난다. 퍼플렉시티는 "다음 토큰을 얼마나 잘 맞히는가" 이지 "쓸모 있는 답을 하는가" 가 아니다. 내려가는 것을 확인하는 용도로는 좋지만, 그것만으로 좋은 모델이라고 말할 수는 없다.
실무에서 진짜 중요한 것
- 로짓에서 바로 로그 확률로 가라. 확률을 거치는 순간 바닥에서 정보가 사라진다.
- 눈금을 먼저 잡아라. 균등 분포의 퍼플렉시티가 어휘 크기라는 것 하나만 알아도 자기 숫자가 말이 되는지 판단할 수 있다.
- 마스킹은 분자와 분모 둘 다에서 한다. 한쪽만 빼면 값이 조용히 좋아진다.
- 비교하려면 조건을 적어라. 어휘 크기, 토크나이저, 로그의 밑, 패딩 처리 — 이 네 가지가 같지 않으면 두 숫자는 다른 것을 잰 것이다.
다음 실습에서 할 것
/root/work/tf-loss/loss.py 를 한 단계씩 키운다. 실제 모델을 부르는 것이 아니라 같은 계산을 표준 라이브러리만으로 직접 만든다 — 이 파드의 시스템 파이썬에는 numpy 도 torch 도 transformers 도 없고, numpy 는 /opt/onnx-lab/bin/python 안에만 있다. 그래서 여기서 나오는 숫자는 전부 여러분이 만든 자료로 잰 것이다.
안정적인 log-softmax 에서 시작한다. 그다음 일부러 틀린 순서의 판을 따로 만들어, 확률을 먼저 구했을 때 -inf 가 실제로 나는 것을 눈으로 본다. 두 함수가 나란히 있어야 어느 자리에서 갈리는지 보인다.
거기서 한 자리의 손실, 여러 자리의 평균, 지수로 되돌린 퍼플렉시티로 올라간다. 아무것도 모르는 모델의 퍼플렉시티가 어휘 크기와 같아지는 것도 직접 확인한다.
마지막 세 단계가 이 실습의 요점이다. 같은 자료를 두고 라벨을 밀었을 때와 안 밀었을 때, 패딩을 뺐을 때와 안 뺐을 때의 숫자를 나란히 놓는다. 네 숫자 모두 오류 없이 나오고 넷 다 그럴듯해 보이는데 서로 다르다는 것 — 그것이 이 모듈이 보여 주려는 전부다. 끝으로 밑이 2 인 로그로 옮겨 비트/토큰으로도 읽는다. 채점기는 여러분의 모듈을 실제로 불러 매번 다른 로짓으로 함수를 직접 두드려 보고, 자기가 따로 계산한 값과 대조한다.