KoreanFoodie's Study

딥러닝 튜토리얼 3강 2부, 신경망 설계, 소프트 맥스 - 밑바닥부터 시작하는 딥러닝 본문

Deep Learning/밑바닥부터 시작하는 딥러닝 1

딥러닝 튜토리얼 3강 2부, 신경망 설계, 소프트 맥스 - 밑바닥부터 시작하는 딥러닝

GoldGiver 2019. 11. 5. 15:21

n

해당 포스팅은 한빛 미디어에서 출판한 '밑바닥부터 시작하는 딥러닝'이라는 교재의 내용을 따라가며 딥러닝 튜토리얼을 진행하고 있습니다. 관련 자료는 여기에서 찾거나 다운로드 받으실 수 있습니다.


3층 신경망 구현하기

이번에는 3층 신경망에서 수행되는, 입력부터 출력까지의 처리(순방향 처리)를 구현해 보자. 이를 위해 전에 설명한 넘파이의 다차원 배열을 사용한다.

위 그림은 3층 신경망으로, 입력층(0층)은 2개, 첫 번째 은닉층(1층)은 3개, 두 번째 은닉층(2층)은 2개, 출력층(3층)은 2개의 뉴런으로 구성된다.

예시를 통해 이 과정을 더 자세히 살펴보자.

import numpy as np

X = np.array([1.0, 0.5])
W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])

A1 = np.dot(X, W1) + B1

W1은 2 X 3 행렬, X는 원소가 2개인 1차원 배열이다.

이어서 1층의 활성화 함수에서의 처리를 보자. 활성화 함수는 h()로 표현되는 부분으로, 여기에서는 sigmoid함수를 이용하겠다.

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

이어서 1층에서 2층으로 가는 과정과 그 구현을 보자.

아래 코드를 추가시켜 주면 된다(2층에서의 weight값, 편향값)

W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
B2 = np.array([0.1, 0.2])

A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid(A2)

마지막으로, 2층에서 출력층으로 신호를 전달해 보자.

구현은 다음과 같다.

def identity_function(x):
    return x

W3 = np.array([[0.1, 0.3], [0.2, 0.4])
B3 = np.array([0.1, 0.2])

A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3)

여기에서는 항등함수인 identity_function()을 정의하고, 이를 출력층의 활성화 함수로 이용했다. 이 함수는 말 그대로 자기 자신을 출력한다!

  • 구현 정리

이것으로 3층 신경망에 대한 설명은 끝이다. 이제 지금까지의 구현을 정리해 보자!

def init_network():
    network = {}
    network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
    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]])
    network['B2'] = np.array([0.1, 0.2])
    network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
    network['B3'] = np.array([0.1, 0.2])

    return network

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]

출력층 설계하기

신경망은 분류와 회귀 모두에 이용할 수 있다. 다만 둘 중 어떤 문제냐에 따라 출력층에서 사용하는 활성화 함수가 달라진다. 일반적으로 회귀에는 항등 함수를, 분류에는 소프트맥스 함수를 사용한다.

기계학습 문제는 분류(classification)와 회귀(regression)으로 나뉜다. 분류는 데이터가 어느 클래스(class)에 속하느냐는 문제이다. 사진 속 인물의 성별을 분류하는 문제가 여기에 속한다. 한편, 회귀는 입력 데이터에서 (연속적인) 수치를 예측하는 문제이다. 사진 속 인물의 몸무게(57.4kg?)를 예측하는 문제가 회귀이다.

  • 항등 함수와 소프트맥스 함수 구현하기

항등 함수(identity function)는 입력을 그대로 출력한다. 입력과 출력이 항상 같다는 뜻의 항등이다.

한편, 분류에서 사용하는 소프트맥스 함수(softmax function)의 식은 다음과 같다.

exp(x)는 자연상수 ex제곱을 한 값이다. n은 출력층의 뉴런 수, yk는 그중 k번째 출력임을 뜻한다. 위와 같이 소프트맥스 함수의 분자는 입력신호 ak의 지수 함수, 분모는 모든 입력 신호의 지수 함수의 합으로 구성된다.

그럼, 앞서 언급한 소프트맥스 함수를 구현해 보자.

def softmax(a):
    exp_a = np.exp(a)
    sum_exp_a = np.sum(exp_a)
    return exp_a / sum_exp_a
  • 소프트맥스 함수 구현 시 주의점

앞서 구현한 softmax() 함수의 코드는 컴퓨터로 계산할 때 결함이 있다. 바로 오버플로 문제이다. 소프트맥스 함수는 지수 함수를 사용하는데, e^1000 같은 인풋이 들어가게 되면 오버플로가 발생해 우리가 원하는 결과값을 얻을 수 없다.

이 문제를 해결하도록 다음과 같이 소프트맥스 구현을 수정할 수 있다.

첫 번째 변형에서는 C라는 임의의 정수를 분자와 분모 양쪽에 곱한다. 그 다음으로 C를 지수 함수 exp() 안으로 옮겨 logC로 만든다. 그 후, logC를 C'로 바꾼다.

여기서 C'에 어떤 값을 대입해도 결과값은 바뀌지 않지만, 오버플로를 막을 목적으로 입력 신호 중 최댓값의 보수(-1 을 곱한 값)를 이용하는 것이 일반적이다. 예시를 하나 살펴보자.

a = np.array([1010, 1000, 990])
np.exp(a) / np.sum(np.exp(a))    # 소프트맥스 함수의 계산, 결과값이 array([nan, nan, nan])

c = np.max(a)    # c = 1010 (최댓값)
np.exp(a-c) / np.sum(np.exp(a-c)
# [9.99954600e-01 4.53978686e-05 2.06106005e-09]

위의 예시처럼, 아무런 조치 없이 계산하면 nan(not a number)가 출력되지만, 입력 신호 중 최댓값(여기서는 c)을 빼주면 올바르게 계산할 수 있다.

이를 바탕으로 소프트맥스 함수를 다시 구현해 보자.

def softmax(a):
    c = np.max(a)
    exp_a = np.exp(a-c) # 오버플로 해결
    exp_sum_a = np.sum(exp_a)
    return exp_a / exp_sum_a
  • 소프트맥스 함수의 특징

softmax() 함수를 사용하면 신경망의 출력은 다음과 같이 계산할 수 있다.

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

보는 바와 같이, 소프트맥스 함수의 출력은 0에서 1.0 사이의 실수이다. 또, 소프트맥스 함수 출력의 총합은 1이다. 출력 총합이 1이 된다는 점은 소프트맥스 함수의 중요한 성질이다. 이 성질 덕분에, 소프트맥스 함수의 출력을 '확률'로 해석할 수 있기 때문이다!

가령, 앞의 예시에서 y[0]일 확률은 0.018(1.8%), y[1]일 확률은 0.245(24.5%), y[2]의 확률은 0.737(73.7%)로 해석할 수 있다. 따라서, 이 결과로부터 "2번째 원소의 확률이 가장 높으니, 답은 2번째 클래스다"라고 할 수 있다. 혹은 "74%의 확률로 2번째 클래스, 25%의 확률로 1번째 클래스, 1%의 확률로 0번쨰 클래스다"와 같이 확률적인 결론도 낼 수 있다.

즉, 소프트맥스 함수를 이용함으로써 문제를 확률적(통계적)으로 대응할 수 있게 되는 것이다.

주의점 : 소프트맥스 함수를 적용해도 각 원소의 대소 관계는 변하지 않는다. 예를 들어, a에서 가장 큰 원소는 2번째 원소이고, y에서 가장 큰 원소도 2번째 원소이다. 신경망을 이용한 분류에서는 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식한다. 따라서, 현업에서는 자원 낭비를 줄이고자 출력층의 소프트맥스 함수는 생략하는 것이 일반적이다.

  • 출력층의 뉴런 수 정하기

출력층의 뉴런 수는 풀려는 문제에 맞게 적절히 정해야 한다. 분류에서는 분류하고 싶은 클래스 수로 설정하는 것이 일반적이다.

예를 들어, 숫자를 0부터 9까지 분류하는 경우, 출력층의 뉴런 수는 10개이다.


손글씨 숫자 인식

이미 학습된 매개변수를 사용하여 학습 과정은 생략하고, 추론 과정만 구현해 보자. 이 추론 과정을 신경망의 순전파(forward propagation)이라고 한다.

기계학습과 마찬가지로 신경망도 두 단계를 거쳐 문제를 해결한다. 먼저 훈련데이터(학습 데이터)를 사용해 가중치 매개변수를 학습하고, 추론 단계에서는 앞서 학습한 매개변수를 사용하여 입력 데이터를 분류한다.

  • MNIST 데이터셋
    이번 예에서 사용하는 데이터셋은 MNIST라는 손글씨 숫자 이미지 집합이다. 이 데이터셋은 0부터 9까지의 숫자 이미지로 구성되며, 훈련 이미지 60,000장, 테스트 이미지 10,000이미지를 이용해, 모델 학습 및 분류의 정확성 평가를 진행해 보자.

MNIST 이미지 데이터는 28X28 크기의 회색조 이미지(1 채널)이며, 각 픽셀은 0에서 255까지의 값을 취한다. 각 이미지에는 '1', '2' ... 와 같이 그 이미지가 실제 의미하는 숫자가 레이블로 붙어 있다.

이번 예시에서는 MNIST 데이터셋을 내려받아 이미지를 넘파이 배열로 변환해주는 파이썬 스크립트를 사용해 보자. (깃헙 저장소의 dataset/mnist.py 파일. 파일은 이 링크에서 다운 받을 수 있다.)

mnist.py를 임포트해 사용하려면 작업 디렉터리를 ch01, ch02, ch02, ... ch08 중 하나로 옮겨주자. mnist.py 파일에 정의된 load_mnist() 함수를 이용하면 MNIST 데이터를 다음과 같이 쉽게 가져올 수 있다.

작업 디렉터리 위치를 잘 체크하자.

import sys, os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
from dataset.mnist import load_mnist

# 처음에는 몇 분 걸림
(x_train, t_train), (x_test, t_test) = \
    load_mnist(flatten=True, normalize=False)

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

코드를 보면 먼저 부모 디렉터리의 파일을 가져올 수 있도록 설정하고, dataset/mnist.pyload_mnist 함수를 임포트한다. 이때, 각 예제에서 mnist.py파일을 찾으려면 부모 디렉터리로부터 시작해야 해서 sys.path.append(os.pardir)문장을 추가한 것이다!

load_mnist 함수는 MNIST 데이터를 "(훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블)" 형식으로 반환한다.

인수로는 normalize, flatten, one_hot_label 세 가지를 설정할 수 있다. 세 인수 모두 bool 값이다. 각각은 다음과 같은 의미를 가진다.

  1. normalize : 입력 이미지의 픽셀 값을 0.0 ~ 1.0사이의 값으로 정규화할지를 정한다. False로 설정하면 입력 이미지의 픽셀은 원래 값 그대로 0 ~ 255 사이의 값을 유지한다.

  2. flatten : 입력 이미지를 1차원 배열로 만들지를 정한다. False로 설정하면 입력 이미지를 1X28X28의 3차원 배열로, True로 설정하면 748개의 원소로 이뤄진 1차원 배열로 저장한다.

  3. one_hot_label : 레이블을 원-핫 인코딩형태로 저장할지를 정한다. 원-핫 인코딩이란, 에를 들어 [0,0,1,0,0,0,0,1,0,0.0]처럼 정답을 뜻하는 원소만 1이고, 나머지는 모두 0인 배열이다. False일 경우, '7'이나 '2'와 같이 숫자 형태의 레이블을 저장한다.

파이썬에는 pickle(피클)이라는 편리한 기능이 있다. 이는 프로그램 실행 중에 특정 객체를 파일로 저장하는 기능이다. 저장해둔 pickle 파일을 로드하면 실행 당시의 객체를 즉시 복원할 수 있다. MNIST 데이터셋을 읽는 load_mnist() 함수에서도 (2번째 이후의 읽기 시) pickle을 이용한다. pickle 덕분에 MNIST 데이터를 순식간에 준비할 수 있다.

MNIST 이미지를 화면으로 불러 데이터를 확인해 보자.

데이터를 확인하는 것에는 PIL(Python Image Library)모듈을 사용한다. 다음 코드(ch03/mnist_show.py 라는 파일임)을 실행하면 첫 번째 훈련 이미지가 모니터 화면에 표시된다.

flatten=True로 설정해 읽어 들인 이미지는 1차원 넘파이 배열로 자장되어 있다. 그래서 이미지를 표시할 때는 reshape() 메서드를 이용하여 원래 형상인 28X28 크기로 다시 변형해야 한다.또한, 넘파이로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환해야 하며, 이 변환은 Image.fromarray()가 수행한다.

  • 신경망의 추론 처리

이제 이 MNIST 데이터셋을 가지고 추론을 수행하는 신경망을 구현해 보자. 이 신경망은 입력층 뉴런이 784개(28x28)이고, 출력층 뉴런이 10개(0~9)이다. 은닉층은 총 두 개로, 첫 번째 은닉층에는 50개의 뉴런을, 두 번째 은닉층에는 100개의 뉴런을 배치할 것이다. 여기서 50과 100은 임의로 정한 값이다.

이제 get_data(),init_network(), predict() 함수를 정의해 보자.

# coding: utf-8
import sys, os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
import numpy as np
import pickle
from dataset.mnist import load_mnist
from common.functions import sigmoid, softmax


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


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

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

그럼 이 세 함수를 사용해 신경망에 의한 추론을 수행해보고, 정확도(분류의 정확성)를 평가해 보자.

x, t = get_data()
network = init_network()
accuracy_cnt = 0
for i in range(len(x)):
    y = predict(network, x[i])
    p= np.argmax(y) # 확률이 가장 높은 원소의 인덱스를 얻는다.
    if p == t[i]:
        accuracy_cnt += 1

print("Accuracy:" + str(float(accuracy_cnt) / len(x)))

가장 먼저 MNIST 데이터셋을 얻고 네트워크를 생성한다.

그 후, 테스트 데이터셋 x에 있는 원소를 하나씩 꺼내 predict()함수를 적용시켜 테스트를 진행한다.

이때, 가장 높은 값을 가진 출력층의 노드가 테스트 결과로 추론한 '가장 확률이 높은'레이블이 되므로, 이를 실제 레이블 값t[i]와 비교하여 맞으면 accuracy_cnt를 1 증가시킨다. 마지막으로, 정확도는 이 accuracy_cnt값을 전체 데이터셋 개수 len(x)로 나누면 된다. 이 코드를 실행시키면 93.52%의 정확도가 나온다!

위의 load_mnist()에서는 0

255 범우인 각 픽셀의 값을 0.0

1.0 범위로 변환했다. 이처럼, 데이터를 특정 범위로 변환하는 처리를 정규화라고 하고, 신경망의 입력 데이터에 특정 변환을 가하는 것을 전처리라고 한다.

데이터 전처리는 광범위하게 이용된다. 표준편차를 이용하는 것 외에도, 전체 데이터를 균일하게 분포시키는 데이터 백색화(whitening)같은 방식도 있다.

  • 배치 처리

아까 우리가 구현한 코드에서, 신경망 각 층의 배열 형상의 추이를 살펴보자.

위 그림을 보면, 형상이 원소 784개로 구성된 1차원 배열이 입력되어 원소가 10개인 1차원 배열이 출력되는 흐름인 것을 알 수 있다. 이는 이미지 데이터를 1장만 입력했을 때의 처리 흐름이다.

그렇다면 이미지 여러 장을 한꺼번에 입력하는 경우는 어떨까? 아래 예시를 보자.

위 예시는 100X784 사이즈를 입력 데이터의 형상으로, 출력 데이터의 형상을 100X10으로 만든다. 이는 100장 분량 입력 데이터의 결과가 한 번에 출력됨을 나타낸다.

이처럼, 하나로 묶은 입력 데이터를 배치(batch)라고 한다. 이미지가 지폐처럼 다발로 묶여 있는 것과 같다.

배치 처리는 1장당 처리 시간을 대폭 줄여 준다. 왜냐하면 수치 계산 라이브러리 대부분이 큰 배열을 효율적으로 처리할 수 있도록 최적화 되어 있으며, 커다란 신경망에서는 데이터 전송이 병목으로 작용하는 경우가 있는데, 배치 처리를 함으로써 버스에 주는 부하를 줄일 수 있기 때문이다(I/O에 걸리는 시간을 줄여 CPU/GPU 계산 비율이 높아짐).

이제 배치 처리를 구현해 보자.

x, t = get_data()
network = init_network()

batch_size = 100 # 배치 크기
accuracy_cnt = 0

for i in range(0, len(x), batch_size):
    batch_in = x[i:i+batch_size]
    y = predict(network, batch_in)
    p = np.argmax(y, axis=1)
    accuracy_cnt += np.sum(p==t[i:i+batch_size])

print("Accuracy:" + str(float(accuracy_cnt) / len(x)))

위의 range()함수를 보면, i가 0부터 len(x)까지 batch_size의 간격으로 증가하는 것을 알 수 있다.

batch_in의 경우, 이전의 x[i]대신, i인덱스부터 i+batch_size만큼을 잘라서 저장하는 것을 알 수 있다. predict()함수를 돌리게 되면, y결과값은 100X1짜리 행렬이 되는데, p = np.argmax(y, axis=1)은 100X10 배열 중 1번째 차원을 구성하는 각 원소에서 (1번째 차원을 축으로) 최댓값의 인덱스를 찾도록 한 것이다(인덱스가 0부터 시작하니 0번째 차원이 가장 처음 차원이다). 즉, 이 경우에는 각 행이 10개씩의 원소를 갖고 있으므로, 각 10개 짜리 리스트에서 최댓값을 가지는 인덱스를 리턴하게 되는 것이다. 예시를 보면 이해가 쉬울 것이다.

x = np.array([[0.1, 0.8, 0.1], [0.3, 0.1, 0.6], [0.2, 0.5, 0.3], [0.8, 0.1, 0.1]])
y = np.argmax(x, axis=1)
print(y)    # Result : [1, 2, 1, 0]

마지막으로, 배치 단위로 분류한 결과를 실제 답(이것도 배열의 형태임)과 비교한다. 배열끼리 ==를 이용해 비교를 하면 [True, False, True, False]처럼 bool 타입 어레이가 나오는데, 이를 np.sum()을 이용하면 True인 갯수의 합을 리턴해 준다.

다음 장에서는, 본격적으로 신경망을 학습시키는 방법에 대해 다뤄보도록 하겠다.

Comments