수치 안정성(Numerical Stability)

부동소수점(Floating point)은 새는 추상화입니다. 학습 중 언젠가 반드시 문제를 일으키지만, 그 순간을 미리 알아차리기는 어렵습니다.

유형: Build 언어: Python 선수 지식: Phase 1, Lessons 01-04 예상 시간: 약 120분

학습 목표

  • 최댓값 빼기(max-subtraction) 기법으로 수치적으로 안정적인 소프트맥스(softmax)와 로그-합-지수(log-sum-exp)를 구현합니다.
  • 부동소수점 계산에서 오버플로(overflow), 언더플로(underflow), 치명적 상쇄(catastrophic cancellation)를 식별합니다.
  • 중심 유한 차분(centered finite differences)으로 해석적 그래디언트(gradient)를 수치 그래디언트와 검증합니다.
  • 학습에서 bfloat16이 float16보다 선호되는 이유와 손실 스케일링(loss scaling)이 그래디언트 언더플로(gradient underflow)를 막는 방식을 설명합니다.

문제

모델이 세 시간 동안 학습되다가 손실(loss)이 NaN이 됩니다. print를 넣어 보면 9,000번째 단계(step)의 로짓(logits)은 멀쩡합니다. 그런데 9,001번째 단계에서 inf가 되고, 9,002번째 단계에서는 모든 그래디언트(gradient)가 nan이 되어 학습이 끝납니다.

또는 모델이 끝까지 학습되었지만 정확도가 논문보다 2% 낮습니다. 아키텍처도, 하이퍼파라미터(hyperparameter)도, 데이터도 모두 맞습니다. 문제는 논문은 float32를 썼고, 여러분은 적절한 스케일링(scaling) 없이 float16을 썼다는 점입니다. 누적된 반올림 오차(rounding error)가 조용히 정확도를 깎아 먹은 것입니다.

또는 교차 엔트로피 손실(cross-entropy loss)을 직접 구현했습니다. 작은 로짓에서는 잘 동작합니다. 그런데 로짓이 100을 넘으면 inf를 반환합니다. exp(100)이 float32가 표현할 수 있는 범위를 넘기 때문에 소프트맥스가 오버플로(overflow)된 것입니다. 모든 머신러닝(ML) 프레임워크는 이 문제를 두 줄짜리 기법으로 처리합니다. 그 기법을 몰랐을 뿐입니다.

수치 안정성은 이론적인 걱정거리가 아닙니다. 성공하는 학습 실행(run)과 조용히 실패하는 학습 실행을 가르는 차이입니다. 결국 디버깅하게 될 심각한 머신러닝 버그(bug) 대부분은 부동소수점으로 내려갑니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.float32가 표현할 수 있는 숫자의 대략적인 범위(range)는 무엇인가요?

2.부동소수점 산술에서 0.1 + 0.2가 0.3과 같지 않은 이유는 무엇인가요?

0/2 답변 완료

개념

IEEE 754: 컴퓨터가 실수를 저장하는 방식

컴퓨터는 IEEE 754 표준을 따라 실수를 부동소수점 값으로 저장합니다. 부동소수점(float)은 부호 비트(sign bit), 지수부(exponent), 가수부(mantissa, 또는 significand) 세 부분으로 구성됩니다.

Float32 layout (총 32 bits):
[1 sign] [8 exponent] [23 mantissa]

Value = (-1)^sign * 2^(exponent - 127) * 1.mantissa

mantissa는 정밀도, 즉 유효 숫자를 얼마나 많이 표현할 수 있는지를 결정합니다. exponent는 범위, 즉 숫자가 얼마나 크거나 작을 수 있는지를 결정합니다.

Format     Bits   Exponent  Mantissa  Decimal digits  Range (approx)
float64    64     11        52        ~15-16          +/- 1.8e308
float32    32     8         23        ~7-8            +/- 3.4e38
float16    16     5         10        ~3-4            +/- 65,504
bfloat16   16     8         7         ~2-3            +/- 3.4e38

float32는 대략 7자리 십진 정밀도를 제공합니다. 1.0000001과 1.0000002는 구분할 수 있지만, 1.00000001과 1.00000002는 구분하지 못합니다. 7자리 이후는 반올림 잡음(rounding noise)이 됩니다.

float16은 대략 3자리 정밀도만 제공합니다. 표현 가능한 가장 큰 값은 65,504입니다. 로짓, 그래디언트, 활성값(activation)이 이 범위를 자주 넘나드는 머신러닝에서는 놀랄 만큼 작은 값입니다.

bfloat16은 float16의 범위 문제를 해결하기 위해 구글(Google)이 만든 형식입니다. float32와 같은 8비트 지수부를 가지므로 범위는 3.4e38까지 동일하지만, 가수부는 7비트라 float16보다 정밀도는 낮습니다. 인공신경망(neural network) 학습에서는 정밀도보다 범위가 더 중요한 경우가 많기 때문에 bfloat16이 보통 더 유리합니다.

왜 0.1 + 0.2 != 0.3인가

0.1은 이진 부동소수점(binary floating point)으로 정확히 표현할 수 없습니다. 2진수에서는 반복 소수입니다.

0.1 in binary = 0.0001100110011001100110011... (무한 반복)

Float32는 이를 mantissa 23 bits로 잘라 저장합니다. 저장된 값은 대략 0.100000001490116입니다. 0.2도 대략 0.200000002980232로 저장됩니다. 둘을 더하면 0.3이 아니라 0.300000004470348이 됩니다.

>>> 0.1 + 0.2
0.30000000000000004

>>> 0.1 + 0.2 == 0.3
False

머신러닝에서 이 문제가 중요한 이유는 다음과 같습니다.

  1. if loss < threshold 같은 손실 비교가 잘못된 답을 줄 수 있습니다.
  2. 작은 값을 많이 누적하면, 예를 들어 수천 단계의 그래디언트 갱신(update)을 더하면 참값에서 점차 어긋남(drift)이 생깁니다.
  3. 부동소수점을 ==로 비교하면 체크섬(checksum)과 재현성(reproducibility) 테스트가 실패할 수 있습니다.

해결책은 간단합니다. 부동소수점을 ==로 비교하지 않습니다. abs(a - b) < epsilon 또는 math.isclose()를 사용합니다.

치명적 상쇄(Catastrophic Cancellation)

거의 같은 두 부동소수점 값을 빼면 중요한 유효 숫자가 상쇄되고, rounding noise가 앞자리로 올라옵니다.

a = 1.0000001    (float32에서는 1.00000011920929로 저장)
b = 1.0000000    (float32에서는 1.00000000000000으로 저장)

True difference:  0.0000001
Computed:         0.00000011920929

Relative error: 19.2%

단 한 번의 뺄셈에서 상대 오차가 19%가 됩니다. 머신러닝에서는 다음 상황에서 이런 일이 자주 발생합니다.

  • 평균이 큰 데이터의 분산을 E[x^2] - E[x]^2로 계산할 때
  • 거의 같은 로그 확률(log-probability)을 뺄 때
  • 너무 작은 엡실론(epsilon)으로 유한 차분 그래디언트(finite-difference gradient)를 계산할 때

해결책은 크고 거의 같은 수를 빼지 않도록 식을 재배열하는 것입니다. 분산에는 웰퍼드(Welford) 알고리즘을 쓰거나 먼저 데이터를 중심화(center)합니다. 로그 확률은 가능한 한 끝까지 로그 공간(log-space)에서 계산합니다.

오버플로(Overflow)와 언더플로(Underflow)

오버플로는 결과가 너무 커서 표현할 수 없을 때 발생합니다. 언더플로는 결과가 너무 작아서, 즉 표현 가능한 가장 작은 양수보다 0에 더 가까울 때 발생합니다.

Float32 boundaries:
  Maximum:  3.4028235e+38
  Minimum positive (normal): 1.175e-38
  Minimum positive (denorm): 1.401e-45
  Overflow:  anything > 3.4e38 becomes inf
  Underflow: anything < 1.4e-45 becomes 0.0

머신러닝에서 오버플로의 가장 흔한 원천은 exp()입니다.

exp(88.7)  = 3.40e+38   (float32 한계에 간신히 들어감)
exp(89.0)  = inf         (overflow)
exp(-87.3) = 1.18e-38   (underflow보다 약간 큼)
exp(-104)  = 0.0         (0으로 underflow)

log()는 반대 방향에서 문제가 생깁니다.

log(0.0)   = -inf
log(-1.0)  = nan
log(1e-45) = -103.3      (문제 없음)
log(1e-46) = -inf        (입력이 0으로 underflow된 뒤 log(0) = -inf)

머신러닝에서 exp()는 소프트맥스, 시그모이드(sigmoid), 확률(probability) 계산에 등장합니다. log()는 교차 엔트로피, 로그 가능도(log-likelihood), 쿨백-라이블러 발산(KL divergence)에 등장합니다. log(exp(x)) 조합은 올바른 기법 없이 계산하면 지뢰밭입니다.

로그-합-지수 기법(Log-Sum-Exp Trick)

log(sum(exp(x_i)))를 직접 계산하는 것은 수치적으로 위험합니다. 어떤 x_i가 크면 exp(x_i)가 오버플로됩니다. 모든 x_i가 매우 작으면 모든 exp(x_i)가 0으로 언더플로되고 log(0)-inf가 됩니다.

기법의 핵심은 지수를 취하기 전에 최댓값을 빼는 것입니다.

log(sum(exp(x_i))) = max(x) + log(sum(exp(x_i - max(x))))

왜 작동할까요? max(x)를 빼면 가장 큰 지수는 exp(0) = 1입니다. 오버플로가 불가능합니다. 또한 합 안에는 적어도 1인 항이 하나 있으므로 합은 최소 1이고 log(1) = 0입니다. -inf로 언더플로되지 않습니다.

증명은 다음과 같습니다.

log(sum(exp(x_i)))
= log(sum(exp(x_i - c + c)))                    (c를 더하고 뺌)
= log(sum(exp(x_i - c) * exp(c)))               (exp(a+b) = exp(a)*exp(b))
= log(exp(c) * sum(exp(x_i - c)))               (exp(c)를 밖으로 묶음)
= c + log(sum(exp(x_i - c)))                    (log(a*b) = log(a) + log(b))

c = max(x)로 두면 오버플로가 제거됩니다.

이 기법은 머신러닝 전반에 등장합니다.

  • 소프트맥스 정규화(softmax normalization)
  • 교차 엔트로피 손실(cross-entropy loss) 계산
  • 시퀀스 모델(sequence model)의 로그 확률 합산(log-probability summation)
  • 가우시안 혼합(Mixture of Gaussians)
  • 변분 추론(Variational inference)

왜 소프트맥스(Softmax)에는 최댓값 빼기 기법이 필요한가

소프트맥스는 로짓을 확률로 바꿉니다.

softmax(x_i) = exp(x_i) / sum(exp(x_j))

기법 없이 [100, 101, 102] 같은 로짓을 넣으면 오버플로가 발생합니다.

exp(100) = 2.69e43
exp(101) = 7.31e43
exp(102) = 1.99e44
sum      = 2.99e44

위 값들이 float32(최대 ~3.4e38)를 넘는가? 표면상 2.69e43 < 3.4e38? 실제로는 다음과 같습니다.
exp(88.7)이 이미 float32의 한계에 있다.
따라서 float32에서 exp(100) = inf.

기법을 적용해 max(x) = 102를 빼면 다음과 같습니다.

exp(100 - 102) = exp(-2) = 0.135
exp(101 - 102) = exp(-1) = 0.368
exp(102 - 102) = exp(0)  = 1.000
sum = 1.503

softmax = [0.090, 0.245, 0.665]

확률 값은 수학적으로 동일합니다. 계산은 안전해집니다. 이것은 최적화가 아니라 정확성을 위한 필수 조건입니다.

NaN과 Inf: 탐지와 예방

nan(Not a Number)과 inf(infinity)는 계산을 통해 전염되듯 퍼집니다. 그래디언트 갱신에 nan 하나가 들어가면 가중치(weight)가 nan이 되고, 이후 모든 출력(output)이 nan이 됩니다. 학습은 한 단계 안에 끝납니다.

inf가 생기는 방식:

  • 큰 양수에 대한 exp()
  • 0으로 나누기: 1.0 / 0.0
  • 누적(accumulation) 중 float32 오버플로

nan이 생기는 방식:

  • 0.0 / 0.0
  • inf - inf
  • inf * 0
  • 음수에 대한 sqrt()
  • 음수에 대한 log()
  • 이미 존재하는 nan이 포함된 산술 연산

탐지는 다음처럼 합니다.

import math

math.isnan(x)       # x가 nan이면 True
math.isinf(x)       # x가 +inf 또는 -inf이면 True
math.isfinite(x)    # x가 nan도 inf도 아니면 True

예방 전략:

  1. exp() 입력을 잘라(clamp)냅니다. exp(clamp(x, -80, 80))
  2. 분모(denominator)에 엡실론을 더합니다. x / (y + 1e-8)
  3. log() 안에 엡실론을 더합니다. log(x + 1e-8)
  4. 안정적인 구현을 사용합니다. 로그-합-지수, 안정적인 소프트맥스 등
  5. 그래디언트 클리핑(gradient clipping)으로 가중치 폭주(weight explosion)를 막습니다.
  6. 디버깅 중에는 순전파(forward pass)마다 nan/inf를 확인합니다.

수치 그래디언트 검사(Numerical Gradient Checking)

역전파(Backpropagation)로 얻는 해석적 그래디언트(analytical gradient)에는 버그가 있을 수 있습니다. 수치 그래디언트 검사(numerical gradient checking)는 유한 차분(finite difference)으로 그래디언트를 계산해 이를 검증합니다.

중심 차분 공식은 다음과 같습니다.

df/dx ~= (f(x + h) - f(x - h)) / (2h)

이는 O(h^2) 정확도를 가지며, O(h)에 그치는 전진 차분(forward difference) (f(x+h) - f(x)) / h보다 훨씬 좋습니다.

h 선택이 중요합니다. 너무 크면 근사가 틀립니다. 너무 작으면 치명적 상쇄가 답을 망가뜨립니다. 보통 h = 1e-5에서 1e-7 사이를 사용합니다.

검증은 해석적 그래디언트와 수치 그래디언트의 상대 차이를 계산합니다.

relative_error = |grad_analytical - grad_numerical| / max(|grad_analytical|, |grad_numerical|, 1e-8)

경험칙은 다음과 같습니다.

  • relative_error < 1e-7: 완벽합니다. 그래디언트가 맞습니다.
  • relative_error < 1e-5: 허용 가능합니다. 대체로 맞습니다.
  • relative_error > 1e-3: 무언가 잘못되었습니다.
  • relative_error > 1: 그래디언트가 완전히 틀렸습니다.

새 층(layer)이나 손실 함수(loss function)를 구현할 때는 항상 그래디언트를 확인합니다. 파이토치(PyTorch)는 이를 위해 torch.autograd.gradcheck()를 제공합니다.

혼합 정밀도 학습(Mixed Precision Training)

현대 GPU는 float16 행렬 곱셈(matrix multiplication)을 float32보다 2-8배 빠르게 계산하는 전용 하드웨어(Tensor Cores)를 갖추고 있습니다. 혼합 정밀도 학습(mixed precision training)은 이를 활용합니다.

1. 가중치의 float32 마스터 사본(master copy)을 유지한다.
2. 순전파(forward pass)는 float16으로 수행한다. (빠름)
3. 손실은 float32로 계산한다. (오버플로 방지)
4. 역전파(backward pass)는 float16으로 수행한다. (빠름)
5. 그래디언트를 float32로 스케일한다.
6. float32 마스터 가중치를 갱신한다.

순수 float16 학습의 문제는 그래디언트가 매우 작은 경우가 많다는 점입니다. 1e-8 이하의 그래디언트도 흔합니다. float16은 약 6e-8보다 작은 값을 0으로 언더플로시킵니다. 모든 그래디언트 갱신이 0이 되어 모델이 학습을 멈출 수 있습니다.

해결책은 손실 스케일링(loss scaling)입니다.

1. 손실에 큰 스케일 인자(scale factor)를 곱한다. (예: 1024)
2. 역전파는 (loss * 1024)의 그래디언트를 계산한다.
3. 모든 그래디언트가 1024배 커진다. (float16 언더플로 범위 위로 올라감)
4. 가중치 갱신 전에 그래디언트를 1024로 나눈다.
5. 최종 효과는 같은 갱신이지만 언더플로가 없다.

동적 손실 스케일링(dynamic loss scaling)은 스케일 인자를 자동으로 조정합니다. 큰 값(65536)에서 시작합니다. 그래디언트가 inf로 오버플로되면 절반으로 줄입니다. N단계 동안 오버플로가 없으면 두 배로 키웁니다.

bfloat16 vs float16: 학습에서는 왜 bfloat16이 유리한가

float16:   [1 sign] [5 exponent]  [10 mantissa]
bfloat16:  [1 sign] [8 exponent]  [7 mantissa]

float16은 더 높은 정밀도(가수부 10비트 vs 7비트)를 갖지만 범위가 제한적입니다. 최대값은 약 65,504입니다. bfloat16은 정밀도는 낮지만 float32와 같은 범위, 약 3.4e38을 가집니다.

인공신경망 학습에서는 다음 이유로 bfloat16이 유리합니다.

  • 활성값과 로짓은 학습 중 급증(spike)으로 65,504를 넘을 수 있습니다. float16은 오버플로되지만 bfloat16은 처리합니다.
  • float16은 손실 스케일링이 필요하지만, bfloat16은 표현 범위가 그래디언트 크기(gradient magnitude) 분포를 대체로 포괄하므로 보통 필요하지 않습니다.
  • bfloat16은 float32에서 가수부 하위 16비트를 버리는 단순한 잘라내기(truncation)입니다. 지수부 변환이 없어 변환이 간단합니다.

float16은 값의 범위가 제한되고 정밀도가 더 중요한 추론(inference)에 선호됩니다. bfloat16은 표현 범위가 더 중요한 학습(training)에 선호됩니다. TPU와 최신 엔비디아(NVIDIA) GPU(A100, H100)가 bfloat16을 네이티브(native)로 지원하는 이유입니다.

그래디언트 클리핑(Gradient Clipping)

그래디언트 폭주(exploding gradient)는 많은 층을 거치며 그래디언트가 지수적으로 커질 때 발생합니다. 순환 신경망(RNN), 심층 신경망(deep network), 트랜스포머(transformer)에서 흔합니다. 큰 그래디언트 하나가 한 단계 만에 모든 가중치를 망가뜨릴 수 있습니다.

클리핑(clipping)은 두 가지 방식이 있습니다.

값 기준 클리핑: 각 그래디언트 원소(element)를 독립적으로 잘라냅니다.

grad = clamp(grad, -max_val, max_val)

간단하지만 그래디언트 벡터(vector)의 방향을 바꿀 수 있습니다.

노름(norm) 기준 클리핑: 전체 그래디언트 벡터의 노름이 임계값(threshold)을 넘지 않도록 스케일합니다.

if ||grad|| > max_norm:
    grad = grad * (max_norm / ||grad||)

그래디언트 방향을 보존합니다. torch.nn.utils.clip_grad_norm_()이 이 방식을 사용하며 표준 선택입니다.

일반적인 값은 트랜스포머에서는 max_norm=1.0, 강화학습(RL)에서는 max_norm=0.5, 더 단순한 신경망에서는 max_norm=5.0입니다.

그래디언트 클리핑은 임시방편이 아닙니다. 안전장치입니다. 없으면 이상치 배치(outlier batch) 하나가 몇 주 동안의 학습을 망칠 만큼 큰 그래디언트를 만들 수 있습니다.

수치 안정화 장치로서의 정규화 층(Normalization Layer)

배치 정규화(batch normalization), 층 정규화(layer normalization), RMS 정규화(RMS normalization)는 보통 학습 수렴(training convergence)을 돕는 정규화 장치(regularizer)로 소개됩니다. 동시에 수치 안정화 장치이기도 합니다.

정규화가 없으면 활성값은 층을 지나며 지수적으로 커지거나 작아질 수 있습니다.

Layer 1: values in [0, 1]
Layer 5: values in [0, 100]
Layer 10: values in [0, 10,000]
Layer 50: values in [0, inf]

정규화는 각 층에서 활성값을 다시 중심화하고 재조정(rescale)합니다.

LayerNorm(x) = (x - mean(x)) / (std(x) + epsilon) * gamma + beta

epsilon은 보통 1e-5이며, 모든 활성값이 동일할 때 0으로 나누는 일을 막습니다. 학습되는 파라미터(parameter) gammabeta는 신경망(network)이 필요한 스케일을 다시 만들 수 있게 합니다.

이렇게 하면 신경망 전반에서 값이 수치적으로 안전한 범위에 머무르며, 순전파에서의 오버플로와 역전파에서의 그래디언트 폭주를 모두 막을 수 있습니다.

흔한 머신러닝 수치 버그

버그: 몇 에폭(epoch) 뒤 손실이 NaN이 됩니다. 원인: 로짓이 너무 커져 소프트맥스가 오버플로되었거나, 학습률(learning rate)이 너무 높아 가중치가 발산(diverge)했습니다. 해결: 안정적인 소프트맥스(최댓값 빼기)를 사용하고, 학습률을 줄이고, 그래디언트 클리핑을 추가합니다.

버그: 손실이 log(num_classes)에 머뭅니다. 원인: 모델 출력이 거의 균등 확률(uniform probability)입니다. 그래디언트가 사라졌거나 모델이 전혀 학습하지 않는 경우가 많습니다. 해결: 라벨(label)이 맞는지 확인하고, 손실 함수를 검증하고, 죽은 ReLU(dead ReLU)가 있는지 확인합니다.

버그: 검증 정확도(validation accuracy)가 예상보다 1-3% 낮습니다. 원인: 적절한 손실 스케일링 없이 혼합 정밀도를 사용했습니다. 그래디언트 언더플로가 작은 갱신을 조용히 0으로 만듭니다. 해결: 동적 손실 스케일링을 활성화하거나 bfloat16으로 전환합니다.

버그: 일부 층의 그래디언트 노름이 0.0입니다. 원인: 죽은 ReLU 뉴런이거나 float16 언더플로입니다. 해결: LeakyReLU 또는 GELU를 사용하고, 그래디언트 스케일링을 쓰고, 가중치 초기화(weight initialization)를 확인합니다.

버그: 한 GPU에서는 작동하지만 다른 GPU에서는 결과가 다릅니다. 원인: 부동소수점 누적 순서가 비결정적(non-deterministic)입니다. GPU 병렬 축약(parallel reduction)은 하드웨어마다 다른 순서로 합을 내며, 부동소수점 덧셈은 결합법칙(associative)을 만족하지 않습니다. 해결: 작은 차이(1e-6)는 받아들이거나, torch.use_deterministic_algorithms(True)를 설정하고 속도 저하를 감수합니다.

버그: 손실 계산에서 exp()inf를 반환합니다. 원인: 원시(raw) 로짓을 최댓값 빼기 기법 없이 exp()에 넣었습니다. 해결: 내부적으로 로그-합-지수를 구현하는 torch.nn.functional.log_softmax()를 사용합니다.

버그: float32에서 float16으로 바꾼 뒤 학습이 발산합니다. 원인: float16은 6e-8보다 작은 그래디언트 크기나 65,504보다 큰 활성값을 표현하지 못합니다. 해결: 손실 스케일링을 사용하는 혼합 정밀도(AMP)를 쓰거나 bfloat16을 사용합니다.

만들어 보기

Step 1: 부동소수점 정밀도 한계 확인

print("=== 부동소수점 정밀도 ===")
print(f"0.1 + 0.2 = {0.1 + 0.2}")
print(f"0.1 + 0.2 == 0.3? {0.1 + 0.2 == 0.3}")
print(f"0.3과의 차이: {(0.1 + 0.2) - 0.3:.2e}")

Step 2: 단순한 소프트맥스(naive softmax)와 안정적인 소프트맥스(stable softmax) 구현

import math

def softmax_naive(logits):
    exps = [math.exp(z) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]

def softmax_stable(logits):
    max_logit = max(logits)
    exps = [math.exp(z - max_logit) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]

safe_logits = [2.0, 1.0, 0.1]
print(f"단순 구현: {softmax_naive(safe_logits)}")
print(f"안정 구현: {softmax_stable(safe_logits)}")

dangerous_logits = [100.0, 101.0, 102.0]
print(f"안정 구현: {softmax_stable(dangerous_logits)}")
# softmax_naive(dangerous_logits)는 [nan, nan, nan]을 반환할 수 있습니다.

Step 3: 안정적인 로그-합-지수(stable log-sum-exp) 구현

def logsumexp_naive(values):
    return math.log(sum(math.exp(v) for v in values))

def logsumexp_stable(values):
    c = max(values)
    return c + math.log(sum(math.exp(v - c) for v in values))

safe = [1.0, 2.0, 3.0]
print(f"단순 구현: {logsumexp_naive(safe):.6f}")
print(f"안정 구현: {logsumexp_stable(safe):.6f}")

large = [500.0, 501.0, 502.0]
print(f"안정 구현: {logsumexp_stable(large):.6f}")
# logsumexp_naive(large)는 inf를 반환합니다.

Step 4: 안정적인 교차 엔트로피(stable cross-entropy) 구현

def cross_entropy_naive(true_class, logits):
    probs = softmax_naive(logits)
    return -math.log(probs[true_class])

def cross_entropy_stable(true_class, logits):
    max_logit = max(logits)
    shifted = [z - max_logit for z in logits]
    log_sum_exp = math.log(sum(math.exp(s) for s in shifted))
    log_prob = shifted[true_class] - log_sum_exp
    return -log_prob

logits = [2.0, 5.0, 1.0]
true_class = 1
print(f"단순 구현: {cross_entropy_naive(true_class, logits):.6f}")
print(f"안정 구현: {cross_entropy_stable(true_class, logits):.6f}")

Step 5: 그래디언트 검사(Gradient checking)

def numerical_gradient(f, x, h=1e-5):
    grad = []
    for i in range(len(x)):
        x_plus = x[:]
        x_minus = x[:]
        x_plus[i] += h
        x_minus[i] -= h
        grad.append((f(x_plus) - f(x_minus)) / (2 * h))
    return grad

def check_gradient(analytical, numerical, tolerance=1e-5):
    for i, (a, n) in enumerate(zip(analytical, numerical)):
        denom = max(abs(a), abs(n), 1e-8)
        rel_error = abs(a - n) / denom
        status = "OK" if rel_error < tolerance else "FAIL"
        print(f"  param {i}: analytical={a:.8f} numerical={n:.8f} "
              f"rel_error={rel_error:.2e} [{status}]")

def f(params):
    x, y = params
    return x**2 + 3*x*y + y**3

def f_grad(params):
    x, y = params
    return [2*x + 3*y, 3*x + 3*y**2]

point = [2.0, 1.0]
analytical = f_grad(point)
numerical = numerical_gradient(f, point)
check_gradient(analytical, numerical)

사용하기

혼합 정밀도 시뮬레이션(Mixed Precision Simulation)

import struct

def float32_to_float16_round(x):
    packed = struct.pack('f', x)
    f32 = struct.unpack('f', packed)[0]
    packed16 = struct.pack('e', f32)
    return struct.unpack('e', packed16)[0]

def simulate_bfloat16(x):
    packed = struct.pack('f', x)
    as_int = int.from_bytes(packed, 'little')
    truncated = as_int & 0xFFFF0000
    repacked = truncated.to_bytes(4, 'little')
    return struct.unpack('f', repacked)[0]

그래디언트 클리핑(Gradient Clipping)

def clip_by_norm(gradients, max_norm):
    total_norm = math.sqrt(sum(g**2 for g in gradients))
    if total_norm > max_norm:
        scale = max_norm / total_norm
        return [g * scale for g in gradients]
    return gradients

grads = [10.0, 20.0, 30.0]
clipped = clip_by_norm(grads, max_norm=5.0)
print(f"원래 노름: {math.sqrt(sum(g**2 for g in grads)):.2f}")
print(f"클리핑 후 노름: {math.sqrt(sum(g**2 for g in clipped)):.2f}")
print(f"방향 보존: {[c/clipped[0] for c in clipped]} == {[g/grads[0] for g in grads]}")

NaN/Inf 탐지(NaN/Inf Detection)

def check_tensor(name, values):
    has_nan = any(math.isnan(v) for v in values)
    has_inf = any(math.isinf(v) for v in values)
    if has_nan or has_inf:
        print(f"경고 {name}: nan={has_nan} inf={has_inf}")
        return False
    return True

check_tensor("good", [1.0, 2.0, 3.0])
check_tensor("bad",  [1.0, float('nan'), 3.0])
check_tensor("ugly", [1.0, float('inf'), 3.0])

모든 경계 사례(edge case)를 포함한 완성 구현은 code/numerical.py를 확인합니다.

산출물 만들기

이 lesson의 최종 산출물은 다음입니다.

  • code/numerical.py: 안정적인 소프트맥스, 로그-합-지수, 교차 엔트로피, 그래디언트 검사, 혼합 정밀도 시뮬레이션을 포함합니다.
  • outputs/prompt-numerical-debugger.md: 학습 중 NaN/Inf와 수치 문제를 진단하는 프롬프트(prompt)입니다.

이 안정적인 구현들은 Phase 3에서 학습 루프(training loop)를 만들 때, Phase 4에서 어텐션 메커니즘(attention mechanism)을 구현할 때 다시 등장합니다.

연습문제

  1. 치명적 상쇄(Catastrophic cancellation). float32에서 [1000000.0, 1000001.0, 1000002.0]의 분산을 단순한 공식 E[x^2] - E[x]^2로 계산합니다. 그다음 웰퍼드(Welford)의 온라인 알고리즘으로 계산합니다. 참 분산(true variance, 0.6667)과의 오차를 비교합니다.

  2. 정밀도 탐색(Precision hunt). 파이썬(Python)에서 1.0 + x == 1.0이 되는 가장 작은 양의 float32 값 x를 찾습니다. 이것이 머신 엡실론(machine epsilon)입니다. numpy.finfo(numpy.float32).eps와 일치하는지 확인합니다.

  3. 로그-합-지수 경계 사례(Log-sum-exp edge cases). logsumexp_stable을 (a) 모든 값이 같은 경우, (b) 하나의 값이 나머지보다 훨씬 큰 경우, (c) 모든 값이 매우 음수(-1000)인 경우에 테스트합니다. 단순한 구현(naive version)이 실패하는 곳에서도 올바른 결과를 내는지 확인합니다.

  4. 신경망 층 그래디언트 검사(Neural network layer gradient checking). 단일 선형 층(linear layer) y = Wx + b와 그에 대한 해석적 역전파를 구현합니다. numerical_gradient로 3x2 가중치 행렬에 대한 그래디언트의 정확성(correctness)을 확인합니다.

  5. 손실 스케일링 실험(Loss scaling experiment). float16 학습을 시뮬레이션합니다. [1e-9, 1e-3] 범위의 무작위(random) 그래디언트를 만들고 float16으로 변환한 뒤 0이 되는 비율을 측정합니다. 그다음 손실 스케일링(1024를 곱함)을 적용하고 float16으로 변환한 뒤 다시 원래 스케일로 되돌려 0 비율을 다시 측정합니다.

핵심 용어

용어흔한 설명실제 의미
IEEE 754"부동소수점 표준"이진 부동소수점 형식, 반올림 규칙, 특수 값(inf, nan)을 정의하는 국제 표준입니다. 현대 CPU와 GPU가 이를 구현합니다.
머신 엡실론(Machine epsilon)"정밀도 한계"특정 부동소수점 형식에서 1.0 + e != 1.0을 만족하는 가장 작은 값입니다. float32에서는 약 1.19e-7입니다.
치명적 상쇄(Catastrophic cancellation)"뺄셈으로 인한 정밀도 손실"거의 같은 부동소수점 수를 뺄 때 중요한 유효 숫자가 사라지고 반올림 잡음이 결과를 지배하는 현상입니다.
오버플로(Overflow)"숫자가 너무 큼"결과가 표현 가능한 최대값을 넘어서 inf가 되는 현상입니다. float32에서는 exp(89)가 오버플로됩니다.
언더플로(Underflow)"숫자가 너무 작음"결과가 표현 가능한 가장 작은 양수보다 0에 가까워 0.0이 되는 현상입니다. float32에서는 exp(-104)가 언더플로됩니다.
로그-합-지수 기법(Log-sum-exp trick)"최댓값을 먼저 빼기"log(sum(exp(x)))exp(max(x))로 묶어 내어 오버플로와 언더플로를 막는 계산법입니다.
안정적인 소프트맥스(Stable softmax)"터지지 않는 소프트맥스"로짓의 최댓값을 뺀 뒤 지수화합니다. 결과는 수학적으로 같고 오버플로가 발생하지 않습니다.
그래디언트 검사(Gradient checking)"역전파 검증"역전파로 얻은 해석적 그래디언트를 유한 차분으로 얻은 수치 그래디언트와 비교해 구현 버그를 찾는 방법입니다.
혼합 정밀도(Mixed precision)"float16 순전파, float32 역전파"속도에 민감한 연산은 낮은 정밀도를, 수치적으로 민감한 연산은 높은 정밀도를 사용하는 방식입니다.
손실 스케일링(Loss scaling)"그래디언트 언더플로 방지"역전파 전에 손실에 큰 상수를 곱해 그래디언트를 float16 표현 범위 안으로 올리고, 가중치 갱신 전에 다시 나누는 방식입니다.
bfloat16"Brain floating point"지수부 8비트와 가수부 7비트를 가진 구글의 16비트 형식입니다. float32와 같은 표현 범위를 가져 학습에 선호됩니다.
그래디언트 클리핑(Gradient clipping)"그래디언트 노름 제한"그래디언트 벡터의 노름이 임계값을 넘지 않도록 스케일하여, 폭주하는 그래디언트가 가중치를 망가뜨리는 일을 막습니다.
NaN"Not a Number"0/0, inf-inf, sqrt(-1) 같은 정의되지 않은 연산에서 생기는 특수한 부동소수점 값입니다. 이후 산술 연산으로 전파됩니다.
Inf"Infinity"오버플로 또는 0으로 나누기에서 생기는 특수한 부동소수점 값입니다. inf - inf, inf * 0은 NaN을 만들 수 있습니다.
수치 그래디언트(Numerical gradient)"무식하지만 믿을 수 있는 도함수"f(x+h)f(x-h)를 평가하고 2h로 나누어 도함수(derivative)를 근사합니다. 느리지만 검증에 유용합니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

numerical
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

prompt-numerical-debugger

Diagnoses NaN, Inf, and numerical stability issues in neural network training

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.안정적인 소프트맥스(stable softmax) 구현에서 지수화(exponentiate)하기 전에 max(logits)를 빼는 이유는 무엇인가요?

2.그래디언트 검사(gradient checking)에서 중심 유한 차분(centered finite difference)을 사용할 때 보폭(step size) h가 너무 작으면(예: 1e-15) 어떤 일이 생기나요?

3.인공신경망 학습(neural network training)에서 bfloat16이 일반적으로 float16보다 선호되는 이유는 무엇인가요?

0/3 답변 완료