LabHub
배우기 러닝패스 코스

Transformers — Compute Attention By Hand

Build the Loss by Hand

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

로짓에서 출발해 "이 모델이 얼마나 잘한다" 를 숫자 하나로 만드는 과정을 표준 라이브러리만으로 직접 짠다. 안정적인 log-softmax, 한 자리의 손실, 여러 자리의 평균인 교차 엔트로피, 지수로 되돌린 퍼플렉시티까지 올라간 뒤, 라벨을 한 칸 미는 일과 패딩 자리를 빼는 일이 그 숫자를 얼마나 바꾸는지 나란히 놓고 잰다. 마지막으로 밑이 2 인 로그로 옮겨 비트/토큰으로도 읽는다.

왜 중요한가

학습도 평가도 이 숫자 하나를 보고 움직인다. 그런데 이 숫자를 만드는 과정에는 오류를 내지 않으면서 조용히 틀리는 자리가 네 곳 있다. 로그를 언제 취했는가, 라벨을 한 칸 밀었는가, 패딩을 뺐는가, 로그의 밑이 무엇인가. 넷 다 코드로는 한 줄이고, 틀려도 예외가 나지 않으며, 대개 숫자가 좋아지는 쪽으로 틀린다. 그래서 의심할 계기가 없다. 이 실습은 실제 모델을 부르지 않는다. 이 파드의 시스템 파이썬에는 numpy·torch·transformers 가 없다. 대신 자리마다 어휘 전체에 대한 점수 한 줄을 결정적으로 만들어 두고 그 위에서 같은 계산을 손으로 짠다. 그래서 "어느 모델의 퍼플렉시티는 얼마" 같은 말은 여기서 하지 않는다. 나오는 숫자는 전부 여러분이 만든 자료로 잰 것이다. 이웃 모듈이 분포에서 하나를 고르는 법(온도·top-k·top-p)이라면 여기는 그 분포가 얼마나 틀렸는지 재는 법이다. 고르기 전에 재는 일이 먼저다. 채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 로짓으로 함수를 직접 두드려 보고, 채점기가 따로 계산한 값과 대조한다. 입력은 실행마다 바뀌므로 값을 외워 넣을 수 없다.

단계

  1. /root/work/tf-loss/loss.pyVOCAB·PAD_ID·SEQ·dataset()log_softmax(xs) 를 만드세요. 확률을 거치지 않고 로짓에서 바로 로그 확률로 갑니다.
  2. NEG_INFnaive_log_softmax(xs) 를 더해 일부러 틀린 순서의 판을 만드세요. 확률을 먼저 구한 뒤 로그를 취해 바닥에서 -inf 가 나는 것을 재현합니다.
  3. token_loss(logits, target) 을 더해 한 자리의 손실을 재게 하세요. 정답 토큰의 로그 확률을 뒤집은 값입니다.
  4. cross_entropy(rows, targets) 를 더해 여러 자리의 손실을 평균 내게 하세요.
  5. perplexity(rows, targets)uniform_perplexity(vocab_size, length) 를 만드세요. 균등 분포에서 퍼플렉시티가 어휘 크기와 같아지는 것을 확인합니다.
  6. shift_pairs(rows, ids)·shifted_loss(rows, ids)·unshifted_loss(rows, ids) 를 만들어 라벨을 한 칸 밀었을 때와 안 밀었을 때를 나란히 재세요.
  7. kept_positions(targets, pad_id)masked_cross_entropy(rows, targets, pad_id) 를 만들어 패딩 자리를 빼고 재게 하세요. 분자와 분모 둘 다에서 뺍니다.
  8. bits_per_token(loss)·nats_per_token(bits) 를 더하고, /root/work/tf-loss/loss_report.json/root/work/tf-loss/loss_report.md 에 잰 값을 기록하세요.

참고

로짓에서 바로 로그 확률로

/root/work/tf-loss/loss.pyVOCAB(12 이상)·PAD_ID·SEQ(16개 이상, 뒤의 3개 이상이 PAD_IDdataset()log_softmax(xs) 를 만드세요. dataset()(로짓 줄 목록, 토큰 번호 목록) 을 돌려주고 난수를 쓰지 않습니다. log_softmax 는 확률을 거치지 않고 로짓에서 바로 로그 확률을 냅니다.

mkdir -p /root/work/tf-loss. 수식은 x_i - (max + log sum exp(x - max)) 한 줄입니다. 나눗셈이 없다는 점이 요점입니다 — 확률을 만들어 나눈 뒤 로그를 취하면 아주 작은 확률이 0.0 이 되어 로그가 무너집니다. log_softmax([0.0, -800.0]) 의 둘째 칸이 유한한 값(-800 근처)이면 제대로 된 것입니다. dataset() 은 자리 t 의 줄이 자리 t+1 의 토큰에 가장 높은 점수를 주도록 만들고, 패딩이 정답인 자리에는 점수를 더 크게 얹으세요.

일부러 무너뜨려 본다

NEG_INFnaive_log_softmax(xs) 를 더하세요. 이번에는 확률을 먼저 구한 뒤 로그를 취합니다. 확률이 0.0 으로 내려앉은 자리는 math.log 가 예외를 던지므로 직접 NEG_INF 로 채웁니다. 같은 입력에서 log_softmax 는 유한한데 이쪽만 -inf 가 나는 것을 확인하세요.

NEG_INF = float("-inf") 입니다. 순서만 바꾸면 됩니다 — exp 한 값을 합으로 나눠 확률을 만들고, 그 확률의 로그를 취합니다. 가운데 값들에서는 앞 단계 함수와 같은 답이 나오고 바닥에서만 갈립니다. [0.0, -800.0] 처럼 차이가 큰 줄을 넣어 보세요. 확률이 0.0 인지 보고 걸러야지, math.log 를 그냥 부르면 예외로 끝납니다.

한 자리의 손실

token_loss(logits, target) 을 더하세요. 정답 토큰에 모델이 준 로그 확률을 뒤집은 값입니다. 정답에 확률 1 을 주었으면 0 이고, 확률이 작아질수록 커집니다.

한 줄입니다 — -log_softmax(logits)[target]. 부호를 뒤집는 것을 잊으면 값이 전부 음수가 되어 "손실이 내려간다" 는 말이 뒤집힙니다. 로짓 자체를 쓰면 안 됩니다. 다른 토큰에 무엇을 주었는지는 따로 세지 않습니다 — 합이 1 이라 정답의 몫이 곧 나머지의 몫입니다.

여러 자리의 평균

cross_entropy(rows, targets) 를 더하세요. 자리마다 token_loss 를 내어 평균을 돌려줍니다. targets 가 비면 0.0 입니다.

합이 아니라 평균입니다. 합으로 재면 긴 문장이 늘 나쁜 문장이 되어 길이가 다른 글을 견줄 수 없습니다. zip(rows, targets) 로 짝지어 더한 뒤 len(targets) 로 나누세요. 여기서 무엇을 분모에 넣는가가 7단계에서 다시 문제가 됩니다.

퍼플렉시티의 눈금

perplexity(rows, targets)uniform_perplexity(vocab_size, length) 를 만드세요. 앞의 것은 exp(평균 손실) 이고, 뒤의 것은 모든 점수가 같은 로짓 줄을 만들어 퍼플렉시티를 잽니다. 결과가 vocab_size 와 같아지는지 확인하세요.

math.exp(cross_entropy(rows, targets)) 한 줄입니다. 자리마다 exp 를 취해 평균 내는 것과 헷갈리기 쉬운데, 균등 분포에서는 두 값이 우연히 같아져서 그 시험으로는 구별되지 않습니다. uniform_perplexity[[0.0] * vocab_size] * length 꼴의 줄을 만들고 아무 정답이나 넣으면 됩니다 — 모든 확률이 1/V 이므로 손실은 log V, 지수를 취하면 V 입니다. 이 값이 퍼플렉시티 눈금의 출발점입니다.

라벨은 한 칸 밀려 있다

shift_pairs(rows, ids)·shifted_loss(rows, ids)·unshifted_loss(rows, ids) 를 만드세요. shift_pairs(rows[:-1], ids[1:]) 로 자리 t 의 로짓이 자리 t+1 의 토큰을 맞히도록 짝을 맞춥니다. 나머지 둘은 민 판과 안 민 판의 교차 엔트로피입니다.

마지막 줄에는 맞힐 다음 토큰이 없고 첫 토큰에는 그것을 예측한 줄이 없습니다. 그래서 로짓은 뒤를, 토큰은 앞을 하나씩 버립니다. 방향을 반대로 밀면(rows[1:], ids[:-1]) 이미 본 것을 답으로 내주는 꼴이라 숫자가 이상하게 좋아집니다. 여러분의 자료에서 민 쪽 손실이 안 민 쪽보다 확실히 작아야 합니다 — dataset() 이 자리 t+1 의 토큰에 점수를 얹어 두었기 때문입니다.

패딩을 빼고 잰다

kept_positions(targets, pad_id)masked_cross_entropy(rows, targets, pad_id) 를 만드세요. 앞의 것은 정답이 패딩이 아닌 자리의 번호 목록이고, 뒤의 것은 그 자리들만 더해 그 자리 수로 나눈 평균입니다. 남는 자리가 없으면 0.0 입니다.

빼는 곳이 두 군데입니다 — 더하는 쪽과 나누는 쪽. 나누는 쪽을 빠뜨려 전체 길이로 나누면 값이 조용히 작아지고, 방향이 늘 좋아지는 쪽이라 의심할 계기가 없습니다. kept_positions 를 따로 두면 무엇이 남았는지 눈으로 볼 수 있습니다. 민 짝에 이 함수를 쓰면 패딩을 넣고 잰 값보다 커져야 합니다 — 패딩이 맞히기 쉬운 자리이기 때문입니다.

네 숫자를 나란히 놓는다

bits_per_token(loss)nats_per_token(bits) 를 더하고, /root/work/tf-loss/loss_report.jsonvocab_size·pad_id·seq_len·pad_count·kept·dropped·unshifted_loss·shifted_loss·masked_loss·unshifted_perplexity·shifted_perplexity·masked_perplexity·bits_per_token·uniform_perplexity·probe_gap·naive_is_inf·stable_logprob 을, /root/work/tf-loss/loss_report.md## 무엇을 쟀나 ## 한 칸 어긋나면 ## 패딩을 빼면 ## 비트로 재면 네 절로 쓰세요.

숫자는 손으로 적지 말고 여러분의 코드를 실제로 돌려 얻은 값으로 채우세요. masked_lossshift_pairs 로 민 짝에 masked_cross_entropy 를 쓴 값이고, shifted_loss 는 같은 짝을 마스킹 없이 잰 값입니다. bits_per_tokenmasked_loss 를 기준으로 냅니다 — 2 ** 그 값masked_perplexity 와 같은지 직접 확인해 보세요. uniform_perplexityuniform_perplexity(VOCAB, 8) 입니다. probe_gap 은 800 으로 고정이고 naive_is_infnaive_log_softmax([0.0, -800.0])[1]-inf 인지, stable_logprob 은 같은 입력에서 log_softmax 가 낸 둘째 칸입니다.