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) 알고리즘의 핵심
역전파는 출력층에서 발생한 오차를 입력층 방향으로 거슬러 올라가며 각 층의 가중치와 편향에 대한 편미분 값을 계산하는 과정입니다. 알고리즘의 주요 단계는 다음과 같습니다.
- 순방향 전파: 각 층에 대해 \(z^l = w^l a^{l-1} + b^l\)과 \(a^l = \sigma(z^l)\)을 계산합니다.
- 출력층 오차 계산: \(\delta^L = \nabla_a C \odot \sigma'(z^L)\)을 구합니다.
- 오차 역전파: 각 층의 오차 \(\delta^l = ((w^{l+1})^T \delta^{l+1}) \odot \sigma'(z^l)\)를 계산합니다.
- 그래디언트 산출: 가중치와 편향의 변화량을 \(\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의 내부적인 행렬 최적화 라이브러리를 활용하므로 대량의 데이터를 학습할 때 훨씬 빠른 성능을 보장합니다.