퍼셉트론에서 신경망으로
신경망을 그림으로 나타내면 위의 그림처럼 된다. 가장 왼쪽 줄을 입력층, 맨 오른쪽 줄을 출력층, 중간 줄은 은닉층이라고 부른다. 입력층과 출력층과 달리 은닉층의 뉴런은 사람 눈에 보이지 않는다. 0층을 입력층, 1층을 은닉층, 2층을 출력층으로 볼 수 있다.
활성화 함수의 등장
\[y \ = \ h(b\ +\ w_1x_1\ +\ w_2x_2)\] \[h(x) = \begin{cases} \ 0 & (\ x \leq \ 0) \\ \ 1 & (\ x \gt \ 0) \end{cases}\]퍼셉트론의 식을 다시 정의해보면, 위의 식처럼 정의할 수 있다. h(x)라는 함수가 등장했는데, 이처럼 입력 신호의 총합을 출력 신호로 변환하는 함수를 일반적으로 활성화 함수(activation fuction)이라 부른다. 활성화 함수는 입력 신호의 총합이 활성화를 일으키는지를 정하는 역할을 한다.
\[a\ =\ b\ + \ w_1x_1\ +\ w_2x_2\] \[y \ =\ h(a)\]a는 가중치가 달린 입력 신호와 편향의 총합을 계산한 것이고, a를 함수 h()에 넣어 y를 출력하는 흐름이다.
그림으로 그려보면 위의 그림처럼 보일 것이다. 가중치 신호를 조합한 결과가 a라는 노드가 되고, 활성화 함수 h()를 통과하여 y라는 노드로 변환되는 과정이다.
활성화 함수
활성화 함수는 임계값을 경계로 출력이 바뀌는데, 이런 함수를 계단 함수(step function)이라 한다. 즉, 활성화 함수로 쓸 수 있는 여러 후보 중에서 퍼셉트론은 계단 함수를 채용하고 있다.
- 그렇다면 계단 함수 이외의 함수를 사용할 수 있는가?
- 활성화 함수를 계단 함수에서 다른 함수로 변경하는 것이 신경망의 세계로 나아가는 열쇠
시그모이드 함수
\[h(x) \ = \ \frac{1}{1\ +\ e^{-x}}\]다음은 신경망에서 자주 이용하는 활성화 함수인 시그모이드 함수(sigmoid function)를 나타낸 식이다. 예를 들어 시그모이드 함수에 1.0과 2.0을 입력하면 h(1.0) = 0.731…, h(2.0) = 0.880.. 처럼 특정 값을 출력한다. 신경망에서는 활성화 함수로 시그모이드 함수를 이용하여 신호를 변환하고, 그 변환된 신호를 다음 뉴런에 전달한다.
계단 함수 및 시그모이드 함수 구현해보기
계단 함수 구현해보기
계단 함수는 입력이 0을 넘으면 1을 출력하고, 그 외에는 0을 출력하는 함수이다. 다음은 계단 함수를 구현한 것이다.
1
2
3
4
5
def step_function(x):
if x > 0:
return 1
else:
return 0
이 구현은 단순하고 쉽지만, 인수 x는 실수(부동소수점)만 받아들인다. 즉, step_function(3.0)은 되지만 넘파이 배열을 인수로 넣을 수 없다. 앞으로를 위해 넘파이 배열도 지원하도록 수정하려 한다. 그러기 위해 다음과 같은 구현을 생각할 수 있다.
1
2
3
def step_function(x):
y = x > 0
return y.astype(np.int)
이 코드는 예시를 들어 보면 쉽게 이해할 수 있다.
1
2
3
4
5
6
7
8
9
import numpy as np
x = np.array([-1.0, 1.0, 2.0])
x
# output: array([-1., 1., 2.])
y = x > 0
y
# output: array([False, True, True], dtype=bool)
넘파이 배열에 부등호 연산을 수행하면 배열의 원소 각각에 부등호 연산을 수행한 bool 배열이 생성된다. 위의 코드에서는 x가 0보다 크면 True, 0이하면 False로 변환한 새로운 배열 y가 생성된다.
y는 bool 배열이다. 하지만 우리가 원하는 계단 함수는 0이나 1의 ‘int형’을 출력하는 함수이기때문에 astype을 이용한다
1
2
3
y = y.astype(np.int)
y
# output: array([0, 1, 1])
위의 코드처럼 넘파이 배열의 자료형을 변환할 때는 astype() 메서드를 이용한다.
계단 함수의 그래프
1
2
3
4
5
6
7
8
9
10
11
import numpy as np
import matplotlib.pylab 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(또는 1에서 0)로 바뀐다. 위의 그림처럼 바뀌는 형태가 계단처럼 생겨서 계단 함수로 불린다.
시그모이드 함수 구현해보기
시그모이드 함수는 다음과 같이 구현 할 수 있다.
1
2
3
4
5
6
def sigmoid(x):
return 1 / (1 + np.exp(-x))
x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)
# output: array([ 0.26894142, 0.73105858, 0.88079708])
이 함수가 넘파이 배열을 훌륭히 처리해줄 수 있는 비밀은 너머파이의 브로드캐스트에 있다. 브로드캐스트 기능이란 넘파이 배열과 스칼라값의 연산을 넘파이 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행하는 것이다.
시그모이드 함수의 그래프
그래프를 그리는 코드는 계단 함수 그래프 그리기 코드와 거의 같다. 다른 부분은 y를 출력하는 함수를 sigmoid 함수로 변경한 곳이다.
1
2
3
4
5
6
7
8
9
10
11
import numpy as np
import matplotlib.pylab as plt
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()
계단 함수 그래프와 비교해봤을 때, 가장 먼저 느껴지는 차이점은 ‘매끄러움’일 것이다. 시그모이드 함수는 부드러운 곡선이며 입력에 따라 출력이 연속적으로 변화한다. 한편, 계단함수는 0을 경계로 출력이 갑자기 바뀌어 버린다. 시그모이드 함수의 이 매끈함이 신경망 학습에서 아주 중요한 역할을 하게된다.
계단 함수가 0과 1 중 하나의 값만 돌려주는 반면 시그모이드 함수는 실수를 돌려준다는 점도 차이점이다. 다시 말해 퍼셉트론에서는 뉴런 사이에 0 혹은 1이 흘렀다면, 신경망에서는 연속적인 실수가 흐른다.
공통점을 보면, 큰 관점에서 보면 계단 함수와 시그모이드 함수는 같은 모양을 하고 있다. 둘 다 입력이 작을 때의 출력은 0에 가깝고, 입력이 커지면 출력이 1에 가까워지는 구조인 것이다. 즉, 둘은 입력이 중요하면 큰 값을 출력하고 입력이 중요하지 않으면 작은 값을 출력한다. 그리고 입력이 아무리 작거나 커도 출력은 0에서 1 사이라는 것도 둘의 공통점이다.
비선형 함수
계단 함수와 시그모이드 함수의 공통점은 그 밖에도 있다. 중요한 공통점으로, 둘 모두는 비선형 함수이다. 시그모이드 함수는 곡선, 계단 함수는 계단처럼 구부러진 직선으로 나타나며, 동시에 비선형 함수로 분리된다.
신경망에서 활성화 함수로 선형함수를 사용해도 될까?
- 신경망에서는 활성화 함수로 비선형 함수를 사용해야한다. 달리 말하면 선형 함수를 사용해선 안된다. 그 이유는 선형 함수를 이용하면 신경망의 층(layer)를 깊게 하는 의미가 없어지기 때문이다.
선형 함수는 층을 아무리 깊게 해도 ‘은닉층이 없는 네트워크’로도 똑같은 기능을 할 수 있다는 데 있다. 예를 들어 선형 함수인 $h(x) \ = \ cx$를 활성화 함수로 사용한 3층 네트워크를 떠올리면 알 수 있다. 이를 식으로 나타내면 $y(x) \ =\ h(h(h(x)))$가 된다. 이 계산은 $y(x) \ =\ ccc*x$처럼 곱셈을 세 번 수행하지만, 실은 $y(x)\ =\ ax$와 똑같은 식이다. $a\ =\ c^3$이라고만 하면 끝이기 때문이다. 즉, 은닉층이 없는 네트워크로 표현할 수 있다.
이 예시 처럼 선형 함수를 이용해서는 여러 층으로 구성하는 이점을 살릴 수 없다. 그래서 층을 쌓는 혜택을 얻고 싶다면 활성화 함수로는 반드시 비선형 함수를 사용해야 한다.
ReLU 함수
지금까지 계단 함수와, 시그모이드 함수를 알아봤다. 시그모이드 함수는 신경망 분야에서 오래전부터 이용해왔으나, 최근에는 ReLU(Reectified Linear Unit) 함수를 주로 이용한다.
ReLU는 입력이 0을 넘으면 그 입력을 그대로 출력하고, 0 이하이면 0을 출력하는 함수이다.
수식으로는 밑의 식처럼 쓸 수 있다.
\[h(x) = \begin{cases} \ x & (\ x \gt \ 0) \\ \ 0 & (\ x \le \ 0) \end{cases}\]1
2
def relu(x):
return np.maximum(0, x)
3층 신경망 구현해보기
그림의 신경망은 입력층(0층)은 2개, 첫 번째 은닉층(1층)은 3개, 두 번째 은닉층(2층)은 2개, 출력층(3층)은 2개의 뉴런으로 구성된다.
표기법 설명
그림과 같은 표기법을 사용한다. 가중치와 은닉층 뉴런의 오른쪽 위에는 ‘(1)’이 붙어 있다. 이는 1층의 가중치, 1층의 뉴런임을 뜻하는 번호이다. 또, 가중치의 오른쪽 아래의 두 숫자는 차례로 다음 층 뉴런과 앞 층 뉴런의 인덱스 번호이다.
각 층의 신호 전달 구현하기
위의 그림과 같이 편향을 뜻하는 뉴런이 추가되었다. 편향은 앞 층의 평향 뉴런이 하나뿐이기 때문에 편향 오른쪽 아래 인덱스가 하나밖에 없다.
$a_1^{(1)}$울 수식으로 나타내보면, $a_1^{(1)}$은 가중치를 곱한 신호 두 개와 편향을 합해서 다음과 같이 계산한다.
\[a_1^{(1)} \ = \ w_{11}^{(1)}x_1 \ + \ w_{12}^{(1)}x_2 \ + b_1^{(1)}\]여기에서 행렬의 곱을 이용하면 1층의 ‘가중치 부분’을 다음 식처럼 간소화할 수 있다.
\[A^{(1)} \ = \ XW^{(1)} \ +\ B^{(1)}\]이때 행렬 $A^{(1)},\ X,\ B^{(1)},\ W^{(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)} = \left( \begin{array}{ccc} w_{11}^{(1)} & w_{21}^{(1)} & w_{31}^{(1)} \\ w_{12}^{(1)} & w_{22}^{(1)} & w_{32}^{(1)} \end{array} \right)\]1
2
3
4
5
6
7
8
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])
print(W1.shape) # (2, 3)
print(X.shape) # (2, )
print(B1.shape) # (3,)
A1 = np.dot(X, W1) + B1
이 계산은 앞 절에서 한 계산과 같다. 이어서 1층의 활성화 함수에서의 처리를 살펴보자.
위의 그림과 같이 은닉층에서 가중치 합(가중 신호와 편향의 총합)을 $\ a$로 표기하고 활성화 함수 h()로 변환된 신호를 z로 표기한다. 여기에서 활성화 함수로 시그모이드 함수를 사용한다. 이를 코드로 구현해보면
1
2
3
4
Z1 = sigmoid(A1)
print(A1) # [0.3, 0.7, 1.1]
print(Z1) # [0.57444252, 0.66818777, 0.75026011]
이 sigmoid()는 앞서 정의한 함수이다. 이어서 1층에서 2층으로 가는 과정을 살펴보자.
1
2
3
4
5
6
7
8
9
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) # (3,)
print(W2.shape) # (3, 2)
print(B2.shape) # (2,)
A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid(A2)
이 구현은 1층의 출력 Z1이 2층의 입력이 된다는 점을 제외하면 조금 전의 구현과 똑같다. 마지막으로 2층에서 출력층으로의 신호 전달을 살펴보자. 지금까지의 구현과 거의 유사하고, 활성화 함수만 지금까지의 은닉층과 다르다.
1
2
3
4
5
6
7
8
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) # 혹은 Y = A3
여기에서 항등 함수인 identity_function()을 정의하고, 이를 출력층의 활성화 함수로 이용했다.
구현 정리
지금까지의 구현을 코드로 정리해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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) # [ 0.31682708, 0.69627909]
여기에서는 init_network()와 foward()라는 함수를 정의했다. init_network() 함수는 가중치와 편향을 초기화하고 이들을 딕셔너리 변수인 network에 저장한다. foward()함수는 입력 신호를 출력으로 변환하는 처리 과정을 모두 구현하고 있다.
출력층 설계하기
신경망은 분류와 회귀 모두에 이용할 수 있다. 다만 둘 중 어떤 문제냐에 따라 출력층에서 사용하는 활성화 함수가 달라진다. 일반적으로 회귀에서는 항등 함수를, 분류에는 소프트맥스 함수를 사용한다.
항등 함수와 소프트맥스 함수 구현하기
항등 함수(identity function)
항등 함수(identity function)는 입력 그대로 출력한다. 입력과 출력이 항상 같다는 뜻의 항등이다. 항등 함수에 의한 변환은 은닉층에서의 활성화 함수와 마찬가지로 화살표로 그린다.
소프트맥스 함수(softmax function)
분류에서 사용하는 소프트맥스 함수(softmax function)의 식은 다음과 같다.
\[y_k \ = \frac{exp(a_k)}{\sum_{i=1}^{n} exp(a_i)}\]위의 식과 같이 소프트맥스 함수의 분자는 입력 신호 $a_k$의 지수 함수, 분모는 모든 입력 신호의 지수 함수의 합으로 구성된다.
이 소프트 맥스 함수를 그림으로 나타내면 밑의 그림처럼 나타난다. 그림과 같이 소프트 맥스의 출력은 출력층의 각 뉴런이 모든 입력 신호에서 양향을 받기 때문에, 모든 입력 신호로부터 화살표를 받는다.
소프트맥스 함수 코드로 구현해보기
1
2
3
4
5
6
7
8
9
10
a = np.array([0.3, 2.9, 4.0])
exp_a = np.exp(a)
print(exp_a) # [ 1.34985881 18.17414537 54.59815003]
sum_exp_a = np.sum(exp_a)
print(sum_exp_a) # 74.1221542102
y = exp_a / sum_exp_a
print(y) # [ 0.01821127 0.24519181 0.73659691]
파이썬 코드로 구현해보면
1
2
3
4
5
6
def softmax(a):
exp_a = np.exp(a)
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y
소프트맥스 함수 구현 시 주의점
앞에서 구현한 softmax() 함수의 코드는 제대로 표현하고 있지만, 컴퓨터로 계산할 때는 결함이 있다. 바로 overflow 문제이다. 소프트맥스 함수는 지수 함수를 사용하는데, 지수 함수는 아주 큰 값을 내뱉는다. 따라서 무한대를 뜻하는 inf를 반환하는 경우가 있다. 그리고 이런 큰 값끼리 나눗셈을 하면 결과 수치가 ‘불안정’해진다.
이 문제를 해결하도록 소프트맥스 함수 구현을 개선해보면, 다음과 같은 수식을 쓸 수 있다.
\[y_k = \frac{\exp(a_k)}{\sum_{i=1}^{n} \exp(a_i)} = \frac{C \exp(a_k)}{C \sum_{i=1}^{n} \exp(a_i)} \\ = \frac{\exp(a_k + \log C)}{\sum_{i=1}^{n} \exp(a_i + \log C)} \\ = \frac{\exp(a_k + C')}{\sum_{i=1}^{n} \exp(a_i + C')}\]식을 살펴보면, 첫 번째 변형에서는 C라는 임의의 정수를 분자와 분모에 양쪽에 곱했다(양쪽에 같은 수를 곱했으닌 결국 똑같은 계산이다). 그 다음으로 C를 지수함수 exp()안으로 옮겨 logC로 만든다. 마지막으로 logC를 $C’$라는 새로운 기호로 바꾼다.
위의 식이 말하는 것은 소프트 맥스의 지수 함수를 계산할 때 어떤 정수를 더하거나 빼도 결과는 바뀌지 않는다는 것이다. 여기서 C’에 어떤 값을 대입해도 상관없지만, overflow를 막을 목적으로는 입력 신호 중 최댓값을 이용하는 것이 일반적이다.
1
2
3
4
5
6
7
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함수를 다시 구현하면 위의 코드와 같다.
소프트맥스 함수의 특징
softmax() 함수를 사용하면 신경망 출력은 다음과 같이 계산할 수 있다.
1
2
3
4
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이 된다는 점은 소프트 맥스 함수의 중요한 성질이다. 이 성질 덕분에 소프트 맥스 함수의 출력을 ‘확률’로 해석할 수 있다.
손글씨 예제 생략..
배치 처리
위의 그림을 전체적으로 보면 원소 784개로 구성된 1차원 배열(원래는 28*28인 2차원 배열)이 입력되어 마지막에는 원소가 10개인 1차원 배열이 출력되는 흐름이다. 이는 이미지 데이터를 1장만 입력했을 때의 처리 흐름이다.
그렇다면 이미지 여러 장을 한꺼번에 입력하는 경우를 생각해보자. 예를 들어, 이미지 100개를 묶어 predict() 함수에 한 번에 넘기는 것이다. x의 형상을 100*784로 바꿔서 100장 분량의 데이터를 하나의 입력 데이터로 표현하면 될 것이다.
그림과 같이 입력 데이터의 형상은 100784, 출력 데이터의 형상은 10010이 된다. 이는 100장 분량 입력 데이터의 결과가 한 번에 출력됨을 나타낸다. 이처럼 하나로 묶은 입력 데이터를 배치(batch)라 한다. 배치가 곧 묶음이란 의미다.
정리
- 신경망에서는 활성화 함수로 시그모이드 함수와 ReLU함수 같은 매끄럽게 변화하는 함수를 이용한다.
- 넘파이의 다차원 배열을 잘 사용하면 신경망을 효율적으로 구현할 수 있다.
- 기계학습 문제는 크게 회귀와 분류로 나눌 수 있다.
- 출력층의 활성화 함수로는 회귀에서는 주로 항등 함수를, 분류에서는 주로 소프트맥스 함수를 이용한다.
- 분류에서는 출력층의 뉴런 수를 분류하려는 클래스 수와 같게 설정한다.
- 입력 데이터를 묶은 것을 배치라 하며, 추론 처리를 이 배치 단위로 진행하면 결과를 훨씬 빠르게 얻을 수 있다.