수학

MATH / 중급 18번

지수와 로그: AI 코드에 log가 도처에 있는 이유

확률 수백 개를 곱하면 fp32에서 65토큰 만에 0이 됩니다. 곱을 합으로 바꾸는 로그의 성질과 순서를 보존하는 단조성이 이 문제를 어떻게 없애는지, 그리고 log-sum-exp 트릭이 왜 필요한지를 실제 수치로 확인합니다.

PALDYN Team17 MIN READ

추론 API에서 logprobs 옵션을 켜면 토큰마다 -0.0004, -2.31, -7.85 같은 음수가 딸려 옵니다. 학습 코드를 열면 log_softmax가 있고 그 뒤에 nll_loss가 붙어 있고, 어딘가에는 logsumexp가 있습니다. 이름에 전부 log가 박혀 있습니다.

확률을 다루는데 왜 확률이 아니라 로그 확률이 돌아다니는가 — 이 글은 그 질문 하나에 답합니다. 답은 로그의 성질 두 개로 끝나고, 그 두 개는 이미 배운 것입니다.

지수와 로그의 법칙 자체, ee 의 정의, 자연로그가 왜 «자연»인지는 지수함수와 로그에서 세웠습니다. 여기서는 그 결과를 도구로 받아 씁니다.

받아 쓰는 두 가지

첫째, 로그는 곱셈을 덧셈으로 보냅니다.

log⁡(ab)=log⁡a+log⁡b,log⁡an=nlog⁡a\log(ab) = \log a + \log b, \qquad \log a^n = n\log a

둘째, 로그는 순서를 지킵니다. a<ba < b 이면 언제나 log⁡a<log⁡b\log a < \log b 입니다. 이 성질을 단조증가라고 합니다 — 입력이 커지면 출력도 반드시 커지고, 절대 뒤집히지 않는다는 뜻입니다.

이 글에서 쓰는 로그는 전부 밑이 ee 인 자연로그이고 log⁡\log 로 적습니다. 딥러닝 라이브러리의 log도 전부 자연로그입니다.

로그가 곱셈의 세계를 덧셈의 세계로 옮기고 exp가 되돌리는 그림

문제 — 확률의 곱은 순식간에 0이 된다

자기회귀 언어모델은 문장의 확률을 토큰 확률의 곱으로 정의합니다.

p(x1,x2,…,xT)=p(x1) p(x2∣x1)⋯p(xT∣x<T)p(x_1, x_2, \dots, x_T) = p(x_1)\,p(x_2 \mid x_1)\cdots p(x_T \mid x_{<T})

이 분해가 왜 성립하는지는 다음 글이 맡습니다. 지금 볼 것은 모양입니다 — 0과 1 사이의 수를 TT 번 곱합니다.

토큰 하나의 확률이 평균 0.20.2 라고 해 봅시다. 길이 500짜리 문장의 확률은

0.2500=10500×log⁡100.2=10−349.50.2^{500} = 10^{500 \times \log_{10} 0.2} = 10^{-349.5}

입니다. fp32가 표현할 수 있는 가장 작은 양수는 대략 1.2×10−381.2 \times 10^{-38} 입니다(정규수 기준). 10−349.510^{-349.5} 는 그 근처에도 못 갑니다. 컴퓨터는 이 값을 그냥 0.0으로 만들어 버리고, 이렇게 표현 범위 아래로 떨어져 0이 되는 것을 언더플로라고 합니다.

이 한계가 왜 하필 10−3810^{-38} 인지는 fp32의 생김새에서 나옵니다. 32비트 중 부호 1비트, 지수부 8비트, 가수부 23비트로 나뉘어 있고, 크기를 정하는 것은 지수부 8비트뿐입니다. 여기에 담기는 지수의 하한이 2−126≈1.2×10−382^{-126} \approx 1.2\times10^{-38} 입니다. 가수부 23비트는 유효숫자를 담당할 뿐 범위를 넓혀 주지 않습니다.

몇 토큰에서 터지는지 세어 봅시다. 0.2n<1.2×10−380.2^n < 1.2\times10^{-38} 이 되는 nn 을 찾으면 되고, 양변에 상용로그를 씌우면

n×0.699>37.9⟹n>54.2n \times 0.699 > 37.9 \quad\Longrightarrow\quad n > 54.2

55토큰입니다. 문장 한 줄도 못 갑니다.

정확히는 여기서 곧장 0이 되지는 않고 비정규수 구간으로 떨어집니다 — 지수부가 더는 못 내려가면 가수부를 깎아 가며 1.4×10−451.4\times10^{-45} 까지 버티는 마지막 여유 구간입니다. 대신 유효숫자가 한 자리씩 사라지므로 값은 이미 못 믿습니다. 그 여유마저 다 쓰면 n=65n = 65 에서 진짜 0.0이 됩니다.

fp64로 바꿔도 구조는 같습니다. 정규수 하한이 2.2×10−3082.2\times10^{-308} 이라 441토큰에서 비정규수로 내려가고 463토큰에서 0이 됩니다 — 미루는 것이지 푸는 것이 아닙니다.

토큰 수가 늘수록 확률의 곱이 fp32와 fp64의 표현 한계 아래로 떨어지는 그래프

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            ← 여기서 죽는다

해법 — 로그 공간에서 더한다

곱이 문제였으니 곱을 없앱니다. 양변에 로그를 씌우면 첫 번째 성질이 곱을 합으로 바꿔 줍니다.

log⁡p(x1,…,xT)=∑t=1Tlog⁡p(xt∣x<t)\log p(x_1,\dots,x_T) = \sum_{t=1}^{T} \log p(x_t \mid x_{<t})

이 값을 로그가능도라고 부릅니다 — 가능도(likelihood)는 «주어진 데이터가 이 모델에서 나올 확률»이고, 거기에 로그를 씌운 것입니다. 아까 그 문장이라면

log⁡(0.2500)=500×log⁡0.2=500×(−1.609)=−804.7\log(0.2^{500}) = 500 \times \log 0.2 = 500 \times (-1.609) = -804.7

입니다. −804.7-804.7 은 fp32가 아무 문제 없이 담는 수입니다. 확률의 유효 자릿수가 아니라 지수부만 남기는 것 — 로그가 하는 일이 정확히 이것입니다.

토큰 확률이 전부 1보다 작으니 로그는 전부 음수이고, 그래서 API가 돌려주는 logprobs가 항상 음수입니다. 00 에 가까울수록 확률 11 에 가깝고, −7.85-7.85 는 e−7.85≈0.00039e^{-7.85} \approx 0.00039 를 뜻합니다.

logp = np.float32(np.log(0.2))
print(logp * 500)     # -804.719
print(np.exp(-2.31))  # 0.09926 ← logprob을 확률로 되돌리기

짧은 예로 한 번 손으로 따라가 봅니다. 다섯 토큰짜리 응답에 API가 이런 logprobs를 돌려주었다고 합시다.

−0.11,−2.31,−0.02,−1.20,−3.50-0.11,\quad -2.31,\quad -0.02,\quad -1.20,\quad -3.50

문장 전체의 로그가능도는 그냥 더한 값입니다.

−0.11−2.31−0.02−1.20−3.50=−7.14-0.11 - 2.31 - 0.02 - 1.20 - 3.50 = -7.14

확률로 되돌리면 e−7.14≈0.00079e^{-7.14} \approx 0.00079 입니다. 곱으로 했다면 0.896×0.099×0.980×0.301×0.0300.896 \times 0.099 \times 0.980 \times 0.301 \times 0.030 을 계산해야 했고, 값은 같지만 자릿수가 매 단계 줄어듭니다. 다섯 토큰에서는 둘 다 되지만 500토큰에서는 한쪽만 됩니다.

로그 공간에서는 산술 자체가 한 단계씩 내려앉습니다.

확률 공간 로그 공간
p×qp \times q log⁡p+log⁡q\log p + \log q
p/qp / q log⁡p−log⁡q\log p - \log q
pnp^n nlog⁡pn \log p
p+qp + q 대응하는 간단한 식이 없다

마지막 줄이 이 글의 뒤쪽 절반을 만듭니다.

순서는 그대로다

로그 공간으로 옮기면 값이 완전히 달라집니다. 그런데도 대부분의 코드가 로그값을 끝까지 되돌리지 않고 그대로 씁니다. 두 번째 성질 덕분입니다.

p1<p2  ⟺  log⁡p1<log⁡p2p_1 < p_2 \iff \log p_1 < \log p_2

단조증가라는 것은 눈으로도 확인됩니다. log⁡x\log x 의 도함수가 1/x1/x 이고 확률은 언제나 양수이므로 기울기가 정의역 전체에서 양수입니다. 한 번도 내려가지 않는 곡선이라 두 값의 대소가 뒤집힐 자리가 없습니다.

순서가 통째로 보존되므로

arg⁡max⁡ipi=arg⁡max⁡ilog⁡pi\arg\max_i p_i = \arg\max_i \log p_i

입니다. 가장 그럴듯한 후보를 고르는 일에는 확률이 필요 없습니다. 로그값만 비교해도 똑같은 답이 나옵니다.

토큰 확률 로그 확률
있 0.42 −0.868
없 0.35 −1.050
같 0.23 −1.470

세 값의 순서가 왼쪽과 오른쪽에서 같습니다. 그리디 디코딩도, 빔서치의 후보 정렬도, 분류기의 예측 라벨도 전부 로그 공간에서 그대로 결정됩니다. 빔서치가 후보 문장의 점수를 더해서 비교하는 것도 같은 이유입니다 — 확률이었다면 곱해야 했고, 그러면 언더플로가 돌아옵니다.

확률 막대와 로그 확률 막대에서 세 후보의 순서가 같게 유지되는 그림

그런데 합을 구해야 한다면

곱은 로그가 합으로 바꿔 주는데, 합은 로그가 어떻게 해 주지 못합니다. log⁡(a+b)\log(a+b) 를 log⁡a\log a 와 log⁡b\log b 로 예쁘게 푸는 법은 없습니다.

문제는 합이 자주 필요하다는 것입니다. softmax의 분모가 그렇습니다.

log⁡∑iexi\log \sum_i e^{x_i}

여기서 xix_i 는 모델이 내놓은 실수 점수, 즉 로짓입니다. 로짓이 커지면 exie^{x_i} 가 폭발합니다 — fp32에서 e89e^{89} 부터 무한대가 됩니다. 로짓이 1000쯤 되는 상황은 드물지만, 학습 초기나 온도를 낮게 잡은 자리에서는 실제로 나옵니다.

빠져나가는 방법이 하나 있고 이름이 붙어 있습니다. 가장 큰 값 m=max⁡ixim = \max_i x_i 를 뽑아 밖으로 빼는 것입니다.

∑iexi=em∑iexi−m⟹log⁡∑iexi=m+log⁡∑iexi−m\sum_i e^{x_i} = e^{m}\sum_i e^{x_i - m} \quad\Longrightarrow\quad \log\sum_i e^{x_i} = m + \log\sum_i e^{x_i - m}

첫 등호는 지수법칙 exi=emexi−me^{x_i} = e^m e^{x_i-m} 을 항마다 쓴 것이고, 두 번째는 거기에 로그를 씌워 곱을 합으로 푼 것입니다. 이것을 log-sum-exp 트릭이라고 합니다.

이제 지수의 어깨에 올라가는 값 xi−mx_i - m 은 전부 0 이하이므로 exi−me^{x_i-m} 은 전부 (0,1](0, 1] 안에 들어옵니다. 폭발할 자리가 사라졌습니다. 그리고 i=arg⁡max⁡i = \arg\max 인 항은 정확히 e0=1e^0 = 1 이라 합이 1보다 작아질 일도 없으니 0이 되지도 않습니다.

결과값 자체는 바뀌지 않는다는 점을 짚어 둡니다. 위 유도는 등식이지 근사가 아닙니다. 나머지 항 중 일부가 e−800e^{-800} 처럼 작아 0으로 내려앉을 수는 있지만, 그런 항은 원래도 합에 아무 기여를 못 하던 항입니다.

x=(1000,1001,1002)x = (1000, 1001, 1002) 로 확인합니다. m=1002m = 1002 이므로

1002+log⁡(e−2+e−1+e0)=1002+log⁡(1.5032)=1002.4081002 + \log(e^{-2} + e^{-1} + e^{0}) = 1002 + \log(1.5032) = 1002.408

최댓값을 빼서 지수를 0 이하로 눌러 두는 log-sum-exp 트릭의 세 단계

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⁡exk∑iexi=xk−log⁡∑iexi\log \frac{e^{x_k}}{\sum_i e^{x_i}} = x_k - \log\sum_i e^{x_i}

이므로, 로짓에서 log-sum-exp 한 번을 빼는 것이 전부입니다. softmax 뒤에 log를 따로 붙이지 말고 log_softmax를 쓰라는 조언이 여기서 나옵니다 — 앞 순서는 확률이 한 번 0으로 내려앉은 뒤 로그를 씌워 −∞-\infty 를 만들지만, 뒤 순서는 뺄셈 하나라 그럴 자리가 없습니다.

차이가 실제로 보이는 값을 하나 넣어 봅니다. 로짓이 (0,−100,−200)(0, -100, -200) 이면 세 확률은 11, e−100e^{-100}, e−200e^{-200} 인데 e−200≈10−87e^{-200} \approx 10^{-87} 은 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.]       ← 정확하다

둘째 값의 참값은 −100-100 인데 앞 순서는 소수점 아래가 이미 어긋났고, 셋째는 참값 −200-200 대신 −∞-\infty 를 내놓습니다. 이 값이 손실에 들어가면 그 자리부터 nan이 번집니다. 표현할 수 없었던 것은 확률이지 로그 확률이 아니었다 — 로그 공간을 떠나지 않는 것만으로 없던 문제가 됩니다.

코드에서 만나는 이름들

이름 무엇인가 로그가 하는 일
logits softmax 이전의 실수 점수 아직 로그가 아니지만, 상수 차이를 빼면 로그 확률이다
log_softmax 로짓을 로그 확률로 나눗셈을 뺄셈으로, 언더플로 제거
logprobs 뽑힌 토큰의 로그 확률 문장 점수를 곱이 아니라 합으로
nll_loss 음의 로그가능도 곱의 최대화를 합의 최소화로
logsumexp log⁡∑exi\log\sum e^{x_i} 오버플로 없이 분모 계산

nll_loss의 부호 하나만 덧붙입니다. 학습은 가능도를 가장 크게 만드는 일인데 최적화기는 최소화만 할 줄 압니다. 단조성이 여기서 한 번 더 쓰입니다 — log⁡\log 가 순서를 지키므로 가능도를 최대화하는 것과 로그가능도를 최대화하는 것은 같은 일이고, 부호를 뒤집으면 최소화 문제가 됩니다. 그래서 손실 이름이 «음의 로그가능도»입니다.

정리

  • 로그는 곱을 합으로 보낸다. 확률의 곱이 언더플로하는 자리가 로그 공간에서는 그냥 덧셈이 된다.
  • fp32에서 확률 곱은 55토큰이면 정규수 밖으로 나가고 65토큰이면 0이 된다(토큰 확률 0.2 기준). fp64도 441·463토큰에서 같은 일을 겪으므로, 정밀도를 올리는 것은 해법이 아니다.
  • 로그는 순서를 지킨다. 그래서 arg⁡max⁡\arg\max 와 후보 정렬은 로그 공간에서 그대로 해도 된다.
  • 합에는 로그가 무력하다. log⁡∑exi\log\sum e^{x_i} 는 최댓값을 밖으로 빼는 log-sum-exp 트릭으로 다룬다. 어깨의 값이 전부 0 이하가 되어 폭발이 사라진다.
  • log_softmax는 로짓에서 log-sum-exp를 뺀 것이다. softmax 뒤에 log를 붙이는 순서와 결과는 같지만 수치는 다르다.

logprobs가 음수인 것, 손실 이름에 «음의»가 붙는 것, 빔서치가 점수를 더하는 것, log_softmax를 따로 쓰라는 것 — 처음의 그 log들이 전부 두 성질에서 나왔습니다. 다음 글은 이 글이 모양만 빌려 쓴 곱, 즉 p(x1)p(x2∣x1)⋯p(x_1)p(x_2\mid x_1)\cdots 라는 분해가 어디서 오는지를 확률의 규칙에서 세웁니다.


읽어주셔서 감사합니다. 😊

LATEST

수학의 최신 글

수학2026.09.07

양자화 오차: 격자 사상, 오차 분산, 이상치 채널

실수를 2^b개 격자에 사상할 때 오차의 분산이 왜 Δ²/12인지 유도하고, 그것이 비트당 6.02dB라는 SNR로 번역되는 과정을 실측과 대조했습니다. 이상치 하나가 나머지 값의 유효 비트를 어떻게 먹는지, 그리고 int4에서 성능이 무너지는 지점을 오차 예산으로 미리 계산하는 법까지.

중급18 MIN
수학2026.09.07

수치적으로 안정한 계산 패턴 모음

최댓값 빼기, 로그 공간, log1p·expm1, 분산의 두 공식, 정규화의 ε, fp32 누산, 역행렬 대신 solve — 프레임워크가 몰래 해 주는 일곱 가지를 하나씩 꺼내 각각 어떤 고장을 막는지 직접 재 봤습니다. 수식을 그대로 옮긴 코드가 왜 라이브러리보다 나쁜지에 대한 목록입니다.

중급22 MIN
수학2026.09.07

부동소수점은 어디서 새는가: 반올림, 상쇄, 더하는 순서

0.1 + 0.2가 0.3이 아닌 이유부터 시작해 머신 엡실론을 유도하고, 같은 16비트인데 fp16과 bf16이 서로 다른 지점에서 터지는 이유, 비슷한 수를 뺄 때 유효자리가 사라지는 파괴적 상쇄, 그리고 1,000만 개를 순서만 바꿔 더했을 때 오차가 백만 배 갈리는 실험까지 직접 재 봤습니다.

중급23 MIN