損失をひとつ手で作る
한국어 원문으로 표시합니다.
목표
로짓에서 출발해 "이 모델이 얼마나 잘한다" 를 숫자 하나로 만드는 과정을 표준 라이브러리만으로 직접 짠다. 안정적인 log-softmax, 한 자리의 손실, 여러 자리의 평균인 교차 엔트로피, 지수로 되돌린 퍼플렉시티까지 올라간 뒤, 라벨을 한 칸 미는 일과 패딩 자리를 빼는 일이 그 숫자를 얼마나 바꾸는지 나란히 놓고 잰다. 마지막으로 밑이 2 인 로그로 옮겨 비트/토큰으로도 읽는다.
왜 중요한가
학습도 평가도 이 숫자 하나를 보고 움직인다. 그런데 이 숫자를 만드는 과정에는 오류를 내지 않으면서 조용히 틀리는 자리가 네 곳 있다. 로그를 언제 취했는가, 라벨을 한 칸 밀었는가, 패딩을 뺐는가, 로그의 밑이 무엇인가. 넷 다 코드로는 한 줄이고, 틀려도 예외가 나지 않으며, 대개 숫자가 좋아지는 쪽으로 틀린다. 그래서 의심할 계기가 없다. 이 실습은 실제 모델을 부르지 않는다. 이 파드의 시스템 파이썬에는 numpy·torch·transformers 가 없다. 대신 자리마다 어휘 전체에 대한 점수 한 줄을 결정적으로 만들어 두고 그 위에서 같은 계산을 손으로 짠다. 그래서 "어느 모델의 퍼플렉시티는 얼마" 같은 말은 여기서 하지 않는다. 나오는 숫자는 전부 여러분이 만든 자료로 잰 것이다. 이웃 모듈이 분포에서 하나를 고르는 법(온도·top-k·top-p)이라면 여기는 그 분포가 얼마나 틀렸는지 재는 법이다. 고르기 전에 재는 일이 먼저다. 채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 로짓으로 함수를 직접 두드려 보고, 채점기가 따로 계산한 값과 대조한다. 입력은 실행마다 바뀌므로 값을 외워 넣을 수 없다.
단계
- /root/work/tf-loss/loss.py 에
VOCAB·PAD_ID·SEQ·dataset()과log_softmax(xs)를 만드세요. 확률을 거치지 않고 로짓에서 바로 로그 확률로 갑니다. NEG_INF와naive_log_softmax(xs)를 더해 일부러 틀린 순서의 판을 만드세요. 확률을 먼저 구한 뒤 로그를 취해 바닥에서-inf가 나는 것을 재현합니다.token_loss(logits, target)을 더해 한 자리의 손실을 재게 하세요. 정답 토큰의 로그 확률을 뒤집은 값입니다.cross_entropy(rows, targets)를 더해 여러 자리의 손실을 평균 내게 하세요.perplexity(rows, targets)와uniform_perplexity(vocab_size, length)를 만드세요. 균등 분포에서 퍼플렉시티가 어휘 크기와 같아지는 것을 확인합니다.shift_pairs(rows, ids)·shifted_loss(rows, ids)·unshifted_loss(rows, ids)를 만들어 라벨을 한 칸 밀었을 때와 안 밀었을 때를 나란히 재세요.kept_positions(targets, pad_id)와masked_cross_entropy(rows, targets, pad_id)를 만들어 패딩 자리를 빼고 재게 하세요. 분자와 분모 둘 다에서 뺍니다.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.py를 파이썬 모듈로 불러VOCAB·PAD_ID·SEQ·dataset·log_softmax·NEG_INF·naive_log_softmax·token_loss·cross_entropy·perplexity·uniform_perplexity·shift_pairs·shifted_loss·unshifted_loss·kept_positions·masked_cross_entropy·bits_per_token·nats_per_token을 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. VOCAB은 12 이상,SEQ는 16개 이상의 토큰 번호 목록이고 모든 번호가 0 부터VOCAB미만이어야 합니다. 뒤의 3개 이상이PAD_ID이고 그 앞에는PAD_ID가 없어야 합니다.PAD_ID도 0 부터VOCAB미만입니다.dataset()은(로짓 줄 목록, 토큰 번호 목록)을 돌려줍니다. 줄 수는len(SEQ)와 같고 줄마다 길이는VOCAB입니다. 난수를 쓰지 마세요 — 두 번 불러 같은 값이 나와야 합니다.dataset()의 자리 t 의 줄은 자리 t+1 에 실제로 오는 토큰이 가장 높은 점수를 받도록 만드세요. 그래야 라벨을 미는 일이 왜 필요한지가 숫자로 드러납니다. 문장이 끝난 뒤의 패딩 자리에는 점수를 더 크게 얹으세요 — 맞히기 쉬운 자리를 평균에 넣으면 값이 좋아 보인다는 것을 보이기 위해서입니다.log_softmax(xs)는x_i - (max + log sum exp(x - max))입니다. 확률을 만들어 나눈 뒤 로그를 취하면 안 됩니다.log_softmax([0.0, -800.0])은 두 칸 모두 유한해야 하고 둘째 칸은 -800 근처입니다.naive_log_softmax(xs)는 반대로 확률을 먼저 만든 뒤 로그를 취합니다.math.log(0.0)은 예외를 던지므로 확률이 0.0 인 자리는 직접NEG_INF로 채우세요. 같은 입력에서log_softmax는 유한한데 이쪽만-inf가 나는 것이 이 단계의 요점입니다.token_loss(logits, target)은-log_softmax(logits)[target]입니다. 부호를 뒤집는 것을 잊지 마세요.cross_entropy(rows, targets)는 합이 아니라 평균입니다.targets가 비면 0.0 을 돌려주세요.perplexity(rows, targets)는exp(평균 손실)입니다. 자리마다exp를 취해 평균 내는 것이 아닙니다 — 균등 분포에서는 두 값이 우연히 같아지므로 그것만으로는 구별되지 않습니다.uniform_perplexity(vocab_size, length)는 모든 점수가 같은 로짓 줄length개를 만들어 퍼플렉시티를 잽니다. 결과는vocab_size와 같아야 합니다.shift_pairs(rows, ids)는(rows[:-1], ids[1:])입니다. 로짓은 뒤를 하나 버리고 토큰은 앞을 하나 버립니다.shifted_loss는 그 짝으로 잰 교차 엔트로피이고,unshifted_loss는 밀지 않고rows와ids를 그대로 넘겨 잰 값입니다. 여러분의 자료에서는 민 쪽이 더 작아야 합니다.masked_cross_entropy(rows, targets, pad_id)는targets[i] != pad_id인 자리만 더하고 그 자리 수로 나눕니다. 전체 길이로 나누면 값이 조용히 작아집니다. 남는 자리가 없으면 0.0 입니다.- 8단계 보고서는
dataset()을 한 번 불러 얻은 한 벌로 잽니다.shift_pairs로 민 짝에서masked_loss를 재고, 밀지 않은 값은unshifted_loss로 잽니다.bits_per_token은masked_loss를 기준으로 냅니다.probe_gap은 800 으로 고정이고,naive_is_inf는naive_log_softmax([0.0, -800.0])[1]이-inf인지,stable_logprob은log_softmax([0.0, -800.0])[1]입니다. - 이 파드에는 인터넷이 없습니다.
pip install은 되지 않고 시스템 파이썬에는 numpy·torch·transformers 가 없습니다. numpy 는/opt/onnx-lab/bin/python안에만 있습니다.import math하나면 충분합니다. - 공식 문서: Attention Is All You Need · Python — math · Python — statistics · Hugging Face — Text generation
- 흔한 실수: 확률을 먼저 만들고 로그 취하기, 손실의 부호를 안 뒤집기, 평균 대신 합 쓰기, 자리마다
exp를 취해 평균 내기, 라벨을 반대로 밀기, 마스킹에서 분모를 안 고치기, 밑이 2 인 로그와 자연로그를 섞어 적기.
로짓에서 바로 로그 확률로
/root/work/tf-loss/loss.py 에 VOCAB(12 이상)·PAD_ID·SEQ(16개 이상, 뒤의 3개 이상이 PAD_ID)·dataset() 과 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_INF 와 naive_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.json 에 vocab_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_loss 는 shift_pairs 로 민 짝에 masked_cross_entropy 를 쓴 값이고, shifted_loss 는 같은 짝을 마스킹 없이 잰 값입니다. bits_per_token 은 masked_loss 를 기준으로 냅니다 — 2 ** 그 값 이 masked_perplexity 와 같은지 직접 확인해 보세요. uniform_perplexity 는 uniform_perplexity(VOCAB, 8) 입니다. probe_gap 은 800 으로 고정이고 naive_is_inf 는 naive_log_softmax([0.0, -800.0])[1] 이 -inf 인지, stable_logprob 은 같은 입력에서 log_softmax 가 낸 둘째 칸입니다.