본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(1)

[밑시딥] 오직! Numpy로 간단한 신경망 구현하기

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 1권의 교재 내용을 기반으로 딥러닝 신경망을 Tensorflow, Pytorch와 같은 딥러닝 프레임워크를 사용하지 않고 순수한 Numpy로 구현하면서 딥러닝의 기초를 탄탄히 하고자 하는 목적 하에 게시되는 포스팅입니다. 내용은 주로 필자가 중요하다고 생각되는 내용 위주로 작성되었음을 알려드립니다.

밑바닥부터 시작하는 딥러닝


이번 포스팅에서는 딥러닝 구현의 가장 기초라고 할 수 있는 것들에 대해 알아보려고 한다. 먼저 Numpy의 Element-wise product와 broadcasting에 대해 알아보자.

1. Element-wise product 와 Broadcasting

우선 Element-wise product는 배열의 동일한 위치의 원소들끼리 연산하는 것을 의미한다. 다음과 같은 배열이 있다고 가정해보면 결과는 아래와 같이 나오게 된다.

Element-wise product


그렇다면 브로드캐스팅은 무엇일까? 브로드캐스팅은 Numpy의 강력한 힘이라고도 할 수 있다. 아래의 그림을 보면 브로드캐스팅이 어떤 역할을 하는 것인지 확 와닿을 것이다.

브로드캐스팅


즉, 두 배열의 형상(shape)가 달라도 브로드캐스팅이 알아서 위처럼 계산을 해준다. 이러한 브로드캐스팅 기능 덕분에 딥러닝을 구현시 Numpy를 적극적으로 활용할 수 있다.

2. 가중치와 편향의 의미

가중치 즉, Weight(파라미터라고도 함)와 편향(bias)의 의미를 제대로 이해해보자. 우선 가중치 의미는 모델이 학습하려는 목표라고도 할 수 있으며 대부분의 모든 사람들이 정확히 정의내릴 수 있다. 가중치는 각 입력신호가 결과에 주는 영향력을 조절하는 요소로서 가중치가 클수록 해당 입력(변수)신호가 그만큼 더 중요함을 뜻한다. 하지만 편향이 무엇인지 물어본다면 필자는 대답하기가 쉽지 않았다. 이 책을 공부하면서 그 정의를 명확히 내릴 수 있었다. 그것은 바로 "결과(출력)값을 얼마나 쉽게 활성화 시킬 것인가" 를 의미한다. 여기서 "활성화" 라는 것은 이진분류라고 가정한다면 결과값이 1로 나오는 경우를 의미한다. 예를 들어, 다음과 같이 단층 퍼셉트론에서 입력 신호가 2개($x_1, x_2$), 파라미터가 2개($w_1, w_2$)가 있다고 가정해보자. $\theta$는 결과값이 0과 1로 나눌 수 있는 특정 임곗값을 의미한다.

$$y = \begin{cases} 0\ (w_1x_1 + w_2x_2 \le \theta) \\ 1\ (w_1x_1 + w_2x_2 \ge \theta) \end{cases}$$

이 때, 파라미터인 $w_1, w_2$는 각 입력신호 $x_1, x_2$가 $y$라는 결과값에 얼마나 영향을 주는지 일종의 '중요도'를 조절하는 매개변수이다. 이 때, $\theta$는 우리가 임의로 줄 수 있는 값인데, 0과 1로 결과값을 결정하는 중요한 기준점이 된다. 즉, 만약 $\theta$값을 매우 낮게 설정한다면 대체로 $y$값이 1로 결과가 나오는 경우가 대부분일 것이다. 반대로 $\theta$를 크게하면 1로 넘어가는 진입장벽(?)이 너무 높아 대부분 0으로 결과값이 나오게 될 것이다. 결국 $\theta$값이 $b$인 편향을 의미하게 된다. 위 수식에서 이제 $\theta$를 옆으로 이항시켜보면 아래와 같이 된다.

$$y = \begin{cases} 0\ (w_1x_1 + w_2x_2 - \theta \le 0) \\ 1\ (w_1x_1 + w_2x_2 - \theta \ge 0) \end{cases}$$

따라서, $-\theta$값이 바로 $b$ 편향이 되면 수식은 아래와 같이 정의된다.

$$y = \begin{cases} 0\ (w_1x_1 + w_2x_2 + b \le 0) \\ 1\ (w_1x_1 + w_2x_2 + b \ge 0) \end{cases}$$

결국, 편향인 $b$를 어떻게 정의하느냐에 따라 결과값이 0으로 나올지, 1로 나올지를 좌우할 수 있다. 그래서 편향값을 너무 크게하거나 작게한다면 어떠한 하나의 결과로 '치우칠 수 있다'라는 의미로 '편향'이라는 의미가 붙는다. 이 때, 1로 결과값이 나오는 경우를 "뉴런이 활성화" 된다고 정의할 수 있다면, 편향인 bias는 뉴런을 얼마나 쉽게 활성화시킬지를 조절하는 변수이다. 다시 말해, 만약 bias 값이 크면 클수록 뉴런이 잘 활성화되고 작으면 작을수록 뉴런이 활성화되지 않는다는 것을 의미한다.

3. 간단한 신경망 구성하기

신경망은 단층 퍼셉트론에 활성화 함수를 추가한 것을 의미한다. 이 활성화 함수가 추가된 단층 퍼셉트론이 여러개 쌓이면 다층 퍼셉트론이 되며 우리가 흔히 알고있는 딥러닝 신경망이라고 정의할 수 있다. 간단한 신경망을 Numpy로 구현하기 이전에 2가지 알아두어야 할 개념들이 있다. 첫 번째는 활성화 함수의 종류와 두번째는 배열 형상(shape) 맞추기이다.

우선, 활성화 함수에는 여러가지가 있지만 여기서는 대표적으로 계단 함수, 시그모이드 함수, Relu 함수, Softmax 함수에 대해 살펴보자. 계단 함수는 그야말로 계단 형태로 이루어진 그래프를 의미한다.

 

import numpy as np import matplotlib.pyplot as plt 

def step_function(x): 
    y = x > 0 # x에 np.array 넣으면 Boolean 인덱싱 적용됨 
    return y.astype(int) 

x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x) 
plt.plot(x, y)
plt.ylim(-0.1, 1.1)
plt.show()

 

계단 함수


위와 같은 계단 함수는 $y$값이 0 또는 1밖에 존재하지 않는다. 즉, 연속적인 실수값들로 구성된 것이 아닌 0 아니면 1로만 구성된다. 이 함수는 미분이 불가능하기 때문에 적절하지 못한 활성함수로 취급된다. 미분이 불가능한 것이 좋지 않은 이유는 추후의 챕터인 신경망 학습 챕터에서 다루기로 한다.

다음은 시그모이드 함수이다. 지수함수를 사용하는 함수인데, 위 계단 함수랑 차이점을 살펴보자.

 

def sigmoid(x): 
    return 1 / (1 + np.exp(-x)) 
x = np.arange(-5.0, 5.0, 1.1)
y = sigmoid(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1)
plt.show()

 

시그모이드 함수


계단 함수보다 훨씬 부드러운 곡선이 되었다. 부드럽게 되었다는 것은 $y$값이 0 또는 1만 나오는 계단 함수와는 달리 연속적인 실수값들로 나오는 것을 의미한다.

다음은 최근 딥러닝 분야에서 가장 자주 사용되는 활성함수로 Relu 함수이다. Relu 함수 용어의 기원에 대해서 잠깐 이야기하자면(필자는 이런 기원을 활용하면 잘 외워지는 이상한 특성이 있다..) Relu의 'Re' 는 'Rectified(정류된)'을 의미한다. 이는 전기회로 분야의 용어로서, +/-가 반복되는 교류에서 - 흐름을 차단하는 회로를 의미한다. 아래에서 보는 것처럼 Relu 함수도 그 용어 기원을 따라 $x$값이 음수(-)일 때의 $y$값을 모두 차단해서 $y = 0$으로 만들어 버린다.

 

def relu(x): 
    return np.maximum(0, x) 
x = np.arange(-10.0, 10.0, 4) 
y = relu(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1)
plt.show()

 

Relu 함수


다음은 Softmax 함수이다. 시그모이드 함수랑 함께 주로 분류 문제에 사용되는 활성함수이다. 그 중에서도 소프트맥스는 다중 클래스 분류에 자주 사용되는 방법이다. 소프트맥스의 수식부터 살펴보자.

 

Softmax 수식


위 그림에서 사용된 '뉴런'은 '노드'와 동일한 의미이다. 그림에서도 써놓은 것처럼 소프트맥스를 활용하면 모든 입력 신호들을 고려하면서 출력층의 $k$ 번째 노드의 결과값을 적절하게 변환하는 역할을 한다는 것을 알 수 있다. 이를 Numpy 로 구현하게 되면 아래와 같다.

 

def softmax(a: np.array):
    exp_a = np.exp(a)
    exp_a_sum = np.sum(exp_a)
    y = exp_a / exp_a_sum
    return y


그러나 위 코드에서 a라는 배열의 원소값이 커지게 된다면, 예를 들어, 만약 1000만 되도 지수함수 1000값은 매우 큰 값이 되어 컴퓨터의 오버플로우 현상이 발생하게 된다. 그래서 결과값이 NaN 값이 나오게 된다. 여기서 오버플로우란, 크기가 특정 범위 내에서 처리할 수 있도록 되어 있는 컴퓨터가 범위 밖의 너무 큰 수를 입력받게 된다면 포함하지 못하여 NaN값을 출력하게 되는 현상을 의미한다.

따라서 우리는 Softmax 수식을 아래와 같이 $C$라는 값을 분자, 분모에 곱하여 일종의 수학적 트릭을 사용할 수 있다.

오버플로우를 막기 위한 Softmax 변형


위에서 추가한 $C$라는 정수를 추가하게 된다해도 지수함수를 계산할 때에 결과는 바뀌지 않게 된다. $C$에는 어떤 수를 더하거나 빼도 상관은 없지만 보통 주어진 입력 데이터 중 최대값을 $C$로 설정해 빼주는 것이 일반적이라고 한다. 따라서 변형된 소프트맥스 함수를 Numpy로 구현한 코드는 아래와 같다.

 

def softmax(a: np.array): 
    max_a = max(a) 
    exp_a = np.exp(a - max_a)
    exp_a_sum = np.sum(exp_a) 
    y = exp_a / exp_a_sum 
    return y 

a = np.array([1010, 1000, 990])
print(softmax(a))


소프트맥스의 결과값의 총 합은 항상 1이다. 그러므로 시그모이드와 마찬가지로 소프트맥스의 결과값을 일종의 확률값으로 볼 수 있다. 그렇기 때문에 특정 클래스를 최종적으로 분류하는 활성함수로 자주 사용된다.

그런데 이 책을 공부하다가 문득 생각이 들었다. 그동안 코드를 짜면서 신경망의 은닉층에는 소프트맥스를 활성함수로 사용하는 것을 잘 보지 못했다. 주로 tanh, Relu 함수를 사용하긴 했지만.. 소프트맥스를 사용하지 않는 정확한 이유를 몰랐었는데, 이번 기회에 알게 되었다.

바로 소프트맥스는 지수함수를 사용하기 때문에 단조 증가함수이기 때문이라는 것이다. 소프트맥스 함수를 적용한다고 해도 적용하기 이전의 원소 간 대소 관계는 변하지가 않는다. 다시 말해, 소프트맥스 함수를 적용해도 출력이 가장 큰 노드의 위치는 달라지지 않는다. 그렇기 때문에 깊은 신경망 모델의 은닉층들의 활성함수를 보면 소프트맥스 함수를 사용하지 않는다. 심지어 소프트맥스 함수를 불필요하게 넣게 되면 소프트맥스를 구성하는 지수함수를 계산하는 데 자원이 오히려 더 낭비가 될 수 있다. 그래서 이러한 이유로 신경망의 최종 레이어에서, 모델의 학습 시에는 소프트맥스를 활용해야 하지만(정답과 비교하기 위해) 테스트(추론) 시에는 정답과 비교하는 과정이 없으므로 굳이 계산 비용이 많이 드는 소프트맥스를 활용하지 않는 것이 일반적이다.

신경망을 구현하기 전에 알아야 하는 두 번째 개념은 다차원 배열의 형상 아다리(?)를 맞추는 것이다. 필자도 아직까지 헷갈리는 개념인데 이번 기회에 명확히 배우고 넘어가려고 한다. 이 배열의 형상을 맞춰야하는 방법을 알아야 하는 이유는 딥러닝을 프레임워크를 사용하지 않고 우리가 직접 Numpy로 구현할 수 있기 때문이다. 요즘에는 딥러닝 프레임워크가 이런 배열의 형상도 자동적으로 맞춰주기 때문에 오히려 우리가 직접 구현하려고 하니 매우 헷갈리는 것 같다. 형상을 맞춰주기 위해서 아래와 같이 총 3가지 규칙만을 기억하자.

 

행렬의 형상을 맞추자


위 그림의 빨간색깔로 표시된 부분은 다차원 행렬을 곱할 때 무조건 맞춰주어야 한다. 그리고 행렬 A, B를 곱한 C의 형상은 파란색 또는 노란색 또는 둘 다로 표시된 형태로 나오게 된다. 한번에 외우려 하지말고 계속 신경망을 구현하면서 반복 숙달해보기로 하자!

이제 그렇다면 간단한 3층 신경망을 구성해서 순전파(Forward)를 넘파이로 직접 구현해보자. 먼저 우리가 설계할 신경망 구성 그림은 아래와 같다.

 

설계할 3층 신경망

 

# 신경망 3층 Forward 구현하기 
import numpy as np 

def relu(x): 
    return np.maximum(0, x) 
    
def init_network(): 
    network = {} 
    network['W1'] = np.random.rand(2, 3)
    network['b1'] = np.ones(3,) 
    network['W2'] = np.random.rand(3, 2) 
    network['b2'] = np.ones(2,) 
    network['W3'] = np.random.rand(2, 1) 
    network['b3'] = np.ones(1,) 
    return network 
    
def forward(network, x): 
    w1, w2, w3 = network['W1'], network['W2'], network['W3'] 
    b1, b2, b3 = network['b1'], network['b2'], network['b3'] 
    # layers 
    a1 = np.matmul(x, w1) + b1 
    a2 = np.matmul(a1, w2) + b2 
    a3 = np.matmul(a2, w3) + b3 
    # activation 
    out = relu(a3) 
    return out
    
# shape 형상 과정 : (2, 2) x (2, 3) => (2, 3) x (3, 2) => (2, 2) x (2, 1) => 최종: (2, 1)
network = init_network() 
x = np.array([[1, 2], [3, 4]]) 
output = forward(network, x) 
print(output, output.shape)

4. MNIST 데이터로 신경망 모델 추론(Inference) 하기

이번에는 이미 만들어진 신경망 모델로 MNIST 데이터를 활용해 손글씨 숫자를 추론(예측)해보자. 이미 만들어진 신경망 모델의 소스코드는 위 코드처럼 구성되어 있으며 지금 상태에서는 책 교제의 Github 소스코드를 활용하자.(파일명이 sample_weight.pkl 로된 파일이다)
MNIST 데이터셋 로드하는 부분은 여기의 dataset 디렉토리의 mnist.py 파일을 임포트해서 사용하자.

우선 첫 번째는 Train 데이터 하나씩 가져오면서 즉, batch_size를 1로 해서 하나씩 예측을 해보고 정확도를 측정해보자.

 

from dataset.mnist import load_mnist 
import numpy as np 
import pickle 
path = '/Users/younghun/Desktop/gitrepo/deep-learning-from-scratch/ch03' 
def get_data(): 
    (X_train, y_train), (X_test, y_test) = load_mnist(flatten=True, normalize=True, one_hot_label=False) 
    return X_test, y_test 
def init_network(): 
    with open(os.path.join(path, 'sample_weight.pkl'), 'rb') as f: 
        network = pickle.load(f) 
    return network 

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

def softmax(x: np.array): 
    max_x = np.max(x) 
    exp_x = np.exp(x - max_x) 
    exp_x_sum = np.sum(exp_x) 
    y = exp_x / exp_x_sum 
    return y 
    
def forward(network, x): 
    w1, w2, w3 = network['W1'], network['W2'], network['W3'] 
    b1, b2, b3 = network['b1'], network['b2'], network['b3'] 
    # Layer 
    a1 = np.matmul(x, w1) + b1 
    z1 = sigmoid(a1) 
    a2 = np.matmul(z1, w2) + b2 
    z2 = sigmoid(a2) 
    a3 = np.matmul(z2, w3) + b3 
    y = softmax(a3) 
    return y 
    
X_test, y_test = get_data() 
network = init_network() 
accuracy = 0 
for i in range(len(X_test)): 
    x = X_test[i] 
    y_prob = forward(network, x) 
    y_pred = np.argmax(y_prob) 
    if y_pred == y_test[i]: 
        accuracy += 1 

print('Accuracy:', accuracy / len(X_test))


자, 이제 다음엔 배치 사이즈를 여러개로 늘려서 추론시켜보자. 하단에서는 배치 사이즈를 100으로 했을 때의 코드이다. 거의 비슷하지만 for loop 문으로 추론할 때, 배치 사이즈가 늘어났기 때문에 약간의 변형만 취해주면 된다.

 

X_test, y_test = get_data() 
network = init_network() 
batch_size = 100 
accuracy = 0 
for i in range(0, len(X_test), batch_size):
    x = X_test[i:i+batch_size] 
    y_proba = forward(network, x) 
    y_pred = np.argmax(y_proba, axis=1) 
    # Boolean 값으로 변환 -> 같으면 True, 틀리면 False 
    accuracy += np.sum(y_pred == y_test[i:i+batch_size]) 

print('Accuracy(Mini-batch):', accuracy / len(X_test))

지금까지 간단한 신경망을 넘파이로 구현하는 방법과 구현하기 위해 필요한 개념들을 다루어 보았다. 다음 챕터에서는 신경망을 학습하는 방법에 대해서 배워보고 기록해보기로 하자.

반응형