딥러닝

딥러닝 기초 부수기 - 신경망(neural network)

삶과계란사이 2022. 2. 7. 23:14

딥러닝 기초 부수기 - 신경망(neural network)

본 게시글은 한빛미디어 출판사의 '밑바닥부터 시작하는 딥러닝(저자: 사이토 고키)' 도서 내용을 바탕으로 작성하였습니다.

 

1. 서론

이전 글에서 보았던 퍼셉트론은 가중치를 사람이 직접 설정해야 한다는 단점이 존재한다. 이를 해결하기 위해 가중치 매개변수의 적절한 값을 데이터로부터 자동으로 학습하는 신경망(neural network)이 등장한다. 참고로, 신경망은 다층 퍼셉트론이라고 말할 수 있다. 이번 글에서는 신경망의 개념과 처리 과정에 대해 정리해보겠다.

 

2. 퍼셉트론과 신경망

퍼셉트론에서 신호를 전달하는 방법을 복습하고 신경망과 퍼셉트론(단층 퍼셉트론)의 차이를 알아보자.

(1) 신경망(neural network)의 예

위 그림은 2층 신경망을 표현한 그림이다. 형태만 보았을 땐 퍼셉트론과 같아 보인다! 그림에서 은닉층은 사람의 눈에 보이지 않는 층이다. 일명 블랙박스라고 할 수 있다. 층수는 3층이지만, 가중치를 갖는 층은 2개이므로 '2층 신경망'이라 한다. 문헌에 따라 층수를 기준으로 '3층 신경망'이라고 하는 경우도 존재한다고 한다!

 

(2) 퍼셉트론 복습

편향(b)을 도입한 퍼셉트론 수식

위 수식에서 '0을 넘으면 1을 출력, 넘지 않으면 0을 출력'이라는 동작을 h(x)라는 하나의 함수로 표현할 수 있다!

위 수식은 입력 신호의 총합이 h(x) 함수를 통해 변환되어 y의 출력이 된다는 의미이다! 이 h(x) 함수를 앞으로 활성화 함수(activation function)라 부를 것이다.

 

(3) 활성화 함수란?

입력 신호의 총합을 출력 신호로 변환하는 함수이다. 즉, 입력 신호의 총합이 활성화를 일으키는지 결정한다.

'(2) 퍼셉트론 복습'에서 봤던 'y = h(b + w1x1 + w2x2)' 식은 2단계로 처리되어, 위 그림과 같이 식을 2개로 나눌 수 있다. 가중치가 곱해진 입력 신호의 총합을 계산하고, (1단계) 그 합을 활성화 함수에 입력해 결과를 출력한다.(2단계)

활성화 함수 처리 과정(편향의 입력 신호는 항상 1이다. 다른 뉴런과 구별하기 위해 회색으로 표시)

신경망과 퍼셉트론(단층 퍼셉트론)의 차이는 바로 이 활성화 함수이다. 둘 다 각각 다른 활성화 함수를 사용하고 있다. 밑에서 자세히 알아보자.

 

3. 활성화 함수

미리 이야기하면, 단층 퍼셉트론은 계단 함수를 활성화 함수로 사용하고 신경망은 시그모이드 함수 등을 사용한다. 여기서는 활성화 함수의 종류에 대해 알아본다.

(1) 계단 함수(step function)

임계값을 경계로 출력이 바뀌는 함수이다. 단층 퍼셉트론은 활성화 함수로 계단 함수를 사용한다. 입력 값이 0을 넘으면 1을 출력하고, 넘지 않으면 0을 출력한다.

# 계단 함수 구현

import numpy as np

def step_function(x):                      # 입력 x는 넘파이 배열
    return np.array(x > 0, dtype=np.int)   # 넘파이 배열에 부등호 연산을 수행하면 bool 배열 생성
                                           # 이 bool 배열을 int형으로 변환(True면 1, False면 0)

 

(2) 시그모이드 함수(sigmoid function)

시그모이드 함수 수식(exp는 자연상수 e를 의미)

0부터 1 사이의 실수 값을 출력하는 함수이다. 신경망에서 자주 사용하는 활성화 함수이다. 시그모이드(sigmoid)라는 이름은 'S자 모양'이라는 뜻이라고 한다!

# 시그모이드 함수 구현

import numpy as np

def sigmoid(x):   # 입력 x는 넘파이 배열
    return 1 / (1 + np.exp(-x))

 

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

공통점

  1. 입력이 중요하면 큰 값을 출력, 중요하지 않으면 작은 값을 출력(입력이 크면 출력이 1에 가까워지거나 1이 되고, 입력이 작으면 0에 가까워지거나 0이 된다!)
  2. 출력이 0에서 1 사이 값
  3. 비선형 함수

차이점

  1. 계단 함수는 구부러진 직선 모양, 시그모이드 함수는 매끄러운 곡선 모양
  2. 계단 함수는 0을 경계로 출력이 갑자기 바뀌고, 시그모이드 함수는 입력에 따라 출력이 연속적으로 변화
  3. 계단 함수는 0과 1 중 하나의 값을 출력, 시그모이드 함수는 0과 1 사이 실수를 출력

 

(4) 비선형 함수

선형 함수는 f(x) = ax + b 와 같이 입력이 상수배만큼 변하는 함수이다. 그래프로 그리면 1개의 곧은 직선이다! 비선형 함수는 선형이 아닌 함수를 의미한다. f(x) = ax^2과 같이 직선 1개로는 그릴 수 없는 함수이다! 신경망에서 활성화 함수로 비선형 함수를 사용하는 이유 선형 함수를 사용하면 신경망의 층을 아무리 깊게 해도 은닉층이 없는 네트워크로도 똑같이 표현할 수 있기 때문이다! 그렇게 되면 신경망에서 층을 깊게 하는 의미가 사라진다.

3층 신경망에서 위와 같은 선형 함수를 활성화 함수로 사용한다고 가정하자. 3층 신경망이므로 이를 식으로 나타내면

위와 같이 나타낼 수 있다. 이 계산은 y(x) = c * c * c * x 와 같은 계산이라고 할 수 있다. x에 상수 c만 3번 곱한 꼴이다. 결론적으로 위 식은 선형 함수 y(x) = ax와 같다. 은닉층을 쌓지 않은 단층 네트워크로도 표현할 수 있다는 의미이다. 층을 깊게 쌓는 신경망의 이점을 살리기 위해서는 활성화 함수로 선형 함수가 아닌 비선형 함수를 사용해야 한다!

 

(5) ReLU(Rectified Linear Unit)  함수

위에서 계단 함수와 시그모이드 함수에 대해 알아보았으니, 이번에는 최근에 주로 사용하는 활성화 함수인 ReLU 함수에 대해 살펴보겠다. 여담이지만, ReLU 함수는 이 티스토리 주인장이 가장 좋아하는 함수이다. 이름이 귀엽기 때문이라고 한다!

ReLU 함수 수식

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

# ReLU 함수 구현

import numpy as np

def relu(x):                  # 입력 x는 넘파이 배열
    return np.maximum(0, x)   # 넘파이 maximum 함수는 두 입력 중 큰 값을 반환

 

4. 신경망에서의 행렬 곱

지금까지 계속 넘파이 모듈을 불러와서 코드 구현을 했는데, 넘파이 다차원 배열 계산으로 신경망을 효율적으로 구현할 수 있기 때문이다. 신경망에서 다음 층으로 신호를 전달할 때, 입력 값과 가중치와의 행렬곱 연산이 이루어진다. 여기서는 넘파이 행렬에 대해 자세히 다루지 않을 거지만, 기초적인 행렬 연산 규칙은 설명하겠다. 추후 넘파이에 대해 자세한 글을 올리도록 하겠습니다!

편향과 활성화 함수를 생략한 간단한 신경망의 모습을 왼쪽에 표현하였다. 입력은 x1, x2이고 출력은 y1, y2, y3이다. 가중치는 1, 3, 5, 3, 4, 6이다. 왼쪽에 표현한 신경망을 넘파이 행렬 곱 식으로 나타내면 오른쪽과 같다. 여기서 입력은 1, 2로 설정했다. X, W, Y 밑에 있는 숫자는 행렬의 형상을 의미한다. 1x2 이면 행이 1개, 열이 2개인 행렬이다. 행렬 연산 규칙을 간단하게 적어보자면, 앞 행렬의 열 개수와 뒤 행렬의 행 개수가 일치할 때만 연산이 가능하다. 연산 결과 행렬의 형상은 '앞 행렬의 행 개수 x 뒤 행렬의 열 개수'이다.(ex. AxB * BxC = AxC, AxB * CxD 인 경우는 B와 C가 달라서 연산 불가능)

# 신경망에서 이루어지는 행렬 곱을 직접 코드로 살펴보자!

import numpy as np

X = np.array([1, 2])   # 입력 행렬
X.shape                # 입력 행렬의 형상 확인
(2,)   # X.shape를 출력해서 확인한 결과, 입력 행렬의 형상은 1x2이다.(1을 생략해서 표현)
W = np.array([[1, 3, 5],[2, 4, 6]])   # 가중치 행렬
print(W)                              # 가중치 행렬 출력
[[1 3 5]    # 가중치 행렬을 출력해 확인한 결과, 이런 모습을 하고 있다!
 [2 4 6]]
W.shape   # 가중치 행렬 형상 확인
(2, 3)   # 가중치 행렬 형상을 출력해 확인한 결과, 2x3 형상이다.
Y = np.dot(X, W)   # 입력 행렬과 가중치 행렬을 곱셈(넘파이의 dot 함수는 행렬 곱을 한꺼번에 계산)
print(Y)           # 결과 행렬 출력
[ 5 11 17]   # 결과 행렬의 모습. 1x3 형상이다!

 

5. 3층 신경망 구현

신경망 구현은 입력부터 출력까지의 순방향 처리를 구현하고, 출력층 부분을 설계하면 된다. 위에서 봤던 넘파이 행렬을 사용하면 간단하게 구현할 수 있다. 그전에, 신경망의 신호 전달 설명에서 쓰일 표기법을 가볍게 알아보자.

구현할 3층 신경망의 전체적인 구조(여기에 편향을 추가한다.)

(1) 표기법

가중치로 알아본 표기법

가중치의 오른쪽 위 숫자 의미는 '1층의 가중치'라는 뜻이다. 가중치의 오른쪽 아래 숫자 의미는 '앞 층의 2번째 뉴런에서 다음 층의 1번째 뉴런으로 향할 때의 가중치'라는 뜻이다. 잘 이해가 안 되면 곧 나오는 '각 층의 신호 전달 모습'을 보며 이해해보자.

 

(2) 각 층의 신호 전달 모습

입력층에서 1층으로 신호를 전달하는 모습(편향 뉴런을 추가)
a1을 수식으로 나타낸 것. 행렬곱을 이용하면 빨간 화살표 아래 식처럼 간소화 가능.
간소화한 식의 A(1), X, W(1), B(1) 행렬의 값 확인
신호를 받은 1층의 활성화 함수 처리 모습(여기서 사용한 활성화 함수는 시그모이드 함수)
1층에서 2층으로 신호를 전달하는 모습
신호를 받은 2층에서 활성화 함수로 인해 변환된 신호 z를 출력층으로 전달하는 모습

출력층에서 사용하는 활성화 함수는 문제의 성질에 맞게 사용자가 정한다. 위에서 은닉층의 활성화 함수와 구분하기 위해, 출력층의 활성화 함수는 σ()로 표시했다!

 

(3) 파이썬(Python) 코드로 구현

'(2) 각 층의 신호 전달 모습'에서 봤던 것을 파이썬 코드로 나타낸 모습이다.

# 3층 신경망 신호 전달 구현

import numpy as np

# 가중치, 편향 초기화 함수
def init_network():
    network = {}
    # 1층의 가중치, 편향
    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])
    # 2층의 가중치, 편향
    network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
    network['b2'] = np.array([0.1, 0.2])
    # 3층의 가중치, 편향
    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     # 입력층에서 1층으로 신호 전달
    z1 = sigmoid(a1)            # 활성화 함수 처리
    a2 = np.dot(z1, W2) + b2    # 1층에서 2층으로 신호 전달
    z2 = sigmoid(a2)            # 활성화 함수 처리
    a3 = np.dot(z2, W3) + b3    # 2층에서 출력층으로 신호 전달
    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] 이다!

 

6. 신경망의 출력층

기계학습 문제는 회귀(regression)분류(classification)로 나뉜다. 회귀는 입력 데이터에서 연속적인 수치를 예측하는 문제(ex. 사진 속 인물의 몸무게 예측)이고, 분류는 데이터가 어느 클래스(class)에 속하느냐는 문제(ex. 사진 속 과일의 종류 분류)이다. 출력층에서 사용하는 활성화 함수는 문제가 회귀이냐 분류이냐에 따라 달라진다. 보통 회귀 문제에서는 항등 함수(identity function)를 사용하고 분류 문제에서는 소프트맥스 함수(softmax function)를 사용한다.

(1) 항등 함수(identity function)

입력을 그대로 출력하는 함수이다. 즉, 입력과 출력이 같다! 일반적으로 회귀 문제에서 사용하는 활성화 함수이다.

항등 함수 처리 모습

# 항등 함수 구현

def identity_function(x):
    return x

 

(2) 소프트맥스 함수(softmax function)

소프트맥스 함수 수식(exp는 자연상수 e를 의미)

일반적으로 분류 문제에서 사용하는 활성화 함수이다. 출력층 뉴런 수도 분류하고 싶은 클래스 수로 설정한다. 예를 하나 들자면, 어떤 숫자 이미지를 신경망이 분류하려고 한다. 입력으로 들어오는 이미지를 0부터 9 중 하나로 분류해야 한다면 출력층 뉴런 수를 10개(0~9)로 설정한다. 위 수식에서 n은 출력층의 뉴런 수, y(아래첨자 k)는 그중 k번째 출력을 의미한다. 분자는 입력 신호 a(아래 첨자 k)의 지수 함수, 분모는 모든 입력 신호의 지수 함수의 합이다. 이것을 보고 알 수 있는 것은 소프트맥스 함수의 출력이 '확률'이라는 것이다. 또 예를 들자면, 어떤 과일 이미지를 신경망 입력으로 넣었을 때 이 이미지가 사과일 확률은 0.05, 포도일 확률은 0.15, 수박일 확률은 0.8로 나왔다면 이 신경망은 해당 이미지가 수박이라고 높게 예측한 것이다.(수박일 확률이 가장 높으니 이 이미지는 수박 class이다) 소프트맥스 함수의 출력은 0에서 1 사이의 실수이고, 출력 총합은 1이다!

소프트맥스 함수 처리 모습(출력층의 각 뉴런이 모든 입력 신호에서 영향을 받는다!)

# 소프트맥스 함수 구현

import numpy as np

def softmax(a):
    exp_a = np.exp(a)           # 입력 a를 자연상수 e의 a제곱으로 변환 후 exp_a에 저장
    sum_exp_a = np.sum(exp_a)   # 소프트맥스 수식의 분모
    y = exp_a / sum_exp_a
    
    return y

 

(3) 소프트맥스 함수 구현 이슈

방금 전 소프트맥스 함수의 코드는 수식을 제대로 표현하고 있지만, 컴퓨터로 계산했을 때 오버플로(overflow) 문제가 발생한다. 컴퓨터가 표현할 수 있는 수의 범위가 한정적이어서 너무 큰 값은 표현할 수 없을 때 발생하는 문제가 오버플로이다. 지수 함수는 쉽게 아주 큰 값을 낸다. 지수 숫자가 조금씩 커져도 결과 값은 많이 커지는 경우가 많다. 이렇게 큰 값끼리 나눗셈을 하면 결과 수치가 불안정해진다.

기존 수식에서 임의의 정수 C를 분자와 분모에 곱해 오버플로를 방지하는 수식으로 개선

위 수식처럼 개선할 수 있다. 이제, 오버플로를 막기 위해서 C'입력 신호 중 최댓값을 넣어 빼주는 방식으로 개선한다.

# 오버플로 방지 소프트맥스 함수 구현

import numpy as np

def softmax(a):
    c = np.max(a)           # 입력 값 중 가장 큰 값을 c에 저장
    exp_a = np.exp(a - c)   # 오버플로를 방지하기 위해 입력 값에서 c만큼을 빼줌
    sum_exp_a = np.sum(exp_a)
    y = exp_a / sum_exp_a
    
    return y

 

7. MNIST 손글씨 숫자 이미지 분류

이번에는 MNIST 손글씨 숫자 이미지 데이터를 분류하는 코드를 살펴보겠다. MNIST 손글씨 숫자 이미지 데이터셋은 0부터 9까지의 숫자 이미지로 이루어져 있다. 훈련 이미지(모델 학습용 이미지)는 60000장, 시험 이미지(모델 평가용 이미지)는 10000장이다. 각 이미지는 회색조(grayscale)이고, 크기는 28x28이다.

MNIST 손글씨 숫자 이미지 데이터셋 중 하나의 이미지

 

MNIST 데이터셋을 분류할 신경망은 입력층 뉴런 784개(28x28), 출력층 뉴런 10개(0~9까지의 숫자)로 구성되어있다. 은닉층은 2개인데 첫 번째 은닉층의 뉴런은 50개, 두 번째 은닉층의 뉴런은 100개이다. 이번 절에서는 데이터를 학습하는 과정이 아닌 이미 학습한 매개변수를 사용하여 분류를 진행하는 추론 과정을 살펴볼 것이다. MNIST 데이터셋을 불러오는 등의 전체 코드는 '밑바닥부터 시작하는 딥러닝' 서적의 GitHub 저장소에 있으며, 해당 코드를 직접 돌려보았다.

# MNIST 손글씨 숫자 이미지 신경망 추론 수행 코드

import numpy as np
import pickle
from mnist import load_mnist        # mnist 데이터셋을 불러오는 함수 import
from ch03 import sigmoid, softmax   # 활성화 함수로 사용할 시그모이드, 소프트맥스 함수 import


# 시험 이미지, 시험 레이블(시험 이미지의 정답) 추출 함수
def get_data():
    """
    load_mnist 함수
    이 함수는 mnist 데이터셋을 load하는 함수이다.
    
    인수는 3개
    normalize : 입력 이미지 픽셀을 0.0~1.0 사이 값으로 정규화할지 결정
    flatten : 입력 이미지를 1차원 배열로 만들지 결정
    one_hot_label : 레이블을 원-핫 인코딩 형태로 저장할지 결정
    				(원-핫 인코딩은 정답을 뜻하는 원소만 1이고 나머지는 0인 배열
                    ex. 해당 이미지의 정답이 2이면, 배열 형태는 [0,0,1,0,0,0,0,0,0,0])
                    
    (훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블) 에 저장된다.
    """
    (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:   # sample_weight.pkl 파일의 학습된 가중치 매개변수 읽기
        network = pickle.load(f)
    return network


# 신경망의 순방향 추론(위에서 봤던 3층 신경망 구현 코드와 거의 동일)
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


# main 함수
x, t = get_data()
network = init_network()
accuracy_cnt = 0          # 정확도(accuracy) 카운트 초기화
for i in range(len(x)):   # 시험 이미지 개수만큼 for문 실행
    y = predict(network, x[i])
    p = np.argmax(y)      # 확률이 가장 높은 원소의 인덱스를 얻는다.
    if p == t[i]:         # 해당 이미지의 정답과 신경망이 추론한 답이 일치하면 카운트+1
        accuracy_cnt += 1

print("Accuracy:" + str(float(accuracy_cnt) / len(x)))   # 출력 결과 정확도 : 0.9352 -> 93.52%

위 코드는 입력 이미지를 하나씩 추론한 코드이다. 정확도가 높을 수록 해당 신경망이 이미지를 올바르게 분류했다고 볼 수 있다. 이 방법도 좋지만, 배치 처리를 사용하여 추론 과정을 수행하면 결과를 훨씬 빠르게 얻을 수 있다. 하나로 묶은 입력 데이터를 배치(batch)라고 한다.

# 위 코드에서 배치 처리를 사용하여 main 함수를 바꾼 코드

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)        # axis=1 : 각 이미지에서 가장 높은 확률값을 지닌 인덱스 추출
    accuracy_cnt += np.sum(p == t[i:i+batch_size])

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

정확도 결과는 0.9352로 같다.