이제라도 기록하기

[밑시딥1] Chapter3 신경망 본문

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

[밑시딥1] Chapter3 신경망

sssky00 2023. 8. 5. 17:21

해당 포스팅은 <밑바닥부터 시작하는 딥러닝1> 책을 기준으로 하며, 공부 기록 목적으로 작성되었습니다.

 

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


퍼셉트론은 복잡한 함수도 표현할 수 있다는 장점이 있지만, 가중치를 사람이 수동으로 설정해야 한다는 단점이 있다. 신경망은 가중치 매개변수의 적절한 값을 데이터로부터 자동으로 학습하는 능력이 있어 퍼셉트론의 단점을 해결해 줄 수 있다.

퍼셉트론에서 신경망으로

아래 그림에서 볼 수 있듯이 가장 왼쪽 줄을 입력층, 맨 오른쪽 줄을 출력층, 중간 줄을 은닉층이라고 한다. 은닉층의 뉴런은 사람 눈에 보이지 않고 '은닉' 되어 있다. 책에서는 입력층, 은닉층, 출력층을 0, 1, 2층이라고 한다.

그림 3-1

퍼셉트론 복습

그림 3-2는 두 신호를 입력받아 y를 출력하는 퍼셉트론이고, 식으로 표현하면 식 3.1이 된다.

그림 3-2
식 3.1

식에서 나온 편향, b까지 그림에 명시하녀 그림 3-3이 된다.

그림 3-3

식 3.1을 더 간결하게 작성하면 식 3.2와 식 3.3으로 표현할 수 있다. 입력 신호의 총합이 h(x)라는 함수를 거쳐 변환되어 0을 넘으면 1을 출력하고 그렇지 않으면 0을 출력한다.

식 3.2
식 3.3

활성화 함수의 등장

위 h(x)와 같이 입력 신호의 총합을 출력 신호로 변환하는 함수를 일반적으로 활성화 함수라고 한다. 활성화 함수는 입력 신호의 총합이 활성화를 일으키는지 정하는 역할을 한다.

 

식 3.2는 식 3.4와 식 3.5로 나눌 수 있다. 식 3.4는 가중치가 달린 입력 신호의 편향의 총합을 계산하고, 이를 a라고 한다. 그리고 식 3.5에서는 a를 함수 h()에 넣어 y를 출력하는 흐름이다.

식 3.4
식3.5

위 두 식을 그림으로 나타내면 아래 그림이 된다. 가중치 신호를 조합한 결과가 a라는 노드가 되고, 활성화 함수 h()를 통과하여 y라는 노드로 변환되는 과정이 그려져 있다. 

그림 3-4

 

활성화 함수

식 3.3과 같은 활성화 함수는 임계값을 경계로 출력이 바뀌는데, 이런 함수를 계단 함수라 한다. 활성화 함수 종류는 여러 가지가 있는데 퍼셉트론에서는 활성화 함수로 계단 함수를 이용하는 것이다. 

시그모이드 함수

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

식 3.6

계단 함수 구현하기

계단 함수는 입력이 0을 넘으면 1을 출력하고, 그 외에는 0을 출력하는 함수로 아래와 같이 파이썬으로 구현 가능하다. 

def step_function(x): # 이때 x는 실수만 받아들임
    if x > 0:
        return 1
    else:
        return 0

위에서 인수 x는 실수(부동소수점)만 받아들인다. 넘파이 배열을 인수로 넣으려면 다음과 같이 수정할 수 있다.

def step_function2(x):
    y = x > 0 
    return y.astype(np.int)

배열 x의 원소 각각이 0보다 크면 True로, 0 이하면 False로 변환한 새로운 배열 y가 생성된다. 그 y 배열은 bool 배열이기 때문에 0이나 1로 출력하기 위해서 int형으로 바꿔준다.

계단 함수의 그래프

matplotlib 라이브러리를 통해 계산함수를 그리면 아래 그림과 같이 나타난다. 계단처럼 생겨 계단 함수라 불리는 것이다.

# 계단함수 그래프
import matplotlib.pyplot as plt

def step_function3(x):
    return np.array(x > 0, dtype=np.int) # x가 0보다 큰 지 확인 후, bool --> int

x = np.arange(-5.0, 5.0, 0.1)
y = step_function3(x)
plt.figure(figsize=(4,4))
plt.plot(x, y)
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.figure(figsize=(4,4))
plt.plot(x, y)
plt.ylim(-0.1, 1.1)
plt.show()

시그모이드 함수 그래프

시그모이드 함수와 계단 함수 비교

시그모이드 함수와 계단 함수를 비교하면 다음과 같다. 

그림 3-8

두 함수의 가장 큰 차이점은 매끄러움이다. 시그모이드 함수는 부드러운 곡선이며, 입력에 따라 출력이 연속적으로 변화하는 반면, 계단 함수는 0을 경계로 출력이 갑자기 바뀐다. 시그모이드 함수의 매끈함이 신경망 학습에서 아주 중요한 역할을 하게 된다. 또한, 계단함수는 0과 1 중 하나의 값만 돌려주지만 시그모이드 함수는 실수를 반환한다. 따라서, 신경망에서는 연속적인 실수가 흐른다. 

공통점을 살펴보면, 두 함수는 큰 관점에서 보면 입력이 작을 때의 출력은 0에 가깝고 입력이 커지면 출력이 1에 가까워지는, 같은 모양을 하고 있다. 입력에 무슨 수이든 출력은 0에서 1 사이이다.

비선형 함수

선형 함수는 '변환기'에 무언가 입력했을 때 출력이 입력의 상수배만큼 변하는 함수로 1개의 직선으로 표현할 수 있는 함수이고, 비선형 함수는 직선 1개로는 그릴 수 없는 함수를 말한다. 

계단 함수와 시그모이드의 또 다른 공통점은 비선형 함수라는 것이다. 

 

신경망에서는 활성화 함수로 비선형 함수를 사용해야 한다. 왜냐하면 선형 함수를 이용하면 신경망의 층을 쌓는 의미가 없어지기 때문이다. 선형 함수는 층을 아무리 깊게 해도 은닉층이 없는 네트워크로도 같은 기능을 할 수 있는 것에 문제가 있다. 

ReLU 함수

신경망 분야에서 활성화 함수로 최근에는 ReLU 함수를 주로 이용한다. ReLU 함수는 입력이 0이 넘으면 입력을 그대로 출력하고, 0 이하이면 0을 출력하는 함수이다. 

그림 3-9
식 3.7

ReLU 함수는 아래처럼 두 입력 중 큰 값을 선택해 반환하는 넘파이의 maximum 함수를 사용할 수 있다.

def relu(x):
    return np.maximum(0, x)

다차원 배열의 계산

넘파이의 다차원 배열을 사용하면 신경망을 효율적으로 구현할 수 있다.

다차원 배열

숫자가 한 줄로 늘어선 것이나 직사각형으로 늘어놓은 것, 3차원으로 늘어놓은 것이나 (더 일반화한) N차원으로 나열하는 것을 통틀어 다차원 배열이라고 한다. 

배열의 차원 수는 np.dim() 함수로 확인 가능하며, 배열의 형상은 인스턴스 변수인 shape으로 알 수 있다.

2차원 배열은 행렬이라고 부르고 배열의 가로 방향을 행(row), 세로 방향을 열(column)이라고 한다.

그림 3-10

행렬의 곱

행렬의 곱을 구하는 방법은 다음과 같다. 왼쪽 행렬의 행과 오른쪽 행렬의 열을 원소별로 곱하고 그 값들을 더해서 계산한다.

그림 3-10

두 행렬의 곱은 넘파이 함수 np.dot()으로 계산한다. np.dot()은 입력이 1차원 배열이면 벡터를, 2차원 배열이면 행렬 곱을 계산한다.

A = np.array([[1,2], [3,4]])
print(A.shape)

B = np.array([[5,6], [7,8]])
print(B.shape)

np.dot(A, B) # 행렬의 곱

행렬의 곱에서는 피연산자의 순서가 다르면 결과도 다르게 나타나며, 행렬 A의 1번째 차원의 원소 수(열 수)와 행렬 B의 0번째 차원의 원소 수(행 수)가 같아야 계산이 가능하다.

신경망에서의 행렬 곱

 

그림 3-14

다음은 편향과 활성화 함수를 생략한 간단한 신경망을 파이썬으로 구현한 코드이다.

X = np.array([1,2])
print(X.shape)

W = np.array([[1,3,5], [2,4,6]])
print(W)
print(W.shape)

Y = np.dot(X, W)
print(Y)

3층 신경망 구현하기

그림 3-15

표기법 설명

가중치를 봤을 때 오른쪽 위의 (1)는 몇 층인지를 의미하고, 가중치의 오른쪽 아래의 두 숫자는 차례로 다음 층 뉴런과 앞 층 뉴런의 인덱스 번호이다.

그림에 표기되어 있는 가중치는 1층의 가중치이며, 앞 층의 2번째 뉴런에 곱해져서 다음 층의 1번째 뉴런으로 간다는 뜻이다.

그림 3-16

각 층의 신호 전달 구현하기

다음 그림에는 편향 b 뉴런이 추가되어 있다. 편향에는 앞층의 편향 뉴런이 하나밖에 없기 때문에(인덱스를 표기할 필요가 없어서) 다음 층 뉴런을 뜻하는 인덱스 하나만 표기되어 있는 것을 확인할 수 있다.

그림 3-17

a(1)1을 수식으로 나타내면 다음과 같다.

식 3.8

여기에서 행렬의 곱을 이용하면 1층의 가중치 부분을 다음 식처럼 간소화할 수 있다.

식 3.9

여기서 활성화 함수를 추가해서 그리면 다음과 같다. 은닉층에서 가중치의 합을 a로 표기하고 활성화 함수 h()로 변환된 신호를 z로 표기한다.

그림 3-18

 활성화 함수로 시그모이드 함수를 사용하기로 하고 파이썬으로 구현하면 다음과 같다.

# 신경망 행렬로 구성
X = np.array([1.0, 0.5])
W1 = np.array([[1.0, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])

print(W1.shape)
print(X.shape)
print(B1.shape)

A1 = np.dot(X, W1) + B1  
Z1 = sigmoid(A1) # 시그모이드 함수는 앞에서 정의

print(A1)
print(Z1)

 

다음은 1층에서 2층으로 가는 과정을 그림으로 나타낸 것이다.

그림 3-19

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

print(Z1.shape)
print(W2.shape)
print(B2.shape)

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

1층의 출력 Z1이 2층의 입력이 된다는 점을 제외하면 이전 구현과 똑같다.

 

다음은 2층에서 출력층으로 가는 과정이다.

그림 3-20

여기서는 항등 함수인 identity_function()을 정의하고 출력층의 활성화 함수로 항등 함수를 사용하였다. 위 그림에서는 출력층의 활성화 함수를 시그마로 표시하여 은닉층의 활성화 함수 h와는 다름은 명시하였다.

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)

구현 정리

지금까지의 구현을 정리하면 다음과 같다. 

init_network() 함수에서는 가중치와 편향을 초기화하고 이들을 딕셔너리 변수인 network에 저장한다. forward() 함수는 입력 신호를 출력으로 변환하는 처리 과정을 모두 구현하고 있다. (신호가 순방향으로 전달되기 때문에(순전파) forward로 함수 이름을 정한 것이다.)

# 위 내용 구현 정리

def init_network():
    network = {}
    network['W1'] = np.array([[1.0, 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)

출력층 설계하기

신경망은 분류와 회귀 모두에 이용 가능하다. 일반적으로 회귀에는 항등 함수를, 분류에는 소프트맥스 함수를 사용한다.

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

항등 함수는 입력을 그대로 출력하는 함수이다.

그림 3-21

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

식 3.10

n은 출력층의 뉴런 수, yk는 그중 k번째 출력임을 뜻한다. 소프트맥스의 출력은 모든 입력 신호로부터 화살표를 받는다.

그림 3-22
def softmax(a):
    exp_a = np.exp(a)
    sum_exp_a = np.sum(exp_a)
    y = exp_a / sum_exp_a

    return y

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

앞에서 구현한 softmax() 함수의 코드에서는 오버플로우 문제가 발생할 수 있다. 소프트맥스 함수는 쉽게 큰 값을 내뱉는 지수 함수를 사용하는데, 이렇게 큰 값끼리 나눗셈을 하면 결과 수치가 불안정해진다.

식 3.11

C`에는 어떤 값을 대입해도 상관없지만, 오버플로우를 막을 목적으로는 입력 신호 중 최댓값을 이용하는 것이 일반적이다.

이를 바탕으로 소프트맥스 함수를 다시 구현하면 아래와 같다.

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

소프트맥스 함수의 특징

첫 번째로, 소프트맥스 함수의 출력은 0에서 1.0 사이이며, 소프트맥스 함수 출력의 총합은 1이다. 출력 총합이 1이 된다는 점은 소프트맥스 함수의 아주 중요한 성질인데 그 이유는 함수의 출력을 '확률'로 해석이 가능하기 때문이다. 

두 번째로, 소프트맥스의 함수를 적용해도 각 원수의 대소 관계는 변하지 않는다. 이는 지수 함수가 단조 증가 함수이기 때문이다. 

신경망을 이용한 분류에서는 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식한다.  결과적으로 신경망으로 분류할 때는 출력층의 소프트맥스 함수를 생략하여도 된다. 실제 현업에서도 자원 낭비를 줄이고자 출력층 소프트맥스 함수는 생략하는 것이 일반적이다. 

출력층의 뉴런 수 정하기

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


손글씨 숫자 인식 - 예제

MNIST 데이터셋

MNIST 데이터셋

MNIST 데이터셋은 28*28 크기의 회색조 이미지(1채널) 7만 장으로 이루어져 있다. 0부터 9까지의 숫자 이미지로 구성되어 있고, 훈련 이미지가 6만 장, 시험 이미지가 1만 장 준비되어 있다. 각 픽셀은 0부터 255까지의 값을 취하고 이미지가 실체 의미하는 숫자가 레이블로 붙어 있다.

책에서 제공하는 mnist.py의 load_mnist() 함수를 이용해서 MNIST 데이터를 불러올 수 있다.

 

load_mnist() 함수는 읽은 MNIST 데이터를 (훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블) 형식으로 반환한다. 인수로는 아래 세 가지를 설정할 수 있다.

normalize는 입력 이미지의 픽셀 값을 0.0~1.0 사이의 값으로 정규화할지를 정한다.

flatten은 입력 이미지는 1차원 배열로 만들지를 정한다.

one_hot_label은 레이블을 원-핫 인코딩 형태로 저장할지를 정한다.

import sys, os
sys.path.append("./dataset")
from dataset.mnist import load_mnist

# flatten: 1차원 넘파이 배열로 저장
(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True, normalize = False)

print(x_train.shape)
print(t_train.shape)
print(x_test.shape)
print(t_test.shape)

다음 코드는 데이터셋 중 하나의 이미지를 보여주는 내용을 담고 있다.

x_train: 이미지 훈련 데이터

t_train: x_train의 라벨

x_test: 이미지 테스트 데이터

t_test: x_test의 라벨

import sys, os
sys.path.append("./dataset")
import numpy as np
import pickle
from dataset.mnist import load_mnist
import matplotlib.pylab as plt
from PIL import Image

(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True, normalize = False)

def img_show(img):
    pil_img = Image.fromarray(np.uint8(img)) # 넘파이로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환
    pil_img.show()
    
img = x_train[0]
label = t_train[0] # 훈련 레이블
print(label)

print(img.shape)
# 이미지를 표시하기 위해서 flatten=True로 인한 1차원 넘파이 배열을 다시 28*28 크기로 변형
img = img.reshape(28, 28) 
print(img.shape)

img_show(img)

위 코드에서 이미지 출력 결과로는 5가 나온다.

신경망의 추론 처리

아래는 MNIST 데이터셋을 가지고 추론을 수행하는 신경망을 구현한 코드로 이 신경망은 입력층 뉴런을 784개, 출력층 뉴런을 10개로 구성한다. 입력층 뉴런이 784개인 이유는 이미지 크기가 28*28=784개 이기 때문이고, 출력층 뉴런이 10개인 이유는 이 문제가 0에서 9까지의 숫자를 구분하는 문제이기 때문이다.

 

가중치와 편향 매개변수는 init_network에서 읽는 sample_weight.pkl 파일에 딕셔너리 변수로 저장되어 있다.

import sys, os
sys.path.append("./dataset")
import numpy as np
import pickle
from dataset.mnist import load_mnist
import matplotlib.pylab as plt

def get_data():
    (x_train, t_train), (x_test, t_test) = load_mnist(flatten=True, normalize = False, 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 sigmoid(x):
    return 1/(1 + np.exp(-x))
 
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
    
 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
 
 x, t = 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)

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

배치 처리

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)

다차원 배열의 대응하는 차원의 원소 수가 일치함을 확인할 수 있다. 그림으로 나타내면 그림 3-26과 같다.

그림 3-26

이미지 여러 장을 한꺼번에 입력하는 경우는 x를 100*784로 바꿔서 100장 분량의 데이터를 하나의 입력 데이터로 표현하면 된다. 출력 데이터의 형상은 100*10이 된다. x[0], y[0]에는 0번째 이미지와 추론 결과가, x[1], y[1]에는 1번째의 이미지와 그 결과가 저장된다.

그림 3-27

이렇게 하나로 묶은 입력 데이터를 배치라고 한다. 배치 처리를 하면 처리 시간을 줄이고, 버스에 주는 부하를 줄일 수 있다는 이점이 있다. 구현은 다음과 같다.

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

batch_size = 100 # 배치 크기
accuracy_cnt = 0

# range(start, end, step) start에서 end-1까지 step 간격으로 증가하는 정수 반환
for i in range(0, len(x), batch_size): 
    x_batch = x[i:i+batch_size]
    y_batch = predict(network, x_batch)
    # 100*10 배열 중 1번째 차원을 구성하는 각 원소에서 최댓값의 인덱스를 찾음
    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)))

정리

  • 신경망에서는 활성화 함수로 시그모이드 함수와 ReLU 함수 같은 매끄럽게 변화하는 함수를 이용한다.
  • 기계 학습 문제는 크게 회귀와 분류로 나눌 수 있다.
  • 출력층의 활성화 함수로는 회귀에서는 주로 항등 함수를, 분류에서는 주로 소프트맥스 함수를 이용한다. 
  • 입력 데이터를 묶은 것을 배치라 하며, 추론 처리를 이 배치 단위로 진행하면 결과를 훨씬 빠르게 얻을 수 있다.