Skip to content

Latest commit

 

History

History
311 lines (231 loc) · 15.6 KB

220204.md

File metadata and controls

311 lines (231 loc) · 15.6 KB

Day 112

Deep Learning from Scratch

3. 신경망

출력층 설계

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

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

항등 함수는 입력을 그대로 출력한다. 그래서 출력층에서 항등 함수를 사용하면 입력 신호가 그대로 출력 신호가 된다.

소프트맥스 함수(softmax function)의 식은 다음과 같다.

e 3 10

n은 출력층의 뉴런 수, yk는 그중 k번째 출력임을 뜻한다. 소프트맥스 함수의 분자는 입력신호 ak의 지수함수, 분모는 모든 입력 신호의 지수함수의 합으로 구성된다.

그림으로 나타내면 다음과 같다.

fig 3-22

소프트맥스의 출력은 모든 입력 신호로부터 화살표를 받는다. 이는 식의 분모에서 보듯, 출력층의 각 뉴런이 모든 입력 신호에서 영향을 받기 때문이다.

파이썬으로 구현

import numpy as np

a = np.array([0.3, 2.9, 4.0])
exp_a = np.exp(a)           # 지수 함수
sum_exp_a = np.sum(exp_a)   # 지수 함수의 합
y = exp_a / sum_exp_a
print(exp_a)        # [ 1.34985881 18.17414537 54.59815003]
print(sum_exp_a)    # 74.1221542101633
print(y)            # [0.01821127 0.24519181 0.73659691]

함수로 표현

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

소프트맥스 함수 구현 시 주의점

소프트맥스 함수는 지수 함수를 사용하는데, 지수 함수는 쉽게 아주 큰 값을 만들어 내어 오버플로가 문제가 된다. 결과적으로 결과 수치가 불안정해진다.

개선한 수식은 다음과 같다.

e 3 11

C라는 임의의 정수를 분자와 분모 양쪽에 곱한다(양쪽에 같은 수를 곱했으므로 결국엔 같은 계산). 그 다음에는 C를 exp() 안으로 옮겨 logC로 만들고 logC를 C'라는 새로운 기호로 바꾼 것이다.

이것이 말하는 것은 소프트맥스의 지수 함수를 계산할 때 어떤 정수를 더하거나 빼도 결과는 바뀌지 않는다는 것이다. 여기서 C'에는 오버플로를 막을 목적으로 입력 신호 중 최댓값을 이용하는 것이 일반적이다.

예시

a = np.array([1010, 1000, 990])
np.exp(a) / np.sum(np.exp(a)) # array([nan, nan, nan]), 제대로 계산되지 않았다.
c = np.max(a)
a - c # array([  0, -10, -20])
np.exp(a - c) / np.sum(np.exp(a - c)) # array([9.99954600e-01, 4.53978686e-05, 2.06106005e-09])

개선한 함수

def softmax(a):
    c = np.max(a)
    exp_a = np.exp(a - c) # 오버플로 대책
    sum_exp_a = np.sum(exp_a)
    y = exp_a / sum_exp_a
    
    return y

소프트맥스 함수의 특징

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

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

소프트맥스 함수의 출력은 0에서 1.0 사이의 실수이고, 출력의 총합은 1이다. 이 출력의 총합이 1이라는 성질을 통해 소프트맥스 함수의 출력을 확률로 해석할 수 있다.

소프트맥스 함수를 이용함으로써 문제를 확률적으로 대응할 수 있게 된다.

주의점은 소프트맥스 함수를 적용해도 각 원소의 대소 관계는 변하지 않는다. 이는 지수 함수 y = exp(x)가 단조 증가 함수이기 때문이다.

단조 증가 함수란 정의역 원소 a, b가 a <= b 일 때, f(a) <= f(b)가 성립하는 함수를 말한다.

이를 예로 들면 a의 가장 큰 원소가 첫 번째 원소라면 y에서 가장 큰 원소도 첫 번째 원소다.

신경망을 이용한 분류에서는 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식한다. 또한 소프트맥스 함수를 적용해도 출력이 가장 큰 뉴런의 위치는 달라지지 않아 신경망으로 분류할 때는 출력층의 소프트맥스 함수를 생략해도 된다.

출력층의 뉴런 수 정하기

출력층의 뉴런 수는 풀려는 문제에 맞게 적절히 정해야 한다. 분류에서는 분류하고 싶은 클래스 수로 설정하는 것이 일반적이다. 입력된 이미지를 0 ~ 9까지 중 하나로 분류하는 것이라면 10개가 된다.

손글씨 숫자 인식

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

MNIST 데이터셋

MNIST는 손글씨 숫자 이미지 집합이다.

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

MNIST 데이터셋을 내려받아 이미지를 넘파이 배열로 변환해주는 스크립트를 책에서 제공하고 있어서 사용한다. 링크

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,)

해당 스크립트의 load_mnist만 가져와서 보겠다.

def load_mnist(normalize=True, flatten=True, one_hot_label=False):
    """MNIST 데이터셋 읽기
    
    Parameters
    ----------
    normalize : 이미지의 픽셀 값을 0.0~1.0 사이의 값으로 정규화할지 정한다.
    one_hot_label : 
        one_hot_label이 True면、레이블을 원-핫(one-hot) 배열로 돌려준다.
        one-hot 배열은 예를 들어 [0,0,1,0,0,0,0,0,0,0]처럼 한 원소만 1인 배열이다.
    flatten : 입력 이미지를 1차원 배열로 만들지를 정한다. 
    
    Returns
    -------
    (훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블)
    """
    if not os.path.exists(save_file):
        init_mnist()
        
    with open(save_file, 'rb') as f:
        dataset = pickle.load(f)
    
    if normalize:
        for key in ('train_img', 'test_img'):
            dataset[key] = dataset[key].astype(np.float32)
            dataset[key] /= 255.0
            
    if one_hot_label:
        dataset['train_label'] = _change_one_hot_label(dataset['train_label'])
        dataset['test_label'] = _change_one_hot_label(dataset['test_label'])    
    
    if not flatten:
        for key in ('train_img', 'test_img'):
            dataset[key] = dataset[key].reshape(-1, 1, 28, 28)

    return (dataset['train_img'], dataset['train_label']), (dataset['test_img'], dataset['test_label']) 

load_mnist 함수는 읽은 MNIST 데이터를 "(훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블)" 형식으로 반환한다. 인수로는 normalize, flatten, one_hot_label 세 가지를 설정할 수 있고, 모두 bool 값이다.

normalize는 입력 이미지의 픽셀 값을 0.01.0 사이의 값으로 정규화할지를 정한다. False로 설정하면 입력 이미지의 픽셀은 원래 값 그대로 0255 사이의 값을 유지한다.

flatten은 입력 이미지를 평탄하게, 즉 1차원 배열로 만들지를 정한다. False로 설정하면 입력 이미지를 1 x 28 x 28의 3차원 배열로, True로 설정하면 784개의 원소로 이워진 1차원 배열로 저장한다.

one_hot_label은 레이블을 원-핫 인코딩(one-hot encoding) 형태로 저장할지를 정한다. 원-핫 인코딩이란 [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]처럼 정답을 뜻하는 원소만 1이고 나머지는 모두 0인 배열이다. one_hot_label이 False면 '7'이나 '2'와 같이 숫자 형태의 레이블을 저장하고, True일 때는 레이블을 원-핫 인코딩하여 저장한다.

MNIST 이미지 확인

import numpy as np
from dataset.mnist import load_mnist
from PIL import Image

def img_show(img):
    pil_img = Image.fromarray(np.uint8(img))
    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) # 5

print(img.shape) # (784,)
img = img.reshape(28, 28)
print(img.shape) # (28, 28)

img_show(img)

mnist_test

flatten을 True로 설정해 읽은 이미지는 1차원 넘파이 배열로 저장되어 있기 때문에 이미지를 표시할 때는 원래 형상인 28 x 28 크기로 다시 변형해야 한다.

reshape() 메서드에 원하는 형상을 인수로 지정하면 넘파이 배열의 형상을 바꿀 수 있다.

또한, 넘파이로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환해야 하는데 이는 Image.fromarray()가 수행한다.

신경망의 추론 처리

MNIST 데이터셋을 가지고 추론을 수행하는 신경망을 구현한다.

신경망은 입력층 뉴런을 784개, 출력층 뉴런을 10개로 구성한다. 입력층 뉴런이 784개인 이유는 이미지 크기가 28 x 28 = 784이기 때문이고, 출력층 뉴런이 10개인 이유는 0~9까지의 숫자를 구분하는 문제이기 때문이다.

은닉층은 총 두 개, 첫 번째 은닉층에는 50개의 뉴런을, 두 번째 은닉층에는 100개의 뉴런을 배치한다. 50과 100은 임의로 정한 값이다.

파이썬에는 pickle이라는 기능이 있는데, 이는 프로그램 실행 중에 특정 객체를 파일로 저장한는 기능이다. 저장해둔 pickle 파일을 로드하면 실행 당시의 객체를 즉시 복원할 수 있다.

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에 저장된 '학습된 가중치 매개변수'를 읽는다. 이 파일에는 가중치와 편향 매개변수가 딕셔너리 변수로 저장되어 있다. sample_weight.pkl도 책에서 제공한다.

import pickle

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))) # Accuracy: 0.9352
  1. MNIST 데이터셋을 얻고 네트워크를 생성한다.
  2. for 문을 돌며 x에 저장된 이미지 데이터를 1장씩 꺼내 predict() 함수로 분류한다.
  3. predict()함수는 각 레이블의 확률을 넘파이 배열로 반환한다. [0.1, 0.3, ....] 같은 배열이 반환되는데, 이는 이미지가 숫자 0일 확률은 0.1, 1일 확률은 0.3 ... 이런식으로 해석한다.
  4. np.argmax() 함수로 이 배열에서 값이 가장 큰(확률이 가장 높은) 원소의 인덱스를 구한다. 이것이 예측 결과가 된다.
  5. 신경망이 예측한 답변과 정답 레이블을 비교하여 맞힌 숫자를 세고, 이를 전체 이미지 숫자로 나눠 정확도를 구한다.

데이터를 특정 범위로 변환하는 처리를 정규화(normalization)라 하고, 신경망의 입력 데이터에 특정 변환을 가하는 것을 전처리(pre-processing)라 한다.

배치 처리

앞에서 구현한 신경망 각 층의 가중치 형상 출력

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

다차원 배열의 대응하는 차원의 원소 수가 일치하는 것을 확인할 수 있다.

신경망 각 층의 배열 형상의 추이는 다음과 같다.

fig 3-26

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

이미지 여러 장을 한꺼번에 입력할 때는 다르다. 예를 들어 이미지 100개를 predict() 함수에 한 번에 넘길 때가 그렇다. 이 때는 x의 형상을 100 x 784로 바꿔서 100장 분량의 데이터를 하나의 입력 데이터로 표현하면 된다.

fig 3-27

결과적으로 입력 데이터의 형상은 100 x 784, 출력 데이터의 형상은 100 x 10이 된다. x[0]와 y[0]에는 0번째 이미지와 그 추론 결과가, x[1]과 y[1]에는 1번째 이미지와 그 결과가 저장되는 식이다.

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

배치 처리의 이점

  • 수치 계산 라이브러리 대부분이 큰 배열을 효율적으로 처리할 수 있도록 고도로 최적화되어 있다.
  • 느린 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):
    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)))

range() 함수는 range(start, end, step)으로 인수를 3개 지정하면 start에서 end-1까지 step 간격으로 증가하는 정수를 반환하는 반복자를 돌려준다.

x[i:i+batch_size]에서 입력 데이터를 묶는다. 입력 데이터의 i번째부터 i+batch_size번째까지의 데이터를 묶게된다. 여기서는 batch_size가 100이므로 [0:100], [100:200] ... 으로 100장씩 묶인다.

argmax()는 최댓값의 인덱스를 가져오는데 전과는 다르게 axis=1이라는 인수를 추가했다. 이는 100 x 10의 배열 중 1번째 차원을 구성하는 각 원소에서(1번째 차원을 축으로) 최댓값의 인덱스를 찾도록 한 것이다.(0번째 차원이 가장 처음 차원).

# 예시
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) # [1 2 1 0]

마지막으로 배치 단위로 분류한 결과를 실제 답과 비교한다. 이를 위해 ==을 사용해 넘파이 배열끼리 비교하여 True/False로 구성된 bool 배열을 만들고, True가 몇 개인지 센다.