본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research6 min read

Loss · Backpropagation: 역전파는 미분 결과를 뒤에서부터 재사용한다

신경망은 순전파로 예측과 손실을 계산하고, 역전파로 각 파라미터가 손실에 미친 영향을 구한다. 핵심은 같은 중간 미분을 여러 파라미터 계산에 재사용하는 것이다. NumPy로 2층 신경망을 구현하고 유한차분으로 기울기가 맞는지 검증한다.

By 송민성

오늘의 목표

역전파(backpropagation, 출력에서 입력 방향으로 미분을 전달하는 알고리즘)가 복잡한 별도 기술이 아니라 연쇄 법칙으로 계산한 미분을 재사용하는 과정임을 이해한다. 2층 다층 퍼셉트론(MLP, 선형층과 활성화 함수를 쌓은 신경망)의 기울기를 직접 계산하고 실제 학습까지 연결한다.

개념과 직관

1. 순전파와 손실

순전파(forward pass, 입력을 신경망의 앞에서 뒤로 통과시켜 예측을 만드는 과정)는 다음 순서로 진행된다.

text
입력 X
  ↓
첫 번째 선형층
  ↓
ReLU
  ↓
두 번째 선형층
  ↓
Softmax
  ↓
클래스별 확률

2층 MLP의 계산은 다음과 같다.

text
Z1 = X @ W1 + b1
H  = ReLU(Z1)
S  = H @ W2 + b2
P  = Softmax(S)
  • W1, b1: 첫 번째 층의 파라미터
  • H: 은닉 표현(hidden representation, 입력을 중간 특징으로 바꾼 값)
  • W2, b2: 두 번째 층의 파라미터
  • S: 로짓(logit, 확률로 변환하기 전 점수)
  • P: 클래스별 예측 확률

손실(loss, 예측이 정답과 얼마나 다른지 나타내는 숫자)은 교차 엔트로피 손실(cross-entropy loss, 정답 클래스의 예측 확률이 낮을수록 커지는 손실)을 사용한다.

text
샘플 하나의 손실 = -log(정답 클래스의 예측 확률)
전체 손실 = 모든 샘플 손실의 평균

정답 확률이 0.9면 손실은 작고, 0.01이면 손실은 크다.

2. 역전파는 책임을 뒤로 나누는 과정

손실이 크게 나왔다고 가정하자. 어느 파라미터를 얼마나 바꿔야 할까?

역전파는 손실에서 시작해 계산 경로를 거꾸로 따라간다.

text
손실 L
  ↑
확률 P
  ↑
점수 S
  ↑
은닉값 H
  ↑
첫 번째 층 Z1
  ↑
입력 X

각 화살표에서는 국소 기울기(local gradient, 해당 연산의 입력이 조금 변할 때 출력이 얼마나 변하는지)를 계산한다.

연쇄 법칙(chain rule, 여러 함수가 이어졌을 때 각 단계의 미분을 곱하는 규칙)은 다음처럼 읽으면 된다.

text
X가 손실에 미친 영향
= X가 H에 미친 영향 × H가 S에 미친 영향 × S가 손실에 미친 영향

택배가 여러 물류센터를 통과했다고 생각해 보자. 마지막 배송 오류를 조사할 때 전체 경로를 매번 처음부터 확인하지 않는다. 마지막 센터의 결과를 계산한 뒤 그 결과를 앞 센터 조사에 계속 재사용한다. 역전파도 뒤쪽에서 계산한 기울기를 앞쪽 층에 전달하며 재사용한다.

3. 층별 기울기

기울기(gradient, 각 파라미터를 조금 바꿨을 때 손실이 얼마나 변하는지 나타내는 값)를 출력층부터 계산한다.

샘플 수를 N, 정답을 원-핫 벡터 Y라고 하면 Softmax와 교차 엔트로피를 합친 미분은 다음처럼 단순해진다.

text
dS = (P - Y) / N

두 번째 선형층:

text
dW2 = H.T @ dS
db2 = dS를 샘플 방향으로 모두 더한 값
dH  = dS @ W2.T

ReLU:

text
Z1 > 0인 위치: dZ1 = dH
Z1 <= 0인 위치: dZ1 = 0

첫 번째 선형층:

text
dW1 = X.T @ dZ1
db1 = dZ1을 샘플 방향으로 모두 더한 값

dH를 한 번 계산해 ReLU와 첫 번째 층 계산에 이어서 사용한다. 이것이 미분의 재사용이다.

4. 순전파에서 저장할 값

역전파에 필요한 중간값은 순전파 중 저장해야 한다.

연산저장할 값역전파에서 쓰는 이유
첫 번째 선형층XdW1 계산
ReLUZ1어느 위치가 양수였는지 확인
두 번째 선형층HdW2 계산
SoftmaxPdS = P - Y 계산

이 저장 공간을 캐시(cache, 나중 계산을 위해 보관한 중간값)라고 부른다. 계산 시간을 줄이는 대신 메모리를 사용한다.

코드로 직접 해보기

자동 미분 기능 없이 NumPy 행렬 연산만 사용한다.

1. 2층 MLP 구현

python
import numpy as np class MLP: def __init__(self, input_dim, hidden_dim, output_dim, seed=0): rng = np.random.default_rng(seed) self.W1 = rng.normal( 0, np.sqrt(2 / input_dim), size=(input_dim, hidden_dim) ) self.b1 = np.zeros(hidden_dim) self.W2 = rng.normal( 0, np.sqrt(2 / hidden_dim), size=(hidden_dim, output_dim) ) self.b2 = np.zeros(output_dim) self.cache = {} def forward(self, X, y=None): z1 = X @ self.W1 + self.b1 h = np.maximum(0, z1) scores = h @ self.W2 + self.b2 # 큰 지수로 인한 수치 오류 방지 shifted = scores - scores.max(axis=1, keepdims=True) exp_scores = np.exp(shifted) probs = exp_scores / exp_scores.sum(axis=1, keepdims=True) self.cache = { "X": X, "z1": z1, "h": h, "probs": probs, } if y is None: return probs n = len(X) loss = -np.log(probs[np.arange(n), y] + 1e-12).mean() return probs, loss def backward(self, y): X = self.cache["X"] z1 = self.cache["z1"] h = self.cache["h"] probs = self.cache["probs"] n = len(X) dscores = probs.copy() dscores[np.arange(n), y] -= 1 dscores /= n dW2 = h.T @ dscores db2 = dscores.sum(axis=0) dh = dscores @ self.W2.T dz1 = dh * (z1 > 0) dW1 = X.T @ dz1 db1 = dz1.sum(axis=0) return { "W1": dW1, "b1": db1, "W2": dW2, "b2": db2, } def update(self, grads, learning_rate): self.W1 -= learning_rate * grads["W1"] self.b1 -= learning_rate * grads["b1"] self.W2 -= learning_rate * grads["W2"] self.b2 -= learning_rate * grads["b2"]

backward()는 파라미터를 직접 수정하지 않는다. 기울기 계산과 파라미터 갱신을 분리해야 오류를 찾기 쉽다.

2. 간단한 분류 데이터 만들기

두 좌표의 부호가 같으면 클래스 1, 다르면 클래스 0으로 지정한다. 직선 하나로는 나누기 어려운 문제라 은닉층의 역할을 확인할 수 있다.

python
def make_data(n=400, seed=1): rng = np.random.default_rng(seed) X = rng.uniform(-1, 1, size=(n, 2)) y = ((X[:, 0] * X[:, 1]) > 0).astype(int) return X, y X, y = make_data() model = MLP(input_dim=2, hidden_dim=4, output_dim=2) probs, loss = model.forward(X, y) grads = model.backward(y) print("초기 손실:", loss) print("W1 모양:", model.W1.shape) print("dW1 모양:", grads["W1"].shape)

파라미터와 해당 기울기의 모양은 같아야 한다.

text
W1과 dW1: (2, 은닉 차원)
b1과 db1: (은닉 차원,)
W2와 dW2: (은닉 차원, 2)
b2와 db2: (2,)

3. 유한차분으로 기울기 검증

유한차분(finite difference, 파라미터를 아주 조금 움직여 손실 변화를 직접 측정하는 방법)으로 역전파 결과를 검사한다.

text
수치 기울기
= (파라미터를 +epsilon 했을 때 손실
   - 파라미터를 -epsilon 했을 때 손실)
  / (2 × epsilon)
python
def finite_difference(model, X, y, name, index, epsilon=1e-5): param = getattr(model, name) original = param[index] param[index] = original + epsilon _, loss_plus = model.forward(X, y) param[index] = original - epsilon _, loss_minus = model.forward(X, y) param[index] = original return (loss_plus - loss_minus) / (2 * epsilon) # 분석 기울기를 먼저 계산 _, _ = model.forward(X, y) grads = model.backward(y) checks = [ ("W1", (0, 0)), ("b1", (0,)), ("W2", (0, 0)), ("b2", (0,)), ] for name, index in checks: numerical = finite_difference(model, X, y, name, index) analytical = grads[name][index] absolute_error = abs(numerical - analytical) relative_error = absolute_error / max( 1e-8, abs(numerical) + abs(analytical) ) print( name, index, "역전파:", analytical, "유한차분:", numerical, "상대 오차:", relative_error, )

상대 오차가 충분히 작으면 역전파 구현이 수치 계산과 일치한다. 값이 거의 0인 기울기는 상대 오차가 크게 보일 수 있으므로 절대 오차도 함께 확인한다.

4. 실제 학습

경사하강(gradient descent, 손실을 줄이는 방향으로 파라미터를 조금씩 옮기는 것)을 적용한다.

python
def train(hidden_dim, steps=1500, learning_rate=0.2): model = MLP( input_dim=2, hidden_dim=hidden_dim, output_dim=2, seed=0, ) _, first_loss = model.forward(X, y) for _ in range(steps): _, loss = model.forward(X, y) grads = model.backward(y) model.update(grads, learning_rate) probs, last_loss = model.forward(X, y) predictions = probs.argmax(axis=1) accuracy = (predictions == y).mean() return first_loss, last_loss, accuracy first_loss, last_loss, accuracy = train(hidden_dim=16) print("학습 전 손실:", first_loss) print("학습 후 손실:", last_loss) print("정확도:", accuracy)

학습이 정상이라면 학습 후 손실이 학습 전보다 낮아진다. 정확도만 보지 말고 손실도 함께 확인한다.

오늘의 실험

은닉 차원을 4, 16, 64로 바꾸고 같은 데이터, 학습 횟수, 학습률을 사용한다.

python
print("은닉 차원 | 초기 손실 | 최종 손실 | 정확도") for hidden_dim in [4, 16, 64]: first, last, accuracy = train(hidden_dim) print( f"{hidden_dim:9d} | " f"{first:9.4f} | " f"{last:9.4f} | " f"{accuracy:7.3f}" )

결과는 다음 표로 기록한다.

은닉 차원초기 손실최종 손실정확도관찰
4
16
64

관찰할 항목:

  1. 모든 설정에서 최종 손실이 초기 손실보다 낮은가
  2. 은닉 차원이 커질수록 항상 정확도가 높아지는가
  3. 실행할 때마다 결과가 달라지는가
  4. 학습률을 0.02, 0.2, 2.0으로 바꾸면 손실이 어떻게 변하는가

은닉 차원이 크면 표현할 수 있는 패턴은 늘지만 항상 더 잘 학습되는 것은 아니다. 초기값, 학습률, 데이터 수에 따라 결과가 달라진다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM도 순전파로 토큰별 예측과 손실을 계산하고, 역전파로 수많은 파라미터의 기울기를 구한다. LLM 후속 학습(post-training) 업무에서는 손실이 감소하지 않거나 기울기가 불안정할 때 데이터, 손실 함수, 마스킹, 학습률, 역전파 경로를 함께 점검한다.

초보자가 막히는 지점

행렬 모양이 맞지 않는다

matmul 관련 오류가 나오면 각 값의 모양부터 출력한다.

python
print(X.shape) print(model.W1.shape) print(model.W2.shape)

행렬곱 (N, 2) @ (2, H)의 결과는 (N, H)다.

Softmax 계산에서 nan이 나온다

큰 값에 exp()를 적용하면 숫자 범위를 넘을 수 있다. 각 행의 최댓값을 먼저 빼야 한다.

python
shifted = scores - scores.max(axis=1, keepdims=True)

ReLU 역전파 방향을 반대로 쓴다

z1 > 0인 위치만 기울기를 통과시킨다. z1 <= 0인 위치의 기울기는 0이다.

유한차분 오차가 크다

epsilon이 너무 크면 근사가 부정확하고, 너무 작으면 부동소수점 오차가 커진다. 먼저 1e-5를 사용하고, 순전파와 역전파가 같은 파라미터 상태에서 계산됐는지 확인한다.

손실이 줄지 않는다

학습률이 너무 크면 손실이 튀고, 너무 작으면 거의 변하지 않는다. 0.02, 0.2, 2.0을 비교하고 매 반복마다 forward → backward → update 순서를 지켰는지 확인한다.

알고리즘 (병행 1시간)

  1. LeetCode Easy: 104. 이진 트리의 최대 깊이

- 재귀 함수가 현재 노드에서 무엇을 반환해야 하는지 먼저 문장으로 적는다. - 빈 노드의 깊이를 0으로 처리한다. - 왼쪽 깊이와 오른쪽 깊이 중 큰 값에 1을 더한다.

  1. LeetCode Medium: 102. 이진 트리의 층별 순회

- 큐(queue, 먼저 들어온 값을 먼저 꺼내는 자료구조)를 사용한다. - 각 층을 처리하기 전 현재 큐의 길이를 저장한다. - 해당 길이만큼만 노드를 꺼내 한 층의 결과로 묶는다.

문제마다 다음 세 항목을 기록한다.

text
접근:
시간복잡도:
틀린 이유:

두 문제 모두 각 노드를 한 번씩 방문하므로 목표 시간복잡도는 O(n)이다. 재귀 풀이에서는 호출 스택, 큐 풀이에서는 큐가 추가 공간을 사용한다.