머신러닝·신경망

DL / 25번째 글

순전파와 역전파: 신경망 학습의 핵심 원리

신경망 학습의 핵심인 순전파와 역전파를 수식과 코드로 완전히 이해한다. 연쇄 법칙이 어떻게 다층 신경망의 기울기를 계산하는지, PyTorch Autograd가 이를 어떻게 자동화하는지, 기울기가 이상할 때 어디를 보는지를 다룬다.

PALDYN Team27 MIN READ

지난 글에서 활성화 함수가 신경망에 비선형성을 부여한다는 것을 봤다. 이번에는 그 신경망이 어떻게 학습하는지를 끝까지 따라간다. 신경망 안에 든 숫자들이 파라미터이고, 학습은 그 숫자를 더 나은 값으로 옮기는 절차이며, 어느 쪽으로 얼마나 옮길지를 알려 주는 값이 기울기다. 그 기울기를 싸게 구하는 방법이 역전파다. 1986년 Rumelhart, Hinton, Williams가 정리한 이 알고리즘이 없었다면 층을 여러 겹 쌓은 신경망은 계산량 때문에 실용화되지 못했을 것이다. 이 글은 예측을 만드는 순전파, 기울기를 구하는 역전파, 그 기울기로 숫자를 옮기는 갱신을 한 바퀴 돌면서 각 단계가 무엇을 남기고 무엇을 소비하는지 본다.

손실과 경사하강

최소화할 대상

학습은 결국 하나의 수를 줄이는 일이다. 그 수를 만드는 것이 손실 함수이고, 예측이 정답에서 얼마나 떨어졌는지를 데이터 전체에 걸쳐 하나로 접어 준다. 회귀라면 오차 제곱의 평균이고 분류라면 정답 클래스에 매긴 확률의 음의 로그다. 어느 쪽이든 손실은 파라미터의 함수다 — 같은 데이터에 같은 정답이라도 신경망 안의 숫자가 달라지면 손실이 달라진다.

그래서 학습의 정의가 선명해진다. 손실을 가장 작게 만드는 파라미터를 찾는 일이다. 다만 그 파라미터를 한 번에 풀어낼 공식이 없다. 층이 겹치고 비선형이 끼어 있어 손실을 파라미터로 미분해 0으로 놓는 고전적인 방법이 닫힌 해를 주지 않는다. 대신 경사하강을 쓴다 — 지금 자리에서 손실이 가장 가파르게 줄어드는 방향을 구해 그쪽으로 조금 옮기고, 새 자리에서 다시 구하는 것을 되풀이한다. 그 '조금'의 크기가 학습률이다.

W ← W - η · ∂L/∂W

이 한 줄이 학습의 전부다. 남은 문제는 오른쪽의 ∂L/∂W를 어떻게 구하느냐이고, 역전파는 정확히 그것만 한다. 역전파는 학습 알고리즘이 아니라 기울기 계산 알고리즘이다 — 옮기는 일은 옵티마이저가 하고 역전파는 어느 쪽으로 옮길지만 알려 준다. 이 구분이 흐려지면 "Adam을 쓰면 역전파를 안 한다" 같은 오해가 생긴다.

수치 미분의 비용

미분의 정의를 그대로 쓰는 방법이 있다. 파라미터 하나를 아주 조금 키운 채 손실을 재고, 아주 조금 줄인 채 다시 재서 그 차이를 나누면 된다. 정확도도 나쁘지 않고 구현은 열 줄이면 끝난다. 그런데 이 방법은 파라미터 하나마다 순전파를 두 번 요구한다.

숫자를 대 보면 왜 못 쓰는지가 바로 보인다. 파라미터가 1,000만 개인 중간 크기 모델을 생각하자. 한 스텝의 기울기를 얻으려면 순전파가 2,000만 번 필요하다. 순전파 한 번이 10밀리초라면 2,000만 번은 20만 초, 곧 55시간이다. 파라미터를 한 번 옮기는 데 이틀이 넘게 드는데 학습에는 그런 스텝이 수만 번 필요하다. 수치 미분은 틀린 방법이 아니라 감당할 수 없는 방법이다.

역전파의 비용

역전파는 순전파를 한 번 하고, 그 결과를 거꾸로 한 번 훑으면서 모든 파라미터의 기울기를 동시에 얻는다. 역방향 한 층에서 하는 일은 행렬곱 두 번이다 — 아래층으로 내려보낼 기울기를 만드는 곱 하나, 그 층의 가중치 기울기를 만드는 곱 하나. 순전파가 층마다 행렬곱 한 번이었으니 역방향은 대략 그 두 배가 되고, 순전파까지 더해 한 스텝의 총비용이 순전파 한 번의 2~3배로 잡힌다.

같은 자리에 두 수를 놓으면 차이가 분명하다. 수치 미분은 파라미터 수에 비례해 2,000만 번, 역전파는 파라미터 수와 무관하게 3번어치다. 학습이 추론보다 대략 세 배 무겁다는 실무의 어림값이 여기서 나온다. 뒤에서 볼 메모리 이야기까지 더하면, 학습용 GPU와 추론용 GPU의 요구 조건이 왜 다른지도 같은 계산에서 설명된다.

순전파가 남기는 값

z와 a

순전파는 입력이 층을 지나며 예측이 되는 과정이다. 층 하나가 하는 일은 두 단계로 갈린다. 먼저 입력에 가중치를 곱하고 편향을 더해 하나의 수로 모으고(이 결과를 보통 z라고 쓴다), 그 z에 활성화 함수를 씌워 다음 층으로 넘길 활성값 a를 만든다.

z1 = x @ W1.t() + b1    # (8, 4) 선형 변환
a1 = torch.relu(z1)      # (8, 4) 활성화
z2 = a1 @ W2.t() + b2   # (8, 1) 선형 변환

구현에서 두 줄을 굳이 나누는 것은 취향이 아니다. 역전파가 이 층을 지날 때 활성화 함수의 미분값을 곱해야 하는데, 그 미분의 인자가 a가 아니라 z이기 때문이다. ReLU처럼 a만 보고도 미분을 복원할 수 있는 함수가 있고 GELU처럼 없는 함수가 있는데, 프레임워크는 함수마다 무엇을 남길지 다르게 정해 둔다. 어느 쪽이든 순전파가 지나간 자리에 무언가는 남아 있어야 한다는 사실은 같다.

저장되는 중간값

순전파가 끝나면 예측만 남고 나머지는 버려질 것 같지만 실제로는 그 반대다. 역전파가 쓸 값들이 통째로 메모리에 붙들려 있다. 목록으로 적으면 이렇다.

  • 각 층의 입력 활성값 — 가중치 기울기가 입력 × 상류 기울기 꼴이라 입력이 없으면 계산이 안 된다
  • 활성화 함수의 미분에 필요한 z 또는 a
  • 드롭아웃이 이번 순전파에서 뽑은 마스크 — 역방향에서 같은 자리를 똑같이 꺼야 한다
  • 배치 정규화가 이번 배치에서 잰 평균과 분산

여기서 추론과 학습이 갈린다. 추론은 예측만 필요하므로 층을 지나는 즉시 앞 층의 중간값을 버릴 수 있다. 학습은 못 버린다. torch.no_grad() 블록 안에서 메모리 사용량이 뚝 떨어지는 이유가 이것이고, 같은 모델이 추론은 되는데 학습은 OOM이 나는 이유도 이것이다.

활성값 메모리

저장되는 중간값의 양은 대략 배치 크기 × 층의 폭 × 층 수에 비례한다. 파라미터 메모리는 배치와 무관하게 고정인데 활성값 메모리만 배치에 정비례한다는 점이 중요하다. 배치를 32에서 64로 올리면 파라미터가 차지하는 자리는 그대로이고 활성값이 두 배가 된다. 학습 중 OOM이 거의 언제나 배치 크기에서 나고, 배치를 반으로 줄이면 대체로 해결되는 배경이 여기 있다.

수를 넣어 보면 자릿수가 잡힌다. 폭 1,024에 층 24개인 모델을 배치 32, 길이 512로 돌리면 층마다 남는 활성값이 32 × 512 × 1,024개이고 24층이면 4억 개가 넘는다. fp16으로 세어도 8GB 남짓이다. 같은 모델의 파라미터가 수억 개라 해도 그쪽은 배치를 아무리 키워도 늘지 않는다. 배치 하나를 올리는 순간 GPU가 터지는 자리는 거의 언제나 이 활성값 쪽이다.

이 성질을 역으로 이용하는 것이 그래디언트 체크포인팅이다. 중간값을 전부 들고 있는 대신 몇 군데만 남겨 두고, 역전파가 그 구간을 지날 때 순전파를 다시 돌려 필요한 값을 재생산한다. 메모리를 계산으로 바꾸는 거래이고 대가는 대략 순전파 한 번어치다. 큰 모델을 작은 GPU에 올릴 때 가장 먼저 켜는 스위치다.

층으로 이어지는 연쇄 법칙

2층 MLP의 전개

역전파는 새로운 수학이 아니라 연쇄 법칙을 층 수만큼 반복 적용한 것이다. 합성 함수의 미분은 바깥 미분과 안쪽 미분의 곱이고, 신경망은 층을 겹쳐 만든 거대한 합성 함수다. 2층 신경망을 끝까지 전개해 보면 반복의 모양이 보인다.

z1=xW1⊤+b1,a1=ReLU(z1)z2=a1W2⊤+b2,y^=z2L=1n∑(y^−y)2\begin{aligned} z_1 &= x W_1^\top + b_1, \quad a_1 = \mathrm{ReLU}(z_1) \\ z_2 &= a_1 W_2^\top + b_2, \quad \hat{y} = z_2 \\ L &= \tfrac{1}{n}\textstyle\sum (\hat{y} - y)^2 \end{aligned}

첫 층의 가중치 기울기는 손실에서 출발해 네 개의 항을 거쳐 도착한다.

∂L∂W1=[(∂L∂y^W2)⊙ReLU′(z1)]⊤x\frac{\partial L}{\partial W_1} = \left[ \left( \frac{\partial L}{\partial \hat{y}} W_2 \right) \odot \mathrm{ReLU}'(z_1) \right]^\top x

항을 하나씩 읽으면 ∂L/∂ŷ는 손실 함수의 미분이고, W₂를 곱하는 것은 출력층을 거슬러 올라가는 단계이고, ReLU'(z₁)를 원소별로 곱하는 것은 활성화를 거슬러 올라가는 단계이고, 마지막에 x를 곱하는 것은 선형 변환을 거슬러 올라가는 단계다. 층이 열 겹이어도 가운데 두 단계가 열 번 되풀이될 뿐 모양은 그대로다.

shape 검산

전개한 식이 맞는지는 모양만 따라가도 확인된다. 배치 8, 입력 3, 은닉 4, 출력 1이라면 ∂L/∂ŷ가 (8,1)이고 W₂가 (1,4)이므로 곱하면 (8,4)다. ReLU'(z₁)도 (8,4)라 원소별 곱이 성립하고, 그 결과를 전치해 (4,8)로 만든 뒤 x의 (8,3)과 곱하면 (4,3)이 나온다. W₁이 (4,3)이니 맞다.

여기서 규칙 하나를 건져 갈 수 있다. 기울기는 언제나 그 파라미터와 같은 모양이다. 당연한 말 같지만 역전파를 손으로 구현하다 전치를 한 번 빠뜨리면 바로 걸리는 검사이고, 배치 차원을 합칠지 남길지 헷갈릴 때 답을 주는 기준이기도 하다. 가중치 기울기에서는 배치 차원이 합쳐져 사라지고 활성값 기울기에서는 남는다.

지역 기울기와 상류 기울기

층 하나의 입장에서 보면 알아야 할 것이 둘뿐이다. 위에서 내려온 "내 출력이 손실에 미치는 영향"이 상류 기울기이고, "내 출력이 내 입력과 내 파라미터에 대해 갖는 미분"이 지역 기울기다. 층은 둘을 곱해 자기 파라미터의 기울기를 만들고, 같은 상류 기울기를 자기 입력 쪽으로 한 번 더 굴려 아래층에 넘긴다.

이 분리가 딥러닝 프레임워크의 설계 전체를 떠받친다. 층은 자기 지역 기울기만 알면 되고 전체 신경망의 생김새는 몰라도 된다. 그래서 새 층을 만들 때 forward와 backward 둘만 정의하면 어떤 모델 한가운데에 꽂아도 학습이 돌아가고, 서로 모르는 사람이 만든 층들을 이어 붙일 수 있다. 역전파의 실용적 가치는 계산량 절감만이 아니라 이 부품화에 있다.

순전파와 역전파 흐름

숫자로 한 스텝

순전파와 손실

식만으로는 손에 안 잡히니 수를 넣어 한 바퀴 완주해 보자. 입력이 x = [1, 2] 하나, 정답이 y = 1이고, 첫 층 가중치가 W₁ = [[0.5, -0.3], [0.2, 0.8]], 편향은 0, 활성화는 ReLU, 출력층 가중치가 W₂ = [1.0, -1.0]이라고 하자.

선형 조합을 구하면 z₁ = [0.5·1 + (-0.3)·2, 0.2·1 + 0.8·2] = [-0.1, 1.8]이다. ReLU를 씌우면 음수가 0이 되어 a₁ = [0, 1.8]이 된다. 출력층은 ŷ = 1.0·0 + (-1.0)·1.8 = -1.8이고, 정답이 1이므로 손실은 (-1.8 - 1)² = 7.84다. 첫 뉴런이 ReLU에서 꺼졌다는 사실을 기억해 두자 — 역방향에서 이 자리가 그대로 다시 나온다.

역전파와 갱신

이제 거꾸로 간다. 손실의 미분은 ∂L/∂ŷ = 2(ŷ - y) = -5.6이다. 출력층 가중치 기울기는 그 값에 입력 활성값을 곱한 [-5.6·0, -5.6·1.8] = [0, -10.08]이고, 아래로 넘길 기울기는 W₂를 곱한 [-5.6, 5.6]이다.

여기에 ReLU의 미분을 원소별로 곱한다. z₁의 첫 성분이 음수였으므로 미분이 0이고 둘째는 1이다. 따라서 ∂L/∂z₁ = [0, 5.6]이 된다. 첫 뉴런으로 내려가던 -5.6이 여기서 끊긴다. 마지막으로 입력을 곱하면 ∂L/∂W₁ = [[0, 0], [5.6, 11.2]]다.

학습률 0.01로 한 스텝 옮기면 둘째 행이 [0.2, 0.8] - 0.01·[5.6, 11.2] = [0.144, 0.688]이 되고 첫 행은 기울기가 0이라 제자리다. 갱신된 가중치로 순전파를 다시 하면 z₁ = [-0.1, 1.52], ŷ = -1.52, 손실은 6.35다. 7.84에서 6.35로 내려왔다. 한 스텝이 이것이고, 학습은 이 계산을 수만 번 되풀이하는 일이다.

동시에 ReLU의 위험도 이 숫자 안에 다 들어 있다. 첫 뉴런은 이번 스텝에서 기울기를 하나도 못 받았다. 그 뉴런에 들어오는 가중치가 계속 음수 쪽 z만 만들면 영원히 갱신되지 않는다 — 흔히 말하는 죽은 뉴런이다.

수치 기울기 대조

손으로 짠 역전파가 맞는지 확인하는 표준 방법이 수치 기울기와의 대조다. 쓸 수 없다던 그 수치 미분을 검증용으로만 쓰는 것이다. 파라미터 전부가 아니라 몇 개만 골라 재면 되므로 비용이 문제되지 않는다.

from torch.autograd import gradcheck

def f(w):
    return (torch.relu(x @ w.t()) @ W2.t()).sum()

# 반드시 double 정밀도로
w = W1.double().detach().requires_grad_(True)
print(gradcheck(f, (w,), eps=1e-6, atol=1e-4))

gradcheck가 double 정밀도를 요구하는 데는 이유가 있다. 중앙차분의 간격이 1e-6인데 float32의 유효숫자가 7자리뿐이라, 두 손실값의 차이가 반올림 오차에 묻혀 버린다. 검사가 실패했는데 원인이 구현이 아니라 정밀도인 상황이 이때 생긴다.

Autograd의 계산 그래프

그래프 구성과 역방향 순회

PyTorch는 위의 손 계산을 자동으로 한다. requires_grad=True인 텐서가 연산에 들어갈 때마다 그 연산을 노드로 하는 계산 그래프가 순전파와 동시에 그려진다. 그래프를 미리 선언하고 데이터를 흘리는 방식이 아니라 파이썬 코드가 실행되는 대로 그려지므로, if 문으로 층을 건너뛰거나 반복 횟수를 입력에 따라 바꿔도 그대로 미분된다.

x = torch.tensor([2.0], requires_grad=True)
y = x ** 3 + 2 * x + 1
print(y.grad_fn)        # AddBackward0
y.backward()
print(x.grad)           # tensor([14.])  ← 3x²+2 = 14

backward()는 그 그래프를 위상 역순으로 훑으면서 각 노드의 지역 기울기를 상류 기울기에 곱해 아래로 흘린다. 앞 절에서 손으로 한 일과 정확히 같은 일이고, 다른 점은 순서를 사람이 정하지 않는다는 것뿐이다.

PyTorch Autograd

기울기 누적

backward()는 .grad에 값을 대입하지 않고 더한다. 그래서 학습 루프에서 optimizer.zero_grad()를 빠뜨리면 두 번째 스텝의 기울기가 첫 스텝의 것과 합쳐지고, 세 번째는 셋이 합쳐진다. 손실은 줄어들 듯 말 듯 하다가 발산하는데 에러는 한 줄도 안 난다.

for epoch in range(200):
    optimizer.zero_grad()      # 1) 이전 기울기 비우기
    loss = criterion(model(X), y)  # 2) 순전파
    loss.backward()            # 3) 역전파
    optimizer.step()           # 4) 갱신

누적이 기본값인 것은 설계 실수가 아니라 기능이다. GPU 메모리에 배치 64가 안 들어갈 때 16짜리 네 번을 연달아 backward() 하고 마지막에 한 번만 step() 하면 배치 64로 학습한 것과 같은 기울기가 나온다. 이 기법이 그래디언트 누적이고, 대형 모델 학습에서 사실상 표준으로 쓰인다.

detach · no_grad · retain_graph

그래프를 다루는 세 도구는 역할이 다른데 이름이 비슷해 자주 섞인다. detach()는 값은 같고 이력만 없는 복사본을 만들어 그 자리에서 그래프를 끊는다. torch.no_grad()는 블록 안에서 그래프를 아예 그리지 않아 평가와 추론에서 메모리를 아낀다. retain_graph=True는 backward()가 끝나며 버리는 중간 버퍼를 남겨 두라는 지시로, 같은 그래프에 역전파를 두 번 해야 할 때만 쓴다.

가장 흔한 사고는 셋을 몰라서가 아니라 로깅에서 난다.

losses.append(loss)          # 그래프 전체가 리스트에 붙는다 → 메모리 폭발
losses.append(loss.item())   # 값만 꺼낸다

loss는 그래프의 마지막 노드라서 리스트에 담는 순간 그 에폭의 중간값이 통째로 살아남는다. 에폭을 돌수록 메모리가 계단처럼 오르다 터지는데, 원인이 모델이 아니라 로그 한 줄이라 찾는 데 오래 걸린다.

기울기 이상의 세 갈래

NaN · 0 · 폭발

학습이 안 될 때 기울기가 보이는 증상은 세 갈래이고 원인도 갈린다. 손실이 nan이 되는 것은 대개 정의역을 벗어난 연산이다 — 확률 0에 로그를 씌웠거나 지수가 넘쳤다. 소프트맥스와 로그를 따로 계산하지 말고 log_softmax를 쓰라는 권고가 여기서 나온다. 수치적으로 같은 식을 안전한 순서로 계산해 주기 때문이다.

기울기 노름이 1e-8 근처로 붙어 있으면 기울기 소실이다. ReLU가 전부 꺼졌거나 sigmoid·tanh가 포화 구간에 들어가 미분이 0에 수렴한 상태다. 반대로 노름이 1e4를 넘나들면 폭발이다. 학습률이 크거나 순환 구조에서 같은 가중치가 여러 번 곱해진 자리에서 잘 난다.

층별 기울기 노름

셋을 가르는 가장 빠른 방법은 층마다 기울기 크기를 찍어 보는 것이다.

for name, p in model.named_parameters():
    if p.grad is not None:
        print(f"{name:24s} {p.grad.norm().item():.3e}")

읽는 법이 있다. 층마다 비슷한 자릿수면 정상이다. 출력 쪽에서 입력 쪽으로 갈수록 자릿수가 계단처럼 떨어지면 소실이고, 층을 깊게 쌓을수록 심해진다. 한 층만 유독 튀면 그 층의 초기화나 활성화를 먼저 본다. 손실 곡선은 세 경우 모두 "안 줄어든다"로 똑같아 보이지만 이 출력은 셋을 가른다.

원인 셋

실무에서 기울기가 망가지는 원인은 대부분 셋 중 하나다. 첫째는 입력 스케일이다. 정규화하지 않은 특성이 수천 단위로 들어오면 첫 층의 z가 처음부터 포화 구간에 앉는다. 둘째는 학습률이다. 손실이 몇 스텝 만에 nan이 되면 대개 한 자릿수 낮추는 것으로 해결된다. 셋째는 손실과 출력 활성화의 조합이다. sigmoid를 씌운 출력에 MSE를 쓰면 예측이 틀릴수록 기울기가 오히려 0에 가까워지는데, BCEWithLogitsLoss처럼 로짓을 그대로 받는 손실을 쓰면 그 자리가 사라진다.

순서를 정해 두면 헤매지 않는다. 먼저 입력 한 배치를 꺼내 평균과 표준편차를 찍어 본다. 그다음 학습률을 열 배 낮춰 몇 스텝만 돌려 증상이 바뀌는지 본다. 그래도 같으면 출력층의 활성화와 손실이 짝이 맞는지 확인한다. 셋을 다 지나도 남으면 그때 구조를 의심하면 된다.

셋 다 모델 구조가 아니라 모델 바깥의 문제라는 점이 중요하다. 층을 바꾸거나 파라미터를 늘리기 전에 입력 분포와 학습률과 손실 선택을 먼저 확인하는 순서가 시간을 아낀다.


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

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN