티스토리 뷰

numpy 라이브러리만을 이용해 Neural Network를 직접 구현한다.

 

Hidden layer는 1개만 사용할 것이다. (Hidden layer 수가 많을수록 성능이 좋아지는 경향이 있으나 연산량이 많아지고 이해하기 좀 더 복잡하기 때문에 일단 1개만 사용)

MNIST 분류 문제를 풀기 위해 Input layer, Hidden layer, Output layer의 노드 수는 각각 784, 100, 10으로 설정한다.

Input 노드 수가 784인 이유는 MNIST 이미지 크기가 28x28 (=784) 이기 때문이다.

Output 노드 수가 10인 이유는 정답 레이블이 0~9까지 총 10개이기 때문이다.

Hidden 노드 수는 딱 정해져 있는 값은 아니고 자신이 선택하면 되는데, MNIST 분류 문제에서는 보통 100으로 많이 설정하기 때문이다.

 

내가 이해하기 편한대로 대충 그려보았다. 

각 layer 안의 원은 노드를 의미하는데 실제로는 784, 100, 10개씩 존재하지만 2개씩만 그렸다.

Input layer에서 x값의 화살표가 출력으로 쭉 나오는 이유는 Input layer에서는 행렬 곱 연산이나 활성화함수를 씌우는 과정을 거치지 않고, Z1, O1이 x값 그대로 나오기 때문이다. 즉 Z1 = x, O1 = x이다. (이 점 유의!)

Z는 우리가 익숙히 알고 있는 Wx+b 연산을 한 값이다. 이 식에서 x는 입력 값, 즉 이전 단계에서의 출력 값을 의미하므로 이렇게 레이어가 여러 개 있는 경우에는 x가 이전 단계의 O가 된다. 예를 들어, Z2 = O1 · W1 + b1이다.

MNIST 분류 문제에서 activation function으로는 sigmoid를 사용한다. O값은 단순히 sigmoid(Z)이다.

O3가 최종 예측 값이 된다.

 

오차 역전파의 식을 이해하는게 좀 어려웠는데, '신경망 첫걸음' 책 p.124~129에 잘 설명되어 있다.

설명해보자면 다음과 같다.

우선 오차 함수로는 MSE를 사용한다. (-> 사실 이 점은 이해가 안된다. 다중 클래스 분류인데 왜 cross entropy가 아니라 MSE를 사용하는가..? 내가 뭔가 잘 못 이해하고 있는 건가?)

 

E는 오차 함수, On은 신경망에서의 예측 결과 값, yn은 실제 값(타겟 값)이다.

 

가중치를 업데이트할 때, Gradient Descent Algorithm을 사용한다. 따라서 위 식을 이용해 가중치를 업데이트한다.

E를 Wjk로 미분하는 식을 살펴보자.

 

j를 hidden layer에서의 노드, k를 output layer에서의 노드로 생각하자.

hidden layer에서 output layer로 가는 가중치 Wjk를 업데이트하기 위해서는 오차 함수를 Wjk로 미분해야 한다.

이 때, 오차 함수 E에서는 모든 n에서의 값을 다 더하고 있다. 하지만 Wjk에 대해 미분해야 하는 상황이므로, Wjk의 영향을 받는 항만 남기면 된다. 즉, 위의 식을 다음과 같이 간략하게 바꿀 수 있다.

yk는 실제 타겟(레이블) 값이므로 상수이다. 따라서 Ok만 미분해준다.

그럼 이제 Ok를 Wjk로 미분하는 것을 구해보자.

Ok는 Output layer에서의 최종 출력 값으로, Hidden layer의 최종 출력 값과 weight를 행렬 곱하고 bias를 더한 다음 sigmoid를 씌운 값이다.

Ok를 Wjk에 대해 미분해야 하는데, Ok는 크게 보면 sigmoid 함수이다. sigmoid 함수를 미분하면 다음과 같다.

따라서 Ok를 Wjk에 대해 미분한 식은 다음과 같다.

맨 끝에 Oj가 붙은 이유는 Ok 식에서 Wjk 항의 계수가 Oj이기 때문이다.

최종적으로 오차 함수를 Wjk로 미분한 식은 다음과 같다.

계수는 중요하지 않아서 생략이 가능하다. 꽤 간단한 식이 되었다!

식 이해를 쉽게 하기 위해 Oj 를 맨 끝에 써줬는데, 행렬 곱 연산의 shape를 맞춰주려면 최종적으로 다음과 같은 순서로 쓴다.

이 값을 learning rate와 곱하여 기존 Wjk에서 빼주어 가중치 업데이트를 한다.

 

Output layer에서의 오차는 실제 값을 알기 때문에 예측 값 - 실제 값 (= Ok - yk) 을 이용해 계산이 가능하지만, Hidden layer에서는 실제 값(타겟 값)이라는 것이 없기 때문에 이렇게 구할 수 없다.

Hidden layer에서의 오차는 Output layer에서의 오차에 Wjk를 행렬 곱 하여 구할 수 있다.

(단순히 Ok-yk 값에 행렬 곱하여 구할 수도 있지만, (Ok-yk)*Ok*(1-Ok) 값에 행렬 곱하여 구하는 것이 정확도가 훨씬 높다.)

오차 함수 E를 Input layer와 Hidden layer 사이의 가중치 Wij로 미분한 식은 다음과 같다.

이 또한 행렬 곱 연산을 위해 shape를 맞춰주면 최종적으로 다음과 같다.

이 값을 learning rate와 곱하여 가중치 Wij에서 빼주어 가중치 업데이트를 한다.

 

 

 

이제 코드로 구현해보자. 코드로 구현할 때 shape를 파악하는 것이 굉장히 중요하다.

 

train_data_list = np.loadtxt('./mnist_train.csv', delimiter=',', dtype=np.float32)

우선 mnist_train.csv 파일을 인터넷에서 다운받아 numpy 배열에 저장한다.

이 배열로부터 하나씩 값을 가져와 신경망에 넣을 것이다.

 

신경망에 들어가는 맨 처음 입력 값인 x는 0~255까지의 픽셀 값을 784개 갖고 있는 1차원 벡터로, shape은 (784, )이다. 

for train_data in train_data_list:
	input_data = train_data[1:] / 255.0 * 0.99 + 0.01	# normalization

0~255 범위의 값을 그대로 넣게 되면 오버플로가 발생할 수 있다고 한다. 따라서 normalization을 해주는데, 0~1이 아니라 0.01~1로 바꿔준다. 0이 아니라 0.01로 하는 이유는 값이 0인 경우 가중치 업데이트가 중단되기 때문이다.

train_data는 [레이블, 픽셀1, 픽셀2, ..., 픽셀 784]와 같이 0번째 값이 레이블이기 때문에 1번째 값부터 가져온다.

 

    target_data = np.zeros(output_nodes) + 0.01
    target_data[int(train_data[0])] = 0.99

target_data는 output_node 수의 크기를 갖는 배열이며 레이블에 해당하는 인덱스 값만 0.99로 설정한다. 나머지 값들은 0.01이다.

 

    input_data = np.array(input_data, ndmin=2)
    target_data = np.array(target_data, ndmin=2))

input_data의 shape은 (784, ), target_data의 shape은 (10, )로 둘 다 1차원이다. 이를 신경망에 넣어줄 때는 2차원인 (1, 784), (1, 10)으로 바꿔줘야 한다.

ndim 파라미터는 최소 몇 차원으로 설정할지를 의미한다.

 

 

def sigmoid(x):
  return 1 / (1 + np.exp(-x))

activation function으로 사용할 sigmoid 함수를 따로 구현해둔다.

 

 

이제 신경망을 만들자.

class NeuralNetwork:
  def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate):
    self.input_nodes = input_nodes
    self.hidden_nodes = hidden_nodes
    self.output_nodes = output_nodes
    self.learning_rate = learning_rate

    self.W1 = np.random.randn(self.input_nodes, self.hidden_nodes) / np.sqrt(self.input_nodes) # Xavier initialization
    self.b1 = np.random.rand(1, self.hidden_nodes)

    self.W2 = np.random.randn(self.hidden_nodes, self.output_nodes) / np.sqrt(self.hidden_nodes) # Xavier initialization
    self.b2 = np.random.rand(1, self.output_nodes)

    self.Z1 = np.zeros([1, input_nodes])  # input 값 그대로
    self.O1 = np.zeros([1, input_nodes])  # input 값 그대로

    self.Z2 = np.zeros([1, self.hidden_nodes])
    self.O2 = np.zeros([1, self.hidden_nodes])

    self.Z3 = np.zeros([1, self.output_nodes])
    self.O3 = np.zeros([1, self.output_nodes])

편의상 self. 는 생략하고 쓰도록 한다.

weight를 의미하는 W1와 W2를 초기화할 때에는 그냥 0이나 랜덤한 값으로 설정해도 되지만, 좀 더 좋은 성능을 위해서는 보통 Xavier initialization을 사용한다고 한다. (activation function이 ReLU인 경우에는 He initialization을 사용하는게 좋다.) randn() 함수를 사용하는 것과 입력 노드의 수에 루트를 씌운 값으로 나눠주는 게 특징이다.

가중치 초기화 방법을 다르게 하여 테스트 했을 때 accuracy는 Xavier: 0.9654, random: 0.8543, zero: 0.9394로 Xavier를 이용했을 때가 가장 성능이 좋았다. (여기서는 좋게 나오긴 했지만 zero로 초기화하는 것은 하지 말라고 한다.)

bias를 의미하는 b1과 b2 초기화 시에는 그냥 rand() 함수를 사용하며, O와 W의 행렬 곱 연산 결과에 b를 더해줘야 하므로 shape이 (1, 다음 레이어의 노드 수)이다.

위에서 말했듯 input layer의 출력 값인 Z1, O1는 입력 값을 그대로 가질 것이므로 (1, input_nodes)의 shape을 갖도록 초기화한다.

 

  def feed_forward(self):
    delta = 1e-7  # log 무한대 발산 방지

    self.Z1 = self.input_data # input 값 그대로
    self.O1 = self.input_data # (1,784)

    self.Z2 = np.dot(self.O1, self.W1) + self.b1
    self.O2 = sigmoid(self.Z2) # (1,100)

    self.Z3 = np.dot(self.O2, self.W2) + self.b2
    self.O3 = sigmoid(self.Z3) # (1,10)
    h = self.O3

    self.loss = -np.mean(self.y * np.log(h + delta) + (1 - self.y) * np.log(1 - h + delta))  # log 무한대 발산 방지를 위해 delta 더해주기

신경망의 각 레이어를 지나 최종 예측 값을 계산해내는 feed_forward() 함수이다.

Z1, O1에는 input 값을 그대로 할당해준다.

Z2 = O1 · W1 + b1이고 Z3 = O2 · W2 + b2이다. 행렬 곱을 하기 위해 shape을 맞추려고 W · O 순서가 아니라 O · W 순서로 연산을 한다. (모든 값의 shape을 꼭 계산해보시길)

O값은 그냥 sigmoid(Z)를 한 값이다.

loss에서는 cross entropy function을 사용했는데 꼭 안 구해도 된다.

 

  def back_propagation(self):
    loss_3 = (self.O3 - self.y) * self.O3 * (1 - self.O3)
    self.W2 -= np.dot(self.O2.T, loss_3) * self.learning_rate
    self.b2 -= loss_3 * self.learning_rate

    loss_2 = np.dot(loss_3, self.W2.T) * self.O2 * (1 - self.O2)
    self.W1 -= np.dot(self.O1.T, loss_2) * self.learning_rate
    self.b1 -= loss_2 * self.learning_rate

대망의 가중치를 업데이트하는 back_propagation() 함수이다.

위에서 식 유도는 복잡했지만, 이해하고 나면 구현은 간단하다.

위에서 구한 이 두 식을 이용해 weight 값을 업데이트하면 된다.

bias 값은 loss와 learning rate를 곱해서 빼주는 방식으로 업데이트한다.

 

'mnist_test.csv' 파일을 인터넷에서 다운받아 테스트해본 결과, 정확도 96.72%를 달성했다.

 

 

쓰는데 이렇게 오래 걸릴지 몰랐는데 힘들다 ^-^.. 그래도 정리하니 후련하다!

반응형

댓글