추론 API에서 logprobs 옵션을 켜면 토큰마다 -0.0004, -2.31, -7.85 같은 음수가 딸려 옵니다. 학습 코드를 열면 log_softmax가 있고 그 뒤에 nll_loss가 붙어 있고, 어딘가에는 logsumexp가 있습니다. 이름에 전부 log가 박혀 있습니다.
확률을 다루는데 왜 확률이 아니라 로그 확률이 돌아다니는가 — 이 글은 그 질문 하나에 답합니다. 답은 로그의 성질 두 개로 끝나고, 그 두 개는 이미 배운 것입니다.
지수와 로그의 법칙 자체, 의 정의, 자연로그가 왜 «자연»인지는 지수함수와 로그에서 세웠습니다. 여기서는 그 결과를 도구로 받아 씁니다.
받아 쓰는 두 가지
첫째, 로그는 곱셈을 덧셈으로 보냅니다.
둘째, 로그는 순서를 지킵니다. 이면 언제나 입니다. 이 성질을 단조증가라고 합니다 — 입력이 커지면 출력도 반드시 커지고, 절대 뒤집히지 않는다는 뜻입니다.
이 글에서 쓰는 로그는 전부 밑이 인 자연로그이고 로 적습니다. 딥러닝 라이브러리의 log도 전부 자연로그입니다.
문제 — 확률의 곱은 순식간에 0이 된다
자기회귀 언어모델은 문장의 확률을 토큰 확률의 곱으로 정의합니다.
이 분해가 왜 성립하는지는 다음 글이 맡습니다. 지금 볼 것은 모양입니다 — 0과 1 사이의 수를 번 곱합니다.
토큰 하나의 확률이 평균 라고 해 봅시다. 길이 500짜리 문장의 확률은
입니다. fp32가 표현할 수 있는 가장 작은 양수는 대략 입니다(정규수 기준). 는 그 근처에도 못 갑니다. 컴퓨터는 이 값을 그냥 0.0으로 만들어 버리고, 이렇게 표현 범위 아래로 떨어져 0이 되는 것을 언더플로라고 합니다.
이 한계가 왜 하필 인지는 fp32의 생김새에서 나옵니다. 32비트 중 부호 1비트, 지수부 8비트, 가수부 23비트로 나뉘어 있고, 크기를 정하는 것은 지수부 8비트뿐입니다. 여기에 담기는 지수의 하한이 입니다. 가수부 23비트는 유효숫자를 담당할 뿐 범위를 넓혀 주지 않습니다.
몇 토큰에서 터지는지 세어 봅시다. 이 되는 을 찾으면 되고, 양변에 상용로그를 씌우면
55토큰입니다. 문장 한 줄도 못 갑니다.
정확히는 여기서 곧장 0이 되지는 않고 비정규수 구간으로 떨어집니다 — 지수부가 더는 못 내려가면 가수부를 깎아 가며 까지 버티는 마지막 여유 구간입니다. 대신 유효숫자가 한 자리씩 사라지므로 값은 이미 못 믿습니다. 그 여유마저 다 쓰면 에서 진짜 0.0이 됩니다.
fp64로 바꿔도 구조는 같습니다. 정규수 하한이 이라 441토큰에서 비정규수로 내려가고 463토큰에서 0이 됩니다 — 미루는 것이지 푸는 것이 아닙니다.
import numpy as np
p = np.float32(0.2)
for n in [10, 40, 55, 64, 65]:
print(n, np.float32(p ** n))
# 10 1.02400016e-07
# 40 1.0995123e-28
# 55 3.602883e-39 ← 정규수 아래. 유효숫자가 깎이기 시작한다
# 64 1e-45 ← 남은 자릿수가 한 자리뿐
# 65 0.0 ← 여기서 죽는다
해법 — 로그 공간에서 더한다
곱이 문제였으니 곱을 없앱니다. 양변에 로그를 씌우면 첫 번째 성질이 곱을 합으로 바꿔 줍니다.
이 값을 로그가능도라고 부릅니다 — 가능도(likelihood)는 «주어진 데이터가 이 모델에서 나올 확률»이고, 거기에 로그를 씌운 것입니다. 아까 그 문장이라면
입니다. 은 fp32가 아무 문제 없이 담는 수입니다. 확률의 유효 자릿수가 아니라 지수부만 남기는 것 — 로그가 하는 일이 정확히 이것입니다.
토큰 확률이 전부 1보다 작으니 로그는 전부 음수이고, 그래서 API가 돌려주는 logprobs가 항상 음수입니다. 에 가까울수록 확률 에 가깝고, 는 를 뜻합니다.
logp = np.float32(np.log(0.2))
print(logp * 500) # -804.719
print(np.exp(-2.31)) # 0.09926 ← logprob을 확률로 되돌리기
짧은 예로 한 번 손으로 따라가 봅니다. 다섯 토큰짜리 응답에 API가 이런 logprobs를 돌려주었다고 합시다.
문장 전체의 로그가능도는 그냥 더한 값입니다.
확률로 되돌리면 입니다. 곱으로 했다면 을 계산해야 했고, 값은 같지만 자릿수가 매 단계 줄어듭니다. 다섯 토큰에서는 둘 다 되지만 500토큰에서는 한쪽만 됩니다.
로그 공간에서는 산술 자체가 한 단계씩 내려앉습니다.
| 확률 공간 | 로그 공간 |
|---|---|
| 대응하는 간단한 식이 없다 |
마지막 줄이 이 글의 뒤쪽 절반을 만듭니다.
순서는 그대로다
로그 공간으로 옮기면 값이 완전히 달라집니다. 그런데도 대부분의 코드가 로그값을 끝까지 되돌리지 않고 그대로 씁니다. 두 번째 성질 덕분입니다.
단조증가라는 것은 눈으로도 확인됩니다. 의 도함수가 이고 확률은 언제나 양수이므로 기울기가 정의역 전체에서 양수입니다. 한 번도 내려가지 않는 곡선이라 두 값의 대소가 뒤집힐 자리가 없습니다.
순서가 통째로 보존되므로
입니다. 가장 그럴듯한 후보를 고르는 일에는 확률이 필요 없습니다. 로그값만 비교해도 똑같은 답이 나옵니다.
| 토큰 | 확률 | 로그 확률 |
|---|---|---|
있 |
0.42 | −0.868 |
없 |
0.35 | −1.050 |
같 |
0.23 | −1.470 |
세 값의 순서가 왼쪽과 오른쪽에서 같습니다. 그리디 디코딩도, 빔서치의 후보 정렬도, 분류기의 예측 라벨도 전부 로그 공간에서 그대로 결정됩니다. 빔서치가 후보 문장의 점수를 더해서 비교하는 것도 같은 이유입니다 — 확률이었다면 곱해야 했고, 그러면 언더플로가 돌아옵니다.
그런데 합을 구해야 한다면
곱은 로그가 합으로 바꿔 주는데, 합은 로그가 어떻게 해 주지 못합니다. 를 와 로 예쁘게 푸는 법은 없습니다.
문제는 합이 자주 필요하다는 것입니다. softmax의 분모가 그렇습니다.
여기서 는 모델이 내놓은 실수 점수, 즉 로짓입니다. 로짓이 커지면 가 폭발합니다 — fp32에서 부터 무한대가 됩니다. 로짓이 1000쯤 되는 상황은 드물지만, 학습 초기나 온도를 낮게 잡은 자리에서는 실제로 나옵니다.
빠져나가는 방법이 하나 있고 이름이 붙어 있습니다. 가장 큰 값 를 뽑아 밖으로 빼는 것입니다.
첫 등호는 지수법칙 을 항마다 쓴 것이고, 두 번째는 거기에 로그를 씌워 곱을 합으로 푼 것입니다. 이것을 log-sum-exp 트릭이라고 합니다.
이제 지수의 어깨에 올라가는 값 은 전부 0 이하이므로 은 전부 안에 들어옵니다. 폭발할 자리가 사라졌습니다. 그리고 인 항은 정확히 이라 합이 1보다 작아질 일도 없으니 0이 되지도 않습니다.
결과값 자체는 바뀌지 않는다는 점을 짚어 둡니다. 위 유도는 등식이지 근사가 아닙니다. 나머지 항 중 일부가 처럼 작아 0으로 내려앉을 수는 있지만, 그런 항은 원래도 합에 아무 기여를 못 하던 항입니다.
로 확인합니다. 이므로
x = np.array([1000., 1001., 1002.], dtype=np.float32)
print(np.log(np.exp(x).sum())) # inf ← 그대로 하면 폭발
m = x.max()
print(m + np.log(np.exp(x - m).sum())) # 1002.4076
torch.logsumexp, scipy.special.logsumexp가 안에서 하는 일이 이 세 줄입니다. 그리고 log_softmax는
이므로, 로짓에서 log-sum-exp 한 번을 빼는 것이 전부입니다. softmax 뒤에 log를 따로 붙이지 말고 log_softmax를 쓰라는 조언이 여기서 나옵니다 — 앞 순서는 확률이 한 번 0으로 내려앉은 뒤 로그를 씌워 를 만들지만, 뒤 순서는 뺄셈 하나라 그럴 자리가 없습니다.
차이가 실제로 보이는 값을 하나 넣어 봅니다. 로짓이 이면 세 확률은 , , 인데 은 fp32 밖입니다.
x = np.array([0., -100., -200.], dtype=np.float32)
p = np.exp(x) / np.exp(x).sum()
print(np.log(p)) # [ 0. -99.98309 -inf] ← 둘째는 흐려지고 셋째는 무너진다
print(x - (x.max() + np.log(np.exp(x - x.max()).sum())))
# [ 0. -100. -200.] ← 정확하다
둘째 값의 참값은 인데 앞 순서는 소수점 아래가 이미 어긋났고, 셋째는 참값 대신 를 내놓습니다. 이 값이 손실에 들어가면 그 자리부터 nan이 번집니다. 표현할 수 없었던 것은 확률이지 로그 확률이 아니었다 — 로그 공간을 떠나지 않는 것만으로 없던 문제가 됩니다.
코드에서 만나는 이름들
| 이름 | 무엇인가 | 로그가 하는 일 |
|---|---|---|
logits |
softmax 이전의 실수 점수 | 아직 로그가 아니지만, 상수 차이를 빼면 로그 확률이다 |
log_softmax |
로짓을 로그 확률로 | 나눗셈을 뺄셈으로, 언더플로 제거 |
logprobs |
뽑힌 토큰의 로그 확률 | 문장 점수를 곱이 아니라 합으로 |
nll_loss |
음의 로그가능도 | 곱의 최대화를 합의 최소화로 |
logsumexp |
오버플로 없이 분모 계산 |
nll_loss의 부호 하나만 덧붙입니다. 학습은 가능도를 가장 크게 만드는 일인데 최적화기는 최소화만 할 줄 압니다. 단조성이 여기서 한 번 더 쓰입니다 — 가 순서를 지키므로 가능도를 최대화하는 것과 로그가능도를 최대화하는 것은 같은 일이고, 부호를 뒤집으면 최소화 문제가 됩니다. 그래서 손실 이름이 «음의 로그가능도»입니다.
정리
- 로그는 곱을 합으로 보낸다. 확률의 곱이 언더플로하는 자리가 로그 공간에서는 그냥 덧셈이 된다.
- fp32에서 확률 곱은 55토큰이면 정규수 밖으로 나가고 65토큰이면 0이 된다(토큰 확률 0.2 기준). fp64도 441·463토큰에서 같은 일을 겪으므로, 정밀도를 올리는 것은 해법이 아니다.
- 로그는 순서를 지킨다. 그래서 와 후보 정렬은 로그 공간에서 그대로 해도 된다.
- 합에는 로그가 무력하다. 는 최댓값을 밖으로 빼는 log-sum-exp 트릭으로 다룬다. 어깨의 값이 전부 0 이하가 되어 폭발이 사라진다.
log_softmax는 로짓에서 log-sum-exp를 뺀 것이다. softmax 뒤에 log를 붙이는 순서와 결과는 같지만 수치는 다르다.
logprobs가 음수인 것, 손실 이름에 «음의»가 붙는 것, 빔서치가 점수를 더하는 것, log_softmax를 따로 쓰라는 것 — 처음의 그 log들이 전부 두 성질에서 나왔습니다. 다음 글은 이 글이 모양만 빌려 쓴 곱, 즉 라는 분해가 어디서 오는지를 확률의 규칙에서 세웁니다.
읽어주셔서 감사합니다. 😊

