홈으로 돌아가기

신경망에서의 경사 하강법과 역전파: CUDA/C++

경사 하강법과 역전파 심층 탐구. CUDA/C++에서 처음부터 MNIST를 위한 신경망 훈련 알고리즘 구현. 수학 및 코드 상세 분석.

경사 하강법과 역전파: CUDA/C++에서 처음부터 신경망 훈련
Advertisement 728x90

경사 하강법과 역전파: CUDA/C++로 신경망을 밑바닥부터 훈련시키기

이 글은 신경망 세계를 깊이 탐구하는 네 번째 시리즈로, CUDA의 가장 기본적인 구성 요소부터 시작하여 트랜스포머 아키텍처를 단계별로 구축하고 있습니다. 이번 편에서는 순전파(forward pass) 구현을 넘어 모델 훈련이라는 중요한 단계로 나아갑니다. 경사 하강법(Gradient Descent)과 역전파(Backpropagation)의 근본적인 메커니즘을 탐구하고, C++와 CUDA를 사용하여 MNIST 데이터셋의 손글씨 숫자를 인식하도록 신경망을 훈련시키는 저수준 구현을 진행할 것입니다. 우리의 목표는 단순히 기성 라이브러리를 사용하는 것을 넘어, 이러한 복잡한 계산 과정이 '내부적으로(under the hood)' 어떻게 작동하는지 철저히 이해하는 것입니다. 이는 현대 머신러닝 및 인공지능 모델을 깊이 이해하는 데 필수적입니다.

신경망 훈련의 기본: 경사 하강법

이전 편에서 순전파를 성공적으로 구현한 후, 우리 모델은 예측을 수행할 수 있게 되었습니다. 하지만 초기 단계에서는 훈련 없이 이러한 예측은 무작위적일 것입니다. 모델이 올바른 결과를 생성하기 시작하려면, 예측과 실제 레이블 간의 오차를 최소화하도록 내부 매개변수(가중치와 편향)를 조정해야 합니다. 경사 하강법과 역전파와 같은 알고리즘은 바로 이러한 목적을 위해 사용됩니다.

짙은 안개 속 산 정상에 서서 계곡의 가장 낮은 지점으로 내려가야 하는 상황을 상상해 보세요. 전체 풍경을 볼 수는 없지만, 발밑의 경사를 느낄 수는 있습니다. 기울기(gradient)는 가장 가파른 오르막 방향을 나타내는 벡터입니다. 따라서 내려가려면 기울기의 반대 방향으로 움직여야 합니다. 신경망의 맥락에서 "산"은 오차 함수(또는 손실 함수)의 표면이며, "계곡"은 그 전역 최솟값(global minimum)입니다. 경사 하강법의 목표는 손실 함수에 대한 네트워크 가중치의 기울기 반대 방향으로 가중치를 반복적으로 조정하여 이 최솟값을 찾는 것입니다.

Google AdInline article slot

역전파는 이러한 기울기를 효율적으로 계산하는 알고리즘입니다. 네트워크의 출력 계층에서 입력 계층으로 오류를 역전파하여, 모든 가중치에 오류에 대한 책임을 분배하는 방식으로 작동합니다. 수학적으로 이는 미분의 연쇄 법칙(Chain Rule)을 통해 구현되며, 각 가중치의 변화가 모델의 전체 오류에 어떻게 영향을 미치는지 계산할 수 있게 해줍니다.

손실 함수: 교차 엔트로피

모델 예측의 "나쁨" 정도를 측정하기 위해 손실 함수가 사용됩니다. MNIST 손글씨 숫자 인식과 같은 분류 작업에서는 교차 엔트로피 손실(cross-entropy loss)이 표준적인 선택입니다. 이는 모델이 예측한 확률 분포와 실제 분포(정답 클래스만 확률이 1이고 나머지는 0인 경우) 간의 차이를 정량화합니다. 이 차이가 클수록 손실 함수 값은 높아집니다.

단일 예시에 대한 교차 엔트로피는 다음 공식을 사용하여 계산됩니다:

Google AdInline article slot

![L = -\sum_{i=1}^{K} y_i \log(\hat{y}_i)](./images/image-1.svg)

여기서:

  • ![K](./images/image-2.svg) — 클래스 수입니다 (MNIST의 경우 10).
  • ![y_i](./images/image-3.svg) — 실제 값입니다 (정답 클래스에 대해 1, 나머지는 0).
  • ![\hat{y}_i](./images/image-4.svg) — 모델의 예측값입니다 (소프트맥스 계층 이후 얻은 확률).

훈련은 일반적으로 개별 예시가 아닌 데이터 배치(batch)에서 이루어지므로, 배치에 대한 총 손실 함수는 배치 크기(![N](./images/image-5.svg) — 배치 크기)로 평균화됩니다:

Google AdInline article slot

![L = -\frac{1}{N} \sum_{j=1}^{N} \sum_{i=1}^{K} y_{j,i} \log(\hat{y}_{j,i})](./images/image-6.svg)

아래는 CPU에서 교차 엔트로피를 계산하는 C++ 코드이며, 디버깅 및 이해를 돕기 위해 간소화되었습니다:

float cross_entropy_loss_cpu(
  const Tensor& predictions, const std::vector<int>& labels
) {
  int batch_size = predictions.shape()[0];
  int num_classes = predictions.shape()[1];
  float loss = 0.0f;
  for (int i = 0; i < batch_size; i++) {
    for (int j = 0; j < num_classes; j++) {
      float prob = predictions.get({i, j});
      float target = (labels[i] == j) ? 1.0f : 0.0f;
      loss += -std::log(prob) * target;
    }
  }
  return loss / batch_size;
}

역전파와 연쇄 법칙

손실 함수 ![L](./images/image-9.svg)를 최소화하려면, 네트워크의 각 가중치가 미세하게 변할 때 그 값이 어떻게 변하는지 알아야 합니다. 이것이 가중치에 대한 손실 함수의 기울기입니다. 예를 들어, 첫 번째 계층의 가중치 ![W_1](./images/image-10.svg)에 대해 ![\frac{\partial L}{\partial W_1}](./images/image-8.svg)를 찾아야 합니다.

이전 글들에서 개발된 우리 네트워크의 아키텍처는 다음과 같습니다:

  • 첫 번째 선형 계층: ![z_1 = W_1 \cdot x + b_1](./images/image-11.svg)
  • 활성화 함수 (ReLU): ![a_1 = \sigma(z_1)](./images/image-12.svg)
  • 두 번째 선형 계층: ![z_2 = W_2 \cdot a_1 + b_2](./images/image-13.svg)
  • 출력 (Softmax + 손실): ![L = Loss(Softmax(z_2))](./images/image-14.svg)

기울기 ![\frac{\partial L}{\partial W_1}](./images/image-16.svg)를 계산하기 위해 연쇄 법칙을 적용합니다:

![\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial W_1}](./images/image-17.svg)

이 연쇄의 각 항은 주어진 계층 또는 함수의 출력이 입력에 어떻게 영향을 미치는지 보여주는 지역 기울기(local gradient)를 나타냅니다. 역전파는 마지막 계층의 출력에 대한 손실 함수의 기울기를 계산하는 것으로 시작하여, 이 기울기를 네트워크의 모든 계층을 통해 순차적으로 뒤로 전파합니다.

소프트맥스와 교차 엔트로피 기울기의 상세 유도

연쇄의 가장 마지막부터 시작해 봅시다. 소프트맥스를 적용한 후 두 번째 선형 계층의 출력 ![z_2](./images/image-20.svg)에 대한 손실 함수 ![L](./images/image-25.svg)의 미분입니다. 이는 가장 중요하고 자주 사용되는 기울기 중 하나입니다. 벡터 ![z_2](./images/image-20.svg)의 각 요소 ![i](./images/image-19.svg)에 대한 소프트맥스가 어떻게 생겼는지 상기해 봅시다:

![a_{2, i} = \frac{e^{z_i}}{\sum_{j=1}^{K} e^{z_j}}](./images/image-21.svg)

여기서 ![z_2](./images/image-22.svg)는 두 번째 선형 계층의 출력(로짓)이고, ![a_2](./images/image-23.svg)는 소프트맥스 결과(확률)입니다. 기울기 ![\frac{\partial L}{\partial z_{2,i}}](./images/image-18.svg)는 연쇄 법칙을 사용하여 분해할 수 있습니다:

![\frac{\partial L}{\partial z_{2,i}} = \sum_{k=1}^{K} \frac{\partial L}{\partial a_{2,k}} \cdot \frac{\partial a_{2, k}}{\partial z_{2,i}}](./images/image-24.svg)

먼저, ![a_i](./images/image-26.svg)에 대한 손실 함수 ![L](./images/image-27.svg)의 미분을 찾아봅시다:

![\frac{\partial L}{\partial a_i} = \frac{\partial}{\partial a_i} \left( -y_i \ln(a_i) \right) = -y_i \cdot \frac{1}{a_i} = -\frac{y_i}{a_i}](./images/image-29.svg)

이제 ![z](./images/image-30.svg)에 대한 소프트맥스의 미분으로 넘어가 봅시다. 여기서는 미분의 몫 규칙(quotient rule)이 필요합니다. 소프트맥스 공식: ![a_i = \frac{e^{z_i}}{\sum_{k=1}^K e^{z_k}}](./images/image-32.svg)

만약 ![i=j](./images/image-43.svg)라면, 미분은 다음과 같습니다:

![\frac{\partial a_i}{\partial z_i} = a_i (1 - a_i)](./images/image-36.svg)

만약 ![i \neq j](./images/image-37.svg)라면, 미분은 다음과 같습니다:

![\frac{\partial a_i}{\partial z_j} = -a_i a_j](./images/image-41.svg)

크로네커 델타 기호 ![\delta_{ij}](./images/image-42.svg)를 사용하여 이 두 가지 경우를 결합할 수 있습니다:

![\frac{\partial a_i}{\partial z_j} = a_i (\delta_{ij} - a_j)](./images/image-44.svg)

교차 엔트로피와 소프트맥스의 미분을 결합하면 다음과 같습니다:

![\frac{\partial L}{\partial z_i} = \sum_{k=1}^{K} \left( -\frac{y_k}{a_k} \right) \cdot \left( a_k(\delta_{ki} - a_i) \right) = \sum_{k=1}^{K} -y_k (\delta_{ki} - a_i)](./images/image-49.svg)

실제 확률의 합 ![\sum_{k=1}^{K} y_k](./images/image-51.svg)가 항상 1과 같다는 점을 고려하여 더 단순화하면 놀랍도록 간단하고 우아한 공식이 나옵니다:

![\frac{\partial L}{\partial z_i} = a_i - y_i](./images/image-52.svg)

이는 로짓(소프트맥스 이전의 출력)에 대한 손실 함수의 기울기가 예측 확률과 실제 레이블 간의 단순한 차이라는 것을 의미합니다. 이 결과는 분류 모델에서 역전파를 구현하는 데 있어 중요한 초석이 됩니다.

C++로 기울기 구현하기

도출된 공식을 적용하여 신경망의 마지막 계층에 대한 기울기 계산을 구현할 수 있습니다. 이 기울기는 모델을 더 작은 오류로 이끌기 위해 가중치를 업데이트하는 데 사용될 것입니다. C++ 코드에서는 다음과 같습니다:

auto grad = Tensor::create_zeros({ batch_size, num_classes });

for (int i = 0; i < batch_size; ++i) {
  for (int j = 0; j < num_classes; ++j) {
    float prob = fc2.output->get({i, j});
    float target = (targets[i] == j) ? 1.0f : 0.0f;
    float gradient = (prob - target) / batch_size; // 평균을 위한 batch_size로 나누기
    grad->set({i, j}, gradient);
  }
}

이 코드는 배치 내 각 요소에 대한 오류 기울기를 계산한 다음 배치 크기로 평균을 냅니다. 결과로 생성된 grad 텐서는 오류를 줄이기 위해 로짓 ![z_2](./images/image-54.svg)를 얼마나 변경해야 하는지에 대한 정보를 담고 있습니다. 이 기울기는 네트워크의 이전 계층을 통해 역전파되어, 해당 지역 미분을 사용하여 가중치 ![W_2](./images/image-13.svg), ![b_2](./images/image-13.svg), ![W_1](./images/image-11.svg), ![b_1](./images/image-11.svg)를 조정할 수 있게 합니다.

핵심 요약:

  • 경사 하강법은 손실 함수의 최솟값을 찾기 위해 손실 함수의 기울기 반대 방향으로 모델 매개변수를 조정하는 반복적인 최적화 방법입니다.
  • 역전파는 신경망의 모든 가중치에 대한 손실 함수의 기울기를 효율적으로 계산하기 위해 연쇄 법칙을 사용하는 알고리즘입니다.
  • 교차 엔트로피는 분류 작업에서 예측된 확률 분포와 실제 확률 분포 간의 차이를 측정하는 표준 손실 함수입니다.
  • 로짓에 대한 소프트맥스 + 교차 엔트로피의 미분은 예측 확률과 실제 레이블 간의 차이로 단순화되어 구현을 크게 간소화합니다.
  • C++/CUDA로 밑바닥부터 구현하면 고수준 추상화에 의존하지 않고 트랜스포머와 같은 모델의 내부 메커니즘을 깊이 이해할 수 있습니다.

— Editorial Team

Advertisement 728x90

다음 읽기