커스텀 연산 하나를 PyTorch에 붙이는 순간이 있습니다.
class MyOp(torch.autograd.Function):
@staticmethod
def backward(ctx, grad_out):
return grad_out * something # ← 여기를 손으로 유도해 적었다
이 한 줄이 틀려도 프로그램은 멈추지 않습니다. 오류도 안 나고 shape도 맞고 손실도 줄어듭니다 — 조금 덜 줄어들 뿐입니다. 그래서 며칠을 학습률 탓, 데이터 탓으로 보내다가 결국 유도로 돌아옵니다.
확인하는 방법은 하나뿐입니다. 함수를 직접 조금 흔들어서 손실이 얼마나 움직이는지 재고, 손으로 유도한 값과 맞대는 것입니다. 이 일을 기울기 검사(gradient checking)라고 부릅니다. 지난 글의 코드 ①에서 이미 한 번 그렇게 했습니다 — 그때 를 쓰고 오차가 대로 나왔는데, 왜 하필 그 이고 왜 하필 그 자릿수인지는 적지 않았습니다. 이 글이 그것을 정하고, 마지막에는 위의 backward 한 줄을 검사하는 절차 하나로 묶습니다.
두 차분과 절단오차의 차수
전진차분과 중심차분
도함수의 정의는 극한입니다.
극한은 컴퓨터가 못 하므로 를 작은 수로 고정합니다. 극한 기호만 떼어 낸 이 식이 전진차분(forward difference)입니다.
한쪽만 보는 대신 양쪽을 대칭으로 보는 방법도 있습니다. 에서 만큼 오른쪽과 왼쪽의 두 점을 잡는 중심차분(central difference)입니다.
둘 다 가까운 두 점을 이은 직선의 기울기이고, 그 직선을 할선(secant line)이라고 부릅니다. 우리가 원하는 것은 그 자리의 접선인데, 할선을 그 대신 쓰는 것입니다. 두 차분은 할선을 어느 두 점에 거느냐만 다릅니다.
같은 을 썼는데도 중심차분 쪽이 접선에 훨씬 가깝습니다. 우연이 아니라 차수의 차이이고, 테일러로 세면 정확히 몇 배 나은지가 나옵니다.
테일러 전개로 센 차수
테일러 전개로 를 적습니다.
전진차분에 넣습니다. 가 지워지고 로 나누면
입니다. 원하는 뒤에 남는 것들이 오차이고, 그중 가장 큰 항이 입니다. 이렇게 무한급수를 잘라 버려서 생기는 오차를 절단오차(truncation error)라고 부릅니다. 의 1제곱에 비례하므로 전진차분의 절단오차는 입니다.
중심차분은 두 방향을 함께 씁니다. 는 위 전개에서 를 로 바꾼 것이므로
이고, 두 식을 빼면 짝수 차수 항이 통째로 사라집니다.
로 나눕니다.
항이 없습니다. 오른쪽으로 갈 때 생긴 휘어짐과 왼쪽으로 갈 때 생긴 휘어짐이 부호가 반대라 서로를 지웠기 때문입니다. 남은 첫 항이 에 비례하므로 중심차분의 절단오차는 입니다. 를 열 배 줄이면 전진차분의 오차는 열 배, 중심차분의 오차는 백 배 줄어든다는 뜻입니다.
차이가 실제로 얼마나 되는지 확인해 봅니다. 앞 글에서 유도한 GELU를 에서 잽니다. 이므로 에서 예측되는 전진차분의 절단오차는
이고, 실제로 재 보면 입니다. 소수점 넷째 자리까지 예측한 대로입니다. 같은 에서 중심차분은 이라 열 배 스무 배가 아니라 십만 배 정확합니다.
# ① 예측한 절단오차와 실제 오차를 맞대 본다 (GELU, x = 1.2)
import math
def Phi(t): return 0.5 * (1 + math.erf(t / math.sqrt(2)))
def phi(t): return math.exp(-t * t / 2) / math.sqrt(2 * math.pi)
g = lambda t: t * Phi(t)
dg = lambda t: Phi(t) + t * phi(t)
x0, h = 1.2, 1e-5
exact = dg(x0)
print(round(h / 2 * abs((2 - x0**2) * phi(x0)), 12)) # 5.43721e-07
print(round(abs((g(x0 + h) - g(x0)) / h - exact), 12)) # 5.43726e-07
테일러로 예측한 절단오차 와 실제로 잰 오차가 유효숫자 다섯 자리까지 같습니다. 오차가 어디서 오는지를 안다는 말이 이런 뜻입니다.
호출 횟수
정확도만 보면 중심차분이 이기지만 값을 치릅니다. 전진차분의 는 좌표를 몇 개 흔들든 한 번만 계산해 두고 계속 다시 쓸 수 있습니다. 좌표가 개면 한 번에 가 번, 모두 번입니다. 여기서 는 번째 칸만 1이고 나머지는 0인 벡터라, 는 좌표 하나만 흔든 입력입니다.
중심차분은 를 아예 쓰지 않는 대신 와 를 좌표마다 새로 불러야 하므로 번입니다. 신경망에서 입력부터 손실까지 한 번 계산해 내려가는 것이 순전파이고 한 번이 곧 순전파 한 번이니, 중심차분은 전진차분보다 순전파를 거의 두 배 돌립니다. 그 반대 방향, 손실에서 거꾸로 기울기를 계산해 올라가는 것이 역전파이고 처음의 backward가 그 한 조각입니다. 기울기 검사는 순전파만으로 잰 값과 역전파가 낸 값을 맞대는 일입니다.
그래도 기울기 검사에서는 중심차분을 씁니다. 검사는 학습 중에 도는 것이 아니라 유도를 적은 직후에 작은 입력으로 한 번 도는 것이라 두 배의 시간은 문제가 안 되고, 뒤에서 보듯 판정할 수 있는 자릿수가 세 자리쯤 차이 나기 때문입니다.
3계 도함수의 영점
중심차분의 절단오차 예측식 에는 가 곱해져 있습니다. 그 값이 0인 자리에서는 이 항이 사라지고, 다음 항이 오차를 정합니다. 테일러 전개를 한 줄 더 밀면 그 항은 입니다 — 홀수 차수만 남는다는 규칙이 그대로 이어져 로 한 번에 두 차수를 건너뜁니다.
GELU에서 이므로 에서 정확히 0입니다. 같은 로 재면 에서는 오차가 으로 예측식 그대로인데, 에서는 이 나옵니다. 십만 배 아래입니다. 를 에서 로 줄이면 오차가 에서 로 정확히 만 배 줄어드는 것도 의 흔적입니다.
이 예외를 알아 두어야 하는 까닭은 판정을 거꾸로 읽지 않기 위해서입니다. 검사 결과가 예측보다 훨씬 좋게 나온 자리가 있다면 거기가 우연히 인 점일 수 있고, 그 한 자리만 보고 를 크게 잡아도 된다고 결론 내리면 다른 자리에서 틀립니다.
반올림오차와 자릿수 소실
기계 엡실론
절단오차만 보면 를 계속 줄이는 것이 답입니다. 을 쓰면 되지 않을까요.
안 됩니다. 컴퓨터의 실수는 자릿수가 유한하기 때문입니다. float64는 유효숫자를 약 16자리 들고 있고, 1과 그다음으로 표현할 수 있는 수 사이의 상대적 간격을 기계 엡실론(machine epsilon)이라 부릅니다 — 입니다. 계산 한 번마다 결과가 가장 가까운 표현 가능한 수로 반올림되므로, 어떤 값이든 오차를 그 값의 크기로 나눈 비율, 곧 상대오차가 정도인 채로 다닙니다. 자세한 구조는 부동소수점 글이 다룹니다.
자릿수 소실
와 는 각각 절대오차 정도를 달고 계산됩니다. 두 값이 거의 같으므로 빼면 앞자리가 통째로 지워지고 오차만 남는데, 이것을 자릿수 소실(catastrophic cancellation)이라고 합니다. 남은 오차를 로 나누므로 결과의 오차는
입니다. 이렇게 반올림에서 와서 뺄셈이 키운 오차를 반올림오차(round-off error)라고 부릅니다. 가 작을수록 커집니다. 절단오차와 정반대 방향입니다.
쯤 가면 가 반올림되어 와 같은 수가 되고, 분자가 정확히 0이 되어 차분값도 0이 됩니다. 그때 오차는 정답의 크기 그 자체가 됩니다 — 아무것도 못 잰 것입니다.
상대 스텝
은 상대적인 간격이므로 표현할 수 있는 수 사이의 실제 간격은 의 크기를 따라 벌어집니다. 근처에서는 이웃한 두 float64 사이가 이고, 여기에 를 더하면 의 유효숫자 가운데 앞 다섯 자리쯤만 살아남습니다. 실제로 를 계산하면 가 아니라 이 나옵니다. 분모에 쓰는 와 실제로 흔든 폭이 벌써 백만분의 4만큼 어긋난 것입니다.
그래서 흔드는 폭을 의 크기에 맞춰 늘립니다. 를 쓰는 것을 상대 스텝(relative step)이라 하고, 가 1보다 작을 때는 가 1을 골라 원래의 로 돌아갑니다. 를 에서 재면 절대 스텝 로는 상대오차가 으로 뒤에서 세울 기준의 「의심스럽다」에 걸리고, 상대 스텝 으로는 로 통과합니다. 유도는 같은데 판정이 갈린 것이니, 검사 쪽의 스텝이 문제였습니다.
float32 중간값
검사를 float64로 하라는 말은 입력만 float64로 바꾸라는 뜻이 아닙니다. 손실까지 가는 길 어딘가에서 한 번이라도 float32로 내려갔다 오면 그 값은 float32의 만큼의 오차를 달고 있고, 뒤에서 다시 float64로 올려도 잃은 자리가 돌아오지 않습니다. 오차를 정하는 은 경로에서 가장 거친 것 하나입니다.
아래 코드 ②가 그 자리를 보여 줍니다. 의 기울기는 맞게 유도했고, 중간의 값 하나만 float32로 캐스팅했습니다. 최대 상대오차가 에서 으로 뛰어 뒤에서 세울 기준으로는 「틀렸다」입니다. 유도가 맞는데 검사가 틀렸다고 말하는 가장 흔한 경우이고, 혼합 정밀도로 학습하는 코드에서 특히 잘 생깁니다. 같은 코드에서 를 으로 키우면 오히려 으로 나아지는데, 를 키워서 좋아진다면 반올림오차가 판정을 쥐고 있다는 뜻이니 이런 캐스팅부터 찾습니다.
# ② 상대 스텝과 숨은 float32
import math
import numpy as np
xb = 1e6
print((xb + 1e-5) - xb) # 9.999959729611874e-06
for h in [1e-5, 1e-5 * max(1.0, abs(xb))]:
n = (math.sqrt(xb + h) - math.sqrt(xb - h)) / (2 * h)
a = 0.5 / math.sqrt(xb)
print(f"h={h:g} 상대오차 {abs(a - n) / (abs(a) + abs(n)):.2e}")
# h=1e-05 상대오차 5.29e-06
# h=10 상대오차 5.23e-12
def loss(z, leak=False):
a = np.tanh(z)
if leak:
a = a.astype(np.float32) # 어딘가에 숨은 캐스팅
return float(np.sum(a.astype(np.float64) ** 2))
z = np.array([0.3, -1.1, 0.7])
grad = 2 * np.tanh(z) * (1 - np.tanh(z) ** 2)
for leak in [False, True]:
worst = 0.0
for j in range(3):
e = np.zeros(3); e[j] = 1e-5
n = (loss(z + e, leak) - loss(z - e, leak)) / 2e-5
worst = max(worst, abs(grad[j] - n) / max(1e-8, abs(grad[j]) + abs(n)))
print(f"float32 섞임={leak} 최대 상대오차 {worst:.2e}")
# float32 섞임=False 최대 상대오차 6.01e-11
# float32 섞임=True 최대 상대오차 2.20e-03
최적 스텝 h
두 오차의 합
총 오차는 둘의 합입니다. 중심차분이면
이고, 앞 항은 가 커질수록, 뒤 항은 작아질수록 커집니다. 그러니 어딘가에 가장 좋은 가 있습니다. 실제로 그런지 GELU를 에서 를 부터 까지 바꿔 가며 재 봅니다.
로그-로그 그래프에서 거듭제곱은 직선이 됩니다. 의 양변에 로그를 취하면 라 기울기가 곧 차수 이기 때문입니다. 오른쪽으로 갈수록 가 작아지도록 그렸으니, 왼쪽 절반의 내려가는 기울기가 절단오차의 차수입니다 — 전진차분이 1, 중심차분이 2입니다.
바닥을 지나면 두 곡선이 겹쳐서 함께 올라갑니다. 그 구간에서는 절단오차가 이미 무의미하고 반올림오차만 남아 있으므로, 어느 공식을 썼든 결과가 같습니다.
# ③ h 를 바꿔 가며 재면 V 자가 나온다 (①의 g, dg 를 그대로 쓴다)
for k in [2, 4, 5, 6, 8, 10, 12, 14]:
h = 10.0 ** -k
fwd = (g(x0 + h) - g(x0)) / h
ctr = (g(x0 + h) - g(x0 - h)) / (2 * h)
print(f"h=1e-{k:<2d} 전진 {abs(fwd-exact):.3e} 중심 {abs(ctr-exact):.3e}")
# h=1e-2 전진 5.338e-04 중심 9.942e-06
# h=1e-4 전진 5.436e-06 중심 9.956e-10
# h=1e-5 전진 5.437e-07 중심 5.306e-12 ← 중심차분의 바닥
# h=1e-6 전진 5.434e-08 중심 6.082e-11
# h=1e-8 전진 6.053e-10 중심 1.050e-08 ← 전진차분의 바닥
# h=1e-10 전진 1.022e-06 중심 8.821e-08
# h=1e-12 전진 4.099e-05 중심 4.099e-05
# h=1e-14 전진 1.447e-02 중심 3.372e-03
최적 스텝의 상수
바닥이 어디인지는 를 미분해 0으로 두면 나옵니다.
는 에 비례하고, 전진차분에 같은 계산을 하면 에 비례합니다. 수를 넣으면 , 이고, 위 표에서 전진차분의 바닥이 , 중심차분의 바닥이 에 선 것과 맞습니다.
상수까지 넣으면 더 정확해집니다. GELU의 에서 , 이므로 이고, 그 자리의 예측 오차는 입니다. 실측 바닥 는 이보다 낮은데, 반올림오차가 늘 최악의 방향으로 쌓이지는 않기 때문입니다. 예측식은 상한에 가깝습니다.
실무에서 를 좌표마다 계산할 수는 없습니다. 그래도 하나를 기본값으로 굳혀도 되는 까닭이 세제곱근에 있습니다. 함수와 자리가 바뀌어 가 백 배 달라져도 는 배만 움직입니다. V 자의 바닥은 로그 눈금으로 몇 칸에 걸쳐 평평하므로, 에서 다섯 배쯤 비켜선 자리의 오차는 바닥과 같은 자릿수에 머뭅니다.
복소 스텝
반올림오차가 생기는 뿌리는 뺄셈이었습니다. 뺄셈을 아예 안 하는 길이 있습니다. 실수 대신 허수 방향으로 만큼 흔든 복소수를 넣으면, 테일러 전개가 복소수에서도 그대로 성립하는 함수라면
이고, 허수부만 떼어 로 나누면 가 남습니다. 이것이 복소 스텝(complex step) 미분 입니다. 절단오차의 차수는 중심차분과 같은 인데, 분자에 거의 같은 두 수의 뺄셈이 없으므로 자릿수 소실이 없습니다.
GPT-2가 쓰는 tanh 근사 GELU로 재면 에서는 두 방법의 오차가 으로 같습니다. 그런데 부터 복소 스텝의 오차는 정확히 0이 되고 에서도 그대로입니다. 같은 자리에서 중심차분은 , 정답 자체만큼 틀립니다. V 자의 오른쪽 절반이 통째로 사라진 것입니다.
조건이 하나 붙습니다. 계산 경로의 모든 연산이 복소수를 받아 해석적으로 이어져야 합니다. abs에 복소수를 넣으면 크기라는 실수 하나가 나와 허수부가 버려지고, max는 복소수끼리 크기를 비교할 수 없어 뜻이 없습니다. ReLU·clip·정렬처럼 대소 비교가 들어간 연산이 하나라도 끼면 쓸 수 없고, 신경망은 대개 그런 연산을 품고 있으므로 복소 스텝은 매끄러운 수식 블록 하나를 검사할 때 쓰는 도구입니다.
# ④ 복소 스텝에는 뺄셈이 없다
import cmath
c = math.sqrt(2 / math.pi)
def gelu_t(t, m=cmath): # GPT-2가 쓰는 tanh 근사
return 0.5 * t * (1 + m.tanh(c * (t + 0.044715 * t**3)))
def dgelu_t(t):
th = math.tanh(c * (t + 0.044715 * t**3))
return 0.5 * (1 + th) + 0.5 * t * (1 - th * th) * c * (1 + 3 * 0.044715 * t * t)
exact_t = dgelu_t(1.2)
for k in [2, 5, 8, 16]:
h = 10.0 ** -k
cs = gelu_t(complex(1.2, h)).imag / h
ctr = (gelu_t(1.2 + h, math) - gelu_t(1.2 - h, math)) / (2 * h)
print(f"h=1e-{k:<2d} 복소 {abs(cs - exact_t):.3e} 중심 {abs(ctr - exact_t):.3e}")
# h=1e-2 복소 9.871e-06 중심 9.871e-06
# h=1e-5 복소 9.871e-12 중심 3.040e-12
# h=1e-8 복소 0.000e+00 중심 8.629e-10
# h=1e-16 복소 0.000e+00 중심 1.118e+00
상대오차와 통과 기준
상대오차
값을 얻었으면 판정해야 합니다. 그냥 빼면 안 됩니다 — 기울기의 크기가 인 자리와 인 자리에서 같은 차이 이 전혀 다른 뜻을 갖기 때문입니다. 차이를 두 값의 크기로 나눈 상대오차를 씁니다.
가 해석적으로 유도한 값, 이 수치로 잰 값입니다. 분모의 은 둘 다 0에 가까울 때 0으로 나누는 것을 막는 바닥값입니다.
| 상대오차 | 판정 |
|---|---|
| 통과 | |
| 거의 확실히 맞다 | |
| 의심스럽다. 함수가 꺾이는 자리는 아닌지 본다 | |
| 틀렸다 |
기울기가 벡터면 좌표마다 상대오차가 나옵니다. 평균이 아니라 최댓값 하나를 봅니다 — 좌표 천 개 중 하나만 틀려도 유도가 틀린 것이고, 평균을 내면 그 하나가 999개에 묻힙니다.
전형적인 실수는 값이 크게 나오므로 바로 걸립니다. softmax와 교차엔트로피의 기울기 를 마지막 절의 코드 ⑧로 검사하면 최대 상대오차가 로 통과하는데, 부호를 뒤집으면 , 상수배 을 잘못 곱하면 가 나옵니다.
무작위 가중 합
지금까지는 출력이 손실 하나, 곧 스칼라였습니다. 커스텀 연산은 보통 벡터를 받아 벡터를 냅니다. 출력이 개, 입력이 개면 기울기는 칸의 표가 되고, 이 표를 야코비안 라고 부릅니다. 칸마다 재려면 표 전체를 채워야 합니다.
그럴 필요가 없습니다. 무작위 벡터 를 하나 뽑아 출력과 내적하면 라는 스칼라 함수가 생기고, 그 기울기는 입니다. 역전파가 하는 일이 바로 이것이라 — backward에 grad_out으로 를 넘기면 가 나옵니다 — 해석 쪽은 역전파 한 번이고, 수치 쪽은 스칼라 함수 하나를 검사하던 방법 그대로입니다. 가 무작위이므로 의 어느 행이 틀렸든 그 행이 섞여 들어가 결과가 어긋납니다. 출력 넷, 입력 셋인 로 해 보면 최대 상대오차가 입니다.
방향 도함수 검사
입력 쪽도 같은 방법으로 줄일 수 있습니다. 좌표를 하나씩 흔드는 대신 무작위 방향 로 모든 좌표를 한꺼번에 흔들면, 방향으로 잰 기울기, 곧 방향 도함수 가 중심차분 한 번으로 나옵니다.
해석 쪽은 유도한 기울기와 의 내적입니다. 파라미터가 백만 개여도 순전파가 두 번이고, 백만 좌표 전부가 이 한 수에 들어가 있습니다. 를 백만 좌표에서 재면 상대오차가 으로 거의 확실히 맞는 칸이고, 부호를 뒤집은 기울기는 입니다.
앞의 대보다 나쁜 것은 반올림오차 때문입니다. 손실이 백만 항의 합이라 인데 방향 도함수는 로 작아서, 같은 절대오차가 상대적으로 커졌습니다. 그리고 한계가 하나 있습니다. 백만 좌표 중 한 칸의 기울기에만 1을 더한 실수는 상대오차 로 여전히 걸리지만, 틀린 칸의 몫이 백만 칸과 섞여 묽어졌습니다. 한 칸의 실수가 작으면 판정 대역 아래로 가라앉을 수 있습니다.
# ⑤ 백만 좌표를 무작위 방향 하나로
rng = np.random.default_rng(0)
rel = lambda a, n: abs(a - n) / max(1e-8, abs(a) + abs(n))
w = rng.normal(size=1_000_000)
f = lambda w: np.sum(np.log(np.cosh(w)))
grad = np.tanh(w) # 손으로 유도한 값
v = rng.normal(size=w.size)
h = 1e-5
num = (f(w + h * v) - f(w - h * v)) / (2 * h) # 순전파 두 번
print(f"{rel(grad @ v, num):.2e}") # 5.71e-08
print(f"{rel(-grad @ v, num):.2e}") # 1.00e+00 부호 뒤집힘
bad = grad.copy(); bad[123] += 1.0
print(f"{rel(bad @ v, num):.2e}") # 2.43e-02 좌표 하나만 틀림
gradcheck의 기본값
PyTorch의 torch.autograd.gradcheck가 이 절차를 그대로 구현합니다. 소스를 열어 보면 수치 기울기는 중심차분으로 재고, 인자 셋의 기본값이 eps=1e-6, atol=1e-5, rtol=1e-3입니다. 이 글의 말로 옮기면 eps가 흔드는 폭 이고, atol과 rtol이 판정 대역입니다. 비교는 torch.allclose로 하므로 칸마다 을 봅니다 — 절대 허용치와 상대 허용치를 더한 것이라 이 글의 상대오차 식과 모양은 다르지만, 값이 클 때는 상대 쪽이, 0 근처에서는 절대 쪽이 판정을 맡는다는 역할은 같습니다.
rtol이 이 글의 보다 훨씬 너그러운 입니다. 온갖 연산에 두루 쓰는 기본값이라 헛경보를 피하는 쪽으로 넉넉하게 잡은 것으로 읽히고, 자기 연산을 검사할 때는 더 좁혀서 불러도 됩니다. 문서도 기본값이 배정밀도 입력을 전제로 맞춰져 있고 float32 입력이면 실패할 가능성이 크다고 적어 둡니다. 그리고 fast_mode=True를 주면 무작위 벡터 , 로 한 수를 재는데, 바로 위 두 소절의 방법입니다.
검사 제외 대상
꺾인 점
유한차분은 함수가 매끄럽다고 가정합니다. 테일러 전개를 썼으니 당연합니다. 함수가 꺾인 점 근처에서는 그 가정이 깨지고, 검사가 「틀렸다」고 말하지만 틀린 것은 유도가 아니라 검사 쪽입니다.
ReLU를 에서 중심차분으로 재면 가 무엇이든 가 나옵니다. 그 점에서 구현이 돌려주는 기울기는 0 아니면 1이므로 상대오차가 크게 뜹니다. 처럼 꺾인 점에서 보다 가까운 자리도 마찬가지입니다 — 재 보면 0.55가 나옵니다.
그래서 검사할 자리를 고를 때 꺾인 점과 그로부터 안쪽은 뺍니다. ReLU·LeakyReLU·max·abs·clip이 들어간 계산이 전부 그렇고, 좌표를 무작위로 몇 개 뽑아 검사하면 대개 저절로 피해집니다.
# ⑥ 꺾인 점에서는 검사가 거짓말을 한다
relu = lambda t: max(0.0, t)
for x0 in [0.0, 1e-6, -1e-6, 0.5]:
print(x0, (relu(x0 + 1e-5) - relu(x0 - 1e-5)) / 2e-5)
# 0.0 0.5 ← 구현이 주는 값은 0 또는 1인데 0.5 가 나온다
# 1e-06 0.55
# -1e-06 0.44999999999999996
# 0.5 0.9999999999982244 ← 꺾인 점에서 멀면 정확하다
드롭아웃과 난수
차분은 같은 함수를 두 번 부른다고 가정합니다. 드롭아웃(dropout)은 학습 중에 층 출력의 일부 칸을 부를 때마다 새로 뽑은 무작위 마스크로 0으로 만드는 층인데, 이것이 켜져 있으면 와 가 서로 다른 마스크를 써서 사실상 다른 함수가 됩니다. 두 손실의 차이가 와 무관한 크기로 벌어지고 그것을 로 나누므로, 유도가 맞아도 상대오차가 이 나옵니다.
고치는 방법은 두 가지입니다. 검사하는 동안 드롭아웃과 데이터 증강을 끄거나, 부를 때마다 난수 시드를 같은 값으로 되돌려 두 호출이 같은 마스크를 쓰게 하는 것입니다. 시드를 고정하고 그 마스크로 유도한 기울기와 맞대면 같은 코드가 로 통과합니다.
배치 정규화
배치 정규화(batch normalization)는 학습 모드에서 한 배치의 평균과 분산으로 각 샘플을 정규화합니다. 그러면 샘플 하나의 출력이 그 샘플의 입력만이 아니라 배치 전체에 매입니다. 샘플 넷짜리 배치에서 첫 샘플의 한 칸만 흔들면 그 칸의 출력만 움직이는 것이 아니라 같은 열의 나머지 세 샘플 출력도 , , 의 비율로 따라 움직입니다.
이것 자체는 오류가 아닙니다. 배치 정규화의 참된 기울기에 이 교차 항이 들어 있어야 합니다. 문제는 샘플마다 따로 기울기를 유도해 검사할 때입니다. 샘플 하나만 보고 유도한 식에는 이 항이 없으므로 검사가 실패하고, 그 실패는 정당합니다. 반대로 추론 모드에서는 저장해 둔 평균과 분산을 쓰므로 교차 항이 사라집니다. 검사할 때는 어느 모드의 함수를 재는지부터 정하고, 유도도 그 모드에 맞춥니다.
# ⑦ 드롭아웃은 시드를, 배치 정규화는 모드를 본다
def loss(w, seed=None):
r = np.random.default_rng(seed) # seed=None 이면 부를 때마다 새 마스크
mask = (r.random(w.shape) > 0.5) / 0.5 # 드롭아웃 p = 0.5
return np.sum((w * mask) ** 2), mask
w = np.array([0.4, -0.9, 1.3, 0.2])
_, m = loss(w, seed=7)
grad = 2 * w * m ** 2 # 마스크 m 을 고정하고 유도한 값
for seed in [None, 7]:
worst = 0.0
for j in range(4):
e = np.zeros(4); e[j] = 1e-5
n = (loss(w + e, seed)[0] - loss(w - e, seed)[0]) / 2e-5
worst = max(worst, rel(grad[j], n))
print(seed, f"{worst:.2e}")
# None 1.00e+00
# 7 4.13e-12
def bn(X): # 배치 정규화, 학습 모드
return (X - X.mean(0)) / np.sqrt(X.var(0) + 1e-5)
X = np.random.default_rng(0).normal(size=(4, 2))
Xp = X.copy(); Xp[0, 0] += 1e-3 # 첫 샘플의 첫 칸만 흔든다
print(np.round((bn(Xp) - bn(X))[:, 0] / 1e-3, 3))
# [ 1.057 -0.317 -0.562 -0.178]
검사 절차와 기본값
다섯 걸음
기본값은 float64 · 중심차분 · 이고, 판정은 상대오차의 최댓값으로 합니다. 그리고 기울기 검사를 학습 루프 안에 두지 않습니다. 좌표 하나마다 순전파를 두 번 더 돌리므로 파라미터가 백만 개면 순전파가 이백만 번입니다. 검사는 유도를 적은 직후에 작은 입력으로 한 번 합니다.
이것을 코드 한 조각으로 굳혀 두면 유도를 적을 때마다 그대로 부를 수 있습니다.
# ⑧ 검사 함수 하나와, 앞 글의 p − y
EPS_H = 1e-5 # 중심차분 · float64 기준
def rel_error(a, n):
return abs(a - n) / max(1e-8, abs(a) + abs(n))
def check_grad(loss, x, analytic, coords=None, h=EPS_H):
"""loss: 리스트를 받아 스칼라를 주는 함수. analytic: 손으로 유도한 기울기."""
coords = coords or range(len(x))
worst = 0.0
for j in coords:
plus = list(x); plus[j] += h
minus = list(x); minus[j] -= h
numeric = (loss(plus) - loss(minus)) / (2 * h)
worst = max(worst, rel_error(analytic[j], numeric))
return worst
def softmax(z):
m = max(z)
e = [math.exp(v - m) for v in z]
s = sum(e)
return [v / s for v in e]
z, y = [2.0, 1.0, 0.1], [1.0, 0.0, 0.0]
def ce_loss(zz):
p = softmax(zz)
return -sum(y[i] * math.log(p[i]) for i in range(3))
p = softmax(z)
print(f"{check_grad(ce_loss, z, [p[i] - y[i] for i in range(3)]):.2e}") # 6.93e-12 통과
print(f"{check_grad(ce_loss, z, [-(p[i] - y[i]) for i in range(3)]):.2e}") # 1.00e+00 부호 뒤집힘
print(f"{check_grad(ce_loss, z, [(p[i] - y[i]) / 3 for i in range(3)]):.2e}") # 5.00e-01 상수배 틀림
좌표 표본
coords 인자는 검사할 좌표를 골라 넘기는 자리입니다. 좌표를 전부 돌지 않고 무작위로 스무 개쯤만 뽑아도 되는 근거는 유도 실수의 모양에 있습니다. 부호를 뒤집거나, 상수를 빠뜨리거나, 항 하나를 잊는 실수는 한 칸이 아니라 식 전체에 걸리므로 거의 모든 좌표가 함께 틀립니다. 위의 부호 실수와 상수배 실수도 세 좌표가 전부 어긋났습니다.
수로 적으면 이렇습니다. 좌표 가운데 비율 만큼이 틀렸을 때 무작위로 개를 뽑아 하나도 안 걸릴 확률은 입니다. 절반이 틀렸으면 스무 개로 놓칠 확률이 이고, 10%가 틀렸으면 입니다. 그러나 1%만 틀렸으면 로 대개 놓칩니다. 인덱스 하나가 밀리는 실수처럼 특정 칸만 틀리는 경우가 여기에 들고, 그런 의심이 있으면 그 칸을 직접 coords에 넣거나 앞의 방향 도함수 검사를 곁들입니다.
순전파 오류
기울기 검사가 확인하는 것은 역전파가 순전파의 미분과 맞는가 하나입니다. 순전파 자체가 맞는가는 확인하지 않습니다. 해석 쪽과 수치 쪽이 같은 순전파를 공유하기 때문에, 순전파가 틀리고 역전파가 그 틀린 순전파를 정확히 미분했다면 검사는 통과합니다.
tanh 근사 GELU의 상수 를 로 잘못 적고, 그 잘못된 식을 충실히 미분해 역전파를 적었다고 해 봅니다. 에서 검사하면 상대오차가 로 깨끗하게 통과합니다. 그런데 순전파를 정확한 GELU와 에서 맞대면 최대 벌어집니다. 올바른 근사식은 같은 구간에서 만 벌어집니다. 그래서 순전파는 따로, 참값을 아는 참조 구현이나 손으로 푼 몇 점과 맞대야 합니다.
이제 처음의 MyOp로 돌아갑니다. backward의 한 줄을 적었으면 입력을 float64로 만들고, 드롭아웃과 배치 정규화의 모드를 정하고, 꺾인 점을 피해 좌표 스무 개쯤을 뽑아 중심차분 로 잽니다. 출력이 벡터면 무작위 grad_out 하나로 스칼라로 묶습니다. 최대 상대오차가 아래면 그 한 줄은 순전파와 맞고, 순전파는 참조 구현과 따로 맞댑니다. 그러고 나서야 손실이 덜 줄어드는 원인을 학습률이나 데이터에서 찾을 수 있습니다.
다음 글부터 어텐션으로 들어갑니다. 첫 자리는 식에 붙어 있는 인데, 그 수가 어디서 나왔는지를 분산 계산 한 번으로 닫습니다.
읽어주셔서 감사합니다. 😊

