신경망 기초와 역전파 알고리즘의 수학적 이해

1. 시그모이드 뉴런과 신경망 구조

기본적인 퍼셉트론 모델의 한계를 극복하기 위해 제안된 시그모이드 뉴런은 입력값의 작은 변화가 출력값의 작은 변화로 이어지게 설계되었습니다. 이는 신경망이 점진적으로 학습할 수 있는 기반이 됩니다. 활성화 함수로 사용되는 시그모이드 함수 \(\sigma(z) = \frac{1}{1+e^{-z}}\)는 출력을 0과 1 사이로 매끄럽게 제한합니다.

신경망은 입력층, 은닉층, 출력층으로 구성되며, 손글씨 숫자 인식(MNIST) 예제에서는 보통 784개의 입력 뉴런(28x28 픽셀)과 10개의 출력 뉴런(숫자 0-9)을 가집니다.

2. 경사 하강법과 비용 함수

학습의 목적은 비용 함수 \(C(w, b)\)를 최소화하는 가중치(\(w\))와 편향(\(b\))을 찾는 것입니다. 2차 비용 함수(Quadratic Cost Function)를 사용할 때, 가중치 업데이트 규칙은 다음과 같습니다.

\(w \rightarrow w - \eta \frac{\partial C}{\partial w}\), \(b \rightarrow b - \eta \frac{\partial C}{\partial b}\)

여기서 \(\eta\)는 학습률(Learning Rate)입니다. 전체 데이터를 한 번에 처리하면 계산 비용이 너무 크기 때문에, 무작위로 추출한 소량의 데이터 묶음인 '미니 배치(Mini-batch)'를 사용하여 그래디언트를 근사하는 확률적 경사 하강법(SGD)을 주로 사용합니다.

3. 역전파(Backpropagation) 알고리즘의 핵심

역전파는 출력층에서 발생한 오차를 입력층 방향으로 거슬러 올라가며 각 층의 가중치와 편향에 대한 편미분 값을 계산하는 과정입니다. 알고리즘의 주요 단계는 다음과 같습니다.

  1. 순방향 전파: 각 층에 대해 \(z^l = w^l a^{l-1} + b^l\)과 \(a^l = \sigma(z^l)\)을 계산합니다.
  2. 출력층 오차 계산: \(\delta^L = \nabla_a C \odot \sigma'(z^L)\)을 구합니다.
  3. 오차 역전파: 각 층의 오차 \(\delta^l = ((w^{l+1})^T \delta^{l+1}) \odot \sigma'(z^l)\)를 계산합니다.
  4. 그래디언트 산출: 가중치와 편향의 변화량을 \(\frac{\partial C}{\partial w^l} = \delta^l (a^{l-1})^T\), \(\frac{\partial C}{\partial b^l} = \delta^l\)로 결정합니다.

4. 파이썬 구현: 비벡터화 방식

각 샘플에 대해 개별적으로 오차를 계산하고 합산하는 기본적인 구현 방식입니다.

import numpy as np
import random

def sigmoid_fn(z):
    return 1.0 / (1.0 + np.exp(-z))

def sigmoid_deriv(z):
    return sigmoid_fn(z) * (1 - sigmoid_fn(z))

class BasicNetwork:
    def __init__(self, layer_sizes):
        self.depth = len(layer_sizes)
        self.biases = [np.random.randn(y, 1) for y in layer_sizes[1:]]
        self.weights = [np.random.randn(y, x) for x, y in zip(layer_sizes[:-1], layer_sizes[1:])]

    def process_sgd(self, train_data, epochs, batch_size, learning_rate, validation_data=None):
        n = len(train_data)
        for i in range(epochs):
            random.shuffle(train_data)
            batches = [train_data[k:k+batch_size] for k in range(0, n, batch_size)]
            for mini_batch in batches:
                self._update_params(mini_batch, learning_rate)
            if validation_data:
                print(f"Epoch {i}: {self.run_eval(validation_data)} / {len(validation_data)}")

    def _update_params(self, batch, lr):
        sum_grad_b = [np.zeros(b.shape) for b in self.biases]
        sum_grad_w = [np.zeros(w.shape) for w in self.weights]
        
        for x, y in batch:
            delta_b, delta_w = self._backpropagate(x, y)
            sum_grad_b = [nb + db for nb, db in zip(sum_grad_b, delta_b)]
            sum_grad_w = [nw + dw for nw, dw in zip(sum_grad_w, delta_w)]
            
        self.weights = [w - (lr / len(batch)) * nw for w, nw in zip(self.weights, sum_grad_w)]
        self.biases = [b - (lr / len(batch)) * nb for b, nb in zip(self.biases, sum_grad_b)]

    def _backpropagate(self, x, y):
        grad_b = [np.zeros(b.shape) for b in self.biases]
        grad_w = [np.zeros(w.shape) for w in self.weights]
        
        # Forward pass
        activation = x
        activations = [x]
        z_vectors = []
        for b, w in zip(self.biases, self.weights):
            z = np.dot(w, activation) + b
            z_vectors.append(z)
            activation = sigmoid_fn(z)
            activations.append(activation)
            
        # Backward pass
        delta = (activations[-1] - y) * sigmoid_deriv(z_vectors[-1])
        grad_b[-1] = delta
        grad_w[-1] = np.dot(delta, activations[-2].transpose())
        
        for l in range(2, self.depth):
            z = z_vectors[-l]
            sd = sigmoid_deriv(z)
            delta = np.dot(self.weights[-l+1].transpose(), delta) * sd
            grad_b[-l] = delta
            grad_w[-l] = np.dot(delta, activations[-l-1].transpose())
        return grad_b, grad_w

    def run_eval(self, data):
        results = [(np.argmax(self._feed_forward(x)), y) for x, y in data]
        return sum(int(p == y) for p, y in results)

    def _feed_forward(self, a):
        for b, w in zip(self.biases, self.weights):
            a = sigmoid_fn(np.dot(w, a) + b)
        return a

5. 파이썬 구현: 벡터화 방식

행렬 연산을 최적화하여 성능을 높인 방식입니다. 루프를 줄이고 행렬 전체를 한 번에 계산합니다.

class VectorizedNetwork(BasicNetwork):
    def _update_params(self, batch, lr):
        m = len(batch)
        # 데이터를 행렬 형태로 재구성
        x_matrix = np.column_stack([item[0] for item in batch])
        y_matrix = np.column_stack([item[1] for item in batch])
        
        # 순방향 전파 (전체 배치)
        activations = [x_matrix]
        z_vectors = []
        current_a = x_matrix
        for b, w in zip(self.biases, self.weights):
            z = np.dot(w, current_a) + b
            z_vectors.append(z)
            current_a = sigmoid_fn(z)
            activations.append(current_a)
            
        # 역방향 전파 (오차 행렬 계산)
        delta = (activations[-1] - y_matrix) * sigmoid_deriv(z_vectors[-1])
        
        # 가중치 및 편향 그래디언트 합산
        nabla_w = [None] * len(self.weights)
        nabla_b = [None] * len(self.biases)
        
        nabla_w[-1] = np.dot(delta, activations[-2].transpose())
        nabla_b[-1] = np.sum(delta, axis=1, keepdims=True)
        
        for l in range(2, self.depth):
            delta = np.dot(self.weights[-l+1].transpose(), delta) * sigmoid_deriv(z_vectors[-l])
            nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
            nabla_b[-l] = np.sum(delta, axis=1, keepdims=True)
            
        # 파라미터 업데이트
        self.weights = [w - (lr / m) * nw for w, nw in zip(self.weights, nabla_w)]
        self.biases = [b - (lr / m) * nb for b, nb in zip(self.biases, nabla_b)]

벡터화된 방식은 NumPy의 내부적인 행렬 최적화 라이브러리를 활용하므로 대량의 데이터를 학습할 때 훨씬 빠른 성능을 보장합니다.

태그: deep-learning Backpropagation Neural-Networks mnist python

7월 27일 10:03에 게시됨