TL;DR — float는 표현 가능한 값이 이산적으로 정해져 있어, 그 사이 값과 일부 연산 결과가 반올림됩니다. 그래서 좋은 코드란 같은 식이라도 오차가 폭발하지 않도록 계산 순서를 설계한 코드입니다.
왜 배우나요? —
0.1 + 0.2가0.3이 아닌 컴퓨터에서는 수학적으로 참인 계산도 코드에서 어긋날 수 있습니다. 이 오차의 정체를 알아야nan의 원인을 추적할 수 있습니다.AI 어디에 쓰이나요? — softmax·cross-entropy·attention은 지수·로그 계산을 포함해 오버플로에 취약합니다. 혼합 정밀도(fp16) 학습의 underflow도 여기서 옵니다.
컴퓨터의 실수는 근사값이다
파이썬을 열어 0.1 + 0.2를 계산해 보면 0.30000000000000004가 나옵니다. 컴퓨터는 실수를 부동소수점(floating point)이라는 유한한 과학적 표기법으로 저장합니다. 담으려는 값이 표현 가능한 눈금에 없으면 가장 가까운 값으로 반올림합니다.
십진수 0.1은 이진법에서 무한히 반복되므로 float64에 정확히 담기지 않습니다. 반면 작은 정수와 처럼 이진법으로 유한하게 적히는 값은 정확히 표현됩니다. 핵심은 '모든 float가 근사'가 아니라 표현 가능한 눈금 사이의 값과 일부 연산 결과가 반올림된다는 점입니다.
: float64의 머신 엡실론 — 1 옆 눈금 간격
float64는 유효숫자 약 16자리까지만 정확
16자리쯤부터는 믿지 말 것
큰 수 옆의 작은 수는 사라진다
같은 +1도 저장되는 크기에 따라 결과가 달라집니다. 3 + 1의 정확한 결과 4는 float64가 표현할 수 있어 그대로 저장되지만, 은 다시 으로 저장되어 변화가 사라집니다. 부근에서 이웃한 float64 값의 간격(ULP)은 2이므로, 정확한 결과 은 두 저장 후보 과 의 한가운데에 놓입니다. 이 동률에서는 round-to-nearest, ties-to-even 규칙이 십진수 끝자리의 짝홀이 아니라 저장 가수의 마지막 비트(LSB)를 비교합니다. 쪽의 LSB는 0이고 쪽은 1이므로, 저장 가수의 최하위 비트가 0인 쪽이 선택됩니다.
한 줄 정리 — 눈금 간격은 수가 커질수록 넓어져, 큰 수 옆의 작은 수는 반올림으로 사라집니다.
수학의 등호 vs 코드의 ==
여기서 수학의 표기와 코드의 표기가 갈라지는 가장 중요한 지점을 짚고 가겠습니다. 수학에서 실수 은 무한히 정밀한 세계입니다. 은 한 치의 오차도 없는 엄밀한 등호이고, 자릿수 걱정 같은 건 존재하지 않습니다.
그런데 코드의 float는 앞서 본 대로 유한한 칸에 담긴 근사값(float64)입니다. 그래서 수학적으로는 완벽히 참인 등식이, 코드에서 ==로 비교하면 거짓이 될 수 있습니다. 0.1 + 0.2 == 0.3이 False인 이유가 바로 이것입니다 — 왼쪽의 반올림 오차와 오른쪽의 반올림 오차가 서로 달라서, 두 근사값이 마지막 자리에서 어긋나기 때문입니다.
그래서 실무의 철칙이 하나 생깁니다: 실수는 ==로 비교하지 말고, np.isclose처럼 "충분히 가까운가"를 묻는 함수로 비교하자. 수학의 등호()를 코드의 ==로 곧이곧대로 믿으면 안 된다는 뜻입니다.
(0.1 + 0.2) # 0.3 이 아니라 ...004 — 이진 근사의 흔적
(0.1 + 0.2 == 0.3) # False — 수학적으론 참인 등식이 코드에선 어긋남
((0.1 + 0.2, 0.3)) # True — 실수 비교는 == 대신 '가까움'으로
big = 1e16
(big + 1.0 == big) # True — 큰 수 옆 +1(동전)이 통째로 사라짐
((big + 1.0) - big) # 0.0 — 사라진 뒤 빼면 1 이 아니라 00.30000000000000004 False True True 0.0
첫 세 줄이 방금 이야기한 표기의 차이를 그대로 보여 줍니다. 0.1 + 0.2 == 0.3은 False지만 np.isclose(0.1 + 0.2, 0.3)은 True입니다 — 실무에서 실수를 비교할 때 왜 np.isclose를 쓰는지가 여기서 드러납니다.
뒤 두 줄은 바로 앞에서 설명한 작은 가수 흡수(absorption)를 코드로 확인합니다. 1e16 + 1 == 1e16에서 +1은 덧셈 순간 이미 반올림되어 사라졌고, 뒤의 뺄셈은 잃어버린 1을 되살리지 못해 0.0을 냅니다. 이것은 가까운 두 근사값을 뺄 때 앞자리가 지워져 상대오차가 커지는 파국적 상쇄(catastrophic cancellation)와 원인이 다릅니다. 흡수는 큰 수와 작은 수를 더할 때, 상쇄는 크기가 비슷한 두 근사값을 뺄 때 경계합니다.
한 줄 정리 — 실수는 등호(==)로 비교하지 말고
np.isclose로 '충분히 가까운가'를 물어야 합니다.
AI에서 특히 아픈 함정
이 함정들은 AI에서 특히 자주, 특히 아프게 나타납니다. 모델의 출력 점수를 확률로 바꾸는 softmax, 손실을 재는 cross-entropy, Transformer의 attention score는 모두 지수함수()와 로그함수()로 범벅이기 때문입니다. 그런데 지수함수는 순식간에 폭발합니다 — 예컨대 은 float64가 담을 수 있는 최댓값(약 )을 훌쩍 넘겨 무한대(inf)가 되어 버리고, 그 순간부터 뒤따르는 모든 계산이 inf와 nan(숫자 아님)으로 오염됩니다. 그래서 이런 위험한 계산은 실제로 돌려 보지 않고 설명으로만 짚습니다(굳이 오버플로를 일으켜 볼 이유가 없으니까요).
또 하나, 요즘 학습 속도를 위해 널리 쓰는 혼합 정밀도(mixed-precision) 학습은 계산 일부를 16비트 부동소수점(fp16)으로 처리합니다. fp16에서는 역전파 중 생기는 아주 작은 gradient(가중치를 얼마나 바꿀지 정하는 값)가 0으로 뭉개져 갱신이 멈출 수 있습니다. 손실 스케일링(loss scaling)은 손실에 큰 수를 곱해 역전파 gradient도 함께 키운 뒤, 가중치에 적용하기 전에 다시 나눕니다. 즉 보호 대상은 손실 숫자 자체가 아니라 작은 gradient의 underflow입니다. 모델이 나빠서가 아니라 숫자 표현의 한계 때문에 학습이 멈출 수 있다는 뜻입니다.
이때 자주 쓰는 안정화 방법이 아래의 Log-Sum-Exp trick입니다. 핵심은 “입력 값 중 가장 큰 값(m : max z_i)을 기준점으로 삼아 전체 점수를 한 번 낮춘 뒤 계산한다”는 것입니다. 가장 큰 값 m을 모든 z_i에서 빼면, exp 안에 들어가는 값들은 모두 0 이하가 됩니다. 그래서 exp 계산은 안전해지고, 마지막에 m을 다시 더해 주면 원래 log-sum-exp 값과 정확히 같아집니다.
: 입력 점수(예: softmax 로짓)
: 빼 주는 최댓값
최댓값 을 빼도 결과는 그대로 — 오버플로만 예방
가장 큰 값을 먼저 빼서 exp를 안전하게
# Log-Sum-Exp trick: log(sum(exp(z))) 를 오버플로 없이 계산하기
z = ([1000., 1001., 1002.])
# 순진한 np.log(np.sum(np.exp(z))) 는 e^1000 이 inf 로 넘쳐 답이 오염됩니다 (설명만)
m = z() # 최댓값 m = 1002 를 기준점으로
lse = m + (((z - m))) # z - m 이 모두 <= 0 이라 exp 가 안전
(lse) # 1002.41... — inf 없이 정상 계산
# 최댓값을 빼도 결과가 같음을, 순진한 방법도 안전한 작은 수로 확인
s = ([1., 2., 3.])
naive = (((s)))
stable = s() + (((s - s())))
((naive, stable)) # True — 같은 값 (오버플로만 예방)1002.4076059644444 True
위 공식 을 그대로 코드로 옮긴 것입니다. 를 순진하게 np.log(np.sum(np.exp(z)))로 계산하면 이 float64 한계()를 넘겨 inf가 되고 뒤 계산 전체가 오염됩니다. 그래서 최댓값 를 먼저 빼면 지수 이 모두 이 되어 np.exp는 0~1의 안전한 값만 다루고, 마지막에 을 다시 더해 정상값 1002.41…을 얻습니다(오버플로를 굳이 일으키지 않으려고 순진한 경로는 주석으로만 둡니다).
두 번째 부분은 ‘최댓값을 빼도 결과는 그대로’임을, 순진한 방법도 안전한 작은 수 로 확인한 것입니다 — np.isclose가 True를 돌려주어 두 방법이 (반올림 오차 범위에서) 같은 값임을 보여 줍니다. softmax·cross-entropy·attention이 속으로 바로 이 log-sum-exp를 쓰기 때문에, 이 한 줄의 ‘최댓값 빼기’가 딥러닝 수치 안정성의 핵심 장치입니다.
좋은 코드는 계산 순서를 설계한다
정리하면, 정확히 표현되지 않는 값과 일부 연산 결과에는 반올림 오차가 생길 수 있고 그 오차는 대개 티끌이지만,
-
큰 수 옆의 작은 수가 사라지거나
-
비슷한 수끼리 빼서 앞자리가 지워지거나
-
지수함수가 폭발할 때 티끌이 폭탄이 됩니다.
그래서 좋은 AI 엔지니어는 공식만 보지 않고 계산 순서를 봅니다. 처럼 수학적으로 똑같은 식이라도, 최댓값을 먼저 빼느냐 아니냐에 따라 안정하게 돌 수도, inf로 터질 수도 있습니다. 기억할 한 문장은 이것입니다 — 좋은 코드란 오차가 폭발하지 않도록 계산 순서를 설계한 코드입니다.