딥러닝

[Deep Learning from scratch] 3. 신경망

PKkomi 2023. 3. 5. 15:50

이번 장에서 배울 신경망은 앞장에서 배운 퍼셉트론의 단점을 보완해준다.

퍼셉트론에서는 적절한 가중치 매개변수 값을 인간이 수동으로 설정해야 했는데, 신경망은 적절한 값을 데이터로부터 자동으로 학습한다.

신경망의 개요를 살펴보고, 입력 데이터가 무엇인지 식별하는 처리 과정을 알아보자.

 

다차원 배열의 계산은 생략한다.

 

 

 

신경망은 다음의 그림과 같다.

은닉층의 뉴런은 사람의 눈에 보이지 않기 때문에 은닉층이라는 이름이 붙여졌다.

이 책에서는 입력층에서부터 출력층 방향으로 0,1,2층으로 한다. 포스트에서도 이 방식을 채택하겠다. 파이썬의 인덱스도 0부터 시작하여 코드를 구현할 때 용이하기 때문이다.

또한 자료에 따라 층 개수로 그림 3-1을 3층 신경망이라고도 하는데, 여기서는 가중치를 갖는 층의 개수를 사용하여 2층 신경망이라 하겠다. 즉, 입력층, 은닉층, 출력층의 개수 합계에서 1을 뺀 값을 기준으로 한다.

신경망의 구조는 퍼셉트론과 크게 다르지 않다

 

 

신경망의 신호 전달 방법을 살펴보기 전에 퍼셉트론을 복습해보자.

편향의 입력값은 항상 1로 고정된다.

 

이를 좀 더 간략한 수식으로 표현하기 위해 '활성화 함수'라는 개념을 도입하자.

        a = h(b + w1x1 + w2x2); a는 h(a)함수에 들어가 출력값 y로 변환됨.

        h(x)는 x<=0일 때는 0을, x>0일 때는 1을 출력한다.

h(x)처럼 입력 신호의 총합을 출력 신호를 변환하는 함수를 일반적으로 활성화 함수라 한다.

위의 처리과정을 그림으로 나타내면 다음과 같다.

총합 a가 h함수를 거쳐 출력값 y가 된다.

a라는 노드가 y라는 노드로 변하게 되는 과정을 그린 것이다.

이 책에서는 뉴런과 노드를 동일한 의미로 사용하므로, 이 포스트에서도 그렇게 하도록 하겠다.

 


이제 활성화 함수에 대해 조금 더 자세히 알아보자.

 

 

신경망에서 주로 사용하는 활성화 함수는 '시그모이드 함수'다.

       h(x) = 1 / {1 + exp(-x)}

신경망에서는 활성화 함수로 시그모이드 함수를 이용하여 신호를 변환하고, 그 변환된 신호를 다음 뉴런에 전달한다.

사실 퍼셉트론과 신경망의 주된 차이는 이것 뿐이다.

 

시그모이드 함수를 자세히 알아보기 위해 계단 함수와 비교해보자.

계단함수는 입력이 0을 넘으면 1을, 그 외에는 0을 출력하는 함수다.

numpy 배열을 입력값으로 받기 위한 코드는 다음과 같으며 그림을 그려보자.

import numpy as np
import matplotlib.pyplot as plt

def step_func(x):
    return np.array(x>0, dtype=np.int) 
    # x>0이라는 조건을 넣어 조건에 부합하면 True, 그렇지 않으면 False를 가짐. 
    # 이때 dtype을 int로 변환하여 True는 1, False는 0을 가지는 array를 반환하는 함수.
    
x = np.arange(-5.0, 5.0, 0.1)
y = step_func(x)

plt.plot(x, y, c='black')
plt.ylim(-0.1, 1.1)
plt.show()

계단 함수의 그래프

 

이제 시그모이드 함수도 그려보자.

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

x = np.arange(-5.0, 5.0, 0.1)
y = sigmoid(X)
plt.plot(x, y, c='b')
plt.ylim(-0.1, 1.1)
plt.show()

시그모이드 함수의 그래프

 

계단함수와 시그모이드 함수의 그래프에서 보이는 가장 큰 차이점은 연속성이다. 시그모이드 함수는 연속적이지만 계단함수는 이산적이다. 하지만 둘 모두 입력이 작으면 출력이 0에 가깝고, 입력이 크면 1에 가깝다는 공통점을 가진다. 출력이 0과 1 사이라는 점도 공통점이다.

 

 

두 함수는 이외에도 비선형 함수라는 공통점을 가진다.

'비선형 함수'란 문자 그대로 선형이 아닌 함수로, 직선 한 개로는 그릴 수 없는 함수를 말한다.

 

신경망에서는 활성화 함수로 오직 비선형 함수만 사용해야 한다.

선형 함수를 사용한다면 층을 쌓는 것이 의미가 없어지기 때문이다.

달리 말하자면, 선형 함수를 활성화 함수로 사용하는 것을 은닉층이 없는(층이 없는) 퍼셉트론으로 표현이 가능하기 때문이다.

간단한 예로 h(x) = c*x라 하자.

y = h(h(h(x)))라 하면, 이는 y = c*c*c*x이고 이는 a = (c^3) * x인 y(x) = a*x와 같다. 

퍼셉트론으로 층을 쌓는 것은 층마다 다양한 연산을 할 수 있다는 이점이 있는데, 활성화 함수를 선형 함수로 지정하면 이것이 상쇄된다.

 

 

시그모이드 함수는 오랫동안 신경망 분야에서 이용됐지만, 최근에는 ReLU 함수를 주로 사용하므로 알아두면 좋다.

ReLU는 입력이 0을 넘으면 그 입력을 그대로 출력하고, 0 이하면 0을 출력하는 함수다.

다음과 같은 그림을 쉽게 상상할 수 있다.

ReLU 함수의 그래프

 


이제 행렬 계산을 이용하여 3층 신경망을 구현해보자.

3층 신경망은 입력층(2개), 은닉층1(3개), 은닉층2(2개), 출력층(2개)로 이루어진다.(괄호 속 숫자는 뉴런의 개수)

 

 

각 층의 신호 전달을 코드로 구현하는 것은 복잡해보이지만 numpy의 행렬곱을 이용한다면 의외로 쉽게 가능하다.

먼저 입력층(0층)에서 1층으로의 신호 전달을 살펴보자.

기호의 표기는 크게 중요하지 않으므로 연산을 명확하게 이해하자.

 

다음과 같이 행렬을 구성한다고 하자.

0층에서 1층으로 신호를 전달할 때 필요한 행렬들

 

행렬의 곱을 이용하여 a1_(1)을 다음과 같이 간단하게 나타낼 수 있다.

행렬 곱으로 간소화한 식

 

넘파이를 이용하여 위의 식을 구현할 수 있다. 값들은 적당하게 지정하였다.

X = np.array([1.0, 0.5]) # 입력값
W1 = np.array([0.1, 0.3, 0.5, 0.2, 0.4, 0.6]).reshape((2,3)) # 가중치
B1 = np.array([0.1, 0.2, 0.3]) # 편향값

A1 = np.dot(X, W1) + B1 
Z1 = sigmoid(A1) # 앞에서 구현한 sigmoid함수 사용

print(A1)
print(Z1)
[0.3 0.7 1.1]
[0.57444252 0.66818777 0.75026011]

 

마찬가지로 1층에서 2층, 2층에서 출력층으로의 신호 전달도 구현이 가능하다.

W2 = np.array([0.1, 0.4, 0.2, 0.5, 0.3, 0.6]).reshape((3,2)) # 가중치
B2 = np.array([0.1, 0.2]) # 편향값

A2 = np.dot(Z1, W2) + B2 
Z2 = sigmoid(A2) # 앞에서 구현한 sigmoid함수 사용

 

은닉층에서와 달리 활성화함수가 항등 함수라는 것을 제외하고는 앞과 같은 방식으로 출력값을 구한다.

def identity_function(x):
    return x

W3 = np.array([0.1, 0.3, 0.2, 0.4]).reshape((2, 2)) # 가중치
B3 = np.array([0.1, 0.2]) # 편향값

A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3) # Y = A3도 가능하다!

 

 

이제 지금까지 구현한 코드를 정리해보자.

보편적으로 가중치만 대문자로 나타내고, 나머지는 모두 소문자로 쓰므로 여기서도 그렇게 하겠다.

def init_network():
    network = {}
    network['W1'] = np.array([0.1, 0.3, 0.5, 0.2, 0.4, 0.6]).reshape((2, 3))
    network['b1'] = np.array([0.1, 0.2, 0.3])
    network['W2'] = np.array([0.1, 0.4, 0.2, 0.5, 0.3, 0.6]).reshape((3,2))
    network['b2'] = np.array([0.1, 0.2])
    network['W3'] = np.array([0.1, 0.3, 0.2, 0.4]).reshape((2, 2))
    network['b3'] = np.array([0.1, 0.2])
    
    return network

def identity_function(x):
    return x

def forward(network, x):
    W1, W2, W3 = network['W1'], network['W2'], network['W3']
    b1, b2, b3 = network['b1'], network['b2'], network['b3']
    
    a1 = np.dot(x, W1) + b1
    z1 = sigmoid(a1)
    a2 = np.dot(z1, W2) + b2
    z2 = sigmoid(a2)
    a3 = np.dot(z2, W3) + b3
    y = identity_function(a3)
    
    return y

network = init_network()
x = np.array([1.0, 0.5])
y = forward(network, x)
print(y)
[0.31682708 0.69627909]

위 코드는 init_network 함수에 다음 층의 뉴런으로 가는 가중치와 편향을 초기화하고 forward 함수를 이용하여 입력 신호를 출력 신호로 전달하는 과정을 구현하였다. 

numpy의 다차원 배열을 이용하면 위처럼 효과적으로 신경망을 구현하는 것이 가능하다.

 


신경망은 분류와 회귀라는 두 가지 문제 모두에 이용할 수 있다.

어떤 문제냐에 따라 출력층에서 사용되는 활성화 함수가 달라진다.

일반적으로 회귀에는 항등 함수를, 분류에는 소프트맥스 함수를 사용한다.

 

기계학습 문제는 분류회귀로 나뉘는데, 분류는 데이터가 어느 클래스에 속하는지를 판별하는 문제다. 가령 사진 속 동물의 종을 맞추는 것이 분류다. 회귀는 입력 데이터에서 (연속적인) 수치를 예측하는 문제다. 사진 속 인물의 몸무게를 예측하는 문제가 예이다.

분류에서 사용하는 소프트맥스 함수는 다음과 같다.

소프트맥스 함수의 출력은 모든 입력으로부터 화살표를 받는다.

이제 소프트맥스 함수를 파이썬으로 구현해보자.

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

 

이때, 컴퓨터는 수를 4바이트나 8바이트의 크기가 유한한 데이터로 다룬다. 하지만 위 식은 지수 함수를 이용하므로 a에 1000이라는 값이 들어가면 inf를 반환한다. 이렇게 큰 값끼리 연산을 하게 되면 매우 불안정해져 표현할 수 없게 된다. 이를 오버플로(overflow)라 하며 이를 개선하기 위해 다음과 같이 개선된 수식을 사용할 수 있다.

exp 함수 내에 어떤 정수를 더하거나 빼도 결과값은 동일하다.

 

위의 특징을 이용하여 a의 입력값 중 가장 큰 값을 exp 연산 전에 빼준다면, overflow를 방지할 수 있다.

def softmax(a):
    max_a = np.max(a)
    exp_a = np.exp(a - max_a) # overflow 방지
    exp_sum_a = np.sum(exp_a)
    y = exp_a / exp_sum_a
    return y

 

이제 개선된 softmax 함수를 이용하여 값들을 구할 수 있다.

a = np.array([0.3, 2.9, 4.0])
y = softmax(a)
print(y)
print(sum(y))
[0.01821127 0.24519181 0.73659691]
1.0

 

여기서 softmax함수의 특징을 알 수 있는데, 출력값은 모두 0과 1 사이의 실수이며 출력값의 합이 1이라는 것이다.

이를 통해 softmax함수의 출력을 '확률'로 해석할 수 있다.

여기서 주의할 점은, 소프트맥스 함수는 지수함수를 사용하며 이것이 단조 증가 함수이므로 함수를 적용해도 각 원소의 대소 관계나 원소의 위치가 변하지는 않는다.

이런 이유로 신경망을 이용한 분류에서는 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식한다.

결과적으로 현업에서도 신경망으로 분류할 때는 지수 함수 계산에 드는 자원 낭비를 줄이고자 소프트맥스 함수를 생략하는 것이 일반적이다.

 


출력층의 뉴런 수를 정하는 것은 문제 상황에 맞게 설정해야 한다.

분류에서는 분류하고 싶은 클래스 수로 설정하는 것이 일반적이다.

예를 들어 강아지, 고양이, 햄스터, 사자, 호랑이의 5종의 동물로 분류하는 문제라면 출력층의 뉴런을 5개로 설정한다.

 

 

이제 손글씨 숫자를 분류하는 것을 직접 해보자. 아직 학습하는 법은 배우지 않았으므로 학습된 매개변수를 사용하여 추론 과정만 구현한다.

이 추론 과정을 신경망의 순전파(forward propagation)라고도 한다.

 

이번 예에서는 MNIST라는 손글씨 숫자 이미지 집합을 데이터셋으로 사용한다. 이는 기계학습 분야에서 매우 유명한 데이터셋이다. MNIST 데이터셋은 0~9의 숫자 이미지로 구성된다. 훈련 이미지가 60000장, 시험 이미지가 10000장이 준비되어 있다.

책에서 MNIST데이터셋을 내려받아 이미지를 numpy배열로 변환해주는 파이썬 스크립트를 제공한다. 이를 이용하면 된다.

 

 

load_mnist 함수를 이용하면 MNIST 데이터를 다음과 같이 쉽게 가져올 수 있다.

import sys, os
sys.path.append(".../ML_scratch/deep-learning-from-scratch-master/deep-learning-from-scratch-master/ch03") 
# 부모 디렉터리의 파일을 가져올 수 있도록 설정
from dataset.mnist import load_mnist

# 처음엔 몇 분 정도 소요
(x_train, t_train), (x_test, t_test) = \
    load_mnist(flatten=True, normalize=False)

# 각 데이터의 shape
print(x_train.shape)
print(t_train.shape)
print(x_test.shape)
print(t_test.shape)
(60000, 784)
(60000,)
(10000, 784)
(10000,)

load_mnist 함수는 다음 3개의 인수를 가진다. 

# normalize : 입력 이미지의 픽셀값을 0.0 ~ 1.0 사이의 값으로 정규화할지 정하는 인수; False로 하면 기존의 0 ~ 255 사이의 픽셀값을 가진다.
# flatten : 입력 이미지를 평탄하게, 즉 1차원 배열로 만들지를 정하는 인수; False로 하면 1 X 28 X 28의 3차원 배열로 저장한다.
# one_hot_label : 레이블을 원-핫 인코딩 형태로 저장할 지를 정하는 인수; 정답을 뜻하는 원소만 1(hot)이고 나머지는 모두 0인 배열을 만든다.

# normalize와 flatten은 True가 기본값이고 one_hot_label은 False이다.

 

 

가져온 데이터를 확인해보기 위해 불러온 데이터(x_train)의 첫번째 숫자 이미지를 확인해보자.

import sys, os
sys.path.append(".../ML_scratch/deep-learning-from-scratch-master/deep-learning-from-scratch-master/ch03") 
from dataset.mnist import load_mnist
import numpy as np
from PIL import Image

def img_show(img):
    pil_img = Image.fromarray(np.uint8(img)) # numpy로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환해야 하므로 이 과정을 수행한다.
    pil_img.show()
    
(x_train, t_train), (x_test, t_test) = \
load_mnist(flatten=True, normalize=False)

img = x_train[0]
label = t_train[0]
print(label)

print(img.shape)
img = img.reshape(28, 28) # 이미지가 1차원 배열이므로 원래 이미지의 모양인 28 X 28로 변형해준 것이다.
print(img.shape)

img_show(img) # MNIST 파일에서 가져온 이미지 중 하나를 보여준다.
5
(784,)
(28, 28)

 

불러온 손글씨 숫자 이미지

가져온 숫자 데이터가 label=5이므로 5의 이미지를 보여주는 것을 알 수 있다.

 

 

드디어 MNIST 데이터셋으로 추론을 수행하는 신경망을 구현할 수 있다.

이미지의 크기가 28 X 28 = 784 이고 flatten입력층 뉴런이 784개로 구성할 것이다. 또한 문제 상황이 0~9의 숫자를 구분하는 문제이므로 출력층 뉴런을 10개로 구성할 것이다. 또한, 은닉층은 총 2개로 각각 50개와 100개의 뉴런을 배치할 것이다. 이는 책에서 임의로 정한 값으로 그대로 사용해보자.

이제 이를 코드로 구현하면 다음과 같다.

# 신경망 구현
import pickle

def get_data():
    (x_train, t_train), (x_test, t_test) = \
        load_mnist(normalize=True, one_hot_label=False)
    return x_test, t_test


sys.path.append(".../ML_scratch/deep-learning-from-scratch-master/deep-learning-from-scratch-master/ch03") 

def init_network():
    with open("sample_weight.pkl", 'rb') as f:
        network = pickle.load(f)
    
    return network

def predict(network, x):
    W1, W2, W3 = network['W1'], network['W2'], network['W3']
    b1, b2, b3 = network['b1'], network['b2'], network['b3']
    
    a1 = np.dot(x, W1) + b1
    z1 = sigmoid(a1)
    a2 = np.dot(z1, W2) + b2
    z2 = sigmoid(a2)
    a3 = np.dot(z2, W3) + b3
    y = softmax(a3)
    
    return y

pickle 파일인 sample_weight.pkl에는 가중치와 편향 매개변수가 딕셔너리 변수로 저장되어 있다. 여기서 학습된 가중치 매겨변수를 읽는다.

load_mnist 함수에서 파일을 가져올 때 normalize=True로 해주었는데, 이는 가져온 데이터를 정규화해주겠다는 의미이다. 이는 전처리 작업의 일종으로 여기서는 간단하게 하였지만 실무에서는 데이터에 맞는 다양한 방식으로 전처리를 수행해준다.

get_data와 predict함수는 앞서 했던 과정과 구현과정이 거의 동일하므로 설명은 생략하겠다. 코드를 찬찬히 읽어보면 충분히 이해할 수 있다.

 

 

만든 신경망의 성능을 검증할 차례다.

코드는 다음과 같다.

x, t = get_data()
network = init_network() # MNIST 데이터셋을 얻고 네트워크 생성

accuracy_cnt = 0
for i in range(len(x)): # x에 저장된 이미지 데이터를 하나씩 predict()함수로 분류
    y = predict(network, x[i])
    p = np.argmax(y) # y배열에서 가장 큰 값을 가지는 원소의 인덱스를 구함
    if p == t[i]:
        accuracy_cnt += 1 # 신경망이 예측한 답변 중 정답을 맞힌 이미지의 숫자
        
print("Accuracy:" + str(float(accuracy_cnt) / len(x))) # 전체 이미지 숫자로 나눠 정확도 계산
Accuracy:0.9352

 predict() 함수는 각 레이블의 확률을 넘파이 배열로 반환한다. 이를테면 [0.1, 0.37, 0.8, 0.19, ..., 0.03] 같은 배열이 반환되며, 각 숫자는 이미지가 특정 숫자일 확률로 해석된다.

 

 

앞서 구현한 신경망은 이미지를 한 장씩 받아서 검증하는 형태이다. 

다음을 살펴보자.

x, _ = get_data()
network = init_network()
W1, W2, W3 = network['W1'], network['W2'], network['W3']
print(x.shape)
print(x[0].shape)
print(W1.shape)
print(W2.shape)
print(W3.shape)
(10000, 784)
(784,)
(784, 50)
(50, 100)
(100, 10)

각 배열의 형태를 살펴보면 다차원 배열의 대응하는 차원의 원소 수가 일치함을 확인할 수 있다. (이때 편향은 생략했다.)

이를 그림으로 보면 이해하기 쉽다.

확인한 배열의 형태를 나타낸 그림

 

위 그림은 이미지 데이터를 1장만 입력했을 때의 처리 흐름이다.

그렇다면 이미지 100개를 묶어서 predict()함수에 한 번에 넘기는 것을 생각해보자.

x의 형상을 100 X 784로 바꿔서 표현하면 된다.

배치처리를 적용한 형태를 나타낸 그림

 

출력데이터의 형상이 100 X 100이 되고 이는 입력 데이터 100장에 따라 한 번에 100장이 출력됨을 나타낸다.

이와 같이 하나로 묶은 입력 데이터를 배치(batch)라고 한다.

이제 배치처리를 앞의 코드에 적용하여 보자. 

# 배치처리 적용한 코드

x, t = get_data()
network = init_network() # MNIST 데이터셋을 얻고 네트워크 생성

batch_size = 100 # 배치 크기
accuracy_cnt = 0

for i in range(0, len(x), batch_size): # 0부터 (x의 크기-1)까지 배치크기인 100을 간격으로 반복
    x_batch = x[i:i+batch_size]
    y_batch = predict(network, x_batch)
    p = np.argmax(y_batch, axis=1) 
    accuracy_cnt += np.sum(p==t[i:i+batch_size]) 
        
print("Accuracy:" + str(float(accuracy_cnt) / len(x)))
Accuracy:0.9352

앞선 코드와 동일한 결과를 얻는 것을 보아 배치처리가 잘 적용된 것을 알 수 있다.

배치 처리는 이미지 1장당 처리 시간을 대폭 줄여준다.

이유1) 수치 계산 라이브러리(대표적으로 numpy) 대부분이 큰 배열을 효율적으로 처리할 수 있도록 고도로 최적화되어 있다.

이유2) 커다란 신경망에서는 데이터 전송이 병목으로 작용하는 경우가 자주 있는데, 이때의 부하를 줄여줄 수 있다.

이런 이유로 컴퓨터에서는 큰 배열을 한꺼번에 계산하는 것이 분할된 작은 배열을 여러 번 계산하는 것보다 빠르다.

 

 

 


이것으로 이번 장의 내용은 끝이 났다.

신경망의 순전파를 다뤄봤는데, 앞 장의 퍼셉트론과 비슷하지만 다음 뉴런으로 넘어갈 때 신호를 변화시키는 활성화 함수에서 큰 차이가 있었다. 신경망은 매끄러운 시그모이드 함수를, 퍼셉트론은 그렇지 않은 계단 함수를 활성화 함수로 사용했다.

 

출처 : Deep Learning from scratch(밑바닥부터 시작하는 딥러닝)