3장. 신경망

괴도소녀·2021년 7월 17일
0

TFMaster

목록 보기
2/9

앞선 포스팅에선 2장. 퍼셉트론에 대해 공부하였다.
앞장에서 배운 내용에 관련해서 좋은 소식/나쁜 소식이 있다.

  • 좋은 소식 : 퍼셉트론으로 복잡한 함수도 표현할 수 있다.
  • 나쁜 소식 : 가중치를 설정하는 작업(원하는 결과를 출력하도록 적절히 정하는 작업)은 여전히 사람이 수동으로 해줘야한다.

신경망은 가중치 매개변수의 적절한 값을 데이터로부터 자동으로 학습하는 성질 덕분에, 위의 나쁜 소식을 커버할 수 있다.

퍼셉트론 -> 신경망

위키백과
출처 : 위키백과

위 그림은 퍼셉트론과 특별히 달라보이지 않는다. 그럼 신경망에서는 신호를 어떻게 전달하는지 퍼셉트론을 복습하면서 알아보자

퍼셉트론 복습

그림3-2

[그림 3-2]

하지만 [그림3-2]에는 편향이 보이지 않는다. 이 그림에 편향을 추가적으로 명시해주면 [그림 3-3]과 같이 나타낼 수 있다.

[그림 3-3]

가중치가 bb이고 입력이 1인 뉴런(노드)가 추가됬다.
[그림3-3]을 식으로 나타내면 아래와 같다.

y={0(b+w1x1+w2x20)1(b+w1x1+w2x2>0)[식 3-1]y = \begin{cases} 0 & (b + w_1x_1 + w_2x_2 \leq 0) \\ 1 & (b + w_1x_1 + w_2x_2 > 0) \end{cases} \qquad\text{[식 3-1]}

bb편향을 나타내는 매개변수로 뉴런이 얼마나 쉽게 활성화되느냐를 제어한다.
w1w_1, w2w_2는 각 신호의 가중치를 나타내는 매개변수로, 각 신호에 영향력을 제어하는 역할을 한다.

[식 3-1]을 좀 더 간결하게 작성하면 아래와 같이 표현할 수 있다.

y=h(b+w1x1+w2x2)[식 3-2]y=h(b + w_1x_1 + w_2x_2) \qquad\text{[식 3-2]}
h(x)={0(x0)1(x>0)[식 3-3]h(x) = \begin{cases} 0 & (x \leq 0) \\ 1 & (x > 0) \end{cases} \qquad\text{[식 3-3]}

입력신호의 총 합이 h(x)h(x)라는 함수를 거쳐 변환되어, 변환된 값이 y의 출력이 됨을 보여주는 식이다. h(x)h(x) 함수는 입력이 0을 넘으면 1, 그렇지 않으면 0을 반환한다.
결과적으로 [식3-1]이 하는 일과 [식3-2] + [식3-3]일은 같다.

활성화 함수 등장~!!

바로 조금 전 h(x)h(x)라는 함수가 등장했다. 이와 같은 함수를 활성화 함수(activation function)라고 하며, 입력 신호의 총합을 출력신호로 변환하는 함수이다. 이름이 지칭하듯 입력신호의 총합이 활성화를 일으키는지를 정하는 역할을 한다.

[식3-2]를 다시 정리해 식을 써보자면,

a=b+w1x1+w2x2[식 3-4]a = b + w_1x_1 + w_2x_2 \qquad\text{[식 3-4]}
y=h(a)[식 3-5]y = h(a) \qquad\text{[식 3-5]}

가중치가 달린 입력 신호와 편향의 총합을 계산한 aa를 함수h(x)h(x)에 넣어 y를 출력하는 식이다.
[식3-4]와 [식3-5]를 뉴런으로 나타내보면 아래와 같다.
그림3-4

[그림3-4]

신경망 동작을 명확히 표시하고자 일반적인 뉴런과 활성화 뉴런의 노드 표시를 다르게 그린다(사실 모양은 같고 크기만 다르다).
그림3-5

[그림3-5]

NOTE! 단순 퍼셉트론은 단층 네트워크에서 계단함수를 활성화 함수로 사용하고,
다층 퍼셉트론은 신경망(여러 층으로 구성된 시그모이드 함수를 사용한 매끈~한 네트워크)을 가리킨다.

활성화 함수(activation function)

[식3-3]과 같이 임계값을 경계로 출력이 바뀌는 활성화 함수를 계단함수(Step function)라고 한다.
"퍼셉트론에서는 활성화 함수로 계단 함수를 이용한다."

시그모이드 함수

신경망에서 자주 사용하는 활성화 함수는 시그모이드 함수(sigmoid function)있다.
신경망에서는 활성화 함수로 시그모이드 함수를 이용하여 신호를 변환하고, 변환된 신호를 다음 뉴런에 전달한다. 뉴런이 여러층으로 이어지는 구조와 신호를 전달하는 방법은 기본적으로 퍼셉트론과 같다. 2장에서의 퍼셉트론과 주된 차이는 활성화 함수이다.

h(x)=11+exp(x)[식 3-6]h(x) = \begin{matrix} 1 \over 1+exp(-x) \end{matrix} \qquad\text{[식 3-6]}

exp(x)exp(-x)exe^x를 뜻한다.

시그모이드 함수 그래프

시그모이드의 기본 함수는 밑에와 같이 구현된다.

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

구현이 특별히 어렵지 않다. 넘파이 배열을 제대로 처리하는지 살펴보자

x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)
------------------------------------
array([0.26894142, 0.73105858, 0.88079708])

이렇게 넘파이 배열을 쉽게 처리해줄 수 있는 비밀은 브로드캐스팅(broadcasting)에 있다.
브로드 캐스팅 기능이란 넘파이 배열과 스칼라값의 연산을 넘파이 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행하는 기능이다.

<브로드캐스팅 간단한 복습>

t = np.array([1.0,2.0,3.0])
1.0 + t 			# array([2., 3., 4.])
1.0 / t				# array([1.        , 0.5       , 0.33333333])

시그모이드 함수를 matplotlib를 이용하여 그려보자.

import matplotlib.pylab as plt
import numpy as np

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

x = np.arange(-5.0, 5.0, 0.1)
y = sigmoid(x)

plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축 범위를 지정
plt.show()

계단 함수(Step function)

계단함수를 파이썬으로 구현해보겠다. [식3-3]과 같이 입력이 0을 넘으면 1 출력, 그 외에는 0을 출력하는 함수이다.

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

위와 같은 함수는 x가 실수만 받아들인다. 넘파이 배열을 인수로 넣을 수는 없다.
그래서 밑에와 같이 수정해보면 인자에 넘파이 배열도 받을 수 있다.

def step_function(x):
  y = x > 0						# broadcasting
  return y.astype(np.int)

다음 예제는 step_function함수를 풀어서 수행해보자.

import numpy as np

x = np.array([-1.0, 1.0, 2.0])
y = x > 0
y
------------------------------------
array([False,  True,  True])

y는 bool 데이터 타입의 배열이다. 우리가 원하는 함수는 0이나 1의 'int'형 을 출력하는 함수이다. 그래서 배열 y를 bool에서 int형으로 바꿔줘야한다. astype()메소드를 이용하여 넘파일 배열의 자료형을 변환할 수 있다.

y = y.astype(np.int)
y
------------------------------------
array([0, 1, 1])

계단 함수 그래프

앞에 정의한 계단함수(step_function)을 matplotlib를 이용해 그려보겠다.

import matplotlib.pylab as plt
import numpy as np

def step_function(x):
  return np.array(x > 0, dtype=np.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()

그림3-6

[그림 3-6]

[그림 3-6]에서 보듯 계단 함수는 0을 경계로 출력이 0에서 1(또는 1에서 0)으로 바뀐다.

Between 시그모이드 and 계단

시그모이드 함수와 계단 함수를 비교해보자.

두 함수간 가장 먼저 느껴지는 차이는 그래프의 '매끄러움'일 것이다.

<차이점>

  • 시그모이드 함수는 부드러운 곡선이며 입력에 따라 출력이 연속적으로 변하는 반면,
    계단 함수는 0을 경계롤 출력이 갑자기 바뀐다.
  • 계단 함수가 0과 1 중 하나의 값만 돌려주는 반면, 시그모이드 함수는 실수를 돌려준다는 점도 다르다.
  • 퍼셉트론에서는 뉴런 사이에 0과 1이 흘렀다면, 신경망에서는 연속적인 실수가 흐른다.

이 때, 시그모이드 함수의 매끈함이 신경망 학습에서 아주 중요한 역할을 한다.

<공통점>

  • 둘다 입력이 작을 때 출력은 0에 가깝고, 입력이 커지면 출력이 1에 가까워진다.
    입력이 아무리 작거나 커도 출력은 0 ~ 1 사이라는 것.
  • 둘 모두 비선형 함수(말그대로 선형이 아닌 함수)이다.

비선형 함수

계단 함수와 시그모이드 함수는 둘다 비선형 함수로 분류된다.
신경망에서는 활성화 함수로 비선형 함수를 사용해야 한다.
그 이유는 선형 함수를 이용하면 신경망의 층을 깊게 하는 의미가 없어지기 때문이다.

예를 들어, 선형함수 h(x)=cxh(x) = cx 를 활성화 함수로 사용한 3층 네트워크를 떠올려보자.
식으로 표현하면 y(x)=h(h(h(x)))y(x) = h(h(h(x)))가 되며, 이 계산은 y(x)=cccxy(x) = c * c * c * x처럼 곱셉을 3번 수행한다. 이는 y=axy = ax와 똑같은 식이며 a=c3a = c^3라고 표현하면 끝난다.
즉, 선형함수를 은닉층이 없는 네트워크로도 표현이 가능하며 여러 층으로 구성하는 것이 의미가 없다는 뜻이다.

ReLu (Rectified Linear Unit)

지금까지 활성화 함수로서 계단함수와 시그모이드 함수만을 소개했다.
시그모이드 함수는 신경망 분야에서 오랫동안 이용해왔으나, 최근에는 ReLu를 주로 이용하는 추세이다.

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

식으로는 아래와 같이 쓸 수 있다.

h(x)={x(x>0)0(x0)[식 3-7]h(x) = \begin{cases} x & (x > 0) \\ 0 & (x \leq 0) \end{cases} \qquad\text{[식 3-7]}

식을 함수로 구현하면 아래와 같다.

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

np.maximum은 두 입력 중 큰 값을 선택해 반환하는 함수이다.
후반부에 ReLu를 많이 사용할 예정이다.

다차원 배열의 계산

넘파이 다차원 배열을 사용하는 계산법을 숙지하면 신경망을 효율적으로 구현할 수 있다.

다차원 배열

기본은 '숫자의 집합'이며,

  • 숫자가 한 줄로 늘어선 것
  • 직사각형으로 늘어 놓은 것
  • 3차원으로 늘어 놓은 것
  • N차원으로 나열하는 것

위의 모든 것을 통틀어서 다차원 배열이라고 칭한다.

2차원 배열은 특히 행렬(matrix)라고 부른다.

예제를 보면서 익숙해지자.

import numpy as np

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

print(np.ndim(A))

print(A.shape)

print(A.shape[0])
------------------------------------
[1 2 3 4]
1
(4,)
4
  • np.ndim() : 배열의 차원수
  • shape : 배열의 형상

행렬의 곱

행렬(2차원 배열)의 곱을 구하는 방법을 알아보자.
2x2행렬의 곱셈 원리는 아래의 그림에서 볼수있다.

왼쪽 행렬의 행과 오른쪽 행렬의 열을 원소별로 곱하고 그 값들을 더해서 계산하는 것이 행렬의 곱셈이다.
파이썬 코드로 구현해보면 아래와 같다.

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

print(A.shape, B.shape)
print(np.dot(A, B))
------------------------------------
(2, 2) (2, 2)
[[19 22]
 [43 50]]

A, B는 2x2행렬이며 두 행렬의 곱은 np.dot()로 계산한다.
여기서 주의할 점은 np.dot(A, B)와 np.dot(B, A)는 다른 값이 될 수도 있다는 점이다.
행렬의 곱에서는 피연산자의 순서가 다르면 결과도 다르게 나온다.

print(np.dot(B, A))
------------------------------------
[[23 34]
 [31 46]]

행렬 A의 1번째 차원의 원소 수(열 수)와 행렬 B의 0번째 차원의 원소 수(행 수)가 같아야 한다.

A가 2차원 행렬이고 B가 1차원 배열일 때도 행렬 곱 연산이 가능하다.
'대응하는 차원의 원소 수를 일치시켜라'원칙은 똑같이 적용된다.

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

np.dot(A, B)
------------------------------------
array([23, 53, 83])

신경망 구현하기

신경망 표기법

  • ww의 (1)은 1층의 가중치, 1층의 뉴런을 뜻한다.
  • ww1212는 앞 층의 2번째 뉴런(x2x_2)에서 다음 층의 첫번째 뉴런(aa)로 향할 때의 가중치라는 뜻이다.

각 층의 신호 전달 구현하기

[그림3-17] 입력층에서 1층으로 신호 전달

[그림3-17]과 같이 편향을 뜻하는 뉴런이 추가 되었다. 위 그림을 식으로 표현하면 아래와 같다.

a1(1)=w11(1)x1+w12(1)x2+b1(1)[식 3-8]\mathrm{a}_{1}^{(1)} = \mathrm{w}_{11}^{(1)}\mathrm{x}_{1} + \mathrm{w}_{12}^{(1)}\mathrm{x}_{2} + \mathrm{b}_{1}^{(1)} \qquad\text{[식 3-8]}

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

A(1)=XW(1)+B(1)[식 3-9]\mathrm{A}^{(1)} = \mathrm{X}\mathrm{W}^{(1)}+ \mathrm{B}^{(1)} \qquad\text{[식 3-9]}

행렬들의 구성을 살펴보면 아래와 같다.

A(1)=(a1(1) a2(1) a3(1)), X=(x1 x2), B(1)=(b1(1) b2(1) b3(1))\mathrm{A}^{(1)} = (\mathrm{a}_{1}^{(1)}\ \mathrm{a}_{2}^{(1)}\ \mathrm{a}_{3}^{(1)}),\ X = (x_1\ x_2),\ B^{(1)} = (\mathrm{b}_{1}^{(1)}\ \mathrm{b}_{2}^{(1)}\ \mathrm{b}_{3}^{(1)})
W(1)=(w11(1)w21(1)w31(1)w12(1)w22(1)w32(1))W^{(1)} = \begin{pmatrix} \mathrm{w}_{11}^{(1)} & \mathrm{w}_{21}^{(1)} & \mathrm{w}_{31}^{(1)} \\ \mathrm{w}_{12}^{(1)} & \mathrm{w}_{22}^{(1)} & \mathrm{w}_{32}^{(1)} \end{pmatrix}

넘파이 다차원 배열을 이용하여 [식 3-9]를 구현해보자.
(입력 신호, 가중치, 편향은 적당히 설정)

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
A1
------------------------------------
array([0.3, 0.7, 1.1])

다른 예제로는 1층에 활성화 함수 처리를 그림으로 나타낸 것이다.

[그림3-18] 입력층에서 1층으로 신호 전달

은닉층에서의 가중치 합(가중신호 + 편향)을 aa로 표기하고 활성화 함수 h()h()로 변한된 신호를 zz로 표기한다.
앞에서 정의한 sigmoid()함수를 사용하여 코드로 구현하면 된다.

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

Z1 = sigmoid(A1)

print("A1: ", A1)
print("sigmoid(A1): ", Z1)
------------------------------------
A1:  [0.3 0.7 1.1]
sigmoid(A1):  [0.57444252 0.66818777 0.75026011]

이어서 1층 -> 2층으로 가는 과정이다.

[그림3-19] 1층에서 2층으로 신호 전달


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)

print("A2: ", A2)
print("sigmoid(A2): ", Z2)
------------------------------------
A2:  [0.51615984 1.21402696]
sigmoid(A2):  [0.62624937 0.7710107 ]

1층의 출력 z1이 2층의 입력이 된다는 점을 제외하면 조금 전의 구현과 똑같다.
위 같이 넘파이 배열을 사용하면 층 사이의 신호 전달을 손쉽게 구현할 수 있다.

마지막 2층에서 출력층으로의 신호전달이다.
출력층의 구현도 그동안의 구현과 거의 같지만, 활성화 함수만 지금까지의 은닉층과 다르다.

[그림3-20] 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) # or Y = A3

print("Y : ", Y)
------------------------------------
Y :  [0.31682708 0.69627909]

항등함수(입력을 그대로 출력하는 함수) identity_function을 정의하고, 출력층의 활성화 함수로 이용했다.
출력층의 활성화 함수를 σ()\sigma()로 표시하여 은닉층의 활성화 함수 h()h()과는 다름을 명시했다.

NOTE! 출력층의 활성화 함수는 풀고자 하는 문제의 성질에 맞게 정의한다.

  • 회귀에는 항등 함수
  • 분류에는 시그모이드 함수
  • 다중 클래스 분류에는 소프트맥스 함수

를 사용하는 것이 일반적이다.

구현정리

위의 예제들로 3층 신경망에 대한 설명은 마쳤다.
지금까지의 구현을 정리한 결과는 밑에와 같다.

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, w):
  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([0.1, 0.5])
y = forward(network, x)

print(y)
------------------------------------
[0.31234736 0.6863161 ]
  • init_function함수는 가중치와 편향을 초기화하고 딕셔너리 변수 network에 저장한다.
  • forward()함수는 입력 신호를 출력으로 변환하는 처리하는 함수
    forward라 한 것은 신호가 순방향(입력 -> 출력)으로 전달(순전파)됨을 알리기 위함이며, 뒤에서 역방향(backward, 출력 -> 입력)처리도 살펴볼 것이다.

출력층 설계

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

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

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

def identity_function(x):
  return x

분류에서 사용하는 소프트맥스 함수(softmax function)의 식은 아래와 같다.

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

위 식과 그림을 토대로 소프트맥스 함수를 구현해보자.

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

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

print(y)
------------------------------------
[0.01821127 0.24519181 0.73659691]

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

위의 소프트맥스 함수는 식을 제대로 표현하고 있지만, 컴퓨터로 계산할 대는 오버플로 문제를 고려해 줘야한다.

NOTE! 컴퓨터는 number를 4byte나 8byte와 같이 유한한 데이터로 다루기 때문에 표현할 수 있는 수의 범위가 한정적이다. 너무 큰 값은 표현할 수 없다는 문제점이 발생한다. 이것을 오버플로(overflow)라 하며 컴퓨터로 수치를 계산할 때 주의할 점이다.

오버플로 문제를 해결하도록 소프트맥스 식을 개선한 수식은 아래와 같다.

yk=exp(ak)i=1nexp(ai)=Cexp(ak)Ci=1nexp(ai)=exp(ak+logC)i=1nexp(ai+logC)=exp(ak+C)i=1nexp(ai+C)[식 3-11]\begin{matrix} y_k = \begin{matrix} \exp(a_k) \over \sum_{i=1}^n \exp(a_i) \end{matrix} &=& C \exp(a_k) \over C \sum_{i=1}^n \exp(a_i) \\ &=& \exp(a_k + \log C) \over \sum_{i=1}^n \exp(a_i + \log C) \\ &=& \exp(a_k + C^\prime) \over \sum_{i=1}^n \exp(a_i + C^\prime) \end{matrix} \qquad\text{[식 3-11]}

[식 3-11]이 말하는 것은 소프트맥스의 지수함수를 계산할 때 어떤 정수를 더해도(혹은 빼도)결과는 바뀌지 않는다는 것이다. 여기서 CC^\prime에 어떤 값을 대입해도 상관없지만, 오버플로를 막을 목적으로는 입력 신호 중 최댓값을 이용하는 것이 일반적이다. 무슨 말인지 이해가 되지 않으면 밑의 예시를 살펴보자.

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
  
a = np.array([0.3, 2.9, 4.0])
y = softmax(a)
print("y : ", y, ", sum of y : ", np.sum(y))
------------------------------------
y :  [0.01821127 0.24519181 0.73659691] , sum of y :  1.0

보는 바와 같이 소프트맥수 함수의 출력은 0 ~ 1사이의 실수이며, 소프트맥스 함수 출력의 총합은 1이다. 출력이 1이 된다는 점은 소프트맥스의 중요한 성질이다.

소프트맥스 함수의 특징

소프트맥스 함수 출력의 총합이 1이라는 성질 덕분에 출력을 '확률'로 해석할 수 있다.
y[0]의 확률은 1.8%, y[1]의 확률은 24.5%, y[2]의 확률은 73.7%로 해석할 수 있으며,
"2번째 원소의 확률이 가장 높으니, 답은 2번째 클래스다"라는 확률적인 결론을 낼 수 있다.
즉, 소프트맥스 함수를 이용해 문제를 확률적(통계적)으로 대응할 수 있게 된다.
주의점으로는 소프트맥스 함수를 적용해도 각 원소의 대소관계는 변하지 않는다. y=exp(x)y = \exp (x)가 **단조증가함수 이기 때문이다.
** 단조증가함수 : 원소 aa, bbaba \leq b 일때, f(a)f(b)f(a) \leq f(b)가 성립하는 함수.

신경망을 이용한 분류에선 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식한다. 그러므로 소프트맥스 함수를 적용해도 출력이 가장 큰 뉴런의 위치는 달라지지 않으므로, 신경망으로 분류할 때는 출력층의 소프트맥스 함수를 생략해도 된다.
현업에서도 계산에 드는 자원 낭비를 줄이려고 출력층의 소프트맥스 함수를 생략하는 것이 일반적이다.

MNIST 숫자 인식. 손글씨

MNIST는 기계학습 분야에서 아주 유명한 데이터셋으로, 간단한 실험부터 논물으로 발표되는 연구까지 다양한 곳에서 이용하고 있으며, 이미지 인식이나 기계학습 논문들을 읽다 보면 실험용 데이터로 자주 등장한다.

0~9까지의 숫자 이미지로 구성되어있으며, 학습(train) 이미지가 60,000장, 시험(test) 이미지가 10,000장으로 이루어져있다. 이미지 데이터는 28x28크기의 흑백이미지이며, 각 픽셀은 0 ~ 255까지의 값을 가지고 있으며, 각 이미지에는 그 이미지가 실제 의미하는 숫자가 레이블(label)로 붙어있다.

load_mnist 함수의 인수로는 normalize, flatten, one_hot_label 3가지를 설정할 수 있다. 세 인수 모두 bool값이다.

  • normalize : 입력 이미지의 픽셀 값을 0.0 ~ 0.1 사이의 값으로 정규화할지를 정한다.
  • flatten : 입력 이미지를 1차원 배열로 만들지를 정한다.
  • one_hot_label : 일명 원-핫 인코딩(one-hot encoding)형태로 저장할지를 정한다. 예를 들어 설명하자면, [0,0,1,0,0,0,0,0]처럼 정답을 뜻하는 원소만 1이고(hot) 나머지는 모두 0인 배열로 변환해준다.
    정규화 용어정리

NOTE! 파이썬의 pickle 이라는 라이브러리를 사용하면 프로그램에서 특정 객체를 파일로 저장할 수 있다. 저장해둔 pickle파일을 로드하면 실행 당시의 객체를 즉시 복원할 수 있다.
python pickle

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

def img_show(img):
  pil_img = Image.fromarray(np.uint8(img))
  pil_img.show()

(X_train, y_train), (X_test, y_test) = load_mnist(flatten=True, normalize=False)

img = X_train[0]  # shape : (784,)
label = y_train[0] # 5

img = img.reshape(28, 28) # 원래 이미지 모양으로 변형. 28 x 28
img_show(img)

위 코드는 MNIST를 불러와서 출력해보는 예제이다.
여기서 주의할점은 flatten=True로 설정해 읽어 들인 이미지는 1차원 넘파이 배열로 저장되어있으므로, 이미지를 표시하고 싶으면 원래 형상인 28 x 28 크기로 다시 변형해야 한다. 또한 넘파이로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환해야 하며, 이 변환은 Image.fromarray()를 사용한다.

신경망의 추론 처리

MNIST 데이터를 가지고 추론을 수행하는 신경망을 구현해 보자.
이 신경망은 입력층 뉴런을 784개, 출력층 뉴런을 10개로 구성했다.
입력층 뉴런이 784개인 이유는 이미지 크기가 28 x 28 = 784이기 때문이고, 출력층 뉴런이 10개인 이유는 0~9까지의 숫자를 구분하는 문제이기 때문이다.
은닉층은 총 2개이다. 첫번째 은닉층은 50개 뉴런을, 두번째 은닉층에는 100개의 뉴런을 배치했는데, 50과 100은 임의로 정한 값이다.

def get_data():
  (X_train, y_train), (X_test, y_test) = \
  load_mnist(flatten=True, normalize=True, one_hot_label=False)
  return X_test, y_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 함수 안에 sample_weight.pkl는 해당 파일안에는 가중치와 편향 매개변수가 딕셔너리 변수로 저장되어 있다.

x, y = get_data()
network = init_network()
accuracy_cnt = 0

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

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

MNIST 데이터셋을 얻고 네트워크를 생성한 후, for문을 돌며 x에 저장된 이미지 데이터를 1장씩 꺼내 predict()함수로 분류한다(각 레이블의 확률을 넘파이 배열로 반환한다: [0.1, 0.3, 0.2, ... , 0.4]). 그런 다음 np.argmax()로 배열에서 가장 값이 큰 원소의 인덱스를 구한다. 이것이 예측 결과이며, 신경망이 예측한 답변과 정답 레이블을 비교하여 맞힌 숫자(accuracy_cnt)를 세고, 이를 전체 이미지 숫자로 나누면 정확도(accuracy)를 구할 수 있다.

위 코드는 실행하면 "Accuracy: 0.9352"라고 나온다.
이 뜻은 올바르게 분류한 비율이 93.52%라는 뜻이다.

배치 처리

입력 데이터와 가중치 매개변수의 '형상'에 주의해서 조금 전의 구현을 다시 살펴보자.

x, _ = 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)
------------------------------------
(10000, 784)
(784,)
(784, 50)
(50, 100)
(100, 10)

원소 784개로 구성된 1차원 배열이 입력되어 마지막에는 원소가 10개인 1차원 배열이 출력된다. 이는 이미지 데이터 1장만 입력했을 때의 데이터 처리 흐름이다.

그렇다면 이미지 여러 장을 한꺼번에 입력하는 경우를 생각해보자.
이미지 100개를 묶어 predict() 함수에 한번에 넘긴다고 가정했을 때, x의 형상이 100 x 784로 바뀌어서 100장 분량의 데이터를 하나의 입력 데이터로 표현하게 된다.

출력 데이터의 형상은 100 x 10인 것을 보아, 100장 분량의 입력 데이터 결과가 한번에 출력됨을 나타낸다. (eg. x[0] -> y[0], x[5] -> y[5])
이처럼 하나로 묶은 입력 데이터를 배치(batch)라고 하며, 배치 처리는 컴퓨터로 계산할 때 처리 시간을 대폭 줄여주는 큰 이점을 준다.
배치처리를 하는 이유는 2가지가 있다.

  • 수치 계산 라이브러리 대부분이 큰 배열을 효율적으로 처리할 수 있도록 고도로 최적화 되어있기 때문이다.
  • 커다란 신경망에서는 데이터 전송이 병목으로 작용하는 경우가 자주 있다. 배치 처리를 함으로써 부하를 줄인다.

즉, 큰 배열로 이뤄진 계산을 하는데 컴퓨터에서는 분할된 작은 배열을 여러 번 계산하는 것보다 큰 배열을 한꺼번에 계산하는 것이 빠르다.

x, y = 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 == y[i:i+batch_size])    

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

위 코드처럼 데이터를 배치로 처리함으로써 효율적이고 빠르게 처리할 수 있다.

정리!!!

이번 포스팅에서 설명한 신경망은 각 층의 뉴런들이 다음 층의 뉴런으로 신호를 전달한다는 점에서 앞 장의 퍼셉트론과 같지만, 다음 뉴런으로 갈때 신호를 변화시키는 활성화 함수에서 큰 차이가 있었다.

  • 신경망 : 시그모이드 함수, ReLu
  • 퍼셉트론 : 계단 함수

<이번 포스팅에서 배운 내용>

  • 신경망에서는 활성화 함수로 시그모이드, ReLu 함수 같은 매끄럽게 변화하는 함수를 이용한다.
  • 넘파이 다차원 배열을 사용하면 신경망을 효율적으로 구현할 수 있다.
  • 기계학습 문제는 회귀와 분류로 나뉜다.
  • 출력층의 활성화 함수로는
    • 회귀 : 항등함수
    • 분류 : 소프트맥스 함수
  • 분류에서는 출력층의 뉴런 수를 분류하려는 클래스 수와 같게 설정
  • 입력 데이터를 묶은 것을 배치라 하며, 추론(predict) 처리를 배치 단위로 진행하면 결과를 훨씬 빠르게 얻을 수 있다.

0개의 댓글