밑바닥부터 시작하는 딥러닝 3장 - 신경망

shyoon·2023년 9월 30일
1

신경망


신경망이란?

인간의 두뇌에서 영감을 얻은 방식으로 데이터를 처리하도록 컴퓨터를 가르치는 인공 지능 방식


신경망의 예

간단한 신경망의 예시이다. 왼쪽 줄은 입력층, 가운데는 은닉층, 오른쪽 줄은 출력층이다. 본 교재에서는 입력층부터를 0층으로 생각하고, 은닉층을 1층, 출력층을 2층이라고 표현한다. 따라서 위 신경망은 2층 신경망이다. (문헌에 따라서 입력층을 1층으로 보는 경우도 있다.)


y={0(w1x1+w2x2+b0)1(w1x1+w2x2+b>0)y = \begin{cases} {0 (w_1x_1 + w_2x_2 + b \le 0)} \\ {1 (w_1x_1 + w_2x_2 + b > 0)} \end{cases}

이전 포스팅에서 간단한 퍼셉트론 식을 위와 같이 표현하였는데, 이를 그림으로 나타내면 아래와 같아진다.

입력층 두 변수 x1,x2x1, x2 에 각 가중치를 곱하여 더한 값에 입력값 1, 가중치 b인 bias를 함께 표현해 주었다. 이를 더 간결하게 표현해 보자.

y=h(b+w1x1+w2x2)h(x)={0   (x0)1   (x>0)y = h(b + w_1x_1 + w_2x_2) \\\\ h(x) = \begin{cases} {0 \ \ \ (x\le0)} \\ {1 \ \ \ (x>0)} \end{cases}

이와 같이 퍼셉트론 식 자체를 하나의 함수 h(x)h(x)로 묶어 표현할 수 있다.
h(x)h(x)는 입력값(퍼셉트론 식)이 0을 넘으면 1 넘지 못하면 0을 출력하는 함수가 된다.


활성화 함수

위에서 정의한 h(x)h(x) 함수가 입력 신호의 총합을 출력 신호로 변환하는 활성화 함수가 된다. 이는 입력 신호의 총합이 활성화를 일으키는지를 결정하는 역할을 한다.

앞에서 계속 0 아니면 1을 출력했던 활성화 함수는 계단 함수이다. 활성화 함수에 입력되는 값의 범위가 0보다 작으면 0이, 0보다 크거나 같으면 1이 출력되기 때문에 플롯을 그려보면 계단같은 형태가 나오기 때문에 계단 함수라고 한다.

계단 함수 구현

def step_function(x):
  if x > 0:
    return 1
  else:
    return 0

위처럼 간단하게 구현할 수도 있지만, 받아들일 수 있는 인수 xx가 실수값 하나만 받아들일 수 있기 때문에 넘파이 배열을 인수로 넣을 수 있도록 수정하는 과정을 알아보자.

import numpy as np
x = np.array([-1.0, 1.0, 2.0])

y  = x > 0
y

출력 결과

넘파이 배열에서는 비교 연산을 수행하면 결과값으로 True, False의 boolean 배열이 결과가 된다. 이 배열을 정수로 형변환을 하면 True는 1, False는 0으로 mapping된다.

y = y.astype(int)
y

출력 결과

따라서 위에서 구현했던 계단 함수를 astype() 메서드를 활용하여 다시 구현할 수 있다.

def step_function(x):
  y = x > 0
  return y.astype(np.int)
import matplotlib.pyplot as plt

def step_function(x):
  return np.array(x > 0, dtype=int)

x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축의 범위 지정
plt.show()

출력 결과

그림과 같이 계단 함수는 입력값이 0을 경계로 출력이 0, 1로 나뉜다.

시그모이드 함수

h(x)=11+exp(x)h(x) = {1 \over 1 + exp(-x)}

exp(x)exp(-x)exe^{-x}를 의미하며, ee는 자연상수 2.7182... 를 의미한다.

그동안 계단 함수를 위주로 활성화 함수를 살펴 보았지만, 시그모이드 함수도 자주 쓰이는 활성화 함수이다.

시그모이드 함수 구현

시그모이드 함수 구현은 간단하다. 아래와 같이 선언해 주면 된다.

def sigmoid(x):
  return 1 / (1 + np.exp(-x))
x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)

출력 결과

넘파이 배열은 스칼라 값과 연산을 하면 브로드캐스트를 이용하여 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행해준다. 따라서 위 코드에서 xx에 exponential 함수를 입히고, 1을 더한 값으로 1을 나누는 연산이 배열의 각 원소 별로 수행되고 결과 역시 이 원소들을 포함하는 넘파이 배열이 된다.

시그모이드 함수 그래프의 예시를 확인해 보자.

x = np.arange(-5.0, 5.0, 0.1) # -5부터 5 전까지 0.1간격의 숫자들을 원소로 갖는 배열 선언
y = sigmoid(x)

plt.plot(x, y)
plt.ylim(-0.1, 1.1)
plt.show()

출력 결과

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

같은 입력값으로 두 함수의 출력 플롯을 비교해 보자.

x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x)
plt.plot(x, y, label='stair') # 계단 함수 결과 플롯 그리기

y = sigmoid(x)
plt.plot(x, y, label='sigmoid') # 시그모이드 함수 결과 플롯 추가로 그리기

plt.ylim(-0.1, 1.1)
plt.legend()
plt.show()

출력 결과

같은 값들을 입력했을 때 두 출력 결과는 위 그림과 같은 차이를 보인다. 계단 함수보다는 시그모이드 함수의 결과가 확실히 매끄럽다. 계단 함수는 0과 1 중 하나의 값만 반환하는 반면 시그모이드는 연속적인 실수 범위에서 값을 반환한다.

공통점은 두 함수 모두 입력이 작을 땐 0에 가깝거나 0이고 입력이 클수록 1에 가깝거나 1이 되는 구조이며 출력값이 0에서 1 사이라는 점이다.

비선형 함수

활성화 함수는 '변환기' 이다. 이러한 활성화 함수로 선형 함수를 사용하면 신경망 층을 깊게 쌓는 의미가 없어진다. 책에서는 h(x)=cxh(x) = cx 라는 선형 함수를 3층 네트워크의 활성화 함수로 사용하는 예시를 들어 설명하고 있다. 이러한 네트워크 식은 y(x)=h(h(h(x)))y(x) = h(h(h(x))) 이고. h(x)h(x)에 순차적으로 cxcx를 대입해보자. 결국 결과값은 c3xc^3x와 같아지고, 이는 y=ax(a=c3)y = ax (a = c^3) 구조와 같아져서 굳이 은닉층을 포함하는 3층 네트워크로 표현할 필요가 없어진다. 따라서 활성화 함수로는 비선형 함수를 많이 사용한다.

ReLU 함수

0이 넘으면 그 입력을 그대로 출력하고, 0 이하이면 0을 출력하는 함수. 따라서 수식은 아래와 같다.

h(x)={x  (x>0)0  (x0)h(x) = \begin{cases} x \ \ (x>0) \\ 0 \ \ (x\le0) \end{cases}
def relu(x):
  return np.maximum(0, x)
x = np.arange(-5.0, 5.0, 0.1)
y = relu(x)

plt.plot(x, y)
plt.show()

출력 결과

간단한 함수이지만 책 후반부에서 활성화 함수로 주로 ReLU 함수를 사용할 것이라고 한다.


다차원 배열 계산

행렬의 곱

앞으로 신경망 식에 자주 사용할 행렬 곱은 아래와 같이 넘파이의 np.dot() 함수로 구현 가능하다.

A = np.array([[1,2,3], [4,5,6]])
B = np.array([[1,2], [3,4], [5,6]])

np.dot(A, B)

출력 결과


위의 행렬 곱 예시는 A라는 2x3 행렬과 B라는 3x2 행렬의 곱을 연산한 결과이다. 출력 행렬의 1행 1열에는 앞 행렬의 1행 값들과 뒤 행렬의 1열 값들의 원소별로 곱한 값을 더한 결과가 된다. 따라서 앞 행렬의 열과 뒤 행렬의 행 수는 같아야 한다. 그리고 결과 행렬의 행과 열은 앞 행렬의 행, 뒤 행렬의 열 수를 따라간다.

다만 주의할 점이 있다.

A = np.array([[1,2], [3,4], [5,6]])
B = np.array([7,8])
np.dot(A, B)

출력 결과

위 코드를 보니 A행렬은 3행 2열이고 B행렬은 1행 2열이라 앞 행렬의 열과 뒤 행렬의 행 수가 다른데, np.dot을 수행한 결과 왜 에러가 뜨지 않고 3행 1열의 결과가 나오는지 궁금할 수 있다.


위처럼 B를 1차원으로 배열로 선언하면, 행렬곱을 할 때 AxB 이면 B의 원소 수와 A의 열 수가 같다면 브로드캐스팅이 일어나 B행렬을 자동으로 (2,1) 행렬로 바꾸어 행렬곱 연산이 가능하게 해준다. 반대로, BxA라면 B의 원소 수와 A의 행 수가 같다면 브로드 캐스팅이 일어나 행렬곱을 가능케 해준다.

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


위처럼 X와 가중치 W행렬의 곱을 표현하는데 브로드캐스팅으로 X를 (1,2) 행렬로 변환 후 곱 연산이 시행됨을 확인할 수 있다.

3층 신경망 구현

위의 3층 신경망 구조에서 1층의 첫번째 뉴런(a1(1)a_1^{(1)})으로 가는 경우를 살펴보자.

식으로 나타내게 되면 아래와 같다.

a1(1)=w11(1)x1+w12(1)x2+b1(1)a_1^{(1)} = w_{11}^{(1)}x_1 + w_{12}^{(1)}x_2 + b_1^{(1)}

이번엔 좀 더 폭넓게 1층 전체의 a(1)a^{(1)} 결과값들을 식으로 작성해보자.

A(1)=(a1(1) a2(1) a3(1)), X=(x1,x2), B(1)=(b1(1) b2(1) b3(1)),A^{(1)} = (a_1^{(1)} \ a_2^{(1)} \ a_3^{(1)}), \ X = (x_1, x_2), \ B^{(1)} = (b_1^{(1)} \ b_2^{(1)} \ b_3^{(1)}), \\ \\ \\
W(1)={w11(1) w21(1) w31(1)w12(1) w22(1) w32(1)W^{(1)} = \begin{cases} w_{11}^{(1)} \ w_{21}^{(1)} \ w_{31}^{(1)} \\ w_{12}^{(1)} \ w_{22}^{(1)} \ w_{32}^{(1)} \end{cases}

이라고 할 때,

A(1)=XW(1)+B(1)A^{(1)} = XW^{(1)} + B^{(1)}

말로 풀어 설명하자면, XW(1)X와 W^{(1)}를 곱한 결과의 첫번째 값은 XX의 1행(x1,x2x1, x2)와 W(1)W^{(1)}의 1열(w11(1),w12(1))(w_{11}^{(1)}, w_{12}^{(1)}) 의 원소 별 곱하여 더한 값을 내뱉어 결과는
w11(1)x1+w12(1)x2w_{11}^{(1)}x_1 + w_{12}^{(1)}x_2가 된다. 이후 b1(1)b_1^{(1)}이 더해져 a1(1)a_1^{(1)}이 된다. 다른 원소들도 반복하면 A(1)A^{(1)}이 완성되고, 1층 노드 세개의 정보를 갖게된다.

이렇게 나온 AA 행렬에 추가적으로 활성화 함수까지 적용 시켜주고 그 결과를 ZZ라고 하자. 이렇게 1층이 완성되었다.


다음은 2층으로 넘어가는 과정이다.

앞의 결과로 나온 ZZ를 다시 입력층으로 사용하자. 입력층 노드가 3개이고(bb는 제외) 출력층 노드가 2개이니 가중치 행렬은 3행 2열이 돼야 할 것이다. 코드를 기준으로 서술하자면 입력층은 (3,) 행렬이고, 가중치는 (3,2) 행렬로 곱 연산 시행하면 입력층은 (1,3)으로 브로드캐스팅되어 결과는 (1,2) 행렬이 되어 두 개의 값이 나올 것이다. (a1(2),a2(2)a_1^{(2)}, a_2^{(2)}) 이후 앞 층과 같이 활성화 함수 적용하여 Z(2)Z^{(2)}를 출력해 내고 이를 다음 층으로의 입력층으로 사용한다.


마지막으로 3층(출력층)이다.

위의 과정과 똑같이 생각하면 되지만, 활성화 함수만 이전 층과 달라진다. 이를 구분짓고자 이전 층의 활성화 함수를 h(x)h(x)로 표현한 것과는 달리 출력층의 활성화 함수를 σ()\sigma()로 표현하였다. 일반적으로 회귀에는 항등 함수, 2클래스 분류에는 시그모이드 함수, 다중 클래스 함수에는 소프트맥스 함수를 사용한다.

구현

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

def identity_function(x):
  return x

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)

실행 결과

Init_network() 함수에서는 각 층의 가중치 행렬과 bias 행렬을 정의하여 딕셔너리에 저장한다.
임의로 (x1,x2)(x1, x2) 행렬을 정의하고, forward() 함수에서는 정의된 입력층, 가중치, bias 행렬을 이용하여 3층짜리 네트워크의 결과를 도출해낸다. sigmoid(), identity_function()은 앞서 정의한 시그모이드 함수와 항등 함수이고, 1, 2층에서는 시그모이드 함수를, 출력층에서는 항등 함수를 호출한 것을 확인할 수 있다.


출력층 설계

앞서 얘기했듯 회귀 문제에서는 출력층 활성화 함수로 항등함수를, 분류(다중) 에서는 소프트맥스 함수를 이용한다. 항등함수는 입력 신호를 그대로 출력 신호로 이용하는 것으로 간단하기 때문에 소프트맥스에 대해서만 자세히 알아보자.

소프트맥스 함수

yk=exp(ak)i=1nexp(ai)y_k = {{exp(a_k)} \over \sum_{i=1}^n exp(a_i)}

exp(x)exp(x)exe^x를 의미하고, nn은 출력층의 뉴런 수, yky_k는 그 중 kk번째 출력임을 뜻한다.

소프트맥스 구현

단순히 직관적인 소프트맥스 함수의 구현은 아래와 같이 가능하다.

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

하지만, 소프트맥스 함수에서는 exp()exp()라는 지수 함수가 쓰인다. 이는 xx에 10이 들어가도 20,000이 넘는 큰 수를 내뱉고, 너무 큰 값 끼리 연산 오버플로우로 인해 불안정한 값을 내뱉을 수 있다.

a = np.array([1010, 1000, 990])
np.exp(a) / np.sum(np.exp(a))

출력 결과


세 출력값 모두 NaN이 나오는 것을 확인할 수 있는데, 이는 'Not a Number'의 약자로, 직역하면 '숫자가 아니다' 라는 뜻이다. 말 그대로 컴퓨터가 연산을 수행할 수 없는 숫자이기에, 적절한 변환이 필요하다.

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() 함수 코드를 수정하면 오버플로우를 피할 수 있다. 원리는 간단하다.

소프트맥스 함수의 분모, 분자에 같은 값 C라는 값을 곱하고 loglog를 씌워 exp()exp() 안에 넣어줄 수 있다. 이는 본래 식에서 각 aia_i값에 같은 상수를 더해도 결과는 같다는 점을 말해준다. 따라서 위 코드에서는 c라는 변수를 새로 만들어 a에서 빼 준 값을 다시 a로 사용하기에 오버플로우를 피할 수 있는 것이다.


x = np.arange(-10.0, 10.0, 0.1)
y = softmax(x)

plt.plot(x, y)
plt.show()


소프트맥스 함수의 특징은 모든 원소 수를 합하면 1이 된다는 점이다. 이러한 점을 이용하여 소프트맥스 함수의 출력을 '확률'로 해석 가능하다. 조금 더 간단한 예시를 들어보자.

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

출력 결과

y[0]y[0]의 확률은 0.018, y[1]y[1]의 확률은 0.25, y[2]y[2]의 확률은 0.74으로 해석 가능하고, '2번 클래스일 확률이 가장 높으니 답은 2번 클래스다' 라고 결론을 낼 수가 있는 것이다. 따라서, 출력층 노드 수는 클래스 수와 동일하게 설정해야 한다는 특징이 있다.


손글씨 숫자 인식

본 실습에서는 학습 과정은 생략하고, 이미 학습 된 매개변수를 이용하여 추론 과정만 구현한다. 필요한 pkl파일과 dataset파일은 https://github.com/youbeebee/deeplearning_from_scratch 사이트를 참고하자.

MNIST 데이터 셋

손글씨 숫자 이미지 집합으로 ML분야에서 아주 유명하여 여러 논문에서 실험 데이터로 많이 쓰인다.
이미지 데이터는 28x28의 흑백 이미지(1채널)이며, 각 픽셀은 0~255 사이의 값을 가지고 있다. 또한 각 이미지 별로 실제 의미하는 숫자가 레이블로 붙어있다.

신경망 추론 처리

import pickle5 as pickle
import sys, os
sys.path.append('/content/drive/MyDrive/dnn_study')
from dataset.mnist import load_mnist
import numpy as np

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('/content/drive/MyDrive/dnn_study/c3_nn/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 = identity_function(a3)

  return y

get_data() :

x_train, t_train에는 훈련 이미지, 훈련 레이블을, x_test, t_test에는 시험 이미지, 시험 레이블을 가져온다. normalize는 0~255의 값을 0~1로 정규화 하고, flatten은 입력된 이미지를 평탄하게(1차원으로) 만든다. one_hot_label은 원-핫 인코딩을 수행할지를 결정한다. False로 설정하였으므로 원-핫 인코딩이 아닌 0,7같은 숫자 형태로 레이블을 저장한다.

init_network() :

sample_weight.pkl 파일의 미리 학습된 가중치 매개변수를 읽어온다.

predict() :

위에서 연습한 것과 마찬가지의 3층 신경망으로 분류 예측을 수행

%%time

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

test이미지는 총 만 장으로 이루어져 있다. 기존에 학습된 가중치 매개변수로 구축된 3층 신경망 모델로 분류작업을 실시했을 때 각 test이미지의 레이블을 맞히면 accuracy_cnt를 1 높이고, 분류가 끝난 후에는 test이미지 수인 10000으로 나누어 이를 정확도로 보여준다.

정확도가 0.9352이므로 총 만 장 중 9352장을 잘 맞혔다는 점을 확인할 수 있다.

배치 처리

위 방식에서는 한 입력에 하나의 이미지만 집어넣었다. 이러한 신경망 각 층의 배열 형상은 아래와 같이 표현 가능하다.

만약 입력 데이터를 여러 장 묶어서 입력하면 어떻게 될까? 입력 이미지를 100장씩 묶어 입력하는 경우를 표현해보자.

아래와 같이 결과는 입력한 100장 만큼의 각 10개 레이블의 확률 배열을 뱉어줄 것이다. x[0]x[0]y[0]y[0]에는 0번째 이미지의 입력과 그 추론 결과를 보여주고, x[99]x[99]y[99]y[99]에는 99번째 이미지의 입력과 그 추론 결과를 보여줄 것이다.

이렇게 하나로 묶은 입력 데이터를 배치(batch) 라고 한다. 이런 방식으로 큰 배열을 한 번에 계산하는 것이 작은 배열을 여러 번 계산하는 것보다 빠르다고 한다.

아래와 같이 구현 가능하다.

%%time

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

출력 결과

두 코드의 정확도는 같게 나왔지만, 코랩에서 %%time 을 이용하여 실제 수행 시간을 비교한 결과 배치 처리를 하지 않은 결과는 1.09s, 배치 처리를 한 결과는 229ms로 4배 이상의 차이가 났다.


profile
큰 사람이 되겠어요

0개의 댓글